Skip to content

fix(workflows): pass user-controlled variables via env vars in bash nodes#1651

Merged
Wirasm merged 6 commits into
coleam00:devfrom
kagura-agent:fix/bash-node-shell-injection
May 15, 2026
Merged

fix(workflows): pass user-controlled variables via env vars in bash nodes#1651
Wirasm merged 6 commits into
coleam00:devfrom
kagura-agent:fix/bash-node-shell-injection

Conversation

@kagura-agent

@kagura-agent kagura-agent commented May 12, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Problem: substituteWorkflowVariables() does literal string replacement of $ARGUMENTS/$USER_MESSAGE and other user-controlled variables into bash node script bodies before passing to bash -c. Shell metacharacters in user input become executable code.
  • Why it matters: Any workflow using $ARGUMENTS/$USER_MESSAGE in a bash: 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.
  • What changed: Added shellSafe option to substituteWorkflowVariables() that skips user-controlled variable substitution. Bash nodes now pass these values as subprocess environment variables instead, so bash naturally expands $ARGUMENTS from env at runtime.
  • What did not change (scope boundary): AI/command prompt substitution (non-bash paths) is unaffected. substituteNodeOutputRefs with escapedForBash=true was already safe via shellQuote() and is not modified. System-controlled variables ($WORKFLOW_ID, $ARTIFACTS_DIR, $BASE_BRANCH, $DOCS_DIR) are still substituted normally.

UX Journey

Before

User / Adapter           Orchestrator              Workflow Executor          bash -c
──────────────           ────────────              ─────────────────          ───────
sends message ─────────▶ stores as user_message
(may contain shell       dispatches workflow ────▶ substituteWorkflowVariables
metacharacters)                                    LITERALLY replaces $ARGUMENTS
                                                   with user text in script body
                                                                     ───────▶ re-parses
                                                                              user content
                                                                              as shell code
                                                                              → crash or
                                                                              injection

After

User / Adapter           Orchestrator              Workflow Executor          bash -c
──────────────           ────────────              ─────────────────          ───────
sends message ─────────▶ stores as user_message
(may contain shell       dispatches workflow ────▶ substituteWorkflowVariables
metacharacters)                                    [SKIPS user-controlled vars]
                                                   [passes as env vars instead]
                                                                     ───────▶ bash sees
                                                                              $ARGUMENTS
                                                                              from env
                                                                              → safe data

Architecture Diagram

Before

executor-shared.ts:substituteWorkflowVariables()
  └── replaces ALL variables (system + user-controlled) into prompt string

dag-executor.ts:executeBashNode()
  └── calls substituteWorkflowVariables(node.bash, ...)
       └── passes result to execFileAsync("bash", ["-c", finalScript])
            └── user text is INSIDE the shell program → injection

After

executor-shared.ts:substituteWorkflowVariables()
  └── [~] when shellSafe=true, replaces ONLY system-controlled variables

dag-executor.ts:executeBashNode()
  └── [~] calls substituteWorkflowVariables(..., { shellSafe: true })
       └── passes result to execFileAsync("bash", ["-c", finalScript],
                                          { env: { USER_MESSAGE, ARGUMENTS, ... } })
            └── user text is in ENV, not in shell source → safe

dag-executor.ts:executeLoopNode (until_bash)
  └── [~] same treatment — shellSafe + env vars

Connection inventory:

From To Status Notes
executeBashNode substituteWorkflowVariables modified passes { shellSafe: true }
executeBashNode execFileAsync modified env includes user-controlled vars
executeLoopNode (until_bash) substituteWorkflowVariables modified passes { shellSafe: true }
executeLoopNode (until_bash) execFileAsync modified env includes user-controlled vars
all other callers substituteWorkflowVariables unchanged no options = original behavior

Label Snapshot

  • Risk: risk: medium
  • Size: size: S
  • Scope: workflows
  • Module: workflows:executor

Change Metadata

  • Change type: security
  • Primary scope: workflows

Linked Issue

Validation Evidence (required)

$ bun test packages/workflows/src/executor-shared.test.ts
69 pass, 0 fail

$ bun test packages/workflows/src/dag-executor.test.ts
210 pass, 0 fail

$ bun run type-check
All 10 packages: Exited with code 0

$ bun run lint
Exited with code 0
  • Evidence provided: All existing tests pass + 3 new tests (2 unit for shellSafe, 1 integration for bash node env var delivery)
  • No commands skipped

Security Impact (required)

  • New permissions/capabilities? No
  • New external network calls? No
  • Secrets/tokens handling changed? No
  • File system access scope changed? No
  • This PR fixes a shell injection vulnerability by changing from string substitution to environment variable pass-through for user-controlled values in bash nodes.

Compatibility / Migration

  • Backward compatible? Yes — workflows referencing $ARGUMENTS/$USER_MESSAGE in bash: nodes continue to work identically because bash resolves $ARGUMENTS from the environment. The token stays in the script as-is; only the source of its value changes.
  • Config/env changes? No
  • Database migration needed? No

Human Verification (required)

  • Verified scenarios: shellSafe=true skips user-controlled vars; system vars still substituted; bash node receives user message via env; shell metacharacters ($(rm -rf /)) do not appear in script body
  • Edge cases checked: empty issueContext, undefined loopUserInput/rejectionReason/loopPrevOutput all default to empty string in env
  • What was not verified: End-to-end with a live Archon instance (no local setup with full server)

Side Effects / Blast Radius (required)

  • Affected subsystems/workflows: Only bash: nodes and until_bash conditions in DAG workflows
  • Potential unintended effects: Workflows that used $ARGUMENTS inside single-quoted strings would now get env value instead of literal text — but this is the correct behavior
  • Guardrails: All 279 existing workflow tests pass unchanged

Rollback Plan (required)

  • Fast rollback: Revert this single commit
  • Feature flags: None needed — internal to executor
  • Observable failure symptoms: Bash nodes would fail to receive $ARGUMENTS/$USER_MESSAGE values (empty variables in bash output)

Risks and Mitigations

  • Risk: Existing workflows depending on substituted value in script source
    • Mitigation: Extremely unlikely — standard pattern echo "$ARGUMENTS" works identically from env. All 210 dag-executor tests pass without modification.

Summary by CodeRabbit

  • Improvements

    • Bash/script nodes now avoid inline substitution of user-controlled values by passing them via environment and safer variable handling.
    • Clone flow enhanced to resolve and use forge-specific tokens for additional git hosts and self‑hosted cases.
  • New Features

    • Added a shellSafe option to control workflow-variable substitution.
    • Bash subprocess environments now surface loop/approval and previous-output context variables.
  • Tests

    • New tests for shell-safe substitution, bash environment handling, and multi-forge auth/security.

Review Change Stack

…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.
@coderabbitai

coderabbitai Bot commented May 12, 2026

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6c9e6180-9716-4e7b-91ed-3f2546a1ef0a

📥 Commits

Reviewing files that changed from the base of the PR and between 8ba4931 and 480bcdf.

📒 Files selected for processing (4)
  • packages/core/src/handlers/clone.test.ts
  • packages/docs-web/src/content/docs/guides/script-nodes.md
  • packages/docs-web/src/content/docs/reference/variables.md
  • packages/workflows/src/dag-executor.ts
✅ Files skipped from review due to trivial changes (2)
  • packages/docs-web/src/content/docs/guides/script-nodes.md
  • packages/docs-web/src/content/docs/reference/variables.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/workflows/src/dag-executor.ts

📝 Walkthrough

Walkthrough

Prevents shell-injection by leaving user-controlled and context workflow variables unsubstituted for bash: nodes, passing them via the spawned subprocess environment (with shellSafe substitution enabled). Also adds forge-aware auth resolution for git clone URLs and corresponding tests.

Changes

Shell-safe variable handling for bash nodes

Layer / File(s) Summary
substituteWorkflowVariables shellSafe option
packages/workflows/src/executor-shared.ts, packages/workflows/src/executor-shared.test.ts
Function accepts options?.shellSafe; when enabled, user variables ($USER_MESSAGE, $ARGUMENTS, $LOOP_USER_INPUT, $REJECTION_REASON, $LOOP_PREV_OUTPUT) and context variables remain unsubstituted, while system variables ($ARTIFACTS_DIR, $BASE_BRANCH, etc.) continue to be replaced. Unit tests verify both behaviors.
Bash node environment variable injection
packages/workflows/src/dag-executor.ts, packages/workflows/src/dag-executor.test.ts, packages/docs-web/src/content/docs/guides/script-nodes.md
executeBashNode() and executeLoopNode() call substituteWorkflowVariables() with shellSafe: true, snapshot prior iteration output, spawn the bash subprocess with an explicit env containing user/context/loop variables instead of splicing them into the bash script, and add error handling for until_bash. Regression test spies on execFileAsync to assert the bash -c body is literal and the child env contains the original payloads; docs note env delivery for script nodes.

Forge-aware clone authentication

Layer / File(s) Summary
Resolve forge auth and URL parsing
packages/core/src/handlers/clone.ts
Add safeParseUrl and exported resolveForgeAuth(url) to derive { token, scheme } for GitHub, GitLab, Gitea and self-hosted label patterns using environment variables.
Clone URL construction using forge auth
packages/core/src/handlers/clone.ts
Replace GitHub-only GH_TOKEN logic with resolveForgeAuth(workingUrl) and build authenticated https://<scheme><token>@... clone URLs when a token exists, handling parsed and bare URL forms.
Multi-forge tests and resolveForgeAuth unit tests
packages/core/src/handlers/clone.test.ts, packages/docs-web/src/content/docs/reference/variables.md
Add tests covering no GH_TOKEN injection for non-GitHub URLs, multi-forge authentication (GitLab/Gitea/self-hosted/unknown), token leakage security checks, and direct unit tests for resolveForgeAuth outputs; docs add authentication environment variables section.

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)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I tuck your message safe from shelly fright,
I hand it to the child process, wrapped polite,
No backticks gnaw, no surprise command,
The bash sees env and not a spliced hand,
Hooray — the workflow hops on, safe and bright!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 71.43% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely summarizes the primary change: fixing shell injection risk by passing user-controlled variables via environment variables in bash nodes rather than inline substitution.
Description check ✅ Passed The PR description is comprehensive and well-structured, covering problem, impact, solution, UX journey, architecture changes, validation evidence, security impact, compatibility, human verification, and rollback plan.
Linked Issues check ✅ Passed All objectives from linked issue #1585 are met: shell injection vulnerability is fixed by switching from literal substitution to env-var pass-through, user-controlled variables are properly isolated, system variables continue to be substituted, and documentation is updated.
Out of Scope Changes check ✅ Passed All changes directly address the shell injection fix and necessary supporting infrastructure. Addition of multi-forge authentication (clone.ts) and auth env var documentation are closely related security hardening efforts within the same commit set.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 win

Fix loop env variable semantics in until_bash evaluation.

Line 2159 always exports LOOP_USER_INPUT, and Line 2160 exports LOOP_PREV_OUTPUT after Line 2124 already overwrote it with current-iteration output. This breaks loop semantics and can skew until_bash completion 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 win

Harden this test against cascading failures by restoring the spy in finally and asserting the call exists first.

If an assertion fails before Line 1424, execFileAsync remains mocked and can pollute later tests. Also, dereferencing mock.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

📥 Commits

Reviewing files that changed from the base of the PR and between b4cd637 and ff17523.

📒 Files selected for processing (4)
  • packages/workflows/src/dag-executor.test.ts
  • packages/workflows/src/dag-executor.ts
  • packages/workflows/src/executor-shared.test.ts
  • packages/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
@Wirasm

Wirasm commented May 13, 2026

Copy link
Copy Markdown
Collaborator

Review Summary

Verdict: minor-fixes-needed

Security fix looks good overall — well-scoped, well-tested, and follows established patterns. Two issues to address before merge: the until_bash path is not covered by the new shellSafe mitigation (leaving a shell injection vector), and the until_bash execution has no timeout guard.

Blocking issues

  • dag-executor.ts:2144 (loop.until_bash): Your fix adds shellSafe: true and env-var pass-through to the main bash node execution, but until_bash still calls substituteWorkflowVariables with no shellSafe flag. If a workflow uses $USER_MESSAGE or $ARGUMENTS in an until_bash expression, those variables are still substituted via string replacement and injected into bash -c. Either add shellSafe: true and pass those vars via env to the until_bash execFileAsync call (same pattern as the main script), or document why until_bash is intentionally excluded.

Suggested fixes

  • dag-executor.ts:2144: execFileAsync for until_bash has no timeout option. Every other bash invocation in this file defaults to SUBPROCESS_DEFAULT_TIMEOUT = 120_000ms. A malicious or looping until_bash expression would hang the workflow indefinitely. Add { timeout: SUBPROCESS_DEFAULT_TIMEOUT } to the execFileAsync call for until_bash.

  • dag-executor.ts:2156 (catch (e)): Non-ENOENT errors in until_bash are silently swallowed — bashComplete is set to false with no log entry. A syntax error in the until_bash expression is invisible to the user. Add getLog().warn(...) for non-ENOENT cases.

  • executor-shared.ts:391–412 + dag-executor.ts:1331–1338: When shellSafe: true, context variables ($CONTEXT, $EXTERNAL_CONTEXT, $ISSUE_CONTEXT) are excluded from inline substitution but may not be available via the subprocess env either — verify that the env-passed CONTEXT/EXTERNAL_CONTEXT/ISSUE_CONTEXT resolve correctly in the bash script when shellSafe: true. Add a test case: shellSafe: true + issueContext: 'test-value' → verify $CONTEXT resolves in the bash script. If not, add those vars to the env block or keep them substituted inline.

Minor / nice-to-have

  • dag-executor.ts:1331–1338: EXTERNAL_CONTEXT and ISSUE_CONTEXT env keys have identical values. YAGNI if this is future-proofing rather than a planned feature.
  • dag-executor.test.ts:2129: No test for the loop.until_bash feature. A basic test (exit 0 = loop complete, non-zero = continue) would guard against regressions as shellSafe evolves.

Compliments

  • The env-var pass-through approach is clean and easy to audit — the security rationale is clear and the implementation doesn't complicate the existing code paths.
  • Test coverage is solid: unit tests for the shellSafe option, an integration test for the injection prevention, and all existing tests pass.
  • The new comment in executor-shared.ts:391–392 explains the why (shell injection prevention) rather than the what, which is exactly what you want for non-obvious security invariants.

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
@kagura-agent

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review @Wirasm! Really appreciate the detailed breakdown. Here's what I've addressed:

Blocking issue — until_bash shell injection vector:
This was actually already fixed in the second commit (47f29a1) — substituteWorkflowVariables is called with shellSafe: true and all user/context variables are passed via the env block to execFileAsync. The first commit only covered the main bash node path, and the CodeRabbit review caught the until_bash gap, so I fixed it before your review landed. You can verify at dag-executor.ts L2136-2165.

Suggested fixes — addressed in 8ba4931:

  1. ✅ Added timeout: SUBPROCESS_DEFAULT_TIMEOUT to the until_bash execFileAsync call, matching the pattern used by other bash invocations in this file.
  2. ✅ Added getLog().warn() for non-ENOENT errors in the until_bash catch block — syntax errors, permission issues, etc. are now visible instead of silently swallowed.

Context variables ($CONTEXT, $EXTERNAL_CONTEXT, $ISSUE_CONTEXT):
These are already passed via the env block (L2162-2164), so they resolve correctly in the bash script when shellSafe: true skips inline substitution.

All 210 dag-executor tests + 69 executor-shared tests pass. 🟢

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (6)
packages/core/src/handlers/clone.ts (4)

59-70: 💤 Low value

JSDoc for resolveForgeAuth is detached from the function.

Lines 59–62 document resolveForgeAuth, but SELF_HOSTED_FORGE is declared between the comment and the function, so tooling/readers will associate the JSDoc with the wrong symbol. Move the function-level doc directly above export 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 win

Misleading JSDoc on FORGE_AUTH — references a non-existent exact field.

The comment describes Entries with exact: true vs Entries with exact: false semantics, but ForgeAuthEntry has no exact property; matching is split between this array (always exact equality on hostPattern) and the separate SELF_HOSTED_FORGE table (label-based). Either align the comment with the actual implementation or add the exact flag.

📝 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 value

Label-match semantics: confirm intent for ambiguous multi-forge hostnames.

The self-hosted matcher uses labels.includes(entry.label) and iterates SELF_HOSTED_FORGE in declaration order. A hostname like gitlab.gitea.example.com will resolve to GITLAB_TOKEN (first match wins) even though gitea is 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_TOKEN on gitlab.gitea.example.com falls through to try GITEA_TOKEN instead 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 win

Silent http://https:// upgrade and dropped URL components when injecting auth.

When safeParseUrl succeeds, the rebuilt cloneUrl always uses https:// and only re-emits parsed.host + parsed.pathname. Two effects worth confirming are intentional:

  1. An http:// clone URL is silently upgraded to https:// 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 preserving parsed.protocol.
  2. parsed.search and parsed.hash are dropped. For git clone these 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 win

Consider 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 implicit http://https:// upgrade in clone.ts Line 333. Given the security-sensitive nature of this PR, an explicit test (e.g., await cloneRepository('http://gitlab.mycompany.com/owner/repo') with GITLAB_TOKEN set, asserting the final clone URL starts with https://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 win

Move resolveForgeAuth import 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() returns any, losing type checking on the function call.

Since resolveForgeAuth is exported from clone.ts with 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

📥 Commits

Reviewing files that changed from the base of the PR and between 47f29a1 and 8ba4931.

📒 Files selected for processing (3)
  • packages/core/src/handlers/clone.test.ts
  • packages/core/src/handlers/clone.ts
  • packages/workflows/src/dag-executor.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/workflows/src/dag-executor.ts

Comment thread packages/core/src/handlers/clone.test.ts
@Wirasm

Wirasm commented May 14, 2026

Copy link
Copy Markdown
Collaborator

Review Summary

Verdict: 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 — executeBashNode does not pass { shellSafe: true } to substituteWorkflowVariables, so user-controlled variables are still substituted directly into bash scripts in production. Documentation for the new auth tokens and the behavioral change to bash node variable injection is also missing.

Blocking issues

  • packages/workflows/src/dag-executor.ts:1312: substituteWorkflowVariables is called without { shellSafe: true }, so the shell injection mitigation is only half-applied. A user message like $(rm -rf /) in $USER_MESSAGE or $ARGUMENTS will still be injected into the bash script. The test added by this PR tests the direct substituteWorkflowVariables call rather than the executeBashNode path, so it passes even though the production call is vulnerable.
    • Fix: Pass { shellSafe: true } as the last argument to substituteWorkflowVariables in executeBashNode.

Suggested fixes

  • packages/workflows/src/dag-executor.ts:2134: Same issue in executeLoopNode for loop.until_bash — also does not pass { shellSafe: true }. The env vars are already provided, but the string substitution is also still active, causing double substitution risk.

    • Fix: Pass { shellSafe: true } here as well.
  • packages/docs-web/src/content/docs/reference/variables.md (+ getting-started/deployment docs): GITLAB_TOKEN and GITEA_TOKEN are new public surface. No docs mention them. Users cloning non-GitHub repos won't know they need these.

    • Fix: Add an authentication env vars section to reference/variables.md (note oauth2: scheme for GitLab, bare token for Gitea/Forgejo) and add env setup guidance to getting-started or deployment docs.
  • packages/docs-web/src/content/docs/guides/authoring-workflows.md + script-nodes.md: Variable reference tables show $USER_MESSAGE / $ARGUMENTS / $CONTEXT as string-substituted. This PR changes them to env-var injection in bash nodes. Scripts relying on current docs will break.

    • Fix: Add a note to bash node sections: user-controlled variables are passed as environment variables (USER_MESSAGE, ARGUMENTS, etc.) rather than inline in the script string. This is intentional to prevent shell injection.

Minor / nice-to-have

  • CHANGELOG.md: Add entries under [Unreleased] for multi-forge auth and the security hardening.
  • packages/core/src/handlers/clone.ts:resolveForgeAuth: Add unit test for bare host form (gitlab.mycompany.com/owner/repo) — currently tested only in integration.
  • packages/workflows/src/dag-executor.ts:2175-2180: Add direct assertion for loop_node.until_bash_unexpected_error warn log (non-ENOENT system errors) rather than relying on the exit-code path.
  • packages/core/src/handlers/clone.ts:safeParseUrl: Add dedicated unit test (null for invalid URL, URL object for valid URL).
  • packages/core/src/handlers/clone.ts:74-76: Strip or shorten resolveForgeAuth JSDoc — the WHAT description is redundant with the name and type signature.
  • packages/core/src/handlers/clone.ts:48-54: gitea.com is a real hosted platform (like gitlab.com). Consider whether it should get the same oauth2: treatment.

Compliments

  • The env-var isolation pattern in executor-shared.ts is the right design — preventing the injection at the source rather than trying to sanitize afterward.
  • resolveForgeAuth unit tests cover the new multi-forge logic well with clean, readable cases.
  • The error-handling review confirmed no silent swallows or inconsistent logging patterns.

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
@kagura-agent

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review @Wirasm! Pushed fixes in 480bcdf:

Loop env semantics (must-fix):

  • LOOP_USER_INPUT in until_bash now only populates on the first iteration (i === startIteration), consistent with the LLM prompt substitution logic at line 1874. Previously it leaked loopUserInput into every iteration.

Test cleanup (should-fix):

  • Changed afterAllafterEach in the multi-forge authentication describe block so env cleanup is throw-safe
  • Added GITLAB_TOKEN/GITEA_TOKEN deletion to the parent beforeEach (previously only cleaned GH_TOKEN)
  • Added afterEach import

Docs (should-fix):

  • Added "Authentication Environment Variables" section to reference/variables.md documenting GH_TOKEN, GITLAB_TOKEN, GITEA_TOKEN
  • Added shell injection prevention note to guides/script-nodes.md explaining that bash: nodes receive user-controlled variables via env vars, not inline substitution

All existing tests pass (210 dag-executor, 54 clone).

@Wirasm Wirasm merged commit 60adb5f into coleam00:dev May 15, 2026
4 checks passed
Wirasm added a commit that referenced this pull request May 18, 2026
- 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
cropse pushed a commit to cropse/Archon that referenced this pull request May 19, 2026
…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
@Wirasm Wirasm mentioned this pull request May 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Variable substitution in bash: nodes is shell-injection-unsafe (subsumes #1377)

2 participants