Files
zernie 9413061d9b feat!: split the public exports by COST — vigiles/test (free) vs vigiles/eval (spends money) (#149)
* refactor(exports)!: split the testing surface on COST, and name the cost

The exports map split the testing API by TEST TIER — `vigiles/testing`,
`vigiles/unit`, `vigiles/integration`, `vigiles/e2e`. That taxonomy already had
a home: the vitest projects, keyed on test-file NAMES. The exports map was a
second, competing implementation of it, and zero of 19 comparable packages
publish `./unit` / `./integration` / `./e2e` subpaths.

It splits on COST now, on two axes at once — the PATH and the NAME.

  "."             -> ./dist/test.js          (was ./dist/core/spec.js)
  "./spec"        -> ./dist/core/spec.js     unchanged
  "./eval"        -> ./dist/eval-surface.js  NEW
  ./linting ./hook ./claude-code ./codex ./adapter ./experimental
  ./vitest ./jest                            unchanged

DELETED: ./testing, ./unit, ./integration, ./e2e.  14 subpaths -> 11.

No `./test` subpath: the bare package name IS the testing surface. Two names for
one door is the duplication being removed (`.` + `./spec` used to be exactly
that). The cost: CLI-verb symmetry is now one-sided — `vigiles/eval` rhymes with
`vigiles eval`, the free half answers to the plain package name. Accepted;
`@playwright/test` publishes no subpath naming a test type at all.

THE NAME AXIS. Every runtime export on `./eval` is prefixed `paid_`:
paid_runEval, paid_measure, paid_measureArms, paid_measureTriggerRate,
paid_judge, paid_judged, paid_claudeEvalDriver. The path warns ONCE at the top
of a file; the name warns EVERY time, at the call site. Reading
`await judged(trace, "did it refuse?")` on line 140, the import is long out of
view. Not a new idiom here — `vigiles/experimental` already pairs a quarantined
path with an `experimental_` prefix; this applies the device to a second axis.

TYPES ARE NOT PREFIXED, deliberately: the prefix warns about CALLING something,
and a type is never called. `paid_EvalReport` would be noise — and since the 29
report types are re-exported from BOTH barrels, prefixing them would put `paid_`
names on the FREE surface.

⚠️ THE PREFIX OVERSTATES SLIGHTLY, AND THIS IS RECORDED, NOT HIDDEN.
`paid_judged(rubric, { judge: myFn })` calls your function and spends nothing;
the `measure*` family takes an injectable `evalDriver`. Only the DEFAULT path
bills (`paid_judge` is the one where `paid_` is exact — no injection seam).
`metered_` would be precise but reads a beat slower, and a warning not absorbed
at a glance is not a warning. Clarity won; the imprecision is written into the
module docstring, each symbol's JSDoc, and docs/testing-api.md.

ALSO FIXED: a PRE-EXISTING violation of the older convention. `makeDockerRuntime`
sat unprefixed among six `experimental_` siblings on `./experimental`. One
missing prefix is worse than none: the six that comply teach the reader that an
unprefixed name is safe. Renamed at source to `experimental_makeDockerRuntime`.

BOTH CONVENTIONS NOW HAVE A GATE, because that violation is exactly what prose
buys you. `scripts/check-export-prefixes.mjs` reads the built `.d.ts` with the
TypeScript compiler (so it can tell a value from a type instead of grepping) and
FAILS with exit 1 — it is a gate, not a nudge. Wired as `npm run exports:check`
and a CI step beside `docs:check`.

ITS TEST HAS BOTH HALVES, and mutation says so. `scripts/check-export-prefixes.test.ts`
(12 cases, colocated) fires on planted violations AND is silent on the legitimate
surface next door. Four mutations, each failing on its OWN assertion:

  drop alias resolution        -> 2 fail  (the vacuous-pass: `./eval` is built
                                  entirely from `export { x as paid_x }`, and an
                                  alias reports Alias, not Value — a check that
                                  skipped resolution would read every export as a
                                  type and pass on a fully unprefixed barrel)
  exit(1) -> exit(0)           -> 1 fail  (gate degrades to nudge)
  delete the ./experimental
    entry from SURFACES        -> 1 fail  (see below)
  never push a violation       -> 4 fail

🔴 THE THIRD MUTATION PASSED GREEN AT FIRST — a hole in the TEST, not the check.
An entire axis could stop being policed with nothing turning red: the axis test
passes its own surface list, the "real barrels" test is satisfied by a SHORTER
list, and the CLI test still exits 1 on its `./eval` defect. Fixed by asserting
SURFACES covers exactly the quarantined subpaths. The CLI test was vacuous the
same way (its fixture lacked `./experimental`, so it exited 1 either way) and now
runs a clean twin that must exit 0.

NOTHING WAS LOST, AND IT IS MEASURED. Union of the four old barrels: 190 symbols.
New: 183 on the root + 36 on ./eval, overlapping in 29 report types re-exported
from both on purpose. 183 + 36 - 29 = 190; set difference empty in BOTH
directions (comparing `paid_x` to `x` by identity). `vigiles/testing` was NOT a
superset of `vigiles/unit`: 11 symbols (all of guardrail-check.ts, plus
decideHook / parseHookOutput) existed only on `unit` and are on the root now.

TWO HONEST COSTS, in the docstrings rather than hidden:
1. The type coupling crosses the boundary and cannot be removed. ~27 helpers
   (assertImproves, compareArms, diffReports, cost/latency/tokens,
   formatEvalReport, …) read an eval RESULT and spend nothing, so they are free
   and unprefixed — but their argument types are defined on the paid side. Hence
   the 29 duplicated types, so nobody imports `./eval` for a type alone.
2. Free is not fast. The old "real CLI, no API key" tier collapses into the root,
   so the import no longer signals that `runHarnessTest` spawns a real claude
   under bubblewrap for ~40s. Duration lives in the runner config now.

`.` moving off `core/spec` means every doc example importing a spec builder from
bare "vigiles" now says "vigiles/spec" (11 blocks). `docs:check` is what caught
those.

Entry points are enumerated in four places independently of package.json; all
updated: scripts/api-extractor.mjs (1:1 with exports now — `vigiles.api.md` is
the test surface, `vigiles-spec.api.md` is new), typedoc.json, eslint.config.mjs
(AGNOSTIC_SURFACE + the no-barrel-imports list; both rules verified by mutation
to still fire on the new filenames), vitest.config.mjs comment.

Gates, by name and in order: npx vitest run (2939 passed, 25 skipped, 174 files)
· npm run lint (0 errors, 206 warnings — unchanged baseline) · npx prettier
--check . · npm run api:check (11 entries, no drift) · npm run build · plus
npm run docs:check (91 blocks, 0 findings) and npm run exports:check. Also
`node dist/cli.js lint` exits 0 with every compiled-markdown hash valid.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uvk2Zx66BAuxuLGLFL2umd

* ci: закрыть класс «сгенерированный файл протух, а CI зелёный»

Шаг «Check generated types compile» перегенерирует файл и компилирует результат.
Свежесгенерированный компилируется всегда — поэтому закоммиченная копия в проверке
НЕ УЧАСТВУЕТ. Замер 16.08: отслеживаемый `.vigiles/generated.d.ts` отставал на
314 строк / ~147 записей, и CI всё это время была зелёной.

Название шага честное — он проверяет, что типы КОМПИЛИРУЮТСЯ, а не что они
актуальны. Именно поэтому дыра пережила ревью: шаг делает ровно то, что обещает.

Предсказание записано за восемь дней до: в .gitignore репозитория zernie/mine
08.08 этим обосновано, почему тот репозиторий свою копию не отслеживает — «drift
in a generated file is invisible unless something regenerates it with --check.
Upstream vigiles tracks its own copy and it IS stale on main for exactly that
reason». Через восемь дней стало хуже. Запись предсказания не помогла; помогает
строка в CI.

Проверено обеими половинами: на актуальном файле проходит, на подложенном
дрейфе (одна строка в конец) падает.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uvk2Zx66BAuxuLGLFL2umd

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-16 22:46:26 +05:00

13 KiB
Raw Permalink Blame History

Sandboxing — what's isolated, what's recorded (honestly)

Testing a hook or a third-party plugin means executing its code with your privileges. vigiles confines that under bubblewrap (bwrap). Being precise about what confinement does and doesn't protect is important — so you don't trust a boundary that isn't there.

Tiers (graceful degradation, no Docker)

The sandbox protects the host from untrusted code. The right question is: "is the host already disposable?" The answer differs by environment.

Tier Mechanism When
A — bubblewrap a fresh user/net/pid/mount namespace: no egress, read-only host, cleared env a dev machine where unprivileged user namespaces work
B — outer container sandbox: false, trusting an already-ephemeral host CI / cloud agents (the runner is thrown away — the inner sandbox is redundant)
D — refuse run nothing untrusted code, no confinement available, host not disposable

bwrap is the only tier that actually confines. Docker/podman-rootless rely on the same unprivileged-user-namespace primitive, so they are not a way around its absence. The honest non-Docker alternative for hosts where user namespaces are hard-disabled is Landlock (unprivileged FS, and network from kernel 6.7) — a future tier, not shipped.

macOS is coming via a native backend, not a VM. bwrap is Linux-only by design (it is Linux namespaces), so Mac confinement comes from a second backend — sandbox-exec (Seatbelt), built into every Mac — behind a vigiles/os-isolation port, with the safe-by-default rule unchanged.

sandboxAvailable() probes real capability, not just the binary. bwrap --version succeeding doesn't mean this host can create namespaces (many CI runners disable unprivileged user namespaces). We probe an actual bwrap --unshare-all … true, so we never claim confinement we can't deliver. On Ubuntu 24.04 runners, unblock it with sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 (see .github/workflows/ci.yml).

Why confinement is opt-in, not always-on

Untrusted code is confined; code you authored runs direct. Forcing the sandbox on everything sounds safer — but it breaks more than it protects. Four concrete reasons, each load-bearing:

  1. It isn't always available. Confinement requires Linux plus working unprivileged user namespaces. sandboxAvailable() probes the real capability — many CI runners ship bwrap but disable userns. Force it everywhere and a hook you wrote turns red on every Mac and every hardened runner. decideSandbox returns throw, not a run. The unit tier is meant to run anywhere.
  2. The sandbox is deliberately hostile. No egress, --clearenv (every host var dropped), a fresh empty $HOME, a read-only /. Exactly right for foreign code. But a hook you wrote that legitimately needs an env var, the network, or writes outside its work dir now fails for reasons unrelated to its logic — and you have to claw each var back via setenvArgs.
  3. Trust follows provenance. Inline code you authored is trusted. Foreign plugin/pluginDir is not. Committing it to your repo is the same trust decision as taking on a dependency. You already made the call when you vendored it. The sandbox earns its keep on the code you haven't linted.
  4. Cost. A direct spawnSync is milliseconds — the whole point of the unit tier. The confined path stands up an IO dir, a fresh HOME, before/after tree snapshots, and a bwrap spawn. Paid on every trusted hook, that defeats the cheap base of the pyramid.

sandbox: "strict" forces confinement even for trusted code when you do want it (belt-and-suspenders on inline code). It's just not the default, because as a default it mostly punishes the code you trust on the platforms where it can't run.

Filesystem (IO) — how good is it against rm -rf?

Against destruction: strong. Under bwrap the host root is mounted --ro-bind / / (read-only) and only a throwaway work dir is writable. So rm -rf /, rm -rf ~, rm -rf /etc all fail with EROFS. The worst a hook can delete is its own disposable scratch dir.

Be honest about two gaps:

  1. ❌ Reading is NOT isolated. The whole host / is mounted read-only, so a hook can cat /home/you/.ssh/id_rsa or cat /home/you/.aws/credentials. Environment secrets are cleared (--clearenv, so ANTHROPIC_API_KEY isn't visible), but secrets on disk are readable. What saves you is that egress is blocked — it can read but can't send. (A future strictFs mode would bind a minimal rootfs instead of all of /, at the cost of compatibility.)
  2. ⚠️ Only Tier A. With sandbox: false / trusted-inline, the hook runs with full host access — rm -rf ~ really deletes your home. That's by design (it's code you wrote), but it means the protection above applies only to confined runs.

Record what it wrote. A confined runHook / runScript also reports the files it touched in its work dir (r.filesWritten, relative paths) — so you can assert it stayed in its lane:

import { assertWroteOnly, assertNoWrite } from "vigiles";

const r = runHook(thirdPartyHookCmd, event, { trusted: false });
// r.filesWritten → [".omc/state/ultrawork-state.json"]
assertWroteOnly(r, [/^\.omc\//]); // only its own state cache
assertNoWrite(r, /\.(env|pem|key)$/); // never a secret-shaped file

🔴 Recording requires confinement, and an unrecorded run refuses. The write list comes from diffing the work dir before and after, which only happens under confinement. An unconfined run therefore leaves filesWritten as undefined — which is deliberately not []:

Value Means
undefined nobody looked — writes were never recorded
[] looked, and it wrote nothing

assertNoWrite / assertWroteOnly throw on undefined rather than return green, and the message tells you to pass { sandbox: "auto" }. Collapsing the two would let a write assertion report success having inspected no evidence — a checker that passes while doing nothing is precisely what vigiles exists to catch elsewhere, so it must not be the default here.

Dogfood: src/run-hook.test.ts confines oh-my-claudecode's keyword-detector and asserts it writes only under .omc/ — its keyword-state cache, nothing else.

Network — block, and (optionally) record

Default: deny-all, and it's a real wall. Under bwrap the net namespace has loopback only and no external route. Nothing a hook does reaches the network. The scripted mock is co-launched inside the namespace, so it stays reachable while real egress is blocked.

recordEgress — record what it tried to reach, while still blocking it. A deny-all wall tells you nothing about what a hook wanted. For the supply-chain question — "what does this skill phone home to / which registry would its install hit?" — turn on recording:

import { runHook, assertNoEgress, assertEgressOnly } from "vigiles";

const r = runHook(thirdPartyHookCmd, event, { recordEgress: true });
// r.egress → [{ host: "registry.npmjs.org", port: 443, ts }]   (recorded)
assertNoEgress(r); // a hook that should phone home to nothing
// or: assertEgressOnly(r, ["registry.npmjs.org", /\.pypi\.org$/]);

recordEgress confines the hook (it needs the namespace) and runs a recording proxy on the sandbox loopback. HTTP(S)_PROXY points proxy-honoring tools (npm, pip, curl, fetch) at it. The proxy records each host:port and refuses it — so the attempt is captured and nothing leaves.

Honest limits of recordEgress:

  • ⚠️ Proxy-honoring tools only. Raw-socket egress bypasses the proxy. The netns still blocks it hard, so it can't get out. It just won't appear in the record. The block is the boundary; the record is best-effort observability over it.
  • ⚠️ Node fetch() needs Node 22+. curl / npm / pip honor the proxy on every Node version. Capturing Node's own fetch() needs NODE_USE_ENV_PROXY, which only takes effect on Node 22+. On older Node a fetch() is still blocked — just not recorded.
  • ✅ It still blocks. So a skill that needs a real npm install to succeed won't work in this mode — that's egress: { allow }, below.

Network — allowlisted real egress (egress: { allow })

Let it actually reach the network, but only the hosts you list. When the question is "does this skill install cleanly, and only from the registries I expect?", a wall (record or not) can't answer it — the install has to succeed. egress: { allow } lets traffic out to an allowlist and drops the rest at the packet layer:

import { runHook } from "vigiles";

const r = runHook(installHookCmd, event, {
  egress: { allow: ["registry.npmjs.org"] },
});
// r.egress       → [{ host: "registry.npmjs.org", allowed: true, packets, bytes }]
// r.egressDropped → { packets: 0, bytes: 0 }   // nothing reached off the allowlist

How it works: slirp4netns --configure attaches a tap to the bwrap netns (rootless egress via a userspace TCP/IP stack); an nft ruleset inside the netns is a policy drop output chain that accepts only the allowlist's resolved IPs (plus loopback + DNS) and log+drops the rest; the per-rule counters read back what was reached and what was dropped.

Why the boundary is nft, not a proxy. A recordEgress/HTTP_PROXY allowlist only constrains tools that honor the proxy — a raw socket ignores the env and goes straight out. The nft wall is at the packet layer, so a raw bash /dev/tcp to an off-list host is dropped too. Needs Linux + bubblewrap + slirp4netns + nft; it refuses (rather than running unconfined) if they're absent.

Honest limits:

  • ⚠️ IPs resolved at launch. The allowlist is resolved to IPs when the run starts. A host whose DNS rotates outside the run's window could hand the hook an IP that wasn't pre-resolved — and so gets dropped. The dynamic, resolver-pinned set (a DNS resolver in the netns that authorizes each answer's IPs just-in-time) is the next layer.
  • ℹ️ Dropped hosts are counted, not named. The record names the allowlisted hosts that were reached and counts how much off-list traffic was dropped. It does not yet name the dropped hosts (that needs the in-netns DNS-query log). r.egressDropped.packets > 0 tells you the hook tried to leave the allowlist; the DNS-log layer will say where to.

Dogfood — a real finding about a real plugin

The egress recorder runs against the real, vendored oh-my-claudecode plugin in the gate (src/run-hook.test.ts, skips where bwrap can't confine):

  • keyword-detector (UserPromptSubmit) → assertNoEgress confirms it phones home to nothing, while still doing its job.
  • session-start (SessionStart) → it fetch()es registry.npmjs.org for an update check on every session start. The recorder captures it (Node fetch via NODE_USE_ENV_PROXY), blocks it, and assertEgressOnly(r, ["registry.npmjs.org"]) proves it phones the npm registry and nowhere else.

That second one isn't a toy curl — it's a genuine supply-chain/privacy property of a popular plugin, asserted from its actual code.

The same session-start hook is dogfooded a second time under egress: { allow: ["registry.npmjs.org"] }: this time the update-check fetch() succeeds to the registry (r.egress records it allowed: true) and r.egressDropped.packets stays 0 — proving, through the allowlist path, that it reaches the npm registry and nothing else.

See also

  • Testing your harness — the three tiers + the sandbox boundary.
  • Testing API — interceptTools/notTool: preventing a real model's tool side effects at the eval tier.
  • src/sandbox.ts — decideSandbox (the pure policy), bwrapArgs, parseEgressLog.
  • src/egress.ts — the egress: { allow } allowlist: ruleset builder, counter parser, the pure seams.