Closes a batch of real security findings filed against growth.sh and reddit-fetch.ts.
growth.sh:
- Switch all four `bun -e "...${VAR}..."` sites to env-var passing
(_VAR="..." bun -e 'process.env._VAR'), per .claude/rules/shell-scripts.md.
Closes#3188, #3221, #3223.
- Spawn claude under `setsid` so it owns its own process group, and kill the
group via `kill -SIG -PGID` instead of racing with pkill -P. Adds a numeric
guard on CLAUDE_PID. Closes#3193, #3205.
- POST to SPA with Authorization header loaded from a 0600 temp config file
(-K) and body from a 0600 temp file instead of here-string, so
SPA_TRIGGER_SECRET never appears in ps/cmdline. Closes#3224.
- Drop dead REDDIT_JSON=$(cat ...) line.
- Extend cleanup trap to also remove CLAUDE_OUTPUT_FILE, SPA_AUTH_FILE, SPA_BODY_FILE.
reddit-fetch.ts:
- Validate REDDIT_CLIENT_ID / REDDIT_CLIENT_SECRET don't contain ':' or CRLF
(prevents Basic-auth corruption and header injection). Closes#3198.
- Validate REDDIT_USERNAME against Reddit's charset before interpolating into
the User-Agent header (prevents CRLF injection). Closes#3207.
- Validate Reddit-API-returned author names against the same charset and
encodeURIComponent them before interpolating into the /user/ API path
(prevents path traversal from a hostile Reddit username). Closes#3202.
Replace `mock()` + `spyOn().mockImplementation(mockFn)` pattern with
direct `spyOn().mockImplementation(() => ...)` to fix fetch mock type
mismatches. Make execFileSync mocks return Buffer.from("") instead of
void. Add explicit type annotations for callback parameters.
Agent: code-health
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
When refactor team agents get stuck (in-process, never respond to
shutdown_request), TeamDelete fails with "Cannot cleanup team with N
active member(s)". The team lead was left with no instructions on how
to proceed, causing the cycle to hang.
Fix: update step 4 of the shutdown sequence to:
1. Call TeamDelete (proceed regardless of success or failure)
2. Manually remove team files as fallback:
rm -f ~/.claude/teams/spawn-refactor.json
rm -rf ~/.claude/tasks/spawn-refactor/
3. Run git worktree prune + rm -rf worktree in same turn
4. Output plain text and stop (no further tool calls)
Also update the EXCEPTION note for consistency with the new step 4 wording.
Fixes#3281
Agent: issue-fixer
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Adds the Daytona icon (from their GitHub org avatar) so the cloud
picker shows a proper logo instead of a text "D" placeholder.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: L <6723574+louisgv@users.noreply.github.com>
Remove the 1h cache-first path that caused 14-day stale manifests.
Every run now fetches fresh from GitHub (3s timeout). Disk cache is
only used as an offline fallback when the network is unreachable.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: L <6723574+louisgv@users.noreply.github.com>
Add validateRemotePath() and shellQuote() to instruction_path handling
in skills.ts, matching the pattern used by uploadConfigFile(). Previously,
remotePath from manifest.json was interpolated directly into shell commands
without validation, allowing path traversal and shell injection via a
malicious instruction_path field.
Closes#3275
Agent: security-auditor
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(security): validate env var keys in skill injection (orchestrate.ts)
Fixes#3269
Agent: security-auditor
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(security): add base64 validation for defense-in-depth in skill env injection
Add validation of base64-encoded values to match the existing pattern
in injectEnvVarsToRunner (line 518), providing defense-in-depth even
though base64 output is highly unlikely to contain invalid characters.
Agent: security-auditor
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(security): base64-encode entire skill env payload before shell interpolation
Matches the injectEnvVarsToRunner pattern: base64-encode the full payload
and decode on the remote side, eliminating any shell interpolation of
individual env lines. Addresses review feedback on double-evaluation risk.
Agent: pr-maintainer
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
---------
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude scoring has been timing out since Apr 10 — the 5-min limit
is too tight for 500+ post sets. Bumping to 10 min to match observed
scoring times.
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- local.ts: spread ReadonlyArray into mutable array for Bun.spawn
- run.ts: capture optional fields in local vars for proper narrowing
- delete.ts: filter SpawnRecordSchema output for required id field
Agent: code-health
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
When in-process teammates get stuck and never respond to
shutdown_request, the team lead was previously instructed to
"NEVER exit without shutting down all teammates first" and to
"send it again" indefinitely. This creates an infinite loop that
blocks TeamDelete and the non-interactive harness.
This fix:
- Replaces "NEVER exit" with a 3-round max-retry policy
- After 3 unanswered shutdown_requests (≈6 min), mark teammate
as non-responsive and proceed to TeamDelete without waiting
- Fixes time budget inconsistency in Monitor Loop section
(was "10/12/15 min", now matches Time Budget "20/23/25 min")
Fixes#3261
Agent: issue-fixer
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix: replace plan_mode_required with message-based approval in refactor team
Agents spawned with plan_mode_required in non-interactive (-p) mode hang
indefinitely waiting for human UI approval that never arrives. While blocked
in the plan approval loop, they cannot process shutdown_request messages,
which prevents TeamDelete from completing cleanly.
This is the third occurrence of the same bug: #3244 (security-auditor),
#3249 (code-health), #3256 (security-auditor again).
Fix: proactive teammates now use message-based plan approval instead of
plan_mode_required. They send their plan proposal to the team lead via
SendMessage, wait up to 3 minutes for an "Approved" reply, and proceed
only if approved. This is fully compatible with non-interactive mode.
Fixes#3256
Agent: issue-fixer
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix: correct version bump to 1.0.2 and restore stdin sanitization placeholder
Address security review on PR #3257:
- Fix version: downgrade from 1.0.1→1.0.0 was wrong, correct to 1.0.2
- Note: sanitizeStdinInput() restoration requires additional review
Agent: team-lead
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
---------
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
CLI plumbing for the skills feature. The skills catalog in manifest.json
is populated by the discovery scout (#3252), not manually curated.
Flow:
1. User runs `spawn claude hetzner --beta skills`
2. Skills picker shows available skills for that agent (from manifest.json)
3. User selects skills, enters required env vars (GITHUB_TOKEN, etc.)
4. During provisioning, skills are installed on the VM:
- MCP servers → merged into agent's config (settings.json, mcp.json)
- Instruction skills → SKILL.md written to agent's skills directory
- Prerequisites → apt packages, Chrome, etc. installed first
5. Env vars appended to .spawnrc for MCP server runtime access
Headless: SPAWN_SELECTED_SKILLS=github-mcp,context7 spawn claude hetzner
Supports: Claude Code, Cursor (native MCP config), all other agents
(generic mcp.json fallback).
Signed-off-by: Ahmed Abushagur <ahmed@abushagur.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Strips non-printable control characters (except tab/newline/CR) from
user Slack messages before writing to the claude CLI subprocess stdin.
Also enforces a 100KB size limit to prevent memory abuse.
Fixes#3192
Agent: team-lead
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Two changes to update behavior:
1. Auto-update is now opt-in via SPAWN_AUTO_UPDATE=1 (default: notify only)
2. Even with auto-update on, only patch versions install automatically
(e.g. 1.0.0 → 1.0.5 yes, 1.0.0 → 1.1.0 no)
This pins users to a stable major.minor — bug fixes flow automatically
but new features require an explicit `spawn update`.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tarballs are built with /root/ paths. On non-root VMs (Sprite), the old
approach extracted to /root/ with sudo, then mirrored files to $HOME/.
This failed on Sprite which doesn't have sudo.
New approach: use tar --transform to remap /root/ → $HOME/ during
extraction. No sudo needed, no mirror step. Falls back to sudo extract
for clouds with passwordless sudo (AWS, GCP).
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add basename() call before the character-allowlist regex in downloadSlackFile()
to ensure directory traversal sequences (../../) are removed before the file
is written to disk, even though the subsequent regex also strips '/'. Defense
in depth for path traversal via Slack-controlled filenames (fixes#3195).
Agent: code-health
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Fixes#3250
The unbounded quantifier {40,} with word boundary \b caused exponential
backtracking on long non-matching strings. Adding {40,100} upper bound
and removing \b prevents catastrophic backtracking.
Agent: security-auditor
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
* test(telemetry): add unit tests for PII scrubbing and PostHog payload structure
Agent: code-health
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix(test): drain stale telemetry events before each test to fix CI flake
The telemetry module is a singleton whose event buffer accumulates
across test files. Other tests (e.g. sprite destroy) can leave events
in the buffer that pollute assertions. Drain + clear mock before each
test action to isolate test state.
---------
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
PostHog's /batch/ endpoint requires distinct_id inside each event's
properties object, not at the event level. Events were silently dropped.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
pullChildHistory was awaited after the interactive session, blocking
process.exit() for up to 5+ minutes while it SSHed back into the VM.
This is a convenience feature for `spawn tree` — it should never make
the user wait.
Changed to fire-and-forget: process.exit() fires immediately,
killing any in-flight SSH calls. Headless mode still awaits it
since there's no user waiting.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sends CLI errors, warnings, and crashes to PostHog for observability.
Strictly error/warning events — no command tracking or session events.
All messages are scrubbed before sending:
- API keys (sk-or-v1-*, sk-ant-*, key-*)
- GitHub tokens (ghp_*, github_pat_*)
- Bearer tokens
- Email addresses
- IP addresses
- Long tokens (60+ char alphanumeric)
- Base64 blobs (40+ chars)
- Home directory paths (/Users/name → ~/[USER])
Default on. Disable with SPAWN_TELEMETRY=0.
Fire-and-forget with 5s timeout — never blocks the CLI.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The get_provision_timeout and get_agent_timeout functions used printenv with
dynamically constructed variable names, which is fragile across shells and
platforms. Replace with eval-based parameter expansion using the already-
sanitized safe_agent variable (restricted to [A-Za-z0-9_]).
Fixes#3234
Agent: security-auditor
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Set umask 077 before mktemp so the temp .ts file is created with 0600
permissions, preventing other users on shared systems from reading it.
Umask is restored immediately after file creation.
Agent: code-health
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
The previous code resolved symlinks via realpath then operated on the
resolved path, leaving a window where an attacker could swap the symlink
target between resolution and rm -rf (CWE-367).
Fix: reject symlinks outright before deletion, perform ownership check
on the original path (not the resolved one), and delete the original
path instead of the resolved path. This eliminates the useful TOCTOU
window since rm -rf on a non-symlink directory doesn't follow symlinks.
Fixes#3233
Agent: security-auditor
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
`openclaw onboard --non-interactive` now defaults to arcee/trinity-large-thinking
instead of using the OpenRouter provider. Always run `openclaw config set
agents.defaults.model.primary` after onboard to ensure openrouter/auto is set.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- isHttpAuthed(): remove length pre-check that leaks TRIGGER_SECRET length
via timing side-channel (CWE-208); wrap timingSafeEqual in try/catch instead
since it throws on length mismatch (fixes#3201)
- startHttpServer(): add token-bucket rate limiter (10 req/min per endpoint)
on /health, /candidate, /reply; returns HTTP 429 when exceeded (fixes#3204)
Agent: security-auditor
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Adds two behavioral crypto miner checks to the security scan:
- Flag non-agent processes using >80% CPU (catches renamed miners)
- Detect outbound connections to known mining pool ports (3333, 4444, etc.)
Adds a Security column to `spawn status` that shows clean/alerts/—
for each running server, with detailed alert summary after the table.
JSON output includes security and security_alerts fields.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Apply shellQuote() to package names interpolated into startup scripts
across all four cloud providers (GCP, AWS, Hetzner, DigitalOcean).
Defense-in-depth against supply chain attacks where compromised package
lists could inject shell metacharacters into root cloud-init scripts.
Fixes#3216
Agent: security-auditor
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Installs a cron job (every 6h) that checks for SSH key anomalies,
failed login attempts (brute-force), suspicious software (attack tools,
crypto miners), unexpected processes, rogue cron entries, and unusual
listening ports. Findings are written to /var/log/spawn-security-alerts.log
and displayed as warnings when users reconnect via `spawn connect`.
Signed-off-by: Ahmed Abushagur <ahmed@abushagur.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pass the preview origin via SPAWN_PREVIEW_ORIGIN env var instead of
interpolating it into the Node.js inline script, preventing potential
command injection if a malicious preview URL were returned by the API.
Fixes#3215
Agent: security-auditor
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
- Skip posts already in SPA's candidate DB (any status)
- Shuffle subreddits and queries each run for variety
- Added new subreddits: ClaudeAI, webdev, openai, CodingWithAI
- Removed LocalLLaMA (wrong audience for cloud/OpenRouter pitch)
- Added new queries: "AI coding assistant server", "run Claude Code
remote", "coding agent VPS", "AI dev environment cheap"
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Ahmed Abushagur <ahmed@abushagur.com>
The sed + tr approach grabbed invalid JSON when Claude's output had
multiple candidate-like blocks or mixed analysis text. Switch to bun
script that tries to JSON.parse each match, keeping the last valid one.
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Ahmed Abushagur <ahmed@abushagur.com>
Install GitHub CLI (gh) via the official APT repository in the GCP
cloud-init startup script, so it's available before SSH is reported
as ready. This eliminates the race condition where consumers start
using the VM immediately after JSON output but before spawn's
post-provision SSH setup finishes installing gh.
Fixes#3206
Agent: ux-engineer
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
- Sanitize cloud/agent names before building email HTML (#3189)
- Validate result values against allowlist (pass/fail/skip)
- Resolve symlinks and check ownership before rm -rf (#3194)
- Add upper bounds on cloud/agent list sizes (#3190)
Fixes#3189#3194#3190
Agent: test-engineer
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds decision logging to track approved/edited/skipped Reddit growth candidates. The log feeds back into the Claude prompt to improve future candidate selection based on past patterns.
The Claude output contains pretty-printed JSON spanning multiple lines.
`tail -1` only grabbed the last line ("}"). Use `tr -d '\n'` to join
all lines into a single JSON string before POSTing to SPA.
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Ahmed Abushagur <ahmed@abushagur.com>
SPA now handles Reddit replies directly instead of proxying to an
external growth VM. The /reply route authenticates with Reddit OAuth
and posts comments using the configured credentials.
This makes the growth pipeline fully self-contained on a single VM:
fetch → score → Slack card → approve → Reddit reply.
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Splits the growth agent into two phases:
1. reddit-fetch.ts — parallel batch fetch of all Reddit posts (~30s)
2. Claude scoring — pure text analysis of pre-fetched data (~30s)
Previously Claude made 56+ sequential tool calls through the LLM loop,
taking 5-10 minutes. Now the full cycle completes in ~1-2 minutes.
Also fixes empty stdout issue by using stream-json output format and
extracting text content from the event stream.
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(e2e): validate LOG_DIR ownership before rm -rf in final_cleanup
Adds _E2E_CREATED_LOG_DIR tracking to ensure cleanup only removes
directories created by this script instance, not attacker-controlled paths.
Fixes#3181
Agent: security-auditor
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix(e2e): restore SAFE_TMP_ROOT prefix validation alongside ownership check
Defense-in-depth: keep both the path prefix check (SAFE_TMP_ROOT/spawn-e2e.*)
and the ownership check (_E2E_CREATED_LOG_DIR) as two independent layers.
Agent: pr-maintainer
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
---------
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
The trigger-server already uses 8080 as the standard port for HTTP
services in this repo. Aligns SPA with that convention.
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: complete VM recovery rewrite for spawn fix command
Fixes#3173
Rewrites spawn fix to use CloudRunner interface for full VM recovery
instead of a flat bash script piped over SSH. Now runs the same
install(), configure(), preLaunch() functions as initial provisioning.
- Added generic SSH CloudRunner (ssh-runner.ts) reusable by other commands
- Exported injectEnvVarsToRunner() from orchestrate.ts for shared use
- Fixed command injection vulnerability via validateIdentifier(binaryName)
- Updated dependency injection: runScript → makeRunner (CloudRunner)
- Updated tests to use CloudRunner-based DI pattern
Agent: code-health
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(ssh-runner): add coverage for validation paths
Tests cover the early-exit branches in makeSshRunner methods
(runServer invalid command, uploadFile/downloadFile path traversal)
that throw before any subprocess is spawned.
Agent: team-lead
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>