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 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
- - Full removal: `wma-service uninstall` deletes the unit, the launcher, the env file, and the `~/.watchmyagents` directory.
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: tested against `^x.y` (verified in CI against the version pinned in our integration fixtures — see `test/fixtures/openai-agents-events/README.md`)
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.0",
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 rawPath = join(logDir, agentId, `raw-${sid}.jsonl`);
325
- await mkdir(join(logDir, agentId), { recursive: true, mode: 0o700 });
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
  }
@@ -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
- info('uninstalled. (Secrets in ' + ENV_FILE + ' left intact — delete manually if you want them gone.)');
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
+ };
@@ -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
- constructor({ logDir, agentId, sessionId }) {
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: 'anthropic-managed',
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
- function safeParseToolArgs(rawArgs) {
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') return rawArgs;
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: parsedArgs,
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: typeof result === 'string' ? { text: result } : { value: result },
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
- // No ruleset, no path default to "always allow". The customer
378
- // probably means to use Fortress; we don't ship that wiring in
379
- // v1.3.0 from this entry point. Log a warning once.
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] no policy ruleset configured — guardrail will allow all. ' +
384
- 'Pass { policiesPath } or { ruleset } to wmaToolInputGuardrail() to enforce.\n',
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: { error: err.message, failOpenApplied: true },
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: ${err.message} (fail-closed)`,
475
- { wma: { error: err.message, failOpenApplied: false } },
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
- minPeerVersion: '0.1.0', // adjust after testing
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,