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:
@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user