deepdebug-local-agent 1.0.23 → 1.2.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/.claude/context.md +76 -0
- package/.claude/skills/README.md +25 -0
- package/.claude/skills/tdd-debugging/SKILL.md +53 -0
- package/.claude/skills/tdd-debugging/references/anti-patterns.md +43 -0
- package/.claude/skills/tdd-debugging/references/command-reference.md +46 -0
- package/.claude/standards/README.md +51 -0
- package/.claude/standards/clean-code.md +75 -0
- package/.claude/standards/code-review-checklist.md +61 -0
- package/.claude/standards/layered-architecture.md +163 -0
- package/.claude/standards/node.md +45 -0
- package/.claude/standards/security-owasp.md +114 -0
- package/.claude/standards/simplicity.md +42 -0
- package/.claude/standards/solid.md +64 -0
- package/.claude/standards/testing.md +59 -0
- package/.cursor/rules/standards.mdc +17 -0
- package/.github/workflows/build-byoc-image.yml +315 -0
- package/.github/workflows/deploy-gcp.yml +102 -0
- package/.github/workflows/npm-publish.yml +76 -8
- package/AGENTS.md +21 -0
- package/CHANGELOG.md +76 -0
- package/CLAUDE.md +13 -0
- package/CLAUDE_CODE_AGENT_INFINITE.md +5 -3
- package/Dockerfile +4 -0
- package/Dockerfile.full +55 -3
- package/bin/install.js +16 -5
- package/cloudbuild-full.yaml +57 -0
- package/docker-compose.yml +5 -3
- package/docs/OBSERVABILITY.md +164 -0
- package/docs/byoc-agent-image.md +70 -0
- package/docs/dlp.md +78 -0
- package/docs/graphify-packaging-plan.md +133 -0
- package/docs/graphify-validation-run-2026-10-28.md +243 -0
- package/docs/releasing.md +37 -0
- package/docs/signed-commands.md +163 -0
- package/env.example +34 -0
- package/helm/Chart.yaml +2 -2
- package/helm/templates/deployment.yaml +13 -3
- package/helm/templates/networkpolicy.yaml +22 -0
- package/helm/templates/secret.yaml +1 -0
- package/helm/values.yaml +13 -14
- package/mcp-server.js +4 -1
- package/package.json +9 -4
- package/scripts/next-version.mjs +75 -0
- package/scripts/upgrade-observability-push.sh +227 -0
- package/src/ai-engine.js +22 -22
- package/src/config/trusted-signing-keys.json +3 -0
- package/src/detectors/build-tool-detector.js +50 -0
- package/src/detectors/multi-project-detector.js +22 -22
- package/src/detectors/service-detector.js +24 -24
- 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/exec-utils.js +26 -4
- package/src/git/base-git-provider.js +117 -117
- package/src/git/bitbucket-provider.js +2 -2
- package/src/git/git-provider-registry.js +18 -18
- package/src/git/github-provider.js +13 -13
- package/src/git/multi-repo-pr.js +161 -0
- package/src/mcp-http-server.js +32 -16
- package/src/observability/bundle-files.js +418 -0
- package/src/observability/detect.js +151 -0
- package/src/observability/provision.js +212 -0
- package/src/observability/render-cli.js +59 -0
- package/src/observability/routes.js +88 -0
- package/src/patch/patch-engine.js +28 -28
- package/src/patch-manager.js +85 -85
- package/src/patch_bkp.js +14 -14
- package/src/ports.js +4 -4
- package/src/routes/workspace.route.js +47 -16
- package/src/runtimes/base-runtime.js +75 -75
- package/src/runtimes/java/gradle-runtime.js +17 -17
- package/src/runtimes/java/java-integrations.js +25 -25
- package/src/runtimes/java/maven-runtime.js +34 -34
- package/src/runtimes/node/node-integrations.js +1 -1
- package/src/runtimes/node/npm-runtime.js +14 -14
- package/src/runtimes/node/yarn-runtime.js +4 -4
- package/src/runtimes/runtime-registry.js +25 -25
- package/src/server-local.js +4 -4
- package/src/server.js +1366 -333
- package/src/siblings/burst-suppressor.js +89 -0
- package/src/siblings/capabilities.js +273 -0
- package/src/siblings/env-compat.cjs +58 -0
- package/src/siblings/incident-forwarder.js +322 -0
- package/src/siblings/logger.js +15 -0
- package/src/siblings/metrics.js +94 -0
- package/src/utils/agent-auth.js +184 -0
- package/src/utils/approval-token.js +56 -0
- package/src/utils/bind-host.js +29 -0
- package/src/utils/branch-refspec.js +98 -0
- package/src/utils/child-env.js +34 -0
- package/src/utils/environment-diagnostics.js +55 -55
- package/src/utils/exec-policy.js +962 -0
- package/src/utils/exec-utils.js +15 -15
- package/src/utils/file-count.js +46 -0
- package/src/utils/fs-utils.js +10 -10
- package/src/utils/multi-repo-open.js +132 -0
- package/src/utils/open-gate.js +183 -0
- package/src/utils/ranged-read.js +65 -0
- package/src/utils/request-guards.js +77 -0
- package/src/utils/request-signing.js +342 -0
- package/src/utils/root-recovery.js +244 -0
- package/src/utils/safe-exec-args.js +58 -0
- package/src/utils/sandbox.js +182 -0
- package/src/utils/search-outcome.js +56 -0
- package/src/utils/workspace-root.js +80 -0
- package/src/validators/syntax-validator.js +149 -0
- package/src/workspace/detect-port.js +16 -16
- package/src/workspace/file-reader.js +1 -1
- package/src/workspace/process-manager.js +46 -46
- package/src/workspace-manager.js +86 -24
- package/test/adapters/registry.test.js +9 -2
- package/test/agent-auth.test.js +194 -0
- package/test/bind-host.test.js +26 -0
- package/test/branch-refspec.test.js +224 -0
- package/test/burst-suppressor.test.js +60 -0
- package/test/capabilities-access.test.js +47 -0
- package/test/capabilities.test.js +87 -0
- package/test/child-env.test.js +23 -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/env-compat.test.js +71 -0
- package/test/exec-policy.test.js +463 -0
- package/test/file-count.test.js +64 -0
- package/test/incident-forwarder.test.js +231 -0
- package/test/multi-repo-open.test.js +180 -0
- package/test/multi-repo-pr.test.js +129 -0
- package/test/next-version.test.js +48 -0
- package/test/observability-detect.test.js +199 -0
- package/test/open-gate.test.js +341 -0
- package/test/ranged-read.test.js +60 -0
- package/test/request-guards.test.js +91 -0
- package/test/request-signing.test.js +284 -0
- package/test/root-recovery-sidecar.test.js +171 -0
- package/test/root-recovery.test.js +274 -0
- package/test/safe-exec-args.test.js +58 -0
- package/test/sandbox.test.js +98 -0
- package/test/search-outcome.test.js +50 -0
- package/test/workspace-root.test.js +64 -0
- package/tools/graphify-validation/README.md +92 -0
- package/tools/graphify-validation/config/questions.json +31 -0
- package/tools/graphify-validation/config/repos.json +9 -0
- package/tools/graphify-validation/config/symbols.json +6 -0
- package/tools/graphify-validation/fixtures/spring/expected.json +34 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/AppConfig.java +29 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/AuditLogger.java +15 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/Cache.java +7 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/CheckoutService.java +25 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/InMemoryCache.java +18 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/OrderController.java +22 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/PaymentGateway.java +5 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/PaypalGateway.java +11 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/RefundService.java +14 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/StripeGateway.java +11 -0
- package/tools/graphify-validation/lib/affected_vs_rg.py +81 -0
- package/tools/graphify-validation/lib/audit_hook/sitecustomize.py +22 -0
- package/tools/graphify-validation/lib/graph_metrics.py +96 -0
- package/tools/graphify-validation/lib/gv_common.py +130 -0
- package/tools/graphify-validation/lib/licenses.py +47 -0
- package/tools/graphify-validation/lib/pip_audit_summary.py +18 -0
- package/tools/graphify-validation/lib/recall.py +115 -0
- package/tools/graphify-validation/lib/report.py +226 -0
- package/tools/graphify-validation/lib/run_all.py +237 -0
- package/tools/graphify-validation/lib/run_graphify.py +144 -0
- package/tools/graphify-validation/lib/rusage_helper.py +17 -0
- package/tools/graphify-validation/lib/sample_edges.py +246 -0
- package/tools/graphify-validation/lib/spring_fixture.py +81 -0
- package/tools/graphify-validation/requirements-py311.lock +508 -0
- package/tools/graphify-validation/requirements-tools.in +1 -0
- package/tools/graphify-validation/requirements-tools.lock +371 -0
- package/tools/graphify-validation/requirements.in +1 -0
- package/tools/graphify-validation/requirements.lock +502 -0
- package/tools/graphify-validation/run.sh +43 -0
- package/tools/graphify-validation/tests/test_harness.py +151 -0
- package/cloudbuild-agent-qa.yaml +0 -43
- package/cloudbuild-agent-staging.yaml +0 -40
- package/cloudbuild.yaml.deprecated +0 -42
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* request-signing.js — Ed25519 verification of commands sent by the platform.
|
|
3
|
+
*
|
|
4
|
+
* WHY. X-Agent-Token proves "someone holding the per-workspace secret"; it does
|
|
5
|
+
* not prove the request came from the VisionHammer platform, is fresh, or was
|
|
6
|
+
* aimed at THIS tenant's agent. Every command the gateway / agents send is now
|
|
7
|
+
* signed with the platform private key (Secret Manager `local-agent-signing-key`)
|
|
8
|
+
* and the agent verifies it with the trusted public key(s) it was provisioned
|
|
9
|
+
* with. X-Agent-Token stays in place as a second factor (agent-auth.js).
|
|
10
|
+
*
|
|
11
|
+
* PROTOCOL v1 (HTTP). Headers:
|
|
12
|
+
* X-VH-Key-Id id of the signing key (selects the trusted public key)
|
|
13
|
+
* X-VH-Timestamp unix epoch SECONDS
|
|
14
|
+
* X-VH-Nonce 16..128 chars [A-Za-z0-9_-], unique per request
|
|
15
|
+
* X-VH-Tenant-Id tenant the platform acts for ('' when unknown)
|
|
16
|
+
* X-VH-Workspace-Id workspace the platform acts for ('' when unknown)
|
|
17
|
+
* X-VH-Signature base64 Ed25519 signature over the canonical string:
|
|
18
|
+
*
|
|
19
|
+
* VH-AGENT-SIG-V1\n<METHOD>\n<raw path?query>\n<sha256hex(body bytes)>\n
|
|
20
|
+
* <tenantId>\n<workspaceId>\n<timestamp>\n<nonce>
|
|
21
|
+
*
|
|
22
|
+
* WS tunnel envelope: { requestId, command, params, paramsRaw, sig: { keyId,
|
|
23
|
+
* timestamp, nonce, tenantId, workspaceId, signature } } with METHOD = "WS",
|
|
24
|
+
* path = command and body = the exact UTF-8 bytes of `paramsRaw`. A verified
|
|
25
|
+
* envelope executes JSON.parse(paramsRaw) — never the unsigned `params` copy.
|
|
26
|
+
*
|
|
27
|
+
* REJECT: unknown key id, bad signature, |now - timestamp| > 60s, a nonce seen
|
|
28
|
+
* before (in-memory LRU, TTL 2x skew), tenant / workspace different from the
|
|
29
|
+
* agent's configured identity.
|
|
30
|
+
*
|
|
31
|
+
* MODES (LOCAL_AGENT_SIGNATURE_MODE): off | warn | enforce (default; an unset
|
|
32
|
+
* or unrecognised value is enforce, so a typo never fails open).
|
|
33
|
+
* warn verify + log, never reject — explicit opt-out for agents whose
|
|
34
|
+
* platform side does not sign yet.
|
|
35
|
+
* enforce reject (401) anything that does not verify.
|
|
36
|
+
*/
|
|
37
|
+
import crypto from "node:crypto";
|
|
38
|
+
import fs from "node:fs";
|
|
39
|
+
import path from "node:path";
|
|
40
|
+
import { fileURLToPath } from "node:url";
|
|
41
|
+
|
|
42
|
+
export const SIG_PREFIX = "VH-AGENT-SIG-V1";
|
|
43
|
+
export const HEADERS = Object.freeze({
|
|
44
|
+
keyId: "x-vh-key-id",
|
|
45
|
+
timestamp: "x-vh-timestamp",
|
|
46
|
+
nonce: "x-vh-nonce",
|
|
47
|
+
tenantId: "x-vh-tenant-id",
|
|
48
|
+
workspaceId: "x-vh-workspace-id",
|
|
49
|
+
signature: "x-vh-signature",
|
|
50
|
+
internal: "x-vh-internal",
|
|
51
|
+
});
|
|
52
|
+
export const MAX_SKEW_SECONDS = 60;
|
|
53
|
+
const NONCE_RE = /^[A-Za-z0-9_-]{16,128}$/;
|
|
54
|
+
const PUBLIC_PATHS = new Set(["/health", "/metrics"]);
|
|
55
|
+
// SPKI DER prefix of an Ed25519 public key (RFC 8410): a raw 32-byte key is
|
|
56
|
+
// accepted too and wrapped with it.
|
|
57
|
+
const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
|
58
|
+
|
|
59
|
+
export const MODES = Object.freeze(["off", "warn", "enforce"]);
|
|
60
|
+
|
|
61
|
+
export function resolveMode(raw, fallback = "enforce") {
|
|
62
|
+
const m = String(raw || "").trim().toLowerCase();
|
|
63
|
+
return MODES.includes(m) ? m : fallback;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function sha256Hex(buf) {
|
|
67
|
+
return crypto.createHash("sha256").update(buf ?? Buffer.alloc(0)).digest("hex");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function canonicalString({ method, pathAndQuery, bodySha256, tenantId, workspaceId, timestamp, nonce }) {
|
|
71
|
+
return [SIG_PREFIX, String(method || "").toUpperCase(), pathAndQuery || "/", bodySha256,
|
|
72
|
+
tenantId || "", workspaceId || "", String(timestamp), nonce].join("\n");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Parses one public key: PEM (SPKI), base64 SPKI DER (44 bytes) or base64 /
|
|
77
|
+
* base64url raw 32-byte key. Returns a KeyObject or throws.
|
|
78
|
+
*/
|
|
79
|
+
export function parsePublicKey(material) {
|
|
80
|
+
const s = String(material || "").trim();
|
|
81
|
+
if (!s) throw new Error("empty public key");
|
|
82
|
+
if (s.includes("BEGIN PUBLIC KEY")) {
|
|
83
|
+
const k = crypto.createPublicKey(s);
|
|
84
|
+
if (k.asymmetricKeyType !== "ed25519") throw new Error("public key is not Ed25519");
|
|
85
|
+
return k;
|
|
86
|
+
}
|
|
87
|
+
const der = Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/"), "base64");
|
|
88
|
+
const spki = der.length === 32 ? Buffer.concat([ED25519_SPKI_PREFIX, der]) : der;
|
|
89
|
+
const k = crypto.createPublicKey({ key: spki, format: "der", type: "spki" });
|
|
90
|
+
if (k.asymmetricKeyType !== "ed25519") throw new Error("public key is not Ed25519");
|
|
91
|
+
return k;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Trusted keys: LOCAL_AGENT_SIGNING_PUBLIC_KEYS="kid1=<b64>,kid2=<b64>" (the
|
|
96
|
+
* BYOC provisioning env) plus the image default file (JSON
|
|
97
|
+
* [{ "keyId": "...", "publicKey": "..." }]) at LOCAL_AGENT_SIGNING_KEYS_FILE or
|
|
98
|
+
* src/config/trusted-signing-keys.json. Several keys = rotation window.
|
|
99
|
+
* Returns { keys: Map<kid, KeyObject>, errors: string[] }.
|
|
100
|
+
*/
|
|
101
|
+
export function loadTrustedKeys(env = process.env, { defaultFile = defaultKeysFile() } = {}) {
|
|
102
|
+
const keys = new Map();
|
|
103
|
+
const errors = [];
|
|
104
|
+
const add = (kid, material, src) => {
|
|
105
|
+
const id = String(kid || "").trim();
|
|
106
|
+
if (!/^[A-Za-z0-9._:-]{1,64}$/.test(id)) { errors.push(`${src}: invalid key id`); return; }
|
|
107
|
+
try { keys.set(id, parsePublicKey(material)); }
|
|
108
|
+
catch (e) { errors.push(`${src}: key ${id}: ${e.message}`); }
|
|
109
|
+
};
|
|
110
|
+
const file = env.LOCAL_AGENT_SIGNING_KEYS_FILE || defaultFile;
|
|
111
|
+
if (file) {
|
|
112
|
+
try {
|
|
113
|
+
if (fs.existsSync(file)) {
|
|
114
|
+
const list = JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
115
|
+
for (const e of Array.isArray(list) ? list : (list.keys || [])) add(e.keyId, e.publicKey, path.basename(file));
|
|
116
|
+
}
|
|
117
|
+
} catch (e) { errors.push(`${path.basename(file)}: ${e.message}`); }
|
|
118
|
+
}
|
|
119
|
+
for (const entry of String(env.LOCAL_AGENT_SIGNING_PUBLIC_KEYS || "").split(/[,\s]+/).filter(Boolean)) {
|
|
120
|
+
const i = entry.indexOf("=");
|
|
121
|
+
if (i <= 0) { errors.push("LOCAL_AGENT_SIGNING_PUBLIC_KEYS: entries must be keyId=publicKey"); continue; }
|
|
122
|
+
add(entry.slice(0, i), entry.slice(i + 1), "LOCAL_AGENT_SIGNING_PUBLIC_KEYS");
|
|
123
|
+
}
|
|
124
|
+
return { keys, errors };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function defaultKeysFile() {
|
|
128
|
+
try {
|
|
129
|
+
return path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "config", "trusted-signing-keys.json");
|
|
130
|
+
} catch {
|
|
131
|
+
return null; // bundled (cjs) build: env only
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Nonce replay cache: Map in insertion order = LRU by first sight, bounded. */
|
|
136
|
+
export class NonceCache {
|
|
137
|
+
constructor({ ttlMs = 2 * MAX_SKEW_SECONDS * 1000, max = 100_000, now = () => Date.now() } = {}) {
|
|
138
|
+
this.ttlMs = ttlMs;
|
|
139
|
+
this.max = max;
|
|
140
|
+
this.now = now;
|
|
141
|
+
this.map = new Map();
|
|
142
|
+
}
|
|
143
|
+
/** true when first seen (and records it); false on replay. */
|
|
144
|
+
checkAndRemember(nonce) {
|
|
145
|
+
const t = this.now();
|
|
146
|
+
this.#sweep(t);
|
|
147
|
+
const exp = this.map.get(nonce);
|
|
148
|
+
if (exp !== undefined && exp > t) return false;
|
|
149
|
+
this.map.delete(nonce);
|
|
150
|
+
this.map.set(nonce, t + this.ttlMs);
|
|
151
|
+
while (this.map.size > this.max) this.map.delete(this.map.keys().next().value);
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
#sweep(t) {
|
|
155
|
+
for (const [k, exp] of this.map) {
|
|
156
|
+
if (exp > t) break; // insertion order ~ expiry order
|
|
157
|
+
this.map.delete(k);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
get size() { return this.map.size; }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Identity this agent serves; blank = shared operator agent (any tenant). */
|
|
164
|
+
export function resolveIdentity(env = process.env, fileCfg = {}) {
|
|
165
|
+
return {
|
|
166
|
+
tenantId: String(env.LOCAL_AGENT_TENANT_ID || env.TENANT_ID || fileCfg.tenantId || "").trim(),
|
|
167
|
+
workspaceId: String(env.LOCAL_AGENT_WORKSPACE_ID || env.WORKSPACE_ID || "").trim(),
|
|
168
|
+
requireWorkspace: String(env.LOCAL_AGENT_REQUIRE_WORKSPACE || "").toLowerCase() === "true",
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function resolveSigningConfig(env = process.env, opts = {}) {
|
|
173
|
+
const { keys, errors } = loadTrustedKeys(env, opts);
|
|
174
|
+
return {
|
|
175
|
+
mode: resolveMode(env.LOCAL_AGENT_SIGNATURE_MODE),
|
|
176
|
+
keys,
|
|
177
|
+
keyErrors: errors,
|
|
178
|
+
identity: resolveIdentity(env, opts.fileCfg || {}),
|
|
179
|
+
maxSkewSeconds: MAX_SKEW_SECONDS,
|
|
180
|
+
nonces: opts.nonces || new NonceCache(),
|
|
181
|
+
now: opts.now || (() => Date.now()),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Pure verification. `sig` = { keyId, timestamp, nonce, tenantId, workspaceId,
|
|
187
|
+
* signature }. Returns { ok: true, tenantId, workspaceId, keyId } or
|
|
188
|
+
* { ok: false, reason }. The nonce is only consumed when everything else passed.
|
|
189
|
+
*/
|
|
190
|
+
export function verifySignature({ method, pathAndQuery, body, sig }, cfg) {
|
|
191
|
+
if (!sig || !sig.signature) return { ok: false, reason: "unsigned request" };
|
|
192
|
+
const { keyId, nonce } = sig;
|
|
193
|
+
const tenantId = sig.tenantId || "";
|
|
194
|
+
const workspaceId = sig.workspaceId || "";
|
|
195
|
+
if (!cfg.keys || cfg.keys.size === 0) return { ok: false, reason: "no trusted signing keys configured" };
|
|
196
|
+
const key = cfg.keys.get(String(keyId || ""));
|
|
197
|
+
if (!key) return { ok: false, reason: `unknown signing key id '${String(keyId || "").slice(0, 64)}'` };
|
|
198
|
+
const ts = Number(sig.timestamp);
|
|
199
|
+
if (!/^\d{9,11}$/.test(String(sig.timestamp || "")) || !Number.isFinite(ts)) return { ok: false, reason: "invalid timestamp" };
|
|
200
|
+
const skew = Math.abs(Math.floor(cfg.now() / 1000) - ts);
|
|
201
|
+
if (skew > cfg.maxSkewSeconds) return { ok: false, reason: `timestamp skew ${skew}s exceeds ${cfg.maxSkewSeconds}s` };
|
|
202
|
+
if (!NONCE_RE.test(String(nonce || ""))) return { ok: false, reason: "invalid nonce" };
|
|
203
|
+
const data = Buffer.from(canonicalString({
|
|
204
|
+
method, pathAndQuery, bodySha256: sha256Hex(body), tenantId, workspaceId, timestamp: sig.timestamp, nonce,
|
|
205
|
+
}), "utf-8");
|
|
206
|
+
let good = false;
|
|
207
|
+
try {
|
|
208
|
+
good = crypto.verify(null, data, key, Buffer.from(String(sig.signature), "base64"));
|
|
209
|
+
} catch { good = false; }
|
|
210
|
+
if (!good) return { ok: false, reason: "bad signature" };
|
|
211
|
+
const id = cfg.identity || {};
|
|
212
|
+
if (id.tenantId && tenantId !== id.tenantId) return { ok: false, reason: "tenant mismatch with the agent's configured identity" };
|
|
213
|
+
if (id.workspaceId && (workspaceId || id.requireWorkspace) && workspaceId !== id.workspaceId) {
|
|
214
|
+
return { ok: false, reason: "workspace mismatch with the agent's configured identity" };
|
|
215
|
+
}
|
|
216
|
+
if (!cfg.nonces.checkAndRemember(`${keyId}:${nonce}`)) return { ok: false, reason: "replayed nonce" };
|
|
217
|
+
return { ok: true, tenantId, workspaceId, keyId };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function sigFromHeaders(headers = {}) {
|
|
221
|
+
if (!headers[HEADERS.signature]) return null;
|
|
222
|
+
return {
|
|
223
|
+
keyId: headers[HEADERS.keyId],
|
|
224
|
+
timestamp: headers[HEADERS.timestamp],
|
|
225
|
+
nonce: headers[HEADERS.nonce],
|
|
226
|
+
tenantId: headers[HEADERS.tenantId] || "",
|
|
227
|
+
workspaceId: headers[HEADERS.workspaceId] || "",
|
|
228
|
+
signature: headers[HEADERS.signature],
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Per-process secret for the agent's own loopback calls (WS tunnel -> HTTP). */
|
|
233
|
+
const INTERNAL_TOKEN = crypto.randomBytes(32).toString("base64url");
|
|
234
|
+
export function internalSignatureHeaders() {
|
|
235
|
+
return { "X-VH-Internal": INTERNAL_TOKEN };
|
|
236
|
+
}
|
|
237
|
+
function isInternal(headers, remoteAddress) {
|
|
238
|
+
const v = headers[HEADERS.internal];
|
|
239
|
+
if (typeof v !== "string" || v.length !== INTERNAL_TOKEN.length) return false;
|
|
240
|
+
const a = String(remoteAddress || "").replace(/^::ffff:/, "");
|
|
241
|
+
if (!(a === "::1" || /^127\./.test(a))) return false;
|
|
242
|
+
return crypto.timingSafeEqual(Buffer.from(v), Buffer.from(INTERNAL_TOKEN));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Rate-limited warn log: one line per (reason, path) per minute. */
|
|
246
|
+
const lastLog = new Map();
|
|
247
|
+
export function logDecision(level, key, msg, now = Date.now()) {
|
|
248
|
+
const last = lastLog.get(key) || 0;
|
|
249
|
+
if (now - last < 60_000) return;
|
|
250
|
+
lastLog.set(key, now);
|
|
251
|
+
if (lastLog.size > 1000) lastLog.clear();
|
|
252
|
+
(level === "error" ? console.error : console.warn)(msg);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export const stats = { verified: 0, failed: 0, rejected: 0, unsigned: 0 };
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Express middleware. Mount AFTER the JSON body parser (which must keep the
|
|
259
|
+
* raw bytes in req.rawBody via `verify`). Sets req.vhSignature = { verified,
|
|
260
|
+
* tenantId, workspaceId, reason }.
|
|
261
|
+
*/
|
|
262
|
+
export function createSignatureMiddleware(cfg = resolveSigningConfig()) {
|
|
263
|
+
return function agentSignature(req, res, next) {
|
|
264
|
+
const method = (req.method || "GET").toUpperCase();
|
|
265
|
+
if (method === "OPTIONS" || ((method === "GET" || method === "HEAD") && PUBLIC_PATHS.has(req.path))) return next();
|
|
266
|
+
if (cfg.mode === "off") { req.vhSignature = { verified: false, reason: "signature mode off" }; return next(); }
|
|
267
|
+
if (isInternal(req.headers, req.socket?.remoteAddress)) {
|
|
268
|
+
req.vhSignature = { verified: true, internal: true, tenantId: req.headers[HEADERS.tenantId] || "", workspaceId: req.headers[HEADERS.workspaceId] || "" };
|
|
269
|
+
return next();
|
|
270
|
+
}
|
|
271
|
+
const sig = sigFromHeaders(req.headers);
|
|
272
|
+
const out = verifySignature({ method, pathAndQuery: req.originalUrl || req.url, body: req.rawBody, sig }, cfg);
|
|
273
|
+
if (out.ok) {
|
|
274
|
+
stats.verified++;
|
|
275
|
+
req.vhSignature = { verified: true, tenantId: out.tenantId, workspaceId: out.workspaceId, keyId: out.keyId };
|
|
276
|
+
return next();
|
|
277
|
+
}
|
|
278
|
+
if (!sig) stats.unsigned++; else stats.failed++;
|
|
279
|
+
req.vhSignature = { verified: false, reason: out.reason };
|
|
280
|
+
if (cfg.mode === "enforce") {
|
|
281
|
+
stats.rejected++;
|
|
282
|
+
logDecision("warn", `rej:${out.reason}:${req.path}`, `[agent-sig] REJECTED ${method} ${req.path}: ${out.reason}`);
|
|
283
|
+
return res.status(401).json({ ok: false, error: "invalid command signature", reason: out.reason });
|
|
284
|
+
}
|
|
285
|
+
logDecision("warn", `warn:${out.reason}:${req.path}`, `[agent-sig] WARN (would reject in enforce) ${method} ${req.path}: ${out.reason}`);
|
|
286
|
+
return next();
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Verifies a WS tunnel envelope. Returns { ok, params, reason, verified }:
|
|
292
|
+
* `params` is what the command must run with (the signed paramsRaw when the
|
|
293
|
+
* envelope verified; the legacy object otherwise, in warn/off).
|
|
294
|
+
*/
|
|
295
|
+
export function verifyWsEnvelope(envelope, cfg) {
|
|
296
|
+
const legacy = envelope.params || {};
|
|
297
|
+
if (cfg.mode === "off") return { ok: true, verified: false, params: legacy };
|
|
298
|
+
const raw = typeof envelope.paramsRaw === "string" ? envelope.paramsRaw : null;
|
|
299
|
+
const out = raw == null
|
|
300
|
+
? { ok: false, reason: "unsigned request" }
|
|
301
|
+
: verifySignature({ method: "WS", pathAndQuery: String(envelope.command || ""), body: Buffer.from(raw, "utf-8"), sig: envelope.sig }, cfg);
|
|
302
|
+
if (out.ok) {
|
|
303
|
+
let params;
|
|
304
|
+
try { params = JSON.parse(raw); } catch { return { ok: false, verified: false, reason: "paramsRaw is not JSON", params: {} }; }
|
|
305
|
+
stats.verified++;
|
|
306
|
+
return { ok: true, verified: true, params: params || {}, tenantId: out.tenantId, workspaceId: out.workspaceId };
|
|
307
|
+
}
|
|
308
|
+
if (!envelope.sig) stats.unsigned++; else stats.failed++;
|
|
309
|
+
if (cfg.mode === "enforce") {
|
|
310
|
+
stats.rejected++;
|
|
311
|
+
logDecision("warn", `wsrej:${out.reason}:${envelope.command}`, `[agent-sig] REJECTED WS ${envelope.command}: ${out.reason}`);
|
|
312
|
+
return { ok: false, verified: false, reason: out.reason, params: {} };
|
|
313
|
+
}
|
|
314
|
+
logDecision("warn", `wswarn:${out.reason}:${envelope.command}`, `[agent-sig] WARN (would reject in enforce) WS ${envelope.command}: ${out.reason}`);
|
|
315
|
+
return { ok: true, verified: false, reason: out.reason, params: legacy };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function describeSigningConfig(cfg) {
|
|
319
|
+
const ids = [...cfg.keys.keys()].join(",") || "none";
|
|
320
|
+
const who = cfg.identity.tenantId ? `tenant=${cfg.identity.tenantId}` : "any tenant (shared agent)";
|
|
321
|
+
return `mode=${cfg.mode}, trusted keys=[${ids}], identity=${who}`
|
|
322
|
+
+ (cfg.keyErrors.length ? `, key errors: ${cfg.keyErrors.join("; ")}` : "");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** body-parser `verify` hook: keeps the exact bytes the signature covers. */
|
|
326
|
+
export function captureRawBody(req, _res, buf) {
|
|
327
|
+
req.rawBody = Buffer.from(buf);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Process-wide signing config (one nonce cache for the 5055 API, the MCP bridge and the WS tunnel). */
|
|
331
|
+
let shared = null;
|
|
332
|
+
export function getSigningConfig() {
|
|
333
|
+
if (!shared) {
|
|
334
|
+
let fileCfg = {};
|
|
335
|
+
try {
|
|
336
|
+
const home = process.env.HOME || process.env.USERPROFILE;
|
|
337
|
+
if (home) fileCfg = JSON.parse(fs.readFileSync(path.join(home, ".deepdebug", "config.json"), "utf-8"));
|
|
338
|
+
} catch { /* no local config */ }
|
|
339
|
+
shared = resolveSigningConfig(process.env, { fileCfg });
|
|
340
|
+
}
|
|
341
|
+
return shared;
|
|
342
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import { WORKSPACE_SIDECAR, readWorkspaceSidecar } from "./open-gate.js";
|
|
4
|
+
|
|
5
|
+
/** Hard cap on the sidecar bytes read during recovery (it is ~200 bytes). */
|
|
6
|
+
export const SIDECAR_MAX_BYTES = 16 * 1024;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* ROOT-RECOVERY - transversal live-root resolution for stateless agents.
|
|
10
|
+
*
|
|
11
|
+
* Problem (proven in QA, 2026-07-19): orchestrating loops (gateway v1,
|
|
12
|
+
* orchestrator/agent2 v2, indexer, IDE) send X-Workspace-Root from a
|
|
13
|
+
* PERSISTED workspace row - typically an NFS-era path like
|
|
14
|
+
* "/mnt/workspaces/<tenant>/<repo>". On a stateless agent (no NFS mount)
|
|
15
|
+
* the actual clone made by /workspace/clone lands under
|
|
16
|
+
* WORKSPACE_MOUNT (e.g. "/tmp/workspaces/<owner>_<repo>"), so every
|
|
17
|
+
* read/search against the dead header path returns 404/empty forever and
|
|
18
|
+
* the AI burns its whole iteration budget re-preparing workspaces.
|
|
19
|
+
*
|
|
20
|
+
* This module recovers the LIVE root for a dead header root, the way
|
|
21
|
+
* Claude Code / Copilot's agent anchor tools to the session's real
|
|
22
|
+
* checkout: the filesystem the agent itself populated is the authority.
|
|
23
|
+
*
|
|
24
|
+
* Safety rules:
|
|
25
|
+
* - NEVER overrides a header root that exists on disk (byte-identical
|
|
26
|
+
* behavior for every caller that already works).
|
|
27
|
+
* - Matching is conservative: the recovered directory's basename must be
|
|
28
|
+
* exactly the dead root's basename, or end with "_<basename>" (the
|
|
29
|
+
* cloud-clone "<owner>_<repo>" layout). No fuzzy matching.
|
|
30
|
+
* - TENANT GATE (2026-07-19, multi-tenant shared agent): a dead root of
|
|
31
|
+
* tenant A must NEVER recover to a clone hydrated for tenant B just
|
|
32
|
+
* because the repo basenames collide. Rules (fail-closed):
|
|
33
|
+
* - workspace-id match (step 1) stays authoritative, EXCEPT when both
|
|
34
|
+
* sides have a KNOWN tenant and they differ -> no recovery.
|
|
35
|
+
* - basename match (step 2) and disk scan (step 3) require the dead
|
|
36
|
+
* root's tenant to be KNOWN (first path segment after
|
|
37
|
+
* "/mnt/workspaces/") AND the candidate clone to have the SAME
|
|
38
|
+
* tenant registered. Unknown tenant on either side -> no recovery
|
|
39
|
+
* -> caller falls into the structured workspace_root_not_hydrated
|
|
40
|
+
* fail-fast with its actionable hint.
|
|
41
|
+
* - SIDECAR TENANT (2026-10-09): the registry is in-memory, so a clone whose
|
|
42
|
+
* registration was lost (cold start / second revision / "default" key
|
|
43
|
+
* overwritten) has an unknown tenant and was denied forever. For the disk
|
|
44
|
+
* scan ONLY, when the registry has no tenant for a candidate dir, the
|
|
45
|
+
* candidate's tenant is read from the sidecar the agent itself wrote there
|
|
46
|
+
* (`.vh-workspace.json`, see WorkspaceManager.writeSidecar). It is used
|
|
47
|
+
* only if ALL hold: the candidate dir really lives inside the clone mount
|
|
48
|
+
* (realpath); the sidecar is a regular file (not a symlink) of <= 16 KB
|
|
49
|
+
* whose realpath stays inside the candidate dir; it parses as strict JSON
|
|
50
|
+
* with a non-empty `id`, a `root` equal to that dir and a non-empty string
|
|
51
|
+
* `tenantId`; and that tenantId EQUALS the dead root's tenant (the same
|
|
52
|
+
* tenantGateAllows comparison as everywhere). Any other field (repoUrl,
|
|
53
|
+
* ...) is ignored. A registry tenant, when present, always wins and the
|
|
54
|
+
* sidecar is not consulted (byte-identical to the previous behaviour).
|
|
55
|
+
* Nothing else is relaxed: no adoption by git remote, no fuzzy matching.
|
|
56
|
+
* - Never throws; returns null when nothing can be recovered.
|
|
57
|
+
*
|
|
58
|
+
* Kept pure/dependency-injected so guard tests can pin the decision
|
|
59
|
+
* without a running server (same style as utils/sandbox.js tests).
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Extract the tenant id from an NFS-era workspace root.
|
|
64
|
+
* "/mnt/workspaces/<tenant>/<repo>[/...]" -> "<tenant>"; anything else -> null.
|
|
65
|
+
* Pure + total: never throws, unknown stays null (fail-closed downstream).
|
|
66
|
+
*/
|
|
67
|
+
export function tenantOfNfsRoot(root) {
|
|
68
|
+
const m = /^\/mnt\/workspaces\/([^/]+)\/.+/.exec(String(root || "").trim());
|
|
69
|
+
return m ? m[1] : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Tenant gate for basename/scan recovery: BOTH tenants must be known and
|
|
74
|
+
* equal. Unknown on either side denies (fail-closed).
|
|
75
|
+
*/
|
|
76
|
+
export function tenantGateAllows(deadTenant, candidateTenant) {
|
|
77
|
+
return Boolean(deadTenant) && Boolean(candidateTenant) && deadTenant === candidateTenant;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isWithin(child, parent) {
|
|
81
|
+
return child === parent || child.startsWith(parent.endsWith(path.sep) ? parent : parent + path.sep);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Tenant a clone dir was hydrated for, from its own sidecar. Fail-closed:
|
|
86
|
+
* returns {tenantId:null, reason} unless every containment/shape check
|
|
87
|
+
* passes (see the SIDECAR TENANT note above). Never throws, never logs data.
|
|
88
|
+
*/
|
|
89
|
+
export function sidecarTenantOf(candDir, mountDir) {
|
|
90
|
+
const no = (reason) => ({ tenantId: null, reason });
|
|
91
|
+
try {
|
|
92
|
+
const realMount = fs.realpathSync(mountDir);
|
|
93
|
+
const realDir = fs.realpathSync(candDir);
|
|
94
|
+
if (realDir === realMount || !isWithin(realDir, realMount)) return no("candidate-outside-mount");
|
|
95
|
+
const file = path.join(candDir, WORKSPACE_SIDECAR);
|
|
96
|
+
let st;
|
|
97
|
+
try { st = fs.lstatSync(file); } catch { return no("missing"); }
|
|
98
|
+
if (st.isSymbolicLink() || !st.isFile()) return no("not-a-regular-file");
|
|
99
|
+
if (!isWithin(fs.realpathSync(file), realDir)) return no("escapes-candidate-dir");
|
|
100
|
+
if (st.size > SIDECAR_MAX_BYTES) return no("oversized");
|
|
101
|
+
const capped = (f) => {
|
|
102
|
+
const fd = fs.openSync(f, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
|
|
103
|
+
try {
|
|
104
|
+
const buf = Buffer.alloc(SIDECAR_MAX_BYTES + 1);
|
|
105
|
+
const n = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
106
|
+
if (n > SIDECAR_MAX_BYTES) throw new Error("oversized");
|
|
107
|
+
return buf.toString("utf8", 0, n);
|
|
108
|
+
} finally { fs.closeSync(fd); }
|
|
109
|
+
};
|
|
110
|
+
// Reuse the open-gate reader (id non-empty, root == this dir).
|
|
111
|
+
const entry = readWorkspaceSidecar(path.resolve(candDir), { readFileSync: capped });
|
|
112
|
+
if (!entry) return no("malformed-or-root-mismatch");
|
|
113
|
+
if (!entry.tenantId) return no("no-tenantId");
|
|
114
|
+
return { tenantId: entry.tenantId, reason: null };
|
|
115
|
+
} catch {
|
|
116
|
+
return no("unreadable");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Attempt to recover the live root for a dead (non-existent) header root.
|
|
122
|
+
*
|
|
123
|
+
* @param {string} deadRoot the X-Workspace-Root value that does NOT exist on disk
|
|
124
|
+
* @param {object} opts
|
|
125
|
+
* @param {string|null} opts.wsIdHint optional workspace id from header/body/query
|
|
126
|
+
* @param {string|null} opts.tenantHint optional tenant id from the request (fallback
|
|
127
|
+
* when the dead root is not an NFS-era path)
|
|
128
|
+
* @param {Array<{id:string,root:string,tenantId?:string|null}>} opts.registry
|
|
129
|
+
* live workspaces (wsManager.list()); tenantId
|
|
130
|
+
* is the tenant the clone was hydrated for
|
|
131
|
+
* @param {string} opts.mountDir the agent's clone mount (WORKSPACE_MOUNT)
|
|
132
|
+
* @param {(p:string)=>boolean} [opts.existsSync] injectable for tests
|
|
133
|
+
* @param {(p:string)=>string[]} [opts.readdirSync] injectable for tests
|
|
134
|
+
* @param {(msg:string)=>void} [opts.warn] injectable warn logger
|
|
135
|
+
* @returns {string|null} the live root, or null
|
|
136
|
+
*/
|
|
137
|
+
export function recoverLiveRoot(deadRoot, opts = {}) {
|
|
138
|
+
try {
|
|
139
|
+
const existsSync = opts.existsSync || fs.existsSync;
|
|
140
|
+
const readdirSync = opts.readdirSync || fs.readdirSync;
|
|
141
|
+
const warn = typeof opts.warn === "function" ? opts.warn : (m) => console.warn(m);
|
|
142
|
+
const registry = Array.isArray(opts.registry) ? opts.registry : [];
|
|
143
|
+
|
|
144
|
+
const base = path.basename(String(deadRoot || "").trim());
|
|
145
|
+
if (!base || base === "/" || base === ".") return null;
|
|
146
|
+
|
|
147
|
+
// TENANT GATE: tenant the dead root belongs to. NFS-era path segment
|
|
148
|
+
// is authoritative; a caller-supplied hint is the fallback.
|
|
149
|
+
const deadTenant = tenantOfNfsRoot(deadRoot) || opts.tenantHint || null;
|
|
150
|
+
|
|
151
|
+
// 1. Registry by workspace id (strongest signal when the caller sent one).
|
|
152
|
+
// Denied only when BOTH tenants are known and differ - that means the
|
|
153
|
+
// id was reused across tenants, which must never cross-recover.
|
|
154
|
+
if (opts.wsIdHint) {
|
|
155
|
+
const byId = registry.find((w) => w && w.id === opts.wsIdHint);
|
|
156
|
+
if (byId && byId.root && existsSync(byId.root)) {
|
|
157
|
+
const candTenant = byId.tenantId || null;
|
|
158
|
+
if (deadTenant && candTenant && deadTenant !== candTenant) {
|
|
159
|
+
warn(`[ROOT-RECOVERY] TENANT GATE: id match denied - dead tenant '${deadTenant}' != registered tenant '${candTenant}' (ws '${opts.wsIdHint}', base '${base}')`);
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
return byId.root;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Basename/scan recovery is only allowed when the dead root's tenant
|
|
167
|
+
// is KNOWN - otherwise fail closed into workspace_root_not_hydrated.
|
|
168
|
+
if (!deadTenant) return null;
|
|
169
|
+
|
|
170
|
+
// 2. Registry by conservative basename match - candidate must be
|
|
171
|
+
// registered for the SAME tenant.
|
|
172
|
+
for (const w of registry) {
|
|
173
|
+
if (!w || !w.root) continue;
|
|
174
|
+
const b = path.basename(w.root);
|
|
175
|
+
if (b !== base && !b.endsWith("_" + base)) continue;
|
|
176
|
+
if (!tenantGateAllows(deadTenant, w.tenantId || null)) {
|
|
177
|
+
warn(`[ROOT-RECOVERY] TENANT GATE: basename match denied - dead tenant '${deadTenant}' vs registered tenant '${w.tenantId || "unknown"}' (base '${base}')`);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (existsSync(w.root)) return w.root;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 3. Disk scan under the clone mount. Tenant knowledge for a scanned
|
|
184
|
+
// dir comes from the registry (root -> tenantId); a dir this
|
|
185
|
+
// instance never registered has an unknown tenant and is DENIED
|
|
186
|
+
// (fail-closed - the caller's structured fail-fast tells it to
|
|
187
|
+
// re-prepare, which re-registers with the tenant).
|
|
188
|
+
const mountDir = opts.mountDir;
|
|
189
|
+
if (mountDir && existsSync(mountDir)) {
|
|
190
|
+
const tenantByRoot = new Map();
|
|
191
|
+
for (const w of registry) {
|
|
192
|
+
if (w && w.root && w.tenantId) tenantByRoot.set(path.resolve(w.root), w.tenantId);
|
|
193
|
+
}
|
|
194
|
+
const entries = readdirSync(mountDir);
|
|
195
|
+
// Every basename candidate is considered (two owners may share a
|
|
196
|
+
// repo name); the first one the tenant gate allows wins. When none
|
|
197
|
+
// is allowed the FIRST denial is logged (same line as before).
|
|
198
|
+
let firstDenial = null;
|
|
199
|
+
for (const hit of entries.filter((e) => e === base || e.endsWith("_" + base))) {
|
|
200
|
+
const p = path.join(mountDir, hit);
|
|
201
|
+
let candTenant = tenantByRoot.get(path.resolve(p)) || null;
|
|
202
|
+
let why = null;
|
|
203
|
+
if (!candTenant) {
|
|
204
|
+
const sc = sidecarTenantOf(p, mountDir);
|
|
205
|
+
candTenant = sc.tenantId;
|
|
206
|
+
why = sc.reason;
|
|
207
|
+
}
|
|
208
|
+
if (!tenantGateAllows(deadTenant, candTenant)) {
|
|
209
|
+
if (!firstDenial) {
|
|
210
|
+
firstDenial = `[ROOT-RECOVERY] TENANT GATE: disk-scan match denied - dead tenant '${deadTenant}' vs candidate tenant '${candTenant || "unknown"}' (base '${base}')`
|
|
211
|
+
+ (why ? ` [sidecar: ${why}]` : "");
|
|
212
|
+
}
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (existsSync(p)) return p;
|
|
216
|
+
}
|
|
217
|
+
if (firstDenial) warn(firstDenial);
|
|
218
|
+
}
|
|
219
|
+
} catch (_) {
|
|
220
|
+
// Recovery is best-effort by design - never break the request path.
|
|
221
|
+
}
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Resolve the EFFECTIVE root for a header-supplied root:
|
|
227
|
+
* - header root exists on disk -> header root (current behavior, untouched)
|
|
228
|
+
* - header root dead + recovery hit -> live root
|
|
229
|
+
* - otherwise -> header root unchanged (caller fails exactly as before)
|
|
230
|
+
* Returns { root, recovered } so the caller can log the correction.
|
|
231
|
+
*/
|
|
232
|
+
export function resolveEffectiveHeaderRoot(headerRoot, opts = {}) {
|
|
233
|
+
const trimmed = String(headerRoot || "").trim();
|
|
234
|
+
if (!trimmed) return { root: null, recovered: false };
|
|
235
|
+
const existsSync = (opts && opts.existsSync) || fs.existsSync;
|
|
236
|
+
try {
|
|
237
|
+
if (existsSync(trimmed)) return { root: trimmed, recovered: false };
|
|
238
|
+
} catch (_) {
|
|
239
|
+
return { root: trimmed, recovered: false };
|
|
240
|
+
}
|
|
241
|
+
const live = recoverLiveRoot(trimmed, opts);
|
|
242
|
+
if (live) return { root: live, recovered: true };
|
|
243
|
+
return { root: trimmed, recovered: false };
|
|
244
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* safe-exec-args.js - argv builders for the few agent routes that used to
|
|
3
|
+
* concatenate request text into shell strings (security review 2026-10-11, H4).
|
|
4
|
+
* Every function returns an argument ARRAY meant for execFile/spawn without a
|
|
5
|
+
* shell, so request input can never be interpreted as shell syntax.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const EXCLUDED_DIRS = [
|
|
9
|
+
"node_modules", ".git", "target", "build", "dist", ".idea", "__pycache__", ".next",
|
|
10
|
+
"vendor", "bin", "obj", ".gradle", ".mvn", ".venv", "coverage",
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
/** grep argv for POST /workspace/search. `pattern` and `fileFilter` stay data. */
|
|
14
|
+
export function buildGrepArgs({ pattern, fileFilter, root }) {
|
|
15
|
+
if (typeof pattern !== "string" || !pattern) throw new Error("pattern is required");
|
|
16
|
+
if (typeof root !== "string" || !root) throw new Error("root is required");
|
|
17
|
+
const args = ["-rn"];
|
|
18
|
+
if (typeof fileFilter === "string" && fileFilter.trim()) args.push(`--include=${fileFilter}`);
|
|
19
|
+
for (const d of EXCLUDED_DIRS) args.push(`--exclude-dir=${d}`);
|
|
20
|
+
args.push("-e", pattern, "--", root);
|
|
21
|
+
return args;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** tar argv for POST /workspace/build-push. */
|
|
25
|
+
export function buildTarArgs({ tarPath, wsPath }) {
|
|
26
|
+
return ["-czf", tarPath, "--exclude=.git", "--exclude=node_modules", "--exclude=target",
|
|
27
|
+
"--exclude=*.class", "--exclude=.gradle", "-C", wsPath, "."];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const AUTH_ARG = /^[A-Za-z0-9][A-Za-z0-9._:=\/@-]{0,127}$/;
|
|
31
|
+
const AUTH_COMMANDS = [
|
|
32
|
+
["gcloud", "auth", "login"],
|
|
33
|
+
["gcloud", "auth", "application-default", "login"],
|
|
34
|
+
["az", "login"],
|
|
35
|
+
["gh", "auth", "login"],
|
|
36
|
+
["glab", "auth", "login"],
|
|
37
|
+
["aws", "sso", "login"],
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* POST /setup/run-auth-command: only interactive login commands are accepted,
|
|
42
|
+
* optionally followed by flag-like tokens made of safe characters. Returns
|
|
43
|
+
* { ok: true, argv } or { ok: false, reason }.
|
|
44
|
+
*/
|
|
45
|
+
export function parseAuthCommand(command) {
|
|
46
|
+
if (typeof command !== "string" || !command.trim() || command.length > 300) {
|
|
47
|
+
return { ok: false, reason: "command is required" };
|
|
48
|
+
}
|
|
49
|
+
const tokens = command.trim().split(/ +/);
|
|
50
|
+
const match = AUTH_COMMANDS.find((c) => c.every((t, i) => tokens[i] === t));
|
|
51
|
+
if (!match) return { ok: false, reason: "only interactive login commands are allowed (gcloud/az/gh/glab/aws)" };
|
|
52
|
+
const rest = tokens.slice(match.length);
|
|
53
|
+
for (const t of rest) {
|
|
54
|
+
const bare = t.startsWith("--") ? t.slice(2) : t;
|
|
55
|
+
if (!AUTH_ARG.test(bare)) return { ok: false, reason: `unsafe argument: ${t.slice(0, 40)}` };
|
|
56
|
+
}
|
|
57
|
+
return { ok: true, argv: tokens };
|
|
58
|
+
}
|