watchmyagents 1.1.6 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SECURITY.md CHANGED
@@ -81,7 +81,7 @@ WMA combines **two complementary layers**:
81
81
  ### What WMA does NOT defend against
82
82
 
83
83
  - **A compromised host.** If an attacker has read access to your user account, they can read the log files. Consider encryption at rest (filesystem-level, or future opt-in via `age`) for sensitive environments.
84
- - **Tampering with local logs.** Files are append-only by convention, not enforced. A future release will add a per-line hash chain for tamper-evident audit.
84
+ - **Tampering with local logs (full coverage).** As of v1.2.0, `shield_decision` rows carry a SHA-256 hash chain (`prev_hash` + `chain_hash`) — `verifyDecisionChain()` detects in-place modification, mid-chain deletion, and insertion. This is **local tamper-evidence**, not tamper-proof: (a) tail truncation (removing the most recent rows) is invisible to an append-only chain, and (b) an attacker with the Shield binary can mint a fresh chain from scratch by re-executing. Both gaps are closed by Fortress-side append-only ingest (planned). Watch's non-decision rows (tool_use, etc.) remain append-only-by-convention and are NOT chained.
85
85
  - **Shield being killed.** Shield is an external process. If killed, the agent runs without enforcement until Shield restarts. Run under a process supervisor (systemd, pm2, docker `restart: always`) in production.
86
86
  - **Pre-installation activity.** Shield only enforces from the moment it attaches forward. Past events are not retroactively replayed or re-evaluated.
87
87
  - **A malicious policy file.** Shield's policy engine refuses obviously unsafe regex patterns (e.g. catastrophic backtracking) and truncates inputs before regex tests to mitigate ReDoS. But a user-controlled policy file remains a code-adjacent input — treat it as you would treat sourcecode.
@@ -0,0 +1,133 @@
1
+ {
2
+ "_comment_doc": "WatchMyAgents starter policy bundle — MITRE ATT&CK + ATLAS coverage. Tune to your environment before promoting from mode=shadow → mode=enforce. See docs/MITRE-ATTCK-COVERAGE.md for technique mapping. Every policy ships in shadow mode so the first run only LOGS would-be decisions without blocking — calibrate, then promote.",
3
+ "_comment_lifecycle": "Lifecycle: observe → shadow → enforce → retired. Per [[project_recursive_fractal_loop]], shadow is the calibration bench (Platt scaling + diff-in-diff efficacy). Promote a rule to mode=enforce only after you've seen enough shadow decisions to be confident the rule isn't false-positiving on your workload.",
4
+ "_comment_uses": "Uses v1.2.0+ DSL extensions (gt, in_range, length_gt) and ctx.* namespace (hour_of_day_utc, recent_error_rate, event_count_recent). Falls back gracefully on v1.1.x where unknown operators / ctx paths fail-closed (no false-allow).",
5
+
6
+ "policies": [
7
+ {
8
+ "id": "mitre-t1567-exfil-large-post",
9
+ "name": "T1567 Exfiltration Over Web Service — large outbound POST",
10
+ "action": "deny",
11
+ "mode": "shadow",
12
+ "message": "MITRE T1567: outbound POST exceeds 1MB threshold. Likely exfil channel.",
13
+ "match": {
14
+ "action_type": "tool_use",
15
+ "tool_name": { "in": ["web_fetch", "http_post"] },
16
+ "input.method": "POST",
17
+ "input.bytes": { "gt": 1000000 }
18
+ }
19
+ },
20
+
21
+ {
22
+ "id": "mitre-t1041-c2-host-allowlist",
23
+ "name": "T1041 Exfiltration Over C2 Channel — host allowlist",
24
+ "action": "deny",
25
+ "mode": "shadow",
26
+ "message": "MITRE T1041: outbound to non-allowlisted host. Replace the regex with your trusted egress hosts.",
27
+ "match": {
28
+ "action_type": "tool_use",
29
+ "tool_name": { "in": ["web_fetch", "http_post"] },
30
+ "input.url": { "not_regex": "^https://(api\\.openai\\.com|api\\.anthropic\\.com|github\\.com|api\\.github\\.com|raw\\.githubusercontent\\.com|wikipedia\\.org|en\\.wikipedia\\.org)(/|$|:[0-9]+)" }
31
+ }
32
+ },
33
+
34
+ {
35
+ "id": "mitre-t1485-data-destruction",
36
+ "name": "T1485 Data Destruction — destructive shell patterns",
37
+ "action": "deny",
38
+ "mode": "shadow",
39
+ "message": "MITRE T1485: destructive shell pattern (rm -rf / mkfs / dd if=).",
40
+ "match": {
41
+ "tool_name": "bash",
42
+ "input.command": { "regex": "(^|\\s|;|&&|\\|\\|)(rm\\s+-rf\\s|mkfs[. ]|dd\\s+if=)" }
43
+ }
44
+ },
45
+
46
+ {
47
+ "id": "mitre-t1548-privilege-escalation",
48
+ "name": "T1548 Abuse Elevation Control — sudo / pkexec / doas",
49
+ "action": "interrupt",
50
+ "mode": "shadow",
51
+ "message": "MITRE T1548: privilege-escalation attempt via sudo / pkexec / doas.",
52
+ "match": {
53
+ "tool_name": "bash",
54
+ "input.command": { "regex": "(^|\\s|;|&&|\\|\\|)(sudo|pkexec|doas)\\s" }
55
+ }
56
+ },
57
+
58
+ {
59
+ "id": "mitre-t1059-bash-command-length",
60
+ "name": "T1059 Command and Scripting Interpreter — improbably long command",
61
+ "action": "deny",
62
+ "mode": "shadow",
63
+ "message": "MITRE T1059: bash command exceeds 2KB — likely a base64-encoded payload or pipeline obfuscation.",
64
+ "match": {
65
+ "tool_name": "bash",
66
+ "input.command": { "length_gt": 2048 }
67
+ }
68
+ },
69
+
70
+ {
71
+ "id": "mitre-t1083-after-hours-discovery",
72
+ "name": "T1083 File and Directory Discovery — outside business hours (late)",
73
+ "action": "interrupt",
74
+ "mode": "shadow",
75
+ "message": "MITRE T1083: file discovery between 22:00 and 23:59 UTC. Tune for your business hours.",
76
+ "match": {
77
+ "tool_name": { "in": ["bash", "file_list"] },
78
+ "ctx.hour_of_day_utc": { "in_range": [22, 23] }
79
+ }
80
+ },
81
+
82
+ {
83
+ "id": "mitre-t1083-after-hours-discovery-early",
84
+ "name": "T1083 File and Directory Discovery — outside business hours (early)",
85
+ "action": "interrupt",
86
+ "mode": "shadow",
87
+ "message": "MITRE T1083: file discovery between 00:00 and 06:59 UTC. Tune for your business hours.",
88
+ "match": {
89
+ "tool_name": { "in": ["bash", "file_list"] },
90
+ "ctx.hour_of_day_utc": { "in_range": [0, 6] }
91
+ }
92
+ },
93
+
94
+ {
95
+ "id": "mitre-t1020-flailing-agent-throttle",
96
+ "name": "T1020 Automated Exfiltration — throttle flailing agent (high error rate)",
97
+ "action": "interrupt",
98
+ "mode": "shadow",
99
+ "message": "MITRE T1020: agent recent_error_rate > 0.5 across last >= 4 events — likely a runaway loop or retry storm.",
100
+ "match": {
101
+ "action_type": "tool_use",
102
+ "ctx.recent_error_rate": { "gt": 0.5 },
103
+ "ctx.event_count_recent": { "gte": 4 }
104
+ }
105
+ },
106
+
107
+ {
108
+ "id": "mitre-t1053-cron-persistence",
109
+ "name": "T1053 Scheduled Task/Job — cron / launchd persistence",
110
+ "action": "deny",
111
+ "mode": "shadow",
112
+ "message": "MITRE T1053: agent attempted to write a persistent task. Possible C2 persistence.",
113
+ "match": {
114
+ "tool_name": { "in": ["bash", "file_write"] },
115
+ "input.path": { "regex": "(crontab|/etc/cron\\.[a-z]+/|\\.crontab|/Library/LaunchAgents/|/Library/LaunchDaemons/|\\.service$|/etc/systemd/system/)" }
116
+ }
117
+ },
118
+
119
+ {
120
+ "id": "mitre-t1552-credential-store-read",
121
+ "name": "T1552 Unsecured Credentials — read of well-known credential store",
122
+ "action": "deny",
123
+ "mode": "shadow",
124
+ "message": "MITRE T1552: agent attempted to read a credential store. Tighten the path regex for your environment.",
125
+ "match": {
126
+ "tool_name": { "in": ["bash", "file_read"] },
127
+ "input.path": { "regex": "(/\\.aws/credentials|/\\.aws/config|/\\.ssh/id_[a-z]+|/\\.netrc|Library/Keychains|/etc/shadow)" }
128
+ }
129
+ }
130
+ ],
131
+
132
+ "default": { "action": "allow" }
133
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "watchmyagents",
3
- "version": "1.1.6",
3
+ "version": "1.2.1",
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": [
@@ -12,6 +12,7 @@
12
12
  "scripts/upload-fortress.js",
13
13
  "scripts/service.js",
14
14
  "scripts/agents.js",
15
+ "examples/policies/",
15
16
  "README.md",
16
17
  "SECURITY.md",
17
18
  "LICENSE"
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.
@@ -193,12 +203,34 @@ async function runSessionWorker({ sessionId, ctx }) {
193
203
  })) {
194
204
  processed++;
195
205
 
206
+ // v1.2.1 F-26 (P1 Codex audit on v1.2.0): record EVERY event
207
+ // flowing through Shield, not just the cacheable tool_use ones.
208
+ // The previous wiring only called record() inside the cacheable
209
+ // branches, so events that DO carry the is_error / error / content
210
+ // tool_result markers (which arrive as a DIFFERENT event type
211
+ // from the upstream tool_use) never reached the tracker. Result
212
+ // in production: ctx.recent_error_rate stayed pinned at 0 even
213
+ // when the agent's tools were failing constantly, making the
214
+ // "throttle a flailing agent" policy class effectively a no-op.
215
+ //
216
+ // Implementation note: try/finally guarantees record() runs even
217
+ // when the cacheable branches `continue;` out mid-iteration. The
218
+ // finally executes BEFORE the continue takes effect (JS spec), so
219
+ // the compute() calls below — which must see PRIOR history only —
220
+ // still observe the correct window.
221
+ try {
222
+
196
223
  // ── INTERRUPT MODE ──────────────────────────────────────────────
197
224
  if (mode === 'interrupt' && CACHEABLE_TOOL_TYPES.has(rawEvent.type)) {
198
225
  // No caching in interrupt mode — react synchronously, free memory.
199
226
  const normalized = normalizeForPolicy(rawEvent);
227
+ // v1.2.0 — compute policy context BEFORE evaluate so rules see
228
+ // PRIOR history (recent_error_rate, agent_age_minutes, …). The
229
+ // finally at the end of this iteration records the event so the
230
+ // NEXT iteration sees this one in its window.
231
+ const policyCtx = policyTracker.compute(rawEvent);
200
232
  const t0 = Date.now();
201
- const result = evaluate(normalized, ctx.ruleset);
233
+ const result = evaluate(normalized, ctx.ruleset, policyCtx);
202
234
  const decidedInMs = Date.now() - t0;
203
235
 
204
236
  // v1.1.3 Phase 1.D — mode badge in the log line for operator visibility.
@@ -266,8 +298,14 @@ async function runSessionWorker({ sessionId, ctx }) {
266
298
  }
267
299
 
268
300
  const normalized = normalizeForPolicy(sourceEvent);
301
+ // v1.2.0 — see interrupt-mode block above for the compute /
302
+ // record ordering rationale. sourceEvent here was the original
303
+ // tool_use rawEvent at an earlier loop iteration, and was
304
+ // recorded by THAT iteration's finally — so compute() correctly
305
+ // sees it as PRIOR history, not as the current event.
306
+ const policyCtx = policyTracker.compute(sourceEvent);
269
307
  const t0 = Date.now();
270
- const result = evaluate(normalized, ctx.ruleset);
308
+ const result = evaluate(normalized, ctx.ruleset, policyCtx);
271
309
  const decidedInMs = Date.now() - t0;
272
310
 
273
311
  // v1.1.3 Phase 1.D — mode badge in the log line for operator visibility.
@@ -331,6 +369,21 @@ async function runSessionWorker({ sessionId, ctx }) {
331
369
  sinfo(sessionId, `session terminated: ${rawEvent.stop_reason?.type || 'unknown'}`);
332
370
  break;
333
371
  }
372
+
373
+ } finally {
374
+ // v1.2.1 F-26: record EVERY event into the policy tracker so
375
+ // recent_error_rate reflects real tool failures (which arrive
376
+ // as tool_result events carrying is_error: true, NOT as the
377
+ // upstream tool_use events). defaultIsError() inside the
378
+ // tracker handles the is_error / error / content[].is_error /
379
+ // type-contains-'error' shapes — see src/shield/context.js.
380
+ //
381
+ // Order: every compute() call above happens BEFORE this
382
+ // record() (JS guarantees finally runs after try-body but
383
+ // before continue/break take effect), so the "PRIOR history"
384
+ // contract of recent_error_rate is preserved.
385
+ policyTracker.record(rawEvent);
386
+ }
334
387
  }
335
388
  } catch (e) {
336
389
  if (!signal.aborted) {
package/src/logger.js CHANGED
@@ -41,7 +41,15 @@ export class Logger {
41
41
  // Audit-grade default: refuse to silently lose events. Disk
42
42
  // full / EACCES / EINVAL must propagate so callers know.
43
43
  // Opt into bestEffort=true only for non-critical paths.
44
- constructor({ logDir, agentId, sessionId, silent, bestEffort } = {}) {
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 } = {}) {
45
53
  // agentId becomes a filesystem path segment (logDir/<agentId>/…). Reject
46
54
  // anything that could traverse out of logDir before we ever build a path.
47
55
  assertSafePathSegment(agentId, 'agentId');
@@ -50,6 +58,7 @@ export class Logger {
50
58
  this.sessionId = sessionId || randomUUID();
51
59
  this.silent = silent !== false;
52
60
  this.bestEffort = bestEffort === true;
61
+ this.chain = chain || null;
53
62
  this.sequence = 0;
54
63
  this.currentDay = null;
55
64
  this.currentPath = null;
@@ -101,12 +110,17 @@ export class Logger {
101
110
  input: e.input ?? null,
102
111
  output: e.output ?? null,
103
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;
104
118
  try {
105
119
  const dir = join(this.logDir, this.agentId);
106
120
  await mkdir(dir, { recursive: true, mode: 0o700 });
107
121
  // v1.1.6 F-24: tighten existing perms — `mode` is creation-only.
108
122
  await tightenMode(dir, 0o700);
109
- await appendFile(path, JSON.stringify(full) + '\n', { encoding: 'utf8', mode: 0o600 });
123
+ await appendFile(path, JSON.stringify(toWrite) + '\n', { encoding: 'utf8', mode: 0o600 });
110
124
  await tightenMode(path, 0o600);
111
125
  this.count++;
112
126
  } catch (err) {
@@ -115,7 +129,7 @@ export class Logger {
115
129
  // Disk full, EACCES, EINVAL etc. should NOT be silently swallowed.
116
130
  if (!this.bestEffort) throw err;
117
131
  }
118
- return full;
132
+ return toWrite;
119
133
  }
120
134
 
121
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
+ }
@@ -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
- this._logger = new Logger({ logDir, agentId, sessionId, silent: true });
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
@@ -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
- export function matchesPolicy(event, policy) {
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 = getNested(event, field);
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
- export function evaluate(event, ruleset) {
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,