fix(workflows): pass user-controlled variables via env vars in bash nodes#1651
Conversation
…odes (coleam00#1585) Stop substituting user-controlled variables ($USER_MESSAGE, $ARGUMENTS, $LOOP_USER_INPUT, $REJECTION_REASON, $LOOP_PREV_OUTPUT, and context variables) into bash node script bodies before passing to `bash -c`. Instead, pass them as environment variables on the subprocess. This prevents shell injection when user messages contain metacharacters (backticks, $(), unbalanced quotes, etc.) that would otherwise be interpreted as shell syntax. The fix adds a `shellSafe` option to `substituteWorkflowVariables()` that skips user-controlled variable replacement. System-controlled variables ($WORKFLOW_ID, $ARTIFACTS_DIR, $BASE_BRANCH, $DOCS_DIR) are still substituted normally since they contain safe, known values. `substituteNodeOutputRefs()` with `escapedForBash=true` was already safe via `shellQuote()` — only the Pass 1 variables needed fixing. Closes coleam00#1585. Subsumes coleam00#1377.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (4)
✅ Files skipped from review due to trivial changes (2)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughPrevents shell-injection by leaving user-controlled and context workflow variables unsubstituted for ChangesShell-safe variable handling for bash nodes
Forge-aware clone authentication
Sequence Diagram(s)sequenceDiagram
participant Executor as DagExecutor
participant Subst as substituteWorkflowVariables
participant CloneExec as execFileAsync
participant Bash as BashChild
Executor->>Subst: call substituteWorkflowVariables(body, { shellSafe: true })
Subst-->>Executor: returns literal body with $USER_MESSAGE left intact
Executor->>CloneExec: execFileAsync('bash', ['-c', body], { env: { USER_MESSAGE, ARGUMENTS, ... } })
CloneExec->>Bash: spawn with provided env (USER_MESSAGE, ARGUMENTS, LOOP_PREV_OUTPUT, CONTEXT...)
Bash-->>CloneExec: runs with safe env values (no substituted shell splicing)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Tip 💬 Introducing Slack Agent: The best way for teams to turn conversations into code.Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.
Built for teams:
One agent for your entire SDLC. Right inside Slack. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
packages/workflows/src/dag-executor.ts (1)
2124-2161:⚠️ Potential issue | 🟠 Major | ⚡ Quick winFix loop env variable semantics in
until_bashevaluation.Line 2159 always exports
LOOP_USER_INPUT, and Line 2160 exportsLOOP_PREV_OUTPUTafter Line 2124 already overwrote it with current-iteration output. This breaks loop semantics and can skewuntil_bashcompletion decisions.Proposed fix
- lastIterationOutput = cleanOutput || fullOutput; + const prevIterationOutput = i === startIteration ? '' : lastIterationOutput; + const currentIterationOutput = cleanOutput || fullOutput; @@ - LOOP_USER_INPUT: loopUserInput ?? '', - LOOP_PREV_OUTPUT: lastIterationOutput, + LOOP_USER_INPUT: i === startIteration ? loopUserInput : '', + LOOP_PREV_OUTPUT: prevIterationOutput, REJECTION_REASON: '', CONTEXT: issueContext ?? '', EXTERNAL_CONTEXT: issueContext ?? '', ISSUE_CONTEXT: issueContext ?? '', }, }); bashComplete = true; // exit 0 = complete @@ + lastIterationOutput = currentIterationOutput;As per coding guidelines
Variable substitution in commands and workflows: ... $LOOP_USER_INPUT ... first iteration only ... $LOOP_PREV_OUTPUT ... empty on first iteration.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/workflows/src/dag-executor.ts` around lines 2124 - 2161, The until_bash evaluation always sets LOOP_USER_INPUT and LOOP_PREV_OUTPUT to current-iteration values, breaking loop semantics; update the loop handling in the block around substituteWorkflowVariables/execFileAsync so LOOP_USER_INPUT is only populated on the first iteration (iterationIndex === 0 ? loopUserInput ?? '' : ''), and LOOP_PREV_OUTPUT is empty on the first iteration and set to the previous iteration's output on subsequent iterations (capture previousIterationOutput before you overwrite lastIterationOutput/cleanOutput/fullOutput). Ensure these adjusted env values are passed into the execFileAsync call used for loop.until_bash.
🧹 Nitpick comments (1)
packages/workflows/src/dag-executor.test.ts (1)
1385-1425: ⚡ Quick winHarden this test against cascading failures by restoring the spy in
finallyand asserting the call exists first.If an assertion fails before Line 1424,
execFileAsyncremains mocked and can pollute later tests. Also, dereferencingmock.calls[0]before asserting invocation can throw a less helpful error.Proposed patch
it('passes user message through env vars, not string substitution, preventing shell injection', async () => { const execSpy = spyOn(git, 'execFileAsync').mockResolvedValue({ stdout: 'ok\n', stderr: '' }); - const mockDeps = createMockDeps(); - const platform = createMockPlatform(); - const workflowRun = makeWorkflowRun('bash-shell-safe-run-id', { - workflow_name: 'bash-shell-safe', - conversation_id: 'conv-shell-safe', - user_message: '$(rm -rf /)', - }); - - const bashNode: BashNode = { - id: 'safe', - bash: 'echo $USER_MESSAGE', - }; - - await executeDagWorkflow( - mockDeps, - platform, - 'conv-shell-safe', - testDir, - { name: 'bash-shell-safe-test', nodes: [bashNode] }, - workflowRun, - 'claude', - undefined, - join(testDir, 'artifacts'), - join(testDir, 'logs'), - 'main', - 'docs/', - minimalConfig - ); - - // The script passed to bash -c must contain literal $USER_MESSAGE (not substituted) - const bashArgs = execSpy.mock.calls[0]?.[1] as string[]; - expect(bashArgs[1]).toBe('echo $USER_MESSAGE'); - - // The env must contain the user message - const envArg = (execSpy.mock.calls[0]?.[2] as { env: NodeJS.ProcessEnv }).env; - expect(envArg?.USER_MESSAGE).toBe('$(rm -rf /)'); - expect(envArg?.ARGUMENTS).toBe('$(rm -rf /)'); - - execSpy.mockRestore(); + try { + const mockDeps = createMockDeps(); + const platform = createMockPlatform(); + const workflowRun = makeWorkflowRun('bash-shell-safe-run-id', { + workflow_name: 'bash-shell-safe', + conversation_id: 'conv-shell-safe', + user_message: '$(rm -rf /)', + }); + + const bashNode: BashNode = { + id: 'safe', + bash: 'echo $USER_MESSAGE', + }; + + await executeDagWorkflow( + mockDeps, + platform, + 'conv-shell-safe', + testDir, + { name: 'bash-shell-safe-test', nodes: [bashNode] }, + workflowRun, + 'claude', + undefined, + join(testDir, 'artifacts'), + join(testDir, 'logs'), + 'main', + 'docs/', + minimalConfig + ); + + expect(execSpy).toHaveBeenCalledTimes(1); + const firstCall = execSpy.mock.calls[0]; + + // The script passed to bash -c must contain literal $USER_MESSAGE (not substituted) + const bashArgs = firstCall?.[1] as string[] | undefined; + expect(bashArgs?.[1]).toBe('echo $USER_MESSAGE'); + + // The env must contain the user message + const envArg = (firstCall?.[2] as { env?: NodeJS.ProcessEnv } | undefined)?.env; + expect(envArg?.USER_MESSAGE).toBe('$(rm -rf /)'); + expect(envArg?.ARGUMENTS).toBe('$(rm -rf /)'); + } finally { + execSpy.mockRestore(); + } });🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/workflows/src/dag-executor.test.ts` around lines 1385 - 1425, The test currently mocks git.execFileAsync as execSpy and then dereferences execSpy.mock.calls[0] without first asserting the spy was called and it restores the mock only at the end; modify the test to (1) wrap the assertions and call-site dereference in a try/finally so execSpy.mockRestore() always runs, and (2) assert execSpy.mock.calls.length > 0 (or expect(execSpy).toHaveBeenCalled()) before reading execSpy.mock.calls[0]; update references to the captured call to use a local variable only after that assertion. Ensure this change targets the test block that calls executeDagWorkflow and references execSpy, git.execFileAsync, and bashArgs/envArg.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@packages/workflows/src/dag-executor.ts`:
- Around line 2124-2161: The until_bash evaluation always sets LOOP_USER_INPUT
and LOOP_PREV_OUTPUT to current-iteration values, breaking loop semantics;
update the loop handling in the block around
substituteWorkflowVariables/execFileAsync so LOOP_USER_INPUT is only populated
on the first iteration (iterationIndex === 0 ? loopUserInput ?? '' : ''), and
LOOP_PREV_OUTPUT is empty on the first iteration and set to the previous
iteration's output on subsequent iterations (capture previousIterationOutput
before you overwrite lastIterationOutput/cleanOutput/fullOutput). Ensure these
adjusted env values are passed into the execFileAsync call used for
loop.until_bash.
---
Nitpick comments:
In `@packages/workflows/src/dag-executor.test.ts`:
- Around line 1385-1425: The test currently mocks git.execFileAsync as execSpy
and then dereferences execSpy.mock.calls[0] without first asserting the spy was
called and it restores the mock only at the end; modify the test to (1) wrap the
assertions and call-site dereference in a try/finally so execSpy.mockRestore()
always runs, and (2) assert execSpy.mock.calls.length > 0 (or
expect(execSpy).toHaveBeenCalled()) before reading execSpy.mock.calls[0]; update
references to the captured call to use a local variable only after that
assertion. Ensure this change targets the test block that calls
executeDagWorkflow and references execSpy, git.execFileAsync, and
bashArgs/envArg.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: c32c454c-b016-4d9f-8b0b-6444c6db394b
📒 Files selected for processing (4)
packages/workflows/src/dag-executor.test.tspackages/workflows/src/dag-executor.tspackages/workflows/src/executor-shared.test.tspackages/workflows/src/executor-shared.ts
…n test cleanup - LOOP_PREV_OUTPUT in until_bash now correctly references the previous iteration's output (not the current one) - Test uses try/finally for spy cleanup and asserts call count before dereferencing mock.calls
Review SummaryVerdict: minor-fixes-needed Security fix looks good overall — well-scoped, well-tested, and follows established patterns. Two issues to address before merge: the Blocking issues
Suggested fixes
Minor / nice-to-have
Compliments
Reviewed via maintainer-review-pr workflow (Pi/Minimax). Aspects run: code-review, error-handling, test-coverage, comment-quality. |
…EA_TOKEN (fixes coleam00#1655) The clone handler only injected auth tokens for github.com URLs. Private repos on GitLab, Gitea, or Forgejo failed to clone via the Web UI URL form. Replace the github.com-specific string substitution with a forge-aware resolver that: - Looks up the correct env var per hostname (GH_TOKEN, GITLAB_TOKEN, GITEA_TOKEN) - Applies the correct auth URL scheme (oauth2: prefix for GitLab, bare token for GitHub/Gitea) - Handles self-hosted instances (gitlab.mycompany.com, gitea.myorg.com) Add 11 new tests: 5 integration (GitLab, self-hosted GitLab, Gitea, Forgejo, unknown forge) + 4 resolveForgeAuth unit tests + 2 updated existing tests.
…view) Replace substring matching on the full URL with hostname-based matching. This prevents token leakage when forge names appear in URL paths (e.g. https://evil.example.com/gitlab/mirror). - Exact hostname match for known forges (github.com, gitlab.com, gitea.com) - Label-based match for self-hosted instances (gitlab.mycompany.com) - Add 2 security regression tests for path-based injection vectors
Address review feedback from @Wirasm: - Add SUBPROCESS_DEFAULT_TIMEOUT to until_bash execFileAsync to prevent indefinite hangs (matching other bash invocations in this file) - Log non-ENOENT errors in until_bash catch block so syntax errors and permission issues are visible instead of silently swallowed
|
Thanks for the thorough review @Wirasm! Really appreciate the detailed breakdown. Here's what I've addressed: Blocking issue — Suggested fixes — addressed in
Context variables ($CONTEXT, $EXTERNAL_CONTEXT, $ISSUE_CONTEXT): All 210 dag-executor tests + 69 executor-shared tests pass. 🟢 |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (6)
packages/core/src/handlers/clone.ts (4)
59-70: 💤 Low valueJSDoc for
resolveForgeAuthis detached from the function.Lines 59–62 document
resolveForgeAuth, butSELF_HOSTED_FORGEis declared between the comment and the function, so tooling/readers will associate the JSDoc with the wrong symbol. Move the function-level doc directly aboveexport function resolveForgeAuth(...).📝 Suggested reorder
-/** - * Resolve forge-specific authentication token and URL scheme for a repository URL. - * Returns the token and auth scheme prefix, or empty values if no token is available. - */ -/** Well-known self-hosted hostname label patterns → env var + scheme. */ +/** Well-known self-hosted hostname label patterns → env var + scheme. */ const SELF_HOSTED_FORGE: { label: string; envVar: string; scheme: string }[] = [ { label: 'gitlab', envVar: 'GITLAB_TOKEN', scheme: 'oauth2:' }, { label: 'gitea', envVar: 'GITEA_TOKEN', scheme: '' }, { label: 'forgejo', envVar: 'GITEA_TOKEN', scheme: '' }, ]; +/** + * Resolve forge-specific authentication token and URL scheme for a repository URL. + * Returns the token and auth scheme prefix, or empty values if no token is available. + */ export function resolveForgeAuth(url: string): { token: string | undefined; scheme: string } {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/core/src/handlers/clone.ts` around lines 59 - 70, The JSDoc for resolveForgeAuth is separated from the function by the SELF_HOSTED_FORGE constant, so tooling will attach the comment to the wrong symbol; move the function-level comment block that starts "/** Resolve forge-specific authentication..." so it sits immediately above "export function resolveForgeAuth(url: string): { token: string | undefined; scheme: string }" (leave the SELF_HOSTED_FORGE declaration in place), ensuring the doc comment directly precedes the resolveForgeAuth declaration.
47-57: ⚡ Quick winMisleading JSDoc on
FORGE_AUTH— references a non-existentexactfield.The comment describes
Entries with exact: truevsEntries with exact: falsesemantics, butForgeAuthEntryhas noexactproperty; matching is split between this array (always exact equality onhostPattern) and the separateSELF_HOSTED_FORGEtable (label-based). Either align the comment with the actual implementation or add theexactflag.📝 Suggested doc fix
-/** - * Known forge authentication mappings. - * Entries with `exact: true` match the full hostname. - * Entries with `exact: false` match if the hostname contains the pattern as a label. - * Order matters: exact matches are checked first. - */ +/** + * Known forge authentication mappings. + * Each entry matches when the URL's hostname equals `hostPattern` exactly. + * Self-hosted/label-based matching is handled separately by `SELF_HOSTED_FORGE`. + */🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/core/src/handlers/clone.ts` around lines 47 - 57, The JSDoc for FORGE_AUTH is incorrect: it mentions an `exact` field that doesn't exist on ForgeAuthEntry and misstates matching behavior; update the comment to state that entries in FORGE_AUTH are matched by exact hostname equality (hostPattern must equal the request host) while label/substring matching is handled by the separate SELF_HOSTED_FORGE table, or alternatively add and wire an `exact` boolean to ForgeAuthEntry and matching logic — reference FORGE_AUTH, SELF_HOSTED_FORGE, and ForgeAuthEntry to locate the code and choose one of those two fixes to make the documentation and implementation consistent.
92-103: 💤 Low valueLabel-match semantics: confirm intent for ambiguous multi-forge hostnames.
The self-hosted matcher uses
labels.includes(entry.label)and iteratesSELF_HOSTED_FORGEin declaration order. A hostname likegitlab.gitea.example.comwill resolve toGITLAB_TOKEN(first match wins) even thoughgiteais also a label; conversely, a host that legitimately uses both products in DNS labels can never resolve to the second one. Two follow-up considerations:
- Document/confirm that the first-listed forge always wins on ambiguity (current order: gitlab → gitea → forgejo).
- Consider returning early only when the env var is set, so a missing
GITLAB_TOKENongitlab.gitea.example.comfalls through to tryGITEA_TOKENinstead of giving up:📝 Optional: fall through on missing token
for (const entry of SELF_HOSTED_FORGE) { if (labels.includes(entry.label)) { const token = process.env[entry.envVar]; if (token) { return { token, scheme: entry.scheme }; } - return { token: undefined, scheme: '' }; + // Try the next label-forge mapping if this token isn't set. + continue; } }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/core/src/handlers/clone.ts` around lines 92 - 103, The current self-hosted matcher iterates SELF_HOSTED_FORGE and uses labels.includes(entry.label) to pick the first matching entry, but it returns immediately even when the matched entry's env var (process.env[entry.envVar]) is unset, preventing later matches (e.g., gitlab.gitea.example.com always choosing gitlab). Change the logic in the loop over SELF_HOSTED_FORGE so that you only return when the env var is defined (token present) and otherwise continue to the next entry; also add a short comment documenting that resolution is first-match-by-order and that order in SELF_HOSTED_FORGE determines priority (gitlab → gitea → forgejo) so maintainers know why ordering matters.
326-339: ⚡ Quick winSilent
http://→https://upgrade and dropped URL components when injecting auth.When
safeParseUrlsucceeds, the rebuiltcloneUrlalways useshttps://and only re-emitsparsed.host+parsed.pathname. Two effects worth confirming are intentional:
- An
http://clone URL is silently upgraded tohttps://once a token is present. This is usually desirable security-wise but can break tests/internal forges that intentionally use plaintext; consider either documenting this as deliberate or preservingparsed.protocol.parsed.searchandparsed.hashare dropped. Forgit clonethese are rare, but if a user passes e.g.?ref=...it silently disappears once auth is injected and not otherwise.If both are intentional, a one-line comment near Line 333 documenting the upgrade/component-drop would meet the “document fallback behavior with a comment when intentional and safe” guideline.
📝 Optional preservation
- if (parsed) { - cloneUrl = `https://${authScheme}${forgeToken}@${parsed.host}${parsed.pathname}`; + if (parsed) { + // Intentional: force https when embedding a token; preserve path/search/hash. + cloneUrl = `https://${authScheme}${forgeToken}@${parsed.host}${parsed.pathname}${parsed.search}${parsed.hash}`; } else if (!workingUrl.startsWith('http')) {As per coding guidelines: "Document fallback behavior with a comment when intentional and safe; otherwise throw."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/core/src/handlers/clone.ts` around lines 326 - 339, The current auth-injection in the clone URL (inside the block using resolveForgeAuth and safeParseUrl) silently upgrades any http:// to https:// and drops parsed.search and parsed.hash; update the logic so that when safeParseUrl(parsed) returns a URL you reconstruct cloneUrl preserving parsed.protocol (fall back to https: only for bare host/path case), and append parsed.search and parsed.hash after parsed.pathname while still injecting `${authScheme}${forgeToken}@` between the protocol and host; if instead the upgrade/drop is intentional, add a one-line comment next to the cloneUrl rebuild explaining that protocol is intentionally upgraded to https and query/hash are discarded when injecting auth.packages/core/src/handlers/clone.test.ts (2)
311-348: ⚡ Quick winConsider adding an http-only-forge case to lock down the https upgrade behavior.
The new tests cover token injection with
https://URLs and the path-only false-positive, but not the implicithttp://→https://upgrade inclone.tsLine 333. Given the security-sensitive nature of this PR, an explicit test (e.g.,await cloneRepository('http://gitlab.mycompany.com/owner/repo')withGITLAB_TOKENset, asserting the final clone URL starts withhttps://oauth2:...) would pin the current behavior and catch any future regression that silently leaks tokens over plaintext.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/core/src/handlers/clone.test.ts` around lines 311 - 348, Add a new test case in clone.test.ts that mirrors the existing GitLab token tests but passes an http:// repository URL to cloneRepository to assert the code upgrades to HTTPS and injects the token; specifically, create a test named like "upgrades http to https and injects GITLAB_TOKEN" that sets process.env.GITLAB_TOKEN, clears GH_TOKEN, mockCreateCodebase to return a codebase whose repository_url is "http://gitlab.mycompany.com/owner/repo", calls await cloneRepository('http://gitlab.mycompany.com/owner/repo'), finds the git clone call via spyExecFileAsync.mock.calls (same search used in other tests), asserts the clone URL equals "https://oauth2:<token>@gitlab.mycompany.com/owner/repo", and finally deletes process.env.GITLAB_TOKEN to clean up.
429-463: ⚡ Quick winMove
resolveForgeAuthimport to top-level for consistency and type safety.Line 430 uses
const { resolveForgeAuth } = require('./clone')while the rest of the file imports from the same module via ES imports (line 70). This creates a style inconsistency and results in an untyped reference—require()returnsany, losing type checking on the function call.Since
resolveForgeAuthis exported fromclone.tswith a proper type signature, add it to the existing top-level import:📝 Suggested fix
-import { cloneRepository, registerRepository } from './clone'; +import { cloneRepository, registerRepository, resolveForgeAuth } from './clone'; describe('resolveForgeAuth', () => { - const { resolveForgeAuth } = require('./clone'); - test('returns GH_TOKEN for github.com', () => {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/core/src/handlers/clone.test.ts` around lines 429 - 463, The test currently uses a runtime require for resolveForgeAuth inside the describe block which bypasses TypeScript typing; remove the inline const { resolveForgeAuth } = require('./clone') and instead add resolveForgeAuth to the existing top-level ES import list (e.g., import { ..., resolveForgeAuth } from './clone') so the test uses the typed export; ensure you delete the require line and run the tests to confirm types and imports are correct.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/core/src/handlers/clone.test.ts`:
- Around line 305-426: The tests in the "multi-forge authentication" suite set
process.env.GITLAB_TOKEN / GITEA_TOKEN inside individual tests but delete them
only at the end of each test body, which can leak if cloneRepository or
assertions throw; add deterministic cleanup by either extending the existing
global beforeEach that currently clears GH_TOKEN to also delete GITLAB_TOKEN and
GITEA_TOKEN, or add an afterEach within this describe block that always deletes
GH_TOKEN, GITLAB_TOKEN, and GITEA_TOKEN (so regardless of failures the env is
cleared after tests that call cloneRepository).
---
Nitpick comments:
In `@packages/core/src/handlers/clone.test.ts`:
- Around line 311-348: Add a new test case in clone.test.ts that mirrors the
existing GitLab token tests but passes an http:// repository URL to
cloneRepository to assert the code upgrades to HTTPS and injects the token;
specifically, create a test named like "upgrades http to https and injects
GITLAB_TOKEN" that sets process.env.GITLAB_TOKEN, clears GH_TOKEN,
mockCreateCodebase to return a codebase whose repository_url is
"http://gitlab.mycompany.com/owner/repo", calls await
cloneRepository('http://gitlab.mycompany.com/owner/repo'), finds the git clone
call via spyExecFileAsync.mock.calls (same search used in other tests), asserts
the clone URL equals "https://oauth2:<token>@gitlab.mycompany.com/owner/repo",
and finally deletes process.env.GITLAB_TOKEN to clean up.
- Around line 429-463: The test currently uses a runtime require for
resolveForgeAuth inside the describe block which bypasses TypeScript typing;
remove the inline const { resolveForgeAuth } = require('./clone') and instead
add resolveForgeAuth to the existing top-level ES import list (e.g., import {
..., resolveForgeAuth } from './clone') so the test uses the typed export;
ensure you delete the require line and run the tests to confirm types and
imports are correct.
In `@packages/core/src/handlers/clone.ts`:
- Around line 59-70: The JSDoc for resolveForgeAuth is separated from the
function by the SELF_HOSTED_FORGE constant, so tooling will attach the comment
to the wrong symbol; move the function-level comment block that starts "/**
Resolve forge-specific authentication..." so it sits immediately above "export
function resolveForgeAuth(url: string): { token: string | undefined; scheme:
string }" (leave the SELF_HOSTED_FORGE declaration in place), ensuring the doc
comment directly precedes the resolveForgeAuth declaration.
- Around line 47-57: The JSDoc for FORGE_AUTH is incorrect: it mentions an
`exact` field that doesn't exist on ForgeAuthEntry and misstates matching
behavior; update the comment to state that entries in FORGE_AUTH are matched by
exact hostname equality (hostPattern must equal the request host) while
label/substring matching is handled by the separate SELF_HOSTED_FORGE table, or
alternatively add and wire an `exact` boolean to ForgeAuthEntry and matching
logic — reference FORGE_AUTH, SELF_HOSTED_FORGE, and ForgeAuthEntry to locate
the code and choose one of those two fixes to make the documentation and
implementation consistent.
- Around line 92-103: The current self-hosted matcher iterates SELF_HOSTED_FORGE
and uses labels.includes(entry.label) to pick the first matching entry, but it
returns immediately even when the matched entry's env var
(process.env[entry.envVar]) is unset, preventing later matches (e.g.,
gitlab.gitea.example.com always choosing gitlab). Change the logic in the loop
over SELF_HOSTED_FORGE so that you only return when the env var is defined
(token present) and otherwise continue to the next entry; also add a short
comment documenting that resolution is first-match-by-order and that order in
SELF_HOSTED_FORGE determines priority (gitlab → gitea → forgejo) so maintainers
know why ordering matters.
- Around line 326-339: The current auth-injection in the clone URL (inside the
block using resolveForgeAuth and safeParseUrl) silently upgrades any http:// to
https:// and drops parsed.search and parsed.hash; update the logic so that when
safeParseUrl(parsed) returns a URL you reconstruct cloneUrl preserving
parsed.protocol (fall back to https: only for bare host/path case), and append
parsed.search and parsed.hash after parsed.pathname while still injecting
`${authScheme}${forgeToken}@` between the protocol and host; if instead the
upgrade/drop is intentional, add a one-line comment next to the cloneUrl rebuild
explaining that protocol is intentionally upgraded to https and query/hash are
discarded when injecting auth.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 165f7d37-0676-4c25-9c6a-39bbb9a8c31f
📒 Files selected for processing (3)
packages/core/src/handlers/clone.test.tspackages/core/src/handlers/clone.tspackages/workflows/src/dag-executor.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- packages/workflows/src/dag-executor.ts
Review SummaryVerdict: blocking-issues This PR adds multi-forge authentication (GitLab, Gitea, Forgejo) and shell injection hardening for bash nodes. The security intent and implementation pattern are solid, but the shell injection fix is incomplete — Blocking issues
Suggested fixes
Minor / nice-to-have
Compliments
Reviewed via maintainer-review-pr workflow (Pi/Minimax). Aspects run: code-review, error-handling, test-coverage, comment-quality, docs-impact. |
- Fix LOOP_USER_INPUT in until_bash: only populate on first iteration (consistent with LLM prompt substitution at line 1874) - Change afterAll → afterEach in multi-forge test cleanup (throw-safe) - Add GITLAB_TOKEN/GITEA_TOKEN cleanup to parent beforeEach - Add auth env vars section to variables.md reference - Add shell injection prevention note to script-nodes.md
|
Thanks for the thorough review @Wirasm! Pushed fixes in 480bcdf: Loop env semantics (must-fix):
Test cleanup (should-fix):
Docs (should-fix):
All existing tests pass (210 dag-executor, 54 clone). |
- Remove issue number refs (#1717, #1651) from comments per CLAUDE.md - Trim multi-line JSDoc on extractNodeOutputEnvVars to 2-line comment - Remove regex comment that explained WHAT not WHY - Trim escapedForBash trailing comment - Add collision guard warn log when two node IDs map to the same env var - Spread config.envVars into until_bash subprocess for parity with executeBashNode - Update variables.md: replace stale "auto shell-quoted" description with env-var mechanism - Update script-nodes.md: fix stale contrast with bash node auto-quoting - Add CHANGELOG entry for #1717 fix - Add integration tests: executeBashNode and until_bash env var wiring via spy - Add empty-string output unit test for extractNodeOutputEnvVars
…odes (coleam00#1651) * fix(workflows): pass user-controlled variables via env vars in bash nodes (coleam00#1585) Stop substituting user-controlled variables ($USER_MESSAGE, $ARGUMENTS, $LOOP_USER_INPUT, $REJECTION_REASON, $LOOP_PREV_OUTPUT, and context variables) into bash node script bodies before passing to `bash -c`. Instead, pass them as environment variables on the subprocess. This prevents shell injection when user messages contain metacharacters (backticks, $(), unbalanced quotes, etc.) that would otherwise be interpreted as shell syntax. The fix adds a `shellSafe` option to `substituteWorkflowVariables()` that skips user-controlled variable replacement. System-controlled variables ($WORKFLOW_ID, $ARTIFACTS_DIR, $BASE_BRANCH, $DOCS_DIR) are still substituted normally since they contain safe, known values. `substituteNodeOutputRefs()` with `escapedForBash=true` was already safe via `shellQuote()` — only the Pass 1 variables needed fixing. Closes coleam00#1585. Subsumes coleam00#1377. * fix: address CodeRabbit review — correct loop env semantics and harden test cleanup - LOOP_PREV_OUTPUT in until_bash now correctly references the previous iteration's output (not the current one) - Test uses try/finally for spy cleanup and asserts call count before dereferencing mock.calls * fix(clone): authenticate non-GitHub forge URLs via GITLAB_TOKEN / GITEA_TOKEN (fixes coleam00#1655) The clone handler only injected auth tokens for github.com URLs. Private repos on GitLab, Gitea, or Forgejo failed to clone via the Web UI URL form. Replace the github.com-specific string substitution with a forge-aware resolver that: - Looks up the correct env var per hostname (GH_TOKEN, GITLAB_TOKEN, GITEA_TOKEN) - Applies the correct auth URL scheme (oauth2: prefix for GitLab, bare token for GitHub/Gitea) - Handles self-hosted instances (gitlab.mycompany.com, gitea.myorg.com) Add 11 new tests: 5 integration (GitLab, self-hosted GitLab, Gitea, Forgejo, unknown forge) + 4 resolveForgeAuth unit tests + 2 updated existing tests. * fix: match forge auth on parsed hostname only (CodeRabbit security review) Replace substring matching on the full URL with hostname-based matching. This prevents token leakage when forge names appear in URL paths (e.g. https://evil.example.com/gitlab/mirror). - Exact hostname match for known forges (github.com, gitlab.com, gitea.com) - Label-based match for self-hosted instances (gitlab.mycompany.com) - Add 2 security regression tests for path-based injection vectors * fix: add timeout and error logging to until_bash execution Address review feedback from @Wirasm: - Add SUBPROCESS_DEFAULT_TIMEOUT to until_bash execFileAsync to prevent indefinite hangs (matching other bash invocations in this file) - Log non-ENOENT errors in until_bash catch block so syntax errors and permission issues are visible instead of silently swallowed * fix: address review feedback — loop env semantics, test cleanup, docs - Fix LOOP_USER_INPUT in until_bash: only populate on first iteration (consistent with LLM prompt substitution at line 1874) - Change afterAll → afterEach in multi-forge test cleanup (throw-safe) - Add GITLAB_TOKEN/GITEA_TOKEN cleanup to parent beforeEach - Add auth env vars section to variables.md reference - Add shell injection prevention note to script-nodes.md
Summary
substituteWorkflowVariables()does literal string replacement of$ARGUMENTS/$USER_MESSAGEand other user-controlled variables into bash node script bodies before passing tobash -c. Shell metacharacters in user input become executable code.$ARGUMENTS/$USER_MESSAGEin abash:node is exploitable when user messages originate from partially-trusted sources (GitHub issue bodies, Slack DMs, email, webhooks). Benign markdown crashes the parse; crafted payloads execute arbitrary commands.shellSafeoption tosubstituteWorkflowVariables()that skips user-controlled variable substitution. Bash nodes now pass these values as subprocess environment variables instead, so bash naturally expands$ARGUMENTSfrom env at runtime.substituteNodeOutputRefswithescapedForBash=truewas already safe viashellQuote()and is not modified. System-controlled variables ($WORKFLOW_ID,$ARTIFACTS_DIR,$BASE_BRANCH,$DOCS_DIR) are still substituted normally.UX Journey
Before
After
Architecture Diagram
Before
After
Connection inventory:
{ shellSafe: true }{ shellSafe: true }Label Snapshot
risk: mediumsize: Sworkflowsworkflows:executorChange Metadata
securityworkflowsLinked Issue
bash:nodes is shell-injection-unsafe (subsumes #1377) #1585bash:nodes is shell-injection-unsafe (subsumes #1377) #1585)Validation Evidence (required)
Security Impact (required)
Compatibility / Migration
$ARGUMENTS/$USER_MESSAGEinbash:nodes continue to work identically because bash resolves$ARGUMENTSfrom the environment. The token stays in the script as-is; only the source of its value changes.Human Verification (required)
$(rm -rf /)) do not appear in script bodySide Effects / Blast Radius (required)
bash:nodes anduntil_bashconditions in DAG workflows$ARGUMENTSinside single-quoted strings would now get env value instead of literal text — but this is the correct behaviorRollback Plan (required)
$ARGUMENTS/$USER_MESSAGEvalues (empty variables in bash output)Risks and Mitigations
echo "$ARGUMENTS"works identically from env. All 210 dag-executor tests pass without modification.Summary by CodeRabbit
Improvements
New Features
Tests