diff --git a/AGENTS.md b/AGENTS.md index d87b9819..d84fc8ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,6 +74,14 @@ actually shipped in V1. - Do not treat the hermetic smokes as the only release gate. They run with a temp `HYP_HOME` and `HYP_DEV_TELEMETRY=1`, which is useful for deterministic regression checks but does not prove installed-daemon behavior. +- Use `scripts/sandbox/hyp-sandbox` to exercise install methods (`npx`, + `npm install -g`, `hyp init`, `hyp join`/`leave`, attach, daemon install) + without touching your own install. It redirects `HOME`, `HYP_HOME`, and the + npm global prefix into a throwaway root, and puts mock `launchctl` / + `security` / `systemctl` first on `PATH` so those calls never reach your real + launchd domain or login keychain (both are per-uid, so a temp `HOME` alone + does not sandbox them). See + [`scripts/sandbox/README.md`](scripts/sandbox/README.md). ## Smoke Test Model diff --git a/scripts/sandbox/README.md b/scripts/sandbox/README.md new file mode 100644 index 00000000..c8c42d3c --- /dev/null +++ b/scripts/sandbox/README.md @@ -0,0 +1,246 @@ +# hyp-sandbox + +Test HypAware installation methods without touching your real install. + +`hyp-sandbox` runs any command with `HOME`, `HYP_HOME`, and the npm global +prefix pointed at a throwaway directory, and with **mock `launchctl`, +`security`, and `systemctl`** first on `PATH`. So a real `hyp daemon install`, +`hyp attach claude`, or `hyp daemon uninstall` runs its real code all the way +down, but the LaunchAgent, the CA trust, and `NODE_USE_SYSTEM_CA` land in the +sandbox instead of your login session. + +```sh +scripts/sandbox/hyp-sandbox info # what the sandbox looks like +scripts/sandbox/hyp-sandbox hyp daemon install # workspace build, sandboxed +scripts/sandbox/hyp-sandbox run npx hypaware@latest status +scripts/sandbox/hyp-sandbox calls # what it intercepted +scripts/sandbox/hyp-sandbox reset # throw it all away +``` + +Default root is `~/.hyp-sandbox`; override with `--root ` or +`HYP_SANDBOX_ROOT`. + +## Why the mocks exist + +`HOME` alone is not enough. launchd service labels and `launchctl setenv` live +in a **per-uid** namespace, and the login keychain that macOS actually consults +is the one belonging to your user, not to `$HOME`. Without the mocks, a +sandboxed `hyp daemon install` would boot out **your real daemon**, and a +sandboxed `hyp daemon uninstall` would delete **your real CA trust** and unset +`NODE_USE_SYSTEM_CA` for your whole login session. + +Every one of those calls goes through a single seam, +`runServiceCommand(bin, args)` in `src/core/daemon/service_ops.js`, which +spawns the bare binary name and so resolves it through `PATH`. That is what +the shims intercept. + +## What is isolated + +| Thing | Where it goes in the sandbox | +|---|---| +| Cache, config, logs (`~/.hyp`) | `/home/.hyp` | +| Client attach files (`~/.claude`, `~/.codex`) | `/home/...` | +| LaunchAgent plists | `/home/Library/LaunchAgents` | +| launchd bootstrap / bootout / kickstart / setenv | `/state/launchd.json` | +| Keychain CA trust | `/state/keychain.json` | +| systemd user units | `/state/systemd.json` | +| `npm install -g`, `npx` downloads | `/npm-global`, `/npm-cache` | +| Every intercepted call | `/state/calls.jsonl` | + +## What is **not** isolated + +- **Network.** Real requests go to real upstreams. +- **Ports.** The gateway defaults to `127.0.0.1:18521`, the same port your live + daemon uses. Use `hyp-sandbox seed-config 18621` or `hyp-sandbox port 18621` + before starting a sandbox daemon. +- **Real client behaviour.** Claude Code and Codex on your machine read your + real `~/.claude` / `~/.codex`, so the sandbox proves what HypAware *writes*, + not that a real client picks it up. +- **Actual trust.** The mock keychain records trust; it does not make TLS + interception work. To prove real trust you still need a real keychain, i.e. a + second macOS user account or a VM. + +## What the sandbox *assumes* (and cannot prove) + +One assumption is load-bearing enough to state on its own, because getting it +wrong produced a confidently wrong answer once already. + +**A daemon-issued `security add-trusted-cert` is refused.** Attach runs the same +code in the CLI and in the daemon's reconciler +(`ensureDarwinProxyTrust`, `hypaware-core/plugins-workspace/claude/src/index.js`), +and trusting a CA in the login keychain is gated by the macOS password dialog. +Nobody is watching a background LaunchAgent, so the sandbox answers a +daemon-issued trust with the error macOS gives when it cannot prompt: + +``` +SecTrustSettingsSetTrustSettings: User interaction is not allowed. +``` + +The result is that an unattended fleet setup ends with: + +``` +proxy trust: + login keychain: not trusted - Remote Control inbound will not work, run `hyp attach claude` to retry + launchd env: NODE_USE_SYSTEM_CA=1 set +``` + +which matches what people report from real machines: the settings and the +launchd env land by themselves, the keychain trust waits for a human. + +**This is an assumption, not a measurement.** Whether real macOS lets a +LaunchAgent raise that dialog can only be settled on a real keychain - a second +macOS user account or a VM. The sandbox takes the pessimistic reading so a test +run cannot claim an unattended setup established trust when it may not have. +Flip it with `--trust-from-daemon grant` to exercise the other branch. + +A mock that always succeeds is worse than no mock: it turns an open question +into a false answer. If you add mocks here, prefer failing the uncertain case +and naming the assumption in the `note` the call log records. + +Related, and *not* modellable here: Remote Control also needs +`NODE_USE_SYSTEM_CA=1` in the environment when Claude Code boots. +`launchctl setenv` only reaches processes launched afterwards, so the terminal +app must be fully quit (Cmd-Q) and reopened. Nothing inside a sandbox can +reproduce your terminal's inherited environment - check `echo +$NODE_USE_SYSTEM_CA` in the real one. + +## Commands + +| Command | What it does | +|---|---| +| `info` | Print the sandbox env, and warn if the shims are not first on `PATH` | +| `shell` | Open a bare `bash` inside the sandbox (`exit` to leave) | +| `run ` | Run one command inside the sandbox | +| `hyp ` | Run the workspace build's CLI inside the sandbox | +| `central start\|stop\|status\|log\|config` | Run a stand-in central server so `hyp join` / `leave` / rejoin can be tested without a real fleet | +| `seed-config [port]` | Write a minimal v2 config with a non-clashing gateway port | +| `port ` | Rewrite every `listen` port in the sandbox config | +| `calls [n]` | Show the last n intercepted `launchctl`/`security` calls | +| `state` | Dump the mock launchd, keychain, and systemd state | +| `reset` | Delete the sandbox root (asks first) | + +| Flag | Effect | +|---|---| +| `--root ` | Sandbox root (default `~/.hyp-sandbox`) | +| `--spawn` | Mock `launchctl bootstrap` really starts the plist's program, so you get a live sandboxed daemon with a real pid, status file, and bound port | +| `--refuse-trust` | `security add-trusted-cert` behaves like the user cancelling the macOS password dialog, for testing the degraded attach path | +| `--trust-from-daemon ` | Whether a *daemon-issued* trust succeeds. Default `refuse` - see "What the sandbox assumes" | +| `--verbose` | Echo every intercepted call to stderr as it happens | + +## Worked examples + +Install method: published package, global install. + +```sh +scripts/sandbox/hyp-sandbox run npm install -g hypaware@latest +scripts/sandbox/hyp-sandbox run hyp status +``` + +Install method: local tarball. + +```sh +npm pack +scripts/sandbox/hyp-sandbox run npm install -g ./hypaware-1.23.0.tgz +scripts/sandbox/hyp-sandbox run hyp init +``` + +Full daemon lifecycle with a live process: + +```sh +scripts/sandbox/hyp-sandbox seed-config 18621 +scripts/sandbox/hyp-sandbox --spawn hyp daemon install +scripts/sandbox/hyp-sandbox hyp daemon status # real pid, real bound port +scripts/sandbox/hyp-sandbox hyp daemon uninstall # bootout stops the process +scripts/sandbox/hyp-sandbox calls +``` + +Attach and detach, including the cancelled-dialog path. Proxy-mode attach only +offers itself on an interactive terminal, so run these from `hyp-sandbox +shell`; a one-shot `hyp-sandbox hyp attach claude` falls back to base-URL mode: + +```sh +scripts/sandbox/hyp-sandbox --refuse-trust hyp attach claude +scripts/sandbox/hyp-sandbox state # keychain still empty +scripts/sandbox/hyp-sandbox hyp attach claude +scripts/sandbox/hyp-sandbox state # cert recorded as trusted +``` + +## Worked example: 1.22 → `hyp leave` → 1.23 → rejoin with the proxy + +The fake central server (`hyp-sandbox central`) serves a fleet config from +`/state/fleet-config.json`, so the whole enrollment lifecycle is +testable. Edit that file mid-run to change what the fleet says; the daemon +picks it up on its next poll (the ETag is the file's content hash). + +```sh +scripts/sandbox/hyp-sandbox --root ~/.hyp-sandbox-upgrade central start +scripts/sandbox/hyp-sandbox --root ~/.hyp-sandbox-upgrade run npm install -g hypaware@1.22.0 +scripts/sandbox/hyp-sandbox --root ~/.hyp-sandbox-upgrade --spawn run hyp join http://127.0.0.1:18700 any-token +# ...org-driven attach lands in base-URL mode (1.22 has no proxy support at all) +scripts/sandbox/hyp-sandbox --root ~/.hyp-sandbox-upgrade --spawn run hyp leave +scripts/sandbox/hyp-sandbox --root ~/.hyp-sandbox-upgrade run npm install -g hypaware@1.23.0 +scripts/sandbox/hyp-sandbox --root ~/.hyp-sandbox-upgrade --spawn run hyp join http://127.0.0.1:18700 any-token +scripts/sandbox/hyp-sandbox --root ~/.hyp-sandbox-upgrade run hyp status # proxy trust: all green +``` + +Two things the mocks had to grow for this to work, both worth knowing: + +- **KeepAlive.** HypAware applies a pulled config by *exiting* and letting + launchd restart it. The mock `launchctl` therefore runs a supervisor per + bootstrapped service (throttled at 1s rather than launchd's 10s), or the + machine would be daemon-less exactly when the fleet config lands. +- **`identity`.** A served central sink block must include an `identity` key + (`{}` is enough once the machine has an `identity.json`); without it the sink + fails to materialize, which takes the config-pull loop down with it. The + machine then recovers only when probation expires and rolls the config back. + +### Testing `hyp remote login` + +The fake server also speaks the attended sign-in flow (`/v1/identity/login/start` +and `/v1/identity/token`), so an enrolling login is testable without a real +identity provider. The "browser" is anything that fetches the start URL - the +server answers with a 302 straight to the client's loopback receiver, so `curl` +completes a sign-in: + +```sh +hyp-sandbox --root ~/.hyp-sandbox-upgrade --spawn run hyp remote login sandbox --no-browser > login.log 2>&1 & +sleep 4 +curl -sL "$(grep -o 'http://127.0.0.1:18700/v1/identity/login/start[^ ]*' login.log | head -1)" +``` + +What that surfaced: **login recovers a machine that ran `hyp leave`, and does +not recover one that only ran `hyp detach`.** The enrollment work (including the +daemon install that makes a freshly upgraded binary actually run) sits behind +`if (seeded.length === 0)` in `remoteLogin` - an already-enrolled machine +re-seeds its identity and stops. `hyp daemon install` is the idempotent step +that covers both. + +The `security add-trusted-cert` in this flow is issued by the **daemon**, not +by a command the user ran. The sandbox mock accepts it silently; a real Mac +raises its password dialog. That step is the one thing this sandbox cannot +prove - verify it in a second macOS user account before telling anyone the +flow is unattended. + +## Known snag: `npm config get prefix` + +npm 11 refuses to *read* `prefix` whenever it is set explicitly, in an +`.npmrc` or in the environment: + +``` +npm error The prefix option is protected, and can not be retrieved in this way +``` + +The sandbox has to set it, so `ensureDurableBinForNpx` +(`src/core/cli/global_install.js`), which shells out to `npm config get +prefix`, fails inside the sandbox on the `npx hypaware` → `hyp init` path. + +This is not only a sandbox artifact: **any** user with `prefix=` in their +`~/.npmrc` (the usual way to avoid `sudo` for global installs) hits the same +error on that path. `npm prefix -g` returns the same value and is not +protected. + +## Adding a mock + +The mocks live in `lib/shim.js`, one function per tool, each returning +`{ code, out, err, note }`. Add a subcommand branch there; the wrapper scripts +in `/bin` are regenerated on every run, so nothing else needs touching. diff --git a/scripts/sandbox/hyp-sandbox b/scripts/sandbox/hyp-sandbox new file mode 100755 index 00000000..f280597c --- /dev/null +++ b/scripts/sandbox/hyp-sandbox @@ -0,0 +1,364 @@ +#!/usr/bin/env bash +# +# hyp-sandbox - run HypAware installs against a throwaway HOME. +# +# Redirects HOME, HYP_HOME, and the npm global prefix into a sandbox root, +# and puts mock `launchctl` / `security` / `systemctl` first on PATH so a +# real `hyp daemon install` or `hyp attach claude` never reaches the login +# session's launchd domain or the login keychain. See README.md. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SHIM="$SCRIPT_DIR/lib/shim.js" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +ROOT="${HYP_SANDBOX_ROOT:-$HOME/.hyp-sandbox}" +SPAWN=0 +TRUST_REFUSE=0 +TRUST_FROM_DAEMON=refuse +VERBOSE=0 + +usage() { + cat <<'EOF' +usage: hyp-sandbox [options] [args...] + +commands: + shell open a subshell inside the sandbox + run [args...] run one command inside the sandbox + hyp [args...] run the workspace build's CLI inside the sandbox + info print the sandbox env and what is installed in it + calls [n] show the last n intercepted launchctl/security calls + state dump the mock launchd + keychain state + central run the stand-in central server (start|stop|status|log|config) + seed-config [port] write a minimal config (gateway + claude + codex) + port rewrite the sandbox config's gateway listen port + reset delete the sandbox root (asks first) + path print the sandbox root + +options: + --root sandbox root (default: $HYP_SANDBOX_ROOT or ~/.hyp-sandbox) + --spawn mock launchctl really starts the plist's program + --refuse-trust `security add-trusted-cert` acts like a cancelled dialog + --trust-from-daemon + whether a daemon-issued `add-trusted-cert` succeeds. + Default refuse: the login keychain is gated by a + password dialog and the daemon is a background agent + with nobody watching. This is an ASSUMPTION the + sandbox cannot verify - see README.md. + --verbose echo every intercepted call to stderr + +examples: + hyp-sandbox run npm install -g /path/to/hypaware-1.23.0.tgz + hyp-sandbox run hyp init + hyp-sandbox --spawn run hyp daemon install + hyp-sandbox calls 20 +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --root) ROOT="$2"; shift 2 ;; + --root=*) ROOT="${1#*=}"; shift ;; + --spawn) SPAWN=1; shift ;; + --refuse-trust) TRUST_REFUSE=1; shift ;; + --trust-from-daemon) TRUST_FROM_DAEMON="$2"; shift 2 ;; + --trust-from-daemon=*) TRUST_FROM_DAEMON="${1#*=}"; shift ;; + --verbose) VERBOSE=1; shift ;; + -h|--help) usage; exit 0 ;; + *) break ;; + esac +done + +if [[ $# -eq 0 ]]; then usage; exit 64; fi + +CMD="$1"; shift + +# Absolute, so a relative --root cannot follow the sandboxed HOME around. +mkdir -p "$ROOT" +ROOT="$(cd "$ROOT" && pwd)" + +SANDBOX_HOME="$ROOT/home" +SANDBOX_HYP_HOME="$SANDBOX_HOME/.hyp" +SANDBOX_BIN="$ROOT/bin" +NPM_PREFIX="$ROOT/npm-global" + +provision() { + mkdir -p \ + "$SANDBOX_HYP_HOME" \ + "$SANDBOX_HOME/Library/LaunchAgents" \ + "$SANDBOX_HOME/Library/Keychains" \ + "$SANDBOX_HOME/.config/systemd/user" \ + "$SANDBOX_BIN" \ + "$NPM_PREFIX/bin" \ + "$ROOT/npm-cache" \ + "$ROOT/state" + + for tool in launchctl security systemctl; do + cat > "$SANDBOX_BIN/$tool" < "$SANDBOX_HOME/.npmrc" </dev/null || true + read -r -p "delete it? [y/N] " reply + if [[ "$reply" == "y" || "$reply" == "Y" ]]; then + rm -rf "$ROOT" + echo "deleted $ROOT" + else + echo "kept $ROOT" + fi + ;; + + info) + provision + echo "sandbox root : $ROOT" + echo "HOME : $SANDBOX_HOME" + echo "HYP_HOME : $SANDBOX_HYP_HOME" + echo "npm prefix : $NPM_PREFIX" + echo "shims : $SANDBOX_BIN (launchctl, security, systemctl)" + echo "spawn mode : $SPAWN (1 = mock launchctl really starts the daemon)" + echo "daemon trust : $TRUST_FROM_DAEMON (assumption: whether a background LaunchAgent can raise the macOS password dialog)" + echo + # Non-login, non-rc bash: a login shell would re-run /etc/profile and + # path_helper, which rebuilds PATH and drops the shims out of first place. + echo "resolved inside the sandbox:" + sandbox_env bash --noprofile --norc -c ' + hyp_path="$(command -v hyp || true)" + echo " hyp : ${hyp_path:-(not installed in the sandbox)}" + case "$hyp_path" in + "$HYP_SANDBOX_ROOT"/*) ;; + "") ;; + *) echo " ^ outside the sandbox prefix: this is a hyp from your real PATH" ;; + esac + echo " launchctl : $(command -v launchctl) (want: $HYP_SANDBOX_ROOT/bin/launchctl)" + echo " security : $(command -v security) (want: $HYP_SANDBOX_ROOT/bin/security)" + echo " node : $(command -v node) ($(node -v))"' + echo + echo "sandbox HOME contents:" + find "$SANDBOX_HOME" -maxdepth 2 -mindepth 1 -not -path '*/.hyp/*' | sed "s|$SANDBOX_HOME| ~|" + ;; + + calls) + LIMIT="${1:-30}" + CALLS="$ROOT/state/calls.jsonl" + if [[ ! -f "$CALLS" ]]; then echo "no intercepted calls yet ($CALLS)"; exit 0; fi + tail -n "$LIMIT" "$CALLS" | node -e ' + let buf = "" + process.stdin.on("data", (c) => { buf += c }) + process.stdin.on("end", () => { + for (const line of buf.split("\n").filter(Boolean)) { + const e = JSON.parse(line) + const note = e.note ? ` # ${e.note}` : "" + console.log(`${e.ts} exit ${String(e.exit).padStart(3)} ${e.tool} ${e.args.join(" ")}${note}`) + } + }) + ' + ;; + + state) + for f in launchd keychain systemd; do + p="$ROOT/state/$f.json" + echo "--- $f" + if [[ -f "$p" ]]; then cat "$p"; else echo "(empty)"; fi + done + ;; + + central) + provision + SUB="${1:-status}"; shift || true + CENTRAL_PID="$ROOT/state/central.pid" + CENTRAL_LOG="$ROOT/state/central.log" + FLEET_CONFIG="$ROOT/state/fleet-config.json" + CENTRAL_PORT="${CENTRAL_PORT:-18700}" + case "$SUB" in + start) + if [[ -f "$CENTRAL_PID" ]] && kill -0 "$(cat "$CENTRAL_PID")" 2>/dev/null; then + echo "fake central already running (pid $(cat "$CENTRAL_PID")) on http://127.0.0.1:$CENTRAL_PORT" + exit 0 + fi + if [[ ! -f "$FLEET_CONFIG" ]]; then + cat > "$FLEET_CONFIG" <> "$CENTRAL_LOG" 2>&1 & + echo $! > "$CENTRAL_PID" + sleep 1 + echo "fake central: http://127.0.0.1:$CENTRAL_PORT (pid $(cat "$CENTRAL_PID"))" + echo "fleet config: $FLEET_CONFIG" + ;; + stop) + if [[ -f "$CENTRAL_PID" ]]; then + kill "$(cat "$CENTRAL_PID")" 2>/dev/null || true + rm -f "$CENTRAL_PID" + echo "stopped fake central" + else + echo "fake central not running" + fi + ;; + status) + if [[ -f "$CENTRAL_PID" ]] && kill -0 "$(cat "$CENTRAL_PID")" 2>/dev/null; then + echo "running (pid $(cat "$CENTRAL_PID")) on http://127.0.0.1:$CENTRAL_PORT" + curl -s "http://127.0.0.1:$CENTRAL_PORT/_sandbox/counts" && echo + else + echo "not running" + fi + ;; + log) + tail -n "${1:-30}" "$CENTRAL_LOG" + ;; + config) + echo "$FLEET_CONFIG" + ;; + *) + echo "usage: hyp-sandbox central start|stop|status|log [n]|config" >&2 + exit 64 + ;; + esac + ;; + + seed-config) + provision + SEED_PORT="${1:-18621}" + CONFIG="$SANDBOX_HYP_HOME/hypaware-config.json" + cat > "$CONFIG" < { + if (!node || typeof node !== "object") return + for (const [key, value] of Object.entries(node)) { + if (key === "listen" && typeof value === "string" && /:\d+$/.test(value)) { + node[key] = value.replace(/:\d+$/, `:${port}`) + changed += 1 + } else walk(value) + } + } + walk(config) + fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`) + console.log(`rewrote ${changed} listen value(s) to port ${port} in ${file}`) + if (changed === 0) console.log("no listen key found - the gateway will use its 18521 default and clash with your live daemon") + ' "$CONFIG" "$NEW_PORT" + ;; + + shell) + provision + echo "entering sandbox: HOME=$SANDBOX_HOME HYP_HOME=$SANDBOX_HYP_HOME" + echo "launchctl/security are mocks. 'exit' to leave." + # Deliberately a bare bash: your own shell rc (and macOS path_helper in a + # login shell) rebuilds PATH and would push the shims out of first place. + sandbox_env bash --noprofile --norc -i + ;; + + hyp) + provision + sandbox_env node "$REPO_ROOT/bin/hypaware.js" "$@" + ;; + + run) + provision + if [[ $# -eq 0 ]]; then echo "usage: hyp-sandbox run [args...]" >&2; exit 64; fi + sandbox_env "$@" + ;; + + *) + echo "unknown command: $CMD" >&2 + usage + exit 64 + ;; +esac diff --git a/scripts/sandbox/lib/fake_central.js b/scripts/sandbox/lib/fake_central.js new file mode 100644 index 00000000..b7282a14 --- /dev/null +++ b/scripts/sandbox/lib/fake_central.js @@ -0,0 +1,275 @@ +// @ts-check + +/** + * A stand-in central server for the HypAware sandbox. + * + * `hyp join` enrolls against a central server and the daemon then pulls its + * fleet config from it, so any test of join → leave → rejoin needs one. This + * speaks the four endpoints the `@hypaware/central` plugin calls: + * + * - `POST /v1/identity/bootstrap` token → `{ jwt, expires_at }` + * - `POST /v1/identity/refresh` bearer → a fresh `{ jwt, expires_at }` + * - `GET /v1/config` the fleet config, with an ETag (304 aware) + * - `POST /v1/ingest/` accepts and counts NDJSON rows + * + * The gateway never verifies the JWT signature (it does not share the issuer + * secret, see `identity_client.js#decodeJwtSub`), so an unsigned token with a + * `sub` claim is enough to be accepted the way a real one would be. + * + * The fleet config is read from disk on every request, so you can edit it + * mid-run (flip `proxy_mode`, change the port) and the daemon picks the new + * revision up on its next poll - the ETag is the config's own content hash. + * + * Usage: `node fake_central.js --port --config --log ` + */ + +import crypto from 'node:crypto' +import fs from 'node:fs' +import http from 'node:http' +import process from 'node:process' + +const args = parseArgs(process.argv.slice(2)) +const port = Number(args.port ?? 18700) +const configPath = args.config +const logPath = args.log + +if (!configPath) { + process.stderr.write('fake_central: --config is required\n') + process.exit(64) +} + +const counts = { + bootstrap: 0, + refresh: 0, + config200: 0, + config304: 0, + ingest: 0, + rows: 0, + login: 0, + sessionRefresh: 0, +} + +/** The org every sign-in resolves to. */ +const ORG = 'sandbox-org' + +/** Live authorization codes, minted at /login/start and spent at /token. */ +const codes = new Map() +let codeSeq = 0 + +const server = http.createServer((req, res) => { + const url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`) + readBody(req).then((body) => { + const handled = route(req, res, url, body) + log(`${req.method} ${url.pathname} -> ${res.statusCode} ${handled}`) + }) +}) + +server.listen(port, '127.0.0.1', () => { + log(`fake central listening on http://127.0.0.1:${port} serving ${configPath}`) + process.stdout.write(`fake central listening on http://127.0.0.1:${port}\n`) +}) + +process.on('SIGTERM', () => { + log(`shutting down; counts=${JSON.stringify(counts)}`) + server.close(() => process.exit(0)) +}) + +/** + * @param {http.IncomingMessage} req + * @param {http.ServerResponse} res + * @param {URL} url + * @param {string} body + * @returns {string} + */ +function route(req, res, url, body) { + if (req.method === 'POST' && url.pathname === '/v1/identity/bootstrap') { + /** @type {any} */ + let parsed = {} + try { parsed = JSON.parse(body || '{}') } catch { /* reported below */ } + const token = parsed.bootstrap_token + if (typeof token !== 'string' || token.length === 0) { + return json(res, 400, { error: 'bootstrap_token is required' }, 'missing token') + } + counts.bootstrap += 1 + return json(res, 200, mintIdentity(), `token=${token.slice(0, 8)}...`) + } + + if (req.method === 'POST' && url.pathname === '/v1/identity/refresh') { + if (!bearer(req)) return json(res, 401, { error: 'missing bearer' }, 'no bearer') + counts.refresh += 1 + return json(res, 200, mintIdentity(), 'refreshed') + } + + if (req.method === 'GET' && url.pathname === '/v1/config') { + if (!bearer(req)) return json(res, 401, { error: 'missing bearer' }, 'no bearer') + let document + try { + document = fs.readFileSync(configPath, 'utf8') + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return json(res, 500, { error: message }, 'config unreadable') + } + const etag = `"${crypto.createHash('sha256').update(document).digest('hex').slice(0, 32)}"` + if (req.headers['if-none-match'] === etag) { + counts.config304 += 1 + res.writeHead(304, { etag }) + res.end() + return `304 ${etag}` + } + counts.config200 += 1 + res.writeHead(200, { 'content-type': 'application/json', etag }) + res.end(document) + return `200 ${etag}` + } + + if (req.method === 'POST' && url.pathname.startsWith('/v1/ingest/')) { + if (!bearer(req)) return json(res, 401, { error: 'missing bearer' }, 'no bearer') + const rows = body.split('\n').filter(Boolean).length + counts.ingest += 1 + counts.rows += rows + res.writeHead(202, { 'content-type': 'application/json' }) + res.end('{}') + return `202 rows=${rows} total=${counts.rows}` + } + + // The attended `hyp remote login` flow (LLP 0058/0059). The browser is + // whatever fetches the start URL: it is answered with a 302 straight to the + // client's loopback receiver, so `curl -L ` completes a sign-in. + if (req.method === 'GET' && url.pathname === '/v1/identity/login/start') { + const redirectUri = url.searchParams.get('redirect_uri') + const state = url.searchParams.get('state') + if (!redirectUri || !state) { + return json(res, 400, { error: 'invalid_request' }, 'missing redirect_uri or state') + } + const code = `code_${codeSeq += 1}` + codes.set(code, { challenge: url.searchParams.get('code_challenge') ?? '' }) + const location = `${redirectUri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}` + res.writeHead(302, { location }) + res.end() + return `302 → ${redirectUri}` + } + + if (req.method === 'POST' && url.pathname === '/v1/identity/token') { + /** @type {any} */ + let parsed = {} + try { parsed = JSON.parse(body || '{}') } catch { /* reported below */ } + + if (parsed.grant_type === 'refresh_token') { + counts.sessionRefresh += 1 + const access = mintIdentity() + return json(res, 200, { + access_jwt: access.jwt, + expires_at: access.expires_at, + org: ORG, + }, 'session refreshed') + } + + if (parsed.grant_type === 'authorization_code') { + if (!codes.delete(parsed.code)) { + return json(res, 401, { error: 'invalid_grant', detail: 'unknown or spent code' }, 'bad code') + } + counts.login += 1 + const access = mintIdentity() + const gateway = mintIdentity() + // The gateway_* triple is what makes a login enroll the machine without a + // bootstrap token; a partial set is a contract violation the client + // rejects loudly, so send all three or none. + return json(res, 200, { + session_id: `sid_sandbox_${counts.login}`, + refresh_token: `refresh_sandbox_${counts.login}`, + access_jwt: access.jwt, + expires_at: access.expires_at, + org: ORG, + gateway_jwt: gateway.jwt, + gateway_expires_at: gateway.expires_at, + gateway_id: 'gw_sandbox_0001', + }, `login ok, org ${ORG}`) + } + + return json(res, 400, { error: 'unsupported_grant_type' }, `grant ${parsed.grant_type}`) + } + + if (req.method === 'GET' && url.pathname === '/_sandbox/counts') { + return json(res, 200, counts, 'counts') + } + + return json(res, 404, { error: `no route for ${req.method} ${url.pathname}` }, 'UNROUTED') +} + +/** + * An unsigned JWT carrying the `sub` the gateway reads for its identity. + * @returns {{ jwt: string, expires_at: number }} + */ +function mintIdentity() { + const expiresAt = Math.floor(Date.now() / 1000) + 86400 + const header = b64url(JSON.stringify({ alg: 'none', typ: 'JWT' })) + const payload = b64url(JSON.stringify({ + sub: 'gw_sandbox_0001', + org: 'sandbox', + exp: expiresAt, + })) + return { jwt: `${header}.${payload}.sandbox`, expires_at: expiresAt } +} + +/** + * @param {string} value + */ +function b64url(value) { + return Buffer.from(value, 'utf8').toString('base64url') +} + +/** + * @param {http.IncomingMessage} req + */ +function bearer(req) { + const header = req.headers.authorization + return typeof header === 'string' && header.toLowerCase().startsWith('bearer ') +} + +/** + * @param {http.ServerResponse} res + * @param {number} status + * @param {any} payload + * @param {string} note + */ +function json(res, status, payload, note) { + res.writeHead(status, { 'content-type': 'application/json' }) + res.end(JSON.stringify(payload)) + return note +} + +/** + * @param {http.IncomingMessage} req + * @returns {Promise} + */ +function readBody(req) { + return new Promise((resolve) => { + let body = '' + req.on('data', (chunk) => { body += chunk.toString('utf8') }) + req.on('end', () => resolve(body)) + }) +} + +/** + * @param {string} message + */ +function log(message) { + const line = `${new Date().toISOString()} ${message}\n` + if (logPath) fs.appendFileSync(logPath, line) +} + +/** + * @param {string[]} argv + * @returns {Record} + */ +function parseArgs(argv) { + /** @type {Record} */ + const out = {} + for (let i = 0; i < argv.length; i += 1) { + if (argv[i].startsWith('--')) { + out[argv[i].slice(2)] = argv[i + 1] + i += 1 + } + } + return out +} diff --git a/scripts/sandbox/lib/shim.js b/scripts/sandbox/lib/shim.js new file mode 100644 index 00000000..43af0624 --- /dev/null +++ b/scripts/sandbox/lib/shim.js @@ -0,0 +1,712 @@ +// @ts-check + +/** + * Mock `launchctl`, `security`, and `systemctl` for the HypAware sandbox. + * + * Every service-manager and keychain call in the kernel goes through one + * seam: `runServiceCommand(bin, args)` in `src/core/daemon/service_ops.js`, + * which spawns the bare binary name and so resolves it through `PATH`. The + * sandbox puts `$HYP_SANDBOX_ROOT/bin` first on `PATH`, and the wrappers + * there call into this file. + * + * The mocks keep their own state on disk so an install/uninstall round trip + * behaves like the real thing (bootstrap twice fails, bootout of an unknown + * label exits 3, an untrusted cert fails verify-cert) without ever touching + * the real launchd domain or the real login keychain. + * + * Usage: `node shim.js ` + * + * Env: + * - `HYP_SANDBOX_ROOT` sandbox root (required) + * - `HYP_SANDBOX_SPAWN=1` launchctl really starts the plist's program + * - `HYP_SANDBOX_TRUST_REFUSE=1` `security add-trusted-cert` acts like the + * user cancelling the macOS password dialog + * - `HYP_SANDBOX_TRUST_FROM_DAEMON=grant` + * let a daemon-issued `add-trusted-cert` succeed. + * The default refuses it: the login keychain is + * gated by a password dialog and the daemon is a + * background agent with nobody watching. This is + * an assumption the sandbox cannot verify, not a + * measured fact - see README.md. + * - `HYP_SANDBOX_SERVICE=1` set by the supervisor in the daemon it starts, + * so the shim can tell a daemon-issued call from + * one the user typed. Inherited by its children. + * - `HYP_SANDBOX_VERBOSE=1` echo each intercepted call to stderr + * + * @import { + * SandboxKeychainState, + * SandboxLaunchdState, + * SandboxService, + * SandboxSystemdState, + * ShimResult, + * } from '../../../scripts/sandbox/lib/types.js' + */ + +import { spawn, spawnSync } from 'node:child_process' +import crypto from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' +import process from 'node:process' + +const root = process.env.HYP_SANDBOX_ROOT +if (!root) { + process.stderr.write('hyp-sandbox shim: HYP_SANDBOX_ROOT is not set\n') + process.exit(64) +} + +const stateDir = path.join(root, 'state') +const callsPath = path.join(stateDir, 'calls.jsonl') +const launchdPath = path.join(stateDir, 'launchd.json') +const keychainPath = path.join(stateDir, 'keychain.json') +const systemdPath = path.join(stateDir, 'systemd.json') + +const tool = process.argv[2] +const args = process.argv.slice(3) + +// `__supervise` is the shim re-entering itself as a KeepAlive supervisor; it +// never returns, and it must not be recorded as an intercepted call. +if (tool === '__supervise') { + supervise(args[0], args[1]) +} else { + main() +} + +function main() { + try { + if (tool === 'launchctl') return finish(launchctl(args)) + if (tool === 'security') return finish(security(args)) + if (tool === 'systemctl') return finish(systemctl(args)) + return finish({ code: 64, err: `hyp-sandbox shim: unknown tool ${tool}\n` }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return finish({ code: 70, err: `hyp-sandbox shim: ${tool} failed: ${message}\n` }) + } +} + +/** + * @param {ShimResult} result + */ +function finish(result) { + record(result) + if (result.out) process.stdout.write(result.out) + if (result.err) process.stderr.write(result.err) + process.exit(result.code) +} + +/** + * @param {ShimResult} result + */ +function record(result) { + const line = JSON.stringify({ + ts: new Date().toISOString(), + tool, + args, + exit: result.code, + note: result.note, + cwd: process.cwd(), + ppid: process.ppid, + }) + fs.mkdirSync(stateDir, { recursive: true }) + fs.appendFileSync(callsPath, `${line}\n`) + if (process.env.HYP_SANDBOX_VERBOSE === '1') { + process.stderr.write(`[sandbox] ${tool} ${args.join(' ')} -> exit ${result.code}\n`) + } +} + +/** + * @param {string} file + * @param {any} fallback + */ +function readState(file, fallback) { + try { + return JSON.parse(fs.readFileSync(file, 'utf8')) + } catch { + return fallback + } +} + +/** + * @param {string} file + * @param {any} value + */ +function writeState(file, value) { + fs.mkdirSync(path.dirname(file), { recursive: true }) + fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`) +} + +// ---------------------------------------------------------------- launchctl + +/** + * @param {string[]} argv + * @returns {ShimResult} + */ +function launchctl(argv) { + const sub = argv[0] + /** @type {SandboxLaunchdState} */ + const state = readState(launchdPath, { services: {}, env: {} }) + + if (sub === 'setenv') { + const [, name, value] = argv + state.env[name] = value ?? '' + writeState(launchdPath, state) + return { code: 0, note: `setenv ${name}` } + } + + if (sub === 'unsetenv') { + const name = argv[1] + delete state.env[name] + writeState(launchdPath, state) + return { code: 0, note: `unsetenv ${name}` } + } + + if (sub === 'getenv') { + const name = argv[1] + const value = state.env[name] + // Real launchctl prints nothing and still exits 0 for an unset variable. + return { code: 0, out: value === undefined ? '' : `${value}\n` } + } + + if (sub === 'bootstrap') { + const plist = argv[argv.length - 1] + const label = labelFromPlistFile(plist) + if (!label) return { code: 64, err: `Bootstrap failed: 64: unreadable plist ${plist}\n` } + // launchd refuses to bootstrap a label already in the domain, running or + // not; `installLaunchAgent` boots out first, so this only fires on a real + // double-bootstrap. + if (state.services[label]) { + return { code: 5, err: 'Bootstrap failed: 5: Input/output error\n', note: `already loaded ${label}` } + } + /** @type {SandboxService} */ + const service = { label, plist, pid: null, loadedAt: new Date().toISOString() } + if (process.env.HYP_SANDBOX_SPAWN === '1') { + service.pid = startSupervisor(label, plist) + service.supervised = true + } + state.services[label] = service + writeState(launchdPath, state) + return { code: 0, note: `bootstrap ${label}${service.pid ? ` pid ${service.pid}` : ' (not spawned)'}` } + } + + if (sub === 'bootout') { + const label = labelFromTarget(argv[argv.length - 1]) + const service = label ? state.services[label] : undefined + if (!label || !service) return { code: 3, err: 'Boot-out failed: 3: No such process\n' } + killService(label, service) + delete state.services[label] + writeState(launchdPath, state) + return { code: 0, note: `bootout ${label}` } + } + + if (sub === 'kickstart') { + const label = labelFromTarget(argv[argv.length - 1]) + const service = label ? state.services[label] : undefined + if (!label || !service) { + return { code: 3, err: `Could not find service "${label}" in domain for\n` } + } + if (process.env.HYP_SANDBOX_SPAWN === '1') { + // `-k` kills the running instance; the supervisor restarts it, which is + // what launchd's KeepAlive does. + const child = childPid(label) + if (child) { + try { process.kill(child, 'SIGTERM') } catch { /* already gone */ } + } else if (!alivePid(service.pid)) { + service.pid = startSupervisor(label, service.plist) + writeState(launchdPath, state) + } + } + return { code: 0, note: `kickstart ${label}` } + } + + if (sub === 'print') { + const label = labelFromTarget(argv[argv.length - 1]) + const service = label ? state.services[label] : undefined + if (!label || !service) { + return { + code: 113, + err: `Could not find service "${label}" in domain for login\n`, + } + } + const child = childPid(label) + const running = Boolean(child) + const lines = [ + `${label} = {`, + '\tactive count = 1', + `\tpath = ${service.plist}`, + `\tstate = ${running ? 'running' : 'not running'}`, + ] + if (running) lines.push(`\tpid = ${child}`) + lines.push('\tdomain = sandbox', '}') + return { code: 0, out: `${lines.join('\n')}\n` } + } + + // Anything else: succeed loudly enough to be visible in `hyp-sandbox calls`. + return { code: 0, note: `unhandled launchctl subcommand ${sub}` } +} + +/** + * Read the `Label` out of a LaunchAgent plist. Falls back to the filename + * so a malformed body still round-trips through bootstrap/bootout. + * + * @param {string} plist + * @returns {string | null} + */ +function labelFromPlistFile(plist) { + let xml = '' + try { + xml = fs.readFileSync(plist, 'utf8') + } catch { + return null + } + const match = /Label<\/key>\s*([^<]+)<\/string>/.exec(xml) + if (match) return match[1].trim() + return path.basename(plist).replace(/\.plist$/, '') +} + +/** + * Turn a launchctl target into a bare label. Accepts `gui/501/com.foo.bar`, + * `user/501/com.foo.bar`, a bare label, or a plist path. + * + * @param {string} target + * @returns {string | null} + */ +function labelFromTarget(target) { + if (!target) return null + if (target.endsWith('.plist')) return labelFromPlistFile(target) + const parts = target.split('/') + return parts[parts.length - 1] || null +} + +/** + * @param {{ pid: number | null }} service + */ +function aliveService(service) { + return Boolean(service) && alivePid(service.pid) +} + +/** + * @param {number | null | undefined} pid + */ +function alivePid(pid) { + if (!pid) return false + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +/** + * Stop a service: kill its supervisor, which kills the daemon it is watching. + * + * @param {string} label + * @param {{ pid: number | null }} service + */ +function killService(label, service) { + const child = childPid(label) + if (aliveService(service)) { + try { process.kill(/** @type {number} */ (service.pid), 'SIGTERM') } catch { /* gone */ } + } + // Belt and braces: if the supervisor died without cleaning up, the daemon + // it started would otherwise keep the port and the pid file. + if (child) { + try { process.kill(child, 'SIGTERM') } catch { /* gone */ } + } + try { fs.rmSync(servicePidPath(label)) } catch { /* nothing to clear */ } +} + +/** + * Path of the file the supervisor keeps its current child's pid in. + * + * @param {string} label + */ +function servicePidPath(label) { + return path.join(stateDir, `service-${label}.json`) +} + +/** + * The pid of the daemon a supervisor is currently running, or null. + * + * @param {string} label + * @returns {number | null} + */ +function childPid(label) { + const entry = readState(servicePidPath(label), null) + if (!entry || !alivePid(entry.pid)) return null + return entry.pid +} + +/** + * Start a detached supervisor for the plist. Only used when + * `HYP_SANDBOX_SPAWN=1`; the default mock records the bootstrap and starts + * nothing. + * + * A plain one-shot spawn is not enough: HypAware applies a pulled config by + * exiting and letting launchd's `KeepAlive` bring it back, so a mock without a + * supervisor leaves the machine daemon-less exactly when a fleet config lands. + * + * @param {string} label + * @param {string} plist + * @returns {number | null} + */ +function startSupervisor(label, plist) { + const child = spawn(process.execPath, [import.meta.filename, '__supervise', label, plist], { + detached: true, + stdio: 'ignore', + env: process.env, + }) + child.unref() + return child.pid ?? null +} + +/** + * Run the plist's program, restarting it when it exits, the way launchd's + * `KeepAlive` does. Exits when booted out (SIGTERM) or when the program has + * crash-looped past the ceiling below. + * + * Restarts are throttled at 1s rather than launchd's 10s `ThrottleInterval`, + * so a restart-driven test does not spend most of its wall clock waiting. + * + * @param {string} label + * @param {string} plist + */ +function supervise(label, plist) { + const xml = fs.readFileSync(plist, 'utf8') + const argv = parsePlistArray(xml, 'ProgramArguments') + if (argv.length === 0) process.exit(0) + const keepAlive = /KeepAlive<\/key>\s*/.test(xml) + // `HYP_SANDBOX_SERVICE` marks everything launchd starts, and is inherited by + // whatever the daemon spawns, so the shim can tell "the background agent did + // this" from "the user typed this" without walking the process tree. + const env = { + ...process.env, + ...parsePlistDict(xml, 'EnvironmentVariables'), + HYP_SANDBOX_SERVICE: '1', + } + const outPath = parsePlistString(xml, 'StandardOutPath') + const errPath = parsePlistString(xml, 'StandardErrorPath') + const pidFile = path.join(stateDir, `service-${label}.json`) + + const RESTART_CEILING = 20 + const RESTART_WINDOW_MS = 60_000 + const THROTTLE_MS = 1000 + /** @type {number[]} */ + const recentStarts = [] + /** @type {import('node:child_process').ChildProcess | null} */ + let current = null + let stopping = false + + const stop = () => { + stopping = true + if (current && current.pid) { + try { process.kill(current.pid, 'SIGTERM') } catch { /* gone */ } + } + try { fs.rmSync(pidFile) } catch { /* nothing to clear */ } + process.exit(0) + } + process.on('SIGTERM', stop) + process.on('SIGINT', stop) + + const runOnce = () => { + if (stopping) return + const now = Date.now() + recentStarts.push(now) + while (recentStarts.length > 0 && now - recentStarts[0] > RESTART_WINDOW_MS) recentStarts.shift() + if (recentStarts.length > RESTART_CEILING) { + fs.appendFileSync(callsPath, `${JSON.stringify({ + ts: new Date().toISOString(), + tool: 'launchctl', + args: ['(supervisor)', label], + exit: -1, + note: `crash loop: ${recentStarts.length} starts in ${RESTART_WINDOW_MS / 1000}s, giving up`, + })}\n`) + stop() + return + } + const out = outPath ? openAppend(outPath) : 'ignore' + const err = errPath ? openAppend(errPath) : 'ignore' + current = spawn(argv[0], argv.slice(1), { stdio: ['ignore', out, err], env }) + writeState(pidFile, { + label, + pid: current.pid ?? null, + startedAt: new Date().toISOString(), + starts: recentStarts.length, + }) + current.on('exit', () => { + current = null + if (stopping) return + if (!keepAlive) { + try { fs.rmSync(pidFile) } catch { /* nothing to clear */ } + process.exit(0) + } + setTimeout(runOnce, THROTTLE_MS) + }) + } + + runOnce() +} + +/** + * @param {string} file + */ +function openAppend(file) { + fs.mkdirSync(path.dirname(file), { recursive: true }) + return fs.openSync(file, 'a') +} + +/** + * @param {string} xml + * @param {string} key + * @returns {string[]} + */ +function parsePlistArray(xml, key) { + const block = sliceAfterKey(xml, key) + if (!block) return [] + const arrayMatch = /([\s\S]*?)<\/array>/.exec(block) + if (!arrayMatch) return [] + return [...arrayMatch[1].matchAll(/([\s\S]*?)<\/string>/g)].map((m) => unescapeXml(m[1])) +} + +/** + * @param {string} xml + * @param {string} key + * @returns {Record} + */ +function parsePlistDict(xml, key) { + const block = sliceAfterKey(xml, key) + if (!block) return {} + const dictMatch = /([\s\S]*?)<\/dict>/.exec(block) + if (!dictMatch) return {} + /** @type {Record} */ + const out = {} + const pairs = [...dictMatch[1].matchAll(/([\s\S]*?)<\/key>\s*([\s\S]*?)<\/string>/g)] + for (const pair of pairs) out[unescapeXml(pair[1])] = unescapeXml(pair[2]) + return out +} + +/** + * @param {string} xml + * @param {string} key + * @returns {string | null} + */ +function parsePlistString(xml, key) { + const block = sliceAfterKey(xml, key) + if (!block) return null + const match = /^\s*([\s\S]*?)<\/string>/.exec(block) + return match ? unescapeXml(match[1]) : null +} + +/** + * @param {string} xml + * @param {string} key + * @returns {string | null} + */ +function sliceAfterKey(xml, key) { + const idx = xml.indexOf(`${key}`) + if (idx === -1) return null + return xml.slice(idx + key.length + 11) +} + +/** + * @param {string} value + */ +function unescapeXml(value) { + return value + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&') +} + +// ------------------------------------------------------------------ security + +/** + * @param {string[]} argv + * @returns {ShimResult} + */ +function security(argv) { + const sub = argv[0] + /** @type {SandboxKeychainState} */ + const state = readState(keychainPath, { certs: [] }) + + if (sub === 'verify-cert') { + const certPath = flagValue(argv, '-c') + if (!certPath) return { code: 1, err: 'Error: no certificate given\n' } + const digest = fileDigest(certPath) + const hit = state.certs.find((c) => c.sha256 === digest && c.trusted) + if (hit) return { code: 0, out: '...certificate verification successful.\n' } + return { + code: 1, + err: 'Error: certificate verification failed: CSSMERR_TP_NOT_TRUSTED\n', + note: 'cert not trusted in sandbox keychain', + } + } + + if (sub === 'add-trusted-cert') { + if (process.env.HYP_SANDBOX_TRUST_REFUSE === '1') { + return { + code: 1, + err: 'SecTrustSettingsSetTrustSettings: User canceled the operation.\n', + note: 'simulated dialog cancel', + } + } + // Trusting a CA in the login keychain is gated by the macOS password + // dialog, so it needs a human at the session. The attach action runs the + // same code in the CLI and in the daemon's reconciler, and the daemon is a + // background LaunchAgent with nobody watching - so a mock that always + // succeeds makes an unattended fleet setup look like it establishes trust, + // which is precisely the wrong answer to take away from a test run. + // + // Whether real macOS lets a LaunchAgent raise that dialog is NOT settled + // (only a second user account or a VM can settle it). The sandbox takes + // the pessimistic reading by default and says so; flip it with + // `--trust-from-daemon grant` to test the other branch. + if (process.env.HYP_SANDBOX_SERVICE === '1' && process.env.HYP_SANDBOX_TRUST_FROM_DAEMON !== 'grant') { + return { + code: 1, + err: 'SecTrustSettingsSetTrustSettings: User interaction is not allowed.\n', + note: 'daemon-issued trust refused (sandbox ASSUMPTION: a background LaunchAgent cannot raise the password dialog; --trust-from-daemon grant to assume it can)', + } + } + const certPath = argv[argv.length - 1] + const digest = fileDigest(certPath) + if (!digest) return { code: 1, err: `SecCertificateCreateFromData: unreadable ${certPath}\n` } + const commonName = certCommonName(certPath) + const keychain = flagValue(argv, '-k') ?? '' + const without = state.certs.filter((c) => c.sha256 !== digest) + without.push({ + cn: commonName, + path: certPath, + sha256: digest, + keychain, + trusted: true, + addedAt: new Date().toISOString(), + }) + state.certs = without + writeState(keychainPath, state) + return { code: 0, note: `trusted ${commonName ?? certPath}` } + } + + if (sub === 'delete-certificate') { + const commonName = flagValue(argv, '-c') + const remaining = state.certs.filter((c) => c.cn !== commonName) + if (remaining.length === state.certs.length) { + return { + code: 1, + err: 'SecKeychainSearchCopyNext: The specified item could not be found in the keychain.\n', + } + } + state.certs = remaining + writeState(keychainPath, state) + return { code: 0, note: `deleted ${commonName}` } + } + + if (sub === 'find-certificate') { + const commonName = flagValue(argv, '-c') + const hit = state.certs.find((c) => c.cn === commonName) + if (!hit) { + return { + code: 44, + err: 'security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.\n', + } + } + return { code: 0, out: `keychain: "${hit.keychain}"\n"labl"="${hit.cn}"\n` } + } + + return { + code: 0, + err: `hyp-sandbox: unhandled 'security ${sub}', pretending it succeeded\n`, + note: `unhandled security subcommand ${sub}`, + } +} + +/** + * @param {string[]} argv + * @param {string} flag + * @returns {string | undefined} + */ +function flagValue(argv, flag) { + const idx = argv.indexOf(flag) + return idx === -1 ? undefined : argv[idx + 1] +} + +/** + * @param {string} file + * @returns {string | null} + */ +function fileDigest(file) { + try { + return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex') + } catch { + return null + } +} + +/** + * Best-effort CN extraction. Real `openssl` is on every macOS box; when it is + * missing the sandbox falls back to the file path as the identity, which only + * costs a `delete-certificate -c ` match. + * + * @param {string} certPath + * @returns {string | null} + */ +function certCommonName(certPath) { + const res = spawnSync('openssl', ['x509', '-noout', '-subject', '-in', certPath], { + encoding: 'utf8', + }) + if (res.status !== 0 || !res.stdout) return null + const match = /CN\s*=\s*([^,/\n]+)/.exec(res.stdout) + return match ? match[1].trim() : null +} + +// ----------------------------------------------------------------- systemctl + +/** + * @param {string[]} argv + * @returns {ShimResult} + */ +function systemctl(argv) { + const rest = argv.filter((a) => a !== '--user') + const sub = rest[0] + const unit = rest[1] + /** @type {SandboxSystemdState} */ + const state = readState(systemdPath, { units: {} }) + + if (sub === 'daemon-reload' || sub === 'reset-failed') return { code: 0 } + + if (sub === 'enable' || sub === 'start' || sub === 'restart') { + state.units[unit] = { enabled: true, active: true, changedAt: new Date().toISOString() } + writeState(systemdPath, state) + return { code: 0, note: `${sub} ${unit}` } + } + + if (sub === 'stop' || sub === 'disable') { + if (state.units[unit]) { + state.units[unit].active = false + if (sub === 'disable') state.units[unit].enabled = false + writeState(systemdPath, state) + } + return { code: 0, note: `${sub} ${unit}` } + } + + if (sub === 'is-active') { + const active = Boolean(state.units[unit] && state.units[unit].active) + return { code: active ? 0 : 3, out: `${active ? 'active' : 'inactive'}\n` } + } + + if (sub === 'is-enabled') { + const enabled = Boolean(state.units[unit] && state.units[unit].enabled) + return { code: enabled ? 0 : 1, out: `${enabled ? 'enabled' : 'disabled'}\n` } + } + + if (sub === 'show') { + const entry = state.units[unit] + return { + code: 0, + out: `MainPID=0\nActiveState=${entry && entry.active ? 'active' : 'inactive'}\n`, + } + } + + return { code: 0, note: `unhandled systemctl subcommand ${sub}` } +} diff --git a/scripts/sandbox/lib/types.d.ts b/scripts/sandbox/lib/types.d.ts new file mode 100644 index 00000000..dea4f146 --- /dev/null +++ b/scripts/sandbox/lib/types.d.ts @@ -0,0 +1,61 @@ +/** + * Types for the sandbox's mock `launchctl` / `security` / `systemctl` + * (`shim.js`). Dev tooling only: none of this ships in the package. + */ + +/** What one intercepted call returns to the caller. */ +export interface ShimResult { + /** Process exit code. */ + code: number + /** Text to write to stdout. */ + out?: string + /** Text to write to stderr. */ + err?: string + /** Short human note, recorded in `calls.jsonl` and shown by `hyp-sandbox calls`. */ + note?: string +} + +/** One bootstrapped LaunchAgent in the mock launchd domain. */ +export interface SandboxService { + label: string + plist: string + /** The supervisor's pid, or null when the mock recorded the bootstrap without spawning. */ + pid: number | null + loadedAt: string + /** True when a KeepAlive supervisor is watching the program. */ + supervised?: boolean +} + +/** The mock launchd domain: bootstrapped services plus the user environment. */ +export interface SandboxLaunchdState { + services: Record + env: Record +} + +/** One certificate trusted in the mock login keychain. */ +export interface SandboxCert { + /** Common name, read with `openssl` when it is available. */ + cn: string | null + path: string + sha256: string + keychain: string + trusted: boolean + addedAt: string +} + +/** The mock login keychain. */ +export interface SandboxKeychainState { + certs: SandboxCert[] +} + +/** One systemd user unit in the mock. */ +export interface SandboxUnit { + enabled: boolean + active: boolean + changedAt: string +} + +/** The mock systemd user manager. */ +export interface SandboxSystemdState { + units: Record +} diff --git a/test/core/sandbox-shim.test.js b/test/core/sandbox-shim.test.js new file mode 100644 index 00000000..bed44adc --- /dev/null +++ b/test/core/sandbox-shim.test.js @@ -0,0 +1,236 @@ +// @ts-check + +import test from 'node:test' +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +const SHIM = fileURLToPath(new URL('../../scripts/sandbox/lib/shim.js', import.meta.url)) + +/** + * The sandbox's mock `launchctl` / `security` (`scripts/sandbox/lib/shim.js`) + * is what keeps a sandboxed `hyp daemon install` or `hyp attach` off the real + * launchd domain and out of the real login keychain, so the contract it + * presents to `runServiceCommand` is worth pinning: the exit codes the kernel + * branches on (bootout's 3, print's 113, bootstrap's transient 5) and the + * trust round trip attach reads its mode from. + * + * Every case runs the shim as a child process, the way the PATH wrappers do. + */ + +/** + * Run the shim once against `root`. + * + * @param {string} root + * @param {string} tool + * @param {string[]} args + * @param {Record} [env] + */ +function shim(root, tool, args, env = {}) { + const result = spawnSync(process.execPath, [SHIM, tool, ...args], { + encoding: 'utf8', + env: { ...process.env, HYP_SANDBOX_ROOT: root, ...env }, + }) + return { code: result.status, stdout: result.stdout, stderr: result.stderr } +} + +/** + * A sandbox root with a LaunchAgent plist in it, removed when the test ends. + * + * @param {import('node:test').TestContext} t + * @param {string} [label] + */ +function sandboxRoot(t, label = 'com.hyperparam.hypaware.test') { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'hyp-shim-test-')) + t.after(() => fs.rmSync(root, { recursive: true, force: true })) + const plist = path.join(root, `${label}.plist`) + fs.writeFileSync(plist, [ + '', + '', + '', + ' Label', + ` ${label}`, + ' ProgramArguments', + ' ', + ' /usr/bin/true', + ' ', + '', + '', + '', + ].join('\n')) + return { root, plist, label, target: `gui/501/${label}` } +} + +test('launchctl mock: bootstrap → print → bootout round trip', (t) => { + const { root, plist, label, target } = sandboxRoot(t) + + assert.equal(shim(root, 'launchctl', ['print', target]).code, 113, 'unknown service prints 113') + + assert.equal(shim(root, 'launchctl', ['bootstrap', 'gui/501', plist]).code, 0) + + const printed = shim(root, 'launchctl', ['print', target]) + assert.equal(printed.code, 0) + assert.match(printed.stdout, new RegExp(`^${label} = \\{`), 'print reports the label launchd would') + + // Not spawned (HYP_SANDBOX_SPAWN unset), so there is no pid to report. + assert.doesNotMatch(printed.stdout, /\bpid = \d+/) + + assert.equal( + shim(root, 'launchctl', ['bootstrap', 'gui/501', plist]).code, + 5, + 'a second bootstrap of a loaded label is launchd error 5' + ) + + assert.equal(shim(root, 'launchctl', ['bootout', target]).code, 0) + assert.equal(shim(root, 'launchctl', ['bootout', target]).code, 3, 'bootout of an absent service is 3') + assert.equal(shim(root, 'launchctl', ['print', target]).code, 113) +}) + +test('launchctl mock: the label comes from the plist body, not the filename', (t) => { + const { root } = sandboxRoot(t) + const plist = path.join(root, 'misnamed.plist') + fs.writeFileSync(plist, [ + '', + 'Labelcom.example.real', + '', + '', + ].join('\n')) + + assert.equal(shim(root, 'launchctl', ['bootstrap', 'gui/501', plist]).code, 0) + assert.equal(shim(root, 'launchctl', ['print', 'gui/501/com.example.real']).code, 0) +}) + +test('launchctl mock: setenv / getenv / unsetenv', (t) => { + const { root } = sandboxRoot(t) + + const unset = shim(root, 'launchctl', ['getenv', 'NODE_USE_SYSTEM_CA']) + assert.equal(unset.code, 0, 'real launchctl exits 0 for an unset variable') + assert.equal(unset.stdout, '') + + assert.equal(shim(root, 'launchctl', ['setenv', 'NODE_USE_SYSTEM_CA', '1']).code, 0) + assert.equal(shim(root, 'launchctl', ['getenv', 'NODE_USE_SYSTEM_CA']).stdout, '1\n') + + assert.equal(shim(root, 'launchctl', ['unsetenv', 'NODE_USE_SYSTEM_CA']).code, 0) + assert.equal(shim(root, 'launchctl', ['getenv', 'NODE_USE_SYSTEM_CA']).stdout, '') +}) + +test('security mock: verify → trust → verify → delete round trip', (t) => { + const { root } = sandboxRoot(t) + const certPath = path.join(root, 'ca-cert.pem') + const openssl = spawnSync('openssl', [ + 'req', '-x509', '-newkey', 'rsa:2048', '-keyout', path.join(root, 'ca-key.pem'), + '-out', certPath, '-days', '1', '-nodes', '-subj', '/CN=HypAware Local CA', + ], { encoding: 'utf8' }) + if (openssl.status !== 0) { + t.skip('openssl is unavailable, so no certificate to trust') + return + } + const keychain = path.join(root, 'login.keychain-db') + + assert.equal( + shim(root, 'security', ['verify-cert', '-c', certPath, '-p', 'ssl']).code, + 1, + 'an untrusted CA fails verify-cert, which is what makes attach pick base-URL mode' + ) + + assert.equal( + shim(root, 'security', ['add-trusted-cert', '-r', 'trustRoot', '-k', keychain, certPath]).code, + 0 + ) + assert.equal(shim(root, 'security', ['verify-cert', '-c', certPath, '-p', 'ssl']).code, 0) + + assert.equal( + shim(root, 'security', ['delete-certificate', '-c', 'HypAware Local CA', '-t', keychain]).code, + 0 + ) + assert.equal(shim(root, 'security', ['verify-cert', '-c', certPath, '-p', 'ssl']).code, 1) + + const missing = shim(root, 'security', ['delete-certificate', '-c', 'HypAware Local CA', '-t', keychain]) + assert.equal(missing.code, 1) + assert.match(missing.stderr, /could not be found/, 'removeCaTrust reads this as already-absent, not an error') +}) + +test('security mock: HYP_SANDBOX_TRUST_REFUSE simulates a cancelled password dialog', (t) => { + const { root } = sandboxRoot(t) + const certPath = path.join(root, 'ca-cert.pem') + fs.writeFileSync(certPath, 'not a real certificate, only its bytes matter here\n') + + const refused = shim( + root, + 'security', + ['add-trusted-cert', '-r', 'trustRoot', '-k', path.join(root, 'login.keychain-db'), certPath], + { HYP_SANDBOX_TRUST_REFUSE: '1' } + ) + assert.equal(refused.code, 1) + assert.match(refused.stderr, /User canceled the operation/) + assert.equal( + shim(root, 'security', ['verify-cert', '-c', certPath, '-p', 'ssl']).code, + 1, + 'a refused trust leaves nothing behind' + ) +}) + +test('security mock: a daemon-issued trust is refused by default, a user-issued one is not', (t) => { + const { root } = sandboxRoot(t) + const certPath = path.join(root, 'ca-cert.pem') + fs.writeFileSync(certPath, 'stand-in certificate bytes\n') + const trustArgs = ['add-trusted-cert', '-r', 'trustRoot', '-k', path.join(root, 'kc.db'), certPath] + + // HYP_SANDBOX_SERVICE marks the subtree the mock launchd started. Trusting a + // CA in the login keychain needs the macOS password dialog answered, and a + // background agent has nobody watching - so the sandbox refuses it rather + // than letting an unattended fleet setup look like it establishes trust. + const fromDaemon = shim(root, 'security', trustArgs, { HYP_SANDBOX_SERVICE: '1' }) + assert.equal(fromDaemon.code, 1) + assert.match(fromDaemon.stderr, /User interaction is not allowed/) + assert.equal(shim(root, 'security', ['verify-cert', '-c', certPath, '-p', 'ssl']).code, 1) + + // Whether real macOS actually refuses is unproven, so the other branch is + // one flag away. + const granted = shim(root, 'security', trustArgs, { + HYP_SANDBOX_SERVICE: '1', + HYP_SANDBOX_TRUST_FROM_DAEMON: 'grant', + }) + assert.equal(granted.code, 0) + assert.equal(shim(root, 'security', ['verify-cert', '-c', certPath, '-p', 'ssl']).code, 0) +}) + +test('security mock: a user-issued trust succeeds with no service marker', (t) => { + const { root } = sandboxRoot(t) + const certPath = path.join(root, 'ca-cert.pem') + fs.writeFileSync(certPath, 'stand-in certificate bytes\n') + + const result = shim(root, 'security', [ + 'add-trusted-cert', '-r', 'trustRoot', '-k', path.join(root, 'kc.db'), certPath, + ]) + assert.equal(result.code, 0, 'a CLI attach has a human at the dialog') + assert.equal(shim(root, 'security', ['verify-cert', '-c', certPath, '-p', 'ssl']).code, 0) +}) + +test('shim records every intercepted call', (t) => { + const { root, plist, target } = sandboxRoot(t) + shim(root, 'launchctl', ['bootstrap', 'gui/501', plist]) + shim(root, 'launchctl', ['bootout', target]) + + const lines = fs.readFileSync(path.join(root, 'state', 'calls.jsonl'), 'utf8') + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) + + assert.equal(lines.length, 2) + assert.deepEqual(lines.map((entry) => entry.args[0]), ['bootstrap', 'bootout']) + assert.deepEqual(lines.map((entry) => entry.exit), [0, 0]) +}) + +test('shim refuses to run without a sandbox root', () => { + const result = spawnSync(process.execPath, [SHIM, 'launchctl', 'getenv', 'PATH'], { + encoding: 'utf8', + env: { ...process.env, HYP_SANDBOX_ROOT: '' }, + }) + assert.equal(result.status, 64) + assert.match(result.stderr, /HYP_SANDBOX_ROOT is not set/) +})