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 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. Never persisted to disk by WMA. |
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.5",
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
- await mkdir(join(this.logDir, this.agentId), { recursive: true, mode: 0o700 });
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`);
@@ -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
- const { done, value } = await reader.read();
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 = [];