watchmyagents 1.3.0 → 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.
- package/docs/adapters/openai-agents-js.md +1 -1
- package/package.json +9 -1
- package/src/anonymizer.js +48 -0
- package/src/index.js +52 -0
- package/src/openai-agents.js +202 -0
- package/src/shield/decisions.js +12 -2
- package/src/sources/openai-agents-js.js +150 -11
|
@@ -196,7 +196,7 @@ Tool arguments and results stay LOCAL. The anonymizer converts WMAAction → sig
|
|
|
196
196
|
|
|
197
197
|
- Node.js: **20+** (matches the WMA SDK baseline and `@openai/agents`'s requirement)
|
|
198
198
|
- TypeScript: any version — types live in `src/sources/openai-agents-js.d.ts`
|
|
199
|
-
- `@openai/agents` version range:
|
|
199
|
+
- `@openai/agents` version range: **`^0.2.0`** (declared in `package.json#peerDependencies`). Real fixtures in `test/fixtures/openai-agents-events/` were captured against 0.2.x and the adapter's lifecycle event signatures match that line. Older 0.1.x lacked AgentHooks ergonomics the adapter relies on.
|
|
200
200
|
|
|
201
201
|
## Limits + known gaps (v1.3.0)
|
|
202
202
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "watchmyagents",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Security observability + real-time policy enforcement for AI agents. Local-first NDJSON capture with a continuous Watch daemon that auto-uploads anonymized signals, Shield CLI that blocks policy violations live (with policies pulled from Fortress cloud), anonymizer producing signals-only payloads, bidirectional sync with WatchMyAgents Fortress, and one-command install as an always-on launchd/systemd service — closing the recursive Watch→Guardian→Shield security loop.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -19,6 +19,14 @@
|
|
|
19
19
|
"SECURITY.md",
|
|
20
20
|
"LICENSE"
|
|
21
21
|
],
|
|
22
|
+
"exports": {
|
|
23
|
+
".": "./src/index.js",
|
|
24
|
+
"./openai-agents": {
|
|
25
|
+
"types": "./src/sources/openai-agents-js.d.ts",
|
|
26
|
+
"default": "./src/openai-agents.js"
|
|
27
|
+
},
|
|
28
|
+
"./package.json": "./package.json"
|
|
29
|
+
},
|
|
22
30
|
"bin": {
|
|
23
31
|
"wma-inspect": "scripts/inspect.js",
|
|
24
32
|
"wma-fetch": "scripts/fetch-anthropic.js",
|
package/src/anonymizer.js
CHANGED
|
@@ -156,17 +156,65 @@ export function normalizeToolInput(rawInput, aliases = {}) {
|
|
|
156
156
|
// programmatically reference the contract.
|
|
157
157
|
export { HASHABLE_INPUT_FIELDS };
|
|
158
158
|
|
|
159
|
+
// ── Heuristic field-name patterns (v1.3.1 F-30 Codex audit on v1.3.0) ───
|
|
160
|
+
//
|
|
161
|
+
// HASHABLE_INPUT_FIELDS is the canonical set; adapter authors are also
|
|
162
|
+
// encouraged to call `normalizeToolInput()` to map their native names.
|
|
163
|
+
// But OpenAI Agents SDK customers define their own tool argument names
|
|
164
|
+
// (`endpoint_url`, `shell_cmd`, `requestUrl`, …) and rarely think to
|
|
165
|
+
// register aliases. The result before v1.3.1: those fields silently
|
|
166
|
+
// disappeared from Fortress signals — not a leak (the raw payload stays
|
|
167
|
+
// LOCAL), but a detection blind spot.
|
|
168
|
+
//
|
|
169
|
+
// This heuristic catches the long tail. For each non-canonical input
|
|
170
|
+
// field, if its NAME matches a common suffix/word pattern, hash its
|
|
171
|
+
// value as if it were the corresponding canonical field. Conservative
|
|
172
|
+
// by design: regex anchored to word/suffix boundaries (`_url` matches,
|
|
173
|
+
// `urlsafe` does NOT) so we err toward false positives (hashing a
|
|
174
|
+
// harmless field is fine) over false negatives (missing a sensitive
|
|
175
|
+
// field is the bug we're fixing).
|
|
176
|
+
//
|
|
177
|
+
// Add new patterns here when adapters surface new common names.
|
|
178
|
+
const HEURISTIC_FIELD_PATTERNS = Object.freeze({
|
|
179
|
+
url: /(?:^|[_-])(?:url|uri|endpoint|webhook|address)$/i,
|
|
180
|
+
command: /(?:^|[_-])(?:cmd|command|shell|exec|script)$/i,
|
|
181
|
+
path: /(?:^|[_-])(?:path|file|filename|filepath|dir|folder)$/i,
|
|
182
|
+
query: /(?:^|[_-])(?:query|search|q|prompt|term)$/i,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
function fieldMatchesCanonicalByHeuristic(fieldName, canonical) {
|
|
186
|
+
const pat = HEURISTIC_FIELD_PATTERNS[canonical];
|
|
187
|
+
return pat ? pat.test(fieldName) : false;
|
|
188
|
+
}
|
|
189
|
+
|
|
159
190
|
// ── Single-entry extractor: what hashable IoCs are in this entry? ────────
|
|
160
191
|
|
|
161
192
|
function extractIocs(entry, salt) {
|
|
162
193
|
const out = [];
|
|
163
194
|
if (!entry.input || typeof entry.input !== 'object') return out;
|
|
195
|
+
|
|
196
|
+
// Exact canonical match (original v1.0+ behavior — backwards compat).
|
|
164
197
|
for (const field of HASHABLE_INPUT_FIELDS) {
|
|
165
198
|
const v = entry.input[field];
|
|
166
199
|
if (typeof v === 'string' && v.length > 0) {
|
|
167
200
|
out.push(hashWithSalt(v, salt));
|
|
168
201
|
}
|
|
169
202
|
}
|
|
203
|
+
|
|
204
|
+
// v1.3.1 F-30 — heuristic suffix matching for non-canonical field
|
|
205
|
+
// names common to OpenAI Agents SDK / LangGraph / CrewAI tools. Skip
|
|
206
|
+
// fields already handled above so we never double-hash.
|
|
207
|
+
for (const [key, v] of Object.entries(entry.input)) {
|
|
208
|
+
if (HASHABLE_INPUT_FIELDS.includes(key)) continue;
|
|
209
|
+
if (typeof v !== 'string' || v.length === 0) continue;
|
|
210
|
+
for (const canonical of HASHABLE_INPUT_FIELDS) {
|
|
211
|
+
if (fieldMatchesCanonicalByHeuristic(key, canonical)) {
|
|
212
|
+
out.push(hashWithSalt(v, salt));
|
|
213
|
+
break; // one canonical bucket per field — first match wins
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
170
218
|
return out;
|
|
171
219
|
}
|
|
172
220
|
|
package/src/index.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// WatchMyAgents — main entry point (v1.4 Codex #10).
|
|
2
|
+
//
|
|
3
|
+
// `import { ... } from 'watchmyagents'` resolves here via the package.json
|
|
4
|
+
// `exports['.']` field. For adapter-specific imports (the recommended
|
|
5
|
+
// path for v1.4+), use the sub-entries:
|
|
6
|
+
//
|
|
7
|
+
// import { openaiAgents } from 'watchmyagents/openai-agents';
|
|
8
|
+
//
|
|
9
|
+
// This root entry exposes the most stable cross-adapter primitives:
|
|
10
|
+
// - the source-adapter contract (PROVIDERS, ACTION_TYPES, COMPOSITION_PATTERNS,
|
|
11
|
+
// ENFORCEMENT_MODES, validateWMAAction)
|
|
12
|
+
// - the policy engine + context tracker + decision chain that EVERY
|
|
13
|
+
// adapter shares
|
|
14
|
+
// - the Logger
|
|
15
|
+
//
|
|
16
|
+
// Internal modules (`src/sources/openai-agents-js.js`,
|
|
17
|
+
// `src/shield/*.js`, etc.) remain accessible to advanced consumers via
|
|
18
|
+
// deep imports, but those paths are NOT part of the stable surface;
|
|
19
|
+
// they may move between minor releases. Stick to the sub-entries and
|
|
20
|
+
// this root export for production code.
|
|
21
|
+
|
|
22
|
+
// ── Source-adapter contract ─────────────────────────────────────────
|
|
23
|
+
export {
|
|
24
|
+
PROVIDERS,
|
|
25
|
+
ACTION_TYPES,
|
|
26
|
+
STATUS_VALUES,
|
|
27
|
+
ENFORCEMENT_MODES,
|
|
28
|
+
COMPOSITION_PATTERNS,
|
|
29
|
+
validateWMAAction,
|
|
30
|
+
} from './sources/contract.js';
|
|
31
|
+
|
|
32
|
+
// ── Shield engine (policy eval + context + audit chain) ─────────────
|
|
33
|
+
export {
|
|
34
|
+
evaluate,
|
|
35
|
+
matchesPolicy,
|
|
36
|
+
loadPolicies,
|
|
37
|
+
} from './shield/policy.js';
|
|
38
|
+
|
|
39
|
+
export { createContextTracker, defaultIsError } from './shield/context.js';
|
|
40
|
+
|
|
41
|
+
export {
|
|
42
|
+
createDecisionChain,
|
|
43
|
+
verifyDecisionChain,
|
|
44
|
+
buildGenesisMarker,
|
|
45
|
+
newChainId,
|
|
46
|
+
CHAIN_FIELDS,
|
|
47
|
+
} from './shield/decision-chain.js';
|
|
48
|
+
|
|
49
|
+
export { DecisionLogger } from './shield/decisions.js';
|
|
50
|
+
|
|
51
|
+
// ── Logger ──────────────────────────────────────────────────────────
|
|
52
|
+
export { Logger } from './logger.js';
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// Public entry point for the OpenAI Agents SDK adapter (v1.4 Codex #1).
|
|
2
|
+
//
|
|
3
|
+
// Customers import the SDK like this:
|
|
4
|
+
//
|
|
5
|
+
// import { openaiAgents } from 'watchmyagents/openai-agents';
|
|
6
|
+
//
|
|
7
|
+
// const wma = openaiAgents({
|
|
8
|
+
// policiesPath: './policies.json',
|
|
9
|
+
// logDir: './watchmyagents-logs',
|
|
10
|
+
// mode: 'enforce', // 'observe' | 'enforce' (default 'enforce')
|
|
11
|
+
// toolInputs: {
|
|
12
|
+
// fetch_url: { endpoint_url: 'url' }, // per-tool aliases
|
|
13
|
+
// shell: { shell_cmd: 'command' },
|
|
14
|
+
// },
|
|
15
|
+
// });
|
|
16
|
+
//
|
|
17
|
+
// const agent = new Agent({
|
|
18
|
+
// name: 'support_bot',
|
|
19
|
+
// tools,
|
|
20
|
+
// toolInputGuardrails: [wma.shield()], // ← Shield
|
|
21
|
+
// });
|
|
22
|
+
//
|
|
23
|
+
// wma.watch(agent); // ← Watch (auto-detects Agent vs Runner)
|
|
24
|
+
// // or:
|
|
25
|
+
// wma.watch(runner);
|
|
26
|
+
//
|
|
27
|
+
// await run(agent, 'help me');
|
|
28
|
+
//
|
|
29
|
+
// Why a factory: it lets the customer configure once (policy path,
|
|
30
|
+
// log dir, mode, tool input aliases) and get back a small object with
|
|
31
|
+
// `shield()` and `watch()` methods that share that config. The pre-v1.4
|
|
32
|
+
// pattern asked customers to import deep paths (
|
|
33
|
+
// `watchmyagents/src/sources/openai-agents-js.js`) and pass options to
|
|
34
|
+
// each call — fragile and verbose. This entry point is the stable
|
|
35
|
+
// surface; the internal module stays free to refactor.
|
|
36
|
+
|
|
37
|
+
import {
|
|
38
|
+
wmaToolInputGuardrail,
|
|
39
|
+
attachWmaWatch,
|
|
40
|
+
attachWmaWatchToAgent,
|
|
41
|
+
adapterMeta,
|
|
42
|
+
} from './sources/openai-agents-js.js';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build the WMA OpenAI Agents SDK adapter from a single shared config.
|
|
46
|
+
*
|
|
47
|
+
* @param {object} [options]
|
|
48
|
+
* @param {string} [options.policiesPath] Local JSON policy file.
|
|
49
|
+
* @param {object} [options.ruleset] In-memory ruleset.
|
|
50
|
+
* @param {string} [options.logDir] NDJSON log dir.
|
|
51
|
+
* @param {'observe'|'enforce'} [options.mode] v1.4 Codex #2 — explicit
|
|
52
|
+
* mode. `enforce` requires
|
|
53
|
+
* a policy source; `observe`
|
|
54
|
+
* attaches Watch only and
|
|
55
|
+
* refuses to issue a Shield
|
|
56
|
+
* (calling `.shield()` throws
|
|
57
|
+
* so it's impossible to ship
|
|
58
|
+
* a build that looks armed
|
|
59
|
+
* but isn't).
|
|
60
|
+
* @param {object<string, object>} [options.toolInputs]
|
|
61
|
+
* v1.4 Codex #4 — per-tool argument aliases. Maps tool_name →
|
|
62
|
+
* { nativeFieldName: canonicalFieldName }. Applied alongside the
|
|
63
|
+
* F-30 heuristic so customers with off-canonical arg names get
|
|
64
|
+
* exact hashing without relying on the suffix heuristic.
|
|
65
|
+
* @param {boolean} [options.failOpen] Default false (closed).
|
|
66
|
+
* @param {number} [options.maxArgBytes] Default 256 KB.
|
|
67
|
+
* @param {number} [options.maxResultBytes] Default 256 KB.
|
|
68
|
+
* @param {object} [options.tracker] Inject ContextTracker.
|
|
69
|
+
* @param {object} [options.logger] Inject Logger.
|
|
70
|
+
* @param {object} [options.decisionLogger] Inject DecisionLogger.
|
|
71
|
+
* @returns {{
|
|
72
|
+
* shield: () => object,
|
|
73
|
+
* watch: (target: object) => () => void,
|
|
74
|
+
* meta: typeof adapterMeta,
|
|
75
|
+
* }}
|
|
76
|
+
*/
|
|
77
|
+
export function openaiAgents(options = {}) {
|
|
78
|
+
const mode = options.mode || 'enforce';
|
|
79
|
+
if (mode !== 'observe' && mode !== 'enforce') {
|
|
80
|
+
throw new TypeError(
|
|
81
|
+
`openaiAgents: options.mode must be 'observe' or 'enforce', got '${mode}'`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// v1.4 Codex #2 — fail-loud refusal to start in enforce mode without
|
|
86
|
+
// any policy source. Avoids the v1.3.0 footgun where missing policies
|
|
87
|
+
// silently degraded to "allow all" with only a stderr warning. The
|
|
88
|
+
// failure surfaces at config time (not on the first request) so it's
|
|
89
|
+
// impossible to deploy a build that LOOKS armed but isn't.
|
|
90
|
+
if (mode === 'enforce' && options.policiesPath == null && options.ruleset == null) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
"openaiAgents({ mode: 'enforce' }) requires policiesPath or ruleset. " +
|
|
93
|
+
"Either provide one, or switch to { mode: 'observe' } if you only " +
|
|
94
|
+
"want Watch (NDJSON capture) without Shield enforcement.",
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Shared config we thread into both shield() and watch() so the
|
|
99
|
+
// customer doesn't have to repeat it.
|
|
100
|
+
const sharedOptions = {
|
|
101
|
+
policiesPath: options.policiesPath,
|
|
102
|
+
ruleset: options.ruleset,
|
|
103
|
+
logDir: options.logDir,
|
|
104
|
+
sessionId: options.sessionId,
|
|
105
|
+
failOpen: options.failOpen,
|
|
106
|
+
recentWindowSize: options.recentWindowSize,
|
|
107
|
+
getTeamId: options.getTeamId,
|
|
108
|
+
tracker: options.tracker,
|
|
109
|
+
logger: options.logger,
|
|
110
|
+
decisionLogger: options.decisionLogger,
|
|
111
|
+
toolInputs: options.toolInputs,
|
|
112
|
+
maxArgBytes: options.maxArgBytes,
|
|
113
|
+
maxResultBytes: options.maxResultBytes,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
return Object.freeze({
|
|
117
|
+
/** Returns the @openai/agents Tool Input Guardrail. In `observe`
|
|
118
|
+
* mode this throws — the customer asked for Watch-only and a
|
|
119
|
+
* guardrail would be misleading. */
|
|
120
|
+
shield() {
|
|
121
|
+
if (mode === 'observe') {
|
|
122
|
+
throw new Error(
|
|
123
|
+
"openaiAgents({ mode: 'observe' }).shield() is not allowed. " +
|
|
124
|
+
"Observe mode does not produce Shield enforcement. " +
|
|
125
|
+
"Switch to { mode: 'enforce', policiesPath: '...' } if you want to block.",
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
return wmaToolInputGuardrail(sharedOptions);
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
/** Attaches Watch listeners. Auto-detects whether `target` is an
|
|
132
|
+
* Agent (AgentHooks) or a Runner (RunHooks) by checking method
|
|
133
|
+
* shape and known fields. Returns a detach function. */
|
|
134
|
+
watch(target) {
|
|
135
|
+
return autoAttachWatch(target, sharedOptions);
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
/** Adapter metadata — useful for tooling that wants to introspect
|
|
139
|
+
* what's available (capabilities, peer-dep min version, etc.). */
|
|
140
|
+
meta: adapterMeta,
|
|
141
|
+
|
|
142
|
+
/** The configured mode, exposed for runtime introspection. */
|
|
143
|
+
mode,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// v1.4 Codex #7 — auto-detect Runner vs Agent and dispatch to the
|
|
148
|
+
// matching attach function. The shape difference:
|
|
149
|
+
// - @openai/agents Runner has `.run(agent, input)` AND emits the
|
|
150
|
+
// RunHooks events with the agent as an explicit arg.
|
|
151
|
+
// - @openai/agents Agent has `.name`/`.instructions`/`.tools` AND
|
|
152
|
+
// emits AgentHooks events with the agent IMPLICIT (closure-
|
|
153
|
+
// captured by attachWmaWatchToAgent).
|
|
154
|
+
//
|
|
155
|
+
// Both expose `.on()` from the EventEmitter base. We detect the
|
|
156
|
+
// runner by checking for `.run` (function) — it's the convenience the
|
|
157
|
+
// Runner class exposes that Agent doesn't. As a tiebreaker we look
|
|
158
|
+
// at the presence of `.tools` (Agent-only) and the absence of `.name`
|
|
159
|
+
// is fine as a hint but not load-bearing.
|
|
160
|
+
function autoAttachWatch(target, options) {
|
|
161
|
+
if (!target || typeof target.on !== 'function') {
|
|
162
|
+
throw new TypeError(
|
|
163
|
+
'openaiAgents().watch(target): target must expose .on(event, listener). ' +
|
|
164
|
+
'Pass an @openai/agents Runner or Agent instance.',
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
// Independent signal checks — DO NOT make `looksLikeRunner` exclusive
|
|
168
|
+
// of `looksLikeAgent`. The whole point of the ambiguity check below
|
|
169
|
+
// is to catch targets that satisfy both shapes.
|
|
170
|
+
const hasRunMethod = typeof target.run === 'function';
|
|
171
|
+
const hasAgentShape = Array.isArray(target.tools) && typeof target.name === 'string';
|
|
172
|
+
|
|
173
|
+
if (hasRunMethod && hasAgentShape) {
|
|
174
|
+
throw new TypeError(
|
|
175
|
+
'openaiAgents().watch(target): target looks like both Agent and Runner ' +
|
|
176
|
+
'(has .tools[] AND .name AND .run() method). Disambiguate by calling ' +
|
|
177
|
+
'attachWmaWatch(runner) or attachWmaWatchToAgent(agent) directly.',
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
if (hasAgentShape) {
|
|
181
|
+
return attachWmaWatchToAgent(target, options);
|
|
182
|
+
}
|
|
183
|
+
if (hasRunMethod) {
|
|
184
|
+
return attachWmaWatch(target, options);
|
|
185
|
+
}
|
|
186
|
+
// Neither shape unambiguously identified — fall back to AgentHooks
|
|
187
|
+
// since the convenience `run(agent, ...)` function dispatches
|
|
188
|
+
// AgentHooks, which is the more common path for new customers
|
|
189
|
+
// (validated against real fixtures captured 2026-06-10).
|
|
190
|
+
return attachWmaWatchToAgent(target, options);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Re-export the low-level functions so customers that need fine-grained
|
|
194
|
+
// control (e.g. test scaffolding, advanced multi-runner setups) can
|
|
195
|
+
// still reach them. The factory is the recommended entry point; these
|
|
196
|
+
// are the escape hatches.
|
|
197
|
+
export {
|
|
198
|
+
wmaToolInputGuardrail,
|
|
199
|
+
attachWmaWatch,
|
|
200
|
+
attachWmaWatchToAgent,
|
|
201
|
+
adapterMeta,
|
|
202
|
+
};
|
package/src/shield/decisions.js
CHANGED
|
@@ -15,7 +15,17 @@ import { Logger } from '../logger.js';
|
|
|
15
15
|
import { createDecisionChain, buildGenesisMarker, newChainId } from './decision-chain.js';
|
|
16
16
|
|
|
17
17
|
export class DecisionLogger {
|
|
18
|
-
|
|
18
|
+
// v1.3.1 F-29 (P1 Codex audit on v1.3.0): `provider` is now an
|
|
19
|
+
// explicit constructor option. Before v1.3.1 the provider was
|
|
20
|
+
// hard-coded to `anthropic-managed` in record() — when the OpenAI
|
|
21
|
+
// Agents SDK adapter (shipped v1.3.0) wrote shield_decision rows
|
|
22
|
+
// through this logger, those rows were mis-attributed to Anthropic.
|
|
23
|
+
// Fortress / Guardian forensic surfaces saw OpenAI blocks as
|
|
24
|
+
// Anthropic blocks. Default is preserved at `anthropic-managed` so
|
|
25
|
+
// existing v1.2.x callers behave unchanged; the OpenAI adapter
|
|
26
|
+
// explicitly passes `provider: PROVIDERS.OPENAI_AGENTS`.
|
|
27
|
+
constructor({ logDir, agentId, sessionId, provider }) {
|
|
28
|
+
this._provider = provider || 'anthropic-managed';
|
|
19
29
|
// Each DecisionLogger instance owns a single chain segment. A Shield
|
|
20
30
|
// restart creates a fresh DecisionLogger → fresh genesis. The
|
|
21
31
|
// genesis marker is self-describing (agent + session + start time +
|
|
@@ -53,7 +63,7 @@ export class DecisionLogger {
|
|
|
53
63
|
&& (decision === 'deny' || decision === 'interrupt');
|
|
54
64
|
return this._logger.write({
|
|
55
65
|
action_type: 'shield_decision',
|
|
56
|
-
provider:
|
|
66
|
+
provider: this._provider,
|
|
57
67
|
tool_name: sourceEvent?.name || sourceEvent?.tool_name || null,
|
|
58
68
|
status: enforced ? 'error' : 'ok',
|
|
59
69
|
error: enforced ? message : null,
|
|
@@ -55,6 +55,7 @@ import { evaluate, loadPolicies } from '../shield/policy.js';
|
|
|
55
55
|
import { createContextTracker } from '../shield/context.js';
|
|
56
56
|
import { DecisionLogger } from '../shield/decisions.js';
|
|
57
57
|
import { Logger } from '../logger.js';
|
|
58
|
+
import { normalizeToolInput } from '../anonymizer.js';
|
|
58
59
|
|
|
59
60
|
// ── Constants ──────────────────────────────────────────────────────────
|
|
60
61
|
|
|
@@ -65,6 +66,18 @@ const PROVIDER = PROVIDERS.OPENAI_AGENTS;
|
|
|
65
66
|
// the customer must opt into explicitly, with all the implications.
|
|
66
67
|
const DEFAULT_FAIL_OPEN = false;
|
|
67
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
|
+
|
|
68
81
|
// Mirror of '@openai/agents' value object factory. Replicated here so we
|
|
69
82
|
// don't take a runtime dep. If '@openai/agents' ever changes the shape
|
|
70
83
|
// (extreme low probability), fixture tests will catch it.
|
|
@@ -95,10 +108,44 @@ function readEnv(key, fallback) {
|
|
|
95
108
|
// as a JSON string; some custom tool implementations pass them through
|
|
96
109
|
// already-parsed. Fail-closed: if we can't parse, return null and let
|
|
97
110
|
// the policy match against {} (which fails any specific clause).
|
|
98
|
-
|
|
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) {
|
|
99
120
|
if (rawArgs == null) return null;
|
|
100
|
-
if (typeof rawArgs === 'object')
|
|
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
|
+
}
|
|
101
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
|
+
}
|
|
102
149
|
try { return JSON.parse(rawArgs); }
|
|
103
150
|
catch { return null; }
|
|
104
151
|
}
|
|
@@ -110,6 +157,17 @@ function makeSessionId() {
|
|
|
110
157
|
return `oai-${randomUUID()}`;
|
|
111
158
|
}
|
|
112
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
|
+
|
|
113
171
|
// Derive a stable, monotonic event id from the SDK's tool_call id when
|
|
114
172
|
// present (so deduplication after restart works), else mint a UUID.
|
|
115
173
|
function makeEventId(toolCall) {
|
|
@@ -274,8 +332,18 @@ export function normalizeAgentHandoff({ fromAgent, toAgent, sessionId, teamId })
|
|
|
274
332
|
});
|
|
275
333
|
}
|
|
276
334
|
|
|
277
|
-
export function normalizeToolStart({ agent, tool, toolCall, sessionId, teamId }) {
|
|
335
|
+
export function normalizeToolStart({ agent, tool, toolCall, sessionId, teamId, toolInputs }) {
|
|
278
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;
|
|
279
347
|
return Object.freeze({
|
|
280
348
|
id: makeEventId(toolCall),
|
|
281
349
|
provider: PROVIDER,
|
|
@@ -292,11 +360,44 @@ export function normalizeToolStart({ agent, tool, toolCall, sessionId, teamId })
|
|
|
292
360
|
parent_agent_id: null,
|
|
293
361
|
composition_pattern: COMPOSITION_PATTERNS.SOLO,
|
|
294
362
|
team_id: teamId,
|
|
295
|
-
input:
|
|
363
|
+
input: finalInput,
|
|
296
364
|
output: null,
|
|
297
365
|
});
|
|
298
366
|
}
|
|
299
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
|
+
|
|
300
401
|
export function normalizeToolEnd({ agent, tool, result, toolCall, sessionId, teamId }) {
|
|
301
402
|
return Object.freeze({
|
|
302
403
|
id: `${makeEventId(toolCall)}-end`,
|
|
@@ -315,7 +416,7 @@ export function normalizeToolEnd({ agent, tool, result, toolCall, sessionId, tea
|
|
|
315
416
|
composition_pattern: COMPOSITION_PATTERNS.SOLO,
|
|
316
417
|
team_id: teamId,
|
|
317
418
|
input: null,
|
|
318
|
-
output:
|
|
419
|
+
output: truncateResult(result),
|
|
319
420
|
});
|
|
320
421
|
}
|
|
321
422
|
|
|
@@ -354,8 +455,13 @@ export function wmaToolInputGuardrail(options = {}) {
|
|
|
354
455
|
let ruleset = options.ruleset || null;
|
|
355
456
|
const tracker = options.tracker
|
|
356
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.
|
|
357
463
|
const decisionLogger = options.decisionLogger
|
|
358
|
-
|| new DecisionLogger({ logDir, agentId: 'openai-agents', sessionId });
|
|
464
|
+
|| new DecisionLogger({ logDir, agentId: 'openai-agents', sessionId, provider: PROVIDER });
|
|
359
465
|
const logger = options.logger
|
|
360
466
|
|| new Logger({ logDir, agentId: 'openai-agents', sessionId, silent: true, bestEffort: false });
|
|
361
467
|
|
|
@@ -406,6 +512,10 @@ export function wmaToolInputGuardrail(options = {}) {
|
|
|
406
512
|
toolCall,
|
|
407
513
|
sessionId,
|
|
408
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,
|
|
409
519
|
});
|
|
410
520
|
|
|
411
521
|
// 2. Compute policy context BEFORE evaluation so recent_error_rate
|
|
@@ -462,17 +572,39 @@ export function wmaToolInputGuardrail(options = {}) {
|
|
|
462
572
|
// Shield internal failure (policy file unreadable, disk full on
|
|
463
573
|
// log write, etc.). Default fail-CLOSED unless customer opted
|
|
464
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();
|
|
465
587
|
process.stderr.write(
|
|
466
|
-
`[wma/openai-agents] guardrail error: ${err.message}\n`,
|
|
588
|
+
`[wma/openai-agents] [${errorRef}] guardrail error: ${err.message}\n`,
|
|
467
589
|
);
|
|
468
590
|
if (failOpen) {
|
|
469
591
|
return ToolGuardrailFunctionOutputFactory.allow({
|
|
470
|
-
wma: {
|
|
592
|
+
wma: {
|
|
593
|
+
errorRef,
|
|
594
|
+
error: err.message,
|
|
595
|
+
failOpenApplied: true,
|
|
596
|
+
},
|
|
471
597
|
});
|
|
472
598
|
}
|
|
473
599
|
return ToolGuardrailFunctionOutputFactory.rejectContent(
|
|
474
|
-
`WMA Shield error
|
|
475
|
-
{
|
|
600
|
+
`WMA Shield internal error (fail-closed). Ref: ${errorRef}`,
|
|
601
|
+
{
|
|
602
|
+
wma: {
|
|
603
|
+
errorRef,
|
|
604
|
+
error: err.message,
|
|
605
|
+
failOpenApplied: false,
|
|
606
|
+
},
|
|
607
|
+
},
|
|
476
608
|
);
|
|
477
609
|
}
|
|
478
610
|
},
|
|
@@ -545,6 +677,7 @@ export function attachWmaWatch(runner, options = {}) {
|
|
|
545
677
|
const teamId = teamTracker.resolve(sessionId) || teamTracker.bootstrap(sessionId);
|
|
546
678
|
const event = normalizeToolStart({
|
|
547
679
|
agent, tool, toolCall: details?.toolCall, sessionId, teamId,
|
|
680
|
+
toolInputs: options.toolInputs, // v1.4 Codex #4
|
|
548
681
|
});
|
|
549
682
|
await logger.write(event);
|
|
550
683
|
} catch (e) {
|
|
@@ -669,6 +802,7 @@ export function attachWmaWatchToAgent(agent, options = {}) {
|
|
|
669
802
|
const teamId = teamTracker.resolve(sessionId) || teamTracker.bootstrap(sessionId);
|
|
670
803
|
const event = normalizeToolStart({
|
|
671
804
|
agent, tool, toolCall: details?.toolCall, sessionId, teamId,
|
|
805
|
+
toolInputs: options.toolInputs, // v1.4 Codex #4
|
|
672
806
|
});
|
|
673
807
|
await logger.write(event);
|
|
674
808
|
} catch (e) {
|
|
@@ -710,7 +844,12 @@ export const adapterMeta = Object.freeze({
|
|
|
710
844
|
compositionDefault: COMPOSITION_PATTERNS.SOLO,
|
|
711
845
|
customerInstrumented: true, // not auto-discovery
|
|
712
846
|
peerPackage: '@openai/agents',
|
|
713
|
-
|
|
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',
|
|
714
853
|
capabilities: Object.freeze({
|
|
715
854
|
watch: true,
|
|
716
855
|
shield: true,
|