watchmyagents 1.1.4 → 1.1.6

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/SECURITY.md CHANGED
@@ -11,13 +11,30 @@ WMA needs your Anthropic API key to call the Managed Agents REST API on your beh
11
11
  | Property | Behavior |
12
12
  |---|---|
13
13
  | **Source** | Environment variable `ANTHROPIC_API_KEY` or `--api-key` CLI flag |
14
- | **Storage** | Held in process memory for the duration of a `wma-fetch` run. Never persisted to disk by WMA. |
14
+ | **Storage (CLI mode)** | Held in process memory for the duration of a `wma-fetch` run. Not persisted to disk. |
15
+ | **Storage (service mode)** | Persisted to `~/.watchmyagents/env` with mode `0600` (user-only read/write) so the launchd / systemd unit can restart the daemon without prompting. See [Service-mode credentials](#service-mode-credentials) below for the exact layout. |
15
16
  | **Network** | Sent only to `api.anthropic.com` over HTTPS with strict certificate verification (`rejectUnauthorized: true`) |
16
17
  | **Logging** | The key is never written to NDJSON logs, never printed in error messages, never included in any export |
17
18
  | **Telemetry** | WMA performs zero telemetry today. No phone-home, no usage reporting. |
18
19
 
19
20
  **Recommendation:** generate a workspace-scoped API key with read-only permissions on the agents you want to monitor. See [Anthropic Console → API Keys](https://console.anthropic.com/settings/keys).
20
21
 
22
+ #### Service-mode credentials
23
+
24
+ When you run `wma-service install`, WMA registers a launchd (macOS) or systemd (Linux) unit that needs to restart the watch daemon across reboots without human intervention. The daemon's environment is therefore loaded from a small file owned by your user account:
25
+
26
+ | Path | Mode | Owner | Contents |
27
+ |---|---|---|---|
28
+ | `~/.watchmyagents/` | `0700` | your user | Holds the env file and the launcher shell script |
29
+ | `~/.watchmyagents/env` | `0600` | your user | `KEY=value` lines for `ANTHROPIC_API_KEY`, `WMA_API_KEY`, `WMA_SIGNALS_SALT`, `WMA_FORTRESS_BASE_URL` |
30
+ | `~/.watchmyagents/<label>.launcher.sh` | `0600` | your user | Reads the env file with a literal `read -r` loop (no shell interpolation), then `exec`s `wma-fetch`. |
31
+
32
+ Hardening notes:
33
+ - The launcher loads secrets with `while IFS='=' read -r k v` instead of `. file` / `source file`. Sourcing would shell-evaluate every value, so a value containing `$(cmd)` would execute at every restart. The literal read assigns the bytes verbatim.
34
+ - Values are validated before write: a newline anywhere in a credential aborts the install (would corrupt the env file or inject extra lines).
35
+ - To wipe the credential without uninstalling the service: `chmod 600 ~/.watchmyagents/env && : > ~/.watchmyagents/env` (the daemon will exit on the next missing-env check).
36
+ - Full removal: `wma-service uninstall` deletes the unit, the launcher, the env file, and the `~/.watchmyagents` directory.
37
+
21
38
  ### Local log files
22
39
 
23
40
  `wma-fetch` writes NDJSON files to `./watchmyagents-logs/<agent_id>/<date>.ndjson` with the following protections:
@@ -73,8 +90,7 @@ WMA combines **two complementary layers**:
73
90
  ## Supply chain
74
91
 
75
92
  - All code is open source on [GitHub](https://github.com/minedorfbm/watchmyagents)
76
- - Zero runtime dependencies (uses Node.js 18+ built-ins only)
77
- - One dev dependency (`@anthropic-ai/sdk`) for the optional adapter examples
93
+ - Zero runtime AND dev dependencies (uses Node.js 18+ built-ins only). Run `npm ls --omit=dev` or check `package.json#dependencies` / `devDependencies` — both are empty.
78
94
  - Future releases will use `npm publish --provenance` for SLSA build attestation
79
95
 
80
96
  ## Reporting a vulnerability
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "watchmyagents",
3
- "version": "1.1.4",
3
+ "version": "1.1.6",
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": [
@@ -91,6 +91,11 @@ function resolveModel(agent) {
91
91
  // is misconfigured or compromised; abort.
92
92
  const MAX_FORTRESS_RESPONSE_BYTES = 1 * 1024 * 1024;
93
93
 
94
+ // v1.1.6 F-22 (P2 Codex audit): hard ceiling on a Fortress POST round
95
+ // trip. Same rationale as in scripts/upload-fortress.js — without this,
96
+ // a slow/unresponsive endpoint hangs the daemon's session loop.
97
+ const FORTRESS_REQUEST_TIMEOUT_MS = 30_000;
98
+
94
99
  function postJson(url, headers, body) {
95
100
  return new Promise((resolveReq, rejectReq) => {
96
101
  const u = new URL(url);
@@ -125,6 +130,11 @@ function postJson(url, headers, body) {
125
130
  });
126
131
  });
127
132
  req.on('error', rejectReq);
133
+ // v1.1.6 F-22: bound the round trip so a non-responding endpoint
134
+ // can't hang the watch daemon's upload loop.
135
+ req.setTimeout(FORTRESS_REQUEST_TIMEOUT_MS, () => {
136
+ req.destroy(new Error(`Fortress request timed out after ${FORTRESS_REQUEST_TIMEOUT_MS}ms`));
137
+ });
128
138
  req.write(data); req.end();
129
139
  });
130
140
  }
@@ -68,6 +68,16 @@ async function collectFiles(p) {
68
68
  // against a compromised or misconfigured response.
69
69
  const MAX_FORTRESS_RESPONSE_BYTES = 1 * 1024 * 1024;
70
70
 
71
+ // v1.1.6 F-22 (P2 Codex audit): hard ceiling on a Fortress POST round
72
+ // trip. Mirrors the timeout already enforced in src/shield/sources/
73
+ // fortress.js. Without this, a Fortress endpoint that accepts the TCP
74
+ // connection but stops responding mid-stream would hang the upload
75
+ // daemon indefinitely — same SLOWLORIS class of bug F-21 fixes for
76
+ // SSE reads. 30 s is plenty for a small JSON POST (signals payloads
77
+ // are kilobytes); legitimate Fortress nodes reply in <1 s in steady
78
+ // state.
79
+ const FORTRESS_REQUEST_TIMEOUT_MS = 30_000;
80
+
71
81
  function postJson(url, headers, body) {
72
82
  return new Promise((resolveReq, rejectReq) => {
73
83
  const u = new URL(url);
@@ -114,6 +124,10 @@ function postJson(url, headers, body) {
114
124
  }
115
125
  );
116
126
  req.on('error', rejectReq);
127
+ // v1.1.6 F-22: ensure a non-responding endpoint can't hang the upload.
128
+ req.setTimeout(FORTRESS_REQUEST_TIMEOUT_MS, () => {
129
+ req.destroy(new Error(`Fortress request timed out after ${FORTRESS_REQUEST_TIMEOUT_MS}ms`));
130
+ });
117
131
  req.write(data);
118
132
  req.end();
119
133
  });
package/src/logger.js CHANGED
@@ -1,8 +1,21 @@
1
- import { mkdir, appendFile } from 'node:fs/promises';
1
+ import { mkdir, appendFile, chmod } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { assertSafePathSegment } from './validate.js';
5
5
 
6
+ // v1.1.6 F-24 (P3 Codex audit): the `mode` option on mkdir() and
7
+ // appendFile() is only honored when the directory or file is CREATED.
8
+ // If a previous wma-fetch run, a different user, or a hand-rolled mkdir
9
+ // already left those paths around with the system umask (typically
10
+ // 0755 / 0644), the original constructor would silently keep the loose
11
+ // perms even though SECURITY.md promises 0700 / 0600. tightenMode runs
12
+ // after every mkdir / append to bring the existing inode in line with
13
+ // the docs. Errors are swallowed (best-effort): the chmod is a hardening
14
+ // pass, not a precondition — failing it shouldn't break logging.
15
+ async function tightenMode(path, mode) {
16
+ try { await chmod(path, mode); } catch { /* not fatal */ }
17
+ }
18
+
6
19
  // PR-B: `framework` → `provider` (canonical name per src/sources/contract.js).
7
20
  // PR-C: adds `parent_agent_id` + `composition_pattern` so any future
8
21
  // adapter that knows the hierarchy (OpenAI Agents handoffs, CrewAI
@@ -89,8 +102,12 @@ export class Logger {
89
102
  output: e.output ?? null,
90
103
  };
91
104
  try {
92
- await mkdir(join(this.logDir, this.agentId), { recursive: true, mode: 0o700 });
105
+ const dir = join(this.logDir, this.agentId);
106
+ await mkdir(dir, { recursive: true, mode: 0o700 });
107
+ // v1.1.6 F-24: tighten existing perms — `mode` is creation-only.
108
+ await tightenMode(dir, 0o700);
93
109
  await appendFile(path, JSON.stringify(full) + '\n', { encoding: 'utf8', mode: 0o600 });
110
+ await tightenMode(path, 0o600);
94
111
  this.count++;
95
112
  } catch (err) {
96
113
  if (!this.silent) process.stderr.write(`[wma] log write error: ${err.message}\n`);
@@ -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
@@ -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.
@@ -21,6 +21,20 @@ const VERSION = '2023-06-01';
21
21
  // reconnect logic — same outcome as a network error.
22
22
  const MAX_SSE_FRAME_BYTES = 1 * 1024 * 1024;
23
23
 
24
+ // v1.1.6 F-21 (P1 Codex audit): inactivity watchdog on the SSE reader.
25
+ // `reader.read()` blocks until the upstream sends bytes — there is no
26
+ // built-in heartbeat check. A misbehaving proxy or compromised upstream
27
+ // can keep the TCP connection open without ever emitting another event,
28
+ // which freezes Shield indefinitely without triggering the reconnect
29
+ // path in streamWithReconnect. 45 s is well above Anthropic's normal
30
+ // inter-event latency (typically sub-second when an agent is active,
31
+ // and the API sends SSE comment heartbeats `: ping` every ~15-30 s
32
+ // when it's idle), so 45 s without any byte at all is a strong signal
33
+ // the stream is dead but TCP-alive. On timeout we throw, the existing
34
+ // try/finally releases the reader, and the caller reconnects with
35
+ // exponential backoff.
36
+ const SSE_INACTIVITY_TIMEOUT_MS = 45_000;
37
+
24
38
  function authHeaders(apiKey) {
25
39
  return {
26
40
  'x-api-key': apiKey,
@@ -50,7 +64,16 @@ export async function* openEventStream({ apiKey, sessionId, signal }) {
50
64
 
51
65
  try {
52
66
  while (true) {
53
- const { done, value } = await reader.read();
67
+ // v1.1.6 F-21: race the reader against the inactivity watchdog so
68
+ // a stalled-but-open stream cannot freeze us indefinitely. We
69
+ // cancel the reader on timeout to release the underlying TCP
70
+ // resources before throwing — otherwise the pending read() would
71
+ // leak. The thrown error propagates to streamWithReconnect which
72
+ // initiates a fresh open with backoff.
73
+ const { done, value } = await readWithInactivityTimeout(
74
+ reader,
75
+ SSE_INACTIVITY_TIMEOUT_MS,
76
+ );
54
77
  if (done) break;
55
78
  buffer += decoder.decode(value, { stream: true });
56
79
  // v1.1.4 F-18 (P1 Codex audit): normalize all SSE line terminators
@@ -89,6 +112,59 @@ export async function* openEventStream({ apiKey, sessionId, signal }) {
89
112
  }
90
113
  }
91
114
 
115
+ /**
116
+ * Race a ReadableStreamDefaultReader's `.read()` against an inactivity
117
+ * timeout. v1.1.6 F-21 (P1 Codex audit).
118
+ *
119
+ * Why this exists: a TCP-alive but byte-silent upstream (proxy with
120
+ * keepalive but no SSE heartbeat, compromised endpoint, slowloris-style
121
+ * stall) can leave `reader.read()` pending forever, freezing Shield's
122
+ * event loop. Bounding the wait surfaces the stall as an error so
123
+ * `streamWithReconnect` initiates a fresh connection.
124
+ *
125
+ * Exported so the unit tests can hit it directly with a mock reader
126
+ * that never resolves.
127
+ *
128
+ * @param {ReadableStreamDefaultReader} reader
129
+ * @param {number} timeoutMs
130
+ * @returns {Promise<{ done: boolean, value?: Uint8Array }>}
131
+ */
132
+ export async function readWithInactivityTimeout(reader, timeoutMs) {
133
+ // We deliberately avoid Promise.race here: racing two pending promises
134
+ // leaves the loser in a perpetual pending state, which Node's test
135
+ // runner (rightly) flags as a resource leak. Instead we use the
136
+ // Web Streams contract — `reader.cancel()` settles a pending read()
137
+ // to `{ done: true }` — and a simple flag to distinguish the cancel
138
+ // we issued from a legitimate end-of-stream.
139
+ let timedOut = false;
140
+ const timeoutId = setTimeout(() => {
141
+ timedOut = true;
142
+ // Cancel resolves the pending read() promise. We swallow any
143
+ // rejection from cancel (some readers throw on already-cancelled
144
+ // state) because the relevant error is the timeout itself.
145
+ Promise.resolve(reader.cancel(new Error('SSE inactivity timeout')))
146
+ .catch(() => undefined);
147
+ }, timeoutMs);
148
+ // NOTE: we deliberately do NOT call timeoutId.unref() here. The
149
+ // unref() would make the timer non-blocking for the event loop, but
150
+ // for a Web Stream backed by no actual I/O (e.g. unit tests, in-memory
151
+ // sources) Node may then consider the loop empty and never fire the
152
+ // timer at all — the call hangs forever. Since the timer is short-
153
+ // lived (single-shot, cleared on the success path), keeping it ref'd
154
+ // is harmless even in long-running daemons.
155
+ try {
156
+ const result = await reader.read();
157
+ if (timedOut) {
158
+ throw new Error(
159
+ `SSE stream stalled — no data received for ${timeoutMs}ms (caller should reconnect)`,
160
+ );
161
+ }
162
+ return result;
163
+ } finally {
164
+ clearTimeout(timeoutId);
165
+ }
166
+ }
167
+
92
168
  function parseFrame(frame) {
93
169
  // Concatenate all `data:` lines per the SSE spec (multi-line payload).
94
170
  const parts = [];