watchmyagents 1.4.5 → 1.4.7

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.5",
3
+ "version": "1.4.7",
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": [
@@ -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, postDecision } 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,75 @@ 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
+ let fortressDecisionSink = null;
105
+ if (options.policies && options.policies.source === 'fortress') {
106
+ if (!options.agentId) {
107
+ throw new Error(
108
+ "openaiAgents({ policies: { source: 'fortress' } }) requires `agentId` " +
109
+ "(the agent id you registered in Fortress) to scope the policy pull.",
110
+ );
111
+ }
112
+ const apiKey = options.policies.apiKey || process.env.WMA_API_KEY;
113
+ if (!apiKey) {
114
+ throw new Error(
115
+ "openaiAgents fortress policies: WMA_API_KEY is required (env or " +
116
+ "policies.apiKey) to authenticate the policy pull.",
117
+ );
118
+ }
119
+ const base = resolveFortressBase({
120
+ explicitBase: options.policies.baseUrl,
121
+ env: process.env,
122
+ });
123
+ if (!base) {
124
+ throw new Error(
125
+ "openaiAgents fortress policies: no base URL — set policies.baseUrl or " +
126
+ "WMA_FORTRESS_BASE_URL (e.g. https://<project>.supabase.co/functions/v1).",
127
+ );
128
+ }
129
+ fortressPolicySource = new FortressPolicySource({
130
+ apiKey,
131
+ base,
132
+ anthropicAgentId: options.agentId, // scopes get-policies by native agent id
133
+ requireSignedPolicies: options.policies.requireSignedPolicies,
134
+ failMode: options.policies.failMode,
135
+ });
136
+
137
+ // v1.4.7 (#1): ship decisions to the SAME Fortress (the control plane you
138
+ // pull policies from is where the decisions belong). Auto-on; opt out with
139
+ // policies.uploadDecisions === false to keep decisions local-only. The
140
+ // guardrail fires these best-effort, off its hot path.
141
+ if (options.policies.uploadDecisions !== false) {
142
+ fortressDecisionSink = (decision) => postDecision({ apiKey, base, decision });
143
+ }
144
+ }
145
+
85
146
  // v1.4 Codex #2 — fail-loud refusal to start in enforce mode without
86
147
  // any policy source. Avoids the v1.3.0 footgun where missing policies
87
148
  // silently degraded to "allow all" with only a stderr warning. The
88
149
  // failure surfaces at config time (not on the first request) so it's
89
150
  // impossible to deploy a build that LOOKS armed but isn't.
90
- if (mode === 'enforce' && options.policiesPath == null && options.ruleset == null) {
151
+ if (mode === 'enforce' && options.policiesPath == null && options.ruleset == null
152
+ && fortressPolicySource == null) {
91
153
  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.",
154
+ "openaiAgents({ mode: 'enforce' }) requires policiesPath, ruleset, or " +
155
+ "policies: { source: 'fortress' }. Either provide one, or switch to " +
156
+ "{ mode: 'observe' } if you only want Watch (NDJSON capture) without Shield.",
95
157
  );
96
158
  }
97
159
 
98
160
  // Shared config we thread into both shield() and watch() so the
99
161
  // customer doesn't have to repeat it.
100
162
  const sharedOptions = {
163
+ agentId: options.agentId, // v1.4.6: NDJSON path + native_agent_id
164
+ fortressPolicySource, // v1.4.6: live Fortress ruleset (or null)
165
+ fortressDecisionSink, // v1.4.7 #1: ship decisions (or null)
166
+ signalsSalt: options.signalsSalt || process.env.WMA_SIGNALS_SALT, // hash session/input
101
167
  policiesPath: options.policiesPath,
102
168
  ruleset: options.ruleset,
103
169
  logDir: options.logDir,
@@ -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,
@@ -54,6 +54,7 @@ import {
54
54
  import { evaluate, loadPolicies } from '../shield/policy.js';
55
55
  import { createContextTracker } from '../shield/context.js';
56
56
  import { DecisionLogger } from '../shield/decisions.js';
57
+ import { buildFortressDecisionPayload } from '../shield/upload.js';
57
58
  import { Logger } from '../logger.js';
58
59
  import { normalizeToolInput } from '../anonymizer.js';
59
60
 
@@ -478,12 +479,13 @@ export function wmaToolInputGuardrail(options = {}) {
478
479
  const hasPolicySource =
479
480
  options.policiesPath != null ||
480
481
  options.ruleset != null ||
482
+ options.fortressPolicySource != null || // v1.4.6: live Fortress source
481
483
  options.allowAllWhenUnconfigured === true;
482
484
  if (!hasPolicySource) {
483
485
  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.',
486
+ 'wmaToolInputGuardrail: no policy configured. Pass policiesPath, ruleset, or ' +
487
+ 'fortressPolicySource. For demos / smoke tests that intentionally allow all ' +
488
+ 'tool calls, pass { allowAllWhenUnconfigured: true } explicitly.',
487
489
  );
488
490
  }
489
491
 
@@ -502,10 +504,16 @@ export function wmaToolInputGuardrail(options = {}) {
502
504
  // mis-attributed to 'anthropic-managed' (DecisionLogger's pre-v1.3.1
503
505
  // hardcoded default). Fortress / Guardian forensic surfaces depend on
504
506
  // this for correct multi-provider attribution.
507
+ // v1.4.6: when the customer registers a named agent (Fortress OpenAI flow),
508
+ // use that id for the NDJSON path + as the native_agent_id, instead of the
509
+ // generic 'openai-agents'. Falls back to 'openai-agents' for the un-named
510
+ // single-agent case (backward compatible). assertSafePathSegment in the
511
+ // Logger validates it as a path segment (fails loud on an unsafe name).
512
+ const loggerAgentId = options.agentId || 'openai-agents';
505
513
  const decisionLogger = options.decisionLogger
506
- || new DecisionLogger({ logDir, agentId: 'openai-agents', sessionId, provider: PROVIDER });
514
+ || new DecisionLogger({ logDir, agentId: loggerAgentId, sessionId, provider: PROVIDER });
507
515
  const logger = options.logger
508
- || new Logger({ logDir, agentId: 'openai-agents', sessionId, silent: true, bestEffort: false });
516
+ || new Logger({ logDir, agentId: loggerAgentId, sessionId, silent: true, bestEffort: false });
509
517
 
510
518
  // Team tracker — re-used for the entire run of this guardrail.
511
519
  const envTeamId = teamIdFromEnv();
@@ -516,7 +524,26 @@ export function wmaToolInputGuardrail(options = {}) {
516
524
  : () => teamTracker.bootstrap(sessionId);
517
525
 
518
526
  // Lazily load the ruleset on first invocation if a path was given.
527
+ // v1.4.6: SINGLE-FLIGHT start. start() creates a setInterval, so two
528
+ // concurrent first tool-calls must NOT both call it (that leaks a second
529
+ // timer the source can't clear + double-fetches). They share one promise;
530
+ // on failure we reset it so the next call retries (and that call fails
531
+ // CLOSED via the guardrail's try/catch in the meantime).
532
+ let fortressStartPromise = null;
519
533
  async function ensureRuleset() {
534
+ // v1.4.6: live Fortress policy source — pull on first call + background
535
+ // refresh (the source owns a setInterval; its timer is unref'd so it can't
536
+ // keep the process alive). On initial-fetch failure start() throws, which
537
+ // propagates to the guardrail's try/catch → that tool call fails CLOSED
538
+ // (default) and we retry start() next call rather than caching a failure.
539
+ if (options.fortressPolicySource) {
540
+ if (!fortressStartPromise) {
541
+ fortressStartPromise = Promise.resolve(options.fortressPolicySource.start())
542
+ .catch((e) => { fortressStartPromise = null; throw e; });
543
+ }
544
+ await fortressStartPromise;
545
+ return options.fortressPolicySource.current();
546
+ }
520
547
  if (ruleset != null) return ruleset;
521
548
  if (options.policiesPath) {
522
549
  ruleset = await loadPolicies(options.policiesPath);
@@ -588,6 +615,13 @@ export function wmaToolInputGuardrail(options = {}) {
588
615
  const decidedInMs = Date.now() - t0;
589
616
 
590
617
  // 4. Audit-grade log (chain-signed).
618
+ // v1.4.6: the in-process guardrail delivers a block SYNCHRONOUSLY by
619
+ // returning rejectContent/throwException below — there is no separate
620
+ // enforcement API call that can fail (unlike Anthropic's async
621
+ // interrupt). So an enforced deny/interrupt IS delivered: record it as
622
+ // such. undefined when not applicable (allow / shadow).
623
+ const enforcedBlock = result.mode !== 'shadow'
624
+ && (result.decision === 'deny' || result.decision === 'interrupt');
591
625
  await decisionLogger.record({
592
626
  sourceEvent: { id: event.id, type: event.action_type, tool_name: event.tool_name, input: event.input },
593
627
  decision: result.decision,
@@ -596,8 +630,36 @@ export function wmaToolInputGuardrail(options = {}) {
596
630
  message: result.message,
597
631
  decidedInMs,
598
632
  mode: result.mode,
633
+ enforcementDelivered: enforcedBlock ? true : undefined,
599
634
  });
600
635
 
636
+ // 4b. v1.4.7 (#1): ship the decision to Fortress's ingest-decisions when
637
+ // a sink is configured. FIRE-AND-FORGET, off the guardrail's hot path —
638
+ // identical model to the Anthropic shield's fireToFortress(). The NDJSON
639
+ // row above is the durable local record; this live post is best-effort
640
+ // (a failure is swallowed, never blocks the tool call or crashes the
641
+ // guardrail). Payload is anonymized (hashes + decision/rule/provider) so
642
+ // Containment holds. Carries provider='openai-agents' + native_agent_id +
643
+ // enforcement_delivered (the v1.4.6 prereqs) so Fortress attributes it
644
+ // to the right runtime and surfaces degraded enforcement.
645
+ if (options.fortressDecisionSink) {
646
+ try {
647
+ const payload = buildFortressDecisionPayload({
648
+ agentId: loggerAgentId,
649
+ provider: PROVIDER,
650
+ nativeAgentId: loggerAgentId,
651
+ sessionId,
652
+ rawEvent: event,
653
+ normalized: event,
654
+ result,
655
+ decidedInMs,
656
+ signalsSalt: options.signalsSalt,
657
+ enforcementDelivered: enforcedBlock ? true : undefined,
658
+ });
659
+ Promise.resolve(options.fortressDecisionSink(payload)).catch(() => undefined);
660
+ } catch { /* payload build must never break enforcement */ }
661
+ }
662
+
601
663
  // 5. Watch log (the event itself, separate from the decision).
602
664
  await logger.write(event);
603
665