diff --git a/iii-permissions.yaml b/iii-permissions.yaml index a12c900d..7f77bf3d 100644 --- a/iii-permissions.yaml +++ b/iii-permissions.yaml @@ -28,6 +28,13 @@ rules: - '!configuration::get' - '!configuration::set' - '!configuration::register' + # Internal hot-reload trigger handler — must not be agent-callable; it is + # invoked only by the engine's `configuration:updated` trigger dispatch. + - '!shell::on-config-change' + # Operator/automation health signal, not an agent tool: it can surface a + # build-error string (e.g. a host_root path). Operators/console reach it via + # the privileged dispatch path that bypasses this agent gate. + - '!shell::config-status' - '!oauth::anthropic::login' - '!oauth::openai-codex::login' - '!run::start' diff --git a/shell/ARCHITECTURE.md b/shell/ARCHITECTURE.md index c01632cf..d50420f2 100644 --- a/shell/ARCHITECTURE.md +++ b/shell/ARCHITECTURE.md @@ -29,15 +29,26 @@ iii -c ./config.yaml | flag | default | purpose | |------|---------|---------| -| `--config ` | `./config.yaml` | YAML config (shape below) | +| `--config ` | `./config.yaml` | Optional seed config: the YAML is passed as `initial_value` when registering the schema with the `configuration` worker on first boot. It is **not** the live source of truth — the live value is fetched over RPC after registration. | | `--url ` | `ws://127.0.0.1:49134` | iii engine WebSocket | -| `--manifest` | off | print the JSON function manifest and exit (use for tooling/introspection) | + +## Configuration + +The shell worker integrates with the central `configuration` worker rather than reading a static file at runtime: + +1. On boot it registers a schema with id `shell`; the YAML at `--config ` (default `./config.yaml`) is sent as the `initial_value` (populates the first-boot default). If the file is missing or unreadable the worker warns and continues without a seed. +2. It immediately fetches the live value over RPC and activates the security policy and fs backend from that response. +3. It then registers the `configuration:updated` trigger and runs a **fail-closed** boot reconcile before exposing any public function. The reconcile re-fetches the authoritative value (closing the race where an update lands between the initial fetch and trigger registration, leaving no listener). If that re-fetch fails the worker aborts startup — it exits rather than serve a possibly stale security policy, and no `shell::*` / `shell::fs::*` function is ever exposed. +4. It subscribes to `configuration:updated` events. When the config for schema id `shell` changes, the worker hot-reloads the security policy and fs backend atomically. +5. If the incoming config is invalid or unsafe (e.g. schema validation passes but the worker cannot build it — bad denylist regex, unreachable `host_root`), the worker keeps the last-good runtime and logs an error — it does **not** crash, and it does **not** retry (re-fetching returns the same bad value, so a retry would storm). The rejection is recorded and surfaced by `shell::config-status` (a `rejected` outcome with a non-zero `rejected_reloads` count) so the divergence between the central store and the enforced policy is detectable instead of silent. +6. A reload that widens the jail (clearing `host_root`) succeeds, but is logged as a privilege change. ## Full YAML defaults | key | default | enforced where | |-----|---------|----------------| -| `max_timeout_ms` | `30000` | hard cap; per-call `timeout_ms` clamped to this | +| `max_timeout_ms` | `30000` | foreground `exec` hard cap; per-call `timeout_ms` clamped to this | +| `max_bg_timeout_ms` | `0` | host bg job hard cap in ms; `0` = unbounded (separate from `max_timeout_ms`, which bounds foreground exec) | | `default_timeout_ms` | `10000` | applied when caller omits `timeout_ms` | | `max_output_bytes` | `1048576` (1 MiB) | stdout/stderr truncated; `*_truncated` flagged | | `working_dir` | `null` | pins cwd for spawned commands when set | @@ -46,12 +57,13 @@ iii -c ./config.yaml | `allowlist` | `[]` (open) | command basename allowlist; empty = open | | `denylist_patterns` | `[]` | advisory regex tripwire on `argv.join(" ")` | | `max_concurrent_jobs` | `16` | rejects new `exec_bg` past the cap | -| `job_retention_secs` | `3600` | finished jobs pruned on every `shell::list` | +| `job_retention_secs` | `3600` | finished jobs evicted by a background reaper (interval `min(30s, retention/2)`) — the primary prune path; prune-on-`shell::list` remains as a harmless secondary trigger | | `fs.host_root` | `null` | jail root; required unless `fs.allow_unjailed: true` | | `fs.allow_unjailed` | `false` | explicit opt-in to running with `host_root: null` | | `fs.max_read_bytes` | `0` (unlimited) | pre-flight cap via `fs::metadata` (`S218`) | | `fs.max_write_bytes` | `0` (unlimited) | mid-stream cap during write (`S218`) | | `fs.denylist_paths` | `[]` | absolute-prefix denylist; rejected with `S215` | +| `fs.allow_special_bits` | `false` | setuid/setgid/sticky bits in `mkdir`/`chmod`/`write` modes are rejected with `S210` unless `true` | | `sandbox.enabled` | `true` | `false` → every sandbox-target call returns `S210` | ## Threat model diff --git a/shell/Cargo.lock b/shell/Cargo.lock index 50a28aa9..67b500cb 100644 --- a/shell/Cargo.lock +++ b/shell/Cargo.lock @@ -84,12 +84,6 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - [[package]] name = "base64" version = "0.22.1" @@ -191,18 +185,6 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" -[[package]] -name = "const-hex" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20d9a563d167a9cce0f94153382b33cb6eded6dfabff03c69ad65a28ea1514e0" -dependencies = [ - "cfg-if", - "cpufeatures", - "proptest", - "serde_core", -] - [[package]] name = "core-foundation" version = "0.10.1" @@ -271,12 +253,6 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - [[package]] name = "equivalent" version = "1.0.2" @@ -299,12 +275,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "foldhash" version = "0.1.5" @@ -433,25 +403,6 @@ dependencies = [ "wasip3", ] -[[package]] -name = "h2" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "hashbrown" version = "0.15.5" @@ -533,7 +484,6 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", "http", "http-body", "httparse", @@ -560,19 +510,6 @@ dependencies = [ "webpki-roots", ] -[[package]] -name = "hyper-timeout" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" -dependencies = [ - "hyper", - "hyper-util", - "pin-project-lite", - "tokio", - "tower-service", -] - [[package]] name = "hyper-util" version = "0.1.20" @@ -707,22 +644,17 @@ dependencies = [ [[package]] name = "iii-observability" -version = "0.16.0-next.2" +version = "0.19.1-next.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00ee84dacaf7e14750ebdc229523e52029441c4ae1f72d25972bf1f2e1edeb99" +checksum = "7169861d75f52022881edf09657763c84299e935e60abe19faf98308dc4122e6" dependencies = [ - "async-trait", "futures-util", "opentelemetry", "opentelemetry-http", - "opentelemetry-proto", "opentelemetry_sdk", - "prost", "reqwest", - "serde", "serde_json", "sysinfo", - "thiserror", "tokio", "tokio-tungstenite", "tracing", @@ -731,9 +663,9 @@ dependencies = [ [[package]] name = "iii-sdk" -version = "0.16.0-next.2" +version = "0.19.1-next.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c18b68b2aa09e18c68fb023c78316fe7ccf42181874eab584d66f40119b47e" +checksum = "619bbf68e82f91fa54d23986f00958a5bd573a08751c89b6528d2e35916a8642" dependencies = [ "async-trait", "futures-util", @@ -784,15 +716,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.18" @@ -891,15 +814,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -964,23 +878,6 @@ dependencies = [ "reqwest", ] -[[package]] -name = "opentelemetry-proto" -version = "0.31.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f" -dependencies = [ - "base64", - "const-hex", - "opentelemetry", - "opentelemetry_sdk", - "prost", - "serde", - "serde_json", - "tonic", - "tonic-prost", -] - [[package]] name = "opentelemetry_sdk" version = "0.31.0" @@ -1004,26 +901,6 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "pin-project" -version = "1.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1067,44 +944,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "proptest" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" -dependencies = [ - "bitflags", - "num-traits", - "rand", - "rand_chacha", - "rand_xorshift", - "regex-syntax", - "unarray", -] - -[[package]] -name = "prost" -version = "0.14.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" -dependencies = [ - "bytes", - "prost-derive", -] - -[[package]] -name = "prost-derive" -version = "0.14.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" -dependencies = [ - "anyhow", - "itertools", - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "quinn" version = "0.11.9" @@ -1210,15 +1049,6 @@ dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "rand_xorshift" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" -dependencies = [ - "rand_core", -] - [[package]] name = "regex" version = "1.12.3" @@ -1538,12 +1368,13 @@ dependencies = [ [[package]] name = "shell" -version = "0.3.5" +version = "0.4.0" dependencies = [ "anyhow", "async-trait", "base64", "clap", + "iii-observability", "iii-sdk", "libc", "once_cell", @@ -1786,56 +1617,6 @@ dependencies = [ "tungstenite", ] -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tonic" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec" -dependencies = [ - "async-trait", - "base64", - "bytes", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-timeout", - "hyper-util", - "percent-encoding", - "pin-project", - "sync_wrapper", - "tokio", - "tokio-stream", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tonic-prost" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a55376a0bbaa4975a3f10d009ad763d8f4108f067c7c2e74f3001fb49778d309" -dependencies = [ - "bytes", - "prost", - "tonic", -] - [[package]] name = "tower" version = "0.5.3" @@ -1844,15 +1625,11 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", - "indexmap", "pin-project-lite", - "slab", "sync_wrapper", "tokio", - "tokio-util", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -1977,12 +1754,6 @@ version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" -[[package]] -name = "unarray" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" - [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/shell/Cargo.toml b/shell/Cargo.toml index 023fa834..872b446e 100644 --- a/shell/Cargo.toml +++ b/shell/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "shell" -version = "0.3.7" +version = "0.4.0" edition = "2021" publish = false @@ -11,7 +11,8 @@ name = "shell" path = "src/main.rs" [dependencies] -iii-sdk = "=0.16.0-next.2" +iii-sdk = "=0.19.1-next.1" +iii-observability = "=0.19.1-next.1" schemars = { version = "0.8", features = ["uuid1"] } libc = "0.2" tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "signal", "process", "time", "io-util", "fs"] } diff --git a/shell/README.md b/shell/README.md index 0ab35954..b156ba0a 100644 --- a/shell/README.md +++ b/shell/README.md @@ -32,14 +32,19 @@ npx skills add iii-hq/workers --all ## Configure -Settings load from a YAML file passed with `--config ` (default `./config.yaml`). The worker refuses to start unless `fs.host_root` is set, or `fs.allow_unjailed: true` is explicitly opted in, because an unset root exposes the whole host filesystem behind only the advisory denylist. +Settings are managed through the central `configuration` worker. On boot, the shell worker registers its schema (id `shell`) and fetches the live value over RPC — that live value is the authoritative config, not a local file. The optional `--config ` flag (default `./config.yaml`) provides the `initial_value` sent on first registration only; once registered, subsequent boots pull the stored value from the `configuration` worker. When the config changes, the worker hot-reloads the security policy and fs backend automatically (see [Hot-reload](#hot-reload)). + +The worker refuses to start unless `fs.host_root` is set, or `fs.allow_unjailed: true` is explicitly opted in, because an unset root exposes the whole host filesystem behind only the advisory denylist. + +By default, `mkdir`/`chmod`/`write` reject modes carrying setuid/setgid/sticky bits (the top octal digit, e.g. `4755`) with `S210`, since they are a privilege-escalation primitive when the worker runs as root inside the jail. Set `fs.allow_special_bits: true` only if your workload genuinely needs them. ```yaml -max_timeout_ms: 30000 # hard cap; per-call timeout_ms is clamped to this +max_timeout_ms: 30000 # foreground exec hard cap; per-call timeout_ms is clamped to this +max_bg_timeout_ms: 0 # host bg job hard cap in ms; 0 = unbounded (foreground uses max_timeout_ms) default_timeout_ms: 10000 # applied when the caller omits timeout_ms max_output_bytes: 1048576 # 1 MiB; stdout/stderr past this set *_truncated inherit_env: false # when false, only allowed_env keys are forwarded -allowed_env: [PATH, HOME, LANG, LC_ALL, TERM] +allowed_env: [PATH, HOME, LANG, LC_ALL, TERM] # also gates per-call `env` (dangerous keys never settable) # exec gate. argv[0] is matched by basename or exact path; an empty # allowlist means open. denylist_patterns are advisory regex over @@ -58,6 +63,7 @@ fs: max_read_bytes: 16777216 # 0 = unlimited max_write_bytes: 16777216 # 0 = unlimited denylist_paths: [/etc/passwd, /etc/shadow] + allow_special_bits: false # permit setuid/setgid/sticky bits in mode (default false) sandbox: enabled: true # false -> every target: sandbox call returns S210 @@ -65,6 +71,16 @@ sandbox: Host `shell::exec` is not a security boundary: any allowlisted interpreter (`sh`, `node`, `python3`) can construct a denylisted token at runtime and bypass the regex. Run untrusted input with `target: { kind: "sandbox", sandbox_id }`, which forwards through the `iii-sandbox` microVM. The allowlist and denylist still apply on top of either backend. +### Per-call `cwd`, `env`, and `stdin` (host target) + +`shell::exec` and `shell::exec_bg` each accept optional fields so an agent can scope a single command to a directory, set specific env values, and feed it standard input without wrapping everything in `sh -lc` (which would defeat the argv allowlist): + +- **`cwd`** (string): the working directory for this one call. It is confined to the fs jail **exactly** like `shell::fs::*` paths — jail-relative when `fs.host_root` is set (else absolute), canonicalized, and required to resolve inside `host_root` and miss `denylist_paths`. A `cwd` that escapes the jail returns `S215`; one that doesn't exist or isn't a directory returns `S211`/`S210`. Omit it to use the configured `working_dir` (unchanged default). +- **`env`** (object of string→string): per-call environment values. A key may be set **only** if the operator already listed it in `allowed_env`, and **never** for an exec-hijacking key — `PATH`, `IFS`, `HOME`, every `LD_*`/`DYLD_*` variant, and other loader/lookup-path and interpreter startup-file keys (`GCONV_PATH`, `BASH_ENV`, `ENV`, `PYTHONSTARTUP`, `PERL5OPT`, `RUBYOPT`, `NODE_OPTIONS`, …) are on a hardcoded denylist that **wins over** `allowed_env`. Note that `HOME` ships in the default `allowed_env` for the worker's own forwarded env but is **not** settable per-call. Supplying a key that is not in `allowed_env`, or any dangerous key, rejects the **whole call** with `S210` (the offending key is named and the permitted keys are listed); the env is never silently dropped. A permitted per-call value overrides the value that would otherwise be forwarded for that key. So an agent can do `NODE_ENV=test` only if the operator put `NODE_ENV` in `allowed_env`, and can never inject `PATH`, `HOME`, or `LD_PRELOAD`. +- **`stdin`** (string): written to the program's standard input, which is then closed (EOF). Use it to feed `tee`, `patch`, `cat`, or any stdin filter instead of a shell heredoc. Omit it and stdin is `/dev/null`. + +All three fields are **host-only**. The `sandbox::exec` protocol does not forward `cwd`/`env`/`stdin`, so a sandbox-targeted call that supplies any of them is rejected with `S210` rather than silently ignoring it. Omit them and behaviour is identical to prior versions. + ## Quick start ```ts @@ -80,30 +96,41 @@ const result = await iii.trigger({ console.log(result) ``` -The example runs on the host. The same payload retargets at a microVM with `target: { kind: 'sandbox', sandbox_id: '' }`. The other entry points are `shell::exec_bg`, `shell::status`, `shell::kill`, `shell::list`, plus the `shell::fs::*` family (`ls`, `stat`, `read`, `write`, `grep`, `sed`, `mkdir`, `rm`, `chmod`, `mv`). +The example runs on the host. The same payload retargets at a microVM with `target: { kind: 'sandbox', sandbox_id: '' }`. The other entry points are `shell::exec_bg`, `shell::status`, `shell::kill`, `shell::list`, `shell::config-status`, plus the `shell::fs::*` family (`ls`, `stat`, `read`, `write`, `grep`, `sed`, `mkdir`, `rm`, `chmod`, `mv`). ## Functions | Function | Purpose | |---|---| -| `shell::exec` | Run an allowlisted command in the foreground; returns stdout, stderr, exit code, and timing. Blocks until exit or timeout. | -| `shell::exec_bg` | Spawn an allowlisted command as a background job; returns `{ job_id, argv }` immediately. Host-targeted jobs ignore `timeout_ms` (end via `shell::kill` or natural exit); sandbox jobs honor it. | -| `shell::status` | Fetch one job's full record: state, exit code, and captured stdout/stderr. `not_found` means the id never existed or aged out past `job_retention_secs`. | +| `shell::exec` | Run an allowlisted command in the foreground; returns stdout, stderr, exit code, and timing. Blocks until exit or timeout. Accepts optional host-only `cwd` (jail-confined), `env` (gated by `allowed_env` + a dangerous-key denylist), and `stdin` (string piped to the program's stdin, then EOF) — see [Per-call `cwd`, `env`, and `stdin`](#per-call-cwd-env-and-stdin-host-target). | +| `shell::exec_bg` | Spawn an allowlisted command as a background job; returns `{ job_id, argv }` immediately. Host-targeted jobs run until they exit or `shell::kill` terminates them — unbounded by default, and capped only when the operator sets a positive `max_bg_timeout_ms` (default `0` = unbounded), after which a runaway job is killed and its status becomes `killed`. Sandbox jobs honor `timeout_ms`. Same optional host-only `cwd`/`env`/`stdin` as `shell::exec`. | +| `shell::status` | Fetch one job's full record: state, exit code, and captured stdout/stderr. A missing id — one that never existed or aged out past `job_retention_secs` — returns an `S211` ("no such job") error. | | `shell::list` | Enumerate current jobs as lightweight summaries; argv, stdout, and stderr are redacted. | | `shell::kill` | Terminate a running background job by `job_id`. Sandbox jobs cannot be hard-killed: the record flips to `killed` but the in-VM process runs until its `timeout_ms` (or `sandbox::stop`). | +| `shell::config-status` | Report the last hot-reload outcome: `last_outcome` (`applied`/`rejected`), `last_error`, and `rejected_reloads` (count since boot). A rejected outcome or non-zero count means a stored config was refused and shell is enforcing an older policy than the central store. Takes no arguments. | | `shell::fs::ls` | List a directory's entries with structured metadata. | | `shell::fs::stat` | Read one path's metadata (size, mode, symlink flag). | -| `shell::fs::mkdir` | Create a directory, optionally with missing parents. | -| `shell::fs::rm` | Remove a file or directory, optionally recursive. | -| `shell::fs::chmod` | Change a path's mode, and optionally its uid/gid. | -| `shell::fs::mv` | Rename or move one path within the jail. | +| `shell::fs::mkdir` | Create a directory, optionally with missing parents. Returns `{ created, path, already_existed }`. | +| `shell::fs::rm` | Remove a file or directory, optionally recursive. Returns `{ removed, path, was_present }`. | +| `shell::fs::chmod` | Change a path's mode, and optionally its uid/gid. Returns `{ entries_changed, path, recursive }`. (**Breaking**: field renamed from `updated` to `entries_changed`.) | +| `shell::fs::mv` | Rename or move one path within the jail. Returns `{ moved, src, dst, overwrote }`. | | `shell::fs::grep` | Recursive regex search across a tree; returns structured matches. Keys are singular `include_glob`/`exclude_glob`; the case flag is `ignore_case`. | | `shell::fs::sed` | Regex find-and-replace across one file or many. | -| `shell::fs::write` | Stream bytes into a file through an SDK channel; writes via a temp file and renames atomically. No inline `content` field. | +| `shell::fs::write` | Write a file. Simplest form passes inline string `content` (host target only): `{ path, content: "file text" }`, with `mode` (octal, default `"0644"`) and `parents: true` to create parents. A `ContentRef` object in `content` instead streams large/staged payloads through an SDK channel (temp file + atomic rename) and is **required** for sandbox targets — an inline string on a sandbox target returns `S210`. Batch form: pass `files: [{ path, content, mode?, parents? }, ...]` (host, inline per file) to write several files in one call; the response then carries per-file `files: [{ path, bytes_written }]`. A single-file write leaves `files` empty and returns `{ bytes_written, path }`. Supplying both single `path`/`content` and `files` returns `S210`. | | `shell::fs::read` | Stream a file's bytes out through an SDK channel. For an inline read on the web surface, use the `harness::fs::read_inline` wrapper instead. | Every `shell::fs::*` call accepts the same optional `target` as `exec`, so host and sandbox share one wire shape. +## Hot-reload + +When the `configuration` worker pushes an updated config, the shell worker swaps in the new security policy and fs backend atomically. A few things to know: + +- Each call executes against one consistent runtime snapshot; there is no mid-call config change. +- Already-running background jobs are **not** retroactively re-checked when the policy tightens — they continue under the policy that was active when they were spawned. +- A reload that widens the jail (for example, clearing `host_root`) succeeds but is logged as a privilege change. +- If the incoming config is invalid or unsafe, the worker keeps the last-good runtime and logs an error. The rejection is also surfaced through `shell::config-status` (a `rejected` outcome with a non-zero `rejected_reloads` count), so the divergence between the central store and the policy shell is actually enforcing is detectable instead of silent. Rejections are kept last-good and not retried (re-fetching returns the same bad value), so they will not retry-storm. +- At boot the reconcile against the configuration worker is **fail-closed**: the worker refuses to start (and exposes no functions) if it cannot confirm the authoritative config, so it never serves a possibly stale security policy. + ## Errors Returned error bodies carry a stable `code` field. Allowlist and denylist rejections come back as a plain message (`command '' not in allowlist`, `command matches denylist: `) rather than an S-code. @@ -111,17 +138,32 @@ Returned error bodies carry a stable `code` field. Allowlist and denylist reject | Code | Meaning | |---|---| | `S200` | In-VM execution failure on a sandbox target. | -| `S210` | Invalid request: non-absolute path, empty command or pattern, bad octal mode, malformed payload, or `sandbox.enabled: false` on a sandbox-targeted call. | -| `S211` | Path not found. | +| `S210` | Invalid request: non-absolute path, empty command or pattern, bad octal mode, malformed payload, `sandbox.enabled: false` on a sandbox-targeted call, a `cwd` that is not a directory, an `env` key outside `allowed_env` or in the dangerous-key denylist, `cwd`/`env`/`stdin` supplied on a sandbox target (host-only), an inline string `content` on a sandbox-targeted `shell::fs::write`, or both single `path`/`content` and `files` on `shell::fs::write`. | +| `S211` | Path not found (including a `cwd` that does not exist). | | `S212` | Wrong file type for the operation (for example, a file where a directory was expected). | | `S213` | Path already exists. | | `S214` | Directory not empty (non-recursive `rm`). | -| `S215` | Path escapes `host_root`, hits `fs.denylist_paths`, or permission denied. | +| `S215` | Path (or a per-call `cwd`) escapes `host_root`, hits `fs.denylist_paths`, or permission denied. | | `S216` | Generic shell-internal failure: host spawn error, channel error, or a bad engine response. | | `S217` | Invalid regex passed to `grep`/`sed`. | | `S218` | `fs.max_read_bytes` / `fs.max_write_bytes` cap exceeded. | | `S300` | Sandbox VM boot failed (needs a virtualization host: Apple Silicon or `/dev/kvm`). | +Sandbox-forwarded `fs::*`/`exec` errors can also surface engine codes verbatim instead of collapsing to `S216`: `S001`–`S004` (sandbox lifecycle), `S100`–`S102` (image/VM/resource), `S300`, and `S400`. Branch on the specific code where relevant; only an unrecognized engine code falls back to `S216`. + +## Upgrading to 0.4.0 + +0.4.0 is a breaking release. Migrating from 0.3.x: + +- **`shell::fs::chmod` response field renamed** `updated` → `entries_changed`; read `entries_changed`. Inbound legacy `{ updated }` from an older engine still deserializes (serde alias), but new consumers should read `entries_changed`. +- **`mkdir`/`rm`/`mv` gained structured fields** (`path`/`already_existed`, `path`/`was_present`, `src`/`dst`/`overwrote`) alongside the original boolean. Additive: existing readers of `created`/`removed`/`moved` keep working. +- **Configuration moved to the central `configuration` worker** (database-style) with hot-reload. `--config` is now a first-boot seed only; the live value from the configuration worker wins once an entry exists. `--manifest` was removed (the interface is collected live). +- **New read-only `shell::config-status`** reports the last hot-reload outcome. Operator/automation only; it is denied to agents. +- **Error envelope: the S-code is now the top-level wire `code`.** Every S-coded failure returns `{ "code": "S211", "message": "no such job: ..." }` with the S-code as the top-level `code`. Previously the code was buried — handlers returned the `{code,message}` JSON stringified into `message` under the generic `invocation_failed` code. Consumers that branched on `code == "invocation_failed"` or parsed the embedded JSON out of the message must update to read `error.code` directly. +- **Host background jobs are unbounded by default.** A host-targeted `shell::exec_bg` job runs until it exits or `shell::kill` terminates it — long installs, builds, dev-servers, and tails are not killed. To force-kill a runaway host bg job, set a positive `max_bg_timeout_ms` (new operator config field, default `0` = unbounded; separate from `max_timeout_ms`, which bounds foreground `shell::exec`); a job that exceeds it is killed and its status becomes `killed`. Sandbox jobs still honor `timeout_ms`. +- **Per-call `env` denylist broadened.** The always-rejected key set now includes `HOME` and loader/lookup-path keys (`LD_*`, `DYLD_*`, `GCONV_PATH`, …) **and** interpreter/shell startup-file keys (`BASH_ENV`, `ENV`, `PYTHONSTARTUP`, `PERL5OPT`, `RUBYOPT`, `NODE_OPTIONS`). These are rejected even if listed in `allowed_env`. `LD_*`/`DYLD_*` are matched by family prefix, so a loader variable not in the explicit list is still rejected. Note that `HOME` is in the default `allowed_env` for the worker's own forwarded env but is **not** settable per-call. +- **Sandbox-forwarded fs/exec errors can now surface engine S-codes** that 0.3.7 collapsed to `S216` — e.g. `S001`–`S004` (lifecycle), `S100`–`S102` (image/VM), `S300`, and `S400`. Branch on the specific code where relevant. + ## Troubleshooting - **`fs.host_root is unset ... refusing to start unjailed`**: set `fs.host_root` to a directory, or set `fs.allow_unjailed: true`. diff --git a/shell/config.collect.yaml b/shell/config.collect.yaml new file mode 100644 index 00000000..e6ddad23 --- /dev/null +++ b/shell/config.collect.yaml @@ -0,0 +1,12 @@ +# Interface-collection config for the registry publish workflow. +# +# The publish job (.github/workflows/_publish-registry.yml) boots a throwaway +# copy of this worker purely to read back the functions it registers with the +# engine — the published "interface". The shell worker refuses to start unless +# the fs jail is configured. Interface collection runs no fs operations, so we +# boot UNJAILED (allow_unjailed: true, no host_root) — this needs no pre-created +# directory, so it works on a clean CI runner with just `--config config.collect.yaml`. +max_bg_timeout_ms: 0 # host bg job hard cap in ms; 0 = unbounded (foreground uses max_timeout_ms) +fs: + allow_unjailed: true +allowlist: [] diff --git a/shell/config.yaml b/shell/config.yaml index eecc1d95..22c67660 100644 --- a/shell/config.yaml +++ b/shell/config.yaml @@ -1,4 +1,5 @@ max_timeout_ms: 30000 +max_bg_timeout_ms: 0 # host bg job hard cap in ms; 0 = unbounded (foreground uses max_timeout_ms) default_timeout_ms: 10000 max_output_bytes: 1048576 working_dir: null @@ -82,6 +83,11 @@ fs: denylist_paths: - /etc/passwd - /etc/shadow + # Permit setuid/setgid/sticky bits (the top octal digit, e.g. 4755) in + # mkdir/chmod/write modes. Default false: such modes are a privilege- + # escalation primitive when the worker runs as root, so they are rejected + # with S210. Set true only if your workload genuinely needs them. + # allow_special_bits: false # When enabled is true, callers can target a live sandbox via the # top-level `target` field on shell::exec, shell::exec_bg, and every diff --git a/shell/skills/SKILL.md b/shell/skills/SKILL.md index 6acebc9d..1b52ca76 100644 --- a/shell/skills/SKILL.md +++ b/shell/skills/SKILL.md @@ -63,26 +63,45 @@ agents, pair with the `skills` worker. ## Functions - `shell::exec`: run an allowlisted command in the foreground and return its - stdout, stderr, exit code, and timing; blocks until exit or timeout. + stdout, stderr, exit code, and timing; blocks until exit or timeout. Sandbox + execution is fully valid (`target: { kind: "sandbox", sandbox_id }`); only the + host-only override fields — `stdin` (string piped to the program's stdin, then + EOF), plus `cwd`/`env` — are rejected with `S210` when supplied on a sandbox + target, because the sandbox exec protocol does not forward them. - `shell::exec_bg`: spawn an allowlisted command as a background job and return - a `job_id` immediately. Host-targeted jobs ignore `timeout_ms` (end via - `shell::kill` or natural exit); sandbox jobs honor it. + a `job_id` immediately. Host-targeted jobs run until they exit or `shell::kill` + terminates them — unbounded by default, capped only when the operator sets a + positive `max_bg_timeout_ms` (default `0` = unbounded), after which a runaway + job is killed and its status becomes `killed`. Sandbox jobs honor `timeout_ms`. + Same optional host-only `stdin` as `shell::exec`. - `shell::status`: fetch one job's full record: state, exit code, and captured - stdout/stderr. + stdout/stderr. A missing id (never existed or aged out) returns an `S211` + ("no such job") error. - `shell::list`: enumerate current jobs as lightweight summaries (no argv, stdout, or stderr). - `shell::kill`: terminate a running background job by `job_id`. +- `shell::config-status` *(operator/automation only — not agent-callable)*: + report the last hot-reload outcome — `last_outcome` (`applied`/`rejected`), + `last_error`, and `rejected_reloads` (count since boot). A rejected outcome or + non-zero count means a stored config was refused and shell is enforcing an + older policy than the central store. Takes no arguments. - `shell::fs::ls`: list a directory's entries with structured metadata. - `shell::fs::stat`: read one path's metadata (size, mode, symlink flag). -- `shell::fs::mkdir`: create a directory, optionally with missing parents. -- `shell::fs::rm`: remove a file or directory, optionally recursive. -- `shell::fs::chmod`: change a path's mode, and optionally its uid/gid. -- `shell::fs::mv`: rename or move one path within the jail. +- `shell::fs::mkdir`: create a directory, optionally with missing parents. Returns `{ created: bool, path: string, already_existed: bool }`. +- `shell::fs::rm`: remove a file or directory, optionally recursive. Returns `{ removed: bool, path: string, was_present: bool }`. +- `shell::fs::chmod`: change a path's mode, and optionally its uid/gid. Returns `{ entries_changed: u64, path: string, recursive: bool }`. **Note**: the field was renamed from `updated` to `entries_changed` — callers relying on `updated` must migrate. +- `shell::fs::mv`: rename or move one path within the jail. Returns `{ moved: bool, src: string, dst: string, overwrote: bool }`. - `shell::fs::grep`: recursive regex search across a tree, returning structured matches. - `shell::fs::sed`: regex find-and-replace across one file or many. -- `shell::fs::write`: stream bytes into a file via a channel; writes through a - temp file and renames atomically. +- `shell::fs::write`: write a file. Simplest form is inline string `content` + (host target only): `{ path, content: "file text" }`, with `mode` (octal, + default `"0644"`) and `parents: true`. A `ContentRef` object in `content` + instead streams large/staged payloads via a channel (temp file + atomic + rename) and is **required** for sandbox targets. Batch form: pass + `files: [{ path, content, mode?, parents? }, ...]` to write several files in + one host call; the response then carries per-file `files: [{ path, + bytes_written }]` (a single-file write leaves `files` empty). - `shell::fs::read`: stream a file's bytes out through a channel. Every `shell::fs::*` call accepts the same optional `target` as `exec`, so host diff --git a/shell/src/config.rs b/shell/src/config.rs index ad761870..5db0fefe 100644 --- a/shell/src/config.rs +++ b/shell/src/config.rs @@ -1,14 +1,24 @@ use anyhow::{Context, Result}; use regex::Regex; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use std::fs; use std::path::PathBuf; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ShellConfig { #[serde(default = "default_max_timeout_ms")] pub max_timeout_ms: u64, + /// Hard cap, in milliseconds, on a HOST background job (`shell::exec_bg`). + /// `0` (the default) means UNBOUNDED — a host bg job runs until it exits or + /// `shell::kill` terminates it. This is deliberately separate from + /// `max_timeout_ms` (which bounds foreground `shell::exec`): background jobs + /// are how callers run long work (installs, builds, dev servers), so binding + /// them to the short foreground cap would kill legitimate jobs. Set a + /// positive value to force-kill runaway bg jobs after that long. + #[serde(default = "default_max_bg_timeout_ms")] + pub max_bg_timeout_ms: u64, + #[serde(default = "default_default_timeout_ms")] pub default_timeout_ms: u64, @@ -27,6 +37,11 @@ pub struct ShellConfig { #[serde(default)] pub allowlist: Vec, + /// ADVISORY ONLY. Regular expressions matched against the whole command + /// line (`argv.join(" ")`). A match rejects the exec, but this is a + /// best-effort guardrail, NOT the security boundary — the sandbox backend + /// is. Do not rely on it to contain untrusted input: regexes over a joined + /// argv are trivially evadable (quoting, env indirection, alternate paths). #[serde(default)] pub denylist_patterns: Vec, @@ -43,12 +58,19 @@ pub struct ShellConfig { pub sandbox: SandboxConfig, #[serde(default, skip)] + #[schemars(skip)] pub compiled_denylist: Vec, } fn default_max_timeout_ms() -> u64 { 30_000 } +fn default_max_bg_timeout_ms() -> u64 { + // 0 == unbounded: a host bg job is not force-killed by time, only by + // shell::kill or natural exit. Operators set a positive cap to bound + // runaway jobs. + 0 +} fn default_default_timeout_ms() -> u64 { 10_000 } @@ -68,7 +90,7 @@ fn default_job_retention_secs() -> u64 { 3600 } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct FsConfig { #[serde(default)] pub host_root: Option, @@ -86,9 +108,16 @@ pub struct FsConfig { pub max_write_bytes: usize, #[serde(default)] pub denylist_paths: Vec, + /// Permit setuid/setgid/sticky bits (the top octal digit, `mode & 0o7000`) + /// in mkdir/chmod/write modes. Default false: a chmod to e.g. `4755` + /// (setuid) is a privilege-escalation primitive when the worker runs as + /// root, so the top bits are rejected with S210 unless an operator + /// explicitly opts in here. + #[serde(default)] + pub allow_special_bits: bool, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct SandboxConfig { #[serde(default = "default_sandbox_enabled")] pub enabled: bool, @@ -112,6 +141,7 @@ impl Default for FsConfig { max_read_bytes: default_max_read_bytes(), max_write_bytes: default_max_write_bytes(), denylist_paths: Vec::new(), + allow_special_bits: false, } } } @@ -128,6 +158,7 @@ impl Default for ShellConfig { fn default() -> Self { Self { max_timeout_ms: default_max_timeout_ms(), + max_bg_timeout_ms: default_max_bg_timeout_ms(), default_timeout_ms: default_default_timeout_ms(), max_output_bytes: default_max_output_bytes(), working_dir: None, @@ -144,15 +175,6 @@ impl Default for ShellConfig { } } -pub fn load_config(path: &str) -> Result { - let content = fs::read_to_string(path).with_context(|| format!("read {}", path))?; - let mut cfg: ShellConfig = - serde_yaml::from_str(&content).with_context(|| format!("parse {}", path))?; - cfg.compile_denylist()?; - cfg.validate_fs_jail()?; - Ok(cfg) -} - impl ShellConfig { pub fn compile_denylist(&mut self) -> Result<()> { self.compiled_denylist = self @@ -190,7 +212,15 @@ impl ShellConfig { .and_then(|s| s.to_str()) .unwrap_or(&cmd); if !self.allowlist.iter().any(|a| a == base || a == &cmd) { - return Err(format!("command '{}' not in allowlist", base)); + // Append the permitted commands so an agent can self-correct + // (mirrors COMMAND_ARRAY_HINT in functions/types.rs). The + // allowlist is the policy the caller must comply with, not a + // secret, so list it in full. + return Err(format!( + "command '{}' not in allowlist; allowed: [{}]", + base, + self.allowlist.join(", ") + )); } } @@ -200,6 +230,54 @@ impl ShellConfig { return Err(format!("command matches denylist: {}", re.as_str())); } } + + // Confinement guard: a command given as a PATH (contains a '/') that + // canonicalizes to a location INSIDE the writable fs jail is rejected. + // `shell::fs::write` can plant an executable (0755) under `fs.host_root`, + // and the basename allowlist check above matches by file_name — so + // `command: "/ls"` would otherwise pass the allowlist and be + // executed verbatim, a host RCE that bypasses the read-only allowlist. + // Bare program names (no '/') are PATH-resolved by the OS and stay + // allowed; legitimate absolute paths OUTSIDE the jail (e.g. /usr/bin/ls) + // are not writable via shell::fs::write and stay allowed. A path that + // fails to canonicalize (does not exist) is NOT rejected here — the + // normal exec spawn surfaces its own not-found error. + if cmd.contains('/') { + // Unjailed mode (host_root: null) has NO writable boundary — the + // whole host filesystem is reachable via shell::fs::write, so an + // agent can plant `/tmp/ls` and run `command: "/tmp/ls"` (basename + // `ls` is allowlisted), bypassing the read-only allowlist entirely. + // There is no path that distinguishes "agent-planted" from "system + // binary" here, so reject ALL command paths and require a bare, + // PATH-resolved name. (In jailed mode the check below is precise: + // only paths inside host_root are rejected.) + if self.fs.host_root.is_none() { + return Err(format!( + "command path '{}' is not allowed when fs is unjailed \ + (fs.host_root is null): any host path is writable via \ + shell::fs::write, so a command path could execute \ + agent-planted bytes and bypass the allowlist. Use a bare \ + command name (PATH-resolved).", + cmd + )); + } + if let Some(host_root) = &self.fs.host_root { + if let Ok(canon_cmd) = std::fs::canonicalize(&cmd) { + if let Ok(canon_root) = std::fs::canonicalize(host_root) { + if canon_cmd.starts_with(&canon_root) { + return Err(format!( + "command path '{}' resolves inside the writable fs jail ({}); \ + executing files written via shell::fs::write is not allowed. \ + Use a bare command name (PATH-resolved) or a path outside the jail.", + cmd, + host_root.display() + )); + } + } + } + } + } + Ok(()) } @@ -207,6 +285,35 @@ impl ShellConfig { let t = requested.unwrap_or(self.default_timeout_ms); t.min(self.max_timeout_ms) } + + /// Parse a YAML seed (no denylist compile, no jail validation — those run + /// in `configuration::build_runtime`). + pub fn from_yaml(yaml: &str) -> Result { + serde_yaml::from_str(yaml).map_err(|e| format!("yaml parse: {e}")) + } + + /// Load a YAML seed file. Used only for the optional `--config` seed. + pub fn from_file(path: &str) -> Result { + let raw = std::fs::read_to_string(path).map_err(|e| format!("read {path}: {e}"))?; + Self::from_yaml(&raw) + } + + /// Deserialize the live value fetched from the configuration worker. + pub fn from_json(value: &serde_json::Value) -> Result { + serde_json::from_value(value.clone()).map_err(|e| format!("json parse: {e}")) + } + + /// Serialize for `initial_value` when registering with the configuration worker. + pub fn to_json(&self) -> serde_json::Value { + serde_json::to_value(self).expect("ShellConfig serializes") + } + + /// JSON Schema registered with the configuration worker so operators get + /// a typed editing surface. + pub fn json_schema() -> serde_json::Value { + let root = schemars::gen::SchemaGenerator::default().into_root_schema_for::(); + serde_json::to_value(root).expect("ShellConfig JSON Schema serializes") + } } #[cfg(test)] @@ -247,6 +354,19 @@ mod tests { assert!(err.contains("not in allowlist")); } + #[test] + fn test_allowlist_rejection_lists_allowed_commands() { + // The rejection must name the permitted commands so an agent can + // self-correct without trial-and-error against the policy. + let c = cfg_with(vec!["ls", "cat", "grep"], vec![]); + let err = c + .is_command_allowed(&["nmap".into()]) + .expect_err("must reject"); + assert!(err.contains("ls"), "got: {err}"); + assert!(err.contains("cat"), "got: {err}"); + assert!(err.contains("grep"), "got: {err}"); + } + #[test] fn test_allowlist_empty_means_open() { let c = cfg_with(vec![], vec![]); @@ -255,10 +375,19 @@ mod tests { #[test] fn test_allowlist_basename_match() { - let c = cfg_with(vec!["ls"], vec![]); + // Basename matching for an absolute command path is only meaningful in + // JAILED mode: an out-of-jail path (not writable via shell::fs::write) + // is permitted by basename. Unjailed mode rejects all paths outright + // (see exec_command_path_rejected_when_unjailed), so set a host_root + // that does NOT contain /usr/bin/ls to exercise the basename contract. + let mut c = cfg_with(vec!["ls"], vec![]); + c.fs.host_root = + Some(std::env::temp_dir().join(format!("shell-basename-{}", uuid::Uuid::new_v4()))); + std::fs::create_dir_all(c.fs.host_root.as_ref().unwrap()).unwrap(); assert!(c .is_command_allowed(&["/usr/bin/ls".into(), "-la".into()]) .is_ok()); + std::fs::remove_dir_all(c.fs.host_root.as_ref().unwrap()).ok(); } #[test] @@ -276,22 +405,39 @@ mod tests { assert!(c.is_command_allowed(&[]).is_err()); } + #[test] + fn test_allowlisted_command_still_blocked_by_denylist() { + // Both lists non-empty: `tar` is allowlisted (passes argv[0] check) + // but the argv string matches a denylist pattern, so it is rejected. + // The denylist is the second gate and must apply even to allowlisted + // commands. + let c = cfg_with(vec!["tar", "ls"], vec![r"--checkpoint-action"]); + // Sanity: a benign allowlisted invocation passes. + assert!(c + .is_command_allowed(&["tar".into(), "-czf".into(), "out.tgz".into()]) + .is_ok()); + // The dangerous allowlisted invocation is blocked by the denylist. + let err = c + .is_command_allowed(&[ + "tar".into(), + "--checkpoint-action=exec=sh".into(), + "x".into(), + ]) + .expect_err("denylist must override allowlist"); + assert!(err.contains("denylist"), "got: {err}"); + } + /// Loads the shipped `config.yaml` and asserts the default allowlist /// preserves read-only env inspection (`printenv`) while rejecting the /// `env ` exec-escape. `env` was removed from the default allowlist /// because `is_command_allowed` only checks argv[0]; with `env` /// allowlisted, `env nmap target` would have argv[0]=="env" and pass. - /// Loads the shipped `config.yaml` and asserts the default allowlist - /// preserves read-only env inspection (`printenv`) while rejecting the - /// `env ` exec-escape. `env` was removed from the default allowlist - /// because `is_command_allowed` only checks argv[0]; with `env` - /// allowlisted, `env nmap target` would have argv[0]=="env" and pass. - /// Parses the YAML directly (skipping `load_config`'s fs-jail check, + /// Parses the YAML directly (skipping the fs-jail check, /// which is unrelated to the allowlist policy under test). #[test] fn shipped_config_blocks_env_exec_escape() { let path = concat!(env!("CARGO_MANIFEST_DIR"), "/config.yaml"); - let content = fs::read_to_string(path).expect("read config.yaml"); + let content = std::fs::read_to_string(path).expect("read config.yaml"); let mut c: ShellConfig = serde_yaml::from_str(&content).expect("config.yaml parses"); c.compile_denylist().expect("denylist compiles"); assert!(c.is_command_allowed(&["printenv".into()]).is_ok()); @@ -301,6 +447,84 @@ mod tests { assert!(err.contains("not in allowlist")); } + #[test] + fn exec_command_path_inside_jail_is_rejected() { + // An agent can plant `/ls` (0755) via shell::fs::write; the + // basename allowlist matches "ls", so without the confinement guard + // `command: "/ls"` would execute that jail-planted file — + // host RCE. The guard must reject a command path that canonicalizes + // inside the jail while still permitting bare PATH-resolved names and + // out-of-jail absolute paths. + let root = std::env::temp_dir().join(format!("shell-cfg-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("ls"), "#!/bin/sh\necho pwned\n").unwrap(); + + let mut c = ShellConfig { + allowlist: vec!["ls".into()], + ..Default::default() + }; + c.fs.host_root = Some(root.clone()); + c.compile_denylist().unwrap(); + + // The jail-planted path is rejected with a jail-mentioning error. + let jailed_cmd = root.join("ls").to_string_lossy().to_string(); + let err = c + .is_command_allowed(&[jailed_cmd]) + .expect_err("jail-planted command must be rejected"); + assert!(err.contains("jail"), "got: {err}"); + + // A bare command name stays allowed (PATH-resolved by the OS). + assert!(c.is_command_allowed(&["ls".into()]).is_ok()); + + // An out-of-jail absolute path whose basename is allowlisted stays + // allowed — it is not writable via shell::fs::write. + for candidate in ["/bin/cat", "/usr/bin/true"] { + if std::path::Path::new(candidate).exists() { + let mut c2 = ShellConfig { + allowlist: vec![std::path::Path::new(candidate) + .file_name() + .unwrap() + .to_string_lossy() + .to_string()], + ..Default::default() + }; + c2.fs.host_root = Some(root.clone()); + c2.compile_denylist().unwrap(); + assert!( + c2.is_command_allowed(&[candidate.to_string()]).is_ok(), + "out-of-jail absolute path {candidate} must be allowed" + ); + } + } + + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn exec_command_path_rejected_when_unjailed() { + // Unjailed mode (host_root: null) has no writable boundary: the whole + // host FS is reachable via shell::fs::write, so ANY command path could + // execute agent-planted bytes and bypass the allowlist. Reject every + // path; only bare PATH-resolved names are permitted. + let mut c = ShellConfig { + allowlist: vec!["ls".into()], + ..Default::default() + }; + c.fs.host_root = None; + c.fs.allow_unjailed = true; + c.compile_denylist().unwrap(); + + // Even a real system binary is rejected — there is no way to tell it + // apart from an agent-planted file when nothing confines writes. + let err = c + .is_command_allowed(&["/bin/ls".into()]) + .expect_err("command path must be rejected when unjailed"); + assert!(err.contains("unjailed"), "got: {err}"); + + // The bare name still resolves via PATH and is allowed. + assert!(c.is_command_allowed(&["ls".into()]).is_ok()); + } + #[test] fn test_resolve_timeout_caps_at_max() { let c = ShellConfig::default(); @@ -371,4 +595,34 @@ sandbox: c.fs.host_root = Some(std::path::PathBuf::from("/tmp/something")); c.validate_fs_jail().expect("pinned host_root is valid"); } + + #[test] + fn json_schema_has_top_level_keys() { + let schema = ShellConfig::json_schema(); + let obj = schema.as_object().expect("schema is an object"); + assert!(obj.contains_key("properties")); + let props = obj["properties"].as_object().expect("properties object"); + assert!(props.contains_key("allowlist")); + assert!(props.contains_key("fs")); + // compiled_denylist must NOT leak into the published schema. + assert!(!props.contains_key("compiled_denylist")); + } + + #[test] + fn to_json_from_json_round_trips() { + let mut c = ShellConfig::default(); + c.fs.host_root = Some(std::path::PathBuf::from("/tmp/shell")); + c.allowlist = vec!["ls".into(), "cat".into()]; + let v = c.to_json(); + let back = ShellConfig::from_json(&v).expect("from_json round-trips"); + assert_eq!(back.allowlist, c.allowlist); + assert_eq!(back.fs.host_root, c.fs.host_root); + } + + #[test] + fn from_yaml_parses_seed() { + let c = ShellConfig::from_yaml("allowlist: [ls]\nfs:\n host_root: /tmp/x\n") + .expect("seed yaml parses"); + assert_eq!(c.allowlist, vec!["ls".to_string()]); + } } diff --git a/shell/src/configuration.rs b/shell/src/configuration.rs new file mode 100644 index 00000000..2e402999 --- /dev/null +++ b/shell/src/configuration.rs @@ -0,0 +1,705 @@ +//! Integration with the `configuration` worker — register the shell security +//! policy schema, fetch the live value, and hot-reload the runtime (config + +//! host fs backend) when it changes. Mirrors `database/src/configuration.rs`. + +use std::sync::Arc; +use std::time::Duration; + +use iii_sdk::{IIIError, RegisterFunction, RegisterTriggerInput, TriggerRequest, III}; +use serde_json::{json, Value}; +use tokio::sync::{Mutex, RwLock}; + +use crate::config::ShellConfig; +use crate::fs::host::{HostFsBackend, HostFsConfig, IiiChannelMaker}; +use crate::fs::FsBackend; + +pub const CONFIG_ID: &str = "shell"; +const CONFIG_FN_ID: &str = "shell::on-config-change"; +const CONFIG_TIMEOUT_MS: u64 = 5_000; +const CONFIG_RETRIES: u32 = 3; +/// Base backoff between configuration RPC retries; multiplied by the attempt +/// number for a linear backoff (250ms, 500ms, …). +const CONFIG_RETRY_BACKOFF_MS: u64 = 250; + +/// Live runtime swapped on hot-reload. `config` and `host_backend` are always +/// built from the same `ShellConfig`, so a reader never mixes new config with +/// an old backend. +pub struct ShellRuntime { + pub config: Arc, + pub host_backend: Arc, +} + +#[derive(Clone)] +pub struct AppState { + pub runtime: Arc>, + pub iii: III, + /// Serializes hot-reloads: held across the authoritative fetch + build + swap + /// so an older event's slow build can never clobber a newer applied config. + pub reload_lock: Arc>, + /// Last hot-reload outcome, exposed via `shell::config-status` so operators + /// can detect when a stored config was rejected and the active policy diverged. + pub reload_status: Arc>, +} + +/// Outcome of the most recent hot-reload attempt, exposed via `shell::config-status`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, schemars::JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ReloadOutcome { + /// The live runtime reflects the most recent configuration the worker loaded. + Applied, + /// The most recent configuration:updated delivered a value the worker could + /// NOT build; the previous valid policy is still active and the central store + /// has DIVERGED from what shell is enforcing. + Rejected, +} + +/// Operator-visible hot-reload status. `rejected_reloads > 0` (or +/// `last_outcome == Rejected`) means a stored config was refused and shell is +/// enforcing an older policy than the central store — actionable divergence. +#[derive(Clone, Debug, serde::Serialize, schemars::JsonSchema)] +pub struct ReloadStatus { + pub last_outcome: ReloadOutcome, + /// Build error from the most recent rejected reload (why it was refused). + pub last_error: Option, + /// Cumulative count of rejected reloads since boot (never reset). + pub rejected_reloads: u64, +} + +impl Default for ReloadStatus { + fn default() -> Self { + // The initial runtime is built from a validated config at boot. + Self { + last_outcome: ReloadOutcome::Applied, + last_error: None, + rejected_reloads: 0, + } + } +} + +impl ReloadStatus { + fn record_applied(&mut self) { + self.last_outcome = ReloadOutcome::Applied; + self.last_error = None; + } + fn record_rejected(&mut self, err: String) { + self.last_outcome = ReloadOutcome::Rejected; + self.last_error = Some(err); + self.rejected_reloads = self.rejected_reloads.saturating_add(1); + } +} + +/// Validate + compile a config into the shared, immutable form handlers read. +/// Pure (no I/O), so it is unit-testable without a live engine. +pub fn prepare_config(cfg: &ShellConfig) -> Result, String> { + let mut prepared = cfg.clone(); + prepared + .compile_denylist() + .map_err(|e| format!("denylist compile: {e}"))?; + prepared + .validate_fs_jail() + .map_err(|e| format!("fs jail: {e}"))?; + Ok(Arc::new(prepared)) +} + +/// Build the live runtime: validate the config, then build the host fs backend. +pub fn build_runtime(cfg: &ShellConfig, iii: &III) -> Result { + let config = prepare_config(cfg)?; + if config.fs.host_root.is_none() { + tracing::warn!( + "fs.host_root is unset — host backend is unjailed; every absolute path \ + is reachable by shell::fs::* (denylist still applies)" + ); + } + let host_fs_cfg = Arc::new(HostFsConfig { + host_root: config.fs.host_root.clone(), + max_read_bytes: config.fs.max_read_bytes, + max_write_bytes: config.fs.max_write_bytes, + denylist_paths: config.fs.denylist_paths.clone(), + allow_special_bits: config.fs.allow_special_bits, + }); + let chan_maker = Arc::new(IiiChannelMaker::new(iii.clone())); + let host_backend: Arc = Arc::new( + HostFsBackend::try_new(host_fs_cfg, chan_maker) + .map_err(|e| format!("host backend init: {} ({})", e.message, e.code))?, + ); + Ok(ShellRuntime { + config, + host_backend, + }) +} + +/// Register the `shell` configuration schema with the configuration worker. +pub async fn register_config(iii: &III, seed: Option<&ShellConfig>) -> Result<(), String> { + let mut payload = json!({ + "id": CONFIG_ID, + "name": "Shell", + "description": "Command allowlist/denylist, timeout & output caps, and the fs jail.", + "schema": ShellConfig::json_schema(), + }); + // Only an explicit --config seed is written as initial_value. We never seed + // the built-in ShellConfig::default(): it is intentionally invalid (unjailed; + // rejected by prepare_config), so writing it would leave the stored `shell` + // config stuck in a self-invalid state. With no seed and no stored value the + // worker fails closed at build_runtime with a clear fs-jail error instead. + if let Some(seed) = seed { + // Validate the seed with the SAME checks build_runtime uses (denylist + // regex compile, fs-jail rule, host_root/denylist reachability) BEFORE + // persisting it. Persisting an invalid seed would store a value the + // worker then rejects; because configuration::register preserves the + // value after first registration, a one-line typo would become a + // persistent outage. If the seed is invalid, register the schema only + // and let the worker fail closed with a clear error. + match build_runtime(seed, iii) { + Ok(_) => payload["initial_value"] = seed.to_json(), + Err(e) => tracing::error!( + error = %e, + "ignoring invalid --config seed; not registering it as initial_value" + ), + } + } + trigger_with_retry(iii, "configuration::register", payload).await?; + Ok(()) +} + +/// Read the live `shell` configuration (env-expanded by the configuration worker). +pub async fn fetch_config(iii: &III) -> Result { + let value = get_config_value(iii).await?; + if value.is_null() { + tracing::warn!( + "configuration value is null; falling back to the built-in default, which is \ + intentionally invalid (unjailed) and is rejected by build_runtime — boot \ + fails closed, hot-reload keeps last-good" + ); + return Ok(ShellConfig::default()); + } + ShellConfig::from_json(&value) +} + +async fn get_config_value(iii: &III) -> Result { + try_get_config_value(iii) + .await? + .ok_or_else(|| format!("configuration `{CONFIG_ID}` not found")) +} + +async fn try_get_config_value(iii: &III) -> Result, String> { + match trigger_with_retry(iii, "configuration::get", json!({ "id": CONFIG_ID })).await { + Ok(resp) => Ok(resp.get("value").cloned()), + // `trigger_with_retry` flattens the structured `IIIError` to its + // Display string, so we substring-match the recovered message rather + // than branch on `IIIError::Remote { code }`. The engine's missing-entry + // codes vary in case (`function_not_found`, `STATEMENT_NOT_FOUND`, + // `NOT_FOUND`), so uppercase before matching to catch them all. A + // false negative is non-fatal — it just propagates the raw retry error + // instead of the cleaner "not found", and boot fails closed either way. + Err(e) if e.to_ascii_uppercase().contains("NOT_FOUND") => Ok(None), + Err(e) => Err(e), + } +} + +/// Build a new runtime and hot-swap it. On any failure the previous runtime is +/// left untouched (last-good). A jail-widening reload succeeds but is logged. +/// +/// MUST only be called from `reload_serialized` (i.e. while holding +/// `reload_lock`): the build happens before the swap, so an unserialized caller +/// could let a slow older build clobber a newer applied config (the TOCTOU this +/// module guards against). Kept private so the reload lock is the only entry point. +async fn apply_config(state: &AppState, cfg: ShellConfig) -> Result<(), String> { + let new_runtime = build_runtime(&cfg, &state.iii)?; + let mut guard = state.runtime.write().await; + let was_jailed = guard.config.fs.host_root.is_some(); + let now_jailed = new_runtime.config.fs.host_root.is_some(); + if was_jailed && !now_jailed { + tracing::warn!( + "configuration change WIDENED the fs jail (host_root cleared); the entire \ + host filesystem is now reachable through shell::fs::* (denylist still applies)" + ); + } + *guard = new_runtime; + Ok(()) +} + +/// Register the internal config-change handler and bind a `configuration` trigger. +pub fn register_config_trigger(iii: &III, state: AppState) -> Result<(), IIIError> { + let st = state.clone(); + iii.register_function( + CONFIG_FN_ID, + RegisterFunction::new_async(move |_payload: Value| { + let st = st.clone(); + async move { + on_config_change(&st).await.map_err(IIIError::from)?; + Ok::(json!({ "ok": true })) + } + }) + .description("Internal: reload the security policy + fs backend on configuration change."), + ); + + iii.register_trigger(RegisterTriggerInput { + trigger_type: "configuration".to_string(), + function_id: CONFIG_FN_ID.to_string(), + config: json!({ + "configuration_id": CONFIG_ID, + "event_types": ["configuration:updated"], + }), + metadata: None, + })?; + Ok(()) +} + +/// Run a config reload under the serialized reload lock. The `fetch` future is +/// awaited INSIDE the lock, so overlapping `configuration:updated` events are +/// applied one at a time and each observes the latest authoritative value — a +/// slow build from an older event can never overwrite a newer applied config. +async fn reload_serialized(state: &AppState, fetch: F) -> Result +where + F: FnOnce() -> Fut, + Fut: std::future::Future>, +{ + let _reload = state.reload_lock.lock().await; + let cfg = match fetch().await { + Ok(cfg) => cfg, + Err(e) => { + // Transient fetch failure (e.g. configuration::get timed out): the + // authoritative value is unknown. Keep the previous runtime AND + // surface the error so the dispatcher can retry — never ack success. + tracing::error!( + error = %e, + "failed to fetch configuration on change; keeping previous runtime, signaling retry" + ); + return Err(e); + } + }; + match apply_config(state, cfg).await { + Ok(()) => { + tracing::info!("shell runtime reloaded after configuration change"); + // Record AFTER apply_config returned so we never hold the runtime + // and reload_status locks at the same time. + state.reload_status.write().await.record_applied(); + Ok(ReloadOutcome::Applied) + } + Err(e) => { + // Config was fetched but is unbuildable (bad denylist regex, unreachable + // host_root, …). Re-fetching returns the SAME rejected value, so ack + + // keep last-good to avoid a retry storm; the loud error log is the signal. + // Record the rejection so `shell::config-status` makes the divergence + // (active policy older than the central store) operator-visible. + tracing::error!( + error = %e, + "rejected configuration change; keeping previous runtime (not retrying — config invalid)" + ); + state.reload_status.write().await.record_rejected(e); + // Steady-state hot-reload tolerates this (last-good); boot reconcile + // turns it into a hard failure — see `reconcile_with`. + Ok(ReloadOutcome::Rejected) + } + } +} + +async fn on_config_change(state: &AppState) -> Result<(), String> { + // SECURITY: do not trust the event payload. `shell::on-config-change` is a + // registered (callable) function, so a direct caller could forge `new_value` + // to hot-swap the security policy (allowlist, fs jail, sandbox toggle), + // bypassing configuration::set authorization. Always re-read the authoritative + // value from the configuration worker and apply that — a spurious direct call + // can at most trigger a reload of the already-stored config. + // Reloads are serialized and re-fetch inside the lock, so concurrent + // configuration:updated events converge to the latest authoritative config + // and a slow build from an older event cannot roll back a newer policy. + // Steady-state: an invalid config keeps last-good (Rejected is not an error + // here), only a transient fetch failure propagates Err for the dispatcher. + reload_serialized(state, || fetch_config(&state.iii)) + .await + .map(|_| ()) +} + +/// Re-fetch the authoritative config and apply it, serialized with any +/// trigger-driven reload. Called once at boot right after the trigger is +/// registered to close the window between the initial fetch and trigger +/// registration: an update that landed in that window had no listener, and this +/// picks it up. FAIL-CLOSED: unlike a steady-state hot-reload, a rejected +/// (unbuildable) authoritative config aborts startup rather than serving the +/// stale last-good runtime, and a transient fetch failure also aborts. +pub async fn reconcile(state: &AppState) -> Result<(), String> { + reconcile_with(state, || fetch_config(&state.iii)).await +} + +/// Boot reconcile core, split from `fetch_config` so the fail-closed mapping is +/// unit-testable without a live engine. +async fn reconcile_with(state: &AppState, fetch: F) -> Result<(), String> +where + F: FnOnce() -> Fut, + Fut: std::future::Future>, +{ + match reload_serialized(state, fetch).await? { + ReloadOutcome::Applied => Ok(()), + ReloadOutcome::Rejected => Err( + "stored `shell` configuration is invalid and could not be built; refusing to \ + start under a stale policy (fix the central config, then restart)" + .to_string(), + ), + } +} + +async fn trigger_with_retry(iii: &III, function_id: &str, payload: Value) -> Result { + let mut last_err = String::new(); + for attempt in 1..=CONFIG_RETRIES { + match iii + .trigger(TriggerRequest { + function_id: function_id.to_string(), + payload: payload.clone(), + action: None, + timeout_ms: Some(CONFIG_TIMEOUT_MS), + }) + .await + { + Ok(v) => return Ok(v), + Err(e) => { + last_err = e.to_string(); + if attempt < CONFIG_RETRIES { + tracing::warn!(function_id, attempt, error = %last_err, "configuration RPC failed; retrying"); + tokio::time::sleep(Duration::from_millis( + CONFIG_RETRY_BACKOFF_MS * u64::from(attempt), + )) + .await; + } + } + } + } + Err(format!( + "{function_id} failed after {CONFIG_RETRIES} attempts: {last_err}" + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_runtime_errors_on_unreachable_host_root() { + // register_worker returns immediately; connect() fires a background thread + // that silently retries but never panics or blocks this thread. + let iii = iii_sdk::register_worker("ws://127.0.0.1:59599", iii_sdk::InitOptions::default()); + let mut cfg = ShellConfig::default(); + cfg.fs.host_root = Some(std::path::PathBuf::from("/nonexistent/shell-jail-xyz")); + cfg.fs.allow_unjailed = false; + let res = build_runtime(&cfg, &iii); + assert!(res.is_err(), "build_runtime must return Err, not panic"); + } + + #[test] + fn prepare_config_rejects_unjailed_default() { + let err = prepare_config(&ShellConfig::default()).expect_err("default is unsafe"); + assert!(err.contains("fs jail")); + } + + #[test] + fn prepare_config_accepts_pinned_host_root() { + let mut c = ShellConfig::default(); + c.fs.host_root = Some(std::path::PathBuf::from("/tmp/shell")); + prepare_config(&c).expect("pinned host_root is valid"); + } + + #[test] + fn prepare_config_rejects_bad_denylist() { + let mut c = ShellConfig::default(); + c.fs.allow_unjailed = true; + c.denylist_patterns = vec!["(".into()]; // invalid regex + let err = prepare_config(&c).expect_err("bad regex rejected"); + assert!(err.contains("denylist")); + } + + #[test] + fn build_runtime_accepts_shipped_collect_config() { + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/config.collect.yaml"); + let cfg = ShellConfig::from_file(path).expect("config.collect.yaml parses"); + let iii = iii_sdk::register_worker("ws://127.0.0.1:59598", iii_sdk::InitOptions::default()); + build_runtime(&cfg, &iii) + .expect("config.collect.yaml must boot for CI interface collection"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn concurrent_reloads_converge_to_newest_config() { + // Guards the reload-path TOCTOU: an OLDER event with a slow fetch/build + // must NOT clobber a NEWER applied config. Two existing jail dirs so + // build_runtime succeeds for both; we assert the newest one wins. + // + // Scope: this exercises the reload_lock serialization mechanics (the + // newer reload blocks until the older one finishes, then applies last) + // with injected configs. It does NOT exercise the live configuration::get + // contract — that the in-lock re-fetch returns the latest stored value is + // a property of the configuration worker, not of this serialization. + let dir_old = std::env::temp_dir().join("shell-reload-old-9b3c"); + let dir_new = std::env::temp_dir().join("shell-reload-new-9b3c"); + std::fs::create_dir_all(&dir_old).unwrap(); + std::fs::create_dir_all(&dir_new).unwrap(); + + let iii = iii_sdk::register_worker("ws://127.0.0.1:59597", iii_sdk::InitOptions::default()); + let mut base = ShellConfig::default(); + base.fs.host_root = Some(dir_old.clone()); + let initial = build_runtime(&base, &iii).expect("initial runtime"); + let state = AppState { + runtime: Arc::new(RwLock::new(initial)), + iii: iii.clone(), + reload_lock: Arc::new(Mutex::new(())), + reload_status: Arc::new(RwLock::new(ReloadStatus::default())), + }; + + let mut cfg_old = ShellConfig::default(); + cfg_old.fs.host_root = Some(dir_old.clone()); + let mut cfg_new = ShellConfig::default(); + cfg_new.fs.host_root = Some(dir_new.clone()); + + // OLDER reload: acquires the reload lock first, then stalls inside the + // (lock-held) fetch — simulating a slow build for an older event. + let s1 = state.clone(); + let old = cfg_old.clone(); + let h1 = tokio::spawn(async move { + let _ = reload_serialized(&s1, || async move { + tokio::time::sleep(std::time::Duration::from_millis(150)).await; + Ok::<_, String>(old) + }) + .await; + }); + + // Give h1 time to take the reload lock before the newer reload starts. + tokio::time::sleep(std::time::Duration::from_millis(40)).await; + + // NEWER reload: dispatched later, fetches immediately. With the fix it + // blocks on the lock until the older reload finishes, then applies last. + let s2 = state.clone(); + let new = cfg_new.clone(); + let h2 = tokio::spawn(async move { + let _ = reload_serialized(&s2, || async move { Ok::<_, String>(new) }).await; + }); + + h1.await.unwrap(); + h2.await.unwrap(); + + let final_root = state.runtime.read().await.config.fs.host_root.clone(); + assert_eq!( + final_root, + Some(dir_new), + "newest config must win; a stale older build must not clobber it" + ); + } + + #[tokio::test] + async fn reload_propagates_transient_fetch_failure() { + // A transient fetch failure (configuration::get timing out) must NOT be + // acked: reload_serialized returns Err and the runtime is unchanged. + let dir = std::env::temp_dir().join("shell-reload-fetchfail-9b3c"); + std::fs::create_dir_all(&dir).unwrap(); + let iii = iii_sdk::register_worker("ws://127.0.0.1:59596", iii_sdk::InitOptions::default()); + let mut base = ShellConfig::default(); + base.fs.host_root = Some(dir.clone()); + let state = AppState { + runtime: Arc::new(RwLock::new(build_runtime(&base, &iii).expect("initial"))), + iii: iii.clone(), + reload_lock: Arc::new(Mutex::new(())), + reload_status: Arc::new(RwLock::new(ReloadStatus::default())), + }; + let res = reload_serialized(&state, || async { + Err::("get timed out".into()) + }) + .await; + assert!( + res.is_err(), + "transient fetch failure must surface as Err, not ack success" + ); + assert_eq!( + state.runtime.read().await.config.fs.host_root, + Some(dir), + "runtime unchanged on fetch failure" + ); + // A transient fetch failure is NOT a config rejection: status untouched. + { + let s = state.reload_status.read().await; + assert_eq!(s.last_outcome, ReloadOutcome::Applied); + assert_eq!(s.rejected_reloads, 0); + } + } + + #[tokio::test] + async fn reload_acks_and_keeps_last_good_on_invalid_config() { + // An invalid fetched config (unjailed default, rejected by prepare_config) + // must keep last-good AND return Ok — returning Err would retry-storm on a + // value re-fetching cannot fix. + let dir = std::env::temp_dir().join("shell-reload-invalid-9b3c"); + std::fs::create_dir_all(&dir).unwrap(); + let iii = iii_sdk::register_worker("ws://127.0.0.1:59595", iii_sdk::InitOptions::default()); + let mut good = ShellConfig::default(); + good.fs.host_root = Some(dir.clone()); + let state = AppState { + runtime: Arc::new(RwLock::new(build_runtime(&good, &iii).expect("initial"))), + iii: iii.clone(), + reload_lock: Arc::new(Mutex::new(())), + reload_status: Arc::new(RwLock::new(ReloadStatus::default())), + }; + // ShellConfig::default() is unjailed (host_root None, allow_unjailed false) → rejected by prepare_config. + let res = + reload_serialized(&state, || async { Ok::<_, String>(ShellConfig::default()) }).await; + assert!( + matches!(res, Ok(ReloadOutcome::Rejected)), + "invalid config must be acked as Rejected (no retry storm), not Err" + ); + assert_eq!( + state.runtime.read().await.config.fs.host_root, + Some(dir), + "runtime keeps last-good on invalid config" + ); + } + + #[tokio::test] + async fn reload_applies_newly_fetched_config() { + // The reconcile primitive: a valid newly-fetched config is applied and Ok'd. + let dir_a = std::env::temp_dir().join("shell-reload-applya-9b3c"); + let dir_b = std::env::temp_dir().join("shell-reload-applyb-9b3c"); + std::fs::create_dir_all(&dir_a).unwrap(); + std::fs::create_dir_all(&dir_b).unwrap(); + let iii = iii_sdk::register_worker("ws://127.0.0.1:59594", iii_sdk::InitOptions::default()); + let mut a = ShellConfig::default(); + a.fs.host_root = Some(dir_a.clone()); + let mut b = ShellConfig::default(); + b.fs.host_root = Some(dir_b.clone()); + let state = AppState { + runtime: Arc::new(RwLock::new(build_runtime(&a, &iii).expect("initial"))), + iii: iii.clone(), + reload_lock: Arc::new(Mutex::new(())), + reload_status: Arc::new(RwLock::new(ReloadStatus::default())), + }; + let res = reload_serialized(&state, { + let b = b.clone(); + move || async move { Ok::<_, String>(b) } + }) + .await; + assert!(matches!(res, Ok(ReloadOutcome::Applied))); + assert_eq!( + state.runtime.read().await.config.fs.host_root, + Some(dir_b), + "reconcile/reload applies the freshly fetched config" + ); + } + + #[tokio::test] + async fn reload_status_tracks_rejection_then_recovery() { + // Finding 2: a rejected (unbuildable) config keeps last-good but must be + // VISIBLE — recorded as Rejected with a reason and a cumulative count; + // a later valid reload flips back to Applied but preserves the count. + let dir = std::env::temp_dir().join("shell-reload-status-9b3c"); + std::fs::create_dir_all(&dir).unwrap(); + let iii = iii_sdk::register_worker("ws://127.0.0.1:59592", iii_sdk::InitOptions::default()); + let mut good = ShellConfig::default(); + good.fs.host_root = Some(dir.clone()); + let state = AppState { + runtime: Arc::new(RwLock::new(build_runtime(&good, &iii).expect("initial"))), + iii: iii.clone(), + reload_lock: Arc::new(Mutex::new(())), + reload_status: Arc::new(RwLock::new(ReloadStatus::default())), + }; + + // Initial status is Applied with no rejections. + { + let s = state.reload_status.read().await; + assert_eq!(s.last_outcome, ReloadOutcome::Applied); + assert_eq!(s.rejected_reloads, 0); + } + + // A rejected (invalid: unjailed default) reload keeps last-good AND records it. + let res = + reload_serialized(&state, || async { Ok::<_, String>(ShellConfig::default()) }).await; + assert!(res.is_ok(), "invalid config is acked (no storm)"); + { + let s = state.reload_status.read().await; + assert_eq!(s.last_outcome, ReloadOutcome::Rejected); + assert!(s.last_error.is_some()); + assert_eq!(s.rejected_reloads, 1); + } + // Runtime kept the previous (valid) policy. + assert_eq!( + state.runtime.read().await.config.fs.host_root, + Some(dir.clone()) + ); + + // A subsequent valid reload flips back to Applied but preserves the count. + let mut good2 = ShellConfig::default(); + good2.fs.host_root = Some(dir.clone()); + let res = reload_serialized(&state, { + let g = good2.clone(); + move || async move { Ok::<_, String>(g) } + }) + .await; + assert!(res.is_ok()); + { + let s = state.reload_status.read().await; + assert_eq!(s.last_outcome, ReloadOutcome::Applied); + assert_eq!(s.last_error, None); + assert_eq!( + s.rejected_reloads, 1, + "cumulative rejection count is preserved across recovery" + ); + } + } + + #[test] + fn reload_status_serializes_to_documented_shape() { + // shell::config-status publishes this shape via response_format; pin the + // operator/automation contract (snake_case outcome + the three fields) so + // it cannot drift silently. + let mut s = ReloadStatus::default(); + let v = serde_json::to_value(&s).expect("serialize"); + assert_eq!(v["last_outcome"], "applied"); + assert_eq!(v["last_error"], serde_json::Value::Null); + assert_eq!(v["rejected_reloads"], 0); + + s.record_rejected("host backend init: boom".into()); + let v = serde_json::to_value(&s).expect("serialize"); + assert_eq!(v["last_outcome"], "rejected"); + assert_eq!(v["last_error"], "host backend init: boom"); + assert_eq!(v["rejected_reloads"], 1); + } + + #[tokio::test] + async fn boot_reconcile_fails_closed_on_invalid_or_unreachable_config() { + // F1: at boot, an invalid OR unfetchable authoritative config must abort + // startup (fail-closed) — NOT keep last-good like a steady-state reload. + // A valid config reconciles cleanly. + let dir = std::env::temp_dir().join("shell-reconcile-failclosed-9b3c"); + std::fs::create_dir_all(&dir).unwrap(); + let iii = iii_sdk::register_worker("ws://127.0.0.1:59591", iii_sdk::InitOptions::default()); + let mut good = ShellConfig::default(); + good.fs.host_root = Some(dir.clone()); + let state = AppState { + runtime: Arc::new(RwLock::new(build_runtime(&good, &iii).expect("initial"))), + iii: iii.clone(), + reload_lock: Arc::new(Mutex::new(())), + reload_status: Arc::new(RwLock::new(ReloadStatus::default())), + }; + + // Invalid (unjailed default) authoritative config → fail closed. + let res = + reconcile_with(&state, || async { Ok::<_, String>(ShellConfig::default()) }).await; + assert!( + res.is_err(), + "boot reconcile must fail closed on an invalid stored config" + ); + + // Transient fetch failure → fail closed too. + let res = reconcile_with(&state, || async { + Err::("get timed out".into()) + }) + .await; + assert!( + res.is_err(), + "boot reconcile must fail closed on a transient fetch failure" + ); + + // A valid config reconciles cleanly. + let mut good2 = ShellConfig::default(); + good2.fs.host_root = Some(dir.clone()); + let res = reconcile_with(&state, { + let g = good2.clone(); + move || async move { Ok::<_, String>(g) } + }) + .await; + assert!(res.is_ok(), "boot reconcile applies a valid config"); + } +} diff --git a/shell/src/exec/backend.rs b/shell/src/exec/backend.rs index 64f31ec6..46d76e9e 100644 --- a/shell/src/exec/backend.rs +++ b/shell/src/exec/backend.rs @@ -1,11 +1,13 @@ //! Backend trait for exec — host and sandbox impls live in sibling -//! modules. The trait takes `argv` and a resolved `timeout_ms`; the -//! handler is responsible for argv parsing, allowlist checking, and -//! timeout resolution before calling `run`. +//! modules. The trait takes `argv`, a resolved `timeout_ms`, and the +//! validated per-call `overrides` (cwd/env); the handler is responsible +//! for argv parsing, allowlist checking, timeout resolution, and the +//! cwd/env gating before calling `run`. use async_trait::async_trait; use super::error::ExecError; +use super::policy::ExecOverrides; pub type ExecCallResult = Result; @@ -22,7 +24,15 @@ pub struct ExecOutcome { #[async_trait] pub trait ExecBackend: Send + Sync { - async fn run(&self, argv: &[String], timeout_ms: u64) -> ExecCallResult; + /// Run `argv` to completion. `overrides` carries the validated per-call + /// cwd/env (host-only — the sandbox backend rejects a populated override + /// with S210 since the in-VM exec protocol does not yet forward them). + async fn run( + &self, + argv: &[String], + timeout_ms: u64, + overrides: &ExecOverrides, + ) -> ExecCallResult; } #[cfg(test)] diff --git a/shell/src/exec/error.rs b/shell/src/exec/error.rs index e3f766a3..3505547d 100644 --- a/shell/src/exec/error.rs +++ b/shell/src/exec/error.rs @@ -23,11 +23,32 @@ impl ExecError { /// Serializing `&'static str` + `String` is effectively infallible /// (OOM only); `expect` so future shape changes fail loudly rather /// than producing malformed JSON. + /// + /// The handler-return path lifts `ExecError` to `IIIError::Remote` directly + /// (see `From for IIIError` below), so it no longer stringifies. + /// `to_json` is kept as the canonical `{code,message}` serialization + /// (round-trip coverage in tests) and for any caller that needs the wire + /// shape as a `String`. pub fn to_json(&self) -> String { serde_json::to_string(self).expect("ExecError is always serializable") } } +/// Carry the S-code to the wire as the top-level `code`. The engine SDK maps +/// `IIIError::Remote { code, message, .. }` to the wire `ErrorBody` verbatim, +/// so an agent can branch on `error.code` (e.g. "S211"). Any other `IIIError` +/// variant collapses to `code: "invocation_failed"` with the real code buried +/// in the message — which is exactly what we are escaping here. +impl From for iii_sdk::IIIError { + fn from(err: ExecError) -> Self { + iii_sdk::IIIError::Remote { + code: err.code.to_string(), + message: err.message, + stacktrace: None, + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -45,4 +66,25 @@ mod tests { assert_eq!(ExecError::new("S210", "x"), ExecError::new("S210", "x"),); assert_ne!(ExecError::new("S210", "x"), ExecError::new("S211", "x"),); } + + /// The wire contract: `ExecError` lifts to `IIIError::Remote { code, .. }` + /// so the S-code reaches the wire `code` verbatim. Any other variant (e.g. + /// Handler) would collapse to `code: "invocation_failed"` — pin against that + /// regression so an agent can keep branching on `error.code`. + #[test] + fn converts_to_iii_remote_carrying_the_s_code() { + let err: iii_sdk::IIIError = ExecError::new("S216", "host exec: boom").into(); + match err { + iii_sdk::IIIError::Remote { + code, + message, + stacktrace, + } => { + assert_eq!(code, "S216"); + assert_eq!(message, "host exec: boom"); + assert!(stacktrace.is_none()); + } + other => panic!("expected IIIError::Remote, got {other:?}"), + } + } } diff --git a/shell/src/exec/host.rs b/shell/src/exec/host.rs index 588cc5b3..fd3c229e 100644 --- a/shell/src/exec/host.rs +++ b/shell/src/exec/host.rs @@ -11,6 +11,7 @@ use tokio::io::AsyncReadExt; use tokio::process::Command; use crate::config::ShellConfig; +use crate::exec::policy::ExecOverrides; use crate::exec::{backend::ExecBackend, error::ExecError, ExecCallResult, ExecOutcome}; pub fn parse_argv(command: &str, args: Option<&Vec>) -> Result, String> { @@ -23,7 +24,17 @@ pub fn parse_argv(command: &str, args: Option<&Vec>) -> Result Result { +/// Build the host `Command`. `overrides` carries the already-validated per-call +/// `cwd`/`env` (see `crate::exec::policy`): when both are `None` (the common +/// case) this is byte-for-byte the prior behaviour. Per-call values are applied +/// AFTER the config-derived defaults so an override wins for that one key / +/// directory; the argv itself is unchanged (the allowlist/denylist already ran +/// in the handler). +pub fn build_command( + argv: &[String], + cfg: &ShellConfig, + overrides: &ExecOverrides, +) -> Result { let program = argv.first().ok_or_else(|| "empty command".to_string())?; let mut cmd = Command::new(program); if argv.len() > 1 { @@ -37,23 +48,100 @@ pub fn build_command(argv: &[String], cfg: &ShellConfig) -> Result) { + if let Some(pid) = pid { + // SAFETY: see the doc above — `pid` is an un-reaped child we own, and the + // negative value signals its process group. + unsafe { + libc::kill(-(pid as libc::pid_t), libc::SIGKILL); + } + } +} + +#[cfg(not(unix))] +pub(crate) fn kill_process_group(_pid: Option) {} + +/// Feed `stdin` (if any) to the child's stdin pipe, then close it so the child +/// sees EOF. Runs in a detached task so a child that interleaves reading stdin +/// with writing stdout cannot deadlock against our stdout/stderr drains +/// (each side would otherwise block on a full pipe). Best-effort: a child that +/// exited before consuming its input just makes the write fail, which we ignore. +pub(crate) fn pump_stdin(child: &mut tokio::process::Child, stdin: &Option) { + if let Some(data) = stdin { + if let Some(mut sin) = child.stdin.take() { + let bytes = data.clone().into_bytes(); + tokio::spawn(async move { + use tokio::io::AsyncWriteExt; + let _ = sin.write_all(&bytes).await; + let _ = sin.shutdown().await; + }); + } + } +} + pub async fn run_to_completion( argv: &[String], cfg: &ShellConfig, timeout_ms: u64, + overrides: &ExecOverrides, ) -> Result { let started = std::time::Instant::now(); - let mut cmd = build_command(argv, cfg)?; + let mut cmd = build_command(argv, cfg, overrides)?; let mut child = cmd.spawn().map_err(|e| format!("spawn: {}", e))?; + pump_stdin(&mut child, &overrides.stdin); let mut stdout_reader = child.stdout.take().ok_or("no stdout pipe")?; let mut stderr_reader = child.stderr.take().ok_or("no stderr pipe")?; @@ -70,6 +158,10 @@ pub async fn run_to_completion( Ok(Ok(status)) => (status.code(), false), Ok(Err(e)) => return Err(format!("wait: {}", e)), Err(_) => { + // Hard timeout: kill the whole process group (grandchildren too) while + // we still own the un-reaped child, then reap. start_kill is the + // non-unix fallback (kill_process_group is a no-op there). + kill_process_group(child.id()); let _ = child.start_kill(); let _ = child.wait().await; (None, true) @@ -134,7 +226,12 @@ impl HostExecBackend { #[async_trait] impl ExecBackend for HostExecBackend { - async fn run(&self, argv: &[String], timeout_ms: u64) -> ExecCallResult { + async fn run( + &self, + argv: &[String], + timeout_ms: u64, + overrides: &ExecOverrides, + ) -> ExecCallResult { // S216 is the generic "shell-internal error" code in the // S2xx range. We deliberately do NOT use S300 here even // though it visually parallels SandboxExecBackend's S300 @@ -143,7 +240,7 @@ impl ExecBackend for HostExecBackend { // wait failures as S300 would make a "command not found" on // the host indistinguishable from a missing /dev/kvm to // callers that branch on the code. - run_to_completion(argv, &self.cfg, timeout_ms) + run_to_completion(argv, &self.cfg, timeout_ms, overrides) .await .map_err(|e| ExecError::new("S216", format!("host exec: {e}"))) } @@ -183,9 +280,14 @@ mod tests { #[tokio::test] async fn test_run_echo() { let cfg = test_cfg(); - let out = run_to_completion(&["echo".into(), "hi".into()], &cfg, 5000) - .await - .unwrap(); + let out = run_to_completion( + &["echo".into(), "hi".into()], + &cfg, + 5000, + &ExecOverrides::default(), + ) + .await + .unwrap(); assert_eq!(out.exit_code, Some(0)); assert_eq!(out.stdout.trim(), "hi"); assert!(!out.timed_out); @@ -194,16 +296,27 @@ mod tests { #[tokio::test] async fn test_run_nonexistent_command() { let cfg = test_cfg(); - let err = run_to_completion(&["_nope_no_exist_".into()], &cfg, 1000).await; + let err = run_to_completion( + &["_nope_no_exist_".into()], + &cfg, + 1000, + &ExecOverrides::default(), + ) + .await; assert!(err.is_err()); } #[tokio::test] async fn test_timeout_kills() { let cfg = test_cfg(); - let out = run_to_completion(&["sleep".into(), "5".into()], &cfg, 200) - .await - .unwrap(); + let out = run_to_completion( + &["sleep".into(), "5".into()], + &cfg, + 200, + &ExecOverrides::default(), + ) + .await + .unwrap(); assert!(out.timed_out); assert_eq!(out.exit_code, None); } @@ -220,6 +333,7 @@ mod tests { ], &cfg, 3000, + &ExecOverrides::default(), ) .await .unwrap(); @@ -232,10 +346,122 @@ mod tests { let cfg = Arc::new(test_cfg()); let backend = HostExecBackend::new(cfg); let out = backend - .run(&["echo".into(), "via-trait".into()], 5000) + .run( + &["echo".into(), "via-trait".into()], + 5000, + &ExecOverrides::default(), + ) .await .unwrap(); assert_eq!(out.exit_code, Some(0)); assert_eq!(out.stdout.trim(), "via-trait"); } + + /// A jail-confined `cwd` actually changes the child's working directory: + /// `pwd` prints the override, not the worker's cwd or `cfg.working_dir`. + /// Engine-free host path, reusing the existing harness. + #[tokio::test] + async fn cwd_override_runs_command_in_that_directory() { + let root = std::env::temp_dir().join(format!("shell-cwd-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(root.join("workdir")).unwrap(); + let mut cfg = test_cfg(); + cfg.fs.host_root = Some(root.clone()); + + let overrides = crate::exec::policy::build_overrides(Some("workdir"), None, &cfg) + .expect("workdir is inside the jail"); + let out = run_to_completion(&["pwd".into()], &cfg, 5000, &overrides) + .await + .unwrap(); + let expected = root.join("workdir").canonicalize().unwrap(); + assert_eq!(out.stdout.trim(), expected.to_string_lossy()); + assert_eq!(out.exit_code, Some(0)); + std::fs::remove_dir_all(&root).ok(); + } + + /// A permitted `env` key is visible to the child process. We forward + /// `printenv NODE_ENV`; with NODE_ENV in allowed_env and a per-call value, + /// the child sees it. + #[tokio::test] + async fn env_override_is_visible_to_child() { + let mut cfg = test_cfg(); + cfg.allowed_env = vec!["NODE_ENV".into()]; + + let mut env = std::collections::BTreeMap::new(); + env.insert("NODE_ENV".to_string(), "from-override".to_string()); + let overrides = crate::exec::policy::build_overrides(None, Some(&env), &cfg) + .expect("NODE_ENV is allowlisted"); + + let out = run_to_completion( + &["printenv".into(), "NODE_ENV".into()], + &cfg, + 5000, + &overrides, + ) + .await + .unwrap(); + assert_eq!(out.stdout.trim(), "from-override"); + } + + /// An env key NOT in allowed_env is rejected (S210) before any spawn, + /// naming the offending key — the call never reaches the child. + #[tokio::test] + async fn env_key_outside_allowed_env_is_rejected_s210() { + let mut cfg = test_cfg(); + cfg.allowed_env = vec!["NODE_ENV".into()]; + let mut env = std::collections::BTreeMap::new(); + env.insert("SECRET_TOKEN".to_string(), "x".to_string()); + let err = crate::exec::policy::build_overrides(None, Some(&env), &cfg) + .expect_err("non-allowlisted key must reject"); + assert_eq!(err.code, "S210"); + assert!( + err.message.contains("SECRET_TOKEN"), + "names key: {}", + err.message + ); + } + + /// LD_PRELOAD is rejected (S210) even when the test also adds it to + /// allowed_env — proof that the dangerous-key denylist wins over the + /// operator's allowlist. + #[tokio::test] + async fn dangerous_env_key_rejected_even_if_allowlisted_on_host_path() { + let mut cfg = test_cfg(); + cfg.allowed_env = vec!["LD_PRELOAD".into(), "NODE_ENV".into()]; + let mut env = std::collections::BTreeMap::new(); + env.insert("LD_PRELOAD".to_string(), "/tmp/evil.so".to_string()); + let err = crate::exec::policy::build_overrides(None, Some(&env), &cfg) + .expect_err("LD_PRELOAD must reject even when allowlisted"); + assert_eq!(err.code, "S210"); + assert!(err.message.contains("LD_PRELOAD")); + } + + /// A `cwd` resolving outside the jail is rejected S215 on the host path. + #[tokio::test] + async fn cwd_escaping_jail_rejected_s215_on_host_path() { + let root = std::env::temp_dir().join(format!("shell-cwd-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + let mut cfg = test_cfg(); + cfg.fs.host_root = Some(root.clone()); + let err = crate::exec::policy::build_overrides(Some("../../etc"), None, &cfg) + .expect_err("escape must reject"); + assert_eq!(err.code, "S215"); + std::fs::remove_dir_all(&root).ok(); + } + + /// Omitting both cwd and env preserves prior behaviour: the empty override + /// runs the command exactly as before (no cwd change, no extra env). + #[tokio::test] + async fn empty_overrides_preserve_default_behaviour() { + let cfg = test_cfg(); + let out = run_to_completion( + &["echo".into(), "default".into()], + &cfg, + 5000, + &ExecOverrides::default(), + ) + .await + .unwrap(); + assert_eq!(out.stdout.trim(), "default"); + assert_eq!(out.exit_code, Some(0)); + } } diff --git a/shell/src/exec/mod.rs b/shell/src/exec/mod.rs index 4854a78d..3f50dbcc 100644 --- a/shell/src/exec/mod.rs +++ b/shell/src/exec/mod.rs @@ -4,9 +4,15 @@ pub mod backend; pub mod error; pub mod host; +pub mod policy; pub mod sandbox; pub use backend::{ExecBackend, ExecCallResult, ExecOutcome}; +// Per-call cwd/env gating surface. `ExecOverrides` is the validated bundle +// threaded into `build_command`; `build_overrides` does the gating. Exposed +// for the handlers and (lib consumers) the test harness. +#[allow(unused_imports)] +pub use policy::{build_overrides, ExecOverrides}; // These two re-exports are the public-API surface for downstream // consumers of `shell::exec::*` and the back-compat surface for // internal `crate::exec::{parse_argv, build_command, run_to_completion, diff --git a/shell/src/exec/policy.rs b/shell/src/exec/policy.rs new file mode 100644 index 00000000..3ad22563 --- /dev/null +++ b/shell/src/exec/policy.rs @@ -0,0 +1,412 @@ +//! Per-call `cwd` + `env` gating for `shell::exec` / `shell::exec_bg`. +//! +//! These two optional request fields let an agent scope a single command to a +//! directory and set specific environment values WITHOUT wrapping everything in +//! `sh -lc` (which would defeat the argv allowlist). The whole point of this +//! module is the gating: untrusted LLM input must never escape the fs jail via +//! `cwd`, nor hijack the child's execution environment via `env`. +//! +//! Gating rules (mandatory, not best-effort): +//! - `cwd` is confined to the SAME jail the fs backend enforces — it is +//! canonicalized and must `starts_with(host_root)` and miss the denylist, +//! exactly like `shell::fs::*` paths. A `cwd` resolving outside the jail is +//! rejected `S215`. When `fs.host_root` is unset (operator opted into +//! `allow_unjailed`), the same code path runs with no root to confine to, +//! matching the fs backend's unjailed behaviour. +//! - `env` may set a VALUE only for a key the operator already put in +//! `cfg.allowed_env`, and NEVER for an exec-hijacking key (see +//! [`DANGEROUS_ENV_KEYS`]) — those are rejected even if an operator +//! mistakenly lists them in `allowed_env`. Any offending key rejects the +//! WHOLE call `S210` (we never silently drop a key — the agent must learn its +//! env was not applied), naming the offending key and listing the permitted +//! ones so the agent can self-correct. + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use crate::config::ShellConfig; +use crate::exec::error::ExecError; + +/// Environment keys that an agent may NEVER set per-call, regardless of +/// `allowed_env`. Setting any of these can hijack which binary the child +/// actually executes or which shared libraries it loads — turning a benign +/// allowlisted `command` into arbitrary code execution. The denylist is a +/// HARD boundary: it wins over `allowed_env` so an operator's typo can't open +/// a privesc hole. +/// +/// - `PATH` / `IFS`: change which binary an allowlisted name resolves to / how +/// the shell re-tokenizes the command line. +/// - `LD_*` (glibc) and `DYLD_*` (macOS dyld): preload / library-path +/// injection — load attacker-controlled code into the child at startup. +/// - `GCONV_PATH` and other glibc lookup paths: code-load vectors (e.g. the +/// `GCONV_PATH` chain in CVE-2021-4034) that point libc at attacker files. +/// - `HOME`: not a loader vector, but redirecting it lets an agent point a +/// config-reading allowlisted program (git/ssh/curl/python) at a jail-planted +/// config; the worker still forwards its own `HOME` when allowlisted, callers +/// just cannot override it per call. +/// - `BASH_ENV` / `ENV` / `PYTHONSTARTUP` / `PERL5OPT` / `RUBYOPT` / +/// `NODE_OPTIONS`: startup-file / option-injection vectors honored by +/// sh/bash/python/perl/ruby/node at process start — a non-interactive +/// interpreter sources or applies them before running anything, so pointing +/// them at a jail-planted file/option turns an allowlisted interpreter into +/// arbitrary code execution (same exec-hijack class as the loader vars). +pub const DANGEROUS_ENV_KEYS: &[&str] = &[ + "PATH", + "IFS", + "HOME", + "LD_PRELOAD", + "LD_LIBRARY_PATH", + "LD_AUDIT", + "LD_PROFILE", + "LD_DEBUG", + "LD_CONFIG", + "LD_ORIGIN_PATH", + "DYLD_INSERT_LIBRARIES", + "DYLD_LIBRARY_PATH", + "DYLD_FRAMEWORK_PATH", + "DYLD_FALLBACK_LIBRARY_PATH", + "DYLD_FALLBACK_FRAMEWORK_PATH", + "GCONV_PATH", + "GETCONF_DIR", + "HOSTALIASES", + "NIS_PATH", + "MALLOC_TRACE", + "RES_OPTIONS", + "LOCALDOMAIN", + "BASH_ENV", + "ENV", + "PYTHONSTARTUP", + "PERL5OPT", + "RUBYOPT", + "NODE_OPTIONS", +]; + +/// True if `key` is always rejected. Two layers: (1) family-prefix checks for +/// the dynamic-loader variables (`LD_*` on glibc, `DYLD_*` on macOS) — the set +/// of these is open-ended across libc/OS versions, so an exact-name list would +/// silently let a future `LD_SOMETHING` through if an operator widened +/// `allowed_env`; (2) the explicit [`DANGEROUS_ENV_KEYS`] denylist for the +/// non-family names (PATH/IFS/HOME, glibc lookup paths, interpreter startup +/// keys). Case-sensitive: env var names are case-sensitive on Unix and the +/// dangerous names are upper-case. +fn is_dangerous_env_key(key: &str) -> bool { + key.starts_with("LD_") || key.starts_with("DYLD_") || DANGEROUS_ENV_KEYS.contains(&key) +} + +/// Validated per-call exec overrides, ready to apply in `build_command`. +/// `None` fields mean "unchanged from the config-derived default", so the +/// default behaviour (both request fields omitted) is byte-for-byte the prior +/// behaviour. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ExecOverrides { + /// Canonical, jail-confined working directory. `None` falls back to + /// `cfg.working_dir` in `build_command`. + pub cwd: Option, + /// Per-call env values, already gated against `allowed_env` + + /// [`DANGEROUS_ENV_KEYS`]. Applied on top of the config-forwarded env. + pub env: Option>, + /// Bytes fed to the child's stdin (then EOF). `None` leaves stdin closed + /// (`/dev/null`). Needs no jail/allowlist gating — it is opaque input data, + /// not a path or env key — but like cwd/env it is HOST-only (the sandbox + /// exec protocol does not forward stdin), enforced via [`is_empty`]. + pub stdin: Option, +} + +impl ExecOverrides { + /// True when there is nothing to apply — used by the sandbox backend to + /// fast-path the common (omitted) case while still rejecting a populated + /// override (cwd/env/stdin are host-only for now). + pub fn is_empty(&self) -> bool { + self.cwd.is_none() && self.env.is_none() && self.stdin.is_none() + } +} + +/// Confine a raw `cwd` string against the fs jail and verify it is an existing +/// directory. Reuses the fs backend's [`crate::fs::host::confine_path`] so the +/// rule is literally the same one `shell::fs::*` enforces: +/// canonicalize → `starts_with(host_root)` → denylist. Per-call (not cached +/// like the fs backend) because the exec handler reads the live config +/// snapshot; canonicalizing the root + denylist here is one extra `stat` per +/// call with a `cwd`, which is negligible. +fn confine_cwd(cwd: &str, cfg: &ShellConfig) -> Result { + // Canonicalize host_root + denylist the same way HostFsBackend::try_new + // does, so confine_path sees the identical inputs. An unreachable root / + // denylist entry is an operator config error; surface it rather than + // silently degrading the jail. + let host_root_canon = match &cfg.fs.host_root { + Some(root) => Some(std::fs::canonicalize(root).map_err(|e| { + ExecError::new( + "S216", + format!("host_root unreachable ({}): {e}", root.display()), + ) + })?), + None => None, + }; + let mut denylist_canon = Vec::with_capacity(cfg.fs.denylist_paths.len()); + for deny in &cfg.fs.denylist_paths { + match std::fs::canonicalize(deny) { + Ok(c) => denylist_canon.push(c), + // A denylist entry that doesn't exist can't be escaped through; + // skip it rather than failing the whole exec (matches the spirit + // of the fs backend, which canonicalizes with a fallback). The + // jail root check below is the primary boundary. + Err(_) => denylist_canon.push(deny.clone()), + } + } + + let canon = crate::fs::host::confine_path(cwd, host_root_canon.as_deref(), &denylist_canon) + // FsError and ExecError carry the same { code, message } shape; the + // confinement codes (S210 bad/empty path, S215 jail escape/denylist) + // are exactly what exec wants to surface, so re-wrap verbatim. + .map_err(|e| ExecError::new(static_code(e.code), e.message))?; + + // The directory must exist and BE a directory — a missing or + // wrong-type cwd is a clear request error, not a spawn-time surprise. + let md = std::fs::symlink_metadata(&canon) + .map_err(|e| ExecError::new("S211", format!("cwd not found: {cwd}: {e}")))?; + // Follow one level for symlinks-to-dir: confine_path already resolved the + // path canonically, so this metadata stat is on the real target. + let md = if md.file_type().is_symlink() { + std::fs::metadata(&canon) + .map_err(|e| ExecError::new("S211", format!("cwd not found: {cwd}: {e}")))? + } else { + md + }; + if !md.is_dir() { + return Err(ExecError::new( + "S210", + format!("cwd is not a directory: {cwd}"), + )); + } + Ok(canon) +} + +/// Map an `FsError`'s runtime `String` code onto the `&'static str` codes +/// `ExecError` uses, preserving the confinement taxonomy (S210/S211/S215) and +/// collapsing anything else to the generic S216. +fn static_code(code: &str) -> &'static str { + match code { + "S210" => "S210", + "S211" => "S211", + "S215" => "S215", + _ => "S216", + } +} + +/// Validate a per-call `env` map: every key must be present in +/// `cfg.allowed_env` AND absent from [`DANGEROUS_ENV_KEYS`]. The dangerous +/// check runs FIRST so a key that is both dangerous and (mistakenly) +/// allowlisted is still rejected. On any violation the WHOLE call fails S210 — +/// we never partially apply env, so the agent always knows whether its env took +/// effect. +fn validate_env( + env: &BTreeMap, + cfg: &ShellConfig, +) -> Result, ExecError> { + // The keys an agent can actually set per call: in allowed_env AND not in the + // dangerous denylist. Listing the raw allowed_env would name HOME/PATH (both + // default-allowed but always-rejected) as "settable", contradicting the very + // error that rejected them and sending the agent into a retry loop. + let settable = cfg + .allowed_env + .iter() + .filter(|k| !is_dangerous_env_key(k)) + .cloned() + .collect::>() + .join(", "); + for key in env.keys() { + if is_dangerous_env_key(key) { + return Err(ExecError::new( + "S210", + format!( + "env key '{key}' is never settable per-call (exec-hijacking key); \ + remove it. Settable keys (in allowed_env, minus exec-hijacking keys): [{settable}]" + ), + )); + } + if !cfg.allowed_env.iter().any(|a| a == key) { + return Err(ExecError::new( + "S210", + format!( + "env key '{key}' is not in allowed_env; the operator must permit it. \ + Settable keys: [{settable}]" + ), + )); + } + } + Ok(env.clone()) +} + +/// Build the validated [`ExecOverrides`] from the raw request fields, applying +/// all gating. Returns an `ExecError` (with the S-code the caller surfaces on +/// the wire) on any violation. Both inputs `None` ⇒ an empty `ExecOverrides`, +/// i.e. unchanged default behaviour. +pub fn build_overrides( + cwd: Option<&str>, + env: Option<&BTreeMap>, + cfg: &ShellConfig, +) -> Result { + let cwd = match cwd { + Some(c) => Some(confine_cwd(c, cfg)?), + None => None, + }; + let env = match env { + Some(e) => Some(validate_env(e, cfg)?), + None => None, + }; + // `stdin` is set by the handler after this call (it needs no gating); leave + // it None here so build_overrides stays the cwd/env confinement entry point. + Ok(ExecOverrides { + cwd, + env, + stdin: None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cfg_jailed(root: &std::path::Path) -> ShellConfig { + let mut c = ShellConfig { + allowed_env: vec!["NODE_ENV".into(), "MY_VAR".into()], + ..Default::default() + }; + c.fs.host_root = Some(root.to_path_buf()); + c + } + + #[test] + fn dangerous_keys_cover_ld_and_dyld_and_path() { + for k in [ + "PATH", + "IFS", + "HOME", + "LD_PRELOAD", + "DYLD_INSERT_LIBRARIES", + "GCONV_PATH", + "HOSTALIASES", + "BASH_ENV", + "ENV", + ] { + assert!(is_dangerous_env_key(k), "{k} must be dangerous"); + } + assert!(!is_dangerous_env_key("NODE_ENV")); + // Family-prefix coverage: loader vars NOT in the explicit list (future + // / less-common names) are still rejected by the LD_/DYLD_ prefix. + for k in ["LD_BIND_NOW", "LD_SOMETHING_NEW", "DYLD_PRINT_LIBRARIES"] { + assert!( + is_dangerous_env_key(k), + "{k} must be rejected by family prefix" + ); + assert!(!DANGEROUS_ENV_KEYS.contains(&k), "{k} is prefix-only"); + } + } + + #[test] + fn env_in_allowed_is_accepted() { + let c = ShellConfig { + allowed_env: vec!["NODE_ENV".into()], + ..Default::default() + }; + let mut env = BTreeMap::new(); + env.insert("NODE_ENV".to_string(), "test".to_string()); + let out = validate_env(&env, &c).expect("allowed key accepted"); + assert_eq!(out.get("NODE_ENV").map(String::as_str), Some("test")); + } + + #[test] + fn env_not_in_allowed_is_rejected_naming_key_and_listing_permitted() { + let c = ShellConfig { + allowed_env: vec!["NODE_ENV".into(), "MY_VAR".into()], + ..Default::default() + }; + let mut env = BTreeMap::new(); + env.insert("SECRET".to_string(), "x".to_string()); + let err = validate_env(&env, &c).expect_err("must reject"); + assert_eq!(err.code, "S210"); + assert!( + err.message.contains("SECRET"), + "names the key: {}", + err.message + ); + assert!( + err.message.contains("NODE_ENV"), + "lists permitted: {}", + err.message + ); + assert!( + err.message.contains("MY_VAR"), + "lists permitted: {}", + err.message + ); + } + + #[test] + fn dangerous_key_rejected_even_when_allowlisted() { + // The denylist must WIN over allowed_env: an operator typo listing + // LD_PRELOAD must not open a code-injection hole. + let c = ShellConfig { + allowed_env: vec!["LD_PRELOAD".into()], + ..Default::default() + }; + let mut env = BTreeMap::new(); + env.insert("LD_PRELOAD".to_string(), "/tmp/evil.so".to_string()); + let err = validate_env(&env, &c).expect_err("dangerous key must reject"); + assert_eq!(err.code, "S210"); + assert!(err.message.contains("LD_PRELOAD")); + assert!(err.message.contains("exec-hijacking")); + } + + #[test] + fn cwd_inside_jail_resolves() { + let root = std::env::temp_dir().join(format!("shell-policy-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(root.join("sub")).unwrap(); + let c = cfg_jailed(&root); + let canon = confine_cwd("sub", &c).expect("cwd inside jail"); + assert_eq!(canon, root.join("sub").canonicalize().unwrap()); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn cwd_escaping_jail_is_rejected_s215() { + let root = std::env::temp_dir().join(format!("shell-policy-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + let c = cfg_jailed(&root); + let err = confine_cwd("../../etc", &c).expect_err("escape rejected"); + assert_eq!(err.code, "S215"); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn cwd_missing_directory_is_rejected() { + let root = std::env::temp_dir().join(format!("shell-policy-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + let c = cfg_jailed(&root); + // `nope` is inside the jail lexically but does not exist — confine_path + // resolves the longest existing ancestor (the root) then the stat + // fails, surfacing S211. + let err = confine_cwd("nope", &c).expect_err("missing cwd rejected"); + assert!(err.code == "S211" || err.code == "S210", "got {}", err.code); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn cwd_pointing_at_a_file_is_rejected_s210() { + let root = std::env::temp_dir().join(format!("shell-policy-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("f.txt"), "x").unwrap(); + let c = cfg_jailed(&root); + let err = confine_cwd("f.txt", &c).expect_err("file-as-cwd rejected"); + assert_eq!(err.code, "S210"); + assert!(err.message.contains("not a directory")); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn build_overrides_both_none_is_empty() { + let c = ShellConfig::default(); + let ov = build_overrides(None, None, &c).expect("ok"); + assert!(ov.is_empty()); + } +} diff --git a/shell/src/exec/sandbox.rs b/shell/src/exec/sandbox.rs index 4d9be902..780b5c74 100644 --- a/shell/src/exec/sandbox.rs +++ b/shell/src/exec/sandbox.rs @@ -4,12 +4,12 @@ //! shell does not stream them. use async_trait::async_trait; -use iii_sdk::IIIError; use serde::Deserialize; -use serde_json::{json, Value}; +use serde_json::json; use std::sync::Arc; use uuid::Uuid; +use crate::exec::policy::ExecOverrides; use crate::exec::{backend::ExecBackend, error::ExecError, ExecCallResult, ExecOutcome}; use crate::triggers::TriggerFwd; @@ -45,10 +45,27 @@ impl SandboxExecBackend { #[async_trait] impl ExecBackend for SandboxExecBackend { - async fn run(&self, argv: &[String], timeout_ms: u64) -> ExecCallResult { + async fn run( + &self, + argv: &[String], + timeout_ms: u64, + overrides: &ExecOverrides, + ) -> ExecCallResult { if !self.enabled { return Err(ExecError::new("S210", "sandbox target disabled in config")); } + // cwd/env/stdin are HOST-ONLY for now: the `sandbox::exec` payload + // forwards only { sandbox_id, cmd, args, timeout_ms }, so honoring them + // here would silently drop them. Fail loudly instead so the contract is + // honest — an agent that wants per-call cwd/env/stdin must target the + // host. `is_empty()` is false when any of the three is set. + if !overrides.is_empty() { + return Err(ExecError::new( + "S210", + "cwd/env/stdin overrides are host-only; the sandbox exec protocol does not \ + forward them. Drop cwd/env/stdin, or use target: host.", + )); + } let cmd = argv .first() .ok_or_else(|| ExecError::new("S210", "empty command"))? @@ -65,7 +82,7 @@ impl ExecBackend for SandboxExecBackend { let resp = match self.fwd.trigger("sandbox::exec", payload).await { Ok(v) => v, Err(e) => { - let exec_err = map_iii_err(e); + let exec_err = crate::scode::map_iii_err(&e, ExecError::new); // The engine's iii-sandbox returns an *error* (code S200, // type=execution) when the in-VM command exceeds // `timeout_ms` rather than a structured response with @@ -120,96 +137,11 @@ fn is_engine_timeout(err: &ExecError) -> bool { err.code == "S200" || err.message.contains("timed out") } -/// Recover an S-code from an `IIIError`. Mirrors -/// `fs::sandbox::map_iii_err` so codes round-trip identically across -/// fs and exec paths. -fn map_iii_err(err: IIIError) -> ExecError { - match &err { - IIIError::Remote { code, message, .. } if code.starts_with('S') => { - return ExecError::new( - map_static_code(code), - format!("forwarded from engine: {message}"), - ); - } - IIIError::Remote { message, .. } => { - if let Some(c) = scan_s_code(message) { - return ExecError::new( - map_static_code(c), - format!("forwarded from engine (wrapped): {message}"), - ); - } - } - IIIError::Handler(s) => { - if let Ok(parsed) = serde_json::from_str::(s) { - if let Some(c) = parsed.get("code").and_then(|v| v.as_str()) { - let msg = parsed.get("message").and_then(|v| v.as_str()).unwrap_or(""); - return ExecError::new( - map_static_code(c), - format!("forwarded from engine: {msg}"), - ); - } - } - if let Some(c) = scan_s_code(s) { - return ExecError::new( - map_static_code(c), - format!("forwarded from engine (raw): {s}"), - ); - } - } - _ => {} - } - ExecError::new("S216", format!("engine error: {err:?}")) -} - -fn scan_s_code(s: &str) -> Option<&str> { - let bytes = s.as_bytes(); - // The window is 4 bytes (`bytes[i..=i+3]`), so the last valid `i` is - // `len - 4`. The loop bound `< len - 3` gives `i <= len - 4`. - // `saturating_sub(3)` collapses to 0 when `len < 4`, which yields an - // empty range (no false access) on too-short inputs. - for i in 0..bytes.len().saturating_sub(3) { - if bytes[i] == b'S' - && bytes[i + 1].is_ascii_digit() - && bytes[i + 2].is_ascii_digit() - && bytes[i + 3].is_ascii_digit() - { - // Reject matches preceded by alphanumerics so we don't grab - // the tail of a longer identifier (e.g. "FOO_S211"). - let preceded_by_word = - i > 0 && (bytes[i - 1].is_ascii_alphanumeric() || bytes[i - 1] == b'_'); - if !preceded_by_word { - return Some(&s[i..i + 4]); - } - } - } - None -} - -fn map_static_code(code: &str) -> &'static str { - match code { - "S001" => "S001", - "S002" => "S002", - "S003" => "S003", - "S004" => "S004", - "S100" => "S100", - "S101" => "S101", - "S102" => "S102", - // S200: in-VM execution failure (engine wire-error shape). The - // common case — exec timeout — is intercepted in `run` and - // surfaced as `{ timed_out: true }`; other S200 reasons (rare) - // round-trip to the caller verbatim. - "S200" => "S200", - "S210" => "S210", - "S216" => "S216", - "S300" => "S300", - "S400" => "S400", - _ => "S216", - } -} - #[cfg(test)] mod tests { use super::*; + use iii_sdk::IIIError; + use serde_json::Value; use std::sync::Mutex; /// Stub forwarder using `Mutex>` to handle the @@ -275,7 +207,11 @@ mod tests { let b = backend(stub.clone(), id); let out = b - .run(&["echo".into(), "hi".into()], 4000) + .run( + &["echo".into(), "hi".into()], + 4000, + &ExecOverrides::default(), + ) .await .expect("ok response"); assert_eq!(out.stdout, "hi\n"); @@ -309,7 +245,9 @@ mod tests { "timed_out": false, })); let b = backend(stub.clone(), Uuid::new_v4()); - b.run(&["pwd".into()], 1000).await.unwrap(); + b.run(&["pwd".into()], 1000, &ExecOverrides::default()) + .await + .unwrap(); let (_fid, payload) = stub.captured.lock().unwrap().clone().unwrap(); assert_eq!(payload["args"], json!([])); } @@ -324,7 +262,14 @@ mod tests { "timed_out": true, })); let b = backend(stub, Uuid::new_v4()); - let out = b.run(&["sleep".into(), "60".into()], 30000).await.unwrap(); + let out = b + .run( + &["sleep".into(), "60".into()], + 30000, + &ExecOverrides::default(), + ) + .await + .unwrap(); assert!(out.timed_out); } @@ -332,7 +277,10 @@ mod tests { async fn disabled_returns_s210() { let stub = StubFwd::ok(json!({})); let b = SandboxExecBackend::new(stub, false, Uuid::new_v4()); - let err = b.run(&["echo".into()], 1000).await.unwrap_err(); + let err = b + .run(&["echo".into()], 1000, &ExecOverrides::default()) + .await + .unwrap_err(); assert_eq!(err.code, "S210"); assert!(err.message.contains("disabled")); } @@ -341,15 +289,44 @@ mod tests { async fn empty_argv_returns_s210() { let stub = StubFwd::ok(json!({})); let b = backend(stub, Uuid::new_v4()); - let err = b.run(&[], 1000).await.unwrap_err(); + let err = b + .run(&[], 1000, &ExecOverrides::default()) + .await + .unwrap_err(); assert_eq!(err.code, "S210"); } + #[tokio::test] + async fn populated_overrides_rejected_host_only_s210() { + // cwd/env are host-only — the sandbox::exec payload doesn't forward + // them, so a populated override must fail loudly rather than be + // silently dropped. + let stub = StubFwd::ok(json!({ + "stdout": "", "stderr": "", "exit_code": 0, + "duration_ms": 1, "timed_out": false, + })); + let b = backend(stub, Uuid::new_v4()); + let overrides = ExecOverrides { + cwd: Some(std::path::PathBuf::from("/tmp")), + env: None, + stdin: None, + }; + let err = b + .run(&["echo".into()], 1000, &overrides) + .await + .expect_err("populated override on sandbox must reject"); + assert_eq!(err.code, "S210"); + assert!(err.message.contains("host-only"), "got: {}", err.message); + } + #[tokio::test] async fn remote_s300_round_trips() { let stub = StubFwd::remote_err("S300", "VM boot failed: no /dev/kvm"); let b = backend(stub, Uuid::new_v4()); - let err = b.run(&["python3".into()], 1000).await.unwrap_err(); + let err = b + .run(&["python3".into()], 1000, &ExecOverrides::default()) + .await + .unwrap_err(); assert_eq!(err.code, "S300"); assert!(err.message.contains("VM boot failed")); } @@ -368,7 +345,11 @@ mod tests { ); let b = backend(stub, Uuid::new_v4()); let out = b - .run(&["sleep".into(), "30".into()], 5000) + .run( + &["sleep".into(), "30".into()], + 5000, + &ExecOverrides::default(), + ) .await .expect("timeout must surface as Ok, not Err"); assert!(out.timed_out, "timed_out must be true"); @@ -392,7 +373,11 @@ mod tests { ); let b = backend(stub, Uuid::new_v4()); let out = b - .run(&["sleep".into(), "30".into()], 5000) + .run( + &["sleep".into(), "30".into()], + 5000, + &ExecOverrides::default(), + ) .await .expect("wrapped timeout must also surface as Ok"); assert!( @@ -414,7 +399,7 @@ mod tests { ); let b = backend(stub, Uuid::new_v4()); let err = b - .run(&["echo".into()], 1000) + .run(&["echo".into()], 1000, &ExecOverrides::default()) .await .expect_err("non-timeout S2xx must not translate to Ok"); // Unknown S299 collapses to S216 via map_static_code; the point @@ -429,7 +414,10 @@ mod tests { "handler error: S101: image not installed", ); let b = backend(stub, Uuid::new_v4()); - let err = b.run(&["python3".into()], 1000).await.unwrap_err(); + let err = b + .run(&["python3".into()], 1000, &ExecOverrides::default()) + .await + .unwrap_err(); assert_eq!(err.code, "S101"); } @@ -437,7 +425,10 @@ mod tests { async fn handler_json_error_recovers_s_code() { let stub = StubFwd::handler_err(r#"{"code":"S002","message":"sandbox not found"}"#); let b = backend(stub, Uuid::new_v4()); - let err = b.run(&["echo".into()], 1000).await.unwrap_err(); + let err = b + .run(&["echo".into()], 1000, &ExecOverrides::default()) + .await + .unwrap_err(); assert_eq!(err.code, "S002"); } @@ -445,7 +436,10 @@ mod tests { async fn unknown_code_collapses_to_s216() { let stub = StubFwd::remote_err("S999", "unknown"); let b = backend(stub, Uuid::new_v4()); - let err = b.run(&["echo".into()], 1000).await.unwrap_err(); + let err = b + .run(&["echo".into()], 1000, &ExecOverrides::default()) + .await + .unwrap_err(); assert_eq!(err.code, "S216"); } @@ -453,7 +447,10 @@ mod tests { async fn malformed_response_returns_s216() { let stub = StubFwd::ok(json!({ "garbage": true })); let b = backend(stub, Uuid::new_v4()); - let err = b.run(&["echo".into()], 1000).await.unwrap_err(); + let err = b + .run(&["echo".into()], 1000, &ExecOverrides::default()) + .await + .unwrap_err(); assert_eq!(err.code, "S216"); assert!(err.message.contains("bad engine response")); } diff --git a/shell/src/exec_dispatch.rs b/shell/src/exec_dispatch.rs index c09ec2e1..dc1df98f 100644 --- a/shell/src/exec_dispatch.rs +++ b/shell/src/exec_dispatch.rs @@ -6,7 +6,6 @@ use std::sync::Arc; use uuid::Uuid; use crate::config::ShellConfig; -use crate::exec::error::ExecError; use crate::exec::host::HostExecBackend; use crate::exec::sandbox::SandboxExecBackend; use crate::exec::ExecBackend; @@ -32,13 +31,7 @@ fn sandbox_for(id: Uuid, iii: iii_sdk::III, enabled: bool) -> Arc String { - e.to_json() -} - -// No unit tests in this module: the JSON envelope shape is already -// covered by `exec::error::tests::to_json_emits_code_and_message`, -// and `pick_exec_backend`'s match arms are trivial constructors that -// would require a real `iii_sdk::III` to exercise. Real coverage of -// the dispatcher's behavior comes from the integration tests in T8 -// once the handler is wired through. +// No unit tests in this module: `pick_exec_backend`'s match arms are trivial +// constructors that would require a real `iii_sdk::III` to exercise. The +// ExecError -> wire-code conversion is covered by +// `exec::error::tests` (the `From for IIIError` Remote lift). diff --git a/shell/src/fs/error.rs b/shell/src/fs/error.rs index 4e9e32e3..408a1529 100644 --- a/shell/src/fs/error.rs +++ b/shell/src/fs/error.rs @@ -37,11 +37,32 @@ impl FsError { /// `serde_json::to_string` on `&'static str` + `String` fields is /// effectively infallible (OOM only); `expect` so future changes that /// break the invariant fail loudly instead of producing malformed JSON. + /// + /// The handler-return path lifts `FsError` to `IIIError::Remote` directly + /// (see `From for IIIError` below), so it no longer stringifies. + /// `to_json` is kept as the canonical `{code,message}` serialization + /// (round-trip coverage in tests) and for any caller that needs the wire + /// shape as a `String`. pub fn to_json(&self) -> String { serde_json::to_string(self).expect("FsError is always serializable") } } +/// Carry the S2xx code to the wire as the top-level `code`. The engine SDK +/// maps `IIIError::Remote { code, message, .. }` to the wire `ErrorBody` +/// verbatim, so an agent can branch on `error.code` (e.g. "S211"). Any other +/// `IIIError` variant collapses to `code: "invocation_failed"` with the real +/// code buried in the message — which is exactly what we are escaping here. +impl From for iii_sdk::IIIError { + fn from(err: FsError) -> Self { + iii_sdk::IIIError::Remote { + code: err.code.to_string(), + message: err.message, + stacktrace: None, + } + } +} + pub fn parse_mode(mode: &str) -> Result { if mode.is_empty() { return Err(FsError::new("S210", "invalid octal mode: (empty)")); @@ -96,4 +117,25 @@ mod tests { assert!(j.contains("\"code\":\"S211\"")); assert!(j.contains("\"message\":\"nope\"")); } + + /// The wire contract: `FsError` lifts to `IIIError::Remote { code, .. }` so + /// the S-code reaches the wire `code` verbatim. Any other variant (e.g. + /// Handler) would collapse to `code: "invocation_failed"` — pin against that + /// regression so an agent can keep branching on `error.code`. + #[test] + fn converts_to_iii_remote_carrying_the_s_code() { + let err: iii_sdk::IIIError = FsError::new("S215", "denied").into(); + match err { + iii_sdk::IIIError::Remote { + code, + message, + stacktrace, + } => { + assert_eq!(code, "S215"); + assert_eq!(message, "denied"); + assert!(stacktrace.is_none()); + } + other => panic!("expected IIIError::Remote, got {other:?}"), + } + } } diff --git a/shell/src/fs/host.rs b/shell/src/fs/host.rs index a91cbd43..f02b57b3 100644 --- a/shell/src/fs/host.rs +++ b/shell/src/fs/host.rs @@ -60,7 +60,7 @@ impl IiiChannelMaker { #[async_trait] impl ChannelMaker for IiiChannelMaker { async fn create_channel(&self, buffer: usize) -> Result { - self.iii.create_channel(Some(buffer)).await + iii_sdk::helpers::create_channel(&self.iii, Some(buffer)).await } fn engine_address(&self) -> String { self.iii.address().to_string() @@ -73,6 +73,65 @@ pub struct HostFsConfig { pub max_read_bytes: usize, pub max_write_bytes: usize, pub denylist_paths: Vec, + /// Permit setuid/setgid/sticky bits (`mode & 0o7000`) in mkdir/chmod/write. + /// Default false rejects them with S210 — they are a privesc primitive + /// when the worker runs as root inside the jail. + pub allow_special_bits: bool, +} + +/// Hard caps on attacker-controlled regex/sed patterns. An unbounded pattern +/// can stall compilation and pin memory; N concurrent calls = DoS. +const MAX_PATTERN_BYTES: usize = 4096; +const REGEX_SIZE_LIMIT: usize = 256 * 1024; + +/// Hard ceiling on the size of a single file `sed` will read+rewrite when the +/// backend config sets no `max_read_bytes` (cap == 0). `sed` builds a +/// same-size output String in memory, so an unbounded file is an OOM vector; +/// N concurrent calls multiply it. 16 MiB mirrors a sane edit target. +const SED_MAX_FILE_BYTES: u64 = 16 * 1024 * 1024; + +/// Upper bound on bytes buffered while reading a SINGLE grep "line". A file +/// with no newlines would otherwise buffer the whole file before the +/// per-match `max_line_bytes` truncation ever ran. We stop reading a line at +/// this cap (discarding the remainder up to the next newline) so memory stays +/// bounded DURING the read, not just in the returned match. +const GREP_MAX_LINE_SCAN_BYTES: usize = 1024 * 1024; + +/// Defaults reused when a caller passes 0 for the corresponding grep cap. 0 +/// must mean "use the default", not "unlimited" — an unbounded match count or +/// line length is a memory/DoS vector. These mirror the schema defaults in +/// `fs/mod.rs` (`default_max_matches` / `default_max_line_bytes`). +const DEFAULT_GREP_MAX_MATCHES: usize = 10_000; +const DEFAULT_GREP_MAX_LINE_BYTES: usize = 4096; + +/// Reject setuid/setgid/sticky bits unless the operator opted in. The policy +/// reads the backend's config flag at the call site; `parse_mode` stays a pure +/// octal parser. Centralized so mkdir/chmod/write stay consistent. +fn check_special_bits(bits: u32, allow_special_bits: bool) -> Result<(), FsError> { + if !allow_special_bits && (bits & 0o7000) != 0 { + return Err(FsError::new( + "S210", + format!( + "setuid/setgid/sticky bits not allowed (mode {bits:04o}); \ + set fs.allow_special_bits to permit" + ), + )); + } + Ok(()) +} + +/// Reject over-long patterns before compiling (or before literal use). +fn check_pattern_len(pattern: &str) -> Result<(), FsError> { + if pattern.len() > MAX_PATTERN_BYTES { + return Err(FsError::new( + "S210", + format!( + "pattern too long ({} bytes); max is {MAX_PATTERN_BYTES} bytes", + pattern.len() + ), + )); + } + Ok(()) } #[derive(Debug, Clone)] @@ -92,6 +151,7 @@ pub struct HostFsBackend { } impl HostFsBackend { + #[allow(dead_code)] pub fn new(cfg: Arc, chan: Arc) -> Self { match Self::try_new(cfg.clone(), chan.clone()) { Ok(b) => b, @@ -139,52 +199,7 @@ impl HostFsBackend { /// can mutate the filesystem between validation and subsequent syscalls. /// Use the sandbox backend for untrusted input. pub(crate) fn validate_path(&self, path: &str) -> Result { - let p = Path::new(path); - let joined; - let p = if p.is_absolute() { - p - } else if path.is_empty() { - return Err(FsError::new("S210", "path must not be empty")); - } else if let Some(root_canon) = &self.host_root_canon { - // Relative paths resolve against the jail root. The canonical - // starts_with(host_root) check below still runs on the joined - // path, so `..` in the relative form cannot escape the jail. - joined = root_canon.join(p); - joined.as_path() - } else { - return Err(FsError::new( - "S210", - format!("path must be absolute: {path}"), - )); - }; - let canon = canonicalize_with_fallback(p).map_err(|e| { - // Dangling-symlink errors are structurally jail violations - // (the path would otherwise resolve through a link that - // pre-fix slipped past the lexical fallback). Map them to - // S215 so wire telemetry treats them as such. - let msg = format!("{e}"); - if msg.contains("dangling symlink in path") { - FsError::new("S215", format!("{path}: {msg}")) - } else { - FsError::new("S210", format!("{path}: {msg}")) - } - })?; - if let Some(root_canon) = &self.host_root_canon { - if !canon.starts_with(root_canon) { - // Name the jail root so a caller (human or agent) can - // self-correct in one step instead of guessing paths. - return Err(FsError::new( - "S215", - format!("path escapes host_root {}: {path}", root_canon.display()), - )); - } - } - for deny_canon in &self.denylist_canon { - if canon.starts_with(deny_canon) { - return Err(FsError::new("S215", format!("path is denylisted: {path}"))); - } - } - Ok(canon) + confine_path(path, self.host_root_canon.as_deref(), &self.denylist_canon) } /// Lexical operand for handlers whose semantics forbid canonicalizing @@ -193,14 +208,85 @@ impl HostFsBackend { /// against, so the validated path and the operated-on path can never /// diverge (the worker's CWD is unrelated to the jail). fn lexical_operand(&self, path: &str) -> PathBuf { - let p = Path::new(path); - if p.is_relative() { - if let Some(root_canon) = &self.host_root_canon { - return normalize_lexical(&root_canon.join(p)); - } + lexical_operand_with(path, self.host_root_canon.as_deref()) + } +} + +/// Jail-confinement check, factored out of `HostFsBackend::validate_path` so +/// the same logic can run inside a `spawn_blocking` closure (which needs a +/// `'static + Send` body and so cannot borrow `&self`). Callers on the blocking +/// thread pass owned/cloned copies of the precomputed canonical root and +/// denylist. Behaviour, error codes (S210/S215) and the returned canonical +/// path are identical to the method form — this is a pure extraction, not a +/// weakening of any check. +/// +/// `pub(crate)` so the exec backend can confine a per-call `cwd` against the +/// SAME jail the fs backend enforces (shell::exec/exec_bg `cwd`) instead of +/// duplicating the canonicalize / starts_with(host_root) / denylist logic. +pub(crate) fn confine_path( + path: &str, + host_root_canon: Option<&Path>, + denylist_canon: &[PathBuf], +) -> Result { + let p = Path::new(path); + let joined; + let p = if p.is_absolute() { + p + } else if path.is_empty() { + return Err(FsError::new("S210", "path must not be empty")); + } else if let Some(root_canon) = host_root_canon { + // Relative paths resolve against the jail root. The canonical + // starts_with(host_root) check below still runs on the joined + // path, so `..` in the relative form cannot escape the jail. + joined = root_canon.join(p); + joined.as_path() + } else { + return Err(FsError::new( + "S210", + format!("path must be absolute: {path}"), + )); + }; + let canon = canonicalize_with_fallback(p).map_err(|e| { + // Dangling-symlink errors are structurally jail violations + // (the path would otherwise resolve through a link that + // pre-fix slipped past the lexical fallback). Map them to + // S215 so wire telemetry treats them as such. + let msg = format!("{e}"); + if msg.contains("dangling symlink in path") { + FsError::new("S215", format!("{path}: {msg}")) + } else { + FsError::new("S210", format!("{path}: {msg}")) + } + })?; + if let Some(root_canon) = host_root_canon { + if !canon.starts_with(root_canon) { + // Name the jail root so a caller (human or agent) can + // self-correct in one step instead of guessing paths. + return Err(FsError::new( + "S215", + format!("path escapes host_root {}: {path}", root_canon.display()), + )); + } + } + for deny_canon in denylist_canon { + if canon.starts_with(deny_canon) { + return Err(FsError::new("S215", format!("path is denylisted: {path}"))); } - normalize_lexical(p) } + Ok(canon) +} + +/// Free-function form of `HostFsBackend::lexical_operand`, for use inside a +/// `spawn_blocking` closure that can't borrow `&self`. Anchors relative inputs +/// to the SAME jail root, identical to the method form. +fn lexical_operand_with(path: &str, host_root_canon: Option<&Path>) -> PathBuf { + let p = Path::new(path); + if p.is_relative() { + if let Some(root_canon) = host_root_canon { + return normalize_lexical(&root_canon.join(p)); + } + } + normalize_lexical(p) } /// Resolve `p` to a canonical path that is symlink-free for every existing @@ -360,26 +446,22 @@ fn expand_regex_replacement(caps: ®ex::Captures, template: &str) -> String { out } -/// When `case_insensitive` is true, delegates to regex with `(?i)` and an -/// escaped pattern. Hand-rolling case-fold over UTF-8 is unsound (e.g. -/// `'İ'` U+0130 folds to a length-changing sequence). +/// When case-insensitive literal replace is requested, the caller passes a +/// precompiled `(?i)`-escaped matcher in `ci_matcher` (built ONCE per sed +/// call, not per line — a 10k-line file otherwise does 10k regex compiles). +/// Hand-rolling case-fold over UTF-8 is unsound (e.g. `'İ'` U+0130 folds to a +/// length-changing sequence), so we delegate to regex for the fold. fn literal_replace_line( line: &str, needle: &str, - case_insensitive: bool, + ci_matcher: Option<®ex::Regex>, replacement: &str, first_only: bool, ) -> (String, u64) { if line.is_empty() || needle.is_empty() { return (line.to_string(), 0); } - if case_insensitive { - let escaped_needle = regex::escape(needle); - let pattern = format!("(?i){escaped_needle}"); - let re = match regex::Regex::new(&pattern) { - Ok(r) => r, - Err(_) => return (line.to_string(), 0), - }; + if let Some(re) = ci_matcher { // Closures returning `String` to `Regex::replacen`/`replace_all` // satisfy `Replacer` via the FnMut blanket impl, which inserts the // returned string verbatim — no `$N` capture substitution. So the @@ -557,28 +639,36 @@ async fn pump_file_to_channel( #[async_trait] impl FsBackend for HostFsBackend { async fn ls(&self, req: LsArgs) -> FsCallResult { + // Jail validation runs here, on the async fn, BEFORE the blocking work. let p = self.validate_path(&req.path)?; - let md = std::fs::symlink_metadata(&p).map_err(|e| FsError::from_io(&req.path, e))?; - if !md.is_dir() { - return Err(FsError::new( - "S212", - format!("not a directory: {}", req.path), - )); - } - let rd = std::fs::read_dir(&p).map_err(|e| FsError::from_io(&req.path, e))?; - let mut entries = Vec::new(); - for ent in rd { - let ent = match ent { - Ok(e) => e, - Err(_) => continue, - }; - let md = match ent.path().symlink_metadata() { - Ok(m) => m, - Err(_) => continue, - }; - let name = ent.file_name().to_string_lossy().into_owned(); - entries.push(fs_entry_from_metadata(name, &md)); - } + // The symlink_metadata stat, read_dir, and the per-entry + // symlink_metadata loop are all blocking std::fs work that scales with + // directory size; move it off the executor (mirrors grep/sed). + let req_path = req.path; + let join = tokio::task::spawn_blocking(move || -> Result<_, FsError> { + let md = std::fs::symlink_metadata(&p).map_err(|e| FsError::from_io(&req_path, e))?; + if !md.is_dir() { + return Err(FsError::new("S212", format!("not a directory: {req_path}"))); + } + let rd = std::fs::read_dir(&p).map_err(|e| FsError::from_io(&req_path, e))?; + let mut entries = Vec::new(); + for ent in rd { + let ent = match ent { + Ok(e) => e, + Err(_) => continue, + }; + let md = match ent.path().symlink_metadata() { + Ok(m) => m, + Err(_) => continue, + }; + let name = ent.file_name().to_string_lossy().into_owned(); + entries.push(fs_entry_from_metadata(name, &md)); + } + Ok(entries) + }); + let entries = join + .await + .map_err(|e| FsError::new("S216", format!("ls task join failed: {e}")))??; Ok(LsResponse { entries }) } @@ -595,13 +685,31 @@ impl FsBackend for HostFsBackend { async fn mkdir(&self, req: crate::fs::MkdirArgs) -> FsCallResult { let p = self.validate_path(&req.path)?; let bits = crate::fs::error::parse_mode(&req.mode)?; + check_special_bits(bits, self.cfg.allow_special_bits)?; if p.exists() { if req.parents { - return Ok(crate::fs::MkdirResponse { created: false }); + // mkdir -p is idempotent only over an existing DIRECTORY. A + // regular file (or symlink to one) at the path is a hard error: + // reporting success here would silently mask a misconfigured + // path and make later fs ops fail far from the real cause. + if !p.is_dir() { + return Err(FsError::new( + "S213", + format!("path exists and is not a directory: {}", req.path), + )); + } + return Ok(crate::fs::MkdirResponse { + created: false, + path: req.path.clone(), + already_existed: true, + }); } return Err(FsError::new( "S213", - format!("path already exists: {}", req.path), + format!( + "path already exists: {}; pass parents: true for an idempotent create", + req.path + ), )); } let res = if req.parents { @@ -612,7 +720,11 @@ impl FsBackend for HostFsBackend { res.map_err(|e| FsError::from_io(&req.path, e))?; let perms = std::fs::Permissions::from_mode(bits); std::fs::set_permissions(&p, perms).map_err(|e| FsError::from_io(&req.path, e))?; - Ok(crate::fs::MkdirResponse { created: true }) + Ok(crate::fs::MkdirResponse { + created: true, + path: req.path.clone(), + already_existed: false, + }) } async fn rm(&self, req: crate::fs::RmArgs) -> FsCallResult { @@ -622,86 +734,118 @@ impl FsBackend for HostFsBackend { self.validate_path(&req.path)?; let p = self.lexical_operand(&req.path); - let md = std::fs::symlink_metadata(&p).map_err(|e| FsError::from_io(&req.path, e))?; - - if md.is_dir() && !md.file_type().is_symlink() { - if req.recursive { - std::fs::remove_dir_all(&p).map_err(|e| FsError::from_io(&req.path, e))?; - } else { - let mut rd = std::fs::read_dir(&p).map_err(|e| FsError::from_io(&req.path, e))?; - if rd.next().is_some() { - return Err(FsError::new( - "S214", - format!("directory not empty: {}", req.path), - )); + // The symlink_metadata stat, recursive remove_dir_all, the non-recursive + // read_dir emptiness probe, and the unlink are all blocking std::fs work + // that scales with subtree size; move it off the executor (mirrors + // grep/sed). Jail validation already ran above on the async fn. + let recursive = req.recursive; + let req_path = req.path.clone(); + let join = tokio::task::spawn_blocking(move || -> Result<(), FsError> { + let md = std::fs::symlink_metadata(&p).map_err(|e| FsError::from_io(&req_path, e))?; + if md.is_dir() && !md.file_type().is_symlink() { + if recursive { + std::fs::remove_dir_all(&p).map_err(|e| FsError::from_io(&req_path, e))?; + } else { + let mut rd = + std::fs::read_dir(&p).map_err(|e| FsError::from_io(&req_path, e))?; + if rd.next().is_some() { + return Err(FsError::new( + "S214", + format!( + "directory not empty: {req_path}; pass recursive: true to remove it" + ), + )); + } + std::fs::remove_dir(&p).map_err(|e| FsError::from_io(&req_path, e))?; } - std::fs::remove_dir(&p).map_err(|e| FsError::from_io(&req.path, e))?; + } else { + std::fs::remove_file(&p).map_err(|e| FsError::from_io(&req_path, e))?; } - } else { - std::fs::remove_file(&p).map_err(|e| FsError::from_io(&req.path, e))?; - } - Ok(crate::fs::RmResponse { removed: true }) + Ok(()) + }); + join.await + .map_err(|e| FsError::new("S216", format!("rm task join failed: {e}")))??; + Ok(crate::fs::RmResponse { + removed: true, + path: req.path.clone(), + was_present: true, + }) } async fn chmod(&self, req: crate::fs::ChmodArgs) -> FsCallResult { + // Jail validation + mode parsing run here, on the async fn, BEFORE the + // blocking work. self.validate_path(&req.path)?; let p = self.lexical_operand(&req.path); let bits = crate::fs::error::parse_mode(&req.mode)?; - if !p.exists() { - return Err(FsError::new( - "S211", - format!("path not found: {}", req.path), - )); - } + check_special_bits(bits, self.cfg.allow_special_bits)?; + + // The exists probe, the recursive WalkDir traversal, and the per-entry + // set_permissions/chown loop are all blocking std::fs work that scales + // with subtree size; move it off the executor (mirrors grep/sed). + // Owned values (canonical-anchored PathBuf, mode bits, uid/gid, flags) + // move into the closure. let uid = req.uid; let gid = req.gid; - let apply = |target: &Path| -> Result<(), FsError> { - let perms = std::fs::Permissions::from_mode(bits); - std::fs::set_permissions(target, perms) - .map_err(|e| FsError::from_io(&target.to_string_lossy(), e))?; - if uid.is_some() || gid.is_some() { - std::os::unix::fs::chown(target, uid, gid) - .map_err(|e| FsError::from_io(&target.to_string_lossy(), e))?; + let recursive = req.recursive; + let req_path = req.path.clone(); + let join = tokio::task::spawn_blocking(move || -> Result { + if !p.exists() { + return Err(FsError::new("S211", format!("path not found: {req_path}"))); } - Ok(()) - }; - let mut updated: u64 = 0; - if req.recursive { - // Reject if the walk root itself is a symlink: descending into - // a symlink target would change perms outside the recursive - // root, and skipping the root entry silently (which is what - // the per-entry skip below would do) is a quiet no-op that - // looks like success to the caller. S212 = wrong file type. - let root_md = - std::fs::symlink_metadata(&p).map_err(|e| FsError::from_io(&req.path, e))?; - if root_md.file_type().is_symlink() { - return Err(FsError::new( - "S212", - format!( - "recursive chmod refuses to follow symlink at root: {}", - req.path - ), - )); - } - for entry in walkdir::WalkDir::new(&p) - .follow_links(false) - .into_iter() - .filter_map(|e| e.ok()) - { - // Skip symlink entries inside the walk: chmod(2)/chown(2) - // follow symlinks and would rewrite the target's - // mode/owner — possibly outside the recursive root or the - // jail. lchmod isn't portable. - if entry.file_type().is_symlink() { - continue; + let apply = |target: &Path| -> Result<(), FsError> { + let perms = std::fs::Permissions::from_mode(bits); + std::fs::set_permissions(target, perms) + .map_err(|e| FsError::from_io(&target.to_string_lossy(), e))?; + if uid.is_some() || gid.is_some() { + std::os::unix::fs::chown(target, uid, gid) + .map_err(|e| FsError::from_io(&target.to_string_lossy(), e))?; + } + Ok(()) + }; + let mut updated: u64 = 0; + if recursive { + // Reject if the walk root itself is a symlink: descending into + // a symlink target would change perms outside the recursive + // root, and skipping the root entry silently (which is what + // the per-entry skip below would do) is a quiet no-op that + // looks like success to the caller. S212 = wrong file type. + let root_md = + std::fs::symlink_metadata(&p).map_err(|e| FsError::from_io(&req_path, e))?; + if root_md.file_type().is_symlink() { + return Err(FsError::new( + "S212", + format!("recursive chmod refuses to follow symlink at root: {req_path}"), + )); + } + for entry in walkdir::WalkDir::new(&p) + .follow_links(false) + .into_iter() + .filter_map(|e| e.ok()) + { + // Skip symlink entries inside the walk: chmod(2)/chown(2) + // follow symlinks and would rewrite the target's + // mode/owner — possibly outside the recursive root or the + // jail. lchmod isn't portable. + if entry.file_type().is_symlink() { + continue; + } + apply(entry.path())?; + updated += 1; } - apply(entry.path())?; - updated += 1; + } else { + apply(&p)?; + updated = 1; } - } else { - apply(&p)?; - updated = 1; - } - Ok(crate::fs::ChmodResponse { updated }) + Ok(updated) + }); + let updated = join + .await + .map_err(|e| FsError::new("S216", format!("chmod task join failed: {e}")))??; + Ok(crate::fs::ChmodResponse { + entries_changed: updated, + path: req.path.clone(), + recursive: req.recursive, + }) } async fn mv(&self, req: crate::fs::MvArgs) -> FsCallResult { @@ -712,165 +856,267 @@ impl FsBackend for HostFsBackend { if !src_p.exists() { return Err(FsError::new("S211", format!("src not found: {}", req.src))); } - if dst_p.exists() && !req.overwrite { + // Best-effort overwrite guard: `dst_existed` is a pre-rename check, so + // `overwrite:false` is race-able and `overwrote` may under-report if a + // concurrent writer creates dst in the check→rename window (POSIX rename + // replaces atomically). A race-free guard needs renameat2(RENAME_NOREPLACE) + // (Linux-only) — tracked as a follow-up; the field doc notes this. + let dst_existed = dst_p.exists(); + if dst_existed && !req.overwrite { return Err(FsError::new( "S213", - format!("dst already exists: {}", req.dst), + format!( + "dst already exists: {}; pass overwrite: true to replace it", + req.dst + ), )); } - match std::fs::rename(&src_p, &dst_p) { - Ok(()) => Ok(crate::fs::MvResponse { moved: true }), - // EXDEV: cross-fs move — fall back to copy+rename+unlink. - // File-only; directories are unsupported (matches engine daemon). - Err(e) if e.raw_os_error() == Some(libc::EXDEV) => { - let tmp = temp_sibling(&dst_p); - std::fs::copy(&src_p, &tmp).map_err(|e| FsError::from_io(&req.dst, e))?; - if let Err(e) = std::fs::rename(&tmp, &dst_p) { - let _ = std::fs::remove_file(&tmp); - return Err(FsError::from_io(&req.dst, e)); + // The rename and the EXDEV copy+rename+unlink fallback are blocking + // std::fs work; the cross-filesystem copy in particular is O(file size) + // and would pin a worker thread on a large file. Move the whole + // rename-or-fallback unit off the executor (mirrors grep/sed). Jail + // validation already ran above on the async fn; we move owned copies of + // the anchored src/dst paths and the src/dst strings (for error + // messages) into the closure. + let src_str = req.src.clone(); + let dst_str = req.dst.clone(); + let join = tokio::task::spawn_blocking(move || -> Result<(), FsError> { + match std::fs::rename(&src_p, &dst_p) { + Ok(()) => Ok(()), + // EXDEV: cross-fs move — fall back to copy+rename+unlink. + // File-only; directories are unsupported (matches engine daemon). + Err(e) if e.raw_os_error() == Some(libc::EXDEV) => { + let tmp = temp_sibling(&dst_p); + std::fs::copy(&src_p, &tmp).map_err(|e| FsError::from_io(&dst_str, e))?; + if let Err(e) = std::fs::rename(&tmp, &dst_p) { + let _ = std::fs::remove_file(&tmp); + return Err(FsError::from_io(&dst_str, e)); + } + std::fs::remove_file(&src_p).map_err(|e| FsError::from_io(&src_str, e))?; + Ok(()) } - std::fs::remove_file(&src_p).map_err(|e| FsError::from_io(&req.src, e))?; - Ok(crate::fs::MvResponse { moved: true }) + Err(e) => Err(FsError::from_io(&dst_str, e)), } - Err(e) => Err(FsError::from_io(&req.dst, e)), - } + }); + join.await + .map_err(|e| FsError::new("S216", format!("mv task join failed: {e}")))??; + Ok(crate::fs::MvResponse { + moved: true, + src: req.src.clone(), + dst: req.dst.clone(), + overwrote: dst_existed, + }) } async fn grep(&self, req: crate::fs::GrepArgs) -> FsCallResult { let root = self.validate_path(&req.path)?; - let md = std::fs::symlink_metadata(&root).map_err(|e| FsError::from_io(&req.path, e))?; + // Cap before compiling: an unbounded pattern stalls compilation and + // pins memory. + check_pattern_len(&req.pattern)?; let re = regex::RegexBuilder::new(&req.pattern) .case_insensitive(req.ignore_case) + .size_limit(REGEX_SIZE_LIMIT) + .dfa_size_limit(REGEX_SIZE_LIMIT) .build() .map_err(|e| FsError::new("S217", format!("bad regex: {e}")))?; - let max_matches_usize = req.max_matches as usize; - let max_line_usize = req.max_line_bytes as usize; - let include_glob = req.include_glob; - let exclude_glob = req.exclude_glob; - let should_scan = |rel: &str| -> bool { - if !include_glob.is_empty() && !include_glob.iter().any(|g| glob_matches_path(g, rel)) { - return false; - } - if exclude_glob.iter().any(|g| glob_matches_path(g, rel)) { - return false; - } - true + // The symlink_metadata stat, the walk, and the per-file scan are all + // blocking std::fs / BufReader work. Move ALL of it off the Tokio + // worker thread — the residual stat at the top used to run on the + // executor — so a grep over a large tree can't stall it. Capture owned + // data; the compiled Regex is Send+Sync so it moves into the closure. + // + // A 0 cap means "use the default", NOT "unlimited": an unbounded match + // count or line length is a memory/DoS vector. Clamp both before the + // closure so the scan always runs with a positive bound. + let max_matches_usize = if req.max_matches == 0 { + DEFAULT_GREP_MAX_MATCHES + } else { + req.max_matches as usize }; - - let mut out: Vec = Vec::new(); - let mut truncated = false; - - let mut scan = |file_path: &Path| -> Result { - if looks_binary(file_path) { - return Ok(false); - } - let f = std::fs::File::open(file_path) - .map_err(|e| FsError::from_io(&file_path.to_string_lossy(), e))?; - let reader = std::io::BufReader::new(f); - use std::io::BufRead; - for (idx, line_res) in reader.lines().enumerate() { - let Ok(mut line) = line_res else { - continue; - }; - if re.is_match(&line) { - if max_line_usize > 0 && line.len() > max_line_usize { - // Floor to nearest char boundary so a multi-byte - // codepoint straddling the cut doesn't panic. - let cut = (0..=max_line_usize) - .rev() - .find(|&i| line.is_char_boundary(i)) - .unwrap_or(0); - line.truncate(cut); - line.push('…'); - } - out.push(crate::fs::wire::FsMatch { - path: file_path.to_string_lossy().into_owned(), - line: (idx + 1) as u64, - content: line, - }); - if max_matches_usize > 0 && out.len() >= max_matches_usize { - return Ok(true); - } - } - } - Ok(false) + let max_line_usize = if req.max_line_bytes == 0 { + DEFAULT_GREP_MAX_LINE_BYTES + } else { + req.max_line_bytes as usize }; - - if md.is_dir() { - if !req.recursive { + let include_glob = req.include_glob; + let exclude_glob = req.exclude_glob; + let recursive = req.recursive; + let req_path = req.path; + + let join = tokio::task::spawn_blocking(move || -> Result<_, FsError> { + let md = + std::fs::symlink_metadata(&root).map_err(|e| FsError::from_io(&req_path, e))?; + let is_dir = md.is_dir(); + if is_dir && !recursive { return Err(FsError::new( "S210", "recursive=false on a directory is unsupported; \ pass a file path or set recursive=true", )); } - for entry in walkdir::WalkDir::new(&root) - .follow_links(false) - .into_iter() - .filter_map(|e| e.ok()) - .filter(|e| e.file_type().is_file()) - { - let rel = entry - .path() - .strip_prefix(&root) - .unwrap_or(entry.path()) - .to_string_lossy() - .into_owned(); - if !should_scan(&rel) { - continue; + + let should_scan = |rel: &str| -> bool { + if !include_glob.is_empty() + && !include_glob.iter().any(|g| glob_matches_path(g, rel)) + { + return false; + } + if exclude_glob.iter().any(|g| glob_matches_path(g, rel)) { + return false; + } + true + }; + + let mut out: Vec = Vec::new(); + let mut truncated = false; + + let mut scan = |file_path: &Path| -> Result { + if looks_binary(file_path) { + return Ok(false); + } + let f = std::fs::File::open(file_path) + .map_err(|e| FsError::from_io(&file_path.to_string_lossy(), e))?; + let mut reader = std::io::BufReader::new(f); + use std::io::{BufRead, Read}; + // Byte-bounded line read: `reader.lines()` materializes each + // full line before `max_line_bytes` truncation ever runs, so a + // file with no newlines buffers the WHOLE file. Read with + // `read_until(b'\n')` capped at GREP_MAX_LINE_SCAN_BYTES; once a + // single line hits the cap we stop buffering and discard the + // rest of the line up to the next newline, so memory is bounded + // during the read, not just in the returned match. + let mut idx = 0usize; + let mut buf: Vec = Vec::new(); + loop { + buf.clear(); + // Read at most GREP_MAX_LINE_SCAN_BYTES of this line. + // `take(cap)` bounds the buffer; if the line is longer the + // read stops without a trailing newline and we drain the + // remainder below. + let n = (&mut reader) + .take(GREP_MAX_LINE_SCAN_BYTES as u64) + .read_until(b'\n', &mut buf) + .map_err(|e| FsError::from_io(&file_path.to_string_lossy(), e))?; + if n == 0 { + break; + } + let ended_with_nl = buf.last() == Some(&b'\n'); + // A line longer than the scan cap: no newline yet AND we + // filled the cap. Discard the remainder of this line (up to + // and including the next newline) one bounded read at a + // time so nothing further is buffered. + let mut truncated_line = false; + if !ended_with_nl && buf.len() >= GREP_MAX_LINE_SCAN_BYTES { + truncated_line = true; + let mut scratch: Vec = Vec::new(); + loop { + scratch.clear(); + let m = (&mut reader) + .take(GREP_MAX_LINE_SCAN_BYTES as u64) + .read_until(b'\n', &mut scratch) + .map_err(|e| FsError::from_io(&file_path.to_string_lossy(), e))?; + // Stop at EOF (m==0) or once we consumed the newline + // that ends this over-long line. Inspect the bytes + // directly rather than comparing lengths — a chunk + // can fill the cap AND end in '\n' simultaneously. + if m == 0 || scratch.last() == Some(&b'\n') { + break; + } + } + } + idx += 1; + // Strip the trailing newline (and a preceding CR) so the + // match content matches the previous `lines()` behavior. + if buf.last() == Some(&b'\n') { + buf.pop(); + if buf.last() == Some(&b'\r') { + buf.pop(); + } + } + // Lossy is fine: grep operated on `String` lines before too + // (invalid-UTF8 lines were dropped by `lines()`); lossy + // keeps a best-effort match rather than silently skipping. + let mut line = String::from_utf8_lossy(&buf).into_owned(); + if re.is_match(&line) { + if line.len() > max_line_usize { + // Floor to nearest char boundary so a multi-byte + // codepoint straddling the cut doesn't panic. + let cut = (0..=max_line_usize) + .rev() + .find(|&i| line.is_char_boundary(i)) + .unwrap_or(0); + line.truncate(cut); + line.push('…'); + } else if truncated_line { + // Cut at the scan cap before matching; mark partial. + line.push('…'); + } + out.push(crate::fs::wire::FsMatch { + path: file_path.to_string_lossy().into_owned(), + line: idx as u64, + content: line, + }); + if out.len() >= max_matches_usize { + return Ok(true); + } + } } - if scan(entry.path())? { + Ok(false) + }; + + if is_dir { + for entry in walkdir::WalkDir::new(&root) + .follow_links(false) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_file()) + { + let rel = entry + .path() + .strip_prefix(&root) + .unwrap_or(entry.path()) + .to_string_lossy() + .into_owned(); + if !should_scan(&rel) { + continue; + } + if scan(entry.path())? { + truncated = true; + break; + } + } + } else { + let rel = root + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + if should_scan(&rel) && scan(&root)? { truncated = true; - break; } } - } else { - let rel = root - .file_name() - .map(|s| s.to_string_lossy().into_owned()) - .unwrap_or_default(); - if should_scan(&rel) && scan(&root)? { - truncated = true; - } - } - Ok(crate::fs::GrepResponse { - matches: out, - truncated, - }) + Ok((out, truncated)) + }); + + let (matches, truncated) = join + .await + .map_err(|e| FsError::new("S216", format!("grep task join failed: {e}")))??; + + Ok(crate::fs::GrepResponse { matches, truncated }) } async fn sed(&self, req: crate::fs::SedArgs) -> FsCallResult { - let files: Vec = match (req.files.is_empty(), req.path.as_ref()) { - (false, None) => req.files.clone(), - (true, Some(root)) => { - self.validate_path(root)?; - let root_anchored = self.lexical_operand(root); - let root_path = root_anchored.as_path(); - let _ = root_path - .symlink_metadata() - .map_err(|e| FsError::from_io(root, e))?; - let target_is_dir = match std::fs::metadata(root_path) { - Ok(m) => m.is_dir(), - Err(e) => return Err(FsError::from_io(root, e)), - }; - if target_is_dir && !req.recursive { - return Err(FsError::new( - "S210", - "recursive=false on a directory is unsupported; \ - pass a file path or set recursive=true", - )); - } - let collected = collect_files_to_sed( - root_path, - req.recursive, - &req.include_glob, - &req.exclude_glob, - ); - collected - .into_iter() - .map(|p| p.to_string_lossy().into_owned()) - .collect() - } + // Decide the file-source shape on the executor (pure, no fs). The + // actual directory walk + per-file jail validation are deferred into + // the spawn_blocking closure below so NO blocking fs syscall (walkdir + // traversal, per-entry symlink_metadata/canonicalize, per-file + // validate_path) runs on the async worker thread. + enum SedSource { + Files(Vec), + Dir(String), + } + let source = match (req.files.is_empty(), req.path.as_ref()) { + (false, None) => SedSource::Files(req.files.clone()), + (true, Some(root)) => SedSource::Dir(root.clone()), (false, Some(_)) => { return Err(FsError::new( "S210", @@ -885,14 +1131,16 @@ impl FsBackend for HostFsBackend { } }; - for f in &files { - self.validate_path(f)?; - } + // Cap before compiling (regex) or using (literal) — an unbounded + // pattern stalls compilation and pins memory. + check_pattern_len(&req.pattern)?; let matcher: Option = if req.regex { Some( regex::RegexBuilder::new(&req.pattern) .case_insensitive(req.ignore_case) + .size_limit(REGEX_SIZE_LIMIT) + .dfa_size_limit(REGEX_SIZE_LIMIT) .build() .map_err(|e| FsError::new("S217", format!("bad regex: {e}")))?, ) @@ -901,91 +1149,258 @@ impl FsBackend for HostFsBackend { } else { None }; - let case_fold = req.ignore_case && !req.regex; + // Hoist the case-insensitive literal matcher: build it ONCE per sed + // call (`literal_replace_line` previously compiled a fresh `(?i)` + // regex on every line). Built only for the literal + ignore_case + // path; the regex path uses `matcher` above. + let ci_matcher: Option = if req.ignore_case && !req.regex { + let pattern = format!("(?i){}", regex::escape(&req.pattern)); + Some( + regex::RegexBuilder::new(&pattern) + .size_limit(REGEX_SIZE_LIMIT) + .dfa_size_limit(REGEX_SIZE_LIMIT) + .build() + .map_err(|e| FsError::new("S217", format!("bad regex: {e}")))?, + ) + } else { + None + }; - let mut results: Vec = Vec::with_capacity(files.len()); - let mut total: u64 = 0; - use std::os::unix::fs::PermissionsExt; + // ALL remaining work is blocking fs: the directory walk + // (collect_files_to_sed — walkdir + per-entry symlink_metadata + + // canonicalize), the per-file jail validation (confine_path), the + // jail-relative anchoring, and the read_to_string + per-line replace + // + temp-write + rename loop. Pre-fix the walk and the per-file + // validation ran on the async worker thread BEFORE spawn_blocking, so + // a `sed --path=large-dir --recursive` stalled the executor for the + // entire traversal — exactly what spawn_blocking was meant to prevent. + // We move it all into the closure. The per-file jail confinement + // (confine_path: starts_with(host_root) + denylist) still runs for + // EVERY file — it just runs on the blocking thread now, with owned + // copies of the precomputed canonical root + denylist (compiled + // regexes are Send+Sync and move in too). Streaming read/write paths + // are untouched — those already use channels. + let pattern = req.pattern; + let replacement = req.replacement; + let first_only = req.first_only; + let recursive = req.recursive; + let include_glob = req.include_glob; + let exclude_glob = req.exclude_glob; + let host_root_canon = self.host_root_canon.clone(); + let denylist_canon = self.denylist_canon.clone(); + // Per-file read cap: sed builds a same-size output String in memory, so + // an unbounded file is an OOM vector (grep skips binary + bounds its + // line read; sed did neither). Honor the backend's max_read_bytes; a 0 + // cap (no limit configured) falls back to a hard ceiling. + let read_cap: u64 = if self.cfg.max_read_bytes > 0 { + self.cfg.max_read_bytes as u64 + } else { + SED_MAX_FILE_BYTES + }; - for file in files { - let anchored = self.lexical_operand(&file); - let p = anchored.as_path(); - let original = match std::fs::read_to_string(p) { - Ok(s) => s, - Err(e) => { + let join = tokio::task::spawn_blocking(move || -> Result<_, FsError> { + use std::os::unix::fs::PermissionsExt; + + // Resolve the concrete file list on the blocking thread. For the + // directory form this is the walk that previously stalled the + // executor; the recursive=false-on-dir guard and the io errors + // keep the same S-codes they had on the executor. + let files: Vec = match source { + SedSource::Files(fs) => fs, + SedSource::Dir(root) => { + confine_path(&root, host_root_canon.as_deref(), &denylist_canon)?; + let root_anchored = lexical_operand_with(&root, host_root_canon.as_deref()); + let root_path = root_anchored.as_path(); + let _ = root_path + .symlink_metadata() + .map_err(|e| FsError::from_io(&root, e))?; + let target_is_dir = match std::fs::metadata(root_path) { + Ok(m) => m.is_dir(), + Err(e) => return Err(FsError::from_io(&root, e)), + }; + if target_is_dir && !recursive { + return Err(FsError::new( + "S210", + "recursive=false on a directory is unsupported; \ + pass a file path or set recursive=true", + )); + } + collect_files_to_sed(root_path, recursive, &include_glob, &exclude_glob) + .into_iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect() + } + }; + + // Per-file jail confinement, exactly as on the executor pre-fix: + // validate EVERY file (S210/S215 on the first violation) BEFORE + // touching any file, so a single bad path aborts the whole call + // with nothing written. + for f in &files { + confine_path(f, host_root_canon.as_deref(), &denylist_canon)?; + } + + // Anchor every file to its jail-relative operand (the operated-on + // path, not its canonical target — sed acts on the link itself). + let anchored: Vec<(String, PathBuf)> = files + .into_iter() + .map(|f| { + let p = lexical_operand_with(&f, host_root_canon.as_deref()); + (f, p) + }) + .collect(); + + let mut results: Vec = + Vec::with_capacity(anchored.len()); + let mut total: u64 = 0; + + for (file, anchored_path) in anchored { + let p = anchored_path.as_path(); + // Symlink operands are skipped: sed reads through the link + // (metadata/read_to_string follow it) but rewrites via + // rename(tmp, p), which would REPLACE the link with a regular + // file — silently destroying the link and detaching it from its + // target. Refuse rather than corrupt. (symlink_metadata does + // not follow, so this detects the link itself. Mirrors the + // chmod recursive walk's skip-symlinks policy.) + if let Ok(lmd) = std::fs::symlink_metadata(p) { + if lmd.file_type().is_symlink() { + results.push(crate::fs::wire::FsSedFileResult { + path: file.clone(), + replacements: 0, + success: false, + error: Some( + "operand is a symlink; skipped (sed would replace the link \ + with a regular file). Pass the resolved target path instead." + .to_string(), + ), + }); + continue; + } + } + // Size cap BEFORE reading: stat the file and skip (per-file + // error, nothing written) if it exceeds the read cap. Mirrors + // grep's OOM defense; sed previously read any size unconditionally. + match std::fs::metadata(p) { + Ok(md) if md.len() > read_cap => { + results.push(crate::fs::wire::FsSedFileResult { + path: file.clone(), + replacements: 0, + success: false, + error: Some(format!( + "file size {} exceeds read cap {} bytes; skipped", + md.len(), + read_cap + )), + }); + continue; + } + Ok(_) => {} + Err(e) => { + results.push(crate::fs::wire::FsSedFileResult { + path: file.clone(), + replacements: 0, + success: false, + error: Some(format!("{e}")), + }); + continue; + } + } + // Binary skip: reuse grep's heuristic. sed only does text + // replacement; rewriting a binary would corrupt it. + if looks_binary(p) { results.push(crate::fs::wire::FsSedFileResult { path: file.clone(), replacements: 0, success: false, - error: Some(format!("{e}")), + error: Some("binary file; skipped".to_string()), }); continue; } - }; - let mut replacements: u64 = 0; - let mut output = String::with_capacity(original.len()); - for line in original.split_inclusive('\n') { - let (new_line, n) = match &matcher { - Some(re) => { - let mut count_here = 0u64; - let produced = if req.first_only { - re.replacen(line, 1, |caps: ®ex::Captures| { - count_here += 1; - expand_regex_replacement(caps, &req.replacement) - }) - .into_owned() - } else { - re.replace_all(line, |caps: ®ex::Captures| { - count_here += 1; - expand_regex_replacement(caps, &req.replacement) - }) - .into_owned() - }; - (produced, count_here) + let original = match std::fs::read_to_string(p) { + Ok(s) => s, + Err(e) => { + results.push(crate::fs::wire::FsSedFileResult { + path: file.clone(), + replacements: 0, + success: false, + error: Some(format!("{e}")), + }); + continue; } - None => literal_replace_line( - line, - &req.pattern, - case_fold, - &req.replacement, - req.first_only, - ), }; - replacements += n; - output.push_str(&new_line); - } - let tmp = temp_sibling(p); - let write_result: Result<(), std::io::Error> = (|| { - let original_md = std::fs::metadata(p)?; - std::fs::write(&tmp, output.as_bytes())?; - std::fs::set_permissions( - &tmp, - std::fs::Permissions::from_mode(original_md.permissions().mode()), - )?; - std::fs::rename(&tmp, p)?; - Ok(()) - })(); - match write_result { - Ok(()) => { - total += replacements; - results.push(crate::fs::wire::FsSedFileResult { - path: file, - replacements, - success: true, - error: None, - }); + let mut replacements: u64 = 0; + let mut output = String::with_capacity(original.len()); + for line in original.split_inclusive('\n') { + let (new_line, n) = match &matcher { + Some(re) => { + let mut count_here = 0u64; + let produced = if first_only { + re.replacen(line, 1, |caps: ®ex::Captures| { + count_here += 1; + expand_regex_replacement(caps, &replacement) + }) + .into_owned() + } else { + re.replace_all(line, |caps: ®ex::Captures| { + count_here += 1; + expand_regex_replacement(caps, &replacement) + }) + .into_owned() + }; + (produced, count_here) + } + None => literal_replace_line( + line, + &pattern, + ci_matcher.as_ref(), + &replacement, + first_only, + ), + }; + replacements += n; + output.push_str(&new_line); } - Err(e) => { - let _ = std::fs::remove_file(&tmp); - results.push(crate::fs::wire::FsSedFileResult { - path: file, - replacements: 0, - success: false, - error: Some(format!("{e}")), - }); + let tmp = temp_sibling(p); + let write_result: Result<(), std::io::Error> = (|| { + let original_md = std::fs::metadata(p)?; + std::fs::write(&tmp, output.as_bytes())?; + std::fs::set_permissions( + &tmp, + std::fs::Permissions::from_mode(original_md.permissions().mode()), + )?; + std::fs::rename(&tmp, p)?; + Ok(()) + })(); + match write_result { + Ok(()) => { + total += replacements; + results.push(crate::fs::wire::FsSedFileResult { + path: file, + replacements, + success: true, + error: None, + }); + } + Err(e) => { + let _ = std::fs::remove_file(&tmp); + results.push(crate::fs::wire::FsSedFileResult { + path: file, + replacements: 0, + success: false, + error: Some(format!("{e}")), + }); + } } } - } + Ok((results, total)) + }); + + // JoinError -> S216; the inner Result carries the per-file + // validation / dir-walk S-codes (S210/S215/io). + let (results, total) = join + .await + .map_err(|e| FsError::new("S216", format!("sed task join failed: {e}")))??; Ok(crate::fs::SedResponse { results, total_replacements: total, @@ -994,6 +1409,7 @@ impl FsBackend for HostFsBackend { async fn write(&self, req: crate::fs::WriteArgs) -> FsCallResult { let p = self.validate_path(&req.path)?; let bits = crate::fs::error::parse_mode(&req.mode)?; + check_special_bits(bits, self.cfg.allow_special_bits)?; // Defense-in-depth: re-check parent against the precomputed // canonical root before creating intermediate directories. @@ -1034,47 +1450,73 @@ impl FsBackend for HostFsBackend { .map_err(|e| FsError::from_io(&req.path, e))?; let guard = TempGuard::new(temp.clone()); - let reader = - iii_sdk::channels::ChannelReader::new(&self.chan.engine_address(), &req.content); let cap = self.cfg.max_write_bytes; - let mut total: u64 = 0; - // Per-chunk idle timeout: if the caller opens a write but never sends - // data and never closes the channel, the worker would hold the temp - // file open indefinitely. N parked writers = resource exhaustion. - let idle = std::time::Duration::from_secs(30); - loop { - let next = match tokio::time::timeout(idle, reader.next_binary()).await { - Ok(r) => r, - Err(_) => { + let total: u64 = match &req.content { + // Inline path: the bytes are right here, no channel to drain. Enforce + // max_write_bytes up front so an oversize inline payload is rejected + // before it touches the temp file. + crate::fs::WriteContent::Inline(data) => { + let bytes = data.as_bytes(); + if cap > 0 && bytes.len() as u64 > cap as u64 { return Err(FsError::new( - "S216", - format!("channel idle for {}s — aborting write", idle.as_secs()), + "S218", + format!( + "inline write payload {} bytes exceeds max_write_bytes {}", + bytes.len(), + cap + ), )); } - }; - match next { - Ok(Some(chunk)) => { - let new_total = total + chunk.len() as u64; - if cap > 0 && new_total > cap as u64 { - return Err(FsError::new( - "S218", - format!( - "write payload exceeds max_write_bytes {} (after {} bytes)", - cap, total - ), - )); - } - if let Err(e) = file.write_all(&chunk).await { - return Err(FsError::from_io(&req.path, e)); + file.write_all(bytes) + .await + .map_err(|e| FsError::from_io(&req.path, e))?; + bytes.len() as u64 + } + // Streaming path: drain the caller's write channel chunk by chunk. + crate::fs::WriteContent::Stream(channel) => { + let reader = + iii_sdk::channels::ChannelReader::new(&self.chan.engine_address(), channel); + let mut total: u64 = 0; + // Per-chunk idle timeout: if the caller opens a write but never + // sends data and never closes the channel, the worker would hold + // the temp file open indefinitely. N parked writers = exhaustion. + let idle = std::time::Duration::from_secs(30); + loop { + let next = match tokio::time::timeout(idle, reader.next_binary()).await { + Ok(r) => r, + Err(_) => { + return Err(FsError::new( + "S216", + format!("channel idle for {}s — aborting write", idle.as_secs()), + )); + } + }; + match next { + Ok(Some(chunk)) => { + let new_total = total + chunk.len() as u64; + if cap > 0 && new_total > cap as u64 { + return Err(FsError::new( + "S218", + format!( + "write payload exceeds max_write_bytes {} (after {} bytes)", + cap, total + ), + )); + } + if let Err(e) = file.write_all(&chunk).await { + return Err(FsError::from_io(&req.path, e)); + } + total = new_total; + } + Ok(None) => break, + Err(e) => { + return Err(FsError::new("S216", format!("channel read failed: {e}"))); + } } - total = new_total; - } - Ok(None) => break, - Err(e) => { - return Err(FsError::new("S216", format!("channel read failed: {e}"))); } + total } - } + }; if let Err(e) = file.flush().await { return Err(FsError::from_io(&req.path, e)); @@ -1096,6 +1538,7 @@ impl FsBackend for HostFsBackend { Ok(crate::fs::WriteResponse { bytes_written: total, path: req.path, + files: Vec::new(), }) } async fn read(&self, req: crate::fs::ReadArgs) -> FsCallResult { @@ -1180,6 +1623,19 @@ mod tests { fn stub_backend(cfg: HostFsConfig) -> HostFsBackend { HostFsBackend::new(Arc::new(cfg), Arc::new(StubChan)) } + + #[test] + fn try_new_returns_err_on_unreachable_host_root() { + let cfg = Arc::new(HostFsConfig { + host_root: Some(PathBuf::from("/nonexistent/shell-jail-xyz")), + ..HostFsConfig::default() + }); + let chan: Arc = Arc::new(StubChan); + let res = HostFsBackend::try_new(cfg, chan); + assert!(res.is_err()); + assert_eq!(res.err().unwrap().code, "S216"); + } + fn stub_ref() -> iii_sdk::channels::StreamChannelRef { iii_sdk::channels::StreamChannelRef { channel_id: "c".into(), @@ -1330,7 +1786,7 @@ mod tests { }) .await .unwrap(); - assert_eq!(res.updated, 1); + assert_eq!(res.entries_changed, 1); let mode = fs::metadata(root.join("f.txt")) .unwrap() .permissions() @@ -1386,7 +1842,7 @@ mod tests { path: "/etc/shell-escape/nested".into(), mode: "0644".into(), parents: true, - content: stub_ref(), + content: crate::fs::WriteContent::Stream(stub_ref()), }) .await .unwrap_err(); @@ -1758,7 +2214,7 @@ mod tests { }) .await .unwrap(); - assert_eq!(resp.updated, 1); + assert_eq!(resp.entries_changed, 1); let perms = std::fs::metadata(&f).unwrap().permissions().mode() & 0o7777; assert_eq!(perms, 0o600); } @@ -1816,7 +2272,7 @@ mod tests { }) .await .unwrap(); - assert_eq!(resp.updated, 3); + assert_eq!(resp.entries_changed, 3); let leaf_perms = std::fs::metadata(tree.join("sub/leaf.txt")) .unwrap() .permissions() @@ -1932,7 +2388,7 @@ mod tests { path: "rel/path".into(), mode: "0644".into(), parents: false, - content: stub_ref(), + content: crate::fs::WriteContent::Stream(stub_ref()), }) .await .unwrap_err(); @@ -1947,7 +2403,7 @@ mod tests { path: "/tmp/shell-write-bad-mode".into(), mode: "not-octal".into(), parents: false, - content: stub_ref(), + content: crate::fs::WriteContent::Stream(stub_ref()), }) .await .unwrap_err(); @@ -1967,7 +2423,87 @@ mod tests { path: "/etc/shell-escape".into(), mode: "0644".into(), parents: false, - content: stub_ref(), + content: crate::fs::WriteContent::Stream(stub_ref()), + }) + .await + .unwrap_err(); + assert_eq!(err.code, "S215"); + } + + #[tokio::test] + async fn write_inline_string_creates_file_with_content_and_mode() { + use std::os::unix::fs::PermissionsExt; + let root = tmp(); + let cfg = HostFsConfig { + host_root: Some(root.clone()), + ..Default::default() + }; + let b = stub_backend(cfg); + // Inline content takes NO channel (StubChan's create_channel errors) — + // proving an agent can write a file with a plain string. + let resp = b + .write(crate::fs::WriteArgs { + path: "hello.txt".into(), + mode: "0644".into(), + parents: false, + content: crate::fs::WriteContent::Inline("hello world\n".into()), + }) + .await + .expect("inline write succeeds without a channel"); + assert_eq!(resp.bytes_written, 12); + assert_eq!(resp.path, "hello.txt"); + assert!(resp.files.is_empty(), "single write leaves files empty"); + assert_eq!( + fs::read_to_string(root.join("hello.txt")).unwrap(), + "hello world\n" + ); + let mode = fs::metadata(root.join("hello.txt")) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o644); + } + + #[tokio::test] + async fn write_inline_respects_max_write_bytes_cap() { + let root = tmp(); + let cfg = HostFsConfig { + host_root: Some(root.clone()), + max_write_bytes: 4, + ..Default::default() + }; + let b = stub_backend(cfg); + let err = b + .write(crate::fs::WriteArgs { + path: "big.txt".into(), + mode: "0644".into(), + parents: false, + content: crate::fs::WriteContent::Inline("too many bytes".into()), + }) + .await + .unwrap_err(); + assert_eq!(err.code, "S218"); + assert!( + !root.join("big.txt").exists(), + "an over-cap inline write must not leave a partial file" + ); + } + + #[tokio::test] + async fn write_inline_escaping_path_returns_s215() { + let root = tmp(); + let cfg = HostFsConfig { + host_root: Some(root.clone()), + ..Default::default() + }; + let b = stub_backend(cfg); + let err = b + .write(crate::fs::WriteArgs { + path: "../../etc/evil".into(), + mode: "0644".into(), + parents: false, + content: crate::fs::WriteContent::Inline("x".into()), }) .await .unwrap_err(); @@ -1988,7 +2524,7 @@ mod tests { path: target, mode: "0644".into(), parents: false, - content: stub_ref(), + content: crate::fs::WriteContent::Stream(stub_ref()), }) .await .unwrap_err(); @@ -2384,4 +2920,322 @@ mod tests { assert_eq!(resp.total_replacements, 1); assert_eq!(std::fs::read_to_string(&f).unwrap(), "$1 world\n"); } + + // --- glob matcher unit coverage (glob_match / glob_match_simple) --- + + #[test] + fn glob_double_star_slash_extension_matches_nested() { + // `**/*.rs` must match both a top-level and a deeply nested .rs file. + assert!(glob_matches_path("**/*.rs", "main.rs")); + assert!(glob_matches_path("**/*.rs", "a/b/c/lib.rs")); + assert!(!glob_matches_path("**/*.rs", "a/b/c/lib.txt")); + } + + #[test] + fn glob_question_mark_is_single_non_slash_char() { + assert!(glob_match_simple("a?c", "abc")); + assert!(!glob_match_simple("a?c", "ac")); + // `?` must not consume a path separator. + assert!(!glob_match_simple("a?c", "a/c")); + } + + #[test] + fn glob_pattern_with_slash_matches_full_relpath() { + // A pattern containing `/` is matched against the full relpath, not + // just the basename. + assert!(glob_matches_path("src/*.rs", "src/main.rs")); + assert!(!glob_matches_path("src/*.rs", "other/main.rs")); + // `*` does not cross `/`, so a nested file under src fails this glob. + assert!(!glob_matches_path("src/*.rs", "src/inner/main.rs")); + } + + #[test] + fn glob_double_star_catch_all_matches_anything() { + assert!(glob_matches_path("**", "anything")); + assert!(glob_matches_path("**", "a/b/c.txt")); + assert!(glob_match("**", "deep/nested/path")); + } + + // --- setuid/setgid/sticky bit rejection --- + + #[tokio::test] + async fn chmod_setuid_mode_rejected_by_default_s210() { + let root = tmp(); + let f = root.join("c.txt"); + fs::write(&f, b"x").unwrap(); + let cfg = HostFsConfig { + host_root: Some(root.clone()), + ..Default::default() + }; + let h = stub_backend(cfg); + let err = h + .chmod(crate::fs::ChmodArgs { + path: "c.txt".into(), + mode: "4755".into(), + uid: None, + gid: None, + recursive: false, + }) + .await + .unwrap_err(); + assert_eq!(err.code, "S210"); + assert!( + err.message.contains("allow_special_bits"), + "message should name the flag, got: {}", + err.message + ); + } + + #[tokio::test] + async fn chmod_setuid_mode_allowed_when_opted_in() { + use std::os::unix::fs::PermissionsExt; + let root = tmp(); + let f = root.join("c.txt"); + fs::write(&f, b"x").unwrap(); + let cfg = HostFsConfig { + host_root: Some(root.clone()), + allow_special_bits: true, + ..Default::default() + }; + let h = stub_backend(cfg); + let resp = h + .chmod(crate::fs::ChmodArgs { + path: "c.txt".into(), + mode: "4755".into(), + uid: None, + gid: None, + recursive: false, + }) + .await + .unwrap(); + assert_eq!(resp.entries_changed, 1); + let mode = fs::metadata(&f).unwrap().permissions().mode() & 0o7777; + assert_eq!(mode, 0o4755); + } + + #[tokio::test] + async fn mkdir_setgid_mode_rejected_by_default_s210() { + let root = tmp(); + let cfg = HostFsConfig { + host_root: Some(root.clone()), + ..Default::default() + }; + let h = stub_backend(cfg); + let err = h + .mkdir(crate::fs::MkdirArgs { + path: "newdir".into(), + mode: "2755".into(), + parents: false, + }) + .await + .unwrap_err(); + assert_eq!(err.code, "S210"); + assert!(!root.join("newdir").exists(), "dir must not be created"); + } + + #[tokio::test] + async fn mkdir_setgid_mode_allowed_when_opted_in() { + use std::os::unix::fs::PermissionsExt; + let root = tmp(); + let cfg = HostFsConfig { + host_root: Some(root.clone()), + allow_special_bits: true, + ..Default::default() + }; + let h = stub_backend(cfg); + let resp = h + .mkdir(crate::fs::MkdirArgs { + path: "newdir".into(), + mode: "2755".into(), + parents: false, + }) + .await + .unwrap(); + assert!(resp.created); + let mode = fs::metadata(root.join("newdir")) + .unwrap() + .permissions() + .mode() + & 0o7777; + assert_eq!(mode, 0o2755); + } + + #[tokio::test] + async fn write_sticky_mode_rejected_by_default_s210() { + let root = tmp(); + let cfg = HostFsConfig { + host_root: Some(root.clone()), + ..Default::default() + }; + let b = stub_backend(cfg); + let err = b + .write(crate::fs::WriteArgs { + path: "f.txt".into(), + mode: "1644".into(), + parents: false, + content: crate::fs::WriteContent::Stream(stub_ref()), + }) + .await + .unwrap_err(); + assert_eq!(err.code, "S210"); + assert!(err.message.contains("allow_special_bits")); + assert!(!root.join("f.txt").exists()); + } + + // --- regex / pattern length caps --- + + #[tokio::test] + async fn grep_over_long_pattern_rejected_fast() { + let root = tmp(); + fs::write(root.join("a.txt"), "x\n").unwrap(); + let h = stub_backend(HostFsConfig::default()); + let huge = "a".repeat(MAX_PATTERN_BYTES + 1); + let started = std::time::Instant::now(); + let err = h + .grep(crate::fs::GrepArgs { + path: root.to_str().unwrap().into(), + pattern: huge, + recursive: true, + ignore_case: false, + include_glob: vec![], + exclude_glob: vec![], + max_matches: 100, + max_line_bytes: 8192, + }) + .await + .unwrap_err(); + assert_eq!(err.code, "S210"); + assert!( + started.elapsed() < std::time::Duration::from_secs(1), + "over-long pattern must be rejected without a compile stall" + ); + } + + #[tokio::test] + async fn grep_normal_pattern_still_works_after_caps() { + let root = tmp(); + fs::write(root.join("a.txt"), "alpha\nbeta\n").unwrap(); + let h = stub_backend(HostFsConfig::default()); + let resp = h + .grep(crate::fs::GrepArgs { + path: root.to_str().unwrap().into(), + pattern: "alpha".into(), + recursive: true, + ignore_case: false, + include_glob: vec![], + exclude_glob: vec![], + max_matches: 100, + max_line_bytes: 8192, + }) + .await + .unwrap(); + assert_eq!(resp.matches.len(), 1); + assert_eq!(resp.matches[0].content, "alpha"); + } + + #[tokio::test] + async fn sed_over_long_pattern_rejected_fast() { + let root = tmp(); + let f = root.join("s.txt"); + fs::write(&f, "x\n").unwrap(); + let h = stub_backend(HostFsConfig::default()); + let huge = "a".repeat(MAX_PATTERN_BYTES + 1); + let started = std::time::Instant::now(); + let err = h + .sed(crate::fs::SedArgs { + files: vec![f.to_str().unwrap().into()], + path: None, + recursive: false, + include_glob: vec![], + exclude_glob: vec![], + pattern: huge, + replacement: "Y".into(), + regex: true, + first_only: false, + ignore_case: false, + }) + .await + .unwrap_err(); + assert_eq!(err.code, "S210"); + assert!(started.elapsed() < std::time::Duration::from_secs(1)); + // File must be untouched. + assert_eq!(fs::read_to_string(&f).unwrap(), "x\n"); + } + + /// Companion to `sed_over_long_pattern_rejected_fast`, which exercises the + /// `regex=true` path. The literal path (`regex=false`) uses the same + /// `check_pattern_len` cap; this asserts it independently so the + /// literal-mode huge-pattern branch can't regress unnoticed. + #[tokio::test] + async fn sed_over_long_literal_pattern_rejected_fast() { + let root = tmp(); + let f = root.join("s.txt"); + fs::write(&f, "x\n").unwrap(); + let h = stub_backend(HostFsConfig::default()); + let huge = "a".repeat(MAX_PATTERN_BYTES + 1); + let started = std::time::Instant::now(); + let err = h + .sed(crate::fs::SedArgs { + files: vec![f.to_str().unwrap().into()], + path: None, + recursive: false, + include_glob: vec![], + exclude_glob: vec![], + pattern: huge, + replacement: "Y".into(), + regex: false, + first_only: false, + ignore_case: false, + }) + .await + .unwrap_err(); + assert_eq!(err.code, "S210"); + assert!(started.elapsed() < std::time::Duration::from_secs(1)); + // File must be untouched. + assert_eq!(fs::read_to_string(&f).unwrap(), "x\n"); + } + + // --- jail escape via a LIVE (non-dangling) symlink whose target is + // outside the jail. The canonicalize + starts_with(host_root) gate in + // validate_path is the core security control; this exact vector was + // untested. We point host_root/escape at a real existing dir outside the + // jail and assert read/stat/ls all reject with S215. + + #[tokio::test] + async fn live_symlink_resolving_outside_jail_is_rejected_s215() { + use std::os::unix::fs::symlink; + let root = tmp(); + // /etc exists and is outside the jail. A non-dangling symlink whose + // target resolves there must be caught by the canonical jail check. + symlink("/etc", root.join("escape")).unwrap(); + let cfg = HostFsConfig { + host_root: Some(root.clone()), + ..Default::default() + }; + let b = stub_backend(cfg); + + let read_err = b + .read(crate::fs::ReadArgs { + path: "escape/hostname".into(), + }) + .await + .unwrap_err(); + assert_eq!(read_err.code, "S215", "read through escape symlink"); + + let stat_err = b + .stat(StatArgs { + path: "escape/hostname".into(), + }) + .await + .unwrap_err(); + assert_eq!(stat_err.code, "S215", "stat through escape symlink"); + + let ls_err = b + .ls(LsArgs { + path: "escape".into(), + }) + .await + .unwrap_err(); + assert_eq!(ls_err.code, "S215", "ls through escape symlink"); + } } diff --git a/shell/src/fs/mod.rs b/shell/src/fs/mod.rs index 4c55964e..ec5fac90 100644 --- a/shell/src/fs/mod.rs +++ b/shell/src/fs/mod.rs @@ -21,8 +21,11 @@ pub use crate::target::Target; /// of `shell::fs::write`/`read`. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)] pub struct ContentRef { + /// Opaque identifier for the open stream channel. pub channel_id: String, + /// Secret key that authorises access to this channel. pub access_key: String, + /// Direction of data flow: "read" (consume) or "write" (produce). #[serde(default)] pub direction: ContentDirection, } @@ -164,14 +167,21 @@ fn default_true() -> bool { true } -#[derive(Debug, Deserialize)] +/// Backend-side bytes to write: either inline UTF-8 text (the common +/// agent path, HOST only) or a streaming channel ref (host or sandbox, for +/// large/streamed payloads). +#[derive(Debug)] +pub enum WriteContent { + Inline(String), + Stream(iii_sdk::channels::StreamChannelRef), +} + +#[derive(Debug)] pub struct WriteArgs { pub path: String, - #[serde(default = "default_write_mode")] pub mode: String, - #[serde(default)] pub parents: bool, - pub content: iii_sdk::channels::StreamChannelRef, + pub content: WriteContent, } #[derive(Debug, Deserialize)] @@ -184,8 +194,10 @@ pub struct ReadArgs { #[derive(Debug, Deserialize, JsonSchema)] pub struct LsRequest { + /// host (default) or { kind: "sandbox", sandbox_id }. #[serde(default)] pub target: Target, + /// Jail-relative when fs.host_root is set, else absolute. pub path: String, } impl LsRequest { @@ -196,8 +208,10 @@ impl LsRequest { #[derive(Debug, Deserialize, JsonSchema)] pub struct StatRequest { + /// host (default) or { kind: "sandbox", sandbox_id }. #[serde(default)] pub target: Target, + /// Jail-relative when fs.host_root is set, else absolute. pub path: String, } impl StatRequest { @@ -208,11 +222,15 @@ impl StatRequest { #[derive(Debug, Deserialize, JsonSchema)] pub struct MkdirRequest { + /// host (default) or { kind: "sandbox", sandbox_id }. #[serde(default)] pub target: Target, + /// Jail-relative when fs.host_root is set, else absolute. pub path: String, + /// Octal permission string, e.g. "0755". #[serde(default = "default_mkdir_mode")] pub mode: String, + /// Create missing parent directories. #[serde(default)] pub parents: bool, } @@ -231,9 +249,12 @@ impl MkdirRequest { #[derive(Debug, Deserialize, JsonSchema)] pub struct RmRequest { + /// host (default) or { kind: "sandbox", sandbox_id }. #[serde(default)] pub target: Target, + /// Jail-relative when fs.host_root is set, else absolute. pub path: String, + /// Required to delete a non-empty directory. #[serde(default)] pub recursive: bool, } @@ -251,14 +272,20 @@ impl RmRequest { #[derive(Debug, Deserialize, JsonSchema)] pub struct ChmodRequest { + /// host (default) or { kind: "sandbox", sandbox_id }. #[serde(default)] pub target: Target, + /// Jail-relative when fs.host_root is set, else absolute. pub path: String, + /// Octal permission string, e.g. "0755". pub mode: String, + /// Optional chown to this numeric uid. #[serde(default)] pub uid: Option, + /// Optional chown to this numeric gid. #[serde(default)] pub gid: Option, + /// Apply mode/owner change to all files under the path recursively. #[serde(default)] pub recursive: bool, } @@ -279,10 +306,14 @@ impl ChmodRequest { #[derive(Debug, Deserialize, JsonSchema)] pub struct MvRequest { + /// host (default) or { kind: "sandbox", sandbox_id }. #[serde(default)] pub target: Target, + /// Source path; jail-relative when fs.host_root is set, else absolute. pub src: String, + /// Destination path; jail-relative when fs.host_root is set, else absolute. pub dst: String, + /// Replace an existing destination instead of returning an error. #[serde(default)] pub overwrite: bool, } @@ -301,20 +332,29 @@ impl MvRequest { #[derive(Debug, Deserialize, JsonSchema)] pub struct GrepRequest { + /// host (default) or { kind: "sandbox", sandbox_id }. #[serde(default)] pub target: Target, + /// Jail-relative when fs.host_root is set, else absolute. pub path: String, + /// Rust regex (RE2-like) matched against each line. pub pattern: String, + /// Descend into subdirectories (default true). #[serde(default = "default_true")] pub recursive: bool, + /// Match pattern case-insensitively. #[serde(default)] pub ignore_case: bool, + /// Glob filters restricting which file paths are searched. #[serde(default)] pub include_glob: Vec, + /// Glob filters excluding file paths from the search. #[serde(default)] pub exclude_glob: Vec, + /// Stop collecting matches after this many results (default 10 000). #[serde(default = "default_max_matches")] pub max_matches: u64, + /// Skip lines longer than this many bytes (default 4 096). #[serde(default = "default_max_line_bytes")] pub max_line_bytes: u64, } @@ -338,24 +378,35 @@ impl GrepRequest { #[derive(Debug, Deserialize, JsonSchema)] pub struct SedRequest { + /// host (default) or { kind: "sandbox", sandbox_id }. #[serde(default)] pub target: Target, + /// Explicit list of file paths to edit; provide either this or `path`, not both. #[serde(default)] pub files: Vec, + /// Root path to walk for files; used with `recursive`, `include_glob`, `exclude_glob`. #[serde(default)] pub path: Option, + /// Descend into subdirectories when `path` is set (default true). #[serde(default = "default_true")] pub recursive: bool, + /// Glob filters restricting which file paths are edited. #[serde(default)] pub include_glob: Vec, + /// Glob filters excluding file paths from editing. #[serde(default)] pub exclude_glob: Vec, + /// Rust regex by default; set regex:false for a literal string. pub pattern: String, + /// String to substitute for each match. pub replacement: String, + /// Treat pattern as a regex (default true) or a literal string (false). #[serde(default = "default_true")] pub regex: bool, + /// Replace only the first match per file instead of all matches. #[serde(default)] pub first_only: bool, + /// Match pattern case-insensitively. #[serde(default)] pub ignore_case: bool, } @@ -379,35 +430,141 @@ impl SedRequest { } } +/// Wire form of write content. A plain JSON **string** is written inline (host +/// target only); a JSON **object** is a streaming `ContentRef` (host or sandbox, +/// for large/streamed payloads). Untagged, so callers just pass `"content": +/// "text"` or `"content": { channel_id, access_key, direction }`. +#[derive(Debug, Clone, Deserialize, JsonSchema)] +#[serde(untagged)] +pub enum WriteContentWire { + /// Inline UTF-8 text, written verbatim. HOST target only. + Inline(String), + /// Open write-stream channel ref. Required for sandbox targets and large + /// or streamed payloads. + Stream(ContentRef), +} +impl From for WriteContent { + fn from(w: WriteContentWire) -> Self { + match w { + WriteContentWire::Inline(s) => WriteContent::Inline(s), + WriteContentWire::Stream(r) => WriteContent::Stream(r.into()), + } + } +} + +/// One file in a batch `shell::fs::write` (`files: [...]`). #[derive(Debug, Deserialize, JsonSchema)] -pub struct WriteRequest { - #[serde(default)] - pub target: Target, +pub struct WriteFileSpec { + /// Jail-relative when fs.host_root is set, else absolute. pub path: String, + /// Inline string (recommended) or a streaming ContentRef. + pub content: WriteContentWire, + /// Octal permission string, e.g. "0644". #[serde(default = "default_write_mode")] pub mode: String, + /// Create missing parent directories. #[serde(default)] pub parents: bool, - pub content: ContentRef, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct WriteRequest { + /// host (default) or { kind: "sandbox", sandbox_id }. + #[serde(default)] + pub target: Target, + /// Single-file form: the path to write. Jail-relative when fs.host_root is + /// set, else absolute. Omit when using `files`. + #[serde(default)] + pub path: Option, + /// Single-file content: a plain string written inline (host target only), + /// OR a ContentRef { channel_id, access_key, direction } for an open write + /// stream channel. Omit when using `files`. + #[serde(default)] + pub content: Option, + /// Octal permission string for the single-file form, e.g. "0644". + /// `Option` (not a defaulted `String`) so the batch-form check can tell + /// "caller omitted mode" from "caller sent a mode" and reject the latter + /// instead of silently dropping it. Defaults to "0644" in the single-file + /// path. Omit when using `files` (each entry carries its own `mode`). + #[serde(default)] + pub mode: Option, + /// Create missing parent directories (single-file form). `Option` for the + /// same reason as `mode` — so a top-level `parents` sent alongside `files` + /// is rejected, not ignored. Defaults to `false`. Omit when using `files`. + #[serde(default)] + pub parents: Option, + /// Batch form: write several files in one call. When present, the + /// single-file fields (`path`/`content`/`mode`/`parents`) must be omitted. + #[serde(default)] + pub files: Option>, } impl WriteRequest { - pub fn split(self) -> (Target, WriteArgs) { - ( - self.target, - WriteArgs { - path: self.path, - mode: self.mode, - parents: self.parents, - content: self.content.into(), - }, - ) + /// Normalize into `(target, specs, is_batch)`. `is_batch` is true when the + /// caller used the `files: [...]` form — the handler then returns the + /// per-file `files` response shape EVEN for a single entry, so a caller that + /// sends `files` always gets `files` back. The single-file form + /// (`path`+`content`) returns the flat `{ bytes_written, path }` shape. + /// Rejects (S210) an ambiguous request (both forms) or an empty `files`. + pub fn into_specs(self) -> Result<(Target, Vec, bool), FsError> { + if let Some(files) = self.files { + if self.path.is_some() + || self.content.is_some() + || self.mode.is_some() + || self.parents.is_some() + { + return Err(FsError::new( + "S210", + "provide either the single-file fields (`path`,`content`,`mode`,`parents`) \ + or a `files` array, not both — each `files` entry carries its own \ + `mode`/`parents`", + )); + } + if files.is_empty() { + return Err(FsError::new( + "S210", + "`files` is empty; provide at least one { path, content } entry", + )); + } + let specs = files + .into_iter() + .map(|f| WriteArgs { + path: f.path, + mode: f.mode, + parents: f.parents, + content: f.content.into(), + }) + .collect(); + Ok((self.target, specs, true)) + } else { + let path = self + .path + .ok_or_else(|| FsError::new("S210", "missing `path` (or pass a `files` array)"))?; + let content = self.content.ok_or_else(|| { + FsError::new( + "S210", + "missing `content` (a string to write inline, or a ContentRef)", + ) + })?; + Ok(( + self.target, + vec![WriteArgs { + path, + mode: self.mode.unwrap_or_else(default_write_mode), + parents: self.parents.unwrap_or(false), + content: content.into(), + }], + false, + )) + } } } #[derive(Debug, Deserialize, JsonSchema)] pub struct ReadRequest { + /// host (default) or { kind: "sandbox", sandbox_id }. #[serde(default)] pub target: Target, + /// Jail-relative when fs.host_root is set, else absolute. pub path: String, } impl ReadRequest { @@ -418,6 +575,7 @@ impl ReadRequest { #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct LsResponse { + /// Metadata for each entry in the directory. pub entries: Vec, } #[derive(Debug, Serialize, Deserialize, JsonSchema)] @@ -425,34 +583,92 @@ pub struct LsResponse { pub struct StatResponse(pub FsEntry); #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct MkdirResponse { + /// True when a new directory was created; false when it already existed + /// (only possible with `parents: true`, which is idempotent). pub created: bool, + /// The directory path that was targeted. Empty for sandbox targets. + #[serde(default)] + pub path: String, + /// True when the path already existed and `parents` was set. Host only; + /// sandbox targets default this to false (not a signal there). + #[serde(default)] + pub already_existed: bool, } #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct RmResponse { + /// True when the path was removed. pub removed: bool, + /// The path that was targeted. Empty for sandbox targets. + #[serde(default)] + pub path: String, + /// True when the path existed before removal. Host only; sandbox targets + /// default this to false, which does NOT mean the path was absent. + #[serde(default)] + pub was_present: bool, } #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct ChmodResponse { - pub updated: u64, + /// Number of filesystem entries whose mode/owner changed. + #[serde(alias = "updated")] + pub entries_changed: u64, + /// The path that was targeted. Empty for sandbox targets. + #[serde(default)] + pub path: String, + /// Whether the change was applied recursively. Host only. + #[serde(default)] + pub recursive: bool, } #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct MvResponse { + /// True when the move/rename succeeded. pub moved: bool, + /// Source path. Empty for sandbox targets. + #[serde(default)] + pub src: String, + /// Destination path. Empty for sandbox targets. + #[serde(default)] + pub dst: String, + /// True when an existing destination was overwritten. Host only (sandbox + /// targets default this to false, not a signal there). + /// Best-effort: derived from a pre-rename existence check, so under a + /// concurrent writer racing the destination it may under-report (and an + /// `overwrite:false` move can still replace a file created in that window). + #[serde(default)] + pub overwrote: bool, } #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct GrepResponse { + /// All collected match locations up to `max_matches`. pub matches: Vec, + /// True when the result was capped by `max_matches` or `max_line_bytes`. pub truncated: bool, } #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct SedResponse { + /// Per-file replacement details. pub results: Vec, + /// Sum of replacements made across all files. pub total_replacements: u64, } +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct WriteFileResult { + /// Path of the written file. + pub path: String, + /// Bytes written to this file. + pub bytes_written: u64, +} + #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct WriteResponse { + /// Bytes written: this file for a single write; the sum across `files` for + /// a batch write. pub bytes_written: u64, + /// Path written for a single write; empty for a batch (see `files`). pub path: String, + /// Per-file results for a batch (`files: [...]`) write; empty for a + /// single-file write. + #[serde(default)] + pub files: Vec, } /// Backend-internal read result, holding the SDK's `StreamChannelRef` @@ -468,9 +684,13 @@ pub struct ReadResponse { #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct ReadResponseWire { + /// Channel reference for streaming the file content back to the caller. pub content: ContentRef, + /// File size in bytes at the time of the read. pub size: u64, + /// Octal permission string of the file, e.g. "0644". pub mode: String, + /// Last-modified time as a Unix timestamp (seconds). pub mtime: i64, } impl From for ReadResponseWire { @@ -554,18 +774,114 @@ mod tests { } #[test] - fn write_args_mode_defaults_0644() { - let r: WriteArgs = serde_json::from_value(serde_json::json!({ + fn write_request_single_defaults_mode_0644_and_parses_contentref() { + let req: WriteRequest = serde_json::from_value(serde_json::json!({ "path":"/x", - "content": { - "channel_id": "c-1", - "access_key": "k-1", - "direction": "read" - } + "content": { "channel_id": "c-1", "access_key": "k-1", "direction": "read" } + })) + .unwrap(); + let (_t, specs, _batch) = req.into_specs().unwrap(); + assert_eq!(specs.len(), 1); + assert_eq!(specs[0].mode, "0644"); + match &specs[0].content { + WriteContent::Stream(r) => assert_eq!(r.channel_id, "c-1"), + other => panic!("expected stream content, got {other:?}"), + } + } + + #[test] + fn write_request_inline_string_content() { + let req: WriteRequest = serde_json::from_value(serde_json::json!({ + "path": "/x", "content": "hello world" + })) + .unwrap(); + let (_t, specs, _batch) = req.into_specs().unwrap(); + assert_eq!(specs.len(), 1); + match &specs[0].content { + WriteContent::Inline(s) => assert_eq!(s, "hello world"), + other => panic!("expected inline content, got {other:?}"), + } + } + + #[test] + fn write_request_batch_files() { + let req: WriteRequest = serde_json::from_value(serde_json::json!({ + "files": [ + {"path": "/a", "content": "A"}, + {"path": "/b", "content": "B", "mode": "0600"} + ] + })) + .unwrap(); + let (_t, specs, batch) = req.into_specs().unwrap(); + assert!(batch, "files form must be flagged as batch"); + assert_eq!(specs.len(), 2); + assert_eq!(specs[0].path, "/a"); + assert_eq!(specs[1].mode, "0600"); + } + + #[test] + fn write_request_single_form_is_not_batch() { + // A single-element `files` array is still the batch form (returns the + // `files` response shape); a flat path+content is the single form. + let single: WriteRequest = + serde_json::from_value(serde_json::json!({"path": "/x", "content": "y"})).unwrap(); + let (_t, _s, batch) = single.into_specs().unwrap(); + assert!(!batch, "flat path+content is the single form"); + let one: WriteRequest = + serde_json::from_value(serde_json::json!({"files": [{"path": "/x", "content": "y"}]})) + .unwrap(); + let (_t, specs, batch) = one.into_specs().unwrap(); + assert!(batch, "single-element files[] is still the batch form"); + assert_eq!(specs.len(), 1); + } + + #[test] + fn write_request_rejects_both_single_and_files() { + let req: WriteRequest = serde_json::from_value(serde_json::json!({ + "path": "/a", "content": "A", + "files": [{"path":"/b","content":"B"}] })) .unwrap(); - assert_eq!(r.mode, "0644"); - assert_eq!(r.content.channel_id, "c-1"); + assert_eq!(req.into_specs().unwrap_err().code, "S210"); + } + + #[test] + fn write_request_rejects_missing_content() { + let req: WriteRequest = serde_json::from_value(serde_json::json!({"path":"/a"})).unwrap(); + assert_eq!(req.into_specs().unwrap_err().code, "S210"); + } + + #[test] + fn write_request_batch_rejects_top_level_mode_or_parents() { + // Top-level mode/parents alongside `files` were silently dropped before + // — each entry carries its own. Reject (S210) so the mismatch surfaces + // instead of masking a caller bug. + let with_mode: WriteRequest = serde_json::from_value(serde_json::json!({ + "mode": "0600", + "files": [{"path":"/b","content":"B"}] + })) + .unwrap(); + assert_eq!(with_mode.into_specs().unwrap_err().code, "S210"); + + let with_parents: WriteRequest = serde_json::from_value(serde_json::json!({ + "parents": true, + "files": [{"path":"/b","content":"B"}] + })) + .unwrap(); + assert_eq!(with_parents.into_specs().unwrap_err().code, "S210"); + } + + #[test] + fn write_request_single_form_defaults_mode_and_parents() { + // Omitted mode/parents still default to "0644"/false in the flat form. + let req: WriteRequest = serde_json::from_value(serde_json::json!({ + "path": "/x", "content": "hi" + })) + .unwrap(); + let (_t, specs, batch) = req.into_specs().unwrap(); + assert!(!batch); + assert_eq!(specs[0].mode, "0644"); + assert!(!specs[0].parents); } #[test] @@ -589,13 +905,43 @@ mod tests { } #[test] - fn write_request_splits_contentref_to_streamchannelref() { + fn write_request_contentref_normalizes_to_stream() { let req: WriteRequest = serde_json::from_value(serde_json::json!({ "path": "/x", "content": {"channel_id": "c", "access_key": "k", "direction": "read"}, })) .unwrap(); - let (_t, args) = req.split(); - assert_eq!(args.content.channel_id, "c"); + let (_t, specs, _batch) = req.into_specs().unwrap(); + match &specs[0].content { + WriteContent::Stream(r) => assert_eq!(r.channel_id, "c"), + other => panic!("expected stream content, got {other:?}"), + } + } + + #[test] + fn mkdir_response_deserializes_legacy_engine_shape() { + // Sandbox round-trip: engine returns only `created`; new fields default. + let r: MkdirResponse = + serde_json::from_value(serde_json::json!({"created": true})).unwrap(); + assert!(r.created); + assert!(!r.already_existed); + assert_eq!(r.path, ""); + } + + #[test] + fn chmod_response_accepts_legacy_updated_key() { + // Engine returns `updated`; alias maps it onto `entries_changed`. + let r: ChmodResponse = serde_json::from_value(serde_json::json!({"updated": 5})).unwrap(); + assert_eq!(r.entries_changed, 5); + } + + #[test] + fn mv_and_rm_responses_deserialize_legacy_shape() { + let m: MvResponse = serde_json::from_value(serde_json::json!({"moved": true})).unwrap(); + assert!(m.moved); + assert!(!m.overwrote); + let r: RmResponse = serde_json::from_value(serde_json::json!({"removed": true})).unwrap(); + assert!(r.removed); + assert!(!r.was_present); } } diff --git a/shell/src/fs/sandbox.rs b/shell/src/fs/sandbox.rs index 422dac14..2d39b45e 100644 --- a/shell/src/fs/sandbox.rs +++ b/shell/src/fs/sandbox.rs @@ -4,7 +4,6 @@ //! passthroughs. use async_trait::async_trait; -use iii_sdk::IIIError; use serde_json::{json, Value}; use std::sync::Arc; use uuid::Uuid; @@ -58,99 +57,12 @@ impl SandboxFsBackend { .fwd .trigger(function_id, payload) .await - .map_err(map_iii_err)?; + .map_err(|e| crate::scode::map_iii_err(&e, FsError::new))?; serde_json::from_value(resp) .map_err(|e| FsError::new("S216", format!("bad engine response: {e}"))) } } -/// Recover an S2xx code from an `IIIError`. Engine `Remote` errors carry -/// the code structurally; the engine's `invocation_failed` wrapper hides -/// it inside the message string; mock paths emit a JSON payload as -/// `Handler(string)`. Unknown shapes fall through to S216. -fn map_iii_err(err: IIIError) -> FsError { - match &err { - IIIError::Remote { code, message, .. } if code.starts_with('S') => { - return FsError::new( - map_static_code(code), - format!("forwarded from engine: {message}"), - ); - } - IIIError::Remote { message, .. } => { - if let Some(c) = scan_s_code(message) { - return FsError::new( - map_static_code(c), - format!("forwarded from engine (wrapped): {message}"), - ); - } - } - IIIError::Handler(s) => { - if let Ok(parsed) = serde_json::from_str::(s) { - if let Some(c) = parsed.get("code").and_then(|v| v.as_str()) { - let msg = parsed.get("message").and_then(|v| v.as_str()).unwrap_or(""); - return FsError::new( - map_static_code(c), - format!("forwarded from engine: {msg}"), - ); - } - } - if let Some(c) = scan_s_code(s) { - return FsError::new( - map_static_code(c), - format!("forwarded from engine (raw): {s}"), - ); - } - } - _ => {} - } - FsError::new("S216", format!("engine error: {err:?}")) -} - -fn scan_s_code(s: &str) -> Option<&str> { - let bytes = s.as_bytes(); - // The window is 4 bytes (`bytes[i..=i+3]`), so the last valid `i` is - // `len - 4`. The loop bound `< len - 3` gives `i <= len - 4`. - // `saturating_sub(3)` collapses to 0 when `len < 4`, which yields an - // empty range (no false access) on too-short inputs. - for i in 0..bytes.len().saturating_sub(3) { - if bytes[i] == b'S' - && bytes[i + 1].is_ascii_digit() - && bytes[i + 2].is_ascii_digit() - && bytes[i + 3].is_ascii_digit() - { - // Reject matches preceded by alphanumerics so we don't grab - // the tail of a longer identifier (e.g. "FOO_S211"). - let preceded_by_word = - i > 0 && (bytes[i - 1].is_ascii_alphanumeric() || bytes[i - 1] == b'_'); - if !preceded_by_word { - return Some(&s[i..i + 4]); - } - } - } - None -} - -fn map_static_code(code: &str) -> &'static str { - match code { - "S001" => "S001", - "S002" => "S002", - "S003" => "S003", - "S004" => "S004", - "S200" => "S200", - "S210" => "S210", - "S211" => "S211", - "S212" => "S212", - "S213" => "S213", - "S214" => "S214", - "S215" => "S215", - "S216" => "S216", - "S217" => "S217", - "S218" => "S218", - "S219" => "S219", - _ => "S216", - } -} - #[async_trait] impl FsBackend for SandboxFsBackend { async fn ls(&self, req: LsArgs) -> FsCallResult { @@ -221,13 +133,25 @@ impl FsBackend for SandboxFsBackend { .await } async fn write(&self, req: WriteArgs) -> FsCallResult { + // The sandbox exec/fs protocol moves bytes via a stream channel, so a + // sandbox write needs a ContentRef. Inline string content is host-only. + let content = match req.content { + crate::fs::WriteContent::Stream(channel) => crate::fs::ContentRef::from(channel), + crate::fs::WriteContent::Inline(_) => { + return Err(crate::fs::error::FsError::new( + "S210", + "inline string content is host-only; for a sandbox target open a write \ + stream channel and pass its ContentRef as `content`, or use target: host", + )); + } + }; self.dispatch( "sandbox::fs::write", json!({ "path": req.path, "mode": req.mode, "parents": req.parents, - "content": req.content, + "content": content, }), ) .await @@ -242,6 +166,7 @@ impl FsBackend for SandboxFsBackend { #[cfg(test)] mod tests { use super::*; + use iii_sdk::IIIError; use std::sync::Mutex; struct StubFwd { @@ -330,7 +255,7 @@ mod tests { path: "/sb/x".into(), mode: "0644".into(), parents: false, - content: content.clone(), + content: crate::fs::WriteContent::Stream(content.clone()), }) .await .unwrap(); @@ -347,6 +272,30 @@ mod tests { assert_eq!(obj["content"]["direction"].as_str(), Some("read")); } + #[tokio::test] + async fn write_rejects_inline_content_on_sandbox_with_s210() { + let stub = Arc::new(StubFwd { + captured: Mutex::new(None), + respond_with: Ok(json!({ "bytes_written": 0, "path": "" })), + }); + let b = SandboxFsBackend::new(stub.clone(), true, Uuid::new_v4()); + let err = b + .write(WriteArgs { + path: "/sb/x".into(), + mode: "0644".into(), + parents: false, + content: crate::fs::WriteContent::Inline("hello".into()), + }) + .await + .expect_err("inline content on a sandbox target must reject"); + assert_eq!(err.code, "S210"); + assert!(err.message.contains("host-only"), "got: {}", err.message); + assert!( + stub.captured.lock().unwrap().is_none(), + "rejected inline write must not forward to the engine" + ); + } + #[tokio::test] async fn read_forwards_path_only_and_returns_engine_response() { let stub = Arc::new(StubFwd { diff --git a/shell/src/functions/exec.rs b/shell/src/functions/exec.rs index 4b289596..d1fc66d2 100644 --- a/shell/src/functions/exec.rs +++ b/shell/src/functions/exec.rs @@ -2,14 +2,15 @@ use std::sync::Arc; use crate::config::ShellConfig; use crate::exec::host::parse_argv; -use crate::exec_dispatch::{err_to_string, pick_exec_backend}; +use crate::exec::policy::build_overrides; +use crate::exec_dispatch::pick_exec_backend; use crate::functions::types::{ExecRequest, ExecResponse}; pub async fn handle( cfg: Arc, iii: iii_sdk::III, req: ExecRequest, -) -> Result { +) -> Result { // Field-level type errors (wrong-type `command`, non-string `args[i]`, // bad `target.kind`) come from the per-field deserializers in // `functions::types`; they surface here as the trigger `Err` carrying @@ -19,15 +20,34 @@ pub async fn handle( // Some(_) → use args verbatim, even if empty // The typed-schema migration must NOT collapse "absent args" into // "args: []" or callers lose the shell-words path. + // argv-parse and allowlist/denylist rejections are plain Strings with no + // S-code; via `From for IIIError` they become the engine's + // `invocation_failed` envelope, message naming the violation. Only the + // backend `ExecError` below carries an S-code, surfaced as the wire `code` + // through `From for IIIError` (Remote) so an agent can branch + // on `error.code`. let argv = parse_argv(&req.command, req.args.as_ref()).map_err(|e| format!("argv: {}", e))?; cfg.is_command_allowed(&argv)?; + // Gate the per-call cwd/env BEFORE picking a backend. A jail-escaping cwd + // (S215) or an env key outside allowed_env / in DANGEROUS_ENV_KEYS (S210) + // rejects here, carrying the S-code to the wire via From. The + // sandbox backend additionally rejects any populated override (host-only). + let mut overrides = build_overrides(req.cwd.as_deref(), req.env.as_ref(), &cfg) + .map_err(iii_sdk::IIIError::from)?; + // stdin needs no gating (opaque input bytes); it is host-only, enforced by + // the sandbox backend's is_empty() rejection of any populated override. + overrides.stdin = req.stdin; + let timeout = cfg.resolve_timeout(req.timeout_ms); let backend = pick_exec_backend(req.target, cfg, iii); - let out = backend.run(&argv, timeout).await.map_err(err_to_string)?; + let out = backend + .run(&argv, timeout, &overrides) + .await + .map_err(iii_sdk::IIIError::from)?; Ok(ExecResponse::from(out)) } diff --git a/shell/src/functions/exec_bg.rs b/shell/src/functions/exec_bg.rs index 0f66a5e8..22ea1048 100644 --- a/shell/src/functions/exec_bg.rs +++ b/shell/src/functions/exec_bg.rs @@ -4,6 +4,7 @@ use uuid::Uuid; use crate::config::ShellConfig; use crate::exec::host::{build_command, parse_argv}; +use crate::exec::policy::{build_overrides, ExecOverrides}; use crate::exec::sandbox::SandboxExecResponse; use crate::functions::types::{ExecBgRequest, ExecBgResponse}; use crate::jobs::{self, JobHandle, JobRecord, JobStatus}; @@ -11,6 +12,18 @@ use crate::target::Target; use crate::triggers::{IiiTriggerFwd, TriggerFwd}; use tokio::io::AsyncReadExt; +/// Grace for joining a host job's stdout/stderr drain tasks after the child has +/// exited. A process-group kill closes the pipes so the drains finish at once; +/// this only bounds the pathological case where the direct child exited but a +/// grandchild inherited and still holds the pipe — there the job finalizes +/// (freeing its slot) instead of wedging forever. +const DRAIN_GRACE_MS: u64 = 10_000; + +/// Slack added to a sandbox job's in-VM `timeout_ms` to form the client-side RPC +/// deadline. The engine should answer within timeout_ms; the slack covers +/// transport + queueing before we declare it unresponsive and finalize the job. +const SANDBOX_RPC_SLACK_MS: u64 = 30_000; + pub async fn handle( cfg: Arc, iii: iii_sdk::III, @@ -26,12 +39,36 @@ pub async fn handle( cfg.is_command_allowed(&argv)?; + // Gate the per-call cwd/env up front, BEFORE branching on target. Same + // rules as shell::exec (jail-confined cwd, allowed_env + dangerous-key env + // gating). exec_bg returns its spawn-time failures as plain strings (its + // documented contract), so we stringify the S-code into the message — the + // agent still sees the code (e.g. "S215") and the self-correcting text. + let mut overrides = build_overrides(req.cwd.as_deref(), req.env.as_ref(), &cfg) + .map_err(|e| format!("{}: {}", e.code, e.message))?; + // stdin needs no gating (opaque input bytes); host-only via is_empty(). + overrides.stdin = req.stdin; + match req.target { - Target::Host => spawn_host_job(cfg, argv).await, + Target::Host => spawn_host_job(cfg, argv, overrides).await, Target::Sandbox { sandbox_id } => { - // Resolve+clamp timeout for the sandbox path; host path - // ignores timeout_ms (preserves today's unbounded host-bg - // semantics — documented in README "Caveats"). + // cwd/env/stdin are host-only — the sandbox::exec payload does not + // forward them. Reject loudly (S210 in the message) rather than + // silently ignoring an override on a sandbox-targeted job. + // `is_empty()` is false when ANY of cwd/env/stdin is set, so the + // message must name all three or it sends `stdin` callers down the + // wrong correction path. + if !overrides.is_empty() { + return Err( + "S210: cwd/env/stdin overrides are host-only; the sandbox exec protocol \ + does not forward them. Drop cwd/env/stdin, or use target: host." + .to_string(), + ); + } + // Resolve+clamp timeout for the sandbox path. The host path ignores + // the per-call timeout_ms but is no longer unbounded: spawn_host_job + // bounds the detached wait by cfg.max_timeout_ms (the hard cap), so a + // runaway host bg job is killed and finalized rather than leaking. let resolved = cfg.resolve_timeout(req.timeout_ms); let fwd: Arc = Arc::new(IiiTriggerFwd::new(iii)); spawn_sandbox_job(cfg, fwd, sandbox_id, argv, resolved).await @@ -39,12 +76,20 @@ pub async fn handle( } } -async fn spawn_host_job( +pub(crate) async fn spawn_host_job( cfg: Arc, argv: Vec, + overrides: ExecOverrides, ) -> Result { - let mut cmd = build_command(&argv, &cfg)?; + let mut cmd = build_command(&argv, &cfg, &overrides)?; let mut child = cmd.spawn().map_err(|e| format!("spawn: {}", e))?; + crate::exec::host::pump_stdin(&mut child, &overrides.stdin); + + // Capture the OS pid NOW, before the detached drain task takes the `Child` + // out of the handle. Once the task owns the Child, `JobHandle.child` is None + // even while the process is alive, so this pid is the only way shell::kill and + // the shutdown sweep can still signal the live host process. + let host_pid = child.id(); let stdout_pipe = child.stdout.take(); let stderr_pipe = child.stderr.take(); @@ -65,33 +110,64 @@ async fn spawn_host_job( // Atomic check-and-insert: prevents a TOCTOU where two concurrent // exec_bg calls both pass a separate running_count() check before // either insert lands. On rejection, kill the orphaned child. - match jobs::try_reserve_and_insert( + let running_guard = match jobs::try_reserve_and_insert( JobHandle { record, child: Some(child), + host_pid, }, cfg.max_concurrent_jobs, ) .await { - Ok(_) => {} + Ok((_, guard)) => guard, Err((running, mut handle)) => { if let Some(mut ch) = handle.child.take() { + // The child was spawned with its own process group + // (process_group(0)), and a long-running command may have + // already forked descendants before we lost the slot race. + // start_kill() only signals the direct leader, leaking the + // group — so SIGKILL the whole group. Safe to signal by pid + // here: we still own the un-reaped Child, so the pid cannot + // have been recycled. THEN reap with wait() to release it. + crate::exec::host::kill_process_group(ch.id()); let _ = ch.start_kill(); + let _ = ch.wait().await; } return Err(format!( "max concurrent jobs ({}) reached, currently running: {}", cfg.max_concurrent_jobs, running )); } - } + }; let id_clone = id.clone(); let limit = cfg.max_output_bytes; + // Host bg hard cap, separate from the foreground `max_timeout_ms`: a bg job + // is how callers run long work (installs, builds, dev servers), so binding + // it to the short foreground cap would kill legitimate jobs. `0` means + // UNBOUNDED — run until exit or shell::kill. A positive value force-kills a + // runaway job. Snapshotting at spawn is intentional: a later hot-reload does + // not retroactively re-bound an already-running job; new jobs pick up the + // new cap on their next spawn. + let cap_ms = cfg.max_bg_timeout_ms; + // Register the kill-signal channel BEFORE spawning the drain task so a + // shell::kill (or shutdown sweep) arriving immediately can request + // termination. The drain task below owns the un-reaped Child and is the ONLY + // code that signals the process — so a kill request can never land on a pid + // that wait() already reaped and the OS reused. + let kill_notify = jobs::register_kill_signal(&id); tokio::spawn(async move { + // Owns this job's contribution to the shell.jobs.running gauge: dropped + // when this task ends (any path — completion, early return, or panic), + // which is exactly when the job leaves the Running state. + let _running_guard = running_guard; let handle = match jobs::get(&id_clone).await { Some(h) => h, - None => return, + None => { + jobs::unregister_kill_signal(&id_clone); + return; + } }; // Drain stdout/stderr concurrently — sequential reads deadlock @@ -114,45 +190,98 @@ async fn spawn_host_job( }) }); - let (stdout_buf, stdout_trunc) = match stdout_task { - Some(t) => t.await.unwrap_or_else(|_| (Vec::new(), false)), - None => (Vec::new(), false), - }; - let (stderr_buf, stderr_trunc) = match stderr_task { - Some(t) => t.await.unwrap_or_else(|_| (Vec::new(), false)), - None => (Vec::new(), false), + // Take ownership of the Child so we can wait()/kill it without holding + // the handle lock across an await. + let child = { + let mut h = handle.lock().await; + h.child.take() }; - { - let mut h = handle.lock().await; - if let Some(mut ch) = h.child.take() { - drop(h); - let wait_res = ch.wait().await; - let mut h2 = handle.lock().await; - match wait_res { - Ok(s) => { - h2.record.exit_code = s.code(); - if h2.record.status == JobStatus::Running { - h2.record.status = if s.success() { - JobStatus::Finished - } else { - JobStatus::Failed - }; - } + // Race the child's exit against (a) the hard cap and (b) an external kill + // request (shell::kill / shutdown sweep, delivered via `kill_notify`). On + // either trigger we kill the child's WHOLE process group while we still + // own the un-reaped Child — so the pid/pgid cannot have been reused and + // grandchildren die too — then loop back to reap via wait(). The `if` + // guards make the cap and kill arms fire at most once each. + let mut timed_out = false; + let mut killed_by_request = false; + let exit_status = if let Some(mut ch) = child { + let pid = ch.id(); + // cap_ms == 0 → unbounded: a future that never resolves, so the cap + // arm never fires and only natural exit / shell::kill ends the job. + let cap = async move { + if cap_ms == 0 { + std::future::pending::<()>().await + } else { + tokio::time::sleep(std::time::Duration::from_millis(cap_ms)).await + } + }; + tokio::pin!(cap); + loop { + tokio::select! { + r = ch.wait() => break r.ok(), + _ = &mut cap, if !timed_out && !killed_by_request => { + timed_out = true; + crate::exec::host::kill_process_group(pid); } - Err(_) => { - h2.record.status = JobStatus::Failed; + _ = kill_notify.notified(), if !timed_out && !killed_by_request => { + killed_by_request = true; + crate::exec::host::kill_process_group(pid); } } } - } + } else { + None + }; + // Join the drains under a bounded grace. After a group-kill the pipes + // close so these complete at once; the grace only bites the pathological + // case where the direct child exited naturally but a grandchild still + // holds the pipe open — there we abort the reader (freeing the fd) and + // report what we have as truncated, so the job finalizes and frees its + // concurrency slot instead of wedging forever. + let (stdout_buf, stdout_trunc) = join_drain(stdout_task).await; + let (stderr_buf, stderr_trunc) = join_drain(stderr_task).await; + + // Single finalize critical section: status, exit_code, stdout, stderr and + // finished_at_ms are published together, so a poller never observes a + // terminal status with empty output (or output with a stale status). let mut h = handle.lock().await; + // Finalize-once: an external killer (shell::kill / shutdown sweep) may + // have already flipped status to Killed and stamped finished_at_ms. Never + // downgrade a terminal status, and never clobber that kill-time timestamp. + if h.record.status == JobStatus::Running { + h.record.status = if timed_out || killed_by_request { + JobStatus::Killed + } else { + match &exit_status { + Some(s) if s.success() => JobStatus::Finished, + _ => JobStatus::Failed, + } + }; + } + h.record.exit_code = exit_status.and_then(|s| s.code()); h.record.stdout = String::from_utf8_lossy(&stdout_buf).into_owned(); h.record.stderr = String::from_utf8_lossy(&stderr_buf).into_owned(); + if timed_out { + // Append the cause without discarding any stderr the child emitted + // before it was killed. + let note = + format!("background job exceeded the host hard cap of {cap_ms}ms and was killed"); + if h.record.stderr.is_empty() { + h.record.stderr = note; + } else { + h.record.stderr.push('\n'); + h.record.stderr.push_str(¬e); + } + } h.record.stdout_truncated = stdout_trunc; h.record.stderr_truncated = stderr_trunc; - h.record.finished_at_ms = Some(jobs::now_ms()); + if h.record.finished_at_ms.is_none() { + h.record.finished_at_ms = Some(jobs::now_ms()); + } + drop(h); + jobs::unregister_kill_signal(&id_clone); }); Ok(ExecBgResponse { job_id: id, argv }) @@ -182,16 +311,19 @@ pub(crate) async fn spawn_sandbox_job( // The concurrency cap (max_concurrent_jobs) covers both backends // uniformly via the running-status count in try_reserve_and_insert. // On rejection, there is no orphan process to kill. - match jobs::try_reserve_and_insert( + let running_guard = match jobs::try_reserve_and_insert( JobHandle { record, child: None, + // Sandbox jobs own no local OS process — leave host_pid None so the + // kill sweep and shell::kill correctly route them to the sandbox path. + host_pid: None, }, cfg.max_concurrent_jobs, ) .await { - Ok(_) => {} + Ok((_, guard)) => guard, Err((running, _)) => { // No orphan child to kill — sandbox jobs don't own a local process. return Err(format!( @@ -199,11 +331,14 @@ pub(crate) async fn spawn_sandbox_job( cfg.max_concurrent_jobs, running )); } - } + }; let id_clone = id.clone(); let argv_for_payload = argv.clone(); tokio::spawn(async move { + // Owns this job's contribution to the shell.jobs.running gauge (see the + // host path); dropped when this task ends, i.e. when the job finalizes. + let _running_guard = running_guard; let cmd = argv_for_payload[0].clone(); let args: Vec = argv_for_payload.iter().skip(1).cloned().collect(); let payload = serde_json::json!({ @@ -212,7 +347,35 @@ pub(crate) async fn spawn_sandbox_job( "args": args, "timeout_ms": timeout_ms, }); - let res = fwd.trigger("sandbox::exec", payload).await; + // Bound the engine RPC with a client-side deadline. Without one, a hung or + // partitioned engine would park this task forever, permanently holding the + // job's concurrency slot and keeping the shell.jobs.running gauge inflated. + // The in-VM work is bounded by the payload's timeout_ms; we allow that plus + // transport/queueing slack before declaring the engine unresponsive. + let rpc_timeout = + std::time::Duration::from_millis(timeout_ms.saturating_add(SANDBOX_RPC_SLACK_MS)); + let res = + match tokio::time::timeout(rpc_timeout, fwd.trigger("sandbox::exec", payload)).await { + Ok(r) => r, + Err(_) => { + // RPC deadline blown: finalize the job (unless an external kill + // already did) so its slot/gauge are released, then stop. + if let Some(handle) = jobs::get(&id_clone).await { + let mut h = handle.lock().await; + if h.record.status == JobStatus::Running { + h.record.status = JobStatus::Failed; + h.record.stderr = format!( + "sandbox::exec RPC timed out after {}ms (engine unresponsive)", + rpc_timeout.as_millis() + ); + if h.record.finished_at_ms.is_none() { + h.record.finished_at_ms = Some(jobs::now_ms()); + } + } + } + return; + } + }; let handle = match jobs::get(&id_clone).await { Some(h) => h, @@ -270,6 +433,30 @@ pub(crate) async fn spawn_sandbox_job( Ok(ExecBgResponse { job_id: id, argv }) } +/// Join one drain task under [`DRAIN_GRACE_MS`]. Returns `(bytes, truncated)`. +/// On grace expiry (a grandchild is holding the pipe open after the child +/// exited) the reader task is aborted to release its file descriptor and the +/// stream is reported truncated, so the owning job can still finalize. +async fn join_drain(task: Option, bool)>>) -> (Vec, bool) { + match task { + None => (Vec::new(), false), + Some(mut t) => { + match tokio::time::timeout(std::time::Duration::from_millis(DRAIN_GRACE_MS), &mut t) + .await + { + Ok(Ok(v)) => v, + // Reader task panicked or was cancelled: no recoverable output. + Ok(Err(_)) => (Vec::new(), false), + // Grace expired: abort the reader to free the fd, report truncated. + Err(_) => { + t.abort(); + (Vec::new(), true) + } + } + } + } +} + async fn read_bounded( reader: &mut R, limit: usize, @@ -297,6 +484,286 @@ async fn read_bounded( } } +#[cfg(test)] +mod host_path_tests { + use super::spawn_host_job; + use crate::config::ShellConfig; + use crate::exec::policy::ExecOverrides; + use crate::jobs::{self, JobStatus}; + use std::sync::Arc; + use std::time::Duration; + + // The first arg sets the HOST BG hard cap (`max_bg_timeout_ms`) for these + // background-job tests; `max_timeout_ms` is set to match for good measure. + // (0 → unbounded bg job.) + fn cfg(bg_cap_ms: u64, max_concurrent_jobs: usize) -> Arc { + let mut c = ShellConfig { + inherit_env: true, + max_output_bytes: 4096, + max_timeout_ms: bg_cap_ms, + max_bg_timeout_ms: bg_cap_ms, + max_concurrent_jobs, + ..Default::default() + }; + c.compile_denylist().unwrap(); + Arc::new(c) + } + + /// Poll a job until it is fully finalized (`finished_at_ms` set — the last + /// write the detached task makes) or the deadline expires, then return its + /// status. Waiting on `finished_at_ms` rather than just a non-Running + /// status avoids racing the gap between the status flip and the trailing + /// stdout/stderr/finished_at_ms writes. Tighter than a fixed sleep: + /// returns the instant the task finalizes, so the test stays fast. + async fn await_terminal(job_id: &str, deadline_ms: u64) -> JobStatus { + let start = std::time::Instant::now(); + loop { + if let Some(h) = jobs::get(job_id).await { + let r = h.lock().await; + if r.record.finished_at_ms.is_some() { + return r.record.status.clone(); + } + } + if start.elapsed() >= Duration::from_millis(deadline_ms) { + // Deadline hit without finalization — the job is still Running + // (a genuine hang), which is what we report. + return JobStatus::Running; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + } + + #[tokio::test] + async fn host_bg_job_exceeding_hard_cap_is_killed_and_marked_killed() { + // Acquire GAUGE before SWEEP (consistent global lock order): a successful + // reserve here perturbs the running gauge that jobs.rs tests assert on, + // and the gate is held through await_terminal so the guard's decrement + // (which fires when the finalize task ends) lands inside the critical + // section rather than racing another test's gauge assertion. + let _gauge_gate = jobs::GAUGE_TEST_GUARD.lock().await; + // Serialize with the global kill-sweep test: it would otherwise SIGKILL + // this job's `sleep 5` child by pid before the 200ms cap fires, which + // would finalize the job as (externally) Killed with no "hard cap" note. + let _guard = jobs::HOST_SWEEP_TEST_GUARD.lock().await; + + // Hard cap of 200ms vs a 5s sleep: the detached wait must time out, + // kill the child, and finalize the record as Killed (the mapping the + // sandbox path uses for timed_out). + let cfg = cfg(200, 16); + let resp = spawn_host_job( + cfg, + vec!["sleep".into(), "5".into()], + ExecOverrides::default(), + ) + .await + .expect("spawn_host_job"); + + // Generous deadline (2s) so we never flake on a loaded CI box, but the + // poll returns the instant the 200ms cap fires. + let status = await_terminal(&resp.job_id, 2000).await; + assert_eq!( + status, + JobStatus::Killed, + "a host bg job past the hard cap must be killed" + ); + + let h = jobs::get(&resp.job_id).await.expect("job exists"); + let r = h.lock().await; + assert!( + r.record.stderr.contains("hard cap"), + "timed-out job records the cause in stderr: {:?}", + r.record.stderr + ); + assert!(r.record.finished_at_ms.is_some(), "finalized"); + assert!(r.child.is_none(), "child reaped/taken after finalization"); + + drop(r); + jobs::JOBS.map.lock().await.remove(&resp.job_id); + } + + #[tokio::test] + async fn host_bg_short_job_finishes_within_cap() { + // Hold the gauge gate through finalization for the same reason as the + // hard-cap test: a successful reserve perturbs the process-global gauge. + let _gauge_gate = jobs::GAUGE_TEST_GUARD.lock().await; + // Control: a fast command finishes normally well within the cap. + let cfg = cfg(5_000, 16); + let resp = spawn_host_job(cfg, vec!["true".into()], ExecOverrides::default()) + .await + .expect("spawn_host_job"); + let status = await_terminal(&resp.job_id, 2000).await; + assert_eq!(status, JobStatus::Finished); + let h = jobs::get(&resp.job_id).await.expect("job exists"); + assert_eq!(h.lock().await.record.exit_code, Some(0)); + jobs::JOBS.map.lock().await.remove(&resp.job_id); + } + + #[tokio::test] + async fn host_bg_rejected_at_cap_returns_rejection_error() { + // Drive the full public `spawn_host_job` reject path with cap=0 (always + // over the budget — deterministic regardless of the global JOBS map + // shared across the concurrent test binary, which a cap=1+count test + // would race). The spawn must be REJECTED with the cap message; the + // "rejected job never enters JOBS, and its child is killed" guarantees + // are proved structurally in jobs.rs (`try_reserve_with_zero_cap_*`, + // which asserts the handle is returned and never inserted) and at the + // OS pid level in `rejected_child_pid_is_dead` below. + let cfg = cfg(30_000, 0); + let err = spawn_host_job( + cfg.clone(), + vec!["sleep".into(), "30".into()], + ExecOverrides::default(), + ) + .await + .expect_err("cap=0 must reject the spawn"); + assert!( + err.contains("max concurrent jobs"), + "rejection message: {err}" + ); + } + + /// Direct, PID-level proof that the reject arm's child is actually dead + /// (not leaked). Mirrors exactly what `spawn_host_job` does on rejection: + /// build + spawn a child, capture its PID, then `try_reserve_and_insert` + /// with cap=0 (forced rejection) and `start_kill` the returned handle — + /// the same two lines as the production reject arm. We then reap the child + /// and assert via `kill -0 ` that the OS no longer knows the process. + #[cfg(unix)] + #[tokio::test] + async fn rejected_child_pid_is_dead() { + use crate::exec::host::build_command; + use crate::exec::policy::ExecOverrides; + use crate::jobs::{JobHandle, JobRecord}; + + let cfg = cfg(30_000, 16); + let argv = vec!["sleep".to_string(), "30".to_string()]; + let mut command = + build_command(&argv, &cfg, &ExecOverrides::default()).expect("build_command"); + let mut child = command.spawn().expect("spawn"); + let pid = child.id().expect("child has a pid"); + + // Sanity: the process is alive right now (kill -0 succeeds). + assert!( + pid_is_alive(pid), + "freshly spawned child should be alive (pid {pid})" + ); + + let record = JobRecord { + id: format!("job-{}", uuid::Uuid::new_v4()), + argv: argv.clone(), + started_at_ms: jobs::now_ms(), + finished_at_ms: None, + status: JobStatus::Running, + exit_code: None, + stdout: String::new(), + stderr: String::new(), + stdout_truncated: false, + stderr_truncated: false, + }; + // child.stdout/stderr are still owned by `child`; that's fine — we move + // the whole child into the handle and never read its pipes here. + // Drain the pipes off the handle the way spawn_host_job does, so the + // handle's child is the sole owner of the process. + let _ = child.stdout.take(); + let _ = child.stderr.take(); + + // cap=0 forces rejection, returning the handle with its child intact — + // exactly the path spawn_host_job hits when over the cap. + let (_, mut handle) = jobs::try_reserve_and_insert( + JobHandle { + record, + child: Some(child), + host_pid: Some(pid), + }, + 0, + ) + .await + .expect_err("cap=0 must reject and return the handle"); + + // The production reject arm does precisely this: + let mut killed_child = handle.child.take().expect("rejected handle owns child"); + killed_child + .start_kill() + .expect("start_kill on rejected child"); + // Reap so the kernel releases the zombie before we probe kill -0. + let _ = killed_child.wait().await; + + // The OS must no longer have this pid as a live (non-zombie) process. + assert!( + !pid_is_alive(pid), + "rejected child (pid {pid}) must be dead, not leaked" + ); + } + + /// `kill(pid, 0)` probes for the existence of a *live* process without + /// sending a signal. After we wait() the child above, the zombie is reaped, + /// so this returns false for a dead process. + #[cfg(unix)] + fn pid_is_alive(pid: u32) -> bool { + // SAFETY: signal 0 performs error checking only; it never delivers a + // signal or touches process memory. + unsafe { libc::kill(pid as libc::pid_t, 0) == 0 } + } + + /// End-to-end: the shutdown sweep terminates a real running host child. + /// Drives the real `spawn_host_job` so a real drain task listens on the + /// kill-signal channel; `kill_running_host_jobs` notifies it and the drain + /// task group-kills the live process. (Relocated from jobs.rs, which could + /// only fake the handle — the sweep no longer signals a bare pid, so a faked + /// job with no registered drain task can't be killed.) + #[cfg(unix)] + #[tokio::test] + async fn shutdown_sweep_terminates_real_host_job() { + use std::time::{Duration, Instant}; + let _gauge_gate = jobs::GAUGE_TEST_GUARD.lock().await; + let _guard = jobs::HOST_SWEEP_TEST_GUARD.lock().await; + + let resp = spawn_host_job( + cfg(60_000, 16), + vec!["sleep".into(), "30".into()], + ExecOverrides::default(), + ) + .await + .expect("spawn_host_job"); + let pid = jobs::get(&resp.job_id) + .await + .expect("job exists") + .lock() + .await + .host_pid + .expect("host bg job has a pid"); + assert!(pid_is_alive(pid), "child alive before sweep"); + + let signalled = jobs::kill_running_host_jobs().await; + assert!(signalled >= 1, "sweep must signal the running host job"); + + // The sweep's bounded grace waits for finalization, so the child should + // already be dead; assert it within a tight margin and check the status. + let start = Instant::now(); + loop { + if !pid_is_alive(pid) { + break; + } + assert!( + start.elapsed() < Duration::from_secs(3), + "swept host child (pid {pid}) must die within 3s" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + let status = jobs::get(&resp.job_id) + .await + .unwrap() + .lock() + .await + .record + .status + .clone(); + assert_eq!(status, JobStatus::Killed, "swept job is marked Killed"); + + jobs::JOBS.map.lock().await.remove(&resp.job_id); + } +} + #[cfg(test)] mod sandbox_path_tests { use crate::config::ShellConfig; diff --git a/shell/src/functions/fs_chmod.rs b/shell/src/functions/fs_chmod.rs index e358da45..e60bf9cd 100644 --- a/shell/src/functions/fs_chmod.rs +++ b/shell/src/functions/fs_chmod.rs @@ -4,17 +4,17 @@ use serde_json::Value; use crate::fs::error::FsError; use crate::fs::{ChmodRequest, ChmodResponse, FsBackend}; -use crate::functions::fs_dispatch::{err_to_string, pick_backend}; +use crate::functions::fs_dispatch::pick_backend; pub async fn handle( host: Arc, iii: iii_sdk::III, sandbox_enabled: bool, payload: Value, -) -> Result { +) -> Result { let req: ChmodRequest = serde_json::from_value(payload) - .map_err(|e| FsError::new("S210", format!("bad chmod payload: {e}")).to_json())?; + .map_err(|e| FsError::new("S210", format!("bad chmod payload: {e}")))?; let (target, args) = req.split(); let backend = pick_backend(target, host, iii, sandbox_enabled); - backend.chmod(args).await.map_err(err_to_string) + backend.chmod(args).await.map_err(iii_sdk::IIIError::from) } diff --git a/shell/src/functions/fs_dispatch.rs b/shell/src/functions/fs_dispatch.rs index 9fb47d1d..5e31e3c4 100644 --- a/shell/src/functions/fs_dispatch.rs +++ b/shell/src/functions/fs_dispatch.rs @@ -4,7 +4,6 @@ use std::sync::Arc; use uuid::Uuid; -use crate::fs::error::FsError; use crate::fs::sandbox::{IiiTriggerFwd, SandboxFsBackend}; use crate::fs::{FsBackend, Target}; @@ -27,7 +26,3 @@ fn sandbox_for(id: Uuid, iii: iii_sdk::III, enabled: bool) -> Arc id, )) } - -pub fn err_to_string(e: FsError) -> String { - e.to_json() -} diff --git a/shell/src/functions/fs_grep.rs b/shell/src/functions/fs_grep.rs index 48213e09..74dbd42a 100644 --- a/shell/src/functions/fs_grep.rs +++ b/shell/src/functions/fs_grep.rs @@ -4,17 +4,17 @@ use serde_json::Value; use crate::fs::error::FsError; use crate::fs::{FsBackend, GrepRequest, GrepResponse}; -use crate::functions::fs_dispatch::{err_to_string, pick_backend}; +use crate::functions::fs_dispatch::pick_backend; pub async fn handle( host: Arc, iii: iii_sdk::III, sandbox_enabled: bool, payload: Value, -) -> Result { +) -> Result { let req: GrepRequest = serde_json::from_value(payload) - .map_err(|e| FsError::new("S210", format!("bad grep payload: {e}")).to_json())?; + .map_err(|e| FsError::new("S210", format!("bad grep payload: {e}")))?; let (target, args) = req.split(); let backend = pick_backend(target, host, iii, sandbox_enabled); - backend.grep(args).await.map_err(err_to_string) + backend.grep(args).await.map_err(iii_sdk::IIIError::from) } diff --git a/shell/src/functions/fs_ls.rs b/shell/src/functions/fs_ls.rs index 2966c64b..13f18cf1 100644 --- a/shell/src/functions/fs_ls.rs +++ b/shell/src/functions/fs_ls.rs @@ -4,17 +4,20 @@ use serde_json::Value; use crate::fs::error::FsError; use crate::fs::{FsBackend, LsRequest, LsResponse}; -use crate::functions::fs_dispatch::{err_to_string, pick_backend}; +use crate::functions::fs_dispatch::pick_backend; pub async fn handle( host: Arc, iii: iii_sdk::III, sandbox_enabled: bool, payload: Value, -) -> Result { +) -> Result { + // Both the payload-deser error (S210) and the backend error carry their + // S-code to the wire `code` via `From for IIIError` (Remote), so + // an agent can branch on `error.code` instead of parsing the message. let req: LsRequest = serde_json::from_value(payload) - .map_err(|e| FsError::new("S210", format!("bad ls payload: {e}")).to_json())?; + .map_err(|e| FsError::new("S210", format!("bad ls payload: {e}")))?; let (target, args) = req.split(); let backend = pick_backend(target, host, iii, sandbox_enabled); - backend.ls(args).await.map_err(err_to_string) + backend.ls(args).await.map_err(iii_sdk::IIIError::from) } diff --git a/shell/src/functions/fs_mkdir.rs b/shell/src/functions/fs_mkdir.rs index e06448e3..341ef86f 100644 --- a/shell/src/functions/fs_mkdir.rs +++ b/shell/src/functions/fs_mkdir.rs @@ -4,17 +4,17 @@ use serde_json::Value; use crate::fs::error::FsError; use crate::fs::{FsBackend, MkdirRequest, MkdirResponse}; -use crate::functions::fs_dispatch::{err_to_string, pick_backend}; +use crate::functions::fs_dispatch::pick_backend; pub async fn handle( host: Arc, iii: iii_sdk::III, sandbox_enabled: bool, payload: Value, -) -> Result { +) -> Result { let req: MkdirRequest = serde_json::from_value(payload) - .map_err(|e| FsError::new("S210", format!("bad mkdir payload: {e}")).to_json())?; + .map_err(|e| FsError::new("S210", format!("bad mkdir payload: {e}")))?; let (target, args) = req.split(); let backend = pick_backend(target, host, iii, sandbox_enabled); - backend.mkdir(args).await.map_err(err_to_string) + backend.mkdir(args).await.map_err(iii_sdk::IIIError::from) } diff --git a/shell/src/functions/fs_mv.rs b/shell/src/functions/fs_mv.rs index 784c965f..93945260 100644 --- a/shell/src/functions/fs_mv.rs +++ b/shell/src/functions/fs_mv.rs @@ -4,17 +4,17 @@ use serde_json::Value; use crate::fs::error::FsError; use crate::fs::{FsBackend, MvRequest, MvResponse}; -use crate::functions::fs_dispatch::{err_to_string, pick_backend}; +use crate::functions::fs_dispatch::pick_backend; pub async fn handle( host: Arc, iii: iii_sdk::III, sandbox_enabled: bool, payload: Value, -) -> Result { +) -> Result { let req: MvRequest = serde_json::from_value(payload) - .map_err(|e| FsError::new("S210", format!("bad mv payload: {e}")).to_json())?; + .map_err(|e| FsError::new("S210", format!("bad mv payload: {e}")))?; let (target, args) = req.split(); let backend = pick_backend(target, host, iii, sandbox_enabled); - backend.mv(args).await.map_err(err_to_string) + backend.mv(args).await.map_err(iii_sdk::IIIError::from) } diff --git a/shell/src/functions/fs_read.rs b/shell/src/functions/fs_read.rs index 6ab882c1..59507ade 100644 --- a/shell/src/functions/fs_read.rs +++ b/shell/src/functions/fs_read.rs @@ -4,18 +4,18 @@ use serde_json::Value; use crate::fs::error::FsError; use crate::fs::{FsBackend, ReadRequest, ReadResponseWire}; -use crate::functions::fs_dispatch::{err_to_string, pick_backend}; +use crate::functions::fs_dispatch::pick_backend; pub async fn handle( host: Arc, iii: iii_sdk::III, sandbox_enabled: bool, payload: Value, -) -> Result { +) -> Result { let req: ReadRequest = serde_json::from_value(payload) - .map_err(|e| FsError::new("S210", format!("bad read payload: {e}")).to_json())?; + .map_err(|e| FsError::new("S210", format!("bad read payload: {e}")))?; let (target, args) = req.split(); let backend = pick_backend(target, host, iii, sandbox_enabled); - let resp = backend.read(args).await.map_err(err_to_string)?; + let resp = backend.read(args).await.map_err(iii_sdk::IIIError::from)?; Ok(resp.into()) } diff --git a/shell/src/functions/fs_rm.rs b/shell/src/functions/fs_rm.rs index 165c424b..38cff0f4 100644 --- a/shell/src/functions/fs_rm.rs +++ b/shell/src/functions/fs_rm.rs @@ -4,17 +4,17 @@ use serde_json::Value; use crate::fs::error::FsError; use crate::fs::{FsBackend, RmRequest, RmResponse}; -use crate::functions::fs_dispatch::{err_to_string, pick_backend}; +use crate::functions::fs_dispatch::pick_backend; pub async fn handle( host: Arc, iii: iii_sdk::III, sandbox_enabled: bool, payload: Value, -) -> Result { +) -> Result { let req: RmRequest = serde_json::from_value(payload) - .map_err(|e| FsError::new("S210", format!("bad rm payload: {e}")).to_json())?; + .map_err(|e| FsError::new("S210", format!("bad rm payload: {e}")))?; let (target, args) = req.split(); let backend = pick_backend(target, host, iii, sandbox_enabled); - backend.rm(args).await.map_err(err_to_string) + backend.rm(args).await.map_err(iii_sdk::IIIError::from) } diff --git a/shell/src/functions/fs_sed.rs b/shell/src/functions/fs_sed.rs index 0bd6129d..5181b4dd 100644 --- a/shell/src/functions/fs_sed.rs +++ b/shell/src/functions/fs_sed.rs @@ -4,17 +4,17 @@ use serde_json::Value; use crate::fs::error::FsError; use crate::fs::{FsBackend, SedRequest, SedResponse}; -use crate::functions::fs_dispatch::{err_to_string, pick_backend}; +use crate::functions::fs_dispatch::pick_backend; pub async fn handle( host: Arc, iii: iii_sdk::III, sandbox_enabled: bool, payload: Value, -) -> Result { +) -> Result { let req: SedRequest = serde_json::from_value(payload) - .map_err(|e| FsError::new("S210", format!("bad sed payload: {e}")).to_json())?; + .map_err(|e| FsError::new("S210", format!("bad sed payload: {e}")))?; let (target, args) = req.split(); let backend = pick_backend(target, host, iii, sandbox_enabled); - backend.sed(args).await.map_err(err_to_string) + backend.sed(args).await.map_err(iii_sdk::IIIError::from) } diff --git a/shell/src/functions/fs_stat.rs b/shell/src/functions/fs_stat.rs index 5db339cd..6dd7b3b7 100644 --- a/shell/src/functions/fs_stat.rs +++ b/shell/src/functions/fs_stat.rs @@ -4,17 +4,17 @@ use serde_json::Value; use crate::fs::error::FsError; use crate::fs::{FsBackend, StatRequest, StatResponse}; -use crate::functions::fs_dispatch::{err_to_string, pick_backend}; +use crate::functions::fs_dispatch::pick_backend; pub async fn handle( host: Arc, iii: iii_sdk::III, sandbox_enabled: bool, payload: Value, -) -> Result { +) -> Result { let req: StatRequest = serde_json::from_value(payload) - .map_err(|e| FsError::new("S210", format!("bad stat payload: {e}")).to_json())?; + .map_err(|e| FsError::new("S210", format!("bad stat payload: {e}")))?; let (target, args) = req.split(); let backend = pick_backend(target, host, iii, sandbox_enabled); - backend.stat(args).await.map_err(err_to_string) + backend.stat(args).await.map_err(iii_sdk::IIIError::from) } diff --git a/shell/src/functions/fs_write.rs b/shell/src/functions/fs_write.rs index ee0edf5f..653dc024 100644 --- a/shell/src/functions/fs_write.rs +++ b/shell/src/functions/fs_write.rs @@ -3,18 +3,47 @@ use std::sync::Arc; use serde_json::Value; use crate::fs::error::FsError; -use crate::fs::{FsBackend, WriteRequest, WriteResponse}; -use crate::functions::fs_dispatch::{err_to_string, pick_backend}; +use crate::fs::{FsBackend, WriteFileResult, WriteRequest, WriteResponse}; +use crate::functions::fs_dispatch::pick_backend; pub async fn handle( host: Arc, iii: iii_sdk::III, sandbox_enabled: bool, payload: Value, -) -> Result { +) -> Result { let req: WriteRequest = serde_json::from_value(payload) - .map_err(|e| FsError::new("S210", format!("bad write payload: {e}")).to_json())?; - let (target, args) = req.split(); + .map_err(|e| FsError::new("S210", format!("bad write payload: {e}")))?; + let (target, mut specs, batch) = req.into_specs().map_err(iii_sdk::IIIError::from)?; let backend = pick_backend(target, host, iii, sandbox_enabled); - backend.write(args).await.map_err(err_to_string) + + // Single-file form (`path`+`content`): return the backend response verbatim + // (files stays empty), preserving the { bytes_written, path } shape for + // sandbox round-trips and prior single-file callers. The batch form always + // returns the per-file `files` array below, even for one entry, so a caller + // that sends `files` always gets `files` back. + if !batch { + return backend + .write(specs.pop().unwrap()) + .await + .map_err(iii_sdk::IIIError::from); + } + + // Batch: write each file in order, aggregating per-file results. A failure + // on any file aborts and surfaces that error (files written so far remain). + let mut files = Vec::with_capacity(specs.len()); + let mut total: u64 = 0; + for spec in specs { + let r = backend.write(spec).await.map_err(iii_sdk::IIIError::from)?; + total += r.bytes_written; + files.push(WriteFileResult { + path: r.path, + bytes_written: r.bytes_written, + }); + } + Ok(WriteResponse { + bytes_written: total, + path: String::new(), + files, + }) } diff --git a/shell/src/functions/kill.rs b/shell/src/functions/kill.rs index 4231344e..7d8cb38c 100644 --- a/shell/src/functions/kill.rs +++ b/shell/src/functions/kill.rs @@ -1,10 +1,16 @@ +use crate::exec::error::ExecError; use crate::functions::types::{KillRequest, KillResponse}; use crate::jobs::{self, JobStatus}; -pub async fn handle(req: KillRequest) -> Result { +pub async fn handle(req: KillRequest) -> Result { + // Return the TYPED ExecError (not its JSON string): main.rs's + // `.map_err(IIIError::from)` lifts it to `IIIError::Remote`, so the S-code + // lands as the top-level wire `code` and an agent's single shell:: error + // handler works here too. job-not-found maps to S211; operational kill + // failures below use S216 (the exec/fs "other io" code). let handle = jobs::get(&req.job_id) .await - .ok_or_else(|| format!("no such job: {}", req.job_id))?; + .ok_or_else(|| ExecError::new("S211", format!("no such job: {}", req.job_id)))?; let mut h = handle.lock().await; if h.record.status != JobStatus::Running { @@ -15,39 +21,114 @@ pub async fn handle(req: KillRequest) -> Result { reason: Some("not running".into()), }); } - let Some(child) = h.child.as_mut() else { - // Sandbox-backed jobs don't own a host child process; the in-VM - // process is reachable only through `sandbox::exec`, which has no - // cancel hook. Mark the record Killed so shell::status / shell::list - // reflect the cancellation; the late response from the trigger will - // capture stdout/stderr but won't overwrite this status (see the - // `already_killed` guard in functions::exec_bg::spawn_sandbox_job). + + // Branch 1: the in-handle Child is still present (job spawned but its drain + // task has not yet taken the Child). We still own the un-reaped Child, so + // the pid cannot have been recycled — SIGKILL the whole process group + // (process_group(0) at spawn) so a command that already forked cannot leave + // descendants running after we report killed: true. The group SIGKILL is + // the authoritative kill; start_kill() is best-effort on the leader (it can + // race the group signal and find the leader already a zombie — that error + // is not a kill failure, so don't surface it). Reaping is left to the drain + // task's wait(). + if let Some(child) = h.child.as_mut() { + crate::exec::host::kill_process_group(child.id()); + let _ = child.start_kill(); h.record.status = JobStatus::Killed; h.record.finished_at_ms = Some(jobs::now_ms()); return Ok(KillResponse { job_id: req.job_id, killed: true, status: h.record.status.clone(), - reason: Some( - "sandbox::exec has no cancel hook; the in-VM process will run \ - until its timeout_ms expires" - .into(), - ), + reason: None, }); - }; - child - .start_kill() - .map_err(|e| format!("failed to kill job {}: {}", req.job_id, e))?; + } + + // Branch 2: a RUNNING host bg job whose drain task already took the Child out + // of the handle (the common case — child is None almost immediately after + // spawn). We do NOT signal a bare pid here: the drain task may be between its + // `wait()` reaping the child and acquiring the lock to flip status, so the pid + // could already be free for OS reuse — signalling it could hit an unrelated + // process (and the worker may run as root). Instead, mark the job Killed and + // notify its kill-signal channel; the drain task, which still owns the + // un-reaped Child, kills the process group safely while the pid is guaranteed + // live, and its finalize-once guard preserves this Killed status + timestamp. + if h.host_pid.is_some() { + h.record.status = JobStatus::Killed; + h.record.finished_at_ms = Some(jobs::now_ms()); + let status = h.record.status.clone(); + let notify = jobs::kill_signal_for(&req.job_id); + // Release the handle lock BEFORE notifying so the woken drain task can + // acquire it immediately to perform the group-kill and finalize. + drop(h); + if let Some(n) = notify { + n.notify_one(); + } + return Ok(KillResponse { + job_id: req.job_id, + killed: true, + status, + reason: None, + }); + } + + // Branch 3: true sandbox-backed job (host_pid None) — no host child process at + // all. The in-VM process is reachable only through `sandbox::exec`, which has + // no cancel hook. Mark the record Killed so shell::status / shell::list reflect + // the cancellation; the late trigger response captures stdout/stderr but won't + // overwrite this status (see the `already_killed` guard in + // functions::exec_bg::spawn_sandbox_job). h.record.status = JobStatus::Killed; h.record.finished_at_ms = Some(jobs::now_ms()); Ok(KillResponse { job_id: req.job_id, killed: true, status: h.record.status.clone(), - reason: None, + reason: Some( + "sandbox::exec has no cancel hook; the in-VM process will run \ + until its timeout_ms expires" + .into(), + ), }) } +#[cfg(test)] +mod missing_job_tests { + use super::*; + use crate::functions::types::KillRequest; + + #[tokio::test] + async fn killing_missing_job_returns_typed_s211() { + let err = handle(KillRequest { + job_id: "job-does-not-exist".into(), + }) + .await + .expect_err("missing job must error"); + // Assert the TYPED ExecError carries the S-code, not a JSON string. + assert_eq!(err.code, "S211"); + assert!(err.message.contains("no such job")); + } + + /// Pin the wire contract: the handler's `Err` lifts to + /// `IIIError::Remote { code: "S211", .. }`, which the engine SDK maps to + /// the wire `code` verbatim — NOT the `invocation_failed`/Handler collapse. + #[tokio::test] + async fn killing_missing_job_lifts_to_remote_s211() { + let err = handle(KillRequest { + job_id: "job-does-not-exist".into(), + }) + .await + .expect_err("missing job must error"); + match iii_sdk::IIIError::from(err) { + iii_sdk::IIIError::Remote { code, message, .. } => { + assert_eq!(code, "S211"); + assert!(message.contains("no such job")); + } + other => panic!("expected IIIError::Remote, got {other:?}"), + } + } +} + #[cfg(test)] mod sandbox_kill_tests { use super::*; @@ -77,6 +158,7 @@ mod sandbox_kill_tests { Arc::new(Mutex::new(JobHandle { record, child: None, + host_pid: None, })), ); @@ -93,3 +175,165 @@ mod sandbox_kill_tests { jobs::JOBS.map.lock().await.remove(&id); } } + +#[cfg(test)] +mod host_kill_tests { + use super::*; + use crate::config::ShellConfig; + use crate::exec::host::build_command; + use crate::exec::policy::ExecOverrides; + use crate::functions::types::KillRequest; + use crate::jobs::{self, JobHandle, JobRecord}; + use std::sync::Arc; + use tokio::sync::Mutex; + + fn open_cfg() -> ShellConfig { + let mut c = ShellConfig { + inherit_env: true, + max_output_bytes: 4096, + ..Default::default() + }; + c.compile_denylist().unwrap(); + c + } + + fn pid_alive(pid: u32) -> bool { + // SAFETY: signal 0 only performs permission/existence checking. + unsafe { libc::kill(pid as libc::pid_t, 0) == 0 } + } + + /// A RUNNING host bg job whose drain task already owns the `Child` + /// (`child: None` in the handle) is terminated by `shell::kill` through the + /// kill-signal channel: `kill::handle` marks it Killed and notifies; the REAL + /// drain task (driven here via `spawn_host_job`) group-kills the live process + /// while it still owns the un-reaped Child. We assert the OS process dies + /// within a tight 3s deadline and the response carries no sandbox caveat. + /// (The previous bare-pid SIGKILL was removed: the pid could be reused after + /// the drain task's `wait()` reaped the child, risking an unrelated kill.) + #[cfg(unix)] + #[tokio::test] + async fn killing_running_host_job_terminates_the_real_child() { + use crate::jobs::{GAUGE_TEST_GUARD, HOST_SWEEP_TEST_GUARD}; + use std::time::{Duration, Instant}; + + // spawn_host_job() reserves a running slot and bumps RUNNING_JOBS, so + // take the gauge guard FIRST (same order as exec_bg.rs host-path tests) + // to serialize with the +1/-1 gauge assertions in jobs.rs, then the + // sweep guard to serialize on the global JOBS map. + let _gauge_gate = GAUGE_TEST_GUARD.lock().await; + let _guard = HOST_SWEEP_TEST_GUARD.lock().await; + + let mut cfg = open_cfg(); + cfg.max_timeout_ms = 60_000; // hard cap well past the test window + cfg.max_concurrent_jobs = 64; + // Drive the REAL spawn path so a real drain task is listening on the + // kill-signal channel (a faked handle cannot be killed — by design). + let resp = crate::functions::exec_bg::spawn_host_job( + Arc::new(cfg), + vec!["sleep".to_string(), "30".to_string()], + ExecOverrides::default(), + ) + .await + .expect("spawn host bg job"); + let id = resp.job_id; + + // The drain task records the child's pid on the handle (host_pid). + let pid = jobs::get(&id) + .await + .expect("job exists") + .lock() + .await + .host_pid + .expect("host bg job has a pid"); + assert!(pid_alive(pid), "child should be alive before kill"); + + let resp = handle(KillRequest { job_id: id.clone() }).await.unwrap(); + assert!(resp.killed, "running host job must report killed"); + assert_eq!(resp.status, JobStatus::Killed); + assert!( + resp.reason.is_none(), + "host kill must NOT return the sandbox caveat reason" + ); + + // TIGHT deadline: the notified drain task group-kills the process; a + // SIGKILL'd `sleep 30` dies within milliseconds. + let start = Instant::now(); + loop { + if !pid_alive(pid) { + break; + } + assert!( + start.elapsed() < Duration::from_secs(3), + "killed host child (pid {pid}) must die within 3s" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + + // Record reflects the cancellation. + { + let h = jobs::get(&id).await.expect("job exists"); + let r = h.lock().await; + assert_eq!(r.record.status, JobStatus::Killed); + assert!(r.record.finished_at_ms.is_some()); + } + + jobs::JOBS.map.lock().await.remove(&id); + } + + /// Coverage for branch 1: a Running host job whose `Child` is still in the + /// handle (drain task has not yet taken it) is terminated via `start_kill`. + #[cfg(unix)] + #[tokio::test] + async fn killing_host_job_with_in_handle_child_uses_start_kill() { + let cfg = open_cfg(); + let argv = vec!["sleep".to_string(), "30".to_string()]; + let mut command = + build_command(&argv, &cfg, &ExecOverrides::default()).expect("build_command"); + let mut child = command.spawn().expect("spawn sleep"); + let pid = child.id().expect("child has pid"); + + let _ = child.stdout.take(); + let _ = child.stderr.take(); + + assert!(pid_alive(pid), "child should be alive before kill"); + + let id = format!("job-{}", uuid::Uuid::new_v4()); + let record = JobRecord { + id: id.clone(), + argv, + started_at_ms: jobs::now_ms(), + finished_at_ms: None, + status: JobStatus::Running, + exit_code: None, + stdout: String::new(), + stderr: String::new(), + stdout_truncated: false, + stderr_truncated: false, + }; + jobs::JOBS.map.lock().await.insert( + id.clone(), + Arc::new(Mutex::new(JobHandle { + record, + child: Some(child), + host_pid: Some(pid), + })), + ); + + let resp = handle(KillRequest { job_id: id.clone() }).await.unwrap(); + assert!(resp.killed, "running host job must report killed"); + assert_eq!(resp.status, JobStatus::Killed); + assert!(resp.reason.is_none(), "host kill has no caveat reason"); + + // Reap the killed child so the kernel releases the zombie, then prove the + // pid is gone. + if let Some(h) = jobs::get(&id).await { + let mut g = h.lock().await; + if let Some(child) = g.child.as_mut() { + let _ = child.wait().await; + } + } + assert!(!pid_alive(pid), "child (pid {pid}) must be dead after kill"); + + jobs::JOBS.map.lock().await.remove(&id); + } +} diff --git a/shell/src/functions/status.rs b/shell/src/functions/status.rs index 0fb21bc0..8b8ff4b4 100644 --- a/shell/src/functions/status.rs +++ b/shell/src/functions/status.rs @@ -1,12 +1,56 @@ +use crate::exec::error::ExecError; use crate::functions::types::{StatusRequest, StatusResponse}; use crate::jobs; -pub async fn handle(req: StatusRequest) -> Result { +pub async fn handle(req: StatusRequest) -> Result { + // Return the TYPED ExecError (not its JSON string): main.rs's + // `.map_err(IIIError::from)` lifts it to `IIIError::Remote`, so the S-code + // (S211 for job-not-found) lands as the top-level wire `code` — an agent + // runs one error handler across every shell:: call instead of branching on + // a plain-string contract for status/kill alone. let handle = jobs::get(&req.job_id) .await - .ok_or_else(|| format!("no such job: {}", req.job_id))?; + .ok_or_else(|| ExecError::new("S211", format!("no such job: {}", req.job_id)))?; let h = handle.lock().await; Ok(StatusResponse { job: h.record.clone(), }) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::functions::types::StatusRequest; + + #[tokio::test] + async fn status_of_missing_job_returns_typed_s211() { + let err = handle(StatusRequest { + job_id: "job-does-not-exist".into(), + }) + .await + .expect_err("missing job must error"); + // Assert the TYPED ExecError carries the S-code (so `From` + // can lift it to the wire `code`), not a stringified-JSON payload. + assert_eq!(err.code, "S211"); + assert!(err.message.contains("no such job")); + } + + /// Pin the wire contract: the handler's `Err` lifts to + /// `IIIError::Remote { code: "S211", .. }`, which the engine SDK maps to + /// the wire `code` verbatim — NOT the `invocation_failed`/Handler collapse. + #[tokio::test] + async fn status_missing_job_lifts_to_remote_s211() { + let err = handle(StatusRequest { + job_id: "job-does-not-exist".into(), + }) + .await + .expect_err("missing job must error"); + match iii_sdk::IIIError::from(err) { + iii_sdk::IIIError::Remote { code, message, .. } => { + assert_eq!(code, "S211"); + assert!(message.contains("no such job")); + } + other => panic!("expected IIIError::Remote, got {other:?}"), + } + } +} diff --git a/shell/src/functions/types.rs b/shell/src/functions/types.rs index b31adb97..c581db7a 100644 --- a/shell/src/functions/types.rs +++ b/shell/src/functions/types.rs @@ -8,6 +8,8 @@ //! caller `command: string` while keeping the loose `timeout_ms` semantic //! and the actionable error texts on type mismatches. +use std::collections::BTreeMap; + use schemars::JsonSchema; use serde::{Deserialize, Deserializer, Serialize}; @@ -103,6 +105,31 @@ pub struct ExecRequest { /// `cfg.default_timeout_ms` (loose wire semantic, preserved on purpose). #[serde(default, deserialize_with = "deserialize_timeout_ms")] pub timeout_ms: Option, + /// Optional working directory for this call (host target only). Confined to + /// the fs jail exactly like `shell::fs::*` paths: jail-relative when + /// `fs.host_root` is set (else absolute), canonicalized, and must resolve + /// inside `host_root` and miss the denylist — a path that escapes returns + /// S215. Must already exist and be a directory. Omit to use the configured + /// `working_dir` (unchanged default). Rejected (S210) on a sandbox target. + #[serde(default)] + pub cwd: Option, + /// Optional per-call environment values (host target only). A key may be + /// set ONLY if the operator listed it in `allowed_env`, and NEVER for an + /// exec-hijacking key (PATH, IFS, HOME, LD_*/DYLD_*, and other loader/lookup + /// and interpreter-startup keys — see DANGEROUS_ENV_KEYS) — those are + /// rejected even if allowlisted. Supplying a key that is not in `allowed_env`, + /// or any dangerous key, rejects the WHOLE call (S210) naming the offending + /// key; the env is never silently dropped. Permitted values override what + /// would otherwise be forwarded for that key. Rejected (S210) on a sandbox target. + #[serde(default)] + pub env: Option>, + /// Optional bytes written to the program's standard input, followed by EOF. + /// Use this to pipe content to a command that reads stdin (`tee`, `patch`, + /// `cat`, a filter) instead of wrapping it in a shell heredoc. Omit to leave + /// stdin closed (`/dev/null`, the default). HOST target only — rejected + /// (S210) on a sandbox target, like cwd/env. + #[serde(default)] + pub stdin: Option, /// Where to run the command. Defaults to the host worker; pass /// `{ kind: "sandbox", sandbox_id }` to forward the call to a microVM. #[serde(default)] @@ -126,6 +153,24 @@ pub struct ExecBgRequest { /// sandbox-targeted ones forward it through `cfg.resolve_timeout`. #[serde(default, deserialize_with = "deserialize_timeout_ms")] pub timeout_ms: Option, + /// Optional working directory for this job (host target only). Same jail + /// confinement and rules as [`ExecRequest::cwd`]: canonicalized, must + /// resolve inside `host_root` (S215 on escape) and be an existing + /// directory. Rejected (S210) on a sandbox target. + #[serde(default)] + pub cwd: Option, + /// Optional per-call environment values (host target only). Same gating as + /// [`ExecRequest::env`]: a key must be in `allowed_env` and must not be an + /// exec-hijacking key (PATH, IFS, HOME, LD_*/DYLD_*, and other loader/lookup + /// and interpreter-startup keys — see DANGEROUS_ENV_KEYS); any violation + /// rejects the whole call (S210). Rejected (S210) on a sandbox target. + #[serde(default)] + pub env: Option>, + /// Optional bytes written to the job's standard input, then EOF. See + /// [`ExecRequest::stdin`]. HOST target only — rejected (S210) on a sandbox + /// target. + #[serde(default)] + pub stdin: Option, /// Where to run. See [`ExecRequest::target`]. #[serde(default)] pub target: Target, @@ -222,8 +267,26 @@ impl From<&JobRecord> for JobSummary { } } +/// `shell::list` takes no arguments; this empty struct publishes an accurate +/// (empty-object) request schema. +/// +/// NOTE: deliberately NOT `#[serde(deny_unknown_fields)]`. The engine injects +/// a `_caller_worker_id` field into every call's payload, so a strict no-arg +/// struct would reject EVERY call. The handler ignores the payload entirely +/// (`move |_req: Value|` in main.rs) for the same reason. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ListRequest {} + #[derive(Debug, Serialize, JsonSchema)] pub struct ListResponse { pub jobs: Vec, pub count: usize, } + +/// `shell::config-status` takes no arguments; this empty struct publishes an +/// accurate (empty-object) request schema, mirroring [`ListRequest`]. The +/// response is `configuration::ReloadStatus`. Like [`ListRequest`], it is NOT +/// `deny_unknown_fields` — the engine-injected `_caller_worker_id` would +/// otherwise be rejected; the handler ignores the payload. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ConfigStatusRequest {} diff --git a/shell/src/jobs.rs b/shell/src/jobs.rs index 67d6174c..7436a503 100644 --- a/shell/src/jobs.rs +++ b/shell/src/jobs.rs @@ -2,6 +2,7 @@ use once_cell::sync::Lazy; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; use tokio::process::Child; @@ -33,6 +34,15 @@ pub struct JobRecord { pub struct JobHandle { pub record: JobRecord, pub child: Option, + /// PID of the host child process, set ONLY for host-backed background jobs. + /// The detached drain task takes `child` out of the handle (so the task can + /// own the `Child` and `wait()` on it), which leaves `child: None` here even + /// while the process is alive. `host_pid` is now only a DISCRIMINATOR — `Some` + /// means "host bg job, terminate via the kill-signal channel" and `None` means + /// "sandbox job, no local process". Termination never signals this pid + /// directly (that risked SIGKILLing a reused pid after `wait()` reaped the + /// child); see [`kill_signal_for`] and the drain task. + pub host_pid: Option, } pub struct Jobs { @@ -49,6 +59,104 @@ impl Jobs { pub static JOBS: Lazy = Lazy::new(Jobs::new); +/// Per-job kill-signal channels for live host background jobs, keyed by job id. +/// +/// The detached drain task is the ONLY code that signals a host child, and it +/// does so while it still owns the un-reaped `Child` — so the signal can never +/// land on a pid that `wait()` already reaped and the OS reused. `shell::kill` +/// and the shutdown sweep REQUEST termination by notifying the job's channel +/// here; the drain task selects on it and kills the child's process group. +/// +/// A `std::sync::Mutex` (not the tokio one) because every access is a short, +/// non-async map operation; it is never held across an `.await`. Registered when +/// a host bg job spawns and removed when its drain task finalizes. +static KILL_SIGNALS: Lazy>>> = + Lazy::new(|| std::sync::Mutex::new(HashMap::new())); + +/// Register (or replace) a host bg job's kill-signal channel and return it for +/// the drain task to await. Called once at spawn, before the drain task starts. +pub fn register_kill_signal(id: &str) -> Arc { + let notify = Arc::new(tokio::sync::Notify::new()); + KILL_SIGNALS + .lock() + .expect("kill-signal map poisoned") + .insert(id.to_string(), notify.clone()); + notify +} + +/// Look up a live host bg job's kill-signal channel. `shell::kill` and the +/// shutdown sweep call this and `notify_one()` the result to request termination. +/// Returns `None` once the drain task has finalized and unregistered (the job is +/// already terminal, so there is nothing to kill). +pub fn kill_signal_for(id: &str) -> Option> { + KILL_SIGNALS + .lock() + .expect("kill-signal map poisoned") + .get(id) + .cloned() +} + +/// Remove a job's kill-signal channel. Called by the drain task as it finalizes, +/// so the map does not grow without bound across the worker's lifetime. +pub fn unregister_kill_signal(id: &str) { + KILL_SIGNALS + .lock() + .expect("kill-signal map poisoned") + .remove(id); +} + +/// Live count of background jobs in the `Running` state, maintained as a plain +/// atomic so the `shell.jobs.running` observable gauge can read it from a +/// synchronous OTel callback without taking any async lock (the `JOBS` map is +/// behind `tokio::sync::Mutex`, which cannot be locked inside a sync gauge +/// callback without risking a deadlock). Incremented exactly once when a job is +/// successfully reserved+inserted and decremented exactly once by +/// `RunningJobGuard::drop`, which the spawned finalize task holds — so any exit +/// path (normal completion, early return, or panic) still decrements. +static RUNNING_JOBS: AtomicUsize = AtomicUsize::new(0); + +/// Snapshot of the live running-job count for the metrics gauge. Cheap, +/// lock-free, and safe to call from a synchronous OTel observe callback. +pub fn running_gauge_value() -> usize { + RUNNING_JOBS.load(Ordering::Relaxed) +} + +/// RAII decrement for the `RUNNING_JOBS` gauge. The exec_bg spawn path moves one +/// of these into its detached finalize task; when that task ends (for any +/// reason) the `Drop` fires and the gauge returns to its true value. This keeps +/// the gauge correct without instrumenting every scattered status-flip site +/// (the host wait arms, the sandbox trigger arms, shell::kill, the shutdown +/// sweep) — the single task that owns the job's lifetime owns the decrement. +#[derive(Debug)] +pub struct RunningJobGuard { + _private: (), +} + +impl Drop for RunningJobGuard { + fn drop(&mut self) { + RUNNING_JOBS.fetch_sub(1, Ordering::Relaxed); + } +} + +/// Test-only serialization gate. `kill_running_host_jobs()` sweeps the *entire* +/// shared `JOBS` singleton, so any test that runs the sweep would SIGKILL every +/// other concurrently-running host job's child (the test binary runs tests in +/// parallel on one runtime). Tests that either run the global sweep or keep a +/// real long-lived host `Running` job in the map hold this mutex so they never +/// interleave. Not compiled into the production binary. +#[cfg(test)] +pub static HOST_SWEEP_TEST_GUARD: Lazy> = + Lazy::new(|| tokio::sync::Mutex::new(())); + +/// Test-only serialization gate for the `RUNNING_JOBS` gauge. The gauge is a +/// process-global atomic that every `try_reserve_and_insert` mutates, so any +/// unit test asserting an exact gauge delta must not interleave with another +/// test's reserve. Every reserve-using unit test in this module holds this +/// mutex; not compiled into the production binary. +#[cfg(test)] +pub static GAUGE_TEST_GUARD: Lazy> = + Lazy::new(|| tokio::sync::Mutex::new(())); + pub fn now_ms() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -63,12 +171,18 @@ pub fn now_ms() -> u64 { /// it's mid-finalization and either still Running or about to be — so the /// soft cap may briefly under-allow at the boundary, but never over-allows. /// +/// On success, returns the job id and a [`RunningJobGuard`] that owns this job's +/// contribution to the `shell.jobs.running` gauge: the gauge is incremented here +/// and decremented when the guard drops. The caller MUST move the guard into the +/// task that owns the job's lifetime so the running count tracks reality. +/// /// On rejection, returns the running count and the original handle so the -/// caller can reclaim the spawned child process and kill it. +/// caller can reclaim the spawned child process and kill it. The gauge is not +/// touched on rejection. pub async fn try_reserve_and_insert( handle: JobHandle, max: usize, -) -> Result { +) -> Result<(String, RunningJobGuard), (usize, JobHandle)> { let mut guard = JOBS.map.lock().await; let mut running = 0usize; for h in guard.values() { @@ -87,7 +201,10 @@ pub async fn try_reserve_and_insert( let id = handle.record.id.clone(); let boxed = Arc::new(Mutex::new(handle)); guard.insert(id.clone(), boxed); - Ok(id) + // Increment under the map lock so the gauge increment is ordered with the + // insert; the matching decrement is the returned guard's Drop. + RUNNING_JOBS.fetch_add(1, Ordering::Relaxed); + Ok((id, RunningJobGuard { _private: () })) } pub async fn get(id: &str) -> Option>> { @@ -132,6 +249,57 @@ pub async fn list_all() -> Vec { out } +/// Best-effort terminate every still-running host-backed job. Called on shutdown +/// so the worker does not leave orphaned OS processes behind. +/// +/// A running host bg job's `Child` is owned by its detached drain task (taken out +/// of the handle at spawn), so we do NOT signal a pid directly here — that risked +/// SIGKILLing a reused pid after the drain task's `wait()` had reaped the child. +/// Instead we notify each running host job's kill-signal channel; the drain task, +/// which still owns the un-reaped `Child`, kills the child's process group (so +/// grandchildren die too) and finalizes. We then poll briefly so shutdown is +/// deterministic rather than racing process exit; `kill_on_drop(true)` on each +/// child is the backstop if a drain task does not finish within the window. +/// +/// Sandbox-backed jobs (`host_pid: None`) own no local process and are skipped, as +/// are terminal jobs. Returns the number of jobs signalled. +pub async fn kill_running_host_jobs() -> usize { + let handles = snapshot().await; + let mut requested: Vec = Vec::new(); + for (id, handle) in &handles { + let h = handle.lock().await; + let is_running_host = h.record.status == JobStatus::Running && h.host_pid.is_some(); + drop(h); + if is_running_host { + if let Some(notify) = kill_signal_for(id) { + notify.notify_one(); + requested.push(id.clone()); + tracing::info!(job_id = %id, "requested shutdown kill of running host job"); + } + } + } + if requested.is_empty() { + return 0; + } + // Bounded grace for the notified drain tasks to group-kill and finalize. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3); + loop { + let mut still_running = 0usize; + for id in &requested { + if let Some(h) = get(id).await { + if h.lock().await.record.status == JobStatus::Running { + still_running += 1; + } + } + } + if still_running == 0 || std::time::Instant::now() >= deadline { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + requested.len() +} + /// Used by integration tests to assert lifecycle invariants. The /// production exec_bg path uses `try_reserve_and_insert` which does the /// counting and insertion atomically. @@ -153,6 +321,7 @@ mod tests { #[tokio::test] async fn test_insert_and_get() { + let _gate = GAUGE_TEST_GUARD.lock().await; let id = format!("test-insert-{}", uuid::Uuid::new_v4()); match try_reserve_and_insert(make_handle(&id, JobStatus::Running), usize::MAX).await { Ok(_) => {} @@ -163,6 +332,57 @@ mod tests { JOBS.map.lock().await.remove(&id); } + /// The `shell.jobs.running` gauge reads `running_gauge_value()`, which a + /// successful reserve increments and the returned `RunningJobGuard` Drop + /// decrements. Assert the delta is exactly +1 while the guard is held and + /// returns to baseline after it drops. + /// + /// The gauge is a process-global atomic that any concurrent test's reserve + /// perturbs, so this holds `GAUGE_TEST_GUARD` to read a stable baseline. + /// The rejected-reserve case is folded in here (rather than a second test) + /// so a single critical section covers both the +1/-1 and the no-op paths + /// without a second lock acquisition racing the first. + #[tokio::test] + async fn running_gauge_tracks_reserve_and_guard_drop() { + let _gate = GAUGE_TEST_GUARD.lock().await; + + // The two reads that bracket `drop(guard)` are synchronous with no + // intervening await, so the guard's `fetch_sub` is the only mutation + // between them — the delta is deterministic regardless of any other + // task's pending gauge activity (the gate keeps reserve-using tests out, + // but cross-module finalize tasks may still settle nearby). + let id = format!("gauge-guard-{}", uuid::Uuid::new_v4()); + let (got_id, guard) = + match try_reserve_and_insert(make_handle(&id, JobStatus::Running), usize::MAX).await { + Ok(pair) => pair, + Err(_) => panic!("usize::MAX cap must always succeed"), + }; + assert_eq!(got_id, id); + let with_guard = running_gauge_value(); + drop(guard); + let after_drop = running_gauge_value(); + assert_eq!( + after_drop + 1, + with_guard, + "dropping the RunningJobGuard must decrement the running gauge by exactly one" + ); + JOBS.map.lock().await.remove(&id); + + // A rejected reserve (cap exceeded) must NOT touch the gauge and yields + // no guard to later decrement. Again bracket with synchronous reads. + let rid = format!("gauge-reject-{}", uuid::Uuid::new_v4()); + let before_reject = running_gauge_value(); + match try_reserve_and_insert(make_handle(&rid, JobStatus::Running), 0).await { + Ok(_) => panic!("cap=0 must reject"), + Err((_, returned)) => assert_eq!(returned.record.id, rid), + } + assert_eq!( + running_gauge_value(), + before_reject, + "a rejected reserve must leave the running gauge unchanged" + ); + } + fn make_handle(id: &str, status: JobStatus) -> JobHandle { JobHandle { record: JobRecord { @@ -182,6 +402,7 @@ mod tests { stderr_truncated: false, }, child: None, + host_pid: None, } } @@ -203,4 +424,82 @@ mod tests { "rejected reservation must not insert into the map" ); } + + /// The reaper drives eviction by calling `remove_old` on a timer. This + /// asserts the prune path that the reaper relies on: a finished record + /// whose `finished_at_ms` is older than the retention window is removed. + /// The reaper wiring in `main()` is a thin sleep loop that calls this same + /// function with the live retention; it is not unit-tested without a + /// running engine, but the eviction it triggers is covered here. + #[tokio::test] + async fn remove_old_evicts_finished_job_past_retention() { + let _gate = GAUGE_TEST_GUARD.lock().await; + let id = format!("reaper-evict-{}", uuid::Uuid::new_v4()); + // Finished one hour ago. + let stale = now_ms().saturating_sub(60 * 60 * 1000); + let handle = JobHandle { + record: JobRecord { + finished_at_ms: Some(stale), + ..make_handle(&id, JobStatus::Finished).record + }, + child: None, + host_pid: None, + }; + try_reserve_and_insert(handle, usize::MAX) + .await + .ok() + .expect("seed insert"); + assert!(get(&id).await.is_some(), "pre-prune sanity"); + + // Retention of 1s: a job finished an hour ago is well past it. + remove_old(1).await; + assert!( + get(&id).await.is_none(), + "finished job older than retention must be evicted by the reaper prune path" + ); + } + + #[tokio::test] + async fn kill_running_host_jobs_skips_sandbox_and_terminal_jobs() { + // Acquire GAUGE before SWEEP (consistent global lock order) — this test + // reserves jobs, perturbing the running gauge other tests assert on. + let _gauge_gate = GAUGE_TEST_GUARD.lock().await; + // The sweep is global: hold the gate so we don't SIGKILL another test's + // live host child while scanning the shared map. + let _guard = HOST_SWEEP_TEST_GUARD.lock().await; + + // A sandbox-backed running job (host_pid: None) must NOT be marked Killed + // by the host sweep — it owns no local process. + let sb = format!("kill-sweep-sandbox-{}", uuid::Uuid::new_v4()); + try_reserve_and_insert(make_handle(&sb, JobStatus::Running), usize::MAX) + .await + .ok() + .expect("seed sandbox job"); + // A terminal job must be left untouched. + let done = format!("kill-sweep-done-{}", uuid::Uuid::new_v4()); + try_reserve_and_insert(make_handle(&done, JobStatus::Finished), usize::MAX) + .await + .ok() + .expect("seed finished job"); + + kill_running_host_jobs().await; + + let sb_status = get(&sb).await.unwrap().lock().await.record.status.clone(); + assert_eq!( + sb_status, + JobStatus::Running, + "sandbox job (host_pid: None) must survive the host kill sweep" + ); + let done_status = get(&done).await.unwrap().lock().await.record.status.clone(); + assert_eq!(done_status, JobStatus::Finished, "terminal job untouched"); + + JOBS.map.lock().await.remove(&sb); + JOBS.map.lock().await.remove(&done); + } + + // The end-to-end "shutdown sweep terminates a real child" test now lives in + // `functions::exec_bg` (`shutdown_sweep_terminates_real_host_job`), where it + // can drive the real `spawn_host_job` drain task that owns the Child and + // performs the process-group kill — the sweep only requests termination via + // the kill-signal channel, so a faked handle here could not be killed. } diff --git a/shell/src/lib.rs b/shell/src/lib.rs index 1bf89713..22598e53 100644 --- a/shell/src/lib.rs +++ b/shell/src/lib.rs @@ -3,11 +3,13 @@ //! share source files via Cargo's two-target compile. pub mod config; +pub mod configuration; pub mod exec; pub mod exec_dispatch; pub mod fs; pub mod functions; pub mod jobs; -pub mod manifest; +pub mod scode; pub mod target; +pub mod telemetry; pub mod triggers; diff --git a/shell/src/main.rs b/shell/src/main.rs index e2ee5d52..0c6451a3 100644 --- a/shell/src/main.rs +++ b/shell/src/main.rs @@ -1,32 +1,43 @@ -use anyhow::Result; +use anyhow::{Context, Result}; use clap::Parser; +use iii_observability::OtelConfig; use iii_sdk::{register_worker, IIIError, InitOptions, RegisterFunction}; use serde_json::Value; -use std::sync::Arc; mod config; +mod configuration; mod exec; mod exec_dispatch; mod fs; mod functions; mod jobs; -mod manifest; +mod scode; mod target; +mod telemetry; mod triggers; +use configuration::AppState; use functions::types::{KillRequest, StatusRequest}; #[derive(Parser, Debug)] #[command(name = "shell", about = "Unix shell execution worker for iii agents")] struct Cli { + /// Seed config registered as `initial_value` with the `configuration` worker + /// on first registration. Defaults to ./config.yaml. The live value from the + /// configuration worker takes precedence once an entry exists. #[arg(long, default_value = "./config.yaml")] config: String, #[arg(long, env = "III_URL", default_value = "ws://127.0.0.1:49134")] url: String, +} - #[arg(long)] - manifest: bool, +/// JSON Schema for a typed request/response struct, attached to a `Value` +/// handler via `request_format`/`response_format` so the engine publishes the +/// full contract while the handler keeps its legacy `S210` deserialization. +fn schema_value() -> Value { + let root = schemars::gen::SchemaGenerator::default().into_root_schema_for::(); + serde_json::to_value(root).expect("schema serializes") } #[tokio::main] @@ -39,329 +50,423 @@ async fn main() -> Result<()> { .init(); let cli = Cli::parse(); + tracing::info!(url = %cli.url, seed_config = %cli.config, "connecting to III engine"); - if cli.manifest { - let m = manifest::build_manifest(); - println!("{}", serde_json::to_string_pretty(&m).unwrap()); - return Ok(()); - } + let iii = register_worker( + &cli.url, + InitOptions { + otel: Some(OtelConfig::default()), + ..Default::default() + }, + ); - let shell_config = match config::load_config(&cli.config) { - Ok(c) => { - tracing::info!( - allowlist_size = c.allowlist.len(), - denylist_size = c.denylist_patterns.len(), - max_timeout_ms = c.max_timeout_ms, - max_concurrent = c.max_concurrent_jobs, - "loaded config from {}", - cli.config - ); - c + // Build the per-call metric instruments once (and register the + // shell.jobs.running observable gauge) now that the OTel meter provider is + // installed. Idempotent and a silent no-op when no collector is attached. + telemetry::init(); + + let seed = match config::ShellConfig::from_file(&cli.config) { + Ok(cfg) => { + tracing::info!(path = %cli.config, "loaded seed config for initial registration"); + Some(cfg) } Err(e) => { - tracing::warn!(error = %e, path = %cli.config, "failed to load config, using defaults"); - let mut c = config::ShellConfig::default(); - c.compile_denylist()?; - // Defaults have host_root=None and allow_unjailed=false, so this - // path refuses to start. Otherwise a missing config file would - // silently bypass the S-H2 jail requirement. - c.validate_fs_jail()?; - c + tracing::warn!(path = %cli.config, error = %e, "failed to load seed config; relying on existing configuration entry"); + None } }; - let shared = Arc::new(shell_config); - tracing::info!(url = %cli.url, "connecting to III engine"); - let iii = register_worker(&cli.url, InitOptions::default()); + configuration::register_config(&iii, seed.as_ref()) + .await + .map_err(anyhow::Error::msg) + .context("registering shell configuration schema")?; + + let cfg = configuration::fetch_config(&iii) + .await + .map_err(anyhow::Error::msg) + .context("loading shell configuration")?; + + let runtime = configuration::build_runtime(&cfg, &iii) + .map_err(anyhow::Error::msg) + .context("building initial shell runtime")?; - // shell::exec, shell::exec_bg, and the 10 shell::fs::* handlers take - // Value at the registration boundary so they can preserve legacy wire - // contracts (S210 for fs malformed payloads, "missing 'command'" / - // "must be a string" for exec, silent timeout_ms fallback) that the - // SDK's typed deserialization can't reproduce. + // ONE advisory reminder at boot (not per-reload): an operator might mistake + // the exec denylist for a hard boundary. It is advisory regex over + // argv.join(" "); the real security boundary is the sandbox backend. + if !runtime.config.denylist_patterns.is_empty() { + tracing::warn!( + target: "sandbox", + count = runtime.config.denylist_patterns.len(), + "exec denylist is ADVISORY: regex matched against argv.join(\" \"), trivially \ + evadable — the security boundary is the sandbox backend, not this denylist" + ); + } + let state = AppState { + runtime: std::sync::Arc::new(tokio::sync::RwLock::new(runtime)), + iii: iii.clone(), + reload_lock: std::sync::Arc::new(tokio::sync::Mutex::new(())), + reload_status: std::sync::Arc::new(tokio::sync::RwLock::new( + configuration::ReloadStatus::default(), + )), + }; + + // Register the config-change trigger and reconcile BEFORE exposing public + // functions. The trigger registration + this reconcile sit ahead of every + // shell/fs function, so a failure here aborts startup before anything is + // exposed (fail-closed). The reconcile closes the boot race: an update that + // landed between the initial fetch (above) and trigger registration has no + // listener and no later event to repair it, so we MUST confirm the + // authoritative value before serving. fetch_config already retries + // internally; the initial fetch already proved the engine reachable, so a + // failure here means the engine just went away — refuse to serve a possibly + // stale security policy and exit so the supervisor restarts us. + configuration::register_config_trigger(&iii, state.clone()) + .context("registering configuration change trigger")?; + configuration::reconcile(&state) + .await + .map_err(anyhow::Error::msg) + .context( + "boot reconcile of configuration failed (refusing to serve a possibly stale policy)", + )?; + + // exec / exec_bg / list read the live config snapshot from AppState. { - let cfg = shared.clone(); - let iii_for_exec = iii.clone(); + let st = state.clone(); iii.register_function( "shell::exec", RegisterFunction::new_async(move |req: functions::types::ExecRequest| { - let cfg = cfg.clone(); - let iii_clone = iii_for_exec.clone(); - async move { - functions::exec::handle(cfg, iii_clone, req) - .await - .map_err(IIIError::from) - } + let st = st.clone(); + telemetry::record_call("shell::exec", async move { + let cfg = { st.runtime.read().await.config.clone() }; + // handle already returns Result<_, IIIError> (S-codes lifted + // to Remote inside); no map_err needed. + let res = functions::exec::handle(cfg, st.iii.clone(), req).await; + // Truncation is only visible on the typed Ok response (the + // generic record_call wrapper sees an opaque T), so emit the + // truncation counter here without altering the result. + if let Ok(ref out) = res { + telemetry::record_output_truncated( + "shell::exec", + out.stdout_truncated, + out.stderr_truncated, + ); + } + res + }) }) .description( - "Run an allowlisted command in the foreground and return its \ - full output. Payload: { command: string (program name), \ - args?: string[], timeout_ms?: number, target?: { kind: \ - 'host'|'sandbox', sandbox_id?: string } }. Returns { stdout, \ - stderr, exit_code, duration_ms, timed_out, stdout_truncated, \ - stderr_truncated }. Do NOT pass argv as an array in 'command' \ - — split program and arguments across the two fields.", + "Run an allowlisted command in the foreground and return its full output. \ + Put the program name in `command` (string) and arguments in `args` (string[]) — \ + do NOT pass argv as an array in `command`. `timeout_ms` is capped at the \ + configured max; negative/fractional values fall back to the default. `target` \ + defaults to the host; pass { kind: \"sandbox\", sandbox_id } to run in a microVM. \ + Optional host-only `cwd` scopes this call to a directory (jail-confined exactly \ + like shell::fs::* paths; escaping it is S215), optional `env` (object) sets \ + per-call values — but only for keys already in allowed_env and never for \ + PATH/IFS/HOME/LD_*/DYLD_* or other loader/lookup and interpreter-startup keys \ + (those reject S210) — and optional host-only `stdin` (string) is written to the \ + program's standard input (use it for `tee`, `patch`, or any stdin filter instead \ + of a shell heredoc). cwd/env/stdin on a sandbox target reject S210. \ + Backend errors return { code, message }; common: S216 host exec error, S300 VM \ + boot failed, S200 in-VM failure. argv-parse and allowlist/denylist rejections are \ + plain-string messages naming the violation.", ), ); } - { - let cfg = shared.clone(); - let iii_for_bg = iii.clone(); + let st = state.clone(); iii.register_function( "shell::exec_bg", RegisterFunction::new_async(move |req: functions::types::ExecBgRequest| { - let cfg = cfg.clone(); - let iii_clone = iii_for_bg.clone(); - async move { - functions::exec_bg::handle(cfg, iii_clone, req) + let st = st.clone(); + telemetry::record_call("shell::exec_bg", async move { + let cfg = { st.runtime.read().await.config.clone() }; + functions::exec_bg::handle(cfg, st.iii.clone(), req) .await .map_err(IIIError::from) - } + }) }) .description( - "Spawn an allowlisted command as a background job. Same \ - payload shape as shell::exec; returns { job_id, argv } \ - immediately. Poll with shell::status, terminate with \ - shell::kill, list with shell::list. Do NOT pass argv as an \ - array in 'command' — use 'command' (string) + 'args' \ - (string[]).", + "Spawn an allowlisted command as a background job; returns { job_id, argv } \ + immediately. Same payload as shell::exec (command + args, do NOT pass argv as an \ + array), including the optional host-only `cwd` (jail-confined; escape is S215), \ + `env` (only allowed_env keys, never PATH/IFS/HOME/LD_*/DYLD_* or other loader/lookup \ + and interpreter-startup keys), and `stdin` (string written to the job's stdin); \ + violations and cwd/env/stdin on a sandbox target reject with an S210 message. Poll \ + with shell::status, terminate with shell::kill, list with shell::list. \ + Host background jobs ignore the per-call timeout_ms and run until they exit or \ + shell::kill terminates them; set the operator config `max_bg_timeout_ms` (0 = \ + unbounded, the default) to force-kill a runaway job after that long. \ + Spawn-time failures (argv-parse, allowlist/denylist, cwd/env gating, spawn, \ + concurrency cap) are plain-string messages naming the violation; once spawned, \ + per-job failures surface through shell::status (the job record's status/stderr), \ + not this call's return.", ), ); } iii.register_function( "shell::kill", - RegisterFunction::new_async(|req: KillRequest| async move { - functions::kill::handle(req).await.map_err(IIIError::from) + RegisterFunction::new_async(|req: KillRequest| { + telemetry::record_call("shell::kill", async move { + functions::kill::handle(req).await.map_err(IIIError::from) + }) }) - .description("Kill a running background job"), + .description( + "Terminate a running background job by job_id (the UUID from shell::exec_bg). \ + Errors return { code, message }; common: S211 no such job, S216 kill/signal delivery \ + failure.", + ), ); iii.register_function( "shell::status", - RegisterFunction::new_async(|req: StatusRequest| async move { - functions::status::handle(req).await.map_err(IIIError::from) + RegisterFunction::new_async(|req: StatusRequest| { + telemetry::record_call("shell::status", async move { + functions::status::handle(req).await.map_err(IIIError::from) + }) }) - .description("Get status of a background job"), + .description( + "Fetch the full record (status, exit_code, timing) of a background job by job_id. \ + Errors return { code, message }; common: S211 no such job.", + ), ); { - let cfg = shared.clone(); + let st = state.clone(); iii.register_function( "shell::list", + // Ignore the payload: shell::list takes no args, and the engine + // injects a `_caller_worker_id` field into every call — a typed + // request param would reject that injected field. (ListRequest is + // schema-only; see its doc comment.) RegisterFunction::new_async(move |_req: Value| { - let cfg = cfg.clone(); - async move { functions::list::handle(cfg).await.map_err(IIIError::from) } + let st = st.clone(); + telemetry::record_call("shell::list", async move { + let cfg = { st.runtime.read().await.config.clone() }; + functions::list::handle(cfg).await.map_err(IIIError::from) + }) }) - .description("List all background jobs"), - ); - } - - let host_fs_cfg = std::sync::Arc::new(crate::fs::host::HostFsConfig { - host_root: shared.fs.host_root.clone(), - max_read_bytes: shared.fs.max_read_bytes, - max_write_bytes: shared.fs.max_write_bytes, - denylist_paths: shared.fs.denylist_paths.clone(), - }); - let chan_maker: std::sync::Arc = - std::sync::Arc::new(crate::fs::host::IiiChannelMaker::new(iii.clone())); - let host_backend: std::sync::Arc = - std::sync::Arc::new(crate::fs::host::HostFsBackend::new(host_fs_cfg, chan_maker)); - let sb_enabled = shared.sandbox.enabled; - - if shared.fs.host_root.is_none() { - tracing::warn!( - "fs.host_root is unset — host backend is unjailed; \ - every absolute path is reachable by shell::fs::* (denylist still applies)", + .request_format(schema_value::()) + .response_format(schema_value::()) + .description( + "List background jobs (running + recently completed). Takes no arguments.", + ), ); } { - let h = host_backend.clone(); - let i = iii.clone(); + let st = state.clone(); iii.register_function( - "shell::fs::ls", - RegisterFunction::new_async(move |req: Value| { - let h = h.clone(); - let i = i.clone(); - async move { - functions::fs_ls::handle(h, i, sb_enabled, req) - .await - .map_err(IIIError::from) - } + "shell::config-status", + // Ignore the payload (see shell::list): no-arg call, and the + // engine-injected `_caller_worker_id` would break a typed param. + RegisterFunction::new_async(move |_req: Value| { + let st = st.clone(); + telemetry::record_call("shell::config-status", async move { + let status = { st.reload_status.read().await.clone() }; + Ok::(serde_json::to_value(status)?) + }) }) - .description("List directory contents on host or sandbox"), + .request_format(schema_value::()) + .response_format(schema_value::()) + .description( + "Report the last configuration hot-reload outcome: last_outcome \ + (applied|rejected), last_error, and rejected_reloads (count since \ + boot). A rejected outcome or non-zero count means a stored config \ + was refused and shell is enforcing an older policy than the central \ + store. Takes no arguments.", + ), ); } - { - let h = host_backend.clone(); - let i = iii.clone(); - iii.register_function( - "shell::fs::stat", - RegisterFunction::new_async(move |req: Value| { - let h = h.clone(); - let i = i.clone(); - async move { - functions::fs_stat::handle(h, i, sb_enabled, req) - .await - .map_err(IIIError::from) - } - }) - .description("Stat a path on host or sandbox"), - ); - } + // fs::* keep Value handlers (preserving S210) and read the live host backend + // + sandbox toggle from AppState; the typed schema is attached separately. + register_fs(&iii, &state); - { - let h = host_backend.clone(); - let i = iii.clone(); - iii.register_function( - "shell::fs::mkdir", - RegisterFunction::new_async(move |req: Value| { - let h = h.clone(); - let i = i.clone(); - async move { - functions::fs_mkdir::handle(h, i, sb_enabled, req) - .await - .map_err(IIIError::from) - } - }) - .description("Create a directory on host or sandbox"), - ); - } + // Background reaper: time-based eviction of finished JobRecords. Without + // it, an agent that uses exec_bg + status-polling (and never calls + // shell::list) leaks every finished record — each holding up to + // max_output_bytes of stdout + stderr — for the worker's lifetime. The + // prune-on-list path remains as a harmless secondary trigger. The reaper + // reads retention from the LIVE config snapshot so a hot-reload of + // job_retention_secs is honored. It is detached and does not block + // shutdown: the process exits on signal regardless of where this loop is. + spawn_job_reaper(state.clone()); - { - let h = host_backend.clone(); - let i = iii.clone(); - iii.register_function( - "shell::fs::rm", - RegisterFunction::new_async(move |req: Value| { - let h = h.clone(); - let i = i.clone(); - async move { - functions::fs_rm::handle(h, i, sb_enabled, req) - .await - .map_err(IIIError::from) - } - }) - .description("Remove a path on host or sandbox"), - ); - } + tracing::info!("shell registered all functions, ready"); + wait_for_shutdown_signal().await?; + tracing::info!("shell shutting down"); - { - let h = host_backend.clone(); - let i = iii.clone(); - iii.register_function( - "shell::fs::chmod", - RegisterFunction::new_async(move |req: Value| { - let h = h.clone(); - let i = i.clone(); - async move { - functions::fs_chmod::handle(h, i, sb_enabled, req) - .await - .map_err(IIIError::from) - } - }) - .description("Change permissions on host or sandbox"), - ); + // Deterministic cleanup: terminate in-flight host jobs so they do not + // outlive the worker as orphans. A running host bg job's Child is owned by + // its drain task (not the handle), so the sweep does NOT signal a pid + // directly (that risked killing a reused pid); it notifies each job's + // kill-signal channel and the drain task kills the child's process group, + // then the sweep polls up to ~3s for the jobs to finalize so shutdown is + // deterministic. kill_on_drop(true) on the spawned commands is the backstop + // if a drain task does not finish within that window. + let killed = jobs::kill_running_host_jobs().await; + if killed > 0 { + tracing::info!(count = killed, "killed in-flight host jobs on shutdown"); } - { - let h = host_backend.clone(); - let i = iii.clone(); - iii.register_function( - "shell::fs::mv", - RegisterFunction::new_async(move |req: Value| { - let h = h.clone(); - let i = i.clone(); - async move { - functions::fs_mv::handle(h, i, sb_enabled, req) - .await - .map_err(IIIError::from) - } - }) - .description("Move/rename a path on host or sandbox"), - ); - } + iii.shutdown_async().await; + Ok(()) +} - { - let h = host_backend.clone(); - let i = iii.clone(); - iii.register_function( - "shell::fs::grep", - RegisterFunction::new_async(move |req: Value| { - let h = h.clone(); - let i = i.clone(); - async move { - functions::fs_grep::handle(h, i, sb_enabled, req) - .await - .map_err(IIIError::from) - } - }) - .description("Recursive regex search on host or sandbox"), - ); - } +/// Detached task that prunes finished `JobRecord`s on a fixed cadence using the +/// live retention from the config snapshot. Interval is `min(30s, retention/2)` +/// so a short retention still gets timely eviction, but we never poll more +/// often than every second. +/// +/// Intentionally fire-and-forget: the returned `JoinHandle` is dropped, so the +/// task has no shutdown hook and is torn down when the tokio runtime stops on +/// signal. That is acceptable because the reaper only evicts already-finished +/// records (pure cleanup) — losing it mid-tick on shutdown drops nothing the +/// process needs, and the explicit kill sweep in `main()` handles live jobs. +fn spawn_job_reaper(state: AppState) { + const MAX_INTERVAL_SECS: u64 = 30; + tokio::spawn(async move { + loop { + let retention = { state.runtime.read().await.config.job_retention_secs }; + // retention/2 keeps eviction timely for short windows; clamp to + // [1s, 30s] so we neither hot-spin nor lag far behind retention. + let tick_secs = (retention / 2).clamp(1, MAX_INTERVAL_SECS); + tokio::time::sleep(std::time::Duration::from_secs(tick_secs)).await; + jobs::remove_old(retention).await; + } + }); +} - { - let h = host_backend.clone(); - let i = iii.clone(); - iii.register_function( - "shell::fs::sed", - RegisterFunction::new_async(move |req: Value| { - let h = h.clone(); - let i = i.clone(); - async move { - functions::fs_sed::handle(h, i, sb_enabled, req) - .await - .map_err(IIIError::from) - } - }) - .description("Find-and-replace on host or sandbox"), - ); +/// Register the 10 shell::fs::* functions. Each keeps a `Value` handler (so the +/// inline S210 mapping survives), reads the live host backend + sandbox toggle +/// from AppState, and publishes its typed schema via request/response_format. +fn register_fs(iii: &iii_sdk::III, state: &AppState) { + macro_rules! fs_fn { + ($id:literal, $module:ident, $req:ty, $resp:ty, $desc:expr) => {{ + let st = state.clone(); + iii.register_function( + $id, + RegisterFunction::new_async(move |req: Value| { + let st = st.clone(); + // telemetry::record_call times the call, classifies the + // Result into outcome/code, and emits shell.calls + + // shell.call.duration_ms — returning the Result untouched. + telemetry::record_call($id, async move { + let (host, sb_enabled) = { + let rt = st.runtime.read().await; + (rt.host_backend.clone(), rt.config.sandbox.enabled) + }; + // handle already returns Result<_, IIIError> (FsError + // S-codes lifted to Remote inside); no map_err needed. + functions::$module::handle(host, st.iii.clone(), sb_enabled, req).await + }) + }) + .request_format(schema_value::<$req>()) + .response_format(schema_value::<$resp>()) + .description($desc), + ); + }}; } + fs_fn!("shell::fs::ls", fs_ls, fs::LsRequest, fs::LsResponse, + "List directory contents. `path` is relative to the configured fs jail root (fs.host_root) \ + when set, otherwise absolute. `target` defaults to host; pass { kind: \"sandbox\", sandbox_id } \ + to run in a microVM. Errors return { code, message }; common: S210 bad path, S211 not found, \ + S212 not a directory, S215 jail/denylist."); + fs_fn!("shell::fs::stat", fs_stat, fs::StatRequest, fs::StatResponse, + "Stat a single path (jail-relative when fs.host_root is set). Returns the entry's type, size, \ + mode, and mtime. Errors return { code, message }; common: S211 not found, S215 jail/denylist."); + fs_fn!("shell::fs::mkdir", fs_mkdir, fs::MkdirRequest, fs::MkdirResponse, + "Create a directory. `mode` is an octal string like \"0755\". `parents: true` creates missing \ + parents and is idempotent. Returns { created, path, already_existed }. Errors return \ + { code, message }; common: S210 bad mode, S213 exists, S215 jail/denylist."); + fs_fn!( + "shell::fs::rm", + fs_rm, + fs::RmRequest, + fs::RmResponse, + "Remove a path. `recursive: true` is required to delete a non-empty directory. Returns \ + { removed, path, was_present }. Errors return { code, message }; common: S211 not found, \ + S214 dir not empty (pass recursive), S215 jail/denylist." + ); + fs_fn!("shell::fs::chmod", fs_chmod, fs::ChmodRequest, fs::ChmodResponse, + "Change permissions. `mode` is an octal string like \"0644\". `uid`/`gid` optionally chown. \ + `recursive: true` walks the tree (symlinks skipped). Returns { entries_changed, path, recursive }. \ + Errors return { code, message }; common: S210 bad mode, S211 not found, S215 jail/denylist."); + fs_fn!( + "shell::fs::mv", + fs_mv, + fs::MvRequest, + fs::MvResponse, + "Move/rename a path. `overwrite: true` allows replacing an existing dst. Returns \ + { moved, src, dst, overwrote }. Errors return { code, message }; common: S211 src not found, \ + S213 dst exists, S215 jail/denylist." + ); + fs_fn!( + "shell::fs::grep", + fs_grep, + fs::GrepRequest, + fs::GrepResponse, + "Search file contents. `pattern` is a Rust regex (RE2-like). `recursive` defaults true. \ + `include_glob`/`exclude_glob` filter paths. Returns { matches, truncated }. Errors return \ + { code, message }; common: S217 bad regex, S215 jail/denylist." + ); + fs_fn!("shell::fs::sed", fs_sed, fs::SedRequest, fs::SedResponse, + "Find-and-replace across files. `pattern` is a Rust regex by default (set regex:false for a \ + literal). Provide either `files` (explicit list) or `path` (+ recursive). Returns \ + { results, total_replacements }. Errors return { code, message }; common: S217 bad regex, \ + S211 not found, S215 jail/denylist."); + fs_fn!( + "shell::fs::write", + fs_write, + fs::WriteRequest, + fs::WriteResponse, + "Write a file. Simplest form: { path, content: \"text\" } — `content` as a plain STRING is \ + written inline (host target only), no streaming channel needed. For large/streamed payloads \ + or a sandbox target, pass `content` as a ContentRef { channel_id, access_key, direction } \ + from a write stream channel you opened through the engine's streaming layer (inline strings \ + reject S210 on a sandbox target). To write several files at once, pass `files: [{ path, \ + content, mode?, parents? }, ...]` instead of the single-file fields (host target, inline \ + content) — the response then carries per-file results in `files`. `mode` is octal \ + (default \"0644\"); `parents: true` creates missing parents. Errors return { code, message }; \ + common: S210 bad mode/payload or inline-on-sandbox, S215 jail/denylist, S218 payload exceeds \ + max_write_bytes, S216 channel/IO error." + ); + fs_fn!("shell::fs::read", fs_read, fs::ReadRequest, fs::ReadResponseWire, + "Stream a file from a path. Returns a ContentRef the caller reads from, plus size/mode/mtime. \ + Errors return { code, message }; common: S211 not found, S212 path is a directory, S215 \ + jail/denylist, S218 file exceeds max_read_bytes, S216 channel/IO error."); +} + +/// Wait for SIGINT or, on Unix, SIGTERM so `docker stop` / `kubectl delete` +/// (SIGTERM) still trigger a clean `iii.shutdown_async()`. +async fn wait_for_shutdown_signal() -> std::io::Result<()> { + #[cfg(unix)] { - let h = host_backend.clone(); - let i = iii.clone(); - iii.register_function( - "shell::fs::write", - RegisterFunction::new_async(move |req: Value| { - let h = h.clone(); - let i = i.clone(); - async move { - functions::fs_write::handle(h, i, sb_enabled, req) - .await - .map_err(IIIError::from) - } - }) - .description("Stream a file to a host path or sandbox via StreamChannelRef"), - ); + use tokio::signal::unix::{signal, SignalKind}; + let mut sigterm = signal(SignalKind::terminate())?; + tokio::select! { + r = tokio::signal::ctrl_c() => r, + _ = sigterm.recv() => Ok(()), + } } - + #[cfg(not(unix))] { - let h = host_backend.clone(); - let i = iii.clone(); - iii.register_function( - "shell::fs::read", - RegisterFunction::new_async(move |req: Value| { - let h = h.clone(); - let i = i.clone(); - async move { - functions::fs_read::handle(h, i, sb_enabled, req) - .await - .map_err(IIIError::from) - } - }) - .description("Stream a file from a host path or sandbox via StreamChannelRef"), - ); + tokio::signal::ctrl_c().await } +} - tracing::info!("shell registered 15 functions, ready"); +#[cfg(test)] +mod tests { + use super::Cli; + use clap::Parser; - tokio::signal::ctrl_c().await?; - tracing::info!("shell shutting down"); - iii.shutdown_async().await; - Ok(()) + #[test] + fn config_defaults_to_local_config_yaml() { + let cli = Cli::parse_from(["shell"]); + assert_eq!(cli.config, "./config.yaml"); + } } diff --git a/shell/src/manifest.rs b/shell/src/manifest.rs deleted file mode 100644 index 878baab6..00000000 --- a/shell/src/manifest.rs +++ /dev/null @@ -1,98 +0,0 @@ -use serde_json::{json, Value}; - -pub fn build_manifest() -> Value { - json!({ - "name": "shell", - "version": env!("CARGO_PKG_VERSION"), - "description": "Unix shell execution worker for iii agents", - "functions": [ - { - "id": "shell::exec", - "description": "Execute a command synchronously and return stdout/stderr (capped at max_output_bytes; truncation flagged per stream)", - }, - { - "id": "shell::exec_bg", - "description": "Spawn a command in the background and return job_id", - }, - { - "id": "shell::kill", - "description": "Kill a running background job", - }, - { - "id": "shell::status", - "description": "Get status of a background job", - }, - { - "id": "shell::list", - "description": "List all background jobs (running + recently completed)", - }, - { - "id": "shell::fs::ls", - "description": "List directory contents on host or sandbox", - }, - { - "id": "shell::fs::stat", - "description": "Stat a path on host or sandbox", - }, - { - "id": "shell::fs::mkdir", - "description": "Create a directory on host or sandbox", - }, - { - "id": "shell::fs::rm", - "description": "Remove a path on host or sandbox", - }, - { - "id": "shell::fs::chmod", - "description": "Change permissions on host or sandbox", - }, - { - "id": "shell::fs::mv", - "description": "Move/rename a path on host or sandbox", - }, - { - "id": "shell::fs::grep", - "description": "Recursive regex search on host or sandbox", - }, - { - "id": "shell::fs::sed", - "description": "Find-and-replace on host or sandbox", - }, - { - "id": "shell::fs::write", - "description": "Stream a file to a host path or sandbox via StreamChannelRef", - }, - { - "id": "shell::fs::read", - "description": "Stream a file from a host path or sandbox via StreamChannelRef", - }, - ], - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_manifest_has_required_fields() { - let m = build_manifest(); - assert!(m.get("name").is_some()); - assert!(m.get("version").is_some()); - assert!(m.get("functions").is_some()); - let fns = m.get("functions").unwrap().as_array().unwrap(); - assert_eq!(fns.len(), 15); - } - - #[test] - fn test_manifest_json_output() { - let m = build_manifest(); - let s = serde_json::to_string(&m).unwrap(); - assert!(s.contains("shell::exec")); - assert!(s.contains("shell::exec_bg")); - assert!(s.contains("shell::kill")); - assert!(s.contains("shell::fs::read")); - assert!(s.contains("shell::fs::write")); - assert!(s.contains("shell::fs::grep")); - } -} diff --git a/shell/src/scode.rs b/shell/src/scode.rs new file mode 100644 index 00000000..5f8d4d22 --- /dev/null +++ b/shell/src/scode.rs @@ -0,0 +1,291 @@ +//! Shared inbound S-code mapping for the sandbox-target backends. +//! +//! Both `fs::sandbox` and `exec::sandbox` forward ops to the engine +//! daemon and must translate the engine's error envelope (an +//! `IIIError`, or a stringified `{code,message}` payload) back into a +//! worker error type. The translation logic — scanning for an S-code, +//! canonicalizing it against the known table, and lifting an +//! `IIIError` into a worker error — was previously duplicated in both +//! modules. The two copies of the canonical-code table had already +//! diverged: each path recognized a different subset of S-codes, so +//! the same engine code could canonicalize to its specific value on +//! one path while collapsing to the generic `S216` on the other. +//! +//! This module unifies all three helpers behind one +//! constructor-agnostic surface. `map_iii_err` is generic over the +//! error constructor, so `fs` passes `FsError::new` and `exec` passes +//! `ExecError::new`; both share the SAME canonical table — the union +//! of every code either path legitimately handles. + +use iii_sdk::IIIError; +use serde_json::Value; + +/// The generic fallback code for any engine error whose S-code we do +/// not recognize (or cannot recover at all). +pub const FALLBACK_CODE: &str = "S216"; + +/// Canonicalize an engine-supplied S-code string into a `&'static str` +/// from the one unified table. Unknown codes collapse to +/// [`FALLBACK_CODE`]. +/// +/// The table is the union of the codes the fs and exec sandbox paths +/// each legitimately handle: +/// - shared lifecycle/config codes: `S001`–`S004`, `S200`, `S210`, `S216` +/// - fs-specific codes: `S211`–`S215`, `S217`–`S219` +/// - exec-specific codes: `S100`–`S102`, `S300`, `S400` +/// +/// Mapping every code on BOTH paths is intentional: the worker should +/// never silently downgrade an engine code to `S216` just because it +/// arrived over the "other" path. +pub fn map_static_code(code: &str) -> &'static str { + match code { + // Shared: validation / lifecycle / config + generic fallback. + "S001" => "S001", + "S002" => "S002", + "S003" => "S003", + "S004" => "S004", + "S200" => "S200", + "S210" => "S210", + "S216" => "S216", + // exec-specific: image / VM / resource codes. + "S100" => "S100", + "S101" => "S101", + "S102" => "S102", + "S300" => "S300", + "S400" => "S400", + // fs-specific: path / permission / payload codes. + "S211" => "S211", + "S212" => "S212", + "S213" => "S213", + "S214" => "S214", + "S215" => "S215", + "S217" => "S217", + "S218" => "S218", + "S219" => "S219", + _ => FALLBACK_CODE, + } +} + +/// Scan a free-form string for the first standalone S-code (`S` + 3 +/// digits) and return it. Used to recover a code from an +/// `invocation_failed` wrapper message or a raw handler string. +/// +/// Matches preceded by an alphanumeric or `_` are rejected so we don't +/// grab the tail of a longer identifier (e.g. `FOO_S211`). +pub fn scan_s_code(s: &str) -> Option<&str> { + let bytes = s.as_bytes(); + // The window is 4 bytes (`bytes[i..=i+3]`), so the last valid `i` is + // `len - 4`. The loop bound `< len - 3` gives `i <= len - 4`. + // `saturating_sub(3)` collapses to 0 when `len < 4`, which yields an + // empty range (no false access) on too-short inputs. + for i in 0..bytes.len().saturating_sub(3) { + if bytes[i] == b'S' + && bytes[i + 1].is_ascii_digit() + && bytes[i + 2].is_ascii_digit() + && bytes[i + 3].is_ascii_digit() + { + // Reject matches glued to a longer identifier on EITHER side so we + // don't grab a substring of it: "FOO_S211" (left) or "S211foo" / + // "S2119" (right) are not standalone S-codes. + let preceded_by_word = + i > 0 && (bytes[i - 1].is_ascii_alphanumeric() || bytes[i - 1] == b'_'); + let followed_by_word = i + 4 < bytes.len() + && (bytes[i + 4].is_ascii_alphanumeric() || bytes[i + 4] == b'_'); + if !preceded_by_word && !followed_by_word { + return Some(&s[i..i + 4]); + } + } + } + None +} + +/// Recover a canonical S-code from an `IIIError` and lift it into the +/// caller's error type via `make`. +/// +/// Engine `Remote` errors carry the code structurally; the engine's +/// `invocation_failed` wrapper hides it inside the message string; mock +/// paths emit a JSON payload as `Handler(string)`. Unknown shapes fall +/// through to [`FALLBACK_CODE`]. +/// +/// Generic over the error constructor so both `FsError::new` and +/// `ExecError::new` reuse the identical recovery logic; the only +/// difference between the fs and exec paths is which `make` is passed. +pub fn map_iii_err(err: &IIIError, make: impl Fn(&'static str, String) -> E) -> E { + match err { + IIIError::Remote { code, message, .. } if code.starts_with('S') => { + return make( + map_static_code(code), + format!("forwarded from engine: {message}"), + ); + } + IIIError::Remote { message, .. } => { + if let Some(c) = scan_s_code(message) { + return make( + map_static_code(c), + format!("forwarded from engine (wrapped): {message}"), + ); + } + } + IIIError::Handler(s) => { + if let Ok(parsed) = serde_json::from_str::(s) { + if let Some(c) = parsed.get("code").and_then(|v| v.as_str()) { + let msg = parsed.get("message").and_then(|v| v.as_str()).unwrap_or(""); + return make(map_static_code(c), format!("forwarded from engine: {msg}")); + } + } + if let Some(c) = scan_s_code(s) { + return make( + map_static_code(c), + format!("forwarded from engine (raw): {s}"), + ); + } + } + _ => {} + } + make(FALLBACK_CODE, format!("engine error: {err:?}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A representative engine code set, including every code that was + /// previously divergent between the fs and exec `map_static_code` + /// tables, plus the shared codes and an unknown one. + /// + /// `expected == input` means the code is canonical (passes through); + /// the only collapsing case is the unknown `S999` -> `S216`. + fn canonical_cases() -> Vec<(&'static str, &'static str)> { + vec![ + // Shared across both paths. + ("S001", "S001"), + ("S002", "S002"), + ("S003", "S003"), + ("S004", "S004"), + ("S200", "S200"), + ("S210", "S210"), + ("S216", "S216"), + // Previously exec-only — now recognized on the fs path too. + ("S100", "S100"), + ("S101", "S101"), + ("S102", "S102"), + ("S300", "S300"), + ("S400", "S400"), + // Previously fs-only — now recognized on the exec path too. + ("S211", "S211"), + ("S212", "S212"), + ("S213", "S213"), + ("S214", "S214"), + ("S215", "S215"), + ("S217", "S217"), + ("S218", "S218"), + ("S219", "S219"), + // Unknown collapses to the generic fallback. + ("S999", "S216"), + ] + } + + #[test] + fn map_static_code_unified_table_is_canonical() { + for (input, expected) in canonical_cases() { + assert_eq!( + map_static_code(input), + expected, + "map_static_code({input}) should canonicalize to {expected}", + ); + } + } + + /// The core of the fix: the same engine code must produce the SAME + /// mapped code regardless of which path (fs vs exec) handled it. + /// We model each path by the constructor it passes to `map_iii_err` + /// and assert both yield identical codes for identical inputs — + /// including the codes that used to diverge. + #[test] + fn fs_and_exec_paths_agree_on_every_code() { + // Two distinct "constructors" standing in for FsError::new and + // ExecError::new. They differ only in a tag, mirroring how the + // real types differ only in their constructor. + let fs_make = |code: &'static str, msg: String| ("fs", code, msg); + let exec_make = |code: &'static str, msg: String| ("exec", code, msg); + + for (input, expected) in canonical_cases() { + let remote = IIIError::Remote { + code: input.to_string(), + message: "boom".into(), + stacktrace: None, + }; + let (fs_tag, fs_code, _) = map_iii_err(&remote, fs_make); + let (exec_tag, exec_code, _) = map_iii_err(&remote, exec_make); + assert_eq!(fs_tag, "fs"); + assert_eq!(exec_tag, "exec"); + assert_eq!( + fs_code, exec_code, + "fs and exec must map {input} to the same code", + ); + assert_eq!( + fs_code, expected, + "{input} must canonicalize to {expected} on both paths", + ); + } + } + + #[test] + fn scan_s_code_finds_standalone_code() { + assert_eq!(scan_s_code("handler error: S211: not found"), Some("S211")); + assert_eq!(scan_s_code("something broke S217 bad regex"), Some("S217")); + } + + #[test] + fn scan_s_code_rejects_glued_identifier() { + assert_eq!(scan_s_code("fooS211bar"), None); + assert_eq!(scan_s_code("FOO_S211"), None); + // Right-hand boundary: a trailing alphanumeric/underscore means the + // 3-digit run is part of a longer token, not a standalone S-code. + assert_eq!(scan_s_code("S211foo"), None); + assert_eq!(scan_s_code("S2119"), None); + assert_eq!(scan_s_code("S211_x"), None); + // But normal punctuation/whitespace boundaries still match. + assert_eq!(scan_s_code("S211: not found"), Some("S211")); + assert_eq!(scan_s_code("error S217."), Some("S217")); + } + + #[test] + fn scan_s_code_handles_short_inputs() { + assert_eq!(scan_s_code(""), None); + assert_eq!(scan_s_code("S21"), None); + } + + #[test] + fn map_iii_err_recovers_from_wrapped_message() { + let err = IIIError::Remote { + code: "invocation_failed".into(), + message: "handler error: S211: not found".into(), + stacktrace: None, + }; + let (code, msg) = map_iii_err(&err, |c, m| (c, m)); + assert_eq!(code, "S211"); + assert!(msg.contains("wrapped")); + } + + #[test] + fn map_iii_err_recovers_from_handler_json() { + let err = IIIError::Handler(r#"{"code":"S214","message":"directory not empty"}"#.into()); + let (code, _) = map_iii_err(&err, |c, m| (c, m)); + assert_eq!(code, "S214"); + } + + #[test] + fn map_iii_err_recovers_from_handler_raw_scan() { + let err = IIIError::Handler("something broke S217 bad regex".into()); + let (code, _) = map_iii_err(&err, |c, m| (c, m)); + assert_eq!(code, "S217"); + } + + #[test] + fn map_iii_err_unknown_falls_back_to_s216() { + let err = IIIError::Handler("just a plain old string".into()); + let (code, _) = map_iii_err(&err, |c, m| (c, m)); + assert_eq!(code, "S216"); + } +} diff --git a/shell/src/telemetry.rs b/shell/src/telemetry.rs new file mode 100644 index 00000000..a99ff920 --- /dev/null +++ b/shell/src/telemetry.rs @@ -0,0 +1,209 @@ +//! Per-call operational telemetry for the shell worker. +//! +//! The worker already initializes `iii-observability` (OTel meter + tracer + +//! logs bridge) at boot, but emitted zero per-call signal: an operator could +//! not see call rate, latency, error-by-code, or how many background jobs are +//! live. This module closes that gap with a handful of OpenTelemetry metric +//! instruments built once (via `Lazy`) over the already-installed global meter, +//! plus a single `record_call` wrapper that every handler routes through. +//! +//! Design constraints honored here: +//! - Instruments are built once and reused (no per-call allocation of the +//! instrument itself); attribute vectors are tiny (2-3 `KeyValue`s). +//! - The hot path adds an `Instant::now()`, a classification of the result, one +//! counter `add`, and one histogram `record`. No clone of payloads/outputs. +//! - The concurrent-jobs gauge reads a plain `AtomicUsize` (see [`crate::jobs`]) +//! in a synchronous callback — never an `.await` — so it cannot deadlock. +//! - When no OTel collector is attached the instruments are silent no-ops, so +//! nothing here can break boot or fail a call. + +use std::future::Future; +use std::time::Instant; + +use iii_observability::opentelemetry::metrics::{Counter, Histogram, Meter, ObservableGauge}; +use iii_observability::opentelemetry::{global, KeyValue}; +use iii_sdk::IIIError; +use once_cell::sync::Lazy; +use tracing::Instrument; + +/// The meter name used for every shell instrument. Matches the worker's +/// OTel service identity so dashboards can group on it. +const METER_NAME: &str = "shell"; + +fn meter() -> Meter { + global::meter(METER_NAME) +} + +/// Per-call counter: one increment per handler invocation, tagged with the +/// function id, the coarse outcome (`ok`/`error`), and the fine-grained code. +static CALLS: Lazy> = Lazy::new(|| { + meter() + .u64_counter("shell.calls") + .with_description("Count of shell worker handler calls, by function and outcome/code") + .build() +}); + +/// Per-call latency histogram in milliseconds, tagged with function id and +/// outcome so an operator can see p50/p99 latency and spot timeouts. +static CALL_DURATION_MS: Lazy> = Lazy::new(|| { + meter() + .f64_histogram("shell.call.duration_ms") + .with_description("Latency of shell worker handler calls in milliseconds") + .with_unit("ms") + .build() +}); + +/// Counter incremented whenever an exec call truncates stdout or stderr at the +/// configured `max_output_bytes`. A rising rate means agents are producing more +/// output than the worker will return. +static EXEC_OUTPUT_TRUNCATED: Lazy> = Lazy::new(|| { + meter() + .u64_counter("shell.exec.output_truncated") + .with_description( + "Count of exec calls whose stdout/stderr was truncated at max_output_bytes", + ) + .build() +}); + +/// Concurrent-jobs gauge. Built once and kept alive for the process lifetime by +/// leaking the handle (the gauge must outlive registration to keep reporting; +/// the worker runs until SIGTERM so this is a one-time, bounded leak). The +/// callback reads a plain atomic — no lock, no `.await` — so it is +/// deadlock-safe regardless of which task the metrics pipeline calls it from. +static JOBS_RUNNING_GAUGE: Lazy> = Lazy::new(|| { + meter() + .u64_observable_gauge("shell.jobs.running") + .with_description("Number of background jobs currently in the Running state") + .with_callback(|observer| { + observer.observe(crate::jobs::running_gauge_value() as u64, &[]); + }) + .build() +}); + +/// Force-build every instrument once at boot so the first real call does not +/// pay the `Lazy` initialization cost, and so the observable gauge is +/// registered with the meter provider immediately (its callback only fires once +/// it exists). Safe and idempotent; a no-op if OTel is unexported. +pub fn init() { + Lazy::force(&CALLS); + Lazy::force(&CALL_DURATION_MS); + Lazy::force(&EXEC_OUTPUT_TRUNCATED); + Lazy::force(&JOBS_RUNNING_GAUGE); +} + +/// Coarse success/failure label for a call. +pub const OUTCOME_OK: &str = "ok"; +pub const OUTCOME_ERROR: &str = "error"; + +/// Fine-grained code label used when a call fails without a coded remote error +/// (e.g. an argv-parse or allowlist rejection surfaced as `IIIError::Handler`). +pub const CODE_INVOCATION_FAILED: &str = "invocation_failed"; + +/// Pure classification of a handler result into the `(outcome, code)` pair used +/// as metric attributes. Kept free of any OTel or IO dependency so it can be +/// unit-tested without a meter or a live collector. +/// +/// - `Ok(_)` -> (`ok`, `ok`) +/// - `Err(Remote { code, .. })` -> (`error`, the S-code verbatim) +/// - `Err(any other variant)` -> (`error`, `invocation_failed`) +pub fn classify(result: &Result) -> (&'static str, String) { + match result { + Ok(_) => (OUTCOME_OK, OUTCOME_OK.to_string()), + Err(IIIError::Remote { code, .. }) => (OUTCOME_ERROR, code.clone()), + Err(_) => (OUTCOME_ERROR, CODE_INVOCATION_FAILED.to_string()), + } +} + +/// Time `fut`, classify its result, emit the per-call counter + latency +/// histogram tagged with `function_id`, and return the result unchanged. +/// +/// This is the single observation point wrapped around every handler. It does +/// not alter the handler's value or error in any way — it only reads the +/// `Result` to derive labels. The future is awaited inside an `info_span!` +/// carrying `function_id` so logs/traces emitted by the handler correlate with +/// the same call. +pub async fn record_call(function_id: &'static str, fut: F) -> Result +where + F: Future>, +{ + // `.instrument()` (not `span.enter()`) attaches the span to the future + // across await points without holding a `!Send` `Entered` guard, keeping + // this future `Send` for tokio's multi-threaded runtime. + let span = tracing::info_span!("shell.call", function_id = function_id); + + let start = Instant::now(); + let result = fut.instrument(span).await; + let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0; + + let (outcome, code) = classify(&result); + + CALLS.add( + 1, + &[ + KeyValue::new("function_id", function_id), + KeyValue::new("outcome", outcome), + KeyValue::new("code", code), + ], + ); + CALL_DURATION_MS.record( + elapsed_ms, + &[ + KeyValue::new("function_id", function_id), + KeyValue::new("outcome", outcome), + ], + ); + + result +} + +/// Record that an exec call truncated `stdout` and/or `stderr`. Called by the +/// foreground exec handler after it has the typed response in hand (the generic +/// `record_call` wrapper cannot see truncation flags on an opaque `T`). Emits at +/// most one increment per call even when both streams truncate, so the counter +/// reads as "calls that truncated", not "streams truncated". +pub fn record_output_truncated(function_id: &'static str, stdout: bool, stderr: bool) { + if stdout || stderr { + EXEC_OUTPUT_TRUNCATED.add(1, &[KeyValue::new("function_id", function_id)]); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classify_ok_is_ok_ok() { + let result: Result = Ok(7); + let (outcome, code) = classify(&result); + assert_eq!(outcome, OUTCOME_OK); + assert_eq!(code, OUTCOME_OK); + } + + #[test] + fn classify_remote_derives_outcome_error_and_the_scode() { + let result: Result = Err(IIIError::Remote { + code: "S215".to_string(), + message: "jail/denylist".to_string(), + stacktrace: None, + }); + let (outcome, code) = classify(&result); + assert_eq!(outcome, OUTCOME_ERROR); + assert_eq!(code, "S215"); + } + + #[test] + fn classify_non_coded_error_falls_back_to_invocation_failed() { + // An argv-parse / allowlist rejection surfaces as Handler (no S-code). + let result: Result = + Err(IIIError::Handler("argv: command not allowed".to_string())); + let (outcome, code) = classify(&result); + assert_eq!(outcome, OUTCOME_ERROR); + assert_eq!(code, CODE_INVOCATION_FAILED); + + // A timeout (distinct non-Remote variant) classifies the same way. + let timed_out: Result = Err(IIIError::Timeout); + let (outcome, code) = classify(&timed_out); + assert_eq!(outcome, OUTCOME_ERROR); + assert_eq!(code, CODE_INVOCATION_FAILED); + } +} diff --git a/shell/tests/e2e/README.md b/shell/tests/e2e/README.md index f80d1b4d..97f45cd4 100644 --- a/shell/tests/e2e/README.md +++ b/shell/tests/e2e/README.md @@ -87,6 +87,24 @@ The harness includes ~52 `shell::fs::*` cases across four files: streaming-specific cases (missing/wrong-type `content`, malformed channel ref). +## v0.4.0 feature coverage + +Three case files exercise the v0.4.0 API additions end-to-end over the engine: + +- **`cases-fs-write-inline.ts`** — `shell::fs::write` with inline string + `content` (host) and the multi-file `files: [...]` batch form, plus the + ambiguity/host-only rejections (both single+`files`, empty `files`, missing + content, inline-on-sandbox → `S210`). Reuses `fsReadStream` to confirm + written bytes on disk. +- **`cases-exec-stdin.ts`** — the per-call `stdin` field on `shell::exec` / + `shell::exec_bg` (piped to `cat`), the closed-stdin default, and the + stdin-on-sandbox → `S210` rejection. +- **`cases-jobs-bg-timeout.ts`** — the separate `max_bg_timeout_ms` host bg + cap. The e2e config sets it to `6000` (> `max_timeout_ms: 5000`), so the cap + test also proves a bg job survives past the foreground cap (the v0.4.0 + regression fix). The shipped default `max_bg_timeout_ms: 0` (unbounded) is + covered by the Rust unit tests. + ## Vulnerability reproductions `cases-vuln-repro.ts` and `cases-vuln-repro-jailed.ts` reproduce the diff --git a/shell/tests/e2e/config-jailed.yaml b/shell/tests/e2e/config-jailed.yaml index 0f492aeb..c0a97009 100644 --- a/shell/tests/e2e/config-jailed.yaml +++ b/shell/tests/e2e/config-jailed.yaml @@ -26,6 +26,8 @@ workers: - name: shell config: max_timeout_ms: 5000 + # host bg job hard cap in ms; 0 = unbounded (foreground uses max_timeout_ms). + max_bg_timeout_ms: 6000 default_timeout_ms: 1500 max_output_bytes: 4096 working_dir: ./data diff --git a/shell/tests/e2e/config.yaml b/shell/tests/e2e/config.yaml index c37e9533..7749136c 100644 --- a/shell/tests/e2e/config.yaml +++ b/shell/tests/e2e/config.yaml @@ -38,6 +38,10 @@ workers: - name: shell config: max_timeout_ms: 5000 + # host bg job hard cap in ms; 0 = unbounded (foreground uses max_timeout_ms). + # Set > max_timeout_ms so the bg-timeout e2e cases prove both that a bg job + # survives past the foreground cap and that it is killed at its own cap. + max_bg_timeout_ms: 6000 default_timeout_ms: 1500 max_output_bytes: 4096 working_dir: ./data diff --git a/shell/tests/e2e/run-tests-jailed.sh b/shell/tests/e2e/run-tests-jailed.sh index fbcc86bf..779a1743 100755 --- a/shell/tests/e2e/run-tests-jailed.sh +++ b/shell/tests/e2e/run-tests-jailed.sh @@ -9,6 +9,13 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# Central configuration (v0.4.0+): the engine launches the shell worker with +# --config derived from the `shell` config block in config-jailed.yaml. +# The shell worker registers that as the SEED with the built-in `configuration` +# worker (configuration::register) and then reads it back (configuration::get). +# The configuration worker is fresh per engine process, so the config-jailed.yaml +# block is authoritative each run — no separate seed step is needed. + WORKER_SRC="${WORKER_SRC:-$(cd "$ROOT_DIR/../.." && pwd)}" III_BIN="${III_BIN:-$(command -v iii 2>/dev/null || echo "$HOME/.local/bin/iii")}" WORKER_BIN_TARGET="${WORKER_BIN_TARGET:-$WORKER_SRC/target/release/shell}" diff --git a/shell/tests/e2e/run-tests.sh b/shell/tests/e2e/run-tests.sh index 6a030dac..0672e7a6 100755 --- a/shell/tests/e2e/run-tests.sh +++ b/shell/tests/e2e/run-tests.sh @@ -3,6 +3,13 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# Central configuration (v0.4.0+): the engine launches the shell worker with +# --config derived from the `shell` config block in config.yaml. +# The shell worker registers that as the SEED with the built-in `configuration` +# worker (configuration::register) and then reads it back (configuration::get). +# The configuration worker is fresh per engine process, so the config.yaml block +# is authoritative each run — no separate seed step is needed. + # Path overrides (defaults assume the harness lives at shell/tests/e2e/ inside # the workers repo and the iii engine is on $PATH or at $HOME/.local/bin/iii — # which is where the install script diff --git a/shell/tests/e2e/workers/harness/src/cases-exec-stdin.ts b/shell/tests/e2e/workers/harness/src/cases-exec-stdin.ts new file mode 100644 index 00000000..ff77608c --- /dev/null +++ b/shell/tests/e2e/workers/harness/src/cases-exec-stdin.ts @@ -0,0 +1,55 @@ +import { expectEqual, type TestCase } from './cases.ts'; + +// Any valid uuid works: stdin-on-sandbox is rejected in the worker before the +// sandbox backend touches a VM (same is_empty() gate as cwd/env). +const FAKE_SANDBOX_ID = '00000000-0000-4000-8000-000000000000'; + +// Covers the per-call `stdin` field added to shell::exec / shell::exec_bg in +// v0.4.0 — the agent-friendly alternative to a shell heredoc. `cat` is in the +// e2e allowlist and echoes stdin to stdout, so it's the natural probe. +export const EXEC_STDIN_CASES: TestCase[] = [ + { + name: 'exec_stdin_piped_to_cat', + async run({ call }) { + const r = await call('shell::exec', { command: 'cat', stdin: 'hello stdin' }); + expectEqual(r.exit_code, 0, 'cat exits 0'); + expectEqual(r.stdout, 'hello stdin', 'cat echoes stdin to stdout'); + expectEqual(r.stderr, '', 'no stderr'); + }, + }, + { + name: 'exec_stdin_omitted_is_closed', + async run({ call }) { + // No stdin -> /dev/null: cat sees immediate EOF, exits 0 with no output + // (and does NOT hang waiting for input). + const r = await call('shell::exec', { command: 'cat' }); + expectEqual(r.exit_code, 0, 'cat with closed stdin exits 0'); + expectEqual(r.stdout, '', 'no stdin -> empty stdout'); + }, + }, + { + name: 'exec_bg_stdin_piped', + async run({ call, sleep }) { + const spawned = await call('shell::exec_bg', { command: 'cat', stdin: 'bg stdin' }); + const jobId = spawned.job_id; + await sleep(500); + const done = await call('shell::status', { job_id: jobId }); + expectEqual(done.job.status, 'finished', 'bg cat finished'); + expectEqual(done.job.stdout, 'bg stdin', 'bg cat echoed its stdin'); + }, + }, + { + name: 'exec_stdin_on_sandbox_rejects_s210', + async run({ call, expectError }) { + await expectError( + () => + call('shell::exec', { + command: 'cat', + stdin: 'x', + target: { kind: 'sandbox', sandbox_id: FAKE_SANDBOX_ID }, + }), + 'S210', + ); + }, + }, +]; diff --git a/shell/tests/e2e/workers/harness/src/cases-fs-host.ts b/shell/tests/e2e/workers/harness/src/cases-fs-host.ts index 02b3a355..a98c76ad 100644 --- a/shell/tests/e2e/workers/harness/src/cases-fs-host.ts +++ b/shell/tests/e2e/workers/harness/src/cases-fs-host.ts @@ -130,7 +130,8 @@ export const FS_HOST_CASES: TestCase[] = [ mode: '0600', recursive: false, }); - expectEqual(c.updated, 1, 'chmod.updated'); + expectEqual(c.entries_changed, 1, 'chmod.entries_changed'); + expectEqual(c.path, f, 'chmod.path'); const s = await ctx.call('shell::fs::stat', { path: f }); expectEqual(s.mode, '0600', 'stat.mode after chmod'); }, diff --git a/shell/tests/e2e/workers/harness/src/cases-fs-protocol-break.ts b/shell/tests/e2e/workers/harness/src/cases-fs-protocol-break.ts index d6baebec..2991bffa 100644 Binary files a/shell/tests/e2e/workers/harness/src/cases-fs-protocol-break.ts and b/shell/tests/e2e/workers/harness/src/cases-fs-protocol-break.ts differ diff --git a/shell/tests/e2e/workers/harness/src/cases-fs-sandbox.ts b/shell/tests/e2e/workers/harness/src/cases-fs-sandbox.ts index ed07239c..c76e765f 100644 --- a/shell/tests/e2e/workers/harness/src/cases-fs-sandbox.ts +++ b/shell/tests/e2e/workers/harness/src/cases-fs-sandbox.ts @@ -189,6 +189,12 @@ export const FS_SANDBOX_CASES: TestCase[] = [ name: 'fs_sandbox_chmod_forwards_uid_gid', async run(ctx: CaseContext) { resetMocks(); + // The engine (which lives outside workers/shell and was NOT modified + // by this work) still returns the LEGACY `updated` key. Shell's + // ChmodResponse carries #[serde(alias = "updated")] on entries_changed + // to map it. Keeping the mock on the legacy key means this case + // actually exercises that alias: legacy {updated:3} round-trips to + // entries_changed === 3. If the alias were dropped, this would fail. scriptResponse({ updated: 3 }); const r = await ctx.call('shell::fs::chmod', { target: { kind: 'sandbox', sandbox_id: SANDBOX_ID }, @@ -198,7 +204,7 @@ export const FS_SANDBOX_CASES: TestCase[] = [ gid: 1000, recursive: true, }); - expectEqual(r.updated, 3, 'updated count'); + expectEqual(r.entries_changed, 3, 'entries_changed count'); expectEqual(captured[0].payload.uid, 1000, 'uid forwarded'); expectEqual(captured[0].payload.gid, 1000, 'gid forwarded'); }, diff --git a/shell/tests/e2e/workers/harness/src/cases-fs-write-inline.ts b/shell/tests/e2e/workers/harness/src/cases-fs-write-inline.ts new file mode 100644 index 00000000..4971241c --- /dev/null +++ b/shell/tests/e2e/workers/harness/src/cases-fs-write-inline.ts @@ -0,0 +1,164 @@ +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { expect, expectEqual, type CaseContext, type TestCase } from './cases.ts'; +import { fsReadStream } from './cases-fs-host.ts'; + +function newWorkdir(prefix: string): string { + return mkdtempSync(join(tmpdir(), `iii-shell-fs-${prefix}-`)); +} + +// Any valid uuid works: the inline-content-on-sandbox rejection happens in the +// worker BEFORE the sandbox is ever dispatched, so no real VM (or mock) is hit. +const FAKE_SANDBOX_ID = '00000000-0000-4000-8000-000000000000'; + +// Exercises the inline-string and multi-file forms of shell::fs::write added in +// v0.4.0 — the path agents actually use (no streaming channel to construct), +// which the rest of the fs suite only covers via fsWriteStream/ContentRef. +export const FS_WRITE_INLINE_CASES: TestCase[] = [ + { + name: 'fs_write_inline_string_creates_file', + async run(ctx: CaseContext) { + const root = newWorkdir('inline'); + const f = join(root, 'hello.txt'); + const r = await ctx.call('shell::fs::write', { + path: f, + content: 'hello inline\n', + mode: '0600', + }); + expectEqual(r.bytes_written, 13, 'bytes_written = inline byte length'); + expectEqual(r.path, f, 'path echoed'); + expect( + Array.isArray(r.files) && r.files.length === 0, + `single write leaves files empty: ${JSON.stringify(r.files)}`, + ); + const back = await fsReadStream(ctx, { path: f }); + expectEqual(back.data.toString('utf8'), 'hello inline\n', 'inline content round-trips on disk'); + }, + }, + { + name: 'fs_write_inline_default_mode_no_channel_needed', + async run(ctx: CaseContext) { + const root = newWorkdir('inline-default'); + const f = join(root, 'd.txt'); + const r = await ctx.call('shell::fs::write', { path: f, content: 'x' }); + expectEqual(r.bytes_written, 1, 'one byte written with default mode'); + const back = await fsReadStream(ctx, { path: f }); + expectEqual(back.data.toString('utf8'), 'x', 'content round-trips'); + }, + }, + { + name: 'fs_write_batch_writes_all_files', + async run(ctx: CaseContext) { + const root = newWorkdir('batch'); + const a = join(root, 'a.txt'); + const b = join(root, 'b.txt'); + const r = await ctx.call('shell::fs::write', { + files: [ + { path: a, content: 'A' }, + { path: b, content: 'B', mode: '0600' }, + ], + }); + expectEqual(r.bytes_written, 2, 'batch bytes_written sums the files'); + expectEqual(r.path, '', 'batch leaves single-file path empty'); + expect(Array.isArray(r.files) && r.files.length === 2, `two file results: ${JSON.stringify(r.files)}`); + expectEqual(r.files[0].path, a, 'first result path'); + expectEqual(r.files[0].bytes_written, 1, 'first result bytes'); + expectEqual(r.files[1].path, b, 'second result path'); + const ra = await fsReadStream(ctx, { path: a }); + const rb = await fsReadStream(ctx, { path: b }); + expectEqual(ra.data.toString('utf8'), 'A', 'file a content'); + expectEqual(rb.data.toString('utf8'), 'B', 'file b content'); + }, + }, + { + name: 'fs_write_batch_single_entry_shape', + async run(ctx: CaseContext) { + const root = newWorkdir('batch1'); + const f = join(root, 'only.txt'); + const r = await ctx.call('shell::fs::write', { + files: [{ path: f, content: 'solo' }], + }); + expect(Array.isArray(r.files) && r.files.length === 1, `one file result: ${JSON.stringify(r.files)}`); + expectEqual(r.files[0].bytes_written, 4, 'bytes for the single batch entry'); + }, + }, + { + name: 'fs_write_rejects_both_single_and_files_s210', + async run(ctx: CaseContext) { + const root = newWorkdir('ambig'); + await ctx.expectError( + () => + ctx.call('shell::fs::write', { + path: join(root, 'x.txt'), + content: 'x', + files: [{ path: join(root, 'y.txt'), content: 'y' }], + }), + 'S210', + ); + }, + }, + { + name: 'fs_write_rejects_empty_files_s210', + async run(ctx: CaseContext) { + await ctx.expectError(() => ctx.call('shell::fs::write', { files: [] }), 'S210'); + }, + }, + { + // Top-level mode/parents alongside `files` were silently dropped before — + // each entry carries its own. Both forms must reject (S210) so the mismatch + // surfaces instead of masking a caller bug. + name: 'fs_write_batch_rejects_top_level_mode_s210', + async run(ctx: CaseContext) { + const root = newWorkdir('batch-mode'); + await ctx.expectError( + () => + ctx.call('shell::fs::write', { + mode: '0600', + files: [{ path: join(root, 'a.txt'), content: 'A' }], + }), + 'S210', + ); + }, + }, + { + name: 'fs_write_batch_rejects_top_level_parents_s210', + async run(ctx: CaseContext) { + const root = newWorkdir('batch-parents'); + await ctx.expectError( + () => + ctx.call('shell::fs::write', { + parents: true, + files: [{ path: join(root, 'a.txt'), content: 'A' }], + }), + 'S210', + ); + }, + }, + { + name: 'fs_write_rejects_missing_content_s210', + async run(ctx: CaseContext) { + const root = newWorkdir('nocontent'); + await ctx.expectError( + () => ctx.call('shell::fs::write', { path: join(root, 'z.txt') }), + 'S210', + ); + }, + }, + { + name: 'fs_write_inline_on_sandbox_rejects_s210', + async run(ctx: CaseContext) { + // Inline string content is host-only; on a sandbox target the worker + // rejects with S210 before forwarding (no sandbox mock involved). + await ctx.expectError( + () => + ctx.call('shell::fs::write', { + target: { kind: 'sandbox', sandbox_id: FAKE_SANDBOX_ID }, + path: '/sb/x', + content: 'inline not allowed on sandbox', + }), + 'S210', + ); + }, + }, +]; diff --git a/shell/tests/e2e/workers/harness/src/cases-jobs-bg-timeout.ts b/shell/tests/e2e/workers/harness/src/cases-jobs-bg-timeout.ts new file mode 100644 index 00000000..1a97b63e --- /dev/null +++ b/shell/tests/e2e/workers/harness/src/cases-jobs-bg-timeout.ts @@ -0,0 +1,46 @@ +import { expect, expectEqual, type CaseContext, type TestCase } from './cases.ts'; + +// Poll shell::status until the job leaves `running`, or the deadline passes +// (in which case the still-running job is returned so the assertion can fail +// loudly — e.g. if max_bg_timeout_ms never took effect). +async function awaitTerminal(ctx: CaseContext, jobId: string, deadlineMs: number): Promise { + const start = Date.now(); + for (;;) { + const s = await ctx.call('shell::status', { job_id: jobId }); + if (s.job.status !== 'running') return s.job; + if (Date.now() - start >= deadlineMs) return s.job; + await ctx.sleep(500); + } +} + +// Covers the host bg-job hard cap added in v0.4.0: `max_bg_timeout_ms` bounds +// background jobs SEPARATELY from the foreground `max_timeout_ms`. The e2e +// config sets max_bg_timeout_ms: 6000 > max_timeout_ms: 5000, so a single +// cap test also proves the regression fix (a bg job is no longer killed at the +// 5s foreground cap — it survives to its own 6s bg cap). +export const JOBS_BG_TIMEOUT_CASES: TestCase[] = [ + { + name: 'bg_job_killed_at_max_bg_timeout_ms', + async run(ctx: CaseContext) { + const spawned = await ctx.call('shell::exec_bg', { command: 'sleep', args: ['20'] }); + const job = await awaitTerminal(ctx, spawned.job_id, 12_000); + expectEqual(job.status, 'killed', 'bg job killed at its hard cap (and survived past the 5s foreground cap)'); + expect( + typeof job.stderr === 'string' && job.stderr.includes('hard cap'), + `stderr names the hard cap: ${JSON.stringify(job.stderr)}`, + ); + }, + }, + { + name: 'bg_job_under_bg_cap_completes', + async run(ctx: CaseContext) { + const spawned = await ctx.call('shell::exec_bg', { + command: 'sh', + args: ['-c', 'sleep 0.3; echo done'], + }); + const job = await awaitTerminal(ctx, spawned.job_id, 4_000); + expectEqual(job.status, 'finished', 'under-cap bg job finishes'); + expectEqual(job.stdout, 'done\n', 'stdout captured'); + }, + }, +]; diff --git a/shell/tests/e2e/workers/harness/src/cases-jobs-break.ts b/shell/tests/e2e/workers/harness/src/cases-jobs-break.ts index ca2b1532..36b2faf3 100644 --- a/shell/tests/e2e/workers/harness/src/cases-jobs-break.ts +++ b/shell/tests/e2e/workers/harness/src/cases-jobs-break.ts @@ -31,6 +31,17 @@ export const JOB_BREAK_CASES: TestCase[] = [ expect(threw, 'numeric job_id must be rejected'); }, }, + { + // shell::list is a no-arg call and MUST tolerate extra fields: the engine + // injects a `_caller_worker_id` into every payload, so a strict no-arg + // request type (`deny_unknown_fields`) would reject every call. This pins + // that contract — an extra caller-supplied key is ignored, not rejected. + name: 'job_list_tolerates_extra_fields', + async run({ call }) { + const r = await call('shell::list', { limt: 10 }); + expect(Array.isArray(r.jobs), 'shell::list ignores unknown fields and returns jobs'); + }, + }, { name: 'job_kill_twice_second_is_idempotent_no_op', async run({ call, sleep }) { diff --git a/shell/tests/e2e/workers/harness/src/cases-safety.ts b/shell/tests/e2e/workers/harness/src/cases-safety.ts index 63c0966f..d1e056ce 100644 --- a/shell/tests/e2e/workers/harness/src/cases-safety.ts +++ b/shell/tests/e2e/workers/harness/src/cases-safety.ts @@ -12,10 +12,21 @@ export const SAFETY_CASES: TestCase[] = [ }, }, { - name: 'allowlist permits via basename match', - async run({ call }) { - const r = await call('shell::exec', { command: '/bin/ls', args: ['-d', '.'] }); - expectEqual(r.exit_code, 0, 'exit_code'); + // This suite runs UNJAILED (config.yaml host_root: null). In that mode a + // command path (anything with '/') is rejected outright: the whole host FS + // is writable via shell::fs::write, so a path could execute agent-planted + // bytes and bypass the read-only allowlist. Bare PATH-resolved names work. + // (In jailed mode an absolute path OUTSIDE host_root is still permitted by + // basename — exercised by the jailed suite / Rust unit tests.) + name: 'unjailed mode rejects command paths (RCE guard)', + async run({ call, expectError }) { + await expectError( + () => call('shell::exec', { command: '/bin/ls', args: ['-d', '.'] }), + 'unjailed', + ); + // The bare name still resolves via PATH and runs. + const r = await call('shell::exec', { command: 'ls', args: ['-d', '.'] }); + expectEqual(r.exit_code, 0, 'bare command name still permitted'); }, }, { diff --git a/shell/tests/e2e/workers/harness/src/cases-vuln-repro-jailed.ts b/shell/tests/e2e/workers/harness/src/cases-vuln-repro-jailed.ts index 38004332..07a4c0d9 100644 --- a/shell/tests/e2e/workers/harness/src/cases-vuln-repro-jailed.ts +++ b/shell/tests/e2e/workers/harness/src/cases-vuln-repro-jailed.ts @@ -53,7 +53,10 @@ export const VULN_REPRO_JAILED_CASES: TestCase[] = [ }); } catch (e: any) { rejected = true; - observed = e?.message ?? String(e); + // v0.4.0 surfaces the S-code in the structured `code` field, not the + // message text — so read `code` and fold it into `observed`. + const code = e && typeof e === 'object' && 'code' in e ? String(e.code) : ''; + observed = `${code ? code + ': ' : ''}${e?.message ?? String(e)}`; } expect( @@ -111,7 +114,10 @@ export const VULN_REPRO_JAILED_CASES: TestCase[] = [ }); } catch (e: any) { rejected = true; - observed = e?.message ?? String(e); + // v0.4.0 surfaces the S-code in the structured `code` field, not the + // message text — so read `code` and fold it into `observed`. + const code = e && typeof e === 'object' && 'code' in e ? String(e.code) : ''; + observed = `${code ? code + ': ' : ''}${e?.message ?? String(e)}`; } expect( diff --git a/shell/tests/e2e/workers/harness/src/cases-vuln-repro.ts b/shell/tests/e2e/workers/harness/src/cases-vuln-repro.ts index 2d599bc0..68eafa21 100644 --- a/shell/tests/e2e/workers/harness/src/cases-vuln-repro.ts +++ b/shell/tests/e2e/workers/harness/src/cases-vuln-repro.ts @@ -97,7 +97,10 @@ export const VULN_REPRO_CASES: TestCase[] = [ }); } catch (e: any) { rejected = true; - observed = e?.message ?? String(e); + // S-code is in the structured `code` field (v0.4.0+); include it so the + // S212 assertion below matches the code, not just the message text. + const code = e && typeof e === 'object' && 'code' in e ? String(e.code) : ''; + observed = `${code ? code + ': ' : ''}${e?.message ?? String(e)}`; } expect( rejected, diff --git a/shell/tests/e2e/workers/harness/src/runner.ts b/shell/tests/e2e/workers/harness/src/runner.ts index f0819b45..b9c188fd 100644 --- a/shell/tests/e2e/workers/harness/src/runner.ts +++ b/shell/tests/e2e/workers/harness/src/runner.ts @@ -7,6 +7,9 @@ import { JOB_CASES } from './cases-jobs.ts'; import { EDGE_CASES } from './cases-edge.ts'; import { FS_HOST_CASES } from './cases-fs-host.ts'; import { FS_HOST_JAIL_CASES } from './cases-fs-host-jail.ts'; +import { FS_WRITE_INLINE_CASES } from './cases-fs-write-inline.ts'; +import { EXEC_STDIN_CASES } from './cases-exec-stdin.ts'; +import { JOBS_BG_TIMEOUT_CASES } from './cases-jobs-bg-timeout.ts'; import { FS_SANDBOX_CASES } from './cases-fs-sandbox.ts'; import { FS_PROTOCOL_BREAK_CASES } from './cases-fs-protocol-break.ts'; import { STREAMING_BREAK_CASES } from './cases-streaming-break.ts'; @@ -47,15 +50,20 @@ export class Runner { fn: () => Promise, pattern: string | RegExp, ): Promise { - const matches = (msg: string): boolean => - typeof pattern === 'string' ? msg.includes(pattern) : pattern.test(msg); + const matches = (s: string): boolean => + typeof pattern === 'string' ? s.includes(pattern) : pattern.test(s); const display = typeof pattern === 'string' ? `"${pattern}"` : pattern.toString(); try { await fn(); } catch (e: any) { const msg = e?.message ?? String(e); - if (!matches(msg)) { - throw new Error(`expected error matching ${display}, got: ${msg}`); + // The SDK rejects with the raw wire error body { code, message, stacktrace }. + // v0.4.0 surfaces S-codes as the structured `code` so agents branch on + // error.code rather than parsing the message — so match the pattern + // against the code AND the human message. + const code = e && typeof e === 'object' && 'code' in e ? String(e.code) : ''; + if (!matches(msg) && !matches(code)) { + throw new Error(`expected error matching ${display}, got: ${code ? code + ': ' : ''}${msg}`); } return; } @@ -131,6 +139,9 @@ export class Runner { ...EDGE_CASES, ...FS_HOST_CASES, ...FS_HOST_JAIL_CASES, + ...FS_WRITE_INLINE_CASES, + ...EXEC_STDIN_CASES, + ...JOBS_BG_TIMEOUT_CASES, ...FS_SANDBOX_CASES, ...FS_PROTOCOL_BREAK_CASES, ...STREAMING_BREAK_CASES, diff --git a/shell/tests/function_handlers.rs b/shell/tests/function_handlers.rs index 945941f1..3d8b8401 100644 --- a/shell/tests/function_handlers.rs +++ b/shell/tests/function_handlers.rs @@ -12,7 +12,10 @@ use shell::jobs::{self, now_ms, JobHandle, JobRecord, JobStatus}; async fn seed(handle: JobHandle) -> String { match jobs::try_reserve_and_insert(handle, usize::MAX).await { - Ok(id) => id, + // Drop the running-job gauge guard immediately: these test seeds have no + // finalize task to decrement it later, so dropping here keeps the gauge + // net-neutral (one increment, one decrement) rather than leaking a count. + Ok((id, _guard)) => id, Err(_) => panic!("usize::MAX cap must always accept"), } } @@ -111,7 +114,15 @@ async fn exec_handler_rejects_unlisted_command() { ) .await .unwrap_err(); - assert!(err.contains("allowlist")); + // Allowlist rejections are intentionally plain-string (no S-code): they + // flow through `From` to `IIIError::Handler`, which the engine maps + // to `code: "invocation_failed"` with the violation in the message — NOT a + // Remote S-code. Assert that contract so the split stays explicit. + assert!( + matches!(err, iii_sdk::IIIError::Handler(_)), + "allowlist rejection must be the plain-string Handler path, got {err:?}" + ); + assert!(err.to_string().contains("allowlist"), "got: {err}"); } /// `args[i]` validation is per-index; a non-string element must be rejected @@ -212,6 +223,7 @@ async fn status_handler_returns_record_for_inserted_job() { stderr_truncated: false, }, child: None, + host_pid: None, }) .await; let r = resp( @@ -230,7 +242,15 @@ async fn status_handler_rejects_unknown_job_id() { )) .await .unwrap_err(); - assert!(err.contains("no such job")); + // The handler now returns the TYPED ExecError carrying the S-code, and it + // lifts to `IIIError::Remote { code, .. }` so the engine maps the S-code to + // the wire `code` verbatim (not the old `invocation_failed`/Handler collapse). + assert_eq!(err.code, "S211"); + assert!(err.message.contains("no such job")); + match iii_sdk::IIIError::from(err) { + iii_sdk::IIIError::Remote { code, .. } => assert_eq!(code, "S211"), + other => panic!("expected IIIError::Remote, got {other:?}"), + } } #[tokio::test] @@ -246,7 +266,14 @@ async fn kill_handler_rejects_unknown_job_id() { )) .await .unwrap_err(); - assert!(err.contains("no such job")); + // Typed ExecError carrying the S-code; lifts to `IIIError::Remote` so the + // S-code reaches the wire `code` verbatim. + assert_eq!(err.code, "S211"); + assert!(err.message.contains("no such job")); + match iii_sdk::IIIError::from(err) { + iii_sdk::IIIError::Remote { code, .. } => assert_eq!(code, "S211"), + other => panic!("expected IIIError::Remote, got {other:?}"), + } } #[tokio::test] @@ -266,6 +293,7 @@ async fn kill_handler_returns_killed_false_when_job_already_terminal() { stderr_truncated: false, }, child: None, + host_pid: None, }) .await; let r = resp( @@ -295,6 +323,7 @@ async fn list_handler_returns_jobs_array_and_count() { stderr_truncated: false, }, child: None, + host_pid: None, }) .await; let r = resp( @@ -426,7 +455,7 @@ async fn fs_chmod_handler_sets_mode() { .await .unwrap(), ); - assert!(r["updated"].as_u64().unwrap() >= 1); + assert!(r["entries_changed"].as_u64().unwrap() >= 1); let mode = std::fs::metadata(&f).unwrap().permissions().mode() & 0o777; assert_eq!(mode, 0o640); } @@ -521,16 +550,25 @@ async fn fs_dispatch_split_target_rejects_unknown_kind() { ) .await .unwrap_err(); - assert!(err.contains("S210"), "got: {err}"); + // The S210 payload-deser code is now the top-level wire `code` (Remote), + // not buried in a stringified-JSON message. + match err { + iii_sdk::IIIError::Remote { code, .. } => assert_eq!(code, "S210"), + other => panic!("expected IIIError::Remote {{ code: S210 }}, got {other:?}"), + } } #[tokio::test] async fn fs_handler_rejects_bad_payload_shape() { - // path must be a string. Hits the S210 mapping in fs_ls::handle. + // path must be a string. Hits the S210 mapping in fs_ls::handle, which now + // lifts to `IIIError::Remote { code: "S210", .. }`. let err = functions::fs_ls::handle(fs_host_backend(), fresh_iii(), true, json!({"path": 42})) .await .unwrap_err(); - assert!(err.contains("S210"), "got: {err}"); + match err { + iii_sdk::IIIError::Remote { code, .. } => assert_eq!(code, "S210"), + other => panic!("expected IIIError::Remote {{ code: S210 }}, got {other:?}"), + } } #[allow(dead_code)] diff --git a/shell/tests/host_fs_branches.rs b/shell/tests/host_fs_branches.rs index 5862afcf..7477a83e 100644 --- a/shell/tests/host_fs_branches.rs +++ b/shell/tests/host_fs_branches.rs @@ -56,6 +56,10 @@ async fn mkdir_parents_true_creates_deeply_nested_path() { .await .expect("idempotent re-mkdir"); assert!(!again.created, "second mkdir reports not created"); + assert!( + again.already_existed, + "second mkdir reports already_existed" + ); } #[tokio::test] @@ -74,6 +78,7 @@ async fn mv_overwrite_true_replaces_existing_dst() { .await .expect("mv overwrite=true succeeds"); assert!(r.moved); + assert!(r.overwrote, "dst pre-existed so overwrote must be true"); assert!(!src.exists(), "src removed after rename"); assert_eq!(std::fs::read(&dst).unwrap(), b"new"); } @@ -96,9 +101,9 @@ async fn chmod_recursive_walks_subtree_and_counts() { .await .expect("chmod recursive succeeds"); assert!( - r.updated >= 3, + r.entries_changed >= 3, "expected ≥3 paths walked, got {}", - r.updated + r.entries_changed ); } @@ -273,3 +278,77 @@ async fn stat_reports_is_symlink_for_symlink_target() { "symlink stat reports either size or is_symlink", ); } + +#[tokio::test] +async fn mkdir_parents_over_existing_file_errors() { + // mkdir -p over a regular file must error, not report idempotent success. + let root = tmpdir("mkdir-file"); + let f = root.join("not-a-dir"); + std::fs::write(&f, b"x").unwrap(); + let err = backend() + .mkdir(MkdirArgs { + path: f.to_string_lossy().into_owned(), + mode: "0755".into(), + parents: true, + }) + .await + .expect_err("mkdir -p over a regular file must error"); + assert_eq!(err.code, "S213", "got: {err:?}"); +} + +#[tokio::test] +async fn host_responses_populate_new_path_fields() { + // Lock in that the host backend actually fills the structured response + // fields (not just the legacy bool) so a regression to Default is caught. + let root = tmpdir("fields"); + + let d = root.join("d"); + let mk = backend() + .mkdir(MkdirArgs { + path: d.to_string_lossy().into_owned(), + mode: "0755".into(), + parents: false, + }) + .await + .expect("mkdir"); + assert!(mk.created && !mk.already_existed); + assert_eq!(mk.path, d.to_string_lossy()); + + let ch = backend() + .chmod(ChmodArgs { + path: d.to_string_lossy().into_owned(), + mode: "0700".into(), + uid: None, + gid: None, + recursive: false, + }) + .await + .expect("chmod"); + assert_eq!(ch.path, d.to_string_lossy()); + assert!(!ch.recursive); + + let src = root.join("s.txt"); + let dst = root.join("t.txt"); + std::fs::write(&src, b"x").unwrap(); + let mv = backend() + .mv(MvArgs { + src: src.to_string_lossy().into_owned(), + dst: dst.to_string_lossy().into_owned(), + overwrite: false, + }) + .await + .expect("mv"); + assert_eq!(mv.src, src.to_string_lossy()); + assert_eq!(mv.dst, dst.to_string_lossy()); + assert!(!mv.overwrote, "fresh dst was not overwritten"); + + let r = backend() + .rm(RmArgs { + path: dst.to_string_lossy().into_owned(), + recursive: false, + }) + .await + .expect("rm"); + assert!(r.removed && r.was_present); + assert_eq!(r.path, dst.to_string_lossy()); +} diff --git a/shell/tests/jobs_lifecycle.rs b/shell/tests/jobs_lifecycle.rs index 1649be5c..a8a9a6ee 100644 --- a/shell/tests/jobs_lifecycle.rs +++ b/shell/tests/jobs_lifecycle.rs @@ -6,7 +6,10 @@ use shell::jobs::{self, now_ms, JobHandle, JobRecord, JobStatus}; async fn seed(handle: JobHandle) -> String { match jobs::try_reserve_and_insert(handle, usize::MAX).await { - Ok(id) => id, + // Drop the running-job gauge guard immediately: these test seeds have no + // finalize task to decrement it later, so dropping here keeps the gauge + // net-neutral (one increment, one decrement) rather than leaking a count. + Ok((id, _guard)) => id, Err(_) => panic!("usize::MAX cap must always accept"), } } @@ -38,6 +41,7 @@ async fn insert_then_get_round_trips_the_record() { seed(JobHandle { record: rec(id, JobStatus::Running, None), child: None, + host_pid: None, }) .await; let h = jobs::get(id).await.expect("inserted job exists"); @@ -60,11 +64,13 @@ async fn list_all_includes_inserted_jobs() { seed(JobHandle { record: rec(id1, JobStatus::Running, None), child: None, + host_pid: None, }) .await; seed(JobHandle { record: rec(id2, JobStatus::Finished, Some(now_ms())), child: None, + host_pid: None, }) .await; let all = jobs::list_all().await; @@ -82,21 +88,25 @@ async fn running_count_excludes_terminal_states() { seed(JobHandle { record: rec(running_id, JobStatus::Running, None), child: None, + host_pid: None, }) .await; seed(JobHandle { record: rec(finished_id, JobStatus::Finished, Some(now_ms())), child: None, + host_pid: None, }) .await; seed(JobHandle { record: rec(killed_id, JobStatus::Killed, Some(now_ms())), child: None, + host_pid: None, }) .await; seed(JobHandle { record: rec(failed_id, JobStatus::Failed, Some(now_ms())), child: None, + host_pid: None, }) .await; @@ -123,6 +133,7 @@ async fn remove_old_retention_matrix() { seed(JobHandle { record: rec(stale_id, JobStatus::Finished, Some(stale_finished)), child: None, + host_pid: None, }) .await; assert!(jobs::get(stale_id).await.is_some(), "pre-prune sanity"); @@ -135,6 +146,7 @@ async fn remove_old_retention_matrix() { seed(JobHandle { record: rec(running_id, JobStatus::Running, None), child: None, + host_pid: None, }) .await; jobs::remove_old(0).await; @@ -147,6 +159,7 @@ async fn remove_old_retention_matrix() { seed(JobHandle { record: rec(fresh_id, JobStatus::Finished, Some(recent)), child: None, + host_pid: None, }) .await; jobs::remove_old(60).await; @@ -165,6 +178,7 @@ async fn concurrent_inserts_and_lookups_dont_deadlock() { seed(JobHandle { record: rec(&id, JobStatus::Running, None), child: None, + host_pid: None, }) .await; jobs::get(&id).await.is_some() diff --git a/shell/tests/sandbox_dispatch.rs b/shell/tests/sandbox_dispatch.rs index 41c66e94..fcc6b87b 100644 --- a/shell/tests/sandbox_dispatch.rs +++ b/shell/tests/sandbox_dispatch.rs @@ -126,7 +126,7 @@ async fn chmod_forwards_uid_gid_recursive() { }) .await .unwrap(); - assert_eq!(resp.updated, 7); + assert_eq!(resp.entries_changed, 7); let (_, payload) = stub.last_call(); assert_eq!(payload["uid"], 1000); assert_eq!(payload["gid"], 1000); @@ -241,7 +241,7 @@ async fn write_forwards_content_ref_verbatim() { path: "/sb/x".into(), mode: "0600".into(), parents: false, - content: content_ref.clone(), + content: shell::fs::WriteContent::Stream(content_ref.clone()), }) .await .unwrap(); diff --git a/shell/tests/sandbox_exec_dispatch.rs b/shell/tests/sandbox_exec_dispatch.rs index 54a23004..a57b3838 100644 --- a/shell/tests/sandbox_exec_dispatch.rs +++ b/shell/tests/sandbox_exec_dispatch.rs @@ -12,7 +12,7 @@ use serde_json::{json, Value}; use uuid::Uuid; use shell::exec::sandbox::SandboxExecBackend; -use shell::exec::ExecBackend; +use shell::exec::{ExecBackend, ExecOverrides}; use shell::triggers::TriggerFwd; struct StubFwd { @@ -57,7 +57,11 @@ async fn sandbox_backend_emits_sandbox_exec_payload() { let b = SandboxExecBackend::new(stub.clone(), true, id); let out = b - .run(&["echo".into(), "ok".into()], 5000) + .run( + &["echo".into(), "ok".into()], + 5000, + &ExecOverrides::default(), + ) .await .expect("ok"); assert_eq!(out.stdout, "ok\n");