watchmyagents 1.2.0 → 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.2.0",
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
@@ -203,18 +203,35 @@ async function runSessionWorker({ sessionId, ctx }) {
203
203
  })) {
204
204
  processed++;
205
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
+
206
223
  // ── INTERRUPT MODE ──────────────────────────────────────────────
207
224
  if (mode === 'interrupt' && CACHEABLE_TOOL_TYPES.has(rawEvent.type)) {
208
225
  // No caching in interrupt mode — react synchronously, free memory.
209
226
  const normalized = normalizeForPolicy(rawEvent);
210
227
  // 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.
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.
213
231
  const policyCtx = policyTracker.compute(rawEvent);
214
232
  const t0 = Date.now();
215
233
  const result = evaluate(normalized, ctx.ruleset, policyCtx);
216
234
  const decidedInMs = Date.now() - t0;
217
- policyTracker.record(rawEvent);
218
235
 
219
236
  // v1.1.3 Phase 1.D — mode badge in the log line for operator visibility.
220
237
  const modeTag = result.mode === 'shadow' ? ' [SHADOW]' : '';
@@ -282,12 +299,14 @@ async function runSessionWorker({ sessionId, ctx }) {
282
299
 
283
300
  const normalized = normalizeForPolicy(sourceEvent);
284
301
  // v1.2.0 — see interrupt-mode block above for the compute /
285
- // record ordering rationale.
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.
286
306
  const policyCtx = policyTracker.compute(sourceEvent);
287
307
  const t0 = Date.now();
288
308
  const result = evaluate(normalized, ctx.ruleset, policyCtx);
289
309
  const decidedInMs = Date.now() - t0;
290
- policyTracker.record(sourceEvent);
291
310
 
292
311
  // v1.1.3 Phase 1.D — mode badge in the log line for operator visibility.
293
312
  const modeTag = result.mode === 'shadow' ? ' [SHADOW]' : '';
@@ -350,6 +369,21 @@ async function runSessionWorker({ sessionId, ctx }) {
350
369
  sinfo(sessionId, `session terminated: ${rawEvent.stop_reason?.type || 'unknown'}`);
351
370
  break;
352
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
+ }
353
387
  }
354
388
  } catch (e) {
355
389
  if (!signal.aborted) {