watchmyagents 1.4.6 → 1.4.8

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.
@@ -29,12 +29,31 @@ Sign in to the Fortress dashboard → **Settings → API Keys → Generate** →
29
29
  ```bash
30
30
  # .env
31
31
  WMA_API_KEY=wma_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
32
+ WMA_FORTRESS_BASE_URL=https://<project>.supabase.co/functions/v1 # for Fortress policies + decisions
33
+ WMA_SIGNALS_SALT=<stable per-customer salt> # hashes session/input in uploads
32
34
  WMA_LOG_DIR=./watchmyagents-logs # optional; default shown
33
35
  WMA_TEAM_ID=customer-support # optional; manual team tagging
34
- # WMA_POLICIES_SOURCE handling for Fortress-pulled policies arrives in 1.3.1
35
36
  ```
36
37
 
37
- Today the adapter loads policies from a local file via `policiesPath`. Fortress-pulled policies for this adapter land in a 1.3.x patch.
38
+ **Policy source (v1.4.6+).** The adapter can load policies two ways:
39
+ - **Fortress (recommended)** — `policies: { source: 'fortress' }` pulls the ruleset
40
+ live from Fortress (the same control plane `wma-shield` uses for Anthropic) and
41
+ refreshes it in the background. Requires `agentId` + `WMA_API_KEY` +
42
+ `WMA_FORTRESS_BASE_URL`. This is what the Fortress "Register an OpenAI agent"
43
+ onboarding gives you.
44
+ - **Local file** — `policiesPath: './policies.json'` (or an in-memory `ruleset`),
45
+ for tests / air-gapped setups.
46
+
47
+ ```typescript
48
+ import { openaiAgents } from 'watchmyagents/openai-agents';
49
+
50
+ const wma = openaiAgents({
51
+ mode: 'enforce',
52
+ agentId: 'support_bot', // your Fortress-registered agent id
53
+ policies: { source: 'fortress' }, // WMA_API_KEY + WMA_FORTRESS_BASE_URL from env
54
+ // policies: { source: 'fortress', uploadDecisions: false } // ← keep decisions local-only
55
+ });
56
+ ```
38
57
 
39
58
  ### 4. Wire it up — two patterns
40
59
 
@@ -190,20 +209,23 @@ attachWmaWatch(runner, {
190
209
 
191
210
  ## What gets sent to Fortress
192
211
 
193
- Tool arguments and results stay LOCAL. The anonymizer converts WMAAction → signals payload (salted hashes, no raw values) and pushes to `WMA_FORTRESS_BASE_URL/upload-signals` if `WMA_API_KEY` is set. See [CONTAINMENT.md](../CONTAINMENT.md) for the full egress model.
212
+ Tool arguments and results stay LOCAL. The anonymizer converts WMAAction → a signals payload (salted hashes, no raw values). Two egress paths, both anonymized see [CONTAINMENT.md](../CONTAINMENT.md) for the full model:
213
+
214
+ - **Signals** — aggregated counts + salted IoC hashes, uploaded to `ingest-signals` (run `wma-upload-fortress --provider openai-agents --agent-id <id>` against the NDJSON, or the Watch path).
215
+ - **Decisions (v1.4.7+)** — when `policies: { source: 'fortress' }` is set, the guardrail ships each Shield decision to `ingest-decisions` **fire-and-forget** (off the hot path). The payload carries `provider: 'openai-agents'`, `native_agent_id`, the decision/rule, salted `session_hash`/`event_id_hash`/`input_hash`, and `enforcement_delivered` (always `true` for an enforced deny/interrupt — the in-process guardrail blocks synchronously). The local NDJSON `shield_decision` row is the durable record; a failed upload never blocks the tool call. In-flight uploads are bounded (`maxDecisionUploadsInFlight`, default 32) — under a Fortress outage the excess is dropped from the live post (still in NDJSON). Opt out with `policies: { uploadDecisions: false }` to keep decisions local-only.
194
216
 
195
217
  ## Compatibility
196
218
 
197
219
  - Node.js: **20+** (matches the WMA SDK baseline and `@openai/agents`'s requirement)
198
- - TypeScript: any version — types live in `src/sources/openai-agents-js.d.ts`
220
+ - TypeScript: any version — `import { openaiAgents } from 'watchmyagents/openai-agents'` is fully typed (`src/openai-agents.d.ts`); the low-level escape hatches are typed in `src/sources/openai-agents-js.d.ts`.
199
221
  - `@openai/agents` version range: **`^0.2.0`** (declared in `package.json#peerDependencies`). Real fixtures in `test/fixtures/openai-agents-events/` were captured against 0.2.x and the adapter's lifecycle event signatures match that line. Older 0.1.x lacked AgentHooks ergonomics the adapter relies on.
200
222
 
201
- ## Limits + known gaps (v1.3.0)
223
+ ## Limits + known gaps (v1.4.8)
202
224
 
203
225
  | Gap | Workaround |
204
226
  |---|---|
205
227
  | Streaming mode behavior under Tool Input Guardrails not formally verified | We expect the SDK to honor guardrails in streaming mode (the SDK code paths are the same); the test fixtures cover non-streaming today. Verification with a streaming smoke test before v1.3.1. |
206
- | Fortress-pulled policies via `WMA_POLICIES_SOURCE=fortress` not wired in this adapter yet | Use `policiesPath` with a local file in v1.3.0; Fortress pull lands in v1.3.x patch |
228
+ | Decision uploads are live fire-and-forget a short-lived process can lose in-flight posts on exit | The NDJSON `shield_decision` row is durable; a batch backfill (NDJSON `ingest-decisions`) is planned for fully-durable short runs |
207
229
  | Tool Output Guardrails (post-execution filter) not exposed | Add only if customer demand — Shield's pre-tool deny covers 95% of the exfil-prevention use case |
208
230
  | Python Agents SDK | Separate package `watchmyagents-py` planned for v1.4.0 (Pattern 2 — pip-installable thin client that talks to a Node Shield daemon over UNIX socket) |
209
231
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "watchmyagents",
3
- "version": "1.4.6",
3
+ "version": "1.4.8",
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": [
@@ -22,7 +22,7 @@
22
22
  "exports": {
23
23
  ".": "./src/index.js",
24
24
  "./openai-agents": {
25
- "types": "./src/sources/openai-agents-js.d.ts",
25
+ "types": "./src/openai-agents.d.ts",
26
26
  "default": "./src/openai-agents.js"
27
27
  },
28
28
  "./package.json": "./package.json"
@@ -0,0 +1,110 @@
1
+ // Type declarations for the stable entry point `watchmyagents/openai-agents`.
2
+ //
3
+ // The runtime is src/openai-agents.js (the openaiAgents() factory + the
4
+ // re-exported low-level escape hatches). v1.4.8 (P2 quality): these types were
5
+ // previously pointed at the low-level module's .d.ts, which never declared
6
+ // openaiAgents() nor the v1.4.6/1.4.7 options (agentId, policies.source,
7
+ // uploadDecisions, signalsSalt) — TS users got a broken/incomplete typed API.
8
+
9
+ import type {
10
+ WMAToolInputGuardrail,
11
+ WMAGuardrailOptions,
12
+ WMAAdapterMeta,
13
+ WMARunnerLike,
14
+ WMAAgentLike,
15
+ WMAWatchOptions,
16
+ } from './sources/openai-agents-js';
17
+
18
+ // Re-export the low-level types + escape-hatch functions so TS users importing
19
+ // from 'watchmyagents/openai-agents' see the full surface the runtime exports.
20
+ export * from './sources/openai-agents-js';
21
+
22
+ /** Live policy source config. Today only Fortress is supported. */
23
+ export interface OpenaiAgentsPoliciesConfig {
24
+ /** Pull the ruleset from Fortress (the same control plane wma-shield uses). */
25
+ source: 'fortress';
26
+ /** Fortress functions base URL. Defaults to WMA_FORTRESS_BASE_URL. */
27
+ baseUrl?: string;
28
+ /** WMA API key. Defaults to WMA_API_KEY. */
29
+ apiKey?: string;
30
+ /** Require Ed25519-signed policies (strict mode). */
31
+ requireSignedPolicies?: boolean;
32
+ /** Fail mode when a refresh yields no valid policies ('open' | 'closed'). */
33
+ failMode?: 'open' | 'closed';
34
+ /** Ship Shield decisions to the same Fortress's ingest-decisions.
35
+ * Default: true. Set false to keep decisions local-only (NDJSON). */
36
+ uploadDecisions?: boolean;
37
+ }
38
+
39
+ /** Options for {@link openaiAgents}. */
40
+ export interface OpenaiAgentsOptions {
41
+ /** 'enforce' (default) requires a policy source; 'observe' attaches Watch
42
+ * only and makes .shield() throw. */
43
+ mode?: 'observe' | 'enforce';
44
+ /** Agent id registered in Fortress — NDJSON path + native_agent_id, and
45
+ * scopes the Fortress policy pull. Required with policies.source='fortress'. */
46
+ agentId?: string;
47
+ /** Live policy source (Fortress). Mutually complete with policiesPath/ruleset. */
48
+ policies?: OpenaiAgentsPoliciesConfig;
49
+ /** Local JSON policy file. */
50
+ policiesPath?: string;
51
+ /** In-memory ruleset (overrides policiesPath). */
52
+ ruleset?: { policies: unknown[]; default?: { action: string } };
53
+ /** NDJSON log dir. Defaults to WMA_LOG_DIR or ./watchmyagents-logs. */
54
+ logDir?: string;
55
+ /** Override the session id (default: auto-minted). */
56
+ sessionId?: string;
57
+ /** Salt for hashing session/input in the Fortress decision payload.
58
+ * Defaults to WMA_SIGNALS_SALT. */
59
+ signalsSalt?: string;
60
+ /** On Shield internal error, allow vs deny. Default: false (fail-CLOSED). */
61
+ failOpen?: boolean;
62
+ /** Trailing window for ctx.recent_error_rate etc. Default: 20. */
63
+ recentWindowSize?: number;
64
+ /** Custom team id resolver. */
65
+ getTeamId?: () => string | null;
66
+ /** Per-tool argument aliases ({ tool: { nativeField: canonical } }). */
67
+ toolInputs?: Record<string, Record<string, string>>;
68
+ /** Byte caps on captured tool args / results. Default 256 KB. */
69
+ maxArgBytes?: number;
70
+ maxResultBytes?: number;
71
+ /** Cap on concurrent in-flight decision uploads (excess dropped best-effort,
72
+ * NDJSON stays durable). Default: 32. */
73
+ maxDecisionUploadsInFlight?: number;
74
+ /** Inject for testing. */
75
+ tracker?: unknown;
76
+ logger?: unknown;
77
+ decisionLogger?: unknown;
78
+ }
79
+
80
+ /** The object returned by {@link openaiAgents}. */
81
+ export interface OpenaiAgentsHandle {
82
+ /** The @openai/agents Tool Input Guardrail (Shield). Throws in observe mode. */
83
+ shield(): WMAToolInputGuardrail;
84
+ /** Attach Watch — auto-detects Runner vs Agent. Returns a detach function. */
85
+ watch(target: WMARunnerLike | WMAAgentLike): () => void;
86
+ /** Adapter metadata. */
87
+ readonly meta: WMAAdapterMeta;
88
+ /** The configured mode. */
89
+ readonly mode: 'observe' | 'enforce';
90
+ }
91
+
92
+ /**
93
+ * Build the WMA OpenAI Agents SDK adapter from one shared config.
94
+ *
95
+ * ```typescript
96
+ * import { openaiAgents } from 'watchmyagents/openai-agents';
97
+ * import { Agent, run } from '@openai/agents';
98
+ *
99
+ * const wma = openaiAgents({
100
+ * mode: 'enforce',
101
+ * agentId: 'support_bot',
102
+ * policies: { source: 'fortress' }, // WMA_API_KEY + WMA_FORTRESS_BASE_URL from env
103
+ * });
104
+ *
105
+ * const agent = new Agent({ name: 'support_bot', tools, toolInputGuardrails: [wma.shield()] });
106
+ * wma.watch(agent);
107
+ * await run(agent, 'help me');
108
+ * ```
109
+ */
110
+ export function openaiAgents(options?: OpenaiAgentsOptions): OpenaiAgentsHandle;
@@ -40,7 +40,7 @@ import {
40
40
  attachWmaWatchToAgent,
41
41
  adapterMeta,
42
42
  } from './sources/openai-agents-js.js';
43
- import { FortressPolicySource } from './shield/sources/fortress.js';
43
+ import { FortressPolicySource, postDecision } from './shield/sources/fortress.js';
44
44
  import { resolveFortressBase } from './fortress/url.js';
45
45
 
46
46
  /**
@@ -101,6 +101,7 @@ export function openaiAgents(options = {}) {
101
101
  // wma-shield gets its ruleset for Anthropic. Requires an agentId to scope
102
102
  // the pull, a base URL (option or WMA_FORTRESS_BASE_URL), and WMA_API_KEY.
103
103
  let fortressPolicySource = null;
104
+ let fortressDecisionSink = null;
104
105
  if (options.policies && options.policies.source === 'fortress') {
105
106
  if (!options.agentId) {
106
107
  throw new Error(
@@ -132,6 +133,14 @@ export function openaiAgents(options = {}) {
132
133
  requireSignedPolicies: options.policies.requireSignedPolicies,
133
134
  failMode: options.policies.failMode,
134
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
+ }
135
144
  }
136
145
 
137
146
  // v1.4 Codex #2 — fail-loud refusal to start in enforce mode without
@@ -153,6 +162,9 @@ export function openaiAgents(options = {}) {
153
162
  const sharedOptions = {
154
163
  agentId: options.agentId, // v1.4.6: NDJSON path + native_agent_id
155
164
  fortressPolicySource, // v1.4.6: live Fortress ruleset (or null)
165
+ fortressDecisionSink, // v1.4.7 #1: ship decisions (or null)
166
+ maxDecisionUploadsInFlight: options.maxDecisionUploadsInFlight, // v1.4.8: backpressure cap
167
+ signalsSalt: options.signalsSalt || process.env.WMA_SIGNALS_SALT, // hash session/input
156
168
  policiesPath: options.policiesPath,
157
169
  ruleset: options.ruleset,
158
170
  logDir: options.logDir,
@@ -110,6 +110,32 @@ export interface WMAGuardrailOptions {
110
110
  decisionLogger?: WMADecisionLogger;
111
111
  /** Inject an existing ContextTracker (testing). */
112
112
  tracker?: WMAContextTracker;
113
+ /** v1.4.6 — agent id used for the NDJSON path segment + native_agent_id.
114
+ * Defaults to 'openai-agents' when un-named. */
115
+ agentId?: string;
116
+ /** v1.4.6 — a live policy source (e.g. FortressPolicySource). The guardrail
117
+ * single-flight-starts it then reads current() on every call. */
118
+ fortressPolicySource?: {
119
+ start(): Promise<void>;
120
+ current(): { policies: unknown[]; default?: { action: string } };
121
+ };
122
+ /** v1.4.7 — best-effort sink the guardrail fires each decision to
123
+ * (fire-and-forget). Built by openaiAgents() from policies.source='fortress'. */
124
+ fortressDecisionSink?: (payload: object) => unknown | Promise<unknown>;
125
+ /** v1.4.7 — salt for hashing session/input in the Fortress decision payload
126
+ * (defaults to WMA_SIGNALS_SALT). Without it, those hashes are null. */
127
+ signalsSalt?: string;
128
+ /** v1.4.7 — cap on concurrent in-flight decision uploads; excess are dropped
129
+ * best-effort (NDJSON stays durable). Default: 32. */
130
+ maxDecisionUploadsInFlight?: number;
131
+ /** v1.4 Codex #4 — per-tool argument aliases ({ tool: { nativeField: canonical } }). */
132
+ toolInputs?: Record<string, Record<string, string>>;
133
+ /** v1.4 F-32 — byte caps on captured tool args / results. Default 256 KB. */
134
+ maxArgBytes?: number;
135
+ maxResultBytes?: number;
136
+ /** v1.4.1 F-35 — explicit escape hatch to allow ALL tool calls when no policy
137
+ * source is configured (demos/smoke tests only). */
138
+ allowAllWhenUnconfigured?: boolean;
113
139
  }
114
140
 
115
141
  /**
@@ -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
 
@@ -522,6 +523,15 @@ export function wmaToolInputGuardrail(options = {}) {
522
523
  ? options.getTeamId
523
524
  : () => teamTracker.bootstrap(sessionId);
524
525
 
526
+ // v1.4.8 (P2 audit): bound the best-effort decision uploads. A Fortress
527
+ // outage + a high tool-call rate could otherwise accumulate unbounded
528
+ // in-flight POSTs in the agent process. We cap concurrency and DROP excess
529
+ // (the NDJSON shield_decision row is the durable record), with a throttled
530
+ // stderr summary so the operator notices sustained backpressure.
531
+ const maxDecisionUploadsInFlight = options.maxDecisionUploadsInFlight ?? 32;
532
+ let decisionUploadsInFlight = 0;
533
+ let decisionUploadsDropped = 0;
534
+
525
535
  // Lazily load the ruleset on first invocation if a path was given.
526
536
  // v1.4.6: SINGLE-FLIGHT start. start() creates a setInterval, so two
527
537
  // concurrent first tool-calls must NOT both call it (that leaks a second
@@ -632,6 +642,55 @@ export function wmaToolInputGuardrail(options = {}) {
632
642
  enforcementDelivered: enforcedBlock ? true : undefined,
633
643
  });
634
644
 
645
+ // 4b. v1.4.7 (#1): ship the decision to Fortress's ingest-decisions when
646
+ // a sink is configured. FIRE-AND-FORGET, off the guardrail's hot path —
647
+ // identical model to the Anthropic shield's fireToFortress(). The NDJSON
648
+ // row above is the durable local record; this live post is best-effort
649
+ // (a failure is swallowed, never blocks the tool call or crashes the
650
+ // guardrail). Payload is anonymized (hashes + decision/rule/provider) so
651
+ // Containment holds. Carries provider='openai-agents' + native_agent_id +
652
+ // enforcement_delivered (the v1.4.6 prereqs) so Fortress attributes it
653
+ // to the right runtime and surfaces degraded enforcement.
654
+ if (options.fortressDecisionSink) {
655
+ if (decisionUploadsInFlight >= maxDecisionUploadsInFlight) {
656
+ // Backpressure: drop the live post (NDJSON keeps the record).
657
+ decisionUploadsDropped++;
658
+ if (decisionUploadsDropped === 1 || decisionUploadsDropped % 100 === 0) {
659
+ process.stderr.write(
660
+ `[wma/openai-agents] Fortress decision-upload backpressure: ${decisionUploadsDropped} ` +
661
+ `decision(s) dropped from the live post (still in local NDJSON); ` +
662
+ `in-flight cap=${maxDecisionUploadsInFlight}\n`,
663
+ );
664
+ }
665
+ } else {
666
+ let payload = null;
667
+ try {
668
+ payload = buildFortressDecisionPayload({
669
+ agentId: loggerAgentId,
670
+ provider: PROVIDER,
671
+ nativeAgentId: loggerAgentId,
672
+ sessionId,
673
+ rawEvent: event,
674
+ normalized: event,
675
+ result,
676
+ decidedInMs,
677
+ signalsSalt: options.signalsSalt,
678
+ enforcementDelivered: enforcedBlock ? true : undefined,
679
+ });
680
+ } catch { /* build error → skip the post; never break enforcement */ }
681
+ if (payload) {
682
+ decisionUploadsInFlight++;
683
+ // Defer the sink call INTO the promise chain so a synchronous
684
+ // throw becomes a rejection — the .finally then always runs and
685
+ // the in-flight counter can never leak (which would wedge the cap).
686
+ Promise.resolve()
687
+ .then(() => options.fortressDecisionSink(payload))
688
+ .catch(() => undefined)
689
+ .finally(() => { decisionUploadsInFlight--; });
690
+ }
691
+ }
692
+ }
693
+
635
694
  // 5. Watch log (the event itself, separate from the decision).
636
695
  await logger.write(event);
637
696