8f23b8a19e
* fix: prevent command injection via untrusted git branch names
Untrusted git branch names (e.g. a fork PR head branch) were substituted
unescaped into executor prepare scripts and run via `eval` in a shell,
yielding command injection (CRITICAL RCE) in the Docker/Sprites/remote
executors. git check-ref-format permits `;`, `|`, `$`, backticks and
`()`, so a branch like `$(touch pwned)` or `main;touch pwned` executed.
- scriptengine: shell-single-quote every data placeholder that lands in
shell text (repository.path/name/branch/clone_url/ssh_url,
workspace.path, worktree.base_path/path/branch/base_branch). Script
fragments (setup_script, git.identity_setup, github.auth_setup,
kandev.*) are left untouched.
- default_scripts: wrap each data placeholder in single quotes in the
postlude, clone lines, and the Sprites echo (double quotes still allow
$(...) substitution). `'<escaped>'` round-trips to the literal.
- orchestrator: defense-in-depth — reject a PR head branch that fails
securityutil.IsValidBranchName before it reaches CheckoutBranch, while
still creating the review task without a checkout.
- tests: PoC-derived regression asserting the injected command no longer
runs through the real sh -c/eval sink, provider escaping unit tests,
and ingestion-layer validation coverage.
* fix: harden branch-name escaping for legacy stored prepare scripts
Automated review (Codex, CodeRabbit) flagged that shell-single-quoting the
value while relying on the templates to add the surrounding quotes only
protects the templates we ship. Profiles snapshot the default prepare
script at creation time, so an OLD stored script referencing a placeholder
bare (e.g. `git clone --depth=1 --branch {{repository.branch}} ...`) still
resolved with an unquoted, injectable branch value.
- scriptengine: add shellQuote, which emits a fully self-contained
single-quoted shell token (surrounding quotes included). Every data
placeholder (repository.path/name/branch/clone_url/ssh_url,
workspace.path, worktree.base_path/path/branch/base_branch) now carries
its own quoting, so a bare `{{placeholder}}` in any template — including
legacy stored scripts — is safe. shellSingleQuote is kept for the git
identity fragment, which supplies its own quotes.
- default_scripts: reference data placeholders bare (the value self-quotes);
the Sprites echo becomes printf with the values as separate args so a
hostile URL/branch can't expand inside a double-quoted string.
- tests: assert providers emit fully-quoted tokens that round-trip through a
real shell; cover base_path/path/repository.path/name/ssh_url; add a
regression proving a legacy stored script with bare placeholders no longer
executes injected commands.
* test: strengthen branch-injection regression assertions
Address Greptile review feedback on the new tests.
- assertNoUnquotedPayload now tracks real single-quote state char-by-char
instead of "some quote before and after", so a payload in an unquoted
context on a line that happens to contain other quotes is caught.
- Drop the unused wantHasRepoRow table field; repo-row presence is already
asserted via len(got) != 1.
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
185 lines
7.7 KiB
Go
185 lines
7.7 KiB
Go
package lifecycle
|
|
|
|
// DefaultPrepareScript returns the default prepare script for a given executor type string.
|
|
func DefaultPrepareScript(executorType string) string {
|
|
switch executorType {
|
|
case "local":
|
|
return defaultLocalPrepareScript
|
|
case "worktree":
|
|
return defaultWorktreePrepareScript
|
|
case "local_docker", "remote_docker":
|
|
return defaultDockerPrepareScript
|
|
case "sprites":
|
|
return defaultSpritesPrepareScript
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// KandevBranchCheckoutPostlude returns a kandev-managed shell snippet that
|
|
// guarantees the session's feature branch is checked out inside the
|
|
// workspace, no matter what the user's stored prepare_script does.
|
|
//
|
|
// Why a postlude instead of just relying on the default script: profiles
|
|
// created in the UI snapshot the *then-current* default into their stored
|
|
// prepare_script field. When kandev's default is updated to add a new
|
|
// kandev-managed step (like the worktree-branch checkout), older profiles
|
|
// silently miss it forever. Making the checkout an invariant — appended
|
|
// after the user's script — keeps the contract regardless of which default
|
|
// the user happens to have stored.
|
|
//
|
|
// The snippet is wrapped in a subshell + `|| true` so any failure (e.g. the
|
|
// user's script never produced /workspace, or the branch is the same as the
|
|
// base) is benign and doesn't block agentctl from starting.
|
|
//
|
|
//nolint:dupword // two `fi` tokens close two distinct shell blocks.
|
|
func KandevBranchCheckoutPostlude() string {
|
|
return `
|
|
|
|
# ---- kandev-managed: ensure session feature branch is checked out ----
|
|
# Appended automatically after the user's prepare script. Idempotent and
|
|
# non-destructive: prefer an existing local branch (which may carry unpushed
|
|
# work after a container resume), then fall through to a fresh tracking
|
|
# branch off origin, and only as a last resort create the branch off HEAD.
|
|
# The previous "git checkout -B feature origin/feature" form was destructive
|
|
# for the resume case — overwriting local commits with the remote tip.
|
|
#
|
|
# SECURITY: the data placeholders below are referenced BARE (no surrounding
|
|
# quotes). The scriptengine providers substitute a fully single-quoted,
|
|
# self-contained shell token (see scriptengine.shellQuote), so a branch name
|
|
# containing shell metacharacters (e.g. "$(...)", backticks, ";") resolves to a
|
|
# quoted literal that cannot inject commands. Do NOT wrap these in double
|
|
# quotes — double quotes would re-expose $(...) command substitution. Do NOT
|
|
# assume they are unquoted data either; the value carries its own quoting.
|
|
(
|
|
if [ -d {{workspace.path}}/.git ] \
|
|
&& [ -n {{worktree.branch}} ] \
|
|
&& [ {{worktree.branch}} != {{repository.branch}} ]; then
|
|
cd {{workspace.path}} || exit 0
|
|
if git rev-parse --verify {{worktree.branch}} >/dev/null 2>&1; then
|
|
git checkout {{worktree.branch}}
|
|
elif git fetch --depth=1 origin {{worktree.branch}} 2>/dev/null; then
|
|
git checkout -b {{worktree.branch}} origin/{{worktree.branch}}
|
|
else
|
|
git checkout -b {{worktree.branch}}
|
|
fi
|
|
fi
|
|
) || true
|
|
`
|
|
}
|
|
|
|
const defaultLocalPrepareScript = `#!/bin/bash
|
|
# Prepare local environment
|
|
# Runs before launching the local agent runtime.
|
|
# The script executes with working directory set to {{workspace.path}}.
|
|
# Use {{repository.path}} when you need the canonical repository root path.
|
|
|
|
# ---- Repository setup (if configured) ----
|
|
{{repository.setup_script}}
|
|
`
|
|
|
|
const defaultWorktreePrepareScript = `#!/bin/bash
|
|
# Prepare worktree environment
|
|
# Runs after the worktree has already been created/reused by Kandev.
|
|
# The script executes with working directory set to {{worktree.path}}.
|
|
# Use {{repository.path}} if you need to run commands in the main repository.
|
|
|
|
# ---- Repository setup (if configured) ----
|
|
{{repository.setup_script}}
|
|
`
|
|
|
|
const defaultDockerPrepareScript = `#!/bin/sh
|
|
# Prepare Docker container environment (kandev/multi-agent image)
|
|
# git, node, and agentctl are already installed in the image
|
|
|
|
set -eu
|
|
|
|
# ---- Git identity (optional) ----
|
|
{{git.identity_setup}}
|
|
|
|
# Mounted local remotes and workspaces can be owned by a host UID that does
|
|
# not match the container user.
|
|
git config --global --add safe.directory '*'
|
|
|
|
# ---- Configure git/gh for HTTPS auth ----
|
|
git config --global url."https://github.com/".insteadOf "git@github.com:"
|
|
git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
|
|
|
|
# Configure GitHub token for gh CLI and git operations
|
|
{{github.auth_setup}}
|
|
|
|
# ---- Clone repository ----
|
|
# The kandev-managed feature-branch checkout is appended as an invariant
|
|
# postlude (see KandevBranchCheckoutPostlude) — keep it out of the default
|
|
# so old profiles snapshotting this script and the postlude never disagree.
|
|
# SECURITY: the providers substitute fully single-quoted tokens (shellQuote) for
|
|
# repository.branch / repository.clone_url / workspace.path, so a hostile branch
|
|
# name or URL cannot break out of the git clone argument even though the
|
|
# placeholders are referenced bare here. Do not add double quotes around them.
|
|
git clone --depth=1 --branch {{repository.branch}} {{repository.clone_url}} {{workspace.path}}
|
|
cd {{workspace.path}}
|
|
|
|
# Strip embedded token from remote URL to avoid persisting credentials in .git/config
|
|
git remote set-url origin "$(git remote get-url origin | sed 's|https://[^@]*@github.com/|https://github.com/|')" 2>/dev/null || true
|
|
|
|
# ---- Repository setup (if configured) ----
|
|
{{repository.setup_script}}
|
|
`
|
|
|
|
const defaultSpritesPrepareScript = `#!/bin/bash
|
|
# Prepare Sprites.dev cloud sandbox
|
|
#
|
|
# Pre-installed tools (no need to install):
|
|
# git, curl, wget, gh (GitHub CLI), node, python, go,
|
|
# build-essential, openssh-client, ca-certificates
|
|
|
|
set -euo pipefail
|
|
|
|
# ---- Add SSH host keys (prevent "Host key verification failed") ----
|
|
mkdir -p ~/.ssh
|
|
ssh-keyscan -t ed25519 github.com gitlab.com bitbucket.org >> ~/.ssh/known_hosts 2>/dev/null
|
|
|
|
# ---- Configure git/gh for HTTPS auth (token-based, no SSH keys needed) ----
|
|
# Rewrite SSH URLs to HTTPS so git clone git@github.com:... works via token auth
|
|
git config --global url."https://github.com/".insteadOf "git@github.com:"
|
|
git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
|
|
|
|
# Configure GitHub token for gh CLI and git operations
|
|
# GH_TOKEN is the primary env var for gh CLI authentication
|
|
{{github.auth_setup}}
|
|
|
|
# ---- Install pnpm globally ----
|
|
curl -fsSL https://github.com/pnpm/pnpm/releases/download/v10.32.1/pnpm-linux-x64 -o /usr/local/bin/pnpm
|
|
chmod +x /usr/local/bin/pnpm
|
|
|
|
# ---- Git identity ----
|
|
{{git.identity_setup}}
|
|
|
|
# ---- Clone repository ----
|
|
# The kandev-managed feature-branch checkout is appended as an invariant
|
|
# postlude (see KandevBranchCheckoutPostlude) — keep it out of the default
|
|
# so old profiles snapshotting this script and the postlude never disagree.
|
|
# SECURITY: the providers substitute fully single-quoted tokens (shellQuote) for
|
|
# these placeholders. printf takes them as separate arguments, NOT inside a
|
|
# double-quoted string (which would re-expand a hostile URL/branch). Do not
|
|
# reintroduce an echo of these placeholders inside a double-quoted string.
|
|
printf 'Cloning %s (branch: %s)...\n' {{repository.clone_url}} {{repository.branch}}
|
|
git clone --depth=1 --quiet --branch {{repository.branch}} {{repository.clone_url}} {{workspace.path}}
|
|
cd {{workspace.path}}
|
|
|
|
# Strip embedded token from remote URL to avoid persisting credentials in .git/config
|
|
git remote set-url origin "$(git remote get-url origin | sed 's|https://[^@]*@github.com/|https://github.com/|')" 2>/dev/null || true
|
|
|
|
# ---- Repository setup (if configured) ----
|
|
{{repository.setup_script}}
|
|
|
|
# ---- Pre-install agent CLI(s) ----
|
|
{{kandev.agents.install}}
|
|
|
|
# ---- Install and start Kandev agent controller ----
|
|
echo "Starting agent controller..."
|
|
{{kandev.agentctl.install}}
|
|
{{kandev.agentctl.start}}
|
|
echo "Prepare complete."
|
|
`
|