deepdebug-local-agent 1.0.22 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/.claude/context.md +76 -0
  2. package/.claude/skills/README.md +25 -0
  3. package/.claude/skills/tdd-debugging/SKILL.md +53 -0
  4. package/.claude/skills/tdd-debugging/references/anti-patterns.md +43 -0
  5. package/.claude/skills/tdd-debugging/references/command-reference.md +46 -0
  6. package/.claude/standards/README.md +51 -0
  7. package/.claude/standards/clean-code.md +75 -0
  8. package/.claude/standards/code-review-checklist.md +61 -0
  9. package/.claude/standards/layered-architecture.md +163 -0
  10. package/.claude/standards/node.md +45 -0
  11. package/.claude/standards/security-owasp.md +114 -0
  12. package/.claude/standards/simplicity.md +42 -0
  13. package/.claude/standards/solid.md +64 -0
  14. package/.claude/standards/testing.md +59 -0
  15. package/.cursor/rules/standards.mdc +17 -0
  16. package/.github/workflows/build-byoc-image.yml +315 -0
  17. package/.github/workflows/deploy-gcp.yml +102 -0
  18. package/.github/workflows/npm-publish.yml +69 -8
  19. package/AGENTS.md +21 -0
  20. package/CHANGELOG.md +69 -0
  21. package/CLAUDE.md +13 -0
  22. package/CLAUDE_CODE_AGENT_INFINITE.md +5 -3
  23. package/Dockerfile +4 -0
  24. package/Dockerfile.full +55 -3
  25. package/README.md +81 -0
  26. package/bin/install.js +16 -5
  27. package/cloudbuild-full.yaml +57 -0
  28. package/docker-compose.yml +5 -3
  29. package/docs/OBSERVABILITY.md +164 -0
  30. package/docs/byoc-agent-image.md +70 -0
  31. package/docs/graphify-packaging-plan.md +133 -0
  32. package/docs/graphify-validation-run-2026-10-28.md +243 -0
  33. package/docs/legacy-connectors.md +268 -0
  34. package/docs/releasing.md +43 -0
  35. package/docs/signed-commands.md +163 -0
  36. package/env.example +30 -0
  37. package/helm/Chart.yaml +2 -2
  38. package/helm/templates/deployment.yaml +13 -3
  39. package/helm/templates/networkpolicy.yaml +22 -0
  40. package/helm/templates/secret.yaml +1 -0
  41. package/helm/values.yaml +13 -14
  42. package/mcp-server.js +4 -1
  43. package/package.json +12 -4
  44. package/scripts/sync-fixtures.mjs +246 -0
  45. package/scripts/upgrade-observability-push.sh +227 -0
  46. package/src/adapters/as400/as400-parser.js +12 -0
  47. package/src/adapters/as400/index.js +134 -0
  48. package/src/adapters/cobol/cobol-parser.js +37 -0
  49. package/src/adapters/cobol/index.js +151 -0
  50. package/src/adapters/ibmmq/ibmmq-parser.js +23 -0
  51. package/src/adapters/ibmmq/index.js +137 -0
  52. package/src/adapters/index.js +79 -0
  53. package/src/adapters/oracle-forms/index.js +145 -0
  54. package/src/adapters/oracle-forms/oracle-forms-parser.js +11 -0
  55. package/src/adapters/sap/index.js +146 -0
  56. package/src/adapters/sap/sap-parser.js +41 -0
  57. package/src/ai-engine.js +22 -22
  58. package/src/config/trusted-signing-keys.json +3 -0
  59. package/src/detectors/build-tool-detector.js +50 -0
  60. package/src/detectors/multi-project-detector.js +22 -22
  61. package/src/detectors/service-detector.js +24 -24
  62. package/src/exec-utils.js +26 -4
  63. package/src/git/base-git-provider.js +117 -117
  64. package/src/git/bitbucket-provider.js +2 -2
  65. package/src/git/git-provider-registry.js +18 -18
  66. package/src/git/github-provider.js +13 -13
  67. package/src/git/multi-repo-pr.js +161 -0
  68. package/src/mcp-http-server.js +32 -16
  69. package/src/observability/bundle-files.js +418 -0
  70. package/src/observability/detect.js +151 -0
  71. package/src/observability/provision.js +212 -0
  72. package/src/observability/render-cli.js +59 -0
  73. package/src/observability/routes.js +88 -0
  74. package/src/patch/patch-engine.js +28 -28
  75. package/src/patch-manager.js +85 -85
  76. package/src/patch_bkp.js +14 -14
  77. package/src/ports.js +4 -4
  78. package/src/routes/workspace.route.js +47 -16
  79. package/src/runtimes/base-runtime.js +75 -75
  80. package/src/runtimes/java/gradle-runtime.js +17 -17
  81. package/src/runtimes/java/java-integrations.js +25 -25
  82. package/src/runtimes/java/maven-runtime.js +34 -34
  83. package/src/runtimes/node/node-integrations.js +1 -1
  84. package/src/runtimes/node/npm-runtime.js +14 -14
  85. package/src/runtimes/node/yarn-runtime.js +4 -4
  86. package/src/runtimes/runtime-registry.js +25 -25
  87. package/src/server-local.js +4 -4
  88. package/src/server.js +1412 -335
  89. package/src/siblings/burst-suppressor.js +89 -0
  90. package/src/siblings/capabilities.js +273 -0
  91. package/src/siblings/env-compat.cjs +58 -0
  92. package/src/siblings/incident-forwarder.js +320 -0
  93. package/src/siblings/logger.js +15 -0
  94. package/src/siblings/metrics.js +94 -0
  95. package/src/utils/agent-auth.js +184 -0
  96. package/src/utils/approval-token.js +56 -0
  97. package/src/utils/bind-host.js +29 -0
  98. package/src/utils/branch-refspec.js +98 -0
  99. package/src/utils/child-env.js +34 -0
  100. package/src/utils/environment-diagnostics.js +55 -55
  101. package/src/utils/exec-policy.js +962 -0
  102. package/src/utils/exec-utils.js +15 -15
  103. package/src/utils/file-count.js +46 -0
  104. package/src/utils/fs-utils.js +10 -10
  105. package/src/utils/multi-repo-open.js +132 -0
  106. package/src/utils/open-gate.js +183 -0
  107. package/src/utils/ranged-read.js +65 -0
  108. package/src/utils/request-guards.js +77 -0
  109. package/src/utils/request-signing.js +342 -0
  110. package/src/utils/root-recovery.js +244 -0
  111. package/src/utils/safe-exec-args.js +58 -0
  112. package/src/utils/sandbox.js +182 -0
  113. package/src/utils/search-outcome.js +56 -0
  114. package/src/utils/workspace-root.js +80 -0
  115. package/src/validators/syntax-validator.js +149 -0
  116. package/src/workspace/detect-port.js +16 -16
  117. package/src/workspace/file-reader.js +1 -1
  118. package/src/workspace/process-manager.js +46 -46
  119. package/src/workspace-manager.js +86 -24
  120. package/test/adapters/as400/as400.test.js +121 -0
  121. package/test/adapters/cobol/cobol.test.js +217 -0
  122. package/test/adapters/ibmmq/ibmmq.test.js +122 -0
  123. package/test/adapters/oracle-forms/oracle-forms.test.js +180 -0
  124. package/test/adapters/registry.test.js +108 -0
  125. package/test/adapters/sap/sap.test.js +142 -0
  126. package/test/agent-auth.test.js +194 -0
  127. package/test/bind-host.test.js +26 -0
  128. package/test/branch-refspec.test.js +224 -0
  129. package/test/burst-suppressor.test.js +60 -0
  130. package/test/capabilities-access.test.js +47 -0
  131. package/test/capabilities.test.js +87 -0
  132. package/test/child-env.test.js +23 -0
  133. package/test/env-compat.test.js +71 -0
  134. package/test/exec-policy.test.js +463 -0
  135. package/test/file-count.test.js +64 -0
  136. package/test/incident-forwarder.test.js +231 -0
  137. package/test/multi-repo-open.test.js +180 -0
  138. package/test/multi-repo-pr.test.js +129 -0
  139. package/test/observability-detect.test.js +199 -0
  140. package/test/open-gate.test.js +341 -0
  141. package/test/ranged-read.test.js +60 -0
  142. package/test/request-guards.test.js +91 -0
  143. package/test/request-signing.test.js +284 -0
  144. package/test/root-recovery-sidecar.test.js +171 -0
  145. package/test/root-recovery.test.js +274 -0
  146. package/test/safe-exec-args.test.js +58 -0
  147. package/test/sandbox.test.js +98 -0
  148. package/test/search-outcome.test.js +50 -0
  149. package/test/workspace-root.test.js +64 -0
  150. package/tools/graphify-validation/README.md +92 -0
  151. package/tools/graphify-validation/config/questions.json +31 -0
  152. package/tools/graphify-validation/config/repos.json +9 -0
  153. package/tools/graphify-validation/config/symbols.json +6 -0
  154. package/tools/graphify-validation/fixtures/spring/expected.json +34 -0
  155. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/AppConfig.java +29 -0
  156. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/AuditLogger.java +15 -0
  157. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/Cache.java +7 -0
  158. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/CheckoutService.java +25 -0
  159. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/InMemoryCache.java +18 -0
  160. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/OrderController.java +22 -0
  161. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/PaymentGateway.java +5 -0
  162. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/PaypalGateway.java +11 -0
  163. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/RefundService.java +14 -0
  164. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/StripeGateway.java +11 -0
  165. package/tools/graphify-validation/lib/affected_vs_rg.py +81 -0
  166. package/tools/graphify-validation/lib/audit_hook/sitecustomize.py +22 -0
  167. package/tools/graphify-validation/lib/graph_metrics.py +96 -0
  168. package/tools/graphify-validation/lib/gv_common.py +130 -0
  169. package/tools/graphify-validation/lib/licenses.py +47 -0
  170. package/tools/graphify-validation/lib/pip_audit_summary.py +18 -0
  171. package/tools/graphify-validation/lib/recall.py +115 -0
  172. package/tools/graphify-validation/lib/report.py +226 -0
  173. package/tools/graphify-validation/lib/run_all.py +237 -0
  174. package/tools/graphify-validation/lib/run_graphify.py +144 -0
  175. package/tools/graphify-validation/lib/rusage_helper.py +17 -0
  176. package/tools/graphify-validation/lib/sample_edges.py +246 -0
  177. package/tools/graphify-validation/lib/spring_fixture.py +81 -0
  178. package/tools/graphify-validation/requirements-py311.lock +508 -0
  179. package/tools/graphify-validation/requirements-tools.in +1 -0
  180. package/tools/graphify-validation/requirements-tools.lock +371 -0
  181. package/tools/graphify-validation/requirements.in +1 -0
  182. package/tools/graphify-validation/requirements.lock +502 -0
  183. package/tools/graphify-validation/run.sh +43 -0
  184. package/tools/graphify-validation/tests/test_harness.py +151 -0
  185. package/cloudbuild-agent-qa.yaml +0 -43
  186. package/cloudbuild-agent-staging.yaml +0 -40
  187. package/cloudbuild.yaml.deprecated +0 -42
  188. package/src/local-agent-source.tar.gz +0 -0
@@ -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
+ }