OWASP MCP Top 10 assessment
Assessment date: 2026-10-08. Scope: the unified mcp-devtools repository,
including server enforcement, control and console capabilities, deployment
examples, and release workflows. This is a source and test-coverage review,
not a penetration-test result or a report of freshly executed tests.
The authorization architecture already implements the central recommendation in the reviewed articles: evaluate calls at runtime, including the actual outbound request. The highest-value follow-up is to tighten the Git execution boundary, test adversarial workflows, validate deployment prerequisites, and keep security claims aligned with implementation. A replacement authorization architecture is not indicated by this review.
Read this alongside the gateway threat model, control and console threat model, and MCP security best practices map. The governance framework map re-indexes the same controls and tests by NIST AI RMF subcategory, ISO/IEC 42001 Annex A control and EU AI Act article. Configuration remains the authority for settings and defaults; this assessment introduces no settings or behavior changes.
Evaluation of the sources
Section titled “Evaluation of the sources”Axiomatics, How authorization fits in the OWASP MCP top 10 (2026-04-22) provides a useful runtime-authorization architecture. Its claim that authorization mitigates six categories should not be read as eliminating those risks. Authorization cannot itself prevent credential leakage, govern servers that bypass enforcement, or constrain data after an authorized response reaches an agent. This repository’s call admission and outbound enforcement already implement the central architectural recommendation.
Equixly, Mapping NSA’s MCP guidance to the OWASP MCP Top 10 (2026-06-04) is most useful as a source of adversarial tests: changed tool metadata, malicious retrieved content, expired tokens, forbidden calls, and cross-user leakage. Adapt its logging advice to this product: raw parameters can contain credentials and customer data. Preserve the existing audit design of identities, decisions, bounded metadata and digests, rather than logging every argument or response. Metadata hashing can detect changes but cannot establish that tool behavior or content is safe.
The OWASP MCP Top 10 project identifies its current list as beta. Use it as a coverage taxonomy, not a certification or a claim of complete protection. Labels are evolving: the landing page calls MCP06 prompt injection via contextual payloads, while its linked entry and Equixly use intent flow subversion. Both concern malicious retrieved context steering subsequent actions. Record the assessment date when updating this mapping.
Control coverage and prerequisites
Section titled “Control coverage and prerequisites”“Implemented” means code and regression evidence exist. It does not mean the
control is active in every deployment. Authenticated HTTP mode requires its
signed policy, revocation generation and audit prerequisites. Local mode
defaults to MCP_AUTH_MODE=off; without configured policy and journaling it
does not provide those governance guarantees.
| Risk | Implemented control | Deployment prerequisite | Residual risk and responsible component |
|---|---|---|---|
| MCP01 Token mismanagement and secret exposure | Evaluation manifests/terminal reports export only digests, allowlisted status and independent verdict metadata (manifest::tests::manifests_are_stable_sensitive_to_inputs_and_content_free, runtime_tests::runtime_refusal_requires_terminal_gateway_and_destination_evidence); Evaluation-only: the pinned SDK receives only the gateway bearer in a cleared environment and the provider credential stays at the gateway (runtime_tests::runtime_cli_ignores_poisoned_parent_credentials_configuration_and_plugins); The local guard deployment example specifies a dedicated account, systemd credentials, a private cache and suppressed content-bearing logs, pending host qualification; CLI shutdown and empty output are covered by test_supervision.py::test_actual_cli_sigterm_stops_worker_and_emits_no_content; Skills serve only an embedded allowlist of reviewed static files, never credentials, configuration or vendor data; a packaging scan refuses secret-shaped content and dotfiles (skills_packaging_tests); Local guard uses a separate bearer, omits the repository token from the model process, disables access/model logs and stores only verified artifacts (test_local_guard.py::test_worker_never_inherits_provider_or_repository_secrets, test_local_guard.py::test_http_auth_complete_subject_and_content_not_retained, test_artifacts.py::test_redirect_strips_repository_token_and_streams_file). Operator key rotation uses owner-only staging files and redacted status; the recovery record binds the environment file by digest and never copies its credentials (rotation::recovery_record_holds_no_environment_contents); the browser receives only public keys and references (devtools-control rotation tests); Log redaction and the content guard share one secret detector that redacts the union of every pattern’s spans, so overlapping matches can no longer leave a URL password in a log line (secret_detector_invariants_tests, logger tests); a write argument carrying a credential-shaped string is refused before dispatch under a schema 3 content rule, and neither the refusal nor the journal repeats it (content_guard_tests); Explicit native credential stores, encrypted Linux Secret Service sessions, payload-free backend diagnostics and no mock fallback (devtools-identity keychain tests); Jira binary downloads reuse vendor credentials and owner-bound artifact storage; cross-origin redirects strip authentication (jira_download_tests); Tasks store hashed owner/generic-reference bindings, recoverable opaque proposal IDs, bounded minimized approval results and bounded owner-bound background tool results (oversize results go to owner-only artifacts); no credential or argument fields (task_wire_tests, background_task_tests); Jira multipart uploads reuse vendor credential resolution and pass metadata-only manifests to egress policy; Explicit operator-file Vault imports keep temporary writer tokens in memory and redact provider errors; JWT issuer, audience, signature and lifetime validation; separate inbound and vendor credentials; secret adapters and snapshot-only nested NinjaOne password/TOTP references; local demo token ignored by Git; log redaction; PostgreSQL connection warnings omit credentials and server message/detail/hint (postgres_connection_tests); HTTP diagnostics exclude credentials, queries and literal path parameters; TLS audit receiver errors exclude raw URLs; restricted audit content; the shipped WAF never targets credentials and its log omits request and response headers and bodies; pending candidate downloads are admin-only and non-cacheable |
The inference path adds the provider credential itself (x-api-key or a bearer) and strips the caller’s Authorization, x-api-key and cookie headers, so the inbound token has no path to the model provider; the credential is a server setting resolved like a vendor credential at startup and never journaled or logged; prompt and completion bytes transit the process and are never persisted in the journal, telemetry or logs, and with a content rule a credential in a prompt is refused before forwarding and one in a completion is stopped before relay (inference_role_tests::client_bearer_never_reaches_upstream, inference_guard_tests::secret_in_prompt_is_denied_before_upstream, ::sensitive_values_never_reach_storage_or_logs); the engine webhook token is a server setting resolved per call and never logged; Configure the intended identity provider, narrow vendor credentials, secret access and rotation |
Credential lifetime and upstream permissions remain operator/vendor responsibilities; authorization does not remove secrets from process memory |
| MCP02 Privilege escalation via scope creep | Explicit ONNX CUDA selection never changes guard error policy; CPU-node fallback requires operator opt-in and execution retry is disabled (test_onnx_gpu.py::test_session_provider_and_fallback_contract); Bounded guard recovery never retries subjects or changes operator error policy (inference_guard_tests::supervised_local_guard_failures_preserve_gateway_policy); Reading or activating a skill grants nothing: skills ship no allowed-tools or scripts (refused at load), and every tool call made while following one passes normal admission, policy and approvals; a skill is visible only when its tools are on the session’s surface (skills_wire_tests, application::skills::tests); The trained ONNX gateway test locks operator actions and error policies (inference_guard_tests::trained_local_model_gateway_preserves_policy_actions_and_failures). Optional local guard preserves default-deny errors and refuses partial completion windows (inference_guard_tests::webhook_marks_complete_subjects_and_refuses_partial_completions); hardware ineligibility never removes a rule. Rotation verifies the old signed set, preserves policy/revocation bytes, rejects stale preparation and refuses rollback over newer revocations (rotation::rollback_refuses_to_discard_newer_revocations); policy activation refuses while the replacement private key is in the directory the gateway’s settings point into (rotation::policy_activation_refuses_while_the_private_key_is_in_the_rotation_directory), and activation after a failed rollback is refused so a retry cannot re-apply an abandoned key (rotation::a_failed_rollback_is_resumed_only_by_rollback); CI polling stops on any policy refusal, including a transport-marked error (ci_wait::tests); Schema 3 content rules narrow what an allowed write may carry: the decoded arguments are inspected after admission and before dispatch, and a finding denies, holds for approval with the findings digest bound into the hold key, or labels; a guard that cannot inspect follows the rule’s on_error, deny by default; what a subject cannot do (redact on a prompt, hold on a result or completion) and what a flow or budget cannot say (spend_usd, an unknown class or vendor) are load errors, never silent (content_guard_tests, policy_engine_tests::schema_v3_*, hold_key_tests); flows rules deny a vendor call or a prompt once the verified owner’s taint classes (environment classes of upstreams it acted on, classes content rules labelled) satisfy when.owner_has, keyed by owner and never by session, bounded and refuse-not-forget; budgets reserve per owner or per tenant at admission under one lock, all or nothing, and an exceeded budget denies or holds (flow_policy_tests, run_budget_tests, policy_engine_tests::flow_rule_denies_prompt_after_prod_read, adversarial_workflow_tests::prod_logs_read_in_one_session_cannot_leave_through_another, adversarial_workflow_tests::runaway_tool_loop_is_held_at_its_budget_not_run); Jira binary downloads authorize the canonical GET and remain unclassified/fail-closed under enforcing file policies (jira_download_obeys_egress_denial_before_dispatch); The CI wait tools add only single-run GET reads (/repos/*/*/actions/runs/*, /projects/*/pipelines/*, the existing CircleCI /workflow/*) to the native inventory, and every poll is re-authorized at egress; teamcity_wait_build stays unmapped like teamcity_get (ci_wait_tests, native::tests::only_known_routes_and_tools_are_reads); Tasks updates only acknowledge input; linked cancellation journals a distinct requester withdrawal before projection; no task grants approval; background execution runs only allow-listed tools after full admission, and refusals and approval holds never become background work (unified_call_approvals_tests::approval_tasks, background_task_tests); Jira attachment writes reuse canonical POST authorization and owner-bound artifact reads; Operator imports use a unique policy limited to planned KV paths with a ten-minute non-renewable writer and attempted revocation/policy deletion; HTTP clone and browser capability exclusion; browser handles bound to the opening principal with per-owner and global caps admitted before launch; call admission and canonical outbound-request checks; conservative resource extraction; revocation; exact-call approvals; digest-checked policy review before administrative approval |
Narrow resource policies and deliberate credential selection; configure approval rules for sensitive actions | Broad allow rules still grant broad authority. Extractors do not understand every vendor operation. Policy authors and vendor owners must review permissions |
| MCP03 Tool poisoning | The Skills extension is opt-in, live and fail-closed, advertised only in server/discover; initialize, disabled and legacy sessions are unchanged; skill content and digests are compiled in and immutable per build (skills_wire_tests); Native jira_download_attachment and preview guidance are golden-locked (tool_surface_matches_golden); The four native *_wait_* CI tools are read-only/idempotent and golden-locked (tool_surface_matches_golden); Tasks discovery is startup-gated; creation and methods require protocol 2026-07-28 or later and negotiated capabilities; legacy methods return method-not-found while legacy initialize stays unchanged; custom gates and task storage failures retain approval responses (task_wire_tests, unified_call_approvals_tests::approval_tasks); Opt-in client-credentials advertisement uses an explicit authenticated HTTP session flag and live configuration, failing closed on invalid reloads; legacy initialize and stdio exclude it (oauth_tasks_compatibility_tests); The native jira_upload_attachment surface and jira_post guidance are golden-locked; Compiled tool surface, stdio golden and HTTP clone/browser-exclusion regression tests; a fixed six-tool browser mapping that never re-exposes upstream tools; explicit Bitbucket PR-update PUT guidance regression; release integrity controls |
Review tool-surface changes and verify release artifacts; clients pin approved identities and capabilities | Golden tests do not establish client trust across upgrades or make vendor content safe. Server maintainers and agent-runtime owners share responsibility |
| MCP04 Supply chain attacks | Evaluation-only: SDK/CLI/Python pins, source/configuration/fixture/policy trial digests, hashed Python lock, advisory audit and an independent Linux loopback-only runtime CI lane (SDK execution pending; scoped root-owned AppArmor helper provisioning required) (compatibility record); The excluded evaluation crate has exact shared pins, a root-version lock drift check (binary_tests::eval_lock_resolves_shared_crates_to_root_versions) and its own CI advisory gate; ONNX CPU and CUDA wheel groups are mutually exclusive and hash-locked; model artifact pins are unchanged. Every guard replacement repeats artifact verification; missing/corrupt offline bundles and incompatible warmup are terminal (test_supervision.py::test_recovery_reverifies_corrupt_offline_cache_without_retry, test_supervision.py::test_permanent_artifact_and_load_failures_do_not_restart); Skills add no dependency (rmcp =3.8.0, sha2, serde_norway already pinned); the image build copies the reviewed skills/ tree, and the allowlist must equal it with no symlinks (skills_packaging_tests); The trained ONNX profile pins every artifact with SHA-256, including tokenizer, config and license/notice (test_evaluation.py::test_reviewed_profiles_pin_every_file_with_sha256); source-weight numerical comparison and provenance limits are recorded in CPU validation. Local classifier runtime is pinned in a separate uv lock; artifact manifests pin commits, sizes and hashes, verified before loading (test_local_guard.py::test_cache_tampering_refuses_offline_and_repairs_online). Patched cryptography, TLS and zstd dependencies; Askama config parsing disabled to remove unmaintained basic-toml; Removed the deprecated zbus credential chain; exact-pinned keyring-core/native stores with vendored libdbus, whose C sources require separate upstream monitoring and binary rebuilds (see docs/keychain-backends.md); Exact dependency pins, Cargo lockfile, dependency/advisory gates (the self-rooted fuzz lockfile held to root versions and its fuzz-only crates advisory-gated), weekly Dependabot version updates with a seven-day cooldown, SHA-pinned workflow actions, source SBOM and release signatures; source-content fingerprints scoped per container build target; the WAF image pinned by digest, one pin across every deployment path |
The inference path and the OTLP/JSON telemetry exporter add no dependency: streaming, SSE parsing and the OTLP wire format are written over the pinned reqwest/axum graph; the SSE scanner and trace validator are fuzzed (sse_usage, trace_context); Verify artifacts and workflow identity; keep deployments patched |
Signing identifies provenance, not absence of vulnerabilities; build-host and signing-identity compromise remain trust-base risks. Routine macOS CI is opt-in via MCP_CI_MACOS=true while hosted billing is blocked; Linux/Windows gates remain required, and release builds still require both macOS architectures. CUDA qualification now binds warmup placement to the actual worker PID and checks no replay under owned-worker faults; host selection and partial-result retention are synthetic-tested (test_onnx_gpu.py::test_qualification_harness_on_synthetic_cpu_graph, test_qualification.py::test_failed_fault_phase_preserves_completed_evidence). NVIDIA hardware and disruptive staging tests remain unverified; see qualification record. |
| MCP06 Prompt injection and intent subversion | Recovering/unready guards return errors without replay or empty allow verdicts (inference_guard_tests::supervised_local_guard_failures_preserve_gateway_policy); The shipped github-pr-readiness skill is read-only and instructs the agent to treat pull request text and diffs as untrusted data and to match CI to head.sha; skills are instructions, not authority; Opt-in local classification uses complete subjects and overlapping token windows; oversized inputs refuse without disabling the model (test_local_guard.py::test_real_onnx_download_warmup_windows_and_offline_restart, test_local_guard.py::test_window_limit_fails_instead_of_truncating). The explicitly selected trained ONNX profile has a curated CPU evaluation, including measured false positives and misses (record); production quality remains unverified (test_real_model.py::test_trained_cold_download_http_and_offline_reuse). A steered follow-up write that carries a credential the injected content planted is refused by a schema 3 content rule even when policy allows the write itself, with zero upstream requests (prompt_injection_tests::injected_secret_in_tool_result_cannot_be_posted_by_follow_up_write); detectors read decoded text, so JSON escapes are no bypass (content_guard_tests::json_escaped_secret_in_write_argument_is_denied); a tool_result rule inspects every representation of a result before the client, the task record or an artifact sees it and can refuse, redact or label it (content_guard_tests::tool_result_label_and_redact_actions, ::oversized_structured_result_is_inspected_before_the_overflow_artifact_is_written, background_task_tests::background_task_result_is_guarded_before_spill); prompt and completion rules inspect model requests and responses in flight, over decoded text and a streaming lookback window, and stop a finding before it is forwarded or relayed (inference_guard_tests::secret_in_prompt_is_denied_before_upstream, ::secret_split_across_deltas_terminates_the_stream, ::buffer_mode_releases_nothing_on_finding); Jira binary downloads expose metadata only and label retrieved contents untrusted; archives are never interpreted (jira_download_tests); Page content returned by the browser tools is bounded, control-character scrubbed and labelled untrusted in the tool text; authorization limits subsequent actions; write freshness and approvals limit selected high-risk calls; a steered follow-up call that policy denies makes zero upstream requests (prompt_injection_tests); multi-step workflows across sessions and both paths (a planted credential carried into an allowed write, an encoded secret sent to a model after a labelled read, production logs copied out from a second session) are refused with no upstream request and no adversarial text in the journal (adversarial_workflow_tests::confluence_injection_cannot_carry_its_planted_credential_into_a_write, adversarial_workflow_tests::tool_result_secret_cannot_reach_a_model_even_encoded, adversarial_workflow_tests::prod_logs_read_in_one_session_cannot_leave_through_another) |
Narrow tool/resource policy and suitable approvals; agent runtime treats retrieved content as untrusted | No general prompt-injection detection or intent validation, and cross-call tracking is by class, not content: an allowed read of unclassified, unlabelled content followed by an allowed write transfers it and is only journaled unless a content rule’s detectors recognise it. Detection is probabilistic and over decoded text: credential-shaped strings (nine built-in patterns) or what an external engine reports; base64 blocks and binary artifacts are not decoded, and bytes a stream relayed before a finding are gone. Agent-runtime owner must test whether the agent follows injected instructions |
| MCP05 Command injection and execution | Evaluation-only: a dedicated process group, bounded output/time/turns and fixed executable paths; cancellation kills owned descendants (runtime::tests::cancellation_timeout_and_leader_exit_kill_owned_descendants); ONNX CUDA uses the existing bounded worker; provider absence refuses before artifact fetch (test_onnx_gpu.py::test_unavailable_cuda_refuses_before_artifact_fetch). GPU libraries/device confinement require host qualification; Guard cleanup signals whole worker process groups even after leader exit and completes under repeated cancellation (test_supervision.py::test_repeated_cleanup_cancellation_kills_descendants_after_leader_exit, test_supervision.py::test_cancellation_during_spawn_joins_and_reaps_child); the hardened systemd example supplies cgroup containment for supervisor death/escaped sessions, pending staging validation; Skill URIs and cursors are parsed by a fuzzed canonical grammar (no traversal, percent-encoding, dotfiles or delimiters) and resolved by exact in-memory lookup with no filesystem or network access (skill_uri_invariants_tests, fuzz/fuzz_targets/skill_uri.rs); The trained profile adds only the observed exact CDN origin us.aws.cdn.hf.co, retaining credential stripping (test_artifacts.py::test_trained_model_cdn_strips_repository_authorization). Local model downloads use exact HTTPS origins, public DNS, bounded redirects/bytes and private partial files (test_artifacts.py::test_redirect_rejects_foreign_origins_plaintext_and_credentials, test_artifacts.py::test_dns_resolution_refuses_private_addresses, test_artifacts.py::test_size_limits_apply_without_content_length). Local rotation uses systemctl only at /usr/bin or /bin (never PATH) through the shared no-shell subprocess helper, validated unit names, a single verified EnvironmentFile and loopback-only health probes; filesystem inputs are operator-owned and the transition log is opened without following a symlink (rotation::transition_log_symlink_is_refused_and_its_target_untouched); NinjaOne explain renders its EXPLAIN prefix from typed flags over the unchanged single-SELECT guard, inside the read-only transaction (ninjaone_db::tests::explain_is_still_guarded_before_connecting, ninjaone_db::tests::explain_statement_renders_only_typed_flags); Jira downloads use numeric IDs, generated artifact paths and bounded redirects; opaque archives are never extracted or executed (jira_download_tests); CI wait tools reuse single-segment owner/repo/project validation, accept only u64 run/pipeline/build ids and a strict CircleCI workflow UUID, and build every URL through the vendor client; typed transport failures retry only status reads within the bounded schedule, each poll repeats egress authorization, and permanent errors/policy refusals end the wait (ci_wait_tests, ci_wait::tests); Tasks use a private operator-selected directory, fixed filenames, parameterized SQL, one lifetime lock, pristine-only first-boot recovery, CLI/store isolation, non-poisoning capacity refusals and verified WAL/FULL durability (tasks::sqlite::tests, task_lifecycle_tests, tasks::slow_worker_tests); Jira attachment uploads reuse single-segment identifiers and validated filenames, never client paths; Vault import helper validates operator-set mount/prefix and uses HTTPS or loopback HTTP, rejects redirects, and invokes no shell; TLS audit destinations reject user-info, paths, queries and fragments; stdio-only MCP clone and browser inventory and dispatch, shell-free subprocess invocation, Git option separator, clone URL bound to the requested host and repository, transport pinned to SSH/HTTPS, Git redirects refused, allowlisted Git and browser-runtime environments, clone slug checks and optional target confinement; browser URLs restricted to http/https, a bounded line transport that refuses oversized child output before parsing, per-request deadlines with kill, no JavaScript or shell capability mapped |
The inference upstream is one operator-set origin (https, or http on loopback, no credentials, query or fragment), validated at startup; the client’s route is appended to it as one of five fixed paths and nothing from the body reaches a URL; the body is parsed as JSON once for model and forwarded as bytes; a bounded traceparent is forwarded only after validation and a malformed one is digested, never echoed (inference_role_tests::traceparent_is_validated_kept_verbatim_and_garbage_is_digested, roles_tests::half_configured_inference_path_refuses_startup); the prompt extractor and the streamed-completion scanner are fuzzed parsers over attacker-controlled bytes (prompt_text_invariants_tests, sse_scanner_invariants_tests, fuzz targets prompt_text and sse_scanner) that read, never rewrite, and the one outbound dependency on the request path, the engine webhook, is a single operator-set origin reached through the shared HTTP facade with a bounded timeout and answer (inference_guard_tests::webhook_timeout_follows_rule_on_error); Set BITBUCKET_CLONE_ROOT when cloning is needed; install and pin the Playwright runtime and browsers yourself; limit filesystem, process and network authority |
Git’s and the browser’s network access do not pass the HTTP policy boundary and produce no egress record (the outcome record names the destination each was handed); the user’s own Git, SSH and browser installation are trusted. Process and network containment of the browser is a deployment control, which is why the browser tools are stdio-only |
| MCP07 Insufficient authentication and authorization | Evaluation-only: real SDK MCP and inference requests bind to one authenticated owner (runtime_tests::runtime_routes_both_paths_under_one_owner_with_isolated_credentials_and_tools); Guard health and inspection stay bearer-authenticated during supervision (test_local_guard.py::test_http_auth_complete_subject_and_content_not_retained); Skills methods share one gate: live opt-in, protocol 2026-07-28, and the validated HTTP principal (or local identity where tools allow it); missing or forged identity is 401 and legacy sessions stay owner-bound (skills_wire_tests::http_*); Local classifier endpoints require a distinct bearer on loopback (test_local_guard.py::test_http_auth_complete_subject_and_content_not_retained). Rotation inventory uses the authenticated admin boundary; browser rotation is an operator handoff with no mutation endpoint (configuration_admin_tests, unified_console_tests::rotation_console_is_an_authenticated_operator_handoff_without_private_keys); Jira binary artifacts retain caller ownership through streaming and chunk reads (zip_larger_than_preview_limit_is_saved_intact_and_owner_bound); Tasks and approval hold indexes bind access to the existing issuer/tenant/subject owner and reuse fresh HTTP authentication/revocation (task_wire_tests); HTTP startup validates the client-credentials opt-in once and requires inbound OIDC; EMA and client-credentials opt-ins reject conflicting vendor settings; token/grant admission remains unchanged (config_tests, auth_mode_tests, admin_principal_kind_tests); Import bootstrap requires an explicitly supplied administrator credential and preserves read-only runtime tokens; Authenticated HTTP, fail-closed startup prerequisites, scope checks, principal-bound sessions and runtime policy; tenant-scoped administrative candidate reads; a default-on WAF (ModSecurity, OWASP CRS, blocking, validated Helm body caps and full inspection or rejection) in front of every shipped deployment path as defense in depth |
A held inference request (a prompt rule or a budget with action: hold) waits on the same call-approval gate as a tool call, under a hold key that digests the exact body bytes and the findings, so only the same request re-issued after a second person’s approval runs; the signed revocation list refuses the subject on /v1/* and /mcp within one poll and leaves its taint marks in place (unified_call_approvals_tests::inference_hold_round_trip, revocation_tests::revoked_subject_is_refused_on_both_paths_and_keeps_its_taint); The inference routes sit behind the same bearer validator, revocation list, generation and rate limiter as /mcp with the mcp:inference scope, bind to the token’s owner, verify an optional Mcp-Session-Id against that owner (a foreign or unknown id is the same 404 as /mcp gives) and never create or bind a session; admission is the signed bundle’s models rules with no permissive default, including by verified principal kind; the routes exist only with a configured upstream and never on the control role (inference_role_tests::inference_request_binds_to_the_token_owner, ::inference_request_with_a_session_id_of_another_owner_is_refused, ::model_rule_denies_before_any_upstream_request, roles_tests::inference_routes_mount_only_with_an_upstream_and_never_on_control); Use authenticated mode for shared service access; configure issuer, audience, signed generation and audit; keep the server reachable only through the WAF (networkPolicy.enabled on Kubernetes); assign the operator roles and grant mcp:admin only to people |
Loopback HTTP with authentication off trusts local callers; client-reported names are not authenticated agent identity. The WAF is not an authorization control and does not inspect tool arguments or authored policy content; the packages and bare image ship without it |
| MCP08 Lack of audit and telemetry | Evaluation-only: versioned manifests and explicit completion/refusal/cancellation/failure receipts plus joined journal and independent destination evidence; a 403 alone is not refusal (runtime::tests::provider_forbidden_without_policy_denial_is_an_incomplete_runtime_failure), incomplete trials have no negative attack verdict; missing receipts are incomplete (runtime::tests::missing_or_untrusted_receipts_never_produce_a_trial); Guard recovery/exhaustion preserves content-free gateway findings and upstream counts under both error policies (inference_guard_tests::supervised_local_guard_failures_preserve_gateway_policy); Local model findings use the existing gateway journal/egress path, with fixed detector/class/count fields and no content (inference_guard_tests::webhook_findings_refuse_under_the_rules_action). Rotation records durable local transition metadata before activation and preserves journals/historical public keys; local recovery records are not signed gateway audit evidence (rotation tests); content_finding records (rule, detector, class, count, action, cause) are durable before the call’s intent under the same fail-closed append and join the intent and outcome by an application event id, never by the digested request id; the activity export carries them as columns (audit_report_tests::content_findings_export_without_text, content_guard_tests); Jira downloads reuse canonical egress authorization and streamed attempt reporting; negotiated runs retain the background call scope (jira_download_tests, background_task_tests); Approval task associations/projections follow journal commit; background calls keep their admission intent and record the outcome when the tool finishes or is cancelled, and an interrupted run leaves an intent without outcome (background_task_tests); pending tasks reconcile against verified evidence, while terminal receipts retain owner-bound historical results (unified_call_approvals_tests::approval_tasks); durable intent before dispatch, outcomes with egress decisions and subprocess destinations, hash chain, signed checkpoints, fail-closed checkpoint write poisoning, atomic no-replace key installation, and optional SIEM forwarding with per-message TLS RELP acknowledgements and explicit digest notices for oversized records; correlated HTTP discovery and refusal diagnostics; PostgreSQL connection warnings include destination, TLS mode and transport cause chain (postgres_connection_tests) |
Intent records carry each budget reservation (scope, window start, estimate, unknown_usage mode, state) and the proposal a held request waits on; outcome records carry each settlement (reported, released, at_estimate), the charge, any over-run, and marked for the classes that became the owner’s taint; the action on a tool-call intent carries the owner classes the decision was taken on (run_budget_tests, flow_policy_tests); the activity export carries those classes, the labels, the marks and each budget’s id, state and settlement as columns (audit_report_tests::export_includes_inference_and_flow_rows); the rollup store keeps one class row per class an outcome marked, from both paths, and answers the tenant-bound data map that the console, admin data-map and audit report --data-map serve (rollup_store_tests, flow_policy_tests::class_rows_reach_the_data_map_from_both_paths); the five first questions are provisioned dashboards over typed span attributes in deploy/inference/ (telemetry::otlp::tests); inference_intent is durable before an inference request is forwarded or refused and inference_outcome records the upstream status, the provider-reported usage (or unknown, never zero), the bytes relayed before any termination and the egress record naming the upstream origin, joined by an application event id that every tool call’s intent and outcome now carry too; a TelemetryEvent per call and request leaves over OTLP/JSON with verbatim W3C ids, metadata only, lossy and counted (inference_role_tests, audit_journal_tests::events_carry_application_event_id, telemetry_backpressure_drops_and_counts_without_delaying_the_request); Configure durable storage and alerts; retain checkpoints outside the gateway operator’s control for host-compromise evidence; follow the incident response runbook and rehearse its demo drill |
Terminal task receipts are private-database history, not independently verified audit evidence; a stale restore may misstate status but cannot authorize execution. An intent without an outcome does not prove no dispatch occurred. Same-host copies cannot establish evidence against an attacker controlling every retained copy. TCP syslog has no application acknowledgement; RELP acceptance depends on receiver queue durability and retries can duplicate a batch prefix; oversized events require retrieving the full local journal line using the acknowledged digest notice |
| MCP09 Shadow MCP servers | Evaluation-only: strict single-MCP configuration, empty built-in tools/settings/skills/plugins, checked at runtime initialization and provider ingress (runtime_tests::runtime_cli_ignores_poisoned_parent_credentials_configuration_and_plugins); ONNX CUDA registration and warmup gate readiness; registration does not prove node placement, and opt-in validation requires traced CUDA kernels. Guard readiness checks worker liveness after verified load/warmup; idle heartbeats detect hangs, capped backoff and a lifetime budget stop repeated failures (test_supervision.py::test_idle_crash_recovers_then_exhausts_lifetime_budget, test_supervision.py::test_idle_hang_heartbeat_and_inflight_timeout_recover); The optional local classifier is explicitly deployed and exposes authenticated readiness; unsupported backends never download (test_local_guard.py::test_no_gpu_never_downloads_non_onnx). This server enforces its configured boundary and exposes operational inventory |
Organization restricts credential distribution, approves client/server connections and inventories deployments | This product cannot govern another server or program using independently available credentials; organizational responsibility, recorded as a framework map non-claim. CUDA qualification now binds warmup placement to the actual worker PID and checks no replay under owned-worker faults; host selection and partial-result retention are synthetic-tested (test_onnx_gpu.py::test_qualification_harness_on_synthetic_cpu_graph, test_qualification.py::test_failed_fault_phase_preserves_completed_evidence). NVIDIA hardware and disruptive staging tests remain unverified; see qualification record. |
| MCP10 Context injection and oversharing | Evaluation manifests/reports retain only digests, allowlisted terminal metadata and independent verdicts, never injected content or SDK exception text (runtime_tests::runtime_refusal_requires_terminal_gateway_and_destination_evidence); Supervision retains no subject across worker recovery and health exposes only fixed state/reason/counters (test_local_guard.py::test_http_auth_complete_subject_and_content_not_retained, inference_guard_tests::supervised_local_guard_failures_preserve_gateway_policy); Skill content is shared product content, identical for every authorized principal and tenant, with cacheScope: private and no principal-specific data (skills_wire_tests); Local inspection returns counts without subject/tenant content and retains no prompt files (test_local_guard.py::test_http_auth_complete_subject_and_content_not_retained). Rotation console/status exclude private keys and environment snapshots; artifact references are admin-only (configuration_admin_tests, rotation console test); Content findings hold no text by type (ContentFindingRecord); a refusal names the content rule and detector but neither the matched value nor the rule list, which stays in the journal (content_guard_tests); across multi-step attacks no refusal, finding, proposal or revocation record carries the read content, the planted credential or its encoding (adversarial_workflow_tests::prod_logs_read_in_one_session_cannot_leave_through_another, adversarial_workflow_tests::tool_result_secret_cannot_reach_a_model_even_encoded, adversarial_workflow_tests::revoked_owner_is_stopped_mid_workflow_on_both_paths); a denied result carries no structuredContent, a redacted one is rewritten in both representations, and the overflow artifact only ever holds the guarded value (content_guard_tests::denied_result_carries_no_structured_content, ::structured_content_is_redacted_together_with_the_text); prompts and completions are inspected without being persisted, and the review holds findings, never text (inference_guard_tests::sensitive_values_never_reach_storage_or_logs); policy explain --content prints detectors, classes and counts only (policy_explain_tests::content_rule_explains_without_connecting); the data map is bound to the caller’s verified tenant at the admin boundary, the generic usage route refuses the query, and the console page never sends a tenant (admin_api_tests::data_map_is_bound_to_the_callers_tenant, unified_console_tests::data_map_is_tenant_scoped); Jira archives return metadata without previews, use generated filenames, and remain opaque (jira_download_tests); CI wait results are fixed-field projections of the run record, rendered through the bounded success path (ci_wait_tests); Task identifiers reveal no cross-owner data; input updates are discarded; approval results are application-minimized, background results are size-bounded and owner-bound, and spilled results are readable only by their owner (task_lifecycle_tests, task_wire_tests, background_task_tests); Principal-bound sessions, artifacts and response cache, with or without an audit journal; credential-cache partitioning; GitHub issue disclosure checks; escaped proposal documents restricted to the proposer’s tenant administrators |
Use distinct verified principals and appropriate resource policies; agent runtime isolates tasks and context | Principal isolation is not task isolation or general information-flow control. Returned data can be shared downstream by the agent |
Implementation and regression evidence
Section titled “Implementation and regression evidence”These are evidence locations, not a declaration that the suites were executed
for this documentation review. Root tests may be registered through suites;
consult Cargo.toml and workspace guidance before running them.
| Area | Implementation | Regression evidence |
|---|---|---|
| Call admission and egress | Call service, egress enforcement, extractors | Policy engine, egress holds and denials |
| Identity and generations | OIDC, generation, startup | Token validator, auth mode, atomic generation and replay |
| Approval binding and reauthorization | Control approvals, approval bootstrap | Jira create approvals, consumption recovery, capability prerequisites |
| Data isolation and disclosure | Sessions, response cache, GitHub issues | Principal partitioning, credential-cache partitioning, issue disclosure, injected follow-up calls |
| Tool and release integrity | Tool descriptions, release workflow | Tool-surface golden test, release verification procedures |
| Downloads and Git | HTTP adapter, clone controller, subprocess adapter | Download redirects and credential stripping, clone URL binding, git hardening, path and option checks, subprocess environment allowlist |
| MCP Skills | Grammar and bounds, catalog, embedded adapter, inbound adapter | Wire tests, packaging, grammar invariants, fuzz target |
| Audit and direct CLI | Call service, audit schema | Journal, checkpoints, forwarding, audited CLI |
Prioritized follow-up
Section titled “Prioritized follow-up”1 Constrain Git execution
Section titled “1 Constrain Git execution”Implemented 2026-10-09 for destination binding and inherited configuration.
bb_clone now parses the upstream clone URL structurally, requires its host
to be the Bitbucket host the vendor base URL implies and its path to name
exactly the requested workspace and repository, pins Git’s transport to SSH
and HTTPS, refuses HTTP redirects (Git otherwise follows one on its first
request), disables hooks, and runs Git in an allowlisted environment
(src/controllers/clone.rs, src/shell.rs). clone_tests cover foreign
hosts on each protocol, workspace and slug mismatches, userinfo and
option-shaped hosts, non-network transports, a redirect against the real
git binary, and the positive argv and environment; shell_tests cover the
adapter’s environment handling. The
tool surface is unchanged.
What remains is deployment work: the subprocess does not use the Rust HTTP
authorization path, so its traffic has no egress record (the call’s outcome
record names the URL Git was handed and how Git ended), and its network and
filesystem authority are those of the process. HTTP does not expose
bb_clone. For local stdio or direct CLI cloning,
confine clone targets with BITBUCKET_CLONE_ROOT and restrict the process
network authority. The user-level ~/.gitconfig and ~/.ssh are trusted by design.
2 Test adversarial workflows
Section titled “2 Test adversarial workflows”Server half implemented 2026-10-09 (tests/prompt_injection_tests.rs). A
Jira issue’s description instructs the agent to post the issue, with a
customer-list sentinel, to a Slack channel. The read is allowed and returns
the content unfiltered; the Slack write the content asks for is denied at
admission, the Slack mock receives zero requests, and the journal holds the
allowed intent and outcome followed by the denied intent, under one
principal, with neither the content nor the requested channel in any record.
The same workflow runs under a native Cedar document with a forbid on Slack
writes, so both engines are covered.
The allowed-read-then-allowed-write case is also locked, as documentation
rather than defence: with a rule permitting issue creation in another
project, the content read from the confidential issue is written there and
the server does not prevent it. The test asserts what the journal provides
instead: both calls ordered under the principal, the read’s resource and the
write’s destination project, an outcome and egress record for each.
Threat model §7 item 7 states the residual. Since runtime security plan
PR 4, flows rules track what an owner has read by class, not by content:
a transfer is refused when the read came from an upstream classified by
environment, or a content rule labelled its result, and a flow rule names
the destination. A read of unclassified, unlabelled content followed by an
allowed write is still journaled, not prevented, and this test still
documents it.
Multi-step corpus implemented 2026-10-28 (governance plan WP6,
tests/adversarial_workflow_tests.rs, content in
tests/fixtures/adversarial/). Five workflows run against one deployment
whose policy carries every control they rely on. Each first shows that the
step it refuses is otherwise allowed, then asserts containment: the target
upstream receives nothing after the refusal, no upstream receives the
adversarial text, and the journal records the refusal without it.
| Workflow | Control that stops it | Test |
|---|---|---|
| Production logs read in one session, copied to Jira from a second session under another client name, then pasted into a model prompt | Flow rule on the owner’s prod mark, on both paths |
adversarial_workflow_tests::prod_logs_read_in_one_session_cannot_leave_through_another |
| Confluence page with an injected instruction and a planted credential, then the Jira write it asks for, to an allowed destination | Content rule on write arguments | adversarial_workflow_tests::confluence_injection_cannot_carry_its_planted_credential_into_a_write |
| Jira issue carrying a credential, then a prompt with it base64 encoded, which the prompt content rule does not decode | Flow rule on the secret mark a result label set |
adversarial_workflow_tests::tool_result_secret_cannot_reach_a_model_even_encoded |
| A loop of reads past a per-owner call budget, retried while held | Budget hold and the approval gate: retries run nothing and open no new proposal |
adversarial_workflow_tests::runaway_tool_loop_is_held_at_its_budget_not_run |
| Owner revoked with a session open, after using both paths; then a re-minted token | Signed revocation list, on /mcp and /v1 |
adversarial_workflow_tests::revoked_owner_is_stopped_mid_workflow_on_both_paths |
Each case fails when its control is disabled in the source and passes with it (recorded in the WP6 section below). Follow-up 2 is closed for the server. The agent half remains:
whether the client follows injected instructions is a property of the agent harness and is assessed by its owner (threat model §8). Existing GitHub issue label checks are a useful disclosure pattern, not a universal content-classification or data-loss-prevention system; no information-flow restriction is claimed. The agentic security research assessment records which scenarios this corpus already locks and reads the research on where enforcement and evaluation should sit, including the case for revisiting this §8 boundary. It proposes; it does not change §8.
3 Validate shared deployment protection
Section titled “3 Validate shared deployment protection”Operator work using existing controls. Validate authenticated mode, narrow resource policies, credential selection, approvals where required, network restrictions, and independent audit retention as one deployment posture. The Helm values default network policy to disabled. Enabling it requires a supporting CNI; its default destination allow rule permits HTTPS broadly, so narrow destinations for the deployment. The WAF tier is on by default in every shipped deployment path; on Kubernetes the same network policy is what keeps the server reachable only through it. An origin allowlist or TLS alone does not establish private-address exclusion.
The server cannot prevent shadow deployments when their operators hold vendor credentials. Restrict credential distribution and approved client connections, and maintain deployment inventory outside this process.
4 Keep the security documentation current
Section titled “4 Keep the security documentation current”Completed for this assessment: reconcile the best-practices map and both threat models with controlled download redirects, atomic policy/revocation generations, audited vendor CLI calls, unified capability prerequisites, Git execution (updated again when follow-up 1 was implemented), and network-policy defaults. Historical planning records retain their original status and dates; the current security documents take precedence for current implementation claims.
Maintain this mapping whenever those boundaries change. For every claimed
mitigation, retain the implementation, configuration prerequisite, regression
evidence, residual risk, and responsible component. Since 2026-10-27,
docs_evidence_tests::cited_tests_resolve_in_the_security_documents fails CI
when a test cited in backticks here, in the threat model, the best-practices
map, the shared deployment checklist, the incident response runbook (since
2026-10-28) or the governance framework map no longer exists. Its first run found three stale citations here and one in the
checklist, all corrected in the same change. Runtime authorization
coverage must not be presented as complete prompt-injection prevention,
context-flow control, or organization-wide shadow-server governance.
HTTP diagnostic logging change (2026-10-09)
Section titled “HTTP diagnostic logging change (2026-10-09)”MCP01 adds exclusion of credentials, query strings, bodies, claims and literal
path parameters from new request diagnostics; only a bearer-presence boolean
is recorded. MCP08 adds arrival and response-header events around the whole
HTTP router, with a server-generated request ID shared by authentication
warnings and returned in x-request-id. Standard methods and route templates
bound request-derived field values; unmatched paths and custom methods use
fixed markers. Diagnostic logs remain filtered and retained operator logs,
not durable audit evidence. Flooding can increase log volume or evict earlier
diagnostics within retention limits; ingress limits remain deployment controls.
MCP07’s authentication decisions are unchanged. MCP10 exclusion of request
content prevents new diagnostic oversharing. No tool surface, dependency,
subprocess, outbound destination or policy change is introduced. Regression
evidence: inbound_auth_tests::http_diagnostics_correlate_discovery_and_refusals_without_request_secrets
and the existing inbound-auth and HTTP transport suites. The security-review
skill was unavailable locally; the change was reviewed directly against this
assessment, the threat model and best-practices map.
Pending policy proposal review
Section titled “Pending policy proposal review”The console reads the stored signed generation through a dedicated authenticated
candidate endpoint. MCP02 adds a review control: show the actual policy and a
structural comparison, including policy versions, before offering approval.
Digest verification guards the read; approval still independently verifies the
digest, signatures, distinct approver, fresh token and current generation.
MCP07 preserves admin scope and tenant isolation. MCP01/MCP10 extend disclosure
to administrators in the proposing tenant: signed documents may contain sensitive
operator text, so the API and browser downloads use no-store, fixed attachment
names and exact bytes; HTML views escape content. Do not put credentials in
policy documents. MCP06 treats submitted text as display data, never commands.
MCP08 is unchanged: reads do not append decisions or apply a generation.
No dependencies, tool schemas, subprocesses or new filesystem access are added
(MCP03/04/05/09 unchanged). Web checks cover XSS escaping, access control,
non-cacheable attachments and the existing CSRF-protected decision forms.
Evidence: unified_console_tests::proposal_review_shows_stored_policy_and_downloads_exact_signed_bytes,
::proposal_review_includes_revocation_changes_and_handles_an_unchanged_policy,
::proposal_review_preserves_cedar_source_and_download_extension (Cedar feature),
and the existing separation/freshness, expiry, tool-proposal and origin tests.
unified_approvals_tests::pending_proposals_survive_a_restart_and_a_tampered_journal_is_refused
also locks refusal to disclose digest-mismatched candidates. Candidate reads
are only available while pending and unexpired; comparisons are a snapshot,
not a guarantee that the generation remains current when approval is attempted.
Default WAF tier (2026-10-09)
Section titled “Default WAF tier (2026-10-09)”Every deployment path the repository ships now puts a web application
firewall in front of the server by default: nginx with ModSecurity v3 and the
OWASP Core Rule Set 4 at paranoia level 1 in blocking mode, from the
owasp/modsecurity-crs image pinned by digest, with one shared ruleset in
deploy/waf/. The dev, demo and deploy/compose
proxies became the WAF; the Helm chart gains a waf tier (waf.enabled,
default true) between the Ingress and the roles, and deploy/k8s gains
waf.yaml. The dev and demo stacks no longer publish the server’s port 3000,
so nothing on the host reaches it around the WAF.
- MCP07 gains a control: defense in depth on the inbound path. It
refuses injection, cross-site scripting, path traversal, scanner and
protocol-abuse requests against the console, the admin API and the MCP
JSON-RPC envelope before the server parses them. It does not change any
authentication or authorization decision. With
networkPolicy.enabled, the chart admits only the WAF pods to the roles (networkPolicy.ingress.directis the explicit, documented exception). Locked byscripts/ci/waf-smoke.sh(61 cases in the CIimagejob),scripts/ci/compose-smoke.shagainst the real server,operations_tests::every_shipped_proxy_runs_the_wafand::helm_network_policy_allowlists_egress_and_blocks_the_metadata_endpoint. - MCP01 is unchanged in the server and protected at the new component:
the
Authorizationheader and the console cookies are never rule targets, and the WAF’s JSON audit log keeps only rule messages (parts A, H, Z), so neither a match nor a passed login can copy a token, cookie,Set-Cookiesession id or tool argument into the proxy’s log.scripts/ci/waf-smoke.shfails if a session cookie set by the backend ever appears there. - MCP04 adds one third-party image to the deployment trust base. It is
pinned by index digest from a dated LTS tag;
operations_tests::every_deployment_path_pins_the_same_waf_imagefails if any path drifts, andthe_chart_ships_the_same_waf_rules_as_deploy_wafkeeps the chart’s copy of the rules identical. - MCP05 and MCP06 are deliberately not claimed. Tool-call arguments
legitimately carry SQL, JQL, shell-like text and code, so the
application-attack rule groups skip
params.argumentson/mcp. A guard rule refuses that argument shape on every other route, so the exemption cannot be used to slip a payload past the console or admin rules. Administrator-authored policy content keeps the XSS rules and is exempt from the code-injection groups. The console callback’s RFC 9207issargument is exempt from the SSRF and remote-file-inclusion groups only; the server validates it against the configured issuer. Command and prompt injection remain governed by the controls in their rows. - MCP02, MCP03, MCP08, MCP09, MCP10: not touched. No policy, tool surface, journal, inventory or response-content change.
The console is a browser application, so the change was also checked against
the web OWASP Top 10. Injection (A03) and security misconfiguration (A05)
gain an edge control. Broken access control (A01) and CSRF remain the
server’s (scope checks and the console’s same-origin and SameSite=Strict
controls); the WAF does not replace them.
Deployment follow-ups (#159–#162)
Section titled “Deployment follow-ups (#159–#162)”MCP01 adds the exact demo-token ignore rule and excludes it from Docker build contexts; operations_tests::local_demo_credentials_and_python_bytecode_are_ignored locks it. It is a staging backstop, not secret scanning or rotation.
MCP04 changes the native container target selection for amd64/arm64 without changing dependencies, release platforms, signatures or SBOMs. Unsupported targets fail explicitly. Source-fingerprint metadata is scoped per architecture while artifacts stay shared; tests/build_cache_test.py locks rebuilds when either target switches source snapshots.
MCP05 changes Kubernetes WAF startup filesystem/process use: only operator-mounted configuration is read, only /tmp/nginx.conf is written, and nginx is executed without evaluating configuration as shell code. Upstreams remain fixed by deployment configuration; request headers cannot select destinations.
MCP07 preserves authentication and WAF exemptions while validating Helm body limits and rejecting oversized requests without partial inspection. operations_tests::helm_waf_body_limit_is_validated_and_rolls_pods and scripts/ci/waf-smoke.sh lock the limits; scripts/ci/waf-dns-smoke.sh locks DNS recovery with the hardened image. MCP02/03/06/08/09/10 controls are unchanged.
Carry-forward issues 117, 119 and 122
Section titled “Carry-forward issues 117, 119 and 122”- MCP01: extends startup/refresh snapshot resolution to the legacy
NINJAONE_SERVERSpassword and TOTP fields. Unresolved references still refuse login before vendor I/O. No request-path provider fetch is added. Locked byninjaone_session_tests::nested_server_secret_references_resolve_before_login_and_rotate,::unresolved_nested_reference_refuses_login_without_contacting_vendor,::a_server_entry_uses_its_own_totp_seed, andcredential_preparation_tests::ninjaone_nested_password_snapshot_preserves_credential_provenance. - MCP05: preserves administrator-controlled destinations, prefixes and
commands; discovery expands only the two credential fields. RELP accepts
only TLS host/port destinations and uses the existing CA/mutual TLS controls.
Locked by
config_tests::ninjaone_nested_reference_discovery_is_limited_to_credential_fieldsandaudit_forwarding_tests::relp_configuration_rejects_plaintext_credentials_and_extra_url_parts. - MCP08: adds per-message RELP acknowledgement; an incomplete batch keeps
its cursor and reconnects for retry. Responses and payloads are bounded,
transaction numbers must match, and peer prose and malformed receiver URLs are excluded from errors.
Locked by
relp_invariants_tests,audit_forwarding_tests::relp_tls_and_mutual_tls_forwarders_conformand::relp_failed_acknowledgements_keep_cursor_and_reconnect.audit::journal::tests::checkpoint_write_failure_poisons_the_sink_and_refuses_later_eventslocks the existing poison control through the public sink and real writer thread.policy_signing_tests::concurrent_keygen_installs_exactly_one_complete_keylocks existing no-replace installation at the two-process boundary. - MCP06/MCP10: preserves the boundary around external receiver content:
only validated protocol acknowledgements can advance the cursor. Receiver
prose cannot create tool actions, select a destination, enter model context,
or appear in delivery errors. The bounded response/acknowledgement checks in
relp_invariants_testsand the failed-acknowledgement integration test lock this boundary; no prompt or tool-result control is weakened. - MCP02, MCP03, MCP04, MCP07, MCP09: no controls changed; no policy, tool surface, dependency, inbound identity or server-inventory behavior changes. The console is unchanged.
Residual limits: TLS syslog still has its documented acknowledgement gap; RELP acknowledges receiver acceptance, not independently verified persistence. The receiver must durably queue before acknowledging. Batch retry can duplicate an acknowledged prefix. The initialize/revocation binding interleaving remains tracked by #106; watcher clock injection remains conditional on observed flakes.
PR 167 review follow-up
Section titled “PR 167 review follow-up”MCP08 changes RELP timeout granularity to one budget per connection phase and
exchange, probes idle closure before reuse, and replaces oversized payloads
with acknowledged sequence/byte-count/SHA-256 notices. The full journal line
is retained; the SIEM lacks that event’s full contents and must alert/retrieve
by origin/sequence/digest. Locked by
relp_batch_can_outlast_each_exchange_budget,
relp_reconnects_an_idle_closed_session_in_the_next_delivery, and
relp_oversized_notice_is_acknowledged_before_cursor_progress, including
rejected notice acknowledgements retaining the cursor. MCP01/MCP05 strengthen
shared TLS URL diagnostics: credentials and other URL contents are not echoed,
and both schemes reject fragments (relp_configuration_rejects_plaintext_credentials_and_extra_url_parts).
MCP06/MCP10 retain bounded receiver parsing and matching acknowledgement
controls; notices contain only fixed metadata and a digest, not external prose.
MCP02/MCP03/MCP04/MCP07/MCP09 are unchanged. No console changes.
Cloud deployment templates (2026-10-10)
Section titled “Cloud deployment templates (2026-10-10)”deploy/aws/fargate.yaml (ECS on Fargate) and deploy/azure/main.bicep (Container Apps) are the first two steps of the cloud deployment plan; the two secret-source adapters gained the platform credentials endpoints those paths run on.
MCP01 adds two controls and changes none. Vendor credentials reach both templates only as awssm:// and azkv:// references, resolved by the process under the task role or the managed identity; the audit signing key is injected by the platform from Secrets Manager or Key Vault into an init container that writes it with the mode the gateway insists on; the templates carry no NoEcho or securestring parameter and the operations tests refuse one. The ECS task credentials endpoint and the Container Apps identity endpoint are accepted on loopback or link-local only, the Container Apps header value is secret material redacted from diagnostics, and a refused exchange is a category with nothing of the platform’s message (aws::tests::pod_identity_on_ecs_takes_the_relative_uri_and_sends_no_token, aws_contract_tests::pod_identity_on_ecs_reads_the_task_endpoint_without_authorization, secrets::azure::tests::managed_identity_takes_the_platform_endpoint_as_a_pair_and_keeps_the_header_secret, azure_contract_tests::managed_identity_on_container_apps_uses_the_platform_endpoint_and_its_header). One residual is recorded: Container Apps has no security context, so the replica’s root filesystem is not read-only and the WAF image runs as root (deploy/azure/README.md); the gateway image is distroless and runs as 65532 regardless.
MCP02 is unchanged in code; the IAM task role is scoped to the named secret ARNs and the four access points, the managed identity holds Key Vault Secrets User and Storage Blob Data Contributor only, and the operations test refuses an IAM wildcard beyond ecr:GetAuthorizationToken. Issue #43 (one identity for secrets and the aws_* tools) stays open and is noted in both READMEs.
MCP04 adds the cloud-deploy-live workflow with SHA-pinned actions and a lint job that runs cfn-lint, cfn_nag, the Bicep linter and a rebuild of the checked-in ARM on every pull request touching a template; the helper images (AWS CLI, Azure CLI) are pinned by digest like the WAF image, and operations_tests::every_deployment_path_pins_the_same_waf_image now covers both templates.
MCP05 adds the platform egress controls: a Fargate security group with no all-traffic rule (TCP 443, NFS and the SIEM port only; a Fargate task has no instance metadata endpoint to reach) and a Container Apps subnet NSG that denies 169.254.169.254/32 before any allow and denies everything unlisted (operations_tests::aws_fargate_template_preserves_the_deployment_constraints, ::azure_container_apps_template_preserves_the_deployment_constraints). The init containers run fixed commands over operator-owned objects; no value from a request reaches them.
MCP07 is unchanged: both templates fix MCP_AUTH_MODE=oidc, require the issuer, audience, public URL and policy public key as parameters, and keep the server reachable only through the WAF sidecar (the only load balancer target, the only ingress port); the environment on Azure is internal and the Application Gateway’s own WAF stays in detection mode so the sidecar remains the blocking one.
MCP08 adds the off-host evidence path on both clouds: the checkpoint export on its own volume, a scheduled copy task that mounts it read-only and writes to an Object Lock bucket or an immutable blob container, and a health-banner alarm; the live smoke.sh on each cloud proves a journal record and a landed checkpoint. MCP03, MCP06, MCP09 and MCP10 controls are unchanged.
Inference path (runtime security plan PR 2, 2026-10-23)
Section titled “Inference path (runtime security plan PR 2, 2026-10-23)”| Category | Effect on controls | Regression evidence |
|---|---|---|
| MCP01 | New residual: prompt and completion bytes transit the process uninspected until PR 3; they are never persisted. New control: the provider credential is held and resolved like a vendor credential, and the caller’s bearer, API key and cookies are stripped before forwarding | inference_role_tests::client_bearer_never_reaches_upstream, ::anthropic_stream_is_forwarded_unchanged_with_usage_recorded (journal contains no prompt, completion or credential text) |
| MCP04 | No new dependency; two new fuzz targets over attacker-controlled bytes (provider SSE, caller traceparent) |
sse_usage_invariants_tests, trace_context_invariants_tests, fuzz/README.md |
| MCP05 | New outbound destination, operator-set and validated at startup; five fixed routes; body forwarded as bytes after one JSON parse; bounded, validated trace headers | roles_tests::half_configured_inference_path_refuses_startup, bootstrap::inference::tests, inference_role_tests::traceparent_is_validated_kept_verbatim_and_garbage_is_digested, ::a_non_json_body_and_an_oversized_body_are_refused_before_admission |
| MCP07 | New scope mcp:inference on the shared validator, revocation and rate limiter; owner binding and verify-only session rule (decision 7); models rules with default deny; routes absent without an upstream and on control |
inference_role_tests::inference_request_binds_to_the_token_owner, ::inference_request_with_a_session_id_of_another_owner_is_refused, ::model_rule_denies_before_any_upstream_request, roles_tests::inference_routes_mount_only_with_an_upstream_and_never_on_control, http_transport_tests::inference_routes_are_absent_without_an_upstream |
| MCP08 | New journal kinds inference_intent and inference_outcome with usage and bytes sent; event id on every tool call record; TelemetryEvent and the bounded OTLP exporter (lossy, counted, metadata only); export columns appended |
inference_role_tests::inference_upstream_is_an_egress_destination, ::partial_stream_failure_is_one_failed_attempt_not_a_replay, ::telemetry_backpressure_drops_and_counts_without_delaying_the_request, audit_journal_tests::events_carry_application_event_id, audit_report_tests |
MCP02, MCP03, MCP06, MCP09 and MCP10 are untouched: no tool, description or
schema changes; policy extractors and approvals unchanged; the inference
body is never an input to any action; nothing from the provider is acted on.
models rules are an authorization input on the new path only.
CI failure fixes (2026-10-10)
Section titled “CI failure fixes (2026-10-10)”The security review found no new high-confidence vulnerabilities. Windows configuration saves retain file synchronization and atomic replacement; directory synchronization remains enabled on Unix. Package smoke tests create the required signed authorization generation before deleting the authoring key.
| Category | Effect on controls | Regression evidence |
|---|---|---|
| MCP01 | Operator key rotation uses owner-only staging files and redacted status; the recovery record binds the environment file by digest and never copies its credentials (rotation::recovery_record_holds_no_environment_contents); the browser receives only public keys and references (devtools-control rotation tests); Secret redaction and authoring-key cleanup unchanged |
configuration::tests::inventory_sources_defaults_and_redaction; packages-smoke.sh deletes the authoring key before service startup |
| MCP02 | Rotation verifies the old signed set, preserves policy/revocation bytes, rejects stale preparation and refuses rollback over newer revocations (rotation::rollback_refuses_to_discard_newer_revocations); policy activation refuses while the replacement private key is in the directory the gateway’s settings point into (rotation::policy_activation_refuses_while_the_private_key_is_in_the_rotation_directory), and activation after a failed rollback is refused so a retry cannot re-apply an abandoned key (rotation::a_failed_rollback_is_resumed_only_by_rollback); Editable allowlist and stale-revision refusal unchanged |
configuration::tests::rejects_invalid_values_sections_and_unapproved_keys, ::saves_preserve_unrelated_values_and_reject_stale_revisions |
| MCP04 | Routine macOS regression coverage reduced to explicit opt-in; Linux/Windows gates and release macOS builds retained | Workflow validated with actionlint; availability and limitation recorded in the release runbook |
| MCP05 | Local rotation uses systemctl only at /usr/bin or /bin (never PATH) through the shared no-shell subprocess helper, validated unit names, a single verified EnvironmentFile and loopback-only health probes; filesystem inputs are operator-owned and the transition log is opened without following a symlink (rotation::transition_log_symlink_is_refused_and_its_target_untouched); Jira downloads use numeric IDs, generated artifact paths and bounded redirects; opaque archives are never extracted or executed (jira_download_tests); File permissions, symlink refusal and atomic replacement unchanged |
configuration::tests::readonly_mode_and_symlinks_cannot_be_written; save regressions fail before and pass after the fix on the Windows KVM guest |
| MCP07 | Rotation inventory uses the authenticated admin boundary; browser rotation is an operator handoff with no mutation endpoint (configuration_admin_tests, unified_console_tests::rotation_console_is_an_authenticated_operator_handoff_without_private_keys); Signed-generation startup requirement and admin authentication unchanged |
auth_mode_tests::http_refuses_okta_mode_without_an_authorization_generation; corrected OIDC bootstrap starts and unauthenticated admin access returns 401 |
| MCP08 | Rotation records durable local transition metadata before activation and preserves journals/historical public keys; local recovery records are not signed gateway audit evidence (rotation tests); Audit-before-save and write freshness unchanged |
configuration_admin_tests::admin_boundary_enforces_scope_freshness_and_audit_before_save |
MCP03, MCP06, MCP09 and MCP10 are untouched. The full Debian/UBI systemd package smoke test was not rerun from this SSH account; the signed OIDC bootstrap was verified locally, which does not prove package installation or the service sandbox.
Stdio-only MCP cloning
Section titled “Stdio-only MCP cloning”MCP02 and MCP05 add a transport capability restriction: HTTP callers cannot invoke Git or write clone destinations, even with Bitbucket enabled and policy allowing it. MCP03 deliberately changes HTTP discovery while preserving the stdio golden, descriptions, schemas and controller error envelope. MCP07’s authentication rules are unchanged; the restriction covers authenticated and loopback HTTP alike. Existing local URL validation, environment hardening, path confinement and subprocess audit controls remain intact. MCP01, MCP04, MCP06, MCP08, MCP09 and MCP10 controls are unchanged. No console changes.
Evidence: http_transport_tests::modern_tools_list_is_stateless_deterministic_and_cacheable
locks HTTP discovery; ::http_refuses_bb_clone_before_creating_a_destination
locks rejection before filesystem work in stateless and initialized sessions;
tool_surface_parity_tests::stdio_advertises_bb_clone_when_bitbucket_is_enabled
and ::tool_surface_matches_golden preserve local discovery.
Artifact and cache ownership without a journal (2026-10-13)
Section titled “Artifact and cache ownership without a journal (2026-10-13)”Finding: current_owner() read only the enforcing call scope, which
call_tool enters only when an audit journal is configured. An
authenticated HTTP call without a journal therefore ran as local: its
artifacts were owned local, so the creator got 404 from
/artifacts/{id} while any bearer that knew the id could read it with
artifact_read or attach it with bb_upload; and all bearers shared one
response-cache partition. With per-subject credential selection the cache
key already differed by upstream credential, so the shared partition served
another bearer’s response only when bearers share one service credential,
which grants them the same upstream data; it still let one bearer be served,
and with fresh evict, another’s entries. Fixed either way.
Without a journal, call_tool now runs every admitted call whose principal
is not local under an owner-only task-local (policy::scope::with_acting_owner), and
current_owner() consults it when no call scope exists. It is deliberately
not a call scope: egress, resource and subprocess readers still see no
scope, so policy and evidence behave exactly as before on that path.
- MCP02 (strengthens a control):
bb_uploadresolvesartifactIdas the validated caller, so it cannot attach another bearer’s artifact.principal_partitioning_tests::without_a_journal_upload_reads_only_the_callers_artifacts. - MCP07 (strengthens a control): artifact download and
artifact_readauthorize against the validated principal on the journal-less path too.::without_a_journal_a_tool_created_artifact_belongs_to_its_creator. - MCP10 (strengthens a control): artifacts and the response cache are
partitioned per principal regardless of journaling.
::without_a_journal_the_response_cache_is_partitioned_by_principal. - Local mode (stdio, the CLI, HTTP without inbound auth) is unchanged and
still owns everything
local.::stdio_calls_stay_local. - Trade-off: ownership is assigned at creation, so artifacts created before
the change stay
local-owned. The registry is in-process and the session directory is reaped at startup, so none survive the restart that deploys this change. - MCP01, MCP03, MCP04, MCP05, MCP06, MCP08 and MCP09 are not touched. The
browser tools’ explicit-owner paths (
begin_artifact_ownedfor screenshots and theartifact_readfallback) are retained. The ambient owner now agrees with the validated principal on the no-journal path, soartifact_readno longer needs the fallback on that path. The phase 2 review’s recorded residual (every other tool’s artifacts ownedlocalwithout a journal) is closed here.
Playwright browser tools over shared HTTP (2026-10-12, issue #34 phase 2)
Section titled “Playwright browser tools over shared HTTP (2026-10-12, issue #34 phase 2)”Phase 2 makes the six playwright_* tools safe to advertise over the HTTP
transport and enables them there behind a runtime opt-in. The change
touches:
- MCP02 (changes a control): a handle is bound to the validated bearer
subject on every call, handed to the controller by the inbound adapter
(
ActingOwnerin the request extensions) rather than read from the call scope. Finding: a gateway without an audit journal enters no call scope, so the phase 1 path would have made every HTTP subjectlocaland let one tenant drive another’s browser; the explicit owner closes that. A subject’s revocation closes its browsers and cancels its launches in flight. Under a policy file the tools are classified (open_pagescoped to the host being opened,read_page,act_on_page,close_pageon apageresource, operation classbrowser), so anallowrule can confine where a browser may be pointed andrequire_approvalholdsplaywright_clickandplaywright_fillat admission like every other write-class tool; previously an unmapped browser call was unclassified and denied under any policy. The host scope is a policy control, not network containment.playwright_http_tests::handles_are_bound_to_the_bearer_subject_without_a_policy_scope,::revoking_a_subject_closes_its_browsers,::click_and_fill_are_held_for_approval_when_policy_requires,playwright_controller_tests::an_explicit_owner_outranks_the_call_scope,::close_where_closes_the_selected_owner_and_refuses_its_launch_in_flight,devtools_contracts::browser::registry::tests::revoking_an_owner_retires_its_sessions_and_refuses_its_opens,for_tool_invariants_tests(I11, fuzzed byfuzz/fuzz_targets/for_tool.rs). - MCP03 (changes a control): the tools leave
STDIO_ONLY_TOOLS; HTTP sessions carry them only whenPLAYWRIGHT_HTTP_ENABLED=trueandPLAYWRIGHT_CONTAINMENTdeclares a valid shape, and the opt-in refuses to turn on otherwise, with the reason in the startup log and a fixed category in the health banner. An enabled runtime that is not installed withdraws the tools on both transports rather than advertising six tools that fail on first use. The stdio golden is byte-identical.playwright_http_tests::the_opt_in_refuses_to_turn_on_without_containment,::an_enabled_but_uninstalled_runtime_withdraws_the_tools_with_a_reason,tool_surface_parity_tests::stdio_withdraws_playwright_tools_when_enabled_but_not_installed,::tool_surface_matches_golden,http_transport_tests::modern_tools_list_is_stateless_deterministic_and_cacheable. - MCP04 (changes a residual risk): a second image variant
(
--target playwright) carries Node 22,@playwright/mcp@0.0.83and Chromium; it is not distroless and runs Chromium without its own sandbox (PLAYWRIGHT_NO_SANDBOX=true, the container is the sandbox; documented how to restore it).scripts/install-playwright-runtime.shpins the same versions for non-container installs. The gateway still downloads nothing. No Rust dependency was added. CI proves the browser starts in the image (scripts/ci/image-playwright-smoke.sh). - MCP05 (adds controls; the phase 1 residual is now a deployment
control with a proof): network containment outside the process. With
proxycontainment the child is started with--proxy-serverand--proxy-bypass "<-loopback>", so every browser request, loopback included, is the operator’s proxy’s decision; the proxy URL is validated toscheme://host:portand refused with credentials. The CI jobplaywright-containmentruns the gateway behind a squid allowlist that refuses private, loopback, link-local and carrier ranges by resolved address, and asserts from the proxy’s and the internal stand-in’s logs that link-local, loopback, RFC 1918 and169.254.169.254are refused directly, through a redirect from the allowed site, over a WebSocket and as subresources, while the allowed site passes.--allowed-originsis not used and documented as not a boundary.network-policyis refused as containment by the gateway and the chart: a pod network policy cannot block the browser from the pod’s loopback, so it does not contain a browser inside the gateway pod.vendor::playwright::tests::a_proxy_server_is_passed_with_the_loopback_bypass_removed,vendor::playwright::availability::tests,playwright_http_tests::opt_in_lists_and_dispatches_the_tools_and_the_proxy_reaches_the_child,scripts/ci/playwright-containment-smoke.sh,operations_tests::helm_browser_tools_need_declared_containment_and_run_the_proxy_sidecar. - MCP02 / availability (adds a control): resource exhaustion as a
denial-of-service path. The global and per-owner caps are admitted before
spawn (phase 1) and now proven under concurrent multi-tenant load: opens
past the cap from six principals at once return the capacity error, no
child is created for a refused open, and the gateway’s resident set stays
flat. The HTTP idle limit defaults to 60 s on the handle’s own clock. A
per-child memory ceiling (
PLAYWRIGHT_CHILD_MEMORY_MAX_BYTES) is applied as a cgroup v2 leaf withmemory.oom.groupwhere Linux lets the gateway write one (the packaged unit setsDelegate=memory), otherwise logged once with the container limit documented as the ceiling. A browser the kernel kills is a dropped handle, not a gateway crash.playwright_http_tests::opens_past_the_cap_are_refused_before_spawn_with_flat_memory,::a_browser_killed_outside_the_gateway_is_a_dropped_handle,::the_http_idle_limit_is_short_and_runs_on_the_handle_clock,playwright_bridge_tests::an_externally_killed_runtime_is_reported_closed,playwright_controller_tests::the_http_transport_reads_its_own_idle_limit,vendor::playwright::tests::the_memory_ceiling_is_optional_clamped_and_its_root_absolute. - MCP07 (changes a control): the owner binding above is the HTTP transport’s authorization for browser state across stateless calls; the single-replica constraint (a browser lives in the process that opened it) is documented beside the rate limiter’s and enforced by the chart.
- MCP08 (changes a control): the startup log states, per transport,
whether the browser tools are offered and why not (
not enabled,runtime not installed,not opted in for HTTP,network containment not configured); the revocation log line counts browsers closed; the memory ceiling logs whether it applied. The health banner carries only the fixed category, never a path. - MCP01, MCP06, MCP09, MCP10: none touched beyond phase 1. No credential reaches the browser or the proxy’s command line (userinfo in the proxy URL is refused, MCP01). Page content handling is unchanged (MCP06/MCP10). The proxy sidecar listens on the pod’s loopback only (MCP09).
Review follow-up (2026-10-13):
- MCP05 (strengthens a control):
network-policyis no longer accepted as containment by the gateway or the chart. KubernetesNetworkPolicycannot block a pod’s traffic to its own loopback, so with the browser in the gateway pod it left the gateway’s listeners and sidecars reachable from a page. The chart’s IPv4 carve-out, which appended exceptions outside a narrowed rule’s CIDR and so rendered policies Kubernetes rejects, went with it.vendor::playwright::availability::tests::containment_requires_a_known_shape_and_a_proxy_for_proxy,operations_tests::helm_browser_tools_need_declared_containment_and_run_the_proxy_sidecar. - MCP04 (restores a control): the distroless runtime is the
Dockerfile’s final stage again, so a plain build and the release workflow
publish it; the Node/Chromium variant is built only with
--target playwright.binary_tests::the_default_container_image_is_the_distroless_runtime. - MCP02 and MCP07 (changes a control): a screenshot artifact is owned by
the handle’s validated owner rather than relying on the call scope,
which is absent on an HTTP call without a journal; its taker can download
it and read it with
artifact_read, and another bearer gets the unknown-artifact answer.artifact_readtries the validated owner after the scope owner; that added lookup reaches only the caller’s own artifacts.playwright_http_tests::a_screenshot_belongs_to_the_bearer_that_took_it. The review also identified a pre-existing residual outside the browser tools: other tools’ artifacts were ownedlocalon the no-journal HTTP path, so their creator got 404 from/artifacts/{id}and any bearer that knew an artifact id could read it through the scope-owner lookup. This residual is now closed by the ambient owner binding described in Artifact and cache ownership without a journal. - MCP05 (strengthens a control): with
proxycontainment each session is launched with a@playwright/mcpconfiguration that sets Chromium’s WebRTC policy todisable_non_proxied_udp;--proxy-serverdoes not carry WebRTC UDP, which would otherwise reach any address the pod can, loopback included.vendor::playwright::tests::a_proxy_server_is_passed_with_the_loopback_bypass_removed. Not yet exercised by the live containment job. - MCP05 (strengthens a control): those switches, and the loopback
bypass, are Chromium’s, so with a proxy set a non-Chromium
PLAYWRIGHT_BROWSER(Firefox, WebKit) is refused by the HTTP opt-in and at launch instead of running uncontained.vendor::playwright::availability::tests::containment_requires_a_known_shape_and_a_proxy_for_proxy,vendor::playwright::tests::a_proxied_session_refuses_engines_the_proxy_cannot_contain. - MCP07 (strengthens a control): an open that started before a
revocation and had not yet reserved its slot is refused at admission
(
403), and one in flight is refused at commit, so a revoked subject cannot end up holding a browser.browser::registry::tests::revoking_an_owner_retires_its_sessions_and_refuses_its_opens.
docs/playwright-http-deployment.md is the deployment shape;
docs/threat-model.md and docs/mcp-security-best-practices.md carry the
updated rows.
Playwright browser tools, stdio only (2026-10-12, issue #34 phase 1)
Section titled “Playwright browser tools, stdio only (2026-10-12, issue #34 phase 1)”The opt-in playwright feature adds six playwright_* tools over a managed
Microsoft Playwright MCP child process. The change touches:
- MCP02 (adds a control): a browser is the one stateful thing the server
hands a model, so every open page is named by an opaque handle bound to the
principal that opened it; another principal’s call receives the same
refusal a missing handle gets. Per-owner and global session caps are
admitted before any process is spawned, and a session that stops answering
loses its handle so it cannot hold a slot. A closed, expired or dead
session keeps counting against both caps until its teardown completes,
which runs on its own task so a cancelled caller cannot release the slot
early or leak it; a failed navigation likewise holds its reservation until
the launched browser has closed. Launch, navigation and commit run on
their own task that owns the reservation, so a caller dropped mid-open
cannot free the slot while a browser exists: the orphaned browser is
closed first. Shutdown cancels launches in flight rather than forgetting
them, refuses new opens, and waits (bounded) for them to close.
playwright_controller_tests::a_cancelled_open_holds_its_slot_until_its_browser_has_closed,::shutdown_refuses_new_opens_and_closes_a_launch_in_flight,playwright_controller_tests::a_closing_browser_holds_its_slot_until_teardown_completes,::a_cancelled_close_still_finishes_teardown_before_the_slot_frees,::a_failed_navigation_holds_its_slot_until_the_browser_has_closed,playwright_controller_tests::handles_are_bound_to_the_principal_that_opened_them,::capacity_is_admitted_before_launch_and_freed_by_failures,::a_session_that_stops_answering_loses_its_handle,devtools_contracts::browser::registry::tests. - MCP03 (adds a control): a fixed mapping of six upstream tools to
gateway-owned names, descriptions and schemas; nothing upstream is
re-exposed, and
browser_evaluateand the optional capability sets are never requested. The tools are advertised over stdio only, likebb_clone; HTTP omits them and refuses a direct call before anything is launched.tool_surface_parity_tests::tool_surface_matches_golden,::stdio_advertises_playwright_tools_when_selected,http_transport_tests::modern_tools_list_is_stateless_deterministic_and_cacheable,::http_refuses_playwright_tools_before_launching_anything. - MCP04 (changes a residual risk): the runtime is an operator-installed,
pinned Node package and browser set; the gateway downloads nothing and
refuses to start a session without an absolute
PLAYWRIGHT_MCP_CLI. The package and browser provenance are the operator’s responsibility, as the user’s Git installation is for cloning. No Rust dependency was added. - MCP05 (adds controls): the child is spawned without a shell, with an
allowlisted environment (
PATH,HOME, temp and locale variables,PLAYWRIGHT_BROWSERS_PATH; neverNODE_OPTIONS), headless and with an in-memory profile; only http and https URLs without user-info are opened; the bridge refuses a child line abovePLAYWRIGHT_MAX_MESSAGE_BYTESat framing, before any parsing or allocation past the cap, and every request has a deadline after which the child is killed. Finding: rmcp 3.5.1’s client read path has no cap (new_with_max_lengthgoverns writes only), so the bridge is a bounded line client of its own rather than rmcp’s client role.playwright_bridge_tests(oversize, hang, exit, server requests, missing binary),vendor::playwright::tests::the_child_command_is_headless_isolated_and_environment_allowlisted,playwright_controller_tests::only_web_urls_are_opened_and_nothing_is_launched_otherwise,playwright_live_tests::oversized_pages_are_bounded_or_refused_without_buffering. Residual: the browser’s own requests, redirects, WebSockets and downloads pass no egress policy and produce no egress record; the outcome record names the URL the session was opened on (note_subprocess). Network containment is a deployment control, which is why HTTP stays off until phase 2 proves one. - MCP06 and MCP10 (adds a control): snapshot and action text is page
content. It is bounded by bytes, nodes, depth and line length, scrubbed of
control characters, marked when truncated, and the tool text tells the
model to treat it as data. Fuzzed:
fuzz/fuzz_targets/browser_snapshot.rsandcrates/contracts/tests/cases/browser_snapshot_invariants_tests.rs. Screenshots never enter the text channel: they are size-checked on the encoded length and stored as artifacts the caller reads back by id. Residual: no detection of instructions inside page content; a steered follow-up call is bounded by policy as for any vendor content. - MCP08 (changes a control): each open records a subprocess entry with
the validated URL’s origin and path and whether the launch and navigation
succeeded, on the same outcome record
bb_cloneuses. - MCP01 (adds a control): a URL carrying user-info is refused before
launch, so a password never reaches the page, the registry or the audit
record, and the recorded destination drops the query and fragment, which
routinely carry signed-link tokens and OAuth codes. No credential is given
to the browser. Residual: a token embedded in the path itself (some reset
links,
;jsessionid=) is still recorded.playwright_controller_tests::urls_with_credentials_are_refused_and_audit_drops_the_query. - MCP07, MCP09: none touched. Inbound auth and sessions are unchanged; the child is a managed subprocess of this server, not a separately reachable MCP endpoint. No console changes.
Vault import operator mode (2026-10-12)
Section titled “Vault import operator mode (2026-10-12)”scripts/operations/import-configs-vault.py adds explicit administrator-file
bootstrap for a temporary writer. MCP01 adds credential separation, in-memory
writer handling, and redacted administration errors; MCP02 adds exact-path
policy scope, maximum TTL and attempted cleanup; MCP05 retains origin/path
validation and redirect refusal without subprocess execution; MCP07 adds
explicit operator authorization rather than elevating the runtime token.
MCP10 retains output limited to the operator-selected configuration, without
printing credentials. MCP03/04/06/08/09 controls are unchanged.
Evidence: test_import_configs_vault.py::test_operator_import_over_http,
::test_operator_mode_scopes_writer_and_cleans_up_without_logging_tokens,
::test_operator_preview_never_opens_admin_credentials_or_contacts_vault,
::test_operator_existing_output_prevents_policy_changes,
::test_operator_failure_revokes_writer_and_deletes_policy, and
::test_operator_token_file_and_cleanup_failures. This helper is a local
operator tool, outside MCP call admission and journaling. The admin credential
is trusted and remains in process memory; Vault audit configuration is an
operator responsibility. An interruption or provider failure can leave a
temporary policy; cleanup failures are reported, and issued tokens expire
within ten minutes.
Jira attachment uploads (2026-10-14)
Section titled “Jira attachment uploads (2026-10-14)”jira_upload_attachment adds a bounded multipart write capability; it does not
weaken the existing upload controls. MCP01 reuses credential resolution and a
metadata-only egress manifest (no file content). MCP02/MCP07 reuse POST egress
authorization and owner-bound artifact reads. MCP03 adds a deliberate native
tool and a description-only jira_post parity exception, locked by
tool_surface_matches_golden. MCP05 reuses identifier and filename validation
and accepts base64 or an owned artifact, never a filesystem path.
jira_upload_tests lock multipart bytes/auth/headers, refusal before dispatch
for malformed inputs and decoded size limits, cross-owner 404, and one attempt
for 403/429/500 responses. jira_upload_attachment_obeys_egress_denial_before_dispatch locks the
canonical POST authorization; the controller passes only names, sizes and
types to policy. multipart_transport_tests lock shared transport headers,
request bytes, single-attempt timeout handling and absolute-URL refusal. MCP08 retains the shared
transport attempt reporting. MCP06/MCP10 treat content and attachment metadata
as untrusted data; no automatic follow-up action is derived from them. MCP04
and MCP09 are untouched. No console code changed.
Residual: a timeout or server error can follow a successful or partial upload.
The tool never retries and reports an unknown outcome; callers must inspect
fields.attachment before retrying to avoid duplicates. Jira permissions and
server attachment settings remain upstream requirements. The new write remains
unclassified by the built-in extractor, like other unsupported generic writes;
enforcing file policies deny it rather than guessing a resource scope.
PR 2 client-credentials advertisement (2026-10-14)
Section titled “PR 2 client-credentials advertisement (2026-10-14)”MCP03 adds a gated capability advertisement, locked by
client_credentials_discovery_requires_authenticated_http_and_preserves_initialize
and client_credentials_is_absent_without_http_middleware_identity_and_on_stdio.
MCP07 adds strict startup validation requiring inbound OIDC, locked by
client_credentials_opt_in_is_strict_and_requires_inbound_oidc and
client_credentials_startup_refuses_disabled_auth_and_invalid_values.
It preserves token validation, scope, revocation and ordinary-client admission:
advertised_client_credentials_reuses_token_scope_and_revocation_checks covers
machine JWTs with wrong issuer/audience/expiry, missing scopes and revocation.
MCP01 and MCP02 preserve credential separation, policy and machine-admin controls:
advertised_client_credentials_machine_calls_remain_policy_bound, the shared
support/client_credentials.rs audit checks, and admin_principal_kind_tests
lock allowed/denied calls and refusal of two distinct machine administrators.
The console keeps this setting read-only and tests attempted edits.
No controls are weakened. MCP04/05/06/08/09/10 have no production control changes;
no dependencies, grant parser, token endpoint, subprocess or persistence is added.
AS registration, client credential rotation/renewal and trusted machine markers
remain operator/client responsibilities; advertisement cannot attest a token’s grant.
Durable Tasks lifecycle (PR 3)
Section titled “Durable Tasks lifecycle (PR 3)”MCP01/02/03/05/07/10 gain the controls above without weakening existing controls. The approval journal remains authoritative (MCP08 unchanged); PR 4 must reconcile proposal-linked projections before creating them. MCP04 pins and features are unchanged; MCP06 gains no generic content classifier, and MCP09 has no new control. No production tool is detached or projected in PR 3.
Residual risks: local mode shares the local owner; trusted filesystem ancestors, private Windows ACLs and reliable disk sync/locking are deployment obligations. Single-process SQLite is not multi-replica durability. Restored backups can be older than client-held handles; application result producers must minimize content. Generic interrupted tasks fail without replay. See tasks deployment and the implementation plan’s measured and unavailable evidence.
Approval status Tasks (PR 4)
Section titled “Approval status Tasks (PR 4)”MCP01/MCP10 preserve minimized payloads: proposal IDs, fixed status text and an
execution deadline, never arguments, reviewer text or credentials. The secrecy
checks in held_task_approval_is_status_only_and_exact_concurrent_reissues_execute_once
remain in force. MCP02 adds distinct cancellation: call_cancellation releases
only the requester’s hold; rejection still imposes its cooldown. Free text never
selects cancellation. MCP07 strengthens issuer isolation by partitioning the
hold index as well as task access; both replay and live cancellation require the
same owner. The cancellation/replay and issuer tests lock these changes.
MCP08 changes retained-history semantics: pending task completion still requires verified journal evidence, while terminal receipts remain readable without the gate. This relaxes terminal status revalidation, not execution authorization. A stale or tampered private task database can misstate historical status; it cannot grant permission. Receipt wording states the execution deadline, and an unspent approval first observed after expiry reports an error. Disabled-gate, expiry, partial-commit and fresh-admission tests lock these boundaries.
MCP03 preserves explicit negotiation and restores synchronous approval responses for custom gates without Tasks support or unavailable task storage. Ownership mismatches never fall back to a response disclosing another owner’s proposal. MCP05 retains parameterized SQLite operations, durability and retention bounds; the approval TTL bound is shared with runtime configuration. MCP04 dependency pins, MCP06 automatic-action boundaries and MCP09 distribution are unchanged. No console web behavior changes.
PR 3 task-store review corrections
Section titled “PR 3 task-store review corrections”MCP05 changes availability and filesystem lifecycle controls: direct CLI calls
cannot acquire the task lock or run server recovery; safe pristine first-boot
initialization resumes after interruption; bounded queue admission remains, but
slow replies and SQLite FULL no longer disable the store. Unexpired rows alone
count toward admission, and lowering the configured limit does not prevent
startup or access to retained tasks. The 4 GiB database ceiling accommodates
100,000 bounded results and indexes. Tests: tasks::sqlite::tests,
tasks::slow_worker_tests, and
task_lifecycle_tests::direct_cli_neither_locks_nor_recovers_the_server_task_store.
A stuck OS write can still stall the worker; physical disk exhaustion during
recovery refuses startup with an operator-facing diagnostic.
MCP03 changes version gating to accept supported later protocols and returns
method-not-found for legacy Tasks requests (task_wire_tests,
tools::tasks::tests). Lifecycle capability checks stay at rmcp dispatch;
creation retains a pre-commit capability check because rmcp checks tool task
responses only after dispatch. MCP07 owner checks and fresh HTTP authentication
remain unchanged, covered by task_wire_tests. MCP01 diagnostics use fixed
stage descriptions and configuration key names, never supplied values, paths,
credentials or stored results; existing wire storage checks remain in place.
MCP10 bounded outcomes are serialized once before queueing and reused for the
write, with existing payload-limit tests. MCP02, MCP04, MCP06, MCP08 and MCP09
are unaffected. No approval, auth, secret-redaction or corruption control is
weakened. The requested security-review skill was not installed; this review
used the repository’s OWASP checklist directly.
PR #179 review fixes (2026-10-14)
Section titled “PR #179 review fixes (2026-10-14)”MCP03 changes discovery to use the HTTP adapter’s explicit authenticated-session
flag and current configuration. Invalid or conflicting reloads withdraw the
advertisement and produce a value-free warning. Locked by
client_credentials_discovery_follows_config_reload_and_fails_closed and
client_credentials_discovery_requires_authenticated_http_and_preserves_initialize.
MCP07 scopes startup validation to HTTP and makes EMA reject conflicting vendor
values, locked by client_credentials_startup_refuses_disabled_auth_and_invalid_values,
client_credentials_does_not_gate_local_cli_runtime, and
ema_opt_in_refuses_conflicting_vendor_configuration. These changes strengthen
configuration validation without changing token admission.
MCP01 preserves secret-free diagnostics and credential separation; the shared
support/client_credentials.rs exercises discovery, policy and audit redaction.
MCP02 preserves policy and machine-admin restrictions with the flag both absent
and enabled (admin_principal_kind_tests). The two-machine check proves refusal
at the administrative read boundary; it does not exercise an approval chain.
No controls are weakened. MCP04/05/06/08/09/10 have no production control changes.
The test-only Keycloak cleanup awaits deletion after assertion failures and
preserves operator-owned scopes, locked by
service_account_scope_cleanup_survives_failures_and_preserves_operator_scopes.
The required security-review skill was unavailable locally; this review was
performed directly against the repository’s OWASP assessment and threat model.
CI wait tools (2026-10-14)
Section titled “CI wait tools (2026-10-14)”github_wait_workflow_run, gitlab_wait_pipeline, circleci_wait_workflow
and teamcity_wait_build (issue #183) poll one CI run until it settles. They
add no new control and weaken none. MCP03: four read-only, idempotent native
tools whose descriptions and schemas are locked by tool_surface_matches_golden.
MCP05/SSRF: tool arguments never name a host; owner, repo and project use the
existing single-segment validators, run, pipeline and build ids are u64, and
a CircleCI workflow id must be a canonical UUID, all rejected before any
request (ci_wait_tests::*_rejects_bad_arguments*). MCP02/MCP07: each poll is
an ordinary fetch, so egress authorization, revocation and credential
resolution run on every observation; the native read inventory gains the two
single-run GET endpoints for GitHub and GitLab (CircleCI’s /workflow/{id} was
already inventoried), and the TeamCity tool stays unmapped like teamcity_get.
MCP10: results are fixed-field projections with timedOut, polls and
waitedSeconds, far below the inline output bound. MCP08: every poll is a
journaled egress attempt. MCP01, MCP04, MCP06 and MCP09 are untouched; the run
status is vendor data and drives no follow-up action.
Residual: one call can make up to 721 upstream reads (3600 s at a 5 s
interval, plus the deadline check) and holds its handler for up to an hour.
Inbound rate limits count calls, not polls; the argument bounds, the
rate-limit back-off that honours Retry-After, and drop-on-cancel
(ci_wait_tests::dropping_a_wait_stops_polling) are the controls. No
docs/mcp-security-best-practices.md claim changes.
PostgreSQL connection diagnostics (2026-10-16)
Section titled “PostgreSQL connection diagnostics (2026-10-16)”MCP01 adds credential exclusion to connection warnings: connection configuration is never formatted, client-side cause text masks the selected username/password, and server ErrorResponse text is omitted in favour of SQLSTATE. MCP08 adds warning-level destination and cause-chain diagnostics for failed plaintext and TLS connections, plus TLS setup warnings. These filtered process logs supplement the audit outcome; they are not durable journal evidence. Host, port and database names are intentionally retained for operators. MCP06/MCP10 do not gain a new model-visible surface: tool error envelopes are unchanged, and server prose does not enter the new warnings. MCP02–MCP05, MCP07 and MCP09 controls are unchanged.
postgres_connection_tests::refused_connections_log_destination_and_underlying_io_cause,
::tls_rejection_logs_underlying_cause and
::server_rejection_logs_sqlstate_without_server_text_or_credentials lock the
warning level, destination, underlying cause and exclusion controls through
real loopback connections. No dependencies, destination selection, TLS policy,
authorization, SQL execution or audit payloads change.
Jira binary attachment downloads
Section titled “Jira binary attachment downloads”jira_download_attachment adds opaque binary retrieval through the existing
streaming transport and artifact store. MCP01, MCP02, MCP03, MCP05, MCP06, MCP07,
MCP08 and MCP10 gain coverage for this native tool; no existing control is
weakened. Numeric IDs and the fixed content route prevent caller-selected URLs;
redirect authorization and credential stripping use the shared controls.
Generated server filenames ignore upstream Content-Disposition paths; archives
are never extracted or executed, and metadata has no text preview. Byte limits,
disk quotas, deadlines and drop cleanup are inherited from streaming downloads.
The resource extractor deliberately remains fail-closed for this unclassified
operation. MCP04 and MCP09 are unchanged; no dependency or server is added.
Regression evidence: jira_download_tests (binary round-trip, ownership, ID and
size rejection, redirect policy and egress denial), background_task_tests::jira_download_supports_tasks_and_synchronous_artifact_retrieval
(MCP task and synchronous retrieval), and tool_surface_matches_golden.
Locally saving or extracting a returned archive is a client action; consumers
must treat its contents as untrusted data.
Native keychain migration (2026-10-19)
Section titled “Native keychain migration (2026-10-19)”MCP01 strengthens native-store selection and diagnostic redaction: the identity
adapter owns an explicitly constructed store, never a global default or mock.
Linux uses DH-encrypted Secret Service sessions; current service/account keys,
legacy fallback and deletion of both namespaces remain intact. Initialization
failure is Unavailable, including keychain-disabled builds. Errors reading the
current namespace do not trigger legacy fallback; failure deleting the legacy
entry cannot be hidden by a missing current entry. Covered by
auth::keychain::backend::tests and crates/identity/tests/keychain_tests.rs.
The isolated scripts/ci/keychain-compatibility.sh writes 24 synthetic entries
using the previous backend and checks reads/deletes using the replacement.
MCP04 changes the dependency trust base: keyring-core plus explicit Apple,
Windows and dbus Secret Service stores replace the v1 convenience wrapper.
zbus_names, zvariant, and zvariant_derive leave the product lockfile;
cargo deny --locked check --deny warnings remains unsuppressed. Linux vendors
libdbus for static linking; Cargo metadata/advisories do not fully cover embedded
C sources. Track the bundled libdbus revision, retain its license/source notice,
and rebuild binaries for C fixes. The distroless and system packages keep
keychain disabled. CI retains native platform gates and no longer requires
unused libsecret/system libdbus headers.
MCP02, MCP03, MCP05–MCP10: no production control changes. There are no new tool
surfaces, policy permissions, vendor-content actions, subprocesses, file paths,
inbound identities, or audit payloads. The isolated compatibility fixture uses
only a private bus and temporary synthetic keyring, not personal credentials.
No console code changes. See docs/keychain-backends.md for validation limits.
Inference path profiling fixes (2026-10-28)
Section titled “Inference path profiling fixes (2026-10-28)”A profile of the runtime security PRs’ in-process work
(benches/inference_stream.rs; record in
docs/benchmarks/2026-10-28-inference-path-profile.txt) found the
streamed completion review rescanning its whole lookback for every delta
(about 600 times the cost of reviewing the same text once), and the
usage scanner parsing every event as a document tree. The fixes keep
every detection claim. One of them closes a gap the review found: an SSE
event past the framer’s 1 MiB bound was held in full, without limit,
while the upstream kept the line going, and was then relayed uninspected.
| Category | Effect on controls | Regression evidence |
|---|---|---|
| MCP04 | No control changes. regex-automata, regex-syntax and memchr become direct, exact-pinned dependencies of devtools-contracts; all three were already in the lockfile through regex, so the graph gains no crate |
cargo deny (unchanged graph) |
| MCP05 | Changes parsers over provider bytes, each under its invariants and fuzz target: the SSE framer moves line bytes by slice (chunking independence unchanged); the usage scanner skips events whose bytes cannot name usage (its report must equal one that parses every event); the secret gate is a new automaton whose “nothing new” answer must leave every pattern’s matches unchanged |
sse_scanner_invariants_tests, sse_usage_invariants_tests::a_usage_key_spelled_with_an_escape_is_still_read, secret_detector_invariants_tests::the_gate_over_approximates_word_boundaries (targets sse_scanner, sse_usage, secret_detector) |
| MCP06, MCP10 | Strengthens a control. Streamed completion review finds what it found before, since the gate only skips windows that provably add no match, and the inspection bound still applies to skipped windows. An over-bound event is no longer relayed uninspected: the rule’s on_error decides (deny by default), and its bytes are never held, so a provider cannot grow the relay’s memory without limit |
inference_guard_tests::an_event_past_the_event_bound_is_refused_not_relayed_uninspected, inference_guard_tests::a_clean_stream_whose_window_outgrows_the_bound_is_still_terminated, inference_guard_tests::secret_split_across_deltas_terminates_the_stream, secret_detector_invariants_tests::the_gate_asks_for_inspection_when_a_split_token_completes |
One finding difference is deliberate. A window cut by the lookback used to
report a token whose start the cut had separated from a preceding word
character (x|ghp_…), a token the patterns do not match in the stream
itself or on the buffered and non-streamed paths. The gate does not ask for
that window, so the three paths now agree. MCP01, MCP02, MCP03, MCP07,
MCP08 and MCP09 are untouched: journal records, telemetry fields, budgets
(request_bytes is the same count, without copying the arguments) and the
tool surface are unchanged. Residual: a completion rule that fails open
(on_error: allow or label) still lets an over-bound event through
uninspected, as that setting chooses.
Adversarial workflow tests (2026-10-28, governance plan WP6)
Section titled “Adversarial workflow tests (2026-10-28, governance plan WP6)”Tests only: tests/adversarial_workflow_tests.rs closes follow-up 2 with
five multi-step workflows (the table in that section). No production code,
setting, tool surface or policy semantics changed. The fixtures commit no
credential-shaped string: planted credentials are a placeholder filled in
at run time.
| Category | Effect on controls | Regression evidence |
|---|---|---|
| MCP02 | No control changes. Flow rules and budget holds are now exercised as an attacker chains them: across sessions, client names and paths, and under a retry loop | adversarial_workflow_tests::prod_logs_read_in_one_session_cannot_leave_through_another, adversarial_workflow_tests::runaway_tool_loop_is_held_at_its_budget_not_run |
| MCP06 | No control changes. Corrects the residual: cross-call tracking is by class since PR 4, so only unclassified, unlabelled reads remain journaled rather than prevented. Adds evidence that a flow rule stops a secret a prompt content rule cannot see (base64 in text) | adversarial_workflow_tests::confluence_injection_cannot_carry_its_planted_credential_into_a_write, adversarial_workflow_tests::tool_result_secret_cannot_reach_a_model_even_encoded |
| MCP07 | No control changes. A revoked owner’s open session, stateless calls, prompts and a token minted after the revocation are all refused at authentication | adversarial_workflow_tests::revoked_owner_is_stopped_mid_workflow_on_both_paths |
| MCP10 | No control changes. Every case asserts the journal holds none of the adversarial text | adversarial_workflow_tests::prod_logs_read_in_one_session_cannot_leave_through_another, adversarial_workflow_tests::tool_result_secret_cannot_reach_a_model_even_encoded, adversarial_workflow_tests::revoked_owner_is_stopped_mid_workflow_on_both_paths |
Each case was checked against its control by disabling the control in the source and rerunning the suite: an owner’s taint read as empty failed the flow-rule cases (1 and 3) only; write-argument inspection skipped failed case 2 only; budgets ignored failed case 4 only; the revocation list ignored failed case 5 only. All five pass on the unmodified source. MCP01, MCP03, MCP04, MCP05, MCP08 and MCP09 are untouched. Residual unchanged: whether an agent follows injected instructions is the agent harness owner’s to assess (threat model §8), and detection limits are as stated under MCP06.
Fuzz lockfile advisories and Dependabot version updates (2026-10-28)
Section titled “Fuzz lockfile advisories and Dependabot version updates (2026-10-28)”Dependabot reported four rustls advisories in fuzz/Cargo.lock. The fuzz
crate is self-rooted, so the root lockfile remediation never reached it; it
carried the same ten RustSec advisories and one yanked crate. The fuzz lock is
now regenerated from the root lock and held there, and Dependabot version
updates are configured (the file was an unfilled template).
| Category | Effect on controls | Regression evidence |
|---|---|---|
| MCP04 | Adds controls. Every crate shared with the root lock must resolve to a root version, so the root cargo deny gate covers it; the fuzz-only crates (libfuzzer-sys, arbitrary) get their own advisory and yanked-crate step in the quality job against fuzz/deny.toml. Dependabot proposes weekly cargo updates for the root workspace and libfuzzer-sys, skipping releases younger than seven days |
binary_tests::fuzz_lock_resolves_shared_crates_to_root_versions |
The fuzz binaries do not ship, so no runtime control changes. MCP01, MCP02, MCP03 and MCP05–MCP10 are untouched. Residual: a Dependabot cargo PR that moves a shared crate fails CI until someone regenerates the fuzz lock on its branch. That is deliberate, because automating it would need a write token in Dependabot-triggered workflows. License and ban policies are not applied to the fuzz graph.
Operator roles and incident response runbook (2026-10-28, governance plan WP5)
Section titled “Operator roles and incident response runbook (2026-10-28, governance plan WP5)”Documentation only: the shared deployment checklist gains an
operator roles table and
docs/incident-response-runbook.md strings
the existing revocation, generation, approval, secret-rotation and audit
controls into one detect, triage, contain, evidence, recover and learn
procedure, rehearsed against the demo stack. No code, setting, tool surface
or policy semantics changed.
| Category | Effect on controls | Regression evidence |
|---|---|---|
| MCP07 | No control changes. The roles table states what the gateway enforces between roles (scope, possession of the policy key, a distinct approving subject, refusal of machine administrators) and what it does not: author, approvers and online revoker share mcp:admin, so their separation is the organization’s. The deployment prerequisite gains the role assignment |
admin_api_tests::auth_role_and_json_contracts, unified_call_approvals_tests::self_approval_other_tenants_rejection_and_stale_tokens_are_refused, admin_principal_kind_tests::default_policy_refuses_machines_at_admin_and_nowhere_else |
| MCP08 | No control changes. The runbook names the journal records and gauges that signal an incident, and the evidence steps (audit verify against off-host checkpoints, activity export, SIEM comparison). It records a detection the journal already supports: a generation change with no admin_mutation before it was installed as files, outside the approval gate. The deployment prerequisite gains the runbook |
audit_checkpoint_tests::the_verifier_catches_edits_forgeries_wrong_keys_and_a_truncated_tail, audit_report_tests::activity_export_flattens_every_record_and_filters, revocation_tests::a_revoked_subject_is_refused_within_a_poll_and_the_refusal_is_journaled |
The runbook joins the documents docs_evidence_tests checks, so its 16
cited tests fail CI if they disappear. MCP01, MCP02, MCP03, MCP04, MCP05,
MCP06, MCP09 and MCP10 are untouched. The threat model and the
best-practices map are unchanged because no control or residual risk
changed. Residual, now stated rather than implied: automation cannot
contain a subject without the policy private key, which must not be given
to it; a time-bounded suspension for automation is
#199 and
policy-driven automatic responses are
#200.
Governance evidence: data map, export columns, dashboards (2026-10-27, runtime security plan PR 5)
Section titled “Governance evidence: data map, export columns, dashboards (2026-10-27, runtime security plan PR 5)”| Category | Effect on controls | Regression evidence |
|---|---|---|
| MCP08 | The rollup store gains class rows (timestamp, tenant, subject, vendor, class, tool, model), one per class an outcome marked on its owner, fed from both paths over the usage pipeline’s own bounded channel with its own drop counter; the activity export appends the flow and budget columns (owner_classes, labels, marked, budget_ids, budget_states, budget_settlements); the OTLP exporter types numbers and booleans so a trace store can sum tokens and durations, and deploy/inference/ provisions the five first questions as read-only dashboards. Nothing new is evidence: rollups and spans stay lossy and pruned, and the journal’s marked column is the record the data map summarises |
rollup_store_tests::data_map_groups_classes_per_subject_and_model, ::consumer_drains_class_events_into_class_rows, flow_policy_tests::class_rows_reach_the_data_map_from_both_paths, audit_report_tests::export_includes_inference_and_flow_rows, telemetry::otlp::tests::known_usage_exports_each_count_as_an_integer |
| MCP10 | The data map is tenant-bound by construction: reports/data-map fills the tenant from the caller’s verified identity and takes no tenant field, the generic usage route refuses a data_map query, and the console page sends a window and never a tenant; class rows carry class ids, never the matched value; the CLI file form takes --tenant explicitly because a file has no caller. The compose front end answers 404 for the gateway’s unauthenticated /metrics page, so turning it on for the overlay exposes nothing through TLS |
admin_api_tests::data_map_is_bound_to_the_callers_tenant, unified_console_tests::data_map_is_tenant_scoped, operations_tests::compose_front_end_never_forwards_metrics |
| Web OWASP (console) | The new page is a read-only GET rendered through the same session, security headers, origin guard and htmx fragment pattern as every other page; every value is escaped by the template engine; the filters are applied in the handler and never reach a query language; no script was added | unified_console_tests::every_route_carries_the_security_headers, ::data_map_is_tenant_scoped |
MCP01, MCP02, MCP03, MCP04, MCP05, MCP06, MCP07 and MCP09 are untouched: no new dependency, outbound destination, tool, description, schema, policy semantics or authentication path; the overlay’s images are pinned by digest with the verification recorded in its README, and the observability containers run read-only, unprivileged and unpublished except Grafana on loopback. Residual: the data map and the dashboards are telemetry, lossy under back-pressure and bounded by retention; Grafana’s own login is a separate administrator set per deployment and not the gateway’s identity.
Flows, budgets, kill switch and findings-triggered holds (2026-10-26, runtime security plan PR 4)
Section titled “Flows, budgets, kill switch and findings-triggered holds (2026-10-26, runtime security plan PR 4)”| Category | Effect on controls | Regression evidence |
|---|---|---|
| MCP02 | New control: flows rules are an authorization input on both paths, evaluated against the verified owner’s taint classes (environment classes of upstreams the owner acted on, classes content rules labelled on results and completions), keyed by OwnerKey and never by session, token or run id; the taint store is bounded and refuses a new owner rather than forgets (fail closed); budgets reserve at admission under one lock per meter, per owner or per tenant, all or nothing, and an exceeded budget denies or holds; a prompt rule may now hold; what a flow or budget cannot say (spend_usd, unknown classes or vendors, subjects other than prompt) is a load error |
flow_policy_tests (all seven), run_budget_tests (all fourteen), policy_engine_tests::flow_rule_denies_prompt_after_prod_read, ::schema_v3_refuses_what_this_build_does_not_enforce, guard::taint::tests, guard::budget::tests |
| MCP07 | The inference path’s held requests go through the same call-approval gate as tool calls, under a hold key that digests the exact body and the findings; the signed revocation list refuses the subject on /v1/* and /mcp within one poll, closes its MCP sessions as before, and leaves its taint in place; the owner classes are part of the authorization fingerprint, so a decision cache never serves a pre-taint allow to a tainted owner |
unified_call_approvals_tests::inference_hold_round_trip, revocation_tests::revoked_subject_is_refused_on_both_paths_and_keeps_its_taint, policy_engine_tests::flow_rule_denies_prompt_after_prod_read (fingerprint), hold_key::tests::an_inference_key_binds_the_body_digest_and_the_findings |
| MCP08 | Intent records carry budget.entries (scope, window start, estimate, unknown_usage, state) and the proposal a held inference request waits on; outcome records carry each settlement, charge and over-run, and marked for the classes that became the owner’s taint; tool-call intents carry action.owner_classes; a budget or flow denial is a policy decision naming the rule; nothing new holds content |
run_budget_tests::token_budget_denies_at_admission_when_the_estimate_does_not_fit, ::budget_hold_routes_to_approval, flow_policy_tests::prod_read_then_jira_write_is_denied_for_the_same_owner, unified_call_approvals_tests::inference_hold_round_trip |
MCP01, MCP03, MCP04, MCP05, MCP06, MCP09 and MCP10 are untouched: no new
dependency, outbound destination, tool, description or schema; the held
request’s response names a proposal id, an expiry and a hold key digest
and never the body; MCP_TAINT_MAX_OWNERS and MCP_TAINT_TTL_SECONDS are
operator settings with clamped ranges. Residual: taint and budget state is
per process and in memory (one enforcing replica, afresh on restart);
budgets bound reservations, not what a provider then reports; marks come
from environment classes and content-rule labels, not yet from data
profile field classes (database dispatch is still gated).
Inspection on the inference path and result labeling (2026-10-25, runtime security plan PR 3)
Section titled “Inspection on the inference path and result labeling (2026-10-25, runtime security plan PR 3)”Schema 3 content rules now cover every subject: tool_result at the one
result boundary (enterprise_response::finalize, decision 16), prompt
before forwarding and completion before relay on the inference path
(§7), each over decoded text (decision 15). Two new fuzzed parsers live in
devtools-contracts (guard::prompt, guard::stream) with the shared
SSE framer behind them; the webhook adapter is the first external engine.
Local mode is unchanged: no rule, no inspection, and the tool-surface
golden is byte-identical.
- MCP01 strengthened. A credential in a prompt is refused before it
reaches the provider; one in a completion is stopped before the client
gets the chunk, or never released in
buffermode; one in a tool result can be refused or redacted in both representations before the task record or an artifact holds it. Prompts and completions are still never persisted (inference_guard_tests::sensitive_values_never_reach_storage_or_logs, against a journal file and the process log). The engine token is a server setting, resolved per call, never logged. - MCP05 adds two fuzzed parsers over provider and client bytes and one
bounded outbound call. The extractor yields exactly the document’s
string values under a count cap; the scanner’s windows are bounded by
the lookback plus one event and are chunking-independent; both run in
linear time (
invariantsmodules, seeded corpora, targetsprompt_textandsse_scanner). The webhook is one operator-sethttps(or loopback) origin, a clamped timeout, a 64 KiB answer, throughcrate::transport; nothing in a subject selects a destination. - MCP06 widens the control: injected content that a model echoes, or that a vendor returns, is now inspected too, with the same stated limits. The non-claim “response content is not inspected” is retired in the governance spec and the best-practices map.
- MCP08 adds
content_findingrecords for the three new subjects (joined by event id),labelson outcome records, two new inference outcomes (content_terminatedwithbytes_sent,content_denied), and the engine on the egress record (vendor: guard). A result finding that cannot be journaled withholds the result. - MCP10 narrows disclosure further: a denied result carries no
structuredContent; the caller-facing decision omits the response rule list too; the overflow artifact is written after inspection. - MCP02 unchanged in kind: actions a subject cannot take (
redacton a prompt,holdon a result or completion,modeoutside a completion rule) are refused at load, andon_errorstays deny by default, including for an engine that is down. MCP03, MCP07, MCP09 unchanged: no tool name, description or schema changes, no inbound identity or session change, no new server. MCP04: no new crate; the webhook uses the pinnedbase64and the HTTP facade.
The pre-merge security review found three defects, fixed before the PR
opened and locked by tests: the prompt extractor skipped object member
names (a credential as a tool-schema property went to the provider;
secret_as_a_tool_schema_key_or_behind_a_data_prefix_is_denied), the
result boundary released a result when an engine refused with a verdict
and no findings (engine_verdict_without_findings_refuses_the_result),
and the data: URI check could panic on a multibyte header (seeded and
fuzzed). Three observations were closed too: the stream relay now holds
the bytes of an event in progress until it is reviewed, a data: prefix
over non-base64 text is inspected as text, and a buffered completion past
the bound is refused rather than answered with an empty body.
Residual: detection is probabilistic and over decoded text; base64 block
payloads (made of the base64 alphabet) and binary artifacts are not
decoded; in stream mode the bytes of completed events relayed before a
finding are gone (the record says how many); a prompt or completion rule
cannot hold for approval or redact; a webhook engine reports counts, so it
cannot drive redaction. Flow rules and budgets are PR 4.
Content guard on the tool path (2026-10-23, runtime security plan PR 1)
Section titled “Content guard on the tool path (2026-10-23, runtime security plan PR 1)”Schema 3 adds classes and content to the signed policy bundle, and a
ContentGuard port inspects the decoded strings of a non-read call’s
arguments after admission and before the approval gate and the intent.
The built-in detectors are the logger’s nine credential patterns, moved to
devtools-contracts with an invariants module and the secret_detector
fuzz target. Local mode wires NoopGuard, which is never consulted
because AllowAll attaches no content rules; the tool-surface golden and
content_guard_tests::local_mode_runs_no_guard lock that it stays
byte-identical.
- MCP01 strengthened. Redaction now removes the union of every
pattern’s matches on the original text; before, sequential replacement
could consume a URL’s scheme and leave
user:passin a log line (overlapping_matches_are_redacted_as_one_span). A credential in a write argument is refused before it reaches the vendor; refusal text, response metadata and journal never contain it (content_guard_tests). - MCP02 adds a control. Content rules can deny or hold an allowed write.
A hold binds the findings digest into the hold key under a separate
domain, so the plain v1 key is unchanged for calls without findings.
Fail mode is per rule, deny by default. Anything this build cannot enforce
is refused at load (
schema_v3_refuses_what_this_build_does_not_enforce). - MCP05 adds a fuzzed parser over attacker-controlled text: the
detector is regex-crate automata (linear time, no backtracking) and its
subject is bounded by
MCP_GUARD_MAX_SUBJECT_BYTES. No subprocess, path or URL is derived from content. - MCP06 adds a control with stated limits: injected credentials cannot be posted by a guarded follow-up write. Detection is probabilistic (decision 3); results, prompts and completions are not inspected yet.
- MCP08 adds
content_findingjournal records and export columns, joined by event id; a finding that cannot be journaled refuses the call. - MCP10 narrows disclosure: the caller-facing
_meta.decisionomits the content rule list; findings and explain output carry no text. - MCP03, MCP07, MCP09 unchanged: no tool name, description or schema
changes (the golden is unchanged), no inbound identity or session change,
no new server. MCP04:
devtools-contractsgains an edge to the already pinnedregex =1.13.1; no new crate enters the lockfile.
Residual: the guard does not see binary payloads or matches split across two strings, and patterns can miss secrets that are not credential-shaped or flag look-alikes. The request-side policy remains the provable boundary; content rules are enforcement with stated detection limits.
Signing-key rotation (issue #150)
Section titled “Signing-key rotation (issue #150)”The operator-side workflow adds MCP01/MCP02/MCP05/MCP07/MCP08/MCP10 controls; see rotation deployment and recovery. Console access also uses existing browser access control, autoescaped templates, CSP and no-store headers. It adds no browser mutation or CSRF exception. Automatic activation currently supports one same-account systemd user service; actual Linux/systemd operation requires platform validation. The recovery record holds environment-file digests, not contents; the directory stays owner-only because it holds the replacement private key until the operator moves it (policy activation refuses until then). A valid signature under a compromised old key does not establish trusted policy content. No claim of automated multi-host rollout or browser-side key custody is made.
PR #186 review fixes
Section titled “PR #186 review fixes”- MCP01 narrows exposure:
rotation.jsonbinds the service environment file by SHA-256 digest and rebuilds either version by switching only the rotated settings in place, so credentials in that file are never copied and the record no longer grows with it (rotation::recovery_record_holds_no_environment_contents). - MCP02 adds two refusals: policy activation while the replacement
private key is still in the rotation directory
(
rotation::policy_activation_refuses_while_the_private_key_is_in_the_rotation_directory), and activation after a failed rollback, which now has its ownrollback_failedphase (rotation::a_failed_rollback_is_resumed_only_by_rollback). A preparation whose key was moved before its record was written refuses to generate a second key (rotation::a_moved_replacement_key_is_never_regenerated). - MCP05 tightens local file handling: staged files are installed whole
through the policy crate’s atomic writer, the transition log is opened
without following a symlink and never read back
(
rotation::transition_log_symlink_is_refused_and_its_target_untouched,rotation::transition_log_is_appended_without_being_read_back), and systemctl runs through the shared no-shell helper from a fixed location. Blocking lock waits run off the async workers (rotation::activation_waits_for_locks_without_blocking_the_runtime). - MCP03, MCP04, MCP06, MCP07, MCP08, MCP09, MCP10 unchanged: no tool, description, schema, dependency, inbound identity, journal or disclosure change; the console page gains operator guidance text only.
Local classifier residual risks
Section titled “Local classifier residual risks”The reviewed ONNX profile and opt-in tests add CPU classification evidence, not a
production blocking recommendation. The validation record
reports misses and false positives. Its tokenizer normalization is preserved from
the pinned JSON; Transformers AutoTokenizer reconstruction is not interchangeable.
The extra exact CDN origin us.aws.cdn.hf.co retains redirect credential stripping
(test_artifacts.py::test_trained_model_cdn_strips_repository_authorization).
The optional service adds a trusted native ML runtime and model supply chain. Model hashes establish byte identity, not model safety; the default manifest uses SHA-256 for weights and Git SHA-1 object IDs for tokenizer/configuration artifacts. ONNX external-data references are interpreted by the runtime, so use a dedicated account and filesystem restrictions. Offline library settings are not network containment. GPU execution, production model quality and performance need deployment-specific validation. Timeouts/cancellation kill the worker group and fail the subject without replay. Known worker failures can recover within a lifetime budget, capped backoff and fresh verification/warmup; permanent artifact/configuration errors and exhausted budgets require an operator restart. Ordinary input-window limits preserve readiness. The systemd example is not yet host-qualified: cgroup cleanup after supervisor SIGKILL/OOM remains a deployment validation requirement. See local model guards for deployment and validation limits.
MCP Skills extension (2026-10-14, issue #177)
Section titled “MCP Skills extension (2026-10-14, issue #177)”Adds opt-in publication of embedded, reviewed workflows through
io.modelcontextprotocol/skills (ext-skills Beta @ff22433). See
MCP Skills and the ADR.
No existing control is weakened.
| Category | Change | Locked by |
|---|---|---|
| MCP01 | Adds: content is an explicit compiled allowlist; no credential, configuration or vendor data reaches it; secret-shaped content, dotfiles and unlisted files fail packaging | skills_packaging_tests::packaged_content_carries_no_secret_or_internal_material, ::allowlist_tree_and_embedded_bytes_are_identical |
| MCP02 | Adds: frontmatter allowlist refuses allowed-tools and unknown fields; scripts and binaries are unservable media types; visibility requires the session to already hold every tool the skill names; tool calls keep admission, policy and approvals unchanged |
application::skills::tests::invalid_packages_are_refused_whole, ::hidden_skills_are_absent_from_every_query, skills_wire_tests::skills_whose_tools_the_session_lacks_do_not_exist_for_it |
| MCP03 | Adds: opt-in, live, fail-closed advertisement in server/discover only; initialize, disabled and legacy surfaces byte-unchanged; malformed opt-in refuses startup; invalid catalog refuses startup when enabled. Console administrators may toggle the opt-in through the authenticated configuration path, which accepts exactly true or false; enabling publishes only the startup-validated catalog and grants no tool access |
configuration::tests::skills_opt_in_is_editable_with_exactly_the_runtime_values, skills_wire_tests::enabling_by_reload_serves_a_server_started_with_skills_off, skills_wire_tests::disabled_surface_is_unchanged, ::legacy_initialize_sessions_keep_the_pre_extension_surface, ::malformed_opt_in_refuses_startup, ::an_invalid_catalog_refuses_startup_only_when_enabled, ::disabling_by_reload_refuses_requests_that_follow_stale_discovery |
| MCP04 | Unchanged dependencies; the Docker build copies skills/; symlinked packaging inputs are refused |
skills_packaging_tests::the_tree_walk_refuses_symlinks |
| MCP05 | Adds: canonical fuzzed URI and cursor grammar, exact map lookup, no filesystem or network access on any request | skill_uri_invariants_tests, skills_wire_tests::stdio_rejects_malformed_unknown_and_oversized_requests |
| MCP06 | Changes: a skill is new model-facing instruction content. The shipped skill is read-only, treats PR text and diffs as untrusted and binds CI evidence to head.sha; digests let hosts detect substitution |
Review of skills/; skills_packaging_tests::the_embedded_catalog_validates |
| MCP07 | Adds: one gate for all Skills methods; HTTP requires the validated bearer (client _meta is not identity); legacy sessions stay owner-bound |
skills_wire_tests::http_serves_authenticated_principals_and_refuses_everyone_else, ::http_legacy_sessions_are_bound_to_their_owner |
| MCP08 | Unchanged: reads of static product content are logged at debug level, not journaled; tool calls made while following a skill are journaled as before | — |
| MCP09 | None touched | — |
| MCP10 | Adds: shared content identical across principals and tenants, cacheScope: private, no interpolation |
skills_wire_tests::http_serves_authenticated_principals_and_refuses_everyone_else |
Residual: skill text is still instructions a model may over-trust; activation
consent, origin binding and digest verification are host responsibilities,
and digests are unsigned (consistency, not trust). Skill reads are not audit
evidence. A compatible-client walkthrough (MCP Inspector 2.13.0 over stdio,
including --verify digest checks) passed on 2026-10-29; authenticated HTTP
with a real client and an end-to-end agent run remain unexercised (see
MCP Skills).
Scripted evaluation slice (2026-11-01)
Section titled “Scripted evaluation slice (2026-11-01)”MCP04 adds the excluded evaluation lock and CI gates described above. MCP06
and MCP10 gain scripted measurement, without changing enforcement or the
agent-runtime residual: one paired runbook workflow checks both paths and
independent destination evidence (workflow_tests::paired_runbook_workflow_proves_containment_and_benign_completion).
MCP01 and MCP07 retain synthetic, isolated credentials and one authenticated
owner on both paths, checked by that same test. MCP05 adds only owned loopback
listeners in the test consumer (deployment::tests::dropping_a_listener_releases_its_socket);
no production egress control changes. MCP08
keeps the production journal unchanged; the harness joins its events and treats
missing admitted outcomes as incomplete (journal::tests::a_missing_outcome_is_incomplete_and_dispatch_does_not_imply_success).
MCP02, MCP03 and MCP09 production controls are unchanged. This is not a live
agent baseline or a new model-backed release gate. See eval/README.md.
Reference-runtime smoke (2026-11-01)
Section titled “Reference-runtime smoke (2026-11-01)”The evaluation-only adapter adds controls for MCP01 (credential separation), MCP04 (locked Python/SDK/bundled CLI and CI), MCP05 (process ownership and bounded execution), MCP07 (one owner on both gateway paths), MCP08 (required terminal and destination evidence) and MCP09 (one configured MCP server, built-in tools off, inherited settings/plugins removed). The tests and limits are in the compatibility record.
MCP02 and MCP03 controls are unchanged: no production policy, extractor, approval, tool schema or description changes. MCP06 and MCP10 add measurement only: injected content still comes from the shared fixture; the scripted provider is not an agent-resistance test, and reports never contain content. The initial smoke kept denied inference incomplete. The terminal-evidence extension requires a compatible API-403 terminal receipt, its matching SDK exit wrapper, the final same-owner policy deny and settled destination evidence before reporting policy refusal. Crashes, timeouts, exhausted turns, cancellation and missing evidence remain incomplete. Positive observed effects survive errors; negative results require a complete trial. All ten categories were reviewed with the security-review skill; no remaining high-confidence high/medium findings. No console or web OWASP surface changed.
Residuals: this is not an OS sandbox against a compromised native runtime, escaped process groups, supervisor SIGKILL/OOM or same-user filesystem/keychain access. Linux CI forbids external networking; local macOS validation uses explicit loopback destinations, isolated configuration and disabled ancillary traffic. The Linux namespace gate still needs runner execution. No live-model baseline or new model-backed release gate is claimed.
Manifest/terminal extension review: MCP01 and MCP10 extend metadata-only export
controls; MCP04 adds source/lock fingerprints and a separate fail-closed Linux
qualification job; MCP08 strengthens event joins and terminal evidence
(runtime_tests::runtime_refusal_requires_terminal_gateway_and_destination_evidence,
runtime_tests::cancelled_trial_has_a_manifest_and_no_negative_attack_result).
MCP05 preserves bounded process cleanup and adds namespace preflight evidence;
the Linux lane now requires a root-owned, runner-group namespace helper with
an exact-path AppArmor permission (provisioning and scope).
Both host AppArmor restrictions remain enabled; SDK execution
is pending. Trusted jobs sharing the runner account share this permission;
MCP07 preserves same-owner routing; MCP09 retains the single-server/tool limits.
MCP02/MCP03 production controls are unchanged. MCP06 adds measurement only.
No production enforcement control or residual boundary is weakened, and no web
console surface changes. Linux runner qualification remains pending.
Project website (2026-11-01)
Section titled “Project website (2026-11-01)”The static website in site/ is not part of the shipped binary or any
image (.dockerignore excludes it). It publishes an allowlisted subset of
docs/ and the README integration table.
| Category | Effect on controls | Regression evidence |
|---|---|---|
| MCP01 | Publication can disclose internal material, so it is gated twice: only documents in site/docs.json are published, and a document carrying an internal-classification marker is refused even when listed. Links into the private repository, source paths and unpublished documents render as plain text; a link to an unreviewed document fails the build |
site/test/doc-publish.test.ts (the repository’s classified documents are detected), site/test/doc-links.test.ts |
| MCP04 | Adds an npm dependency graph for the site only: exact pins, a committed lockfile, install scripts disabled (ignore-scripts), weekly Dependabot updates with a seven-day cooldown. No new GitHub workflow or repository secret: Cloudflare Workers Builds holds the deploy credential. Rust and CodeQL workflows skip site-only changes; mixed changes run them in full |
npm ci from the lockfile; npm audit clean at introduction |
The site sends a hash-based Content-Security-Policy with no inline-script
allowance, frame-ancestors 'none', HSTS and related headers. The post-build
audit fails on inline event handlers, javascript: URLs and broken internal
links (site/test/site-audit.test.ts). It was checked in Chrome for CSP
violations across theme switching, search and the mobile menu, and an injected
inline script was confirmed blocked. MCP02, MCP03, MCP05–MCP10 are untouched:
the change adds no tool, policy, subprocess, inbound surface or journal
behavior.