watchmyagents 1.1.5 → 1.2.0
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/shield.js +21 -2
- package/scripts/upload-fortress.js +14 -0
- package/src/logger.js +36 -5
- package/src/shield/context.js +136 -0
- package/src/shield/decision-chain.js +186 -0
- package/src/shield/decisions.js +20 -1
- package/src/shield/policy.js +58 -4
- 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.
|
|
3
|
+
"version": "1.2.0",
|
|
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
|
}
|
package/scripts/shield.js
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
import { resolve } from 'node:path';
|
|
28
28
|
import { streamWithReconnect } from '../src/shield/stream.js';
|
|
29
29
|
import { loadPolicies, evaluate } from '../src/shield/policy.js';
|
|
30
|
+
import { createContextTracker } from '../src/shield/context.js';
|
|
30
31
|
import {
|
|
31
32
|
confirmAllow, confirmDeny, interruptSession,
|
|
32
33
|
getAgentConfig, detectAlwaysAsk,
|
|
@@ -155,6 +156,15 @@ async function runSessionWorker({ sessionId, ctx }) {
|
|
|
155
156
|
};
|
|
156
157
|
|
|
157
158
|
let processed = 0, enforced = 0, sessionInterrupted = false;
|
|
159
|
+
|
|
160
|
+
// v1.2.0 — per-session context tracker. Feeds runtime attributes
|
|
161
|
+
// (hour_of_day_utc, agent_age_minutes, recent_error_rate, …) into the
|
|
162
|
+
// policy engine so rules can express context-aware authorization (see
|
|
163
|
+
// src/shield/context.js + test/policy-context.test.js). Lives entirely
|
|
164
|
+
// in process memory; nothing is forwarded to Fortress — Containment
|
|
165
|
+
// doctrine preserved.
|
|
166
|
+
const policyTracker = createContextTracker({ recentWindowSize: 20 });
|
|
167
|
+
|
|
158
168
|
// Cache is only needed for tool_confirmation mode (lookup by event_id when
|
|
159
169
|
// requires_action fires). Interrupt mode evaluates synchronously and never
|
|
160
170
|
// reads the cache, so caching there would just leak memory on long sessions.
|
|
@@ -197,9 +207,14 @@ async function runSessionWorker({ sessionId, ctx }) {
|
|
|
197
207
|
if (mode === 'interrupt' && CACHEABLE_TOOL_TYPES.has(rawEvent.type)) {
|
|
198
208
|
// No caching in interrupt mode — react synchronously, free memory.
|
|
199
209
|
const normalized = normalizeForPolicy(rawEvent);
|
|
210
|
+
// v1.2.0 — compute policy context BEFORE evaluate so rules see
|
|
211
|
+
// PRIOR history (recent_error_rate, agent_age_minutes, …), then
|
|
212
|
+
// record AFTER so the next event sees this one in its window.
|
|
213
|
+
const policyCtx = policyTracker.compute(rawEvent);
|
|
200
214
|
const t0 = Date.now();
|
|
201
|
-
const result = evaluate(normalized, ctx.ruleset);
|
|
215
|
+
const result = evaluate(normalized, ctx.ruleset, policyCtx);
|
|
202
216
|
const decidedInMs = Date.now() - t0;
|
|
217
|
+
policyTracker.record(rawEvent);
|
|
203
218
|
|
|
204
219
|
// v1.1.3 Phase 1.D — mode badge in the log line for operator visibility.
|
|
205
220
|
const modeTag = result.mode === 'shadow' ? ' [SHADOW]' : '';
|
|
@@ -266,9 +281,13 @@ async function runSessionWorker({ sessionId, ctx }) {
|
|
|
266
281
|
}
|
|
267
282
|
|
|
268
283
|
const normalized = normalizeForPolicy(sourceEvent);
|
|
284
|
+
// v1.2.0 — see interrupt-mode block above for the compute /
|
|
285
|
+
// record ordering rationale.
|
|
286
|
+
const policyCtx = policyTracker.compute(sourceEvent);
|
|
269
287
|
const t0 = Date.now();
|
|
270
|
-
const result = evaluate(normalized, ctx.ruleset);
|
|
288
|
+
const result = evaluate(normalized, ctx.ruleset, policyCtx);
|
|
271
289
|
const decidedInMs = Date.now() - t0;
|
|
290
|
+
policyTracker.record(sourceEvent);
|
|
272
291
|
|
|
273
292
|
// v1.1.3 Phase 1.D — mode badge in the log line for operator visibility.
|
|
274
293
|
const modeTag = result.mode === 'shadow' ? ' [SHADOW]' : '';
|
|
@@ -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
|
|
@@ -28,7 +41,15 @@ export class Logger {
|
|
|
28
41
|
// Audit-grade default: refuse to silently lose events. Disk
|
|
29
42
|
// full / EACCES / EINVAL must propagate so callers know.
|
|
30
43
|
// Opt into bestEffort=true only for non-critical paths.
|
|
31
|
-
|
|
44
|
+
// `chain` : v1.2.0 — optional decision-chain instance (see
|
|
45
|
+
// src/shield/decision-chain.js). When set, every record
|
|
46
|
+
// written through this logger gets prev_hash + chain_hash
|
|
47
|
+
// appended, building a tamper-evident audit chain. Today
|
|
48
|
+
// this is only enabled by DecisionLogger (shield_decision
|
|
49
|
+
// rows). Watch's Loggers leave it null so Watch rows have
|
|
50
|
+
// no chain fields — verifyDecisionChain() filters by
|
|
51
|
+
// action_type, so both kinds of rows coexist cleanly.
|
|
52
|
+
constructor({ logDir, agentId, sessionId, silent, bestEffort, chain } = {}) {
|
|
32
53
|
// agentId becomes a filesystem path segment (logDir/<agentId>/…). Reject
|
|
33
54
|
// anything that could traverse out of logDir before we ever build a path.
|
|
34
55
|
assertSafePathSegment(agentId, 'agentId');
|
|
@@ -37,6 +58,7 @@ export class Logger {
|
|
|
37
58
|
this.sessionId = sessionId || randomUUID();
|
|
38
59
|
this.silent = silent !== false;
|
|
39
60
|
this.bestEffort = bestEffort === true;
|
|
61
|
+
this.chain = chain || null;
|
|
40
62
|
this.sequence = 0;
|
|
41
63
|
this.currentDay = null;
|
|
42
64
|
this.currentPath = null;
|
|
@@ -88,9 +110,18 @@ export class Logger {
|
|
|
88
110
|
input: e.input ?? null,
|
|
89
111
|
output: e.output ?? null,
|
|
90
112
|
};
|
|
113
|
+
// v1.2.0 — if a decision chain is attached, wrap the composed record
|
|
114
|
+
// so it carries prev_hash + chain_hash. The wrap is computed over the
|
|
115
|
+
// canonical body that ends up on disk; the verifier reproduces the
|
|
116
|
+
// same hash by reading the file. See src/shield/decision-chain.js.
|
|
117
|
+
const toWrite = this.chain ? this.chain.wrap(full) : full;
|
|
91
118
|
try {
|
|
92
|
-
|
|
93
|
-
await
|
|
119
|
+
const dir = join(this.logDir, this.agentId);
|
|
120
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
121
|
+
// v1.1.6 F-24: tighten existing perms — `mode` is creation-only.
|
|
122
|
+
await tightenMode(dir, 0o700);
|
|
123
|
+
await appendFile(path, JSON.stringify(toWrite) + '\n', { encoding: 'utf8', mode: 0o600 });
|
|
124
|
+
await tightenMode(path, 0o600);
|
|
94
125
|
this.count++;
|
|
95
126
|
} catch (err) {
|
|
96
127
|
if (!this.silent) process.stderr.write(`[wma] log write error: ${err.message}\n`);
|
|
@@ -98,7 +129,7 @@ export class Logger {
|
|
|
98
129
|
// Disk full, EACCES, EINVAL etc. should NOT be silently swallowed.
|
|
99
130
|
if (!this.bestEffort) throw err;
|
|
100
131
|
}
|
|
101
|
-
return
|
|
132
|
+
return toWrite;
|
|
102
133
|
}
|
|
103
134
|
|
|
104
135
|
toExportRecord(entry) {
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Shield context tracker — v1.2.0.
|
|
2
|
+
//
|
|
3
|
+
// Computes runtime attributes that policy rules can evaluate alongside
|
|
4
|
+
// the event payload itself. This closes the "context-aware authorization"
|
|
5
|
+
// gap called out in Anthropic's May 2026 agentic security framework
|
|
6
|
+
// (Part IV §Phase 4): a policy isn't just "what is the agent doing right
|
|
7
|
+
// now" but also "what hour is it, how long has this agent been running,
|
|
8
|
+
// has it been throwing a lot of errors lately".
|
|
9
|
+
//
|
|
10
|
+
// The tracker is stateful per session and lives in the Shield process. It
|
|
11
|
+
// holds only short summaries — no payloads. Containment is preserved:
|
|
12
|
+
// nothing here leaves the customer machine. See [[project_containment_architecture]].
|
|
13
|
+
//
|
|
14
|
+
// Computed attributes exposed via compute(event) → ctx:
|
|
15
|
+
// - hour_of_day_utc number 0..23
|
|
16
|
+
// - day_of_week_utc number 0..6 (Sunday=0)
|
|
17
|
+
// - agent_age_minutes minutes since the first event observed in this tracker
|
|
18
|
+
// - session_duration_ms milliseconds since the first event in this tracker
|
|
19
|
+
// - recent_error_rate fraction 0..1 over the trailing window (PRIOR events
|
|
20
|
+
// only — the current event isn't counted, so a rule
|
|
21
|
+
// like "deny if recent_error_rate > 0.5" reflects
|
|
22
|
+
// the agent's RECENT track record, not itself)
|
|
23
|
+
// - event_count_recent number of events in the trailing window
|
|
24
|
+
// - event_count_total total events seen by the tracker
|
|
25
|
+
//
|
|
26
|
+
// Usage:
|
|
27
|
+
// const tracker = createContextTracker({ recentWindowSize: 20 });
|
|
28
|
+
// for each event {
|
|
29
|
+
// const ctx = tracker.compute(event);
|
|
30
|
+
// const result = evaluate(event, ruleset, ctx);
|
|
31
|
+
// tracker.record(event, { isError: result.decision === 'deny' });
|
|
32
|
+
// }
|
|
33
|
+
//
|
|
34
|
+
// Order matters: compute() BEFORE evaluate(), record() AFTER. That way
|
|
35
|
+
// recent_error_rate describes the past, not the present, and the rule
|
|
36
|
+
// is self-consistent.
|
|
37
|
+
|
|
38
|
+
const DEFAULT_RECENT_WINDOW = 20;
|
|
39
|
+
|
|
40
|
+
function defaultIsError(event) {
|
|
41
|
+
// Heuristic: treat as error if the event carries an explicit error
|
|
42
|
+
// marker. We deliberately don't synthesize errors from missing fields
|
|
43
|
+
// — false negatives here just mean recent_error_rate underestimates,
|
|
44
|
+
// which is the safe direction. Callers can pass an explicit isError
|
|
45
|
+
// to record() to override.
|
|
46
|
+
if (!event || typeof event !== 'object') return false;
|
|
47
|
+
if (event.is_error === true) return true;
|
|
48
|
+
if (event.error != null) return true;
|
|
49
|
+
if (typeof event.type === 'string' && event.type.includes('error')) return true;
|
|
50
|
+
// tool_result with error content
|
|
51
|
+
if (Array.isArray(event.content)) {
|
|
52
|
+
for (const part of event.content) {
|
|
53
|
+
if (part && part.type === 'tool_result' && part.is_error === true) return true;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createContextTracker(options = {}) {
|
|
60
|
+
const recentWindowSize = options.recentWindowSize ?? DEFAULT_RECENT_WINDOW;
|
|
61
|
+
// Clock injection — Date.now is forbidden in some sandboxes (e.g.
|
|
62
|
+
// workflow scripts) and pinning is essential for testability. Callers
|
|
63
|
+
// can pass options.now = () => fixedEpoch to control the perceived time.
|
|
64
|
+
const now = options.now ?? (() => Date.now());
|
|
65
|
+
|
|
66
|
+
// v1.2.0: cap the window to avoid runaway memory if a caller passes
|
|
67
|
+
// something like 1_000_000. The realistic ceiling for "recent" is in
|
|
68
|
+
// the low thousands — anything bigger is no longer "recent" anyway.
|
|
69
|
+
if (!Number.isInteger(recentWindowSize) || recentWindowSize < 1 || recentWindowSize > 10_000) {
|
|
70
|
+
throw new Error(`createContextTracker: recentWindowSize must be an integer in [1, 10000], got ${recentWindowSize}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let firstSeenMs = null; // ms epoch of the first observation
|
|
74
|
+
let totalCount = 0; // total events seen
|
|
75
|
+
const recent = []; // ring buffer of booleans (true = error)
|
|
76
|
+
let errorsInWindow = 0; // O(1) sum so compute() stays fast
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
// Build a ctx object for the given event. Pure read — no state change.
|
|
80
|
+
// Pass an optional `at` (ms epoch) to override the clock for this
|
|
81
|
+
// call (rare; used by replay scenarios).
|
|
82
|
+
compute(event, at) {
|
|
83
|
+
const nowMs = Number.isFinite(at) ? at : now();
|
|
84
|
+
const wallClock = Number.isFinite(at) ? new Date(at) : new Date(nowMs);
|
|
85
|
+
const ageMs = firstSeenMs == null ? 0 : Math.max(0, nowMs - firstSeenMs);
|
|
86
|
+
const denom = recent.length;
|
|
87
|
+
return {
|
|
88
|
+
hour_of_day_utc: wallClock.getUTCHours(),
|
|
89
|
+
day_of_week_utc: wallClock.getUTCDay(),
|
|
90
|
+
agent_age_minutes: Math.floor(ageMs / 60_000),
|
|
91
|
+
session_duration_ms: ageMs,
|
|
92
|
+
recent_error_rate: denom === 0 ? 0 : errorsInWindow / denom,
|
|
93
|
+
event_count_recent: denom,
|
|
94
|
+
event_count_total: totalCount,
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
// Update tracker with the outcome of `event`. Call AFTER compute() +
|
|
99
|
+
// evaluate() for the current event so its outcome enters the window
|
|
100
|
+
// for the NEXT compute() call.
|
|
101
|
+
//
|
|
102
|
+
// Options:
|
|
103
|
+
// isError explicit error flag (boolean). When omitted, falls back
|
|
104
|
+
// to the default heuristic on the event itself.
|
|
105
|
+
record(event, options = {}) {
|
|
106
|
+
if (firstSeenMs == null) firstSeenMs = now();
|
|
107
|
+
totalCount += 1;
|
|
108
|
+
|
|
109
|
+
const isError = typeof options.isError === 'boolean'
|
|
110
|
+
? options.isError
|
|
111
|
+
: defaultIsError(event);
|
|
112
|
+
|
|
113
|
+
recent.push(isError);
|
|
114
|
+
if (isError) errorsInWindow += 1;
|
|
115
|
+
|
|
116
|
+
// Evict from the head until we're back at window size.
|
|
117
|
+
while (recent.length > recentWindowSize) {
|
|
118
|
+
const dropped = recent.shift();
|
|
119
|
+
if (dropped) errorsInWindow -= 1;
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
// Reset all state. Useful at the start of a fresh session, or for
|
|
124
|
+
// tests that want a clean tracker between cases.
|
|
125
|
+
reset() {
|
|
126
|
+
firstSeenMs = null;
|
|
127
|
+
totalCount = 0;
|
|
128
|
+
recent.length = 0;
|
|
129
|
+
errorsInWindow = 0;
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Exported for tests + callers who want the same heuristic without going
|
|
135
|
+
// through a tracker (e.g. one-off classification).
|
|
136
|
+
export { defaultIsError };
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Shield decision audit chain — v1.2.0.
|
|
2
|
+
//
|
|
3
|
+
// Tamper-evidence on the shield_decision NDJSON log. Each record gets two
|
|
4
|
+
// new fields:
|
|
5
|
+
//
|
|
6
|
+
// prev_hash — chain_hash of the previous record (or the genesis
|
|
7
|
+
// marker for the first record in this chain segment)
|
|
8
|
+
// chain_hash — sha256(prev_hash || canonical(record without these
|
|
9
|
+
// two fields))
|
|
10
|
+
//
|
|
11
|
+
// Why this matters (mapping to Anthropic's May 2026 framework, Part IV
|
|
12
|
+
// §Phase 6 "audit + forensics"):
|
|
13
|
+
//
|
|
14
|
+
// - Operational: after an incident the forensic question is "what did
|
|
15
|
+
// Shield decide, and was the log doctored". With the chain in place,
|
|
16
|
+
// any insertion / deletion / modification breaks the next record's
|
|
17
|
+
// prev_hash. A single broken link locates the tampering window.
|
|
18
|
+
// - Investigator workflow: replay the file through verifyChain() →
|
|
19
|
+
// either OK (every record's chain_hash matches the next record's
|
|
20
|
+
// prev_hash) or BROKEN at index N (everything after is suspect).
|
|
21
|
+
//
|
|
22
|
+
// Scope + limitations (v1.2.0):
|
|
23
|
+
//
|
|
24
|
+
// - Per-process chain. Each Shield restart begins a new chain segment
|
|
25
|
+
// with a fresh genesis. An NDJSON file may therefore contain MORE
|
|
26
|
+
// than one chain — that's deliberate and self-describing (the
|
|
27
|
+
// genesis marker carries process_id + start time). The verifier
|
|
28
|
+
// walks segments sequentially.
|
|
29
|
+
// - Soft tamper-evidence only: an attacker who can re-execute Shield
|
|
30
|
+
// can re-derive the chain from scratch. Strong tamper-evidence
|
|
31
|
+
// requires offloading to an append-only sink (Fortress + signed
|
|
32
|
+
// ingest) — tracked separately. This module is the LOCAL piece.
|
|
33
|
+
// - Hash: SHA-256 hex (32 bytes → 64 chars). Node built-in. We
|
|
34
|
+
// deliberately do NOT introduce a non-stdlib hash to preserve the
|
|
35
|
+
// zero-runtime-deps guarantee.
|
|
36
|
+
// - Canonicalization: reuses canonicalize() from signature.js — same
|
|
37
|
+
// rules as the Ed25519 chain-of-trust, so verifiers only need one
|
|
38
|
+
// serializer.
|
|
39
|
+
|
|
40
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
41
|
+
import { canonicalize } from './signature.js';
|
|
42
|
+
|
|
43
|
+
// The two link fields are EXCLUDED from the hashed body of a record —
|
|
44
|
+
// the hash of (R) cannot contain its own value, and prev_hash is
|
|
45
|
+
// already mixed into the digest input separately.
|
|
46
|
+
export const CHAIN_FIELDS = ['prev_hash', 'chain_hash'];
|
|
47
|
+
|
|
48
|
+
function sha256Hex(input) {
|
|
49
|
+
return createHash('sha256').update(input).digest('hex');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Strip the chain fields from a record before hashing. We do NOT mutate
|
|
53
|
+
// the caller's object — we return a shallow copy.
|
|
54
|
+
function bodyForHash(record) {
|
|
55
|
+
const out = {};
|
|
56
|
+
for (const k of Object.keys(record)) {
|
|
57
|
+
if (k === 'prev_hash' || k === 'chain_hash') continue;
|
|
58
|
+
out[k] = record[k];
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Build the digest input for a record: prev_hash followed by a single
|
|
64
|
+
// separator byte (chosen as `|` since it never appears in hex output and
|
|
65
|
+
// keeps the input human-inspectable), then the canonical body. The
|
|
66
|
+
// separator prevents an attacker from shifting bytes between the two
|
|
67
|
+
// inputs and re-deriving a collision.
|
|
68
|
+
function digestInput(prevHash, body) {
|
|
69
|
+
return prevHash + '|' + canonicalize(body);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Build a structured genesis marker. The verifier treats it as opaque
|
|
73
|
+
// (the marker only matters as the prev_hash of the first record), but
|
|
74
|
+
// the structure aids manual forensics: an investigator opening the
|
|
75
|
+
// NDJSON can tell which Shield process minted that chain.
|
|
76
|
+
//
|
|
77
|
+
// All three components are LOCAL identifiers — none of them is sensitive
|
|
78
|
+
// or correlatable to a customer account.
|
|
79
|
+
export function buildGenesisMarker({ agentId, sessionId, startedAtIso, chainId } = {}) {
|
|
80
|
+
const parts = [
|
|
81
|
+
'genesis',
|
|
82
|
+
agentId || 'unknown-agent',
|
|
83
|
+
sessionId || 'unknown-session',
|
|
84
|
+
startedAtIso || new Date(0).toISOString(), // caller injects a real timestamp
|
|
85
|
+
chainId || 'unknown-chain',
|
|
86
|
+
];
|
|
87
|
+
return parts.join(':');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Per-process chain state. wrap(body) returns a NEW object with the two
|
|
91
|
+
// link fields inserted at the END of the record (NDJSON readers tolerate
|
|
92
|
+
// either position, but appending keeps human diffs cleaner).
|
|
93
|
+
export function createDecisionChain({ genesis } = {}) {
|
|
94
|
+
if (typeof genesis !== 'string' || genesis.length === 0) {
|
|
95
|
+
throw new Error('createDecisionChain: genesis must be a non-empty string (use buildGenesisMarker)');
|
|
96
|
+
}
|
|
97
|
+
let prevHash = genesis;
|
|
98
|
+
let count = 0;
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
// Returns the chain-augmented record. Caller writes the returned
|
|
102
|
+
// object verbatim to NDJSON.
|
|
103
|
+
wrap(body) {
|
|
104
|
+
if (body == null || typeof body !== 'object' || Array.isArray(body)) {
|
|
105
|
+
throw new Error('decisionChain.wrap: body must be a plain object');
|
|
106
|
+
}
|
|
107
|
+
const chainHash = sha256Hex(digestInput(prevHash, bodyForHash(body)));
|
|
108
|
+
const out = { ...body, prev_hash: prevHash, chain_hash: chainHash };
|
|
109
|
+
prevHash = chainHash;
|
|
110
|
+
count += 1;
|
|
111
|
+
return out;
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
// Read-only inspection. Useful for tests + for shield.js to write a
|
|
115
|
+
// periodic "chain snapshot" line if we want one in v1.3.
|
|
116
|
+
state() {
|
|
117
|
+
return { prev_hash: prevHash, count };
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Walk an array of records (deserialized from NDJSON) and check that
|
|
123
|
+
// every record's chain_hash is reproducible from prev_hash + body, AND
|
|
124
|
+
// that record[i+1].prev_hash === record[i].chain_hash. Returns:
|
|
125
|
+
//
|
|
126
|
+
// { ok: true, count, segments } if intact
|
|
127
|
+
// { ok: false, broken_at: i, reason, count, segments } if tampered
|
|
128
|
+
//
|
|
129
|
+
// A "segment" is a contiguous run of records that share a chain. The
|
|
130
|
+
// first segment starts at the genesis marker derived from
|
|
131
|
+
// records[0].prev_hash; subsequent segments start at any record whose
|
|
132
|
+
// prev_hash does NOT match the previous record's chain_hash AND that
|
|
133
|
+
// looks like a genesis marker (starts with "genesis:"). That tolerance
|
|
134
|
+
// is what lets one NDJSON file hold the chains of multiple Shield runs.
|
|
135
|
+
//
|
|
136
|
+
// Anything OTHER than "valid step within the current chain" or "new
|
|
137
|
+
// segment starting at a genesis marker" is a tamper signal.
|
|
138
|
+
export function verifyDecisionChain(records) {
|
|
139
|
+
if (!Array.isArray(records)) {
|
|
140
|
+
return { ok: false, broken_at: -1, reason: 'records must be an array', count: 0, segments: 0 };
|
|
141
|
+
}
|
|
142
|
+
if (records.length === 0) {
|
|
143
|
+
return { ok: true, count: 0, segments: 0 };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let segmentCount = 0;
|
|
147
|
+
let runningPrev = null; // chain_hash of the most recently verified record
|
|
148
|
+
|
|
149
|
+
for (let i = 0; i < records.length; i++) {
|
|
150
|
+
const r = records[i];
|
|
151
|
+
if (r == null || typeof r !== 'object') {
|
|
152
|
+
return { ok: false, broken_at: i, reason: 'record is not an object', count: i, segments: segmentCount };
|
|
153
|
+
}
|
|
154
|
+
if (typeof r.prev_hash !== 'string' || typeof r.chain_hash !== 'string') {
|
|
155
|
+
return { ok: false, broken_at: i, reason: 'missing prev_hash or chain_hash', count: i, segments: segmentCount };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Is this the start of a new segment?
|
|
159
|
+
const isFirstInFile = i === 0;
|
|
160
|
+
const linksToPrev = !isFirstInFile && r.prev_hash === runningPrev;
|
|
161
|
+
const looksLikeGenesis = r.prev_hash.startsWith('genesis:');
|
|
162
|
+
|
|
163
|
+
if (!linksToPrev) {
|
|
164
|
+
if (!isFirstInFile && !looksLikeGenesis) {
|
|
165
|
+
return { ok: false, broken_at: i, reason: 'prev_hash mismatch (not a genesis marker either)', count: i, segments: segmentCount };
|
|
166
|
+
}
|
|
167
|
+
segmentCount += 1;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Recompute the chain_hash and compare.
|
|
171
|
+
const expected = sha256Hex(digestInput(r.prev_hash, bodyForHash(r)));
|
|
172
|
+
if (expected !== r.chain_hash) {
|
|
173
|
+
return { ok: false, broken_at: i, reason: 'chain_hash recomputation mismatch', count: i, segments: segmentCount };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
runningPrev = r.chain_hash;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { ok: true, count: records.length, segments: segmentCount };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Tiny convenience for shield.js / tests that want a fresh chain id
|
|
183
|
+
// without pulling crypto/randomUUID at the call site.
|
|
184
|
+
export function newChainId() {
|
|
185
|
+
return randomUUID();
|
|
186
|
+
}
|
package/src/shield/decisions.js
CHANGED
|
@@ -4,12 +4,31 @@
|
|
|
4
4
|
// file as Watch, with action_type: "shield_decision". This closes the
|
|
5
5
|
// recursive loop trivially — the next wma-fetch / wma-inspect run will
|
|
6
6
|
// surface Shield's actions alongside the agent's actions.
|
|
7
|
+
//
|
|
8
|
+
// v1.2.0 — every shield_decision row carries a SHA-256 audit chain
|
|
9
|
+
// (prev_hash + chain_hash). Watch rows in the same file carry no chain
|
|
10
|
+
// fields, so verifyDecisionChain() must be given the filtered subset
|
|
11
|
+
// `records.filter(r => r.action_type === 'shield_decision')`. See
|
|
12
|
+
// src/shield/decision-chain.js for the chain format + verifier.
|
|
7
13
|
|
|
8
14
|
import { Logger } from '../logger.js';
|
|
15
|
+
import { createDecisionChain, buildGenesisMarker, newChainId } from './decision-chain.js';
|
|
9
16
|
|
|
10
17
|
export class DecisionLogger {
|
|
11
18
|
constructor({ logDir, agentId, sessionId }) {
|
|
12
|
-
|
|
19
|
+
// Each DecisionLogger instance owns a single chain segment. A Shield
|
|
20
|
+
// restart creates a fresh DecisionLogger → fresh genesis. The
|
|
21
|
+
// genesis marker is self-describing (agent + session + start time +
|
|
22
|
+
// chain id) so forensics can attribute segments to processes.
|
|
23
|
+
const chain = createDecisionChain({
|
|
24
|
+
genesis: buildGenesisMarker({
|
|
25
|
+
agentId,
|
|
26
|
+
sessionId,
|
|
27
|
+
startedAtIso: new Date().toISOString(),
|
|
28
|
+
chainId: newChainId(),
|
|
29
|
+
}),
|
|
30
|
+
});
|
|
31
|
+
this._logger = new Logger({ logDir, agentId, sessionId, silent: true, chain });
|
|
13
32
|
}
|
|
14
33
|
|
|
15
34
|
// Record a decision Shield made about an upstream event. Shield's own
|
package/src/shield/policy.js
CHANGED
|
@@ -184,6 +184,44 @@ function matchValue(value, condition) {
|
|
|
184
184
|
if (condition._regex_any !== undefined) {
|
|
185
185
|
return condition._regex_any.some(r => safeRegexTest(r, value));
|
|
186
186
|
}
|
|
187
|
+
// v1.2.0 — DSL extensions for ABAC / parameter validation.
|
|
188
|
+
// Numeric comparators are strict: a non-finite VALUE or a non-finite
|
|
189
|
+
// CONDITION operand fails-closed. Same as the regex branch: we never
|
|
190
|
+
// coerce or guess intent for a malformed policy.
|
|
191
|
+
if (Number.isFinite(condition.gt)) {
|
|
192
|
+
return Number.isFinite(value) && value > condition.gt;
|
|
193
|
+
}
|
|
194
|
+
if (Number.isFinite(condition.gte)) {
|
|
195
|
+
return Number.isFinite(value) && value >= condition.gte;
|
|
196
|
+
}
|
|
197
|
+
if (Number.isFinite(condition.lt)) {
|
|
198
|
+
return Number.isFinite(value) && value < condition.lt;
|
|
199
|
+
}
|
|
200
|
+
if (Number.isFinite(condition.lte)) {
|
|
201
|
+
return Number.isFinite(value) && value <= condition.lte;
|
|
202
|
+
}
|
|
203
|
+
// in_range: tuple [min, max] inclusive both sides. An inverted tuple
|
|
204
|
+
// (min > max) fails-closed — most likely an operator typo, never a
|
|
205
|
+
// legitimate "match nothing" intent.
|
|
206
|
+
if (Array.isArray(condition.in_range) && condition.in_range.length === 2) {
|
|
207
|
+
const [min, max] = condition.in_range;
|
|
208
|
+
if (!Number.isFinite(min) || !Number.isFinite(max) || min > max) return false;
|
|
209
|
+
return Number.isFinite(value) && value >= min && value <= max;
|
|
210
|
+
}
|
|
211
|
+
// length_* operators apply to strings or arrays. Anything else (object,
|
|
212
|
+
// number, null) fails-closed — length is meaningless there.
|
|
213
|
+
if (Number.isFinite(condition.length_gt)) {
|
|
214
|
+
return (typeof value === 'string' || Array.isArray(value)) && value.length > condition.length_gt;
|
|
215
|
+
}
|
|
216
|
+
if (Number.isFinite(condition.length_gte)) {
|
|
217
|
+
return (typeof value === 'string' || Array.isArray(value)) && value.length >= condition.length_gte;
|
|
218
|
+
}
|
|
219
|
+
if (Number.isFinite(condition.length_lt)) {
|
|
220
|
+
return (typeof value === 'string' || Array.isArray(value)) && value.length < condition.length_lt;
|
|
221
|
+
}
|
|
222
|
+
if (Number.isFinite(condition.length_lte)) {
|
|
223
|
+
return (typeof value === 'string' || Array.isArray(value)) && value.length <= condition.length_lte;
|
|
224
|
+
}
|
|
187
225
|
// Unknown condition shape — defensive: fail-closed (no match) so unknown
|
|
188
226
|
// conditions never silently allow events.
|
|
189
227
|
return false;
|
|
@@ -192,18 +230,34 @@ function matchValue(value, condition) {
|
|
|
192
230
|
// Evaluate a single policy against an event. Returns true iff every match
|
|
193
231
|
// clause is satisfied. A match clause with an undefined target field still
|
|
194
232
|
// counts as "no match" rather than "any match".
|
|
195
|
-
|
|
233
|
+
//
|
|
234
|
+
// v1.2.0 — `ctx` is an optional second namespace for runtime-computed
|
|
235
|
+
// attributes (hour_of_day_utc, agent_age_minutes, recent_error_rate,
|
|
236
|
+
// session_duration_ms — see src/shield/context.js). Field paths in the
|
|
237
|
+
// `match` clause that start with the reserved prefix `ctx.` resolve from
|
|
238
|
+
// ctx rather than event. Everything else still resolves from event, so
|
|
239
|
+
// existing policies keep working unchanged.
|
|
240
|
+
//
|
|
241
|
+
// The `ctx.` prefix is a reserved namespace: WMA-normalized events never
|
|
242
|
+
// have a top-level `ctx` field (normalizeForPolicy never sets one), so
|
|
243
|
+
// there's no risk of shadowing. If a future Anthropic event shape ever
|
|
244
|
+
// has one, callers can rename their context attributes.
|
|
245
|
+
export function matchesPolicy(event, policy, ctx = {}) {
|
|
196
246
|
for (const [field, condition] of Object.entries(policy.match || {})) {
|
|
197
|
-
const value =
|
|
247
|
+
const value = field.startsWith('ctx.')
|
|
248
|
+
? getNested(ctx, field.slice(4))
|
|
249
|
+
: getNested(event, field);
|
|
198
250
|
if (!matchValue(value, condition)) return false;
|
|
199
251
|
}
|
|
200
252
|
return true;
|
|
201
253
|
}
|
|
202
254
|
|
|
203
255
|
// First-match-wins evaluation. Returns the policy decision and metadata.
|
|
204
|
-
|
|
256
|
+
// v1.2.0 — accepts an optional `ctx` for runtime-computed attributes;
|
|
257
|
+
// see matchesPolicy() above. Omitting ctx preserves v1.1.x behavior.
|
|
258
|
+
export function evaluate(event, ruleset, ctx = {}) {
|
|
205
259
|
for (const policy of ruleset.policies) {
|
|
206
|
-
if (matchesPolicy(event, policy)) {
|
|
260
|
+
if (matchesPolicy(event, policy, ctx)) {
|
|
207
261
|
return {
|
|
208
262
|
decision: policy.action,
|
|
209
263
|
rule_id: policy.id || null,
|
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 = [];
|