@qvac/harness 0.2.8 → 0.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/harness.bundle +65 -12
  2. package/dist/prebuilds-manifest.json +8 -0
  3. package/dist-lib/index.d.ts +2 -1
  4. package/dist-lib/index.js +2 -1
  5. package/dist-lib/lib/capabilities/completion.d.ts +35 -2
  6. package/dist-lib/lib/capabilities/completion.js +84 -18
  7. package/dist-lib/lib/connect.js +1 -0
  8. package/dist-lib/lib/contract-types.d.ts +1 -0
  9. package/dist-lib/lib/serve.js +1 -0
  10. package/dist-lib/lib/wire.js +28 -0
  11. package/dist-lib/schema/harness.js +5 -2
  12. package/dist-lib/spec/hrpc/messages.js +22 -7
  13. package/dist-lib/spec/hyperschema/index.js +22 -7
  14. package/dist-lib/spec/hyperschema/schema.json +14 -1
  15. package/dist-lib/spec/hyperschema/types.d.ts +3 -2
  16. package/package.json +6 -6
  17. package/.claude/skills/asana/SKILL.md +0 -86
  18. package/.claude/skills/gh-stack/SKILL.md +0 -181
  19. package/.claude/skills/gh-stack/references/commands.md +0 -177
  20. package/.claude/skills/gh-stack/references/stack-design.md +0 -97
  21. package/.claude/skills/gh-stack/references/troubleshooting.md +0 -159
  22. package/.claude/skills/js-house-style/SKILL.md +0 -671
  23. package/.claude/skills/kvcache/SKILL.md +0 -171
  24. package/.claude/skills/pr-create/SKILL.md +0 -180
  25. package/.claude/skills/pr-review/SKILL.md +0 -414
  26. package/CLAUDE.md +0 -39
  27. package/docs/decisions/2026-05-29-kvcache-tool-turns.md +0 -75
  28. package/docs/decisions/2026-06-02-kvcache-tool-turns-reenabled.md +0 -127
  29. package/docs/decisions/2026-06-23-android-cpu-llm-defaults.md +0 -126
  30. package/docs/decisions/2026-07-29-assistant-owned-storage-layout.md +0 -88
  31. package/docs/decisions/2026-08-04-sidecar-orphan-reaping.md +0 -79
  32. package/docs/decisions/2026-08-11-pyodide-worker-thread.md +0 -68
  33. package/docs/decisions/2026-08-17-harness-hook-pipeline.md +0 -84
  34. package/docs/decisions/2026-08-17-harness-outbound-egress-guard.md +0 -176
  35. package/docs/decisions/2026-08-20-chat-prompt-prefix-is-stable.md +0 -95
  36. package/docs/decisions/2026-08-20-kvcache-static-placement.md +0 -109
  37. package/docs/decisions/2026-08-21-vendored-python-wheels.md +0 -85
  38. package/docs/decisions/2026-08-26-exec-is-not-gated-by-skills.md +0 -64
  39. package/docs/decisions/2026-08-27-writable-skills-overlay.md +0 -56
  40. package/docs/decisions/2026-09-01-skill-operation-manifests.md +0 -53
  41. package/docs/decisions/2026-09-01-usage-breakdown-is-measured.md +0 -78
  42. package/docs/decisions/2026-09-03-usage-breakdown-reported-by-the-harness.md +0 -136
  43. package/docs/decisions/2026-09-10-skill-wording-is-measured-offline.md +0 -63
  44. package/docs/decisions/2026-09-16-a-lookup-reports-the-place-it-resolved.md +0 -70
  45. package/docs/decisions/INDEX.md +0 -40
  46. package/docs/decisions/TEMPLATE.md +0 -32
  47. package/docs/kvcache-reference.md +0 -655
@@ -1,126 +0,0 @@
1
- ---
2
- id: ADR-2026-06-23-android-cpu-llm-defaults
3
- status: active
4
- introduced_by: PR-691
5
- supersedes: []
6
- ---
7
-
8
- # Android LLM defaults to CPU except Tensor-era Pixels
9
-
10
- ## Decision
11
-
12
- Android LLM inference defaults to CPU with a `q4_0` KV cache, except on
13
- Tensor-era Google Pixel devices before Pixel 10 where LLM inference defaults
14
- back to GPU. Pixel 10 and newer remain on CPU.
15
-
16
- ## Context
17
-
18
- Android GPU inference runs on Vulkan, which is unreliable across the fleet:
19
- Adreno on Snapdragon devices, including many Samsung variants, aborts under the
20
- LLM workload, and older devices ship buggy drivers. CPU is the only
21
- consistently stable target.
22
-
23
- The earlier Samsung and Pixel 10 CPU special-casing plus stripping the KV-cache
24
- quantization fields at the host boundary existed only to dodge the Vulkan abort.
25
- With all inference on CPU that abort path is gone, so a quantized `q4_0` cache
26
- is safe and lowers memory use.
27
-
28
- Pixel 9 profiling later showed that long chat/tool-shaped prompts can collapse
29
- decode throughput on the SDK CPU path while the GPU path keeps decode TPS
30
- healthy. Pixel 10 had prior GPU stability issues, so the exception is limited
31
- to Tensor-era Pixel devices before Pixel 10, including Pixel Tablet and the
32
- first-generation Pixel Fold. Original Pixel phones are not included in this
33
- exception.
34
-
35
- Core v2 device profiling (2026-07) added two KV-cache facts for the GPU
36
- exception: a quantized V cache aborts the Adreno backend outright, and a
37
- quantized K cache forces per-token dequantization onto a slow flash-attention
38
- path on Mali (Pixel 8 decode dropped from ~10 tps to ~3 tps). The GPU branch
39
- therefore sends no KV-cache override at all; the SDK default f16/f16 is both
40
- the stable and the fast profile, matching shipped 0.12. The harness
41
- additionally clamps cache-type-v to f16 on Android GPU loads as a backstop.
42
-
43
- ## Consequences
44
-
45
- - Embeddings follow the same split, and only on Android: Android devices and
46
- every simulator pin `embedDevice: "cpu"`, while iOS devices keep GPU
47
- embeddings. That matches the retired Core v1 policy and reflects the failure modes:
48
- Adreno/Vulkan aborts outright, whereas a Metal device-lost fault self-heals
49
- through the harness escalation in `lib/models/sdk-faults.ts`
50
- (reload once, fall back to CPU on a repeat).
51
- - Do not reintroduce host-boundary KV-cache field stripping; it only made sense
52
- for the GPU/Vulkan path.
53
- - This trades GPU acceleration on most Android devices for reliability and
54
- lower memory.
55
- - Tensor-era Pixel devices before Pixel 10 keep GPU acceleration for LLM
56
- completions.
57
- - Pixel 10 and newer stay on the Android CPU/q4_0 policy.
58
- - Revisit if the SDK gains a capability probe that can distinguish Vulkan
59
- drivers/devices that are safe under load, allowing a selective GPU re-enable.
60
-
61
- ## Amendment 2026-08-03: the abort attribution is unverified
62
-
63
- The Adreno aborts above are attributed to Vulkan, but the app never supplied
64
- `openclCacheDir` on any load. `@qvac/llm-llamacpp` documents Adreno 700+ as an
65
- **OpenCL** target, and the OpenCL backend `ggml_abort()`s during init when it
66
- cannot write its kernel cache, the same symptom this ADR blames on Vulkan. The
67
- backend that actually ran was never recorded, so which of the two caused the
68
- aborts is still open.
69
-
70
- Three things changed to make it answerable rather than re-litigated:
71
-
72
- - `configureSdkStorage` now exports a writable kernel-cache directory, and the
73
- harness passes `openclCacheDir` on Android LLM, embedding and transcription
74
- loads (`lib/models/opencl-cache.ts`).
75
- - The KV clamp covers **K as well as V** on Android GPU loads
76
- (`adrenoSafeKvCache`). Adreno OpenCL throws on any K type other than
77
- `f16`/`f32`/`bf16`, and this ADR's own Mali finding points the same way; only V
78
- was clamped before, so an agent row pinning `kvCacheK` reached the load
79
- unclamped.
80
- - `EXPO_PUBLIC_QVAC_ANDROID_LLM_DEVICE` and
81
- `EXPO_PUBLIC_QVAC_ANDROID_EMBED_DEVICE` (`"cpu" | "gpu"`) override the Android
82
- branches per build, so a device can be retested without a code change. The
83
- simulator branch ignores them.
84
-
85
- The defaults in this ADR are unchanged and remain in force. Before flipping any
86
- of them, record on real hardware the `backendDevice` value from
87
- `hlog('round stats', …)` (the SDK reports `cpu | vulkan | metal | opencl`) plus
88
- cold and warm `hlog('llm loaded', { loadMs })`. `opencl` means the kernel cache
89
- is load-bearing and this ADR's attribution needs correcting; `vulkan` means the
90
- cache dir is inert on this fleet and the CPU pin stands on its original
91
- reasoning.
92
-
93
- ## Amendment 2026-09-04: transcription follows the split too
94
-
95
- The Consequences above extended the split to embeddings and stopped there, so
96
- the transcription pool loaded parakeet with a hardcoded `useGPU: true` on every
97
- device — including the Pixel 10 whose GPU this ADR pins to CPU for the LLM. On
98
- that hardware the model loads, runs for a time proportional to the clip, and
99
- returns an empty transcript, so the app reports "contained no detectable
100
- speech" about a file full of speech. Nothing throws, so the
101
- `voice gpu load refused, retrying on cpu` fallback added for QVAC-24132 never
102
- fires: it covers a load that fails, not one that succeeds and produces nothing.
103
-
104
- Transcription now takes the same GPU verdict as the LLM, carried on the host
105
- boundary as `transcriptionDevice` (`"cpu" | "gpu"`) alongside `llmDevice` and
106
- `embedDevice`, with `EXPO_PUBLIC_QVAC_ANDROID_TRANSCRIPTION_DEVICE` and
107
- `EXPO_PUBLIC_QVAC_IOS_TRANSCRIPTION_DEVICE` to retest a device per build. An
108
- absent value still means GPU, so desktop is unaffected. Parakeet allocates its
109
- weights in one `ggml_backend_alloc_ctx_tensors` call with no partial-offload
110
- knob, so a GPU this ADR does not trust with the LLM cannot be trusted with it
111
- either.
112
-
113
- Still open: the voice pools have no equivalent of the embedding fault
114
- escalation in `lib/models/sdk-faults.ts`, so an empty
115
- transcript from a GPU-loaded model is reported as fact rather than retried on
116
- CPU. Until that exists, a mis-set device is silent rather than self-healing.
117
-
118
- ## When Agents Should Read This
119
-
120
- Read when changing Android device selection or KV-cache configuration for any
121
- inference category — LLM, embeddings or transcription — in
122
- `qvac-app → mobile/lib/android-llm-device.ts`, `qvac-app → mobile/lib/core-v2-harness-config.ts` or
123
- the pool constructors in `index.ts`, or re-evaluating the
124
- Android Vulkan/CPU and KV-cache Known Risks. Read the 2026-08-03 amendment
125
- before treating the Vulkan attribution as settled, and the 2026-09-04 amendment
126
- before adding a category that loads a model on the GPU.
@@ -1,88 +0,0 @@
1
- ---
2
- id: ADR-2026-07-29-assistant-owned-storage-layout
3
- status: active
4
- introduced_by: PR-000
5
- supersedes: []
6
- ---
7
-
8
- # Assistant-owned storage separates shared models from private KV
9
-
10
- ## Decision
11
-
12
- The assistant owns the Desktop and Mobile storage layout. Hosts pass a release
13
- environment plus optional root/model overrides; they do not construct assistant
14
- or SDK subpaths.
15
-
16
- Models stay at the SDK's canonical shared location on every platform:
17
-
18
- ```text
19
- ~/.qvac/models/ SDK-wide model and asset cache
20
- ```
21
-
22
- (Snap uses `$SNAP_USER_COMMON` as the home base, i.e.
23
- `$SNAP_USER_COMMON/.qvac/models`.) This deliberately matches the `@qvac/sdk`
24
- default (`getDefaultCacheDir → $HOME/.qvac/models`) so a standalone SDK, a CLI, or
25
- another QVAC product on the same machine reuses one downloaded model cache rather
26
- than each re-downloading multi-GB assets. Because of that sharing requirement
27
- models are **not** moved under `XDG_DATA_HOME` — the host may still relocate them
28
- explicitly via `QVAC_MODEL_CACHE_PATH` / the `models` override.
29
-
30
- The assistant **image cache** resolves via `bare-storage.persistent()` —
31
- `qvac/image-cache` under the OS data tier on every platform (`$XDG_DATA_HOME` on
32
- Linux, `~/Library/Application Support` on macOS, `%APPDATA%` on Windows; Flatpak
33
- `~/.var/app/<id>/data`). It stays in the _data_ tier (not `ephemeral()`/cache)
34
- because the assistant sweeps it itself (`imageCacheMaxBytes`/`imageCacheMaxAgeMs`
35
- → `sweepImageCache`), so it does not rely on OS cache eviction.
36
-
37
- Private state is rooted at the platform persistent-data directory returned by
38
- `bare-storage` (or `SNAP_USER_COMMON` for Snap):
39
-
40
- ```text
41
- qvac/{prod|staging|dev}/
42
- ├── assistant/ private application state
43
- └── sdk-home/ private SDK runtime home, registry, and KV cache
44
- ```
45
-
46
- The assistant writes an SDK JSON config with `cacheDirectory` pointing at the
47
- canonical models directory and uses the SDK's supported home override to assign
48
- each app/channel a private `sdk-home`. The SDK continues to derive its own
49
- KV-cache subpath; Workbench does not duplicate that SDK implementation detail.
50
-
51
- On Mobile, Delete All Data asks the live assistant harness to call the SDK's
52
- `deleteCache({ all: true })` operation before stopping Core, then removes only
53
- the `assistant` directory. A failed or unsupported KV-cache operation aborts the
54
- reset so Workbench does not report permanent deletion while conversation-derived
55
- cache remains. The host never deletes `sdk-home` directly, preserving SDK
56
- registry metadata alongside the shared model assets.
57
-
58
- The channel that home override rides on is per-runtime, not per-process. Off
59
- bare-kit it is `env[SNAP_USER_COMMON]`, which is also the key `resolveStorage`
60
- reads as the platform root; on bare-kit it is `Bare.argv[0]`, which `@qvac/sdk`
61
- prefers over `HOME_DIR` and resolves lazily. Bare-kit worklets share one OS
62
- `environ`, so a host that runs two of them — mobile runs the assistant and the
63
- harness side by side — would have each `configureSdkStorage` redefine the root
64
- the other's `resolveStorage` is about to read. Argv is copied into each
65
- runtime, so the write cannot leave the worklet that made it.
66
-
67
- Desktop `--storage`, `--tmp`, and `QVAC_STORAGE_PATH` replace only the private
68
- root. `QVAC_MODEL_CACHE_PATH` is a separate override for hermetic tests and
69
- other explicit host requirements. Mobile applies the same relative layout, but
70
- OS app-ID sandboxes naturally prevent sharing across Development, Staging, and
71
- Production installations.
72
-
73
- ## Consequences
74
-
75
- - Desktop apps and release channels reuse downloaded model assets without
76
- sharing conversations, meshes, runtime locks, or KV caches.
77
- - Mobile app reset clears every SDK KV cache through the harness and deletes
78
- private assistant state while leaving shared models and SDK registry metadata
79
- intact.
80
- - Existing `~/.qvac/models` downloads continue to be used on every platform —
81
- models are unchanged and stay aligned with the SDK default. The assistant
82
- **image cache** moved from `~/.qvac-assistant/image-cache` to
83
- `bare-storage.persistent()` (`qvac/image-cache`, data tier) — a greenfield
84
- change with no migration of prior cache data. Legacy global KV and app-data
85
- directories are not migrated or deleted.
86
- - SDK cache layout changes remain owned by the SDK: Core supplies the supported
87
- model-cache and home overrides and invokes SDK cache deletion without
88
- reproducing the KV subpath.
@@ -1,79 +0,0 @@
1
- ---
2
- id: ADR-2026-08-04-sidecar-orphan-reaping
3
- status: active
4
- introduced_by: PR-1382
5
- supersedes: []
6
- ---
7
-
8
- # The host reaps the sidecar's process group; the harness cannot reap itself
9
-
10
- ## Decision
11
-
12
- When the assistant sidecar exits, the desktop host kills the sidecar's whole process group.
13
- The sidecar is spawned detached (`qvac-app → desktop/electron/assistant.ts`), so it leads its own group
14
- and every harness it spawns stays in that group; killing the group on the exit edge reaps the
15
- strays and can reach nothing else.
16
-
17
- This is POSIX-only, and so is the bug. On Windows the kernel already ties the tree together:
18
- libuv assigns every non-detached child to a Job Object created with `KILL_ON_JOB_CLOSE`
19
- ("so windows will kill it when the parent process dies" — libuv's own comment), and
20
- `bare-subprocess` is a thin `uv_spawn` binding that sets `UV_PROCESS_DETACHED` only when asked.
21
- The sidecar and the harness are both spawned non-detached there, so killing the sidecar kills
22
- the harness at kernel level, mid-inference included. Verified empirically on a real Windows
23
- machine: sidecar killed mid-generation, both bare processes died, nothing lingered.
24
-
25
- The harness does not try to detect its own orphaning.
26
-
27
- ## Context
28
-
29
- The harness holds gigabytes of loaded model weights. Stow's stop signal is pipe EOF, and a
30
- sidecar killed outright (SIGKILL, OOM) never runs the ordered teardown that bounds its child —
31
- so on POSIX the harness reparents to init and holds that memory until the machine reboots.
32
-
33
- A manual `kill -9` is only the cleanest way to reproduce it. The same state follows from an
34
- `abort()` or segfault in one of the sidecar's native addons (SQLite, the vector extension,
35
- ffmpeg image normalization), from the kernel OOM killer picking the sidecar instead of the
36
- harness, or from a developer killing a hung app.
37
-
38
- ## Alternatives Considered
39
-
40
- An in-process watchdog in the harness (poll `os.ppid()`, exit on reparent) was implemented and
41
- reverted. The shipped bundle did contain the call, unref'd intervals do fire under bare, and
42
- `os.ppid()` is a live `getppid()` that reflects reparenting — yet a harness orphaned for minutes
43
- never exited. A harness parked in a native SDK call is not running JS at all, so no _JS-level_
44
- mechanism can survive it, whatever signal it watches.
45
-
46
- A native parent-death watcher would survive it, since it does not need the JS thread:
47
- `WaitForSingleObject` on the parent handle, `PR_SET_PDEATHSIG` on Linux, kqueue `NOTE_EXIT` on
48
- macOS. All require native work in bare, so the host-side reap was preferred.
49
-
50
- ## Consequences
51
-
52
- - The host is the only place that can bound the harness on abrupt sidecar death. The reap must
53
- stay wired for every sidecar spawned, whether or not a renderer ever adopted it.
54
- - The `detached: process.platform !== "win32"` split is load-bearing in both directions. POSIX
55
- needs the detach or the group kill has nothing to aim at; Windows needs the non-detach or
56
- libuv skips the job assignment and the kernel tie is lost. Setting `detached: true` on Windows
57
- would create there the very bug this reap fixes on POSIX.
58
- - Keep the harness in the sidecar's group — never spawn it detached, on any platform. On POSIX
59
- a detached harness leaves the sidecar's group and silently escapes this reap (no error, no
60
- failing test unless the real-process test in
61
- `qvac-app → desktop/test/electron/assistant-sidecar-orphan.test.ts` is kept); on Windows it leaves the
62
- sidecar's job and escapes the kernel cascade the same way.
63
- - Prefer the group over a pid on POSIX. The harness's own children (exec and shell tools) are in
64
- the group too; killing the harness pid alone leaves them orphaned.
65
- - The win32 branch of `forceKillAssistantSidecarTree` (`taskkill /T` on the sidecar) matters for
66
- the clean-stop escalation, where the sidecar is alive but will not unwind; on the exit edge it
67
- is a post-mortem no-op — the job object has already done the work.
68
- - Not solved on POSIX: the host dying abruptly while the sidecar is itself blocked in a native
69
- call. Then the sidecar cannot act on its own pipe EOF and nothing alive can reap either
70
- process. A sweep for stray harnesses at startup is the remedy if that shows up in practice.
71
- (Windows is covered even here: the host's death closes its job handle and the cascade takes
72
- the whole tree.)
73
- - `killStowChildren()` (`bare-supervisor/stow`) still covers a clean host exit; this covers the
74
- abrupt sidecar death it cannot see.
75
-
76
- ## When Agents Should Read This
77
-
78
- Read this when changing the paths, workflows, runtime boundaries, or platform behavior listed
79
- for this ADR in `docs/decisions/INDEX.md`.
@@ -1,68 +0,0 @@
1
- ---
2
- id: ADR-2026-08-11-pyodide-worker-thread
3
- status: active
4
- introduced_by: PR-1524
5
- supersedes: []
6
- ---
7
-
8
- # Pyodide runs in a worker thread with a shared interrupt buffer
9
-
10
- ## Decision
11
-
12
- The harness executes python (Pyodide, CPython on WebAssembly) in a dedicated
13
- worker thread — `Bare.Thread` + `bare-channel` on Bare, `worker_threads` on
14
- Node — never on the harness thread. Cancellation goes through a 1-byte
15
- `SharedArrayBuffer` registered with `pyodide.setInterruptBuffer()`: the host
16
- writes SIGINT, CPython raises `KeyboardInterrupt` at the next bytecode
17
- boundary, and the interpreter survives for the next run. A run that ignores
18
- the interrupt past a grace window gets the worker killed and respawned.
19
-
20
- ## Context
21
-
22
- `runPython` is synchronous inside Pyodide, so on the harness thread a busy
23
- script froze every tool and chat turn, and timeout/abort could only settle
24
- before or after a run. An interrupt can only be delivered from another thread
25
- (the blocked thread cannot run JS), so shared memory plus a second thread is
26
- the minimal correct shape. Two Bare constraints shaped the implementation:
27
-
28
- - `Bare.Thread.terminate()` is honored at a safepoint only. It cannot stop a
29
- busy-spinning thread, and `join()` on one blocks forever — so on Bare a
30
- wedged worker is abandoned (never joined), while Node's `terminate()`
31
- genuinely kills. Joining a healthy worker is only safe after its channel end
32
- has closed (`drainAndReap`), because the channel teardown itself needs the
33
- host's event loop.
34
- - A fresh thread's module loader reads plain files only. Spawning a thread
35
- whose entry lives inside a stowed `.bundle` aborts the whole process, but a
36
- `.bundle` path _as the entry itself_ boots fine. Every stowed harness —
37
- desktop (dev and packaged) and mobile — must therefore stage the worker as
38
- its own bundle and name it via `QVAC_PYODIDE_WORKER_ENTRY`; until a host
39
- sets it, a bundled harness falls back to the pre-worker in-process path
40
- rather than crash, so the worker only runs under the harness test suites.
41
-
42
- ## Alternatives Considered
43
-
44
- - **In-process with a watchdog thread writing the interrupt buffer**: keeps
45
- the harness thread blocked during runs and cannot serve a user abort (the
46
- event loop that would receive it is the blocked one).
47
- - **Subprocess CPython**: no wasm sandbox, not portable to mobile (iOS forbids
48
- spawning processes).
49
-
50
- ## Consequences
51
-
52
- - The worker thread is an availability boundary, not a security boundary:
53
- python can still reach the worker realm's globals via `from js import …`, so
54
- a python grant keeps shell-level trust (see `scope.ts`).
55
- - Anything that changes worker/entry module paths must keep the
56
- bundled-harness fallback and the `QVAC_PYODIDE_WORKER_ENTRY` override
57
- working, or every bundled host crashes on the first python run.
58
- - The in-process path survives for injected test loaders only; fakes cannot
59
- cross a thread boundary. Worker transport tests load a fake interpreter
60
- inside the thread via `QVAC_PYODIDE_TEST_LOADER`.
61
- - Revisit if Bare threads gain forced termination, or when the packaging
62
- pipeline ships the staged worker bundle (the fallback then becomes dead
63
- code).
64
-
65
- ## When Agents Should Read This
66
-
67
- Read this when changing the paths, workflows, runtime boundaries, or platform
68
- behavior listed for this ADR in `docs/decisions/INDEX.md`.
@@ -1,84 +0,0 @@
1
- ---
2
- id: ADR-2026-08-17-harness-hook-pipeline
3
- status: active
4
- introduced_by: PR-000
5
- supersedes: []
6
- ---
7
-
8
- # Harness hooks fold a whole round before any call runs, and fail closed
9
-
10
- ## Decision
11
-
12
- `assistant-harness` exposes four hook phases — `runStart`, `preToolCall`, `postToolCall`,
13
- `runEnd` — registered in a single process-wide array (`harnessHooks`) and driven by
14
- `HookPipeline` from inside `completion()`. Hooks receive a deep JSON clone of their payload and
15
- influence the run only through what they return: new `args` for a pre-hook, a new `result` for a
16
- post-hook, or a `HookRejection` (`{ error, guidance: true }`).
17
-
18
- Two ordering rules are durable:
19
-
20
- - **Pre-hooks fold the whole round before any call in that round executes.** The assistant turn
21
- written to history replays the folded calls, so history can never disagree with what ran.
22
- - **Post-hooks fire only after a real `tool.execute()`.** Refused, denied, invalid, guarded,
23
- capped, and unknown calls never reach a post-hook.
24
-
25
- A pre-hook may also _repair_ a call: the `args` it returns replace the model's own, and the
26
- payload carries the tool's declared `parameters` so the repair is schema-driven rather than
27
- per-tool. `ArgRepairHook` is registered first for that reason — it decodes a structured parameter
28
- a small model handed over as text (fenced, newline-broken, or missing its outer closer) before
29
- approval, the activity log, and `execute()` read the call, so all three see the same canonical
30
- args and the tool itself keeps a strict reader.
31
-
32
- Failure is closed, not fatal. A throwing tool hook becomes model-facing guidance and the loop
33
- continues; a throwing `runStart`/`runEnd` hook is logged and ignored, because run authorization
34
- lives in the policy layer, not here.
35
-
36
- ## Context
37
-
38
- Tool calls already pass through validation, the trusted-source guard, effect classification, and
39
- the approval memo. Extending any of those for cross-cutting concerns (redaction, arg rewriting,
40
- telemetry, per-tool filtering) would have meant editing the gate chain for every new concern.
41
-
42
- The round-level fold exists because the assistant turn is written to history once, before the
43
- calls run. Folding per call would let a pre-hook mutate arg N after the turn already recorded the
44
- model's original wrapper — history and execution would diverge, and the model would be trained by
45
- its own record to re-emit the un-mutated form.
46
-
47
- Deep cloning exists because payloads are the run's live objects: model-emitted args, history-bound
48
- results, the message list. A hook that mutates one in place would leak into the run without
49
- returning anything, which makes the "influence only through return values" contract unenforceable.
50
- Payloads are JSON-domain, so a `JSON.parse(JSON.stringify(...))` round-trip severs every reference;
51
- non-JSON values (`undefined`, functions, `BigInt`, cycles) pass through as-is rather than
52
- withholding output that already ran.
53
-
54
- ## Alternatives Considered
55
-
56
- - **Per-call fold (pre(N) after post(N-1)).** Rejected: it desynchronizes the assistant turn from
57
- what executes. Accepted cost of the round fold is that pre-hooks fire for calls a later gate
58
- (skill lock, search cap, abort) never runs.
59
- - **Hooks that throw abort the run.** Rejected: a buggy third-party hook would become a run-level
60
- denial of service. Failing closed on the _call_ keeps the run recoverable.
61
- - **In-place mutation of the live payload.** Rejected: no way to distinguish a deliberate change
62
- from an accidental one, and no way to reject a hook's edit.
63
- - **Structured-clone / a clone library.** Rejected: payloads are already JSON-domain; a dependency
64
- buys nothing a `JSON` round-trip does not.
65
-
66
- ## Consequences
67
-
68
- - A new hook phase must state whether it fires before or after execution, and post-phase hooks
69
- must never fire for a call that did not execute.
70
- - Rejection wording must never match the SDK wedge patterns in `models/sdk-faults.ts` — those mark
71
- a dead native context for eviction, not guidance for the model. A post-hook rejection says the
72
- tool already executed, so the model does not retry a side effect.
73
- - With no hook registered a run pays nothing, not even a payload clone. Keep that: `harnessHooks`
74
- is empty by default.
75
- - Registration is a process-wide array with no ordering, priority, or per-agent scoping. That is
76
- deliberate and should be revisited only when a real consumer needs it.
77
- - Hooks cannot bypass approval, the trusted-source guard, or the denial memo — those gates run on
78
- the folded args, after the hook.
79
-
80
- ## When Agents Should Read This
81
-
82
- Read this when changing the paths listed for this ADR in `docs/decisions/INDEX.md` — the tool loop
83
- in `capabilities/completion.ts`, anything under `lib/hooks/`, or any new gate that must order
84
- itself against the fold.
@@ -1,176 +0,0 @@
1
- ---
2
- id: ADR-2026-08-17-harness-outbound-egress-guard
3
- status: active
4
- introduced_by: PR-QVAC-23689
5
- supersedes: []
6
- ---
7
-
8
- # Harness tool egress and spawn environments are allowlists, not filters
9
-
10
- ## Decision
11
-
12
- Every outbound URL a harness tool reaches — `http_request` and `mcp_call`, on the
13
- first request and on each redirect hop — passes `resolveOutboundUrl`
14
- (`lib/tools/url-guard.ts`) before a socket opens. The guard
15
- rejects loopback, RFC1918, CGNAT, link-local, cloud-metadata hosts and their IPv6
16
- equivalents through `lib/tools/private-ip.ts`, and rejects a
17
- hostname whose resolved addresses land in those ranges. `private-ip.ts`
18
- default-denies any address it cannot positively classify as global unicast.
19
-
20
- The check splits along the hook boundary from
21
- ADR-2026-08-17-harness-hook-pipeline. The model-supplied URL gets a
22
- synchronous literal-host check from `checkOutboundHost`
23
- (`lib/tools/url-guard.ts`), run by `EgressGuardHook`
24
- (`lib/hooks/built-in/egress.ts`), a `preToolCall` hook
25
- shipped by default in `harnessHooks`
26
- (`lib/hooks/registry.ts`) so every host gets it without
27
- registering anything; a rejected call never executes and the rejection
28
- reaches the model as guidance. The hook does not resolve DNS — it only
29
- catches a literal blocked host (an IP or bracketed IPv6 literal in the URL
30
- itself). Resolving the hostname and pinning the connection to the addresses
31
- the guard approved happens in the tool, which needs those addresses anyway to
32
- connect: each tool runs `resolveOutboundUrl` itself with a `resolveHost`
33
- defaulting to `lib/dns-lookup.ts`, so a tool constructed
34
- directly (bypassing the pipeline) is still guarded. `resolveOutboundUrl`
35
- returns both the verdict and the resolved address set, which the tool then
36
- pins the connection to. `http_request` re-runs the
37
- check on every redirect hop, which only exists mid-execution after the hook
38
- has fired; `mcp_call` runs it before any credential resolves, so a blocked
39
- host never gets a token minted for it.
40
-
41
- On Node, `lib/no-redirect-fetch-node.ts` pins the connection
42
- to the addresses the guard already approved: it passes them to `http`/`https`
43
- request's `lookup` callback, which connects only to those addresses and
44
- rejects private results. On Bare, `lib/no-redirect-fetch-bare.ts`
45
- does not pin — the fetch agent carries a validating `lookup` hook
46
- (`lib/validating-lookup.ts`) that re-resolves through the
47
- same validation, plus a `createConnection` wrapper that re-checks literal-IP
48
- hosts, so the address it connects to is validated but not necessarily the one
49
- the guard first approved. That gap is the residual rebinding window on Bare;
50
- a rebind into a private range is still caught, just by a second resolve
51
- instead of by reusing the first one.
52
-
53
- A spawned child receives an environment built by adding to an allowlist, never by
54
- subtracting from `process.env`. `EXEC_ENV_ALLOWLIST` in
55
- `lib/helpers/spawn-path.ts` is `PATH`, `HOME`, the temp-dir
56
- trio, locale, `TZ`, `USER`, `LOGNAME`, the proxy and CA-bundle variables a
57
- granted binary needs to reach the network at all behind a corporate proxy, plus
58
- a per-platform set for the Obsidian launchers. Nothing else — no `SSH_*`, no
59
- `AWS_*`, no host tokens — crosses into a child.
60
-
61
- The same fail-closed shape covers the two remaining tool inputs that name a host
62
- resource. `exec`'s shell `workdir` resolves through `resolveKey` against the
63
- granted `roots`, and with no roots granted it is refused outright rather than
64
- passed to `spawn`. Skill loading is gated by `SkillCatalogGuardHook`
65
- (`lib/hooks/built-in/skill-catalog.ts`), shipped by default
66
- in `harnessHooks` alongside `EgressGuardHook`; a host that loads a catalog
67
- passes it to the constructor so `name` resolves against the advertised
68
- catalog, keeping the loadable set equal to the set the model was shown. The
69
- `skill` tool itself reads through a `Localdrive` rooted at `skillsDir`, which
70
- refuses traversal and absolute names regardless of the hook.
71
-
72
- A grant names what a tool may reach; a deny root names what no grant can reach, and
73
- deny wins. `resolveKey` (`lib/tools/fs/scope.ts`) resolves every
74
- path against the granted `roots` and then against `deniedRoots`, which the host
75
- passes and the model cannot influence. Both sides are canonicalized through
76
- `realpath` before comparison — once per resolution, not once per path segment — so
77
- a symlink, an uncanonical root, or a `..` walk cannot step around the deny root.
78
- The walking tools (`file_glob`, `grep`) drop denied paths as they enumerate,
79
- because a listing must not name what a read may not open. The desktop sidecar and
80
- the mobile worklet both derive the deny root from the storage layout the host
81
- already passes (ADR-2026-07-29-assistant-owned-storage-layout), so the app's own
82
- `credentials.vault`, `vault.key` and plaintext config database sit outside every
83
- grant without a new host argument.
84
-
85
- ## Context
86
-
87
- `qvac-app → docs/tool-review-checklist.md` carried SSRF as an unchecked placeholder — "port
88
- v1 `private-ip.ts`" — while `http_request` and `mcp_call` shipped in desktop and
89
- mobile builds. Both are model-driven: the URL, the redirect target, the `workdir`
90
- and the skill name all come from generated arguments, so any host reachable from
91
- the device was reachable by a prompt. Cloud metadata endpoints and a developer's
92
- own loopback services are the valuable targets, and both are one plain hostname
93
- away.
94
-
95
- An allowlist rather than a denylist for the spawn environment follows from the
96
- same reasoning applied to a different axis. A denylist has to enumerate every
97
- secret-bearing variable a host might set, and loses the moment a new tool
98
- convention appears. The allowlist only has to enumerate what the pinned binaries
99
- actually need to run, which is short and changes rarely.
100
-
101
- ## Alternatives Considered
102
-
103
- **Check only the literal URL, and skip DNS resolution.** Cheap, and it stops
104
- `http://127.0.0.1/`. It does not stop a hostname with an `A` record pointing at
105
- `127.0.0.1`, which is the standard way this is exploited. Resolving is the
106
- point of the guard as a whole — the hook's literal check is a fast early
107
- reject, but the tool must still resolve the hostname and check the resolved
108
- addresses before it connects, since it is the tool that ends up holding those
109
- addresses.
110
-
111
- **Make `resolveHost` a required option.** Rejected: every existing construction
112
- site would have to pass it, and the one that forgets ships a tool with no guard.
113
- Defaulting it means the unsafe configuration cannot be expressed by omission.
114
-
115
- **Put the initial-URL check only in the hook pipeline, not in each tool.**
116
- Tempting once the pipeline landed: the guard is exactly the cross-cutting
117
- per-tool gate the pipeline exists for, a rejection there reaches the model as
118
- guidance without the call executing, and one hook covers every URL-bearing tool
119
- instead of each tool re-implementing the same preamble. `EgressGuardHook` ships
120
- by default in `harnessHooks` (`lib/hooks/registry.ts`), so
121
- every host gets the model-facing guidance without registering anything — but
122
- the tools still keep their own check, because a tool can be constructed
123
- directly and called outside the hook pipeline, and that construction site must
124
- stay guarded on its own. A host that needs to drop the hook builds its own
125
- hook list instead of using `harnessHooks`; the tool-level check still holds
126
- either way.
127
-
128
- **Rely on the redirect-following code to re-enter the guard implicitly.** Rejected
129
- — redirect handling lives in shared helpers that do not own the resolver, so the
130
- hop check is written explicitly at each hop instead. A guard that only runs on the
131
- first URL is bypassed by any open redirect.
132
-
133
- **Filter `process.env` by pattern (drop anything matching `*TOKEN*`, `*KEY*`).**
134
- Rejected: it reads as security while depending on naming discipline in software we
135
- do not control.
136
-
137
- **Rely on the fs grants alone, and trust hosts not to grant a directory that
138
- contains the vault.** Rejected: the natural grant on desktop and mobile _is_ the
139
- user's home or the app sandbox, and both contain the storage root. A boundary that
140
- holds only while every host picks a narrow grant is not a boundary.
141
-
142
- ## Consequences
143
-
144
- - A new URL-bearing tool must run `resolveOutboundUrl` itself, on its own target
145
- and on any URL it derives mid-execution (redirects, discovery), and should be
146
- added to `EgressGuardHook`'s tool match so the refusal reaches the model
147
- as guidance. A tool that opens its own socket is outside the boundary and the
148
- review checklist should catch it.
149
- - A new host embedding the harness gets `EgressGuardHook` and
150
- `SkillCatalogGuardHook` by default through `harnessHooks`; registration is
151
- optional, not required. A host that needs to drop one builds its own hook
152
- list instead of using `harnessHooks`. Dropping the egress hook costs
153
- the guidance, not the gate; dropping the catalog hook leaves skill loading
154
- bounded only by the `skill` tool's `Localdrive` root, so the loadable set is
155
- the whole skills directory rather than the advertised catalog.
156
- - New entries in `EXEC_ENV_ALLOWLIST` need a stated reason — the file's comments
157
- say what each existing entry is load-bearing for, and `PATH` in particular is
158
- what makes the exec binary allowlist resolvable.
159
- - The Node fetch path pins each request to the address set returned by the
160
- egress check. A direct use of the low-level Node fetch adapter without a
161
- supplied address set performs its own validating lookup before connecting.
162
- - A new fs-shaped tool takes `deniedRoots` and resolves through `resolveKey`; one
163
- that walks the tree must also drop denied entries while enumerating, not only
164
- refuse the eventual read. A new host embedding the harness must pass the deny
165
- root — omitting it hands the vault to whatever else it grants.
166
- - `workdir` on a device with no granted directories is an error, not a silent
167
- fallback to the process cwd. That is intended, and the error text says to use
168
- absolute paths instead.
169
- - Revisit if the harness ever needs to reach a private address deliberately — a
170
- self-hosted MCP server on a LAN, say. That needs an explicit per-device grant,
171
- not a hole in `private-ip.ts`.
172
-
173
- ## When Agents Should Read This
174
-
175
- Read this when changing the paths, workflows, runtime boundaries, or platform
176
- behavior listed for this ADR in `docs/decisions/INDEX.md`.