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 +19 -3
- package/package.json +1 -1
- package/scripts/fetch-anthropic.js +10 -0
- package/scripts/upload-fortress.js +14 -0
- package/src/logger.js +19 -2
- 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/src/shield/stream.js +77 -1
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.
|
|
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.
|
|
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
|
-
|
|
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`);
|
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.
|
package/src/shield/stream.js
CHANGED
|
@@ -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
|
-
|
|
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 = [];
|