watchmyagents 1.4.9 → 1.4.12

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
@@ -106,7 +106,7 @@ import { Agent, Runner } from '@openai/agents';
106
106
  import {
107
107
  wmaToolInputGuardrail,
108
108
  attachWmaWatch,
109
- } from 'watchmyagents/src/sources/openai-agents-js.js';
109
+ } from 'watchmyagents/openai-agents';
110
110
 
111
111
  const wmaShield = wmaToolInputGuardrail({
112
112
  policiesPath: './examples/policies/mitre-starter.json',
@@ -131,7 +131,7 @@ import { Agent, run } from '@openai/agents';
131
131
  import {
132
132
  wmaToolInputGuardrail,
133
133
  attachWmaWatchToAgent,
134
- } from 'watchmyagents/src/sources/openai-agents-js.js';
134
+ } from 'watchmyagents/openai-agents';
135
135
 
136
136
  const wmaShield = wmaToolInputGuardrail({
137
137
  policiesPath: './examples/policies/mitre-starter.json',
@@ -154,14 +154,14 @@ That's it. NDJSON lands in `./watchmyagents-logs/openai-agents/`. The MITRE star
154
154
 
155
155
  See [docs/adapters/openai-agents-js.md](docs/adapters/openai-agents-js.md) for the full options reference + troubleshooting.
156
156
 
157
- ## Supported runtimes (v1.3.0)
157
+ ## Supported runtimes
158
158
 
159
159
  | Runtime | Mode | Onboarding | Status |
160
160
  |---|---|---|---|
161
161
  | **Anthropic Managed Agents** | pull REST/SSE | API key (zero-touch) | ✓ shipped |
162
- | **OpenAI Agents SDK** (TypeScript/JS) | push (in-process hooks + guardrails) | 2 lines of code | ✓ v1.3.0 |
163
- | OpenAI Agents SDK (Python) | push (separate `watchmyagents-py` package) | — | planned v1.4.0 |
164
- | Claude Code (dynamic workflows) | push (hooks via `settings.json`) | — | planned v1.4.x |
162
+ | **OpenAI Agents SDK** (TypeScript/JS) | push (in-process hooks + guardrails) | 2 lines of code, or Fortress "Register an OpenAI agent" | ✓ shipped (live Fortress policies + decisions since v1.4.6) |
163
+ | OpenAI Agents SDK (Python) | push (separate `watchmyagents-py` package) | — | planned |
164
+ | Claude Code / Cowork (hooks) | push (plugin hooks via `settings.json`) | — | planned (Phase 2.B) |
165
165
  | AWS Bedrock AgentCore | pull REST/SSE | similar to Anthropic | planned v1.5.0 |
166
166
 
167
167
  ## What gets logged
@@ -1,6 +1,6 @@
1
1
  # OpenAI Agents SDK adapter (TypeScript / JavaScript)
2
2
 
3
- **Status: v1.3.0 (Phase 2.A) — first adapter that observes a runtime which executes locally on the customer machine.**
3
+ **Status: shipped (introduced v1.3.0, Phase 2.A) — the first adapter that observes a runtime which executes locally on the customer machine. Live Fortress policies + decision upload since v1.4.6.**
4
4
 
5
5
  This adapter integrates `watchmyagents` with the [OpenAI Agents SDK](https://openai.github.io/openai-agents-js/) (`@openai/agents` on npm). Customers add two lines to their existing agent code; WMA logs every lifecycle event locally and can block tool calls before execution via Shield policies.
6
6
 
@@ -67,7 +67,7 @@ import { Agent, Runner } from '@openai/agents';
67
67
  import {
68
68
  wmaToolInputGuardrail,
69
69
  attachWmaWatch,
70
- } from 'watchmyagents/src/sources/openai-agents-js.js';
70
+ } from 'watchmyagents/openai-agents';
71
71
 
72
72
  const wmaShield = wmaToolInputGuardrail({
73
73
  policiesPath: './examples/policies/mitre-starter.json',
@@ -93,7 +93,7 @@ import { Agent, run } from '@openai/agents';
93
93
  import {
94
94
  wmaToolInputGuardrail,
95
95
  attachWmaWatchToAgent,
96
- } from 'watchmyagents/src/sources/openai-agents-js.js';
96
+ } from 'watchmyagents/openai-agents';
97
97
 
98
98
  const wmaShield = wmaToolInputGuardrail({
99
99
  policiesPath: './examples/policies/mitre-starter.json',
@@ -161,7 +161,7 @@ Three resolution channels for `team_id`, in precedence order:
161
161
  Every `shield_decision` row carries `prev_hash` + `chain_hash` (SHA-256). The audit chain is local-only tamper-evidence. Verify with:
162
162
 
163
163
  ```typescript
164
- import { verifyDecisionChain } from 'watchmyagents/src/shield/decision-chain.js';
164
+ import { verifyDecisionChain } from 'watchmyagents';
165
165
  import { readFile } from 'node:fs/promises';
166
166
 
167
167
  const lines = (await readFile('./watchmyagents-logs/openai-agents/2026-06-09.ndjson', 'utf8'))
@@ -26,7 +26,7 @@ import { z } from 'zod';
26
26
  import {
27
27
  wmaToolInputGuardrail,
28
28
  attachWmaWatch,
29
- } from 'watchmyagents/src/sources/openai-agents-js.js';
29
+ } from 'watchmyagents/openai-agents';
30
30
 
31
31
  // ── Tools the agent can call ───────────────────────────────────────────
32
32
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "watchmyagents",
3
- "version": "1.4.9",
3
+ "version": "1.4.12",
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": [
@@ -28,7 +28,7 @@ import { URL } from 'node:url';
28
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
- import { resolveFortressBase, fortressEndpoint } from '../src/fortress/url.js';
31
+ import { resolveFortressBase, fortressEndpoint, guardedLookup } from '../src/fortress/url.js';
32
32
  import { cleanLabel } from '../src/labels.js';
33
33
  import { isValidAgentId, isValidSessionId, assertSafePathSegment } from '../src/validate.js';
34
34
  import { classifyAgentType } from '../src/typology.js';
@@ -107,6 +107,7 @@ function postJson(url, headers, body) {
107
107
  path: u.pathname + (u.search || ''),
108
108
  headers: { ...headers, 'content-type': 'application/json', 'content-length': data.length },
109
109
  rejectUnauthorized: true,
110
+ lookup: guardedLookup, // v1.4.11: DNS-rebinding guard on the Fortress upload
110
111
  }, (res) => {
111
112
  const chunks = [];
112
113
  let receivedBytes = 0;
@@ -29,7 +29,7 @@ import { join, resolve } from 'node:path';
29
29
  import { createReadStream } from 'node:fs';
30
30
  import { createInterface } from 'node:readline';
31
31
  import { SignalsAggregator } from '../src/anonymizer.js';
32
- import { resolveFortressBase, fortressEndpoint } from '../src/fortress/url.js';
32
+ import { resolveFortressBase, fortressEndpoint, guardedLookup } from '../src/fortress/url.js';
33
33
  import { AnthropicManagedSource } from '../src/sources/anthropic-managed.js';
34
34
  import { cleanLabel } from '../src/labels.js';
35
35
  import { maybePrintVersionAndExit } from '../src/version.js';
@@ -97,6 +97,7 @@ function postJson(url, headers, body) {
97
97
  'content-length': data.length,
98
98
  },
99
99
  rejectUnauthorized: true,
100
+ lookup: guardedLookup, // v1.4.11: DNS-rebinding guard on the Fortress upload
100
101
  },
101
102
  (res) => {
102
103
  const chunks = [];
package/src/anonymizer.js CHANGED
@@ -77,6 +77,7 @@ const WELL_KNOWN_TOOLS = new Set([
77
77
  */
78
78
  export function hashWithSalt(value, salt) {
79
79
  if (value == null) return null;
80
+ assertStrongSalt(salt, 'hashWithSalt');
80
81
  const s = typeof value === 'string' ? value : JSON.stringify(value);
81
82
  return 'sha256:' + createHash('sha256').update(salt).update(s).digest('hex').slice(0, 32);
82
83
  }
@@ -86,6 +87,25 @@ export function generateSalt() {
86
87
  return randomBytes(16).toString('hex');
87
88
  }
88
89
 
90
+ // v1.4.12 (audit residual): the IoC hashes are only non-reversible if the salt
91
+ // is strong. IoCs are low-entropy (IPs, ports, common URLs/commands), so a
92
+ // weak/short salt lets anyone who obtains the uploaded ioc_hashes[] brute-force
93
+ // them back to the raw values — breaking Containment-at-rest. The wma-* CLIs
94
+ // already enforce a >=16-char floor; this moves it INTO the library so a
95
+ // PROGRAMMATIC consumer (SignalsAggregator, hashWithSalt) can't bypass it with
96
+ // a weak salt. 16 chars matches the CLI floor + generateSalt()'s 32-hex output.
97
+ export const MIN_SALT_LENGTH = 16;
98
+ export function assertStrongSalt(salt, ctx = 'salt') {
99
+ if (typeof salt !== 'string' || salt.length < MIN_SALT_LENGTH) {
100
+ const got = typeof salt === 'string' ? `${salt.length} chars` : typeof salt;
101
+ throw new Error(
102
+ `${ctx}: salt must be a string of at least ${MIN_SALT_LENGTH} characters (got ${got}). ` +
103
+ 'A weak salt makes the IoC hashes brute-forceable. Use generateSalt() or a ' +
104
+ 'stable >=16-char per-customer secret (WMA_SIGNALS_SALT).',
105
+ );
106
+ }
107
+ }
108
+
89
109
  // ── Tool name normalization (Containment hardening, v1.0.1 F-3) ────────
90
110
 
91
111
  /**
@@ -106,7 +126,7 @@ export function normalizeToolName(toolName, salt) {
106
126
  const s = String(toolName);
107
127
  if (s.length === 0) return null;
108
128
  if (WELL_KNOWN_TOOLS.has(s)) return s;
109
- if (!salt) throw new Error('normalizeToolName requires a salt to hash custom tool names');
129
+ assertStrongSalt(salt, 'normalizeToolName');
110
130
  return 'tool_hash:' + createHash('sha256').update(salt).update(s).digest('hex').slice(0, 32);
111
131
  }
112
132
 
@@ -223,7 +243,7 @@ function extractIocs(entry, salt) {
223
243
 
224
244
  export class SignalsAggregator {
225
245
  constructor({ salt } = {}) {
226
- if (!salt) throw new Error('SignalsAggregator requires a salt');
246
+ assertStrongSalt(salt, 'SignalsAggregator');
227
247
  this.salt = salt;
228
248
  this.counts = Object.create(null); // action_type → count
229
249
  this.toolCounts = Object.create(null); // tool_name → count
@@ -369,7 +389,7 @@ export class SignalsAggregator {
369
389
  // ── Streaming convenience: anonymize a whole NDJSON file/dir ────────────
370
390
 
371
391
  export async function anonymizeFile(filePath, { salt } = {}) {
372
- if (!salt) throw new Error('anonymizeFile requires a salt');
392
+ assertStrongSalt(salt, 'anonymizeFile');
373
393
  const agg = new SignalsAggregator({ salt });
374
394
  const stream = createReadStream(filePath, { encoding: 'utf8' });
375
395
  const rl = createInterface({ input: stream, crlfDelay: Infinity });
@@ -1,6 +1,7 @@
1
1
  // ─────────────────────────────────────────────────────────────────────────
2
2
  // Fortress URL resolution — shared across upload-fortress, shield, etc.
3
3
  // ─────────────────────────────────────────────────────────────────────────
4
+ import { lookup as dnsLookup } from 'node:dns';
4
5
  // The user sets ONE of:
5
6
  //
6
7
  // WMA_FORTRESS_BASE_URL=https://<project>.supabase.co/functions/v1
@@ -74,29 +75,40 @@ export function assertSafeFortressBase(base) {
74
75
  if (u.username || u.password) {
75
76
  throw new Error('Fortress base URL must not embed credentials (user:pass@host).');
76
77
  }
77
- if (isBlockedHost(u.hostname)) {
78
+ if (!privateFortressAllowed() && isBlockedHost(u.hostname)) {
78
79
  throw new Error(
79
80
  `Fortress base URL host "${u.hostname}" is a private/loopback/link-local address — refusing (SSRF guard). ` +
80
- 'Point WMA_FORTRESS_BASE_URL at your public Fortress endpoint.',
81
+ 'Point WMA_FORTRESS_BASE_URL at your public Fortress endpoint, or set ' +
82
+ 'WMA_FORTRESS_ALLOW_PRIVATE_IPS=1 for a self-hosted Fortress on a private network.',
81
83
  );
82
84
  }
83
85
  return base;
84
86
  }
85
87
 
86
88
  // True if the host is an IP literal in a private/loopback/link-local/ULA range,
87
- // or localhost. Public hostnames and public IPs pass.
88
- function isBlockedHost(hostname) {
89
+ // or localhost. Public hostnames and public IPs pass. Exported so the per-request
90
+ // DNS guard (guardedLookup) can check RESOLVED ips with the exact same logic.
91
+ export function isBlockedHost(hostname) {
89
92
  // Node keeps surrounding brackets on IPv6 hostnames (e.g. "[::1]"); strip them.
90
- const h = hostname.toLowerCase().replace(/^\[/, '').replace(/\]$/, '');
93
+ const h = String(hostname).toLowerCase().replace(/^\[/, '').replace(/\]$/, '');
91
94
  if (h === 'localhost' || h.endsWith('.localhost')) return true;
92
95
 
93
- // IPv6 literal. Normalize IPv4-mapped form.
96
+ // IPv6 literal.
94
97
  if (h.includes(':')) {
95
98
  if (h === '::1' || h === '::') return true; // loopback / unspecified
96
99
  if (h.startsWith('fe8') || h.startsWith('fe9') || h.startsWith('fea') || h.startsWith('feb')) return true; // fe80::/10 link-local
97
100
  if (h.startsWith('fc') || h.startsWith('fd')) return true; // fc00::/7 unique-local
98
- const mapped = h.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/); // IPv4-mapped IPv6
99
- if (mapped) return isBlockedIpv4(mapped[1]);
101
+ // IPv4-mapped IPv6 (::ffff:a.b.c.d). CRITICAL: Node's URL parser normalizes
102
+ // the dotted form to HEX — new URL('https://[::ffff:127.0.0.1]/').hostname
103
+ // is '[::ffff:7f00:1]' — so a regex matching only the dotted form lets
104
+ // 127.0.0.1 / 169.254.169.254 (metadata!) slip past. Decode BOTH forms.
105
+ const mappedDotted = h.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
106
+ if (mappedDotted) return isBlockedIpv4(mappedDotted[1]);
107
+ const mappedHex = h.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
108
+ if (mappedHex) {
109
+ const hi = parseInt(mappedHex[1], 16), lo = parseInt(mappedHex[2], 16);
110
+ return isBlockedIpv4(`${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`);
111
+ }
100
112
  return false;
101
113
  }
102
114
 
@@ -105,6 +117,40 @@ function isBlockedHost(hostname) {
105
117
  return false;
106
118
  }
107
119
 
120
+ // v1.4.11 (Codex P1/P2): per-request DNS guard — closes the DNS-rebinding gap
121
+ // the name-based check can't (a public hostname resolving to a private IP at
122
+ // connect time). Drop-in `lookup` for https.request: it resolves the hostname,
123
+ // rejects the connection if the RESOLVED ip is private/loopback/link-local
124
+ // (same ranges as isBlockedHost, incl. IPv4-mapped IPv6), and otherwise pins
125
+ // the connection to exactly that resolved address. Wire it into every
126
+ // Fortress-bound https.request via `{ lookup: guardedLookup }`.
127
+ export function guardedLookup(hostname, options, callback) {
128
+ const cb = typeof options === 'function' ? options : callback;
129
+ const opts = typeof options === 'function' ? {} : (options || {});
130
+ // Escape hatch for a LEGITIMATE self-hosted Fortress on a private network
131
+ // (hostname → 10.x/192.168.x/etc). Off by default — the secure posture is to
132
+ // refuse private resolutions. Same flag gates assertSafeFortressBase.
133
+ if (privateFortressAllowed()) return dnsLookup(hostname, opts, cb);
134
+ dnsLookup(hostname, opts, (err, address, family) => {
135
+ if (err) return cb(err);
136
+ const list = Array.isArray(address) ? address : [{ address, family }];
137
+ for (const a of list) {
138
+ if (isBlockedHost(a.address)) {
139
+ return cb(new Error(
140
+ `SSRF guard: "${hostname}" resolved to a private/loopback/link-local IP (${a.address}) — refusing the connection. ` +
141
+ 'Set WMA_FORTRESS_ALLOW_PRIVATE_IPS=1 to allow a self-hosted Fortress on a private network.',
142
+ ));
143
+ }
144
+ }
145
+ return cb(null, address, family);
146
+ });
147
+ }
148
+
149
+ // Opt-out for self-hosted Fortress on a private network. Default: not allowed.
150
+ function privateFortressAllowed() {
151
+ return process.env.WMA_FORTRESS_ALLOW_PRIVATE_IPS === '1';
152
+ }
153
+
108
154
  function isBlockedIpv4(ip) {
109
155
  const o = ip.split('.').map(Number);
110
156
  if (o.length !== 4 || o.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return true; // malformed → block
@@ -33,6 +33,7 @@ import { request as httpsRequest } from 'node:https';
33
33
  import { URL } from 'node:url';
34
34
  import { EventEmitter } from 'node:events';
35
35
  import { normalizeSseBuffer } from './sse.js';
36
+ import { guardedLookup } from '../fortress/url.js';
36
37
 
37
38
  const RECONNECT_MIN_MS = 1_000;
38
39
  const RECONNECT_MAX_MS = 60_000;
@@ -109,6 +110,7 @@ export class PolicyStream extends EventEmitter {
109
110
  'connection': 'keep-alive',
110
111
  },
111
112
  rejectUnauthorized: true,
113
+ lookup: guardedLookup, // v1.4.11: DNS-rebinding guard on the Fortress SSE
112
114
  }, (res) => {
113
115
  this._req = req;
114
116
 
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { request as httpsRequest } from 'node:https';
13
13
  import { URL } from 'node:url';
14
- import { fortressEndpoint } from '../../fortress/url.js';
14
+ import { fortressEndpoint, guardedLookup } from '../../fortress/url.js';
15
15
 
16
16
  const DEFAULT_TIMEOUT_MS = 15_000;
17
17
  // v1.1.2 F-17 (P3 Codex audit): cap on the total bytes we'll accumulate
@@ -41,6 +41,10 @@ function httpsJson(method, url, headers, body, timeoutMs = DEFAULT_TIMEOUT_MS) {
41
41
  ...(data ? { 'content-type': 'application/json', 'content-length': data.length } : {}),
42
42
  },
43
43
  rejectUnauthorized: true,
44
+ // v1.4.11 (Codex P1/P2): per-request DNS guard — reject a hostname that
45
+ // resolves to a private/loopback/link-local IP (DNS rebinding), and pin
46
+ // the connection to the checked address.
47
+ lookup: guardedLookup,
44
48
  };
45
49
  const req = httpsRequest(opts, (res) => {
46
50
  const chunks = [];
@@ -87,14 +91,25 @@ function httpsJson(method, url, headers, body, timeoutMs = DEFAULT_TIMEOUT_MS) {
87
91
  * @param {string} [opts.provider] - runtime provider (default 'anthropic-managed')
88
92
  * @returns {Promise<{ ok: true, policies: array, signing_keys: array, fetched_at: string }>}
89
93
  */
90
- // v1.4.9: pure, testable get-policies URL builder. Always carries `provider`
91
- // so a provider-aware Fortress can scope by (provider, native_agent_id) an
92
- // OpenAI agent id is NOT in the `agent_…` shape. Harmless on the older Fortress
93
- // that ignores unknown query params.
94
+ // v1.4.10: pure, testable get-policies URL builder. Always carries `provider`.
95
+ // The AGENT-ID PARAM NAME is provider-dependent and this matters:
96
+ // - anthropic-managed `agent_id` (legacy). Fortress resolves by
97
+ // anthropic_agent_id, which works for ALL Anthropic agents including legacy
98
+ // rows whose native_agent_id was never backfilled.
99
+ // - other providers (openai-agents) → `native_agent_id` (canonical). Fortress
100
+ // resolves by (provider, native_agent_id). This is REQUIRED: an OpenAI id is
101
+ // not in the `agent_…` shape and its anthropic_agent_id is null. If we sent
102
+ // it as `agent_id`, Fortress's `useLegacyLookup` (native absent + agent_id
103
+ // present) forces the anthropic_agent_id lookup → the OpenAI agent isn't
104
+ // found → empty policies → default-allow → enforcement SILENTLY OFF (a
105
+ // fail-OPEN, worse than fail-closed). v1.4.9 had this bug.
94
106
  export function buildGetPoliciesUrl(base, { anthropicAgentId, provider = 'anthropic-managed' } = {}) {
95
107
  let url = fortressEndpoint(base, 'get-policies');
96
108
  const params = [];
97
- if (anthropicAgentId) params.push(`agent_id=${encodeURIComponent(anthropicAgentId)}`);
109
+ if (anthropicAgentId) {
110
+ const key = provider === 'anthropic-managed' ? 'agent_id' : 'native_agent_id';
111
+ params.push(`${key}=${encodeURIComponent(anthropicAgentId)}`);
112
+ }
98
113
  params.push(`provider=${encodeURIComponent(provider)}`);
99
114
  url += (url.includes('?') ? '&' : '?') + params.join('&');
100
115
  return url;
@@ -12,13 +12,17 @@
12
12
  // it — only the leak-y field is omitted.
13
13
 
14
14
  import { createHash } from 'node:crypto';
15
- import { normalizeToolName } from '../anonymizer.js';
15
+ import { normalizeToolName, MIN_SALT_LENGTH } from '../anonymizer.js';
16
16
 
17
17
  // Salted SHA-256 hash with the same 32-char truncation as the anonymizer
18
18
  // and the rest of Shield's decision flow. Returns null for nullish input
19
19
  // or when no salt is configured (fail-safe omission).
20
+ // v1.4.12 (audit residual): a weak/short salt yields a brute-forceable hash, so
21
+ // treat it like NO salt — OMIT the field rather than emit a weak hash. This is
22
+ // the fail-safe / Containment-safe choice and (unlike throwing) can't break the
23
+ // synchronous decision-payload build in shield.js's fireToFortress.
20
24
  function hashWithSaltOpt(value, salt) {
21
- if (value == null || !salt) return null;
25
+ if (value == null || typeof salt !== 'string' || salt.length < MIN_SALT_LENGTH) return null;
22
26
  const s = typeof value === 'string' ? value : JSON.stringify(value);
23
27
  return 'sha256:' + createHash('sha256').update(salt).update(s).digest('hex').slice(0, 32);
24
28
  }