watchmyagents 1.4.0 → 1.4.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/README.md CHANGED
@@ -287,7 +287,7 @@ export WMA_SIGNALS_SALT="..." # stable per-custo
287
287
 
288
288
  wma-service install (--agent-id agent_01ABC... | --all-agents) [--interval 1m] [--with-shield]
289
289
  wma-service status
290
- wma-service uninstall [--with-shield]
290
+ wma-service uninstall [--with-shield] [--purge]
291
291
  ```
292
292
 
293
293
  - macOS → **launchd** LaunchAgent · Linux → **systemd** user unit.
@@ -295,6 +295,9 @@ wma-service uninstall [--with-shield]
295
295
  runtime — **never** written into the plist/unit.
296
296
  - `--with-shield` also runs `wma-shield --policies-source fortress` always-on for
297
297
  live enforcement.
298
+ - `uninstall` by default **leaves `~/.watchmyagents/env` on disk** so a re-install
299
+ keeps your snapshotted keys. Pass `--purge` to also delete the env file and the
300
+ whole `~/.watchmyagents` directory (including local logs).
298
301
  - Raw logs stay local (`~/.watchmyagents/logs`); only anonymized signals upload.
299
302
 
300
303
  After this, the full Watch→Guardian→Shield loop runs hands-off.
package/SECURITY.md CHANGED
@@ -33,7 +33,8 @@ Hardening notes:
33
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
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
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.
36
+ - Service removal: `wma-service uninstall` removes the unit and the launcher but **leaves `~/.watchmyagents/env` on disk** so a re-install keeps your snapshotted keys. This is the default since v1.4.1 (F-37) and is the safer behavior — uninstalling to retry an install does not destroy your API keys.
37
+ - Full removal incl. secrets: `wma-service uninstall --purge` removes the unit, the launcher, the env file, and the whole `~/.watchmyagents` directory (including local NDJSON logs under `~/.watchmyagents/logs`). Use this when you really want to scrub the machine.
37
38
 
38
39
  ### Local log files
39
40
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "watchmyagents",
3
- "version": "1.4.0",
3
+ "version": "1.4.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": [
@@ -25,7 +25,7 @@ import { createInterface } from 'node:readline';
25
25
  import { join, resolve } from 'node:path';
26
26
  import { request as httpsRequest } from 'node:https';
27
27
  import { URL } from 'node:url';
28
- import { Logger } from '../src/logger.js';
28
+ import { Logger, tightenMode } from '../src/logger.js';
29
29
  import { TokenTracker } from '../src/tokens.js';
30
30
  import { SignalsAggregator } from '../src/anonymizer.js';
31
31
  import { resolveFortressBase, fortressEndpoint } from '../src/fortress/url.js';
@@ -321,10 +321,26 @@ async function fetchOneShot({ apiKey, agentId, model, logDir, since, sessionId,
321
321
  process.stdout.write(`\n[wma-fetch] session ${sid}\n`);
322
322
  if (dumpRaw) {
323
323
  assertSafePathSegment(sid, 'session-id'); // defense-in-depth: sid → file path
324
- const rawPath = join(logDir, agentId, `raw-${sid}.jsonl`);
325
- await mkdir(join(logDir, agentId), { recursive: true, mode: 0o700 });
324
+ const rawDir = join(logDir, agentId);
325
+ const rawPath = join(rawDir, `raw-${sid}.jsonl`);
326
+ await mkdir(rawDir, { recursive: true, mode: 0o700 });
327
+ // v1.4.1 F-34 (P2 Codex audit): mkdir/appendFile `mode` is creation-
328
+ // only. If a previous `wma-fetch` run, a different user, or a hand-
329
+ // rolled mkdir left the directory or file in place with loose perms
330
+ // (typically 0755/0644 via umask), the original code path kept them.
331
+ // The raw JSONL carries unredacted API events, so loose perms make
332
+ // them readable to any local user. Tighten after the directory and
333
+ // after the first append so an existing inode is brought in line
334
+ // with the doc promise (0700/0600). Best-effort: chmod failures
335
+ // here MUST NOT break wma-fetch.
336
+ await tightenMode(rawDir, 0o700);
337
+ let firstAppend = true;
326
338
  for await (const ev of fetchRawEvents(apiKey, sid)) {
327
339
  await appendFile(rawPath, JSON.stringify(ev) + '\n', { encoding: 'utf8', mode: 0o600 });
340
+ if (firstAppend) {
341
+ await tightenMode(rawPath, 0o600);
342
+ firstAppend = false;
343
+ }
328
344
  }
329
345
  process.stdout.write(` raw events → ${rawPath}\n`);
330
346
  }
@@ -11,7 +11,14 @@
11
11
  // One integrated install:
12
12
  // wma-service install --agent-id agent_xxx [--interval 5m] [--with-shield]
13
13
  // wma-service status
14
- // wma-service uninstall [--with-shield]
14
+ // wma-service uninstall [--with-shield] [--purge]
15
+ //
16
+ // v1.4.1 F-37 (P3 Codex audit on v1.4.0): `uninstall` defaults to LEAVING
17
+ // ~/.watchmyagents/env on disk (containing the snapshotted API keys). This
18
+ // matches the historical behavior and protects users who uninstall just to
19
+ // reinstall with a different agent-id from losing their snapshot. To wipe
20
+ // the env file + config dir, pass `--purge` explicitly. SECURITY.md says
21
+ // the same thing; do not let the doc + the code drift again.
15
22
  //
16
23
  // Secrets NEVER go in the plist/unit. They're snapshotted (from the current
17
24
  // environment) into a protected env file (~/.watchmyagents/env, chmod 600) that
@@ -289,6 +296,7 @@ function cmdInstall(args) {
289
296
 
290
297
  function cmdUninstall(args) {
291
298
  const withShield = !!args['with-shield'];
299
+ const purge = !!args.purge;
292
300
  if (PLATFORM === 'darwin') {
293
301
  macUnload(WATCH_LABEL);
294
302
  if (withShield) macUnload(SHIELD_LABEL);
@@ -298,7 +306,18 @@ function cmdUninstall(args) {
298
306
  } else {
299
307
  die(`unsupported platform "${PLATFORM}"`);
300
308
  }
301
- info('uninstalled. (Secrets in ' + ENV_FILE + ' left intact — delete manually if you want them gone.)');
309
+ if (purge) {
310
+ // F-37: explicit opt-in only. Removes the snapshotted env file (chmod
311
+ // 600, contains API keys) and the whole config dir. Logs under
312
+ // ~/.watchmyagents/logs are wiped too — the user asked for purge, so
313
+ // we do not second-guess them on local NDJSON. `force: true` ensures
314
+ // we don't throw if the path is already gone.
315
+ try { rmSync(ENV_FILE, { force: true }); } catch { /* best-effort */ }
316
+ try { rmSync(CONFIG_DIR, { recursive: true, force: true }); } catch { /* best-effort */ }
317
+ info(`uninstalled + purged ${CONFIG_DIR} (env file and local logs removed).`);
318
+ } else {
319
+ info(`uninstalled. Secrets in ${ENV_FILE} left intact — re-run with --purge to wipe them.`);
320
+ }
302
321
  }
303
322
 
304
323
  function cmdStatus() {
@@ -328,11 +347,15 @@ function usage() {
328
347
  Usage:
329
348
  wma-service install --agent-id agent_xxx [--interval 5m] [--log-dir DIR] [--with-shield]
330
349
  wma-service status
331
- wma-service uninstall [--with-shield]
350
+ wma-service uninstall [--with-shield] [--purge]
332
351
 
333
352
  Required env at install (snapshotted to ~/.watchmyagents/env, chmod 600):
334
353
  ANTHROPIC_API_KEY, WMA_API_KEY, WMA_FORTRESS_BASE_URL, WMA_SIGNALS_SALT
335
354
 
355
+ Uninstall by default leaves ~/.watchmyagents/env on disk so a re-install
356
+ keeps your snapshotted keys. Pass --purge to also delete the env file and
357
+ the whole ~/.watchmyagents directory (including local logs).
358
+
336
359
  macOS → launchd LaunchAgent · Linux → systemd user unit.
337
360
  The service starts at login and restarts on crash. Raw logs stay local.
338
361
  `);
package/src/logger.js CHANGED
@@ -16,6 +16,14 @@ async function tightenMode(path, mode) {
16
16
  try { await chmod(path, mode); } catch { /* not fatal */ }
17
17
  }
18
18
 
19
+ // v1.4.1 F-34 (P2 Codex audit on v1.4.0): re-exported so other writers
20
+ // of WMA-controlled log paths can defend against the same loose-perms
21
+ // trap (mkdir/appendFile `mode` is creation-only). Used by:
22
+ // scripts/fetch-anthropic.js --dump-raw (raw API events on disk)
23
+ // any future code path that mkdir/appendFile into a customer log dir
24
+ // Best-effort by design — failure of chmod must NOT break log writes.
25
+ export { tightenMode };
26
+
19
27
  // PR-B: `framework` → `provider` (canonical name per src/sources/contract.js).
20
28
  // PR-C: adds `parent_agent_id` + `composition_pattern` so any future
21
29
  // adapter that knows the hierarchy (OpenAI Agents handoffs, CrewAI
@@ -116,24 +116,48 @@ function readEnv(key, fallback) {
116
116
  // Policies that match on tool_name still work; policies that match on
117
117
  // argument values silently miss the truncated suffix — which is the
118
118
  // correct fail-closed behavior for an oversize input.
119
+ // v1.4.1 F-36 (P3 Codex audit on v1.4.0): proper byte-level truncation
120
+ // for UTF-8 strings. Pre-v1.4.1 we used `str.slice(0, maxBytes)`, which
121
+ // slices by CHARACTERS — with multi-byte Unicode (emoji = 4 bytes per
122
+ // char) the cap could be exceeded by up to 3x. This helper cuts on the
123
+ // byte boundary via Buffer and strips the trailing U+FFFD replacement
124
+ // character if the cut landed mid-sequence. We accept the dropped
125
+ // trailing char as acceptable cost: the result is marked _wmaTruncated.
126
+ function truncateUtf8(str, maxBytes) {
127
+ if (typeof str !== 'string') return str;
128
+ const buf = Buffer.from(str, 'utf8');
129
+ if (buf.length <= maxBytes) return str;
130
+ return buf.subarray(0, maxBytes).toString('utf8').replace(/�+$/, '');
131
+ }
132
+
119
133
  function safeParseToolArgs(rawArgs, maxBytes = DEFAULT_MAX_ARG_BYTES) {
120
134
  if (rawArgs == null) return null;
121
135
  if (typeof rawArgs === 'object') {
122
- // Already parsed by the SDK or the customer. We don't deep-walk
123
- // and truncate that would silently change policy match semantics
124
- // on nested fields. Defer the decision to the caller; the size cap
125
- // applies at the string-parse boundary, which is the realistic
126
- // SDK entry point.
136
+ // v1.4.1 F-36 also cap object inputs by their serialized byte
137
+ // size. Pre-v1.4.1 already-parsed objects bypassed the cap entirely,
138
+ // which let a misbehaving tool / customer pass a 5 MB object through
139
+ // safeParseToolArgs unimpeded. We don't deep-walk-and-truncate (that
140
+ // would silently change policy match semantics on nested fields);
141
+ // instead we replace the whole input with a truncation marker if
142
+ // the serialized form exceeds maxBytes.
143
+ try {
144
+ const serialized = JSON.stringify(rawArgs);
145
+ const byteLen = Buffer.byteLength(serialized, 'utf8');
146
+ if (byteLen > maxBytes) {
147
+ return { _wmaTruncated: true, _wmaOriginalBytes: byteLen };
148
+ }
149
+ } catch {
150
+ // Unserializable input — return as-is. Policy match against
151
+ // anything in this object is going to fail anyway.
152
+ }
127
153
  return rawArgs;
128
154
  }
129
155
  if (typeof rawArgs !== 'string') return null;
130
156
  // Byte cap: Buffer.byteLength is the safe length on the wire.
131
157
  const byteLen = Buffer.byteLength(rawArgs, 'utf8');
132
158
  if (byteLen > maxBytes) {
133
- // Truncate to maxBytes worth of bytes (substring is char-bounded;
134
- // good enough exact byte truncation isn't required since we mark
135
- // the result as truncated and never feed it back to a real tool).
136
- const head = rawArgs.slice(0, maxBytes);
159
+ // v1.4.1 F-36 byte-level truncation (was char-level before).
160
+ const head = truncateUtf8(rawArgs, maxBytes);
137
161
  try {
138
162
  const parsed = JSON.parse(head);
139
163
  // If by luck the head is valid JSON, return it plus the marker.
@@ -374,8 +398,9 @@ function truncateResult(result, maxBytes = DEFAULT_MAX_RESULT_BYTES) {
374
398
  if (typeof result === 'string') {
375
399
  const byteLen = Buffer.byteLength(result, 'utf8');
376
400
  if (byteLen > maxBytes) {
401
+ // v1.4.1 F-36 — byte-level truncation (was char-level before).
377
402
  return {
378
- text: result.slice(0, maxBytes) + TRUNCATION_SENTINEL,
403
+ text: truncateUtf8(result, maxBytes) + TRUNCATION_SENTINEL,
379
404
  _wmaTruncated: true,
380
405
  _wmaOriginalBytes: byteLen,
381
406
  };
@@ -445,6 +470,23 @@ export function normalizeToolEnd({ agent, tool, result, toolCall, sessionId, tea
445
470
  // policy ruleset on first invocation if `policiesPath` is set.
446
471
 
447
472
  export function wmaToolInputGuardrail(options = {}) {
473
+ // v1.4.1 F-35 (P2 Codex audit on v1.4.0): fail-loud at construction
474
+ // time when nothing safety-relevant is configured. Same intent as the
475
+ // openaiAgents() factory's mode:'enforce' check, but applied to the
476
+ // lower-level entry point too — defense in depth. Customers using the
477
+ // direct import path no longer get a silent "allow all" surprise.
478
+ const hasPolicySource =
479
+ options.policiesPath != null ||
480
+ options.ruleset != null ||
481
+ options.allowAllWhenUnconfigured === true;
482
+ if (!hasPolicySource) {
483
+ throw new Error(
484
+ 'wmaToolInputGuardrail: no policy configured. Pass policiesPath or ruleset. ' +
485
+ 'For demos / smoke tests that intentionally allow all tool calls, pass ' +
486
+ '{ allowAllWhenUnconfigured: true } explicitly.',
487
+ );
488
+ }
489
+
448
490
  const failOpen = options.failOpen === true ? true : DEFAULT_FAIL_OPEN;
449
491
  const sessionId = options.sessionId || makeSessionId();
450
492
  const logDir = options.logDir
@@ -480,14 +522,16 @@ export function wmaToolInputGuardrail(options = {}) {
480
522
  ruleset = await loadPolicies(options.policiesPath);
481
523
  return ruleset;
482
524
  }
483
- // No ruleset, no path default to "always allow". The customer
484
- // probably means to use Fortress; we don't ship that wiring in
485
- // v1.3.0 from this entry point. Log a warning once.
525
+ // v1.4.1 F-35: only reachable when the customer passed
526
+ // `allowAllWhenUnconfigured: true` the synchronous check at
527
+ // construction time has already rejected every other un-configured
528
+ // shape. Log a loud warning once so demo deployments don't quietly
529
+ // ship without a real ruleset.
486
530
  if (!ensureRuleset._warned) {
487
531
  ensureRuleset._warned = true;
488
532
  process.stderr.write(
489
- '[wma/openai-agents] no policy ruleset configured — guardrail will allow all. ' +
490
- 'Pass { policiesPath } or { ruleset } to wmaToolInputGuardrail() to enforce.\n',
533
+ '[wma/openai-agents] allowAllWhenUnconfigured=true — guardrail will allow ALL tool calls. ' +
534
+ 'This is intended for demos only; ship with a real ruleset in production.\n',
491
535
  );
492
536
  }
493
537
  ruleset = { policies: [], default: { action: 'allow' } };