Merge origin/main

This commit is contained in:
garrytan committed 2026-10-19 20:53:27 +00:00
commit da029d8996
12 files changed
+579 -50

No files matched your search

+23 -1
View File
@@ -1,6 +1,6 @@
# Changelog
## [Unreleased]
## [1.91.154.0] - 2026-10-19
**/cso can verify Rails repairs in a read-only checkout, its retained Git history no longer brings back credential files the snapshot leaves out, a repair whose external assertions fail is now reported as `ASSERTION_FAILED` instead of a misleading `INCOMPATIBLE_INPUT`, and a security assertion can accept several safe rejection statuses while still requiring the secret to be absent.** The helper keeps `.env`, `master.key`, keys and agent configuration out of the readable tree, but `history.txt` captured `git log -p` in full, so a committed `.env` came back through its diff whenever the value wasn't credential-shaped enough for the redactor. Paid qualification run 39186328569 caught this when the private corpus's planted `.env` value showed up in the judge's helper state.
@@ -12,6 +12,28 @@
### Changed
- **A security assertion's `expected.status` may list up to eight safe statuses** (for example `[403, 404]`), so a repair that refuses an attack with a different reasonable rejection still passes. A set is accepted only on a security assertion that also carries a `vulnerable` expectation, and only with `excludes` set, so the response must still withhold the vulnerable marker. The fixed and vulnerable oracles must stay provably mutually exclusive: a set that contains the vulnerable status is accepted only when the vulnerable response's `includes` contains the excluded marker. Legitimate controls and `vulnerable` expectations still take exactly one status, so a patch that answers every route with 403 still fails its controls. A no-op patch, a listed rejection that still leaks the marker, and an unlisted status all stay `inconclusive`.
## [1.91.153.0] - 2026-10-19
**An outside review whose findings are untagged prose no longer reads `clean` because a "no issues" phrase appears somewhere in it, and a clean Codex review that says "no actionable regressions" no longer reads `unverified`.** Reported in #3294 by @SchizasNik, with the false-pass half pointed out by @xg-gh-25. `lib/outside-review-result.ts` matched its no-findings vocabulary anywhere in the reply, so "The parser change has no new issues. However, … drops users_email_idx" passed /ship's structured gate. It also lacked `regressions`, so `codex review --uncommitted`'s "I found no actionable regressions in the staged, unstaged, or untracked changes." was missing coverage.
### Fixed
- **A no-findings statement counts only as the review's conclusion.** It must open the reply's last sentence or last ", and" clause (or the `because` clause of a review's `Recommendation:` line) and carry no contrast or exception ("but", "however", "except", "other than", "elsewhere"). When other prose comes first, anything after the statement other than a predicate ("were found", "is evident") or a colon explanation must scope it to the change as a whole, so "No issues in the parser." and "No regressions were found by the existing tests." do not conclude the review. Anything else stays `unverified` / `untagged_review`, the same reason code and troubleshooting anchor as before.
- **`regressions` and `defects` join the vocabulary**, and `NO_FINDINGS` written as inline code still counts.
### Gate integrity
- The structured and review gates' no-findings check (`classifyOutsideReview`) is stricter about position and wider in vocabulary. New free regressions in `test/outside-review-result.test.ts` cover the reporter's exact reply through their CLI invocation, two clean `codex review --uncommitted` replies captured from codex-cli 0.160.0 with gpt-6-sol (both read `unverified` before), fifteen mixed replies that must stay `unverified` under both gates (the two strings from the issue, the phrase before, after and inside untagged findings, exception conclusions, conclusions scoped to one place or to the tests, a quoted `NO_FINDINGS`, a no-issues line inside a code fence), and Recommendation-clause cases including a wrapped clause with "but". A GPT-6.1 Sol outside review probed the classifier; its false passes are among those controls. A known residual, unchanged from before: untagged findings followed by "No issues found in the changed parser." still read `clean`, because "changed" names the change and the parser cannot tell a finding from a summary without tags. All four new tests fail on the previous classifier. Every existing negative control passes unchanged, including the stored Codex clean review that still reads `unverified` ("no actionable correctness issues"). No threshold, trial count or case kind changed.
## [1.91.152.0] - 2026-10-19
**`/cso` now starts and finishes on ordinary repositories that hold a nested Git repository, a long patch history, or a Docker socket on macOS 27.** Three user reports (#3255, #3248) each stopped a run on a normal repo.
### Fixed
- **An untracked nested Git repository or a registered submodule no longer stops `gstack-cso start` with `UNSAFE_PATH`.** Git lists such a directory as one entry (`inner/` for an untracked repo, `sub` for a gitlink). The snapshot now records it as one explicit `excluded: nested Git repository` entry that is never read or copied, and the snapshot coverage lists it under exclusions. The capture pins the boundary's device, inode and mode, so a directory swapped in during capture fails as `SNAPSHOT_RACE`. A symlink to a directory is still refused. Ported from #3256 by Chao Chen (@surlymochan), who keeps authorship of the fix. (#3255, bug 1)
- **A patch history over 1 MiB no longer leaves `history-inputs` not assessed.** The helper reads up to 100 commits of patches, then keeps the newest whole commits that fit the 1 MiB redaction bound. It never cuts inside a commit, so a secret can't be split across the cut and escape redaction. When even the newest patch is too large, it keeps commit metadata only. A truncated `history.txt` starts with a `[gstack-cso history truncated: …]` line, `history-status.json` records `truncated` and the retained commit count, and the history coverage becomes `partial` with that gap. The redaction bound is unchanged. (#3255, bug 2)
### For contributors
- **`test/cso-ordinary-repos.test.ts`** covers each report end to end through the compiled launcher and `capture()`. Its negative controls show that a symlinked directory is still refused, that a nested repository swapped during capture fails as a race (this check fails if the device and inode comparison is removed), that retained and omitted history never shows a credential canary, and that Docker endpoint admission still refuses non-sockets, dangling and looping links, `..` paths and TCP. The #3248 socket fallback (`realpathSocket`) shipped in 1.91.96.0. The new endpoint-level case fails when admission calls plain `realpathSync`.
## [1.91.151.0] - 2026-10-19
**Three contributor fixes: a saved "never ask" now sticks for /plan-eng-review's structure question, /context-save and /context-restore close three checkpoint gaps, and a new how-to covers the first production deploy.** Ported from fork PRs so they can run the paid gate; each commit keeps its author.
+1 -1
View File
@@ -1 +1 @@
1.91.151.0
1.91.153.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.151.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.153.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows,
+6 -6
View File
@@ -5,16 +5,16 @@
"": {
"name": "gstack",
"dependencies": {
"@huggingface/transformers": "^4.3.0",
"@huggingface/transformers": "^4.3.1",
"@ngrok/ngrok": "^1.7.0",
"cross-spawn": "^7.0.6",
"diff": "^9.0.0",
"html-to-docx": "1.8.0",
"html-to-vdom": "0.7.0",
"marked": "^18.0.11",
"marked": "^18.1.1",
"playwright": "^1.62.1",
"sharp": "^0.35.4",
"socks": "^2.8.9",
"socks": "^2.8.10",
},
"devDependencies": {
"@anthropic-ai/claude-agent-sdk": "0.2.141",
@@ -71,7 +71,7 @@
"@huggingface/tokenizers": ["@huggingface/tokenizers@0.2.0", "", {}, "sha512-LidMHe1FpcSYH4vcSjXooda34pC0M8m1gmDOv7SQi6Iv+ib1zX7J3sHBCC9/3FPCkn2k1b55FneVqMRxrr99pg=="],
"@huggingface/transformers": ["@huggingface/transformers@4.3.0", "", { "dependencies": { "@huggingface/jinja": "^0.5.10", "@huggingface/tokenizers": "^0.2.0", "onnxruntime-node": "1.30.0", "onnxruntime-web": "1.31.0-dev.20260914-8d85527a0", "sharp": "^0.35.4" } }, "sha512-fL1A/WUZwouPrOlYxU5dzIwD2T5J781JiB2jDR8bFe5DwCj0Gfudq+NEXCMno49kQgajHA7xQkrRLJlqG1veEA=="],
"@huggingface/transformers": ["@huggingface/transformers@4.3.1", "", { "dependencies": { "@huggingface/jinja": "^0.5.10", "@huggingface/tokenizers": "^0.2.0", "onnxruntime-node": "1.30.0", "onnxruntime-web": "1.31.0-dev.20260914-8d85527a0", "sharp": "^0.35.4" } }, "sha512-5+voSeUCFK6PCKlY15dPhNOKq3/zOTxDN7oC+Wy9dFb1QZ0TSQomZRRy7URRK8YRuUoE9fGMQTTPC5d/pWBJhA=="],
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
@@ -413,7 +413,7 @@
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
"marked": ["marked@18.0.11", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw=="],
"marked": ["marked@18.1.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-zEGgrtUG8crk+TrMacJSVABU4DYDDkS8uqgU3ohrn/2fW9C2AEfUrO3m6IhpIpQpl7YcVpiXXC9cSMWDANT3dg=="],
"matcher": ["matcher@4.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ=="],
@@ -529,7 +529,7 @@
"smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="],
"socks": ["socks@2.8.9", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw=="],
"socks": ["socks@2.8.10", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
+11 -3
View File
@@ -172,9 +172,17 @@ the readiness dashboard and the PR body, and never count it as a pass.
review, including the `execution` gate behind `/codex` consult and challenge.
A reply whose last `Verdict:` line says `not ready` but carries no readable
severity prints `FINDINGS: unspecified` and exits 3 like a blocking finding.
- **No findings.** A review with no tag and no label is `clean` only when it
says so explicitly (`NO_FINDINGS`, "no issues", "did not find any bugs").
Otherwise it is `unverified` (see below), never `clean`. A `/codex` consult
- **No findings.** A review with no tag and no label is `clean` only when its
conclusion says so: the last sentence (or its last ", and" clause), or the
`because` clause of its `Recommendation:` line, opens with `NO_FINDINGS`,
"No issues found", "I found no actionable regressions" or "I did not find
any bugs", with no "but", "however", "except", "other than" or "elsewhere".
When other prose comes first, anything after that statement except a
predicate ("were found", "is evident") or a colon explanation must scope it
to the change as a whole ("in this change", "in the staged changes"); "No
issues in the parser." does not conclude the review. A "no issues" phrase
anywhere else in the reply does not count. Otherwise the review is
`unverified` (see below), never `clean`. A `/codex` consult
answer with no severity labels at all is not a review summary and stays
`clean`.
- **Design proposals** (the design-direction voices in /design-consultation
+9 -6
View File
@@ -545,13 +545,16 @@ function historyCoverage(status: any, scope: string, auditPaths?: string[]): Cov
? {
domain: 'history-inputs',
scope,
status: auditPaths ? 'partial' : 'assessed',
status: auditPaths || status.truncated ? 'partial' : 'assessed',
method: 'bounded helper-retained Git history',
gaps: auditPaths
? [
`History outside the --paths audit scope (${auditPaths.join(', ')}) was not retained or assessed`,
]
: [],
gaps: [
...(auditPaths
? [
`History outside the --paths audit scope (${auditPaths.join(', ')}) was not retained or assessed`,
]
: []),
...(typeof status.truncated === 'string' ? [`History truncated: ${status.truncated}`] : []),
],
exclusions: [],
evidence: [
`Captured ${String(status.commits ?? 'bounded commits')} from ${String(status.range ?? 'the pinned source range')}`,
+109 -23
View File
@@ -69,6 +69,14 @@ export function sourceCapMessage(bytes: number, files: number): string {
}
/** Transformation recorded for a source path outside the start --paths prefixes; its payload is never copied or assessed. */
export const OUTSIDE_AUDIT_PATHS = 'withheld: outside the --paths audit scope';
const HISTORY_MARKER_RESERVE = 1024;
/** First line of a history.txt that holds fewer commits or less detail than Git returned. */
export function historyMarker(note: string): string {
return `[gstack-cso history truncated: ${note}]\n`;
}
/** Transformation recorded for a repository boundary Git listed as one entry: an untracked nested repository or a registered submodule gitlink. */
export const NESTED_REPOSITORY =
'excluded: nested Git repository; Git listed it as a single boundary entry and never descends into it, so its own source and history are not audited with this repository';
export function inAuditPaths(path: string, prefixes: readonly string[]): boolean {
return prefixes.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
}
@@ -518,7 +526,12 @@ async function paths(
for (const value of data.split('\0')) {
admission.time();
if (!value) continue;
seen.add(relativePath(value));
// `ls-files --others` reports a repository boundary as one entry with a
// trailing slash: an untracked nested repository that Git refuses to
// descend into. The path itself is the boundary, exactly like a
// registered submodule's gitlink, so record it under its own name
// instead of rejecting the whole run.
seen.add(relativePath(value.replace(/\/$/, '')));
admission.count(seen.size);
}
const result = [...seen].sort();
@@ -644,6 +657,7 @@ export async function capture(
gitHashes = new Map<string, string>(),
gitModes = new Map<string, string>(),
sensitiveEvidence: any[] = [],
boundaries = new Map<string, fs.Stats>(),
absentPaths = new Set<string>();
let total = 0;
guard();
@@ -691,15 +705,35 @@ export async function capture(
};
for (const path of list) {
guard();
let size: number;
let source: fs.Stats;
try {
size = fs.lstatSync(join(repo, path)).size;
source = fs.lstatSync(join(repo, path));
} catch (error: any) {
if (error?.code === 'ENOENT') {
absentPaths.add(path);
continue;
}
throw error;
}
const size = source.size;
// A directory in Git's inventory is a repository boundary, not source:
// an untracked nested repository (listed with a trailing slash) or a
// registered submodule's gitlink. Git reports it as one entry and never
// descends, so its contents are not this repository's source. Record an
// explicit exclusion bound to the boundary's identity, exactly like a
// host dependency tree, instead of reading it or failing the run.
if (source.isDirectory()) {
boundaries.set(path, source);
entries.push({
path,
pathId: snapshotPathId(repo, path),
originalHash: 'not-read',
bytes: source.size,
mode: source.mode & 0o777,
transformation: NESTED_REPOSITORY,
});
guard();
continue;
} // tracked deletions are represented by absence and the diff manifest
// Host dependency trees and paths outside --paths aren't copied, assessed,
// or counted toward the source cap. Their omission is still explicit.
@@ -808,6 +842,22 @@ export async function capture(
for (const e of entries) {
guard();
if (e.originalHash === 'not-read') {
const boundary = boundaries.get(e.path);
if (boundary) {
// A nested repository is recorded by identity, never read: a
// swap, symlink or replacement directory fails the capture.
const current = fs.lstatSync(containedFile(repo, e.path));
if (
current.isSymbolicLink() ||
!current.isDirectory() ||
current.dev !== boundary.dev ||
current.ino !== boundary.ino ||
current.mode !== boundary.mode
)
throw new CsoError('SNAPSHOT_RACE', `${message}: ${e.path}`);
guard();
continue;
}
const current = fs.lstatSync(containedFile(repo, e.path)),
compared = gitHashes.get(e.path);
if (
@@ -876,33 +926,69 @@ export async function capture(
});
} else {
const range = manifest.baseCommit ? `${manifest.baseCommit}..${headCommit}` : headCommit;
const log = (patches: boolean, maxBytes?: number) =>
git(
repo,
[
'-c',
'core.quotePath=false',
'log',
'--no-ext-diff',
'--no-textconv',
'--max-count=100',
'--format=commit %H%nAuthor: %an%nDate: %aI%nSubject: %s',
...(patches ? ['--unified=3', '-p'] : []),
range,
'--',
...(auditPaths ?? []).map((prefix) => `:(literal)${prefix}`),
],
home,
maxBytes,
);
admission.time();
const raw = await git(
repo,
[
'-c',
'core.quotePath=false',
'log',
'--no-ext-diff',
'--no-textconv',
'--max-count=100',
'--format=commit %H%nAuthor: %an%nDate: %aI%nSubject: %s',
'--unified=3',
'-p',
range,
'--',
...(auditPaths ?? []).map((prefix) => `:(literal)${prefix}`),
],
home,
);
let patched: string | undefined;
try {
patched = await log(true, SNAPSHOT_MANIFEST_LIMIT);
} catch (error) {
if (!(error instanceof CsoError) || error.code !== 'MISSING_INPUT') throw error;
}
guard();
const safe = redact(withholdHistory(raw, exclusion));
const commits = withholdHistory(patched ?? '', exclusion).split(/^(?=commit [0-9a-f]{40}(?:[0-9a-f]{24})?$)/m).filter(Boolean),
sizes = commits.map((commit) => Buffer.byteLength(commit)),
whole = sizes.reduce((sum, size) => sum + size, 0) <= MAX_OUTPUT;
let kept = 0,
bytes = 0,
safe = patched === '' ? '' : undefined,
truncated: string | undefined,
retainedCommits: number | string = 'at most 100';
while (kept < commits.length && (whole || bytes + sizes[kept] <= MAX_OUTPUT - HISTORY_MARKER_RESERVE))
bytes += sizes[kept++];
for (; kept > 0 && safe === undefined; kept--) {
admission.time();
const omitted = commits.length - kept,
note = omitted
? `the newest ${kept} of ${commits.length} commits were retained with patches; ${omitted} older commits were omitted to stay within the ${MAX_OUTPUT}-byte history limit`
: undefined,
text = redact((note ? historyMarker(note) : '') + commits.slice(0, kept).join(''));
if (Buffer.byteLength(text) > MAX_OUTPUT) continue;
safe = text;
truncated = note;
if (omitted) retainedCommits = kept;
}
if (safe === undefined) {
const metadata = await log(false);
guard();
truncated = `patches were withheld because the newest commit's patch or the patch log exceeds the ${MAX_OUTPUT}-byte history limit; only commit metadata was retained`;
safe = redact(historyMarker(truncated) + metadata);
retainedCommits = 'metadata for at most 100';
}
atomicWriteSync(join(runDir, 'history.txt'), safe, { mode: 0o600 });
writeJson(join(runDir, 'history-status.json'), {
status: 'captured',
range: auditPaths ? `${range}, limited to --paths ${auditPaths.join(', ')}` : range,
commits: 'at most 100',
commits: retainedCommits,
bytes: Buffer.byteLength(safe),
...(truncated ? { truncated } : {}),
});
}
} catch (error) {
+52 -4
View File
@@ -135,8 +135,56 @@ const SEVERITY_LABELS = [
function severityWords(text: string): Severity[] {
return SEVERITY_LABELS.flatMap(re => [...text.matchAll(re)].map(m => SEVERITY_WORDS[m[1]!.toLowerCase()]!));
}
/** An explicit conclusion that the review found nothing to report. */
const NO_FINDINGS = /\bNO_FINDINGS\b|\bno (?:actionable |significant |new |concrete )?(?:bugs?|issues?|findings?|problems?)\b|\b(?:did not|didn't|cannot|can't|could not|couldn't) (?:find|identify) any (?:actionable |new |concrete )?(?:bugs|issues|findings|problems)\b/i;
/**
* A no-findings statement opening a sentence: NO_FINDINGS (its underscore is
* gone after plainReview), "No issues found", "I found no actionable
* regressions", "I did not find any bugs". Never a phrase inside another
* clause ("renaming the flag is no problem for callers, but ...").
*/
const FINDING_NOUN = '(?:(?:actionable|significant|new|concrete)\\s+)?(?:bugs?|issues?|findings?|problems?|regressions?|defects?)\\b';
const NO_FINDINGS_LEAD = '(?:(?:overall|in summary|in short|summary|conclusion|result)[,:]?\\s+|(?:after|having|on|upon)\\s+(?:reviewing|reading|checking|inspecting|reviewed|read|checked|inspected)\\b[^,.;]{0,80},\\s+)?';
const NO_FINDINGS = new RegExp(`^${NO_FINDINGS_LEAD}(?:NO_?FINDINGS\\b|(?:(?:i|we)\\s+(?:found|see|saw|identified)\\s+|there\\s+(?:are|were|is)\\s+)?no\\s+${FINDING_NOUN}|(?:i|we)\\s+(?:did not|didn't|could not|couldn't|cannot|can't)\\s+(?:find|identify|see)\\s+any\\s+${FINDING_NOUN})`, 'i');
/** A contrast or exception qualifies the claim, so the sentence does not conclude that nothing was found. */
const NO_FINDINGS_EXCEPTION = /(?<![\w-])(?:but|however|although|though|yet|except|excluding|unless|only|apart from|aside from|other than|besides|beyond|elsewhere|otherwise)(?![\w-])/i;
const NO_FINDINGS_PREDICATE = /^\s*(?:(?:was|were|is|are|has been|have been)\s+)?(?:found|identified|detected|evident|apparent|introduced|remain|exist|to report)?/i;
const WHOLE_CHANGE_SCOPE = /^(?:in|within|across|throughout|with|from|for)\s.*\b(?:chang(?:e|es|ed)|diffs?|patch(?:es)?|staged|unstaged|untracked|branch|commits?|pull request|PR)\b/i;
/**
* The review's conclusion says it found nothing: its last prose sentence or
* ", and" clause (a wrapped line that continues in lowercase joined), or the because-clause of its
* Recommendation line, is an unqualified no-findings statement. When other
* prose precedes it, anything after the statement other than a predicate
* ("were found", "is evident") or a colon explanation must scope it to the
* change as a whole: "No issues in the parser." or "No regressions were found
* by the tests." says nothing about the rest of the reply.
*/
function concludesNoFindings(code: string): boolean {
let prose = '', because: string | undefined, recommendationOpen = false;
for (const line of code.split(/\r?\n/)) {
const text = plainReview(line).trim();
const listed = /^[\t ]*(?:#{1,6}|[-+*])[\t ]/.test(line);
if (recommendationOpen && text && !listed) {
because = `${because} ${text}`;
recommendationOpen = !/[.!?]$/.test(text);
continue;
}
recommendationOpen = false;
if (!text || /^(?:Recommendation|Verdict)[\t ]*:/i.test(text)) {
const clause = text.match(/^Recommendation[\t ]*:.*?\bbecause\b[\t ]*(\S.*)$/i)?.[1];
if (clause) { because = clause; recommendationOpen = !/[.!?]$/.test(clause); }
prose += '\n';
} else if (/^[\t ]*#{1,6}[\t ]/.test(line)) prose += `\n${text}\n`;
else prose += listed || prose.endsWith('\n') || /[.!?;:]$/.test(prose) || !/^[a-z]/.test(text) ? `\n${text}` : ` ${text}`;
}
const sentences = prose.split(/(?<=[.!?;])\s+|\n|,\s+and\s+/i).map(sentence => sentence.trim()).filter(Boolean);
const total = sentences.length + (because ? 1 : 0);
return [sentences.at(-1), because].some(conclusion => {
const statement = conclusion?.match(NO_FINDINGS)?.[0];
if (!conclusion || !statement || NO_FINDINGS_EXCEPTION.test(conclusion)) return false;
const qualifier = conclusion.slice(statement.length).replace(NO_FINDINGS_PREDICATE, '').replace(/[\s.!]+$/, '').trim();
return total === 1 || !qualifier || qualifier.startsWith(':') || WHOLE_CHANGE_SCOPE.test(qualifier);
});
}
/** Formatting the requested marker in bold, inline code, or a list does not invalidate a completed review. */
function plainReview(text: string): string {
@@ -159,7 +207,7 @@ function withoutCode(text: string): string {
}
if (marker) { fence = marker; return ''; }
return line.replace(/`([^`]+)`/g, (span, inner: string, at: number) =>
/^\[?(?:P[0-3]|critical|high|medium|low)\]?$/i.test(inner.trim()) && CODE_LABEL_BEFORE.test(line.slice(0, at)) ? inner : ' ');
inner.trim() === 'NO_FINDINGS' || /^\[?(?:P[0-3]|critical|high|medium|low)\]?$/i.test(inner.trim()) && CODE_LABEL_BEFORE.test(line.slice(0, at)) ? inner : ' ');
}).join('\n');
}
@@ -218,7 +266,7 @@ export function classifyOutsideReview(input: OutsideReviewInput): OutsideReviewC
const notReady = !findings.highest && verdictNotReady(plainCode);
if (blocking || notReady) return { ...result('findings'), ...(unparsed ? { unparsed } : {}) };
if (unparsed) return { ...result('unverified', 'unparsed_severity', unparsed), unparsed };
if ((input.gate === 'structured' || input.gate === 'review') && !findings.highest && !NO_FINDINGS.test(input.text)) {
if ((input.gate === 'structured' || input.gate === 'review') && !findings.highest && !concludesNoFindings(code)) {
return result('unverified', 'untagged_review', 'missing severity or explicit no-findings conclusion');
}
return result('clean');
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.151",
"version": "1.91.153",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
@@ -69,16 +69,16 @@
"eval:bg:release": "bun run scripts/eval-bg.ts release"
},
"dependencies": {
"@huggingface/transformers": "^4.3.0",
"@huggingface/transformers": "^4.3.1",
"@ngrok/ngrok": "^1.7.0",
"cross-spawn": "^7.0.6",
"diff": "^9.0.0",
"html-to-docx": "1.8.0",
"html-to-vdom": "0.7.0",
"marked": "^18.0.11",
"marked": "^18.1.1",
"playwright": "^1.62.1",
"sharp": "^0.35.4",
"socks": "^2.8.9"
"socks": "^2.8.10"
},
"engines": {
"bun": ">=1.4.2"
+299
View File
@@ -0,0 +1,299 @@
// Regressions for /cso on ordinary user repositories: an untracked nested Git
// repository (#3255 bug 1), a patch history over the 1 MiB bound (#3255 bug 2),
// and a Docker socket that realpath refuses to open (#3248).
import { afterAll, afterEach, beforeAll, describe, expect, spyOn, test } from 'bun:test';
import * as fs from 'node:fs';
import * as net from 'node:net';
import * as os from 'node:os';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
import { NESTED_REPOSITORY, capture, historyMarker } from '../lib/cso/snapshot';
import { newRun, readJson } from '../lib/cso/state';
import { MAX_OUTPUT } from '../lib/cso/contracts';
import { dockerEndpoint } from '../lib/cso/docker';
import { TRUSTED_DIRECTORIES } from '../lib/cso/process';
const ROOT = path.resolve(import.meta.dir, '..');
const launcher = path.join(ROOT, 'bin', process.platform === 'win32' ? 'gstack-cso-launcher.exe' : 'gstack-cso-launcher');
const MIB = 1024 * 1024;
const CREDENTIAL_CANARY = ['ghp_', 'abcdefghijklmnopqrstuvwxyz1234567890'].join('');
const OLD_CANARY = ['ghp_', 'zyxwvutsrqponmlkjihgfedcba0987654321'].join('');
const MARKER = '[gstack-cso history truncated:';
const roots: string[] = [];
const originalHome = process.env.GSTACK_HOME;
let state = '';
beforeAll(() => {
state = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cso-ordinary-state-')));
process.env.GSTACK_HOME = state;
});
afterEach(() => {
for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
});
afterAll(() => {
if (originalHome === undefined) delete process.env.GSTACK_HOME;
else process.env.GSTACK_HOME = originalHome;
fs.rmSync(state, { recursive: true, force: true });
});
function tmp(): string {
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cso-ordinary-')));
roots.push(root);
return root;
}
function git(repo: string, ...args: string[]): string {
const result = spawnSync('/usr/bin/git', ['-C', repo, ...args], { encoding: 'utf8', env: { HOME: path.dirname(repo), PATH: '/usr/bin:/bin' }, timeout: 60_000 });
if (result.status) throw new Error(result.stderr);
return result.stdout;
}
function init(repo: string): string {
fs.mkdirSync(repo, { recursive: true });
git(repo, 'init', '-q');
git(repo, 'config', 'user.email', 'fixture@example.test');
git(repo, 'config', 'user.name', 'Fixture');
return repo;
}
function commit(repo: string, files: Record<string, string>, subject: string): void {
for (const [file, body] of Object.entries(files)) fs.writeFileSync(path.join(repo, file), body);
git(repo, 'add', '-A');
git(repo, 'commit', '-qm', subject);
}
function filler(tag: string, bytes: number): string {
const lines: string[] = [];
for (let i = 0, size = 0; size < bytes; i++) {
const line = `export const ${tag}${i} = ${i};`;
lines.push(line);
size += line.length + 1;
}
return lines.join('\n') + '\n';
}
function nestedRepository(outer: string, name: string, files: Record<string, string>): string {
const inner = init(path.join(outer, name));
commit(inner, files, 'inner');
return inner;
}
function cso(repo: string, args: string[]) {
return spawnSync(launcher, args, { cwd: repo, encoding: 'utf8', env: { HOME: path.dirname(repo), GSTACK_HOME: state, PATH: '/usr/bin:/bin' }, timeout: 120_000, maxBuffer: 64 * MIB });
}
function runFiles(dir: string): string[] {
return fs.readdirSync(dir, { recursive: true, withFileTypes: true }).filter((item) => item.isFile()).map((item) => path.join(item.parentPath, item.name));
}
describe('CSO start on a repository with an untracked nested repository (#3255 bug 1)', () => {
test('start succeeds, records the boundary as an explicit exclusion, and never reads the nested source', () => {
const repo = init(path.join(tmp(), 'repo'));
commit(repo, { 'README.md': 'outer\n' }, 'outer');
nestedRepository(repo, 'inner', { 'secret.txt': `TOKEN=${CREDENTIAL_CANARY}\n` });
const started = cso(repo, ['start', '--repo', repo, '--scope', 'auth', '--offline']);
expect(started.stderr).toBe('');
expect(started.status).toBe(0);
const run = JSON.parse(started.stdout),
dir = path.join(state, 'security', 'cso', run.repoId, run.runId),
report = readJson(path.join(dir, 'report.json')),
snapshot = report.coverage.find((item: any) => item.domain === 'snapshot-inputs');
expect(snapshot.exclusions).toEqual([`inner: ${NESTED_REPOSITORY}`]);
expect(snapshot.gaps).toEqual([]);
for (const file of runFiles(dir)) expect(fs.readFileSync(file, 'utf8')).not.toContain(CREDENTIAL_CANARY);
expect(fs.existsSync(path.join(dir, 'snapshot', 'inner'))).toBe(false);
expect(fs.existsSync(path.join(dir, 'readable', 'inner'))).toBe(false);
});
test('negative control: an untracked symlink to a directory is still refused, not treated as a boundary', () => {
const root = tmp(),
repo = init(path.join(root, 'repo')),
outside = path.join(root, 'outside');
commit(repo, { 'README.md': 'outer\n' }, 'outer');
nestedRepository(repo, 'inner', { 'f.txt': 'x\n' });
fs.mkdirSync(outside);
fs.writeFileSync(path.join(outside, 'host.txt'), 'host\n');
fs.symlinkSync(outside, path.join(repo, 'escape'));
const started = cso(repo, ['start', '--repo', repo, '--scope', 'auth', '--offline']);
expect(started.status).not.toBe(0);
expect(JSON.parse(started.stderr).error).toMatchObject({ code: 'UNSAFE_PATH', message: expect.stringContaining('escape') });
});
test('negative control: a nested repository swapped for another directory during capture fails as a snapshot race', async () => {
const root = tmp(),
repo = init(path.join(root, 'repo')),
target = path.join(repo, 'vendor', 'inner'),
replacement = path.join(root, 'replacement');
commit(repo, { 'README.md': 'outer\n' }, 'outer');
fs.mkdirSync(path.join(repo, 'vendor'));
nestedRepository(repo, 'vendor/inner', { 'f.txt': 'x\n' });
nestedRepository(root, 'replacement', { 'f.txt': 'y\n' });
const original = fs.lstatSync;
let hits = 0;
const counting = spyOn(fs, 'lstatSync').mockImplementation(((candidate: any, options?: any) => {
if (path.resolve(String(candidate)) === target) hits++;
return options === undefined ? original(candidate) : original(candidate, options);
}) as typeof fs.lstatSync);
try {
await capture(repo, newRun(repo).dir, 'HEAD');
} finally {
counting.mockRestore();
}
const last = hits;
expect(last).toBeGreaterThan(1);
hits = 0;
let swapped = false;
const swapping = spyOn(fs, 'lstatSync').mockImplementation(((candidate: any, options?: any) => {
if (path.resolve(String(candidate)) === target && ++hits === last) {
fs.renameSync(target, `${target}.parked`);
fs.renameSync(replacement, target);
swapped = true;
}
return options === undefined ? original(candidate) : original(candidate, options);
}) as typeof fs.lstatSync);
try {
await expect(capture(repo, newRun(repo).dir, 'HEAD')).rejects.toMatchObject({ code: 'SNAPSHOT_RACE', message: 'Source changed before snapshot persistence: vendor/inner' });
} finally {
swapping.mockRestore();
}
expect(swapped).toBe(true);
});
});
describe('CSO history capture over the 1 MiB bound (#3255 bug 2)', () => {
test('keeps the newest whole commits that fit, marks the truncation, and still redacts', async () => {
const repo = init(path.join(tmp(), 'repo'));
commit(repo, { 'old.ts': filler('old', 450 * 1024) + `// ${OLD_CANARY}\n` }, 'oldest');
commit(repo, { 'mid.ts': filler('mid', 450 * 1024) }, 'middle');
commit(repo, { 'new.ts': filler('new', 300 * 1024) + `// ${CREDENTIAL_CANARY}\n` }, 'newest');
const run = newRun(repo);
await capture(repo, run.dir);
const status = readJson(path.join(run.dir, 'history-status.json')),
history = fs.readFileSync(path.join(run.dir, 'history.txt'), 'utf8');
expect(status).toMatchObject({ status: 'captured', commits: 2 });
expect(status.truncated).toBe(`the newest 2 of 3 commits were retained with patches; 1 older commits were omitted to stay within the ${MAX_OUTPUT}-byte history limit`);
expect(history.startsWith(historyMarker(status.truncated))).toBe(true);
expect(Buffer.byteLength(history)).toBeLessThanOrEqual(MAX_OUTPUT);
expect(status.bytes).toBe(Buffer.byteLength(history));
expect(history).toContain('Subject: newest');
expect(history).toContain('Subject: middle');
expect(history).not.toContain('Subject: oldest');
expect(history).toContain('diff --git a/new.ts b/new.ts');
expect(history).not.toContain(CREDENTIAL_CANARY);
expect(history).not.toContain(OLD_CANARY);
});
test('falls back to commit metadata when the newest patch alone exceeds the bound, and still redacts', async () => {
const repo = init(path.join(tmp(), 'repo'));
commit(repo, { 'a.ts': 'export const a = 1;\n' }, 'first');
commit(repo, { 'big.ts': filler('big', 1.2 * MIB) }, `rotate ${CREDENTIAL_CANARY}`);
const run = newRun(repo);
await capture(repo, run.dir);
const status = readJson(path.join(run.dir, 'history-status.json')),
history = fs.readFileSync(path.join(run.dir, 'history.txt'), 'utf8');
expect(status).toMatchObject({ status: 'captured', commits: 'metadata for at most 100' });
expect(status.truncated).toMatch(/^patches were withheld because/);
expect(history.startsWith(historyMarker(status.truncated))).toBe(true);
expect(history).toContain('Subject: first');
expect(history).toContain('Subject: rotate');
expect(history).not.toContain('diff --git');
expect(history).not.toContain(CREDENTIAL_CANARY);
});
test('a history within the bound is retained whole with no marker', async () => {
const repo = init(path.join(tmp(), 'repo'));
commit(repo, { 'a.ts': 'export const a = 1;\n' }, 'first');
commit(repo, { 'b.ts': `// ${CREDENTIAL_CANARY}\n` }, 'second');
const run = newRun(repo);
await capture(repo, run.dir);
const status = readJson(path.join(run.dir, 'history-status.json')),
history = fs.readFileSync(path.join(run.dir, 'history.txt'), 'utf8');
expect(status).toEqual({ status: 'captured', range: status.range, commits: 'at most 100', bytes: Buffer.byteLength(history) });
expect(history.startsWith('commit ')).toBe(true);
expect(history).toContain('Subject: first');
expect(history).not.toContain(MARKER);
expect(history).not.toContain(CREDENTIAL_CANARY);
});
test('start reports the truncated history as partial coverage and history stays readable', () => {
const repo = init(path.join(tmp(), 'repo'));
commit(repo, { 'old.ts': filler('old', 700 * 1024) }, 'oldest');
commit(repo, { 'new.ts': filler('new', 700 * 1024) }, 'newest');
const started = cso(repo, ['start', '--repo', repo, '--scope', 'auth', '--offline']);
expect(started.stderr).toBe('');
expect(started.status).toBe(0);
const run = JSON.parse(started.stdout),
report = readJson(path.join(state, 'security', 'cso', run.repoId, run.runId, 'report.json')),
coverage = report.coverage.find((item: any) => item.domain === 'history-inputs');
expect(coverage.status).toBe('partial');
expect(coverage.gaps).toEqual([`History truncated: the newest 1 of 2 commits were retained with patches; 1 older commits were omitted to stay within the ${MAX_OUTPUT}-byte history limit`]);
expect(coverage.evidence[0]).toStartWith('Captured 1 from ');
const whole = cso(repo, ['history', run.runId]);
expect(whole.status).toBe(0);
expect(whole.stdout.startsWith(MARKER)).toBe(true);
const scoped = cso(repo, ['history', run.runId, 'new.ts']);
expect(scoped.status).toBe(0);
expect(scoped.stdout.startsWith('commit ')).toBe(true);
expect(scoped.stdout).toContain('diff --git a/new.ts b/new.ts');
});
});
const dockerInstalled = TRUSTED_DIRECTORIES.some((directory) => fs.existsSync(path.join(directory, 'docker')));
describe.skipIf(process.platform === 'win32' || !dockerInstalled)('CSO Docker endpoint admission when realpath refuses sockets (#3248)', () => {
let root = '';
let socketPath = '';
let server: net.Server;
const realpath = fs.realpathSync;
const refusing = ((candidate: fs.PathLike, ...rest: any[]) => {
let socket = false;
try {
socket = fs.statSync(candidate).isSocket();
} catch {}
if (socket) {
const error = new Error('EOPNOTSUPP: operation not supported on socket') as NodeJS.ErrnoException;
error.code = 'EOPNOTSUPP';
throw error;
}
return realpath(candidate, ...rest);
}) as typeof fs.realpathSync;
beforeAll(async () => {
root = realpath(fs.mkdtempSync(path.join(os.tmpdir(), 'cso-endpoint-')));
socketPath = path.join(root, 'docker.sock');
server = net.createServer();
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(socketPath, () => resolve());
});
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
fs.rmSync(root, { recursive: true, force: true });
});
afterEach(() => {
(fs.realpathSync as any).mockRestore?.();
});
const admit = (host: string) => dockerEndpoint(root, { HOME: root, DOCKER_HOST: host });
test('admits a socket reached through a symlinked directory and a symlinked leaf, pinned to its identity', async () => {
const linkedDirectory = path.join(root, 'run'),
leaf = path.join(root, 'colima.sock');
fs.symlinkSync(root, linkedDirectory);
fs.symlinkSync('docker.sock', leaf);
const stub = spyOn(fs, 'realpathSync').mockImplementation(refusing);
const identity = fs.statSync(socketPath);
for (const host of [`unix://${socketPath}`, `unix://${path.join(linkedDirectory, 'colima.sock')}`]) {
const endpoint = await admit(host);
expect(endpoint).toMatchObject({ socket: socketPath, uri: `unix://${socketPath}`, device: identity.dev, inode: identity.ino });
}
expect(stub.mock.calls.some(([candidate]) => String(candidate) === socketPath)).toBe(true);
});
test('negative controls: non-sockets, dangling or looping links, and non-local URIs are still refused', async () => {
spyOn(fs, 'realpathSync').mockImplementation(refusing);
const file = path.join(root, 'plain.sock'),
dangling = path.join(root, 'dangling.sock'),
loopA = path.join(root, 'loop-a.sock'),
loopB = path.join(root, 'loop-b.sock');
fs.writeFileSync(file, '');
fs.symlinkSync(path.join(root, 'missing.sock'), dangling);
fs.symlinkSync(loopB, loopA);
fs.symlinkSync(loopA, loopB);
await expect(admit(`unix://${file}`)).rejects.toMatchObject({ code: 'ISOLATION_FAILED', message: 'Docker endpoint is not a local Unix socket' });
for (const candidate of [dangling, loopA])
await expect(admit(`unix://${candidate}`)).rejects.toMatchObject({ code: 'ISOLATION_FAILED', message: 'Pinned local Docker socket is unavailable' });
await expect(admit(`unix://${root}/../${path.basename(root)}/docker.sock`)).rejects.toMatchObject({ code: 'ISOLATION_FAILED' });
await expect(admit('tcp://127.0.0.1:2375')).rejects.toMatchObject({ code: 'ISOLATION_FAILED' });
});
});
+3 -1
View File
@@ -1,6 +1,6 @@
import { afterAll, afterEach, describe, expect, spyOn, test } from 'bun:test';
import * as fs from 'node:fs';import * as os from 'node:os';import * as path from 'node:path';import { spawn, spawnSync } from 'node:child_process';
import { assertSnapshot, capture } from '../lib/cso/snapshot';import { CsoError, snapshotPathHandle } from '../lib/cso/contracts';import { runProcess, sanitizeForJson, sanitizeHelperForJson } from '../lib/cso/process';import { discardAtomicNoReplaceTemp, finalizeReplayTemporary,reclaimDeadAttemptScratch, loadReport, newRun, privateRoot, readJson, recoverAtomicNoReplaceJson, retention, saveReport, secureDirectory, stateRoot, withLock, writeHelperJson, writeJson } from '../lib/cso/state';
import { assertSnapshot, capture, NESTED_REPOSITORY } from '../lib/cso/snapshot';import { CsoError, snapshotPathHandle } from '../lib/cso/contracts';import { runProcess, sanitizeForJson, sanitizeHelperForJson } from '../lib/cso/process';import { discardAtomicNoReplaceTemp, finalizeReplayTemporary,reclaimDeadAttemptScratch, loadReport, newRun, privateRoot, readJson, recoverAtomicNoReplaceJson, retention, saveReport, secureDirectory, stateRoot, withLock, writeHelperJson, writeJson } from '../lib/cso/state';
const roots:string[]=[];const tmp=()=>{const p=fs.mkdtempSync(path.join(os.tmpdir(),'cso-snapshot-'));roots.push(p);return p;};afterEach(()=>{for(const p of roots.splice(0))fs.rmSync(p,{recursive:true,force:true});});
const originalState=process.env.GSTACK_HOME,state=fs.mkdtempSync(path.join(os.tmpdir(),'cso-state-'));process.env.GSTACK_HOME=state;afterAll(()=>{if(originalState===undefined)delete process.env.GSTACK_HOME;else process.env.GSTACK_HOME=originalState;fs.rmSync(state,{recursive:true,force:true});});
function git(repo:string,...args:string[]){const r=spawnSync('/usr/bin/git',['-C',repo,...args],{encoding:'utf8',env:{PATH:'/usr/bin:/bin',HOME:repo},timeout:30_000});if(r.status)throw new Error(r.stderr);return r.stdout;}
@@ -19,6 +19,8 @@ const unlink=fs.unlinkSync.bind(fs);spyOn(fsModule,'unlinkSync').mockImplementat
const {withLock}=await import(${JSON.stringify(path.resolve(import.meta.dir,'../lib/cso/state.ts'))});let outcome='success';try{withLock(dir,()=>{const active=path.join(barrier,'work-active');try{write(active,id,{flag:'wx'});}catch{fs.appendFileSync(path.join(barrier,'overlap'),'overlap\\n');}mark('entered-'+id);if(process.env.RACE_HOLD==='1')waitFor('release-'+id);try{if(fs.readFileSync(active,'utf8')===id)fs.unlinkSync(active);}catch{}});}catch(error){outcome=String(error?.code??error);}write(path.join(barrier,'result-'+id),outcome);`;}
describe('CSO dirty snapshot boundary',()=>{
test('captures tracked modifications and nonignored untracked source without changing Git state',async()=>{const p=repo();fs.writeFileSync(path.join(p,'tracked.txt'),'dirty\n');fs.writeFileSync(path.join(p,'new.txt'),'untracked\n');fs.writeFileSync(path.join(p,'.gitignore'),'ignored.txt\n');fs.writeFileSync(path.join(p,'ignored.txt'),'ignored\n');const before=git(p,'status','--porcelain=v1','-z'),run=newRun(p),m=await capture(p,run.dir,'HEAD');expect(fs.readFileSync(path.join(run.dir,'snapshot','tracked.txt'),'utf8')).toBe('dirty\n');expect(fs.readFileSync(path.join(run.dir,'snapshot','new.txt'),'utf8')).toBe('untracked\n');expect(fs.existsSync(path.join(run.dir,'snapshot','ignored.txt'))).toBe(false);expect(git(p,'status','--porcelain=v1','-z')).toBe(before);expect(m.changedPaths).toContain('new.txt');});
test('records an untracked nested repository as one boundary entry instead of failing the run',async()=>{const p=repo(),inner=path.join(p,'inner');fs.mkdirSync(inner);git(inner,'init','-q');git(inner,'config','user.email','fixture@example.test');git(inner,'config','user.name','Fixture');fs.writeFileSync(path.join(inner,'f.txt'),'x\n');git(inner,'add','.');git(inner,'commit','-qm','inner');const run=newRun(p),manifest=await capture(p,run.dir,'HEAD');assertSnapshot(run.dir,manifest);const entry=manifest.entries.find(e=>e.path==='inner');expect(entry?.transformation).toBe(NESTED_REPOSITORY);expect(entry?.originalHash).toBe('not-read');expect(manifest.entries.map(e=>e.path).filter(p=>p==='tracked.txt'||p.startsWith('inner'))).toEqual(['inner','tracked.txt']);expect(manifest.changedPaths).toContain('inner');expect(fs.existsSync(path.join(run.dir,'snapshot','inner'))).toBe(false);expect(fs.existsSync(path.join(run.dir,'readable','inner'))).toBe(false);});
test('records a registered submodule gitlink the same way as an untracked nested repository',async()=>{const p=repo(),src=tmp();git(src,'init','-q');git(src,'config','user.email','fixture@example.test');git(src,'config','user.name','Fixture');fs.writeFileSync(path.join(src,'g.txt'),'g\n');git(src,'add','.');git(src,'commit','-qm','sub');git(p,'-c','protocol.file.allow=always','submodule','add','-q',src,'sub');const run=newRun(p),manifest=await capture(p,run.dir,'HEAD');assertSnapshot(run.dir,manifest);expect(manifest.entries.find(e=>e.path==='sub')?.transformation).toBe(NESTED_REPOSITORY);expect(fs.existsSync(path.join(run.dir,'snapshot','sub','g.txt'))).toBe(false);});
test('admits source membership and time before an unbounded startup walk',async()=>{const p=repo();fs.writeFileSync(path.join(p,'second.txt'),'second\n');const tooMany=newRun(p);await expect(capture(p,tooMany.dir,undefined,undefined,{maxEntries:1})).rejects.toMatchObject({code:'MISSING_INPUT'});expect(fs.existsSync(path.join(tooMany.dir,'snapshot'))).toBe(false);const expired=newRun(p);await expect(capture(p,expired.dir,undefined,undefined,{deadlineMs:Date.now()-1})).rejects.toMatchObject({code:'DEADLINE'});expect(fs.existsSync(path.join(expired.dir,'snapshot'))).toBe(false);});
test('includes chmod-only changes in diff scope',async()=>{const p=repo();fs.chmodSync(path.join(p,'tracked.txt'),0o755);const manifest=await capture(p,newRun(p).dir,'HEAD');expect(manifest.changedPaths).toContain('tracked.txt');});
test('does not invoke configured clean filters, hooks, fsmonitor, or PATH shims',async()=>{const p=repo(),marker=path.join(p,'marker');fs.writeFileSync(path.join(p,'.gitattributes'),'*.txt filter=hostile\n');git(p,'config','filter.hostile.clean',`/bin/sh -c 'touch ${marker}; cat'`);git(p,'config','core.fsmonitor',`/bin/sh -c 'touch ${marker}'`);const fake=path.join(p,'bin');fs.mkdirSync(fake);fs.writeFileSync(path.join(fake,'git'),`#!/bin/sh\ntouch '${marker}'\nexit 99\n`,{mode:0o755});const old=process.env.PATH;process.env.PATH=`${fake}:${old}`;try{await capture(p,newRun(p).dir,'HEAD');}finally{process.env.PATH=old;}expect(fs.existsSync(marker)).toBe(false);});
+61
View File
@@ -65,6 +65,67 @@ describe('classifyOutsideReview: separate execution, findings and verdict', () =
});
});
// #3294: "clean" comes only from a no-findings statement that concludes the reply,
// never from a "no X" phrase among untagged findings anywhere in it.
describe('a no-findings statement counts only as the reply\'s conclusion (#3294)', () => {
const REPORTER = 'I found no actionable regressions in the staged, unstaged, or untracked changes.';
const FINDING = 'The fallback path now swallows ENOENT and returns an empty config.';
test("the reporter's exact codex review reply reads clean through the reporter's CLI invocation", () => {
expect(classifyOutsideReview({ text: REPORTER, gate: 'structured' }).verdict).toBe('clean');
const r = cli(['--label', 'probe', '--exit', '0', '--stderr', file(''), 'structured', file(REPORTER)]);
expect([r.status, r.stdout]).toEqual([0, 'VERDICT: clean\nFINDINGS: none\n']);
});
test('clean conclusions, with the added regressions/defects vocabulary and the requested marker formatted', () => {
for (const text of ['No regressions found.', 'I did not find any defects.', 'There are no actionable regressions.',
'NO_FINDINGS', '**NO_FINDINGS**', '`NO_FINDINGS`', '- NO_FINDINGS',
`The change adds a retry cap and its tests.\n${REPORTER}`, 'Reviewed the retry helper.\n\nNo issues found in this change.',
'After reviewing the patch, I found no issues.', 'I found no issues in this read-only change.',
'Summary of the retry helper\nNo issues found.', 'I found no actionable regressions in the staged\nchanges.',
// Clean `codex review --uncommitted` replies captured from codex-cli 0.160.0 with gpt-6-sol.
'The change validates the retry limit before execution. No actionable regression was found.',
'The change validates `max` before retrying. No affected callers or tests are present in the repository, and no actionable regression is evident.']) {
expect({ text, verdict: classifyOutsideReview({ text, gate: 'structured' }).verdict }).toEqual({ text, verdict: 'clean' });
}
});
test('mixed replies: untagged findings beside a "no X" phrase are unverified, never clean', () => {
for (const text of [
'The parser change has no new issues. However, db/002_drop_idx.sql drops users_email_idx, which will regress login latency under load.',
'Renaming the flag is no problem for callers, but the fallback path now swallows ENOENT and returns an empty config.',
`No issues in the parser.\n\n${FINDING}`,
`${FINDING}\n\nNo issues in the parser.`,
`${FINDING} No regressions in the existing tests.`,
'No regressions in the existing tests, but the fallback path now swallows ENOENT.',
`${FINDING}\nI found no issues other than the fallback path.`,
`${FINDING}\nNo other issues.`,
'No issues found, except that the fallback path now swallows ENOENT.',
'No issues in the parser, and the fallback path now swallows ENOENT.',
'Missing configuration now silently disables authentication. No regressions were found by the existing tests.',
'Missing configuration now silently disables authentication. No issues are apparent in the parser.',
'The fallback path now swallows ENOENT, and no issues elsewhere.',
'The diff adds a NO_FINDINGS check, but the regex misses lowercase input.',
`${FINDING}\n\`\`\`\nNo issues found.\n\`\`\``,
]) {
for (const gate of ['structured', 'review'] as const) {
const r = classifyOutsideReview({ text: gate === 'review' ? `${text}\nRecommendation: ship because it looks fine.` : text, gate });
expect({ text, gate, verdict: r.verdict, reason: r.reason }).toEqual({ text, gate, verdict: 'unverified', reason: 'untagged_review' });
}
}
});
test("a review's Recommendation because-clause concludes under the same rules", () => {
const verdict = (text: string) => classifyOutsideReview({ text, gate: 'review' }).verdict;
expect(verdict('Recommendation: ship because I found no regressions in this change.')).toBe('clean');
expect(verdict(`The change adds a retry cap.\nRecommendation: ship because no issues remain.`)).toBe('clean');
expect(verdict('No issues in the parser.\nRecommendation: fix the fallback because it swallows ENOENT.')).toBe('unverified');
expect(verdict(`${FINDING}\nRecommendation: ship because no regressions in the existing tests.`)).toBe('unverified');
expect(verdict(`${FINDING}\nRecommendation: ship because no issues remain, but the fallback needs a follow-up.`)).toBe('unverified');
expect(verdict('Recommendation: address the migration because no regressions were found in the parser change,\nbut the migration drops users_email_idx.')).toBe('unverified');
});
});
describe('validateOutsideReview keeps the text-only shape for direct importers', () => {
test('existing shapes are unchanged', () => {
expect(validateOutsideReview('', 'review')).toEqual({ completed: false, reason: 'empty response' });