watchmyagents 1.4.8 → 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 +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 +54 -8
- package/src/openai-agents.js +1 -0
- package/src/shield/policy-stream.js +2 -0
- package/src/shield/sources/fortress.js +36 -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.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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
99
|
-
|
|
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
|
package/src/openai-agents.js
CHANGED
|
@@ -130,6 +130,7 @@ export function openaiAgents(options = {}) {
|
|
|
130
130
|
apiKey,
|
|
131
131
|
base,
|
|
132
132
|
anthropicAgentId: options.agentId, // scopes get-policies by native agent id
|
|
133
|
+
provider: adapterMeta.provider, // v1.4.9: 'openai-agents' → Fortress scopes by (provider, native_agent_id)
|
|
133
134
|
requireSignedPolicies: options.policies.requireSignedPolicies,
|
|
134
135
|
failMode: options.policies.failMode,
|
|
135
136
|
});
|
|
@@ -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 = [];
|
|
@@ -83,15 +87,36 @@ function httpsJson(method, url, headers, body, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
|
83
87
|
* @param {object} opts
|
|
84
88
|
* @param {string} opts.apiKey - wma_xxx
|
|
85
89
|
* @param {string} opts.base - Fortress base URL (https://x.supabase.co/functions/v1)
|
|
86
|
-
* @param {string} [opts.anthropicAgentId] - optional filter
|
|
90
|
+
* @param {string} [opts.anthropicAgentId] - optional native agent id filter
|
|
91
|
+
* @param {string} [opts.provider] - runtime provider (default 'anthropic-managed')
|
|
87
92
|
* @returns {Promise<{ ok: true, policies: array, signing_keys: array, fetched_at: string }>}
|
|
88
93
|
*/
|
|
89
|
-
|
|
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.
|
|
106
|
+
export function buildGetPoliciesUrl(base, { anthropicAgentId, provider = 'anthropic-managed' } = {}) {
|
|
90
107
|
let url = fortressEndpoint(base, 'get-policies');
|
|
108
|
+
const params = [];
|
|
91
109
|
if (anthropicAgentId) {
|
|
92
|
-
const
|
|
93
|
-
|
|
110
|
+
const key = provider === 'anthropic-managed' ? 'agent_id' : 'native_agent_id';
|
|
111
|
+
params.push(`${key}=${encodeURIComponent(anthropicAgentId)}`);
|
|
94
112
|
}
|
|
113
|
+
params.push(`provider=${encodeURIComponent(provider)}`);
|
|
114
|
+
url += (url.includes('?') ? '&' : '?') + params.join('&');
|
|
115
|
+
return url;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function fetchPolicies({ apiKey, base, anthropicAgentId, provider = 'anthropic-managed' }) {
|
|
119
|
+
const url = buildGetPoliciesUrl(base, { anthropicAgentId, provider });
|
|
95
120
|
const { status, body } = await httpsJson('GET', url, {
|
|
96
121
|
authorization: `Bearer ${apiKey}`,
|
|
97
122
|
accept: 'application/json',
|
|
@@ -195,12 +220,16 @@ const ROOT_PUBLIC_KEY = (() => {
|
|
|
195
220
|
})();
|
|
196
221
|
|
|
197
222
|
export class FortressPolicySource {
|
|
198
|
-
constructor({ apiKey, base, anthropicAgentId, refreshIntervalMs = 5 * 60_000, onError, onRefresh, requireSignedPolicies, failMode }) {
|
|
223
|
+
constructor({ apiKey, base, anthropicAgentId, provider = 'anthropic-managed', refreshIntervalMs = 5 * 60_000, onError, onRefresh, requireSignedPolicies, failMode }) {
|
|
199
224
|
if (!apiKey) throw new Error('FortressPolicySource: apiKey required');
|
|
200
225
|
if (!base) throw new Error('FortressPolicySource: base URL required');
|
|
201
226
|
this.apiKey = apiKey;
|
|
202
227
|
this.base = base;
|
|
203
228
|
this.anthropicAgentId = anthropicAgentId;
|
|
229
|
+
// v1.4.9: runtime provider, sent to get-policies so Fortress scopes by
|
|
230
|
+
// (provider, native_agent_id). Defaults to anthropic-managed (the Anthropic
|
|
231
|
+
// shield path doesn't pass it); the OpenAI factory passes 'openai-agents'.
|
|
232
|
+
this.provider = provider;
|
|
204
233
|
this.refreshIntervalMs = refreshIntervalMs;
|
|
205
234
|
this.onError = onError || (() => {});
|
|
206
235
|
this.onRefresh = onRefresh || (() => {});
|
|
@@ -257,6 +286,7 @@ export class FortressPolicySource {
|
|
|
257
286
|
apiKey: this.apiKey,
|
|
258
287
|
base: this.base,
|
|
259
288
|
anthropicAgentId: this.anthropicAgentId,
|
|
289
|
+
provider: this.provider,
|
|
260
290
|
});
|
|
261
291
|
}
|
|
262
292
|
|
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
|
}
|