@intutic/gate 1.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +261 -0
- package/dist/actions.d.ts +86 -0
- package/dist/actions.d.ts.map +1 -0
- package/dist/actions.js +239 -0
- package/dist/actions.js.map +1 -0
- package/dist/client.d.ts +94 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +207 -0
- package/dist/client.js.map +1 -0
- package/dist/dsh.d.ts +162 -0
- package/dist/dsh.d.ts.map +1 -0
- package/dist/dsh.js +189 -0
- package/dist/dsh.js.map +1 -0
- package/dist/errors.d.ts +41 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +53 -0
- package/dist/errors.js.map +1 -0
- package/dist/eve.d.ts +425 -0
- package/dist/eve.d.ts.map +1 -0
- package/dist/eve.js +378 -0
- package/dist/eve.js.map +1 -0
- package/dist/gate.d.ts +113 -0
- package/dist/gate.d.ts.map +1 -0
- package/dist/gate.js +274 -0
- package/dist/gate.js.map +1 -0
- package/dist/harness.d.ts +482 -0
- package/dist/harness.d.ts.map +1 -0
- package/dist/harness.js +504 -0
- package/dist/harness.js.map +1 -0
- package/dist/headers.d.ts +26 -0
- package/dist/headers.d.ts.map +1 -0
- package/dist/headers.js +30 -0
- package/dist/headers.js.map +1 -0
- package/dist/imagecheck.d.ts +93 -0
- package/dist/imagecheck.d.ts.map +1 -0
- package/dist/imagecheck.js +286 -0
- package/dist/imagecheck.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/managedAgents.d.ts +375 -0
- package/dist/managedAgents.d.ts.map +1 -0
- package/dist/managedAgents.js +446 -0
- package/dist/managedAgents.js.map +1 -0
- package/dist/mastra.d.ts +175 -0
- package/dist/mastra.d.ts.map +1 -0
- package/dist/mastra.js +161 -0
- package/dist/mastra.js.map +1 -0
- package/dist/openai.d.ts +392 -0
- package/dist/openai.d.ts.map +1 -0
- package/dist/openai.js +658 -0
- package/dist/openai.js.map +1 -0
- package/dist/shlex.d.ts +16 -0
- package/dist/shlex.d.ts.map +1 -0
- package/dist/shlex.js +78 -0
- package/dist/shlex.js.map +1 -0
- package/dist/snapshot.d.ts +111 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +243 -0
- package/dist/snapshot.js.map +1 -0
- package/dist/soprules.d.ts +88 -0
- package/dist/soprules.d.ts.map +1 -0
- package/dist/soprules.js +176 -0
- package/dist/soprules.js.map +1 -0
- package/dist/trueforge.d.ts +191 -0
- package/dist/trueforge.d.ts.map +1 -0
- package/dist/trueforge.js +184 -0
- package/dist/trueforge.js.map +1 -0
- package/dist/vercel.d.ts +174 -0
- package/dist/vercel.d.ts.map +1 -0
- package/dist/vercel.js +157 -0
- package/dist/vercel.js.map +1 -0
- package/dist/workflow.d.ts +209 -0
- package/dist/workflow.d.ts.map +1 -0
- package/dist/workflow.js +254 -0
- package/dist/workflow.js.map +1 -0
- package/dist/wrapTools.d.ts +68 -0
- package/dist/wrapTools.d.ts.map +1 -0
- package/dist/wrapTools.js +95 -0
- package/dist/wrapTools.js.map +1 -0
- package/package.json +123 -0
package/dist/openai.js
ADDED
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI Agents SDK (TypeScript) adapter: `intuticToolGuardrail()`,
|
|
3
|
+
* `wrapTools()`, `wrapAgent()`, `installOpenAiGate()`.
|
|
4
|
+
*
|
|
5
|
+
* `@intutic/gate/openai` — a framework-specific specialization of the same
|
|
6
|
+
* idea `wrapTools.ts` implements generically (see that file's module doc):
|
|
7
|
+
* wrap the framework's own veto point around this package's `Gate.guard()`.
|
|
8
|
+
* The TypeScript twin of the Python SDK's
|
|
9
|
+
* `intutic_clawde.gate.adapters.openai_agents` (`intutic_tool_guardrail`).
|
|
10
|
+
*
|
|
11
|
+
* ## The veto mechanism — CONFIRMED against a real install
|
|
12
|
+
*
|
|
13
|
+
* `@openai/agents@0.16.1` was installed as a devDependency of this package
|
|
14
|
+
* and its shipped `.d.ts`/compiled source read directly (not inferred from
|
|
15
|
+
* docs alone). `@openai/agents` is a meta-package (`export * from
|
|
16
|
+
* '@openai/agents-core'` + `'@openai/agents-openai'` + `export * as
|
|
17
|
+
* realtime`); the relevant surface lives in `@openai/agents-core`:
|
|
18
|
+
*
|
|
19
|
+
* The SDK's documented per-tool veto point is a **tool input guardrail**
|
|
20
|
+
* (`dist/toolGuardrail.d.ts`, present since `@openai/agents-core` 0.3.8,
|
|
21
|
+
* 2026-01-14):
|
|
22
|
+
*
|
|
23
|
+
* ```ts
|
|
24
|
+
* interface ToolInputGuardrailDefinition<TContext> {
|
|
25
|
+
* type: 'tool_input'
|
|
26
|
+
* name: string
|
|
27
|
+
* run: (data: { context: RunContext; agent: Agent; toolCall: protocol.FunctionCallItem })
|
|
28
|
+
* => Promise<ToolGuardrailFunctionOutput> // { behavior, outputInfo? }
|
|
29
|
+
* }
|
|
30
|
+
* // behavior: {type:'allow'} | {type:'rejectContent'; message} | {type:'throwException'}
|
|
31
|
+
* ```
|
|
32
|
+
*
|
|
33
|
+
* The runner (`dist/runner/toolExecution.js`) runs every guardrail in
|
|
34
|
+
* `FunctionTool.inputGuardrails` BEFORE `invoke` — on `rejectContent` the
|
|
35
|
+
* tool body never runs and `message` becomes the model-visible tool output
|
|
36
|
+
* (`resolveFunctionFailureOutput`). `protocol.FunctionCallItem.arguments` is
|
|
37
|
+
* the RAW, unparsed JSON arguments string (`z.ZodString`), same as the Python
|
|
38
|
+
* SDK's `ToolContext.tool_arguments` — hence {@link toolInputFromArguments}.
|
|
39
|
+
*
|
|
40
|
+
* Guardrail attachment is PER-TOOL: there is no run-level tool guardrail.
|
|
41
|
+
* That is what {@link wrapTools} and {@link wrapAgent} are for.
|
|
42
|
+
*
|
|
43
|
+
* Ordering caveat (documented, verified in `toolExecution.js`): for a tool
|
|
44
|
+
* whose `needsApproval` resolves true, input guardrails run AFTER the
|
|
45
|
+
* human-approval flow by default. Opt in to guardrails-before-approval with
|
|
46
|
+
* the run config `toolExecution: { preApprovalInputGuardrails: true }`
|
|
47
|
+
* (available since `@openai/agents-core` 0.11.8;
|
|
48
|
+
* `dist/runner/runConfig.d.ts`).
|
|
49
|
+
*
|
|
50
|
+
* ## The MCP materialization gotcha — why `wrapAgent` patches `getAllTools`
|
|
51
|
+
*
|
|
52
|
+
* Tools contributed by `agent.mcpServers` do NOT live in `agent.tools`: they
|
|
53
|
+
* are materialized per run by `agent.getAllTools()` → `getMcpTools()` →
|
|
54
|
+
* `mcpToFunctionTool()` (`dist/mcp.js`), which builds them with the same
|
|
55
|
+
* `tool()` factory — same guardrail path — but attaches NO guardrails of its
|
|
56
|
+
* own. Mapping over `agent.tools` alone therefore silently misses every
|
|
57
|
+
* MCP-sourced tool. {@link wrapAgent} closes that hole by patching the
|
|
58
|
+
* agent INSTANCE's `getAllTools` to inject the Intutic guardrail into every
|
|
59
|
+
* `FunctionTool` it returns, MCP-derived ones included (the runner resolves
|
|
60
|
+
* tools exclusively through `executionAgent.getAllTools(...)` —
|
|
61
|
+
* `dist/runner/modelPreparation.js`). Injection is idempotent (keyed on
|
|
62
|
+
* {@link GUARDRAIL_NAME}), so tools that are both in `agent.tools` and in the
|
|
63
|
+
* `getAllTools` result are gated exactly once.
|
|
64
|
+
*
|
|
65
|
+
* ## Non-function tools — what is and is not gateable
|
|
66
|
+
*
|
|
67
|
+
* * **hostedMcpTool** entries execute at OpenAI's side, but expose a
|
|
68
|
+
* client-visible approval loop: `providerData.require_approval` /
|
|
69
|
+
* `providerData.on_approval` (snake_case POST-construction — the
|
|
70
|
+
* camelCase `requireApproval`/`onApproval` exist only as `hostedMcpTool()`
|
|
71
|
+
* factory options; the runner reads only the snake_case fields,
|
|
72
|
+
* `dist/runner/mcpApprovals.js`). `wrapTools` rewrites these entries to
|
|
73
|
+
* `require_approval: 'always'` plus an Intutic `on_approval` that guards
|
|
74
|
+
* each call and composes any pre-existing handler. NOTE
|
|
75
|
+
* `hostedMcpTool()`'s own default is `require_approval: 'never'` — an
|
|
76
|
+
* unwrapped hosted MCP tool is entirely ungated.
|
|
77
|
+
* * **shellTool / applyPatchTool** (local) carry `needsApproval` +
|
|
78
|
+
* `onApproval` auto-resolvers. `wrapTools` forces `needsApproval` to
|
|
79
|
+
* always-true and installs a composed Intutic `onApproval`: gate refusal
|
|
80
|
+
* → reject (with the BLOCKED message); gate allow → the tool's ORIGINAL
|
|
81
|
+
* approval policy is replayed (original `needsApproval` false → approve;
|
|
82
|
+
* original `onApproval` present → delegated to). When the original
|
|
83
|
+
* policy demanded a human and no original `onApproval` exists, the
|
|
84
|
+
* decision is deliberately left undecided so the interruption still
|
|
85
|
+
* surfaces — see {@link OpenAiApprovalDecision} for the verified
|
|
86
|
+
* mechanism this relies on.
|
|
87
|
+
* * **computerTool** has NO `onApproval` — only a per-action
|
|
88
|
+
* `needsApproval` predicate (plus `onSafetyCheck`). `wrapTools` (and
|
|
89
|
+
* {@link intuticComputerNeedsApproval}) can therefore only turn a gate
|
|
90
|
+
* refusal into `needsApproval → true`, which surfaces as a pending
|
|
91
|
+
* interruption in `result.interruptions` that YOUR code must
|
|
92
|
+
* `state.reject(...)`. Stated honestly: a caller who `state.approve()`s
|
|
93
|
+
* that interruption overrides the gate — the SDK gives this adapter no
|
|
94
|
+
* reject-with-message hook for computer actions. (The refusal is still
|
|
95
|
+
* emitted to the control plane as `tool_blocked` telemetry either way.)
|
|
96
|
+
* * **Hosted tools** (`webSearchTool`, `fileSearchTool`,
|
|
97
|
+
* `codeInterpreterTool`, `imageGenerationTool`, ...) are bare
|
|
98
|
+
* `HostedTool` objects executed server-side at OpenAI: NO client-side
|
|
99
|
+
* guardrail, approval hook, or wrapper ever sees them. The
|
|
100
|
+
* hosted-environment variant of `shellTool` (`environment.type:
|
|
101
|
+
* 'container_*'`) is in the same bucket — its factory options type
|
|
102
|
+
* `needsApproval`/`onApproval` as `never`. `wrapTools` passes all of
|
|
103
|
+
* these through UNCHANGED rather than pretending they are gated.
|
|
104
|
+
*
|
|
105
|
+
* ## Realtime
|
|
106
|
+
*
|
|
107
|
+
* `@openai/agents-realtime` honours `FunctionTool.inputGuardrails` through
|
|
108
|
+
* the same `runToolInputGuardrails` helper (verified in its shipped
|
|
109
|
+
* `realtimeSession.js`), so a guardrail injected by this module also gates
|
|
110
|
+
* voice-agent tool calls.
|
|
111
|
+
*
|
|
112
|
+
* ## LLM egress and the tracing DLP leak
|
|
113
|
+
*
|
|
114
|
+
* Routing chat traffic through the Intutic proxy is env-only for this SDK:
|
|
115
|
+
* the underlying `openai` client (7.5.0, `client.js`) reads
|
|
116
|
+
* `OPENAI_BASE_URL` whenever no explicit `baseURL`/client is passed to
|
|
117
|
+
* `OpenAIProvider` — so a sourced `.env.intutic` governs egress with zero
|
|
118
|
+
* code, unlike the Vercel AI SDK (see vercel.ts's module doc). The default
|
|
119
|
+
* transport is the HTTP Responses API, whose wire shape the Intutic proxy
|
|
120
|
+
* already parses.
|
|
121
|
+
*
|
|
122
|
+
* BUT: `@openai/agents-openai`'s tracing exporter POSTs trace spans —
|
|
123
|
+
* including tool inputs/outputs — to a HARDCODED
|
|
124
|
+
* `https://api.openai.com/v1/traces/ingest` (`openaiTracingExporter.js`),
|
|
125
|
+
* bypassing `OPENAI_BASE_URL` entirely, and tracing is ON by default. That
|
|
126
|
+
* is a real DLP exfiltration path around the proxy.
|
|
127
|
+
* {@link suppressAgentsTracingExport} (called by {@link installOpenAiGate}
|
|
128
|
+
* unless opted out) sets the SDK's own kill-switch env
|
|
129
|
+
* `OPENAI_AGENTS_DISABLE_TRACING=1` (read lazily per run —
|
|
130
|
+
* `@openai/agents-core`'s `config.js`). Callers who need traces should
|
|
131
|
+
* instead re-point the exporter endpoint in code
|
|
132
|
+
* (`new OpenAITracingExporter({ endpoint })` + `setTraceProcessors`) at an
|
|
133
|
+
* approved collector and opt out with `{ tracingExport: 'keep' }`.
|
|
134
|
+
*
|
|
135
|
+
* ## What this module does NOT import
|
|
136
|
+
*
|
|
137
|
+
* Deliberately no runtime dependency on `@openai/agents`/
|
|
138
|
+
* `@openai/agents-core` — the `OpenAi*` types below are narrow structural
|
|
139
|
+
* copies of only the shapes this adapter touches, same policy as mastra.ts /
|
|
140
|
+
* vercel.ts / dsh.ts (see dsh.ts's module doc for the full rationale). The
|
|
141
|
+
* real packages are devDependencies used by `__tests__/openai.test.ts` to
|
|
142
|
+
* type-check these structural types against the real shipped ones and to
|
|
143
|
+
* drive the real runner machinery end to end.
|
|
144
|
+
*
|
|
145
|
+
* @module
|
|
146
|
+
*/
|
|
147
|
+
import { randomUUID } from 'node:crypto';
|
|
148
|
+
import { Gate, active as activeGate, install } from './gate.js';
|
|
149
|
+
import { GateClient } from './client.js';
|
|
150
|
+
import { IntuticGateRefusal } from './errors.js';
|
|
151
|
+
/** The `name` every guardrail this module builds carries. Also the
|
|
152
|
+
* idempotency key: a `FunctionTool` whose `inputGuardrails` already contains
|
|
153
|
+
* a guardrail with this name is never double-wrapped. */
|
|
154
|
+
export const GUARDRAIL_NAME = 'intutic';
|
|
155
|
+
const WRAPPED = Symbol.for('intutic.gate.openai.wrapped');
|
|
156
|
+
const AGENT_WRAPPED = Symbol.for('intutic.gate.openai.agentWrapped');
|
|
157
|
+
// ------------------------------------------------------------------ helpers
|
|
158
|
+
function resolveGate(explicit, what) {
|
|
159
|
+
const g = explicit ?? activeGate();
|
|
160
|
+
if (g === null) {
|
|
161
|
+
throw new Error('No gate configured: call install(new Gate(...)) from @intutic/gate, or pass ' +
|
|
162
|
+
`{ gate } to ${what}. Refusing to run the tool unguarded.`);
|
|
163
|
+
}
|
|
164
|
+
return g;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Parse the raw JSON `arguments` string into the object `Gate.guard()`
|
|
168
|
+
* evaluates. Port of the Python adapter's `_tool_input_of` — same fallback
|
|
169
|
+
* keys (`raw`, `value`): a call the gate cannot parse should still reach a
|
|
170
|
+
* rule, not silently skip evaluation.
|
|
171
|
+
*/
|
|
172
|
+
export function toolInputFromArguments(raw) {
|
|
173
|
+
if (!raw)
|
|
174
|
+
return {};
|
|
175
|
+
let parsed;
|
|
176
|
+
try {
|
|
177
|
+
parsed = JSON.parse(raw);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return { raw };
|
|
181
|
+
}
|
|
182
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
183
|
+
return parsed;
|
|
184
|
+
}
|
|
185
|
+
return { value: parsed };
|
|
186
|
+
}
|
|
187
|
+
function crashMessage(err) {
|
|
188
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
189
|
+
return `[Intutic Governance] BLOCKED: gate crashed (${detail}) — failing closed rather than allowing an unevaluated call.`;
|
|
190
|
+
}
|
|
191
|
+
/** Refusal → rejection decision; anything else → fail-closed rejection.
|
|
192
|
+
* Same posture as dsh.ts's `createPreExecuteListener` (see that module's
|
|
193
|
+
* doc for why every adapter takes the closed direction on failure). */
|
|
194
|
+
function errorToDecision(err) {
|
|
195
|
+
if (err instanceof IntuticGateRefusal) {
|
|
196
|
+
return { approve: false, reason: err.message };
|
|
197
|
+
}
|
|
198
|
+
return { approve: false, reason: crashMessage(err) };
|
|
199
|
+
}
|
|
200
|
+
// -------------------------------------------------------- primary guardrail
|
|
201
|
+
/**
|
|
202
|
+
* Builds the Intutic tool input guardrail — `{ type: 'tool_input', name:
|
|
203
|
+
* 'intutic', run }` — attachable to any `FunctionTool`, either at
|
|
204
|
+
* construction:
|
|
205
|
+
*
|
|
206
|
+
* ```ts
|
|
207
|
+
* import { tool } from '@openai/agents'
|
|
208
|
+
* import { Gate, install } from '@intutic/gate'
|
|
209
|
+
* import { intuticToolGuardrail } from '@intutic/gate/openai'
|
|
210
|
+
*
|
|
211
|
+
* install(new Gate({ workspaceId: process.env.INTUTIC_WORKSPACE_ID }))
|
|
212
|
+
*
|
|
213
|
+
* const shell = tool({
|
|
214
|
+
* name: 'shell',
|
|
215
|
+
* description: 'Run a shell command',
|
|
216
|
+
* parameters: z.object({ command: z.string() }),
|
|
217
|
+
* inputGuardrails: [intuticToolGuardrail()],
|
|
218
|
+
* execute: async ({ command }) => run(command),
|
|
219
|
+
* })
|
|
220
|
+
* ```
|
|
221
|
+
*
|
|
222
|
+
* or after the fact via {@link wrapTools}/{@link wrapAgent}. On refusal the
|
|
223
|
+
* guardrail resolves `{ behavior: { type: 'rejectContent', message } }` —
|
|
224
|
+
* the runner skips the tool body and hands `message` (the
|
|
225
|
+
* `[Intutic Governance] BLOCKED: ...` text) back to the model in place of a
|
|
226
|
+
* tool result. On an unexpected gate crash it rejects too (fail closed). On
|
|
227
|
+
* allow it resolves `{ behavior: { type: 'allow' } }`.
|
|
228
|
+
*
|
|
229
|
+
* Requires a `Gate` — either passed via `{ gate }`, or installed
|
|
230
|
+
* process-wide with `install(new Gate(...))`. A call with neither throws
|
|
231
|
+
* out of `run` (which the runner escalates into a run-fatal `ToolCallError`)
|
|
232
|
+
* rather than running the tool unguarded — same posture as
|
|
233
|
+
* `wrapTool`/`intuticHooks`/`intuticToolApproval`.
|
|
234
|
+
*/
|
|
235
|
+
export function intuticToolGuardrail(opts = {}) {
|
|
236
|
+
return {
|
|
237
|
+
type: 'tool_input',
|
|
238
|
+
name: GUARDRAIL_NAME,
|
|
239
|
+
run: async (data) => {
|
|
240
|
+
const g = resolveGate(opts.gate, 'intuticToolGuardrail()');
|
|
241
|
+
try {
|
|
242
|
+
await g.guard(data.toolCall.name, toolInputFromArguments(data.toolCall.arguments));
|
|
243
|
+
}
|
|
244
|
+
catch (err) {
|
|
245
|
+
if (err instanceof IntuticGateRefusal) {
|
|
246
|
+
return {
|
|
247
|
+
behavior: { type: 'rejectContent', message: err.message },
|
|
248
|
+
outputInfo: { code: err.code, incidentId: err.incidentId },
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
return { behavior: { type: 'rejectContent', message: crashMessage(err) } };
|
|
252
|
+
}
|
|
253
|
+
return { behavior: { type: 'allow' } };
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
// ------------------------------------------------------- per-type wrapping
|
|
258
|
+
function wrapFunctionTool(tool, opts) {
|
|
259
|
+
const existing = tool.inputGuardrails ?? [];
|
|
260
|
+
if (existing.some((gr) => gr.name === GUARDRAIL_NAME))
|
|
261
|
+
return tool;
|
|
262
|
+
// Intutic FIRST: a governance refusal should not depend on (or pay the
|
|
263
|
+
// cost of) whatever caller-authored guardrails follow it.
|
|
264
|
+
return { ...tool, inputGuardrails: [intuticToolGuardrail(opts), ...existing] };
|
|
265
|
+
}
|
|
266
|
+
function isHostedMcp(tool) {
|
|
267
|
+
if (tool.name === 'hosted_mcp')
|
|
268
|
+
return true;
|
|
269
|
+
const pd = tool.providerData;
|
|
270
|
+
return pd?.type === 'mcp';
|
|
271
|
+
}
|
|
272
|
+
/** Whether the tool's ORIGINAL `require_approval` demanded approval for
|
|
273
|
+
* `toolName`. Interpretation of the filter object form: a tool listed under
|
|
274
|
+
* `never` does not need approval; everything else does (matching the
|
|
275
|
+
* conservative reading of OpenAI's approval filter — unlisted tools require
|
|
276
|
+
* approval unless the whole setting is 'never'). */
|
|
277
|
+
function originallyDemandedApproval(requireApproval, toolName) {
|
|
278
|
+
if (requireApproval === undefined || requireApproval === 'never')
|
|
279
|
+
return false;
|
|
280
|
+
if (requireApproval === 'always')
|
|
281
|
+
return true;
|
|
282
|
+
if (requireApproval !== null && typeof requireApproval === 'object') {
|
|
283
|
+
const filter = requireApproval;
|
|
284
|
+
const neverNames = filter.never?.tool_names ?? filter.never?.toolNames ?? [];
|
|
285
|
+
if (neverNames.includes(toolName))
|
|
286
|
+
return false;
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
function hostedMcpApprovalDetails(item) {
|
|
292
|
+
const rawItem = item?.rawItem;
|
|
293
|
+
if (!rawItem || rawItem.type !== 'hosted_tool_call')
|
|
294
|
+
return null;
|
|
295
|
+
const pd = rawItem.providerData ?? {};
|
|
296
|
+
const toolName = typeof pd.name === 'string' && pd.name.length > 0
|
|
297
|
+
? pd.name
|
|
298
|
+
: typeof rawItem.name === 'string' && rawItem.name !== 'mcp_approval_request'
|
|
299
|
+
? rawItem.name
|
|
300
|
+
: null;
|
|
301
|
+
if (toolName === null)
|
|
302
|
+
return null;
|
|
303
|
+
return {
|
|
304
|
+
toolName,
|
|
305
|
+
...(typeof pd.arguments === 'string' ? { rawArguments: pd.arguments } : {}),
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
function wrapHostedMcpTool(tool, opts) {
|
|
309
|
+
const marked = tool;
|
|
310
|
+
if (marked[WRAPPED] === true)
|
|
311
|
+
return tool;
|
|
312
|
+
const pd = { ...(tool.providerData ?? {}) };
|
|
313
|
+
const originalOnApproval = pd.on_approval ?? pd.onApproval;
|
|
314
|
+
const originalRequireApproval = pd.require_approval ?? pd.requireApproval;
|
|
315
|
+
pd.require_approval = 'always';
|
|
316
|
+
pd.on_approval = async (context, item) => {
|
|
317
|
+
const g = resolveGate(opts.gate, 'wrapTools()');
|
|
318
|
+
const details = hostedMcpApprovalDetails(item);
|
|
319
|
+
if (details === null) {
|
|
320
|
+
// Nothing recognisable to evaluate — refuse rather than approve a call
|
|
321
|
+
// the gate never saw.
|
|
322
|
+
return {
|
|
323
|
+
approve: false,
|
|
324
|
+
reason: '[Intutic Governance] BLOCKED: could not extract the MCP tool call from the approval ' +
|
|
325
|
+
'request — failing closed rather than approving an unevaluated call.',
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
try {
|
|
329
|
+
await g.guard(details.toolName, toolInputFromArguments(details.rawArguments));
|
|
330
|
+
}
|
|
331
|
+
catch (err) {
|
|
332
|
+
return errorToDecision(err);
|
|
333
|
+
}
|
|
334
|
+
if (originalOnApproval)
|
|
335
|
+
return originalOnApproval(context, item);
|
|
336
|
+
if (originallyDemandedApproval(originalRequireApproval, details.toolName)) {
|
|
337
|
+
// The caller's original config demanded a human for this tool but gave
|
|
338
|
+
// the runner no resolver. The hosted-MCP approval loop consumes
|
|
339
|
+
// decisions with a truthy check (see OpenAiApprovalDecision's doc), so
|
|
340
|
+
// there is no "leave it pending" here: per this product's own posture
|
|
341
|
+
// (gate.ts, SOP_RULE_APPROVAL), an approval that cannot be granted is a
|
|
342
|
+
// block. Keep your own onApproval on the hostedMcpTool — this wrapper
|
|
343
|
+
// composes with it — to retain a human flow.
|
|
344
|
+
return {
|
|
345
|
+
approve: false,
|
|
346
|
+
reason: '[Intutic Governance] BLOCKED: this MCP tool call requires human approval ' +
|
|
347
|
+
'(require_approval) and the tool has no onApproval resolver; an approval that ' +
|
|
348
|
+
'cannot be granted in an unattended run is a block.',
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
return { approve: true };
|
|
352
|
+
};
|
|
353
|
+
delete pd.requireApproval;
|
|
354
|
+
delete pd.onApproval;
|
|
355
|
+
const wrapped = { ...tool, providerData: pd };
|
|
356
|
+
Object.defineProperty(wrapped, WRAPPED, { value: true, enumerable: false });
|
|
357
|
+
return wrapped;
|
|
358
|
+
}
|
|
359
|
+
/** Shared shell/apply_patch wrapping: force every action through the
|
|
360
|
+
* approval path, gate it there, then replay the tool's original policy.
|
|
361
|
+
*
|
|
362
|
+
* Fall-through, stated because it is easy to miss (TD-406): this relies on
|
|
363
|
+
* `needsApproval`/`onApproval` being the SDK's veto point for these two
|
|
364
|
+
* hosted tools. A future `@openai/agents` that executes a shell or
|
|
365
|
+
* apply_patch action without consulting `onApproval` would bypass the gate
|
|
366
|
+
* with no error here — which is why `package.json` pins the exact version
|
|
367
|
+
* this was verified against, and a bump must re-run the openai adapter
|
|
368
|
+
* tests (`__tests__/openai.test.ts`) before landing. */
|
|
369
|
+
function wrapApprovalGatedTool(tool, opts, extract) {
|
|
370
|
+
const marked = tool;
|
|
371
|
+
if (marked[WRAPPED] === true)
|
|
372
|
+
return tool;
|
|
373
|
+
const originalNeedsApproval = tool.needsApproval;
|
|
374
|
+
const originalOnApproval = tool.onApproval;
|
|
375
|
+
const onApproval = async (runContext, item) => {
|
|
376
|
+
const g = resolveGate(opts.gate, 'wrapTools()');
|
|
377
|
+
const details = extract(item?.rawItem);
|
|
378
|
+
if (details === null) {
|
|
379
|
+
return {
|
|
380
|
+
approve: false,
|
|
381
|
+
reason: `[Intutic Governance] BLOCKED: could not extract the ${tool.type} action from the ` +
|
|
382
|
+
'approval item — failing closed rather than approving an unevaluated call.',
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
try {
|
|
386
|
+
for (const toolInput of details.toolInputs) {
|
|
387
|
+
await g.guard(tool.name, toolInput);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
catch (err) {
|
|
391
|
+
return errorToDecision(err);
|
|
392
|
+
}
|
|
393
|
+
// Gate allows. Replay the tool's ORIGINAL approval policy so wrapping is
|
|
394
|
+
// behaviour-preserving beyond the gate itself.
|
|
395
|
+
const originallyNeeded = typeof originalNeedsApproval === 'function'
|
|
396
|
+
? await originalNeedsApproval(runContext, details.action, details.callId)
|
|
397
|
+
: Boolean(originalNeedsApproval);
|
|
398
|
+
if (!originallyNeeded)
|
|
399
|
+
return { approve: true };
|
|
400
|
+
if (originalOnApproval)
|
|
401
|
+
return originalOnApproval(runContext, item);
|
|
402
|
+
// Original policy: interruption for a human. Leave the decision unmade —
|
|
403
|
+
// the runner's `=== true`/`=== false` checks fall through to "pending"
|
|
404
|
+
// and the interruption surfaces exactly as before wrapping. See
|
|
405
|
+
// OpenAiApprovalDecision's doc for the verification of this mechanism.
|
|
406
|
+
return {};
|
|
407
|
+
};
|
|
408
|
+
const wrapped = { ...tool, needsApproval: async () => true, onApproval };
|
|
409
|
+
Object.defineProperty(wrapped, WRAPPED, { value: true, enumerable: false });
|
|
410
|
+
return wrapped;
|
|
411
|
+
}
|
|
412
|
+
function shellDetails(rawItem) {
|
|
413
|
+
const raw = rawItem;
|
|
414
|
+
if (!raw || raw.type !== 'shell_call')
|
|
415
|
+
return null;
|
|
416
|
+
const commands = Array.isArray(raw.action?.commands) ? raw.action.commands : null;
|
|
417
|
+
if (commands === null)
|
|
418
|
+
return null;
|
|
419
|
+
return {
|
|
420
|
+
// One guard() per command: a snapshot/SOP rule written against `command`
|
|
421
|
+
// must see each command on its own, not a joined blob.
|
|
422
|
+
toolInputs: commands.map((command) => ({ command: String(command) })),
|
|
423
|
+
action: raw.action,
|
|
424
|
+
...(typeof raw.callId === 'string' ? { callId: raw.callId } : {}),
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
function applyPatchDetails(rawItem) {
|
|
428
|
+
const raw = rawItem;
|
|
429
|
+
if (!raw || raw.type !== 'apply_patch_call' || !raw.operation)
|
|
430
|
+
return null;
|
|
431
|
+
const op = raw.operation;
|
|
432
|
+
if (typeof op.path !== 'string')
|
|
433
|
+
return null;
|
|
434
|
+
const toolInput = {
|
|
435
|
+
path: op.path,
|
|
436
|
+
operation: typeof op.type === 'string' ? op.type : 'unknown',
|
|
437
|
+
// `content` so gate.ts's authoring-time infra check sees the diff body.
|
|
438
|
+
...(typeof op.diff === 'string' ? { content: op.diff } : {}),
|
|
439
|
+
...(typeof op.moveTo === 'string' ? { move_to: op.moveTo } : {}),
|
|
440
|
+
};
|
|
441
|
+
return {
|
|
442
|
+
toolInputs: [toolInput],
|
|
443
|
+
action: op,
|
|
444
|
+
...(typeof raw.callId === 'string' ? { callId: raw.callId } : {}),
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Gate-driven `needsApproval` predicate for `computerTool` — the ONLY
|
|
449
|
+
* pre-execution hook that tool exposes (no guardrails, no `onApproval`):
|
|
450
|
+
*
|
|
451
|
+
* ```ts
|
|
452
|
+
* computerTool({ computer, needsApproval: intuticComputerNeedsApproval() })
|
|
453
|
+
* ```
|
|
454
|
+
*
|
|
455
|
+
* A gate-refused (or gate-crashed — fail closed) action resolves `true`,
|
|
456
|
+
* which the runner turns into a pending interruption in
|
|
457
|
+
* `result.interruptions`; your interruption handler must `state.reject(...)`
|
|
458
|
+
* it. This is honestly weaker than the guardrail path: the SDK offers no way
|
|
459
|
+
* for this adapter to auto-reject a computer action with a message, and a
|
|
460
|
+
* handler that `state.approve()`s the interruption overrides the gate. An
|
|
461
|
+
* allowed action defers to `fallback` (the predicate the tool would
|
|
462
|
+
* otherwise have used; defaults to no approval).
|
|
463
|
+
*/
|
|
464
|
+
export function intuticComputerNeedsApproval(opts = {}) {
|
|
465
|
+
const toolName = opts.toolName ?? 'computer_use_preview';
|
|
466
|
+
return async (runContext, action, callId) => {
|
|
467
|
+
const g = resolveGate(opts.gate, 'intuticComputerNeedsApproval()');
|
|
468
|
+
const toolInput = action !== null && typeof action === 'object' && !Array.isArray(action)
|
|
469
|
+
? action
|
|
470
|
+
: { value: action };
|
|
471
|
+
try {
|
|
472
|
+
await g.guard(toolName, toolInput);
|
|
473
|
+
}
|
|
474
|
+
catch {
|
|
475
|
+
// Refusal OR crash: force the approval interruption (fail closed).
|
|
476
|
+
// The refusal itself was already emitted as tool_blocked telemetry
|
|
477
|
+
// inside guard().
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
if (opts.fallback) {
|
|
481
|
+
return opts.fallback(runContext, action, callId);
|
|
482
|
+
}
|
|
483
|
+
return false;
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
function wrapComputerTool(tool, opts) {
|
|
487
|
+
const marked = tool;
|
|
488
|
+
if (marked[WRAPPED] === true)
|
|
489
|
+
return tool;
|
|
490
|
+
const wrapped = {
|
|
491
|
+
...tool,
|
|
492
|
+
needsApproval: intuticComputerNeedsApproval({
|
|
493
|
+
...opts,
|
|
494
|
+
toolName: tool.name,
|
|
495
|
+
fallback: typeof tool.needsApproval === 'function' ? tool.needsApproval : undefined,
|
|
496
|
+
}),
|
|
497
|
+
};
|
|
498
|
+
Object.defineProperty(wrapped, WRAPPED, { value: true, enumerable: false });
|
|
499
|
+
return wrapped;
|
|
500
|
+
}
|
|
501
|
+
// -------------------------------------------------------------- public API
|
|
502
|
+
function wrapOneTool(tool, opts) {
|
|
503
|
+
const t = tool;
|
|
504
|
+
switch (t.type) {
|
|
505
|
+
case 'function':
|
|
506
|
+
return wrapFunctionTool(tool, opts);
|
|
507
|
+
case 'hosted_tool': {
|
|
508
|
+
const hosted = tool;
|
|
509
|
+
// Non-MCP hosted tools (webSearch, fileSearch, codeInterpreter, image
|
|
510
|
+
// generation, ...) execute server-side at OpenAI with no client-side
|
|
511
|
+
// hook — pass through UNCHANGED, documented in the module doc.
|
|
512
|
+
return isHostedMcp(hosted) ? wrapHostedMcpTool(hosted, opts) : tool;
|
|
513
|
+
}
|
|
514
|
+
case 'shell': {
|
|
515
|
+
// Hosted-environment shell (no local `shell` impl) is server-side —
|
|
516
|
+
// its factory types needsApproval/onApproval as `never`. Pass through.
|
|
517
|
+
if (t.shell === undefined)
|
|
518
|
+
return tool;
|
|
519
|
+
return wrapApprovalGatedTool(tool, opts, shellDetails);
|
|
520
|
+
}
|
|
521
|
+
case 'apply_patch':
|
|
522
|
+
return wrapApprovalGatedTool(tool, opts, applyPatchDetails);
|
|
523
|
+
case 'computer':
|
|
524
|
+
return wrapComputerTool(tool, opts);
|
|
525
|
+
default:
|
|
526
|
+
return tool;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Wrap a list of OpenAI Agents SDK tools so `Gate.guard()` runs before each
|
|
531
|
+
* one executes — see the module doc for exactly what each tool `type` gets
|
|
532
|
+
* (and what hosted tools cannot get). Returns NEW tool objects (shallow
|
|
533
|
+
* copies); the ones passed in are not mutated. Wrapping twice is a no-op.
|
|
534
|
+
*
|
|
535
|
+
* NOTE: this covers only the tools you hand it. Tools materialized from
|
|
536
|
+
* `agent.mcpServers` do not exist yet at wrapping time — use
|
|
537
|
+
* {@link wrapAgent} for an agent with MCP servers.
|
|
538
|
+
*/
|
|
539
|
+
export function wrapTools(tools, opts = {}) {
|
|
540
|
+
return tools.map((t) => wrapOneTool(t, opts));
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Gate every tool an agent can reach, MCP-sourced tools included.
|
|
544
|
+
*
|
|
545
|
+
* ```ts
|
|
546
|
+
* import { Agent, run } from '@openai/agents'
|
|
547
|
+
* import { installOpenAiGate, wrapAgent } from '@intutic/gate/openai'
|
|
548
|
+
*
|
|
549
|
+
* installOpenAiGate({ workspaceId: process.env.INTUTIC_WORKSPACE_ID })
|
|
550
|
+
*
|
|
551
|
+
* const agent = wrapAgent(new Agent({
|
|
552
|
+
* name: 'ops',
|
|
553
|
+
* tools: [shell],
|
|
554
|
+
* mcpServers: [filesystemServer],
|
|
555
|
+
* }))
|
|
556
|
+
* const result = await run(agent, prompt)
|
|
557
|
+
* ```
|
|
558
|
+
*
|
|
559
|
+
* Two things happen, both idempotent:
|
|
560
|
+
*
|
|
561
|
+
* 1. `agent.tools` is replaced with {@link wrapTools}' wrapped copies.
|
|
562
|
+
* 2. The agent INSTANCE's `getAllTools` — the materializer the runner
|
|
563
|
+
* actually consumes each turn, and the ONLY place `mcpServers`-derived
|
|
564
|
+
* tools ever appear (see module doc) — is patched to wrap everything it
|
|
565
|
+
* returns.
|
|
566
|
+
*
|
|
567
|
+
* MUTATES (and returns) the agent passed in.
|
|
568
|
+
*
|
|
569
|
+
* Scope, stated plainly: this wraps ONE agent. Agents reached via handoffs
|
|
570
|
+
* are separate objects — wrap each of them too.
|
|
571
|
+
*/
|
|
572
|
+
export function wrapAgent(agent, opts = {}) {
|
|
573
|
+
const marked = agent;
|
|
574
|
+
if (marked[AGENT_WRAPPED] === true)
|
|
575
|
+
return agent;
|
|
576
|
+
agent.tools = wrapTools(agent.tools, opts);
|
|
577
|
+
const originalGetAllTools = agent.getAllTools.bind(agent);
|
|
578
|
+
agent.getAllTools = async (...args) => wrapTools(await originalGetAllTools(...args), opts);
|
|
579
|
+
Object.defineProperty(marked, AGENT_WRAPPED, { value: true, enumerable: false });
|
|
580
|
+
return agent;
|
|
581
|
+
}
|
|
582
|
+
// ------------------------------------------------------------ installation
|
|
583
|
+
/**
|
|
584
|
+
* Close the tracing DLP leak: set the SDK's own kill-switch env
|
|
585
|
+
* (`OPENAI_AGENTS_DISABLE_TRACING=1`) so the default exporter never POSTs
|
|
586
|
+
* trace spans — which carry tool inputs/outputs — to its HARDCODED
|
|
587
|
+
* `https://api.openai.com/v1/traces/ingest` endpoint (which ignores
|
|
588
|
+
* `OPENAI_BASE_URL`, i.e. bypasses the Intutic proxy). The SDK reads the env
|
|
589
|
+
* lazily per run, so calling this any time before `run(...)` is effective.
|
|
590
|
+
*
|
|
591
|
+
* To keep traces instead, re-point the exporter at an approved collector in
|
|
592
|
+
* your own code — `setTraceProcessors([new BatchTraceProcessor(new
|
|
593
|
+
* OpenAITracingExporter({ endpoint }))])` — and pass
|
|
594
|
+
* `{ tracingExport: 'keep' }` to {@link installOpenAiGate}.
|
|
595
|
+
*/
|
|
596
|
+
export function suppressAgentsTracingExport() {
|
|
597
|
+
process.env.OPENAI_AGENTS_DISABLE_TRACING = '1';
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* One-call setup: build a {@link Gate} (with a `GateClient` from the
|
|
601
|
+
* environment when credentials allow — degrading to a client-less gate, Tier
|
|
602
|
+
* B/A3 inactive, rather than throwing out of setup, same posture as dsh.ts's
|
|
603
|
+
* `apply()`), `install()` it process-wide so every guardrail/wrapper in this
|
|
604
|
+
* module picks it up, and close the tracing leak (unless `tracingExport:
|
|
605
|
+
* 'keep'`). Returns the gate.
|
|
606
|
+
*
|
|
607
|
+
* Session id: one per PROCESS (`sessionId` option, else
|
|
608
|
+
* `INTUTIC_SESSION_ID`, else a random UUID) — the SDK has no single "current
|
|
609
|
+
* session" readable at install time; see dsh.ts's `resolveSessionId` for the
|
|
610
|
+
* full rationale this mirrors.
|
|
611
|
+
*/
|
|
612
|
+
/**
|
|
613
|
+
* Where to point `@openai/agents`' tracing exporter so traces stay governed
|
|
614
|
+
* (TD-405): the control plane's ingest route, authenticated with the same
|
|
615
|
+
* API key the gate uses. Pass the result to the SDK's own exporter —
|
|
616
|
+
*
|
|
617
|
+
* ```ts
|
|
618
|
+
* import { setTraceProcessors, BatchTraceProcessor, OpenAITracingExporter } from '@openai/agents'
|
|
619
|
+
* setTraceProcessors([new BatchTraceProcessor(new OpenAITracingExporter(intuticTracingExporterOptions()))])
|
|
620
|
+
* ```
|
|
621
|
+
*
|
|
622
|
+
* — and pass `{ tracingExport: 'intutic' }` to {@link installOpenAiGate}. No
|
|
623
|
+
* `@openai/agents` import here, so this module stays loadable without the SDK.
|
|
624
|
+
* Generation spans become traces in the ledger; tool spans are counted, not
|
|
625
|
+
* stored (the route's own doc says why).
|
|
626
|
+
*/
|
|
627
|
+
export function intuticTracingExporterOptions(env = process.env) {
|
|
628
|
+
// Trailing slashes stripped with a loop, not `/\/+$/`: CodeQL flags that
|
|
629
|
+
// regex as polynomial on a long run of slashes, and the input is operator
|
|
630
|
+
// config read once, so a loop is both safe and obviously linear.
|
|
631
|
+
let base = env.INTUTIC_CONTROL_PLANE_URL ?? '';
|
|
632
|
+
while (base.endsWith('/'))
|
|
633
|
+
base = base.slice(0, -1);
|
|
634
|
+
const apiKey = env.INTUTIC_API_KEY ?? '';
|
|
635
|
+
if (!base || !apiKey) {
|
|
636
|
+
throw new Error('intuticTracingExporterOptions: INTUTIC_CONTROL_PLANE_URL and INTUTIC_API_KEY must be set to route traces to Intutic');
|
|
637
|
+
}
|
|
638
|
+
return { endpoint: `${base}/api/v1/integrations/openai-agents/traces/ingest`, apiKey };
|
|
639
|
+
}
|
|
640
|
+
export function installOpenAiGate(config = {}) {
|
|
641
|
+
if ((config.tracingExport ?? 'disable') === 'disable') {
|
|
642
|
+
suppressAgentsTracingExport();
|
|
643
|
+
}
|
|
644
|
+
let client;
|
|
645
|
+
try {
|
|
646
|
+
client = GateClient.fromEnv({
|
|
647
|
+
harness: 'openai-agents',
|
|
648
|
+
sessionId: config.sessionId || process.env.INTUTIC_SESSION_ID || randomUUID(),
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
catch {
|
|
652
|
+
client = null;
|
|
653
|
+
}
|
|
654
|
+
const gate = new Gate(config, client);
|
|
655
|
+
install(gate);
|
|
656
|
+
return gate;
|
|
657
|
+
}
|
|
658
|
+
//# sourceMappingURL=openai.js.map
|