diff --git a/docs/AGENT_TOOL_QUICKSTART.md b/docs/AGENT_TOOL_QUICKSTART.md index e74b34ca..7aaddbca 100644 --- a/docs/AGENT_TOOL_QUICKSTART.md +++ b/docs/AGENT_TOOL_QUICKSTART.md @@ -14,7 +14,7 @@ node pilots/action-boundary/guarded-tool-demo.mjs The first fixture has an exact policy rule and a **simulated** approval; its callback returns a `would_send_to` object. The second changes the destination and is blocked before the callback. There is no network request, message delivery, or human authentication. -For the thirteen-case host rehearsal, list the case IDs and ordered receipt statuses, then inspect one case: +For the fourteen-case host rehearsal, list the case IDs and ordered receipt statuses, then inspect one case: ```bash node pilots/action-boundary/guarded-tool-rehearsal.mjs --list @@ -26,6 +26,8 @@ The list prints one tab-separated ID and status sequence per fixture; a two-atte The [host allow case](https://aruintelligence.github.io/aml-core/agent-start.html?case=host_allow#rehearsal) shows an exact rule with `requires_approval: false`: one local callback returns with zero approval requests and zero claim attempts. Only the host can choose that policy; the fixture is not a recommendation to waive approval for a real send. +The [approval hook error case](https://aruintelligence.github.io/aml-core/agent-start.html?case=approval_hook_error#rehearsal) writes one in-memory approval fixture marker just before the hook throws. The tool receipt is `blocked`, with no claim or tool callback. This is not a human approval or a durable grant; a real host must reconcile its authorization store rather than infer that no approval-state change occurred. + On the visual builder, **Copy fixture JSON** copies the selected case in the same shape as `--case`, including its project-authored provenance and scope. Include your checkout commit when sharing a reproduction; the copied fixture is not evidence of a real approval or delivery. The [declined approval case](https://aruintelligence.github.io/aml-core/agent-start.html?case=approval_declined#rehearsal) requests a fixture decision and receives a refusal. The receipt blocks before any claim or tool callback. A refusal is not a grant; a later attempt needs its own authenticated decision. diff --git a/docs/agent-start.html b/docs/agent-start.html index f1790b06..476f5e12 100644 --- a/docs/agent-start.html +++ b/docs/agent-start.html @@ -32,7 +32,7 @@ git rev-parse HEAD node pilots/action-boundary/guarded-tool-demo.mjs node pilots/action-boundary/guarded-tool-rehearsal.mjs --check

-
HOST REHEARSAL / CONTROLLED CASES

Where does the call stop?

Choose a condition. Use arrow keys, Home, or End after focusing a case. These are recorded results from the project adapter running local fixtures, with approval and callback counts visible.

Jump to selected result ↓Inspect the complete report ↗
OBSERVED IN THE LOCAL REHEARSAL

Loading the report…

Checking the project-authored snapshot.

HOST DESIGN MOVE

REPRODUCE OR SHARE THIS CASE
node pilots/action-boundary/guarded-tool-rehearsal.mjs --case changed_destination

The hook trace records which fixture hooks ran; it does not expose every internal policy check. A dispatched receipt means the fixture callback returned. An unknown receipt does not mean no side effect occurred. A claim-hook error can leave a grant spent without entering the tool callback. In-memory fixture writes are local markers, not provider delivery. Neither proves real approval, service delivery, or production safety.

+
HOST REHEARSAL / CONTROLLED CASES

Where does the call stop?

Choose a condition. Use arrow keys, Home, or End after focusing a case. These are recorded results from the project adapter running local fixtures, with approval and callback counts visible.

Jump to selected result ↓Inspect the complete report ↗
OBSERVED IN THE LOCAL REHEARSAL

Loading the report…

Checking the project-authored snapshot.

HOST DESIGN MOVE

REPRODUCE OR SHARE THIS CASE
node pilots/action-boundary/guarded-tool-rehearsal.mjs --case changed_destination

The hook trace records which fixture hooks ran; it does not expose every internal policy check. A dispatched receipt means the fixture callback returned. An unknown receipt does not mean no side effect occurred. A claim-hook error can leave a grant spent without entering the tool callback. An approval-hook error can leave host authorization state changed. In-memory fixture writes are local markers, not provider delivery. Neither proves real approval, service delivery, or production safety.

02

Move the boundary into your host.

The real host owns the tool identity, effect, resource mapping, policy, authenticated approval, one-use claim, and service call. This sketch deliberately throws until you implement the three host functions. It imports the project preview from your checkout.

import { createGuardedToolExecute } from "./pilots/action-boundary/guarded-tool.mjs"; @@ -65,6 +65,6 @@
Scope: This is a project-authored, simulated function-tool path from a preview checkout. It does not intercept every agent tool, authenticate a human, send mail, prove delivery, or certify a production integration. ĀML™ / ĀRU Meaning Language™ is stewarded by ĀRU Intelligence Inc. · Claims and evidence.
- + diff --git a/docs/agent-start.js b/docs/agent-start.js index 92431b5a..7cbbd7df 100644 --- a/docs/agent-start.js +++ b/docs/agent-start.js @@ -106,7 +106,7 @@ if (typeof document !== "undefined" && document.getElementById("rehearsal")) { } }); - fetch("guarded-tool-rehearsal.json?v=5cad3661cb4c").then(response => { + fetch("guarded-tool-rehearsal.json?v=a2c836a065eb").then(response => { if (!response.ok) throw new Error("Report request failed"); return response.json(); }).then(report => { diff --git a/docs/guarded-tool-rehearsal.json b/docs/guarded-tool-rehearsal.json index 61767ed1..d9f2909d 100644 --- a/docs/guarded-tool-rehearsal.json +++ b/docs/guarded-tool-rehearsal.json @@ -128,6 +128,31 @@ "callback_count": 0, "fixture_state_changes": 0 }, + { + "id": "approval_hook_error", + "title": "Approval hook throws after a local write", + "change": "The approval fixture writes an in-memory marker, then throws before returning a decision. No one-use claim or tool callback runs.", + "host_move": "Reconcile host authorization records after an approval hook error. A blocked tool receipt does not prove that the approval system recorded nothing; require a fresh verified decision for another attempt.", + "destination": "team@example.test", + "attempts": [ + { + "policy_decision": "requires_approval", + "proposal_sha256": "f416748534da5982a029d03d6db48adf00b4138e0785d38857f0b1734923776c", + "execution_status": "blocked", + "reason": "approval_error", + "host_hook_events": [ + "Approval requested", + "In-memory approval fixture marker written", + "Approval hook threw" + ] + } + ], + "approval_requests": 1, + "claim_attempts": 0, + "claims_consumed": 0, + "callback_count": 0, + "fixture_state_changes": 1 + }, { "id": "approval_mismatch", "title": "Approval for another proposal", diff --git a/pilots/action-boundary/guarded-tool-rehearsal.mjs b/pilots/action-boundary/guarded-tool-rehearsal.mjs index 86abd9c5..64e9443c 100644 --- a/pilots/action-boundary/guarded-tool-rehearsal.mjs +++ b/pilots/action-boundary/guarded-tool-rehearsal.mjs @@ -39,6 +39,12 @@ const fixtures = [ host_move: "Treat a refusal as final for this attempt. A later attempt needs a new authenticated approval decision; never turn a refusal into an implicit grant.", options: { approval: "decline" } }, + { + id: "approval_hook_error", title: "Approval hook throws after a local write", + change: "The approval fixture writes an in-memory marker, then throws before returning a decision. No one-use claim or tool callback runs.", + host_move: "Reconcile host authorization records after an approval hook error. A blocked tool receipt does not prove that the approval system recorded nothing; require a fresh verified decision for another attempt.", + options: { approval: "throw_after_write" } + }, { id: "approval_mismatch", title: "Approval for another proposal", change: "The approval fixture returns a different proposal digest.", @@ -116,6 +122,12 @@ async function runFixture({ id, title, change, host_move, options }) { hookEvents.push("Fixture approval declined"); return { approved: false }; } + if (options.approval === "throw_after_write") { + fixtureStateChanges++; + hookEvents.push("In-memory approval fixture marker written"); + hookEvents.push("Approval hook threw"); + throw new Error("simulated_approval_response_error"); + } hookEvents.push(options.approval === "mismatch" ? "Different digest returned" : "Matching digest returned"); return { approved: true, proposal_sha256: options.approval === "mismatch" ? "0".repeat(64) : proposal_sha256 }; }; @@ -191,6 +203,7 @@ export function assertGuardedToolRehearsal(report) { changed_destination: [["deny", "blocked", "no_exact_rule"], 0], approval_unavailable: [["requires_approval", "blocked", "approval_unavailable"], 0], approval_declined: [["requires_approval", "blocked", "approval_missing_or_mismatched"], 0], + approval_hook_error: [["requires_approval", "blocked", "approval_error"], 0], approval_mismatch: [["requires_approval", "blocked", "approval_missing_or_mismatched"], 0], approval_reused: [ ["requires_approval", "dispatched", "host_dispatch_returned"], @@ -232,6 +245,11 @@ export function assertGuardedToolRehearsal(report) { assert.equal(byId.approval_declined.claim_attempts, 0); assert.deepEqual(byId.approval_declined.attempts[0].host_hook_events, ["Approval requested", "Fixture approval declined"]); + assert.equal(byId.approval_hook_error.approval_requests, 1); + assert.equal(byId.approval_hook_error.claim_attempts, 0); + assert.equal(byId.approval_hook_error.fixture_state_changes, 1); + assert.deepEqual(byId.approval_hook_error.attempts[0].host_hook_events, + ["Approval requested", "In-memory approval fixture marker written", "Approval hook threw"]); assert.equal(byId.approval_mismatch.claim_attempts, 0); assert.equal(byId.policy_revoked.claim_attempts, 0); assert.equal(byId.approval_reused.claim_attempts, 2); diff --git a/test/agent-start.test.js b/test/agent-start.test.js index 4850685b..1aaa76b9 100644 --- a/test/agent-start.test.js +++ b/test/agent-start.test.js @@ -30,6 +30,7 @@ test("the page labels the report as a fixture and offers the exact recheck comma assert.match(page, /consumeApproval: \(input, context\) => hostConsumeGrantOnce/); assert.match(page, /An unknown<\/code> receipt does not mean no side effect occurred/); assert.match(page, /A claim-hook error can leave a grant spent/); + assert.match(page, /An approval-hook error can leave host authorization state changed/); }); test("case radios expose one checked choice and explicit receipt meanings", () => { diff --git a/test/guarded-tool-rehearsal.test.js b/test/guarded-tool-rehearsal.test.js index 72d64e8b..4c2edee4 100644 --- a/test/guarded-tool-rehearsal.test.js +++ b/test/guarded-tool-rehearsal.test.js @@ -30,6 +30,13 @@ test("checked-in browser examples come from the live host adapter rehearsal", as assert.equal(declined.callback_count, 0); assert.deepEqual(declined.attempts[0].host_hook_events, ["Approval requested", "Fixture approval declined"]); + const approvalError = report.cases.find(item => item.id === "approval_hook_error"); + assert.equal(approvalError.attempts[0].reason, "approval_error"); + assert.equal(approvalError.approval_requests, 1); + assert.equal(approvalError.claim_attempts, 0); + assert.equal(approvalError.callback_count, 0); + assert.equal(approvalError.fixture_state_changes, 1); + assert.ok(approvalError.attempts[0].host_hook_events.includes("In-memory approval fixture marker written")); const revoked = report.cases.find(item => item.id === "claim_revoked"); assert.equal(revoked.claims_consumed, 1); assert.equal(revoked.callback_count, 0); @@ -62,8 +69,9 @@ test("one selected case keeps the same observed receipt and callback count", asy test("case list prints each fixture ID and its ordered receipt statuses", async () => { const report = await buildGuardedToolRehearsal(); const listed = listGuardedToolCases(report); - assert.equal(listed.split("\n").filter(Boolean).length, 13); + assert.equal(listed.split("\n").filter(Boolean).length, 14); assert.match(listed, /^host_allow\tdispatched$/m); + assert.match(listed, /^approval_hook_error\tblocked$/m); assert.match(listed, /^approval_declined\tblocked$/m); assert.match(listed, /^approval_reused\tdispatched -> blocked$/m); assert.match(listed, /^claim_hook_unknown\tblocked$/m);