Safe agent plan boundary

@honua/sdk-js/agent-safety is a deterministic trust boundary between untrusted plan proposals and host-owned effect execution. It is part of the SDK's stable tier (semver-protected; see docs/decisions/agent-surface-stabilization.md), and its security posture — envelope forgery, replay, effect-budget bypass, receipt tampering, plan-fingerprint mismatch — is documented with per-threat conformance tests in the agent-safety threat model. It validates JSON-compatible structured data, produces an immutable dry run and effect budget, binds a reviewer signature to the exact plan and policy, revalidates current source context, executes an exact approved operation through a host-supplied adapter, and signs/verifies execution evidence.

Plan normalization, dry run, approval, and verification never invoke a model, tool, source, renderer, subscription, or job. executeAgentPlanStep is the explicit effect boundary; it invokes only a matching host executor after authorization, single-use consumption, and a durable start audit. @honua/sdk-js/agent-tools remains the runtime action adapter, and @honua/sdk-js/query-planner remains the source of query-plan fingerprints.

Plan and policy

Every step declares its tool, effect, fields, row and byte ceilings, exact query plan fingerprint, canonical parameters digest, canonical operation-input digest, and source binding. The operation digest is recomputed from the visible tool, effect, source ID, query-plan identity, fields, and parameters digest, so those claims cannot disagree. The source binding includes schema/source versions, stable authorization-scope identifiers, data mode, observation time, attribution, and credential-free citations. The plan also identifies its actor and can identify the proposing provider/model. Do not put prompts, credentials, tokens, or tool arguments in this envelope.

import {
  dryRunAgentPlan,
  issueAgentApproval,
  executeAgentPlanStep,
} from "@honua/sdk-js/agent-safety";

const policy = {
  allowedTools: ["query"],
  // Omission is deliberately read-only. Other effects require explicit opt-in.
  allowedEffects: ["read"],
  sources: {
    incidents: {
      fields: ["OBJECTID", "status"],
      authorizationScope: ["incidents:read"],
      schemaVersions: ["schema-7"],
      sourceVersions: ["snapshot-9"],
      dataModes: ["live"],
      maxProvenanceAgeMs: 60_000,
      citationOrigins: ["https://data.example.test"],
      citationResourcePrefixes: ["/incidents"],
    },
  },
  maxSteps: 1,
  maxRows: 500,
  maxBytes: 2_000_000,
  maxFieldsPerStep: 16,
  maxAuthorizationScopesPerSource: 8,
  maxCitationsPerSource: 4,
  maxOperationParameterBytes: 16_384,
  maxOperationParameterNodes: 256,
  maxOperationParameterDepth: 8,
};
const dryRun = dryRunAgentPlan(untrustedPlan, policy, {
  now: "2026-07-10T20:00:00.000Z",
});

// The signer is supplied by a trusted host. Keep keys out of browser bundles.
const approval = await issueAgentApproval(
  dryRun,
  policy,
  {
    id: "approval-42",
    approver: "reviewer@example.test",
    issuedAt: "2026-07-10T20:00:00.000Z",
    expiresAt: "2026-07-10T20:05:00.000Z",
    maxRows: 100, // single-step shorthand; may narrow, never widen
  },
  hostSigner,
  { now: "2026-07-10T20:00:00.000Z", maxClockSkewMs: 5_000 },
);

const execution = await executeAgentPlanStep({
  dryRun,
  policy,
  approval,
  approvalVerifier: hostVerifier,
  context: { sources: { incidents: currentIncidentSourceBinding } },
  stepId: "query-incidents",
  operation: exactQueryPlanInput,
  useConsumer: hostAtomicApprovalUseStore,
  executor: hostQueryExecutor,
  auditSink: hostDurableAuditSink,
  receiptSigner: hostReceiptSigner,
  executionId: "execution-42",
});
// execution.value is an owned frozen JSON snapshot; execution.receipt binds it.

dryRunAgentPlan rejects unknown properties, accessors, non-plain objects, invalid or duplicate values, non-canonical timestamps, credentials in citation URLs, conflicting bindings, unknown tools/sources, operation-input drift, disallowed effects or fields, scope/version/data-mode drift, stale provenance, and row/byte overflow. Its default effect allowlist is only read.

Foreign policy data is descriptor-snapshotted and frozen exactly once per public operation. That same snapshot supplies the signed policy digest, dry-run revalidation, freshness check, and parameter limits. Reflection errors are reported as HonuaAgentSafetyError; a raw Proxy trap error is not exposed. Policy ceilings are themselves capped by AGENT_SAFETY_HARD_LIMITS. Collection lengths are rejected before indexed descriptors are read, and operation JSON is bounded by node count, depth, object/array count, and cumulative UTF-8 bytes. The same cumulative pre-signature budget applies to dry runs, approval requests, approval step arrays, current contexts/source maps, execution evidence, consumption records, and receipts—including both property-key and value bytes.

Signatures, cancellation, and receipts

Signer/verifier interfaces carry an algorithm and key ID and receive canonical JSON. Hosts can back them with WebCrypto, KMS, HSM, or a remote same-origin signing service. Approval and receipt verifiers require the configured algorithm/key ID to match the envelope. Signatures cover the canonical payload; SHA-256 envelope digests provide deterministic identity and diagnostics.

All operations accept AbortSignal. Cancellation is checked before validation, across awaited signer/verifier boundaries, and before a successful value is returned. Aborted work cannot return a usable approval or receipt.

Approval envelopes are explicitly single-use per step. The required AgentApprovalUseConsumer must atomically consume the approval-digest/step key and return an unforgeable host record containing a nonce, consumption time, opaque authentication token, and exact approval/step/input binding. The SDK immediately asks the same store to verify that record. This host-owned replay store is the concurrency boundary that prevents duplicate mutation, publish, share, subscription, and job effects.

Signed approvals contain a row/byte allocation for every step. Multi-step approvals must use stepLimits when narrowing; an ambiguous aggregate-only narrowing is rejected. Step authorization returns a copy with those narrowed limits, not the wider proposed limits.

After a separately authorized host executor finishes, it can call issueAgentExecutionReceipt with bounded outcome evidence. The helper first re-verifies approval and current context, refuses evidence beyond approved row/byte ceilings, and signs the plan, policy, source bindings, approval, step ID, operation-input digest, atomic-use digest, outcome, result digest, and completion time. verifyAgentExecutionReceipt repeats those checks deterministically and rejects any modified field. Receipt issuance and verification require a host consumption verifier; public digests alone cannot create a receipt. The authenticated consumption record and token are included under the receipt signature. Receipt verification evaluates the historical approval at the signed completion time, so an authentic receipt remains verifiable after approval expiry. Supply the bound historical source context, not newly discovered current metadata.

Approved execution and audit

executeAgentPlanStep composes the authorization and receipt primitives. It requires an executor with one exact tool and effect, rejects mismatches before consuming the approval, and passes only the frozen operation and narrowed approval limits. All host data descriptors and bound callbacks are captured once before the first await; later caller mutation cannot replace the executor, approval store, verifier, signer, audit sink, clock, or operation. Executor output must be a bounded plain JSON value. Arrays use one captured length and objects stop discovery at the width ceiling before reading excess values. The SDK snapshots data descriptors without invoking accessors, derives the canonical UTF-8 byte count and SHA-256 digest, and refuses row or byte overflow.

The audit sink must durably append a started event before execution and a completed event after every observed success, failure, or cancellation. Audit events carry pseudonymous digests for plan/actor/provider/model/step/tool/source/ schema/source-version identity, the finite effect and data mode, observation time, and plan/policy/binding/approval/input/use/result/receipt digests. They deliberately omit raw free-text identity, parameters, results, citations, authorization scopes, signatures, consumption nonce/token, and thrown error messages. A failed start audit prevents execution but deliberately leaves the already-consumed approval unusable; retry requires a new approval so a host cannot mistake an uncertain persistence boundary for an unused grant. A failed terminal audit is a typed HonuaAgentExecutionError carrying the signed receipt so a host can reconcile it without pretending persistence succeeded.

@honua/mcp-server/agent-execution provides createReadOnlyMcpAgentExecutor. It binds one named standalone MCP read tool to this same SDK execution contract; it does not create a parallel approval model or enable mutation tools. Names must be bounded exact identifiers—wildcards and patterns are rejected—and every adapter must provide a deterministic countRows(result) callback. An uncounted result cannot silently produce a zero-row receipt.

The API precondition is JSON-compatible structured data produced by JSON parsing or an equivalent structured clone. Indexed and object accessors are rejected without invocation. JavaScript Proxy traps are executable by language-level reflection itself and cannot be made side-effect free by a portable library; do not pass Proxies across this boundary. When reflection nevertheless fails, the failure is typed and no partially normalized authority escapes.

Citation URLs must be HTTPS and contain no user info, query, or fragment. Paths are repeatedly percent-decoded within a fixed bound, Unicode-normalized, checked for traversal, and canonicalized before identity hashing. Every citation must match the source policy's exact origin and decoded resource-prefix allowlist. The SDK does not claim to recognize secrets embedded in arbitrary opaque path text; hosts prevent those paths by choosing narrow resource IDs or prefixes.

Deliberate boundaries

This is one production vertical slice of #397, not completion of that XL workstream. It does not translate natural language, provide a model adapter, manage signing keys or audit persistence, parse query predicates to infer field use, perform compensating actions, or establish CLI/renderer/mutation execution parity. Hosts must declare every field a step may read or write; query compiler integration can automate that declaration in a later slice.