watchmyagents 1.1.1 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "watchmyagents",
3
- "version": "1.1.1",
3
+ "version": "1.1.2",
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": [
@@ -85,6 +85,12 @@ function resolveModel(agent) {
85
85
  }
86
86
 
87
87
  // HTTPS POST helper for the --upload signals push (mirrors wma-upload-fortress).
88
+ // v1.1.2 F-17: response body cap for the Fortress ingest-signals POST.
89
+ // The expected reply is a small JSON confirmation ({signal_id, agent_id,
90
+ // registered_new_agent}) — well under 1 MB. Any larger and the endpoint
91
+ // is misconfigured or compromised; abort.
92
+ const MAX_FORTRESS_RESPONSE_BYTES = 1 * 1024 * 1024;
93
+
88
94
  function postJson(url, headers, body) {
89
95
  return new Promise((resolveReq, rejectReq) => {
90
96
  const u = new URL(url);
@@ -97,8 +103,22 @@ function postJson(url, headers, body) {
97
103
  rejectUnauthorized: true,
98
104
  }, (res) => {
99
105
  const chunks = [];
100
- res.on('data', (c) => chunks.push(c));
106
+ let receivedBytes = 0;
107
+ let aborted = false;
108
+ res.on('data', (c) => {
109
+ if (aborted) return;
110
+ receivedBytes += c.length;
111
+ if (receivedBytes > MAX_FORTRESS_RESPONSE_BYTES) {
112
+ aborted = true;
113
+ chunks.length = 0;
114
+ try { req.destroy(); } catch { /* already destroyed */ }
115
+ rejectReq(new Error(`Fortress response exceeded ${MAX_FORTRESS_RESPONSE_BYTES} bytes — aborting`));
116
+ return;
117
+ }
118
+ chunks.push(c);
119
+ });
101
120
  res.on('end', () => {
121
+ if (aborted) return;
102
122
  const raw = Buffer.concat(chunks).toString('utf8');
103
123
  let parsed = null; try { parsed = JSON.parse(raw); } catch { /* keep raw */ }
104
124
  resolveReq({ status: res.statusCode || 0, body: parsed ?? raw });
@@ -63,6 +63,11 @@ async function collectFiles(p) {
63
63
  return out;
64
64
  }
65
65
 
66
+ // v1.1.2 F-17: Fortress ingest-signals response is a small confirmation
67
+ // JSON. Cap at 1 MB and abort if the endpoint streams more — defensive
68
+ // against a compromised or misconfigured response.
69
+ const MAX_FORTRESS_RESPONSE_BYTES = 1 * 1024 * 1024;
70
+
66
71
  function postJson(url, headers, body) {
67
72
  return new Promise((resolveReq, rejectReq) => {
68
73
  const u = new URL(url);
@@ -85,8 +90,22 @@ function postJson(url, headers, body) {
85
90
  },
86
91
  (res) => {
87
92
  const chunks = [];
88
- res.on('data', (c) => chunks.push(c));
93
+ let receivedBytes = 0;
94
+ let aborted = false;
95
+ res.on('data', (c) => {
96
+ if (aborted) return;
97
+ receivedBytes += c.length;
98
+ if (receivedBytes > MAX_FORTRESS_RESPONSE_BYTES) {
99
+ aborted = true;
100
+ chunks.length = 0;
101
+ try { req.destroy(); } catch { /* already destroyed */ }
102
+ rejectReq(new Error(`Fortress response exceeded ${MAX_FORTRESS_RESPONSE_BYTES} bytes — aborting`));
103
+ return;
104
+ }
105
+ chunks.push(c);
106
+ });
89
107
  res.on('end', () => {
108
+ if (aborted) return;
90
109
  const raw = Buffer.concat(chunks).toString('utf8');
91
110
  let parsed = null;
92
111
  try { parsed = JSON.parse(raw); } catch { /* keep raw */ }
@@ -32,13 +32,27 @@ export async function loadPolicies(path) {
32
32
  throw new Error(`policy file ${path} has no "policies" array`);
33
33
  }
34
34
  // Pre-compile regex for performance + early failure on bad patterns.
35
+ const VALID_ACTIONS = ['allow', 'deny', 'interrupt'];
35
36
  for (const p of data.policies) {
36
37
  compileMatchRegexes(p.match || {});
37
- if (!['allow', 'deny', 'interrupt'].includes(p.action)) {
38
+ if (!VALID_ACTIONS.includes(p.action)) {
38
39
  throw new Error(`policy ${p.id || p.name}: unsupported action "${p.action}"`);
39
40
  }
40
41
  }
42
+ // v1.1.2 F-14 (P2 Codex audit): validate the ruleset's default.action
43
+ // against the SAME canonical set as per-policy actions. Before this fix
44
+ // a typo like `default: { action: "drop" }` was accepted silently — at
45
+ // evaluation time evaluate() returned `decision: "drop"`, which the
46
+ // interrupt-mode runtime treated as a no-op (only deny/interrupt trigger
47
+ // termination) and the tool_confirmation-mode runtime left dangling
48
+ // (no allow/deny event sent). Either way the agent ran without
49
+ // enforcement, exactly opposite of the operator's intent.
41
50
  data.default = data.default || { action: 'allow' };
51
+ if (!VALID_ACTIONS.includes(data.default.action)) {
52
+ throw new Error(
53
+ `policy file ${path} default.action "${data.default.action}" is invalid — must be one of: ${VALID_ACTIONS.join(', ')}`,
54
+ );
55
+ }
42
56
  return data;
43
57
  }
44
58
 
@@ -14,6 +14,15 @@ import { URL } from 'node:url';
14
14
  import { fortressEndpoint } from '../../fortress/url.js';
15
15
 
16
16
  const DEFAULT_TIMEOUT_MS = 15_000;
17
+ // v1.1.2 F-17 (P3 Codex audit): cap on the total bytes we'll accumulate
18
+ // for a Fortress JSON response before aborting the request. A misconfigured
19
+ // or compromised endpoint streaming an unbounded body would otherwise
20
+ // exhaust Shield's memory, despite the HTTPS-only + timeout guards.
21
+ // 8 MB is far above the realistic ceiling for a customer's policy ruleset
22
+ // (hundreds of policies × ~1 KB each → ~hundreds of KB). On overflow we
23
+ // destroy the request, which propagates to onError + cached-ruleset
24
+ // fallback.
25
+ const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
17
26
 
18
27
  function httpsJson(method, url, headers, body, timeoutMs = DEFAULT_TIMEOUT_MS) {
19
28
  return new Promise((resolveReq, rejectReq) => {
@@ -35,8 +44,23 @@ function httpsJson(method, url, headers, body, timeoutMs = DEFAULT_TIMEOUT_MS) {
35
44
  };
36
45
  const req = httpsRequest(opts, (res) => {
37
46
  const chunks = [];
38
- res.on('data', (c) => chunks.push(c));
47
+ let receivedBytes = 0;
48
+ let aborted = false;
49
+ res.on('data', (c) => {
50
+ if (aborted) return;
51
+ receivedBytes += c.length;
52
+ if (receivedBytes > MAX_RESPONSE_BYTES) {
53
+ aborted = true;
54
+ // Free anything we already buffered, then tear down the request.
55
+ chunks.length = 0;
56
+ try { req.destroy(); } catch { /* already destroyed */ }
57
+ rejectReq(new Error(`Fortress response exceeded ${MAX_RESPONSE_BYTES} bytes — aborting (received ${receivedBytes} so far)`));
58
+ return;
59
+ }
60
+ chunks.push(c);
61
+ });
39
62
  res.on('end', () => {
63
+ if (aborted) return;
40
64
  const raw = Buffer.concat(chunks).toString('utf8');
41
65
  let parsed = null;
42
66
  try { parsed = raw ? JSON.parse(raw) : null; } catch { /* keep raw */ }
@@ -179,6 +203,17 @@ export class FortressPolicySource {
179
203
  this.onError(new Error(`skipping invalid Fortress policy "${p?.rule_id || p?.name || '?'}": ${e.message}`));
180
204
  }
181
205
  }
206
+ // v1.1.2 F-15 (P2 Codex audit): the policy evaluator is "first match
207
+ // wins" (src/shield/policy.js evaluate()), so policy order matters.
208
+ // Fortress validates `priority` server-side, but the API does not
209
+ // contractually guarantee that the returned array is sorted by
210
+ // priority. If a wide "allow" rule sat before a higher-priority
211
+ // "deny" rule in the response, the deny would never fire. Sort
212
+ // client-side by descending priority (higher priority first) before
213
+ // assigning to ruleset. Policies without `priority` (or with equal
214
+ // priorities) keep their relative order via the stable sort
215
+ // guarantee in V8 — predictable behavior.
216
+ compiled.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
182
217
  this.ruleset = {
183
218
  version: 1,
184
219
  policies: compiled,
@@ -9,6 +9,15 @@
9
9
  const API_BASE = 'https://api.anthropic.com';
10
10
  const BETA = 'managed-agents-2026-04-01';
11
11
  const VERSION = '2023-06-01';
12
+ // v1.1.2 F-16 (P2 Codex audit): hard cap on a single SSE frame buffer.
13
+ // A buggy upstream proxy that strips event separators OR a compromised
14
+ // Anthropic-style endpoint streaming bytes forever without "\n\n" would
15
+ // otherwise OOM Shield's host. 1 MB is far above any real Anthropic
16
+ // event payload (the heaviest events are agent.thinking + agent.message
17
+ // which carry at most a few hundred KB of text). On overflow we throw,
18
+ // which propagates through the generator and triggers the caller's
19
+ // reconnect logic — same outcome as a network error.
20
+ const MAX_SSE_FRAME_BYTES = 1 * 1024 * 1024;
12
21
 
13
22
  function authHeaders(apiKey) {
14
23
  return {
@@ -43,6 +52,14 @@ export async function* openEventStream({ apiKey, sessionId, signal }) {
43
52
  if (done) break;
44
53
  buffer += decoder.decode(value, { stream: true });
45
54
 
55
+ // v1.1.2 F-16: guard against an upstream that never emits "\n\n" —
56
+ // throw to abort the stream cleanly, the caller's reconnect logic
57
+ // will pick up. Drop the buffer to free memory before throwing.
58
+ if (buffer.length > MAX_SSE_FRAME_BYTES) {
59
+ buffer = '';
60
+ throw new Error(`SSE frame exceeded ${MAX_SSE_FRAME_BYTES} bytes — aborting stream (caller should reconnect)`);
61
+ }
62
+
46
63
  // SSE frames are separated by a blank line ("\n\n"). Each frame may
47
64
  // contain multiple lines; we only care about `data:` lines for now.
48
65
  let nlIdx;
@@ -29,6 +29,12 @@ const VERSION = '2023-06-01';
29
29
  // Hard cap on any single GET so a hung connection can't pin Watch/Shield
30
30
  // forever. getWithRetry will retry on timeout (the error propagates here).
31
31
  const REQUEST_TIMEOUT_MS = 30_000;
32
+ // v1.1.2 F-17 (P3 Codex audit): cap on a single Anthropic response body.
33
+ // Event history pages (/v1/sessions/{id}/events) can carry up to ~1000
34
+ // events × thousands of bytes each, so 16 MB is the headroom we leave
35
+ // before we conclude something is wrong. Above this we abort the
36
+ // request and getWithRetry will retry on the next attempt.
37
+ const MAX_ANTHROPIC_RESPONSE_BYTES = 16 * 1024 * 1024;
32
38
 
33
39
  function httpGet(apiKey, path) {
34
40
  return new Promise((resolve, reject) => {
@@ -43,8 +49,22 @@ function httpGet(apiKey, path) {
43
49
  },
44
50
  }, res => {
45
51
  const chunks = [];
46
- res.on('data', c => chunks.push(c));
52
+ let receivedBytes = 0;
53
+ let aborted = false;
54
+ res.on('data', c => {
55
+ if (aborted) return;
56
+ receivedBytes += c.length;
57
+ if (receivedBytes > MAX_ANTHROPIC_RESPONSE_BYTES) {
58
+ aborted = true;
59
+ chunks.length = 0;
60
+ try { req.destroy(); } catch { /* already destroyed */ }
61
+ reject(new Error(`Anthropic response exceeded ${MAX_ANTHROPIC_RESPONSE_BYTES} bytes — aborting (${path})`));
62
+ return;
63
+ }
64
+ chunks.push(c);
65
+ });
47
66
  res.on('end', () => {
67
+ if (aborted) return;
48
68
  const body = Buffer.concat(chunks).toString('utf8');
49
69
  if (res.statusCode >= 200 && res.statusCode < 300) {
50
70
  try { resolve(JSON.parse(body)); } catch (e) { reject(e); }