deepdebug-local-agent 1.2.0 → 1.3.0

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/CHANGELOG.md CHANGED
@@ -6,6 +6,13 @@ Security release (security review 2026-10-11, C1 and H4). The agent now fails
6
6
  closed by default. Read "Upgrade" before rolling it out to agents whose
7
7
  platform side does not sign commands yet.
8
8
 
9
+ ### Added (opt-in, off by default)
10
+
11
+ - **`LOCAL_AGENT_WS_METADATA_ONLY=off|enforce`** (T4-10, D17, D19). `enforce`
12
+ restricts the outbound WS channel to allowlisted metadata (ids, hashes,
13
+ counts, state, cost), refuses legacy content commands and denies
14
+ PolicyGate-backed actions while the control plane is unreachable. See
15
+ `docs/ws-metadata-only.md`. No behaviour change when off.
9
16
  ### Added
10
17
 
11
18
  - **DLP module (`src/dlp/`, flag `LOCAL_AGENT_DLP_MODE=off|warn|enforce`, default `off`).**
@@ -0,0 +1,44 @@
1
+ # WS metadata-only mode (T4-10, D17, D19)
2
+
3
+ Flag: `LOCAL_AGENT_WS_METADATA_ONLY=off|enforce` (default `off`; an unrecognised value fails closed to `enforce`).
4
+
5
+ ## What `enforce` does
6
+
7
+ 1. **Outbound allowlist** (`src/ws-channel/allowlist.js`). Only these message types leave the agent:
8
+ `register`, `hello`, `status`, `cost` and command replies (`requestId`/`command`/`ok`/`status`/`openWorkspaces`/`errorCode`).
9
+ Fields are typed as ids, sha256 hashes, bounded integers, booleans or fixed enums. There is no free-text
10
+ field, so code, diffs, logs and chat text cannot be carried. `register` sends `workspaceHash` (sha256) instead of `workspacePath`.
11
+ 2. **Legacy content commands are refused before execution** (`workspace.file-content`, `workspace.diff`,
12
+ `workspace.safe-patch`, `mcp.*`, ...). Only `health` and `workspace.open` run; the reply is
13
+ `{ok:false, errorCode:"blocked_metadata_only"}`. Free-text error messages become `errorCode`.
14
+ 3. **Rejections are logged without content**: `[ws-metadata] BLOCKED {"reason":..,"type":..,"field":..}` (field names and codes only).
15
+ 4. **D17 transit controls**: nothing is persisted or queued by the module (blocked/over-limit messages are dropped);
16
+ a reply is sent only once, for a known request, within 60 s (TTL); 4 KiB per message; 200 messages per 10 s; 256 in-flight requests.
17
+ 5. **D19 offline = DENY**: while the tunnel is not open (or silent for more than 90 s), exec decisions that depend on
18
+ PolicyGate (signed platform tool commands, human-approval tokens) are denied with `via=policy-gate-offline`.
19
+ Allowlisted local commands do not need PolicyGate and are unaffected. Note: with `DISABLE_WS_TUNNEL=true` the agent is
20
+ permanently "offline", so these actions are always denied under `enforce` (a startup warning is logged; product decision for standalone agents pending, see Open points).
21
+ The same guard is shared with the MCP bridge (`/mcp/execute-command`), so the DENY applies to both exec entry points.
22
+
23
+ ## Reserved contract and known limits
24
+
25
+ - `hello`, `status` and `cost` are **reserved contract types: nothing emits them yet**. They are in the allowlist so that the
26
+ protocol v2 emitters (F3-05b, not merged) can be added without widening the closed world. Only `register` and command replies are sent today.
27
+ - `workspace.open` stays allowed in `enforce` (D16: VH is authoritative). It can still change local state (it sets the workspace root
28
+ from `params.root` sent by the control plane, protected by request signing); only its reply is projected to metadata.
29
+ - D19 liveness relies on the gateway answering the protocol `ping` (or the text `ping` with `pong`) within 90 s. This has **not** been
30
+ verified against the real QA gateway; validate it before enabling `enforce` in QA (open point on the PR).
31
+
32
+ ## What does not change
33
+
34
+ With the flag off (default) the tunnel, the register frame, command handling and exec policy are byte-for-byte the legacy ones.
35
+ The module opens no socket of its own. DLP (`src/dlp/*`), signing, bind and Helm are untouched.
36
+
37
+ ## Rollback
38
+
39
+ Unset `LOCAL_AGENT_WS_METADATA_ONLY` (or set `off`) and restart the agent.
40
+
41
+ ## Extending the contract
42
+
43
+ Add the type/field to `OUTBOUND_SCHEMAS` and a golden example to `test/ws-channel/allowlist.test.js` (the closed-set test fails on purpose).
44
+ The CP-side contract (F3-05b protocol v2) is not merged yet; align the schema with it when it lands.
package/env.example CHANGED
@@ -70,6 +70,13 @@ LOG_LEVEL=info
70
70
  # LOCAL_AGENT_WORKSPACE_ID=
71
71
  # LOCAL_AGENT_REQUIRE_WORKSPACE=false
72
72
 
73
+ # LOCAL_AGENT_WS_METADATA_ONLY=off|enforce (default off). enforce: the outbound WS channel to the
74
+ # control plane carries ONLY allowlisted metadata (ids, hashes, counts, state, cost) and never
75
+ # content (code, diffs, logs, chat). Legacy content commands are refused in this mode, and
76
+ # PolicyGate-backed actions (platform tool commands, human approvals) are DENIED while the agent
77
+ # has no live connection to the control plane (D19). Unrecognised values fail closed to enforce.
78
+ # LOCAL_AGENT_WS_METADATA_ONLY=off
79
+
73
80
  # DLP (T4-09): mask secrets/PII before content leaves the agent. Default off.
74
81
  # LOCAL_AGENT_DLP_MODE=off|warn|enforce (warn = count only, enforce = redact)
75
82
  # LOCAL_AGENT_DLP_BLOCK_CLASSES=private-key,cloud-credential (enforce: withhold whole text)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deepdebug-local-agent",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "DeepDebug Local Agent - AI-powered code debugging assistant",
5
5
  "private": false,
6
6
  "type": "module",
@@ -23,7 +23,7 @@ import { applyUnifiedDiff } from "./patch.js";
23
23
 
24
24
  const IGNORE_DIRS = ["node_modules", ".git", "target", "build", "dist", ".idea", "__pycache__", "vendor", ".gradle"];
25
25
 
26
- export function startMCPHttpServer(workspaceManager, port = 5056) {
26
+ export function startMCPHttpServer(workspaceManager, port = 5056, execPolicyOverride = null) {
27
27
  const app = express();
28
28
  // SECURITY: this bridge runs shell commands (/mcp/execute-command) and
29
29
  // writes files. Its only consumer is the in-process WS tunnel, so it binds
@@ -32,7 +32,9 @@ export function startMCPHttpServer(workspaceManager, port = 5056) {
32
32
  app.use(express.json({ limit: "50mb", verify: captureRawBody }));
33
33
  // Same signed-command verification as the main API (shared nonce cache).
34
34
  const signing = getSigningConfig();
35
- const execPolicy = resolveExecPolicyConfig();
35
+ // D19: server.js shares its EXEC_POLICY (with the offlineGuard when WS metadata-only is on),
36
+ // so /mcp/execute-command denies PolicyGate-backed actions while the control plane is offline.
37
+ const execPolicy = execPolicyOverride || resolveExecPolicyConfig();
36
38
  app.use(createSignatureMiddleware(signing));
37
39
 
38
40
  // ========================================
@@ -258,7 +260,7 @@ export function startMCPHttpServer(workspaceManager, port = 5056) {
258
260
  // START SERVER
259
261
  // ========================================
260
262
  const host = process.env.MCP_BIND_HOST || "127.0.0.1";
261
- app.listen(port, host, () => {
263
+ app.locals.server = app.listen(port, host, () => {
262
264
  console.log(`🔌 MCP HTTP Bridge listening on http://${host}:${port}`);
263
265
  });
264
266
 
package/src/server.js CHANGED
@@ -17,6 +17,8 @@ import { compileAndTest, run } from "./exec-utils.js";
17
17
  import { confineToRoot, checkArgsWithinRoot, scanRawCommand, SandboxViolation } from "./utils/sandbox.js";
18
18
  import { getSigningConfig, createSignatureMiddleware, captureRawBody, describeSigningConfig, verifyWsEnvelope, internalSignatureHeaders } from "./utils/request-signing.js";
19
19
  import { decideExec, rejectionBody, resolveExecPolicyConfig } from "./utils/exec-policy.js";
20
+ import { createHash as wsCreateHash } from "node:crypto";
21
+ import { resolveWsMetadataConfig, createWsMetadataGuard, createConnectionState, createOfflinePolicyGuard } from "./ws-channel/index.js";
20
22
  import { resolveEffectiveHeaderRoot } from "./utils/root-recovery.js";
21
23
  import { classifyGrepFailure, partialSearchBody } from "./utils/search-outcome.js";
22
24
  import { classifyOpenDestination, extractOpenTenantId, isSafeToRemove } from "./utils/open-gate.js";
@@ -637,6 +639,22 @@ app.use(createSignatureMiddleware(SIGNING));
637
639
  (SIGNING.mode === "enforce" ? console.log : console.warn)(`[agent-sig] ${describeSigningConfig(SIGNING)}`);
638
640
  console.log(`[exec-policy] mode=${EXEC_POLICY.mode} (allowlist; LOCAL_AGENT_EXEC_POLICY_MODE=off|warn|enforce)`);
639
641
 
642
+ // WS METADATA-ONLY (T4-10, D17/D19): LOCAL_AGENT_WS_METADATA_ONLY=off|enforce (default off).
643
+ // enforce: the outbound WS channel carries only allowlisted metadata and PolicyGate-backed
644
+ // actions are denied while the control plane is unreachable. off = nothing changes.
645
+ const WS_METADATA = resolveWsMetadataConfig();
646
+ const WS_CONNECTION = createConnectionState();
647
+ if (WS_METADATA.mode === "enforce") {
648
+ EXEC_POLICY.offlineGuard = createOfflinePolicyGuard({
649
+ connection: WS_CONNECTION,
650
+ onDeny: ({ via }) => console.warn(`[ws-metadata] D19 DENY (${via}): control plane offline`),
651
+ });
652
+ console.log("[ws-metadata] mode=enforce (metadata-only WS channel; offline PolicyGate = DENY)");
653
+ if (process.env.DISABLE_WS_TUNNEL === "true") {
654
+ console.warn("[ws-metadata] WARNING: DISABLE_WS_TUNNEL=true with enforce - the agent is permanently offline and every PolicyGate-backed action (platform tools, human approval) will be denied");
655
+ }
656
+ }
657
+
640
658
  // DEFAULT WORKSPACE - Sets the default workspace
641
659
  // Can be overridden via an environment variable or POST /workspace/open
642
660
  const DEFAULT_WORKSPACE = process.env.WORKSPACE_ROOT
@@ -6953,6 +6971,12 @@ async function startWebSocketTunnel(port, gatewayUrl, authToken, tenantId) {
6953
6971
  let reconnectDelay = 2000;
6954
6972
  let isShuttingDown = false;
6955
6973
  let ws = null;
6974
+ // null unless LOCAL_AGENT_WS_METADATA_ONLY=enforce; with it null every send below is the legacy frame.
6975
+ const metaGuard = createWsMetadataGuard({ config: WS_METADATA });
6976
+ const sendFrame = (legacyObj, guardedFrame) => {
6977
+ const out = metaGuard ? guardedFrame : JSON.stringify(legacyObj);
6978
+ if (out) ws.send(out);
6979
+ };
6956
6980
 
6957
6981
  async function connect() {
6958
6982
  if (isShuttingDown) return;
@@ -6963,14 +6987,26 @@ async function startWebSocketTunnel(port, gatewayUrl, authToken, tenantId) {
6963
6987
  ws.on('open', () => {
6964
6988
  console.log(`WebSocket tunnel connected to Gateway`);
6965
6989
  reconnectDelay = 2000;
6966
- ws.send(JSON.stringify({ type: 'register', tenantId, port, workspacePath: WORKSPACE_ROOT || null }));
6990
+ WS_CONNECTION.markOpen();
6991
+ const legacyRegister = { type: 'register', tenantId, port, workspacePath: WORKSPACE_ROOT || null };
6992
+ sendFrame(legacyRegister, metaGuard && metaGuard.frameMessage({
6993
+ type: 'register', tenantId, port: Number(port),
6994
+ ...(WORKSPACE_ROOT ? { workspaceHash: wsCreateHash("sha256").update(String(WORKSPACE_ROOT)).digest('hex') } : {}),
6995
+ }));
6967
6996
  const hb = setInterval(() => {
6968
- if (ws.readyState === WebSocket.OPEN) ws.send('ping');
6969
- else clearInterval(hb);
6997
+ if (ws.readyState === WebSocket.OPEN) {
6998
+ ws.send('ping');
6999
+ // Metadata-only: a protocol-level ping (auto-answered by any RFC 6455 server) proves
7000
+ // liveness for D19 even if the gateway never answers the text 'ping'.
7001
+ if (metaGuard) { try { ws.ping(); } catch { /* socket closing */ } }
7002
+ } else clearInterval(hb);
6970
7003
  }, 30000);
6971
7004
  });
6972
7005
 
7006
+ ws.on('pong', () => WS_CONNECTION.markSeen());
7007
+
6973
7008
  ws.on('message', async (data) => {
7009
+ WS_CONNECTION.markSeen();
6974
7010
  const text = data.toString();
6975
7011
  if (text === 'pong') return;
6976
7012
  let request;
@@ -6982,16 +7018,23 @@ async function startWebSocketTunnel(port, gatewayUrl, authToken, tenantId) {
6982
7018
  // with the signed paramsRaw, never the unsigned params copy.
6983
7019
  const envelope = verifyWsEnvelope(request, SIGNING);
6984
7020
  if (!envelope.ok) {
6985
- ws.send(JSON.stringify({ requestId, command, ok: false, error: "invalid command signature", reason: envelope.reason }));
7021
+ sendFrame({ requestId, command, ok: false, error: "invalid command signature", reason: envelope.reason },
7022
+ metaGuard && metaGuard.frameError(requestId, command, 'invalid_signature'));
6986
7023
  return;
6987
7024
  }
7025
+ if (metaGuard) {
7026
+ // Metadata-only: legacy content commands never run; the reply is a metadata-only error.
7027
+ const admit = metaGuard.admitCommand(requestId, command);
7028
+ if (!admit.ok) { if (admit.errorFrame) ws.send(admit.errorFrame); return; }
7029
+ }
6988
7030
  let result;
6989
7031
  try { result = await handleWsCommand(command, envelope.params || {}, envelope); }
6990
7032
  catch (err) { result = { error: err.message }; }
6991
- ws.send(JSON.stringify({ requestId, command, ...result }));
7033
+ sendFrame({ requestId, command, ...result }, metaGuard && metaGuard.frameResponse(requestId, command, result));
6992
7034
  });
6993
7035
 
6994
7036
  ws.on('close', () => {
7037
+ WS_CONNECTION.markClosed();
6995
7038
  if (!isShuttingDown) {
6996
7039
  console.log(`WS disconnected. Reconnecting in ${reconnectDelay/1000}s...`);
6997
7040
  setTimeout(connect, reconnectDelay);
@@ -8151,7 +8194,7 @@ app.listen(PORT, BIND_HOST, async () => {
8151
8194
 
8152
8195
  // Start MCP HTTP Bridge em paralelo (port 5056)
8153
8196
  try {
8154
- startMCPHttpServer(wsManager, parseInt(MCP_PORT));
8197
+ startMCPHttpServer(wsManager, parseInt(MCP_PORT), EXEC_POLICY);
8155
8198
  } catch (err) {
8156
8199
  console.warn(`MCP HTTP Server failed to start: ${err.message}`);
8157
8200
  console.warn(`(MCP features disabled, REST API continues normally)`);
@@ -907,7 +907,14 @@ export const execStats = { allowed: 0, platform: 0, approved: 0, warned: 0, reje
907
907
  * Decides one exec request. `command` (shell string) or `argv` (no shell).
908
908
  * Returns { allow: boolean, status?, reason?, via }.
909
909
  */
910
- export function decideExec({ command, argv, root, headers = {}, body = {}, vhSignature, signingCfg }, cfg = resolveExecPolicyConfig()) {
910
+ export function decideExec(input, cfg = resolveExecPolicyConfig()) {
911
+ const decision = decideExecInner(input, cfg);
912
+ // D19 (LOCAL_AGENT_WS_METADATA_ONLY=enforce): PolicyGate-backed ALLOWs become DENY while the
913
+ // control plane is unreachable. `offlineGuard` is unset by default, so this is a no-op.
914
+ return typeof cfg.offlineGuard === "function" ? cfg.offlineGuard(decision) : decision;
915
+ }
916
+
917
+ function decideExecInner({ command, argv, root, headers = {}, body = {}, vhSignature, signingCfg }, cfg) {
911
918
  if (cfg.mode === "off") return { allow: true, via: "policy-off" };
912
919
  const verdict = argv
913
920
  ? (() => {
@@ -0,0 +1,143 @@
1
+ /**
2
+ * allowlist.js - the contract of what may leave the Local Agent on the WS
3
+ * channel in metadata-only mode (T4-10).
4
+ *
5
+ * Rules:
6
+ * - closed world: a message type, a field or a value outside this file is rejected;
7
+ * - there is NO free-text field: strings are ids, sha256 hashes or members of a
8
+ * fixed enum, so code/diffs/logs/chat text cannot fit even by accident;
9
+ * - flat messages only (no nested objects/arrays).
10
+ *
11
+ * Validation returns reason CODES and field NAMES only - never values - so a
12
+ * caller can log a rejection without leaking the content that triggered it.
13
+ */
14
+
15
+ const ID_RE = /^[A-Za-z0-9._:-]{1,128}$/;
16
+ const HASH_RE = /^[a-f0-9]{64}$/;
17
+ const VERSION_RE = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.]{1,32})?$/;
18
+
19
+ /** Commands the CP may still invoke in metadata-only mode (legacy content commands are vh-mode only). */
20
+ export const METADATA_COMMANDS = Object.freeze(["health", "workspace.open"]);
21
+
22
+ /** Machine-readable error classes that replace free-text error messages. */
23
+ export const ERROR_CODES = Object.freeze([
24
+ "command_error", "blocked_metadata_only", "invalid_signature", "request_expired", "rate_limited", "unknown_request",
25
+ ]);
26
+
27
+ const KINDS = {
28
+ id: (v) => typeof v === "string" && ID_RE.test(v),
29
+ hash: (v) => typeof v === "string" && HASH_RE.test(v),
30
+ version: (v) => typeof v === "string" && VERSION_RE.test(v),
31
+ int: (v) => Number.isSafeInteger(v) && v >= 0,
32
+ port: (v) => Number.isInteger(v) && v >= 1 && v <= 65535,
33
+ bool: (v) => typeof v === "boolean",
34
+ };
35
+
36
+ const enumOf = (...values) => ({ kind: "enum", values });
37
+ const f = (kind, extra = {}) => ({ kind, ...extra });
38
+
39
+ /** Outbound schemas, keyed by message type. `response` is the implicit type of a command reply. */
40
+ export const OUTBOUND_SCHEMAS = Object.freeze({
41
+ register: {
42
+ type: f("const", { value: "register" }),
43
+ tenantId: f("id", { required: true }),
44
+ port: f("port", { required: true }),
45
+ workspaceHash: f("hash"),
46
+ },
47
+ hello: {
48
+ type: f("const", { value: "hello" }),
49
+ agentVersion: f("version", { required: true }),
50
+ protocol: f("int"),
51
+ signatureMode: enumOf("off", "warn", "enforce"),
52
+ metadataOnly: enumOf("off", "enforce"),
53
+ },
54
+ status: {
55
+ type: f("const", { value: "status" }),
56
+ state: enumOf("idle", "busy", "degraded"),
57
+ openWorkspaces: f("int"),
58
+ queueDepth: f("int"),
59
+ uptimeSeconds: f("int"),
60
+ },
61
+ cost: {
62
+ type: f("const", { value: "cost" }),
63
+ requestId: f("id", { required: true }),
64
+ model: f("id"),
65
+ tokensIn: f("int"),
66
+ tokensOut: f("int"),
67
+ costMicros: f("int"),
68
+ },
69
+ response: {
70
+ requestId: f("id", { required: true }),
71
+ command: f("id", { required: true }),
72
+ ok: f("bool"),
73
+ status: enumOf("ok"),
74
+ openWorkspaces: f("int"),
75
+ errorCode: enumOf(...ERROR_CODES),
76
+ },
77
+ });
78
+
79
+ /** Per-command result keys that are dropped (never sent) because they are paths/content; listed so the drop is explicit. */
80
+ export const RESPONSE_STRIP = Object.freeze({
81
+ "health": Object.freeze(["workspace"]),
82
+ "workspace.open": Object.freeze(["root"]),
83
+ });
84
+
85
+ /** Free-text keys of a legacy result: replaced by an `errorCode`, value discarded. */
86
+ export const FREE_TEXT_KEYS = Object.freeze(["error", "reason", "hint", "message", "detail"]);
87
+
88
+ function checkField(spec, value) {
89
+ if (spec.kind === "const") return value === spec.value;
90
+ if (spec.kind === "enum") return typeof value === "string" && spec.values.includes(value);
91
+ const fn = KINDS[spec.kind];
92
+ return Boolean(fn) && fn(value);
93
+ }
94
+
95
+ /** The wire type a message will be validated as. */
96
+ export function messageType(msg) {
97
+ if (msg && typeof msg === "object" && !Array.isArray(msg)) {
98
+ if (typeof msg.type === "string") return msg.type;
99
+ if ("requestId" in msg && "command" in msg) return "response";
100
+ }
101
+ return null;
102
+ }
103
+
104
+ /**
105
+ * @returns {{ok:true,type:string}|{ok:false,reason:string,type?:string,field?:string}}
106
+ * reason: not-an-object | unknown-type | unknown-field | invalid-value | missing-field
107
+ */
108
+ export function validateOutbound(msg) {
109
+ if (!msg || typeof msg !== "object" || Array.isArray(msg)) return { ok: false, reason: "not-an-object" };
110
+ const type = messageType(msg);
111
+ const schema = type && Object.prototype.hasOwnProperty.call(OUTBOUND_SCHEMAS, type) ? OUTBOUND_SCHEMAS[type] : null;
112
+ if (!schema) return { ok: false, reason: "unknown-type" };
113
+ for (const key of Object.keys(msg)) {
114
+ if (!Object.prototype.hasOwnProperty.call(schema, key)) return { ok: false, reason: "unknown-field", type, field: key };
115
+ if (!checkField(schema[key], msg[key])) return { ok: false, reason: "invalid-value", type, field: key };
116
+ }
117
+ for (const [key, spec] of Object.entries(schema)) {
118
+ if (spec.required && !(key in msg)) return { ok: false, reason: "missing-field", type, field: key };
119
+ }
120
+ return { ok: true, type };
121
+ }
122
+
123
+ /**
124
+ * Projects a legacy command result onto the response schema.
125
+ * Strip-listed keys are dropped; free-text keys become errorCode `command_error`;
126
+ * any other key outside the schema makes the projection fail (content-bearing).
127
+ * @returns {{ok:true,message:object}|{ok:false,reason:string,field?:string}}
128
+ */
129
+ export function projectResponse(requestId, command, result) {
130
+ const schema = OUTBOUND_SCHEMAS.response;
131
+ const strip = RESPONSE_STRIP[command] || [];
132
+ const out = { requestId, command };
133
+ let hadFreeText = false;
134
+ for (const [key, value] of Object.entries(result && typeof result === "object" ? result : {})) {
135
+ if (key === "requestId" || key === "command") continue;
136
+ if (strip.includes(key)) continue;
137
+ if (FREE_TEXT_KEYS.includes(key)) { hadFreeText = true; continue; }
138
+ if (!Object.prototype.hasOwnProperty.call(schema, key)) return { ok: false, reason: "unknown-field", field: key };
139
+ out[key] = value;
140
+ }
141
+ if (hadFreeText) { out.ok = false; out.errorCode = "command_error"; }
142
+ return { ok: true, message: out };
143
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * config.js - LOCAL_AGENT_WS_METADATA_ONLY flag (T4-10).
3
+ *
4
+ * off (default) legacy behaviour, byte-for-byte: the WS tunnel is untouched.
5
+ * enforce metadata-only: the outbound WS channel carries ONLY allowlisted
6
+ * metadata (state, counts, hashes, ids, cost) and never content
7
+ * (code, diffs, logs, chat text). D19 (offline PolicyGate = DENY) is
8
+ * activated by the same flag.
9
+ *
10
+ * Unset/empty = off. A non-empty but unrecognised value fails CLOSED to
11
+ * `enforce` (a typo must never silently re-open the content path).
12
+ */
13
+ export const WS_METADATA_FLAG = "LOCAL_AGENT_WS_METADATA_ONLY";
14
+ export const WS_METADATA_MODES = Object.freeze(["off", "enforce"]);
15
+
16
+ export function resolveWsMetadataConfig(env = process.env, warn = console.warn) {
17
+ const raw = String(env[WS_METADATA_FLAG] ?? "").trim().toLowerCase();
18
+ if (raw === "") return { mode: "off" };
19
+ if (WS_METADATA_MODES.includes(raw)) return { mode: raw };
20
+ warn(`[ws-metadata] unrecognised ${WS_METADATA_FLAG} value; failing closed to 'enforce'`);
21
+ return { mode: "enforce" };
22
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * connection-state.js - is the Local Agent connected to the control plane?
3
+ * Input for D19 (offline PolicyGate = DENY). Online = the tunnel socket is open
4
+ * AND something (message or pong) was seen within `staleAfterMs`; the heartbeat
5
+ * is 30 s, so the default tolerates one missed beat.
6
+ */
7
+ export const DEFAULT_STALE_AFTER_MS = 90_000;
8
+
9
+ export function createConnectionState({ now = Date.now, staleAfterMs = DEFAULT_STALE_AFTER_MS } = {}) {
10
+ let open = false;
11
+ let lastSeen = 0;
12
+ return {
13
+ markOpen() { open = true; lastSeen = now(); },
14
+ markClosed() { open = false; },
15
+ markSeen() { if (open) lastSeen = now(); },
16
+ isOnline() { return open && now() - lastSeen <= staleAfterMs; },
17
+ };
18
+ }
@@ -0,0 +1,6 @@
1
+ export { resolveWsMetadataConfig, WS_METADATA_FLAG, WS_METADATA_MODES } from "./config.js";
2
+ export { createWsMetadataGuard } from "./metadata-guard.js";
3
+ export { createConnectionState } from "./connection-state.js";
4
+ export { createOfflinePolicyGuard, requiresPolicyGate, decideOffline, OFFLINE_DENY_REASON } from "./policy-offline.js";
5
+ export { validateOutbound, projectResponse, METADATA_COMMANDS, OUTBOUND_SCHEMAS } from "./allowlist.js";
6
+ export { createTransitGuard, DEFAULT_LIMITS } from "./transit-guard.js";
@@ -0,0 +1,78 @@
1
+ /**
2
+ * metadata-guard.js - the metadata-only outbound filter for the Local Agent WS
3
+ * channel (T4-10). A NEW filtering module: it opens no socket itself and
4
+ * returns null when the flag is off, so with the flag off nothing changes.
5
+ *
6
+ * Every outbound message passes: size limit -> rate limit -> allowlist
7
+ * validation. A violation is dropped, logged WITHOUT its content (type, field
8
+ * name, reason code, byte size) and, for command replies, answered with a
9
+ * metadata-only error so the control plane does not wait for a timeout.
10
+ */
11
+ import { METADATA_COMMANDS, validateOutbound, projectResponse } from "./allowlist.js";
12
+ import { createTransitGuard } from "./transit-guard.js";
13
+
14
+ export function createWsMetadataGuard({ config, transit, logger = console, now = Date.now } = {}) {
15
+ if (!config || config.mode !== "enforce") return null; // flag off: no guard, no behaviour change
16
+ const guard = transit || createTransitGuard({ now });
17
+ const stats = { sent: 0, blocked: 0 };
18
+
19
+ // Content-free log line: only codes/names/sizes, never values.
20
+ const logBlocked = (info) => {
21
+ stats.blocked++;
22
+ logger.warn(`[ws-metadata] BLOCKED ${JSON.stringify(info)}`);
23
+ };
24
+
25
+ /** Serialises + checks one message. @returns {string|null} the frame to send, null if blocked. */
26
+ function frame(msg) {
27
+ const v = validateOutbound(msg);
28
+ if (!v.ok) { logBlocked({ reason: v.reason, type: v.type || null, field: v.field || null }); return null; }
29
+ const serialized = JSON.stringify(msg);
30
+ const size = guard.checkSize(serialized);
31
+ if (!size.ok) { logBlocked({ reason: size.reason, type: v.type }); return null; }
32
+ const rate = guard.checkRate();
33
+ if (!rate.ok) { logBlocked({ reason: rate.reason, type: v.type }); return null; }
34
+ stats.sent++;
35
+ return serialized;
36
+ }
37
+
38
+ const errorFrame = (requestId, command, errorCode) =>
39
+ frame({ requestId, command, ok: false, errorCode });
40
+
41
+ return {
42
+ mode: "enforce",
43
+ stats,
44
+ /** Spontaneous (non-reply) messages: register, hello, status, cost. */
45
+ frameMessage: frame,
46
+ /**
47
+ * Inbound command gate: legacy content commands never execute in
48
+ * metadata-only mode. @returns {{ok:true}|{ok:false,errorFrame:string|null}}
49
+ */
50
+ admitCommand(requestId, command) {
51
+ if (!METADATA_COMMANDS.includes(command)) {
52
+ logBlocked({ reason: "command-not-allowed", type: "command", field: "command" });
53
+ return { ok: false, errorFrame: errorFrame(requestId, command, "blocked_metadata_only") };
54
+ }
55
+ const a = guard.admitRequest(requestId);
56
+ if (!a.ok) return { ok: false, errorFrame: errorFrame(requestId, command, "rate_limited") };
57
+ return { ok: true };
58
+ },
59
+ /** Reply to an admitted command: projected onto the allowlist, TTL-checked, validated. */
60
+ frameResponse(requestId, command, result) {
61
+ const t = guard.consumeRequest(requestId);
62
+ if (!t.ok) {
63
+ logBlocked({ reason: t.reason, type: "response" });
64
+ return null; // nothing to reply to / too late: drop silently (no queueing)
65
+ }
66
+ const p = projectResponse(requestId, command, result);
67
+ if (!p.ok) {
68
+ logBlocked({ reason: p.reason, type: "response", field: p.field || null });
69
+ return errorFrame(requestId, command, "blocked_metadata_only");
70
+ }
71
+ return frame(p.message) || errorFrame(requestId, command, "blocked_metadata_only");
72
+ },
73
+ /** Error reply for a request that never reached execution (e.g. bad signature). */
74
+ frameError(requestId, command, errorCode) {
75
+ return errorFrame(requestId, command, errorCode);
76
+ },
77
+ };
78
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * policy-offline.js - D19: PolicyGate per action; offline = DENY (fail-closed).
3
+ *
4
+ * Actions that need a PolicyGate decision (a platform-authored tool command or
5
+ * a human-approval token) are denied while the agent has no live connection to
6
+ * the control plane. Purely local, allowlisted commands do not need PolicyGate
7
+ * and are unaffected. Active only when LOCAL_AGENT_WS_METADATA_ONLY=enforce.
8
+ */
9
+
10
+ /** decideExec().via prefixes that mean "PolicyGate authorised this". */
11
+ const GATED_VIA_PREFIXES = Object.freeze(["platform-tool:", "human-approval:"]);
12
+
13
+ export const OFFLINE_DENY_REASON = "PolicyGate unreachable: no connection to the control plane (offline = DENY)";
14
+
15
+ export function requiresPolicyGate(via) {
16
+ return typeof via === "string" && GATED_VIA_PREFIXES.some((p) => via.startsWith(p));
17
+ }
18
+
19
+ /**
20
+ * Generic per-action decision.
21
+ * @returns {{decision:"ALLOW"|"DENY",reason?:string}}
22
+ */
23
+ export function decideOffline({ gated, online }) {
24
+ if (gated && !online) return { decision: "DENY", reason: OFFLINE_DENY_REASON };
25
+ return { decision: "ALLOW" };
26
+ }
27
+
28
+ /**
29
+ * Builds the guard plugged into exec-policy (`cfg.offlineGuard`): receives a
30
+ * decideExec() result and downgrades PolicyGate-backed ALLOWs to DENY when offline.
31
+ */
32
+ export function createOfflinePolicyGuard({ connection, onDeny = () => {} }) {
33
+ return function offlineGuard(decision) {
34
+ if (!decision || !decision.allow || !requiresPolicyGate(decision.via)) return decision;
35
+ const verdict = decideOffline({ gated: true, online: connection.isOnline() });
36
+ if (verdict.decision === "ALLOW") return decision;
37
+ onDeny({ via: decision.via.split(":")[0] });
38
+ return { allow: false, status: 403, reason: verdict.reason, via: "policy-gate-offline" };
39
+ };
40
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * transit-guard.js - D17 controls for content/metadata in transit through the
3
+ * control plane: nothing is persisted or queued here, no payload is ever
4
+ * logged, replies are only sent for known requests inside a TTL, and size and
5
+ * rate are bounded.
6
+ */
7
+ export const DEFAULT_LIMITS = Object.freeze({
8
+ maxMessageBytes: 4096,
9
+ requestTtlMs: 60_000,
10
+ maxInFlight: 256,
11
+ rateWindowMs: 10_000,
12
+ rateMax: 200,
13
+ });
14
+
15
+ export function createTransitGuard({ now = Date.now, limits = {} } = {}) {
16
+ const lim = { ...DEFAULT_LIMITS, ...limits };
17
+ const inFlight = new Map(); // requestId -> expiresAt (in memory only; never persisted)
18
+ let windowStart = now();
19
+ let windowCount = 0;
20
+
21
+ const sweep = () => {
22
+ const t = now();
23
+ for (const [id, exp] of inFlight) if (exp <= t) inFlight.delete(id);
24
+ };
25
+
26
+ return {
27
+ limits: lim,
28
+ /** Registers an inbound request. @returns {{ok:true}|{ok:false,reason:string}} */
29
+ admitRequest(requestId) {
30
+ sweep();
31
+ if (inFlight.size >= lim.maxInFlight) return { ok: false, reason: "too-many-in-flight" };
32
+ inFlight.set(requestId, now() + lim.requestTtlMs);
33
+ return { ok: true };
34
+ },
35
+ /** A reply may only be sent once, to a known request, before its TTL. */
36
+ consumeRequest(requestId) {
37
+ const exp = inFlight.get(requestId);
38
+ if (exp === undefined) return { ok: false, reason: "unknown-request" };
39
+ inFlight.delete(requestId);
40
+ if (exp <= now()) return { ok: false, reason: "request-expired" };
41
+ return { ok: true };
42
+ },
43
+ checkSize(serialized) {
44
+ return Buffer.byteLength(serialized, "utf8") <= lim.maxMessageBytes ? { ok: true } : { ok: false, reason: "message-too-large" };
45
+ },
46
+ checkRate() {
47
+ const t = now();
48
+ if (t - windowStart >= lim.rateWindowMs) { windowStart = t; windowCount = 0; }
49
+ if (windowCount >= lim.rateMax) return { ok: false, reason: "rate-limited" };
50
+ windowCount++;
51
+ return { ok: true };
52
+ },
53
+ pending: () => inFlight.size,
54
+ };
55
+ }
@@ -0,0 +1,67 @@
1
+ // Contract tests for the metadata-only outbound allowlist (T4-10).
2
+ import { test } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { validateOutbound, projectResponse, OUTBOUND_SCHEMAS } from '../../src/ws-channel/allowlist.js';
5
+
6
+ const H = 'a'.repeat(64);
7
+
8
+ test('contract: every allowed message type validates with a golden example', () => {
9
+ const golden = [
10
+ { type: 'register', tenantId: 'tenant-1', port: 5055, workspaceHash: H },
11
+ { type: 'hello', agentVersion: '1.1.0', protocol: 2, signatureMode: 'enforce', metadataOnly: 'enforce' },
12
+ { type: 'status', state: 'idle', openWorkspaces: 2, queueDepth: 0, uptimeSeconds: 120 },
13
+ { type: 'cost', requestId: 'req-1', model: 'claude-sonnet-5-5', tokensIn: 10, tokensOut: 5, costMicros: 1200 },
14
+ { requestId: 'r1', command: 'health', ok: true, status: 'ok', openWorkspaces: 1 },
15
+ { requestId: 'r2', command: 'workspace.open', ok: false, errorCode: 'command_error' },
16
+ ];
17
+ for (const m of golden) assert.deepEqual(validateOutbound(m).ok, true, JSON.stringify(m));
18
+ });
19
+
20
+ test('contract: schema set is closed (changing it is a deliberate, reviewed edit)', () => {
21
+ assert.deepEqual(Object.keys(OUTBOUND_SCHEMAS).sort(), ['cost', 'hello', 'register', 'response', 'status']);
22
+ });
23
+
24
+ test('rejects unknown types, unknown fields and non-objects', () => {
25
+ assert.equal(validateOutbound({ type: 'file-content', content: 'x' }).reason, 'unknown-type');
26
+ assert.equal(validateOutbound({ type: 'status', state: 'idle', content: 'x' }).reason, 'unknown-field');
27
+ assert.equal(validateOutbound('ping').reason, 'not-an-object');
28
+ assert.equal(validateOutbound([]).reason, 'not-an-object');
29
+ assert.equal(validateOutbound({ type: '__proto__' }).reason, 'unknown-type');
30
+ });
31
+
32
+ test('rejects free text: code, diffs, paths, log lines and chat do not fit any string field', () => {
33
+ const leaks = ['const x = require("fs");', '--- a/x\n+++ b/x', '/Users/me/project/src/A.java', 'ERROR something failed', 'hello how are you', ''];
34
+ for (const v of leaks) {
35
+ assert.equal(validateOutbound({ type: 'register', tenantId: v, port: 1 }).ok, false, JSON.stringify(v));
36
+ assert.equal(validateOutbound({ type: 'cost', requestId: 'r', model: v }).ok, false);
37
+ assert.equal(validateOutbound({ requestId: 'r', command: v }).ok, false);
38
+ }
39
+ assert.equal(validateOutbound({ type: 'register', tenantId: 't', port: 1, workspaceHash: '/etc/passwd' }).field, 'workspaceHash');
40
+ });
41
+
42
+ test('rejects nested values, wrong numeric ranges and missing required fields', () => {
43
+ assert.equal(validateOutbound({ type: 'status', state: { a: 1 } }).ok, false);
44
+ assert.equal(validateOutbound({ type: 'status', openWorkspaces: -1 }).ok, false);
45
+ assert.equal(validateOutbound({ type: 'status', openWorkspaces: 1.5 }).ok, false);
46
+ assert.equal(validateOutbound({ type: 'register', port: 70000, tenantId: 't' }).ok, false);
47
+ assert.equal(validateOutbound({ type: 'register', port: 5055 }).reason, 'missing-field');
48
+ assert.equal(validateOutbound({ type: 'status', state: 'unknown-state' }).ok, false);
49
+ });
50
+
51
+ test('validation reports field names and codes, never the offending value', () => {
52
+ const secret = 'AKIA_SECRET_VALUE_123 /home/x';
53
+ const r = validateOutbound({ type: 'register', tenantId: secret, port: 1 });
54
+ assert.equal(JSON.stringify(r).includes('SECRET'), false);
55
+ });
56
+
57
+ test('projectResponse strips paths, turns free text into errorCode, rejects content fields', () => {
58
+ assert.deepEqual(projectResponse('r', 'health', { status: 'ok', workspace: '/home/u/p', openWorkspaces: 1 }).message,
59
+ { requestId: 'r', command: 'health', status: 'ok', openWorkspaces: 1 });
60
+ assert.deepEqual(projectResponse('r', 'workspace.open', { ok: true, root: '/home/u/p' }).message,
61
+ { requestId: 'r', command: 'workspace.open', ok: true });
62
+ assert.deepEqual(projectResponse('r', 'x', { error: 'ENOENT /home/u/secret.txt' }).message,
63
+ { requestId: 'r', command: 'x', ok: false, errorCode: 'command_error' });
64
+ const c = projectResponse('r', 'workspace.file-content', { content: 'code', found: true });
65
+ assert.equal(c.ok, false);
66
+ assert.equal(c.field, 'content');
67
+ });
@@ -0,0 +1,104 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createWsMetadataGuard } from '../../src/ws-channel/metadata-guard.js';
4
+ import { resolveWsMetadataConfig } from '../../src/ws-channel/config.js';
5
+ import { createTransitGuard } from '../../src/ws-channel/transit-guard.js';
6
+
7
+ const capture = () => { const lines = []; return { lines, warn: (m) => lines.push(String(m)), log: (m) => lines.push(String(m)) }; };
8
+ const enforce = { mode: 'enforce' };
9
+
10
+ test('flag config: default off, enforce, and unrecognised values fail closed', () => {
11
+ const warn = () => {};
12
+ assert.equal(resolveWsMetadataConfig({}, warn).mode, 'off');
13
+ assert.equal(resolveWsMetadataConfig({ LOCAL_AGENT_WS_METADATA_ONLY: '' }, warn).mode, 'off');
14
+ assert.equal(resolveWsMetadataConfig({ LOCAL_AGENT_WS_METADATA_ONLY: 'off' }, warn).mode, 'off');
15
+ assert.equal(resolveWsMetadataConfig({ LOCAL_AGENT_WS_METADATA_ONLY: ' ENFORCE ' }, warn).mode, 'enforce');
16
+ assert.equal(resolveWsMetadataConfig({ LOCAL_AGENT_WS_METADATA_ONLY: 'enfroce' }, warn).mode, 'enforce');
17
+ });
18
+
19
+ test('flag off: no guard is created and no socket is opened', () => {
20
+ let socketsOpened = 0;
21
+ const openSocket = () => { socketsOpened++; };
22
+ const guard = createWsMetadataGuard({ config: resolveWsMetadataConfig({}) });
23
+ assert.equal(guard, null);
24
+ if (guard) openSocket(); // the conditional start point: only reached when the guard exists
25
+ assert.equal(socketsOpened, 0);
26
+ });
27
+
28
+ test('legacy content commands are refused before execution, with a metadata-only error', () => {
29
+ const log = capture();
30
+ const g = createWsMetadataGuard({ config: enforce, logger: log });
31
+ for (const cmd of ['workspace.file-content', 'workspace.diff', 'workspace.safe-patch', 'mcp.read-file', 'mcp.execute-command', 'mcp.apply-patch']) {
32
+ const a = g.admitCommand('req-1', cmd);
33
+ assert.equal(a.ok, false, cmd);
34
+ assert.deepEqual(JSON.parse(a.errorFrame), { requestId: 'req-1', command: cmd, ok: false, errorCode: 'blocked_metadata_only' });
35
+ }
36
+ });
37
+
38
+ test('health reply carries only metadata (workspace path dropped)', () => {
39
+ const g = createWsMetadataGuard({ config: enforce, logger: capture() });
40
+ assert.equal(g.admitCommand('r1', 'health').ok, true);
41
+ const out = JSON.parse(g.frameResponse('r1', 'health', { status: 'ok', workspace: '/Users/x/client-repo', openWorkspaces: 2 }));
42
+ assert.deepEqual(out, { requestId: 'r1', command: 'health', status: 'ok', openWorkspaces: 2 });
43
+ });
44
+
45
+ test('content in a reply is blocked, replaced by an error, and never logged', () => {
46
+ const log = capture();
47
+ const g = createWsMetadataGuard({ config: enforce, logger: log });
48
+ g.admitCommand('r2', 'health');
49
+ const secret = 'SUPER_SECRET_SOURCE_CODE_LINE';
50
+ const out = JSON.parse(g.frameResponse('r2', 'health', { status: 'ok', diff: secret }));
51
+ assert.deepEqual(out, { requestId: 'r2', command: 'health', ok: false, errorCode: 'blocked_metadata_only' });
52
+ assert.ok(log.lines.some((l) => l.includes('BLOCKED') && l.includes('unknown-field') && l.includes('diff')));
53
+ assert.equal(log.lines.join('\n').includes(secret), false, 'payload must never be logged');
54
+ assert.equal(JSON.stringify(out).includes(secret), false);
55
+ });
56
+
57
+ test('free-text error messages become an errorCode and are not sent', () => {
58
+ const g = createWsMetadataGuard({ config: enforce, logger: capture() });
59
+ g.admitCommand('r3', 'workspace.open');
60
+ const out = JSON.parse(g.frameResponse('r3', 'workspace.open', { error: 'ENOENT: /home/u/secret/file.js' }));
61
+ assert.deepEqual(out, { requestId: 'r3', command: 'workspace.open', ok: false, errorCode: 'command_error' });
62
+ });
63
+
64
+ test('spontaneous messages are validated; violations return null (dropped, not queued)', () => {
65
+ const log = capture();
66
+ const g = createWsMetadataGuard({ config: enforce, logger: log });
67
+ assert.ok(g.frameMessage({ type: 'status', state: 'idle', openWorkspaces: 1 }));
68
+ assert.equal(g.frameMessage({ type: 'chat', text: 'hi' }), null);
69
+ assert.equal(g.frameMessage({ type: 'register', tenantId: 't', port: 1, workspacePath: '/x' }), null);
70
+ assert.equal(g.stats.blocked, 2);
71
+ });
72
+
73
+ test('D17 TTL: reply to an unknown, expired or already-answered request is dropped', () => {
74
+ let t = 1_000;
75
+ const transit = createTransitGuard({ now: () => t, limits: { requestTtlMs: 1000 } });
76
+ const g = createWsMetadataGuard({ config: enforce, transit, logger: capture() });
77
+ assert.equal(g.frameResponse('never', 'health', { status: 'ok' }), null);
78
+ g.admitCommand('a', 'health');
79
+ assert.ok(g.frameResponse('a', 'health', { status: 'ok' }));
80
+ assert.equal(g.frameResponse('a', 'health', { status: 'ok' }), null, 'single use');
81
+ g.admitCommand('b', 'health');
82
+ t += 1500;
83
+ assert.equal(g.frameResponse('b', 'health', { status: 'ok' }), null, 'expired');
84
+ });
85
+
86
+ test('D17 limits: message size, rate and in-flight bound', () => {
87
+ const transit = createTransitGuard({ limits: { rateMax: 2, maxInFlight: 2, maxMessageBytes: 60 } });
88
+ const g = createWsMetadataGuard({ config: enforce, transit, logger: capture() });
89
+ assert.equal(g.frameMessage({ type: 'cost', requestId: 'r', model: 'm'.repeat(100) }), null, 'oversize');
90
+ assert.ok(g.frameMessage({ type: 'status', state: 'idle' }));
91
+ assert.ok(g.frameMessage({ type: 'status', state: 'busy' }));
92
+ assert.equal(g.frameMessage({ type: 'status', state: 'idle' }), null, 'rate limited');
93
+ assert.equal(g.admitCommand('x1', 'health').ok, true);
94
+ assert.equal(g.admitCommand('x2', 'health').ok, true);
95
+ assert.equal(g.admitCommand('x3', 'health').ok, false, 'in-flight cap');
96
+ });
97
+
98
+ test('D17: the guard persists nothing (no fs/network usage in the module)', async () => {
99
+ const { readFile } = await import('node:fs/promises');
100
+ for (const f of ['metadata-guard', 'transit-guard', 'allowlist']) {
101
+ const src = await readFile(new URL(`../../src/ws-channel/${f}.js`, import.meta.url), 'utf8');
102
+ assert.equal(/node:fs|from "fs"|appendFile|writeFile|node:net|node:http/.test(src), false, f);
103
+ }
104
+ });
@@ -0,0 +1,99 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createConnectionState } from '../../src/ws-channel/connection-state.js';
4
+ import { createOfflinePolicyGuard, requiresPolicyGate, decideOffline } from '../../src/ws-channel/policy-offline.js';
5
+ import { decideExec } from '../../src/utils/exec-policy.js';
6
+
7
+ test('connection state: online only while open and fresh', () => {
8
+ let t = 0;
9
+ const c = createConnectionState({ now: () => t, staleAfterMs: 90_000 });
10
+ assert.equal(c.isOnline(), false);
11
+ c.markOpen();
12
+ assert.equal(c.isOnline(), true);
13
+ t = 60_000; c.markSeen();
14
+ t = 120_000;
15
+ assert.equal(c.isOnline(), true);
16
+ t = 200_000;
17
+ assert.equal(c.isOnline(), false, 'stale = offline');
18
+ c.markSeen(); // ignored while stale? still open, refreshes
19
+ assert.equal(c.isOnline(), true);
20
+ c.markClosed();
21
+ assert.equal(c.isOnline(), false);
22
+ c.markSeen();
23
+ assert.equal(c.isOnline(), false, 'a message on a closed socket does not revive it');
24
+ });
25
+
26
+ test('D19 classification: only PolicyGate-backed decisions need the gate', () => {
27
+ assert.equal(requiresPolicyGate('platform-tool:git_push'), true);
28
+ assert.equal(requiresPolicyGate('human-approval:ana'), true);
29
+ assert.equal(requiresPolicyGate('allowlist:git'), false);
30
+ assert.equal(requiresPolicyGate('policy-off'), false);
31
+ assert.deepEqual(decideOffline({ gated: true, online: false }).decision, 'DENY');
32
+ assert.deepEqual(decideOffline({ gated: true, online: true }).decision, 'ALLOW');
33
+ assert.deepEqual(decideOffline({ gated: false, online: false }).decision, 'ALLOW');
34
+ });
35
+
36
+ test('D19 guard: offline DENY for gated allows, allowlisted local commands unaffected', () => {
37
+ const state = { online: false };
38
+ const denied = [];
39
+ const guard = createOfflinePolicyGuard({ connection: { isOnline: () => state.online }, onDeny: (d) => denied.push(d) });
40
+ const gated = { allow: true, via: 'platform-tool:git_push' };
41
+ const local = { allow: true, via: 'allowlist:git' };
42
+
43
+ const d = guard(gated);
44
+ assert.equal(d.allow, false);
45
+ assert.equal(d.status, 403);
46
+ assert.equal(d.via, 'policy-gate-offline');
47
+ assert.deepEqual(denied, [{ via: 'platform-tool' }]);
48
+ assert.equal(guard(local), local);
49
+ assert.deepEqual(guard({ allow: false, via: 'rejected' }), { allow: false, via: 'rejected' });
50
+
51
+ state.online = true;
52
+ assert.equal(guard(gated), gated, 'online: PolicyGate path unchanged');
53
+ });
54
+
55
+ test('D19 via decideExec: a signed platform tool is denied offline, allowed online; flag off = unchanged', () => {
56
+ const input = {
57
+ command: 'git push origin feat/x', root: process.cwd(), headers: {},
58
+ body: { commandOrigin: 'platform', tool: 'git_push' }, vhSignature: { verified: true },
59
+ };
60
+ const base = { mode: 'enforce' };
61
+ assert.equal(decideExec(input, base).allow, true, 'flag off (no guard): unchanged');
62
+ let online = false;
63
+ const cfg = { mode: 'enforce', offlineGuard: createOfflinePolicyGuard({ connection: { isOnline: () => online } }) };
64
+ const off = decideExec(input, cfg);
65
+ assert.equal(off.allow, false);
66
+ assert.equal(off.via, 'policy-gate-offline');
67
+ online = true;
68
+ assert.equal(decideExec(input, cfg).allow, true);
69
+ });
70
+
71
+ test('D19 on the MCP bridge: /mcp/execute-command denies a platform tool offline, allows it online', async () => {
72
+ const net = await import('node:net');
73
+ const { startMCPHttpServer } = await import('../../src/mcp-http-server.js');
74
+ const { internalSignatureHeaders } = await import('../../src/utils/request-signing.js');
75
+ const port = await new Promise((resolve) => {
76
+ const s = net.createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); });
77
+ });
78
+ let online = false;
79
+ const execPolicy = { mode: 'enforce', offlineGuard: createOfflinePolicyGuard({ connection: { isOnline: () => online } }) };
80
+ const app = startMCPHttpServer({ resolveRoot: () => process.cwd(), count: 0, list: () => [] }, port, execPolicy);
81
+ await new Promise((r) => (app.locals.server.listening ? r() : app.locals.server.once('listening', r)));
82
+ try {
83
+ const call = () => fetch(`http://127.0.0.1:${port}/mcp/execute-command`, {
84
+ method: 'POST',
85
+ headers: { 'content-type': 'application/json', ...internalSignatureHeaders() },
86
+ body: JSON.stringify({ workspaceId: 'w', command: 'git --version', commandOrigin: 'platform', tool: 'current_branch' }),
87
+ });
88
+ const off = await call();
89
+ const offBody = await off.json();
90
+ assert.equal(off.status, 403);
91
+ assert.equal(offBody.ok, false);
92
+ assert.match(String(offBody.reason), /control plane|offline|policy/i);
93
+ online = true;
94
+ const on = await call();
95
+ assert.notEqual(on.status, 403, 'online: PolicyGate path unchanged');
96
+ } finally {
97
+ app.locals.server.close();
98
+ }
99
+ });
@@ -0,0 +1,88 @@
1
+ // End-to-end: boots the real agent against a fake control-plane WS server and
2
+ // inspects what actually leaves the socket (T4-10).
3
+ import { test } from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import net from 'node:net';
6
+ import os from 'node:os';
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { spawn } from 'node:child_process';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { WebSocketServer } from 'ws';
12
+
13
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
14
+ const SECRET = 'TOP_SECRET_SOURCE_MARKER_8841';
15
+
16
+ const freePort = () => new Promise((res) => { const s = net.createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => res(p)); }); });
17
+
18
+ async function run({ flag, disableTunnel = false }) {
19
+ const ws = fs.mkdtempSync(path.join(os.tmpdir(), 'wsmeta-ws-'));
20
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'wsmeta-home-'));
21
+ fs.writeFileSync(path.join(ws, 'secret.txt'), SECRET);
22
+ const wss = new WebSocketServer({ port: 0, host: '127.0.0.1' });
23
+ await new Promise((r) => wss.on('listening', r));
24
+ const frames = [];
25
+ let connections = 0;
26
+ wss.on('connection', (sock) => {
27
+ connections++;
28
+ sock.on('message', (d) => {
29
+ const t = d.toString();
30
+ if (t === 'ping') return;
31
+ frames.push(JSON.parse(t));
32
+ if (frames[frames.length - 1].type === 'register') {
33
+ sock.send(JSON.stringify({ requestId: 'req-health', command: 'health', params: {} }));
34
+ sock.send(JSON.stringify({ requestId: 'req-file', command: 'workspace.file-content', params: { path: 'secret.txt', workspacePath: ws } }));
35
+ }
36
+ });
37
+ });
38
+ const env = { ...process.env, PORT: String(await freePort()), GATEWAY_URL: `http://127.0.0.1:${wss.address().port}`,
39
+ TENANT_ID: 'tenant-test', AUTH_TOKEN: 'test-token', WORKSPACE_ROOT: ws, HOME: home,
40
+ LOCAL_AGENT_SIGNATURE_MODE: 'off', ...(flag ? { LOCAL_AGENT_WS_METADATA_ONLY: flag } : {}),
41
+ ...(disableTunnel ? { DISABLE_WS_TUNNEL: 'true' } : {}) };
42
+ delete env.LOCAL_AGENT_TOKEN;
43
+ const child = spawn(process.execPath, ['src/server.js'], { cwd: ROOT, env, stdio: 'ignore' });
44
+ try {
45
+ const deadline = Date.now() + (disableTunnel ? 4000 : 20000);
46
+ while (Date.now() < deadline) {
47
+ if (frames.some((f) => f.requestId === 'req-file') && frames.some((f) => f.requestId === 'req-health')) break;
48
+ await new Promise((r) => setTimeout(r, 100));
49
+ }
50
+ } finally {
51
+ child.kill('SIGKILL');
52
+ for (const c of wss.clients) c.terminate();
53
+ await new Promise((r) => wss.close(r));
54
+ fs.rmSync(ws, { recursive: true, force: true });
55
+ fs.rmSync(home, { recursive: true, force: true });
56
+ }
57
+ return { frames, connections };
58
+ }
59
+
60
+ test('flag off (default): legacy channel is unchanged', async () => {
61
+ const { frames } = await run({});
62
+ const reg = frames.find((f) => f.type === 'register');
63
+ assert.ok(reg && 'workspacePath' in reg, 'legacy register still carries workspacePath');
64
+ const file = frames.find((f) => f.requestId === 'req-file');
65
+ assert.equal(file.content, SECRET, 'legacy content command still works with the flag off');
66
+ });
67
+
68
+ test('enforce: nothing but allowlisted metadata leaves the socket, content never does', async () => {
69
+ const { frames } = await run({ flag: 'enforce' });
70
+ assert.ok(frames.length >= 3, 'register + 2 replies expected');
71
+ assert.equal(JSON.stringify(frames).includes(SECRET), false);
72
+ assert.equal(JSON.stringify(frames).includes('secret.txt'), false);
73
+ const reg = frames.find((f) => f.type === 'register');
74
+ assert.equal(reg.workspacePath, undefined);
75
+ assert.match(reg.workspaceHash, /^[a-f0-9]{64}$/);
76
+ assert.deepEqual(frames.find((f) => f.requestId === 'req-file'),
77
+ { requestId: 'req-file', command: 'workspace.file-content', ok: false, errorCode: 'blocked_metadata_only' });
78
+ const health = frames.find((f) => f.requestId === 'req-health');
79
+ assert.equal(health.status, 'ok');
80
+ assert.equal('workspace' in health, false);
81
+ });
82
+
83
+ test('DISABLE_WS_TUNNEL=true: no socket is opened, whatever the flag', async () => {
84
+ for (const flag of [undefined, 'enforce']) {
85
+ const { connections } = await run({ flag, disableTunnel: true });
86
+ assert.equal(connections, 0, `flag=${flag}`);
87
+ }
88
+ });