Skip to main content

kranz_engine/
permissions.rs

1//! Role → permission profile mapping (plan §4.7; the table in
2//! `docs/design.md` is authoritative).
3//!
4//! Deny rules take precedence over allows in Claude Code, and in `-p` mode a
5//! tool call outside the allowed set simply fails with an error the model can
6//! read — that failure IS the read-only guarantee for orchestrator and
7//! validators, backstopped for validators by the `validator.tamper`
8//! HEAD/index/worktree identity assertion around every session
9//! ([`crate::validator_integrity`]): a validator that slips a write past the
10//! patterns fails its round honestly. The profiles here express every
11//! restriction through `permission_mode` + `allowed_tools` +
12//! `disallowed_tools`: the CLI `--tools` built-in restriction is recorded on
13//! the profile for future wiring, but [`apply`] does not map it —
14//! [`SessionSpec`] carries a separate `tools` field populated straight from
15//! per-role config (see [`MissionConfig`]), by design left disconnected from
16//! this read-only profile field.
17
18use crate::backend::SessionSpec;
19use crate::types::{MissionConfig, Role};
20
21/// Tool names recognized when deciding whether a config `deny_patterns` entry
22/// is already a tool rule (vs. a bare Bash command pattern to wrap).
23const KNOWN_TOOLS: &[&str] = &[
24    "Bash",
25    "Read",
26    "Write",
27    "Edit",
28    "MultiEdit",
29    "NotebookEdit",
30    "Glob",
31    "Grep",
32    "WebFetch",
33    "WebSearch",
34    "Task",
35    "TodoWrite",
36    "SlashCommand",
37    "KillShell",
38    "BashOutput",
39];
40
41/// Deny rules protecting the consent substrate, applied to EVERY role
42/// (audit 2026-09-01 C1 / H6).
43///
44/// Two things must stay out of reach of an agent session:
45///
46/// - `~/.kranz/**` holds the repository authority key that signs control
47///   files and MACs `events.jsonl`, plus `serve.token`. A session that can
48///   read the key can forge operator consent; one that can write there can
49///   substitute a key of its own.
50/// - `.kranz/missions/**/control/**` is the control inbox itself. Signature
51///   checking already refuses forged files, but a session with no business
52///   writing there should not be able to fill the inbox with quarantine
53///   fodder, and the deny rule states the boundary where a reader will look
54///   for it.
55///
56/// This is the barrier under the DEFAULT posture, where `sandbox.enforce` is
57/// `off` and there is no OS containment at all. It is the agent CLI enforcing
58/// its own rules, so it is weaker than the sandbox's read-deny set and does
59/// not survive `dangerouslyAllowAll`, which short-circuits every profile to
60/// `bypassPermissions` by design.
61const AUTHORITY_DENY: &[&str] = &[
62    "Read(~/.kranz/**)",
63    "Edit(~/.kranz/**)",
64    "Write(~/.kranz/**)",
65    "Read(.kranz/missions/**/control/**)",
66    "Edit(.kranz/missions/**/control/**)",
67    "Write(.kranz/missions/**/control/**)",
68];
69
70/// Built-in worker deny list (§4.7): no pushing, no publishing, no privilege
71/// escalation, no raw network access.
72const WORKER_DENY: &[&str] = &[
73    "Bash(git push*)",
74    "Bash(git remote add*)",
75    "Bash(npm publish*)",
76    "Bash(yarn publish*)",
77    "Bash(pnpm publish*)",
78    "Bash(cargo publish*)",
79    "Bash(twine*)",
80    "Bash(gem push*)",
81    "Bash(sudo*)",
82    "Bash(curl*)",
83    "Bash(wget*)",
84    "WebFetch",
85    "WebSearch",
86];
87
88/// Git inspection patterns shared by the orchestrator and both validators.
89///
90/// `git branch` and `git tag` are deliberately NOT allowed as bare `*`
91/// prefixes: `Bash(git branch*)` would also match mutating invocations such
92/// as `git branch -D main` or `git branch -f`, and `Bash(git tag*)` would
93/// match `git tag -d v1` and tag creation. Only the read-only listing/query
94/// forms are enumerated instead (exact match unless the entry ends in `*`).
95const GIT_INSPECT: &[&str] = &[
96    "Bash(git log*)",
97    "Bash(git diff*)",
98    "Bash(git show*)",
99    "Bash(git status*)",
100    "Bash(git rev-parse*)",
101    // Read-only `git branch` forms.
102    "Bash(git branch)",
103    "Bash(git branch --list*)",
104    "Bash(git branch --show-current)",
105    "Bash(git branch -a)",
106    "Bash(git branch -r)",
107    "Bash(git branch --contains*)",
108    // Read-only `git tag` forms.
109    "Bash(git tag)",
110    "Bash(git tag --list*)",
111    "Bash(git tag -l*)",
112    "Bash(git tag --contains*)",
113];
114
115/// Deny list for the read-only roles (orchestrator, validators).
116const READ_ONLY_DENY: &[&str] = &[
117    "Write",
118    "Edit",
119    "NotebookEdit",
120    "WebFetch",
121    "WebSearch",
122    "Bash(git push*)",
123];
124
125/// The CLI `--tools` restriction for read-only roles (design.md table).
126const INSPECT_TOOLS: &[&str] = &["Bash", "Read", "Glob", "Grep"];
127
128/// Everything the engine passes to the CLI to sandbox one role's session.
129#[derive(Debug, Clone, Default, PartialEq, Eq)]
130pub struct PermissionProfile {
131    /// `--permission-mode` value ("default", "acceptEdits", "bypassPermissions").
132    pub permission_mode: Option<String>,
133    /// The CLI `--tools` built-in restriction; `None` = default tool set.
134    /// NOT yet wired into [`SessionSpec`] — the restriction is folded into
135    /// `allowed_tools`/`disallowed_tools` instead (see module docs).
136    pub tools: Option<Vec<String>>,
137    /// `--allowedTools` patterns.
138    pub allowed_tools: Vec<String>,
139    /// `--disallowedTools` patterns (deny wins over allow).
140    pub disallowed_tools: Vec<String>,
141}
142
143/// Build the permission profile for a role (plan §4.7).
144///
145/// `validator_commands` are the contract `command` strings for the milestone
146/// under validation (ignored for non-validator roles); each becomes a
147/// `Bash(<command>*)` allow. Config `allow_validator_commands` entries are
148/// folded in the same way, so callers may pass either just the contract
149/// commands or the full union — duplicates are removed.
150///
151/// `grants` are the plan-level `Mission.command_grants` — commands the plan
152/// itself has authorized. Each becomes a `Bash(<grant>*)` allow (via
153/// [`command_allow_patterns`]) folded into BOTH the worker profile (alongside
154/// the worker's existing bare `Bash` allow) and the validator profiles
155/// (alongside `validator_commands`), so a command the plan grants is runnable
156/// by the worker AND re-runnable by the validators verifying it — one source
157/// of truth for both surfaces. The orchestrator role ignores `grants`.
158///
159/// `cfg.dangerously_allow_all` short-circuits every role to
160/// `bypassPermissions` with empty lists (loud escape hatch, never default).
161pub fn for_role(
162    role: Role,
163    cfg: &MissionConfig,
164    validator_commands: &[String],
165    grants: &[String],
166    deny_exceptions: &[String],
167) -> PermissionProfile {
168    if cfg.dangerously_allow_all {
169        return PermissionProfile {
170            permission_mode: Some("bypassPermissions".to_string()),
171            tools: None,
172            allowed_tools: Vec::new(),
173            disallowed_tools: Vec::new(),
174        };
175    }
176
177    match role {
178        Role::Worker => {
179            let mut disallowed = to_strings(WORKER_DENY);
180            for pattern in &cfg.deny_patterns {
181                disallowed.push(as_tool_rule(pattern));
182            }
183            dedup_preserving_order(&mut disallowed);
184            // Subtract operator-lifted rules (WorkerDeny grants). Exact-match
185            // removal: the event log names the precise rule lifted, and only
186            // that rule leaves the worker deny set — a deliberate, auditable
187            // erosion of the guardrail. Applied AFTER dedup so a lifted rule is
188            // gone whether it came from WORKER_DENY or config deny_patterns.
189            if !deny_exceptions.is_empty() {
190                disallowed.retain(|rule| !deny_exceptions.contains(rule));
191            }
192            // AFTER the lift: a `WorkerDeny` grant is an operator decision
193            // about a shell command, and must never be able to hand a session
194            // the authority key that signs the operator's own approvals.
195            disallowed.extend(authority_deny());
196            let mut allowed = vec!["Bash".to_string()];
197            for grant in grants {
198                allowed.extend(command_allow_patterns(grant));
199            }
200            dedup_preserving_order(&mut allowed);
201            PermissionProfile {
202                permission_mode: Some("acceptEdits".to_string()),
203                tools: None,
204                allowed_tools: allowed,
205                disallowed_tools: disallowed,
206            }
207        }
208
209        Role::Orchestrator => {
210            let mut allowed = to_strings(&["Read", "Glob", "Grep"]);
211            allowed.extend(to_strings(GIT_INSPECT));
212            PermissionProfile {
213                permission_mode: Some("default".to_string()),
214                tools: Some(to_strings(INSPECT_TOOLS)),
215                allowed_tools: allowed,
216                disallowed_tools: read_only_deny(),
217            }
218        }
219
220        Role::ValidatorScrutiny | Role::ValidatorFunctional => {
221            let mut allowed = to_strings(&["Read", "Glob", "Grep"]);
222            allowed.extend(to_strings(GIT_INSPECT));
223            // Read-only env introspection (ticket validator-env-reads-no-grant;
224            // m-83d1ed's ms-2 blocked on a printenv park): exactly
225            // `printenv <KRANZ_*>` — never bare `printenv` or `env` (a full
226            // dump writes the injected backend auth key into the otherwise
227            // sanitized transcript, and `env <cmd>` is a command runner),
228            // never `echo` (command substitution). The KRANZ_ prefix IS the
229            // disclosure boundary: those vars are engine-injected.
230            allowed.push("Bash(printenv KRANZ_*)".to_string());
231            // Scrutiny/mechanical split: only the functional validator runs
232            // the contract/validator commands. Scrutiny inspects the range
233            // read-only (Read/Grep/Glob + plain git) and is neither
234            // advertised nor permitted the cargo gates — see the per-role
235            // task in runner::run_validator_in. Operator grants still apply
236            // to both roles (a grant is an explicit operator decision).
237            if role == Role::ValidatorFunctional {
238                for command in validator_commands
239                    .iter()
240                    .chain(cfg.allow_validator_commands.iter())
241                {
242                    allowed.extend(command_allow_patterns(command));
243                }
244            }
245            for command in grants {
246                allowed.extend(command_allow_patterns(command));
247            }
248            if role == Role::ValidatorFunctional {
249                // Live-QA mode (functional only): a browser/computer-use tool
250                // configured in `--tools` still needs auto-approval in `-p`
251                // mode or every call fails (docs/design.md §4.7). The
252                // standard inspect tools are excluded because they are
253                // already governed by the precise patterns above — folding
254                // a bare `Bash` in here would broaden it to any command.
255                for tool in &cfg.validator_functional.tools {
256                    if !INSPECT_TOOLS.contains(&tool.as_str()) {
257                        allowed.push(tool.clone());
258                    }
259                }
260            }
261            dedup_preserving_order(&mut allowed);
262            PermissionProfile {
263                permission_mode: Some("default".to_string()),
264                tools: Some(to_strings(INSPECT_TOOLS)),
265                allowed_tools: allowed,
266                disallowed_tools: read_only_deny(),
267            }
268        }
269    }
270}
271
272/// Copy a profile onto a [`SessionSpec`]. `profile.tools` is intentionally
273/// not mapped here: `spec.tools` is populated separately from per-role
274/// config (opt-in `--tools` allow-list), while the profiles already express
275/// this read-only restriction through allowed/disallowed patterns.
276pub fn apply(profile: PermissionProfile, spec: &mut SessionSpec) {
277    spec.permission_mode = profile.permission_mode;
278    spec.allowed_tools = profile.allowed_tools;
279    spec.disallowed_tools = profile.disallowed_tools;
280}
281
282/// Allow patterns for one contract/validator command: the exact command forms
283/// the contract declares — the verbatim command and each of its
284/// `&&` / `||` / `;` / `|` segments, each as a `Bash(<form>*)` prefix rule.
285/// Prefix-suffix matching still admits the natural reinvocations that made
286/// verbatim-only rules untenable (observed live): `python3 extract_links.py`
287/// matches the segment rule from `python3 extract_links.py && echo EXIT_OK`,
288/// and a trailing extra flag matches the declared prefix.
289///
290/// Nothing wider (ticket `validator-immutability-proof`, review P1 #5). The
291/// old leading-two-token rule widened `python3 -m pytest` to
292/// `Bash(python3 -m*)` and heredoc contracts to `Bash(python3 -*)` —
293/// arbitrary interpreter use (`python3 -c '<any write>'`) under a "read-only"
294/// role. A validator needs to RUN the declared commands, nothing else, and
295/// engine-run contract commands (validation_round's captured PASS/FAIL
296/// evidence) mean heredoc forms need no validator Bash rule at all. The
297/// read-only guarantee now rests on the denied Write/Edit tools, the deny
298/// list, and the `validator.tamper` identity assertion
299/// ([`crate::validator_integrity`]) — with Bash precision no longer working
300/// against it.
301pub fn command_allow_patterns(command: &str) -> Vec<String> {
302    let command = command.trim();
303    if command.is_empty() {
304        return Vec::new();
305    }
306    let mut patterns = vec![format!("Bash({command}*)")];
307    for segment in command
308        .split("&&")
309        .flat_map(|s| s.split("||"))
310        .flat_map(|s| s.split(';'))
311        .flat_map(|s| s.split('|'))
312    {
313        let segment = segment.trim();
314        if segment.is_empty() {
315            continue;
316        }
317        patterns.push(format!("Bash({segment}*)"));
318    }
319    patterns
320}
321
322/// The worker deny rule that blocks `command`, if any — used to name the rule a
323/// `WorkerDeny` grant would lift. Best-effort: parses `Bash(<pattern>*)` rules
324/// and prefix-matches the command against `<pattern>`. Non-`Bash(...)` rules
325/// (WebFetch/WebSearch/tool names) never match a shell command.
326///
327/// When several rules match, returns the MOST SPECIFIC (longest-prefix) one —
328/// so a narrow config rule (`Bash(git push --force*)`) is offered over the broad
329/// built-in (`Bash(git push*)`), keeping the operator's lift as narrow as the
330/// rule that actually blocked the command. A wrong or absent match just means
331/// the grant offers the wrong/no rule and the command stays denied (cap-bounded);
332/// the authoritative enforcement is Claude Code removing the exact rule string
333/// from `disallowed_tools`.
334pub fn matching_deny_rule(command: &str, deny_rules: &[String]) -> Option<String> {
335    let cmd = command.trim();
336    deny_rules
337        .iter()
338        .filter_map(|rule| {
339            let pat = rule
340                .strip_prefix("Bash(")
341                .and_then(|r| r.strip_suffix(')'))?;
342            let prefix = pat.strip_suffix('*').unwrap_or(pat);
343            (!prefix.is_empty() && cmd.starts_with(prefix)).then_some((rule, prefix.len()))
344        })
345        .max_by_key(|(_, len)| *len)
346        .map(|(rule, _)| rule.clone())
347}
348
349/// Wrap a config `deny_patterns` entry as `Bash(<pattern>)` unless it already
350/// looks like a tool rule: contains `(` (e.g. `Bash(dd*)`) or exactly matches
351/// a known tool name (e.g. `WebFetch`).
352fn as_tool_rule(pattern: &str) -> String {
353    let trimmed = pattern.trim();
354    if trimmed.contains('(') || KNOWN_TOOLS.contains(&trimmed) {
355        trimmed.to_string()
356    } else {
357        format!("Bash({trimmed})")
358    }
359}
360
361/// The read-only roles' deny set with [`AUTHORITY_DENY`] folded in. The
362/// read-only roles already deny `Write`/`Edit` outright, so the authority
363/// rules add the `Read` half: an orchestrator or validator has no business
364/// reading the key that signs the operator's approvals either.
365fn read_only_deny() -> Vec<String> {
366    let mut deny = to_strings(READ_ONLY_DENY);
367    deny.extend(authority_deny());
368    deny
369}
370
371/// [`AUTHORITY_DENY`] plus the same rules in ABSOLUTE form for the global
372/// kranz directory that actually resolves at runtime. `~` in a rule relies
373/// on the agent CLI expanding it, and `KRANZ_HOME` moves the key directory
374/// somewhere `~/.kranz/**` never covers; the absolute rule follows
375/// `paths::global_kranz_dir` so the deny and the key writer cannot drift
376/// (follow-up review F-9 and F-17). Backends without a permission-rule
377/// plane (codex, droid) never see these rules; there the OS sandbox is the
378/// only barrier, and the module docs say so.
379pub fn authority_deny() -> Vec<String> {
380    let mut deny = to_strings(AUTHORITY_DENY);
381    if let Some(global) = crate::paths::global_kranz_dir() {
382        let global = global.to_string_lossy().replace('\\', "/");
383        let global = global.trim_end_matches('/');
384        for tool in ["Read", "Edit", "Write"] {
385            deny.push(format!("{tool}(/{global}/**)"));
386        }
387    }
388    dedup_preserving_order(&mut deny);
389    deny
390}
391
392fn to_strings(items: &[&str]) -> Vec<String> {
393    items.iter().map(|s| s.to_string()).collect()
394}
395
396/// Remove duplicates, keeping the first occurrence of each entry.
397fn dedup_preserving_order(items: &mut Vec<String>) {
398    let mut seen = std::collections::HashSet::new();
399    items.retain(|item| seen.insert(item.clone()));
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    /// Audit 2026-09-01 C1/H6: every role denies the authority material, and
407    /// the deny survives the one operator lever that edits the deny list.
408    #[test]
409    fn every_role_denies_the_authority_key_and_the_control_inbox() {
410        let cfg = MissionConfig::default();
411        for role in [
412            Role::Worker,
413            Role::Orchestrator,
414            Role::ValidatorScrutiny,
415            Role::ValidatorFunctional,
416        ] {
417            let profile = for_role(role, &cfg, &[], &[], &[]);
418            for rule in AUTHORITY_DENY {
419                assert!(
420                    profile.disallowed_tools.iter().any(|r| r == rule),
421                    "{role:?} must deny `{rule}`: {:?}",
422                    profile.disallowed_tools
423                );
424            }
425        }
426
427        // A WorkerDeny grant lifts the rule it names, never the authority
428        // rules: an operator approving `git push` must not hand the session
429        // the key that signs their own approvals.
430        let lifted = for_role(
431            Role::Worker,
432            &cfg,
433            &[],
434            &[],
435            &[
436                "Bash(git push*)".to_string(),
437                "Read(~/.kranz/**)".to_string(),
438            ],
439        );
440        assert!(!lifted
441            .disallowed_tools
442            .iter()
443            .any(|r| r == "Bash(git push*)"));
444        assert!(
445            lifted
446                .disallowed_tools
447                .iter()
448                .any(|r| r == "Read(~/.kranz/**)"),
449            "the authority deny is not liftable: {:?}",
450            lifted.disallowed_tools
451        );
452    }
453
454    /// The escape hatch stays an escape hatch: `dangerouslyAllowAll` clears
455    /// every list including this one, and the docs say so. Pinned so the
456    /// interaction is a decision on the record rather than an oversight.
457    #[test]
458    fn dangerously_allow_all_still_clears_the_authority_deny() {
459        let cfg = MissionConfig {
460            dangerously_allow_all: true,
461            ..MissionConfig::default()
462        };
463        let profile = for_role(Role::Worker, &cfg, &[], &[], &[]);
464        assert!(profile.disallowed_tools.is_empty());
465        assert_eq!(
466            profile.permission_mode.as_deref(),
467            Some("bypassPermissions")
468        );
469    }
470
471    #[test]
472    fn worker_deny_exceptions_lift_exactly_the_named_rule() {
473        let cfg = MissionConfig::default();
474        // Baseline: git push is denied.
475        let base = for_role(Role::Worker, &cfg, &[], &[], &[]);
476        assert!(base.disallowed_tools.iter().any(|r| r == "Bash(git push*)"));
477
478        // Lifting `Bash(git push*)` removes exactly that rule; the other rails
479        // (sudo, curl, …) stay in force.
480        let lifted = for_role(
481            Role::Worker,
482            &cfg,
483            &[],
484            &[],
485            &["Bash(git push*)".to_string()],
486        );
487        assert!(!lifted
488            .disallowed_tools
489            .iter()
490            .any(|r| r == "Bash(git push*)"));
491        assert!(lifted.disallowed_tools.iter().any(|r| r == "Bash(sudo*)"));
492        assert!(lifted.disallowed_tools.iter().any(|r| r == "Bash(curl*)"));
493    }
494
495    #[test]
496    fn matching_deny_rule_maps_a_command_to_the_rule_that_blocks_it() {
497        let deny = to_strings(WORKER_DENY);
498        assert_eq!(
499            matching_deny_rule("git push origin main", &deny).as_deref(),
500            Some("Bash(git push*)")
501        );
502        assert_eq!(
503            matching_deny_rule("sudo rm -rf /", &deny).as_deref(),
504            Some("Bash(sudo*)")
505        );
506        // A command no deny rule blocks maps to nothing.
507        assert_eq!(matching_deny_rule("cargo build", &deny), None);
508        // Non-Bash rules (WebFetch/WebSearch/tool names) never match a command.
509        assert_eq!(
510            matching_deny_rule("anything at all", &["WebFetch".to_string()]),
511            None
512        );
513        // Most specific wins: a narrower config rule is offered over the broad
514        // built-in, so the operator's lift stays as narrow as what blocked it.
515        let mixed = vec![
516            "Bash(git push*)".to_string(),
517            "Bash(git push --force*)".to_string(),
518        ];
519        assert_eq!(
520            matching_deny_rule("git push --force origin main", &mixed).as_deref(),
521            Some("Bash(git push --force*)")
522        );
523    }
524
525    #[test]
526    fn grants_reach_worker_and_validator() {
527        let cfg = MissionConfig::default();
528        let grants = vec!["gc lint".to_string()];
529
530        // Granting a base command also covers `<cmd> --help`: the pattern is
531        // a `*`-suffixed prefix match, not a verbatim match.
532        assert!(command_allow_patterns("gc lint").contains(&"Bash(gc lint*)".to_string()));
533
534        let worker = for_role(Role::Worker, &cfg, &[], &grants, &[]);
535        assert!(worker.allowed_tools.contains(&"Bash(gc lint*)".to_string()));
536        // Grants are additive: the bare worker Bash allow must survive.
537        assert!(worker.allowed_tools.contains(&"Bash".to_string()));
538        assert_eq!(worker.permission_mode, Some("acceptEdits".to_string()));
539
540        let validator = for_role(Role::ValidatorScrutiny, &cfg, &[], &grants, &[]);
541        assert!(validator
542            .allowed_tools
543            .contains(&"Bash(gc lint*)".to_string()));
544    }
545
546    /// Narrowed widening (ticket `validator-immutability-proof`): only the
547    /// verbatim command and its exact shell segments become allow rules — no
548    /// leading-two-token catch-alls like `python3 -*` / `python3 -m*`.
549    #[test]
550    fn command_allow_patterns_stick_to_the_declared_command_forms() {
551        // Verbatim + exact segments, nothing wider.
552        assert_eq!(
553            command_allow_patterns("python3 extract_links.py && echo EXIT_OK"),
554            vec![
555                "Bash(python3 extract_links.py && echo EXIT_OK*)".to_string(),
556                "Bash(python3 extract_links.py*)".to_string(),
557                "Bash(echo EXIT_OK*)".to_string(),
558            ]
559        );
560        // No binary/flag catch-alls: neither the heredoc form's `python3 -*`
561        // nor a `-m` widening survives.
562        let heredoc = command_allow_patterns("python3 - <<'PY'\nprint('ok')\nPY");
563        assert!(!heredoc.iter().any(|p| p == "Bash(python3 -*)"));
564        let module = command_allow_patterns("python3 -m pytest test_x.py -v");
565        assert!(!module.iter().any(|p| p == "Bash(python3 -m*)"));
566        assert!(module.contains(&"Bash(python3 -m pytest test_x.py -v*)".to_string()));
567
568        // And the functional validator's profile carries the exact form only:
569        // `cargo test --workspace x` must not widen to `cargo test*`.
570        let cfg = MissionConfig::default();
571        let profile = for_role(
572            Role::ValidatorFunctional,
573            &cfg,
574            &["cargo test --workspace x".to_string()],
575            &[],
576            &[],
577        );
578        assert!(profile
579            .allowed_tools
580            .contains(&"Bash(cargo test --workspace x*)".to_string()));
581        assert!(!profile
582            .allowed_tools
583            .iter()
584            .any(|p| p == "Bash(cargo test*)"));
585        assert!(!profile.allowed_tools.iter().any(|p| p == "Bash(cargo*)"));
586    }
587
588    /// Ticket validator-env-reads-no-grant (m-83d1ed): validators get
589    /// exactly `printenv KRANZ_*` — never bare printenv/env/echo forms.
590    #[test]
591    fn validator_env_reads_allow_kranz_printenv_only() {
592        let cfg = MissionConfig::default();
593        for role in [Role::ValidatorFunctional, Role::ValidatorScrutiny] {
594            let profile = for_role(role, &cfg, &[], &[], &[]);
595            assert!(
596                profile
597                    .allowed_tools
598                    .contains(&"Bash(printenv KRANZ_*)".to_string()),
599                "{role:?} must allow printenv of KRANZ_ vars"
600            );
601            for poisoned in [
602                "Bash(printenv*)",
603                "Bash(env*)",
604                "Bash(echo*)",
605                "Bash(echo *)",
606            ] {
607                assert!(
608                    !profile.allowed_tools.iter().any(|p| p == poisoned),
609                    "{role:?} must NOT allow {poisoned} (auth-key dump / command runner / substitution)"
610                );
611            }
612        }
613    }
614
615    #[test]
616    fn validator_allowlist_includes_contract_and_worker_commands() {
617        let cfg = MissionConfig::default();
618        let contract_commands = vec!["cargo test".to_string()];
619        let worker_commands = vec!["gc lint".to_string()];
620
621        let mut combined = contract_commands.clone();
622        for command in &worker_commands {
623            if !combined.contains(command) {
624                combined.push(command.clone());
625            }
626        }
627
628        // Scrutiny/mechanical split: contract/worker commands fold into the
629        // functional validator's allow-list only; scrutiny stays read-only.
630        let functional = for_role(Role::ValidatorFunctional, &cfg, &combined, &[], &[]);
631        assert!(functional
632            .allowed_tools
633            .contains(&"Bash(cargo test*)".to_string()));
634        assert!(functional
635            .allowed_tools
636            .contains(&"Bash(gc lint*)".to_string()));
637
638        let scrutiny = for_role(Role::ValidatorScrutiny, &cfg, &combined, &[], &[]);
639        assert!(!scrutiny
640            .allowed_tools
641            .contains(&"Bash(cargo test*)".to_string()));
642        assert!(!scrutiny
643            .allowed_tools
644            .contains(&"Bash(gc lint*)".to_string()));
645    }
646
647    /// Composition audit (ticket `config-fail-open-audit`): config
648    /// `deny_patterns` must EXTEND the built-in worker deny list, never
649    /// replace it. This is the audit's most dangerous replace-shaped
650    /// regression target: if a custom list ever displaced WORKER_DENY, the
651    /// worker could push, publish, sudo, and open raw network sockets while
652    /// the operator believed the §4.7 rails were still on.
653    #[test]
654    fn composition_audit_config_deny_patterns_extend_never_replace_builtin_worker_deny() {
655        let cfg = MissionConfig {
656            deny_patterns: vec!["rm -rf *".to_string(), "TodoWrite".to_string()],
657            ..MissionConfig::default()
658        };
659        let profile = for_role(Role::Worker, &cfg, &[], &[], &[]);
660        for builtin in WORKER_DENY {
661            assert!(
662                profile.disallowed_tools.iter().any(|r| r == builtin),
663                "built-in worker deny {builtin} must survive a custom deny_patterns list"
664            );
665        }
666        // The custom entries ride alongside (wrapped as tool rules as needed).
667        assert!(profile
668            .disallowed_tools
669            .iter()
670            .any(|r| r == "Bash(rm -rf *)"));
671        assert!(profile.disallowed_tools.iter().any(|r| r == "TodoWrite"));
672    }
673
674    /// Composition audit: plan command grants add ALLOWS only — they never
675    /// lift a deny rule. Deny precedence in Claude Code (deny rules win over
676    /// allows) is what makes this safe: a granted command that still matches
677    /// a deny rule stays denied until the operator-approved WorkerDeny grant
678    /// lifts the exact rule (deny_exceptions, event-logged). A regression
679    /// that lets a grant silently erode the deny list fails here.
680    #[test]
681    fn composition_audit_grants_add_allows_without_lifting_deny() {
682        let cfg = MissionConfig::default();
683        let grants = vec!["git push".to_string()];
684        let profile = for_role(Role::Worker, &cfg, &[], &grants, &[]);
685        // The grant's allow pattern is present...
686        assert!(profile
687            .allowed_tools
688            .contains(&"Bash(git push*)".to_string()));
689        // ...but the matching deny rule is NOT removed by the allow, so deny
690        // precedence keeps the command blocked at enforcement time.
691        assert!(profile
692            .disallowed_tools
693            .contains(&"Bash(git push*)".to_string()));
694    }
695
696    /// Composition audit: `bypassPermissions` is reachable ONLY through the
697    /// dangerously-named config key — the naming rule's one escape valve.
698    /// No other config shape (custom deny lists, grants, validator command
699    /// allows) may flip a role into bypass mode.
700    #[test]
701    fn composition_audit_bypass_permissions_requires_the_dangerously_named_key() {
702        let roles = [
703            Role::Worker,
704            Role::Orchestrator,
705            Role::ValidatorScrutiny,
706            Role::ValidatorFunctional,
707        ];
708        let mut cfg = MissionConfig {
709            deny_patterns: vec!["sudo".to_string()],
710            allow_validator_commands: vec!["anything at all".to_string()],
711            ..MissionConfig::default()
712        };
713        for role in roles {
714            let profile = for_role(
715                role,
716                &cfg,
717                &["cargo test".to_string()],
718                &["git push".to_string()],
719                &[],
720            );
721            assert_ne!(
722                profile.permission_mode.as_deref(),
723                Some("bypassPermissions"),
724                "{role:?} must never reach bypassPermissions without the dangerous key"
725            );
726        }
727        cfg.dangerously_allow_all = true;
728        for role in roles {
729            let profile = for_role(role, &cfg, &[], &[], &[]);
730            assert_eq!(
731                profile.permission_mode.as_deref(),
732                Some("bypassPermissions"),
733                "{role:?}: the dangerously-named key is the sanctioned escape valve"
734            );
735            assert!(profile.disallowed_tools.is_empty());
736            assert!(profile.allowed_tools.is_empty());
737        }
738    }
739}