From 423cd18232fedd4b248180802c85a629f628f8c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 27 Aug 2026 19:36:00 +0200 Subject: [PATCH] fix(daemon): fail closed when the auth hook is silent about tenant An auth hook that ran but returned no tenantId opted the deployment into tenant attestation; falling back to the client's own claim (RPC body meta.tenantId, aux-route x-agent-device-tenant header) let a holder of one valid shared token impersonate any tenant on /rpc and on the diagnostics/ upload/download routes. resolveTrustedTenant() in the new src/daemon/server/tenant-trust.ts is now the single seam both surfaces go through and the only place that computes the resulting identity: hook attests -> use it; no hook configured -> keep today's client-declared behavior (loopback/dev unchanged); hook configured but silent with a client-declared tenant -> refuse (401) instead of trusting the claim, and no raw client-declared metadata survives into the dispatched request in that case either. Fixes #2095 --- CHANGELOG.md | 8 + src/__tests__/test-utils/env.ts | 4 + .../http-server-tenant-trust.test.ts | 466 ++++++++++++++++++ src/daemon/server/http-server.ts | 48 +- src/daemon/server/tenant-trust.ts | 22 + .../integration/provider-scenarios/harness.ts | 5 +- test/wire-compat/ledger.json | 7 +- website/docs/docs/security-trust.md | 2 + 8 files changed, 546 insertions(+), 16 deletions(-) create mode 100644 src/__tests__/test-utils/env.ts create mode 100644 src/daemon/__tests__/http-server-tenant-trust.test.ts create mode 100644 src/daemon/server/tenant-trust.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 44951b5d8d..069d1fd4a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +- Security (daemon, remote/proxy HTTP only): when `AGENT_DEVICE_HTTP_AUTH_HOOK` is configured and a + request's hook result does not attest a `tenantId`, the request is now refused (401) outright — the + daemon no longer runs it as whichever tenant the client declared (RPC body `meta.tenantId` or + `flags.tenant`, or the `x-agent-device-tenant` header on the upload/artifact-download/diagnostics + routes) and no longer admits it unscoped when the client declares nothing either. This closes both + a shared-token impersonation path and an unscoped-access path to tenant-owned sessions/artifacts in + multi-tenant deployments. Deployments with no hook configured (the local loopback CLI) are + unaffected. A hook must attest `tenantId` on every request it wants the daemon to admit. - Breaking (0.21): removed aggregate performance compatibility (`perf`, `perf sample`, `perf metrics`, the `metrics` alias, optionless `client.observability.perf()`, and SDK `area: 'metrics'`). Use `perf frames`, `perf memory sample`, `perf cpu profile start|stop|report`, or `perf trace start|stop`; removed CLI and raw daemon forms fail with this migration guidance. - Breaking (0.21): removed legacy batch JSON steps with `positionals`/`flags`. Use `{"command":"...","input":{...}}`; rejected steps now include a concrete structured example. - Breaking (0.21): removed the deprecated Node client `command.rotate` wrapper and its `RotateCommandOptions` / `RotateCommandResult` exports. Use `command.orientation`; the already-removed CLI `rotate` form keeps its targeted migration error. diff --git a/src/__tests__/test-utils/env.ts b/src/__tests__/test-utils/env.ts new file mode 100644 index 0000000000..8c611dadcf --- /dev/null +++ b/src/__tests__/test-utils/env.ts @@ -0,0 +1,4 @@ +export function restoreEnv(key: string, previous: string | undefined): void { + if (previous === undefined) delete process.env[key]; + else process.env[key] = previous; +} diff --git a/src/daemon/__tests__/http-server-tenant-trust.test.ts b/src/daemon/__tests__/http-server-tenant-trust.test.ts new file mode 100644 index 0000000000..47282b58d6 --- /dev/null +++ b/src/daemon/__tests__/http-server-tenant-trust.test.ts @@ -0,0 +1,466 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { createDaemonHttpServer } from '../server/http-server.ts'; +import { resolveSessionRequestLogPath } from '../session-store.ts'; +import { safeSessionName } from '../session-paths.ts'; +import { DAEMON_HTTP_TENANT_HEADER } from '../http-contract.ts'; +import type { DaemonRequest, DaemonResponse } from '../types.ts'; +import { + closeLoopbackServer, + listenOnLoopback, + skipWhenLoopbackUnavailable, +} from '../../__tests__/test-utils/loopback.ts'; +import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; +import { restoreEnv } from '../../__tests__/test-utils/env.ts'; + +const DAEMON_TOKEN = 'daemon-secret'; +const DIAGNOSTICS_RECORD = '{"phase":"request_start"}\n{"phase":"request_failed"}\n'; + +function writeSilentAuthHook(root: string): string { + const hookPath = path.join(root, 'silent-auth-hook.mjs'); + fs.writeFileSync(hookPath, 'export default function authHook() { return { ok: true }; }\n'); + return hookPath; +} + +const ATTESTED_TENANT_ID = 'tenant-real'; + +function writeAttestingAuthHook(root: string): string { + const hookPath = path.join(root, 'attesting-auth-hook.mjs'); + fs.writeFileSync( + hookPath, + "export default function authHook() { return { tenantId: 'tenant-real' }; }\n", + ); + return hookPath; +} + +async function withRpcServer( + hookPath: string | undefined, + run: (ctx: { baseUrl: string; observedRequests: DaemonRequest[] }) => Promise, +): Promise { + const previousHook = process.env.AGENT_DEVICE_HTTP_AUTH_HOOK; + if (hookPath) process.env.AGENT_DEVICE_HTTP_AUTH_HOOK = hookPath; + else delete process.env.AGENT_DEVICE_HTTP_AUTH_HOOK; + + const observedRequests: DaemonRequest[] = []; + const server = await createDaemonHttpServer({ + token: DAEMON_TOKEN, + handleRequest: async (req): Promise => { + observedRequests.push(req); + return { ok: true, data: { meta: req.meta } }; + }, + }); + try { + const port = await listenOnLoopback(server); + await run({ baseUrl: `http://127.0.0.1:${port}`, observedRequests }); + } finally { + await closeLoopbackServer(server); + restoreEnv('AGENT_DEVICE_HTTP_AUTH_HOOK', previousHook); + } +} + +async function callRpc( + baseUrl: string, + payload: Record, +): Promise<{ status: number; body: Record }> { + const response = await fetch(`${baseUrl}/rpc`, { + method: 'POST', + headers: { + authorization: `Bearer ${DAEMON_TOKEN}`, + 'content-type': 'application/json', + }, + body: JSON.stringify(payload), + }); + return { status: response.status, body: (await response.json()) as Record }; +} + +async function withDiagnosticsHookServer( + hookPath: string | undefined, + run: (ctx: { baseUrl: string; sessionsDir: string }) => Promise, +): Promise { + const previousHook = process.env.AGENT_DEVICE_HTTP_AUTH_HOOK; + if (hookPath) process.env.AGENT_DEVICE_HTTP_AUTH_HOOK = hookPath; + else delete process.env.AGENT_DEVICE_HTTP_AUTH_HOOK; + + const stateDir = mkdtempForTestSync('agent-device-tenant-trust-diagnostics-'); + const sessionsDir = path.join(stateDir, 'sessions'); + const server = await createDaemonHttpServer({ + token: DAEMON_TOKEN, + handleRequest: async (): Promise => ({ ok: true, data: {} }), + resolveRequestDiagnosticsPath: (ref) => + resolveSessionRequestLogPath( + path.join(sessionsDir, safeSessionName(ref.session)), + ref.requestId, + ), + }); + try { + const port = await listenOnLoopback(server); + await run({ baseUrl: `http://127.0.0.1:${port}`, sessionsDir }); + } finally { + await closeLoopbackServer(server); + restoreEnv('AGENT_DEVICE_HTTP_AUTH_HOOK', previousHook); + fs.rmSync(stateDir, { recursive: true, force: true }); + } +} + +function writeDiagnosticsRecord(sessionsDir: string, session: string, requestId: string): void { + const recordPath = resolveSessionRequestLogPath( + path.join(sessionsDir, safeSessionName(session)), + requestId, + ); + fs.mkdirSync(path.dirname(recordPath), { recursive: true }); + fs.writeFileSync(recordPath, DIAGNOSTICS_RECORD); +} + +function diagnosticsUrl(baseUrl: string, session: string, requestId: string): string { + return `${baseUrl}/sessions/${encodeURIComponent(session)}/requests/${encodeURIComponent(requestId)}/diagnostics`; +} + +test('RPC: a hook configured but silent on tenant refuses a client-declared meta.tenantId', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const root = mkdtempForTestSync('agent-device-tenant-trust-rpc-'); + try { + const hookPath = writeSilentAuthHook(root); + await withRpcServer(hookPath, async ({ baseUrl, observedRequests }) => { + const response = await callRpc(baseUrl, { + jsonrpc: '2.0', + id: 'rpc-impersonate', + method: 'agent_device.command', + params: { + command: 'session_list', + positionals: [], + meta: { tenantId: 'victim' }, + }, + }); + assert.equal(response.status, 401); + assert.equal(response.body.error?.code, -32001); + assert.equal( + observedRequests.length, + 0, + 'the handler must never see the impersonated request', + ); + }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('RPC: a hook configured but silent on tenant refuses a client-declared lease tenantId', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const root = mkdtempForTestSync('agent-device-tenant-trust-rpc-lease-'); + try { + const hookPath = writeSilentAuthHook(root); + await withRpcServer(hookPath, async ({ baseUrl, observedRequests }) => { + const response = await callRpc(baseUrl, { + jsonrpc: '2.0', + id: 'rpc-impersonate-lease', + method: 'agent_device.lease.allocate', + params: { + tenantId: 'victim', + runId: 'run-1', + ttlMs: 60000, + backend: 'android-instance', + }, + }); + assert.equal(response.status, 401); + assert.equal(response.body.error?.code, -32001); + assert.equal( + observedRequests.length, + 0, + 'the handler must never see the impersonated request', + ); + }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('RPC: a hook configured but silent on tenant refuses a client-declared flags.tenant', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const root = mkdtempForTestSync('agent-device-tenant-trust-rpc-flags-'); + try { + const hookPath = writeSilentAuthHook(root); + await withRpcServer(hookPath, async ({ baseUrl, observedRequests }) => { + const response = await callRpc(baseUrl, { + jsonrpc: '2.0', + id: 'rpc-impersonate-flags', + method: 'agent_device.command', + params: { + command: 'session_list', + positionals: [], + flags: { tenant: 'victim', sessionIsolation: 'tenant' }, + }, + }); + assert.equal(response.status, 401); + assert.equal(response.body.error?.code, -32001); + assert.equal( + observedRequests.length, + 0, + 'the handler must never see the impersonated request', + ); + }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('RPC: a hook configured but silent on tenant refuses a request declaring no tenant at all', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const root = mkdtempForTestSync('agent-device-tenant-trust-rpc-omitted-'); + try { + const hookPath = writeSilentAuthHook(root); + await withRpcServer(hookPath, async ({ baseUrl, observedRequests }) => { + const response = await callRpc(baseUrl, { + jsonrpc: '2.0', + id: 'rpc-omitted-tenant', + method: 'agent_device.command', + params: { + command: 'session_list', + positionals: [], + }, + }); + assert.equal(response.status, 401); + assert.equal(response.body.error?.code, -32001); + assert.equal( + observedRequests.length, + 0, + 'the handler must never see an unscoped request once a hook is configured', + ); + }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('RPC: a hook that attests a tenant wins over a mismatched client-declared meta.tenantId', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const root = mkdtempForTestSync('agent-device-tenant-trust-rpc-attest-'); + try { + const hookPath = writeAttestingAuthHook(root); + await withRpcServer(hookPath, async ({ baseUrl, observedRequests }) => { + const response = await callRpc(baseUrl, { + jsonrpc: '2.0', + id: 'rpc-attested', + method: 'agent_device.command', + params: { + command: 'session_list', + positionals: [], + meta: { tenantId: 'victim' }, + }, + }); + assert.equal(response.status, 200); + assert.equal(observedRequests[0]?.meta?.tenantId, ATTESTED_TENANT_ID); + }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('RPC: a hook that attests a tenant overwrites a mismatched client-declared flags.tenant', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const root = mkdtempForTestSync('agent-device-tenant-trust-rpc-attest-flags-'); + try { + const hookPath = writeAttestingAuthHook(root); + await withRpcServer(hookPath, async ({ baseUrl, observedRequests }) => { + const response = await callRpc(baseUrl, { + jsonrpc: '2.0', + id: 'rpc-attested-flags', + method: 'agent_device.command', + params: { + command: 'session_list', + positionals: [], + flags: { tenant: 'victim', sessionIsolation: 'tenant' }, + }, + }); + assert.equal(response.status, 200); + assert.equal(observedRequests[0]?.meta?.tenantId, ATTESTED_TENANT_ID); + assert.equal( + observedRequests[0]?.flags?.tenant, + ATTESTED_TENANT_ID, + 'the flag must not survive as a second, unattested route to identity', + ); + }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('RPC: no hook configured keeps a client-declared meta.tenantId unchanged (regression)', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + await withRpcServer(undefined, async ({ baseUrl, observedRequests }) => { + const response = await callRpc(baseUrl, { + jsonrpc: '2.0', + id: 'rpc-loopback', + method: 'agent_device.command', + params: { + command: 'session_list', + positionals: [], + meta: { tenantId: 'tenant-x' }, + }, + }); + assert.equal(response.status, 200); + assert.equal(observedRequests[0]?.meta?.tenantId, 'tenant-x'); + }); +}); + +test('RPC: no hook configured keeps a client-declared flags.tenant unchanged (regression)', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + await withRpcServer(undefined, async ({ baseUrl, observedRequests }) => { + const response = await callRpc(baseUrl, { + jsonrpc: '2.0', + id: 'rpc-loopback-flags', + method: 'agent_device.command', + params: { + command: 'session_list', + positionals: [], + flags: { tenant: 'tenant-x', sessionIsolation: 'tenant' }, + }, + }); + assert.equal(response.status, 200); + assert.equal(observedRequests[0]?.meta?.tenantId, 'tenant-x'); + assert.equal(observedRequests[0]?.flags?.tenant, 'tenant-x'); + }); +}); + +test('aux route: a hook configured but silent on tenant refuses a client-declared header claiming another tenant', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const root = mkdtempForTestSync('agent-device-tenant-trust-aux-'); + try { + const hookPath = writeSilentAuthHook(root); + await withDiagnosticsHookServer(hookPath, async ({ baseUrl, sessionsDir }) => { + writeDiagnosticsRecord(sessionsDir, 'victim-tenant:default', 'abc123'); + const response = await fetch(diagnosticsUrl(baseUrl, 'victim-tenant:default', 'abc123'), { + headers: { + authorization: `Bearer ${DAEMON_TOKEN}`, + [DAEMON_HTTP_TENANT_HEADER]: 'victim-tenant', + }, + }); + assert.equal(response.status, 401); + assert.equal((await response.text()).includes('request_start'), false); + }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('aux route: a hook configured but silent on tenant refuses an omitted header reading a tenant-owned session', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const root = mkdtempForTestSync('agent-device-tenant-trust-aux-omitted-'); + try { + const hookPath = writeSilentAuthHook(root); + await withDiagnosticsHookServer(hookPath, async ({ baseUrl, sessionsDir }) => { + writeDiagnosticsRecord(sessionsDir, 'victim-tenant:default', 'abc123'); + const response = await fetch(diagnosticsUrl(baseUrl, 'victim-tenant:default', 'abc123'), { + headers: { authorization: `Bearer ${DAEMON_TOKEN}` }, + }); + assert.equal(response.status, 401); + assert.equal( + (await response.text()).includes('request_start'), + false, + 'an unscoped caller must not read a tenant-owned session by naming it directly', + ); + }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('aux route: a hook that attests a tenant wins over a mismatched client-declared header', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const root = mkdtempForTestSync('agent-device-tenant-trust-aux-attest-'); + try { + const hookPath = writeAttestingAuthHook(root); + await withDiagnosticsHookServer(hookPath, async ({ baseUrl, sessionsDir }) => { + writeDiagnosticsRecord(sessionsDir, `${ATTESTED_TENANT_ID}:default`, 'abc123'); + const owner = await fetch( + diagnosticsUrl(baseUrl, `${ATTESTED_TENANT_ID}:default`, 'abc123'), + { + headers: { + authorization: `Bearer ${DAEMON_TOKEN}`, + [DAEMON_HTTP_TENANT_HEADER]: 'victim-tenant', + }, + }, + ); + assert.equal(owner.status, 200); + assert.equal(await owner.text(), DIAGNOSTICS_RECORD); + }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('aux route: no hook configured keeps the header-declared tenant unchanged (regression)', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + await withDiagnosticsHookServer(undefined, async ({ baseUrl, sessionsDir }) => { + writeDiagnosticsRecord(sessionsDir, 'tenant-a:default', 'abc123'); + const owner = await fetch(diagnosticsUrl(baseUrl, 'tenant-a:default', 'abc123'), { + headers: { + authorization: `Bearer ${DAEMON_TOKEN}`, + [DAEMON_HTTP_TENANT_HEADER]: 'tenant-a', + }, + }); + assert.equal(owner.status, 200); + const otherTenant = await fetch(diagnosticsUrl(baseUrl, 'tenant-a:default', 'abc123'), { + headers: { + authorization: `Bearer ${DAEMON_TOKEN}`, + [DAEMON_HTTP_TENANT_HEADER]: 'tenant-b', + }, + }); + assert.equal(otherTenant.status, 401); + }); +}); + +test('aux route (upload): a hook configured but silent on tenant refuses a client-declared header', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const root = mkdtempForTestSync('agent-device-tenant-trust-upload-'); + const previousHook = process.env.AGENT_DEVICE_HTTP_AUTH_HOOK; + process.env.AGENT_DEVICE_HTTP_AUTH_HOOK = writeSilentAuthHook(root); + const server = await createDaemonHttpServer({ + token: DAEMON_TOKEN, + handleRequest: async (): Promise => ({ ok: true, data: {} }), + }); + try { + const port = await listenOnLoopback(server); + const response = await fetch(`http://127.0.0.1:${port}/upload`, { + method: 'POST', + headers: { + authorization: `Bearer ${DAEMON_TOKEN}`, + [DAEMON_HTTP_TENANT_HEADER]: 'victim-tenant', + 'x-artifact-type': 'file', + 'x-artifact-filename': 'demo.apk', + 'content-type': 'application/octet-stream', + }, + body: Buffer.from('fake-apk'), + }); + assert.equal(response.status, 401); + } finally { + await closeLoopbackServer(server); + restoreEnv('AGENT_DEVICE_HTTP_AUTH_HOOK', previousHook); + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('RPC: a whitespace-only meta.tenantId is refused the same as any other unattested request under a silent hook', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const root = mkdtempForTestSync('agent-device-tenant-trust-rpc-blank-'); + try { + const hookPath = writeSilentAuthHook(root); + await withRpcServer(hookPath, async ({ baseUrl, observedRequests }) => { + const response = await callRpc(baseUrl, { + jsonrpc: '2.0', + id: 'rpc-blank-tenant', + method: 'agent_device.command', + params: { + command: 'session_list', + positionals: [], + meta: { tenantId: ' ' }, + }, + }); + assert.equal(response.status, 401); + assert.equal(observedRequests.length, 0); + }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/src/daemon/server/http-server.ts b/src/daemon/server/http-server.ts index 017d987d95..acf441814b 100644 --- a/src/daemon/server/http-server.ts +++ b/src/daemon/server/http-server.ts @@ -38,6 +38,7 @@ import { sendRestJsonError, statusCodeForNormalizedError } from '../http-errors. import { tryHandleUploadHttpRoute } from '../upload-http.ts'; import { tryHandleDownloadableArtifactHttpRoute } from '../downloadable-artifact-http.ts'; import { tryHandleRequestDiagnosticsHttpRoute } from '../request-diagnostics-http.ts'; +import { resolveTrustedTenant, tenantTrustRejectionError } from './tenant-trust.ts'; type JsonRpcRequest = JsonRpcRequestEnvelope; @@ -665,6 +666,7 @@ export async function createDaemonHttpServer(options: { requestId: requestIdForCleanup, }; requestAbortRegistration = registerRequestAbort(requestIdForCleanup); + const clientDeclaredTenant = daemonRequest.meta?.tenantId ?? daemonRequest.flags?.tenant; const authResult = await runHttpAuthHook(authHook, { headers: req.headers, @@ -675,15 +677,31 @@ export async function createDaemonHttpServer(options: { sendJson(res, authResult.response, authResult.statusCode); return; } - if (authResult.tenantId) { - daemonRequest.meta = { - ...daemonRequest.meta, - tenantId: authResult.tenantId, - sessionIsolation: - daemonRequest.meta?.sessionIsolation ?? + const tenantTrust = resolveTrustedTenant({ + hookConfigured: authHook !== null, + hookAttestedTenant: authResult.tenantId, + clientDeclaredTenant, + }); + if (!tenantTrust.trusted) { + const normalized = tenantTrustRejectionError(); + sendJson( + res, + createRpcError(rpcRequest.id ?? null, -32001, normalized.message, normalized), + 401, + ); + return; + } + daemonRequest.meta = { + ...daemonRequest.meta, + tenantId: tenantTrust.tenantId, + sessionIsolation: authResult.tenantId + ? (daemonRequest.meta?.sessionIsolation ?? daemonRequest.flags?.sessionIsolation ?? - 'tenant', - }; + 'tenant') + : daemonRequest.meta?.sessionIsolation, + }; + if (daemonRequest.flags?.tenant !== undefined) { + daemonRequest.flags = { ...daemonRequest.flags, tenant: tenantTrust.tenantId }; } let canceledInFlight = false; @@ -825,9 +843,17 @@ async function authorizeAuxiliaryHttpRequest(params: { return null; } - // Auth-hook identity remains authoritative. The header fallback only preserves the - // client-declared tenant used by RPC when a deployment does not derive tenant scope in its hook. - return { tenantId: authResult.tenantId ?? tenantId }; + const tenantTrust = resolveTrustedTenant({ + hookConfigured: authHook !== null, + hookAttestedTenant: authResult.tenantId, + clientDeclaredTenant: tenantId, + }); + if (!tenantTrust.trusted) { + sendRestJsonError(res, tenantTrustRejectionError()); + return null; + } + + return { tenantId: tenantTrust.tenantId }; } function readHeaderValue(headers: IncomingHttpHeaders, name: string): string | undefined { diff --git a/src/daemon/server/tenant-trust.ts b/src/daemon/server/tenant-trust.ts new file mode 100644 index 0000000000..72f74b8717 --- /dev/null +++ b/src/daemon/server/tenant-trust.ts @@ -0,0 +1,22 @@ +import { AppError, normalizeError } from '@agent-device/kernel/errors'; + +export type TenantTrustDecision = + | { trusted: true; tenantId: string | undefined } + | { trusted: false }; + +export function resolveTrustedTenant(params: { + hookConfigured: boolean; + hookAttestedTenant: string | undefined; + clientDeclaredTenant: string | undefined; +}): TenantTrustDecision { + const { hookConfigured, hookAttestedTenant, clientDeclaredTenant } = params; + if (hookAttestedTenant) return { trusted: true, tenantId: hookAttestedTenant }; + if (!hookConfigured) return { trusted: true, tenantId: clientDeclaredTenant }; + return { trusted: false }; +} + +export function tenantTrustRejectionError(): ReturnType { + return normalizeError( + new AppError('UNAUTHORIZED', 'Request tenant is not attested by the auth hook'), + ); +} diff --git a/test/integration/provider-scenarios/harness.ts b/test/integration/provider-scenarios/harness.ts index 60d2bbacc8..67969b76e7 100644 --- a/test/integration/provider-scenarios/harness.ts +++ b/test/integration/provider-scenarios/harness.ts @@ -264,10 +264,7 @@ async function removeProviderScenarioTempDir(dir: string): Promise { } } -export function restoreEnv(key: string, previous: string | undefined): void { - if (previous === undefined) delete process.env[key]; - else process.env[key] = previous; -} +export { restoreEnv } from '../../../src/__tests__/test-utils/env.ts'; export function likelyPlayableMp4Container(): Buffer { return Buffer.concat([atom('ftyp', Buffer.from('isom0000isom')), atom('moov')]); diff --git a/test/wire-compat/ledger.json b/test/wire-compat/ledger.json index 91864fb874..3ae289cba8 100644 --- a/test/wire-compat/ledger.json +++ b/test/wire-compat/ledger.json @@ -103,7 +103,7 @@ "src/daemon/server/http-server.ts#MAX_HTTP_RPC_BODY_BYTES": "sha256:3cb06e12b3110fa27e390d5c6473578b3f13c93b9f2b571e0e8e42d7ce29d48a", "src/daemon/server/http-server.ts#RELEASE_MATERIALIZED_PATHS_RPC_METHODS": "sha256:75a18d3496894309dd6042b16c4dcc241c16ec6336783e6310eddd6db7437ccb", "src/daemon/server/http-server.ts#SUPPORTED_RPC_METHODS": "sha256:4d5ed730dcb669b0dacca3bb4977f35686bab9b7fb464c0590fd50f838e6c243", - "src/daemon/server/http-server.ts#authorizeAuxiliaryHttpRequest": "sha256:6351886d5e412006a06b4cdbdc2a59ba103416c92ccf80c664afb8705e6b5816", + "src/daemon/server/http-server.ts#authorizeAuxiliaryHttpRequest": "sha256:f74f2fc70313a4cc048bcd531f9831970cab75c2b098688e846c44b3c37a7185", "src/daemon/server/http-server.ts#createRpcError": "sha256:1921762129636afc7772937d48e8afb8f09047b343e3a27d18b49c1c4385bbe8", "src/daemon/server/http-server.ts#enforceDaemonToken": "sha256:60036cffc34388b33fc0dad39c905d9cb3fc18c9ed0cf24255bb7105f5d444c7", "src/daemon/server/http-server.ts#isCommandRpcMethod": "sha256:9922102ba9c72c3032f205717d772ab7c3506c6f3012f55b8e7e5636b672ed7e", @@ -173,6 +173,11 @@ "src/remote/upload-stream.ts#streamFileToHttpRequestAttempt": "sha256:da39a79fa7c1f81e55caf613eedc347c0f3a9a9711b265a4db185677532d9552" }, "compatibleChanges": [ + { + "declaration": "src/daemon/server/http-server.ts#authorizeAuxiliaryHttpRequest", + "digest": "sha256:f74f2fc70313a4cc048bcd531f9831970cab75c2b098688e846c44b3c37a7185", + "rationale": "#2095 adds one new rejection condition (a configured-but-tenant-silent auth hook plus a client-declared tenant) that answers with the same 401 `{ok:false,error,code}` REST shape `sendRestJsonError` already sends for the token-error and hook-rejected cases on this route. An older client already parses that shape; nothing new appears on the wire, only a request that used to be silently trusted now gets an error it already knows how to read." + }, { "declaration": "packages/kernel/src/errors.ts#DaemonError", "digest": "sha256:ae49c9c9c8fb93bb6b31ab865f37cc8b0db6c1b6ff026cc2791425b772c162a6", diff --git a/website/docs/docs/security-trust.md b/website/docs/docs/security-trust.md index 72f3c0e0d3..5df52dcef5 100644 --- a/website/docs/docs/security-trust.md +++ b/website/docs/docs/security-trust.md @@ -24,6 +24,8 @@ CLI commands run through a per-user background daemon: For remote or cloud deployments, the daemon supports a custom auth hook: `AGENT_DEVICE_HTTP_AUTH_HOOK` names a module path that is dynamically imported and invoked for each HTTP request (with `AGENT_DEVICE_HTTP_AUTH_EXPORT` selecting the export). The hook runs with the daemon's full privileges, so treat it as part of your trusted computing base: point it only at a read-only path you control, never at a location writable by less-trusted users or processes. Whoever controls the daemon's environment controls the hook. +If a hook is configured and its result does not attest a `tenantId`, the daemon refuses the request (401) outright — it never falls back to a tenant the client declares itself (RPC body `meta.tenantId` or `flags.tenant`, or the `x-agent-device-tenant` header on the upload/artifact-download/diagnostics routes), and it never admits the request unscoped either: a shared token must not let one caller claim another tenant's identity, nor read a tenant-owned session or artifact by simply declaring none. A hook must attest `tenantId` on every request it wants admitted; a deployment with no hook configured is unaffected. + ## Sensitive artifacts Screenshots, recordings, traces, logs, network dumps, audio probes, replay files, provider-hosted cloud videos/logs, and reports can contain private UI state, credentials, tokens, request data, timing signals, or customer information. Store them in a controlled directory, review before sharing, and avoid committing artifacts unless they are intentionally sanitized fixtures.