Files
gstack/lib/pregate/run-scratch.ts
Garry Tanandcapy-ai[bot] 568cd5aa56 v1.91.254.0 pregate: starter policy, benign-finding allowlist, environment vs diff failures, per-run scratch (#3480)
* pregate: starter policy, benign-finding allowlist, environment failures, per-run scratch

A repository with no .gstack/pregate.json now gets a warn (PREGATE_NO_POLICY)
with a starter policy printed, instead of PREGATE_NO_LANE on every touched
file; gstack-pregate init remains the explicit write. The policy gains an
allowlist for benign secrets/pii findings (env.value and secret never
allowlistable). Checks that cannot run because of the machine report
PREGATE_ENVIRONMENT (incomplete, with kind and evidence) apart from diff
failures. Every run writes its scratch under the state root, per run id.

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

* pregate: rename run-scratch module (strays rule) and map starter.ts to its test

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

* pregate literals: match a module as a path fragment, require the constant name for a one-character value

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

* fix: pre-landing review fixes

guards: an exited guard is the machine's only on exit 127, never from its output;
secrets: clear an interrupted run's added-line copies before scanning;
environment: one fix table per kind, one ANSI strip, hoisted not-found vocabulary;
starter returns its lines; allowlistDigest reuses sha256; stale headers and the
PREGATE_INCOMPLETE fix clause updated; tests for the mixed verify branch, disk
precedence, allowlist boundaries, a positionless finding, head-policy strictness,
run-id edges and the unwritable state root.

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

* pregate: count a module's full file name without a directory as a consumer

consumersOf anchored both the stem and the full basename on a path
separator, so run('install.sh') or bash install.sh without a directory
stopped counting for a multi-character old value. Only the bare stem
needs the separator; the full file name is specific on its own.

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

* pregate: a diff cannot clear itself as an environment failure; a taken --run id gets its own directory

Adversarial review of the environment classifier and the scratch layout:

- exit 127 and 'command not found' are missing-binary only when the
  command's own binary is off PATH (runner, guard and remote gate alike);
  a script the diff controls that exits 127 is the diff's failure
- a local lane with no test summary is classified from its output only
  when the runner was not seen starting a test file; a remote gate only
  from its first 40 lines, before any test output could stream
- environment.proof records launch (certain) or output (inferred); an
  output-inferred cause never says 'the diff is not at fault', its line
  says '(inferred from output)', and verify names such checks
- a runner killed by a signal is PREGATE_INCOMPLETE, not missing-binary
- a --run id whose directory exists takes <id>-2 .. -9 instead of
  sharing (and truncating) a live run's logs
- engine.* findings are never allowlistable; a pattern entry the scanner
  gave no position for is reported as not tested instead of silently inert
- docs: synopsis --run, no-policy lanes wording, verify's code list,
  guard exit 127 carve-out, PREGATE_SECRETS allowlist pointer, scratch

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

* docs(pregate): PREGATE_INCOMPLETE names a signal-killed runner; CHANGELOG lists every doc edit

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>
2026-10-26 20:17:41 +00:00

70 lines
3.4 KiB
TypeScript

/**
* pregate/run-scratch — one scratch directory per run under the state root
* (Lane D, GSTA-112): `<state root>/projects/<slug>/pregate/runs/<run id>/`.
* Every check keeps its working files there (the secrets scan's added-line
* copies, the lane and guard logs, the remote gate's file list) instead of
* os.tmpdir(): two pregates running at once never share a path (a taken
* `--run <id>` takes a numbered sibling), and the log a `fix:` line names
* survives a host that wipes /tmp between wakes (gbrain 16). The record names
* the directory. Runs older than the retention are pruned
* best-effort when a new run starts; the current run is never pruned.
*/
import { randomBytes } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
export const SCRATCH_RETENTION_MS = 7 * 24 * 3600 * 1000;
const MAX_RUN_ID = 96;
/** A filesystem-safe run id: `--run <id>` reduced to [A-Za-z0-9._-], else `<utc stamp>-<pid>-<4 hex>`. */
export function scratchRunId(requested?: string, pid: number = process.pid): string {
const cleaned = (requested ?? '').replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[.-]+|[.-]+$/g, '').slice(0, MAX_RUN_ID);
if (cleaned) return cleaned;
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
return `${stamp}-${pid}-${randomBytes(2).toString('hex')}`;
}
/** How many numbered siblings a taken run id may fall back to before the run refuses. */
const MAX_RUN_ID_SIBLINGS = 9;
/**
* `<runsRoot>/<run id>`, created 0700 (and its parents), after pruning old
* siblings. The leaf is created without `recursive`, so a directory that
* already exists (a second `--run <id>` beside a live run, or a finished one
* under the same name) is never shared: the run takes `<run id>-2` … `-9`
* instead, and the record names the directory it got.
*/
export function createScratch(runsRoot: string, runId: string): string {
fs.mkdirSync(runsRoot, { recursive: true, mode: 0o700 });
pruneScratch(runsRoot, runId);
for (let n = 1; n <= MAX_RUN_ID_SIBLINGS; n++) {
const dir = path.join(runsRoot, n === 1 ? runId : `${runId}-${n}`);
try { fs.mkdirSync(dir, { mode: 0o700 }); } catch (e: any) { if (e?.code === 'EEXIST') continue; throw e; }
try { fs.chmodSync(dir, 0o700); } catch { /* best effort on hosts without POSIX modes */ }
return dir;
}
throw new Error(`run directory ${path.join(runsRoot, runId)} and its ${MAX_RUN_ID_SIBLINGS - 1} numbered siblings all exist; choose another --run id`);
}
/** Remove sibling run directories older than the retention, never `keep` (the current run); every failure is ignored. */
export function pruneScratch(runsRoot: string, keep: string, now: number = Date.now()): void {
let names: string[];
try { names = fs.readdirSync(runsRoot); } catch { return; }
for (const name of names) {
if (name === keep) continue;
const dir = path.join(runsRoot, name);
try {
const st = fs.lstatSync(dir);
if (!st.isDirectory() || now - st.mtimeMs < SCRATCH_RETENTION_MS) continue;
fs.rmSync(dir, { recursive: true, force: true });
} catch { /* another run may hold it; leave it */ }
}
}
/** A fresh subdirectory of the run's scratch for one check (`lanes`, `guards`, `secrets`, `remote`). */
export function checkScratch(scratch: string, check: string): string {
const dir = path.join(scratch, check);
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
return dir;
}