Skip to content

Writable-config rule ATL-163 + drift handle-replay DRF-009/010#104

Merged
zeyadalsakhii merged 3 commits into
mainfrom
rules/local-config-writability
Jul 24, 2026
Merged

Writable-config rule ATL-163 + drift handle-replay DRF-009/010#104
zeyadalsakhii merged 3 commits into
mainfrom
rules/local-config-writability

Conversation

@zeyadalsakhii

Copy link
Copy Markdown
Contributor

Two issue-driven checks from the 2026-07-22 research backlog, one static and one runtime.

Closes #98. Closes #101.

ATL-163: MCP config file writable by other users (high)

CVE-2026-30615 (Windsurf) turned a prompt injection into persistence: the injected agent rewrote the local MCP config and auto-registered a malicious stdio server on every future session. The static precondition of that chain is a world-writable config, and that is what this rule flags.

  • ingest_mcp checks each config file once (o+w on the file, falling back to its parent dir; the same _world_writable helper ATL-113 uses) and stamps _config_world_writable on every server the file registers. Per-server findings are honest: each entry in a writable config is independently swappable for an attacker's launch command.
  • Group-write deliberately does not fire; macOS staff-group setups would false-positive.
  • Fail-closed: an unstattable path is not writable. bool(servers) gates the stat, so empty configs cost nothing.
  • Covers repo-committed configs and scan --local alike, including Claude Code's nested project-scope servers, which bypass ingest_mcp and get the stamp re-applied in build_local_model.
  • Fixture examples/writable-mcp-config with two deliberately boring pinned servers, so the file permission is the only signal. The o+w bit is set by the test and the benchmark harness (world_writable: case setup) since git cannot store it.
  • Cites OWASP-ASI05, CWE-732, NIST CM-5. Pack 258 -> 259.

DRF-009/010: portable-handle replay under the stateless core (critical/high)

MCP SEP-2567/2575 make servers mint portable task/resource handles that travel in conversation text, so a handle is a bearer credential: any principal that sees it can spend it. drift now builds a handle-provenance ledger from the optional handle + handle_op (mint/spend) event fields.

  • DRF-009 (critical): a spend whose principal differs from the handle's minter. A replay or confused deputy in progress. First mint wins, so a thief cannot launder a stolen handle by re-minting it under their own identity.
  • DRF-010 (high): a spend with no observed mint in this log. Unknown provenance, possibly replayed from another session's transcript.
  • Fail-closed in the DRF-008 style: only a non-empty string handle with a recognized op participates; anything absent or malformed is ignored, so legacy handle-free logs behave byte-identically.
  • Policy-independent by design: provenance is a property of the event stream, so a replay by an unattested server is still flagged (DRF-001 fires separately for the server).
  • Batch detect_drift and streaming DriftMonitor share one detector. Findings truncate the handle so a report never re-publishes a spendable credential.
  • Double-spend is deliberately out of scope: neither the event schema nor the compiled policy carries a single-use marker, and inventing one would be speculative until the SEP lands.

Gates

  • Full suite 928 passed / 3 skipped, ruff clean on attestral/ + tests/ (19 new tests across test_writable_config_rule.py and test_drift_handle_replay.py).
  • Benchmark recall 145/145, 0 false positives, agentic-rule coverage 79/83.
  • All three site payloads regenerated (docs.html 259 rules); docs-sync, site-data-sync, fixture-readme-sync green.
  • docs.html drift tables gained DRF-009/010 rows and the handle/handle_op event fields.

Generated with Claude Code.

zeyadalsakhii and others added 3 commits July 23, 2026 17:16
…class)

ingest_mcp checks each config file for world-write (o+w on the file or its
parent dir, fail-closed, group-write deliberately ignored to spare macOS
staff-group setups) and stamps _config_world_writable on every server the
file registers: each entry in a writable config is independently swappable
for an attacker's launch command, the persistence step in the Windsurf
attack (a prompt injection rewrote the local MCP config to auto-register a
malicious stdio server). Covers repo-committed configs and scan --local,
including Claude Code's nested project-scope servers.

Pack 258 -> 259. Fixture examples/writable-mcp-config (the o+w bit is set
by the test and the benchmark harness; git cannot store it). Benchmark
recall 145/145, 0 FP. Cites OWASP-ASI05, CWE-732, NIST CM-5.

Closes #98

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The stateless-core SEPs make servers mint portable task/resource handles
that travel in conversation text, so a handle is a bearer credential: any
principal that sees it can spend it. drift now builds a handle-provenance
ledger from the optional handle + handle_op (mint/spend) event fields.
DRF-009 (critical): a spend whose principal differs from the handle's
minter, a replay or confused deputy in progress; first mint wins, so a
thief cannot launder a stolen handle by re-minting it. DRF-010 (high): a
spend with no observed mint, unknown provenance.

Fail-closed in the DRF-008 style: only a well-formed handle pair
participates, so handle-free logs behave byte-identically. Batch
detect_drift and streaming DriftMonitor share one detector; findings
truncate the handle so a report never re-publishes a spendable
credential. Double-spend is deliberately out of scope until the SEP
lands a single-use marker.

Closes #101

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ruff 0.16.0 (released today) fires 187 new-rule violations repo-wide
(I001, B167, UP037, RUF022, ...), so the unpinned dev extra broke every
CI run overnight on files no PR touched. Pin to the series the repo
actually lints with; unpin after a dedicated 0.16 adoption pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant