deepdebug-local-agent 1.0.23 → 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.
- package/.claude/context.md +76 -0
- package/.claude/skills/README.md +25 -0
- package/.claude/skills/tdd-debugging/SKILL.md +53 -0
- package/.claude/skills/tdd-debugging/references/anti-patterns.md +43 -0
- package/.claude/skills/tdd-debugging/references/command-reference.md +46 -0
- package/.claude/standards/README.md +51 -0
- package/.claude/standards/clean-code.md +75 -0
- package/.claude/standards/code-review-checklist.md +61 -0
- package/.claude/standards/layered-architecture.md +163 -0
- package/.claude/standards/node.md +45 -0
- package/.claude/standards/security-owasp.md +114 -0
- package/.claude/standards/simplicity.md +42 -0
- package/.claude/standards/solid.md +64 -0
- package/.claude/standards/testing.md +59 -0
- package/.cursor/rules/standards.mdc +17 -0
- package/.github/workflows/build-byoc-image.yml +315 -0
- package/.github/workflows/deploy-gcp.yml +102 -0
- package/.github/workflows/npm-publish.yml +69 -8
- package/AGENTS.md +21 -0
- package/CHANGELOG.md +69 -0
- package/CLAUDE.md +13 -0
- package/CLAUDE_CODE_AGENT_INFINITE.md +5 -3
- package/Dockerfile +4 -0
- package/Dockerfile.full +55 -3
- package/bin/install.js +16 -5
- package/cloudbuild-full.yaml +57 -0
- package/docker-compose.yml +5 -3
- package/docs/OBSERVABILITY.md +164 -0
- package/docs/byoc-agent-image.md +70 -0
- package/docs/graphify-packaging-plan.md +133 -0
- package/docs/graphify-validation-run-2026-10-28.md +243 -0
- package/docs/releasing.md +43 -0
- package/docs/signed-commands.md +163 -0
- package/env.example +30 -0
- package/helm/Chart.yaml +2 -2
- package/helm/templates/deployment.yaml +13 -3
- package/helm/templates/networkpolicy.yaml +22 -0
- package/helm/templates/secret.yaml +1 -0
- package/helm/values.yaml +13 -14
- package/mcp-server.js +4 -1
- package/package.json +9 -4
- package/scripts/upgrade-observability-push.sh +227 -0
- package/src/ai-engine.js +22 -22
- package/src/config/trusted-signing-keys.json +3 -0
- package/src/detectors/build-tool-detector.js +50 -0
- package/src/detectors/multi-project-detector.js +22 -22
- package/src/detectors/service-detector.js +24 -24
- package/src/exec-utils.js +26 -4
- package/src/git/base-git-provider.js +117 -117
- package/src/git/bitbucket-provider.js +2 -2
- package/src/git/git-provider-registry.js +18 -18
- package/src/git/github-provider.js +13 -13
- package/src/git/multi-repo-pr.js +161 -0
- package/src/mcp-http-server.js +32 -16
- package/src/observability/bundle-files.js +418 -0
- package/src/observability/detect.js +151 -0
- package/src/observability/provision.js +212 -0
- package/src/observability/render-cli.js +59 -0
- package/src/observability/routes.js +88 -0
- package/src/patch/patch-engine.js +28 -28
- package/src/patch-manager.js +85 -85
- package/src/patch_bkp.js +14 -14
- package/src/ports.js +4 -4
- package/src/routes/workspace.route.js +47 -16
- package/src/runtimes/base-runtime.js +75 -75
- package/src/runtimes/java/gradle-runtime.js +17 -17
- package/src/runtimes/java/java-integrations.js +25 -25
- package/src/runtimes/java/maven-runtime.js +34 -34
- package/src/runtimes/node/node-integrations.js +1 -1
- package/src/runtimes/node/npm-runtime.js +14 -14
- package/src/runtimes/node/yarn-runtime.js +4 -4
- package/src/runtimes/runtime-registry.js +25 -25
- package/src/server-local.js +4 -4
- package/src/server.js +1366 -333
- package/src/siblings/burst-suppressor.js +89 -0
- package/src/siblings/capabilities.js +273 -0
- package/src/siblings/env-compat.cjs +58 -0
- package/src/siblings/incident-forwarder.js +320 -0
- package/src/siblings/logger.js +15 -0
- package/src/siblings/metrics.js +94 -0
- package/src/utils/agent-auth.js +184 -0
- package/src/utils/approval-token.js +56 -0
- package/src/utils/bind-host.js +29 -0
- package/src/utils/branch-refspec.js +98 -0
- package/src/utils/child-env.js +34 -0
- package/src/utils/environment-diagnostics.js +55 -55
- package/src/utils/exec-policy.js +962 -0
- package/src/utils/exec-utils.js +15 -15
- package/src/utils/file-count.js +46 -0
- package/src/utils/fs-utils.js +10 -10
- package/src/utils/multi-repo-open.js +132 -0
- package/src/utils/open-gate.js +183 -0
- package/src/utils/ranged-read.js +65 -0
- package/src/utils/request-guards.js +77 -0
- package/src/utils/request-signing.js +342 -0
- package/src/utils/root-recovery.js +244 -0
- package/src/utils/safe-exec-args.js +58 -0
- package/src/utils/sandbox.js +182 -0
- package/src/utils/search-outcome.js +56 -0
- package/src/utils/workspace-root.js +80 -0
- package/src/validators/syntax-validator.js +149 -0
- package/src/workspace/detect-port.js +16 -16
- package/src/workspace/file-reader.js +1 -1
- package/src/workspace/process-manager.js +46 -46
- package/src/workspace-manager.js +86 -24
- package/test/adapters/registry.test.js +9 -2
- package/test/agent-auth.test.js +194 -0
- package/test/bind-host.test.js +26 -0
- package/test/branch-refspec.test.js +224 -0
- package/test/burst-suppressor.test.js +60 -0
- package/test/capabilities-access.test.js +47 -0
- package/test/capabilities.test.js +87 -0
- package/test/child-env.test.js +23 -0
- package/test/env-compat.test.js +71 -0
- package/test/exec-policy.test.js +463 -0
- package/test/file-count.test.js +64 -0
- package/test/incident-forwarder.test.js +231 -0
- package/test/multi-repo-open.test.js +180 -0
- package/test/multi-repo-pr.test.js +129 -0
- package/test/observability-detect.test.js +199 -0
- package/test/open-gate.test.js +341 -0
- package/test/ranged-read.test.js +60 -0
- package/test/request-guards.test.js +91 -0
- package/test/request-signing.test.js +284 -0
- package/test/root-recovery-sidecar.test.js +171 -0
- package/test/root-recovery.test.js +274 -0
- package/test/safe-exec-args.test.js +58 -0
- package/test/sandbox.test.js +98 -0
- package/test/search-outcome.test.js +50 -0
- package/test/workspace-root.test.js +64 -0
- package/tools/graphify-validation/README.md +92 -0
- package/tools/graphify-validation/config/questions.json +31 -0
- package/tools/graphify-validation/config/repos.json +9 -0
- package/tools/graphify-validation/config/symbols.json +6 -0
- package/tools/graphify-validation/fixtures/spring/expected.json +34 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/AppConfig.java +29 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/AuditLogger.java +15 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/Cache.java +7 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/CheckoutService.java +25 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/InMemoryCache.java +18 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/OrderController.java +22 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/PaymentGateway.java +5 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/PaypalGateway.java +11 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/RefundService.java +14 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/StripeGateway.java +11 -0
- package/tools/graphify-validation/lib/affected_vs_rg.py +81 -0
- package/tools/graphify-validation/lib/audit_hook/sitecustomize.py +22 -0
- package/tools/graphify-validation/lib/graph_metrics.py +96 -0
- package/tools/graphify-validation/lib/gv_common.py +130 -0
- package/tools/graphify-validation/lib/licenses.py +47 -0
- package/tools/graphify-validation/lib/pip_audit_summary.py +18 -0
- package/tools/graphify-validation/lib/recall.py +115 -0
- package/tools/graphify-validation/lib/report.py +226 -0
- package/tools/graphify-validation/lib/run_all.py +237 -0
- package/tools/graphify-validation/lib/run_graphify.py +144 -0
- package/tools/graphify-validation/lib/rusage_helper.py +17 -0
- package/tools/graphify-validation/lib/sample_edges.py +246 -0
- package/tools/graphify-validation/lib/spring_fixture.py +81 -0
- package/tools/graphify-validation/requirements-py311.lock +508 -0
- package/tools/graphify-validation/requirements-tools.in +1 -0
- package/tools/graphify-validation/requirements-tools.lock +371 -0
- package/tools/graphify-validation/requirements.in +1 -0
- package/tools/graphify-validation/requirements.lock +502 -0
- package/tools/graphify-validation/run.sh +43 -0
- package/tools/graphify-validation/tests/test_harness.py +151 -0
- package/cloudbuild-agent-qa.yaml +0 -43
- package/cloudbuild-agent-staging.yaml +0 -40
- package/cloudbuild.yaml.deprecated +0 -42
|
@@ -0,0 +1,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
|
-
//
|
|
1
|
+
// Add this to the Local Agent's server.js
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* GET /workspace/detect-port
|
|
5
5
|
*
|
|
6
|
-
*
|
|
6
|
+
* Detects the port of a specific service by reading configuration files
|
|
7
7
|
*
|
|
8
8
|
* Query params:
|
|
9
|
-
* - servicePath:
|
|
10
|
-
* - language:
|
|
11
|
-
* - framework: framework
|
|
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
|
-
//
|
|
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
|
-
*
|
|
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
|
-
*
|
|
98
|
+
* Detects the Node.js port
|
|
99
99
|
*/
|
|
100
100
|
async function detectNodePort(servicePath) {
|
|
101
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
*
|
|
133
|
+
* Detects the Python port
|
|
134
134
|
*/
|
|
135
135
|
async function detectPythonPort(servicePath) {
|
|
136
|
-
//
|
|
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
|
|
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
|
-
*
|
|
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
|
-
//
|
|
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
|
-
//
|
|
13
|
+
// Security validation - prevents path traversal
|
|
14
14
|
if (!fullPath.startsWith(this.rootPath)) {
|
|
15
15
|
throw new Error('Path traversal detected');
|
|
16
16
|
}
|