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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "watchmyagents",
3
- "version": "1.1.3",
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
- // Extract the most relevant input value to hash (URL > command > query > path)
150
- const inp = normalized?.input;
151
- let inputForHash = null;
152
- if (inp && typeof inp === 'object') {
153
- inputForHash = inp.url || inp.command || inp.query || inp.path || inp.file_path || null;
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 ("\n\n").
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);
@@ -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
- // so a malicious or buggy pattern (e.g. `(a+)+$`) could pin the CPU on a long
73
- // input. We mitigate two ways:
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
- // A future v0.5 may add a proper safe-regex-2 dependency for thorough analysis.
80
- const MAX_REGEX_INPUT = 8192;
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
- if (src.length > 2000) {
92
- throw new Error(`policy ${where}: regex too long (>2000 chars)`);
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 { ok: true, policies: body.policies || [], fetched_at: body.fetched_at };
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
- // Compile + validate each policy. A single malformed/dangerous policy
195
- // (bad action, ReDoS-prone regex) must NOT take down the whole ruleset:
196
- // skip it, report it, keep the rest. This matters because policies come
197
- // from the cloud (Guardian-generated) they're not fully trusted input.
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 policies) {
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
+ }
@@ -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 ("\n\n"). Each frame may
64
- // contain multiple lines; we only care about `data:` lines for now.
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
+ }