v1.95.1.0 review-depth: executables stay code, sealed custody and egress are security, quoted names in slices (GST-1177, GSTA-258 L7) (#3972)

* review-depth: an exec bit or shebang keeps logs/ and .out paths code (GSTA-258 L7 Addendum 1, 7)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* review-brief: sliceDiff splits and names git-quoted and spaced filenames (GSTA-258 L7 Addendum 1.2)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* review-risk: sealed custody, redaction and outside-model egress code class as security (GST-1177)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: review key row, review_depth receipt fields, executable and egress classing (GSTA-258 L7 Addendum 2, GST-1177)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* review pins: GSTA-258-L7 planner no-loss replay admits the new depth rules; changelog fragment

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes (deleted paths are not executables, read two bytes of candidates only, wider egress line rules)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: Step 11 review fixes (executables and symlinks are code with their lines read whatever their docs, data or config name; never open a symlink)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: second review exception (docs credential check reads every docs path whatever its class; the live SVG reader never opens a symlink or non-regular file)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* changelog: L7 fragment matches the final change

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:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-04 00:11:07 +00:00
1 parent 26a4e59165
commit 20645bce84
17 files changed
+203 -26

No files matched your search

+1 -1
View File
@@ -1 +1 @@
{"sha":"fb21c0b80e98685ca8c5192aeaa217cfffe9fbdaa8890455c97e01be1b044a53","replay":"GSTA-258-C+GST-940"}
{"sha":"6688963750c14f03cdbb249dcaa27ebd9e9c2c62170f4e142ec8ded28af010a1","replay":"GSTA-258-L7"}
+1 -1
View File
@@ -1 +1 @@
{"sha":"e2a4d2aabe02e28140b3b8f3d5288bf0eac1872b5cb375eb011e4f0cde2ad67a","replay":"GSTA-258-C+GST-942"}
{"sha":"1d03d5cd8f0cae5f01af85502004849c9fa2545009957f2c4c44a1359d9d0fc0","replay":"GSTA-258-L7"}
+1
View File
@@ -0,0 +1 @@
{"replay":"GSTA-258-L7","justification":"GSTA-258 L7 planner no-loss check (GST-1177, Addenda 1 and 7): the change only adds triggers (executables stay code; seal, sealed, custody and egress path tokens; redaction calls (camelCase or snake_case), redaction or outside-voice imports (from, side-effect, require, dynamic and Python forms) and SECRET or CREDENTIAL constants set to a regex literal, a regex list or new RegExp are security; the docs credential check reads every docs path whatever its class; a deleted file is never an executable, and an executable or symlink is code with its lines read whatever its docs, data or config name) and removes none; over the last 200 first-parent main commits to bf2cab34c5, planReview and collectCommit from origin/main and from this branch give the same depth, class, risk classes, next step and dispatches on 194, and the other 6 only raise: ed5c948eb6 (#3921, the GST-1164 secret-egress fix) and c197a139b7 (#3918, approval text in public PR bodies) and 514daebf5f (#3569, lib/sealed-guard.ts) go light to full on security, while 90a170c941, 71acd83aa6 and 216493fb32 gain security at unchanged full depth and reviewers; no commit's depth drops ; reruns after the pass 1 and Step 11 fixes give the same rows, and after the second-exception fixes 216493fb32 also gains security at unchanged depth and readers (GStack Minder drive handoffs/GSTA-258/L7-planner-noloss/, l7-200-v2.tsv, l7-200-v3.tsv, l7-200-v4.tsv)"}
+61
View File
@@ -100,6 +100,67 @@ describe('collectCommit: git objects only', () => {
});
});
describe('GSTA-258 L7 Addendum 1 and 7: executables among data-named paths', () => {
// Value: protects=an exec bit or a shebang keeps a logs/ or .out path code in both collectors; fails_when=scripts/logs/prune
// or an executable .out reaches classifyDiff as data (docs, light); seam=collectCommit/collectLive -> depthOfCommitInput
test('the exec bit or a shebang lists the path as executable; a plain log does not', () => {
const r = repo({});
r.write({ 'scripts/logs/prune': '#!/bin/sh\nrm -rf "$1"\n', 'bin/tool.out': 'ELF-ish\n', 'logs/run.txt': 'ok\n', 'docs/r/logs/ready': 'yes\n' });
fs.chmodSync(path.join(r.dir, 'bin/tool.out'), 0o755);
const head = r.commit({});
const c = collectCommit(r.dir, r.baseSha, head);
expect(c.executables).toEqual(['bin/tool.out', 'scripts/logs/prune']);
expect(depthOfCommitInput({ ...c, gateTagged: 0 }).class).toBe('code');
r.write({ 'docs/r/logs/later': '#!/usr/bin/env bash\necho\n' });
expect(collectLive(r.dir, 'main').executables).toEqual(['bin/tool.out', 'docs/r/logs/later', 'scripts/logs/prune']);
const plain = repo({});
const ph = plain.commit({ 'logs/run.txt': 'ok\n', 'docs/r/logs/ready': 'yes\n', 'docs/r/agent.out': 'done\n' });
const pc = collectCommit(plain.dir, plain.baseSha, ph);
expect(pc.executables).toEqual([]);
expect(depthOfCommitInput({ ...pc, gateTagged: 0 }).class).toBe('docs');
});
// Value: protects=a deleted log is absent, not unreadable, and an unrelated large blob never forces the fail-closed default;
// fails_when=removing logs/run classes as gate, or a big text file in the diff makes r.log executable; seam=executablesAt
test('a deleted log is not executable, and only candidates are read (two bytes each)', () => {
const r = repo({ 'logs/run': 'ok\n', 'r.log': 'ok\n', 'big.txt': 'a'.repeat(1024 * 1024) });
const del = r.commit({ 'logs/run': null, 'r.log': 'ok2\n', 'big.txt': 'b' + 'a'.repeat(1024 * 1024) });
const c = collectCommit(r.dir, r.baseSha, del);
expect(c.executables).toEqual([]);
expect(depthOfCommitInput({ ...c, gateTagged: 0 }).class).toBe('docs');
r.write({ 'r.log': null });
expect(collectLive(r.dir, 'main').executables).toEqual([]);
});
// Value: protects=a docs or config name never hides an executable's lines, and a symlink is never opened (outside voice on #3972);
// fails_when=a 755 prune.txt or prune.json with rm -rf classes light with no security, or a logs/ symlink is read through;
// seam=collectors -> classifyDiff -> matchRisk
test('executable .txt and .json scripts are code with security lines; a symlink counts as executable without being read', () => {
const r = repo({});
const script = '#!/bin/sh\nrm -rf "$1"\n';
r.write({ 'scripts/logs/prune.txt': script, 'tools/prune.json': script });
for (const p of ['scripts/logs/prune.txt', 'tools/prune.json']) fs.chmodSync(path.join(r.dir, p), 0o755);
fs.mkdirSync(path.join(r.dir, 'logs'));
fs.symlinkSync('/dev/null', path.join(r.dir, 'logs/run'));
const head = r.commit({});
for (const c of [collectCommit(r.dir, r.baseSha, head), collectLive(r.dir, 'main')]) {
expect(c.executables).toEqual(['logs/run', 'scripts/logs/prune.txt', 'tools/prune.json']);
const d = depthOfCommitInput({ ...c, gateTagged: 0 });
expect([d.class, d.risk.includes('security'), d.depth]).toEqual(['code', true, 'full']);
}
});
// Value: protects=collectLive never opens a symlinked SVG (GStack Minder second exception on #3972); fails_when=a tracked
// art.svg linked to a FIFO blocks the drawing read; seam=drawingsAmong's live reader
test('a symlinked SVG is not read as a drawing in collectLive', () => {
const r = repo({});
const fifo = path.join(r.dir, '.git', 'fifo');
spawnSync('mkfifo', [fifo], { timeout: 5_000 });
fs.symlinkSync(fifo, path.join(r.dir, 'art.svg'));
r.commit({});
const t0 = Date.now();
expect(collectLive(r.dir, 'main').drawings).toEqual([]);
expect(Date.now() - t0).toBeLessThan(20_000);
}, 30_000);
});
describe('collectLive: the working tree', () => {
test('tracked uncommitted edits and untracked files count per path; scratch under .gstack/tmp/ does not', () => {
const r = repo({ 'src/a.ts': lines(1) });
+40
View File
@@ -28,6 +28,35 @@ describe('HARN-1 false triggers (harness-evals#231 on 1.93.7.0): data, config an
expect([c.class, c.risk]).toEqual(['docs', []]);
expect(classifyDiff({ paths: ['src/logs/rotate.ts'], gateTagged: 0 }).class).toBe('code');
});
// Value: protects=a directory name or a data extension never downgrades an executable (GSTA-258 L7 Addendum 1 and 7);
// fails_when=scripts/logs/prune or an executable .out classes as docs; seam=collector executables -> classifyDiff
test('an executable (exec bit or shebang) under logs/ or named .out stays code; plain logs stay docs', () => {
const exe = ['scripts/logs/prune', 'tools/build/runner.out'];
const c = classifyDiff({ paths: [...exe, 'logs/run.txt'], gateTagged: 0, executables: exe });
expect(c.class).toBe('code');
expect(c.reasons).toEqual(expect.arrayContaining(['code: scripts/logs/prune']));
for (const p of exe) expect(classifyDiff({ paths: [p], gateTagged: 0, executables: [p] }).class).toBe('code');
const txt = 'scripts/logs/prune.txt';
const printsKey = classifyDiff({ paths: [txt], gateTagged: 0, executables: [txt], added: { [txt]: ['curl -H "Authorization: Bearer abcdefgh12345678"'] } });
expect([printsKey.class, printsKey.risk]).toEqual(['code', ['security']]);
expect(classifyDiff({ paths: ['logs/run.txt', 'docs/r/logs/ready'], gateTagged: 0, executables: [] }).class).toBe('docs');
});
// Value: protects=sealed custody and outside-model prompt egress plan full depth (GST-1177: harness-evals#237, gstack #3921);
// fails_when=scripts/sealed/seal.ts or the #3921 secret-egress lines class light with no risk; seam=matchRisk path and line rules
test('sealed custody and code that redacts or ships repo content to an outside model are security', () => {
const sec = (p: string, lines: string[] = ['export const x = 1;']) => matchRisk({ codePaths: [p], added: { [p]: lines }, scopeAuth: false }).has('security');
expect(sec('scripts/sealed/seal.ts')).toBe(true);
expect(sec('lib/eval-custody.ts')).toBe(true);
expect(sec('lib/autoplan-lean.ts', ["import { redactFindingSpans } from './redact-engine';"])).toBe(true);
expect(sec('lib/autoplan-lean.ts', [' const redacted = (patch: string) => redactFindingSpans(patch) ?? "";'])).toBe(true);
expect(sec('lib/autoplan-lean.ts', ['const SECRET_NAME = /(^|\\/)(\\.env[^/]*)$/i;'])).toBe(true);
expect(sec('lib/review-x.ts', ["import { runOutsideVoice } from './outside-voice-runner';"])).toBe(true);
for (const l of ['out = redact_secrets(text)', 'from gstack.redact import scrub', "import './outside-voice';", 'SECRET_PATTERNS = [/sk-[a-z]+/]', 'const CREDENTIAL_RE = new RegExp(src);']) expect(sec('lib/x.ts', [l])).toBe(true);
expect(sec('lib/autoplan-lean.ts', ['const INLINE_DRAFT_BYTES = 64_000;'])).toBe(false);
expect(sec('lib/x.ts', ['const SECRET_COUNT = total / 2;', "import { readFile } from 'node:fs';"])).toBe(false);
expect(sec('lib/seasonal.ts')).toBe(false);
expect(classifyDiff({ paths: ['scripts/sealed/seal.ts'], gateTagged: 0 }).risk).toContain('security');
});
test('a sqlite file is data even when a script names it', () => {
const c = classifyDiff({ paths: [sqlite, 'scripts/load.py'], gateTagged: 0, perPath: [{ path: sqlite, lines: 0, binary: true }, { path: 'scripts/load.py', lines: 4, binary: false }], consumed: [sqlite] });
expect([c.class, c.errors.map(e => e.code), c.runtimeLines]).toEqual(['code', [], 4]);
@@ -222,6 +251,17 @@ describe('C7 merged reader brief and the outside voice reading its diff from dis
expect(sliceNote(s, 100)).toMatch(/2 of 3 files.*big\.json/);
expect(sliceDiff(sec('a.ts', 10), 100).sliced).toBe(false);
});
// Value: protects=no file leaves the outside voice's slice unnamed (GSTA-258 L7 Addendum 1.2); fails_when=a git-quoted header
// before the first plain one is dropped unlisted, or glued onto the file before it; seam=sliceDiff headers
test('a git-quoted or spaced filename is its own file in the slice and is named when left out', () => {
const quoted = (p: string, n: number) => `diff --git "a/${p}" "b/${p}"\n+${'x'.repeat(n)}\n`;
const sec = (p: string, n: number) => `diff --git a/${p} b/${p}\n+${'x'.repeat(n)}\n`;
const s = sliceDiff(quoted('t\\303\\251st.sql', 500) + sec('a.ts', 10) + quoted('tab\\there.ts', 10), 100);
expect([s.files, s.kept, s.left]).toEqual([3, 2, ['"t\\303\\251st.sql"']]);
expect(s.text).toContain('tab\\there.ts');
const spaced = sliceDiff(sec('my notes/big data.csv', 500) + sec('a.ts', 10), 100);
expect(spaced.left).toEqual(['my notes/big data.csv']);
});
test('the outside voice gets a path and a hash, not the pasted diff', () => {
const text = renderBrief('outside-voice', { diff_base: sha, diff_file: '/tmp/o/outside-voice.diff', diff_sha256: 'd'.repeat(64), diff_bytes: '1234' }, t);
expect(text).toContain('/tmp/o/outside-voice.diff');