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 +7 -0
- package/docs/ws-metadata-only.md +44 -0
- package/env.example +7 -0
- package/package.json +1 -1
- package/src/mcp-http-server.js +5 -3
- package/src/server.js +49 -6
- 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/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/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
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,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
|
-
|
|
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)
|
|
6969
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)`);
|
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
|
+
}
|
|
@@ -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
|
+
});
|