@intutic/gate / harness
@intutic/gate/harness
@ai-sdk/harness adapter: intuticApprovalResponder(), intuticSubmitApprovals(), intuticStaticApprovals(), recommendedHarnessSettings(), and intuticSandboxBootstrap().
@intutic/gate/harness — the same idea vercel.ts implements for the base ai package (wrap the framework's own veto point around this package's Gate.guard()), reshaped for Vercel's sandboxed coding-agent runtime, whose veto surface is materially different from generateText's.
Confirmed against a real install
@ai-sdk/harness@1.0.75 (plus @ai-sdk/harness-claude-code@1.0.78 and @ai-sdk/harness-grok-build@1.0.12) were installed and their shipped .d.ts/compiled source read directly (not inferred from docs alone); points 1-3 re-read on @ai-sdk/harness@1.0.138 (the current devDependency, on ai@7.0.127) — only the continuation element type changed (point 2):
- The
toolApprovalsetting is STATIC.HarnessAgentSettings.toolApprovalisHarnessAgentToolApprovalConfiguration = Readonly<Record<string, ToolApprovalStatus>>(dist/agent/index.d.ts), whose own doc comment says it mirrors "AI SDKtoolApprovalobject configuration for host-executed tools, without callback support".vercel.ts's callback-shapedintuticToolApproval()therefore does NOT apply here — there is no place to hang a function. Per-call gating instead goes through the approval FLOW: mark tools'user-approval'(see intuticStaticApprovals), let the turn pause, and answer each pending approval with the gate (see intuticApprovalResponder). - The portable answer path is continuation-based.
continueTurn/continueGenerate/continueStreamaccepttoolApprovalContinuations(dist/agent/index.d.ts) — in 1.0.75 an array ofHarnessAgentToolApprovalContinuation({ approvalResponse, toolCall }), since 1.0.101 an array of bareToolApprovalResponseparts (the old type is no longer exported in 1.0.138); see HarnessToolApprovalContinuation for how one value serves both. The adapter-session methodsession.submitToolApproval?({approvalId, approved, reason})is OPTIONAL (dist/index.d.ts—HarnessV1PromptControl), and support genuinely varies per adapter:@ai-sdk/harness-claude-code@1.0.78implements it;@ai-sdk/harness-grok-build@1.0.12does not (zero occurrences in its shipped dist). This module therefore builds the responder on the continuation path and treatssubmitToolApprovalas an optimization when present (intuticSubmitApprovals). - Built-in sandbox tools are NOT governed by
toolApprovalat all. They are governed bypermissionMode: 'allow-reads' | 'allow-edits' | 'allow-all', which defaults to'allow-all'— the shipped doc comment says so verbatim: "Defaults to'allow-all', preserving the existing bypass-permissions behavior unless users opt in." Under'allow-all'nothing pauses. Under the stricter modes the mode decides WHICH built-ins pause, and each paused call is then a genuine per-call approval this gate answers: the@ai-sdk/harness-claude-codebridge (dist/bridge/index.mjs,createPermissionSettings/nativeToolRequiresApproval) addsaskrules for bash-kind tools (Bash,Monitor) under'allow-edits', and for edit- and bash-kind tools under'allow-reads', emitting atool-approval-requestand blocking on the host's answer;@ai-sdk/harness/agentrecords it as a pending approval withkind: 'builtin', pauses the turn, and delivers the continuation's decision through the adapter'ssubmitToolApproval. What never pauses: read-kind built-ins (Read/Glob/Grep/WebFetch/...) in ANY mode, and write/edit under'allow-edits'. recommendedHarnessSettings exists to steer callers off the'allow-all'default. - Execution is server-side, in a sandbox. The agent's tools run inside a microVM (
@ai-sdk/sandbox-verceland friends), NOT on the machine where the Intutic proxy runs — the laptop proxy cannot see sandbox egress. The only egress control available at this layer is the sandbox's ownHarnessV1NetworkPolicy({mode:'allow-all'|'deny-all'} | {mode: 'custom', allowedHosts/allowedCIDRs/deniedCIDRs}), applied via the sandbox session's optionalsetNetworkPolicy?.(). That is coarse host-level filtering, not Intutic DLP — stated plainly inapps/docs/integrations/ai-sdk-harness.mdrather than oversold.
What this module does NOT import
Deliberately no runtime dependency on @ai-sdk/harness — the structural types below capture only the shapes this adapter touches, matching the convention vercel.ts/mastra.ts/dsh.ts established. The real package is a devDependency used by __tests__/harness.test.ts to (a) structurally check these types against the shipped ones and (b) run this responder's output through the REAL collectHarnessAgentToolApprovalContinuations machinery.
Interfaces
HarnessApprovalRequest
One pending tool-approval request, in the shape this responder consumes.
Covers BOTH places a HarnessAgent surfaces one:
- the
tool-approval-requeststream/message parts of a running turn (paired with theirtool-callparts —inputis the parsed value), and HarnessAgentPendingToolApprovalrows from a suspended session, whereinputis a JSON string (confirmed:HarnessV1PendingToolApproval. input: stringin@ai-sdk/harness@1.0.75's dist/index.d.ts). String inputs are JSON-parsed before the gate evaluates them — see renderHarnessToolInput.
Properties
HarnessSessionLike
The slice of @ai-sdk/harness's adapter-session control surface this module touches — submitToolApproval is OPTIONAL in the real HarnessV1PromptControl too, and per-adapter support varies (see module doc point 2).
Methods
submitToolApproval()?
optional submitToolApproval(input): PromiseLike<void>;Parameters
| Parameter | Type |
|---|---|
input | { approvalId: string; approved: boolean; reason?: string; } |
input.approvalId | string |
input.approved | boolean |
input.reason? | string |
Returns
PromiseLike<void>
HarnessToolApprovalContinuation
One element of the toolApprovalContinuations array continueGenerate/continueStream accept — shaped to satisfy BOTH upstream contracts, because the shape changed in a patch release:
@ai-sdk/harness<= 1.0.100 readsHarnessAgentToolApprovalContinuation={ approvalResponse, toolCall }.@ai-sdk/harness>= 1.0.101 (2026-09-03) reads a bareToolApprovalResponseand keys it by its top-levelapprovalId; an element without one is silently ignored and the paused turn never resumes (observed live,uat/evidence/live-verify/harness-docker/). Still the contract in 1.0.138, whosecollectHarnessAgentToolApproval Continuationsalso returns bareToolApprovalResponseparts.
So each continuation IS the tool-approval-response part (top-level fields) and also carries approvalResponse/toolCall for older runtimes.
Extends
Properties
| Property | Modifier | Type | Inherited from |
|---|---|---|---|
approvalId | public | string | HarnessToolApprovalResponse.approvalId |
approvalResponse | readonly | HarnessToolApprovalResponse | - |
approved | public | boolean | HarnessToolApprovalResponse.approved |
providerExecuted? | public | boolean | HarnessToolApprovalResponse.providerExecuted |
reason? | public | string | HarnessToolApprovalResponse.reason |
toolCall | readonly | HarnessToolCallPart | - |
type | public | "tool-approval-response" | HarnessToolApprovalResponse.type |
HarnessToolApprovalResponse
Structural copy of ai's ToolApprovalResponse prompt part (re-exported from @ai-sdk/provider-utils — confirmed field-for-field on 5.0.27 and 5.0.53). This is the part a role: 'tool' message carries to answer an approval request.
Extended by
Properties
| Property | Type |
|---|---|
approvalId | string |
approved | boolean |
providerExecuted? | boolean |
reason? | string |
type | "tool-approval-response" |
HarnessToolCallPart
Structural copy of the toolCall member of @ai-sdk/harness <= 1.0.100's HarnessAgentToolApprovalContinuation (dist/agent/index.d.ts).
Properties
| Property | Modifier | Type |
|---|---|---|
input | readonly | unknown |
providerExecuted? | readonly | boolean |
toolCallId | readonly | string |
toolName | readonly | string |
type | readonly | "tool-call" |
IntuticApprovalResponderOptions
Properties
| Property | Type | Description |
|---|---|---|
gate? | Gate | Overrides the process-wide installed gate (install(new Gate(...))). |
IntuticSandboxBootstrap
Properties
IntuticSandboxBootstrapOptions
Properties
IntuticStaticApprovalsOptions
Properties
IntuticSubmitApprovalsResult
Properties
| Property | Type | Description |
|---|---|---|
continuations | HarnessToolApprovalContinuation[] | The evaluated decisions, always produced — pass these to continueGenerate/continueStream when submitted is false. |
submitted | boolean | True when the session supported submitToolApproval and every decision was delivered through it; false when the caller must use the continuation path instead. |
RecommendedHarnessSettings
Properties
| Property | Type | Description |
|---|---|---|
inactiveTools? | readonly ["bash"] | Spread into HarnessAgentSettings.inactiveTools — undefined when filterBash: false was passed. HarnessV1BuiltinToolFiltering (the same package's own type, dist/index.d.ts/dist/agent/index.d.ts) genuinely CAN drop bash at config time, confirmed against real shipped dist/index.js for both wrapped harnesses, Claude Code and Grok Build (not just their .d.ts — the behaviour lives in compiled logic no type file states): - @ai-sdk/harness-claude-code@1.0.78 sets BOTH supportsBuiltinToolApprovals: true AND supportsBuiltinToolFiltering: true on its adapter object, and its doStart/doContinueTurn forward builtinToolFiltering verbatim onto the bridge start/continue message — true native exclusion, enforced by the wrapped Claude Agent SDK inside the sandbox itself, not merely a framework-side veto. bash never becomes callable. - @ai-sdk/harness-grok-build@1.0.12 sets neither flag directly, but is built on @ai-sdk/harness-acp@1.0.13 (its exact pinned dependency, confirmed via npm pack), which sets supportsBuiltinToolApprovals: true (and supportsBuiltinToolFiltering: false) — so Grok Build gets the FRAMEWORK's own fallback instead: @ai-sdk/harness/agent's compiled resolveHarnessAgentToolFiltering (dist/agent/index.js) routes every inactive-builtin call through the approval path and auto-denies it before execution. Different mechanism, same practical outcome — bash is still never executed. - Confirmed further: if an adapter supported NEITHER flag, the framework does not silently ignore inactiveTools for a builtin — HarnessAgent's constructor throws HarnessCapabilityUnsupportedError (dist/agent/index.js) rather than accepting a setting it cannot honour. So this recommendation is never a silent no-op for any adapter that accepts it at all. This is a coarse, all-or-nothing exclusion decided once at session-construction time — never a Gate.guard()-evaluated verdict per call. A workspace that genuinely needs bash passes filterBash: false and keeps permissionMode: 'allow-edits' (or 'allow-reads'): every bash call then pauses as a builtin approval request and intuticApprovalResponder gives it a per-call verdict (see the permissionMode bullet on recommendedHarnessSettings). |
networkPolicy | RecommendedNetworkPolicy | NOT a HarnessAgentSettings field — apply it to the sandbox session via its optional setNetworkPolicy?.() (e.g. from sandboxConfig.onSession), or configure the equivalent on the sandbox provider. Optional-call it: providers without a local enforcement primitive omit the method, and a missing implementation means NO egress policy is applied at all — check for undefined and treat that as an uncovered surface, not a silent ok. |
permissionMode | "allow-reads" | "allow-edits" | Spread into HarnessAgentSettings. |
RecommendedHarnessSettingsOptions
Properties
| Property | Type | Description |
|---|---|---|
allowedHosts? | readonly string[] | Hosts sandbox egress should be limited to. When omitted, the recommended policy is { mode: 'deny-all' } — deny-by-default until the caller decides what the sandbox legitimately needs to reach. |
filterBash? | boolean | Whether to also recommend excluding the sandbox's native bash builtin from the tool set entirely, via HarnessAgentSettings.inactiveTools. Defaults to true — see RecommendedHarnessSettings.inactiveTools for what this does and does not guarantee. |
permissionMode? | "allow-reads" | "allow-edits" | 'allow-reads' is stricter (built-ins can read but not write); the default recommendation is 'allow-edits' (read + write inside the sandbox, but not the unrestricted 'allow-all' the framework itself defaults to). 'allow-all' is deliberately not accepted here — asking this function for the framework's own bypass default would make the helper's name a lie. |
SandboxBootstrapCallbackOptions
Structural copy of @ai-sdk/harness/agent's HarnessAgentSandboxConfig['onBootstrap'] parameter shape (dist/agent/index.d.ts), narrowed to the fields this module reads.
Properties
| Property | Modifier | Type |
|---|---|---|
abortSignal? | readonly | AbortSignal |
session | readonly | SandboxWriteSession |
workDir | readonly | string |
SandboxWriteSession
Structural copy of @ai-sdk/provider-utils's SandboxSession, narrowed to the methods intuticSandboxBootstrap's onBootstrap calls. Confirmed field-for-field against @ai-sdk/provider-utils@5.0.27's shipped dist/index.d.ts (WriteFileOptions<string> plus its own encoding addition on writeTextFile; readTextFile resolves null for a missing file).
Methods
readTextFile()
readTextFile(options): PromiseLike<string | null>;Parameters
| Parameter | Type |
|---|---|
options | { abortSignal?: AbortSignal; path: string; } |
options.abortSignal? | AbortSignal |
options.path | string |
Returns
PromiseLike<string | null>
run()
run(options): PromiseLike<{
exitCode: number;
stderr: string;
stdout: string;
}>;Parameters
| Parameter | Type |
|---|---|
options | { abortSignal?: AbortSignal; command: string; } |
options.abortSignal? | AbortSignal |
options.command | string |
Returns
PromiseLike<{ exitCode: number; stderr: string; stdout: string; }>
writeTextFile()
writeTextFile(options): PromiseLike<void>;Parameters
| Parameter | Type |
|---|---|
options | { content: string; encoding?: string; path: string; } |
options.content | string |
options.encoding? | string |
options.path | string |
Returns
PromiseLike<void>
Type Aliases
HarnessNetworkPolicy
type HarnessNetworkPolicy =
| {
mode: "allow-all";
}
| {
mode: "deny-all";
}
| {
allowedCIDRs?: readonly string[];
allowedHosts?: readonly string[];
deniedCIDRs?: readonly string[];
mode: "custom";
};Structural copy of @ai-sdk/harness's HarnessV1NetworkPolicy. The two custom branches of the real union each require at least one allow field; this single structural branch keeps both optional and relies on the test suite's assignability check against the real (stricter) type for the values this module itself constructs.
HarnessPermissionMode
type HarnessPermissionMode = "allow-reads" | "allow-edits" | "allow-all";Structural copy of @ai-sdk/harness's HarnessV1PermissionMode.
RecommendedNetworkPolicy
type RecommendedNetworkPolicy =
| {
mode: "deny-all";
}
| {
allowedHosts: readonly string[];
deniedCIDRs: readonly string[];
mode: "custom";
};The exact policy shapes recommendedHarnessSettings constructs — narrower than HarnessNetworkPolicy so the value is assignable to the REAL HarnessV1NetworkPolicy union (whose custom branches each require an allow field) without a cast; the test suite pins that assignability against the shipped type.
Functions
intuticApprovalResponder()
function intuticApprovalResponder(opts?): (requests) => Promise<HarnessToolApprovalContinuation[]>;Build an approval responder: a function that evaluates each pending tool-approval request via Gate.guard() and produces the toolApprovalContinuations array for the continue-turn call — agent.continueGenerate({ session, toolApprovalContinuations }) / continueStream(...):
import { HarnessAgent } from '@ai-sdk/harness/agent'
import { Gate, install } from '@intutic/gate'
import {
intuticApprovalResponder,
intuticStaticApprovals,
recommendedHarnessSettings,
} from '@intutic/gate/harness'
install(new Gate({ workspaceId: process.env.INTUTIC_WORKSPACE_ID }))
const agent = new HarnessAgent({
harness,
sandbox,
tools,
...recommendedHarnessSettings(), // permissionMode: 'allow-edits'
toolApproval: intuticStaticApprovals(tools), // every custom tool -> 'user-approval'
})
const respond = intuticApprovalResponder()
// ...run a turn; when it pauses awaiting approval, collect the pending
// tool-approval requests (stream parts or suspended-session rows), then:
const toolApprovalContinuations = await respond(pendingApprovals)
await agent.continueGenerate({ session, toolApprovalContinuations })Decision mapping, per request:
- gate allows ->
approved: true IntuticGateRefusal->approved: false,reason= the[Intutic Governance] BLOCKED: ...message (the runtime submits anexecution-deniedresult to the model in place of a tool result)- any OTHER throw ->
approved: falsewith a "gate crashed" reason — fail closed, same posture asdsh.ts's listener: this responder's output is a decision list consumed by a paused turn, and a deny is deliverable and auditable where an escaped throw could leave the turn suspended with no answer at all.
Requires a Gate — either passed via { gate }, or installed process-wide with install(new Gate(...)). A call with neither rejects before evaluating anything, refusing to answer approvals unguarded.
Parameters
| Parameter | Type |
|---|---|
opts | IntuticApprovalResponderOptions |
Returns
(requests) => Promise<HarnessToolApprovalContinuation[]>
intuticSandboxBootstrap()
function intuticSandboxBootstrap(opts?): IntuticSandboxBootstrap;Builds { bootstrapHash, onBootstrap } for HarnessAgentSettings.sandboxConfig — a channel the framework provides but nothing used:
import { HarnessAgent } from '@ai-sdk/harness/agent'
import { intuticSandboxBootstrap, recommendedHarnessSettings } from '@intutic/gate/harness'
const agent = new HarnessAgent({
harness,
sandbox,
...recommendedHarnessSettings(),
sandboxConfig: intuticSandboxBootstrap({
policySnapshotRules: fs.readFileSync(snapshotPath(), 'utf-8'),
workspaceId: process.env.INTUTIC_WORKSPACE_ID,
}),
})Writes two files under <workDir>/<bootstrapDir> (default .intutic/hooks) — the rules text verbatim, and a self-contained hook script implementing Tier A1 against it (see renderSandboxGateScript) — plus .claude/settings.json at <workDir>/.claude/settings.json registering that script as a PreToolUse hook, and the same registration merged into the sandbox user's $HOME/.claude/settings.json (see writeSandboxUserSettings — the project-level file alone does not reach per-session work directories). See the module-level comment above this section for what this does and does not cover, and why.
Parameters
| Parameter | Type |
|---|---|
opts | IntuticSandboxBootstrapOptions |
Returns
intuticStaticApprovals()
function intuticStaticApprovals(tools, opts?): Readonly<Record<string, "user-approval" | "denied">>;Build the static toolApproval record for HarnessAgentSettings.
The record shape is Readonly<Record<string, ToolApprovalStatus>> — STATIC per-tool statuses "without callback support" (the shipped doc comment's own words), so a per-call gate function cannot be expressed here at all. What a static record CAN express is deny-by-default routing: every listed tool is marked 'user-approval', which pauses the turn on every call to it, and intuticApprovalResponder then answers each pause with a real per-call Gate.guard() verdict. 'denied' is deliberately NOT the default here — it would refuse every call to the tool unconditionally, without the gate ever seeing the arguments; use { deny: [...] } for tools where that is actually what you want.
Two scope limits, stated plainly:
- This covers CUSTOM host-executed tools only. Built-in sandbox tools (read/write/edit/bash/...) never consult
toolApproval— which of them pause is decided bypermissionMode(default'allow-all': none), and a paused built-in reaches intuticApprovalResponder the same way a custom tool's approval does. See recommendedHarnessSettings. - A tool absent from this record has status
undefined= "not-applicable" and runs WITHOUT any approval pause. Pass the sametoolsrecord you give the agent (or every tool name), not a subset.
Parameters
| Parameter | Type |
|---|---|
tools | readonly string[] | Record<string, unknown> |
opts | IntuticStaticApprovalsOptions |
Returns
Readonly<Record<string, "user-approval" | "denied">>
intuticSubmitApprovals()
function intuticSubmitApprovals(
session,
requests,
opts?
): Promise<IntuticSubmitApprovalsResult>;Evaluate pending approvals and deliver the decisions through session.submitToolApproval when the adapter supports it.
submitToolApproval is an OPTIONAL adapter-session method and per-adapter support varies (harness-claude-code implements it, harness-grok-build does not — see module doc point 2). This helper treats it as an optimization: when present, every decision is submitted directly (the turn resumes in place); when absent, nothing is submitted and the caller passes the returned continuations to continueGenerate/continueStream — the portable path — instead. Either way the SAME evaluated decisions are returned, so the caller never re-runs the gate for one delivery mechanism vs. the other.
Parameters
| Parameter | Type |
|---|---|
session | HarnessSessionLike |
requests | readonly HarnessApprovalRequest[] |
opts | IntuticApprovalResponderOptions |
Returns
Promise<IntuticSubmitApprovalsResult>
recommendedHarnessSettings()
function recommendedHarnessSettings(opts?): RecommendedHarnessSettings;The settings posture this integration recommends, as a value callers can spread/apply rather than prose they have to transcribe:
permissionMode: 'allow-edits'(or'allow-reads') — because the framework's own default is'allow-all', under which built-in sandbox tools (includingbash) run with no approval surface this gate can reach. Under'allow-edits'every bash-kind built-in call pauses as akind: 'builtin'approval request that intuticApprovalResponder answers per call;'allow-reads'adds write/edit to that. Read-kind built-ins never pause in any mode — the mode is the only control for them.inactiveTools: ['bash'](default) — removes bash outright. With the default, bash calls never reach the gate because bash is never callable;filterBash: falseis the per-call alternative (bash stays available and every call is gated as above).- a
networkPolicyfor the sandbox —{ mode: 'deny-all' }by default, or acustomallow-list (with the cloud-metadata CIDR denied) whenallowedHostsis given. This is the honest egress-governance story for server-side sandbox execution: the Intutic proxy on the developer's machine never sees sandbox traffic, and host-level filtering is what the sandbox layer actually offers. It is coarse — no DLP, no per-request policy — and the integration docs say so.
Parameters
| Parameter | Type |
|---|---|
opts | RecommendedHarnessSettingsOptions |
Returns
renderHarnessToolInput()
function renderHarnessToolInput(input): ToolInput;Render a pending approval's input as the tool_input object Gate.guard evaluates.
Mirrors dsh.ts's renderDshToolInput, plus one harness-specific case: a HarnessAgentPendingToolApproval carries its input as a JSON string, so strings are parsed first. A string that does not parse (or parses to a non-object) falls back to { args: [...] } so the gate still has something to evaluate rather than throwing on a shape it does not recognise.
Parameters
| Parameter | Type |
|---|---|
input | unknown |