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.
- package/.github/workflows/deploy-gcp.yml +58 -26
- package/CHANGELOG.md +13 -0
- package/docs/la-protocol-v2.md +68 -0
- package/docs/ws-metadata-only.md +44 -0
- package/env.example +7 -0
- package/package.json +2 -1
- package/scripts/sync-la-protocol-v2-contract.mjs +58 -0
- package/src/la-protocol-v2/chunks.js +45 -0
- package/src/la-protocol-v2/config.js +27 -0
- package/src/la-protocol-v2/content.js +57 -0
- package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +81 -0
- package/src/la-protocol-v2/contract/README.md +103 -0
- package/src/la-protocol-v2/contract/ack.schema.json +51 -0
- package/src/la-protocol-v2/contract/alert.schema.json +62 -0
- package/src/la-protocol-v2/contract/approval.decision.schema.json +57 -0
- package/src/la-protocol-v2/contract/audit.event.schema.json +86 -0
- package/src/la-protocol-v2/contract/config.update.schema.json +73 -0
- package/src/la-protocol-v2/contract/content.request.schema.json +110 -0
- package/src/la-protocol-v2/contract/content.response.schema.json +90 -0
- package/src/la-protocol-v2/contract/defs.schema.json +191 -0
- package/src/la-protocol-v2/contract/envelope.schema.json +89 -0
- package/src/la-protocol-v2/contract/error.schema.json +59 -0
- package/src/la-protocol-v2/contract/heartbeat.schema.json +100 -0
- package/src/la-protocol-v2/contract/hello.schema.json +111 -0
- package/src/la-protocol-v2/contract/incident.candidate.schema.json +102 -0
- package/src/la-protocol-v2/contract/index.json +166 -0
- package/src/la-protocol-v2/contract/job.assign.schema.json +212 -0
- package/src/la-protocol-v2/contract/job.cancel.schema.json +52 -0
- package/src/la-protocol-v2/contract/job.event.schema.json +115 -0
- package/src/la-protocol-v2/contract/job.result.schema.json +100 -0
- package/src/la-protocol-v2/contract/log.stats.schema.json +79 -0
- package/src/la-protocol-v2/contract/ready.schema.json +106 -0
- package/src/la-protocol-v2/contract/register.schema.json +92 -0
- package/src/la-protocol-v2/contract/scan.upload.schema.json +59 -0
- package/src/la-protocol-v2/contract/scan.uploaded.schema.json +51 -0
- package/src/la-protocol-v2/contract/webhook.forward.schema.json +69 -0
- package/src/la-protocol-v2/contract.js +90 -0
- package/src/la-protocol-v2/errors.js +34 -0
- package/src/la-protocol-v2/index.js +8 -0
- package/src/la-protocol-v2/link.js +93 -0
- package/src/la-protocol-v2/runtime.js +72 -0
- package/src/la-protocol-v2/session.js +650 -0
- package/src/la-protocol-v2/strict-json.js +47 -0
- package/src/mcp-http-server.js +5 -3
- package/src/server.js +70 -9
- package/src/utils/exec-policy.js +8 -1
- package/src/ws-channel/allowlist.js +143 -0
- package/src/ws-channel/config.js +22 -0
- package/src/ws-channel/connection-state.js +18 -0
- package/src/ws-channel/index.js +6 -0
- package/src/ws-channel/metadata-guard.js +78 -0
- package/src/ws-channel/policy-offline.js +40 -0
- package/src/ws-channel/transit-guard.js +55 -0
- package/test/contract/la-protocol-v2/vectors/ack.vectors.json +149 -0
- package/test/contract/la-protocol-v2/vectors/alert.vectors.json +101 -0
- package/test/contract/la-protocol-v2/vectors/approval.decision.vectors.json +165 -0
- package/test/contract/la-protocol-v2/vectors/audit.event.vectors.json +156 -0
- package/test/contract/la-protocol-v2/vectors/config.update.vectors.json +189 -0
- package/test/contract/la-protocol-v2/vectors/content.request.vectors.json +325 -0
- package/test/contract/la-protocol-v2/vectors/content.response.vectors.json +413 -0
- package/test/contract/la-protocol-v2/vectors/cross-type.vectors.json +260 -0
- package/test/contract/la-protocol-v2/vectors/error.vectors.json +179 -0
- package/test/contract/la-protocol-v2/vectors/heartbeat.vectors.json +109 -0
- package/test/contract/la-protocol-v2/vectors/hello.vectors.json +224 -0
- package/test/contract/la-protocol-v2/vectors/incident.candidate.vectors.json +186 -0
- package/test/contract/la-protocol-v2/vectors/job.assign.vectors.json +631 -0
- package/test/contract/la-protocol-v2/vectors/job.cancel.vectors.json +133 -0
- package/test/contract/la-protocol-v2/vectors/job.event.vectors.json +233 -0
- package/test/contract/la-protocol-v2/vectors/job.result.vectors.json +137 -0
- package/test/contract/la-protocol-v2/vectors/log.stats.vectors.json +137 -0
- package/test/contract/la-protocol-v2/vectors/ready.vectors.json +177 -0
- package/test/contract/la-protocol-v2/vectors/register.vectors.json +399 -0
- package/test/contract/la-protocol-v2/vectors/scan.upload.vectors.json +137 -0
- package/test/contract/la-protocol-v2/vectors/scan.uploaded.vectors.json +101 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-agent-mismatch.json +140 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-after-last.json +200 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-byte-length-mismatch.json +174 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-last-flag.json +200 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-out-of-order.json +200 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-total-changes.json +200 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-clock-skew.json +46 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-event-before-ready.json +72 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-first-frame-not-register.json +48 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-frame-over-48kib.json +141 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-la-sends-job-assign.json +173 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-params-raw-differs-from-payload.json +172 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-payload-above-limit.json +174 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-resume-restarts-seq.json +144 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-seq-gap.json +166 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-seq-repeated.json +166 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-sig-tenant-mismatch.json +172 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-signature-warn-with-enforce-required.json +77 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-tenant-mismatch.json +140 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-type-outside-allowlist.json +140 -0
- package/test/contract/la-protocol-v2/vectors/traces/valid-content-chunks-in-order.json +224 -0
- package/test/contract/la-protocol-v2/vectors/traces/valid-handshake-and-job.json +266 -0
- package/test/contract/la-protocol-v2/vectors/traces/valid-resume-continues-seq.json +168 -0
- package/test/contract/la-protocol-v2/vectors/traces/valid-retransmit-is-idempotent.json +193 -0
- package/test/contract/la-protocol-v2/vectors/webhook.forward.vectors.json +170 -0
- package/test/la-protocol-v2/contract.test.js +131 -0
- package/test/la-protocol-v2/helpers/la-rig.js +83 -0
- package/test/la-protocol-v2/helpers/reference-cp.js +349 -0
- package/test/la-protocol-v2/helpers/spawn-agent.js +35 -0
- package/test/la-protocol-v2/link.test.js +141 -0
- package/test/la-protocol-v2/session-inbound.test.js +279 -0
- package/test/la-protocol-v2/session-outbound.test.js +382 -0
- package/test/la-protocol-v2/traces.test.js +270 -0
- package/test/la-protocol-v2/tunnel-e2e.test.js +124 -0
- package/test/ws-channel/allowlist.test.js +67 -0
- package/test/ws-channel/metadata-guard.test.js +104 -0
- package/test/ws-channel/policy-offline.test.js +99 -0
- package/test/ws-channel/tunnel-e2e.test.js +88 -0
- package/tools/graphify-validation/extras/README.md +19 -0
- package/tools/graphify-validation/extras/bymethod.py +35 -0
- package/tools/graphify-validation/extras/docker/Dockerfile +16 -0
- package/tools/graphify-validation/extras/docker/inner.sh +7 -0
- package/tools/graphify-validation/extras/g10.py +59 -0
- package/tools/graphify-validation/extras/g3aug.py +49 -0
- package/tools/graphify-validation/extras/hybrid.py +34 -0
- package/tools/graphify-validation/extras/truth2.py +44 -0
- package/tools/interop/LaV2NodeInteropTest.java +266 -0
- package/tools/interop/README.md +21 -0
- 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
|
+
}
|
package/src/mcp-http-server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|
|
6969
|
-
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
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)`);
|
package/src/utils/exec-policy.js
CHANGED
|
@@ -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(
|
|
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
|
+
}
|