deepdebug-local-agent 1.0.22 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/.claude/context.md +76 -0
  2. package/.claude/skills/README.md +25 -0
  3. package/.claude/skills/tdd-debugging/SKILL.md +53 -0
  4. package/.claude/skills/tdd-debugging/references/anti-patterns.md +43 -0
  5. package/.claude/skills/tdd-debugging/references/command-reference.md +46 -0
  6. package/.claude/standards/README.md +51 -0
  7. package/.claude/standards/clean-code.md +75 -0
  8. package/.claude/standards/code-review-checklist.md +61 -0
  9. package/.claude/standards/layered-architecture.md +163 -0
  10. package/.claude/standards/node.md +45 -0
  11. package/.claude/standards/security-owasp.md +114 -0
  12. package/.claude/standards/simplicity.md +42 -0
  13. package/.claude/standards/solid.md +64 -0
  14. package/.claude/standards/testing.md +59 -0
  15. package/.cursor/rules/standards.mdc +17 -0
  16. package/.github/workflows/build-byoc-image.yml +315 -0
  17. package/.github/workflows/deploy-gcp.yml +102 -0
  18. package/.github/workflows/npm-publish.yml +69 -8
  19. package/AGENTS.md +21 -0
  20. package/CHANGELOG.md +69 -0
  21. package/CLAUDE.md +13 -0
  22. package/CLAUDE_CODE_AGENT_INFINITE.md +5 -3
  23. package/Dockerfile +4 -0
  24. package/Dockerfile.full +55 -3
  25. package/README.md +81 -0
  26. package/bin/install.js +16 -5
  27. package/cloudbuild-full.yaml +57 -0
  28. package/docker-compose.yml +5 -3
  29. package/docs/OBSERVABILITY.md +164 -0
  30. package/docs/byoc-agent-image.md +70 -0
  31. package/docs/graphify-packaging-plan.md +133 -0
  32. package/docs/graphify-validation-run-2026-10-28.md +243 -0
  33. package/docs/legacy-connectors.md +268 -0
  34. package/docs/releasing.md +43 -0
  35. package/docs/signed-commands.md +163 -0
  36. package/env.example +30 -0
  37. package/helm/Chart.yaml +2 -2
  38. package/helm/templates/deployment.yaml +13 -3
  39. package/helm/templates/networkpolicy.yaml +22 -0
  40. package/helm/templates/secret.yaml +1 -0
  41. package/helm/values.yaml +13 -14
  42. package/mcp-server.js +4 -1
  43. package/package.json +12 -4
  44. package/scripts/sync-fixtures.mjs +246 -0
  45. package/scripts/upgrade-observability-push.sh +227 -0
  46. package/src/adapters/as400/as400-parser.js +12 -0
  47. package/src/adapters/as400/index.js +134 -0
  48. package/src/adapters/cobol/cobol-parser.js +37 -0
  49. package/src/adapters/cobol/index.js +151 -0
  50. package/src/adapters/ibmmq/ibmmq-parser.js +23 -0
  51. package/src/adapters/ibmmq/index.js +137 -0
  52. package/src/adapters/index.js +79 -0
  53. package/src/adapters/oracle-forms/index.js +145 -0
  54. package/src/adapters/oracle-forms/oracle-forms-parser.js +11 -0
  55. package/src/adapters/sap/index.js +146 -0
  56. package/src/adapters/sap/sap-parser.js +41 -0
  57. package/src/ai-engine.js +22 -22
  58. package/src/config/trusted-signing-keys.json +3 -0
  59. package/src/detectors/build-tool-detector.js +50 -0
  60. package/src/detectors/multi-project-detector.js +22 -22
  61. package/src/detectors/service-detector.js +24 -24
  62. package/src/exec-utils.js +26 -4
  63. package/src/git/base-git-provider.js +117 -117
  64. package/src/git/bitbucket-provider.js +2 -2
  65. package/src/git/git-provider-registry.js +18 -18
  66. package/src/git/github-provider.js +13 -13
  67. package/src/git/multi-repo-pr.js +161 -0
  68. package/src/mcp-http-server.js +32 -16
  69. package/src/observability/bundle-files.js +418 -0
  70. package/src/observability/detect.js +151 -0
  71. package/src/observability/provision.js +212 -0
  72. package/src/observability/render-cli.js +59 -0
  73. package/src/observability/routes.js +88 -0
  74. package/src/patch/patch-engine.js +28 -28
  75. package/src/patch-manager.js +85 -85
  76. package/src/patch_bkp.js +14 -14
  77. package/src/ports.js +4 -4
  78. package/src/routes/workspace.route.js +47 -16
  79. package/src/runtimes/base-runtime.js +75 -75
  80. package/src/runtimes/java/gradle-runtime.js +17 -17
  81. package/src/runtimes/java/java-integrations.js +25 -25
  82. package/src/runtimes/java/maven-runtime.js +34 -34
  83. package/src/runtimes/node/node-integrations.js +1 -1
  84. package/src/runtimes/node/npm-runtime.js +14 -14
  85. package/src/runtimes/node/yarn-runtime.js +4 -4
  86. package/src/runtimes/runtime-registry.js +25 -25
  87. package/src/server-local.js +4 -4
  88. package/src/server.js +1412 -335
  89. package/src/siblings/burst-suppressor.js +89 -0
  90. package/src/siblings/capabilities.js +273 -0
  91. package/src/siblings/env-compat.cjs +58 -0
  92. package/src/siblings/incident-forwarder.js +320 -0
  93. package/src/siblings/logger.js +15 -0
  94. package/src/siblings/metrics.js +94 -0
  95. package/src/utils/agent-auth.js +184 -0
  96. package/src/utils/approval-token.js +56 -0
  97. package/src/utils/bind-host.js +29 -0
  98. package/src/utils/branch-refspec.js +98 -0
  99. package/src/utils/child-env.js +34 -0
  100. package/src/utils/environment-diagnostics.js +55 -55
  101. package/src/utils/exec-policy.js +962 -0
  102. package/src/utils/exec-utils.js +15 -15
  103. package/src/utils/file-count.js +46 -0
  104. package/src/utils/fs-utils.js +10 -10
  105. package/src/utils/multi-repo-open.js +132 -0
  106. package/src/utils/open-gate.js +183 -0
  107. package/src/utils/ranged-read.js +65 -0
  108. package/src/utils/request-guards.js +77 -0
  109. package/src/utils/request-signing.js +342 -0
  110. package/src/utils/root-recovery.js +244 -0
  111. package/src/utils/safe-exec-args.js +58 -0
  112. package/src/utils/sandbox.js +182 -0
  113. package/src/utils/search-outcome.js +56 -0
  114. package/src/utils/workspace-root.js +80 -0
  115. package/src/validators/syntax-validator.js +149 -0
  116. package/src/workspace/detect-port.js +16 -16
  117. package/src/workspace/file-reader.js +1 -1
  118. package/src/workspace/process-manager.js +46 -46
  119. package/src/workspace-manager.js +86 -24
  120. package/test/adapters/as400/as400.test.js +121 -0
  121. package/test/adapters/cobol/cobol.test.js +217 -0
  122. package/test/adapters/ibmmq/ibmmq.test.js +122 -0
  123. package/test/adapters/oracle-forms/oracle-forms.test.js +180 -0
  124. package/test/adapters/registry.test.js +108 -0
  125. package/test/adapters/sap/sap.test.js +142 -0
  126. package/test/agent-auth.test.js +194 -0
  127. package/test/bind-host.test.js +26 -0
  128. package/test/branch-refspec.test.js +224 -0
  129. package/test/burst-suppressor.test.js +60 -0
  130. package/test/capabilities-access.test.js +47 -0
  131. package/test/capabilities.test.js +87 -0
  132. package/test/child-env.test.js +23 -0
  133. package/test/env-compat.test.js +71 -0
  134. package/test/exec-policy.test.js +463 -0
  135. package/test/file-count.test.js +64 -0
  136. package/test/incident-forwarder.test.js +231 -0
  137. package/test/multi-repo-open.test.js +180 -0
  138. package/test/multi-repo-pr.test.js +129 -0
  139. package/test/observability-detect.test.js +199 -0
  140. package/test/open-gate.test.js +341 -0
  141. package/test/ranged-read.test.js +60 -0
  142. package/test/request-guards.test.js +91 -0
  143. package/test/request-signing.test.js +284 -0
  144. package/test/root-recovery-sidecar.test.js +171 -0
  145. package/test/root-recovery.test.js +274 -0
  146. package/test/safe-exec-args.test.js +58 -0
  147. package/test/sandbox.test.js +98 -0
  148. package/test/search-outcome.test.js +50 -0
  149. package/test/workspace-root.test.js +64 -0
  150. package/tools/graphify-validation/README.md +92 -0
  151. package/tools/graphify-validation/config/questions.json +31 -0
  152. package/tools/graphify-validation/config/repos.json +9 -0
  153. package/tools/graphify-validation/config/symbols.json +6 -0
  154. package/tools/graphify-validation/fixtures/spring/expected.json +34 -0
  155. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/AppConfig.java +29 -0
  156. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/AuditLogger.java +15 -0
  157. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/Cache.java +7 -0
  158. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/CheckoutService.java +25 -0
  159. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/InMemoryCache.java +18 -0
  160. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/OrderController.java +22 -0
  161. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/PaymentGateway.java +5 -0
  162. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/PaypalGateway.java +11 -0
  163. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/RefundService.java +14 -0
  164. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/StripeGateway.java +11 -0
  165. package/tools/graphify-validation/lib/affected_vs_rg.py +81 -0
  166. package/tools/graphify-validation/lib/audit_hook/sitecustomize.py +22 -0
  167. package/tools/graphify-validation/lib/graph_metrics.py +96 -0
  168. package/tools/graphify-validation/lib/gv_common.py +130 -0
  169. package/tools/graphify-validation/lib/licenses.py +47 -0
  170. package/tools/graphify-validation/lib/pip_audit_summary.py +18 -0
  171. package/tools/graphify-validation/lib/recall.py +115 -0
  172. package/tools/graphify-validation/lib/report.py +226 -0
  173. package/tools/graphify-validation/lib/run_all.py +237 -0
  174. package/tools/graphify-validation/lib/run_graphify.py +144 -0
  175. package/tools/graphify-validation/lib/rusage_helper.py +17 -0
  176. package/tools/graphify-validation/lib/sample_edges.py +246 -0
  177. package/tools/graphify-validation/lib/spring_fixture.py +81 -0
  178. package/tools/graphify-validation/requirements-py311.lock +508 -0
  179. package/tools/graphify-validation/requirements-tools.in +1 -0
  180. package/tools/graphify-validation/requirements-tools.lock +371 -0
  181. package/tools/graphify-validation/requirements.in +1 -0
  182. package/tools/graphify-validation/requirements.lock +502 -0
  183. package/tools/graphify-validation/run.sh +43 -0
  184. package/tools/graphify-validation/tests/test_harness.py +151 -0
  185. package/cloudbuild-agent-qa.yaml +0 -43
  186. package/cloudbuild-agent-staging.yaml +0 -40
  187. package/cloudbuild.yaml.deprecated +0 -42
  188. package/src/local-agent-source.tar.gz +0 -0
@@ -0,0 +1,182 @@
1
+ /**
2
+ * sandbox.js — per-workspace filesystem/exec confinement (SEG-3, P0).
3
+ *
4
+ * DEFENSE IN DEPTH: the boundary lives HERE, in the agent — not only in the
5
+ * gateway. Even if the gateway passes a wrong path, the agent refuses any
6
+ * path that resolves (after `..` and symlinks) outside the workspace root.
7
+ *
8
+ * A workspace root is the directory that holds the N cloned repos of ONE
9
+ * workspace. Everything under it (repoA/…, repoB/…) is allowed — multi-repo,
10
+ * Copilot-style. Anything outside — a sibling workspace of the same tenant,
11
+ * another tenant, /etc, … — is DENIED.
12
+ *
13
+ * Why the previous checks failed (the A3 escape):
14
+ * - `path.join(root, userPath)` with NO check → `../neighbor/x` escaped.
15
+ * - `fullPath.startsWith(root)` (string prefix, no separator) → a sibling
16
+ * `…/simple-api-2` matched `…/simple-api`, and `path.resolve` after `..`
17
+ * still string-starts-with the root.
18
+ * Both are fixed here: we realpath the root and the resolved target's
19
+ * existing prefix, then require an exact match or a `root + path.sep` prefix.
20
+ */
21
+ import fs from "fs";
22
+ import path from "path";
23
+
24
+ export class SandboxViolation extends Error {
25
+ constructor(message, details = {}) {
26
+ super(message);
27
+ this.name = "SandboxViolation";
28
+ this.details = details;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Realpath the deepest existing ancestor of `p` (p itself may not exist yet,
34
+ * e.g. a file about to be written). This defeats symlinks anywhere along the
35
+ * path while still allowing creation of new files inside the root.
36
+ */
37
+ function realpathExistingPrefix(p) {
38
+ let cur = path.resolve(p);
39
+ const tail = [];
40
+ // Walk up until we hit an existing path, remembering the non-existing tail.
41
+ // Bounded by the number of path segments (no infinite loop at fs root).
42
+ while (!fs.existsSync(cur)) {
43
+ const parent = path.dirname(cur);
44
+ if (parent === cur) break; // reached fs root
45
+ tail.unshift(path.basename(cur));
46
+ cur = parent;
47
+ }
48
+ let realBase;
49
+ try {
50
+ realBase = fs.realpathSync(cur);
51
+ } catch {
52
+ realBase = cur;
53
+ }
54
+ return tail.length ? path.join(realBase, ...tail) : realBase;
55
+ }
56
+
57
+ /**
58
+ * Resolve `requested` against `root` and confine it to `root`.
59
+ *
60
+ * @param {string} root Absolute workspace root (the dir holding the repos).
61
+ * @param {string} requested Path from the caller: relative (repoA/src/x), or
62
+ * absolute — an absolute path is allowed ONLY when it
63
+ * is already inside the root; an absolute path that
64
+ * points outside (another workspace, /etc, …) is
65
+ * DENIED, not silently re-based.
66
+ * @returns {string} The confined absolute path (safe to fs-op / cwd).
67
+ * @throws {SandboxViolation} when it resolves outside the root.
68
+ */
69
+ export function confineToRoot(root, requested) {
70
+ if (!root || typeof root !== "string") {
71
+ throw new SandboxViolation("workspace root not set", { root });
72
+ }
73
+ const realRoot = realpathExistingPrefix(root);
74
+
75
+ const req = requested == null ? "" : String(requested);
76
+ // Absolute → resolve as filesystem-absolute (must land inside root).
77
+ // Relative → resolve against root. Either way, symlinks/`..` are collapsed by
78
+ // realpathExistingPrefix before the containment check.
79
+ const target = path.isAbsolute(req)
80
+ ? realpathExistingPrefix(req)
81
+ : realpathExistingPrefix(path.resolve(realRoot, req));
82
+
83
+ if (target !== realRoot && !target.startsWith(realRoot + path.sep)) {
84
+ throw new SandboxViolation("path escapes the workspace sandbox", {
85
+ root: realRoot,
86
+ requested: req,
87
+ resolved: target,
88
+ });
89
+ }
90
+ return target;
91
+ }
92
+
93
+ /** Boolean form — never throws. */
94
+ export function isInsideRoot(root, requested) {
95
+ try {
96
+ confineToRoot(root, requested);
97
+ return true;
98
+ } catch {
99
+ return false;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Best-effort confinement for a run_command whose arguments are tokenized
105
+ * (e.g. /workspace/run with args[]). Rejects any argument that looks like a
106
+ * path (absolute, or containing a `..` segment) and resolves outside the root.
107
+ * cwd is always pinned to the root by the caller; this catches explicit
108
+ * out-of-root path arguments. Raw-shell endpoints get the scanRawCommand
109
+ * heuristic instead.
110
+ *
111
+ * @returns {{ok:true}|{ok:false,arg:string,resolved:string}}
112
+ */
113
+ export function checkArgsWithinRoot(root, args = []) {
114
+ const realRoot = realpathExistingPrefix(root);
115
+ for (const a of args) {
116
+ if (typeof a !== "string") continue;
117
+ const looksPath = path.isAbsolute(a) || a.includes("..") || a.includes("/") || a.includes("\\");
118
+ if (!looksPath) continue;
119
+
120
+ if (path.isAbsolute(a)) {
121
+ // Absolute arg → resolve as FILESYSTEM-absolute (do NOT strip the leading
122
+ // slash and re-base under root — that rebasing let `/etc/passwd` and
123
+ // `/tmp/workspaces/<other>` pass the containment check while `run()`
124
+ // still executed the real absolute path). Mirror scanRawCommand: allow
125
+ // only paths inside the root or a normal system-binary/toolchain path;
126
+ // deny anything else (other workspace roots, /etc, /home, /tmp, …).
127
+ const resolved = path.resolve(a);
128
+ const inside = resolved === realRoot || resolved.startsWith(realRoot + path.sep);
129
+ const systemBinary = /^\/(usr|bin|sbin|lib|lib64|opt|etc\/alternatives)(\/|$)/.test(resolved);
130
+ const sensitive = /^\/(tmp\/workspaces|mnt\/workspaces|home|root|workspace|Users|etc|var\/secrets)(\/|$)/.test(resolved);
131
+ if (!inside && (sensitive || (!systemBinary && /^\/(tmp|var|mnt)/.test(resolved)))) {
132
+ return { ok: false, arg: a, resolved };
133
+ }
134
+ continue;
135
+ }
136
+
137
+ // Relative arg → resolve against the root. `..` that climbs out escapes.
138
+ const resolved = path.resolve(realRoot, a);
139
+ if (a.includes("..") &&
140
+ resolved !== realRoot && !resolved.startsWith(realRoot + path.sep)) {
141
+ return { ok: false, arg: a, resolved };
142
+ }
143
+ }
144
+ return { ok: true };
145
+ }
146
+
147
+ /**
148
+ * Best-effort scan of a raw shell command string for out-of-root escapes:
149
+ * absolute paths outside root, `..` traversal, and cwd changes (`cd /...`).
150
+ * This is a heuristic — full shell containment needs OS namespaces — but it
151
+ * blocks the obvious `cat ../neighbor/x` / `cd /etc` cases. cwd stays pinned.
152
+ *
153
+ * @returns {{ok:true}|{ok:false,reason:string,token:string}}
154
+ */
155
+ export function scanRawCommand(root, command) {
156
+ if (!command || typeof command !== "string") return { ok: true };
157
+ const realRoot = realpathExistingPrefix(root);
158
+ // Tokens that look like filesystem paths.
159
+ const tokens = command.split(/\s+|["'`;|&(){}<>]+/).filter(Boolean);
160
+ for (const t of tokens) {
161
+ // `..` traversal anywhere.
162
+ if (/(^|\/)\.\.(\/|$)/.test(t)) {
163
+ const resolved = path.resolve(realRoot, t.replace(/^[/\\]+/, ""));
164
+ if (resolved !== realRoot && !resolved.startsWith(realRoot + path.sep)) {
165
+ return { ok: false, reason: "parent-traversal escapes workspace", token: t };
166
+ }
167
+ }
168
+ // Absolute path that is neither inside the root nor a normal system binary
169
+ // (allow /usr, /bin, /opt toolchains; deny absolute paths into other
170
+ // workspace roots, /home, /mnt, /etc, /tmp/workspaces/<other>, …).
171
+ if (path.isAbsolute(t)) {
172
+ const resolved = path.resolve(t);
173
+ const inside = resolved === realRoot || resolved.startsWith(realRoot + path.sep);
174
+ const systemBinary = /^\/(usr|bin|sbin|lib|lib64|opt|etc\/alternatives)(\/|$)/.test(resolved);
175
+ const sensitive = /^\/(tmp\/workspaces|mnt\/workspaces|home|root|workspace|Users|etc|var\/secrets)(\/|$)/.test(resolved);
176
+ if (!inside && (sensitive || (!systemBinary && /^\/(tmp|var|mnt)/.test(resolved)))) {
177
+ return { ok: false, reason: "absolute path outside workspace", token: t };
178
+ }
179
+ }
180
+ }
181
+ return { ok: true };
182
+ }
@@ -0,0 +1,56 @@
1
+ // search-outcome.js - pure classification of a grep failure for POST /workspace/search.
2
+ //
3
+ // grep over a multi-repo parent (6 clones, no file filter) can exceed the 15 s
4
+ // execSync timeout. execSync then throws with `killed: true` / `signal: 'SIGTERM'`
5
+ // and `status: null`, and the handler fell through to `throw grepErr` -> HTTP 500.
6
+ // The platform then raised an incident on its own agent (QA 2026-10-14..15,
7
+ // hash b83215a8) and the AI, calling search_code in a loop, saw "search_code
8
+ // error: 500" instead of the lines grep had already found.
9
+ //
10
+ // A timeout (or an output cap) is an answer, not a crash: return what grep
11
+ // produced so far, say it is partial, and tell the caller how to narrow.
12
+
13
+ /**
14
+ * @param {object} grepErr the error execSync threw
15
+ * @param {object} [opts]
16
+ * @param {number} [opts.timeoutMs] the timeout that was configured (for the hint)
17
+ * @returns {{kind: 'no_match'} | {kind: 'bad_pattern', message: string}
18
+ * | {kind: 'partial', reason: 'search_timeout'|'output_cap', stdout: string}
19
+ * | {kind: 'unknown'}}
20
+ */
21
+ export function classifyGrepFailure(grepErr, { timeoutMs } = {}) {
22
+ if (!grepErr) return { kind: 'unknown' };
23
+ // grep exit 1 = no matches (only when grep itself finished)
24
+ if (grepErr.status === 1 && !grepErr.killed) return { kind: 'no_match' };
25
+ // grep exit 2 = pattern/usage error
26
+ if (grepErr.status === 2 && !grepErr.killed) {
27
+ return { kind: 'bad_pattern', message: String(grepErr.message || '') };
28
+ }
29
+ const stdout = typeof grepErr.stdout === 'string' ? grepErr.stdout : '';
30
+ if (grepErr.killed || grepErr.signal === 'SIGTERM' || grepErr.code === 'ETIMEDOUT') {
31
+ return { kind: 'partial', reason: 'search_timeout', stdout, timeoutMs };
32
+ }
33
+ if (grepErr.code === 'ENOBUFS') {
34
+ return { kind: 'partial', reason: 'output_cap', stdout };
35
+ }
36
+ return { kind: 'unknown' };
37
+ }
38
+
39
+ /** Body for a partial outcome: HTTP 200, ok:false, what we have, and how to narrow. */
40
+ export function partialSearchBody(pattern, outcome, relativize) {
41
+ const lines = (outcome.stdout || '').split('\n').filter((l) => l.trim());
42
+ const results = lines.map(relativize).join('\n');
43
+ const why = outcome.reason === 'search_timeout'
44
+ ? `grep exceeded ${Math.round((outcome.timeoutMs || 0) / 1000)} s`
45
+ : 'grep output exceeded the buffer';
46
+ return {
47
+ ok: false,
48
+ error: outcome.reason,
49
+ partial: true,
50
+ results,
51
+ count: lines.length,
52
+ hint: `${why} for pattern "${pattern}". The ${lines.length} match(es) above are partial. `
53
+ + 'Narrow the search: pass file_filter (e.g. "*.js"), name the repo (repo=<dir>) '
54
+ + 'in a multi-repo workspace, or use a more specific pattern.'
55
+ };
56
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * workspace-root.js — validation of the X-Workspace-Root header.
3
+ *
4
+ * WHY. resolveWorkspaceRoot() used the header verbatim as the confinement root
5
+ * for every exec/file route, and the header is NOT part of the Ed25519
6
+ * canonical string (request-signing.js). So a caller could name `/` or another
7
+ * tenant's directory on the shared NFS and the "sandbox" confined paths to
8
+ * that: the sandbox was self-declared (security review 2026-10-11, H4).
9
+ *
10
+ * Rule: the root must be an absolute, normalised path strictly inside one of
11
+ * the workspace base directories (WORKSPACES_MOUNT, WORKSPACE_MOUNT,
12
+ * WORKSPACE_ROOT, LOCAL_AGENT_WORKSPACE_BASES) or be / be inside a workspace
13
+ * registered in the WorkspaceManager. When the request carries a VERIFIED
14
+ * signature with a tenantId, a registered workspace that belongs to another
15
+ * tenant is refused. Pure function, unit-tested in test/workspace-root.test.js.
16
+ */
17
+ import path from "node:path";
18
+
19
+ /**
20
+ * WORKSPACE_ROOT (docker-compose / Helm single-workspace agents) IS a
21
+ * workspace, not a directory of workspaces: accepted exactly or below.
22
+ */
23
+ export function exactWorkspaceRoots(env = process.env) {
24
+ const s = String(env.WORKSPACE_ROOT || "").trim();
25
+ if (!s || !path.isAbsolute(s)) return [];
26
+ const n = path.resolve(s);
27
+ return n === path.parse(n).root ? [] : [n];
28
+ }
29
+
30
+ export function workspaceBases(env = process.env) {
31
+ const raw = [
32
+ env.WORKSPACES_MOUNT || "/mnt/workspaces",
33
+ env.WORKSPACE_MOUNT || "/tmp/workspaces",
34
+ ...String(env.LOCAL_AGENT_WORKSPACE_BASES || "").split(","),
35
+ ];
36
+ const out = new Set();
37
+ for (const b of raw) {
38
+ const s = String(b || "").trim();
39
+ if (!s || !path.isAbsolute(s)) continue;
40
+ const n = path.resolve(s);
41
+ if (n !== path.parse(n).root) out.add(n);
42
+ }
43
+ return [...out];
44
+ }
45
+
46
+ function inside(base, p) {
47
+ const rel = path.relative(base, p);
48
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
49
+ }
50
+
51
+ /**
52
+ * @param {string} raw header value
53
+ * @param {{bases?: string[], exact?: string[], registry?: Array<{root?: string, tenantId?: string|null}>, tenantId?: string|null}} opts
54
+ * tenantId = tenant bound by a VERIFIED signature (undefined when unsigned).
55
+ * @returns {{ok: true, root: string} | {ok: false, reason: string}}
56
+ */
57
+ export function validateHeaderRoot(raw, opts = {}) {
58
+ const s = String(raw || "").trim();
59
+ if (!s) return { ok: false, reason: "empty root" };
60
+ if (s.includes("\0")) return { ok: false, reason: "invalid characters in root" };
61
+ if (!path.isAbsolute(s)) return { ok: false, reason: "root must be an absolute path" };
62
+ const root = path.resolve(s);
63
+ if (root === path.parse(root).root) return { ok: false, reason: "filesystem root is not a workspace" };
64
+
65
+ const registry = Array.isArray(opts.registry) ? opts.registry : [];
66
+ const owner = registry.find(w => w && w.root && inside(path.resolve(w.root), root));
67
+ if (owner && opts.tenantId && owner.tenantId && owner.tenantId !== opts.tenantId) {
68
+ return { ok: false, reason: "root belongs to another tenant" };
69
+ }
70
+ if (owner) return { ok: true, root };
71
+
72
+ const exact = opts.exact || exactWorkspaceRoots();
73
+ if (exact.some(e => inside(e, root))) return { ok: true, root };
74
+
75
+ const bases = opts.bases || workspaceBases();
76
+ // A workspace is a child of a base (the base itself is shared by every
77
+ // tenant); the tenant/workspace segment must exist (strictly inside).
78
+ if (bases.some(b => inside(b, root) && root !== b)) return { ok: true, root };
79
+ return { ok: false, reason: "root is outside every workspace base directory" };
80
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * syntax-validator.js — Camada 2 (SyntaxValidator) of the fix validation
3
+ * ladder. Parse-only, NEVER builds. Resolved per file extension:
4
+ *
5
+ * .js/.jsx/.mjs/.cjs/.ts/.tsx → TypeScript compiler front-end (pure JS,
6
+ * syntactic diagnostics only — no type check, no emit).
7
+ * .swift → tree-sitter + tree-sitter-swift (native prebuilds; the grammar
8
+ * is used INSTEAD of `swiftc -parse` on purpose: swiftc fails on
9
+ * unresolved iOS framework imports, the grammar does not).
10
+ * .java → skipped here (the engine validates Java in-process with
11
+ * JavaParser, which is stricter than tree-sitter).
12
+ * anything else → validator 'none' (tier degrades, never blocks).
13
+ *
14
+ * Every dependency is lazy-loaded and failure-isolated: a missing/broken
15
+ * parser downgrades that file to {validator:'none'} instead of erroring.
16
+ */
17
+ import path from 'path';
18
+ import { promises as fs } from 'fs';
19
+ import { createRequire } from 'module';
20
+
21
+ const require = createRequire(import.meta.url);
22
+
23
+ const MAX_PROBLEMS_PER_FILE = 5;
24
+ const MAX_FILE_BYTES = 1_000_000;
25
+
26
+ let tsModule; // lazy typescript
27
+ let swiftParser; // lazy tree-sitter parser wired to swift grammar
28
+ let swiftLoadFailed = false;
29
+
30
+ function loadTypescript() {
31
+ if (tsModule !== undefined) return tsModule;
32
+ try {
33
+ tsModule = require('typescript');
34
+ } catch (err) {
35
+ console.warn('[SyntaxValidator] typescript unavailable, JS/TS tier degrades:', err.message);
36
+ tsModule = null;
37
+ }
38
+ return tsModule;
39
+ }
40
+
41
+ function loadSwiftParser() {
42
+ if (swiftParser || swiftLoadFailed) return swiftParser || null;
43
+ try {
44
+ const Parser = require('tree-sitter');
45
+ const Swift = require('tree-sitter-swift');
46
+ const parser = new Parser();
47
+ parser.setLanguage(Swift);
48
+ swiftParser = parser;
49
+ } catch (err) {
50
+ console.warn('[SyntaxValidator] tree-sitter-swift unavailable, Swift tier degrades:', err.message);
51
+ swiftLoadFailed = true;
52
+ }
53
+ return swiftParser || null;
54
+ }
55
+
56
+ function validateWithTypescript(filePath, source) {
57
+ const ts = loadTypescript();
58
+ if (!ts) return { validator: 'none', ok: true, problems: [] };
59
+
60
+ const ext = path.extname(filePath).toLowerCase();
61
+ const scriptKind =
62
+ ext === '.tsx' ? ts.ScriptKind.TSX :
63
+ ext === '.jsx' ? ts.ScriptKind.JSX :
64
+ ext === '.ts' ? ts.ScriptKind.TS :
65
+ ts.ScriptKind.JS;
66
+
67
+ const sf = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, scriptKind);
68
+ // parseDiagnostics is a stable-in-practice internal: syntactic errors only.
69
+ const diags = sf.parseDiagnostics || [];
70
+ const problems = diags.slice(0, MAX_PROBLEMS_PER_FILE).map(d => {
71
+ const { line, character } = ts.getLineAndCharacterOfPosition(sf, d.start ?? 0);
72
+ return {
73
+ line: line + 1,
74
+ col: character + 1,
75
+ message: ts.flattenDiagnosticMessageText(d.messageText, ' '),
76
+ };
77
+ });
78
+ return { validator: 'typescript', ok: problems.length === 0, problems };
79
+ }
80
+
81
+ function collectTreeSitterErrors(node, problems) {
82
+ if (problems.length >= MAX_PROBLEMS_PER_FILE) return;
83
+ if (node.type === 'ERROR' || node.isMissing) {
84
+ problems.push({
85
+ line: node.startPosition.row + 1,
86
+ col: node.startPosition.column + 1,
87
+ message: node.isMissing
88
+ ? `missing ${node.type}`
89
+ : `syntax error near "${node.text.slice(0, 60).replace(/\n/g, '\\n')}"`,
90
+ });
91
+ return; // children of an ERROR node are noise
92
+ }
93
+ if (!node.hasError) return; // prune healthy subtrees
94
+ for (let i = 0; i < node.childCount; i++) {
95
+ collectTreeSitterErrors(node.child(i), problems);
96
+ if (problems.length >= MAX_PROBLEMS_PER_FILE) return;
97
+ }
98
+ }
99
+
100
+ function validateSwift(source) {
101
+ const parser = loadSwiftParser();
102
+ if (!parser) return { validator: 'none', ok: true, problems: [] };
103
+ const tree = parser.parse(source);
104
+ const problems = [];
105
+ if (tree.rootNode.hasError) collectTreeSitterErrors(tree.rootNode, problems);
106
+ if (tree.rootNode.hasError && problems.length === 0) {
107
+ problems.push({ line: 1, col: 1, message: 'unparseable swift (no detail)' });
108
+ }
109
+ return { validator: 'tree-sitter-swift', ok: problems.length === 0, problems };
110
+ }
111
+
112
+ const JS_TS_EXTS = new Set(['.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx']);
113
+
114
+ /**
115
+ * Validate a list of workspace-relative paths. Path traversal outside the
116
+ * workspace root is rejected per-file (never throws for the whole batch).
117
+ */
118
+ export async function validateFiles(workspaceRoot, relativePaths) {
119
+ const results = [];
120
+ for (const rel of relativePaths || []) {
121
+ const entry = { path: rel, validator: 'none', ok: true, problems: [] };
122
+ try {
123
+ const abs = path.resolve(workspaceRoot, rel);
124
+ if (!abs.startsWith(path.resolve(workspaceRoot) + path.sep)) {
125
+ results.push({ ...entry, ok: false, validator: 'security',
126
+ problems: [{ line: 0, col: 0, message: 'path escapes workspace root' }] });
127
+ continue;
128
+ }
129
+ const stat = await fs.stat(abs).catch(() => null);
130
+ if (!stat || !stat.isFile() || stat.size > MAX_FILE_BYTES) {
131
+ results.push(entry); // absent/huge → skip, never block
132
+ continue;
133
+ }
134
+ const source = await fs.readFile(abs, 'utf8');
135
+ const ext = path.extname(rel).toLowerCase();
136
+ if (JS_TS_EXTS.has(ext)) {
137
+ results.push({ path: rel, ...validateWithTypescript(rel, source) });
138
+ } else if (ext === '.swift') {
139
+ results.push({ path: rel, ...validateSwift(source) });
140
+ } else {
141
+ results.push(entry); // .java handled engine-side; others no-op
142
+ }
143
+ } catch (err) {
144
+ console.warn(`[SyntaxValidator] ${rel} crashed, failing open:`, err.message);
145
+ results.push(entry);
146
+ }
147
+ }
148
+ return results;
149
+ }
@@ -1,14 +1,14 @@
1
- // Adicionar no server.js do Local Agent
1
+ // Add this to the Local Agent's server.js
2
2
 
3
3
  /**
4
4
  * GET /workspace/detect-port
5
5
  *
6
- * Detecta porta de um serviço específico lendo arquivos de configuração
6
+ * Detects the port of a specific service by reading configuration files
7
7
  *
8
8
  * Query params:
9
- * - servicePath: caminho relativo do serviço
10
- * - language: linguagem do serviço
11
- * - framework: framework do serviço
9
+ * - servicePath: relative path of the service
10
+ * - language: language of the service
11
+ * - framework: framework of the service
12
12
  */
13
13
  app.get("/workspace/detect-port", async (req, res) => {
14
14
  if (!WORKSPACE_ROOT) {
@@ -32,7 +32,7 @@ app.get("/workspace/detect-port", async (req, res) => {
32
32
 
33
33
  let port = null;
34
34
 
35
- // Detectar porta baseado na linguagem/framework
35
+ // Detect the port based on language/framework
36
36
  if (language === 'java' && framework === 'spring-boot') {
37
37
  port = await detectSpringBootPort(fullPath);
38
38
  } else if (language === 'node') {
@@ -53,7 +53,7 @@ app.get("/workspace/detect-port", async (req, res) => {
53
53
  });
54
54
 
55
55
  /**
56
- * Detecta porta do Spring Boot
56
+ * Detects the Spring Boot port
57
57
  */
58
58
  async function detectSpringBootPort(servicePath) {
59
59
  const candidates = [
@@ -95,10 +95,10 @@ async function detectSpringBootPort(servicePath) {
95
95
  }
96
96
 
97
97
  /**
98
- * Detecta porta do Node.js
98
+ * Detects the Node.js port
99
99
  */
100
100
  async function detectNodePort(servicePath) {
101
- // Tentar .env
101
+ // Try .env
102
102
  const envPath = path.join(servicePath, '.env');
103
103
  if (await exists(envPath)) {
104
104
  const content = await readFile(envPath, 'utf8');
@@ -109,13 +109,13 @@ async function detectNodePort(servicePath) {
109
109
  }
110
110
  }
111
111
 
112
- // Tentar package.json scripts
112
+ // Try package.json scripts
113
113
  const pkgPath = path.join(servicePath, 'package.json');
114
114
  if (await exists(pkgPath)) {
115
115
  const content = await readFile(pkgPath, 'utf8');
116
116
  const pkg = JSON.parse(content);
117
117
 
118
- // Procurar por --port ou PORT= nos scripts
118
+ // Look for --port or PORT= in the scripts
119
119
  const scripts = JSON.stringify(pkg.scripts || {});
120
120
  const match = scripts.match(/--port[=\s]+(\d+)|PORT[=\s]+(\d+)/i);
121
121
  if (match) {
@@ -130,10 +130,10 @@ async function detectNodePort(servicePath) {
130
130
  }
131
131
 
132
132
  /**
133
- * Detecta porta do Python
133
+ * Detects the Python port
134
134
  */
135
135
  async function detectPythonPort(servicePath) {
136
- // Tentar .env
136
+ // Try .env
137
137
  const envPath = path.join(servicePath, '.env');
138
138
  if (await exists(envPath)) {
139
139
  const content = await readFile(envPath, 'utf8');
@@ -144,13 +144,13 @@ async function detectPythonPort(servicePath) {
144
144
  }
145
145
  }
146
146
 
147
- // Defaults por framework
147
+ // Defaults per framework
148
148
  console.log('⚠️ No port config found, using framework default');
149
149
  return 8000; // Default Django/FastAPI
150
150
  }
151
151
 
152
152
  /**
153
- * Detecta porta do .NET
153
+ * Detects the .NET port
154
154
  */
155
155
  async function detectDotNetPort(servicePath) {
156
156
  const launchSettings = path.join(servicePath, 'Properties/launchSettings.json');
@@ -159,7 +159,7 @@ async function detectDotNetPort(servicePath) {
159
159
  const content = await readFile(launchSettings, 'utf8');
160
160
  const settings = JSON.parse(content);
161
161
 
162
- // Procurar em profiles
162
+ // Look inside profiles
163
163
  const profiles = settings.profiles || {};
164
164
  for (const profile of Object.values(profiles)) {
165
165
  if (profile.applicationUrl) {
@@ -10,7 +10,7 @@ export class FileReader {
10
10
  async read(relativePath) {
11
11
  const fullPath = path.join(this.rootPath, relativePath);
12
12
 
13
- // Validação de segurança - previne path traversal
13
+ // Security validation - prevents path traversal
14
14
  if (!fullPath.startsWith(this.rootPath)) {
15
15
  throw new Error('Path traversal detected');
16
16
  }