deepdebug-local-agent 1.2.0 → 1.4.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.
Files changed (123) hide show
  1. package/.github/workflows/deploy-gcp.yml +58 -26
  2. package/CHANGELOG.md +13 -0
  3. package/docs/la-protocol-v2.md +68 -0
  4. package/docs/ws-metadata-only.md +44 -0
  5. package/env.example +7 -0
  6. package/package.json +2 -1
  7. package/scripts/sync-la-protocol-v2-contract.mjs +58 -0
  8. package/src/la-protocol-v2/chunks.js +45 -0
  9. package/src/la-protocol-v2/config.js +27 -0
  10. package/src/la-protocol-v2/content.js +57 -0
  11. package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +81 -0
  12. package/src/la-protocol-v2/contract/README.md +103 -0
  13. package/src/la-protocol-v2/contract/ack.schema.json +51 -0
  14. package/src/la-protocol-v2/contract/alert.schema.json +62 -0
  15. package/src/la-protocol-v2/contract/approval.decision.schema.json +57 -0
  16. package/src/la-protocol-v2/contract/audit.event.schema.json +86 -0
  17. package/src/la-protocol-v2/contract/config.update.schema.json +73 -0
  18. package/src/la-protocol-v2/contract/content.request.schema.json +110 -0
  19. package/src/la-protocol-v2/contract/content.response.schema.json +90 -0
  20. package/src/la-protocol-v2/contract/defs.schema.json +191 -0
  21. package/src/la-protocol-v2/contract/envelope.schema.json +89 -0
  22. package/src/la-protocol-v2/contract/error.schema.json +59 -0
  23. package/src/la-protocol-v2/contract/heartbeat.schema.json +100 -0
  24. package/src/la-protocol-v2/contract/hello.schema.json +111 -0
  25. package/src/la-protocol-v2/contract/incident.candidate.schema.json +102 -0
  26. package/src/la-protocol-v2/contract/index.json +166 -0
  27. package/src/la-protocol-v2/contract/job.assign.schema.json +212 -0
  28. package/src/la-protocol-v2/contract/job.cancel.schema.json +52 -0
  29. package/src/la-protocol-v2/contract/job.event.schema.json +115 -0
  30. package/src/la-protocol-v2/contract/job.result.schema.json +100 -0
  31. package/src/la-protocol-v2/contract/log.stats.schema.json +79 -0
  32. package/src/la-protocol-v2/contract/ready.schema.json +106 -0
  33. package/src/la-protocol-v2/contract/register.schema.json +92 -0
  34. package/src/la-protocol-v2/contract/scan.upload.schema.json +59 -0
  35. package/src/la-protocol-v2/contract/scan.uploaded.schema.json +51 -0
  36. package/src/la-protocol-v2/contract/webhook.forward.schema.json +69 -0
  37. package/src/la-protocol-v2/contract.js +90 -0
  38. package/src/la-protocol-v2/errors.js +34 -0
  39. package/src/la-protocol-v2/index.js +8 -0
  40. package/src/la-protocol-v2/link.js +93 -0
  41. package/src/la-protocol-v2/runtime.js +72 -0
  42. package/src/la-protocol-v2/session.js +650 -0
  43. package/src/la-protocol-v2/strict-json.js +47 -0
  44. package/src/mcp-http-server.js +5 -3
  45. package/src/server.js +70 -9
  46. package/src/utils/exec-policy.js +8 -1
  47. package/src/ws-channel/allowlist.js +143 -0
  48. package/src/ws-channel/config.js +22 -0
  49. package/src/ws-channel/connection-state.js +18 -0
  50. package/src/ws-channel/index.js +6 -0
  51. package/src/ws-channel/metadata-guard.js +78 -0
  52. package/src/ws-channel/policy-offline.js +40 -0
  53. package/src/ws-channel/transit-guard.js +55 -0
  54. package/test/contract/la-protocol-v2/vectors/ack.vectors.json +149 -0
  55. package/test/contract/la-protocol-v2/vectors/alert.vectors.json +101 -0
  56. package/test/contract/la-protocol-v2/vectors/approval.decision.vectors.json +165 -0
  57. package/test/contract/la-protocol-v2/vectors/audit.event.vectors.json +156 -0
  58. package/test/contract/la-protocol-v2/vectors/config.update.vectors.json +189 -0
  59. package/test/contract/la-protocol-v2/vectors/content.request.vectors.json +325 -0
  60. package/test/contract/la-protocol-v2/vectors/content.response.vectors.json +413 -0
  61. package/test/contract/la-protocol-v2/vectors/cross-type.vectors.json +260 -0
  62. package/test/contract/la-protocol-v2/vectors/error.vectors.json +179 -0
  63. package/test/contract/la-protocol-v2/vectors/heartbeat.vectors.json +109 -0
  64. package/test/contract/la-protocol-v2/vectors/hello.vectors.json +224 -0
  65. package/test/contract/la-protocol-v2/vectors/incident.candidate.vectors.json +186 -0
  66. package/test/contract/la-protocol-v2/vectors/job.assign.vectors.json +631 -0
  67. package/test/contract/la-protocol-v2/vectors/job.cancel.vectors.json +133 -0
  68. package/test/contract/la-protocol-v2/vectors/job.event.vectors.json +233 -0
  69. package/test/contract/la-protocol-v2/vectors/job.result.vectors.json +137 -0
  70. package/test/contract/la-protocol-v2/vectors/log.stats.vectors.json +137 -0
  71. package/test/contract/la-protocol-v2/vectors/ready.vectors.json +177 -0
  72. package/test/contract/la-protocol-v2/vectors/register.vectors.json +399 -0
  73. package/test/contract/la-protocol-v2/vectors/scan.upload.vectors.json +137 -0
  74. package/test/contract/la-protocol-v2/vectors/scan.uploaded.vectors.json +101 -0
  75. package/test/contract/la-protocol-v2/vectors/traces/invalid-agent-mismatch.json +140 -0
  76. package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-after-last.json +200 -0
  77. package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-byte-length-mismatch.json +174 -0
  78. package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-last-flag.json +200 -0
  79. package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-out-of-order.json +200 -0
  80. package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-total-changes.json +200 -0
  81. package/test/contract/la-protocol-v2/vectors/traces/invalid-clock-skew.json +46 -0
  82. package/test/contract/la-protocol-v2/vectors/traces/invalid-event-before-ready.json +72 -0
  83. package/test/contract/la-protocol-v2/vectors/traces/invalid-first-frame-not-register.json +48 -0
  84. package/test/contract/la-protocol-v2/vectors/traces/invalid-frame-over-48kib.json +141 -0
  85. package/test/contract/la-protocol-v2/vectors/traces/invalid-la-sends-job-assign.json +173 -0
  86. package/test/contract/la-protocol-v2/vectors/traces/invalid-params-raw-differs-from-payload.json +172 -0
  87. package/test/contract/la-protocol-v2/vectors/traces/invalid-payload-above-limit.json +174 -0
  88. package/test/contract/la-protocol-v2/vectors/traces/invalid-resume-restarts-seq.json +144 -0
  89. package/test/contract/la-protocol-v2/vectors/traces/invalid-seq-gap.json +166 -0
  90. package/test/contract/la-protocol-v2/vectors/traces/invalid-seq-repeated.json +166 -0
  91. package/test/contract/la-protocol-v2/vectors/traces/invalid-sig-tenant-mismatch.json +172 -0
  92. package/test/contract/la-protocol-v2/vectors/traces/invalid-signature-warn-with-enforce-required.json +77 -0
  93. package/test/contract/la-protocol-v2/vectors/traces/invalid-tenant-mismatch.json +140 -0
  94. package/test/contract/la-protocol-v2/vectors/traces/invalid-type-outside-allowlist.json +140 -0
  95. package/test/contract/la-protocol-v2/vectors/traces/valid-content-chunks-in-order.json +224 -0
  96. package/test/contract/la-protocol-v2/vectors/traces/valid-handshake-and-job.json +266 -0
  97. package/test/contract/la-protocol-v2/vectors/traces/valid-resume-continues-seq.json +168 -0
  98. package/test/contract/la-protocol-v2/vectors/traces/valid-retransmit-is-idempotent.json +193 -0
  99. package/test/contract/la-protocol-v2/vectors/webhook.forward.vectors.json +170 -0
  100. package/test/la-protocol-v2/contract.test.js +131 -0
  101. package/test/la-protocol-v2/helpers/la-rig.js +83 -0
  102. package/test/la-protocol-v2/helpers/reference-cp.js +349 -0
  103. package/test/la-protocol-v2/helpers/spawn-agent.js +35 -0
  104. package/test/la-protocol-v2/link.test.js +141 -0
  105. package/test/la-protocol-v2/session-inbound.test.js +279 -0
  106. package/test/la-protocol-v2/session-outbound.test.js +382 -0
  107. package/test/la-protocol-v2/traces.test.js +270 -0
  108. package/test/la-protocol-v2/tunnel-e2e.test.js +124 -0
  109. package/test/ws-channel/allowlist.test.js +67 -0
  110. package/test/ws-channel/metadata-guard.test.js +104 -0
  111. package/test/ws-channel/policy-offline.test.js +99 -0
  112. package/test/ws-channel/tunnel-e2e.test.js +88 -0
  113. package/tools/graphify-validation/extras/README.md +19 -0
  114. package/tools/graphify-validation/extras/bymethod.py +35 -0
  115. package/tools/graphify-validation/extras/docker/Dockerfile +16 -0
  116. package/tools/graphify-validation/extras/docker/inner.sh +7 -0
  117. package/tools/graphify-validation/extras/g10.py +59 -0
  118. package/tools/graphify-validation/extras/g3aug.py +49 -0
  119. package/tools/graphify-validation/extras/hybrid.py +34 -0
  120. package/tools/graphify-validation/extras/truth2.py +44 -0
  121. package/tools/interop/LaV2NodeInteropTest.java +266 -0
  122. package/tools/interop/README.md +21 -0
  123. package/tools/interop/la-interop-client.mjs +97 -0
@@ -0,0 +1,47 @@
1
+ /**
2
+ * strict-json.js - JSON.parse that refuses duplicate object keys (the gateway refuses them too: with a
3
+ * duplicate, two parsers could disagree about which value a signed field has).
4
+ *
5
+ * parseStrict(text) -> value. Throws on invalid JSON or on a duplicate key at any depth.
6
+ * Implemented as a small scanner for the keys plus the native parser for the values, so the parsed
7
+ * result is exactly what JSON.parse returns.
8
+ */
9
+ export function parseStrict(text) {
10
+ const value = JSON.parse(text); // throws on invalid JSON
11
+ assertNoDuplicateKeys(text);
12
+ return value;
13
+ }
14
+
15
+ function assertNoDuplicateKeys(text) {
16
+ const stack = []; // one Set of keys per open object; null for arrays
17
+ let i = 0;
18
+ const n = text.length;
19
+ let expectKey = false;
20
+ while (i < n) {
21
+ const c = text.charCodeAt(i);
22
+ if (c === 0x22 /* " */) {
23
+ const start = i + 1;
24
+ i++;
25
+ while (i < n) {
26
+ const d = text.charCodeAt(i);
27
+ if (d === 0x5c /* \ */) { i += 2; continue; }
28
+ if (d === 0x22) break;
29
+ i++;
30
+ }
31
+ const top = stack[stack.length - 1];
32
+ if (expectKey && top) {
33
+ const key = JSON.parse(`"${text.slice(start, i)}"`);
34
+ if (top.has(key)) throw new Error("duplicate key");
35
+ top.add(key);
36
+ expectKey = false;
37
+ }
38
+ i++;
39
+ continue;
40
+ }
41
+ if (c === 0x7b /* { */) { stack.push(new Set()); expectKey = true; }
42
+ else if (c === 0x5b /* [ */) { stack.push(null); expectKey = false; }
43
+ else if (c === 0x7d || c === 0x5d) { stack.pop(); expectKey = false; }
44
+ else if (c === 0x2c /* , */) { expectKey = stack.length > 0 && stack[stack.length - 1] !== null; }
45
+ i++;
46
+ }
47
+ }
@@ -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,9 @@ 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";
22
+ import { resolveProtocolV2Config, resolveAgentId } from "./la-protocol-v2/config.js";
20
23
  import { resolveEffectiveHeaderRoot } from "./utils/root-recovery.js";
21
24
  import { classifyGrepFailure, partialSearchBody } from "./utils/search-outcome.js";
22
25
  import { classifyOpenDestination, extractOpenTenantId, isSafeToRemove } from "./utils/open-gate.js";
@@ -637,6 +640,24 @@ app.use(createSignatureMiddleware(SIGNING));
637
640
  (SIGNING.mode === "enforce" ? console.log : console.warn)(`[agent-sig] ${describeSigningConfig(SIGNING)}`);
638
641
  console.log(`[exec-policy] mode=${EXEC_POLICY.mode} (allowlist; LOCAL_AGENT_EXEC_POLICY_MODE=off|warn|enforce)`);
639
642
 
643
+ // WS METADATA-ONLY (T4-10, D17/D19): LOCAL_AGENT_WS_METADATA_ONLY=off|enforce (default off).
644
+ // enforce: the outbound WS channel carries only allowlisted metadata and PolicyGate-backed
645
+ // actions are denied while the control plane is unreachable. off = nothing changes.
646
+ const WS_METADATA = resolveWsMetadataConfig();
647
+ const WS_CONNECTION = createConnectionState();
648
+ // LOCAL_AGENT_PROTOCOL_V2 (F3-05b, default off): with it off the tunnel below is the v1 client, untouched.
649
+ const PROTOCOL_V2 = resolveProtocolV2Config();
650
+ if (WS_METADATA.mode === "enforce") {
651
+ EXEC_POLICY.offlineGuard = createOfflinePolicyGuard({
652
+ connection: WS_CONNECTION,
653
+ onDeny: ({ via }) => console.warn(`[ws-metadata] D19 DENY (${via}): control plane offline`),
654
+ });
655
+ console.log("[ws-metadata] mode=enforce (metadata-only WS channel; offline PolicyGate = DENY)");
656
+ if (process.env.DISABLE_WS_TUNNEL === "true") {
657
+ 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");
658
+ }
659
+ }
660
+
640
661
  // DEFAULT WORKSPACE - Sets the default workspace
641
662
  // Can be overridden via an environment variable or POST /workspace/open
642
663
  const DEFAULT_WORKSPACE = process.env.WORKSPACE_ROOT
@@ -6923,7 +6944,7 @@ app.post("/workspace/:workspaceId/run", async (req, res) => {
6923
6944
  // WEBSOCKET REVERSE TUNNEL
6924
6945
  // Connects Local Agent to Gateway no public URL needed
6925
6946
  // ============================================
6926
- async function startWebSocketTunnel(port, gatewayUrl, authToken, tenantId) {
6947
+ async function startWebSocketTunnel(port, gatewayUrl, authToken, tenantId, agentId = null) {
6927
6948
  if (!gatewayUrl || !tenantId) {
6928
6949
  console.log('WebSocket tunnel skipped: missing gatewayUrl or tenantId');
6929
6950
  return;
@@ -6953,6 +6974,22 @@ async function startWebSocketTunnel(port, gatewayUrl, authToken, tenantId) {
6953
6974
  let reconnectDelay = 2000;
6954
6975
  let isShuttingDown = false;
6955
6976
  let ws = null;
6977
+ // null unless LOCAL_AGENT_WS_METADATA_ONLY=enforce; with it null every send below is the legacy frame.
6978
+ const metaGuard = createWsMetadataGuard({ config: WS_METADATA });
6979
+ const sendFrame = (legacyObj, guardedFrame) => {
6980
+ const out = metaGuard ? guardedFrame : JSON.stringify(legacyObj);
6981
+ if (out) ws.send(out);
6982
+ };
6983
+ // Protocol v2 (F3-05b): only when LOCAL_AGENT_PROTOCOL_V2=true. null = the v1 client exactly as before.
6984
+ let v2Link = null;
6985
+ if (PROTOCOL_V2.enabled) {
6986
+ try {
6987
+ const { buildV2Link } = await import('./la-protocol-v2/runtime.js');
6988
+ v2Link = buildV2Link({ tenantId, agentId, signing: SIGNING, wsManager, getWorkspaceRoot: () => WORKSPACE_ROOT });
6989
+ } catch (err) {
6990
+ console.warn(`[la-v2] off: ${err.code || err.name} while loading the protocol module`);
6991
+ }
6992
+ }
6956
6993
 
6957
6994
  async function connect() {
6958
6995
  if (isShuttingDown) return;
@@ -6963,16 +7000,32 @@ async function startWebSocketTunnel(port, gatewayUrl, authToken, tenantId) {
6963
7000
  ws.on('open', () => {
6964
7001
  console.log(`WebSocket tunnel connected to Gateway`);
6965
7002
  reconnectDelay = 2000;
6966
- ws.send(JSON.stringify({ type: 'register', tenantId, port, workspacePath: WORKSPACE_ROOT || null }));
7003
+ WS_CONNECTION.markOpen();
7004
+ const legacyRegister = { type: 'register', tenantId, port, workspacePath: WORKSPACE_ROOT || null };
7005
+ const sendLegacyRegister = () => sendFrame(legacyRegister, metaGuard && metaGuard.frameMessage({
7006
+ type: 'register', tenantId, port: Number(port),
7007
+ ...(WORKSPACE_ROOT ? { workspaceHash: wsCreateHash("sha256").update(String(WORKSPACE_ROOT)).digest('hex') } : {}),
7008
+ }));
7009
+ // v2 owns the registration while it negotiates; the v1 register goes out only if the gateway does not speak v2.
7010
+ if (!(v2Link && v2Link.begin(ws, sendLegacyRegister))) sendLegacyRegister();
6967
7011
  const hb = setInterval(() => {
6968
- if (ws.readyState === WebSocket.OPEN) ws.send('ping');
6969
- else clearInterval(hb);
7012
+ if (ws.readyState === WebSocket.OPEN) {
7013
+ ws.send('ping');
7014
+ // Metadata-only: a protocol-level ping (auto-answered by any RFC 6455 server) proves
7015
+ // liveness for D19 even if the gateway never answers the text 'ping'.
7016
+ if (metaGuard) { try { ws.ping(); } catch { /* socket closing */ } }
7017
+ } else clearInterval(hb);
6970
7018
  }, 30000);
6971
7019
  });
6972
7020
 
6973
- ws.on('message', async (data) => {
7021
+ ws.on('pong', () => WS_CONNECTION.markSeen());
7022
+
7023
+ ws.on('message', async (data, isBinary) => {
7024
+ WS_CONNECTION.markSeen();
6974
7025
  const text = data.toString();
6975
7026
  if (text === 'pong') return;
7027
+ // v2 frames (schemaVersion) are handled by the protocol module; v1 commands fall through to the handler below.
7028
+ if (v2Link && v2Link.handleMessage(ws, text, isBinary)) return;
6976
7029
  let request;
6977
7030
  try { request = JSON.parse(text); } catch { return; }
6978
7031
  const { requestId, command } = request;
@@ -6982,16 +7035,24 @@ async function startWebSocketTunnel(port, gatewayUrl, authToken, tenantId) {
6982
7035
  // with the signed paramsRaw, never the unsigned params copy.
6983
7036
  const envelope = verifyWsEnvelope(request, SIGNING);
6984
7037
  if (!envelope.ok) {
6985
- ws.send(JSON.stringify({ requestId, command, ok: false, error: "invalid command signature", reason: envelope.reason }));
7038
+ sendFrame({ requestId, command, ok: false, error: "invalid command signature", reason: envelope.reason },
7039
+ metaGuard && metaGuard.frameError(requestId, command, 'invalid_signature'));
6986
7040
  return;
6987
7041
  }
7042
+ if (metaGuard) {
7043
+ // Metadata-only: legacy content commands never run; the reply is a metadata-only error.
7044
+ const admit = metaGuard.admitCommand(requestId, command);
7045
+ if (!admit.ok) { if (admit.errorFrame) ws.send(admit.errorFrame); return; }
7046
+ }
6988
7047
  let result;
6989
7048
  try { result = await handleWsCommand(command, envelope.params || {}, envelope); }
6990
7049
  catch (err) { result = { error: err.message }; }
6991
- ws.send(JSON.stringify({ requestId, command, ...result }));
7050
+ sendFrame({ requestId, command, ...result }, metaGuard && metaGuard.frameResponse(requestId, command, result));
6992
7051
  });
6993
7052
 
6994
7053
  ws.on('close', () => {
7054
+ WS_CONNECTION.markClosed();
7055
+ if (v2Link) v2Link.onClose(ws);
6995
7056
  if (!isShuttingDown) {
6996
7057
  console.log(`WS disconnected. Reconnecting in ${reconnectDelay/1000}s...`);
6997
7058
  setTimeout(connect, reconnectDelay);
@@ -8121,7 +8182,7 @@ app.listen(PORT, BIND_HOST, async () => {
8121
8182
  }
8122
8183
 
8123
8184
  if (process.env.DISABLE_WS_TUNNEL !== 'true') {
8124
- startWebSocketTunnel(PORT, gwUrl, wsToken, tenantId);
8185
+ startWebSocketTunnel(PORT, gwUrl, wsToken, tenantId, resolveAgentId(process.env, cfg));
8125
8186
  }
8126
8187
 
8127
8188
  // Sibling plane (started here so the frozen monolith is untouched): metrics
@@ -8151,7 +8212,7 @@ app.listen(PORT, BIND_HOST, async () => {
8151
8212
 
8152
8213
  // Start MCP HTTP Bridge em paralelo (port 5056)
8153
8214
  try {
8154
- startMCPHttpServer(wsManager, parseInt(MCP_PORT));
8215
+ startMCPHttpServer(wsManager, parseInt(MCP_PORT), EXEC_POLICY);
8155
8216
  } catch (err) {
8156
8217
  console.warn(`MCP HTTP Server failed to start: ${err.message}`);
8157
8218
  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
+ }