diff --git a/.gitignore b/.gitignore index 5e1ed0f..86264ca 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ node_modules *.excalidraw* check.md current.md -overview.md \ No newline at end of file +overview.md +OVERVIEW.md \ No newline at end of file diff --git a/.npmignore b/.npmignore index cb5d3da..3eb353c 100644 --- a/.npmignore +++ b/.npmignore @@ -11,4 +11,5 @@ agents-chain-dashboard.excalidraw # Compiled tests dist/__tests__/ -*.env* \ No newline at end of file +*.env* + diff --git a/package.json b/package.json index 7170a5d..136cc65 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agents-chain", - "version": "0.0.45", + "version": "0.0.5-1", "description": "Lightweight identity, auth, and audit layer for AI agent SDKs (OpenAI, Anthropic)", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", @@ -28,7 +28,7 @@ "prepublishOnly": "pnpm run build", "lint": "prettier --check .", "format": "prettier --write .", - "test": "pnpm run build && node --test dist/esm/__tests__/agents-chain.test.js", + "test": "pnpm run build && node --test dist/esm/__tests__/agents-chain.test.js dist/esm/__tests__/access-requests.test.js", "test:interop": "node --test scripts/test-cjs.cjs && node --test scripts/test-esm.mjs", "test:all": "pnpm run test && pnpm run test:interop" }, diff --git a/src/__tests__/access-requests.test.ts b/src/__tests__/access-requests.test.ts new file mode 100644 index 0000000..45ab941 --- /dev/null +++ b/src/__tests__/access-requests.test.ts @@ -0,0 +1,1110 @@ +/** + * Access Request System — test suite + * + * Covers: + * 12. AccessRequestManager — HMAC codes, approve, deny, expire, rate limit + * 13. ApprovalStore — rule creation, all 4 scopes, tamper detection, revocation, TTL + * 14. AppChain + access requests — suspend/resume, all scopes, constraint expansion, e2e + * + * Run with: pnpm test (builds then runs from dist/esm) + */ + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { EncryptedStore } from "../memory/encrypted-store.js"; +import { AccessRequestManager } from "../access/access-request-manager.js"; +import { ApprovalStore } from "../access/approval-store.js"; +import { AppChain } from "../chain.js"; +import { ChainAuthError } from "../errors/chain-error.js"; +import type { AccessRequest } from "../types/access-request.js"; + +// ─── Shared helpers ──────────────────────────────────────────────────────── + +/** Builds a notifier that captures all notifications in an array. */ +function makeNotifier() { + const received: AccessRequest[] = []; + const resolved: Array<{ request: AccessRequest; outcome: string }> = []; + return { + notifier: { + async notify(request: AccessRequest) { + received.push(request); + }, + async onResolved(request: AccessRequest, outcome: string) { + resolved.push({ request, outcome }); + }, + }, + received, + resolved, + }; +} + +/** Waits for `n` items to appear in the array, polling up to timeoutMs. */ +async function waitFor(arr: T[], n: number, timeoutMs = 500): Promise { + const deadline = Date.now() + timeoutMs; + while (arr.length < n && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 10)); + } + assert.ok(arr.length >= n, `Expected ${n} items, got ${arr.length} after ${timeoutMs}ms`); +} + +/** Minimal SMS capability for AppChain tests. */ +function makeSmsCapability() { + return { + name: "send_sms", + description: "Send an SMS message", + inputSchema: { + type: "object" as const, + required: ["to", "body"] as string[], + properties: { + to: { type: "string" as const }, + body: { type: "string" as const }, + }, + }, + outputSchema: { type: "object" as const }, + execute: async (params: unknown) => { + const { to, body } = params as { to: string; body: string }; + return { sent: true, to, body }; + }, + }; +} + +/** Creates an AppChain with access requests enabled, returns chain + notifier internals. */ +async function makeChainWithAccessRequests(approvalSecret = "test-secret-32-bytes-long-enough") { + const { notifier, received, resolved } = makeNotifier(); + const chain = await AppChain.create({ + providerName: "sms-service", + issuer: "https://sms.example.com", + capabilities: [makeSmsCapability()], + accessRequests: { + approvalSecret, + requestTTLMs: 60_000, + notifier, + }, + }); + return { chain, received, resolved }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// 12. AccessRequestManager +// ───────────────────────────────────────────────────────────────────────────── + +describe("AccessRequestManager", () => { + it("createRequest() generates a non-empty verificationCode", async () => { + const { notifier } = makeNotifier(); + const manager = new AccessRequestManager({ + approvalSecret: "test-secret", + notifier, + }); + + const { request } = await manager.createRequest({ + agentId: "agent-1", + agentName: "Test Agent", + hostId: "host-thumb", + capability: "send_sms", + args: { to: "+9999999", body: "hi" }, + reason: "number not in whitelist", + errorCode: "constraint_violated", + violatedField: "to", + violatedValue: "+9999999", + }); + + assert.ok(request.requestId.startsWith("areq_"), "requestId must have areq_ prefix"); + assert.equal(request.status, "pending"); + assert.ok(request.verificationCode.length === 8, "code must be 8 chars"); + assert.ok(/^[0-9A-F]{8}$/.test(request.verificationCode), "code must be uppercase hex"); + manager.destroy(); + }); + + it("createRequest() calls notifier.notify() with the request", async () => { + const { notifier, received } = makeNotifier(); + const manager = new AccessRequestManager({ approvalSecret: "test-secret", notifier }); + + await manager.createRequest({ + agentId: "agent-1", + agentName: "Agent", + hostId: "host", + capability: "send_sms", + args: { to: "+9999" }, + reason: "denied", + errorCode: "capability_denied", + }); + + await waitFor(received, 1); + assert.equal(received[0]!.capability, "send_sms"); + assert.equal(received[0]!.agentId, "agent-1"); + manager.destroy(); + }); + + it("getPendingForAgent() returns all pending requests for that agent", async () => { + const { notifier } = makeNotifier(); + const manager = new AccessRequestManager({ approvalSecret: "s", notifier }); + + await manager.createRequest({ + agentId: "agent-A", + agentName: "A", + hostId: "h", + capability: "cap1", + args: {}, + reason: "r", + errorCode: "capability_denied", + }); + await manager.createRequest({ + agentId: "agent-A", + agentName: "A", + hostId: "h", + capability: "cap2", + args: {}, + reason: "r", + errorCode: "capability_denied", + }); + await manager.createRequest({ + agentId: "agent-B", + agentName: "B", + hostId: "h", + capability: "cap1", + args: {}, + reason: "r", + errorCode: "capability_denied", + }); + + assert.equal(manager.getPendingForAgent("agent-A").length, 2); + assert.equal(manager.getPendingForAgent("agent-B").length, 1); + assert.equal(manager.getAllPending().length, 3); + manager.destroy(); + }); + + it("approve() with correct code resolves the waitForApproval promise", async () => { + const { notifier } = makeNotifier(); + const manager = new AccessRequestManager({ approvalSecret: "test-secret", notifier }); + + const { request, waitForApproval } = await manager.createRequest({ + agentId: "agent-1", + agentName: "A", + hostId: "h", + capability: "send_sms", + args: { to: "+9999" }, + reason: "denied", + errorCode: "constraint_violated", + violatedField: "to", + violatedValue: "+9999", + }); + + // Approve asynchronously + setTimeout(() => { + manager.approve({ + requestId: request.requestId, + code: request.verificationCode, + scope: "value", + }); + }, 10); + + const result = await waitForApproval as { approved: boolean }; + assert.equal(result.approved, true); + assert.equal(manager.getAllPending().length, 0, "request removed after approval"); + manager.destroy(); + }); + + it("approve() with wrong code throws and leaves request pending", async () => { + const { notifier } = makeNotifier(); + const manager = new AccessRequestManager({ approvalSecret: "test-secret", notifier }); + + const { request } = await manager.createRequest({ + agentId: "agent-1", + agentName: "A", + hostId: "h", + capability: "send_sms", + args: {}, + reason: "denied", + errorCode: "capability_denied", + }); + + assert.throws( + () => manager.approve({ requestId: request.requestId, code: "WRONGCOD", scope: "call" }), + /Invalid verification code/ + ); + + // Request is still pending — not consumed + assert.equal(manager.getAllPending().length, 1); + manager.destroy(); + }); + + it("deny() with correct code rejects the waitForApproval promise", async () => { + const { notifier } = makeNotifier(); + const manager = new AccessRequestManager({ approvalSecret: "test-secret", notifier }); + + const { request, waitForApproval } = await manager.createRequest({ + agentId: "agent-1", + agentName: "A", + hostId: "h", + capability: "send_sms", + args: {}, + reason: "denied", + errorCode: "capability_denied", + }); + + setTimeout(() => { + manager.deny({ + requestId: request.requestId, + code: request.verificationCode, + reason: "Not allowed", + }); + }, 10); + + await assert.rejects(waitForApproval, /Access request denied.*Not allowed/); + assert.equal(manager.getAllPending().length, 0); + manager.destroy(); + }); + + it("approve() throws for unknown requestId", async () => { + const { notifier } = makeNotifier(); + const manager = new AccessRequestManager({ approvalSecret: "s", notifier }); + + assert.throws( + () => manager.approve({ requestId: "areq_nonexistent", code: "XXXXXXXX", scope: "call" }), + /not found or already resolved/ + ); + manager.destroy(); + }); + + it("destroy() rejects all suspended calls", async () => { + const { notifier } = makeNotifier(); + const manager = new AccessRequestManager({ approvalSecret: "s", notifier }); + + const { waitForApproval } = await manager.createRequest({ + agentId: "a", + agentName: "A", + hostId: "h", + capability: "cap", + args: {}, + reason: "r", + errorCode: "capability_denied", + }); + + const rejectionPromise = assert.rejects(waitForApproval, /destroyed/); + manager.destroy(); + await rejectionPromise; + }); + + it("approvalSecret is exposed as a Buffer on the manager", async () => { + const { notifier } = makeNotifier(); + const manager = new AccessRequestManager({ approvalSecret: "my-secret", notifier }); + + assert.ok(Buffer.isBuffer(manager.approvalSecret)); + assert.ok(manager.approvalSecret.length > 0); + manager.destroy(); + }); + + it("two different requests for the same capability produce different codes", async () => { + const { notifier } = makeNotifier(); + const manager = new AccessRequestManager({ approvalSecret: "test-secret", notifier }); + + const { request: r1, waitForApproval: w1 } = await manager.createRequest({ + agentId: "agent-1", + agentName: "A", + hostId: "h", + capability: "send_sms", + args: { to: "+1111" }, + reason: "r", + errorCode: "constraint_violated", + }); + const { request: r2, waitForApproval: w2 } = await manager.createRequest({ + agentId: "agent-1", + agentName: "A", + hostId: "h", + capability: "send_sms", + args: { to: "+2222" }, + reason: "r", + errorCode: "constraint_violated", + }); + + // Codes must differ (tied to different requestId + createdAt) + assert.notEqual(r1.verificationCode, r2.verificationCode); + + // Drain pending promises before destroy to avoid unhandledRejection warnings + const drain = Promise.allSettled([w1, w2]); + manager.destroy(); + await drain; + }); + + it("rate limit: oldest pending is expired when maxPendingPerAgent is exceeded", async () => { + const { notifier } = makeNotifier(); + const manager = new AccessRequestManager({ + approvalSecret: "s", + notifier, + maxPendingPerAgent: 2, + }); + + const { request: r1, waitForApproval: w1 } = await manager.createRequest({ + agentId: "agent-1", agentName: "A", hostId: "h", + capability: "c", args: {}, reason: "r", errorCode: "capability_denied", + }); + const { waitForApproval: w2 } = await manager.createRequest({ + agentId: "agent-1", agentName: "A", hostId: "h", + capability: "c", args: {}, reason: "r", errorCode: "capability_denied", + }); + + // Third request — should expire the first one + const rejectionPromise = assert.rejects(w1, /expired/); + const { waitForApproval: w3 } = await manager.createRequest({ + agentId: "agent-1", agentName: "A", hostId: "h", + capability: "c", args: {}, reason: "r", errorCode: "capability_denied", + }); + + await rejectionPromise; + // First request is gone — only 2 remain + assert.equal(manager.getPendingForAgent("agent-1").length, 2); + assert.equal(manager.getPending(r1.requestId), undefined); + + // Drain w2, w3 before destroy + const drain = Promise.allSettled([w2, w3]); + manager.destroy(); + await drain; + }); + + it("onResolved is called with 'approved' after approval", async () => { + const { notifier, resolved } = makeNotifier(); + const manager = new AccessRequestManager({ approvalSecret: "s", notifier }); + + const { request } = await manager.createRequest({ + agentId: "a", agentName: "A", hostId: "h", + capability: "c", args: {}, reason: "r", errorCode: "capability_denied", + }); + + manager.approve({ + requestId: request.requestId, + code: request.verificationCode, + scope: "call", + }); + + await waitFor(resolved, 1); + assert.equal(resolved[0]!.outcome, "approved"); + manager.destroy(); + }); + + it("onResolved is called with 'denied' after denial", async () => { + const { notifier, resolved } = makeNotifier(); + const manager = new AccessRequestManager({ approvalSecret: "s", notifier }); + + const { request, waitForApproval } = await manager.createRequest({ + agentId: "a", agentName: "A", hostId: "h", + capability: "c", args: {}, reason: "r", errorCode: "capability_denied", + }); + + // Catch the rejection before it becomes unhandled + waitForApproval.catch(() => {}); + + manager.deny({ requestId: request.requestId, code: request.verificationCode }); + + await waitFor(resolved, 1); + assert.equal(resolved[0]!.outcome, "denied"); + manager.destroy(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 13. ApprovalStore +// ───────────────────────────────────────────────────────────────────────────── + +describe("ApprovalStore", () => { + function makeStore() { + const secret = Buffer.from("test-integrity-secret", "utf8"); + const store = EncryptedStore.create(); + const approvalStore = new ApprovalStore(store, secret); + return { store, approvalStore, secret }; + } + + function fakeRequest(overrides: Partial = {}): AccessRequest { + return { + requestId: "areq_test001", + agentId: "agent-1", + agentName: "Agent", + hostId: "host-thumb", + capability: "send_sms", + args: { to: "+9999", body: "hi" }, + reason: "number not in whitelist", + violatedField: "to", + violatedValue: "+9999", + errorCode: "constraint_violated", + createdAt: Date.now(), + expiresAt: Date.now() + 60_000, + status: "approved", + verificationCode: "TESTCODE", + ...overrides, + }; + } + + it("starts empty", () => { + const { approvalStore } = makeStore(); + assert.equal(approvalStore.getAll().length, 0); + }); + + it("createRule() creates a 'value' scope rule with field+value set", () => { + const { approvalStore } = makeStore(); + const request = fakeRequest(); + const rule = approvalStore.createRule(request, { requestId: request.requestId, code: "X", scope: "value" }); + + assert.equal(rule.scope, "value"); + assert.equal(rule.capability, "send_sms"); + assert.equal(rule.field, "to"); + assert.equal(rule.value, "+9999"); + assert.equal(rule.global, false); + assert.equal(approvalStore.getAll().length, 1); + }); + + it("createRule() creates a 'capability' scope rule with no field", () => { + const { approvalStore } = makeStore(); + const request = fakeRequest(); + const rule = approvalStore.createRule(request, { requestId: request.requestId, code: "X", scope: "capability" }); + + assert.equal(rule.scope, "capability"); + assert.equal(rule.field, undefined); + assert.equal(rule.global, false); + }); + + it("createRule() creates a 'global' scope rule with global=true", () => { + const { approvalStore } = makeStore(); + const request = fakeRequest(); + const rule = approvalStore.createRule(request, { requestId: request.requestId, code: "X", scope: "global" }); + + assert.equal(rule.global, true); + }); + + it("createRule() with TTL sets expiresAt", () => { + const { approvalStore } = makeStore(); + const now = Date.now(); + const request = fakeRequest(); + const rule = approvalStore.createRule(request, { + requestId: request.requestId, + code: "X", + scope: "value", + ttl: { durationMs: 5 * 60 * 1000 }, + }); + + assert.ok(rule.expiresAt !== undefined); + assert.ok(rule.expiresAt! > now + 4 * 60 * 1000); + }); + + it("createRule() with explicit expiresAt uses it directly", () => { + const { approvalStore } = makeStore(); + const expiresAt = Date.now() + 99_999; + const request = fakeRequest(); + const rule = approvalStore.createRule(request, { + requestId: request.requestId, + code: "X", + scope: "value", + ttl: { expiresAt }, + }); + + assert.equal(rule.expiresAt, expiresAt); + }); + + it("findMatchingRule() finds 'value' scope rule by field+value", () => { + const { approvalStore } = makeStore(); + const request = fakeRequest(); + approvalStore.createRule(request, { requestId: request.requestId, code: "X", scope: "value" }); + + const match = approvalStore.findMatchingRule("agent-1", "send_sms", { to: "+9999" }, "to", "+9999"); + assert.ok(match !== null); + assert.equal(match!.scope, "value"); + }); + + it("findMatchingRule() returns null for wrong field value", () => { + const { approvalStore } = makeStore(); + const request = fakeRequest(); + approvalStore.createRule(request, { requestId: request.requestId, code: "X", scope: "value" }); + + const match = approvalStore.findMatchingRule("agent-1", "send_sms", { to: "+8888" }, "to", "+8888"); + assert.equal(match, null); + }); + + it("findMatchingRule() finds 'capability' scope rule for any value", () => { + const { approvalStore } = makeStore(); + const request = fakeRequest(); + approvalStore.createRule(request, { requestId: request.requestId, code: "X", scope: "capability" }); + + const match1 = approvalStore.findMatchingRule("agent-1", "send_sms", { to: "+1111" }); + const match2 = approvalStore.findMatchingRule("agent-2", "send_sms", { to: "+2222" }); + assert.ok(match1 !== null); + assert.ok(match2 !== null); + }); + + it("findMatchingRule() finds 'global' scope rule for any agent", () => { + const { approvalStore } = makeStore(); + const request = fakeRequest(); + approvalStore.createRule(request, { requestId: request.requestId, code: "X", scope: "global" }); + + assert.ok(approvalStore.findMatchingRule("agent-A", "send_sms", {}) !== null); + assert.ok(approvalStore.findMatchingRule("agent-B", "send_sms", {}) !== null); + }); + + it("findMatchingRule() skips 'call' scope rules", () => { + const { approvalStore } = makeStore(); + // Manually push a call-scope rule (bypassing createRule to simulate stale state) + (approvalStore as any).rules.push({ + ruleId: "arule_call_test", + capability: "send_sms", + scope: "call", + approvedBy: "areq_test", + createdAt: Date.now(), + global: false, + }); + + const match = approvalStore.findMatchingRule("agent-1", "send_sms", {}); + assert.equal(match, null, "call-scope rules should be skipped by findMatchingRule"); + }); + + it("getExpandedConstraints() returns null for 'capability' scope (bypass all constraints)", () => { + const { approvalStore } = makeStore(); + const request = fakeRequest(); + approvalStore.createRule(request, { requestId: request.requestId, code: "X", scope: "capability" }); + + const expanded = approvalStore.getExpandedConstraints("send_sms"); + assert.equal(expanded, null); + }); + + it("getExpandedConstraints() returns null for 'global' scope", () => { + const { approvalStore } = makeStore(); + const request = fakeRequest(); + approvalStore.createRule(request, { requestId: request.requestId, code: "X", scope: "global" }); + + assert.equal(approvalStore.getExpandedConstraints("send_sms"), null); + }); + + it("getExpandedConstraints() expands 'in' list for 'value' scope", () => { + const { approvalStore } = makeStore(); + const request = fakeRequest(); + approvalStore.createRule(request, { requestId: request.requestId, code: "X", scope: "value" }); + + const expanded = approvalStore.getExpandedConstraints("send_sms"); + assert.ok(expanded !== null); + const toConstraint = expanded!["to"] as { in: string[] }; + assert.ok(Array.isArray(toConstraint.in)); + assert.ok(toConstraint.in.includes("+9999")); + }); + + it("getExpandedConstraints() returns undefined for different capability", () => { + const { approvalStore } = makeStore(); + const request = fakeRequest(); + approvalStore.createRule(request, { requestId: request.requestId, code: "X", scope: "value" }); + + const expanded = approvalStore.getExpandedConstraints("other_capability"); + assert.equal(expanded, undefined); + }); + + it("revokeRule() removes the rule and returns true", () => { + const { approvalStore } = makeStore(); + const request = fakeRequest(); + const rule = approvalStore.createRule(request, { requestId: request.requestId, code: "X", scope: "value" }); + + assert.equal(approvalStore.revokeRule(rule.ruleId), true); + assert.equal(approvalStore.getAll().length, 0); + }); + + it("revokeRule() returns false for unknown ruleId", () => { + const { approvalStore } = makeStore(); + assert.equal(approvalStore.revokeRule("arule_nonexistent"), false); + }); + + it("revokeAllForCapability() removes only matching rules", () => { + const { approvalStore } = makeStore(); + approvalStore.createRule(fakeRequest({ requestId: "r1", capability: "send_sms" }), { + requestId: "r1", code: "X", scope: "value", + }); + approvalStore.createRule(fakeRequest({ requestId: "r2", capability: "send_email", violatedField: "to" }), { + requestId: "r2", code: "X", scope: "value", + }); + + const count = approvalStore.revokeAllForCapability("send_sms"); + assert.equal(count, 1); + assert.equal(approvalStore.getAll().length, 1); + assert.equal(approvalStore.getAll()[0]!.capability, "send_email"); + }); + + it("revokeAll() removes all rules and returns count", () => { + const { approvalStore } = makeStore(); + approvalStore.createRule(fakeRequest({ requestId: "r1" }), { requestId: "r1", code: "X", scope: "value" }); + approvalStore.createRule(fakeRequest({ requestId: "r2" }), { requestId: "r2", code: "X", scope: "capability" }); + + const count = approvalStore.revokeAll(); + assert.equal(count, 2); + assert.equal(approvalStore.getAll().length, 0); + }); + + it("expired rules are swept automatically by getAll()", () => { + const { approvalStore } = makeStore(); + const request = fakeRequest(); + const rule = approvalStore.createRule(request, { + requestId: request.requestId, code: "X", scope: "value", + ttl: { expiresAt: Date.now() - 1 }, // already expired + }); + + // getAll() triggers sweepExpired + assert.equal(approvalStore.getAll().length, 0); + }); + + it("expired rules are ignored by findMatchingRule()", () => { + const { approvalStore } = makeStore(); + const request = fakeRequest(); + approvalStore.createRule(request, { + requestId: request.requestId, code: "X", scope: "value", + ttl: { expiresAt: Date.now() - 1 }, + }); + + const match = approvalStore.findMatchingRule("agent-1", "send_sms", { to: "+9999" }, "to", "+9999"); + assert.equal(match, null); + }); + + it("tamper detection: corrupting rules in the store wipes all rules on reload", () => { + const secret = Buffer.from("integrity-secret", "utf8"); + const encStore = EncryptedStore.create(); + const approvalStore = new ApprovalStore(encStore, secret); + + const request = fakeRequest(); + approvalStore.createRule(request, { requestId: request.requestId, code: "X", scope: "global" }); + assert.equal(approvalStore.getAll().length, 1); + + // Tamper: write rules directly to the store bypassing ApprovalStore + // (simulates an agent writing to EncryptedStore directly) + encStore.set("approval_rules", [{ ruleId: "injected", capability: "send_sms", scope: "global", global: true, approvedBy: "fake", createdAt: 0 }]); + // The integrity tag now mismatches + + // Load a fresh ApprovalStore from the same tampered EncryptedStore + const reloaded = new ApprovalStore(encStore, secret); + assert.equal( + reloaded.getAll().length, 0, + "tampered rules should be wiped on reload" + ); + }); + + it("rules survive a clean reload when not tampered", () => { + const secret = Buffer.from("integrity-secret", "utf8"); + const encStore = EncryptedStore.create(); + const store1 = new ApprovalStore(encStore, secret); + + const request = fakeRequest(); + store1.createRule(request, { requestId: request.requestId, code: "X", scope: "global" }); + + // Reload from the same EncryptedStore — same secret, same data + const store2 = new ApprovalStore(encStore, secret); + assert.equal(store2.getAll().length, 1, "rules should survive clean reload"); + assert.equal(store2.getAll()[0]!.scope, "global"); + }); + + it("wrong secret on reload wipes all rules", () => { + const secret1 = Buffer.from("correct-secret", "utf8"); + const secret2 = Buffer.from("wrong-secret-xx", "utf8"); + const encStore = EncryptedStore.create(); + const store1 = new ApprovalStore(encStore, secret1); + + const request = fakeRequest(); + store1.createRule(request, { requestId: request.requestId, code: "X", scope: "global" }); + + // Try to load with wrong secret + const store2 = new ApprovalStore(encStore, secret2); + assert.equal(store2.getAll().length, 0, "rules should be wiped when loaded with wrong secret"); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 14. AppChain + access requests (integration) +// ───────────────────────────────────────────────────────────────────────────── + +describe("AppChain — access requests disabled (default behavior unchanged)", () => { + it("throws ChainAuthError immediately when access requests not configured", async () => { + const chain = await AppChain.create({ + providerName: "sms-service", + issuer: "https://sms.example.com", + capabilities: [makeSmsCapability()], + }); + + const service = { send_sms: async () => ({ sent: true }) }; + const grants = [{ capability: "send_sms", status: "active" as const, constraints: { to: { in: ["+254700000001"] } } }]; + const secured = chain.wrap(service, grants) as Record; + + await assert.rejects( + () => secured["send_sms"]!({ to: "+9999999", body: "hi" }), + (err: unknown) => { + assert.ok(err instanceof ChainAuthError); + assert.equal(err.code, "constraint_violated"); + return true; + } + ); + }); + + it("accessRequestsEnabled is false when not configured", async () => { + const chain = await AppChain.create({ + providerName: "test", + issuer: "https://test.com", + capabilities: [makeSmsCapability()], + }); + assert.equal(chain.accessRequestsEnabled, false); + }); + + it("chain.approve() throws when access requests not enabled", async () => { + const chain = await AppChain.create({ + providerName: "test", + issuer: "https://test.com", + capabilities: [makeSmsCapability()], + }); + assert.throws( + () => chain.approve({ requestId: "r", code: "c", scope: "call" }), + /not enabled/ + ); + }); + + it("chain.deny() throws when access requests not enabled", async () => { + const chain = await AppChain.create({ + providerName: "test", + issuer: "https://test.com", + capabilities: [makeSmsCapability()], + }); + assert.throws( + () => chain.deny({ requestId: "r", code: "c" }), + /not enabled/ + ); + }); +}); + +describe("AppChain — access requests enabled", () => { + it("accessRequestsEnabled is true when configured", async () => { + const { chain } = await makeChainWithAccessRequests(); + assert.equal(chain.accessRequestsEnabled, true); + chain.destroy(); + }); + + it("getPendingRequests() returns empty array initially", async () => { + const { chain } = await makeChainWithAccessRequests(); + assert.equal(chain.getPendingRequests().length, 0); + chain.destroy(); + }); + + it("getApprovalRules() returns empty array initially", async () => { + const { chain } = await makeChainWithAccessRequests(); + assert.equal(chain.getApprovalRules().length, 0); + chain.destroy(); + }); + + it("suspended call creates a pending request visible via getPendingRequests()", async () => { + const { chain, received } = await makeChainWithAccessRequests(); + const service = { send_sms: async () => ({ sent: true }) }; + const grants = [{ capability: "send_sms", status: "active" as const, constraints: { to: { in: ["+254700000001"] } } }]; + const secured = chain.wrap(service, grants) as Record; + + // Start a call that will be suspended — don't await it yet + const callPromise = secured["send_sms"]!({ to: "+9999999", body: "hello" }); + + // Wait for the notifier to receive the request + await waitFor(received, 1); + + assert.equal(chain.getPendingRequests().length, 1); + const pending = chain.getPendingRequests()[0]!; + assert.equal(pending.capability, "send_sms"); + assert.equal(pending.violatedField, "to"); + assert.equal(pending.violatedValue, "+9999999"); + + // Deny to clean up + chain.deny({ requestId: pending.requestId, code: pending.verificationCode }); + await assert.rejects(callPromise); + chain.destroy(); + }); + + it("SCOPE 'call': call succeeds once then the rule is removed", async () => { + const { chain, received } = await makeChainWithAccessRequests(); + const service = { send_sms: async () => ({ sent: true }) }; + const grants = [{ capability: "send_sms", status: "active" as const, constraints: { to: { in: ["+254700000001"] } } }]; + const secured = chain.wrap(service, grants) as Record; + + const callPromise = secured["send_sms"]!({ to: "+9999999", body: "test" }); + await waitFor(received, 1); + + const pending = chain.getPendingRequests()[0]!; + chain.approve({ + requestId: pending.requestId, + code: pending.verificationCode, + scope: "call", + }); + + const result = await callPromise as { sent: boolean }; + assert.equal(result.sent, true); + + // Rule should be gone after single use + assert.equal(chain.getApprovalRules().length, 0); + chain.destroy(); + }); + + it("SCOPE 'value': subsequent calls to approved value succeed without re-prompting", async () => { + const { chain, received } = await makeChainWithAccessRequests(); + const service = { send_sms: async () => ({ sent: true }) }; + const grants = [{ capability: "send_sms", status: "active" as const, constraints: { to: { in: ["+254700000001"] } } }]; + const secured = chain.wrap(service, grants) as Record; + + // First call — will be suspended + const callPromise = secured["send_sms"]!({ to: "+9999999", body: "first" }); + await waitFor(received, 1); + + const pending = chain.getPendingRequests()[0]!; + chain.approve({ + requestId: pending.requestId, + code: pending.verificationCode, + scope: "value", + }); + + const result1 = await callPromise as { sent: boolean }; + assert.equal(result1.sent, true); + assert.equal(chain.getApprovalRules().length, 1, "value rule should persist"); + + // Second call — same number, should pass without triggering a new request + const result2 = await secured["send_sms"]!({ to: "+9999999", body: "second" }) as { sent: boolean }; + assert.equal(result2.sent, true); + // Still only 1 notification (not 2) + assert.equal(received.length, 1, "second call should not trigger a new notification"); + + chain.destroy(); + }); + + it("SCOPE 'value': different unapproved value still triggers a new request", async () => { + const { chain, received } = await makeChainWithAccessRequests(); + const service = { send_sms: async () => ({ sent: true }) }; + const grants = [{ capability: "send_sms", status: "active" as const, constraints: { to: { in: ["+254700000001"] } } }]; + const secured = chain.wrap(service, grants) as Record; + + // Approve +9999999 + const call1 = secured["send_sms"]!({ to: "+9999999", body: "a" }); + await waitFor(received, 1); + const p1 = chain.getPendingRequests()[0]!; + chain.approve({ requestId: p1.requestId, code: p1.verificationCode, scope: "value" }); + await call1; + + // Now try a different unapproved number + const call2 = secured["send_sms"]!({ to: "+8888888", body: "b" }); + await waitFor(received, 2); + + assert.equal(chain.getPendingRequests().length, 1); + const p2 = chain.getPendingRequests()[0]!; + assert.equal(p2.violatedValue, "+8888888"); + + chain.deny({ requestId: p2.requestId, code: p2.verificationCode }); + await assert.rejects(call2); + chain.destroy(); + }); + + it("SCOPE 'capability': all values for the capability are allowed without constraint", async () => { + const { chain, received } = await makeChainWithAccessRequests(); + const service = { send_sms: async () => ({ sent: true }) }; + const grants = [{ capability: "send_sms", status: "active" as const, constraints: { to: { in: ["+254700000001"] } } }]; + const secured = chain.wrap(service, grants) as Record; + + // Approve at capability scope + const call1 = secured["send_sms"]!({ to: "+9999999", body: "a" }); + await waitFor(received, 1); + const p1 = chain.getPendingRequests()[0]!; + chain.approve({ requestId: p1.requestId, code: p1.verificationCode, scope: "capability" }); + await call1; + + // Any subsequent number works — no new requests + const r2 = await secured["send_sms"]!({ to: "+1111111", body: "b" }) as { sent: boolean }; + const r3 = await secured["send_sms"]!({ to: "+2222222", body: "c" }) as { sent: boolean }; + assert.equal(r2.sent, true); + assert.equal(r3.sent, true); + assert.equal(received.length, 1, "only 1 notification should have been sent"); + + chain.destroy(); + }); + + it("SCOPE 'global': approval rule shows global=true and applies to all agents", async () => { + const { chain, received } = await makeChainWithAccessRequests(); + const service = { send_sms: async () => ({ sent: true }) }; + const grants = [{ capability: "send_sms", status: "active" as const, constraints: { to: { in: ["+254700000001"] } } }]; + const secured = chain.wrap(service, grants) as Record; + + const call1 = secured["send_sms"]!({ to: "+9999999", body: "a" }); + await waitFor(received, 1); + const p1 = chain.getPendingRequests()[0]!; + chain.approve({ requestId: p1.requestId, code: p1.verificationCode, scope: "global" }); + await call1; + + const rules = chain.getApprovalRules(); + assert.equal(rules.length, 1); + assert.equal(rules[0]!.global, true); + assert.equal(rules[0]!.scope, "global"); + chain.destroy(); + }); + + it("SCOPE 'global' with TTL: rule disappears after expiry", async () => { + const { chain, received } = await makeChainWithAccessRequests(); + const service = { send_sms: async () => ({ sent: true }) }; + const grants = [{ capability: "send_sms", status: "active" as const, constraints: { to: { in: ["+254700000001"] } } }]; + const secured = chain.wrap(service, grants) as Record; + + const call1 = secured["send_sms"]!({ to: "+9999999", body: "a" }); + await waitFor(received, 1); + const p1 = chain.getPendingRequests()[0]!; + chain.approve({ + requestId: p1.requestId, + code: p1.verificationCode, + scope: "global", + ttl: { expiresAt: Date.now() - 1 }, // already expired + }); + await call1; + + // Rule was created but already expired + assert.equal(chain.getApprovalRules().length, 0, "expired rule should be swept on getAll()"); + chain.destroy(); + }); + + it("denial rejects the suspended call with an error", async () => { + const { chain, received } = await makeChainWithAccessRequests(); + const service = { send_sms: async () => ({ sent: true }) }; + const grants = [{ capability: "send_sms", status: "active" as const, constraints: { to: { in: ["+254700000001"] } } }]; + const secured = chain.wrap(service, grants) as Record; + + const callPromise = secured["send_sms"]!({ to: "+9999999", body: "hi" }); + await waitFor(received, 1); + + const pending = chain.getPendingRequests()[0]!; + chain.deny({ + requestId: pending.requestId, + code: pending.verificationCode, + reason: "Administrator declined", + }); + + await assert.rejects( + callPromise, + /Access request denied.*Administrator declined/ + ); + chain.destroy(); + }); + + it("wrong verification code on chain.approve() throws without resuming call", async () => { + const { chain, received } = await makeChainWithAccessRequests(); + const service = { send_sms: async () => ({ sent: true }) }; + const grants = [{ capability: "send_sms", status: "active" as const, constraints: { to: { in: ["+254700000001"] } } }]; + const secured = chain.wrap(service, grants) as Record; + + const callPromise = secured["send_sms"]!({ to: "+9999999", body: "hi" }); + await waitFor(received, 1); + + const pending = chain.getPendingRequests()[0]!; + assert.throws( + () => chain.approve({ requestId: pending.requestId, code: "WRONGCOD", scope: "call" }), + /Invalid verification code/ + ); + + // Call is still suspended + assert.equal(chain.getPendingRequests().length, 1); + + // Clean up + chain.deny({ requestId: pending.requestId, code: pending.verificationCode }); + await assert.rejects(callPromise); + chain.destroy(); + }); + + it("revokeApproval() removes a rule and subsequent calls re-trigger access request", async () => { + const { chain, received } = await makeChainWithAccessRequests(); + const service = { send_sms: async () => ({ sent: true }) }; + const grants = [{ capability: "send_sms", status: "active" as const, constraints: { to: { in: ["+254700000001"] } } }]; + const secured = chain.wrap(service, grants) as Record; + + // Approve with 'value' scope + const call1 = secured["send_sms"]!({ to: "+9999999", body: "a" }); + await waitFor(received, 1); + const p1 = chain.getPendingRequests()[0]!; + chain.approve({ requestId: p1.requestId, code: p1.verificationCode, scope: "value" }); + await call1; + + const rules = chain.getApprovalRules(); + assert.equal(rules.length, 1); + + // Revoke the rule + assert.equal(chain.revokeApproval(rules[0]!.ruleId), true); + assert.equal(chain.getApprovalRules().length, 0); + + // Next call triggers a new access request + const call2 = secured["send_sms"]!({ to: "+9999999", body: "b" }); + await waitFor(received, 2); + assert.equal(chain.getPendingRequests().length, 1); + + const p2 = chain.getPendingRequests()[0]!; + chain.deny({ requestId: p2.requestId, code: p2.verificationCode }); + await assert.rejects(call2); + chain.destroy(); + }); + + it("revokeApprovalsForCapability() removes rules for that capability only", async () => { + const { chain, received } = await makeChainWithAccessRequests(); + const service = { send_sms: async () => ({ sent: true }) }; + const grants = [{ capability: "send_sms", status: "active" as const, constraints: { to: { in: ["+254700000001"] } } }]; + const secured = chain.wrap(service, grants) as Record; + + const call1 = secured["send_sms"]!({ to: "+9999999", body: "a" }); + await waitFor(received, 1); + const p1 = chain.getPendingRequests()[0]!; + chain.approve({ requestId: p1.requestId, code: p1.verificationCode, scope: "capability" }); + await call1; + + assert.equal(chain.getApprovalRules().length, 1); + const removed = chain.revokeApprovalsForCapability("send_sms"); + assert.equal(removed, 1); + assert.equal(chain.getApprovalRules().length, 0); + chain.destroy(); + }); + + it("revokeAllApprovals() removes everything", async () => { + const { chain, received } = await makeChainWithAccessRequests(); + const service = { send_sms: async () => ({ sent: true }) }; + const grants = [{ capability: "send_sms", status: "active" as const, constraints: { to: { in: ["+254700000001"] } } }]; + const secured = chain.wrap(service, grants) as Record; + + const call1 = secured["send_sms"]!({ to: "+9999999", body: "a" }); + await waitFor(received, 1); + const p1 = chain.getPendingRequests()[0]!; + chain.approve({ requestId: p1.requestId, code: p1.verificationCode, scope: "global" }); + await call1; + + assert.equal(chain.getApprovalRules().length, 1); + const removed = chain.revokeAllApprovals(); + assert.equal(removed, 1); + assert.equal(chain.getApprovalRules().length, 0); + chain.destroy(); + }); + + it("allowed calls still pass through normally (not affected by access request layer)", async () => { + const { chain } = await makeChainWithAccessRequests(); + const service = { send_sms: async () => ({ sent: true }) }; + const grants = [{ capability: "send_sms", status: "active" as const, constraints: { to: { in: ["+254700000001"] } } }]; + const secured = chain.wrap(service, grants) as Record; + + // Whitelisted number — should pass immediately without any access request + const result = await secured["send_sms"]!({ to: "+254700000001", body: "hi" }) as { sent: boolean }; + assert.equal(result.sent, true); + assert.equal(chain.getPendingRequests().length, 0); + chain.destroy(); + }); + + it("audit log records denied entry when call is suspended", async () => { + const { chain, received } = await makeChainWithAccessRequests(); + const service = { send_sms: async () => ({ sent: true }) }; + const grants = [{ capability: "send_sms", status: "active" as const, constraints: { to: { in: ["+254700000001"] } } }]; + const secured = chain.wrap(service, grants) as Record; + + const callPromise = secured["send_sms"]!({ to: "+9999999", body: "test" }); + await waitFor(received, 1); + + // Audit should have recorded the initial denial + const log = chain.getAuditLog(); + const deniedEntry = log.find((e) => e.result === "denied"); + assert.ok(deniedEntry !== undefined, "audit log should have a denied entry"); + assert.ok(deniedEntry!.denialReason?.includes("[access_request]"), "denial reason should mention access_request"); + + const pending = chain.getPendingRequests()[0]!; + chain.deny({ requestId: pending.requestId, code: pending.verificationCode }); + await assert.rejects(callPromise); + chain.destroy(); + }); + + it("destroy() cleans up access request manager without error", async () => { + const { chain } = await makeChainWithAccessRequests(); + assert.doesNotThrow(() => chain.destroy()); + }); +}); diff --git a/src/access/access-request-manager.ts b/src/access/access-request-manager.ts new file mode 100644 index 0000000..83a28fd --- /dev/null +++ b/src/access/access-request-manager.ts @@ -0,0 +1,297 @@ +/** AccessRequestManager — creates HMAC-signed access requests, tracks pending calls, verifies approval codes. */ + +import { createHmac, randomBytes } from "node:crypto"; +import { generateId } from "../crypto/utils.js"; +import type { + AccessRequest, + AccessRequestConfig, + AccessRequestNotifier, + ApprovalDecision, + DenialDecision, + SuspendedCall, +} from "../types/access-request.js"; + +const DEFAULT_REQUEST_TTL_MS = 5 * 60 * 1000; // 5 minutes +const DEFAULT_MAX_PENDING = 10; +const HMAC_ALGORITHM = "sha256"; +/** Verification codes are truncated to this many hex chars for human-friendliness. */ +const CODE_LENGTH = 8; + +export class AccessRequestManager { + private readonly secret: Buffer; + private readonly notifier: AccessRequestNotifier; + private readonly requestTTLMs: number; + private readonly maxPendingPerAgent: number; + private readonly blockOnExcess: boolean; + + /** requestId → AccessRequest */ + readonly approvalSecret: Buffer; + private readonly pending = new Map(); + /** requestId → SuspendedCall (the blocked promise) */ + private readonly suspended = new Map(); + /** Expiry timer */ + private readonly expiryTimer: ReturnType; + + constructor(config: AccessRequestConfig) { + this.notifier = config.notifier; + this.requestTTLMs = config.requestTTLMs ?? DEFAULT_REQUEST_TTL_MS; + this.maxPendingPerAgent = config.maxPendingPerAgent ?? DEFAULT_MAX_PENDING; + this.blockOnExcess = config.blockOnExcessRequests ?? false; + + // The approval secret — agent NEVER sees this. + // If not provided, generate a random 32-byte key. + this.secret = config.approvalSecret + ? Buffer.from(config.approvalSecret, "utf8") + : randomBytes(32); + this.approvalSecret = this.secret; + + // Sweep expired requests every 30 seconds + this.expiryTimer = setInterval(() => this.sweepExpired(), 30_000); + if (this.expiryTimer.unref) this.expiryTimer.unref(); + } + + destroy(): void { + clearInterval(this.expiryTimer); + // Reject all suspended calls + for (const [, suspended] of this.suspended) { + suspended.reject(new Error("AccessRequestManager destroyed — pending request cancelled")); + } + this.suspended.clear(); + this.pending.clear(); + } + + // ─── Create Request ────────────────────────────────────────────────────── + + /** + * Create an access request and notify the human. + * Returns a Promise that resolves when the human approves, or rejects on deny/expire. + */ + async createRequest(params: { + agentId: string; + agentName: string; + hostId: string; + capability: string; + args: Record; + reason: string; + errorCode: "constraint_violated" | "capability_denied"; + violatedField?: string; + violatedValue?: unknown; + }): Promise<{ request: AccessRequest; waitForApproval: Promise }> { + // Check rate limit + const agentPending = this.getPendingForAgent(params.agentId); + if (agentPending.length >= this.maxPendingPerAgent) { + if (this.blockOnExcess) { + throw new Error( + `Agent "${params.agentId}" has too many pending access requests (${agentPending.length}). ` + + `Possible abuse — agent is blocked from creating more requests.` + ); + } + // Expire oldest to make room + const oldest = agentPending[0]!; + this.expireRequest(oldest.requestId); + } + + const requestId = generateId("areq"); + const createdAt = Date.now(); + const expiresAt = createdAt + this.requestTTLMs; + + // Generate HMAC verification code. + // Input: requestId + agentId + capability + createdAt + // The agent cannot compute this because it doesn't have `this.secret`. + const verificationCode = this.generateCode(requestId, params.agentId, params.capability, createdAt); + + const request: AccessRequest = { + requestId, + agentId: params.agentId, + agentName: params.agentName, + hostId: params.hostId, + capability: params.capability, + args: params.args, + reason: params.reason, + violatedField: params.violatedField, + violatedValue: params.violatedValue, + errorCode: params.errorCode, + createdAt, + expiresAt, + status: "pending", + verificationCode, + }; + + this.pending.set(requestId, request); + + // Create the suspended promise that the intercepted call will await + const waitForApproval = new Promise((resolve, reject) => { + this.suspended.set(requestId, { + requestId, + capability: params.capability, + args: params.args, + suspendedAt: Date.now(), + resolve, + reject, + }); + }); + + // Fire notification (don't await — we don't want to block if notify is slow) + this.notifier.notify(request).catch((err) => { + // If notification fails, we still keep the request pending — + // the human might have another way to check (dashboard, etc.) + console.error(`[agents-chain] Failed to send access request notification: ${err}`); + }); + + return { request, waitForApproval }; + } + + // ─── Approve ───────────────────────────────────────────────────────────── + + /** + * Approve a pending access request. Called by the host/server when the + * human submits their verification code. + * + * Returns the original request (now marked approved) so the caller can + * build approval rules from it. + */ + approve(decision: ApprovalDecision): AccessRequest { + const request = this.pending.get(decision.requestId); + if (!request) { + throw new Error(`Access request "${decision.requestId}" not found or already resolved`); + } + + if (request.status !== "pending") { + throw new Error(`Access request "${decision.requestId}" is already ${request.status}`); + } + + if (Date.now() > request.expiresAt) { + this.expireRequest(decision.requestId); + throw new Error(`Access request "${decision.requestId}" has expired`); + } + + // Verify the HMAC code — this is the critical security check. + // The human received this code out-of-band; the agent cannot forge it. + if (!this.verifyCode(decision.code, request)) { + throw new Error("Invalid verification code — approval denied"); + } + + // Mark approved + request.status = "approved"; + + // Resume the suspended call + const suspended = this.suspended.get(decision.requestId); + if (suspended) { + // The suspended promise resolves with a signal that the wrapper + // should re-execute the call. We pass the decision so the wrapper + // knows what scope/constraints to apply. + suspended.resolve({ approved: true, decision }); + } + + // Cleanup + this.pending.delete(decision.requestId); + this.suspended.delete(decision.requestId); + + // Notify adapter of resolution + this.notifier.onResolved?.(request, "approved").catch(() => {}); + + return request; + } + + // ─── Deny ──────────────────────────────────────────────────────────────── + + deny(decision: DenialDecision): AccessRequest { + const request = this.pending.get(decision.requestId); + if (!request) { + throw new Error(`Access request "${decision.requestId}" not found or already resolved`); + } + + if (request.status !== "pending") { + throw new Error(`Access request "${decision.requestId}" is already ${request.status}`); + } + + // Verify code + if (!this.verifyCode(decision.code, request)) { + throw new Error("Invalid verification code — denial rejected"); + } + + request.status = "denied"; + + // Reject the suspended call + const suspended = this.suspended.get(decision.requestId); + if (suspended) { + suspended.reject( + new Error(`Access request denied${decision.reason ? `: ${decision.reason}` : ""}`) + ); + } + + this.pending.delete(decision.requestId); + this.suspended.delete(decision.requestId); + + this.notifier.onResolved?.(request, "denied").catch(() => {}); + + return request; + } + + // ─── Queries ───────────────────────────────────────────────────────────── + + getPending(requestId: string): AccessRequest | undefined { + return this.pending.get(requestId); + } + + getPendingForAgent(agentId: string): AccessRequest[] { + return [...this.pending.values()].filter((r) => r.agentId === agentId && r.status === "pending"); + } + + getAllPending(): AccessRequest[] { + return [...this.pending.values()].filter((r) => r.status === "pending"); + } + + // ─── HMAC Code Generation / Verification ───────────────────────────────── + + private generateCode(requestId: string, agentId: string, capability: string, createdAt: number): string { + const input = `${requestId}:${agentId}:${capability}:${createdAt}`; + const hmac = createHmac(HMAC_ALGORITHM, this.secret).update(input).digest("hex"); + // Truncate to CODE_LENGTH for human-friendly codes (still 32 bits of entropy) + return hmac.slice(0, CODE_LENGTH).toUpperCase(); + } + + private verifyCode(submittedCode: string, request: AccessRequest): boolean { + const expected = this.generateCode( + request.requestId, + request.agentId, + request.capability, + request.createdAt + ); + // Constant-time comparison to prevent timing attacks + if (submittedCode.length !== expected.length) return false; + let diff = 0; + for (let i = 0; i < expected.length; i++) { + diff |= submittedCode.charCodeAt(i) ^ expected.charCodeAt(i); + } + return diff === 0; + } + + // ─── Expiry Sweep ──────────────────────────────────────────────────────── + + private sweepExpired(): void { + const now = Date.now(); + for (const [id, request] of this.pending) { + if (request.status === "pending" && now > request.expiresAt) { + this.expireRequest(id); + } + } + } + + private expireRequest(requestId: string): void { + const request = this.pending.get(requestId); + if (!request) return; + + request.status = "expired"; + + const suspended = this.suspended.get(requestId); + if (suspended) { + suspended.reject(new Error(`Access request "${requestId}" expired — no approval received in time`)); + } + + this.pending.delete(requestId); + this.suspended.delete(requestId); + + this.notifier.onResolved?.(request, "expired").catch(() => {}); + } +} diff --git a/src/access/approval-store.ts b/src/access/approval-store.ts new file mode 100644 index 0000000..fd6a598 --- /dev/null +++ b/src/access/approval-store.ts @@ -0,0 +1,284 @@ +/** ApprovalStore — encrypted store for approval rules. Agent cannot write directly; + * only the AccessRequestManager (via verified HMAC codes) can create rules. */ + +import { createHmac } from "node:crypto"; +import { generateId } from "../crypto/utils.js"; +import type { EncryptedStore } from "../memory/encrypted-store.js"; +import type { AccessRequest, ApprovalDecision, ApprovalRule } from "../types/access-request.js"; +import type { ConstraintOperator, ConstraintPrimitive, GrantConstraints } from "../types/capabilities.js"; + +const STORE_KEY = "approval_rules"; +const INTEGRITY_KEY = "approval_rules_integrity"; + +export class ApprovalStore { + private readonly store: EncryptedStore; + /** HMAC secret for integrity — same secret as AccessRequestManager. */ + private readonly secret: Buffer; + /** In-memory cache of rules (source of truth is the encrypted store). */ + private rules: ApprovalRule[] = []; + + constructor(store: EncryptedStore, secret: Buffer) { + this.store = store; + this.secret = secret; + this.load(); + } + + // ─── Create Rule from Approved Request ─────────────────────────────────── + + /** + * Called ONLY after HMAC-verified approval. Creates the appropriate rule + * based on the approval scope. + */ + createRule(request: AccessRequest, decision: ApprovalDecision): ApprovalRule { + const now = Date.now(); + let expiresAt: number | undefined; + + if (decision.ttl?.expiresAt) { + expiresAt = decision.ttl.expiresAt; + } else if (decision.ttl?.durationMs) { + expiresAt = now + decision.ttl.durationMs; + } + // No TTL + session scope = no expiresAt (lives for the session) + // No TTL + global scope = no expiresAt (lives forever until revoked) + + const rule: ApprovalRule = { + ruleId: generateId("arule"), + capability: request.capability, + scope: decision.scope, + approvedBy: request.requestId, + createdAt: now, + expiresAt, + global: decision.scope === "global", + }; + + // For "value" scope — record the specific field/value that was approved + if (decision.scope === "value" && request.violatedField != null) { + rule.field = request.violatedField; + rule.value = request.violatedValue; + } + + // For constraint expansion + if (decision.expandConstraints) { + rule.expandedConstraints = decision.expandConstraints; + } + + this.rules.push(rule); + this.persist(); + + return rule; + } + + // ─── Query Rules ───────────────────────────────────────────────────────── + + /** + * Check if there's an active approval rule that covers this capability + args. + * Returns the matching rule, or null if none found. + */ + findMatchingRule( + agentId: string, + capability: string, + args: Record, + violatedField?: string, + violatedValue?: unknown + ): ApprovalRule | null { + this.sweepExpired(); + + for (const rule of this.rules) { + if (rule.capability !== capability) continue; + + // Global rules apply to any agent + // Non-global rules only apply via the approval flow (we don't store agentId + // on the rule because session rules are per-chain, not per-agent) + + switch (rule.scope) { + case "call": + // One-time rules are consumed immediately — they shouldn't be in the store + // after use. If somehow one is here, skip it. + continue; + + case "value": + // Match if the violated field+value matches this rule + if (rule.field === violatedField && this.valueMatches(rule.value, violatedValue)) { + return rule; + } + break; + + case "capability": + // Blanket approval for this capability — always matches + return rule; + + case "global": + // Blanket approval for all agents on this capability + return rule; + } + } + + return null; + } + + /** + * Get the expanded constraints from all active rules for a capability. + * These get merged into the grant constraints before enforcement. + */ + /** + * Get the expanded constraints from all active rules for a capability. + * These get merged into the grant constraints before enforcement. + * + * Returns: + * - `null` → a capability/global rule bypasses ALL constraints + * - `undefined` → no matching rules found, no expansions (use original constraints) + * - `GrantConstraints` → merged expansions to apply on top of the grant constraints + */ + getExpandedConstraints(capability: string): GrantConstraints | null | undefined { + this.sweepExpired(); + + const merged: GrantConstraints = {}; + let hasExpansions = false; + + for (const rule of this.rules) { + if (rule.capability !== capability) continue; + + // "capability" and "global" scopes bypass constraints entirely + if (rule.scope === "capability" || rule.scope === "global") { + return null; // null = no constraints (all allowed) + } + + // Merge expanded constraints + if (rule.expandedConstraints) { + for (const [field, constraint] of Object.entries(rule.expandedConstraints)) { + merged[field] = this.mergeConstraintValue(merged[field], constraint); + hasExpansions = true; + } + } + + // For "value" and "call" scope, expand the `in` list for that field. + // "call" scope needs this too — without it the re-execution hits the + // same constraint violation and creates an infinite access request loop. + if ((rule.scope === "value" || rule.scope === "call") && rule.field && rule.value !== undefined) { + const existing = merged[rule.field]; + if (existing && typeof existing === "object" && !Array.isArray(existing)) { + const op = existing as ConstraintOperator; + if (op.in && !op.in.includes(rule.value as ConstraintPrimitive)) { + op.in.push(rule.value as ConstraintPrimitive); + } + } else { + // Create a new `in` constraint with just this value + merged[rule.field] = { in: [rule.value as ConstraintPrimitive] }; + } + hasExpansions = true; + } + } + + return hasExpansions ? merged : undefined; + } + + // ─── Revoke ────────────────────────────────────────────────────────────── + + revokeRule(ruleId: string): boolean { + const idx = this.rules.findIndex((r) => r.ruleId === ruleId); + if (idx === -1) return false; + this.rules.splice(idx, 1); + this.persist(); + return true; + } + + revokeAllForCapability(capability: string): number { + const before = this.rules.length; + this.rules = this.rules.filter((r) => r.capability !== capability); + this.persist(); + return before - this.rules.length; + } + + revokeAll(): number { + const count = this.rules.length; + this.rules = []; + this.persist(); + return count; + } + + getAll(): ApprovalRule[] { + this.sweepExpired(); + return [...this.rules]; + } + + // ─── Persistence (tamper-proof) ────────────────────────────────────────── + + private persist(): void { + this.store.set(STORE_KEY, this.rules); + // Write HMAC integrity tag so we can detect tampering + const integrity = this.computeIntegrity(this.rules); + this.store.set(INTEGRITY_KEY, integrity); + } + + private load(): void { + const rules = this.store.get(STORE_KEY); + if (!rules) { + this.rules = []; + return; + } + + // Verify integrity — if the agent somehow wrote to the store directly, + // the HMAC won't match and we reject all rules. + const storedIntegrity = this.store.get(INTEGRITY_KEY); + const computed = this.computeIntegrity(rules); + if (storedIntegrity !== computed) { + console.error("[agents-chain] ApprovalStore integrity check FAILED — possible tampering. All rules cleared."); + this.rules = []; + this.persist(); + return; + } + + this.rules = rules; + } + + private computeIntegrity(rules: ApprovalRule[]): string { + const payload = JSON.stringify(rules); + return createHmac("sha256", this.secret).update(payload).digest("hex"); + } + + // ─── Helpers ───────────────────────────────────────────────────────────── + + private sweepExpired(): void { + const now = Date.now(); + const before = this.rules.length; + this.rules = this.rules.filter((r) => !r.expiresAt || r.expiresAt > now); + if (this.rules.length !== before) { + this.persist(); + } + } + + private valueMatches(ruleValue: unknown, actual: unknown): boolean { + if (ruleValue === actual) return true; + // Deep comparison for objects + return JSON.stringify(ruleValue) === JSON.stringify(actual); + } + + private mergeConstraintValue( + existing: import("../types/capabilities.js").ConstraintValue | undefined, + incoming: import("../types/capabilities.js").ConstraintValue + ): import("../types/capabilities.js").ConstraintValue { + if (!existing) return incoming; + + // If both are operators, merge their lists + if (typeof existing === "object" && typeof incoming === "object") { + const merged = { ...existing } as ConstraintOperator; + const inc = incoming as ConstraintOperator; + if (inc.in) { + merged.in = [...new Set([...(merged.in ?? []), ...inc.in])]; + } + if (inc.not_in) { + // Remove from not_in if we're approving it + merged.not_in = (merged.not_in ?? []).filter( + (v) => !inc.in?.includes(v) + ); + if (merged.not_in.length === 0) delete merged.not_in; + } + if (inc.max !== undefined) merged.max = Math.max(merged.max ?? -Infinity, inc.max); + if (inc.min !== undefined) merged.min = Math.min(merged.min ?? Infinity, inc.min); + return merged; + } + + // Incoming takes precedence for primitives + return incoming; + } +} diff --git a/src/app/app-wrapper.ts b/src/app/app-wrapper.ts index 7788388..65fc428 100644 --- a/src/app/app-wrapper.ts +++ b/src/app/app-wrapper.ts @@ -1,5 +1,6 @@ /** Proxy wrapper for arbitrary service objects. Registered methods are auth-gated via CapabilityRegistry. - * If a Capability has an `execute` function, it is called. Otherwise, the target's own method is called. */ + * If a Capability has an `execute` function, it is called. Otherwise, the target's own method is called. + * When access requests are enabled, denied calls suspend and wait for human approval. */ import { ChainAuthError } from "../errors/chain-error.js"; import { enforceConstraints } from "../auth/constraints.js"; @@ -9,7 +10,10 @@ import type { TokenBuilder } from "../auth/token-builder.js"; import type { TokenVerifier } from "../auth/token-verifier.js"; import type { AgentIdentity } from "../identity/agent-identity.js"; import type { ResolvedGrant } from "../types/protocol.js"; -import type { AgentContext } from "../types/capabilities.js"; +import type { AgentContext, GrantConstraints } from "../types/capabilities.js"; +import type { AccessRequestManager } from "../access/access-request-manager.js"; +import type { ApprovalStore } from "../access/approval-store.js"; +import type { ApprovalDecision } from "../types/access-request.js"; export type AppInterceptContext = { identity: AgentIdentity; @@ -17,6 +21,10 @@ export type AppInterceptContext = { verifier: TokenVerifier; log: AuditLog; grants: ResolvedGrant[]; + /** If set, denied calls will suspend and wait for human approval. */ + accessRequestManager?: AccessRequestManager; + /** Stores approved rules so future calls don't re-prompt. */ + approvalStore?: ApprovalStore; }; export function wrapApp( @@ -49,79 +57,127 @@ function createInterceptedMethod( ): (...args: unknown[]) => Promise { return async (...args: unknown[]) => { const callArgs = (args[0] ?? {}) as Record; + return executeWithAccessRequest(capabilityName, callArgs, ctx, targetFn); + }; +} - let jti = "unknown"; - const authStart = Date.now(); - try { - const { token, claims } = await ctx.builder.build(capabilityName); - jti = claims.jti; - const verified = await ctx.verifier.verify(token, capabilityName, ctx.grants); - const authOverheadMs = Date.now() - authStart; - - const registryEntry = getCapabilityFromCtx(capabilityName, ctx); - if (!registryEntry) { - throw new ChainAuthError( - "capability_denied", - `Capability "${capabilityName}" not found in registry` - ); - } +/** + * Core execution logic. Separated so it can be re-invoked after approval + * without losing context — the callArgs, capability, and targetFn are all + * captured in the closure. + */ +async function executeWithAccessRequest( + capabilityName: string, + callArgs: Record, + ctx: AppInterceptContext, + targetFn?: (...args: unknown[]) => unknown +): Promise { + let jti = "unknown"; + const authStart = Date.now(); + try { + const { token, claims } = await ctx.builder.build(capabilityName); + jti = claims.jti; + const verified = await ctx.verifier.verify(token, capabilityName, ctx.grants); + const authOverheadMs = Date.now() - authStart; - const grant = ctx.grants.find( - (g) => g.capability === capabilityName && g.status === "active" + const registryEntry = getCapabilityFromCtx(capabilityName, ctx); + if (!registryEntry) { + throw new ChainAuthError( + "capability_denied", + `Capability "${capabilityName}" not found in registry` ); - if (grant?.constraints) { - enforceConstraints(grant.constraints, callArgs, registryEntry.inputSchema); - } + } - const agentContext: AgentContext = { - agentId: verified.agentId, - hostId: verified.hostThumbprint, - permissions: ctx.grants - .filter((g) => g.status === "active") - .map((g) => g.capability), - }; - - // If Capability defines execute, use it. Otherwise, delegate to the target's method. - const executeFn = registryEntry.execute - ? (a: unknown) => registryEntry.execute!(a, agentContext) - : targetFn - ? (...a: unknown[]) => Promise.resolve(targetFn(...a)) - : null; - - if (!executeFn) { - throw new ChainAuthError( - "capability_denied", - `Capability "${capabilityName}" has no execute function and no target method to delegate to` - ); - } + const grant = ctx.grants.find( + (g) => g.capability === capabilityName && g.status === "active" + ); - const callStart = Date.now(); - let result: unknown; - try { - result = await executeFn(callArgs); - } catch (execErr) { - ctx.log.recordCall({ - context: verified, - args: callArgs, - result: "error", - durationMs: Date.now() - callStart, - errorMessage: execErr instanceof Error ? execErr.message : String(execErr), - authOverheadMs, - }); - throw execErr; + if (grant?.constraints) { + // Check if any approval rules expand these constraints + const effectiveConstraints = getEffectiveConstraints( + capabilityName, grant.constraints, ctx.approvalStore + ); + + if (effectiveConstraints) { + enforceConstraints(effectiveConstraints, callArgs, registryEntry.inputSchema); } + // effectiveConstraints === null means a "capability"/"global" rule removed all constraints + } + const agentContext: AgentContext = { + agentId: verified.agentId, + hostId: verified.hostThumbprint, + permissions: ctx.grants + .filter((g) => g.status === "active") + .map((g) => g.capability), + }; + + // If Capability defines execute, use it. Otherwise, delegate to the target's method. + const executeFn = registryEntry.execute + ? (a: unknown) => registryEntry.execute!(a, agentContext) + : targetFn + ? (...a: unknown[]) => Promise.resolve(targetFn(...a)) + : null; + + if (!executeFn) { + throw new ChainAuthError( + "capability_denied", + `Capability "${capabilityName}" has no execute function and no target method to delegate to` + ); + } + + const callStart = Date.now(); + let result: unknown; + try { + result = await executeFn(callArgs); + } catch (execErr) { ctx.log.recordCall({ context: verified, args: callArgs, - result: "success", + result: "error", durationMs: Date.now() - callStart, + errorMessage: execErr instanceof Error ? execErr.message : String(execErr), authOverheadMs, }); + throw execErr; + } + + ctx.log.recordCall({ + context: verified, + args: callArgs, + result: "success", + durationMs: Date.now() - callStart, + authOverheadMs, + }); + + return result; + } catch (err) { + if (err instanceof ChainAuthError) { + // ── Access Request Flow ────────────────────────────────────── + // If access requests are enabled, instead of throwing immediately, + // we suspend the call and wait for human approval. + if (ctx.accessRequestManager && isRequestableError(err)) { + const { violatedField, violatedValue } = extractViolationDetails(err); - return result; - } catch (err) { - if (err instanceof ChainAuthError) { + // Check if there's already an approval rule that covers this + if (ctx.approvalStore) { + const existingRule = ctx.approvalStore.findMatchingRule( + ctx.identity.agentId, + capabilityName, + callArgs, + violatedField, + violatedValue + ); + if (existingRule) { + // Rule exists but constraint enforcement still failed — + // this shouldn't happen if getEffectiveConstraints worked. + // Re-execute with fresh auth token (the approval rule + // will take effect via getEffectiveConstraints). + return executeWithAccessRequest(capabilityName, callArgs, ctx, targetFn); + } + } + + // Log the denial before suspending ctx.log.recordDenied({ agentId: ctx.identity.agentId, agentName: ctx.identity.registration.agentName, @@ -129,17 +185,155 @@ function createInterceptedMethod( hostThumbprint: ctx.identity.registration.hostThumbprint, capability: capabilityName, args: callArgs, - reason: err.message, + reason: `[access_request] ${err.message}`, jti, authOverheadMs: Date.now() - authStart, }); - throw err; + + // Create the access request and SUSPEND — the promise won't + // resolve until the human approves/denies/expires. + const { request, waitForApproval } = await ctx.accessRequestManager.createRequest({ + agentId: ctx.identity.agentId, + agentName: ctx.identity.registration.agentName, + hostId: ctx.identity.registration.hostThumbprint, + capability: capabilityName, + args: callArgs, + reason: err.message, + errorCode: err.code as "constraint_violated" | "capability_denied", + violatedField, + violatedValue, + }); + + // Block here — the agent's call is suspended. + // Context is preserved: capabilityName, callArgs, ctx, targetFn + // are all in the closure. When the promise resolves, we re-execute. + const approvalResult = await waitForApproval as { + approved: boolean; + decision: ApprovalDecision; + }; + + // Human approved — create the approval rule + if (approvalResult.approved && ctx.approvalStore) { + const rule = ctx.approvalStore.createRule(request, approvalResult.decision); + + // For "call" scope, execute once then remove the rule + if (approvalResult.decision.scope === "call") { + const result = await executeWithAccessRequest( + capabilityName, callArgs, ctx, targetFn + ); + ctx.approvalStore.revokeRule(rule.ruleId); + return result; + } + } + + // Re-execute with the new approval rule in place + return executeWithAccessRequest(capabilityName, callArgs, ctx, targetFn); } + + // No access request manager — throw as before + ctx.log.recordDenied({ + agentId: ctx.identity.agentId, + agentName: ctx.identity.registration.agentName, + hostname: ctx.identity.registration.hostname, + hostThumbprint: ctx.identity.registration.hostThumbprint, + capability: capabilityName, + args: callArgs, + reason: err.message, + jti, + authOverheadMs: Date.now() - authStart, + }); throw err; } + throw err; + } +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** Only constraint_violated and capability_denied can trigger access requests. */ +function isRequestableError(err: ChainAuthError): boolean { + return err.code === "constraint_violated" || err.code === "capability_denied"; +} + +/** Extract which field/value was violated from the error message. */ +function extractViolationDetails(err: ChainAuthError): { + violatedField?: string; + violatedValue?: unknown; +} { + if (err.code !== "constraint_violated") return {}; + + // Parse field name from messages like: field "to": "..." is not in allowed list [...] + const fieldMatch = err.message.match(/field "([^"]+)"/); + const valueMatch = err.message.match(/field "[^"]+": "([^"]*)"/) ?? + err.message.match(/field "[^"]+": (\S+)/); + + return { + violatedField: fieldMatch?.[1], + violatedValue: valueMatch?.[1], }; } +/** + * Merge approval-store expansions into the grant constraints. + * Returns null if a capability/global rule removes all constraints. + * Returns the original constraints if no expansions apply. + */ +function getEffectiveConstraints( + capability: string, + grantConstraints: GrantConstraints, + approvalStore?: ApprovalStore +): GrantConstraints | null { + if (!approvalStore) return grantConstraints; + + const expansions = approvalStore.getExpandedConstraints(capability); + if (expansions === null) { + // A capability/global rule removed all constraints + return null; + } + + if (expansions === undefined) return grantConstraints; // No rules found — keep original + + // Merge expansions into a copy of the grant constraints + const merged = { ...grantConstraints }; + for (const [field, expansion] of Object.entries(expansions)) { + const existing = merged[field]; + if (!existing) continue; // Don't add new constraints, only expand existing ones + + if (typeof existing === "object" && !Array.isArray(existing) && + typeof expansion === "object" && !Array.isArray(expansion)) { + const existingOp = existing as import("../types/capabilities.js").ConstraintOperator; + const expOp = expansion as import("../types/capabilities.js").ConstraintOperator; + + const mergedOp = { ...existingOp }; + + // Expand `in` lists + if (expOp.in && mergedOp.in) { + mergedOp.in = [...new Set([...mergedOp.in, ...expOp.in])]; + } + + // Remove approved values from `not_in` + if (expOp.in && mergedOp.not_in) { + mergedOp.not_in = mergedOp.not_in.filter( + (v) => !expOp.in!.includes(v) + ); + if (mergedOp.not_in.length === 0) delete mergedOp.not_in; + } + + // Expand max/min bounds + if (expOp.max !== undefined && mergedOp.max !== undefined) { + mergedOp.max = Math.max(mergedOp.max, expOp.max); + } + if (expOp.min !== undefined && mergedOp.min !== undefined) { + mergedOp.min = Math.min(mergedOp.min, expOp.min); + } + + merged[field] = mergedOp; + } + } + + return merged; +} + // Helper — we need registry access at intercept time. // We store it on the context via a symbol to keep the type clean. const REGISTRY_SYM = Symbol("registry"); diff --git a/src/chain.ts b/src/chain.ts index 6ac0ae0..6d699f2 100644 --- a/src/chain.ts +++ b/src/chain.ts @@ -11,11 +11,14 @@ import { wrapAnthropic } from "./wrappers/anthropic-wrapper.js"; import { HostIdentity } from "./host/host-identity.js"; import { CapabilityRegistry } from "./app/capability-registry.js"; import { wrapApp, attachRegistry } from "./app/app-wrapper.js"; +import { AccessRequestManager } from "./access/access-request-manager.js"; +import { ApprovalStore } from "./access/approval-store.js"; import type { AgentConfig, ChainStats, AuditSnapshot, AppChainConfig } from "./types/chain.js"; import type { AuditEntry } from "./types/audit.js"; import type { AuditExporter } from "./audit/audit-exporter.js"; import type { ResolvedGrant, AgentConfiguration } from "./types/protocol.js"; import type { AppInterceptContext } from "./app/app-wrapper.js"; +import type { ApprovalDecision, DenialDecision, AccessRequest, ApprovalRule } from "./types/access-request.js"; export class AgentsChain { private readonly store: EncryptedStore; @@ -163,6 +166,8 @@ export class AppChain { private readonly log: AuditLog; private readonly jtiCache: JtiCache; private readonly exporter?: AuditExporter; + private readonly accessRequestManager?: AccessRequestManager; + private readonly approvalStore?: ApprovalStore; private constructor( host: HostIdentity, @@ -172,7 +177,9 @@ export class AppChain { verifier: TokenVerifier, log: AuditLog, jtiCache: JtiCache, - exporter?: AuditExporter + exporter?: AuditExporter, + accessRequestManager?: AccessRequestManager, + approvalStore?: ApprovalStore ) { this.host = host; this.registry = registry; @@ -182,6 +189,8 @@ export class AppChain { this.log = log; this.jtiCache = jtiCache; this.exporter = exporter; + this.accessRequestManager = accessRequestManager; + this.approvalStore = approvalStore; } static async create(config: AppChainConfig): Promise { @@ -267,11 +276,24 @@ export class AppChain { registry.register(cap); } - return new AppChain(host, registry, identity, builder, verifier, log, jtiCache, config.auditExporter); + // Access request system (optional) + let accessRequestManager: AccessRequestManager | undefined; + let approvalStoreInstance: ApprovalStore | undefined; + if (config.accessRequests) { + accessRequestManager = new AccessRequestManager(config.accessRequests); + // The approval secret must match between manager and store for integrity checks. + approvalStoreInstance = new ApprovalStore(store, accessRequestManager.approvalSecret); + } + + return new AppChain( + host, registry, identity, builder, verifier, log, jtiCache, + config.auditExporter, accessRequestManager, approvalStoreInstance + ); } destroy(): void { this.jtiCache.destroy(); + this.accessRequestManager?.destroy(); } wrap(target: T, grants: ResolvedGrant[]): T { @@ -281,11 +303,81 @@ export class AppChain { verifier: this.verifier, log: this.log, grants, + accessRequestManager: this.accessRequestManager, + approvalStore: this.approvalStore, }; attachRegistry(ctx, this.registry); return wrapApp(target, this.registry, ctx); } + // ─── Access Request API ────────────────────────────────────────────────── + + /** + * Approve a pending access request. Called by the server when a human + * submits their verification code (via webhook, API endpoint, UI, etc.). + * + * The verification code was sent out-of-band to the human — the agent + * cannot forge it because it doesn't have the HMAC secret. + */ + approve(decision: ApprovalDecision): AccessRequest { + if (!this.accessRequestManager) { + throw new Error("Access requests are not enabled on this AppChain"); + } + return this.accessRequestManager.approve(decision); + } + + /** + * Deny a pending access request. + */ + deny(decision: DenialDecision): AccessRequest { + if (!this.accessRequestManager) { + throw new Error("Access requests are not enabled on this AppChain"); + } + return this.accessRequestManager.deny(decision); + } + + /** + * Get all pending access requests (for building a dashboard/UI). + */ + getPendingRequests(): AccessRequest[] { + return this.accessRequestManager?.getAllPending() ?? []; + } + + /** + * Get all active approval rules. + */ + getApprovalRules(): ApprovalRule[] { + return this.approvalStore?.getAll() ?? []; + } + + /** + * Revoke a specific approval rule. + */ + revokeApproval(ruleId: string): boolean { + return this.approvalStore?.revokeRule(ruleId) ?? false; + } + + /** + * Revoke all approval rules for a capability. + */ + revokeApprovalsForCapability(capability: string): number { + return this.approvalStore?.revokeAllForCapability(capability) ?? 0; + } + + /** + * Revoke all approval rules (nuclear option). + */ + revokeAllApprovals(): number { + return this.approvalStore?.revokeAll() ?? 0; + } + + /** Whether access requests are enabled on this chain. */ + get accessRequestsEnabled(): boolean { + return this.accessRequestManager !== undefined; + } + + // ─── Existing API ──────────────────────────────────────────────────────── + /** Serve this at GET /.well-known/agent-configuration for agent discovery. */ getWellKnownConfig( endpointPrefix?: string, diff --git a/src/errors/chain-error.ts b/src/errors/chain-error.ts index 88045d4..1f79253 100644 --- a/src/errors/chain-error.ts +++ b/src/errors/chain-error.ts @@ -16,7 +16,10 @@ export type ChainErrorCode = | "token_replayed" | "token_expired" | "token_invalid" - | "agent_not_found"; + | "agent_not_found" + | "access_request_pending" + | "access_request_denied" + | "access_request_expired"; export class ChainAuthError extends Error { readonly code: ChainErrorCode; diff --git a/src/index.ts b/src/index.ts index cc2a511..b58d6c2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -72,6 +72,24 @@ export type { StorePersistenceAdapter } from "./memory/encrypted-store.js"; export type { VerifierConfig, VerifiedCallContext } from "./auth/token-verifier.js"; +// ─── Access Requests ───────────────────────────────────────────────────────── + +export { AccessRequestManager } from "./access/access-request-manager.js"; +export { ApprovalStore } from "./access/approval-store.js"; + +export type { + AccessRequest, + AccessRequestStatus, + AccessRequestConfig, + AccessRequestNotifier, + ApprovalDecision, + DenialDecision, + ApprovalScope, + ApprovalTTL, + ApprovalRule, + SuspendedCall, +} from "./types/access-request.js"; + // ─── Crypto utilities ───────────────────────────────────────────────────────── export { diff --git a/src/types/access-request.ts b/src/types/access-request.ts new file mode 100644 index 0000000..7204e5c --- /dev/null +++ b/src/types/access-request.ts @@ -0,0 +1,176 @@ +/** Access Request types — agent-initiated permission escalation with out-of-band human verification. */ + +import type { GrantConstraints } from "./capabilities.js"; + +// ─── Approval Scope ────────────────────────────────────────────────────────── + +/** + * Controls how broadly an approval applies: + * + * - "call" → one-time: approve this exact call only, then discard. + * - "value" → session: approve this specific constraint value for the session + * (e.g. allow "+1234567890" for the rest of the session). + * - "capability" → session: approve the entire capability with relaxed constraints + * for the session. + * - "global" → persistent: approve for ALL agents that hit this constraint, + * stored encrypted and survives restart. + */ +export type ApprovalScope = "call" | "value" | "capability" | "global"; + +/** + * Optional TTL for approvals. If not provided: + * - "call" scope: expires after a single use. + * - "value"/"capability" scope: expires when the session ends. + * - "global" scope: never expires (until explicitly revoked). + */ +export type ApprovalTTL = { + /** Duration in milliseconds. */ + durationMs?: number; + /** Absolute expiry timestamp (Unix ms). Takes precedence over durationMs. */ + expiresAt?: number; +}; + +// ─── Access Request ────────────────────────────────────────────────────────── + +export type AccessRequestStatus = "pending" | "approved" | "denied" | "expired"; + +export type AccessRequest = { + /** Unique request ID — used to correlate notification → approval. */ + requestId: string; + /** Agent that triggered the request. */ + agentId: string; + agentName: string; + /** Host that owns this chain. */ + hostId: string; + /** The capability the agent tried to use. */ + capability: string; + /** The args that triggered the violation. */ + args: Record; + /** Why the request was created (constraint violation message, denial reason). */ + reason: string; + /** The specific constraint field that was violated (if applicable). */ + violatedField?: string; + /** The violated constraint value (e.g. the number not in the whitelist). */ + violatedValue?: unknown; + /** The error code that triggered this request. */ + errorCode: "constraint_violated" | "capability_denied"; + /** When the request was created. */ + createdAt: number; + /** When the request expires (pending requests auto-expire). */ + expiresAt: number; + /** Current status. */ + status: AccessRequestStatus; + /** + * HMAC of (requestId + agentId + capability + createdAt) using the host's + * approval secret. The agent never sees this secret, so it cannot forge + * an approval code. This is sent to the human via the notification channel. + */ + verificationCode: string; +}; + +// ─── Approval Decision ─────────────────────────────────────────────────────── + +export type ApprovalDecision = { + requestId: string; + /** The verification code the human received out-of-band. */ + code: string; + /** How broadly to apply this approval. */ + scope: ApprovalScope; + /** Optional TTL override. */ + ttl?: ApprovalTTL; + /** Optional: expand constraints instead of removing them. + * e.g. { "to": { in: ["+1234567890"] } } — adds this value to the whitelist. */ + expandConstraints?: GrantConstraints; +}; + +export type DenialDecision = { + requestId: string; + /** The verification code. */ + code: string; + /** Optional reason for denial (recorded in audit). */ + reason?: string; +}; + +// ─── Stored Approval Rule ──────────────────────────────────────────────────── + +/** A rule stored in the ApprovalStore that allows future calls without re-prompting. */ +export type ApprovalRule = { + ruleId: string; + /** Which capability this rule applies to. */ + capability: string; + scope: ApprovalScope; + /** The specific field + value approved (for "value" scope). */ + field?: string; + value?: unknown; + /** Expanded constraints (merged into the grant's constraints). */ + expandedConstraints?: GrantConstraints; + /** Who approved this. */ + approvedBy: string; // requestId that created this rule + /** When this rule was created. */ + createdAt: number; + /** When this rule expires (undefined = session-scoped or never). */ + expiresAt?: number; + /** If true, this rule applies to all agents, not just the one that requested it. */ + global: boolean; +}; + +// ─── Notification Adapter ──────────────────────────────────────────────────── + +/** + * Pluggable notification channel — implement this to send access requests + * via email, SMS, push notification, webhook, Slack, etc. + * + * The adapter is ONLY responsible for delivering the notification. + * It does NOT handle approval — that comes back through AppChain.approve(). + */ +export interface AccessRequestNotifier { + /** + * Send a notification to the human operator. + * The `request` contains the `verificationCode` that the human must + * submit back to approve. + */ + notify(request: AccessRequest): Promise; + + /** + * Optional: called when a request is resolved (approved/denied/expired). + * Useful for updating a UI or closing a notification. + */ + onResolved?(request: AccessRequest, outcome: "approved" | "denied" | "expired"): Promise; +} + +// ─── Suspended Call Context ────────────────────────────────────────────────── + +/** + * Captured when a call is suspended waiting for approval. + * Contains everything needed to resume the call exactly where it left off. + */ +export type SuspendedCall = { + requestId: string; + capability: string; + args: Record; + /** Timestamp when the call was suspended. */ + suspendedAt: number; + /** Resolve the suspended promise (call resumes). */ + resolve: (result: unknown) => void; + /** Reject the suspended promise (call fails). */ + reject: (error: Error) => void; +}; + +// ─── Access Request Config ─────────────────────────────────────────────────── + +export type AccessRequestConfig = { + /** The notification adapter (email, SMS, webhook, etc.). */ + notifier: AccessRequestNotifier; + /** + * The approval secret — HMAC key used to sign verification codes. + * MUST be kept outside the agent's reach (env var, KMS, separate service). + * 32+ bytes recommended. If not provided, a random one is generated. + */ + approvalSecret?: string; + /** How long a pending request stays valid before expiring (ms). Default: 5 minutes. */ + requestTTLMs?: number; + /** Maximum number of pending requests per agent. Default: 10. */ + maxPendingPerAgent?: number; + /** Whether to auto-block agents that exceed maxPending (potential abuse). Default: false. */ + blockOnExcessRequests?: boolean; +}; diff --git a/src/types/audit.ts b/src/types/audit.ts index 566a63e..180dc13 100644 --- a/src/types/audit.ts +++ b/src/types/audit.ts @@ -1,5 +1,5 @@ -export type AuditResult = "success" | "denied" | "error"; +export type AuditResult = "success" | "denied" | "error" | "access_requested" | "access_approved" | "access_denied"; export type AuditEntry = { id: string; @@ -16,4 +16,8 @@ export type AuditEntry = { timestamp: number; durationMs: number; authOverheadMs: number; + /** Set when result is access_requested/access_approved/access_denied. */ + accessRequestId?: string; + /** The approval scope that was applied (for access_approved). */ + approvalScope?: import("./access-request.js").ApprovalScope; }; diff --git a/src/types/chain.ts b/src/types/chain.ts index f53a1ee..5ebd94e 100644 --- a/src/types/chain.ts +++ b/src/types/chain.ts @@ -4,6 +4,7 @@ import type { JtiPersistenceAdapter } from "../memory/jti-cache.js"; import type { AuditEntry } from "./audit.js"; import type { Capability } from "./capabilities.js"; import type { AgentConfig } from "./identity.js"; +import type { AccessRequestConfig } from "./access-request.js"; export type { AgentConfig }; @@ -30,6 +31,8 @@ export type AppChainConfig = { auditExporter?: AuditExporter; /** Resolve grants from DB/Redis instead of the grants passed to wrap(). */ grantResolver?: VerifierConfig["grantResolver"]; + /** Enable agent access requests — agents can request permission for denied actions. */ + accessRequests?: AccessRequestConfig; }; export type ChainStats = {