返回 CodeWhale
execution_envelope.rs
根目录 / crates / tui / src / tools / execution_envelope.rs
1 //! The one place that decides whether a delegated child may make a call that
2 //! **executes**, **mutates**, or **reaches the network**.
3 //!
4 //! Before this module the answer was spread across three hand-maintained name
5 //! lists ([`crate::fleet::role::RAW_SHELL_DENYLIST`] and its siblings) plus a
6 //! role posture that keyed on `ShellPolicy::Full`. That shape had a structural
7 //! hole: a name list can only deny the execution primitives someone remembered
8 //! to write down, and `shell = "full"` was being read as "may run arbitrary
9 //! code" by every tool whose approval requirement is `Required`. So a member
10 //! saved as read-only-with-checks (`write = false`, `shell = "full"` — the
11 //! `tester`/`verifier` preset, and any `custom` member shaped like it) lost
12 //! `Bash` and kept:
13 //!
14 //! - `tasks{action:"gate_run"}` — runs an operator-supplied command line;
15 //! - `automation{action:"run"}` / `{action:"create"}` — executes or schedules a
16 //! stored automation, with its own cwd and prompt;
17 //! - `start_mcp_server` — spawns a process and opens a socket;
18 //! - every repository plugin tool, which is a shell command by definition;
19 //!
20 //! each of which mutates the workspace and reaches the network exactly as well
21 //! as the shell that was just removed, while the receipt said `write=false`.
22 //!
23 //! ## What is enforced
24 //!
25 //! The classification is derived, never listed: it comes from the tool's own
26 //! [`ToolCapability`] set and from `is_read_only_for` applied to the **actual
27 //! input**, after [`canonical_action_alias`] has resolved the family/action
28 //! pair. That is what makes it cover tools this file has never heard of —
29 //! plugins, runtime MCP servers, and anything registered later.
30 //!
31 //! | call classification | requires |
32 //! |---|---|
33 //! | read-only for this input | nothing |
34 //! | built-in verification (default or test-selection) | shell authority |
35 //! | `ExecutesCode` | write **and** shell authority |
36 //! | `WritesFiles` | write authority |
37 //! | `Network` | network authority |
38 //!
39 //! `ExecutesCode` requires *write* authority because an arbitrary program is an
40 //! arbitrary mutation primitive; requiring shell authority as well keeps the
41 //! existing posture rule from being weakened. Two carve-outs are deliberate and
42 //! are the "bounded positives" this module must not break:
43 //!
44 //! - **`agent`** declares `ExecutesCode` (it runs a child model loop), but
45 //! delegation is governed by the depth budget and by the fact that the child
46 //! inherits this same envelope. Denying it here would stop a read-only member
47 //! from fanning out read-only work, which is a capability, not an escape.
48 //! - **Bounded verification** — `run_tests` / `run_verifiers` / `Run` — is the
49 //! whole purpose of a read-only verifier, and the shipped `verifier` role is
50 //! exactly `write = false, shell = "full"`. Classifying it by tool name would
51 //! either take the role's job away or hand it a program launcher, so the
52 //! bound is read off the concrete call by [`classify_verification`]:
53 //! argument-free and pure test *selection* both cost shell authority (each
54 //! forks a process, which `analyst`/`scout` were never granted), and
55 //! anything that can name a program is held to the raw-shell bar. Every
56 //! consumer of that contract — the catalog filter, the dispatch guard, and
57 //! `reject_unbounded_verification` / `is_delegated_builtin_verification` in
58 //! [`crate::tools::subagent`] — reads this one classifier rather than
59 //! re-deriving it.
60
61 use serde_json::Value;
62
63 use crate::tools::canonical_action::canonical_action_alias;
64 use crate::tools::spec::{ApprovalRequirement, ToolCapability, ToolSpec};
65
66 /// The execution authority a child actually holds, read off the runtime posture
67 /// rather than off a label.
68 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
69 pub(crate) struct ExecutionEnvelope {
70 /// May mutate the workspace.
71 pub(crate) write: bool,
72 /// May be handed a model-visible network tool.
73 pub(crate) network: bool,
74 /// May run arbitrary commands (raw shell posture).
75 pub(crate) shell: bool,
76 }
77
78 impl ExecutionEnvelope {
79 /// The widest envelope: used by callers that impose no narrowing at all.
80 ///
81 /// Currently reached only from this module's tests — the production
82 /// callers all derive an envelope from a real authority rather than
83 /// starting from the widest one. Kept because it is the identity element
84 /// [`Self::narrow`] is defined against, and removing it would leave that
85 /// invariant untestable.
86 #[cfg_attr(not(test), expect(dead_code))]
87 pub(crate) const UNRESTRICTED: Self = Self {
88 write: true,
89 network: true,
90 shell: true,
91 };
92
93 /// Whether this envelope narrows anything, i.e. whether enforcement can
94 /// ever refuse a call.
95 #[must_use]
96 pub(crate) const fn is_unrestricted(self) -> bool {
97 self.write && self.network && self.shell
98 }
99
100 /// Intersect with another envelope. Used wherever a child envelope is
101 /// derived from a parent one: every field takes the more restrictive side,
102 /// so a descendant can never widen an ancestor.
103 ///
104 /// Exercised by this module's tests today; the grandchild-derivation path
105 /// that consumes it in production lands with the ratification UI.
106 #[must_use]
107 #[cfg_attr(not(test), expect(dead_code))]
108 pub(crate) const fn narrow(self, other: Self) -> Self {
109 Self {
110 write: self.write && other.write,
111 network: self.network && other.network,
112 shell: self.shell && other.shell,
113 }
114 }
115 }
116
117 /// Tool names whose execution is governed by a different, stricter mechanism
118 /// and which therefore must not be judged by capability alone.
119 ///
120 /// Only `agent` qualifies, and the reason is specific: its `ExecutesCode`
121 /// capability describes running a child *model loop*, not a child *program*,
122 /// and that loop runs under a narrowed copy of this same envelope. See the
123 /// module docs.
124 fn is_delegation_tool(canonical: &str) -> bool {
125 canonical == "agent"
126 }
127
128 /// Cargo/test-harness flags a bounded verification call may carry.
129 ///
130 /// An allowlist, not a denylist, and that is the whole of its security value.
131 /// The flags that turn `cargo test` into an arbitrary-program launcher —
132 /// `--config` (which can set `target.runner`), `--manifest-path`,
133 /// `--target-dir`, `--target` — are dangerous precisely because nobody thinks
134 /// to write them down. A denylist would have to enumerate them; this list has
135 /// to enumerate the harmless ones, and an unknown flag is refused by default.
136 ///
137 /// Everything here either selects *which* of the workspace's own tests run or
138 /// changes how their output is reported.
139 const BOUNDED_TEST_FLAGS: &[&str] = &[
140 "--all",
141 "--all-features",
142 "--all-targets",
143 "--benches",
144 "--bin",
145 "--bins",
146 "--color",
147 "--doc",
148 "--example",
149 "--examples",
150 "--exact",
151 "--features",
152 "--ignored",
153 "--include-ignored",
154 "--jobs",
155 "--lib",
156 "--no-default-features",
157 "--no-fail-fast",
158 "--nocapture",
159 "--package",
160 "--quiet",
161 "--release",
162 "--show-output",
163 "--skip",
164 "--test",
165 "--test-threads",
166 "--tests",
167 "--verbose",
168 "--workspace",
169 "-j",
170 "-p",
171 "-q",
172 ];
173
174 /// How tightly one call to the built-in verification surface is bounded.
175 ///
176 /// This is the typed policy the whole verification contract keys on. It exists
177 /// because "bounded" is not a property of the *tool* — `run_tests` is both the
178 /// verifier's entire job and, with the wrong `args`, a way to point cargo at
179 /// another manifest — so the question has to be asked of the concrete call and
180 /// answered in one place that catalog and dispatch both read.
181 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
182 pub(crate) enum VerificationBound {
183 /// No operator input at all: the workspace's own configured checks.
184 Default,
185 /// Operator-supplied arguments that only *select* among the workspace's own
186 /// tests. No shell metacharacters, no separators, no unknown flags — see
187 /// [`is_bounded_test_argv`].
188 Filter,
189 /// Names a program to run, or a flag that can redirect what runs. This is
190 /// arbitrary code execution wearing a verification tool's name.
191 Unbounded,
192 }
193
194 /// Classify a call to the built-in verification surface, or `None` if the call
195 /// is not one.
196 ///
197 /// `run_verifiers{commands}` is *always* unbounded: every entry names a
198 /// `program`, so there is no bounded form of it to admit.
199 #[must_use]
200 pub(crate) fn classify_verification(canonical: &str, input: &Value) -> Option<VerificationBound> {
201 match canonical {
202 "run_tests" => Some(match input.get("args") {
203 None | Some(Value::Null) => VerificationBound::Default,
204 Some(Value::String(args)) if args.trim().is_empty() => VerificationBound::Default,
205 Some(Value::String(args)) if is_bounded_test_argv(args) => VerificationBound::Filter,
206 // A wrongly-typed value fails closed and lets the tool's own schema
207 // error explain the shape.
208 Some(_) => VerificationBound::Unbounded,
209 }),
210 "run_verifiers" => Some(match input.get("commands") {
211 None | Some(Value::Null) => VerificationBound::Default,
212 Some(Value::Array(commands)) if commands.is_empty() => VerificationBound::Default,
213 Some(_) => VerificationBound::Unbounded,
214 }),
215 _ => None,
216 }
217 }
218
219 /// Whether a `run_tests` argv is a pure test *selection*.
220 ///
221 /// Every token must be either an allowlisted flag (optionally `flag=value`) or
222 /// a bare filter, and every value must survive [`is_bounded_argv_value`], whose
223 /// character set contains no separator, no glob, and no shell metacharacter. So
224 /// `-p tui exact_fleet` passes, and `--manifest-path ../evil/Cargo.toml`,
225 /// `--config target.runner="sh -c ..."`, `$(id)`, `a; rm -rf .` and `../..` do
226 /// not.
227 #[must_use]
228 fn is_bounded_test_argv(args: &str) -> bool {
229 args.split_whitespace().all(|arg| {
230 // The cargo/harness separator carries nothing itself.
231 if arg == "--" {
232 return true;
233 }
234 if arg.starts_with('-') {
235 let (flag, value) = arg.split_once('=').unwrap_or((arg, ""));
236 return BOUNDED_TEST_FLAGS.contains(&flag) && is_bounded_argv_value(value);
237 }
238 is_bounded_argv_value(arg)
239 })
240 }
241
242 /// The character set a bounded argv token may draw from.
243 ///
244 /// Deliberately expressed as what is *allowed*: alphanumerics plus the four
245 /// characters a Rust test path needs (`_`, `-`, `.`, `:`). No `/`, `\`, `~`,
246 /// `*`, `$`, backtick, quote, or redirection — so a path, a glob, a traversal,
247 /// and every shell expansion are all excluded by construction rather than by a
248 /// list of things to fear.
249 #[must_use]
250 fn is_bounded_argv_value(value: &str) -> bool {
251 value
252 .chars()
253 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.' | ':'))
254 }
255
256 /// How one concrete call is classified, for both enforcement and diagnostics.
257 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
258 pub(crate) enum CallClass {
259 /// Read-only for this input.
260 Bounded,
261 /// Built-in verification in its default, argument-free form, or narrowed to
262 /// a test *selection*. Needs shell authority — either one starts a process —
263 /// but not write authority, because running the workspace's own checks is
264 /// what a read-only verifier is for.
265 ///
266 /// The argument-free form is deliberately **not** free. It carries no
267 /// operator argument, so nothing about *what* runs is in doubt — but it
268 /// still forks cargo and every configured verifier command, and a member
269 /// saved as `analyst` (`shell = "none"`) or `scout` was given no authority
270 /// to start a process at all. Treating "bounded" as "costless" is what let a
271 /// shell-less posture launch the test suite while its ceiling said it could
272 /// not run anything. The typed `verifier`/`tester` preset keeps this
273 /// capability because its ceiling grants `shell = "full"`; that is the whole
274 /// difference between the two roles.
275 VerificationFilter,
276 /// Built-in verification carrying an operator command line. Held to the
277 /// same bar as raw shell, with its own wording.
278 UnboundedVerification,
279 /// Bounded remote-ref fetch (`Git{action: fetch}`): fixed argv against a
280 /// configured remote, updating only remote-tracking refs. Needs shell
281 /// authority (it forks git) and network (it reaches the remote) — but
282 /// not write authority, because fetching what is under review is what a
283 /// read-only verifier is for. Shape errors (unknown remote, malformed
284 /// refspec) surface from the tool, not the envelope.
285 BoundedFetch,
286 /// Runs a program or a child process.
287 Executes,
288 /// Mutates the filesystem.
289 Mutates,
290 /// Reaches the network.
291 Reaches,
292 }
293
294 /// Classify one call from the tool's real capabilities plus this input.
295 ///
296 /// `ExecutesCode` outranks the others because it subsumes them: a call that can
297 /// run a program can write and can reach out, whatever else it declares.
298 #[must_use]
299 pub(crate) fn classify_call(name: &str, input: &Value, spec: &dyn ToolSpec) -> CallClass {
300 let canonical = canonical_action_alias(name, input);
301 if is_delegation_tool(canonical) {
302 return CallClass::Bounded;
303 }
304 // The verification surface answers for itself, before the generic rules, so
305 // an unbounded form can never be swallowed by a read-only claim and a
306 // bounded one can never be judged on the tool's name alone.
307 match classify_verification(canonical, input) {
308 // Default and Filter land on the same class: both start a process, and
309 // the only thing that separates them is whether an operator argument
310 // narrowed *which* tests run. Neither is available to a posture with no
311 // shell authority.
312 Some(VerificationBound::Default | VerificationBound::Filter) => {
313 return CallClass::VerificationFilter;
314 }
315 Some(VerificationBound::Unbounded) => return CallClass::UnboundedVerification,
316 None => {}
317 }
318 // The bounded fetch answers for itself next, for the same reason: a
319 // `git fetch <remote>` updates only remote-tracking refs through fixed
320 // argv, so it needs a process (shell) and a remote (network) — but not
321 // workspace write authority. Classed before the generic rules so a
322 // malformed fetch can never be swallowed by the family's read-only
323 // default and a bounded one is never judged as opaque execution.
324 if canonical == "git_fetch" {
325 return CallClass::BoundedFetch;
326 }
327 if spec.is_read_only_for(input) {
328 return CallClass::Bounded;
329 }
330 let capabilities = spec.capabilities();
331 if capabilities.contains(&ToolCapability::ExecutesCode) {
332 CallClass::Executes
333 } else if capabilities.contains(&ToolCapability::WritesFiles) {
334 CallClass::Mutates
335 } else if capabilities.contains(&ToolCapability::Network) {
336 CallClass::Reaches
337 } else {
338 // Fail closed on an under-declared tool. A call that is not read-only
339 // for this input and names no positive capability, yet still asks for
340 // approval, is a tool describing its *consequence* without describing
341 // its *mechanism* — `AutomationTool` was exactly this shape and its
342 // `run` action executes a stored automation. Treating the approval
343 // requirement as the floor means a tool has to be positively read-only
344 // to escape the envelope, rather than merely quiet about itself.
345 //
346 // The two approval levels map to different classes on purpose.
347 // `Required` is the level shell and code execution sit at, so it earns
348 // `Executes`. `Suggest` is the file-mutation level, and mapping it to
349 // `Executes` would additionally demand *shell* authority from a
350 // write-capable child that has none — an over-block with no security
351 // value, since the write requirement is the one that bites.
352 match spec.approval_requirement_for(input) {
353 ApprovalRequirement::Required => CallClass::Executes,
354 ApprovalRequirement::Suggest => CallClass::Mutates,
355 ApprovalRequirement::Auto => CallClass::Bounded,
356 }
357 }
358 }
359
360 /// Refuse a call that falls outside `envelope`.
361 ///
362 /// Returns the operator-facing refusal text, which names the posture rather
363 /// than the tool, so the refusal reads as a contract instead of a malfunction.
364 ///
365 /// `proven_read_only` (#5426/#5438) carries bounded read-only shell evidence
366 /// — the exact `agent_readonly_bash_input` predicate `BashTool::execute`
367 /// enforces under `ShellPolicy::ReadOnly` — so the call classifies as
368 /// [`CallClass::Bounded`]: the same class `classify_call` assigns when the
369 /// spec itself reports the input read-only. Two invariants: the admission
370 /// can never outrun the execute-time refusal (same predicate both sides),
371 /// and the parent's parallel auto-approve classifier is untouched —
372 /// `spec.is_read_only_for` still answers the deliberately tighter
373 /// `is_parallel_readonly_command` for every other consumer.
374 pub(crate) fn enforce_execution_envelope(
375 name: &str,
376 input: &Value,
377 spec: &dyn ToolSpec,
378 envelope: ExecutionEnvelope,
379 proven_read_only: bool,
380 ) -> Result<(), String> {
381 if envelope.is_unrestricted() {
382 return Ok(());
383 }
384 if proven_read_only {
385 // Classified Bounded: no capability the call can exercise escapes
386 // the envelope. Network-reaching shape is still rejected separately
387 // by the child's network gate, and the execute path re-verifies the
388 // same predicate before running anything.
389 return Ok(());
390 }
391 match classify_call(name, input, spec) {
392 CallClass::Bounded => Ok(()),
393 CallClass::VerificationFilter => {
394 if envelope.shell {
395 Ok(())
396 } else {
397 Err(format!(
398 "[execution_envelope.verification.shell_denied] Tool {name} starts a test or verifier process, and this agent has no shell \
399 authority under its clamped permission ceiling. That holds for the default, \
400 argument-free form too: running the workspace's own checks still forks a \
401 process, which a `shell = \"none\"` posture was never granted. Use a member \
402 whose saved ceiling grants `shell = \"full\"` (the `verifier`/`tester` \
403 preset), or report findings without running the checks yourself."
404 ))
405 }
406 }
407 CallClass::UnboundedVerification => {
408 if envelope.write && envelope.shell {
409 Ok(())
410 } else {
411 Err(format!(
412 "[execution_envelope.verification.unbounded] Tool {name} was called with operator-supplied commands or arguments that can \
413 name a program or redirect what runs, which is arbitrary execution however \
414 it is spelled. This agent runs read-only under its clamped permission \
415 ceiling. The default verification gates, and test-selection arguments \
416 (filters, `-p`, `--lib`, `--exact`), remain available to a member whose \
417 ceiling grants shell authority."
418 ))
419 }
420 }
421 CallClass::BoundedFetch => {
422 if !envelope.shell {
423 return Err(format!(
424 "[execution_envelope.fetch.shell_denied] Tool {name} fetches remote refs, which starts a git process and updates remote-tracking refs, and this agent has no shell \
425 authority under its clamped permission ceiling. Use a member whose saved \
426 ceiling grants `shell = \"full\"` (the `verifier`/`tester` preset), or report \
427 findings without fetching the remote yourself."
428 ));
429 }
430 if !envelope.network {
431 return Err(format!(
432 "[execution_envelope.fetch.network_denied] Tool {name} fetches remote refs, which reaches the network, and this agent runs with no network \
433 capability under its clamped permission ceiling."
434 ));
435 }
436 Ok(())
437 }
438 CallClass::Executes => {
439 if !envelope.write {
440 return Err(format!(
441 "[execution_envelope.executes.write_denied] Tool {name} runs a program or a child process, which mutates the workspace \
442 just as directly as a file write. This agent runs read-only under its \
443 clamped permission ceiling, so arbitrary execution is refused however it is \
444 spelled — shell, verification gate, automation, plugin, or MCP server. The \
445 built-in verification gates (Run/run_tests/run_verifiers in their default \
446 form) are still available."
447 ));
448 }
449 if !envelope.shell {
450 return Err(format!(
451 "[execution_envelope.executes.shell_denied] Tool {name} runs a program or a child process, and this agent has no shell \
452 authority under its clamped permission ceiling. Use a member whose saved \
453 ceiling grants `shell = \"full\"`."
454 ));
455 }
456 Ok(())
457 }
458 CallClass::Mutates => {
459 if envelope.write {
460 Ok(())
461 } else {
462 Err(format!(
463 "[execution_envelope.mutates.write_denied] Tool {name} mutates state and this agent runs read-only under its clamped \
464 permission ceiling."
465 ))
466 }
467 }
468 CallClass::Reaches => {
469 if envelope.network {
470 Ok(())
471 } else {
472 Err(format!(
473 "[execution_envelope.network.denied] Tool {name} reaches the network and this agent runs with no network \
474 capability (`network_tool = false`) under its clamped permission ceiling."
475 ))
476 }
477 }
478 }
479 }
480
481 #[cfg(test)]
482 mod tests {
483 use super::*;
484 use serde_json::json;
485
486 const READ_ONLY: ExecutionEnvelope = ExecutionEnvelope {
487 write: false,
488 network: false,
489 shell: true,
490 };
491
492 /// A stand-in for any tool the registry may hold, including ones this file
493 /// has never heard of. The point of the guard is that it needs nothing but
494 /// the trait.
495 struct FakeTool {
496 name: &'static str,
497 capabilities: Vec<ToolCapability>,
498 read_only_action: Option<&'static str>,
499 }
500
501 #[async_trait::async_trait]
502 impl ToolSpec for FakeTool {
503 fn name(&self) -> &str {
504 self.name
505 }
506 fn description(&self) -> &str {
507 "fake"
508 }
509 fn input_schema(&self) -> Value {
510 json!({})
511 }
512 fn capabilities(&self) -> Vec<ToolCapability> {
513 self.capabilities.clone()
514 }
515 fn is_read_only_for(&self, input: &Value) -> bool {
516 match self.read_only_action {
517 Some(action) => input.get("action").and_then(Value::as_str) == Some(action),
518 None => false,
519 }
520 }
521 async fn execute(
522 &self,
523 _input: Value,
524 _context: &crate::tools::spec::ToolContext,
525 ) -> Result<crate::tools::spec::ToolResult, crate::tools::spec::ToolError> {
526 unreachable!("classification never executes")
527 }
528 }
529
530 fn executes(name: &'static str, read_only_action: Option<&'static str>) -> FakeTool {
531 FakeTool {
532 name,
533 capabilities: vec![ToolCapability::ExecutesCode],
534 read_only_action,
535 }
536 }
537
538 /// The blocker this module exists for: a read-only member that kept
539 /// `shell = "full"` so it could run checks must not regain arbitrary
540 /// execution through a tool the raw-shell name list never mentioned.
541 #[test]
542 fn execution_primitives_spelled_as_bookkeeping_are_refused_read_only() {
543 for (name, input) in [
544 (
545 "tasks",
546 json!({"action": "gate_run", "command": "rm -rf src"}),
547 ),
548 ("automation", json!({"action": "run", "id": "a1"})),
549 (
550 "automation",
551 json!({"action": "create", "name": "x", "prompt": "exfiltrate"}),
552 ),
553 ("start_mcp_server", json!({"command": "node", "args": []})),
554 ("plugin_deploy", json!({})),
555 ] {
556 let spec = executes(name, Some("list"));
557 let error = enforce_execution_envelope(name, &input, &spec, READ_ONLY, false)
558 .expect_err("read-only member must not execute programs");
559 assert!(error.contains("read-only"), "{name}: {error}");
560 }
561 }
562
563 /// The `analyst`/`scout` posture: tools, but no shell authority at all.
564 const NO_SHELL: ExecutionEnvelope = ExecutionEnvelope {
565 write: false,
566 network: false,
567 shell: false,
568 };
569
570 /// A member whose ceiling grants no shell must not start a process, and the
571 /// argument-free verification gates are no exception: running the
572 /// workspace's own checks still forks cargo and every configured verifier.
573 /// "Bounded" bounds *what* runs, not *whether* something runs.
574 #[test]
575 fn a_shell_less_posture_cannot_start_a_verification_process() {
576 let verifier = executes("run_verifiers", None);
577 for input in [json!({}), json!({"commands": []})] {
578 let error =
579 enforce_execution_envelope("run_verifiers", &input, &verifier, NO_SHELL, false)
580 .expect_err("an analyst was granted no authority to start a process");
581 assert!(error.contains("shell authority"), "{error}");
582 }
583
584 let tests = executes("run_tests", None);
585 for input in [json!({}), json!({"args": " "}), json!({"args": "-p tui"})] {
586 enforce_execution_envelope("run_tests", &input, &tests, NO_SHELL, false)
587 .expect_err("the default test gate still forks a process");
588 }
589
590 // The unbounded form was already refused and stays refused, with its own
591 // wording — the two failures must not collapse into one.
592 let error = enforce_execution_envelope(
593 "run_verifiers",
594 &json!({"commands": [{"program": "bash", "args": ["-lc", "id"]}]}),
595 &verifier,
596 NO_SHELL,
597 false,
598 )
599 .expect_err("operator command lines are refused first");
600 assert!(error.contains("arbitrary execution"), "{error}");
601 }
602
603 /// Bounded fetch costs shell plus network, never workspace write: the
604 /// shipped `verifier`/`tester` ceiling (`write = false, shell = "full"`,
605 /// network) keeps it, while a shell-less or network-less posture loses
606 /// it with a naming-its-cause refusal (#6298).
607 #[test]
608 fn bounded_fetch_costs_shell_plus_network_never_write() {
609 let fetch = executes("Git", None);
610 let input = json!({"action": "fetch", "remote": "origin"});
611 assert_eq!(
612 classify_call("Git", &input, &fetch),
613 CallClass::BoundedFetch
614 );
615
616 let verifier = ExecutionEnvelope {
617 write: false,
618 network: true,
619 shell: true,
620 };
621 enforce_execution_envelope("Git", &input, &fetch, verifier, false)
622 .expect("write=false shell=full with network keeps fetch");
623
624 let error = enforce_execution_envelope("Git", &input, &fetch, NO_SHELL, false)
625 .expect_err("a shell-less posture cannot fetch");
626 assert!(error.contains("shell authority"), "{error}");
627
628 let no_network = ExecutionEnvelope {
629 write: false,
630 network: false,
631 shell: true,
632 };
633 let error = enforce_execution_envelope("Git", &input, &fetch, no_network, false)
634 .expect_err("a network-less posture cannot fetch");
635 assert!(error.contains("network"), "{error}");
636 }
637
638 /// `cwd` scopes *where* the workspace's own checks run; it cannot name a
639 /// program or redirect what runs, so the Default/Filter bound — and the
640 /// shell authority it costs — is unchanged by it.
641 #[test]
642 fn run_cwd_does_not_change_the_verification_bound() {
643 assert_eq!(
644 classify_verification("run_tests", &json!({"cwd": "crates/tui"})),
645 Some(VerificationBound::Default)
646 );
647 assert_eq!(
648 classify_verification("run_tests", &json!({"args": "-p tui", "cwd": "crates/tui"})),
649 Some(VerificationBound::Filter)
650 );
651 assert_eq!(
652 classify_verification("run_verifiers", &json!({"cwd": "crates/tui"})),
653 Some(VerificationBound::Default)
654 );
655 }
656
657 /// The other side of the same rule: the shipped `verifier`/`tester` preset
658 /// is `write = false, shell = "full"`, and that is exactly the ceiling that
659 /// keeps the verification surface. The typed role, not the tool name, is
660 /// what separates it from `analyst`.
661 #[test]
662 fn a_verifier_ceiling_keeps_the_verification_surface() {
663 let tests = executes("run_tests", None);
664 for input in [json!({}), json!({"args": "-p tui exact_fleet"})] {
665 enforce_execution_envelope("run_tests", &input, &tests, READ_ONLY, false)
666 .expect("a verifier ceiling grants shell authority, which is its whole job");
667 }
668 }
669
670 /// Catalog and dispatch read the same classifier, so a call the dispatch
671 /// guard would refuse is never advertised. Asserting on `classify_call`
672 /// directly is what pins that they cannot drift apart.
673 #[test]
674 fn the_default_and_filter_forms_classify_identically() {
675 let tests = executes("run_tests", None);
676 assert_eq!(
677 classify_call("run_tests", &json!({}), &tests),
678 CallClass::VerificationFilter
679 );
680 assert_eq!(
681 classify_call("run_tests", &json!({"args": "-p tui"}), &tests),
682 CallClass::VerificationFilter
683 );
684 assert_eq!(
685 classify_call(
686 "run_tests",
687 &json!({"args": "--manifest-path ../x"}),
688 &tests
689 ),
690 CallClass::UnboundedVerification
691 );
692 }
693
694 /// …and the bounded positives it must not break.
695 #[test]
696 fn bounded_read_only_and_verification_calls_survive() {
697 let tasks = executes("tasks", Some("list"));
698 enforce_execution_envelope(
699 "tasks",
700 &json!({"action": "list"}),
701 &tasks,
702 READ_ONLY,
703 false,
704 )
705 .expect("durable task bookkeeping is read-only");
706
707 let verifier = executes("run_verifiers", None);
708 for input in [json!({}), json!({"commands": []})] {
709 enforce_execution_envelope("run_verifiers", &input, &verifier, READ_ONLY, false)
710 .expect("the default verification gate is what a verifier is for");
711 }
712 let tests = executes("run_tests", None);
713 for input in [json!({}), json!({"args": " "})] {
714 enforce_execution_envelope("run_tests", &input, &tests, READ_ONLY, false)
715 .expect("the default test gate is bounded");
716 }
717
718 // Delegation stays available: a read-only member may still fan out
719 // read-only children, which inherit this same envelope.
720 let agent = executes("agent", None);
721 enforce_execution_envelope(
722 "agent",
723 &json!({"prompt": "read"}),
724 &agent,
725 READ_ONLY,
726 false,
727 )
728 .expect("delegation is governed by depth, not by write authority");
729 }
730
731 /// The shipped `verifier` role is `write = false, shell = "full"`, and its
732 /// documented job is running the suite. A test *selection* must survive, or
733 /// the envelope has taken a shipped role's purpose away.
734 #[test]
735 fn a_read_only_shell_capable_role_keeps_test_selection_arguments() {
736 let tests = executes("run_tests", None);
737 for args in [
738 "-p codewhale-tui",
739 "--lib fleet::exact",
740 "exact_fleet_workflow --exact",
741 "--package tui --test-threads=1 --nocapture",
742 "--workspace --all-features -- --skip slow_case",
743 ] {
744 enforce_execution_envelope(
745 "run_tests",
746 &json!({"args": args}),
747 &tests,
748 READ_ONLY,
749 false,
750 )
751 .unwrap_or_else(|error| panic!("`{args}` selects tests and must run: {error}"));
752 }
753 }
754
755 /// …and *every* form is refused for the stricter read-only roles, which
756 /// hold no shell authority at all.
757 ///
758 /// Both the selection form and the argument-free default are refused,
759 /// because both fork a process: running the workspace's own configured
760 /// checks is still starting a program, and a `shell = "none"` posture was
761 /// never granted that. Admitting the default form would make "no shell" a
762 /// label rather than a ceiling.
763 #[test]
764 fn a_shell_less_read_only_role_cannot_start_a_verification_process_at_all() {
765 const NO_SHELL: ExecutionEnvelope = ExecutionEnvelope {
766 write: false,
767 network: false,
768 shell: false,
769 };
770 let tests = executes("run_tests", None);
771 for input in [json!({"args": "-p tui"}), json!({})] {
772 let error = enforce_execution_envelope("run_tests", &input, &tests, NO_SHELL, false)
773 .expect_err("a planner/scout/consultant has no shell authority");
774 assert!(error.contains("shell"), "{input}: {error}");
775 }
776 }
777
778 /// The escape hatch stays shut: a selection is a selection, and anything
779 /// that can name a program or redirect what runs is raw shell.
780 #[test]
781 fn operator_command_lines_are_refused_however_they_are_spelled() {
782 let tests = executes("run_tests", None);
783 for args in [
784 "--manifest-path ../evil/Cargo.toml",
785 "--config target.runner=sh",
786 "--target-dir /tmp/out",
787 "$(id)",
788 "a; rm -rf .",
789 "--lib | tee /tmp/x",
790 "../../etc/passwd",
791 "tests/*",
792 "--features tui/evil",
793 "`whoami`",
794 ] {
795 let error = enforce_execution_envelope(
796 "run_tests",
797 &json!({"args": args}),
798 &tests,
799 READ_ONLY,
800 false,
801 )
802 .expect_err("`{args}` is not a test selection");
803 assert!(error.contains("read-only"), "{args}: {error}");
804 }
805
806 let verifier = executes("run_verifiers", None);
807 for input in [
808 json!({"commands": [{"program": "bash", "args": ["-lc", "rm -rf src"]}]}),
809 // Wrongly-typed values fail closed rather than reading as absent.
810 json!({"commands": "bash -lc whoami"}),
811 ] {
812 assert!(
813 enforce_execution_envelope("run_verifiers", &input, &verifier, READ_ONLY, false)
814 .is_err(),
815 "run_verifiers names programs and must be refused: {input}"
816 );
817 }
818 }
819
820 #[test]
821 fn write_and_network_capabilities_are_gated_independently() {
822 let writer = FakeTool {
823 name: "pandoc_convert",
824 capabilities: vec![ToolCapability::WritesFiles],
825 read_only_action: None,
826 };
827 assert!(
828 enforce_execution_envelope("pandoc_convert", &json!({}), &writer, READ_ONLY, false)
829 .is_err()
830 );
831
832 let reacher = FakeTool {
833 name: "mcp__remote__query",
834 capabilities: vec![ToolCapability::Network],
835 read_only_action: None,
836 };
837 assert!(
838 enforce_execution_envelope(
839 "mcp__remote__query",
840 &json!({}),
841 &reacher,
842 READ_ONLY,
843 false
844 )
845 .is_err()
846 );
847 assert!(
848 enforce_execution_envelope(
849 "mcp__remote__query",
850 &json!({}),
851 &reacher,
852 ExecutionEnvelope {
853 network: true,
854 ..READ_ONLY
855 },
856 false
857 )
858 .is_ok()
859 );
860 }
861
862 /// An unrestricted envelope must be a true no-op, so nothing here can
863 /// change behavior for an ordinary write-capable child.
864 #[test]
865 fn an_unrestricted_envelope_refuses_nothing() {
866 let spec = executes("tasks", None);
867 enforce_execution_envelope(
868 "tasks",
869 &json!({"action": "gate_run", "command": "cargo test"}),
870 &spec,
871 ExecutionEnvelope::UNRESTRICTED,
872 false,
873 )
874 .expect("a write-capable, shell-capable child keeps its gates");
875 }
876
877 #[test]
878 fn narrowing_never_widens() {
879 let parent = ExecutionEnvelope {
880 write: false,
881 network: true,
882 shell: true,
883 };
884 let child = ExecutionEnvelope {
885 write: true,
886 network: false,
887 shell: true,
888 };
889 let narrowed = parent.narrow(child);
890 assert!(!narrowed.write, "a child cannot regain the parent's writes");
891 assert!(!narrowed.network, "a child cannot regain its own denial");
892 assert!(narrowed.shell);
893 }
894 }
895
895 lines RUST