fix(export): redact secrets in-place instead of aborting (#3383)

* fix(export): redact secrets in-place instead of aborting

Before: any staged file matching the secret regex caused the export
to fail with `{"ok":false,"error":"Possible secrets detected..."}`,
forcing the user to SSH in and clean things up by hand.

After: matched strings are replaced with `***REDACTED-BY-SPAWN-EXPORT***`
via sed -i -E, the file is re-staged, and the export proceeds. The list
of redacted files is included in the success result and surfaced as a
warning on the host CLI:

  ✓ Exported to https://github.com/alice/my-vm
  ⚠ Redacted potential secrets in 1 file:
    - project/test/brain-sync.test.ts

The regex is unchanged. The redact placeholder is intentionally loud so
a casual reader of the published repo can tell that something was
scrubbed and isn't just blank.

Bumps CLI 1.0.33 -> 1.0.34.

* fix(export): gate redaction behind a host-side confirmation prompt

Previously the VM would silently redact any staged files matching the
secret regex and push the repo — meaning a regex miss (#3381 tracks
broadening) would publish a real secret without the user ever seeing
the file list. That's a fail-open posture on a tool that can push to
public GitHub.

New flow:

- buildExportScript takes allowRedact: boolean.
- First pass (allowRedact=false): VM stages, runs the secret scan,
  and on hits writes a needs_confirmation result (hits=[...]) and
  exits 0 before any commit or push. No hits → commit + push as
  before.
- Host reads the result. If needs_confirmation: print the file list,
  explain that the regex has known gaps, and ask "Redact these N files
  and continue pushing?" (initialValue false). Decline → exit 0, no
  push. Approve → re-run the script with allowRedact=true, which now
  actually does the sed + re-stage + commit + push.

Other changes:
- ResultSchema gains the needs_confirmation variant.
- cmdExport factors the runServer + downloadFile + parse cycle into
  runPassAndParseResult so the two-pass orchestration is readable.
- Tests: 4 new cases cover the gate scripting (ALLOW_REDACT=0 writes
  needs_confirmation and exits 0, ALLOW_REDACT=1 redacts) and the
  end-to-end host flow (approve → two passes with ALLOW_REDACT 0→1;
  decline → one pass, exit 0; no-secrets happy path → one pass, no
  confirm). 38/38 export tests, 2176/0 fail overall.
- CLI 1.0.34 → 1.0.35.

---------

Co-authored-by: Claude <claude@anthropic.com>
This commit is contained in:
Ahmed Abushagur 2026-05-02 00:30:28 -07:00 committed by GitHub
parent 46bbeea751
commit 3152a1911f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 298 additions and 30 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "1.0.33",
"version": "1.0.35",
"type": "module",
"bin": {
"spawn": "cli.js"

View file

@ -1,7 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
import { writeFileSync } from "node:fs";
import { mockClackPrompts } from "./test-helpers";
mockClackPrompts();
const clackMocks = mockClackPrompts();
import type { SpawnRecord } from "../history";
@ -154,6 +155,10 @@ describe("buildExportScript", () => {
steps: "github,auto-update,security-scan",
visibility: "private" as const,
resultPath: "/tmp/spawn-export-result.json",
// Second-pass behaviour. Flipping this to true is what enables the
// sed-based redact + commit + push. Flipping to false exercises the
// pre-commit gate that pauses for host confirmation.
allowRedact: true,
};
it("uses set -eo pipefail", () => {
@ -185,7 +190,7 @@ describe("buildExportScript", () => {
expect(s).toContain('"error":"gh is not authenticated');
});
it("scans staged files for known API-key patterns and aborts on hit", () => {
it("scans staged files for known API-key patterns", () => {
const s = buildExportScript(opts);
expect(s).toContain("SECRET_REGEX=");
// Verify a representative pattern from each provider family is present
@ -197,7 +202,39 @@ describe("buildExportScript", () => {
expect(s).toContain("hcloud_"); // Hetzner
expect(s).toContain("dop_v1_"); // DigitalOcean
expect(s).toContain("BEGIN ([A-Z]+ )?PRIVATE KEY"); // PEM
expect(s).toContain("Possible secrets detected");
});
it("redacts matched secrets in-place when ALLOW_REDACT=1 (second pass)", () => {
const s = buildExportScript(opts); // opts.allowRedact = true
expect(s).toContain("ALLOW_REDACT=1");
// Redact placeholder is defined and used as the sed replacement.
expect(s).toContain("REDACT_PLACEHOLDER='***REDACTED-BY-SPAWN-EXPORT***'");
expect(s).toContain("sed -i -E");
// The script re-stages after redacting so the redacted blobs replace
// the originals.
expect(s).toMatch(/sed -i -E[\s\S]*git add -A/);
// The legacy abort path is gone — no false "ok":false on secret hits.
expect(s).not.toContain("Possible secrets detected in staged files; aborting");
});
it("pauses before commit with needs_confirmation when ALLOW_REDACT=0 (first pass)", () => {
const s = buildExportScript({
...opts,
allowRedact: false,
});
expect(s).toContain("ALLOW_REDACT=0");
// The gate path emits a structured result the host can parse.
expect(s).toContain('"needsConfirmation":true,"hits":%s');
// Exit 0, not 1 — a gate is not a failure.
expect(s).toMatch(/needsConfirmation":true[\s\S]*exit 0/);
// The redact path is conditional on ALLOW_REDACT=1.
expect(s).toContain('if [ "$ALLOW_REDACT" != "1" ]; then');
});
it("includes the redacted file list in the success result", () => {
const s = buildExportScript(opts);
expect(s).toContain('REDACTED_JSON="[]"');
expect(s).toContain('"redacted":%s');
});
it("uses gh repo create with the cloud and slug from the script", () => {
@ -367,4 +404,125 @@ describe("cmdExport", () => {
).rejects.toThrow("__exit__");
expect(exitSpy).toHaveBeenCalledWith(1);
});
// ── Gate flow ─────────────────────────────────────────────────────────────
//
// When the first pass returns needs_confirmation, the host prompts the user.
// Approve → re-run with ALLOW_REDACT=1 → success. Decline → exit 0, no push.
function makeSequencedRunner(resultsJson: string[]) {
const calls: {
allowRedact: string;
}[] = [];
let callIndex = 0;
const runner = {
runServer: async (script: string) => {
const m = script.match(/\nALLOW_REDACT=([01])\n/);
calls.push({
allowRedact: m ? m[1] : "?",
});
},
uploadFile: async () => {},
downloadFile: async (_remote: string, local: string) => {
const idx = Math.min(callIndex, resultsJson.length - 1);
callIndex += 1;
writeFileSync(local, resultsJson[idx]);
},
};
return {
runner,
calls,
};
}
it("prompts and re-runs with ALLOW_REDACT=1 when the user approves redaction", async () => {
const { runner, calls } = makeSequencedRunner([
JSON.stringify({
ok: false,
needsConfirmation: true,
hits: [
"project/test/brain-sync.test.ts",
],
}),
JSON.stringify({
ok: true,
slug: "alice/my-vm",
url: "https://github.com/alice/my-vm",
redacted: [
"project/test/brain-sync.test.ts",
],
}),
]);
// Default confirm returns true → user approves the gate.
clackMocks.confirm.mockImplementation(async () => true);
await cmdExport(undefined, {
records: [
baseRecord,
],
visibility: "private",
makeRunner: () => runner,
});
expect(calls).toHaveLength(2);
expect(calls[0].allowRedact).toBe("0");
expect(calls[1].allowRedact).toBe("1");
expect(exitSpy).not.toHaveBeenCalled();
});
it("cancels the export cleanly when the user declines redaction", async () => {
const { runner, calls } = makeSequencedRunner([
JSON.stringify({
ok: false,
needsConfirmation: true,
hits: [
"project/leaky.ts",
],
}),
]);
// User declines at the gate.
clackMocks.confirm.mockImplementation(async () => false);
await expect(
cmdExport(undefined, {
records: [
baseRecord,
],
visibility: "private",
makeRunner: () => runner,
}),
).rejects.toThrow("__exit__");
// Exactly one pass happened (ALLOW_REDACT=0) — nothing got pushed.
expect(calls).toHaveLength(1);
expect(calls[0].allowRedact).toBe("0");
// exit(0) — cancellation is not a failure.
expect(exitSpy).toHaveBeenCalledWith(0);
});
it("runs once and succeeds when the first pass finds no secrets", async () => {
const { runner, calls } = makeSequencedRunner([
JSON.stringify({
ok: true,
slug: "alice/clean-repo",
url: "https://github.com/alice/clean-repo",
}),
]);
// confirm shouldn't fire at all on the happy path.
clackMocks.confirm.mockImplementation(async () => {
throw new Error("confirm should not be called when no secrets are found");
});
await cmdExport(undefined, {
records: [
baseRecord,
],
visibility: "private",
makeRunner: () => runner,
});
expect(calls).toHaveLength(1);
expect(calls[0].allowRedact).toBe("0");
expect(exitSpy).not.toHaveBeenCalled();
});
});

View file

@ -13,7 +13,17 @@
// comes from `gh api user`.
// - Before the commit, every staged file is scanned for known API-key
// shapes (Anthropic, OpenRouter, OpenAI, GitHub, AWS, PEM, Hetzner,
// DigitalOcean). Hits abort the export.
// DigitalOcean). When hits are found, the VM pauses before commit
// and writes a `needs_confirmation` result. The host lists the files
// and asks the user whether to redact and push. Only on approval
// does a second pass run with ALLOW_REDACT=1, which replaces the
// matches with a loud placeholder and finalizes the export.
//
// The gate exists because redaction depends on a regex with known
// gaps (#3381): auto-redacting and pushing means a regex miss gets
// published without the user ever seeing the file list. The prompt
// moves the decision back to the human before the `gh repo create
// --push` happens.
import type { SpawnRecord } from "../history.js";
@ -64,12 +74,23 @@ export function resolveSteps(record: SpawnRecord): string {
return parseStepsFromLaunchCmd(record.connection?.launch_cmd) ?? DEFAULT_STEPS;
}
/** Result the on-VM script writes to REMOTE_RESULT_PATH. */
/** Result the on-VM script writes to REMOTE_RESULT_PATH.
* Three shapes:
* - success: ok=true with the repo URL (and optionally the redacted list).
* - needs_confirmation: ok=false with hits=[...]. The host prompts, and
* on approval re-runs the script with ALLOW_REDACT=1.
* - error: ok=false with a human-readable error string. */
const ResultSchema = v.union([
v.object({
ok: v.literal(true),
slug: v.string(),
url: v.string(),
redacted: v.optional(v.array(v.string())),
}),
v.object({
ok: v.literal(false),
needsConfirmation: v.literal(true),
hits: v.array(v.string()),
}),
v.object({
ok: v.literal(false),
@ -194,8 +215,13 @@ export function buildExportScript(opts: {
steps: string;
visibility: "private" | "public";
resultPath: string;
/** First pass = false the script stops before commit when hits are
* found and writes a needs_confirmation result. Second pass = true
* the script redacts in-place and pushes. */
allowRedact: boolean;
}): string {
const visibilityFlag = opts.visibility === "public" ? "--public" : "--private";
const allowRedact = opts.allowRedact ? "1" : "0";
return [
"#!/bin/bash",
"set -eo pipefail",
@ -204,6 +230,7 @@ export function buildExportScript(opts: {
`CLOUD=${shSingleQuote(opts.cloud)}`,
`STEPS=${shSingleQuote(opts.steps)}`,
`VISIBILITY_FLAG=${visibilityFlag}`,
`ALLOW_REDACT=${allowRedact}`,
"",
'EXPORT_DIR="$(mktemp -d)"',
'trap "rm -rf \\"$EXPORT_DIR\\"" EXIT',
@ -293,14 +320,36 @@ export function buildExportScript(opts: {
"git init -q -b main",
"git add -A",
"",
"# 9. SECRETS SCAN — abort if any staged file matches known API-key shapes.",
"# 9. SECRETS SCAN — first pass just detects and stops if hits exist so the",
"# host can confirm before pushing. Second pass (ALLOW_REDACT=1) redacts",
"# in-place and continues to commit/push.",
"SECRET_REGEX='(sk-or-v1-[a-f0-9]{20,})|(sk-ant-api[0-9-]+_[A-Za-z0-9_-]{20,})|(sk-proj-[A-Za-z0-9_-]{20,})|(gh[ops]_[A-Za-z0-9]{36})|(AKIA[0-9A-Z]{16})|(hcloud_[a-zA-Z0-9_-]{20,})|(dop_v1_[a-f0-9]{32,})|(-----BEGIN ([A-Z]+ )?PRIVATE KEY-----)'",
"REDACT_PLACEHOLDER='***REDACTED-BY-SPAWN-EXPORT***'",
'SECRET_HITS="$(git ls-files -z | xargs -0 grep -lEa "$SECRET_REGEX" 2>/dev/null || true)"',
'REDACTED_JSON="[]"',
'if [ -n "$SECRET_HITS" ]; then',
' printf \'%s\\n\' \'{"ok":false,"error":"Possible secrets detected in staged files; aborting export. SSH in and inspect the files listed below."}\' > "$RESULT_PATH"',
' echo "✗ Possible secrets detected in:" >&2',
' HITS_JSON="$(_PATHS_RAW="$SECRET_HITS" bun -e "',
" const raw = process.env._PATHS_RAW || '';",
" const arr = raw.split('\\n').map(s => s.trim()).filter(Boolean);",
" process.stdout.write(JSON.stringify(arr));",
' ")"',
' if [ "$ALLOW_REDACT" != "1" ]; then',
" # First pass: stop before commit; host will prompt the user.",
' echo "⚠ Potential secrets detected in:" >&2',
' printf "%s\\n" "$SECRET_HITS" >&2',
' printf \'{"ok":false,"needsConfirmation":true,"hits":%s}\\n\' "$HITS_JSON" > "$RESULT_PATH"',
" exit 0",
" fi",
" # Second pass: redact in-place and continue.",
' echo "⚠ Redacting potential secrets in:" >&2',
' printf "%s\\n" "$SECRET_HITS" >&2',
" exit 1",
" while IFS= read -r f; do",
' [ -z "$f" ] && continue',
' sed -i -E "s|${SECRET_REGEX}|${REDACT_PLACEHOLDER}|g" "$f"',
' done <<< "$SECRET_HITS"',
" # Re-stage so the redacted blobs replace the originals in the index.",
" git add -A",
' REDACTED_JSON="$HITS_JSON"',
"fi",
"",
"# 10. Commit and push.",
@ -308,8 +357,8 @@ export function buildExportScript(opts: {
"",
'gh repo create "$SLUG" "$VISIBILITY_FLAG" --source=. --push --description="Exported with spawn"',
"",
"# 11. Emit the success result.",
'printf \'{"ok":true,"slug":"%s","url":"https://github.com/%s"}\\n\' "$SLUG" "$SLUG" > "$RESULT_PATH"',
"# 11. Emit the success result (with the list of redacted files, if any).",
'printf \'{"ok":true,"slug":"%s","url":"https://github.com/%s","redacted":%s}\\n\' "$SLUG" "$SLUG" "$REDACTED_JSON" > "$RESULT_PATH"',
"",
].join("\n");
}
@ -446,7 +495,12 @@ export async function cmdExport(target: string | undefined, options?: ExportOpti
visibility = makePublic === true ? "public" : "private";
}
const steps = resolveSteps(r);
const script = buildExportScript({
// Pick a runner: tests inject one; sprite uses sprite's exec channel; everything
// else goes over SSH using the connection's ip/user.
const runner = options?.makeRunner ? options.makeRunner(conn.ip, conn.user, []) : await buildRunnerForRecord(r);
const scriptOpts = {
spawnMd: buildSpawnMd(r),
readmeTemplate: buildReadmeTemplate(),
gitignore: buildGitignore(),
@ -454,14 +508,80 @@ export async function cmdExport(target: string | undefined, options?: ExportOpti
steps,
visibility,
resultPath: REMOTE_RESULT_PATH,
});
};
// Pick a runner: tests inject one; sprite uses sprite's exec channel; everything
// else goes over SSH using the connection's ip/user.
const runner = options?.makeRunner ? options.makeRunner(conn.ip, conn.user, []) : await buildRunnerForRecord(r);
// Run the export script. 10-min timeout — large repos take time to push.
// First pass: never redact. If hits are found, the script writes a
// needs_confirmation result and exits. If not, it pushes.
p.log.step("Running export on the VM (claude is naming the repo)...");
let parsed = await runPassAndParseResult(
runner,
buildExportScript({
...scriptOpts,
allowRedact: false,
}),
);
// Gate: if the VM reported needs_confirmation, show the file list and
// prompt the user. On approval, re-run with ALLOW_REDACT=1.
if (!parsed.ok && "needsConfirmation" in parsed && parsed.needsConfirmation === true) {
console.log();
p.log.warn(`Potential secrets detected in ${parsed.hits.length} file${parsed.hits.length === 1 ? "" : "s"}:`);
for (const f of parsed.hits) {
console.log(pc.dim(` - ${f}`));
}
console.log();
p.log.info(
"Matches will be replaced with '***REDACTED-BY-SPAWN-EXPORT***' before the repo is pushed. The regex has known gaps — review the list above and cancel if anything looks like a real secret you'd rather scrub by hand.",
);
const approved = await p.confirm({
message: `Redact ${parsed.hits.length === 1 ? "this file" : "these files"} and continue pushing to GitHub?`,
initialValue: false,
});
if (p.isCancel(approved) || approved !== true) {
p.log.info("Export cancelled. Nothing was pushed.");
process.exit(0);
}
p.log.step("Re-running export with redaction enabled...");
parsed = await runPassAndParseResult(
runner,
buildExportScript({
...scriptOpts,
allowRedact: true,
}),
);
}
if (!parsed.ok) {
// Any remaining non-ok shape is a hard error.
p.log.error("error" in parsed ? parsed.error : "Export ran but produced no parseable result.");
process.exit(1);
}
console.log();
p.log.success(`Exported to ${pc.cyan(parsed.url)}`);
if (parsed.redacted && parsed.redacted.length > 0) {
p.log.warn(
`Redacted potential secrets in ${parsed.redacted.length} file${parsed.redacted.length === 1 ? "" : "s"}:`,
);
for (const f of parsed.redacted) {
console.log(pc.dim(` - ${f}`));
}
}
console.log();
console.log(pc.dim("Re-spawn with:"));
console.log(` ${pc.cyan(`spawn ${CLAUDE_AGENT} ${r.cloud} --repo ${parsed.slug} --steps ${steps}`)}`);
console.log();
}
/** Run the export script on the VM, download the result file, parse it, and
* return the validated shape. Exits the process on any infrastructure-level
* failure (ssh, download, unparseable JSON) the caller only has to handle
* the three valid result shapes. */
async function runPassAndParseResult(
runner: ExportRunner,
script: string,
): Promise<v.InferOutput<typeof ResultSchema>> {
// 10-min timeout — large repos take time to push.
const runResult = await asyncTryCatch(() => runner.runServer(script, 600));
if (!runResult.ok) {
p.log.error(`Export failed: ${getErrorMessage(runResult.error)}`);
@ -469,7 +589,6 @@ export async function cmdExport(target: string | undefined, options?: ExportOpti
process.exit(1);
}
// Download result file
const localTmp = mkdtempSync(join(tmpdir(), "spawn-export-"));
const localResult = join(localTmp, "result.json");
const dlResult = await asyncTryCatch(() => runner.downloadFile(REMOTE_RESULT_PATH, localResult));
@ -491,14 +610,5 @@ export async function cmdExport(target: string | undefined, options?: ExportOpti
p.log.error("Export ran but produced no parseable result.");
process.exit(1);
}
if (!parsed.ok) {
p.log.error(parsed.error);
process.exit(1);
}
console.log();
p.log.success(`Exported to ${pc.cyan(parsed.url)}`);
console.log();
console.log(pc.dim("Re-spawn with:"));
console.log(` ${pc.cyan(`spawn ${CLAUDE_AGENT} ${r.cloud} --repo ${parsed.slug} --steps ${steps}`)}`);
console.log();
return parsed;
}