Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
62 changes: 57 additions & 5 deletions TRIAGE.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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)."
}
]
}
10 changes: 10 additions & 0 deletions TRIAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
129 changes: 125 additions & 4 deletions crates/core/src/attenuation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Policy, AttenuationError> {
// Mode may only stay the same or become stricter (parent Enforce ⇒ child Enforce).
if request.mode.strictness() < self.mode.strictness() {
Expand All @@ -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<String, Access>) -> Result<(), AttenuationError> {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
29 changes: 16 additions & 13 deletions crates/core/src/compiler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}

Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading