rvlib-shipkit 0.32.0 → 0.33.0
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 +9 -0
- package/package.json +4 -1
- package/src/core/canonical-scripts.ts +3 -1
- package/src/core/one-way-in-policy.ts +183 -0
- package/src/core/run-ci.ts +17 -2
- package/src/hooks/one-way-in-guard.ts +39 -0
- package/src/init-repo.ts +19 -1
- package/src/rules/SK001-package-json-shape.ts +2 -1
- package/src/rules/SK013-one-way-in-policy.ts +136 -0
- package/src/rules/rule-registry.ts +2 -0
- package/src/scaffold/files/claude-settings-local.json +0 -3
- package/src/scaffold/files/verify-skill.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.33.0
|
|
4
|
+
|
|
5
|
+
- **ONE WAY IN, enforced. A wired repo's `test`, `lint` and `typecheck` scripts route through shipkit, and the harness refuses the raw runner.** Measured in a real wired repo before this: `"lint": "eslint ."`, `"test": "bun test"`, no ci script, an empty `.claude/`. An agent doing the obvious thing ran the tool, shipkit never heard the call, and the cache it was building was worthless. **This changes the shape of every wired repo's package.json**, and 0.32.0 already shipped the first half of it without saying so, which is the gap this note closes. Rule SK001 (blocking, autofixable) now FAILs a governed script that runs the tool directly; the fix slides the body to `<id>:raw` and makes the name `shipkit ci --only <id>`. `<id>:raw` is what the gate itself runs, and what you use to iterate on one suite. Those THREE names only: `imports:check` and `cycles` are shipkit's own vocabulary, and `fmt` is the slot the fix pass invokes by script, so routing it would make the fixer call itself. `resolveFloor` moved to `core/floor.ts` and refuses to run a routed script, which spawns the gate from inside the gate.
|
|
6
|
+
- **New rule SK013 (blocking, autofixable) writes the harness half**, because a package.json cannot reach it: `bun test` is a bun builtin and no script can intercept it. A tracked `.claude/settings.json` gets `permissions.deny` for every raw runner plus a PreToolUse guard, both rendered from ONE table. The deny is the floor, unbypassable in either direction; the guard is the messenger, because a bare deny tells the model only that it was denied and it then goes hunting for another runner. Every refusal names the shipkit command, the narrow way in, and the exact line that lifts it. The guard reads the deny list out of the settings FILE, so deleting one `Bash(...)` line lifts both mechanisms at once. It also catches what a prefix rule cannot (`CI=1 bun test`, `./node_modules/.bin/tsc`) and segments at quote depth zero, so a commit message quoting a runner is data. SK013 FAILs when the declared guard script does not resolve: a hook that errors refuses nothing.
|
|
7
|
+
- **`shipkit init` repairs an existing repo, not just a fresh one**, calling the same fixers `shipkit check --fix` runs. That is the half that matters, because the repos that need this most already exist. `shipkit start` inherits it.
|
|
8
|
+
- **A `--only` run repairs the mechanical slots ALONE.** The structural rule fixers rewrite package.json and repo config, and arming rewrites `hooks/`. That is the right price for the full gate and the wrong one for `bun run test`, which now routes here: the reflex verb must not leave files in an agent's diff that it never wrote, and under CI a formatting rewrite came back as a test failure.
|
|
9
|
+
- **Escaping it**: delete the `Bash(<runner>*)` line from `.claude/settings.json`; or `repo.config.ts oneWayIn: { exempt: ['test'] }`, which drops the script requirement and the shell rules together (an unknown name throws at config load); a slot turned off with `defaults: { lint: false }` is exempt already. `bun run test:raw <name filter>` stays open for iterating on one suite. A repo with sub-packages gives each one its own `test:raw`, or the escape does not reach where the work is. The formatter is deliberately not denied at all.
|
|
10
|
+
- `--allow-dirty` now says WHY it is safe in one place and not the other: `npm publish` packs the working tree, not the commit, so dirt in other paths cannot reach the tarball and dirt inside the unit ships a half-written file under a tag that describes something else.
|
|
11
|
+
|
|
3
12
|
## 0.32.0
|
|
4
13
|
|
|
5
14
|
- **`shipkit pb collection <name>` scaffolds a PocketBase collection's whole UI folder.** Building a CRUD screen meant retyping a schema that already exists in the app's generated types, one column at a time, and the boring half of that is exactly where a column goes missing. The command reads `src/pb.types.ts` (`.tsx` and `.d.ts` too), resolves one collection, and writes `src/collections/<name>/`: `columns.tsx` with one entry per response field, `card.tsx`, `grid.tsx` over `LiveGrid`, `form.tsx` (a create/edit modal writing through `db` fire and forget), and an `index.ts` calling `defineCollectionUI`. It then registers the folder in `src/collections/index.ts`, creating that file when it is the first one, because a collection folder nothing imports is dead code whose failure mode is a runtime throw. A field the schema cannot turn into a cell on its own (relation, file, json, raw html) is emitted COMMENTED with the reason rather than dropped, because a missing column is invisible and a commented one takes ten seconds to accept or delete. The card leads on the fields a human reads, not on the first three of an alphabetical record block, and the label picks the first of `name`, `label`, `title`, `number`, `code`, `email`, else `id`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rvlib-shipkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.0",
|
|
4
4
|
"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",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -28,6 +28,9 @@
|
|
|
28
28
|
"mobx": "^7.0.0",
|
|
29
29
|
"mobx-react-lite": "^5.0.0"
|
|
30
30
|
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test:raw": "bun test"
|
|
33
|
+
},
|
|
31
34
|
"dependencies": {
|
|
32
35
|
"@napi-rs/canvas": "^1.0.2",
|
|
33
36
|
"clipanion": "^4.0.0-rc.4",
|
|
@@ -60,7 +60,9 @@ export function routedBody(id: string, opts: ScriptStyle = {}): string {
|
|
|
60
60
|
* under a shipkit checkout (a repo calling rvlib's source-graph cli by path)
|
|
61
61
|
* is not a route back in. */
|
|
62
62
|
export function routesToShipkit(body: string): boolean {
|
|
63
|
-
|
|
63
|
+
// the `ci` verb must belong to the shipkit invocation, not merely appear
|
|
64
|
+
// somewhere in the line: `shipkit contracts && bun run x:ci` routes nothing
|
|
65
|
+
return /\bshipkit\b\S*(?:\s+\S+)*?\s+ci(?:\s|$)/.test(body.split(/&&|\|\||;/)[0] ?? body)
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
// recover the consumption style from existing scripts: any shipkit command
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// ONE WAY IN, the harness half (build/shipkit.md). The package.json half
|
|
2
|
+
// (rule SK001) routes `bun run test` into shipkit, but `bun test` is a bun
|
|
3
|
+
// BUILTIN: no script can intercept it, so a package.json alone leaves the
|
|
4
|
+
// reflex wide open. Claude Code permission rules can close it.
|
|
5
|
+
//
|
|
6
|
+
// ONE table, two renderings, because two hand-kept lists drift:
|
|
7
|
+
// - `permissions.deny` in .claude/settings.json — declarative, unbypassable
|
|
8
|
+
// (deny beats allow at every settings level, and bypassPermissions mode
|
|
9
|
+
// does not lift it), and it survives a missing bun.
|
|
10
|
+
// - a PreToolUse hook — the only thing that can put a REASON in front of the
|
|
11
|
+
// model. A bare deny says "Permission to use Bash with command bun test
|
|
12
|
+
// has been denied." and nothing else, so the agent goes looking for
|
|
13
|
+
// another way in, which is the failure this whole file exists to prevent.
|
|
14
|
+
// The deny is the floor and the hook is the messenger. When the hook cannot
|
|
15
|
+
// run, enforcement degrades to terse rather than to nothing.
|
|
16
|
+
//
|
|
17
|
+
// Subprocesses are NOT intercepted (verified): shipkit spawning oxlint or
|
|
18
|
+
// `bun run test:raw` from inside a permitted `shipkit ci` is invisible to
|
|
19
|
+
// these rules, which is exactly what makes the gate itself still work.
|
|
20
|
+
|
|
21
|
+
export type RawRunner = {
|
|
22
|
+
/** the command head as an agent types it — the deny rule is this + `*` */
|
|
23
|
+
head: string
|
|
24
|
+
/** the floor slot it answers, so ONE exemption governs the script name and
|
|
25
|
+
* the shell command together: a repo that keeps `test` raw is not then
|
|
26
|
+
* refused `bun test` */
|
|
27
|
+
slot: 'test' | 'lint' | 'typecheck'
|
|
28
|
+
/** the shipkit command that answers the same question, cache and all */
|
|
29
|
+
use: string
|
|
30
|
+
/** the narrower way in, when there is one: iterating on a single repro
|
|
31
|
+
* test while you fix a bug is not a verification and stays normal. A
|
|
32
|
+
* FILTER, not a path: `bun run` quotes what it forwards, and bun reads a
|
|
33
|
+
* quoted path as a name filter that then matches nothing */
|
|
34
|
+
iterate?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// the house stack, plus the foreign runners a wired repo is actually found
|
|
38
|
+
// carrying. Kept tight on purpose: a deny an agent hits for no good reason
|
|
39
|
+
// teaches it to route around the whole file.
|
|
40
|
+
export const RAW_RUNNERS: readonly RawRunner[] = [
|
|
41
|
+
{ head: 'bun test', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <name filter>' },
|
|
42
|
+
{ head: 'bunx vitest', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <name filter>' },
|
|
43
|
+
{ head: 'npx vitest', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <name filter>' },
|
|
44
|
+
{ head: 'vitest', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <name filter>' },
|
|
45
|
+
{ head: 'npx jest', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <name filter>' },
|
|
46
|
+
{ head: 'jest', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <name filter>' },
|
|
47
|
+
{ head: 'bunx tsc', slot: 'typecheck', use: 'shipkit ci --only typecheck' },
|
|
48
|
+
{ head: 'npx tsc', slot: 'typecheck', use: 'shipkit ci --only typecheck' },
|
|
49
|
+
{ head: 'tsc', slot: 'typecheck', use: 'shipkit ci --only typecheck' },
|
|
50
|
+
{ head: 'bunx oxlint', slot: 'lint', use: 'shipkit ci --only lint' },
|
|
51
|
+
{ head: 'npx oxlint', slot: 'lint', use: 'shipkit ci --only lint' },
|
|
52
|
+
{ head: 'oxlint', slot: 'lint', use: 'shipkit ci --only lint' },
|
|
53
|
+
{ head: 'npx eslint', slot: 'lint', use: 'shipkit ci --only lint' },
|
|
54
|
+
{ head: 'eslint', slot: 'lint', use: 'shipkit ci --only lint' },
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
// the FORMATTER is deliberately absent, and that is not an oversight. It
|
|
58
|
+
// answers no cached question and produces no green anyone could mistake for a
|
|
59
|
+
// verification, shipkit's own fix pass runs it BY SCRIPT (`bun run fmt`), and
|
|
60
|
+
// denying the bare binary while that stays open would be theatre. `shipkit ci`
|
|
61
|
+
// formats before it gates, which is the only reason the reflex matters at all.
|
|
62
|
+
|
|
63
|
+
/** the `permissions.deny` entries, in the syntax Claude Code matches:
|
|
64
|
+
* `Bash(<head>*)` is a prefix match, and a compound command is checked one
|
|
65
|
+
* segment at a time, so `cd x && bun test` is caught too */
|
|
66
|
+
export const denyRules = (exemptSlots: Iterable<string> = []): string[] => {
|
|
67
|
+
const off = new Set(exemptSlots)
|
|
68
|
+
return RAW_RUNNERS.filter((r) => !off.has(r.slot)).map((r) => `Bash(${r.head}*)`)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** what a refusal says. Never just the ban: a refusal with no replacement
|
|
72
|
+
* sends the agent hunting for another runner. `segment` is the part of the
|
|
73
|
+
* command that offended, shown only when it says more than the head already
|
|
74
|
+
* does: a long compound refused on `CI=1 bun test --timeout 5` otherwise
|
|
75
|
+
* leaves the reader guessing which of its clauses was the problem */
|
|
76
|
+
export function refusal(r: RawRunner, segment?: string): string {
|
|
77
|
+
const alt = r.iterate == null ? '' : `, or \`${r.iterate}\` to iterate on ONE suite while you fix a bug`
|
|
78
|
+
const which = segment == null || segment.trim() === '' || segment.trim() === r.head ? '' : ` (refused on \`${segment.trim().slice(0, 80)}\`)`
|
|
79
|
+
return `\`${r.head}\`${which} bypasses shipkit, so nothing is cached and no other agent's run can be attached to. Use \`${r.use}\`${alt}. Lifting this for good: delete the "Bash(${r.head}*)" line from .claude/settings.json permissions.deny (build/shipkit.md, ONE WAY IN).`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** shell wrappers that do not change what is being run */
|
|
83
|
+
const WRAPPERS = new Set(['time', 'timeout', 'nice', 'nohup', 'stdbuf', 'command', 'builtin', 'sudo', 'env'])
|
|
84
|
+
|
|
85
|
+
/** the heads a settings file actually denies. The guard asks THE FILE rather
|
|
86
|
+
* than the table, so deleting a `Bash(...)` line lifts the deny AND the
|
|
87
|
+
* hook's refusal in one edit: two mechanisms an agent has to find and lift
|
|
88
|
+
* separately is the deny it cannot escape, which is worse than the disease */
|
|
89
|
+
export function deniedHeads(settings: unknown): Set<string> {
|
|
90
|
+
const deny = (settings as { permissions?: { deny?: unknown } } | null)?.permissions?.deny
|
|
91
|
+
const out = new Set<string>()
|
|
92
|
+
if (!Array.isArray(deny)) return out
|
|
93
|
+
for (const rule of deny) {
|
|
94
|
+
const m = typeof rule === 'string' ? /^Bash\((.+?)\*?\)$/.exec(rule) : null
|
|
95
|
+
if (m != null) out.add(m[1]!.trim())
|
|
96
|
+
}
|
|
97
|
+
return out
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** a word that could be the name of a program. Anything carrying a quote, a
|
|
101
|
+
* parenthesis or a bracket is prose or code, not an invocation, and matching
|
|
102
|
+
* it is how a guard earns a reputation for crying wolf */
|
|
103
|
+
const PROGRAM_WORD = /^[\w./@+-]+$/
|
|
104
|
+
|
|
105
|
+
/** split a command line on the shell operators, at QUOTE DEPTH ZERO only.
|
|
106
|
+
* Without that, `git commit -m "why cd x && bun test is refused"` splits
|
|
107
|
+
* inside the message and the second half reads as a command. House commit
|
|
108
|
+
* bodies are dense and multi-line, so this is not a corner case here, it is
|
|
109
|
+
* the most common shape of command an agent runs. */
|
|
110
|
+
export function shellSegments(command: string): string[] {
|
|
111
|
+
// everything after a heredoc opener is the document, never a command
|
|
112
|
+
const shell = command.split(/<<-?\s*['"]?\w+/)[0] ?? command
|
|
113
|
+
const out: string[] = []
|
|
114
|
+
let cur = ''
|
|
115
|
+
let quote: '"' | "'" | null = null
|
|
116
|
+
for (let i = 0; i < shell.length; i++) {
|
|
117
|
+
const ch = shell[i]!
|
|
118
|
+
if (quote != null) {
|
|
119
|
+
cur += ch
|
|
120
|
+
if (ch === '\\' && quote === '"') { cur += shell[++i] ?? ''; continue }
|
|
121
|
+
if (ch === quote) quote = null
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
if (ch === '"' || ch === "'") { quote = ch; cur += ch; continue }
|
|
125
|
+
if (ch === '\n' || ch === ';' || ch === '|' || ch === '&') {
|
|
126
|
+
// `&&` and `||` are one operator, and a lone `&` backgrounds
|
|
127
|
+
if ((ch === '&' || ch === '|') && shell[i + 1] === ch) i++
|
|
128
|
+
out.push(cur)
|
|
129
|
+
cur = ''
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
cur += ch
|
|
133
|
+
}
|
|
134
|
+
out.push(cur)
|
|
135
|
+
return out
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** the raw runner this shell command reaches for, plus the segment that
|
|
139
|
+
* offended, or null.
|
|
140
|
+
*
|
|
141
|
+
* Deliberately conservative in BOTH directions. It must catch the forms a
|
|
142
|
+
* prefix deny rule misses (a leading `CI=1`, `./node_modules/.bin/tsc`), and
|
|
143
|
+
* it must never fire on a command that merely CONTAINS the words: a heredoc
|
|
144
|
+
* body is data and so is a quoted commit message, and a false refusal teaches
|
|
145
|
+
* an agent to route around the whole file. So: the heredoc body is dropped,
|
|
146
|
+
* segments are cut at quote depth zero, each is reduced to its first program
|
|
147
|
+
* word, and that word must match a head exactly or as the basename of a path.
|
|
148
|
+
* Longest head first, so `bunx tsc` is never reported as `tsc`. `only`
|
|
149
|
+
* narrows to the heads this repo really denies. */
|
|
150
|
+
export function rawRunnerIn(command: string, only?: ReadonlySet<string>): RawRunner | null {
|
|
151
|
+
return rawRunnerHit(command, only)?.runner ?? null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function rawRunnerHit(command: string, only?: ReadonlySet<string>): { runner: RawRunner; segment: string } | null {
|
|
155
|
+
const heads = [...RAW_RUNNERS]
|
|
156
|
+
.filter((r) => only == null || only.has(r.head))
|
|
157
|
+
.sort((a, b) => b.head.length - a.head.length)
|
|
158
|
+
for (const seg of shellSegments(command)) {
|
|
159
|
+
let s = seg.trim().replace(/^[(){}\s]+/, '')
|
|
160
|
+
// leading VAR=value assignments, then a wrapper word, repeatedly. A
|
|
161
|
+
// wrapper's own flags and durations go with it (`timeout 60 bun test`)
|
|
162
|
+
let wrapped = false
|
|
163
|
+
for (;;) {
|
|
164
|
+
const next = s.replace(/^\w+=(?:"[^"]*"|'[^']*'|\S*)\s+/, '')
|
|
165
|
+
if (next !== s) { s = next; continue }
|
|
166
|
+
const w = /^(\S+)\s+/.exec(s)
|
|
167
|
+
if (w != null && WRAPPERS.has(w[1]!.split('/').pop() ?? '')) { s = s.slice(w[0].length); wrapped = true; continue }
|
|
168
|
+
if (wrapped && w != null && /^(?:-\S*|\d+[smhd]?)$/.test(w[1]!)) { s = s.slice(w[0].length); continue }
|
|
169
|
+
break
|
|
170
|
+
}
|
|
171
|
+
const words = s.split(/\s+/).filter((w) => w !== '')
|
|
172
|
+
const first = words[0]
|
|
173
|
+
if (first == null || !PROGRAM_WORD.test(first)) continue
|
|
174
|
+
// a path-qualified binary counts: ./node_modules/.bin/tsc IS tsc
|
|
175
|
+
const program = first.split('/').pop() ?? first
|
|
176
|
+
for (const r of heads) {
|
|
177
|
+
const parts = r.head.split(' ')
|
|
178
|
+
if (parts[0] !== program) continue
|
|
179
|
+
if (parts.length === 1 || words[1] === parts[1]) return { runner: r, segment: seg }
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return null
|
|
183
|
+
}
|
package/src/core/run-ci.ts
CHANGED
|
@@ -102,8 +102,16 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
102
102
|
const c = o.config ?? (await loadRepoConfig(root))
|
|
103
103
|
// mechanical failures are not information, so they never cost a round trip
|
|
104
104
|
let fixFailed = false
|
|
105
|
+
// a NARROWED run repairs only the mechanical slots. The structural rule
|
|
106
|
+
// fixers rewrite package.json and repo config, and arming rewrites hooks/:
|
|
107
|
+
// that is the right price for the full gate and the wrong one for
|
|
108
|
+
// `bun run test`, which routes here and must answer the one question its
|
|
109
|
+
// name promises without leaving files in the agent's diff that it never
|
|
110
|
+
// wrote. Under CI it is worse than surprising, a formatting rewrite comes
|
|
111
|
+
// back as a test failure.
|
|
112
|
+
const narrowed = (o.onlyIds ?? []).length > 0
|
|
105
113
|
if (o.fix !== false)
|
|
106
|
-
fixFailed = (await runFixPass(root, c, { rules: o.fixRules !== false, armHooks: o.armHooks === true, print: sink })).failed
|
|
114
|
+
fixFailed = (await runFixPass(root, c, { rules: !narrowed && o.fixRules !== false, armHooks: !narrowed && o.armHooks === true, print: sink })).failed
|
|
107
115
|
else sink('skip: fix pass (--no-fix)')
|
|
108
116
|
const all = allTasks(c, root)
|
|
109
117
|
const { disabled, unfilled } = all
|
|
@@ -124,7 +132,14 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
124
132
|
for (const d of disabled) sink(`skip: ${d} (skipped-by-config)`)
|
|
125
133
|
// a floor slot nothing could fill is VISIBLE — a thin gate must never
|
|
126
134
|
// look like a full one (the fresh-repo floor gap)
|
|
127
|
-
for (const u of unfilled)
|
|
135
|
+
for (const u of unfilled) {
|
|
136
|
+
sink(`skip: ${u.id} (${u.reason})`)
|
|
137
|
+
// asked for BY NAME and unfillable: this is where an agent lands after
|
|
138
|
+
// the harness refused it the raw runner, so the line says what to add
|
|
139
|
+
// rather than leaving it with `unknown task id` and no next move
|
|
140
|
+
if ((o.onlyIds ?? []).includes(u.id))
|
|
141
|
+
sink(` fix: add a '${u.id}:raw' script holding the tool, then \`shipkit ci --only ${u.id}\` runs it (meta/rv-conventions/package-json.md)`)
|
|
142
|
+
}
|
|
128
143
|
// the release survey is ~4s of network that five discovered checks need
|
|
129
144
|
// and the TASKS do not. Started HERE it overlaps lint/typecheck/test and
|
|
130
145
|
// costs the run nothing; started inside the first check that wants it, it
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// the PreToolUse guard for ONE WAY IN (core/one-way-in-policy.ts owns the
|
|
3
|
+
// table and the wording). Its ONE job is to refuse a raw runner with the
|
|
4
|
+
// shipkit command to type instead: `permissions.deny` already blocks these,
|
|
5
|
+
// but a bare deny tells the model only that it was denied, and a refusal with
|
|
6
|
+
// no replacement just sends it looking for another way in.
|
|
7
|
+
//
|
|
8
|
+
// It runs before EVERY Bash call, so it stays a leaf: one tiny import, one
|
|
9
|
+
// small json read, no clipanion, no repo config. Anything it cannot decide it
|
|
10
|
+
// waves through, because a guard that fails closed on its own bugs would wedge
|
|
11
|
+
// a repo with no way to tell why, and `permissions.deny` is still the floor
|
|
12
|
+
// underneath it. Measured at ~10ms per call.
|
|
13
|
+
import { deniedHeads, rawRunnerHit, refusal } from '../core/one-way-in-policy.ts'
|
|
14
|
+
|
|
15
|
+
const input = await Bun.stdin.text()
|
|
16
|
+
let command = ''
|
|
17
|
+
try {
|
|
18
|
+
const ev = JSON.parse(input) as { tool_name?: string; tool_input?: { command?: string } }
|
|
19
|
+
if (ev.tool_name !== 'Bash') process.exit(0)
|
|
20
|
+
command = ev.tool_input?.command ?? ''
|
|
21
|
+
} catch {
|
|
22
|
+
process.exit(0)
|
|
23
|
+
}
|
|
24
|
+
// the settings file is the authority on WHICH runners this repo refuses, so
|
|
25
|
+
// deleting one `Bash(...)` line lifts the deny and this refusal together
|
|
26
|
+
const settingsPath = `${process.env['CLAUDE_PROJECT_DIR'] ?? process.cwd()}/.claude/settings.json`
|
|
27
|
+
let denied: Set<string>
|
|
28
|
+
try {
|
|
29
|
+
denied = deniedHeads(await Bun.file(settingsPath).json())
|
|
30
|
+
} catch {
|
|
31
|
+
process.exit(0)
|
|
32
|
+
}
|
|
33
|
+
const hit = command === '' || denied.size === 0 ? null : rawRunnerHit(command, denied)
|
|
34
|
+
if (hit == null) process.exit(0)
|
|
35
|
+
// exit 2 is the block: stderr becomes the reason the model reads
|
|
36
|
+
// naming the segment earns its place only when the command had MORE in it:
|
|
37
|
+
// on a bare call it repeats the head back at the reader
|
|
38
|
+
console.error(refusal(hit.runner, hit.segment.trim() === command.trim() ? undefined : hit.segment))
|
|
39
|
+
process.exit(2)
|
package/src/init-repo.ts
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
import { existsSync, readFileSync, writeFileSync } from 'fs'
|
|
10
10
|
import { join } from 'path'
|
|
11
11
|
import { canonicalBodies } from './core/canonical-scripts.ts'
|
|
12
|
-
import { hasTestFiles } from './rules/SK001-package-json-shape.ts'
|
|
12
|
+
import { hasTestFiles, packageJsonShape } from './rules/SK001-package-json-shape.ts'
|
|
13
|
+
import { oneWayInPolicy } from './rules/SK013-one-way-in-policy.ts'
|
|
13
14
|
import { reportToMotherhome } from './core/motherhome.ts'
|
|
14
15
|
import { ensureGitRepo, publishRepo, type Run } from './git-bootstrap.ts'
|
|
15
16
|
import { defineRepo, loadRepoConfig, resolveFloor } from './repo-config.ts'
|
|
@@ -67,6 +68,9 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
67
68
|
const cfg = await loadRepoConfig(root)
|
|
68
69
|
const g = await gitignorePolicy.fix!({ root, config: cfg })
|
|
69
70
|
p(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
|
|
71
|
+
// the repos that need ONE WAY IN most are the ones that already exist,
|
|
72
|
+
// so a re-run REPAIRS rather than only seeding a fresh checkout
|
|
73
|
+
await repairOneWayIn(root, cfg, p)
|
|
70
74
|
const mh = reportToMotherhome(root, cfg)
|
|
71
75
|
if (mh.note != null) p(mh.note)
|
|
72
76
|
if (wired && opts.noPublish !== true) await publishRepo(root, { github: opts.github, gh: opts.gh, print: p })
|
|
@@ -99,6 +103,7 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
99
103
|
// `shipkit check --fix SK004`, one code path
|
|
100
104
|
const g = await gitignorePolicy.fix!({ root, config: defineRepo({ name }) })
|
|
101
105
|
p(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
|
|
106
|
+
await repairOneWayIn(root, defineRepo({ name }), p)
|
|
102
107
|
// what the gate actually runs, in ONE line: a thin floor must be seen at
|
|
103
108
|
// init time rather than discovered after a bad commit slipped through.
|
|
104
109
|
// The per-task resolution is diagnostics, and a `skip:` naming a script
|
|
@@ -128,3 +133,16 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
128
133
|
if (opts.noPublish !== true) p('next: `shipkit ci` is the gate — run it to check your work')
|
|
129
134
|
return 0
|
|
130
135
|
}
|
|
136
|
+
|
|
137
|
+
/** ONE WAY IN, both halves, through the SAME fixers `shipkit check --fix` runs
|
|
138
|
+
* (build/shipkit.md). Nothing here is a second implementation: rule SK001
|
|
139
|
+
* routes the package.json scripts, rule SK013 writes the harness policy, and
|
|
140
|
+
* init just calls them so a wired repo is born enforced and a re-run repairs
|
|
141
|
+
* one that was not. The deny is a RESTRICTION, which is why it lands
|
|
142
|
+
* unconditionally where the scaffold's allow list stays opt in. */
|
|
143
|
+
async function repairOneWayIn(root: string, config: Parameters<typeof defineRepo>[0], p: (line: string) => void): Promise<void> {
|
|
144
|
+
const pkg = await packageJsonShape.fix!({ root, config })
|
|
145
|
+
if (pkg.changed) p(`ok: package.json — ${pkg.summary}`)
|
|
146
|
+
const policy = await oneWayInPolicy.fix!({ root, config })
|
|
147
|
+
p(policy.changed ? `ok: ${policy.summary}` : `ok: .claude/settings.json already closes every raw runner`)
|
|
148
|
+
}
|
|
@@ -12,7 +12,8 @@ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'fs'
|
|
|
12
12
|
import { join } from 'path'
|
|
13
13
|
import { canonicalBodies, conditionalBodies, inferBin, ROUTED_SCRIPTS, rawName, routedBody, routesToShipkit } from '../core/canonical-scripts.ts'
|
|
14
14
|
import type { CheckCtx } from '../core/check-ctx.ts'
|
|
15
|
-
import { resolveFloor
|
|
15
|
+
import { resolveFloor } from '../core/floor.ts'
|
|
16
|
+
import type { RepoConfig } from '../repo-config.ts'
|
|
16
17
|
import type { FixResult, Rule, RuleEnv } from './rule.ts'
|
|
17
18
|
|
|
18
19
|
type Pkg = { name?: string; private?: boolean; license?: string; scripts?: Record<string, string> }
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// SK013 — the harness half of ONE WAY IN (build/shipkit.md). Rule SK001 routes
|
|
2
|
+
// the package.json scripts, and that alone is not enough: `bun test` is a bun
|
|
3
|
+
// BUILTIN, so no script can intercept it and the reflex stays open. This rule
|
|
4
|
+
// makes the repo carry the two things that CAN close it, and repairs them:
|
|
5
|
+
//
|
|
6
|
+
// - `permissions.deny` — declarative and unbypassable (deny beats allow at
|
|
7
|
+
// every settings level, and bypassPermissions does not lift it)
|
|
8
|
+
// - a PreToolUse hook — the only way to put a REASON in front of the model.
|
|
9
|
+
// A bare deny says only that the call was denied, and a refusal with no
|
|
10
|
+
// replacement sends the agent looking for another runner.
|
|
11
|
+
//
|
|
12
|
+
// Both are generated from ONE table (core/one-way-in-policy.ts). The file is
|
|
13
|
+
// TRACKED on purpose: `.claude/settings.local.json` is gitignored by the house
|
|
14
|
+
// scaffold, so a policy written there would bind exactly one checkout.
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
16
|
+
import { dirname, join } from 'path'
|
|
17
|
+
import { inferBin } from '../core/canonical-scripts.ts'
|
|
18
|
+
import { denyRules, RAW_RUNNERS } from '../core/one-way-in-policy.ts'
|
|
19
|
+
import type { RepoConfig } from '../repo-config.ts'
|
|
20
|
+
import type { FixResult, Rule, RuleEnv } from './rule.ts'
|
|
21
|
+
|
|
22
|
+
const OWNER = 'build/shipkit.md'
|
|
23
|
+
export const SETTINGS_PATH = '.claude/settings.json'
|
|
24
|
+
|
|
25
|
+
type Settings = {
|
|
26
|
+
permissions?: { deny?: string[] }
|
|
27
|
+
hooks?: Record<string, Array<{ matcher?: string; hooks?: Array<{ type?: string; command?: string }> }>>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** slots this repo does not route, so it must not be refused the shell command
|
|
31
|
+
* either: ONE declaration governs the script name and the raw call together */
|
|
32
|
+
export function exemptSlots(c: RepoConfig): Set<string> {
|
|
33
|
+
const off = new Set(c.oneWayIn?.exempt ?? [])
|
|
34
|
+
for (const [slot, def] of Object.entries(c.defaults ?? {})) if (def === false) off.add(slot)
|
|
35
|
+
return off
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** how the hook invokes the guard. `$CLAUDE_PROJECT_DIR` because a hook's cwd
|
|
39
|
+
* is not promised to be the repo root */
|
|
40
|
+
export function guardCommand(bin?: string): string {
|
|
41
|
+
const path = bin == null
|
|
42
|
+
? 'node_modules/rvlib-shipkit/src/hooks/one-way-in-guard.ts'
|
|
43
|
+
: bin.replace(/cli\.ts$/, 'hooks/one-way-in-guard.ts')
|
|
44
|
+
return `bun "$CLAUDE_PROJECT_DIR/${path}"`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const readSettings = (root: string): Settings | null => {
|
|
48
|
+
const p = join(root, SETTINGS_PATH)
|
|
49
|
+
if (!existsSync(p)) return null
|
|
50
|
+
try { return JSON.parse(readFileSync(p, 'utf8')) as Settings } catch { return null }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const guardHookCommand = (s: Settings): string | null => {
|
|
54
|
+
for (const m of s.hooks?.['PreToolUse'] ?? [])
|
|
55
|
+
for (const h of m.hooks ?? [])
|
|
56
|
+
if (h.command?.includes('one-way-in-guard') === true) return h.command
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const hasGuardHook = (s: Settings): boolean => guardHookCommand(s) != null
|
|
61
|
+
|
|
62
|
+
/** the guard file the hook command points at, resolved against the repo root.
|
|
63
|
+
* A hook whose script does not exist errors on EVERY Bash call and refuses
|
|
64
|
+
* nothing, so the agent sees only the bare deny, which is the one failure
|
|
65
|
+
* this rule exists to prevent. Declaring it and never running it is worse
|
|
66
|
+
* than not declaring it, so it FAILs. */
|
|
67
|
+
function guardScriptMissing(root: string, command: string): string | null {
|
|
68
|
+
const m = /["']?\$(?:\{)?CLAUDE_PROJECT_DIR(?:\})?\/([^"'\s]+)["']?/.exec(command)
|
|
69
|
+
const rel = m?.[1]
|
|
70
|
+
if (rel == null) return null // a form this rule cannot resolve is not a form it may condemn
|
|
71
|
+
return existsSync(join(root, rel)) ? null : rel
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const readBin = (root: string): string | undefined => {
|
|
75
|
+
try { return inferBin((JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { scripts?: Record<string, string> }).scripts ?? {}) }
|
|
76
|
+
catch { return undefined }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const oneWayInPolicy: Rule = {
|
|
80
|
+
id: 'SK013',
|
|
81
|
+
blocking: true,
|
|
82
|
+
title: 'one way in policy',
|
|
83
|
+
owner: OWNER,
|
|
84
|
+
lint: (t, env) => {
|
|
85
|
+
const want = denyRules(exemptSlots(env.config))
|
|
86
|
+
if (want.length === 0) return t.ok('every governed slot is exempt (repo.config oneWayIn/defaults) — nothing to deny')
|
|
87
|
+
const s = readSettings(env.root)
|
|
88
|
+
if (s == null) {
|
|
89
|
+
t.fail(`${SETTINGS_PATH} ${existsSync(join(env.root, SETTINGS_PATH)) ? 'is unparseable' : 'missing'} — nothing stops an agent typing \`bun test\`, and a package.json script cannot intercept a bun builtin`)
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
const have = new Set(s.permissions?.deny ?? [])
|
|
93
|
+
const missing = want.filter((r) => !have.has(r))
|
|
94
|
+
if (missing.length > 0)
|
|
95
|
+
t.fail(`${SETTINGS_PATH}: ${missing.length} raw runner(s) still permitted (${missing.slice(0, 3).join(', ')}${missing.length > 3 ? ', …' : ''}) — those calls never reach shipkit, so nothing is cached and no run can be attached to`)
|
|
96
|
+
const guard = guardHookCommand(s)
|
|
97
|
+
if (guard == null)
|
|
98
|
+
t.fail(`${SETTINGS_PATH}: no PreToolUse guard — a bare deny tells the model only that it was denied, so it goes looking for another runner instead of the shipkit command`)
|
|
99
|
+
else {
|
|
100
|
+
const missing = guardScriptMissing(env.root, guard)
|
|
101
|
+
if (missing != null)
|
|
102
|
+
t.fail(
|
|
103
|
+
`${SETTINGS_PATH}: the PreToolUse guard points at ${missing}, which does not exist — every Bash call errors and no refusal ever names the shipkit command`,
|
|
104
|
+
`bun install (or bun add -d rvlib-shipkit)`,
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
if (!t.failed) t.ok(`${SETTINGS_PATH} closes ${want.length}/${RAW_RUNNERS.length} raw runner(s), with the replacement named`)
|
|
108
|
+
},
|
|
109
|
+
// MERGES, never rewrites: this file also carries the attribution silencer,
|
|
110
|
+
// a repo's own allow list and its own hooks, and clobbering those to fix a
|
|
111
|
+
// deny list would cost more than the finding it repairs
|
|
112
|
+
fix: (env: RuleEnv): FixResult => {
|
|
113
|
+
const want = denyRules(exemptSlots(env.config))
|
|
114
|
+
if (want.length === 0) return { changed: false, summary: 'every governed slot is exempt — nothing to write' }
|
|
115
|
+
const p = join(env.root, SETTINGS_PATH)
|
|
116
|
+
const existing = readSettings(env.root)
|
|
117
|
+
if (existing == null && existsSync(p)) return { changed: false, summary: `${SETTINGS_PATH} is unparseable — repair it by hand first, a merge would destroy it` }
|
|
118
|
+
const s: Settings = existing ?? {}
|
|
119
|
+
const changes: string[] = []
|
|
120
|
+
const deny = s.permissions?.deny ?? []
|
|
121
|
+
const added = want.filter((r) => !deny.includes(r))
|
|
122
|
+
if (added.length > 0) {
|
|
123
|
+
s.permissions = { ...s.permissions, deny: [...deny, ...added] }
|
|
124
|
+
changes.push(`${added.length} raw runner(s) denied`)
|
|
125
|
+
}
|
|
126
|
+
if (!hasGuardHook(s)) {
|
|
127
|
+
const pre = s.hooks?.['PreToolUse'] ?? []
|
|
128
|
+
s.hooks = { ...s.hooks, PreToolUse: [...pre, { matcher: 'Bash', hooks: [{ type: 'command', command: guardCommand(readBin(env.root)) }] }] }
|
|
129
|
+
changes.push('PreToolUse guard wired (every refusal names the shipkit command)')
|
|
130
|
+
}
|
|
131
|
+
if (changes.length === 0) return { changed: false, summary: `${SETTINGS_PATH} already closes every raw runner` }
|
|
132
|
+
mkdirSync(dirname(p), { recursive: true })
|
|
133
|
+
writeFileSync(p, JSON.stringify(s, null, 2) + '\n')
|
|
134
|
+
return { changed: true, summary: `${SETTINGS_PATH}: ${changes.join(', ')}` }
|
|
135
|
+
},
|
|
136
|
+
}
|
|
@@ -15,6 +15,7 @@ import { commitAiTells } from './SK009-commit-ai-tells.ts'
|
|
|
15
15
|
import { undeclaredReleaseUnit } from './SK010-undeclared-release-unit.ts'
|
|
16
16
|
import { siblingUnitRanges } from './SK011-sibling-unit-ranges.ts'
|
|
17
17
|
import { configBarrelImport } from './SK012-config-barrel-import.ts'
|
|
18
|
+
import { oneWayInPolicy } from './SK013-one-way-in-policy.ts'
|
|
18
19
|
|
|
19
20
|
export const RULES: readonly Rule[] = [
|
|
20
21
|
packageJsonShape,
|
|
@@ -29,6 +30,7 @@ export const RULES: readonly Rule[] = [
|
|
|
29
30
|
undeclaredReleaseUnit,
|
|
30
31
|
siblingUnitRanges,
|
|
31
32
|
configBarrelImport,
|
|
33
|
+
oneWayInPolicy,
|
|
32
34
|
]
|
|
33
35
|
|
|
34
36
|
// THE id shape, one place: 2-4 uppercase letters + exactly 3 digits. `SK` is
|
|
@@ -3,10 +3,8 @@
|
|
|
3
3
|
"allow": [
|
|
4
4
|
"Bash(shipkit *)",
|
|
5
5
|
"Bash(bun run *)",
|
|
6
|
-
"Bash(bun test*)",
|
|
7
6
|
"Bash(bun install*)",
|
|
8
7
|
"Bash(bun add *)",
|
|
9
|
-
"Bash(bunx tsc*)",
|
|
10
8
|
"Bash(bunx wrangler*)",
|
|
11
9
|
"Bash(git status*)",
|
|
12
10
|
"Bash(git diff*)",
|
|
@@ -15,7 +13,6 @@
|
|
|
15
13
|
"Bash(git commit*)",
|
|
16
14
|
"Bash(git push*)",
|
|
17
15
|
"Bash(curl *)",
|
|
18
|
-
"Bash(oxlint*)",
|
|
19
16
|
"Bash(./deploy.sh)"
|
|
20
17
|
],
|
|
21
18
|
"deny": []
|
|
@@ -11,7 +11,7 @@ description: Prove a change in THIS project actually works — drive the real fl
|
|
|
11
11
|
|
|
12
12
|
## The rules (house-wide)
|
|
13
13
|
- Failing repro test BEFORE the fix. Green checks ≠ verification.
|
|
14
|
-
- Logic → fast headless
|
|
14
|
+
- Logic → fast headless bun tests, run through `shipkit ci --only test` (tiny ~10-line test files reusing fixtures). While you fix one bug, `bun run test:raw <name filter>` is the narrow way in.
|
|
15
15
|
- Look & feel → NEVER screenshot-self-verify; the repo owner playtests it.
|
|
16
16
|
- Verify as the end user would: drive the real flow, observe the real signal.
|
|
17
17
|
|