@sriinnu/omit 0.3.0 → 0.4.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/bin/omit.mjs CHANGED
@@ -3,21 +3,26 @@
3
3
  // CI, and any other agent's hook system all call the same commands.
4
4
  //
5
5
  // omit init [agents|claude|cursor|cline|windsurf|all] copy rule files into this repo
6
- // omit audit [--base <ref>] [--json|--markdown] net diff, new deps, hazards, score
6
+ // omit audit [--base <ref>] [--json|--markdown] net diff, new deps, hazards
7
7
  // omit check <file...> hazard-scan specific files
8
8
  // omit gate audit the staged diff; fail on hazards/uncited deps
9
+ // omit verify re-check every claim in .omit/receipts.jsonl
10
+ // omit leak "<cmd>" would this command print a real secret to stdout?
9
11
  // omit hook install add the gate to .git/hooks/pre-commit
12
+ // omit hook install codex write .codex/hooks.json (live sentinels inside Codex CLI)
10
13
  import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from 'node:fs'
11
- import { execSync } from 'node:child_process'
12
14
  import { basename, dirname, join } from 'node:path'
13
15
  import { fileURLToPath } from 'node:url'
14
- import { isManifest, addedDeps } from '../lib/deps.mjs'
16
+ import { isManifest, addedDeps, unparsedDependencyFile } from '../lib/deps.mjs'
17
+ import { git, probe, repoRoot, fileAtRevision, GitError } from '../lib/git.mjs'
18
+ import { LEDGER, verifyLedger, newDepCitations } from '../lib/receipts.mjs'
15
19
  import { findHazards } from '../lib/hazards.mjs'
16
20
  import { lintFiles } from '../lib/lint.mjs'
17
21
  import { assessCommand } from '../lib/danger.mjs'
22
+ import { assessLeak } from '../lib/leaks.mjs'
18
23
 
24
+ const cwd = process.cwd()
19
25
  const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
20
- const git = (args) => execSync(`git ${args}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
21
26
 
22
27
  // ---------- init ----------
23
28
  const targets = {
@@ -50,69 +55,332 @@ function init(pick = 'agents') {
50
55
  }
51
56
 
52
57
  // ---------- audit / gate ----------
58
+ const die = (msg) => {
59
+ console.error(`omit: ${msg}`)
60
+ process.exit(1)
61
+ }
62
+
63
+ // The repo root, resolved once and threaded through everything below. git prints
64
+ // and resolves `rev:path` against the ROOT, so a cwd-relative join reads the
65
+ // wrong tree the moment this runs from a subdirectory — and silently: the path is
66
+ // simply not found, and a file that is not found declares no dependencies.
67
+ function rootOrDie() {
68
+ const root = repoRoot(cwd)
69
+ if (!root) die('not a git repository — audit and gate measure a diff against git history')
70
+ return root
71
+ }
72
+
73
+ const splitLines = (text) => {
74
+ const lines = text.split('\n')
75
+ if (lines[lines.length - 1] === '') lines.pop()
76
+ return lines
77
+ }
78
+
79
+ // The blob the index holds for a path — the file as the commit would contain it,
80
+ // or null when the index does not hold it at all. Written as `:0:<path>` rather
81
+ // than `:<path>` because git reads `:.omit/receipts.jsonl` as a malformed
82
+ // revision and refuses to answer: for a path that is not staged, a refusal is
83
+ // indistinguishable from an absence, and the ledger lives under `.omit/`.
84
+ function indexText(root, path) {
85
+ if (!path) return null
86
+ const r = fileAtRevision(root, '', path)
87
+ if (r.present) return r.text
88
+ if (r.failed) die(`could not read ${path} from the index — ${r.reason}`)
89
+ return null // not in the index: nothing there
90
+ }
91
+
92
+ // What a path actually holds: the working copy, or the index blob when the gate
93
+ // is judging a commit. null means there is genuinely nothing to read there (a
94
+ // deleted path); a git that cannot answer is an error, never an empty file.
95
+ function contentOf(root, path, staged) {
96
+ if (staged) return indexText(root, path)
97
+ try {
98
+ return readFileSync(join(root, path), 'utf8')
99
+ } catch (e) {
100
+ if (e.code === 'ENOENT') return null
101
+ die(`could not read ${path} — ${e.message}`)
102
+ }
103
+ }
104
+
105
+ // A file's lines, or null when it isn't text to be counted (binary, unreadable).
106
+ function readLines(root, path) {
107
+ const text = contentOf(root, path, false)
108
+ return text === null || text.includes('\0') ? null : splitLines(text)
109
+ }
110
+
111
+ function manifestText(root, path, oldPath, version) {
112
+ if (version === '') return indexText(root, path) ?? '' // the index: what is being committed
113
+ if (version !== null) {
114
+ const r = fileAtRevision(root, version, oldPath)
115
+ // A revision that could not be read is not "this manifest declares nothing".
116
+ // That substitution is the difference between a new dependency and no new
117
+ // dependency, made silently.
118
+ if (r.failed) die(`could not read ${oldPath} at ${version} — ${r.reason}`)
119
+ return r.present ? r.text : '' // deleted, or new here: declares nothing
120
+ }
121
+ return contentOf(root, path, false) ?? '' // deleted: declares nothing
122
+ }
123
+
124
+ // `--base` names a revision. An unresolvable one — a typo, a shallow clone, a
125
+ // force-push survivor — used to diff to nothing and render as a clean verdict,
126
+ // which is an audit of nothing reported as a pass. git would also read an
127
+ // unresolvable argument as a PATHSPEC and silently diff a directory, so it is
128
+ // resolved as a revision here rather than left to `git diff` to guess.
129
+ function requireRevision(root, range) {
130
+ if (/\.\./.test(range)) die(`--base ${range}: expected a single revision — audit measures the change against one revision`)
131
+ if (!probe(root, ['rev-parse', '--verify', '--quiet', `${range}^{commit}`]).ok) {
132
+ die(`${range} is not a revision this repo can resolve — refusing to measure a change against nothing (no commits yet, a typo, a shallow clone, or a rewritten history?)`)
133
+ }
134
+ }
135
+
136
+ // numstat rows. `-z` because a renamed path is otherwise printed as the brace
137
+ // composite `{old => new}/package.json` — not a path at all: `git show` and a
138
+ // filesystem read both fail on it, so both revisions of a rename commit read as
139
+ // empty and a dependency added there goes uncited. With -z the pre-image and
140
+ // post-image names arrive as separate fields:
141
+ // ordinary `A\tD\t<path>\0`
142
+ // rename `A\tD\t\0<old>\0<new>\0`
143
+ function parseNumstat(out) {
144
+ const fields = out.split('\0')
145
+ const rows = []
146
+ for (let i = 0; i < fields.length; i++) {
147
+ const head = fields[i]
148
+ if (!head) continue
149
+ const [added, deleted, path] = head.split('\t')
150
+ if (path === '') {
151
+ const oldPath = fields[++i] ?? ''
152
+ const newPath = fields[++i] ?? ''
153
+ rows.push({ path: newPath, oldPath, added, deleted })
154
+ } else {
155
+ rows.push({ path, oldPath: path, added, deleted })
156
+ }
157
+ }
158
+ return rows
159
+ }
160
+
161
+ // The added lines, which is the set every hazard rule reads — so it has to be all
162
+ // of them. A `startsWith('+++')` filter drops a real content line that begins
163
+ // with `++` (the patch prefixes it to `++++`), which hides a secret on that line
164
+ // and makes the count disagree with numstat. Skip the file header as a block.
165
+ function addedLinesOf(patch) {
166
+ const added = []
167
+ let header = true
168
+ for (const l of patch.split('\n')) {
169
+ if (l.startsWith('diff --git ')) {
170
+ header = true
171
+ continue
172
+ }
173
+ if (header) {
174
+ // The block ends at the post-image header or the first hunk. Every other
175
+ // header line (index, mode, similarity, rename) starts with neither, so
176
+ // skipping the block cannot drop an added line.
177
+ if (l.startsWith('+++ ') || l.startsWith('@@')) header = false
178
+ continue
179
+ }
180
+ if (l.startsWith('+')) added.push(l.slice(1))
181
+ }
182
+ return added
183
+ }
184
+
185
+ // The receipts the gate weighs are the ones in the COMMIT, not the ones on disk.
186
+ // `--cached` judges the index, and a working-tree ledger the commit does not
187
+ // carry — or contradicts — verified nothing that ships: an untracked ledger used
188
+ // to cite a dependency while the commit contained no ledger at all.
189
+ function ledgerForChange(root, staged) {
190
+ if (!staged) return { inChange: true, results: verifyLedger(root), citations: newDepCitations(root) }
191
+ const stagedLedger = indexText(root, LEDGER)
192
+ if (stagedLedger === null) return { inChange: false, citations: new Map(), results: [] }
193
+ let disk = null
194
+ try {
195
+ disk = readFileSync(join(root, LEDGER), 'utf8')
196
+ } catch {}
197
+ const lf = (s) => s.replace(/\r\n/g, '\n') // a CRLF checkout is not a different ledger
198
+ if (disk === null || lf(disk) !== lf(stagedLedger)) {
199
+ die(
200
+ `the staged ${LEDGER} is not the file on disk — receipts are checked against the working tree, so a ledger that differs from the one being committed was never the one checked. Stage the same content, or restore it with \`git checkout -- ${LEDGER}\`.`
201
+ )
202
+ }
203
+ return { inChange: true, results: verifyLedger(root), citations: newDepCitations(root) }
204
+ }
205
+
206
+ // `git config core.hooksPath /tmp/empty` disarms the pre-commit gate for good and
207
+ // leaves no trace in `git status` or in any diff. This is the only evidence there
208
+ // is, so it is read and reported rather than assumed absent.
209
+ // `--show-origin` because where it is set is what tells a machine-wide setting
210
+ // apart from `git config core.hooksPath /tmp/empty` run a moment ago.
211
+ function hooksPathAt(root) {
212
+ const r = probe(root, ['config', '--show-origin', '--get', 'core.hooksPath'])
213
+ if (!r.ok) return null
214
+ const line = r.out.trim().replace(/\n.*$/, '')
215
+ const tab = line.indexOf('\t')
216
+ return tab === -1 ? { origin: '(unknown)', value: line } : { origin: line.slice(0, tab), value: line.slice(tab + 1) }
217
+ }
218
+
53
219
  function collect(diffRange) {
54
- const numstat = git(`diff --numstat ${diffRange}`).split('\n').filter(Boolean)
220
+ const root = rootOrDie()
221
+ const staged = diffRange === '--cached'
222
+ if (!staged) requireRevision(root, diffRange)
223
+
55
224
  const files = []
225
+ const binaries = []
56
226
  let added = 0
227
+ let countedAdded = 0
57
228
  let deleted = 0
58
- for (const row of numstat) {
59
- const [a, d, path] = row.split('\t')
60
- if (a === '-') continue // binary
61
- files.push({ path, added: +a, deleted: +d })
62
- added += +a
63
- deleted += +d
229
+ for (const row of parseNumstat(git(root, ['diff', '--numstat', '-z', '-M', diffRange]))) {
230
+ if (row.added === '-') {
231
+ binaries.push(row) // git says binary — read it by content below
232
+ continue
233
+ }
234
+ files.push({ path: row.path, oldPath: row.oldPath, added: +row.added, deleted: +row.deleted })
235
+ added += +row.added
236
+ countedAdded += +row.added
237
+ deleted += +row.deleted
238
+ }
239
+
240
+ // Untracked files are invisible to `git diff`, and a working tree mid-task is
241
+ // mostly untracked: new files, not yet added. Counting them is the difference
242
+ // between auditing the change and auditing only the part that got staged.
243
+ // Excluded from the staged range — a gate judges what is being committed, and
244
+ // an untracked file is not in the commit.
245
+ const untracked = new Map()
246
+ if (!staged) {
247
+ for (const path of git(root, ['ls-files', '--others', '--exclude-standard']).split('\n').filter(Boolean)) {
248
+ if (path.startsWith('.omit/')) continue // omit's own ledger, not the change
249
+ const lines = readLines(root, path)
250
+ if (!lines) continue // no text to count: a binary file adds no line to scan
251
+ untracked.set(path, lines)
252
+ files.push({ path, oldPath: path, added: lines.length, deleted: 0 })
253
+ added += lines.length
254
+ }
255
+ }
256
+
257
+ const patchAdded = addedLinesOf(git(root, ['diff', '-M', diffRange]))
258
+ // numstat and the patch describe the same diff, so their added-line totals have
259
+ // to agree. If they do not, part of the change went unread — and "scanned
260
+ // nothing" must not be able to render as "found nothing".
261
+ if (patchAdded.length !== countedAdded) {
262
+ die(`the diff reports ${countedAdded} added lines but only ${patchAdded.length} could be read — refusing to report a verdict on a change that could not be read in full`)
64
263
  }
65
264
 
265
+ const addedLines = [...patchAdded]
266
+ for (const lines of untracked.values()) addedLines.push(...lines)
267
+
268
+ // numstat calls a path binary: either it is one, or an attribute says so — and
269
+ // the second is a way to erase a file from the scan. `src/config.js -diff` in
270
+ // .git/info/attributes makes numstat report `-` and the patch carry no `+`
271
+ // lines, so a committed secret renders as hazards: 0. The content is read
272
+ // directly instead: how git chose to represent a file is a formatting decision,
273
+ // and it must not decide whether the file is examined.
274
+ for (const b of binaries) {
275
+ const text = contentOf(root, b.path, staged)
276
+ if (text === null) continue // gone from the index, or deleted: nothing left to scan
277
+ files.push({ path: b.path, oldPath: b.oldPath, added: 0, deleted: 0, binary: true })
278
+ addedLines.push(...splitLines(text))
279
+ }
280
+
281
+ // Dependencies are compared as parsed sets between the two revisions, so a
282
+ // minified manifest is read the same as a pretty-printed one.
66
283
  const newDeps = []
67
284
  for (const f of files.filter((f) => isManifest(f.path))) {
68
- const diff = git(`diff ${diffRange} -- "${f.path}"`)
69
- newDeps.push(...addedDeps(basename(f.path), diff).map((dep) => ({ dep, manifest: f.path })))
285
+ const before = manifestText(root, f.path, f.oldPath, staged ? 'HEAD' : diffRange)
286
+ const after = manifestText(root, f.path, f.path, staged ? '' : null)
287
+ newDeps.push(...addedDeps(basename(f.path), before, after).map((dep) => ({ dep, manifest: f.path })))
70
288
  }
71
289
 
72
- const fullDiff = git(`diff ${diffRange}`)
73
- const addedLines = fullDiff.split('\n').filter((l) => l.startsWith('+') && !l.startsWith('+++')).map((l) => l.slice(1))
290
+ // Dependency-shaped files omit cannot parse — setup.py, build.gradle, a csproj.
291
+ // A dependency added to one of those is invisible here, and an invisible
292
+ // dependency renders as `new deps: 0 ✅`, which is a claim about a file nobody
293
+ // read. It is reported, never failed: plenty of repos carry these for reasons
294
+ // that have nothing to do with a dependency.
295
+ const unparsed = [...new Set(files.map((f) => f.path).filter((p) => unparsedDependencyFile(p)))].sort()
296
+
74
297
  const hazards = findHazards(addedLines)
75
298
  const footnotes = addedLines.filter((l) => /omitted:/.test(l)).length
76
299
  const loadBearing = addedLines.filter((l) => /load-bearing:/.test(l)).length
77
300
 
78
- let receipts = ''
79
- if (existsSync('.omit/receipts.jsonl')) receipts = readFileSync('.omit/receipts.jsonl', 'utf8')
80
- const uncitedDeps = newDeps.filter((d) => !receipts.includes(d.dep))
301
+ // A new dependency is cited only by a VERIFIED new-dep receipt. A ledger line
302
+ // that merely names the dep is an assertion, and assertions are the thing this
303
+ // mechanism exists to stop accepting.
304
+ const ledger = ledgerForChange(root, staged)
305
+ const uncitedDeps = newDeps.filter((d) => ledger.citations.get(d.dep)?.status !== 'verified')
81
306
 
82
- const lint = lintFiles(process.cwd(), files.map((f) => f.path).filter((p) => existsSync(p)))
307
+ const lint = lintFiles(root, files.map((f) => f.path).filter((p) => existsSync(join(root, p))))
83
308
 
84
- return { files, added, deleted, net: added - deleted, newDeps, uncitedDeps, hazards, footnotes, loadBearing, lint }
309
+ return {
310
+ files,
311
+ added,
312
+ deleted,
313
+ net: added - deleted,
314
+ newDeps,
315
+ unparsed,
316
+ uncitedDeps,
317
+ ledgerInChange: ledger.inChange,
318
+ receipts: summarize(ledger.results),
319
+ receiptFailures: ledger.results.filter((r) => r.status === 'failed'),
320
+ hazards,
321
+ footnotes,
322
+ loadBearing,
323
+ lint,
324
+ hooksPath: hooksPathAt(root),
325
+ }
85
326
  }
86
327
 
87
- function scoreOf(r) {
88
- let s = 100
89
- if (r.net > 0) s -= Math.min(30, Math.round(r.net / 25))
90
- s -= 15 * r.newDeps.length
91
- s -= Math.min(40, 25 * r.hazards.filter((h) => h.type === 'secret').length + 10 * r.hazards.filter((h) => h.type === 'injection').length)
92
- s += Math.min(10, 2 * r.footnotes)
93
- s -= Math.min(20, 10 * r.files.filter((f) => f.added > 300).length)
94
- s -= Math.min(20, 10 * (r.lint ?? []).filter((l) => !l.ok).length)
95
- return Math.max(0, Math.min(100, s))
328
+ // A linter that never started is neither a pass nor a failure, and `ok` alone
329
+ // cannot tell them apart: it stays true for `unavailable` (configured, nothing to
330
+ // run it with) and `not-run` (execution is off) so that `!ok` keeps meaning "the
331
+ // lint found something". Rendering on `ok` alone prints `eslint ✅` for a linter
332
+ // that never ran, which is a false pass — worse than the false "no linter
333
+ // configured" it replaced. So the verdict says which of the four it was.
334
+ // The reason a linter did not run, one line and short enough for a verdict row.
335
+ // `not run: X` is the entry's own phrasing and reads as a stutter under a "not
336
+ // run" label, so the prefix comes off.
337
+ const lintReason = (l) => (l.output ?? '').replace(/^not run: /, '').split('\n')[0].slice(0, 80)
338
+
339
+ const lintLine = (lint) => {
340
+ if (!lint || lint.length === 0) return 'no linter configured'
341
+ const ran = lint.filter((l) => l.status === 'ok' || l.status === 'fail')
342
+ const idle = lint.filter((l) => l.status !== 'ok' && l.status !== 'fail')
343
+ const parts = []
344
+ const failing = ran.filter((l) => l.status === 'fail')
345
+ if (failing.length) parts.push(`${failing.map((l) => l.linter).join(', ')} failing ⛔`)
346
+ const passing = ran.filter((l) => l.status === 'ok')
347
+ if (passing.length) parts.push(`${passing.map((l) => l.linter).join(', ')} ✅`)
348
+ if (idle.length) parts.push(`${idle.map((l) => `${l.linter} ${l.status === 'not-run' ? 'not run' : 'unavailable'} (${lintReason(l)})`).join(' · ')} ⚠`)
349
+ return parts.join(' · ')
96
350
  }
97
351
 
352
+ const summarize = (rs) => ({
353
+ total: rs.length,
354
+ verified: rs.filter((r) => r.status === 'verified').length,
355
+ failed: rs.filter((r) => r.status === 'failed').length,
356
+ unverifiable: rs.filter((r) => r.status === 'unverifiable').length,
357
+ })
358
+
98
359
  function render(r, fmt) {
99
- const score = scoreOf(r)
100
- const light = score >= 85 ? '🟢' : score >= 60 ? '🟡' : '🔴'
101
- if (fmt === 'json') return JSON.stringify({ ...r, score }, null, 2)
360
+ if (fmt === 'json') return JSON.stringify(r, null, 2)
361
+ const binaries = r.files.filter((f) => f.binary).length
362
+ const unparsed = r.unparsed ?? []
102
363
  const rows = [
103
- `net: +${r.added} −${r.deleted} lines across ${r.files.length} file${r.files.length === 1 ? '' : 's'}`,
104
- `new deps: ${r.newDeps.length}${r.newDeps.length ? ': ' + r.newDeps.map((d) => d.dep).join(', ') : ' ✅'}${r.uncitedDeps.length ? ` (${r.uncitedDeps.length} without receipts ⚠)` : ''}`,
364
+ `net: +${r.added} −${r.deleted} lines across ${r.files.length} file${r.files.length === 1 ? '' : 's'}${binaries ? ` (${binaries} binary — scanned by content, git counts no lines)` : ''}`,
365
+ // "0" with a ✅ next to it is the overclaim: where a dependency-shaped file
366
+ // omit cannot read changed, nothing was counted AND nothing could be.
367
+ r.newDeps.length || unparsed.length === 0
368
+ ? `new deps: ${r.newDeps.length}${r.newDeps.length ? ': ' + r.newDeps.map((d) => d.dep).join(', ') : ' ✅'}${r.uncitedDeps.length ? ` (${r.uncitedDeps.length} without receipts ⚠)` : ''}`
369
+ : `new deps: 0 (${unparsed.length} dependency-shaped file${unparsed.length === 1 ? '' : 's'} not parsed)`,
105
370
  `hazards: ${r.hazards.length === 0 ? '0 ✅' : r.hazards.map((h) => `${h.type}:${h.rule}`).join(', ') + ' ⛔'}`,
106
371
  `footnotes: ${r.footnotes} recorded · load-bearing: ${r.loadBearing} marked`,
107
- `lint: ${!r.lint || r.lint.length === 0 ? 'no linter configured' : r.lint.every((l) => l.ok) ? r.lint.map((l) => l.linter).join(', ') + ' ✅' : r.lint.filter((l) => !l.ok).map((l) => l.linter).join(', ') + ' failing ⛔'}`,
108
- `omit score: ${score}/100 ${light}`,
372
+ `receipts: ${r.receipts.total === 0 ? 'none recorded' : `${r.receipts.verified}/${r.receipts.total} verified${r.receipts.failed ? ` · ${r.receipts.failed} FAILED ⛔` : ''}${r.receipts.unverifiable ? ` · ${r.receipts.unverifiable} unverifiable ⚠` : ''}`}`,
373
+ `lint: ${lintLine(r.lint)}`,
109
374
  ]
375
+ if (unparsed.length) rows.push(`unparsed deps: ${unparsed.join(', ')} — dependency-shaped, omit does not parse ${unparsed.length === 1 ? 'it' : 'them'} ⚠`)
376
+ if (r.hooksPath) rows.push(`hooks: core.hooksPath=${r.hooksPath.value} (set in ${r.hooksPath.origin}) — the pre-commit gate installed here is NOT the hook git runs ⛔`)
110
377
  if (fmt === 'markdown') return `### omit verdict\n\n${rows.map((x) => `- ${x}`).join('\n')}\n`
111
378
  return `omit verdict\n────────────\n${rows.join('\n')}`
112
379
  }
113
380
 
114
381
  function audit(args) {
115
382
  const baseIdx = args.indexOf('--base')
383
+ if (baseIdx !== -1 && (!args[baseIdx + 1] || args[baseIdx + 1].startsWith('-'))) die('usage: omit audit [--base <ref>] [--json|--markdown]')
116
384
  const base = baseIdx !== -1 ? args[baseIdx + 1] : 'HEAD'
117
385
  const fmt = args.includes('--json') ? 'json' : args.includes('--markdown') ? 'markdown' : 'text'
118
386
  console.log(render(collect(base), fmt))
@@ -121,13 +389,41 @@ function audit(args) {
121
389
  function gate() {
122
390
  const r = collect('--cached')
123
391
  console.log(render(r, 'text'))
392
+ // A shadowed hooks directory is reported, not fatal: `core.hooksPath` is a
393
+ // legitimate global setup (a hooks manager, a shared hooks repo), and failing
394
+ // every commit over the user's own configuration would be a false objection.
395
+ // The row above already says the gate omit installs is not the hook git runs;
396
+ // `hook install` now writes where git actually looks, so the honest fix lives
397
+ // there rather than in refusing to work.
124
398
  const secrets = r.hazards.filter((h) => h.type === 'secret')
125
399
  if (secrets.length) {
126
400
  console.error(`\n⛔ omit gate: ${secrets.length} secret(s) staged. Move to env/secrets manager, rotate if real, restage.`)
127
401
  process.exit(1)
128
402
  }
403
+ if (r.receiptFailures.length) {
404
+ console.error(
405
+ `\n⛔ omit gate: ${r.receiptFailures.length} receipt(s) in ${LEDGER} did not survive their own check — a refuted claim is a fabricated citation:\n` +
406
+ r.receiptFailures.map((f) => ` line ${f.at} (${f.claim ?? 'no claim'}): ${f.checks.filter((c) => c.ok === false).map((c) => c.detail).join('; ')}`).join('\n') +
407
+ `\nFix the receipt or the code it cites, then re-run.`
408
+ )
409
+ process.exit(1)
410
+ }
129
411
  if (r.uncitedDeps.length) {
130
- console.error(`\n⛔ omit gate: new dep(s) without receipts: ${r.uncitedDeps.map((d) => d.dep).join(', ')}. Append citations to .omit/receipts.jsonl or drop them.`)
412
+ console.error(
413
+ `\n⛔ omit gate: new dep(s) without a verified receipt: ${r.uncitedDeps.map((d) => d.dep).join(', ')}.` +
414
+ (r.ledgerInChange
415
+ ? ''
416
+ : `\n ${LEDGER} is not part of this commit — receipts are read from the index, so a ledger that is not being committed cites nothing.`) +
417
+ `\nAdd one line to ${LEDGER} naming the dep and the omission you tried (each entry is re-checked, and must NOT hold):\n` +
418
+ // The printed line has to be a receipt that verifies: a template whose
419
+ // shape the checker rejects leaves an agent that followed it verbatim
420
+ // blocked on its own instructions. `absent` names a symbol the checker
421
+ // searches the whole tracked tree for, and the claim survives only if the
422
+ // search comes back empty — so replace it with the symbol you actually
423
+ // looked for.
424
+ ` {"claim":"new-dep","rung":7,"dep":"${r.uncitedDeps[0].dep}","tried":[{"rung":2,"absent":"<symbol you searched for>"}]}\n` +
425
+ `Run \`omit verify\` to check it before committing.`
426
+ )
131
427
  process.exit(1)
132
428
  }
133
429
  if (r.hazards.length) {
@@ -158,19 +454,29 @@ function check(files) {
158
454
  }
159
455
 
160
456
  function lint(files) {
457
+ // Files named on the command line are relative to where the caller stands; a
458
+ // list derived from git is root-relative, because that is how git reports it.
459
+ // Keep the two apart instead of resolving one against the other's base.
460
+ let base = cwd
161
461
  if (!files.length) {
162
- files = git('diff --name-only HEAD').split('\n').filter(Boolean)
163
- try {
164
- files.push(...git('ls-files --others --exclude-standard').split('\n').filter(Boolean))
165
- } catch {}
166
- files = files.filter((f) => existsSync(f))
462
+ base = rootOrDie()
463
+ files = [
464
+ ...git(base, ['diff', '--name-only', '-M', 'HEAD']).split('\n'),
465
+ ...git(base, ['ls-files', '--others', '--exclude-standard']).split('\n'),
466
+ ].filter((f) => f && existsSync(join(base, f)))
167
467
  }
168
- const results = lintFiles(process.cwd(), files)
468
+ const results = lintFiles(base, files)
169
469
  if (results.length === 0) {
170
470
  console.log('omit lint: no configured linter applies to these files')
171
471
  return
172
472
  }
173
- for (const r of results) console.log(`[${r.linter}] ${r.ok ? 'pass ✅' : `fail ⛔\n${r.output}`}`)
473
+ for (const r of results) {
474
+ // Same distinction as the verdict: a linter that did not run gets neither a
475
+ // pass nor a failure, and exits 0 — it is unrun, not clean.
476
+ if (!r.ok) console.log(`[${r.linter}] fail ⛔\n${r.output}`)
477
+ else if (r.status === 'ok') console.log(`[${r.linter}] pass ✅`)
478
+ else console.log(`[${r.linter}] not run ⚠ — ${lintReason(r)}`)
479
+ }
174
480
  process.exit(results.some((r) => !r.ok) ? 1 : 0)
175
481
  }
176
482
 
@@ -189,14 +495,103 @@ function guard(args) {
189
495
  process.exit(1)
190
496
  }
191
497
 
498
+ function leak(args) {
499
+ const command = args.join(' ')
500
+ if (!command) {
501
+ console.error('usage: omit leak "<shell command>"')
502
+ process.exit(1)
503
+ }
504
+ const findings = assessLeak(command)
505
+ if (findings.length === 0) {
506
+ console.log('ok')
507
+ return
508
+ }
509
+ for (const f of findings) console.error(`[${f.rule}] ${f.reason}`)
510
+ process.exit(1)
511
+ }
512
+
513
+ // Codex CLI's hook schema is the same shape as Claude Code's (confirmed against
514
+ // developers.openai.com/codex/hooks: PreToolUse fires with tool_input.command for
515
+ // Bash, exit 2 blocks). Command-sentinel and leak-sentinel run on that shape as-is.
516
+ // The PostToolUse file hooks (dep/hazard/lint-sentinel) and the Stop gate are wired
517
+ // too since Codex documents the same events, but Codex's apply_patch tool_input
518
+ // shape for PostToolUse isn't confirmed here — those three no-op safely if the
519
+ // file_path field isn't present, so this is best-effort, not verified parity.
520
+ function hookInstallCodex() {
521
+ const dir = '.codex'
522
+ const hooksPath = join(dir, 'hooks.json')
523
+ mkdirSync(dir, { recursive: true })
524
+
525
+ let doc = { hooks: {} }
526
+ if (existsSync(hooksPath)) {
527
+ try {
528
+ doc = JSON.parse(readFileSync(hooksPath, 'utf8'))
529
+ } catch {
530
+ console.error(`omit: ${hooksPath} exists but isn't valid JSON — fix or remove it first`)
531
+ process.exit(1)
532
+ }
533
+ }
534
+ doc.hooks ??= {}
535
+ for (const [event, entry] of Object.entries(doc.hooks)) {
536
+ if (entry !== undefined && !Array.isArray(entry)) {
537
+ console.error(`omit: ${hooksPath} has a malformed "${event}" entry (expected an array of matcher groups) — fix or remove it first`)
538
+ process.exit(1)
539
+ }
540
+ }
541
+
542
+ const scriptCmd = (script) => `node "${join(pkgRoot, 'hooks', script)}"`
543
+ const mergeHook = (event, matcher, script) => {
544
+ doc.hooks[event] ??= []
545
+ let group = doc.hooks[event].find((g) => g.matcher === matcher)
546
+ if (!group) {
547
+ group = { matcher, hooks: [] }
548
+ doc.hooks[event].push(group)
549
+ }
550
+ const command = scriptCmd(script)
551
+ if (!group.hooks.some((h) => h.command === command)) group.hooks.push({ type: 'command', command })
552
+ }
553
+
554
+ mergeHook('PreToolUse', 'Bash', 'command-sentinel.mjs')
555
+ mergeHook('PreToolUse', 'Bash', 'leak-sentinel.mjs')
556
+ mergeHook('PostToolUse', 'apply_patch|Edit|Write', 'dep-sentinel.mjs')
557
+ mergeHook('PostToolUse', 'apply_patch|Edit|Write', 'hazard-sentinel.mjs')
558
+ mergeHook('PostToolUse', 'apply_patch|Edit|Write', 'lint-sentinel.mjs')
559
+ mergeHook('Stop', '', 'final-draft-gate.mjs')
560
+
561
+ writeFileSync(hooksPath, JSON.stringify(doc, null, 2) + '\n')
562
+ console.log(`wrote ${hooksPath}`)
563
+ console.log(' verified against Codex\'s documented schema: command sentinel + leak sentinel run on Bash commands (same tool_input.command shape as Claude Code)')
564
+ console.log(' best-effort, unverified: dep/hazard/lint sentinels + Final Draft gate on apply_patch/Edit/Write — they no-op safely if the field shape differs, report back if you see them miss real edits')
565
+ console.log('\nCodex requires trusting new hook definitions once per session: run `/hooks` in Codex to review, or start with --dangerously-bypass-hook-trust for unattended runs.')
566
+ }
567
+
568
+ // The gate goes in the repository's own hooks directory — always, never into
569
+ // `core.hooksPath`. That setting is global on most machines that use it (a
570
+ // hooks manager, a shared hooks repo), so writing there would install ONE
571
+ // pre-commit gate for EVERY repository on the machine when the user asked to
572
+ // gate this one. `hookInstall` reports the shadowing instead and leaves that
573
+ // choice to the user.
574
+ function hooksDir() {
575
+ return join('.git', 'hooks')
576
+ }
577
+
578
+ // `core.hooksPath` and the file it came from, or null when it is not set.
579
+ function hooksPathShadow() {
580
+ const r = probe(process.cwd(), ['config', '--show-origin', '--get', 'core.hooksPath'])
581
+ if (!r.ok) return null
582
+ const [origin, value] = r.out.trim().split('\t')
583
+ return value ? { origin, value } : null
584
+ }
585
+
192
586
  function hookInstall() {
193
- const hookPath = join('.git', 'hooks', 'pre-commit')
194
587
  if (!existsSync('.git')) {
195
588
  console.error('omit: not a git repository')
196
589
  process.exit(1)
197
590
  }
591
+ const dir = hooksDir()
592
+ const hookPath = join(dir, 'pre-commit')
198
593
  if (existsSync(hookPath) && readFileSync(hookPath, 'utf8').includes('omit gate')) {
199
- console.log('skip pre-commit gate already installed')
594
+ console.log(`skip pre-commit gate already installed at ${hookPath}`)
200
595
  return
201
596
  }
202
597
  const line = '\nnpx -y @sriinnu/omit gate || exit 1\n'
@@ -206,20 +601,85 @@ function hookInstall() {
206
601
  writeFileSync(hookPath, '#!/bin/sh' + line)
207
602
  }
208
603
  chmodSync(hookPath, 0o755)
209
- console.log('wrote .git/hooks/pre-commit: every commit now passes the omit gate, whatever agent wrote it')
604
+ console.log(`wrote ${hookPath}: every commit now passes the omit gate, whatever agent wrote it`)
605
+ const shadow = hooksPathShadow()
606
+ if (shadow) {
607
+ console.log(
608
+ `\n⚠ core.hooksPath is set to ${shadow.value} (${shadow.origin}), so git will not run ${hookPath}.\n` +
609
+ ' omit installs into the repository, not into a global hooks directory: one gate for every\n' +
610
+ ' repo on this machine is not what "install the gate here" means.\n' +
611
+ ` To make this gate effective here: git config core.hooksPath .git/hooks\n` +
612
+ ` To use your global directory instead, add this line to ${shadow.value}/pre-commit:\n` +
613
+ ' npx -y @sriinnu/omit gate || exit 1'
614
+ )
615
+ }
616
+ }
617
+
618
+ // The manifests the working tree change touches, tracked or not. `verify` needs
619
+ // them because "no claims recorded" and "nothing to check" are not the same
620
+ // thing: a manifest that changed with no ledger at all is the case this
621
+ // mechanism exists to catch.
622
+ function changedManifests(root) {
623
+ const out = new Set()
624
+ for (const f of git(root, ['ls-files', '--others', '--exclude-standard']).split('\n')) if (f && isManifest(f)) out.add(f)
625
+ if (probe(root, ['rev-parse', '--verify', '--quiet', 'HEAD']).ok) {
626
+ for (const f of git(root, ['diff', '--name-only', '-M', 'HEAD']).split('\n')) if (f && isManifest(f)) out.add(f)
627
+ }
628
+ return [...out]
629
+ }
630
+
631
+ // Every claim in the ledger, re-checked. This is the command the whole receipts
632
+ // mechanism exists for: `verify` passes only when nothing was taken on faith.
633
+ function verify() {
634
+ const root = repoRoot(cwd)
635
+ const results = verifyLedger(root ?? cwd)
636
+ if (results.length === 0) {
637
+ const manifests = root ? changedManifests(root) : []
638
+ if (manifests.length) {
639
+ die(`no ${LEDGER}, but ${manifests.join(', ')} changed — a manifest change with no receipt is the thing this checks, and it used to read as "nothing to check". Record a receipt in ${LEDGER}, or revert the manifest change.`)
640
+ }
641
+ console.log(`omit verify: no ${LEDGER} — no claims recorded, nothing to check`)
642
+ return
643
+ }
644
+ for (const r of results) {
645
+ console.log(`${r.status === 'verified' ? '✅' : r.status === 'failed' ? '⛔' : '⚠ '} line ${String(r.at).padStart(3)} ${r.status.padEnd(13)} ${r.claim ?? '(no claim)'}`)
646
+ for (const c of r.checks) if (c.ok !== true) console.log(` [${c.kind}] ${c.detail}`)
647
+ }
648
+ const s = summarize(results)
649
+ console.log(`\n${s.verified}/${s.total} claims survived re-checking · ${s.failed} refuted · ${s.unverifiable} unverifiable`)
650
+ if (s.failed || s.unverifiable) {
651
+ console.error('omit verify: every claim has to survive its own check. A refuted one is a citation that does not hold; an unverifiable one is still a self-report.')
652
+ process.exit(1)
653
+ }
210
654
  }
211
655
 
212
656
  // ---------- dispatch ----------
657
+ // A git call that could not answer reaches here as a throw, and it exits non-zero
658
+ // on purpose: a verdict rendered from a diff that could not be read is the
659
+ // failure this whole contract exists to prevent, and a gate that cannot read the
660
+ // change has to block it rather than pass it.
213
661
  const [cmd, ...rest] = process.argv.slice(2)
214
- if (cmd === 'audit') audit(rest)
215
- else if (cmd === 'gate') gate()
216
- else if (cmd === 'check') check(rest)
217
- else if (cmd === 'lint') lint(rest)
218
- else if (cmd === 'guard') guard(rest)
219
- else if (cmd === 'hook' && rest[0] === 'install') hookInstall()
220
- else if (cmd === 'init') init(rest[0])
221
- else if (targets[cmd]) init(cmd) // back-compat: `omit cursor`
222
- else {
223
- console.error('usage: omit <init|audit|check|gate|lint|guard|hook install>')
224
- process.exit(cmd ? 1 : 0)
662
+ try {
663
+ if (cmd === 'audit') audit(rest)
664
+ else if (cmd === 'gate') gate()
665
+ else if (cmd === 'check') check(rest)
666
+ else if (cmd === 'lint') lint(rest)
667
+ else if (cmd === 'guard') guard(rest)
668
+ else if (cmd === 'leak') leak(rest)
669
+ else if (cmd === 'verify') verify()
670
+ else if (cmd === 'hook' && rest[0] === 'install' && rest[1] === 'codex') hookInstallCodex()
671
+ else if (cmd === 'hook' && rest[0] === 'install' && rest[1] === undefined) hookInstall()
672
+ else if (cmd === 'hook' && rest[0] === 'install') {
673
+ console.error(`omit: unrecognized 'hook install' target '${rest[1]}' — usage: omit hook install [codex]`)
674
+ process.exit(1)
675
+ }
676
+ else if (cmd === 'init') init(rest[0])
677
+ else if (targets[cmd]) init(cmd) // back-compat: `omit cursor`
678
+ else {
679
+ console.error('usage: omit <init|audit|check|gate|lint|guard|leak|verify|hook install|hook install codex>')
680
+ process.exit(cmd ? 1 : 0)
681
+ }
682
+ } catch (e) {
683
+ if (!(e instanceof GitError)) throw e
684
+ die(`git could not answer: ${e.message}\nNothing was checked. Fix the repository state (or the revision named), then re-run.`)
225
685
  }