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