deepdebug-local-agent 1.1.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/.github/workflows/npm-publish.yml +16 -9
- package/CHANGELOG.md +14 -0
- package/docs/dlp.md +78 -0
- package/docs/releasing.md +37 -43
- package/docs/ws-metadata-only.md +44 -0
- package/env.example +11 -0
- package/package.json +1 -1
- package/scripts/next-version.mjs +75 -0
- package/src/dlp/engine.js +111 -0
- package/src/dlp/index.js +137 -0
- package/src/dlp/rules.js +97 -0
- package/src/dlp/validators.js +123 -0
- package/src/mcp-http-server.js +5 -3
- package/src/server.js +49 -6
- package/src/siblings/incident-forwarder.js +3 -1
- 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/dlp/engine.test.js +173 -0
- package/test/dlp/fixtures.js +126 -0
- package/test/dlp/index.test.js +166 -0
- package/test/next-version.test.js +48 -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/src/dlp/rules.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DLP ruleset (versioned). Each rule: { id, class, re, group?, validate? }.
|
|
3
|
+
* - id: stable identifier, used as the metric label and in the placeholder.
|
|
4
|
+
* - class: coarse family, used by LOCAL_AGENT_DLP_BLOCK_CLASSES.
|
|
5
|
+
* - re: global regex (the engine resets lastIndex; never share state).
|
|
6
|
+
* - group: capture group to redact; default is the whole match.
|
|
7
|
+
* - validate: optional predicate on the redactable text; false = not a hit.
|
|
8
|
+
*
|
|
9
|
+
* Order matters: specific rules run before generic ones, so a Stripe key is
|
|
10
|
+
* reported as stripe_key and not as a generic secret assignment.
|
|
11
|
+
* Bump RULESET_VERSION on every change to the list or to a pattern.
|
|
12
|
+
*
|
|
13
|
+
* Reimplemented from public token formats; no third-party rule files copied.
|
|
14
|
+
*/
|
|
15
|
+
import {
|
|
16
|
+
looksLikeLiteralSecret, looksLikeOpaqueKey, realEmail, plausiblePhone,
|
|
17
|
+
plausibleCard, ibanValid, ptNifValid,
|
|
18
|
+
} from './validators.js';
|
|
19
|
+
|
|
20
|
+
export const RULESET_VERSION = '2026.10.28-1';
|
|
21
|
+
|
|
22
|
+
export const RULE_CLASSES = Object.freeze([
|
|
23
|
+
'private-key', 'cloud-credential', 'vcs-token', 'payment-key', 'saas-token', 'token',
|
|
24
|
+
'credential', 'connection-string', 'url-credential', 'pii-email', 'pii-phone', 'pii-id',
|
|
25
|
+
'pii-financial', 'high-entropy',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
const r = (id, cls, re, extra = {}) => Object.freeze({ id, class: cls, re, ...extra });
|
|
29
|
+
|
|
30
|
+
export const RULES = Object.freeze([
|
|
31
|
+
// Private keys first: they span lines and may contain other-looking text.
|
|
32
|
+
r('pem_private_key', 'private-key',
|
|
33
|
+
/-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?(?:-----END (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----|$)/g),
|
|
34
|
+
|
|
35
|
+
// Cloud
|
|
36
|
+
r('aws_access_key_id', 'cloud-credential', /\b(?:AKIA|ASIA|AGPA|AIDA|AROA|ANPA|ANVA|AIPA)[A-Z0-9]{16}\b/g),
|
|
37
|
+
r('aws_secret_access_key', 'cloud-credential',
|
|
38
|
+
/\baws[_-]?secret[_-]?(?:access[_-]?)?key["']?\s*[:=]\s*["']?([A-Za-z0-9/+=]{40})\b/gi, { group: 1 }),
|
|
39
|
+
r('gcp_api_key', 'cloud-credential', /\bAIza[0-9A-Za-z_-]{35}\b/g),
|
|
40
|
+
r('gcp_oauth_token', 'cloud-credential', /\bya29\.[0-9A-Za-z_-]{20,}/g),
|
|
41
|
+
r('gcp_oauth_client_secret', 'cloud-credential', /\bGOCSPX-[A-Za-z0-9_-]{20,}/g),
|
|
42
|
+
r('azure_storage_account_key', 'cloud-credential', /\bAccountKey=([A-Za-z0-9+/]{40,}={0,2})/g, { group: 1 }),
|
|
43
|
+
r('digitalocean_token', 'cloud-credential', /\bdo[opr]_v1_[a-f0-9]{64}\b/g),
|
|
44
|
+
|
|
45
|
+
// VCS / package registries
|
|
46
|
+
r('github_token', 'vcs-token', /\bgh[pousr]_[A-Za-z0-9]{36,}\b/g),
|
|
47
|
+
r('github_fine_grained_pat', 'vcs-token', /\bgithub_pat_[A-Za-z0-9_]{22,}\b/g),
|
|
48
|
+
r('gitlab_pat', 'vcs-token', /\bglpat-[A-Za-z0-9_-]{20,}/g),
|
|
49
|
+
r('npm_token', 'vcs-token', /\bnpm_[A-Za-z0-9]{36}\b/g),
|
|
50
|
+
r('pypi_token', 'vcs-token', /\bpypi-AgEIcHlwaS5vcmc[A-Za-z0-9_-]{50,}/g),
|
|
51
|
+
|
|
52
|
+
// Payments / SaaS
|
|
53
|
+
r('stripe_key', 'payment-key', /\b[sr]k_(?:live|test)_[0-9A-Za-z]{16,}\b/g),
|
|
54
|
+
r('stripe_webhook_secret', 'payment-key', /\bwhsec_[A-Za-z0-9]{16,}\b/g),
|
|
55
|
+
r('shopify_token', 'saas-token', /\bshp(?:at|ca|pa|ss)_[a-f0-9]{32}\b/g),
|
|
56
|
+
r('slack_token', 'saas-token', /\bxox[abprs]-[A-Za-z0-9-]{10,}/g),
|
|
57
|
+
r('slack_webhook', 'saas-token', /https:\/\/hooks\.slack\.com\/services\/T[A-Z0-9]+\/B[A-Z0-9]+\/[A-Za-z0-9]+/g),
|
|
58
|
+
r('discord_webhook', 'saas-token', /https:\/\/(?:discord|discordapp)\.com\/api\/webhooks\/\d+\/[\w-]+/g),
|
|
59
|
+
r('sendgrid_key', 'saas-token', /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/g),
|
|
60
|
+
r('twilio_api_key', 'saas-token', /\bSK[0-9a-f]{32}\b/g),
|
|
61
|
+
r('mailgun_key', 'saas-token', /\bkey-[0-9a-f]{32}\b/g),
|
|
62
|
+
r('huggingface_token', 'saas-token', /\bhf_[A-Za-z0-9]{34,}\b/g),
|
|
63
|
+
r('telegram_bot_token', 'saas-token', /\b\d{8,10}:AA[A-Za-z0-9_-]{33}\b/g),
|
|
64
|
+
r('anthropic_key', 'saas-token', /\bsk-ant-[A-Za-z0-9_-]{20,}/g),
|
|
65
|
+
r('openai_key', 'saas-token', /\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}/g, { validate: (v) => /\d/.test(v) }),
|
|
66
|
+
|
|
67
|
+
// Generic tokens
|
|
68
|
+
r('jwt', 'token', /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g),
|
|
69
|
+
r('bearer_token', 'token', /\bBearer\s+([A-Za-z0-9._~+/-]{20,}=*)/g, { group: 1 }),
|
|
70
|
+
r('basic_auth_header', 'token', /\bAuthorization:\s*Basic\s+([A-Za-z0-9+/]{12,}={0,2})/gi, { group: 1 }),
|
|
71
|
+
|
|
72
|
+
// Connection strings and URL credentials (before the generic assignment rule)
|
|
73
|
+
r('db_connection_string', 'connection-string',
|
|
74
|
+
/\b(?:postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|redis|rediss|amqps?|mssql|sqlserver|cockroachdb):\/\/[^\s"'`<>]+/gi,
|
|
75
|
+
{ validate: (v) => /:\/\/[^/\s]*@/.test(v) }),
|
|
76
|
+
r('jdbc_password', 'connection-string', /\bjdbc:[a-z0-9]+:[^\s"'`]*?[?&;](?:password|pwd)=([^\s&;"'`]+)/gi, { group: 1 }),
|
|
77
|
+
r('ado_connection_password', 'connection-string', /(?:^|;)\s*(?:Password|Pwd)\s*=\s*([^;\r\n"']{3,})/gim, { group: 1 }),
|
|
78
|
+
r('url_credentials', 'url-credential', /\b[a-z][a-z0-9+.-]{0,20}:\/\/([^\s:@/"'`<>]+:[^\s@/"'`<>]+)@/gi, { group: 1, validate: (v) => !/[${}<>]/.test(v) }),
|
|
79
|
+
|
|
80
|
+
// Generic "name = literal" assignments
|
|
81
|
+
r('generic_secret_assignment', 'credential',
|
|
82
|
+
/\b(?:[A-Za-z0-9_-]{0,40}?(?:password|passwd|pwd|secret|api[_-]?key|apikey|access[_-]?token|auth[_-]?token|client[_-]?secret|private[_-]?key|token))["']?\s*(?::|=|=>)\s*["']?([^\s"'`,;]{8,})/gi,
|
|
83
|
+
{ group: 1, validate: looksLikeLiteralSecret }),
|
|
84
|
+
|
|
85
|
+
// PII
|
|
86
|
+
r('email', 'pii-email', /[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9-]{1,63}(?:\.[A-Za-z0-9-]{1,63}){0,6}\.[A-Za-z]{2,24}(?![A-Za-z0-9_~./-]|:[A-Za-z0-9_~./-])/g, { validate: realEmail }),
|
|
87
|
+
r('iban', 'pii-financial', /\b[A-Z]{2}\d{2}(?: ?[A-Z0-9]{4}){2,7}(?: ?[A-Z0-9]{1,3})?\b/g, { validate: ibanValid }),
|
|
88
|
+
r('credit_card', 'pii-financial', /(?<![\d.-])\d{4}(?:[ -]?\d{4}){2}[ -]?\d{1,7}(?![\d-])/g, { validate: plausibleCard }),
|
|
89
|
+
r('pt_nif', 'pii-id',
|
|
90
|
+
/\b(?:NIF|NIPC|contribuinte|VAT)\b\s*(?:n[.ºo°]*\s*)?[:=#-]?\s*(?:PT)?(\d{9})\b/gi, { group: 1, validate: ptNifValid }),
|
|
91
|
+
r('phone_international', 'pii-phone', /(?<![\w.+])(?:\+|00)\d{1,3}(?:[ .-]?\(?\d{1,4}\)?){2,5}(?![\w])/g,
|
|
92
|
+
{ validate: plausiblePhone }),
|
|
93
|
+
|
|
94
|
+
// Last: opaque high-entropy blobs
|
|
95
|
+
r('high_entropy_blob', 'high-entropy', /(?<![A-Za-z0-9+/_=-])[A-Za-z0-9+/_=-]{40,}(?![A-Za-z0-9+/_=-])/g,
|
|
96
|
+
{ validate: looksLikeOpaqueKey }),
|
|
97
|
+
]);
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DLP validators: pure helpers that cut false positives after a regex matched.
|
|
3
|
+
* No I/O, no state.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Shannon entropy in bits per character. */
|
|
7
|
+
export function shannonEntropy(s) {
|
|
8
|
+
if (!s) return 0;
|
|
9
|
+
const freq = new Map();
|
|
10
|
+
for (const ch of s) freq.set(ch, (freq.get(ch) || 0) + 1);
|
|
11
|
+
let h = 0;
|
|
12
|
+
for (const n of freq.values()) {
|
|
13
|
+
const p = n / s.length;
|
|
14
|
+
h -= p * Math.log2(p);
|
|
15
|
+
}
|
|
16
|
+
return h;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Luhn checksum over a digit string. */
|
|
20
|
+
export function luhnValid(digits) {
|
|
21
|
+
if (!/^\d{13,19}$/.test(digits)) return false;
|
|
22
|
+
let sum = 0;
|
|
23
|
+
let alt = false;
|
|
24
|
+
for (let i = digits.length - 1; i >= 0; i--) {
|
|
25
|
+
let d = digits.charCodeAt(i) - 48;
|
|
26
|
+
if (alt) {
|
|
27
|
+
d *= 2;
|
|
28
|
+
if (d > 9) d -= 9;
|
|
29
|
+
}
|
|
30
|
+
sum += d;
|
|
31
|
+
alt = !alt;
|
|
32
|
+
}
|
|
33
|
+
return sum % 10 === 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** ISO 13616 mod-97 check for an IBAN (spaces allowed). */
|
|
37
|
+
export function ibanValid(raw) {
|
|
38
|
+
const s = raw.replace(/\s+/g, '').toUpperCase();
|
|
39
|
+
if (!/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/.test(s)) return false;
|
|
40
|
+
const rearranged = s.slice(4) + s.slice(0, 4);
|
|
41
|
+
let rem = 0;
|
|
42
|
+
for (const ch of rearranged) {
|
|
43
|
+
const v = ch >= 'A' && ch <= 'Z' ? String(ch.charCodeAt(0) - 55) : ch;
|
|
44
|
+
for (const c of v) rem = (rem * 10 + (c.charCodeAt(0) - 48)) % 97;
|
|
45
|
+
}
|
|
46
|
+
return rem === 1;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Portuguese NIF/NIPC checksum (9 digits, mod 11). */
|
|
50
|
+
export function ptNifValid(digits) {
|
|
51
|
+
if (!/^\d{9}$/.test(digits)) return false;
|
|
52
|
+
const okPrefix = '1235689'.includes(digits[0]) || /^(?:45|70|71|72|74|75|77|79)/.test(digits);
|
|
53
|
+
if (!okPrefix) return false;
|
|
54
|
+
let sum = 0;
|
|
55
|
+
for (let i = 0; i < 8; i++) sum += (digits.charCodeAt(i) - 48) * (9 - i);
|
|
56
|
+
const check = 11 - (sum % 11);
|
|
57
|
+
const expected = check >= 10 ? 0 : check;
|
|
58
|
+
return expected === digits.charCodeAt(8) - 48;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const PLACEHOLDER_PREFIX = /^(?:\$|\{|<|%|\[|\(|process\.env|os\.getenv|env\[|getenv|config\.|settings\.|this\.|self\.|args\.|params\.|req\.|request\.|\.\.\.)/i;
|
|
62
|
+
const PLACEHOLDER_WORDS = /^(?:\*+|x{3,}|null|undefined|none|true|false|string|required|optional|redacted|changeme|change_me|your[-_ ].*|example.*|placeholder.*|dummy.*|secret|password|token|apikey|api_key)$/i;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Decide whether the value assigned to a secret-looking name is a literal
|
|
66
|
+
* credential or just code (identifier, call, template, placeholder).
|
|
67
|
+
*/
|
|
68
|
+
export function looksLikeLiteralSecret(value) {
|
|
69
|
+
const v = value.replace(/^["'`]+|["'`]+$/g, '');
|
|
70
|
+
if (v.length < 8) return false;
|
|
71
|
+
if (PLACEHOLDER_PREFIX.test(v) || PLACEHOLDER_WORDS.test(v)) return false;
|
|
72
|
+
if (v.includes('REDACTED') || v.includes('***')) return false;
|
|
73
|
+
if (/[()]/.test(v)) return false; // function call / expression
|
|
74
|
+
if (/^[A-Za-z_][A-Za-z0-9_.]*$/.test(v) && !/\d/.test(v)) {
|
|
75
|
+
// plain identifier or dotted path (getPassword, config.password): code, not a secret
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
if (/^[a-z]+(?:[-_][a-z]+)+$/.test(v)) return false; // kebab/snake names (secret ids, flags), not values
|
|
79
|
+
const hasDigit = /\d/.test(v);
|
|
80
|
+
const hasSymbol = /[!@#$%^&*+/=~\-]/.test(v);
|
|
81
|
+
if (hasDigit || hasSymbol) return true;
|
|
82
|
+
return v.length >= 16 && shannonEntropy(v) >= 3.5;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** High-entropy blob heuristic (opaque keys); excludes hashes, UUIDs and paths. */
|
|
86
|
+
export function looksLikeOpaqueKey(token) {
|
|
87
|
+
if (token.length < 40) return false;
|
|
88
|
+
if (/^sha\d+-/i.test(token)) return false; // npm/SRI integrity hashes
|
|
89
|
+
if (/^[0-9a-f]+$/i.test(token)) return false; // hex digests / git SHAs
|
|
90
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(token)) return false; // UUID
|
|
91
|
+
if (token.startsWith('/') || token.includes('//')) return false; // path / URL remnants
|
|
92
|
+
// Standard base64 never has - or _, URL-safe never has /: a mix means a path or URL fragment.
|
|
93
|
+
if (token.includes('/') && /[-_]/.test(token)) return false;
|
|
94
|
+
if (!/[a-z]/.test(token) || !/[A-Z]/.test(token) || !/\d/.test(token)) return false;
|
|
95
|
+
return shannonEntropy(token) >= 4.5;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const NON_MAIL_TLDS = new Set(['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'css', 'js', 'ts', 'json', 'map', 'woff', 'woff2', 'ttf', 'eot', 'html', 'md']);
|
|
99
|
+
const DOC_DOMAINS = /(?:^|\.)(?:example\.(?:com|org|net)|test|invalid|localhost|local)$/i;
|
|
100
|
+
|
|
101
|
+
/** E-mail: reject asset names (logo@2x.png) and reserved documentation domains. */
|
|
102
|
+
export function realEmail(addr) {
|
|
103
|
+
const at = addr.lastIndexOf('@');
|
|
104
|
+
const domain = addr.slice(at + 1);
|
|
105
|
+
const tld = domain.slice(domain.lastIndexOf('.') + 1).toLowerCase();
|
|
106
|
+
if (NON_MAIL_TLDS.has(tld)) return false;
|
|
107
|
+
if (DOC_DOMAINS.test(domain)) return false;
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** International phone: leading +, 9 to 15 digits overall. */
|
|
112
|
+
export function plausiblePhone(raw) {
|
|
113
|
+
const digits = raw.replace(/\D/g, '');
|
|
114
|
+
return digits.length >= 9 && digits.length <= 15;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Card number candidates: Luhn + plausible leading digit, separators ignored. */
|
|
118
|
+
export function plausibleCard(raw) {
|
|
119
|
+
const digits = raw.replace(/[ -]/g, '');
|
|
120
|
+
if (!/^[2-6]/.test(digits)) return false;
|
|
121
|
+
if (/^(\d)\1+$/.test(digits)) return false;
|
|
122
|
+
return luhnValid(digits);
|
|
123
|
+
}
|
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)`);
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
import { logger as defaultLogger } from './logger.js';
|
|
27
27
|
import { BurstSuppressor } from './burst-suppressor.js';
|
|
28
|
+
import { protectEgress } from '../dlp/index.js';
|
|
28
29
|
|
|
29
30
|
const SIX_KEYS = ['adapter', 'errorCode', 'file', 'message', 'source', 'timestamp'];
|
|
30
31
|
|
|
@@ -293,7 +294,8 @@ export class IncidentForwarder {
|
|
|
293
294
|
return {
|
|
294
295
|
tenantId: this.tenantId || undefined,
|
|
295
296
|
service: evt.source,
|
|
296
|
-
|
|
297
|
+
// DLP (LOCAL_AGENT_DLP_MODE, default off = unchanged): log excerpts leave the customer network here.
|
|
298
|
+
message: protectEgress(evt.message, { source: 'incident-forwarder', log: (o, m) => this.logger.info(o, m) }).text,
|
|
297
299
|
severity: 'error',
|
|
298
300
|
environment: 'production',
|
|
299
301
|
detectedBy: 'local-agent-adapter',
|
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";
|