feat(sdk): framework adapters — authorizeTool + SanctionMiddleware + Vercel AI SDK gate#157
Conversation
…Vercel AI SDK gate Roadmap 'Next': real, importable adapter code, not just recipes. The tool runs BEHIND the decision, never beside it in a log. - SDK: client.authorizeTool(input) → ToolDecision, normalizing the tool route's 'allowed' status to 'approved' so adapters branch on one vocabulary. Fails CLOSED (denied) when Sanction is unreachable — an ungoverned tool never runs. Surfaces 401/400 as errors (bad key ≠ policy denial). - SanctionMiddleware(client): framework-agnostic runTool that authorizes then runs only on approval; throws SanctionToolBlocked (with status/code/ requestId) otherwise. authorizeToolCall for agents that branch-not-throw. - sanctionTool(client, name, aiTool): wraps a Vercel AI SDK tool so its execute is gated — structural typing keeps 'ai' a peer, not a dependency. - docs/FRAMEWORK-ADAPTERS.md: the TS adapters now 'ship' (were 'needs SDK support'); Python recipes stay copy-in until their packages land. - 10 adapter tests (approve/deny/escalate, fail-closed, AI SDK gating); full gate green (782 tests). Python/CrewAI packages queued in BACKLOG. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UEw1RdHk5bmphKxr6N5mJY
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughThis PR adds tool-authorization support to the Sanction SDK: new ChangesTool Authorization Adapters
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant Agent
participant SanctionMiddleware
participant SanctionClient
participant SanctionAPI
participant Tool
Agent->>SanctionMiddleware: runTool(call)
SanctionMiddleware->>SanctionClient: authorizeToolCall(call)
SanctionClient->>SanctionAPI: POST /authorize/tool
alt unreachable/5xx
SanctionAPI--xSanctionClient: network error
SanctionClient-->>SanctionMiddleware: denied decision (fail closed)
else success
SanctionAPI-->>SanctionClient: decision response
SanctionClient-->>SanctionMiddleware: ToolDecision
end
alt decision approved
SanctionMiddleware->>Tool: run()
Tool-->>SanctionMiddleware: result
SanctionMiddleware-->>Agent: result
else denied or escalated
SanctionMiddleware-->>Agent: throw SanctionToolBlocked
end
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
ESLint install failed: one or more packages not found in the registry. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (1)
sdk/src/adapters.test.ts (1)
73-80: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winTest can pass without ever asserting anything.
.catch()only runs its callback on rejection; ifrunToolregressed to resolve instead of throw for"escalated", this test would pass silently with theexpectcalls never executed.✅ Proposed fix
it("escalation carries the request id for grant polling", async () => { const c = new SanctionClient("pxy_k", { baseUrl: BASE, fetch: fakeFetch([escalated]).fetch }) const runTool = SanctionMiddleware(c) - await runTool({ tool: "github.merge", run: () => "no" }).catch((e: SanctionToolBlocked) => { - expect(e.status).toBe("escalated") - expect(e.requestId).toBe("req_2") - }) + await expect(runTool({ tool: "github.merge", run: () => "no" })).rejects.toMatchObject({ + status: "escalated", + requestId: "req_2", + }) })🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@sdk/src/adapters.test.ts` around lines 73 - 80, The escalation test in SanctionMiddleware can pass without validating anything because the assertions live only inside the .catch callback. Update the test around runTool and SanctionToolBlocked so it explicitly fails if the promise resolves, and only treats a SanctionToolBlocked rejection as success; keep the existing status and requestId checks on the rejected error.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@sdk/src/adapters.ts`:
- Around line 13-30: SanctionToolBlocked currently hardcodes a denied message
for every non-escalated ToolDecision status, so pending decisions are
mislabeled. Update the constructor in SanctionToolBlocked to branch on
decision.status explicitly and generate status-appropriate text for pending
versus denied, while keeping the escalated path unchanged. Preserve the existing
use of tool, decision.code, decision.reason, and decision.requestId so the error
message still carries the same context.
- Around line 114-128: The sanctionTool wrapper currently authorizes only tool,
server, and input, so escalated AI SDK retries cannot pass a redeemed grant back
through the same path. Update sanctionTool’s gated execute flow to accept and
forward grantId into client.authorizeTool, matching the authorizeToolCall
behavior, and keep the existing SanctionToolBlocked handling for non-approved
decisions.
In `@sdk/src/client.ts`:
- Around line 204-227: `authorizeTool` currently skips the client-level offline
short-circuit, so an `offline: true` SanctionClient still makes a network
request. Add the same early return used by `authorize()` at the start of
`authorizeTool` before `requestRaw`, returning a denied ToolDecision immediately
when `this.offline` is set. Keep the change scoped to the `authorizeTool` method
in `Client` so tool authorization honors the offline contract consistently.
---
Nitpick comments:
In `@sdk/src/adapters.test.ts`:
- Around line 73-80: The escalation test in SanctionMiddleware can pass without
validating anything because the assertions live only inside the .catch callback.
Update the test around runTool and SanctionToolBlocked so it explicitly fails if
the promise resolves, and only treats a SanctionToolBlocked rejection as
success; keep the existing status and requestId checks on the rejected error.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 850bd9f0-571d-45d3-98fe-aba53e480f29
⛔ Files ignored due to path filters (1)
sdk/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.jsonis excluded by!**/node_modules/**
📒 Files selected for processing (7)
docs/BACKLOG.mddocs/FRAMEWORK-ADAPTERS.mdsdk/src/adapters.test.tssdk/src/adapters.tssdk/src/client.tssdk/src/index.tssdk/src/types.ts
| /** Thrown when a tool call is not approved. Carries the machine code + request | ||
| * id so the agent can branch: escalated → poll the grant, denied → replan. */ | ||
| export class SanctionToolBlocked extends Error { | ||
| readonly status: ToolDecision["status"] | ||
| readonly code?: ToolDecision["code"] | ||
| readonly requestId: string | ||
| constructor(tool: string, decision: ToolDecision) { | ||
| super( | ||
| decision.status === "escalated" | ||
| ? `Sanction escalation required for '${tool}' — poll request ${decision.requestId} for the grant, then retry.` | ||
| : `Sanction denied '${tool}': ${decision.code ?? decision.reason ?? "not authorized"}`, | ||
| ) | ||
| this.name = "SanctionToolBlocked" | ||
| this.status = decision.status | ||
| this.code = decision.code | ||
| this.requestId = decision.requestId | ||
| } | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Error message hardcodes "denied" for any non-escalated status, including pending.
ToolDecision.status can be "pending", but the constructor's fallback message always reads Sanction denied '...' regardless of the actual status.
💡 Proposed fix
super(
decision.status === "escalated"
? `Sanction escalation required for '${tool}' — poll request ${decision.requestId} for the grant, then retry.`
- : `Sanction denied '${tool}': ${decision.code ?? decision.reason ?? "not authorized"}`,
+ : `Sanction ${decision.status} '${tool}': ${decision.code ?? decision.reason ?? "not authorized"}`,
)📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| /** Thrown when a tool call is not approved. Carries the machine code + request | |
| * id so the agent can branch: escalated → poll the grant, denied → replan. */ | |
| export class SanctionToolBlocked extends Error { | |
| readonly status: ToolDecision["status"] | |
| readonly code?: ToolDecision["code"] | |
| readonly requestId: string | |
| constructor(tool: string, decision: ToolDecision) { | |
| super( | |
| decision.status === "escalated" | |
| ? `Sanction escalation required for '${tool}' — poll request ${decision.requestId} for the grant, then retry.` | |
| : `Sanction denied '${tool}': ${decision.code ?? decision.reason ?? "not authorized"}`, | |
| ) | |
| this.name = "SanctionToolBlocked" | |
| this.status = decision.status | |
| this.code = decision.code | |
| this.requestId = decision.requestId | |
| } | |
| } | |
| /** Thrown when a tool call is not approved. Carries the machine code + request | |
| * id so the agent can branch: escalated → poll the grant, denied → replan. */ | |
| export class SanctionToolBlocked extends Error { | |
| readonly status: ToolDecision["status"] | |
| readonly code?: ToolDecision["code"] | |
| readonly requestId: string | |
| constructor(tool: string, decision: ToolDecision) { | |
| super( | |
| decision.status === "escalated" | |
| ? `Sanction escalation required for '${tool}' — poll request ${decision.requestId} for the grant, then retry.` | |
| : `Sanction ${decision.status} '${tool}': ${decision.code ?? decision.reason ?? "not authorized"}`, | |
| ) | |
| this.name = "SanctionToolBlocked" | |
| this.status = decision.status | |
| this.code = decision.code | |
| this.requestId = decision.requestId | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@sdk/src/adapters.ts` around lines 13 - 30, SanctionToolBlocked currently
hardcodes a denied message for every non-escalated ToolDecision status, so
pending decisions are mislabeled. Update the constructor in SanctionToolBlocked
to branch on decision.status explicitly and generate status-appropriate text for
pending versus denied, while keeping the escalated path unchanged. Preserve the
existing use of tool, decision.code, decision.reason, and decision.requestId so
the error message still carries the same context.
| export function sanctionTool<T extends AiSdkToolLike>( | ||
| client: SanctionClient, | ||
| name: string, | ||
| aiTool: T, | ||
| opts: { server?: string } = {}, | ||
| ): T { | ||
| const original = aiTool.execute | ||
| if (typeof original !== "function") return aiTool | ||
| const gated = async (args: unknown, options?: unknown) => { | ||
| const decision = await client.authorizeTool({ tool: name, server: opts.server, input: args }) | ||
| if (decision.status !== "approved") throw new SanctionToolBlocked(name, decision) | ||
| return original(args, options) | ||
| } | ||
| return { ...aiTool, execute: gated } | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
git ls-files | rg '^(sdk/src/(adapters|sanction|.*)|docs/FRAMEWORK-ADAPTERS\.md|.*sanction.*|.*authorize.*)\.(ts|tsx|md)$' || true
echo '--- adapters.ts outline ---'
ast-grep outline sdk/src/adapters.ts --view expanded || true
echo '--- sanction-related files outline ---'
for f in $(git ls-files | rg '^sdk/src/.*(sanction|authorize|adapters).*\.ts$' | head -n 20); do
echo "### $f"
ast-grep outline "$f" --view expanded || true
done
echo '--- docs excerpt ---'
sed -n '1,220p' docs/FRAMEWORK-ADAPTERS.mdRepository: ericlovold/sanction
Length of output: 7587
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the adapter and sanction-related APIs around grant handling.
sed -n '1,240p' sdk/src/adapters.ts
echo '---'
rg -n "grantId|authorizeToolCall|authorizeTool\(|SanctionedToolCall|sanctionTool" sdk/src docs/FRAMEWORK-ADAPTERS.md -n
echo '--- related source files ---'
for f in $(git ls-files sdk/src | rg '(sanction|authorize|adapter)'); do
echo "### $f"
sed -n '1,240p' "$f"
echo
doneRepository: ericlovold/sanction
Length of output: 19340
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo 'FILES:'
git ls-files | rg '^(sdk/src/.*(adapters|sanction|authorize).*\.ts|docs/FRAMEWORK-ADAPTERS\.md)$' || true
echo
echo 'ADAPTERS:'
sed -n '1,220p' sdk/src/adapters.ts
echo
echo 'SEARCH:'
rg -n "grantId|authorizeToolCall|authorizeTool\(|SanctionedToolCall|sanctionTool" sdk/src docs/FRAMEWORK-ADAPTERS.mdRepository: ericlovold/sanction
Length of output: 8492
sanctionTool needs a way to retry escalated calls with a redeemed grant. authorizeToolCall already threads grantId, but this wrapper only authorizes { tool, server, input }, so an escalated AI SDK tool call has no path back through the same gate once the grant is issued. Add a grantId input here or document that AI SDK retries must bypass sanctionTool and call authorizeTool directly.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@sdk/src/adapters.ts` around lines 114 - 128, The sanctionTool wrapper
currently authorizes only tool, server, and input, so escalated AI SDK retries
cannot pass a redeemed grant back through the same path. Update sanctionTool’s
gated execute flow to accept and forward grantId into client.authorizeTool,
matching the authorizeToolCall behavior, and keep the existing
SanctionToolBlocked handling for non-approved decisions.
| /** | ||
| * Ask Sanction whether a tool/action may run — the pre-action gate for agent | ||
| * frameworks (MCP tools, shell, deploys, sends). Call BEFORE executing the | ||
| * tool; run it only on `approved`. A `denied`/`escalated` decision is returned | ||
| * (not thrown) so the agent branches and replans. Unlike spend, tool decisions | ||
| * carry no local-offline fallback — a tool gate that can't reach Sanction | ||
| * fails closed (denied) so an ungoverned tool never runs. | ||
| */ | ||
| async authorizeTool(input: ToolAuthorizeInput): Promise<ToolDecision> { | ||
| let raw: { ok: boolean; status: number; body: unknown } | ||
| try { | ||
| raw = await requestRaw({ | ||
| baseUrl: this.baseUrl, | ||
| fetch: this.fetch, | ||
| method: "POST", | ||
| path: "/authorize/tool", | ||
| timeoutMs: this.networkTimeoutMs, | ||
| headers: this.authHeaders(input.idempotencyKey ? { "idempotency-key": input.idempotencyKey } : undefined), | ||
| body: { tool: input.tool, server: input.server, input: input.input, grant_id: input.grantId }, | ||
| }) | ||
| } catch { | ||
| // Unreachable: fail closed — an ungoverned tool must not run. | ||
| return { authorized: false, status: "denied", requestId: "", code: "POLICY_DENIED", reason: "Sanction unreachable; tool gate failing closed" } | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
authorizeTool ignores the offline client option.
authorize() short-circuits on this.offline before any network call (line 81). authorizeTool has no such check, so an offline: true client still performs a real fetch for every tool call and only fails closed after waiting up to networkTimeoutMs, contradicting the offline configuration contract.
🛡️ Proposed fix
async authorizeTool(input: ToolAuthorizeInput): Promise<ToolDecision> {
+ if (this.offline) {
+ return {
+ authorized: false,
+ status: "denied",
+ requestId: "",
+ code: "POLICY_DENIED",
+ reason: "Client configured offline; tool gate failing closed",
+ }
+ }
let raw: { ok: boolean; status: number; body: unknown }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| /** | |
| * Ask Sanction whether a tool/action may run — the pre-action gate for agent | |
| * frameworks (MCP tools, shell, deploys, sends). Call BEFORE executing the | |
| * tool; run it only on `approved`. A `denied`/`escalated` decision is returned | |
| * (not thrown) so the agent branches and replans. Unlike spend, tool decisions | |
| * carry no local-offline fallback — a tool gate that can't reach Sanction | |
| * fails closed (denied) so an ungoverned tool never runs. | |
| */ | |
| async authorizeTool(input: ToolAuthorizeInput): Promise<ToolDecision> { | |
| let raw: { ok: boolean; status: number; body: unknown } | |
| try { | |
| raw = await requestRaw({ | |
| baseUrl: this.baseUrl, | |
| fetch: this.fetch, | |
| method: "POST", | |
| path: "/authorize/tool", | |
| timeoutMs: this.networkTimeoutMs, | |
| headers: this.authHeaders(input.idempotencyKey ? { "idempotency-key": input.idempotencyKey } : undefined), | |
| body: { tool: input.tool, server: input.server, input: input.input, grant_id: input.grantId }, | |
| }) | |
| } catch { | |
| // Unreachable: fail closed — an ungoverned tool must not run. | |
| return { authorized: false, status: "denied", requestId: "", code: "POLICY_DENIED", reason: "Sanction unreachable; tool gate failing closed" } | |
| } | |
| /** | |
| * Ask Sanction whether a tool/action may run — the pre-action gate for agent | |
| * frameworks (MCP tools, shell, deploys, sends). Call BEFORE executing the | |
| * tool; run it only on `approved`. A `denied`/`escalated` decision is returned | |
| * (not thrown) so the agent branches and replans. Unlike spend, tool decisions | |
| * carry no local-offline fallback — a tool gate that can't reach Sanction | |
| * fails closed (denied) so an ungoverned tool never runs. | |
| */ | |
| async authorizeTool(input: ToolAuthorizeInput): Promise<ToolDecision> { | |
| if (this.offline) { | |
| return { | |
| authorized: false, | |
| status: "denied", | |
| requestId: "", | |
| code: "POLICY_DENIED", | |
| reason: "Client configured offline; tool gate failing closed", | |
| } | |
| } | |
| let raw: { ok: boolean; status: number; body: unknown } | |
| try { | |
| raw = await requestRaw({ | |
| baseUrl: this.baseUrl, | |
| fetch: this.fetch, | |
| method: "POST", | |
| path: "/authorize/tool", | |
| timeoutMs: this.networkTimeoutMs, | |
| headers: this.authHeaders(input.idempotencyKey ? { "idempotency-key": input.idempotencyKey } : undefined), | |
| body: { tool: input.tool, server: input.server, input: input.input, grant_id: input.grantId }, | |
| }) | |
| } catch { | |
| // Unreachable: fail closed — an ungoverned tool must not run. | |
| return { authorized: false, status: "denied", requestId: "", code: "POLICY_DENIED", reason: "Sanction unreachable; tool gate failing closed" } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@sdk/src/client.ts` around lines 204 - 227, `authorizeTool` currently skips
the client-level offline short-circuit, so an `offline: true` SanctionClient
still makes a network request. Add the same early return used by `authorize()`
at the start of `authorizeTool` before `requestRaw`, returning a denied
ToolDecision immediately when `this.offline` is set. Keep the change scoped to
the `authorizeTool` method in `Client` so tool authorization honors the offline
contract consistently.
…ap, DOMAIN, backlog (#163) Three ships had outrun their descriptions: the changelog had no entry for the framework adapters (#157) or the tamper-evident exports (#159); the roadmap still listed sequential simulation and adapters as Next and cryptographic audit as Later after all three merged; DOMAIN.md still called hash-chained exports a Later item in the Audit Event row and the audit-trail section. - changelog: two entries — the signed decision export (with its honest AUDIT-2 boundary) and the SDK adapter layer (in-repo, publishing is next) - roadmap: shipped items move into Now with shipped phrasing; Next becomes the published SDK + Python adapters and sequential-simulation follow-ons; Later gains audit chain anchors (AUDIT-2) - DOMAIN: Audit Event row → Partial with the real endpoints; the distributed- trail section states exactly what is exportable today (decisions) and what is not yet (unified cross-table export, across-export anchors) - backlog: seat-leadership hardening capture + console-parity checkoff Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Bump package.json 0.5.0 → 0.6.0 and stamp the changelog with the release header. The pack since v0.5.0 (ten commits): SDK framework adapters (#157), sequential simulation (#158), tamper-evident audit exports (#159), AuthZEN hardening sprint 2 (#160), MCP capability parity (#161), coverage-ratchet + decision-code contract tests (#162, #164), truth surfaces (#163), storefront internal-buyer patch (#165), org-level console visibility (#166). Also adds the missing changelog entry for #161 (sanction_authorize_capability, sanction-mcp 0.5.0 — ten tools, verified against mcp-server.ts). Claude-Session: https://claude.ai/code/session_01UEw1RdHk5bmphKxr6N5mJY Co-authored-by: Claude <noreply@anthropic.com>
Roadmap Next: "Framework adapters — not just docs, but the adapter code that routes every tool call through Sanction before it executes." This ships the TypeScript foundation as real, importable
@sanction/sdkcode.docs/FRAMEWORK-ADAPTERS.mdpreviously described the shapes but flagged "the concrete package still needs SDK support forauthorizeTool" — that gap is now closed.The invariant every adapter preserves: the tool runs behind the decision, never beside it in a log. Approved → run; escalated → wait for a grant; denied → replan (a normal outcome, not a crash).
What ships
client.authorizeTool(input)— the missing SDK method. Returns aToolDecision, normalizing the tool route's"allowed"status to"approved"so adapters branch on one vocabulary. Fails closed (returnsdenied) when Sanction is unreachable — an ungoverned tool never runs. A 401/400 surfaces as aSanctionError(a bad key is not a policy denial).SanctionMiddleware(client)— framework-agnosticrunTool({ server, tool, input, run }): authorizes, then runs the thunk only on approval; otherwise throwsSanctionToolBlockedcarryingstatus/code/requestId. Use with LangChain.js, LangGraph, Mastra, or any custom runtime.authorizeToolCallis the branch-not-throw variant.sanctionTool(client, name, aiTool, { server })— wraps a Vercel AI SDK tool so itsexecuteis gated: the model can pick the tool, but it only runs on an approved decision. Structural typing keepsaia peer, not a hard dependency (noaiimport).Verification
executegating, no-execute passthrough.npm run checkgreen — 782 tests (SDK typechecks clean via its owntscproject too).Scope
TypeScript-first, since AI SDK + LangChain.js are where the TS agent ecosystem lives and the gateway already meters their model calls. Queued as follow-on packages in
docs/BACKLOG.md: a Python package (LiteLLM callback + LangChain/LangGraph decorator — recipes already in the doc), a CrewAI authorize-tool, and a runnableexamples/agent per adapter.🤖 Generated with Claude Code
https://claude.ai/code/session_01UEw1RdHk5bmphKxr6N5mJY
Generated by Claude Code
Summary by CodeRabbit
New Features
Bug Fixes
Documentation