mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-06 08:54:04 +01:00
v1.94.5.0 fix(autoplan): keep secret files and keys out of the lean reviewer prompt (GST-1164, #3914) (#3921)
* test(autoplan): failing-first: secret files and keys reach the lean reviewer's draft (GST-1164, #3914) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * 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> * fix(autoplan): list renames as delete and add so a renamed secret file stays out of the draft (GST-1164) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(autoplan): isolate the lean draft tests from global git config Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): keep compact renames and leave out both sides of a secret file renamed or copied on the branch (GST-1164) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(autoplan): say which renames and copies the lean draft name rule catches (GST-1164) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
5f705f367a
commit
ed5c948eb6
7 files changed
+129
-16
No files matched your search
@@ -1,5 +1,17 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [1.94.5.0] - 2026-11-03
|
||||||
|
|
||||||
|
**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. A secret-named file that git sees renamed or copied on the branch stays out under both names, even when the new name looks ordinary. A secret moved without `git add`, copied unchanged, or renamed and heavily edited still reaches the draft under its new name, guarded only by the content check. 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, and so do an `.envrc` renamed to `.envrc.local` and a `.pgpass` renamed to `pg.txt`; a fake key in an untracked file and in a working-tree edit to a tracked file comes out redacted. All four fail on main at b42ad1e.
|
||||||
|
|
||||||
|
Thanks to the /ship pre-landing review of #3901, which filed the finding.
|
||||||
|
|
||||||
## [1.94.4.0] - 2026-11-03
|
## [1.94.4.0] - 2026-11-03
|
||||||
|
|
||||||
- The captain skill, the CREW template, the lane brief, docs/captain.md and the Capy gstack skill now follow R65 instead of telling a lane that every fix needs a one-round, eng-only plan. A bug fix is exempt from plan review (`gstack-autoplan next --kind fix`; the brief's reason is `bug fix (R65)`), and its PR carries a repro on current main or a failing test written first. A feature gets the lean run (`--kind feature`): one reviewer reading the plan and the draft code together. The S1 and audited-problem-list exemptions are unchanged.
|
- The captain skill, the CREW template, the lane brief, docs/captain.md and the Capy gstack skill now follow R65 instead of telling a lane that every fix needs a one-round, eng-only plan. A bug fix is exempt from plan review (`gstack-autoplan next --kind fix`; the brief's reason is `bug fix (R65)`), and its PR carries a repro on current main or a failing test written first. A feature gets the lean run (`--kind feature`): one reviewer reading the plan and the draft code together. The S1 and audited-problem-list exemptions are unchanged.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# gstack digest v1.94.4.0 — regenerate/re-copy after upgrading gstack
|
# gstack digest v1.94.5.0 — regenerate/re-copy after upgrading gstack
|
||||||
|
|
||||||
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
||||||
for agent hosts without a full skill install. The full skills add workflows,
|
for agent hosts without a full skill install. The full skills add workflows,
|
||||||
|
|||||||
+11
-3
@@ -385,9 +385,17 @@ 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
|
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
|
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
|
command that fails is `DRAFT: none (draft capture failed: …)`, never a
|
||||||
partial draft. An untracked file whose name looks like a secret (`.env*`,
|
partial draft. The draft goes to a model provider, so a file whose name
|
||||||
`*.pem`, `*.key`, `id_rsa`, `credentials*`, `*secret*`) is left out with a
|
looks like a secret (the whole `.env*` family, `.envrc` included, `*.pem`,
|
||||||
note, and the `DRAFT:` line names the checkout it diffed (`root=`). The prompt inlines the diff in a fence longer than any tilde
|
`*.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; so is either side of a rename or
|
||||||
|
copy git detects in the branch's commits or staged changes. A secret moved
|
||||||
|
without `git add`, copied unchanged, or renamed and heavily edited reaches
|
||||||
|
the draft under its new name, guarded only by the content check. 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
|
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
|
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
|
line after INPUT names the draft (`DRAFT: <sha256>` or `DRAFT: none`), or
|
||||||
|
|||||||
+25
-8
@@ -16,6 +16,7 @@ import * as fs from 'node:fs';
|
|||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { OUTSIDE_ROLE, sectionLoadFor, type Phase, type Scope } from './autoplan-prompts';
|
import { OUTSIDE_ROLE, sectionLoadFor, type Phase, type Scope } from './autoplan-prompts';
|
||||||
import { RunError, type RunState } from './autoplan-state';
|
import { RunError, type RunState } from './autoplan-state';
|
||||||
|
import { redactFindingSpans } from './redact-engine';
|
||||||
|
|
||||||
export const KINDS = ['fix', 'feature'] as const;
|
export const KINDS = ['fix', 'feature'] as const;
|
||||||
export type AutoplanKind = typeof KINDS[number];
|
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 LENS: Record<Phase, string> = { ceo: 'CEO', design: 'Design', dx: 'DX', eng: 'Eng' };
|
||||||
const INLINE_DRAFT_BYTES = 100_000;
|
const INLINE_DRAFT_BYTES = 100_000;
|
||||||
const UNTRACKED_MAX = 200;
|
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. */
|
/** 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 {
|
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 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
|
* 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 {
|
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 });
|
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,33 @@ 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)
|
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;
|
? `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 raw = ['diff', '--no-color', '--no-ext-diff', '--no-textconv'];
|
||||||
const tracked = git([...raw, mergeBase, '--'], root);
|
const names = git(['diff', '--name-status', '-z', '-M', '-C', mergeBase, '--'], root);
|
||||||
const names = git(['diff', '--name-only', mergeBase, '--'], root);
|
|
||||||
const others = git(['ls-files', '--others', '--exclude-standard', '-z'], 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 fields = names.stdout.split('\0');
|
||||||
|
const changed: string[] = [];
|
||||||
|
const secretTracked: string[][] = [];
|
||||||
|
for (let i = 0; fields[i];) {
|
||||||
|
const entry = /^[RC]/.test(fields[i++]!) ? [fields[i++]!, fields[i++]!] : [fields[i++]!];
|
||||||
|
changed.push(...entry);
|
||||||
|
if (entry.some(f => SECRET_NAME.test(f))) secretTracked.push(entry);
|
||||||
|
}
|
||||||
|
const tracked = git([...raw, '-M', mergeBase, '--', '.', ...secretTracked.flat().map(f => `:(exclude,literal)${f}`)], root);
|
||||||
|
const broken = failed(tracked, 'git diff');
|
||||||
if (broken) return { base: ref, none: broken };
|
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 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(e => `# draft: ${e.join(' -> ')} left out (${e.length > 1 ? 'a rename or copy of a file whose name looks like a secret' : 'its name looks like a secret'})\n`),
|
||||||
|
];
|
||||||
for (const f of untracked.slice(0, UNTRACKED_MAX)) {
|
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; }
|
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 d = git([...raw, '--no-index', '--', '/dev/null', f], root);
|
||||||
const bad = failed(d, `git diff --no-index ${f}`, [0, 1]);
|
const bad = failed(d, `git diff --no-index ${f}`, [0, 1]);
|
||||||
if (bad) return { base: ref, none: bad };
|
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`);
|
if (untracked.length > UNTRACKED_MAX) parts.push(`# draft: ${untracked.length - UNTRACKED_MAX} more untracked files not shown\n`);
|
||||||
const text = parts.join('');
|
const text = parts.join('');
|
||||||
@@ -94,7 +111,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 });
|
fs.writeFileSync(file, text, process.platform === 'win32' ? { flag: 'wx' } : { flag: 'wx', mode: 0o444 });
|
||||||
const bytes = fs.readFileSync(file);
|
const bytes = fs.readFileSync(file);
|
||||||
return { base: ref, root, merge_base: mergeBase, sha256: crypto.createHash('sha256').update(bytes).digest('hex'), bytes: bytes.length,
|
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. */
|
/** The merged brief (before the shared blocks) and the receipt line (after RESULT FORMAT) a lean phase's native prompt adds. */
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "gstack",
|
"name": "gstack",
|
||||||
"version": "1.94.4",
|
"version": "1.94.5",
|
||||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -21,9 +21,11 @@ const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'ap-lean-'));
|
|||||||
afterAll(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
afterAll(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
||||||
const sha256 = (b: string | Buffer) => crypto.createHash('sha256').update(b).digest('hex');
|
const sha256 = (b: string | Buffer) => crypto.createHash('sha256').update(b).digest('hex');
|
||||||
const OFF_CAPY = { GSTACK_CAPY: '' };
|
const OFF_CAPY = { GSTACK_CAPY: '' };
|
||||||
|
const GIT_ISOLATED = { GIT_CONFIG_GLOBAL: path.join(TMP, 'empty-gitconfig'), GIT_CONFIG_NOSYSTEM: '1' };
|
||||||
|
fs.writeFileSync(GIT_ISOLATED.GIT_CONFIG_GLOBAL, '');
|
||||||
|
|
||||||
function git(cwd: string, ...args: string[]): string {
|
function git(cwd: string, ...args: string[]): string {
|
||||||
const r = spawnSync('git', ['-c', 'user.name=fixture', '-c', 'user.email=fixture@example.com', '-c', 'commit.gpgsign=false', ...args], { cwd, encoding: 'utf8', timeout: 30_000 });
|
const r = spawnSync('git', ['-c', 'user.name=fixture', '-c', 'user.email=fixture@example.com', '-c', 'commit.gpgsign=false', ...args], { cwd, encoding: 'utf8', timeout: 30_000, env: { ...process.env, ...GIT_ISOLATED } });
|
||||||
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
|
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
|
||||||
return r.stdout;
|
return r.stdout;
|
||||||
}
|
}
|
||||||
@@ -323,8 +325,9 @@ describe('which runs are lean', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('draft capture edges', () => {
|
describe('draft capture edges', () => {
|
||||||
|
const isolated = { ...OFF_CAPY, ...GIT_ISOLATED };
|
||||||
const lean = (name: string, cwd: string, out = path.join(TMP, name, 'run'), base = 'main') => {
|
const lean = (name: string, cwd: string, out = path.join(TMP, name, 'run'), base = 'main') => {
|
||||||
const r = run(['next', '--kind', 'feature', '--base', base, '--out', out, '--plan', plan(name), '--no-ui'], cwd);
|
const r = run(['next', '--kind', 'feature', '--base', base, '--out', out, '--plan', plan(name), '--no-ui'], cwd, isolated);
|
||||||
if (r.status !== 0) throw new Error(`${r.stderr}\n${r.stdout}`);
|
if (r.status !== 0) throw new Error(`${r.stderr}\n${r.stdout}`);
|
||||||
return { r, ps: readRun(out).phases.eng, prompt: fs.readFileSync(path.join(out, 'eng-native-prompt.md'), 'utf8') };
|
return { r, ps: readRun(out).phases.eng, prompt: fs.readFileSync(path.join(out, 'eng-native-prompt.md'), 'utf8') };
|
||||||
};
|
};
|
||||||
@@ -388,6 +391,79 @@ describe('draft capture edges', () => {
|
|||||||
expect(diff).toContain('+export const attempts = new Map');
|
expect(diff).toContain('+export const attempts = new Map');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const FAKE_KEY = ['sk', 'ant', 'api03', 'C'.repeat(93) + 'AA'].join('-');
|
||||||
|
|
||||||
|
test('the whole .env family and other secret files are left out before they are read (GST-1164, #3914)', () => {
|
||||||
|
const cwd = repo('envrc', true);
|
||||||
|
const files = ['.envrc', '.env-production', '.env_local', 'config/credentials-prod.json', '.ssh/id_ed25519_sk', '_netrc', '.git-credentials', '.pgpass'];
|
||||||
|
for (const f of files) {
|
||||||
|
fs.mkdirSync(path.dirname(path.join(cwd, f)), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(cwd, f), `export ANTHROPIC_API_KEY=${FAKE_KEY}\n`);
|
||||||
|
}
|
||||||
|
const { ps, prompt } = lean('envrc', cwd);
|
||||||
|
const diff = fs.readFileSync(path.join(ps.snapshot_dir, 'draft.diff'), 'utf8');
|
||||||
|
for (const text of [diff, prompt]) {
|
||||||
|
expect(text).not.toContain(FAKE_KEY.slice(0, 20));
|
||||||
|
for (const f of files) expect(text).toContain(`# draft: untracked ${f} left out (its name looks like a secret)`);
|
||||||
|
}
|
||||||
|
expect(diff).toContain('+export const attempts = new Map');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a secret file committed or edited on the branch is left out of the tracked diff too', () => {
|
||||||
|
const cwd = repo('tracked-envrc', true);
|
||||||
|
fs.writeFileSync(path.join(cwd, '.envrc'), `export ANTHROPIC_API_KEY=${FAKE_KEY}\n`);
|
||||||
|
git(cwd, 'add', '.envrc');
|
||||||
|
git(cwd, 'commit', '-q', '-m', 'direnv');
|
||||||
|
fs.appendFileSync(path.join(cwd, '.envrc'), 'export MORE=1\n');
|
||||||
|
const { ps, prompt } = lean('tracked-envrc', cwd);
|
||||||
|
const diff = fs.readFileSync(path.join(ps.snapshot_dir, 'draft.diff'), 'utf8');
|
||||||
|
for (const text of [diff, prompt]) {
|
||||||
|
expect(text).not.toContain(FAKE_KEY.slice(0, 20));
|
||||||
|
expect(text).not.toContain('MORE=1');
|
||||||
|
expect(text).toContain('# draft: .envrc left out (its name looks like a secret)');
|
||||||
|
}
|
||||||
|
expect(ps.draft.files).toContain('.envrc');
|
||||||
|
expect(diff).toContain('+export const JITTER = 0.1;');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a secret file renamed on the branch stays out under both names', () => {
|
||||||
|
const cwd = repo('rename-envrc', false);
|
||||||
|
git(cwd, 'checkout', '-q', 'main');
|
||||||
|
fs.writeFileSync(path.join(cwd, '.envrc'), 'dotenv_if_exists marker-zz9-old\n');
|
||||||
|
fs.writeFileSync(path.join(cwd, '.pgpass'), 'db.example.com:5432:app:bot:marker-zz9-pg\n');
|
||||||
|
git(cwd, 'add', '.envrc', '.pgpass');
|
||||||
|
git(cwd, 'commit', '-q', '-m', 'direnv');
|
||||||
|
git(cwd, 'checkout', '-q', 'feature');
|
||||||
|
git(cwd, 'merge', '-q', 'main');
|
||||||
|
git(cwd, 'mv', '.envrc', '.envrc.local');
|
||||||
|
fs.appendFileSync(path.join(cwd, '.envrc.local'), 'dotenv_if_exists marker-zz9-new\n');
|
||||||
|
git(cwd, 'mv', '.pgpass', 'pg.txt');
|
||||||
|
git(cwd, 'mv', 'lib/worker.ts', 'lib/retry.ts');
|
||||||
|
const { ps, prompt } = lean('rename-envrc', cwd);
|
||||||
|
const diff = fs.readFileSync(path.join(ps.snapshot_dir, 'draft.diff'), 'utf8');
|
||||||
|
for (const text of [diff, prompt]) {
|
||||||
|
expect(text).not.toContain('marker-zz9');
|
||||||
|
expect(text).toContain('# draft: .envrc -> .envrc.local left out (a rename or copy of a file whose name looks like a secret)');
|
||||||
|
expect(text).toContain('# draft: .pgpass -> pg.txt left out (a rename or copy of a file whose name looks like a secret)');
|
||||||
|
}
|
||||||
|
expect(diff).toContain('rename from lib/worker.ts\nrename to lib/retry.ts');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a key inside an ordinary file is redacted, untracked or edited in the working tree', () => {
|
||||||
|
const cwd = repo('redact', true);
|
||||||
|
fs.writeFileSync(path.join(cwd, 'lib', 'client.ts'), `export const KEY = '${FAKE_KEY}';\n`);
|
||||||
|
fs.appendFileSync(path.join(cwd, 'lib', 'worker.ts'), `const fallback = '${FAKE_KEY}';\n`);
|
||||||
|
const { ps, prompt } = lean('redact', cwd);
|
||||||
|
const diff = fs.readFileSync(path.join(ps.snapshot_dir, 'draft.diff'), 'utf8');
|
||||||
|
for (const text of [diff, prompt]) {
|
||||||
|
expect(text).not.toContain(FAKE_KEY.slice(0, 20));
|
||||||
|
expect(text).toMatch(/^\+export const KEY = '<REDACTED-[^>]+>';$/m);
|
||||||
|
expect(text).toMatch(/^\+const fallback = '<REDACTED-[^>]+>';$/m);
|
||||||
|
}
|
||||||
|
expect(diff).toContain('+export const JITTER = 0.1;');
|
||||||
|
expect(ps.draft.sha256).toBe(sha256(fs.readFileSync(path.join(ps.snapshot_dir, 'draft.diff'))));
|
||||||
|
});
|
||||||
|
|
||||||
test('the DRAFT line and the prompt name the checkout that was diffed', () => {
|
test('the DRAFT line and the prompt name the checkout that was diffed', () => {
|
||||||
const cwd = repo('root', true);
|
const cwd = repo('root', true);
|
||||||
const { r, ps, prompt } = lean('root', cwd);
|
const { r, ps, prompt } = lean('root', cwd);
|
||||||
|
|||||||
Reference in new issue
Block a user