@agenthippo-ai/agenthippo 0.2.3-dataplane → 1.110.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 (130) hide show
  1. package/out/analytics/dashboard/provider.js +13 -2
  2. package/out/analytics/dashboard/webview/panel.js +3 -2
  3. package/out/analytics/identity.js +0 -0
  4. package/out/analytics/service.js +23 -25
  5. package/out/analytics/sessionEvents.js +98 -23
  6. package/out/auth/authBaseUrl.js +59 -0
  7. package/out/auth/bootstrapApply.js +9 -10
  8. package/out/auth/brokerClient.js +48 -14
  9. package/out/auth/provider.js +8 -21
  10. package/out/auth/sessionStore.js +3 -40
  11. package/out/auth/signInFlow.js +2 -1
  12. package/out/auth-broker/config.js +50 -5
  13. package/out/auth-broker/server.js +122 -26
  14. package/out/auth-broker/store.js +25 -24
  15. package/out/authority/warrant.js +206 -0
  16. package/out/branding.js +27 -1
  17. package/out/cli/commands/conformance.js +249 -0
  18. package/out/cli/commands/data-plane.js +67 -0
  19. package/out/cli/commands/deploy.js +1 -0
  20. package/out/cli/commands/gateway.js +4 -0
  21. package/out/cli/commands/pack.js +182 -3
  22. package/out/cli/commands/sandbox.js +332 -0
  23. package/out/cli/commands/serve.js +875 -52
  24. package/out/cli/commands/store.js +12 -4
  25. package/out/cli/data-plane/gateway.js +28 -5
  26. package/out/cli/data-plane/index.js +17 -2
  27. package/out/cli/data-plane/registry.js +9 -0
  28. package/out/cli/data-plane/remote.js +106 -0
  29. package/out/cli/data-plane/sidecar.js +161 -0
  30. package/out/cli/deploy/layers/identity.js +85 -4
  31. package/out/cli/deploy-init.js +89 -24
  32. package/out/cli/deps.js +106 -2
  33. package/out/cli/idp-init.js +35 -0
  34. package/out/cli/index.js +155 -16
  35. package/out/cli/pack-install-ref.js +15 -0
  36. package/out/cli/pack-loader.js +8 -1
  37. package/out/cli/pack-runtime.js +61 -0
  38. package/out/cli/runtime.js +35 -1
  39. package/out/cli/serve-hardening.js +80 -3
  40. package/out/cli/utils.js +12 -1
  41. package/out/engines/base.js +32 -3
  42. package/out/engines/claude.js +33 -15
  43. package/out/engines/codex.js +110 -26
  44. package/out/engines/custom.js +8 -0
  45. package/out/evidence/sessionShip.js +610 -0
  46. package/out/extension.js +11 -2
  47. package/out/gateway/bridge.js +40 -2
  48. package/out/gateway/client.js +34 -1
  49. package/out/gateway/installSource.js +68 -0
  50. package/out/gateway/lifecycle.js +185 -51
  51. package/out/gateway/outboundPaths.js +4 -1
  52. package/out/gateway/secretsPreflight.js +90 -0
  53. package/out/gateway/service.js +7 -2
  54. package/out/headless/index.js +17 -1
  55. package/out/live-e2e/mcp-demo/server.mjs +32 -0
  56. package/out/mcp.js +30 -0
  57. package/out/pack/caps.js +271 -0
  58. package/out/pack/governor.js +309 -0
  59. package/out/pack/lease-schema.js +270 -0
  60. package/out/pack/lease.js +640 -0
  61. package/out/pack/reconcile.js +217 -0
  62. package/out/pack/signature.js +839 -0
  63. package/out/remote/handleRemoteChat.js +44 -3
  64. package/out/remote/remoteTrust.js +277 -0
  65. package/out/remote/serveClient.js +52 -5
  66. package/out/runner/host.js +131 -0
  67. package/out/runner/protocol.js +108 -0
  68. package/out/runner/remoteEngine.js +130 -0
  69. package/out/runtime/interfaces/edit-tracker.js +2 -0
  70. package/out/runtime/vscode/vscode-edit-tracker.js +2 -0
  71. package/out/sandbox/containerDriver.js +570 -0
  72. package/out/sandbox/processDriver.js +315 -0
  73. package/out/sandbox/sandboxRunner.js +191 -0
  74. package/out/sandbox/support.js +301 -0
  75. package/out/sandbox/types.js +55 -0
  76. package/out/scripts/agentide-litellm.sh +67 -5
  77. package/out/scripts/agentide_litellm_hooks_v1.py +182 -21
  78. package/out/scripts/analytics/agentide-litellm-otel.config.yaml +94 -132
  79. package/out/scripts/analytics/setup-analytics.ps1 +37 -13
  80. package/out/scripts/analytics/setup-analytics.sh +93 -13
  81. package/out/scripts/analytics/start-analytics.sh +86 -14
  82. package/out/scripts/gateway/setup-gateway.sh +72 -32
  83. package/out/shared/models.js +11 -110
  84. package/out/spotlight/install.js +7 -1
  85. package/out/store/index.js +41 -10
  86. package/out/sync.js +3 -1
  87. package/out/test/analytics.test.js +162 -23
  88. package/out/test/auth-baseurl.test.js +132 -0
  89. package/out/test/auth-bootstrapApply.test.js +27 -6
  90. package/out/test/auth-broker-hardening.test.js +227 -0
  91. package/out/test/auth-broker.test.js +59 -9
  92. package/out/test/branding.test.js +13 -0
  93. package/out/test/caps-governor.test.js +455 -0
  94. package/out/test/codex-new-file-survival.test.js +119 -0
  95. package/out/test/configuration-scopes.test.js +131 -0
  96. package/out/test/data-plane.test.js +67 -0
  97. package/out/test/deploy-init.test.js +40 -0
  98. package/out/test/gateway-client.test.js +28 -0
  99. package/out/test/gateway-install-source.test.js +111 -0
  100. package/out/test/gateway-lifecycle.test.js +1 -1
  101. package/out/test/gateway-outbound.test.js +7 -0
  102. package/out/test/gateway-secrets-preflight.test.js +127 -0
  103. package/out/test/gateway-session-routing.test.js +18 -0
  104. package/out/test/governor-hardening.test.js +217 -0
  105. package/out/test/headless-sync.test.js +22 -0
  106. package/out/test/lease-signature.test.js +357 -0
  107. package/out/test/lease-trust-root.test.js +273 -0
  108. package/out/test/lease-validation.test.js +169 -0
  109. package/out/test/lease.test.js +114 -0
  110. package/out/test/mcp.test.js +27 -1
  111. package/out/test/pack-admission.test.js +216 -0
  112. package/out/test/pack-digest-parity.test.js +140 -0
  113. package/out/test/pack-reconcile.test.js +183 -0
  114. package/out/test/pack-signature-artifact.test.js +103 -0
  115. package/out/test/pack-signature.test.js +176 -0
  116. package/out/test/pack-trust-root.test.js +202 -0
  117. package/out/test/remote-trust.test.js +242 -0
  118. package/out/test/runner-protocol.test.js +249 -0
  119. package/out/test/sandbox-driver.test.js +553 -0
  120. package/out/test/sandbox-hardening.test.js +395 -0
  121. package/out/test/sandbox-runner.test.js +343 -0
  122. package/out/test/serve-hardening.test.js +45 -6
  123. package/out/test/serveClient.test.js +6 -5
  124. package/out/test/sessionShip.test.js +560 -0
  125. package/out/test/spotlightPublish.test.js +28 -0
  126. package/out/test/store-install-safe.test.js +69 -0
  127. package/out/test/tls-bypass.test.js +90 -0
  128. package/out/test/warrant.test.js +138 -0
  129. package/out/utils.js +46 -9
  130. package/package.json +1 -1
@@ -0,0 +1,839 @@
1
+ "use strict";
2
+ /*---------------------------------------------------------------------------------------------
3
+ * Copyright (c) AgentHippo.ai. All rights reserved.
4
+ *--------------------------------------------------------------------------------------------*/
5
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
6
+ if (k2 === undefined) k2 = k;
7
+ var desc = Object.getOwnPropertyDescriptor(m, k);
8
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
9
+ desc = { enumerable: true, get: function() { return m[k]; } };
10
+ }
11
+ Object.defineProperty(o, k2, desc);
12
+ }) : (function(o, m, k, k2) {
13
+ if (k2 === undefined) k2 = k;
14
+ o[k2] = m[k];
15
+ }));
16
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
17
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
18
+ }) : function(o, v) {
19
+ o["default"] = v;
20
+ });
21
+ var __importStar = (this && this.__importStar) || (function () {
22
+ var ownKeys = function(o) {
23
+ ownKeys = Object.getOwnPropertyNames || function (o) {
24
+ var ar = [];
25
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
26
+ return ar;
27
+ };
28
+ return ownKeys(o);
29
+ };
30
+ return function (mod) {
31
+ if (mod && mod.__esModule) return mod;
32
+ var result = {};
33
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
34
+ __setModuleDefault(result, mod);
35
+ return result;
36
+ };
37
+ })();
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.PACK_FULL_REVERIFY_INTERVAL_MS = exports.PACK_PROBE_INTERVAL_MS = exports.LEASE_JWKS_PINS_ENV = exports.LEASE_JWKS_URL_ENV = exports.LEASE_SIGNING_KEYS_ENV = exports.ALLOW_INSECURE_TRUST_ROOT_ENV = exports.PACK_JWKS_PINS_ENV = exports.PACK_JWKS_URL_ENV_LEGACY = exports.PACK_JWKS_URL_ENV = exports.ALLOW_UNSIGNED_PACK_ENV = exports.DIGEST_EXCLUDED_SEGMENTS = exports.PACK_SIGNATURE_FILE = void 0;
40
+ exports.isDigestExcluded = isDigestExcluded;
41
+ exports.computePackDigestFromFiles = computePackDigestFromFiles;
42
+ exports.computePackDigest = computePackDigest;
43
+ exports.packTreeProbe = packTreeProbe;
44
+ exports.signPack = signPack;
45
+ exports.readPackSignature = readPackSignature;
46
+ exports.verifyPack = verifyPack;
47
+ exports.verifyPackFromFiles = verifyPackFromFiles;
48
+ exports.requireSignedPack = requireSignedPack;
49
+ exports.resolveRequireSignedPack = resolveRequireSignedPack;
50
+ exports.allowUnsignedPackFromEnv = allowUnsignedPackFromEnv;
51
+ exports.describePackRefusal = describePackRefusal;
52
+ exports.publicKeyThumbprints = publicKeyThumbprints;
53
+ exports.parseJwksPins = parseJwksPins;
54
+ exports.assertSecureTrustUrl = assertSecureTrustUrl;
55
+ exports.loadPackTrustRoot = loadPackTrustRoot;
56
+ exports.loadLeaseTrustRoot = loadLeaseTrustRoot;
57
+ exports.selectLeaseSigningKeys = selectLeaseSigningKeys;
58
+ exports.loadPackSigningKeys = loadPackSigningKeys;
59
+ exports.createPackVerifier = createPackVerifier;
60
+ /**
61
+ * Pack signing — the gate that has to exist before a control plane may ever FETCH an artifact.
62
+ *
63
+ * A reconcile loop that pulls a pack and runs it is a remote-code-execution feature until a
64
+ * signature decides what it is allowed to run. So this lands first, on its own, and the pull
65
+ * comes after: verification with no fetch is merely unused; a fetch with no verification is a
66
+ * vulnerability.
67
+ *
68
+ * Shape, and why:
69
+ *
70
+ * digest — sha256 over the pack's files, path AND content, sorted, each length-prefixed.
71
+ * Length prefixes matter: without them `a/b` + `c` and `a/bc` + `''` hash the same,
72
+ * so a rename could be laundered into an identical digest.
73
+ * signature — a compact JWS (ES256) whose payload names the pack, the version, and that digest.
74
+ * Binding all three is what stops a valid signature being replayed onto a different
75
+ * pack or a different version of the same one — a bare content signature would let
76
+ * an attacker who can place files claim any name they like.
77
+ * keys — verified with PUBLIC halves only, distributed by the control plane's existing
78
+ * JWKS endpoint (which refuses private material). Whoever verifies cannot sign,
79
+ * which is the same asymmetry the inference warrants rely on.
80
+ *
81
+ * Deliberately NOT expiring: a warrant is turn-short because it authorises spend, but a pack
82
+ * signature attests to content that does not rot. An expiry here would ground a fleet on a
83
+ * calendar boundary for no security gain.
84
+ */
85
+ const crypto = __importStar(require("node:crypto"));
86
+ const fs = __importStar(require("node:fs"));
87
+ const path = __importStar(require("node:path"));
88
+ const warrant_1 = require("../authority/warrant");
89
+ /** Written at the pack root. Excluded from the digest — it cannot cover itself. */
90
+ exports.PACK_SIGNATURE_FILE = 'pack.sig';
91
+ /**
92
+ * Path segments never hashed, at ANY depth: VCS metadata and installed dependencies.
93
+ *
94
+ * This list used to also carry `.claude`, `.codex`, `.agent-hippo` and `.DS_Store`, and the walk
95
+ * tested it on every directory entry rather than only at the top level — despite a comment
96
+ * claiming "top-level". The result was a hole big enough to drive a payload through:
97
+ * `.claude/settings.json` is where Claude Code reads its HOOKS from, i.e. arbitrary shell commands
98
+ * that run around every tool call. A pack with an injected `PreToolUse` hook verified clean, and
99
+ * `agenthippo pack verify` printed OK. The same was true of `.codex/` and `.agent-hippo/`.
100
+ *
101
+ * Executable content is now IN the digest. The cost is real and accepted: anything that writes
102
+ * into a pack directory after signing (including macOS writing `.DS_Store` when someone opens the
103
+ * folder in Finder) invalidates the signature, and the pack must be re-signed. That is the correct
104
+ * direction to fail — "the tree changed since it was signed" is exactly what the signature is for,
105
+ * and the failure says so.
106
+ *
107
+ * `.git` and `node_modules` remain excluded at every depth because they are not pack content: a
108
+ * checkout's git objects differ per clone, and dependencies are installed, not authored. Excluding
109
+ * them at every depth (rather than only the top level) is deliberate — a nested `node_modules`
110
+ * from a workspace install is the same non-content.
111
+ */
112
+ exports.DIGEST_EXCLUDED_SEGMENTS = new Set(['.git', 'node_modules']);
113
+ /**
114
+ * The ONE predicate both digest paths use.
115
+ *
116
+ * `computePackDigest` (directory walk) and `computePackDigestFromFiles` (fetched artifact) MUST
117
+ * agree for identical trees, or a locally-signed pack can never verify through the store path —
118
+ * which is precisely what happened when the walk excluded seven names and the file list excluded
119
+ * one. There is a test asserting the two agree on a tree containing every excluded and formerly
120
+ * excluded name.
121
+ *
122
+ * @param relPosixPath path RELATIVE to the pack root, `/`-separated.
123
+ */
124
+ function isDigestExcluded(relPosixPath) {
125
+ if (relPosixPath === exports.PACK_SIGNATURE_FILE) {
126
+ return true;
127
+ }
128
+ return relPosixPath.split('/').some((seg) => exports.DIGEST_EXCLUDED_SEGMENTS.has(seg));
129
+ }
130
+ /**
131
+ * The single hashing routine. Sorted so two checkouts agree regardless of readdir order, and
132
+ * length-prefixed so no combination of names and contents can be rearranged into the same hash.
133
+ *
134
+ * Symlinks are hashed as their TARGET STRING rather than followed (following them would let a link
135
+ * resolving differently on another host produce the same digest for different bytes) and carry an
136
+ * `L:` domain tag so a symlink `a → b` cannot be swapped for a regular file `a` containing `b`.
137
+ * Regular-file framing is unchanged, so signatures over packs without symlinks still verify.
138
+ */
139
+ function hashEntries(entries) {
140
+ const hash = crypto.createHash('sha256');
141
+ const sorted = [...entries].sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
142
+ for (const entry of sorted) {
143
+ if (entry.link) {
144
+ hash.update('L:');
145
+ }
146
+ const rel = Buffer.from(entry.rel);
147
+ // length-prefix both halves — see the header note on rename laundering
148
+ hash.update(`${rel.length}:`);
149
+ hash.update(rel);
150
+ hash.update(`${entry.body.length}:`);
151
+ hash.update(entry.body);
152
+ }
153
+ return hash.digest('hex');
154
+ }
155
+ /**
156
+ * Hash an artifact file list (a store download, or a materialised tree read back).
157
+ *
158
+ * Uses `isDigestExcluded`, exactly like the directory walk. It previously excluded only `pack.sig`
159
+ * while the walk excluded seven names, so any pack containing `.claude/`, `.codex/` or
160
+ * `.agent-hippo/` produced two different digests and could never verify through the store path.
161
+ */
162
+ function computePackDigestFromFiles(files) {
163
+ const entries = [];
164
+ for (const f of files) {
165
+ const rel = f.path.replace(/\\/g, '/');
166
+ if (isDigestExcluded(rel)) {
167
+ continue;
168
+ }
169
+ entries.push({ rel, body: f.content, link: f.link });
170
+ }
171
+ return hashEntries(entries);
172
+ }
173
+ /** Deterministic digest of a pack directory. */
174
+ function computePackDigest(packDir) {
175
+ const entries = [];
176
+ const walk = (dir, prefix) => {
177
+ for (const name of fs.readdirSync(dir)) {
178
+ const rel = prefix ? `${prefix}/${name}` : name;
179
+ if (isDigestExcluded(rel)) {
180
+ continue;
181
+ }
182
+ const abs = path.join(dir, name);
183
+ const st = fs.lstatSync(abs);
184
+ if (st.isSymbolicLink()) {
185
+ entries.push({ rel, body: Buffer.from(fs.readlinkSync(abs)), link: true });
186
+ }
187
+ else if (st.isDirectory()) {
188
+ walk(abs, rel);
189
+ }
190
+ else if (st.isFile()) {
191
+ entries.push({ rel, body: fs.readFileSync(abs) });
192
+ }
193
+ // Sockets, fifos and devices are not pack content; ignoring them keeps the digest
194
+ // stable across environments that happen to leave one behind.
195
+ }
196
+ };
197
+ walk(packDir, '');
198
+ return hashEntries(entries);
199
+ }
200
+ /**
201
+ * A CHEAP probe of a pack tree, for deciding whether a full re-verify is needed.
202
+ *
203
+ * Not a security boundary on its own — see `createPackVerifier`, which pairs it with a periodic
204
+ * unconditional re-verify. This walks the tree with `lstat` only: no file is read and nothing is
205
+ * hashed, so the cost is O(entries) syscalls rather than O(bytes) of SHA-256. On a typical pack
206
+ * (tens to low hundreds of files) that is sub-millisecond, which is what makes it acceptable on
207
+ * the turn path where a full digest is not.
208
+ *
209
+ * Covers: content edits (mtime + size), whole-file replacement (inode), permission changes (mode),
210
+ * and any addition or removal (the entry list itself).
211
+ */
212
+ function packTreeProbe(packDir) {
213
+ const parts = [];
214
+ const walk = (dir, prefix) => {
215
+ let names;
216
+ try {
217
+ names = fs.readdirSync(dir).sort();
218
+ }
219
+ catch {
220
+ parts.push(`${prefix}/?unreadable`);
221
+ return;
222
+ }
223
+ for (const name of names) {
224
+ const rel = prefix ? `${prefix}/${name}` : name;
225
+ if (isDigestExcluded(rel)) {
226
+ continue;
227
+ }
228
+ const abs = path.join(dir, name);
229
+ let st;
230
+ try {
231
+ st = fs.lstatSync(abs);
232
+ }
233
+ catch {
234
+ parts.push(`${rel}|gone`);
235
+ continue;
236
+ }
237
+ if (st.isDirectory()) {
238
+ walk(abs, rel);
239
+ continue;
240
+ }
241
+ parts.push(`${rel}|${st.size}|${st.mtimeMs}|${st.ino}|${st.mode}`);
242
+ }
243
+ };
244
+ try {
245
+ walk(packDir, '');
246
+ }
247
+ catch {
248
+ return 'unwalkable';
249
+ }
250
+ return crypto.createHash('sha256').update(parts.join('\n')).digest('hex');
251
+ }
252
+ /** Sign a pack in place: computes the digest and writes `pack.sig` at the pack root. */
253
+ function signPack(input) {
254
+ const digest = computePackDigest(input.packDir);
255
+ const claims = {
256
+ pack: input.pack,
257
+ packVersion: input.packVersion,
258
+ digest,
259
+ iat: input.nowSeconds ?? Math.floor(Date.now() / 1000),
260
+ };
261
+ const signature = (0, warrant_1.signCompactJws)({ typ: 'JWT', kid: input.kid }, claims, input.privateKeyPem);
262
+ fs.writeFileSync(path.join(input.packDir, exports.PACK_SIGNATURE_FILE), `${signature}\n`);
263
+ return { signature, digest };
264
+ }
265
+ function readPackSignature(packDir) {
266
+ try {
267
+ const raw = fs.readFileSync(path.join(packDir, exports.PACK_SIGNATURE_FILE), 'utf8').trim();
268
+ return raw || undefined;
269
+ }
270
+ catch {
271
+ return undefined;
272
+ }
273
+ }
274
+ /**
275
+ * Verify a pack against public keys only.
276
+ *
277
+ * Every failure is a refusal with a stated reason rather than a thrown error, because the caller
278
+ * is an admission decision: it needs to log precisely why a pack was rejected, and "unknown kid"
279
+ * and "content changed after signing" are very different operational problems.
280
+ */
281
+ function verifyPack(token, publicKeysByKid, input) {
282
+ return verifyPackAgainst(token, publicKeysByKid, input.pack, input.packVersion, () => computePackDigest(input.packDir));
283
+ }
284
+ /**
285
+ * Verify a downloaded artifact against its signature using the fetched file list, not a directory
286
+ * walk. Both paths hash through `isDigestExcluded`, so a tree and the file list it was built from
287
+ * produce the SAME digest — the property `pack-digest-parity.test.ts` pins.
288
+ */
289
+ function verifyPackFromFiles(token, publicKeysByKid, pack, packVersion, files) {
290
+ return verifyPackAgainst(token, publicKeysByKid, pack, packVersion, () => computePackDigestFromFiles(files));
291
+ }
292
+ /** The shared body of both verify paths — identity binding, then digest. */
293
+ function verifyPackAgainst(token, publicKeysByKid, pack, packVersion, actualDigest) {
294
+ if (Object.keys(publicKeysByKid).length === 0) {
295
+ // Distinct from every other refusal, and with a distinct remedy: nothing is wrong with the
296
+ // pack, this instance simply has nothing to check it against. Verifying against an empty
297
+ // keyring would otherwise report success for any signature at all.
298
+ return { ok: false, kind: 'no-trust-root', reason: 'no trusted pack-signing keys are configured on this instance' };
299
+ }
300
+ // alg pinning, kid-must-be-known and the raw R||S encoding all live in verifyCompactJws.
301
+ const jws = (0, warrant_1.verifyCompactJws)(token, publicKeysByKid);
302
+ if (!jws.ok) {
303
+ if (jws.reason === 'unknown kid') {
304
+ // Never trusted-on-first-use: accepting a key because it arrived with the artifact would
305
+ // let anyone who can write the pack also choose who vouches for it.
306
+ const kid = readUnverifiedKid(token);
307
+ return { ok: false, kind: 'unknown-key', reason: `unknown signing key ${kid ?? '(none named)'}` };
308
+ }
309
+ if (jws.reason === 'bad signature') {
310
+ return { ok: false, kind: 'bad-signature', reason: 'signature does not verify' };
311
+ }
312
+ if (jws.reason === 'malformed') {
313
+ return { ok: false, kind: 'malformed', reason: 'malformed signature' };
314
+ }
315
+ if (jws.reason === 'unparseable') {
316
+ return { ok: false, kind: 'malformed', reason: 'unreadable signature' };
317
+ }
318
+ return { ok: false, kind: 'malformed', reason: jws.reason };
319
+ }
320
+ const claims = jws.payload;
321
+ // Identity binding is checked BEFORE the digest: a signature that verifies cryptographically
322
+ // but names a different pack is an attempted replay, and saying so is more useful than
323
+ // reporting a digest mismatch.
324
+ if (claims.pack !== pack) {
325
+ return { ok: false, kind: 'identity-mismatch', reason: `signature is for pack ${claims.pack}, not ${pack}` };
326
+ }
327
+ if (claims.packVersion !== packVersion) {
328
+ return { ok: false, kind: 'identity-mismatch', reason: `signature is for ${claims.pack}@${claims.packVersion}, not @${packVersion}` };
329
+ }
330
+ if (typeof claims.digest !== 'string' || !claims.digest) {
331
+ return { ok: false, kind: 'malformed', reason: 'signature carries no digest' };
332
+ }
333
+ if (!(0, warrant_1.timingSafeEqualString)(actualDigest(), claims.digest)) {
334
+ return { ok: false, kind: 'digest-mismatch', reason: 'pack contents changed after signing' };
335
+ }
336
+ return { ok: true, claims };
337
+ }
338
+ /** The `kid` a token CLAIMS, for error messages only — never for a trust decision. */
339
+ function readUnverifiedKid(token) {
340
+ try {
341
+ const header = JSON.parse(Buffer.from(token.split('.')[0], 'base64url').toString('utf8'));
342
+ return header.kid;
343
+ }
344
+ catch {
345
+ return undefined;
346
+ }
347
+ }
348
+ /**
349
+ * Signature requirement, merged tighten-only.
350
+ *
351
+ * Either side may demand signed packs; neither may waive the other's demand. Same rule as caps,
352
+ * and for the same reason: a requirement an instance can switch off by editing its own flags is
353
+ * decorative, and this one gates code execution.
354
+ */
355
+ function requireSignedPack(leaseValue, localValue) {
356
+ return Boolean(leaseValue) || Boolean(localValue);
357
+ }
358
+ /** Escape hatch for local development. Loud on every boot; the lease can still override it. */
359
+ exports.ALLOW_UNSIGNED_PACK_ENV = 'AGENTHIPPO_ALLOW_UNSIGNED_PACK';
360
+ /**
361
+ * Is a verified signature REQUIRED for this pack to run?
362
+ *
363
+ * **Default: yes.** This flipped from opt-in. `requireSignedPack` used to be off unless an operator
364
+ * turned it on, so the shipped default was warn-and-run: serve logged
365
+ * `[Pack] unsigned or unverifiable — …` and then executed the pack anyway. A gate that logs and
366
+ * proceeds is not a gate, and the commit message "verify before run, fail closed" was not true of
367
+ * the default shape.
368
+ *
369
+ * Tighten-only is preserved exactly: the LEASE's demand is checked first and cannot be waived, so
370
+ * an instance can never opt out of a requirement the control plane made. The escape hatch only
371
+ * relaxes the instance's OWN default.
372
+ */
373
+ function resolveRequireSignedPack(opts) {
374
+ if (requireSignedPack(opts.lease, opts.localRequire)) {
375
+ return true;
376
+ }
377
+ return !opts.localAllowUnsigned;
378
+ }
379
+ /** Reads the escape hatch from the environment. `1`/`true`/`yes` enable it; anything else does not. */
380
+ function allowUnsignedPackFromEnv(env = process.env) {
381
+ const raw = env[exports.ALLOW_UNSIGNED_PACK_ENV]?.trim().toLowerCase();
382
+ return raw === '1' || raw === 'true' || raw === 'yes';
383
+ }
384
+ /**
385
+ * The operator-facing refusal.
386
+ *
387
+ * Written out longhand on purpose. Flipping the default to refuse WILL stop deployments that ran
388
+ * unsigned packs, and the difference between a good and a terrible version of that change is
389
+ * entirely in this function: an operator must learn from the message alone what is wrong, how to
390
+ * fix it properly, and how to get moving again right now.
391
+ */
392
+ function describePackRefusal(ctx) {
393
+ const id = ctx.packVersion ? `${ctx.pack}@${ctx.packVersion}` : ctx.pack;
394
+ const lines = [`refusing to run ${id}: ${ctx.detail}`];
395
+ switch (ctx.kind) {
396
+ case 'unsigned':
397
+ lines.push(` The pack directory has no ${exports.PACK_SIGNATURE_FILE}${ctx.packDir ? ` at ${ctx.packDir}` : ''}.`, ` Sign it: agenthippo pack sign ${ctx.pack} --key <private-key.pem> --kid <key-id>`, ' (generate a key with: agenthippo pack keygen -o key.pem, then publish its public half to JWKS)');
398
+ break;
399
+ case 'no-trust-root':
400
+ lines.push(' This is NOT a problem with the pack — this instance has no keys to check it against.', ' Configure ONE of:', ' AGENTHIPPO_PACK_SIGNING_KEYS inline JSON {"<kid>":"<public PEM>"} (air-gapped installs)', ` ${exports.PACK_JWKS_URL_ENV} https URL of the JWKS document publishing your signing keys`, ` ${exports.PACK_JWKS_PINS_ENV} optional, comma-separated sha256 thumbprints to pin those keys to`);
401
+ break;
402
+ case 'trust-root-unavailable':
403
+ lines.push(' A trust root IS configured but could not be loaded, so no signature can be checked.', ` ${ctx.trustRootHint ?? ''}`.trimEnd(), ' This is deliberately an error and not an empty keyring: "cannot reach the trust root" and', ' "no trust root configured" have different causes and different fixes.');
404
+ break;
405
+ case 'unknown-key':
406
+ lines.push(' The pack IS signed, but by a key this fleet does not trust.', ' Either publish that key to your JWKS / AGENTHIPPO_PACK_SIGNING_KEYS, or re-sign the pack', ' with a key that is already trusted. A key is never trusted just because it arrived with the pack.');
407
+ break;
408
+ case 'digest-mismatch':
409
+ lines.push(' The signature is valid but the pack tree no longer matches it — something changed after signing.', ' Note that .claude/, .codex/ and .agent-hippo/ are now INSIDE the digest (they carry hooks and', ' other executable content), so edits there invalidate a signature that used to survive them.', ` Re-sign after reviewing the diff: agenthippo pack sign ${ctx.pack} --key <private-key.pem> --kid <key-id>`);
410
+ break;
411
+ case 'identity-mismatch':
412
+ lines.push(' The signature is valid but names a different pack or version — a replayed signature, not this artifact.');
413
+ break;
414
+ case 'malformed':
415
+ case 'bad-signature':
416
+ lines.push(` ${exports.PACK_SIGNATURE_FILE} is present but unusable. Re-sign the pack; do not hand-edit it.`);
417
+ break;
418
+ case 'no-pack':
419
+ break;
420
+ }
421
+ lines.push(` To run unsigned packs anyway (LOCAL DEVELOPMENT ONLY), set ${exports.ALLOW_UNSIGNED_PACK_ENV}=1.`, ' A lease that sets requireSignedPack overrides that escape hatch — the control plane\'s demand cannot be waived locally.');
422
+ return lines.join('\n');
423
+ }
424
+ // ---------------------------------------------------------------------------------------------
425
+ // Trust root
426
+ // ---------------------------------------------------------------------------------------------
427
+ /** Dedicated JWKS URL. Pins the trust root INDEPENDENTLY of wherever the lease happens to live. */
428
+ exports.PACK_JWKS_URL_ENV = 'AGENTHIPPO_PACK_JWKS_URL';
429
+ /** Legacy name for the same thing; still honoured. */
430
+ exports.PACK_JWKS_URL_ENV_LEGACY = 'AGENTHIPPO_PACK_SIGNING_JWKS_URL';
431
+ /** Comma-separated sha256 thumbprints. Any key not matching one of these is refused. */
432
+ exports.PACK_JWKS_PINS_ENV = 'AGENTHIPPO_PACK_JWKS_PINS';
433
+ /** Local-dev escape hatch allowing a plaintext-http trust root / lease origin. Logs loudly. */
434
+ exports.ALLOW_INSECURE_TRUST_ROOT_ENV = 'AGENTHIPPO_ALLOW_INSECURE_TRUST_ROOT';
435
+ /**
436
+ * The LEASE trust root — the same three knobs, named the same way, one word different.
437
+ *
438
+ * Deliberately parallel to the pack names so an operator who has configured one can guess the
439
+ * other. They must be separate keyrings, not one: the control plane publishes the lease public key
440
+ * under purpose `lease-signing` and REFUSES to register a single kid under two purposes, so a
441
+ * correctly-configured plane and a serve that verified leases against the pack keyring could never
442
+ * agree on a key — and `requireSignedLease` is sticky with no plane-side undo, so that disagreement
443
+ * latches an instance into refusing turns. Separating the roots is also the right shape on its own:
444
+ * a lease-signing key that lives in the pack keyring is a key that can authorise code execution.
445
+ */
446
+ exports.LEASE_SIGNING_KEYS_ENV = 'AGENTHIPPO_LEASE_SIGNING_KEYS';
447
+ exports.LEASE_JWKS_URL_ENV = 'AGENTHIPPO_LEASE_JWKS_URL';
448
+ exports.LEASE_JWKS_PINS_ENV = 'AGENTHIPPO_LEASE_JWKS_PINS';
449
+ /**
450
+ * sha256 thumbprints of a public key, in both forms an operator is likely to have.
451
+ *
452
+ * jwk — RFC 7638 JWK thumbprint, base64url. What `openssl`-free tooling and most JWKS
453
+ * publishers produce, and what a `kid` is conventionally derived from.
454
+ * spki — sha256 over the DER SPKI, hex. What `openssl pkey -pubout -outform der | sha256sum`
455
+ * gives, which is what an operator with a PEM in hand will reach for first.
456
+ *
457
+ * A pin matching EITHER is accepted, with or without a `sha256:` prefix, case-insensitively.
458
+ * Publishing both costs nothing and removes an entire class of "my pin doesn't match" tickets.
459
+ */
460
+ function publicKeyThumbprints(publicKeyPem) {
461
+ const key = crypto.createPublicKey(publicKeyPem);
462
+ const jwk = key.export({ format: 'jwk' });
463
+ // RFC 7638: lexicographically ordered, whitespace-free JSON of the REQUIRED members only.
464
+ const required = jwk.kty === 'EC'
465
+ ? { crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y }
466
+ : jwk.kty === 'RSA'
467
+ ? { e: jwk.e, kty: jwk.kty, n: jwk.n }
468
+ : { kty: jwk.kty };
469
+ return {
470
+ jwk: crypto.createHash('sha256').update(JSON.stringify(required)).digest('base64url'),
471
+ spki: crypto.createHash('sha256').update(key.export({ type: 'spki', format: 'der' })).digest('hex'),
472
+ };
473
+ }
474
+ const normalisePin = (p) => p.trim().replace(/^sha256:/i, '').toLowerCase();
475
+ /** Parse the pin list. Empty/blank ⇒ no pinning. */
476
+ function parseJwksPins(raw) {
477
+ return (raw ?? '').split(',').map(normalisePin).filter((p) => p.length > 0);
478
+ }
479
+ /**
480
+ * Is this URL safe to fetch a TRUST ROOT from?
481
+ *
482
+ * The keyring that decides which code may run must not be fetched over a channel any on-path
483
+ * device can rewrite. https is required; the escape hatch exists because a local control plane on
484
+ * `http://localhost` is a real development shape, and it is deliberately loud.
485
+ */
486
+ function assertSecureTrustUrl(url, what, env = process.env) {
487
+ let parsed;
488
+ try {
489
+ parsed = new URL(url);
490
+ }
491
+ catch {
492
+ return { ok: false, reason: `${what} is not a valid URL: ${url}` };
493
+ }
494
+ if (parsed.protocol === 'https:') {
495
+ return { ok: true, warnings: [] };
496
+ }
497
+ const allowed = (env[exports.ALLOW_INSECURE_TRUST_ROOT_ENV] ?? '').trim().toLowerCase();
498
+ if (allowed === '1' || allowed === 'true' || allowed === 'yes') {
499
+ return {
500
+ ok: true,
501
+ warnings: [
502
+ `INSECURE: ${what} is ${parsed.protocol}//${parsed.host} (not https) and ` +
503
+ `${exports.ALLOW_INSECURE_TRUST_ROOT_ENV} is set. Anyone on the path between this instance and that ` +
504
+ 'host can choose which packs it will run and whether the kill switch is honoured. ' +
505
+ 'This must never be set outside local development.',
506
+ ],
507
+ };
508
+ }
509
+ return {
510
+ ok: false,
511
+ reason: `${what} must be https (got ${parsed.protocol}//${parsed.host}). ` +
512
+ `Set ${exports.ALLOW_INSECURE_TRUST_ROOT_ENV}=1 to allow plaintext for LOCAL DEVELOPMENT ONLY.`,
513
+ };
514
+ }
515
+ const PACK_TRUST_ROOT_SPEC = {
516
+ label: 'pack',
517
+ purpose: 'pack-signing',
518
+ inlineEnv: 'AGENTHIPPO_PACK_SIGNING_KEYS',
519
+ urlEnvs: [exports.PACK_JWKS_URL_ENV, exports.PACK_JWKS_URL_ENV_LEGACY],
520
+ pinsEnv: exports.PACK_JWKS_PINS_ENV,
521
+ derivedRisk: 'chooses which packs this instance will run',
522
+ };
523
+ const LEASE_TRUST_ROOT_SPEC = {
524
+ label: 'lease',
525
+ purpose: 'lease-signing',
526
+ inlineEnv: exports.LEASE_SIGNING_KEYS_ENV,
527
+ urlEnvs: [exports.LEASE_JWKS_URL_ENV],
528
+ pinsEnv: exports.LEASE_JWKS_PINS_ENV,
529
+ derivedRisk: "chooses whether this instance's kill switch is ever honoured",
530
+ };
531
+ /**
532
+ * Public halves used to verify one class of signature.
533
+ *
534
+ * Three sources, in precedence order:
535
+ * 1. `<…>_SIGNING_KEYS` — inline kid→PEM JSON. Air-gapped installs; no network at all.
536
+ * 2. `<…>_JWKS_URL` (plus, for packs, the legacy `…_SIGNING_JWKS_URL`) — an EXPLICIT trust root.
537
+ * 3. `/.well-known/jwks.json?purpose=<purpose>` on the lease origin — back-compat, reported weaker.
538
+ *
539
+ * (3) is why this function exists in this shape. The whole governance story — kill switch, deny
540
+ * rules, caps, version pin, pack signing — was rooted in an origin named by `AGENTHIPPO_LEASE_URL`
541
+ * and fetched with a bare `fetch()`: no scheme check, no pinning, and nothing signing the key
542
+ * document. Whoever could answer that URL could hand the fleet its trust root. Now: https is
543
+ * required unless explicitly opted out of, the trust root can be pinned independently of the lease,
544
+ * and thumbprint pins let an operator refuse any key the plane did not previously publish.
545
+ *
546
+ * And a load FAILURE is no longer an empty keyring (finding A4). "No trust root configured" and
547
+ * "trust root configured but unreachable" are different states with different remedies; collapsing
548
+ * them into `{}` meant a 503 during a rolling restart looked exactly like an unmanaged deployment.
549
+ *
550
+ * NOT re-fetched once `configured` — that is the caller's job (`serve` retries only while a root is
551
+ * missing), so key rotation still needs the documented restart and a compromised plane cannot swap
552
+ * a trust root under a running instance mid-flight. Both roots behave identically here.
553
+ */
554
+ async function loadTrustRoot(spec, opts) {
555
+ const env = opts.env ?? process.env;
556
+ const warnings = [];
557
+ const pins = parseJwksPins(env[spec.pinsEnv]);
558
+ const inline = env[spec.inlineEnv]?.trim();
559
+ const explicit = spec.urlEnvs.map((name) => env[name]).find((v) => v !== undefined && v.trim() !== '')?.trim();
560
+ // Read BEFORE any early return: `unavailable` still has to say whether an operator asked for it.
561
+ const explicitlyConfigured = Boolean(inline) || Boolean(explicit);
562
+ const applyPins = (keys, source, url) => {
563
+ if (pins.length === 0) {
564
+ return Object.keys(keys).length === 0
565
+ ? { status: 'unavailable', keys: {}, source, url, warnings, explicitlyConfigured, reason: 'the trust root published no usable ES256 keys' }
566
+ : { status: 'configured', keys, source, url, warnings, explicitlyConfigured };
567
+ }
568
+ const kept = {};
569
+ for (const [kid, pem] of Object.entries(keys)) {
570
+ let tp;
571
+ try {
572
+ tp = publicKeyThumbprints(pem);
573
+ }
574
+ catch {
575
+ warnings.push(`${spec.label} trust root: key ${kid} is unreadable and was dropped`);
576
+ continue;
577
+ }
578
+ const matched = pins.some((p) => (0, warrant_1.timingSafeEqualString)(p, tp.jwk.toLowerCase()) || (0, warrant_1.timingSafeEqualString)(p, tp.spki));
579
+ if (matched) {
580
+ kept[kid] = pem;
581
+ }
582
+ else {
583
+ warnings.push(`${spec.label} trust root: key ${kid} does not match any pin in ${spec.pinsEnv} and was REFUSED ` +
584
+ `(its thumbprints are jwk=${tp.jwk} spki=${tp.spki})`);
585
+ }
586
+ }
587
+ if (Object.keys(kept).length === 0) {
588
+ return {
589
+ status: 'unavailable', keys: {}, source, url, warnings, explicitlyConfigured,
590
+ reason: `every key offered by the trust root failed the ${spec.pinsEnv} pin check`,
591
+ };
592
+ }
593
+ return { status: 'configured', keys: kept, source, url, warnings, explicitlyConfigured };
594
+ };
595
+ if (inline) {
596
+ let parsed;
597
+ try {
598
+ parsed = JSON.parse(inline);
599
+ }
600
+ catch (err) {
601
+ // Previously `{}` — i.e. a typo in an env var silently became "unmanaged".
602
+ return {
603
+ status: 'unavailable', keys: {}, source: 'inline', warnings, explicitlyConfigured,
604
+ reason: `${spec.inlineEnv} is set but is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
605
+ };
606
+ }
607
+ return applyPins(parsed, 'inline');
608
+ }
609
+ let url = explicit;
610
+ let derivedFromLease = false;
611
+ if (!url && opts.leaseUrl) {
612
+ // `?purpose=…` because the bare path's meaning belongs to the plane: it used to return every
613
+ // key it held, and a control plane that separates keys by purpose may now default it to
614
+ // something else entirely. Asking for the purpose we actually need makes this fallback survive
615
+ // that change — an older plane ignores the query string and returns the same document it
616
+ // always did, so this is safe in both directions.
617
+ //
618
+ // The plane also publishes `/.well-known/lease-jwks.json` as an alias for
619
+ // `?purpose=lease-signing`. The query form is used for BOTH roots on purpose: one derivation
620
+ // rule, one thing to reason about, and it is the form that already survives a plane changing
621
+ // what the bare path defaults to.
622
+ try {
623
+ url = new URL(`/.well-known/jwks.json?purpose=${spec.purpose}`, opts.leaseUrl).toString();
624
+ }
625
+ catch {
626
+ return { status: 'unavailable', keys: {}, warnings, explicitlyConfigured, reason: `AGENTHIPPO_LEASE_URL is not a valid URL: ${opts.leaseUrl}` };
627
+ }
628
+ derivedFromLease = true;
629
+ }
630
+ if (!url) {
631
+ return { status: 'unconfigured', keys: {}, warnings, explicitlyConfigured };
632
+ }
633
+ const secure = assertSecureTrustUrl(url, `the ${spec.purpose} JWKS URL`, env);
634
+ if (!secure.ok) {
635
+ return { status: 'unavailable', keys: {}, source: 'jwks', url, warnings, reason: secure.reason, derivedFromLease, explicitlyConfigured };
636
+ }
637
+ warnings.push(...secure.warnings);
638
+ if (derivedFromLease && pins.length === 0) {
639
+ warnings.push(`${spec.label} trust root is derived from AGENTHIPPO_LEASE_URL (${url}) and is not pinned. Whoever can answer ` +
640
+ `that origin ${spec.derivedRisk}. Pin it with ${spec.urlEnvs[0]} and ` +
641
+ `${spec.pinsEnv}.`);
642
+ }
643
+ try {
644
+ const res = await (opts.fetchImpl ?? fetch)(url);
645
+ if (!res.ok) {
646
+ return { status: 'unavailable', keys: {}, source: 'jwks', url, warnings, reason: `JWKS fetch returned HTTP ${res.status}`, derivedFromLease, explicitlyConfigured };
647
+ }
648
+ const doc = (await res.json());
649
+ const keys = jwksToPems(doc);
650
+ if (!keys.ok) {
651
+ return { status: 'unavailable', keys: {}, source: 'jwks', url, warnings, reason: keys.reason, derivedFromLease, explicitlyConfigured };
652
+ }
653
+ const result = applyPins(keys.pems, 'jwks', url);
654
+ return { ...result, derivedFromLease };
655
+ }
656
+ catch (err) {
657
+ return {
658
+ status: 'unavailable', keys: {}, source: 'jwks', url, warnings, derivedFromLease, explicitlyConfigured,
659
+ reason: `JWKS fetch failed: ${err instanceof Error ? err.message : String(err)}`,
660
+ };
661
+ }
662
+ }
663
+ /** Public halves used to verify PACKS — i.e. which code this fleet is allowed to execute. */
664
+ async function loadPackTrustRoot(opts) {
665
+ return loadTrustRoot(PACK_TRUST_ROOT_SPEC, opts);
666
+ }
667
+ /**
668
+ * Public halves used to verify signed LEASE envelopes — i.e. which policy this instance obeys.
669
+ *
670
+ * Separate from the pack root because the control plane refuses to register one kid under two
671
+ * purposes, so there is no key that can serve both. See `LEASE_SIGNING_KEYS_ENV`.
672
+ *
673
+ * A caller with no lease root configured must fall back to the pack root and say so: that is what
674
+ * every deployment signing leases today is doing, and `requireSignedLease` is sticky with no
675
+ * plane-side undo, so a hard cutover here is unrecoverable from the plane. `explicitlyConfigured`
676
+ * is what makes that fallback decidable — a DERIVED lease root that comes back `unavailable`
677
+ * (a plane publishing no lease key) must not be read as "the operator has moved over".
678
+ */
679
+ async function loadLeaseTrustRoot(opts) {
680
+ return loadTrustRoot(LEASE_TRUST_ROOT_SPEC, opts);
681
+ }
682
+ /**
683
+ * Which keyring verifies a signed lease — and whether that answer is the back-compat fallback.
684
+ *
685
+ * A lease root named OUTRIGHT (`AGENTHIPPO_LEASE_JWKS_URL` / `AGENTHIPPO_LEASE_SIGNING_KEYS`) is
686
+ * the whole answer: the pack keyring is not consulted, so a lease-signing key cannot be smuggled in
687
+ * through the root that decides which code runs, and an operator who has moved over gets the
688
+ * separation they asked for.
689
+ *
690
+ * With nothing named, the pack keyring stays in play. That is not laziness — it is every deployment
691
+ * that signs leases today, whose lease key necessarily sits in its PACK trust root because until
692
+ * now that was the only keyring serve consulted. `requireSignedLease` latches per instance and the
693
+ * control plane has no undo, so an instance that stopped recognising its own lease key would refuse
694
+ * turns and could not be talked out of it from the console. The derived lease keys are unioned in
695
+ * (and win a kid collision) so a rollover has both halves live at once.
696
+ *
697
+ * `packFallback` is returned rather than logged here: the caller owns the "warn exactly once, and
698
+ * only when a signed lease is actually being verified" part.
699
+ */
700
+ function selectLeaseSigningKeys(leaseTrust, packTrust) {
701
+ if (leaseTrust.explicitlyConfigured) {
702
+ return { keys: leaseTrust.keys, packFallback: false };
703
+ }
704
+ return { keys: { ...packTrust.keys, ...leaseTrust.keys }, packFallback: true };
705
+ }
706
+ /**
707
+ * Accept both shapes: this codebase's `{pemsByKid}` and a standard JWKS `{keys:[…]}` — the latter
708
+ * is exactly what `authority/warrant.ts:jwks()` publishes, so the pack trust root and the warrant
709
+ * trust root can be the same document.
710
+ *
711
+ * ES256 only, matching the alg pinning in the verifier. An RSA or symmetric entry in a JWKS is not
712
+ * something to quietly skip past — it means the document is not what this verifier expects.
713
+ */
714
+ function jwksToPems(doc) {
715
+ if (doc.pemsByKid && typeof doc.pemsByKid === 'object') {
716
+ return { ok: true, pems: doc.pemsByKid };
717
+ }
718
+ if (!Array.isArray(doc.keys)) {
719
+ return { ok: false, reason: 'JWKS document has neither `pemsByKid` nor a `keys` array' };
720
+ }
721
+ const pems = {};
722
+ for (const jwk of doc.keys) {
723
+ if (!jwk || typeof jwk !== 'object') {
724
+ continue;
725
+ }
726
+ if (jwk.kty !== 'EC' || jwk.crv !== 'P-256') {
727
+ continue;
728
+ }
729
+ if (jwk.alg !== undefined && jwk.alg !== 'ES256') {
730
+ continue;
731
+ }
732
+ if (typeof jwk.kid !== 'string' || !jwk.kid) {
733
+ continue;
734
+ }
735
+ try {
736
+ pems[jwk.kid] = crypto.createPublicKey({ key: jwk, format: 'jwk' })
737
+ .export({ type: 'spki', format: 'pem' }).toString();
738
+ }
739
+ catch { /* an unreadable entry is dropped; an empty result is reported as unavailable */ }
740
+ }
741
+ if (Object.keys(pems).length === 0) {
742
+ return { ok: false, reason: 'JWKS document published no usable ES256 (P-256) keys' };
743
+ }
744
+ return { ok: true, pems };
745
+ }
746
+ /**
747
+ * Back-compat shim. Returns just the keys, `{}` on any failure.
748
+ *
749
+ * Prefer `loadPackTrustRoot`: this signature cannot express "configured but unavailable", which is
750
+ * the distinction finding A4 is about.
751
+ *
752
+ * @deprecated
753
+ */
754
+ async function loadPackSigningKeys(opts) {
755
+ return (await loadPackTrustRoot(opts)).keys;
756
+ }
757
+ // ---------------------------------------------------------------------------------------------
758
+ // Re-verification on the turn path
759
+ // ---------------------------------------------------------------------------------------------
760
+ /** How long a verified result is reused without even probing the tree. */
761
+ exports.PACK_PROBE_INTERVAL_MS = 1_000;
762
+ /** How long a verified result is reused without a FULL digest, however clean the probe looks. */
763
+ exports.PACK_FULL_REVERIFY_INTERVAL_MS = 30_000;
764
+ /**
765
+ * Verify-per-turn with a cheap change probe.
766
+ *
767
+ * The signature used to be computed ONCE at boot and the boolean cached for the life of the
768
+ * process. In the DEFAULT deployment shape (no `--sandbox`) the engine runs IN THIS PROCESS with
769
+ * `fileAccess: full`, so an agent can edit its own pack directory — including, before the digest
770
+ * fix above, dropping a `PreToolUse` hook into `.claude/` — and every subsequent turn would still
771
+ * pass on the boot-time boolean. "Verified before run" has to mean before each run.
772
+ *
773
+ * Why this shape rather than a per-turn digest, or a plain interval:
774
+ *
775
+ * - A full digest reads and hashes every byte of the pack. On the turn path that is a filesystem
776
+ * walk plus O(bytes) of SHA-256 per turn, for a fact that is usually unchanged. Not measured,
777
+ * therefore not put on a hot path — which is exactly what the original comment said and why it
778
+ * cached, so replacing one unmeasured choice with another would be no better.
779
+ * - `packTreeProbe` is `lstat` only: no reads, no hashing. It catches edits, replacements,
780
+ * additions and removals, and it is what makes the common case (nothing changed) cheap.
781
+ * - The probe alone is NOT sufficient: an attacker inside the process can restore mtime and size
782
+ * after editing a file of identical length. So a full re-verify also runs unconditionally every
783
+ * `PACK_FULL_REVERIFY_INTERVAL_MS`, which bounds that window rather than leaving it open for
784
+ * the life of the process. Lowering that constant trades CPU for a shorter window.
785
+ *
786
+ * Residual, stated plainly: within one full-reverify interval, an in-process agent that edits its
787
+ * pack while preserving size and mtime is not detected. The structural fix for that is `--sandbox`
788
+ * (the engine cannot reach the pack directory at all); this bounds the exposure for the default
789
+ * shape rather than eliminating it.
790
+ */
791
+ function createPackVerifier(opts = {}) {
792
+ const probeEvery = opts.probeIntervalMs ?? exports.PACK_PROBE_INTERVAL_MS;
793
+ const fullEvery = opts.fullReverifyIntervalMs ?? exports.PACK_FULL_REVERIFY_INTERVAL_MS;
794
+ let cached;
795
+ let cachedKey = '';
796
+ let cachedProbe = '';
797
+ let lastProbeAt = 0;
798
+ let lastFullAt = 0;
799
+ let fullCount = 0;
800
+ const identity = (i) => `${i.packDir}${i.pack}${i.packVersion}${Object.keys(i.keys).sort().join(',')}`;
801
+ const full = (input, nowMs) => {
802
+ fullCount += 1;
803
+ cachedKey = identity(input);
804
+ cachedProbe = packTreeProbe(input.packDir);
805
+ lastProbeAt = nowMs;
806
+ lastFullAt = nowMs;
807
+ const token = readPackSignature(input.packDir);
808
+ // "Unsigned" is reported ahead of "no trust root": the first step is the same either way
809
+ // (sign the pack), and an operator who is told about a missing keyring while their pack has
810
+ // no signature at all will configure the keyring and land right back here.
811
+ cached = token
812
+ ? verifyPack(token, input.keys, { packDir: input.packDir, pack: input.pack, packVersion: input.packVersion })
813
+ : { ok: false, kind: 'unsigned', reason: `${input.pack} is not signed (no ${exports.PACK_SIGNATURE_FILE} at the pack root)` };
814
+ return cached;
815
+ };
816
+ return {
817
+ fullVerifications: () => fullCount,
818
+ refresh(input, nowMs = Date.now()) { return full(input, nowMs); },
819
+ verify(input, nowMs = Date.now()) {
820
+ // Any change of pack, version or keyring invalidates outright — none of those are the
821
+ // kind of thing to reuse a cached verdict across.
822
+ if (!cached || cachedKey !== identity(input)) {
823
+ return full(input, nowMs);
824
+ }
825
+ if (nowMs - lastFullAt >= fullEvery) {
826
+ return full(input, nowMs);
827
+ }
828
+ if (nowMs - lastProbeAt < probeEvery) {
829
+ return cached;
830
+ }
831
+ lastProbeAt = nowMs;
832
+ const probe = packTreeProbe(input.packDir);
833
+ if (probe === cachedProbe) {
834
+ return cached;
835
+ }
836
+ return full(input, nowMs);
837
+ },
838
+ };
839
+ }