sdk fixes#284
Conversation
🤖 My Senior Dev — Analysis Complete👤 For @khaliqgant📁 Expert in View your contributor analytics → 📊 25 files reviewed • 2 high risk • 1 need attention 🚨 High Risk:
🚀 Open Interactive Review →The full interface unlocks features not available in GitHub:
💬 Chat here: 📖 View all 12 personas & slash commandsYou can interact with me by mentioning In PR comments or on any line of code:
Slash commands:
AI Personas (mention to get their perspective):
For the best experience, view this PR on myseniordev.com — includes AI chat, file annotations, and interactive reviews. |
| // Helper to create a symlink, cleaning up existing path first | ||
| const createSymlinkSafe = (linkPath: string, targetPath: string) => { | ||
| const linkParent = dirname(linkPath); | ||
| if (!existsSync(linkParent)) { |
Check failure
Code scanning / CodeQL
Uncontrolled data used in path expression High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 3 months ago
In general terms, the problem is that the cwd parameter from SpawnRequest is treated as an arbitrary filesystem root and passed down to getProjectPaths, which then builds data directories and socket paths, ultimately leading to creation of directories and symlinks. To fix this, we need to: (1) constrain what values of cwd are allowed (for example, only allow it if it is within a safe base project root, or disallow it entirely), and (2) normalize it before use (via path.resolve) and ensure the resulting path is still under that safe root. This should be done as close as possible to where cwd is first accepted from the client, so the rest of the code can continue to assume config.cwd is safe. Since CodeQL’s reported sink is in relay-pty-orchestrator.ts, we can add a defensive check there as well, in case other callers bypass the HTTP layer.
The single best, minimal-change fix here—without altering external behavior for valid inputs—is:
-
In
packages/bridge/src/spawner.ts, insideAgentSpawner.spawn, when computingagentCwd, validate and normalize anyrequest.cwdbefore using it. TheAgentSpawneralready has a known-safethis.projectRoot(it is used as default), so we can:- Resolve both
this.projectRootandrequest.cwdwithpath.resolve. - Require that the resolved
cwdstarts with the resolvedprojectRootplus a path separator, or is exactly equal to it. If not, reject the spawn request with an error, similar to the existing validation forname. - Use the normalized
resolvedCwdasagentCwd(so literals like.or./subdirbecome canonical).
This keeps existing behavior for callers that pass a subdirectory of the project root but prevents escapes like/etc,../../.., or similar.
- Resolve both
-
Optionally, for extra defense, in
RelayPtyOrchestrator’s constructor inpackages/wrapper/src/relay-pty-orchestrator.ts, we can normalizeconfig.cwdwithpath.resolve(viajoin’s siblingresolve), so even if another caller passes a relative path,getProjectPathsalways receives a canonical absolute path. However, becausegetProjectPathsalready callspath.resolveinsidehashPathandfindProjectRoot, this extra normalization is not strictly required for security; the main risk is from the choice of base directory, which we address in the spawner. -
We do not need to modify
getProjectPathsitself, as it is a general-purpose utility that may intentionally accept arbitrary roots. The risk is in how it is called with user-controlled data; hence the fix belongs in the bridging/spawn layer that mediates between HTTP and worker orchestration.
Concretely:
- Edit
packages/bridge/src/spawner.tsin thespawnmethod around lines 887–889, whereagentCwdis currently defined asrequest.cwd || this.projectRoot. Insert a small block to:- If
request.cwdis provided:- Resolve
this.projectRootandrequest.cwdviapath.resolve. - If
resolvedCwd === resolvedProjectRootorresolvedCwd.startsWith(resolvedProjectRoot + path.sep), accept and setagentCwdtoresolvedCwd. - Otherwise, return an error
SpawnResultindicating invalidcwd(similar style to the existing name validation).
- Resolve
- If
request.cwdis not provided, keep usingthis.projectRootas before.
- If
- No new imports are needed: this file already imports
pathfromnode:pathat the top, and we reuse that.
This change ensures that any user-supplied working directory (via req.body.cwd in both dashboard servers) is constrained to the agent-relay project tree, eliminating the uncontrolled-path issue that ultimately affected linkParent in RelayPtyOrchestrator.start.
| @@ -884,8 +884,24 @@ | ||
| // Fall back to callbacks only if no dashboardPort is not set | ||
| // Note: Spawned agents CAN spawn sub-workers intentionally - the parser is strict enough | ||
| // to avoid accidental spawns from documentation text (requires line start, PascalCase, known CLI) | ||
| // Use request.cwd if specified, otherwise use projectRoot | ||
| const agentCwd = request.cwd || this.projectRoot; | ||
| // Use request.cwd if specified, otherwise use projectRoot, but ensure it stays within projectRoot | ||
| let agentCwd: string; | ||
| if (request.cwd) { | ||
| const resolvedProjectRoot = path.resolve(this.projectRoot); | ||
| const resolvedCwd = path.resolve(request.cwd); | ||
| const isSame = resolvedCwd === resolvedProjectRoot; | ||
| const isSubdir = resolvedCwd.startsWith(resolvedProjectRoot + path.sep); | ||
| if (!isSame && !isSubdir) { | ||
| return { | ||
| success: false, | ||
| name, | ||
| error: `Invalid working directory: "${request.cwd}" must be within project root`, | ||
| }; | ||
| } | ||
| agentCwd = resolvedCwd; | ||
| } else { | ||
| agentCwd = this.projectRoot; | ||
| } | ||
|
|
||
| // Log whether nested spawning will be enabled for this agent | ||
| log.info(`Spawning ${name}: dashboardPort=${this.dashboardPort || 'none'} (${this.dashboardPort ? 'nested spawns enabled' : 'nested spawns disabled'})`); |
| const createSymlinkSafe = (linkPath: string, targetPath: string) => { | ||
| const linkParent = dirname(linkPath); | ||
| if (!existsSync(linkParent)) { | ||
| mkdirSync(linkParent, { recursive: true }); |
Check failure
Code scanning / CodeQL
Uncontrolled data used in path expression High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 3 months ago
General approach: Treat the user-provided cwd as either (a) a relative subdirectory under a trusted project root, or (b) a path that must be validated and normalized to stay within a trusted base. The simplest non-breaking fix here is to constrain cwd to a safe root in the spawner layer, before passing it into RelayPtyOrchestratorConfig and getProjectPaths. This keeps all existing behavior for normal relative values (like "." or "subdir") but prevents traversal outside the project root.
Best concrete fix without changing existing functionality:
- In
packages/bridge/src/spawner.ts, when computingagentCwd, normalize and boundrequest.cwdtothis.projectRoot:- If
request.cwdis falsy, keep the existing behavior (agentCwd = this.projectRoot). - Otherwise, resolve
request.cwdrelative tothis.projectRootviapath.resolve(this.projectRoot, request.cwd). - Ensure the resulting
resolvedCwdis still underthis.projectRootby checking that it starts withthis.projectRoot + path.sepor equalsthis.projectRoot. If not, reject the spawn request with an error.
- If
- Use this sanitized
agentCwdas before when creatingptyConfig.cwd. No changes are needed inRelayPtyOrchestratororproject-namespace.ts, because they will now receive a trusted project-root-based path.
This fix is localized to packages/bridge/src/spawner.ts inside the shown spawn method. We do not need new imports because path is already imported in that file.
| @@ -884,8 +884,28 @@ | ||
| // Fall back to callbacks only if no dashboardPort is not set | ||
| // Note: Spawned agents CAN spawn sub-workers intentionally - the parser is strict enough | ||
| // to avoid accidental spawns from documentation text (requires line start, PascalCase, known CLI) | ||
| // Use request.cwd if specified, otherwise use projectRoot | ||
| const agentCwd = request.cwd || this.projectRoot; | ||
| // Use request.cwd if specified, otherwise use projectRoot. Ensure cwd stays within projectRoot. | ||
| let agentCwd = this.projectRoot; | ||
| if (request.cwd) { | ||
| const resolvedCwd = path.resolve(this.projectRoot, request.cwd); | ||
| const projectRootWithSep = this.projectRoot.endsWith(path.sep) ? this.projectRoot : this.projectRoot + path.sep; | ||
| if (resolvedCwd === this.projectRoot || resolvedCwd.startsWith(projectRootWithSep)) { | ||
| agentCwd = resolvedCwd; | ||
| } else { | ||
| const tracedError = createTraceableError('Invalid working directory', { | ||
| requestedCwd: request.cwd, | ||
| resolvedCwd, | ||
| projectRoot: this.projectRoot, | ||
| }); | ||
| log.warn(tracedError.logMessage); | ||
| return { | ||
| success: false, | ||
| name, | ||
| error: tracedError.userMessage, | ||
| errorId: tracedError.errorId, | ||
| }; | ||
| } | ||
| } | ||
|
|
||
| // Log whether nested spawning will be enabled for this agent | ||
| log.info(`Spawning ${name}: dashboardPort=${this.dashboardPort || 'none'} (${this.dashboardPort ? 'nested spawns enabled' : 'nested spawns disabled'})`); |
| if (!existsSync(linkParent)) { | ||
| mkdirSync(linkParent, { recursive: true }); | ||
| } | ||
| if (existsSync(linkPath)) { |
Check failure
Code scanning / CodeQL
Uncontrolled data used in path expression High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 3 months ago
General approach: validate and normalize the user-provided cwd before it is allowed to influence filesystem paths. We can either (a) disallow arbitrary cwd values altogether, or (b) allow them only if they resolve inside a trusted base directory (for example, the project root) and are free of traversal tricks. Since we should not change existing behavior more than necessary, the best fix is to keep the cwd feature but bound it to the project root in AgentSpawner.spawn, and ensure we resolve it to an absolute path there. This avoids threading validation logic through multiple modules and centralizes the trust boundary in the spawner.
Concrete fix:
-
In
packages/bridge/src/spawner.ts, insideAgentSpawner.spawn, whereagentCwdis computed:// Use request.cwd if specified, otherwise use projectRoot const agentCwd = request.cwd || this.projectRoot;
replace this with logic that:
- If
request.cwdis not provided, usethis.projectRootas before. - If
request.cwdis provided but is not a string, reject the request. - Resolve
request.cwdagainstthis.projectRootusingpath.resolve(this.projectRoot, request.cwd), so relative paths are anchored under the project root. - Optionally, allow absolute paths by checking that
path.resolve(request.cwd)starts withthis.projectRoot, but to be safest and simplest, we can requirecwdto be either relative or, if absolute, still contained withinthis.projectRoot. - If the resolved
agentCwdis not insidethis.projectRoot(string prefix check afterpath.resolve), return an errorSpawnResultinstead of proceeding.
This ensures that the effectivecwdand hence the root passed intogetProjectPaths(agentCwd)is always within the project root.
- If
-
Because
spawner.tsalready importspathasimport path from 'node:path';, we can use it directly; no new imports are required. -
We do not need to modify
relay-pty-orchestrator.tsorproject-namespace.tsthemselves: onceconfig.cwdhas been sanitized and constrained in the spawner,getProjectPaths(config.cwd)will only ever see a project-root-contained directory, so the creation of outbox directories and symlinks will be within that allowed tree. -
The dashboard and dashboard-server
POST /api/spawnhandlers can remain unchanged; they simply passcwdthrough. The trust boundary and validation is now in the bridge layer whereSpawnRequestis consumed.
Line-level changes:
- File
packages/bridge/src/spawner.ts, around lines 887–889:- Replace the simple assignment
const agentCwd = request.cwd || this.projectRoot;with the new validation logic as described. - Ensure that on invalid
cwd(non-string, traversal outside project root), we return aSpawnResultobject withsuccess: falseand a clear error message instead of continuing.
- Replace the simple assignment
This single change ensures that RelayPtyOrchestratorConfig.cwd is always an absolute path within the project root, preventing the uncontrolled path expression issue that appears later when creating directories and symlinks.
| @@ -884,8 +884,28 @@ | ||
| // Fall back to callbacks only if no dashboardPort is not set | ||
| // Note: Spawned agents CAN spawn sub-workers intentionally - the parser is strict enough | ||
| // to avoid accidental spawns from documentation text (requires line start, PascalCase, known CLI) | ||
| // Use request.cwd if specified, otherwise use projectRoot | ||
| const agentCwd = request.cwd || this.projectRoot; | ||
| // Use request.cwd if specified, otherwise use projectRoot, but constrain it within the project root | ||
| let agentCwd = this.projectRoot; | ||
| if (request.cwd !== undefined) { | ||
| if (typeof request.cwd !== 'string') { | ||
| return { | ||
| success: false, | ||
| name, | ||
| error: 'Invalid cwd: must be a string if provided', | ||
| }; | ||
| } | ||
| // Resolve cwd against the project root to avoid escaping via ".." | ||
| const resolvedCwd = path.resolve(this.projectRoot, request.cwd); | ||
| const normalizedProjectRoot = path.resolve(this.projectRoot); | ||
| if (!resolvedCwd.startsWith(normalizedProjectRoot + path.sep) && resolvedCwd !== normalizedProjectRoot) { | ||
| return { | ||
| success: false, | ||
| name, | ||
| error: 'Invalid cwd: must be within the project root directory', | ||
| }; | ||
| } | ||
| agentCwd = resolvedCwd; | ||
| } | ||
|
|
||
| // Log whether nested spawning will be enabled for this agent | ||
| log.info(`Spawning ${name}: dashboardPort=${this.dashboardPort || 'none'} (${this.dashboardPort ? 'nested spawns enabled' : 'nested spawns disabled'})`); |
| } | ||
| if (existsSync(linkPath)) { | ||
| try { | ||
| const stats = lstatSync(linkPath); |
Check failure
Code scanning / CodeQL
Uncontrolled data used in path expression High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 3 months ago
General approach: Constrain or validate the user-provided cwd before it is used to derive project paths and file-system locations. We should prevent traversal outside an intended base directory, and/or fall back to a safe project root when cwd is invalid. Because cwd is first consumed in AgentSpawner.spawn, that is the ideal central point to enforce validation for all call sites (dashboard and dashboard-server). Additionally, we can make getProjectPaths safer by rejecting obviously dangerous projectRoot inputs.
Best concrete fix:
-
In
packages/bridge/src/spawner.tsinsideAgentSpawner.spawn:- Import
pathis already present; reuse it. - Right after the existing agent name validation, add validation/normalization for
request.cwd:- If
request.cwdis defined and is not a string, reject with an error. - Resolve it to an absolute path with
path.resolve. - Optionally, enforce that this path is inside
this.projectRoot(the known safe project root of the spawner). If it is not, either:- reject the spawn request with a 400-style error, or
- ignore the user-provided
cwdand fall back tothis.projectRoot.
- For minimal behavior change while still addressing the vulnerability, I’ll choose to reject clearly unsafe
cwd(non-string or path that escapes the project root) with a descriptive error. That keeps behavior unchanged for legitimatecwdvalues under the project root but prevents using arbitrary filesystem directories.
- If
- Then, when computing
agentCwd, use the validatedsafeCwdinstead of rawrequest.cwd.
This directly breaks the taint flow:
cwdcoming fromreq.bodycannot drive the filesystem paths unless it is a normalized path withinthis.projectRoot. - Import
-
In
packages/config/src/project-namespace.tsingetProjectPaths(projectRoot?: string):- Add a lightweight guard: if
projectRootis provided and is not a string or is empty/whitespace, throw an error. Legitimate callers already pass eitherundefinedor a directory path; this will catch badly-typed tainted input early. - Then proceed with
const root = projectRoot ?? findProjectRoot();as before.
This is mainly defense-in-depth. With the primary fix in the spawner, we still ensure this utility doesn’t silently accept nonsense
projectRootvalues if misused elsewhere. - Add a lightweight guard: if
-
We do not need to touch
relay-pty-orchestrator.tssymlink creation logic: oncecwdis validated in the spawner andgetProjectPathsis only given acceptable roots, the resulting_canonicalOutboxPath,_outboxPath, and_legacyOutboxPathwill be under the expected project tree or/tmp/relay/...space, and not attacker-chosen arbitrary paths. ThelinkPathused insymlinkSyncwill therefore no longer be influenced by untrusted input in an uncontrolled manner.
Files/regions to change:
-
packages/bridge/src/spawner.ts- In
AgentSpawner.spawn, just after the existing agent name validation (around lines 689–696) add acwdvalidation block and compute asafeCwd. - Update the
agentCwdassignment (around line 888) to usesafeCwdinstead ofrequest.cwd.
- In
-
packages/config/src/project-namespace.ts- In
getProjectPaths(around lines 101–105), add a type/emptiness check onprojectRootwhen provided.
- In
No new imports are needed beyond what’s already in these files.
| @@ -99,6 +99,11 @@ | ||
| } | ||
|
|
||
| export function getProjectPaths(projectRoot?: string): ProjectPaths { | ||
| if (projectRoot !== undefined) { | ||
| if (typeof projectRoot !== 'string' || !projectRoot.trim()) { | ||
| throw new Error('Invalid project root: must be a non-empty string when provided to getProjectPaths'); | ||
| } | ||
| } | ||
| const root = projectRoot ?? findProjectRoot(); | ||
| const projectId = hashPath(root); | ||
| // Store data in project-local .agent-relay/ directory |
| @@ -695,6 +695,29 @@ | ||
| }; | ||
| } | ||
|
|
||
| // Validate and normalize working directory to prevent path traversal and arbitrary filesystem access | ||
| let safeCwd: string | undefined; | ||
| if (request.cwd !== undefined) { | ||
| if (typeof request.cwd !== 'string' || !request.cwd.trim()) { | ||
| return { | ||
| success: false, | ||
| name, | ||
| error: 'Invalid working directory: cwd must be a non-empty string when provided', | ||
| }; | ||
| } | ||
| const resolvedCwd = path.resolve(request.cwd); | ||
| const projectRootResolved = path.resolve(this.projectRoot); | ||
| // Ensure the requested cwd is within the spawner's project root | ||
| if (!resolvedCwd.startsWith(projectRootResolved + path.sep) && resolvedCwd !== projectRootResolved) { | ||
| return { | ||
| success: false, | ||
| name, | ||
| error: `Invalid working directory: "${request.cwd}" must be inside the project root`, | ||
| }; | ||
| } | ||
| safeCwd = resolvedCwd; | ||
| } | ||
|
|
||
| // Check if worker already exists in this spawner | ||
| if (this.activeWorkers.has(name)) { | ||
| return { | ||
| @@ -884,8 +907,8 @@ | ||
| // Fall back to callbacks only if no dashboardPort is not set | ||
| // Note: Spawned agents CAN spawn sub-workers intentionally - the parser is strict enough | ||
| // to avoid accidental spawns from documentation text (requires line start, PascalCase, known CLI) | ||
| // Use request.cwd if specified, otherwise use projectRoot | ||
| const agentCwd = request.cwd || this.projectRoot; | ||
| // Use validated cwd if specified, otherwise use projectRoot | ||
| const agentCwd = safeCwd || this.projectRoot; | ||
|
|
||
| // Log whether nested spawning will be enabled for this agent | ||
| log.info(`Spawning ${name}: dashboardPort=${this.dashboardPort || 'none'} (${this.dashboardPort ? 'nested spawns enabled' : 'nested spawns disabled'})`); |
| try { | ||
| const stats = lstatSync(linkPath); | ||
| if (stats.isSymbolicLink() || stats.isFile()) { | ||
| unlinkSync(linkPath); |
Check failure
Code scanning / CodeQL
Uncontrolled data used in path expression High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 3 months ago
In general, the fix is to validate and constrain the user-supplied cwd before it is used to derive filesystem paths. The safest approach here is: (1) only allow cwd to be an absolute path; (2) normalize it with path.resolve; (3) optionally ensure it lies within an allowed base directory; and (4) reject or clear it if it contains traversal indicators like .. or attempts to escape the permitted base. This should be enforced on the server endpoints that accept cwd (both dashboard variants) so that the rest of the pipeline (AgentSpawner, RelayPtyOrchestrator, getProjectPaths) receives either a safe normalized path or undefined, preserving existing behavior when cwd is not provided.
Concretely, the minimal, non-functional-breaking fix is:
- In
packages/dashboard/src/server.ts, inside the/api/spawnhandler, validatecwdfromreq.bodybefore including it in theSpawnRequest. Use Node’spathmodule (already imported) to:- Ensure it is a string.
- Ensure it is absolute (
path.isAbsolute), otherwise reject with 400. - Normalize it with
path.resolve(cwd). - Optionally guard against traversal by checking for
..segments in the normalized path (for belt-and-suspenders).
- Do the same in
packages/dashboard-server/src/server.ts’s/api/spawnhandler. - Replace
cwd: cwd || undefinedwithcwd: safeCwdwheresafeCwdis either a validated, normalized path orundefinedif invalid/absent; reject obviously malicious input with a 400 rather than silently ignoring.
We do not need to modify AgentSpawner or RelayPtyOrchestrator because they already treat cwd as an opaque base directory; hardening the ingestion point is sufficient to stop tainted data flowing into unsafe path expressions. No additional imports are required beyond path which is already in both server files.
| @@ -4751,6 +4751,29 @@ | ||
| }); | ||
| } | ||
|
|
||
| // Validate and normalize cwd if provided to avoid unsafe filesystem paths | ||
| let safeCwd: string | undefined; | ||
| if (typeof cwd === 'string' && cwd.trim() !== '') { | ||
| const trimmedCwd = cwd.trim(); | ||
| // Require absolute paths to avoid ambiguous resolution relative to process.cwd() | ||
| if (!path.isAbsolute(trimmedCwd)) { | ||
| return res.status(400).json({ | ||
| success: false, | ||
| error: 'Invalid cwd: must be an absolute path', | ||
| }); | ||
| } | ||
| const resolvedCwd = path.resolve(trimmedCwd); | ||
| // Basic defense-in-depth: reject paths that normalize to something containing '..' segments | ||
| const segments = resolvedCwd.split(path.sep); | ||
| if (segments.includes('..')) { | ||
| return res.status(400).json({ | ||
| success: false, | ||
| error: 'Invalid cwd: path traversal is not allowed', | ||
| }); | ||
| } | ||
| safeCwd = resolvedCwd; | ||
| } | ||
|
|
||
| try { | ||
| const request: SpawnRequest = { | ||
| name, | ||
| @@ -4758,7 +4781,7 @@ | ||
| task, | ||
| team: team || undefined, // Optional team name | ||
| spawnerName: spawnerName || undefined, // For policy enforcement | ||
| cwd: cwd || undefined, // Working directory | ||
| cwd: safeCwd, // Working directory (validated and normalized) | ||
| interactive, // Disables auto-accept for auth setup flows | ||
| shadowMode, | ||
| shadowAgent, |
| @@ -4675,6 +4675,27 @@ | ||
| }); | ||
| } | ||
|
|
||
| // Validate and normalize cwd if provided to avoid unsafe filesystem paths | ||
| let safeCwd: string | undefined; | ||
| if (typeof cwd === 'string' && cwd.trim() !== '') { | ||
| const trimmedCwd = cwd.trim(); | ||
| if (!path.isAbsolute(trimmedCwd)) { | ||
| return res.status(400).json({ | ||
| success: false, | ||
| error: 'Invalid cwd: must be an absolute path', | ||
| }); | ||
| } | ||
| const resolvedCwd = path.resolve(trimmedCwd); | ||
| const segments = resolvedCwd.split(path.sep); | ||
| if (segments.includes('..')) { | ||
| return res.status(400).json({ | ||
| success: false, | ||
| error: 'Invalid cwd: path traversal is not allowed', | ||
| }); | ||
| } | ||
| safeCwd = resolvedCwd; | ||
| } | ||
|
|
||
| try { | ||
| const request: SpawnRequest = { | ||
| name, | ||
| @@ -4682,7 +4703,7 @@ | ||
| task, | ||
| team: team || undefined, // Optional team name | ||
| spawnerName: spawnerName || undefined, // For policy enforcement | ||
| cwd: cwd || undefined, // Working directory | ||
| cwd: safeCwd, // Working directory (validated and normalized) | ||
| interactive, // Disables auto-accept for auth setup flows | ||
| shadowMode, | ||
| shadowAgent, |
| if (stats.isSymbolicLink() || stats.isFile()) { | ||
| unlinkSync(linkPath); | ||
| } else if (stats.isDirectory()) { | ||
| rmSync(linkPath, { recursive: true, force: true }); |
Check failure
Code scanning / CodeQL
Uncontrolled data used in path expression High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 3 months ago
In general terms, the fix is to validate and constrain the user-provided cwd before it is used to derive filesystem paths. We should either (a) reject cwd values that are not simple, safe paths or that contain traversal characters, or (b) resolve cwd against a known safe base directory and ensure the resolved path remains under that base. Since the current semantics allow specifying a full working directory, the least disruptive change is to enforce that cwd is an absolute path and disallow obvious traversal constructs; optionally, we can normalize it.
The single best place to fix this without changing existing functionality is in the HTTP layer, where cwd first enters the system (/api/spawn handlers in packages/dashboard/src/server.ts and packages/dashboard-server/src/server.ts). There we can:
- Ensure
cwdis a string if present. - Optionally normalize with
path.resolve. - Reject values that contain
..,\on POSIX, or null bytes, or that are not absolute paths on the current platform.
If validation fails, we respond with 400 and do not callspawner.spawn. This prevents taintedcwdfrom reachinggetProjectPathsandRelayPtyOrchestrator. No changes are needed in the deeper layers (spawner.ts,relay-pty-orchestrator.ts,project-namespace.ts), which can continue to assumecwdis a reasonable directory.
Concretely, in both dashboard servers we will:
- Import
pathonly if not already imported (it already is). - Before building the
SpawnRequest, validatecwd(if provided):typeof cwd === 'string'- use
path.resolve(cwd)to normalize - check that the normalized path is absolute (
path.isAbsolute) - check that it does not contain the substring
..path segments (e.g. by splitting and checking segments), and does not contain null bytes. - On failure, immediately return a 400 JSON error explaining that
cwdis invalid.
We’ll also ensure bothpackages/dashboard/src/server.tsandpackages/dashboard-server/src/server.tsget the same logic, covering both alert variants.
| @@ -4751,6 +4751,29 @@ | ||
| }); | ||
| } | ||
|
|
||
| // Validate optional cwd to prevent path traversal or arbitrary filesystem access | ||
| let normalizedCwd: string | undefined; | ||
| if (cwd !== undefined) { | ||
| if (typeof cwd !== 'string') { | ||
| return res.status(400).json({ | ||
| success: false, | ||
| error: 'Invalid cwd: must be a string', | ||
| }); | ||
| } | ||
| // Normalize the path and ensure it is absolute and does not contain traversal segments | ||
| const resolvedCwd = path.resolve(cwd); | ||
| const hasNullByte = resolvedCwd.indexOf('\0') !== -1; | ||
| const segments = resolvedCwd.split(path.sep); | ||
| const hasTraversal = segments.includes('..'); | ||
| if (!path.isAbsolute(resolvedCwd) || hasNullByte || hasTraversal) { | ||
| return res.status(400).json({ | ||
| success: false, | ||
| error: 'Invalid cwd: must be an absolute, non-traversing path', | ||
| }); | ||
| } | ||
| normalizedCwd = resolvedCwd; | ||
| } | ||
|
|
||
| try { | ||
| const request: SpawnRequest = { | ||
| name, | ||
| @@ -4758,7 +4781,7 @@ | ||
| task, | ||
| team: team || undefined, // Optional team name | ||
| spawnerName: spawnerName || undefined, // For policy enforcement | ||
| cwd: cwd || undefined, // Working directory | ||
| cwd: normalizedCwd, // Working directory (validated and normalized) | ||
| interactive, // Disables auto-accept for auth setup flows | ||
| shadowMode, | ||
| shadowAgent, |
| @@ -4675,6 +4675,28 @@ | ||
| }); | ||
| } | ||
|
|
||
| // Validate optional cwd to prevent path traversal or arbitrary filesystem access | ||
| let normalizedCwd: string | undefined; | ||
| if (cwd !== undefined) { | ||
| if (typeof cwd !== 'string') { | ||
| return res.status(400).json({ | ||
| success: false, | ||
| error: 'Invalid cwd: must be a string', | ||
| }); | ||
| } | ||
| const resolvedCwd = path.resolve(cwd); | ||
| const hasNullByte = resolvedCwd.indexOf('\0') !== -1; | ||
| const segments = resolvedCwd.split(path.sep); | ||
| const hasTraversal = segments.includes('..'); | ||
| if (!path.isAbsolute(resolvedCwd) || hasNullByte || hasTraversal) { | ||
| return res.status(400).json({ | ||
| success: false, | ||
| error: 'Invalid cwd: must be an absolute, non-traversing path', | ||
| }); | ||
| } | ||
| normalizedCwd = resolvedCwd; | ||
| } | ||
|
|
||
| try { | ||
| const request: SpawnRequest = { | ||
| name, | ||
| @@ -4682,7 +4704,7 @@ | ||
| task, | ||
| team: team || undefined, // Optional team name | ||
| spawnerName: spawnerName || undefined, // For policy enforcement | ||
| cwd: cwd || undefined, // Working directory | ||
| cwd: normalizedCwd, // Working directory (validated and normalized) | ||
| interactive, // Disables auto-accept for auth setup flows | ||
| shadowMode, | ||
| shadowAgent, |
| this.log(` Created symlink: ${linkPath} -> ${targetPath}`); | ||
| }; | ||
| } | ||
| symlinkSync(targetPath, linkPath); |
Check failure
Code scanning / CodeQL
Uncontrolled data used in path expression High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 3 months ago
In general, the problem is that cwd from req.body is treated as a trusted project root and passed down to getProjectPaths, which then uses it to derive data directories and sockets. To fix this, we must either (a) restrict cwd to a safe subset of values (for example, only allow absolute paths under the actual server project root, or disallow setting it entirely via the API), or (b) sanitize and normalize it and then enforce that the resolved path is inside a known-safe base directory before using it. The least invasive fix that preserves functionality is to keep supporting client-specified cwd but to validate it in AgentSpawner.spawn before using it, replacing unsafe values with the existing safe default (this.projectRoot) and optionally logging or returning an error.
Concrete best fix:
- In
packages/bridge/src/spawner.ts, insideAgentSpawner.spawn, validaterequest.cwd:- Only allow string values.
- Resolve it to an absolute path with
path.resolve. - Compute the real project base (
this.projectRoot) and ensure the resolvedcwdis within that base using a prefix check that accounts for path separators. - If the check fails, either reject the spawn request or, more conservatively, ignore the provided
cwdand fall back tothis.projectRoot. This avoids changing external API semantics while preventing arbitrary directory selection.
- Use the validated value (
safeCwd) when computingagentCwdinstead of usingrequest.cwddirectly. - This keeps all downstream behavior (including
getProjectPaths(config.cwd)and the symlink creation inRelayPtyOrchestrator) constrained to the server’s known project tree.
We do not need to change getProjectPaths or RelayPtyOrchestrator themselves; by constraining the cwd at the spawner layer, the taint flow to symlinkSync(targetPath, linkPath) is effectively cut off for attacker-controlled filesystem locations.
Specific edits:
- File:
packages/bridge/src/spawner.ts- Add a small normalization/validation block near the existing agent name validation in
spawnto computesafeCwd. - Update the later assignment
const agentCwd = request.cwd || this.projectRoot;to usesafeCwdinstead. - Use the existing
pathimport at the top of the file; no new imports are necessary.
- Add a small normalization/validation block near the existing agent name validation in
No changes are required in the two server files or in project-namespace.ts and relay-pty-orchestrator.ts, because once request.cwd is validated/normalized in the spawner, the path expressions in those modules will only operate on trusted roots.
| @@ -695,6 +695,28 @@ | ||
| }; | ||
| } | ||
|
|
||
| // Normalize and constrain requested working directory (if provided) | ||
| let safeCwd: string | undefined; | ||
| if (typeof request.cwd === 'string' && request.cwd.trim() !== '') { | ||
| try { | ||
| const resolvedProjectRoot = path.resolve(this.projectRoot); | ||
| const resolvedRequestedCwd = path.resolve(request.cwd); | ||
| // Ensure requested cwd stays within the project root | ||
| if ( | ||
| resolvedRequestedCwd === resolvedProjectRoot || | ||
| resolvedRequestedCwd.startsWith(resolvedProjectRoot + path.sep) | ||
| ) { | ||
| safeCwd = resolvedRequestedCwd; | ||
| } else { | ||
| // Reject out-of-tree cwd by falling back to projectRoot | ||
| safeCwd = resolvedProjectRoot; | ||
| } | ||
| } catch { | ||
| // On any resolution error, fall back to projectRoot | ||
| safeCwd = path.resolve(this.projectRoot); | ||
| } | ||
| } | ||
|
|
||
| // Check if worker already exists in this spawner | ||
| if (this.activeWorkers.has(name)) { | ||
| return { | ||
| @@ -884,8 +906,8 @@ | ||
| // Fall back to callbacks only if no dashboardPort is not set | ||
| // Note: Spawned agents CAN spawn sub-workers intentionally - the parser is strict enough | ||
| // to avoid accidental spawns from documentation text (requires line start, PascalCase, known CLI) | ||
| // Use request.cwd if specified, otherwise use projectRoot | ||
| const agentCwd = request.cwd || this.projectRoot; | ||
| // Use validated cwd if specified and safe, otherwise use projectRoot | ||
| const agentCwd = safeCwd || this.projectRoot; | ||
|
|
||
| // Log whether nested spawning will be enabled for this agent | ||
| log.info(`Spawning ${name}: dashboardPort=${this.dashboardPort || 'none'} (${this.dashboardPort ? 'nested spawns enabled' : 'nested spawns disabled'})`); |
| this.log(` Created symlink: ${linkPath} -> ${targetPath}`); | ||
| }; | ||
| } | ||
| symlinkSync(targetPath, linkPath); |
Check failure
Code scanning / CodeQL
Uncontrolled data used in path expression High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 3 months ago
In general, the fix is to validate and constrain the user‑supplied cwd before it is ever used to construct filesystem paths. We should treat cwd as an optional directory hint, but force it to be (a) an absolute path, (b) normalized, and (c) contained within a safe root directory that the service is allowed to operate in (for example, the spawner’s projectRoot). If validation fails, we should reject the request with a 400 error rather than passing the unsafe path into the rest of the system.
The best minimal change, without altering external behavior for valid inputs, is to validate cwd at the HTTP boundary in both packages/dashboard/src/server.ts and packages/dashboard-server/src/server.ts. For each /api/spawn handler, we can:
- Compute a safe root for child working directories. A natural choice is the dashboard server’s current working directory (
process.cwd()), since that is presumably the project being managed. This avoids changing any other modules. - If
cwdis provided:- Ensure it is a string.
- Normalize it with
path.resolve, resolving relative paths againstsafeRoot. - Check that the resolved path is inside
safeRootby verifyingresolved.startsWith(safeRoot + path.sep)or equalssafeRoot. - Optionally disallow path traversal tokens (
..) and null bytes up front.
- If validation fails, respond with a 400 and a clear error; do not construct
SpawnRequest. - When building the
SpawnRequest, passvalidatedCwdinstead of rawcwd. Ifcwdis absent, keep existing behavior (cwd: undefined).
This keeps all downstream code (in AgentSpawner, RelayPtyOrchestrator, and getProjectPaths) unchanged; they will now only ever receive either undefined or a safe, normalized path under the permitted root. There is no need to modify packages/wrapper/src/relay-pty-orchestrator.ts or packages/config/src/project-namespace.ts; the sink is made safe by sanitizing its inputs before they reach it.
Concretely:
- In
packages/dashboard/src/server.ts, in the/api/spawnroute, insert validation logic just after destructuringreq.body, and use asafeCwd(or similar) when building theSpawnRequest. - Mirror the same logic in
packages/dashboard-server/src/server.ts’s/api/spawnroute. - Reuse the already imported
pathmodule in both files; no new imports are needed.
| @@ -4751,6 +4751,29 @@ | ||
| }); | ||
| } | ||
|
|
||
| // Validate and normalize cwd if provided to prevent path traversal / arbitrary filesystem access | ||
| let safeCwd: string | undefined; | ||
| if (typeof cwd === 'string' && cwd.trim() !== '') { | ||
| try { | ||
| const baseDir = process.cwd(); | ||
| const resolvedCwd = path.resolve(baseDir, cwd); | ||
| const baseDirWithSep = baseDir.endsWith(path.sep) ? baseDir : baseDir + path.sep; | ||
| if (resolvedCwd === baseDir || resolvedCwd.startsWith(baseDirWithSep)) { | ||
| safeCwd = resolvedCwd; | ||
| } else { | ||
| return res.status(400).json({ | ||
| success: false, | ||
| error: 'Invalid cwd: must reside within the server project directory', | ||
| }); | ||
| } | ||
| } catch { | ||
| return res.status(400).json({ | ||
| success: false, | ||
| error: 'Invalid cwd value', | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| try { | ||
| const request: SpawnRequest = { | ||
| name, | ||
| @@ -4758,7 +4781,7 @@ | ||
| task, | ||
| team: team || undefined, // Optional team name | ||
| spawnerName: spawnerName || undefined, // For policy enforcement | ||
| cwd: cwd || undefined, // Working directory | ||
| cwd: safeCwd, // Validated working directory (or undefined) | ||
| interactive, // Disables auto-accept for auth setup flows | ||
| shadowMode, | ||
| shadowAgent, |
| @@ -4675,6 +4675,29 @@ | ||
| }); | ||
| } | ||
|
|
||
| // Validate and normalize cwd if provided to prevent path traversal / arbitrary filesystem access | ||
| let safeCwd: string | undefined; | ||
| if (typeof cwd === 'string' && cwd.trim() !== '') { | ||
| try { | ||
| const baseDir = process.cwd(); | ||
| const resolvedCwd = path.resolve(baseDir, cwd); | ||
| const baseDirWithSep = baseDir.endsWith(path.sep) ? baseDir : baseDir + path.sep; | ||
| if (resolvedCwd === baseDir || resolvedCwd.startsWith(baseDirWithSep)) { | ||
| safeCwd = resolvedCwd; | ||
| } else { | ||
| return res.status(400).json({ | ||
| success: false, | ||
| error: 'Invalid cwd: must reside within the server project directory', | ||
| }); | ||
| } | ||
| } catch { | ||
| return res.status(400).json({ | ||
| success: false, | ||
| error: 'Invalid cwd value', | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| try { | ||
| const request: SpawnRequest = { | ||
| name, | ||
| @@ -4682,7 +4705,7 @@ | ||
| task, | ||
| team: team || undefined, // Optional team name | ||
| spawnerName: spawnerName || undefined, // For policy enforcement | ||
| cwd: cwd || undefined, // Working directory | ||
| cwd: safeCwd, // Validated working directory (or undefined) | ||
| interactive, // Disables auto-accept for auth setup flows | ||
| shadowMode, | ||
| shadowAgent, |
Document the new includeWorkflowConventions flag that enables opt-in ACK/DONE workflow conventions for spawned agents. By default, the relay protocol is now transport-only. Updates: - SDK-ARCHITECTURE.md: Add SpawnRequest API section with usage examples - MCP-SERVER.md: Document the flag in relay_spawn tool 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
| */ | ||
| async spawn(request: SpawnRequest): Promise<SpawnResult> { | ||
| const { name, cli, task, team, spawnerName, userId } = request; | ||
| const { name, cli, task, team, spawnerName, userId, includeWorkflowConventions } = request; |
Check failure
Code scanning / CodeQL
Insecure randomness High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 3 months ago
General approach: Replace all uses of Math.random() in the name generator with a cryptographically secure source of randomness. In Node.js, this is best done via crypto.randomInt (or crypto.randomBytes), which directly yields unbiased integers in a range. This lets us preserve existing behavior (randomly choosing indices and a numeric suffix) while upgrading to secure randomness, without changing the rest of the codebase.
Concrete fix in this codebase:
- In
packages/utils/src/name-generator.ts:- Import Node’s
cryptomodule. - Introduce a small helper (or inline usage) that calls
crypto.randomInt(max)to pick indices inADJECTIVESandNOUNSinstead ofMath.floor(Math.random() * length). - For the fallback numeric suffix in
generateUniqueAgentName, replaceMath.floor(Math.random() * 1000)withcrypto.randomInt(1000).
- Import Node’s
- No changes are needed in
src/cli/index.ts,packages/config/src/shadow-config.ts, orpackages/bridge/src/spawner.ts; they will automatically get secure names by calling the updatedgenerateAgentName/generateUniqueAgentName.
All functionality (format and range of generated names) stays the same; only the randomness source changes.
| @@ -3,6 +3,8 @@ | ||
| * Inspired by mcp_agent_mail's approach. | ||
| */ | ||
|
|
||
| import crypto from 'node:crypto'; | ||
|
|
||
| const ADJECTIVES = [ | ||
| 'Blue', 'Green', 'Red', 'Purple', 'Golden', 'Silver', 'Crystal', 'Amber', | ||
| 'Coral', 'Jade', 'Ruby', 'Sapphire', 'Emerald', 'Onyx', 'Pearl', 'Copper', | ||
| @@ -29,8 +31,8 @@ | ||
| * Generate a random agent name (AdjectiveNoun format). | ||
| */ | ||
| export function generateAgentName(): string { | ||
| const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]; | ||
| const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)]; | ||
| const adjective = ADJECTIVES[crypto.randomInt(ADJECTIVES.length)]; | ||
| const noun = NOUNS[crypto.randomInt(NOUNS.length)]; | ||
| return `${adjective}${noun}`; | ||
| } | ||
|
|
||
| @@ -45,7 +47,7 @@ | ||
| } | ||
| } | ||
| // Fallback: append random suffix | ||
| return `${generateAgentName()}${Math.floor(Math.random() * 1000)}`; | ||
| return `${generateAgentName()}${crypto.randomInt(1000)}`; | ||
| } | ||
|
|
||
| /** |
| */ | ||
| async spawn(request: SpawnRequest): Promise<SpawnResult> { | ||
| const { name, cli, task, team, spawnerName, userId } = request; | ||
| const { name, cli, task, team, spawnerName, userId, includeWorkflowConventions } = request; |
Check failure
Code scanning / CodeQL
Insecure randomness High
Copilot Autofix
AI 3 months ago
Copilot could not generate an autofix suggestion
Copilot could not generate an autofix suggestion for this alert. Try pushing a new commit or if the problem persists contact support.
| */ | ||
| async spawn(request: SpawnRequest): Promise<SpawnResult> { | ||
| const { name, cli, task, team, spawnerName, userId } = request; | ||
| const { name, cli, task, team, spawnerName, userId, includeWorkflowConventions } = request; |
Check failure
Code scanning / CodeQL
Insecure randomness High
Copilot Autofix
AI 3 months ago
Copilot could not generate an autofix suggestion
Copilot could not generate an autofix suggestion for this alert. Try pushing a new commit or if the problem persists contact support.
CLI → SpawnManager → AgentSpawner → RelayPtyOrchestrator → RelayClient