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