@sriinnu/omit 0.3.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/.clinerules/omit.md +15 -0
- package/.cursor/rules/omit.mdc +25 -0
- package/.windsurf/rules/omit.md +15 -0
- package/AGENTS.md +43 -0
- package/LICENSE +21 -0
- package/README.md +175 -0
- package/action.yml +26 -0
- package/bench/README.md +13 -0
- package/bench/bench.config.example.json +18 -0
- package/bench/run.mjs +103 -0
- package/bin/omit.mjs +225 -0
- package/hooks/command-sentinel.mjs +29 -0
- package/hooks/dep-sentinel.mjs +45 -0
- package/hooks/final-draft-gate.mjs +52 -0
- package/hooks/hazard-sentinel.mjs +58 -0
- package/hooks/hooks.json +45 -0
- package/hooks/lint-sentinel.mjs +28 -0
- package/lib/danger.mjs +80 -0
- package/lib/deps.mjs +56 -0
- package/lib/hazards.mjs +45 -0
- package/lib/lint.mjs +42 -0
- package/package.json +42 -0
- package/skills/omit/SKILL.md +98 -0
package/bin/omit.mjs
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// omit CLI: provider-neutral enforcement. Claude Code hooks, git pre-commit,
|
|
3
|
+
// CI, and any other agent's hook system all call the same commands.
|
|
4
|
+
//
|
|
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
|
|
7
|
+
// omit check <file...> hazard-scan specific files
|
|
8
|
+
// omit gate audit the staged diff; fail on hazards/uncited deps
|
|
9
|
+
// omit hook install add the gate to .git/hooks/pre-commit
|
|
10
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from 'node:fs'
|
|
11
|
+
import { execSync } from 'node:child_process'
|
|
12
|
+
import { basename, dirname, join } from 'node:path'
|
|
13
|
+
import { fileURLToPath } from 'node:url'
|
|
14
|
+
import { isManifest, addedDeps } from '../lib/deps.mjs'
|
|
15
|
+
import { findHazards } from '../lib/hazards.mjs'
|
|
16
|
+
import { lintFiles } from '../lib/lint.mjs'
|
|
17
|
+
import { assessCommand } from '../lib/danger.mjs'
|
|
18
|
+
|
|
19
|
+
const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
20
|
+
const git = (args) => execSync(`git ${args}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
|
|
21
|
+
|
|
22
|
+
// ---------- init ----------
|
|
23
|
+
const targets = {
|
|
24
|
+
agents: [['AGENTS.md', 'AGENTS.md']],
|
|
25
|
+
claude: [[join('skills', 'omit', 'SKILL.md'), join('.claude', 'skills', 'omit', 'SKILL.md')]],
|
|
26
|
+
cursor: [[join('.cursor', 'rules', 'omit.mdc'), join('.cursor', 'rules', 'omit.mdc')]],
|
|
27
|
+
cline: [[join('.clinerules', 'omit.md'), join('.clinerules', 'omit.md')]],
|
|
28
|
+
windsurf: [[join('.windsurf', 'rules', 'omit.md'), join('.windsurf', 'rules', 'omit.md')]],
|
|
29
|
+
copilot: [[join('.github', 'copilot-instructions.md'), join('.github', 'copilot-instructions.md')]],
|
|
30
|
+
}
|
|
31
|
+
targets.codex = targets.agents // Codex reads AGENTS.md
|
|
32
|
+
targets.takumi = targets.agents // Takumi reads AGENTS.md
|
|
33
|
+
targets.all = [...new Set(Object.values(targets).flat())]
|
|
34
|
+
|
|
35
|
+
function init(pick = 'agents') {
|
|
36
|
+
if (!targets[pick]) {
|
|
37
|
+
console.error(`usage: omit init [${Object.keys(targets).join('|')}] (default: agents)`)
|
|
38
|
+
process.exit(1)
|
|
39
|
+
}
|
|
40
|
+
for (const [src, dest] of targets[pick]) {
|
|
41
|
+
if (existsSync(dest)) {
|
|
42
|
+
console.log(`skip ${dest} (already exists: will not overwrite)`)
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
mkdirSync(dirname(dest), { recursive: true })
|
|
46
|
+
copyFileSync(join(pkgRoot, src), dest)
|
|
47
|
+
console.log(`wrote ${dest}`)
|
|
48
|
+
}
|
|
49
|
+
console.log('\nDraft less. Cite everything. Cut last.')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------- audit / gate ----------
|
|
53
|
+
function collect(diffRange) {
|
|
54
|
+
const numstat = git(`diff --numstat ${diffRange}`).split('\n').filter(Boolean)
|
|
55
|
+
const files = []
|
|
56
|
+
let added = 0
|
|
57
|
+
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
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const newDeps = []
|
|
67
|
+
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 })))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const fullDiff = git(`diff ${diffRange}`)
|
|
73
|
+
const addedLines = fullDiff.split('\n').filter((l) => l.startsWith('+') && !l.startsWith('+++')).map((l) => l.slice(1))
|
|
74
|
+
const hazards = findHazards(addedLines)
|
|
75
|
+
const footnotes = addedLines.filter((l) => /omitted:/.test(l)).length
|
|
76
|
+
const loadBearing = addedLines.filter((l) => /load-bearing:/.test(l)).length
|
|
77
|
+
|
|
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))
|
|
81
|
+
|
|
82
|
+
const lint = lintFiles(process.cwd(), files.map((f) => f.path).filter((p) => existsSync(p)))
|
|
83
|
+
|
|
84
|
+
return { files, added, deleted, net: added - deleted, newDeps, uncitedDeps, hazards, footnotes, loadBearing, lint }
|
|
85
|
+
}
|
|
86
|
+
|
|
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))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
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)
|
|
102
|
+
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 ⚠)` : ''}`,
|
|
105
|
+
`hazards: ${r.hazards.length === 0 ? '0 ✅' : r.hazards.map((h) => `${h.type}:${h.rule}`).join(', ') + ' ⛔'}`,
|
|
106
|
+
`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}`,
|
|
109
|
+
]
|
|
110
|
+
if (fmt === 'markdown') return `### omit verdict\n\n${rows.map((x) => `- ${x}`).join('\n')}\n`
|
|
111
|
+
return `omit verdict\n────────────\n${rows.join('\n')}`
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function audit(args) {
|
|
115
|
+
const baseIdx = args.indexOf('--base')
|
|
116
|
+
const base = baseIdx !== -1 ? args[baseIdx + 1] : 'HEAD'
|
|
117
|
+
const fmt = args.includes('--json') ? 'json' : args.includes('--markdown') ? 'markdown' : 'text'
|
|
118
|
+
console.log(render(collect(base), fmt))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function gate() {
|
|
122
|
+
const r = collect('--cached')
|
|
123
|
+
console.log(render(r, 'text'))
|
|
124
|
+
const secrets = r.hazards.filter((h) => h.type === 'secret')
|
|
125
|
+
if (secrets.length) {
|
|
126
|
+
console.error(`\nâ›” omit gate: ${secrets.length} secret(s) staged. Move to env/secrets manager, rotate if real, restage.`)
|
|
127
|
+
process.exit(1)
|
|
128
|
+
}
|
|
129
|
+
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.`)
|
|
131
|
+
process.exit(1)
|
|
132
|
+
}
|
|
133
|
+
if (r.hazards.length) {
|
|
134
|
+
console.error(`\nâš omit gate: injection-prone lines staged. Parameterize, use safe APIs, or mark reviewed lines with omit-allow: <reason>.`)
|
|
135
|
+
process.exit(1)
|
|
136
|
+
}
|
|
137
|
+
const lintFails = (r.lint ?? []).filter((l) => !l.ok)
|
|
138
|
+
if (lintFails.length) {
|
|
139
|
+
console.error(`\nâ›” omit gate: the repo's own linter is failing on staged files.\n${lintFails.map((l) => `[${l.linter}]\n${l.output}`).join('\n')}`)
|
|
140
|
+
process.exit(1)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function check(files) {
|
|
145
|
+
if (!files.length) {
|
|
146
|
+
console.error('usage: omit check <file...>')
|
|
147
|
+
process.exit(1)
|
|
148
|
+
}
|
|
149
|
+
let bad = false
|
|
150
|
+
for (const f of files) {
|
|
151
|
+
const findings = findHazards(readFileSync(f, 'utf8').split('\n'))
|
|
152
|
+
for (const h of findings) {
|
|
153
|
+
console.log(`${f}:${h.line} ${h.type} [${h.rule}] ${h.text}`)
|
|
154
|
+
bad = true
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
process.exit(bad ? 1 : 0)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function lint(files) {
|
|
161
|
+
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))
|
|
167
|
+
}
|
|
168
|
+
const results = lintFiles(process.cwd(), files)
|
|
169
|
+
if (results.length === 0) {
|
|
170
|
+
console.log('omit lint: no configured linter applies to these files')
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
for (const r of results) console.log(`[${r.linter}] ${r.ok ? 'pass ✅' : `fail ⛔\n${r.output}`}`)
|
|
174
|
+
process.exit(results.some((r) => !r.ok) ? 1 : 0)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function guard(args) {
|
|
178
|
+
const command = args.join(' ')
|
|
179
|
+
if (!command) {
|
|
180
|
+
console.error('usage: omit guard "<shell command>"')
|
|
181
|
+
process.exit(1)
|
|
182
|
+
}
|
|
183
|
+
const findings = assessCommand(command)
|
|
184
|
+
if (findings.length === 0) {
|
|
185
|
+
console.log('ok')
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
for (const f of findings) console.error(`[${f.rule}] ${f.reason}`)
|
|
189
|
+
process.exit(1)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function hookInstall() {
|
|
193
|
+
const hookPath = join('.git', 'hooks', 'pre-commit')
|
|
194
|
+
if (!existsSync('.git')) {
|
|
195
|
+
console.error('omit: not a git repository')
|
|
196
|
+
process.exit(1)
|
|
197
|
+
}
|
|
198
|
+
if (existsSync(hookPath) && readFileSync(hookPath, 'utf8').includes('omit gate')) {
|
|
199
|
+
console.log('skip pre-commit gate already installed')
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
const line = '\nnpx -y @sriinnu/omit gate || exit 1\n'
|
|
203
|
+
if (existsSync(hookPath)) {
|
|
204
|
+
writeFileSync(hookPath, readFileSync(hookPath, 'utf8') + line)
|
|
205
|
+
} else {
|
|
206
|
+
writeFileSync(hookPath, '#!/bin/sh' + line)
|
|
207
|
+
}
|
|
208
|
+
chmodSync(hookPath, 0o755)
|
|
209
|
+
console.log('wrote .git/hooks/pre-commit: every commit now passes the omit gate, whatever agent wrote it')
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---------- dispatch ----------
|
|
213
|
+
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)
|
|
225
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PreToolUse hook: assesses shell commands BEFORE they execute and blocks the
|
|
3
|
+
// classic agent disasters (recursive deletes of home/system paths, deletes
|
|
4
|
+
// through unset variables, disk overwrites). The user's machine is load-bearing.
|
|
5
|
+
import { readFileSync } from 'node:fs'
|
|
6
|
+
import { assessCommand } from '../lib/danger.mjs'
|
|
7
|
+
|
|
8
|
+
if (process.env.OMIT_OFF === '1') process.exit(0)
|
|
9
|
+
|
|
10
|
+
let data
|
|
11
|
+
try {
|
|
12
|
+
data = JSON.parse(readFileSync(0, 'utf8'))
|
|
13
|
+
} catch {
|
|
14
|
+
process.exit(0)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const command = data.tool_input?.command
|
|
18
|
+
if (!command) process.exit(0)
|
|
19
|
+
|
|
20
|
+
const findings = assessCommand(command)
|
|
21
|
+
if (findings.length === 0) process.exit(0)
|
|
22
|
+
|
|
23
|
+
console.error(
|
|
24
|
+
'omit blocks this command: the machine is load-bearing.\n' +
|
|
25
|
+
findings.map((f) => ` [${f.rule}] ${f.reason}`).join('\n') +
|
|
26
|
+
'\nUse a narrower target inside the workspace, guard variables with ${VAR:?}, ' +
|
|
27
|
+
'or, only after genuine review with the user, append: # omit-allow: <reason>'
|
|
28
|
+
)
|
|
29
|
+
process.exit(2)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PostToolUse hook: raises a blocking objection when a new dependency lands
|
|
3
|
+
// in a manifest without a receipt in .omit/receipts.jsonl.
|
|
4
|
+
import { readFileSync, existsSync } from 'node:fs'
|
|
5
|
+
import { execSync } from 'node:child_process'
|
|
6
|
+
import { basename, join } from 'node:path'
|
|
7
|
+
import { isManifest, addedDeps } from '../lib/deps.mjs'
|
|
8
|
+
|
|
9
|
+
if (process.env.OMIT_OFF === '1') process.exit(0)
|
|
10
|
+
|
|
11
|
+
let data
|
|
12
|
+
try {
|
|
13
|
+
data = JSON.parse(readFileSync(0, 'utf8'))
|
|
14
|
+
} catch {
|
|
15
|
+
process.exit(0)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const file = data.tool_input?.file_path
|
|
19
|
+
if (!file || !isManifest(file)) process.exit(0)
|
|
20
|
+
|
|
21
|
+
const cwd = data.cwd ?? process.cwd()
|
|
22
|
+
let diff = ''
|
|
23
|
+
try {
|
|
24
|
+
diff = execSync(`git diff HEAD -- "${file}"`, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
|
|
25
|
+
} catch {
|
|
26
|
+
process.exit(0) // not a git repo: nothing to compare against
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const deps = addedDeps(basename(file), diff)
|
|
30
|
+
if (deps.length === 0) process.exit(0)
|
|
31
|
+
|
|
32
|
+
let receipts = ''
|
|
33
|
+
const ledger = join(cwd, '.omit', 'receipts.jsonl')
|
|
34
|
+
if (existsSync(ledger)) receipts = readFileSync(ledger, 'utf8')
|
|
35
|
+
|
|
36
|
+
const uncited = deps.filter((d) => !receipts.includes(d))
|
|
37
|
+
if (uncited.length === 0) process.exit(0)
|
|
38
|
+
|
|
39
|
+
console.error(
|
|
40
|
+
`omit objects: new dependenc${uncited.length > 1 ? 'ies' : 'y'} with no receipt: ${uncited.join(', ')}.\n` +
|
|
41
|
+
`Omissions 2-5 come first: show the codebase, stdlib, platform, and installed deps cannot cover this.\n` +
|
|
42
|
+
`If they truly cannot, append the citation to .omit/receipts.jsonl ` +
|
|
43
|
+
`({"claim":"new dep ${uncited[0]} required","receipt":"<what you verified>","rung":7}) and re-apply. Otherwise revert the manifest change.`
|
|
44
|
+
)
|
|
45
|
+
process.exit(2)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Stop hook: the turn may not end with an edited working tree and no Final Draft.
|
|
3
|
+
// The deletion pass is a gate, not a suggestion.
|
|
4
|
+
import { readFileSync, statSync, existsSync } from 'node:fs'
|
|
5
|
+
import { execSync } from 'node:child_process'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
|
|
8
|
+
if (process.env.OMIT_OFF === '1') process.exit(0)
|
|
9
|
+
|
|
10
|
+
let data = {}
|
|
11
|
+
try {
|
|
12
|
+
data = JSON.parse(readFileSync(0, 'utf8'))
|
|
13
|
+
} catch {}
|
|
14
|
+
if (data.stop_hook_active) process.exit(0) // never loop the gate
|
|
15
|
+
|
|
16
|
+
const cwd = data.cwd ?? process.cwd()
|
|
17
|
+
let changed = []
|
|
18
|
+
try {
|
|
19
|
+
changed = execSync('git status --porcelain', { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
|
|
20
|
+
.split('\n')
|
|
21
|
+
.filter(Boolean)
|
|
22
|
+
.map((l) => l.slice(3).replace(/^.*-> /, ''))
|
|
23
|
+
.filter((p) => !p.startsWith('.omit/'))
|
|
24
|
+
} catch {
|
|
25
|
+
process.exit(0)
|
|
26
|
+
}
|
|
27
|
+
// omitted: distinguishing this session's edits from pre-existing dirt: git has no
|
|
28
|
+
// session concept; set OMIT_OFF=1 in dirty-tree workflows until a marker file lands.
|
|
29
|
+
if (changed.length === 0) process.exit(0)
|
|
30
|
+
|
|
31
|
+
const draft = join(cwd, '.omit', 'final-draft.md')
|
|
32
|
+
if (existsSync(draft)) {
|
|
33
|
+
const draftTime = statSync(draft).mtimeMs
|
|
34
|
+
const newestEdit = Math.max(
|
|
35
|
+
...changed.map((p) => {
|
|
36
|
+
try {
|
|
37
|
+
return statSync(join(cwd, p)).mtimeMs
|
|
38
|
+
} catch {
|
|
39
|
+
return 0 // deleted file: cannot be newer than the draft
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
)
|
|
43
|
+
if (draftTime >= newestEdit) process.exit(0)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
console.error(
|
|
47
|
+
'omit: the working tree changed but there is no current Final Draft.\n' +
|
|
48
|
+
'Working code is a first draft. Edit your own diff once (dead branches, unused params/imports, ' +
|
|
49
|
+
'speculative options, restating comments, single-caller indirection), then write the net report to ' +
|
|
50
|
+
'.omit/final-draft.md: files touched, lines +/-, new dependencies, footnotes recorded. Then finish.'
|
|
51
|
+
)
|
|
52
|
+
process.exit(2)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PostToolUse hook: blocks hardcoded secrets and injection-prone patterns
|
|
3
|
+
// the moment they land in an edited file. Load-bearing lines are never cut -
|
|
4
|
+
// and hazards are never shipped.
|
|
5
|
+
import { readFileSync, existsSync } from 'node:fs'
|
|
6
|
+
import { execSync } from 'node:child_process'
|
|
7
|
+
import { basename, join } from 'node:path'
|
|
8
|
+
import { findHazards } from '../lib/hazards.mjs'
|
|
9
|
+
|
|
10
|
+
if (process.env.OMIT_OFF === '1') process.exit(0)
|
|
11
|
+
|
|
12
|
+
let data
|
|
13
|
+
try {
|
|
14
|
+
data = JSON.parse(readFileSync(0, 'utf8'))
|
|
15
|
+
} catch {
|
|
16
|
+
process.exit(0)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const file = data.tool_input?.file_path
|
|
20
|
+
if (!file) process.exit(0)
|
|
21
|
+
const name = basename(file)
|
|
22
|
+
if (name.endsWith('.lock') || name === 'package-lock.json' || file.includes(`${'.omit'}/`)) process.exit(0)
|
|
23
|
+
|
|
24
|
+
const cwd = data.cwd ?? process.cwd()
|
|
25
|
+
|
|
26
|
+
// Scan only what this edit added: the diff for tracked files, the whole file if untracked.
|
|
27
|
+
let addedLines = []
|
|
28
|
+
try {
|
|
29
|
+
const diff = execSync(`git diff HEAD -- "${file}"`, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
|
|
30
|
+
if (diff) {
|
|
31
|
+
addedLines = diff.split('\n').filter((l) => l.startsWith('+') && !l.startsWith('+++')).map((l) => l.slice(1))
|
|
32
|
+
}
|
|
33
|
+
} catch {}
|
|
34
|
+
if (addedLines.length === 0 && existsSync(join(cwd, file).length > 0 ? file : file)) {
|
|
35
|
+
try {
|
|
36
|
+
addedLines = readFileSync(file, 'utf8').split('\n')
|
|
37
|
+
} catch {
|
|
38
|
+
process.exit(0)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const findings = findHazards(addedLines)
|
|
43
|
+
if (findings.length === 0) process.exit(0)
|
|
44
|
+
|
|
45
|
+
const secrets = findings.filter((f) => f.type === 'secret')
|
|
46
|
+
const injections = findings.filter((f) => f.type === 'injection')
|
|
47
|
+
|
|
48
|
+
let msg = 'omit objects: hazards in this edit:\n'
|
|
49
|
+
for (const f of secrets) msg += ` SECRET [${f.rule}] ${f.text}\n`
|
|
50
|
+
for (const f of injections) msg += ` INJECT [${f.rule}] ${f.text}\n`
|
|
51
|
+
msg += secrets.length
|
|
52
|
+
? 'Secrets never ship: move them to environment variables or a secrets manager and rotate any real key that was just written.\n'
|
|
53
|
+
: ''
|
|
54
|
+
msg += injections.length
|
|
55
|
+
? 'Injection-prone patterns need parameterized queries, safe APIs, or an explicit reviewed `omit-allow: <reason>` on the line.\n'
|
|
56
|
+
: ''
|
|
57
|
+
console.error(msg.trimEnd())
|
|
58
|
+
process.exit(2)
|
package/hooks/hooks.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"PreToolUse": [
|
|
4
|
+
{
|
|
5
|
+
"matcher": "Bash|PowerShell",
|
|
6
|
+
"hooks": [
|
|
7
|
+
{
|
|
8
|
+
"type": "command",
|
|
9
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/command-sentinel.mjs\""
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"PostToolUse": [
|
|
15
|
+
{
|
|
16
|
+
"matcher": "Edit|Write|MultiEdit",
|
|
17
|
+
"hooks": [
|
|
18
|
+
{
|
|
19
|
+
"type": "command",
|
|
20
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/dep-sentinel.mjs\""
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"type": "command",
|
|
24
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/hazard-sentinel.mjs\""
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"type": "command",
|
|
28
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/lint-sentinel.mjs\""
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
],
|
|
33
|
+
"Stop": [
|
|
34
|
+
{
|
|
35
|
+
"matcher": "",
|
|
36
|
+
"hooks": [
|
|
37
|
+
{
|
|
38
|
+
"type": "command",
|
|
39
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/final-draft-gate.mjs\""
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PostToolUse hook: runs the repo's OWN linter on the file just edited and
|
|
3
|
+
// objects immediately on errors. omit brings no lint rules of its own.
|
|
4
|
+
import { readFileSync } from 'node:fs'
|
|
5
|
+
import { lintFiles } from '../lib/lint.mjs'
|
|
6
|
+
|
|
7
|
+
if (process.env.OMIT_OFF === '1') process.exit(0)
|
|
8
|
+
|
|
9
|
+
let data
|
|
10
|
+
try {
|
|
11
|
+
data = JSON.parse(readFileSync(0, 'utf8'))
|
|
12
|
+
} catch {
|
|
13
|
+
process.exit(0)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const file = data.tool_input?.file_path
|
|
17
|
+
if (!file) process.exit(0)
|
|
18
|
+
|
|
19
|
+
const cwd = data.cwd ?? process.cwd()
|
|
20
|
+
const failing = lintFiles(cwd, [file]).filter((r) => !r.ok)
|
|
21
|
+
if (failing.length === 0) process.exit(0)
|
|
22
|
+
|
|
23
|
+
console.error(
|
|
24
|
+
`omit: the linter this repo already configured objects (omission 2: use what exists).\n` +
|
|
25
|
+
failing.map((r) => `[${r.linter}]\n${r.output}`).join('\n') +
|
|
26
|
+
`\nFix these now; do not restate or suppress them.`
|
|
27
|
+
)
|
|
28
|
+
process.exit(2)
|
package/lib/danger.mjs
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Destructive-command detection: the user's machine is load-bearing.
|
|
2
|
+
// Blocks the classic agent disasters before they execute: recursive deletes of
|
|
3
|
+
// home/system/drive roots, deletes through unset variables, disk overwrites.
|
|
4
|
+
// Suppress a reviewed command by appending: # omit-allow: <reason>
|
|
5
|
+
|
|
6
|
+
const PROTECTED_TARGETS = [
|
|
7
|
+
/^\/+$/, // filesystem root
|
|
8
|
+
/^~[\\/]?$/,
|
|
9
|
+
/^\$HOME[\\/]?$/,
|
|
10
|
+
/^\$\{HOME\}[\\/]?$/,
|
|
11
|
+
/^%USERPROFILE%[\\/]?$/i,
|
|
12
|
+
/^\$env:USERPROFILE[\\/]?$/i,
|
|
13
|
+
/^[a-z]:[\\/]*$/i, // drive root
|
|
14
|
+
/^[\\/](home|root|etc|usr|bin|sbin|boot|var|opt|users)[\\/]?$/i,
|
|
15
|
+
/^[\\/](home|users)[\\/][^\\/]+[\\/]?$/i, // a user's home dir
|
|
16
|
+
/^[a-z]:[\\/](users|windows|program files( \(x86\))?)[\\/]?$/i,
|
|
17
|
+
/^[a-z]:[\\/]users[\\/][^\\/]+[\\/]?$/i, // a Windows profile
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
const CWD_WIPE = /^(\*|\.|\.\/|\.\\)$/
|
|
21
|
+
|
|
22
|
+
const isProtected = (t) => PROTECTED_TARGETS.some((re) => re.test(stripQuotes(t)))
|
|
23
|
+
const stripQuotes = (t) => t.replace(/^['"]|['"]$/g, '')
|
|
24
|
+
|
|
25
|
+
// unresolved variable at the head of a delete target: `rm -rf $OUT/*` nukes / when $OUT is empty
|
|
26
|
+
const VARIABLE_HEAD = /^['"]?(\$\{?\w+\}?|%\w+%|\$env:\w+)['"]?([\\/].*)?$/
|
|
27
|
+
|
|
28
|
+
function deleteTargets(segment) {
|
|
29
|
+
const words = segment.trim().split(/\s+/)
|
|
30
|
+
if (words[0] === 'sudo') words.shift()
|
|
31
|
+
const cmd = (words[0] ?? '').toLowerCase().replace(/\.exe$/, '')
|
|
32
|
+
let recursive = false
|
|
33
|
+
let targets = []
|
|
34
|
+
|
|
35
|
+
if (['rm'].includes(cmd)) {
|
|
36
|
+
const flags = words.filter((w) => w.startsWith('-'))
|
|
37
|
+
recursive = flags.some((f) => /^-[a-z]*r/i.test(f) || f === '--recursive')
|
|
38
|
+
if (flags.includes('--no-preserve-root')) return { recursive: true, targets: ['/'], noPreserveRoot: true }
|
|
39
|
+
targets = words.slice(1).filter((w) => !w.startsWith('-'))
|
|
40
|
+
} else if (['rmdir', 'rd', 'del'].includes(cmd)) {
|
|
41
|
+
recursive = words.some((w) => /^\/s$/i.test(w))
|
|
42
|
+
targets = words.slice(1).filter((w) => !w.startsWith('/') || w.length > 2)
|
|
43
|
+
} else if (['remove-item', 'ri'].includes(cmd)) {
|
|
44
|
+
recursive = words.some((w) => /^-recurse/i.test(w))
|
|
45
|
+
targets = words.slice(1).filter((w) => !w.startsWith('-'))
|
|
46
|
+
}
|
|
47
|
+
return { recursive, targets }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function assessCommand(command) {
|
|
51
|
+
if (/omit-allow:/.test(command)) return []
|
|
52
|
+
const findings = []
|
|
53
|
+
const push = (rule, reason) => findings.push({ rule, reason })
|
|
54
|
+
|
|
55
|
+
if (/dd\s+[^|;&]*of=\/dev\/(sd|hd|nvme|disk|mmcblk)/.test(command)) push('disk-overwrite', 'dd writing directly to a block device')
|
|
56
|
+
if (/\bmkfs(\.\w+)?\b/.test(command)) push('disk-format', 'mkfs formats a filesystem')
|
|
57
|
+
if (/\b(format)\s+[a-z]:/i.test(command)) push('disk-format', 'formatting a drive')
|
|
58
|
+
if (/\bdiskpart\b/i.test(command)) push('disk-format', 'diskpart can repartition disks')
|
|
59
|
+
if (/:\(\)\s*\{\s*:\|:&\s*\}\s*;\s*:/.test(command)) push('fork-bomb', 'fork bomb')
|
|
60
|
+
if (/\bfind\s+(\/|~|\$HOME)[^|;&]*(-delete|-exec\s+rm)/.test(command)) push('find-delete-root', 'find -delete sweeping a protected root')
|
|
61
|
+
if (/\b(chmod|chown)\s+(-[a-z]*R[a-z]*\s+)[^|;&]*\s(\/|~|\$HOME)\s*$/im.test(command)) push('recursive-perms-root', 'recursive permission change on a protected root')
|
|
62
|
+
|
|
63
|
+
for (const segment of command.split(/&&|\|\||;|\|/)) {
|
|
64
|
+
const { recursive, targets, noPreserveRoot } = deleteTargets(segment)
|
|
65
|
+
if (noPreserveRoot) {
|
|
66
|
+
push('no-preserve-root', '`--no-preserve-root` exists only to delete the root filesystem')
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
if (!recursive || !targets) continue
|
|
70
|
+
for (const t of targets) {
|
|
71
|
+
const bare = stripQuotes(t)
|
|
72
|
+
if (isProtected(bare)) push('protected-root-delete', `recursive delete of protected path: ${bare}`)
|
|
73
|
+
else if (CWD_WIPE.test(bare)) push('cwd-wipe', `recursive delete of the entire working directory (${bare}); delete specific paths instead`)
|
|
74
|
+
else if (VARIABLE_HEAD.test(bare) && !/^\$\{\w+:?[-?]/.test(bare)) {
|
|
75
|
+
push('unset-variable-delete', `recursive delete through a variable (${bare}); if it is empty or unset this can hit / or the home directory. Use \${VAR:?} or check it first`)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return findings
|
|
80
|
+
}
|
package/lib/deps.mjs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Shared manifest/dependency detection for hooks and `omit audit`.
|
|
2
|
+
import { basename } from 'node:path'
|
|
3
|
+
|
|
4
|
+
const MANIFESTS = new Set([
|
|
5
|
+
'package.json', 'requirements.txt', 'pyproject.toml', 'go.mod',
|
|
6
|
+
'Cargo.toml', 'Gemfile', 'composer.json',
|
|
7
|
+
])
|
|
8
|
+
|
|
9
|
+
export const isManifest = (path) => MANIFESTS.has(basename(path))
|
|
10
|
+
|
|
11
|
+
// Extracts dependency names that are genuinely NEW in a unified diff of one
|
|
12
|
+
// manifest: present in added lines and absent from removed lines (a dep whose
|
|
13
|
+
// line was merely reformatted: trailing comma, version bump: is not new).
|
|
14
|
+
// omitted: full manifest parsing: heuristics per format are enough to raise an
|
|
15
|
+
// objection; false negatives are acceptable, the agent's own rules still apply.
|
|
16
|
+
export function addedDeps(manifestName, diff) {
|
|
17
|
+
const side = (prefix) =>
|
|
18
|
+
depsIn(
|
|
19
|
+
manifestName,
|
|
20
|
+
diff
|
|
21
|
+
.split('\n')
|
|
22
|
+
.filter((l) => l.startsWith(prefix) && !l.startsWith(prefix.repeat(3)))
|
|
23
|
+
.map((l) => l.slice(1).trim())
|
|
24
|
+
)
|
|
25
|
+
const removed = new Set(side('-'))
|
|
26
|
+
return side('+').filter((d) => !removed.has(d))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function depsIn(manifestName, lines) {
|
|
30
|
+
const out = []
|
|
31
|
+
for (const line of lines) {
|
|
32
|
+
let m = null
|
|
33
|
+
switch (manifestName) {
|
|
34
|
+
case 'package.json':
|
|
35
|
+
case 'composer.json':
|
|
36
|
+
// dep values start with a version range (^1.2, ~3, >=2, 1.0): script lines don't
|
|
37
|
+
m = line.match(/^"(@?[a-z0-9._/-]+)"\s*:\s*"[~^><=]?\d/i)
|
|
38
|
+
break
|
|
39
|
+
case 'requirements.txt':
|
|
40
|
+
m = line.match(/^([A-Za-z0-9._-]+)\s*(?:[><=~[]|$)/)
|
|
41
|
+
break
|
|
42
|
+
case 'go.mod':
|
|
43
|
+
m = line.match(/^(?:require\s+)?([\w.-]+\.[\w./-]+)\s+v\d/)
|
|
44
|
+
break
|
|
45
|
+
case 'Cargo.toml':
|
|
46
|
+
case 'pyproject.toml':
|
|
47
|
+
m = line.match(/^([A-Za-z0-9_-]+)\s*=\s*["{]/)
|
|
48
|
+
break
|
|
49
|
+
case 'Gemfile':
|
|
50
|
+
m = line.match(/^gem\s+['"]([\w-]+)['"]/)
|
|
51
|
+
break
|
|
52
|
+
}
|
|
53
|
+
if (m) out.push(m[1])
|
|
54
|
+
}
|
|
55
|
+
return [...new Set(out)]
|
|
56
|
+
}
|
package/lib/hazards.mjs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Hazard scanning: hardcoded secrets and injection-prone patterns in added lines.
|
|
2
|
+
// Heuristics raise objections, not verdicts: suppress a reviewed line with `omit-allow: <reason>`.
|
|
3
|
+
|
|
4
|
+
const SECRET_RULES = [
|
|
5
|
+
['aws-access-key', /AKIA[0-9A-Z]{16}/],
|
|
6
|
+
['private-key-block', /-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/],
|
|
7
|
+
['github-token', /gh[pousr]_[A-Za-z0-9]{36,}/],
|
|
8
|
+
['slack-token', /xox[baprs]-[A-Za-z0-9-]{10,}/],
|
|
9
|
+
['anthropic-key', /sk-ant-[A-Za-z0-9_-]{20,}/],
|
|
10
|
+
['stripe-live-key', /sk_live_[A-Za-z0-9]{20,}/],
|
|
11
|
+
['openai-style-key', /\bsk-[A-Za-z0-9_-]{32,}/],
|
|
12
|
+
['google-api-key', /AIza[0-9A-Za-z_-]{35}/],
|
|
13
|
+
['hardcoded-credential', /(?:api[_-]?key|secret|token|password|passwd)["']?\s*[:=]\s*["'][A-Za-z0-9+/_-]{16,}["']/i],
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
const INJECTION_RULES = [
|
|
17
|
+
['eval', /\beval\s*\(/],
|
|
18
|
+
['new-function', /new\s+Function\s*\(/],
|
|
19
|
+
['shell-concat', /\bexec(?:Sync)?\s*\(\s*(?:`[^`]*\$\{|[^)"'`]*\+)/],
|
|
20
|
+
['subprocess-shell-true', /subprocess\.(?:run|call|Popen)\s*\(.*shell\s*=\s*True/],
|
|
21
|
+
['os-system-dynamic', /os\.system\s*\(\s*(?:f["']|[^)"']*\+)/],
|
|
22
|
+
['inner-html', /\.innerHTML\s*=|dangerouslySetInnerHTML/],
|
|
23
|
+
['pickle-load', /pickle\.loads?\s*\(/],
|
|
24
|
+
['yaml-unsafe-load', /yaml\.load\s*\((?![^)]*SafeLoader)/],
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
const sqlInjection = (line) =>
|
|
28
|
+
/\b(?:execute|query|raw)\s*\(/i.test(line) &&
|
|
29
|
+
/\b(?:SELECT|INSERT|UPDATE|DELETE)\b/i.test(line) &&
|
|
30
|
+
(/\$\{/.test(line) || /["'`]\s*\+/.test(line) || /f["']/.test(line) || /%\s*\(/.test(line))
|
|
31
|
+
|
|
32
|
+
export function findHazards(lines) {
|
|
33
|
+
const findings = []
|
|
34
|
+
lines.forEach((text, i) => {
|
|
35
|
+
if (text.includes('omit-allow:')) return
|
|
36
|
+
for (const [rule, re] of SECRET_RULES) {
|
|
37
|
+
if (re.test(text)) findings.push({ type: 'secret', rule, line: i + 1, text: text.trim().slice(0, 120) })
|
|
38
|
+
}
|
|
39
|
+
for (const [rule, re] of INJECTION_RULES) {
|
|
40
|
+
if (re.test(text)) findings.push({ type: 'injection', rule, line: i + 1, text: text.trim().slice(0, 120) })
|
|
41
|
+
}
|
|
42
|
+
if (sqlInjection(text)) findings.push({ type: 'injection', rule: 'sql-string-built', line: i + 1, text: text.trim().slice(0, 120) })
|
|
43
|
+
})
|
|
44
|
+
return findings
|
|
45
|
+
}
|