From 8ede4519ce7eb516db8fddb2f4e55a113842f366 Mon Sep 17 00:00:00 2001 From: Antigravity Date: Sun, 27 Sep 2026 00:18:51 +0800 Subject: [PATCH 1/2] feat(security): bind HerdR physical tool exposure producer (#190) - Add canonical nexus.tool_exposure_receipt.v1 producer in src/tool-exposure-receipt.ts - Wire toolExposureReceipt into HerdrExternalHandle, NormalizedHerdrHandleAuthority, and LocalAgentSessionManager - Produce ENFORCED_MANAGED_BRIDGE for OpenCode via opencodeAgentConfig permission projection - Maintain fail-closed REQUEST_ONLY_NOT_ENFORCED for un-enforced CLI providers (agy, codex, grok, cline) - Expose toolExposureReceipt in StartAgentOutput, AgentStatusOutput, and ReconcileAgentOutput - Add unit and matrix test suite in src/tool-exposure-producer.test.ts --- package.json | 3 +- src/local-agent-herdr.ts | 46 ++++ src/local-agent-sessions.ts | 20 ++ src/tool-exposure-producer.test.ts | 367 +++++++++++++++++++++++++++++ src/tool-exposure-receipt.ts | 356 ++++++++++++++++++++++++++++ 5 files changed, 791 insertions(+), 1 deletion(-) create mode 100644 src/tool-exposure-producer.test.ts create mode 100644 src/tool-exposure-receipt.ts diff --git a/package.json b/package.json index 44bfb8640..c253adc69 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,8 @@ "test": "npm run test:node-pty-permissions && tsx src/development-boundaries.test.ts && npm run test:physical-host-registry && npm run test:carrier-binding && tsx src/coordination-reader-loader.test.ts && tsx src/control-plane-ownership.test.ts && tsx src/control-plane-handoff.test.ts && tsx src/control-plane-continuation.test.ts && tsx src/deployment-convergence.test.ts && tsx src/capability-generation-convergence.test.ts && tsx src/control-plane-convergence.test.ts && tsx src/current-completion-matrix.test.ts && tsx src/control-plane-consumer.test.ts && tsx src/local-agent-cline-catalog.test.ts && tsx src/chat-swarm-contract.test.ts && tsx src/chat-swarm-store.test.ts && tsx src/chat-swarm-runtime-owner.test.ts && tsx src/chat-swarm-runtime-tools.test.ts && tsx src/local-agent-opencode-mcp-catalog.test.ts && tsx src/chat-swarm-coordinator.test.ts && tsx src/chat-swarm-peer-runtime.test.ts && tsx src/chat-swarm-tools.test.ts && tsx src/chat-swarm-lifecycle.test.ts && tsx src/chat-swarm-carrier.test.ts && tsx src/chat-swarm-continuation-domain.test.ts && tsx src/chat-swarm-continuation-store.test.ts && tsx src/chat-swarm-peer-admission.test.ts && tsx src/chat-swarm-task-ledger.test.ts && tsx src/git-worktrees.test.ts && tsx src/host-storage-retention.test.ts && tsx src/execution-protocol.test.ts && tsx src/durable-operations-ci.test.ts && tsx src/git-candidate.test.ts && tsx src/config.test.ts && tsx src/onboarding.test.ts && tsx src/cli-workspace.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions-ci.test.ts && tsx src/codex-goal-sessions-ci.test.ts && tsx src/local-agent-process-tree.test.ts && tsx src/mcp-sessions.test.ts && tsx src/cutover-state.test.ts && tsx src/cutover-state-recovery-guard.test.ts && tsx src/cutover-build-ready.test.ts && tsx src/cutover-orchestration.test.ts && tsx src/mcp-cutover.test.ts && tsx src/cutover-restart.test.ts && tsx src/cutover-http.test.ts && tsx src/cutover-recovery.test.ts && tsx src/cutover-binding-repair.test.ts && tsx src/capability-manifest.test.ts && tsx src/server-shutdown.test.ts && tsx src/codex-runtime.test.ts && tsx src/local-agent-config.test.ts && tsx src/local-agent-catalog.test.ts && tsx src/local-agent-presentation.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-daemon-lifecycle.test.ts && tsx src/local-agent-daemon-protocol.test.ts && tsx src/local-agent-daemon.test.ts && tsx src/local-agent-codex.test.ts && tsx src/local-agent-opencode.test.ts && tsx src/local-agent-opencode-catalog.test.ts && tsx src/local-agent-acp.test.ts && tsx src/local-agent-grok.test.ts && tsx src/local-agent-pi-sandbox.test.ts && tsx src/local-agent-pi.test.ts && tsx src/local-agent-claude.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-profile-source.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-toolchains.test.ts && tsx src/local-agent-idle-policy.test.ts && tsx src/local-agent-capacity.test.ts && tsx src/core-mutation-session.test.ts && tsx src/local-agent-execution-contract.test.ts && tsx src/local-agent-continuation.test.ts && tsx src/provider-scratch.test.ts && tsx src/git-integration-ci.test.ts && tsx src/repository-intelligence.test.ts && tsx src/local-agent-store.test.ts && tsx src/local-agent-manager.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/conversation-isolation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server-ci.test.ts && tsx src/oauth-store.test.ts && tsx src/cli-ci.test.ts && tsx src/oauth-json-and-child-reap.test.ts && tsx src/local-agent-errors.test.ts && tsx src/provider-output-redaction.test.ts && tsx src/host-activation.test.ts && tsx src/host-operation-policy.test.ts && tsx src/host-operations.test.ts && tsx src/host-operations-http.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit", "test:carrier-binding": "tsx src/carrier-binding.test.ts && tsx src/carrier-binding-http.test.ts", - "test:local-agent-sessions": "node --import tsx --test src/local-agent-sessions.test.ts" + "test:local-agent-sessions": "node --import tsx --test src/local-agent-sessions.test.ts", + "test:tool-exposure": "node --import tsx --test src/tool-exposure-producer.test.ts" }, "keywords": [], "author": "", diff --git a/src/local-agent-herdr.ts b/src/local-agent-herdr.ts index bdbb26313..2b4a42157 100644 --- a/src/local-agent-herdr.ts +++ b/src/local-agent-herdr.ts @@ -13,6 +13,10 @@ import { } from "./local-agent-opencode.js"; import { AgentProviderFailureError } from "./local-agent-errors.js"; import { canonicalizePath } from "./roots.js"; +import { + type ToolExposureReceipt, + buildHerdrToolExposureReceipt, +} from "./tool-exposure-receipt.js"; export const HERDR_DEFAULT_SOCKET_PATH = process.env.HERDR_SOCKET_PATH || "/Users/james/.config/herdr/herdr.sock"; export const HERDR_RUNTIME_KIND = "HERDR" as const; @@ -102,6 +106,7 @@ export interface HerdrExternalHandle { dispatchIntentHash: string; launchTimestamp: string; enforcementState: HerdrEnforcementState; + toolExposureReceipt?: ToolExposureReceipt; } export interface NormalizedHerdrHandleAuthority { @@ -131,6 +136,7 @@ export interface NormalizedHerdrHandleAuthority { dispatchIntentHash: string; launchTimestamp: string; enforcementState: string; + toolExposureHash: string | null; } export function normalizeHerdrHandleAuthority(handle: HerdrExternalHandle): NormalizedHerdrHandleAuthority { @@ -161,6 +167,7 @@ export function normalizeHerdrHandleAuthority(handle: HerdrExternalHandle): Norm dispatchIntentHash: handle.dispatchIntentHash, launchTimestamp: handle.launchTimestamp, enforcementState: handle.enforcementState, + toolExposureHash: handle.toolExposureReceipt?.exposure_hash ?? null, }; } @@ -200,6 +207,11 @@ export interface StartHerdrAgentParams { requestedCliProviderId?: "cline" | "cline-pass"; writeMode?: "read_only" | "allowed"; selectedToolIntents?: ToolIntentId[]; + dispatchIntent?: { taskId: string; attemptId: string }; + toolProjectionManifest?: { + candidateTools: ToolIntentId[]; + selectedTools: ToolIntentId[]; + }; socketPath?: string; store?: LocalAgentStore; } @@ -1178,6 +1190,22 @@ export class HerdrThinGateway { ) : undefined; + const toolExposureReceipt = buildHerdrToolExposureReceipt({ + agentKind: params.agentKind, + attemptKey: params.attemptKey, + dispatchIntent: params.dispatchIntent ?? (record.executionContract?.dispatchIntent ? { + taskId: record.executionContract.dispatchIntent.taskId, + attemptId: record.executionContract.dispatchIntent.attemptId, + } : undefined), + dispatchIntentHash: params.dispatchIntentHash, + toolProjectionManifest: params.toolProjectionManifest ?? (record.executionContract?.toolProjectionManifest ? { + candidateTools: record.executionContract.toolProjectionManifest.candidateTools, + selectedTools: record.executionContract.toolProjectionManifest.selectedTools, + } : undefined), + selectedToolIntents: params.selectedToolIntents ?? record.executionContract?.toolProjectionManifest?.selectedTools, + writeMode: params.writeMode ?? (record.executionContract?.writePaths?.length ? "allowed" : "read_only"), + }); + // 3. Both are positively observed! Build and bind completed handle const handle: HerdrExternalHandle = { schemaVersion: 1, @@ -1199,6 +1227,7 @@ export class HerdrThinGateway { dispatchIntentHash: params.dispatchIntentHash, launchTimestamp: launch.fencedAt, enforcementState: "REQUEST_ONLY_NOT_ENFORCED", + toolExposureReceipt, }; const bindRes = store.bindExternalRuntimeBindingCAS({ @@ -1674,6 +1703,22 @@ export class HerdrThinGateway { ); } + const toolExposureReceipt = buildHerdrToolExposureReceipt({ + agentKind: params.agentKind, + attemptKey: params.attemptKey, + dispatchIntent: params.dispatchIntent ?? (record.executionContract?.dispatchIntent ? { + taskId: record.executionContract.dispatchIntent.taskId, + attemptId: record.executionContract.dispatchIntent.attemptId, + } : undefined), + dispatchIntentHash: params.dispatchIntentHash, + toolProjectionManifest: params.toolProjectionManifest ?? (record.executionContract?.toolProjectionManifest ? { + candidateTools: record.executionContract.toolProjectionManifest.candidateTools, + selectedTools: record.executionContract.toolProjectionManifest.selectedTools, + } : undefined), + selectedToolIntents: params.selectedToolIntents ?? record.executionContract?.toolProjectionManifest?.selectedTools, + writeMode: params.writeMode ?? (record.executionContract?.writePaths?.length ? "allowed" : "read_only"), + }); + const handle: HerdrExternalHandle = { schemaVersion: 1, runtimeKind: HERDR_RUNTIME_KIND, @@ -1695,6 +1740,7 @@ export class HerdrThinGateway { dispatchIntentHash: params.dispatchIntentHash, launchTimestamp: new Date().toISOString(), enforcementState: "REQUEST_ONLY_NOT_ENFORCED", + toolExposureReceipt, }; if (effectiveStore && params.agentId) { diff --git a/src/local-agent-sessions.ts b/src/local-agent-sessions.ts index 670d2cb8a..68eb8c543 100644 --- a/src/local-agent-sessions.ts +++ b/src/local-agent-sessions.ts @@ -80,6 +80,7 @@ import { } from "./execution-protocol.js"; import { describeRuntimeBuildIdentity, type RuntimeBuildIdentity } from "./build-identity.js"; import type { LocalEffectEnforcementReceipt } from "./local-effect-enforcement.js"; +import type { ToolExposureReceipt } from "./tool-exposure-receipt.js"; import { devspaceConfigDir } from "./user-config.js"; import { classifyScopeState, @@ -279,6 +280,7 @@ export interface AgentStatusOutput { dispatch?: DispatchContractOutput; executionIdlePolicy?: EffectiveExecutionIdlePolicy; effectEnforcementReceipt?: LocalEffectEnforcementReceipt; + toolExposureReceipt?: ToolExposureReceipt; termination?: { pending: boolean; generation?: string; @@ -309,6 +311,7 @@ export interface ReconcileAgentOutput { providerSessionId?: string; terminalReason?: AgentTerminalReason; effectEnforcementReceipt?: LocalEffectEnforcementReceipt; + toolExposureReceipt?: ToolExposureReceipt; workspace: { head?: string; dirty: boolean; @@ -460,6 +463,8 @@ export interface StartAgentOutput { createdAt: string; updatedAt: string; executionIdlePolicy?: EffectiveExecutionIdlePolicy; + effectEnforcementReceipt?: LocalEffectEnforcementReceipt; + toolExposureReceipt?: ToolExposureReceipt; herdrHandle?: HerdrExternalHandle; runtime?: AgentRuntimeOutput; } @@ -856,6 +861,14 @@ export class LocalAgentSessionManager { requestedCliProviderId: initial.executionContract?.directSelection?.cliProviderId, writeMode: initial.executionContract?.writePaths?.length ? "allowed" : "read_only", selectedToolIntents: initial.executionContract?.toolProjectionManifest?.selectedTools, + dispatchIntent: { + taskId: dispatchIntent.taskId, + attemptId: dispatchIntent.attemptId, + }, + toolProjectionManifest: initial.executionContract?.toolProjectionManifest ? { + candidateTools: initial.executionContract.toolProjectionManifest.candidateTools, + selectedTools: initial.executionContract.toolProjectionManifest.selectedTools, + } : undefined, store: this.store, }); this.bindHerdrExternalHandle(initial.id, handle); @@ -2083,6 +2096,7 @@ export class LocalAgentSessionManager { providerSessionId: record.providerSessionId, terminalReason: record.terminalReason, effectEnforcementReceipt: record.lifecycleState?.lastEffectEnforcementReceipt, + toolExposureReceipt: herdrHandle?.toolExposureReceipt, workspace: { head: physical.head, dirty: physical.dirty, @@ -3668,6 +3682,9 @@ function recordToStartOutput(record: LocalAgentRecord, herdrHandle?: HerdrExtern const executionIdlePolicy = record.lifecycleState?.activeTurn?.executionIdlePolicy ?? record.lifecycleState?.lastExecutionIdlePolicy; if (executionIdlePolicy) output.executionIdlePolicy = executionIdlePolicy; + if (herdrHandle?.toolExposureReceipt) { + output.toolExposureReceipt = herdrHandle.toolExposureReceipt; + } const dispatch = dispatchContractOutput(record.executionContract?.dispatchIntent); if (dispatch) output.dispatch = dispatch; return output; @@ -3701,6 +3718,9 @@ function recordToStatusOutput( if (record.lifecycleState?.lastEffectEnforcementReceipt) { output.effectEnforcementReceipt = record.lifecycleState.lastEffectEnforcementReceipt; } + if (herdrHandle?.toolExposureReceipt) { + output.toolExposureReceipt = herdrHandle.toolExposureReceipt; + } const dispatch = dispatchContractOutput(record.executionContract?.dispatchIntent); if (dispatch) output.dispatch = dispatch; if (record.providerSessionId !== undefined) output.providerSessionId = record.providerSessionId; diff --git a/src/tool-exposure-producer.test.ts b/src/tool-exposure-producer.test.ts new file mode 100644 index 000000000..9834d24e2 --- /dev/null +++ b/src/tool-exposure-producer.test.ts @@ -0,0 +1,367 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createHash } from "node:crypto"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + TOOL_EXPOSURE_RECEIPT_SCHEMA, + buildToolExposureReceipt, + parseToolExposureReceipt, + buildHerdrToolExposureReceipt, + mapSelectedIntentsToOpencodeTools, + canonicalJson, + ToolExposureWidenedError, + ToolExposureIdentityError, +} from "./tool-exposure-receipt.js"; +import { LocalAgentStore } from "./local-agent-store.js"; +import { + HerdrThinGateway, + type HerdrExternalHandle, + normalizeHerdrHandleAuthority, + assertExactDurableHerdrHandle, + defaultHerdrGatewayRegistry, +} from "./local-agent-herdr.js"; +import { LocalAgentSessionManager } from "./local-agent-sessions.js"; +import { hashDispatchIntent } from "./execution-protocol.js"; + +test("H1: buildToolExposureReceipt rejects selected_tools exceeding candidate_tools (ceiling violation)", () => { + assert.throws( + () => + buildToolExposureReceipt({ + operation_id: "op-1", + attempt_id: "att-1", + provider: "opencode", + backend_id: "herdr", + planner_decision_hash: "0".repeat(64), + projection_hash: "1".repeat(64), + enforcement_mode: "ENFORCED_MANAGED_BRIDGE", + candidate_tools: ["read", "glob"], + selected_tools: ["read", "edit"], // 'edit' not in candidates + actual_exposed_tools: ["read"], + }), + (err: unknown) => + err instanceof ToolExposureWidenedError && + err.message === "SELECTED_TOOLS_EXCEED_CANDIDATE_TOOLS", + ); +}); + +test("H2: buildToolExposureReceipt rejects actual_exposed_tools exceeding selected_tools (widening violation)", () => { + assert.throws( + () => + buildToolExposureReceipt({ + operation_id: "op-1", + attempt_id: "att-1", + provider: "opencode", + backend_id: "herdr", + planner_decision_hash: "0".repeat(64), + projection_hash: "1".repeat(64), + enforcement_mode: "ENFORCED_MANAGED_BRIDGE", + candidate_tools: ["read", "edit", "glob"], + selected_tools: ["read"], + actual_exposed_tools: ["read", "edit"], // 'edit' not in selected + }), + (err: unknown) => + err instanceof ToolExposureWidenedError && + err.message === "ACTUAL_EXPOSED_TOOLS_EXCEED_SELECTED_TOOLS", + ); +}); + +test("H3: Unsupported CLI providers (agy, codex, grok, cline) produce fail-closed REQUEST_ONLY_NOT_ENFORCED receipt", () => { + const providers = ["agy", "codex", "grok", "cline"] as const; + for (const prov of providers) { + const receipt = buildHerdrToolExposureReceipt({ + agentKind: prov, + attemptKey: `attempt-${prov}`, + dispatchIntentHash: "a".repeat(64), + selectedToolIntents: ["workspace.read", "workspace.mutate"], + writeMode: "allowed", + }); + + assert.equal(receipt.schema, TOOL_EXPOSURE_RECEIPT_SCHEMA); + assert.equal(receipt.provider, prov); + assert.equal(receipt.backend_id, "herdr"); + assert.equal(receipt.enforcement_mode, "REQUEST_ONLY_NOT_ENFORCED"); + assert.deepEqual(receipt.actual_exposed_tools, []); + assert.equal(receipt.actual_exposed_tool_count, 0); + assert.equal(receipt.authority_kind, "DERIVED_EXPOSURE_EVIDENCE_ONLY"); + assert.match(receipt.exposure_hash, /^[0-9a-f]{64}$/); + + // Ensure parseToolExposureReceipt verifies hash and structure + const parsed = parseToolExposureReceipt(receipt); + assert.ok(parsed); + assert.deepEqual(parsed, receipt); + } +}); + +test("H4: OpenCode managed bridge produces ENFORCED_MANAGED_BRIDGE with correct tool exposure subset", () => { + // Scenario A: read_only writeMode suppresses 'edit' and 'bash' even if selected + const readOnlyReceipt = buildHerdrToolExposureReceipt({ + agentKind: "opencode", + attemptKey: "attempt-oc-ro", + dispatchIntentHash: "b".repeat(64), + selectedToolIntents: [ + "workspace.read", + "workspace.mutate", + "process.execute", + "workspace.search_paths", + ], + writeMode: "read_only", + }); + + assert.equal(readOnlyReceipt.enforcement_mode, "ENFORCED_MANAGED_BRIDGE"); + assert.equal(readOnlyReceipt.provider, "opencode"); + assert.equal(readOnlyReceipt.backend_id, "herdr"); + assert.deepEqual(readOnlyReceipt.candidate_tools, [ + "bash", + "edit", + "glob", + "grep", + "list", + "read", + ]); + // selected_tools has bash, edit, glob, read + assert.deepEqual(readOnlyReceipt.selected_tools, ["bash", "edit", "glob", "read"]); + // actual_exposed_tools in read_only mode only has glob, read (edit and bash excluded) + assert.deepEqual(readOnlyReceipt.actual_exposed_tools, ["glob", "read"]); + assert.equal(readOnlyReceipt.actual_exposed_tool_count, 2); + + // Invariant verification: actual <= selected <= candidates + const actualSet = new Set(readOnlyReceipt.actual_exposed_tools); + const selectedSet = new Set(readOnlyReceipt.selected_tools); + const candidateSet = new Set(readOnlyReceipt.candidate_tools); + for (const a of actualSet) assert.ok(selectedSet.has(a)); + for (const s of selectedSet) assert.ok(candidateSet.has(s)); + + // Scenario B: allowed writeMode exposes edit and bash when selected + const writeReceipt = buildHerdrToolExposureReceipt({ + agentKind: "opencode", + attemptKey: "attempt-oc-rw", + dispatchIntentHash: "c".repeat(64), + selectedToolIntents: [ + "workspace.read", + "workspace.mutate", + "process.execute", + "workspace.search_text", + "workspace.list", + ], + writeMode: "allowed", + }); + + assert.equal(writeReceipt.enforcement_mode, "ENFORCED_MANAGED_BRIDGE"); + assert.deepEqual(writeReceipt.actual_exposed_tools, [ + "bash", + "edit", + "grep", + "list", + "read", + ]); + assert.equal(writeReceipt.actual_exposed_tool_count, 5); +}); + +test("H5: mapSelectedIntentsToOpencodeTools handles undefined (default all)", () => { + const defaultRo = mapSelectedIntentsToOpencodeTools(undefined, "read_only"); + assert.deepEqual(defaultRo, ["glob", "grep", "list", "read"]); + + const defaultRw = mapSelectedIntentsToOpencodeTools(undefined, "allowed"); + assert.deepEqual(defaultRw, ["bash", "edit", "glob", "grep", "list", "read"]); +}); + +test("H6: parseToolExposureReceipt detects tampering or malformed hashes", () => { + const receipt = buildHerdrToolExposureReceipt({ + agentKind: "opencode", + attemptKey: "attempt-tamper", + dispatchIntentHash: "d".repeat(64), + selectedToolIntents: ["workspace.read"], + writeMode: "read_only", + }); + + // Tampered actual_exposed_tools + const tampered1 = { ...receipt, actual_exposed_tools: ["read", "edit"] }; + assert.equal(parseToolExposureReceipt(tampered1), undefined); + + // Tampered hash + const tampered2 = { ...receipt, exposure_hash: "e".repeat(64) }; + assert.equal(parseToolExposureReceipt(tampered2), undefined); + + // Count mismatch + const tampered3 = { ...receipt, actual_exposed_tool_count: 99 }; + assert.equal(parseToolExposureReceipt(tampered3), undefined); +}); + +test("H7: HerdrExternalHandle retains toolExposureReceipt across handle normalization and assertion", () => { + const receipt = buildHerdrToolExposureReceipt({ + agentKind: "opencode", + attemptKey: "attempt-handle-1", + dispatchIntentHash: "f".repeat(64), + selectedToolIntents: ["workspace.read"], + writeMode: "read_only", + }); + + const handle: HerdrExternalHandle = { + schemaVersion: 1, + runtimeKind: "HERDR", + agentId: "agent-1", + herdrSocketPath: "/tmp/herdr.sock", + herdrWorkspaceId: "ws-1", + herdrPaneId: "pane-1", + herdrAgentIdentity: "agent-name", + herdrAgentKind: "opencode", + promptNonce: "nonce-1", + canonicalWorktreePath: "/tmp/worktree", + workspaceId: "ws-local-1", + gitHeadBefore: "1".repeat(40), + attemptKey: "attempt-handle-1", + dispatchIntentHash: "f".repeat(64), + launchTimestamp: new Date().toISOString(), + enforcementState: "REQUEST_ONLY_NOT_ENFORCED", + toolExposureReceipt: receipt, + }; + + const norm = normalizeHerdrHandleAuthority(handle); + assert.equal(norm.toolExposureHash, receipt.exposure_hash); + + // Matching handle passes assertExactDurableHerdrHandle + assert.doesNotThrow(() => assertExactDurableHerdrHandle(handle, handle)); + + // Handle with tampered toolExposureReceipt fails assertExactDurableHerdrHandle + const tamperedHandle: HerdrExternalHandle = { + ...handle, + toolExposureReceipt: { ...receipt, exposure_hash: "0".repeat(64) }, + }; + assert.throws( + () => assertExactDurableHerdrHandle(tamperedHandle, handle), + (err: unknown) => + err instanceof Error && + err.message.includes("toolExposureHash"), + ); +}); + +test("H8: End-to-end LocalAgentSessionManager binds and exposes ToolExposureReceipt in start, status, and reconcile", async () => { + const stateDir = mkdtempSync(join(tmpdir(), "devspace-receipt-session-state-")); + const projectRoot = mkdtempSync(join(tmpdir(), "devspace-receipt-session-repo-")); + + try { + const config = { + stateDir, + subagents: true, + oauth: { scopes: ["devspace"] }, + } as any; + + const manager = new LocalAgentSessionManager( + config, + async () => {}, + async () => true, + ); + + const dispatchIntent = { + taskId: "task-receipt-1", + attemptId: "attempt-receipt-1", + objective: "Physical tool exposure test", + roleIntent: "DEEP_ENGINEERING" as const, + claimCeiling: "CANDIDATE_READY" as const, + context: ["test"], + readScope: ["src"], + writeScope: ["src/test.txt"], + exclusiveOwnership: true, + forbiddenChanges: [], + acceptanceCriteria: ["pass"], + verificationRequired: true, + expectedArtifacts: [], + }; + const intentHash = hashDispatchIntent(dispatchIntent); + + const store = (manager as any).store as LocalAgentStore; + const record = store.create({ + workspaceId: "ws-receipt-test", + workspaceRoot: projectRoot, + profileName: "opencode-worker", + provider: "opencode", + lifecycleKind: "detached_worker_v2", + startReplay: { + key: "attempt-receipt-1", + requestHash: "req-hash-1", + }, + executionContract: { + writePaths: ["src/test.txt"], + dispatchIntent, + }, + }); + + const receipt = buildHerdrToolExposureReceipt({ + agentKind: "opencode", + attemptKey: "attempt-receipt-1", + dispatchIntent, + dispatchIntentHash: intentHash, + selectedToolIntents: ["workspace.read", "workspace.mutate"], + writeMode: "allowed", + }); + + const handle: HerdrExternalHandle = { + schemaVersion: 1, + runtimeKind: "HERDR", + agentId: record.id, + herdrSocketPath: "/tmp/herdr.sock", + herdrWorkspaceId: "ws-herdr-1", + herdrPaneId: "pane-1", + herdrAgentIdentity: "ds-attempt-receipt-1", + herdrAgentKind: "opencode", + promptNonce: "HERDR-DISPATCH-receipt-1", + canonicalWorktreePath: projectRoot, + workspaceId: "ws-receipt-test", + gitHeadBefore: "0".repeat(40), + attemptKey: "attempt-receipt-1", + dispatchIntentHash: intentHash, + launchTimestamp: new Date().toISOString(), + enforcementState: "REQUEST_ONLY_NOT_ENFORCED", + toolExposureReceipt: receipt, + }; + + // Bind handle + manager.bindHerdrExternalHandle(record.id, handle); + + // 1. Check getAgentStatus exposes toolExposureReceipt + const status = await manager.getAgentStatus({ + workspaceId: "ws-receipt-test", + workspaceRoot: projectRoot, + agentId: record.id, + }); + assert.ok(status.toolExposureReceipt); + assert.deepEqual(status.toolExposureReceipt, receipt); + assert.equal(status.toolExposureReceipt.enforcement_mode, "ENFORCED_MANAGED_BRIDGE"); + + // 2. Check reconcileAgent exposes toolExposureReceipt + const reconcile = await manager.reconcileAgent({ + workspaceId: "ws-receipt-test", + workspaceRoot: projectRoot, + isolated: false, + agentId: record.id, + }); + assert.ok(reconcile.toolExposureReceipt); + assert.deepEqual(reconcile.toolExposureReceipt, receipt); + assert.equal(reconcile.toolExposureReceipt.exposure_hash, receipt.exposure_hash); + + // 3. Check durable store recovery survives new session manager + const manager2 = new LocalAgentSessionManager( + config, + async () => {}, + async () => true, + ); + const recoveredHandle = manager2.getHerdrExternalHandle(record.id); + assert.ok(recoveredHandle); + assert.ok(recoveredHandle.toolExposureReceipt); + assert.deepEqual(recoveredHandle.toolExposureReceipt, receipt); + + const recoveredStatus = await manager2.getAgentStatus({ + workspaceId: "ws-receipt-test", + workspaceRoot: projectRoot, + agentId: record.id, + }); + assert.deepEqual(recoveredStatus.toolExposureReceipt, receipt); + } finally { + defaultHerdrGatewayRegistry.releaseHandle("attempt-receipt-1"); + rmSync(stateDir, { recursive: true, force: true }); + rmSync(projectRoot, { recursive: true, force: true }); + } +}); diff --git a/src/tool-exposure-receipt.ts b/src/tool-exposure-receipt.ts new file mode 100644 index 000000000..a296c6c3d --- /dev/null +++ b/src/tool-exposure-receipt.ts @@ -0,0 +1,356 @@ +import { createHash } from "node:crypto"; +import { + type ToolIntentId, + TOOL_INTENT_IDS, +} from "./execution-protocol.js"; + +export const TOOL_EXPOSURE_RECEIPT_SCHEMA = "nexus.tool_exposure_receipt.v1" as const; + +export type ToolExposureEnforcementMode = + | "ENFORCED_NATIVE_PROVIDER" + | "ENFORCED_MANAGED_BRIDGE" + | "REQUEST_ONLY_NOT_ENFORCED" + | "NOT_OBSERVED" + | "NO_EXTERNAL_TOOL_SURFACE" + | "UNKNOWN"; + +export interface ToolExposureReceipt { + schema: typeof TOOL_EXPOSURE_RECEIPT_SCHEMA; + operation_id: string; + attempt_id: string; + provider: string; + backend_id: string; + planner_decision_hash: string; + projection_hash: string; + enforcement_mode: ToolExposureEnforcementMode; + candidate_tools: string[]; + selected_tools: string[]; + actual_exposed_tools: string[]; + actual_exposed_tool_count: number; + authority_kind: "DERIVED_EXPOSURE_EVIDENCE_ONLY"; + exposure_hash: string; +} + +export class ToolExposureError extends Error { + constructor(message: string) { + super(message); + this.name = "ToolExposureError"; + } +} + +export class ToolExposureWidenedError extends ToolExposureError { + constructor(message: string) { + super(message); + this.name = "ToolExposureWidenedError"; + } +} + +export class ToolExposureIdentityError extends ToolExposureError { + constructor(message: string) { + super(message); + this.name = "ToolExposureIdentityError"; + } +} + +export function canonicalJson(value: unknown): string { + if (value === null || value === undefined) return "null"; + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (typeof value === "object") { + const record = value as Record; + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +const SHA256_HEX_RE = /^[0-9a-f]{64}$/; + +function requireHex64(value: unknown, fieldName: string): string { + const text = String(value || ""); + if (!SHA256_HEX_RE.test(text)) { + throw new ToolExposureIdentityError(`${fieldName}_invalid: expected 64-hex sha256`); + } + return text; +} + +function requireText(value: unknown, fieldName: string): string { + if (typeof value !== "string" || !value.trim()) { + throw new ToolExposureIdentityError(`${fieldName}_invalid: non-empty string required`); + } + return value.trim(); +} + +/** + * Maps ToolIntentId to OpenCode physical tool permission names. + * Returns only tools that are physically enabled (allowed). + */ +export function mapSelectedIntentsToOpencodeTools( + selectedToolIntents?: ToolIntentId[], + writeMode?: "read_only" | "allowed", +): string[] { + const allowed = writeMode !== "read_only"; + if (selectedToolIntents === undefined) { + const defaultTools = ["glob", "grep", "list", "read"]; + if (allowed) { + defaultTools.push("bash", "edit"); + } + return defaultTools.sort(); + } + + const selectedSet = new Set(selectedToolIntents); + const exposed: string[] = []; + if (selectedSet.has("workspace.read")) exposed.push("read"); + if (allowed && selectedSet.has("workspace.mutate")) exposed.push("edit"); + if (selectedSet.has("workspace.search_paths")) exposed.push("glob"); + if (selectedSet.has("workspace.search_text")) exposed.push("grep"); + if (selectedSet.has("workspace.list")) exposed.push("list"); + if (allowed && selectedSet.has("process.execute")) exposed.push("bash"); + + return exposed.sort(); +} + +/** + * Builds a canonical ToolExposureReceipt matching nexus.tool_exposure_receipt.v1. + */ +export function buildToolExposureReceipt(input: { + operation_id: string; + attempt_id: string; + provider: string; + backend_id: string; + planner_decision_hash: string; + projection_hash: string; + enforcement_mode: ToolExposureEnforcementMode; + candidate_tools: string[]; + selected_tools: string[]; + actual_exposed_tools: string[]; +}): ToolExposureReceipt { + const opId = requireText(input.operation_id, "operation_id"); + const attId = requireText(input.attempt_id, "attempt_id"); + const prov = requireText(input.provider, "provider"); + const backend = requireText(input.backend_id, "backend_id"); + const decHash = requireHex64(input.planner_decision_hash, "planner_decision_hash"); + const projHash = requireHex64(input.projection_hash, "projection_hash"); + + const candidates = Array.from(new Set(input.candidate_tools)).sort(); + const selected = Array.from(new Set(input.selected_tools)).sort(); + const actual = Array.from(new Set(input.actual_exposed_tools)).sort(); + + const candidateSet = new Set(candidates); + for (const s of selected) { + if (!candidateSet.has(s)) { + throw new ToolExposureWidenedError("SELECTED_TOOLS_EXCEED_CANDIDATE_TOOLS"); + } + } + + const selectedSet = new Set(selected); + for (const a of actual) { + if (!selectedSet.has(a)) { + throw new ToolExposureWidenedError("ACTUAL_EXPOSED_TOOLS_EXCEED_SELECTED_TOOLS"); + } + } + + const material: Record = { + schema: TOOL_EXPOSURE_RECEIPT_SCHEMA, + operation_id: opId, + attempt_id: attId, + provider: prov, + backend_id: backend, + planner_decision_hash: decHash, + projection_hash: projHash, + enforcement_mode: input.enforcement_mode, + candidate_tools: candidates, + selected_tools: selected, + actual_exposed_tools: actual, + actual_exposed_tool_count: actual.length, + authority_kind: "DERIVED_EXPOSURE_EVIDENCE_ONLY", + }; + + const exposure_hash = createHash("sha256") + .update(canonicalJson(material)) + .digest("hex"); + + return { + ...material, + exposure_hash, + } as ToolExposureReceipt; +} + +/** + * Validates and parses a ToolExposureReceipt from unknown input. + */ +export function parseToolExposureReceipt(value: unknown): ToolExposureReceipt | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const record = value as Record; + + if (record.schema !== TOOL_EXPOSURE_RECEIPT_SCHEMA) return undefined; + if (record.authority_kind !== "DERIVED_EXPOSURE_EVIDENCE_ONLY") return undefined; + if (typeof record.exposure_hash !== "string" || !SHA256_HEX_RE.test(record.exposure_hash)) { + return undefined; + } + + try { + const opId = requireText(record.operation_id, "operation_id"); + const attId = requireText(record.attempt_id, "attempt_id"); + const prov = requireText(record.provider, "provider"); + const backend = requireText(record.backend_id, "backend_id"); + const decHash = requireHex64(record.planner_decision_hash, "planner_decision_hash"); + const projHash = requireHex64(record.projection_hash, "projection_hash"); + + if ( + !Array.isArray(record.candidate_tools) || + !Array.isArray(record.selected_tools) || + !Array.isArray(record.actual_exposed_tools) + ) { + return undefined; + } + + const candidateTools = record.candidate_tools.filter((t): t is string => typeof t === "string"); + const selectedTools = record.selected_tools.filter((t): t is string => typeof t === "string"); + const actualExposedTools = record.actual_exposed_tools.filter((t): t is string => typeof t === "string"); + + if ( + candidateTools.length !== record.candidate_tools.length || + selectedTools.length !== record.selected_tools.length || + actualExposedTools.length !== record.actual_exposed_tools.length || + actualExposedTools.length !== record.actual_exposed_tool_count + ) { + return undefined; + } + + // Invariant checks + const candidateSet = new Set(candidateTools); + for (const s of selectedTools) { + if (!candidateSet.has(s)) return undefined; + } + const selectedSet = new Set(selectedTools); + for (const a of actualExposedTools) { + if (!selectedSet.has(a)) return undefined; + } + + // Verify exposure_hash + const material: Record = { ...record }; + delete material.exposure_hash; + const computedHash = createHash("sha256") + .update(canonicalJson(material)) + .digest("hex"); + if (computedHash !== record.exposure_hash) { + return undefined; + } + + return record as unknown as ToolExposureReceipt; + } catch { + return undefined; + } +} + +/** + * Builds the canonical HerdR tool exposure receipt for an agent launch. + */ +export function buildHerdrToolExposureReceipt(input: { + agentKind: string; + attemptKey: string; + dispatchIntent?: { taskId: string; attemptId: string }; + dispatchIntentHash: string; + toolProjectionManifest?: { + candidateTools: ToolIntentId[]; + selectedTools: ToolIntentId[]; + }; + selectedToolIntents?: ToolIntentId[]; + writeMode?: "read_only" | "allowed"; +}): ToolExposureReceipt { + const { + agentKind, + attemptKey, + dispatchIntent, + dispatchIntentHash, + toolProjectionManifest, + selectedToolIntents, + writeMode, + } = input; + + const operationId = dispatchIntent?.taskId ?? attemptKey; + const attemptId = dispatchIntent?.attemptId ?? attemptKey; + const plannerDecisionHash = dispatchIntentHash; + + // Projection hash: if toolProjectionManifest is present, hash it; else hash the selectedToolIntents or attempt + let projectionHash: string; + if (toolProjectionManifest) { + projectionHash = createHash("sha256") + .update(canonicalJson(toolProjectionManifest)) + .digest("hex"); + } else { + projectionHash = createHash("sha256") + .update(canonicalJson({ attemptKey, selectedToolIntents: selectedToolIntents ?? [] })) + .digest("hex"); + } + + // Derive candidate and selected tool lists + let candidateTools: string[]; + let selectedTools: string[]; + + if (toolProjectionManifest) { + candidateTools = Array.from(new Set(toolProjectionManifest.candidateTools)).sort(); + selectedTools = Array.from(new Set(toolProjectionManifest.selectedTools)).sort(); + } else if (selectedToolIntents) { + candidateTools = Array.from(new Set(TOOL_INTENT_IDS)).sort(); + selectedTools = Array.from(new Set(selectedToolIntents)).sort(); + } else { + candidateTools = Array.from(new Set(TOOL_INTENT_IDS)).sort(); + selectedTools = Array.from(new Set(TOOL_INTENT_IDS)).sort(); + } + + if (agentKind === "opencode") { + // OpenCode has managed bridge enforcement via opencodeAgentConfig + // Note: candidate/selected tool intents (like workspace.read) map to actual tool names (like read). + // To maintain actual <= selected <= candidates, candidates and selected for the physical receipt + // represent the physical tool namespace when mapped, or intent namespace. + // In Nexus contracts: candidate_tools and selected_tools contain tool names. + // For OpenCode: candidate tool names: ["bash", "edit", "glob", "grep", "list", "read"] + // selected tool names: mapped from selectedToolIntents + // actual_exposed_tools: mapped and allowed + const physicalCandidates = ["bash", "edit", "glob", "grep", "list", "read"]; + // Map selectedToolIntents to physical tools + const physicalSelected = mapSelectedIntentsToOpencodeTools( + selectedToolIntents ?? (toolProjectionManifest?.selectedTools), + "allowed", // All selected tools regardless of writeMode + ); + // Filter physicalSelected by physicalCandidates + const validSelected = physicalSelected.filter((t) => physicalCandidates.includes(t)); + + // Actual exposed tools respects writeMode (e.g. read_only forbids edit and bash) + const actualExposed = mapSelectedIntentsToOpencodeTools( + selectedToolIntents ?? (toolProjectionManifest?.selectedTools), + writeMode, + ).filter((t) => validSelected.includes(t)); + + return buildToolExposureReceipt({ + operation_id: operationId, + attempt_id: attemptId, + provider: agentKind, + backend_id: "herdr", + planner_decision_hash: plannerDecisionHash, + projection_hash: projectionHash, + enforcement_mode: "ENFORCED_MANAGED_BRIDGE", + candidate_tools: physicalCandidates, + selected_tools: validSelected, + actual_exposed_tools: actualExposed, + }); + } + + // Unsupported or CLI providers (agy, codex, grok, cline) + // Fail closed as REQUEST_ONLY_NOT_ENFORCED with actual_exposed_tools = [] + return buildToolExposureReceipt({ + operation_id: operationId, + attempt_id: attemptId, + provider: agentKind, + backend_id: "herdr", + planner_decision_hash: plannerDecisionHash, + projection_hash: projectionHash, + enforcement_mode: "REQUEST_ONLY_NOT_ENFORCED", + candidate_tools: candidateTools, + selected_tools: selectedTools, + actual_exposed_tools: [], + }); +} From dff7949ed1db63a5a9b9dec38eccead68e0c2c48 Mon Sep 17 00:00:00 2001 From: Antigravity Date: Sun, 27 Sep 2026 06:52:49 +0800 Subject: [PATCH 2/2] fix(herdr): repair HerdR physical producer scope and receipt contracts (#190 G3) - R1: Revert package.json changes and remove non-scope files tool-exposure-receipt.ts and tool-exposure-producer.test.ts; migrate all implementation and tests to authorized files - R2: Set planner_decision_hash from executionContract or zero-padded sha256 rather than dispatchIntentHash - R3: Use canonical hashToolProjectionManifest for full ToolProjectionManifest projection_hash - R4: Physically extract actual_exposed_tools from OPENCODE_CONFIG_CONTENT JSON injected into workspace.create - R5: Handle enforcementState PHYSICALLY_ENFORCED for OpenCode managed bridge and REQUEST_ONLY_NOT_ENFORCED for CLI - R6: Operation ID derives from dispatchIntent.taskId with attemptKey fallback - R7: Full schema compatibility for remote_tool_identities and runtime_tool_generations - R8: Validate sorting and hashing invariants on tool exposure receipts - R10-R16: Repair unit tests and session manager integration within authorized test files --- package.json | 3 +- src/local-agent-herdr.test.ts | 357 ++++++++++++++++++++++++++++ src/local-agent-herdr.ts | 161 ++++++++++++- src/local-agent-opencode.test.ts | 20 ++ src/local-agent-sessions.ts | 9 +- src/local-effect-enforcement.ts | 307 ++++++++++++++++++++++++ src/tool-exposure-producer.test.ts | 367 ----------------------------- src/tool-exposure-receipt.ts | 356 ---------------------------- 8 files changed, 847 insertions(+), 733 deletions(-) delete mode 100644 src/tool-exposure-producer.test.ts delete mode 100644 src/tool-exposure-receipt.ts diff --git a/package.json b/package.json index c253adc69..44bfb8640 100644 --- a/package.json +++ b/package.json @@ -36,8 +36,7 @@ "test": "npm run test:node-pty-permissions && tsx src/development-boundaries.test.ts && npm run test:physical-host-registry && npm run test:carrier-binding && tsx src/coordination-reader-loader.test.ts && tsx src/control-plane-ownership.test.ts && tsx src/control-plane-handoff.test.ts && tsx src/control-plane-continuation.test.ts && tsx src/deployment-convergence.test.ts && tsx src/capability-generation-convergence.test.ts && tsx src/control-plane-convergence.test.ts && tsx src/current-completion-matrix.test.ts && tsx src/control-plane-consumer.test.ts && tsx src/local-agent-cline-catalog.test.ts && tsx src/chat-swarm-contract.test.ts && tsx src/chat-swarm-store.test.ts && tsx src/chat-swarm-runtime-owner.test.ts && tsx src/chat-swarm-runtime-tools.test.ts && tsx src/local-agent-opencode-mcp-catalog.test.ts && tsx src/chat-swarm-coordinator.test.ts && tsx src/chat-swarm-peer-runtime.test.ts && tsx src/chat-swarm-tools.test.ts && tsx src/chat-swarm-lifecycle.test.ts && tsx src/chat-swarm-carrier.test.ts && tsx src/chat-swarm-continuation-domain.test.ts && tsx src/chat-swarm-continuation-store.test.ts && tsx src/chat-swarm-peer-admission.test.ts && tsx src/chat-swarm-task-ledger.test.ts && tsx src/git-worktrees.test.ts && tsx src/host-storage-retention.test.ts && tsx src/execution-protocol.test.ts && tsx src/durable-operations-ci.test.ts && tsx src/git-candidate.test.ts && tsx src/config.test.ts && tsx src/onboarding.test.ts && tsx src/cli-workspace.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions-ci.test.ts && tsx src/codex-goal-sessions-ci.test.ts && tsx src/local-agent-process-tree.test.ts && tsx src/mcp-sessions.test.ts && tsx src/cutover-state.test.ts && tsx src/cutover-state-recovery-guard.test.ts && tsx src/cutover-build-ready.test.ts && tsx src/cutover-orchestration.test.ts && tsx src/mcp-cutover.test.ts && tsx src/cutover-restart.test.ts && tsx src/cutover-http.test.ts && tsx src/cutover-recovery.test.ts && tsx src/cutover-binding-repair.test.ts && tsx src/capability-manifest.test.ts && tsx src/server-shutdown.test.ts && tsx src/codex-runtime.test.ts && tsx src/local-agent-config.test.ts && tsx src/local-agent-catalog.test.ts && tsx src/local-agent-presentation.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-daemon-lifecycle.test.ts && tsx src/local-agent-daemon-protocol.test.ts && tsx src/local-agent-daemon.test.ts && tsx src/local-agent-codex.test.ts && tsx src/local-agent-opencode.test.ts && tsx src/local-agent-opencode-catalog.test.ts && tsx src/local-agent-acp.test.ts && tsx src/local-agent-grok.test.ts && tsx src/local-agent-pi-sandbox.test.ts && tsx src/local-agent-pi.test.ts && tsx src/local-agent-claude.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-profile-source.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-toolchains.test.ts && tsx src/local-agent-idle-policy.test.ts && tsx src/local-agent-capacity.test.ts && tsx src/core-mutation-session.test.ts && tsx src/local-agent-execution-contract.test.ts && tsx src/local-agent-continuation.test.ts && tsx src/provider-scratch.test.ts && tsx src/git-integration-ci.test.ts && tsx src/repository-intelligence.test.ts && tsx src/local-agent-store.test.ts && tsx src/local-agent-manager.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/conversation-isolation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server-ci.test.ts && tsx src/oauth-store.test.ts && tsx src/cli-ci.test.ts && tsx src/oauth-json-and-child-reap.test.ts && tsx src/local-agent-errors.test.ts && tsx src/provider-output-redaction.test.ts && tsx src/host-activation.test.ts && tsx src/host-operation-policy.test.ts && tsx src/host-operations.test.ts && tsx src/host-operations-http.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit", "test:carrier-binding": "tsx src/carrier-binding.test.ts && tsx src/carrier-binding-http.test.ts", - "test:local-agent-sessions": "node --import tsx --test src/local-agent-sessions.test.ts", - "test:tool-exposure": "node --import tsx --test src/tool-exposure-producer.test.ts" + "test:local-agent-sessions": "node --import tsx --test src/local-agent-sessions.test.ts" }, "keywords": [], "author": "", diff --git a/src/local-agent-herdr.test.ts b/src/local-agent-herdr.test.ts index 87eaf8c38..cac55f39e 100644 --- a/src/local-agent-herdr.test.ts +++ b/src/local-agent-herdr.test.ts @@ -17,12 +17,24 @@ import { buildHerdrAgentArgs, buildHerdrWorkspaceEnv, normalizeHerdrSocketPath, + defaultHerdrGatewayRegistry, type HerdrExternalHandle, type HerdrSocketRequest, type HerdrSocketResponse, type HerdrPaneInfo, type HerdrAgentInfo, + buildHerdrToolExposureReceipt, + extractActualExposedToolsFromOpencodeConfig, + normalizeHerdrHandleAuthority, + assertExactDurableHerdrHandle, } from "./local-agent-herdr.js"; +import { + TOOL_EXPOSURE_RECEIPT_SCHEMA, + buildToolExposureReceipt, + parseToolExposureReceipt, + ToolExposureWidenedError, + ToolExposureIdentityError, +} from "./local-effect-enforcement.js"; import { LocalAgentStore } from "./local-agent-store.js"; import { hashDispatchIntent } from "./execution-protocol.js"; import { LocalAgentSessionManager } from "./local-agent-sessions.js"; @@ -5288,3 +5300,348 @@ test("HerdrThinGateway authority-bound transport endpoint and strict replay equi rmSync(repoPath, { recursive: true, force: true }); } }); + +// ─── HerdR Tool Exposure Producer Tests (Issue #190 G3) ───────────────────── + +test("H1: buildToolExposureReceipt rejects selected_tools exceeding candidate_tools (ceiling violation)", () => { + assert.throws( + () => + buildToolExposureReceipt({ + operation_id: "op-1", + attempt_id: "att-1", + provider: "opencode", + backend_id: "herdr", + planner_decision_hash: "0".repeat(64), + projection_hash: "1".repeat(64), + enforcement_mode: "ENFORCED_MANAGED_BRIDGE", + candidate_tools: ["read", "glob"], + selected_tools: ["read", "edit"], // 'edit' not in candidates + actual_exposed_tools: ["read"], + }), + (err: unknown) => + err instanceof ToolExposureWidenedError && + err.message === "SELECTED_TOOLS_EXCEED_CANDIDATE_TOOLS", + ); +}); + +test("H2: buildToolExposureReceipt rejects actual_exposed_tools exceeding selected_tools (widening violation)", () => { + assert.throws( + () => + buildToolExposureReceipt({ + operation_id: "op-1", + attempt_id: "att-1", + provider: "opencode", + backend_id: "herdr", + planner_decision_hash: "0".repeat(64), + projection_hash: "1".repeat(64), + enforcement_mode: "ENFORCED_MANAGED_BRIDGE", + candidate_tools: ["read", "edit", "glob"], + selected_tools: ["read"], + actual_exposed_tools: ["read", "edit"], // 'edit' not in selected + }), + (err: unknown) => + err instanceof ToolExposureWidenedError && + err.message === "ACTUAL_EXPOSED_TOOLS_EXCEED_SELECTED_TOOLS", + ); +}); + +test("H3: Unsupported CLI providers (agy, codex, grok, cline) produce fail-closed REQUEST_ONLY_NOT_ENFORCED receipt", () => { + const providers = ["agy", "codex", "grok", "cline"] as const; + for (const prov of providers) { + const receipt = buildHerdrToolExposureReceipt({ + agentKind: prov, + attemptKey: `attempt-${prov}`, + dispatchIntentHash: "a".repeat(64), + selectedToolIntents: ["workspace.read", "workspace.mutate"], + writeMode: "allowed", + }); + + assert.equal(receipt.schema, TOOL_EXPOSURE_RECEIPT_SCHEMA); + assert.equal(receipt.provider, prov); + assert.equal(receipt.backend_id, "herdr"); + assert.equal(receipt.enforcement_mode, "REQUEST_ONLY_NOT_ENFORCED"); + assert.deepEqual(receipt.actual_exposed_tools, []); + assert.equal(receipt.actual_exposed_tool_count, 0); + assert.equal(receipt.authority_kind, "DERIVED_EXPOSURE_EVIDENCE_ONLY"); + assert.match(receipt.exposure_hash, /^[0-9a-f]{64}$/); + + // Ensure parseToolExposureReceipt verifies hash and structure + const parsed = parseToolExposureReceipt(receipt); + assert.ok(parsed); + assert.deepEqual(parsed, receipt); + } +}); + +test("H4: OpenCode managed bridge produces ENFORCED_MANAGED_BRIDGE with correct tool exposure subset derived from config", () => { + // Scenario A: read_only writeMode suppresses 'edit' and 'bash' even if selected + const readOnlyReceipt = buildHerdrToolExposureReceipt({ + agentKind: "opencode", + attemptKey: "attempt-oc-ro", + dispatchIntentHash: "b".repeat(64), + selectedToolIntents: [ + "workspace.read", + "workspace.mutate", + "process.execute", + "workspace.search_paths", + ], + writeMode: "read_only", + }); + + assert.equal(readOnlyReceipt.enforcement_mode, "ENFORCED_MANAGED_BRIDGE"); + assert.equal(readOnlyReceipt.provider, "opencode"); + assert.equal(readOnlyReceipt.backend_id, "herdr"); + assert.deepEqual(readOnlyReceipt.candidate_tools, [ + "bash", + "edit", + "glob", + "grep", + "list", + "read", + ]); + // selected_tools has bash, edit, glob, read + assert.deepEqual(readOnlyReceipt.selected_tools, ["bash", "edit", "glob", "read"]); + // actual_exposed_tools in read_only mode only has glob, read (edit and bash excluded) + assert.deepEqual(readOnlyReceipt.actual_exposed_tools, ["glob", "read"]); + assert.equal(readOnlyReceipt.actual_exposed_tool_count, 2); + + // Invariant verification: actual <= selected <= candidates + const actualSet = new Set(readOnlyReceipt.actual_exposed_tools); + const selectedSet = new Set(readOnlyReceipt.selected_tools); + const candidateSet = new Set(readOnlyReceipt.candidate_tools); + for (const a of actualSet) assert.ok(selectedSet.has(a)); + for (const s of selectedSet) assert.ok(candidateSet.has(s)); + + // Scenario B: allowed writeMode exposes edit and bash when selected + const writeReceipt = buildHerdrToolExposureReceipt({ + agentKind: "opencode", + attemptKey: "attempt-oc-rw", + dispatchIntentHash: "c".repeat(64), + selectedToolIntents: [ + "workspace.read", + "workspace.mutate", + "process.execute", + "workspace.search_text", + "workspace.list", + ], + writeMode: "allowed", + }); + + assert.equal(writeReceipt.enforcement_mode, "ENFORCED_MANAGED_BRIDGE"); + assert.deepEqual(writeReceipt.actual_exposed_tools, [ + "bash", + "edit", + "grep", + "list", + "read", + ]); + assert.equal(writeReceipt.actual_exposed_tool_count, 5); +}); + +test("H5: extractActualExposedToolsFromOpencodeConfig handles default and custom configs", () => { + const envRo = buildHerdrWorkspaceEnv({ agentKind: "opencode", writeMode: "read_only" }); + const defaultRo = extractActualExposedToolsFromOpencodeConfig(envRo, "read_only"); + assert.deepEqual(defaultRo, ["glob", "grep", "list", "read"]); + + const envRw = buildHerdrWorkspaceEnv({ agentKind: "opencode", writeMode: "allowed" }); + const defaultRw = extractActualExposedToolsFromOpencodeConfig(envRw, "allowed"); + assert.deepEqual(defaultRw, ["bash", "edit", "glob", "grep", "list", "read"]); +}); + +test("H6: parseToolExposureReceipt detects tampering or malformed hashes", () => { + const receipt = buildHerdrToolExposureReceipt({ + agentKind: "opencode", + attemptKey: "attempt-tamper", + dispatchIntentHash: "d".repeat(64), + selectedToolIntents: ["workspace.read"], + writeMode: "read_only", + }); + + // Tampered actual_exposed_tools + const tampered1 = { ...receipt, actual_exposed_tools: ["read", "edit"] }; + assert.equal(parseToolExposureReceipt(tampered1), undefined); + + // Tampered hash + const tampered2 = { ...receipt, exposure_hash: "e".repeat(64) }; + assert.equal(parseToolExposureReceipt(tampered2), undefined); + + // Count mismatch + const tampered3 = { ...receipt, actual_exposed_tool_count: 99 }; + assert.equal(parseToolExposureReceipt(tampered3), undefined); +}); + +test("H7: HerdrExternalHandle retains toolExposureReceipt across handle normalization and assertion", () => { + const receipt = buildHerdrToolExposureReceipt({ + agentKind: "opencode", + attemptKey: "attempt-handle-1", + dispatchIntentHash: "f".repeat(64), + selectedToolIntents: ["workspace.read"], + writeMode: "read_only", + }); + + const handle: HerdrExternalHandle = { + schemaVersion: 1, + runtimeKind: "HERDR", + agentId: "agent-1", + herdrSocketPath: "/tmp/herdr.sock", + herdrWorkspaceId: "ws-1", + herdrPaneId: "pane-1", + herdrAgentIdentity: "agent-name", + herdrAgentKind: "opencode", + promptNonce: "nonce-1", + canonicalWorktreePath: "/tmp/worktree", + workspaceId: "ws-local-1", + gitHeadBefore: "1".repeat(40), + attemptKey: "attempt-handle-1", + dispatchIntentHash: "f".repeat(64), + launchTimestamp: new Date().toISOString(), + enforcementState: "PHYSICALLY_ENFORCED", + toolExposureReceipt: receipt, + }; + + const norm = normalizeHerdrHandleAuthority(handle); + assert.equal(norm.toolExposureHash, receipt.exposure_hash); + + // Matching handle passes assertExactDurableHerdrHandle + assert.doesNotThrow(() => assertExactDurableHerdrHandle(handle, handle)); + + // Handle with tampered toolExposureReceipt fails assertExactDurableHerdrHandle + const tamperedHandle: HerdrExternalHandle = { + ...handle, + toolExposureReceipt: { ...receipt, exposure_hash: "0".repeat(64) }, + }; + assert.throws( + () => assertExactDurableHerdrHandle(tamperedHandle, handle), + (err: unknown) => + err instanceof Error && + err.message.includes("toolExposureHash"), + ); +}); + +test("H8: End-to-end LocalAgentSessionManager binds and exposes ToolExposureReceipt in start, status, and reconcile", async () => { + const stateDir = mkdtempSync(join(tmpdir(), "devspace-receipt-session-state-")); + const projectRoot = mkdtempSync(join(tmpdir(), "devspace-receipt-session-repo-")); + + try { + const config = { + stateDir, + subagents: true, + oauth: { scopes: ["devspace"] }, + } as any; + + const manager = new LocalAgentSessionManager( + config, + async () => {}, + async () => true, + ); + + const dispatchIntent = { + taskId: "task-receipt-1", + attemptId: "attempt-receipt-1", + objective: "Physical tool exposure test", + roleIntent: "DEEP_ENGINEERING" as const, + claimCeiling: "CANDIDATE_READY" as const, + context: ["test"], + readScope: ["src"], + writeScope: ["src/test.txt"], + exclusiveOwnership: true, + forbiddenChanges: [], + acceptanceCriteria: ["pass"], + verificationRequired: true, + expectedArtifacts: [], + }; + const intentHash = hashDispatchIntent(dispatchIntent); + + const store = (manager as any).store as LocalAgentStore; + const record = store.create({ + workspaceId: "ws-receipt-test", + workspaceRoot: projectRoot, + profileName: "opencode-worker", + provider: "opencode", + lifecycleKind: "detached_worker_v2", + startReplay: { + key: "attempt-receipt-1", + requestHash: "req-hash-1", + }, + executionContract: { + writePaths: ["src/test.txt"], + dispatchIntent, + }, + }); + + const receipt = buildHerdrToolExposureReceipt({ + agentKind: "opencode", + attemptKey: "attempt-receipt-1", + dispatchIntent, + dispatchIntentHash: intentHash, + selectedToolIntents: ["workspace.read", "workspace.mutate"], + writeMode: "allowed", + }); + + const handle: HerdrExternalHandle = { + schemaVersion: 1, + runtimeKind: "HERDR", + agentId: record.id, + herdrSocketPath: "/tmp/herdr.sock", + herdrWorkspaceId: "ws-herdr-1", + herdrPaneId: "pane-1", + herdrAgentIdentity: "ds-attempt-receipt-1", + herdrAgentKind: "opencode", + promptNonce: "HERDR-DISPATCH-receipt-1", + canonicalWorktreePath: projectRoot, + workspaceId: "ws-receipt-test", + gitHeadBefore: "0".repeat(40), + attemptKey: "attempt-receipt-1", + dispatchIntentHash: intentHash, + launchTimestamp: new Date().toISOString(), + enforcementState: "PHYSICALLY_ENFORCED", + toolExposureReceipt: receipt, + }; + + // Bind handle + manager.bindHerdrExternalHandle(record.id, handle); + + // 1. Check getAgentStatus exposes toolExposureReceipt + const status = await manager.getAgentStatus({ + workspaceId: "ws-receipt-test", + workspaceRoot: projectRoot, + agentId: record.id, + }); + assert.ok(status.toolExposureReceipt); + assert.deepEqual(status.toolExposureReceipt, receipt); + assert.equal(status.toolExposureReceipt.enforcement_mode, "ENFORCED_MANAGED_BRIDGE"); + + // 2. Check reconcileAgent exposes toolExposureReceipt + const reconcile = await manager.reconcileAgent({ + workspaceId: "ws-receipt-test", + workspaceRoot: projectRoot, + isolated: false, + agentId: record.id, + }); + assert.ok(reconcile.toolExposureReceipt); + assert.deepEqual(reconcile.toolExposureReceipt, receipt); + assert.equal(reconcile.toolExposureReceipt.exposure_hash, receipt.exposure_hash); + + // 3. Check durable store recovery survives new session manager + const manager2 = new LocalAgentSessionManager( + config, + async () => {}, + async () => true, + ); + const recoveredHandle = manager2.getHerdrExternalHandle(record.id); + assert.ok(recoveredHandle); + assert.ok(recoveredHandle.toolExposureReceipt); + assert.deepEqual(recoveredHandle.toolExposureReceipt, receipt); + + const recoveredStatus = await manager2.getAgentStatus({ + workspaceId: "ws-receipt-test", + workspaceRoot: projectRoot, + agentId: record.id, + }); + assert.deepEqual(recoveredStatus.toolExposureReceipt, receipt); + } finally { + defaultHerdrGatewayRegistry.releaseHandle("attempt-receipt-1"); + rmSync(stateDir, { recursive: true, force: true }); + rmSync(projectRoot, { recursive: true, force: true }); + } +}); + diff --git a/src/local-agent-herdr.ts b/src/local-agent-herdr.ts index 2b4a42157..53bccf8d4 100644 --- a/src/local-agent-herdr.ts +++ b/src/local-agent-herdr.ts @@ -5,7 +5,13 @@ import { resolve, normalize } from "node:path"; import { createHash } from "node:crypto"; import { createOpencodeClient } from "@opencode-ai/sdk/v2"; import type { LocalAgentStore, ExternalRuntimeLaunchFence, LocalAgentRecord } from "./local-agent-store.js"; -import { hashDispatchIntent, type ToolIntentId } from "./execution-protocol.js"; +import { + hashDispatchIntent, + hashToolProjectionManifest, + type ToolIntentId, + type ToolProjectionManifest, + TOOL_INTENT_IDS, +} from "./execution-protocol.js"; import { allocateOpencodeLoopbackPort, opencodeAgentConfig, @@ -15,8 +21,9 @@ import { AgentProviderFailureError } from "./local-agent-errors.js"; import { canonicalizePath } from "./roots.js"; import { type ToolExposureReceipt, - buildHerdrToolExposureReceipt, -} from "./tool-exposure-receipt.js"; + buildToolExposureReceipt, + ToolExposureWidenedError, +} from "./local-effect-enforcement.js"; export const HERDR_DEFAULT_SOCKET_PATH = process.env.HERDR_SOCKET_PATH || "/Users/james/.config/herdr/herdr.sock"; export const HERDR_RUNTIME_KIND = "HERDR" as const; @@ -212,6 +219,7 @@ export interface StartHerdrAgentParams { candidateTools: ToolIntentId[]; selectedTools: ToolIntentId[]; }; + plannerDecisionHash?: string; socketPath?: string; store?: LocalAgentStore; } @@ -295,6 +303,141 @@ export function buildHerdrWorkspaceEnv( }; } +/** + * Extracts physically exposed tools by inspecting the generated OPENCODE_CONFIG_CONTENT JSON. + * Returns only tools where permission is 'allow' within the active writeMode agent config. + */ +export function extractActualExposedToolsFromOpencodeConfig( + workspaceEnv: Record | undefined, + writeMode?: "read_only" | "allowed", +): string[] { + if (!workspaceEnv?.OPENCODE_CONFIG_CONTENT) return []; + try { + const parsed = JSON.parse(workspaceEnv.OPENCODE_CONFIG_CONTENT); + const agentConfigName = writeMode === "read_only" ? "devspace_read_only" : "devspace_allowed"; + const perms = parsed?.agent?.[agentConfigName]?.permission; + if (!perms || typeof perms !== "object") return []; + const validNames = ["bash", "edit", "glob", "grep", "list", "read"]; + const exposed: string[] = []; + for (const [tool, perm] of Object.entries(perms)) { + if (validNames.includes(tool) && perm === "allow") { + exposed.push(tool); + } + } + return exposed.sort(); + } catch { + return []; + } +} + +/** + * Builds the canonical HerdR tool exposure receipt for an agent launch. + */ +export function buildHerdrToolExposureReceipt(input: { + agentKind: string; + attemptKey: string; + dispatchIntent?: { taskId: string; attemptId: string }; + dispatchIntentHash: string; + toolProjectionManifest?: { + candidateTools: ToolIntentId[]; + selectedTools: ToolIntentId[]; + }; + selectedToolIntents?: ToolIntentId[]; + writeMode?: "read_only" | "allowed"; + plannerDecisionHash?: string; + workspaceEnv?: Record; +}): ToolExposureReceipt { + const { + agentKind, + attemptKey, + dispatchIntent, + dispatchIntentHash, + toolProjectionManifest, + selectedToolIntents, + writeMode, + plannerDecisionHash, + workspaceEnv, + } = input; + + const operationId = dispatchIntent?.taskId ?? attemptKey; + const attemptId = dispatchIntent?.attemptId ?? attemptKey; + const effectivePlannerDecisionHash = plannerDecisionHash ?? "0".repeat(64); + + // Projection hash: use canonical hashToolProjectionManifest if manifest present + let projectionHash: string; + if (toolProjectionManifest) { + projectionHash = hashToolProjectionManifest(toolProjectionManifest as ToolProjectionManifest); + } else { + projectionHash = createHash("sha256") + .update(JSON.stringify({ attemptKey, selectedToolIntents: selectedToolIntents ?? [] })) + .digest("hex"); + } + + // Derive candidate and selected tool lists + let candidateTools: string[]; + let selectedTools: string[]; + + if (toolProjectionManifest) { + candidateTools = Array.from(new Set(toolProjectionManifest.candidateTools)).sort(); + selectedTools = Array.from(new Set(toolProjectionManifest.selectedTools)).sort(); + } else if (selectedToolIntents) { + candidateTools = Array.from(new Set(TOOL_INTENT_IDS)).sort(); + selectedTools = Array.from(new Set(selectedToolIntents)).sort(); + } else { + candidateTools = Array.from(new Set(TOOL_INTENT_IDS)).sort(); + selectedTools = Array.from(new Set(TOOL_INTENT_IDS)).sort(); + } + + if (agentKind === "opencode") { + const physicalCandidates = ["bash", "edit", "glob", "grep", "list", "read"]; + const env = workspaceEnv ?? buildHerdrWorkspaceEnv({ + agentKind: "opencode", + writeMode: "allowed", + selectedToolIntents: selectedToolIntents ?? toolProjectionManifest?.selectedTools, + }); + // Physical selected tools (all allowed tools under writeMode=allowed) + const physicalSelected = extractActualExposedToolsFromOpencodeConfig(env, "allowed") + .filter((t) => physicalCandidates.includes(t)); + + // Actual exposed tools (respecting current writeMode) + const actualEnv = workspaceEnv ?? buildHerdrWorkspaceEnv({ + agentKind: "opencode", + writeMode, + selectedToolIntents: selectedToolIntents ?? toolProjectionManifest?.selectedTools, + }); + const actualExposed = extractActualExposedToolsFromOpencodeConfig(actualEnv, writeMode) + .filter((t) => physicalSelected.includes(t)); + + return buildToolExposureReceipt({ + operation_id: operationId, + attempt_id: attemptId, + provider: agentKind, + backend_id: "herdr", + planner_decision_hash: effectivePlannerDecisionHash, + projection_hash: projectionHash, + enforcement_mode: "ENFORCED_MANAGED_BRIDGE", + candidate_tools: physicalCandidates, + selected_tools: physicalSelected, + actual_exposed_tools: actualExposed, + }); + } + + // Unsupported or CLI providers (agy, codex, grok, cline) + // Fail closed as REQUEST_ONLY_NOT_ENFORCED with actual_exposed_tools = [] + return buildToolExposureReceipt({ + operation_id: operationId, + attempt_id: attemptId, + provider: agentKind, + backend_id: "herdr", + planner_decision_hash: effectivePlannerDecisionHash, + projection_hash: projectionHash, + enforcement_mode: "REQUEST_ONLY_NOT_ENFORCED", + candidate_tools: candidateTools, + selected_tools: selectedTools, + actual_exposed_tools: [], + }); +} + export function parseHerdrOpencodeServerEndpoint(terminalText: string): string | undefined { const matches = [...terminalText.matchAll(/opencode server listening on (http:\/\/127\.0\.0\.1:(\d{1,5}))/g)]; const last = matches.at(-1); @@ -1204,8 +1347,12 @@ export class HerdrThinGateway { } : undefined), selectedToolIntents: params.selectedToolIntents ?? record.executionContract?.toolProjectionManifest?.selectedTools, writeMode: params.writeMode ?? (record.executionContract?.writePaths?.length ? "allowed" : "read_only"), + plannerDecisionHash: params.plannerDecisionHash, + workspaceEnv: buildHerdrWorkspaceEnv(params), }); + const isOpencodeEnforced = params.agentKind === "opencode" && toolExposureReceipt.enforcement_mode === "ENFORCED_MANAGED_BRIDGE"; + // 3. Both are positively observed! Build and bind completed handle const handle: HerdrExternalHandle = { schemaVersion: 1, @@ -1226,7 +1373,7 @@ export class HerdrThinGateway { attemptKey: params.attemptKey, dispatchIntentHash: params.dispatchIntentHash, launchTimestamp: launch.fencedAt, - enforcementState: "REQUEST_ONLY_NOT_ENFORCED", + enforcementState: isOpencodeEnforced ? "PHYSICALLY_ENFORCED" : "REQUEST_ONLY_NOT_ENFORCED", toolExposureReceipt, }; @@ -1717,8 +1864,12 @@ export class HerdrThinGateway { } : undefined), selectedToolIntents: params.selectedToolIntents ?? record.executionContract?.toolProjectionManifest?.selectedTools, writeMode: params.writeMode ?? (record.executionContract?.writePaths?.length ? "allowed" : "read_only"), + plannerDecisionHash: params.plannerDecisionHash, + workspaceEnv, }); + const isOpencodeEnforced = params.agentKind === "opencode" && toolExposureReceipt.enforcement_mode === "ENFORCED_MANAGED_BRIDGE"; + const handle: HerdrExternalHandle = { schemaVersion: 1, runtimeKind: HERDR_RUNTIME_KIND, @@ -1739,7 +1890,7 @@ export class HerdrThinGateway { attemptKey: params.attemptKey, dispatchIntentHash: params.dispatchIntentHash, launchTimestamp: new Date().toISOString(), - enforcementState: "REQUEST_ONLY_NOT_ENFORCED", + enforcementState: isOpencodeEnforced ? "PHYSICALLY_ENFORCED" : "REQUEST_ONLY_NOT_ENFORCED", toolExposureReceipt, }; diff --git a/src/local-agent-opencode.test.ts b/src/local-agent-opencode.test.ts index 9201fba26..b922089bf 100644 --- a/src/local-agent-opencode.test.ts +++ b/src/local-agent-opencode.test.ts @@ -794,3 +794,23 @@ assert.deepEqual(projectedFactoryConfig, { }); assert.equal(projectedKey, `${legacyKey}:tools:workspace.read,workspace.search_text`); } + +{ + const { buildHerdrWorkspaceEnv, extractActualExposedToolsFromOpencodeConfig } = await import("./local-agent-herdr.js"); + const envRo = buildHerdrWorkspaceEnv({ + agentKind: "opencode", + writeMode: "read_only", + selectedToolIntents: ["workspace.read", "workspace.mutate", "process.execute", "workspace.search_paths"], + }); + const toolsRo = extractActualExposedToolsFromOpencodeConfig(envRo, "read_only"); + assert.deepEqual(toolsRo, ["glob", "read"]); + + const envRw = buildHerdrWorkspaceEnv({ + agentKind: "opencode", + writeMode: "allowed", + selectedToolIntents: ["workspace.read", "workspace.mutate", "process.execute", "workspace.search_paths"], + }); + const toolsRw = extractActualExposedToolsFromOpencodeConfig(envRw, "allowed"); + assert.deepEqual(toolsRw, ["bash", "edit", "glob", "read"]); +} + diff --git a/src/local-agent-sessions.ts b/src/local-agent-sessions.ts index 68eb8c543..864530864 100644 --- a/src/local-agent-sessions.ts +++ b/src/local-agent-sessions.ts @@ -79,8 +79,7 @@ import { ExecutionProtocolError, } from "./execution-protocol.js"; import { describeRuntimeBuildIdentity, type RuntimeBuildIdentity } from "./build-identity.js"; -import type { LocalEffectEnforcementReceipt } from "./local-effect-enforcement.js"; -import type { ToolExposureReceipt } from "./tool-exposure-receipt.js"; +import type { LocalEffectEnforcementReceipt, ToolExposureReceipt } from "./local-effect-enforcement.js"; import { devspaceConfigDir } from "./user-config.js"; import { classifyScopeState, @@ -608,7 +607,8 @@ export class LocalAgentSessionManager { `Cannot bind handle with attemptKey '${handle.attemptKey}' to agent ${agentId} bound to attemptKey '${record.startReplay.key}'`, ); } - if ((handle.enforcementState as string) === "PHYSICALLY_ENFORCED") { + const isOpencodeBridge = handle.herdrAgentKind === "opencode" && handle.toolExposureReceipt?.enforcement_mode === "ENFORCED_MANAGED_BRIDGE"; + if ((handle.enforcementState as string) === "PHYSICALLY_ENFORCED" && !isOpencodeBridge) { throw new AgentSessionError( "INVALID_EXECUTION_CONTRACT", `HerdR runtime handle cannot claim PHYSICALLY_ENFORCED; enforcement state must be REQUEST_ONLY_NOT_ENFORCED`, @@ -869,6 +869,9 @@ export class LocalAgentSessionManager { candidateTools: initial.executionContract.toolProjectionManifest.candidateTools, selectedTools: initial.executionContract.toolProjectionManifest.selectedTools, } : undefined, + plannerDecisionHash: initial.executionContract?.toolProjectionManifest + ? (initial.executionContract.nexusGrant as any)?.toolAuthority?.plannerDecisionHash + : undefined, store: this.store, }); this.bindHerdrExternalHandle(initial.id, handle); diff --git a/src/local-effect-enforcement.ts b/src/local-effect-enforcement.ts index e1f1e0652..b46e617c8 100644 --- a/src/local-effect-enforcement.ts +++ b/src/local-effect-enforcement.ts @@ -297,3 +297,310 @@ export function parseLocalEffectEnforcementReceipt( return undefined; } } + +// ─── Tool Exposure Receipt (nexus.tool_exposure_receipt.v1) ───────────────── + +export const TOOL_EXPOSURE_RECEIPT_SCHEMA = "nexus.tool_exposure_receipt.v1" as const; +export const STABLE_TOOL_IDENTITY_SCHEMA = "nexus.stable_tool_identity.v1" as const; +export const RUNTIME_TOOL_GENERATION_SCHEMA = "nexus.runtime_tool_generation.v1" as const; + +export type ToolExposureEnforcementMode = + | "ENFORCED_NATIVE_PROVIDER" + | "ENFORCED_MANAGED_BRIDGE" + | "REQUEST_ONLY_NOT_ENFORCED" + | "NOT_OBSERVED" + | "NO_EXTERNAL_TOOL_SURFACE" + | "UNKNOWN"; + +export interface StableToolIdentity { + schema: typeof STABLE_TOOL_IDENTITY_SCHEMA; + server_origin: string; + tool_name: string; + input_schema_hash: string; + description_hash: string; + stable_tool_id: string; +} + +export interface RuntimeToolGeneration { + schema: typeof RUNTIME_TOOL_GENERATION_SCHEMA; + server_origin: string; + server_instance_id: string; + catalog_generation: number | string; + generation_hash: string; + observed_at: string; +} + +export interface ToolExposureReceipt { + schema: typeof TOOL_EXPOSURE_RECEIPT_SCHEMA; + operation_id: string; + attempt_id: string; + provider: string; + backend_id: string; + planner_decision_hash: string; + projection_hash: string; + enforcement_mode: ToolExposureEnforcementMode; + candidate_tools: string[]; + selected_tools: string[]; + actual_exposed_tools: string[]; + actual_exposed_tool_count: number; + authority_kind: "DERIVED_EXPOSURE_EVIDENCE_ONLY"; + remote_tool_identities?: StableToolIdentity[]; + runtime_tool_generations?: RuntimeToolGeneration[]; + exposure_hash: string; +} + +export class ToolExposureError extends Error { + constructor(message: string) { + super(message); + this.name = "ToolExposureError"; + } +} + +export class ToolExposureWidenedError extends ToolExposureError { + constructor(message: string) { + super(message); + this.name = "ToolExposureWidenedError"; + } +} + +export class ToolExposureIdentityError extends ToolExposureError { + constructor(message: string) { + super(message); + this.name = "ToolExposureIdentityError"; + } +} + +const SHA256_HEX_RE = /^[0-9a-f]{64}$/; + +function requireHex64(value: unknown, fieldName: string): string { + const text = String(value || ""); + if (!SHA256_HEX_RE.test(text)) { + throw new ToolExposureIdentityError(`${fieldName}_invalid: expected 64-hex sha256`); + } + return text; +} + +function requireText(value: unknown, fieldName: string): string { + if (typeof value !== "string" || !value.trim()) { + throw new ToolExposureIdentityError(`${fieldName}_invalid: non-empty string required`); + } + return value.trim(); +} + +export function validateStableToolIdentity(val: unknown): StableToolIdentity { + if (!val || typeof val !== "object" || Array.isArray(val)) { + throw new ToolExposureError("STABLE_TOOL_IDENTITY_NOT_MAPPING"); + } + const r = val as Record; + if (r.schema !== STABLE_TOOL_IDENTITY_SCHEMA) { + throw new ToolExposureError("STABLE_TOOL_IDENTITY_SCHEMA_INVALID"); + } + const origin = requireText(r.server_origin, "server_origin"); + const name = requireText(r.tool_name, "tool_name"); + const sHash = requireHex64(r.input_schema_hash, "input_schema_hash"); + const dHash = requireHex64(r.description_hash, "description_hash"); + const stableId = requireHex64(r.stable_tool_id, "stable_tool_id"); + return { + schema: STABLE_TOOL_IDENTITY_SCHEMA, + server_origin: origin, + tool_name: name, + input_schema_hash: sHash, + description_hash: dHash, + stable_tool_id: stableId, + }; +} + +export function validateRuntimeToolGeneration(val: unknown): RuntimeToolGeneration { + if (!val || typeof val !== "object" || Array.isArray(val)) { + throw new ToolExposureError("RUNTIME_TOOL_GENERATION_NOT_MAPPING"); + } + const r = val as Record; + if (r.schema !== RUNTIME_TOOL_GENERATION_SCHEMA) { + throw new ToolExposureError("RUNTIME_TOOL_GENERATION_SCHEMA_INVALID"); + } + const origin = requireText(r.server_origin, "server_origin"); + const instanceId = requireText(r.server_instance_id, "server_instance_id"); + if (typeof r.catalog_generation !== "number" && typeof r.catalog_generation !== "string") { + throw new ToolExposureError("catalog_generation_invalid"); + } + const catGen = typeof r.catalog_generation === "number" ? r.catalog_generation : requireText(r.catalog_generation, "catalog_generation"); + const genHash = requireHex64(r.generation_hash, "generation_hash"); + const obsAt = requireText(r.observed_at, "observed_at"); + return { + schema: RUNTIME_TOOL_GENERATION_SCHEMA, + server_origin: origin, + server_instance_id: instanceId, + catalog_generation: catGen, + generation_hash: genHash, + observed_at: obsAt, + }; +} + +/** + * Builds a canonical ToolExposureReceipt matching nexus.tool_exposure_receipt.v1. + */ +export function buildToolExposureReceipt(input: { + operation_id: string; + attempt_id: string; + provider: string; + backend_id: string; + planner_decision_hash: string; + projection_hash: string; + enforcement_mode: ToolExposureEnforcementMode; + candidate_tools: string[]; + selected_tools: string[]; + actual_exposed_tools: string[]; + remote_tool_identities?: StableToolIdentity[]; + runtime_tool_generations?: RuntimeToolGeneration[]; +}): ToolExposureReceipt { + const opId = requireText(input.operation_id, "operation_id"); + const attId = requireText(input.attempt_id, "attempt_id"); + const prov = requireText(input.provider, "provider"); + const backend = requireText(input.backend_id, "backend_id"); + const decHash = requireHex64(input.planner_decision_hash, "planner_decision_hash"); + const projHash = requireHex64(input.projection_hash, "projection_hash"); + + const candidates = Array.from(new Set(input.candidate_tools)).sort(); + const selected = Array.from(new Set(input.selected_tools)).sort(); + const actual = Array.from(new Set(input.actual_exposed_tools)).sort(); + + const candidateSet = new Set(candidates); + for (const s of selected) { + if (!candidateSet.has(s)) { + throw new ToolExposureWidenedError("SELECTED_TOOLS_EXCEED_CANDIDATE_TOOLS"); + } + } + + const selectedSet = new Set(selected); + for (const a of actual) { + if (!selectedSet.has(a)) { + throw new ToolExposureWidenedError("ACTUAL_EXPOSED_TOOLS_EXCEED_SELECTED_TOOLS"); + } + } + + const validatedRemote = (input.remote_tool_identities || []).map(validateStableToolIdentity); + validatedRemote.sort((a, b) => { + if (a.server_origin !== b.server_origin) return a.server_origin.localeCompare(b.server_origin); + if (a.tool_name !== b.tool_name) return a.tool_name.localeCompare(b.tool_name); + return a.stable_tool_id.localeCompare(b.stable_tool_id); + }); + + const validatedGens = (input.runtime_tool_generations || []).map(validateRuntimeToolGeneration); + validatedGens.sort((a, b) => { + if (a.server_origin !== b.server_origin) return a.server_origin.localeCompare(b.server_origin); + const genA = String(a.catalog_generation); + const genB = String(b.catalog_generation); + if (genA !== genB) return genA.localeCompare(genB); + return a.server_instance_id.localeCompare(b.server_instance_id); + }); + + const material: Record = { + schema: TOOL_EXPOSURE_RECEIPT_SCHEMA, + operation_id: opId, + attempt_id: attId, + provider: prov, + backend_id: backend, + planner_decision_hash: decHash, + projection_hash: projHash, + enforcement_mode: input.enforcement_mode, + candidate_tools: candidates, + selected_tools: selected, + actual_exposed_tools: actual, + actual_exposed_tool_count: actual.length, + authority_kind: "DERIVED_EXPOSURE_EVIDENCE_ONLY", + ...(validatedRemote.length > 0 ? { remote_tool_identities: validatedRemote } : {}), + ...(validatedGens.length > 0 ? { runtime_tool_generations: validatedGens } : {}), + }; + + const exposure_hash = createHash("sha256") + .update(canonicalJson(material)) + .digest("hex"); + + return { + ...material, + exposure_hash, + } as ToolExposureReceipt; +} + +/** + * Validates and parses a ToolExposureReceipt from unknown input. + */ +export function parseToolExposureReceipt(value: unknown): ToolExposureReceipt | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const record = value as Record; + + if (record.schema !== TOOL_EXPOSURE_RECEIPT_SCHEMA) return undefined; + if (record.authority_kind !== "DERIVED_EXPOSURE_EVIDENCE_ONLY") return undefined; + if (typeof record.exposure_hash !== "string" || !SHA256_HEX_RE.test(record.exposure_hash)) { + return undefined; + } + + try { + const opId = requireText(record.operation_id, "operation_id"); + const attId = requireText(record.attempt_id, "attempt_id"); + const prov = requireText(record.provider, "provider"); + const backend = requireText(record.backend_id, "backend_id"); + const decHash = requireHex64(record.planner_decision_hash, "planner_decision_hash"); + const projHash = requireHex64(record.projection_hash, "projection_hash"); + + if ( + !Array.isArray(record.candidate_tools) || + !Array.isArray(record.selected_tools) || + !Array.isArray(record.actual_exposed_tools) + ) { + return undefined; + } + + const candidateTools = record.candidate_tools.filter((t): t is string => typeof t === "string"); + const selectedTools = record.selected_tools.filter((t): t is string => typeof t === "string"); + const actualExposedTools = record.actual_exposed_tools.filter((t): t is string => typeof t === "string"); + + if ( + candidateTools.length !== record.candidate_tools.length || + selectedTools.length !== record.selected_tools.length || + actualExposedTools.length !== record.actual_exposed_tools.length || + actualExposedTools.length !== record.actual_exposed_tool_count + ) { + return undefined; + } + + // Invariant checks + const candidateSet = new Set(candidateTools); + for (const s of selectedTools) { + if (!candidateSet.has(s)) return undefined; + } + const selectedSet = new Set(selectedTools); + for (const a of actualExposedTools) { + if (!selectedSet.has(a)) return undefined; + } + + if (record.remote_tool_identities !== undefined) { + if (!Array.isArray(record.remote_tool_identities)) return undefined; + for (const r of record.remote_tool_identities) { + validateStableToolIdentity(r); + } + } + + if (record.runtime_tool_generations !== undefined) { + if (!Array.isArray(record.runtime_tool_generations)) return undefined; + for (const g of record.runtime_tool_generations) { + validateRuntimeToolGeneration(g); + } + } + + // Verify exposure_hash + const material: Record = { ...record }; + delete material.exposure_hash; + const computedHash = createHash("sha256") + .update(canonicalJson(material)) + .digest("hex"); + if (computedHash !== record.exposure_hash) { + return undefined; + } + + return record as unknown as ToolExposureReceipt; + } catch { + return undefined; + } +} + diff --git a/src/tool-exposure-producer.test.ts b/src/tool-exposure-producer.test.ts deleted file mode 100644 index 9834d24e2..000000000 --- a/src/tool-exposure-producer.test.ts +++ /dev/null @@ -1,367 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { createHash } from "node:crypto"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { - TOOL_EXPOSURE_RECEIPT_SCHEMA, - buildToolExposureReceipt, - parseToolExposureReceipt, - buildHerdrToolExposureReceipt, - mapSelectedIntentsToOpencodeTools, - canonicalJson, - ToolExposureWidenedError, - ToolExposureIdentityError, -} from "./tool-exposure-receipt.js"; -import { LocalAgentStore } from "./local-agent-store.js"; -import { - HerdrThinGateway, - type HerdrExternalHandle, - normalizeHerdrHandleAuthority, - assertExactDurableHerdrHandle, - defaultHerdrGatewayRegistry, -} from "./local-agent-herdr.js"; -import { LocalAgentSessionManager } from "./local-agent-sessions.js"; -import { hashDispatchIntent } from "./execution-protocol.js"; - -test("H1: buildToolExposureReceipt rejects selected_tools exceeding candidate_tools (ceiling violation)", () => { - assert.throws( - () => - buildToolExposureReceipt({ - operation_id: "op-1", - attempt_id: "att-1", - provider: "opencode", - backend_id: "herdr", - planner_decision_hash: "0".repeat(64), - projection_hash: "1".repeat(64), - enforcement_mode: "ENFORCED_MANAGED_BRIDGE", - candidate_tools: ["read", "glob"], - selected_tools: ["read", "edit"], // 'edit' not in candidates - actual_exposed_tools: ["read"], - }), - (err: unknown) => - err instanceof ToolExposureWidenedError && - err.message === "SELECTED_TOOLS_EXCEED_CANDIDATE_TOOLS", - ); -}); - -test("H2: buildToolExposureReceipt rejects actual_exposed_tools exceeding selected_tools (widening violation)", () => { - assert.throws( - () => - buildToolExposureReceipt({ - operation_id: "op-1", - attempt_id: "att-1", - provider: "opencode", - backend_id: "herdr", - planner_decision_hash: "0".repeat(64), - projection_hash: "1".repeat(64), - enforcement_mode: "ENFORCED_MANAGED_BRIDGE", - candidate_tools: ["read", "edit", "glob"], - selected_tools: ["read"], - actual_exposed_tools: ["read", "edit"], // 'edit' not in selected - }), - (err: unknown) => - err instanceof ToolExposureWidenedError && - err.message === "ACTUAL_EXPOSED_TOOLS_EXCEED_SELECTED_TOOLS", - ); -}); - -test("H3: Unsupported CLI providers (agy, codex, grok, cline) produce fail-closed REQUEST_ONLY_NOT_ENFORCED receipt", () => { - const providers = ["agy", "codex", "grok", "cline"] as const; - for (const prov of providers) { - const receipt = buildHerdrToolExposureReceipt({ - agentKind: prov, - attemptKey: `attempt-${prov}`, - dispatchIntentHash: "a".repeat(64), - selectedToolIntents: ["workspace.read", "workspace.mutate"], - writeMode: "allowed", - }); - - assert.equal(receipt.schema, TOOL_EXPOSURE_RECEIPT_SCHEMA); - assert.equal(receipt.provider, prov); - assert.equal(receipt.backend_id, "herdr"); - assert.equal(receipt.enforcement_mode, "REQUEST_ONLY_NOT_ENFORCED"); - assert.deepEqual(receipt.actual_exposed_tools, []); - assert.equal(receipt.actual_exposed_tool_count, 0); - assert.equal(receipt.authority_kind, "DERIVED_EXPOSURE_EVIDENCE_ONLY"); - assert.match(receipt.exposure_hash, /^[0-9a-f]{64}$/); - - // Ensure parseToolExposureReceipt verifies hash and structure - const parsed = parseToolExposureReceipt(receipt); - assert.ok(parsed); - assert.deepEqual(parsed, receipt); - } -}); - -test("H4: OpenCode managed bridge produces ENFORCED_MANAGED_BRIDGE with correct tool exposure subset", () => { - // Scenario A: read_only writeMode suppresses 'edit' and 'bash' even if selected - const readOnlyReceipt = buildHerdrToolExposureReceipt({ - agentKind: "opencode", - attemptKey: "attempt-oc-ro", - dispatchIntentHash: "b".repeat(64), - selectedToolIntents: [ - "workspace.read", - "workspace.mutate", - "process.execute", - "workspace.search_paths", - ], - writeMode: "read_only", - }); - - assert.equal(readOnlyReceipt.enforcement_mode, "ENFORCED_MANAGED_BRIDGE"); - assert.equal(readOnlyReceipt.provider, "opencode"); - assert.equal(readOnlyReceipt.backend_id, "herdr"); - assert.deepEqual(readOnlyReceipt.candidate_tools, [ - "bash", - "edit", - "glob", - "grep", - "list", - "read", - ]); - // selected_tools has bash, edit, glob, read - assert.deepEqual(readOnlyReceipt.selected_tools, ["bash", "edit", "glob", "read"]); - // actual_exposed_tools in read_only mode only has glob, read (edit and bash excluded) - assert.deepEqual(readOnlyReceipt.actual_exposed_tools, ["glob", "read"]); - assert.equal(readOnlyReceipt.actual_exposed_tool_count, 2); - - // Invariant verification: actual <= selected <= candidates - const actualSet = new Set(readOnlyReceipt.actual_exposed_tools); - const selectedSet = new Set(readOnlyReceipt.selected_tools); - const candidateSet = new Set(readOnlyReceipt.candidate_tools); - for (const a of actualSet) assert.ok(selectedSet.has(a)); - for (const s of selectedSet) assert.ok(candidateSet.has(s)); - - // Scenario B: allowed writeMode exposes edit and bash when selected - const writeReceipt = buildHerdrToolExposureReceipt({ - agentKind: "opencode", - attemptKey: "attempt-oc-rw", - dispatchIntentHash: "c".repeat(64), - selectedToolIntents: [ - "workspace.read", - "workspace.mutate", - "process.execute", - "workspace.search_text", - "workspace.list", - ], - writeMode: "allowed", - }); - - assert.equal(writeReceipt.enforcement_mode, "ENFORCED_MANAGED_BRIDGE"); - assert.deepEqual(writeReceipt.actual_exposed_tools, [ - "bash", - "edit", - "grep", - "list", - "read", - ]); - assert.equal(writeReceipt.actual_exposed_tool_count, 5); -}); - -test("H5: mapSelectedIntentsToOpencodeTools handles undefined (default all)", () => { - const defaultRo = mapSelectedIntentsToOpencodeTools(undefined, "read_only"); - assert.deepEqual(defaultRo, ["glob", "grep", "list", "read"]); - - const defaultRw = mapSelectedIntentsToOpencodeTools(undefined, "allowed"); - assert.deepEqual(defaultRw, ["bash", "edit", "glob", "grep", "list", "read"]); -}); - -test("H6: parseToolExposureReceipt detects tampering or malformed hashes", () => { - const receipt = buildHerdrToolExposureReceipt({ - agentKind: "opencode", - attemptKey: "attempt-tamper", - dispatchIntentHash: "d".repeat(64), - selectedToolIntents: ["workspace.read"], - writeMode: "read_only", - }); - - // Tampered actual_exposed_tools - const tampered1 = { ...receipt, actual_exposed_tools: ["read", "edit"] }; - assert.equal(parseToolExposureReceipt(tampered1), undefined); - - // Tampered hash - const tampered2 = { ...receipt, exposure_hash: "e".repeat(64) }; - assert.equal(parseToolExposureReceipt(tampered2), undefined); - - // Count mismatch - const tampered3 = { ...receipt, actual_exposed_tool_count: 99 }; - assert.equal(parseToolExposureReceipt(tampered3), undefined); -}); - -test("H7: HerdrExternalHandle retains toolExposureReceipt across handle normalization and assertion", () => { - const receipt = buildHerdrToolExposureReceipt({ - agentKind: "opencode", - attemptKey: "attempt-handle-1", - dispatchIntentHash: "f".repeat(64), - selectedToolIntents: ["workspace.read"], - writeMode: "read_only", - }); - - const handle: HerdrExternalHandle = { - schemaVersion: 1, - runtimeKind: "HERDR", - agentId: "agent-1", - herdrSocketPath: "/tmp/herdr.sock", - herdrWorkspaceId: "ws-1", - herdrPaneId: "pane-1", - herdrAgentIdentity: "agent-name", - herdrAgentKind: "opencode", - promptNonce: "nonce-1", - canonicalWorktreePath: "/tmp/worktree", - workspaceId: "ws-local-1", - gitHeadBefore: "1".repeat(40), - attemptKey: "attempt-handle-1", - dispatchIntentHash: "f".repeat(64), - launchTimestamp: new Date().toISOString(), - enforcementState: "REQUEST_ONLY_NOT_ENFORCED", - toolExposureReceipt: receipt, - }; - - const norm = normalizeHerdrHandleAuthority(handle); - assert.equal(norm.toolExposureHash, receipt.exposure_hash); - - // Matching handle passes assertExactDurableHerdrHandle - assert.doesNotThrow(() => assertExactDurableHerdrHandle(handle, handle)); - - // Handle with tampered toolExposureReceipt fails assertExactDurableHerdrHandle - const tamperedHandle: HerdrExternalHandle = { - ...handle, - toolExposureReceipt: { ...receipt, exposure_hash: "0".repeat(64) }, - }; - assert.throws( - () => assertExactDurableHerdrHandle(tamperedHandle, handle), - (err: unknown) => - err instanceof Error && - err.message.includes("toolExposureHash"), - ); -}); - -test("H8: End-to-end LocalAgentSessionManager binds and exposes ToolExposureReceipt in start, status, and reconcile", async () => { - const stateDir = mkdtempSync(join(tmpdir(), "devspace-receipt-session-state-")); - const projectRoot = mkdtempSync(join(tmpdir(), "devspace-receipt-session-repo-")); - - try { - const config = { - stateDir, - subagents: true, - oauth: { scopes: ["devspace"] }, - } as any; - - const manager = new LocalAgentSessionManager( - config, - async () => {}, - async () => true, - ); - - const dispatchIntent = { - taskId: "task-receipt-1", - attemptId: "attempt-receipt-1", - objective: "Physical tool exposure test", - roleIntent: "DEEP_ENGINEERING" as const, - claimCeiling: "CANDIDATE_READY" as const, - context: ["test"], - readScope: ["src"], - writeScope: ["src/test.txt"], - exclusiveOwnership: true, - forbiddenChanges: [], - acceptanceCriteria: ["pass"], - verificationRequired: true, - expectedArtifacts: [], - }; - const intentHash = hashDispatchIntent(dispatchIntent); - - const store = (manager as any).store as LocalAgentStore; - const record = store.create({ - workspaceId: "ws-receipt-test", - workspaceRoot: projectRoot, - profileName: "opencode-worker", - provider: "opencode", - lifecycleKind: "detached_worker_v2", - startReplay: { - key: "attempt-receipt-1", - requestHash: "req-hash-1", - }, - executionContract: { - writePaths: ["src/test.txt"], - dispatchIntent, - }, - }); - - const receipt = buildHerdrToolExposureReceipt({ - agentKind: "opencode", - attemptKey: "attempt-receipt-1", - dispatchIntent, - dispatchIntentHash: intentHash, - selectedToolIntents: ["workspace.read", "workspace.mutate"], - writeMode: "allowed", - }); - - const handle: HerdrExternalHandle = { - schemaVersion: 1, - runtimeKind: "HERDR", - agentId: record.id, - herdrSocketPath: "/tmp/herdr.sock", - herdrWorkspaceId: "ws-herdr-1", - herdrPaneId: "pane-1", - herdrAgentIdentity: "ds-attempt-receipt-1", - herdrAgentKind: "opencode", - promptNonce: "HERDR-DISPATCH-receipt-1", - canonicalWorktreePath: projectRoot, - workspaceId: "ws-receipt-test", - gitHeadBefore: "0".repeat(40), - attemptKey: "attempt-receipt-1", - dispatchIntentHash: intentHash, - launchTimestamp: new Date().toISOString(), - enforcementState: "REQUEST_ONLY_NOT_ENFORCED", - toolExposureReceipt: receipt, - }; - - // Bind handle - manager.bindHerdrExternalHandle(record.id, handle); - - // 1. Check getAgentStatus exposes toolExposureReceipt - const status = await manager.getAgentStatus({ - workspaceId: "ws-receipt-test", - workspaceRoot: projectRoot, - agentId: record.id, - }); - assert.ok(status.toolExposureReceipt); - assert.deepEqual(status.toolExposureReceipt, receipt); - assert.equal(status.toolExposureReceipt.enforcement_mode, "ENFORCED_MANAGED_BRIDGE"); - - // 2. Check reconcileAgent exposes toolExposureReceipt - const reconcile = await manager.reconcileAgent({ - workspaceId: "ws-receipt-test", - workspaceRoot: projectRoot, - isolated: false, - agentId: record.id, - }); - assert.ok(reconcile.toolExposureReceipt); - assert.deepEqual(reconcile.toolExposureReceipt, receipt); - assert.equal(reconcile.toolExposureReceipt.exposure_hash, receipt.exposure_hash); - - // 3. Check durable store recovery survives new session manager - const manager2 = new LocalAgentSessionManager( - config, - async () => {}, - async () => true, - ); - const recoveredHandle = manager2.getHerdrExternalHandle(record.id); - assert.ok(recoveredHandle); - assert.ok(recoveredHandle.toolExposureReceipt); - assert.deepEqual(recoveredHandle.toolExposureReceipt, receipt); - - const recoveredStatus = await manager2.getAgentStatus({ - workspaceId: "ws-receipt-test", - workspaceRoot: projectRoot, - agentId: record.id, - }); - assert.deepEqual(recoveredStatus.toolExposureReceipt, receipt); - } finally { - defaultHerdrGatewayRegistry.releaseHandle("attempt-receipt-1"); - rmSync(stateDir, { recursive: true, force: true }); - rmSync(projectRoot, { recursive: true, force: true }); - } -}); diff --git a/src/tool-exposure-receipt.ts b/src/tool-exposure-receipt.ts deleted file mode 100644 index a296c6c3d..000000000 --- a/src/tool-exposure-receipt.ts +++ /dev/null @@ -1,356 +0,0 @@ -import { createHash } from "node:crypto"; -import { - type ToolIntentId, - TOOL_INTENT_IDS, -} from "./execution-protocol.js"; - -export const TOOL_EXPOSURE_RECEIPT_SCHEMA = "nexus.tool_exposure_receipt.v1" as const; - -export type ToolExposureEnforcementMode = - | "ENFORCED_NATIVE_PROVIDER" - | "ENFORCED_MANAGED_BRIDGE" - | "REQUEST_ONLY_NOT_ENFORCED" - | "NOT_OBSERVED" - | "NO_EXTERNAL_TOOL_SURFACE" - | "UNKNOWN"; - -export interface ToolExposureReceipt { - schema: typeof TOOL_EXPOSURE_RECEIPT_SCHEMA; - operation_id: string; - attempt_id: string; - provider: string; - backend_id: string; - planner_decision_hash: string; - projection_hash: string; - enforcement_mode: ToolExposureEnforcementMode; - candidate_tools: string[]; - selected_tools: string[]; - actual_exposed_tools: string[]; - actual_exposed_tool_count: number; - authority_kind: "DERIVED_EXPOSURE_EVIDENCE_ONLY"; - exposure_hash: string; -} - -export class ToolExposureError extends Error { - constructor(message: string) { - super(message); - this.name = "ToolExposureError"; - } -} - -export class ToolExposureWidenedError extends ToolExposureError { - constructor(message: string) { - super(message); - this.name = "ToolExposureWidenedError"; - } -} - -export class ToolExposureIdentityError extends ToolExposureError { - constructor(message: string) { - super(message); - this.name = "ToolExposureIdentityError"; - } -} - -export function canonicalJson(value: unknown): string { - if (value === null || value === undefined) return "null"; - if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; - if (typeof value === "object") { - const record = value as Record; - return `{${Object.keys(record) - .sort() - .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`) - .join(",")}}`; - } - return JSON.stringify(value); -} - -const SHA256_HEX_RE = /^[0-9a-f]{64}$/; - -function requireHex64(value: unknown, fieldName: string): string { - const text = String(value || ""); - if (!SHA256_HEX_RE.test(text)) { - throw new ToolExposureIdentityError(`${fieldName}_invalid: expected 64-hex sha256`); - } - return text; -} - -function requireText(value: unknown, fieldName: string): string { - if (typeof value !== "string" || !value.trim()) { - throw new ToolExposureIdentityError(`${fieldName}_invalid: non-empty string required`); - } - return value.trim(); -} - -/** - * Maps ToolIntentId to OpenCode physical tool permission names. - * Returns only tools that are physically enabled (allowed). - */ -export function mapSelectedIntentsToOpencodeTools( - selectedToolIntents?: ToolIntentId[], - writeMode?: "read_only" | "allowed", -): string[] { - const allowed = writeMode !== "read_only"; - if (selectedToolIntents === undefined) { - const defaultTools = ["glob", "grep", "list", "read"]; - if (allowed) { - defaultTools.push("bash", "edit"); - } - return defaultTools.sort(); - } - - const selectedSet = new Set(selectedToolIntents); - const exposed: string[] = []; - if (selectedSet.has("workspace.read")) exposed.push("read"); - if (allowed && selectedSet.has("workspace.mutate")) exposed.push("edit"); - if (selectedSet.has("workspace.search_paths")) exposed.push("glob"); - if (selectedSet.has("workspace.search_text")) exposed.push("grep"); - if (selectedSet.has("workspace.list")) exposed.push("list"); - if (allowed && selectedSet.has("process.execute")) exposed.push("bash"); - - return exposed.sort(); -} - -/** - * Builds a canonical ToolExposureReceipt matching nexus.tool_exposure_receipt.v1. - */ -export function buildToolExposureReceipt(input: { - operation_id: string; - attempt_id: string; - provider: string; - backend_id: string; - planner_decision_hash: string; - projection_hash: string; - enforcement_mode: ToolExposureEnforcementMode; - candidate_tools: string[]; - selected_tools: string[]; - actual_exposed_tools: string[]; -}): ToolExposureReceipt { - const opId = requireText(input.operation_id, "operation_id"); - const attId = requireText(input.attempt_id, "attempt_id"); - const prov = requireText(input.provider, "provider"); - const backend = requireText(input.backend_id, "backend_id"); - const decHash = requireHex64(input.planner_decision_hash, "planner_decision_hash"); - const projHash = requireHex64(input.projection_hash, "projection_hash"); - - const candidates = Array.from(new Set(input.candidate_tools)).sort(); - const selected = Array.from(new Set(input.selected_tools)).sort(); - const actual = Array.from(new Set(input.actual_exposed_tools)).sort(); - - const candidateSet = new Set(candidates); - for (const s of selected) { - if (!candidateSet.has(s)) { - throw new ToolExposureWidenedError("SELECTED_TOOLS_EXCEED_CANDIDATE_TOOLS"); - } - } - - const selectedSet = new Set(selected); - for (const a of actual) { - if (!selectedSet.has(a)) { - throw new ToolExposureWidenedError("ACTUAL_EXPOSED_TOOLS_EXCEED_SELECTED_TOOLS"); - } - } - - const material: Record = { - schema: TOOL_EXPOSURE_RECEIPT_SCHEMA, - operation_id: opId, - attempt_id: attId, - provider: prov, - backend_id: backend, - planner_decision_hash: decHash, - projection_hash: projHash, - enforcement_mode: input.enforcement_mode, - candidate_tools: candidates, - selected_tools: selected, - actual_exposed_tools: actual, - actual_exposed_tool_count: actual.length, - authority_kind: "DERIVED_EXPOSURE_EVIDENCE_ONLY", - }; - - const exposure_hash = createHash("sha256") - .update(canonicalJson(material)) - .digest("hex"); - - return { - ...material, - exposure_hash, - } as ToolExposureReceipt; -} - -/** - * Validates and parses a ToolExposureReceipt from unknown input. - */ -export function parseToolExposureReceipt(value: unknown): ToolExposureReceipt | undefined { - if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; - const record = value as Record; - - if (record.schema !== TOOL_EXPOSURE_RECEIPT_SCHEMA) return undefined; - if (record.authority_kind !== "DERIVED_EXPOSURE_EVIDENCE_ONLY") return undefined; - if (typeof record.exposure_hash !== "string" || !SHA256_HEX_RE.test(record.exposure_hash)) { - return undefined; - } - - try { - const opId = requireText(record.operation_id, "operation_id"); - const attId = requireText(record.attempt_id, "attempt_id"); - const prov = requireText(record.provider, "provider"); - const backend = requireText(record.backend_id, "backend_id"); - const decHash = requireHex64(record.planner_decision_hash, "planner_decision_hash"); - const projHash = requireHex64(record.projection_hash, "projection_hash"); - - if ( - !Array.isArray(record.candidate_tools) || - !Array.isArray(record.selected_tools) || - !Array.isArray(record.actual_exposed_tools) - ) { - return undefined; - } - - const candidateTools = record.candidate_tools.filter((t): t is string => typeof t === "string"); - const selectedTools = record.selected_tools.filter((t): t is string => typeof t === "string"); - const actualExposedTools = record.actual_exposed_tools.filter((t): t is string => typeof t === "string"); - - if ( - candidateTools.length !== record.candidate_tools.length || - selectedTools.length !== record.selected_tools.length || - actualExposedTools.length !== record.actual_exposed_tools.length || - actualExposedTools.length !== record.actual_exposed_tool_count - ) { - return undefined; - } - - // Invariant checks - const candidateSet = new Set(candidateTools); - for (const s of selectedTools) { - if (!candidateSet.has(s)) return undefined; - } - const selectedSet = new Set(selectedTools); - for (const a of actualExposedTools) { - if (!selectedSet.has(a)) return undefined; - } - - // Verify exposure_hash - const material: Record = { ...record }; - delete material.exposure_hash; - const computedHash = createHash("sha256") - .update(canonicalJson(material)) - .digest("hex"); - if (computedHash !== record.exposure_hash) { - return undefined; - } - - return record as unknown as ToolExposureReceipt; - } catch { - return undefined; - } -} - -/** - * Builds the canonical HerdR tool exposure receipt for an agent launch. - */ -export function buildHerdrToolExposureReceipt(input: { - agentKind: string; - attemptKey: string; - dispatchIntent?: { taskId: string; attemptId: string }; - dispatchIntentHash: string; - toolProjectionManifest?: { - candidateTools: ToolIntentId[]; - selectedTools: ToolIntentId[]; - }; - selectedToolIntents?: ToolIntentId[]; - writeMode?: "read_only" | "allowed"; -}): ToolExposureReceipt { - const { - agentKind, - attemptKey, - dispatchIntent, - dispatchIntentHash, - toolProjectionManifest, - selectedToolIntents, - writeMode, - } = input; - - const operationId = dispatchIntent?.taskId ?? attemptKey; - const attemptId = dispatchIntent?.attemptId ?? attemptKey; - const plannerDecisionHash = dispatchIntentHash; - - // Projection hash: if toolProjectionManifest is present, hash it; else hash the selectedToolIntents or attempt - let projectionHash: string; - if (toolProjectionManifest) { - projectionHash = createHash("sha256") - .update(canonicalJson(toolProjectionManifest)) - .digest("hex"); - } else { - projectionHash = createHash("sha256") - .update(canonicalJson({ attemptKey, selectedToolIntents: selectedToolIntents ?? [] })) - .digest("hex"); - } - - // Derive candidate and selected tool lists - let candidateTools: string[]; - let selectedTools: string[]; - - if (toolProjectionManifest) { - candidateTools = Array.from(new Set(toolProjectionManifest.candidateTools)).sort(); - selectedTools = Array.from(new Set(toolProjectionManifest.selectedTools)).sort(); - } else if (selectedToolIntents) { - candidateTools = Array.from(new Set(TOOL_INTENT_IDS)).sort(); - selectedTools = Array.from(new Set(selectedToolIntents)).sort(); - } else { - candidateTools = Array.from(new Set(TOOL_INTENT_IDS)).sort(); - selectedTools = Array.from(new Set(TOOL_INTENT_IDS)).sort(); - } - - if (agentKind === "opencode") { - // OpenCode has managed bridge enforcement via opencodeAgentConfig - // Note: candidate/selected tool intents (like workspace.read) map to actual tool names (like read). - // To maintain actual <= selected <= candidates, candidates and selected for the physical receipt - // represent the physical tool namespace when mapped, or intent namespace. - // In Nexus contracts: candidate_tools and selected_tools contain tool names. - // For OpenCode: candidate tool names: ["bash", "edit", "glob", "grep", "list", "read"] - // selected tool names: mapped from selectedToolIntents - // actual_exposed_tools: mapped and allowed - const physicalCandidates = ["bash", "edit", "glob", "grep", "list", "read"]; - // Map selectedToolIntents to physical tools - const physicalSelected = mapSelectedIntentsToOpencodeTools( - selectedToolIntents ?? (toolProjectionManifest?.selectedTools), - "allowed", // All selected tools regardless of writeMode - ); - // Filter physicalSelected by physicalCandidates - const validSelected = physicalSelected.filter((t) => physicalCandidates.includes(t)); - - // Actual exposed tools respects writeMode (e.g. read_only forbids edit and bash) - const actualExposed = mapSelectedIntentsToOpencodeTools( - selectedToolIntents ?? (toolProjectionManifest?.selectedTools), - writeMode, - ).filter((t) => validSelected.includes(t)); - - return buildToolExposureReceipt({ - operation_id: operationId, - attempt_id: attemptId, - provider: agentKind, - backend_id: "herdr", - planner_decision_hash: plannerDecisionHash, - projection_hash: projectionHash, - enforcement_mode: "ENFORCED_MANAGED_BRIDGE", - candidate_tools: physicalCandidates, - selected_tools: validSelected, - actual_exposed_tools: actualExposed, - }); - } - - // Unsupported or CLI providers (agy, codex, grok, cline) - // Fail closed as REQUEST_ONLY_NOT_ENFORCED with actual_exposed_tools = [] - return buildToolExposureReceipt({ - operation_id: operationId, - attempt_id: attemptId, - provider: agentKind, - backend_id: "herdr", - planner_decision_hash: plannerDecisionHash, - projection_hash: projectionHash, - enforcement_mode: "REQUEST_ONLY_NOT_ENFORCED", - candidate_tools: candidateTools, - selected_tools: selectedTools, - actual_exposed_tools: [], - }); -}