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}