diff --git a/Cargo.toml b/Cargo.toml index 6ecda9b..a18e3de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -187,13 +187,13 @@ rmcp = { version = "2.2", default-features = false, features = ["client", "trans # Optional + `tui`-gated, like every other terminal dep. NOT the `ratatui-markdown` crate: that one # still pins `ratatui ^0.29`, which cannot unify with the 0.30 the TUI is built on, so cargo would # resolve a *second* ratatui whose `Text`/`Buffer` are different types — widgets from one cannot -# render into the other's buffer. `tui-markdown` 0.3.8 moved to `ratatui-core ^0.1`, which is exactly +# render into the other's buffer. `tui-markdown` 0.3.x moved to `ratatui-core ^0.1`, which is exactly # what ratatui 0.30 is built on, so there is one ratatui in the tree. `highlight-code` (syntect) # colors recognized fenced blocks; 005's objection — a second, theme-blind highlighter — is answered # in `tui::markdown`, which picks the bundled code theme nearest the active bee theme and strips the # highlighter's colors under `NO_COLOR`. `default-features = false` + the explicit feature keeps the # list deliberate rather than whatever the crate's default grows into. -tui-markdown = { version = "0.3.8", default-features = false, features = ["highlight-code"], optional = true } +tui-markdown = { version = "0.3.9", default-features = false, features = ["highlight-code"], optional = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/TRIAGE.json b/TRIAGE.json index d642bd4..654ff40 100644 --- a/TRIAGE.json +++ b/TRIAGE.json @@ -350,7 +350,12 @@ "owner_hint": "top committer: jg (1/1 recent commits); no CODEOWNERS entry", "missing_fields": [ "preconditions" - ] + ], + "remediation": { + "status": "fixed", + "where": "013-attenuation-inheritance", + "note": "Policy::derive now returns the effective child policy \u2014 an omitted filesystem/exec/network dimension is inherited from the parent, parent deny rules and inode pins are re-added, and a child grant reaching into an FR-008 protected region is refused (research R15)." + } }, { "id": "f002", @@ -392,7 +397,12 @@ "owner_hint": "top committer: jg (1/1 recent commits); no CODEOWNERS entry", "missing_fields": [ "preconditions" - ] + ], + "remediation": { + "status": "fixed", + "where": "013-attenuation-inheritance", + "note": "Policy::derive now returns the effective child policy \u2014 an omitted filesystem/exec/network dimension is inherited from the parent, parent deny rules and inode pins are re-added, and a child grant reaching into an FR-008 protected region is refused (research R15)." + } }, { "id": "f012", @@ -434,7 +444,12 @@ "owner_hint": "top committer: jg (1/1 recent commits); no CODEOWNERS entry", "missing_fields": [ "preconditions" - ] + ], + "remediation": { + "status": "fixed", + "where": "013-attenuation-inheritance", + "note": "Policy::derive now returns the effective child policy \u2014 an omitted filesystem/exec/network dimension is inherited from the parent, parent deny rules and inode pins are re-added, and a child grant reaching into an FR-008 protected region is refused (research R15)." + } }, { "id": "f014", @@ -478,7 +493,12 @@ "owner_hint": "top committer: jg (1/1 recent commits); no CODEOWNERS entry", "missing_fields": [ "preconditions" - ] + ], + "remediation": { + "status": "fixed", + "where": "013-attenuation-inheritance", + "note": "Policy::derive now returns the effective child policy \u2014 an omitted filesystem/exec/network dimension is inherited from the parent, parent deny rules and inode pins are re-added, and a child grant reaching into an FR-008 protected region is refused (research R15)." + } }, { "id": "f009", @@ -637,7 +657,12 @@ "owner_hint": "top committer: jg (1/1 recent commits); no CODEOWNERS entry", "missing_fields": [ "preconditions" - ] + ], + "remediation": { + "status": "fixed", + "where": "013-attenuation-inheritance", + "note": "Policy::derive now returns the effective child policy \u2014 an omitted filesystem/exec/network dimension is inherited from the parent, parent deny rules and inode pins are re-added, and a child grant reaching into an FR-008 protected region is refused (research R15)." + } }, { "id": "f010", @@ -2081,5 +2106,32 @@ "preconditions" ] } + ], + "remediation_log": [ + { + "findings": [ + "f018", + "f020", + "f022", + "f026", + "f028", + "f030" + ], + "status": "fixed", + "where": "8e2cdbb, 3225d44", + "note": "Six HIGH-severity findings closed and VM-verified (31/31 matrix)." + }, + { + "findings": [ + "f001", + "f002", + "f012", + "f014", + "f025" + ], + "status": "fixed", + "where": "013-attenuation-inheritance", + "note": "Policy::derive now returns the effective child policy \u2014 an omitted filesystem/exec/network dimension is inherited from the parent, parent deny rules and inode pins are re-added, and a child grant reaching into an FR-008 protected region is refused (research R15)." + } ] } diff --git a/TRIAGE.md b/TRIAGE.md index 30586f5..4848781 100644 --- a/TRIAGE.md +++ b/TRIAGE.md @@ -4,6 +4,16 @@ Context: interactive; environment = CLI/batch tool and interactive REPL, with operator inputs trusted and repository/model/tool/skill/MCP content untrusted; scoring = derived HIGH/MEDIUM/LOW; 3-vote verification; precision tie-breaking. +## Remediated + +This report is a snapshot of the triage run; the findings below have since been fixed. The entries +are left in place unedited for provenance. + +| Findings | Fix | +|----------|-----| +| f018, f020, f022, f026, f028, f030 (all six HIGHs) | `8e2cdbb` + `3225d44` — closed and VM-verified (31/31 matrix) | +| f001, f002, f012, f014, f025 (+ absorbed f006, f032) | branch `013-attenuation-inheritance` — one root cause: attenuation validated only what a child *stated*, so omission widened authority. `Policy::derive` now returns the *effective* child policy (silence inherits, it does not reset) and refuses child grants reaching into FR-008 protected regions. See research R15; regression tests in `crates/core/tests/attenuation.rs`. | + ## Act on these ### [HIGH] Provider TOML can send an arbitrary environment secret to an attacker endpoint (f018) `src/batch.rs:133` | credential-exposure | claimed HIGH (alignment +4) | confidence 10.0/10 diff --git a/crates/core/src/attenuation.rs b/crates/core/src/attenuation.rs index 341d868..54529b1 100644 --- a/crates/core/src/attenuation.rs +++ b/crates/core/src/attenuation.rs @@ -5,10 +5,39 @@ //! in fact be safe. Reasoning is at the authoring level over raw pattern strings — parent and child //! share the same token vocabulary (`:project_root`, `~`), so string-level subtree reasoning is //! valid without resolving to the host environment. +//! +//! # Silence inherits, it does not reset +//! +//! Checking the child's *stated* rules against the parent is only half of subset containment. The +//! other half is what the child leaves out. An authoring policy says nothing about a dimension it +//! omits, and every layer below reads "no rules" as "nothing to enforce": an empty +//! `[policy.network]` clears `FLAG_NET_ENFORCED`, an empty `[policy.exec]` installs no `EXEC_ALLOW` +//! entry, and no write rule anywhere leaves `FLAG_FS_WRITE_DEFAULT_DENY` unset. That reading is +//! deliberate for a **root** policy — an operator who writes no network rules is not asking for an +//! egress firewall — but for a *derived* policy it inverts the whole point: the shortest possible +//! child would silently out-rank the parent that bounds it. +//! +//! So [`Policy::derive`] does not hand back the request as written. It returns the **effective** +//! child policy, under one rule applied uniformly: +//! +//! > A dimension the child does not mention is inherited from the parent, and a restriction the +//! > parent placed inside a region the child re-grants is re-added if the child dropped it. +//! +//! Inheriting (rather than rejecting, or defaulting to deny-all) is what keeps the rule usable: a +//! subagent policy that narrows only the filesystem should not have to restate the parent's exec +//! and network lists to avoid being handed either nothing or everything. The result is always ⊆ the +//! parent, because every inherited rule *is* a parent rule. +//! +//! The same reasoning covers two smaller leaks: an inode pin (`!bin`) is part of a parent's +//! restriction, so a child naming the same executable unpinned inherits the pin; and the FR-008 +//! protected defaults ([`crate::compiler::PROTECTED_DEFAULTS`]) are injected during *compilation*, +//! after this check runs, so a child grant reaching into `~/.ssh` or `.git` is refused here unless +//! the parent named that region explicitly. Overriding a protected default is the author's +//! prerogative; it is not inherited by an untrusted child. use std::collections::BTreeMap; -use crate::compiler::{lower_pattern, FsPrimitive}; +use crate::compiler::{lower_pattern, FsPrimitive, PROTECTED_DEFAULTS}; use crate::error::AttenuationError; use crate::policy::{Access, Policy}; use bee_common::AccessMode; @@ -40,8 +69,9 @@ fn prefix_contains(outer: &[u8], inner: &[u8]) -> bool { } impl Policy { - /// Derive a validated child policy that is provably ⊆ `self`. Returns the request on success, - /// or the first [`AttenuationError`] found. + /// Derive a validated child policy that is provably ⊆ `self`. Returns the **effective** child — + /// the request plus every parent restriction it left out (see the module docs) — or the first + /// [`AttenuationError`] found. pub fn derive(&self, request: Policy) -> Result { // Mode may only stay the same or become stricter (parent Enforce ⇒ child Enforce). if request.mode.strictness() < self.mode.strictness() { @@ -56,7 +86,54 @@ impl Policy { self.check_network(&request)?; // Exfiltration: a child may only *add* sensitive paths / enable detection (narrowing) — no check. - Ok(request) + Ok(self.inherit_restrictions(request)) + } + + /// Close what the request left unsaid. Every rule added here is a parent rule, so the result is + /// still ⊆ `self`; what changes is that the child can no longer *widen by omission*. + fn inherit_restrictions(&self, mut child: Policy) -> Policy { + // Filesystem. A child that states no path rules inherits the parent's map wholesale: the + // alternative — an empty rule set — is read downstream as "no filesystem enforcement", + // which is the widening this exists to stop. A child that does state rules keeps them, but + // re-acquires every parent `deny`: `check_filesystem` only forces a child to replicate the + // restrictions that fall *inside* a region it re-granted, so a parent deny the child never + // went near would otherwise simply evaporate. + if child.filesystem.is_empty() { + child.filesystem = self.filesystem.clone(); + } else { + for (raw, &access) in &self.filesystem { + if access == Access::Deny { + // A validated child cannot already hold a *grant* at a denied region — + // `check_prefix_grant` rejects that outright — so this never demotes a grant. + child.filesystem.entry(raw.clone()).or_insert(Access::Deny); + } + } + } + + // Executables. An empty child list installs no allowlist at all, which the kernel reads as + // unrestricted execution; inherit the parent's list instead. A child that names executables + // keeps its (already validated ⊆) selection, but each entry re-acquires the parent's inode + // pin — dropping the `!` is a widening, since a pinned rule survives a swapped binary. + if child.exec.allow.is_empty() { + child.exec.allow = self.exec.allow.clone(); + } else { + for entry in &mut child.exec.allow { + if entry.starts_with('!') { + continue; + } + if self.exec.allow.iter().any(|p| p == &format!("!{entry}")) { + entry.insert(0, '!'); + } + } + } + + // Network. An empty child list clears `FLAG_NET_ENFORCED` and permits every destination; + // inherit the parent's allowlist instead. + if child.network.allow.is_empty() { + child.network.allow = self.network.allow.clone(); + } + + child } fn check_filesystem(&self, child: &BTreeMap) -> Result<(), AttenuationError> { @@ -126,6 +203,11 @@ impl Policy { )); } + // 1b. FR-008 protected regions are injected at compile time, *after* this check, and a + // more-specific rule out-ranks them at load. A broad parent grant must therefore not let a + // child reach into `~/.ssh` (or write `.git`) by naming it precisely. + self.check_protected(cap, pc, access)?; + // 2. Any parent rule that REDUCES access *inside* pc must be replicated by the child. for (pr, &pa) in &self.filesystem { let reduces = !access.is_subset_of(pa); // deny, or a lesser grant @@ -156,6 +238,45 @@ impl Policy { Ok(()) } + /// Refuse a child grant that lands inside an FR-008 protected region, unless the protected + /// default already permits that access (`.git` is read-only, not denied) or the parent named a + /// region inside the protected one explicitly — an override the operator authored, which the + /// child is merely inheriting rather than inventing. + fn check_protected( + &self, + cap: &str, + pc: &[u8], + access: Access, + ) -> Result<(), AttenuationError> { + for (raw, protected) in PROTECTED_DEFAULTS { + let Some(Region::Prefix(pp)) = region_of(raw) else { + continue; // the table holds concrete paths; a glob there would be a bug. + }; + if !prefix_contains(&pp, pc) { + continue; + } + if protected.grants(access.to_bits()) { + continue; // e.g. reading `.git`, which the default allows. + } + let overridden = self.filesystem.iter().any(|(pr, &pa)| { + pa != Access::Deny + && access.is_subset_of(pa) + && matches!(region_of(pr), Some(Region::Prefix(pe)) + if prefix_contains(&pp, &pe) && prefix_contains(&pe, pc)) + }); + if !overridden { + return Err(AttenuationError::new( + cap, + format!( + "path lies inside the protected region '{raw}' (FR-008); \ + the parent must grant it explicitly for a child to receive it" + ), + )); + } + } + Ok(()) + } + fn check_exec(&self, request: &Policy) -> Result<(), AttenuationError> { for entry in &request.exec.allow { let name = entry.strip_prefix('!').unwrap_or(entry); diff --git a/crates/core/src/compiler.rs b/crates/core/src/compiler.rs index aba3f78..c6b59a2 100644 --- a/crates/core/src/compiler.rs +++ b/crates/core/src/compiler.rs @@ -85,11 +85,14 @@ impl Policy { // Inject FR-008 protected defaults first, then explicit rules; explicit rules that name the // same resolved path override (a later duplicate prefix with the same length wins at load). - for (path, access) in protected_defaults(r) { + // That override is the *author's* prerogative: for a derived policy, attenuation has already + // refused any child grant reaching into a protected region that the parent did not name + // explicitly, so nothing untrusted gets here (see [`crate::attenuation`]). + for (raw, access) in PROTECTED_DEFAULTS { fs.push(FsPrimitive::Prefix { - path: path.into_bytes(), + path: resolve_tokens(raw, r).into_bytes(), subtree: true, - mode: access, + mode: *access, }); } @@ -138,16 +141,16 @@ impl Policy { } /// FR-008: within any writable root, protect VCS/config/credential dirs unless explicitly overridden. -fn protected_defaults(r: &dyn Resolver) -> Vec<(String, AccessMode)> { - let root = r.project_root(); - let home = r.home(); - vec![ - (format!("{root}/.git"), AccessMode::READ), - (format!("{root}/.bee"), AccessMode::DENY), - (format!("{home}/.ssh"), AccessMode::DENY), - (format!("{home}/.aws"), AccessMode::DENY), - ] -} +/// +/// Held in **authoring tokens**, not resolved paths, because two callers need it at two different +/// stages: [`Policy::compile`] resolves and injects it, while [`crate::attenuation`] reasons over it +/// at the authoring level (where it, too, compares raw patterns). One table, one truth. +pub const PROTECTED_DEFAULTS: &[(&str, AccessMode)] = &[ + (":project_root/.git", AccessMode::READ), + (":project_root/.bee", AccessMode::DENY), + ("~/.ssh", AccessMode::DENY), + ("~/.aws", AccessMode::DENY), +]; /// Resolve `:project_root` and a leading `~` to absolute paths. Other characters pass through. pub fn resolve_tokens(raw: &str, r: &dyn Resolver) -> String { diff --git a/crates/core/tests/attenuation.rs b/crates/core/tests/attenuation.rs index 1768b70..e13e945 100644 --- a/crates/core/tests/attenuation.rs +++ b/crates/core/tests/attenuation.rs @@ -1,6 +1,6 @@ //! Attenuation validator unit tests (T040) — targeted soundness/usability cases. -use bee_core::Policy; +use bee_core::{Access, Policy}; fn p(toml: &str) -> Policy { Policy::from_toml(toml).expect("valid policy") @@ -132,3 +132,131 @@ fn novel_glob_grant_rejected() { "glob not provably contained in a prefix grant" ); } + +// --------------------------------------------------------------------------- +// Widening by omission (f001, f002, f012, f014, f025). `derive` returns the +// *effective* child: silence inherits the parent's restrictions, never resets them. +// --------------------------------------------------------------------------- + +#[test] +fn parent_deny_outside_any_child_grant_is_inherited() { + // f001: the child never mentions /secrets, so no replication check fires — but dropping the + // denial would hand it back the default-allowed read the parent took away. + let parent = p(r#" +[policy] +name = "p" +[policy.filesystem] +"/proj" = "write" +"/secrets" = "deny" +"#); + let child = p("[policy]\nname=\"c\"\n[policy.filesystem]\n\"/proj/src\" = \"read\"\n"); + let derived = parent + .derive(child) + .expect("narrowing child is within the parent"); + assert_eq!( + derived.filesystem.get("/secrets"), + Some(&Access::Deny), + "the parent's denial must survive into the derived policy" + ); +} + +#[test] +fn silent_filesystem_child_inherits_the_parent_map() { + // An empty rule set installs no rules at all, which reads downstream as "no filesystem + // enforcement" — writes included. Inherit instead. + let parent = p(r#" +[policy] +name = "p" +[policy.filesystem] +"/proj" = "write" +"/secrets" = "deny" +"#); + let derived = parent + .derive(p("[policy]\nname=\"c\"\n")) + .expect("a child that asks for nothing is trivially a subset"); + assert_eq!(derived.filesystem, parent.filesystem); +} + +#[test] +fn empty_child_network_inherits_rather_than_disabling_egress() { + // f012: an empty list clears FLAG_NET_ENFORCED, which permits *every* destination. + let parent = p("[policy]\nname=\"p\"\n[policy.network]\nallow=[\"crates.io:443\"]\n"); + let derived = parent.derive(p("[policy]\nname=\"c\"\n")).unwrap(); + assert_eq!(derived.network.allow, vec!["crates.io:443".to_string()]); +} + +#[test] +fn empty_child_exec_inherits_rather_than_unrestricting() { + // f014: no EXEC_ALLOW entry is read by the kernel as unrestricted execution. + let parent = p("[policy]\nname=\"p\"\n[policy.exec]\nallow=[\"cargo\",\"rustc\"]\n"); + let derived = parent.derive(p("[policy]\nname=\"c\"\n")).unwrap(); + assert_eq!( + derived.exec.allow, + vec!["cargo".to_string(), "rustc".to_string()] + ); + + // A child that *does* name executables still narrows to its own selection. + let narrowed = parent + .derive(p( + "[policy]\nname=\"c\"\n[policy.exec]\nallow=[\"cargo\"]\n", + )) + .unwrap(); + assert_eq!(narrowed.exec.allow, vec!["cargo".to_string()]); +} + +#[test] +fn child_inherits_the_parent_inode_pin() { + // f025: dropping the `!` widens — an unpinned rule matches whatever now sits at that path. + let parent = p("[policy]\nname=\"p\"\n[policy.exec]\nallow=[\"!cargo\"]\n"); + let derived = parent + .derive(p( + "[policy]\nname=\"c\"\n[policy.exec]\nallow=[\"cargo\"]\n", + )) + .unwrap(); + assert_eq!(derived.exec.allow, vec!["!cargo".to_string()]); +} + +#[test] +fn child_cannot_reach_into_a_protected_region_under_a_broad_grant() { + // f002: protected defaults are injected at compile time, after attenuation, and a more + // specific rule out-ranks them at load — so the refusal has to happen here. + let parent = p("[policy]\nname=\"p\"\n[policy.filesystem]\n\"~\" = \"write\"\n"); + let err = parent + .derive(p( + "[policy]\nname=\"c\"\n[policy.filesystem]\n\"~/.ssh/id_rsa\" = \"read\"\n", + )) + .unwrap_err(); + assert!(err.reason.contains("protected region"), "{}", err.reason); + + // The operator may still override it explicitly; the child then inherits that override. + let permissive = p(r#" +[policy] +name = "p" +[policy.filesystem] +"~" = "write" +"~/.ssh" = "read" +"#); + assert!(permissive + .derive(p( + "[policy]\nname=\"c\"\n[policy.filesystem]\n\"~/.ssh/id_rsa\" = \"read\"\n" + )) + .is_ok()); +} + +#[test] +fn protected_git_is_readable_but_not_writable_by_a_child() { + let parent = p("[policy]\nname=\"p\"\n[policy.filesystem]\n\":project_root\" = \"write\"\n"); + assert!( + parent + .derive(p( + "[policy]\nname=\"c\"\n[policy.filesystem]\n\":project_root/.git\" = \"read\"\n" + )) + .is_ok(), + "the protected default for .git is read-only, not deny" + ); + assert!(parent + .derive(p( + "[policy]\nname=\"c\"\n[policy.filesystem]\n\":project_root/.git/hooks\" = \"write\"\n" + )) + .is_err()); +} diff --git a/specs/001-ebpf-agent-sandbox/contracts/library-api.md b/specs/001-ebpf-agent-sandbox/contracts/library-api.md index 32c7906..233ed45 100644 --- a/specs/001-ebpf-agent-sandbox/contracts/library-api.md +++ b/specs/001-ebpf-agent-sandbox/contracts/library-api.md @@ -20,6 +20,9 @@ pub fn Policy::compile(&self, resolver: &dyn Resolver) -> Result Result; // Audit event (serde Serialize/Deserialize → JSON, FR-007). diff --git a/specs/001-ebpf-agent-sandbox/contracts/policy.schema.md b/specs/001-ebpf-agent-sandbox/contracts/policy.schema.md index cea0429..1ca436f 100644 --- a/specs/001-ebpf-agent-sandbox/contracts/policy.schema.md +++ b/specs/001-ebpf-agent-sandbox/contracts/policy.schema.md @@ -64,3 +64,8 @@ sensitive_paths = ["~/.ssh", "~/.aws", "~/.gnupg", "~/.config/gcloud"] Engine initialization or cgroup creation. - **Attenuation**: a derived policy is valid only if every grant is provably ⊆ its parent (FR-005); glob-derived grants that cannot be proven contained are rejected (conservative fail-closed). + Omission does not widen: a child that declares no `[policy.filesystem]`, `[policy.exec]`, or + `[policy.network]` inherits the parent's, a child filesystem map re-absorbs every parent `deny`, and + a parent's `!`-pinned executable stays pinned in the child. A child grant reaching into an FR-008 + protected region is refused unless the parent named a containing region explicitly. `derive` returns + the effective child policy that results (research R15). diff --git a/specs/001-ebpf-agent-sandbox/research.md b/specs/001-ebpf-agent-sandbox/research.md index a810f6c..77bf172 100644 --- a/specs/001-ebpf-agent-sandbox/research.md +++ b/specs/001-ebpf-agent-sandbox/research.md @@ -335,6 +335,44 @@ remains misleading and failures occur after kernel setup begins; (c) silently dr --- +## R15. Attenuation: silence inherits, it does not reset (FR-005, SC-002) + +**Context**: `derive` validated only the rules a child *stated*, and returned the request verbatim. +Every downstream layer, though, reads absence as "nothing to enforce": an empty `[policy.network]` +leaves `FLAG_NET_ENFORCED` clear (egress unrestricted), 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 authored; +fatal for a derived one, where a child could widen its authority purely by omission. Separately, 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, so a child under a broad parent +grant could name `~/.ssh` precisely and out-rank the protection. + +**Decision**: A dimension the child does not mention is **inherited** from the parent, and a +restriction the parent placed inside a region the child re-grants is **re-added** if the child dropped +it. `derive` therefore returns the *effective* child policy, not the request: + +- empty child `filesystem` / `exec.allow` / `network.allow` → the parent's map/list is copied in; +- a non-empty child filesystem map re-absorbs every parent `deny`; +- a child exec entry whose parent counterpart is inode-pinned (`!cargo`) is re-pinned. + +Attenuation additionally refuses any child grant landing inside an FR-008 protected region unless the +parent named a region containing that path explicitly, so compile-time injection stays trustworthy for +derived policies. + +**Rationale**: Inheritance is trivially ⊆ the parent — every inherited rule *is* a parent rule — so the +subset property is preserved by construction, and the fix lives entirely in the core validator where +the property tests already run. It also keeps a child usable: a subagent that only wants to narrow the +filesystem does not silently lose its toolchain. + +**Alternatives**: (a) treat an empty child list as deny-all — rejected: the backend cannot express +"enforced but zero destinations" today (`plan.rs` derives the enforcement flags from rule presence), so +it would require a backend change to mean anything, and it makes exec unusable for a partial child; +(b) reject a child that omits a dimension the parent constrains — rejected as hostile to the common +case of narrowing one dimension; (c) fix it downstream in `plan.rs` — rejected because the subset +property belongs to attenuation, and every future backend would have to re-implement it. + +--- + ## Resolved Technical Context values | Field | Value | diff --git a/src/tui/markdown.rs b/src/tui/markdown.rs index 636825f..1b64464 100644 --- a/src/tui/markdown.rs +++ b/src/tui/markdown.rs @@ -6,7 +6,7 @@ //! Two rules shape this module. //! //! **Color is semantic** (design-system §1): every style here resolves through a [`Role`] and the -//! active theme, never a literal color. That is what [`BeeStyleSheet`] is for — `tui-markdown` 0.3.8 +//! active theme, never a literal color. That is what [`BeeStyleSheet`] is for — `tui-markdown` 0.3.9 //! exposes a `StyleSheet` seam precisely so a host can supply its own look, so bee supplies its own //! rather than accepting the crate's hard-coded cyan-and-black. `NO_COLOR` therefore degrades this //! path exactly like every other one, for free.