diff --git a/apps/web/package.json b/apps/web/package.json index 2c9468d..bddfb75 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,9 +14,9 @@ "preview": "bunx opennextjs-cloudflare build && bunx wrangler dev" }, "dependencies": { - "@wasmagent/react": "^1.0.0", - "@wasmagent/ui-cards": "^1.0.0", - "@wasmagent/ui-cards-react": "^1.0.0", + "@wasmagent/react": "^1.4.1", + "@wasmagent/ui-cards": "^1.0.3", + "@wasmagent/ui-cards-react": "^1.0.3", "@monaco-editor/react": "^4.6.0", "@opennextjs/cloudflare": "^1.19.11", "@terrastruct/d2": "^0.1.33", diff --git a/apps/worker/package.json b/apps/worker/package.json index 37fb87d..a7152a4 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -18,20 +18,20 @@ "@hono/node-server": "^2.0.4", "@jitl/quickjs-wasmfile-release-sync": "0.32.0", "@modelcontextprotocol/sdk": ">=1.7.0", - "@wasmagent/aep": "^1.2.0", - "@wasmagent/agent-prompts": "^1.0.0", - "@wasmagent/core": "^1.0.1", - "@wasmagent/kernel-pyodide": "^1.0.0", - "@wasmagent/kernel-quickjs": "^1.0.0", - "@wasmagent/kernel-remote": "^1.0.0", - "@wasmagent/mcp-server": "^1.1.0", - "@wasmagent/model-anthropic": "^1.0.0", - "@wasmagent/model-deepseek": "^1.0.0", - "@wasmagent/model-doubao": "^1.0.0", - "@wasmagent/model-openai": "^1.0.0", - "@wasmagent/model-zhipu": "^1.0.0", - "@wasmagent/tools-browser": "^1.0.0", - "@wasmagent/tools-rag": "^1.0.0", + "@wasmagent/aep": "^2.3.0", + "@wasmagent/agent-prompts": "^1.0.3", + "@wasmagent/core": "^3.3.0", + "@wasmagent/kernel-pyodide": "^1.0.11", + "@wasmagent/kernel-quickjs": "^1.2.8", + "@wasmagent/kernel-remote": "^1.3.12", + "@wasmagent/mcp-server": "^1.1.9", + "@wasmagent/model-anthropic": "^2.0.4", + "@wasmagent/model-deepseek": "^2.0.4", + "@wasmagent/model-doubao": "^2.0.4", + "@wasmagent/model-openai": "^2.0.4", + "@wasmagent/model-zhipu": "^2.0.4", + "@wasmagent/tools-browser": "^1.0.11", + "@wasmagent/tools-rag": "^1.0.11", "diff": "^9.0.0", "hono": "^4.6.0", "pyodide": "0.29.4", diff --git a/apps/worker/scripts/test-aep-roundtrip.ts b/apps/worker/scripts/test-aep-roundtrip.ts index 2e8654e..4734e4f 100644 --- a/apps/worker/scripts/test-aep-roundtrip.ts +++ b/apps/worker/scripts/test-aep-roundtrip.ts @@ -53,8 +53,8 @@ async function main() { }); // ── Step 2: Validate structure ──────────────────────────────────────────────── - if (record.schema_version !== "aep/v0.2") { - console.error("[test-aep-roundtrip] FAIL: schema_version is not aep/v0.2"); + if (record.schema_version !== "aep/v0.3") { + console.error("[test-aep-roundtrip] FAIL: schema_version is not aep/v0.3"); process.exit(1); } if (!record.signature || record.signature.sig === "UNSIGNED_PLACEHOLDER") { @@ -91,7 +91,7 @@ async function main() { const fixtureLine = (fixtureContent as { default: string }).default.trim().split("\n")[0]; const fixtureRecord = JSON.parse(fixtureLine); if ( - fixtureRecord.schema_version !== "aep/v0.2" || + fixtureRecord.schema_version !== "aep/v0.3" || !fixtureRecord.signature || fixtureRecord.signature.sig === "UNSIGNED_PLACEHOLDER" ) { diff --git a/apps/worker/src/__fixtures__/aep-shared.jsonl b/apps/worker/src/__fixtures__/aep-shared.jsonl index 33267de..3add3ea 100644 --- a/apps/worker/src/__fixtures__/aep-shared.jsonl +++ b/apps/worker/src/__fixtures__/aep-shared.jsonl @@ -1 +1 @@ -{"schema_version":"aep/v0.2","run_id":"fixture-run-aep-shared-001","model_provider":"test-provider","model_id":"test-model-v1","input_refs":[{"uri":"task://fixture-task-001","taint_labels":[]}],"output_refs":[{"uri":"file://dist/main.js","digest":"sha256:deadbeef"}],"capability_decisions":[{"capability":"bash","subject":"bscode-agent","resource":"bscode-workspace","decision":"allow","reason_code":"auto_derived"}],"actions":[{"action_id":"action-0","tool_name":"read_file","state_changing":false,"evidence_refs":[],"timestamp_ms":1700000001000},{"action_id":"action-1","tool_name":"bash","state_changing":true,"evidence_refs":[],"timestamp_ms":1700000002000}],"verifier_results":[{"verifier_id":"bscode-build-verifier","passed":true,"score":1,"claim_ids":[]}],"budget_ledger":{"tool_budget":{"spent":2}},"created_at_ms":1700000000000,"signature":{"alg":"ed25519","key_id":"bscode-aep-key-v1","sig":"grK3DFEKzWKn4eckEI5j9HPIn1C7bADPaU9BRRHgWQvKIfxDiaaeHG7LsoRTe7JqLevjwnftWaxGhhqWLieOAg=="}} +{"schema_version":"aep/v0.3","run_id":"fixture-run-aep-shared-001","model_provider":"test-provider","model_id":"test-model-v1","input_refs":[{"uri":"task://fixture-task-001","taint_labels":[]}],"output_refs":[{"uri":"file://dist/main.js","digest":"sha256:deadbeef"}],"capability_decisions":[{"capability":"bash","subject":"bscode-agent","resource":"bscode-workspace","decision":"allow","reason_code":"auto_derived","approval_mode":"policy-allow-with-receipt"}],"actions":[{"action_id":"action-0","tool_name":"read_file","state_changing":false,"evidence_refs":[],"timestamp_ms":1700000001000,"recording_mode":"validation","side_effect_class":"read"},{"action_id":"action-1","tool_name":"bash","state_changing":true,"evidence_refs":[],"timestamp_ms":1700000002000,"recording_mode":"full","side_effect_class":"mutate-external"}],"verifier_results":[{"verifier_id":"bscode-build-verifier","passed":true,"score":1,"claim_ids":[]}],"budget_ledger":{"tool_budget":{"spent":2}},"created_at_ms":1700000000000,"prev_record_hash":null,"run_side_effect_class_max":"mutate-external","signature":{"alg":"ed25519","key_id":"bscode-aep-key-v1","sig":"xSyJ1UNqpDRLaeQxnSaQxfntzt4b4Jb77XPvwL4zEL5WKn+yqxoY68YWyZXh4W43cli0SKpuwXEYB8sAZ8opBw=="}} diff --git a/apps/worker/src/trajectoryExport.test.ts b/apps/worker/src/trajectoryExport.test.ts index 7a8f198..c737f5f 100644 --- a/apps/worker/src/trajectoryExport.test.ts +++ b/apps/worker/src/trajectoryExport.test.ts @@ -331,7 +331,7 @@ describe("buildAEPEvidence", () => { { event: "tool_result" as const, data: { exit_code: 0 }, timestamp_ms: 1003 }, ]; - it("returns a signed AEPRecord with schema_version aep/v0.2", async () => { + it("returns a signed AEPRecord with schema_version aep/v0.3", async () => { const record = await buildAEPEvidence({ run_id: "run-aep-test-01", model_id: "test-model", @@ -339,7 +339,7 @@ describe("buildAEPEvidence", () => { objective_passed: true, created_at_ms: 1_700_000_000_000, }); - expect(record.schema_version).toBe("aep/v0.2"); + expect(record.schema_version).toBe("aep/v0.3"); expect(record.run_id).toBe("run-aep-test-01"); expect(record.model_id).toBe("test-model"); }); @@ -424,6 +424,42 @@ describe("buildAEPEvidence", () => { expect(record.capability_decisions[0].capability).toBe("bash"); expect(record.capability_decisions[0].decision).toBe("allow"); expect(record.capability_decisions[0].reason_code).toBe("auto_derived"); + // AEP v0.3: an executed tool is an allow backed by a policy receipt. + expect(record.capability_decisions[0].approval_mode).toBe("policy-allow-with-receipt"); + }); + + it("classifies side_effect_class and recording_mode per AEP v0.3", async () => { + const record = await buildAEPEvidence({ + run_id: "run-side-effect-test", + model_id: "test-model", + tool_calls: toolCalls, + objective_passed: null, + created_at_ms: 1_700_000_000_000, + }); + // Fixture has one read_file (read-only) and one bash (shells out) call. + const readAction = record.actions.find((a) => a.tool_name === "read_file"); + const bashAction = record.actions.find((a) => a.tool_name === "bash"); + expect(readAction?.side_effect_class).toBe("read"); + expect(readAction?.recording_mode).toBe("validation"); + expect(bashAction?.side_effect_class).toBe("mutate-external"); + expect(bashAction?.recording_mode).toBe("full"); + // Run-level max severity is computed by the emitter across all actions. + expect(record.run_side_effect_class_max).toBe("mutate-external"); + }); + + it("emits deny decisions with policy-deny-with-evidence approval_mode", async () => { + const record = await buildAEPEvidence({ + run_id: "run-deny-test", + model_id: "test-model", + tool_calls: toolCalls, + objective_passed: null, + denied_tools: ["curl_exfiltrate"], + created_at_ms: 1_700_000_000_000, + }); + const deny = record.capability_decisions.find((d) => d.decision === "deny"); + expect(deny?.capability).toBe("curl_exfiltrate"); + expect(deny?.approval_mode).toBe("policy-deny-with-evidence"); + expect(deny?.deny_reason_class).toBe("policy-rule"); }); it("records budget_ledger with auto-derived tool_budget", async () => { @@ -445,7 +481,13 @@ describe("buildAEPEvidence", () => { tool_calls: toolCalls, objective_passed: null, capability_decisions: [ - { capability: "write_file", subject: "agent", resource: "main.ts", decision: "allow" }, + { + capability: "write_file", + subject: "agent", + resource: "main.ts", + decision: "allow", + approval_mode: "policy-allow-with-receipt", + }, ], verifier_results: [ { verifier_id: "custom-verifier", passed: true, score: 0.9, claim_ids: ["c1"] }, diff --git a/apps/worker/src/trajectoryExport.ts b/apps/worker/src/trajectoryExport.ts index faf6a65..5dc26e2 100644 --- a/apps/worker/src/trajectoryExport.ts +++ b/apps/worker/src/trajectoryExport.ts @@ -335,6 +335,49 @@ const STATE_CHANGING_TOOLS = new Set([ "npm_publish", ]); +// AEP v0.3 side-effect classification (README §"Side-Effect Classification"). +// Tools that cross the process/network boundary — their effects escape the +// sandbox and are therefore the highest-severity, evidence-worthy actions. +const NETWORK_EGRESS_TOOLS = new Set(["git_push", "npm_publish"]); +// Tools that shell out — they can mutate anything reachable from the sandbox, +// so we conservatively class them as external mutation. +const MUTATE_EXTERNAL_TOOLS = new Set(["bash", "run_bash", "execute_bash"]); +// Tools that mutate only local workspace state (file edits, local commit). +const MUTATE_LOCAL_TOOLS = new Set(["write_file", "create_file", "delete_file", "git_commit"]); + +type SideEffectClass = "read" | "mutate-local" | "mutate-external" | "network-egress" | "unknown"; + +/** + * Classify a tool's side-effect severity for AEP v0.3 `side_effect_class`. + * Unknown tool names fall back to "read" only when they are not in the + * state-changing set; anything state-changing but unclassified is "unknown" + * so the emitter's `run_side_effect_class_max` never under-reports risk. + */ +function classifySideEffect(toolName: string | undefined): SideEffectClass { + if (!toolName) return "unknown"; + if (NETWORK_EGRESS_TOOLS.has(toolName)) return "network-egress"; + if (MUTATE_EXTERNAL_TOOLS.has(toolName)) return "mutate-external"; + if (MUTATE_LOCAL_TOOLS.has(toolName)) return "mutate-local"; + if (STATE_CHANGING_TOOLS.has(toolName)) return "unknown"; + return "read"; +} + +/** + * Choose an AEP v0.3 `recording_mode` for an action (README §"Recording + * Modes"): capture full content for high-risk actions (external mutation, + * network egress, or tainted input), a delta for local mutations, and + * digests-only ("validation") for read-only actions. + */ +function recordingModeFor( + sideEffect: SideEffectClass, + isUntrusted: boolean +): "validation" | "delta" | "full" { + if (isUntrusted) return "full"; + if (sideEffect === "mutate-external" || sideEffect === "network-egress") return "full"; + if (sideEffect === "mutate-local") return "delta"; + return "validation"; +} + /** Default AEP seed used in development / CI. Override with BSCODE_AEP_SEED env var. */ const DEV_AEP_SEED = "0".repeat(64); @@ -464,13 +507,24 @@ export async function buildAEPEvidence(opts: { const toolName = (ev.data as Record).name as string | undefined; const isStateChanging = Boolean(toolName && STATE_CHANGING_TOOLS.has(toolName)); const isUntrusted = Boolean((ev.data as Record).isUntrusted); + // AEP v0.3: classify the action's blast radius and pick a recording mode + // proportional to its risk (README §"Side-Effect Classification" / + // §"Recording Modes"). Tainted inputs force full capture. + const sideEffect = classifySideEffect(toolName); emitter.addAction({ action_id: `action-${i}`, tool_name: toolName ?? "unknown", state_changing: isStateChanging, + side_effect_class: sideEffect, + recording_mode: recordingModeFor(sideEffect, isUntrusted), evidence_refs: [], timestamp_ms: ev.timestamp_ms ?? Date.now() + i, - ...(isUntrusted ? { output_taint_labels: ["external", "user-supplied"] } : {}), + ...(isUntrusted + ? { + input_taint_labels: ["external", "user-supplied"], + output_taint_labels: ["external", "user-supplied"], + } + : {}), }); } } @@ -510,6 +564,9 @@ export async function buildAEPEvidence(opts: { subject: "bscode-agent", resource: "bscode-workspace", decision: "allow", + // AEP v0.3: the tool actually executed, so the allow is backed by a + // policy receipt rather than an interactive one-shot approval. + approval_mode: "policy-allow-with-receipt", reason_code: "auto_derived", }); } @@ -521,6 +578,10 @@ export async function buildAEPEvidence(opts: { subject: "bscode-agent", resource: "bscode-workspace", decision: "deny", + // AEP v0.3: the firewall denied on a policy rule and the record + // itself is the evidence of that denial. + approval_mode: "policy-deny-with-evidence", + deny_reason_class: "policy-rule", reason_code: "policy_denied", }); } diff --git a/bun.lock b/bun.lock index 229158e..2ec77c1 100644 --- a/bun.lock +++ b/bun.lock @@ -21,9 +21,9 @@ "@monaco-editor/react": "^4.6.0", "@opennextjs/cloudflare": "^1.19.11", "@terrastruct/d2": "^0.1.33", - "@wasmagent/react": "^1.0.0", - "@wasmagent/ui-cards": "^1.0.0", - "@wasmagent/ui-cards-react": "^1.0.0", + "@wasmagent/react": "^1.4.1", + "@wasmagent/ui-cards": "^1.0.3", + "@wasmagent/ui-cards-react": "^1.0.3", "@webcontainer/api": "^1.6.4", "docx": "^9.7.1", "idb-keyval": "^6.2.5", @@ -52,20 +52,20 @@ "@hono/node-server": "^2.0.4", "@jitl/quickjs-wasmfile-release-sync": "0.32.0", "@modelcontextprotocol/sdk": ">=1.7.0", - "@wasmagent/aep": "^1.2.0", - "@wasmagent/agent-prompts": "^1.0.0", - "@wasmagent/core": "^1.0.1", - "@wasmagent/kernel-pyodide": "^1.0.0", - "@wasmagent/kernel-quickjs": "^1.0.0", - "@wasmagent/kernel-remote": "^1.0.0", - "@wasmagent/mcp-server": "^1.1.0", - "@wasmagent/model-anthropic": "^1.0.0", - "@wasmagent/model-deepseek": "^1.0.0", - "@wasmagent/model-doubao": "^1.0.0", - "@wasmagent/model-openai": "^1.0.0", - "@wasmagent/model-zhipu": "^1.0.0", - "@wasmagent/tools-browser": "^1.0.0", - "@wasmagent/tools-rag": "^1.0.0", + "@wasmagent/aep": "^2.3.0", + "@wasmagent/agent-prompts": "^1.0.3", + "@wasmagent/core": "^3.3.0", + "@wasmagent/kernel-pyodide": "^1.0.11", + "@wasmagent/kernel-quickjs": "^1.2.8", + "@wasmagent/kernel-remote": "^1.3.12", + "@wasmagent/mcp-server": "^1.1.9", + "@wasmagent/model-anthropic": "^2.0.4", + "@wasmagent/model-deepseek": "^2.0.4", + "@wasmagent/model-doubao": "^2.0.4", + "@wasmagent/model-openai": "^2.0.4", + "@wasmagent/model-zhipu": "^2.0.4", + "@wasmagent/tools-browser": "^1.0.11", + "@wasmagent/tools-rag": "^1.0.11", "diff": "^9.0.0", "hono": "^4.6.0", "pyodide": "0.29.4", @@ -602,7 +602,7 @@ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.2", "", {}, "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA=="], - "@wasmagent/aep": ["@wasmagent/aep@1.2.0", "", { "dependencies": { "@noble/ed25519": "^3.1.0", "zod": "^3.23.0" } }, "sha512-GA5Fw+A2vLjIWlJ6EQzkrycevdp9xwnWdBDe4gngQLa6eYfWBVANRfJ05ABp/Q5oOTsQAdl5YBLpYBj3Fq/Ujw=="], + "@wasmagent/aep": ["@wasmagent/aep@2.3.0", "", { "dependencies": { "@noble/ed25519": "^3.1.0", "zod": "^3.23.0" } }, "sha512-OUZUV6zmDoNlq/XEiwVsaK6T3EtALNRItiZaBejBkYxT3AKkUqm0O+5524y9r2pnu7VUDHe0OixMHf+VaBHAEg=="], "@wasmagent/agent-prompts": ["@wasmagent/agent-prompts@1.0.3", "", {}, "sha512-2AZSdY2yx06lUBsBAk5uHho5DWUdPkaZy07fOcFn/0Q/m/4lmWqNm4kA9xN44MJ34ztbAGIJ9ygMe89e/Bplkg=="],