@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.
- package/dist/harness.bundle +70 -16
- package/dist/prebuilds-manifest.json +8 -0
- package/dist-lib/index.d.ts +2 -1
- package/dist-lib/index.js +60 -59
- package/dist-lib/lib/audio-transcode-bare.js +2 -2
- package/dist-lib/lib/capabilities/completion.d.ts +35 -2
- package/dist-lib/lib/capabilities/completion.js +114 -48
- package/dist-lib/lib/capabilities/download.js +2 -2
- package/dist-lib/lib/capabilities/speech-text.js +1 -1
- package/dist-lib/lib/capabilities/tool-prompt-log.js +1 -1
- package/dist-lib/lib/capabilities/tool-selection.js +2 -2
- package/dist-lib/lib/capabilities/tool-turns.js +1 -1
- package/dist-lib/lib/capabilities/usage-breakdown.js +1 -1
- package/dist-lib/lib/capabilities/voice.js +3 -3
- package/dist-lib/lib/connect.js +12 -11
- package/dist-lib/lib/context-window.js +1 -1
- package/dist-lib/lib/contract-types.d.ts +1 -0
- package/dist-lib/lib/docx/index.js +3 -3
- package/dist-lib/lib/hardware/capability-probe.js +3 -3
- package/dist-lib/lib/hardware/gpu-probe-addon.js +2 -2
- package/dist-lib/lib/hardware/gpu-probe-entry.js +1 -1
- package/dist-lib/lib/helpers/markup-stream-filter.js +1 -1
- package/dist-lib/lib/helpers/run-outcome.js +1 -1
- package/dist-lib/lib/helpers/spawn-path.d.ts +1 -1
- package/dist-lib/lib/helpers/thinking.js +2 -2
- package/dist-lib/lib/helpers/tool-intent.js +1 -1
- package/dist-lib/lib/helpers/tool-label.js +3 -3
- package/dist-lib/lib/hooks/built-in/arg-repair.d.ts +1 -1
- package/dist-lib/lib/hooks/built-in/arg-repair.js +1 -1
- package/dist-lib/lib/hooks/built-in/egress.d.ts +1 -1
- package/dist-lib/lib/hooks/built-in/egress.js +2 -2
- package/dist-lib/lib/hooks/built-in/index.js +3 -3
- package/dist-lib/lib/hooks/built-in/skill-catalog.d.ts +1 -1
- package/dist-lib/lib/hooks/pipeline.js +4 -4
- package/dist-lib/lib/hooks/registry.js +1 -1
- package/dist-lib/lib/hooks/run.js +1 -1
- package/dist-lib/lib/models/model-catalog.js +4 -4
- package/dist-lib/lib/models/model-delete.js +1 -1
- package/dist-lib/lib/models/model-handle.js +1 -1
- package/dist-lib/lib/models/model-list.js +1 -1
- package/dist-lib/lib/models/model-pool.js +8 -8
- package/dist-lib/lib/models/model-source.js +1 -1
- package/dist-lib/lib/models/music-catalog.d.ts +1 -1
- package/dist-lib/lib/no-redirect-fetch-bare.js +3 -3
- package/dist-lib/lib/no-redirect-fetch-impl.js +1 -1
- package/dist-lib/lib/no-redirect-fetch-node.js +2 -2
- package/dist-lib/lib/resolution-detector.js +1 -1
- package/dist-lib/lib/sdk-loader.js +2 -2
- package/dist-lib/lib/sdk-storage.js +1 -1
- package/dist-lib/lib/serve.js +11 -10
- package/dist-lib/lib/spawn.js +3 -3
- package/dist-lib/lib/staging.js +1 -1
- package/dist-lib/lib/tools/catalog.js +1 -1
- package/dist-lib/lib/tools/create-documents.js +2 -2
- package/dist-lib/lib/tools/edit-documents.js +2 -2
- package/dist-lib/lib/tools/edit-image.js +2 -2
- package/dist-lib/lib/tools/exec/adapters/python/attachments.js +2 -2
- package/dist-lib/lib/tools/exec/adapters/python/create-effect.js +1 -1
- package/dist-lib/lib/tools/exec/adapters/python/loader.js +1 -1
- package/dist-lib/lib/tools/exec/adapters/python/memfs.js +2 -2
- package/dist-lib/lib/tools/exec/adapters/python/pyodide.js +6 -6
- package/dist-lib/lib/tools/exec/adapters/python/python-adapter.js +3 -3
- package/dist-lib/lib/tools/exec/adapters/python/run-session.js +5 -5
- package/dist-lib/lib/tools/exec/adapters/python/wheels.js +1 -1
- package/dist-lib/lib/tools/exec/adapters/python/worker/client.js +3 -3
- package/dist-lib/lib/tools/exec/adapters/python/worker/entry.js +4 -4
- package/dist-lib/lib/tools/exec/adapters/shell/shell-adapter.js +1 -1
- package/dist-lib/lib/tools/exec/obsidian/cli-process.js +2 -2
- package/dist-lib/lib/tools/exec/obsidian/cli.js +4 -4
- package/dist-lib/lib/tools/exec/obsidian/command.js +1 -1
- package/dist-lib/lib/tools/exec/obsidian/route.js +7 -7
- package/dist-lib/lib/tools/exec/obsidian/vault-create.js +2 -2
- package/dist-lib/lib/tools/exec/obsidian/vault-fs.js +6 -6
- package/dist-lib/lib/tools/exec/obsidian/vault-paths.js +2 -2
- package/dist-lib/lib/tools/exec/obsidian/vault-read.js +1 -1
- package/dist-lib/lib/tools/exec/obsidian/vault-write.js +2 -2
- package/dist-lib/lib/tools/exec/runner.js +1 -1
- package/dist-lib/lib/tools/exec.js +13 -13
- package/dist-lib/lib/tools/file-glob.js +3 -3
- package/dist-lib/lib/tools/fs/local.js +1 -1
- package/dist-lib/lib/tools/fs/remote.js +1 -1
- package/dist-lib/lib/tools/fs/walk.js +2 -2
- package/dist-lib/lib/tools/generate-image.js +4 -4
- package/dist-lib/lib/tools/generate-music.js +6 -6
- package/dist-lib/lib/tools/grep.js +6 -6
- package/dist-lib/lib/tools/http-request/truncate-response.js +1 -1
- package/dist-lib/lib/tools/http-request.js +9 -9
- package/dist-lib/lib/tools/mcp/utils.js +1 -1
- package/dist-lib/lib/tools/mcp-call.js +8 -8
- package/dist-lib/lib/tools/operations/builders.d.ts +1 -1
- package/dist-lib/lib/tools/operations/builders.js +1 -1
- package/dist-lib/lib/tools/operations/compile.js +1 -1
- package/dist-lib/lib/tools/operations/manifest.d.ts +16 -16
- package/dist-lib/lib/tools/operations/manifest.js +1 -1
- package/dist-lib/lib/tools/outbound-guard.js +1 -1
- package/dist-lib/lib/tools/rag-search.js +1 -1
- package/dist-lib/lib/tools/read-files.js +3 -3
- package/dist-lib/lib/tools/skill.js +8 -8
- package/dist-lib/lib/tools/skills/auth-resolver.js +2 -2
- package/dist-lib/lib/tools/skills/bin-check.js +1 -1
- package/dist-lib/lib/tools/skills/bundled-dir.js +2 -2
- package/dist-lib/lib/tools/skills/catalog.js +10 -10
- package/dist-lib/lib/tools/skills/cli-schema-check.js +1 -1
- package/dist-lib/lib/tools/skills/cli-schema-load.js +1 -1
- package/dist-lib/lib/tools/skills/cli-schema.js +4 -4
- package/dist-lib/lib/tools/skills/credential-check.js +3 -3
- package/dist-lib/lib/tools/skills/credential-grants.js +1 -1
- package/dist-lib/lib/tools/skills/credential-health.js +2 -2
- package/dist-lib/lib/tools/skills/frontmatter-parser.js +1 -1
- package/dist-lib/lib/tools/skills/index.js +5 -5
- package/dist-lib/lib/tools/skills/mention.js +1 -1
- package/dist-lib/lib/tools/skills/overlay-crud.js +1 -1
- package/dist-lib/lib/tools/skills/setup-routes.js +1 -1
- package/dist-lib/lib/tools/skills/skill-settings.js +1 -1
- package/dist-lib/lib/tools/skills/skill-state-constants.d.ts +8 -8
- package/dist-lib/lib/tools/skills/skill-state.js +9 -9
- package/dist-lib/lib/tools/toolsets.d.ts +1 -1
- package/dist-lib/lib/tools/url-guard.js +2 -2
- package/dist-lib/lib/tools/weather-lookup/place.d.ts +11 -0
- package/dist-lib/lib/tools/weather-lookup/place.js +152 -0
- package/dist-lib/lib/tools/weather-lookup/report.d.ts +37 -0
- package/dist-lib/lib/tools/weather-lookup/report.js +56 -0
- package/dist-lib/lib/tools/weather-lookup.d.ts +8 -0
- package/dist-lib/lib/tools/weather-lookup.js +110 -0
- package/dist-lib/lib/tools/web-fetch.js +10 -10
- package/dist-lib/lib/tools/web-search.js +2 -2
- package/dist-lib/lib/transport.js +1 -1
- package/dist-lib/lib/validating-lookup.js +1 -1
- package/dist-lib/lib/web-fetch/embedded-state.js +1 -1
- package/dist-lib/lib/web-fetch/extract.js +1 -1
- package/dist-lib/lib/web-fetch/images.js +1 -1
- package/dist-lib/lib/web-fetch/video.js +1 -1
- package/dist-lib/lib/web-search/engine-registry.js +7 -7
- package/dist-lib/lib/web-search/engines/aol.js +3 -3
- package/dist-lib/lib/web-search/engines/bing.js +4 -4
- package/dist-lib/lib/web-search/engines/brave.js +2 -2
- package/dist-lib/lib/web-search/engines/duckduckgo.js +3 -3
- package/dist-lib/lib/web-search/engines/google.js +4 -4
- package/dist-lib/lib/web-search/engines/wikipedia.js +2 -2
- package/dist-lib/lib/web-search/engines/yahoo.js +3 -3
- package/dist-lib/lib/web-search/fetch-html.js +1 -1
- package/dist-lib/lib/web-search/merge-results.js +1 -1
- package/dist-lib/lib/web-search/orchestrator.js +3 -3
- package/dist-lib/lib/wire.js +28 -0
- package/dist-lib/schema/baseline.js +1 -1
- package/dist-lib/schema/build.js +3 -3
- package/dist-lib/schema/harness.js +5 -2
- package/dist-lib/schema/release-tags.d.ts +2 -1
- package/dist-lib/schema/release-tags.js +9 -7
- package/dist-lib/spec/hrpc/messages.js +22 -7
- package/dist-lib/spec/hyperschema/index.js +22 -7
- package/dist-lib/spec/hyperschema/schema.json +14 -1
- package/dist-lib/spec/hyperschema/types.d.ts +3 -2
- package/package.json +21 -8
- package/worker/harness-sidecar.mjs +2 -0
- package/.claude/skills/asana/SKILL.md +0 -86
- package/.claude/skills/gh-stack/SKILL.md +0 -181
- package/.claude/skills/gh-stack/references/commands.md +0 -177
- package/.claude/skills/gh-stack/references/stack-design.md +0 -97
- package/.claude/skills/gh-stack/references/troubleshooting.md +0 -159
- package/.claude/skills/js-house-style/SKILL.md +0 -671
- package/.claude/skills/kvcache/SKILL.md +0 -171
- package/.claude/skills/pr-create/SKILL.md +0 -180
- package/.claude/skills/pr-review/SKILL.md +0 -217
- package/CLAUDE.md +0 -39
- package/docs/decisions/2026-05-29-kvcache-tool-turns.md +0 -75
- package/docs/decisions/2026-06-02-kvcache-tool-turns-reenabled.md +0 -127
- package/docs/decisions/2026-06-23-android-cpu-llm-defaults.md +0 -126
- package/docs/decisions/2026-07-29-assistant-owned-storage-layout.md +0 -88
- package/docs/decisions/2026-08-04-sidecar-orphan-reaping.md +0 -79
- package/docs/decisions/2026-08-11-pyodide-worker-thread.md +0 -68
- package/docs/decisions/2026-08-17-harness-hook-pipeline.md +0 -84
- package/docs/decisions/2026-08-17-harness-outbound-egress-guard.md +0 -176
- package/docs/decisions/2026-08-20-chat-prompt-prefix-is-stable.md +0 -95
- package/docs/decisions/2026-08-20-kvcache-static-placement.md +0 -109
- package/docs/decisions/2026-08-21-vendored-python-wheels.md +0 -85
- package/docs/decisions/2026-08-26-exec-is-not-gated-by-skills.md +0 -64
- package/docs/decisions/2026-08-27-writable-skills-overlay.md +0 -56
- package/docs/decisions/2026-09-01-skill-operation-manifests.md +0 -53
- package/docs/decisions/2026-09-01-usage-breakdown-is-measured.md +0 -78
- package/docs/decisions/2026-09-03-usage-breakdown-reported-by-the-harness.md +0 -136
- package/docs/decisions/INDEX.md +0 -38
- package/docs/decisions/TEMPLATE.md +0 -32
- 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`.
|