@intutic/gate / openai
@intutic/gate/openai
OpenAI Agents SDK (TypeScript) adapter: intuticToolGuardrail(), wrapTools(), wrapAgent(), installOpenAiGate().
@intutic/gate/openai — a framework-specific specialization of the same idea wrapTools.ts implements generically (see that file's module doc): wrap the framework's own veto point around this package's Gate.guard(). The TypeScript twin of the Python SDK's intutic_clawde.gate.adapters.openai_agents (intutic_tool_guardrail).
The veto mechanism — CONFIRMED against a real install
@openai/agents@0.16.1 was installed as a devDependency of this package and its shipped .d.ts/compiled source read directly (not inferred from docs alone). @openai/agents is a meta-package (export * from '@openai/agents-core' + '@openai/agents-openai' + export * as realtime); the relevant surface lives in @openai/agents-core:
The SDK's documented per-tool veto point is a tool input guardrail (dist/toolGuardrail.d.ts, present since @openai/agents-core 0.3.8, 2026-01-14):
interface ToolInputGuardrailDefinition<TContext> {
type: 'tool_input'
name: string
run: (data: { context: RunContext; agent: Agent; toolCall: protocol.FunctionCallItem })
=> Promise<ToolGuardrailFunctionOutput> // { behavior, outputInfo? }
}
// behavior: {type:'allow'} | {type:'rejectContent'; message} | {type:'throwException'}The runner (dist/runner/toolExecution.js) runs every guardrail in FunctionTool.inputGuardrails BEFORE invoke — on rejectContent the tool body never runs and message becomes the model-visible tool output (resolveFunctionFailureOutput). protocol.FunctionCallItem.arguments is the RAW, unparsed JSON arguments string (z.ZodString), same as the Python SDK's ToolContext.tool_arguments — hence toolInputFromArguments.
Guardrail attachment is PER-TOOL: there is no run-level tool guardrail. That is what wrapTools and wrapAgent are for.
Ordering caveat (documented, verified in toolExecution.js): for a tool whose needsApproval resolves true, input guardrails run AFTER the human-approval flow by default. Opt in to guardrails-before-approval with the run config toolExecution: { preApprovalInputGuardrails: true } (available since @openai/agents-core 0.11.8; dist/runner/runConfig.d.ts).
The MCP materialization gotcha — why wrapAgent patches getAllTools
Tools contributed by agent.mcpServers do NOT live in agent.tools: they are materialized per run by agent.getAllTools() → getMcpTools() → mcpToFunctionTool() (dist/mcp.js), which builds them with the same tool() factory — same guardrail path — but attaches NO guardrails of its own. Mapping over agent.tools alone therefore silently misses every MCP-sourced tool. wrapAgent closes that hole by patching the agent INSTANCE's getAllTools to inject the Intutic guardrail into every FunctionTool it returns, MCP-derived ones included (the runner resolves tools exclusively through executionAgent.getAllTools(...) — dist/runner/modelPreparation.js). Injection is idempotent (keyed on GUARDRAIL_NAME), so tools that are both in agent.tools and in the getAllTools result are gated exactly once.
Non-function tools — what is and is not gateable
- hostedMcpTool entries execute at OpenAI's side, but expose a client-visible approval loop:
providerData.require_approval/providerData.on_approval(snake_case POST-construction — the camelCaserequireApproval/onApprovalexist only ashostedMcpTool()factory options; the runner reads only the snake_case fields,dist/runner/mcpApprovals.js).wrapToolsrewrites these entries torequire_approval: 'always'plus an Intuticon_approvalthat guards each call and composes any pre-existing handler. NOTEhostedMcpTool()'s own default isrequire_approval: 'never'— an unwrapped hosted MCP tool is entirely ungated. - shellTool / applyPatchTool (local) carry
needsApproval+onApprovalauto-resolvers.wrapToolsforcesneedsApprovalto always-true and installs a composed IntuticonApproval: gate refusal → reject (with the BLOCKED message); gate allow → the tool's ORIGINAL approval policy is replayed (originalneedsApprovalfalse → approve; originalonApprovalpresent → delegated to). When the original policy demanded a human and no originalonApprovalexists, the decision is deliberately left undecided so the interruption still surfaces — see OpenAiApprovalDecision for the verified mechanism this relies on. - computerTool has NO
onApproval— only a per-actionneedsApprovalpredicate (plusonSafetyCheck).wrapTools(and intuticComputerNeedsApproval) can therefore only turn a gate refusal intoneedsApproval → true, which surfaces as a pending interruption inresult.interruptionsthat YOUR code muststate.reject(...). Stated honestly: a caller whostate.approve()s that interruption overrides the gate — the SDK gives this adapter no reject-with-message hook for computer actions. (The refusal is still emitted to the control plane astool_blockedtelemetry either way.) - Hosted tools (
webSearchTool,fileSearchTool,codeInterpreterTool,imageGenerationTool, ...) are bareHostedToolobjects executed server-side at OpenAI: NO client-side guardrail, approval hook, or wrapper ever sees them. The hosted-environment variant ofshellTool(environment.type: 'container_*') is in the same bucket — its factory options typeneedsApproval/onApprovalasnever.wrapToolspasses all of these through UNCHANGED rather than pretending they are gated.
Realtime
@openai/agents-realtime honours FunctionTool.inputGuardrails through the same runToolInputGuardrails helper (realtimeSession.js), so a guardrail injected by this module also gates voice-agent tool calls. __tests__/openai.test.ts drives a real RealtimeSession (0.16.1) with a stand-in transport emitting function_call: a refused call never executes and the BLOCKED message goes back via sendFunctionCallOutput.
LLM egress and the tracing DLP leak
Routing chat traffic through the Intutic proxy is env-only for this SDK: the underlying openai client (7.5.0, client.js) reads OPENAI_BASE_URL whenever no explicit baseURL/client is passed to OpenAIProvider — so a sourced .env.intutic governs egress with zero code, unlike the Vercel AI SDK (see vercel.ts's module doc). The default transport is the HTTP Responses API, whose wire shape the Intutic proxy already parses.
BUT: @openai/agents-openai's tracing exporter POSTs trace spans — including tool inputs/outputs — to a HARDCODED https://api.openai.com/v1/traces/ingest (openaiTracingExporter.js), bypassing OPENAI_BASE_URL entirely, and tracing is ON by default. That is a real DLP exfiltration path around the proxy. suppressAgentsTracingExport (called by installOpenAiGate unless opted out) sets the SDK's own kill-switch env OPENAI_AGENTS_DISABLE_TRACING=1 (read lazily per run — @openai/agents-core's config.js). Callers who need traces should instead re-point the exporter endpoint in code (new OpenAITracingExporter({ endpoint }) + setTraceProcessors) at an approved collector and opt out with { tracingExport: 'keep' }.
What this module does NOT import
Deliberately no runtime dependency on @openai/agents/ @openai/agents-core — the OpenAi* types below are narrow structural copies of only the shapes this adapter touches, same policy as mastra.ts / vercel.ts / dsh.ts (see dsh.ts's module doc for the full rationale). The real packages are devDependencies used by __tests__/openai.test.ts to type-check these structural types against the real shipped ones and to drive the real runner machinery end to end.
Interfaces
OpenAiAgentLike
Minimal structural slice of Agent needed by wrapAgent: the static tool list plus the per-run materializer the runner actually consumes (dist/runner/modelPreparation.js).
Properties
| Property | Type |
|---|---|
tools | object[] |
Methods
getAllTools()
getAllTools(...args): Promise<object[]>;Parameters
| Parameter | Type |
|---|---|
...args | never[] |
Returns
Promise<object[]>
OpenAiApprovalDecision
Structural copy of the { approve, reason? } decision onApproval / on_approval handlers resolve with — with one deliberate widening: approve is optional here.
For SHELL and APPLY_PATCH tools the runner checks decision.approve === true and === false explicitly and treats anything else as "no decision made", leaving the approval pending so it surfaces as an interruption (VERIFIED against @openai/agents-core@0.16.1's dist/runner/toolExecution.js, resolveToolApproval()). This adapter returns {} exactly once — when the gate allows a call whose ORIGINAL tool policy demanded a human and no original onApproval exists — so the caller's own interruption flow survives wrapping. That fall-through is real but UNDOCUMENTED SDK behaviour; see the TD entry this phase filed.
Hosted MCP on_approval results are consumed with a TRUTHY check instead (dist/runner/mcpApprovals.js), so {} there would silently REJECT — the hosted-MCP wrapper never uses the fall-through.
Properties
| Property | Type |
|---|---|
approve? | boolean |
reason? | string |
OpenAiFunctionCallLike
Slice of protocol.FunctionCallItem read by the guardrail: arguments is the RAW, unparsed JSON string (the full item also carries callId, type: 'function_call', ...).
Properties
| Property | Type |
|---|---|
arguments | string |
name | string |
OpenAiGateConfig
Config for installOpenAiGate. Extends the core GateConfig.
Extends
Properties
| Property | Type | Default value | Description | Inherited from |
|---|---|---|---|---|
allowlistPath? | string | '.intutic/image-allowlist.json' | The image allowlist, relative to repoRoot unless absolute. | GateConfig.allowlistPath |
enforce? | boolean | true | false turns Gate.guard into a no-op. | GateConfig.enforce |
repoRoot? | string | '.' | Where relative manifest and allowlist paths resolve from. | GateConfig.repoRoot |
sessionId? | string | undefined | Overrides the generated-per-process session id. Mirrors INTUTIC_SESSION_ID for callers that already have one to hand. | - |
tracingExport? | "intutic" | "disable" | "keep" | undefined | What to do about the tracing exporter's proxy bypass (see suppressAgentsTracingExport). 'disable' (default) sets the SDK's kill-switch env; 'keep' leaves tracing exactly as configured — choose it ONLY once the exporter endpoint is re-pointed somewhere approved, otherwise tool inputs/outputs leave via api.openai.com. | - |
useHookGate? | boolean | true | Call the control plane's hook gate (needs a client). | GateConfig.useHookGate |
useSopRules? | boolean | true | Fetch and apply the workspace's SOP rules (needs a client). | GateConfig.useSopRules |
workspaceId? | string | '' | Compared with the workspace the policy snapshot was compiled for, and scopes the SOP-rule fetch. | GateConfig.workspaceId |
OpenAiToolApprovalItemLike
Slice of RunToolApprovalItem the approval handlers read.
Properties
| Property | Type |
|---|---|
rawItem? | unknown |
OpenAiToolGuardrailFunctionOutput
Structural copy of ToolGuardrailFunctionOutput — the subset of behavior values this adapter emits (throwException is never used: a refusal is a model-visible rejection, not a run-fatal tripwire).
Properties
| Property | Type |
|---|---|
behavior | | { type: "allow"; } | { message: string; type: "rejectContent"; } |
outputInfo? | unknown |
OpenAiToolInputGuardrail
Structural copy of ToolInputGuardrailDefinition — directly assignable both to a built FunctionTool.inputGuardrails array and to the tool() factory's inputGuardrails option.
Properties
| Property | Type |
|---|---|
name | string |
run | (data) => Promise<OpenAiToolGuardrailFunctionOutput> |
type | "tool_input" |
OpenAiToolInputGuardrailData
Slice of ToolInputGuardrailData the guardrail reads. The real data also carries context: RunContext and agent: Agent, which pass through this (wider-parameter) structural type untouched.
Properties
| Property | Type |
|---|---|
toolCall | OpenAiFunctionCallLike |
OpenAiWrapOptions
Properties
| Property | Type | Description |
|---|---|---|
gate? | Gate | Overrides the process-wide installed gate (install(new Gate(...))). |
Variables
GUARDRAIL_NAME
const GUARDRAIL_NAME: "intutic" = 'intutic';The name every guardrail this module builds carries. Also the idempotency key: a FunctionTool whose inputGuardrails already contains a guardrail with this name is never double-wrapped.
Functions
installOpenAiGate()
function installOpenAiGate(config?): Gate;Parameters
| Parameter | Type |
|---|---|
config | OpenAiGateConfig |
Returns
intuticComputerNeedsApproval()
function intuticComputerNeedsApproval(opts?): (runContext, action, callId?) => Promise<boolean>;Gate-driven needsApproval predicate for computerTool — the ONLY pre-execution hook that tool exposes (no guardrails, no onApproval):
computerTool({ computer, needsApproval: intuticComputerNeedsApproval() })A gate-refused (or gate-crashed — fail closed) action resolves true, which the runner turns into a pending interruption in result.interruptions; your interruption handler must state.reject(...) it. This is honestly weaker than the guardrail path: the SDK offers no way for this adapter to auto-reject a computer action with a message, and a handler that state.approve()s the interruption overrides the gate. An allowed action defers to fallback (the predicate the tool would otherwise have used; defaults to no approval).
Parameters
| Parameter | Type |
|---|---|
opts | OpenAiWrapOptions & object |
Returns
(runContext, action, callId?) => Promise<boolean>
intuticToolGuardrail()
function intuticToolGuardrail(opts?): OpenAiToolInputGuardrail;Builds the Intutic tool input guardrail — { type: 'tool_input', name: 'intutic', run } — attachable to any FunctionTool, either at construction:
import { tool } from '@openai/agents'
import { Gate, install } from '@intutic/gate'
import { intuticToolGuardrail } from '@intutic/gate/openai'
install(new Gate({ workspaceId: process.env.INTUTIC_WORKSPACE_ID }))
const shell = tool({
name: 'shell',
description: 'Run a shell command',
parameters: z.object({ command: z.string() }),
inputGuardrails: [intuticToolGuardrail()],
execute: async ({ command }) => run(command),
})or after the fact via wrapTools/wrapAgent. On refusal the guardrail resolves { behavior: { type: 'rejectContent', message } } — the runner skips the tool body and hands message (the [Intutic Governance] BLOCKED: ... text) back to the model in place of a tool result. On an unexpected gate crash it rejects too (fail closed). On allow it resolves { behavior: { type: 'allow' } }.
Requires a Gate — either passed via { gate }, or installed process-wide with install(new Gate(...)). A call with neither throws out of run (which the runner escalates into a run-fatal ToolCallError) rather than running the tool unguarded — same posture as wrapTool/intuticHooks/intuticToolApproval.
Parameters
| Parameter | Type |
|---|---|
opts | OpenAiWrapOptions |
Returns
intuticTracingExporterOptions()
function intuticTracingExporterOptions(env?): object;Where to point @openai/agents' tracing exporter so traces stay governed rather than lost: the control plane's ingest route, authenticated with the same API key the gate uses. Pass the result to the SDK's own exporter —
import { setTraceProcessors, BatchTraceProcessor, OpenAITracingExporter } from '@openai/agents'
setTraceProcessors([new BatchTraceProcessor(new OpenAITracingExporter(intuticTracingExporterOptions()))])— and pass { tracingExport: 'intutic' } to installOpenAiGate. No @openai/agents import here, so this module stays loadable without the SDK. Generation spans become traces in the ledger; tool spans are counted, not stored (the route's own doc says why).
Parameters
| Parameter | Type | Default value |
|---|---|---|
env | ProcessEnv | process.env |
Returns
object
| Name | Type |
|---|---|
apiKey | string |
endpoint | string |
suppressAgentsTracingExport()
function suppressAgentsTracingExport(): void;Close the tracing DLP leak: set the SDK's own kill-switch env (OPENAI_AGENTS_DISABLE_TRACING=1) so the default exporter never POSTs trace spans — which carry tool inputs/outputs — to its HARDCODED https://api.openai.com/v1/traces/ingest endpoint (which ignores OPENAI_BASE_URL, i.e. bypasses the Intutic proxy). The SDK reads the env lazily per run, so calling this any time before run(...) is effective.
To keep traces instead, re-point the exporter at an approved collector in your own code — setTraceProcessors([new BatchTraceProcessor(new OpenAITracingExporter({ endpoint }))]) — and pass { tracingExport: 'keep' } to installOpenAiGate.
Returns
void
toolInputFromArguments()
function toolInputFromArguments(raw): ToolInput;Parse the raw JSON arguments string into the object Gate.guard() evaluates. Port of the Python adapter's _tool_input_of — same fallback keys (raw, value): a call the gate cannot parse should still reach a rule, not silently skip evaluation.
Parameters
| Parameter | Type |
|---|---|
raw | string | null | undefined |
Returns
wrapAgent()
function wrapAgent<T>(agent, opts?): T;Gate every tool an agent can reach, MCP-sourced tools included.
import { Agent, run } from '@openai/agents'
import { installOpenAiGate, wrapAgent } from '@intutic/gate/openai'
installOpenAiGate({ workspaceId: process.env.INTUTIC_WORKSPACE_ID })
const agent = wrapAgent(new Agent({
name: 'ops',
tools: [shell],
mcpServers: [filesystemServer],
}))
const result = await run(agent, prompt)Two things happen, both idempotent:
agent.toolsis replaced with wrapTools' wrapped copies.- The agent INSTANCE's
getAllTools— the materializer the runner actually consumes each turn, and the ONLY placemcpServers-derived tools ever appear (see module doc) — is patched to wrap everything it returns.
MUTATES (and returns) the agent passed in.
Scope, stated plainly: this wraps ONE agent. Agents reached via handoffs are separate objects — wrap each of them too.
Type Parameters
| Type Parameter |
|---|
T extends OpenAiAgentLike |
Parameters
| Parameter | Type |
|---|---|
agent | T |
opts | OpenAiWrapOptions |
Returns
T
wrapTools()
function wrapTools<T>(tools, opts?): T[];Wrap a list of OpenAI Agents SDK tools so Gate.guard() runs before each one executes — see the module doc for exactly what each tool type gets (and what hosted tools cannot get). Returns NEW tool objects (shallow copies); the ones passed in are not mutated. Wrapping twice is a no-op.
NOTE: this covers only the tools you hand it. Tools materialized from agent.mcpServers do not exist yet at wrapping time — use wrapAgent for an agent with MCP servers.
Type Parameters
| Type Parameter |
|---|
T extends object |
Parameters
| Parameter | Type |
|---|---|
tools | readonly T[] |
opts | OpenAiWrapOptions |
Returns
T[]