watchmyagents 1.1.4 → 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/src/shield/policy.js +7 -0
- package/src/shield/root-key.js +49 -0
- package/src/shield/signature.js +259 -0
- package/src/shield/sources/fortress.js +110 -9
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/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
|
|
@@ -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.
|