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 +1 -1
- package/scripts/shield.js +15 -3
- package/scripts/upload-fortress.js +26 -8
- package/src/openai-agents.js +59 -4
- package/src/shield/stream.js +72 -3
- package/src/shield/upload.js +14 -1
- package/src/sources/anthropic-managed.js +6 -2
- package/src/sources/openai-agents-js.js +39 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "watchmyagents",
|
|
3
|
-
"version": "1.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
|
-
|
|
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 (
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
249
|
+
provider,
|
|
235
250
|
native_agent_id: agentId,
|
|
236
|
-
|
|
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
|
|
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,
|
package/src/openai-agents.js
CHANGED
|
@@ -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
|
|
93
|
-
"Either provide one, or switch to
|
|
94
|
-
"want Watch (NDJSON capture) without Shield
|
|
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,
|
package/src/shield/stream.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
}
|
package/src/shield/upload.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
485
|
-
'For demos / smoke tests that intentionally allow all
|
|
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:
|
|
513
|
+
|| new DecisionLogger({ logDir, agentId: loggerAgentId, sessionId, provider: PROVIDER });
|
|
507
514
|
const logger = options.logger
|
|
508
|
-
|| new Logger({ logDir, agentId:
|
|
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).
|