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 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-in: send the human agent name as the Fortress `display_name`. Default sends the agent id only (the name may contain client/project info). |
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-anonymize` — preview what would leave your machine
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-anonymize ./watchmyagents-logs
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-anonymize
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-anonymize` output), the agent's **`classification`** when the daemon has it (`{agent_type, confidence, stage}` — anonymized metadata, never raw content), **plus two routing identifiers**: your `anthropic_agent_id` 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.
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) + two routing identifiers: your `anthropic_agent_id` and a `display_name` (defaults to the agent id; the human agent name only with `--send-agent-names`). Shield enforcement **decisions** (hashed session/event/input fingerprints — never raw values). **Never** raw prompts, URLs, commands, or outputs. |
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
- - ❌ Does not phone home, telemetry, analytics, or usage reporting
34
- - ❌ Does not send any data to WMA-controlled servers
35
- - ❌ Does not store, log, or transmit your Anthropic API key anywhere except `api.anthropic.com`
36
- - ❌ Does not require an account, signup, or license key
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.9.4",
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/anonymize.js",
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-anonymize": "scripts/anonymize.js",
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
- "anonymize": "node scripts/anonymize.js",
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', framework: 'anthropic-managed', status: 'ok', model,
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
- const resp = await uploadSignals(uploadCtx, ag.agentId, sendNames ? ag.displayName : ag.agentId, fresh, classification);
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', framework: 'anthropic-managed', status: 'ok', model: ag.model,
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-anonymizeproduce the anonymized signals payload that Watch would
3
- // send to Fortress, for inspection / verification.
2
+ // wma-signalsbuild 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-anonymize <path-to-ndjson-or-dir> [--salt <hex>] [--out <file>]
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-anonymize <path> [--salt <hex>] [--out <file>]
65
+ die(`usage: wma-signals <path> [--salt <hex>] [--out <file>]
60
66
 
61
- Reads Watch NDJSON logs and produces the anonymized signals payload
62
- that would be sent to Fortress. Use this to inspect exactly what
63
- leaves your machine BEFORE any upload feature is enabled.
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-anonymize] warning: --salt on the command line is visible in shell history.\n' +
77
- ' Prefer: export WMA_SIGNALS_SALT=...\n');
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-anonymize] wrote ${args.out} (${signals._meta.entries_processed} entries processed)\n`);
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-anonymize → signals payload (Containment: no raw content)
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
- this.toolCounts[entry.tool_name] = (this.toolCounts[entry.tool_name] || 0) + 1;
121
- if (entry.status === 'error') {
122
- this.toolErrors[entry.tool_name] = (this.toolErrors[entry.tool_name] || 0) + 1;
123
- }
124
- if (typeof entry.duration_ms === 'number') {
125
- if (!this.toolLatencies[entry.tool_name]) this.toolLatencies[entry.tool_name] = [];
126
- this.toolLatencies[entry.tool_name].push(entry.duration_ms);
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', 'framework', 'timestamp', 'action_type',
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
- framework: e.framework || 'generic',
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,
@@ -25,7 +25,7 @@ export class DecisionLogger {
25
25
  }) {
26
26
  return this._logger.write({
27
27
  action_type: 'shield_decision',
28
- framework: 'anthropic-managed',
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
- const base = { framework: 'anthropic-managed', agent_id: agentId, session_id: sessionId };
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
+ }