watchmyagents 1.3.0 → 1.4.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/README.md +4 -1
- package/SECURITY.md +2 -1
- package/docs/adapters/openai-agents-js.md +1 -1
- package/package.json +9 -1
- package/scripts/fetch-anthropic.js +19 -3
- package/scripts/service.js +26 -3
- package/src/anonymizer.js +48 -0
- package/src/index.js +52 -0
- package/src/logger.js +8 -0
- package/src/openai-agents.js +202 -0
- package/src/shield/decisions.js +12 -2
- package/src/sources/openai-agents-js.js +199 -16
package/README.md
CHANGED
|
@@ -287,7 +287,7 @@ export WMA_SIGNALS_SALT="..." # stable per-custo
|
|
|
287
287
|
|
|
288
288
|
wma-service install (--agent-id agent_01ABC... | --all-agents) [--interval 1m] [--with-shield]
|
|
289
289
|
wma-service status
|
|
290
|
-
wma-service uninstall [--with-shield]
|
|
290
|
+
wma-service uninstall [--with-shield] [--purge]
|
|
291
291
|
```
|
|
292
292
|
|
|
293
293
|
- macOS → **launchd** LaunchAgent · Linux → **systemd** user unit.
|
|
@@ -295,6 +295,9 @@ wma-service uninstall [--with-shield]
|
|
|
295
295
|
runtime — **never** written into the plist/unit.
|
|
296
296
|
- `--with-shield` also runs `wma-shield --policies-source fortress` always-on for
|
|
297
297
|
live enforcement.
|
|
298
|
+
- `uninstall` by default **leaves `~/.watchmyagents/env` on disk** so a re-install
|
|
299
|
+
keeps your snapshotted keys. Pass `--purge` to also delete the env file and the
|
|
300
|
+
whole `~/.watchmyagents` directory (including local logs).
|
|
298
301
|
- Raw logs stay local (`~/.watchmyagents/logs`); only anonymized signals upload.
|
|
299
302
|
|
|
300
303
|
After this, the full Watch→Guardian→Shield loop runs hands-off.
|
package/SECURITY.md
CHANGED
|
@@ -33,7 +33,8 @@ Hardening notes:
|
|
|
33
33
|
- The launcher loads secrets with `while IFS='=' read -r k v` instead of `. file` / `source file`. Sourcing would shell-evaluate every value, so a value containing `$(cmd)` would execute at every restart. The literal read assigns the bytes verbatim.
|
|
34
34
|
- Values are validated before write: a newline anywhere in a credential aborts the install (would corrupt the env file or inject extra lines).
|
|
35
35
|
- To wipe the credential without uninstalling the service: `chmod 600 ~/.watchmyagents/env && : > ~/.watchmyagents/env` (the daemon will exit on the next missing-env check).
|
|
36
|
-
-
|
|
36
|
+
- Service removal: `wma-service uninstall` removes the unit and the launcher but **leaves `~/.watchmyagents/env` on disk** so a re-install keeps your snapshotted keys. This is the default since v1.4.1 (F-37) and is the safer behavior — uninstalling to retry an install does not destroy your API keys.
|
|
37
|
+
- Full removal incl. secrets: `wma-service uninstall --purge` removes the unit, the launcher, the env file, and the whole `~/.watchmyagents` directory (including local NDJSON logs under `~/.watchmyagents/logs`). Use this when you really want to scrub the machine.
|
|
37
38
|
|
|
38
39
|
### Local log files
|
|
39
40
|
|
|
@@ -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.1",
|
|
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",
|
|
@@ -25,7 +25,7 @@ import { createInterface } from 'node:readline';
|
|
|
25
25
|
import { join, resolve } from 'node:path';
|
|
26
26
|
import { request as httpsRequest } from 'node:https';
|
|
27
27
|
import { URL } from 'node:url';
|
|
28
|
-
import { Logger } from '../src/logger.js';
|
|
28
|
+
import { Logger, tightenMode } from '../src/logger.js';
|
|
29
29
|
import { TokenTracker } from '../src/tokens.js';
|
|
30
30
|
import { SignalsAggregator } from '../src/anonymizer.js';
|
|
31
31
|
import { resolveFortressBase, fortressEndpoint } from '../src/fortress/url.js';
|
|
@@ -321,10 +321,26 @@ async function fetchOneShot({ apiKey, agentId, model, logDir, since, sessionId,
|
|
|
321
321
|
process.stdout.write(`\n[wma-fetch] session ${sid}\n`);
|
|
322
322
|
if (dumpRaw) {
|
|
323
323
|
assertSafePathSegment(sid, 'session-id'); // defense-in-depth: sid → file path
|
|
324
|
-
const
|
|
325
|
-
|
|
324
|
+
const rawDir = join(logDir, agentId);
|
|
325
|
+
const rawPath = join(rawDir, `raw-${sid}.jsonl`);
|
|
326
|
+
await mkdir(rawDir, { recursive: true, mode: 0o700 });
|
|
327
|
+
// v1.4.1 F-34 (P2 Codex audit): mkdir/appendFile `mode` is creation-
|
|
328
|
+
// only. If a previous `wma-fetch` run, a different user, or a hand-
|
|
329
|
+
// rolled mkdir left the directory or file in place with loose perms
|
|
330
|
+
// (typically 0755/0644 via umask), the original code path kept them.
|
|
331
|
+
// The raw JSONL carries unredacted API events, so loose perms make
|
|
332
|
+
// them readable to any local user. Tighten after the directory and
|
|
333
|
+
// after the first append so an existing inode is brought in line
|
|
334
|
+
// with the doc promise (0700/0600). Best-effort: chmod failures
|
|
335
|
+
// here MUST NOT break wma-fetch.
|
|
336
|
+
await tightenMode(rawDir, 0o700);
|
|
337
|
+
let firstAppend = true;
|
|
326
338
|
for await (const ev of fetchRawEvents(apiKey, sid)) {
|
|
327
339
|
await appendFile(rawPath, JSON.stringify(ev) + '\n', { encoding: 'utf8', mode: 0o600 });
|
|
340
|
+
if (firstAppend) {
|
|
341
|
+
await tightenMode(rawPath, 0o600);
|
|
342
|
+
firstAppend = false;
|
|
343
|
+
}
|
|
328
344
|
}
|
|
329
345
|
process.stdout.write(` raw events → ${rawPath}\n`);
|
|
330
346
|
}
|
package/scripts/service.js
CHANGED
|
@@ -11,7 +11,14 @@
|
|
|
11
11
|
// One integrated install:
|
|
12
12
|
// wma-service install --agent-id agent_xxx [--interval 5m] [--with-shield]
|
|
13
13
|
// wma-service status
|
|
14
|
-
// wma-service uninstall [--with-shield]
|
|
14
|
+
// wma-service uninstall [--with-shield] [--purge]
|
|
15
|
+
//
|
|
16
|
+
// v1.4.1 F-37 (P3 Codex audit on v1.4.0): `uninstall` defaults to LEAVING
|
|
17
|
+
// ~/.watchmyagents/env on disk (containing the snapshotted API keys). This
|
|
18
|
+
// matches the historical behavior and protects users who uninstall just to
|
|
19
|
+
// reinstall with a different agent-id from losing their snapshot. To wipe
|
|
20
|
+
// the env file + config dir, pass `--purge` explicitly. SECURITY.md says
|
|
21
|
+
// the same thing; do not let the doc + the code drift again.
|
|
15
22
|
//
|
|
16
23
|
// Secrets NEVER go in the plist/unit. They're snapshotted (from the current
|
|
17
24
|
// environment) into a protected env file (~/.watchmyagents/env, chmod 600) that
|
|
@@ -289,6 +296,7 @@ function cmdInstall(args) {
|
|
|
289
296
|
|
|
290
297
|
function cmdUninstall(args) {
|
|
291
298
|
const withShield = !!args['with-shield'];
|
|
299
|
+
const purge = !!args.purge;
|
|
292
300
|
if (PLATFORM === 'darwin') {
|
|
293
301
|
macUnload(WATCH_LABEL);
|
|
294
302
|
if (withShield) macUnload(SHIELD_LABEL);
|
|
@@ -298,7 +306,18 @@ function cmdUninstall(args) {
|
|
|
298
306
|
} else {
|
|
299
307
|
die(`unsupported platform "${PLATFORM}"`);
|
|
300
308
|
}
|
|
301
|
-
|
|
309
|
+
if (purge) {
|
|
310
|
+
// F-37: explicit opt-in only. Removes the snapshotted env file (chmod
|
|
311
|
+
// 600, contains API keys) and the whole config dir. Logs under
|
|
312
|
+
// ~/.watchmyagents/logs are wiped too — the user asked for purge, so
|
|
313
|
+
// we do not second-guess them on local NDJSON. `force: true` ensures
|
|
314
|
+
// we don't throw if the path is already gone.
|
|
315
|
+
try { rmSync(ENV_FILE, { force: true }); } catch { /* best-effort */ }
|
|
316
|
+
try { rmSync(CONFIG_DIR, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
317
|
+
info(`uninstalled + purged ${CONFIG_DIR} (env file and local logs removed).`);
|
|
318
|
+
} else {
|
|
319
|
+
info(`uninstalled. Secrets in ${ENV_FILE} left intact — re-run with --purge to wipe them.`);
|
|
320
|
+
}
|
|
302
321
|
}
|
|
303
322
|
|
|
304
323
|
function cmdStatus() {
|
|
@@ -328,11 +347,15 @@ function usage() {
|
|
|
328
347
|
Usage:
|
|
329
348
|
wma-service install --agent-id agent_xxx [--interval 5m] [--log-dir DIR] [--with-shield]
|
|
330
349
|
wma-service status
|
|
331
|
-
wma-service uninstall [--with-shield]
|
|
350
|
+
wma-service uninstall [--with-shield] [--purge]
|
|
332
351
|
|
|
333
352
|
Required env at install (snapshotted to ~/.watchmyagents/env, chmod 600):
|
|
334
353
|
ANTHROPIC_API_KEY, WMA_API_KEY, WMA_FORTRESS_BASE_URL, WMA_SIGNALS_SALT
|
|
335
354
|
|
|
355
|
+
Uninstall by default leaves ~/.watchmyagents/env on disk so a re-install
|
|
356
|
+
keeps your snapshotted keys. Pass --purge to also delete the env file and
|
|
357
|
+
the whole ~/.watchmyagents directory (including local logs).
|
|
358
|
+
|
|
336
359
|
macOS → launchd LaunchAgent · Linux → systemd user unit.
|
|
337
360
|
The service starts at login and restarts on crash. Raw logs stay local.
|
|
338
361
|
`);
|
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';
|
package/src/logger.js
CHANGED
|
@@ -16,6 +16,14 @@ async function tightenMode(path, mode) {
|
|
|
16
16
|
try { await chmod(path, mode); } catch { /* not fatal */ }
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// v1.4.1 F-34 (P2 Codex audit on v1.4.0): re-exported so other writers
|
|
20
|
+
// of WMA-controlled log paths can defend against the same loose-perms
|
|
21
|
+
// trap (mkdir/appendFile `mode` is creation-only). Used by:
|
|
22
|
+
// scripts/fetch-anthropic.js --dump-raw (raw API events on disk)
|
|
23
|
+
// any future code path that mkdir/appendFile into a customer log dir
|
|
24
|
+
// Best-effort by design — failure of chmod must NOT break log writes.
|
|
25
|
+
export { tightenMode };
|
|
26
|
+
|
|
19
27
|
// PR-B: `framework` → `provider` (canonical name per src/sources/contract.js).
|
|
20
28
|
// PR-C: adds `parent_agent_id` + `composition_pattern` so any future
|
|
21
29
|
// adapter that knows the hierarchy (OpenAI Agents handoffs, CrewAI
|
|
@@ -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,68 @@ 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
|
+
// v1.4.1 F-36 (P3 Codex audit on v1.4.0): proper byte-level truncation
|
|
120
|
+
// for UTF-8 strings. Pre-v1.4.1 we used `str.slice(0, maxBytes)`, which
|
|
121
|
+
// slices by CHARACTERS — with multi-byte Unicode (emoji = 4 bytes per
|
|
122
|
+
// char) the cap could be exceeded by up to 3x. This helper cuts on the
|
|
123
|
+
// byte boundary via Buffer and strips the trailing U+FFFD replacement
|
|
124
|
+
// character if the cut landed mid-sequence. We accept the dropped
|
|
125
|
+
// trailing char as acceptable cost: the result is marked _wmaTruncated.
|
|
126
|
+
function truncateUtf8(str, maxBytes) {
|
|
127
|
+
if (typeof str !== 'string') return str;
|
|
128
|
+
const buf = Buffer.from(str, 'utf8');
|
|
129
|
+
if (buf.length <= maxBytes) return str;
|
|
130
|
+
return buf.subarray(0, maxBytes).toString('utf8').replace(/�+$/, '');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function safeParseToolArgs(rawArgs, maxBytes = DEFAULT_MAX_ARG_BYTES) {
|
|
99
134
|
if (rawArgs == null) return null;
|
|
100
|
-
if (typeof rawArgs === 'object')
|
|
135
|
+
if (typeof rawArgs === 'object') {
|
|
136
|
+
// v1.4.1 F-36 — also cap object inputs by their serialized byte
|
|
137
|
+
// size. Pre-v1.4.1 already-parsed objects bypassed the cap entirely,
|
|
138
|
+
// which let a misbehaving tool / customer pass a 5 MB object through
|
|
139
|
+
// safeParseToolArgs unimpeded. We don't deep-walk-and-truncate (that
|
|
140
|
+
// would silently change policy match semantics on nested fields);
|
|
141
|
+
// instead we replace the whole input with a truncation marker if
|
|
142
|
+
// the serialized form exceeds maxBytes.
|
|
143
|
+
try {
|
|
144
|
+
const serialized = JSON.stringify(rawArgs);
|
|
145
|
+
const byteLen = Buffer.byteLength(serialized, 'utf8');
|
|
146
|
+
if (byteLen > maxBytes) {
|
|
147
|
+
return { _wmaTruncated: true, _wmaOriginalBytes: byteLen };
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
// Unserializable input — return as-is. Policy match against
|
|
151
|
+
// anything in this object is going to fail anyway.
|
|
152
|
+
}
|
|
153
|
+
return rawArgs;
|
|
154
|
+
}
|
|
101
155
|
if (typeof rawArgs !== 'string') return null;
|
|
156
|
+
// Byte cap: Buffer.byteLength is the safe length on the wire.
|
|
157
|
+
const byteLen = Buffer.byteLength(rawArgs, 'utf8');
|
|
158
|
+
if (byteLen > maxBytes) {
|
|
159
|
+
// v1.4.1 F-36 — byte-level truncation (was char-level before).
|
|
160
|
+
const head = truncateUtf8(rawArgs, maxBytes);
|
|
161
|
+
try {
|
|
162
|
+
const parsed = JSON.parse(head);
|
|
163
|
+
// If by luck the head is valid JSON, return it plus the marker.
|
|
164
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
165
|
+
return { ...parsed, _wmaTruncated: true, _wmaOriginalBytes: byteLen };
|
|
166
|
+
}
|
|
167
|
+
return { _wmaTruncated: true, _wmaOriginalBytes: byteLen, head: parsed };
|
|
168
|
+
} catch {
|
|
169
|
+
// Common path: truncated JSON is invalid mid-tree.
|
|
170
|
+
return { _wmaTruncated: true, _wmaOriginalBytes: byteLen };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
102
173
|
try { return JSON.parse(rawArgs); }
|
|
103
174
|
catch { return null; }
|
|
104
175
|
}
|
|
@@ -110,6 +181,17 @@ function makeSessionId() {
|
|
|
110
181
|
return `oai-${randomUUID()}`;
|
|
111
182
|
}
|
|
112
183
|
|
|
184
|
+
// v1.4 F-31 — short reference code minted per Shield internal error.
|
|
185
|
+
// Returned to the model as `Ref: WMA-SHL-<8hex>` and logged to stderr
|
|
186
|
+
// alongside the full err.message so an operator can correlate the
|
|
187
|
+
// generic model-facing message to the local detailed log without
|
|
188
|
+
// leaking the raw error to the model.
|
|
189
|
+
function makeErrorRef() {
|
|
190
|
+
// 8 hex chars from a fresh UUID — wide enough to deconflict within
|
|
191
|
+
// a session, short enough to be readable in a tool result.
|
|
192
|
+
return `WMA-SHL-${randomUUID().replace(/-/g, '').slice(0, 8)}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
113
195
|
// Derive a stable, monotonic event id from the SDK's tool_call id when
|
|
114
196
|
// present (so deduplication after restart works), else mint a UUID.
|
|
115
197
|
function makeEventId(toolCall) {
|
|
@@ -274,8 +356,18 @@ export function normalizeAgentHandoff({ fromAgent, toAgent, sessionId, teamId })
|
|
|
274
356
|
});
|
|
275
357
|
}
|
|
276
358
|
|
|
277
|
-
export function normalizeToolStart({ agent, tool, toolCall, sessionId, teamId }) {
|
|
359
|
+
export function normalizeToolStart({ agent, tool, toolCall, sessionId, teamId, toolInputs }) {
|
|
278
360
|
const parsedArgs = safeParseToolArgs(toolCall?.arguments);
|
|
361
|
+
// v1.4 Codex #4 — per-tool argument aliases. If `toolInputs` is
|
|
362
|
+
// provided AND has an entry for this tool's name, apply the alias
|
|
363
|
+
// map via normalizeToolInput() from the anonymizer. The result
|
|
364
|
+
// populates canonical names (`url`, `query`, `command`, `path`,
|
|
365
|
+
// `file_path`) so the SignalsAggregator hashes them EXACTLY, not
|
|
366
|
+
// via the F-30 suffix heuristic (which is the opt-out safety net,
|
|
367
|
+
// not the precision path). Both layers coexist: explicit aliases
|
|
368
|
+
// win on declared fields; heuristic catches the long tail.
|
|
369
|
+
const aliases = toolInputs && tool?.name ? toolInputs[tool.name] : null;
|
|
370
|
+
const finalInput = aliases ? normalizeToolInput(parsedArgs, aliases) : parsedArgs;
|
|
279
371
|
return Object.freeze({
|
|
280
372
|
id: makeEventId(toolCall),
|
|
281
373
|
provider: PROVIDER,
|
|
@@ -292,11 +384,45 @@ export function normalizeToolStart({ agent, tool, toolCall, sessionId, teamId })
|
|
|
292
384
|
parent_agent_id: null,
|
|
293
385
|
composition_pattern: COMPOSITION_PATTERNS.SOLO,
|
|
294
386
|
team_id: teamId,
|
|
295
|
-
input:
|
|
387
|
+
input: finalInput,
|
|
296
388
|
output: null,
|
|
297
389
|
});
|
|
298
390
|
}
|
|
299
391
|
|
|
392
|
+
// v1.4 F-32 — cap result bytes before writing NDJSON. Verbose tools
|
|
393
|
+
// (HTML scrapers, web_fetch with full-page payloads, computer use
|
|
394
|
+
// screenshots base64'd) can return arbitrary megabytes. Without a cap
|
|
395
|
+
// we'd write a single ~MB-sized JSON line per call into the rotation
|
|
396
|
+
// file, fill the disk, and slow every subsequent NDJSON read.
|
|
397
|
+
function truncateResult(result, maxBytes = DEFAULT_MAX_RESULT_BYTES) {
|
|
398
|
+
if (typeof result === 'string') {
|
|
399
|
+
const byteLen = Buffer.byteLength(result, 'utf8');
|
|
400
|
+
if (byteLen > maxBytes) {
|
|
401
|
+
// v1.4.1 F-36 — byte-level truncation (was char-level before).
|
|
402
|
+
return {
|
|
403
|
+
text: truncateUtf8(result, maxBytes) + TRUNCATION_SENTINEL,
|
|
404
|
+
_wmaTruncated: true,
|
|
405
|
+
_wmaOriginalBytes: byteLen,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
return { text: result };
|
|
409
|
+
}
|
|
410
|
+
if (result == null) return { value: null };
|
|
411
|
+
// Object / array result: serialize, check size, truncate if needed.
|
|
412
|
+
try {
|
|
413
|
+
const serialized = JSON.stringify(result);
|
|
414
|
+
const byteLen = Buffer.byteLength(serialized, 'utf8');
|
|
415
|
+
if (byteLen > maxBytes) {
|
|
416
|
+
return {
|
|
417
|
+
value: { _wmaTruncated: true, _wmaOriginalBytes: byteLen },
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
return { value: result };
|
|
421
|
+
} catch {
|
|
422
|
+
return { value: { _wmaTruncated: true, _wmaUnserializable: true } };
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
300
426
|
export function normalizeToolEnd({ agent, tool, result, toolCall, sessionId, teamId }) {
|
|
301
427
|
return Object.freeze({
|
|
302
428
|
id: `${makeEventId(toolCall)}-end`,
|
|
@@ -315,7 +441,7 @@ export function normalizeToolEnd({ agent, tool, result, toolCall, sessionId, tea
|
|
|
315
441
|
composition_pattern: COMPOSITION_PATTERNS.SOLO,
|
|
316
442
|
team_id: teamId,
|
|
317
443
|
input: null,
|
|
318
|
-
output:
|
|
444
|
+
output: truncateResult(result),
|
|
319
445
|
});
|
|
320
446
|
}
|
|
321
447
|
|
|
@@ -344,6 +470,23 @@ export function normalizeToolEnd({ agent, tool, result, toolCall, sessionId, tea
|
|
|
344
470
|
// policy ruleset on first invocation if `policiesPath` is set.
|
|
345
471
|
|
|
346
472
|
export function wmaToolInputGuardrail(options = {}) {
|
|
473
|
+
// v1.4.1 F-35 (P2 Codex audit on v1.4.0): fail-loud at construction
|
|
474
|
+
// time when nothing safety-relevant is configured. Same intent as the
|
|
475
|
+
// openaiAgents() factory's mode:'enforce' check, but applied to the
|
|
476
|
+
// lower-level entry point too — defense in depth. Customers using the
|
|
477
|
+
// direct import path no longer get a silent "allow all" surprise.
|
|
478
|
+
const hasPolicySource =
|
|
479
|
+
options.policiesPath != null ||
|
|
480
|
+
options.ruleset != null ||
|
|
481
|
+
options.allowAllWhenUnconfigured === true;
|
|
482
|
+
if (!hasPolicySource) {
|
|
483
|
+
throw new Error(
|
|
484
|
+
'wmaToolInputGuardrail: no policy configured. Pass policiesPath or ruleset. ' +
|
|
485
|
+
'For demos / smoke tests that intentionally allow all tool calls, pass ' +
|
|
486
|
+
'{ allowAllWhenUnconfigured: true } explicitly.',
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
347
490
|
const failOpen = options.failOpen === true ? true : DEFAULT_FAIL_OPEN;
|
|
348
491
|
const sessionId = options.sessionId || makeSessionId();
|
|
349
492
|
const logDir = options.logDir
|
|
@@ -354,8 +497,13 @@ export function wmaToolInputGuardrail(options = {}) {
|
|
|
354
497
|
let ruleset = options.ruleset || null;
|
|
355
498
|
const tracker = options.tracker
|
|
356
499
|
|| createContextTracker({ recentWindowSize: options.recentWindowSize ?? 20 });
|
|
500
|
+
// v1.3.1 F-29 (P1 Codex audit): provider must be passed explicitly so
|
|
501
|
+
// shield_decision NDJSON rows carry 'openai-agents' instead of being
|
|
502
|
+
// mis-attributed to 'anthropic-managed' (DecisionLogger's pre-v1.3.1
|
|
503
|
+
// hardcoded default). Fortress / Guardian forensic surfaces depend on
|
|
504
|
+
// this for correct multi-provider attribution.
|
|
357
505
|
const decisionLogger = options.decisionLogger
|
|
358
|
-
|| new DecisionLogger({ logDir, agentId: 'openai-agents', sessionId });
|
|
506
|
+
|| new DecisionLogger({ logDir, agentId: 'openai-agents', sessionId, provider: PROVIDER });
|
|
359
507
|
const logger = options.logger
|
|
360
508
|
|| new Logger({ logDir, agentId: 'openai-agents', sessionId, silent: true, bestEffort: false });
|
|
361
509
|
|
|
@@ -374,14 +522,16 @@ export function wmaToolInputGuardrail(options = {}) {
|
|
|
374
522
|
ruleset = await loadPolicies(options.policiesPath);
|
|
375
523
|
return ruleset;
|
|
376
524
|
}
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
//
|
|
525
|
+
// v1.4.1 F-35: only reachable when the customer passed
|
|
526
|
+
// `allowAllWhenUnconfigured: true` — the synchronous check at
|
|
527
|
+
// construction time has already rejected every other un-configured
|
|
528
|
+
// shape. Log a loud warning once so demo deployments don't quietly
|
|
529
|
+
// ship without a real ruleset.
|
|
380
530
|
if (!ensureRuleset._warned) {
|
|
381
531
|
ensureRuleset._warned = true;
|
|
382
532
|
process.stderr.write(
|
|
383
|
-
'[wma/openai-agents]
|
|
384
|
-
'
|
|
533
|
+
'[wma/openai-agents] allowAllWhenUnconfigured=true — guardrail will allow ALL tool calls. ' +
|
|
534
|
+
'This is intended for demos only; ship with a real ruleset in production.\n',
|
|
385
535
|
);
|
|
386
536
|
}
|
|
387
537
|
ruleset = { policies: [], default: { action: 'allow' } };
|
|
@@ -406,6 +556,10 @@ export function wmaToolInputGuardrail(options = {}) {
|
|
|
406
556
|
toolCall,
|
|
407
557
|
sessionId,
|
|
408
558
|
teamId: getTeamId(),
|
|
559
|
+
// v1.4 Codex #4 — per-tool alias map (option `toolInputs`).
|
|
560
|
+
// Threaded through so the anonymizer hashes by canonical name
|
|
561
|
+
// for tools the customer explicitly mapped.
|
|
562
|
+
toolInputs: options.toolInputs,
|
|
409
563
|
});
|
|
410
564
|
|
|
411
565
|
// 2. Compute policy context BEFORE evaluation so recent_error_rate
|
|
@@ -462,17 +616,39 @@ export function wmaToolInputGuardrail(options = {}) {
|
|
|
462
616
|
// Shield internal failure (policy file unreadable, disk full on
|
|
463
617
|
// log write, etc.). Default fail-CLOSED unless customer opted
|
|
464
618
|
// into fail-OPEN.
|
|
619
|
+
//
|
|
620
|
+
// v1.4 F-31 (P2 Codex audit on v1.3.0): the agent must NOT see
|
|
621
|
+
// the raw err.message — it can carry local paths, policy file
|
|
622
|
+
// names, disk-mount strings, permission codes, or even fragments
|
|
623
|
+
// of the policy itself that should never reach a model context.
|
|
624
|
+
// We mint a short reference code, log the full error locally to
|
|
625
|
+
// stderr keyed by that code, and return a generic message that
|
|
626
|
+
// operators can correlate against the local log without exposing
|
|
627
|
+
// anything actionable to the model. The outputInfo (which is NOT
|
|
628
|
+
// surfaced to the model — it stays in the application's run
|
|
629
|
+
// metadata) keeps the err.message for SDK-side tracing.
|
|
630
|
+
const errorRef = makeErrorRef();
|
|
465
631
|
process.stderr.write(
|
|
466
|
-
`[wma/openai-agents] guardrail error: ${err.message}\n`,
|
|
632
|
+
`[wma/openai-agents] [${errorRef}] guardrail error: ${err.message}\n`,
|
|
467
633
|
);
|
|
468
634
|
if (failOpen) {
|
|
469
635
|
return ToolGuardrailFunctionOutputFactory.allow({
|
|
470
|
-
wma: {
|
|
636
|
+
wma: {
|
|
637
|
+
errorRef,
|
|
638
|
+
error: err.message,
|
|
639
|
+
failOpenApplied: true,
|
|
640
|
+
},
|
|
471
641
|
});
|
|
472
642
|
}
|
|
473
643
|
return ToolGuardrailFunctionOutputFactory.rejectContent(
|
|
474
|
-
`WMA Shield error
|
|
475
|
-
{
|
|
644
|
+
`WMA Shield internal error (fail-closed). Ref: ${errorRef}`,
|
|
645
|
+
{
|
|
646
|
+
wma: {
|
|
647
|
+
errorRef,
|
|
648
|
+
error: err.message,
|
|
649
|
+
failOpenApplied: false,
|
|
650
|
+
},
|
|
651
|
+
},
|
|
476
652
|
);
|
|
477
653
|
}
|
|
478
654
|
},
|
|
@@ -545,6 +721,7 @@ export function attachWmaWatch(runner, options = {}) {
|
|
|
545
721
|
const teamId = teamTracker.resolve(sessionId) || teamTracker.bootstrap(sessionId);
|
|
546
722
|
const event = normalizeToolStart({
|
|
547
723
|
agent, tool, toolCall: details?.toolCall, sessionId, teamId,
|
|
724
|
+
toolInputs: options.toolInputs, // v1.4 Codex #4
|
|
548
725
|
});
|
|
549
726
|
await logger.write(event);
|
|
550
727
|
} catch (e) {
|
|
@@ -669,6 +846,7 @@ export function attachWmaWatchToAgent(agent, options = {}) {
|
|
|
669
846
|
const teamId = teamTracker.resolve(sessionId) || teamTracker.bootstrap(sessionId);
|
|
670
847
|
const event = normalizeToolStart({
|
|
671
848
|
agent, tool, toolCall: details?.toolCall, sessionId, teamId,
|
|
849
|
+
toolInputs: options.toolInputs, // v1.4 Codex #4
|
|
672
850
|
});
|
|
673
851
|
await logger.write(event);
|
|
674
852
|
} catch (e) {
|
|
@@ -710,7 +888,12 @@ export const adapterMeta = Object.freeze({
|
|
|
710
888
|
compositionDefault: COMPOSITION_PATTERNS.SOLO,
|
|
711
889
|
customerInstrumented: true, // not auto-discovery
|
|
712
890
|
peerPackage: '@openai/agents',
|
|
713
|
-
|
|
891
|
+
// v1.4 F-33 — aligned with package.json#peerDependencies range
|
|
892
|
+
// `^0.2.0`. Real fixtures captured against 0.2.x verified the
|
|
893
|
+
// lifecycle event signatures in v1.3.0; minPeerVersion below that is
|
|
894
|
+
// unsupported and may show drift in args layout (the AgentHooks vs
|
|
895
|
+
// RunHooks gap that motivated the v1.3.0 finalize commit).
|
|
896
|
+
minPeerVersion: '0.2.0',
|
|
714
897
|
capabilities: Object.freeze({
|
|
715
898
|
watch: true,
|
|
716
899
|
shield: true,
|