@agenthippo-ai/agenthippo 0.2.3-dataplane → 1.110.11

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 (139) hide show
  1. package/out/agentpack/index.js +3 -1
  2. package/out/agentpack/paths.js +25 -2
  3. package/out/analytics/dashboard/localFleetAdapter.js +82 -2
  4. package/out/analytics/dashboard/provider.js +13 -2
  5. package/out/analytics/dashboard/webview/panel.js +3 -2
  6. package/out/analytics/identity.js +0 -0
  7. package/out/analytics/service.js +29 -25
  8. package/out/analytics/sessionEvents.js +100 -24
  9. package/out/auth/authBaseUrl.js +59 -0
  10. package/out/auth/bootstrapApply.js +9 -10
  11. package/out/auth/brokerClient.js +48 -14
  12. package/out/auth/provider.js +8 -21
  13. package/out/auth/sessionStore.js +3 -40
  14. package/out/auth/signInFlow.js +2 -1
  15. package/out/auth-broker/config.js +50 -5
  16. package/out/auth-broker/server.js +126 -26
  17. package/out/auth-broker/store.js +25 -24
  18. package/out/authority/warrant.js +206 -0
  19. package/out/branding.js +27 -1
  20. package/out/cli/commands/conformance.js +249 -0
  21. package/out/cli/commands/data-plane.js +151 -0
  22. package/out/cli/commands/deploy.js +1 -0
  23. package/out/cli/commands/gateway.js +4 -0
  24. package/out/cli/commands/pack.js +182 -3
  25. package/out/cli/commands/sandbox.js +351 -0
  26. package/out/cli/commands/serve.js +1145 -130
  27. package/out/cli/commands/store.js +12 -4
  28. package/out/cli/credential-context.js +17 -0
  29. package/out/cli/data-plane/gateway.js +28 -5
  30. package/out/cli/data-plane/index.js +18 -2
  31. package/out/cli/data-plane/registry.js +9 -0
  32. package/out/cli/data-plane/remote.js +106 -0
  33. package/out/cli/data-plane/sidecar.js +225 -0
  34. package/out/cli/deploy/layers/identity.js +88 -4
  35. package/out/cli/deploy-init.js +89 -24
  36. package/out/cli/deps.js +106 -2
  37. package/out/cli/idp-init.js +35 -0
  38. package/out/cli/index.js +171 -17
  39. package/out/cli/pack-install-ref.js +15 -0
  40. package/out/cli/pack-loader.js +8 -1
  41. package/out/cli/pack-runtime.js +61 -0
  42. package/out/cli/runtime.js +35 -1
  43. package/out/cli/serve-hardening.js +260 -14
  44. package/out/cli/utils.js +12 -1
  45. package/out/engines/base.js +32 -3
  46. package/out/engines/claude.js +33 -15
  47. package/out/engines/codex.js +111 -27
  48. package/out/engines/custom.js +15 -1
  49. package/out/evidence/sessionShip.js +643 -0
  50. package/out/extension.js +11 -2
  51. package/out/gateway/bridge.js +40 -2
  52. package/out/gateway/client.js +34 -1
  53. package/out/gateway/installSource.js +68 -0
  54. package/out/gateway/lifecycle.js +185 -51
  55. package/out/gateway/outboundPaths.js +4 -1
  56. package/out/gateway/secretsPreflight.js +90 -0
  57. package/out/gateway/service.js +7 -2
  58. package/out/headless/index.js +25 -2
  59. package/out/live-e2e/mcp-demo/server.mjs +32 -0
  60. package/out/mcp.js +30 -0
  61. package/out/pack/caps.js +271 -0
  62. package/out/pack/governor.js +326 -0
  63. package/out/pack/lease-schema.js +273 -0
  64. package/out/pack/lease.js +682 -0
  65. package/out/pack/reconcile.js +217 -0
  66. package/out/pack/signature.js +839 -0
  67. package/out/remote/handleRemoteChat.js +44 -3
  68. package/out/remote/remoteTrust.js +277 -0
  69. package/out/remote/serveClient.js +52 -5
  70. package/out/runner/host.js +141 -0
  71. package/out/runner/protocol.js +128 -0
  72. package/out/runner/remoteEngine.js +166 -0
  73. package/out/runtime/interfaces/edit-tracker.js +2 -0
  74. package/out/runtime/vscode/vscode-edit-tracker.js +2 -0
  75. package/out/sandbox/containerDriver.js +572 -0
  76. package/out/sandbox/processDriver.js +318 -0
  77. package/out/sandbox/sandboxRunner.js +303 -0
  78. package/out/sandbox/support.js +301 -0
  79. package/out/sandbox/types.js +55 -0
  80. package/out/scripts/agentide-litellm.sh +67 -5
  81. package/out/scripts/agentide_litellm_hooks_v1.py +266 -21
  82. package/out/scripts/analytics/agentide-litellm-otel.config.yaml +98 -132
  83. package/out/scripts/analytics/setup-analytics.ps1 +45 -14
  84. package/out/scripts/analytics/setup-analytics.sh +106 -15
  85. package/out/scripts/analytics/start-analytics.sh +86 -14
  86. package/out/scripts/gateway/setup-gateway.sh +72 -32
  87. package/out/shared/models.js +11 -110
  88. package/out/spotlight/install.js +7 -1
  89. package/out/store/index.js +41 -10
  90. package/out/sync.js +3 -1
  91. package/out/test/analytics.test.js +162 -23
  92. package/out/test/auth-baseurl.test.js +132 -0
  93. package/out/test/auth-bootstrapApply.test.js +27 -6
  94. package/out/test/auth-broker-hardening.test.js +227 -0
  95. package/out/test/auth-broker.test.js +59 -9
  96. package/out/test/branding.test.js +13 -0
  97. package/out/test/caps-governor.test.js +460 -0
  98. package/out/test/codex-new-file-survival.test.js +119 -0
  99. package/out/test/configuration-scopes.test.js +131 -0
  100. package/out/test/data-plane-engine-wiring.test.js +32 -0
  101. package/out/test/data-plane.test.js +380 -0
  102. package/out/test/deploy-init.test.js +47 -1
  103. package/out/test/gateway-client.test.js +28 -0
  104. package/out/test/gateway-install-source.test.js +111 -0
  105. package/out/test/gateway-lifecycle.test.js +1 -1
  106. package/out/test/gateway-outbound.test.js +7 -0
  107. package/out/test/gateway-secrets-preflight.test.js +127 -0
  108. package/out/test/gateway-session-routing.test.js +18 -0
  109. package/out/test/governor-hardening.test.js +222 -0
  110. package/out/test/headless-sync.test.js +22 -0
  111. package/out/test/lease-signature.test.js +357 -0
  112. package/out/test/lease-trust-root.test.js +273 -0
  113. package/out/test/lease-validation.test.js +224 -0
  114. package/out/test/lease.test.js +114 -0
  115. package/out/test/localFleetAdapter.test.js +91 -0
  116. package/out/test/mcp.test.js +27 -1
  117. package/out/test/pack-admission.test.js +216 -0
  118. package/out/test/pack-digest-parity.test.js +140 -0
  119. package/out/test/pack-reconcile.test.js +183 -0
  120. package/out/test/pack-signature-artifact.test.js +103 -0
  121. package/out/test/pack-signature.test.js +176 -0
  122. package/out/test/pack-trust-root.test.js +202 -0
  123. package/out/test/remote-trust.test.js +242 -0
  124. package/out/test/runner-protocol.test.js +249 -0
  125. package/out/test/sandbox-driver.test.js +553 -0
  126. package/out/test/sandbox-hardening.test.js +406 -0
  127. package/out/test/sandbox-runner.test.js +343 -0
  128. package/out/test/sandbox-scope.test.js +449 -0
  129. package/out/test/serve-cron-acceptance.test.js +370 -0
  130. package/out/test/serve-gateway-turn.test.js +122 -0
  131. package/out/test/serve-hardening.test.js +227 -15
  132. package/out/test/serveClient.test.js +6 -5
  133. package/out/test/sessionShip.test.js +618 -0
  134. package/out/test/spotlightPublish.test.js +28 -0
  135. package/out/test/store-install-safe.test.js +69 -0
  136. package/out/test/tls-bypass.test.js +90 -0
  137. package/out/test/warrant.test.js +138 -0
  138. package/out/utils.js +46 -9
  139. package/package.json +1 -1
@@ -45,6 +45,7 @@ const os = __importStar(require("os"));
45
45
  const path = __importStar(require("path"));
46
46
  const stream_1 = require("stream");
47
47
  const serve_hardening_1 = require("../cli/serve-hardening");
48
+ const serve_hardening_2 = require("../cli/serve-hardening");
48
49
  suite('serve-hardening: resolveStateDir', () => {
49
50
  test('explicit --state-dir wins and is absolute and non-ephemeral', () => {
50
51
  const r = (0, serve_hardening_1.resolveStateDir)('./state', {});
@@ -167,9 +168,79 @@ suite('serve-hardening: RateLimiter', () => {
167
168
  });
168
169
  });
169
170
  suite('serve-hardening: resolveUserId', () => {
170
- test('X-Agent-User header wins over body', () => {
171
- const id = (0, serve_hardening_1.resolveUserId)({ 'x-agent-user': 'alice' }, { user_id: 'bob' });
172
- assert.strictEqual(id, 'alice');
171
+ /**
172
+ * BREAKING, and deliberately so. `x-agent-user` used to be the FIRST trusted alias, so on any
173
+ * deployment that had not pinned AGENTHIPPO_IDENTITY_HEADER, any HTTP client could send
174
+ * `x-agent-user: victim@example.com` and be namespaced into that user's session state, attach
175
+ * to their conversation, and be issued a data-plane turn nonce scoped to them. No platform
176
+ * strips it — Cloud Run IAP strips `X-Goog-*` and forwards this one untouched.
177
+ */
178
+ test('x-agent-user is NOT trusted by default — it is not stripped by any platform', () => {
179
+ assert.strictEqual((0, serve_hardening_1.resolveUserId)({ 'x-agent-user': 'victim@example.com' }, undefined, {}), undefined);
180
+ });
181
+ test('x-agent-user IS trusted when the deployment pins it (the Caddy forward_auth flow)', () => {
182
+ const env = { AGENTHIPPO_IDENTITY_HEADER: 'x-agent-user' };
183
+ assert.strictEqual((0, serve_hardening_1.resolveUserId)({ 'x-agent-user': 'alice@corp.com' }, { user_id: 'bob' }, env), 'alice@corp.com');
184
+ });
185
+ test('an unpinned deployment is told which env var to set, and why', () => {
186
+ const advice = (0, serve_hardening_1.identityHeaderAdvice)({});
187
+ assert.ok(advice.length > 0);
188
+ assert.ok(advice.every((l) => /AGENTHIPPO_IDENTITY_HEADER/.test(l)));
189
+ assert.ok(advice.some((l) => /x-agent-user/.test(l)), 'the removal must be explained, not silent');
190
+ assert.deepStrictEqual((0, serve_hardening_1.identityHeaderAdvice)({ AGENTHIPPO_IDENTITY_HEADER: 'x-agent-user' }), [], 'a pinned deployment is quiet');
191
+ });
192
+ test('advice severity follows reachability, so the loud case stays loud', () => {
193
+ const loopback = (0, serve_hardening_1.identityHeaderAdvice)({}, '127.0.0.1');
194
+ assert.strictEqual(loopback.length, 1, 'a local run gets one short note, not a lecture');
195
+ assert.ok(loopback.every((l) => /AGENTHIPPO_IDENTITY_HEADER/.test(l)));
196
+ assert.deepStrictEqual((0, serve_hardening_1.identityHeaderAdvice)({}, 'localhost').length, 1);
197
+ assert.deepStrictEqual((0, serve_hardening_1.identityHeaderAdvice)({}, '::1').length, 1);
198
+ const exposed = (0, serve_hardening_1.identityHeaderAdvice)({}, '0.0.0.0');
199
+ assert.ok(exposed.length > loopback.length, 'an exposed bind must say strictly more');
200
+ assert.ok(exposed.some((l) => /reachable beyond this machine/.test(l)));
201
+ assert.ok(exposed.every((l) => /AGENTHIPPO_IDENTITY_HEADER/.test(l)));
202
+ // Pinning silences both, exposed or not.
203
+ const pinned = { AGENTHIPPO_IDENTITY_HEADER: 'x-goog-authenticated-user-email' };
204
+ assert.deepStrictEqual((0, serve_hardening_1.identityHeaderAdvice)(pinned, '0.0.0.0'), []);
205
+ });
206
+ test('a pinned multi-user serve on tier 0 is told its filesystem is shared', () => {
207
+ const env = { AGENTHIPPO_IDENTITY_HEADER: 'x-agent-user' };
208
+ const tier0 = (0, serve_hardening_1.tenantIsolationAdvice)({ sandbox: 'process', workspace: '/work', stateDir: '/state' }, env);
209
+ assert.strictEqual(tier0.level, 'warn');
210
+ assert.ok(tier0.lines.some((l) => /--sandbox=process/.test(l)));
211
+ assert.ok(tier0.lines.some((l) => /--sandbox=container/.test(l)), 'the advice must name the fix');
212
+ // No sandbox at all is the same shared filesystem, and is the DEFAULT deployment shape.
213
+ const none = (0, serve_hardening_1.tenantIsolationAdvice)({ workspace: '/work', stateDir: '/state' }, env);
214
+ assert.strictEqual(none.level, 'warn');
215
+ assert.ok(none.lines.some((l) => /no sandbox/.test(l)));
216
+ });
217
+ test('nesting the workspace inside the state dir is called out on top of the shared-fs warning', () => {
218
+ const env = { AGENTHIPPO_IDENTITY_HEADER: 'x-agent-user' };
219
+ const nested = (0, serve_hardening_1.tenantIsolationAdvice)({ sandbox: 'process', workspace: '/state/workspace', stateDir: '/state' }, env);
220
+ const separate = (0, serve_hardening_1.tenantIsolationAdvice)({ sandbox: 'process', workspace: '/work', stateDir: '/state' }, env);
221
+ assert.ok(nested.lines.length > separate.lines.length, 'the shipped-sample shape must say strictly more');
222
+ assert.ok(nested.lines.some((l) => /sits INSIDE --state-dir/.test(l)));
223
+ // The reverse nesting is the same exposure reached without any traversal at all.
224
+ const inverted = (0, serve_hardening_1.tenantIsolationAdvice)({ sandbox: 'process', workspace: '/state', stateDir: '/state/serve' }, env);
225
+ assert.ok(inverted.lines.some((l) => /sits INSIDE --workspace/.test(l)));
226
+ });
227
+ test('tenant-isolation advice is quiet where it has nothing to say', () => {
228
+ // Unpinned: identityHeaderAdvice owns that case, and two warnings bury the actionable one.
229
+ assert.deepStrictEqual((0, serve_hardening_1.tenantIsolationAdvice)({ sandbox: 'process', workspace: '/state/workspace', stateDir: '/state' }, {}), { level: 'ok', lines: [] });
230
+ // A container sandbox gets a per-session workspace and state dir — the nesting is then an
231
+ // implementation detail of paths the sandbox never sees whole.
232
+ const env = { AGENTHIPPO_IDENTITY_HEADER: 'x-agent-user' };
233
+ assert.strictEqual((0, serve_hardening_1.tenantIsolationAdvice)({ sandbox: 'container', workspace: '/state/workspace', stateDir: '/state' }, env).level, 'ok');
234
+ // …unless the operator opted back into one workspace for everyone.
235
+ assert.strictEqual((0, serve_hardening_1.tenantIsolationAdvice)({ sandbox: 'container', sharedWorkspace: true, workspace: '/work', stateDir: '/state' }, env).level, 'warn');
236
+ });
237
+ test('the refusal is opt-in, so an upgrade cannot brick a deployment that already shipped', () => {
238
+ const base = { sandbox: 'process', workspace: '/state/workspace', stateDir: '/state' };
239
+ const pinned = { AGENTHIPPO_IDENTITY_HEADER: 'x-agent-user' };
240
+ assert.strictEqual((0, serve_hardening_1.tenantIsolationAdvice)(base, pinned).level, 'warn');
241
+ assert.strictEqual((0, serve_hardening_1.tenantIsolationAdvice)(base, { ...pinned, [serve_hardening_1.REQUIRE_TENANT_ISOLATION_ENV]: '1' }).level, 'refuse');
242
+ // A deployment that fixed the isolation is not refused just for asking for the check.
243
+ assert.strictEqual((0, serve_hardening_1.tenantIsolationAdvice)({ sandbox: 'container', workspace: '/work', stateDir: '/state' }, { ...pinned, [serve_hardening_1.REQUIRE_TENANT_ISOLATION_ENV]: '1' }).level, 'ok');
173
244
  });
174
245
  test('Databricks X-Forwarded-Email', () => {
175
246
  assert.strictEqual((0, serve_hardening_1.resolveUserId)({ 'x-forwarded-email': 'priya@meridian.com' }, undefined), 'priya@meridian.com');
@@ -181,12 +252,53 @@ suite('serve-hardening: resolveUserId', () => {
181
252
  test('Cloudflare Access header', () => {
182
253
  assert.strictEqual((0, serve_hardening_1.resolveUserId)({ 'cf-access-authenticated-user-email': 'susan@meridian.com' }, undefined), 'susan@meridian.com');
183
254
  });
184
- test('falls back to body.user_id when no header', () => {
185
- assert.strictEqual((0, serve_hardening_1.resolveUserId)({}, { user_id: 'bob' }), 'bob');
255
+ test('body.user_id is ignored unless explicitly opted in (§10 hole 3)', () => {
256
+ // Safe-by-convention was the hole: any deployment that forgot to pin a header let
257
+ // every caller name an arbitrary victim via the client-controlled body.
258
+ assert.strictEqual((0, serve_hardening_1.resolveUserId)({}, { user_id: 'bob' }, {}), undefined);
259
+ });
260
+ test('AGENTHIPPO_ALLOW_BODY_IDENTITY=1 restores the dev-convenience body fallback', () => {
261
+ const env = { AGENTHIPPO_ALLOW_BODY_IDENTITY: '1' };
262
+ assert.strictEqual((0, serve_hardening_1.resolveUserId)({}, { user_id: 'bob' }, env), 'bob');
263
+ // A trusted platform header still wins over the body even when opted in.
264
+ assert.strictEqual((0, serve_hardening_1.resolveUserId)({ 'x-forwarded-email': 'alice@corp.com' }, { user_id: 'bob' }, env), 'alice@corp.com');
186
265
  });
187
266
  test('undefined when nothing present', () => {
188
267
  assert.strictEqual((0, serve_hardening_1.resolveUserId)({}, undefined), undefined);
189
- assert.strictEqual((0, serve_hardening_1.resolveUserId)({}, { user_id: ' ' }), undefined);
268
+ assert.strictEqual((0, serve_hardening_1.resolveUserId)({}, { user_id: ' ' }, { AGENTHIPPO_ALLOW_BODY_IDENTITY: '1' }), undefined);
269
+ });
270
+ /**
271
+ * IDENTITY_PROXY_SECRET_ENV: pinning declares WHICH header carries identity, not WHO may
272
+ * assert it. On a shared network any container that can dial serve's port can send the
273
+ * pinned header; when the secret is configured, identity requires the request to also carry
274
+ * the proxy's proof header — the same contract the auth-broker enforces for
275
+ * X-Auth-Request-Email.
276
+ */
277
+ suite('identity proxy secret (AGENTHIPPO_IDENTITY_PROXY_SECRET)', () => {
278
+ const env = {
279
+ AGENTHIPPO_IDENTITY_HEADER: 'x-agent-user',
280
+ AGENTHIPPO_IDENTITY_PROXY_SECRET: 'shhh',
281
+ };
282
+ test('a pinned header WITHOUT proof runs anonymous — the network-neighbour spoof', () => {
283
+ assert.strictEqual((0, serve_hardening_1.resolveUserId)({ 'x-agent-user': 'victim@corp.com' }, undefined, env), undefined);
284
+ });
285
+ test('a wrong secret is a missing secret', () => {
286
+ assert.strictEqual((0, serve_hardening_1.resolveUserId)({ 'x-agent-user': 'victim@corp.com', 'x-agenthippo-proxy-secret': 'nope' }, undefined, env), undefined);
287
+ });
288
+ test('the trusted proxy (correct proof) resolves identity as before', () => {
289
+ assert.strictEqual((0, serve_hardening_1.resolveUserId)({ 'x-agent-user': 'alice@corp.com', 'x-agenthippo-proxy-secret': 'shhh' }, undefined, env), 'alice@corp.com');
290
+ });
291
+ test('proof gates the alias list and the body fallback too, not just the pin', () => {
292
+ const unpinned = { AGENTHIPPO_IDENTITY_PROXY_SECRET: 'shhh', AGENTHIPPO_ALLOW_BODY_IDENTITY: '1' };
293
+ assert.strictEqual((0, serve_hardening_1.resolveUserId)({ 'x-forwarded-email': 'a@b.com' }, undefined, unpinned), undefined);
294
+ // A deployment that configured the secret has declared a real proxy; a caller without
295
+ // proof must not be able to name a victim via the client-controlled body either.
296
+ assert.strictEqual((0, serve_hardening_1.resolveUserId)({}, { user_id: 'bob' }, unpinned), undefined);
297
+ });
298
+ test('unset secret keeps existing behaviour — the gate is opt-in', () => {
299
+ const pinnedOnly = { AGENTHIPPO_IDENTITY_HEADER: 'x-agent-user' };
300
+ assert.strictEqual((0, serve_hardening_1.resolveUserId)({ 'x-agent-user': 'alice@corp.com' }, undefined, pinnedOnly), 'alice@corp.com');
301
+ });
190
302
  });
191
303
  test('AGENTHIPPO_IDENTITY_HEADER pins trust to one header — spoofed aliases are ignored', () => {
192
304
  const env = { AGENTHIPPO_IDENTITY_HEADER: 'x-goog-authenticated-user-email' };
@@ -343,37 +455,137 @@ suite('serve-hardening: readBodyWithCap', () => {
343
455
  suite('serve-hardening: resolveServeSessionKey (secure-obo-design §3.4 Phase 0)', () => {
344
456
  const PINNED = { AGENTHIPPO_IDENTITY_HEADER: 'x-forwarded-email' };
345
457
  test('verified user + session_id → user-namespaced key', () => {
346
- const r = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', 'alice@corp.com', PINNED);
458
+ const r = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', 'alice@corp.com', 'session', PINNED);
347
459
  assert.strictEqual(r.sessionKey, `u${(0, serve_hardening_1.userNamespaceHash)('alice@corp.com')}--chat-1`);
348
460
  assert.strictEqual(r.rejected, undefined);
349
461
  });
350
462
  test('two users presenting the SAME session_id can never share a key (the defect this closes)', () => {
351
- const a = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', 'alice@corp.com', PINNED);
352
- const b = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', 'bob@corp.com', PINNED);
463
+ const a = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', 'alice@corp.com', 'session', PINNED);
464
+ const b = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', 'bob@corp.com', 'session', PINNED);
353
465
  assert.notStrictEqual(a.sessionKey, b.sessionKey);
354
466
  });
355
467
  test('verified user without session_id → stable per-user default key', () => {
356
- const r1 = (0, serve_hardening_1.resolveServeSessionKey)(undefined, 'alice@corp.com', PINNED);
357
- const r2 = (0, serve_hardening_1.resolveServeSessionKey)('', 'alice@corp.com', PINNED);
468
+ const r1 = (0, serve_hardening_1.resolveServeSessionKey)(undefined, 'alice@corp.com', 'session', PINNED);
469
+ const r2 = (0, serve_hardening_1.resolveServeSessionKey)('', 'alice@corp.com', 'session', PINNED);
358
470
  assert.strictEqual(r1.sessionKey, r2.sessionKey);
359
471
  assert.strictEqual(r1.sessionKey, `u${(0, serve_hardening_1.userNamespaceHash)('alice@corp.com')}--default`);
360
472
  });
361
473
  test('pinned identity + session_id but NO verified user → fail closed', () => {
362
- const r = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', undefined, PINNED);
474
+ const r = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', undefined, 'session', PINNED);
475
+ assert.strictEqual(r.rejected, serve_hardening_1.SESSION_WITHOUT_USER_REASON);
476
+ });
477
+ test('pinned identity + NO session_id + no verified user → fail closed (no shared anon bucket)', () => {
478
+ const r = (0, serve_hardening_1.resolveServeSessionKey)(undefined, undefined, 'session', PINNED);
363
479
  assert.strictEqual(r.rejected, serve_hardening_1.SESSION_WITHOUT_USER_REASON);
480
+ assert.strictEqual(r.sessionKey, '');
364
481
  });
365
482
  test('unpinned (local dev) keeps raw session_id behavior', () => {
366
- const r = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', undefined, {});
483
+ const r = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', undefined, 'session', {});
367
484
  assert.strictEqual(r.sessionKey, 'chat-1');
368
485
  assert.strictEqual(r.rejected, undefined);
369
486
  });
370
487
  test('unpinned, no user, no session_id → legacy anon bucket', () => {
371
- const r = (0, serve_hardening_1.resolveServeSessionKey)(undefined, undefined, {});
488
+ const r = (0, serve_hardening_1.resolveServeSessionKey)(undefined, undefined, 'session', {});
372
489
  assert.strictEqual(r.sessionKey, 'anon-unknown');
373
490
  });
374
491
  test('the key never embeds the raw user id (emails are not path segments)', () => {
375
- const r = (0, serve_hardening_1.resolveServeSessionKey)('s', 'alice@corp.com', PINNED);
492
+ const r = (0, serve_hardening_1.resolveServeSessionKey)('s', 'alice@corp.com', 'session', PINNED);
376
493
  assert.ok(!r.sessionKey.includes('alice@corp.com'));
377
494
  assert.ok(!r.sessionKey.includes('@'));
378
495
  });
379
496
  });
497
+ suite('serve-hardening: sandbox scope (what one sandbox holds)', () => {
498
+ const PINNED = { AGENTHIPPO_IDENTITY_HEADER: 'x-forwarded-email' };
499
+ test('session scope: the sandbox key IS the session key — today\'s behaviour, unchanged', () => {
500
+ const r = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', 'alice@corp.com', 'session', PINNED);
501
+ assert.strictEqual(r.sandboxKey, r.sessionKey);
502
+ });
503
+ test('user scope: one user\'s sessions share a sandbox key but keep distinct session keys', () => {
504
+ const a = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', 'alice@corp.com', 'user', PINNED);
505
+ const b = (0, serve_hardening_1.resolveServeSessionKey)('chat-2', 'alice@corp.com', 'user', PINNED);
506
+ assert.strictEqual(a.sandboxKey, b.sandboxKey, 'one container for this user');
507
+ assert.notStrictEqual(a.sessionKey, b.sessionKey, 'but two conversations inside it');
508
+ assert.strictEqual(a.sandboxKey, `u${(0, serve_hardening_1.userNamespaceHash)('alice@corp.com')}`);
509
+ });
510
+ test('user scope never merges two DIFFERENT users', () => {
511
+ const alice = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', 'alice@corp.com', 'user', PINNED);
512
+ const bob = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', 'bob@corp.com', 'user', PINNED);
513
+ assert.notStrictEqual(alice.sandboxKey, bob.sandboxKey);
514
+ });
515
+ test('user scope does NOT apply without a verified user', () => {
516
+ // Merging unidentified callers would group strangers on a fact nobody established.
517
+ const a = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', undefined, 'user', {});
518
+ const b = (0, serve_hardening_1.resolveServeSessionKey)('chat-2', undefined, 'user', {});
519
+ assert.strictEqual(a.sandboxKey, a.sessionKey);
520
+ assert.notStrictEqual(a.sandboxKey, b.sandboxKey);
521
+ });
522
+ test('a rejected turn yields no sandbox key to create anything with', () => {
523
+ const r = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', undefined, 'user', PINNED);
524
+ assert.strictEqual(r.rejected, serve_hardening_1.SESSION_WITHOUT_USER_REASON);
525
+ assert.strictEqual(r.sandboxKey, '');
526
+ });
527
+ test('the sandbox key never embeds the raw user id either', () => {
528
+ const r = (0, serve_hardening_1.resolveServeSessionKey)('s', 'alice@corp.com', 'user', PINNED);
529
+ assert.ok(!r.sandboxKey.includes('alice@corp.com'));
530
+ assert.ok(!r.sandboxKey.includes('@'));
531
+ });
532
+ });
533
+ /**
534
+ * PLAN/agent-identity.md §5.2 (build record §10.4).
535
+ *
536
+ * Shape, not vocabulary. An earlier draft required `^svc:[a-z0-9-]+$` and would have refused to
537
+ * boot on the two handles an enterprise operator is most likely to bind — a SPIFFE ID and an Entra
538
+ * agent object id — which are precisely the federated principals §8 wants this to grow into.
539
+ */
540
+ suite('resolveServiceIdentity: the unattended-run identity (PR-3)', () => {
541
+ const env = (v) => (v === undefined ? {} : { [serve_hardening_2.SERVICE_USER_ENV]: v });
542
+ test('unset is not an error — it just means unattended runs are off', () => {
543
+ const r = (0, serve_hardening_2.resolveServiceIdentity)({});
544
+ assert.strictEqual(r.identity, undefined);
545
+ assert.strictEqual(r.error, undefined);
546
+ assert.strictEqual(r.warning, undefined);
547
+ });
548
+ test('the local svc: convention is accepted silently', () => {
549
+ const r = (0, serve_hardening_2.resolveServiceIdentity)(env('svc:invoice-agent'));
550
+ assert.strictEqual(r.identity, 'svc:invoice-agent');
551
+ assert.strictEqual(r.warning, undefined);
552
+ });
553
+ test('a SPIFFE ID is accepted — the earlier ^svc: rule would have refused to boot on it', () => {
554
+ const r = (0, serve_hardening_2.resolveServiceIdentity)(env('spiffe://corp/ns/agents/invoice'));
555
+ assert.strictEqual(r.identity, 'spiffe://corp/ns/agents/invoice');
556
+ assert.strictEqual(r.error, undefined);
557
+ assert.strictEqual(r.warning, undefined);
558
+ });
559
+ test('an Entra agent object id (bare GUID) is accepted', () => {
560
+ const guid = '3f2504e0-4f89-11d3-9a0c-0305e82c3301';
561
+ const r = (0, serve_hardening_2.resolveServiceIdentity)(env(guid));
562
+ assert.strictEqual(r.identity, guid);
563
+ assert.strictEqual(r.warning, undefined);
564
+ });
565
+ test('an unnamespaced value still works, but warns about collision with a real user id', () => {
566
+ const r = (0, serve_hardening_2.resolveServiceIdentity)(env('invoicebot'));
567
+ assert.strictEqual(r.identity, 'invoicebot');
568
+ assert.strictEqual(r.error, undefined);
569
+ assert.match(r.warning ?? '', /namespace marker/);
570
+ });
571
+ test('set-but-empty is a boot REFUSAL, not a silently anonymous fleet', () => {
572
+ for (const v of ['', ' ']) {
573
+ const r = (0, serve_hardening_2.resolveServiceIdentity)(env(v));
574
+ assert.ok(r.error, `${JSON.stringify(v)} must be refused`);
575
+ assert.strictEqual(r.identity, undefined);
576
+ }
577
+ });
578
+ test('whitespace inside the value is refused rather than silently split or trimmed', () => {
579
+ const r = (0, serve_hardening_2.resolveServiceIdentity)(env('svc:invoice agent'));
580
+ assert.ok(r.error);
581
+ assert.match(r.error, /whitespace/);
582
+ });
583
+ test('an absurdly long value is refused — that is a pasted file, not an identity', () => {
584
+ const r = (0, serve_hardening_2.resolveServiceIdentity)(env(`svc:${'a'.repeat(300)}`));
585
+ assert.ok(r.error);
586
+ assert.match(r.error, /maximum/);
587
+ });
588
+ test('surrounding whitespace is trimmed rather than refused', () => {
589
+ assert.strictEqual((0, serve_hardening_2.resolveServiceIdentity)(env(' svc:nightly ')).identity, 'svc:nightly');
590
+ });
591
+ });
@@ -98,11 +98,12 @@ suite('ServeClient', () => {
98
98
  test('normalizeServeBaseUrl strips trailing slashes', () => {
99
99
  assert.strictEqual((0, serveClient_js_1.normalizeServeBaseUrl)('http://127.0.0.1:8787/'), 'http://127.0.0.1:8787');
100
100
  });
101
- test('buildServeAuthHeaders includes bearer and identity', () => {
102
- assert.deepStrictEqual((0, serveClient_js_1.buildServeAuthHeaders)({ accessToken: 'tok', userEmail: 'you@acme.com' }), {
103
- Authorization: 'Bearer tok',
104
- 'X-Agent-User': 'you@acme.com',
105
- });
101
+ test('buildServeAuthHeaders sends the bearer token and NO self-asserted identity', () => {
102
+ // `X-Agent-User` used to ride along here, and the serve side trusts `x-agent-user` as its
103
+ // first identity alias. On a deployment that does not pin AGENTHIPPO_IDENTITY_HEADER, a
104
+ // client could name someone else and be namespaced into that user's session/state and
105
+ // handed a data-plane nonce scoped to them. The bearer already carries a verified identity.
106
+ assert.deepStrictEqual((0, serveClient_js_1.buildServeAuthHeaders)({ accessToken: 'tok', userEmail: 'you@acme.com' }), { Authorization: 'Bearer tok' });
106
107
  });
107
108
  test('streamRemoteServeChat sends Authorization when auth provided', async () => {
108
109
  const mock = await startMockServeServer([