watchmyagents 1.2.1 → 1.4.0

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.
@@ -0,0 +1,872 @@
1
+ // OpenAI Agents SDK adapter — v1.3.0 (Phase 2.A).
2
+ //
3
+ // Adapter type: customer-instrumented, push-model. NOT auto-discovery.
4
+ //
5
+ // Why customer-instrumented:
6
+ // OpenAI's Agents SDK runs IN-PROCESS on the customer's machine — not
7
+ // on OpenAI's servers. There is no `listAgents` / `listConversations`
8
+ // equivalent that lets WMA pull events from outside. To observe an
9
+ // OpenAI agent, WMA code MUST run inside the customer's process,
10
+ // alongside the agent. This is fundamentally different from Anthropic
11
+ // Managed Agents (REST poll) and from AgentCore (REST poll). It is
12
+ // the same model Datadog APM, Sentry, Langfuse, and OpenLLMetry use
13
+ // for OpenAI observability. The customer wires two lines of code; the
14
+ // rest is automatic.
15
+ //
16
+ // Two extension points used (both OFFICIAL APIs of @openai/agents):
17
+ // 1. Tool Input Guardrails (Shield enforcement).
18
+ // `wmaToolInputGuardrail()` returns a shape-compatible
19
+ // `{ type: 'tool_input', name, run }` object that slots into the
20
+ // Agent's `toolInputGuardrails` array. When a tool is about to fire,
21
+ // the SDK awaits our `run()` and respects its `behavior`:
22
+ // - { type: 'allow' } → tool proceeds normally
23
+ // - { type: 'rejectContent', message } → tool is BLOCKED,
24
+ // message returned in
25
+ // place of result
26
+ // - { type: 'throwException' } → run aborts with an
27
+ // exception (kills the
28
+ // agent loop)
29
+ // 2. RunHooks EventEmitter (Watch observability).
30
+ // `attachWmaWatch(runner)` registers listeners on the Runner's
31
+ // EventEmitter for: agent_start, agent_end, agent_handoff,
32
+ // agent_tool_start, agent_tool_end. Each event is normalized to
33
+ // the WMA contract NDJSON shape and written via the Logger.
34
+ //
35
+ // Zero runtime dependency invariant:
36
+ // We do NOT import from '@openai/agents'. Instead we return shape-
37
+ // compatible plain objects. This keeps the SDK's zero-deps guarantee
38
+ // intact AND avoids the dynamic-import dance. The downside: any
39
+ // schema drift on the OpenAI side is caught only at runtime, not
40
+ // compile-time. We mitigate via fixture-based tests + a TypeScript
41
+ // .d.ts that pins the public surface.
42
+ //
43
+ // Containment invariant:
44
+ // Tool args + results may carry sensitive customer data. They are
45
+ // captured into the WMAAction's `input` / `output` fields and written
46
+ // to LOCAL NDJSON only. The anonymizer is the single egress gate to
47
+ // Fortress. Identical to Anthropic Managed adapter discipline.
48
+
49
+ import { randomUUID, createHash } from 'node:crypto';
50
+ import {
51
+ PROVIDERS, ACTION_TYPES, STATUS_VALUES,
52
+ COMPOSITION_PATTERNS, ENFORCEMENT_MODES,
53
+ } from './contract.js';
54
+ import { evaluate, loadPolicies } from '../shield/policy.js';
55
+ import { createContextTracker } from '../shield/context.js';
56
+ import { DecisionLogger } from '../shield/decisions.js';
57
+ import { Logger } from '../logger.js';
58
+ import { normalizeToolInput } from '../anonymizer.js';
59
+
60
+ // ── Constants ──────────────────────────────────────────────────────────
61
+
62
+ const PROVIDER = PROVIDERS.OPENAI_AGENTS;
63
+
64
+ // We default fail-CLOSED on Shield evaluation errors. Customer can flip
65
+ // to fail-OPEN via options.failOpen — but that's a security regression
66
+ // the customer must opt into explicitly, with all the implications.
67
+ const DEFAULT_FAIL_OPEN = false;
68
+
69
+ // v1.4 F-32 (P2 Codex audit on v1.3.0): bound the bytes we accept on
70
+ // the hot path. A misbehaving tool, a model hallucinating a huge
71
+ // response, or a malicious customer-controlled fixture can otherwise
72
+ // pin CPU on JSON.parse, blow up the NDJSON line, or fill the disk.
73
+ // 256 KB is generous for legitimate tool inputs/outputs (real-world
74
+ // captures from the Guardian agent peak at < 2 KB) and well under what
75
+ // would degrade Watch / Shield. Customers with outlier traffic can
76
+ // override per-guardrail via options.maxArgBytes / options.maxResultBytes.
77
+ const DEFAULT_MAX_ARG_BYTES = 256 * 1024;
78
+ const DEFAULT_MAX_RESULT_BYTES = 256 * 1024;
79
+ const TRUNCATION_SENTINEL = '…[truncated by WMA Shield]';
80
+
81
+ // Mirror of '@openai/agents' value object factory. Replicated here so we
82
+ // don't take a runtime dep. If '@openai/agents' ever changes the shape
83
+ // (extreme low probability), fixture tests will catch it.
84
+ const ToolGuardrailFunctionOutputFactory = Object.freeze({
85
+ allow(outputInfo) {
86
+ return { behavior: { type: 'allow' }, outputInfo };
87
+ },
88
+ rejectContent(message, outputInfo) {
89
+ return { behavior: { type: 'rejectContent', message }, outputInfo };
90
+ },
91
+ throwException(outputInfo) {
92
+ return { behavior: { type: 'throwException' }, outputInfo };
93
+ },
94
+ });
95
+
96
+ // ── Helpers ────────────────────────────────────────────────────────────
97
+
98
+ function nowIso() {
99
+ return new Date().toISOString();
100
+ }
101
+
102
+ function readEnv(key, fallback) {
103
+ const v = process.env[key];
104
+ return v && v.length > 0 ? v : fallback;
105
+ }
106
+
107
+ // Parse `toolCall.arguments` defensively. The OpenAI SDK serializes them
108
+ // as a JSON string; some custom tool implementations pass them through
109
+ // already-parsed. Fail-closed: if we can't parse, return null and let
110
+ // the policy match against {} (which fails any specific clause).
111
+ //
112
+ // v1.4 F-32 — cap the input bytes before JSON.parse. Beyond the cap
113
+ // the input is truncated with a sentinel and a parsed shape that
114
+ // preserves the field structure as much as possible (try-parse the
115
+ // truncated text, fall back to `{ _wmaTruncated: true, original_bytes }`).
116
+ // Policies that match on tool_name still work; policies that match on
117
+ // argument values silently miss the truncated suffix — which is the
118
+ // correct fail-closed behavior for an oversize input.
119
+ function safeParseToolArgs(rawArgs, maxBytes = DEFAULT_MAX_ARG_BYTES) {
120
+ if (rawArgs == null) return null;
121
+ if (typeof rawArgs === 'object') {
122
+ // Already parsed by the SDK or the customer. We don't deep-walk
123
+ // and truncate — that would silently change policy match semantics
124
+ // on nested fields. Defer the decision to the caller; the size cap
125
+ // applies at the string-parse boundary, which is the realistic
126
+ // SDK entry point.
127
+ return rawArgs;
128
+ }
129
+ if (typeof rawArgs !== 'string') return null;
130
+ // Byte cap: Buffer.byteLength is the safe length on the wire.
131
+ const byteLen = Buffer.byteLength(rawArgs, 'utf8');
132
+ if (byteLen > maxBytes) {
133
+ // Truncate to maxBytes worth of bytes (substring is char-bounded;
134
+ // good enough — exact byte truncation isn't required since we mark
135
+ // the result as truncated and never feed it back to a real tool).
136
+ const head = rawArgs.slice(0, maxBytes);
137
+ try {
138
+ const parsed = JSON.parse(head);
139
+ // If by luck the head is valid JSON, return it plus the marker.
140
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
141
+ return { ...parsed, _wmaTruncated: true, _wmaOriginalBytes: byteLen };
142
+ }
143
+ return { _wmaTruncated: true, _wmaOriginalBytes: byteLen, head: parsed };
144
+ } catch {
145
+ // Common path: truncated JSON is invalid mid-tree.
146
+ return { _wmaTruncated: true, _wmaOriginalBytes: byteLen };
147
+ }
148
+ }
149
+ try { return JSON.parse(rawArgs); }
150
+ catch { return null; }
151
+ }
152
+
153
+ // Stable session id per run. The OpenAI SDK doesn't give us a top-level
154
+ // run identifier we can rely on across all event types (run-state-machine
155
+ // internals), so we mint our own UUID at first event and keep it.
156
+ function makeSessionId() {
157
+ return `oai-${randomUUID()}`;
158
+ }
159
+
160
+ // v1.4 F-31 — short reference code minted per Shield internal error.
161
+ // Returned to the model as `Ref: WMA-SHL-<8hex>` and logged to stderr
162
+ // alongside the full err.message so an operator can correlate the
163
+ // generic model-facing message to the local detailed log without
164
+ // leaking the raw error to the model.
165
+ function makeErrorRef() {
166
+ // 8 hex chars from a fresh UUID — wide enough to deconflict within
167
+ // a session, short enough to be readable in a tool result.
168
+ return `WMA-SHL-${randomUUID().replace(/-/g, '').slice(0, 8)}`;
169
+ }
170
+
171
+ // Derive a stable, monotonic event id from the SDK's tool_call id when
172
+ // present (so deduplication after restart works), else mint a UUID.
173
+ function makeEventId(toolCall) {
174
+ if (toolCall && typeof toolCall === 'object') {
175
+ const id = toolCall.callId || toolCall.id;
176
+ if (typeof id === 'string' && id.length > 0) return `oai-${id}`;
177
+ }
178
+ return `oai-evt-${randomUUID()}`;
179
+ }
180
+
181
+ // Hash a customer-provided tool name for use as a stable agent_id when
182
+ // the SDK's `agent.name` field is missing or empty. Salt is intentionally
183
+ // fixed (no anti-collision goal — this is just a stable derivation).
184
+ function fallbackAgentId(toolName) {
185
+ const h = createHash('sha256').update(String(toolName || 'unknown')).digest('hex');
186
+ return `oai-agent-${h.slice(0, 16)}`;
187
+ }
188
+
189
+ // ── Team correlation ───────────────────────────────────────────────────
190
+ //
191
+ // A "team" is a stable id shared by every event in a related group of
192
+ // cooperating agents. Three resolution channels, highest precedence first:
193
+ // 1. Customer override: WMA_TEAM_ID env var.
194
+ // 2. Auto-detected: when the SDK emits `agent_handoff(from, to)`, the
195
+ // from-agent's existing team_id propagates to the to-agent. The
196
+ // first agent in a run gets a freshly-minted team_id (run-scoped).
197
+ // 3. Null: customer didn't opt into team correlation, no handoff
198
+ // observed — events are tagged team_id=null. Legions UI shows them
199
+ // under the "Untagged" bucket.
200
+
201
+ function teamIdFromEnv() {
202
+ const v = readEnv('WMA_TEAM_ID', null);
203
+ return v && v.length > 0 ? v : null;
204
+ }
205
+
206
+ // Per-run team tracker. Keyed by sessionId so multiple concurrent runs
207
+ // don't cross-pollinate.
208
+ function createTeamTracker(envTeamId) {
209
+ // Map<sessionId, teamId>
210
+ const teams = new Map();
211
+ return {
212
+ // Resolve the team_id for this event. If the customer set
213
+ // WMA_TEAM_ID, that always wins. Otherwise we look up the
214
+ // sessionId-keyed map (populated by recordHandoff() + first-event
215
+ // bootstrap). Returns null if no info available.
216
+ resolve(sessionId) {
217
+ if (envTeamId) return envTeamId;
218
+ return teams.get(sessionId) || null;
219
+ },
220
+ // First event in a run — establish a team_id. Subsequent events in
221
+ // the same session inherit it.
222
+ bootstrap(sessionId) {
223
+ if (envTeamId) return envTeamId;
224
+ let t = teams.get(sessionId);
225
+ if (!t) {
226
+ t = `oai-team-${randomUUID()}`;
227
+ teams.set(sessionId, t);
228
+ }
229
+ return t;
230
+ },
231
+ // SDK emitted agent_handoff(from, to) — propagate the from-agent's
232
+ // team to the to-agent. In Agents SDK both agents are within the
233
+ // same run / sessionId, so this is mostly a no-op in our model.
234
+ // We keep the hook for future fan-out scenarios.
235
+ recordHandoff(_fromAgent, _toAgent, sessionId) {
236
+ if (envTeamId) return envTeamId;
237
+ return teams.get(sessionId) || null;
238
+ },
239
+ };
240
+ }
241
+
242
+ // ── Event normalization ────────────────────────────────────────────────
243
+ //
244
+ // Five event types from @openai/agents map to WMA contract action_types:
245
+ //
246
+ // agent_start → MESSAGE (with action_type override = 'agent_start')
247
+ // agent_end → MESSAGE (with action_type override = 'agent_end')
248
+ // agent_handoff → HANDOFF
249
+ // agent_tool_start → CUSTOM_TOOL_USE (the tool fires NEXT — capture
250
+ // input, no output yet)
251
+ // agent_tool_end → CUSTOM_TOOL_RESULT (capture output, link by
252
+ // tool_call_id to the start)
253
+ //
254
+ // Notes:
255
+ // - We pick CUSTOM_TOOL_USE over plain TOOL_USE: OpenAI Agents SDK
256
+ // tools are ALWAYS customer-defined (no provider-built-in shell
257
+ // equivalent at this surface). TOOL_USE stays reserved for provider-
258
+ // built-ins (Anthropic web_search, AgentCore Browser Tool, etc.).
259
+ // - agent_start / agent_end aren't in ACTION_TYPES today. We map them
260
+ // to MESSAGE so the contract validator stays green. The action_type
261
+ // override goes in the `output` envelope so consumers can still
262
+ // filter. A first-class agent lifecycle action_type may be proposed
263
+ // in v1.4.x.
264
+
265
+ export function normalizeAgentStart({ agent, turnInput, sessionId, teamId }) {
266
+ return Object.freeze({
267
+ id: `oai-evt-${randomUUID()}`,
268
+ provider: PROVIDER,
269
+ agent_id: agent?.name || fallbackAgentId('start'),
270
+ agent_name: agent?.name || null,
271
+ session_id: sessionId,
272
+ session_thread_id: sessionId,
273
+ action_type: ACTION_TYPES.MESSAGE,
274
+ timestamp: nowIso(),
275
+ status: STATUS_VALUES.OK,
276
+ tool_name: null,
277
+ model: agent?.model || null,
278
+ duration_ms: null,
279
+ parent_agent_id: null,
280
+ composition_pattern: COMPOSITION_PATTERNS.SOLO,
281
+ team_id: teamId,
282
+ input: turnInput ? { turn_input: turnInput } : null,
283
+ output: { kind: 'agent_start' },
284
+ });
285
+ }
286
+
287
+ export function normalizeAgentEnd({ agent, output, sessionId, teamId }) {
288
+ return Object.freeze({
289
+ id: `oai-evt-${randomUUID()}`,
290
+ provider: PROVIDER,
291
+ agent_id: agent?.name || fallbackAgentId('end'),
292
+ agent_name: agent?.name || null,
293
+ session_id: sessionId,
294
+ session_thread_id: sessionId,
295
+ action_type: ACTION_TYPES.MESSAGE,
296
+ timestamp: nowIso(),
297
+ status: STATUS_VALUES.OK,
298
+ tool_name: null,
299
+ model: agent?.model || null,
300
+ duration_ms: null,
301
+ parent_agent_id: null,
302
+ composition_pattern: COMPOSITION_PATTERNS.SOLO,
303
+ team_id: teamId,
304
+ input: null,
305
+ output: { kind: 'agent_end', text: typeof output === 'string' ? output : null },
306
+ });
307
+ }
308
+
309
+ export function normalizeAgentHandoff({ fromAgent, toAgent, sessionId, teamId }) {
310
+ return Object.freeze({
311
+ id: `oai-evt-${randomUUID()}`,
312
+ provider: PROVIDER,
313
+ agent_id: toAgent?.name || fallbackAgentId('handoff'),
314
+ agent_name: toAgent?.name || null,
315
+ session_id: sessionId,
316
+ session_thread_id: sessionId,
317
+ action_type: ACTION_TYPES.HANDOFF,
318
+ timestamp: nowIso(),
319
+ status: STATUS_VALUES.OK,
320
+ tool_name: null,
321
+ model: toAgent?.model || null,
322
+ duration_ms: null,
323
+ parent_agent_id: fromAgent?.name || null,
324
+ composition_pattern: COMPOSITION_PATTERNS.HIERARCHY,
325
+ team_id: teamId,
326
+ input: null,
327
+ output: {
328
+ kind: 'agent_handoff',
329
+ from: fromAgent?.name || null,
330
+ to: toAgent?.name || null,
331
+ },
332
+ });
333
+ }
334
+
335
+ export function normalizeToolStart({ agent, tool, toolCall, sessionId, teamId, toolInputs }) {
336
+ const parsedArgs = safeParseToolArgs(toolCall?.arguments);
337
+ // v1.4 Codex #4 — per-tool argument aliases. If `toolInputs` is
338
+ // provided AND has an entry for this tool's name, apply the alias
339
+ // map via normalizeToolInput() from the anonymizer. The result
340
+ // populates canonical names (`url`, `query`, `command`, `path`,
341
+ // `file_path`) so the SignalsAggregator hashes them EXACTLY, not
342
+ // via the F-30 suffix heuristic (which is the opt-out safety net,
343
+ // not the precision path). Both layers coexist: explicit aliases
344
+ // win on declared fields; heuristic catches the long tail.
345
+ const aliases = toolInputs && tool?.name ? toolInputs[tool.name] : null;
346
+ const finalInput = aliases ? normalizeToolInput(parsedArgs, aliases) : parsedArgs;
347
+ return Object.freeze({
348
+ id: makeEventId(toolCall),
349
+ provider: PROVIDER,
350
+ agent_id: agent?.name || fallbackAgentId(tool?.name),
351
+ agent_name: agent?.name || null,
352
+ session_id: sessionId,
353
+ session_thread_id: sessionId,
354
+ action_type: ACTION_TYPES.CUSTOM_TOOL_USE,
355
+ timestamp: nowIso(),
356
+ status: STATUS_VALUES.OK,
357
+ tool_name: tool?.name || null,
358
+ model: agent?.model || null,
359
+ duration_ms: null,
360
+ parent_agent_id: null,
361
+ composition_pattern: COMPOSITION_PATTERNS.SOLO,
362
+ team_id: teamId,
363
+ input: finalInput,
364
+ output: null,
365
+ });
366
+ }
367
+
368
+ // v1.4 F-32 — cap result bytes before writing NDJSON. Verbose tools
369
+ // (HTML scrapers, web_fetch with full-page payloads, computer use
370
+ // screenshots base64'd) can return arbitrary megabytes. Without a cap
371
+ // we'd write a single ~MB-sized JSON line per call into the rotation
372
+ // file, fill the disk, and slow every subsequent NDJSON read.
373
+ function truncateResult(result, maxBytes = DEFAULT_MAX_RESULT_BYTES) {
374
+ if (typeof result === 'string') {
375
+ const byteLen = Buffer.byteLength(result, 'utf8');
376
+ if (byteLen > maxBytes) {
377
+ return {
378
+ text: result.slice(0, maxBytes) + TRUNCATION_SENTINEL,
379
+ _wmaTruncated: true,
380
+ _wmaOriginalBytes: byteLen,
381
+ };
382
+ }
383
+ return { text: result };
384
+ }
385
+ if (result == null) return { value: null };
386
+ // Object / array result: serialize, check size, truncate if needed.
387
+ try {
388
+ const serialized = JSON.stringify(result);
389
+ const byteLen = Buffer.byteLength(serialized, 'utf8');
390
+ if (byteLen > maxBytes) {
391
+ return {
392
+ value: { _wmaTruncated: true, _wmaOriginalBytes: byteLen },
393
+ };
394
+ }
395
+ return { value: result };
396
+ } catch {
397
+ return { value: { _wmaTruncated: true, _wmaUnserializable: true } };
398
+ }
399
+ }
400
+
401
+ export function normalizeToolEnd({ agent, tool, result, toolCall, sessionId, teamId }) {
402
+ return Object.freeze({
403
+ id: `${makeEventId(toolCall)}-end`,
404
+ provider: PROVIDER,
405
+ agent_id: agent?.name || fallbackAgentId(tool?.name),
406
+ agent_name: agent?.name || null,
407
+ session_id: sessionId,
408
+ session_thread_id: sessionId,
409
+ action_type: ACTION_TYPES.CUSTOM_TOOL_RESULT,
410
+ timestamp: nowIso(),
411
+ status: STATUS_VALUES.OK,
412
+ tool_name: tool?.name || null,
413
+ model: agent?.model || null,
414
+ duration_ms: null,
415
+ parent_agent_id: null,
416
+ composition_pattern: COMPOSITION_PATTERNS.SOLO,
417
+ team_id: teamId,
418
+ input: null,
419
+ output: truncateResult(result),
420
+ });
421
+ }
422
+
423
+ // ── Public API: Shield (Tool Input Guardrail) ──────────────────────────
424
+ //
425
+ // Returns a guardrail object SHAPE-COMPATIBLE with the @openai/agents
426
+ // `defineToolInputGuardrail()` output. Customer drops it into an Agent's
427
+ // `toolInputGuardrails: [...]` and the SDK runs it before every tool
428
+ // call.
429
+ //
430
+ // Options:
431
+ // policiesPath: string — local JSON policy file path
432
+ // ruleset: object — in-memory policy ruleset (overrides path)
433
+ // logDir: string — NDJSON log dir (default: WMA_LOG_DIR or
434
+ // ./watchmyagents-logs)
435
+ // sessionId: string — override session id (default: auto-minted)
436
+ // recentWindowSize:number — context tracker window (default: 20)
437
+ // failOpen: boolean — on Shield internal error, allow vs deny
438
+ // (default: false = fail-CLOSED)
439
+ // getTeamId: fn()→string|null — custom team resolver
440
+ // logger: Logger — inject an existing Logger (testing)
441
+ // decisionLogger: DecisionLogger — inject existing (testing)
442
+ // tracker: ContextTracker — inject existing (testing)
443
+ //
444
+ // All options are optional; defaults read from env. Lazy loading of the
445
+ // policy ruleset on first invocation if `policiesPath` is set.
446
+
447
+ export function wmaToolInputGuardrail(options = {}) {
448
+ const failOpen = options.failOpen === true ? true : DEFAULT_FAIL_OPEN;
449
+ const sessionId = options.sessionId || makeSessionId();
450
+ const logDir = options.logDir
451
+ || readEnv('WMA_LOG_DIR', './watchmyagents-logs');
452
+
453
+ // Set up the shared shield state. Re-used across all tool calls
454
+ // through this guardrail instance.
455
+ let ruleset = options.ruleset || null;
456
+ const tracker = options.tracker
457
+ || createContextTracker({ recentWindowSize: options.recentWindowSize ?? 20 });
458
+ // v1.3.1 F-29 (P1 Codex audit): provider must be passed explicitly so
459
+ // shield_decision NDJSON rows carry 'openai-agents' instead of being
460
+ // mis-attributed to 'anthropic-managed' (DecisionLogger's pre-v1.3.1
461
+ // hardcoded default). Fortress / Guardian forensic surfaces depend on
462
+ // this for correct multi-provider attribution.
463
+ const decisionLogger = options.decisionLogger
464
+ || new DecisionLogger({ logDir, agentId: 'openai-agents', sessionId, provider: PROVIDER });
465
+ const logger = options.logger
466
+ || new Logger({ logDir, agentId: 'openai-agents', sessionId, silent: true, bestEffort: false });
467
+
468
+ // Team tracker — re-used for the entire run of this guardrail.
469
+ const envTeamId = teamIdFromEnv();
470
+ const teamTracker = createTeamTracker(envTeamId);
471
+
472
+ const getTeamId = typeof options.getTeamId === 'function'
473
+ ? options.getTeamId
474
+ : () => teamTracker.bootstrap(sessionId);
475
+
476
+ // Lazily load the ruleset on first invocation if a path was given.
477
+ async function ensureRuleset() {
478
+ if (ruleset != null) return ruleset;
479
+ if (options.policiesPath) {
480
+ ruleset = await loadPolicies(options.policiesPath);
481
+ return ruleset;
482
+ }
483
+ // No ruleset, no path → default to "always allow". The customer
484
+ // probably means to use Fortress; we don't ship that wiring in
485
+ // v1.3.0 from this entry point. Log a warning once.
486
+ if (!ensureRuleset._warned) {
487
+ ensureRuleset._warned = true;
488
+ process.stderr.write(
489
+ '[wma/openai-agents] no policy ruleset configured — guardrail will allow all. ' +
490
+ 'Pass { policiesPath } or { ruleset } to wmaToolInputGuardrail() to enforce.\n',
491
+ );
492
+ }
493
+ ruleset = { policies: [], default: { action: 'allow' } };
494
+ return ruleset;
495
+ }
496
+
497
+ return {
498
+ type: 'tool_input',
499
+ name: 'watchmyagents-shield',
500
+ run: async (data) => {
501
+ // Defensive: data may be { context, agent, toolCall }.
502
+ const agent = data?.agent || null;
503
+ const toolCall = data?.toolCall || null;
504
+
505
+ try {
506
+ const rs = await ensureRuleset();
507
+
508
+ // 1. Normalize the about-to-fire tool_use event.
509
+ const event = normalizeToolStart({
510
+ agent,
511
+ tool: { name: toolCall?.name },
512
+ toolCall,
513
+ sessionId,
514
+ teamId: getTeamId(),
515
+ // v1.4 Codex #4 — per-tool alias map (option `toolInputs`).
516
+ // Threaded through so the anonymizer hashes by canonical name
517
+ // for tools the customer explicitly mapped.
518
+ toolInputs: options.toolInputs,
519
+ });
520
+
521
+ // 2. Compute policy context BEFORE evaluation so recent_error_rate
522
+ // and friends reflect prior history.
523
+ const policyCtx = tracker.compute(event);
524
+
525
+ // 3. Evaluate Shield.
526
+ const t0 = Date.now();
527
+ const result = evaluate(event, rs, policyCtx);
528
+ const decidedInMs = Date.now() - t0;
529
+
530
+ // 4. Audit-grade log (chain-signed).
531
+ await decisionLogger.record({
532
+ sourceEvent: { id: event.id, type: event.action_type, tool_name: event.tool_name, input: event.input },
533
+ decision: result.decision,
534
+ ruleId: result.rule_id,
535
+ ruleName: result.rule_name,
536
+ message: result.message,
537
+ decidedInMs,
538
+ mode: result.mode,
539
+ });
540
+
541
+ // 5. Watch log (the event itself, separate from the decision).
542
+ await logger.write(event);
543
+
544
+ // 6. Record outcome into the context tracker for the next event.
545
+ // On a Shield block, the tool won't run — that's NOT a tool
546
+ // error from the agent's perspective. Use the default
547
+ // heuristic which won't mark allow/deny as errors.
548
+ tracker.record(event);
549
+
550
+ // 7. Translate Shield decision into OpenAI guardrail behavior.
551
+ // Shadow mode never blocks — log + allow.
552
+ if (result.mode === 'shadow') {
553
+ return ToolGuardrailFunctionOutputFactory.allow({
554
+ wma: { rule_id: result.rule_id, mode: 'shadow', shadow_decision: result.decision },
555
+ });
556
+ }
557
+ if (result.decision === 'deny') {
558
+ return ToolGuardrailFunctionOutputFactory.rejectContent(
559
+ result.message || `Blocked by ${result.rule_name || 'WMA Shield'}`,
560
+ { wma: { rule_id: result.rule_id, rule_name: result.rule_name } },
561
+ );
562
+ }
563
+ if (result.decision === 'interrupt') {
564
+ return ToolGuardrailFunctionOutputFactory.throwException({
565
+ wma: { rule_id: result.rule_id, rule_name: result.rule_name, message: result.message },
566
+ });
567
+ }
568
+ return ToolGuardrailFunctionOutputFactory.allow({
569
+ wma: { rule_id: null, rule_name: '(default-allow)' },
570
+ });
571
+ } catch (err) {
572
+ // Shield internal failure (policy file unreadable, disk full on
573
+ // log write, etc.). Default fail-CLOSED unless customer opted
574
+ // into fail-OPEN.
575
+ //
576
+ // v1.4 F-31 (P2 Codex audit on v1.3.0): the agent must NOT see
577
+ // the raw err.message — it can carry local paths, policy file
578
+ // names, disk-mount strings, permission codes, or even fragments
579
+ // of the policy itself that should never reach a model context.
580
+ // We mint a short reference code, log the full error locally to
581
+ // stderr keyed by that code, and return a generic message that
582
+ // operators can correlate against the local log without exposing
583
+ // anything actionable to the model. The outputInfo (which is NOT
584
+ // surfaced to the model — it stays in the application's run
585
+ // metadata) keeps the err.message for SDK-side tracing.
586
+ const errorRef = makeErrorRef();
587
+ process.stderr.write(
588
+ `[wma/openai-agents] [${errorRef}] guardrail error: ${err.message}\n`,
589
+ );
590
+ if (failOpen) {
591
+ return ToolGuardrailFunctionOutputFactory.allow({
592
+ wma: {
593
+ errorRef,
594
+ error: err.message,
595
+ failOpenApplied: true,
596
+ },
597
+ });
598
+ }
599
+ return ToolGuardrailFunctionOutputFactory.rejectContent(
600
+ `WMA Shield internal error (fail-closed). Ref: ${errorRef}`,
601
+ {
602
+ wma: {
603
+ errorRef,
604
+ error: err.message,
605
+ failOpenApplied: false,
606
+ },
607
+ },
608
+ );
609
+ }
610
+ },
611
+ };
612
+ }
613
+
614
+ // ── Public API: Watch (RunHooks listener attach) ───────────────────────
615
+ //
616
+ // Attaches WMA listeners to a Runner (or any EventEmitter-shaped object
617
+ // with `.on(event, listener)`). Captures all 5 lifecycle events,
618
+ // normalizes each, and writes to local NDJSON. Returns an unsubscribe
619
+ // function the customer can call on cleanup.
620
+ //
621
+ // Why a separate function from the guardrail:
622
+ // The guardrail is OPTIONAL (customer might want Watch-only — pure
623
+ // observability with no enforcement). The Watch attach is the
624
+ // complementary opt-in. Both are independent.
625
+
626
+ export function attachWmaWatch(runner, options = {}) {
627
+ if (!runner || typeof runner.on !== 'function') {
628
+ throw new TypeError(
629
+ 'attachWmaWatch: runner must expose an .on(event, listener) method ' +
630
+ '(the @openai/agents Runner EventEmitter, or a compatible shim).',
631
+ );
632
+ }
633
+
634
+ const sessionId = options.sessionId || makeSessionId();
635
+ const logDir = options.logDir
636
+ || readEnv('WMA_LOG_DIR', './watchmyagents-logs');
637
+ const logger = options.logger
638
+ || new Logger({ logDir, agentId: 'openai-agents', sessionId, silent: true, bestEffort: true });
639
+
640
+ const envTeamId = teamIdFromEnv();
641
+ const teamTracker = options.teamTracker || createTeamTracker(envTeamId);
642
+
643
+ // Per-event handlers. Each one normalizes then writes. We wrap each in
644
+ // try/catch — Watch must NEVER throw inside the customer's Runner
645
+ // event loop. Watch is observation; observation must be best-effort.
646
+ const handlers = {
647
+ agent_start: async (context, agent, turnInput) => {
648
+ try {
649
+ const teamId = teamTracker.bootstrap(sessionId);
650
+ const event = normalizeAgentStart({ agent, turnInput, sessionId, teamId });
651
+ await logger.write(event);
652
+ } catch (e) {
653
+ process.stderr.write(`[wma/openai-agents] watch agent_start error: ${e.message}\n`);
654
+ }
655
+ },
656
+ agent_end: async (context, agent, output) => {
657
+ try {
658
+ const teamId = teamTracker.resolve(sessionId);
659
+ const event = normalizeAgentEnd({ agent, output, sessionId, teamId });
660
+ await logger.write(event);
661
+ } catch (e) {
662
+ process.stderr.write(`[wma/openai-agents] watch agent_end error: ${e.message}\n`);
663
+ }
664
+ },
665
+ agent_handoff: async (context, fromAgent, toAgent) => {
666
+ try {
667
+ const teamId = teamTracker.recordHandoff(fromAgent, toAgent, sessionId)
668
+ || teamTracker.bootstrap(sessionId);
669
+ const event = normalizeAgentHandoff({ fromAgent, toAgent, sessionId, teamId });
670
+ await logger.write(event);
671
+ } catch (e) {
672
+ process.stderr.write(`[wma/openai-agents] watch agent_handoff error: ${e.message}\n`);
673
+ }
674
+ },
675
+ agent_tool_start: async (context, agent, tool, details) => {
676
+ try {
677
+ const teamId = teamTracker.resolve(sessionId) || teamTracker.bootstrap(sessionId);
678
+ const event = normalizeToolStart({
679
+ agent, tool, toolCall: details?.toolCall, sessionId, teamId,
680
+ toolInputs: options.toolInputs, // v1.4 Codex #4
681
+ });
682
+ await logger.write(event);
683
+ } catch (e) {
684
+ process.stderr.write(`[wma/openai-agents] watch agent_tool_start error: ${e.message}\n`);
685
+ }
686
+ },
687
+ agent_tool_end: async (context, agent, tool, result, details) => {
688
+ try {
689
+ const teamId = teamTracker.resolve(sessionId) || teamTracker.bootstrap(sessionId);
690
+ const event = normalizeToolEnd({
691
+ agent, tool, result, toolCall: details?.toolCall, sessionId, teamId,
692
+ });
693
+ await logger.write(event);
694
+ } catch (e) {
695
+ process.stderr.write(`[wma/openai-agents] watch agent_tool_end error: ${e.message}\n`);
696
+ }
697
+ },
698
+ };
699
+
700
+ for (const [evt, fn] of Object.entries(handlers)) {
701
+ runner.on(evt, fn);
702
+ }
703
+
704
+ // Unsubscribe helper. If `runner.off` exists (standard EventEmitter),
705
+ // detach. Otherwise no-op. Customer should call this on shutdown.
706
+ return function detachWmaWatch() {
707
+ if (typeof runner.off !== 'function') return;
708
+ for (const [evt, fn] of Object.entries(handlers)) {
709
+ try { runner.off(evt, fn); }
710
+ catch { /* listener wasn't attached or off() not supported */ }
711
+ }
712
+ };
713
+ }
714
+
715
+ // ── Public API: Watch via AgentHooks (when customer uses run() helper) ─
716
+ //
717
+ // The @openai/agents SDK exposes TWO event-emitter surfaces:
718
+ // - RunHooks : `runner.on(event, listener)` — fires when customer
719
+ // explicitly creates a Runner via `new Runner()`.
720
+ // Listener args INCLUDE the agent.
721
+ // - AgentHooks: `agent.on(event, listener)` — fires when customer
722
+ // uses the convenience `run(agent, ...)` function
723
+ // (without an explicit Runner). The agent is implicit
724
+ // (it's the one we registered listeners on), so the
725
+ // listener args do NOT include it (except agent_start).
726
+ //
727
+ // Real-world capture (June 2026) shows AgentHooks signatures:
728
+ // agent_start [context, agent, turnInput?]
729
+ // agent_end [context, output] ← no agent
730
+ // agent_handoff [context, toAgent] ← only the "to" side
731
+ // agent_tool_start [context, tool, details] ← no agent
732
+ // agent_tool_end [context, tool, result, details] ← no agent
733
+ //
734
+ // `attachWmaWatchToAgent(agent, options)` mirrors `attachWmaWatch`
735
+ // for the AgentHooks pattern. The agent is captured via closure so
736
+ // our normalizers still receive an agent_id even for events that
737
+ // don't pass the agent in their args.
738
+ //
739
+ // Returns a `detach()` function symmetric to attachWmaWatch.
740
+
741
+ export function attachWmaWatchToAgent(agent, options = {}) {
742
+ if (!agent || typeof agent.on !== 'function') {
743
+ throw new TypeError(
744
+ 'attachWmaWatchToAgent: agent must expose an .on(event, listener) method ' +
745
+ '(the @openai/agents Agent EventEmitter, or a compatible shim).',
746
+ );
747
+ }
748
+
749
+ const sessionId = options.sessionId || makeSessionId();
750
+ const logDir = options.logDir
751
+ || readEnv('WMA_LOG_DIR', './watchmyagents-logs');
752
+ const logger = options.logger
753
+ || new Logger({ logDir, agentId: 'openai-agents', sessionId, silent: true, bestEffort: true });
754
+
755
+ const envTeamId = teamIdFromEnv();
756
+ const teamTracker = options.teamTracker || createTeamTracker(envTeamId);
757
+
758
+ // Same try/catch wrapping discipline as attachWmaWatch: Watch must
759
+ // NEVER throw inside the customer's agent loop. Handlers below absorb
760
+ // exceptions and write them to stderr instead of propagating.
761
+ const handlers = {
762
+ agent_start: async (context, eventAgent, turnInput) => {
763
+ try {
764
+ const teamId = teamTracker.bootstrap(sessionId);
765
+ const event = normalizeAgentStart({
766
+ agent: eventAgent || agent,
767
+ turnInput, sessionId, teamId,
768
+ });
769
+ await logger.write(event);
770
+ } catch (e) {
771
+ process.stderr.write(`[wma/openai-agents] watch agent_start error: ${e.message}\n`);
772
+ }
773
+ },
774
+ // AgentHooks agent_end does NOT pass the agent — captured via closure.
775
+ agent_end: async (context, output) => {
776
+ try {
777
+ const teamId = teamTracker.resolve(sessionId);
778
+ const event = normalizeAgentEnd({ agent, output, sessionId, teamId });
779
+ await logger.write(event);
780
+ } catch (e) {
781
+ process.stderr.write(`[wma/openai-agents] watch agent_end error: ${e.message}\n`);
782
+ }
783
+ },
784
+ // AgentHooks agent_handoff passes only the "to" agent. The "from"
785
+ // is the agent we registered on (this listener is fired right
786
+ // before control passes away from it).
787
+ agent_handoff: async (context, toAgent) => {
788
+ try {
789
+ const teamId = teamTracker.recordHandoff(agent, toAgent, sessionId)
790
+ || teamTracker.bootstrap(sessionId);
791
+ const event = normalizeAgentHandoff({
792
+ fromAgent: agent, toAgent, sessionId, teamId,
793
+ });
794
+ await logger.write(event);
795
+ } catch (e) {
796
+ process.stderr.write(`[wma/openai-agents] watch agent_handoff error: ${e.message}\n`);
797
+ }
798
+ },
799
+ // AgentHooks tool events do NOT pass the agent — captured via closure.
800
+ agent_tool_start: async (context, tool, details) => {
801
+ try {
802
+ const teamId = teamTracker.resolve(sessionId) || teamTracker.bootstrap(sessionId);
803
+ const event = normalizeToolStart({
804
+ agent, tool, toolCall: details?.toolCall, sessionId, teamId,
805
+ toolInputs: options.toolInputs, // v1.4 Codex #4
806
+ });
807
+ await logger.write(event);
808
+ } catch (e) {
809
+ process.stderr.write(`[wma/openai-agents] watch agent_tool_start error: ${e.message}\n`);
810
+ }
811
+ },
812
+ agent_tool_end: async (context, tool, result, details) => {
813
+ try {
814
+ const teamId = teamTracker.resolve(sessionId) || teamTracker.bootstrap(sessionId);
815
+ const event = normalizeToolEnd({
816
+ agent, tool, result, toolCall: details?.toolCall, sessionId, teamId,
817
+ });
818
+ await logger.write(event);
819
+ } catch (e) {
820
+ process.stderr.write(`[wma/openai-agents] watch agent_tool_end error: ${e.message}\n`);
821
+ }
822
+ },
823
+ };
824
+
825
+ for (const [evt, fn] of Object.entries(handlers)) {
826
+ agent.on(evt, fn);
827
+ }
828
+
829
+ return function detachWmaWatchFromAgent() {
830
+ if (typeof agent.off !== 'function') return;
831
+ for (const [evt, fn] of Object.entries(handlers)) {
832
+ try { agent.off(evt, fn); }
833
+ catch { /* listener wasn't attached or off() not supported */ }
834
+ }
835
+ };
836
+ }
837
+
838
+ // ── Enforcement metadata (for adapter registry / introspection) ───────
839
+
840
+ export const adapterMeta = Object.freeze({
841
+ provider: PROVIDER,
842
+ displayName: 'OpenAI Agents SDK',
843
+ enforcement: ENFORCEMENT_MODES.SYNC_CONFIRM,
844
+ compositionDefault: COMPOSITION_PATTERNS.SOLO,
845
+ customerInstrumented: true, // not auto-discovery
846
+ peerPackage: '@openai/agents',
847
+ // v1.4 F-33 — aligned with package.json#peerDependencies range
848
+ // `^0.2.0`. Real fixtures captured against 0.2.x verified the
849
+ // lifecycle event signatures in v1.3.0; minPeerVersion below that is
850
+ // unsupported and may show drift in args layout (the AgentHooks vs
851
+ // RunHooks gap that motivated the v1.3.0 finalize commit).
852
+ minPeerVersion: '0.2.0',
853
+ capabilities: Object.freeze({
854
+ watch: true,
855
+ shield: true,
856
+ preToolDeny: true, // via ToolInputGuardrail
857
+ postToolFilter: false, // ToolOutputGuardrail later
858
+ teamIdAutoDetect: true, // via agent_handoff
859
+ streamingSupported: 'verify-day-2', // verify in smoke test
860
+ }),
861
+ });
862
+
863
+ // Internal-only — exported for tests. The factory mirrors @openai/agents'
864
+ // public symbol; tests can assert against it.
865
+ export const __testing__ = Object.freeze({
866
+ ToolGuardrailFunctionOutputFactory,
867
+ safeParseToolArgs,
868
+ makeSessionId,
869
+ makeEventId,
870
+ fallbackAgentId,
871
+ createTeamTracker,
872
+ });