Skip to content

fix(sec): a derived policy inherits what the child leaves unsaid - #8

Merged
jlgore merged 2 commits into
mainfrom
013-attenuation-inheritance
Jul 26, 2026
Merged

fix(sec): a derived policy inherits what the child leaves unsaid#8
jlgore merged 2 commits into
mainfrom
013-attenuation-inheritance

Conversation

@jlgore

@jlgore jlgore commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Closes five MEDIUM triage findings that share one root cause: attenuation validated only the rules a child stated, so an untrusted child could widen its authority purely by omission.

The hole

Every downstream layer reads absence as "nothing to enforce":

Child omits Consequence
[policy.network] FLAG_NET_ENFORCED stays clear — egress unrestricted (f012)
[policy.exec] no EXEC_ALLOW entries installed — any binary runs (f014)
a parent deny rule unmatched reads are allowed — the denial evaporates (f001)
any write grant FLAG_FS_WRITE_DEFAULT_DENY never set (f001)
a ! inode pin degrades to path matching, TOCTOU back on the table (f025)

Intentional for a root policy the operator authored. Fatal for a derived one.

Separately (f002): the FR-008 protected defaults (.git, .bee, ~/.ssh, ~/.aws) are injected during compilation — after attenuation — and a more-specific rule out-ranks them at load. A child under a broad parent grant could name ~/.ssh precisely and out-rank the protection.

The fix

Silence inherits, it does not reset. Policy::derive now returns the effective child policy rather than the request:

  • an omitted filesystem / exec.allow / network.allow is copied from the parent;
  • a non-empty child filesystem map re-absorbs every parent deny;
  • a child exec entry whose parent counterpart is inode-pinned is re-pinned.

Inheritance is trivially ⊆ the parent — every inherited rule is a parent rule — so the FR-005 containment property is preserved by construction, and the whole fix lives in the core validator where the property tests already run. It also keeps a child usable: a subagent narrowing only the filesystem does not silently lose its toolchain.

For f002, the protected-defaults table moves to a shared PROTECTED_DEFAULTS const held in authoring tokens (one table, one truth — the compiler resolves it, attenuation reasons over it), and attenuation refuses any child grant landing inside a protected region unless the parent named a containing region explicitly.

Rejected alternative: treating an empty child list as deny-all. The backend cannot express "enforced but zero destinations" today (plan.rs derives the enforcement flags from rule presence), so it would need a backend change to mean anything, and it makes exec unusable for a partial child. Recorded in full as research R15.

Verification

  • 7 new regression tests in crates/core/tests/attenuation.rs; with the source change stashed, exactly those 7 fail and the 11 pre-existing pass.
  • cargo test --workspace --no-fail-fast: green except repl_command::no_provider_at_all_reports_what_is_missing, which fails identically on main (this build lacks --features enforce) — pre-existing and unrelated.
  • cargo clippy --workspace --all-targets -- -D warnings clean, also with --features mcp; cargo fmt applied.

Docs updated: research R15, contracts/library-api.md, contracts/policy.schema.md, and a remediation log in TRIAGE.md / TRIAGE.json (which also records the six HIGHs already closed by 8e2cdbb + 3225d44).

🤖 Generated with Claude Code

jlgore and others added 2 commits July 25, 2026 10:52
Patch bump; the `StyleSheet` seam bee relies on is unchanged. Comments that
pinned the prose to 0.3.8 now name the version they actually describe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Attenuation validated only the rules a child *stated*, and `derive` returned the
request verbatim. But every downstream layer reads absence as "nothing to
enforce": an empty `[policy.network]` leaves FLAG_NET_ENFORCED clear, an empty
`[policy.exec]` installs no EXEC_ALLOW entries, a policy with no write grant
never sets FLAG_FS_WRITE_DEFAULT_DENY, and a dropped `!` inode pin degrades to
path matching. Intentional for a root policy the operator wrote; a widening
channel for a derived one, where an untrusted child could gain authority purely
by omission.

Silence now inherits, it does not reset. `derive` returns the *effective* child
policy: an omitted filesystem/exec/network dimension is copied from the parent, a
non-empty child filesystem map re-absorbs every parent `deny`, and a child exec
entry whose parent counterpart is inode-pinned is re-pinned. Inheritance is
trivially a subset — every inherited rule *is* a parent rule — so the FR-005
containment property is preserved by construction.

Separately, the FR-008 protected defaults are injected during compilation, after
attenuation, and a more-specific rule out-ranks them at load — so a child under a
broad parent grant could name `~/.ssh` precisely and out-rank the protection.
The table moves to a shared const in authoring tokens (one table, one truth) and
attenuation refuses any child grant landing inside a protected region unless the
parent named a containing region explicitly.

Closes triage f001, f002, f012, f014, f025 (+ absorbed f006, f032) — five
findings, one root cause. Decision recorded as research R15; the seven new tests
in crates/core/tests/attenuation.rs each fail without this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jlgore
jlgore merged commit 04020d7 into main Jul 26, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant