Skip to content

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.

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.

“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

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

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.

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.

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.

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.

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.

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.direct is the explicit, documented exception). Locked by scripts/ci/waf-smoke.sh (61 cases in the CI image job), scripts/ci/compose-smoke.sh against the real server, operations_tests::every_shipped_proxy_runs_the_waf and ::helm_network_policy_allowlists_egress_and_blocks_the_metadata_endpoint.
  • MCP01 is unchanged in the server and protected at the new component: the Authorization header 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-Cookie session id or tool argument into the proxy’s log. scripts/ci/waf-smoke.sh fails 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_image fails if any path drifts, and the_chart_ships_the_same_waf_rules_as_deploy_waf keeps 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.arguments on /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 9207 iss argument 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.

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.

  • MCP01: extends startup/refresh snapshot resolution to the legacy NINJAONE_SERVERS password and TOTP fields. Unresolved references still refuse login before vendor I/O. No request-path provider fetch is added. Locked by ninjaone_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, and credential_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_fields and audit_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_conform and ::relp_failed_acknowledgements_keep_cursor_and_reconnect. audit::journal::tests::checkpoint_write_failure_poisons_the_sink_and_refuses_later_events locks the existing poison control through the public sink and real writer thread. policy_signing_tests::concurrent_keygen_installs_exactly_one_complete_key locks 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_tests and 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.

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.

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.

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.

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_upload resolves artifactId as 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_read authorize 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_owned for screenshots and the artifact_read fallback) are retained. The ambient owner now agrees with the validated principal on the no-journal path, so artifact_read no longer needs the fallback on that path. The phase 2 review’s recorded residual (every other tool’s artifacts owned local without 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 (ActingOwner in 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 subject local and 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_page scoped to the host being opened, read_page, act_on_page, close_page on a page resource, operation class browser), so an allow rule can confine where a browser may be pointed and require_approval holds playwright_click and playwright_fill at 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 by fuzz/fuzz_targets/for_tool.rs).
  • MCP03 (changes a control): the tools leave STDIO_ONLY_TOOLS; HTTP sessions carry them only when PLAYWRIGHT_HTTP_ENABLED=true and PLAYWRIGHT_CONTAINMENT declares 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.83 and 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.sh pins 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 proxy containment the child is started with --proxy-server and --proxy-bypass "<-loopback>", so every browser request, loopback included, is the operator’s proxy’s decision; the proxy URL is validated to scheme://host:port and refused with credentials. The CI job playwright-containment runs 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 and 169.254.169.254 are refused directly, through a redirect from the allowed site, over a WebSocket and as subresources, while the allowed site passes. --allowed-origins is not used and documented as not a boundary. network-policy is 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 with memory.oom.group where Linux lets the gateway write one (the packaged unit sets Delegate=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-policy is no longer accepted as containment by the gateway or the chart. Kubernetes NetworkPolicy cannot 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_read tries 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 owned local on 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 proxy containment each session is launched with a @playwright/mcp configuration that sets Chromium’s WebRTC policy to disable_non_proxied_udp; --proxy-server does 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_evaluate and the optional capability sets are never requested. The tools are advertised over stdio only, like bb_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; never NODE_OPTIONS), headless and with an in-memory profile; only http and https URLs without user-info are opened; the bridge refuses a child line above PLAYWRIGHT_MAX_MESSAGE_BYTES at 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_length governs 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.rs and crates/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_clone uses.
  • 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.

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_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.

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.

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.

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.

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.

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_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.

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 buffer mode; 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 (invariants modules, seeded corpora, targets prompt_text and sse_scanner). The webhook is one operator-set https (or loopback) origin, a clamped timeout, a 64 KiB answer, through crate::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_finding records for the three new subjects (joined by event id), labels on outcome records, two new inference outcomes (content_terminated with bytes_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 (redact on a prompt, hold on a result or completion, mode outside a completion rule) are refused at load, and on_error stays 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 pinned base64 and 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:pass in 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_finding journal records and export columns, joined by event id; a finding that cannot be journaled refuses the call.
  • MCP10 narrows disclosure: the caller-facing _meta.decision omits 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-contracts gains an edge to the already pinned regex =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.

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.

  • MCP01 narrows exposure: rotation.json binds 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 own rollback_failed phase (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.

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).

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.

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.

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.