@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/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)
@@ -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
+ }
@@ -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
+ }