fix(autoplan): keep the .env* family and secret files out of the lean draft, redact the rest (GST-1164, #3914)

The lean reviewer's prompt goes to an outside model provider. captureDraft left
out only some secret-named untracked files (.env and .env.*, not .envrc or
.env-production), applied no name rule to tracked files and did no content
check. Secret-named files are now never diffed, tracked or untracked, and
every other patch goes through redactFindingSpans; a patch it cannot redact is
left out with a note.

Refs #3914

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-11-03 03:44:01 +00:00
1 parent f3f176b9e2
commit e16009e054
3 files changed
+36 -11

No files matched your search

+9
View File
@@ -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 `<REDACTED-…>`. 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.
+8 -3
View File
@@ -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 (<why>)`. 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 `<REDACTED-…>`; 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: <sha256>` or `DRAFT: none`), or
+19 -8
View File
@@ -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<Phase, string> = { 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 <dir>/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<typeof git>, 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. */