feat(permissions): voie projection CLI (LP0→LP3) + checkpoint Codex/input
Jalon vert regroupant deux chantiers entrelacés dans le working tree, indissociables au niveau fichier mais tous deux verts (cargo test --workspace + tests frontend permissions au vert). Permissions — voie « projection CLI » (advisory), complète : - LP0 domaine pur : modèle PermissionSet/EffectivePermissions, resolve deny-wins + postures Allow<Ask<Deny (crates/domain/src/permission.rs). - LP1 store : FsPermissionStore (.ideai/permissions.json). - LP2 use cases : Get/Update project, Update agent override, Resolve. - LP3 projecteurs Claude/Codex (settings.local.json / config.toml), câblage launch-path + PermissionProjectorRegistry, nettoyage des fichiers Replace orphelins au swap de profil (LP3-4), composition root + commandes Tauri, UI PermissionsPanel (projet + override agent). - ports.rs : PermissionStore + FileSystem::remove_file (cleanup au swap). Reste ouvert (hors scope, marqué dans le code) : LP4 enforcement OS airtight (Landlock fichiers) + résumé de permissions injecté. Inclut aussi le chantier Codex/input/sessions structurées en cours (McpConfigStrategy, StructuredAdapter, gestion d'input) partageant les mêmes fichiers (lifecycle.rs, commands.rs, dto.rs, state.rs). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
362
crates/infrastructure/src/permission/claude.rs
Normal file
362
crates/infrastructure/src/permission/claude.rs
Normal file
@ -0,0 +1,362 @@
|
||||
//! Claude Code permission projector (lot LP3-2).
|
||||
//!
|
||||
//! Produces the `.claude/settings.local.json` seed written into an agent run dir:
|
||||
//! full project autonomy (`bypassPermissions` + broad Read/Edit/Write/Bash) with
|
||||
//! the project root granted as an additional working directory, while keeping
|
||||
//! destructive/out-of-project commands denied. The translation is extracted
|
||||
//! verbatim from the former `claude_settings_seed` in `lifecycle.rs`.
|
||||
|
||||
use domain::permission::{
|
||||
Capability, CommandMatcher, Effect, EffectivePermissions, PermissionProjection,
|
||||
PermissionProjector, PermissionRule, Posture, ProjectedFile, ProjectionContext, ProjectorKey,
|
||||
};
|
||||
|
||||
use super::json_escape;
|
||||
|
||||
/// Run-dir-relative path of the owned Claude settings seed.
|
||||
const SETTINGS_REL_PATH: &str = ".claude/settings.local.json";
|
||||
|
||||
/// Projects [`EffectivePermissions`] into Claude Code's `settings.local.json`.
|
||||
///
|
||||
/// Pure: `project` only computes the JSON; the launch path materialises it. A
|
||||
/// `Replace`-owned file (clobbered at launch, removed on swap-away).
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct ClaudePermissionProjector;
|
||||
|
||||
impl PermissionProjector for ClaudePermissionProjector {
|
||||
fn key(&self) -> ProjectorKey {
|
||||
ProjectorKey::Claude
|
||||
}
|
||||
|
||||
fn project(
|
||||
&self,
|
||||
eff: Option<&EffectivePermissions>,
|
||||
ctx: &ProjectionContext,
|
||||
) -> PermissionProjection {
|
||||
// Product invariant: nothing posed ⇒ nothing projected (native prompting).
|
||||
let Some(_) = eff else {
|
||||
return PermissionProjection::empty();
|
||||
};
|
||||
let contents = claude_settings_seed(ctx.project_root, eff);
|
||||
PermissionProjection {
|
||||
files: vec![ProjectedFile::Replace {
|
||||
rel_path: SETTINGS_REL_PATH.to_owned(),
|
||||
contents,
|
||||
}],
|
||||
args: Vec::new(),
|
||||
env: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn owned_replace_paths(&self) -> Vec<String> {
|
||||
vec![SETTINGS_REL_PATH.to_owned()]
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the Claude Code permission seed. `project_root` is embedded verbatim
|
||||
/// (JSON-escaped) and granted as an additional working directory, since the cwd is
|
||||
/// the run dir and the agent works on the root above it.
|
||||
fn claude_settings_seed(project_root: &str, permissions: Option<&EffectivePermissions>) -> String {
|
||||
let root = json_escape(project_root);
|
||||
let default_mode = match permissions.map(EffectivePermissions::fallback) {
|
||||
Some(Posture::Deny) => "plan",
|
||||
Some(Posture::Ask) => "acceptEdits",
|
||||
Some(Posture::Allow) | None => "bypassPermissions",
|
||||
};
|
||||
let allow = claude_permission_entries(permissions, Effect::Allow);
|
||||
let deny = claude_permission_entries(permissions, Effect::Deny);
|
||||
let default_allow = [
|
||||
"Read".to_owned(),
|
||||
"Edit".to_owned(),
|
||||
"Write".to_owned(),
|
||||
"Bash".to_owned(),
|
||||
];
|
||||
let allow = json_string_array(if allow.is_empty() {
|
||||
&default_allow
|
||||
} else {
|
||||
&allow
|
||||
});
|
||||
let deny = json_string_array(&merge_default_deny(deny));
|
||||
format!(
|
||||
r#"{{
|
||||
"permissions": {{
|
||||
"defaultMode": "{default_mode}",
|
||||
"additionalDirectories": [
|
||||
"{root}"
|
||||
],
|
||||
"allow": {allow},
|
||||
"deny": {deny}
|
||||
}},
|
||||
"skipDangerousModePermissionPrompt": true,
|
||||
"enabledMcpjsonServers": ["idea"],
|
||||
"sandbox": {{
|
||||
"enabled": false
|
||||
}}
|
||||
}}
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
fn merge_default_deny(mut deny: Vec<String>) -> Vec<String> {
|
||||
for item in [
|
||||
"Bash(sudo *)",
|
||||
"Bash(rm -rf /)",
|
||||
"Bash(rm -rf /*)",
|
||||
"Bash(rm -rf ~)",
|
||||
"Bash(rm -rf ~/)",
|
||||
"Bash(rm -rf ~/*)",
|
||||
"Bash(rm -rf $HOME*)",
|
||||
"Bash(mkfs*)",
|
||||
"Bash(dd if=*)",
|
||||
"Bash(shutdown*)",
|
||||
"Bash(reboot*)",
|
||||
] {
|
||||
if !deny.iter().any(|existing| existing == item) {
|
||||
deny.push(item.to_owned());
|
||||
}
|
||||
}
|
||||
deny
|
||||
}
|
||||
|
||||
fn claude_permission_entries(
|
||||
permissions: Option<&EffectivePermissions>,
|
||||
effect: Effect,
|
||||
) -> Vec<String> {
|
||||
let Some(permissions) = permissions else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for rule in permissions.rules() {
|
||||
if rule.effect() != effect {
|
||||
continue;
|
||||
}
|
||||
match rule.capability() {
|
||||
Capability::Read => push_path_entries(&mut out, "Read", rule),
|
||||
Capability::Write => {
|
||||
push_path_entries(&mut out, "Edit", rule);
|
||||
push_path_entries(&mut out, "Write", rule);
|
||||
}
|
||||
Capability::Delete => push_delete_entries(&mut out, rule),
|
||||
Capability::ExecuteBash => push_bash_entries(&mut out, rule),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn push_path_entries(out: &mut Vec<String>, capability: &str, rule: &PermissionRule) {
|
||||
if rule.paths().is_empty() {
|
||||
out.push(capability.to_owned());
|
||||
return;
|
||||
}
|
||||
for glob in rule.paths().globs() {
|
||||
out.push(format!("{capability}({})", glob.pattern()));
|
||||
}
|
||||
}
|
||||
|
||||
fn push_delete_entries(out: &mut Vec<String>, rule: &PermissionRule) {
|
||||
if rule.paths().is_empty() {
|
||||
out.push("Bash(rm *)".to_owned());
|
||||
return;
|
||||
}
|
||||
for glob in rule.paths().globs() {
|
||||
out.push(format!("Bash(rm {})", glob.pattern()));
|
||||
}
|
||||
}
|
||||
|
||||
fn push_bash_entries(out: &mut Vec<String>, rule: &PermissionRule) {
|
||||
if rule.commands().is_empty() {
|
||||
out.push("Bash".to_owned());
|
||||
return;
|
||||
}
|
||||
for cmd in rule.commands() {
|
||||
if cmd.effect != rule.effect() {
|
||||
continue;
|
||||
}
|
||||
out.push(format!("Bash({})", command_matcher_pattern(&cmd.matcher)));
|
||||
}
|
||||
}
|
||||
|
||||
fn command_matcher_pattern(matcher: &CommandMatcher) -> String {
|
||||
match matcher {
|
||||
CommandMatcher::Exact(value) => value.clone(),
|
||||
CommandMatcher::Prefix(value) => format!("{value}*"),
|
||||
CommandMatcher::Glob(glob) => glob.pattern().to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn json_string_array(items: &[String]) -> String {
|
||||
if items.is_empty() {
|
||||
return "[]".to_owned();
|
||||
}
|
||||
let body = items
|
||||
.iter()
|
||||
.map(|item| format!(" \"{}\"", json_escape(item)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",\n");
|
||||
format!("[\n{body}\n ]")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::permission::{resolve, PathScope, PermissionSet};
|
||||
use serde_json::Value;
|
||||
|
||||
fn ctx<'a>(root: &'a str, run_dir: &'a str) -> ProjectionContext<'a> {
|
||||
ProjectionContext {
|
||||
project_root: root,
|
||||
run_dir,
|
||||
}
|
||||
}
|
||||
|
||||
fn path_scope(patterns: &[&str]) -> PathScope {
|
||||
PathScope::new(patterns.iter().map(ToString::to_string)).unwrap()
|
||||
}
|
||||
|
||||
/// Builds an [`EffectivePermissions`] from a single (project) set via the
|
||||
/// domain API, exactly like the `permission` unit tests do.
|
||||
fn eff_with(rules: Vec<PermissionRule>, fallback: Posture) -> EffectivePermissions {
|
||||
resolve(Some(&PermissionSet::new(rules, fallback)), None).unwrap()
|
||||
}
|
||||
|
||||
/// Projects and returns the parsed `settings.local.json` value, asserting the
|
||||
/// projection's structural contract (1 Replace file, no args/env) along the way.
|
||||
fn project_json(eff: &EffectivePermissions, root: &str) -> Value {
|
||||
let proj = ClaudePermissionProjector.project(Some(eff), &ctx(root, "/run/agent"));
|
||||
assert!(proj.args.is_empty(), "Claude projection carries no args");
|
||||
assert!(proj.env.is_empty(), "Claude projection carries no env");
|
||||
assert_eq!(proj.files.len(), 1, "exactly one file projected");
|
||||
match &proj.files[0] {
|
||||
ProjectedFile::Replace { rel_path, contents } => {
|
||||
assert_eq!(rel_path, SETTINGS_REL_PATH);
|
||||
serde_json::from_str(contents).expect("the produced settings is valid JSON")
|
||||
}
|
||||
ProjectedFile::MergeToml { .. } => panic!("Claude must emit a Replace file"),
|
||||
}
|
||||
}
|
||||
|
||||
fn str_array(value: &Value) -> Vec<String> {
|
||||
value
|
||||
.as_array()
|
||||
.expect("array")
|
||||
.iter()
|
||||
.map(|v| v.as_str().expect("string").to_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---- product invariant + ownership ----------------------------------
|
||||
|
||||
#[test]
|
||||
fn project_none_is_empty() {
|
||||
let proj = ClaudePermissionProjector.project(None, &ctx("/proj", "/run"));
|
||||
assert!(proj.files.is_empty());
|
||||
assert!(proj.args.is_empty());
|
||||
assert!(proj.env.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_replace_paths_is_the_settings_file() {
|
||||
assert_eq!(
|
||||
ClaudePermissionProjector.owned_replace_paths(),
|
||||
vec![SETTINGS_REL_PATH.to_owned()]
|
||||
);
|
||||
}
|
||||
|
||||
// ---- (1) posture → defaultMode --------------------------------------
|
||||
|
||||
#[test]
|
||||
fn default_mode_maps_each_posture() {
|
||||
for (posture, mode) in [
|
||||
(Posture::Allow, "bypassPermissions"),
|
||||
(Posture::Ask, "acceptEdits"),
|
||||
(Posture::Deny, "plan"),
|
||||
] {
|
||||
let json = project_json(&eff_with(vec![], posture), "/proj");
|
||||
assert_eq!(
|
||||
json["permissions"]["defaultMode"], mode,
|
||||
"posture {posture:?} should map to defaultMode {mode}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- (2) deny-wins: deny entry surfaces in the deny list -------------
|
||||
|
||||
#[test]
|
||||
fn specific_deny_with_broad_allow_appears_in_deny_list() {
|
||||
let rules = vec![
|
||||
PermissionRule::file(Capability::Write, Effect::Deny, path_scope(&[".ideai/**"]))
|
||||
.unwrap(),
|
||||
PermissionRule::file(Capability::Write, Effect::Allow, path_scope(&["**"])).unwrap(),
|
||||
];
|
||||
let json = project_json(&eff_with(rules, Posture::Allow), "/proj");
|
||||
|
||||
let deny = str_array(&json["permissions"]["deny"]);
|
||||
// A Write capability fans out to both Edit(..) and Write(..) entries.
|
||||
assert!(deny.contains(&"Edit(.ideai/**)".to_owned()), "deny={deny:?}");
|
||||
assert!(deny.contains(&"Write(.ideai/**)".to_owned()), "deny={deny:?}");
|
||||
|
||||
let allow = str_array(&json["permissions"]["allow"]);
|
||||
assert!(
|
||||
allow.contains(&"Edit(**)".to_owned()) && allow.contains(&"Write(**)".to_owned()),
|
||||
"the broad allow stays in the allow list; allow={allow:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- (3) additionalDirectories carries the (escaped) project root ----
|
||||
|
||||
#[test]
|
||||
fn additional_directories_contains_project_root_escaped() {
|
||||
// A Windows-ish path with a backslash AND a quote exercises JSON escaping;
|
||||
// parsing it back must yield the original raw path verbatim.
|
||||
let root = r#"C:\Users\a"b\proj"#;
|
||||
let json = project_json(&eff_with(vec![], Posture::Allow), root);
|
||||
let dirs = str_array(&json["permissions"]["additionalDirectories"]);
|
||||
assert_eq!(dirs, vec![root.to_owned()]);
|
||||
}
|
||||
|
||||
// ---- (4) hard-coded destructive guardrails --------------------------
|
||||
|
||||
#[test]
|
||||
fn default_deny_guardrails_are_present() {
|
||||
let json = project_json(&eff_with(vec![], Posture::Allow), "/proj");
|
||||
let deny = str_array(&json["permissions"]["deny"]);
|
||||
for guard in [
|
||||
"Bash(sudo *)",
|
||||
"Bash(rm -rf /)",
|
||||
"Bash(rm -rf ~)",
|
||||
"Bash(rm -rf $HOME*)",
|
||||
"Bash(mkfs*)",
|
||||
"Bash(dd if=*)",
|
||||
"Bash(shutdown*)",
|
||||
"Bash(reboot*)",
|
||||
] {
|
||||
assert!(deny.contains(&guard.to_owned()), "missing guardrail {guard}; deny={deny:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- (5) valid JSON + expected static shape -------------------------
|
||||
|
||||
#[test]
|
||||
fn produced_settings_has_expected_static_shape() {
|
||||
// `project_json` already proved the document parses; assert the fixed keys.
|
||||
let json = project_json(&eff_with(vec![], Posture::Ask), "/proj");
|
||||
assert_eq!(json["enabledMcpjsonServers"][0], "idea");
|
||||
assert_eq!(json["skipDangerousModePermissionPrompt"], true);
|
||||
assert_eq!(json["sandbox"]["enabled"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_rules_fall_back_to_broad_default_allow() {
|
||||
let json = project_json(&eff_with(vec![], Posture::Allow), "/proj");
|
||||
let allow = str_array(&json["permissions"]["allow"]);
|
||||
assert_eq!(
|
||||
allow,
|
||||
vec![
|
||||
"Read".to_owned(),
|
||||
"Edit".to_owned(),
|
||||
"Write".to_owned(),
|
||||
"Bash".to_owned()
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
190
crates/infrastructure/src/permission/codex.rs
Normal file
190
crates/infrastructure/src/permission/codex.rs
Normal file
@ -0,0 +1,190 @@
|
||||
//! Codex CLI permission projector (lot LP3-2).
|
||||
//!
|
||||
//! Produces the **permission-relevant** part of Codex's `config.toml`
|
||||
//! (`sandbox_mode` / `approval_policy`) plus the matching launch args
|
||||
//! (`--sandbox` / `--ask-for-approval`). The posture→mode derivation is extracted
|
||||
//! verbatim from the former `codex_sandbox_mode` / `codex_approval_policy` /
|
||||
//! `apply_codex_cli_permission_args` in `lifecycle.rs`.
|
||||
//!
|
||||
//! Unlike Claude's seed, Codex's `config.toml` is **co-owned** (it also carries the
|
||||
//! `mcp_servers.idea` table and the `projects.*` trust entries, which are MCP/trust
|
||||
//! concerns, not permissions). The projector therefore emits a
|
||||
//! [`ProjectedFile::MergeToml`] limited to the two permission keys it manages —
|
||||
//! everything else in the file is preserved by the fold, and the file is **never**
|
||||
//! deleted on swap (hence an empty `owned_replace_paths`).
|
||||
|
||||
use domain::permission::{
|
||||
EffectivePermissions, PermissionProjection, PermissionProjector, Posture, ProjectedFile,
|
||||
ProjectionContext, ProjectorKey,
|
||||
};
|
||||
|
||||
use super::toml_string;
|
||||
|
||||
/// Run-dir-relative path of Codex's `config.toml`. Codex reads its config from
|
||||
/// `$CODEX_HOME/config.toml`; IdeA isolates `CODEX_HOME` to `{runDir}/.codex`, so
|
||||
/// the file lives at `.codex/config.toml` relative to the run dir.
|
||||
const CONFIG_REL_PATH: &str = ".codex/config.toml";
|
||||
|
||||
/// The two top-level keys this projector manages in `config.toml`. Everything else
|
||||
/// (MCP table, trust entries, user keys) is preserved by the merge.
|
||||
const MANAGED_KEYS: [&str; 2] = ["sandbox_mode", "approval_policy"];
|
||||
|
||||
/// Projects [`EffectivePermissions`] into Codex's sandbox/approval config + args.
|
||||
///
|
||||
/// Pure: `project` only computes the plan; the launch path merges the TOML fragment
|
||||
/// and appends the args.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct CodexPermissionProjector;
|
||||
|
||||
impl PermissionProjector for CodexPermissionProjector {
|
||||
fn key(&self) -> ProjectorKey {
|
||||
ProjectorKey::Codex
|
||||
}
|
||||
|
||||
fn project(
|
||||
&self,
|
||||
eff: Option<&EffectivePermissions>,
|
||||
_ctx: &ProjectionContext,
|
||||
) -> PermissionProjection {
|
||||
// Product invariant: nothing posed ⇒ nothing projected. Codex keeps its
|
||||
// native sandbox/approval defaults (no args, no managed keys written).
|
||||
let Some(permissions) = eff else {
|
||||
return PermissionProjection::empty();
|
||||
};
|
||||
let sandbox = codex_sandbox_mode(permissions);
|
||||
let approval = codex_approval_policy(permissions);
|
||||
|
||||
// Permission-only TOML fragment (escaped exactly like the former
|
||||
// `set_top_level_toml_value`). The mcp_servers/trust tables are NOT a
|
||||
// permission concern and stay with the MCP wiring (LP3-3).
|
||||
let contents = format!(
|
||||
"sandbox_mode = {}\napproval_policy = {}\n",
|
||||
toml_string(sandbox),
|
||||
toml_string(approval),
|
||||
);
|
||||
|
||||
PermissionProjection {
|
||||
files: vec![ProjectedFile::MergeToml {
|
||||
rel_path: CONFIG_REL_PATH.to_owned(),
|
||||
managed_tables: Vec::new(),
|
||||
managed_keys: MANAGED_KEYS.iter().map(|k| (*k).to_owned()).collect(),
|
||||
contents,
|
||||
}],
|
||||
args: vec![
|
||||
"--sandbox".to_owned(),
|
||||
sandbox.to_owned(),
|
||||
"--ask-for-approval".to_owned(),
|
||||
approval.to_owned(),
|
||||
],
|
||||
env: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn owned_replace_paths(&self) -> Vec<String> {
|
||||
// config.toml is co-owned (MergeToml), never an owned Replace file.
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_sandbox_mode(permissions: &EffectivePermissions) -> &'static str {
|
||||
match permissions.fallback() {
|
||||
Posture::Deny => "read-only",
|
||||
Posture::Ask | Posture::Allow => "workspace-write",
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_approval_policy(permissions: &EffectivePermissions) -> &'static str {
|
||||
match permissions.fallback() {
|
||||
Posture::Allow => "never",
|
||||
Posture::Ask | Posture::Deny => "on-request",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::permission::{resolve, PermissionSet};
|
||||
|
||||
fn ctx<'a>() -> ProjectionContext<'a> {
|
||||
ProjectionContext {
|
||||
project_root: "/proj",
|
||||
run_dir: "/run/agent",
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds an [`EffectivePermissions`] with the given fallback posture via the
|
||||
/// domain API (only the fallback drives Codex's sandbox/approval derivation).
|
||||
fn eff(fallback: Posture) -> EffectivePermissions {
|
||||
resolve(Some(&PermissionSet::new(vec![], fallback)), None).unwrap()
|
||||
}
|
||||
|
||||
// ---- product invariant + ownership ----------------------------------
|
||||
|
||||
#[test]
|
||||
fn project_none_is_empty() {
|
||||
let proj = CodexPermissionProjector.project(None, &ctx());
|
||||
assert!(proj.files.is_empty());
|
||||
assert!(proj.args.is_empty());
|
||||
assert!(proj.env.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_replace_paths_is_empty() {
|
||||
// config.toml is co-owned (MergeToml) ⇒ nothing to clean up on swap.
|
||||
assert!(CodexPermissionProjector.owned_replace_paths().is_empty());
|
||||
}
|
||||
|
||||
// ---- (6) posture → sandbox_mode / approval_policy + (7) args↔contents
|
||||
// coherence + MergeToml shape -------------------------------
|
||||
|
||||
#[test]
|
||||
fn posture_maps_sandbox_and_approval_in_file_and_args() {
|
||||
for (posture, sandbox, approval) in [
|
||||
(Posture::Deny, "read-only", "on-request"),
|
||||
(Posture::Ask, "workspace-write", "on-request"),
|
||||
(Posture::Allow, "workspace-write", "never"),
|
||||
] {
|
||||
let proj = CodexPermissionProjector.project(Some(&eff(posture)), &ctx());
|
||||
assert!(proj.env.is_empty(), "Codex projection carries no env");
|
||||
|
||||
// -- The single MergeToml file, with the two managed permission keys.
|
||||
assert_eq!(proj.files.len(), 1, "exactly one file projected");
|
||||
match &proj.files[0] {
|
||||
ProjectedFile::MergeToml {
|
||||
rel_path,
|
||||
managed_tables,
|
||||
managed_keys,
|
||||
contents,
|
||||
} => {
|
||||
assert_eq!(rel_path, CONFIG_REL_PATH);
|
||||
assert!(managed_tables.is_empty(), "no managed tables");
|
||||
assert_eq!(
|
||||
managed_keys,
|
||||
&vec!["sandbox_mode".to_owned(), "approval_policy".to_owned()]
|
||||
);
|
||||
assert!(
|
||||
contents.contains(&format!("sandbox_mode = \"{sandbox}\"")),
|
||||
"posture {posture:?}: contents={contents:?}"
|
||||
);
|
||||
assert!(
|
||||
contents.contains(&format!("approval_policy = \"{approval}\"")),
|
||||
"posture {posture:?}: contents={contents:?}"
|
||||
);
|
||||
}
|
||||
ProjectedFile::Replace { .. } => panic!("Codex must emit a MergeToml file"),
|
||||
}
|
||||
|
||||
// -- (7) Args reflect the SAME values as the TOML, in CLI order.
|
||||
assert_eq!(
|
||||
proj.args,
|
||||
vec![
|
||||
"--sandbox".to_owned(),
|
||||
sandbox.to_owned(),
|
||||
"--ask-for-approval".to_owned(),
|
||||
approval.to_owned(),
|
||||
],
|
||||
"posture {posture:?}: args must mirror the TOML values"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
46
crates/infrastructure/src/permission/mod.rs
Normal file
46
crates/infrastructure/src/permission/mod.rs
Normal file
@ -0,0 +1,46 @@
|
||||
//! Per-CLI **permission projectors** (lot LP3-2).
|
||||
//!
|
||||
//! Concrete implementations of the domain port
|
||||
//! [`domain::permission::PermissionProjector`]: they translate the resolved
|
||||
//! [`domain::permission::EffectivePermissions`] into a CLI-specific
|
||||
//! [`domain::permission::PermissionProjection`] — a **plan** (files + args + env),
|
||||
//! never an action. Writing/merging the plan into the agent run dir is the launch
|
||||
//! path's job (lot LP3-3); the projectors here stay **pure** (no `FileSystem`, no
|
||||
//! I/O), exactly like the domain trait demands.
|
||||
//!
|
||||
//! This is an **extraction**: the translation rules (postures → allow/deny/ask
|
||||
//! lists for Claude, posture → sandbox/approval modes for Codex) are moved here
|
||||
//! verbatim from `application/src/agent/lifecycle.rs`, only reshaped to return a
|
||||
//! `PermissionProjection`. The rules themselves are unchanged.
|
||||
|
||||
mod claude;
|
||||
mod codex;
|
||||
|
||||
pub use claude::ClaudePermissionProjector;
|
||||
pub use codex::CodexPermissionProjector;
|
||||
|
||||
/// Minimal JSON string escaper for embedding a filesystem path / permission entry
|
||||
/// in a settings document (handles the characters that actually occur in paths:
|
||||
/// backslash, quote, control chars). Extracted verbatim from `lifecycle.rs`.
|
||||
pub(crate) fn json_escape(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Escapes `s` as a TOML basic string (quotes included). The escape set required
|
||||
/// by a TOML basic string coincides with JSON's for the characters that concern
|
||||
/// us (paths, mode keywords). Extracted verbatim from `lifecycle.rs`.
|
||||
pub(crate) fn toml_string(s: &str) -> String {
|
||||
format!("\"{}\"", json_escape(s))
|
||||
}
|
||||
Reference in New Issue
Block a user