watchmyagents 1.4.9 → 1.4.13
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 +6 -6
- package/docs/adapters/openai-agents-js.md +4 -4
- package/examples/adapters/openai-agents-js-quickstart.ts +1 -1
- package/package.json +1 -1
- package/scripts/fetch-anthropic.js +2 -1
- package/scripts/upload-fortress.js +2 -1
- package/src/anonymizer.js +23 -3
- package/src/fortress/url.js +119 -36
- package/src/shield/policy-stream.js +2 -0
- package/src/shield/sources/fortress.js +21 -6
- package/src/shield/upload.js +6 -2
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/
|
|
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/
|
|
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
|
|
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.
|
|
163
|
-
| OpenAI Agents SDK (Python) | push (separate `watchmyagents-py` package) | — | planned
|
|
164
|
-
| Claude Code (
|
|
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
|
|
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/
|
|
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/
|
|
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
|
|
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/
|
|
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.
|
|
3
|
+
"version": "1.4.13",
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 });
|
package/src/fortress/url.js
CHANGED
|
@@ -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
|
|
@@ -54,15 +55,20 @@ export function resolveFortressBase({ explicitUrl, explicitBase, env = process.e
|
|
|
54
55
|
// host, or a cloud metadata endpoint (169.254.169.254) and exfiltrate the key
|
|
55
56
|
// — the only prior check was `protocol === 'https:'` at the request layer.
|
|
56
57
|
//
|
|
57
|
-
// We require https, reject embedded credentials, and reject IP
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
58
|
+
// We require https, reject embedded credentials, and reject IP hosts that are
|
|
59
|
+
// not global-unicast. v1.4.13 (Codex P2) hardens this into a 3-way classify:
|
|
60
|
+
// - 'blocked' — loopback / link-local+metadata / multicast / reserved /
|
|
61
|
+
// broadcast / CGNAT / TEST-NET / benchmarking / 0/8. NEVER
|
|
62
|
+
// allowed, even with the opt-out flag (metadata exfil + the
|
|
63
|
+
// other non-routable ranges have no legitimate Fortress use).
|
|
64
|
+
// - 'private' — RFC1918 (10/8, 172.16/12, 192.168/16) + IPv6 ULA. Blocked by
|
|
65
|
+
// default; allowed ONLY with WMA_FORTRESS_ALLOW_PRIVATE_IPS=1
|
|
66
|
+
// (a legitimate self-hosted Fortress on a private network).
|
|
67
|
+
// - 'public' — global-unicast / public hostname. Allowed.
|
|
68
|
+
// We deliberately do NOT pin to *.supabase.co because operators self-host. The
|
|
69
|
+
// DNS-rebinding gap (a public hostname resolving to a non-global IP at connect
|
|
70
|
+
// time) is closed by guardedLookup (below) — wired into every Fortress request.
|
|
71
|
+
// Throws (fail-loud) so a misconfigured/hostile endpoint stops the run.
|
|
66
72
|
export function assertSafeFortressBase(base) {
|
|
67
73
|
let u;
|
|
68
74
|
try { u = new URL(base); }
|
|
@@ -74,48 +80,125 @@ export function assertSafeFortressBase(base) {
|
|
|
74
80
|
if (u.username || u.password) {
|
|
75
81
|
throw new Error('Fortress base URL must not embed credentials (user:pass@host).');
|
|
76
82
|
}
|
|
77
|
-
|
|
83
|
+
const cat = classifyHost(u.hostname);
|
|
84
|
+
if (cat === 'blocked') {
|
|
78
85
|
throw new Error(
|
|
79
|
-
`Fortress base URL host "${u.hostname}" is a
|
|
80
|
-
'Point WMA_FORTRESS_BASE_URL at
|
|
86
|
+
`Fortress base URL host "${u.hostname}" is a loopback/link-local/metadata/reserved address — refusing (SSRF guard). ` +
|
|
87
|
+
'This is ALWAYS blocked (the opt-out flag does not apply). Point WMA_FORTRESS_BASE_URL at a public endpoint.',
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
if (cat === 'private' && !privateFortressAllowed()) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`Fortress base URL host "${u.hostname}" is a private-network (RFC1918/ULA) address — refusing (SSRF guard). ` +
|
|
93
|
+
'For a legitimate self-hosted Fortress on a private network, set WMA_FORTRESS_ALLOW_PRIVATE_IPS=1.',
|
|
81
94
|
);
|
|
82
95
|
}
|
|
83
96
|
return base;
|
|
84
97
|
}
|
|
85
98
|
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
|
|
99
|
+
// Classify a host (literal IP or hostname) into 'public' | 'private' | 'blocked'.
|
|
100
|
+
// 'blocked' — never a legitimate Fortress target (always refused).
|
|
101
|
+
// 'private' — RFC1918 / IPv6 ULA (refused unless the opt-out flag is set).
|
|
102
|
+
// 'public' — global-unicast / public hostname (allowed).
|
|
103
|
+
// Used by both the URL validator and the per-request DNS guard so a literal IP
|
|
104
|
+
// and a RESOLVED IP go through the EXACT same policy.
|
|
105
|
+
export function classifyHost(hostname) {
|
|
89
106
|
// Node keeps surrounding brackets on IPv6 hostnames (e.g. "[::1]"); strip them.
|
|
90
|
-
const h = hostname.toLowerCase().replace(/^\[/, '').replace(/\]$/, '');
|
|
91
|
-
if (h === 'localhost' || h.endsWith('.localhost')) return
|
|
107
|
+
const h = String(hostname).toLowerCase().replace(/^\[/, '').replace(/\]$/, '');
|
|
108
|
+
if (h === 'localhost' || h.endsWith('.localhost')) return 'blocked';
|
|
92
109
|
|
|
93
|
-
// IPv6 literal.
|
|
110
|
+
// IPv6 literal.
|
|
94
111
|
if (h.includes(':')) {
|
|
95
|
-
if (h === '::1' || h === '::') return
|
|
96
|
-
if (h.startsWith('fe8') || h.startsWith('fe9') || h.startsWith('fea') || h.startsWith('feb')) return
|
|
97
|
-
if (h.startsWith('
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
112
|
+
if (h === '::1' || h === '::') return 'blocked'; // loopback / unspecified
|
|
113
|
+
if (h.startsWith('fe8') || h.startsWith('fe9') || h.startsWith('fea') || h.startsWith('feb')) return 'blocked'; // fe80::/10 link-local
|
|
114
|
+
if (h.startsWith('ff')) return 'blocked'; // ff00::/8 multicast
|
|
115
|
+
if (h.startsWith('fc') || h.startsWith('fd')) return 'private'; // fc00::/7 ULA (RFC1918-equivalent)
|
|
116
|
+
// IPv4-mapped IPv6 (::ffff:a.b.c.d). CRITICAL: Node's URL parser normalizes
|
|
117
|
+
// the dotted form to HEX — new URL('https://[::ffff:127.0.0.1]/').hostname
|
|
118
|
+
// is '[::ffff:7f00:1]' — so a regex matching only the dotted form lets
|
|
119
|
+
// 127.0.0.1 / 169.254.169.254 (metadata!) slip past. Decode BOTH forms.
|
|
120
|
+
const mappedDotted = h.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
|
121
|
+
if (mappedDotted) return classifyIpv4(mappedDotted[1]);
|
|
122
|
+
const mappedHex = h.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
123
|
+
if (mappedHex) {
|
|
124
|
+
const hi = parseInt(mappedHex[1], 16), lo = parseInt(mappedHex[2], 16);
|
|
125
|
+
return classifyIpv4(`${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`);
|
|
126
|
+
}
|
|
127
|
+
return 'public';
|
|
101
128
|
}
|
|
102
129
|
|
|
103
130
|
// IPv4 literal?
|
|
104
|
-
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(h)) return
|
|
105
|
-
return
|
|
131
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(h)) return classifyIpv4(h);
|
|
132
|
+
return 'public'; // public hostname (DNS rebinding closed by guardedLookup)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// True if a host is refused under the CURRENT policy (flag-aware).
|
|
136
|
+
function hostRefused(hostname) {
|
|
137
|
+
const cat = classifyHost(hostname);
|
|
138
|
+
return cat === 'blocked' || (cat === 'private' && !privateFortressAllowed());
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// v1.4.11 (Codex P1/P2): per-request DNS guard — closes the DNS-rebinding gap
|
|
142
|
+
// the name-based check can't (a public hostname resolving to a private IP at
|
|
143
|
+
// connect time). Drop-in `lookup` for https.request: it resolves the hostname,
|
|
144
|
+
// rejects the connection if the RESOLVED ip is private/loopback/link-local
|
|
145
|
+
// (same ranges as isBlockedHost, incl. IPv4-mapped IPv6), and otherwise pins
|
|
146
|
+
// the connection to exactly that resolved address. Wire it into every
|
|
147
|
+
// Fortress-bound https.request via `{ lookup: guardedLookup }`.
|
|
148
|
+
export function guardedLookup(hostname, options, callback) {
|
|
149
|
+
const cb = typeof options === 'function' ? options : callback;
|
|
150
|
+
const opts = typeof options === 'function' ? {} : (options || {});
|
|
151
|
+
// NOTE: we do NOT early-return when the opt-out flag is set. hostRefused()
|
|
152
|
+
// is flag-aware: it allows RFC1918 ('private') resolutions when the flag is
|
|
153
|
+
// set, but STILL blocks loopback / link-local / metadata / reserved
|
|
154
|
+
// ('blocked') even then — the flag must not open the cloud-metadata vector.
|
|
155
|
+
dnsLookup(hostname, opts, (err, address, family) => {
|
|
156
|
+
if (err) return cb(err);
|
|
157
|
+
const list = Array.isArray(address) ? address : [{ address, family }];
|
|
158
|
+
for (const a of list) {
|
|
159
|
+
if (hostRefused(a.address)) {
|
|
160
|
+
const cat = classifyHost(a.address);
|
|
161
|
+
return cb(new Error(
|
|
162
|
+
`SSRF guard: "${hostname}" resolved to a ${cat === 'blocked' ? 'loopback/link-local/metadata/reserved' : 'private-network'} ` +
|
|
163
|
+
`IP (${a.address}) — refusing the connection.` +
|
|
164
|
+
(cat === 'private' ? ' Set WMA_FORTRESS_ALLOW_PRIVATE_IPS=1 for a self-hosted Fortress on a private network.' : ''),
|
|
165
|
+
));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return cb(null, address, family);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Opt-out for self-hosted Fortress on a private network. Default: not allowed.
|
|
173
|
+
function privateFortressAllowed() {
|
|
174
|
+
return process.env.WMA_FORTRESS_ALLOW_PRIVATE_IPS === '1';
|
|
106
175
|
}
|
|
107
176
|
|
|
108
|
-
|
|
177
|
+
// Classify an IPv4 literal. 'private' = RFC1918 (opt-out-able). Everything
|
|
178
|
+
// non-global-unicast and non-RFC1918 is 'blocked' (never allowed): loopback,
|
|
179
|
+
// link-local/metadata, CGNAT, IETF protocol, TEST-NET-1/2/3, benchmarking,
|
|
180
|
+
// multicast, reserved/future, broadcast, this-network. (v1.4.13 Codex P2.)
|
|
181
|
+
function classifyIpv4(ip) {
|
|
109
182
|
const o = ip.split('.').map(Number);
|
|
110
|
-
if (o.length !== 4 || o.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return
|
|
111
|
-
const [a, b] = o;
|
|
112
|
-
|
|
113
|
-
if (a === 10) return
|
|
114
|
-
if (a === 172 && b >= 16 && b <= 31) return
|
|
115
|
-
if (a === 192 && b === 168) return
|
|
116
|
-
|
|
117
|
-
if (a === 0) return
|
|
118
|
-
return
|
|
183
|
+
if (o.length !== 4 || o.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return 'blocked'; // malformed
|
|
184
|
+
const [a, b, c] = o;
|
|
185
|
+
// RFC1918 private — the only ranges the opt-out flag un-blocks.
|
|
186
|
+
if (a === 10) return 'private'; // 10.0.0.0/8
|
|
187
|
+
if (a === 172 && b >= 16 && b <= 31) return 'private'; // 172.16.0.0/12
|
|
188
|
+
if (a === 192 && b === 168) return 'private'; // 192.168.0.0/16
|
|
189
|
+
// Always blocked (not global-unicast; flag does NOT apply).
|
|
190
|
+
if (a === 0) return 'blocked'; // 0.0.0.0/8 this-network
|
|
191
|
+
if (a === 127) return 'blocked'; // 127.0.0.0/8 loopback
|
|
192
|
+
if (a === 169 && b === 254) return 'blocked'; // 169.254.0.0/16 link-local + cloud metadata
|
|
193
|
+
if (a === 100 && b >= 64 && b <= 127) return 'blocked'; // 100.64.0.0/10 CGNAT
|
|
194
|
+
if (a === 192 && b === 0 && c === 0) return 'blocked'; // 192.0.0.0/24 IETF protocol assignments
|
|
195
|
+
if (a === 192 && b === 0 && c === 2) return 'blocked'; // 192.0.2.0/24 TEST-NET-1
|
|
196
|
+
if (a === 198 && (b === 18 || b === 19)) return 'blocked'; // 198.18.0.0/15 benchmarking
|
|
197
|
+
if (a === 198 && b === 51 && c === 100) return 'blocked'; // 198.51.100.0/24 TEST-NET-2
|
|
198
|
+
if (a === 203 && b === 0 && c === 113) return 'blocked'; // 203.0.113.0/24 TEST-NET-3
|
|
199
|
+
if (a >= 224 && a <= 239) return 'blocked'; // 224.0.0.0/4 multicast
|
|
200
|
+
if (a >= 240) return 'blocked'; // 240.0.0.0/4 reserved + 255.255.255.255 broadcast
|
|
201
|
+
return 'public';
|
|
119
202
|
}
|
|
120
203
|
|
|
121
204
|
function stripTrailingSlash(s) {
|
|
@@ -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.
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
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)
|
|
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;
|
package/src/shield/upload.js
CHANGED
|
@@ -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 ||
|
|
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
|
}
|