Files
gstack/lib/ship-policy.ts
T
Garry Tanandcapy-ai[bot] 7edaf4f50a v1.91.98.0 fix: plan obligations meet repo gates; priced paid gates; machine-proved restamps (field wave C, items 14-17) (#3166)
* docs(designs): gstack field wave plan (2026-10-12) and the shared simulated Capy host fixture

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

* feat(pregate): price_command config key for the non-executing call plan a paid gate is priced from

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

* feat(pregate): repo guards a lane must pass, verified launch Bun and per-file serial runs in lanes, unknown flags exit 2

The guards check runs each .gstack/pregate.json guard whose globs match a
touched file in its own process with its own timeout (PREGATE_GUARD_FAILED,
never downgradable, incomplete without --allow-repo-commands). The lanes
runner and guards get the first Bun in $BUN_INSTALL/bin or ~/.bun/bin that
satisfies gstack's and the project's engines.bun; serial_patterns files run
one process each. price_file replaces the unreleased price_command key: a
paid gate is priced from a call-plan data file, never by running a command.

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

* fix(pricing): Sonnet 5.5, Gemini 3.x and gbrain embedding rows from fetched pages; Opus 4.7 at its current list price

Each new row carries its source URL and fetch date (2026-10-12). Embedding
models bill input only, so they live in EMBEDDING_PRICING beside PRICING
(eval arms never resolve to them); pricingRow also accepts gbrain's
provider:model ids (voyage:voyage-4). claude-opus-4-7 moves from 15/75 to the
5/25 the Anthropic pricing page lists; benchmark-runner's expected costs follow.

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

* feat(eval-plan): price-gate prices a paid gate from a call-plan data file, never executing it

gstack-eval-plan price-gate --gate "<exact command>" reads a GSTACK_EVAL_CALLS
v1 file (--plan-file, else the base policy's price_file at HEAD), checks its
binding (gate command string equality, head with only the plan file changed
since, every input's sha256 at HEAD), prices each call purpose (aggregate
totals) and each one-call reservation, and writes a receipt binding the price
to the gate, head, inputs and plan bytes. Unpriced models print unknown, never
zero (EVAL_PRICE_MISSING); a missing plan or no call lines is
EVAL_GATE_UNPRICED unless --allow-unpriced records an owner override; over
--cap-usd / --smoke-cap-usd is EVAL_GATE_OVER_CAP; malformed or partial files
refuse EVAL_DRY_RUN_FAILED and stale ones EVAL_PLAN_STALE. dry-run --cap adds
the same reservation-fits-cap check. Wire format, receipt and the reference
emitter (formatEvalCalls): docs/eval-calls.md.

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

* feat(eval-plan): the approval step prices every paid gate with price-gate

Step 4 runs gstack-eval-plan price-gate for each paid gate the plan names and
puts its GATE_PRICE line in the ask; the dry-run checklist documents
reservation-fits-cap under --cap.

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

* feat(sealed): sealed-output guard over every published commit, in the pre-push hook and gstack-pregate sealed

A repo with a sealed key in .gstack/pregate.json (base branch union the
pushed commit, so a push can tighten but never relax) refuses publishing
sealed paths, transcripts, quoted answers, seeds or local home paths to a
public or unknown destination (SEALED_OUTPUT_REFUSED); private warns.
Path rules see every added, renamed or copied path in each published
commit (git rev-list <local> --not --remotes=<remote>), text rules each
commit's added lines; waivers only from the base branch allow file; a
malformed policy fails closed; visibility caches public 24 h, private 1 h.

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

* fix(eval-plan): only an undeclared plan is unpriced; a missing price_file is stale, a bad base policy refuses

price-gate's EVAL_GATE_UNPRICED (and --allow-unpriced) now covers only no
plan declared at all or a plan with zero call lines. A declared price_file
missing from the HEAD commit is EVAL_PLAN_STALE (exit 1, not overridable); an
unresolvable origin/<base> is POLICY_SOURCE_UNAVAILABLE and an invalid
.gstack/pregate.json is PREGATE_CONFIG_INVALID (both exit 3).

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

* feat(restamp): the repo's stamp rule (stamp_files, literal stamp_patterns) and restamp_command in the staging worktree

stamp_files is an allowlist applied before every stamp rule; stamp_patterns
are bounded literal {version} templates shared by the restamp writer and the
tree receipt. restamp_command runs first in the staging worktree with the
target version (argument and GSTACK_RESTAMP_VERSION), needs a clean caller
tree, skips gstack's version writer and keeps output confinement; a failure
or a wrong version writes nothing. migrations { dir, id } is the policy key
gstack-pregate regate reads.

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

* feat(pregate): gstack-pregate regate --against <gated-head> with per-lane reuse and migration collisions

Reuse comes only from a tree receipt evaluated now, with a bundle produced
on the gated tree, the current execution identity and an unmoved, known
predecessor head. Each lane prints reusable/rerun/not-reusable; REGATE:
not-needed needs stamp-only, every bundle and --require lane reusable (a
stamp-sensitive lane reruns) and a finished migration lookup. Migration
numbers the branch adds are checked against the base tree and every other
open PR (gh pr list --limit 1000; 100-file lists refetched with gh api
--paginate); any lookup that cannot finish is unknown and blocks
not-needed (MIGRATION_COLLISION). Bare --against routes to the verb.
docs/pregate.md gains the regate section and the gbrain worked configs.

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

* feat(contract): lanes run gstack-pregate (and the sealed guard where configured) before done; handoff carries the gstack trailer and PREGATE: line

The coordinator contract gains a 'Before done' paragraph and the handoff
order GSTACK_RESULT, gstack: ok|fail <rev>, PREGATE:, gate summary; the
spec section is regenerated and docs/unattended.md states the same rule.
A new test pins the terms and their order; the byte-identity pins stay.

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

* v1.91.98.0: pregate guards, priced paid gates, regate and the sealed-output guard

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

* test: drop the helper-only Capy host fixture test (test-of-test ratchet); lane tests exercise the helper

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

* fix: price-gate --gate-file keeps free text out of the /eval-plan fence; shortest dependency traces; bounded test spawns

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

* ci(windows): curate out the new pregate/restamp tests that spawn bin/ shebang scripts through runBin

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-13 15:38:41 +00:00

422 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* ship-policy — the machine-readable repo ship policy (plan C1):
* `.gstack/ship-policy.json`, read from the committed base-branch copy
* (`origin/<base>`) so a PR cannot smuggle a command into its own gate.
*
* File presence never authorizes execution: `release_tool` runs only under
* `/ship` or `--allow-repo-commands` (bin/gstack-restamp enforces that; this
* module only names the commands a policy declares). Every repo-controlled
* path (`stamp_paths`, `release_outputs`, `changelog`, `mirror`) is
* containment-checked here: no absolute paths, no `..`, globs confined to the
* repository, regex size caps, and symlink-escape checks when a glob is
* resolved against a tree. Docs: docs/ship-policy.md. Callers: bin/gstack-
* ship-policy, lib/restamp.ts, lib/tree-receipt.ts, lib/ship-receipt.ts.
*/
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { RESULT_CODES, type ResultCodeName } from './result-codes';
export const SHIP_POLICY_PATH = '.gstack/ship-policy.json';
export const SHIP_POLICY_SCHEMA = 'https://github.com/garrytan/gstack/docs/ship-policy.md#schema-v1';
export type BumpPolicy = 'patch' | 'ask';
export type QueueMode = 'claim-at-open' | 'stamp-at-merge';
export type HistoryPolicy = 'merge-only' | 'squash-ok';
export interface ShipPolicy {
$schema?: string;
/** `patch` skips the MINOR/MAJOR prompts in /ship Step 12; `ask` keeps them. */
bump: BumpPolicy;
/** `stamp-at-merge`: no claim at PR open; stamp when the PR is next. `claim-at-open` is today's queue. */
queue_mode: QueueMode;
/** The repo's own release tool (for example `bun run release:restamp`); run only under /ship or --allow-repo-commands. */
release_tool: string | null;
/** Globs the release tool may write beyond the stamp files; any other output is a refusal. */
release_outputs: string[];
/** Globs where `(was X)`, `since: X` and `vX` mentions are rewritten on a restamp. */
stamp_paths: string[];
/** The CHANGELOG re-headed on a restamp; null for repos without one. */
changelog: string | null;
/** Path of a paired repo to restamp identically (tier 2). */
mirror: string | null;
/** `merge-only` refuses a squash or rebase plan in /ship. */
history: HistoryPolicy;
/** Each must be an ancestor of HEAD before push (`gstack-evidence ancestor`). */
preregistration_shas: string[];
/** Evidence labels that consume VERSION or generated bytes and always rerun on the stamped tree. */
stamp_sensitive_gates: string[];
/** Consumed by the plan reviews (D1). */
pr_count: { default_pr_count: number; split_requires_owner: boolean };
/** Maintainer logins for contributor-PR mode (E3); empty means "use push permission". */
maintainers: string[];
/** The repo's stamp rule: when non-empty, only these files can be stamps (the allowlist applies before every stamp rule). */
stamp_files: string[];
/** Literal stamp templates with one `{version}` slot, beside the built-in `(was X)`, `since: X`, `vX`; writer and classifier share them. */
stamp_patterns: string[];
/** The repo's own restamp, run first in the staging worktree with the target version; gstack's version writer is skipped. */
restamp_command: string | null;
/** Migration numbering: `{ dir, id }` where `id` is a regex with one capture group over the file name. */
migrations: { dir: string; id: string } | null;
}
export const DEFAULT_POLICY: ShipPolicy = {
bump: 'ask',
queue_mode: 'claim-at-open',
release_tool: null,
release_outputs: [],
stamp_paths: [],
changelog: 'CHANGELOG.md',
mirror: null,
history: 'squash-ok',
preregistration_shas: [],
stamp_sensitive_gates: [],
pr_count: { default_pr_count: 1, split_requires_owner: true },
maintainers: [],
stamp_files: [],
stamp_patterns: [],
restamp_command: null,
migrations: null,
};
export type PolicyKey = Exclude<keyof ShipPolicy, '$schema'>;
export const POLICY_KEYS = Object.keys(DEFAULT_POLICY) as PolicyKey[];
/** What each key means, printed by `init --explain` (JSON carries no comments). */
export const POLICY_EXPLANATIONS: Record<PolicyKey, string> = {
bump: 'patch | ask. `patch` skips the MINOR/MAJOR prompt in /ship Step 12.',
queue_mode: 'claim-at-open | stamp-at-merge. stamp-at-merge stamps when the PR is next (`gstack-restamp --next`), from the base branch version at that moment.',
release_tool: 'The repo’s own release command, run in an isolated staging worktree with an enumerated allowed-output set. Never runs without /ship or --allow-repo-commands.',
release_outputs: 'Globs the release tool may write beyond the version files, lockfiles, CHANGELOG and stamp_paths. Any other output refuses the restamp.',
stamp_paths: 'Globs (repo-relative, no `..`) where exact `(was X)`, `since: X` and `vX` mentions of the old version are rewritten.',
changelog: 'CHANGELOG path re-headed on a restamp (the PR’s `[Unreleased]` or old-version section moves to the top under the new version). null to skip.',
mirror: 'Relative path of a paired repo to restamp identically; writes are confined to it. null to skip.',
history: 'merge-only | squash-ok. merge-only makes /ship refuse a squash or rebase plan.',
preregistration_shas: 'Commits that must be ancestors of HEAD before push (preregistered evals); checked by `gstack-evidence ancestor`.',
stamp_sensitive_gates: 'Evidence labels (gstack-evidence --label) that read VERSION or generated bytes; the tree receipt never marks them reusable.',
pr_count: '{ default_pr_count, split_requires_owner }: the PR-count row in plan reviews.',
maintainers: 'GitHub logins treated as maintainers by /review contributor mode; empty uses repository push permission.',
stamp_files: 'The repo’s stamp rule: globs of the only files that may differ by a stamp (version file, manifests, plugin trees, the PR’s own since: rows). Empty keeps gstack’s per-file rules; set, it applies before every rule.',
stamp_patterns: 'Literal templates with exactly one {version} slot (at most 16, 128 characters each), for example `"version": "{version}"` or `since: \'{version}\'`; the restamp writer and the tree receipt use them beside (was X), since: X and vX.',
restamp_command: 'The repo’s own restamp, run first in gstack-restamp’s staging worktree (a clean checkout of HEAD) with the target version as its argument and in GSTACK_RESTAMP_VERSION; gstack’s version writer is skipped, output confinement still applies. Needs a clean caller tree and --allow-repo-commands.',
migrations: '{ dir, id }: the migration directory and a regex with one capture group for the number in a file name; `gstack-pregate regate` reports MIGRATION_COLLISION against the base branch and every open PR.',
};
export interface PolicyError { code: ResultCodeName; path: string; message: string }
const MAX_LIST = 64;
const MAX_PATTERN = 256;
const MAX_REGEX = 512;
const MAX_COMMAND = 512;
const SHA_RE = /^[0-9a-f]{7,64}$/;
const MAX_TEMPLATES = 16;
const MAX_TEMPLATE = 128;
export const VERSION_SLOT = '{version}';
function hasDotDot(p: string): boolean {
return p.split(/[\\/]+/).some(seg => seg === '..');
}
/** A repo-relative path or glob: no absolute paths, drive letters, `..` segments, NULs or control bytes. */
export function containedRelative(p: string): string | null {
if (typeof p !== 'string' || p.length === 0) return 'must be a non-empty string';
if (p.length > MAX_PATTERN) return `longer than ${MAX_PATTERN} characters`;
if (/[\0\r\n]/.test(p)) return 'contains a control character';
if (path.isAbsolute(p) || /^[A-Za-z]:[\\/]/.test(p) || p.startsWith('\\\\')) return 'must be repo-relative, not absolute';
if (p.startsWith('~')) return 'must not start with ~';
if (hasDotDot(p)) return 'must not contain a `..` segment';
return null;
}
/** Glob → anchored RegExp. `**` spans directories, `*` and `?` stay inside one segment. Size-capped. */
export function globToRegExp(glob: string): RegExp {
let out = '^';
for (let i = 0; i < glob.length; i++) {
const ch = glob[i]!;
if (ch === '*') {
if (glob[i + 1] === '*') {
i += 1;
if (glob[i + 1] === '/') { i += 1; out += '(?:.*/)?'; } else out += '.*';
} else out += '[^/]*';
} else if (ch === '?') out += '[^/]';
else out += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&');
}
out += '$';
if (out.length > MAX_REGEX) throw new Error(`pattern expands past ${MAX_REGEX} characters`);
return new RegExp(out);
}
export function matchesAny(globs: readonly string[], rel: string): boolean {
const norm = rel.replace(/\\/g, '/').replace(/^\.\//, '');
return globs.some(g => globToRegExp(g).test(norm));
}
function listErrors(key: string, value: unknown, out: PolicyError[], check: (item: string) => string | null): void {
if (!Array.isArray(value)) { out.push({ code: 'POLICY_INVALID', path: key, message: 'must be an array of strings' }); return; }
if (value.length > MAX_LIST) out.push({ code: 'POLICY_CONTAINMENT', path: key, message: `more than ${MAX_LIST} entries` });
value.forEach((item, i) => {
if (typeof item !== 'string') { out.push({ code: 'POLICY_INVALID', path: `${key}[${i}]`, message: 'must be a string' }); return; }
const why = check(item);
if (why) out.push({ code: 'POLICY_CONTAINMENT', path: `${key}[${i}]`, message: `${JSON.stringify(item)} ${why}` });
});
}
function globError(g: string): string | null {
const why = containedRelative(g);
if (why) return why;
try { globToRegExp(g); } catch (e: any) { return e.message; }
return null;
}
function templateErrors(value: unknown, out: PolicyError[]): void {
if (!Array.isArray(value)) { out.push({ code: 'POLICY_INVALID', path: 'stamp_patterns', message: 'must be an array of literal templates' }); return; }
if (value.length > MAX_TEMPLATES) out.push({ code: 'POLICY_CONTAINMENT', path: 'stamp_patterns', message: `more than ${MAX_TEMPLATES} templates` });
value.forEach((t, i) => {
const at = `stamp_patterns[${i}]`;
if (typeof t !== 'string') { out.push({ code: 'POLICY_INVALID', path: at, message: 'must be a string' }); return; }
if (t.split(VERSION_SLOT).length !== 2) out.push({ code: 'POLICY_INVALID', path: at, message: `${JSON.stringify(t)} must contain exactly one ${VERSION_SLOT}` });
else if (t.length > MAX_TEMPLATE || /[\0\r\n]/.test(t)) out.push({ code: 'POLICY_CONTAINMENT', path: at, message: `must be one line of at most ${MAX_TEMPLATE} characters` });
});
}
function migrationErrors(value: unknown, out: PolicyError[]): void {
const m = value as Record<string, unknown> | null;
if (!m || typeof m !== 'object' || Array.isArray(m)) { out.push({ code: 'POLICY_INVALID', path: 'migrations', message: 'must be { dir, id } or null' }); return; }
for (const k of Object.keys(m)) if (k !== 'dir' && k !== 'id') out.push({ code: 'POLICY_INVALID', path: `migrations.${k}`, message: 'unknown key' });
const dirWhy = typeof m.dir === 'string' ? containedRelative(m.dir) : 'must be a repo-relative directory';
if (dirWhy) out.push({ code: 'POLICY_CONTAINMENT', path: 'migrations.dir', message: dirWhy });
if (typeof m.id !== 'string' || !m.id || m.id.length > MAX_PATTERN) { out.push({ code: 'POLICY_INVALID', path: 'migrations.id', message: `must be a regex of 1-${MAX_PATTERN} characters` }); return; }
try {
const groups = new RegExp(`${m.id}|`).exec('')!.length - 1;
if (groups !== 1) out.push({ code: 'POLICY_INVALID', path: 'migrations.id', message: `needs exactly one capture group for the migration number (has ${groups})` });
} catch (e: any) { out.push({ code: 'POLICY_INVALID', path: 'migrations.id', message: `not a valid regex: ${e.message}` }); }
}
/** Schema and containment check of a parsed policy object. Unknown keys are errors (a typo must not silently mean "default"). */
export function validatePolicy(raw: unknown): PolicyError[] {
const errors: PolicyError[] = [];
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [{ code: 'POLICY_INVALID', path: '', message: 'policy must be a JSON object' }];
const obj = raw as Record<string, unknown>;
for (const key of Object.keys(obj)) {
if (key !== '$schema' && !POLICY_KEYS.includes(key as PolicyKey)) errors.push({ code: 'POLICY_INVALID', path: key, message: `unknown key; valid keys: ${POLICY_KEYS.join(', ')}` });
}
const oneOf = (key: string, values: readonly string[]) => {
if (key in obj && !values.includes(obj[key] as string)) errors.push({ code: 'POLICY_INVALID', path: key, message: `must be one of ${values.join(' | ')}, got ${JSON.stringify(obj[key])}` });
};
oneOf('bump', ['patch', 'ask']);
oneOf('queue_mode', ['claim-at-open', 'stamp-at-merge']);
oneOf('history', ['merge-only', 'squash-ok']);
if ('release_tool' in obj && obj.release_tool !== null) {
const t = obj.release_tool;
if (typeof t !== 'string' || !t.trim()) errors.push({ code: 'POLICY_INVALID', path: 'release_tool', message: 'must be a non-empty string or null' });
else if (t.length > MAX_COMMAND) errors.push({ code: 'POLICY_CONTAINMENT', path: 'release_tool', message: `longer than ${MAX_COMMAND} characters` });
else if (/[\0\r\n]/.test(t)) errors.push({ code: 'POLICY_CONTAINMENT', path: 'release_tool', message: 'contains a newline or NUL' });
}
if ('release_outputs' in obj) listErrors('release_outputs', obj.release_outputs, errors, globError);
if ('stamp_files' in obj) listErrors('stamp_files', obj.stamp_files, errors, globError);
if ('stamp_patterns' in obj) templateErrors(obj.stamp_patterns, errors);
if ('restamp_command' in obj && obj.restamp_command !== null) {
const t = obj.restamp_command;
if (typeof t !== 'string' || !t.trim()) errors.push({ code: 'POLICY_INVALID', path: 'restamp_command', message: 'must be a non-empty string or null' });
else if (t.length > MAX_COMMAND || /[\0\r\n]/.test(t)) errors.push({ code: 'POLICY_CONTAINMENT', path: 'restamp_command', message: `must be one line under ${MAX_COMMAND} characters` });
}
if ('migrations' in obj && obj.migrations !== null) migrationErrors(obj.migrations, errors);
if ('stamp_paths' in obj) listErrors('stamp_paths', obj.stamp_paths, errors, globError);
for (const key of ['changelog', 'mirror'] as const) {
if (!(key in obj) || obj[key] === null) continue;
if (typeof obj[key] !== 'string') { errors.push({ code: 'POLICY_INVALID', path: key, message: 'must be a string or null' }); continue; }
const p = obj[key] as string;
const why = key === 'mirror' ? mirrorPathError(p) : containedRelative(p);
if (why) errors.push({ code: 'POLICY_CONTAINMENT', path: key, message: `${JSON.stringify(p)} ${why}` });
}
if ('preregistration_shas' in obj) listErrors('preregistration_shas', obj.preregistration_shas, errors, s => (SHA_RE.test(s) ? null : 'is not a 7-64 character hex commit id'));
if ('stamp_sensitive_gates' in obj) listErrors('stamp_sensitive_gates', obj.stamp_sensitive_gates, errors, s => (/^[A-Za-z0-9_.:-]{1,64}$/.test(s) ? null : 'is not an evidence label ([A-Za-z0-9_.:-]{1,64})'));
if ('maintainers' in obj) listErrors('maintainers', obj.maintainers, errors, s => (/^[A-Za-z0-9-]{1,39}$/.test(s) ? null : 'is not a GitHub login'));
if ('pr_count' in obj) {
const pc = obj.pr_count as Record<string, unknown> | null;
if (!pc || typeof pc !== 'object' || Array.isArray(pc)) errors.push({ code: 'POLICY_INVALID', path: 'pr_count', message: 'must be { default_pr_count, split_requires_owner }' });
else {
if (!Number.isInteger(pc.default_pr_count) || (pc.default_pr_count as number) < 1) errors.push({ code: 'POLICY_INVALID', path: 'pr_count.default_pr_count', message: 'must be an integer >= 1' });
if (typeof pc.split_requires_owner !== 'boolean') errors.push({ code: 'POLICY_INVALID', path: 'pr_count.split_requires_owner', message: 'must be a boolean' });
for (const k of Object.keys(pc)) if (!['default_pr_count', 'split_requires_owner'].includes(k)) errors.push({ code: 'POLICY_INVALID', path: `pr_count.${k}`, message: 'unknown key' });
}
}
return errors;
}
/**
* A mirror is a sibling checkout, so leading `..` segments are allowed; an
* interior `..`, an absolute path or the repo itself (`.`) are not.
*/
function mirrorPathError(p: string): string | null {
if (!p || p.length > MAX_PATTERN) return 'must be a non-empty relative path';
if (/[\0\r\n]/.test(p)) return 'contains a control character';
if (path.isAbsolute(p) || /^[A-Za-z]:[\\/]/.test(p)) return 'must be relative to the repository root';
const segs = p.split(/[\\/]+/).filter(s => s && s !== '.');
if (segs.length === 0) return 'must not be the repository itself';
let i = 0;
while (i < segs.length && segs[i] === '..') i += 1;
if (segs.slice(i).includes('..')) return 'must not contain an interior `..` segment';
if (i === segs.length) return 'must name a directory, not only parents';
return null;
}
/** Merge a parsed file over the defaults, recording per-key provenance. */
export function mergePolicy(raw: Record<string, unknown> | null, sourceLabel: string): { policy: ShipPolicy; values: Record<string, string> } {
const policy: ShipPolicy = JSON.parse(JSON.stringify(DEFAULT_POLICY));
const values: Record<string, string> = {};
for (const key of POLICY_KEYS) {
if (raw && key in raw) { (policy as any)[key] = raw[key]; values[key] = sourceLabel; }
else values[key] = 'default';
}
if (raw && typeof raw.$schema === 'string') policy.$schema = raw.$schema;
return { policy, values };
}
export interface PolicySource { kind: 'base' | 'head' | 'none'; ref: string | null; sha: string | null; label: string }
export interface LoadedPolicy {
policy: ShipPolicy;
present: boolean;
source: PolicySource;
errors: PolicyError[];
values: Record<string, string>;
}
export type PolicyLoad = LoadedPolicy | { unavailable: true; base: string; detail: string };
function git(repoRoot: string, args: string[]): { ok: boolean; out: string; err: string } {
const r = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8', timeout: 30_000 });
return { ok: r.status === 0, out: (r.stdout ?? '').trim(), err: (r.stderr ?? '').trim() };
}
/** `--base` when given, else origin/HEAD's branch, else `main`. */
export function resolveBaseBranch(repoRoot: string, explicit?: string): string {
if (explicit && explicit.trim()) return explicit.trim().replace(/^origin\//, '');
const r = git(repoRoot, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD']);
if (r.ok && r.out) return r.out.replace(/^origin\//, '');
return 'main';
}
function finish(rawText: string | null, source: PolicySource): LoadedPolicy {
if (rawText === null) return { policy: mergePolicy(null, source.label).policy, present: false, source, errors: [], values: mergePolicy(null, source.label).values };
let raw: unknown;
try { raw = JSON.parse(rawText); }
catch (e: any) { return { policy: JSON.parse(JSON.stringify(DEFAULT_POLICY)), present: true, source, errors: [{ code: 'POLICY_INVALID', path: SHIP_POLICY_PATH, message: `not valid JSON: ${e.message}` }], values: {} }; }
const errors = validatePolicy(raw);
const merged = mergePolicy(errors.length ? null : (raw as Record<string, unknown>), source.label);
return { policy: merged.policy, present: true, source, errors, values: merged.values };
}
/**
* The reviewed policy: `.gstack/ship-policy.json` as committed on
* `origin/<base>`. An unresolvable base is `unavailable` (POLICY_SOURCE_
* UNAVAILABLE); the caller must never fall back to the working tree silently.
*/
export function loadPolicyFromBase(repoRoot: string, base: string): PolicyLoad {
const ref = `origin/${base.replace(/^origin\//, '')}`;
const sha = git(repoRoot, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
if (!sha.ok || !sha.out) return { unavailable: true, base: base.replace(/^origin\//, ''), detail: `${ref} is not a resolvable commit` };
const source: PolicySource = { kind: 'base', ref, sha: sha.out, label: `${ref}@${sha.out.slice(0, 12)}:${SHIP_POLICY_PATH}` };
const exists = git(repoRoot, ['cat-file', '-e', `${sha.out}:${SHIP_POLICY_PATH}`]);
if (!exists.ok) return finish(null, { ...source, kind: 'none', label: `${ref}@${sha.out.slice(0, 12)} (no ${SHIP_POLICY_PATH})` });
const show = git(repoRoot, ['show', `${sha.out}:${SHIP_POLICY_PATH}`]);
return finish(show.ok ? show.out : null, source);
}
/** The working-tree policy: unreviewed, labeled so in every receipt. */
export function loadPolicyFromHead(repoRoot: string): LoadedPolicy {
const file = path.join(repoRoot, SHIP_POLICY_PATH);
const source: PolicySource = { kind: 'head', ref: null, sha: null, label: `head (unreviewed) ${SHIP_POLICY_PATH}` };
if (!fs.existsSync(file)) return finish(null, { ...source, kind: 'none', label: `head (no ${SHIP_POLICY_PATH})` });
return finish(fs.readFileSync(file, 'utf8'), source);
}
/** The one POLICY_SOURCE_UNAVAILABLE line, with the base branch substituted into the fix clause. */
export function policyUnavailableLine(context: string, base: string, detail: string): string {
const row = RESULT_CODES.POLICY_SOURCE_UNAVAILABLE;
return `${context}: ${row.summary} (${detail}); fix: ${row.fix.replace('<base>', base)} (POLICY_SOURCE_UNAVAILABLE)`;
}
export function isUnavailable(load: PolicyLoad): load is { unavailable: true; base: string; detail: string } {
return (load as any).unavailable === true;
}
/** Commands a policy declares; their presence never authorizes execution. */
export function declaredCommands(policy: ShipPolicy): Array<{ key: string; command: string; cwd: string }> {
const out: Array<{ key: string; command: string; cwd: string }> = [];
if (policy.release_tool) out.push({ key: 'release_tool', command: policy.release_tool, cwd: '.' });
if (policy.release_tool && policy.mirror) out.push({ key: 'mirror.release_tool', command: policy.release_tool, cwd: policy.mirror });
if (policy.restamp_command) out.push({ key: 'restamp_command', command: policy.restamp_command, cwd: '.' });
return out;
}
/** The example `init` writes: every key at its default plus `$schema`. */
export function examplePolicyJson(overrides: Partial<ShipPolicy> = {}): string {
const body: Record<string, unknown> = { $schema: SHIP_POLICY_SCHEMA };
for (const key of POLICY_KEYS) body[key] = key in overrides ? (overrides as any)[key] : (DEFAULT_POLICY as any)[key];
return JSON.stringify(body, null, 2) + '\n';
}
export function explainPolicy(): string {
return POLICY_KEYS.map(key => `${key}: ${POLICY_EXPLANATIONS[key]} default: ${JSON.stringify((DEFAULT_POLICY as any)[key])}`).join('\n') + '\n';
}
/** `key=value (source)` lines plus the `policy:` provenance line, as show-effective prints them. */
export function renderEffective(load: LoadedPolicy, opts: { base?: string } = {}): string {
const lines = [`policy: ${load.source.kind === 'base' ? 'base' : load.source.kind === 'head' ? 'head (unreviewed)' : 'none (defaults)'} ${load.source.label}`];
for (const key of POLICY_KEYS) lines.push(`${key}=${JSON.stringify((load.policy as any)[key])} (${load.values[key] ?? 'default'})`);
return lines.join('\n') + '\n' + shipConsequences(load.policy, opts.base ?? '<base>').map(l => `ship: ${l}`).join('\n') + '\n';
}
/** What `/ship` does differently under this policy: the workflow prose the skill prints instead of carrying (docs/ship-policy.md#ship). */
export function shipConsequences(policy: ShipPolicy, base: string): string[] {
const out: string[] = [];
if (policy.history === 'merge-only') out.push('history merge-only: never rebase, squash or force-push this branch; `gstack-ship-policy check-history --plan <merge|squash|rebase>` refuses a rewrite plan (HISTORY_POLICY_VIOLATION)');
if (policy.queue_mode === 'stamp-at-merge') {
out.push(`queue_mode stamp-at-merge: Step 12 items 1-4 are replaced by \`gstack-restamp --next --base ${base} --pr <pr-number> --expect-base <base-sha>\` when this PR is next in the queue (\`--after <predecessor-pr>\` gates ahead in a throwaway worktree when it is not); follow its next: line; claims are ignored`);
out.push('gate-ahead: with .gstack/tmp/gate-ahead.json present, Step 16 reuses only the lanes `gstack-tree-receipt --from .gstack/tmp/gate-ahead.json --predecessor-pr <pr> --bundle <bundle> --json > .gstack/tmp/tree-receipt.json` marks reusable; stamp-sensitive and not-eligible lanes run live; `--force-gate <reason>` is the coordinator\'s call');
} else out.push('queue_mode claim-at-open: Step 12 runs as written (claims through gstack-next-version)');
out.push(policy.bump === 'patch' ? 'bump patch: Step 12 never asks for MINOR or MAJOR; PATCH or MICRO by size' : 'bump ask: Step 12 asks for MINOR and MAJOR as written');
if (policy.preregistration_shas.length) out.push(`preregistration: \`gstack-evidence ancestor --from-policy --base ${base}\` must print only ANCESTOR: ok before push; the ship receipt records the result`);
if (policy.release_tool) out.push(`release_tool: \`${policy.release_tool}\` runs only inside gstack-restamp (staging worktree, outputs confined to release_outputs)`);
return out;
}
/**
* Files under `globs` inside a committed tree, as `git ls-tree` lists them.
* Symlinks (mode 120000) never match: a stamp write through a link would
* escape the tree the glob confines it to.
*/
export function listTreeFiles(repoRoot: string, treeish: string): Array<{ path: string; mode: string }> {
const r = git(repoRoot, ['ls-tree', '-r', '-z', treeish]);
if (!r.ok) return [];
return r.out.split('\0').filter(Boolean).map(line => {
const [meta, p] = line.split('\t');
return { path: p ?? '', mode: meta?.split(' ')[0] ?? '' };
}).filter(e => e.path);
}
export function stampFilesInTree(repoRoot: string, treeish: string, globs: readonly string[]): string[] {
if (globs.length === 0) return [];
return listTreeFiles(repoRoot, treeish).filter(e => e.mode !== '120000' && matchesAny(globs, e.path)).map(e => e.path);
}
/**
* A working-tree path a stamp may write: inside `root` after realpath (a
* symlink out of the repository is refused even when the glob matched).
*/
export function assertInsideRoot(root: string, rel: string): string | null {
const realRoot = fs.realpathSync(root);
const abs = path.resolve(realRoot, rel);
if (abs !== realRoot && !abs.startsWith(realRoot + path.sep)) return `${rel} resolves outside ${root}`;
let probe = abs;
while (!fs.existsSync(probe)) {
const parent = path.dirname(probe);
if (parent === probe) break;
probe = parent;
}
let real: string;
try { real = fs.realpathSync(probe); } catch { return null; }
if (real !== realRoot && !real.startsWith(realRoot + path.sep)) return `${rel} resolves through a symlink to outside ${root}`;
return null;
}