MCP upstream proxy — enforcing tools/call mode (P61e review-spec)¶
Status: shipped (assay v3.24.0, proxy-enforce); this review-spec is kept as the P61e design of record. This is the heaviest risk class in the proxy line. It extends the shipped, opt-in manifest-observation proxy (P61a–d, assay v3.23.0) from observe-only to enforcing: it forwards a privileged tools/call only after a fail-closed policy decision. Tracked as #1624. The binding decisions are agreed (§16, "Resolved decisions"); this is the design of record. The arc is complete and shipped: P61e-b (the deny-all enforcing run mode), P61e-c1 (caller-allowance), P61e-c2 (credential-scope), P61e-c3 (the drift gate + the first allow/forward path), and P61e-d (the assay.enforcement_decision.v0 per-call evidence record). The enforcing proxy forwards a privileged tools/call only after a clear allow from every gate (classification, caller-allowance, credential-scope, drift) — the narrowest such path — records every decision as evidence, and keeps the forwarded call's side effect asserted, never proven.
The one-line scope: the manifest-observation proxy answers "did this tool surface change?"; the enforcing proxy answers "should this specific privileged call be forwarded, right now, for this caller?" — and defaults to no.
The review sentence to keep: P61e turns tool-call forwarding into an enforcing, fail-closed decision point. It does not make the proxy an authorization server, does not prove side effects, and does not allow observe-only privileged forwarding.
0. Why this is its own arc¶
P61b–d deliberately never forward tools/call (not even observe-only) — a credential-bearing proxy that relays privileged calls without a blocking decision is the textbook confused deputy. P61e is the only slice that crosses that line, so it opens, all at once: caller authorization, upstream credential use on a caller's behalf, a policy decision before forwarding, confused-deputy prevention, proxy_denied semantics, the interaction with manifest drift, and the interaction with the side-effect evidence ladder. Each is specified below; none may be skipped.
1. The decision (Q1): enforce, or never forward?¶
Recommendation: forward a privileged tools/call only enforcing and fail-closed, never observe-only. Observe-only forwarding of a privileged call is exactly the trap P61a rejected and stays rejected. Manifest-observation mode remains the default and unchanged; enforcing mode is a separate, explicit opt-in run mode — not a flag on the same proxy, because a flag reads as a small variation and this is a different risk class.
Mode shape (decided): a distinct enforcing subcommand.
assay-mcp-server proxy observe ... # manifest-observation (the shipped P61a–d mode)
assay-mcp-server proxy enforce ... # enforcing tools/call (P61e)
proxy observe / proxy enforce subcommands; if the clap layout makes nesting awkward, two top-level subcommands proxy-observe / proxy-enforce. Either way the enforcing mode is its own command, never implicit. A deployment that does not run the enforcing mode keeps P61b–d's behavior (every tools/call → proxy_unsupported). Implementation note (v0, P61e-b): the shipped CLI spelling is the top-level proxy-enforce subcommand (a sibling of the existing proxy), chosen so the shipped proxy --upstream-command … invocation is untouched and the enforcing slice stays the smallest possible change in a security-load-bearing PR. The design concept is unchanged — an explicit, separate enforcing proxy mode; folding it into nested proxy observe / proxy enforce is a later ergonomic change, not a v0 concern.
2. Claim, non-claims, boundary¶
Claim (when shipped): in an explicit enforcing mode, the proxy applies a deterministic, fail-closed policy decision before forwarding a privileged tools/call, denies the call when the decision is not a clear allow, and records the decision as evidence.
Boundary sentence: the enforcing proxy decides whether to forward a call against a declared policy; it does not prove the call's side effect occurred, does not verify the upstream's behavior, and does not classify maliciousness.
Non-claims: - not an authorization server: it mints no tokens and runs no OAuth flow of its own; - not a sandbox: an allowed call runs at the upstream with the upstream's own authority; - a forwarded-after-allow call's side effect stays asserted, never proven (the E9 ladder applies); - allow is a policy decision, not a safety guarantee; deny is fail-closed caution, not a maliciousness verdict; - no behavior verification, no descriptor semantic analysis, no LLM judge.
3. Threat model (SOTA, June 2026)¶
- Confused deputy. A proxy holding a powerful upstream credential is induced by an inbound caller to make a privileged upstream call the caller itself was not authorized for. The MCP-specific form is acute when a proxy uses a static client identity, allows dynamic client registration, and forwards without per-caller consent. Mitigation: §5 (per-caller authorization) + §8 (credential boundary).
- Post-approval drift (the rug-pull threat). The tool the caller approved may have mutated since approval (OWASP MCP03 post-approval descriptor mutation via
tools/list_changed; see mcp-manifest-drift.md and the E12 lifecycle experiment). A call to a tool whose contract digest drifted since approval is invoking something other than what was approved. Mitigation: §9 (drift-aware enforcement) — genuinely the novel part of this design. - Excessive agency / ambient authority. The proxy's reach must not silently become the caller's reach. Mitigation: §5 + §8.
- Credential exfiltration / token passthrough. Inbound caller auth must never be forwarded to the upstream, and the upstream credential must never leak into evidence. Mitigation: §8.
4. Architecture: a policy decision point before forwarding¶
MCP client ──► assay-mcp-server (enforcing proxy) ──► upstream MCP server
│ observe (P57 tool-decision classification; P60/E12 manifest-drift state)
│ DECIDE (PDP: allow / deny, fail-closed, BEFORE forwarding) ← P61e
│ forward (only on allow; the upstream runs with its own credential)
└─ record (enforcement decision evidence, separate carrier)
The PDP runs on every tools/call, not only on privileged-classifiable calls — "unclassified" is not "safe", so an unclassifiable call is exactly the fail-closed case. The decision logic:
- the call is classified privileged (P57c) → evaluate the privileged policy gates (caller allowance, credential scope, drift) and forward only on a clear allow;
- the call is unclassified, or classification is incomplete → deny unless an explicit non-privileged allow rule matches; an unclassified call never passes through by default.
Non-tools/call methods keep the manifest-observation behavior (handshake, ping, tools/list); everything else stays proxy_unsupported. For P61e-b (below) the whole tools/call surface is deny-all; the allow path arrives only in P61e-c.
5. Caller authorization (the confused-deputy core)¶
The decision to forward a privileged call must consider who is calling and what they are allowed to do — not merely "the proxy can reach the upstream."
- Caller identity is declared, not inferred (decided). Over stdio there is no transport identity, so v0 is one caller per stdio session, declared via explicit config — never inferred from the transport, no multi-caller, no OAuth, no token introspection. The shipped
--enforce-policyshape (c1 caller/allowances + the c2upstream_credentialblock):A missingcaller: id: "ci-agent" upstream_credential: # c2: the single credential the proxy holds alias: "gh-deploy" # referenced in evidence by alias, never by value scopes: ["repo:deploy_key:write"] # checked against the action's required scope allowances: - action_class: "github_deploy_key" # the only class with a matcher so far targets: - { owner: "acme", repo: "prod-app" } # exact owner+repo; no wildcardcaller.idfails startup (not a runtime deny — see §14), sounknown_callercannot occur at runtime under the static-config model. Multi-caller is a later arc. The allowance usestargets:(notallowed_effects:); anaction_classother thangithub_deploy_keyhas no verifiable matcher in c1 and is fail-closed (no_declared_allowance) until its own gate slice. - Per-caller consent/authorization. The proxy keeps a per-caller registry of allowed privileged action classes/targets (operator-declared, consistent with the declared tool surface, P58). A privileged call with no matching per-caller allowance is denied, never forwarded.
- Ambient authority is not caller authority. The proxy's upstream credential never becomes the caller's authority: the allowance is keyed to the caller, not to the proxy's reach.
- No silent surface mixing. Assay's own tools and the upstream's tools are never merged into one list a caller cannot distinguish.
PDP inputs (all deterministic, no LLM): caller identity; the P57c-classified action + projected target; the declared per-caller allowance (P58); the credential scope the call would use (P59 credential-scope); and the manifest-drift state of the invoked tool (§9).
6. Fail-closed (the core invariant)¶
If the PDP cannot reach a clear allow — policy load error, classifier failure, unknown caller, inconclusive manifest-drift state, missing per-caller allowance, insufficient credential scope — the privileged call is denied and not forwarded. A proxy that fails open is worse than no proxy. "Inconclusive" is a deny, never a silent allow.
7. proxy_denied semantics¶
proxy_denied (the code reserved in P61b, now used) is returned for a policy denial, distinct from upstream_error (the upstream said no), proxy_failed (the proxy could not complete), and proxy_unsupported (method not handled in this mode). It carries data.origin: "assay-proxy" and a machine reason from the pinned set:
unknown_caller classification_incomplete
unclassified_tool_call no_declared_allowance
credential_scope_insufficient credential_scope_unknown
manifest_baseline_missing manifest_current_observation_incomplete
manifest_observation_ambiguous manifest_drifted_since_approval
policy_unavailable policy_error
credential_scope_unknown is distinct from credential_scope_insufficient: when coverage cannot be determined the denial reason is unknown, not insufficient (see §8). Reason history across the slices. P61e-b used a single enforcing_mode_deny_all; c1 superseded it with per-gate reasons (unclassified_tool_call → classification_incomplete → no_declared_allowance), and while c1/c2 had no allow path a fully-cleared call denied with a temporary pdp_gate_unavailable. c3 removes both: enforcing_mode_deny_all and pdp_gate_unavailable are retired and no longer emitted — a call that clears every gate is now forwarded, and every deny is a real gate reason.
8. Credential boundary (locked from P61a, extended)¶
- No token passthrough by default. Inbound caller auth is never forwarded to the upstream (the standing no-passthrough invariant from P61b).
- Upstream credential from operator config only, referenced by alias in evidence, never by value; raw key material is never stored.
- Least privilege on the upstream credential (ties to P59 credential-scope): the proxy should hold only the scope the deployment legitimately needs, and a call requiring more than the declared credential scope covers is denied (
credential_scope_insufficient). Hard non-claim: declared credential scope coverage is policy metadata, not a provider-verified token grant — there is no token introspection. When coverage cannot be determined, the call is denied withcredential_scope_unknown, never silentlycredential_scope_insufficient(an unknown is not an insufficiency). This gate is P61e-c2 (shipped): it reads the policy'supstream_credential.scopesagainst the action'srequired_scope_for(category)through a deterministic lattice that matches the authoritative credential-scope contract exactly (repo:deploy_key:writeis covered only byrepo:deploy_key:writeandrepo:admin). The enforcement gate is never broader than that documented contract —repo:write, for instance, does NOT cover, and any scope not in the contract is unrecognized →credential_scope_unknown. Overbroad (admin breadth) still covers and is a recommendation, never a block in v0; an absent credential, an unrecognized scope, and a non-covering scope deny. - Audience binding (concept-aligned, RFC 8707 / OAuth 2.1). The proxy must not let a token or credential issued for one audience be borrowed to reach another; it does not upgrade or broaden caller authority. v0 need not implement token minting, but it must state and enforce that the proxy does not broaden authority.
9. Drift-aware enforcement (the novel contribution) — P61e-c3, shipped¶
Enforcement is tied to the manifest-drift state, connecting P60 (manifest drift), E12 (tool lifecycle), and P61e (enforcement): a privileged call to a tool whose contract digest changed since the caller approved it is invoking something other than what was approved — the post-approval-mutation payoff. The drift gate needs both of two evidence inputs, and is fail-closed without either:
- the approval-time baseline comes from a declared approved manifest artifact only — the
assay.declared_mcp_manifest.v0baseline (or a future approval record that references it), never the first observed complete manifest of the session. A first-observed baseline can already be a post-rug-pull state, so pinning it and calling it "approval-time" would pin the wrong state. The baseline is the required--declared-mcp-manifeststartup input (a missing/invalid/wrong-schema baseline fails startup, like the policy); a baseline that lacks the invoked tool →proxy_denied(manifest_baseline_missing); - a current complete observed manifest is required — the proxy reads it from its own observed
tools/list(P61c). If the session has no complete current observation, or the invoked tool is absent from a complete one, the drift state isproxy_denied(manifest_current_observation_incomplete), because you cannot compare against a baseline without a current complete view of that tool.
Two observation-integrity rules close the rug-pull window (both fail-closed): - a post-approval tools/list_changed invalidates the current complete observation — the surface may have mutated, which is exactly the rug-pull signal, so the proxy will not authorize against the prior manifest until a fresh complete tools/list is re-observed (manifest_current_observation_incomplete until then). Observing a clean manifest then receiving a list_changed must never leave a stale digest authorizing a privileged call; - a duplicate-name (ambiguous) observed manifest is inconclusive — when the observed tools/list has colliding tool names the manifest is status: ambiguous (its manifest_digest is withheld; see mcp-manifest-drift.md), so the gate denies manifest_observation_ambiguous rather than pick one of the colliding per-tool digests. Likewise, a duplicate-name declared baseline fails startup (an approval baseline must be unambiguous — no first-match-wins).
The comparison is the per-tool tool_digest, looked up by tool name in both the baseline and the current observed manifest (the same canonical P60 projection on both sides; server id is not part of the per-tool digest). It compares the tool's contract, never the call arguments (those are the allowance gate). An exact digest match clears the gate; any difference is manifest_drifted_since_approval.
With both in hand: - current contract digest equals the declared approved baseline → the drift gate is satisfied (other gates still apply); - digest drifted since approval → proxy_denied (manifest_drifted_since_approval) — a hard deny; - drift state inconclusive (partial/unobserved, per the E12 boundary) → fail-closed deny, because "no drift observed" is not "no drift."
pending_tool_manifest_review is review-layer language, not a runtime proxy response; v0 is hard-deny (see §16-C). The proxy checks runtime manifest evidence on the call path instead of relying solely on prior review.
10. Side-effect evidence interaction¶
A forwarded-after-allow call's side effect stays asserted unless independently verified — the E9 ladder (asserted < observed_confirmed < audit_record_bound) is unchanged. The enforcement record notes the proxy allowed and forwarded the call; it never claims the upstream performed or persisted the action. Allowing a call and proving its effect are different, and the proxy only does the first.
11. Enforcement decision record (separate carrier) — P61e-d, shipped¶
The assay.enforcement_decision.v0 artifact, emitted by the enforcing path and kept separate from the manifest-observation artifact (the standing observation/enforcement separation). It records, per tools/call: caller id, the P57c-classified action + projected target (sensitive ids hashed) + target digest, the decision (allow/deny), the machine reason, the fail_closed flag, the derived drift_state (satisfied on allow / the specific drift reason / not_evaluated when an earlier gate denied), and the credential alias (never the token or the declared scopes). It records the policy decision only — it does not assert the side effect, and it deliberately carries no transport-outcome field: because it is written before the forward (so an allowed call is never forwarded unrecorded), it cannot honestly claim delivery, so an allow is the decision to forward and never a claim that the call reached or was performed by the upstream. A forward that then fails surfaces to the caller as proxy_failed (§12), never as a delivery claim in the record.
Implementation (P61e-d): opt-in via --enforcement-decision-out <path>; the proxy appends one compact JSON record per decision (NDJSON), for both allow and deny, so a killed proxy still keeps the decisions recorded so far. The record is deterministic (no timestamp). The diagnostic tracing line from c1–c3 stays as operability output; this artifact is the canonical evidence carrier. Safety rule (see §12): a record-write failure on an allowed call fails closed — the call is not forwarded (proxy_failed / enforcement_record_write_failed), never a silent unrecorded forward; a deny stands regardless (a missing deny-record is a completeness gap, logged, not a safety gap).
Name discipline (no overlap with the existing carrier): - assay.enforcement_health.v0 = mechanism / runtime-capability state (was enforcement active, did the mechanism attach) — the existing carrier; - assay.enforcement_decision.v0 = the per-call proxy policy decision (allow/deny + reason) — this new one. They never overlap: one is "could the mechanism enforce", the other is "what did the proxy decide for this call".
11a. Denied-call observation record (separate carrier)¶
The assay.denied_call_observation.v1 artifact is an optional sibling carrier for the caller-visible proxy denial surface. It exists for post-hoc review of attribution claims such as "the proxy issued an Assay proxy denial for this call", without turning the observation into a policy verdict. The record carries the called tool name, the classified target digest when classification produced one, the Assay deny code -31999, origin: assay-proxy, the machine reason, and a sha256: digest of the exact JSON-RPC response line the proxy built for the caller, without its trailing newline. "Caller-visible" therefore means that line: the proxy writes the record before it queues the line for its stdout, so the record does not show that the line was written or that the caller read it.
Assay-owned application codes sit outside JSON-RPC's reserved -32768..=-32000 band. Discriminator is error.data.origin = assay-proxy, not the integer alone:
| Meaning | Old (do not emit) | New | Observation |
|---|---|---|---|
| unsupported | -32040 | -31997 | none |
| failed | -32041 | -31998 | none |
| denied | -32042 | -31999 | assay.denied_call_observation.v1 / -31999 / assay-proxy |
| upstream reserved elicitation | -32042 (not Assay) | -32042 unchanged | none |
An upstream reserved -32042 (MCP URL elicitation) is relayed value-equivalently and does not mint an Assay observation. Live producer output is valid under assay evidence verify-privileged-mcp-action --profile-version v1. The verifier default stays v0.
Implementation: opt-in via --denied-call-observation-out <path> in proxy-enforce; the proxy appends one compact NDJSON record for each answered proxy_denied tools/call. The carrier is not written for allowed calls, and with the flag absent no file is created. A write failure is logged but does not change the denial response: the call is already fail-closed, and a missing observation record is a completeness gap, not a reason to synthesize a different verdict.
Non-claims: this carrier does not decide policy, does not assert the upstream side effect, does not certify safety or maliciousness, and does not replace assay.enforcement_decision.v0. It also does not show that the response line reached the caller, or what the caller's MCP client presented to its application or model after reading it; nothing in this record observes either step. A consumer that wants to bind a caller-visible proxy denial to a policy decision must join this observation record to a digest-bound assay.enforcement_decision.v0 deny record for the same tool and target digest.
Correlation (forward-looking, not shipped): the observation record carries no request-id, so a record is correlated to its call and to an upstream observation by order + content. An optional, caller-supplied, opaque per-call correlation id — echoed by the proxy, never minted by it (so the record stays deterministic), validated to a bounded safe shape before it lands (so the id can't smuggle content into the evidence), and carrying no transport claim — is specified as the clean join-key in enforcement-decision-correlation-id.md. It is additive within the current observation schema, not a further schema bump.
12. Failure semantics (extends P61a)¶
- PDP cannot decide →
proxy_denied(fail-closed), never forwarded; - upstream unreachable on an allowed call →
proxy_failed(the decision stands; the forward failed); - malformed upstream response → never trusted (as P61a);
- the enforcement record (P61e-d) is written for both allow and deny when
--enforcement-decision-outis set; a per-call record-write failure on an allowed call fails that call closed (proxy_failed/enforcement_record_write_failed), so it is never a silent unrecorded forward; a denied call's record-write failure is logged but the deny still stands (the call is fail-closed either way). The per-call streaming record fails the individual call closed rather than exiting the whole session.
13. What enforcing-v0 is NOT¶
No token minting / OAuth authorization-server behavior; no sandboxing of the upstream; no behavior verification; no descriptor semantic analysis; no maliciousness classification; no LLM judge; no HTTP-upstream or multi-upstream (still stdio, one upstream, as P61a); no change to the manifest- observation artifact shape.
14. PR slicing¶
P61e-a this review-spec (design doc, no code)
P61e-b the enforcing run mode exists and is DENY-ALL: every tools/call -> proxy_denied
(enforcing_mode_deny_all), the upstream receives nothing, the allowlisted list methods still
forward, an unknown method is proxy_unsupported. NO PDP skeleton, NO inputs, NO allow path.
(SHIPPED, superseded by c1.)
P61e-c split gate-by-gate, each fail-closed and independently tested, NO forwarding before c3:
c1 caller-allowance gate (deny-only): --enforce-policy with caller.id + per-caller allowances
(P58); the PDP classifies each tools/call and denies with the first-failing-gate reason
(classification gate before allowance); a fully-allowed call is still denied
(pdp_gate_unavailable). github_deploy_key {owner, repo} only; no wildcard. (SHIPPED.)
c2 credential-scope gate (deny-only): the call's required scope vs the declared credential scope
(P59); credential_scope_insufficient / credential_scope_unknown. Still no forwarding. (SHIPPED.)
c3 drift-aware gate (declared baseline + current complete manifest, P60/E12) + the FIRST allow /
forward path: a call that clears every gate is forwarded; pdp_gate_unavailable is removed.
(SHIPPED.)
P61e-d the assay.enforcement_decision.v0 record + the side-effect-evidence interaction (asserted),
kept separate from the observation artifact; a downstream consumer reads it separately. (SHIPPED:
opt-in --enforcement-decision-out NDJSON, one record per decision, allow+deny, fail-closed on
an unrecordable allow.)
Implementation note (P61e-c1): the enforcing subcommand now requires --enforce-policy <path> (a YAML file with caller.id and the caller's allowances). A missing, unreadable, or malformed policy — or one without caller.id — is a startup failure (non-zero exit), never a runtime deny: an enforcing proxy that cannot decide is a misconfigured service and must not start. Caller identity is the static caller.id only (no transport/env inference), so unknown_caller cannot occur at runtime. The PDP precedence is classification → allowance → pdp_gate_unavailable; the classification gate runs before allowance so a missing-target call reads as classification_incomplete, never as a target mismatch. The proxy emits a per-decision tracing line (caller, action class, a domain-separated target digest, reason) for operability only — it is a diagnostic log, not the canonical assay.enforcement_decision.v0 evidence artifact (that is P61e-d). c1 still forwards no tools/call.
Implementation note (P61e-c2): the PDP gains a third gate after the allowance match — the credential-scope gate. It reads the policy's upstream_credential.scopes against the action's required_scope_for(category) (P59) through a deterministic lattice that matches the authoritative credential-scope contract exactly — NOT the richer E10 measurement vocabulary. The enforcement gate is never broader than the documented contract (repo:deploy_key:write is covered only by repo:deploy_key:write and repo:admin; repo:write is unrecognized and does not cover), so broadening it is a deliberate, contract-and-fixture-backed change, never a guess. Precedence is now classification → allowance → credential-scope → pdp_gate_unavailable. Coverage maps to: covered (exact or admin-breadth — overbroad still covers, a recommendation not a block) → fall through; recognized but non-covering → credential_scope_insufficient; absent credential / unrecognized scope → credential_scope_unknown (an unknown is not an insufficiency). Fail-closed: an unknown required scope denies (credential_scope_unknown), never a silent pass. Only github_deploy_key has a documented lattice today; any other classified class is fail-closed credential_scope_unknown until its own contract slice lands (and is unreachable anyway — c1's allowance matcher only admits github_deploy_key). The policy grows an optional upstream_credential: {alias, scopes} block; the alias is reserved for the P61e-d evidence record and is not read by the gate. (In c2 a call that cleared the credential-scope gate was still denied with the temporary pdp_gate_unavailable; c3 replaces that with the drift gate and the allow path.)
Implementation note (P61e-c3): the PDP gains the fourth and final v0 gate — drift — and the first allow/forward path. The enforcing subcommand now ALSO requires --declared-mcp-manifest <path> (the assay.declared_mcp_manifest.v0 JSON baseline); a missing/unreadable/malformed/wrong-schema baseline fails startup, exactly like the policy — in enforcing mode both inputs are required, so the proxy never "enforces" without an approval baseline. The drift gate compares the invoked tool's approved baseline tool_digest against the current observed tool_digest (read from the proxy's own complete tools/list observation), denying manifest_baseline_missing (tool absent from the baseline), manifest_current_observation_incomplete (no complete current observation of the tool), or manifest_drifted_since_approval (digests differ). Precedence is classification → allowance → credential-scope → drift → ALLOW. A call that clears every gate is forwarded to the upstream and the upstream's reply is relayed verbatim — the single, narrowest allow path (exact allowance + covered credential + an approved baseline whose per-tool digest exactly equals the current complete observed digest), and the only place a privileged tools/call is forwarded. pdp_gate_unavailable is gone. The per-decision diagnostic tracing line now also carries decision=allow|deny and forwarded; it remains operability-only, NOT the canonical assay.enforcement_decision.v0 record (that is P61e-d).
Startup diagnostic contract: after argument parsing and before the upstream starts, an invalid enforcement policy, declared manifest, or establish budget exits non-zero with empty stdout and one top-level JSON object on stderr. This event is emitted independently of ASSAY_LOG, because callers use it for recovery rather than log filtering:
{"event":"startup_failure","reason_code":"proxy_enforce_policy_invalid","next_step":"Check --enforce-policy and retry proxy-enforce."}
The stable startup reasons are proxy_enforce_policy_invalid, proxy_declared_manifest_invalid, and proxy_establish_budget_invalid. The ordinary human error chain may follow on stderr. Clap errors such as a missing required flag or a non-numeric budget happen before logging and retain clap's exit-2/text contract. A startup failure is never encoded as pre-session JSON-RPC, converted into a runtime deny, or downgraded to observe-only mode.
P61e-b is deliberately just a deny-all enforcing proxy — no policy decision point, no inputs, no allow path. It lands the enforcement boundary so "fail-closed" and the proxy_denied-vs- proxy_unsupported distinction are testable the moment the mode exists (the same discipline as P61b's negative-forwarding test), without building any of the gate machinery yet.
P61e-b tests (mirror P61b's negative-first discipline):
proxy enforce: a tools/call -> proxy_denied (enforcing_mode_deny_all); the upstream receives nothing
proxy enforce: an unknown method -> proxy_unsupported (not proxy_denied)
proxy enforce: an allowlisted list method (tools/list) still forwards
proxy observe: a tools/call still -> proxy_unsupported (the modes stay distinct)
proxy_denied (policy denial, enforcing mode) cleanly separate from proxy_unsupported (method not handled). 15. Design rules (binding for the arc)¶
- enforcing mode is explicit opt-in; the default stays manifest-observation;
- the PDP runs before forwarding; an unclear decision is a deny;
- ambient (proxy) authority never becomes caller authority; per-caller allowance required;
- no token passthrough; no broadening of caller authority; upstream credential by alias only;
- a privileged call into a tool that drifted since approval is denied (hard-deny in v0; review-mode is a later, explicit opt-in), never silently allowed, and the baseline is a declared approved manifest;
- the PDP runs on every
tools/call; an unclassified call is denied, never passed through by default; - a forwarded call's side effect stays asserted; allowing is not proving;
proxy_deniedis distinguishable from upstream/proxy_failed/proxy_unsupported, with a machine reason;- enforcement evidence is a separate carrier from observation evidence.
16. Resolved decisions¶
- A. Mode shape — decided: a distinct enforcing subcommand (
proxy enforce, alongsideproxy observe), never an--enforceflag on the same proxy. A flag reads as a small variation; this is a different risk class (§1). - B. Drift baseline source — decided: the approval-time baseline comes from a declared approved manifest artifact only (
assay.declared_mcp_manifest.v0, or a future approval record referencing it), never the first observed session manifest. Enforcement also requires a current complete observed manifest; without either, the drift gate is a fail-closed deny (manifest_baseline_missing/manifest_current_observation_incomplete). You cannot enforce drift against a baseline you never established (§9). - C. Review-mode vs hard-deny — decided: hard-deny by default.
pending_tool_manifest_reviewis review-layer language and is not a runtime proxy response in v0; the proxy is on the call path and must return a concrete decision. A--decision-mode reviewis a later, explicit opt-in, not v0 (§9). - D. Caller identity source — decided: one caller per stdio session, declared via explicit config (
caller.id); no inferred transport identity, no multi-caller, no OAuth, no token introspection. A missingcaller.idfails startup (non-zero exit, before the proxy starts — see §14), so under this static one-caller-per-stdio-session modelunknown_callercannot occur at runtime. (Theunknown_callerreason stays reserved in the §7 pinned set for a future multi-caller arc where caller identity is per-request rather than static-config.) (§5, §14) - E. Scope of v0 — decided: stdio + one upstream + deny-all (P61e-b) then narrow allow (P61e-c); no token minting, no OAuth resource-server behavior, no HTTP upstream, no multi-upstream (§13).
One-line recommendation, now locked: explicit opt-in proxy enforce subcommand, fail-closed deny-all first (P61e-b), then a narrow allow gated by declared caller allowance + credential-scope + drift-since-approval against a declared approved baseline with a required current complete manifest, a separate enforcement-decision record (P61e-d), and the side effect kept asserted. P61e-a (this spec) is approved; P61e-b code is the next step, not part of this doc.