diff --git a/changelog.d/gsta258-s1-envrc-redact.md b/changelog.d/gsta258-s1-envrc-redact.md new file mode 100644 index 000000000..46a7d71ee --- /dev/null +++ b/changelog.d/gsta258-s1-envrc-redact.md @@ -0,0 +1,9 @@ +**Security: the lean /autoplan reviewer no longer gets `.envrc`, `.env-production` or a key pasted into an ordinary file (GST-1164, #3914).** Since v1.93.36.0 (#3897), the lean run puts the branch's draft code into a prompt that goes to an outside model provider. It left out only some secret-named untracked files: `.env` and `.env.*`, but not `.envrc` or `.env-production`. It applied no name rule to tracked files and no content check at all. So a direnv `.envrc`, a `.env-production`, a committed or edited secret file, or a key sitting in any source file could reach the provider. + +### Fixed +- **`lib/autoplan-lean.ts` `captureDraft`:** the secret-name rule now covers the whole `.env*` family, `.envrc` included, every `*credentials*` name, `_netrc`, `.pgpass`, `*.jks` and `id_*_sk` keys. Matching files are never diffed, tracked or untracked, and the draft names each one it left out. Every other file's patch goes through gstack's redact engine (`redactFindingSpans`), so a key in an ordinary file reaches the reviewer as ``. A patch the engine can't redact safely, including one over 1 MiB, is left out with a note. + +### Tests +- `test/autoplan-lean.test.ts`: an untracked `.envrc`, `.env-production` and six other secret files stay out of `draft.diff` and the prompt; a committed and edited `.envrc` stays out of the tracked diff; a fake key in an untracked file and in a working-tree edit to a tracked file comes out redacted. All three fail on main at b42ad1e. + +Thanks to the /ship pre-landing review of #3901, which filed the finding. diff --git a/docs/unattended.md b/docs/unattended.md index 08703d6b6..5f46e0517 100644 --- a/docs/unattended.md +++ b/docs/unattended.md @@ -385,9 +385,14 @@ The example above is the full run. The first `next` also takes `--kind`: hashed into `run.json` (`phases.eng.draft`) and the `phase_opened` journal row. With no code yet, the run says so: `DRAFT: none ()`. A git command that fails is `DRAFT: none (draft capture failed: …)`, never a - partial draft. An untracked file whose name looks like a secret (`.env*`, - `*.pem`, `*.key`, `id_rsa`, `credentials*`, `*secret*`) is left out with a - note, and the `DRAFT:` line names the checkout it diffed (`root=`). The prompt inlines the diff in a fence longer than any tilde + partial draft. The draft goes to a model provider, so a file whose name + looks like a secret (the whole `.env*` family, `.envrc` included, `*.pem`, + `*.key`, `*.p12`, `id_rsa` and the other SSH private keys, `.netrc`, + `.npmrc`, `.pgpass`, `*credentials*`, `*secret*`) is never read, tracked or + untracked, and is left out with a note. Every other file's patch goes + through the redact engine (`lib/redact-engine.ts`), so a key in an ordinary + file reaches the reviewer as ``; a patch it can't redact safely + is left out with a note. The `DRAFT:` line names the checkout it diffed (`root=`). The prompt inlines the diff in a fence longer than any tilde run inside it, cut at 100,000 bytes (`shown=` on the `DRAFT:` line) with a note asking the reviewer to name what it didn't see. The reviewer's line after INPUT names the draft (`DRAFT: ` or `DRAFT: none`), or diff --git a/lib/autoplan-lean.ts b/lib/autoplan-lean.ts index 57327b604..d690132f8 100644 --- a/lib/autoplan-lean.ts +++ b/lib/autoplan-lean.ts @@ -16,6 +16,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { OUTSIDE_ROLE, sectionLoadFor, type Phase, type Scope } from './autoplan-prompts'; import { RunError, type RunState } from './autoplan-state'; +import { redactFindingSpans } from './redact-engine'; export const KINDS = ['fix', 'feature'] as const; export type AutoplanKind = typeof KINDS[number]; @@ -33,7 +34,7 @@ export const FIX_LINES = [ const LENS: Record = { ceo: 'CEO', design: 'Design', dx: 'DX', eng: 'Eng' }; const INLINE_DRAFT_BYTES = 100_000; const UNTRACKED_MAX = 200; -const SECRET_NAME = /(^|\/)(\.env(\.[^/]*)?|[^/]*\.(pem|key|p12|pfx|keystore)|id_(rsa|dsa|ecdsa|ed25519)|\.netrc|\.npmrc|\.pypirc|credentials(\.[^/]*)?|[^/]*secret[^/]*)$/i; +const SECRET_NAME = /(^|\/)(\.env[^/]*|[^/]*\.(pem|key|p12|pfx|keystore|jks)|id_(rsa|dsa|ecdsa|ed25519)(_sk)?|[._]netrc|\.npmrc|\.pypirc|\.pgpass|[^/]*credentials[^/]*|[^/]*secret[^/]*)$/i; /** The first next's mode: lean for --kind feature, or for an unattended Capy run that names no kind; --full and --ceo-only keep today's run. */ export function leanMode(o: { kind?: AutoplanKind; full?: boolean; ceoOnly?: boolean; base?: string; sessionKind: string; env: NodeJS.ProcessEnv }): LeanMode | undefined { @@ -56,7 +57,9 @@ export const draftLine = (d: DraftInput) => /** * The draft code against the base: tracked changes since the merge base (committed and not) plus untracked files, * the run directory excluded, written once to /draft.diff (read-only) and hashed. No git, no merge base, no - * change or a git command that failed is an honest none with its reason, never a partial draft. + * change or a git command that failed is an honest none with its reason, never a partial draft. The draft goes to + * a model provider, so a file whose name looks like a secret is never diffed (tracked or untracked), and every + * file's patch passes through the redact engine; one it cannot redact is left out with a note (GST-1164, #3914). */ export function captureDraft(cwd: string, dir: string, out: string, base?: string): DraftInput { const git = (args: string[], at = cwd) => spawnSync('git', ['-c', 'core.quotepath=off', ...args], { cwd: at, encoding: 'utf8', timeout: 60_000, maxBuffer: 256 * 1024 * 1024 }); @@ -73,19 +76,27 @@ export function captureDraft(cwd: string, dir: string, out: string, base?: strin const failed = (r: ReturnType, what: string, ok = [0]) => r.error || r.status === null || !ok.includes(r.status) ? `draft capture failed: ${what} ${r.error ? `errored (${r.error.message})` : `exited ${r.status ?? r.signal}`}${r.stderr?.trim() ? `: ${r.stderr.trim().split('\n')[0]}` : ''}` : undefined; const raw = ['diff', '--no-color', '--no-ext-diff', '--no-textconv']; - const tracked = git([...raw, mergeBase, '--'], root); - const names = git(['diff', '--name-only', mergeBase, '--'], root); + const names = git(['diff', '--name-only', '-z', mergeBase, '--'], root); const others = git(['ls-files', '--others', '--exclude-standard', '-z'], root); - const broken = failed(tracked, 'git diff') ?? failed(names, 'git diff --name-only') ?? failed(others, 'git ls-files'); + const listed = failed(names, 'git diff') ?? failed(others, 'git ls-files'); + if (listed) return { base: ref, none: listed }; + const changed = names.stdout.split('\0').filter(Boolean); + const secretTracked = changed.filter(f => SECRET_NAME.test(f)); + const tracked = git([...raw, mergeBase, '--', '.', ...secretTracked.map(f => `:(exclude,literal)${f}`)], root); + const broken = failed(tracked, 'git diff'); if (broken) return { base: ref, none: broken }; + const redacted = (patch: string, what: string) => redactFindingSpans(patch) ?? `# draft: ${what} left out (it could not be redacted safely)\n`; const untracked = others.stdout.split('\0').filter(f => f && !inRun(f)).sort(); - const parts = [tracked.stdout]; + const parts = [ + ...tracked.stdout.split(/^(?=diff --git )/m).map(p => redacted(p, p.slice(0, p.indexOf('\n')))), + ...secretTracked.map(f => `# draft: ${f} left out (its name looks like a secret)\n`), + ]; for (const f of untracked.slice(0, UNTRACKED_MAX)) { if (SECRET_NAME.test(f)) { parts.push(`# draft: untracked ${f} left out (its name looks like a secret)\n`); continue; } const d = git([...raw, '--no-index', '--', '/dev/null', f], root); const bad = failed(d, `git diff --no-index ${f}`, [0, 1]); if (bad) return { base: ref, none: bad }; - parts.push(d.stdout); + parts.push(redacted(d.stdout, `untracked ${f}`)); } if (untracked.length > UNTRACKED_MAX) parts.push(`# draft: ${untracked.length - UNTRACKED_MAX} more untracked files not shown\n`); const text = parts.join(''); @@ -94,7 +105,7 @@ export function captureDraft(cwd: string, dir: string, out: string, base?: strin fs.writeFileSync(file, text, process.platform === 'win32' ? { flag: 'wx' } : { flag: 'wx', mode: 0o444 }); const bytes = fs.readFileSync(file); return { base: ref, root, merge_base: mergeBase, sha256: crypto.createHash('sha256').update(bytes).digest('hex'), bytes: bytes.length, - ...(bytes.length > INLINE_DRAFT_BYTES ? { shown_bytes: INLINE_DRAFT_BYTES } : {}), files: [...new Set([...names.stdout.split('\n').filter(Boolean), ...untracked])].sort() }; + ...(bytes.length > INLINE_DRAFT_BYTES ? { shown_bytes: INLINE_DRAFT_BYTES } : {}), files: [...new Set([...changed, ...untracked])].sort() }; } /** The merged brief (before the shared blocks) and the receipt line (after RESULT FORMAT) a lean phase's native prompt adds. */