@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.
- package/out/agentpack/index.js +3 -1
- package/out/agentpack/paths.js +25 -2
- package/out/analytics/dashboard/localFleetAdapter.js +82 -2
- package/out/analytics/dashboard/provider.js +13 -2
- package/out/analytics/dashboard/webview/panel.js +3 -2
- package/out/analytics/identity.js +0 -0
- package/out/analytics/service.js +29 -25
- package/out/analytics/sessionEvents.js +100 -24
- package/out/auth/authBaseUrl.js +59 -0
- package/out/auth/bootstrapApply.js +9 -10
- package/out/auth/brokerClient.js +48 -14
- package/out/auth/provider.js +8 -21
- package/out/auth/sessionStore.js +3 -40
- package/out/auth/signInFlow.js +2 -1
- package/out/auth-broker/config.js +50 -5
- package/out/auth-broker/server.js +126 -26
- package/out/auth-broker/store.js +25 -24
- package/out/authority/warrant.js +206 -0
- package/out/branding.js +27 -1
- package/out/cli/commands/conformance.js +249 -0
- package/out/cli/commands/data-plane.js +151 -0
- package/out/cli/commands/deploy.js +1 -0
- package/out/cli/commands/gateway.js +4 -0
- package/out/cli/commands/pack.js +182 -3
- package/out/cli/commands/sandbox.js +351 -0
- package/out/cli/commands/serve.js +1145 -130
- package/out/cli/commands/store.js +12 -4
- package/out/cli/credential-context.js +17 -0
- package/out/cli/data-plane/gateway.js +28 -5
- package/out/cli/data-plane/index.js +18 -2
- package/out/cli/data-plane/registry.js +9 -0
- package/out/cli/data-plane/remote.js +106 -0
- package/out/cli/data-plane/sidecar.js +225 -0
- package/out/cli/deploy/layers/identity.js +88 -4
- package/out/cli/deploy-init.js +89 -24
- package/out/cli/deps.js +106 -2
- package/out/cli/idp-init.js +35 -0
- package/out/cli/index.js +171 -17
- package/out/cli/pack-install-ref.js +15 -0
- package/out/cli/pack-loader.js +8 -1
- package/out/cli/pack-runtime.js +61 -0
- package/out/cli/runtime.js +35 -1
- package/out/cli/serve-hardening.js +260 -14
- package/out/cli/utils.js +12 -1
- package/out/engines/base.js +32 -3
- package/out/engines/claude.js +33 -15
- package/out/engines/codex.js +111 -27
- package/out/engines/custom.js +15 -1
- package/out/evidence/sessionShip.js +643 -0
- package/out/extension.js +11 -2
- package/out/gateway/bridge.js +40 -2
- package/out/gateway/client.js +34 -1
- package/out/gateway/installSource.js +68 -0
- package/out/gateway/lifecycle.js +185 -51
- package/out/gateway/outboundPaths.js +4 -1
- package/out/gateway/secretsPreflight.js +90 -0
- package/out/gateway/service.js +7 -2
- package/out/headless/index.js +25 -2
- package/out/live-e2e/mcp-demo/server.mjs +32 -0
- package/out/mcp.js +30 -0
- package/out/pack/caps.js +271 -0
- package/out/pack/governor.js +326 -0
- package/out/pack/lease-schema.js +273 -0
- package/out/pack/lease.js +682 -0
- package/out/pack/reconcile.js +217 -0
- package/out/pack/signature.js +839 -0
- package/out/remote/handleRemoteChat.js +44 -3
- package/out/remote/remoteTrust.js +277 -0
- package/out/remote/serveClient.js +52 -5
- package/out/runner/host.js +141 -0
- package/out/runner/protocol.js +128 -0
- package/out/runner/remoteEngine.js +166 -0
- package/out/runtime/interfaces/edit-tracker.js +2 -0
- package/out/runtime/vscode/vscode-edit-tracker.js +2 -0
- package/out/sandbox/containerDriver.js +572 -0
- package/out/sandbox/processDriver.js +318 -0
- package/out/sandbox/sandboxRunner.js +303 -0
- package/out/sandbox/support.js +301 -0
- package/out/sandbox/types.js +55 -0
- package/out/scripts/agentide-litellm.sh +67 -5
- package/out/scripts/agentide_litellm_hooks_v1.py +266 -21
- package/out/scripts/analytics/agentide-litellm-otel.config.yaml +98 -132
- package/out/scripts/analytics/setup-analytics.ps1 +45 -14
- package/out/scripts/analytics/setup-analytics.sh +106 -15
- package/out/scripts/analytics/start-analytics.sh +86 -14
- package/out/scripts/gateway/setup-gateway.sh +72 -32
- package/out/shared/models.js +11 -110
- package/out/spotlight/install.js +7 -1
- package/out/store/index.js +41 -10
- package/out/sync.js +3 -1
- package/out/test/analytics.test.js +162 -23
- package/out/test/auth-baseurl.test.js +132 -0
- package/out/test/auth-bootstrapApply.test.js +27 -6
- package/out/test/auth-broker-hardening.test.js +227 -0
- package/out/test/auth-broker.test.js +59 -9
- package/out/test/branding.test.js +13 -0
- package/out/test/caps-governor.test.js +460 -0
- package/out/test/codex-new-file-survival.test.js +119 -0
- package/out/test/configuration-scopes.test.js +131 -0
- package/out/test/data-plane-engine-wiring.test.js +32 -0
- package/out/test/data-plane.test.js +380 -0
- package/out/test/deploy-init.test.js +47 -1
- package/out/test/gateway-client.test.js +28 -0
- package/out/test/gateway-install-source.test.js +111 -0
- package/out/test/gateway-lifecycle.test.js +1 -1
- package/out/test/gateway-outbound.test.js +7 -0
- package/out/test/gateway-secrets-preflight.test.js +127 -0
- package/out/test/gateway-session-routing.test.js +18 -0
- package/out/test/governor-hardening.test.js +222 -0
- package/out/test/headless-sync.test.js +22 -0
- package/out/test/lease-signature.test.js +357 -0
- package/out/test/lease-trust-root.test.js +273 -0
- package/out/test/lease-validation.test.js +224 -0
- package/out/test/lease.test.js +114 -0
- package/out/test/localFleetAdapter.test.js +91 -0
- package/out/test/mcp.test.js +27 -1
- package/out/test/pack-admission.test.js +216 -0
- package/out/test/pack-digest-parity.test.js +140 -0
- package/out/test/pack-reconcile.test.js +183 -0
- package/out/test/pack-signature-artifact.test.js +103 -0
- package/out/test/pack-signature.test.js +176 -0
- package/out/test/pack-trust-root.test.js +202 -0
- package/out/test/remote-trust.test.js +242 -0
- package/out/test/runner-protocol.test.js +249 -0
- package/out/test/sandbox-driver.test.js +553 -0
- package/out/test/sandbox-hardening.test.js +406 -0
- package/out/test/sandbox-runner.test.js +343 -0
- package/out/test/sandbox-scope.test.js +449 -0
- package/out/test/serve-cron-acceptance.test.js +370 -0
- package/out/test/serve-gateway-turn.test.js +122 -0
- package/out/test/serve-hardening.test.js +227 -15
- package/out/test/serveClient.test.js +6 -5
- package/out/test/sessionShip.test.js +618 -0
- package/out/test/spotlightPublish.test.js +28 -0
- package/out/test/store-install-safe.test.js +69 -0
- package/out/test/tls-bypass.test.js +90 -0
- package/out/test/warrant.test.js +138 -0
- package/out/utils.js +46 -9
- 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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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('
|
|
185
|
-
|
|
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
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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([
|