Skip to content

Commit 2d519dd

Browse files
Merge pull request #1 from Evan1108-Coder/fix/hardening-pass
Harden safety, executor & CI; dedup shell quoting
2 parents 90263ee + 077dba7 commit 2d519dd

11 files changed

Lines changed: 220 additions & 21 deletions

File tree

.github/workflows/ci.yml

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: ["**"]
6+
pull_request:
7+
branches: ["**"]
8+
9+
# Cancel superseded runs on the same ref to save CI minutes.
10+
concurrency:
11+
group: ci-${{ github.workflow }}-${{ github.ref }}
12+
cancel-in-progress: true
13+
14+
jobs:
15+
build-and-test:
16+
name: Build & test (Node ${{ matrix.node }})
17+
runs-on: ubuntu-latest
18+
strategy:
19+
fail-fast: false
20+
matrix:
21+
# The dev/test toolchain (vitest 4 via rolldown) requires Node 20+
22+
# (it imports `styleText` from node:util, added in Node 20). The published
23+
# CLI bundle still targets Node 18, but CI builds and tests on 20/22.
24+
node: ["20", "22"]
25+
steps:
26+
- name: Checkout
27+
uses: actions/checkout@v4
28+
29+
- name: Set up Node ${{ matrix.node }}
30+
uses: actions/setup-node@v4
31+
with:
32+
node-version: ${{ matrix.node }}
33+
cache: npm
34+
35+
- name: Install dependencies
36+
run: npm ci
37+
38+
- name: Typecheck
39+
run: npm run typecheck
40+
41+
- name: Lint
42+
run: npm run lint
43+
44+
- name: Build
45+
run: npm run build
46+
47+
# The test suite is fully offline (no live AI provider calls), so no API keys are needed.
48+
- name: Test
49+
run: npm test

SECURITY.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,35 @@ Please do not open a public issue for a suspected vulnerability. Email the maint
1616
## Secret Handling
1717

1818
Never commit API keys, Telegram tokens, OAuth credentials, database files, `.env` files, or local user exports. Use `.env.example` for placeholders only.
19+
20+
Secrets are redacted on a best-effort basis (`redactText`/`redactObject` in `src/core/engine.ts`) before commands, history, and logs are persisted. This targets common token shapes and `NAME=value` credential assignments — it is not a guarantee that no secret will ever reach a log file.
21+
22+
## Threat Model
23+
24+
Setupr runs real shell commands on your machine with your full user privileges. The command-safety
25+
layer (`src/agent/safety.ts`) is a **best-effort, defense-in-depth guard, not a sandbox.** It
26+
classifies each planned command and decides whether to allow, confirm, or block it:
27+
28+
- **Block (cannot be bypassed by `--force`)** — clearly destructive or hostile patterns such as
29+
`rm -rf /` (and `~`, `*`, `$HOME` variants), `sudo`, `chmod 777`, `chown -R`, and
30+
`curl … | sh`/`| bash` pipe-to-shell installs.
31+
- **Confirm** — high/medium-risk commands (dependency installs, commands that delete files or reset
32+
git state, commands that embed a secret value or `KEY=value` credential assignment). `--force`
33+
may proceed past a **medium**-risk confirmation using safe defaults, but **never** past a
34+
high-risk or blocked one.
35+
- **Allow** — everything else.
36+
37+
What this layer is **not**:
38+
39+
- **Not a security boundary.** Anything you could run in your shell, a command run through Setupr
40+
can run.
41+
- **The block list is a denylist, and denylists are inherently incomplete.** It catches common,
42+
obvious footguns; an obfuscated or equivalent destructive command can get past it. Treat the
43+
guard as a seatbelt, not a vault door.
44+
- **Trust the project and its AI-generated plan.** When AI planning is enabled, setup steps may be
45+
proposed by a model based on the project's contents. Review the plan — especially before using
46+
`--force` — the way you would review a shell script you downloaded.
47+
48+
Recommendations: run Setupr only against projects you trust, read the pre-execution plan before
49+
confirming, be deliberate with `--force`, and keep real secrets in `.env` files rather than inline
50+
in commands.

src/agent/safety.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ export interface SafetyEvaluation {
1010
forceCanSkipConfirmation: boolean;
1111
}
1212

13-
const SECRET_PATTERN = /(API[_-]?KEY|TOKEN|SECRET|PASSWORD|PRIVATE[_-]?KEY|CREDENTIAL|AUTH)/i;
13+
// Flag only commands that actually embed a secret: an inline `NAME=value` assignment to a
14+
// credential-shaped variable, or a recognizable secret value literal. Matching bare words like
15+
// "auth" or "token" produced false positives on legitimate commands (e.g. `npm i next-auth`).
16+
const SECRET_ASSIGNMENT_PATTERN = /\b(?:API[_-]?KEY|ACCESS[_-]?KEY|AUTH[_-]?TOKEN|TOKEN|SECRET|PASSWORD|PASSWD|PRIVATE[_-]?KEY|CREDENTIALS?)\s*=\s*[^\s'"]{6,}/i;
17+
const SECRET_VALUE_PATTERN = /\b(?:sk-ant-[A-Za-z0-9-]{16,}|sk-[A-Za-z0-9]{16,}|ghp_[A-Za-z0-9]{16,}|gho_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{20,}|AIza[A-Za-z0-9_-]{16,}|xox[baprs]-[A-Za-z0-9-]{10,})\b|-----BEGIN[A-Z ]+PRIVATE KEY-----/;
1418
const SHELL_META_PATTERN = /(;|&&|\|\||`|\$\(|>\s*\/|rm\s+-rf\s+(?:\/|\*|~|\$HOME))/;
1519
const DESTRUCTIVE_PATTERN = /\b(rm|del|rmdir|trash|git\s+reset|git\s+clean|docker\s+system\s+prune)\b/i;
1620
const INSTALL_PATTERN = /\b(npm|pnpm|yarn|bun|pip|poetry|cargo|go)\b.*\b(install|add|get|download|build)\b/i;
@@ -20,9 +24,9 @@ export function evaluateCommandSafety(command: string, options: { force?: boolea
2024
let risk: SafetyRisk = "none";
2125
let decision: SafetyDecision = "allow";
2226

23-
if (SECRET_PATTERN.test(command)) {
27+
if (SECRET_ASSIGNMENT_PATTERN.test(command) || SECRET_VALUE_PATTERN.test(command)) {
2428
risk = maxRisk(risk, "high");
25-
reasons.push("The command text appears to include a secret-looking token or variable name.");
29+
reasons.push("The command appears to embed a secret value or credential assignment in plaintext.");
2630
}
2731

2832
if (SHELL_META_PATTERN.test(command)) {
@@ -51,14 +55,18 @@ export function evaluateCommandSafety(command: string, options: { force?: boolea
5155
}
5256

5357
if (risk === "critical") decision = "block";
54-
else if (risk === "high") decision = options.force ? "confirm" : "confirm";
58+
// High risk always requires confirmation; --force cannot bypass it.
59+
else if (risk === "high") decision = "confirm";
60+
// Medium risk confirms by default, but --force may proceed with safe defaults.
5561
else if (risk === "medium") decision = options.force ? "allow" : "confirm";
5662

5763
return {
5864
decision,
5965
risk,
6066
reasons,
61-
forceCanSkipConfirmation: risk === "low" || risk === "medium",
67+
// --force only skips a confirmation it would otherwise raise (medium risk). Low/none have no
68+
// confirmation to skip; high/critical can never be skipped by force.
69+
forceCanSkipConfirmation: risk === "medium",
6270
};
6371
}
6472

src/commands/plain/product.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { createSetuprError, printPlainError } from "../../errors/index.js";
88
import { scanProject } from "../../scanner/index.js";
99
import { collectContext } from "../../context/collector.js";
1010
import { collectDashboardStatus } from "../../status/collector.js";
11+
import { shellQuote } from "../../util/shell.js";
1112

1213
interface ProductFlags {
1314
args?: string[];
@@ -231,7 +232,3 @@ function parseGitHubRepo(remote: string): string | null {
231232
const https = remote.match(/github\.com\/([^/]+\/[^/.]+)(?:\.git)?$/);
232233
return https?.[1] || null;
233234
}
234-
235-
function shellQuote(value: string): string {
236-
return `'${value.replace(/'/g, "'\\''")}'`;
237-
}

src/core/engine.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,9 @@ export class ProjectEngine {
144144
decision: risk === "high" || risk === "medium" ? "confirm" : "allow",
145145
risk: risk === "none" ? "none" : risk,
146146
reasons,
147-
forceCanSkipConfirmation: risk !== "high",
147+
// Consistent with agent/safety.ts: --force only skips the medium-risk confirmation;
148+
// high risk always confirms.
149+
forceCanSkipConfirmation: risk === "medium",
148150
};
149151
}
150152

src/executor/index.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,24 @@ export interface ExecutionResult {
1717
duration: number;
1818
}
1919

20+
// Per-step wall-clock limits so a hung command (stuck install, prompt waiting on stdin,
21+
// unreachable registry) can never block the setup run forever. Override with the
22+
// SETUPR_STEP_TIMEOUT_MS env var (applies to every command step).
23+
const DEFAULT_STEP_TIMEOUT_MS: Record<SetupStep["type"], number> = {
24+
runtime: 600_000, // installing/switching runtimes can be slow
25+
deps: 600_000, // package installs on large projects
26+
script: 600_000, // build/postinstall scripts
27+
config: 120_000,
28+
env: 120_000,
29+
verify: 120_000,
30+
};
31+
32+
export function stepTimeoutMs(step: SetupStep): number {
33+
const override = Number(process.env.SETUPR_STEP_TIMEOUT_MS);
34+
if (Number.isFinite(override) && override > 0) return override;
35+
return DEFAULT_STEP_TIMEOUT_MS[step.type] ?? 300_000;
36+
}
37+
2038
export async function executeStep(
2139
step: SetupStep,
2240
cwd: string,
@@ -64,14 +82,16 @@ export async function executeStep(
6482

6583
store.getState().addLog({ content: step.command, type: "command" });
6684

85+
const timeoutMs = stepTimeoutMs(step);
86+
6787
try {
6888
const result = await runCommand(step.command, cwd, (line) => {
6989
store.getState().addLog({ content: line, type: "progress" });
7090
store.getState().addMessage({
7191
role: "system",
7292
content: `[${step.label}] ${line}`,
7393
});
74-
});
94+
}, undefined, { timeoutMs });
7595

7696
const duration = Date.now() - start;
7797
const durationStr = duration < 1000 ? `${duration}ms` : `${(duration / 1000).toFixed(1)}s`;
@@ -81,19 +101,28 @@ export async function executeStep(
81101
store.getState().addLog({ content: `✓ ${step.label} — OK (${durationStr})`, type: "success" });
82102
return { success: true, output: result.stdout, duration };
83103
} else {
104+
if (result.timedOut) {
105+
store.getState().addLog({
106+
content: `✗ ${step.label} — timed out after ${Math.round(timeoutMs / 1000)}s and was terminated`,
107+
type: "error",
108+
});
109+
}
110+
const stderr = result.timedOut
111+
? `Command timed out after ${Math.round(timeoutMs / 1000)}s and was terminated.\n${result.stderr}`.trim()
112+
: result.stderr;
84113
const psetupError = classifyCommandFailure({
85114
command: step.command,
86115
cwd,
87116
exitCode: result.exitCode,
88117
stdout: result.stdout,
89-
stderr: result.stderr,
118+
stderr,
90119
stepLabel: step.label,
91120
stepType: step.type,
92121
});
93122
store.getState().updateStep(step.id, { status: "failed", error: psetupError.title });
94123
store.getState().addLog({ content: `✗ ${step.label}${errorSummary(psetupError)}`, type: "error" });
95124
store.getState().addMessage({ role: "system", content: errorSummary(psetupError) });
96-
return { success: false, output: result.stdout, error: result.stderr, psetupError, duration };
125+
return { success: false, output: result.stdout, error: stderr, psetupError, duration };
97126
}
98127
} catch (err) {
99128
const duration = Date.now() - start;

src/security/index.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { runCommand } from "../executor/index.js";
55
import { parseEnvKeys, parseEnvPairs } from "../env/index.js";
66
import { scanProject } from "../scanner/index.js";
77
import { appendHistoryEvent, readProjectJson, writeProjectJson } from "../state/project.js";
8+
import { shellQuote } from "../util/shell.js";
89

910
export type SecuritySeverity = "info" | "low" | "medium" | "high" | "critical";
1011
export type SecurityCategory = "deps" | "secrets" | "env" | "docker" | "ci" | "code" | "routes" | "auth" | "headers" | "config";
@@ -422,7 +423,3 @@ function score(findings: SecurityFinding[]): number {
422423
function redact(text: string): string {
423424
return text.replace(/[A-Za-z0-9_/-]{16,}/g, "****").slice(0, 180);
424425
}
425-
426-
function shellQuote(value: string): string {
427-
return `'${value.replace(/'/g, "'\\''")}'`;
428-
}

src/util/shell.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/**
2+
* POSIX-safe single-quote escaping for interpolating untrusted values into shell commands.
3+
* Wraps the value in single quotes and escapes any embedded single quotes using the
4+
* `'\''` idiom, so the result is safe to splice into a `sh -c` style command line.
5+
*/
6+
export function shellQuote(value: string): string {
7+
return `'${value.replace(/'/g, "'\\''")}'`;
8+
}

src/verification/index.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { basename, dirname, extname, join } from "path";
44
import { runCommand } from "../executor/index.js";
55
import { scanProject, type ScanResult } from "../scanner/index.js";
66
import { appendHistoryEvent, readProjectJson, writeProjectJson } from "../state/project.js";
7+
import { shellQuote } from "../util/shell.js";
78

89
export type VerificationStatus = "pass" | "warn" | "fail" | "skip";
910

@@ -442,10 +443,6 @@ function dedupe(values: string[]): string[] {
442443
return [...new Set(values)];
443444
}
444445

445-
function shellQuote(value: string): string {
446-
return `'${value.replace(/'/g, "'\\''")}'`;
447-
}
448-
449446
function safeName(file: string): string {
450447
return basename(file, extname(file)).replace(/[^A-Za-z0-9_]/g, "_");
451448
}

tests/agent-runtime.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,38 @@ describe("agent runtime", () => {
155155
expect(evaluateCommandSafety("curl https://example.com/install.sh | sh").decision).toBe("block");
156156
});
157157

158+
it("does not flag bare credential words in package names as secrets", () => {
159+
// Previously the secret check matched any occurrence of "auth"/"token"/"secret",
160+
// raising false-positive high-risk confirmations on benign installs.
161+
const next = evaluateCommandSafety("npm install next-auth");
162+
expect(next.risk).toBe("low");
163+
expect(next.reasons.join(" ")).not.toMatch(/secret value/i);
164+
165+
const token = evaluateCommandSafety("npm install passport-token");
166+
expect(token.reasons.join(" ")).not.toMatch(/secret value/i);
167+
});
168+
169+
it("flags inline secret assignments and recognizable secret values", () => {
170+
const assignment = evaluateCommandSafety("API_KEY=sk-livetokenvalue1234 node deploy.js");
171+
expect(assignment.risk).toBe("high");
172+
expect(assignment.decision).toBe("confirm");
173+
174+
const literal = evaluateCommandSafety("echo ghp_abcdefghijklmnop1234567890");
175+
expect(literal.reasons.join(" ")).toMatch(/secret value/i);
176+
});
177+
178+
it("never lets --force skip a high-risk confirmation", () => {
179+
const high = evaluateCommandSafety("git reset --hard HEAD~3", { force: true });
180+
expect(high.risk).toBe("high");
181+
expect(high.decision).toBe("confirm");
182+
expect(high.forceCanSkipConfirmation).toBe(false);
183+
184+
const medium = evaluateCommandSafety("mkdir build && cd build", { force: true });
185+
expect(medium.risk).toBe("medium");
186+
expect(medium.decision).toBe("allow");
187+
expect(medium.forceCanSkipConfirmation).toBe(true);
188+
});
189+
158190
it("saves and restores agent workflow checkpoints", async () => {
159191
await saveAgentWorkflowCheckpoint(tempDir, {
160192
cwd: tempDir,

0 commit comments

Comments
 (0)