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.
|
|
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
|
-
|
|
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
|
-
|
|
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 */ }
|
package/src/shield/policy.js
CHANGED
|
@@ -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 (!
|
|
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
|
-
|
|
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,
|
package/src/shield/stream.js
CHANGED
|
@@ -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
|
-
|
|
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); }
|