watchmyagents 1.4.5 → 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.5",
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": [
@@ -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,
@@ -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,
@@ -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).