diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index ad75d261..4cc9650d 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -62,6 +62,21 @@ permissions: contents: read jobs: + python-sdk-compat: + runs-on: ubuntu-22.04 + strategy: + matrix: + python: ['3.8', '3.14'] + steps: + - uses: actions/checkout@v7.0.1 + - uses: actions/setup-python@v7.0.0 + with: + python-version: ${{ matrix.python }} + - name: Test dependency-free Python SDK at minimum and current versions + env: + PYTHONPATH: sdk/python/src + run: python -m unittest discover -s sdk/python/tests -p 'test_*.py' + validate: runs-on: ubuntu-latest steps: @@ -134,6 +149,8 @@ jobs: run: | node --test test/sdk-public-source.test.mjs node --test test/local-governance-cli.test.mjs + node --test test/governance-lifecycle.test.mjs + node --test test/governance-receipt-identity.test.mjs node --check sdk/node/index.js node --check sdk/node/index.mjs node --check sdk/agent-os-cli/cli.js diff --git a/sdk/node/README.md b/sdk/node/README.md index d42d94a6..e459b069 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -94,6 +94,26 @@ const safeTool = govern(sendEmail, { The approval callback receives only the normalized action and argument count. Result evidence is shape-only by default, and raw tool inputs or outputs are not persisted. +Callback failures are recorded by phase. Approval failure produces `approval_failed` +without dispatch; evidence failure after a successful tool produces +`completed_evidence_failed`. A failed `onReceipt` raises `receipt_callback_failed` +with the original receipt in both `error.response.receipt` and +`error.agoragenticReceipt`, without writing a second action receipt. Approval, tool, +and evidence exceptions retain their identity and use the `agoragenticReceipt` +access convention when the thrown value can accept properties. `receipts: false` +disables persistence and supplies `null` to receipt callbacks. +Receipt callbacks receive a deep copy, so their mutations cannot change the +canonical receipt retained on notification errors. Nested failures retain the +inner `agoragenticReceipt`; successfully persisted enclosing action receipts are +listed separately in `agoragenticEnclosingReceipts`, from inner to outer. +If recording an approval, tool, or evidence failure itself fails, the original +exception still propagates. Where metadata attachment is possible, +`agoragenticReceiptError` records only `code: "receipt_persistence_failed"`, +`action`, `phase`, and `outcome`; it does not claim a durable receipt or include +storage paths or error messages. Frozen, primitive, or hostile thrown values may +not expose metadata; any successfully persisted receipts remain on disk. +These wrappers do not retry, resume, roll back effects, or provide restart-safe execution. + ## Hosted Router Model `agoragentic` is a thin client to the Agoragentic-hosted Agent OS router. diff --git a/sdk/node/local-governance.js b/sdk/node/local-governance.js index b4c66e08..659a3be0 100644 --- a/sdk/node/local-governance.js +++ b/sdk/node/local-governance.js @@ -229,11 +229,31 @@ function govern(tool, options = {}) { prepareReceiptDirectory(policy, cwd); let approved = options.approved === true; const initial = evaluatePolicy(policy, action, { approved }); + const startedAt = nowIso(options); + const record = (decision, outcome, evidence) => writeReceipt(policy, cwd, buildReceipt({ + options, action, classification: 'local_tool_evidence', decision, + startedAt, finishedAt: nowIso(options), outcome, + evidence: { argument_count: args.length, ...evidence }, + })); + const recordFailure = (error, decision, phase, outcome) => { + try { + attachReceipt(error, record(decision, outcome, { error_code: safeErrorCode(error) })); + } catch { + // The original failure takes precedence; do not claim a durable receipt. + attachMetadata(error, 'agoragenticReceiptError', { + code: 'receipt_persistence_failed', phase, action, outcome, + }); + } + }; if (initial.decision === 'ask' && !approved && typeof options.approve === 'function') { - approved = await options.approve({ action, argument_count: args.length }) === true; + try { + approved = await options.approve({ action, argument_count: args.length }) === true; + } catch (err) { + recordFailure(err, initial, 'approval', 'approval_failed'); + throw err; + } } const decision = evaluatePolicy(policy, action, { approved }); - const startedAt = nowIso(options); if (!decision.execute) { const receipt = writeReceipt(policy, cwd, buildReceipt({ options, @@ -248,42 +268,56 @@ function govern(tool, options = {}) { throw governanceError(decision.reason, `Action ${action} was not executed: ${decision.reason}.`, 3, { receipt }); } + let result; + try { + result = await tool(...args); + } catch (err) { + recordFailure(err, decision, 'tool', 'failed'); + throw err; + } + let evidence; try { - const result = await tool(...args); - const finishedAt = nowIso(options); - const evidence = typeof options.evidence === 'function' + evidence = typeof options.evidence === 'function' ? summarizeEvidence(await options.evidence(result)) : summarizeEvidence(result); - const receipt = writeReceipt(policy, cwd, buildReceipt({ - options, - action, - classification: 'local_tool_evidence', - decision, - startedAt, - finishedAt, - outcome: 'completed', - evidence: { argument_count: args.length, result: evidence }, - })); - if (typeof options.onReceipt === 'function') await options.onReceipt(receipt); - return result; } catch (err) { - const finishedAt = nowIso(options); - const receipt = writeReceipt(policy, cwd, buildReceipt({ - options, - action, - classification: 'local_tool_evidence', - decision, - startedAt, - finishedAt, - outcome: 'failed', - evidence: { argument_count: args.length, error_code: safeErrorCode(err) }, - })); - err.agoragenticReceipt = receipt; + recordFailure(err, decision, 'evidence', 'completed_evidence_failed'); throw err; } + const receipt = record(decision, 'completed', { result: evidence }); + if (typeof options.onReceipt === 'function') { + try { + // Callback-owned copy: retained evidence must match the persisted JSON. + await options.onReceipt(receipt === null ? null : JSON.parse(JSON.stringify(receipt))); + } catch (err) { + const error = governanceError('receipt_callback_failed', 'Receipt callback failed after the tool completed.', 1, { receipt }); + error.agoragenticReceipt = receipt; + error.cause = err; + throw error; + } + } + return result; }; } +function attachReceipt(error, receipt) { + try { + const existing = Object.getOwnPropertyDescriptor(error, 'agoragenticReceipt'); + if (!existing || existing.value == null) { + attachMetadata(error, 'agoragenticReceipt', receipt); + } else if (receipt !== null) { + const enclosing = Object.getOwnPropertyDescriptor(error, 'agoragenticEnclosingReceipts')?.value; + attachMetadata(error, 'agoragenticEnclosingReceipts', [...(Array.isArray(enclosing) ? enclosing : []), receipt]); + } + } catch { /* hostile/primitive/frozen errors retain identity; receipts remain on disk */ } +} + +function attachMetadata(error, key, value) { + try { + Object.defineProperty(error, key, { value, writable: true, configurable: true, enumerable: true }); + } catch { /* diagnostic metadata must never replace the original failure */ } +} + async function runGovernedCommand(executable, args = [], options = {}) { if (!executable || typeof executable !== 'string') { throw governanceError('command_missing', 'A command is required after "agoragentic run --".', 2); @@ -451,7 +485,14 @@ function nowIso(options) { } function safeErrorCode(err) { - return typeof err?.code === 'string' && /^[A-Za-z0-9_.-]{1,64}$/.test(err.code) ? err.code : 'error'; + try { + // Inspect only the own data property: error accessors are untrusted + // executable code, and even descriptor inspection can throw on a Proxy. + const descriptor = Object.getOwnPropertyDescriptor(err, 'code'); + const code = descriptor?.value; + if (typeof code === 'string' && /^[A-Za-z0-9_.-]{1,64}$/.test(code)) return code; + } catch { /* primitive/revoked/hostile values retain a generic bounded code */ } + return 'error'; } function governanceError(code, message, exitCode = 1, response) { diff --git a/sdk/python/README.md b/sdk/python/README.md index ed8b8786..fe4ad689 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -44,6 +44,38 @@ result = safe_tool(recipient="owner@example.com", body="review requested") The approval callback receives only the normalized action, argument count, and keyword names. Local receipts persist result shape rather than raw inputs or outputs, keep spend and retry authority owner-only, and cannot escape the project through configured paths. A receipt proves only the wrapped local call. It is not host, provider, deployment, payment, settlement, or on-chain proof. +Async cancellation propagates `asyncio.CancelledError` with the phase receipt in +`error.agoragentic_receipt`. Cancellation during approval records `approval_failed` +without dispatch; during the tool it records `cancelled_effect_uncertain`, which +does not mean rollback or safe retry. After tool success, cancellation during +evidence capture records `completed_evidence_failed`. Cancellation during receipt +notification retains the original completed receipt and its ID. Ordinary callback +failures retain the existing `GovernanceError.receipt` convention. Disabling +receipts supplies `None` and performs no receipt writes. Neither wrapper retries, +resumes, rolls back effects, or provides restart-safe execution. + +If receipt persistence fails while handling cancellation, the original +`CancelledError` still propagates and the task remains cancelled. +`agoragentic_receipt` is `None` unless a nested governed call already attached +its own durable receipt, which is retained on outer persistence failure. +It is also retained when outer persistence succeeds: enclosing action receipts +are listed separately in `agoragentic_enclosing_receipts`, from inner to outer. +Nested ordinary failures preserve `GovernanceError.receipt` and its +`agoragentic_receipt` alias in the same way. Receipt callbacks receive deep copies, +so callback mutation cannot change the canonical receipt retained on errors. +`agoragentic_receipt_error` contains only `code: "receipt_persistence_failed"`, +the failed action identity, the phase, and the attempted outcome. An inner +action's retained receipt does not prove persistence for the failed outer action. +That marker is not a durable receipt or proof that a partial file was removed; +it contains no filesystem path or persistence-exception message. +The same bounded marker accompanies ordinary failure-recording errors while +preserving tool exceptions and existing callback `GovernanceError` cause chains. +All exception metadata is best-effort: a subclass that rejects attribute writes +still propagates unchanged, and successfully persisted receipts remain on disk. +These identity guarantees apply at the governed coroutine boundary. Python 3.8 +may replace `CancelledError` when it crosses a cancelled `Task` boundary; catch +inside that task to inspect metadata, or reconcile the persisted receipts. + ## Hosted Router Model `agoragentic` is a thin client to the Agoragentic-hosted Agent OS router. diff --git a/sdk/python/src/agoragentic/governance.py b/sdk/python/src/agoragentic/governance.py index 0dfd755e..6d06c7d2 100644 --- a/sdk/python/src/agoragentic/governance.py +++ b/sdk/python/src/agoragentic/governance.py @@ -7,6 +7,8 @@ from __future__ import annotations +import asyncio +import copy import functools import inspect import json @@ -192,8 +194,8 @@ def wrapped(*args: Any, **kwargs: Any) -> Any: error = exc else: error = GovernanceError("approval_failed", "Approval callback failed.") - receipt = _write_pre_execution_failure(context, request, error.code) - error.receipt = receipt + _record_failure(error, context, "approval", "approval_failed", + lambda: _write_pre_execution_failure(context, request, error.code)) raise error from exc decision = evaluate_policy(context["policy"], context["action"], approved=resolved_approval) @@ -201,11 +203,8 @@ def wrapped(*args: Any, **kwargs: Any) -> Any: try: result = tool(*args, **kwargs) except Exception as exc: - receipt = _write_failed_receipt(context, decision, request, exc) - try: - setattr(exc, "agoragentic_receipt", receipt) - except (AttributeError, TypeError): - pass + _record_failure(exc, context, "tool", "failed", + lambda: _write_failed_receipt(context, decision, request, exc)) raise if inspect.isawaitable(result): if hasattr(result, "close"): @@ -214,7 +213,8 @@ def wrapped(*args: Any, **kwargs: Any) -> Any: "tool_result_async", "A synchronous governed tool returned an awaitable; declare the tool with async def.", ) - error.receipt = _write_evidence_failure(context, decision, request, error) + _record_failure(error, context, "evidence", "completed_evidence_failed", + lambda: _write_evidence_failure(context, decision, request, error)) raise error try: evidence_result = options["evidence"](result) if options["evidence"] else result @@ -230,7 +230,8 @@ def wrapped(*args: Any, **kwargs: Any) -> Any: "evidence_failed", "Post-action evidence capture failed after the tool completed.", ) - error.receipt = _write_evidence_failure(context, decision, request, error) + _record_failure(error, context, "evidence", "completed_evidence_failed", + lambda: _write_evidence_failure(context, decision, request, error)) raise error from exc receipt = _write_receipt( context, @@ -259,45 +260,59 @@ async def wrapped(*args: Any, **kwargs: Any) -> Any: if inspect.isawaitable(approval_result): approval_result = await approval_result resolved_approval = approval_result is True + except asyncio.CancelledError as exc: + _record_cancellation(context, context["initial"], request, exc, + phase="approval", outcome="approval_failed") + raise except Exception as exc: error = GovernanceError("approval_failed", "Approval callback failed.") - receipt = _write_pre_execution_failure(context, request, error.code) - error.receipt = receipt + _record_failure(error, context, "approval", "approval_failed", + lambda: _write_pre_execution_failure(context, request, error.code)) raise error from exc decision = evaluate_policy(context["policy"], context["action"], approved=resolved_approval) _raise_if_blocked(context, decision, request) try: result = await tool(*args, **kwargs) + except asyncio.CancelledError as exc: + _record_cancellation(context, decision, request, exc, + phase="tool", outcome="cancelled_effect_uncertain") + raise except Exception as exc: - receipt = _write_failed_receipt(context, decision, request, exc) - try: - setattr(exc, "agoragentic_receipt", receipt) - except (AttributeError, TypeError): - pass + _record_failure(exc, context, "tool", "failed", + lambda: _write_failed_receipt(context, decision, request, exc)) raise try: evidence_result = options["evidence"](result) if options["evidence"] else result if inspect.isawaitable(evidence_result): evidence_result = await evidence_result + summary = _summarize_evidence(evidence_result) + except asyncio.CancelledError as exc: + _record_cancellation(context, decision, request, exc, + phase="evidence", outcome="completed_evidence_failed") + raise except Exception as exc: error = GovernanceError( "evidence_failed", "Post-action evidence capture failed after the tool completed.", ) - error.receipt = _write_evidence_failure(context, decision, request, exc) + _record_failure(error, context, "evidence", "completed_evidence_failed", + lambda: _write_evidence_failure(context, decision, request, exc)) raise error from exc receipt = _write_receipt( context, decision, outcome="completed", - evidence=dict(request, result=_summarize_evidence(evidence_result)), + evidence=dict(request, result=summary), ) if options["on_receipt"]: try: - callback_result = options["on_receipt"](receipt) + callback_result = options["on_receipt"](copy.deepcopy(receipt)) if inspect.isawaitable(callback_result): await callback_result + except asyncio.CancelledError as exc: + _attach_receipt(exc, receipt) + raise except Exception as exc: raise GovernanceError( "receipt_callback_failed", @@ -309,6 +324,65 @@ async def wrapped(*args: Any, **kwargs: Any) -> Any: return wrapped +def _record_cancellation( + context: Dict[str, Any], + decision: Dict[str, Any], + request: Dict[str, Any], + cancellation: asyncio.CancelledError, + *, + phase: str, + outcome: str, +) -> None: + _record_failure(cancellation, context, phase, outcome, + lambda: _write_receipt( + context, decision, outcome=outcome, + evidence=dict(request, error_code="CancelledError"), + ) + ) + + +def _attach_metadata(error: BaseException, name: str, value: Any) -> None: + try: + setattr(error, name, value) + except BaseException: + # User exception subclasses may reject metadata with any exception. + # Only this diagnostic operation is guarded, never the governed work. + pass + + +def _attach_receipt(error: BaseException, receipt: Optional[Dict[str, Any]]) -> None: + try: + metadata = object.__getattribute__(error, "__dict__") + existing = metadata.get("agoragentic_receipt") + if existing is None and isinstance(error, GovernanceError): + existing = metadata.get("receipt") + if existing is None: + _attach_metadata(error, "agoragentic_receipt", receipt) + if isinstance(error, GovernanceError): + _attach_metadata(error, "receipt", receipt) + else: + _attach_metadata(error, "agoragentic_receipt", existing) + if receipt is not None: + enclosing = metadata.get("agoragentic_enclosing_receipts", []) + _attach_metadata(error, "agoragentic_enclosing_receipts", [*enclosing, receipt]) + except BaseException: + pass # Preserve hostile cancellation/error subclasses and durable disk evidence. + + +def _record_failure(error: BaseException, context: Dict[str, Any], phase: str, + outcome: str, write: Callable[[], Optional[Dict[str, Any]]]) -> None: + try: + receipt = write() + except Exception: + _attach_receipt(error, None) + _attach_metadata(error, "agoragentic_receipt_error", { + "code": "receipt_persistence_failed", "phase": phase, + "action": context["action"], "outcome": outcome, + }) + else: + _attach_receipt(error, receipt) + + def _prepare_invocation(options: Dict[str, Any]) -> Dict[str, Any]: root = _project_root(options["cwd"]) loaded = load_policy(options["policy_source"], cwd=root) @@ -364,7 +438,7 @@ def _write_evidence_failure( context: Dict[str, Any], decision: Dict[str, Any], request: Dict[str, Any], - error: Exception, + error: BaseException, ) -> Optional[Dict[str, Any]]: return _write_receipt( context, @@ -504,7 +578,7 @@ def _notify_sync( if callback is None: return try: - result = callback(receipt) + result = callback(copy.deepcopy(receipt)) if inspect.isawaitable(result): if hasattr(result, "close"): result.close() diff --git a/sdk/python/tests/test_governance_lifecycle.py b/sdk/python/tests/test_governance_lifecycle.py new file mode 100644 index 00000000..96e60c2c --- /dev/null +++ b/sdk/python/tests/test_governance_lifecycle.py @@ -0,0 +1,276 @@ +import asyncio +import json +import tempfile +import unittest +from pathlib import Path + +from agoragentic.governance import GovernanceError, create_default_policy, govern + + +async def observe_cancellation(awaitable, observed): + # Python 3.8 Task.result/await may create a fresh CancelledError. Observe the + # wrapper boundary inside the task to test SDK identity and metadata. + try: + return await awaitable + except asyncio.CancelledError as exc: + observed.append(exc) + raise + + +class CancellationTests(unittest.IsolatedAsyncioTestCase): + async def test_outer_persistence_failure_retains_inner_notification_receipt(self): + with tempfile.TemporaryDirectory() as root: + policy = create_default_policy() + policy["default_decision"] = "allow" + inner_root = Path(root, "inner") + inner_root.mkdir() + outer_root = Path(root, "outer") + outer_root.mkdir() + entered = asyncio.Event() + delivered = [] + cancellations = [] + + async def inner_tool(): + Path(inner_root, "effect.txt").write_text("one bounded write") + + async def notify(receipt): + delivered.append(receipt) + entered.set() + try: + await asyncio.Future() + except asyncio.CancelledError as exc: + cancellations.append(exc) + raise + + inner = govern(inner_tool, action="inner.write", policy=policy, + cwd=inner_root, on_receipt=notify) + + async def outer_tool(): + await inner() + + observed = [] + task = asyncio.create_task(observe_cancellation(govern(outer_tool, action="outer.run", policy=policy, + cwd=outer_root)(), observed)) + await asyncio.wait_for(entered.wait(), 2) + directory = outer_root / ".agoragentic" / "receipts" + directory.rmdir() + directory.write_text("obstruction") + task.cancel() + with self.assertRaises(asyncio.CancelledError) as raised: + await task + self.assertTrue(task.cancelled()) + self.assertIs(observed[0], cancellations[0]) + self.assertEqual(observed[0].agoragentic_receipt, delivered[0]) + self.assertEqual(delivered[0]["action"], "inner.write") + persisted = json.loads((inner_root / delivered[0]["path"]).read_text()) + self.assertEqual(persisted["receipt_id"], delivered[0]["receipt_id"]) + self.assertEqual(persisted["outcome"], "completed") + self.assertEqual(observed[0].agoragentic_receipt_error, { + "code": "receipt_persistence_failed", "phase": "tool", + "action": "outer.run", "outcome": "cancelled_effect_uncertain", + }) + self.assertEqual(len(list(Path(root).rglob("*.json"))), 1) + + async def test_cancellation_survives_receipt_persistence_failure_in_each_phase(self): + for phase in ("approval", "tool", "evidence"): + with self.subTest(phase=phase), tempfile.TemporaryDirectory() as root: + policy = create_default_policy() + entered = asyncio.Event() + cancellations = [] + + async def pause(at): + if at == phase: + entered.set() + try: + await asyncio.Future() + except asyncio.CancelledError as exc: + cancellations.append(exc) + raise + + async def approve(request): + await pause("approval") + return True + + async def tool(): + Path(root, "effect.txt").write_text("one bounded write") + await pause("tool") + return "private-result" + + async def evidence(result): + await pause("evidence") + return result + + observed = [] + task = asyncio.create_task(observe_cancellation(govern(tool, action="file.write", policy=policy, + cwd=root, approve=approve, evidence=evidence)(), observed)) + await asyncio.wait_for(entered.wait(), 2) + directory = Path(root, ".agoragentic", "receipts") + directory.rmdir() # Verified empty fixture directory, inside TemporaryDirectory. + directory.write_text("private-obstruction") + task.cancel() + with self.assertRaises(asyncio.CancelledError) as raised: + await task + self.assertTrue(task.cancelled()) + self.assertIs(observed[0], cancellations[0]) + self.assertIsNone(observed[0].agoragentic_receipt) + self.assertEqual(observed[0].agoragentic_receipt_error, { + "code": "receipt_persistence_failed", "phase": phase, "action": "file.write", + "outcome": {"approval": "approval_failed", "tool": "cancelled_effect_uncertain", + "evidence": "completed_evidence_failed"}[phase], + }) + self.assertEqual(Path(root, "effect.txt").exists(), phase != "approval") + self.assertEqual(directory.read_text(), "private-obstruction") + self.assertEqual(list(Path(root).rglob("*.json")), []) + marker = json.dumps(observed[0].agoragentic_receipt_error) + self.assertNotIn("private-", marker) + self.assertNotIn(root, marker) + + async def test_async_callback_failures_and_disabled_receipts(self): + for enabled in (True, False): + for phase in ("approval", "evidence", "notification"): + with self.subTest(enabled=enabled, phase=phase), tempfile.TemporaryDirectory() as root: + policy = create_default_policy() + calls = [] + delivered = [] + + async def fail(at): + if phase == at: + raise RuntimeError("private-callback-error") + + async def approve(request): + await fail("approval") + return True + + async def tool(payload): + calls.append(True) + return payload + + async def evidence(result): + await fail("evidence") + return result + + async def notify(receipt): + delivered.append(receipt) + await fail("notification") + + wrapped = govern(tool, action="tool.run", policy=policy, cwd=root, + receipts=enabled, approve=approve, evidence=evidence, on_receipt=notify) + with self.assertRaises(GovernanceError) as raised: + await wrapped("private-payload") + self.assertEqual(len(calls), 0 if phase == "approval" else 1) + files = list(Path(root, ".agoragentic/receipts").glob("*.json")) + self.assertEqual(len(files), int(enabled)) + if enabled: + raw = files[0].read_text() + receipt = json.loads(raw) + self.assertEqual(receipt["outcome"], {"approval": "approval_failed", + "evidence": "completed_evidence_failed", "notification": "completed"}[phase]) + self.assertEqual(raised.exception.receipt["receipt_id"], receipt["receipt_id"]) + self.assertNotIn("private-", raw) + if phase == "notification": + self.assertEqual(delivered[0]["receipt_id"], receipt["receipt_id"]) + else: + self.assertIsNone(raised.exception.receipt) + self.assertFalse(Path(root, ".agoragentic").exists()) + + async def test_integrated_policy_flow(self): + with tempfile.TemporaryDirectory() as root: + policy = create_default_policy() + policy["actions"] = {"*": {"decision": "deny"}, "file.read": {"decision": "allow"}, + "file.write": {"decision": "ask"}} + effects = [] + + async def read(): + return "ready" + + async def write(): + effects.append(True) + Path(root, "bounded.txt").write_text("one") + return "private-result" + + self.assertEqual(await govern(read, action="file.read", policy=policy, cwd=root)(), "ready") + with self.assertRaises(GovernanceError): + await govern(write, action="file.write", policy=policy, cwd=root)() + self.assertEqual(effects, []) + entered = asyncio.Event() + approved = asyncio.Future() + + async def approve(request): + entered.set() + return await approved + + task = asyncio.create_task(govern(write, action="file.write", policy=policy, + cwd=root, approve=approve)()) + await asyncio.wait_for(entered.wait(), 2) + self.assertEqual(effects, []) + approved.set_result(True) + self.assertEqual(await task, "private-result") + policy["actions"]["file.write"] = {"decision": "deny"} + with self.assertRaises(GovernanceError): + await govern(write, action="file.write", policy=policy, cwd=root, approved=True, + approve=lambda _: self.fail("deny bypass"))() + self.assertEqual(effects, [True]) + receipts = [json.loads(p.read_text()) for p in Path(root, ".agoragentic/receipts").glob("*.json")] + self.assertEqual(len(receipts), 4) + self.assertNotIn("private-result", json.dumps(receipts)) + + async def test_phase_cancellation_persists_one_receipt_and_propagates(self): + phases = ("approval", "tool", "evidence", "notification") + for phase, enabled in ((phase, enabled) for phase in phases for enabled in (True, False)): + with self.subTest(phase=phase, enabled=enabled), tempfile.TemporaryDirectory() as root: + policy = create_default_policy() + policy["actions"]["file.write"] = {"decision": "ask"} + entered = asyncio.Event() + delivered = [] + + async def pause(at): + if at == phase: + entered.set() + await asyncio.Future() + + async def approve(request): + await pause("approval") + return True + + async def tool(payload): + Path(root, "effect.txt").write_text("one bounded write") + await pause("tool") + return payload + + async def evidence(result): + await pause("evidence") + return result + + async def notify(receipt): + delivered.append(receipt) + await pause("notification") + + wrapped = govern(tool, action="file.write", policy=policy, cwd=root, + receipts=enabled, approve=approve, evidence=evidence, on_receipt=notify) + observed = [] + task = asyncio.create_task(observe_cancellation(wrapped("private-payload-and-error"), observed)) + await asyncio.wait_for(entered.wait(), 2) + task.cancel() + with self.assertRaises(asyncio.CancelledError) as raised: + await task + self.assertTrue(task.cancelled()) + files = list(Path(root, ".agoragentic/receipts").glob("*.json")) + if not enabled: + self.assertEqual(files, []) + self.assertIsNone(observed[0].agoragentic_receipt) + self.assertFalse(Path(root, ".agoragentic").exists()) + continue + self.assertEqual(len(files), 1) + raw = files[0].read_text() + receipt = json.loads(raw) + self.assertEqual(receipt["outcome"], { + "approval": "approval_failed", "tool": "cancelled_effect_uncertain", + "evidence": "completed_evidence_failed", "notification": "completed", + }[phase]) + self.assertEqual(Path(root, "effect.txt").exists(), phase != "approval") + self.assertNotIn("private-payload-and-error", raw) + self.assertEqual(observed[0].agoragentic_receipt["receipt_id"], receipt["receipt_id"]) + for flag in ("provider_execution", "payment", "settlement"): + self.assertFalse(receipt["proof_scope"][flag]) + if phase == "notification": + self.assertEqual(delivered[0]["receipt_id"], receipt["receipt_id"]) diff --git a/sdk/python/tests/test_receipt_identity.py b/sdk/python/tests/test_receipt_identity.py new file mode 100644 index 00000000..1aa4c94e --- /dev/null +++ b/sdk/python/tests/test_receipt_identity.py @@ -0,0 +1,170 @@ +import asyncio +import json +import tempfile +import unittest +from pathlib import Path + +from agoragentic.governance import GovernanceError, create_default_policy, govern + + +class HostileCancellation(asyncio.CancelledError): + def __setattr__(self, name, value): + if name.startswith('agoragentic_'): + raise RuntimeError('private-metadata-error') + super().__setattr__(name, value) + + @property + def agoragentic_receipt(self): + raise RuntimeError('private-property-error') + + +def policy(): + result = create_default_policy() + result['default_decision'] = 'allow' + return result + + +class ReceiptIdentityTests(unittest.IsolatedAsyncioTestCase): + async def test_original_failures_survive_storage_failure_sync_and_async(self): + for asynchronous in (False, True): + for phase in ('approval', 'tool', 'evidence'): + with self.subTest(asynchronous=asynchronous, phase=phase), tempfile.TemporaryDirectory() as root: + p = policy() + p['default_decision'] = 'ask' + original = RuntimeError('private-original') + effects = [] + + def fail(at): + if at == phase: + directory = Path(root, '.agoragentic/receipts') + directory.rmdir() + directory.write_text('private-obstruction') + raise original + + def tool(): + effects.append(1) + fail('tool') + return 'private-result' + + async def async_tool(): + return tool() + + wrapped = govern(async_tool if asynchronous else tool, action='file.write', + policy=p, cwd=root, approve=lambda _: fail('approval') or True, + evidence=lambda _: fail('evidence')) + with self.assertRaises(RuntimeError) as raised: + if asynchronous: + await wrapped() + else: + wrapped() + error = raised.exception + self.assertIs(error if phase == 'tool' else error.__cause__, original) + self.assertIsNone(error.agoragentic_receipt) + self.assertEqual(error.agoragentic_receipt_error, { + 'code': 'receipt_persistence_failed', 'phase': phase, 'action': 'file.write', + 'outcome': {'approval': 'approval_failed', 'tool': 'failed', + 'evidence': 'completed_evidence_failed'}[phase]}) + self.assertEqual(len(effects), int(phase != 'approval')) + self.assertEqual(list(Path(root).rglob('*.json')), []) + + async def test_hostile_cancellation_preserved_in_all_phases(self): + for phase in ('approval', 'tool', 'evidence', 'notification'): + for obstruct in (False, True): + with self.subTest(phase=phase, obstruct=obstruct), tempfile.TemporaryDirectory() as root: + p = policy() + p['default_decision'] = 'ask' + original = HostileCancellation('private-cancel') + effects = [] + + async def fail(at): + if phase == at: + if obstruct and phase != 'notification': + directory = Path(root, '.agoragentic/receipts') + directory.rmdir() + directory.write_text('private-obstruction') + raise original + + async def approve(_): + await fail('approval') + return True + + async def tool(): + effects.append(1) + await fail('tool') + return 'private-result' + + async def evidence(_): + await fail('evidence') + + async def notify(_): + await fail('notification') + + with self.assertRaises(asyncio.CancelledError) as raised: + await govern(tool, action='file.write', policy=p, cwd=root, + approve=approve, evidence=evidence, on_receipt=notify)() + self.assertIs(raised.exception, original) + self.assertEqual(len(effects), int(phase != 'approval')) + receipts = [json.loads(f.read_text()) for f in Path(root).rglob('*.json')] + self.assertEqual(len(receipts), int(not obstruct or phase == 'notification')) + if receipts: + self.assertEqual(receipts[0]['outcome'], {'approval': 'approval_failed', + 'tool': 'cancelled_effect_uncertain', 'evidence': 'completed_evidence_failed', + 'notification': 'completed'}[phase]) + self.assertNotIn('private-', json.dumps(receipts)) + + async def test_nested_cancellation_keeps_inner_receipt_with_successful_outer_write(self): + with tempfile.TemporaryDirectory() as root: + original = asyncio.CancelledError('private-cancel') + + async def tool(): + return 'done' + + async def notify(receipt): + receipt['receipt_id'] = 'nonexistent' + receipt['proof_scope']['payment'] = True + raise original + + inner = govern(tool, action='inner.run', policy=policy(), cwd=root, on_receipt=notify) + outer = govern(inner, action='outer.run', policy=policy(), cwd=root) + with self.assertRaises(asyncio.CancelledError) as raised: + await outer() + self.assertIs(raised.exception, original) + receipts = [json.loads(f.read_text()) for f in Path(root).rglob('*.json')] + self.assertEqual(len(receipts), 2) + self.assertEqual(original.agoragentic_receipt['action'], 'inner.run') + self.assertFalse(original.agoragentic_receipt['proof_scope']['payment']) + enclosing = original.agoragentic_enclosing_receipts + self.assertEqual([r['action'] for r in enclosing], ['outer.run']) + self.assertEqual(enclosing[0]['outcome'], 'cancelled_effect_uncertain') + self.assertEqual({r['receipt_id'] for r in receipts}, + {original.agoragentic_receipt['receipt_id'], enclosing[0]['receipt_id']}) + + async def test_nested_notification_errors_and_mutation_sync_and_async(self): + for asynchronous in (False, True): + with self.subTest(asynchronous=asynchronous), tempfile.TemporaryDirectory() as root: + def notify(receipt): + receipt['receipt_id'] = 'nonexistent' + receipt['action'] = 'wrong.action' + receipt['proof_scope']['payment'] = True + raise RuntimeError('private-callback') + + async def async_tool(): + return 'done' + + inner = govern(async_tool if asynchronous else lambda: 'done', action='inner.run', + policy=policy(), cwd=root, on_receipt=notify) + outer = govern(inner, action='outer.run', policy=policy(), cwd=root) + with self.assertRaises(GovernanceError) as raised: + if asynchronous: + await outer() + else: + outer() + error = raised.exception + self.assertEqual(error.code, 'receipt_callback_failed') + self.assertEqual(error.receipt['action'], 'inner.run') + self.assertIs(error.agoragentic_receipt, error.receipt) + self.assertFalse(error.receipt['proof_scope']['payment']) + self.assertEqual([r['action'] for r in error.agoragentic_enclosing_receipts], ['outer.run']) + receipts = [json.loads(f.read_text()) for f in Path(root).rglob('*.json')] + self.assertEqual(len(receipts), 2) + self.assertIn(error.receipt['receipt_id'], [r['receipt_id'] for r in receipts]) diff --git a/test/governance-lifecycle.test.mjs b/test/governance-lifecycle.test.mjs new file mode 100644 index 00000000..1eb5cc68 --- /dev/null +++ b/test/governance-lifecycle.test.mjs @@ -0,0 +1,140 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { govern, createDefaultPolicy } from '../sdk/node/local-governance.mjs'; + +const secret = 'private-payload-and-error'; +for (const phase of ['approval', 'tool', 'evidence']) { + for (const hostile of ['accessor', 'proxy']) { + test(`${phase} failure safely records a ${hostile} error code`, async (t) => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'govern-error-code-')); + t.after(() => fs.rmSync(cwd, { recursive: true, force: true })); + const policy = createDefaultPolicy(); + policy.default_decision = phase === 'approval' ? 'ask' : 'allow'; + let accessorCalls = 0; + let effects = 0; + const failure = hostile === 'accessor' + ? Object.defineProperty(new Error(secret), 'code', { get() { + accessorCalls += 1; + throw new Error('private-getter-error'); + } }) + : new Proxy(new Error(secret), { getOwnPropertyDescriptor() { + throw new Error('private-descriptor-error'); + } }); + const fail = () => { throw failure; }; + const wrapped = govern(() => { + effects += 1; + if (phase === 'tool') fail(); + return secret; + }, { cwd, policy, action: 'tool.run', approve: fail, + evidence: phase === 'evidence' ? fail : undefined }); + let caught; + try { await wrapped(); } catch (error) { caught = error; } + assert.equal(caught, failure); + assert.equal(accessorCalls, 0); + assert.equal(effects, phase === 'approval' ? 0 : 1); + const directory = path.join(cwd, '.agoragentic/receipts'); + const files = fs.readdirSync(directory); + assert.equal(files.length, 1); + const raw = fs.readFileSync(path.join(directory, files[0]), 'utf8'); + const receipt = JSON.parse(raw); + assert.equal(receipt.outcome, { approval: 'approval_failed', tool: 'failed', evidence: 'completed_evidence_failed' }[phase]); + assert.equal(receipt.evidence.error_code, 'error'); + assert.ok(!raw.includes('private-')); + }); + } +} + +for (const phase of ['approval', 'tool', 'evidence', 'notification']) { + test(`persist one truthful receipt on ${phase} failure`, async (t) => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'govern-lifecycle-')); + t.after(() => fs.rmSync(cwd, { recursive: true, force: true })); + const policy = createDefaultPolicy(); + policy.actions['file.write'] = { decision: phase === 'approval' ? 'ask' : 'allow' }; + let delivered; + const failure = new Error(secret); + const fail = () => { throw failure; }; + const wrapped = govern(() => { + fs.writeFileSync(path.join(cwd, 'effect.txt'), 'one bounded write'); + if (phase === 'tool') fail(); + return secret; + }, { + cwd, policy, action: 'file.write', + approve: fail, + evidence: phase === 'evidence' ? fail : undefined, + onReceipt: (receipt) => { delivered = receipt; if (phase === 'notification') fail(); }, + }); + let error; + try { await wrapped(secret); } catch (caught) { error = caught; } + assert.ok(error); + const files = fs.readdirSync(path.join(cwd, '.agoragentic/receipts')); + assert.equal(files.length, 1); + const raw = fs.readFileSync(path.join(cwd, '.agoragentic/receipts', files[0]), 'utf8'); + const receipt = JSON.parse(raw); + assert.equal(receipt.outcome, { approval: 'approval_failed', tool: 'failed', evidence: 'completed_evidence_failed', notification: 'completed' }[phase]); + assert.equal(fs.existsSync(path.join(cwd, 'effect.txt')), phase !== 'approval'); + assert.equal(receipt.schema, 'agoragentic.local-action-receipt.v1'); + assert.equal(receipt.decision.authority.retry, 'owner_only'); + for (const flag of ['provider_execution', 'payment', 'settlement']) assert.equal(receipt.proof_scope[flag], false); + assert.ok(!raw.includes(secret)); + assert.equal(error.agoragenticReceipt.receipt_id, receipt.receipt_id); + if (phase === 'notification') { + assert.equal(error.code, 'receipt_callback_failed'); + assert.equal(error.cause, failure); + assert.equal(delivered.receipt_id, receipt.receipt_id); + } else assert.equal(error, failure); + }); +} + +test('allow, paused ask, explicit approval, and deny share one bounded local fixture', async (t) => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'govern-flow-')); + t.after(() => fs.rmSync(cwd, { recursive: true, force: true })); + const policy = createDefaultPolicy(); + policy.actions = { '*': { decision: 'deny' }, 'file.read': { decision: 'allow' }, 'file.write': { decision: 'ask' } }; + const effects = []; + const tool = () => { effects.push(1); fs.writeFileSync(path.join(cwd, 'bounded.txt'), 'one'); return secret; }; + assert.equal(await govern(() => 'ready', { cwd, policy, action: 'file.read' })(), 'ready'); + await assert.rejects(govern(tool, { cwd, policy, action: 'file.write' })(), { code: 'explicit_approval_required' }); + assert.equal(effects.length, 0); + let approve; + let entered; + const pending = new Promise(resolve => { entered = resolve; }); + const invocation = govern(tool, { cwd, policy, action: 'file.write', approve: () => { + entered(); return new Promise(resolve => { approve = resolve; }); + } })(); + await pending; + assert.equal(effects.length, 0); + approve(true); + assert.equal(await invocation, secret); + assert.equal(effects.length, 1); + policy.actions['file.write'] = { decision: 'deny' }; + await assert.rejects(govern(tool, { cwd, policy, action: 'file.write', approved: true, + approve: () => assert.fail('deny must not call approval'), + })(), { code: 'policy_denied' }); + assert.equal(effects.length, 1); + const receipts = fs.readdirSync(path.join(cwd, '.agoragentic/receipts')).map(name => + JSON.parse(fs.readFileSync(path.join(cwd, '.agoragentic/receipts', name)))); + assert.equal(receipts.length, 4); + assert.equal(receipts.filter(r => r.outcome === 'completed').length, 2); + assert.ok(!JSON.stringify(receipts).includes(secret)); +}); + +test('receipts:false retains returns and failure phases without persistence', async (t) => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'govern-disabled-')); + t.after(() => fs.rmSync(cwd, { recursive: true, force: true })); + const policy = createDefaultPolicy(); + policy.default_decision = 'allow'; + const options = { cwd, policy, action: 'tool.run', receipts: false }; + let notified = 'unset'; + assert.equal(await govern(() => secret, { ...options, onReceipt: r => { notified = r; } })(), secret); + assert.equal(notified, null); + for (const failure of ['primitive', Object.freeze(new Error(secret))]) { + try { await govern(() => { throw failure; }, options)(); assert.fail('must throw'); } + catch (error) { assert.equal(error, failure); } + } + await assert.rejects(govern(() => secret, { ...options, onReceipt: () => { throw new Error(secret); } })(), + error => error.code === 'receipt_callback_failed' && error.agoragenticReceipt === null); + assert.equal(fs.existsSync(path.join(cwd, '.agoragentic')), false); +}); diff --git a/test/governance-receipt-identity.test.mjs b/test/governance-receipt-identity.test.mjs new file mode 100644 index 00000000..b960280e --- /dev/null +++ b/test/governance-receipt-identity.test.mjs @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { govern, createDefaultPolicy } from '../sdk/node/local-governance.mjs'; + +function fixture(t) { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'receipt-identity-')); + t.after(() => fs.rmSync(cwd, { recursive: true, force: true })); + const policy = createDefaultPolicy(); + policy.default_decision = 'allow'; + const directory = path.join(cwd, '.agoragentic/receipts'); + return { cwd, policy, directory, read: () => fs.readdirSync(directory).map(name => + JSON.parse(fs.readFileSync(path.join(directory, name), 'utf8'))) }; +} + +for (const phase of ['approval', 'tool', 'evidence']) { + test(`${phase} original error survives failed receipt persistence`, async t => { + const f = fixture(t); + if (phase === 'approval') f.policy.default_decision = 'ask'; + const original = new Error('private-original'); + let effects = 0; + const fail = () => { + fs.rmdirSync(f.directory); + fs.writeFileSync(f.directory, 'private-obstruction'); + throw original; + }; + const wrapped = govern(() => { effects++; if (phase === 'tool') fail(); return 'private-result'; }, { + ...f, action: 'file.write', approve: fail, evidence: phase === 'evidence' ? fail : undefined, + }); + await assert.rejects(wrapped(), error => { + assert.equal(error, original); + assert.equal(error.agoragenticReceipt, undefined); + assert.deepEqual(error.agoragenticReceiptError, { code: 'receipt_persistence_failed', phase, + action: 'file.write', outcome: { approval: 'approval_failed', tool: 'failed', evidence: 'completed_evidence_failed' }[phase] }); + return true; + }); + assert.equal(effects, phase === 'approval' ? 0 : 1); + assert.equal(fs.readFileSync(f.directory, 'utf8'), 'private-obstruction'); + }); +} + +for (const enabled of [true, false]) { + test(`nested notification failure retains inner identity (outer receipts ${enabled})`, async t => { + const f = fixture(t); + let innerError; + const inner = govern(() => 'done', { ...f, action: 'inner.run', onReceipt() { throw new Error('private'); } }); + const middle = govern(async () => { try { await inner(); } catch (e) { innerError = e; throw e; } }, + { ...f, action: 'middle.run', receipts: enabled }); + const outer = govern(middle, { ...f, action: 'outer.run' }); + await assert.rejects(outer(), error => { + assert.equal(error, innerError); + assert.equal(error.agoragenticReceipt, error.response.receipt); + assert.equal(error.agoragenticReceipt.action, 'inner.run'); + assert.deepEqual(error.agoragenticEnclosingReceipts.map(r => r.action), enabled ? ['middle.run', 'outer.run'] : ['outer.run']); + const receipts = f.read(); + assert.equal(receipts.length, enabled ? 3 : 2); + for (const receipt of [error.agoragenticReceipt, ...error.agoragenticEnclosingReceipts]) { + assert.equal(receipts.find(r => r.receipt_id === receipt.receipt_id).action, receipt.action); + } + return true; + }); + }); +} + +test('notification mutation cannot alter canonical persisted receipt references', async t => { + const f = fixture(t); + const wrapped = govern(() => 'private-result', { ...f, action: 'file.write', onReceipt(receipt) { + receipt.receipt_id = 'nonexistent'; + receipt.action = 'wrong.action'; + receipt.proof_scope.payment = true; + throw new Error('private-callback'); + } }); + await assert.rejects(wrapped(), error => { + const receipts = f.read(); + assert.equal(receipts.length, 1); + assert.equal(error.agoragenticReceipt, error.response.receipt); + assert.equal(error.agoragenticReceipt.receipt_id, receipts[0].receipt_id); + assert.equal(error.agoragenticReceipt.action, 'file.write'); + assert.equal(error.agoragenticReceipt.proof_scope.payment, false); + return true; + }); +});