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
package/src/server.js CHANGED
@@ -5,13 +5,35 @@ import path from "path";
5
5
  import os from "os";
6
6
  import fs from "fs";
7
7
  const fsPromises = fs.promises;
8
- import { exec, spawn } from "child_process";
8
+ import { exec, execFile, spawn } from "child_process";
9
9
  import { promisify } from "util";
10
10
  import { EventEmitter } from "events";
11
11
  import { exists, listRecursive, readFile, writeFile } from "./fs-utils.js";
12
12
  import { detectProject, readText } from "./detectors.js";
13
+ import { detectBuildTool } from "./detectors/build-tool-detector.js";
14
+ import { validateFiles } from "./validators/syntax-validator.js";
13
15
  import { detectPort } from "./ports.js";
14
16
  import { compileAndTest, run } from "./exec-utils.js";
17
+ import { confineToRoot, checkArgsWithinRoot, scanRawCommand, SandboxViolation } from "./utils/sandbox.js";
18
+ import { getSigningConfig, createSignatureMiddleware, captureRawBody, describeSigningConfig, verifyWsEnvelope, internalSignatureHeaders } from "./utils/request-signing.js";
19
+ import { decideExec, rejectionBody, resolveExecPolicyConfig } from "./utils/exec-policy.js";
20
+ import { resolveEffectiveHeaderRoot } from "./utils/root-recovery.js";
21
+ import { classifyGrepFailure, partialSearchBody } from "./utils/search-outcome.js";
22
+ import { classifyOpenDestination, extractOpenTenantId, isSafeToRemove } from "./utils/open-gate.js";
23
+ import { fetchRequestedRef } from "./utils/branch-refspec.js";
24
+ import { sliceFileContent } from "./utils/ranged-read.js";
25
+ import { countFilesExcludingGit } from "./utils/file-count.js";
26
+ import { hydrateRepos, multiRepoAllowPartial, planRepos } from "./utils/multi-repo-open.js";
27
+ import {
28
+ multiRepoPrEnabled,
29
+ isMultiRepoRoot,
30
+ listRepoSubdirs,
31
+ parseAuthedRemote,
32
+ stripRemoteCreds,
33
+ buildCompareUrl,
34
+ buildCrossLinkNote,
35
+ buildMultiRepoPrBody,
36
+ } from "./git/multi-repo-pr.js";
15
37
  import { applyUnifiedDiff } from "./patch.js";
16
38
  import { WorkspaceScanner } from "./workspace/scanner.js";
17
39
  import { FileReader } from "./workspace/file-reader.js";
@@ -25,13 +47,49 @@ import { ConfigAnalyzer } from "./analyzers/config-analyzer.js";
25
47
  import { WorkspaceManager } from "./workspace-manager.js";
26
48
  import { startMCPHttpServer } from "./mcp-http-server.js";
27
49
  import { registerVercelRoutes } from "./vercel-proxy.js";
50
+ import { registerObservabilityRoutes } from "./observability/routes.js";
51
+ import { resolveAuthConfig, allowedOrigins, corsOriginCallback, createAuthMiddleware, internalAuthHeaders, describeAuthMode } from "./utils/agent-auth.js";
52
+ import { isSafeBranchName } from "./utils/branch-refspec.js";
53
+ import { resolveBindHost } from "./utils/bind-host.js";
54
+ import { sanitizedChildEnv } from "./utils/child-env.js";
55
+ import { validateHeaderRoot } from "./utils/workspace-root.js";
56
+ import { buildGrepArgs, buildTarArgs, parseAuthCommand } from "./utils/safe-exec-args.js";
57
+ import { validateLocalTestUrl, buildLocalTestUrl, isSafeTenantId, validateGitUrl, isSafeToolName } from "./utils/request-guards.js";
58
+ import { loadAdapters, stopAll } from "./adapters/index.js";
28
59
 
29
60
  const execAsync = promisify(exec);
61
+ const execFileAsync = promisify(execFile);
62
+
63
+ /**
64
+ * Resolve a repo's real default branch by asking the remote directly.
65
+ *
66
+ * `git symbolic-ref refs/remotes/origin/HEAD` is unreliable: a `--single-branch`
67
+ * clone (our fallback when the requested branch is absent) never creates the
68
+ * origin/HEAD ref, so on the next update the lookup returns nothing and we wrongly
69
+ * keep assuming 'main'. `git ls-remote --symref origin HEAD` queries the remote and
70
+ * always reports the true default (master/main/anything), independent of how the
71
+ * local clone was made.
72
+ *
73
+ * @returns {Promise<string|null>} branch name (e.g. "master") or null if undeterminable.
74
+ */
75
+ async function resolveDefaultBranch(cwd) {
76
+ try {
77
+ const { stdout } = await execAsync(`git ls-remote --symref origin HEAD`, { cwd, timeout: 30000 });
78
+ // First line looks like: "ref: refs/heads/master\tHEAD"
79
+ const m = /ref:\s+refs\/heads\/(\S+)\s+HEAD/.exec(stdout || "");
80
+ if (m) return m[1];
81
+ } catch {
82
+ // Network/token failure — fall through to the local symbolic-ref probe.
83
+ }
84
+ const head = await execAsync(`git symbolic-ref refs/remotes/origin/HEAD --short`, { cwd })
85
+ .catch(() => null);
86
+ return head && head.stdout ? head.stdout.trim().replace(/^origin\//, "") : null;
87
+ }
30
88
 
31
89
  // ============================================
32
90
  // AI VIBE CODING ENGINE
33
- // Sistema universal de auto-healing que usa AI
34
- // para resolver QUALQUER erro automaticamente
91
+ // Universal auto-healing system that uses AI
92
+ // to resolve ANY error automatically
35
93
  // ============================================
36
94
 
37
95
  class AIVibeCodingEngine extends EventEmitter {
@@ -95,7 +153,7 @@ class AIVibeCodingEngine extends EventEmitter {
95
153
  /datasource.*failed/i
96
154
  ];
97
155
 
98
- // Excluir falsos positivos (linhas normais que contm palavras de erro)
156
+ // Exclude false positives (normal lines that contain error words)
99
157
  const falsePositives = [
100
158
  /hikaripool.*start/i, // "HikariPool-1 - Starting..." normal
101
159
  /hikaripool.*completed/i, // "HikariPool-1 - Start completed" normal
@@ -105,7 +163,7 @@ class AIVibeCodingEngine extends EventEmitter {
105
163
  /tomcat started/i // "Tomcat started on port" sucesso!
106
164
  ];
107
165
 
108
- // Se match com falso positivo, no erro
166
+ // If it matches a false positive, it is not an error
109
167
  if (falsePositives.some(p => p.test(message))) {
110
168
  return false;
111
169
  }
@@ -292,7 +350,7 @@ class AIVibeCodingEngine extends EventEmitter {
292
350
  clearTimeout(timeout);
293
351
  };
294
352
 
295
- // Detectar sucesso do Spring Boot nos logs
353
+ // Detect Spring Boot success in the logs
296
354
  const isStartupSuccess = (message) => {
297
355
  if (!message) return false;
298
356
  return /started.*in.*seconds/i.test(message) ||
@@ -304,7 +362,7 @@ class AIVibeCodingEngine extends EventEmitter {
304
362
  if (serviceId === 'test-local' && message) {
305
363
  logs.push(message);
306
364
 
307
- // Detectar sucesso nos logs do Spring Boot
365
+ // Detect success in the Spring Boot logs
308
366
  if (isStartupSuccess(message) && !resolved) {
309
367
  console.log(`[AI-Engine] Detected startup success: ${message.substring(0, 80)}...`);
310
368
  resolved = true;
@@ -541,64 +599,46 @@ class AIVibeCodingEngine extends EventEmitter {
541
599
  // Instncia global do AI Engine
542
600
  let aiEngine = null;
543
601
 
602
+ // Phase 2 — Legacy connector adapters (lifecycle owned by SIGTERM/SIGINT handlers below).
603
+ // Populated at boot from LEGACY_STACKS env var; empty when env var unset (zero-regression).
604
+ let _legacyAdapters = [];
605
+
544
606
  const app = express();
545
607
 
546
608
  // FIXED: Support Cloud Run PORT environment variable (GCP uses PORT)
547
609
  const PORT = process.env.PORT || process.env.LOCAL_AGENT_PORT || 5055;
548
610
 
549
- // CORS - Allow from Cloud Run, local dev, and Lovable frontend domains
611
+ // SECURITY (audit 2026-09): CORS is an exact allowlist (no wildcard shared-
612
+ // hosting domains such as *.vercel.app / *.run.app, no allow-all in non-prod)
613
+ // and every non-public route goes through the auth perimeter in
614
+ // utils/agent-auth.js. No browser page calls the agent directly.
615
+ const AGENT_AUTH = resolveAuthConfig();
616
+ const AGENT_ORIGINS = allowedOrigins();
550
617
  app.use(cors({
551
- origin: function(origin, callback) {
552
- // Allow requests with no origin (curl, mobile apps, server-to-server)
553
- if (!origin) return callback(null, true);
554
-
555
- const allowed = [
556
- // Local development
557
- "http://localhost:3010",
558
- "http://localhost:3000",
559
- "http://localhost:8085",
560
- "http://127.0.0.1:3010",
561
- "http://127.0.0.1:3000",
562
- "http://127.0.0.1:8085",
563
- ];
564
-
565
- const allowedPatterns = [
566
- /https:\/\/.*\.run\.app$/, // Cloud Run
567
- /https:\/\/.*\.web\.app$/, // Firebase hosting
568
- /https:\/\/.*\.lovable\.app$/, // Lovable preview
569
- /https:\/\/.*\.lovableproject\.com$/, // Lovable project domains
570
- /https:\/\/.*\.netlify\.app$/, // Netlify
571
- /https:\/\/.*\.vercel\.app$/, // Vercel
572
- /https:\/\/deepdebug\.ai$/, // Production
573
- /https:\/\/.*\.deepdebug\.ai$/, // Production subdomains
574
- ];
575
-
576
- if (allowed.includes(origin) || allowedPatterns.some(p => p.test(origin))) {
577
- return callback(null, true);
578
- }
579
-
580
- // In development/QA, log and allow unknown origins instead of blocking
581
- const env = process.env.NODE_ENV || 'development';
582
- if (env !== 'production') {
583
- console.warn(`CORS: allowing unknown origin in non-prod: ${origin}`);
584
- return callback(null, true);
585
- }
586
-
587
- console.warn(`CORS: blocked origin: ${origin}`);
588
- return callback(new Error(`CORS: origin ${origin} not allowed`));
589
- },
618
+ origin: corsOriginCallback(AGENT_ORIGINS),
590
619
  methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
591
- allowedHeaders: ["Content-Type", "Authorization", "X-Tenant-ID", "X-User-ID"],
620
+ allowedHeaders: ["Content-Type", "Authorization", "X-Tenant-ID", "X-User-ID", "X-Agent-Token", "X-Workspace-Id", "X-Workspace-Root"],
592
621
  credentials: true
593
622
  }));
594
-
595
- // Handle preflight requests explicitly
596
- app.options('*', cors());
597
-
598
- app.use(bodyParser.json({ limit: "50mb" }));
599
-
600
- // DEFAULT WORKSPACE - Define o workspace padro
601
- // Pode ser sobrescrito via varivel de ambiente ou POST /workspace/open
623
+ app.options('*', cors({ origin: corsOriginCallback(AGENT_ORIGINS) }));
624
+ app.use(createAuthMiddleware(AGENT_AUTH, AGENT_ORIGINS));
625
+ (AGENT_AUTH.mode === 'token' || AGENT_AUTH.mode === 'loopback' ? console.log : console.warn)(`[agent-auth] mode: ${describeAuthMode(AGENT_AUTH)}`);
626
+
627
+ // captureRawBody keeps the exact bytes the platform signed (X-VH-Signature).
628
+ app.use(bodyParser.json({ limit: "50mb", verify: captureRawBody }));
629
+
630
+ // SIGNED COMMANDS (utils/request-signing.js): every platform request carries an
631
+ // Ed25519 signature over method/path/body/tenant/workspace/timestamp/nonce.
632
+ // LOCAL_AGENT_SIGNATURE_MODE=off|warn|enforce (default enforce since 1.1.0; warn = verify + log only).
633
+ // X-Agent-Token (agent-auth above) stays as the second factor.
634
+ const SIGNING = getSigningConfig();
635
+ const EXEC_POLICY = resolveExecPolicyConfig();
636
+ app.use(createSignatureMiddleware(SIGNING));
637
+ (SIGNING.mode === "enforce" ? console.log : console.warn)(`[agent-sig] ${describeSigningConfig(SIGNING)}`);
638
+ console.log(`[exec-policy] mode=${EXEC_POLICY.mode} (allowlist; LOCAL_AGENT_EXEC_POLICY_MODE=off|warn|enforce)`);
639
+
640
+ // DEFAULT WORKSPACE - Sets the default workspace
641
+ // Can be overridden via an environment variable or POST /workspace/open
602
642
  const DEFAULT_WORKSPACE = process.env.WORKSPACE_ROOT
603
643
  || process.env.DEFAULT_WORKSPACE
604
644
  || '/Users/macintosh/IdeaProjects/pure-core-ms';
@@ -612,6 +652,24 @@ let DETECTED_SERVICES = [];
612
652
  const processManager = new ProcessManager();
613
653
  const wsManager = new WorkspaceManager();
614
654
 
655
+ // SEG-1: strip credentials from any URL before it reaches a log line or an
656
+ // error returned to the caller. Masks the userinfo segment of every http(s)
657
+ // URL in the string (user:pass, x-token-auth:token, oauth2:token,
658
+ // x-access-token:token) regardless of token format, so a client access token
659
+ // never appears in Cloud Logging. Token-agnostic (unlike a literal token
660
+ // replace, which misses repeated occurrences and breaks on regex chars) and
661
+ // never throws. Returns non-strings coerced to string; null/undefined pass
662
+ // through unchanged.
663
+ function maskUrlCreds(input) {
664
+ if (input === null || input === undefined) return input;
665
+ const s = typeof input === "string" ? input : String(input);
666
+ // Mask the entire userinfo (everything from "://" up to the delimiting "@").
667
+ // [^\s@]+ allows ":" and "/" so tokens that embed a slash (base64, some
668
+ // PATs) or carry no colon ("https://<token>@host") are fully masked, not
669
+ // just up to the first slash.
670
+ return s.replace(/(https?:\/\/)[^\s@]+@/gi, "$1***@");
671
+ }
672
+
615
673
  function resolveWorkspaceRoot(req) {
616
674
  // TENANT ISOLATION: stateless, never mutates global state.
617
675
  // Returns the workspace path for THIS specific request only.
@@ -627,7 +685,46 @@ function resolveWorkspaceRoot(req) {
627
685
  // 1. Tenant-isolated NFS path from Gateway
628
686
  const secureRoot = req.headers['x-workspace-root'];
629
687
  if (secureRoot && secureRoot.trim()) {
630
- return secureRoot.trim();
688
+ // SECURITY (2026-10-11): the header is not covered by the request
689
+ // signature, so it is validated against the workspace bases / the
690
+ // registry (and the signed tenant) before it becomes the sandbox root.
691
+ const check = validateHeaderRoot(secureRoot, {
692
+ registry: wsManager.list(),
693
+ tenantId: req.vhSignature && req.vhSignature.verified ? (req.vhSignature.tenantId || null) : undefined,
694
+ });
695
+ if (!check.ok) {
696
+ console.warn(`[workspace-root] REJECTED X-Workspace-Root '${secureRoot.trim().slice(0, 200)}': ${check.reason}`);
697
+ return null;
698
+ }
699
+ // ROOT-RECOVERY (transversal, 2026-07-19): on a stateless agent the
700
+ // header often carries a DEAD persisted path ("/mnt/workspaces/...")
701
+ // while the real clone this agent made lives under WORKSPACE_MOUNT
702
+ // ("/tmp/workspaces/<owner>_<repo>"). If the header root exists on
703
+ // disk it is returned UNCHANGED (byte-identical for every working
704
+ // caller); only a dead root triggers conservative recovery via the
705
+ // workspace registry + clone-mount scan. Fixes the v1/v2/indexer
706
+ // split-brain (prepare clones to one path, reads hit another) at the
707
+ // single point every caller already goes through.
708
+ const wsIdHint = req.headers['x-workspace-id']
709
+ || (req.body && req.body.workspaceId)
710
+ || (req.query && req.query.workspaceId)
711
+ || null;
712
+ // TENANT GATE: tenant of the dead root is derived from the
713
+ // "/mnt/workspaces/<tenant>/..." path inside the recovery module;
714
+ // the request payload tenantId is only a fallback hint.
715
+ const tenantHint = (req.body && req.body.tenantId)
716
+ || (req.query && req.query.tenantId)
717
+ || null;
718
+ const eff = resolveEffectiveHeaderRoot(secureRoot, {
719
+ wsIdHint,
720
+ tenantHint,
721
+ registry: wsManager.list(),
722
+ mountDir: process.env.WORKSPACE_MOUNT || process.env.WORKSPACES_MOUNT || "/tmp/workspaces"
723
+ });
724
+ if (eff.recovered) {
725
+ console.log(`[ROOT-RECOVERY] dead root '${secureRoot.trim()}' -> live root '${eff.root}'`);
726
+ }
727
+ return eff.root;
631
728
  }
632
729
 
633
730
  // 2. WorkspaceId (dev/local mode only)
@@ -644,6 +741,42 @@ const MCP_PORT = process.env.MCP_PORT || 5056;
644
741
  // Inicializar AI Vibe Coding Engine
645
742
  aiEngine = new AIVibeCodingEngine(processManager, () => WORKSPACE_ROOT);
646
743
 
744
+ // Phase 2 — bootstrap legacy connectors from LEGACY_STACKS env var.
745
+ // Adapters are loaded UNSTARTED by the registry (AD-1); we start them inline so that
746
+ // the existing local-agent flow is byte-for-byte unchanged when LEGACY_STACKS is unset.
747
+ // Errors during start surface via the adapter's own 'error' event; the loop never throws.
748
+ try {
749
+ const stacks = (process.env.LEGACY_STACKS || '')
750
+ .split(',')
751
+ .map((s) => s.trim())
752
+ .filter(Boolean);
753
+ if (stacks.length > 0) {
754
+ _legacyAdapters = loadAdapters({ legacyStacks: stacks });
755
+ // Wire the adapter consumer (sibling module): forward each adapter's 6-key
756
+ // 'error' event to the gateway incident endpoint. Attached here in the
757
+ // existing boot loop; the forwarder buffers until it is configured with the
758
+ // gateway credentials from the app.listen callback below.
759
+ import('./siblings/incident-forwarder.js')
760
+ .then(({ incidentForwarder }) => {
761
+ for (const a of _legacyAdapters) {
762
+ a.on('error', (evt) => incidentForwarder.handleAdapterEvent(evt, a?.constructor?.name));
763
+ }
764
+ })
765
+ .catch((err) => process.stderr.write(`adapter-consumer wiring failed: ${err.message}\n`));
766
+ for (const a of _legacyAdapters) {
767
+ // Start failures are surfaced via the adapter's 'error' event; do not crash boot.
768
+ Promise.resolve()
769
+ .then(() => a.start())
770
+ .catch((err) => {
771
+ process.stderr.write(`legacy-connectors: ${a.constructor.name}.start() failed: ${err.message}\n`);
772
+ });
773
+ }
774
+ }
775
+ } catch (err) {
776
+ // Bad LEGACY_STACKS = programmer/operator error; emit to stderr without crashing boot.
777
+ process.stderr.write(`legacy-connectors: ${err.message}\n`);
778
+ }
779
+
647
780
  // ============================================
648
781
  // BACKUP STORAGE (Sprint 1.3)
649
782
  // In-memory backup storage with configurable max size
@@ -722,6 +855,24 @@ function loadBackupIndex() {
722
855
  // Load backups on startup
723
856
  loadBackupIndex();
724
857
 
858
+ // ============================================
859
+ // TEST LOCAL STATE (EARLY INITIALIZATION)
860
+ // Must be initialized BEFORE event listeners that call addServerLog()
861
+ // ============================================
862
+ // Single source of truth for test-local state. Declared early (before the
863
+ // ProcessManager event listeners that call addServerLog) and reused by the
864
+ // test-local endpoints below. Full shape so every field the endpoints read
865
+ // (status/config/testResults/endpoints/...) exists from the start.
866
+ const TEST_LOCAL_STATE = {
867
+ status: "idle", // idle, compiling, compiled, starting, running, stopped, error
868
+ compilationResult: null,
869
+ serverProcess: null,
870
+ endpoints: [],
871
+ config: null,
872
+ testResults: [],
873
+ serverLogs: [] // circular buffer of server logs (last 1000)
874
+ };
875
+
725
876
  // Event listeners do ProcessManager
726
877
  processManager.on("started", ({ serviceId }) => {
727
878
  console.log(`Service ${serviceId} started successfully`);
@@ -784,14 +935,246 @@ app.get("/health", (req, res) => {
784
935
  });
785
936
 
786
937
  /** Define/abre o workspace local */
938
+ /**
939
+ * Clone or update ONE repo into `destDir`. Extracted so both the single-repo
940
+ * and multi-repo branches share identical auth/checkout/reset semantics.
941
+ */
942
+ async function cloneOrUpdateRepo({ repoUrl, token, branch = "main", destDir }) {
943
+ let authUrl;
944
+ if (token) {
945
+ const cleanUrl = repoUrl.replace(/https:\/\/[^@]+@/, 'https://');
946
+ authUrl = token.includes(':')
947
+ ? cleanUrl.replace('https://', `https://${token}@`)
948
+ : cleanUrl.replace('https://', `https://x-access-token:${token}@`);
949
+ } else {
950
+ authUrl = repoUrl;
951
+ }
952
+ const alreadyCloned = await exists(path.join(destDir, '.git'));
953
+ if (alreadyCloned) {
954
+ await execAsync(`git remote set-url origin "${authUrl}"`, { cwd: destDir }).catch(() => {});
955
+ await execAsync(`git fetch origin`, { cwd: destDir, timeout: 120000 });
956
+ // REFSPEC FIX (QA 2026-07-20) composed with main's default-branch
957
+ // fallback (PR #4): the original clone was --single-branch, so the
958
+ // `git fetch origin` above only refreshes THAT one branch. Fetch the
959
+ // REQUESTED ref explicitly so branches created after the clone (e.g.
960
+ // a PR branch minutes old) exist before checkout/reset. If the branch
961
+ // does not exist on the remote (caller assumed main, repo uses
962
+ // master), fall back to the repo's real default branch. Shell-unsafe
963
+ // branch names still throw tagged INVALID_BRANCH -> clear response.
964
+ let effBranch = branch;
965
+ try {
966
+ await fetchRequestedRef(execAsync, branch, destDir);
967
+ } catch (refErr) {
968
+ if (refErr && refErr.code === 'BRANCH_NOT_FOUND') {
969
+ const defBranch = await resolveDefaultBranch(destDir);
970
+ if (defBranch) {
971
+ console.warn(`[cloud] Branch '${branch}' not on remote — using default '${defBranch}'`);
972
+ effBranch = defBranch;
973
+ } else {
974
+ throw refErr;
975
+ }
976
+ } else {
977
+ throw refErr;
978
+ }
979
+ }
980
+ await execAsync(`git checkout ${effBranch} 2>/dev/null || git checkout -b ${effBranch} origin/${effBranch}`, { cwd: destDir }).catch(() => {});
981
+ await execAsync(`git reset --hard origin/${effBranch}`, { cwd: destDir });
982
+ } else {
983
+ try {
984
+ await execAsync(
985
+ `git clone --branch ${branch} --single-branch --depth 50 "${authUrl}" "${destDir}"`,
986
+ { timeout: 300000 }
987
+ );
988
+ } catch (cloneErr) {
989
+ // Requested branch absent (e.g. repo uses master). Git phrases this as
990
+ // "Remote branch <x> not found in upstream origin" or "could not find
991
+ // remote branch" — match both, then clone the repo's default branch.
992
+ if (/(remote branch .* not found|could not find remote branch)/i.test(cloneErr.message || '')) {
993
+ console.warn(`[cloud] Branch '${branch}' not found — falling back to the repo default branch`);
994
+ await execAsync(`rm -rf "${destDir}"`).catch(() => {});
995
+ await execAsync(
996
+ `git clone --single-branch --depth 50 "${authUrl}" "${destDir}"`,
997
+ { timeout: 300000 }
998
+ );
999
+ } else {
1000
+ throw cloneErr;
1001
+ }
1002
+ }
1003
+ await execAsync(`git config user.email "deepdebug-ai@deepdebug.ai"`, { cwd: destDir });
1004
+ await execAsync(`git config user.name "DeepDebug AI"`, { cwd: destDir });
1005
+ }
1006
+ }
1007
+
787
1008
  app.post("/workspace/open", async (req, res) => {
788
- const { root, workspaceId, repoUrl, token, branch = "main" } = req.body || {};
1009
+ const { root, workspaceId, repoUrl, token, branch = "main", repos } = req.body || {};
1010
+ // TENANT GATE (ROOT-RECOVERY): remember which tenant this clone is
1011
+ // hydrated for. Best-effort and additive - null means unknown, and
1012
+ // unknown NEVER cross-recovers by basename/scan (fail-closed).
1013
+ // OPEN GATE (2026-07-20): body.tenantId wins, X-Tenant-ID header is the
1014
+ // fallback, so EVERY branch below registers the tenant when the caller
1015
+ // sends it in either place.
1016
+ const openTenantId = extractOpenTenantId(req.body, req.headers);
1017
+
1018
+ // ==========================================
1019
+ // MULTI-REPO CLOUD MODE (SEG-3 + FEATURE):
1020
+ // Gateway sends repos:[{repoUrl,token?,branch?,dir?}] for a workspace.
1021
+ // All N repos are cloned UNDER ONE per-workspace dir, and that dir — the
1022
+ // PARENT of the repos — becomes the sandbox root. The agent's confineToRoot
1023
+ // then allows every repo of THIS workspace and denies anything outside it
1024
+ // (another workspace of the same tenant lives under a different <wsId> dir).
1025
+ // ==========================================
1026
+ if (Array.isArray(repos) && repos.length > 0) {
1027
+ const wsId = (workspaceId && workspaceId.trim()) ? workspaceId.trim() : null;
1028
+ if (!wsId) return res.status(400).json({ ok: false, error: "workspaceId is required for multi-repo open" });
1029
+ // Reject a workspaceId that would escape the cache dir (e.g. "../x").
1030
+ if (!/^[A-Za-z0-9._-]+$/.test(wsId)) {
1031
+ return res.status(400).json({ ok: false, error: "invalid workspaceId" });
1032
+ }
1033
+ // PATH CONVENTION (Phase C, reconciled 2026-09): multi-repo and single-repo
1034
+ // BOTH root under the same WORKSPACE_CACHE base (the NFS mount
1035
+ // /mnt/workspaces in QA/PROD; /tmp/workspaces only as the local-dev
1036
+ // fallback). Single-repo keys by <org_repo>; multi-repo keys by <wsId>
1037
+ // (a globally-unique workspace UUID) because it needs ONE parent dir to
1038
+ // hold N sibling repos. Keying by wsId - not tenantId/repoName - is the
1039
+ // isolation boundary the rest of this handler already enforces:
1040
+ // classifyOpenDestination() denies a dir REGISTERED to another tenant
1041
+ // (requestTenantId gate), and confineToRoot() below keeps every repo
1042
+ // inside this wsId dir, so a second workspace of the same tenant lives
1043
+ // under a different <wsId> and can never be reached. Nesting under
1044
+ // tenantId would add nothing (the UUID is already unique + tenant-gated)
1045
+ // while breaking isSafeToRemove()'s cacheDir/workspaceDir depth check.
1046
+ const cacheDir = process.env.WORKSPACE_CACHE || '/tmp/workspaces';
1047
+ const workspaceDir = path.join(cacheDir, wsId);
1048
+
1049
+ // OPEN GATE (2026-07-20): an open with repos[] is a HYDRATION request.
1050
+ // Never die on a stale dir a previous revision left behind (in-memory
1051
+ // registry lost on restart => orphan): remove the orphan and clone
1052
+ // fresh. A dir REGISTERED for another tenant is never touched - the
1053
+ // caller gets a clear 409 instead of a generic 500.
1054
+ const multiGate = classifyOpenDestination(workspaceDir, {
1055
+ registry: wsManager.list(),
1056
+ requestTenantId: openTenantId
1057
+ });
1058
+ if (multiGate.action === "deny") {
1059
+ console.warn(`[open-gate] DENY multi-repo open: ${workspaceDir} is registered for another tenant (ws '${multiGate.ownerWorkspaceId}') - request tenant '${openTenantId}'`);
1060
+ return res.status(409).json({
1061
+ ok: false,
1062
+ error: "workspace_dir_owned_by_another_tenant",
1063
+ hint: "The clone destination on this agent is registered for a different tenant. Use a distinct workspaceId."
1064
+ });
1065
+ }
1066
+ if (multiGate.action === "clean_and_fresh" && isSafeToRemove(cacheDir, workspaceDir)) {
1067
+ console.warn(`[open-gate] ORPHAN dir at ${workspaceDir} (no registered workspace) - removing stale clone and hydrating fresh`);
1068
+ await fsPromises.rm(workspaceDir, { recursive: true, force: true });
1069
+ }
1070
+ if (multiGate.action === "reuse" && multiGate.registration && multiGate.registration.source === "sidecar") {
1071
+ console.log(`[open-gate] REHYDRATED registration for ${workspaceDir} from sidecar (ws '${multiGate.ownerWorkspaceId}', tenant '${multiGate.ownerTenantId || "unknown"}') - reusing existing clones`);
1072
+ }
1073
+
1074
+ // Validate EVERY repo entry before touching the filesystem (400, nothing cloned).
1075
+ let plan;
1076
+ try {
1077
+ plan = planRepos(repos, { workspaceDir, confineToRoot });
1078
+ } catch (planErr) {
1079
+ return res.status(planErr.httpStatus || 400).json({ ok: false, error: planErr.message });
1080
+ }
1081
+
1082
+ await fsPromises.mkdir(workspaceDir, { recursive: true });
1083
+
1084
+ // REGISTRATION-FIRST (QA 2026-09-22): register the workspace dir (and
1085
+ // write its sidecar) BEFORE cloning. Previously registration happened
1086
+ // only after a fully successful loop, so any failed hydration left an
1087
+ // UNREGISTERED dir on disk; the next open classified it as an orphan,
1088
+ // rm -rf'd the N repos already cloned and re-cloned everything just to
1089
+ // fail on the same repo again (self-perpetuating, 10 clones per scan).
1090
+ try { await wsManager.open(wsId, workspaceDir, { tenantId: openTenantId }); } catch (err) {
1091
+ console.warn(`WorkspaceManager.open failed (non-fatal): ${err.message}`);
1092
+ }
1093
+
1094
+ // PARTIAL TOLERANCE (QA 2026-09-22): one repo the token cannot see
1095
+ // ("Repository not found") must not discard the other N-1 clones.
1096
+ // MULTI_REPO_ALLOW_PARTIAL=false restores the pre-fix fail-all loop.
1097
+ const allowPartial = multiRepoAllowPartial(process.env);
1098
+ let cloned, failed, firstError;
1099
+ try {
1100
+ ({ cloned, failed, firstError } = await hydrateRepos(plan, {
1101
+ cloneRepo: cloneOrUpdateRepo,
1102
+ detectProject,
1103
+ mkdir: (d) => fsPromises.mkdir(d, { recursive: true }),
1104
+ // a failed clone must not leave an empty per-repo dir behind
1105
+ // (an existing clone that failed to REFRESH keeps its .git).
1106
+ cleanupFailed: async (d) => {
1107
+ if (!(await exists(path.join(d, '.git')))) await fsPromises.rm(d, { recursive: true, force: true });
1108
+ },
1109
+ maskUrlCreds,
1110
+ token, branch, allowPartial, wsId
1111
+ }));
1112
+ } catch (gitErr) {
1113
+ // allowPartial=false: first failure aborts the loop (old behavior).
1114
+ cloned = []; failed = []; firstError = gitErr;
1115
+ }
1116
+
1117
+ if (cloned.length === 0) {
1118
+ const gitErr = firstError || new Error("no repo could be cloned");
1119
+ console.error(`[multi-repo] clone failed (0 of ${plan.length} cloned):`, maskUrlCreds(gitErr.message));
1120
+ // Nothing usable was hydrated. If THIS open created the dir
1121
+ // (fresh / cleaned orphan): unregister + remove it so it is neither
1122
+ // a registered-but-empty workspace nor an orphan on disk. On a
1123
+ // REUSE all-fail (e.g. transient network during refresh) the
1124
+ // previously hydrated clones stay registered and are retried next open.
1125
+ if (multiGate.action !== "reuse") {
1126
+ wsManager.close(wsId);
1127
+ if (isSafeToRemove(cacheDir, workspaceDir)) {
1128
+ await fsPromises.rm(workspaceDir, { recursive: true, force: true }).catch(() => {});
1129
+ }
1130
+ }
1131
+ // REFSPEC FIX: a missing/invalid REQUESTED branch is a clear client
1132
+ // error, not a generic 500 (fetchRequestedRef tags it).
1133
+ if (gitErr && (gitErr.code === "BRANCH_NOT_FOUND" || gitErr.code === "INVALID_BRANCH")) {
1134
+ return res.status(gitErr.httpStatus || 500).json({ ok: false, error: gitErr.message, ...(failed.length ? { failed } : {}) });
1135
+ }
1136
+ // SEG-1: the message embeds the authenticated clone URL — mask the
1137
+ // credential before it reaches the response body.
1138
+ return res.status(500).json({
1139
+ ok: false,
1140
+ error: maskUrlCreds(gitErr.message),
1141
+ hint: "Clone failed. Check repo URLs and tokens.",
1142
+ ...(failed.length ? { failed } : {})
1143
+ });
1144
+ }
1145
+
1146
+ // ENG-1: total files across ALL cloned repo dirs (excl. .git). 0 => every
1147
+ // repo of this workspace cloned empty => structural empty clone. We keep
1148
+ // ok:true and surface fileCount/empty so the GATEWAY hard-stops the run
1149
+ // (single decision point) instead of silently proceeding with 0 files.
1150
+ let fileCount = 0;
1151
+ try {
1152
+ for (const c of cloned) fileCount += countFilesExcludingGit(c.path);
1153
+ } catch (err) {
1154
+ console.warn(`countFilesExcludingGit failed (non-fatal): ${err.message}`);
1155
+ }
1156
+ if (fileCount === 0) {
1157
+ console.error(`[multi-repo] EMPTY CLONE: 0 files across ${cloned.length} repo(s) under ${wsId}`);
1158
+ }
1159
+ return res.json({
1160
+ ok: true,
1161
+ root: workspaceDir, // the sandbox root = the workspace dir (parent of the repos)
1162
+ workspaceId: wsId,
1163
+ mode: "cloud-multi-clone",
1164
+ repos: cloned,
1165
+ partial: failed.length > 0,
1166
+ failed, // [{dir, repoUrl (masked), error (masked)}]
1167
+ port: null,
1168
+ fileCount,
1169
+ ...(fileCount === 0 ? { empty: true } : {})
1170
+ });
1171
+ }
789
1172
 
790
1173
  // ==========================================
791
1174
  // CLOUD MODE: Git clone (when Gateway sends repoUrl)
792
1175
  // ==========================================
793
1176
  if (repoUrl) {
794
- console.log(`[/workspace/open] CLOUD MODE cloning ${repoUrl} (branch: ${branch})`);
1177
+ console.log(`[/workspace/open] CLOUD MODE cloning ${maskUrlCreds(repoUrl)} (branch: ${branch})`);
795
1178
 
796
1179
  try {
797
1180
  // Extract repo name: https://github.com/org/repo org_repo
@@ -808,6 +1191,32 @@ app.post("/workspace/open", async (req, res) => {
808
1191
  // Ensure cache directory exists
809
1192
  await fsPromises.mkdir(cacheDir, { recursive: true });
810
1193
 
1194
+ // OPEN GATE (2026-07-20, QA-proven): an open with repoUrl is a
1195
+ // HYDRATION request - it must never 500 on a stale clone dir left
1196
+ // by a previous revision (registry lost on restart => the dir is
1197
+ // an ORPHAN; its git state may be broken, e.g. "ambiguous
1198
+ // argument 'origin/main'" on the reuse path). Orphans are removed
1199
+ // and the repo is cloned fresh, then registered with the request
1200
+ // tenant. A dir REGISTERED for ANOTHER tenant is never touched:
1201
+ // clear 409 instead of a generic 500. Registered same/unknown
1202
+ // tenant keeps the byte-identical reuse/refresh path below.
1203
+ const openGate = classifyOpenDestination(clonePath, {
1204
+ registry: wsManager.list(),
1205
+ requestTenantId: openTenantId
1206
+ });
1207
+ if (openGate.action === "deny") {
1208
+ console.warn(`[open-gate] DENY open: ${clonePath} is registered for another tenant (ws '${openGate.ownerWorkspaceId}') - request tenant '${openTenantId}'`);
1209
+ return res.status(409).json({
1210
+ ok: false,
1211
+ error: "workspace_dir_owned_by_another_tenant",
1212
+ hint: "The clone destination on this agent is registered for a different tenant. Use a distinct workspaceId."
1213
+ });
1214
+ }
1215
+ if (openGate.action === "clean_and_fresh" && isSafeToRemove(cacheDir, clonePath)) {
1216
+ console.warn(`[open-gate] ORPHAN dir at ${clonePath} (no registered workspace) - removing stale clone and cloning fresh`);
1217
+ await fsPromises.rm(clonePath, { recursive: true, force: true });
1218
+ }
1219
+
811
1220
  // Build authenticated URL
812
1221
  // Token formats:
813
1222
  // GitHub: TOKEN (uses x-access-token:TOKEN@)
@@ -836,16 +1245,57 @@ app.post("/workspace/open", async (req, res) => {
836
1245
  // Update remote URL with fresh token
837
1246
  await execAsync(`git remote set-url origin "${authUrl}"`, { cwd: clonePath }).catch(() => {});
838
1247
  await execAsync(`git fetch origin`, { cwd: clonePath, timeout: 120000 });
1248
+ // REFSPEC FIX (QA 2026-07-20, PR #13 review FAILED) composed
1249
+ // with main's default-branch fallback (PR #4): the clone below
1250
+ // is --single-branch, so on reuse `git fetch origin` never
1251
+ // brings branches born AFTER the clone; the checkout error was
1252
+ // swallowed and `git reset --hard origin/<branch>` died with
1253
+ // "ambiguous argument ... unknown revision" -> 500. Fetch the
1254
+ // requested ref explicitly first; if the branch is absent on
1255
+ // the remote, fall back to the repo's real default branch.
1256
+ let effBranch = branch;
1257
+ try {
1258
+ await fetchRequestedRef(execAsync, branch, clonePath);
1259
+ } catch (refErr) {
1260
+ if (refErr && refErr.code === 'BRANCH_NOT_FOUND') {
1261
+ const defBranch = await resolveDefaultBranch(clonePath);
1262
+ if (defBranch) {
1263
+ console.warn(`[cloud] Branch '${branch}' not on remote — using default '${defBranch}'`);
1264
+ effBranch = defBranch;
1265
+ } else {
1266
+ throw refErr;
1267
+ }
1268
+ } else {
1269
+ throw refErr;
1270
+ }
1271
+ }
839
1272
  // Checkout correct branch and reset to remote (discard previous patches)
840
- await execAsync(`git checkout ${branch} 2>/dev/null || git checkout -b ${branch} origin/${branch}`, { cwd: clonePath }).catch(() => {});
841
- await execAsync(`git reset --hard origin/${branch}`, { cwd: clonePath });
842
- console.log(`[cloud] Updated to latest ${branch}`);
1273
+ await execAsync(`git checkout ${effBranch} 2>/dev/null || git checkout -b ${effBranch} origin/${effBranch}`, { cwd: clonePath }).catch(() => {});
1274
+ await execAsync(`git reset --hard origin/${effBranch}`, { cwd: clonePath });
1275
+ console.log(`[cloud] Updated to latest ${effBranch}`);
843
1276
  } else {
844
- console.log(`[cloud] Cloning ${repoUrl} (branch: ${branch})...`);
845
- await execAsync(
846
- `git clone --branch ${branch} --single-branch --depth 50 "${authUrl}" "${clonePath}"`,
847
- { timeout: 300000 }
848
- );
1277
+ console.log(`[cloud] Cloning ${maskUrlCreds(repoUrl)} (branch: ${branch})...`);
1278
+ try {
1279
+ await execAsync(
1280
+ `git clone --branch ${branch} --single-branch --depth 50 "${authUrl}" "${clonePath}"`,
1281
+ { timeout: 300000 }
1282
+ );
1283
+ } catch (cloneErr) {
1284
+ // Requested branch may not exist (e.g. repo uses master, caller assumed main).
1285
+ // Git phrases this as "Remote branch <x> not found in upstream origin" or
1286
+ // "could not find remote branch" depending on version — match both.
1287
+ // Fall back to the repo's default branch instead of failing the session.
1288
+ if (/(remote branch .* not found|could not find remote branch)/i.test(cloneErr.message || '')) {
1289
+ console.warn(`[cloud] Branch '${branch}' not found — falling back to the repo default branch`);
1290
+ await execAsync(`rm -rf "${clonePath}"`).catch(() => {});
1291
+ await execAsync(
1292
+ `git clone --single-branch --depth 50 "${authUrl}" "${clonePath}"`,
1293
+ { timeout: 300000 }
1294
+ );
1295
+ } else {
1296
+ throw cloneErr;
1297
+ }
1298
+ }
849
1299
  // Configure git user for future commits
850
1300
  await execAsync(`git config user.email "deepdebug-ai@deepdebug.ai"`, { cwd: clonePath });
851
1301
  await execAsync(`git config user.name "DeepDebug AI"`, { cwd: clonePath });
@@ -858,30 +1308,46 @@ app.post("/workspace/open", async (req, res) => {
858
1308
  WORKSPACE_ROOT = clonePath;
859
1309
  }
860
1310
  const wsId = workspaceId || "default";
861
- try { await wsManager.open(wsId, clonePath); } catch (err) {
1311
+ try { await wsManager.open(wsId, clonePath, { tenantId: openTenantId }); } catch (err) {
862
1312
  console.warn(`WorkspaceManager.open failed (non-fatal): ${err.message}`);
863
1313
  }
864
1314
 
865
1315
  const meta = await detectProject(clonePath);
866
1316
  const port = await detectPort(clonePath);
867
1317
 
1318
+ // ENG-1: files under the clone (excl. .git). 0 => empty repo / wrong
1319
+ // branch. Keep ok:true and report fileCount/empty; the GATEWAY makes
1320
+ // the hard-stop decision (avoids silent "NEEDS_REVIEW, 0 files read").
1321
+ const fileCount = countFilesExcludingGit(clonePath);
1322
+ if (fileCount === 0) {
1323
+ console.error(`[cloud] EMPTY CLONE: 0 files at ${clonePath} for ${maskUrlCreds(repoUrl)} (branch ${branch})`);
1324
+ }
1325
+
868
1326
  return res.json({
869
1327
  ok: true,
870
1328
  root: clonePath,
871
1329
  workspaceId: wsId,
872
1330
  mode: "cloud-clone",
873
1331
  meta,
874
- port
1332
+ port,
1333
+ fileCount,
1334
+ ...(fileCount === 0 ? { empty: true } : {})
875
1335
  });
876
1336
 
877
1337
  } catch (gitErr) {
878
- console.error(`[cloud] Git clone failed:`, gitErr.message);
1338
+ console.error(`[cloud] Git clone failed:`, maskUrlCreds(gitErr.message));
1339
+ // REFSPEC FIX: a missing/invalid REQUESTED branch is a clear client
1340
+ // error, not a generic 500 (fetchRequestedRef tags it).
1341
+ if (gitErr && (gitErr.code === "BRANCH_NOT_FOUND" || gitErr.code === "INVALID_BRANCH")) {
1342
+ return res.status(gitErr.httpStatus || 500).json({ ok: false, error: gitErr.message });
1343
+ }
879
1344
  const hint = gitErr.message.includes('Authentication') || gitErr.message.includes('could not read')
880
1345
  ? "Authentication failed. Check token and repo URL."
881
1346
  : gitErr.message.includes('not found') || gitErr.message.includes('does not exist')
882
1347
  ? "Repository not found. Check the URL."
883
1348
  : "Clone failed. Ensure the URL is accessible.";
884
- return res.status(500).json({ ok: false, error: gitErr.message, hint });
1349
+ // SEG-1: mask the authenticated clone URL in the response body too.
1350
+ return res.status(500).json({ ok: false, error: maskUrlCreds(gitErr.message), hint });
885
1351
  }
886
1352
  }
887
1353
 
@@ -900,7 +1366,7 @@ app.post("/workspace/open", async (req, res) => {
900
1366
  WORKSPACE_ROOT = abs;
901
1367
  }
902
1368
  try {
903
- await wsManager.open(wsId, abs);
1369
+ await wsManager.open(wsId, abs, { tenantId: openTenantId });
904
1370
  } catch (err) {
905
1371
  console.warn(`WorkspaceManager open failed (non-fatal): ${err.message}`);
906
1372
  }
@@ -931,8 +1397,20 @@ app.post("/workspace/clone", async (req, res) => {
931
1397
 
932
1398
  if (!gitUrl) return res.status(400).json({ ok: false, error: "gitUrl is required" });
933
1399
 
1400
+ // SECURITY: gitUrl/branch were interpolated into a shell string (command
1401
+ // injection via "$(...)" or ";"), tenantId into a filesystem path (../
1402
+ // traversal), and file:// / local paths were clonable (reads arbitrary
1403
+ // host repos). Validate, then run git via execFile (no shell).
1404
+ const gitUrlCheck = validateGitUrl(gitUrl);
1405
+ if (!gitUrlCheck.ok) return res.status(400).json({ ok: false, error: gitUrlCheck.reason });
1406
+ if (branch && !isSafeBranchName(branch)) return res.status(400).json({ ok: false, error: "invalid branch name" });
1407
+ if (tenantId && !isSafeTenantId(tenantId)) return res.status(400).json({ ok: false, error: "invalid tenantId" });
1408
+
934
1409
  // Strip ALL trailing .git suffixes (handles accidental double .git.git from frontend)
935
1410
  const repoName = gitUrl.split('/').pop().replace(/\.git$/i, '').replace(/\.git$/i, '');
1411
+ if (!repoName || repoName === '.' || repoName === '..' || /[\\/]/.test(repoName)) {
1412
+ return res.status(400).json({ ok: false, error: "cannot derive repository name from gitUrl" });
1413
+ }
936
1414
 
937
1415
  const NFS_MOUNT = '/mnt/workspaces';
938
1416
  let absTarget;
@@ -962,7 +1440,7 @@ app.post("/workspace/clone", async (req, res) => {
962
1440
  authenticatedUrl = cleanGitUrl.replace('https://', `https://x-access-token:${gitToken}@`);
963
1441
  }
964
1442
  }
965
- console.log(`Clone request: ${cleanGitUrl} -> ${absTarget}`);
1443
+ console.log(`Clone request: ${maskUrlCreds(cleanGitUrl)} -> ${absTarget}`);
966
1444
 
967
1445
  try {
968
1446
  // Ensure parent directory exists
@@ -978,18 +1456,18 @@ app.post("/workspace/clone", async (req, res) => {
978
1456
  console.log(`Repo already exists at ${absTarget}, updating branch: ${targetBranch}...`);
979
1457
  await execAsync('git fetch --all', { cwd: absTarget, timeout: 60000 });
980
1458
  try {
981
- await execAsync(`git checkout ${targetBranch}`, { cwd: absTarget, timeout: 30000 });
982
- const { stdout } = await execAsync(`git pull origin ${targetBranch}`, { cwd: absTarget, timeout: 120000 });
1459
+ await execFileAsync('git', ['checkout', targetBranch], { cwd: absTarget, timeout: 30000 });
1460
+ const { stdout } = await execFileAsync('git', ['pull', 'origin', targetBranch], { cwd: absTarget, timeout: 120000 });
983
1461
  console.log(`git pull: ${stdout.trim()}`);
984
1462
  } catch (checkoutErr) {
985
1463
  console.warn(`Checkout ${targetBranch} failed, staying on current branch: ${checkoutErr.message}`);
986
1464
  }
987
1465
  } else {
988
1466
  const safeLog = authenticatedUrl.replace(/x-token-auth:[^@]+@/g, 'x-token-auth:***@').replace(/oauth2:[^@]+@/g, 'oauth2:***@').replace(/x-access-token:[^@]+@/g, 'x-access-token:***@');
989
- const cloneCmd = branch ? `git clone -b ${branch} "${authenticatedUrl}" "${absTarget}"` : `git clone "${authenticatedUrl}" "${absTarget}"`;
1467
+ const cloneArgs = branch ? ['clone', '-b', branch, '--', authenticatedUrl, absTarget] : ['clone', '--', authenticatedUrl, absTarget];
990
1468
  console.log(`Running: git clone${branch ? ' -b ' + branch : ''} "${safeLog}" "${absTarget}"`);
991
1469
  const cloneEnvWorkspace = { ...process.env, GIT_TERMINAL_PROMPT: "0", HOME: process.env.HOME || "/tmp" };
992
- await execAsync(cloneCmd, { timeout: 300000, env: cloneEnvWorkspace });
1470
+ await execFileAsync('git', cloneArgs, { timeout: 300000, env: cloneEnvWorkspace });
993
1471
  console.log(`Clone complete: ${absTarget}`);
994
1472
  }
995
1473
 
@@ -999,7 +1477,9 @@ app.post("/workspace/clone", async (req, res) => {
999
1477
  WORKSPACE_ROOT = absTarget;
1000
1478
  }
1001
1479
  try {
1002
- await wsManager.open(wsId, absTarget);
1480
+ // TENANT GATE (ROOT-RECOVERY): register the tenant this clone is
1481
+ // hydrated for so recovery can refuse cross-tenant basename hits.
1482
+ await wsManager.open(wsId, absTarget, { tenantId: tenantId || null });
1003
1483
  } catch (err) {
1004
1484
  console.warn(`WorkspaceManager.open failed (non-fatal): ${err.message}`);
1005
1485
  }
@@ -1017,17 +1497,17 @@ app.post("/workspace/clone", async (req, res) => {
1017
1497
  });
1018
1498
 
1019
1499
  } catch (err) {
1020
- console.error(`Clone failed: ${err.message}`);
1500
+ console.error(`Clone failed: ${maskUrlCreds(err.message)}`);
1021
1501
  const hint = err.message.includes('Authentication') || err.message.includes('could not read')
1022
1502
  ? "Authentication failed. Ensure the repo is public or GitHub integration is configured."
1023
1503
  : err.message.includes('not found') || err.message.includes('does not exist')
1024
1504
  ? "Repository not found. Check the URL is correct."
1025
1505
  : "Clone failed. Ensure git is installed and the URL is accessible.";
1026
- res.status(500).json({ ok: false, error: err.message, hint });
1506
+ res.status(500).json({ ok: false, error: maskUrlCreds(err.message), hint });
1027
1507
  }
1028
1508
  });
1029
1509
 
1030
- /** Info do workspace */
1510
+ /** Workspace info */
1031
1511
  app.get("/workspace/info", async (req, res) => {
1032
1512
  const wsRoot = resolveWorkspaceRoot(req);
1033
1513
  if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
@@ -1036,7 +1516,7 @@ app.get("/workspace/info", async (req, res) => {
1036
1516
  res.json({ root: wsRoot, meta, port });
1037
1517
  });
1038
1518
 
1039
- /** Scan completo do workspace */
1519
+ /** Full workspace scan */
1040
1520
  app.get("/workspace/scan", async (req, res) => {
1041
1521
  const wsRoot = resolveWorkspaceRoot(req);
1042
1522
  if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
@@ -1082,17 +1562,42 @@ app.get("/workspace/analyze", async (req, res) => {
1082
1562
  }
1083
1563
  });
1084
1564
 
1085
- /** L contedo de arquivo especfico */
1565
+ /** Reads the content of a specific file */
1086
1566
  app.get("/workspace/file-content", async (req, res) => {
1087
1567
  const wsRoot = resolveWorkspaceRoot(req);
1088
1568
  if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
1089
1569
 
1570
+ // ROOT-RECOVERY fail-fast: the effective root does not exist on this agent
1571
+ // at all (dead persisted path, no live clone to recover). Tell the caller
1572
+ // EXACTLY what to do instead of a generic 404 the AI retries forever.
1573
+ if (!fs.existsSync(wsRoot)) {
1574
+ return res.status(404).json({
1575
+ error: "workspace_root_not_hydrated",
1576
+ root: wsRoot,
1577
+ hint: "This agent has no clone at that path. Run git_prepare_workspace "
1578
+ + "(POST /workspace/clone) for this workspace first, then retry."
1579
+ });
1580
+ }
1581
+
1090
1582
  const { path: relativePath } = req.query;
1091
1583
  if (!relativePath) return res.status(400).json({ error: "path query param required" });
1092
1584
 
1093
1585
  try {
1094
1586
  const reader = new FileReader(wsRoot);
1095
1587
  const file = await reader.read(relativePath);
1588
+ // RANGED READS (Claude Code model, 2026-07-19): huge files (this repo's
1589
+ // own server.js is 7500+ lines) used to be returned WHOLE, blowing the
1590
+ // AI's context/tool-result budget - the model then burned its whole
1591
+ // iteration budget extracting chunks via `sed` in run_command. Pure
1592
+ // slicing logic in utils/ranged-read.js (guard-tested); small files
1593
+ // without a range keep the byte-identical original response.
1594
+ const sliced = sliceFileContent(file, {
1595
+ startLine: parseInt(req.query.startLine, 10),
1596
+ endLine: parseInt(req.query.endLine, 10),
1597
+ lineCap: parseInt(process.env.FILE_CONTENT_LINE_CAP || "2000", 10)
1598
+ });
1599
+ if (sliced) return res.json(sliced);
1600
+
1096
1601
  res.json(file);
1097
1602
  } catch (err) {
1098
1603
  res.status(404).json({ error: "file not found", details: err.message });
@@ -1122,6 +1627,10 @@ app.post("/workspace/build-push", async (req, res) => {
1122
1627
  };
1123
1628
 
1124
1629
  try {
1630
+ if (!isSafeTenantId(workspaceId)) {
1631
+ emit("error", { error: "invalid workspaceId" });
1632
+ return res.end();
1633
+ }
1125
1634
  const nfsBase = process.env.WORKSPACES_MOUNT || "/mnt/workspaces";
1126
1635
  const wsPath = path.join(nfsBase, workspaceId);
1127
1636
 
@@ -1169,15 +1678,11 @@ app.post("/workspace/build-push", async (req, res) => {
1169
1678
 
1170
1679
  // Step 2: Compress workspace to tar.gz
1171
1680
  emit("progress", "Compressing source code...");
1172
- const { execSync } = await import("child_process");
1681
+ const { execFileSync } = await import("child_process");
1173
1682
  const tarPath = `/tmp/source-${workspaceId}-${Date.now()}.tar.gz`;
1174
1683
  try {
1175
- execSync(
1176
- `tar -czf "${tarPath}" --exclude=".git" --exclude="node_modules" ` +
1177
- `--exclude="target" --exclude="*.class" --exclude=".gradle" ` +
1178
- `-C "${wsPath}" . 2>&1`,
1179
- { stdio: "pipe", timeout: 120000 }
1180
- );
1684
+ // No shell: workspaceId is validated above and paths are argv entries.
1685
+ execFileSync("tar", buildTarArgs({ tarPath, wsPath }), { stdio: "pipe", timeout: 120000 });
1181
1686
  const sizeKB = Math.round(fs.statSync(tarPath).size / 1024);
1182
1687
  emit("progress", `Source compressed: ${sizeKB}KB`);
1183
1688
  } catch (e) {
@@ -1308,6 +1813,12 @@ app.post("/workspace/setup", async (req, res) => {
1308
1813
  if (!workspaceId || !repoUrl) {
1309
1814
  return res.status(400).json({ error: "workspaceId and repoUrl required" });
1310
1815
  }
1816
+ // SECURITY (2026-10-11): workspaceId became a path segment under the NFS
1817
+ // base (../ traversal + rmSync of the escaped dir) and repoUrl was
1818
+ // interpolated into a shell string. Validate, then run git via execFile.
1819
+ if (!isSafeTenantId(workspaceId)) return res.status(400).json({ error: "invalid workspaceId" });
1820
+ const repoUrlCheck = validateGitUrl(repoUrl);
1821
+ if (!repoUrlCheck.ok) return res.status(400).json({ error: repoUrlCheck.reason });
1311
1822
 
1312
1823
  res.setHeader("Content-Type", "text/event-stream");
1313
1824
  res.setHeader("Cache-Control", "no-cache");
@@ -1345,30 +1856,33 @@ app.post("/workspace/setup", async (req, res) => {
1345
1856
  authUrl = urlObj.toString();
1346
1857
  emit("progress", `Using authenticated clone for ${urlObj.hostname} (auth=${authUser})`);
1347
1858
  } catch {
1348
- authUrl = repoUrl.replace("https://", `https://${authUser}:${gitToken}@`);
1349
- emit("progress", `Using authenticated clone (fallback, auth=${authUser})`);
1859
+ emit("error", "repoUrl must be an https URL when a gitToken is supplied");
1860
+ return res.end();
1350
1861
  }
1351
1862
  }
1352
1863
 
1353
- const { execSync } = await import("child_process");
1864
+ const { execFileSync } = await import("child_process");
1354
1865
  const cloneEnv = { ...process.env, GIT_TERMINAL_PROMPT: "0", HOME: process.env.HOME || "/tmp" };
1355
1866
  const execOpts = { stdio: "pipe", timeout: 300000, encoding: "utf8", env: cloneEnv };
1867
+ // No shell: arguments are passed as an argv array.
1868
+ const execSync = (argv, o = execOpts) => execFileSync("git", argv, o);
1356
1869
  let cloneBranch = "main";
1357
1870
 
1358
1871
  // Configure safe directory
1359
- try { execSync(`git config --global --add safe.directory "${wsPath}"`, execOpts); } catch {}
1872
+ try { execSync(["config", "--global", "--add", "safe.directory", wsPath]); } catch {}
1360
1873
 
1361
1874
  if (fs.existsSync(path.join(wsPath, ".git"))) {
1362
1875
  emit("progress", "Workspace exists, updating...");
1363
1876
  try {
1364
1877
  if (gitToken && gitToken.length > 5) {
1365
- execSync(`git remote set-url origin "${authUrl}"`, { ...execOpts, cwd: wsPath });
1878
+ execSync(["remote", "set-url", "origin", authUrl], { ...execOpts, cwd: wsPath });
1366
1879
  }
1367
- execSync("git fetch origin", { ...execOpts, cwd: wsPath });
1368
- cloneBranch = execSync("git branch --show-current", { ...execOpts, cwd: wsPath }).trim() || "main";
1369
- execSync(`git reset --hard origin/${cloneBranch}`, { ...execOpts, cwd: wsPath });
1880
+ execSync(["fetch", "origin"], { ...execOpts, cwd: wsPath });
1881
+ cloneBranch = execSync(["branch", "--show-current"], { ...execOpts, cwd: wsPath }).trim() || "main";
1882
+ if (!isSafeBranchName(cloneBranch)) throw new Error("unsafe current branch name");
1883
+ execSync(["reset", "--hard", `origin/${cloneBranch}`], { ...execOpts, cwd: wsPath });
1370
1884
  } catch (e) {
1371
- const safeMsg = gitToken ? e.message.replace(gitToken, "***") : e.message;
1885
+ const safeMsg = maskUrlCreds(e.message);
1372
1886
  emit("progress", `Update failed (${safeMsg.substring(0, 100)}), re-cloning...`);
1373
1887
  fs.rmSync(wsPath, { recursive: true, force: true });
1374
1888
  }
@@ -1384,13 +1898,16 @@ app.post("/workspace/setup", async (req, res) => {
1384
1898
  for (const b of ["main", "master", "develop"]) {
1385
1899
  try {
1386
1900
  emit("progress", `Trying branch: ${b}...`);
1387
- execSync(`git clone --depth=1 --branch ${b} "${authUrl}" "${wsPath}"`, execOpts);
1901
+ execSync(["clone", "--depth=1", "--branch", b, "--", authUrl, wsPath]);
1388
1902
  cloneBranch = b;
1389
1903
  emit("progress", `Cloned on branch: ${b}`);
1390
1904
  break;
1391
1905
  } catch (e) {
1392
1906
  const stderr = (e.stderr || e.stdout || e.message || "").toString();
1393
- const safeMsg = gitToken ? stderr.replace(new RegExp(gitToken, 'g'), "***") : stderr;
1907
+ // maskUrlCreds is token-agnostic and cannot throw; the previous
1908
+ // new RegExp(gitToken) both missed non-URL repeats and could throw
1909
+ // on regex-special chars in the token.
1910
+ const safeMsg = maskUrlCreds(stderr);
1394
1911
  console.warn(`[Clone] Branch ${b} failed: ${safeMsg.substring(0, 500)}`);
1395
1912
  emit("progress", `Branch ${b} failed: ${safeMsg.substring(0, 200)}`);
1396
1913
  // Clean up partial clone
@@ -1402,12 +1919,15 @@ app.post("/workspace/setup", async (req, res) => {
1402
1919
  if (!fs.existsSync(path.join(wsPath, ".git"))) {
1403
1920
  try {
1404
1921
  emit("progress", "Trying default branch...");
1405
- execSync(`git clone --depth=1 "${authUrl}" "${wsPath}" 2>&1`, execOpts);
1406
- cloneBranch = execSync("git branch --show-current", { ...execOpts, cwd: wsPath }).trim() || "main";
1922
+ execSync(["clone", "--depth=1", "--", authUrl, wsPath]);
1923
+ cloneBranch = execSync(["branch", "--show-current"], { ...execOpts, cwd: wsPath }).trim() || "main";
1407
1924
  emit("progress", `Cloned on default branch: ${cloneBranch}`);
1408
1925
  } catch (e) {
1409
1926
  const stderr = (e.stderr || e.stdout || e.message || "").toString();
1410
- const safeMsg = gitToken ? stderr.replace(new RegExp(gitToken, 'g'), "***") : stderr;
1927
+ // maskUrlCreds is token-agnostic and cannot throw; the previous
1928
+ // new RegExp(gitToken) both missed non-URL repeats and could throw
1929
+ // on regex-special chars in the token.
1930
+ const safeMsg = maskUrlCreds(stderr);
1411
1931
  console.error(`[Clone] Default branch failed: ${safeMsg.substring(0, 500)}`);
1412
1932
  emit("progress", `Default branch failed: ${safeMsg.substring(0, 200)}`);
1413
1933
  if (fs.existsSync(wsPath)) fs.rmSync(wsPath, { recursive: true, force: true });
@@ -1491,7 +2011,7 @@ app.post("/workspace/detect", async (req, res) => {
1491
2011
  }
1492
2012
  });
1493
2013
 
1494
- /** Escreve/salva conteudo de arquivo no workspace */
2014
+ /** Writes/saves file content to the workspace */
1495
2015
  app.post("/workspace/write-file", async (req, res) => {
1496
2016
  const workspaceRoot = resolveWorkspaceRoot(req);
1497
2017
  if (!workspaceRoot) return res.status(400).json({ error: "workspace not set" });
@@ -1501,11 +2021,17 @@ app.post("/workspace/write-file", async (req, res) => {
1501
2021
  if (fileContent === undefined || fileContent === null) return res.status(400).json({ error: "content is required" });
1502
2022
 
1503
2023
  const pathMod = await import('path');
1504
- const fullPath = pathMod.default.resolve(workspaceRoot, relativePath);
1505
-
1506
- // Security: prevent path traversal outside workspace root
1507
- if (!fullPath.startsWith(workspaceRoot)) {
1508
- return res.status(403).json({ error: "Path traversal not allowed" });
2024
+ // SEG-3: realpath + separator-aware containment (the old `startsWith(root)`
2025
+ // let siblings like `<root>-2` and `..`-traversal through).
2026
+ let fullPath;
2027
+ try {
2028
+ fullPath = confineToRoot(workspaceRoot, relativePath);
2029
+ } catch (e) {
2030
+ if (e instanceof SandboxViolation) {
2031
+ console.warn(`[WRITE-FILE] SANDBOX DENIED: ${relativePath} (${e.details?.resolved})`);
2032
+ return res.status(403).json({ error: "path escapes the workspace sandbox" });
2033
+ }
2034
+ throw e;
1509
2035
  }
1510
2036
 
1511
2037
  try {
@@ -1520,7 +2046,7 @@ app.post("/workspace/write-file", async (req, res) => {
1520
2046
 
1521
2047
 
1522
2048
 
1523
- /** L mltiplos arquivos */
2049
+ /** Reads multiple files */
1524
2050
  app.post("/workspace/batch-read", async (req, res) => {
1525
2051
  const wsRoot = resolveWorkspaceRoot(req);
1526
2052
  if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
@@ -1559,7 +2085,7 @@ app.get("/workspace/file-exists", async (req, res) => {
1559
2085
  }
1560
2086
 
1561
2087
  try {
1562
- const fullPath = path.join(wsRoot, relativePath);
2088
+ const fullPath = confineToRoot(wsRoot, relativePath); // SEG-3
1563
2089
  const fileExists = await exists(fullPath);
1564
2090
 
1565
2091
  console.log(`[file-exists] ${relativePath} -> ${fileExists ? 'EXISTS' : 'NOT FOUND'}`);
@@ -1572,6 +2098,9 @@ app.get("/workspace/file-exists", async (req, res) => {
1572
2098
  });
1573
2099
  } catch (err) {
1574
2100
  console.error(`[file-exists] Error:`, err.message);
2101
+ if (err instanceof SandboxViolation) {
2102
+ return res.status(403).json({ ok: false, error: "path escapes the workspace sandbox" });
2103
+ }
1575
2104
  res.status(500).json({ ok: false, error: err.message });
1576
2105
  }
1577
2106
  });
@@ -1594,7 +2123,7 @@ app.post("/workspace/validate-paths", async (req, res) => {
1594
2123
  try {
1595
2124
  const results = await Promise.all(
1596
2125
  pathList.map(async (relativePath) => {
1597
- const fullPath = path.join(wsRoot, relativePath);
2126
+ const fullPath = confineToRoot(wsRoot, relativePath); // SEG-3
1598
2127
  const fileExists = await exists(fullPath);
1599
2128
  return { path: relativePath, exists: fileExists };
1600
2129
  })
@@ -1618,6 +2147,50 @@ app.post("/workspace/validate-paths", async (req, res) => {
1618
2147
  }
1619
2148
  });
1620
2149
 
2150
+ /**
2151
+ * GET /workspace/detect-stack
2152
+ * PASSO 0 of the fix validation ladder: resolve {stack, tier, language,
2153
+ * buildTool} for this workspace so the engine can pick validators without
2154
+ * any language logic of its own.
2155
+ */
2156
+ app.get("/workspace/detect-stack", async (req, res) => {
2157
+ const wsRoot = resolveWorkspaceRoot(req);
2158
+ if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
2159
+ try {
2160
+ const result = await detectBuildTool(wsRoot);
2161
+ console.log(`[detect-stack] ${wsRoot} -> stack=${result.stack} tier=${result.tier}`);
2162
+ res.json({ ok: true, ...result });
2163
+ } catch (err) {
2164
+ console.error("[detect-stack] Error:", err.message);
2165
+ res.status(500).json({ ok: false, error: err.message });
2166
+ }
2167
+ });
2168
+
2169
+ /**
2170
+ * POST /workspace/validate { paths: [relativePath...] }
2171
+ * Camada 2 (SyntaxValidator) of the fix validation ladder: parse-only
2172
+ * validation of the given files. Per-extension resolution (typescript for
2173
+ * JS/TS, tree-sitter for Swift); files without a validator come back as
2174
+ * {validator:"none", ok:true} — the tier degrades, it never blocks.
2175
+ */
2176
+ app.post("/workspace/validate", async (req, res) => {
2177
+ const wsRoot = resolveWorkspaceRoot(req);
2178
+ if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
2179
+ const { paths: pathList } = req.body || {};
2180
+ if (!pathList || !Array.isArray(pathList)) {
2181
+ return res.status(400).json({ error: "paths array required in body" });
2182
+ }
2183
+ try {
2184
+ const results = await validateFiles(wsRoot, pathList.slice(0, 50));
2185
+ const failed = results.filter(r => !r.ok);
2186
+ console.log(`[validate] ${results.length} file(s), ${failed.length} with syntax problems`);
2187
+ res.json({ ok: true, allValid: failed.length === 0, results });
2188
+ } catch (err) {
2189
+ console.error("[validate] Error:", err.message);
2190
+ res.status(500).json({ ok: false, error: err.message });
2191
+ }
2192
+ });
2193
+
1621
2194
  /**
1622
2195
  * POST /workspace/search-file
1623
2196
  * Searches for a file by name in the workspace
@@ -1742,7 +2315,7 @@ app.post("/workspace/search-by-content", async (req, res) => {
1742
2315
  if (results.length >= maxResults * 2) break;
1743
2316
 
1744
2317
  try {
1745
- const fullPath = path.join(wsRoot, filePath);
2318
+ const fullPath = confineToRoot(wsRoot, filePath); // SEG-3
1746
2319
  const content = await readFile(fullPath, 'utf8');
1747
2320
  const lines = content.split('\n');
1748
2321
 
@@ -1838,7 +2411,7 @@ app.post("/workspace/find-field-definition", async (req, res) => {
1838
2411
 
1839
2412
  for (const filePath of targetFiles) {
1840
2413
  try {
1841
- const fullPath = path.join(wsRoot, filePath);
2414
+ const fullPath = confineToRoot(wsRoot, filePath); // SEG-3
1842
2415
  const content = await readFile(fullPath, 'utf8');
1843
2416
  const lines = content.split('\n');
1844
2417
 
@@ -1910,7 +2483,7 @@ app.post("/workspace/find-field-definition", async (req, res) => {
1910
2483
  // RUNTIME MANAGEMENT ENDPOINTS
1911
2484
  // ============================================
1912
2485
 
1913
- /** Detecta servios no workspace */
2486
+ /** Detects services in the workspace */
1914
2487
  app.get("/workspace/services/detect", async (req, res) => {
1915
2488
  const wsRoot = resolveWorkspaceRoot(req);
1916
2489
  if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
@@ -1947,12 +2520,12 @@ app.get("/workspace/services/detect", async (req, res) => {
1947
2520
  }
1948
2521
  });
1949
2522
 
1950
- /** Lista todos os servios */
2523
+ /** Lists all services */
1951
2524
  app.get("/workspace/services", (req, res) => {
1952
2525
  const wsRoot = resolveWorkspaceRoot(req);
1953
2526
  if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
1954
2527
 
1955
- // Atualizar status dos servios com info do ProcessManager
2528
+ // Update the service statuses with info from the ProcessManager
1956
2529
  const servicesWithStatus = DETECTED_SERVICES.map(service => {
1957
2530
  const status = processManager.getStatus(service.id);
1958
2531
  return {
@@ -1964,7 +2537,7 @@ app.get("/workspace/services", (req, res) => {
1964
2537
  res.json({ services: servicesWithStatus });
1965
2538
  });
1966
2539
 
1967
- /** Inicia um servio */
2540
+ /** Starts a service */
1968
2541
  app.post("/workspace/services/:serviceId/start", async (req, res) => {
1969
2542
  const wsRoot = resolveWorkspaceRoot(req);
1970
2543
  if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
@@ -1991,7 +2564,7 @@ app.post("/workspace/services/:serviceId/start", async (req, res) => {
1991
2564
  }
1992
2565
  });
1993
2566
 
1994
- /** Para um servio */
2567
+ /** Stops a service */
1995
2568
  app.post("/workspace/services/:serviceId/stop", async (req, res) => {
1996
2569
  const wsRoot = resolveWorkspaceRoot(req);
1997
2570
  if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
@@ -2006,7 +2579,7 @@ app.post("/workspace/services/:serviceId/stop", async (req, res) => {
2006
2579
  }
2007
2580
  });
2008
2581
 
2009
- /** Retorna status de um servio */
2582
+ /** Returns the status of a service */
2010
2583
  app.get("/workspace/services/:serviceId/status", (req, res) => {
2011
2584
  const wsRoot = resolveWorkspaceRoot(req);
2012
2585
  if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
@@ -2017,7 +2590,7 @@ app.get("/workspace/services/:serviceId/status", (req, res) => {
2017
2590
  res.json({ serviceId, ...status });
2018
2591
  });
2019
2592
 
2020
- /** Retorna logs de um servio */
2593
+ /** Returns the logs of a service */
2021
2594
  app.get("/workspace/services/:serviceId/logs", (req, res) => {
2022
2595
  const wsRoot = resolveWorkspaceRoot(req);
2023
2596
  if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
@@ -2045,7 +2618,7 @@ app.get("/workspace/services/:serviceId/logs/stream", (req, res) => {
2045
2618
  res.setHeader("Cache-Control", "no-cache");
2046
2619
  res.setHeader("Connection", "keep-alive");
2047
2620
 
2048
- // Enviar logs existentes
2621
+ // Send existing logs
2049
2622
  const existingLogs = processManager.getLogs(serviceId);
2050
2623
  existingLogs.forEach(log => {
2051
2624
  res.write(`data: ${JSON.stringify(log)}\n\n`);
@@ -2060,7 +2633,7 @@ app.get("/workspace/services/:serviceId/logs/stream", (req, res) => {
2060
2633
 
2061
2634
  processManager.on("log", logHandler);
2062
2635
 
2063
- // Cleanup ao fechar conexo
2636
+ // Cleanup when the connection closes
2064
2637
  req.on("close", () => {
2065
2638
  processManager.off("log", logHandler);
2066
2639
  });
@@ -2073,9 +2646,28 @@ app.get("/workspace/services/:serviceId/logs/stream", (req, res) => {
2073
2646
  app.get("/workspace/files", async (req, res) => {
2074
2647
  const wsRoot = resolveWorkspaceRoot(req);
2075
2648
  if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
2076
- const { max = 5000 } = req.query;
2077
- const tree = await listRecursive(wsRoot, { maxFiles: Number(max) });
2078
- res.json({ root: wsRoot, count: tree.length, tree });
2649
+ // CRASH-LOOP FIX (2026-07-20): this handler awaited listRecursive with NO
2650
+ // try/catch. A dead root (indexer sending the NFS-era /mnt path) made the
2651
+ // scandir ENOENT an UNHANDLED promise rejection - Node 20 kills the whole
2652
+ // process, Cloud Run restarts it, the indexer retries, and the single QA
2653
+ // agent instance crash-looped 503s for every tenant. Fail fast with the
2654
+ // structured hint instead; and any other walk error answers 500 JSON -
2655
+ // never a dead process.
2656
+ if (!fs.existsSync(wsRoot)) {
2657
+ return res.status(404).json({
2658
+ error: "workspace_root_not_hydrated",
2659
+ root: wsRoot,
2660
+ hint: "This agent has no clone at that path. Run git_prepare_workspace "
2661
+ + "(POST /workspace/clone) for this workspace first, then retry."
2662
+ });
2663
+ }
2664
+ try {
2665
+ const { max = 5000 } = req.query;
2666
+ const tree = await listRecursive(wsRoot, { maxFiles: Number(max) });
2667
+ res.json({ root: wsRoot, count: tree.length, tree });
2668
+ } catch (err) {
2669
+ res.status(500).json({ error: "workspace walk failed", details: String(err && err.message) });
2670
+ }
2079
2671
  });
2080
2672
 
2081
2673
  app.get("/workspace/file", async (req, res) => {
@@ -2097,7 +2689,7 @@ app.post("/workspace/write", async (req, res) => {
2097
2689
  const { path: rel, content } = req.body || {};
2098
2690
  if (!rel) return res.status(400).json({ error: "path is required" });
2099
2691
  try {
2100
- const fullPath = path.join(wsRoot, rel);
2692
+ const fullPath = confineToRoot(wsRoot, rel); // SEG-3
2101
2693
  await fsPromises.mkdir(path.dirname(fullPath), { recursive: true });
2102
2694
  await writeFile(fullPath, content ?? "", "utf8");
2103
2695
  res.json({ ok: true, path: rel, bytes: Buffer.byteLength(content ?? "", "utf8") });
@@ -2158,11 +2750,64 @@ app.post("/workspace/test", async (req, res) => {
2158
2750
  res.json({ root: wsRoot, meta, result });
2159
2751
  });
2160
2752
 
2753
+ /**
2754
+ * POST /workspace/compile (v2 — added 2026-06-03 for BYOC Phase 7.5+8)
2755
+ *
2756
+ * Compile-only path. Sibling of /workspace/test but with skipTests=true so
2757
+ * the BYOC gateway can validate the build without paying the test runtime.
2758
+ *
2759
+ * The gateway's LocalAgentClient.compileAt + the operator-side compile path
2760
+ * both target this endpoint. local-agent-full:v1 did NOT expose it, so
2761
+ * 75-04 added an adapter that routed compile -> /workspace/test instead;
2762
+ * once :v2 ships everywhere we can flip compileAt back to this URL.
2763
+ *
2764
+ * Response shape matches /workspace/test:
2765
+ * { root, meta, result: { code, stdout, stderr, duration } }
2766
+ * plus top-level { ok, message, output, durationMs } convenience fields
2767
+ * for callers that consume the simple shape.
2768
+ */
2769
+ app.post("/workspace/compile", async (req, res) => {
2770
+ const wsRoot = resolveWorkspaceRoot(req);
2771
+ if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
2772
+ const meta = await detectProject(wsRoot);
2773
+ const result = await compileAndTest({
2774
+ language: meta.language,
2775
+ buildTool: meta.buildTool,
2776
+ cwd: wsRoot,
2777
+ skipTests: true,
2778
+ });
2779
+ const ok = result?.code === 0;
2780
+ res.json({
2781
+ ok,
2782
+ message: ok ? "compile succeeded" : "compile failed",
2783
+ output: result?.stdout || result?.stderr || "",
2784
+ durationMs: result?.duration || 0,
2785
+ root: wsRoot,
2786
+ meta,
2787
+ result,
2788
+ });
2789
+ });
2790
+
2161
2791
  app.post("/workspace/run", async (req, res) => {
2162
2792
  const wsRoot = resolveWorkspaceRoot(req);
2163
2793
  if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
2164
2794
  const { cmd, args = [] } = req.body || {};
2165
2795
  if (!cmd) return res.status(400).json({ error: "cmd is required" });
2796
+ // SEG-3: cwd is pinned to the workspace root; reject any path argument that
2797
+ // resolves outside it (e.g. `cat ../<other-workspace>/x`).
2798
+ const argCheck = checkArgsWithinRoot(wsRoot, [cmd, ...args]);
2799
+ if (!argCheck.ok) {
2800
+ console.warn(`[workspace/run] SANDBOX DENIED arg: ${argCheck.arg} (${argCheck.resolved})`);
2801
+ return res.status(403).json({ error: "command argument escapes the workspace sandbox", arg: argCheck.arg });
2802
+ }
2803
+ const runDecision = decideExec({
2804
+ argv: [cmd, ...args].map(String), root: wsRoot, headers: req.headers, body: req.body || {},
2805
+ vhSignature: req.vhSignature, signingCfg: SIGNING,
2806
+ }, EXEC_POLICY);
2807
+ if (!runDecision.allow) {
2808
+ console.warn(`[workspace/run] POLICY DENIED: ${runDecision.reason}`);
2809
+ return res.status(runDecision.status || 403).json(rejectionBody(runDecision));
2810
+ }
2166
2811
  const out = await run(cmd, args, wsRoot, 5 * 60 * 1000);
2167
2812
  res.json(out);
2168
2813
  });
@@ -2171,16 +2816,9 @@ app.post("/workspace/run", async (req, res) => {
2171
2816
  // TEST LOCAL ENDPOINTS
2172
2817
  // ============================================
2173
2818
 
2174
- /** Store test local state */
2175
- let TEST_LOCAL_STATE = {
2176
- status: "idle", // idle, compiling, compiled, starting, running, stopped, error
2177
- compilationResult: null,
2178
- serverProcess: null,
2179
- endpoints: [],
2180
- config: null,
2181
- testResults: [],
2182
- serverLogs: [] // Buffer circular de logs do servidor (ltimos 1000)
2183
- };
2819
+ // TEST_LOCAL_STATE is declared once, early (see above) - it must exist before
2820
+ // the ProcessManager event listeners run. The endpoints below mutate that same
2821
+ // single object.
2184
2822
 
2185
2823
  // ============================================
2186
2824
  // TEST LOCAL STATE ENDPOINTS (ADDED)
@@ -2239,19 +2877,19 @@ app.post("/workspace/git/prepare", async (req, res) => {
2239
2877
  console.log(`[git-prepare] Starting workspace preparation: wsRoot=${wsRoot} repoUrl=${repoUrl || 'none'}`);
2240
2878
 
2241
2879
  try {
2242
- const { execSync } = await import("child_process");
2880
+ const { execFileSync } = await import("child_process");
2243
2881
  const execOpts = { encoding: "utf8", timeout: 120000, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } };
2244
- const execGit = (cmd) => {
2882
+ // No shell: `cmd` is an argv array (git is the executable).
2883
+ const execSync = (argv, o = execOpts) => execFileSync("git", argv, o);
2884
+ const execGit = (argv) => {
2245
2885
  try {
2246
- return { ok: true, output: execSync(cmd, {
2247
- cwd: wsRoot, ...execOpts
2248
- }).trim() };
2886
+ return { ok: true, output: execSync(argv, { cwd: wsRoot, ...execOpts }).trim() };
2249
2887
  } catch (err) {
2250
2888
  return { ok: false, output: (err.stderr || err.stdout || err.message || "unknown").toString().trim() };
2251
2889
  }
2252
2890
  };
2253
2891
 
2254
- execGit(`git config --global --add safe.directory "${wsRoot}"`);
2892
+ execGit(["config", "--global", "--add", "safe.directory", wsRoot]);
2255
2893
 
2256
2894
  // If workspace is empty (no .git), clone first before attempting fetch/reset
2257
2895
  const gitDir = path.join(wsRoot, ".git");
@@ -2259,9 +2897,13 @@ app.post("/workspace/git/prepare", async (req, res) => {
2259
2897
  if (!repoUrl) {
2260
2898
  return res.json({ ok: false, error: "Workspace is empty and no repoUrl provided for clone" });
2261
2899
  }
2262
- console.log(`[git-prepare] No .git found at ${wsRoot}, cloning from ${repoUrl}`);
2900
+ console.log(`[git-prepare] No .git found at ${wsRoot}, cloning from ${maskUrlCreds(repoUrl)}`);
2263
2901
  fs.mkdirSync(wsRoot, { recursive: true });
2264
2902
 
2903
+ // SECURITY (2026-10-11): repoUrl/branch reached a shell string.
2904
+ const prepUrlCheck = validateGitUrl(repoUrl);
2905
+ if (!prepUrlCheck.ok) return res.status(400).json({ ok: false, error: prepUrlCheck.reason });
2906
+ if (reqBranch && !isSafeBranchName(reqBranch)) return res.status(400).json({ ok: false, error: "invalid branch name" });
2265
2907
  // Build authenticated URL
2266
2908
  let authUrl = repoUrl;
2267
2909
  if (gitToken) {
@@ -2283,24 +2925,24 @@ app.post("/workspace/git/prepare", async (req, res) => {
2283
2925
  try {
2284
2926
  fs.rmSync(wsRoot, { recursive: true, force: true });
2285
2927
  fs.mkdirSync(wsRoot, { recursive: true });
2286
- execSync(`git clone --depth 50 --branch "${b}" "${authUrl}" "${wsRoot}"`, execOpts);
2928
+ execSync(["clone", "--depth", "50", "--branch", b, "--", authUrl, wsRoot]);
2287
2929
  clonedBranch = b;
2288
2930
  cloned = true;
2289
2931
  console.log(`[git-prepare] Cloned branch ${b} successfully`);
2290
2932
  break;
2291
2933
  } catch (cloneErr) {
2292
- console.warn(`[git-prepare] Clone branch ${b} failed: ${(cloneErr.message || "").slice(0, 100)}`);
2934
+ console.warn(`[git-prepare] Clone branch ${b} failed: ${maskUrlCreds(cloneErr.message || "").slice(0, 100)}`);
2293
2935
  }
2294
2936
  }
2295
2937
  if (!cloned) {
2296
2938
  try {
2297
2939
  fs.rmSync(wsRoot, { recursive: true, force: true });
2298
2940
  fs.mkdirSync(wsRoot, { recursive: true });
2299
- execSync(`git clone --depth 50 "${authUrl}" "${wsRoot}"`, execOpts);
2941
+ execSync(["clone", "--depth", "50", "--", authUrl, wsRoot]);
2300
2942
  cloned = true;
2301
2943
  clonedBranch = "default";
2302
2944
  } catch (e) {
2303
- return res.json({ ok: false, error: "Clone failed: " + (e.message || "").slice(0, 200) });
2945
+ return res.json({ ok: false, error: "Clone failed: " + maskUrlCreds(e.message || "").slice(0, 200) });
2304
2946
  }
2305
2947
  }
2306
2948
 
@@ -2308,9 +2950,17 @@ app.post("/workspace/git/prepare", async (req, res) => {
2308
2950
  WORKSPACE_ROOT = wsRoot;
2309
2951
  try { await wsManager.open(workspaceId || path.basename(wsRoot), wsRoot); } catch (_) {}
2310
2952
 
2311
- const commit = execGit("git rev-parse --short HEAD");
2312
- let fileCount = 0;
2313
- try { function walk(d) { for (const e of fs.readdirSync(d, {withFileTypes:true})) { if (e.name==='.git') continue; if (e.isDirectory()) walk(path.join(d,e.name)); else fileCount++; } } walk(wsRoot); } catch (_) {}
2953
+ const commit = execGit(["rev-parse", "--short", "HEAD"]);
2954
+ const fileCount = countFilesExcludingGit(wsRoot);
2955
+ // ENG-1 HARD-STOP: a clone that yields 0 files (excl. .git) is a
2956
+ // structural failure (empty repo, wrong/empty branch, bad path).
2957
+ // Fail closed instead of returning ok:true — the previous behaviour
2958
+ // let the run continue and die later as "NEEDS_REVIEW, 0 files read".
2959
+ if (fileCount === 0) {
2960
+ console.error(`[git-prepare] EMPTY CLONE: 0 files after cloning branch ${clonedBranch} from ${maskUrlCreds(repoUrl)}`);
2961
+ return res.json({ ok: false, empty: true, fileCount: 0, reason: "empty_clone",
2962
+ error: "Cloned repository has no files (empty repo or wrong branch)" });
2963
+ }
2314
2964
  return res.json({ ok: true, cloned: true, mainBranch: clonedBranch,
2315
2965
  currentCommit: commit.ok ? commit.output : "unknown", fileCount,
2316
2966
  message: `Repository cloned: ${clonedBranch} (${fileCount} files)` });
@@ -2326,48 +2976,56 @@ app.post("/workspace/git/prepare", async (req, res) => {
2326
2976
  } else {
2327
2977
  authUrl = repoUrl.replace(/https:\/\/([^@]*@)?/, `https://x-access-token:${gitToken}@`);
2328
2978
  }
2329
- execGit(`git remote set-url origin "${authUrl}"`);
2979
+ execGit(["remote", "set-url", "origin", authUrl]);
2330
2980
  }
2331
2981
 
2332
2982
  console.log("[git-prepare] Fetching origin...");
2333
- execGit("git fetch origin --prune");
2983
+ execGit(["fetch", "origin", "--prune"]);
2334
2984
 
2335
2985
  let mainBranch = null;
2336
2986
  for (const branch of ["main", "master", "develop", "dev"]) {
2337
- if (execGit(`git rev-parse --verify origin/${branch}`).ok) {
2987
+ if (execGit(["rev-parse", "--verify", `origin/${branch}`]).ok) {
2338
2988
  mainBranch = branch;
2339
2989
  break;
2340
2990
  }
2341
2991
  }
2342
2992
  if (!mainBranch) {
2343
- const cur = execGit("git rev-parse --abbrev-ref HEAD");
2344
- mainBranch = cur.ok ? cur.output : "main";
2993
+ const cur = execGit(["rev-parse", "--abbrev-ref", "HEAD"]);
2994
+ mainBranch = cur.ok && isSafeBranchName(cur.output) ? cur.output : "main";
2345
2995
  }
2346
2996
 
2347
- const prevBranch = (execGit("git rev-parse --abbrev-ref HEAD").output || "unknown");
2348
- const prevCommit = (execGit("git rev-parse --short HEAD").output || "unknown");
2997
+ const prevBranch = (execGit(["rev-parse", "--abbrev-ref", "HEAD"]).output || "unknown");
2998
+ const prevCommit = (execGit(["rev-parse", "--short", "HEAD"]).output || "unknown");
2349
2999
 
2350
3000
  console.log(`[git-prepare] ${prevBranch}@${prevCommit} -> ${mainBranch}`);
2351
3001
 
2352
- let checkout = execGit(`git checkout ${mainBranch}`);
2353
- if (!checkout.ok) checkout = execGit(`git checkout -f ${mainBranch}`);
3002
+ let checkout = execGit(["checkout", mainBranch]);
3003
+ if (!checkout.ok) checkout = execGit(["checkout", "-f", mainBranch]);
2354
3004
  if (!checkout.ok) {
2355
3005
  return res.json({ ok: false, error: `Could not checkout ${mainBranch}: ${checkout.output}`,
2356
3006
  mainBranch, previousBranch: prevBranch, previousCommit: prevCommit });
2357
3007
  }
2358
3008
 
2359
- execGit(`git reset --hard origin/${mainBranch}`);
2360
- execGit('git clean -fd --exclude="*.env" --exclude=".env*"');
3009
+ execGit(["reset", "--hard", `origin/${mainBranch}`]);
3010
+ execGit(["clean", "-fd", "--exclude=*.env", "--exclude=.env*"]);
2361
3011
 
2362
- const newCommit = (execGit("git rev-parse --short HEAD").output || "unknown");
2363
- console.log(`[git-prepare] Ready: ${mainBranch}@${newCommit}`);
3012
+ const newCommit = (execGit(["rev-parse", "--short", "HEAD"]).output || "unknown");
3013
+ // ENG-1 HARD-STOP: same structural check on the fetch/reset path — an
3014
+ // already-cloned workspace that holds 0 files (excl. .git) is empty.
3015
+ const fileCount = countFilesExcludingGit(wsRoot);
3016
+ if (fileCount === 0) {
3017
+ console.error(`[git-prepare] EMPTY WORKSPACE: 0 files after prepare on ${mainBranch}@${newCommit}`);
3018
+ return res.json({ ok: false, empty: true, fileCount: 0, reason: "empty_clone",
3019
+ error: "Prepared workspace has no files (empty repo or wrong branch)" });
3020
+ }
3021
+ console.log(`[git-prepare] Ready: ${mainBranch}@${newCommit} (${fileCount} files)`);
2364
3022
 
2365
- res.json({ ok: true, mainBranch, currentCommit: newCommit,
3023
+ res.json({ ok: true, mainBranch, currentCommit: newCommit, fileCount,
2366
3024
  previousBranch: prevBranch, previousCommit: prevCommit,
2367
3025
  message: `Workspace prepared: on ${mainBranch} at ${newCommit}` });
2368
3026
  } catch (err) {
2369
- console.error("[git-prepare] Error:", err.message);
2370
- res.status(500).json({ ok: false, error: err.message });
3027
+ console.error("[git-prepare] Error:", maskUrlCreds(err.message));
3028
+ res.status(500).json({ ok: false, error: maskUrlCreds(err.message) });
2371
3029
  }
2372
3030
  });
2373
3031
 
@@ -2475,7 +3133,7 @@ app.post("/workspace/test-local/start", async (req, res) => {
2475
3133
  // Detect port if not provided
2476
3134
  let serverPort = port || 8080;
2477
3135
 
2478
- // Para Java, encontrar o JAR e correr directamente
3136
+ // For Java, find the JAR and run it directly
2479
3137
  if (meta.language === 'java') {
2480
3138
  const targetDir = path.join(wsRoot, 'target');
2481
3139
 
@@ -2497,19 +3155,19 @@ app.post("/workspace/test-local/start", async (req, res) => {
2497
3155
 
2498
3156
  const jarPath = path.join(targetDir, jarFiles[0]);
2499
3157
 
2500
- // Comando EXACTO como no terminal (sem profiles!)
3158
+ // EXACT command as in the terminal (no profiles!)
2501
3159
  const command = 'java';
2502
3160
  const args = ['-jar', jarPath, `--server.port=${serverPort}`];
2503
3161
 
2504
3162
  console.log(`Command: ${command} ${args.join(' ')}`);
2505
3163
 
2506
- // Env LIMPO - remover TODAS as variveis Spring que podem interferir
3164
+ // CLEAN env - remove ALL Spring variables that could interfere
2507
3165
  const cleanEnv = { ...process.env };
2508
3166
  delete cleanEnv.SPRING_PROFILES_ACTIVE;
2509
3167
  delete cleanEnv.SPRING_DATASOURCE_URL;
2510
3168
  delete cleanEnv.SPRING_DATASOURCE_USERNAME;
2511
3169
  delete cleanEnv.SPRING_DATASOURCE_PASSWORD;
2512
- // Remover qualquer varivel que comece com SPRING_
3170
+ // Remove any variable that starts with SPRING_
2513
3171
  Object.keys(cleanEnv).forEach(key => {
2514
3172
  if (key.startsWith('SPRING_')) {
2515
3173
  delete cleanEnv[key];
@@ -2668,7 +3326,13 @@ app.post("/workspace/test-local/execute", async (req, res) => {
2668
3326
 
2669
3327
  try {
2670
3328
  const serverPort = port || TEST_LOCAL_STATE.config?.server?.port || 8080;
2671
- const url = `http://localhost:${serverPort}${reqPath}`;
3329
+ // Security: `${port}${path}` concatenation let path "@host/x" retarget the
3330
+ // request to any host (SSRF). Build + validate a loopback URL instead.
3331
+ const built = buildLocalTestUrl(serverPort, reqPath);
3332
+ if (!built.ok) {
3333
+ return res.status(400).json({ ok: false, error: built.reason });
3334
+ }
3335
+ const url = built.url;
2672
3336
 
2673
3337
  console.log(`[TEST-LOCAL] Executing: ${method} ${url}`);
2674
3338
 
@@ -2686,7 +3350,7 @@ app.post("/workspace/test-local/execute", async (req, res) => {
2686
3350
  fetchOptions.body = typeof body === 'string' ? body : JSON.stringify(body);
2687
3351
  }
2688
3352
 
2689
- const response = await fetch(url, fetchOptions);
3353
+ const response = await fetch(url, { ...fetchOptions, redirect: "manual" });
2690
3354
  const duration = Date.now() - startTime;
2691
3355
 
2692
3356
  let responseBody;
@@ -2953,7 +3617,7 @@ app.post("/workspace/safe-patch", async (req, res) => {
2953
3617
  const backupFiles = [];
2954
3618
 
2955
3619
  for (const relPath of targetFiles) {
2956
- const fullPath = path.join(wsRoot, relPath);
3620
+ const fullPath = confineToRoot(wsRoot, relPath); // SEG-3
2957
3621
  if (await exists(fullPath)) {
2958
3622
  const content = await readFile(fullPath, 'utf8');
2959
3623
  backupFiles.push({ path: relPath, content });
@@ -3008,7 +3672,7 @@ app.post("/workspace/safe-patch", async (req, res) => {
3008
3672
  console.error(`Patch failed, rolling back: ${patchError.message}`);
3009
3673
 
3010
3674
  for (const file of backupFiles) {
3011
- const fullPath = path.join(wsRoot, file.path);
3675
+ const fullPath = confineToRoot(wsRoot, file.path); // SEG-3
3012
3676
  await writeFile(fullPath, file.content, 'utf8');
3013
3677
  }
3014
3678
 
@@ -3065,7 +3729,7 @@ app.post("/workspace/patch/dry-run", async (req, res) => {
3065
3729
  // 3. Check if files exist
3066
3730
  const fileChecks = await Promise.all(
3067
3731
  targetFiles.map(async (relPath) => {
3068
- const fullPath = path.join(wsRoot, relPath);
3732
+ const fullPath = confineToRoot(wsRoot, relPath); // SEG-3
3069
3733
  const fileExists = await exists(fullPath);
3070
3734
  let currentContent = null;
3071
3735
  let lineCount = 0;
@@ -3154,7 +3818,7 @@ app.post("/workspace/backup", async (req, res) => {
3154
3818
  const backupFiles = [];
3155
3819
 
3156
3820
  for (const relPath of files) {
3157
- const fullPath = path.join(wsRoot, relPath);
3821
+ const fullPath = confineToRoot(wsRoot, relPath); // SEG-3
3158
3822
  if (await exists(fullPath)) {
3159
3823
  const content = await readFile(fullPath, 'utf8');
3160
3824
  backupFiles.push({ path: relPath, content });
@@ -3219,7 +3883,7 @@ app.post("/workspace/rollback", async (req, res) => {
3219
3883
  console.log(`Rolling back to backup: ${backupId}`);
3220
3884
 
3221
3885
  for (const file of backup.files) {
3222
- const fullPath = path.join(wsRoot, file.path);
3886
+ const fullPath = confineToRoot(wsRoot, file.path); // SEG-3
3223
3887
  await writeFile(fullPath, file.content, 'utf8');
3224
3888
  }
3225
3889
 
@@ -3346,7 +4010,7 @@ app.get("/workspace/diff/by-incident/:incidentId", async (req, res) => {
3346
4010
  try {
3347
4011
  const diffs = [];
3348
4012
  for (const file of matchedBackup.files) {
3349
- const fullPath = path.join(wsRoot, file.path);
4013
+ const fullPath = confineToRoot(wsRoot, file.path); // SEG-3
3350
4014
  let currentContent = '';
3351
4015
  try {
3352
4016
  currentContent = await readFile(fullPath, 'utf8');
@@ -3425,7 +4089,7 @@ app.get("/workspace/diff/:backupId", async (req, res) => {
3425
4089
  const diffs = [];
3426
4090
 
3427
4091
  for (const file of backup.files) {
3428
- const fullPath = path.join(wsRoot, file.path);
4092
+ const fullPath = confineToRoot(wsRoot, file.path); // SEG-3
3429
4093
  let currentContent = '';
3430
4094
 
3431
4095
  try {
@@ -3493,7 +4157,7 @@ app.get("/workspace/detect-port", async (req, res) => {
3493
4157
  const { servicePath = '', language, framework } = req.query;
3494
4158
 
3495
4159
  try {
3496
- const fullPath = path.join(wsRoot, servicePath);
4160
+ const fullPath = confineToRoot(wsRoot, servicePath); // SEG-3
3497
4161
  console.log(`Detecting port for service at: ${fullPath}`);
3498
4162
 
3499
4163
  let port = null;
@@ -3998,17 +4662,20 @@ app.get("/workspace/test-local/logs/stream", async (req, res) => {
3998
4662
 
3999
4663
  // ============================================
4000
4664
  // AUTO-TRAINING ENDPOINTS
4001
- // Escanear e ler arquivos para treinamento AI
4665
+ // Scan and read files for AI training
4002
4666
  // ============================================
4003
4667
 
4004
4668
  /**
4005
4669
  * POST /workspace/scan-files
4006
- * Escaneia todos os arquivos do workspace para auto-training
4670
+ * Scans all workspace files for auto-training
4007
4671
  */
4008
4672
  app.post("/workspace/scan-files", async (req, res) => {
4009
4673
  const { root, excludePatterns = [], includeExtensions = [], maxDepth = 10, maxFiles = 1000 } = req.body;
4010
4674
 
4011
- const workspaceRoot = root || WORKSPACE_ROOT;
4675
+ // SEG-3: when the gateway sets the tenant-isolated root (header), it wins and
4676
+ // a body `root` can never override it. Body root is honoured only in dev
4677
+ // (no header present).
4678
+ const workspaceRoot = resolveWorkspaceRoot(req) || root || WORKSPACE_ROOT;
4012
4679
  if (!workspaceRoot) {
4013
4680
  return res.status(400).json({
4014
4681
  error: "workspace not set",
@@ -4025,7 +4692,7 @@ app.post("/workspace/scan-files", async (req, res) => {
4025
4692
  const fs = await import("fs/promises");
4026
4693
  const pathModule = await import("path");
4027
4694
 
4028
- // Padres padro para excluir
4695
+ // Default patterns to exclude
4029
4696
  const defaultExcludes = [
4030
4697
  "node_modules", "target", "build", "dist", ".git", ".idea",
4031
4698
  ".vscode", "__pycache__", ".gradle", "bin", "obj",
@@ -4033,7 +4700,7 @@ app.post("/workspace/scan-files", async (req, res) => {
4033
4700
  ];
4034
4701
  const allExcludes = [...defaultExcludes, ...excludePatterns];
4035
4702
 
4036
- // Funo recursiva para escanear
4703
+ // Recursive function to scan
4037
4704
  async function scanDir(dir, depth = 0) {
4038
4705
  if (depth > maxDepth || files.length >= maxFiles) return;
4039
4706
 
@@ -4046,7 +4713,7 @@ app.post("/workspace/scan-files", async (req, res) => {
4046
4713
  const fullPath = pathModule.join(dir, entry.name);
4047
4714
  const relativePath = pathModule.relative(workspaceRoot, fullPath);
4048
4715
 
4049
- // Verificar se deve excluir
4716
+ // Check whether it should be excluded
4050
4717
  const shouldExclude = allExcludes.some(pattern => {
4051
4718
  if (pattern.includes("*")) {
4052
4719
  const regex = new RegExp(pattern.replace(/\*/g, ".*"));
@@ -4060,7 +4727,7 @@ app.post("/workspace/scan-files", async (req, res) => {
4060
4727
  if (entry.isDirectory()) {
4061
4728
  await scanDir(fullPath, depth + 1);
4062
4729
  } else if (entry.isFile()) {
4063
- // Verificar extenso
4730
+ // Check the extension
4064
4731
  const ext = pathModule.extname(entry.name).toLowerCase();
4065
4732
  if (includeExtensions.length === 0 || includeExtensions.includes(ext)) {
4066
4733
  files.push(relativePath);
@@ -4095,12 +4762,14 @@ app.post("/workspace/scan-files", async (req, res) => {
4095
4762
 
4096
4763
  /**
4097
4764
  * POST /workspace/read-file
4098
- * L contedo de um arquivo especfico
4765
+ * Reads the content of a specific file
4099
4766
  */
4100
4767
  app.post("/workspace/read-file", async (req, res) => {
4101
- const { root, filePath } = req.body;
4768
+ const { filePath } = req.body;
4102
4769
 
4103
- const workspaceRoot = root || WORKSPACE_ROOT;
4770
+ // SEG-3: the root comes ONLY from the tenant-isolated request context,
4771
+ // never from the body (a body `root` could point at another workspace).
4772
+ const workspaceRoot = resolveWorkspaceRoot(req);
4104
4773
  if (!workspaceRoot) {
4105
4774
  return res.status(400).json({
4106
4775
  error: "workspace not set"
@@ -4117,11 +4786,19 @@ app.post("/workspace/read-file", async (req, res) => {
4117
4786
 
4118
4787
  try {
4119
4788
  const fs = await import("fs/promises");
4120
- const pathModule = await import("path");
4121
4789
 
4122
- const fullPath = pathModule.join(workspaceRoot, filePath);
4790
+ let fullPath;
4791
+ try {
4792
+ fullPath = confineToRoot(workspaceRoot, filePath);
4793
+ } catch (e) {
4794
+ if (e instanceof SandboxViolation) {
4795
+ console.warn(`[READ-FILE] SANDBOX DENIED: ${filePath} (${e.details?.resolved})`);
4796
+ return res.status(403).json({ success: false, error: "path escapes the workspace sandbox", filePath });
4797
+ }
4798
+ throw e;
4799
+ }
4123
4800
 
4124
- // Verificar se arquivo existe
4801
+ // Check whether the file exists
4125
4802
  const stats = await fs.stat(fullPath);
4126
4803
 
4127
4804
  // Limitar tamanho (100KB)
@@ -4155,12 +4832,13 @@ app.post("/workspace/read-file", async (req, res) => {
4155
4832
 
4156
4833
  /**
4157
4834
  * POST /workspace/read-files
4158
- * L mltiplos arquivos de uma vez (batch)
4835
+ * Reads multiple files at once (batch)
4159
4836
  */
4160
4837
  app.post("/workspace/read-files", async (req, res) => {
4161
- const { root, filePaths } = req.body;
4838
+ const { filePaths } = req.body;
4162
4839
 
4163
- const workspaceRoot = root || WORKSPACE_ROOT;
4840
+ // SEG-3: root only from the tenant-isolated request context.
4841
+ const workspaceRoot = resolveWorkspaceRoot(req);
4164
4842
  if (!workspaceRoot) {
4165
4843
  return res.status(400).json({
4166
4844
  error: "workspace not set"
@@ -4177,17 +4855,25 @@ app.post("/workspace/read-files", async (req, res) => {
4177
4855
 
4178
4856
  try {
4179
4857
  const fs = await import("fs/promises");
4180
- const pathModule = await import("path");
4181
4858
 
4182
4859
  const files = {};
4183
4860
  const errors = [];
4184
4861
 
4185
4862
  for (const filePath of filePaths) {
4186
4863
  try {
4187
- const fullPath = pathModule.join(workspaceRoot, filePath);
4864
+ let fullPath;
4865
+ try {
4866
+ fullPath = confineToRoot(workspaceRoot, filePath);
4867
+ } catch (e) {
4868
+ if (e instanceof SandboxViolation) {
4869
+ errors.push({ filePath, error: "path escapes the workspace sandbox" });
4870
+ continue;
4871
+ }
4872
+ throw e;
4873
+ }
4188
4874
  const stats = await fs.stat(fullPath);
4189
4875
 
4190
- // Pular arquivos muito grandes
4876
+ // Skip files that are too large
4191
4877
  if (stats.size > 100 * 1024) {
4192
4878
  errors.push({ filePath, error: "File too large" });
4193
4879
  continue;
@@ -4225,7 +4911,7 @@ app.post("/workspace/read-files", async (req, res) => {
4225
4911
 
4226
4912
  /**
4227
4913
  * GET /system/folder-picker
4228
- * Abre o file picker nativo do sistema operacional
4914
+ * Opens the native operating system file picker
4229
4915
  */
4230
4916
  app.get("/system/folder-picker", async (req, res) => {
4231
4917
  console.log(`[FOLDER-PICKER] Opening native folder picker...`);
@@ -4241,7 +4927,7 @@ app.get("/system/folder-picker", async (req, res) => {
4241
4927
  const { stdout } = await execAsync(script);
4242
4928
  selectedPath = stdout.trim();
4243
4929
  } catch (error) {
4244
- // Usurio cancelou
4930
+ // User cancelled
4245
4931
  if (error.code === 1) {
4246
4932
  return res.json({
4247
4933
  success: true,
@@ -4270,7 +4956,7 @@ app.get("/system/folder-picker", async (req, res) => {
4270
4956
  }
4271
4957
 
4272
4958
  } else if (platform === "linux") {
4273
- // Linux - tentar zenity, kdialog ou yad
4959
+ // Linux - try zenity, kdialog or yad
4274
4960
  const commands = [
4275
4961
  `zenity --file-selection --directory --title="Select your project folder"`,
4276
4962
  `kdialog --getexistingdirectory ~ --title "Select your project folder"`,
@@ -4306,7 +4992,7 @@ app.get("/system/folder-picker", async (req, res) => {
4306
4992
  });
4307
4993
  }
4308
4994
 
4309
- // Remover trailing slash se houver
4995
+ // Remove trailing slash if present
4310
4996
  if (selectedPath && selectedPath.endsWith("/")) {
4311
4997
  selectedPath = selectedPath.slice(0, -1);
4312
4998
  }
@@ -4332,7 +5018,7 @@ app.get("/system/folder-picker", async (req, res) => {
4332
5018
 
4333
5019
  /**
4334
5020
  * GET /system/info
4335
- * Retorna informaes do sistema
5021
+ * Returns system information
4336
5022
  */
4337
5023
  app.get("/system/info", (req, res) => {
4338
5024
  res.json({
@@ -4346,12 +5032,12 @@ app.get("/system/info", (req, res) => {
4346
5032
 
4347
5033
  // ============================================
4348
5034
  // API DOCS ENDPOINT
4349
- // Retorna endpoints detectados das controllers
5035
+ // Returns endpoints detected from the controllers
4350
5036
  // ============================================
4351
5037
 
4352
5038
  /**
4353
5039
  * GET /workspace/api-docs
4354
- * Analisa controllers e retorna todos os endpoints da API
5040
+ * Analyzes controllers and returns all API endpoints
4355
5041
  */
4356
5042
  app.get("/workspace/api-docs", async (req, res) => {
4357
5043
  const wsRoot = resolveWorkspaceRoot(req);
@@ -4365,7 +5051,7 @@ app.get("/workspace/api-docs", async (req, res) => {
4365
5051
  console.log(`[API-DOCS] Analyzing controllers in ${wsRoot}`);
4366
5052
 
4367
5053
  try {
4368
- // Primeiro, escanear arquivos do workspace
5054
+ // First, scan the workspace files
4369
5055
  const fs = await import("fs/promises");
4370
5056
  const pathModule = await import("path");
4371
5057
 
@@ -4403,7 +5089,7 @@ app.get("/workspace/api-docs", async (req, res) => {
4403
5089
 
4404
5090
  console.log(`[API-DOCS] Found ${files.length} Java files`);
4405
5091
 
4406
- // Agora analisar os controllers
5092
+ // Now analyze the controllers
4407
5093
  const controllerAnalyzer = new ControllerAnalyzer(wsRoot);
4408
5094
  const apiDocs = await controllerAnalyzer.generateApiDocs(files);
4409
5095
 
@@ -4434,7 +5120,7 @@ app.get("/workspace/api-docs", async (req, res) => {
4434
5120
 
4435
5121
  /**
4436
5122
  * GET /workspace/api-docs/:controller
4437
- * Retorna endpoints de uma controller especfica
5123
+ * Returns the endpoints of a specific controller
4438
5124
  */
4439
5125
  app.get("/workspace/api-docs/:controller", async (req, res) => {
4440
5126
  const wsRoot = resolveWorkspaceRoot(req);
@@ -4449,7 +5135,7 @@ app.get("/workspace/api-docs/:controller", async (req, res) => {
4449
5135
  console.log(`[API-DOCS] Getting endpoints for controller: ${controller}`);
4450
5136
 
4451
5137
  try {
4452
- // Primeiro, escanear arquivos do workspace
5138
+ // First, scan the workspace files
4453
5139
  const fs = await import("fs/promises");
4454
5140
  const pathModule = await import("path");
4455
5141
 
@@ -4484,7 +5170,7 @@ app.get("/workspace/api-docs/:controller", async (req, res) => {
4484
5170
 
4485
5171
  await scanDir(wsRoot);
4486
5172
 
4487
- // Analisar os controllers
5173
+ // Analyze the controllers
4488
5174
  const controllerAnalyzer = new ControllerAnalyzer(wsRoot);
4489
5175
  const apiDocs = await controllerAnalyzer.generateApiDocs(files);
4490
5176
 
@@ -4535,7 +5221,7 @@ app.get("/workspace/api-docs/:controller", async (req, res) => {
4535
5221
 
4536
5222
  /**
4537
5223
  * GET /ai-engine/status
4538
- * Retorna estado do AI Vibe Coding Engine
5224
+ * Returns the state of the AI Vibe Coding Engine
4539
5225
  */
4540
5226
  app.get("/ai-engine/status", (req, res) => {
4541
5227
  res.json(aiEngine ? aiEngine.getStatus() : { active: false, error: 'AI Engine not initialized' });
@@ -4570,7 +5256,7 @@ app.post("/ai-engine/clear-history", (req, res) => {
4570
5256
 
4571
5257
  /**
4572
5258
  * GET /ai-engine/errors
4573
- * Retorna histrico de erros
5259
+ * Returns the error history
4574
5260
  */
4575
5261
  app.get("/ai-engine/errors", (req, res) => {
4576
5262
  const { limit = 50 } = req.query;
@@ -4587,7 +5273,7 @@ app.get("/ai-engine/errors", (req, res) => {
4587
5273
 
4588
5274
  /**
4589
5275
  * GET /ai-engine/fixes
4590
- * Retorna histrico de fixes
5276
+ * Returns the fix history
4591
5277
  */
4592
5278
  app.get("/ai-engine/fixes", (req, res) => {
4593
5279
  if (aiEngine) {
@@ -4603,7 +5289,7 @@ app.get("/ai-engine/fixes", (req, res) => {
4603
5289
 
4604
5290
  /**
4605
5291
  * GET /workspace/smart-config
4606
- * Recolhe ficheiros de configurao para anlise AI
5292
+ * Collects configuration files for AI analysis
4607
5293
  */
4608
5294
  app.get("/workspace/smart-config", async (req, res) => {
4609
5295
  const wsRoot = resolveWorkspaceRoot(req);
@@ -4652,7 +5338,7 @@ app.get("/workspace/smart-config", async (req, res) => {
4652
5338
  }
4653
5339
  }
4654
5340
 
4655
- // Detectar profiles disponveis
5341
+ // Detect available profiles
4656
5342
  const availableProfiles = [];
4657
5343
  const resourcesDir = path.join(wsRoot, 'src/main/resources');
4658
5344
  if (fs.existsSync(resourcesDir)) {
@@ -4767,40 +5453,40 @@ app.post("/workspace/search", async (req, res) => {
4767
5453
  return res.status(400).json({ ok: false, error: "pattern is required" });
4768
5454
  }
4769
5455
 
5456
+ // ROOT-RECOVERY fail-fast: dead root with no recoverable clone - answer
5457
+ // with an actionable error instead of an empty result set the AI cannot
5458
+ // distinguish from "no matches".
5459
+ if (!fs.existsSync(wsRoot)) {
5460
+ return res.status(200).json({
5461
+ ok: false,
5462
+ error: "workspace_root_not_hydrated",
5463
+ root: wsRoot,
5464
+ hint: "This agent has no clone at that path. Run git_prepare_workspace "
5465
+ + "(POST /workspace/clone) for this workspace first, then retry."
5466
+ });
5467
+ }
5468
+
4770
5469
  console.log(`[workspace/search] pattern="${pattern}" filter="${fileFilter || '*'}"`);
4771
5470
 
4772
5471
  try {
4773
- const { execSync } = await import('child_process');
4774
-
4775
- // Build grep command with safety limits
4776
- let cmd = `grep -rn `;
4777
-
4778
- // Apply file filter if provided
4779
- if (fileFilter && fileFilter.trim()) {
4780
- cmd += `--include="${fileFilter}" `;
4781
- }
4782
-
4783
- // Exclude common non-source directories
4784
- cmd += `--exclude-dir=node_modules --exclude-dir=.git --exclude-dir=target `;
4785
- cmd += `--exclude-dir=build --exclude-dir=dist --exclude-dir=.idea `;
4786
- cmd += `--exclude-dir=__pycache__ --exclude-dir=.next --exclude-dir=vendor `;
4787
- cmd += `--exclude-dir=bin --exclude-dir=obj --exclude-dir=.gradle `;
4788
- cmd += `--exclude-dir=.mvn --exclude-dir=.venv --exclude-dir=coverage `;
5472
+ const { execFileSync } = await import('child_process');
4789
5473
 
4790
- // Escape pattern for shell safety and limit results
4791
- const safePattern = pattern.replace(/"/g, '\\"').replace(/`/g, '\\`').replace(/\$/g, '\\$');
4792
- cmd += `"${safePattern}" "${wsRoot}" | head -100`;
5474
+ // No shell: pattern and fileFilter are argv entries (H4), never interpolated.
5475
+ const grepArgs = buildGrepArgs({ pattern: String(pattern), fileFilter, root: wsRoot });
4793
5476
 
5477
+ const relativize = (line) => line.replace(wsRoot + '/', '');
5478
+ const SEARCH_TIMEOUT_MS = parseInt(process.env.WORKSPACE_SEARCH_TIMEOUT_MS || "15000", 10);
4794
5479
  let stdout = '';
4795
5480
  try {
4796
- stdout = execSync(cmd, {
5481
+ stdout = execFileSync('grep', grepArgs, {
4797
5482
  encoding: 'utf8',
4798
- timeout: 15000,
5483
+ timeout: SEARCH_TIMEOUT_MS,
4799
5484
  maxBuffer: 1024 * 1024
4800
- });
5485
+ }).split('\n').slice(0, 100).join('\n');
4801
5486
  } catch (grepErr) {
5487
+ const outcome = classifyGrepFailure(grepErr, { timeoutMs: SEARCH_TIMEOUT_MS });
4802
5488
  // grep returns exit code 1 when no matches found
4803
- if (grepErr.status === 1) {
5489
+ if (outcome.kind === 'no_match') {
4804
5490
  return res.json({
4805
5491
  ok: true,
4806
5492
  results: `No matches found for: ${pattern}`,
@@ -4808,12 +5494,18 @@ app.post("/workspace/search", async (req, res) => {
4808
5494
  });
4809
5495
  }
4810
5496
  // Exit code 2 = error in grep
4811
- if (grepErr.status === 2) {
5497
+ if (outcome.kind === 'bad_pattern') {
4812
5498
  return res.status(400).json({
4813
5499
  ok: false,
4814
- error: "Invalid search pattern: " + grepErr.message
5500
+ error: "Invalid search pattern: " + outcome.message
4815
5501
  });
4816
5502
  }
5503
+ // Timeout / output cap: a 15 s grep over a multi-repo parent used to end
5504
+ // here as HTTP 500 (QA incident b83215a8). Answer with what grep found.
5505
+ if (outcome.kind === 'partial') {
5506
+ console.warn(`[workspace/search] ${outcome.reason} pattern="${pattern}" root=${wsRoot}`);
5507
+ return res.status(200).json(partialSearchBody(pattern, outcome, relativize));
5508
+ }
4817
5509
  throw grepErr;
4818
5510
  }
4819
5511
 
@@ -4821,7 +5513,7 @@ app.post("/workspace/search", async (req, res) => {
4821
5513
  const results = stdout
4822
5514
  .split('\n')
4823
5515
  .filter(line => line.trim())
4824
- .map(line => line.replace(wsRoot + '/', ''))
5516
+ .map(relativize)
4825
5517
  .join('\n');
4826
5518
 
4827
5519
  const count = results.split('\n').filter(l => l.trim()).length;
@@ -4865,19 +5557,26 @@ app.post("/workspace/exec", async (req, res) => {
4865
5557
  return res.status(400).json({ ok: false, error: "command is required" });
4866
5558
  }
4867
5559
 
4868
- // Security: block dangerous commands
4869
- const dangerous = [
4870
- 'rm -rf /', 'rm -rf /*', 'mkfs', 'dd if=', 'shutdown', 'reboot',
4871
- 'init 0', '> /dev/', 'chmod -R 777 /', 'curl | sh', 'wget | sh',
4872
- 'curl | bash', 'wget | bash', 'format c:', 'del /f /s /q'
4873
- ];
4874
- const lowerCmd = command.toLowerCase().trim();
4875
- if (dangerous.some(d => lowerCmd.includes(d))) {
4876
- console.warn(`[workspace/exec] BLOCKED dangerous command: ${command}`);
4877
- return res.status(403).json({
4878
- ok: false,
4879
- error: "Command blocked for security reasons"
4880
- });
5560
+ // Security: ALLOWLIST (utils/exec-policy.js) instead of the old substring
5561
+ // blocklist. Outside the allowlist a command needs a PolicyGate human
5562
+ // approval token, or must be a signed platform-authored tool command.
5563
+ const execDecision = decideExec({
5564
+ command, root: wsRoot, headers: req.headers, body: req.body || {},
5565
+ vhSignature: req.vhSignature, signingCfg: SIGNING,
5566
+ }, EXEC_POLICY);
5567
+ if (!execDecision.allow) {
5568
+ console.warn(`[workspace/exec] POLICY DENIED: ${execDecision.reason}`);
5569
+ return res.status(execDecision.status || 403).json(rejectionBody(execDecision));
5570
+ }
5571
+
5572
+ // SEG-3: cwd is pinned to the workspace root below; additionally scan the
5573
+ // raw command for out-of-root escapes (`..` traversal, absolute paths into
5574
+ // other workspaces / sensitive dirs). Best-effort — full shell containment
5575
+ // needs OS namespaces — but it blocks the obvious `cat ../neighbor/x` case.
5576
+ const rawScan = scanRawCommand(wsRoot, command);
5577
+ if (!rawScan.ok) {
5578
+ console.warn(`[workspace/exec] SANDBOX DENIED: ${rawScan.reason} (${rawScan.token})`);
5579
+ return res.status(403).json({ ok: false, error: "command escapes the workspace sandbox", detail: rawScan.reason });
4881
5580
  }
4882
5581
 
4883
5582
  console.log(`[workspace/exec] Running: ${command.substring(0, 120)}...`);
@@ -4891,7 +5590,9 @@ app.post("/workspace/exec", async (req, res) => {
4891
5590
  cwd: wsRoot,
4892
5591
  timeout: 60000,
4893
5592
  maxBuffer: 5 * 1024 * 1024,
4894
- env: { ...process.env, FORCE_COLOR: '0' }
5593
+ // SECURITY (2026-10-11): never hand the agent's own credentials to
5594
+ // a model-authored command (`env`, /proc/self/environ).
5595
+ env: sanitizedChildEnv(process.env, { FORCE_COLOR: '0' })
4895
5596
  });
4896
5597
 
4897
5598
  console.log(`Command completed (stdout: ${result.stdout.length} chars)`);
@@ -4931,9 +5632,10 @@ app.post("/workspace/test-endpoint", async (req, res) => {
4931
5632
  return res.status(400).json({ error: "url is required" });
4932
5633
  }
4933
5634
 
4934
- // Security: only localhost
4935
- if (!url.startsWith('http://localhost') && !url.startsWith('http://127.0.0.1')) {
4936
- return res.status(403).json({ error: "Only localhost URLs allowed for security" });
5635
+ // Security: only loopback, parsed (not prefix-matched), never the agent's own ports.
5636
+ const urlCheck = validateLocalTestUrl(url);
5637
+ if (!urlCheck.ok) {
5638
+ return res.status(403).json({ error: `Only localhost URLs allowed for security: ${urlCheck.reason}` });
4937
5639
  }
4938
5640
 
4939
5641
  console.log(`Testing: ${method} ${url} (${testName || 'unnamed'})`);
@@ -4954,7 +5656,7 @@ app.post("/workspace/test-endpoint", async (req, res) => {
4954
5656
  fetchOptions.body = reqBody;
4955
5657
  }
4956
5658
 
4957
- const response = await fetch(url, fetchOptions);
5659
+ const response = await fetch(urlCheck.url, { ...fetchOptions, redirect: 'manual' });
4958
5660
  const durationMs = Date.now() - startTime;
4959
5661
 
4960
5662
  // Capture response
@@ -5012,6 +5714,198 @@ app.post("/workspace/test-endpoint", async (req, res) => {
5012
5714
  // Create branch, commit, push for auto-fix PRs
5013
5715
  // ============================================
5014
5716
 
5717
+ /**
5718
+ * Coordinated multi-repo branch + PR (Workbench Option B).
5719
+ *
5720
+ * wsRoot is the per-workspace PARENT dir; each immediate git sub-repo that has
5721
+ * uncommitted changes gets the SAME `branchName`, a commit, a push, and ONE PR.
5722
+ * After all PRs exist, each PR body is updated (two-pass) to cross-link the
5723
+ * sibling PRs. Returns a LIST of per-repo PR results; single-repo callers never
5724
+ * reach here (server routes only when isMultiRepoRoot()).
5725
+ *
5726
+ * PR creation currently uses the GitHub REST API (primary provider). Non-GitHub
5727
+ * sub-repos still get branch+commit+push and a "compare/new MR" URL, but no API
5728
+ * PR/cross-link (tracked as follow-up).
5729
+ */
5730
+ async function createMultiRepoFixBranch({ wsRoot, branchName, commitMessage, gitToken, files }) {
5731
+ if (!isSafeBranchName(branchName)) {
5732
+ const err = new Error("invalid branch name"); err.httpStatus = 400; throw err;
5733
+ }
5734
+ const { execSync, execFileSync } = await import('child_process');
5735
+ const gitEnv = {
5736
+ ...process.env,
5737
+ GIT_TERMINAL_PROMPT: '0',
5738
+ GIT_ASKPASS: 'echo',
5739
+ GIT_SSH_COMMAND: 'ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=5',
5740
+ };
5741
+ const repos = listRepoSubdirs(wsRoot);
5742
+ const prs = [];
5743
+ let totalFiles = 0;
5744
+
5745
+ for (const repo of repos) {
5746
+ const opts = { cwd: repo.path, encoding: 'utf8', timeout: 60000, env: gitEnv };
5747
+ const runQuiet = (cmd, o = {}) => {
5748
+ try { return execSync(cmd, { ...opts, ...o }).toString().trim(); }
5749
+ catch { return ''; }
5750
+ };
5751
+ // No shell for anything that carries caller-supplied text (branch,
5752
+ // commit message, file names): argv array via execFile.
5753
+ const git = (args, o = {}) => execFileSync('git', args, { ...opts, ...o }).toString().trim();
5754
+ const gitQuiet = (args, o = {}) => { try { return git(args, o); } catch { return ''; } };
5755
+
5756
+ // Only repos with actual changes get a PR (one-PR-per-CHANGED-repo).
5757
+ let status = '';
5758
+ try { status = execSync('git status --porcelain', opts).toString().trim(); }
5759
+ catch { status = ''; }
5760
+ if (!status) {
5761
+ prs.push({ dir: repo.dir, changed: false, prCreated: false, prUrl: '' });
5762
+ continue;
5763
+ }
5764
+ const filesChanged = status.split('\n').filter(Boolean).length;
5765
+ totalFiles += filesChanged;
5766
+
5767
+ // git identity (Cloud Run has none by default).
5768
+ runQuiet('git config user.email "ai@deepdebug.ai"');
5769
+ runQuiet('git config user.name "DeepDebug AI"');
5770
+
5771
+ // Shared branch across every repo. Reuse if it already exists in this repo.
5772
+ try { git(['checkout', '-b', branchName]); }
5773
+ catch { gitQuiet(['checkout', branchName]); }
5774
+
5775
+ // Stage + commit.
5776
+ if (Array.isArray(files) && files.length > 0) {
5777
+ for (const f of files) gitQuiet(['add', '--', String(f)]);
5778
+ } else {
5779
+ runQuiet('git add -A');
5780
+ }
5781
+ try { git(['commit', '-m', String(commitMessage)]); }
5782
+ catch (e) { /* nothing staged after all */ }
5783
+ const commitSha = runQuiet('git rev-parse HEAD');
5784
+
5785
+ // Resolve authed remote for push + API.
5786
+ const remoteRaw = runQuiet('git remote get-url origin 2>/dev/null');
5787
+ const parsed = parseAuthedRemote(remoteRaw);
5788
+ const cleanBase = stripRemoteCreds(remoteRaw);
5789
+
5790
+ // Determine PR base: repo default branch (origin/HEAD) else main.
5791
+ let targetBranch = 'main';
5792
+ const head = runQuiet('git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null');
5793
+ if (head) {
5794
+ const parts = head.split('/');
5795
+ if (parts.length) targetBranch = parts[parts.length - 1];
5796
+ }
5797
+
5798
+ // Push the shared branch (origin already carries the per-repo token from clone).
5799
+ let pushed = false;
5800
+ try {
5801
+ git(['push', '-u', 'origin', branchName], { timeout: 120000 });
5802
+ pushed = true;
5803
+ } catch (e) {
5804
+ console.warn(`[multi-repo-pr] push failed for ${repo.dir}: ${maskUrlCreds(e.message || '')}`);
5805
+ }
5806
+
5807
+ const entry = {
5808
+ dir: repo.dir,
5809
+ changed: true,
5810
+ filesChanged,
5811
+ commitSha,
5812
+ branch: branchName,
5813
+ targetBranch,
5814
+ pushed,
5815
+ repoUrl: cleanBase,
5816
+ provider: parsed ? parsed.provider : 'generic',
5817
+ prCreated: false,
5818
+ prUrl: '',
5819
+ prNumber: null,
5820
+ };
5821
+
5822
+ // Open the PR (GitHub API). Others: fall back to a compare/new-MR URL.
5823
+ if (pushed && parsed && parsed.provider === 'github' && parsed.apiToken) {
5824
+ const body = buildMultiRepoPrBody({
5825
+ dir: repo.dir, branch: branchName, commitMessage, filesChanged, commitSha,
5826
+ });
5827
+ try {
5828
+ const resp = await fetch(`https://api.github.com/repos/${parsed.ownerRepo}/pulls`, {
5829
+ method: 'POST',
5830
+ headers: {
5831
+ 'Authorization': `Bearer ${parsed.apiToken}`,
5832
+ 'Accept': 'application/vnd.github+json',
5833
+ 'Content-Type': 'application/json',
5834
+ 'X-GitHub-Api-Version': '2022-11-28',
5835
+ 'User-Agent': 'DeepDebug-AI',
5836
+ },
5837
+ body: JSON.stringify({ title: commitMessage, head: branchName, base: targetBranch, body }),
5838
+ });
5839
+ if (resp.ok) {
5840
+ const data = await resp.json();
5841
+ entry.prUrl = data.html_url;
5842
+ entry.prNumber = data.number;
5843
+ entry.prCreated = true;
5844
+ console.log(`[multi-repo-pr] GitHub PR created for ${repo.dir}: ${entry.prUrl}`);
5845
+ } else {
5846
+ const errText = (await resp.text()).substring(0, 200);
5847
+ console.warn(`[multi-repo-pr] GitHub PR failed (${resp.status}) for ${repo.dir}: ${errText}`);
5848
+ entry.prUrl = buildCompareUrl(cleanBase, 'github', branchName, targetBranch);
5849
+ }
5850
+ } catch (e) {
5851
+ console.warn(`[multi-repo-pr] GitHub PR error for ${repo.dir}: ${e.message}`);
5852
+ entry.prUrl = buildCompareUrl(cleanBase, 'github', branchName, targetBranch);
5853
+ }
5854
+ } else if (pushed) {
5855
+ entry.prUrl = buildCompareUrl(cleanBase, entry.provider, branchName, targetBranch);
5856
+ }
5857
+ prs.push(entry);
5858
+ }
5859
+
5860
+ // Second pass: cross-link sibling PRs in each real GitHub PR body.
5861
+ const realPrs = prs.filter((p) => p.prCreated && p.prNumber != null);
5862
+ if (realPrs.length > 1) {
5863
+ for (const p of realPrs) {
5864
+ const note = buildCrossLinkNote(p.dir, realPrs);
5865
+ if (!note) continue;
5866
+ const repo = repos.find((r) => r.dir === p.dir);
5867
+ const remoteRaw = execSync('git remote get-url origin 2>/dev/null',
5868
+ { cwd: repo.path, encoding: 'utf8', env: gitEnv }).toString().trim();
5869
+ const parsed = parseAuthedRemote(remoteRaw);
5870
+ if (!parsed || parsed.provider !== 'github' || !parsed.apiToken) continue;
5871
+ const baseBody = buildMultiRepoPrBody({
5872
+ dir: p.dir, branch: branchName, commitMessage,
5873
+ filesChanged: p.filesChanged, commitSha: p.commitSha,
5874
+ });
5875
+ try {
5876
+ await fetch(`https://api.github.com/repos/${parsed.ownerRepo}/pulls/${p.prNumber}`, {
5877
+ method: 'PATCH',
5878
+ headers: {
5879
+ 'Authorization': `Bearer ${parsed.apiToken}`,
5880
+ 'Accept': 'application/vnd.github+json',
5881
+ 'Content-Type': 'application/json',
5882
+ 'X-GitHub-Api-Version': '2022-11-28',
5883
+ 'User-Agent': 'DeepDebug-AI',
5884
+ },
5885
+ body: JSON.stringify({ body: baseBody + note }),
5886
+ });
5887
+ } catch (e) {
5888
+ console.warn(`[multi-repo-pr] cross-link update failed for ${p.dir}: ${e.message}`);
5889
+ }
5890
+ }
5891
+ }
5892
+
5893
+ const changedPrs = prs.filter((p) => p.changed);
5894
+ const firstUrl = (changedPrs.find((p) => p.prUrl) || {}).prUrl || '';
5895
+ return {
5896
+ ok: true,
5897
+ multiRepo: true,
5898
+ branch: branchName,
5899
+ prs,
5900
+ prUrls: changedPrs.filter((p) => p.prUrl).map((p) => p.prUrl),
5901
+ mrUrl: firstUrl,
5902
+ prCreated: changedPrs.some((p) => p.prCreated),
5903
+ filesChanged: totalFiles,
5904
+ reposChanged: changedPrs.length,
5905
+ reposTotal: repos.length,
5906
+ };
5907
+ }
5908
+
5015
5909
  /**
5016
5910
  * POST /workspace/git/create-fix-branch
5017
5911
  * Creates a branch, commits changes, and pushes to remote.
@@ -5028,9 +5922,33 @@ app.post("/workspace/git/create-fix-branch", async (req, res) => {
5028
5922
  if (!branchName || !commitMessage) {
5029
5923
  return res.status(400).json({ error: "branchName and commitMessage required" });
5030
5924
  }
5925
+ // SECURITY (2026-10-11): branchName / commitMessage / files were interpolated
5926
+ // into shell strings (`git commit -m "${msg}"` only escaped quotes, so
5927
+ // backticks and $() ran). Validate the branch, run git via execFile below.
5928
+ if (!isSafeBranchName(branchName)) return res.status(400).json({ error: "invalid branch name" });
5929
+ if (files !== undefined && (!Array.isArray(files) || files.some(f => typeof f !== "string" || !f || f.startsWith("-")))) {
5930
+ return res.status(400).json({ error: "files must be an array of relative paths" });
5931
+ }
5932
+
5933
+ // MULTI-REPO (Workbench Option B): when the session root is a per-workspace
5934
+ // PARENT dir with N git sub-repos (cloud-multi-clone), open ONE PR per CHANGED
5935
+ // repo on the SAME shared branch and cross-link the siblings. Single-repo
5936
+ // roots (root itself is a git repo) fall through to the legacy path below,
5937
+ // unchanged. Gated by MULTI_REPO_PR_ENABLED (default on).
5938
+ if (multiRepoPrEnabled() && isMultiRepoRoot(wsRoot)) {
5939
+ try {
5940
+ const result = await createMultiRepoFixBranch({
5941
+ wsRoot, branchName, commitMessage, gitToken, files,
5942
+ });
5943
+ return res.json(result);
5944
+ } catch (err) {
5945
+ console.error(`[multi-repo-pr] failed: ${maskUrlCreds(err.message)}`);
5946
+ return res.status(500).json({ ok: false, error: maskUrlCreds(err.message) });
5947
+ }
5948
+ }
5031
5949
 
5032
5950
  try {
5033
- const { execSync } = await import('child_process');
5951
+ const { execSync, execFileSync } = await import('child_process');
5034
5952
  const opts = {
5035
5953
  cwd: wsRoot,
5036
5954
  encoding: 'utf8',
@@ -5039,9 +5957,11 @@ app.post("/workspace/git/create-fix-branch", async (req, res) => {
5039
5957
  ...process.env,
5040
5958
  GIT_TERMINAL_PROMPT: '0', // Never prompt for credentials
5041
5959
  GIT_ASKPASS: 'echo', // Return empty if asked
5042
- GIT_SSH_COMMAND: 'ssh -o BatchMode=yes -o StrictHostKeyChecking=no -o ConnectTimeout=5'
5960
+ GIT_SSH_COMMAND: 'ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=5'
5043
5961
  }
5044
5962
  };
5963
+ // argv form (no shell) for every call that carries request data.
5964
+ const git = (args, o = {}) => execFileSync('git', args, { ...opts, ...o }).toString();
5045
5965
 
5046
5966
  // 1. Check if git repo
5047
5967
  try {
@@ -5068,12 +5988,12 @@ app.post("/workspace/git/create-fix-branch", async (req, res) => {
5068
5988
  // 4. Create and checkout new branch (with unique name if exists)
5069
5989
  let finalBranchName = branchName;
5070
5990
  try {
5071
- execSync(`git checkout -b ${finalBranchName}`, opts);
5991
+ git(['checkout', '-b', finalBranchName]);
5072
5992
  } catch (e) {
5073
5993
  // Branch already exists add timestamp suffix
5074
5994
  finalBranchName = `${branchName}-${Date.now() % 100000}`;
5075
5995
  try {
5076
- execSync(`git checkout -b ${finalBranchName}`, opts);
5996
+ git(['checkout', '-b', finalBranchName]);
5077
5997
  } catch (e2) {
5078
5998
  // Restore stash and bail
5079
5999
  if (hadStash) try { execSync('git stash pop', opts); } catch {}
@@ -5094,7 +6014,7 @@ app.post("/workspace/git/create-fix-branch", async (req, res) => {
5094
6014
  // 6. Stage changes
5095
6015
  if (files && files.length > 0) {
5096
6016
  for (const file of files) {
5097
- execSync(`git add "${file}"`, opts);
6017
+ git(['add', '--', file]);
5098
6018
  }
5099
6019
  } else {
5100
6020
  execSync('git add -A', opts);
@@ -5103,7 +6023,7 @@ app.post("/workspace/git/create-fix-branch", async (req, res) => {
5103
6023
  // 7. Check for changes
5104
6024
  const status = execSync('git status --porcelain', opts).trim();
5105
6025
  if (!status) {
5106
- try { execSync(`git checkout ${currentBranch}`, opts); } catch {}
6026
+ try { git(['checkout', currentBranch]); } catch {}
5107
6027
  return res.json({ ok: false, error: "No changes to commit" });
5108
6028
  }
5109
6029
 
@@ -5115,8 +6035,7 @@ app.post("/workspace/git/create-fix-branch", async (req, res) => {
5115
6035
  } catch (e) {
5116
6036
  console.warn('Could not set git config:', e.message);
5117
6037
  }
5118
- const safeMsg = commitMessage.replace(/"/g, '\\"').replace(/\n/g, '\\n');
5119
- execSync(`git commit -m "${safeMsg}"`, opts);
6038
+ git(['commit', '-m', String(commitMessage)]);
5120
6039
 
5121
6040
  // 9. Push (use token if available for authentication)
5122
6041
  let pushResult = '';
@@ -5134,11 +6053,11 @@ app.post("/workspace/git/create-fix-branch", async (req, res) => {
5134
6053
  // No remote configured use repoUrl from integration if available
5135
6054
  if (repoUrl) {
5136
6055
  remoteUrlRaw = repoUrl;
5137
- console.log(`No git remote, using repoUrl from integration: ${repoUrl}`);
6056
+ console.log(`No git remote, using repoUrl from integration: ${maskUrlCreds(repoUrl)}`);
5138
6057
  }
5139
6058
  }
5140
6059
 
5141
- console.log(`Remote URL: ${remoteUrlRaw.replace(gitToken, '***')}`);
6060
+ console.log(`Remote URL: ${maskUrlCreds(remoteUrlRaw)}`);
5142
6061
  let authenticatedUrl = '';
5143
6062
 
5144
6063
  if (remoteUrlRaw.includes('github.com')) {
@@ -5162,7 +6081,7 @@ app.post("/workspace/git/create-fix-branch", async (req, res) => {
5162
6081
  .replace(/^git@bitbucket\.org:/, '')
5163
6082
  .replace(/\.git$/, '');
5164
6083
  // FIXED: Bitbucket App Password auth token j est no formato "username:appPassword"
5165
- // x-token-auth no funciona; Basic Auth via URL o mtodo correcto
6084
+ // x-token-auth does not work; Basic Auth via URL is the correct method
5166
6085
  authenticatedUrl = `https://${gitToken}@bitbucket.org/${repoPath}.git`;
5167
6086
  } else if (remoteUrlRaw) {
5168
6087
  // Unknown provider try generic https with token
@@ -5176,42 +6095,46 @@ app.post("/workspace/git/create-fix-branch", async (req, res) => {
5176
6095
  console.log(`Pushing ${finalBranchName} with token auth...`);
5177
6096
  try {
5178
6097
  // Don't use 2>&1 let execSync capture stderr separately
5179
- pushResult = execSync(`git push ${authenticatedUrl} ${finalBranchName}`, {
5180
- ...opts,
6098
+ pushResult = git(['push', authenticatedUrl, finalBranchName], {
5181
6099
  timeout: 60000, // 60s for slow connections
5182
6100
  stdio: ['pipe', 'pipe', 'pipe'] // capture stdin, stdout, stderr separately
5183
- }).toString();
6101
+ });
5184
6102
  pushed = true;
5185
6103
  console.log(`Pushed with token authentication`);
5186
6104
  } catch (innerErr) {
5187
- // Mask token in error messages before logging
5188
- const maskToken = (str) => str ? str.replace(gitToken, '***TOKEN***') : '';
5189
- const errMsg = maskToken(innerErr.stderr?.toString() || innerErr.stdout?.toString() || innerErr.message || '');
6105
+ // Mask token in error messages before logging. maskUrlCreds
6106
+ // strips URL userinfo (all occurrences, token-agnostic); the
6107
+ // literal maskToken is kept as a second layer for any bare
6108
+ // token echo outside a URL.
6109
+ const maskToken = (str) => str ? str.split(gitToken).join('***TOKEN***') : '';
6110
+ const errMsg = maskUrlCreds(maskToken(innerErr.stderr?.toString() || innerErr.stdout?.toString() || innerErr.message || ''));
5190
6111
  console.warn(`Authenticated push failed: ${errMsg}`);
5191
6112
  pushResult = errMsg;
5192
6113
  }
5193
6114
  } else {
5194
- console.warn(`Could not construct authenticated URL from: ${remoteUrlRaw}`);
6115
+ console.warn(`Could not construct authenticated URL from: ${maskUrlCreds(remoteUrlRaw)}`);
5195
6116
  }
5196
6117
  } catch (pushErr) {
5197
- const maskToken = (str) => str ? str.replace(gitToken, '***TOKEN***') : '';
5198
- console.warn(`Git auth setup failed: ${maskToken(pushErr.message)}`);
5199
- pushResult = maskToken(pushErr.message) || 'Push setup failed';
6118
+ const maskToken = (str) => str ? str.split(gitToken).join('***TOKEN***') : '';
6119
+ const safe = maskUrlCreds(maskToken(pushErr.message));
6120
+ console.warn(`Git auth setup failed: ${safe}`);
6121
+ pushResult = safe || 'Push setup failed';
5200
6122
  }
5201
6123
  }
5202
6124
 
5203
6125
  // Fallback: try push without token (uses local git credentials)
5204
6126
  if (!pushed) {
5205
6127
  try {
5206
- pushResult = execSync(`git push -u origin ${finalBranchName} 2>&1`, opts).toString();
6128
+ pushResult = git(['push', '-u', 'origin', finalBranchName], { stdio: ['pipe', 'pipe', 'pipe'] });
5207
6129
  pushed = true;
5208
6130
  } catch (pushErr) {
5209
6131
  try {
5210
- pushResult = execSync(`git push origin ${finalBranchName} 2>&1`, opts).toString();
6132
+ pushResult = git(['push', 'origin', finalBranchName], { stdio: ['pipe', 'pipe', 'pipe'] });
5211
6133
  pushed = true;
5212
6134
  } catch (pushErr2) {
5213
- console.warn(`Git push failed: ${pushErr2.message}`);
5214
- pushResult = pushErr2.message || 'Push failed no remote configured or auth required';
6135
+ const safe = maskUrlCreds(pushErr2.message);
6136
+ console.warn(`Git push failed: ${safe}`);
6137
+ pushResult = safe || 'Push failed no remote configured or auth required';
5215
6138
  }
5216
6139
  }
5217
6140
  }
@@ -5274,11 +6197,11 @@ app.post("/workspace/git/create-fix-branch", async (req, res) => {
5274
6197
 
5275
6198
  // 10. Switch back to original branch and merge fix to keep changes on disk
5276
6199
  try {
5277
- execSync(`git checkout ${currentBranch}`, opts);
6200
+ git(['checkout', currentBranch]);
5278
6201
  // Merge the fix branch so the working directory has the patched files
5279
6202
  // This keeps the diff viewer working (disk has patched version)
5280
6203
  try {
5281
- execSync(`git merge ${finalBranchName} --no-edit`, opts);
6204
+ git(['merge', finalBranchName, '--no-edit']);
5282
6205
  console.log(`Merged ${finalBranchName} into ${currentBranch}`);
5283
6206
  } catch (mergeErr) {
5284
6207
  // If merge fails (conflicts), just cherry-pick the changes without committing
@@ -5290,7 +6213,7 @@ app.post("/workspace/git/create-fix-branch", async (req, res) => {
5290
6213
  const changedFiles = status.split('\n').map(l => l.trim().split(/\s+/).pop()).filter(Boolean);
5291
6214
  for (const f of changedFiles) {
5292
6215
  try {
5293
- execSync(`git checkout ${finalBranchName} -- ${f}`, opts);
6216
+ git(['checkout', finalBranchName, '--', f]);
5294
6217
  } catch {}
5295
6218
  }
5296
6219
  console.log(`Cherry-picked ${changedFiles.length} file(s) from ${finalBranchName}`);
@@ -5299,17 +6222,17 @@ app.post("/workspace/git/create-fix-branch", async (req, res) => {
5299
6222
  } catch {}
5300
6223
 
5301
6224
  // 11. Auto-create Pull Request if pushed and token available
5302
- // FIXED: suporta GitHub, GitLab e Bitbucket; branding Insptech AI
6225
+ // FIXED: suporta GitHub, GitLab e Bitbucket; branding VisionHammer
5303
6226
  let prUrl = '';
5304
6227
  const prBodyText =
5305
- `## Auto-fix by Insptech AI\n\n` +
6228
+ `## Auto-fix by VisionHammer\n\n` +
5306
6229
  `**Branch:** \`${finalBranchName}\`\n` +
5307
6230
  `**Files changed:** ${status.split('\n').length}\n` +
5308
6231
  `**Commit:** ${commitSha.substring(0, 8)}\n\n` +
5309
- `This pull request was automatically created by Insptech AI after detecting and fixing a production error.\n\n` +
6232
+ `This pull request was automatically created by VisionHammer after detecting and fixing a production error.\n\n` +
5310
6233
  `### Changes\n` +
5311
6234
  status.split('\n').map(l => `- \`${l.trim()}\``).join('\n') + '\n\n' +
5312
- `---\n*Generated by [Insptech AI](https://app.insptech.pt) Autonomous Debugging Platform*`;
6235
+ `---\n*Generated by [VisionHammer](https://visionhammer.io) Autonomous Debugging Platform*`;
5313
6236
 
5314
6237
  if (pushed && gitToken && remoteUrl) {
5315
6238
 
@@ -5871,7 +6794,7 @@ async function _getGitProvider() {
5871
6794
  const providerId = registry.detectProviderFromUrl(remoteUrl);
5872
6795
 
5873
6796
  if (!providerId) {
5874
- console.warn(`Unknown git provider for URL: ${remoteUrl}`);
6797
+ console.warn(`Unknown git provider for URL: ${maskUrlCreds(remoteUrl)}`);
5875
6798
  return null;
5876
6799
  }
5877
6800
 
@@ -5914,7 +6837,7 @@ async function _getGitProvider() {
5914
6837
  // MULTI-WORKSPACE ENDPOINTS
5915
6838
  // ============================================
5916
6839
 
5917
- /** Lista todos os workspaces abertos */
6840
+ /** Lists all open workspaces */
5918
6841
  app.get("/workspaces", (req, res) => {
5919
6842
  res.json({
5920
6843
  workspaces: wsManager.list(),
@@ -5948,7 +6871,7 @@ app.post("/workspace/:workspaceId/open", async (req, res) => {
5948
6871
  }
5949
6872
  });
5950
6873
 
5951
- /** Aplica patch num workspace especfico */
6874
+ /** Applies a patch to a specific workspace */
5952
6875
  app.post("/workspace/:workspaceId/patch", async (req, res) => {
5953
6876
  const { workspaceId } = req.params;
5954
6877
  const { diff } = req.body || {};
@@ -5962,7 +6885,7 @@ app.post("/workspace/:workspaceId/patch", async (req, res) => {
5962
6885
  }
5963
6886
  });
5964
6887
 
5965
- /** Compila e testa num workspace especfico */
6888
+ /** Compiles and tests a specific workspace */
5966
6889
  app.post("/workspace/:workspaceId/test", async (req, res) => {
5967
6890
  const { workspaceId } = req.params;
5968
6891
  try {
@@ -5975,13 +6898,19 @@ app.post("/workspace/:workspaceId/test", async (req, res) => {
5975
6898
  }
5976
6899
  });
5977
6900
 
5978
- /** Executa comando num workspace especfico */
6901
+ /** Runs a command in a specific workspace */
5979
6902
  app.post("/workspace/:workspaceId/run", async (req, res) => {
5980
6903
  const { workspaceId } = req.params;
5981
6904
  const { cmd, args = [] } = req.body || {};
5982
6905
  if (!cmd) return res.status(400).json({ error: "cmd is required" });
5983
6906
  try {
5984
6907
  const root = wsManager.resolveRoot(workspaceId);
6908
+ // SEG-3: reject path arguments that resolve outside this workspace root.
6909
+ const argCheck = checkArgsWithinRoot(root, [cmd, ...args]);
6910
+ if (!argCheck.ok) {
6911
+ console.warn(`[workspace/:id/run] SANDBOX DENIED arg: ${argCheck.arg}`);
6912
+ return res.status(403).json({ error: "command argument escapes the workspace sandbox", arg: argCheck.arg });
6913
+ }
5985
6914
  const out = await run(cmd, args, root, 5 * 60 * 1000);
5986
6915
  res.json({ workspaceId, ...out });
5987
6916
  } catch (e) {
@@ -5994,14 +6923,32 @@ app.post("/workspace/:workspaceId/run", async (req, res) => {
5994
6923
  // WEBSOCKET REVERSE TUNNEL
5995
6924
  // Connects Local Agent to Gateway no public URL needed
5996
6925
  // ============================================
5997
- async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
6926
+ async function startWebSocketTunnel(port, gatewayUrl, authToken, tenantId) {
5998
6927
  if (!gatewayUrl || !tenantId) {
5999
6928
  console.log('WebSocket tunnel skipped: missing gatewayUrl or tenantId');
6000
6929
  return;
6001
6930
  }
6002
6931
 
6003
6932
  const wsUrl = gatewayUrl.replace(/^https:\/\//, 'wss://').replace(/^http:\/\//, 'ws://');
6004
- const fullUrl = `${wsUrl}/api/v1/agent/ws?tenantId=${encodeURIComponent(tenantId)}${apiKey ? '&apiKey=' + encodeURIComponent(apiKey) : ''}`;
6933
+ // SECURITY: the tunnel carries the machine credential and receives commands
6934
+ // the agent executes; never run it in cleartext to a remote host (MITM =
6935
+ // command injection into the customer network). Loopback ws:// stays for dev.
6936
+ try {
6937
+ const u = new URL(wsUrl);
6938
+ const loop = ['localhost', '127.0.0.1', '[::1]', '::1'].includes(u.hostname);
6939
+ if (u.protocol !== 'wss:' && !(u.protocol === 'ws:' && loop)) {
6940
+ console.error(`WebSocket tunnel refused: gateway URL must be https:// (got ${u.protocol}//${u.hostname})`);
6941
+ return;
6942
+ }
6943
+ } catch {
6944
+ console.error('WebSocket tunnel refused: invalid gateway URL');
6945
+ return;
6946
+ }
6947
+ // Credential (the per-machine authorizationToken) goes in the Authorization
6948
+ // header, NOT the URL query (query strings leak to access logs / proxies /
6949
+ // referrers — issue #207). tenantId is not a secret and stays in the query.
6950
+ const fullUrl = `${wsUrl}/api/v1/agent/ws?tenantId=${encodeURIComponent(tenantId)}`;
6951
+ const wsHeaders = authToken ? { Authorization: `Bearer ${authToken}` } : {};
6005
6952
 
6006
6953
  let reconnectDelay = 2000;
6007
6954
  let isShuttingDown = false;
@@ -6011,7 +6958,7 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
6011
6958
  if (isShuttingDown) return;
6012
6959
  try {
6013
6960
  const { default: WebSocket } = await import('ws');
6014
- ws = new WebSocket(fullUrl);
6961
+ ws = new WebSocket(fullUrl, { headers: wsHeaders });
6015
6962
 
6016
6963
  ws.on('open', () => {
6017
6964
  console.log(`WebSocket tunnel connected to Gateway`);
@@ -6028,11 +6975,18 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
6028
6975
  if (text === 'pong') return;
6029
6976
  let request;
6030
6977
  try { request = JSON.parse(text); } catch { return; }
6031
- const { requestId, command, params } = request;
6978
+ const { requestId, command } = request;
6032
6979
  if (!requestId || !command) return;
6033
6980
  console.log(`WS command: ${command}`);
6981
+ // Signed envelope (utils/request-signing.js): a verified command runs
6982
+ // with the signed paramsRaw, never the unsigned params copy.
6983
+ const envelope = verifyWsEnvelope(request, SIGNING);
6984
+ if (!envelope.ok) {
6985
+ ws.send(JSON.stringify({ requestId, command, ok: false, error: "invalid command signature", reason: envelope.reason }));
6986
+ return;
6987
+ }
6034
6988
  let result;
6035
- try { result = await handleWsCommand(command, params || {}); }
6989
+ try { result = await handleWsCommand(command, envelope.params || {}, envelope); }
6036
6990
  catch (err) { result = { error: err.message }; }
6037
6991
  ws.send(JSON.stringify({ requestId, command, ...result }));
6038
6992
  });
@@ -6058,8 +7012,18 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
6058
7012
  }
6059
7013
  }
6060
7014
 
6061
- async function handleWsCommand(command, params) {
7015
+ async function handleWsCommand(command, params, sigCtx = {}) {
6062
7016
  const localBase = `http://localhost:${port}`;
7017
+ // In-process hop to the HTTP API: X-Agent-Token plus, when the envelope
7018
+ // verified, the per-process internal marker carrying the signed identity.
7019
+ const hopHeaders = () => ({
7020
+ ...internalAuthHeaders(AGENT_AUTH),
7021
+ ...(sigCtx.verified ? {
7022
+ ...internalSignatureHeaders(),
7023
+ 'X-VH-Tenant-Id': sigCtx.tenantId || '',
7024
+ 'X-VH-Workspace-Id': sigCtx.workspaceId || '',
7025
+ } : {}),
7026
+ });
6063
7027
  switch (command) {
6064
7028
  case 'health':
6065
7029
  return { status: 'ok', workspace: WORKSPACE_ROOT, openWorkspaces: wsManager ? wsManager.list().length : 0 };
@@ -6073,10 +7037,23 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
6073
7037
  }
6074
7038
 
6075
7039
  case 'workspace.file-content': {
6076
- const { path: filePath } = params;
7040
+ const { path: filePath, workspacePath: wsRootParam } = params;
6077
7041
  if (!filePath) return { error: 'path is required' };
6078
- if (!WORKSPACE_ROOT) return { error: 'No workspace open' };
6079
- const absPath = path.isAbsolute(filePath) ? filePath : path.join(WORKSPACE_ROOT, filePath);
7042
+ // SEG-3: confine to the tenant root the gateway sent (params.workspacePath),
7043
+ // never to the shared mutable WORKSPACE_ROOT global. Absolute paths are
7044
+ // treated as workspace-relative and denied if they escape.
7045
+ const wsRoot = (wsRootParam && wsRootParam.trim()) ? wsRootParam.trim() : WORKSPACE_ROOT;
7046
+ if (!wsRoot) return { error: 'No workspace open' };
7047
+ let absPath;
7048
+ try {
7049
+ absPath = confineToRoot(wsRoot, filePath);
7050
+ } catch (e) {
7051
+ if (e instanceof SandboxViolation) {
7052
+ console.warn(`[ws:file-content] SANDBOX DENIED: ${filePath}`);
7053
+ return { content: null, found: false, error: 'path escapes the workspace sandbox' };
7054
+ }
7055
+ throw e;
7056
+ }
6080
7057
  try {
6081
7058
  const content = await fsPromises.readFile(absPath, 'utf-8');
6082
7059
  return { content, path: filePath, found: true };
@@ -6101,7 +7078,7 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
6101
7078
  const method = command === 'workspace.backups' ? 'GET' : 'POST';
6102
7079
  const res = await fetch(`${localBase}${endpoint}`, {
6103
7080
  method,
6104
- headers: { 'Content-Type': 'application/json' },
7081
+ headers: { 'Content-Type': 'application/json', ...hopHeaders() },
6105
7082
  ...(method === 'POST' ? { body: JSON.stringify(params) } : {})
6106
7083
  });
6107
7084
  return await res.json();
@@ -6125,7 +7102,7 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
6125
7102
  console.log(`WS workspace.diff ${endpoint}`);
6126
7103
  const res = await fetch(`${localBase}${endpoint}`, {
6127
7104
  method: 'GET',
6128
- headers: { 'Content-Type': 'application/json' }
7105
+ headers: { 'Content-Type': 'application/json', ...hopHeaders() }
6129
7106
  });
6130
7107
  return await res.json();
6131
7108
  }
@@ -6137,7 +7114,7 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
6137
7114
  const mcpBase = `http://localhost:5056`;
6138
7115
  const res = await fetch(`${mcpBase}/mcp/read-file`, {
6139
7116
  method: 'POST',
6140
- headers: { 'Content-Type': 'application/json' },
7117
+ headers: { 'Content-Type': 'application/json', ...hopHeaders() },
6141
7118
  body: JSON.stringify({
6142
7119
  workspaceId: params.workspaceId || 'default',
6143
7120
  path: params.path
@@ -6150,7 +7127,7 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
6150
7127
  const mcpBase = `http://localhost:5056`;
6151
7128
  const res = await fetch(`${mcpBase}/mcp/read-files`, {
6152
7129
  method: 'POST',
6153
- headers: { 'Content-Type': 'application/json' },
7130
+ headers: { 'Content-Type': 'application/json', ...hopHeaders() },
6154
7131
  body: JSON.stringify({
6155
7132
  workspaceId: params.workspaceId || 'default',
6156
7133
  paths: params.paths
@@ -6163,7 +7140,7 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
6163
7140
  const mcpBase = `http://localhost:5056`;
6164
7141
  const res = await fetch(`${mcpBase}/mcp/list-directory`, {
6165
7142
  method: 'POST',
6166
- headers: { 'Content-Type': 'application/json' },
7143
+ headers: { 'Content-Type': 'application/json', ...hopHeaders() },
6167
7144
  body: JSON.stringify({
6168
7145
  workspaceId: params.workspaceId || 'default',
6169
7146
  path: params.path || '.',
@@ -6177,7 +7154,7 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
6177
7154
  const mcpBase = `http://localhost:5056`;
6178
7155
  const res = await fetch(`${mcpBase}/mcp/search-code`, {
6179
7156
  method: 'POST',
6180
- headers: { 'Content-Type': 'application/json' },
7157
+ headers: { 'Content-Type': 'application/json', ...hopHeaders() },
6181
7158
  body: JSON.stringify({
6182
7159
  workspaceId: params.workspaceId || 'default',
6183
7160
  query: params.query,
@@ -6192,11 +7169,14 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
6192
7169
  const mcpBase = `http://localhost:5056`;
6193
7170
  const res = await fetch(`${mcpBase}/mcp/execute-command`, {
6194
7171
  method: 'POST',
6195
- headers: { 'Content-Type': 'application/json' },
7172
+ headers: { 'Content-Type': 'application/json', ...hopHeaders() },
6196
7173
  body: JSON.stringify({
6197
7174
  workspaceId: params.workspaceId || 'default',
6198
7175
  command: params.command,
6199
- timeoutMs: params.timeoutMs || 120000
7176
+ timeoutMs: params.timeoutMs || 120000,
7177
+ // exec-policy inputs (allowlist exceptions) travel with the command
7178
+ ...(params.approvalToken ? { approvalToken: params.approvalToken } : {}),
7179
+ ...(params.commandOrigin ? { commandOrigin: params.commandOrigin, tool: params.tool } : {}),
6200
7180
  })
6201
7181
  });
6202
7182
  return await res.json();
@@ -6206,7 +7186,7 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
6206
7186
  const mcpBase = `http://localhost:5056`;
6207
7187
  const res = await fetch(`${mcpBase}/mcp/project-info`, {
6208
7188
  method: 'POST',
6209
- headers: { 'Content-Type': 'application/json' },
7189
+ headers: { 'Content-Type': 'application/json', ...hopHeaders() },
6210
7190
  body: JSON.stringify({
6211
7191
  workspaceId: params.workspaceId || 'default'
6212
7192
  })
@@ -6218,7 +7198,7 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
6218
7198
  const mcpBase = `http://localhost:5056`;
6219
7199
  const res = await fetch(`${mcpBase}/mcp/apply-patch`, {
6220
7200
  method: 'POST',
6221
- headers: { 'Content-Type': 'application/json' },
7201
+ headers: { 'Content-Type': 'application/json', ...hopHeaders() },
6222
7202
  body: JSON.stringify({
6223
7203
  workspaceId: params.workspaceId || 'default',
6224
7204
  diff: params.diff
@@ -6236,7 +7216,7 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
6236
7216
  }
6237
7217
  const res = await fetch(`${mcpBase}/mcp/open`, {
6238
7218
  method: 'POST',
6239
- headers: { 'Content-Type': 'application/json' },
7219
+ headers: { 'Content-Type': 'application/json', ...hopHeaders() },
6240
7220
  body: JSON.stringify({
6241
7221
  workspaceId: params.workspaceId || 'default',
6242
7222
  root: params.root
@@ -6250,8 +7230,22 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
6250
7230
  }
6251
7231
  }
6252
7232
 
6253
- process.on('SIGINT', () => { isShuttingDown = true; if (ws) ws.close(); });
6254
- process.on('SIGTERM', () => { isShuttingDown = true; if (ws) ws.close(); });
7233
+ process.on('SIGINT', async () => {
7234
+ isShuttingDown = true;
7235
+ if (ws) ws.close();
7236
+ // Phase 2: graceful shutdown of legacy connectors (AC-11; bounded to 5 s).
7237
+ try { await stopAll(_legacyAdapters, 5000); } catch (_) { /* never block exit */ }
7238
+ // Preserve existing process model: signal trap does NOT call process.exit();
7239
+ // Node exits naturally once the event loop drains.
7240
+ });
7241
+ process.on('SIGTERM', async () => {
7242
+ isShuttingDown = true;
7243
+ if (ws) ws.close();
7244
+ // Phase 2: graceful shutdown of legacy connectors (AC-11; bounded to 5 s).
7245
+ try { await stopAll(_legacyAdapters, 5000); } catch (_) { /* never block exit */ }
7246
+ // Preserve existing process model: signal trap does NOT call process.exit();
7247
+ // Node exits naturally once the event loop drains.
7248
+ });
6255
7249
  connect();
6256
7250
  }
6257
7251
 
@@ -6264,13 +7258,27 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
6264
7258
  * POST /setup/run-command
6265
7259
  * Run any shell command on the user's machine.
6266
7260
  */
7261
+ // SECURITY: /setup/run-command, /setup/install-tool and /setup/run-auth-command
7262
+ // run arbitrary host commands outside any workspace sandbox (the denylist is
7263
+ // trivially bypassable). No platform component calls them today, so they are
7264
+ // disabled unless the operator explicitly opts in.
7265
+ function setupCommandsEnabled(res) {
7266
+ if (process.env.LOCAL_AGENT_ENABLE_SETUP_COMMANDS === 'true') return true;
7267
+ res.status(403).json({ ok: false, error: "setup commands are disabled on this agent (set LOCAL_AGENT_ENABLE_SETUP_COMMANDS=true to enable)" });
7268
+ return false;
7269
+ }
7270
+
6267
7271
  app.post("/setup/run-command", async (req, res) => {
7272
+ if (!setupCommandsEnabled(res)) return;
6268
7273
  const { command, description, workingDir } = req.body || {};
6269
7274
  if (!command) return res.status(400).json({ ok: false, error: "command is required" });
6270
7275
 
6271
- const dangerous = ['rm -rf /', 'mkfs', 'dd if=', 'shutdown', 'reboot', 'format c:', 'del /f /s /q'];
6272
- if (dangerous.some(d => command.toLowerCase().includes(d))) {
6273
- return res.status(403).json({ ok: false, error: "Command blocked for safety" });
7276
+ const setupDecision = decideExec({
7277
+ command, root: workingDir || WORKSPACE_ROOT || process.cwd(), headers: req.headers, body: req.body || {},
7278
+ vhSignature: req.vhSignature, signingCfg: SIGNING,
7279
+ }, EXEC_POLICY);
7280
+ if (!setupDecision.allow) {
7281
+ return res.status(setupDecision.status || 403).json(rejectionBody(setupDecision));
6274
7282
  }
6275
7283
 
6276
7284
  console.log(`[SetupAgent] Running: ${command.substring(0, 100)}`);
@@ -6282,7 +7290,7 @@ app.post("/setup/run-command", async (req, res) => {
6282
7290
  const cwd = workingDir || WORKSPACE_ROOT || process.env.HOME || process.cwd();
6283
7291
  const result = await execAsync(command, {
6284
7292
  cwd, timeout: 60000, maxBuffer: 2 * 1024 * 1024,
6285
- env: { ...process.env, FORCE_COLOR: '0' }, shell: true
7293
+ env: sanitizedChildEnv(process.env, { FORCE_COLOR: '0' }), shell: true
6286
7294
  });
6287
7295
  res.json({ ok: true, stdout: result.stdout, stderr: result.stderr, exitCode: 0, command });
6288
7296
  } catch (err) {
@@ -6318,6 +7326,8 @@ app.post("/setup/check-ports", async (req, res) => {
6318
7326
  app.get("/setup/check-tool", async (req, res) => {
6319
7327
  const { name } = req.query;
6320
7328
  if (!name) return res.status(400).json({ error: "name is required" });
7329
+ // SECURITY: name is interpolated into a shell command.
7330
+ if (!isSafeToolName(name)) return res.status(400).json({ error: "invalid tool name" });
6321
7331
 
6322
7332
  const { exec: execCb } = await import('child_process');
6323
7333
  const { promisify } = await import('util');
@@ -6340,8 +7350,10 @@ app.get("/setup/check-tool", async (req, res) => {
6340
7350
  * Auto-install a CLI tool using the available package manager.
6341
7351
  */
6342
7352
  app.post("/setup/install-tool", async (req, res) => {
7353
+ if (!setupCommandsEnabled(res)) return;
6343
7354
  const { tool } = req.body || {};
6344
7355
  if (!tool) return res.status(400).json({ error: "tool is required" });
7356
+ if (!isSafeToolName(tool)) return res.status(400).json({ error: "invalid tool name" });
6345
7357
 
6346
7358
  const { exec: execCb } = await import('child_process');
6347
7359
  const { promisify } = await import('util');
@@ -6366,16 +7378,23 @@ app.post("/setup/install-tool", async (req, res) => {
6366
7378
  * Run an auth command (gcloud, az login, gh auth) opening browser on user's machine.
6367
7379
  */
6368
7380
  app.post("/setup/run-auth-command", async (req, res) => {
7381
+ if (!setupCommandsEnabled(res)) return;
6369
7382
  const { command, session_id } = req.body || {};
6370
7383
  if (!command) return res.status(400).json({ error: "command is required" });
7384
+ // H4: allowlisted interactive logins only, spawned without a shell.
7385
+ const parsed = parseAuthCommand(command);
7386
+ if (!parsed.ok) return res.status(400).json({ ok: false, error: parsed.reason });
6371
7387
 
6372
- const { exec: execCb } = await import('child_process');
6373
- const bgCmd = process.platform === 'win32' ? `start cmd /c "${command}"` : `${command} &`;
6374
- console.log(`[SetupAgent] Starting auth: ${command.substring(0, 80)}`);
6375
-
6376
- execCb(bgCmd, { shell: true }, (err) => {
6377
- if (err) console.warn(`Auth command warning: ${err.message}`);
6378
- });
7388
+ console.log(`[SetupAgent] Starting auth: ${parsed.argv.slice(0, 3).join(" ")}`);
7389
+ try {
7390
+ const child = spawn(parsed.argv[0], parsed.argv.slice(1), {
7391
+ detached: true, stdio: "ignore", env: sanitizedChildEnv(process.env),
7392
+ });
7393
+ child.on("error", (err) => console.warn(`Auth command warning: ${err.message}`));
7394
+ child.unref();
7395
+ } catch (err) {
7396
+ console.warn(`Auth command warning: ${err.message}`);
7397
+ }
6379
7398
 
6380
7399
  res.json({ ok: true, status: "started", session_id, message: "Auth command started, browser should open" });
6381
7400
  });
@@ -6855,6 +7874,14 @@ app.delete("/workspace/vibe/tunnel/:sessionId", async (req, res) => {
6855
7874
  // ============================================
6856
7875
  registerVercelRoutes(app);
6857
7876
 
7877
+ // ============================================
7878
+ // OBSERVABILITY PROVISIONING ROUTES
7879
+ // (zero-observability detection -> deploy the client observability bundle).
7880
+ // Registered after createAuthMiddleware, so they need the agent token like every
7881
+ // other route; provisioning runs docker compose on the host.
7882
+ // ============================================
7883
+ registerObservabilityRoutes(app);
7884
+
6858
7885
  // ============================================
6859
7886
  // LIVE PREVIEW — iOS / Android / Web
6860
7887
  // ============================================
@@ -7039,8 +8066,27 @@ function buildPhoneFrame(body, platform) {
7039
8066
  // START SERVER
7040
8067
 
7041
8068
  // ============================================
7042
- app.listen(PORT, '0.0.0.0', async () => {
7043
- console.log(`\n DeepDebug Local Agent listening on port ${PORT}`);
8069
+ // ============================================
8070
+ // PROCESS SAFETY NET (2026-07-20)
8071
+ // A multi-tenant agent must NEVER die because one handler leaked a promise
8072
+ // rejection (Node 20 default = kill the process). The 2026-07-20 QA
8073
+ // crash-loop was exactly that: an unhandled scandir ENOENT in
8074
+ // /workspace/files killed the single instance on every indexer retry,
8075
+ // 503-ing every tenant. Log loudly, keep serving. Per-handler try/catch
8076
+ // remains the first line of defense - this is the last one.
8077
+ // ============================================
8078
+ process.on("unhandledRejection", (reason) => {
8079
+ const msg = reason && reason.stack ? reason.stack : String(reason);
8080
+ console.error(`[SAFETY-NET] Unhandled promise rejection (process kept alive): ${msg}`);
8081
+ });
8082
+ process.on("uncaughtException", (err) => {
8083
+ const msg = err && err.stack ? err.stack : String(err);
8084
+ console.error(`[SAFETY-NET] Uncaught exception (process kept alive): ${msg}`);
8085
+ });
8086
+
8087
+ const BIND_HOST = resolveBindHost(process.env, AGENT_AUTH);
8088
+ app.listen(PORT, BIND_HOST, async () => {
8089
+ console.log(`\n DeepDebug Local Agent listening on ${BIND_HOST}:${PORT}`);
7044
8090
  console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
7045
8091
  console.log(`Process Manager initialized`);
7046
8092
  console.log(`Backup system ready (max: ${MAX_BACKUPS} backups)`);
@@ -7059,6 +8105,13 @@ app.listen(PORT, '0.0.0.0', async () => {
7059
8105
 
7060
8106
  const gwUrl = cfg.gatewayUrl || process.env.GATEWAY_URL;
7061
8107
  const apiKey = cfg.apiKey || process.env.DEEPDEBUG_API_KEY;
8108
+ // The WS tunnel credential is the per-machine authorizationToken minted by
8109
+ // /api/v1/agent/authorize (install.js saves it to config.json and passes it
8110
+ // to the container as AUTH_TOKEN). The gateway authenticates the handshake
8111
+ // with findByAuthorizationToken — NOT the user apiKey. Sending apiKey (the
8112
+ // old behaviour) was rejected 100% of the time (0 connects / 1000+ rejections
8113
+ // in QA). Fall back to apiKey only if no authorizationToken is present.
8114
+ const wsToken = cfg.authorizationToken || process.env.AUTH_TOKEN || apiKey;
7062
8115
  let tenantId = cfg.tenantId || process.env.TENANT_ID;
7063
8116
  if (!tenantId && apiKey) {
7064
8117
  try {
@@ -7068,8 +8121,32 @@ app.listen(PORT, '0.0.0.0', async () => {
7068
8121
  }
7069
8122
 
7070
8123
  if (process.env.DISABLE_WS_TUNNEL !== 'true') {
7071
- startWebSocketTunnel(PORT, gwUrl, apiKey, tenantId);
8124
+ startWebSocketTunnel(PORT, gwUrl, wsToken, tenantId);
7072
8125
  }
8126
+
8127
+ // Sibling plane (started here so the frozen monolith is untouched): metrics
8128
+ // on 5055, the adapter->incident forwarder configured with gateway creds +
8129
+ // metrics, and the capability preflight (logs mode, serves /capabilities).
8130
+ (async () => {
8131
+ try {
8132
+ const [{ startMetrics }, { incidentForwarder }, { startCapabilityPreflight }] = await Promise.all([
8133
+ import('./siblings/metrics.js'),
8134
+ import('./siblings/incident-forwarder.js'),
8135
+ import('./siblings/capabilities.js'),
8136
+ ]);
8137
+ const metrics = startMetrics(app);
8138
+ incidentForwarder.configure({ gatewayUrl: gwUrl, apiKey, tenantId, metrics });
8139
+ // One-shot contract preflight (must not block/prevent startup).
8140
+ incidentForwarder.contractPreflight().catch(() => {});
8141
+ // authToken = the agent API key: unlocks the full inventory on
8142
+ // /capabilities. Unauthenticated callers get health-only.
8143
+ await startCapabilityPreflight(app, {
8144
+ metrics, workspaceRoot: WORKSPACE_ROOT, gatewayUrl: gwUrl, authToken: apiKey,
8145
+ });
8146
+ } catch (err) {
8147
+ console.warn(`[siblings] startup failed: ${err.message}`);
8148
+ }
8149
+ })();
7073
8150
  });
7074
8151
 
7075
8152
  // Start MCP HTTP Bridge em paralelo (port 5056)