watchmyagents 1.4.0 → 1.4.3
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 +13 -4
- package/SECURITY.md +2 -1
- package/package.json +1 -1
- package/scripts/fetch-anthropic.js +71 -11
- package/scripts/service.js +26 -3
- package/scripts/shield.js +145 -67
- package/src/anonymizer.js +5 -2
- package/src/fortress/url.js +77 -3
- package/src/logger.js +8 -0
- package/src/shield/decisions.js +31 -8
- package/src/shield/policy.js +80 -9
- package/src/shield/signature.js +37 -4
- package/src/shield/sources/fortress.js +66 -6
- package/src/shield/upload.js +8 -0
- package/src/sources/anthropic-managed.js +113 -26
- package/src/sources/contract.js +17 -0
- package/src/sources/openai-agents-js.js +76 -16
- package/src/tokens.js +25 -10
- package/src/watch-state.js +56 -0
package/src/fortress/url.js
CHANGED
|
@@ -21,29 +21,103 @@
|
|
|
21
21
|
*/
|
|
22
22
|
export function resolveFortressBase({ explicitUrl, explicitBase, env = process.env } = {}) {
|
|
23
23
|
// 1. Explicit base URL from CLI
|
|
24
|
-
if (explicitBase) return stripTrailingSlash(explicitBase);
|
|
24
|
+
if (explicitBase) return assertSafeFortressBase(stripTrailingSlash(explicitBase));
|
|
25
25
|
|
|
26
26
|
// 2. Env: WMA_FORTRESS_BASE_URL (preferred)
|
|
27
|
-
if (env.WMA_FORTRESS_BASE_URL) return stripTrailingSlash(env.WMA_FORTRESS_BASE_URL);
|
|
27
|
+
if (env.WMA_FORTRESS_BASE_URL) return assertSafeFortressBase(stripTrailingSlash(env.WMA_FORTRESS_BASE_URL));
|
|
28
28
|
|
|
29
29
|
// 3. Legacy: WMA_FORTRESS_URL (full path to ingest-signals)
|
|
30
30
|
const legacy = explicitUrl || env.WMA_FORTRESS_URL;
|
|
31
31
|
if (legacy) {
|
|
32
32
|
// Strip last path segment to get the base
|
|
33
|
+
let derived;
|
|
33
34
|
try {
|
|
34
35
|
const u = new URL(legacy);
|
|
35
36
|
const parts = u.pathname.split('/').filter(Boolean);
|
|
36
37
|
if (parts.length > 0) parts.pop();
|
|
37
38
|
u.pathname = '/' + parts.join('/');
|
|
38
|
-
|
|
39
|
+
derived = stripTrailingSlash(u.toString());
|
|
39
40
|
} catch {
|
|
40
41
|
return null;
|
|
41
42
|
}
|
|
43
|
+
return assertSafeFortressBase(derived);
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
return null;
|
|
45
47
|
}
|
|
46
48
|
|
|
49
|
+
// v1.4.2 F-47 (P1->P2 audit) — SSRF guard on the operator-supplied Fortress
|
|
50
|
+
// base URL. WMA POSTs the customer's Bearer API key + signals/decisions to
|
|
51
|
+
// this URL and PULLS live-enforcement policies from it, so an attacker who can
|
|
52
|
+
// influence the SDK's environment (templated deploy, shared .env, compromised
|
|
53
|
+
// orchestration layer) could previously point it at http://, an internal
|
|
54
|
+
// host, or a cloud metadata endpoint (169.254.169.254) and exfiltrate the key
|
|
55
|
+
// — the only prior check was `protocol === 'https:'` at the request layer.
|
|
56
|
+
//
|
|
57
|
+
// We require https, reject embedded credentials, and reject IP-LITERAL hosts
|
|
58
|
+
// in private / loopback / link-local / ULA ranges (the direct metadata-
|
|
59
|
+
// endpoint vector). We deliberately do NOT pin to *.supabase.co because
|
|
60
|
+
// operators self-host Fortress; any PUBLIC host is allowed. Residual:
|
|
61
|
+
// DNS-rebinding (a public hostname resolving to a private IP) is not caught
|
|
62
|
+
// here — that needs per-request resolved-IP pinning, out of scope for a sync
|
|
63
|
+
// URL validator. Throws (fail-loud) on a bad URL rather than returning null,
|
|
64
|
+
// so a misconfigured/hostile endpoint stops the run instead of silently
|
|
65
|
+
// disabling upload/enforcement.
|
|
66
|
+
export function assertSafeFortressBase(base) {
|
|
67
|
+
let u;
|
|
68
|
+
try { u = new URL(base); }
|
|
69
|
+
catch { throw new Error(`Fortress base URL is not a valid URL: ${base}`); }
|
|
70
|
+
|
|
71
|
+
if (u.protocol !== 'https:') {
|
|
72
|
+
throw new Error(`Fortress base URL must use https:// (got ${u.protocol}//). Refusing to send credentials over ${u.protocol}//.`);
|
|
73
|
+
}
|
|
74
|
+
if (u.username || u.password) {
|
|
75
|
+
throw new Error('Fortress base URL must not embed credentials (user:pass@host).');
|
|
76
|
+
}
|
|
77
|
+
if (isBlockedHost(u.hostname)) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`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
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return base;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 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
|
+
// 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 true;
|
|
92
|
+
|
|
93
|
+
// IPv6 literal. Normalize IPv4-mapped form.
|
|
94
|
+
if (h.includes(':')) {
|
|
95
|
+
if (h === '::1' || h === '::') return true; // loopback / unspecified
|
|
96
|
+
if (h.startsWith('fe8') || h.startsWith('fe9') || h.startsWith('fea') || h.startsWith('feb')) return true; // fe80::/10 link-local
|
|
97
|
+
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]);
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// IPv4 literal?
|
|
104
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(h)) return isBlockedIpv4(h);
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isBlockedIpv4(ip) {
|
|
109
|
+
const o = ip.split('.').map(Number);
|
|
110
|
+
if (o.length !== 4 || o.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return true; // malformed → block
|
|
111
|
+
const [a, b] = o;
|
|
112
|
+
if (a === 127) return true; // 127.0.0.0/8 loopback
|
|
113
|
+
if (a === 10) return true; // 10.0.0.0/8 private
|
|
114
|
+
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 private
|
|
115
|
+
if (a === 192 && b === 168) return true; // 192.168.0.0/16 private
|
|
116
|
+
if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local (cloud metadata)
|
|
117
|
+
if (a === 0) return true; // 0.0.0.0/8
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
|
|
47
121
|
function stripTrailingSlash(s) {
|
|
48
122
|
return s.endsWith('/') ? s.slice(0, -1) : s;
|
|
49
123
|
}
|
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
|
package/src/shield/decisions.js
CHANGED
|
@@ -52,6 +52,16 @@ export class DecisionLogger {
|
|
|
52
52
|
message,
|
|
53
53
|
decidedInMs,
|
|
54
54
|
mode, // v1.1.3 Phase 1.D: 'enforce' | 'shadow' (default 'enforce' if absent)
|
|
55
|
+
// v1.4.2 F-44 (P1 audit): the ACTUAL outcome of the enforcement API call,
|
|
56
|
+
// captured by the caller AFTER it attempts confirmDeny / interruptSession.
|
|
57
|
+
// true → the block/interrupt landed (confirmed delivered)
|
|
58
|
+
// false → the call FAILED after retries; the violating action was
|
|
59
|
+
// NOT blocked on the wire (silent fail-open if we lied here)
|
|
60
|
+
// undefined → not applicable (allow / shadow / non-enforcing caller).
|
|
61
|
+
// Pre-F-44 the row claimed "enforced" purely from mode+decision, before
|
|
62
|
+
// (and regardless of) the API call — so the tamper-evident audit chain
|
|
63
|
+
// could assert a block that never happened.
|
|
64
|
+
enforcementDelivered,
|
|
55
65
|
}) {
|
|
56
66
|
// In shadow mode the decision is computed and logged but NOT enforced.
|
|
57
67
|
// status must reflect what actually happened on the wire: shadow + deny
|
|
@@ -61,25 +71,38 @@ export class DecisionLogger {
|
|
|
61
71
|
const effectiveMode = mode || 'enforce';
|
|
62
72
|
const enforced = effectiveMode === 'enforce'
|
|
63
73
|
&& (decision === 'deny' || decision === 'interrupt');
|
|
74
|
+
// F-44: enforcement was ATTEMPTED but FAILED → the action was NOT blocked.
|
|
75
|
+
// Surface that honestly instead of recording a clean block.
|
|
76
|
+
const failedDelivery = enforced && enforcementDelivered === false;
|
|
77
|
+
|
|
78
|
+
const output = {
|
|
79
|
+
decision,
|
|
80
|
+
rule_id: ruleId,
|
|
81
|
+
rule_name: ruleName,
|
|
82
|
+
message,
|
|
83
|
+
mode: effectiveMode,
|
|
84
|
+
};
|
|
85
|
+
// Only add the field when enforcement was actually attempted, so allow /
|
|
86
|
+
// shadow / pre-F-44 callers keep their exact record shape.
|
|
87
|
+
if (enforcementDelivered !== undefined) {
|
|
88
|
+
output.enforcement_delivered = enforcementDelivered;
|
|
89
|
+
}
|
|
90
|
+
|
|
64
91
|
return this._logger.write({
|
|
65
92
|
action_type: 'shield_decision',
|
|
66
93
|
provider: this._provider,
|
|
67
94
|
tool_name: sourceEvent?.name || sourceEvent?.tool_name || null,
|
|
68
95
|
status: enforced ? 'error' : 'ok',
|
|
69
|
-
error:
|
|
96
|
+
error: failedDelivery
|
|
97
|
+
? `ENFORCEMENT FAILED (action NOT blocked): ${message || ruleName || 'policy violation'}`
|
|
98
|
+
: (enforced ? message : null),
|
|
70
99
|
duration_ms: decidedInMs ?? null,
|
|
71
100
|
input: {
|
|
72
101
|
source_event_id: sourceEvent?.id || null,
|
|
73
102
|
source_event_type: sourceEvent?.type || null,
|
|
74
103
|
tool_input: sourceEvent?.input ?? null,
|
|
75
104
|
},
|
|
76
|
-
output
|
|
77
|
-
decision,
|
|
78
|
-
rule_id: ruleId,
|
|
79
|
-
rule_name: ruleName,
|
|
80
|
-
message,
|
|
81
|
-
mode: effectiveMode,
|
|
82
|
-
},
|
|
105
|
+
output,
|
|
83
106
|
});
|
|
84
107
|
}
|
|
85
108
|
}
|
package/src/shield/policy.js
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
// Field paths use dotted notation (`input.url`, `output.content.text`).
|
|
25
25
|
|
|
26
26
|
import { readFile } from 'node:fs/promises';
|
|
27
|
+
import { TOOL_USE_FAMILY } from '../sources/contract.js';
|
|
27
28
|
|
|
28
29
|
export async function loadPolicies(path) {
|
|
29
30
|
const raw = await readFile(path, 'utf8');
|
|
@@ -165,6 +166,17 @@ function safeRegexTest(re, value) {
|
|
|
165
166
|
return re.test(s);
|
|
166
167
|
}
|
|
167
168
|
|
|
169
|
+
// v1.4.3 F-40: coerce ONLY a clean decimal-number string to a number, for the
|
|
170
|
+
// ordered numeric comparators. Anything else (real number → passthrough;
|
|
171
|
+
// hex/exponent/whitespace/Infinity/non-string → returned as-is so the caller's
|
|
172
|
+
// Number.isFinite check fails closed). Never used for equality or length_*.
|
|
173
|
+
const CLEAN_NUMERIC_STRING = /^-?\d+(\.\d+)?$/;
|
|
174
|
+
function asOrderedNumber(value) {
|
|
175
|
+
if (typeof value === 'number') return value;
|
|
176
|
+
if (typeof value === 'string' && CLEAN_NUMERIC_STRING.test(value)) return Number(value);
|
|
177
|
+
return value;
|
|
178
|
+
}
|
|
179
|
+
|
|
168
180
|
function matchValue(value, condition) {
|
|
169
181
|
// Literal scalar match
|
|
170
182
|
if (condition === null || typeof condition !== 'object') {
|
|
@@ -185,20 +197,34 @@ function matchValue(value, condition) {
|
|
|
185
197
|
return condition._regex_any.some(r => safeRegexTest(r, value));
|
|
186
198
|
}
|
|
187
199
|
// v1.2.0 — DSL extensions for ABAC / parameter validation.
|
|
188
|
-
// Numeric comparators
|
|
189
|
-
//
|
|
190
|
-
//
|
|
200
|
+
// Numeric comparators reject a non-finite CONDITION operand (malformed
|
|
201
|
+
// policy → fail-closed).
|
|
202
|
+
//
|
|
203
|
+
// v1.4.3 F-40 (P2 audit) — the VALUE is run through asOrderedNumber():
|
|
204
|
+
// a real number passes through; a CLEAN decimal-number STRING (e.g. a tool
|
|
205
|
+
// that serializes `bytes` as "1500000") is coerced to its number. Why: a
|
|
206
|
+
// deny threshold like { gt: 1000000 } previously did NOT match a stringified
|
|
207
|
+
// value, so the rule silently no-matched and the action fell through to
|
|
208
|
+
// default-allow — a fail-OPEN on exfil-size / rate thresholds. Coercion is
|
|
209
|
+
// deliberately NARROW: only /^-?\d+(\.\d+)?$/ (no hex, exponent, whitespace,
|
|
210
|
+
// Infinity/NaN). This applies ONLY to the ORDERED comparators below.
|
|
211
|
+
// EQUALITY (literal / in / not_in) stays strict — "3" still never === 3 —
|
|
212
|
+
// and length_* (further down) still measures the raw string/array length.
|
|
191
213
|
if (Number.isFinite(condition.gt)) {
|
|
192
|
-
|
|
214
|
+
const v = asOrderedNumber(value);
|
|
215
|
+
return Number.isFinite(v) && v > condition.gt;
|
|
193
216
|
}
|
|
194
217
|
if (Number.isFinite(condition.gte)) {
|
|
195
|
-
|
|
218
|
+
const v = asOrderedNumber(value);
|
|
219
|
+
return Number.isFinite(v) && v >= condition.gte;
|
|
196
220
|
}
|
|
197
221
|
if (Number.isFinite(condition.lt)) {
|
|
198
|
-
|
|
222
|
+
const v = asOrderedNumber(value);
|
|
223
|
+
return Number.isFinite(v) && v < condition.lt;
|
|
199
224
|
}
|
|
200
225
|
if (Number.isFinite(condition.lte)) {
|
|
201
|
-
|
|
226
|
+
const v = asOrderedNumber(value);
|
|
227
|
+
return Number.isFinite(v) && v <= condition.lte;
|
|
202
228
|
}
|
|
203
229
|
// in_range: tuple [min, max] inclusive both sides. An inverted tuple
|
|
204
230
|
// (min > max) fails-closed — most likely an operator typo, never a
|
|
@@ -206,7 +232,8 @@ function matchValue(value, condition) {
|
|
|
206
232
|
if (Array.isArray(condition.in_range) && condition.in_range.length === 2) {
|
|
207
233
|
const [min, max] = condition.in_range;
|
|
208
234
|
if (!Number.isFinite(min) || !Number.isFinite(max) || min > max) return false;
|
|
209
|
-
|
|
235
|
+
const v = asOrderedNumber(value);
|
|
236
|
+
return Number.isFinite(v) && v >= min && v <= max;
|
|
210
237
|
}
|
|
211
238
|
// length_* operators apply to strings or arrays. Anything else (object,
|
|
212
239
|
// number, null) fails-closed — length is meaningless there.
|
|
@@ -247,11 +274,55 @@ export function matchesPolicy(event, policy, ctx = {}) {
|
|
|
247
274
|
const value = field.startsWith('ctx.')
|
|
248
275
|
? getNested(ctx, field.slice(4))
|
|
249
276
|
: getNested(event, field);
|
|
250
|
-
|
|
277
|
+
// v1.4.2 F-38 (P0 audit): the action_type field gets tool-family
|
|
278
|
+
// expansion so a rule keyed on the generic `tool_use` catches MCP +
|
|
279
|
+
// custom tool calls too. Every other field uses plain matchValue.
|
|
280
|
+
const matched = field === 'action_type'
|
|
281
|
+
? matchActionType(value, condition)
|
|
282
|
+
: matchValue(value, condition);
|
|
283
|
+
if (!matched) return false;
|
|
251
284
|
}
|
|
252
285
|
return true;
|
|
253
286
|
}
|
|
254
287
|
|
|
288
|
+
// v1.4.2 F-38 (P0 audit) — action_type matching with tool-family expansion.
|
|
289
|
+
//
|
|
290
|
+
// Adapters emit three distinct tool-invocation action_types: `tool_use`
|
|
291
|
+
// (provider built-ins), `mcp_tool_use` (MCP servers), `custom_tool_use`
|
|
292
|
+
// (customer-wired tools). A policy that targets the GENERIC `tool_use` must
|
|
293
|
+
// match all three — otherwise a deny/allowlist rule silently misses MCP and
|
|
294
|
+
// custom tool calls (e.g. the Deep Researcher "only web_search/web_fetch,
|
|
295
|
+
// deny everything else" containment rule had a hole exactly there). The OpenAI
|
|
296
|
+
// adapter emits `custom_tool_use` for EVERY tool, so without this a
|
|
297
|
+
// `tool_use`-keyed rule matched nothing on that runtime at all.
|
|
298
|
+
//
|
|
299
|
+
// Expansion is one-directional and conservative: only the generic `tool_use`
|
|
300
|
+
// token expands to the family. A policy that names a SPECIFIC member
|
|
301
|
+
// (`mcp_tool_use` / `custom_tool_use`) stays an exact match, so an operator
|
|
302
|
+
// who deliberately wants to target one surface still can. Non-set conditions
|
|
303
|
+
// on action_type (regex, numeric — unusual but legal) fall back to matchValue.
|
|
304
|
+
function expandActionTypeTargets(target) {
|
|
305
|
+
return target === 'tool_use' ? TOOL_USE_FAMILY : [target];
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function matchActionType(value, condition) {
|
|
309
|
+
if (condition === null || typeof condition !== 'object') {
|
|
310
|
+
return expandActionTypeTargets(condition).includes(value);
|
|
311
|
+
}
|
|
312
|
+
if (Array.isArray(condition)) {
|
|
313
|
+
return condition.flatMap(expandActionTypeTargets).includes(value);
|
|
314
|
+
}
|
|
315
|
+
if (condition.in !== undefined) {
|
|
316
|
+
return condition.in.flatMap(expandActionTypeTargets).includes(value);
|
|
317
|
+
}
|
|
318
|
+
if (condition.not_in !== undefined) {
|
|
319
|
+
return !condition.not_in.flatMap(expandActionTypeTargets).includes(value);
|
|
320
|
+
}
|
|
321
|
+
// regex / numeric / unknown shapes on action_type are unusual but legal —
|
|
322
|
+
// defer to the generic matcher (which fails-closed on unknown shapes).
|
|
323
|
+
return matchValue(value, condition);
|
|
324
|
+
}
|
|
325
|
+
|
|
255
326
|
// First-match-wins evaluation. Returns the policy decision and metadata.
|
|
256
327
|
// v1.2.0 — accepts an optional `ctx` for runtime-computed attributes;
|
|
257
328
|
// see matchesPolicy() above. Omitting ctx preserves v1.1.x behavior.
|
package/src/shield/signature.js
CHANGED
|
@@ -50,14 +50,47 @@ import { createPublicKey, verify as cryptoVerify } from 'node:crypto';
|
|
|
50
50
|
|
|
51
51
|
const POLICY_SIGNED_FIELDS = ['rule_id', 'match', 'action', 'message', 'priority', 'mode'];
|
|
52
52
|
|
|
53
|
-
// Recursive deterministic JSON: sorted keys at every depth, arrays kept in
|
|
53
|
+
// Recursive deterministic JSON: sorted keys at every depth, arrays kept in
|
|
54
|
+
// order. This MUST mirror JSON.stringify's value semantics exactly — the only
|
|
55
|
+
// intentional difference is key sorting for determinism. The reason is
|
|
56
|
+
// load-bearing: the bytes we hash here have to equal the bytes that land on
|
|
57
|
+
// disk after JSON.stringify(record), because the verifier re-reads the disk
|
|
58
|
+
// record (already JSON round-tripped) and re-canonicalizes it.
|
|
59
|
+
//
|
|
60
|
+
// v1.4.2 F-39 (P0 audit) — the previous version emitted the literal token
|
|
61
|
+
// `undefined` for object keys / array elements whose value was `undefined`
|
|
62
|
+
// (or a function / symbol). JSON.stringify DROPS such object keys and renders
|
|
63
|
+
// such array elements as `null`. So a record like { tool_input: { max_uses:
|
|
64
|
+
// undefined } } hashed to `{"tool_input":{"max_uses":undefined}}` at write
|
|
65
|
+
// time but, after JSON.stringify dropped `max_uses` on disk, re-canonicalized
|
|
66
|
+
// to `{"tool_input":{}}` at verify time — chain_hash mismatch on an UNTAMPERED
|
|
67
|
+
// record. That broke the tamper-evidence guarantee (false alarms masking real
|
|
68
|
+
// tampering) and corrupted the Ed25519 signing domain the same way. The fix
|
|
69
|
+
// makes canonicalize JSON-faithful; for JSON-clean data it is byte-identical
|
|
70
|
+
// to the old behavior, so existing valid chains keep verifying.
|
|
54
71
|
export function canonicalize(value) {
|
|
55
|
-
if (value === null || typeof value !== 'object')
|
|
72
|
+
if (value === null || typeof value !== 'object') {
|
|
73
|
+
// JSON.stringify(undefined | function | symbol) returns `undefined`
|
|
74
|
+
// (the JS value, not a string). Default those to `null` so we never
|
|
75
|
+
// concatenate the literal token `undefined` into the hashed string.
|
|
76
|
+
const enc = JSON.stringify(value);
|
|
77
|
+
return enc === undefined ? 'null' : enc;
|
|
78
|
+
}
|
|
56
79
|
if (Array.isArray(value)) {
|
|
57
|
-
|
|
80
|
+
// JSON renders undefined / function / symbol array elements as null —
|
|
81
|
+
// the primitive branch above already returns 'null' for them.
|
|
82
|
+
return '[' + value.map((v) => canonicalize(v)).join(',') + ']';
|
|
58
83
|
}
|
|
84
|
+
// JSON.stringify omits object keys whose value is undefined / a function /
|
|
85
|
+
// a symbol. Mirror that, or the hashed string carries a key that vanishes
|
|
86
|
+
// on disk (the F-39 break).
|
|
59
87
|
const keys = Object.keys(value).sort();
|
|
60
|
-
const pairs =
|
|
88
|
+
const pairs = [];
|
|
89
|
+
for (const k of keys) {
|
|
90
|
+
const v = value[k];
|
|
91
|
+
if (v === undefined || typeof v === 'function' || typeof v === 'symbol') continue;
|
|
92
|
+
pairs.push(JSON.stringify(k) + ':' + canonicalize(v));
|
|
93
|
+
}
|
|
61
94
|
return '{' + pairs.join(',') + '}';
|
|
62
95
|
}
|
|
63
96
|
|
|
@@ -167,6 +167,18 @@ function strictModeFromEnv() {
|
|
|
167
167
|
return true;
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
// v1.4.2 F-48 (P2 audit) — what to do when a SUCCESSFUL refresh yields zero
|
|
171
|
+
// usable policies even though Fortress SENT some (i.e. every policy was
|
|
172
|
+
// dropped by signature verification or compile validation). 'closed' (the
|
|
173
|
+
// default, the right stance for a security control) refuses to install an
|
|
174
|
+
// empty allow-everything ruleset; 'open' preserves the pre-F-48 behavior
|
|
175
|
+
// (install default-allow) as an explicit opt-out.
|
|
176
|
+
function failModeFromEnv() {
|
|
177
|
+
const v = process.env.WMA_SHIELD_FAIL_MODE;
|
|
178
|
+
if (v === 'open') return 'open';
|
|
179
|
+
return 'closed'; // unset / anything else → fail-closed
|
|
180
|
+
}
|
|
181
|
+
|
|
170
182
|
// Parse the embedded root pubkey ONCE at module load. If the file still
|
|
171
183
|
// carries the placeholder, we DO NOT throw — but every refresh logs a
|
|
172
184
|
// loud reminder so an unattended deploy can't silently trust a key whose
|
|
@@ -183,7 +195,7 @@ const ROOT_PUBLIC_KEY = (() => {
|
|
|
183
195
|
})();
|
|
184
196
|
|
|
185
197
|
export class FortressPolicySource {
|
|
186
|
-
constructor({ apiKey, base, anthropicAgentId, refreshIntervalMs = 5 * 60_000, onError, onRefresh, requireSignedPolicies }) {
|
|
198
|
+
constructor({ apiKey, base, anthropicAgentId, refreshIntervalMs = 5 * 60_000, onError, onRefresh, requireSignedPolicies, failMode }) {
|
|
187
199
|
if (!apiKey) throw new Error('FortressPolicySource: apiKey required');
|
|
188
200
|
if (!base) throw new Error('FortressPolicySource: base URL required');
|
|
189
201
|
this.apiKey = apiKey;
|
|
@@ -196,12 +208,18 @@ export class FortressPolicySource {
|
|
|
196
208
|
this.lastFetchedAt = null;
|
|
197
209
|
this._timer = null;
|
|
198
210
|
this._aborted = false;
|
|
211
|
+
// v1.4.2 F-48: tracks whether this.ruleset came from a non-degraded
|
|
212
|
+
// refresh (so a later all-dropped refresh can retain last-known-good
|
|
213
|
+
// instead of wiping it) vs. is still the constructor's default-allow.
|
|
214
|
+
this._installedFromRefresh = false;
|
|
199
215
|
// v1.1.5: per-instance override of the env var. Tests use this to
|
|
200
216
|
// exercise both modes without touching process.env. If neither the
|
|
201
217
|
// constructor option nor the env var is set, strict mode wins.
|
|
202
218
|
this.requireSignedPolicies = requireSignedPolicies != null
|
|
203
219
|
? !!requireSignedPolicies
|
|
204
220
|
: strictModeFromEnv();
|
|
221
|
+
// v1.4.2 F-48: 'closed' (default) | 'open'. Per-instance override for tests.
|
|
222
|
+
this.failMode = failMode != null ? failMode : failModeFromEnv();
|
|
205
223
|
}
|
|
206
224
|
|
|
207
225
|
/** Initial fetch — fails loud if it can't reach Fortress at startup. */
|
|
@@ -232,14 +250,20 @@ export class FortressPolicySource {
|
|
|
232
250
|
return this._refresh();
|
|
233
251
|
}
|
|
234
252
|
|
|
253
|
+
// v1.4.2 F-48: injectable fetch seam (tests override this to drive _refresh
|
|
254
|
+
// hermetically). Production hits the real Fortress endpoint.
|
|
255
|
+
async _fetchPolicies() {
|
|
256
|
+
return fetchPolicies({
|
|
257
|
+
apiKey: this.apiKey,
|
|
258
|
+
base: this.base,
|
|
259
|
+
anthropicAgentId: this.anthropicAgentId,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
235
263
|
async _refresh({ initial = false } = {}) {
|
|
236
264
|
if (this._aborted) return;
|
|
237
265
|
try {
|
|
238
|
-
const { policies, signing_keys, fetched_at } = await
|
|
239
|
-
apiKey: this.apiKey,
|
|
240
|
-
base: this.base,
|
|
241
|
-
anthropicAgentId: this.anthropicAgentId,
|
|
242
|
-
});
|
|
266
|
+
const { policies, signing_keys, fetched_at } = await this._fetchPolicies();
|
|
243
267
|
|
|
244
268
|
// v1.1.5 Phase 1.5 — verify the chain-of-trust BEFORE any other
|
|
245
269
|
// processing. We must verify on the raw JSON shape sent by Fortress,
|
|
@@ -272,11 +296,47 @@ export class FortressPolicySource {
|
|
|
272
296
|
// priorities) keep their relative order via the stable sort
|
|
273
297
|
// guarantee in V8 — predictable behavior.
|
|
274
298
|
compiled.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
|
299
|
+
|
|
300
|
+
// v1.4.2 F-48 (P2 audit): FAIL-CLOSED on a "successful" refresh that
|
|
301
|
+
// dropped EVERYTHING. If Fortress SENT policies (policies.length > 0)
|
|
302
|
+
// but none survived verification/compile (compiled.length === 0), an
|
|
303
|
+
// on-path attacker or a broken signing pipeline has effectively
|
|
304
|
+
// disarmed Shield — installing the empty { default: allow } ruleset
|
|
305
|
+
// here would silently allow every action while the daemon looks
|
|
306
|
+
// healthy. We distinguish this from a LEGITIMATELY empty response
|
|
307
|
+
// (operator cleared all policies → policies.length === 0 → install
|
|
308
|
+
// allow as intended).
|
|
309
|
+
const allDropped = policies.length > 0 && compiled.length === 0;
|
|
310
|
+
if (allDropped && this.failMode === 'closed') {
|
|
311
|
+
this.lastFetchedAt = fetched_at;
|
|
312
|
+
if (this._installedFromRefresh) {
|
|
313
|
+
// We have a last-known-good ruleset → KEEP it; do not overwrite
|
|
314
|
+
// with allow-everything. Protection continues on the prior rules.
|
|
315
|
+
this.onError(new Error(
|
|
316
|
+
`Fortress refresh: all ${policies.length} policies failed verification/compile — ` +
|
|
317
|
+
'FAIL-CLOSED, retaining last-known-good ruleset (set WMA_SHIELD_FAIL_MODE=open to override).',
|
|
318
|
+
));
|
|
319
|
+
this.onRefresh({ policies: this.ruleset.policies, fetched_at, initial, failClosed: true });
|
|
320
|
+
} else {
|
|
321
|
+
// No prior good ruleset (e.g. initial load) → cannot fall back to
|
|
322
|
+
// allow-everything for a security control. Install deny-by-default
|
|
323
|
+
// so unmatched actions are denied rather than waved through.
|
|
324
|
+
this.ruleset = { version: 1, policies: [], default: { action: 'deny' } };
|
|
325
|
+
this.onError(new Error(
|
|
326
|
+
`Fortress refresh: all ${policies.length} policies failed verification/compile and there is no ` +
|
|
327
|
+
'prior ruleset — FAIL-CLOSED to deny-by-default (set WMA_SHIELD_FAIL_MODE=open to override).',
|
|
328
|
+
));
|
|
329
|
+
this.onRefresh({ policies: [], fetched_at, initial, failClosed: true });
|
|
330
|
+
}
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
275
334
|
this.ruleset = {
|
|
276
335
|
version: 1,
|
|
277
336
|
policies: compiled,
|
|
278
337
|
default: { action: 'allow' },
|
|
279
338
|
};
|
|
339
|
+
this._installedFromRefresh = true;
|
|
280
340
|
this.lastFetchedAt = fetched_at;
|
|
281
341
|
this.onRefresh({ policies: compiled, fetched_at, initial });
|
|
282
342
|
} catch (e) {
|
package/src/shield/upload.js
CHANGED
|
@@ -57,6 +57,12 @@ function pickInputForHash(input) {
|
|
|
57
57
|
export function buildFortressDecisionPayload({
|
|
58
58
|
agentId, sessionId, rawEvent, normalized, result, decidedInMs,
|
|
59
59
|
signalsSalt, decidedAtIso,
|
|
60
|
+
// v1.4.2 F-44 (P1 audit): the real enforcement outcome (true delivered /
|
|
61
|
+
// false failed / undefined n/a). Lets the Fortress dashboard distinguish a
|
|
62
|
+
// confirmed block from one whose API call failed — i.e. surface degraded
|
|
63
|
+
// enforcement instead of trusting the computed verdict. Boolean only; no
|
|
64
|
+
// raw content, so Containment is unaffected.
|
|
65
|
+
enforcementDelivered,
|
|
60
66
|
}) {
|
|
61
67
|
// F-19: vendor built-ins survive even without a salt (allowlist short-circuit);
|
|
62
68
|
// custom tool names throw without a salt — we catch and drop the field.
|
|
@@ -85,5 +91,7 @@ export function buildFortressDecisionPayload({
|
|
|
85
91
|
// v1.1.3 Phase 1.D: mode threading so Fortress can store and surface
|
|
86
92
|
// shadow-vs-enforce in the Reports timeline.
|
|
87
93
|
mode: result.mode || undefined,
|
|
94
|
+
// v1.4.2 F-44: real enforcement outcome (omitted when not applicable).
|
|
95
|
+
enforcement_delivered: enforcementDelivered === undefined ? undefined : enforcementDelivered,
|
|
88
96
|
};
|
|
89
97
|
}
|