watchmyagents 0.9.4 → 1.0.1
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/README.md +6 -6
- package/SECURITY.md +15 -5
- package/package.json +4 -4
- package/scripts/fetch-anthropic.js +47 -4
- package/scripts/{anonymize.js → signals.js} +16 -10
- package/scripts/upload-fortress.js +19 -1
- package/src/anonymizer.js +59 -8
- package/src/logger.js +15 -2
- package/src/shield/decisions.js +1 -1
- package/src/sources/anthropic-managed.js +179 -1
- package/src/sources/contract.js +259 -0
package/README.md
CHANGED
|
@@ -122,18 +122,18 @@ wma-fetch (--agent-id <agent_id> | --all-agents) [--session-id <sess_id>] [--sin
|
|
|
122
122
|
| `--interval 5m` | Poll interval in watch mode (default `5m`; accepts `30s`/`1h`/…) |
|
|
123
123
|
| `--upload` | In watch mode, anonymize each new window and ship signals to Fortress (needs `WMA_API_KEY` + `WMA_FORTRESS_BASE_URL` + `WMA_SIGNALS_SALT`). Raw stays local. |
|
|
124
124
|
| `--discovery-since 7d` | Window for discovering NEW sessions (default `7d`). Sessions already being tracked are re-fetched regardless of age, so long-running ones never drop out. |
|
|
125
|
-
| `--send-agent-names` | Opt-
|
|
125
|
+
| `--no-send-agent-names` | Opt-out: send only the agent id as the Fortress `display_name`. **By default, the human agent name** (sanitized) is sent so dashboards/decisions stay legible. Pass this flag if your agent names themselves carry client/project info you'd rather keep pseudonymized. |
|
|
126
126
|
| `--api-key sk-ant-…` | Override the `ANTHROPIC_API_KEY` env var. **Discouraged** — visible in shell history & process list. Prefer the env var. |
|
|
127
127
|
|
|
128
128
|
Logs land in `./watchmyagents-logs/<agent_id>/<date>.ndjson` (file mode `0600`, dir `0700`).
|
|
129
129
|
|
|
130
|
-
### `wma-
|
|
130
|
+
### `wma-signals` — preview what would leave your machine
|
|
131
131
|
|
|
132
132
|
Produces the anonymized signals payload (counts, latencies, salted IoC hashes, sequence histograms — no raw URLs/commands/prompts) that future WMA cloud features would ship. Useful to verify Containment compliance and to test the format.
|
|
133
133
|
|
|
134
134
|
```bash
|
|
135
135
|
export WMA_SIGNALS_SALT="$(node -e 'console.log(require("crypto").randomBytes(16).toString("hex"))')"
|
|
136
|
-
wma-
|
|
136
|
+
wma-signals ./watchmyagents-logs
|
|
137
137
|
# → JSON on stdout. Add --out signals.json to write to file.
|
|
138
138
|
```
|
|
139
139
|
|
|
@@ -146,7 +146,7 @@ Anonymizes your local NDJSON and POSTs the resulting payload to the WMA Fortress
|
|
|
146
146
|
```bash
|
|
147
147
|
export WMA_API_KEY="wma_..." # from Fortress dashboard → Settings → API Keys
|
|
148
148
|
export WMA_FORTRESS_URL="https://<your-project>.supabase.co/functions/v1/ingest-signals"
|
|
149
|
-
export WMA_SIGNALS_SALT="..." # same salt as wma-
|
|
149
|
+
export WMA_SIGNALS_SALT="..." # same salt as wma-signals
|
|
150
150
|
|
|
151
151
|
wma-upload-fortress --agent-id agent_01ABC... [--display-name "My agent"]
|
|
152
152
|
# → POSTs the anonymized payload. Server returns signal_id + agent_id.
|
|
@@ -155,7 +155,7 @@ wma-upload-fortress --agent-id agent_01ABC... [--display-name "My agent"]
|
|
|
155
155
|
wma-upload-fortress --agent-id agent_xxx --dry-run
|
|
156
156
|
```
|
|
157
157
|
|
|
158
|
-
**What is sent:** the anonymized signals payload (counts, latencies, salted IoC hashes, sequences — same as `wma-
|
|
158
|
+
**What is sent:** the anonymized signals payload (counts, latencies, salted IoC hashes, sequences — same as `wma-signals` output), the agent's **`classification`** when the daemon has it (`{agent_type, confidence, stage}` — anonymized metadata, never raw content), **plus the routing identifiers**: `provider` (e.g., `"anthropic-managed"` — added in v1.0 for the multi-framework SDK), `native_agent_id` (the canonical provider-agnostic field), `anthropic_agent_id` (kept for backwards compat with existing Fortress instances; will be dropped once Fortress migrates), `parent_agent_id` (`null` for root agents — populated for sub-agents detected via OpenAI Agents handoffs, CrewAI manager mode, Hermes Agent `spawn_subagent`, LangGraph sub-graphs), `composition_pattern` (`"solo" | "hierarchy" | "graph" | "peer"` — defaults to `"solo"` for Anthropic until thread-message detection lands), `enforcement_mode` (`"sync_confirm" | "sync_interrupt" | "detect_only"` — the strongest enforcement capability the Source provides; Fortress greys out Shield UI for `detect_only` agents to prevent UI/runtime mismatch), and a `display_name`. The agent id is required so Fortress can associate signals with the right agent; `display_name` defaults to the **human-readable agent name** (sanitized to strip control chars) for UX in the dashboard — pass `--no-send-agent-names` to keep it pseudonymized (sends the agent id instead) if your agent names themselves carry sensitive client/project info.
|
|
159
159
|
**What is NOT sent:** raw prompts, raw URLs/commands/queries, raw agent responses, raw error messages. All payload content stays on your machine.
|
|
160
160
|
|
|
161
161
|
The endpoint auto-registers the agent on the first upload if it doesn't exist in Fortress yet — no manual onboarding needed for new agents.
|
|
@@ -247,7 +247,7 @@ WatchMyAgents is built so that **your prompts and outputs never have to leave yo
|
|
|
247
247
|
|---|---|
|
|
248
248
|
| **Your machine** (`./watchmyagents-logs/`) | Full NDJSON with all prompts, tool inputs, agent outputs. `chmod 600` on every file. |
|
|
249
249
|
| **Anthropic API** | Where the agent runs. WMA pulls events via the public REST API only. |
|
|
250
|
-
| **WMA Fortress** (opt-in, only with `--upload` / `wma-upload-fortress` / `wma-shield --policies-source fortress`) | The **anonymized signals** payload (counts, timings, salted hashes, sequences) +
|
|
250
|
+
| **WMA Fortress** (opt-in, only with `--upload` / `wma-upload-fortress` / `wma-shield --policies-source fortress`) | The **anonymized signals** payload (counts, timings, salted hashes, sequences) + routing identifiers: `provider` (e.g. `"anthropic-managed"`), `native_agent_id`, `anthropic_agent_id` (legacy alias), and `display_name` (defaults to the **human agent name** for dashboard UX — pass `--no-send-agent-names` to opt out and send only the agent id). Shield enforcement **decisions** (hashed session/event/input fingerprints — never raw values). **Never** raw prompts, URLs, commands, or outputs. |
|
|
251
251
|
|
|
252
252
|
This is the "local-first" guarantee: **raw payloads never leave your machine.** Cloud upload is opt-in and carries only anonymized metadata + the agent id/name needed to route it.
|
|
253
253
|
|
package/SECURITY.md
CHANGED
|
@@ -30,10 +30,21 @@ WMA needs your Anthropic API key to call the Managed Agents REST API on your beh
|
|
|
30
30
|
|
|
31
31
|
### What WMA does NOT do
|
|
32
32
|
|
|
33
|
-
- ❌
|
|
34
|
-
- ❌ Does not
|
|
35
|
-
- ❌ Does not
|
|
36
|
-
|
|
33
|
+
- ❌ No phone-home, no usage analytics, no silent telemetry — WMA never opens a network connection to a WMA-controlled endpoint on its own.
|
|
34
|
+
- ❌ Does not store, log, or transmit your Anthropic API key anywhere except `api.anthropic.com`.
|
|
35
|
+
- ❌ Does not require an account, signup, or license key.
|
|
36
|
+
|
|
37
|
+
### Fortress upload — strictly opt-in
|
|
38
|
+
|
|
39
|
+
Since v0.5.0, WMA supports an **opt-in** cloud component (WMA Fortress) for teams who want a multi-agent dashboard + cross-fleet Guardian analysis. The upload only happens when you explicitly invoke `--upload` on `wma-fetch`, run `wma-upload-fortress`, or run `wma-shield --policies-source fortress`. The defaults across all CLIs are zero-cloud — your machine stays the only place raw data ever exists.
|
|
40
|
+
|
|
41
|
+
What goes to Fortress when you opt in:
|
|
42
|
+
- ✅ The **anonymized signals payload** (counts, latencies, salted IoC hashes, sequences, classification metadata) — see [`docs/CONTAINMENT.md`](docs/CONTAINMENT.md) for the bit-exact contract and the 6 invariant tests that lock it down.
|
|
43
|
+
- ✅ Routing identifiers (`provider`, `native_agent_id`, optionally the human `display_name` — see `--no-send-agent-names` to opt this out).
|
|
44
|
+
|
|
45
|
+
What does **NOT** go to Fortress, ever:
|
|
46
|
+
- ❌ Raw prompts, agent outputs, tool inputs, tool outputs, error message text, raw URLs, raw commands, raw queries — these stay in your local `watchmyagents-logs/`.
|
|
47
|
+
- ❌ Your Anthropic API key. Fortress authenticates with a separate `WMA_API_KEY` issued from your Fortress account and never sees `ANTHROPIC_API_KEY`.
|
|
37
48
|
|
|
38
49
|
## Threat model
|
|
39
50
|
|
|
@@ -56,7 +67,6 @@ WMA combines **two complementary layers**:
|
|
|
56
67
|
- **Pre-installation activity.** Shield only enforces from the moment it attaches forward. Past events are not retroactively replayed or re-evaluated.
|
|
57
68
|
- **A malicious policy file.** Shield's policy engine refuses obviously unsafe regex patterns (e.g. catastrophic backtracking) and truncates inputs before regex tests to mitigate ReDoS. But a user-controlled policy file remains a code-adjacent input — treat it as you would treat sourcecode.
|
|
58
69
|
- **A compromised Anthropic API.** WMA trusts the events delivered by Anthropic. This is out of scope.
|
|
59
|
-
- **A compromised Anthropic API.** WMA trusts the events delivered by Anthropic. This is out of scope.
|
|
60
70
|
|
|
61
71
|
## Supply chain
|
|
62
72
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "watchmyagents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.1",
|
|
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": [
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"scripts/inspect.js",
|
|
9
9
|
"scripts/fetch-anthropic.js",
|
|
10
10
|
"scripts/shield.js",
|
|
11
|
-
"scripts/
|
|
11
|
+
"scripts/signals.js",
|
|
12
12
|
"scripts/upload-fortress.js",
|
|
13
13
|
"scripts/service.js",
|
|
14
14
|
"scripts/agents.js",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"wma-inspect": "scripts/inspect.js",
|
|
21
21
|
"wma-fetch": "scripts/fetch-anthropic.js",
|
|
22
22
|
"wma-shield": "scripts/shield.js",
|
|
23
|
-
"wma-
|
|
23
|
+
"wma-signals": "scripts/signals.js",
|
|
24
24
|
"wma-upload-fortress": "scripts/upload-fortress.js",
|
|
25
25
|
"wma-service": "scripts/service.js",
|
|
26
26
|
"wma-agents": "scripts/agents.js"
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"inspect": "node scripts/inspect.js",
|
|
31
31
|
"fetch": "node scripts/fetch-anthropic.js",
|
|
32
32
|
"shield": "node scripts/shield.js",
|
|
33
|
-
"
|
|
33
|
+
"signals": "node scripts/signals.js",
|
|
34
34
|
"upload-fortress": "node scripts/upload-fortress.js",
|
|
35
35
|
"service": "node scripts/service.js",
|
|
36
36
|
"agents": "node scripts/agents.js"
|
|
@@ -34,6 +34,7 @@ import { classifyAgentType } from '../src/typology.js';
|
|
|
34
34
|
import { aggregate, buildFeatures } from '../src/typology-features.js';
|
|
35
35
|
import {
|
|
36
36
|
getAgent, listAgents, listSessions, fetchSessionEntries, fetchRawEvents,
|
|
37
|
+
AnthropicManagedSource, effectiveEnforcementMode,
|
|
37
38
|
} from '../src/sources/anthropic-managed.js';
|
|
38
39
|
|
|
39
40
|
function parseArgs(argv) {
|
|
@@ -110,13 +111,39 @@ function postJson(url, headers, body) {
|
|
|
110
111
|
// `classification` (optional) carries the agent's typology — Fortress upserts
|
|
111
112
|
// agent_type/confidence/stage on the agent row so the typology badge + the
|
|
112
113
|
// apply-template flow fill themselves with no manual click.
|
|
113
|
-
async function uploadSignals(uploadCtx, agentId, displayName, entries, classification) {
|
|
114
|
+
async function uploadSignals(uploadCtx, agentId, displayName, entries, classification, enforcementMode) {
|
|
114
115
|
const agg = new SignalsAggregator({ salt: uploadCtx.salt });
|
|
115
116
|
for (const e of entries) agg.add(e);
|
|
116
117
|
const sig = agg.finalize();
|
|
117
118
|
if (!sig.window_start || !sig.window_end) return null; // nothing datable to ship
|
|
119
|
+
// PR-C: derive the agent's composition pattern + parent from the
|
|
120
|
+
// observed entries. For Anthropic today, the Source yields solo/root
|
|
121
|
+
// agents — sub-agent detection from thread_message_* events lands
|
|
122
|
+
// with PR-D or a follow-up. Once a future adapter populates these on
|
|
123
|
+
// the events themselves, this carries them up to Fortress without
|
|
124
|
+
// any payload-shape change.
|
|
125
|
+
const firstWithHierarchy = entries.find((e) => e.parent_agent_id != null);
|
|
126
|
+
const parent_agent_id = firstWithHierarchy?.parent_agent_id ?? null;
|
|
127
|
+
const composition_pattern = firstWithHierarchy?.composition_pattern
|
|
128
|
+
|| entries.find((e) => e.composition_pattern && e.composition_pattern !== 'solo')?.composition_pattern
|
|
129
|
+
|| 'solo';
|
|
130
|
+
// PR-B: payload carries the canonical provider-agnostic identifiers
|
|
131
|
+
// (`provider` + `native_agent_id`) AND the legacy `anthropic_agent_id`
|
|
132
|
+
// so old Fortress instances still recognize the upload. Once the
|
|
133
|
+
// Lovable-deployed ingest-signals migrates, future SDK releases will
|
|
134
|
+
// stop emitting `anthropic_agent_id`.
|
|
135
|
+
// PR-D / v1.0.1 F-2: enforcement_mode is the EFFECTIVE per-agent mode
|
|
136
|
+
// (sync_confirm only if the agent has permission_policy: always_ask on
|
|
137
|
+
// at least one tool; sync_interrupt otherwise). Falls back to the
|
|
138
|
+
// Source's static MAX capability if the resolution failed upstream —
|
|
139
|
+
// legacy behavior, but flags a warning in the daemon log.
|
|
118
140
|
const body = JSON.stringify({
|
|
141
|
+
provider: AnthropicManagedSource.providerName,
|
|
142
|
+
native_agent_id: agentId,
|
|
119
143
|
anthropic_agent_id: agentId,
|
|
144
|
+
parent_agent_id,
|
|
145
|
+
composition_pattern,
|
|
146
|
+
enforcement_mode: enforcementMode || AnthropicManagedSource.enforcementMode,
|
|
120
147
|
display_name: displayName,
|
|
121
148
|
window_start: sig.window_start,
|
|
122
149
|
window_end: sig.window_end,
|
|
@@ -194,7 +221,7 @@ async function fetchOneShot({ apiKey, agentId, model, logDir, since, sessionId,
|
|
|
194
221
|
}
|
|
195
222
|
const stats = tracker.stats().total;
|
|
196
223
|
await logger.write({
|
|
197
|
-
action_type: 'session_end',
|
|
224
|
+
action_type: 'session_end', provider: 'anthropic-managed', status: 'ok', model,
|
|
198
225
|
session_tokens: { input: stats.input, output: stats.output, cache_read: stats.cache_read, cache_creation: stats.cache_creation, total: stats.sum },
|
|
199
226
|
session_cost_usd: stats.cost_usd || null,
|
|
200
227
|
});
|
|
@@ -225,6 +252,10 @@ async function runWatch({ apiKey, resolveAgents, fleet, logDir, intervalMs, wind
|
|
|
225
252
|
const sessionAgent = new Map();// sessionId → { agentId, model, displayName }
|
|
226
253
|
const priors = new Map(); // agentId → previous classification (threads the
|
|
227
254
|
// typology state machine across upload cycles)
|
|
255
|
+
// F-2: cache the effective enforcement mode per agent. One getAgent call
|
|
256
|
+
// per agent per daemon run (until the entry is evicted). Refreshed only
|
|
257
|
+
// if upload fails — agent permission_policy doesn't change mid-flight.
|
|
258
|
+
const enforcementModes = new Map(); // agentId → 'sync_confirm' | 'sync_interrupt'
|
|
228
259
|
|
|
229
260
|
const ac = new AbortController();
|
|
230
261
|
const shutdown = () => { info('shutting down…'); ac.abort(); };
|
|
@@ -294,7 +325,19 @@ async function runWatch({ apiKey, resolveAgents, fleet, logDir, intervalMs, wind
|
|
|
294
325
|
classification = { agent_type: cls.classified_type, confidence: cls.confidence, stage: cls.stage };
|
|
295
326
|
} catch (e) { warn(` classification skipped: ${e.message}`); }
|
|
296
327
|
|
|
297
|
-
|
|
328
|
+
// F-2: resolve the effective enforcement mode for this agent
|
|
329
|
+
// (cached across cycles). On failure, fall back to the static
|
|
330
|
+
// provider max so the upload still succeeds.
|
|
331
|
+
let mode = enforcementModes.get(ag.agentId);
|
|
332
|
+
if (!mode) {
|
|
333
|
+
try {
|
|
334
|
+
mode = await effectiveEnforcementMode(apiKey, ag.agentId);
|
|
335
|
+
enforcementModes.set(ag.agentId, mode);
|
|
336
|
+
} catch (e) {
|
|
337
|
+
warn(` enforcement_mode resolution failed for ${ag.agentId}: ${e.message} (falling back to provider max)`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const resp = await uploadSignals(uploadCtx, ag.agentId, sendNames ? ag.displayName : ag.agentId, fresh, classification, mode);
|
|
298
341
|
if (resp?.signal_id) {
|
|
299
342
|
const cTag = classification ? ` · type ${classification.agent_type} (${Math.round(classification.confidence * 100)}%, ${classification.stage})` : '';
|
|
300
343
|
info(` ↑ signals uploaded (signal_id ${resp.signal_id})${cTag}`);
|
|
@@ -307,7 +350,7 @@ async function runWatch({ apiKey, resolveAgents, fleet, logDir, intervalMs, wind
|
|
|
307
350
|
for (const e of fresh) tracker.record(e);
|
|
308
351
|
const stats = tracker.stats().total;
|
|
309
352
|
await logger.write({
|
|
310
|
-
action_type: 'session_end',
|
|
353
|
+
action_type: 'session_end', provider: 'anthropic-managed', status: 'ok', model: ag.model,
|
|
311
354
|
session_tokens: { input: stats.input, output: stats.output, cache_read: stats.cache_read, cache_creation: stats.cache_creation, total: stats.sum },
|
|
312
355
|
session_cost_usd: stats.cost_usd || null,
|
|
313
356
|
});
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// wma-
|
|
3
|
-
//
|
|
2
|
+
// wma-signals — build the signals payload that Watch would send to
|
|
3
|
+
// Fortress, for inspection / verification.
|
|
4
|
+
//
|
|
5
|
+
// (Renamed from `wma-anonymize` in v1.0.1. The script's job is to PRODUCE
|
|
6
|
+
// the signals payload; anonymization is a property of that payload,
|
|
7
|
+
// guaranteed by the underlying SignalsAggregator. The new name aligns
|
|
8
|
+
// with the rest of the product vocabulary: SignalsAggregator,
|
|
9
|
+
// ingest-signals Edge Function, signals.payload shape.)
|
|
4
10
|
//
|
|
5
11
|
// Usage:
|
|
6
|
-
// wma-
|
|
12
|
+
// wma-signals <path-to-ndjson-or-dir> [--salt <hex>] [--out <file>]
|
|
7
13
|
//
|
|
8
14
|
// The `--salt` argument MUST be a stable per-customer secret. Using a
|
|
9
15
|
// random salt each run means hashes won't correlate across runs (useless
|
|
@@ -56,11 +62,11 @@ async function main() {
|
|
|
56
62
|
const args = parseArgs(process.argv.slice(2));
|
|
57
63
|
|
|
58
64
|
if (!args._target) {
|
|
59
|
-
die(`usage: wma-
|
|
65
|
+
die(`usage: wma-signals <path> [--salt <hex>] [--out <file>]
|
|
60
66
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
67
|
+
Builds the signals payload that Watch would send to Fortress, from
|
|
68
|
+
local NDJSON logs. Use this to inspect exactly what leaves your
|
|
69
|
+
machine BEFORE any upload feature is enabled.
|
|
64
70
|
|
|
65
71
|
Required: --salt <hex> or WMA_SIGNALS_SALT env var (per-customer secret).
|
|
66
72
|
If you don't have one, generate: node -e "console.log(require('crypto').randomBytes(16).toString('hex'))"
|
|
@@ -73,8 +79,8 @@ and save it in .env.local.`);
|
|
|
73
79
|
' generate one with: node -e "console.log(require(\'crypto\').randomBytes(16).toString(\'hex\'))"');
|
|
74
80
|
}
|
|
75
81
|
if (args.salt) {
|
|
76
|
-
process.stderr.write('[wma-
|
|
77
|
-
'
|
|
82
|
+
process.stderr.write('[wma-signals] warning: --salt on the command line is visible in shell history.\n' +
|
|
83
|
+
' Prefer: export WMA_SIGNALS_SALT=...\n');
|
|
78
84
|
}
|
|
79
85
|
if (salt.length < 16) {
|
|
80
86
|
die('error: salt too short (need ≥16 hex chars / ≥8 bytes of entropy)');
|
|
@@ -102,7 +108,7 @@ and save it in .env.local.`);
|
|
|
102
108
|
const json = JSON.stringify(signals, null, 2);
|
|
103
109
|
if (args.out) {
|
|
104
110
|
await writeFile(resolve(args.out), json + '\n', { encoding: 'utf8', mode: 0o600 });
|
|
105
|
-
process.stderr.write(`[wma-
|
|
111
|
+
process.stderr.write(`[wma-signals] wrote ${args.out} (${signals._meta.entries_processed} entries processed)\n`);
|
|
106
112
|
} else {
|
|
107
113
|
process.stdout.write(json + '\n');
|
|
108
114
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Composable with the rest of the SDK:
|
|
6
6
|
// wma-fetch → ./watchmyagents-logs/<agent_id>/<date>.ndjson (local capture)
|
|
7
|
-
// wma-
|
|
7
|
+
// wma-signals → signals payload (Containment: no raw content)
|
|
8
8
|
// wma-upload-fortress → POST signals to https://<project>.supabase.co/functions/v1/ingest-signals
|
|
9
9
|
//
|
|
10
10
|
// Usage:
|
|
@@ -30,6 +30,7 @@ import { createReadStream } from 'node:fs';
|
|
|
30
30
|
import { createInterface } from 'node:readline';
|
|
31
31
|
import { SignalsAggregator } from '../src/anonymizer.js';
|
|
32
32
|
import { resolveFortressBase, fortressEndpoint } from '../src/fortress/url.js';
|
|
33
|
+
import { AnthropicManagedSource } from '../src/sources/anthropic-managed.js';
|
|
33
34
|
|
|
34
35
|
function parseArgs(argv) {
|
|
35
36
|
const out = {};
|
|
@@ -177,8 +178,25 @@ async function main() {
|
|
|
177
178
|
die('error: no entries had timestamps — nothing to upload');
|
|
178
179
|
}
|
|
179
180
|
|
|
181
|
+
// PR-B: provider-agnostic identifiers + legacy fallback (see fetch-anthropic.js).
|
|
182
|
+
// PR-C: ship the agent's hierarchy + composition pattern. wma-upload-fortress
|
|
183
|
+
// is a one-shot post-hoc tool — it has no per-entry context to derive
|
|
184
|
+
// hierarchy from, so it sends defaults (solo / null) until a future
|
|
185
|
+
// adapter writes those fields into the local NDJSON.
|
|
186
|
+
// PR-D / v1.0.1 F-2: enforcement_mode set to the provider's MAX
|
|
187
|
+
// capability (sync_confirm). The continuous Watch daemon
|
|
188
|
+
// (wma-fetch --watch --upload) resolves the EFFECTIVE per-agent mode
|
|
189
|
+
// via effectiveEnforcementMode(), but this one-shot uploader has no
|
|
190
|
+
// ANTHROPIC_API_KEY in scope so it cannot make the live getAgent
|
|
191
|
+
// call. Best-effort: send the max; the daemon's subsequent uploads
|
|
192
|
+
// will correct the value once it resolves.
|
|
180
193
|
const body = {
|
|
194
|
+
provider: AnthropicManagedSource.providerName,
|
|
195
|
+
native_agent_id: agentId,
|
|
181
196
|
anthropic_agent_id: agentId,
|
|
197
|
+
parent_agent_id: null,
|
|
198
|
+
composition_pattern: 'solo',
|
|
199
|
+
enforcement_mode: AnthropicManagedSource.enforcementMode,
|
|
182
200
|
display_name: displayName,
|
|
183
201
|
window_start: signals.window_start,
|
|
184
202
|
window_end: signals.window_end,
|
package/src/anonymizer.js
CHANGED
|
@@ -37,6 +37,27 @@ const HASHABLE_INPUT_FIELDS = ['url', 'query', 'command', 'path', 'file_path'];
|
|
|
37
37
|
// Tool types whose inputs we want to hash for IoC tracking
|
|
38
38
|
const TOOL_ACTIONS = new Set(['tool_use', 'mcp_tool_use', 'custom_tool_use']);
|
|
39
39
|
|
|
40
|
+
// Well-known vendor built-in tool names that are SAFE to keep in clear in the
|
|
41
|
+
// signals payload. They are documented by the vendor, common across customers,
|
|
42
|
+
// and the operator NEEDS them legible in the dashboard ("3 web_search calls
|
|
43
|
+
// in 10 minutes" is the actionable signal). Anything not on this list is
|
|
44
|
+
// considered customer-controlled (custom tool, MCP tool with a customer-chosen
|
|
45
|
+
// name like "client_acme_export") and gets hashed before egress.
|
|
46
|
+
//
|
|
47
|
+
// To add a built-in: only confirmed-public-by-vendor names — never speculative
|
|
48
|
+
// matches. When in doubt, hash.
|
|
49
|
+
const WELL_KNOWN_TOOLS = new Set([
|
|
50
|
+
// Anthropic Managed Agents
|
|
51
|
+
'web_search', 'web_fetch', 'bash', 'code_execution',
|
|
52
|
+
'str_replace_editor', 'str_replace_based_edit_tool',
|
|
53
|
+
'computer', 'computer_use_20250124', 'computer_use_20241022',
|
|
54
|
+
'text_editor', 'text_editor_20250124', 'text_editor_20241022',
|
|
55
|
+
// OpenAI Agents / Responses
|
|
56
|
+
'web_search_preview', 'file_search', 'computer_use_preview', 'code_interpreter',
|
|
57
|
+
// Common framework primitives
|
|
58
|
+
'function', 'retrieval',
|
|
59
|
+
]);
|
|
60
|
+
|
|
40
61
|
// ── Hash helpers ─────────────────────────────────────────────────────────
|
|
41
62
|
|
|
42
63
|
/**
|
|
@@ -56,6 +77,30 @@ export function generateSalt() {
|
|
|
56
77
|
return randomBytes(16).toString('hex');
|
|
57
78
|
}
|
|
58
79
|
|
|
80
|
+
// ── Tool name normalization (Containment hardening, v1.0.1 F-3) ────────
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Return the canonical tool-name token that's safe to ship to Fortress.
|
|
84
|
+
*
|
|
85
|
+
* - For documented vendor built-ins (WELL_KNOWN_TOOLS) the name is kept
|
|
86
|
+
* in clear so dashboards and Guardian policies can reason about the
|
|
87
|
+
* tool by its public identifier.
|
|
88
|
+
* - For anything else (customer-defined functions, MCP tools whose name
|
|
89
|
+
* is set by the customer's MCP server, e.g. "client_acme_export"),
|
|
90
|
+
* the name is salted-SHA256-hashed with a `tool_hash:` prefix so it
|
|
91
|
+
* cannot leak project/client identifiers.
|
|
92
|
+
*
|
|
93
|
+
* Empty / null tool names return null.
|
|
94
|
+
*/
|
|
95
|
+
export function normalizeToolName(toolName, salt) {
|
|
96
|
+
if (toolName == null) return null;
|
|
97
|
+
const s = String(toolName);
|
|
98
|
+
if (s.length === 0) return null;
|
|
99
|
+
if (WELL_KNOWN_TOOLS.has(s)) return s;
|
|
100
|
+
if (!salt) throw new Error('normalizeToolName requires a salt to hash custom tool names');
|
|
101
|
+
return 'tool_hash:' + createHash('sha256').update(salt).update(s).digest('hex').slice(0, 32);
|
|
102
|
+
}
|
|
103
|
+
|
|
59
104
|
// ── Single-entry extractor: what hashable IoCs are in this entry? ────────
|
|
60
105
|
|
|
61
106
|
function extractIocs(entry, salt) {
|
|
@@ -115,15 +160,21 @@ export class SignalsAggregator {
|
|
|
115
160
|
this._prevActionType = at;
|
|
116
161
|
this._prevSessionId = entry.session_id || null;
|
|
117
162
|
|
|
118
|
-
// Tools
|
|
163
|
+
// Tools — Containment (v1.0.1 F-3): well-known vendor built-ins keep
|
|
164
|
+
// their public name; customer-defined / MCP tool names get hashed so
|
|
165
|
+
// no client-identifying string ("client_acme_export") leaks via the
|
|
166
|
+
// tool_counts / tool_latencies / error_rate maps.
|
|
119
167
|
if (entry.tool_name && TOOL_ACTIONS.has(at)) {
|
|
120
|
-
|
|
121
|
-
if (
|
|
122
|
-
this.
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
168
|
+
const toolKey = normalizeToolName(entry.tool_name, this.salt);
|
|
169
|
+
if (toolKey) {
|
|
170
|
+
this.toolCounts[toolKey] = (this.toolCounts[toolKey] || 0) + 1;
|
|
171
|
+
if (entry.status === 'error') {
|
|
172
|
+
this.toolErrors[toolKey] = (this.toolErrors[toolKey] || 0) + 1;
|
|
173
|
+
}
|
|
174
|
+
if (typeof entry.duration_ms === 'number') {
|
|
175
|
+
if (!this.toolLatencies[toolKey]) this.toolLatencies[toolKey] = [];
|
|
176
|
+
this.toolLatencies[toolKey].push(entry.duration_ms);
|
|
177
|
+
}
|
|
127
178
|
}
|
|
128
179
|
// Extract & hash IoCs from this tool's input
|
|
129
180
|
for (const h of extractIocs(entry, this.salt)) this.iocHashes.add(h);
|
package/src/logger.js
CHANGED
|
@@ -3,8 +3,16 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { assertSafePathSegment } from './validate.js';
|
|
5
5
|
|
|
6
|
+
// PR-B: `framework` → `provider` (canonical name per src/sources/contract.js).
|
|
7
|
+
// PR-C: adds `parent_agent_id` + `composition_pattern` so any future
|
|
8
|
+
// adapter that knows the hierarchy (OpenAI Agents handoffs, CrewAI
|
|
9
|
+
// manager, Hermes Agent spawn_subagent, LangGraph sub-graphs) can
|
|
10
|
+
// thread the relationship through to Fortress without rework.
|
|
11
|
+
// NDJSON written before PR-B may carry `framework`; readers that need the
|
|
12
|
+
// provider tag should read `provider` first and fall back to `framework`.
|
|
6
13
|
const EXPORT_FIELDS = [
|
|
7
|
-
'id', 'agent_id', '
|
|
14
|
+
'id', 'agent_id', 'parent_agent_id', 'composition_pattern',
|
|
15
|
+
'provider', 'timestamp', 'action_type',
|
|
8
16
|
'tool_name', 'duration_ms', 'tokens_used',
|
|
9
17
|
'input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_creation_tokens',
|
|
10
18
|
'cost_usd', 'model',
|
|
@@ -47,7 +55,12 @@ export class Logger {
|
|
|
47
55
|
const full = {
|
|
48
56
|
id: e.id || randomUUID(),
|
|
49
57
|
agent_id: this.agentId,
|
|
50
|
-
|
|
58
|
+
// PR-C: sub-agent fields. Defaults are honest for solo / root agents.
|
|
59
|
+
// An adapter that detects hierarchy (e.g. OpenAI Agents handoffs)
|
|
60
|
+
// populates these on the event, and the Logger threads them through.
|
|
61
|
+
parent_agent_id: e.parent_agent_id ?? null,
|
|
62
|
+
composition_pattern: e.composition_pattern || 'solo',
|
|
63
|
+
provider: e.provider || e.framework || 'generic',
|
|
51
64
|
timestamp: e.timestamp || new Date().toISOString(),
|
|
52
65
|
action_type: e.action_type || 'tool_call',
|
|
53
66
|
tool_name: e.tool_name || null,
|
package/src/shield/decisions.js
CHANGED
|
@@ -25,7 +25,7 @@ export class DecisionLogger {
|
|
|
25
25
|
}) {
|
|
26
26
|
return this._logger.write({
|
|
27
27
|
action_type: 'shield_decision',
|
|
28
|
-
|
|
28
|
+
provider: 'anthropic-managed',
|
|
29
29
|
tool_name: sourceEvent?.name || sourceEvent?.tool_name || null,
|
|
30
30
|
status: decision === 'deny' || decision === 'interrupt' ? 'error' : 'ok',
|
|
31
31
|
error: decision === 'deny' || decision === 'interrupt' ? message : null,
|
|
@@ -17,6 +17,11 @@
|
|
|
17
17
|
|
|
18
18
|
import { request } from 'node:https';
|
|
19
19
|
import { URLSearchParams } from 'node:url';
|
|
20
|
+
import { Source, PROVIDERS, ENFORCEMENT_MODES, ACTION_TYPES } from './contract.js';
|
|
21
|
+
import {
|
|
22
|
+
getAgentConfig, detectAlwaysAsk,
|
|
23
|
+
confirmAllow, confirmDeny, interruptSession,
|
|
24
|
+
} from '../shield/enforce.js';
|
|
20
25
|
|
|
21
26
|
const API_HOST = 'api.anthropic.com';
|
|
22
27
|
const BETA = 'managed-agents-2026-04-01';
|
|
@@ -163,7 +168,14 @@ export async function* fetchSessionEntries({ apiKey, agentId, sessionId, model }
|
|
|
163
168
|
const pendingModelReq = new Map(); // span.model_request_start.id → ts
|
|
164
169
|
const pendingToolUse = new Map(); // agent.tool_use.id → { ts, name, isMcp, input }
|
|
165
170
|
|
|
166
|
-
|
|
171
|
+
// `provider` is the canonical field per src/sources/contract.js (no
|
|
172
|
+
// other consumer ever read the previous `framework` field, so it was
|
|
173
|
+
// dropped in PR-B with zero downstream impact).
|
|
174
|
+
const base = {
|
|
175
|
+
provider: PROVIDERS.ANTHROPIC_MANAGED,
|
|
176
|
+
agent_id: agentId,
|
|
177
|
+
session_id: sessionId,
|
|
178
|
+
};
|
|
167
179
|
|
|
168
180
|
// No server-side `types[]` filter: the API rejects unknown values, but the
|
|
169
181
|
// exact filterable set is undocumented & evolves. We pull everything and
|
|
@@ -451,6 +463,27 @@ export async function* fetchSessionEntries({ apiKey, agentId, sessionId, model }
|
|
|
451
463
|
}
|
|
452
464
|
}
|
|
453
465
|
|
|
466
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
467
|
+
// effectiveEnforcementMode — F-2 of the Codex v1.0.1 audit
|
|
468
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
469
|
+
// AnthropicManagedSource.enforcementMode is the PROVIDER'S MAX capability
|
|
470
|
+
// (sync_confirm). But the EFFECTIVE mode for a given agent depends on
|
|
471
|
+
// whether at least one of its tools/toolsets has permission_policy =
|
|
472
|
+
// always_ask. When none does, Shield can only interrupt AFTER a violating
|
|
473
|
+
// tool ran, not block before — that's sync_interrupt territory.
|
|
474
|
+
//
|
|
475
|
+
// This helper resolves the per-agent effective mode from the live agent
|
|
476
|
+
// config so the value shipped to Fortress matches what Shield can
|
|
477
|
+
// actually do for THIS agent. Without this, Fortress can mis-display
|
|
478
|
+
// "sync_confirm" UI on an agent that's only interrupt-capable, leading
|
|
479
|
+
// the operator to deploy Shield policies that won't pre-block.
|
|
480
|
+
export async function effectiveEnforcementMode(apiKey, agentId) {
|
|
481
|
+
const agentConfig = await getAgentConfig(apiKey, agentId);
|
|
482
|
+
return detectAlwaysAsk(agentConfig)
|
|
483
|
+
? ENFORCEMENT_MODES.SYNC_CONFIRM
|
|
484
|
+
: ENFORCEMENT_MODES.SYNC_INTERRUPT;
|
|
485
|
+
}
|
|
486
|
+
|
|
454
487
|
function extractText(content) {
|
|
455
488
|
if (typeof content === 'string') return content;
|
|
456
489
|
if (Array.isArray(content)) {
|
|
@@ -459,3 +492,148 @@ function extractText(content) {
|
|
|
459
492
|
if (content && typeof content === 'object') return content.text || JSON.stringify(content);
|
|
460
493
|
return '';
|
|
461
494
|
}
|
|
495
|
+
|
|
496
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
497
|
+
// AnthropicManagedSource — V1 Source contract wrapper
|
|
498
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
499
|
+
// Implements the Source ABC over the low-level functions above. New SDK
|
|
500
|
+
// code should use this class; the function exports stay public for
|
|
501
|
+
// backwards compat with the existing wma-fetch + wma-shield daemons
|
|
502
|
+
// (migration is PR-B / PR-D).
|
|
503
|
+
//
|
|
504
|
+
// Capability declaration:
|
|
505
|
+
// sync_confirm — Anthropic Managed Agents exposes pre-execution
|
|
506
|
+
// `user.tool_confirmation` (block before the tool runs) AND
|
|
507
|
+
// `user.interrupt` (stop the current LLM turn). The stronger of the
|
|
508
|
+
// two is sync_confirm.
|
|
509
|
+
|
|
510
|
+
export class AnthropicManagedSource extends Source {
|
|
511
|
+
static providerName = PROVIDERS.ANTHROPIC_MANAGED;
|
|
512
|
+
static enforcementMode = ENFORCEMENT_MODES.SYNC_CONFIRM;
|
|
513
|
+
|
|
514
|
+
constructor({ apiKey } = {}) {
|
|
515
|
+
super({ apiKey });
|
|
516
|
+
if (!apiKey) throw new Error('AnthropicManagedSource requires an apiKey');
|
|
517
|
+
this.apiKey = apiKey;
|
|
518
|
+
// Per-agent effective enforcement mode cache. One getAgent call per
|
|
519
|
+
// agent across the lifetime of the Source instance.
|
|
520
|
+
this._modeCache = new Map();
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Resolve the effective enforcement mode for an agent and cache the
|
|
525
|
+
* answer. Useful internally for enforce() to choose between
|
|
526
|
+
* pre-execution confirmation (always_ask agents) and post-hoc
|
|
527
|
+
* interrupt (default agents).
|
|
528
|
+
*/
|
|
529
|
+
async _getEffectiveModeFor(agentId) {
|
|
530
|
+
const cached = this._modeCache.get(agentId);
|
|
531
|
+
if (cached) return cached;
|
|
532
|
+
const mode = await effectiveEnforcementMode(this.apiKey, agentId);
|
|
533
|
+
this._modeCache.set(agentId, mode);
|
|
534
|
+
return mode;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Discover Managed Agents under this API key. Returns the canonical
|
|
539
|
+
* agent descriptor (`{ id, name, native }`) — the raw vendor agent
|
|
540
|
+
* stays in `native` for adapters/UI that want richer metadata.
|
|
541
|
+
*/
|
|
542
|
+
async listAgents() {
|
|
543
|
+
const raw = await listAgents(this.apiKey);
|
|
544
|
+
return raw.map((a) => ({
|
|
545
|
+
id: a.id,
|
|
546
|
+
name: a.name || null,
|
|
547
|
+
native: a,
|
|
548
|
+
}));
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Stream WMAAction entries for a session. Anthropic events are
|
|
553
|
+
* per-session, so opts.sessionId is required — fleet-wide watching is
|
|
554
|
+
* the caller's job (wma-fetch already orchestrates this).
|
|
555
|
+
*/
|
|
556
|
+
async *streamEvents(agentId, { sessionId, model } = {}) {
|
|
557
|
+
if (!sessionId) {
|
|
558
|
+
throw new Error('AnthropicManagedSource.streamEvents requires opts.sessionId — Anthropic events are scoped to a session');
|
|
559
|
+
}
|
|
560
|
+
yield* fetchSessionEntries({
|
|
561
|
+
apiKey: this.apiKey, agentId, sessionId, model,
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Enforce a policy decision against a pending action — v1.0.1 F-4.
|
|
567
|
+
*
|
|
568
|
+
* Routes through the right Anthropic event depending on the agent's
|
|
569
|
+
* effective enforcement mode:
|
|
570
|
+
* - sync_confirm (agent has at least one tool with always_ask):
|
|
571
|
+
* 'allow' → user.tool_confirmation { result: allow }
|
|
572
|
+
* 'deny' → user.tool_confirmation { result: deny } (pre-execution block)
|
|
573
|
+
* - sync_interrupt (no always_ask available):
|
|
574
|
+
* 'allow' → no-op (nothing to confirm — the tool already ran or
|
|
575
|
+
* will run without a gate)
|
|
576
|
+
* 'deny' → user.interrupt + optional follow-up message
|
|
577
|
+
* (post-hoc termination)
|
|
578
|
+
*
|
|
579
|
+
* Returns { enforced: boolean, mode: string, native_response?: object }
|
|
580
|
+
* where `mode` describes the path taken so the caller can log it.
|
|
581
|
+
*
|
|
582
|
+
* @param {object} action A WMAAction (must carry session_id and id)
|
|
583
|
+
* @param {object} decision { decision: 'allow'|'deny', reason?: string }
|
|
584
|
+
*/
|
|
585
|
+
async enforce(action, decision) {
|
|
586
|
+
if (!action || typeof action !== 'object') {
|
|
587
|
+
throw new Error('enforce(action, decision): action must be a WMAAction object');
|
|
588
|
+
}
|
|
589
|
+
if (!action.session_id) {
|
|
590
|
+
throw new Error('enforce(action, decision): action.session_id is required');
|
|
591
|
+
}
|
|
592
|
+
if (!action.agent_id) {
|
|
593
|
+
throw new Error('enforce(action, decision): action.agent_id is required');
|
|
594
|
+
}
|
|
595
|
+
if (!decision || (decision.decision !== 'allow' && decision.decision !== 'deny')) {
|
|
596
|
+
throw new Error(`enforce(action, decision): decision must be 'allow' or 'deny' (got ${decision?.decision})`);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const mode = await this._getEffectiveModeFor(action.agent_id);
|
|
600
|
+
const isToolUse = action.action_type === ACTION_TYPES.TOOL_USE
|
|
601
|
+
|| action.action_type === ACTION_TYPES.MCP_TOOL_USE
|
|
602
|
+
|| action.action_type === ACTION_TYPES.CUSTOM_TOOL_USE;
|
|
603
|
+
|
|
604
|
+
// Path 1 — pre-execution confirmation when the agent supports it AND
|
|
605
|
+
// the pending action is a tool_use (only kind we can pre-block).
|
|
606
|
+
if (mode === ENFORCEMENT_MODES.SYNC_CONFIRM && isToolUse && action.id) {
|
|
607
|
+
if (decision.decision === 'allow') {
|
|
608
|
+
const res = await confirmAllow({
|
|
609
|
+
apiKey: this.apiKey,
|
|
610
|
+
sessionId: action.session_id,
|
|
611
|
+
toolUseId: action.id,
|
|
612
|
+
});
|
|
613
|
+
return { enforced: true, mode: 'confirm_allow', native_response: res };
|
|
614
|
+
}
|
|
615
|
+
const res = await confirmDeny({
|
|
616
|
+
apiKey: this.apiKey,
|
|
617
|
+
sessionId: action.session_id,
|
|
618
|
+
toolUseId: action.id,
|
|
619
|
+
denyMessage: decision.reason,
|
|
620
|
+
});
|
|
621
|
+
return { enforced: true, mode: 'confirm_deny', native_response: res };
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Path 2 — post-hoc interrupt. The only enforcement available when
|
|
625
|
+
// the agent has no always_ask tools, OR for non-tool actions we
|
|
626
|
+
// can't pre-block.
|
|
627
|
+
if (decision.decision === 'deny') {
|
|
628
|
+
const res = await interruptSession({
|
|
629
|
+
apiKey: this.apiKey,
|
|
630
|
+
sessionId: action.session_id,
|
|
631
|
+
followUpMessage: decision.reason,
|
|
632
|
+
});
|
|
633
|
+
return { enforced: true, mode: 'interrupt', native_response: res };
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Allow + no pre-gate available = nothing to do at the SDK level.
|
|
637
|
+
return { enforced: false, mode: 'no_op', reason: 'no pre-execution gate available for this action' };
|
|
638
|
+
}
|
|
639
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// WatchMyAgents — Source contract (V1)
|
|
3
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// THIS FILE IS THE CONTRACT every adapter MUST follow.
|
|
6
|
+
//
|
|
7
|
+
// Why this exists:
|
|
8
|
+
// The SDK shipped today integrates Anthropic Managed Agents via the
|
|
9
|
+
// functions in `./anthropic-managed.js`. To add OpenAI / LangGraph /
|
|
10
|
+
// CrewAI / Bedrock / etc. without rewriting the pipe each time, the
|
|
11
|
+
// contract between "fetching events" and "the rest of WMA" has to be
|
|
12
|
+
// explicit.
|
|
13
|
+
//
|
|
14
|
+
// Everything in WMA (anonymizer, typology classifier, Guardian scoring,
|
|
15
|
+
// Shield enforcement, Fortress signals payload) operates on `WMAAction`
|
|
16
|
+
// objects — the canonical shape defined below. Each Source adapter is
|
|
17
|
+
// responsible for translating its provider's native events into this
|
|
18
|
+
// shape, and nothing else.
|
|
19
|
+
//
|
|
20
|
+
// Containment invariant:
|
|
21
|
+
// A Source's `streamEvents()` yields WMAAction objects that MAY carry
|
|
22
|
+
// raw payload bytes in `input`/`output` — these are written to the
|
|
23
|
+
// LOCAL NDJSON file but NEVER sent to Fortress. The anonymizer is the
|
|
24
|
+
// single gate between WMAAction (raw) and the signals payload (cloud).
|
|
25
|
+
// See `src/anonymizer.js` and `docs/SOURCE-ADAPTER-CONTRACT.md`.
|
|
26
|
+
|
|
27
|
+
// ── Canonical vocabulary ────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
// Every WMAAction.action_type MUST be one of these. New adapters that
|
|
30
|
+
// emit a novel kind of action should propose adding a new constant here
|
|
31
|
+
// (and document it) rather than inventing one inline.
|
|
32
|
+
export const ACTION_TYPES = Object.freeze({
|
|
33
|
+
LLM_CALL: 'llm_call',
|
|
34
|
+
TOOL_USE: 'tool_use',
|
|
35
|
+
MCP_TOOL_USE: 'mcp_tool_use',
|
|
36
|
+
CUSTOM_TOOL_USE: 'custom_tool_use',
|
|
37
|
+
CUSTOM_TOOL_RESULT: 'custom_tool_result',
|
|
38
|
+
TOOL_CONFIRMATION: 'tool_confirmation',
|
|
39
|
+
USER_MESSAGE: 'user_message',
|
|
40
|
+
USER_INTERRUPT: 'user_interrupt',
|
|
41
|
+
MESSAGE: 'message',
|
|
42
|
+
THINKING: 'thinking',
|
|
43
|
+
CONTEXT_COMPACTED: 'context_compacted',
|
|
44
|
+
THREAD_CREATED: 'thread_created',
|
|
45
|
+
THREAD_MESSAGE_SENT: 'thread_message_sent',
|
|
46
|
+
THREAD_MESSAGE_RECEIVED: 'thread_message_received',
|
|
47
|
+
CONFIG_CHANGE: 'config_change',
|
|
48
|
+
STATE_TRANSITION: 'state_transition',
|
|
49
|
+
SESSION_ERROR: 'session_error',
|
|
50
|
+
// Shield-only — emitted when WMA itself blocks an action:
|
|
51
|
+
SHIELD_DECISION: 'shield_decision',
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export const STATUS_VALUES = Object.freeze({
|
|
55
|
+
OK: 'ok',
|
|
56
|
+
ERROR: 'error',
|
|
57
|
+
BLOCKED: 'blocked',
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// A Source declares how strongly it can enforce policies. This drives
|
|
61
|
+
// what Shield can do on its events:
|
|
62
|
+
// sync_confirm → can confirm/deny a tool call before execution
|
|
63
|
+
// (Anthropic user.tool_confirmation, AgentCore
|
|
64
|
+
// Gateway interceptor with transformedResponse)
|
|
65
|
+
// sync_interrupt → can interrupt mid-execution after an LLM call
|
|
66
|
+
// (Anthropic user.interrupt)
|
|
67
|
+
// detect_only → can observe but cannot block — post-hoc audit
|
|
68
|
+
// (E2B lifecycle webhooks, pure observability sinks)
|
|
69
|
+
export const ENFORCEMENT_MODES = Object.freeze({
|
|
70
|
+
SYNC_CONFIRM: 'sync_confirm',
|
|
71
|
+
SYNC_INTERRUPT: 'sync_interrupt',
|
|
72
|
+
DETECT_ONLY: 'detect_only',
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// How the agent composes with other agents — drives the WMA dashboard
|
|
76
|
+
// tree view and the policy `subtree` surface (PR-C).
|
|
77
|
+
// solo → no sub-agents, one tool-loop
|
|
78
|
+
// hierarchy → boss + workers (CrewAI manager, Anthropic Task tool,
|
|
79
|
+
// Hermes Agent spawn-subagent)
|
|
80
|
+
// graph → nodes + edges (LangGraph)
|
|
81
|
+
// peer → N agents converse on equal footing (AutoGen)
|
|
82
|
+
export const COMPOSITION_PATTERNS = Object.freeze({
|
|
83
|
+
SOLO: 'solo',
|
|
84
|
+
HIERARCHY: 'hierarchy',
|
|
85
|
+
GRAPH: 'graph',
|
|
86
|
+
PEER: 'peer',
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Known provider identifiers. Adapters should register their provider
|
|
90
|
+
// name here as they land so consumers can build provider-specific UI.
|
|
91
|
+
export const PROVIDERS = Object.freeze({
|
|
92
|
+
ANTHROPIC_MANAGED: 'anthropic-managed',
|
|
93
|
+
// Coming next:
|
|
94
|
+
// OPENAI_AGENTS: 'openai-agents',
|
|
95
|
+
// AWS_BEDROCK_AGENTCORE: 'aws-bedrock-agentcore',
|
|
96
|
+
// LANGGRAPH: 'langgraph',
|
|
97
|
+
// CREWAI: 'crewai',
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// ── WMAAction canonical shape ───────────────────────────────────────────
|
|
101
|
+
//
|
|
102
|
+
// /**
|
|
103
|
+
// * @typedef {object} WMAAction
|
|
104
|
+
// *
|
|
105
|
+
// * REQUIRED — every Source MUST populate these:
|
|
106
|
+
// * @property {string} id Stable, dedup-friendly event id
|
|
107
|
+
// * @property {string} provider From PROVIDERS (e.g. 'anthropic-managed')
|
|
108
|
+
// * @property {string} agent_id Native agent identifier
|
|
109
|
+
// * @property {string} session_id Native session/thread/run identifier
|
|
110
|
+
// * @property {string} action_type From ACTION_TYPES
|
|
111
|
+
// * @property {string} timestamp ISO-8601
|
|
112
|
+
// * @property {'ok'|'error'|'blocked'} status
|
|
113
|
+
// *
|
|
114
|
+
// * OPTIONAL — present when applicable:
|
|
115
|
+
// * @property {string|null} tool_name For tool_use family
|
|
116
|
+
// * @property {string|null} model For llm_call
|
|
117
|
+
// * @property {number|null} duration_ms Latency (start→end pair)
|
|
118
|
+
// * @property {number|null} tokens_used For llm_call (input+output+cache)
|
|
119
|
+
// * @property {number|null} input_tokens
|
|
120
|
+
// * @property {number|null} output_tokens
|
|
121
|
+
// * @property {number|null} cache_read_tokens
|
|
122
|
+
// * @property {number|null} cache_creation_tokens
|
|
123
|
+
// * @property {string|null} error Truncated error message (≤500ch)
|
|
124
|
+
// * @property {object|null} input Raw input payload — STAYS LOCAL
|
|
125
|
+
// * @property {object|null} output Raw output payload — STAYS LOCAL
|
|
126
|
+
// *
|
|
127
|
+
// * SUB-AGENT FIELDS (PR-C — see WMAAction.parent_agent_id):
|
|
128
|
+
// * @property {string|null} parent_agent_id Null for root agents
|
|
129
|
+
// * @property {string|null} composition_pattern From COMPOSITION_PATTERNS
|
|
130
|
+
// */
|
|
131
|
+
|
|
132
|
+
const REQUIRED_FIELDS = ['id', 'provider', 'agent_id', 'session_id', 'action_type', 'timestamp', 'status'];
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Validate a WMAAction at runtime. Returns `{ valid, errors }`.
|
|
136
|
+
* Cheap enough to run on every yield in dev (process.env.WMA_DEV_VALIDATE=1).
|
|
137
|
+
*
|
|
138
|
+
* Adapters should call this BEFORE yielding in their test suite, and the
|
|
139
|
+
* SDK can opt into runtime validation via the env flag.
|
|
140
|
+
*/
|
|
141
|
+
export function validateWMAAction(obj) {
|
|
142
|
+
const errors = [];
|
|
143
|
+
if (!obj || typeof obj !== 'object') {
|
|
144
|
+
return { valid: false, errors: ['not an object'] };
|
|
145
|
+
}
|
|
146
|
+
for (const f of REQUIRED_FIELDS) {
|
|
147
|
+
if (obj[f] == null) errors.push(`missing required field: ${f}`);
|
|
148
|
+
}
|
|
149
|
+
if (obj.action_type && !Object.values(ACTION_TYPES).includes(obj.action_type)) {
|
|
150
|
+
errors.push(`unknown action_type "${obj.action_type}" — add to ACTION_TYPES in contract.js`);
|
|
151
|
+
}
|
|
152
|
+
if (obj.status && !Object.values(STATUS_VALUES).includes(obj.status)) {
|
|
153
|
+
errors.push(`unknown status "${obj.status}" — must be one of ${Object.values(STATUS_VALUES).join(', ')}`);
|
|
154
|
+
}
|
|
155
|
+
if (obj.composition_pattern != null
|
|
156
|
+
&& !Object.values(COMPOSITION_PATTERNS).includes(obj.composition_pattern)) {
|
|
157
|
+
errors.push(`unknown composition_pattern "${obj.composition_pattern}"`);
|
|
158
|
+
}
|
|
159
|
+
if (obj.timestamp && Number.isNaN(Date.parse(obj.timestamp))) {
|
|
160
|
+
errors.push(`timestamp not parseable: ${obj.timestamp}`);
|
|
161
|
+
}
|
|
162
|
+
return { valid: errors.length === 0, errors };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── Source abstract base class ──────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Every framework adapter MUST extend this class and override the abstract
|
|
169
|
+
* methods. The Source is the boundary between "the customer's agent
|
|
170
|
+
* runtime" and "the rest of WMA" — the only place where vendor-specific
|
|
171
|
+
* code lives. Once a Source yields WMAAction objects, the pipe is
|
|
172
|
+
* provider-agnostic.
|
|
173
|
+
*
|
|
174
|
+
* Static contract:
|
|
175
|
+
* providerName — value from PROVIDERS
|
|
176
|
+
* enforcementMode — value from ENFORCEMENT_MODES
|
|
177
|
+
*
|
|
178
|
+
* Instance contract:
|
|
179
|
+
* listAgents() — return all agents accessible with the client creds
|
|
180
|
+
* streamEvents(id) — async generator yielding WMAAction objects
|
|
181
|
+
* enforce(action, d) — only required if enforcementMode != detect_only
|
|
182
|
+
*
|
|
183
|
+
* See `docs/SOURCE-ADAPTER-CONTRACT.md` for the full author guide.
|
|
184
|
+
*/
|
|
185
|
+
export class Source {
|
|
186
|
+
static providerName = null;
|
|
187
|
+
static enforcementMode = null;
|
|
188
|
+
|
|
189
|
+
constructor(config = {}) {
|
|
190
|
+
if (new.target === Source) {
|
|
191
|
+
throw new Error('Source is abstract — extend it in a subclass (e.g., AnthropicManagedSource).');
|
|
192
|
+
}
|
|
193
|
+
this.config = config;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Discover all agents under the client's credentials.
|
|
198
|
+
* @returns {Promise<Array<{id: string, name?: string, native?: object}>>}
|
|
199
|
+
*/
|
|
200
|
+
async listAgents() {
|
|
201
|
+
throw new Error(`${this.constructor.name}.listAgents() not implemented`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Stream WMAAction objects for the given agent. The implementation may
|
|
206
|
+
* page, retry, dedup, or restart internally — consumers see a single
|
|
207
|
+
* ordered stream.
|
|
208
|
+
* @param {string} agentId
|
|
209
|
+
* @param {object} [opts]
|
|
210
|
+
* @yields {WMAAction}
|
|
211
|
+
*/
|
|
212
|
+
async *streamEvents(agentId, opts) { // eslint-disable-line no-unused-vars
|
|
213
|
+
throw new Error(`${this.constructor.name}.streamEvents() not implemented`);
|
|
214
|
+
yield; // make this a generator
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Enforce a policy decision against a pending action. Only called when
|
|
219
|
+
* the Source's static `enforcementMode` is not `detect_only`. The
|
|
220
|
+
* subclass is responsible for translating WMA's canonical decision
|
|
221
|
+
* (`allow`|`deny`) into the provider's native confirm/interrupt call.
|
|
222
|
+
* @param {WMAAction} action
|
|
223
|
+
* @param {{decision: 'allow'|'deny', reason?: string}} decision
|
|
224
|
+
* @returns {Promise<{enforced: boolean, native_response?: object}>}
|
|
225
|
+
*/
|
|
226
|
+
async enforce(action, decision) { // eslint-disable-line no-unused-vars
|
|
227
|
+
if (this.constructor.enforcementMode === ENFORCEMENT_MODES.DETECT_ONLY) {
|
|
228
|
+
throw new Error(`${this.constructor.name} is detect_only — enforce() must not be called`);
|
|
229
|
+
}
|
|
230
|
+
throw new Error(`${this.constructor.name}.enforce() not implemented`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Assertion helper for tests: verify a Source subclass declares the
|
|
236
|
+
* required static fields and overrides the abstract methods.
|
|
237
|
+
* Throws on any contract violation.
|
|
238
|
+
*/
|
|
239
|
+
export function assertImplementsSource(SourceClass) {
|
|
240
|
+
if (!(SourceClass.prototype instanceof Source)) {
|
|
241
|
+
throw new Error(`${SourceClass?.name || SourceClass} does not extend Source`);
|
|
242
|
+
}
|
|
243
|
+
if (!Object.values(PROVIDERS).includes(SourceClass.providerName)) {
|
|
244
|
+
throw new Error(`${SourceClass.name}.providerName="${SourceClass.providerName}" not in PROVIDERS`);
|
|
245
|
+
}
|
|
246
|
+
if (!Object.values(ENFORCEMENT_MODES).includes(SourceClass.enforcementMode)) {
|
|
247
|
+
throw new Error(`${SourceClass.name}.enforcementMode="${SourceClass.enforcementMode}" not in ENFORCEMENT_MODES`);
|
|
248
|
+
}
|
|
249
|
+
// The base class throws "not implemented" — a real subclass must override.
|
|
250
|
+
for (const m of ['listAgents', 'streamEvents']) {
|
|
251
|
+
if (SourceClass.prototype[m] === Source.prototype[m]) {
|
|
252
|
+
throw new Error(`${SourceClass.name}.${m}() must be overridden`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (SourceClass.enforcementMode !== ENFORCEMENT_MODES.DETECT_ONLY
|
|
256
|
+
&& SourceClass.prototype.enforce === Source.prototype.enforce) {
|
|
257
|
+
throw new Error(`${SourceClass.name}.enforce() must be overridden (enforcementMode=${SourceClass.enforcementMode})`);
|
|
258
|
+
}
|
|
259
|
+
}
|