| 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 |