返回 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::exact::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 #[allow(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 #[allow(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 /// Runs a program or a child process.
280 Executes,
281 /// Mutates the filesystem.
282 Mutates,
283 /// Reaches the network.
284 Reaches,
285 }
286
287 /// Classify one call from the tool's real capabilities plus this input.
288 ///
289 /// `ExecutesCode` outranks the others because it subsumes them: a call that can
290 /// run a program can write and can reach out, whatever else it declares.
291 #[must_use]
292 pub(crate) fn classify_call(name: &str, input: &Value, spec: &dyn ToolSpec) -> CallClass {
293 let canonical = canonical_action_alias(name, input);
294 if is_delegation_tool(canonical) {
295 return CallClass::Bounded;
296 }
297 // The verification surface answers for itself, before the generic rules, so
298 // an unbounded form can never be swallowed by a read-only claim and a
299 // bounded one can never be judged on the tool's name alone.
300 match classify_verification(canonical, input) {
301 // Default and Filter land on the same class: both start a process, and
302 // the only thing that separates them is whether an operator argument
303 // narrowed *which* tests run. Neither is available to a posture with no
304 // shell authority.
305 Some(VerificationBound::Default | VerificationBound::Filter) => {
306 return CallClass::VerificationFilter;
307 }
308 Some(VerificationBound::Unbounded) => return CallClass::UnboundedVerification,
309 None => {}
310 }
311 if spec.is_read_only_for(input) {
312 return CallClass::Bounded;
313 }
314 let capabilities = spec.capabilities();
315 if capabilities.contains(&ToolCapability::ExecutesCode) {
316 CallClass::Executes
317 } else if capabilities.contains(&ToolCapability::WritesFiles) {
318 CallClass::Mutates
319 } else if capabilities.contains(&ToolCapability::Network) {
320 CallClass::Reaches
321 } else {
322 // Fail closed on an under-declared tool. A call that is not read-only
323 // for this input and names no positive capability, yet still asks for
324 // approval, is a tool describing its *consequence* without describing
325 // its *mechanism* — `AutomationTool` was exactly this shape and its
326 // `run` action executes a stored automation. Treating the approval
327 // requirement as the floor means a tool has to be positively read-only
328 // to escape the envelope, rather than merely quiet about itself.
329 //
330 // The two approval levels map to different classes on purpose.
331 // `Required` is the level shell and code execution sit at, so it earns
332 // `Executes`. `Suggest` is the file-mutation level, and mapping it to
333 // `Executes` would additionally demand *shell* authority from a
334 // write-capable child that has none — an over-block with no security
335 // value, since the write requirement is the one that bites.
336 match spec.approval_requirement_for(input) {
337 ApprovalRequirement::Required => CallClass::Executes,
338 ApprovalRequirement::Suggest => CallClass::Mutates,
339 ApprovalRequirement::Auto => CallClass::Bounded,
340 }
341 }
342 }
343
344 /// Refuse a call that falls outside `envelope`.
345 ///
346 /// Returns the operator-facing refusal text, which names the posture rather
347 /// than the tool, so the refusal reads as a contract instead of a malfunction.
348 pub(crate) fn enforce_execution_envelope(
349 name: &str,
350 input: &Value,
351 spec: &dyn ToolSpec,
352 envelope: ExecutionEnvelope,
353 ) -> Result<(), String> {
354 if envelope.is_unrestricted() {
355 return Ok(());
356 }
357 match classify_call(name, input, spec) {
358 CallClass::Bounded => Ok(()),
359 CallClass::VerificationFilter => {
360 if envelope.shell {
361 Ok(())
362 } else {
363 Err(format!(
364 "Tool {name} starts a test or verifier process, and this agent has no shell \
365 authority under its clamped permission ceiling. That holds for the default, \
366 argument-free form too: running the workspace's own checks still forks a \
367 process, which a `shell = \"none\"` posture was never granted. Use a member \
368 whose saved ceiling grants `shell = \"full\"` (the `verifier`/`tester` \
369 preset), or report findings without running the checks yourself."
370 ))
371 }
372 }
373 CallClass::UnboundedVerification => {
374 if envelope.write && envelope.shell {
375 Ok(())
376 } else {
377 Err(format!(
378 "Tool {name} was called with operator-supplied commands or arguments that can \
379 name a program or redirect what runs, which is arbitrary execution however \
380 it is spelled. This agent runs read-only under its clamped permission \
381 ceiling. The default verification gates, and test-selection arguments \
382 (filters, `-p`, `--lib`, `--exact`), remain available to a member whose \
383 ceiling grants shell authority."
384 ))
385 }
386 }
387 CallClass::Executes => {
388 if !envelope.write {
389 return Err(format!(
390 "Tool {name} runs a program or a child process, which mutates the workspace \
391 just as directly as a file write. This agent runs read-only under its \
392 clamped permission ceiling, so arbitrary execution is refused however it is \
393 spelled — shell, verification gate, automation, plugin, or MCP server. The \
394 built-in verification gates (Run/run_tests/run_verifiers in their default \
395 form) are still available."
396 ));
397 }
398 if !envelope.shell {
399 return Err(format!(
400 "Tool {name} runs a program or a child process, and this agent has no shell \
401 authority under its clamped permission ceiling. Use a member whose saved \
402 ceiling grants `shell = \"full\"`."
403 ));
404 }
405 Ok(())
406 }
407 CallClass::Mutates => {
408 if envelope.write {
409 Ok(())
410 } else {
411 Err(format!(
412 "Tool {name} mutates state and this agent runs read-only under its clamped \
413 permission ceiling."
414 ))
415 }
416 }
417 CallClass::Reaches => {
418 if envelope.network {
419 Ok(())
420 } else {
421 Err(format!(
422 "Tool {name} reaches the network and this agent runs with no network \
423 capability (`network_tool = false`) under its clamped permission ceiling."
424 ))
425 }
426 }
427 }
428 }
429
430 #[cfg(test)]
431 mod tests {
432 use super::*;
433 use serde_json::json;
434
435 const READ_ONLY: ExecutionEnvelope = ExecutionEnvelope {
436 write: false,
437 network: false,
438 shell: true,
439 };
440
441 /// A stand-in for any tool the registry may hold, including ones this file
442 /// has never heard of. The point of the guard is that it needs nothing but
443 /// the trait.
444 struct FakeTool {
445 name: &'static str,
446 capabilities: Vec<ToolCapability>,
447 read_only_action: Option<&'static str>,
448 }
449
450 #[async_trait::async_trait]
451 impl ToolSpec for FakeTool {
452 fn name(&self) -> &str {
453 self.name
454 }
455 fn description(&self) -> &str {
456 "fake"
457 }
458 fn input_schema(&self) -> Value {
459 json!({})
460 }
461 fn capabilities(&self) -> Vec<ToolCapability> {
462 self.capabilities.clone()
463 }
464 fn is_read_only_for(&self, input: &Value) -> bool {
465 match self.read_only_action {
466 Some(action) => input.get("action").and_then(Value::as_str) == Some(action),
467 None => false,
468 }
469 }
470 async fn execute(
471 &self,
472 _input: Value,
473 _context: &crate::tools::spec::ToolContext,
474 ) -> Result<crate::tools::spec::ToolResult, crate::tools::spec::ToolError> {
475 unreachable!("classification never executes")
476 }
477 }
478
479 fn executes(name: &'static str, read_only_action: Option<&'static str>) -> FakeTool {
480 FakeTool {
481 name,
482 capabilities: vec![ToolCapability::ExecutesCode],
483 read_only_action,
484 }
485 }
486
487 /// The blocker this module exists for: a read-only member that kept
488 /// `shell = "full"` so it could run checks must not regain arbitrary
489 /// execution through a tool the raw-shell name list never mentioned.
490 #[test]
491 fn execution_primitives_spelled_as_bookkeeping_are_refused_read_only() {
492 for (name, input) in [
493 (
494 "tasks",
495 json!({"action": "gate_run", "command": "rm -rf src"}),
496 ),
497 ("automation", json!({"action": "run", "id": "a1"})),
498 (
499 "automation",
500 json!({"action": "create", "name": "x", "prompt": "exfiltrate"}),
501 ),
502 ("start_mcp_server", json!({"command": "node", "args": []})),
503 ("plugin_deploy", json!({})),
504 ] {
505 let spec = executes(name, Some("list"));
506 let error = enforce_execution_envelope(name, &input, &spec, READ_ONLY)
507 .expect_err("read-only member must not execute programs");
508 assert!(error.contains("read-only"), "{name}: {error}");
509 }
510 }
511
512 /// The `analyst`/`scout` posture: tools, but no shell authority at all.
513 const NO_SHELL: ExecutionEnvelope = ExecutionEnvelope {
514 write: false,
515 network: false,
516 shell: false,
517 };
518
519 /// A member whose ceiling grants no shell must not start a process, and the
520 /// argument-free verification gates are no exception: running the
521 /// workspace's own checks still forks cargo and every configured verifier.
522 /// "Bounded" bounds *what* runs, not *whether* something runs.
523 #[test]
524 fn a_shell_less_posture_cannot_start_a_verification_process() {
525 let verifier = executes("run_verifiers", None);
526 for input in [json!({}), json!({"commands": []})] {
527 let error = enforce_execution_envelope("run_verifiers", &input, &verifier, NO_SHELL)
528 .expect_err("an analyst was granted no authority to start a process");
529 assert!(error.contains("shell authority"), "{error}");
530 }
531
532 let tests = executes("run_tests", None);
533 for input in [json!({}), json!({"args": " "}), json!({"args": "-p tui"})] {
534 enforce_execution_envelope("run_tests", &input, &tests, NO_SHELL)
535 .expect_err("the default test gate still forks a process");
536 }
537
538 // The unbounded form was already refused and stays refused, with its own
539 // wording — the two failures must not collapse into one.
540 let error = enforce_execution_envelope(
541 "run_verifiers",
542 &json!({"commands": [{"program": "bash", "args": ["-lc", "id"]}]}),
543 &verifier,
544 NO_SHELL,
545 )
546 .expect_err("operator command lines are refused first");
547 assert!(error.contains("arbitrary execution"), "{error}");
548 }
549
550 /// The other side of the same rule: the shipped `verifier`/`tester` preset
551 /// is `write = false, shell = "full"`, and that is exactly the ceiling that
552 /// keeps the verification surface. The typed role, not the tool name, is
553 /// what separates it from `analyst`.
554 #[test]
555 fn a_verifier_ceiling_keeps_the_verification_surface() {
556 let tests = executes("run_tests", None);
557 for input in [json!({}), json!({"args": "-p tui exact_fleet"})] {
558 enforce_execution_envelope("run_tests", &input, &tests, READ_ONLY)
559 .expect("a verifier ceiling grants shell authority, which is its whole job");
560 }
561 }
562
563 /// Catalog and dispatch read the same classifier, so a call the dispatch
564 /// guard would refuse is never advertised. Asserting on `classify_call`
565 /// directly is what pins that they cannot drift apart.
566 #[test]
567 fn the_default_and_filter_forms_classify_identically() {
568 let tests = executes("run_tests", None);
569 assert_eq!(
570 classify_call("run_tests", &json!({}), &tests),
571 CallClass::VerificationFilter
572 );
573 assert_eq!(
574 classify_call("run_tests", &json!({"args": "-p tui"}), &tests),
575 CallClass::VerificationFilter
576 );
577 assert_eq!(
578 classify_call(
579 "run_tests",
580 &json!({"args": "--manifest-path ../x"}),
581 &tests
582 ),
583 CallClass::UnboundedVerification
584 );
585 }
586
587 /// …and the bounded positives it must not break.
588 #[test]
589 fn bounded_read_only_and_verification_calls_survive() {
590 let tasks = executes("tasks", Some("list"));
591 enforce_execution_envelope("tasks", &json!({"action": "list"}), &tasks, READ_ONLY)
592 .expect("durable task bookkeeping is read-only");
593
594 let verifier = executes("run_verifiers", None);
595 for input in [json!({}), json!({"commands": []})] {
596 enforce_execution_envelope("run_verifiers", &input, &verifier, READ_ONLY)
597 .expect("the default verification gate is what a verifier is for");
598 }
599 let tests = executes("run_tests", None);
600 for input in [json!({}), json!({"args": " "})] {
601 enforce_execution_envelope("run_tests", &input, &tests, READ_ONLY)
602 .expect("the default test gate is bounded");
603 }
604
605 // Delegation stays available: a read-only member may still fan out
606 // read-only children, which inherit this same envelope.
607 let agent = executes("agent", None);
608 enforce_execution_envelope("agent", &json!({"prompt": "read"}), &agent, READ_ONLY)
609 .expect("delegation is governed by depth, not by write authority");
610 }
611
612 /// The shipped `verifier` role is `write = false, shell = "full"`, and its
613 /// documented job is running the suite. A test *selection* must survive, or
614 /// the envelope has taken a shipped role's purpose away.
615 #[test]
616 fn a_read_only_shell_capable_role_keeps_test_selection_arguments() {
617 let tests = executes("run_tests", None);
618 for args in [
619 "-p codewhale-tui",
620 "--lib fleet::exact",
621 "exact_fleet_workflow --exact",
622 "--package tui --test-threads=1 --nocapture",
623 "--workspace --all-features -- --skip slow_case",
624 ] {
625 enforce_execution_envelope("run_tests", &json!({"args": args}), &tests, READ_ONLY)
626 .unwrap_or_else(|error| panic!("`{args}` selects tests and must run: {error}"));
627 }
628 }
629
630 /// …and *every* form is refused for the stricter read-only roles, which
631 /// hold no shell authority at all.
632 ///
633 /// Both the selection form and the argument-free default are refused,
634 /// because both fork a process: running the workspace's own configured
635 /// checks is still starting a program, and a `shell = "none"` posture was
636 /// never granted that. Admitting the default form would make "no shell" a
637 /// label rather than a ceiling.
638 #[test]
639 fn a_shell_less_read_only_role_cannot_start_a_verification_process_at_all() {
640 const NO_SHELL: ExecutionEnvelope = ExecutionEnvelope {
641 write: false,
642 network: false,
643 shell: false,
644 };
645 let tests = executes("run_tests", None);
646 for input in [json!({"args": "-p tui"}), json!({})] {
647 let error = enforce_execution_envelope("run_tests", &input, &tests, NO_SHELL)
648 .expect_err("a planner/scout/consultant has no shell authority");
649 assert!(error.contains("shell"), "{input}: {error}");
650 }
651 }
652
653 /// The escape hatch stays shut: a selection is a selection, and anything
654 /// that can name a program or redirect what runs is raw shell.
655 #[test]
656 fn operator_command_lines_are_refused_however_they_are_spelled() {
657 let tests = executes("run_tests", None);
658 for args in [
659 "--manifest-path ../evil/Cargo.toml",
660 "--config target.runner=sh",
661 "--target-dir /tmp/out",
662 "$(id)",
663 "a; rm -rf .",
664 "--lib | tee /tmp/x",
665 "../../etc/passwd",
666 "tests/*",
667 "--features tui/evil",
668 "`whoami`",
669 ] {
670 let error =
671 enforce_execution_envelope("run_tests", &json!({"args": args}), &tests, READ_ONLY)
672 .expect_err("`{args}` is not a test selection");
673 assert!(error.contains("read-only"), "{args}: {error}");
674 }
675
676 let verifier = executes("run_verifiers", None);
677 for input in [
678 json!({"commands": [{"program": "bash", "args": ["-lc", "rm -rf src"]}]}),
679 // Wrongly-typed values fail closed rather than reading as absent.
680 json!({"commands": "bash -lc whoami"}),
681 ] {
682 assert!(
683 enforce_execution_envelope("run_verifiers", &input, &verifier, READ_ONLY).is_err(),
684 "run_verifiers names programs and must be refused: {input}"
685 );
686 }
687 }
688
689 #[test]
690 fn write_and_network_capabilities_are_gated_independently() {
691 let writer = FakeTool {
692 name: "pandoc_convert",
693 capabilities: vec![ToolCapability::WritesFiles],
694 read_only_action: None,
695 };
696 assert!(
697 enforce_execution_envelope("pandoc_convert", &json!({}), &writer, READ_ONLY).is_err()
698 );
699
700 let reacher = FakeTool {
701 name: "mcp__remote__query",
702 capabilities: vec![ToolCapability::Network],
703 read_only_action: None,
704 };
705 assert!(
706 enforce_execution_envelope("mcp__remote__query", &json!({}), &reacher, READ_ONLY)
707 .is_err()
708 );
709 assert!(
710 enforce_execution_envelope(
711 "mcp__remote__query",
712 &json!({}),
713 &reacher,
714 ExecutionEnvelope {
715 network: true,
716 ..READ_ONLY
717 },
718 )
719 .is_ok()
720 );
721 }
722
723 /// An unrestricted envelope must be a true no-op, so nothing here can
724 /// change behavior for an ordinary write-capable child.
725 #[test]
726 fn an_unrestricted_envelope_refuses_nothing() {
727 let spec = executes("tasks", None);
728 enforce_execution_envelope(
729 "tasks",
730 &json!({"action": "gate_run", "command": "cargo test"}),
731 &spec,
732 ExecutionEnvelope::UNRESTRICTED,
733 )
734 .expect("a write-capable, shell-capable child keeps its gates");
735 }
736
737 #[test]
738 fn narrowing_never_widens() {
739 let parent = ExecutionEnvelope {
740 write: false,
741 network: true,
742 shell: true,
743 };
744 let child = ExecutionEnvelope {
745 write: true,
746 network: false,
747 shell: true,
748 };
749 let narrowed = parent.narrow(child);
750 assert!(!narrowed.write, "a child cannot regain the parent's writes");
751 assert!(!narrowed.network, "a child cannot regain its own denial");
752 assert!(narrowed.shell);
753 }
754 }
755
755 lines RUST