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 +4 -1
- package/SECURITY.md +2 -1
- package/package.json +1 -1
- package/scripts/fetch-anthropic.js +19 -3
- package/scripts/service.js +26 -3
- package/src/logger.js +8 -0
- package/src/sources/openai-agents-js.js +59 -15
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
|
-
-
|
|
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.
|
|
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
|
|
325
|
-
|
|
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
|
}
|
package/scripts/service.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
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
|
-
//
|
|
134
|
-
|
|
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
|
|
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
|
-
//
|
|
484
|
-
//
|
|
485
|
-
//
|
|
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]
|
|
490
|
-
'
|
|
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' } };
|