feat(permissions): LP4-0 — fondations domaine de l'enforcement OS (pur)

Fondations pures (zéro I/O, zéro dépendance landlock, aucun câblage
runtime — SpawnSpec.sandbox posé mais jamais lu ⇒ zéro régression) de la
voie « airtight » des permissions, complément de la voie projection LP3.

- domain/sandbox.rs : SandboxPlan/PathGrant/PathAccess (RO|RW|EXEC),
  SandboxContext, SandboxKind/Status/Error, port SandboxEnforcer, et la
  fonction pure compile_sandbox_plan(EffectivePermissions → plan OS).
- domain/permission.rs : render_permission_summary (bloc Markdown injecté
  plus tard ; mentionne explicitement fichiers OS-enforced vs commandes
  advisory).
- domain/ports.rs : SpawnSpec.sandbox: Option<SandboxPlan> (None ⇒ natif),
  propagé à tous les sites de construction.

Sémantique de compile_sandbox_plan :
- Invariant produit : eff == None ⇒ None (rien posé ⇒ CLI 100 % native).
- Borne Landlock : seules les capabilities fichier produisent des grants
  (Read→RO, Write/Delete→RW) ; ExecuteBash reste advisory (non verrouillable
  par chemin).
- Deny-wins PAR CLASSE D'ACCÈS (RO/RW), fail-closed intra-classe : un Deny
  ne ferme que les grants de sa propre classe (un Deny Write n'ampute pas un
  Allow Read). Choix retenu pour maximiser l'autonomie des agents : on
  respecte exactement la politique pré-renseignée sans sur-restreindre, donc
  moins de blocages qui forceraient l'agent à redemander l'utilisateur.
- Globs réduits à leur préfixe statique ; grant abandonné si une barrière de
  même classe chevauche (égal/ancêtre/descendant) — sous-approximation
  conservatrice (un sandbox additif ne peut pas carver un deny sous-arbre).

Tests : 16 tests purs sur sandbox + 3 sur render_permission_summary,
cargo test --workspace 100 % vert, 0 ignored.

Reste LP4 : LP4-1 adapter LandlockSandbox + pre_exec PTY (fail-open+warning
sauf posture Deny), LP4-2 câblage application, LP4-3 composition root.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 21:37:34 +02:00
parent 27597eb64e
commit b05d04ab7a
14 changed files with 908 additions and 4 deletions

View File

@ -683,6 +683,111 @@ pub fn resolve(
Some(EffectivePermissions { rules, fallback })
}
/// Renders a human-readable Markdown **summary** of the resolved policy, suitable
/// for injection into an agent's context (lot LP4-0).
///
/// `eff == None ⇒ None` (mirrors [`resolve`]'s product invariant: nothing posed ⇒
/// nothing to summarise). A `Some` result is a self-contained Markdown block.
///
/// The summary makes the **enforcement boundary** explicit: file rules are locked
/// by the OS sandbox **when supported** (Landlock), whereas command rules
/// ([`Capability::ExecuteBash`]) remain **advisory** — honoured only by the CLI's
/// own prompting, never by the OS. This honesty is load-bearing: an agent must not
/// believe a command deny is airtight.
#[must_use]
pub fn render_permission_summary(eff: Option<&EffectivePermissions>) -> Option<String> {
let eff = eff?;
let mut file_lines: Vec<String> = Vec::new();
let mut bash_lines: Vec<String> = Vec::new();
for rule in eff.rules() {
if rule.capability().is_file() {
let verb = match rule.effect() {
Effect::Allow => "Allow",
Effect::Deny => "Deny",
};
let cap = match rule.capability() {
Capability::Read => "read",
Capability::Write => "write",
Capability::Delete => "delete",
Capability::ExecuteBash => "run", // unreachable (is_file filtered)
};
let scope = if rule.paths().is_empty() {
"(nothing)".to_string()
} else {
rule.paths()
.globs()
.iter()
.map(|g| format!("`{}`", g.pattern()))
.collect::<Vec<_>>()
.join(", ")
};
file_lines.push(format!("- {verb} {cap}: {scope}"));
} else {
// Bash rule: blanket (empty commands) or per-command.
if rule.commands().is_empty() {
let verb = match rule.effect() {
Effect::Allow => "Allow",
Effect::Deny => "Deny",
};
bash_lines.push(format!("- {verb}: every command"));
} else {
for cmd in rule.commands() {
let verb = match cmd.effect {
Effect::Allow => "Allow",
Effect::Deny => "Deny",
};
let shown = match &cmd.matcher {
CommandMatcher::Exact(s) => format!("`{s}` (exact)"),
CommandMatcher::Prefix(s) => format!("`{s}*` (prefix)"),
CommandMatcher::Glob(g) => format!("`{}` (glob)", g.pattern()),
};
bash_lines.push(format!("- {verb}: {shown}"));
}
}
}
}
let mut out = String::new();
out.push_str("## Permissions (IdeA)\n\n");
out.push_str(&format!(
"**Default posture:** {}\n\n",
match eff.fallback() {
Posture::Allow => "Allow",
Posture::Ask => "Ask",
Posture::Deny => "Deny",
}
));
out.push_str("### Files — OS-enforced when the sandbox is supported (Landlock)\n");
if file_lines.is_empty() {
out.push_str("- (no file rule; only the default posture applies)\n");
} else {
for line in &file_lines {
out.push_str(line);
out.push('\n');
}
}
out.push('\n');
out.push_str("### Commands — advisory, NOT OS-locked\n");
out.push_str(
"These rules are honoured only by the AI CLI's own prompting, not by the OS \
sandbox: Landlock locks file access only, so command execution (`ExecuteBash`) \
cannot be sandboxed and stays advisory.\n",
);
if bash_lines.is_empty() {
out.push_str("- (no command rule; only the default posture applies)\n");
} else {
for line in &bash_lines {
out.push_str(line);
out.push('\n');
}
}
Some(out)
}
// ---------------------------------------------------------------------------
// Per-CLI projection (lot LP3) — the PURE contract that translates the resolved
// `EffectivePermissions` into a concrete, file/args/env-level *plan* for one CLI.
@ -1312,4 +1417,66 @@ mod tests {
assert_eq!(doc.agents.len(), 1);
assert_eq!(doc.resolve_for(agent).unwrap().fallback(), Posture::Deny);
}
// ---- LP4-0: render_permission_summary -------------------------------
#[test]
fn summary_is_none_when_nothing_posed() {
// Mirrors resolve's product invariant: nothing posed ⇒ nothing to render.
assert!(render_permission_summary(None).is_none());
}
#[test]
fn summary_states_files_os_enforced_and_commands_advisory() {
// The honesty invariant: files are OS-enforced (Landlock when supported)
// while commands stay advisory / NOT OS-locked. Both must be explicit.
let set = PermissionSet::new(
vec![
PermissionRule::file(Capability::Read, Effect::Allow, path_scope(&["src/**"]))
.unwrap(),
PermissionRule::bash(
Effect::Deny,
vec![CommandRule::new(
CommandMatcher::prefix("rm ").unwrap(),
Effect::Deny,
)],
),
],
Posture::Ask,
);
let eff = resolve(Some(&set), None).unwrap();
let md = render_permission_summary(Some(&eff)).expect("a posed policy renders a summary");
// Files: OS-enforced / Landlock when supported.
assert!(md.contains("OS-enforced"), "files block must say OS-enforced");
assert!(md.contains("Landlock"), "files block must name Landlock");
// Commands: advisory, NOT OS-locked, and the why (ExecuteBash).
assert!(md.contains("advisory"), "commands must be called advisory");
assert!(
md.contains("NOT OS-locked"),
"commands must be flagged NOT OS-locked"
);
assert!(
md.contains("ExecuteBash"),
"the rationale must name the ExecuteBash capability"
);
// The actual rules surface in their respective sections.
assert!(md.contains("`src/**`"), "the file scope is shown");
assert!(md.contains("`rm *` (prefix)"), "the command matcher is shown");
// Resolved posture is surfaced.
assert!(md.contains("**Default posture:** Ask"));
}
#[test]
fn summary_handles_empty_rule_lists_per_section() {
// A policy with only a fallback still renders both sections honestly.
let set = PermissionSet::new(vec![], Posture::Deny);
let eff = resolve(Some(&set), None).unwrap();
let md = render_permission_summary(Some(&eff)).unwrap();
assert!(md.contains("no file rule"));
assert!(md.contains("no command rule"));
// The enforcement-boundary wording is present even with no rules.
assert!(md.contains("OS-enforced") && md.contains("NOT OS-locked"));
assert!(md.contains("**Default posture:** Deny"));
}
}