@qvac/harness 0.2.7 → 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 (184) hide show
  1. package/dist/harness.bundle +70 -16
  2. package/dist/prebuilds-manifest.json +8 -0
  3. package/dist-lib/index.d.ts +2 -1
  4. package/dist-lib/index.js +60 -59
  5. package/dist-lib/lib/audio-transcode-bare.js +2 -2
  6. package/dist-lib/lib/capabilities/completion.d.ts +35 -2
  7. package/dist-lib/lib/capabilities/completion.js +114 -48
  8. package/dist-lib/lib/capabilities/download.js +2 -2
  9. package/dist-lib/lib/capabilities/speech-text.js +1 -1
  10. package/dist-lib/lib/capabilities/tool-prompt-log.js +1 -1
  11. package/dist-lib/lib/capabilities/tool-selection.js +2 -2
  12. package/dist-lib/lib/capabilities/tool-turns.js +1 -1
  13. package/dist-lib/lib/capabilities/usage-breakdown.js +1 -1
  14. package/dist-lib/lib/capabilities/voice.js +3 -3
  15. package/dist-lib/lib/connect.js +12 -11
  16. package/dist-lib/lib/context-window.js +1 -1
  17. package/dist-lib/lib/contract-types.d.ts +1 -0
  18. package/dist-lib/lib/docx/index.js +3 -3
  19. package/dist-lib/lib/hardware/capability-probe.js +3 -3
  20. package/dist-lib/lib/hardware/gpu-probe-addon.js +2 -2
  21. package/dist-lib/lib/hardware/gpu-probe-entry.js +1 -1
  22. package/dist-lib/lib/helpers/markup-stream-filter.js +1 -1
  23. package/dist-lib/lib/helpers/run-outcome.js +1 -1
  24. package/dist-lib/lib/helpers/spawn-path.d.ts +1 -1
  25. package/dist-lib/lib/helpers/thinking.js +2 -2
  26. package/dist-lib/lib/helpers/tool-intent.js +1 -1
  27. package/dist-lib/lib/helpers/tool-label.js +3 -3
  28. package/dist-lib/lib/hooks/built-in/arg-repair.d.ts +1 -1
  29. package/dist-lib/lib/hooks/built-in/arg-repair.js +1 -1
  30. package/dist-lib/lib/hooks/built-in/egress.d.ts +1 -1
  31. package/dist-lib/lib/hooks/built-in/egress.js +2 -2
  32. package/dist-lib/lib/hooks/built-in/index.js +3 -3
  33. package/dist-lib/lib/hooks/built-in/skill-catalog.d.ts +1 -1
  34. package/dist-lib/lib/hooks/pipeline.js +4 -4
  35. package/dist-lib/lib/hooks/registry.js +1 -1
  36. package/dist-lib/lib/hooks/run.js +1 -1
  37. package/dist-lib/lib/models/model-catalog.js +4 -4
  38. package/dist-lib/lib/models/model-delete.js +1 -1
  39. package/dist-lib/lib/models/model-handle.js +1 -1
  40. package/dist-lib/lib/models/model-list.js +1 -1
  41. package/dist-lib/lib/models/model-pool.js +8 -8
  42. package/dist-lib/lib/models/model-source.js +1 -1
  43. package/dist-lib/lib/models/music-catalog.d.ts +1 -1
  44. package/dist-lib/lib/no-redirect-fetch-bare.js +3 -3
  45. package/dist-lib/lib/no-redirect-fetch-impl.js +1 -1
  46. package/dist-lib/lib/no-redirect-fetch-node.js +2 -2
  47. package/dist-lib/lib/resolution-detector.js +1 -1
  48. package/dist-lib/lib/sdk-loader.js +2 -2
  49. package/dist-lib/lib/sdk-storage.js +1 -1
  50. package/dist-lib/lib/serve.js +11 -10
  51. package/dist-lib/lib/spawn.js +3 -3
  52. package/dist-lib/lib/staging.js +1 -1
  53. package/dist-lib/lib/tools/catalog.js +1 -1
  54. package/dist-lib/lib/tools/create-documents.js +2 -2
  55. package/dist-lib/lib/tools/edit-documents.js +2 -2
  56. package/dist-lib/lib/tools/edit-image.js +2 -2
  57. package/dist-lib/lib/tools/exec/adapters/python/attachments.js +2 -2
  58. package/dist-lib/lib/tools/exec/adapters/python/create-effect.js +1 -1
  59. package/dist-lib/lib/tools/exec/adapters/python/loader.js +1 -1
  60. package/dist-lib/lib/tools/exec/adapters/python/memfs.js +2 -2
  61. package/dist-lib/lib/tools/exec/adapters/python/pyodide.js +6 -6
  62. package/dist-lib/lib/tools/exec/adapters/python/python-adapter.js +3 -3
  63. package/dist-lib/lib/tools/exec/adapters/python/run-session.js +5 -5
  64. package/dist-lib/lib/tools/exec/adapters/python/wheels.js +1 -1
  65. package/dist-lib/lib/tools/exec/adapters/python/worker/client.js +3 -3
  66. package/dist-lib/lib/tools/exec/adapters/python/worker/entry.js +4 -4
  67. package/dist-lib/lib/tools/exec/adapters/shell/shell-adapter.js +1 -1
  68. package/dist-lib/lib/tools/exec/obsidian/cli-process.js +2 -2
  69. package/dist-lib/lib/tools/exec/obsidian/cli.js +4 -4
  70. package/dist-lib/lib/tools/exec/obsidian/command.js +1 -1
  71. package/dist-lib/lib/tools/exec/obsidian/route.js +7 -7
  72. package/dist-lib/lib/tools/exec/obsidian/vault-create.js +2 -2
  73. package/dist-lib/lib/tools/exec/obsidian/vault-fs.js +6 -6
  74. package/dist-lib/lib/tools/exec/obsidian/vault-paths.js +2 -2
  75. package/dist-lib/lib/tools/exec/obsidian/vault-read.js +1 -1
  76. package/dist-lib/lib/tools/exec/obsidian/vault-write.js +2 -2
  77. package/dist-lib/lib/tools/exec/runner.js +1 -1
  78. package/dist-lib/lib/tools/exec.js +13 -13
  79. package/dist-lib/lib/tools/file-glob.js +3 -3
  80. package/dist-lib/lib/tools/fs/local.js +1 -1
  81. package/dist-lib/lib/tools/fs/remote.js +1 -1
  82. package/dist-lib/lib/tools/fs/walk.js +2 -2
  83. package/dist-lib/lib/tools/generate-image.js +4 -4
  84. package/dist-lib/lib/tools/generate-music.js +6 -6
  85. package/dist-lib/lib/tools/grep.js +6 -6
  86. package/dist-lib/lib/tools/http-request/truncate-response.js +1 -1
  87. package/dist-lib/lib/tools/http-request.js +9 -9
  88. package/dist-lib/lib/tools/mcp/utils.js +1 -1
  89. package/dist-lib/lib/tools/mcp-call.js +8 -8
  90. package/dist-lib/lib/tools/operations/builders.d.ts +1 -1
  91. package/dist-lib/lib/tools/operations/builders.js +1 -1
  92. package/dist-lib/lib/tools/operations/compile.js +1 -1
  93. package/dist-lib/lib/tools/operations/manifest.d.ts +16 -16
  94. package/dist-lib/lib/tools/operations/manifest.js +1 -1
  95. package/dist-lib/lib/tools/outbound-guard.js +1 -1
  96. package/dist-lib/lib/tools/rag-search.js +1 -1
  97. package/dist-lib/lib/tools/read-files.js +3 -3
  98. package/dist-lib/lib/tools/skill.js +8 -8
  99. package/dist-lib/lib/tools/skills/auth-resolver.js +2 -2
  100. package/dist-lib/lib/tools/skills/bin-check.js +1 -1
  101. package/dist-lib/lib/tools/skills/bundled-dir.js +2 -2
  102. package/dist-lib/lib/tools/skills/catalog.js +10 -10
  103. package/dist-lib/lib/tools/skills/cli-schema-check.js +1 -1
  104. package/dist-lib/lib/tools/skills/cli-schema-load.js +1 -1
  105. package/dist-lib/lib/tools/skills/cli-schema.js +4 -4
  106. package/dist-lib/lib/tools/skills/credential-check.js +3 -3
  107. package/dist-lib/lib/tools/skills/credential-grants.js +1 -1
  108. package/dist-lib/lib/tools/skills/credential-health.js +2 -2
  109. package/dist-lib/lib/tools/skills/frontmatter-parser.js +1 -1
  110. package/dist-lib/lib/tools/skills/index.js +5 -5
  111. package/dist-lib/lib/tools/skills/mention.js +1 -1
  112. package/dist-lib/lib/tools/skills/overlay-crud.js +1 -1
  113. package/dist-lib/lib/tools/skills/setup-routes.js +1 -1
  114. package/dist-lib/lib/tools/skills/skill-settings.js +1 -1
  115. package/dist-lib/lib/tools/skills/skill-state-constants.d.ts +8 -8
  116. package/dist-lib/lib/tools/skills/skill-state.js +9 -9
  117. package/dist-lib/lib/tools/toolsets.d.ts +1 -1
  118. package/dist-lib/lib/tools/url-guard.js +2 -2
  119. package/dist-lib/lib/tools/weather-lookup/place.d.ts +11 -0
  120. package/dist-lib/lib/tools/weather-lookup/place.js +152 -0
  121. package/dist-lib/lib/tools/weather-lookup/report.d.ts +37 -0
  122. package/dist-lib/lib/tools/weather-lookup/report.js +56 -0
  123. package/dist-lib/lib/tools/weather-lookup.d.ts +8 -0
  124. package/dist-lib/lib/tools/weather-lookup.js +110 -0
  125. package/dist-lib/lib/tools/web-fetch.js +10 -10
  126. package/dist-lib/lib/tools/web-search.js +2 -2
  127. package/dist-lib/lib/transport.js +1 -1
  128. package/dist-lib/lib/validating-lookup.js +1 -1
  129. package/dist-lib/lib/web-fetch/embedded-state.js +1 -1
  130. package/dist-lib/lib/web-fetch/extract.js +1 -1
  131. package/dist-lib/lib/web-fetch/images.js +1 -1
  132. package/dist-lib/lib/web-fetch/video.js +1 -1
  133. package/dist-lib/lib/web-search/engine-registry.js +7 -7
  134. package/dist-lib/lib/web-search/engines/aol.js +3 -3
  135. package/dist-lib/lib/web-search/engines/bing.js +4 -4
  136. package/dist-lib/lib/web-search/engines/brave.js +2 -2
  137. package/dist-lib/lib/web-search/engines/duckduckgo.js +3 -3
  138. package/dist-lib/lib/web-search/engines/google.js +4 -4
  139. package/dist-lib/lib/web-search/engines/wikipedia.js +2 -2
  140. package/dist-lib/lib/web-search/engines/yahoo.js +3 -3
  141. package/dist-lib/lib/web-search/fetch-html.js +1 -1
  142. package/dist-lib/lib/web-search/merge-results.js +1 -1
  143. package/dist-lib/lib/web-search/orchestrator.js +3 -3
  144. package/dist-lib/lib/wire.js +28 -0
  145. package/dist-lib/schema/baseline.js +1 -1
  146. package/dist-lib/schema/build.js +3 -3
  147. package/dist-lib/schema/harness.js +5 -2
  148. package/dist-lib/schema/release-tags.d.ts +2 -1
  149. package/dist-lib/schema/release-tags.js +9 -7
  150. package/dist-lib/spec/hrpc/messages.js +22 -7
  151. package/dist-lib/spec/hyperschema/index.js +22 -7
  152. package/dist-lib/spec/hyperschema/schema.json +14 -1
  153. package/dist-lib/spec/hyperschema/types.d.ts +3 -2
  154. package/package.json +21 -8
  155. package/worker/harness-sidecar.mjs +2 -0
  156. package/.claude/skills/asana/SKILL.md +0 -86
  157. package/.claude/skills/gh-stack/SKILL.md +0 -181
  158. package/.claude/skills/gh-stack/references/commands.md +0 -177
  159. package/.claude/skills/gh-stack/references/stack-design.md +0 -97
  160. package/.claude/skills/gh-stack/references/troubleshooting.md +0 -159
  161. package/.claude/skills/js-house-style/SKILL.md +0 -671
  162. package/.claude/skills/kvcache/SKILL.md +0 -171
  163. package/.claude/skills/pr-create/SKILL.md +0 -180
  164. package/.claude/skills/pr-review/SKILL.md +0 -217
  165. package/CLAUDE.md +0 -39
  166. package/docs/decisions/2026-05-29-kvcache-tool-turns.md +0 -75
  167. package/docs/decisions/2026-06-02-kvcache-tool-turns-reenabled.md +0 -127
  168. package/docs/decisions/2026-06-23-android-cpu-llm-defaults.md +0 -126
  169. package/docs/decisions/2026-07-29-assistant-owned-storage-layout.md +0 -88
  170. package/docs/decisions/2026-08-04-sidecar-orphan-reaping.md +0 -79
  171. package/docs/decisions/2026-08-11-pyodide-worker-thread.md +0 -68
  172. package/docs/decisions/2026-08-17-harness-hook-pipeline.md +0 -84
  173. package/docs/decisions/2026-08-17-harness-outbound-egress-guard.md +0 -176
  174. package/docs/decisions/2026-08-20-chat-prompt-prefix-is-stable.md +0 -95
  175. package/docs/decisions/2026-08-20-kvcache-static-placement.md +0 -109
  176. package/docs/decisions/2026-08-21-vendored-python-wheels.md +0 -85
  177. package/docs/decisions/2026-08-26-exec-is-not-gated-by-skills.md +0 -64
  178. package/docs/decisions/2026-08-27-writable-skills-overlay.md +0 -56
  179. package/docs/decisions/2026-09-01-skill-operation-manifests.md +0 -53
  180. package/docs/decisions/2026-09-01-usage-breakdown-is-measured.md +0 -78
  181. package/docs/decisions/2026-09-03-usage-breakdown-reported-by-the-harness.md +0 -136
  182. package/docs/decisions/INDEX.md +0 -38
  183. package/docs/decisions/TEMPLATE.md +0 -32
  184. package/docs/kvcache-reference.md +0 -655
@@ -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`.
@@ -1,95 +0,0 @@
1
- ---
2
- id: ADR-2026-08-20-chat-prompt-prefix-is-stable
3
- status: active
4
- introduced_by: PR-1714
5
- supersedes: []
6
- ---
7
-
8
- # A chat's prompt prefix changes only when the user changes something
9
-
10
- ## Decision
11
-
12
- The system prompt and the tool block a chat sends are byte-identical from one turn to the
13
- next unless the user changed something: agent config, an installed skill, a device tool
14
- toggle. The runtime never varies them on its own.
15
-
16
- `promptPrefixHash` (`lib/capabilities/prompt-prefix.ts`) is the identity of
17
- that prefix. It rides the harness's per-round `round start` log next to `turns` and `tools`,
18
- and a harness test asserts consecutive turns of an unchanged agent hash identically while
19
- enabling a tool moves the hash.
20
-
21
- The corollary, where a capability is resolved from more than one input: **one predicate
22
- answers it for every consumer.** Skill activation is resolved by `skillIsActive`
23
- (`lib/capabilities/completion.ts`), read both by the active catalog that
24
- fills the skills section and grants tools, and by the skill loader's per-argument allowlist.
25
- A skill in one and not the other either hides a capability the model was told about or
26
- advertises one the loader then refuses.
27
-
28
- ## Context
29
-
30
- The prefix is what the KV cache matches on, so a prefix that moves reprocesses the whole
31
- chat. That cost is acceptable once, for a change the user made. It is not acceptable per
32
- turn, and a prefix that moves on its own is usually a capability appearing or disappearing
33
- rather than a cache problem.
34
-
35
- The defect that motivated this: `@mention` activation was resolved from the current turn
36
- only, so a follow-up turn that named no skill lost the skill, the `skill` tool, and every
37
- tool the skill granted. `@presentations` built a deck; "edit it to make the background
38
- orange" reached a model with no way to act. The skills section and two tool definitions
39
- left the prefix on every such turn, which was observable in the cache numbers and observed
40
- by nobody.
41
-
42
- The second half is the same failure one level down. Activation had two implementations — the
43
- catalog filter and the loader's allowlist — and repairing only the first would have listed a
44
- skill in the prompt that the loader answered with `skill not enabled`.
45
-
46
- ## Alternatives Considered
47
-
48
- **Sorting the tool block, or otherwise normalizing the prefix, to absorb variation.** It
49
- would hide exactly the signal this record exists to expose: KV positions are absolute, so an
50
- inserted or reordered tool invalidates everything after it whatever we hash. The prefix
51
- should be stable because nothing varies it, not because the hash forgives variation.
52
-
53
- ## Consequences
54
-
55
- - A change that makes the system prompt or the tool block vary within a chat is a defect,
56
- whatever motivates it, and the test says so.
57
- - A mid-chat `CACHE_INIT` or a `cacheTokens` collapse is now attributable: either the logged
58
- prefix moved, and the config change that moved it can be named, or it did not, and the
59
- cause is elsewhere.
60
- - Adding a third consumer of skill activation means extending `SkillActivation`, not
61
- re-deriving activation locally.
62
- - **This record does not decide skill-activation semantics.** SKILL-3 has since been
63
- rewritten: an agent is born with every ready skill and a mention grants nothing, so the
64
- migration below no longer has a grant to persist. As written at the time,
65
- [SKILL-3](../product/skills.md#skill-3) required an `@mention` to enable the skill on the
66
- agent — durably, visibly, and removably — and
67
- [`qvac-app → docs/plan/tool-safety/p1.md`](../plan/tool-safety/p1.md) §1a-i owns that migration,
68
- including moving mention detection out of `completion()` to where the user message is
69
- minted. Until it lands, the harness keeps a chat's already-loaded skills active so a
70
- follow-up turn does not lose them. That is a runtime repair for the prefix invariant above,
71
- not the durable mechanism, and it becomes redundant for the mention path once a mention
72
- persists onto `enabledSkills`.
73
- - **Accepted risk until §1a-i lands: a mention grant is invisible and cannot be revoked.** The
74
- only record that the user granted it is the chat transcript, so the Skills UI cannot show it,
75
- and removing the skill from the agent (`removeSkillFromAgentLists`, which clears both
76
- `enabledSkills` and `installedSkills`) leaves it active in every chat that already loaded it,
77
- with the tools it grants, `exec` included. No harness-side signal can fix this: removal clears
78
- both lists, so a revoked skill is byte-identical to a never-enabled one, and sending
79
- `installedSkills` to the runtime would not distinguish them either. Uninstalling the skill from
80
- the _device_ does revoke it everywhere, because the entry then leaves `skillCatalog`.
81
- - That is a regression in revocability, not just an unbuilt half of SKILL-3. Before, a mention
82
- grant was revoked by doing nothing, because it evaporated on the next turn, which is the
83
- defect this record exists to fix. Persisting a mention onto the agent is what makes the grant
84
- durable _and_ revocable; keeping only the durable half is the interim cost.
85
- - Not addressed: `historyFromChunks` (`@qvac/core → lib/assistant.ts`) replays only `skill` tool
86
- results, so a tool-produced file's `attachmentId` never reaches a later turn. Also not
87
- addressed: the `@mention` preload rewrites the last user turn in memory only, so that turn
88
- diverges from its persisted form on every later run — a second divergence point downstream
89
- of the prefix.
90
-
91
- ## When Agents Should Read This
92
-
93
- Read this before changing what a run puts in the system prompt or the tool list, when adding
94
- a consumer of skill activation, or when a chat turns out to send a different prefix on
95
- different turns.
@@ -1,109 +0,0 @@
1
- ---
2
- id: ADR-2026-08-20-kvcache-static-placement
3
- status: active
4
- introduced_by: PR-1770
5
- supersedes: [ADR-2026-06-02-kvcache-tool-turns-reenabled]
6
- ---
7
-
8
- # Chat models load with static tool placement and no sliding window
9
-
10
- ## Decision
11
-
12
- `model-pool.ts` no longer passes `toolsMode` or `n_discarded` in `modelConfig`
13
- (static placement in PR-1711, the sliding window in PR-1770).
14
- Models load with `tools: true` only, which selects the SDK's **static**
15
- placement — the default, and since SDK 0.18 the only one, because that release
16
- deletes dynamic mode and rejects the key. The per-turn payload is
17
- `history.slice(savedCount)`, the unsent tail.
18
- `kvCache: 'chat-<id>'` stays on every completion, unchanged.
19
-
20
- The two removals ship as separate PRs, deliberately: the dynamic-tools defect
21
- had to reach a release on its own. Each key removal changes `modelConfig`,
22
- which re-keys the SDK's identity-by-config `modelId`, so every chat
23
- cold-primes once per removal — one wave per release rather than one wave for
24
- both. That cost is accepted in exchange for shipping the fix early.
25
-
26
- ## Context
27
-
28
- ADR-2026-06-02 chose `toolsMode: 'dynamic'` on the strength of the addon's
29
- compact-tools anchoring. The 2026-08-18 investigation
30
- (`docs/kvcache-reference.md`) established that on every shipped model the addon
31
- half of that feature never ran: the `tools_compact` gate matches architecture
32
- `qwen3` exactly, and the catalog is `qwen35`/`qwen36`, so the addon logged
33
- `tools_compact is not supported … ignoring` on every load while the SDK still
34
- sliced as-if-dynamic. Consequences, measured:
35
-
36
- - the previous answer was duplicated into the KV every warm turn, and the tool
37
- block was re-sent with nothing trimming it — three one-line exchanges filled
38
- a 2048 window;
39
- - after any cache reset (deleted `.bin`, `configHash`/`modelId` move, new
40
- device) the dynamic slice still sent only the last exchange, so the model
41
- silently lost everything older — TTFT unchanged, no error, answer
42
- confabulated. Under static placement the same reset costs one proportional
43
- prefill and the fact survives (measured: `promptTokens` 16→65, TTFT
44
- 41→96 ms).
45
-
46
- The sliding window fails the same way from the other side, and it fails on the
47
- shipped stack. **A slide is invisible to the SDK**: the cache session records a
48
- turn's full message count on success and the next turn sends
49
- `history.slice(savedCount)`, so nothing observes the addon discarding tokens in
50
- C++ — the discarded span is gone from the live cache and from the saved `.bin`
51
- while the app still counts it as sent, and the model silently loses the middle
52
- of the chat. Not sliding also obsoletes the two slide-time addon bugs we
53
- reported (the reasoning-span wipe, the single-step prefill slide), and 0 is the
54
- addon's own documented default, so nothing is being opted out of.
55
-
56
- Upstream has since deleted dynamic mode on both sides (SDK `main` rejects the
57
- `toolsMode` key at parse time; the addon drops `tools_compact` after 0.39.4),
58
- and the addon team asked (Slack, 2026-08-19) that `n_discarded != 0` never be
59
- passed because context shifting is dropped in a future addon. That last one is
60
- guidance about an unreleased version and carries no weight here: the shipped
61
- 0.39.4 documents the knob as live, default 0, with no deprecation note. The
62
- decision rests on the defect above, which lands today.
63
-
64
- ## Consequences
65
-
66
- - A cold cache now falls back to the full history: chats survive cache resets
67
- at TTFT cost instead of silently forgetting.
68
- - With no sliding window, a full context surfaces `ContextOverflowError`, and
69
- the overflow recovery ladder (`lib/context-window.ts`,
70
- drop-oldest-turns) becomes effective — under dynamic slicing it was inert.
71
- The planned compact flow (MIGRATION, KV cache follow-ups) is its successor.
72
- - The tool block is **not** in the primed prefix on the SDK we ship (0.18.2;
73
- QVAC-23251 landed in 0.17.1) — the prime is the system prompt alone, and the
74
- block travels with a turn, written once and skipped thereafter. Its names still
75
- join `configHash`, so a tool-set change (a skill activation, an `enabledTools`
76
- or `disabledToolsets` edit) re-keys the cache file and costs a cold prime —
77
- once per change, since the prefix is otherwise stable for the chat
78
- ([2026-08-20-chat-prompt-prefix-is-stable](./2026-08-20-chat-prompt-prefix-is-stable.md)).
79
- - **Dropping `n_discarded` is what lets that block be skipped at all.** The
80
- shipped SDK sets `toolBlockEvictable = (modelConfig.n_discarded ?? 0) > 0`,
81
- because with sliding on the addon's discard window opens exactly where the
82
- block sits and nothing protects it. So while core-v2 passed the key, the whole
83
- tool block was re-sent on every turn — measured ~331 tokens for a 4-tool agent.
84
- This is the second present-day reason for the removal, alongside a slide being
85
- invisible to the SDK's own `savedCount` accounting.
86
- - With `n_discarded` at 0 the addon's decode-time slide never engages either: a
87
- generation that fits at prefill but crosses `ctx_size` mid-stream cannot
88
- slide, and the overflow ladder cannot recover it — the ladder only retries a
89
- round that has not yet yielded tokens — so a mid-decode exhaustion ends the
90
- turn. Rare (bounded by generation length, not chat length); the compact flow's
91
- proactive trigger is the structural answer. That round still fails with the
92
- harness's own `CONTEXT_OVERFLOW_MESSAGE` rather than the SDK's text, which
93
- names the `modelId` hash and blames the prompt — and which the compaction
94
- trigger would not recognise.
95
- - **Each removal strands the previous cache file, and nothing sweeps it.** The
96
- file lives at `{kvCacheDir}/{cacheKey}/{modelId}/{configHash}.bin`, so a
97
- `modelId` re-key leaves the old `{modelId}/` directory unreferenced — 21–26 MB
98
- at ctx 2048, more at the desktop default. The SDK's retention sweep cannot
99
- reach it: eviction plans only keys marked by `markAutoCacheKey`, and the
100
- custom-key path (`beginCustom`) never marks, so `chat-<id>` is outside
101
- retention permanently and by design (a config revert may revive the file).
102
- Pre-existing — every `configHash` move already did this — but two releases in
103
- a row now do it to every chat at once. The reclaim is the MIGRATION
104
- "Delete on re-key" item, which deletes the whole key directory (every
105
- `modelId`/`configHash` under it) on chat deletion and agent re-key; a chat
106
- that is never deleted and whose agent is never edited keeps its stale
107
- directories until then.
108
- - One cold prime per chat on first run after the upgrade (the `modelId`
109
- re-key) — expected, not a regression.
@@ -1,85 +0,0 @@
1
- ---
2
- id: ADR-2026-08-21-vendored-python-wheels
3
- status: active
4
- introduced_by: PR-1757
5
- supersedes: []
6
- ---
7
-
8
- # Python packages ship with the app, pinned by hash
9
-
10
- ## Decision
11
-
12
- Every Python distribution a skill declares in `packages` is vendored into the
13
- app: `wheels/manifest.json` pins each wheel with its sha256,
14
- `scripts/vendor-python-wheels.mjs` downloads and verifies them at build time,
15
- and the desktop build copies the tree to `dist/main/python-wheels`. The harness
16
- receives the directory on argv and installs from it, so a run declaring a pinned
17
- package makes no network request at all.
18
-
19
- Two channels need two mechanisms. Lockfile wheels (micropip, pillow, lxml,
20
- fonttools, typing-extensions, cryptography and its deps) are copied into
21
- Pyodide's `packageCacheDir` before the interpreter boots, because `loadPackage`
22
- reads that directory and nothing else. PyPI wheels (fpdf2, pypdf, openpyxl,
23
- python-docx, python-pptx and their deps) are staged into the interpreter and
24
- installed by `emfs:` path with `deps=False`, because the manifest already names
25
- the whole closure and dependency resolution is the step that would reach PyPI.
26
-
27
- ## Context
28
-
29
- The PDF skill failed on an offline device: `micropip.install` resolves names
30
- against pypi.org, so `fpdf2` could not be fetched. micropip reports an
31
- unreachable index as `Can't fetch metadata for 'fpdf2'`, which reads as a wrong
32
- package name — the model answered it by guessing `fpdf`, `pdf2`, `pdfkit` and
33
- `reportlab` in turn and never produced a file. Every python-backed skill had the
34
- same defect, and PyPI wheels were never persisted, so even a device that had
35
- been online re-downloaded them on the next launch.
36
-
37
- Pinning was already the skills' convention for reproducibility. Vendoring the
38
- pinned set turns that into a supply-chain guarantee: the app cannot install a
39
- version that was not reviewed, and the runtime makes no PyPI request at all.
40
-
41
- Measured cost for the whole set: 17 wheels, 8,314,167 bytes on disk — 1.8 MB of
42
- PyPI wheels, 3.9 MB of lockfile wheels, and 2.3 MB for the cryptography closure
43
- the PDF skill's AES flows need. Two macOS DMGs built from the same tree, one with
44
- the wheels staged and one without, put the shipped cost at **8,245,247 bytes
45
- (+3.6%)**: 229,209,450 → 237,454,697. Wheels are zip archives, so the DMG's LZFSE
46
- pass cannot compress them further and the installer grows by very nearly their
47
- raw size. Against the 24 MB of Pyodide runtime the app already ships, they are the
48
- cheap part; lxml, long assumed to be the dominant cost, is 1.6 MB.
49
-
50
- ## Alternatives Considered
51
-
52
- - **Point Pyodide's `packageCacheDir` at the read-only vendored tree.** Pyodide
53
- writes downloaded wheels into that directory, so an unvendored package would
54
- fail on the write rather than on the download, and the online fallback would
55
- break. Seeding a writable cache keeps both paths working.
56
- - **Install the vendored wheels and let micropip resolve the rest.** A partial
57
- install pins versions the resolver then has to work around, and it still needs
58
- the network for the remainder. The plan is all-or-nothing per request: any
59
- unvendored package sends the whole install back to PyPI, dependencies included.
60
- - **Ship the wheels inside `node_modules/pyodide`.** Its `indexURL` is bypassed
61
- the moment `packageCacheDir` is set, and `bun install` would wipe the tree.
62
-
63
- ## Consequences
64
-
65
- - A pin change is two edits and a regeneration: the version in `SKILL.md`, the
66
- version in the manifest, then `node scripts/vendor-python-wheels.mjs --update`.
67
- The `python-wheels` test suite fails when those disagree, because a silent
68
- disagreement reverts that skill to online-only.
69
- - Bumping Pyodide invalidates the lockfile wheels — they carry its ABI tag. The
70
- vendoring script refuses to run when the manifest and `node_modules/pyodide`
71
- name different versions.
72
- - A package no skill declares still installs from PyPI and still needs a
73
- connection. micropip reports a missing name and an unreachable index
74
- identically, so `packageFetchFailedHint` names both causes and tells the model
75
- not to try other spellings, which is the part that ends the loop.
76
- - Mobile is unchanged: no Pyodide runtime ships there, so no wheels are passed.
77
- Vendoring for mobile means shipping the interpreter first.
78
- - The desktop build needs network access at package time to fill the wheel tree.
79
- A hash mismatch fails the build rather than shipping an unreviewed wheel.
80
-
81
- ## When Agents Should Read This
82
-
83
- Read this when changing the Python exec adapter's install path, the vendoring
84
- script or manifest, the `packages` pins in a skill, the Pyodide dependency, or
85
- the desktop build steps that stage harness assets.
@@ -1,64 +0,0 @@
1
- ---
2
- id: ADR-2026-08-26-exec-is-not-gated-by-skills
3
- status: active
4
- introduced_by: PR-1854
5
- supersedes: []
6
- ---
7
-
8
- # A skill no longer decides what `exec` may run; approval does
9
-
10
- ## Decision
11
-
12
- `exec` is reachable whenever the agent's Tools settings reach it, and it runs whatever
13
- command it is given. The two gates that made a skill the source of that permission are
14
- gone:
15
-
16
- - the call-time lock that refused a granted tool until the granting skill's body had
17
- been loaded in the same chat, and
18
- - the per-turn binary allowlist, which built the set of runnable programs out of the
19
- `exec(...)` scopes of the active skills and refused everything else.
20
-
21
- What remains in front of a command is the sanitizer — one program per call, no
22
- chaining, pipes, redirection or shell expansion — and the approval prompt, which every
23
- `exec` write raises unless the agent is in Skip All Approvals.
24
-
25
- A skill's `exec(bin)` scope still means something narrower: it names the bin whose
26
- `cli.schema.json` validates argv and carries the `x-effect: read` annotations that let
27
- a read run unattended. It no longer decides whether the command may run at all.
28
-
29
- ## Context
30
-
31
- The agent's Tools tab lists Exec as a capability the user switches on, with the hint
32
- "Lets QV.AC run commands on the device" ([PERM-10](../product/tool-permissions.md#perm-10)).
33
- That switch writes the tool's name into the agent's allowlist and nothing else. It
34
- could not add a binary to the allowlist or load a skill body, so switching Exec on left
35
- `exec` hidden from the model, and a model that reached it anyway got told to load a
36
- skill it may not have. The user's switch said yes and the gate said no.
37
-
38
- Both gates were built when a skill was the only way a tool became available
39
- (PR-1426 for the lock, and the scope work before it). That premise is what changed.
40
-
41
- ## Consequences
42
-
43
- - **Approval is the boundary.** With no argv schema for an arbitrary bin, `shellEffect`
44
- cannot classify a command as a read, so it classifies write and the call prompts. An
45
- agent on Skip All Approvals runs shell commands unattended; that mode already carried
46
- that meaning for the granted binaries, and now carries it for all of them.
47
- - **`exec(python)` no longer gates Pyodide.** `language: "python"` runs without any
48
- grant. The realm it runs in is unchanged, so
49
- [PERM-7](../product/tool-permissions.md#perm-7) is exactly as met, or unmet, as it
50
- was; what changed is that reaching it no longer requires a skill. The mitigation is
51
- still [tool-safety p3](../plan/tool-safety/p3.md).
52
- - **The argv schema now reads backwards.** `resolveShellOps` looks the bin up in
53
- `grantedOps`, which only active skills fill. With apple-notes on,
54
- `osascript -e 'do shell script "id"'` is refused before approval; with it off, the same
55
- command sanitizes clean and spawns. Enabling a skill makes `exec` narrower than leaving
56
- it off. That is kept deliberately: the schema is how a skill states the shape it expects
57
- its own CLI to be called in, and it bounds nothing that running the command directly
58
- would not already reach. It stops being defensible the moment anything treats it as a
59
- security control.
60
- - **The model is told the truth.** `exec`'s description no longer promises an allowlist,
61
- and a loaded skill's tool note no longer claims its scopes are the only runnable
62
- executables.
63
- - The `skillGated` and `scopeRequired` wire fields survive, reported off, so a build on
64
- the other side of the sidecar hop reads both gates as open rather than missing.
@@ -1,56 +0,0 @@
1
- ---
2
- id: ADR-2026-08-27-writable-skills-overlay
3
- status: active
4
- introduced_by: PR-1857
5
- supersedes: []
6
- ---
7
-
8
- # A user-added skill layers over the bundled base and is trusted only by shadow
9
-
10
- Skills load from two roots. The bundled base is the read-only, version-pinned set
11
- shipped with the app. Over it sits a writable, per-user **overlay** at
12
- `<sdk-home>/skills`, resolved in `lib/tools/skills/overlay-dir.ts`
13
- from the `QVAC_CONFIG_PATH` env var that `configureSdkStorage` publishes on both
14
- runtimes — read via env rather than importing from `assistant`, which would be a
15
- circular dependency, and needing no new caller argument, so desktop and mobile
16
- provide it just by configuring storage. `loadSkillCatalog` in
17
- `lib/tools/skills/catalog.ts` reads both roots and merges them.
18
-
19
- ## Decision
20
-
21
- **The merge key is the folder name, and the overlay wins.** A skill's identity is
22
- its folder, not its front-matter `name` — because the `skill` tool reads a skill by
23
- folder path (`/<name>/SKILL.md`, `lib/tools/skill.ts`). Keying the
24
- catalog on the folder makes the merge key and the on-disk read lookup the same key,
25
- so they can never disagree. (Keying on `meta.name` did not: an overlay `gmail/`
26
- declaring `name: pirate` would file itself as a _new_, untrusted `pirate` entry
27
- while its files still shadowed the trusted base `gmail` at read time — so loading
28
- `gmail` served the overlay body under the base's grants, bypassing the merge below.
29
- A divergent `meta.name` is now ignored.) An overlay folder matching a bundled folder
30
- _shadows_ it; any other folder is a _new_ skill.
31
-
32
- **A new overlay skill is untrusted.** A skill's front matter is a grant: loading it
33
- unlocks tools, widens the credential egress allow-list, and classifies MCP methods
34
- as reads. A file anyone can drop on disk must not do those things silently. So a
35
- new overlay skill is excluded from `credentialGrants`
36
- (`lib/tools/skills/credential-grants.ts`) and from `mcpReadMethods`
37
- (`catalog.ts`) — it still loads and runs, but its http/exec calls go through the
38
- normal per-call approval gate, and its declared reads count as writes. This is the
39
- `origin: 'overlay'` marker, which is local-only and never crosses the wire.
40
-
41
- **A shadow overrides instructions, never capability.** Editing a first-party skill
42
- is the point of the overlay, but the edit must not be able to re-arm it. `mergeOverlay`
43
- takes the overlay copy's body and display metadata but pins its `allowList`,
44
- `credentials`, `mcpReads`, and `platform` to the trusted base. So an edit can't add
45
- an egress host, can't break the skill's auth by dropping a field, and can't claim an
46
- OS the base doesn't support — and a shadow is _not_ marked `origin: 'overlay'`,
47
- because its capability surface is the base's.
48
-
49
- **An undeclared platform means "here", not "nowhere".** The base treats a skill with
50
- no `platform` as supported nowhere, because every bundled skill lists its platforms
51
- explicitly. A user dropping a bare `SKILL.md` (e.g. one from the Claude or Codex
52
- skills repos) does not know that incantation, and requiring it would mean every
53
- such skill shows as "not available on this device". So a _new_ overlay skill with no
54
- declared platform defaults to every platform the app targets; the other gates
55
- (missing tools, model capability) still apply, so this only lifts the OS gate. This
56
- is what makes "drop a folder and it works" true.
@@ -1,53 +0,0 @@
1
- ---
2
- id: ADR-2026-09-01-skill-operation-manifests
3
- status: active
4
- introduced_by: PR-000
5
- supersedes: []
6
- ---
7
-
8
- # A provider write is a compiled skill operation, not a shaping hook in `http_request`
9
-
10
- ## Decision
11
-
12
- A skill declares its write operations in a sibling `operations.json` manifest. The harness compiles
13
- each entry into a typed tool (`gmail_send`, `gmail_draft`, `calendar_create_meet_event`,
14
- `calendar_add_meet`) that validates arguments, builds the provider payload, and delegates the call to
15
- `http_request`. `http_request` itself carries no provider knowledge: the per-host request/response
16
- shaping registry is removed, and an oversized JSON response is reduced by a generic per-node pruner.
17
-
18
- A compiled tool is owned by the skill whose bundled manifest defined it. The tool gate matches on the
19
- owning skill, not the tool name, so a sibling skill or a writable-overlay skill that declares the same
20
- name in `tools:` cannot expose it. A writable overlay never contributes operations of its own; editing
21
- a bundled skill keeps the base manifest's tools, exactly as it keeps the base credentials and
22
- allow-list.
23
-
24
- ## Context
25
-
26
- Gmail, Calendar, and Docs each needed request shaping (RFC 2822 assembly, base64url, conference
27
- requests) and response shaping. That knowledge lived inside the generic `http_request` tool as a
28
- host-keyed registry, so every new provider widened a shared tool, and the model still had to write raw
29
- URLs and bodies for a write it could not verify.
30
-
31
- Shaping also meant the response cap was provider-specific. A provider with no registry entry got a raw
32
- character slice, which cuts JSON mid-token and drops shallow identifiers the model needs next.
33
-
34
- ## Alternatives Considered
35
-
36
- - **Keep the shaping registry, add a provider per file.** Rejected: provider coupling in a generic
37
- tool, and it never gives the model a typed schema for the write.
38
- - **Let an overlay skill ship its own `operations.json`.** Rejected: a user-editable file would mint a
39
- tool with the base skill's credentials — privilege escalation by rename.
40
-
41
- ## Consequences
42
-
43
- - A new provider write is a manifest entry plus a builder, never an `http_request` change.
44
- - Every operation tool is `externalWrite`, non-auto-approved, and skill-gated; it passes through the
45
- same egress guard, private-IP check, and approval path as a raw `http_request`.
46
- - Response truncation is uniform: parseable JSON in, parseable JSON out, for every host.
47
- - The manifest describes one HTTP call. Multi-step or paginated flows stay in skill instructions.
48
- - Revisit if a provider needs a write whose payload cannot be expressed by a named builder.
49
-
50
- ## When Agents Should Read This
51
-
52
- Read this when changing the paths, workflows, runtime boundaries, or platform
53
- behavior listed for this ADR in `docs/decisions/INDEX.md`.