Skip to content

feat(sdk): framework adapters — authorizeTool + SanctionMiddleware + Vercel AI SDK gate#157

Merged
ericlovold merged 1 commit into
mainfrom
claude/framework-adapters
Jul 9, 2026
Merged

feat(sdk): framework adapters — authorizeTool + SanctionMiddleware + Vercel AI SDK gate#157
ericlovold merged 1 commit into
mainfrom
claude/framework-adapters

Conversation

@ericlovold

@ericlovold ericlovold commented Jul 9, 2026

Copy link
Copy Markdown
Owner

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/sdk code. docs/FRAMEWORK-ADAPTERS.md previously described the shapes but flagged "the concrete package still needs SDK support for authorizeTool" — 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 a ToolDecision, normalizing the tool route's "allowed" status to "approved" so adapters branch on one vocabulary. Fails closed (returns denied) when Sanction is unreachable — an ungoverned tool never runs. A 401/400 surfaces as a SanctionError (a bad key is not a policy denial).
  • SanctionMiddleware(client) — framework-agnostic runTool({ server, tool, input, run }): authorizes, then runs the thunk only on approval; otherwise throws SanctionToolBlocked carrying status / code / requestId. Use with LangChain.js, LangGraph, Mastra, or any custom runtime. authorizeToolCall is the branch-not-throw variant.
  • sanctionTool(client, name, aiTool, { server }) — wraps a Vercel AI SDK tool so its execute is gated: the model can pick the tool, but it only runs on an approved decision. Structural typing keeps ai a peer, not a hard dependency (no ai import).

Verification

  • 10 new adapter tests: approve/deny/escalate as decisions, fail-closed on unreachable, 401-is-an-error, middleware runs-only-on-approval, AI SDK execute gating, no-execute passthrough.
  • npm run check green — 782 tests (SDK typechecks clean via its own tsc project 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 runnable examples/ agent per adapter.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UEw1RdHk5bmphKxr6N5mJY


Generated by Claude Code

Summary by CodeRabbit

  • New Features

    • Added tool-authorization support for SDK apps, including a middleware wrapper and AI tool gating with approved, denied, and escalated outcomes.
    • Added a consistent blocked-state error so non-approved tool calls surface clear feedback.
  • Bug Fixes

    • Improved failure handling so tool authorization fails closed when the service is unreachable.
  • Documentation

    • Updated adapter guidance and examples to reflect the new approval-based tool execution flow.
    • Added a backlog note for upcoming follow-on packages and examples.

…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
@cursor

cursor Bot commented Jul 9, 2026

Copy link
Copy Markdown

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.

@vercel

vercel Bot commented Jul 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
sanction Ready Ready Preview, Comment Jul 9, 2026 6:02pm

@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds tool-authorization support to the Sanction SDK: new ToolAuthorizeInput/ToolDecision types, a SanctionClient.authorizeTool method with fail-closed behavior, framework-agnostic and Vercel AI SDK adapters (SanctionMiddleware, authorizeToolCall, sanctionTool, SanctionToolBlocked), accompanying tests, barrel exports, and updated documentation/backlog.

Changes

Tool Authorization Adapters

Layer / File(s) Summary
Tool decision types and client method
sdk/src/types.ts, sdk/src/client.ts
Adds ToolAuthorizeInput/ToolDecision interfaces and implements SanctionClient.authorizeTool, which POSTs to /authorize/tool, normalizes responses via toToolDecision, and fails closed (denied decision) on network errors or 5xx.
Adapter implementations and tests
sdk/src/adapters.ts, sdk/src/adapters.test.ts
Implements SanctionToolBlocked, SanctionedToolCall, SanctionMiddleware, authorizeToolCall, and sanctionTool (Vercel AI SDK wrapper), with Vitest coverage for approval/denial/escalation and fail-closed behavior.
Public exports and documentation
sdk/src/index.ts, docs/FRAMEWORK-ADAPTERS.md, docs/BACKLOG.md
Re-exports adapter symbols, rewrites adapter documentation to reflect the shipped SanctionMiddleware/sanctionTool APIs, and adds a backlog entry for remaining framework-adapter follow-on work.

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
Loading

Possibly related PRs

  • ericlovold/sanction#26: Adds the /authorize/tool endpoint and tool-decision status/code contract that SanctionClient.authorizeTool in this PR directly consumes.
  • ericlovold/sanction#67: Introduces the SanctionClient/SDK foundation that this PR extends with authorizeTool, ToolAuthorizeInput/ToolDecision types, and adapter wrappers.
  • ericlovold/sanction#79: Implements backend POST /api/v1/authorize/tool behavior (idempotency-key, grant redemption, escalation persistence) that this PR's client/adapter logic relies on.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and accurately summarizes the main SDK adapter changes, including authorizeTool, SanctionMiddleware, and Vercel AI SDK gating.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/framework-adapters

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

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed: one or more packages not found in the registry.


Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
sdk/src/adapters.test.ts (1)

73-80: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Test can pass without ever asserting anything.

.catch() only runs its callback on rejection; if runTool regressed to resolve instead of throw for "escalated", this test would pass silently with the expect calls 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

📥 Commits

Reviewing files that changed from the base of the PR and between 5a350ce and 11070b4.

⛔ Files ignored due to path filters (1)
  • sdk/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json is excluded by !**/node_modules/**
📒 Files selected for processing (7)
  • docs/BACKLOG.md
  • docs/FRAMEWORK-ADAPTERS.md
  • sdk/src/adapters.test.ts
  • sdk/src/adapters.ts
  • sdk/src/client.ts
  • sdk/src/index.ts
  • sdk/src/types.ts

Comment thread sdk/src/adapters.ts
Comment on lines +13 to +30
/** 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
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Suggested change
/** 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.

Comment thread sdk/src/adapters.ts
Comment on lines +114 to +128
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 }
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.md

Repository: 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
done

Repository: 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.md

Repository: 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.

Comment thread sdk/src/client.ts
Comment on lines +204 to +227
/**
* 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" }
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Suggested change
/**
* 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.

@ericlovold
ericlovold merged commit b78f0e3 into main Jul 9, 2026
8 checks passed
ericlovold added a commit that referenced this pull request Jul 10, 2026
…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>
ericlovold added a commit that referenced this pull request Jul 10, 2026
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>
@ericlovold
ericlovold deleted the claude/framework-adapters branch July 13, 2026 04:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants