watchmyagents 1.4.4 → 1.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "watchmyagents",
3
- "version": "1.4.4",
3
+ "version": "1.4.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": [
package/scripts/shield.js CHANGED
@@ -34,7 +34,7 @@ import {
34
34
  } from '../src/shield/enforce.js';
35
35
  import { maybePrintVersionAndExit } from '../src/version.js';
36
36
  import { DecisionLogger } from '../src/shield/decisions.js';
37
- import { listSessions, listAgents } from '../src/sources/anthropic-managed.js';
37
+ import { listSessions, listAgents, fetchRawEvents } from '../src/sources/anthropic-managed.js';
38
38
  import { FortressPolicySource, postDecision } from '../src/shield/sources/fortress.js';
39
39
  import { resolveFortressBase, fortressEndpoint } from '../src/fortress/url.js';
40
40
  import { PolicyStream } from '../src/shield/policy-stream.js';
@@ -223,9 +223,21 @@ async function runSessionWorker({ sessionId, ctx }) {
223
223
  try {
224
224
  for await (const rawEvent of streamWithReconnect({
225
225
  apiKey, sessionId, signal, maxAttempts: 3,
226
- onReconnect: ({ attempt, backoffMs, error }) => {
227
- sinfo(sessionId, `reconnect attempt ${attempt}/3 in ${backoffMs}ms (${error.message})`);
226
+ onReconnect: ({ attempt, backoffMs, error, backfilled, afterId, backfillError }) => {
227
+ if (backfillError) {
228
+ swarn(sessionId, `reconnect backfill failed (continuing on live stream): ${backfillError.message}`);
229
+ } else if (backfilled) {
230
+ // F-55: surface that the gap was closed — these events would
231
+ // otherwise have been silently missed by enforcement.
232
+ sinfo(sessionId, `reconnect backfilled ${backfilled} missed event(s) after ${String(afterId).slice(0, 16)}…`);
233
+ } else {
234
+ sinfo(sessionId, `reconnect attempt ${attempt}/3 in ${backoffMs}ms (${error?.message ?? 'drop'})`);
235
+ }
228
236
  },
237
+ // F-55: close the enforcement blind window on reconnect — page the
238
+ // persisted events endpoint for everything the live stream missed
239
+ // during the drop, exclusive of the last id we already processed.
240
+ backfill: (afterId, sig) => fetchRawEvents(apiKey, sessionId, { afterId }),
229
241
  })) {
230
242
  processed++;
231
243
 
@@ -139,6 +139,14 @@ async function main() {
139
139
  const args = parseArgs(process.argv.slice(2));
140
140
 
141
141
  const agentId = args['agent-id'];
142
+ // v1.4.6: provider-aware upload (Fortress OpenAI register flow). Default
143
+ // anthropic-managed for back-compat; pass --provider openai-agents (or
144
+ // WMA_PROVIDER) when uploading an OpenAI agent's local NDJSON.
145
+ const VALID_PROVIDERS = ['anthropic-managed', 'openai-agents'];
146
+ const provider = args.provider || process.env.WMA_PROVIDER || 'anthropic-managed';
147
+ if (!VALID_PROVIDERS.includes(provider)) {
148
+ die(`error: --provider must be one of ${VALID_PROVIDERS.join(', ')} (got "${provider}")`);
149
+ }
142
150
  const logDir = resolve(args['log-dir'] || './watchmyagents-logs');
143
151
  const apiKey = args['api-key'] || process.env.WMA_API_KEY;
144
152
  const salt = args.salt || process.env.WMA_SIGNALS_SALT;
@@ -160,11 +168,18 @@ async function main() {
160
168
  const fortressUrl = fortressBase ? fortressEndpoint(fortressBase, 'ingest-signals') : null;
161
169
 
162
170
  // Validation
163
- if (!agentId) die('error: --agent-id required (Anthropic agent_id, e.g. agent_01ABC...)');
164
- // Strict alphanumeric to prevent path traversal in collectFiles below
165
- // (--agent-id ends up as a filesystem path segment).
166
- if (!/^agent_[a-zA-Z0-9]+$/.test(agentId)) {
167
- die(`error: --agent-id has invalid format (expected "agent_" + alphanumeric, got "${agentId}")`);
171
+ if (!agentId) die('error: --agent-id required (e.g. agent_01ABC... for Anthropic, or your agent name for OpenAI)');
172
+ // --agent-id ends up as a filesystem path segment must be traversal-safe.
173
+ // Anthropic native ids are `agent_…`; other providers (OpenAI) use arbitrary
174
+ // but filesystem-safe agent names.
175
+ if (provider === 'anthropic-managed') {
176
+ if (!/^agent_[a-zA-Z0-9]+$/.test(agentId)) {
177
+ die(`error: --agent-id has invalid format for anthropic-managed (expected "agent_" + alphanumeric, got "${agentId}")`);
178
+ }
179
+ } else if (!/^[A-Za-z0-9._-]+$/.test(agentId) || agentId.includes('..')) {
180
+ // reject ".." explicitly: it passes the charset but is a path-traversal
181
+ // segment (agentId becomes a directory under logDir in collectFiles).
182
+ die(`error: --agent-id has unsafe characters or path traversal (allowed: letters, digits, . _ - ; no "..", got "${agentId}")`);
168
183
  }
169
184
  if (!dryRun && !fortressUrl) {
170
185
  die('error: --fortress-url or WMA_FORTRESS_URL required (full URL to /functions/v1/ingest-signals).\n' +
@@ -231,12 +246,15 @@ async function main() {
231
246
  // call. Best-effort: send the max; the daemon's subsequent uploads
232
247
  // will correct the value once it resolves.
233
248
  const body = {
234
- provider: AnthropicManagedSource.providerName,
249
+ provider,
235
250
  native_agent_id: agentId,
236
- anthropic_agent_id: agentId,
251
+ // legacy field only meaningful for the Anthropic runtime
252
+ anthropic_agent_id: provider === 'anthropic-managed' ? agentId : undefined,
237
253
  parent_agent_id: null,
238
254
  composition_pattern: 'solo',
239
- enforcement_mode: AnthropicManagedSource.enforcementMode,
255
+ // enforcement_mode is an Anthropic confirm-vs-interrupt concept; don't
256
+ // send a misleading Anthropic value for other runtimes (Fortress defaults).
257
+ enforcement_mode: provider === 'anthropic-managed' ? AnthropicManagedSource.enforcementMode : undefined,
240
258
  display_name: displayName,
241
259
  window_start: signals.window_start,
242
260
  window_end: signals.window_end,
@@ -40,11 +40,24 @@ import {
40
40
  attachWmaWatchToAgent,
41
41
  adapterMeta,
42
42
  } from './sources/openai-agents-js.js';
43
+ import { FortressPolicySource } from './shield/sources/fortress.js';
44
+ import { resolveFortressBase } from './fortress/url.js';
43
45
 
44
46
  /**
45
47
  * Build the WMA OpenAI Agents SDK adapter from a single shared config.
46
48
  *
47
49
  * @param {object} [options]
50
+ * @param {string} [options.agentId] v1.4.6 — the agent id registered
51
+ * in Fortress. Used as the NDJSON
52
+ * path segment + native_agent_id,
53
+ * and to scope the Fortress policy
54
+ * pull. Required with
55
+ * policies.source='fortress'.
56
+ * @param {object} [options.policies] v1.4.6 — live policy source.
57
+ * `{ source: 'fortress', baseUrl?, apiKey?, requireSignedPolicies?, failMode? }`
58
+ * pulls the ruleset from Fortress (apiKey defaults to WMA_API_KEY, baseUrl to
59
+ * WMA_FORTRESS_BASE_URL) and refreshes it in the background — the same control
60
+ * plane wma-shield uses for Anthropic.
48
61
  * @param {string} [options.policiesPath] Local JSON policy file.
49
62
  * @param {object} [options.ruleset] In-memory ruleset.
50
63
  * @param {string} [options.logDir] NDJSON log dir.
@@ -82,22 +95,64 @@ export function openaiAgents(options = {}) {
82
95
  );
83
96
  }
84
97
 
98
+ // v1.4.6 — Fortress live policy source (the OpenAI register flow). When
99
+ // `policies: { source: 'fortress' }` is set, build a FortressPolicySource
100
+ // that the guardrail pulls from (+ background refresh), mirroring how
101
+ // wma-shield gets its ruleset for Anthropic. Requires an agentId to scope
102
+ // the pull, a base URL (option or WMA_FORTRESS_BASE_URL), and WMA_API_KEY.
103
+ let fortressPolicySource = null;
104
+ if (options.policies && options.policies.source === 'fortress') {
105
+ if (!options.agentId) {
106
+ throw new Error(
107
+ "openaiAgents({ policies: { source: 'fortress' } }) requires `agentId` " +
108
+ "(the agent id you registered in Fortress) to scope the policy pull.",
109
+ );
110
+ }
111
+ const apiKey = options.policies.apiKey || process.env.WMA_API_KEY;
112
+ if (!apiKey) {
113
+ throw new Error(
114
+ "openaiAgents fortress policies: WMA_API_KEY is required (env or " +
115
+ "policies.apiKey) to authenticate the policy pull.",
116
+ );
117
+ }
118
+ const base = resolveFortressBase({
119
+ explicitBase: options.policies.baseUrl,
120
+ env: process.env,
121
+ });
122
+ if (!base) {
123
+ throw new Error(
124
+ "openaiAgents fortress policies: no base URL — set policies.baseUrl or " +
125
+ "WMA_FORTRESS_BASE_URL (e.g. https://<project>.supabase.co/functions/v1).",
126
+ );
127
+ }
128
+ fortressPolicySource = new FortressPolicySource({
129
+ apiKey,
130
+ base,
131
+ anthropicAgentId: options.agentId, // scopes get-policies by native agent id
132
+ requireSignedPolicies: options.policies.requireSignedPolicies,
133
+ failMode: options.policies.failMode,
134
+ });
135
+ }
136
+
85
137
  // v1.4 Codex #2 — fail-loud refusal to start in enforce mode without
86
138
  // any policy source. Avoids the v1.3.0 footgun where missing policies
87
139
  // silently degraded to "allow all" with only a stderr warning. The
88
140
  // failure surfaces at config time (not on the first request) so it's
89
141
  // impossible to deploy a build that LOOKS armed but isn't.
90
- if (mode === 'enforce' && options.policiesPath == null && options.ruleset == null) {
142
+ if (mode === 'enforce' && options.policiesPath == null && options.ruleset == null
143
+ && fortressPolicySource == null) {
91
144
  throw new Error(
92
- "openaiAgents({ mode: 'enforce' }) requires policiesPath or ruleset. " +
93
- "Either provide one, or switch to { mode: 'observe' } if you only " +
94
- "want Watch (NDJSON capture) without Shield enforcement.",
145
+ "openaiAgents({ mode: 'enforce' }) requires policiesPath, ruleset, or " +
146
+ "policies: { source: 'fortress' }. Either provide one, or switch to " +
147
+ "{ mode: 'observe' } if you only want Watch (NDJSON capture) without Shield.",
95
148
  );
96
149
  }
97
150
 
98
151
  // Shared config we thread into both shield() and watch() so the
99
152
  // customer doesn't have to repeat it.
100
153
  const sharedOptions = {
154
+ agentId: options.agentId, // v1.4.6: NDJSON path + native_agent_id
155
+ fortressPolicySource, // v1.4.6: live Fortress ruleset (or null)
101
156
  policiesPath: options.policiesPath,
102
157
  ruleset: options.ruleset,
103
158
  logDir: options.logDir,
@@ -177,15 +177,68 @@ function parseFrame(frame) {
177
177
  catch { return null; }
178
178
  }
179
179
 
180
+ // v1.4.5 F-55 (P1 audit): bounded set of recently-yielded event ids. The
181
+ // reconnect path can re-deliver events (backfill tail overlapping the resumed
182
+ // live head, or an upstream redelivering after a drop). De-duplicating by id
183
+ // makes the whole stream idempotent so Shield never double-enforces /
184
+ // double-records the same event. Bounded so a long-lived stream can't grow it
185
+ // without limit; ordered eviction drops the oldest ids first.
186
+ export function makeSeenIdSet(cap = 4096) {
187
+ const set = new Set();
188
+ return {
189
+ seen(id) {
190
+ if (set.has(id)) return true;
191
+ set.add(id);
192
+ if (set.size > cap) {
193
+ // Evict oldest insertion-order ids down to ~90% of cap.
194
+ const target = Math.floor(cap * 0.9);
195
+ for (const old of set) {
196
+ set.delete(old);
197
+ if (set.size <= target) break;
198
+ }
199
+ }
200
+ return false;
201
+ },
202
+ };
203
+ }
204
+
180
205
  // High-level wrapper: stream forever, reconnecting on transient errors.
181
206
  // Yields events; on fatal/permanent errors throws after maxAttempts.
182
- export async function* streamWithReconnect({ apiKey, sessionId, signal, maxAttempts = 5, onReconnect }) {
207
+ //
208
+ // v1.4.5 F-55 (P1 audit): SSE reconnect previously re-opened the stream at the
209
+ // LIVE position with no resume cursor, so every event emitted during the drop
210
+ // window was lost — including a `requires_action` (which pauses the agent
211
+ // waiting on Shield) or the `agent.tool_use` that must be cached before it.
212
+ // That was a silent enforcement blind window. Now, before resuming the live
213
+ // stream after a drop, we BACKFILL the gap: page the persisted events endpoint
214
+ // for everything after the last id we yielded, feed those through, then resume
215
+ // live. Yielded ids are de-duplicated so the backfill/live overlap can't cause
216
+ // double-processing.
217
+ //
218
+ // `backfill(afterId, signal)` — optional async-iterable factory the caller
219
+ // injects (Shield passes one backed by fetchRawEvents with `afterId`). Kept as
220
+ // an injected dependency so this module stays import-free of the Anthropic
221
+ // source and remains unit-testable with a stub.
222
+ export async function* streamWithReconnect({ apiKey, sessionId, signal, maxAttempts = 5, onReconnect, backfill, _openStream = openEventStream }) {
183
223
  let attempt = 0;
224
+ let lastEventId = null;
225
+ const dedup = makeSeenIdSet();
226
+
227
+ // Yield an event unless we've already emitted it; track the last id so a
228
+ // reconnect knows where to backfill from.
229
+ function shouldYield(ev) {
230
+ if (ev && ev.id != null) {
231
+ if (dedup.seen(ev.id)) return false;
232
+ lastEventId = ev.id;
233
+ }
234
+ return true;
235
+ }
236
+
184
237
  while (true) {
185
238
  try {
186
- for await (const ev of openEventStream({ apiKey, sessionId, signal })) {
239
+ for await (const ev of _openStream({ apiKey, sessionId, signal })) {
187
240
  attempt = 0; // any event resets the backoff
188
- yield ev;
241
+ if (shouldYield(ev)) yield ev;
189
242
  }
190
243
  // Stream ended without throwing — session likely closed cleanly. Exit.
191
244
  return;
@@ -198,6 +251,22 @@ export async function* streamWithReconnect({ apiKey, sessionId, signal, maxAttem
198
251
  const backoffMs = Math.min(30_000, 1000 * 2 ** (attempt - 1));
199
252
  if (onReconnect) onReconnect({ attempt, backoffMs, error: e });
200
253
  await new Promise(r => setTimeout(r, backoffMs));
254
+
255
+ // F-55: backfill the gap the live stream missed before reconnecting.
256
+ // Best-effort: a backfill failure must not abort enforcement — we log
257
+ // it and fall through to the live re-open, which will itself retry.
258
+ if (backfill && lastEventId && !signal?.aborted) {
259
+ try {
260
+ let backfilled = 0;
261
+ for await (const ev of backfill(lastEventId, signal)) {
262
+ if (signal?.aborted) return;
263
+ if (shouldYield(ev)) { backfilled++; yield ev; }
264
+ }
265
+ if (backfilled && onReconnect) onReconnect({ attempt, backfilled, afterId: lastEventId });
266
+ } catch (be) {
267
+ if (onReconnect) onReconnect({ attempt, backfillError: be });
268
+ }
269
+ }
201
270
  }
202
271
  }
203
272
  }
@@ -63,6 +63,14 @@ export function buildFortressDecisionPayload({
63
63
  // enforcement instead of trusting the computed verdict. Boolean only; no
64
64
  // raw content, so Containment is unaffected.
65
65
  enforcementDelivered,
66
+ // v1.4.6 (SDK prereq for the Fortress OpenAI register flow): provider +
67
+ // native_agent_id so Fortress's ingest-decisions attributes the decision to
68
+ // the right runtime. Defaults preserve the legacy Anthropic shape
69
+ // (provider='anthropic-managed', anthropic_agent_id=agentId) so existing
70
+ // callers (wma-shield) are unchanged. ingest-decisions keys on
71
+ // (provider, native_agent_id) with a fallback to anthropic_agent_id.
72
+ provider,
73
+ nativeAgentId,
66
74
  }) {
67
75
  // F-19: vendor built-ins survive even without a salt (allowlist short-circuit);
68
76
  // custom tool names throw without a salt — we catch and drop the field.
@@ -76,8 +84,13 @@ export function buildFortressDecisionPayload({
76
84
  }
77
85
  }
78
86
 
87
+ const effectiveProvider = provider || 'anthropic-managed';
79
88
  return {
80
- anthropic_agent_id: agentId,
89
+ provider: effectiveProvider,
90
+ native_agent_id: nativeAgentId || agentId,
91
+ // Keep the legacy field only for the Anthropic runtime (an OpenAI agent id
92
+ // is not an `agent_…` value and the field is meaningless there).
93
+ anthropic_agent_id: effectiveProvider === 'anthropic-managed' ? agentId : undefined,
81
94
  decision: result.decision,
82
95
  rule_id: result.rule_id || undefined,
83
96
  session_hash: hashWithSaltOpt(sessionId, signalsSalt) || undefined,
@@ -168,8 +168,12 @@ export async function listSessions(apiKey, { agentId, since, limit = 100, maxPag
168
168
 
169
169
  // Yields raw events in chronological order. Accepts an optional types filter
170
170
  // to reduce payload (server-side `types[]=...&types[]=...`).
171
- export async function* fetchRawEvents(apiKey, sessionId, { types } = {}) {
172
- let after = null;
171
+ export async function* fetchRawEvents(apiKey, sessionId, { types, afterId } = {}) {
172
+ // v1.4.5 F-55: `afterId` lets a caller page only events AFTER a known id
173
+ // (exclusive) — used by Shield's reconnect backfill to fetch exactly the
174
+ // gap that the live SSE stream missed during a drop, rather than re-walking
175
+ // the whole session.
176
+ let after = afterId || null;
173
177
  while (true) {
174
178
  const qs = new URLSearchParams({ limit: '1000' });
175
179
  if (after) qs.set('after_id', after);
@@ -478,12 +478,13 @@ export function wmaToolInputGuardrail(options = {}) {
478
478
  const hasPolicySource =
479
479
  options.policiesPath != null ||
480
480
  options.ruleset != null ||
481
+ options.fortressPolicySource != null || // v1.4.6: live Fortress source
481
482
  options.allowAllWhenUnconfigured === true;
482
483
  if (!hasPolicySource) {
483
484
  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.',
485
+ 'wmaToolInputGuardrail: no policy configured. Pass policiesPath, ruleset, or ' +
486
+ 'fortressPolicySource. For demos / smoke tests that intentionally allow all ' +
487
+ 'tool calls, pass { allowAllWhenUnconfigured: true } explicitly.',
487
488
  );
488
489
  }
489
490
 
@@ -502,10 +503,16 @@ export function wmaToolInputGuardrail(options = {}) {
502
503
  // mis-attributed to 'anthropic-managed' (DecisionLogger's pre-v1.3.1
503
504
  // hardcoded default). Fortress / Guardian forensic surfaces depend on
504
505
  // this for correct multi-provider attribution.
506
+ // v1.4.6: when the customer registers a named agent (Fortress OpenAI flow),
507
+ // use that id for the NDJSON path + as the native_agent_id, instead of the
508
+ // generic 'openai-agents'. Falls back to 'openai-agents' for the un-named
509
+ // single-agent case (backward compatible). assertSafePathSegment in the
510
+ // Logger validates it as a path segment (fails loud on an unsafe name).
511
+ const loggerAgentId = options.agentId || 'openai-agents';
505
512
  const decisionLogger = options.decisionLogger
506
- || new DecisionLogger({ logDir, agentId: 'openai-agents', sessionId, provider: PROVIDER });
513
+ || new DecisionLogger({ logDir, agentId: loggerAgentId, sessionId, provider: PROVIDER });
507
514
  const logger = options.logger
508
- || new Logger({ logDir, agentId: 'openai-agents', sessionId, silent: true, bestEffort: false });
515
+ || new Logger({ logDir, agentId: loggerAgentId, sessionId, silent: true, bestEffort: false });
509
516
 
510
517
  // Team tracker — re-used for the entire run of this guardrail.
511
518
  const envTeamId = teamIdFromEnv();
@@ -516,7 +523,26 @@ export function wmaToolInputGuardrail(options = {}) {
516
523
  : () => teamTracker.bootstrap(sessionId);
517
524
 
518
525
  // Lazily load the ruleset on first invocation if a path was given.
526
+ // v1.4.6: SINGLE-FLIGHT start. start() creates a setInterval, so two
527
+ // concurrent first tool-calls must NOT both call it (that leaks a second
528
+ // timer the source can't clear + double-fetches). They share one promise;
529
+ // on failure we reset it so the next call retries (and that call fails
530
+ // CLOSED via the guardrail's try/catch in the meantime).
531
+ let fortressStartPromise = null;
519
532
  async function ensureRuleset() {
533
+ // v1.4.6: live Fortress policy source — pull on first call + background
534
+ // refresh (the source owns a setInterval; its timer is unref'd so it can't
535
+ // keep the process alive). On initial-fetch failure start() throws, which
536
+ // propagates to the guardrail's try/catch → that tool call fails CLOSED
537
+ // (default) and we retry start() next call rather than caching a failure.
538
+ if (options.fortressPolicySource) {
539
+ if (!fortressStartPromise) {
540
+ fortressStartPromise = Promise.resolve(options.fortressPolicySource.start())
541
+ .catch((e) => { fortressStartPromise = null; throw e; });
542
+ }
543
+ await fortressStartPromise;
544
+ return options.fortressPolicySource.current();
545
+ }
520
546
  if (ruleset != null) return ruleset;
521
547
  if (options.policiesPath) {
522
548
  ruleset = await loadPolicies(options.policiesPath);
@@ -588,6 +614,13 @@ export function wmaToolInputGuardrail(options = {}) {
588
614
  const decidedInMs = Date.now() - t0;
589
615
 
590
616
  // 4. Audit-grade log (chain-signed).
617
+ // v1.4.6: the in-process guardrail delivers a block SYNCHRONOUSLY by
618
+ // returning rejectContent/throwException below — there is no separate
619
+ // enforcement API call that can fail (unlike Anthropic's async
620
+ // interrupt). So an enforced deny/interrupt IS delivered: record it as
621
+ // such. undefined when not applicable (allow / shadow).
622
+ const enforcedBlock = result.mode !== 'shadow'
623
+ && (result.decision === 'deny' || result.decision === 'interrupt');
591
624
  await decisionLogger.record({
592
625
  sourceEvent: { id: event.id, type: event.action_type, tool_name: event.tool_name, input: event.input },
593
626
  decision: result.decision,
@@ -596,6 +629,7 @@ export function wmaToolInputGuardrail(options = {}) {
596
629
  message: result.message,
597
630
  decidedInMs,
598
631
  mode: result.mode,
632
+ enforcementDelivered: enforcedBlock ? true : undefined,
599
633
  });
600
634
 
601
635
  // 5. Watch log (the event itself, separate from the decision).