diff --git a/0.62_to_0.70_NEW_FEATURES.md b/0.62_to_0.70_NEW_FEATURES.md new file mode 100644 index 000000000..5ae426c84 --- /dev/null +++ b/0.62_to_0.70_NEW_FEATURES.md @@ -0,0 +1,304 @@ +# sandbox-runtime v0.0.62 → v0.0.70: What's New and What It Means for the ccx Configs + +Status: the `ltic-main` fork is now merged up to upstream **v0.0.70** (version +`0.0.70-ltic.1`, verify with `srt --version`). Both fork features — +`network.allowAllDomains` (PR #283, still open upstream) and +`filesystem.denyReadAlways` (PR #284, still open upstream) — survived the +update intact; this sync had **zero code conflicts** (only the version +stamp), so upstream drift remains cheap to carry. Both +`~/.srt-claude-denyall.json` and `~/.srt-claude-allowall.json` were +validated against the new schema and pass. **No config changes are +required.** The rest of this doc is what's newly possible and recommended. + +None of the v0.0.66 recommendations below have been adopted yet — they all +still apply, and the v0.0.70 additions compose with them (the biggest +payoffs come from combining the two, see "What this unlocks" at the end). + +--- + +## Part 1 — upstream v0.0.62 → v0.0.66 (133 commits, 4 releases) + +- **Credential masking, greatly expanded.** Sentinel substitution now covers + request **bodies** (JSON, form, multipart, gzip, chunked framing) and + **environment variables** (`credentials.envVars`), with optional structured + masking (`extract` regex), JWT decoding (`decode: "jwt"`), and per-claim + masking (`maskClaims`). The sandboxed process sees a structurally valid + fake credential; the TLS-terminating proxy swaps in the real one only on + egress to configured hosts. +- **Linux hardening** (not active on macOS): seccomp-notify violation + observer, forced `--unshare-user` + `--cap-drop ALL` in the secure bwrap + branch, symlink resolution for write-deny paths before computing bwrap + masks. +- **`network.tlsTerminate.extraCaCertPaths`** — new: PEM CA files appended to + the trust bundle srt injects into the sandbox (after the MITM CA and the + host roots). Only `CERTIFICATE` blocks are copied; missing files are + skipped safely. +- **Perf:** MITM CA/leaf signing moved from pure-JS node-forge to native + `crypto.sign()` — faster session startup when `tlsTerminate` is enabled. +- **Removed:** the `disableViolationMonitoring` config option. The schema + strips unknown keys silently, so configs that still set it won't error — + but drop it if present. (The ccx configs never used it.) +- **Misc:** `GRPC_PROXY` now advertises the HTTP CONNECT proxy instead of + socks5h; large Windows (srt-win) overhaul — both irrelevant to the macOS + ccx setup. + +### Recommended: mask `GH_TOKEN` instead of passing it in plain + +Today `_ccx_run` extracts the gh token from the keychain and exports it as +`GH_TOKEN` into the sandbox. Any sandboxed process can read it, and because +the configs run with `allowAllDomains: true`, it could be sent anywhere. +The env-var masking closes exactly this hole: + +```json +"credentials": { + "envVars": [ + { + "name": "GH_TOKEN", + "mode": "mask", + "injectHosts": ["github.com", "*.github.com"] + } + ] +} +``` + +The sandbox sees a length-matched fake token; the proxy substitutes the real +one only for GitHub hosts — in headers *and* request bodies. A process that +exfiltrates `$GH_TOKEN` to any other host leaks a worthless sentinel. + +#### Caveats — read before enabling + +1. **`injectHosts` is mandatory in these configs.** When unset it defaults + to `network.allowedDomains`, which is `[]` here because the fork's + `allowAllDomains` flag does the allowing. Without an explicit + `injectHosts`, the real token is injected **nowhere** and `gh` silently + fails auth. +2. **Requires `network.tlsTerminate`.** Substitution happens inside the + TLS-terminated stream (plain-HTTP substitution is off by default via + `allowPlaintextInject`). Enabling it means the sandbox must trust srt's + MITM CA — see next section. +3. **`enableWeakerNetworkIsolation: true` must stay.** `gh` is a Go binary + and still needs `com.apple.trustd.agent` for TLS verification on macOS. +4. Tools that bypass the proxy send the fake token and fail auth — a + functionality caveat, not a leak (the sentinel is worthless). + +### Enabling `tlsTerminate` may retire the CA-bundle staging hack + +`_ccx_run` currently copies `/etc/ssl/cert.pem` to a `.crt` path and exports +`SSL_CERT_FILE`/`CURL_CA_BUNDLE`/`CARGO_HTTP_CAINFO`/`GIT_SSL_CAINFO`/ +`REQUESTS_CA_BUNDLE`, because the `/**/*.pem` deny glob eats the system +bundle. With `tlsTerminate` enabled, srt **builds and injects its own trust +bundle** (MITM CA + host roots + any `extraCaCertPaths`) and sets those same +env vars itself, with the bundle at an allowed temp path the pem-glob +doesn't cover. The zshrc comment anticipating "srt's MITM CA must be +appended to this bundle" is thus handled natively — the manual `cp` and env +exports are likely removable. **Smoke-test before deleting**: run a +sandboxed `gh api user`, `curl https://example.com`, and a `cargo` fetch; +srt's env injection should win, but confirm the wrapper's own exports don't +shadow it. + +`extraCaCertPaths` is also the supported way to trust site-local/internal +CAs inside the sandbox if that ever comes up on the managed machine. + +### Explicitly *not* worth adopting from this range (macOS) + +- **`credentials.files` masking** — whole-file masking is Linux-only and + degrades to plain `deny` on macOS (SBPL cannot redirect reads). The + existing `denyReadAlways` globs already provide everything it could offer + here. This also means the keychain→`~/.claude/.credentials.json` seeding + scheme in `_ccx_run` remains the right approach; file masking cannot + replace it on a Mac. +- **Violation-monitoring / seccomp options** — Linux-only. +- **`windows` / `srtWin` config blocks** — N/A. +- **Symlinked-deny-path resolution, native CA signing, `GRPC_PROXY`** — + automatic behavior changes with no config surface. + +--- + +## Part 2 — upstream v0.0.66 → v0.0.70 (42 commits, 4 releases) + +### `:port` suffixes on `allowedDomains` / `deniedDomains` + +Domain entries may now carry an optional destination-port suffix: +`"api.example.com:443"`, `"github.com:22"`, and — in `deniedDomains` only — +port-scoped deny-alls like `"*:22"`. An entry without a port matches any +port, exactly as before. This is the first way to say "this host, but only +this protocol" (or "any host, except this protocol") in srt config. + +This composes cleanly with the fork: **`allowAllDomains` short-circuits the +allowlist *after* `deniedDomains` is checked** +(`src/sandbox/sandbox-manager.ts` — the deny loop runs first, port-aware), +so port-scoped denies are fully enforced in the default-allow ccx setup. + +### `deniedDomainReasons` — explain a deny to the model + +New optional `network.deniedDomainReasons` map, keyed by the **exact** +`deniedDomains` entry string: + +```json +"network": { + "deniedDomains": ["gist.github.com"], + "deniedDomainReasons": { + "gist.github.com": "Creating gists is blocked in this sandbox (exfiltration risk). Use a file in the repo instead." + } +} +``` + +When that entry denies a connection, the reason is recorded on the +violation line instead of a generic "host is on the deny list". + +**Honest caveat for the ccx setup:** the reason travels in the violation +*store*, which is an embedder API — `annotateStderrWithSandboxFailures` +appends a `` block to a wrapped command's stderr when +srt is embedded as a library (as Claude Code's native sandboxing does). +The `srt` CLI wrapper that ccx uses spawns the child directly and never +prints the store, and the sandboxed client itself still sees only a generic +`403 Connection blocked by network allowlist`. So today this is +self-documenting config + future-proofing, not model-visible feedback. Two +ways to make it pay off are in Part 3. + +### Proxy denials are now real violations + +Previously a network deny was only a debug-log line; now every proxy denial +(HTTP and SOCKS) is recorded in the `SandboxViolationStore` with the denied +`host:port` and reason. Hardening came with it: + +- Violation lines **honor `ignoreViolations`** (the same command-pattern → + ignore map that already silenced filesystem noise), so an expected, + recurring deny can be kept out of the report. +- URLs on violation lines are **redacted**: query strings collapse to `?…` + and `user:pass@` userinfo is dropped — runtime-interpolated secrets + (api_key=, signed URLs) can't leak into a transcript via the violation + block. Lines are also forced single-line and `<`/`>`-free so they can't + break the block's framing. + +### Per-exec `commandLabel` (embedder API) + +`SandboxManager` exec options gained `commandLabel` — a display label used +for violation attribution instead of the raw command string (which may be +long or contain decoded proxy internals). N/A for the CLI wrapper today; +relevant if ccx ever embeds the library. + +### AWS SigV4 re-signing (`credentials.awsPairs`, `credentials.sigv4`) + +The masking story from 0.66 had a gap for AWS: SigV4 signatures are HMACs +computed *from* the secret key, so a client signing with a masked secret +produced signatures no header substitution could repair. The proxy can now +**re-sign** such requests with the real credentials. The conventional +`AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`/`AWS_SESSION_TOKEN` trio pairs +automatically when masked whole-value; `awsPairs` names non-standard +variables; `sigv4` sets deny/passthrough policy for the un-re-signable +shapes (streaming uploads, presigned URLs, SigV4A). **N/A for ccx until an +AWS credential enters the sandbox** — but it means AWS creds can get the +same treatment as `GH_TOKEN` if that day comes. + +### `git.safeDirectories` + +Injects `safe.directory` entries via `GIT_CONFIG_*` env vars, without +granting write. Solves git's "dubious ownership" failures where the sandbox +runs as a different user — a Linux (bwrap user-namespace) and Windows +(sandbox user) problem. **macOS sandbox-exec keeps your uid, so ccx never +hits this**; nothing to adopt. + +### Robustness fixes (automatic, no config surface) + +- **Trailing slashes on filesystem entries are now normalized** at the + `normalizePathForSandbox` chokepoint: a non-glob spelling like + `"/Users/"` used to silently produce a weaker/non-matching rule; now it + equals `"/Users"`. The ccx configs were already slash-free, so this + changes nothing there — but hand-edits are now safer. +- **Client-abort hardening** in the proxy: a sandboxed client disconnecting + mid-request no longer risks wedged sockets/unhandled errors. +- Linux-only: creation stubs under a read-only denied directory are skipped + correctly; the sandbox now starts when the seccomp helper binary is + execute-only. +- Windows: another large srt-win overhaul; `windows.srtWin.path` is now + required to use the Windows sandbox. N/A. + +--- + +## Part 3 — what this unlocks for the ccx configs + +The denyall config's intent is: **default-allow network with targeted +exfiltration denies** (fork `allowAllDomains` + `gist.github.com`), +**secrets unreadable even under broad read grants** (fork `denyReadAlways` +globs), and minimal friction/false positives for everything else. Against +that intent, ranked by value: + +### 1. Widen the exfiltration denylist cheaply (port suffixes) + +`gist.github.com` blocks one paste-site; port-scoped denies let you cut +whole *protocols* without touching the default-allow posture: + +```json +"deniedDomains": ["gist.github.com", "*:25", "*:587", "*:465"] +``` + +blocks direct SMTP from the sandbox (a classic quiet-exfil channel that +`allowAllDomains` currently permits). If/when `GH_TOKEN` masking is on, +`"github.com:22"` + `"ssh.github.com"` is worth considering too: it forces +GitHub traffic onto https where the proxy can see it and where the masked +token is the only credential — over SSH the proxy substitutes nothing, so +an agent quietly switching a remote to SSH bypasses the whole masking +story. (Leave SSH alone until then; today it's a legitimate path.) + +### 2. Adopt the 0.66 combo — it just got better + +`tlsTerminate` + `GH_TOKEN` masking (Part 1) remains **the single biggest +risk reduction and simplification available**: it retires the CA-staging +hack in `_ccx_run` (simpler wrapper, fewer env vars to keep in sync) and +turns the plaintext token into a sentinel. The 0.70 changes strengthen the +same path: the proxy that does the masking is now more robust under client +aborts, and its denials are observable instead of silent. + +### 3. Make denials visible to the agent — IMPLEMENTED (2026-08-05) + +Recording proxy denials in the violation store is the foundation the ccx +setup was missing: a blocked gist push used to look like a mysterious 403 +to the agent, which reads as a *false positive* ("the network is broken") +rather than policy. This is now closed end-to-end: + +- **srt side (fork patch in `src/cli.ts`):** the CLI enables the seatbelt + log monitor (previously off in CLI mode, so filesystem denials were + never collected) and streams every violation line to + `~/.local/state/srt/violations-.log`, advertised to the sandboxed + child via `SRT_VIOLATIONS_FILE`. That directory is readable but **not + writable** under both ccx configs — sandboxed code cannot forge + violation lines into an agent's context. +- **Claude Code side (no fork drift):** a PostToolUse(Bash) hook + (`~/.claude/hooks/srt-violations.mjs`, registered in + `~/.claude/settings.json`) reads new lines since its last offset and + injects them as `additionalContext` wrapped in ``, + with a preamble explaining these are policy blocks. Outside an srt + sandbox the env var is unset and the hook is a no-op. +- **Config side:** both ccx configs now carry `deniedDomainReasons` for + the gist entry and an `ignoreViolations` block filtering benign macOS + noise (sysctl kern.*, GlobalPreferences plists, configd mach-lookup) — + proxy deny lines honor `ignoreViolations` as of 0.70. + +Verified end-to-end: a denied `https://gist.github.com` fetch logs the +deny with the configured reason instantly; a denied `.env` read logs the +exact path after ~1–2s of `log stream` latency (a trailing violation can +therefore slip past a session that exits immediately — irrelevant for +interactive sessions, where the next Bash command's hook picks it up). + +### 4. Nothing to simplify away yet + +Nothing in 0.67–0.70 replaces an existing entry in either config, and +nothing upstream supersedes the fork features: `allowedDomains` still +rejects bare `*` (so PR #283 `allowAllDomains` is still needed for +default-allow) and deny-beats-allow read globs remain fork-only (PR #284; +upstream's credential-file masking is still Linux-only). Both PRs remain +open upstream. The configs as written are already minimal for what they +express — the simplification wins live in `_ccx_run` (CA staging, see #2), +not in the JSON. + +### Suggested next step + +Copy `.srt-claude-denyall.json`, add the `credentials` block + +`"tlsTerminate": {}` from Part 1 and the `deniedDomainReasons` + +port-scoped denies from Part 3, and run `ccx` against it. Verify: `gh api +user` succeeds, `env | grep GH_TOKEN` shows a fake value inside the +sandbox, TLS tools (curl, git, cargo, uv) still verify, and a deliberate +`curl https://gist.github.com` / SMTP connection is denied. If that passes, +apply to `.srt-claude-allowall.json`, drop the CA-staging lines from +`_ccx_run`, and decide on the CLI violation-dump patch. diff --git a/0.70_to_0.77_NEW_FEATURES.md b/0.70_to_0.77_NEW_FEATURES.md new file mode 100644 index 000000000..bcaf7962f --- /dev/null +++ b/0.70_to_0.77_NEW_FEATURES.md @@ -0,0 +1,148 @@ +# sandbox-runtime v0.0.70 → v0.0.77: What's New and What It Means for the ccx Configs + +Status: `ltic-main` is merged up to upstream **v0.0.77** (ddbeb74, 76 commits), +version `0.0.77-ltic.1` (check with `srt --version`). Typecheck is clean and +`npm test` passes: 1218 pass, 689 skipped (Linux/Windows only), 0 fail. + +This sync changed the fork, unlike the zero-conflict v0.0.70 sync: + +| Fork patch | Outcome | +|---|---| +| `network.allowAllDomains` | **Kept, and tightened** (see below). | +| `filesystem.denyReadAlways` / `denyReadAlwaysExcept` | **Retired.** Upstream now does this with plain `denyRead` / `allowRead`. | +| macOS socat `GIT_SSH_COMMAND` | Kept. Merged cleanly. | +| Violations file (`SRT_VIOLATIONS_FILE`) in `cli.ts` | Kept. Re-seated in the rewritten `main()`. | +| Unrecognized-settings-key warning | Kept. Moved into the rewritten `loadConfig`. | +| Version read from `package.json` | Dropped. Upstream has its own `getPackageVersion()`. | + +**Config changes were required, and have been made.** +- `~/.srt-claude-denyall.json` and `~/.srt-claude-allowall.json` are migrated. +- Originals are saved next to them as `*.pre-v0.0.77`. + +--- + +## 1. `denyReadAlways` is gone: use `denyRead` + `allowRead` + +Upstream d106511 (macOS) and the Linux rework (bf3959a, 879f3e0, …) make a +**`denyRead` glob beat an enclosing `allowRead` region**. For example, +`/**/.env*` stays denied inside `allowRead: ["."]` or `["~/src"]`. That's the +case denyReadAlways was built for. Upstream also refers to "a denyReadAlways +option that never landed" and has settled on this design instead. + +Exceptions work by listing the file in `allowRead`. A narrower `allowRead` +(an exact path, or a file-shaped glob) **carves back out** of a covering deny +glob. + +**Gotcha, verified empirically.** The carve-out only counts if the deny glob +covers a sample path built from the allow glob. `/**/id_*.pub` does **not** +carve out of `/**/id_ed25519*`, so `.pub` keys stayed denied. Spell the +allow in the same shape as the deny: + +```json +"denyRead": ["/**/id_rsa*", "/**/id_ed25519*", "/**/.env*", ...], +"allowRead": ["/**/id_rsa*.pub", "/**/id_ed25519*.pub", "/**/.env.example", ...] +``` + +Both ccx configs now use this. Results from a sandboxed `cat` under each config: + +| Path | Result | +|---|---| +| `./.env`, `sub/.env`, `.env.local`, `key.pem`, `credentials`, `sub/id_ed25519`, `~/.ssh/id_ed25519-github`, `~/Documents` | denied | +| `.env.example`, `.env.sample`, `id_ed25519*.pub`, `~/.ssh/config`, ordinary files | readable | + +What was lost: denyReadAlways also beat an *exact* `allowRead` of the same +file. Upstream deliberately lets the exact allow win. That only matters if an +untrusted layer can add `allowRead` entries, which the ccx setup doesn't have. + +Leftover `denyReadAlways` keys in any config now trigger the fork's +unrecognized-key warning. They no longer do anything. + +## 2. `allowAllDomains` no longer opens IMDS or host loopback + +New upstream **resolved-address guard** (a3ac53e and ~20 follow-ups): an +allowed *hostname* that resolves to one of these is refused: +- loopback, link-local, or a cloud-metadata address (including ones outside + link-local such as `fd00:ec2::254`); +- the host's own addresses; +- anything listed in `deniedResolvedAddresses`. + +NAT64, 6to4 and IPv4-mapped forms are decoded before the check. + +The guard treats an **IP literal or `localhost` name** on the allowlist as a +deliberate choice and skips it. The fork's flag made *everything* count as +allow-listed, so under the old build `curl --noproxy '' http://169.254.169.254/` +and `http://127.0.0.1:` were forwarded by the proxy. That hole already +existed in 0.0.70. + +Fork fix 9583642: the flag now covers **hostnames only**. IP literals, +`localhost` and `*.localhost` still need an explicit `allowedDomains` entry. + +| Target | old build | new build | +|---|---|---| +| `http://169.254.169.254/` via proxy | forwarded (timeout) | **403** | +| `http://127.0.0.1:9/`, `http://localhost:9/` | forwarded (502) | **403** | +| `https://example.com/` | 200 | 200 | + +Note that curl's default `no_proxy` sends `169.254/16` and loopback direct. +Seatbelt blocks those either way. + +If a sandboxed tool ever needs a local service, allow-list it explicitly, e.g. +`"localhost:5432"`. + +## 3. Breaking or stricter upstream behaviour to know about + +- **A settings file that is invalid, empty or unreadable now fails closed** + (e307e18). srt exits 1 instead of silently running with the defaults. +- **`--control-fd`** must be an integer ≥ 3 and readable, or srt refuses to + run. srt exits with the command (7c3eb33), and the child no longer + inherits the fd. +- **Deny globs ending in `/` are rejected** (322c59a), e.g. `"/data/*/"`. +- **IPv6 literals in domain lists must be bracketed** (a52b7d5): `[::1]:443`. +- **Default write paths now obey a read deny** (316e9f3). For example, + `~/.npm/_logs` stops being writable if a `denyRead` covers it; list it in + `allowWrite` to keep it. +- **A `/` read root (macOS) no longer switches off the denies beneath it** + (46c06d8). + +## 4. New features + +- **`network.deniedResolvedAddresses`**: extra CIDRs that allowed names + must not resolve to. Useful for the UBC intranet ranges (e.g. `10.0.0.0/8`, + `172.16.0.0/12`, `192.168.0.0/16`) if the sandbox shouldn't reach internal + hosts through public DNS names. The built-in set does *not* include RFC1918. +- **JVM proxy agent** (`javaAgentJarPath`, 9ee66e8): injected via + `JAVA_TOOL_OPTIONS`, so Gradle, Maven and Bazel honour the proxy and its + auth token. +- **Legible SSH refusals** (44f601b): an SSH client going through the + no-auth SOCKS path gets an in-band banner saying why. This doesn't apply to + the fork's macOS socat path, where a denied host shows up as a CONNECT + error plus a line in the violations file. +- **Violation lines are sanitized** at ingestion (1bcd1cc, a73fcff). Control + characters, bidi characters and `<`/`>` are stripped, so each violation is + exactly one physical line. This hardens the ccx PostToolUse hook against + forged lines for free. The hook's line format is unchanged. +- **Much smaller seatbelt profiles** (ca5501a, ee6b2eb: ~6–10× smaller), and + literal deny paths are emitted as literals (416eecc). +- TLS-terminate keeps the upstream connection alive per client (6fa7313). +- The package metadata moved to github.com/anthropics/sandbox-runtime. + +## 5. Things noticed when testing with bare `srt` (not issues under ccx) + +These showed up when testing with bare `srt --settings …`. They are +identical on the pre-sync build, and the ccx wrapper already handles both: + +- **`mktemp` fails in the default macOS `$TMPDIR`** (`/private/var/folders` + is read-denied). `_ccx_run` sets a per-PID `TMPDIR=/tmp/claude/ccx-`. +- **git-over-SSH can't authenticate.** Private keys are read-denied and + the ssh-agent launchd socket is blocked. `_ccx_run` forwards + `SSH_AUTH_SOCK` and injects it into `network.allowUnixSockets` in a + per-launch config copy. The socat transport itself works: it reached + github.com through the proxy. + - The `.pub` carve-out fix in section 1 is what keeps the + `IdentitiesOnly yes` agent-signing fallback working. + +## 6. Still-open ideas from the v0.0.70 writeup + +The `GH_TOKEN` masking and `tlsTerminate` recommendations from +`0.62_to_0.70_NEW_FEATURES.md` still apply unchanged. Credential validation +under `allowAllDomains` was not touched upstream. diff --git a/0.77_to_0.81_NEW_FEATURES.md b/0.77_to_0.81_NEW_FEATURES.md new file mode 100644 index 000000000..70efa1356 --- /dev/null +++ b/0.77_to_0.81_NEW_FEATURES.md @@ -0,0 +1,180 @@ +# sandbox-runtime v0.0.77 → v0.0.81: What's New and What It Means for the ccx Configs + +Status: `ltic-main` is merged up to upstream `main` at **v0.0.81 + 4 commits** (98a4016, +130 commits since v0.0.77), version `0.0.81-ltic.1` (check with `srt --version`). +Build and typecheck are clean. `npm test`: 3400 pass, 1179 skipped (Linux/Windows only), 28 fail. Every remaining failure +also fails on a pristine upstream/main checkout on this Mac (Bun 1.4.4 here vs 1.4.2 +in upstream CI for the 24 non-ASCII request-target tests and 2 `ambiguousRequestTarget` +tests; macOS 26's `/bin/tcsh` and `/bin/csh` for the 4 `sshConnectScript` / git-over-ssh +shell tests). None touch fork code. + +Fork patch outcomes this sync: + +| Fork patch | Outcome | +|---|---| +| `network.allowAllDomains` | **Kept.** Merged with zero conflicts; the block still sits after the `deniedDomains` loop and before `allowedDomains`, `strictAllowlist`, the new per-command lists and the ask callback. | +| Violations file (`SRT_VIOLATIONS_FILE`) in `cli.ts` | **Kept.** Re-seated after upstream's new interrupt handling around `initialize()`. | +| Unrecognized-settings-key warning | **Kept.** Had to follow upstream's move to `import { z } from 'zod/v3'`, or tsc rejects the schema walk and the `instanceof` checks would miss at runtime. | +| macOS socat `GIT_SSH_COMMAND` (PR #452) | **Retired.** Upstream #681 does authenticated git-over-SSH on macOS with stock `/usr/bin/nc`. socat is no longer a dependency. | +| Version read from `package.json` via `createRequire` | **Dropped.** Upstream #630 uses a static JSON import. | + +**No config changes are required.** Both `~/.srt-claude-*.json` load with no warnings and +pass the egress, deny-reason, metadata, `gh api user` and git-over-SSH smoke tests +unchanged. Optional hardening is listed in section 4. + +The deployed `srt` (nvm global) is a symlink to this checkout, so `npm run build` here +already upgraded ccx. The guidelines repo still has to be updated: see +`HANDOFF_sandbox-runtime-config_v0.0.81.md`. + +--- + +## 1. socat is gone: git-over-SSH on macOS now uses `/usr/bin/nc` + +Upstream #681 (v0.0.80). When the proxy wants a token and the SOCKS and HTTP listeners +are the same port (always true for the CLI's mux proxy), macOS gets two variables: + +``` +SRT_SSH_PROXY_COMMAND=LC_ALL=C; no() { ... }; ... | /usr/bin/nc 127.0.0.1 | ... +GIT_SSH_COMMAND=ssh -o ControlMaster=no -o ControlPath=none -o ProxyCommand="/bin/sh -c 'eval \"\${SRT_SSH_PROXY_COMMAND:?unset, but GIT_SSH_COMMAND needs it}\"' sh '%h' %p" +``` + +The script sends an authenticated `CONNECT host:port` with `Proxy-Authorization: Basic …` +over nc, reads the response head, and either execs `cat` to relay or prints the status +line to stderr and exits. The token travels in a variable, not in ssh's argv. Without a +token (external SOCKS proxy) the old `nc -X 5` spelling is still used. + +Verified through the real `_ccx_run` wrapper with `/opt/homebrew/bin` off `PATH`: + +``` +$ git ls-remote git@github.com:ubc/sandbox-runtime.git HEAD # denyall and allowall +17e438ba09021a80ffbb4bcb1ff4f340bdf158a3 HEAD +$ eval "$GIT_SSH_COMMAND -T git@github.com" # README recipe for plain ssh +Hi smolyn-ubc! You've successfully authenticated, but GitHub does not provide shell access. +$ eval "$GIT_SSH_COMMAND -T git@gist.github.com" # deny reason arrives in-band +sandbox proxy: gist.github.com:22: 403 Creating gists is blocked by sandbox policy (exfiltration risk). Write to a file in the repo instead. +``` + +Bare `ssh github.com` still fails with "Could not resolve hostname" as before: only the +`GIT_SSH_COMMAND` spelling carries the ProxyCommand. + +## 2. Refused CONNECTs say why, in the status line + +Also #681. The `403` that answers a refused CONNECT carries the deny reason as its status +phrase, cleaned the way violation lines are (non-ASCII becomes `?`). `curl -I` shows it +directly, ssh prints it via the script above, git prints it as the proxy error. Example: + +``` +$ curl -sI https://gist.github.com | head -1 +HTTP/1.1 403 Creating gists is blocked by sandbox policy (exfiltration risk). Write to a file in the repo instead. +``` + +The violations file gets the same line (`deny network-outbound gist.github.com:443 (…)`), +so the agent now sees the reason twice: in the tool output and in the PostToolUse hook. +Filesystem denials still reach the agent only through the hook. + +## 3. Behaviour changes relevant on macOS (no config edit needed) + +- **Node ≥ 22.12 is required** (#636). Local Node is 24. +- **Glob brackets** (#620–#622, #687, #718): a path entry whose characters read as a + pattern is also read as the path it spells, where that exists; brackets that spell no + character set (`[user-id]`, `backup[2024-01-15]`, `list[].ts`) are text; a `*` or `?` + inside brackets is a member of the set. New `{ "path": "...", "literal": true }` form in + all four filesystem lists marks an entry as a name whatever it holds. Neither ccx config + has bracketed paths. The types changed accordingly (`FilesystemPathEntry[]`), but the + fork's unrecognized-key walker skips unions, so the object form is not flagged. +- **Read rules apply under both spellings of a path** on macOS (#680): as written and + with the symlinks on the way resolved. +- **`credentials.files` entries beat `allowRead`/`allowWrite`** on every platform (#680, + #686). ccx does not use `credentials.files`. +- **Library self-protection** (#595): a wrapped command is write-denied the library's own + install directory, and `checkDependencies()` warns when srt is installed where the + sandbox can write. Under nvm that is `~/.nvm/...`, which neither config grants write + to. No new violation noise observed. +- **TLS termination runs in-process** (#665, #700): no per-tunnel listener; new + `tlsTerminate.maxTunnels` (default 256; a CONNECT past it gets `503` with + `X-Proxy-Error: too-many-tunnels` and a violation) and `handshakeTimeoutMs` (default + 10000). Needs Node or Bun ≥ 1.4. +- **Proxy robustness**: an upstream that answers with an invalid status line gets `502` + instead of crashing the proxy (#660); an upstream that sends more than its + `Content-Length` no longer loses its answer (#717); certificate serials never start with + a zero byte (#685). +- **Temp files**: `process.exit()` or a failed `initialize()` no longer leaves the + library's temp objects (a generated CA key among them) behind (#670). ccx uses a + persistent CA via `caCertPath`/`caKeyPath`, unaffected. +- **Ask callback** only allows on `true` (#591). The CLI has no callback; no impact. + +## 4. New options worth considering for the ccx configs + +- **`network.requireHostMatch: true`** (#663): answer `421` to a request whose Host + header, SNI or absolute-form authority does not name the host and port it is sent to. + Refusals are recorded as violations. Cheap hardening around credential injection. + Recommended to trial in `denyall` first, then both, and re-run `just check` plus + npm/cargo/uv/gh smoke tests. +- **`deniedDomains: ["github.com:22", "ssh.github.com"]`** with reasons: closes the SSH + path that bypasses token masking. Now that the reason reaches ssh in-band (section 1) + the failure is self-explanatory. Still a policy choice: SSH is the documented push path + today. +- **`ignoreViolations` noise**: this sync's smoke runs showed three recurring benign + lines from ssh and its `$SHELL -c` ProxyCommand hop under `denyall`: + `mach-lookup com.apple.dnssd.service`, `mach-lookup com.apple.usymptomsd`, and + `file-read-data /Users//.zshenv`. Optional additions. + +Not adopting, and why: + +- `refuseOpaqueTunnels` refuses every CONNECT the proxy cannot see into, SSH included. +- `stripResponseHeaders`, `allowPlaintextHeaderSet`, `ambiguousRequestTarget` only + matter with a `filterRequest` callback, which is a library API. +- `credentials.files` masking still degrades to deny on macOS. + +## 5. New upstream capabilities (library / embedder level) + +These do not reach the CLI configs but change the "future directions" picture: + +- **Per-command network allow lists** (#591): `registerCommandNetworkLists(commandId, + { allowedDomains })`, keyed by the proxy username the wrap presents. The primitive for + "npm reaches only `*.npmjs.org`". +- **`filterRequest` can rewrite and observe** (#663): an allow may carry + `removeHeaders`, `setHeaders`, `onResponse`; a deny may choose its status; the callback + gets `scheme`, `sni`, `target`, `requestTarget`, `rawHeaders`. URL-level policy. +- **`srt proxy` / `srt-proxy`** (#666, #668): the HTTP proxy alone, with an external + decider process answering every request (`docs/srt-proxy.md`). The "external policy + engine" idea, upstreamed. +- **`network.ambiguousRequestTarget`** (#698): `refuse` (default) / `asSpelled` / + `normalize` for targets that name another path once normalised. +- New export `realPathOf` (#683); `writeRootsOf()` for every write root (#621). + +## 6. Linux-only changes (for the "Using on Linux" notes) + +- **Sandboxed commands stay in their namespaces** (#588, #617): no new user or mount + namespaces by default, so Chromium/Electron's sandbox, rootless podman/buildah, nested + bubblewrap and `unshare -r` fail inside. `allowNestedUserNamespaces: true` lifts it (at + the cost of the write denies against a hostile command). Needs the seccomp helper, or + bubblewrap ≥ 0.8.0 not installed setuid. +- **`denyWrite` beats `allowWrite` however nested** (#684): `denyWrite: ["~"]` makes a + project under `~` read-only; `denyWrite: ["/tmp"]` makes the sandbox's own `TMPDIR` + read-only. +- **Dangerous names** (`.bashrc`, `.git/config`, `.vscode/`) are write-denied one level + below the working directory whatever else the tree holds (#629). +- **Glob walk** is ~6× faster, yields to the event loop every 10 ms, honours the + `AbortSignal`, and restarts if the config or cwd changes mid-wrap (#627, #628, #638, + #644, #645, #655, #679). +- **Fixes**: bubblewrap 0.4 starts again (#580); a command starts only once both relays + listen (#639); relays fall back to IPv4 without IPv6 (#719); `AF_VSOCK` is refused + (#691); a file above a write deny is kept read-only (#705); empty placeholder mounts are + removed when the last sandbox ends (#671); `denyRead` patterns work on file systems + whose listings carry no entry types (#672). + +## 7. v0.0.81 is Windows-only + +Write-restricted token, profile read grant (`srt-win profile …`), `--allow-loopback`, +AppContainer and stdin-pipe escapes closed, kept ACL grants, lower-case-only proxy +variables, `NODE_USE_ENV_PROXY=1`. Nothing changes on macOS or Linux. + +## 8. Things noticed during the sync + +- The guidelines repo's `just check` requires socat on PATH; that check must be relaxed + (see the handoff document). +- `test/proxy-cli.test.ts` asserted a bare `X.Y.Z` version; the fork now allows a + prerelease suffix there. +- Upstream PRs #283 (allowAllDomains), #284 (denyReadAlways) and #452 (socat) are all + still open. #284 and #452 are superseded and can be closed. diff --git a/HANDOFF_sandbox-runtime-config_v0.0.81.md b/HANDOFF_sandbox-runtime-config_v0.0.81.md new file mode 100644 index 000000000..b16621940 --- /dev/null +++ b/HANDOFF_sandbox-runtime-config_v0.0.81.md @@ -0,0 +1,221 @@ +# Handoff: update `ltic-ai-guidelines/sandbox-runtime-config` for srt `0.0.81-ltic.1` + +Audience: an agent working in `~/ltic/aitools/ltic-ai-guidelines/` with no other context. +Goal: one PR that brings `sandbox-runtime-config/` (README.md, DETAILS.md, justfile, the two +`.srt-claude-*.json`, `.zshrc.example`, `.bashrc.example`, `srt-violations.mjs`) in line with +the fork release described below. Everything stated here as verified was run on 2026-10-27 +against the built fork, through the repo's own `_ccx_run` wrapper and the deployed configs. + +## 1. Ground truth + +- The fork `ubc/sandbox-runtime`, branch `ltic-main`, moved from `0.0.77-ltic.1` to + **`0.0.81-ltic.1`**: upstream `anthropic-experimental/sandbox-runtime` main at v0.0.81 + 4 + commits. Release notes: upstream `releases/tag/v0.0.78`, `v0.0.79`, `v0.0.80`, `v0.0.81`. + The fork's own write-up is `0.77_to_0.81_NEW_FEATURES.md` next to this file. +- **No mandatory config changes.** Both repo configs are byte-identical to the deployed + `~/.srt-claude-denyall.json` and `~/.srt-claude-allowall.json` apart from JSON formatting, + and both load on the new build with no "unrecognized settings key" warning. +- **One fork delta is retired: the macOS socat `GIT_SSH_COMMAND`.** Upstream v0.0.80 (#681) + does authenticated git-over-SSH on macOS with stock `/usr/bin/nc`. **socat is no longer a + dependency** of the ccx setup. The fork now has three deltas, not four: `allowAllDomains`, + the violations file, the unrecognized-key warning. +- **One new prerequisite: Node ≥ 22.12** (upstream `engines`). +- **Optional hardening** became available: `network.requireHostMatch`. See section 4. +- The `-ltic` version check, the violations hook (`srt-violations.mjs`), the shell wrapper + files and the CA trust steps are unchanged. + +## 2. Verified behaviour you may document as fact + +Build: `npm install && npm run build && npm run typecheck` clean. `npm test`: 3400 pass, 1179 skipped (Linux/Windows only), 28 fail; +every failure also fails on pristine upstream on the same Mac (Bun 1.4.4 vs upstream CI's +1.4.2; macOS 26's tcsh/csh), none in fork code. + +``` +$ srt --version +0.0.81-ltic.1 +$ srt --settings ~/.srt-claude-denyall.json -- true # and allowall: exit 0, no output +``` + +Inside `_ccx_run ~/.srt-claude-denyall.json sh -c '…'` (so: real wrapper, per-launch config +with `allowUnixSockets` for the ssh-agent socket and the persistent CA paths): + +``` +SRT_VIOLATIONS_FILE=/Users//.local/state/srt/violations-.log +GIT_SSH_COMMAND=ssh -o ControlMaster=no -o ControlPath=none -o ProxyCommand="/bin/sh -c 'eval \"\${SRT_SSH_PROXY_COMMAND:?unset, but GIT_SSH_COMMAND needs it}\"' sh '%h' %p" +SRT_SSH_PROXY_COMMAND=LC_ALL=C; no() { printf "sandbox proxy: %s\n" "$*" >&2; exit 1; }; … | /usr/bin/nc 127.0.0.1 | … + +$ curl -s -o /dev/null -w '%{http_code}' https://example.com/ → 200 +$ curl --noproxy '' -s -o /dev/null -w '%{http_code}' http://169.254.169.254/ → 403 +$ curl -sI https://gist.github.com | head -1 +HTTP/1.1 403 Creating gists is blocked by sandbox policy (exfiltration risk). Write to a file in the repo instead. + +$ gh api user --jq .login → +$ PATH=/usr/bin:/bin command -v socat → (nothing) +$ git ls-remote git@github.com:ubc/sandbox-runtime.git HEAD → HEAD (exit 0) +$ eval "$GIT_SSH_COMMAND -T git@github.com" +Hi ! You've successfully authenticated, but GitHub does not provide shell access. +$ eval "$GIT_SSH_COMMAND -T git@gist.github.com" +sandbox proxy: gist.github.com:22: 403 Creating gists is blocked by sandbox policy (exfiltration risk). Write to a file in the repo instead. +Connection closed by UNKNOWN port 65535 +``` + +Same results for `git ls-remote` and `gh api user` under `~/.srt-claude-allowall.json`. +The git-over-SSH runs had `/opt/homebrew/bin` removed from `PATH` inside the sandbox, so +socat was not reachable: the ProxyCommand uses `/bin/sh`, `/usr/bin/nc` and `/bin/cat` by +absolute path. + +Violations file after that session (denyall): + +``` +… deny network-outbound gist.github.com:443 (Creating gists is blocked by sandbox policy (exfiltration risk). Write to a file in the repo instead.) +… deny network-outbound 169.254.169.254:80 (host is not on the allow list) +… ssh() deny(1) mach-lookup com.apple.dnssd.service +… ssh() deny(1) mach-lookup com.apple.usymptomsd +… ssh() deny(1) file-read-data /Users//.ssh/id_ed25519-github-ubc ← expected: agent signs, key file stays denied +… zsh() deny(1) file-read-metadata /Users//.zshenv ← ssh runs ProxyCommand via $SHELL -c +``` + +Bare `ssh -T git@github.com` (without the `eval "$GIT_SSH_COMMAND …"` recipe) fails with +`Could not resolve hostname github.com`, exactly as before this release. + +## 3. File-by-file edits + +### `README.md` + +1. **Step 1 "Install the patched `srt` from the fork"** (currently "plus three more deltas: + … and git-over-SSH works on macOS via an auth-capable socat ProxyCommand"): + make it two more deltas (violations file, unrecognized-key warning) and drop the socat + clause. Keep the PR #283 link for `allowAllDomains`. +2. Same step, the version line `srt --version # → 0.0.77-ltic.1` → `0.0.81-ltic.1`. +3. Same step, **delete the whole paragraph** "Also install socat (`just deps`) — it's what + carries sandboxed SSH through the authenticated proxy …" and change the code block + `brew install socat jq` → `brew install jq`. +4. Add a **prerequisite line**: Node ≥ 22.12 (the nvm note in that step is the natural + place; `node --version` as the check). Say that an older Node fails at `npm install` + with an `engines` error. +5. **"Git, SSH and GitHub"** bullet "**git-over-SSH works via the forwarded ssh-agent** — + needs socat and keys loaded with `ssh-add` …": remove "needs socat and". Add one + sentence: a destination refused by `deniedDomains` now prints its configured reason on + ssh's stderr (`sandbox proxy: :22: 403 `), and `git` shows the same line. + Keep the `eval "$GIT_SSH_COMMAND git@github.com"` recipe; it is verified. +6. **"Upgrading"**: no structural change; the pointer to "upgrade notes by version" covers + the new DETAILS entry. Optionally note that `brew uninstall socat` is safe afterwards + if nothing else uses it. +7. **"Shared between both"** (the postures section): if Greg adopts `requireHostMatch` + (section 4), add one bullet describing it. Otherwise no change. + +### `DETAILS.md` + +1. **"## The fork"**: "Four deltas over upstream `srt`:" → three. Delete item 4 ("Working + git-over-SSH on macOS … PR #452 …"). Add a short "Retired deltas" note after the list, + beside the existing denyReadAlways history if there is one: `denyReadAlways` (retired at + 0.0.77, upstream deny-within-allow) and the macOS socat `GIT_SSH_COMMAND` (retired at + 0.0.81, upstream #681 nc-based ProxyCommand; PR #452 superseded). +2. **New "### Upstream changes in 0.0.81"** after "### Upstream changes in 0.0.77", same + bullet style. Content (macOS-relevant; PR numbers in parentheses): + - git-over-SSH on macOS uses stock `/usr/bin/nc`: `SRT_SSH_PROXY_COMMAND` holds an + authenticated `CONNECT` script, `GIT_SSH_COMMAND`'s ProxyCommand evals it; the token + never appears in ssh's argv (#681). + - A refused CONNECT carries the deny reason as the `403` status phrase, so curl, git and + ssh print it in-band; the violations file still gets the line too (#681). + - Path entries with glob characters are also read as the path they spell where it + exists; brackets that spell no character set are text; `{ "path": …, "literal": true }` + marks an entry literal in any filesystem list (#620–#622, #687, #718). + - macOS read rules apply under both spellings of a path, as written and realpath'd + (#680). `credentials.files` entries beat `allowRead`/`allowWrite` everywhere (#680, #686). + - The sandbox write-denies srt's own install directory, and `checkDependencies()` warns + if srt is installed somewhere the sandbox can write (#595). + - TLS termination runs in-process; new `network.tlsTerminate.maxTunnels` (default 256, + past it a CONNECT gets `503 X-Proxy-Error: too-many-tunnels` and a violation) and + `handshakeTimeoutMs` (default 10000) (#665, #700). + - New `network.requireHostMatch`, `refuseOpaqueTunnels`, `stripResponseHeaders`, + `allowPlaintextHeaderSet`, `ambiguousRequestTarget` (#663, #698); see section 4 for + which apply here. + - Proxy: invalid upstream status lines get `502` instead of crashing the proxy (#660); + over-long bodies are passed whole (#717); cert serials never start with 0x00 (#685). + - Node ≥ 22.12 (#636). The ask callback only allows on `true` (#591; the CLI has none). +3. **"### Upgrade notes by version"**: add, above the 0.70 → 0.77 entry: + `**0.0.77-ltic.1 → 0.0.81-ltic.1.** No config migration. socat is no longer needed + (`just deps` and `just check` stop asking for it; `brew uninstall socat` is optional). + Node must be ≥ 22.12. After `just upgrade`, `just check` should pass unchanged; if the + configs were customized, nothing in them needs to move. Optional: add + `"requireHostMatch": true` under `network` in both configs (below).` +4. **"## SSH" → "Transport." paragraph**: replace "srt sets `GIT_SSH_COMMAND` with a + `ProxyCommand`; with socat on PATH (fork delta 4) that is an authenticated HTTP CONNECT + tunnel — without socat srt falls back to `nc`, which can't authenticate, and + git-over-SSH dies at the handshake." with: srt sets `GIT_SSH_COMMAND` whose + `ProxyCommand` runs `/bin/sh -c 'eval "$SRT_SSH_PROXY_COMMAND" sh %h %p'`; that script + (also in the environment) sends an authenticated `CONNECT %h:%p` through `/usr/bin/nc` + to the session proxy and relays with `cat`. Nothing beyond what macOS ships is needed. + A refused destination prints `sandbox proxy: host:22: 403 `. Keep the rest of + the paragraph (opaque pass-through, `deniedDomains` still apply, the `eval` recipe). +5. **"## Using on Linux"**: add the Linux-only changes: sandboxed commands can no longer + create user or mount namespaces by default (Chromium/Electron sandboxes, rootless + podman/buildah, nested bwrap and `unshare -r` fail inside; `allowNestedUserNamespaces: + true` lifts it, at the cost of write-deny enforcement against a hostile command; needs + the seccomp helper or bwrap ≥ 0.8.0 not setuid) (#588, #617); `denyWrite` now beats + `allowWrite` however nested, so `denyWrite: ["~"]` makes a project under `~` read-only + (#684); the read-deny glob walk is ~6× faster and no longer blocks the event loop + (#627 ff.); bubblewrap 0.4 works again (#580); the first network call of a command is + no longer refused because the in-sandbox relays were not yet listening (#639). +6. **"## Future directions"**: refresh. Three of the five ideas now have upstream + primitives, all library-level (not reachable from `srt --settings` configs): per-tool + egress via `SandboxManager.registerCommandNetworkLists(commandId, { allowedDomains })` + (#591); URL/header-level policy via `filterRequest` with `setHeaders`/`removeHeaders`/ + `onResponse` (#663); an external policy engine via `srt proxy` / `srt-proxy`, the HTTP + proxy alone with an external decider process (`docs/srt-proxy.md`, #666). Keep "egress + audit logging" as still open. Note `credentials.files` masking is still Linux-only. +7. **"0.0.62-ltic.1 → later"** entry mentions "socat and jq became dependencies": leave as + history, but it is fine to append "(socat dropped again at 0.0.81)". + +### `justfile` + +1. **`deps`**: the loop `for f in socat jq; do …` → `for f in jq; do …`. Update the recipe + comment "install socat + jq via Homebrew" accordingly. Add a Node version check, e.g. + `node -e 'const [M,m]=process.versions.node.split(".").map(Number); process.exit(M>22||(M===22&&m>=12)?0:1)' && echo " ok node $(node --version)" || echo " FAIL node $(node --version) — srt 0.0.81 needs >= 22.12"`. +2. **`check` step 1 "tools"**: `for t in srt socat jq node openssl` → drop `socat`; add the + same Node floor check. +3. **`check`, optional new step** after the `gh api user` smoke test: when `ssh-add -l` + exits 0, run + `_ccx_run "$deny" sh -c 'eval "$GIT_SSH_COMMAND -T git@github.com" 2>&1'` and pass on + output containing `successfully authenticated` (ssh exits 1 there; judge the text, not + the status). Skip with a note when the agent holds no keys. +4. `upgrade`, `install-*`, `trust-ca` unchanged. + +### `.srt-claude-denyall.json`, `.srt-claude-allowall.json` + +No required edits. Two optional, separately decided additions (ask Greg; default is to +leave them out of this PR unless told otherwise): + +- Hardening, both files, under `network`: `"requireHostMatch": true`. Verify afterwards + with `just check` and a few tool runs (`npm view`, `cargo search`, `uv pip install + --dry-run`, `gh api user`); a tool that sends a Host header or SNI not matching its + CONNECT target would now get `421` and a violation line. +- Policy, both files: `"github.com:22"` and `"ssh.github.com"` in `deniedDomains` with + matching `deniedDomainReasons` (e.g. "SSH to GitHub is blocked; use an https remote, + gh auth is injected by the proxy"). Closes the SSH path that bypasses token masking. + Only sensible if the documented push path moves to HTTPS remotes. +- Noise (optional): the ssh hop produced three benign denials under `denyall` + (`mach-lookup com.apple.dnssd.service`, `mach-lookup com.apple.usymptomsd`, + `/.zshenv` read). They may be added to `ignoreViolations["*"]` if they annoy. + +### `.zshrc.example`, `.bashrc.example`, `srt-violations.mjs` + +No changes. The wrapper's per-launch `allowUnixSockets` and CA-path injection, the +credential seeding and the hook are all exercised by the verified runs above. + +## 4. Acceptance checklist for the PR + +- [ ] `just check` passes **with socat uninstalled or hidden from PATH**. +- [ ] `srt --version` → `0.0.81-ltic.1` and `just check` reports it as the fork. +- [ ] Both configs load with no "unrecognized settings key" warning. +- [ ] `ccx_exec gh api user --jq .login` prints the login. +- [ ] `ccx_exec sh -c 'eval "$GIT_SSH_COMMAND -T git@github.com"'` prints "successfully + authenticated" (needs a key in the agent). +- [ ] `ccx_exec curl -sI https://gist.github.com | head -1` shows the configured reason in + the 403 line, and the same line appears in `$SRT_VIOLATIONS_FILE`. +- [ ] README no longer mentions socat as a requirement; DETAILS lists three fork deltas, + has the 0.0.81 upstream-changes section, the 0.77 → 0.81 upgrade note, the rewritten + SSH transport paragraph, and the refreshed Linux and future-directions sections. +- [ ] If `requireHostMatch` was adopted: both configs carry it and the tool smoke tests + above still pass. diff --git a/package-lock.json b/package-lock.json index 86a5938fc..54204352a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@anthropic-ai/sandbox-runtime", - "version": "0.0.81", + "version": "0.0.81-ltic.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@anthropic-ai/sandbox-runtime", - "version": "0.0.81", + "version": "0.0.81-ltic.1", "license": "Apache-2.0", "dependencies": { "@pondwader/socks5-server": "^1.0.10", diff --git a/package.json b/package.json index 98b577445..f88da44a1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@anthropic-ai/sandbox-runtime", - "version": "0.0.81", + "version": "0.0.81-ltic.1", "description": "Anthropic Sandbox Runtime (ASRT) - A general-purpose tool for wrapping security boundaries around arbitrary processes", "type": "module", "main": "./dist/index.js", diff --git a/src/cli.ts b/src/cli.ts index 560141eaa..2019dd30d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -20,6 +20,65 @@ function getDefaultConfigPath(): string { return path.join(os.homedir(), '.srt-settings.json') } +/** + * LTIC fork: stream sandbox violations to a file the sandboxed child can + * read. + * + * The violation store lives in this host-side process; the sandboxed agent + * only ever sees a generic 403 / EPERM when it trips a rule. Appending each + * violation line to a host-written file (advertised to the child via + * SRT_VIOLATIONS_FILE) lets tooling inside the sandbox — e.g. a Claude Code + * PostToolUse hook — surface denial reasons to the agent. + * + * The file lives under ~/.local/state/srt, which the sandbox config should + * grant read but NOT write: violation lines flow into an agent's context, + * so sandboxed code must not be able to forge them. + */ +function startViolationsFile(): string | undefined { + try { + const dir = path.join(os.homedir(), '.local', 'state', 'srt') + fs.mkdirSync(dir, { recursive: true }) + + // Prune logs from long-dead sessions so the directory doesn't grow + // without bound. + const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000 + for (const name of fs.readdirSync(dir)) { + if (!/^violations-\d+\.log$/.test(name)) continue + const p = path.join(dir, name) + try { + if (fs.statSync(p).mtimeMs < weekAgo) fs.unlinkSync(p) + } catch { + // Another session may have pruned it first. + } + } + + const file = path.join(dir, `violations-${process.pid}.log`) + fs.writeFileSync(file, '') + + const store = SandboxManager.getSandboxViolationStore() + // The store notifies synchronously on every addViolation, so each + // callback carries exactly the events past `seen`; the initial + // subscribe() replay is skipped because `seen` already covers it. + let seen = store.getTotalCount() + store.subscribe(() => { + const total = store.getTotalCount() + if (total <= seen) return + const fresh = store.getViolations().slice(-(total - seen)) + seen = total + const text = fresh + .map(v => `${v.timestamp.toISOString()} ${v.line}`) + .join('\n') + fs.appendFile(file, text + '\n', () => {}) + }) + return file + } catch (error) { + logForDebugging( + `Failed to set up violations file: ${error instanceof Error ? error.message : String(error)}`, + ) + return undefined + } +} + /** * The version `--version` reports, from the package's own manifest, which * sits one directory above both src/cli.ts and dist/cli.js. A static import @@ -500,9 +559,19 @@ async function main(): Promise { process.off('SIGTERM', interrupt) } - // Initialize sandbox with config + // Initialize sandbox with config. LTIC fork: enable the log + // monitor so filesystem (seatbelt/seccomp) denials reach the + // violation store — the CLI otherwise only collects proxy denials. logForDebugging('Initializing sandbox...') - await SandboxManager.initialize(runtimeConfig) + await SandboxManager.initialize(runtimeConfig, undefined, true) + + // LTIC fork: expose violations to the sandboxed child (see + // startViolationsFile). The child inherits process.env. + const violationsFile = startViolationsFile() + if (violationsFile) { + process.env.SRT_VIOLATIONS_FILE = violationsFile + logForDebugging(`Violations file: ${violationsFile}`) + } // Read config updates only now. The stream has been waiting // unread, so nothing the caller wrote meanwhile is lost, and an diff --git a/src/sandbox/http-proxy.ts b/src/sandbox/http-proxy.ts index d450b1065..2f849f975 100644 --- a/src/sandbox/http-proxy.ts +++ b/src/sandbox/http-proxy.ts @@ -734,7 +734,7 @@ export function createHttpProxyServer(options: HttpProxyServerOptions): Server { (options.shouldTerminateTLS?.(hostname, port) ?? true) ) { // We can only terminate TLS. CONNECT also carries non-TLS streams — - // notably SSH on Linux, where the sandbox's own GIT_SSH_COMMAND + // notably SSH, where the sandbox's own GIT_SSH_COMMAND // routes `ssh` through this proxy via `socat - PROXY:`. Send 200 so // the client transmits its first bytes, sniff for a ClientHello, and // only terminate if it is one. Non-TLS falls through to the opaque diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 66f9db747..3f329255f 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -1501,7 +1501,7 @@ function probeUid0UserNamespace(bwrap: string): string | null { * * 3. Configure environment: * - HTTP_PROXY=http://localhost:3128 for HTTP/HTTPS tools - * - GIT_SSH_COMMAND with socat for SSH through SOCKS5 + * - GIT_SSH_COMMAND with socat for SSH through HTTP CONNECT * * LIMITATION: Unlike macOS sandbox which can enforce domain-based allowlists at the kernel level, * Linux's --unshare-net provides only all-or-nothing network isolation. Domain filtering happens diff --git a/src/sandbox/sandbox-config.ts b/src/sandbox/sandbox-config.ts index 3c4f96974..2712d43e0 100644 --- a/src/sandbox/sandbox-config.ts +++ b/src/sandbox/sandbox-config.ts @@ -701,6 +701,19 @@ export const NetworkConfigSchema = z.object({ .describe( 'If true, hosts not in allowedDomains are denied without consulting the ask callback, and a per-command allow list (registerCommandNetworkLists) is ignored. Set this when allowedDomains is policy enforcement, not a prompt-suppression hint.', ), + allowAllDomains: z + .boolean() + .optional() + .describe( + 'If true, permit network egress to any domain. deniedDomains is still ' + + 'enforced (checked before the allow-all decision), so explicit denies ' + + 'still take effect. IP literals and loopback names (localhost, ' + + '*.localhost) are not covered: they still need an allowedDomains ' + + 'entry, and allowed hostnames remain subject to the resolved-address ' + + 'check. Intended for environments where filesystem ' + + 'restrictions are relied on as the primary boundary and the ' + + 'allowlist would otherwise need to enumerate the entire public web.', + ), deniedResolvedAddresses: z .array(addressRangeSchema) .optional() @@ -1298,10 +1311,16 @@ export const SandboxRuntimeConfigSchema = z // Host-scoped view of the allowlist (":port" suffixes dropped) — // injection is per-host, not per-port. const allowedHosts = cfg.network.allowedDomains.map(stripDomainPatternPort) + // allowAllDomains makes every host reachable regardless of the + // allowlist, so the reachability requirement on injectHosts is + // vacuous — without this, the masking recipe would demand allowlist + // entries that the flag makes meaningless. + const allowAll = cfg.network.allowAllDomains === true const checkSubset = ( hosts: readonly string[], path: (string | number)[], ) => { + if (allowAll) return for (const [i, host] of hosts.entries()) { if (!isInjectHostCoveredByAllowedDomains(host, allowedHosts)) { ctx.addIssue({ @@ -1331,6 +1350,28 @@ export const SandboxRuntimeConfigSchema = z } if (entry.mode !== 'mask') return hasMasked = true + // Runtime default injectHosts is network.allowedDomains. Under + // allowAllDomains the allowlist no longer gates reachability and is + // typically empty — the default then resolves to no hosts at all + // and the credential would be masked but never injected (silent + // auth failure inside the sandbox). Require explicit injectHosts. + if ( + allowAll && + entry.injectHosts === undefined && + allowedHosts.length === 0 + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path, + message: + `This masked credential has no injectHosts, so it defaults to ` + + `network.allowedDomains — which is empty. With ` + + `network.allowAllDomains the allowlist does not gate ` + + `reachability, so the default would inject the credential ` + + `nowhere. List the hosts that should receive the real value ` + + `in injectHosts.`, + }) + } // Credential substitution only runs on the TLS-terminated path, so a // host covered by tlsTerminate.excludeDomains can never receive the // real value — the upstream sees the placeholder. Reject the diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index 6b6e0197a..e28ec7ed5 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -144,6 +144,8 @@ import { stripDomainPatternPort, } from './domain-pattern.js' import type { ChildProcess } from 'node:child_process' +import { isIP } from 'node:net' +import { isLoopbackName } from './address.js' import type { DirectLookup, ResolvedParentProxy } from './parent-proxy.js' import { createResolvedAddressGuard, @@ -776,6 +778,20 @@ async function filterNetworkRequest( } } + // allowAllDomains short-circuits the allowlist after denies are checked, + // so explicit deniedDomains entries still take effect. It covers hostnames + // only: the resolved-address guard trusts an allow-listed IP literal or + // loopback name as an explicit choice, so those must still earn an + // allowedDomains entry rather than ride the flag to IMDS or host loopback. + if ( + config.network.allowAllDomains && + !isIP(canonicalHost) && + !isLoopbackName(canonicalHost) + ) { + logForDebugging(`Allowed by allowAllDomains: ${host}:${port}`) + return true + } + // Check allowed domains for (const allowedDomain of config.network.allowedDomains) { if (matchesDomainPatternWithPort(canonicalHost, port, allowedDomain)) { diff --git a/src/utils/config-loader.ts b/src/utils/config-loader.ts index 7f0f8d158..8341c2a2a 100644 --- a/src/utils/config-loader.ts +++ b/src/utils/config-loader.ts @@ -1,9 +1,92 @@ import * as fs from 'fs' +import { z } from 'zod/v3' import { SandboxRuntimeConfigSchema, type SandboxRuntimeConfig, } from '../sandbox/sandbox-config.js' +/** + * LTIC fork: surface unrecognized settings keys instead of silently + * stripping them. + * + * Zod's default object behavior drops unknown keys, so a mistyped key + * (`allowAllDomainz`) or a key from a different srt build (fork keys like + * `network.allowAllDomains` on stock srt, or newer upstream keys on an + * older build) validates "successfully" while the intended restriction or + * grant is silently lost. A network config that looks default-allow can + * actually be deny-all. These helpers walk the parsed JSON against the + * schema and name every key the validator does not know, so the mismatch + * is loud at startup rather than discovered from sandbox behavior. + */ +function unwrapSchema(schema: z.ZodTypeAny): z.ZodTypeAny { + for (;;) { + if (schema instanceof z.ZodOptional || schema instanceof z.ZodNullable) { + schema = schema.unwrap() as z.ZodTypeAny + } else if (schema instanceof z.ZodDefault) { + schema = schema.removeDefault() as z.ZodTypeAny + } else if (schema instanceof z.ZodEffects) { + schema = schema.innerType() as z.ZodTypeAny + } else { + return schema + } + } +} + +function collectUnrecognized( + value: unknown, + schema: z.ZodTypeAny, + path: string, + out: string[], +): void { + const s = unwrapSchema(schema) + if ( + s instanceof z.ZodObject && + value !== null && + typeof value === 'object' && + !Array.isArray(value) + ) { + const shape = s.shape as Record + for (const [key, child] of Object.entries(value)) { + const childPath = path ? `${path}.${key}` : key + const childSchema = shape[key] + if (childSchema) { + collectUnrecognized(child, childSchema, childPath, out) + } else { + out.push(childPath) + } + } + } else if (s instanceof z.ZodArray && Array.isArray(value)) { + for (const [i, item] of value.entries()) { + collectUnrecognized(item, s.element as z.ZodTypeAny, `${path}[${i}]`, out) + } + } + // Unions, records, and primitives: keys can't be attributed to a single + // shape, so they are left to the schema itself (the union members that + // need it are .strict() and fail the parse outright). +} + +/** + * Names every key in a parsed settings object that the config schema does + * not recognize (dot-separated paths, e.g. "network.allowAllDomainz"). + * Exported for tests. + */ +export function collectUnrecognizedConfigKeys(parsed: unknown): string[] { + const out: string[] = [] + collectUnrecognized(parsed, SandboxRuntimeConfigSchema, '', out) + return out +} + +function warnUnrecognizedKeys(parsed: unknown, source: string): void { + const unknown = collectUnrecognizedConfigKeys(parsed) + if (unknown.length === 0) return + console.error( + `[sandbox-runtime] WARNING: unrecognized settings key(s) in ${source}: ` + + `${unknown.join(', ')}. Unrecognized keys are IGNORED — the ` + + 'restriction or grant they were meant to express is not in effect. ' + + 'Check for typos or an srt build mismatch (`srt --version`).', + ) +} + /** * Parse and validate sandbox configuration from a string * Used for parsing config from control fd (JSON lines protocol) @@ -21,6 +104,7 @@ export function loadConfigFromString( if (!result.success) { return null } + warnUnrecognizedKeys(parsed, 'control-fd config update') return result.data } catch { return null @@ -86,5 +170,7 @@ export function loadConfig(filePath: string): LoadConfigResult { } } + warnUnrecognizedKeys(parsed, filePath) + return { kind: 'ok', config: result.data } } diff --git a/test/config-unrecognized-keys.test.ts b/test/config-unrecognized-keys.test.ts new file mode 100644 index 000000000..ae498931b --- /dev/null +++ b/test/config-unrecognized-keys.test.ts @@ -0,0 +1,121 @@ +import { describe, test, expect } from 'bun:test' +import { collectUnrecognizedConfigKeys } from '../src/utils/config-loader.js' + +// LTIC fork: the settings loader warns on keys the schema does not +// recognize instead of letting zod strip them silently. These tests pin +// down both directions: every key the fork's configs rely on (including +// the fork-only keys) is recognized, and unknown/mistyped keys are named. +describe('collectUnrecognizedConfigKeys', () => { + const base = { + network: { allowedDomains: [], deniedDomains: [] }, + filesystem: { denyRead: [], allowWrite: [], denyWrite: [] }, + } + + test('recognizes every key used by the ccx configs, including fork keys', () => { + const config = { + network: { + allowedDomains: ['example.com'], + deniedDomains: ['gist.github.com', '*:25'], + // Fork key (upstream PR #283) + allowAllDomains: true, + deniedDomainReasons: { + 'gist.github.com': 'blocked by sandbox policy', + }, + allowUnixSockets: ['/var/run/docker.sock'], + allowLocalBinding: true, + }, + filesystem: { + denyRead: ['/Users'], + allowRead: ['~/src', '/**/.env.example'], + allowWrite: ['/tmp'], + denyWrite: [], + }, + ignoreViolations: { + '*': ['sysctl-read kern.'], + }, + enableWeakerNetworkIsolation: true, + } + + expect(collectUnrecognizedConfigKeys(config)).toEqual([]) + }) + + test('flags a mistyped fork key in network', () => { + const config = { + ...base, + network: { ...base.network, allowAllDomainz: true }, + } + + expect(collectUnrecognizedConfigKeys(config)).toEqual([ + 'network.allowAllDomainz', + ]) + }) + + test('flags a mistyped key in filesystem', () => { + const config = { + ...base, + filesystem: { ...base.filesystem, alowRead: ['~/src'] }, + } + + expect(collectUnrecognizedConfigKeys(config)).toEqual([ + 'filesystem.alowRead', + ]) + }) + + test('flags the retired denyReadAlways keys', () => { + // Retired in 0.0.77-ltic.1: upstream's denyRead now beats an enclosing + // allowRead, and a narrower allowRead carves exceptions back out. A + // leftover config must be told its keys no longer do anything. + const config = { + ...base, + filesystem: { + ...base.filesystem, + denyReadAlways: ['/**/.env*'], + denyReadAlwaysExcept: ['/**/.env.example'], + }, + } + + expect(collectUnrecognizedConfigKeys(config)).toEqual([ + 'filesystem.denyReadAlways', + 'filesystem.denyReadAlwaysExcept', + ]) + }) + + test('flags unknown top-level keys', () => { + const config = { ...base, allowEverything: true } + + expect(collectUnrecognizedConfigKeys(config)).toEqual(['allowEverything']) + }) + + test('flags nested unknown keys inside tlsTerminate', () => { + const config = { + ...base, + network: { ...base.network, tlsTerminate: { caCert: '/etc/ca.crt' } }, + } + + expect(collectUnrecognizedConfigKeys(config)).toEqual([ + 'network.tlsTerminate.caCert', + ]) + }) + + test('does not flag record keys (ignoreViolations command patterns)', () => { + const config = { + ...base, + ignoreViolations: { 'git push': ['/usr/bin/nc'] }, + } + + expect(collectUnrecognizedConfigKeys(config)).toEqual([]) + }) + + test('reports multiple unknown keys with full paths', () => { + const config = { + ...base, + network: { ...base.network, allowedDomainz: [] }, + filesystem: { ...base.filesystem, denyReed: [] }, + } + + expect(collectUnrecognizedConfigKeys(config).sort()).toEqual([ + 'filesystem.denyReed', + 'network.allowedDomainz', + ]) + }) +}) diff --git a/test/config-validation.test.ts b/test/config-validation.test.ts index c0f353325..dce89720f 100644 --- a/test/config-validation.test.ts +++ b/test/config-validation.test.ts @@ -93,6 +93,24 @@ describe('Config Validation', () => { expect(result.success).toBe(false) }) + test('should validate config with allowAllDomains flag', () => { + const config = { + network: { + allowedDomains: [], + deniedDomains: ['evil.com'], + allowAllDomains: true, + }, + filesystem: { + denyRead: [], + allowWrite: [], + denyWrite: [], + }, + } + + const result = SandboxRuntimeConfigSchema.safeParse(config) + expect(result.success).toBe(true) + }) + test('should validate config with optional fields', () => { const config = { network: { @@ -2406,3 +2424,107 @@ describe('Config Validation', () => { }) }) }) + +describe('allowAllDomains × credential masking', () => { + const base = { + network: { allowedDomains: [], deniedDomains: [], allowAllDomains: true }, + filesystem: { denyRead: [], allowWrite: [], denyWrite: [] }, + } + + test('accepts injectHosts not covered by allowedDomains', () => { + // The docs' masking recipe: default-allow networking, empty + // allowlist, credential injected only toward GitHub. Without the + // allowAll bypass the subset check would demand github.com in + // allowedDomains — entries the flag makes meaningless. + const result = SandboxRuntimeConfigSchema.safeParse({ + ...base, + network: { ...base.network, tlsTerminate: {} }, + credentials: { + envVars: [ + { + name: 'GH_TOKEN', + mode: 'mask', + injectHosts: ['github.com', '*.github.com'], + }, + ], + }, + }) + expect(result.success).toBe(true) + }) + + test('without allowAllDomains the same config is still rejected', () => { + const result = SandboxRuntimeConfigSchema.safeParse({ + ...base, + network: { allowedDomains: [], deniedDomains: [], tlsTerminate: {} }, + credentials: { + envVars: [ + { name: 'GH_TOKEN', mode: 'mask', injectHosts: ['github.com'] }, + ], + }, + }) + expect(result.success).toBe(false) + }) + + test('rejects a masked credential with no injectHosts and an empty allowlist', () => { + // Runtime default injectHosts = allowedDomains = [] — the credential + // would be masked but injected nowhere, a silent auth failure. + const result = SandboxRuntimeConfigSchema.safeParse({ + ...base, + network: { ...base.network, tlsTerminate: {} }, + credentials: { + envVars: [{ name: 'GH_TOKEN', mode: 'mask' }], + }, + }) + expect(result.success).toBe(false) + if (!result.success) { + const messages = result.error.issues.map(i => i.message).join('\n') + expect(messages).toContain('inject the credential nowhere') + } + }) + + test('accepts a masked credential with no injectHosts when allowedDomains is non-empty', () => { + // Default injectHosts = allowedDomains still names real hosts, so the + // credential is injectable — same as without allowAllDomains. + const result = SandboxRuntimeConfigSchema.safeParse({ + ...base, + network: { + ...base.network, + allowedDomains: ['api.github.com'], + tlsTerminate: {}, + }, + credentials: { + envVars: [{ name: 'GH_TOKEN', mode: 'mask' }], + }, + }) + expect(result.success).toBe(true) + }) + + test('still rejects injectHosts entirely covered by tlsTerminate.excludeDomains', () => { + // allowAllDomains does not rescue the terminated-path contradiction: + // an excluded host never gets injection regardless of reachability. + const result = SandboxRuntimeConfigSchema.safeParse({ + ...base, + network: { + ...base.network, + tlsTerminate: { excludeDomains: ['github.com'] }, + }, + credentials: { + envVars: [ + { name: 'GH_TOKEN', mode: 'mask', injectHosts: ['github.com'] }, + ], + }, + }) + expect(result.success).toBe(false) + }) + + test('still rejects an explicitly empty injectHosts', () => { + const result = SandboxRuntimeConfigSchema.safeParse({ + ...base, + network: { ...base.network, tlsTerminate: {} }, + credentials: { + envVars: [{ name: 'GH_TOKEN', mode: 'mask', injectHosts: [] }], + }, + }) + expect(result.success).toBe(false) + }) +}) diff --git a/test/proxy-cli.test.ts b/test/proxy-cli.test.ts index 6f18e582b..5bfbfba80 100644 --- a/test/proxy-cli.test.ts +++ b/test/proxy-cli.test.ts @@ -369,7 +369,7 @@ describe('srt-proxy --version', () => { test('prints one line, the name and the package version, and exits 0', () => { expect(manifest.version).toMatch( - /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/, + /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$/, ) expect(runToExit(['--version'])).toEqual({ status: 0, diff --git a/test/sandbox/connect-non-tls.test.ts b/test/sandbox/connect-non-tls.test.ts index ae9322362..8b7d9088c 100644 --- a/test/sandbox/connect-non-tls.test.ts +++ b/test/sandbox/connect-non-tls.test.ts @@ -8,7 +8,7 @@ import { looksLikeClientHello } from '../../src/sandbox/tls-terminate-proxy.js' /** * Regression: with tlsTerminate on, every CONNECT was handed to the - * TLS terminator regardless of port/protocol. The Linux sandbox routes + * TLS terminator regardless of port/protocol. The sandbox routes * `git push` over SSH through this proxy via * GIT_SSH_COMMAND=ssh -o ProxyCommand='socat - PROXY:localhost:%h:%p,...' * which is `CONNECT github.com:22` — not TLS. The terminator replied with a diff --git a/test/sandbox/update-config.test.ts b/test/sandbox/update-config.test.ts index f0e1d2330..e4aaa09b6 100644 --- a/test/sandbox/update-config.test.ts +++ b/test/sandbox/update-config.test.ts @@ -420,6 +420,33 @@ describe('proxy auth + network deny semantics', () => { expect((await proxyRequest(port, 'nope.net')).statusCode).toBe(403) expect(asked).toBe(false) }) + + it('allowAllDomains admits hostnames but not IP literals or loopback names', async () => { + await SandboxManager.initialize({ + network: { + allowedDomains: [], + deniedDomains: [], + allowAllDomains: true, + }, + filesystem: { denyRead: [], allowWrite: [], denyWrite: [] }, + }) + const port = SandboxManager.getProxyPort()! + expect((await proxyRequest(port, 'example.com')).allowed).toBe(true) + for (const host of [ + '169.254.169.254', + '127.0.0.1', + '127.1', + '[::1]', + '[::ffff:127.0.0.1]', + 'localhost', + 'foo.localhost', + ]) { + expect({ host, ...(await proxyRequest(port, host)) }).toMatchObject({ + host, + statusCode: 403, + }) + } + }) }) /**