watchmyagents 1.1.5 → 1.1.6
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/upload-fortress.js +14 -0
- package/src/logger.js +19 -2
- 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.1.
|
|
3
|
+
"version": "1.1.6",
|
|
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
|
}
|
|
@@ -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
|
|
@@ -89,8 +102,12 @@ export class Logger {
|
|
|
89
102
|
output: e.output ?? null,
|
|
90
103
|
};
|
|
91
104
|
try {
|
|
92
|
-
|
|
105
|
+
const dir = join(this.logDir, this.agentId);
|
|
106
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
107
|
+
// v1.1.6 F-24: tighten existing perms — `mode` is creation-only.
|
|
108
|
+
await tightenMode(dir, 0o700);
|
|
93
109
|
await appendFile(path, JSON.stringify(full) + '\n', { encoding: 'utf8', mode: 0o600 });
|
|
110
|
+
await tightenMode(path, 0o600);
|
|
94
111
|
this.count++;
|
|
95
112
|
} catch (err) {
|
|
96
113
|
if (!this.silent) process.stderr.write(`[wma] log write error: ${err.message}\n`);
|
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 = [];
|