@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
@@ -36,11 +36,16 @@ var __importStar = (this && this.__importStar) || (function () {
36
36
  };
37
37
  })();
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
- exports.TurnGate = exports.TURN_LIMIT_BUSY_MESSAGE = exports.RATE_LIMIT_BUSY_MESSAGE = exports.TURN_GATE_BUSY_MESSAGE = exports.TURN_GATE_RETRY_AFTER_SECONDS = exports.TURN_QUEUE_ENV = exports.MAX_CONCURRENT_TURNS_ENV = exports.TurnLimiter = exports.SESSION_WITHOUT_USER_REASON = exports.IDENTITY_HEADER_ENV = exports.RateLimiter = exports.XFF_TRUSTED_HOPS_ENV = exports.CONTAINER_SERVE_HOST = exports.SERVE_HOST_ENV = exports.DEFAULT_BIND_HOST = exports.DEFAULT_MAX_BODY_BYTES = void 0;
39
+ exports.TurnGate = exports.TURN_LIMIT_BUSY_MESSAGE = exports.RATE_LIMIT_BUSY_MESSAGE = exports.TURN_GATE_BUSY_MESSAGE = exports.TURN_GATE_RETRY_AFTER_SECONDS = exports.TURN_QUEUE_ENV = exports.MAX_CONCURRENT_TURNS_ENV = exports.TurnLimiter = exports.SESSION_WITHOUT_USER_REASON = exports.REQUIRE_TENANT_ISOLATION_ENV = exports.IDENTITY_PROXY_SECRET_ENV = exports.IDENTITY_HEADER_ENV = exports.PIN_ONLY_IDENTITY_HEADERS = exports.RateLimiter = exports.XFF_TRUSTED_HOPS_ENV = exports.CONTAINER_SERVE_HOST = exports.SERVE_HOST_ENV = exports.DEFAULT_BIND_HOST = exports.SERVICE_USER_ENV = exports.DEFAULT_MAX_BODY_BYTES = void 0;
40
40
  exports.resolveStateDir = resolveStateDir;
41
41
  exports.resolveAnalyticsSessionsDir = resolveAnalyticsSessionsDir;
42
+ exports.resolveServiceIdentity = resolveServiceIdentity;
43
+ exports.isLoopbackHost = isLoopbackHost;
42
44
  exports.resolveBindHost = resolveBindHost;
43
45
  exports.clientIp = clientIp;
46
+ exports.hasTrustedProxyProof = hasTrustedProxyProof;
47
+ exports.identityHeaderAdvice = identityHeaderAdvice;
48
+ exports.tenantIsolationAdvice = tenantIsolationAdvice;
44
49
  exports.resolveUserId = resolveUserId;
45
50
  exports.resolveDataToken = resolveDataToken;
46
51
  exports.peerToUserId = peerToUserId;
@@ -59,12 +64,15 @@ exports.readBodyWithCap = readBodyWithCap;
59
64
  *
60
65
  * SECURITY CONTRACT: serve trusts the identity and data-token headers below unconditionally.
61
66
  * It MUST sit behind a proxy that (a) authenticates the caller and sets the identity header,
62
- * and (b) STRIPS any client-supplied copies of these headers on the way in.
67
+ * and (b) STRIPS any client-supplied copies of these headers on the way in. Deployments where
68
+ * other workloads share the network with serve (compose, sidecar platforms) can additionally
69
+ * require proof that a request came through that proxy — see IDENTITY_PROXY_SECRET_ENV.
63
70
  */
64
71
  const os = __importStar(require("os"));
65
72
  const path = __importStar(require("path"));
66
73
  const crypto = __importStar(require("crypto"));
67
74
  const paths_1 = require("../utils/paths");
75
+ const config_1 = require("../auth-broker/config");
68
76
  /** Default cap on a single request body (1 MiB). Chat messages are small; large bodies are abuse or bugs. */
69
77
  exports.DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
70
78
  /**
@@ -107,8 +115,63 @@ function resolveAnalyticsSessionsDir(stateDir, stateDirEphemeral, env = process.
107
115
  }
108
116
  return path.join((0, paths_1.getAgentideHome)(), 'analytics', 'sessions');
109
117
  }
118
+ /**
119
+ * Env: the identity an UNATTENDED turn runs as — a cron- or channel-fired run with no chat peer
120
+ * (PLAN/agent-identity.md §5). Deployment configuration, deliberately not a pack field: it names
121
+ * an identity that exists only in the operator's environment and must match their role map.
122
+ */
123
+ exports.SERVICE_USER_ENV = 'AGENTHIPPO_SERVICE_USER';
124
+ /** Longest identity we will carry. Generous; the point is to reject a pasted file, not to be tight. */
125
+ const MAX_SERVICE_USER_LEN = 256;
126
+ /**
127
+ * Validate the service identity at boot.
128
+ *
129
+ * **Shape, not vocabulary.** An earlier draft of the design required `^svc:[a-z0-9-]+$` and would
130
+ * have refused to boot on the two handles an enterprise operator is most likely to bind: a SPIFFE
131
+ * ID (`spiffe://corp/ns/agents/invoice`) and a Microsoft Entra agent identity object id. Both are
132
+ * exactly the federated principals §8 wants this to become, so the validator checks that the value
133
+ * is a usable single token and warns — rather than refuses — when it carries no recognisable
134
+ * namespace.
135
+ *
136
+ * Malformed IS a boot refusal though: the alternative is a fleet that looks configured and runs
137
+ * every scheduled job anonymously.
138
+ */
139
+ function resolveServiceIdentity(env = process.env) {
140
+ const raw = env[exports.SERVICE_USER_ENV];
141
+ if (raw === undefined) {
142
+ return {};
143
+ }
144
+ const identity = raw.trim();
145
+ if (!identity) {
146
+ return { error: `${exports.SERVICE_USER_ENV} is set but empty. Unset it, or give it the identity unattended runs should act as.` };
147
+ }
148
+ if (/\s/.test(identity)) {
149
+ return { error: `${exports.SERVICE_USER_ENV} must not contain whitespace, got ${JSON.stringify(raw)}.` };
150
+ }
151
+ if (identity.length > MAX_SERVICE_USER_LEN) {
152
+ return { error: `${exports.SERVICE_USER_ENV} is ${identity.length} chars; the maximum is ${MAX_SERVICE_USER_LEN}.` };
153
+ }
154
+ // Recognised shapes: the local convention, a SPIFFE ID, or a bare GUID (Entra object id).
155
+ const namespaced = /^svc:/.test(identity)
156
+ || /^spiffe:\/\//.test(identity)
157
+ || /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(identity);
158
+ if (!namespaced) {
159
+ return {
160
+ identity,
161
+ warning: `${exports.SERVICE_USER_ENV}="${identity}" carries no namespace marker (svc: / spiffe:// / GUID). `
162
+ + 'It will be used verbatim and must be mapped in the connector role map, exactly like a human identity. '
163
+ + 'If it can collide with a real user id, prefix it with `svc:`.',
164
+ };
165
+ }
166
+ return { identity };
167
+ }
110
168
  /** Loopback default — safe everywhere a human runs `serve` locally. */
111
169
  exports.DEFAULT_BIND_HOST = '127.0.0.1';
170
+ /** True when a bind address is reachable only from this machine. */
171
+ function isLoopbackHost(host) {
172
+ const h = host.trim().toLowerCase().replace(/^\[|\]$/g, '');
173
+ return h === 'localhost' || h === '::1' || /^127\./.test(h);
174
+ }
112
175
  /** Env var for the listen address (used by generated deploy Dockerfiles). */
113
176
  exports.SERVE_HOST_ENV = 'AGENTHIPPO_SERVE_HOST';
114
177
  /** Bind address baked into container deploy artifacts (Docker / Cloud Run sidecars). */
@@ -204,15 +267,177 @@ class RateLimiter {
204
267
  }
205
268
  }
206
269
  exports.RateLimiter = RateLimiter;
270
+ /**
271
+ * Identity headers trusted when NOTHING is pinned.
272
+ *
273
+ * Every entry here is a header that exactly one platform sets AND owns the request path for, so
274
+ * a client-supplied copy cannot reach serve without that platform having been bypassed entirely.
275
+ *
276
+ * `x-agent-user` is deliberately NOT in this list, and this is a breaking change for anything
277
+ * that relied on it being trusted by default. It is AgentHippo's own canonical header — the name
278
+ * an arbitrary client is most likely to guess — it belongs to no platform that strips it, and it
279
+ * used to sit FIRST, so it beat every platform header that follows. The consequence on any
280
+ * deployment that had not pinned `AGENTHIPPO_IDENTITY_HEADER`: any HTTP client could send
281
+ * `x-agent-user: victim@example.com` and be namespaced into that user's session state, attach to
282
+ * their conversation, and be issued a data-plane turn nonce scoped to them. Cloud Run IAP makes
283
+ * this concrete — it strips `X-Goog-*` and forwards a client-supplied `x-agent-user` untouched.
284
+ *
285
+ * It remains perfectly valid as a PINNED header (`AGENTHIPPO_IDENTITY_HEADER=x-agent-user`),
286
+ * which is what a deployment whose proxy really does set and strip it should use — including the
287
+ * Caddy `forward_auth` flow, where `auth-broker/server.ts` sets `X-Agent-User` on `/api/ide/me`
288
+ * so Caddy can copy a VALIDATED identity upstream. That direction is the correct and only source
289
+ * of the header, and it is unaffected: pinning is exactly the declaration that it is trustworthy.
290
+ */
207
291
  const HEADER_ALIASES = [
208
- 'x-agent-user', // AgentHippo canonical (set by our Worker / generic proxy)
209
292
  'x-forwarded-email', // Databricks Apps
210
293
  'x-forwarded-preferred-username', // Databricks Apps
211
294
  'x-goog-authenticated-user-email', // Cloud Run IAP (prefixed value, stripped below)
212
295
  'cf-access-authenticated-user-email', // Cloudflare Access
213
296
  ];
297
+ /**
298
+ * Headers that are only ever trusted when explicitly pinned.
299
+ *
300
+ * Kept as a named list rather than deleted, because "why is my identity header ignored?" needs an
301
+ * answer, and `identityHeaderAdvice` gives one that names the env var to set.
302
+ */
303
+ exports.PIN_ONLY_IDENTITY_HEADERS = ['x-agent-user'];
214
304
  /** Env var: pin the single identity header the deploy layer actually sets (and strips). */
215
305
  exports.IDENTITY_HEADER_ENV = 'AGENTHIPPO_IDENTITY_HEADER';
306
+ /**
307
+ * Env var: shared secret proving the identity header was set by the trusted proxy, not by
308
+ * something else that can reach serve's port.
309
+ *
310
+ * Pinning declares WHICH header carries identity; it says nothing about WHO may assert it. On a
311
+ * shared network (compose, sidecar platforms where every container shares localhost) any
312
+ * neighbour that can dial serve can send the pinned header and be namespaced as that user —
313
+ * network topology cannot express "the agent may dial the gateway but not the reverse". When
314
+ * this is set, serve honours identity headers only on requests that also carry the matching
315
+ * `x-agenthippo-proxy-secret` (the same proof-of-proxy contract the auth-broker enforces for
316
+ * `X-Auth-Request-Email`); without proof the request runs anonymous, same as an unpinned
317
+ * header. Opt-in: unset keeps today's behaviour, because platform-header deployments (IAP,
318
+ * Databricks Apps) have an unforgeable header and no proxy hop that could attach a secret.
319
+ */
320
+ exports.IDENTITY_PROXY_SECRET_ENV = 'AGENTHIPPO_IDENTITY_PROXY_SECRET';
321
+ /**
322
+ * Proof that an identity header was set by the trusted proxy and not by a client: the request
323
+ * carries `x-agenthippo-proxy-secret` matching the configured secret. Shared with the
324
+ * auth-broker, which applies the same contract to `X-Auth-Request-Email` (it lives here rather
325
+ * than in the broker because the broker already imports from this module).
326
+ */
327
+ function hasTrustedProxyProof(headers, expectedSecret) {
328
+ if (!expectedSecret) {
329
+ return false;
330
+ }
331
+ const raw = headers[config_1.AUTH_PROXY_SECRET_HEADER];
332
+ const presented = typeof raw === 'string' ? raw : Array.isArray(raw) ? String(raw[0] ?? '') : '';
333
+ if (!presented) {
334
+ return false;
335
+ }
336
+ // Hash both sides first: timingSafeEqual throws on a length mismatch, which would itself leak
337
+ // the secret's length.
338
+ const a = crypto.createHash('sha256').update(presented).digest();
339
+ const b = crypto.createHash('sha256').update(expectedSecret).digest();
340
+ return crypto.timingSafeEqual(a, b);
341
+ }
342
+ /**
343
+ * Boot-time advice about identity trust. Returns the lines serve must print, loudest first.
344
+ *
345
+ * Not a refusal. Refusing to boot without a pin would stop deployments that are configured
346
+ * correctly today (Databricks Apps, Cloudflare Access) and whose platform header is genuinely
347
+ * unforgeable — a fail-closed default that mostly punishes the compliant is not a security win.
348
+ * What was actually dangerous was `x-agent-user` being trusted by default, and that is now gone.
349
+ */
350
+ function identityHeaderAdvice(env = process.env, bindHost) {
351
+ const pinned = env[exports.IDENTITY_HEADER_ENV]?.trim();
352
+ if (pinned) {
353
+ return [];
354
+ }
355
+ // Severity follows reachability. On loopback nothing off-box can reach the port, so the same
356
+ // paragraph every local run is warning fatigue — and fatigue is why the message gets ignored
357
+ // on the deploy where it decides whether strangers can name themselves as another user.
358
+ const host = bindHost?.trim();
359
+ if (host && isLoopbackHost(host)) {
360
+ return [
361
+ `${exports.IDENTITY_HEADER_ENV} is not set. Fine for this loopback bind; pin it before exposing ` +
362
+ `serve on a network interface.`,
363
+ ];
364
+ }
365
+ return [
366
+ ...(host
367
+ ? [`serve is bound to ${host} — reachable beyond this machine — with no ${exports.IDENTITY_HEADER_ENV} pinned. ` +
368
+ `Anyone who can reach this port is trusted to state who they are.`]
369
+ : []),
370
+ `${exports.IDENTITY_HEADER_ENV} is not set, so identity falls back to the platform header aliases ` +
371
+ `(${HEADER_ALIASES.join(', ')}). That is only safe when your proxy sets AND STRIPS every one of them. ` +
372
+ `Pin the single header your deploy layer really controls, e.g. ${exports.IDENTITY_HEADER_ENV}=x-goog-authenticated-user-email.`,
373
+ `NOTE: ${exports.PIN_ONLY_IDENTITY_HEADERS.join(', ')} is NO LONGER trusted by default — it is not stripped by any ` +
374
+ `platform, so an arbitrary client could send it and be namespaced as another user. If your proxy does set it ` +
375
+ `(the Caddy forward_auth / auth-broker flow does), pin it: ${exports.IDENTITY_HEADER_ENV}=x-agent-user.`,
376
+ ];
377
+ }
378
+ /** Env var: turn the tenant-isolation advice below into a boot refusal. */
379
+ exports.REQUIRE_TENANT_ISOLATION_ENV = 'AGENTHIPPO_REQUIRE_TENANT_ISOLATION';
380
+ /** True when `child` is `parent` or sits beneath it. */
381
+ function isWithin(child, parent) {
382
+ const c = path.resolve(child);
383
+ const p = path.resolve(parent);
384
+ return c === p || c.startsWith(p + path.sep);
385
+ }
386
+ /**
387
+ * Boot-time advice about what a second user of this agent can read off the disk.
388
+ *
389
+ * The condition this exists for: a deployment that PINS an identity header has declared it serves
390
+ * more than one person, and a tier-0 (`--sandbox=process`) or sandbox-less serve gives every one
391
+ * of them the same uid, the same workspace and the same state dir. Per-user namespacing —
392
+ * `userScopedEngineHome`'s `users/<hash>`, user-namespaced session keys, per-session analytics
393
+ * JSONL — is naming, not access control: a same-uid process reads its neighbours' names by
394
+ * listing the directory. What sits there is the engines' own session files, i.e. the plaintext of
395
+ * every other user's conversation, including whatever rows the OBO connector correctly scoped to
396
+ * them on the way in. The data path stays isolated and the artifact path undoes it.
397
+ *
398
+ * The nesting check is the sharper half. `--workspace=/state/workspace --state-dir=/state` is the
399
+ * shape the samples shipped, and it puts the artifact store one `..` above the agent's own cwd —
400
+ * so reaching it takes no traversal trick, just a relative path. Unrelated subtrees
401
+ * (`--workspace=/work --state-dir=/state`) do not make a same-uid process unable to read /state;
402
+ * they stop it being the obvious next directory.
403
+ *
404
+ * Warn, not refuse, by default — the shipped samples are in exactly this shape and a fatal check
405
+ * would brick them on upgrade, which is the same trade `identityHeaderAdvice` documents. An
406
+ * operator who has finished the migration sets REQUIRE_TENANT_ISOLATION_ENV and gets the refusal.
407
+ */
408
+ function tenantIsolationAdvice(input, env = process.env) {
409
+ // Unpinned means serve cannot tell one caller from another at all; identityHeaderAdvice owns
410
+ // that case and saying both at once buries the one the operator can act on.
411
+ if (!env[exports.IDENTITY_HEADER_ENV]?.trim()) {
412
+ return { level: 'ok', lines: [] };
413
+ }
414
+ const perSessionFilesystem = input.sandbox === 'container' && !input.sharedWorkspace;
415
+ if (perSessionFilesystem) {
416
+ return { level: 'ok', lines: [] };
417
+ }
418
+ const tier = input.sandbox === 'process' ? 'a tier-0 (--sandbox=process) sandbox' : 'no sandbox';
419
+ const lines = [
420
+ `${exports.IDENTITY_HEADER_ENV} is pinned — so this serve has more than one user — but it runs with ` +
421
+ `${tier}, which shares one uid, one workspace (${input.workspace ?? 'unset'}) and one state dir ` +
422
+ `(${input.stateDir}) across all of them. Any user can read every other user's engine session ` +
423
+ `files, which hold their conversations verbatim. Per-user paths are namespacing, not isolation.`,
424
+ `The boundary is --sandbox=container (per-session workspace and state, without ` +
425
+ `--shared-workspace) or one serve per tenant. Until then, restrict the pack's tool surface so ` +
426
+ `the agent has no way to read arbitrary files.`,
427
+ ];
428
+ if (input.workspace && isWithin(input.workspace, input.stateDir)) {
429
+ lines.push(`--workspace ${input.workspace} sits INSIDE --state-dir ${input.stateDir}: every other ` +
430
+ `user's sessions, analytics and evidence spool are one '..' above the agent's own cwd. ` +
431
+ `Put them in unrelated subtrees (e.g. --workspace=/work --state-dir=/state).`);
432
+ }
433
+ else if (input.workspace && isWithin(input.stateDir, input.workspace)) {
434
+ lines.push(`--state-dir ${input.stateDir} sits INSIDE --workspace ${input.workspace}: every other ` +
435
+ `user's sessions, analytics and evidence spool are inside the directory the agent is ` +
436
+ `pointed at. Put them in unrelated subtrees (e.g. --workspace=/work --state-dir=/state).`);
437
+ }
438
+ const strict = /^(1|true|yes|on)$/i.test(env[exports.REQUIRE_TENANT_ISOLATION_ENV]?.trim() ?? '');
439
+ return { level: strict ? 'refuse' : 'warn', lines };
440
+ }
216
441
  /**
217
442
  * Resolve the end-user identity for a request.
218
443
  *
@@ -222,7 +447,10 @@ exports.IDENTITY_HEADER_ENV = 'AGENTHIPPO_IDENTITY_HEADER';
222
447
  * that pins a header has declared it has a real proxy-verified identity, so an
223
448
  * unauthenticated caller must not be able to name themselves via the client-
224
449
  * controlled body. Body fallback is dev/internal convenience for the *unpinned*
225
- * case only (e.g. direct curl against a local serve with no identity proxy).
450
+ * case only (e.g. direct curl against a local serve with no identity proxy) —
451
+ * and it must be OPTED INTO via AGENTHIPPO_ALLOW_BODY_IDENTITY=1 (serve-v4 §10
452
+ * hole 3). Before the gate it was safe "by convention": any deployment that
453
+ * forgot to pin a header silently let every caller name an arbitrary victim.
226
454
  *
227
455
  * The alias-list default (used when unpinned) is only safe when the proxy strips
228
456
  * or overwrites ALL of the aliases; platforms that strip only their own header
@@ -231,14 +459,22 @@ exports.IDENTITY_HEADER_ENV = 'AGENTHIPPO_IDENTITY_HEADER';
231
459
  * artifacts set it (e.g. cloudrun-deploy.sh pins `x-goog-authenticated-user-email`)
232
460
  * so an authenticated caller cannot impersonate another user.
233
461
  *
234
- * Resolves identity for `analyticsUserId` attribution and the request-scoped
235
- * credential context (per-user data scoping). Not folded into the session key —
236
- * session continuity uses the client-supplied `session_id`.
462
+ * Resolves identity for `analyticsUserId` attribution, the request-scoped
463
+ * credential context (per-user data scoping), and the user-namespaced session
464
+ * key (resolveServeSessionKey folds this identity into every session key).
237
465
  *
238
466
  * Per-turn *data credentials* (OBO token, e.g. X-Forwarded-Access-Token) are
239
467
  * handled by resolveDataToken — not read here.
240
468
  */
241
469
  function resolveUserId(headers, body, env = process.env) {
470
+ // Proof-of-proxy gate (IDENTITY_PROXY_SECRET_ENV): when configured, no identity — header OR
471
+ // body — is honoured without the matching secret header. A caller that fails this check is
472
+ // by definition not the trusted proxy, so the request runs anonymous rather than letting a
473
+ // network neighbour (or a dev body fallback) name a victim.
474
+ const proxySecret = env[exports.IDENTITY_PROXY_SECRET_ENV]?.trim();
475
+ if (proxySecret && !hasTrustedProxyProof(headers, proxySecret)) {
476
+ return undefined;
477
+ }
242
478
  const pinned = env[exports.IDENTITY_HEADER_ENV]?.trim().toLowerCase();
243
479
  const names = pinned ? [pinned] : HEADER_ALIASES;
244
480
  for (const name of names) {
@@ -257,6 +493,10 @@ function resolveUserId(headers, body, env = process.env) {
257
493
  // access, gcloud run services proxy bypassing IAP) name an arbitrary victim.
258
494
  return undefined;
259
495
  }
496
+ // Unpinned body identity is an explicit opt-in, never a default (§10 hole 3).
497
+ if (env.AGENTHIPPO_ALLOW_BODY_IDENTITY?.trim() !== '1') {
498
+ return undefined;
499
+ }
260
500
  const fromBody = body?.user_id?.trim();
261
501
  return fromBody || undefined;
262
502
  }
@@ -315,20 +555,26 @@ function userNamespaceHash(userId) {
315
555
  * cross-user attachment structurally impossible without changing client behavior.
316
556
  *
317
557
  * Fail-closed rule: when the deployment pins an identity header (it declared a real
318
- * proxy-verified identity exists) and a request carries a session_id but no verified user,
319
- * reject — never let an unauthenticated caller name a session. Unpinned deployments
558
+ * proxy-verified identity exists), any turn without a verified user is rejected — with or
559
+ * without a session_id. Rejecting only named sessions would leave every identity-less
560
+ * caller sharing the durable `anon-unknown` conversation state. Unpinned deployments
320
561
  * (local dev, no identity boundary at all) keep today's raw-key behavior.
321
562
  */
322
- function resolveServeSessionKey(sessionId, userId, env = process.env) {
563
+ function resolveServeSessionKey(sessionId, userId, scope = 'session', env = process.env) {
323
564
  const sid = sessionId?.trim();
324
565
  const uid = userId?.trim();
325
566
  if (uid) {
326
- return { sessionKey: `u${userNamespaceHash(uid)}--${sid || 'default'}` };
567
+ const sessionKey = `u${userNamespaceHash(uid)}--${sid || 'default'}`;
568
+ return { sessionKey, sandboxKey: scope === 'user' ? `u${userNamespaceHash(uid)}` : sessionKey };
327
569
  }
328
- if (sid && env[exports.IDENTITY_HEADER_ENV]?.trim()) {
329
- return { sessionKey: '', rejected: exports.SESSION_WITHOUT_USER_REASON };
570
+ if (env[exports.IDENTITY_HEADER_ENV]?.trim()) {
571
+ return { sessionKey: '', sandboxKey: '', rejected: exports.SESSION_WITHOUT_USER_REASON };
330
572
  }
331
- return { sessionKey: sid || 'anon-unknown' };
573
+ // No verified user, so there is no user to scope a sandbox TO. `user` scope deliberately does
574
+ // not apply here: merging unidentified callers would put two strangers in one sandbox on the
575
+ // strength of a fact nobody established. Local dev keeps one sandbox per session.
576
+ const sessionKey = sid || 'anon-unknown';
577
+ return { sessionKey, sandboxKey: sessionKey };
332
578
  }
333
579
  /** Build CORS headers. Serve is always behind a proxy; CORS is wide-open here. */
334
580
  function corsHeaders() {
package/out/cli/utils.js CHANGED
@@ -77,6 +77,13 @@ function findClaudeCodePath() {
77
77
  if (depsPath) {
78
78
  return depsPath;
79
79
  }
80
+ // 2b. Our OWN dependency tree — the case where the runtime ships as a node app with the
81
+ // SDK already in node_modules. Without this, such a deployment fails with "Claude Code CLI
82
+ // not found" while the SDK sits right there (hit on the Hanoi VM, 2026-07-27).
83
+ const ownCli = (0, deps_1.resolveFromOwnDeps)('@anthropic-ai/claude-agent-sdk', 'cli.js');
84
+ if (ownCli) {
85
+ return ownCli;
86
+ }
80
87
  // 3. Check bundled location (next to binary) - legacy fallback
81
88
  const binaryDir = getBinaryDir();
82
89
  const bundledPath = path.join(binaryDir, 'claude-sdk', 'cli.js');
@@ -131,6 +138,10 @@ function findCodexPath() {
131
138
  return process.env.CODEX_PATH;
132
139
  }
133
140
  // 2. Check npm-installed location (.agenthippo-deps/node_modules)
141
+ const ownCodex = (0, deps_1.resolveFromOwnDeps)('@openai/codex', 'bin/codex.js');
142
+ if (ownCodex) {
143
+ return ownCodex;
144
+ }
134
145
  const depsPath = (0, deps_1.getCodexSdkBinaryPath)();
135
146
  if (depsPath) {
136
147
  return depsPath;
@@ -211,7 +222,7 @@ exports.COLORS = {
211
222
  red: '\x1b[31m',
212
223
  yellow: '\x1b[33m',
213
224
  };
214
- exports.SUGGESTED_LITELLM_SMOKE_TEST_COMMAND = 'AGENTHIPPO_LITELLM_BASE_URL="http://localhost:4000" AGENTHIPPO_LITELLM_API_KEY="dummy" AGENTHIPPO_DEFAULT_MODEL="litellm/gpt-5.3-codex" AGENTHIPPO_DEFAULT_ENGINE="codex" agenthippo ask "Reply with exactly: CLI_TEST_OK"';
225
+ exports.SUGGESTED_LITELLM_SMOKE_TEST_COMMAND = 'AGENTHIPPO_LITELLM_BASE_URL="http://localhost:4000" AGENTHIPPO_LITELLM_API_KEY="dummy" AGENTHIPPO_DEFAULT_MODEL="litellm/gpt-5.5" AGENTHIPPO_DEFAULT_ENGINE="codex" agenthippo ask "Reply with exactly: CLI_TEST_OK"';
215
226
  function getLiteLLMQuickstartHintLines() {
216
227
  return [
217
228
  'Try local LiteLLM quickstart:',
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  /*---------------------------------------------------------------------------------------------
3
3
  * Copyright (c) AgentHippo.ai. All rights reserved.
4
+ * Licensed under the MIT License. See LICENSE.txt in the project root for license information.
4
5
  *--------------------------------------------------------------------------------------------*/
5
6
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
6
7
  if (k2 === undefined) k2 = k;
@@ -42,6 +43,7 @@ exports.generateRequestId = generateRequestId;
42
43
  exports.persistSessionIfNeeded = persistSessionIfNeeded;
43
44
  exports.touchSessionIfNeeded = touchSessionIfNeeded;
44
45
  exports.getPersistedSessionId = getPersistedSessionId;
46
+ const identity_1 = require("../analytics/identity");
45
47
  const session_1 = require("../session");
46
48
  const path = __importStar(require("path"));
47
49
  /**
@@ -54,16 +56,26 @@ const path = __importStar(require("path"));
54
56
  * Langfuse passthrough: langfuse_session_id / langfuse_trace_user_id headers
55
57
  * are included so users who add Langfuse as a LiteLLM callback get session
56
58
  * grouping automatically.
59
+ *
60
+ * IDENTITY: the user id is a salted hash (see analytics/identity.ts). It used to be the raw
61
+ * value — usually an email — sent on two headers to a proxy that logs every request and forwards
62
+ * to a third-party observability backend. The hash is stable, so grouping and per-user analytics
63
+ * still work; it is just no longer a directory of who works here. Set
64
+ * AGENTHIPPO_EVIDENCE_RAW_IDS=1 to send the raw value again.
65
+ *
66
+ * The provider never sees either form: `additional_drop_params: ["user"]` in the LiteLLM config
67
+ * strips the end-user field before dispatch.
57
68
  */
58
69
  function buildLiteLLMMetadataHeaders(useLiteLLM, metadata) {
59
70
  if (!useLiteLLM) {
60
71
  return [];
61
72
  }
62
73
  const headers = [];
63
- if (metadata.analyticsUserId?.trim()) {
64
- headers.push(`x-litellm-metadata-user-id: ${metadata.analyticsUserId.trim()}`);
74
+ const userId = (0, identity_1.hashIdentity)(metadata.analyticsUserId);
75
+ if (userId) {
76
+ headers.push(`x-litellm-metadata-user-id: ${userId}`);
65
77
  // Langfuse passthrough: maps to Langfuse trace user_id when Langfuse callback is enabled
66
- headers.push(`langfuse_trace_user_id: ${metadata.analyticsUserId.trim()}`);
78
+ headers.push(`langfuse_trace_user_id: ${userId}`);
67
79
  }
68
80
  if (metadata.sessionId?.trim()) {
69
81
  headers.push(`x-litellm-metadata-session-id: ${metadata.sessionId.trim()}`);
@@ -88,6 +100,23 @@ function buildLiteLLMMetadataHeaders(useLiteLLM, metadata) {
88
100
  if (metadata.requestId?.trim()) {
89
101
  headers.push(`x-litellm-metadata-request-id: ${metadata.requestId.trim()}`);
90
102
  }
103
+ // Provenance stamp (PLAN/compliance.md §3.1): lands in requester_metadata on every OTEL
104
+ // span, which is where the evidence index reads policy_version / pack_digest from.
105
+ if (metadata.policyVersion?.trim()) {
106
+ headers.push(`x-litellm-metadata-policy-version: ${metadata.policyVersion.trim()}`);
107
+ }
108
+ if (metadata.packDigest?.trim()) {
109
+ headers.push(`x-litellm-metadata-pack-digest: ${metadata.packDigest.trim()}`);
110
+ }
111
+ if (metadata.engineVersion?.trim()) {
112
+ headers.push(`x-litellm-metadata-engine-version: ${metadata.engineVersion.trim()}`);
113
+ }
114
+ if (metadata.tier !== undefined) {
115
+ headers.push(`x-litellm-metadata-tier: ${metadata.tier}`);
116
+ }
117
+ if (metadata.instanceId?.trim()) {
118
+ headers.push(`x-litellm-metadata-instance-id: ${metadata.instanceId.trim()}`);
119
+ }
91
120
  return headers;
92
121
  }
93
122
  /**
@@ -39,14 +39,15 @@ Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.ClaudeEngine = void 0;
40
40
  const fs_1 = require("fs");
41
41
  const path = __importStar(require("path"));
42
+ const branding_1 = require("../branding");
42
43
  const base_1 = require("./base");
43
44
  const utils_1 = require("../utils");
44
45
  const permissions_1 = require("../permissions");
45
46
  const mcp_1 = require("../mcp");
46
- const data_plane_1 = require("../cli/data-plane");
47
47
  const runtime_1 = require("../cli/runtime");
48
48
  const session_1 = require("../session");
49
49
  const agentpack_1 = require("../agentpack");
50
+ const credential_context_1 = require("../cli/credential-context");
50
51
  const shared_1 = require("../shared");
51
52
  // File tools we track for edit diffs via hooks (Set for O(1) lookup)
52
53
  // Includes Claude SDK tools, Computer Use tools, and common variations
@@ -261,6 +262,11 @@ class ClaudeEngine {
261
262
  source: 'agenthippo',
262
263
  engine: 'claude',
263
264
  requestId: options.requestId,
265
+ policyVersion: options.policyVersion,
266
+ packDigest: options.packDigest,
267
+ instanceId: options.instanceId,
268
+ engineVersion: options.engineVersion,
269
+ tier: options.tier,
264
270
  });
265
271
  // Build environment for Claude SDK.
266
272
  // Setup-token auth must use Claude's OAuth token env var instead of ANTHROPIC_API_KEY.
@@ -270,11 +276,13 @@ class ClaudeEngine {
270
276
  'DEBUG_CLAUDE_AGENT_SDK': '1',
271
277
  'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC': '1',
272
278
  };
273
- // Egress broker active ⇒ the gateway token stays broker-side; scrub it from the child
274
- // env so it never reaches the model (secure-obo-design §7.6 / §7.4 gap 1).
275
- if (claudeEnv[data_plane_1.EGRESS_URL_ENV]) {
276
- delete claudeEnv[runtime_1.OPENCLAW_GATEWAY_TOKEN_ENV];
277
- }
279
+ // The gateway token stays broker-side; scrub it from the child env so it never reaches
280
+ // the model (secure-obo-design §7.6 / §7.4 gap 1). UNCONDITIONALLY (serve-v4 §10 hole 2):
281
+ // gating this on the egress broker let a deployment without --gateway-bridge re-inherit
282
+ // the token, including via buildCliEnvPatch's bake from the host's ~/.openclaw config.
283
+ delete claudeEnv[runtime_1.OPENCLAW_GATEWAY_TOKEN_ENV];
284
+ // Data-plane sidecar control token: never the engine's to hold (secure-obo-design §7.6).
285
+ delete claudeEnv['AGENTHIPPO_DATA_PLANE_TOKEN'];
278
286
  const isSetupTokenAuth = (0, shared_1.isAnthropicSetupToken)(apiKey);
279
287
  if (isSetupTokenAuth) {
280
288
  const normalizedSetupToken = (0, shared_1.normalizeAnthropicSetupToken)(apiKey);
@@ -303,8 +311,13 @@ class ClaudeEngine {
303
311
  // Note: cwd is ALWAYS workspaceRoot - active agent content is synced to workspace's .claude/.codex
304
312
  const agentId = options.agentPackId ?? 'default';
305
313
  const runtimePaths = (0, agentpack_1.resolveRuntimePaths)(options.workspaceRoot, this.config.storagePath, agentId, options.agentPackVersion);
306
- // Claude config directory (isolated per agent for session/state isolation)
307
- const claudeConfigDir = runtimePaths.claudeConfigDir;
314
+ // Claude config directory (isolated per agent for session/state isolation). In
315
+ // multi-tenant serve, additionally key it by the verified request user — the same
316
+ // §8 Phase 0 scoping codex.ts applies to CODEX_HOME. A shared per-agent dir lets
317
+ // concurrent turns of different users race each other's per-turn credential state
318
+ // (the agentpack/paths.ts race); per-user scoping makes any remaining race
319
+ // same-user. No credential frame (IDE, single-tenant CLI) ⇒ unchanged path.
320
+ const claudeConfigDir = (0, agentpack_1.userScopedEngineHomeFromKey)(runtimePaths.claudeConfigDir, (0, credential_context_1.resolveEngineHomeUserKey)(options.engineHomeUserKey));
308
321
  claudeEnv['CLAUDE_CONFIG_DIR'] = claudeConfigDir;
309
322
  await fs_1.promises.mkdir(claudeConfigDir, { recursive: true });
310
323
  // Always set AGENT_ID for agent to know its identity
@@ -549,9 +562,10 @@ class ClaudeEngine {
549
562
  logger.debug(`[AgentIDE] SessionStart hook called! event=${input.hook_event_name}`);
550
563
  return CONTINUE;
551
564
  };
552
- // Load MCP servers for the active agent and pass directly to SDK. claudeEnv is the
553
- // per-turn child env — URL servers resolve their bearerTokenEnv (turn nonce) from it,
554
- // in memory, so no per-turn value ever lands in a shared config file.
565
+ // Load MCP servers for the active agent and pass directly to SDK. URL servers carry a
566
+ // `${NAME}` bearer reference the CLI expands from claudeEnv at connect time, so the
567
+ // turn nonce rides neither a shared config file nor the SDK's --mcp-config argv —
568
+ // claudeEnv is passed for the rare non-expandable-name fallback only.
555
569
  const mcpServers = await this.loadMcpServersForClaude(logger, options.workspaceRoot, agentId, claudeEnv);
556
570
  // Determine cwd and settingSources
557
571
  // cwd is ALWAYS workspaceRoot - active agent content has been synced to workspace's .claude/
@@ -1068,7 +1082,7 @@ class ClaudeEngine {
1068
1082
  logger.info(`[AgentIDE] Claude thinking summary: startBlocks=${thinkingStartBlockCount}, redactedBlocks=${redactedThinkingBlockCount}, deltas=${thinkingDeltaCount}, chars=${thinkingCharCount}`);
1069
1083
  logger.info(`[AgentIDE] Claude tool summary: toolBlocks=${toolUseBlockCount}, toolEmits=${toolUseEmitCount}, toolProgressMsgs=${toolProgressCount}, inputJsonDeltas=${inputJsonDeltaCount}`);
1070
1084
  if (thinkingStartBlockCount === 0 && thinkingDeltaCount === 0 && redactedThinkingBlockCount === 0) {
1071
- logger.debug('[AgentIDE] Claude emitted no thinking blocks/deltas. Ensure model supports extended thinking and set agentide.claude.maxThinkingTokens >= 1024.');
1085
+ logger.debug(`[AgentIDE] Claude emitted no thinking blocks/deltas. Ensure model supports extended thinking and set ${(0, branding_1.toSettingsDisplayKey)('agentide.claude.maxThinkingTokens')} >= 1024.`);
1072
1086
  }
1073
1087
  if (toolUseBlockCount === 0 && toolProgressCount === 0) {
1074
1088
  logger.debug('[AgentIDE] Claude emitted no tool usage events. If tools were used, ensure includePartialMessages=true and parse tool_use blocks.');
@@ -1202,8 +1216,12 @@ class ClaudeEngine {
1202
1216
  * - Pack agents: uses pack's mcp/ directory
1203
1217
  *
1204
1218
  * The SDK accepts mcpServers directly in query options — including HTTP Streamable
1205
- * servers (`{type:'http', url, headers}`). `turnEnv` is the per-turn child env; a URL
1206
- * server's `bearerTokenEnv` (e.g. AGENTHIPPO_TURN_NONCE) resolves against it in memory.
1219
+ * servers (`{type:'http', url, headers}`). A URL server's `bearerTokenEnv` (e.g.
1220
+ * AGENTHIPPO_TURN_NONCE) becomes a `${NAME}` reference the CLI expands from its own env,
1221
+ * NOT a resolved header: the SDK serializes mcpServers into `--mcp-config` argv, and a
1222
+ * resolved bearer there is world-readable via /proc/<pid>/cmdline (see
1223
+ * referenceHttpMcpHeaders). `turnEnv` is the per-turn child env, used only for the
1224
+ * fallback when a name is outside the CLI's expansion grammar.
1207
1225
  */
1208
1226
  async loadMcpServersForClaude(logger, workspaceRoot, agentId = 'default', turnEnv) {
1209
1227
  const basePromptsDir = this.getBundledBasePromptsDir();
@@ -1214,7 +1232,7 @@ class ClaudeEngine {
1214
1232
  const mcpServers = {};
1215
1233
  for (const s of servers) {
1216
1234
  if (s.url) {
1217
- const headers = (0, mcp_1.resolveHttpMcpHeaders)(s, turnEnv ?? process.env);
1235
+ const headers = (0, mcp_1.referenceHttpMcpHeaders)(s, turnEnv ?? process.env);
1218
1236
  mcpServers[s.name] = {
1219
1237
  type: 'http',
1220
1238
  url: s.url,