watchmyagents 1.1.3 → 1.1.5
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/package.json +1 -1
- package/scripts/shield.js +17 -30
- package/src/shield/policy-stream.js +10 -1
- package/src/shield/policy.js +49 -9
- package/src/shield/root-key.js +49 -0
- package/src/shield/signature.js +259 -0
- package/src/shield/sources/fortress.js +110 -9
- package/src/shield/sse.js +48 -0
- package/src/shield/stream.js +11 -2
- package/src/shield/upload.js +89 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "watchmyagents",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.5",
|
|
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": [
|
package/scripts/shield.js
CHANGED
|
@@ -25,7 +25,6 @@
|
|
|
25
25
|
// ANTHROPIC_API_KEY env var is used if --api-key is omitted.
|
|
26
26
|
|
|
27
27
|
import { resolve } from 'node:path';
|
|
28
|
-
import { createHash } from 'node:crypto';
|
|
29
28
|
import { streamWithReconnect } from '../src/shield/stream.js';
|
|
30
29
|
import { loadPolicies, evaluate } from '../src/shield/policy.js';
|
|
31
30
|
import {
|
|
@@ -39,6 +38,12 @@ import { FortressPolicySource, postDecision } from '../src/shield/sources/fortre
|
|
|
39
38
|
import { resolveFortressBase, fortressEndpoint } from '../src/fortress/url.js';
|
|
40
39
|
import { PolicyStream } from '../src/shield/policy-stream.js';
|
|
41
40
|
import { isValidAgentId, isValidSessionId } from '../src/validate.js';
|
|
41
|
+
// v1.1.4 F-19 (P1 Codex audit): all egress to Fortress now flows through
|
|
42
|
+
// buildFortressDecisionPayload, which normalizes tool_name via the
|
|
43
|
+
// anonymizer's allowlist + salted-hash scheme and drops anything it can't
|
|
44
|
+
// safely normalize. Keeps Shield's payload aligned with the README
|
|
45
|
+
// promise that decisions ship fingerprints, not raw values.
|
|
46
|
+
import { buildFortressDecisionPayload } from '../src/shield/upload.js';
|
|
42
47
|
|
|
43
48
|
function parseArgs(argv) {
|
|
44
49
|
const out = {};
|
|
@@ -133,38 +138,20 @@ async function runSessionWorker({ sessionId, ctx }) {
|
|
|
133
138
|
// refreshes from Fortress (every 5 min) take effect without restart.
|
|
134
139
|
sinfo(sessionId, `attached (${mode} mode)`);
|
|
135
140
|
|
|
136
|
-
// Helper: hash an IoC value with the customer salt (same one used by
|
|
137
|
-
// anonymizer for signals → correlates decisions to signals in Fortress).
|
|
138
|
-
// Returns null if no salt is configured (decisions still upload, just
|
|
139
|
-
// without input_hash).
|
|
140
|
-
const hashIoc = (value) => {
|
|
141
|
-
if (!signalsSalt || value == null) return null;
|
|
142
|
-
const s = typeof value === 'string' ? value : JSON.stringify(value);
|
|
143
|
-
return 'sha256:' + createHash('sha256').update(signalsSalt).update(s).digest('hex').slice(0, 32);
|
|
144
|
-
};
|
|
145
|
-
|
|
146
141
|
// Helper: assemble + fire the decision push to Fortress (fire-and-forget).
|
|
142
|
+
// v1.1.4 F-19 (P1 Codex audit): delegates payload construction to the
|
|
143
|
+
// pure helper in src/shield/upload.js so the egress-side containment
|
|
144
|
+
// logic (tool_name allowlist + salted hashing) is unit-tested in
|
|
145
|
+
// isolation. The helper drops any field it cannot safely normalize
|
|
146
|
+
// (custom tool without salt, missing salt for hashes) rather than
|
|
147
|
+
// passing the raw value through.
|
|
147
148
|
const fireToFortress = (rawEvent, normalized, result, decidedInMs) => {
|
|
148
149
|
if (!pushDecisionToFortress) return;
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
pushDecisionToFortress({
|
|
156
|
-
anthropic_agent_id: agentId,
|
|
157
|
-
decision: result.decision,
|
|
158
|
-
rule_id: result.rule_id || undefined,
|
|
159
|
-
session_hash: hashIoc(sessionId) || undefined,
|
|
160
|
-
event_id_hash: hashIoc(rawEvent?.id) || undefined,
|
|
161
|
-
input_hash: hashIoc(inputForHash) || undefined,
|
|
162
|
-
action_type: normalized?.action_type || undefined,
|
|
163
|
-
tool_name: normalized?.tool_name || undefined,
|
|
164
|
-
message: result.message || result.rule_name || undefined,
|
|
165
|
-
decided_at: new Date().toISOString(),
|
|
166
|
-
decided_in_ms: decidedInMs,
|
|
167
|
-
}).catch(() => undefined);
|
|
150
|
+
const payload = buildFortressDecisionPayload({
|
|
151
|
+
agentId, sessionId, rawEvent, normalized, result, decidedInMs,
|
|
152
|
+
signalsSalt,
|
|
153
|
+
});
|
|
154
|
+
pushDecisionToFortress(payload).catch(() => undefined);
|
|
168
155
|
};
|
|
169
156
|
|
|
170
157
|
let processed = 0, enforced = 0, sessionInterrupted = false;
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
import { request as httpsRequest } from 'node:https';
|
|
33
33
|
import { URL } from 'node:url';
|
|
34
34
|
import { EventEmitter } from 'node:events';
|
|
35
|
+
import { normalizeSseBuffer } from './sse.js';
|
|
35
36
|
|
|
36
37
|
const RECONNECT_MIN_MS = 1_000;
|
|
37
38
|
const RECONNECT_MAX_MS = 60_000;
|
|
@@ -154,6 +155,13 @@ export class PolicyStream extends EventEmitter {
|
|
|
154
155
|
let buffer = '';
|
|
155
156
|
res.on('data', (chunk) => {
|
|
156
157
|
buffer += chunk;
|
|
158
|
+
// v1.1.4 F-18 (P1 Codex audit): normalize CR / CRLF line
|
|
159
|
+
// terminators to LF before scanning for the event separator.
|
|
160
|
+
// Without this, a Fortress deployment behind a reverse-proxy
|
|
161
|
+
// that emits CRLF would never trigger a policy refresh push —
|
|
162
|
+
// updates would silently fall back to the 60s polling loop,
|
|
163
|
+
// breaking the "sub-second propagation" promise.
|
|
164
|
+
buffer = normalizeSseBuffer(buffer);
|
|
157
165
|
// v1.1.1 F-9: cap on a single SSE event buffer. A buggy/compromised
|
|
158
166
|
// endpoint that never emits "\n\n" would otherwise OOM Shield.
|
|
159
167
|
// Abort + reconnect on overflow; the buffer is dropped so we
|
|
@@ -165,7 +173,8 @@ export class PolicyStream extends EventEmitter {
|
|
|
165
173
|
if (!this._closed) this._scheduleReconnect();
|
|
166
174
|
return;
|
|
167
175
|
}
|
|
168
|
-
// SSE events are separated by a blank line
|
|
176
|
+
// SSE events are separated by a blank line. Post-normalize the
|
|
177
|
+
// canonical separator is "\n\n".
|
|
169
178
|
let eolIdx;
|
|
170
179
|
while ((eolIdx = buffer.indexOf('\n\n')) !== -1) {
|
|
171
180
|
const rawEvent = buffer.slice(0, eolIdx);
|
package/src/shield/policy.js
CHANGED
|
@@ -50,6 +50,13 @@ export async function loadPolicies(path) {
|
|
|
50
50
|
if (!VALID_MODES.includes(p.mode)) {
|
|
51
51
|
throw new Error(`policy ${p.id || p.name}: unsupported mode "${p.mode}" (must be one of: ${VALID_MODES.join(', ')})`);
|
|
52
52
|
}
|
|
53
|
+
// v1.1.5 Phase 1.5 — mark every local-file policy so the signature
|
|
54
|
+
// verifier (src/shield/signature.js) bypasses the Ed25519 chain on
|
|
55
|
+
// these rows. Today the local path and the Fortress path are entirely
|
|
56
|
+
// separate (loadPolicies → local ruleset; FortressPolicySource →
|
|
57
|
+
// cloud ruleset) so this marker is a safety net for future refactors
|
|
58
|
+
// that might mix them. It also documents intent at read time.
|
|
59
|
+
p.__local = true;
|
|
53
60
|
}
|
|
54
61
|
// v1.1.2 F-14 (P2 Codex audit): validate the ruleset's default.action
|
|
55
62
|
// against the SAME canonical set as per-policy actions. Before this fix
|
|
@@ -68,28 +75,61 @@ export async function loadPolicies(path) {
|
|
|
68
75
|
return data;
|
|
69
76
|
}
|
|
70
77
|
|
|
71
|
-
// ReDoS protection: regexes are loaded from a user-provided JSON policy file
|
|
72
|
-
//
|
|
73
|
-
//
|
|
78
|
+
// ReDoS protection: regexes are loaded from a user-provided JSON policy file
|
|
79
|
+
// (LOCAL adapter) AND from Fortress / Guardian-generated rules (FORTRESS
|
|
80
|
+
// adapter), so a malicious or buggy pattern (e.g. `(a+)+$`) could pin
|
|
81
|
+
// Shield's CPU on a long input — taking the whole enforcement loop down.
|
|
82
|
+
// We mitigate three ways:
|
|
74
83
|
// 1) Cap the maximum input length passed to any regex test to MAX_REGEX_INPUT
|
|
75
84
|
// bytes. Above that we truncate before testing. Real agent values
|
|
76
|
-
// (URLs, commands, queries) are well under this in practice.
|
|
77
|
-
// 2) Reject obviously dangerous patterns at compile time (heuristic
|
|
85
|
+
// (URLs, commands, queries, file paths) are well under this in practice.
|
|
86
|
+
// 2) Reject obviously dangerous patterns at compile time (heuristic — see
|
|
87
|
+
// SUSPICIOUS_REGEX_PATTERNS below). The list errs toward false positives
|
|
88
|
+
// because Shield runs in the hot path: a rejected rule is loud (the
|
|
89
|
+
// rule is dropped at load time with a clear error) while a runaway
|
|
90
|
+
// regex would silently degrade Shield to "no enforcement" until the
|
|
91
|
+
// operator notices a CPU spike.
|
|
92
|
+
// 3) Hard upper bound on the regex source length so a deeply nested
|
|
93
|
+
// pattern can't game the heuristic by spreading the gadget across
|
|
94
|
+
// thousands of chars.
|
|
78
95
|
//
|
|
79
|
-
//
|
|
80
|
-
|
|
96
|
+
// Future work (v1.2+): proper RE2 or safe-regex-2 dependency for thorough
|
|
97
|
+
// analysis, or moving evaluation into a worker with a hard CPU timeout.
|
|
98
|
+
// We can't ship that today without breaking the zero-runtime-deps promise.
|
|
99
|
+
//
|
|
100
|
+
// v1.1.4 F-20 (P2 Codex audit): cap reduced from 8192 → 2048 (4×). Worst
|
|
101
|
+
// realistic IoC values (URL with long query string, base64 in a path)
|
|
102
|
+
// remain comfortably under 2048; the previous 8192 was a defence-in-depth
|
|
103
|
+
// holdover that no real workload exercises.
|
|
104
|
+
const MAX_REGEX_INPUT = 2048;
|
|
81
105
|
|
|
106
|
+
// v1.1.4 F-20: heuristic list extended to catch ambiguous alternation
|
|
107
|
+
// (`(a|a)*`, `(a|ab)+`, `(.|.)*`) — these don't trip the existing
|
|
108
|
+
// nested-quantifier heuristic but exhibit the same exponential behavior
|
|
109
|
+
// because every char has two paths to match. The new pattern rejects any
|
|
110
|
+
// alternation group immediately followed by `+` or `*`; this is intentionally
|
|
111
|
+
// over-broad — a customer who needs `(http|https):` should use either a
|
|
112
|
+
// character class (`[a-z]+:`) or move the optional letter (`https?:`).
|
|
82
113
|
const SUSPICIOUS_REGEX_PATTERNS = [
|
|
83
114
|
/(\([^)]*[+*][^)]*\))[+*]/, // (x+)+ or (x*)* — classic catastrophic backtracking
|
|
84
115
|
/(\.\*){3,}/, // multiple .* in a row
|
|
116
|
+
// F-20: alternation inside a group, then `+` or `*` — `(a|a)*`, `(a|ab)+`,
|
|
117
|
+
// `(.|.)*`. The `[^)]*\|[^)]*` body requires at least one `|` inside the
|
|
118
|
+
// group; a single-branch group like `(foo)+` is NOT matched.
|
|
119
|
+
/\([^)]*\|[^)]*\)[+*]/,
|
|
85
120
|
];
|
|
86
121
|
|
|
87
122
|
export function validateRegexString(src, where) {
|
|
88
123
|
if (typeof src !== 'string') {
|
|
89
124
|
throw new Error(`policy ${where}: regex must be a string`);
|
|
90
125
|
}
|
|
91
|
-
|
|
92
|
-
|
|
126
|
+
// v1.1.4 F-20: cap regex source at 1024 chars (was 2000). Real Shield
|
|
127
|
+
// policies are short (URL-prefix match, host-list deny, command-prefix
|
|
128
|
+
// check). A 1KB regex source is already an outlier; anything longer is
|
|
129
|
+
// either pathological or trying to bypass the heuristics by smuggling
|
|
130
|
+
// a gadget across many bytes.
|
|
131
|
+
if (src.length > 1024) {
|
|
132
|
+
throw new Error(`policy ${where}: regex too long (>1024 chars)`);
|
|
93
133
|
}
|
|
94
134
|
for (const sus of SUSPICIOUS_REGEX_PATTERNS) {
|
|
95
135
|
if (sus.test(src)) {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// Fortress root public key — v1.1.5 Phase 1.5 (Guardian Core Axis 2)
|
|
3
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// The Ed25519 root public key of the WMA Fortress signing hierarchy.
|
|
6
|
+
// All cloud-supplied policies are signed by a signing key, which is itself
|
|
7
|
+
// signed by this root. The verifier in src/shield/signature.js chains
|
|
8
|
+
// from policy → signing_key → ROOT to decide whether to trust a policy.
|
|
9
|
+
//
|
|
10
|
+
// The root PRIVATE key NEVER touches this file (nor any customer machine,
|
|
11
|
+
// nor any release artifact). It lives in the Fortress operator's offline
|
|
12
|
+
// vault. Only the public counterpart is shipped, and only its public
|
|
13
|
+
// counterpart is needed to verify.
|
|
14
|
+
//
|
|
15
|
+
// ROTATION POLICY:
|
|
16
|
+
// - Compromised root → ship a new SDK version with both the old AND new
|
|
17
|
+
// root keys in an array, give customers a grace window to upgrade,
|
|
18
|
+
// then ship another SDK version with only the new key. This is the
|
|
19
|
+
// same "double-signing" model used by Sigstore + Let's Encrypt.
|
|
20
|
+
// - Routine rotation: every 2 years. Signing keys (one tier down) are
|
|
21
|
+
// rotated every quarter and don't require an SDK release.
|
|
22
|
+
//
|
|
23
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
24
|
+
// Real root pubkey — generated by offline ceremony on 2026-06-02
|
|
25
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
26
|
+
//
|
|
27
|
+
// The private counterpart NEVER touched this repository, npm, GitHub, or
|
|
28
|
+
// any connected machine. It was generated on the operator's machine in
|
|
29
|
+
// an air-gapped session and immediately stored in 1Password Vault under
|
|
30
|
+
// "WMA Fortress Root Private Key v1". It will be used 4× per year to
|
|
31
|
+
// sign new signing keys for the Fortress signing pipeline.
|
|
32
|
+
//
|
|
33
|
+
// Fortress operator: Minedor (arma@talkytranslate.com)
|
|
34
|
+
//
|
|
35
|
+
// To rotate this key (every 2 years routine, or on suspected compromise):
|
|
36
|
+
// 1. Generate a NEW keypair offline (same node:crypto snippet)
|
|
37
|
+
// 2. Ship an SDK version with BOTH old + new pubkeys in an array,
|
|
38
|
+
// give customers 6-12 months overlap
|
|
39
|
+
// 3. Ship a follow-up SDK version with only the new pubkey
|
|
40
|
+
//
|
|
41
|
+
// 32-byte Ed25519 public key as base64 (44 chars).
|
|
42
|
+
export const WMA_FORTRESS_ROOT_PUBKEY_B64 = 'w4qKFACBbawZW1kx2wWxnITlOfvLDe+SXaPyZvp+mV4=';
|
|
43
|
+
|
|
44
|
+
// Flipped to false at release time (v1.1.5). The FortressPolicySource
|
|
45
|
+
// reads this to decide whether to skip verification entirely (placeholder
|
|
46
|
+
// era) or strictly enforce the chain (production). Keep this field —
|
|
47
|
+
// future rotations may temporarily re-enable placeholder mode in
|
|
48
|
+
// release-candidate builds where the new root isn't yet provisioned.
|
|
49
|
+
export const WMA_FORTRESS_ROOT_IS_PLACEHOLDER = false;
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// Ed25519 policy signature verification — v1.1.5 Phase 1.5 (Guardian Core Axis 2)
|
|
3
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// Two-level chain of trust:
|
|
6
|
+
//
|
|
7
|
+
// ROOT KEY (offline-generated, public key embedded in the SDK)
|
|
8
|
+
// │ signs
|
|
9
|
+
// ▼
|
|
10
|
+
// SIGNING KEY (rotated quarterly by Fortress, distributed in get-policies)
|
|
11
|
+
// │ signs
|
|
12
|
+
// ▼
|
|
13
|
+
// POLICY (each policy.signature is verified against its signing_key)
|
|
14
|
+
//
|
|
15
|
+
// The root private key NEVER leaves Fortress's secure vault. Signing keys
|
|
16
|
+
// rotate without requiring an SDK release because their public keys travel
|
|
17
|
+
// in the get-policies response, signed by the root. The SDK only needs to
|
|
18
|
+
// know the embedded root public key to validate the whole chain.
|
|
19
|
+
//
|
|
20
|
+
// Why per-policy signatures (not bundle signatures):
|
|
21
|
+
// - A single corrupted/malicious policy doesn't invalidate the whole
|
|
22
|
+
// ruleset — we drop the invalid one and keep the rest.
|
|
23
|
+
// - Lets us mix "Fortress-signed cloud policies" with "local JSON
|
|
24
|
+
// policies for dev/test" without forcing the local path through the
|
|
25
|
+
// signing pipeline. The local-file adapter sets `__local: true` on
|
|
26
|
+
// its policies to opt out of signature requirements.
|
|
27
|
+
// - Compatible with first-match-wins evaluation: order is preserved,
|
|
28
|
+
// gaps from dropped policies are silently filled by lower-priority
|
|
29
|
+
// rules or the ruleset default.
|
|
30
|
+
//
|
|
31
|
+
// Zero runtime deps preserved — uses node:crypto's native Ed25519 support
|
|
32
|
+
// (Node 16+). No tweetnacl, no @noble/curves.
|
|
33
|
+
|
|
34
|
+
import { createPublicKey, verify as cryptoVerify } from 'node:crypto';
|
|
35
|
+
|
|
36
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
37
|
+
// Canonical serialization for signing
|
|
38
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
39
|
+
//
|
|
40
|
+
// Both ends (signer + verifier) must agree on EXACTLY what bytes are signed.
|
|
41
|
+
// We use a deterministic JSON encoding: keys sorted lexicographically at
|
|
42
|
+
// every nesting depth, arrays preserved in order, no whitespace, no escape
|
|
43
|
+
// variations (JSON.stringify's default escaping is deterministic).
|
|
44
|
+
//
|
|
45
|
+
// The signed payload for a POLICY excludes the signature field itself plus
|
|
46
|
+
// any signer-side bookkeeping (signed_at, signing_key_id are NOT signed —
|
|
47
|
+
// they're metadata that travels alongside the signature for verifier
|
|
48
|
+
// lookup). The fields we DO sign are the ones whose modification would
|
|
49
|
+
// change Shield's decision: rule_id, match, action, message, priority, mode.
|
|
50
|
+
|
|
51
|
+
const POLICY_SIGNED_FIELDS = ['rule_id', 'match', 'action', 'message', 'priority', 'mode'];
|
|
52
|
+
|
|
53
|
+
// Recursive deterministic JSON: sorted keys at every depth, arrays kept in order.
|
|
54
|
+
export function canonicalize(value) {
|
|
55
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
56
|
+
if (Array.isArray(value)) {
|
|
57
|
+
return '[' + value.map(canonicalize).join(',') + ']';
|
|
58
|
+
}
|
|
59
|
+
const keys = Object.keys(value).sort();
|
|
60
|
+
const pairs = keys.map((k) => JSON.stringify(k) + ':' + canonicalize(value[k]));
|
|
61
|
+
return '{' + pairs.join(',') + '}';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Build the byte string the policy signer hashed-and-signed.
|
|
65
|
+
// Order: only the fields in POLICY_SIGNED_FIELDS, in their canonical order.
|
|
66
|
+
// Missing fields are encoded as `null` so a policy that loses a field on
|
|
67
|
+
// the wire (truncation, MITM) fails the signature check rather than
|
|
68
|
+
// silently passing under a different shape.
|
|
69
|
+
export function policySigningPayload(policy) {
|
|
70
|
+
const obj = {};
|
|
71
|
+
for (const f of POLICY_SIGNED_FIELDS) {
|
|
72
|
+
obj[f] = policy[f] !== undefined ? policy[f] : null;
|
|
73
|
+
}
|
|
74
|
+
return canonicalize(obj);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Build the byte string the ROOT signs when issuing a signing key.
|
|
78
|
+
// Order: only kid + pubkey + valid_from + valid_until — all four required.
|
|
79
|
+
// `pubkey` is the raw 32-byte Ed25519 public key, base64-encoded.
|
|
80
|
+
const SIGNING_KEY_SIGNED_FIELDS = ['kid', 'pubkey', 'valid_from', 'valid_until'];
|
|
81
|
+
|
|
82
|
+
export function signingKeyPayload(signingKey) {
|
|
83
|
+
const obj = {};
|
|
84
|
+
for (const f of SIGNING_KEY_SIGNED_FIELDS) {
|
|
85
|
+
obj[f] = signingKey[f] !== undefined ? signingKey[f] : null;
|
|
86
|
+
}
|
|
87
|
+
return canonicalize(obj);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
91
|
+
// Key parsing
|
|
92
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
// Accept either:
|
|
95
|
+
// - a raw 32-byte Ed25519 public key, base64-encoded
|
|
96
|
+
// - a SubjectPublicKeyInfo (SPKI) DER-encoded blob, base64-encoded
|
|
97
|
+
// - a PEM-armored public key
|
|
98
|
+
// Returns a node:crypto KeyObject ready for verify().
|
|
99
|
+
//
|
|
100
|
+
// Errors are surfaced verbatim so the operator sees WHY the key didn't
|
|
101
|
+
// load (corrupted base64, wrong curve, etc.) instead of a silent "no
|
|
102
|
+
// policies loaded".
|
|
103
|
+
export function importEd25519PublicKey(input) {
|
|
104
|
+
if (input == null) throw new Error('importEd25519PublicKey: input is null/undefined');
|
|
105
|
+
if (typeof input === 'string') {
|
|
106
|
+
const trimmed = input.trim();
|
|
107
|
+
if (trimmed.startsWith('-----BEGIN ')) {
|
|
108
|
+
return createPublicKey({ key: trimmed, format: 'pem' });
|
|
109
|
+
}
|
|
110
|
+
// Try raw 32-byte → wrap in SPKI prefix for Ed25519.
|
|
111
|
+
const buf = Buffer.from(trimmed, 'base64');
|
|
112
|
+
if (buf.length === 32) {
|
|
113
|
+
// Ed25519 SPKI prefix (RFC 8410 §4): 0x302a300506032b6570032100
|
|
114
|
+
const SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
|
|
115
|
+
return createPublicKey({
|
|
116
|
+
key: Buffer.concat([SPKI_PREFIX, buf]),
|
|
117
|
+
format: 'der',
|
|
118
|
+
type: 'spki',
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
// Otherwise assume DER-encoded SPKI.
|
|
122
|
+
return createPublicKey({ key: buf, format: 'der', type: 'spki' });
|
|
123
|
+
}
|
|
124
|
+
if (Buffer.isBuffer(input)) {
|
|
125
|
+
return createPublicKey({ key: input, format: 'der', type: 'spki' });
|
|
126
|
+
}
|
|
127
|
+
throw new Error(`importEd25519PublicKey: unsupported input type ${typeof input}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
131
|
+
// Low-level verify
|
|
132
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
// Verify a base64-encoded Ed25519 signature over a string payload.
|
|
135
|
+
// Returns true/false — never throws on a bad signature (operational
|
|
136
|
+
// callers want a boolean, not exception flow). Throws only on programmer
|
|
137
|
+
// errors (missing key, wrong types).
|
|
138
|
+
export function verifyEd25519(publicKey, payloadString, signatureBase64) {
|
|
139
|
+
if (publicKey == null) throw new Error('verifyEd25519: publicKey required');
|
|
140
|
+
if (typeof payloadString !== 'string') throw new Error('verifyEd25519: payload must be a string');
|
|
141
|
+
if (typeof signatureBase64 !== 'string') return false;
|
|
142
|
+
let sigBuf;
|
|
143
|
+
try {
|
|
144
|
+
sigBuf = Buffer.from(signatureBase64, 'base64');
|
|
145
|
+
} catch {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
// Ed25519 signatures are exactly 64 bytes — anything else is invalid.
|
|
149
|
+
if (sigBuf.length !== 64) return false;
|
|
150
|
+
try {
|
|
151
|
+
return cryptoVerify(null, Buffer.from(payloadString, 'utf8'), publicKey, sigBuf);
|
|
152
|
+
} catch {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
158
|
+
// High-level chain verification
|
|
159
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Verify a signing key's root signature and parse its public key.
|
|
163
|
+
*
|
|
164
|
+
* @param {object} signingKey
|
|
165
|
+
* { kid, pubkey, valid_from, valid_until, signed_by_root }
|
|
166
|
+
* @param {KeyObject} rootPublicKey - the embedded SDK root public key
|
|
167
|
+
* @param {Date} [now] - clock for valid_from/valid_until checks (default: real now)
|
|
168
|
+
* @returns {{ ok: true, kid: string, pubkey: KeyObject } | { ok: false, reason: string }}
|
|
169
|
+
*/
|
|
170
|
+
export function verifySigningKey(signingKey, rootPublicKey, now = new Date()) {
|
|
171
|
+
if (!signingKey || typeof signingKey !== 'object') {
|
|
172
|
+
return { ok: false, reason: 'signing key is not an object' };
|
|
173
|
+
}
|
|
174
|
+
for (const f of SIGNING_KEY_SIGNED_FIELDS) {
|
|
175
|
+
if (signingKey[f] == null) return { ok: false, reason: `signing key missing required field "${f}"` };
|
|
176
|
+
}
|
|
177
|
+
if (typeof signingKey.signed_by_root !== 'string') {
|
|
178
|
+
return { ok: false, reason: 'signing_key.signed_by_root missing or not a string' };
|
|
179
|
+
}
|
|
180
|
+
// Validity window — both ends inclusive per ISO 8601 convention.
|
|
181
|
+
const from = new Date(signingKey.valid_from);
|
|
182
|
+
const until = new Date(signingKey.valid_until);
|
|
183
|
+
if (isNaN(from.getTime()) || isNaN(until.getTime())) {
|
|
184
|
+
return { ok: false, reason: 'invalid valid_from or valid_until ISO date' };
|
|
185
|
+
}
|
|
186
|
+
if (now < from) return { ok: false, reason: `signing key ${signingKey.kid} not yet valid (valid_from ${signingKey.valid_from})` };
|
|
187
|
+
if (now > until) return { ok: false, reason: `signing key ${signingKey.kid} expired (valid_until ${signingKey.valid_until})` };
|
|
188
|
+
// Root signature over canonical {kid, pubkey, valid_from, valid_until}.
|
|
189
|
+
const payload = signingKeyPayload(signingKey);
|
|
190
|
+
if (!verifyEd25519(rootPublicKey, payload, signingKey.signed_by_root)) {
|
|
191
|
+
return { ok: false, reason: `signing key ${signingKey.kid} not signed by trusted root` };
|
|
192
|
+
}
|
|
193
|
+
// Parse the signing key's pubkey itself (must be a valid Ed25519 key).
|
|
194
|
+
let pubkey;
|
|
195
|
+
try {
|
|
196
|
+
pubkey = importEd25519PublicKey(signingKey.pubkey);
|
|
197
|
+
} catch (e) {
|
|
198
|
+
return { ok: false, reason: `signing key ${signingKey.kid} pubkey invalid: ${e.message}` };
|
|
199
|
+
}
|
|
200
|
+
return { ok: true, kid: signingKey.kid, pubkey };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Verify one policy's signature against its signing key.
|
|
205
|
+
*
|
|
206
|
+
* @param {object} policy - must include signature + signing_key_id
|
|
207
|
+
* @param {Map<string, KeyObject>} signingKeysByKid - verified signing keys
|
|
208
|
+
* @returns {{ ok: true } | { ok: false, reason: string }}
|
|
209
|
+
*/
|
|
210
|
+
export function verifyPolicy(policy, signingKeysByKid) {
|
|
211
|
+
if (!policy || typeof policy !== 'object') return { ok: false, reason: 'policy is not an object' };
|
|
212
|
+
// Local-policy escape hatch: the local JSON adapter sets __local: true
|
|
213
|
+
// on its rows so dev/test/CI workflows don't have to involve the
|
|
214
|
+
// signing pipeline. The FortressPolicySource path NEVER sets this.
|
|
215
|
+
if (policy.__local === true) return { ok: true };
|
|
216
|
+
if (typeof policy.signature !== 'string') return { ok: false, reason: `policy ${policy.rule_id || '?'} missing signature` };
|
|
217
|
+
if (typeof policy.signing_key_id !== 'string') return { ok: false, reason: `policy ${policy.rule_id || '?'} missing signing_key_id` };
|
|
218
|
+
const pubkey = signingKeysByKid.get(policy.signing_key_id);
|
|
219
|
+
if (!pubkey) {
|
|
220
|
+
return { ok: false, reason: `policy ${policy.rule_id || '?'} references unknown signing_key_id "${policy.signing_key_id}"` };
|
|
221
|
+
}
|
|
222
|
+
const payload = policySigningPayload(policy);
|
|
223
|
+
if (!verifyEd25519(pubkey, payload, policy.signature)) {
|
|
224
|
+
return { ok: false, reason: `policy ${policy.rule_id || '?'} signature does not verify against signing key ${policy.signing_key_id}` };
|
|
225
|
+
}
|
|
226
|
+
return { ok: true };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* High-level: given a Fortress `get-policies` response and the embedded
|
|
231
|
+
* root pubkey, return { validPolicies, droppedPolicies, validSigningKeys }.
|
|
232
|
+
*
|
|
233
|
+
* Caller is expected to ignore droppedPolicies (with logging) and load
|
|
234
|
+
* validPolicies into the ruleset. Never throws — failure modes are
|
|
235
|
+
* returned in `droppedPolicies[].reason` for the caller to surface.
|
|
236
|
+
*
|
|
237
|
+
* @param {object} opts
|
|
238
|
+
* @param {Array} opts.policies - raw policies from get-policies response
|
|
239
|
+
* @param {Array} opts.signingKeys - raw signing_keys from response
|
|
240
|
+
* @param {KeyObject} opts.rootPublicKey - SDK-embedded root pubkey
|
|
241
|
+
* @param {Date} [opts.now]
|
|
242
|
+
*/
|
|
243
|
+
export function verifyPolicyBundle({ policies, signingKeys, rootPublicKey, now = new Date() }) {
|
|
244
|
+
const validSigningKeys = new Map();
|
|
245
|
+
const signingKeyErrors = [];
|
|
246
|
+
for (const sk of signingKeys || []) {
|
|
247
|
+
const r = verifySigningKey(sk, rootPublicKey, now);
|
|
248
|
+
if (r.ok) validSigningKeys.set(r.kid, r.pubkey);
|
|
249
|
+
else signingKeyErrors.push({ kid: sk?.kid || '?', reason: r.reason });
|
|
250
|
+
}
|
|
251
|
+
const validPolicies = [];
|
|
252
|
+
const droppedPolicies = [];
|
|
253
|
+
for (const p of policies || []) {
|
|
254
|
+
const r = verifyPolicy(p, validSigningKeys);
|
|
255
|
+
if (r.ok) validPolicies.push(p);
|
|
256
|
+
else droppedPolicies.push({ rule_id: p?.rule_id || '?', reason: r.reason });
|
|
257
|
+
}
|
|
258
|
+
return { validPolicies, droppedPolicies, validSigningKeys, signingKeyErrors };
|
|
259
|
+
}
|
|
@@ -84,7 +84,7 @@ function httpsJson(method, url, headers, body, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
|
84
84
|
* @param {string} opts.apiKey - wma_xxx
|
|
85
85
|
* @param {string} opts.base - Fortress base URL (https://x.supabase.co/functions/v1)
|
|
86
86
|
* @param {string} [opts.anthropicAgentId] - optional filter
|
|
87
|
-
* @returns {Promise<{ ok: true, policies: array, fetched_at: string }>}
|
|
87
|
+
* @returns {Promise<{ ok: true, policies: array, signing_keys: array, fetched_at: string }>}
|
|
88
88
|
*/
|
|
89
89
|
export async function fetchPolicies({ apiKey, base, anthropicAgentId }) {
|
|
90
90
|
let url = fortressEndpoint(base, 'get-policies');
|
|
@@ -97,7 +97,18 @@ export async function fetchPolicies({ apiKey, base, anthropicAgentId }) {
|
|
|
97
97
|
accept: 'application/json',
|
|
98
98
|
});
|
|
99
99
|
if (status === 200 && body && body.ok) {
|
|
100
|
-
return {
|
|
100
|
+
return {
|
|
101
|
+
ok: true,
|
|
102
|
+
policies: body.policies || [],
|
|
103
|
+
// v1.1.5 Phase 1.5 — the chain-of-trust: signing keys travel
|
|
104
|
+
// with the response, each signed by the embedded root pubkey.
|
|
105
|
+
// Older Fortress deployments that haven't shipped the signing
|
|
106
|
+
// pipeline yet just won't include this field — the verifier
|
|
107
|
+
// then drops every policy as "unknown signing_key_id" and
|
|
108
|
+
// operators see the gap immediately.
|
|
109
|
+
signing_keys: body.signing_keys || [],
|
|
110
|
+
fetched_at: body.fetched_at,
|
|
111
|
+
};
|
|
101
112
|
}
|
|
102
113
|
const err = body?.error || (typeof body === 'string' ? body.slice(0, 200) : 'unknown');
|
|
103
114
|
throw new Error(`get-policies failed (HTTP ${status}): ${err}`);
|
|
@@ -136,11 +147,43 @@ export async function postDecision({ apiKey, base, decision }) {
|
|
|
136
147
|
// ────────────────────────────────────────────────────────────────────────
|
|
137
148
|
|
|
138
149
|
import { matchesPolicy, compileMatchRegexes } from '../policy.js';
|
|
150
|
+
// v1.1.5 Phase 1.5 — signature verification on the cloud path.
|
|
151
|
+
import { verifyPolicyBundle, importEd25519PublicKey } from '../signature.js';
|
|
152
|
+
import { WMA_FORTRESS_ROOT_PUBKEY_B64, WMA_FORTRESS_ROOT_IS_PLACEHOLDER } from '../root-key.js';
|
|
139
153
|
|
|
140
154
|
const VALID_ACTIONS = new Set(['allow', 'deny', 'interrupt']);
|
|
141
155
|
|
|
156
|
+
// v1.1.5 Phase 1.5 — strict-by-default signature verification.
|
|
157
|
+
// Set WMA_REQUIRE_SIGNED_POLICIES=false to accept unsigned policies
|
|
158
|
+
// from Fortress with a loud warning at each refresh. This is an escape
|
|
159
|
+
// hatch for ops emergencies (e.g. Fortress signing pipeline temporarily
|
|
160
|
+
// down) and dev/CI workflows where a staging Fortress hasn't been
|
|
161
|
+
// upgraded yet. Default is strict to honour the security stance chosen
|
|
162
|
+
// for v1.1.5.
|
|
163
|
+
function strictModeFromEnv() {
|
|
164
|
+
const v = process.env.WMA_REQUIRE_SIGNED_POLICIES;
|
|
165
|
+
if (v == null) return true; // unset → strict by default
|
|
166
|
+
if (v === 'false' || v === '0') return false;
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Parse the embedded root pubkey ONCE at module load. If the file still
|
|
171
|
+
// carries the placeholder, we DO NOT throw — but every refresh logs a
|
|
172
|
+
// loud reminder so an unattended deploy can't silently trust a key whose
|
|
173
|
+
// private counterpart is in the git history.
|
|
174
|
+
const ROOT_PUBLIC_KEY = (() => {
|
|
175
|
+
try {
|
|
176
|
+
return importEd25519PublicKey(WMA_FORTRESS_ROOT_PUBKEY_B64);
|
|
177
|
+
} catch (e) {
|
|
178
|
+
// The placeholder string isn't valid base64 of 32 bytes, so import
|
|
179
|
+
// will throw. That's the desired behaviour during development —
|
|
180
|
+
// verification will fail-closed until the real key is embedded.
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
})();
|
|
184
|
+
|
|
142
185
|
export class FortressPolicySource {
|
|
143
|
-
constructor({ apiKey, base, anthropicAgentId, refreshIntervalMs = 5 * 60_000, onError, onRefresh }) {
|
|
186
|
+
constructor({ apiKey, base, anthropicAgentId, refreshIntervalMs = 5 * 60_000, onError, onRefresh, requireSignedPolicies }) {
|
|
144
187
|
if (!apiKey) throw new Error('FortressPolicySource: apiKey required');
|
|
145
188
|
if (!base) throw new Error('FortressPolicySource: base URL required');
|
|
146
189
|
this.apiKey = apiKey;
|
|
@@ -153,6 +196,12 @@ export class FortressPolicySource {
|
|
|
153
196
|
this.lastFetchedAt = null;
|
|
154
197
|
this._timer = null;
|
|
155
198
|
this._aborted = false;
|
|
199
|
+
// v1.1.5: per-instance override of the env var. Tests use this to
|
|
200
|
+
// exercise both modes without touching process.env. If neither the
|
|
201
|
+
// constructor option nor the env var is set, strict mode wins.
|
|
202
|
+
this.requireSignedPolicies = requireSignedPolicies != null
|
|
203
|
+
? !!requireSignedPolicies
|
|
204
|
+
: strictModeFromEnv();
|
|
156
205
|
}
|
|
157
206
|
|
|
158
207
|
/** Initial fetch — fails loud if it can't reach Fortress at startup. */
|
|
@@ -186,17 +235,26 @@ export class FortressPolicySource {
|
|
|
186
235
|
async _refresh({ initial = false } = {}) {
|
|
187
236
|
if (this._aborted) return;
|
|
188
237
|
try {
|
|
189
|
-
const { policies, fetched_at } = await fetchPolicies({
|
|
238
|
+
const { policies, signing_keys, fetched_at } = await fetchPolicies({
|
|
190
239
|
apiKey: this.apiKey,
|
|
191
240
|
base: this.base,
|
|
192
241
|
anthropicAgentId: this.anthropicAgentId,
|
|
193
242
|
});
|
|
194
|
-
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
243
|
+
|
|
244
|
+
// v1.1.5 Phase 1.5 — verify the chain-of-trust BEFORE any other
|
|
245
|
+
// processing. We must verify on the raw JSON shape sent by Fortress,
|
|
246
|
+
// not on the post-compile form, because compileMatchRegexes mutates
|
|
247
|
+
// `match` in place (adds _regex / _not_regex KeyObjects) and the
|
|
248
|
+
// signed canonical payload would no longer match.
|
|
249
|
+
const verifiedPolicies = this._verifyAndFilter(policies, signing_keys);
|
|
250
|
+
|
|
251
|
+
// Compile + validate each VERIFIED policy. A single malformed/dangerous
|
|
252
|
+
// policy (bad action, ReDoS-prone regex) must NOT take down the whole
|
|
253
|
+
// ruleset: skip it, report it, keep the rest. This matters because
|
|
254
|
+
// even after signature verification the rule shape can be wrong
|
|
255
|
+
// (server-side signing happened on a payload the SDK doesn't accept).
|
|
198
256
|
const compiled = [];
|
|
199
|
-
for (const p of
|
|
257
|
+
for (const p of verifiedPolicies) {
|
|
200
258
|
try {
|
|
201
259
|
compiled.push(compilePolicyFromFortress(p));
|
|
202
260
|
} catch (e) {
|
|
@@ -228,6 +286,49 @@ export class FortressPolicySource {
|
|
|
228
286
|
this.onError(e);
|
|
229
287
|
}
|
|
230
288
|
}
|
|
289
|
+
|
|
290
|
+
// v1.1.5 Phase 1.5 — verify the Fortress chain of trust on a refresh
|
|
291
|
+
// response. Returns the array of policies that pass the gate (verified
|
|
292
|
+
// signatures in strict mode, OR all policies in lax mode with a warning
|
|
293
|
+
// per unsigned one).
|
|
294
|
+
//
|
|
295
|
+
// FAIL MODES:
|
|
296
|
+
// - ROOT key is the placeholder (release wasn't ceremony-completed):
|
|
297
|
+
// emit a one-line WARNING at each refresh and skip verification
|
|
298
|
+
// entirely — better than silently trusting a known-compromised key.
|
|
299
|
+
// - Strict (default): drop every policy that doesn't verify; log each
|
|
300
|
+
// drop reason via onError so the operator sees the gap.
|
|
301
|
+
// - Lax (WMA_REQUIRE_SIGNED_POLICIES=false): keep every policy but
|
|
302
|
+
// emit a WARNING per unsigned one — gives migration slack while
|
|
303
|
+
// making the audit trail visible.
|
|
304
|
+
_verifyAndFilter(rawPolicies, rawSigningKeys) {
|
|
305
|
+
if (WMA_FORTRESS_ROOT_IS_PLACEHOLDER || ROOT_PUBLIC_KEY == null) {
|
|
306
|
+
this.onError(new Error(
|
|
307
|
+
'FortressPolicySource: ROOT_PUBLIC_KEY is the placeholder (not a real Fortress root). ' +
|
|
308
|
+
'Signature verification SKIPPED. This is the expected state during development; ' +
|
|
309
|
+
'NEVER ship this configuration to production.'
|
|
310
|
+
));
|
|
311
|
+
return rawPolicies || [];
|
|
312
|
+
}
|
|
313
|
+
const bundle = verifyPolicyBundle({
|
|
314
|
+
policies: rawPolicies || [],
|
|
315
|
+
signingKeys: rawSigningKeys || [],
|
|
316
|
+
rootPublicKey: ROOT_PUBLIC_KEY,
|
|
317
|
+
});
|
|
318
|
+
for (const ke of bundle.signingKeyErrors) {
|
|
319
|
+
this.onError(new Error(`FortressPolicySource: rejected signing key "${ke.kid}": ${ke.reason}`));
|
|
320
|
+
}
|
|
321
|
+
for (const dp of bundle.droppedPolicies) {
|
|
322
|
+
const verb = this.requireSignedPolicies ? 'DROPPING' : 'WARNING (lax mode)';
|
|
323
|
+
this.onError(new Error(`FortressPolicySource: ${verb} policy "${dp.rule_id}": ${dp.reason}`));
|
|
324
|
+
}
|
|
325
|
+
if (this.requireSignedPolicies) {
|
|
326
|
+
return bundle.validPolicies;
|
|
327
|
+
}
|
|
328
|
+
// Lax mode: keep every raw policy but the loud warnings above let
|
|
329
|
+
// ops see what would be dropped in strict mode.
|
|
330
|
+
return rawPolicies || [];
|
|
331
|
+
}
|
|
231
332
|
}
|
|
232
333
|
|
|
233
334
|
// Convert a Fortress DB policy row to the local Shield format.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// SSE line terminator normalization — v1.1.4 F-18 (P1 Codex audit).
|
|
2
|
+
//
|
|
3
|
+
// The HTML Living Standard's event-stream parsing rules (whatwg SSE)
|
|
4
|
+
// accept three line terminator forms: LF (\n), CR (\r), and CRLF (\r\n).
|
|
5
|
+
// An event ends at a blank line: TWO consecutive line terminators of any
|
|
6
|
+
// of those forms, possibly mixed (e.g. \r\n\r\n, \r\r, \n\n, \r\n\n).
|
|
7
|
+
//
|
|
8
|
+
// Before this fix, Shield's two SSE consumers (stream.js for live agent
|
|
9
|
+
// events, policy-stream.js for Fortress policy push) only looked for
|
|
10
|
+
// the LF-LF separator. An upstream proxy or endpoint that emitted CRLF
|
|
11
|
+
// (most production-grade reverse-proxies do, by default!) would yield
|
|
12
|
+
// a buffer that never matched \n\n — Shield would silently never see
|
|
13
|
+
// the events:
|
|
14
|
+
// - agent-stream side: no deny/interrupt would fire live, breaking
|
|
15
|
+
// the sub-second enforcement promise
|
|
16
|
+
// - policy-stream side: updates would fall back to the 60s polling
|
|
17
|
+
// loop, making rule rollouts visibly slow
|
|
18
|
+
//
|
|
19
|
+
// Fix: normalize the buffer to LF-only before scanning. The normalize
|
|
20
|
+
// step is chunk-safe: a CR at the very end of the current buffer is
|
|
21
|
+
// preserved verbatim (it might be the first half of an incoming CRLF
|
|
22
|
+
// on the next chunk). Once a CR is no longer trailing, it's converted.
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Normalize SSE line terminators in a streaming buffer to LF.
|
|
26
|
+
*
|
|
27
|
+
* Use as: `buffer = normalizeSseBuffer(buffer + newChunk);`
|
|
28
|
+
*
|
|
29
|
+
* Guarantees, after the call:
|
|
30
|
+
* - every `\r\n` pair has been replaced by `\n`
|
|
31
|
+
* - every bare `\r` NOT at the very end has been replaced by `\n`
|
|
32
|
+
* - a trailing `\r` is preserved verbatim so the next iteration can
|
|
33
|
+
* check whether it was actually the first half of a CRLF
|
|
34
|
+
*
|
|
35
|
+
* @param {string} buffer the streaming buffer (already concatenated)
|
|
36
|
+
* @returns {string} buffer with line terminators normalized to LF
|
|
37
|
+
*/
|
|
38
|
+
export function normalizeSseBuffer(buffer) {
|
|
39
|
+
if (typeof buffer !== 'string' || buffer.length === 0) return buffer;
|
|
40
|
+
// Defer the very last character if it's a CR — we don't know yet
|
|
41
|
+
// whether it's a bare CR terminator or the first half of CRLF.
|
|
42
|
+
const tail = buffer.endsWith('\r') ? '\r' : '';
|
|
43
|
+
const scannable = tail ? buffer.slice(0, -1) : buffer;
|
|
44
|
+
// Two-pass replace: CRLF first (so we don't double-convert the LF
|
|
45
|
+
// half), then any remaining bare CR.
|
|
46
|
+
const normalized = scannable.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
47
|
+
return tail ? normalized + tail : normalized;
|
|
48
|
+
}
|
package/src/shield/stream.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Uses built-in fetch + ReadableStream (Node 18+). Zero deps.
|
|
8
8
|
|
|
9
|
+
import { normalizeSseBuffer } from './sse.js';
|
|
10
|
+
|
|
9
11
|
const API_BASE = 'https://api.anthropic.com';
|
|
10
12
|
const BETA = 'managed-agents-2026-04-01';
|
|
11
13
|
const VERSION = '2023-06-01';
|
|
@@ -51,6 +53,12 @@ export async function* openEventStream({ apiKey, sessionId, signal }) {
|
|
|
51
53
|
const { done, value } = await reader.read();
|
|
52
54
|
if (done) break;
|
|
53
55
|
buffer += decoder.decode(value, { stream: true });
|
|
56
|
+
// v1.1.4 F-18 (P1 Codex audit): normalize all SSE line terminators
|
|
57
|
+
// (CR, CRLF) to LF so the indexOf('\n\n') scan below catches every
|
|
58
|
+
// event boundary the spec allows. Without this, an upstream that
|
|
59
|
+
// emits CRLF (common in reverse-proxy paths) yielded a buffer that
|
|
60
|
+
// never matched and Shield silently lost the live enforcement loop.
|
|
61
|
+
buffer = normalizeSseBuffer(buffer);
|
|
54
62
|
|
|
55
63
|
// v1.1.2 F-16: guard against an upstream that never emits "\n\n" —
|
|
56
64
|
// throw to abort the stream cleanly, the caller's reconnect logic
|
|
@@ -60,8 +68,9 @@ export async function* openEventStream({ apiKey, sessionId, signal }) {
|
|
|
60
68
|
throw new Error(`SSE frame exceeded ${MAX_SSE_FRAME_BYTES} bytes — aborting stream (caller should reconnect)`);
|
|
61
69
|
}
|
|
62
70
|
|
|
63
|
-
// SSE frames are separated by a blank line
|
|
64
|
-
//
|
|
71
|
+
// SSE frames are separated by a blank line. Post-normalize, the
|
|
72
|
+
// canonical separator is "\n\n"; each frame may contain multiple
|
|
73
|
+
// lines; we only care about `data:` lines for now.
|
|
65
74
|
let nlIdx;
|
|
66
75
|
while ((nlIdx = buffer.indexOf('\n\n')) !== -1) {
|
|
67
76
|
const frame = buffer.slice(0, nlIdx);
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Shield → Fortress decision payload builder.
|
|
2
|
+
// v1.1.4 F-19 (P1 Codex audit): pure helper extracted from scripts/shield.js
|
|
3
|
+
// so the egress-side containment can be unit-tested in isolation. The
|
|
4
|
+
// security invariant under test:
|
|
5
|
+
//
|
|
6
|
+
// nothing on this payload may carry raw customer identifiers — tool
|
|
7
|
+
// names, session ids, event ids, input values must either be in the
|
|
8
|
+
// documented allowlist (vendor built-ins) or salted-hashed.
|
|
9
|
+
//
|
|
10
|
+
// Anything that can't be safely normalized is DROPPED (set to undefined)
|
|
11
|
+
// rather than passed through. Decision still ships so Fortress can count
|
|
12
|
+
// it — only the leak-y field is omitted.
|
|
13
|
+
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
import { normalizeToolName } from '../anonymizer.js';
|
|
16
|
+
|
|
17
|
+
// Salted SHA-256 hash with the same 32-char truncation as the anonymizer
|
|
18
|
+
// and the rest of Shield's decision flow. Returns null for nullish input
|
|
19
|
+
// or when no salt is configured (fail-safe omission).
|
|
20
|
+
function hashWithSaltOpt(value, salt) {
|
|
21
|
+
if (value == null || !salt) return null;
|
|
22
|
+
const s = typeof value === 'string' ? value : JSON.stringify(value);
|
|
23
|
+
return 'sha256:' + createHash('sha256').update(salt).update(s).digest('hex').slice(0, 32);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Extract the most relevant input value to fingerprint (URL > command >
|
|
27
|
+
// query > path > file_path). The actual hashing is done downstream by
|
|
28
|
+
// hashIoc — this function only picks the IoC field.
|
|
29
|
+
function pickInputForHash(input) {
|
|
30
|
+
if (!input || typeof input !== 'object') return null;
|
|
31
|
+
return input.url || input.command || input.query || input.path || input.file_path || null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build the body POSTed to Fortress's ingest-decisions endpoint.
|
|
36
|
+
*
|
|
37
|
+
* Containment guarantees:
|
|
38
|
+
* - tool_name: vendor allowlist returned in clear; custom/MCP names
|
|
39
|
+
* return as "tool_hash:<32hex>" when a salt is configured; dropped
|
|
40
|
+
* entirely otherwise.
|
|
41
|
+
* - session_hash / event_id_hash / input_hash: salted SHA-256, omitted
|
|
42
|
+
* when no salt is configured.
|
|
43
|
+
* - Raw payload values (rawEvent.input, normalized.input) never
|
|
44
|
+
* appear on the wire — only their hashes do.
|
|
45
|
+
*
|
|
46
|
+
* @param {object} opts
|
|
47
|
+
* @param {string} opts.agentId - Anthropic agent id (already an opaque token)
|
|
48
|
+
* @param {string} opts.sessionId - Anthropic session id (hashed before egress)
|
|
49
|
+
* @param {object} opts.rawEvent - raw upstream event (id field is hashed)
|
|
50
|
+
* @param {object} opts.normalized - normalized event from normalizeForPolicy()
|
|
51
|
+
* @param {object} opts.result - policy evaluator output
|
|
52
|
+
* @param {number} opts.decidedInMs
|
|
53
|
+
* @param {string|null|undefined} opts.signalsSalt
|
|
54
|
+
* @param {string} [opts.decidedAtIso] - ISO 8601 timestamp; defaults to now()
|
|
55
|
+
* @returns {object} payload ready to POST to ingest-decisions
|
|
56
|
+
*/
|
|
57
|
+
export function buildFortressDecisionPayload({
|
|
58
|
+
agentId, sessionId, rawEvent, normalized, result, decidedInMs,
|
|
59
|
+
signalsSalt, decidedAtIso,
|
|
60
|
+
}) {
|
|
61
|
+
// F-19: vendor built-ins survive even without a salt (allowlist short-circuit);
|
|
62
|
+
// custom tool names throw without a salt — we catch and drop the field.
|
|
63
|
+
let safeToolName;
|
|
64
|
+
const rawToolName = normalized?.tool_name;
|
|
65
|
+
if (rawToolName) {
|
|
66
|
+
try {
|
|
67
|
+
safeToolName = normalizeToolName(rawToolName, signalsSalt);
|
|
68
|
+
} catch {
|
|
69
|
+
safeToolName = undefined;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
anthropic_agent_id: agentId,
|
|
75
|
+
decision: result.decision,
|
|
76
|
+
rule_id: result.rule_id || undefined,
|
|
77
|
+
session_hash: hashWithSaltOpt(sessionId, signalsSalt) || undefined,
|
|
78
|
+
event_id_hash: hashWithSaltOpt(rawEvent?.id, signalsSalt) || undefined,
|
|
79
|
+
input_hash: hashWithSaltOpt(pickInputForHash(normalized?.input), signalsSalt) || undefined,
|
|
80
|
+
action_type: normalized?.action_type || undefined,
|
|
81
|
+
tool_name: safeToolName,
|
|
82
|
+
message: result.message || result.rule_name || undefined,
|
|
83
|
+
decided_at: decidedAtIso || new Date().toISOString(),
|
|
84
|
+
decided_in_ms: decidedInMs,
|
|
85
|
+
// v1.1.3 Phase 1.D: mode threading so Fortress can store and surface
|
|
86
|
+
// shadow-vs-enforce in the Reports timeline.
|
|
87
|
+
mode: result.mode || undefined,
|
|
88
|
+
};
|
|
89
|
+
}
|