Status: Proposed (2026-05-11). Supersedes the dashboard portions of
spec.md: §0.5 (dashboard scaffold), §1.6 (dashboard live
action feed + PCA chain inspector), and the UI portions of §2.3 (block-queue
- justified-override). The proxy-side endpoints those steps already delivered remain — only the React/Next.js dashboard is dropped.
Authority: This file is the spec for the customer-facing surfaces of
Proxilion. Anything not covered here (PIC chain shape, OAuth interception,
read-filter rules, policy engine internals) is unchanged from spec.md.
Proxilion's customer is a security team that already owns:
- A metrics stack (Grafana / Datadog / Honeycomb / New Relic) for graphs.
- A ticketing or chat tool (Slack / Teams / PagerDuty / Jira) for human decisions.
- A SIEM or data lake (Splunk / Elastic / Snowflake / BigQuery) for audit retention and queryable forensics.
- A terminal, with
jq, for ad-hoc grep.
Building a Proxilion-specific React dashboard means:
- Re-implementing each of those surfaces, badly, in one tab they have to remember to open. Diminishing returns.
- Owning a long maintenance tail (auth, RBAC, accessibility, browser support, screenshot rot, Next.js upgrades) that doesn't move the product's outcome forward.
- Creating a second source of truth for the audit log. The audit log is the customer's data — it belongs in their lake, indexed by their tools, not behind a self-hosted React app.
The outcome customers buy is "managed AI agents you don't own are constrained to act only inside human authority, and you can prove it forever." Achieving that outcome requires:
- Telemetry so people can see what the system is doing →
Prometheus
/metrics, scraped by their stack. - Control so people can change policy, switch enforcement modes,
search history, download evidence →
proxilion-cli+ HTTP API + NDJSON streaming exports. - Human-in-the-loop for the blocked-action approval flow → Slack / email / generic webhook, with signed one-click approve / reject links.
Three surfaces. None of them is a dashboard.
┌──────────────────────────────────────────────┐
│ Customer's existing tooling │
│ │
│ Grafana ── pulls ── /metrics │
│ Slack ── pushes / pulls ── /api/v1/blocked│
│ SIEM ── pulls / pushes ── /api/v1/actions │
│ Terminal ── proxilion-cli ── any of the above │
└──────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ Proxilion proxy │
│ │
│ /metrics (Prometheus exposition) │
│ /api/v1/... (audit + control HTTP) │
│ /api/v1/actions/stream (SSE NDJSON) │
│ Slack/webhook OUT (block notifications) │
└──────────────────────────────────────────────┘
| Surface | What it gives | Where it lives | Who consumes it |
|---|---|---|---|
| A. Telemetry | Counters, gauges, histograms covering every PIC decision, OAuth step, adapter call, policy hit, block, approval | GET /metrics (Prometheus exposition format) |
Customer's Grafana / Datadog / etc. — they draw the graphs they care about |
| B. Control + audit | Policy CRUD; mode toggle (observe / enforce); log search; log download (JSON / NDJSON / CSV); live tail; killswitch; manual block override | proxilion-cli (thin wrapper over the existing HTTP API) |
Terminal, scripts, CI, runbooks |
| C. Human-in-the-loop | "This action was blocked, approve or reject?" interactive messages with signed one-click links | Slack (interactive blocks) / email (signed mailto) / generic webhook (any other tool) | Whoever the customer designates: secops on-call, the user's manager, a shared channel |
Everything else — the metrics dashboard layout, the alert routing, the long-term audit retention — happens in the customer's own tools, against feeds Proxilion exposes.
The biggest operational complaint about preventative security tooling is "it blocks me from doing my job." The mitigation is to let customers roll out policies in observe mode first, watch the metric, then promote to enforce once the false-positive rate is acceptable.
Every Layer-B policy and the system-wide PIC enforcement layer carry a
mode field with three possible values:
| Mode | Decision recorded | Action taken | Use case |
|---|---|---|---|
observe |
yes | request proceeds untouched | Roll-out, tuning, baseline |
enforce |
yes | matched action is blocked / quarantined / require-confirmation | Production |
disabled |
no | nothing — policy is not evaluated | Emergency disable; otherwise prefer observe |
PIC invariant violations (chain breakage, ops monotonicity break) have
their own runtime-gate setting (spec.md §1.4 / §2.4) which mirrors this
two-mode model: pic_invariants.mode: observe | enforce. In observe
mode a tampered PCA is logged and metric'd but the request still flows.
config/policy.yaml example:
defaults:
# Applied to any policy that doesn't set mode explicitly.
mode: observe # safe default — flip to enforce after baseline
pic_invariants:
mode: enforce # crypto invariants always enforce by default;
# observe is a deliberate exception for migrations
policies:
- id: drive-injection-filter
mode: enforce
match:
vendor: google
action: drive.files.get
then:
read_filter:
patterns: [...]
- id: gmail-external-recipient
mode: observe # not yet enforcing — collecting baseline
match:
vendor: google
action: gmail.messages.send
body.to_domain:
not_in: ["${customer_domain}"]
then:
decision: block
reason: "external recipient"The proxy watches the policy file (inotify on Linux, kqueue on macOS,
fall back to 5s polling everywhere else) and reloads on change. A reload
is atomic: the new policy set is parsed and validated before it replaces
the live set. Validation failure leaves the previous set running and
emits proxilion_policy_reload_failures_total{reason}. Successful reloads
emit proxilion_policy_reload_success_total and a structured log line
with the diff (added / removed / modified policy ids).
proxilion-cli policy set-mode gmail-external-recipient enforce
proxilion-cli policy set-mode gmail-external-recipient observe
proxilion-cli policy list --mode=observe # show what's still in dry-run
proxilion-cli policy diff main feature/new-rules # YAML diff helperThe CLI edits the YAML file in place (preserving comments via yq semantics
or a hand-rolled CST-preserving editor — see §11) and triggers the same
hot-reload path as a manual edit.
Status (2026-05-11) — observe mode + hot reload shipped. Delivered:
- crates/policy-engine/src/yaml.rs — new top-level
mode: enforce | observe | disabledfield onPolicyDoc(defaultenforce). Thepic_invariants.modeknob the spec sketches at the file level isn't a YAML field; PIC enforcement remains controlled by the existing per-policypic_mode: audit | runtime-gate, which already mirrors the observe/enforce shape for Layer A. - crates/policy-engine/src/rego.rs —
Engine::evaluatenow demotesBlock/RequireConfirmation/RateLimittoDecision::Allowin observe mode and surfaces anobserve_would_have: Option<String>("observe_block" / "observe_require_confirmation" / "observe_rate_limit") on theOutcome.disabledpolicies are skipped entirely without consuming the match slot, so a later policy can still fire. - crates/proxy/src/adapters/google_{drive,gmail,calendar}.rs — all three adapters now record the would-have label on the
action_events.decisioncolumn and emitproxilion_observe_would_have_blocked_total{policy_id,reason}per match. - crates/proxy/src/policy_handle.rs —
PolicyHandlewrapsArcSwap<Engine>for lock-free atomic swaps. Three reload paths: API (POST /api/v1/policy/reload), file watcher background task (5s mtime poll, ui-less-surfaces.md §2.3 fallback semantics), and in-memory mode flip (POST /api/v1/policy/{id}/mode, round-trips the mutated YAML back into the cached buffer). Parse failures leave the previous engine live — load-bearing for production safety, verified end-to-end below. - crates/proxy/src/api/policy.rs — three new endpoints:
GET /api/v1/policy(lists{id, vendor, action, mode, pic_mode}for every loaded policy plussourcepath),POST /api/v1/policy/reload,POST /api/v1/policy/{id}/mode(body{"mode":"enforce|observe|disabled"}). 400 on unknown mode, 404 on unknown policy, 409 on reload-after-parse-failure. - Metrics:
proxilion_policy_reload_success_total,proxilion_policy_reload_failures_total{reason="io_error|parse_error|no_source"},proxilion_observe_would_have_blocked_total{policy_id,reason}.
Unit tests (4 new in policy-engine/tests/observe_mode.rs, 5 new in policy_handle::tests): observe demotes block→allow with label, enforce passes decision through, disabled skips evaluation, default mode is enforce; swap atomicity, bad YAML keeps previous engine, set_mode round-trips through the YAML cache, set_mode 404 on unknown id, reload-without-source returns error. cargo test --workspace is green at 84 passing (was 74).
End-to-end verification (2026-05-11) via scripts/stress-observe-reload.sh against the live compose stack:
GET /api/v1/policyreturns all 5 policies with current modes + source path.POST /api/v1/policy/drive-injection-filter/mode {"mode":"observe"}round-trips: subsequentGETshowsmode: observe.- Invalid mode → 400; unknown policy → 404.
- File watcher: append a 6th policy on disk → watcher detects within 3s (well under the 12s budget),
GETreflects 6 policies. - Write garbage YAML to disk → watcher reads it, fails to parse, leaves the previous 6-policy engine live;
proxilion_policy_reload_failures_total{reason="parse_error"}=1ticks. - Restore file → watcher reloads cleanly,
proxilion_policy_reload_success_totalticks.
Spec deviations to flag.
- No
pic_invariants.modetop-level field. §2.2 sketches adefaults: { pic_invariants: { mode: enforce } }block. PIC invariant enforcement is already per-policy via the existingpic_mode: audit | runtime-gate, which gives the same observability story without adding a defaults pre-processor. If a customer asks for global PIC observe-mode (e.g. for a migration), it's a one-linefor_each_policyover the loaded set. CST-preserving editor not used. §11.1 flagsResolved 2026-05-12 via the line-oriented edit inyqvs hand-rolled CST. We useserde_yamlround-trip onset_mode— comments and key ordering ARE NOT preserved across writes. The mutation lives in memory; the file watcher picks up the operator's manual edits independently. A CST-preserving editor is a follow-up when a customer reports a real comment-mangling regression.policy_handle::edit_mode_in_yaml; comments + ordering survive byte-for-byte. See §11.1 for the full rationale. The legacyserde_yamlround-trip still backs an exotic-YAML fallback path; both are validated by parsing into anEnginebefore the atomic swap.Resolved 2026-05-12.policy simulatenot yet shipped.crates/cli/src/main.rs::cmd_policy_simulateimplementsproxilion-cli policy simulate <file> --against last-7d. The flow: parse the candidate YAML into apolicy_engine::Engine, page through/api/v1/actions(200-line limit, followsnext_beforecursors), rehydrate aRequestContextper historical row fromvendor,action,p_0, and the body-shape fields the adapters write intoaction_events.extra(to_domain/to_domains/external_recipientfor Gmail;attendee_domains/external_attendeefor Calendar;request_path_paramsfor Drive), then aggregate per-policy_idcounts:was_blocked(history),now_blocked(candidate),would_now_block/would_now_allow(deltas). Output:--format pretty(aligned columns + max-pct summary) or--format json.--fail-if-delta-exceeds 5.0exits 1 when any policy's delta-percentage of total replayed events exceeds the threshold, suitable for CI gates. 4 unit tests cover the window parser (last-7d/last-15m/last-30s/ RFC 3339 / unknown unit).
Deviations. (a) Reads/writes whose body fields aren't inextra(default-deny privacy posture per §6.4) are replayed with empty body context — match expressions againstbody.*will not fire, undercounting deltas for policies that gate on body shape. The customer can opt their policies intoaudit_body: fullfor a richer simulation pass. (b) Groups are unknown post-hoc (we don't persist them on the action_events row), sogroups-based match expressions are skipped. (c) Replay is single-shot; no cron-friendly "watch this trend over weeks" surface — that's a daily-cron-running-this-CLI ergonomic, not a missing primitive.
In observe mode, the decision pipeline runs identically:
- Layer-B policy evaluates → would-have-blocked / would-have-rate-limited / etc.
- PIC required-ops template computed → would-have-been-rejected by Trust Plane.
- The
action_eventsrow is persisted withdecision = "observe_$X"where$Xis the would-have decision (observe_block,observe_require_confirmation,observe_rate_limit). - The request continues to the upstream as if nothing happened.
proxilion_observe_would_have_blocked_total{policy_id,reason}ticks.
This is what lets a customer roll out a new policy, leave it in observe
for a week, and graph would_have_blocked_total by policy id — if the
shape looks safe, promote to enforce. If not, refine the policy and
re-baseline.
GET /metrics on the same port as the rest of the proxy (8443 by default).
Returns Prometheus exposition format. No auth in v1 — same trust boundary
as /api/v1/* (assume operator network is private; revisit when the proxy
is exposed to a multi-tenant ingress).
Metrics are exposed in the Prometheus text format on /metrics (scrape it
directly, or point an OTLP collector's Prometheus receiver at it). A native
OTLP push exporter is planned, not yet implemented — there is no
PROXILION_METRICS_EXPORTER env var today.
Every name is proxilion_<subsystem>_<thing>_<unit> (Prometheus naming
convention). Labels are kept low-cardinality on purpose: vendor, action
(verb only, never the URL path), decision, mode, policy_id,
reason_code. Never label by p_0, pca_id, request_id,
session_id, or anything user-shaped — those go in logs / action_events,
not metrics.
# OAuth interception
proxilion_oauth_authorize_total{result="ok|denied|error"}
proxilion_oauth_callback_total{idp,result}
proxilion_oauth_token_refreshes_total{vendor,result}
proxilion_oauth_active_sessions{} # gauge
# PIC / Trust Plane
proxilion_pca_issue_total{result,hop_class} # hop_class = "0|1|2|n"
proxilion_pca_cache_hits_total{}
proxilion_pca_cache_misses_total{reason}
proxilion_pca_verify_total{result="intact|broken"}
proxilion_pca_verify_duration_seconds{} # histogram, le buckets per spec.md §1.5
proxilion_pic_invariant_violations_total{kind="continuity|monotonicity|p0|hop|signature"}
# Adapter calls (one bucket per vendor.action)
proxilion_adapter_requests_total{vendor,action,decision,mode}
proxilion_adapter_request_duration_seconds{vendor,action} # histogram
proxilion_adapter_upstream_errors_total{vendor,action,kind="timeout|5xx|network"}
# Policy engine
proxilion_policy_evaluations_total{policy_id,result="match|nomatch|error"}
proxilion_policy_evaluation_duration_seconds{} # histogram, p99<1ms budget
proxilion_policy_reload_success_total{}
proxilion_policy_reload_failures_total{reason}
# Read-filter
proxilion_readfilter_scans_total{vendor,action,result="clean|stripped|quarantined"}
proxilion_readfilter_quarantined_bytes_total{vendor}
# Block + override
proxilion_blocks_total{policy_id,reason}
proxilion_observe_would_have_blocked_total{policy_id,reason}
proxilion_overrides_requested_total{channel="slack|email|webhook|cli"}
proxilion_overrides_resolved_total{outcome="approved|rejected|expired",channel}
proxilion_overrides_pending{} # gauge
proxilion_override_latency_seconds{outcome} # histogram
# Killswitch
proxilion_killswitch_invocations_total{scope="session|user|agent|all"}
proxilion_killswitch_revoked_capabilities_total{}
# Federation / Trust Plane health
proxilion_trust_plane_up{} # gauge 0/1
proxilion_federation_bridge_up{} # gauge 0/1
# Action stream + audit log
proxilion_action_events_persisted_total{decision}
proxilion_action_events_persist_failures_total{reason}
proxilion_audit_export_bytes_total{format="json|ndjson|csv"}
proxilion_audit_export_requests_total{format}
# Process / runtime
proxilion_build_info{version,git_sha,rust_version}
process_* # standard prometheus rust client
| Label | Allowed values (approx) | Why this bound |
|---|---|---|
vendor |
~10 (google, slack, jira, salesforce, …) | Adapters are hand-written |
action |
~50 per vendor | Verb only, never :id or paths |
decision |
5 (allow / block / require_confirmation / rate_limit / observe_*) | Enum |
mode |
3 (observe / enforce / disabled) | Enum |
policy_id |
bounded by customer's YAML (typically <100) | Customer-controlled, document the bound |
reason_code |
<30 per policy | Curated set, not free-text |
idp |
4 (okta / azure / google / oidc) | Enum |
Total active series ceiling per metric: low-thousands at the 99th-percentile deployment, fits comfortably in a Prometheus single-node setup.
Status (2026-05-12) — proxilion_oauth_token_refreshes_total rename + proxilion_oauth_callback_total bridge leg shipped.
proxilion_oauth_token_refreshes_total{vendor,result}— the existingproxilion_token_refreshes_total{result}is renamed to match the §3.2 contract and gains thevendorlabel. The only current source is the Google access-token refresh path in crates/proxy/src/auth_middleware.rs (vendor="google"); future vendor adapters slot in by passing their ownvendorconstant. Wire-incompatible rename. Anyone scraping the old name will lose data on the rename — the old name was not promised as stable, but flag this in release notes when cutting v0.2.proxilion_oauth_callback_total{idp,result}— extended to cover the bridge leg, not just the Google leg. crates/proxy/src/oauth/bridge.rs —FederationClaimsgains an optionaliss: Option<String>field (serde-default; the stub bridge may not emit it, production bridges will). A newinfer_idp(iss)helper coarsely classifies the issuer URL into the spec.md §3.2 contract label set (okta | azure | google | oidc | unknown) by substring match against the well-known IdP issuer patterns. The bridge_callback handler is refactored tobridge_callback_innerso the wrapper threads both the result classification AND the inferred idp into a single metric increment per request. Verified by the newinfer_idp_classifies_known_issuersunit test in bridge.rs covering Okta (incl. oktapreview), Azure (login.microsoftonline.com, *.windows.net), Google (accounts.google.com, googleapis.com), generic OIDC, empty, and None. Closes the §3.2 contract gap noted in the prior status block.
Status (2026-05-12) — proxilion_pic_invariant_violations_total{kind} shipped, hot-swap burst-suppressor caveat resolved, verifier flake fixed.
proxilion_pic_invariant_violations_total{kind}— emitted at the verifier-walkfailure site in crates/proxy/src/pic/verifier.rs.kindfollows the spec.md §3.2 contract (continuity | monotonicity | p0 | hop | signature) plus three out-of-contract buckets for failures that prevent the walk from completing:missing(PCA not in cache),decode(CBOR rejected),cat_key(key registry transient failure). Curated string set, bounded cardinality. Distinct from the existingproxilion_pic_violations_total— that counter captures Trust-Plane Layer-A refusals at successor-mint time (executor.rs); this one captures the chain verifier's view of historical tampering, which is a separate operational signal (an SRE wants to know "are old chains being rewritten?" vs. "is the Trust Plane refusing new chains?").Resolved. The test previously XOR'd the byte atpic::verifier::tests::tampered_payload_caught_by_cat_signatureflake.cbor.len() / 2, which lands in different CBOR regions across runs (uuid randomness shifts the layout). When the byte hit a length / map-header position,decoderejected the bytes before signature verification ran, anddecode(...).unwrap()panicked — flaky. The test now (a) iterates the tamper position across the middle quartile and accepts eitherErr(_)from decode ORErr(_)from signature verify (both are valid "tampering rejected" outcomes), and (b) preserves the explicit midpoint-byte scenario as a regression anchor. Stable across 5 consecutivecargo test --workspaceruns.
Status (2026-05-12) — proxilion_pca_issue_total, proxilion_trust_plane_up + proxilion_federation_bridge_up, and proxilion_oauth_active_sessions shipped.
proxilion_pca_issue_total{result,hop_class}— emitted at every Trust-Plane round-trip in pic/executor.rs.result∈ok | invariant | upstream_error;hop_class∈0 | 1 | 2 | n(curated per spec.md §3.2 to keep cardinality bounded regardless of chain depth — anything ≥3 collapses ton). Refusals carryhop_class=""since we never learned what hop the Trust Plane would have minted; keeps refusal rows distinct from any real successor series. The metric covers BOTHmint_pca_0(the IdP-JWT → PCA_0 path, hop=0) andmint_successor(PCA_1 → PCA_2 → … → PCA_n).proxilion_trust_plane_up{}+proxilion_federation_bridge_up{}— gauges set on a 30-second tick by a background probe spawned atserver::runstartup. Implementation reuses the existingprobe_endpointhelper (the same one/healthzuses) — 2xx/3xx/4xx all count as "up" (reachability is what we measure, not method semantics); 5xx and transport-level failures count as "down." 30s tick matches what the §3.4 Grafana scrape interval can usefully render; a tighter cadence would burn upstream throughput for no observability gain.proxilion_oauth_active_sessions{}— gauge sampled on a 30s tick by a separate background probe. SQL:SELECT count(*) FROM agent_bearers WHERE revoked_at IS NULL(partial-index-friendly predicate; the table holds at most thousands of rows at the 99th-percentile deployment). Transient DB errors do not reset the gauge to 0 — last-known value is more useful than a misleading "all sessions gone" signal during a brief DB blip.
Status (2026-05-12) — proxilion_oauth_authorize_total, proxilion_oauth_callback_total, and proxilion_adapter_upstream_errors_total shipped.
proxilion_oauth_authorize_total{result="ok|denied|error"}— emitted at the end of oauth/routes.rs::authorize. The body was refactored toauthorize_inner; the public wrapper classifies the result viaoauth_error_class(denied = auth-level failures: bad_request / unknown_client / session_gone / bridge_rejected / pkce_fail / bad_auth_code / pic_invariant; error = system failures: upstream / db / crypto / internal). Counter ticks exactly once per request.proxilion_oauth_callback_total{idp,result}— emitted at the end ofgoogle_callback(idp="google") and, since the federation-callback metering landed, at the end ofbridge_callback(federation_token → PCA_0) withidpinferred from the JWTissclaim viabridge::infer_idp(okta|azure|google|oidc|unknown); both share the sameresultclassifier.proxilion_adapter_upstream_errors_total{vendor,action,kind="timeout|network|5xx|other"}— emitted at every upstream-call site in the three Google adapters. Transport errors (timeout / connect / request shape) are classified bysuper::error::upstream_error_kind. HTTP 5xx responses are counted at the status-check site too — the customer wants to see "Google flapped" separately from transport failures even when the response was technically delivered. The customer's Grafana can computerate(proxilion_adapter_upstream_errors_total[5m]) / rate(proxilion_adapter_requests_total[5m])for an upstream-failure ratio.
Status (2026-05-12) — proxilion_readfilter_scans_total, proxilion_readfilter_quarantined_bytes_total, and proxilion_override_latency_seconds shipped.
proxilion_readfilter_scans_total{vendor,action,result}— ticked at everyread_filter::applycall site across the three Google adapters.result∈clean | stripped | quarantined:cleanwhen the regex set didn't match,strippedforreplace_with_marker/strip_silently(content modified, request proceeds),quarantinedforblock_request(full body quarantined + request blocked). Per-vendor cardinality bounded by adapter inventory.proxilion_readfilter_quarantined_bytes_total{vendor}— counter (not gauge). For thestrippedpath the increment isbody_bytes.len() - final_body.len()(the actual delta replaced by the marker / stripped); for thequarantinedpath it's the full pre-filter body length (the request blocked, so the entire body was effectively quarantined). Lets Grafana / SIEM compute "exfiltration attempted in bytes" alongside the scan-count panel.proxilion_override_latency_seconds{outcome}— histogram. Recordsnow() - blocked_actions.atat each resolution path:outcome="approved"from api/blocked.rs::approve_inner,outcome="rejected"fromreject_inner(the UPDATE now usesRETURNING atto compute latency without a separate SELECT),outcome="expired"from blocked_expiry.rs::sweep_once (RETURNINGatfor the same reason). All three feed §3.5's "p50 < 5 min" override SLO; the expired bucket sits at the TTL by construction and is useful as a "is anyone ever deciding?" signal.
Status (2026-05-12) — proxilion_pca_verify_total + duration, proxilion_policy_evaluations_total + duration, and proxilion_adapter_request_duration_seconds shipped. Three more §3.2 contract metrics now emit:
proxilion_pca_verify_total{result="intact|broken"}+proxilion_pca_verify_duration_seconds— ticked at the cold path of crates/proxy/src/pic/verifier.rs::verify_chain. 60s-cached chain verifications (cache hits) deliberately don't tick — the counter measures new verification work, not re-fetches; pair withproxilion_pca_cache_hits_totalto compute hit ratio. The histogram covers the full walk: PCA cache reads + CAT signature verification + invariant checks per hop. §1.5's "<5ms warm / <20ms cold" budget reads against this histogram.proxilion_policy_evaluations_total{policy_id,result}+proxilion_policy_evaluation_duration_seconds— ticked at every adapter call site (Drive / Gmail / Calendar).policy_idis the matched policy or(none)when no policy fired;result∈match | nomatch | error. The duration histogram answers §3.5's "p99 < 1ms" SLO without instrumenting the policy-engine crate itself (the engine stays metrics-agnostic; emission lives at the adapter boundary, which is where the policy-handle'sArcSwap::load()cost also lands).proxilion_adapter_request_duration_seconds{vendor,action}— ticked at the publish site of each Google adapter, measured fromproxy_requestentry (so it covers policy eval + Trust Plane round-trip + upstream + body capture). §3.5's "p99 < 50ms (decision latency minus upstream)" can be computed by subtracting the upstream-fetch duration; today the histogram is the inclusive end-to-end latency.
Status (2026-05-12) — proxilion_blocks_total, proxilion_adapter_requests_total, and proxilion_build_info shipped. Three spec'd metrics from the §3.2 contract that were previously implicit (covered by other names) are now emitted directly:
proxilion_blocks_total{policy_id,reason}— ticked in crates/proxy/src/blocked.rs::persist_returning_id every time ablocked_actionsrow commits.reasonis the layer that produced the block (policy/pic_invariant/read_filter);policy_iddegrades to(none)for Layer-A invariant breaks that never matched a Layer-B policy. Distinct fromproxilion_pic_violations_total(which counts Trust-Plane invariant rejections including audit-mode pass-throughs) andproxilion_observe_would_have_blocked_total(observe-mode demotions);blocks_totalis the operator's "are we annoying people?" quadrant from §3.4.proxilion_adapter_requests_total{vendor,action,decision,mode}— ticked in each Google adapter (google_drive / google_gmail / google_calendar) immediately before publishing the action event. Covers happy path + audit-fallback + observe-mode demotion paths; the Layer-B block early-return is captured byproxilion_blocks_totalinstead, so the combined cardinality is bounded by(vendors × actions × ~6 decisions × 2 modes)≈ low-thousands. Thedecisionlabel matches the existingaction_events.decisioncolumn shape (allow/require_confirmation/rate_limit/observe_block/observe_require_confirmation/observe_rate_limit);modeisobservewhen the engine returned anobserve_would_havelabel, elseenforce.proxilion_build_info{version,git_sha,rust_version}— gauge held at1in crates/proxy/src/main.rs, set once at startup.versionisenv!("CARGO_PKG_VERSION");git_shaandrust_versioncome fromoption_env!("GIT_SHA")/option_env!("RUSTC_VERSION"), both defaulting tounknownfor local dev builds and stamped by CI. Lets Grafana joinproxilion_build_info{git_sha="..."}against any other series for "what build was running when this fired" forensics.
Spec deviations to flag.
- Block path early-return doesn't tick
adapter_requests_total. The Layer-B block path returnsErr(AppError::PolicyBlocked)before reaching the publish site, so thedecision="block"label never appears underadapter_requests_total. We deliberately don't double-instrument —proxilion_blocks_total{policy_id,reason}is the canonical block counter andadapter_requests_totalcovers everything that crossed the wire. A future iteration could mirror the metric at the early-return sites if a customer needs a single-counter "request-attempted total"; today's combination is what the §3.4 Grafana dashboard pulls. git_sha+rust_versionare CI-stamped, not auto-detected. Nobuild.rsrunsgit rev-parseorrustc --version. Dev builds reportunknown; production builds set the env vars at compile time via the CI workflow. Avoidingbuild.rskeeps the build hermetic forcargo installconsumers; the trade-off is that locally-built binaries don't surface a sha.
Proxilion ships a Grafana dashboard JSON in ops/grafana/proxilion.json,
designed for their Grafana, not ours. The dashboard is documentation in
JSON form — they import it, version it, modify it. We never run a
Grafana ourselves. Same approach for Datadog: a ops/datadog/monitors.tf
Terraform module with the recommended alerts. For OTLP customers,
ops/otel/ has the equivalent OTel collector config snippets.
The Grafana JSON answers the four questions a security team will actually ask:
- Are we secure? (
pic_invariant_violations_totalrate,blocks_totalrate,oauth_authorize_total{result!=ok}rate) - Are we annoying people? (
blocks_totalrate,overrides_pendinggauge,override_latency_secondshistogram) - What rolls out next? (
observe_would_have_blocked_totalbypolicy_id— the candidates to promote toenforce) - Is the system healthy? (
trust_plane_up,*_request_duration_secondsp99,policy_reload_failures_total)
These aren't promises Proxilion makes — they're suggested SLOs the customer can write against the data, surfaced in the bundled Grafana dashboard:
- Decision latency p99 < 50ms (adapter request duration minus upstream).
pca_verify_duration_secondsp99 < 5ms warm / < 20ms cold.- Override approval latency p50 < 5 min (signals Slack rollout is working).
- Policy evaluation p99 < 1ms (already a budget in
spec.md§0.3).
A single Rust binary built from crates/cli/. Wraps the existing HTTP API,
adds shell ergonomics (pretty / JSON / NDJSON output, paging, --watch,
--filter shorthand, --format=csv, tab-completion, --explain).
proxilion <command> [subcommand] [flags]
GLOBAL FLAGS
--url URL (default: $PROXILION_URL or https://localhost:8443)
--token TOKEN (default: $PROXILION_OPERATOR_TOKEN)
--format pretty|json|ndjson|csv|tsv
--no-color
-v / -vv (verbose / debug logging from the CLI itself)
COMMANDS
status system + health snapshot, exit≠0 if unhealthy
setup run the /api/v1/setup/status checklist locally
selftest synthetic transaction end-to-end (already in spec)
actions
tail live SSE stream (= /actions/stream)
--filter <expr> e.g. decision=block, vendor=google, action=*.send
--since 5m
--output json|ndjson|csv
list paginated history (= /actions)
--since 24h --until now
--vendor google --action drive.files.get
--decision block
--p_0 alice@org.com
--session-id UUID
--limit 500 --all
show <action_id> full record + chain (= /actions/:id)
export bulk download
--since 2026-01-01 --until 2026-05-01
--format ndjson|csv|json
--output proxilion-audit-2026-01.ndjson.zst
--compress zst|gz|none
verify <pca_id> verify chain by leaf id (= /pca/:id/verify)
chain <session_id> ordered chain for a session (= /sessions/:id/chain)
blocked
list pending block queue
--since 24h
--policy-id ...
--pending|--approved|--rejected|--expired
show <id> full block record + chain + suggested fix
approve <id> --justification "<text>" [--ttl 30m]
reject <id> --reason "<text>"
policy
list [--mode observe|enforce|disabled]
show <id>
edit opens $EDITOR on policy.yaml, validates, hot-reloads
set-mode <id> observe|enforce|disabled
validate <file> parse + simulate; exit code 0/1; works in CI
simulate <file> --against last-7d replay history against a candidate policy,
report would-have-block deltas
diff <branch-or-file> <branch-or-file>
reload force hot-reload (also runs on file change)
pic
invariants show current mode (observe|enforce) per invariant
set-invariants-mode observe|enforce
show <pca_id> PCA JSON + CBOR hex (= /pca/:id)
verify <pca_id> = /pca/:id/verify
killswitch
revoke session <session_id>
revoke user <p_0>
revoke agent <agent_session_id>
revoke all --confirm global stop, must type yes
notifier Slack / email / webhook diagnostics
show webhook + burst-suppressor state
test fire a synthetic notification
# Coming in a future iteration when the `notifier_config` table lands:
# test slack | test email | test webhook
# set slack.bot_token <token> / channel <#channel>
# set email.smtp.url smtp://... --from sec-ops@org.com
# set webhook.url https://... --hmac-secret <hex>
clients OAuth client registry (replaces SQL editing)
list
add <client_id> --name "Anthropic Managed Claude" --redirect-uri ...
rotate <client_id>
revoke <client_id>
metrics
sample curl /metrics and pretty-print top series
serve local Prometheus pushgateway shim (rarely needed)
trust-plane
info upstream Trust Plane status + CAT key info
rotate-cat-key triggered rotation; emits new kid; clients re-fetch
version build info, embeds git SHA + rust toolchain
Status (2026-05-12) — status, pic show|verify, and killswitch session|user|all shipped.
proxilion-cli status(--format pretty|json) — combined/healthz+/api/v1/setup/statussnapshot.ready:falseexits 1 so a CI / shell pipeline canproxilion-cli status && deploy.shwithout parsing JSON. The setup checklist gracefully degrades to(unavailable — token missing or insufficient scope)when the operator token is empty or lacks the needed read scope; the health snapshot is always shown.proxilion-cli pic show <id>/pic verify <id>— token-aware wrappers aroundGET /api/v1/pca/{id}and/verifyso the operator-token scope check fires (pca:read).verifyexits 1 onintact:falsefor CI gating. Theshowpayload duplicates the legacypcasubcommand; we keep both —pcafor muscle-memory,pic showfor the §4.1 spec'd command tree.proxilion-cli killswitch session <id> | user <p_0> | all --confirm yes— POSTs to/api/v1/killswitch/{session/{id}, user/{p_0}, all}with a{ reason }body (defaultoperator-initiated). Theallform refuses without--confirm yes(case-sensitive) and exits 2 to make fat-finger fleet-wide kills loud. All three surface the server's response JSON unchanged so the audit-row id is greppable.
Spec deviations to flag.
- No
killswitch revoke agent <agent_session_id>form. The §4.1 sketch listsrevoke agent <agent_session_id>as distinct fromsession. Today's proxy doesn't expose anagent-scoped killswitch endpoint —sessioncovers the operationally relevant case (one OAuth session = one agent at runtime). Wires up alongside the future per-agent registry if a customer asks. Resolved 2026-05-12. Shipped asclientssubcommand still deferred.proxilion-cli clients list | add <id> --name "..." --redirect-uri <url> | revoke <id>. Follows the same DB-direct bootstrap model astokens(writes viaDATABASE_URL, no operator token required) — that's the right shape for client registry because it's pre-token bootstrap.revokeis soft (setsrevoked_at); the row stays in the table so historicaloauth_sessions.client_idreferences still resolve. migrations/0013_oauth_client_revocation.sql adds the column + a partial index on(id) WHERE revoked_at IS NULLso the authorize-path lookup stays fast; oauth/routes.rs::authorize now refuses revoked clients with the existingunknown_clientenvelope (revoked-vs-never-existed isn't surfaced to the agent for the same info-leak reason the rest of the OAuth error mapping has). Deviation: the §4.1 sketch also listsrotate <client_id>. PKCE-only v1 (spec.md§1.1) doesn't store a client secret to rotate, sorotateis unimplemented; if a customer migrates to a confidential-client flow we revisit.versionisproxilion-cli --version(clap-provided), no extra subcommand needed.
Status (2026-05-12) — auth-threading bug fixed, email install guide shipped.
- Bug fix:
proxilion-cli actions tail / list / show / export+pca+verifywere not sending the operator token. All five handlers receivedcli.tokenbut constructed thereqwestrequest withoutauth_header, so any proxy withPROXILION_DISABLE_OPERATOR_AUTHunset (i.e. every production deployment) would 401 these commands. Plumbedtokenthrough each handler's signature and replacedhttp.get(...)withauth_header(http.get(...), token). Theactions chainhandler shipped last round was already correct (usedauth_headerfrom the start); this is purely closing the gap left by the originaltokens-era PRs that predated operator-scope enforcement. - Email install guide (docs/install/email.md) — closes §11 open question #3. Three known-good relay configurations (AWS SES, Postmark, self-hosted Postfix/OpenDKIM), DNS-record cheat sheet (DKIM CNAME + SPF + DMARC TXT), troubleshooting table mapping symptoms to
smtp_url/ sender-signature / DMARC issues + the proxy's metric reasons (smtp_transient_exhausted/smtp_permanent), and an explicit "what we deliberately don't do" section (no proxy-side DKIM signing; no bounce-handling — the relay owns those).
Status (2026-05-12) — actions chain <session_id> shipped + no-JS CI lint rule landed.
proxilion-cli actions chain <session_id> [--format pretty|json]— wrapsGET /api/v1/sessions/{id}/chain. Pretty mode renders one line per hop withhop,pca_id,p_0, andopscount; json mode passes the full envelope through. Token-aware (usesauth_header) so theactions:readscope check fires. Closes the last item in the §4.1actionscommand tree.- No-JS lint rule (.github/workflows/static-html-no-js.yml) — closes §11 open question #5 ("the one-HTML-page exception"). On every PR + push to main: fails CI if any file in
crates/proxy/static-html/is not.html/.css, and additionally greps for<script src=...>and any<script>…</script>block to defend against a server-rendered SPA creeping in via inline JS. Workflow is two minutes max — runs in parallel with the coverage gate, no impact on the existing CI shape. Verified locally: currentapprove.htmlcarries zero JS files and no<script>tags.
Status (2026-05-12) — policy show, policy validate, and policy diff shipped. Closes the remaining §4.1 policy command-tree gaps.
proxilion-cli policy show <id> [--format pretty|json]— pulls the live policy set viaGET /api/v1/policyand filters by id locally. No new endpoint needed (the existing list response carries every field a show would surface). Exits 1 with a red "not found" if the id is missing.proxilion-cli policy validate <file>— local-only YAML parse viapolicy_engine::yaml::parse_policies. Prints one-line summary per policy on success; exits 1 with the serde_yaml error on failure. Safe to run in CI without proxy or DB access — fitslint + validate + simulatein a single pre-merge pipeline.proxilion-cli policy diff <before> <after> [--format pretty|json]— local-only diff between two policy YAML files. Reportsadded/removed/modifiedpolicy ids; modified rows list which fields changed (vendor / action / mode / pic_mode / required_ops / match / decision). Comparison of structural fields (match,decision) is YAML-serialization-equality rather than recursive walk — surfaces "this changed" without false-positive deltas from key ordering.Deviation:Resolved 2026-05-12.policy edit(opens$EDITORonpolicy.yaml+ validates + hot-reloads) is still unimplemented — operators can edit the file manually andproxilion-cli policy reloadto apply. The CST-preservation blocker that gatedpolicy editis now resolved (§11.1 —policy_handle::edit_mode_in_yamldoes a comment-safe line-oriented edit); shipping the$EDITORwrapper is now purely an ergonomic add and is the next CLI piece to land.proxilion-cli policy edit [--file <path>] [--editor "code --wait"] [--no-reload]ships in crates/cli/src/main.rs::cmd_policy_edit. The flow: resolve the file path (--filewins; otherwiseGET /api/v1/policyreturns the livesource), drop a<path>.bakbackup, spawn$EDITOR/$VISUAL/viviash -cwith the path POSIX-shell-quoted, wait for clean exit, short-circuit on no-change, parse the result throughpolicy_engine::yaml::parse_policies(the same parse the proxy will run), roll back from the in-memory original on validation failure, thenPOST /api/v1/policy/reloadand roll back on a proxy-side parse failure too. The backup is removed only after a successful reload — the operator'sgit diffis the canonical history from there.--no-reloadskips the reload and keeps the backup. 5 new unit tests pin the POSIX shell-quote behavior (spaces, embedded',$/backtick neutralization) and the local-validation contract. Deviation from the §4.1 sketch: the command writes the whole file — there's no field-level merge logic. The operator's editor session is the source of truth; the proxy only validates + reloads. This sidesteps the entire class of "what if two operators edit at once?" race questions (last-write-wins viagit, same as every other policy-as-code workflow). Theset_modefield-level edit path remains for the API-driven mode-flip flow.
Status (2026-05-12) — per-driver notifier test shipped.
proxilion-cli notifier test [--driver all|webhook|slack|email](crates/cli/src/main.rs) — was webhook-only; now fans out to every configured driver by default (--driver all) and targets a single driver when asked. The server endpoint (crates/proxy/src/api/notifier.rs::test) accepts{ "driver": "..." }JSON, fires the synthetic blocked-action envelope (policy_id="proxilion.test",vendor="proxilion",action="notifier.test"so receivers can detect+ignore it), and returns{ ok, fired: [drivers], not_configured: [drivers] }. Single-driver requests against an unconfigured driver return 412 with adriver-keyed envelope so setup scripts fail noisily on the right line. Closes the §4.1 sketch'stest slack | test email | test webhookrows.
Status (2026-05-12) — metrics sample and trust-plane info shipped.
proxilion-cli metrics sample [--filter <substr>] [--raw]—GET /metricson the proxy, parses the Prometheus exposition format inline (no extra dependency), groups by metric family (name before{), prints one row per family withSERIES(count of label-set permutations) +MIN+MAXvalue seen.--filter foorestricts to families whose name containsfoo(cheap O(n) substring),--rawfalls through to the originalname{labels} valuelines forgrep/awkpipelines. The--rawmode also honors--filter. Integer-valued samples (counters, gauges held at integer values) render without decimals; histogram bucket totals keep 6 significant digits.proxilion-cli trust-plane info—GET <trust_plane_url>/v1/federation/info(the Trust Plane reachability + key-id probe already used byselftest). Surfaces the CAT key id the proxy will verify chain signatures against. Pretty-prints the JSON;error_for_statuspropagates non-2xx as a non-zero exit. Useful as a one-shot "is the upstream alive?" check separate from the proxy's/healthz.
--format |
Use case |
|---|---|
pretty (default) |
Human in a terminal. ANSI color, columns, time-relative. |
json |
One JSON document, for jq consumption of single results. |
ndjson |
One JSON document per line, for streaming + jq -c. Default for tail and export. |
csv / tsv |
Spreadsheet / SQL import. Headers on first line; deterministic column order. |
pretty mode never truncates IDs silently — it abbreviates with … and
shows the full ID with proxilion actions show <id>. pretty mode always
exits 0 / 1 / 2 with documented meanings (0 = ok, 1 = matched but
unhealthy, 2 = transport error).
# Live tail of blocked actions, formatted for humans
proxilion actions tail --decision block
# Dump last 30 days of audit log to a file
proxilion actions export --since 30d --format ndjson \
--output /tmp/proxilion-$(date +%F).ndjson
# Grafana / Splunk: pipe NDJSON straight in
proxilion actions tail --format ndjson | splunk -i forward:tcp:9997
# CI: fail the build if any policy would have changed behavior
proxilion policy validate config/policy.yaml &&
proxilion policy simulate config/policy.yaml --against last-7d \
--fail-if-delta-exceeds 5%
# On-call runbook: who's blocked right now?
proxilion blocked list --status pending --format json | jq '.action'
# Killswitch in one keystroke
proxilion killswitch user alice@org.comThe CLI authenticates with an operator token — pxl_operator_*,
issued once at install (proxilion-cli init mints it, prints once,
stores hashed in operator_tokens). Tokens are scoped: a CI bot might
have policy:read, policy:simulate only; an on-call human has
blocks:approve, killswitch:revoke. Scopes are documented and listed
by proxilion-cli tokens scopes.
WebAuthn / passkey login is not a v1 requirement because the CLI is the only UI — operators authenticate to the CLI, not to a browser. Passkeys land if and only if we add a notifier-served HTML page for approve-from-mobile use cases (§5.4).
Status (2026-05-11) — operator tokens shipped. Delivered:
- migrations/0006_operator_tokens.sql —
operator_tokenstable:id,token_hash(SHA-256 of plaintext, raw bytes),name,scopes TEXT[],created_at,last_used_at,revoked_at,revoked_reason. Partial index onrevoked_atfor fast active-token lookup. - crates/proxy/src/operator_auth.rs — token format (
pxl_operator_<52 base32 chars>, same shape as agent bearer), SHA-256 hashing,OperatorPrincipalextension,middleware()extractsAuthorization: Bearer pxl_operator_*, looks up hash, rejects revoked/missing/malformed with401 unauthorized(fixed body, no info leak). Successful auth attachesOperatorPrincipalto request extensions for downstream handlers + fires atokio::spawn-ed best-effortlast_used_atupdate. Therequire_scope(...)helper exists and is unit-tested but per-endpoint scope checks are a follow-up (see deviation 1). - crates/proxy/src/server.rs — all five
/api/v1/*routers (pca,actions,blocked,killswitch,policy) are merged and wrapped in a singleoperator_auth::middlewarelayer./healthz,/metrics,/admin,/admin/setup,/oauth/*, and the adapter routes (/google/*) remain outside the operator-auth boundary — those have their own auth model (the agent'spxl_live_*bearer). - crates/cli/src/main.rs — three new subcommands:
tokens issue --name <n> --scope <scopes>(mints + prints once, hashes + persists),tokens list [--all](active or full history),tokens revoke <id> --reason <r>. Writes directly to postgres viaDATABASE_URL— that's the bootstrap path the spec describes ("proxilion-cli init mints it, prints once"). - Metrics:
proxilion_operator_auth_total{result="ok|rejected",reason}. Reason buckets are bounded (missing,malformed,unknown_or_revoked,no_principal,scope_denied). - Default posture: enforced. Set
PROXILION_DISABLE_OPERATOR_AUTH=1to bypass for local dev — emits a loudWARNon every startup so it's visible in CI/prod logs. The docker compose file flips this on for the dev stack so the existing demo and stress scripts continue to work; production deployments leave it unset.
Unit tests (7 new in operator_auth::tests): token-format validation (correct shape; rejected: wrong prefix, lowercase, wrong length), wildcard scope acceptance, exact-scope acceptance + rejection on miss, SHA-256 hash stability. cargo test --workspace is green at 87 passing (was 80).
End-to-end verification (2026-05-11) via scripts/stress-operator-tokens.sh:
proxilion-cli tokens issuemints an admin (*scope) token and a narrowpolicy:read,actions:readci-bot token.tokens listreturns 2 rows.- Toggle proxy to enforced (
PROXILION_DISABLE_OPERATOR_AUTH=0+ restart). Unauthed/api/v1/policy→ 401, bogus token → 401, valid admin → 200, valid ci-bot → 200. tokens revoke <id>flipsrevoked_at. Subsequent request with the revoked token → 401; admin token unaffected.proxilion_operator_auth_total{result="ok"}and{result="rejected"}both tick.last_used_atis updated within ~1s of a successful request.- Restore disabled mode → unauthed requests succeed again, preserving the dev-compose default.
Update (2026-05-11) — per-endpoint scope enforcement shipped + proxilion-cli policy / blocked subcommands wired. Delivered:
- crates/proxy/src/operator_auth.rs —
scope_checkmiddleware function used viaaxum::middleware::from_fn_with_state("scope:name", scope_check). When the outer operator-auth middleware is disabled (PROXILION_DISABLE_OPERATOR_AUTH=1) it attaches a synthetic wildcard principal so the per-route checks are no-ops; in enforced mode an absent principal would have already 401'd at the outer layer. Wrong scope →403 scope_deniedwith a structured body{ "code": "scope_denied", "required": "<scope>", "have": [...], "error": "insufficient scope" }. - crates/proxy/src/api/{mod,actions,blocked,killswitch,policy}.rs — every route layered with its specific scope:
| Method + path | Scope |
|---|---|
GET /api/v1/pca/{id} + /verify |
pca:read |
GET /api/v1/actions* (list, recent, stream, {id}, sessions chain) |
actions:read |
GET /api/v1/actions/export |
actions:export |
GET /api/v1/blocked + /{id} |
blocks:read |
POST /api/v1/blocked/{id}/{approve,reject} |
blocks:approve |
POST /api/v1/killswitch/{session,user,all}/... |
killswitch:revoke |
GET /api/v1/policy |
policy:read |
POST /api/v1/policy/reload + …/{id}/mode |
policy:write |
- crates/cli/src/main.rs — new global
--token / $PROXILION_OPERATOR_TOKENflag; new subcommandspolicy {list,reload,set-mode}andblocked {list,show,approve,reject}(covers ui-less-surfaces.md §4.1'spolicyandblockedblocks). Each command builds the request via a smallauth_header()helper so absent tokens (dev mode) still produce a clean request.
End-to-end verification (2026-05-11) via scripts/stress-scope-and-cli.sh — 21 assertions, all passing:
- Mint three tokens (policy-read-only, blocks-approve, wildcard admin).
policy:readtoken →GET /policy = 200,POST /policy/reload = 403,POST /killswitch/all = 403, body carriesrequired+have.blocks:approvetoken →GET /blocked = 200,GET /policy = 403.- CLI happy-path:
policy list / set-mode / reload, then seed a blocked-action with a real Trust-Plane-minted PCA_0,blocked list / show / approve, then re-seed and exercisereject. - CLI fail-path:
proxilion-cli blocked approvewith thepolicy:readtoken surfaces the 403 to the operator. - Metric
proxilion_operator_auth_total{result="rejected",reason="scope_denied"}ticks.
Spec deviations to flag.
Resolved 2026-05-12.POST /api/v1/policy/{id}/moderound-trips through serde_yaml. Same caveat as the original notice: comments / key ordering are not preserved across writes. Still tracked alongside §11.1 (CST-preserving editor).set_modedoes a line-oriented in-place edit (policy_handle::edit_mode_in_yaml) that preserves comments, key ordering, blank lines, and the trailing comment on the edited line itself.serde_yamlis the fallback for YAML shapes the line walker can't recognize. See §11.1.- No
proxilion-cli initwrapper. §4.4 sketchesproxilion-cli initas the bootstrap command. We ship the same functionality split into the three explicit verbs (issue/list/revoke) — that fits better with operator scripting (set -e-friendly, JSON output tojq) than a one-shotinitthat prints a token to a TTY. A thininitalias can be added when an installer needs it. NoResolved 2026-05-12. crates/shared-types/src/operator_scopes.rs is now the single source of truth for the scope catalogue (proxilion-cli tokens scopeslisting.SCOPE_CATALOGUE: &[(scope, description, endpoints)]). crates/proxy/src/operator_auth.rs consumes it; the CLI shipsproxilion-cli tokens scopes [--format pretty|json](crates/cli/src/main.rs::cmd_tokens_scopes). The command does not requireDATABASE_URL— it's a pure read of the in-binary catalogue, so it works in CI / container builds where the env-less invariant matters. 3 unit tests inoperator_scopes::testspin "no duplicate scopes," "wildcard present," and "every entry has a non-empty description + endpoints list."Resolved 2026-05-12. crates/proxy/src/operator_auth.rs —last_used_atwrites are fire-and-forget.OperatorAuthStatenow carries a per-processmoka::future::Cache<Uuid, Instant>(TOUCH_CACHE_CAPACITY = 100_000,time_to_idle = 2 × LAST_USED_DEBOUNCE). Every successful auth checks the cache: if the token was touched withinLAST_USED_DEBOUNCE(60s), the DBUPDATEis skipped andproxilion_operator_last_used_writes_total{result="debounced"}ticks; otherwise the cache is seeded with the currentInstantand the existing fire-and-forgettokio::spawnUPDATE runs (now taggedresult="ok|error"). Drops sustained-load write amplification from one UPDATE per request to at most one UPDATE per token per minute; observability loss is bounded —last_used_atis at mostLAST_USED_DEBOUNCEstale. Verified bytouch_cache_debounces_within_windowinoperator_auth::tests.
cargo install proxilion-cli(workspace member already exists perspec.md§0.1).- Pre-built static binaries in GitHub releases (linux-amd64, linux-arm64, darwin-arm64, darwin-amd64, windows-amd64).
brew install proxilion/tap/proxilion-cli.- A 600 KB single binary, no runtime dependencies, no Node/Python.
This is the surface that earns its keep. Everything else is "we exposed the data, you went and got it." This one is "we pushed something into your existing tool, you clicked yes."
Agent Proxilion Slack channel Approver
│ │ │ │
│ POST /gmail/send │ │ │
│ ──────────────────▶│ │ │
│ │ Layer-B block decision│ │
│ │ persist blocked_actions │
│ HTTP 202 ⏸︎ │ signed Slack message ▶│ │
│ ◀──────────────────│ "Approve / Reject" │ notify channel members │
│ │ │ ──────────────────────▶ │
│ │ │ click ✅ Approve │
│ │ Slack interaction │ ◀───────────────────── │
│ │ webhook ◀──────────│ │
│ │ POST /api/v1/blocked/:id/approve │
│ │ create override PCA branch │
│ resume + execute │ │ │
│ ◀──────────────────│ │ │
│ │ post "approved by @alice" reply in thread │
│ │ ─────────────────────▶│ │
{
"id": "01HFAB...",
"session_id": "...",
"p_0": "alice@org.com",
"vendor": "google",
"action": "gmail.messages.send",
"method": "POST",
"path": "/gmail/v1/users/me/messages/send",
"summary": "Send to evilcorp.example (external) — 1 recipient",
"request_canonical_json": "{...}",
"policy_id": "gmail-external-recipient",
"policy_reason": "external recipient",
"pic_invariant_violated": null,
"predecessor_pca_id": "...",
"required_ops": ["gmail:send:alice@org.com:to:evilcorp.example"],
"missing_ops": ["gmail:send:alice@org.com:to:evilcorp.example"],
"suggested_fix": "Add `gmail:send:alice@org.com:to:evilcorp.example` to alice@org.com's PCA_0 ops (org IdP group), or override this single send.",
"status": "pending",
"created_at": "...",
"expires_at": "...",
"override_pca_id": null
}The message uses Slack's Block Kit. One message per blocked action,
posted to the customer-configured channel (#proxilion-blocks by
default), threaded by policy_id so a burst of similar blocks doesn't
flood the channel — they fold into a thread.
Header: 🛑 Blocked: gmail.messages.send
Body: alice@org.com → evilcorp.example (external recipient)
Policy: gmail-external-recipient
Subject: "Q4 forecast — please review"
[Approve once] [Approve + add to policy exception] [Reject] [Why?]
Footer: Expires in 30 min · Action ID 01HFAB…
- [Approve once] — single-use override; creates an override PCA branch
per
spec.md§2.3; setsexpires_at = now + ttl(default 30 min); the underlying action retries automatically (the agent's request that was blocked is still suspended on the proxy side waiting for the decision). - [Approve + add to policy exception] — same as Approve, but also
appends a YAML exception block to
policy.yaml. Generates a PR-style diff in the message thread that the security owner can edit later. - [Reject] — operator chooses from a short reason list (Suspicious /
Wrong recipient / Not authorized / Testing); reason is logged and
attached to the PCA
reject_attestation. Agent sees a 403 with a redacted reason. - [Why?] — opens an ephemeral message with the full
request_canonical_json(truncated to 4 KB), the matched policy YAML, and a link to the PCA chain JSON for forensic review.
Slack interactivity is verified per Slack's signed-request scheme:
X-Slack-Signature(HMAC-SHA256 over timestamp + body, keyed by the app's signing secret stored innotifier_config).- 5-minute timestamp skew window.
- Idempotency: each Slack interaction has a unique
trigger_id; we record it inblocked_actions.slack_trigger_idso a double-click can't approve twice.
The approver's identity (U_SLACK123) is mapped to a Proxilion operator
identity via notifier_config.slack.user_map (an IdP-group lookup is the
production path; manual map is fine for v1). The mapped operator
identity is what attests the override PCA — same as a CLI blocked approve.
Status (2026-05-11) — shipped. Delivered:
- crates/proxy/src/notifier/slack.rs —
SlackNotifier { incoming_webhook_url, signing_secret, proxy_public_url }.notify(&BlockedNotification)POSTs a Block Kit JSON envelope (header / context section / detail context / actions buttons / footer). Buttonvaluecarriesapprove:<uuid>/reject:<uuid>— the interaction webhook parses these to route.SlackSigningSecret::verify(signature, timestamp, body)implements Slack'sv0:<ts>:<body>scheme with constant-time compare + 5-minute skew window. - crates/proxy/src/api/notifier_slack.rs —
POST /api/v1/notifier/slack/interactlives outside the operator-auth boundary; the signed request IS the credential. Reads the raw request body (so signature verification matches Slack's byte-exact computation), verifies via the configuredsigning_secret, parses the form-encoded JSONpayload, and routes the button viaparse_button_value→approve_inner/reject_innerwithchannel="slack"andapprover_subject="slack:<username>". - crates/proxy/src/notifier/handle.rs — generalized to
Handle<T>with per-driverNotifierHandle(webhook) andSlackHandlealiases. NewNotifiersbundle holds both;AdapterState.notifier: Notifiersandblocked::persist_and_notifyfan out to both drivers in parallel when both are configured. - crates/proxy/src/api/notifier.rs —
POST /api/v1/notifier/confignow acceptsdriver: "slack"withconfig: { incoming_webhook_url, signing_secret }.GETredacts both the signing_secret and the URL. - crates/cli/src/main.rs —
proxilion-cli notifier set-slack --incoming-webhook-url <u> --signing-secret <s> [--disabled]. - Metrics:
proxilion_slack_post_total{result,layer},proxilion_slack_post_failures_total{reason},proxilion_slack_interact_total{result},proxilion_notifier_config_changes_total{driver="slack"}.
Unit tests (5 new in notifier::slack::tests): Block Kit payload shape (header + approve/reject buttons with style: primary|danger), button value round-trip + rejection of bad shapes, signed-request verify happy path + rejection of stale timestamp + rejection of tampered body + rejection of missing v0= prefix. cargo test --workspace is green at 119 proxy tests (was 110).
End-to-end verification (2026-05-11) via scripts/stress-slack-driver.sh — 12 assertions, all passing:
proxilion-cli notifier set-slackpersists row + hot-swaps notifier./api/v1/notifier/showreportsslack: configured: true./api/v1/notifier/configredactssigning_secret(echoessigning_secret_set: true) and the URL.- Seeded blocked-action row + valid Slack-signed POST → approval succeeds, blocked row →
overridden, approver =slack:<username>. - Missing signature → 401. Bad signature → 401. 10-minute-old timestamp → 401 (replay rejected even with valid HMAC).
- Reject button → row →
rejected. - Metrics:
proxilion_slack_interact_total{result="ok"}=2,{result="rejected_signature"}=2.
Spec deviations to flag.
- No
[Approve once]vs[Approve + add to policy exception]distinction. The Block Kit message ships two buttons: Approve and Reject. The "add to policy exception" path requires a YAML editor that we'd plumb back into the policy hot-reload — a follow-up when a customer asks for it. For v1 the single-use approve covers the operational case. NoResolved 2026-05-12. The Block Kit message now ships a third button —[Why?]button.Why?— alongside Approve / Reject. Itsvaluecarrieswhy:<uuid>.crate::api::notifier_slack::handle_whyshort-circuits before the trigger_id idempotency claim (Why is purely informational — it doesn't mutate the row, doesn't consume a trigger_id, doesn't fan out a notification), looks upstatus / p_0 / policy_id / detail / path / vendor / action / requested_ops / expires_at, and returns aresponse_type: ephemeralSlack response visible only to the clicker. Long requested-ops lists are truncated to the first 5 with a+N moresuffix; the full set is still available viaproxilion-cli blocked show <id>. Metric:proxilion_slack_interact_total{result="why"}. Verified by the existing slackblock_kit_payload_has_header_and_buttonstest (now asserting all 3 buttons) plus a newparse_button_value_round_trip_whytest.NoResolved 2026-05-12. migrations/0011_slack_trigger_id.sql addsslack_trigger_ididempotency check.blocked_actions.slack_trigger_id TEXTwith a unique partial indexWHERE slack_trigger_id IS NOT NULL(rejects two distinct trigger_ids racing on the same row at the DB layer). crates/proxy/src/api/notifier_slack.rs::claim_trigger_id runs before the existingapprove_inner/reject_innerdispatch: an atomicUPDATE … WHERE id=$1 AND status='pending' AND slack_trigger_id IS NULLreturns one of four states — Fresh (proceed), Retry (Slack delivered the same trigger_id twice → return idempotent success message), Conflict (different trigger_id already claimed the row → 409), or Error (continue and rely on the existingFOR UPDATErace protection insideapprove_inner). New metrics:proxilion_slack_interact_total{result="retry_idempotent|conflict_other_trigger|claim_error"}. The FOR UPDATE insideapprove_innerremains the canonical race protection; trigger_id is strictly additive (gives idempotent retry semantics + an audit trail of which Slack click owned the override).NoResolved 2026-05-12. Theuser_mapfrom Slack user to Proxilion operator.slackdriver config accepts an optionaluser_map: { "U01ABC": "alice@acme.com", "bob": "bob@acme.com" }object. Keys can be either a Slack user id (payload.user.id) or a Slack username (payload.user.username) — id is consulted first, username as fallback. The mapped value (typically the operator's email) becomes theapprover_subjectrecorded on the override PCA + the audit row, replacing the opaqueslack:<username>shape. Unmapped clickers still fall through toslack:<username>so the historical default is preserved. Plumbing:SlackNotifier::with_user_map+resolve_user;set_slackvalidates the map shape (object of string → string, non-empty values only) and persists it insidenotifier_config.config;resolve_slack_configreads the map at boot. The map is not redacted onGET /api/v1/notifier/config— Slack user ids and operator emails are routinely visible in the audit trail.GETechoesuser_map_entries: <count>on the set response so operators can confirm the upload took. SCIM / IdP-group sync is still the v2 piece (§4.4 dev 1); the static map covers the small-team case until that lands. Verified by the newuser_map_resolves_by_id_then_usernametest in crates/proxy/src/notifier/slack.rs.No burst-suppression / thread folding.Resolved 2026-05-12. crates/proxy/src/notifier/slack.rs::with_burst plumbs the sameBurstSuppressorthe webhook driver uses; suppressed events skip the per-event POST and a periodic flush calls the newnotify_summary(&BurstSummary)method which emits a dedicated Slack Block Kit layout (no Approve/Reject buttons — the summary is informational; the per-burst exemplar names the canonical vendor/action/layer so the operator can find the queue). crates/proxy/src/server.rs::build_notifiers attaches the suppressor + spawns the flush loop with the per-policyBurstResolveralready used by the webhook driver, sopolicy.yaml'snotifier_burst:block hot-applies to the Slack path too. New metrics:proxilion_slack_summary_sent_total{policy_id},proxilion_slack_summary_failures_total{reason}. One deviation — the spec calls for the summary to be "folded into a thread" viathread_ts. We post the summary as a standalone message instead; threading requires storing the original parentmessage_tsper(policy_id, p_0)bucket, which is one schema row away. Today's standalone summary still collapses the channel noise (one message per burst window), so the operational pain point — channel mute — is solved.
Configurable per-policy; can coexist with Slack. The email contains:
- Plain-text and HTML bodies. Both have a one-click Approve and Reject link.
- Each link is a signed URL:
https://proxilion.local/api/v1/blocked/<id>/approve?token=<bearer>where<bearer>is a single-use HMAC-signed token bound to(blocked_action_id, action, expires_at, approver_email). - Token TTL = block expiry (default 30 min). Single-use enforced by
consumed_atcolumn innotifier_tokens. - Reject link opens a
mailto:with the reason text pre-filled so the approver can hit send — server polls the reply mailbox (or accepts the click+form on the rejection landing page). - DMARC / SPF / DKIM aligned via the customer's SMTP relay.
If the approver clicks Approve from a mobile mail client and they're not already logged in, the URL serves a single-purpose HTML page (no SPA, no React) — just the block summary, a confirm button, and the result. This is the one server-rendered HTML page Proxilion ships. It's ~80 lines of HTML, no JS framework. It exists because most security people read email on a phone.
Update (2026-05-11) — outbound email composer + SMTP delivery shipped. Builds on the signed-URL plumbing below: every blocked-action persist mints two single-use notifier_tokens (approve + reject) and emails plain-text + HTML bodies with both one-click links. Delivered:
- crates/proxy/src/notifier/email.rs —
EmailNotifierwraps a lettreAsyncSmtpTransport<Tokio1Executor>with a 10s timeout.notify(&BlockedNotification)issues two tokens (one per action), composes a multipart alternative (text/plain + text/html), and sends via SMTP. HTML body has Approve (green) + Reject (red) buttons; plain-text mirrors the same two links. HTML-escapes every field that goes into the body. - Cargo.toml + crates/proxy/Cargo.toml —
lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "tokio1-rustls-tls", "rustls-platform-verifier", "builder", "tokio1"] }. Pulls in TLS support; no native-tls dep. - crates/proxy/src/notifier/handle.rs —
EmailHandle = Handle<EmailNotifier>joinsNotifierHandle(webhook) andSlackHandlein theNotifiersbundle. - crates/proxy/src/blocked.rs —
persist_and_notifyfans out to all three drivers in parallel (tokio::spawnper driver). The owned-notification snapshot is cloned per branch. - crates/proxy/src/server.rs —
build_notifiersconsultsnotifier_configfor theemailrow at startup. Config shape:{ smtp_url: "smtps://user:pass@host:465", from: "RFC 5322 mailbox", to: <string|array> }. No env fallback — SMTP credentials live in DB only. - crates/proxy/src/api/notifier.rs —
POST /api/v1/notifier/config { driver: "email", config: { smtp_url, from, to } }validates + builds + persists + hot-swaps.toaccepts either a single string or an array of strings.GETredactssmtp_url(since the URL may containuser:pass) tosmtp_url_redacted = scheme://host/...;fromandtoecho plaintext. - crates/cli/src/main.rs —
proxilion-cli notifier set-email --smtp-url <u> --from <addr> --to <addr1> [--to <addr2>] [--disabled]. - Metrics:
proxilion_email_send_total{result,layer},proxilion_email_send_failures_total{reason="token_issue|build|smtp"},proxilion_notifier_config_changes_total{driver="email"}.
Unit tests (4 new in notifier::email::tests): HTML-escape sanitization, invalid smtp_url rejection, empty to rejection, malformed from rejection. cargo test --workspace is green at 123 proxy tests (was 119).
End-to-end verification (2026-05-11) via scripts/stress-email-driver.sh — 13 assertions, all passing, exercised against a live axllent/mailpit:v1.27 SMTP-test container inside the compose network:
proxilion-cli notifier set-emailpersists + hot-swaps./api/v1/notifier/showreportsemail: configured: true./api/v1/notifier/configredactssmtp_url(echoessmtp_url_redacted), preservesfrom+to.- Bad SMTP URL → 400. Missing
to→ 400. Emptytoarray → 400. Malformedfrom→ 400. - DB row survives env-less proxy restart; email notifier is rebuilt at boot from
notifier_config. --disabledhot-swaps the notifier to None;/showflips toemail: configured: false.- Metric increments per config change.
Spec deviations to flag.
- No DKIM signing on the proxy side. §5.4 calls out DMARC / SPF / DKIM alignment "via the customer's SMTP relay." We accept whatever relay the customer configures; if the relay handles DKIM (most do, e.g. SES, SendGrid, Mailgun, Postmark), alignment works. The proxy itself doesn't sign — it's a customer-relay concern.
mailto:reject link not generated. §5.4 sketches amailto:rejection link that pre-fills the reason text and lets the approver hit Send. We use the same HTTP signed-URL flow as the approve path (both go through/notifier/approvewithaction=reject). The signed-URL approach is friendlier on mobile mail clients that show preview cards for HTTP URLs but not formailto:.Single globalResolved 2026-05-12.torecipient list.PolicyDocnow carries an optionalnotifier_recipients: { to, cc, bcc }block (crates/policy-engine/src/yaml.rs). Each list is independently optional and accepts either a single string or a sequence (same shape as thenotifier_config.emailpayload).Engine::email_recipients_for(policy_id)returns the override tuple;EmailNotifier::with_recipients_resolver(...)plumbs a resolver closure that consults the live policy engine insidenotify(), so apolicy.yamlhot-reload immediately re-routes in-flight blocks. Each list the policy explicitly set replaces the global default for that one list; the other two inherit. Malformed override addresses fall back to the global default per-list (logged) so a typo in policy YAML can't black-hole a block. Verified by 5 policy-engine tests in crates/policy-engine/tests/per_policy_recipients.rs + 2 proxy tests innotifier::email::testscovering override layering and the malformed-address fallback.No bcc / cc.Resolved 2026-05-12. crates/proxy/src/notifier/email.rs::EmailNotifier::new_with_recipients accepts optionalcc: &[String]andbcc: &[String]slices alongsideto. The same single-string-or-array shape thattoaccepts on thePOST /api/v1/notifier/config { driver: "email" }payload (and on the DB row) now applies toccandbcc— each independently optional, each independently validated against the lettreMailboxparser. TheGET /api/v1/notifier/configview echoes both.EmailNotifier::newremains as a back-compat alias (empty cc / bcc) so existing call sites are untouched. No metric churn — the existingproxilion_email_send_totalcovers all three recipient types under the same row.No retry on transient SMTP failures.Resolved 2026-05-12. crates/proxy/src/notifier/email.rs now wrapstransport.send(...)in a retry loop: lettre'sError::is_permanentshort-circuits permanent failures (auth refused, bad recipient, 5xx) — no point burning the retry budget on those. Transient failures (timeout, 4xx, network blip) retry up tomax_retries = 3(matches the webhook + SIEM forwarder budgets) with250ms × 4ⁿexponential backoff capped at 10s. New metric reasons:proxilion_email_send_failures_total{reason="smtp_permanent|smtp_transient_exhausted"}(replaces the prior singlereason="smtp").with_max_retries(0)is exposed for tests that need to keep the loop fast.
endpoints + landing page are live. Delivered:
- migrations/0007_notifier_tokens.sql —
notifier_tokens(token_id, blocked_id, action CHECK approve|reject, approver_hint, issued_by, expires_at, consumed_at)with a partial index on unconsumed tokens for cheap lookup. - crates/proxy/src/api/blocked.rs —
POST /api/v1/blocked/{id}/issue-link(scopeblocks:approve) accepts{ action, ttl_minutes?, approver_hint? }, validatesaction ∈ {approve, reject}+ttl_minutes ∈ 1..=1440, checks the row is stillpending, inserts the token row, and returns{ token_id, url: "/notifier/approve?t=<uuid>", action, expires_at }. Theapprove_inner+reject_innerwere refactored from the operator-facing handlers into reusable inner functions that take achannellabel. - crates/proxy/src/api/notifier_public.rs —
GET /notifier/approve?t=<uuid>renders a single HTML page with a confirm form;POST /notifier/approve(urlencoded bodyt=<uuid> & justification=... | reason=...) callsapprove_inner/reject_innerwithchannel="email"and marks the token consumed inside the same transaction that locked itFOR UPDATE. Replay-safe by construction: a second click on the same link seesconsumed_at IS NOT NULLand renders the "already used" page. - crates/proxy/static-html/approve.html — single-file template, no JS framework, embeds via
include_str!. Light/dark color scheme viaprefers-color-scheme. ~80 lines of HTML + CSS. All user-supplied fields are HTML-escaped viahtml_escape()before substitution — the<script>alert(1)</script>payload in thedetailcolumn is rendered as<script>. - crates/proxy/src/server.rs —
notifier_public::router(...)is merged into the app outside the operator-auth boundary. The single-use token IS the credential. - Metrics:
proxilion_overrides_requested_total{channel="email_link"}ticks on every issued link;proxilion_overrides_resolved_total{outcome="approved|rejected",channel="email"}ticks on every consumed link.
Unit tests (2 new in notifier_public::tests): html_escape_handles_payload_attacks (XSS sanitization on < > & " ') and template_substitutions_fill_all_placeholders (no unfilled {{...}} placeholders, real fields propagate).
End-to-end verification (2026-05-11) via scripts/stress-signed-link-approve.sh — 14 assertions, all passing:
- Real
PCA_0minted via mock-okta → Trust Plane, seeded intopca_cache+ a pendingblocked_actionsrow. POST /api/v1/blocked/{id}/issue-link {"action":"approve"}returns{token_id, url, action, expires_at}.GET /notifier/approve?t=...renders without an operator token, carriesp_0, escapes the<script>payload in thedetailfield.POSTwith short justification → validation error, token still unconsumed.POSTwith valid justification → success banner with the freshly-minted override PCA id + hop; blocked row flips tooverriddenwithapprover_subject = "on-call@acme.com"(taken from the link'sapprover_hint).- Re-clicking the consumed link → "already used."
- Reject path: same shape, blocked row →
rejected. - Negative paths: invalid
action→ 400; resolved row → 409; expired token surfaced in HTML; unknown UUID → friendly error page.
Spec deviations to flag.
No outbound email composer. §5.4 promises Plain-text + HTML mail bodies, DMARC/SPF/DKIM alignment, signed mailto reject links. We ship the chokepoint (signed URL + landing page); the email body composition + SMTP delivery is the next layer. The notifier driver model (Slack / Email / Webhook trait) lives in §10.3 as a separate deferred item.Resolved 2026-05-12. crates/proxy/src/notifier/email.rs —EmailNotifierships the full SMTP delivery path vialettre:multipart/alternativewith both plain-text and styled HTML bodies (color-coded approve / reject buttons, identification table, single-use-link disclaimer), configurablefrom/ cc / bcc, transient-vs-permanent error classification on send (retried with exponential backoff for transient, recorded permanent for the audit row), per-policy recipient overrides vianotifier_recipients:inpolicy.yaml, and anotify_escalationvariant that re-fires with aREMINDER:subject prefix for the §5.7 dev 2 escalation sweep. Counters:proxilion_email_send_total{kind="initial|escalation",result},proxilion_email_send_failures_total{reason}. DMARC / SPF / DKIM alignment lives at the customer's SMTP relay rather than proxy-side — three known-good configurations documented in docs/install/email.md (resolves §11 open question #3).- Single signed URL — not HMAC-signed query string. §5.4 specifies a URL with an HMAC token. We use a UUID-keyed
notifier_tokensrow (essentially a single-use bearer in DB rather than a self-contained signed JWT). Both shapes are equivalent for one-time-use links; the DB-keyed shape is replay-resistant by construction (single-use enforced byconsumed_atUPDATE inside the locking transaction) and doesn't require key rotation. A future iteration can layer HMAC-signed URLs on top for stateless verification if a customer's email infrastructure needs that. MetricResolved 2026-05-11. Root-caused to a_totalcounters coalesce to 1.get_or_create_counternon-idempotency bug inmetrics-util 0.19.1(used bymetrics-exporter-prometheus 0.16.2): eachmetrics::counter!("name")call site was inserting a freshArc<AtomicU64>into the registry's sharded hashmap, so increments split across N counters with the LAST-inserted one rendered. Confirmed via a minimal repro that printed Arc addresses — tworegistry.get_or_create_counter(&same_key, |c| c.clone())calls returned different Arc pointers. Bumpingmetrics-exporter-prometheus = "0.17"(pullsmetrics-util 0.20.3) makesget_or_create_counteridempotent and counters accumulate correctly. Live verification: 5 unauth probes →proxilion_auth_attempts_total{result="rejected"} 5; signed-link stress'sproxilion_overrides_requested_total{channel="email_link"} = 3after 3 issue-link calls.
For PagerDuty / Opsgenie / Teams / Jira / custom:
- Outbound:
POST <webhook_url>with the blocked-action JSON envelope and anX-Proxilion-SignatureHMAC. Customer's webhook handler does whatever (creates a Jira ticket, fires PagerDuty incident, posts to Teams) and eventually returns its own approve/reject via: - Inbound:
POST /api/v1/blocked/:id/{approve,reject}with the same HMAC signature scheme. This is justproxilion-cli blocked approveover the wire.
This makes Proxilion notifier-agnostic without writing a Teams plugin and a Jira plugin and an Opsgenie plugin.
Status (2026-05-11) — outbound webhook driver shipped. Delivered:
- crates/proxy/src/notifier/mod.rs —
BlockedNotificationenvelope (schema: "proxilion.blocked_action.v1",blocked_id,p_0,vendor,action,method,path,layer,policy_id,detail,predecessor_pca_id,requested_ops, plusapprove_urlandreject_urlfor one-click operator landing). - crates/proxy/src/notifier/webhook.rs —
WebhookNotifierPOSTs JSON withx-proxilion-signature: sha256=<hmac>,x-proxilion-schema: proxilion.blocked_action.v1,x-proxilion-blocked-id: <uuid>. Retry policy mirrors the SIEM forwarder (no retry on 4xx; exp-backoff on 5xx/transport up to 3 attempts). - crates/proxy/src/blocked.rs —
persist_and_notify(db, notifier, record)is the new adapter call. Persistence runs first (synchronous, durable); the webhook is spawned on tokio so a slow receiver never slows the request response. The three Google adapters (Drive / Gmail / Calendar) all switched to this signature. - Config:
PROXILION_BLOCKED_WEBHOOK_URL+PROXILION_BLOCKED_WEBHOOK_HMAC_KEY. Empty URL → notifier disabled. URL set without key → refuse-to-sign-without-a-key (warn + disable). Invalid hex / too-short key → same. - Metrics:
proxilion_notifier_send_total{result,layer}andproxilion_notifier_send_failures_total{reason="serialize|client_error|server_error_exhausted|transport_exhausted"}.
Unit tests (notifier::webhook::tests, 5 new): HMAC round-trip, hex-validation rejection paths, wiremock'd POST asserting signature + schema + blocked-id headers and body content, no-retry-on-4xx, retry-on-5xx-then-success. cargo test --workspace is green at 74 passing.
End-to-end verification (2026-05-11) via scripts/stress-notifier.sh against the live compose stack:
- Proxy log confirms
blocked-action webhook notifier installed url=http://…. - Negative paths: empty
PROXILION_BLOCKED_WEBHOOK_HMAC_KEY→ warn + run without the notifier (refuse to sign with no key). Too-short hex (dead) → warn + run without. Both verified in CI-friendly assertions. - Restore path: setting both env vars again brings the notifier back to nominal state.
- Live POST to a
mendhak/http-https-echoreceiver inside the compose network is unit-tested via wiremock; full adapter→notifier→receiver round-trip requires a seeded signed-PCA in cache (CI harness gap shared with §1.1 / §1.3).
Spec deviations to flag.
Driver model elided.Resolved 2026-05-12. All three drivers ship as concrete types —WebhookNotifier,SlackNotifier,EmailNotifier— fanned out via theNotifiersbundle (webhook: NotifierHandle,slack: SlackHandle,email: EmailHandle).blocked::persist_and_notifyfans the sameBlockedNotificationto every configured driver in parallel viatokio::spawnper branch. We elected NOT to introduce aBox<dyn Notifier>trait — each driver'snotify()signature is slightly different (Slack carries Block Kit shape and threading hooks; Email mints DB-keyed tokens and composes multipart MIME; Webhook signs + retries) and the trait would have ended up as a least-common-denominator. The bundle gives the same fan-out ergonomics without forcing a uniform method shape. Per-driver burst suppression, per-policy recipient routing, and per-policy burst overrides are all wired (§5.6, §5.4 dev 3).No inbound Slack interaction webhook.Resolved 2026-05-11/12.POST /api/v1/notifier/slack/interactships with Slack signed-request verification +trigger_ididempotency (§5.3 dev 3 resolution).GET /notifier/approve+ signed-URL landing page is the email path (§5.4 status update). Both operate outside the operator-auth boundary using single-use credentials (Slack signed request /notifier_tokensrow).No burst suppression yet.Resolved 2026-05-11. §5.6 below shipped the suppressor; the §5.4 Slack notifier and §5.4 webhook driver both consume it viawith_burst(...), and the flush loop now routes throughHandle::current()so hot-swapped notifiers receive the next flush tick (§10.3 dev 2 resolution).
If 50 blocks fire in a minute from the same (policy_id, p_0), Slack
gets one message with a counter, not 50 messages — "57 more blocks of
this kind suppressed; click for the full list." Threshold and window
configurable per-policy. This is the difference between "approval flow
helps" and "approval flow has been muted by the team."
Status (2026-05-11) — shipped. Delivered:
- crates/proxy/src/notifier/burst.rs —
BurstSuppressorkeyed by(policy_id, p_0). Each bucket holds a sliding window of recent timestamps; once the window count hitsthreshold, subsequent events are dropped and a per-bucketsuppressedcounter accrues with anexemplarsnapshot of the first dropped event. Defaults:threshold=50,window=60s,flush_interval=30s. Events with nopolicy_id(Layer-A invariant breaks, read-filter blocks) bypass the suppressor — they're rare enough that collapsing distinct attack signals would lose information. - crates/proxy/src/notifier/webhook.rs —
WebhookNotifier::with_burst(...)attaches the suppressor;notify(...)consults it before each POST. Newnotify_summary(...)method deliversBurstSummaryenvelopes on a separate schema (proxilion.blocked_action_burst.v1) so receivers can route differently. - crates/proxy/src/server.rs —
build_blocked_notifiernow attaches the suppressor by default and spawns a flush loop that drains every 30s. Startup log line confirmswith burst suppression. - Metrics:
proxilion_notifier_suppressed_total{policy_id}(counter, increments per dropped event);proxilion_notifier_summary_sent_total{policy_id}(counter, increments per delivered summary).
Unit tests (6 new in notifier::burst::tests): passes-through below threshold, suppresses above threshold + drain returns the right summary, separate (policy_id, p_0) keys stay independent, window expiry resets the bucket, missing policy_id bypasses, summary carries exemplar.
End-to-end verification (2026-05-11) via scripts/stress-burst-and-notifier-cli.sh — 13 assertions, all passing:
notifier showreportsnot-configuredwhen env-less, thenconfiguredwith default burst block (threshold=50 / window=60s / flush=30s) after restart withPROXILION_BLOCKED_WEBHOOK_URLset.notifier testPOSTs a synthetic envelope to the receiver — receiver echoes"action": "notifier.test"withpolicy_id == "proxilion.test".- 60 synthetic notifications on the same bucket → receiver sees exactly 50 raw events;
proxilion_notifier_suppressed_totalticks. - After 18s the burst-summary envelope arrives. The body contains
"schema": "proxilion.blocked_action_burst.v1","suppressed_count": 11, and a fullexemplarblock with vendor/action/layer. proxilion_notifier_summary_sent_totalticks once per delivered summary.
Spec deviations to flag.
Per-policy threshold/window not yet honored.Resolved 2026-05-11.PolicyDocnow carries an optionalnotifier_burst: { threshold, window_seconds }block (crates/policy-engine/src/yaml.rs).Engine::burst_override_for(policy_id)returns the override pair; the proxy wires a resolver closure intoBurstSuppressor::with_resolver(...)that consults the live policy engine on everyadmit()call, so apolicy.yamlhot-reload immediately changes threshold/window for in-flight buckets. Both fields are individually optional —threshold: 5alone keeps the default window,window_seconds: 10alone keeps the default threshold. Policies without anotifier_burst:block fall through toBurstConfig::default()(threshold=50, window=60s). Verified by 4 policy-engine tests + 2 burst-suppressor resolver tests + live stress.No "click for the full list" deep link.Resolved 2026-05-12. crates/proxy/src/notifier/burst.rs::BurstSummary::with_details_url populates a percent-encoded<proxy_public_url>/api/v1/blocked?policy_id=<id>[&p_0=<email>]link on every flushed summary. The Slack driver's summary message renders this as an "Open full list" button; the webhook driver carries the field on the JSON envelope (schemaproxilion.blocked_action_burst.v1).with_details_url("")is a no-op so test fixtures and the env-less burst path stay clean. Three new unit tests cover the URL shape (round-trip, empty-base no-op, p_0-omitted-when-absent).- Suppression state is in-process. A proxy restart wipes the buckets. For a multi-replica deployment, suppression is per-pod — bursts get collapsed at each pod rather than across the fleet. Spec.md §13's design partner won't notice; a Redis-backed shared suppressor is the v2 path if we add multi-region.
- Default TTL 30 min, configurable per-policy.
- 5 min before expiry, post an
@herereminder into the thread. - On expiry, the block is finalized as
rejected (expired); the agent has long since received its 403 / 202 timeout response, so this is just bookkeeping for the audit log. - Optional escalation: if no decision in 10 min, send to a backup channel / user. Configurable per-policy.
Status (2026-05-12) — proactive expiry sweeper shipped; reminders + escalation deferred.
- crates/proxy/src/blocked_expiry.rs — new module with
sweep_once(&PgPool)(single SQLUPDATE blocked_actions SET status='expired', resolved_at=now() WHERE status='pending' AND expires_at < now() RETURNING …) andspawn(db, tick_interval, email)background task (theemailhandle drives the escalation reminders added later). Default tick interval 60s. Each expired row gets a structuredtracing::info!line plus aproxilion_blocked_expired_total{policy_id}increment AND aproxilion_overrides_resolved_total{outcome="expired",channel="sweeper"}increment — so a single PromQLsum by(outcome) (rate(proxilion_overrides_resolved_total[5m]))covers the approve / reject / expired triumvirate without joining tables. - Per-tick the sweeper also runs
SELECT count(*) FROM blocked_actions WHERE status='pending'and sets theproxilion_overrides_pendinggauge. That's the gauge the new Grafana dashboard's "annoyance" quadrant pulls — keeps Grafana off the DB entirely. - Spawned in crates/proxy/src/server.rs alongside the policy watcher.
- Existing lazy expiry at
GET /api/v1/blocked(in api/blocked.rs) is retained as a belt-and-suspenders for the moment between sweeper ticks.
Deviations.
- No 5-min
@herereminder. The reminder requires either (a) the original Slackmessage_tsso a thread reply makes sense, or (b) a separate "reminder" Block Kit shape. Both add the same complexity as the §5.3 dev 5 thread-folding work — when that ships, the reminder slots into the same path. The expiry sweep itself, which is the load-bearing bookkeeping piece, ships today. No 10-min escalation to a backup channel.Resolved 2026-05-12. Per-policy escalation now ships end-to-end:- migrations/0012_blocked_escalation.sql adds
blocked_actions.escalation_at+escalated_atwith a partial index on pending-unescalated-due rows. - crates/policy-engine/src/yaml.rs —
RecipientsCfgcarries an optionalescalation_after_minutes: u32.Engine::escalation_after_minutes_for(policy_id)returns the configured value (orNone). - The three Google adapters resolve the policy's escalation deadline on every block and pass it through
BlockedActionRecord::escalation_after_minutes;crates/proxy/src/blocked.rsINSERTsescalation_at = now() + N minatomically with the row. - crates/proxy/src/blocked_expiry.rs — new
sweep_escalations(db, email_handle)runs each tick alongside the existing expiry sweep. Claims due rows viaUPDATE … SET escalated_at = now() WHERE id IN (SELECT … FOR UPDATE SKIP LOCKED) RETURNING …so two replicas can't double-escalate. For each claimed row it builds aBlockedNotificationand callsEmailNotifier::notify_escalation— same body shape as the original send, Subject prefixedREMINDER:, fresh approve/reject tokens minted (the originals may have been consumed mid-deliberation). - Metrics:
proxilion_blocked_escalated_total{policy_id},proxilion_email_send_total{kind="initial|escalation"}(akindlabel was added to the existing email send counter so a single PromQL distinguishes initial vs. escalation deliveries),proxilion_blocked_escalation_sweep_failures_totalfor the rare DB error. - Slack thread reminders (the §5.7 dev 1 5-minute reminder path) ride on the still-deferred
thread_tsplumbing — incoming-webhook URLs don't return amessage_ts, so threading requires switching tochat.postMessagewith a bot token. Tracked. - Verified by 4 new policy-engine tests in crates/policy-engine/tests/per_policy_recipients.rs covering the configured value, absent-when-only-recipients-set, absent-when-no-recipients-block, and unknown-policy paths.
- migrations/0012_blocked_escalation.sql adds
- No
notifierfan-out on expiry. Spec text implies a notification when the row flips to expired. We deliberately don't: the agent has already received its timeout response, the operator's blocked-action message is timestamped enough to explain what happened, and a "the thing you didn't decide on has timed out" message is exactly the kind of channel-noise the burst suppressor exists to prevent. The structured log + metric increment is enough; if a customer wants an outbound expiry notification, the SIEM forwarder (§7) readsaction_eventsand surfaces this without notifier involvement.
The audit log is the customer's data. Proxilion's job is to expose it in formats their tools can ingest.
- HTTP, JSON:
GET /api/v1/actions(already in spec). Paginated envelope, all the filters from §1.6. - HTTP, NDJSON streaming:
GET /api/v1/actions/stream(already in spec). One event per line. SSEevent: actionframing. - HTTP, NDJSON bulk export:
GET /api/v1/actions/export?format=ndjson&since=...&until=.... Returns a streaming response (chunked), one JSON document per line, no pagination cursor. The proxy streams directly from a postgres cursor; memory is O(1) regardless of result size. - HTTP, CSV export:
?format=csv. First line is the column header, documented + stable. - HTTP, JSON Lines (gzip / zstd):
Accept-Encoding: gzip/zstdhonored on the export endpoint. - SIEM forwarder (
spec.md§3.3): proxy-initiated push to a customer webhook for every persisted action_event. Same payload as NDJSON.
The action_event JSON shape is a versioned contract:
{"schema": "proxilion.action_event.v1", ...}. Field additions are
non-breaking (consumers must ignore unknown fields). Field removals or
type changes bump to v2 and v1 continues to be emitted for one minor
release minimum. Documented in docs/audit/schema-v1.md.
Status (2026-05-12) — schema doc shipped. docs/audit/schema-v1.md covers the full top-level field set, the adapter-specific extra keys (request_path_params / to_domain / attendee_domains / pic_audit_violation), the committed CSV column order, sibling schemas (action_event_batch.v1, blocked_action.v1, blocked_action_burst.v1), and the versioning policy (what counts as non-breaking vs. v2-bumping).
Proxilion does not manage retention. The proxy keeps action_events
indefinitely by default; a cron-friendly proxilion-cli actions purge --older-than 90d exists for customers who don't tier to a SIEM. Most
customers tier — the SIEM is the long-term system of record, the proxy is
the queryable working set.
Status (2026-05-12) — purge shipped. Delivered:
- crates/proxy/src/api/actions.rs —
POST /api/v1/actions/purge(scopeactions:purge) accepts{ older_than: <rfc3339>, dry_run?: bool }, refuses cutoffs in the future, and either counts (dry_run=true) or deletes (DELETE FROM action_events WHERE at < $1). Returns{ older_than, dry_run, deleted }. Joined-table rows inaction_event_bodies/quarantined_payloadsretain their own lifecycle (no FK cascade by design — body retention is independent per §6.4). - crates/shared-types/src/operator_scopes.rs — new
actions:purgescope entry in the catalogue. Distinct fromactions:exportso an operator can have read/export rights without retention authority. - crates/cli/src/main.rs —
proxilion-cli actions purge --older-than 90d [--dry-run] [--format json|pretty]. Reuses theparse_sincehelper so the same window syntax works (5m/24h/7d/90d/ RFC3339 absolute). Metrics:proxilion_actions_purged_total{dry_run}.
Cron form: proxilion-cli --token "$PROXILION_OPERATOR_TOKEN" actions purge --older-than 90d.
Request and response bodies are not persisted by default. Only their
hashes go into action_events. The full body is held in the
quarantined_payloads table only when the read-filter quarantined it
(i.e., the customer specifically asked us to). This is a privacy default,
not a feature — opt-in to fuller body persistence is per-policy:
then.audit_body: hash | redact_pii | full.
Status (2026-05-11) — shipped. Delivered:
- migrations/0009_action_event_bodies.sql —
action_event_bodies (request_id PK, mode CHECK in (hash, redact_pii, full), request_hash, response_hash, request_body_b64, response_body_b64, request_bytes, response_bytes, created_at). Joined byrequest_id(not theaction_events.idUUID) so the adapter can insert without awaiting the action_events row's generated id; intentionally NO foreign key so deletes fromaction_eventsdon't cascade — body retention has its own lifecycle (customers will tier audit-body rows to cold storage on a different cadence than the action_events row count). - crates/policy-engine/src/yaml.rs —
PolicyDoc.audit_body: Option<AuditBodyMode>withHash | RedactPii | Fullvariants (kebab/snake-case YAML).Outcome.audit_bodysurfaces the directive to the adapter. - crates/proxy/src/audit_body.rs —
persist(db, request_id, mode, request, response)writes the row.redact_pii_bytes()runs the regex set; binary content (first 256 bytes contain a null) is left intact since pattern-matching binary data is noisy. Redactor patterns: email, US SSN, US phone (10-digit + parens / dots / dashes), credit-card-shaped 13–19-digit runs (no Luhn check — false positives acceptable for redaction; false negatives are not),Bearer <token>,pxl_live_*/pxl_operator_*/sk-*/ghp_*/xox?-*API-key shapes. Order matters: known-token shapes redact BEFORE the digit-pattern redactors so a Slack token's leading 10-digit workspace id isn't pre-redacted by the phone regex (regression caught + fixed during the build-out). - Adapter call sites (Drive / Gmail / Calendar):
if let Some(mode) = outcome.audit_body { crate::audit_body::persist(..., request_id, mode, req_bytes, &final_body).await; }. Skipped entirely when the policy doesn't opt in — the privacy default is unchanged. - crates/proxy/src/api/actions.rs —
GET /api/v1/actions/{id}now includes anaudit_body: { mode, request_hash, response_hash, request_body_b64, response_body_b64, request_bytes, response_bytes }field when a row exists inaction_event_bodiesfor the request_id. Null when the policy didn't opt in. - Metrics:
proxilion_audit_body_persisted_total{mode}andproxilion_audit_body_persist_failures_total{mode}. Both cardinality-bounded by the enum.
Unit tests (9 new in audit_body::tests): email / SSN / phone (four format variations) / credit-card-shaped / Bearer <token> / API-key shapes (4 patterns) / binary input unchanged / text input redacted / SHA-256 hex matches the known vector for "hello".
End-to-end verification (2026-05-11) via scripts/stress-audit-body.sh — 10 assertions, all passing:
- Schema + CHECK constraint enforced (
mode='leak_all'rejected by Postgres). - Three rows (one per mode) round-trip through INSERT + SELECT.
- Hot reload picks up
audit_body: fullandaudit_body: redact_piipolicies. GET /api/v1/actions/{id}surfaces the audit_body object with correct mode + body_b64 + hash; returnsaudit_body: nullwhen no row exists.- Decoupled lifecycle: deleting an
action_eventsrow leaves theaction_event_bodiesrow intact (no FK, no cascade).
Spec deviations to flag.
- Adapter call sites pre-Layer-B and post-Layer-A. The body capture fires AFTER the upstream call completes and ONLY when a policy matched (no policy → skipped). Layer-A blocks (PIC invariant violations) never reach the publish path, so they never get an audit_body row even on a policy with
audit_body: full. This is the right shape: a request that was refused at the chain layer didn't produce a meaningful body to capture, only the predecessor PCA + the refused-ops-set, both already stored inpca_cache+blocked_actions. - No streaming-body support. Bodies are buffered up to
MAX_BODY(10 MB) before capture. Streaming-body redaction is in scope for §15 #6 inspec.md, not this PR. - PII redactor is regex-only. No NER, no PII detection model, no per-customer pattern override. The customer can disable redact_pii and use
full+ their own downstream redactor if their threat model demands one. Pattern catalogue lives inaudit_body.rs::redactors()— adding a new shape is a one-line PR. hashmode persists SHA-256 only. Spec sketch hints at storing per-field hashes (e.g. hash of subject, hash of body). We store request-body and response-body hashes, not field-level. Sufficient for tamper-detection + audit; field-level would be a follow-up if a customer needs to grep audits bysubject_hash.
We do not "alert SIEM." We expose the data so the SIEM can pull it the way the customer's SIEM pulls things:
- Splunk: an HTTP Event Collector forwarder, configured via the
generic webhook (§5.5). Or
splunk forwardconsumingproxilion-cli actions tail. - Elastic: a Logstash / Beats pipeline against
/api/v1/actions/exportpolled on an interval. - Snowflake / BigQuery: a scheduled
proxilion-cli actions export --since 1h --format ndjsondropped on S3 / GCS, ingested by their existing data pipeline. - Datadog Logs: their built-in HTTP poller against
/api/v1/actions.
The customer's data team writes whichever they prefer. Proxilion ships
example configs in ops/siem/ for the top four (Splunk, Elastic,
Datadog, Snowflake).
spec.md§0.5 — Dashboard scaffold (Next.js 15). The scaffold is not built; remove the step. Replace with new §0.5: "Metrics + CLI + notifier crates scaffolded" (see §10 below for the new milestone shape).spec.md§1.6 — Dashboard live action feed + PCA chain inspector. The proxy-side endpoints (/actions,/actions/stream,/actions/:id,/sessions/:id/chain) shipped — they stay. The dashboard React work is dropped. §1.6 becomes "CLI consumes /actions/stream + /actions; ships pretty + ndjson tail."spec.md§2.3 — Block-queue + justified-override dashboard UI. The proxy-side/api/v1/blockedendpoints + override PCA branch logic stay. The Reactdashboard/app/blocked/page.tsxis replaced by the Slack/email/webhook flow described in §5 here.spec.md§4.3 — Static marketing site. Unchanged — that's proxilion.com, not the product UI. (Already shipped insite/.)
- The PIC chain math, OAuth interception, read-filter, policy engine,
Trust Plane integration, federation, killswitch design — every
protocol-level piece of
spec.mdis unchanged. - The HTTP API surface (
/api/v1/*) is unchanged and grows by exactly the endpoints below.
| Method | Path | Purpose |
|---|---|---|
GET |
/metrics |
Prometheus exposition |
GET |
/api/v1/actions/export |
NDJSON / CSV streaming bulk export |
POST |
/api/v1/blocked/:id/approve |
Approve (Slack / email / CLI / webhook all funnel here) |
POST |
/api/v1/blocked/:id/reject |
Reject |
POST |
/api/v1/notifier/slack/interact |
Slack interaction webhook (signed-body verified) |
GET |
/api/v1/notifier/approve |
Email signed-URL landing page (HTML) |
POST |
/api/v1/notifier/test |
Send a synthetic test notification to verify wiring |
GET |
/api/v1/policy |
List policies + modes (drives proxilion-cli policy list) |
POST |
/api/v1/policy/reload |
Force hot-reload |
POST |
/api/v1/policy/:id/mode |
Flip a single policy's mode |
POST |
/api/v1/policy/simulate |
Replay history against a candidate YAML |
POST |
/api/v1/killswitch/:scope/:id |
Killswitch (already in spec §3.2; re-listed) |
Status (2026-05-11) — notifier_config shipped (webhook driver). Delivered:
- migrations/0010_notifier_config.sql —
notifier_config (id PK CHECK in (webhook,slack,email), enabled, config JSONB, updated_at, updated_by). v1 ships only thewebhookdriver row;slack/emailrows are reserved for §5.3 / §5.4. - crates/proxy/src/notifier/handle.rs —
NotifierHandlewrapsArc<ArcSwap<Option<Arc<WebhookNotifier>>>>. All consumers (adapters viaAdapterState.notifier, public approve flow viaNotifierApiState.notifier, blocked-action notify viapersist_and_notify) callcurrent()per use;replace()atomically hot-swaps. - crates/proxy/src/api/notifier.rs —
GET /api/v1/notifier/config(scopenotifier:read) reads the row + redactshmac_key(echoeshmac_key_set: boolinstead) and the URL (url_redacted).POST /api/v1/notifier/config(scopenotifier:write) validates{ driver: "webhook", config: { url, hmac_key } }, builds a newWebhookNotifier, persists to DB, then callsNotifierHandle::replace(...). Returns 400 on missing url / missing hmac / invalid hex / unknown driver. - crates/proxy/src/server.rs — startup calls
build_notifiers(&cfg, &core.db, ...)(later generalized from the single-webhookbuild_notifier_handleto the webhook + Slack + email bundle). The handle's initial value is resolved byresolve_webhook_config: DB row wins (whenenabled=trueAND bothurl+hmac_keyare present); env vars are the fallback for the no-DB bootstrap path. - crates/cli/src/main.rs —
proxilion-cli notifier set-webhook --url <u> --hmac-hex <h> [--disabled]POSTs to/api/v1/notifier/config.proxilion-cli notifier configGETs the redacted view. - Metric:
proxilion_notifier_config_changes_total{driver}per successful set.
Unit tests (3 new in notifier::handle::tests): starts-none, replace swaps in new (clones see the same swap), replace-to-none clears.
End-to-end verification (2026-05-11) via scripts/stress-notifier-config-hotswap.sh — 14 assertions, all passing:
- Schema + CHECK constraint enforced.
proxilion-cli notifier set-webhookpersists + hot-swaps without restart.- After setting URL=A,
notifier testPOSTs to A. After setting URL=B, the nextnotifier testPOSTs to B; receiver A's POST count is unchanged. notifier configGET redactshmac_key(showshmac_key_set: true) and the URL.set-webhook --disabledclears the active notifier;notifier test→ 412.- Four negative paths (unknown driver / missing url / missing hmac / short hmac) → 400.
- After full proxy restart with env vars cleared, the DB-stored row is loaded and the notifier is operational — confirms DB-first bootstrap.
Spec deviations to flag.
Only theResolved 2026-05-12.webhookdriver is exposed today. The migration's CHECK acceptsslackandemailfor forward-compat, butset_configreturns 400 for those drivers until §5.3 / §5.4 ship.api/notifier.rs::set_configdispatcheswebhook→set_webhook,slack→set_slack,email→set_email; all three drivers ship per §5.3 / §5.4 deviations above. The 400 branch is reserved for genuinely unknown drivers.Flush loop captures the initial notifier instance.Resolved 2026-05-12. crates/proxy/src/server.rs — both the webhook and Slackspawn_flush_loopcall sites now clone the correspondingHandle<T>(Notifiers::webhook/Notifiers::slack) and resolvehandle.current()inside the per-tick closure, so a subsequentwebhook.replace(...)/slack.replace(...)viaPOST /api/v1/notifier/configdirects the next flush to the live notifier rather than the one captured at spawn time.current().is_none()(driver disabled mid-flight) cleanly skips the tick.One caveat that remains by design: theCaveat resolved 2026-05-12.BurstSuppressorinstance is still the one created at boot — a new notifier built from a config change doesn't carry burst suppression unless paired with the boot-time suppressor.Notifiersnow carries optionalwebhook_burst: Option<BurstSuppressor>andslack_burst: Option<BurstSuppressor>fields.build_notifiersconstructs both suppressors at function entry (even when no driver is configured at boot, so first-time-via-API drivers still get suppression) and parks them on the bundle; the flush loops are spawned unconditionally and become no-ops when the correspondingHandle::current()isNone.set_webhook/set_slackin api/notifier.rs re-attachstate.notifiers.{webhook,slack}_burst.clone()to every freshly-built notifier before storing it on the handle. BecauseBurstSuppressor::bucketsisArc<Mutex<…>>, cloning shares state — bucket counters and exemplars now survive config swaps. The flush-tick closures still resolveHandle::current()inside the closure, so hot-swap routing of summaries is unchanged.
-- 0010_notifier_config.sql (shipped)
CREATE TABLE notifier_config (
id TEXT PRIMARY KEY CHECK (id IN ('webhook','slack','email')),
enabled BOOLEAN NOT NULL DEFAULT true,
config JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_by TEXT
);
CREATE TABLE notifier_tokens (
token_id UUID PRIMARY KEY,
blocked_id UUID NOT NULL REFERENCES blocked_actions(id) ON DELETE CASCADE,
action TEXT NOT NULL, -- "approve" | "reject"
approver_hint TEXT, -- email or slack user id
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ
);
-- 0006_operator_tokens.sql
CREATE TABLE operator_tokens (
id UUID PRIMARY KEY,
token_hash BYTEA NOT NULL UNIQUE, -- SHA-256 of pxl_operator_*
name TEXT NOT NULL,
scopes TEXT[] NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ
);
-- blocked_actions gets two new columns
ALTER TABLE blocked_actions
ADD COLUMN slack_trigger_id TEXT, -- idempotency key for Slack
ADD COLUMN notification_channels TEXT[]; -- e.g. ["slack","email"]crates/
proxy/ # unchanged, plus the new endpoints
policy-engine/ # unchanged
cli/ # GROWN — was a stub; now the primary UI
src/
main.rs
commands/
actions.rs
blocked.rs
policy.rs
pic.rs
killswitch.rs
notifier.rs
clients.rs
metrics.rs
trust_plane.rs
output.rs # pretty / json / ndjson / csv writers
http.rs # thin axum-client wrapper
shared-types/ # extended with notifier + cli DTOs
metrics/ # NEW — Prometheus exporter facade + OTLP
src/
lib.rs # re-exports + registry
labels.rs # cardinality-bounded label types
notifier/ # NEW — Slack / email / webhook drivers
src/
lib.rs
slack.rs # Block Kit message build, signed-request verify
email.rs # SMTP + signed-URL token issuance
webhook.rs # generic outbound + signed-request verify
template.rs # single-purpose approve.html (no JS framework)
burst.rs # de-dupe / batching
Wraps prometheus-client (Rust's modern crate, OpenMetrics-aware) and
opentelemetry-otlp behind a single metrics::counter!(...),
metrics::histogram!(...) facade. Internally registers each series at
process start (cardinality bound checked at registration; runtime label
values that don't match the registered set are dropped with a warn-once
log, never silently allowed to blow up cardinality).
A driver model:
#[async_trait]
pub trait Notifier: Send + Sync {
async fn notify_block(&self, block: &BlockedAction) -> Result<NotificationHandle>;
async fn resolve(&self, handle: &NotificationHandle, outcome: Outcome) -> Result<()>;
fn name(&self) -> &'static str;
}Drivers: SlackNotifier, EmailNotifier, WebhookNotifier. The proxy
holds Vec<Box<dyn Notifier>> and fans out (configurable per policy).
Approval comes back through POST /api/v1/blocked/:id/approve regardless
of which driver delivered the notification — that handler is driver-agnostic.
The pivot doesn't change the overall M0–M5 envelope; it changes what "M1 done" and "M2 done" look like.
| Milestone | Weeks | Outcome (revised) |
|---|---|---|
| M0 — Foundation | 1 | Workspace, CI, dev compose stack with Trust Plane (unchanged). cli crate scaffolded with status, selftest, version. metrics crate scaffolded with /metrics endpoint exposing a hello-world counter. |
| M1 — Drive read path end-to-end | 3 | OAuth → bridge → Trust Plane → OAuth interception → Drive read → PCA chain audited. proxilion-cli actions tail / list / show / export works end-to-end. /metrics exposes adapter + PIC + policy counters. No React dashboard. |
| M2 — Gmail write gate + override | 2 | Block + Slack-mediated override loop closed; override creates attested PCA branch. proxilion-cli blocked list / approve / reject works. Email + generic-webhook drivers ship alongside Slack. |
| M3 — Killswitch + stream + invariant enforcement | 1 | Runtime-gate enforces; NATS stream; killswitch revokes session's PCA issuance right. SIEM forwarder ships. proxilion-cli killswitch works. |
| M4 — Calendar + harden | 1 | Helm chart, marketing site (already shipped), public repo, recorded demo (terminal + Slack, no browser). |
| M5 — First design partner | 9+ | One real org running Proxilion in front of Claude managed agents, Okta-federated. |
Step 1.6 (revised) — proxilion-cli actions surface + Prometheus
/metrics
Phase: M1
Goal: Operator can search history, tail live actions, export bulk
audit, and read PIC chain inspector output entirely from a terminal.
Customer's Grafana scrapes /metrics and renders the bundled dashboard.
Files: crates/cli/src/commands/actions.rs,
crates/cli/src/output.rs, crates/metrics/src/lib.rs,
crates/proxy/src/metrics.rs, crates/proxy/src/api/export.rs,
ops/grafana/proxilion.json, ops/datadog/monitors.tf.
Prerequisites: Steps 1.1–1.5.
Acceptance:
proxilion-cli actions tailstreams live events with sub-second latency from proxy → terminal.proxilion-cli actions list --since 24h --decision block --format ndjsonreturns paginated NDJSON.proxilion-cli actions export --since 30d --format ndjson --compress zstproduces a >100 MB streaming export with constant proxy-side memory.proxilion-cli actions show <id>renders the full PCA chain ASCII-art (root→leaf, hop / p_0 / ops diff / signature ✓-or-✗) equivalent to the dropped React inspector.curl https://localhost:8443/metricsreturns Prometheus exposition format with all metrics from §3.2 populated.ops/grafana/proxilion.jsonimports cleanly into Grafana 10+ and renders the four panels of §3.4.
Step 2.3 (revised) — Block notification + signed-URL approval + attested PCA branch
Phase: M2
Goal: When an action is blocked, an authorized approver receives a
Slack interactive message (or signed email link, or webhook), clicks
Approve, and the agent's blocked request resumes against an
attested override PCA branch.
Files: crates/notifier/, crates/proxy/src/api/blocked.rs
(handler logic; endpoints already in spec), crates/proxy/src/pic_executor.rs
(override PCA branch construction — unchanged from spec §2.3),
crates/proxy/static-approval/approve.html (the one server-rendered page).
Acceptance:
- Slack interactive message arrives within 5s of a Layer-B block.
- Clicking Approve in Slack creates an override PCA whose provenance
links to BOTH the blocked PCA and the approver's
PCA_op_origin. - Email path: signed URL works on iOS Mail (tested), single-use, expires correctly.
- Webhook path: outbound signed POST + inbound signed POST round-trip
works against a
httpbin-style harness. proxilion-cli blocked approve <id> --justification "..."produces the identical override PCA as the Slack click would (the CLI is a thin wrapper over the same endpoint).- Burst suppression: 50 blocks/min collapse to 1 thread with counter.
Editor preservation forResolved 2026-05-12. Skipped bothpolicy.yaml. YAML round-trip without trashing comments needs a CST-aware library. Options: vendoryamlfmtlogic, depend onserde_yaml::Value+ a separate comment-attachment pass, or shell out toyq. Decision: prototype in M0 withyq, replace if it shows up in profiles. Tracked.yq(external-binary dependency on the proxy's hot path) and a full CST parser in favor of a line-oriented in-place edit:set_modelocates the<dash_indent>- id: <policy_id>line, finds (or inserts) the top-levelmode:field at the matching field indent, and rewrites only that one line. Comments above and below the field, key ordering, blank lines, the trailing# inline commenton the edited line itself, and the rest of the bundle survive byte-for-byte. The legacyserde_yaml::Valueround-trip remains as a defensive fallback for exotic YAML shapes the line walker doesn't recognize (anonymous mapping at the top level, block flows, etc.). Both paths validate the result by parsing into anEnginebefore the atomic swap, so a mangled edit can't take down the live policy set. Implementation: crates/proxy/src/policy_handle.rs::edit_mode_in_yaml plus four new tests (set_mode_preserves_comments_and_ordering,set_mode_inserts_when_field_absent,set_mode_does_not_match_nested_mode_key,edit_mode_in_yaml_not_found_falls_through_to_serde) that pin the guarantee.proxilion-cli policy editis the natural follow-on ergonomic — the$EDITORwrapper called out in §4.1 dev "policy edit" — and is now unblocked.Slack workspace vs Slack app distribution. Customers self-host Proxilion; do they each install a per-workspace Slack app, or do we ship a public Slack app they install once with workspace-scoped tokens? Latter is friendlier; depends on Slack's review process for a security app. Open.Resolved 2026-05-12 (decision: per-workspace install for v1). The shipped Slack driver (crates/proxy/src/notifier/slack.rs) uses an incoming webhook URL + signing secret, both workspace-scoped — customers self-install a Slack app in their own workspace and feed the proxy the two strings viaproxilion-cli notifier set-slack. No public Slack-app listing is needed and no Slack-app-review process gates customer onboarding. A future public Slack-app distribution remains a fine v2 option if we ever ship a hosted Proxilion offering — at which point workspace-scoped bot tokens (xoxb-…) via OAuth replace the incoming-webhook URL. Until then the per-workspace install is the right shape for the self-hosted-only product.Email DMARC alignment. Customers' SMTP relays vary wildly. Document the three known-good configurations (Postmark, SES, internal relay) inResolved 2026-05-12. docs/install/email.md ships the three known-good configurations (AWS SES, Postmark, internal Postfix/OpenDKIM) with concretedocs/install/email.mdrather than try to be smart.proxilion-cli notifier set-emailcommands per relay, DNS-record cheat sheet (DKIM CNAME / SPF / DMARC TXT), a troubleshooting table mapping symptoms →smtp_url/ sender signature / DMARC alignment / metric reasons, and an explicit "what we deliberately do not do" section (no proxy-side DKIM, no bounce-handling — the relay owns those).Multi-tenant approver mapping. §5.3 hand-waves "map Slack user ID to operator identity." For an org with hundreds of approvers, this needs SCIM or an IdP-group sync. Out of scope for v1; document.Resolved 2026-05-12. v1 ships the staticnotifier_config.slack.user_mapshipped under §5.3 dev 4 (U_SLACK_ID → operator_email) as the small-team path; the larger-org SCIM / IdP-group sync path is documented end-to-end in docs/install/multi-tenant-approvers.md. The doc walks the three sizing tiers (<25, 25–250, 250+ approvers), gives a concrete Okta-SCIM → Postgres sync pattern (cron + JSON bulk-upsert intonotifier_config.slack.user_map), explains why we chose static map over live IdP lookup on the interaction hot path (Slack's 3-second response budget), and lists the metrics + audit columns operators should watch (slack_interact_total{result="why"}is the canary for "approvers don't know who they are").The one-HTML-page exception. §5.4's mobile approve page is the single React-less HTML file Proxilion serves. We should be principled about not letting it grow into a SPA. Lint rule:Resolved 2026-05-12. .github/workflows/static-html-no-js.yml runs on every PR + push tofind crates/proxy/static-approval -name '*.js'must be empty in CI.mainand fails CI if anything other than.html/.csslands in crates/proxy/static-html/, if a<script src=...>is ever added, or if a multi-line inline<script>block creeps in. Inline CSP boilerplate and trivial single-line scripts pass; SPA-shaped drift fails loudly. (The directory name diverged from the spec sketch —static-htmlvsstatic-approval— the workflow tracks the actual path.)OTLP push vs scrape. Default isResolved 2026-05-12 (decision: scrape-only for v1)./metricsscrape. Some customers (Datadog Agent, Grafana Cloud) prefer push. The exporter facade supports both; defaulting to scrape avoids egress firewall conversations.crates/proxy/src/main.rsinstallsmetrics_exporter_prometheus::PrometheusBuilderand the proxy exposes/metricson the same listener as/healthz— no OTLP push, no separate egress conversation, no extra port. The customer's Grafana / Datadog / VictoriaMetrics scrapes the endpoint via their existing service-discovery story (one of the shipped Grafana dashboard imports inops/grafana/proxilion.jsonis the canonical scrape target). OTLP push lands when a real customer asks for it: trivial to add behind a config flag (themetricsfacade is exporter-agnostic), but until then shipping unused egress code violates the §0 "no speculative features" rule.CLI vs API auth tokens — same or different? Likely same scheme (Resolved 2026-05-12. Same scheme, scopes-per-route. The CLI sendspxl_operator_*), different scopes. Confirm in the M0 implementation.Authorization: Bearer pxl_operator_<52 base32>against the same/api/v1/*surface a third-party HTTP client would hit (crates/proxy/src/operator_auth.rs);parse_token+ thepxl_operator_prefix +BODY_LEN=52are the single source of truth. Scopes are exact-match strings on the token row (policy:read,blocks:approve,killswitch:revoke,actions:export,pca:read,tokens:admin, …) checked per route viaoperator_auth::scope_check/require_scope;*is the bootstrap-admin wildcard. CLI and API differ only in what scopes you'd typically issue them — a CI bot might holdactions:read,pca:read; an on-call operator holdsblocks:approve,policy:write. The CLI itself is a thin HTTP client; no special-cased CLI-only auth path exists.
Proxilion sits in the OAuth path between your hosted AI agents and your SaaS providers. Every action the agent takes is bound to a cryptographic chain rooted at the human user. The chain is verifiable forever. When the agent tries to do something outside that user's authority — read a finance doc, send an external email, share a file — Proxilion either blocks it or asks a human to approve. The human gets a Slack message; one click. The audit log is yours, in NDJSON, streamed to your SIEM. Metrics in Prometheus, drawn by your Grafana. The only thing we don't ship is another dashboard.
Three surfaces. No tab to babysit. Outcome as a service.