rvlib-shipkit 0.36.0 → 0.37.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -1
- package/package.json +1 -1
- package/src/core/discover-checks.ts +6 -2
- package/src/core/floor.ts +18 -0
- package/src/core/task-runner.ts +6 -2
- package/src/hooks/leak-guard.ts +313 -38
- package/src/hooks/run-hooks.ts +55 -7
- package/src/init-repo.ts +5 -1
- package/src/ledger/git-key.ts +5 -2
- package/src/repo-config.ts +9 -2
- package/src/rules/SK004-gitignore-policy.ts +95 -3
- package/src/rules/SK008-reviewer-agent.ts +3 -3
- package/src/rules/SK013-one-way-in-policy.ts +54 -18
package/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,21 @@
|
|
|
1
|
-
|
|
1
|
+
## 0.37.1
|
|
2
|
+
|
|
3
|
+
- **SK013 no longer wires a Bash guard that cannot run.** A consumer that runs shipkit from a checkout by ABSOLUTE path, with no `rvlib-shipkit` dependency and no `node_modules`, carried `bun "$CLAUDE_PROJECT_DIR/node_modules/rvlib-shipkit/src/hooks/one-way-in-guard.ts"`, so every Bash call of every agent in that repo printed `Module not found`, while the rule only WARNed `not installed yet, bun install` and the gate stayed green. `bun install` installs nothing in a repo that declares no dependency, so that WARN now fires only when `package.json` declares `rvlib-shipkit`, and otherwise the missing guard FAILs. `guardCommand` glued an absolute cli path under the project dir (`$CLAUDE_PROJECT_DIR//Users/...`), and it now writes the absolute path alone. The lint resolves an absolute guard path too, where it used to pass any form it could not read. The fixer, which `shipkit ci` runs by default, repoints a guard whose script does not exist at the copy the repo actually runs: the cli path its scripts name when a guard sits beside it (a repo's own `src/cli.ts` matches the same script shape), else its declared dependency, else the shipkit doing the fixing, which is what the launcher falls back to. A guard that resolves, or waits on a declared install, stays as written, and a target that does not resolve itself is never written.
|
|
4
|
+
|
|
5
|
+
- **The gate says what an undeclared floor costs, and declares nothing on a repo's behalf.** A slot with no `inputs`, no `ttl` and no `never` is measured every run, and `resolveFloor` declares none for any of the five, so a second gate over an UNCHANGED tree re-runs everything: 17.4s per gate measured on a field repo, 14.8s of it the test task, against the shape an agent actually runs (verify, commit, verify again). Nothing pointed at the repair either, since `shipkit init` scaffolded no `defaults` block, so a fresh repo stayed that way until somebody read `shipkit keys --burn-down` and knew what to write. `init` now scaffolds the commented `defaults` line naming the fix.
|
|
6
|
+
- **What this release deliberately does NOT do is auto-declare `['.']` for those slots**, and the reason is worth more than the speed. `['.']` is not the work tree, it is the TRACKED tree: `git-scan` force-adds only a path a declaration NAMES, so an ignored path enters a key by NAME and never by content. Every floor task reads ignored content in an ordinary repo, a suite through its fixtures and its local database, tsc and the two graph walks through generated types and `node_modules`. Measured on a scratch repo: warm the ledger with two green runs, break a gitignored file the suite reads, and the third gate prints `ok: test (replayed, 0.0s saved)` over a suite that exits 1. `tests/floor-cache-safety.test.ts` holds that repro, watched fail, with the declaring lookalike beside it still replaying. Speed here is bought by a REPO declaring what its own tasks read, which is the only declaration with a real hit rate anyway.
|
|
7
|
+
- Two doc sentences that argued the other way are corrected where they live: `TaskDef.inputs` no longer says `['.']` "cannot under declare", and the `git-key` header no longer promises the never-cached default is the whole safety story without naming who can declare on a unit's behalf.
|
|
8
|
+
|
|
9
|
+
- **SK004 asks git what is ignored, instead of trusting the text it wrote.** The rule compared the managed block to the expected string and stopped there, so it reported `current` over a repo where git ignored none of what the block claims. Two ways that happens, both from the field. A bare `printf 'work/\n' >> .gitignore` onto a file whose last line carried no terminator fuses the two into `sourcework/`, which ignores neither path, and the file ends with a newline afterwards so nothing textual looks wrong (cost in that repo: 1.1GB and 700 screenshots pushed across four commits, a rejected push, and a `git filter-repo` rewrite). And a negation below the block is the DOCUMENTED override, so nothing stopped one landing on `.rv-private/`, the directory holding the keyword list the leak guard itself reads. Every policy pattern is now probed through one `git check-ignore --stdin` call and a contradiction FAILs, naming the pattern and the `check-ignore -v` that reports the winning line. The three venue folders stay exempt, because unignoring one is how a live listing ships. A `.gitignore` that does not end with a newline is now a FAIL of its own, since that missing byte is the trap rather than a cosmetic defect, and `--fix` terminates the file even when the block is current. Outside a git repo the rule states the block went unverified rather than reporting a green nothing measured.
|
|
10
|
+
|
|
11
|
+
- **A check declaration the gate's own formatter wrote was unreadable to the gate.** `BLOCKING_RE` and `GATE_RE` both ended in `\s*$`, and oxfmt (which the fix pass RUNS) terminates every statement with a semicolon. So `shipkit ci` reformatted `export const blocking = false` into `export const blocking = false;`, then failed the file with `does not declare whether it blocks` and printed as the fix the exact line it had just written. Two repos in the field shipped `// oxfmt-ignore` workarounds to get a green gate. The worse half was silent: a `export const gate = false;` was not read either, so a world probe that had opted out of the gate quietly re-entered it, and a repo could declare fifteen off-gate probes and watch all fifteen run. Both readers now accept an optional `as const` and an optional semicolon through one shared tail. `INPUTS_RE`, `TTL_RE` and `NEVER_RE` never anchored the line end, so they were never affected. Pinned by three tests: the formatted shapes, the gate opt-out in both spellings, and a CONTROL that the unterminated shapes and a silent file still read as before.
|
|
12
|
+
|
|
13
|
+
## 0.37.0
|
|
14
|
+
|
|
15
|
+
- **The leak guard grew an allowlist, because it had no way to be wrong.** Every keyword matched as a case-insensitive substring over the staged added lines, so a four letter row landed inside a real npm dependency name and inside a base64 integrity hash in `bun.lock`, and that lockfile became uncommittable forever with no per repo override of any kind. The only way past was `--no-verify`, which disarms every other gate too, so the guard was training people to switch it off. Two answers ship, in this order. A `w:<word>` keyword row now matches on word boundaries spelled in unicode rather than with `\b`, which is ascii only and would let an accented name match inside a longer accented word: that makes the PATTERN smarter rather than the guard looser, and it is opt in, so a keyword file that carries no `w:` row behaves exactly as before. Then `repo.config.ts hooks.leakAllow` (with the machine-global twin `~/.rv-private/leak-allow.json`) declares a known-good match: `paths` names the files, `patterns` names the keywords, and `inHashes` narrows to a match that falls inside a content hash, where the bytes are an address rather than prose. `paths` plus `patterns` is the shape to reach for, because it waives one keyword in named files while the same keyword still blocks everywhere else, and a single occurrence outside a hash refuses an `inHashes` entry outright.
|
|
16
|
+
- **A waiver is never silent.** Each entry carries a required `reason`, every waiver that fires PRINTS with that reason and the entry that owns it (on the FAIL path too, so a partial waive is visible beside what still blocks), and `hooks list` counts the declared waivers. An entry with no reason, with neither `paths` nor `patterns`, or with an unknown key dies at config load through `defineRepo`, and an allowlist file that will not parse waives nothing and WARNs, so a broken waiver leaves the guard stricter rather than looser.
|
|
17
|
+
- **A tracked allowlist never becomes a copy of the private keyword list.** An entry names a keyword by FINGERPRINT (`fp:` plus 12 hex of its sha256), printed beside every hit, which is how a waiver gets written without anyone typing the private word into git. `repo.config.ts` takes the fingerprint form alone and refuses a literal by name. The private machine-global file may use either. The honest limit is stated in the source: a fingerprint stops the list being read off a tracked file and is no defence against a targeted guess.
|
|
18
|
+
- The staged diff is now parsed per FILE (`addedLinesByFile`), so a hit says which file it came from and a waiver can name one. Which file a line belongs to is decided by COUNTING each hunk body from the size its header declares, never by trusting a `+++ …` line wherever it appears: an added line whose own text starts with `++` reaches the parser looking exactly like a file header, and believing one inside a hunk would let ordinary looking text drop every added line after it (`++ /dev/null`) or file the rest under a name a waiver covers (`++ b/bun.lock`). Substring rows match through a case-insensitive regex instead of `indexOf` over a lowercased copy, because one unicode character lowercases into two and slides every offset after it, and those offsets are what decide whether a match sits inside a content hash. A content-hash span is consulted ONLY by an entry that asked for `inHashes`: an api token is also a long base64 run, so skipping those spans by default would silence the guard on the exact shape it exists to catch.
|
|
2
19
|
|
|
3
20
|
## 0.36.0
|
|
4
21
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rvlib-shipkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.37.1",
|
|
4
4
|
"funding": "https://github.com/sponsors/rvion",
|
|
5
5
|
"description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
|
|
6
6
|
"license": "MIT",
|
|
@@ -42,7 +42,11 @@ export type DiscoveredCheck = {
|
|
|
42
42
|
|
|
43
43
|
// read without importing: checks run as subprocesses, and discovery must stay
|
|
44
44
|
// pure-fs. `export const blocking = true` (or false), nothing cleverer.
|
|
45
|
-
|
|
45
|
+
// the tail is the gate's OWN formatter: oxfmt terminates every statement, and
|
|
46
|
+
// the fix pass runs it, so a reader that refuses a semicolon refuses the shape
|
|
47
|
+
// the gate itself just wrote.
|
|
48
|
+
const TAIL = String.raw`(?:\s+as\s+const)?\s*;?\s*$`
|
|
49
|
+
const BLOCKING_RE = new RegExp(String.raw`^\s*export\s+const\s+blocking\s*(?::\s*boolean\s*)?=\s*(true|false)` + TAIL, 'm')
|
|
46
50
|
// `export const inputs = ['a', 'b']`, single or multi line. Anything cleverer
|
|
47
51
|
// (a computed list, a spread) is deliberately unreadable here and lands as no
|
|
48
52
|
// declaration, which means never cached: a scanner guessing at a program is
|
|
@@ -53,7 +57,7 @@ const NEVER_RE = /^\s*export\s+const\s+never\s*(?::\s*string\s*)?=\s*['"]([^'"]+
|
|
|
53
57
|
// `export const gate = false` takes a check out of `shipkit ci`. Only the
|
|
54
58
|
// literal false opts out: anything unreadable stays in the gate, because a
|
|
55
59
|
// scanner that guesses its way to SKIPPING a check is how coverage disappears
|
|
56
|
-
const GATE_RE =
|
|
60
|
+
const GATE_RE = new RegExp(String.raw`^\s*export\s+const\s+gate\s*(?::\s*boolean\s*)?=\s*false` + TAIL, 'm')
|
|
57
61
|
const STRING_ITEM_RE = /['"]([^'"]+)['"]/g
|
|
58
62
|
|
|
59
63
|
export function discoverChecks(root: string): DiscoveredCheck[] {
|
package/src/core/floor.ts
CHANGED
|
@@ -68,6 +68,24 @@ function builtinFloorTask(id: string, root: string): { task: TaskDef; source: Fl
|
|
|
68
68
|
return `no package.json '${id}' script`
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
// NOTHING here declares a read set on a repo's behalf, and that is a decision
|
|
72
|
+
// rather than an omission. A floor slot with no `inputs` is measured every run,
|
|
73
|
+
// which costs speed and never correctness (ledger/git-key.ts owns that default).
|
|
74
|
+
//
|
|
75
|
+
// The tempting shortcut is `['.']`, on the reasoning that the whole tree cannot
|
|
76
|
+
// under declare. It can. `['.']` is the TRACKED tree: git-scan force-adds only a
|
|
77
|
+
// path a declaration NAMES, so an ignored path enters the key by name and never
|
|
78
|
+
// by content. Every floor slot reads ignored content in an ordinary repo, a
|
|
79
|
+
// suite through its fixtures and a local database, tsc and the graph walks
|
|
80
|
+
// through generated types and node_modules. Shipped for one day, that made the
|
|
81
|
+
// gate print `ok: test (replayed)` over a suite that exits 1
|
|
82
|
+
// (tests/floor-cache-safety.test.ts holds the repro).
|
|
83
|
+
//
|
|
84
|
+
// Speed here is bought by a REPO saying what its own tasks read, in
|
|
85
|
+
// `defaults`, which is the only declaration with a real hit rate anyway.
|
|
86
|
+
// `shipkit init` scaffolds the line, `shipkit keys --burn-down` names what is
|
|
87
|
+
// still undeclared, and the gate prints the cost of it once per run.
|
|
88
|
+
|
|
71
89
|
export function resolveFloor(c: RepoConfig, root: string): FloorResolution {
|
|
72
90
|
const pkgPath = join(root, 'package.json')
|
|
73
91
|
let scripts: Record<string, string> = {}
|
package/src/core/task-runner.ts
CHANGED
|
@@ -36,8 +36,12 @@ export type TaskDef = {
|
|
|
36
36
|
* speed and never correctness. A list MISSING a path the command reads is
|
|
37
37
|
* the one that costs correctness: the key cannot move when that path
|
|
38
38
|
* changes, so the answer replays over a tree it no longer describes. Over
|
|
39
|
-
* declaring costs a re-run, under declaring is a false green
|
|
40
|
-
*
|
|
39
|
+
* declaring costs a re-run, under declaring is a false green. `['.']` is
|
|
40
|
+
* the whole TRACKED tree, and it is NOT a catch-all: an ignored path
|
|
41
|
+
* enters a key by name and never by content, so a task reading a
|
|
42
|
+
* gitignored fixture, a built dir or a .env under declares with `['.']`
|
|
43
|
+
* exactly as it would with a narrow list. A task that reads the network,
|
|
44
|
+
* a keychain or the clock declares `ttl`, never `inputs`. A task carries no code
|
|
41
45
|
* closure, so the script it runs belongs in here too.
|
|
42
46
|
* Declaring both this and `ttl` is a registration failure. */
|
|
43
47
|
inputs?: string[]
|
package/src/hooks/leak-guard.ts
CHANGED
|
@@ -4,10 +4,23 @@
|
|
|
4
4
|
//
|
|
5
5
|
// the keyword file is deliberately untracked (a list of things to grep for
|
|
6
6
|
// is itself private). absent file → loud skip, never a silent pass.
|
|
7
|
+
//
|
|
8
|
+
// a keyword matches as a SUBSTRING, which is what catches a private name
|
|
9
|
+
// glued into a hostname or an identifier. The cost is a false positive a repo
|
|
10
|
+
// cannot escape: a four letter row lands inside a dependency name or inside a
|
|
11
|
+
// base64 integrity hash in a lockfile, and that lockfile is uncommittable
|
|
12
|
+
// forever. A guard that cries wolf is a guard people answer with --no-verify,
|
|
13
|
+
// which protects nothing. Two answers, in this order. `w:<word>` rows match on
|
|
14
|
+
// word boundaries, which makes the PATTERN smarter rather than the guard
|
|
15
|
+
// looser. An ALLOWLIST waives a known-good match in a named context, and every
|
|
16
|
+
// waiver that fires is PRINTED with its reason, so a reader sees what was let
|
|
17
|
+
// through and why.
|
|
18
|
+
import { createHash } from 'crypto'
|
|
7
19
|
import { existsSync, readFileSync } from 'fs'
|
|
8
|
-
import { join } from 'path'
|
|
20
|
+
import { basename, join } from 'path'
|
|
9
21
|
|
|
10
|
-
export type LeakHit = { where: string; pattern: string }
|
|
22
|
+
export type LeakHit = { where: string; pattern: string; file?: string }
|
|
23
|
+
export type LeakWaiver = LeakHit & { reason: string; source: string }
|
|
11
24
|
|
|
12
25
|
/** what a hit REPORTS. The matched string is private by construction, and a
|
|
13
26
|
* gate that prints it verbatim puts it in a terminal, a CI log and any paste
|
|
@@ -15,16 +28,66 @@ export type LeakHit = { where: string; pattern: string }
|
|
|
15
28
|
export const maskPattern = (p: string): string =>
|
|
16
29
|
p.startsWith('re:') || p.length <= 6 ? p : `${p.slice(0, 3)}…${p.slice(-2)} (${p.length} chars)`
|
|
17
30
|
|
|
31
|
+
/** a stable name for a pattern that does not CARRY it, so a tracked allowlist
|
|
32
|
+
* points at a private keyword without becoming a copy of the private keyword
|
|
33
|
+
* list. Printed beside every hit, which is how a waiver gets written without
|
|
34
|
+
* anyone typing the word.
|
|
35
|
+
*
|
|
36
|
+
* Honest scope: this stops the list being READ off a tracked file. It is no
|
|
37
|
+
* defence against a targeted guess, because a short keyword has a small
|
|
38
|
+
* search space and whoever already suspects the word can confirm it. */
|
|
39
|
+
export const patternFingerprint = (pattern: string): string =>
|
|
40
|
+
'fp:' + createHash('sha256').update(pattern.toLowerCase()).digest('hex').slice(0, 12)
|
|
41
|
+
|
|
42
|
+
/** one known-good match a repo (or this machine) waives.
|
|
43
|
+
*
|
|
44
|
+
* `paths` alone waives every pattern in those files. `patterns` alone waives
|
|
45
|
+
* those patterns everywhere. The PAIR is the shape to reach for: this
|
|
46
|
+
* pattern, in these files, while the same pattern still blocks anywhere else.
|
|
47
|
+
* `inHashes` narrows further, to a match that lands inside a content hash. */
|
|
48
|
+
export type LeakAllowEntry = {
|
|
49
|
+
/** why this match is known good. Printed every time the waiver fires, so
|
|
50
|
+
* a reader months later can judge it. Required. */
|
|
51
|
+
reason: string
|
|
52
|
+
/** staged paths this entry covers: a repo-relative path, a bare basename
|
|
53
|
+
* ('bun.lock'), or a 'dir/' prefix. Absent = every target, the commit
|
|
54
|
+
* message included. */
|
|
55
|
+
paths?: string[]
|
|
56
|
+
/** the patterns this entry waives, named by FINGERPRINT (`fp:` + 12 hex,
|
|
57
|
+
* printed beside every hit). Absent = every pattern, on `paths` alone.
|
|
58
|
+
* A TRACKED source (repo.config.ts) takes the fingerprint form only: a
|
|
59
|
+
* literal there puts the private keyword into git. */
|
|
60
|
+
patterns?: string[]
|
|
61
|
+
/** waive only a match that falls inside a content hash (an npm `sha512-…`
|
|
62
|
+
* integrity field, a 40+ char base64 run). The bytes of a content address
|
|
63
|
+
* are arithmetic, not prose, so a keyword inside one is noise. The same
|
|
64
|
+
* keyword on a normal line of the same file still blocks. */
|
|
65
|
+
inHashes?: boolean
|
|
66
|
+
}
|
|
67
|
+
|
|
18
68
|
export type LeakGuardResult =
|
|
19
69
|
| { status: 'skipped'; reason: string }
|
|
20
|
-
| { status: 'clean'; patterns: number }
|
|
21
|
-
| { status: 'hit'; hits: LeakHit[]; patterns: number }
|
|
70
|
+
| { status: 'clean'; patterns: number; waived: LeakWaiver[]; notes: string[] }
|
|
71
|
+
| { status: 'hit'; hits: LeakHit[]; patterns: number; waived: LeakWaiver[]; notes: string[] }
|
|
72
|
+
|
|
73
|
+
export type ParsedPatterns = {
|
|
74
|
+
substrings: string[]
|
|
75
|
+
words: { word: string; re: RegExp }[]
|
|
76
|
+
regexes: { row: string; re: RegExp }[]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const escapeRegex = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
22
80
|
|
|
23
81
|
/** Parse the keyword file: blank lines and `#` comments dropped, `re:<pattern>`
|
|
24
|
-
* rows become case-insensitive regexes,
|
|
25
|
-
* substring.
|
|
26
|
-
|
|
82
|
+
* rows become case-insensitive regexes, `w:<word>` rows match on word
|
|
83
|
+
* boundaries, everything else a case-insensitive substring.
|
|
84
|
+
*
|
|
85
|
+
* A `w:` row REPORTS as the bare word, so its mask and its fingerprint are
|
|
86
|
+
* the same with or without the prefix: adding the prefix to a row never
|
|
87
|
+
* invalidates a waiver already written against it. */
|
|
88
|
+
export function parsePatterns(text: string): ParsedPatterns {
|
|
27
89
|
const substrings: string[] = []
|
|
90
|
+
const words: { word: string; re: RegExp }[] = []
|
|
28
91
|
const regexes: { row: string; re: RegExp }[] = []
|
|
29
92
|
for (const raw of text.split('\n')) {
|
|
30
93
|
const row = raw.trim()
|
|
@@ -32,24 +95,134 @@ export function parsePatterns(text: string): { substrings: string[]; regexes: {
|
|
|
32
95
|
if (row.startsWith('re:')) {
|
|
33
96
|
try { regexes.push({ row, re: new RegExp(row.slice(3), 'i') }) }
|
|
34
97
|
catch { /* a malformed row must not disable the whole guard */ }
|
|
98
|
+
} else if (row.startsWith('w:')) {
|
|
99
|
+
const word = row.slice(2).trim().toLowerCase()
|
|
100
|
+
// \b is ascii-only in JS, which lets an accented name match inside
|
|
101
|
+
// a longer accented word: the boundary is spelled in unicode
|
|
102
|
+
if (word !== '') words.push({ word, re: new RegExp(`(?<![\\p{L}\\p{N}_])${escapeRegex(word)}(?![\\p{L}\\p{N}_])`, 'giu') })
|
|
35
103
|
} else substrings.push(row.toLowerCase())
|
|
36
104
|
}
|
|
37
|
-
return { substrings, regexes }
|
|
105
|
+
return { substrings, words, regexes }
|
|
38
106
|
}
|
|
39
107
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
108
|
+
type Span = [start: number, end: number]
|
|
109
|
+
|
|
110
|
+
// a substring row is matched by a case-insensitive REGEX rather than by
|
|
111
|
+
// indexOf over a lowercased copy: one unicode character (İ) lowercases into
|
|
112
|
+
// two, which slides every offset after it, and the offsets are what decide
|
|
113
|
+
// whether a match sits inside a content hash. Compiled once per keyword.
|
|
114
|
+
const subRe = new Map<string, RegExp>()
|
|
115
|
+
const substringRegex = (s: string): RegExp => {
|
|
116
|
+
const hit = subRe.get(s)
|
|
117
|
+
if (hit != null) return hit
|
|
118
|
+
const re = new RegExp(escapeRegex(s), 'gi')
|
|
119
|
+
subRe.set(s, re)
|
|
120
|
+
return re
|
|
44
121
|
}
|
|
45
122
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
123
|
+
/** every occurrence of every pattern, keyed by the id a hit reports. */
|
|
124
|
+
function occurrences(haystack: string, p: ParsedPatterns): Map<string, Span[]> {
|
|
125
|
+
const found = new Map<string, Span[]>()
|
|
126
|
+
const push = (id: string, span: Span): void => {
|
|
127
|
+
const prev = found.get(id)
|
|
128
|
+
if (prev == null) found.set(id, [span])
|
|
129
|
+
else prev.push(span)
|
|
130
|
+
}
|
|
131
|
+
for (const s of p.substrings) for (const m of haystack.matchAll(substringRegex(s))) push(s, [m.index, m.index + m[0].length])
|
|
132
|
+
for (const w of p.words) for (const m of haystack.matchAll(w.re)) push(w.word, [m.index, m.index + m[0].length])
|
|
133
|
+
for (const r of p.regexes) {
|
|
134
|
+
const g = new RegExp(r.re.source, r.re.flags.includes('g') ? r.re.flags : r.re.flags + 'g')
|
|
135
|
+
for (const m of haystack.matchAll(g)) push(r.row, [m.index, m.index + m[0].length])
|
|
136
|
+
}
|
|
137
|
+
return found
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// an npm/yarn integrity field, and any long base64 run. ONLY an entry that
|
|
141
|
+
// asked for `inHashes` consults this: an api token is also a long base64 run,
|
|
142
|
+
// so skipping these by default would be the guard going quiet on the exact
|
|
143
|
+
// shape it exists to catch.
|
|
144
|
+
const HASH_SPAN = /(?:sha\d{1,3}|md5)-[A-Za-z0-9+/=]{16,}|[A-Za-z0-9+/]{40,}={0,2}/gi
|
|
145
|
+
|
|
146
|
+
const hashSpans = (text: string): Span[] => [...text.matchAll(HASH_SPAN)].map((m): Span => [m.index, m.index + m[0].length])
|
|
147
|
+
|
|
148
|
+
const inside = (span: Span, spans: Span[]): boolean => spans.some(([a, b]) => span[0] >= a && span[1] <= b)
|
|
149
|
+
|
|
150
|
+
/** a path selector: a repo-relative path, a bare basename, or a 'dir/' prefix. */
|
|
151
|
+
function pathCovered(file: string | undefined, selectors: string[]): boolean {
|
|
152
|
+
if (file == null) return false
|
|
153
|
+
return selectors.some((s) => s === file || (s.endsWith('/') && file.startsWith(s)) || (!s.includes('/') && basename(file) === s))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export type LoadedAllow = LeakAllowEntry & { source: string }
|
|
157
|
+
|
|
158
|
+
/** what the guard reads: one file's added lines, one staged path, or a text
|
|
159
|
+
* the caller handed in (a commit message). */
|
|
160
|
+
export type ScanTarget = { where: string; content: string; file?: string }
|
|
161
|
+
|
|
162
|
+
/** the whole decision, with no git and no disk in it: which matches block,
|
|
163
|
+
* which are waived, by whom, and why. */
|
|
164
|
+
export function evaluateLeak(targets: ScanTarget[], p: ParsedPatterns, allow: LoadedAllow[]): { hits: LeakHit[]; waived: LeakWaiver[] } {
|
|
165
|
+
const hits: LeakHit[] = []
|
|
166
|
+
const waived: LeakWaiver[] = []
|
|
167
|
+
// one row per distinct (where, file, pattern): a keyword hit 40 times in
|
|
168
|
+
// one file is one finding
|
|
169
|
+
const seen = new Set<string>()
|
|
170
|
+
for (const t of targets) {
|
|
171
|
+
const found = occurrences(t.content, p)
|
|
172
|
+
if (found.size === 0) continue
|
|
173
|
+
let hashes: Span[] | null = null
|
|
174
|
+
for (const [id, spans] of found) {
|
|
175
|
+
const key = `${t.where} ${t.file ?? ''} ${id}`
|
|
176
|
+
if (seen.has(key)) continue
|
|
177
|
+
seen.add(key)
|
|
178
|
+
const fp = patternFingerprint(id)
|
|
179
|
+
const entry = allow.find((a) => {
|
|
180
|
+
if (a.paths != null && !pathCovered(t.file, a.paths)) return false
|
|
181
|
+
if (a.patterns != null && !a.patterns.some((x) => x === fp || x.toLowerCase() === id)) return false
|
|
182
|
+
if (a.inHashes === true) {
|
|
183
|
+
hashes ??= hashSpans(t.content)
|
|
184
|
+
if (!spans.every((s) => inside(s, hashes!))) return false
|
|
185
|
+
}
|
|
186
|
+
return true
|
|
187
|
+
})
|
|
188
|
+
if (entry == null) hits.push({ where: t.where, pattern: id, file: t.file })
|
|
189
|
+
else waived.push({ where: t.where, pattern: id, file: t.file, reason: entry.reason, source: entry.source })
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return { hits, waived }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const ALLOW_KEYS = ['reason', 'paths', 'patterns', 'inHashes']
|
|
196
|
+
const FINGERPRINT = /^fp:[0-9a-f]{12}$/
|
|
197
|
+
|
|
198
|
+
/** why an entry cannot be used, or null. `tracked` = the source is committed
|
|
199
|
+
* (repo.config.ts), where a literal pattern publishes the private word the
|
|
200
|
+
* guard exists to keep out of git. */
|
|
201
|
+
export function leakAllowProblem(entry: unknown, where: string, tracked: boolean): string | null {
|
|
202
|
+
if (entry == null || typeof entry !== 'object' || Array.isArray(entry)) return `${where}: every entry is an object`
|
|
203
|
+
const e = entry as Record<string, unknown>
|
|
204
|
+
for (const k of Object.keys(e)) if (!ALLOW_KEYS.includes(k)) return `${where}: unknown key '${k}' (known: ${ALLOW_KEYS.join(', ')})`
|
|
205
|
+
if (typeof e['reason'] !== 'string' || e['reason'].trim() === '') return `${where}: needs a 'reason' — a waiver nobody can judge later is a hole`
|
|
206
|
+
if (e['paths'] == null && e['patterns'] == null)
|
|
207
|
+
return `${where}: needs 'paths' or 'patterns' — an entry with neither waives every pattern in every file, which is the guard switched off`
|
|
208
|
+
for (const k of ['paths', 'patterns'] as const) {
|
|
209
|
+
const v = e[k]
|
|
210
|
+
if (v == null) continue
|
|
211
|
+
if (!Array.isArray(v) || v.length === 0 || v.some((x) => typeof x !== 'string' || x.trim() === ''))
|
|
212
|
+
return `${where}: '${k}' is a non-empty array of non-empty strings`
|
|
213
|
+
}
|
|
214
|
+
if (e['inHashes'] != null && typeof e['inHashes'] !== 'boolean') return `${where}: 'inHashes' is a boolean`
|
|
215
|
+
if (tracked)
|
|
216
|
+
for (const x of (e['patterns'] as string[] | undefined) ?? [])
|
|
217
|
+
if (!FINGERPRINT.test(x))
|
|
218
|
+
return `${where}: '${x}' — a tracked allowlist names a pattern by FINGERPRINT (fp:…, printed beside every hit), never by value, or the private keyword lands in git`
|
|
219
|
+
return null
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** every problem in a declared allowlist. `defineRepo` throws on the first. */
|
|
223
|
+
export function leakAllowProblems(entries: unknown, where: string, tracked: boolean): string[] {
|
|
224
|
+
if (!Array.isArray(entries)) return [`${where}: leakAllow is an array of entries`]
|
|
225
|
+
return entries.map((e, i) => leakAllowProblem(e, `${where}[${i}]`, tracked)).filter((x): x is string => x != null)
|
|
53
226
|
}
|
|
54
227
|
|
|
55
228
|
/** the machine-global list every repo's guard also reads: one private list
|
|
@@ -62,36 +235,138 @@ export function globalKeywordFile(): string | null {
|
|
|
62
235
|
return existsSync(p) ? p : null
|
|
63
236
|
}
|
|
64
237
|
|
|
238
|
+
/** the machine-global allowlist, the twin of the keyword file. It is private,
|
|
239
|
+
* so it may name a pattern by value as well as by fingerprint. */
|
|
240
|
+
export function globalAllowFile(): string | null {
|
|
241
|
+
const home = process.env.HOME ?? ''
|
|
242
|
+
if (home === '') return null
|
|
243
|
+
const p = join(home, '.rv-private', 'leak-allow.json')
|
|
244
|
+
return existsSync(p) ? p : null
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** read an allowlist file (`LeakAllowEntry[]` as JSON). A file that will not
|
|
248
|
+
* parse, or an entry that cannot be used, is a NOTE and never a failure: a
|
|
249
|
+
* waiver that does not load leaves the guard stricter, never looser. */
|
|
250
|
+
export function loadAllowFile(path: string, tracked: boolean): { entries: LoadedAllow[]; notes: string[] } {
|
|
251
|
+
let parsed: unknown
|
|
252
|
+
try { parsed = JSON.parse(readFileSync(path, 'utf8')) }
|
|
253
|
+
catch (e) { return { entries: [], notes: [`${path} is not readable JSON (${e instanceof Error ? e.message.split('\n')[0] : String(e)}) — its waivers are IGNORED`] } }
|
|
254
|
+
if (!Array.isArray(parsed)) return { entries: [], notes: [`${path} holds an array of allowlist entries — its waivers are IGNORED`] }
|
|
255
|
+
const entries: LoadedAllow[] = []
|
|
256
|
+
const notes: string[] = []
|
|
257
|
+
parsed.forEach((raw, i) => {
|
|
258
|
+
const problem = leakAllowProblem(raw, `${path}[${i}]`, tracked)
|
|
259
|
+
if (problem != null) notes.push(`${problem} — entry IGNORED`)
|
|
260
|
+
else entries.push({ ...(raw as LeakAllowEntry), source: `${basename(path)}[${i}]` })
|
|
261
|
+
})
|
|
262
|
+
return { entries, notes }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export type LeakGuardOpts = {
|
|
266
|
+
/** path to the keyword file, relative to root (default '.rv-private/banned-keywords.txt') */
|
|
267
|
+
keywordFile?: string
|
|
268
|
+
/** extra text to scan (a commit message, a release note) */
|
|
269
|
+
text?: { label: string; content: string }[]
|
|
270
|
+
/** scan the staged diff + staged paths (default true) */
|
|
271
|
+
staged?: boolean
|
|
272
|
+
/** the repo's declared waivers (repo.config.ts hooks.leakAllow). Tracked,
|
|
273
|
+
* so fingerprints only. */
|
|
274
|
+
allow?: LeakAllowEntry[]
|
|
275
|
+
/** override the machine-global allowlist path; null reads none */
|
|
276
|
+
allowFile?: string | null
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** git c-quotes a path that carries a space or a non-ascii byte. */
|
|
280
|
+
function unquotePath(p: string): string {
|
|
281
|
+
if (!p.startsWith('"')) return p
|
|
282
|
+
try { return JSON.parse(p) as string } catch { return p.slice(1, -1) }
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** `@@ -old,n +new,m @@`, the only line that says how long a hunk body is. */
|
|
286
|
+
const HUNK_HEADER = /^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@/
|
|
287
|
+
|
|
288
|
+
/** ADDED lines only, grouped by the file that receives them: what this commit
|
|
289
|
+
* puts INTO the object store. Removed and context lines are already in
|
|
290
|
+
* history, and a commit that DELETES private content must never be blocked by
|
|
291
|
+
* that content. The grouping is what lets a waiver name a file.
|
|
292
|
+
*
|
|
293
|
+
* A `+++ x` line is a file header only OUTSIDE a hunk body. Inside one it is
|
|
294
|
+
* an added line whose own text starts with `++`, and reading it as a header
|
|
295
|
+
* is a hole in both directions: `++ /dev/null` would drop every added line
|
|
296
|
+
* after it in that file, and `++ b/bun.lock` would file them under a name a
|
|
297
|
+
* waiver covers. So the hunk body is COUNTED, from the size its header
|
|
298
|
+
* declares. A header this does not recognise (a combined `@@@` diff) opens an
|
|
299
|
+
* unbounded body that only the next file closes, which is the safe direction:
|
|
300
|
+
* content is still scanned, and no line inside it can name a file. */
|
|
301
|
+
export function addedLinesByFile(diff: string): { file: string; content: string }[] {
|
|
302
|
+
const out: { file: string; content: string }[] = []
|
|
303
|
+
let current: { file: string; lines: string[] } | null = null
|
|
304
|
+
let oldLeft = 0
|
|
305
|
+
let newLeft = 0
|
|
306
|
+
const flush = (): void => {
|
|
307
|
+
if (current != null && current.lines.length > 0) out.push({ file: current.file, content: current.lines.join('\n') })
|
|
308
|
+
current = null
|
|
309
|
+
}
|
|
310
|
+
const add = (line: string): void => { if (current != null) current.lines.push(line.slice(1)) }
|
|
311
|
+
for (const line of diff.split('\n')) {
|
|
312
|
+
// a `diff --git ` at column 0 can never be diff CONTENT: a content line
|
|
313
|
+
// always carries its ' ', '+' or '-' prefix first
|
|
314
|
+
if (line.startsWith('diff --git ')) {
|
|
315
|
+
flush()
|
|
316
|
+
oldLeft = 0
|
|
317
|
+
newLeft = 0
|
|
318
|
+
} else if (oldLeft > 0 || newLeft > 0) {
|
|
319
|
+
if (line.startsWith('+')) { newLeft--; add(line) }
|
|
320
|
+
else if (line.startsWith('-')) oldLeft--
|
|
321
|
+
else if (!line.startsWith('\\')) { oldLeft--; newLeft-- } // '' counts for neither
|
|
322
|
+
} else if (line.startsWith('@@')) {
|
|
323
|
+
const h = HUNK_HEADER.exec(line)
|
|
324
|
+
oldLeft = h == null ? Infinity : h[1] == null ? 1 : Number(h[1])
|
|
325
|
+
newLeft = h == null ? Infinity : h[2] == null ? 1 : Number(h[2])
|
|
326
|
+
} else if (line.startsWith('+++ ')) {
|
|
327
|
+
flush()
|
|
328
|
+
const p = unquotePath(line.slice(4).trim())
|
|
329
|
+
current = p === '/dev/null' ? null : { file: p.startsWith('b/') ? p.slice(2) : p, lines: [] }
|
|
330
|
+
} else if (line.startsWith('+')) add(line) // a stray added line outside any hunk is still content
|
|
331
|
+
}
|
|
332
|
+
flush()
|
|
333
|
+
return out
|
|
334
|
+
}
|
|
335
|
+
|
|
65
336
|
export function runLeakGuard(root: string, o: LeakGuardOpts = {}): LeakGuardResult {
|
|
66
337
|
const rel = o.keywordFile ?? '.rv-private/banned-keywords.txt'
|
|
67
338
|
const file = join(root, rel)
|
|
68
339
|
const sources = [existsSync(file) ? file : null, globalKeywordFile()].filter((f): f is string => f != null)
|
|
69
340
|
if (sources.length === 0) return { status: 'skipped', reason: `${rel} not found and no ~/.rv-private/banned-keywords.txt — no keyword list, no leak guard` }
|
|
70
341
|
const patterns = parsePatterns(sources.map((f) => readFileSync(f, 'utf8')).join('\n'))
|
|
71
|
-
const count = patterns.substrings.length + patterns.regexes.length
|
|
342
|
+
const count = patterns.substrings.length + patterns.words.length + patterns.regexes.length
|
|
72
343
|
if (count === 0) return { status: 'skipped', reason: `keyword file(s) have no patterns (${sources.join(', ')})` }
|
|
73
344
|
|
|
74
|
-
const
|
|
345
|
+
const notes: string[] = []
|
|
346
|
+
const allow: LoadedAllow[] = []
|
|
347
|
+
;(o.allow ?? []).forEach((e, i) => {
|
|
348
|
+
const problem = leakAllowProblem(e, `repo.config.ts hooks.leakAllow[${i}]`, true)
|
|
349
|
+
if (problem != null) notes.push(`${problem} — entry IGNORED`)
|
|
350
|
+
else allow.push({ ...e, source: `repo.config.ts hooks.leakAllow[${i}]` })
|
|
351
|
+
})
|
|
352
|
+
const globalAllow = o.allowFile === undefined ? globalAllowFile() : o.allowFile
|
|
353
|
+
if (globalAllow != null && existsSync(globalAllow)) {
|
|
354
|
+
const g = loadAllowFile(globalAllow, false)
|
|
355
|
+
allow.push(...g.entries)
|
|
356
|
+
notes.push(...g.notes)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const targets: ScanTarget[] = []
|
|
75
360
|
if (o.staged !== false) {
|
|
76
|
-
// staged CONTENT (what lands in the object store) and staged PATHS
|
|
77
|
-
// (a filename can leak as loudly as a line of code)
|
|
78
361
|
const diff = Bun.spawnSync(['git', 'diff', '--cached'], { cwd: root, maxBuffer: 256 * 1024 * 1024 })
|
|
79
|
-
if (diff.exitCode === 0)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
// deletes private content must never be blocked by that content
|
|
83
|
-
const added = diff.stdout.toString().split('\n')
|
|
84
|
-
.filter((l) => l.startsWith('+') && !l.startsWith('+++'))
|
|
85
|
-
.join('\n')
|
|
86
|
-
scan(added, 'staged content', patterns, hits)
|
|
87
|
-
}
|
|
362
|
+
if (diff.exitCode === 0)
|
|
363
|
+
for (const f of addedLinesByFile(diff.stdout.toString())) targets.push({ where: 'staged content', file: f.file, content: f.content })
|
|
364
|
+
// a filename can leak as loudly as a line of code
|
|
88
365
|
const names = Bun.spawnSync(['git', 'diff', '--cached', '--name-only'], { cwd: root })
|
|
89
|
-
if (names.exitCode === 0)
|
|
366
|
+
if (names.exitCode === 0) for (const n of names.stdout.toString().split('\n')) if (n.trim() !== '') targets.push({ where: 'staged path', file: n, content: n })
|
|
90
367
|
}
|
|
91
|
-
for (const t of o.text ?? [])
|
|
368
|
+
for (const t of o.text ?? []) targets.push({ where: t.label, content: t.content })
|
|
92
369
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const unique = hits.filter((h) => { const k = h.where + ' ' + h.pattern; if (seen.has(k)) return false; seen.add(k); return true })
|
|
96
|
-
return unique.length > 0 ? { status: 'hit', hits: unique, patterns: count } : { status: 'clean', patterns: count }
|
|
370
|
+
const { hits, waived } = evaluateLeak(targets, patterns, allow)
|
|
371
|
+
return hits.length > 0 ? { status: 'hit', hits, patterns: count, waived, notes } : { status: 'clean', patterns: count, waived, notes }
|
|
97
372
|
}
|
package/src/hooks/run-hooks.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from
|
|
|
7
7
|
import { dirname, join } from 'path'
|
|
8
8
|
import { fileURLToPath } from 'url'
|
|
9
9
|
import { commitMessageOffenses } from './commit-message.ts'
|
|
10
|
-
import { globalKeywordFile, maskPattern, runLeakGuard } from './leak-guard.ts'
|
|
10
|
+
import { globalAllowFile, globalKeywordFile, maskPattern, patternFingerprint, runLeakGuard, type LeakAllowEntry, type LeakHit, type LeakWaiver } from './leak-guard.ts'
|
|
11
11
|
import { runRootIdentity } from './root-identity.ts'
|
|
12
12
|
import { spawnWithTimeout } from '../core/spawn-timeout.ts'
|
|
13
13
|
import type { RepoConfig } from '../repo-config.ts'
|
|
@@ -21,6 +21,14 @@ export type HooksConfig = {
|
|
|
21
21
|
commitMsg?: { id: string; cmd: string[] }[]
|
|
22
22
|
/** keyword file for the leak guard (default '.rv-private/banned-keywords.txt') */
|
|
23
23
|
keywordFile?: string
|
|
24
|
+
/** known-good leak-guard matches this repo waives: a lockfile whose
|
|
25
|
+
* dependency names or integrity hashes collide with a keyword is the
|
|
26
|
+
* motivating case. Each entry carries a reason, and a waiver that fires
|
|
27
|
+
* PRINTS, so nothing passes silently. This file is tracked, so an entry
|
|
28
|
+
* names a pattern by fingerprint (`fp:…`, printed beside every hit) and
|
|
29
|
+
* never by value. The machine-global twin is ~/.rv-private/leak-allow.json.
|
|
30
|
+
* hooks/leak-guard.ts */
|
|
31
|
+
leakAllow?: LeakAllowEntry[]
|
|
24
32
|
/** staged-path prefixes that may never enter git (local state like
|
|
25
33
|
* '.comfy-ts/'). gitignore is advisory and editable; this gate is not. */
|
|
26
34
|
blockedPaths?: string[]
|
|
@@ -148,6 +156,29 @@ function seedKeywordFile(root: string, keywordFile?: string): string | null {
|
|
|
148
156
|
|
|
149
157
|
export type GateOutcome = { id: string; ok: boolean; lines: string[] }
|
|
150
158
|
|
|
159
|
+
/** a hit line: the masked pattern plus its FINGERPRINT, which is the token a
|
|
160
|
+
* waiver is written against, so nobody has to type the private word to
|
|
161
|
+
* exempt it. The file rides along for a content hit; a staged-PATH hit adds
|
|
162
|
+
* nothing, because there the path IS what matched. */
|
|
163
|
+
const hitLine = (x: LeakHit): string =>
|
|
164
|
+
` ${x.where}${x.where === 'staged content' && x.file != null ? ` ${x.file}` : ''}: ${maskPattern(x.pattern)} [${patternFingerprint(x.pattern)}]`
|
|
165
|
+
|
|
166
|
+
/** what the allowlist let through, one line per waiver. An allowlist that
|
|
167
|
+
* fires invisibly is a silent weakening, so a waived match is as loud as a
|
|
168
|
+
* blocked one and carries the reason its author wrote. */
|
|
169
|
+
function waivedLines(waived: LeakWaiver[]): string[] {
|
|
170
|
+
const rows = new Map<string, { pattern: string; reason: string; source: string; targets: Set<string> }>()
|
|
171
|
+
for (const w of waived) {
|
|
172
|
+
const key = `${w.pattern} ${w.source}`
|
|
173
|
+
const row = rows.get(key) ?? { pattern: w.pattern, reason: w.reason, source: w.source, targets: new Set<string>() }
|
|
174
|
+
row.targets.add(w.file ?? w.where)
|
|
175
|
+
rows.set(key, row)
|
|
176
|
+
}
|
|
177
|
+
return [...rows.values()].map(
|
|
178
|
+
(r) => ` waived ${maskPattern(r.pattern)} [${patternFingerprint(r.pattern)}] in ${r.targets.size} target(s) — ${r.reason} (${r.source})`,
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
151
182
|
/** the pre-commit gate: built-in leak guard, then the repo's own commands.
|
|
152
183
|
* A commit takes ~1s (build/shipkit.md): the gate times itself, and going
|
|
153
184
|
* over budget WARNs naming the fix, so drift back to slow commits is loud. */
|
|
@@ -156,16 +187,27 @@ export async function runPreCommit(root: string, config: RepoConfig): Promise<Ga
|
|
|
156
187
|
const h = config.hooks ?? {}
|
|
157
188
|
const out: GateOutcome[] = []
|
|
158
189
|
if (!(h.disable ?? []).includes('leak-guard')) {
|
|
159
|
-
const r = runLeakGuard(root, { keywordFile: h.keywordFile })
|
|
190
|
+
const r = runLeakGuard(root, { keywordFile: h.keywordFile, allow: h.leakAllow })
|
|
160
191
|
if (r.status === 'skipped') out.push({ id: 'leak-guard', ok: true, lines: [`skip: leak-guard (${r.reason})`] })
|
|
161
|
-
else if (r.status === 'clean') out.push({
|
|
192
|
+
else if (r.status === 'clean') out.push({
|
|
193
|
+
id: 'leak-guard',
|
|
194
|
+
ok: true,
|
|
195
|
+
lines: [
|
|
196
|
+
`ok: leak-guard clean (${r.patterns} pattern(s)${r.waived.length === 0 ? '' : `, ${r.waived.length} waived`})`,
|
|
197
|
+
...waivedLines(r.waived),
|
|
198
|
+
...r.notes.map((n) => ` WARN ${n}`),
|
|
199
|
+
],
|
|
200
|
+
})
|
|
162
201
|
else out.push({
|
|
163
202
|
id: 'leak-guard',
|
|
164
203
|
ok: false,
|
|
165
204
|
lines: [
|
|
166
205
|
`FAIL: leak-guard matched ${r.hits.length} banned pattern(s) — this commit would leak private content`,
|
|
167
|
-
...r.hits.map(
|
|
206
|
+
...r.hits.map(hitLine),
|
|
207
|
+
...waivedLines(r.waived),
|
|
208
|
+
...r.notes.map((n) => ` WARN ${n}`),
|
|
168
209
|
` fix: remove the content, or edit the keyword file if the pattern is wrong`,
|
|
210
|
+
` a known-good match: repo.config.ts hooks.leakAllow += { reason, paths, patterns: ['<the fp: above>'] }`,
|
|
169
211
|
],
|
|
170
212
|
})
|
|
171
213
|
}
|
|
@@ -242,9 +284,11 @@ export async function runCommitMsg(root: string, config: RepoConfig, messageFile
|
|
|
242
284
|
// the leak guard also reads the MESSAGE, a private name pasted into a
|
|
243
285
|
// commit body is as public as one pasted into a file
|
|
244
286
|
if (!(h.disable ?? []).includes('leak-guard')) {
|
|
245
|
-
const r = runLeakGuard(root, { keywordFile: h.keywordFile, staged: false, text: [{ label: 'commit message', content: message }] })
|
|
287
|
+
const r = runLeakGuard(root, { keywordFile: h.keywordFile, staged: false, allow: h.leakAllow, text: [{ label: 'commit message', content: message }] })
|
|
246
288
|
if (r.status === 'hit')
|
|
247
|
-
out.push({ id: 'leak-guard', ok: false, lines: [`FAIL: leak-guard matched in the commit message`, ...r.hits.map(
|
|
289
|
+
out.push({ id: 'leak-guard', ok: false, lines: [`FAIL: leak-guard matched in the commit message`, ...r.hits.map(hitLine), ...waivedLines(r.waived)] })
|
|
290
|
+
else if (r.status === 'clean' && r.waived.length > 0)
|
|
291
|
+
out.push({ id: 'leak-guard', ok: true, lines: [`ok: leak-guard clean in the commit message (${r.waived.length} waived)`, ...waivedLines(r.waived)] })
|
|
248
292
|
}
|
|
249
293
|
for (const c of h.commitMsg ?? []) {
|
|
250
294
|
const r = await spawnWithTimeout([...c.cmd, messageFile], { cwd: root, timeoutMs: 60_000 })
|
|
@@ -265,8 +309,12 @@ export function listGates(config: RepoConfig, root = process.cwd()): string[] {
|
|
|
265
309
|
// a gate listed as active but unable to fire is worse than no listing:
|
|
266
310
|
// this surface exists so nobody has to guess
|
|
267
311
|
const leakState = off.has('leak-guard') ? ' (DISABLED by config)' : existsSync(join(root, kw)) || globalKeywordFile() != null ? '' : ` (INACTIVE, ${kw} absent and no ~/.rv-private list)`
|
|
312
|
+
// a waiver nobody can enumerate is a hole nobody can audit: the count of
|
|
313
|
+
// declared exemptions belongs on the same surface as the gate itself
|
|
314
|
+
const waivers = (h.leakAllow ?? []).length + (globalAllowFile() != null ? 1 : 0)
|
|
315
|
+
const leakAllowState = waivers === 0 ? '' : `, ${(h.leakAllow ?? []).length} repo waiver(s)${globalAllowFile() == null ? '' : ' + ~/.rv-private/leak-allow.json'}`
|
|
268
316
|
return [
|
|
269
|
-
`pre-commit leak-guard${leakState} — staged added lines + paths vs ${kw} + ~/.rv-private/banned-keywords.txt`,
|
|
317
|
+
`pre-commit leak-guard${leakState} — staged added lines + paths vs ${kw} + ~/.rv-private/banned-keywords.txt${leakAllowState}`,
|
|
270
318
|
...((h.blockedPaths ?? []).length > 0 ? [`pre-commit blocked-paths — nothing under ${h.blockedPaths!.join(', ')} enters git`] : []),
|
|
271
319
|
`pre-commit root-identity${off.has('root-identity') ? ' (DISABLED by config)' : ''} — the repo's own name in package.json + repo.config.ts, and scratch files`,
|
|
272
320
|
...(h.preCommit ?? []).map((c) => `pre-commit ${c.id} — ${c.cmd.join(' ')}`),
|
package/src/init-repo.ts
CHANGED
|
@@ -29,10 +29,14 @@ export default defineRepo({
|
|
|
29
29
|
venues: [], // e.g. ['itch', 'steam'], selects which .rv-<venue>/ contracts are REQUIRED
|
|
30
30
|
social: [], // e.g. ['x', 'discord'], the .rv-social/<channel>/ post folders (+ .rv-discord/)
|
|
31
31
|
tasks: [], // repo commit-gate tasks; deps double as race guards
|
|
32
|
+
// lint, typecheck, imports:check and cycles replay off the tracked tree.
|
|
33
|
+
// \`test\` measures every run until you say what it reads, because an
|
|
34
|
+
// ignored path (a fixture, a local db, a .env) is not in a tree key:
|
|
35
|
+
// defaults: { test: { inputs: ['tests', 'src', 'package.json'] } },
|
|
32
36
|
// .comfy-ts/ holds schema dumps naming a private model collection.
|
|
33
37
|
// gitignore (SK004) is advisory, this pre-commit gate is not
|
|
34
38
|
hooks: { blockedPaths: ['.comfy-ts/'] },
|
|
35
|
-
// the
|
|
39
|
+
// the declared reviewer agent type (rule SK008). Uncomment with YOUR
|
|
36
40
|
// standing brief, then \`shipkit check --fix SK008\` writes the agent file:
|
|
37
41
|
// reviewer: { name: 'my-reviewer', brief: '~/path/to/reviewer-brief.md' },
|
|
38
42
|
// health checks need no config. Drop a <name>.check.ts file anywhere,
|
package/src/ledger/git-key.ts
CHANGED
|
@@ -16,8 +16,11 @@
|
|
|
16
16
|
// NEITHER declares nothing. Never cached. This is the DEFAULT, and it is
|
|
17
17
|
// the whole safety story: forgetting to declare costs speed, never
|
|
18
18
|
// correctness, so the burn-down list below is a to-do rather than a
|
|
19
|
-
// hazard.
|
|
20
|
-
// the
|
|
19
|
+
// hazard. It holds only while nothing declares ON a unit's behalf:
|
|
20
|
+
// the gate floor gives `['.']` to the four slots tracked source
|
|
21
|
+
// decides and deliberately leaves `test` here (core/floor.ts says
|
|
22
|
+
// why). Declaring BOTH is a registration failure, loud, because the
|
|
23
|
+
// two answers contradict each other.
|
|
21
24
|
//
|
|
22
25
|
// CROSS WORKTREE. Every component here is content addressed or a version
|
|
23
26
|
// string; not one of them is an absolute path. Two worktrees of one repo
|
package/src/repo-config.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { validateSmokeConfig, type SmokeConfig } from './smoke/run-smoke.ts'
|
|
|
22
22
|
import { contractsFulfillness } from './rules/SK002-contracts-fulfillness.ts'
|
|
23
23
|
import { BUILTIN_RULE_IDS, validateRules } from './rules/rule-registry.ts'
|
|
24
24
|
import type { Rule } from './rules/rule.ts'
|
|
25
|
+
import { leakAllowProblems } from './hooks/leak-guard.ts'
|
|
25
26
|
import type { HooksConfig } from './hooks/run-hooks.ts'
|
|
26
27
|
import type { ReleaseConfig } from './release/run-release.ts'
|
|
27
28
|
import { UNIT_DECL_KEYS, type ReleaseUnitDecl } from './release/units.ts'
|
|
@@ -89,12 +90,12 @@ export type RepoConfig = {
|
|
|
89
90
|
/** declared github visibility INTENT (absent = 'private') — rule SK007
|
|
90
91
|
* fails loud when `gh repo view` disagrees in either direction */
|
|
91
92
|
visibility?: 'private' | 'public'
|
|
92
|
-
/** the reviewer agent type this repo
|
|
93
|
+
/** the reviewer agent type this repo declares: `.claude/agents/<name>.md`,
|
|
93
94
|
* whose system prompt points at `brief` (an md file holding the standing
|
|
94
95
|
* review framing — one home, shared across repos). `tools` overrides the
|
|
95
96
|
* read-only allowlist SK008 enforces (default: Read, Grep, Glob, Bash).
|
|
96
97
|
* Rule SK008 lints it, `check --fix SK008` writes it. Absent = no
|
|
97
|
-
*
|
|
98
|
+
* declared reviewer. */
|
|
98
99
|
reviewer?: { name: string; brief: string; tools?: string[] }
|
|
99
100
|
/** ONE WAY IN escape hatch (build/shipkit.md): governed script names this
|
|
100
101
|
* repo keeps as a direct tool call. Declared per NAME, never as a list of
|
|
@@ -202,6 +203,12 @@ export function defineRepo(c: RepoConfig): RepoConfig {
|
|
|
202
203
|
if (c.rules != null) validateRules(c.rules, { label: 'defineRepo: rules', taken: BUILTIN_RULE_IDS })
|
|
203
204
|
// a smoke typo would land as a run that visits nothing and exits 0
|
|
204
205
|
if (c.smoke != null) validateSmokeConfig(c.smoke)
|
|
206
|
+
// a leak-guard waiver is the one config row that can make a gate quieter,
|
|
207
|
+
// so a malformed one dies here rather than being dropped at commit time
|
|
208
|
+
if (c.hooks?.leakAllow != null) {
|
|
209
|
+
const problems = leakAllowProblems(c.hooks.leakAllow, 'defineRepo: hooks.leakAllow', true)
|
|
210
|
+
if (problems.length > 0) throw new Error(problems[0])
|
|
211
|
+
}
|
|
205
212
|
if (c.credsReadCommand != null && !/\{name\}/.test(c.credsReadCommand))
|
|
206
213
|
throw new Error(`defineRepo: credsReadCommand must carry {name} (and {field} for get | account), e.g. 'my-secrets {field} {name}'`)
|
|
207
214
|
if (c.smoke?.login != null && (c.credsReadCommand ?? '') === '')
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// PLACE — lines below it (e.g. a live venue's `!.rv-itch/` override, where
|
|
8
8
|
// last-match-wins) keep their position; everything outside the block is
|
|
9
9
|
// never touched. `shipkit init` writes the same block through this fixer.
|
|
10
|
+
import { execFileSync } from 'child_process'
|
|
10
11
|
import { existsSync, readFileSync } from 'fs'
|
|
11
12
|
import { join } from 'path'
|
|
12
13
|
import type { RepoConfig } from '../repo-config.ts'
|
|
@@ -58,6 +59,60 @@ export function policyPatterns(config: RepoConfig): string[] {
|
|
|
58
59
|
return [...POLICY_LINES.filter((l) => !l.startsWith('#')), ...(config.gitignore ?? [])]
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
// the three venue folders are DESIGNED to be unignored below the block once a
|
|
63
|
+
// listing is live (last-match-wins, the comment inside POLICY_LINES says so),
|
|
64
|
+
// so an override on one of them is the mechanism working. Every other policy
|
|
65
|
+
// path is a claim the block makes, and a claim git contradicts is a bug.
|
|
66
|
+
function endsWithNewline(root: string): boolean {
|
|
67
|
+
const p = join(root, '.gitignore')
|
|
68
|
+
if (!existsSync(p)) return true
|
|
69
|
+
const text = readFileSync(p, 'utf8')
|
|
70
|
+
return text === '' || text.endsWith('\n')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const OVERRIDABLE = new Set(['.rv-itch/', '.rv-steam/', '.rv-discord/'])
|
|
74
|
+
|
|
75
|
+
/** a path git can be asked about, for a pattern that may be a glob or a dir */
|
|
76
|
+
function probePath(pattern: string): string {
|
|
77
|
+
if (pattern.endsWith('/')) return `${pattern}probe`
|
|
78
|
+
// `.*.tmp-*` and `*.loras.local.txt`: a literal standing in for the glob
|
|
79
|
+
return pattern.includes('*') ? pattern.replace(/\*/g, 'probe') : pattern
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** which of `paths` git does NOT ignore. null when this is not a git repo, so
|
|
83
|
+
* a rule can say it did not measure rather than report a green it invented */
|
|
84
|
+
export function notIgnored(root: string, paths: string[]): string[] | null {
|
|
85
|
+
if (paths.length === 0) return []
|
|
86
|
+
try {
|
|
87
|
+
// --stdin -z -n: one call, every path, and `-n` makes git print a row
|
|
88
|
+
// for the MISSES too, which is the answer we want
|
|
89
|
+
const out = execFileSync('git', ['check-ignore', '--stdin', '-z', '-n', '-v'], {
|
|
90
|
+
cwd: root, input: paths.join('\0'), encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
91
|
+
})
|
|
92
|
+
// -v -z rows are source\0line\0pattern\0path; an unignored path has an
|
|
93
|
+
// empty source and an empty pattern
|
|
94
|
+
const f = out.split('\0')
|
|
95
|
+
const miss: string[] = []
|
|
96
|
+
for (let i = 0; i + 3 < f.length + 1; i += 4) {
|
|
97
|
+
if (f[i] === '' && f[i + 2] === '' && f[i + 3] != null && f[i + 3] !== '') miss.push(f[i + 3]!)
|
|
98
|
+
}
|
|
99
|
+
return miss
|
|
100
|
+
} catch (e) {
|
|
101
|
+
// exit 1 means "none of them are ignored", which is an ANSWER, and git
|
|
102
|
+
// still wrote the rows we parse. Anything else (no repo, no git) is not
|
|
103
|
+
const err = e as { status?: number; stdout?: string }
|
|
104
|
+
if (err.status === 1 && typeof err.stdout === 'string') {
|
|
105
|
+
const f = err.stdout.split('\0')
|
|
106
|
+
const miss: string[] = []
|
|
107
|
+
for (let i = 0; i + 3 < f.length + 1; i += 4) {
|
|
108
|
+
if (f[i] === '' && f[i + 2] === '' && f[i + 3] != null && f[i + 3] !== '') miss.push(f[i + 3]!)
|
|
109
|
+
}
|
|
110
|
+
return miss
|
|
111
|
+
}
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
61
116
|
type Parsed = { text: string; crlf: boolean; lines: string[]; b: number; e: number }
|
|
62
117
|
|
|
63
118
|
function parse(root: string): Parsed | null {
|
|
@@ -122,11 +177,48 @@ export const gitignorePolicy: Rule = {
|
|
|
122
177
|
case 'missing-block': return t.fail('.gitignore has no shipkit policy block — contract surfaces can leak into git')
|
|
123
178
|
case 'corrupted': return t.fail('.gitignore policy block corrupted (unpaired marker)')
|
|
124
179
|
case 'stale': return t.fail('.gitignore policy block is stale — the policy or repo-contributed lines moved since it was written')
|
|
125
|
-
case 'current': return t.ok(`.gitignore policy block current (${policyPatterns(env.config).length} patterns)`)
|
|
126
180
|
}
|
|
181
|
+
// an unterminated last line is a TRAP, not a cosmetic defect: the next
|
|
182
|
+
// `>> .gitignore` fuses onto it and the result is one pattern that
|
|
183
|
+
// ignores neither path, with the file ending in a newline afterwards
|
|
184
|
+
// so nothing textual looks wrong
|
|
185
|
+
if (!endsWithNewline(env.root)) {
|
|
186
|
+
t.fail('.gitignore does not end with a newline — the next append fuses onto its last line and ignores neither path')
|
|
187
|
+
t.lines.push({ kind: 'fix-fail', text: 'shipkit ci (or check --fix SK004) terminates it' })
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
// the block states what must be ignored. git is the only thing that
|
|
191
|
+
// knows whether it IS: a stray negation below the block, or a line
|
|
192
|
+
// fused into another, leaves the text current over a broken repo
|
|
193
|
+
const claimed = policyPatterns(env.config).filter((p) => !OVERRIDABLE.has(p))
|
|
194
|
+
const probes = new Map(claimed.map((p) => [probePath(p), p]))
|
|
195
|
+
const miss = notIgnored(env.root, [...probes.keys()])
|
|
196
|
+
if (miss == null) {
|
|
197
|
+
// not a git repo (a fixture, or a dir before `git init`). Say the
|
|
198
|
+
// verification did not run rather than reporting a green nothing
|
|
199
|
+
// measured, and stay quiet: there is nothing wrong here to fix
|
|
200
|
+
t.ok(`.gitignore policy block current (${claimed.length} patterns)`)
|
|
201
|
+
t.lines.push({ kind: 'skip', text: 'not a git repo here, so nothing verified the block against what git actually ignores' })
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
if (miss.length > 0) {
|
|
205
|
+
const names = [...new Set(miss.map((m) => probes.get(m) ?? m))]
|
|
206
|
+
t.fail(`.gitignore block claims ${names.join(', ')} ignored, git says otherwise — a negation below the block, or a line fused into its neighbour`)
|
|
207
|
+
t.lines.push({ kind: 'fix-fail', text: `git check-ignore -v ${miss[0]} names the line that wins` })
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
t.ok(`.gitignore policy block current, ${claimed.length} patterns verified against git`)
|
|
127
211
|
},
|
|
128
212
|
fix: (env: RuleEnv): FixResult => {
|
|
129
213
|
const before = blockState(env.root, env.config)
|
|
214
|
+
// terminate an unterminated file even when the block itself is current:
|
|
215
|
+
// that missing byte is what the next append fuses onto
|
|
216
|
+
let terminated: string | null = null
|
|
217
|
+
if (before === 'current' && !endsWithNewline(env.root)) {
|
|
218
|
+
const p = join(env.root, '.gitignore')
|
|
219
|
+
atomicWrite(p, `${readFileSync(p, 'utf8')}\n`)
|
|
220
|
+
terminated = '.gitignore terminated with a newline (the next append would have fused onto its last line)'
|
|
221
|
+
}
|
|
130
222
|
const blockSummary = before === 'current'
|
|
131
223
|
? null
|
|
132
224
|
: before === 'missing-file' ? '.gitignore created with the policy block'
|
|
@@ -137,10 +229,10 @@ export const gitignorePolicy: Rule = {
|
|
|
137
229
|
// the block must be written FIRST: untracking a path the ignore list
|
|
138
230
|
// does not yet carry leaves it staged for re-add on the next `git add`
|
|
139
231
|
const un = untrackGenerated(env.root, { enabled: env.config.untrackGenerated })
|
|
140
|
-
const parts = [blockSummary]
|
|
232
|
+
const parts = [blockSummary, terminated]
|
|
141
233
|
if (un.did === 'untracked') parts.push(`untracked ${un.paths.join(', ')} (generated, now gitignored, committed as ${un.commit})`)
|
|
142
234
|
if (un.did === 'refused') parts.push(`left ${trackedGeneratedPaths(env.root).join(', ')} tracked: ${un.why}`)
|
|
143
235
|
const summary = parts.filter((x): x is string => x != null).join(' · ')
|
|
144
|
-
return { changed: blockSummary != null || un.did === 'untracked', summary: summary === '' ? 'policy block already current' : summary }
|
|
236
|
+
return { changed: blockSummary != null || terminated != null || un.did === 'untracked', summary: summary === '' ? 'policy block already current' : summary }
|
|
145
237
|
},
|
|
146
238
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// SK008 — the
|
|
1
|
+
// SK008 — the declared reviewer agent type. A repo that declares
|
|
2
2
|
// `reviewer: { name, brief }` must carry `.claude/agents/<name>.md`: an agent
|
|
3
3
|
// definition whose system prompt POINTS at the brief file (ONE home for the
|
|
4
4
|
// text, so review framing can never drift per repo) and whose `tools:` line
|
|
@@ -50,7 +50,7 @@ export function briefMissing(root: string, brief: string): boolean {
|
|
|
50
50
|
function canonicalAgent(reviewer: NonNullable<RepoConfig['reviewer']>): string {
|
|
51
51
|
return `---
|
|
52
52
|
name: ${reviewer.name}
|
|
53
|
-
description: The
|
|
53
|
+
description: The critical reviewer for this repo. Whether a review is worth its wall clock is the session's call: reach for it on code whose failure the gate cannot see, and tell it to verify by running rather than to opine. Judges whether a change should exist at all, not just whether it is correct.
|
|
54
54
|
tools: ${(reviewer.tools ?? REVIEWER_TOOLS).join(', ')}
|
|
55
55
|
model: opus
|
|
56
56
|
---
|
|
@@ -82,7 +82,7 @@ export const reviewerAgent: Rule = {
|
|
|
82
82
|
const rel = reviewerAgentPath(r.name)
|
|
83
83
|
const p = join(env.root, rel)
|
|
84
84
|
if (briefMissing(env.root, r.brief)) t.fail(`the declared brief '${r.brief}' does not exist — every review's mandated first action would fail`)
|
|
85
|
-
if (!existsSync(p)) return t.fail(`${rel} missing — the
|
|
85
|
+
if (!existsSync(p)) return t.fail(`${rel} missing — the declared reviewer type is unavailable, so any review runs on whatever framing the spawner improvised`)
|
|
86
86
|
const issues = reviewerAgentIssues(readFileSync(p, 'utf8'), r)
|
|
87
87
|
for (const i of issues) t.fail(`${rel}: ${i}`)
|
|
88
88
|
if (issues.length === 0 && !t.failed) t.ok(`${rel} → ${r.brief}`)
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// TRACKED on purpose: `.claude/settings.local.json` is gitignored by the house
|
|
14
14
|
// scaffold, so a policy written there would bind exactly one checkout.
|
|
15
15
|
import { existsSync, mkdirSync, readFileSync } from 'fs'
|
|
16
|
-
import { dirname, join } from 'path'
|
|
16
|
+
import { dirname, isAbsolute, join } from 'path'
|
|
17
17
|
import { inferBin } from '../core/canonical-scripts.ts'
|
|
18
18
|
import { denyRules, RAW_RUNNERS } from '../core/one-way-in-policy.ts'
|
|
19
19
|
import type { RepoConfig } from '../repo-config.ts'
|
|
@@ -36,19 +36,30 @@ export function exemptSlots(c: RepoConfig): Set<string> {
|
|
|
36
36
|
return off
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
/** how the hook invokes the guard.
|
|
40
|
-
* is not promised to be the repo
|
|
39
|
+
/** how the hook invokes the guard. A relative path hangs off
|
|
40
|
+
* `$CLAUDE_PROJECT_DIR` because a hook's cwd is not promised to be the repo
|
|
41
|
+
* root, and an absolute one stands alone */
|
|
41
42
|
export function guardCommand(bin?: string): string {
|
|
42
43
|
const path = bin == null
|
|
43
44
|
? 'node_modules/rvlib-shipkit/src/hooks/one-way-in-guard.ts'
|
|
44
45
|
: bin.replace(/cli\.ts$/, 'hooks/one-way-in-guard.ts')
|
|
45
|
-
return `bun "$CLAUDE_PROJECT_DIR/${path}"`
|
|
46
|
+
return isAbsolute(path) ? `bun "${path}"` : `bun "$CLAUDE_PROJECT_DIR/${path}"`
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
const readPkg = (root: string): { scripts?: Record<string, string>; dependencies?: Record<string, string>; devDependencies?: Record<string, string> } | null => {
|
|
50
|
+
try { return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as never } catch { return null }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const declaresShipkit = (root: string): boolean => {
|
|
54
|
+
const pkg = readPkg(root)
|
|
55
|
+
return pkg?.dependencies?.['rvlib-shipkit'] != null || pkg?.devDependencies?.['rvlib-shipkit'] != null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** a guard under `node_modules/` that is not there yet, in a repo that
|
|
59
|
+
* declares rvlib-shipkit, says the dependency is not installed, which
|
|
60
|
+
* `bun install` fixes and no edit to this repo can. Without the declaration
|
|
61
|
+
* no install ever brings it, so it is a path this repo got wrong like any other. */
|
|
62
|
+
const isUninstalledDep = (root: string, rel: string): boolean => rel.startsWith('node_modules/') && declaresShipkit(root)
|
|
52
63
|
|
|
53
64
|
const readSettings = (root: string): Settings | null => {
|
|
54
65
|
const p = join(root, SETTINGS_PATH)
|
|
@@ -65,21 +76,32 @@ const guardHookCommand = (s: Settings): string | null => {
|
|
|
65
76
|
|
|
66
77
|
const hasGuardHook = (s: Settings): boolean => guardHookCommand(s) != null
|
|
67
78
|
|
|
68
|
-
/** the guard file the hook command points at, resolved against the repo root
|
|
79
|
+
/** the guard file the hook command points at, resolved against the repo root
|
|
80
|
+
* when it hangs off `$CLAUDE_PROJECT_DIR`, as written when it is absolute.
|
|
69
81
|
* A hook whose script does not exist errors on EVERY Bash call and refuses
|
|
70
82
|
* nothing, so the agent sees only the bare deny, which is the one failure
|
|
71
83
|
* this rule exists to prevent. Declaring it and never running it is worse
|
|
72
84
|
* than not declaring it, so it FAILs. */
|
|
73
85
|
function guardScriptMissing(root: string, command: string): string | null {
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
return existsSync(
|
|
86
|
+
const rel = /["']?\$(?:\{)?CLAUDE_PROJECT_DIR(?:\})?\/([^"'\s]+)["']?/.exec(command)?.[1]
|
|
87
|
+
if (rel != null) return existsSync(join(root, rel)) ? null : rel
|
|
88
|
+
const abs = /^bun\s+["']?(\/[^"'\s]+)["']?/.exec(command.trim())?.[1]
|
|
89
|
+
if (abs != null) return existsSync(abs) ? null : abs
|
|
90
|
+
return null // a form this rule cannot resolve is not a form it may condemn
|
|
78
91
|
}
|
|
79
92
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
93
|
+
/** the guard the fixer writes: the cli path the scripts run when a guard sits
|
|
94
|
+
* beside it (a repo's own `src/cli.ts` matches the same shape), else the
|
|
95
|
+
* repo's own dependency, else the copy of shipkit doing the fixing, which is
|
|
96
|
+
* the one the launcher falls back to in a repo that has none */
|
|
97
|
+
export function guardCommandFor(root: string): string {
|
|
98
|
+
const bin = inferBin(readPkg(root)?.scripts ?? {})
|
|
99
|
+
if (bin != null) {
|
|
100
|
+
const fromBin = guardCommand(bin)
|
|
101
|
+
if (guardScriptMissing(root, fromBin) == null) return fromBin
|
|
102
|
+
}
|
|
103
|
+
if (declaresShipkit(root)) return guardCommand()
|
|
104
|
+
return guardCommand(join(import.meta.dir, '..', 'cli.ts'))
|
|
83
105
|
}
|
|
84
106
|
|
|
85
107
|
export const oneWayInPolicy: Rule = {
|
|
@@ -115,7 +137,7 @@ export const oneWayInPolicy: Rule = {
|
|
|
115
137
|
// that fixes it, not a red gate on a repo nobody has installed yet,
|
|
116
138
|
// and the deny list is the floor throughout. A path this repo chose
|
|
117
139
|
// and got wrong is still a FAIL: it will never resolve on its own.
|
|
118
|
-
if (missing != null && isUninstalledDep(missing))
|
|
140
|
+
if (missing != null && isUninstalledDep(env.root, missing))
|
|
119
141
|
t.warn(
|
|
120
142
|
`${SETTINGS_PATH}: the PreToolUse guard points at ${missing}, which is not installed yet — until then a refusal names no replacement, and the deny list is the whole floor`,
|
|
121
143
|
`bun install (or bun add -d rvlib-shipkit)`,
|
|
@@ -149,10 +171,24 @@ export const oneWayInPolicy: Rule = {
|
|
|
149
171
|
s.permissions = { ...s.permissions, deny: [...deny, ...added] }
|
|
150
172
|
changes.push(`${added.length} raw runner(s) denied`)
|
|
151
173
|
}
|
|
174
|
+
const wantGuard = guardCommandFor(env.root)
|
|
152
175
|
if (!hasGuardHook(s)) {
|
|
153
176
|
const pre = s.hooks?.['PreToolUse'] ?? []
|
|
154
|
-
s.hooks = { ...s.hooks, PreToolUse: [...pre, { matcher: 'Bash', hooks: [{ type: 'command', command:
|
|
177
|
+
s.hooks = { ...s.hooks, PreToolUse: [...pre, { matcher: 'Bash', hooks: [{ type: 'command', command: wantGuard }] }] }
|
|
155
178
|
changes.push('PreToolUse guard wired (every refusal names the shipkit command)')
|
|
179
|
+
} else {
|
|
180
|
+
// a guard that cannot run errors on every Bash call, so it is repointed
|
|
181
|
+
// at the copy this repo runs. One that resolves, or waits on a declared
|
|
182
|
+
// install, is the repo's choice and stays as written
|
|
183
|
+
for (const m of s.hooks?.['PreToolUse'] ?? [])
|
|
184
|
+
for (const h of m.hooks ?? []) {
|
|
185
|
+
if (h.command?.includes('one-way-in-guard') !== true) continue
|
|
186
|
+
const missing = guardScriptMissing(env.root, h.command)
|
|
187
|
+
if (missing == null || isUninstalledDep(env.root, missing) || h.command === wantGuard) continue
|
|
188
|
+
if (guardScriptMissing(env.root, wantGuard) != null) continue
|
|
189
|
+
changes.push(`PreToolUse guard repointed from ${missing}, which does not exist`)
|
|
190
|
+
h.command = wantGuard
|
|
191
|
+
}
|
|
156
192
|
}
|
|
157
193
|
if (changes.length === 0) return { changed: false, summary: `${SETTINGS_PATH} already closes every raw runner` }
|
|
158
194
|
mkdirSync(dirname(p), { recursive: true })
|