@sriinnu/omit 0.3.0 → 0.4.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.
@@ -2,57 +2,175 @@
2
2
  // PostToolUse hook: blocks hardcoded secrets and injection-prone patterns
3
3
  // the moment they land in an edited file. Load-bearing lines are never cut -
4
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'
5
+ //
6
+ // Fires on file edits (Edit|Write|MultiEdit|NotebookEdit) and on Bash, because
7
+ // a file also lands through `cat > f`, `tee f` and `sed -i` — and because
8
+ // `NotebookEdit` carries no file_path at all, so a notebook cell used to reach
9
+ // no sentinel.
10
+ import { readFileSync, realpathSync } from 'node:fs'
11
+ import { basename, resolve } from 'node:path'
12
+ import { probe, git, fileAtRevision, repoRelPath } from '../lib/git.mjs'
8
13
  import { findHazards } from '../lib/hazards.mjs'
9
14
 
10
15
  if (process.env.OMIT_OFF === '1') process.exit(0)
11
16
 
17
+ // Exit 2 blocks the tool call; any other non-zero code is a non-blocking error
18
+ // the model sees and moves past. A hook that crashed has not objected.
19
+ const block = (message) => {
20
+ console.error(message)
21
+ process.exit(2)
22
+ }
23
+
24
+ // repoRoot() answers null for "not a repository" and for "git could not answer",
25
+ // which want opposite exits — see dep-sentinel for the full note. Same rule here:
26
+ // only git's own "not a repository" reads as absence, everything else throws.
27
+ function repoRootOrNull(cwd) {
28
+ const r = probe(cwd, ['rev-parse', '--show-toplevel'])
29
+ if (r.ok) return r.out.trim()
30
+ if (/not a git repository|must be run in a work tree/i.test(String(r.reason))) return null
31
+ throw new Error(`git could not report the repository root (${r.reason})`)
32
+ }
33
+
34
+ // repoRelPath is lexical, and the path the harness sends and the root git
35
+ // reports do not always agree on symlinks: on macOS `/tmp` and `/var` are links
36
+ // into `/private`, and git answers with the resolved root. A mismatch reads as
37
+ // "outside the repo", which decides how the added lines are found. Resolve the
38
+ // file and ask again before treating it as untracked.
39
+ function relInRoot(root, abs) {
40
+ const lexical = repoRelPath(root, abs)
41
+ if (lexical !== null) return lexical
42
+ try {
43
+ return repoRelPath(root, realpathSync(abs))
44
+ } catch {
45
+ return null // no such file: nothing was written to it
46
+ }
47
+ }
48
+
49
+ // Only omit's own directory is exempt, compared whole-segment against the path,
50
+ // so a checkout named `proj.omit` no longer skips EVERY file in the repo the way
51
+ // `.includes('.omit/')` did. Omit's own ledger is not the change under review —
52
+ // and nothing else in the suite watches that directory, which is why the
53
+ // comparison is exact rather than a substring.
54
+ const inOmitDir = (p) => p.split(/[\\/]/).includes('.omit')
55
+
56
+ // A file we cannot read as text has no added lines to scan: a deleted file
57
+ // (ENOENT) or a directory (EISDIR) is not content. Anything else is a real
58
+ // failure and belongs in the wrapper's exit 2.
59
+ function readLines(abs) {
60
+ try {
61
+ return readFileSync(abs, 'utf8').split('\n')
62
+ } catch (e) {
63
+ if (e.code === 'ENOENT' || e.code === 'EISDIR') return []
64
+ throw e
65
+ }
66
+ }
67
+
68
+ // Scan only what this edit added: the diff for a file git already knew, the whole
69
+ // file otherwise — a new file has no earlier revision to diff against.
70
+ // omitted: skipping a checkout's pre-existing hazards on an untracked file: there
71
+ // is no revision to attribute them to, and objecting is the safe side.
72
+ function addedLines(abs, rel, root) {
73
+ if (root === null || rel === null) return readLines(abs) // outside git: scan the file as written
74
+ const head = fileAtRevision(root, 'HEAD', rel)
75
+ // `{failed:true}` is not "no earlier revision": treating it as one silently
76
+ // rescans the whole file (a file's worth of pre-existing lines reported as this
77
+ // edit's), and treating it as an empty file would scan nothing at all.
78
+ if (head.failed) throw new Error(`git could not read ${rel} at HEAD (${head.reason}) — the added lines could not be isolated`)
79
+ if (!head.present) return readLines(abs)
80
+ return git(root, ['diff', 'HEAD', '--', rel])
81
+ .split('\n')
82
+ .filter((l) => l.startsWith('+') && !l.startsWith('+++'))
83
+ .map((l) => l.slice(1))
84
+ }
85
+
86
+ // The shapes of a shell command that writes file content. This runs on EVERY Bash
87
+ // call, so it gates all the work below: no path extraction, no git, until the
88
+ // command itself says it wrote something.
89
+ const WRITE_SHAPES = /(?:>>?\s*[^\s;&|<>()]|\btee\s+(?:-a\s+)?[^\s;&|<>()]|\bsed\s+[^;|]*\s-i\b|\bdd\s+of=|\bnpm\s+pkg\s+set\b|\btruncate\b)/
90
+
91
+ const unquote = (s) => s.replace(/^['"]|['"]$/g, '')
92
+
93
+ // The paths a command plausibly wrote, from a short list of shapes rather than a
94
+ // shell parser (a parser is a second implementation of the shell, and a wrong one
95
+ // is worse than a missing one).
96
+ // omitted: extraction for `python -c "...open('f','w')"`, `awk > ` and friends:
97
+ // their command text is scanned for secrets below, which is where a literal in
98
+ // such a command would have to appear anyway.
99
+ function writtenPaths(command) {
100
+ const out = []
101
+ for (const m of command.matchAll(/>>?\s*("[^"]+"|'[^']+'|[^\s;&|<>()]+)/g)) out.push(unquote(m[1]))
102
+ for (const m of command.matchAll(/\btee\s+(?:-a\s+)?("[^"]+"|'[^']+'|[^\s;&|<>()]+)/g)) out.push(unquote(m[1]))
103
+ for (const m of command.matchAll(/\bsed\s+[^;|]*?\s-i\b[^;|]*/g)) {
104
+ const words = m[0].split(/\s+/).filter((w) => w && !w.startsWith('-'))
105
+ if (words.length > 2) out.push(unquote(words[words.length - 1])) // last non-flag token: the file sed edits
106
+ }
107
+ return out.filter((p) => p && !p.startsWith('/dev/')) // the void is not a file to scan
108
+ }
109
+
12
110
  let data
13
111
  try {
14
112
  data = JSON.parse(readFileSync(0, 'utf8'))
15
113
  } catch {
16
- process.exit(0)
114
+ process.exit(0) // harness-authored payload we cannot read: not evidence about a file
17
115
  }
18
116
 
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
117
  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)
118
+ const ti = data.tool_input ?? {}
119
+ for (const f of ['file_path', 'notebook_path', 'command']) {
120
+ if (ti[f] !== undefined && typeof ti[f] !== 'string') {
121
+ block(
122
+ `omit blocks this edit: the hook was handed a \`${f}\` that is not a path or a command, so nothing behind it could be scanned.\n` +
123
+ 'Send the tool input as a string, or set OMIT_OFF=1 to run without the sentinel.'
124
+ )
125
+ }
39
126
  }
40
- }
127
+ const cwd = data.cwd ?? process.cwd()
41
128
 
42
- const findings = findHazards(addedLines)
43
- if (findings.length === 0) process.exit(0)
129
+ const file = typeof ti.file_path === 'string' ? ti.file_path : typeof ti.notebook_path === 'string' ? ti.notebook_path : null
130
+ let findings = []
131
+ if (file !== null && !/\.lock$/.test(basename(file)) && basename(file) !== 'package-lock.json') {
132
+ const root = repoRootOrNull(cwd)
133
+ const abs = resolve(cwd, file)
134
+ const rel = root === null ? null : relInRoot(root, abs)
135
+ if (!inOmitDir(rel ?? file)) {
136
+ // A notebook's added content is the cell the tool just wrote; there is no
137
+ // useful diff of a .ipynb and no file_path to diff it with.
138
+ findings = typeof ti.new_source === 'string' ? findHazards(ti.new_source.split('\n')) : findHazards(addedLines(abs, rel, root))
139
+ }
140
+ } else if (typeof ti.command === 'string' && WRITE_SHAPES.test(ti.command)) {
141
+ const root = repoRootOrNull(cwd)
142
+ // The command's own text carries what a heredoc writes. Secret rules only:
143
+ // the injection rules are about code landing in a file, and the file check
144
+ // below covers the files we can locate — running them over every command's
145
+ // text flags read-only commands like a grep of the source for the eval token.
146
+ findings = findHazards(ti.command.split('\n')).filter((f) => f.type === 'secret')
147
+ for (const raw of writtenPaths(ti.command)) {
148
+ const abs = resolve(cwd, raw)
149
+ const rel = root === null ? null : relInRoot(root, abs)
150
+ if (rel === null || inOmitDir(rel)) continue // outside the repo, or omit's own ledger
151
+ findings.push(...findHazards(addedLines(abs, rel, root)))
152
+ }
153
+ findings = [...new Map(findings.map((f) => [`${f.type}:${f.rule}:${f.text}`, f])).values()]
154
+ }
155
+ if (findings.length === 0) process.exit(0)
44
156
 
45
- const secrets = findings.filter((f) => f.type === 'secret')
46
- const injections = findings.filter((f) => f.type === 'injection')
157
+ const secrets = findings.filter((f) => f.type === 'secret')
158
+ const injections = findings.filter((f) => f.type === 'injection')
47
159
 
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)
160
+ let msg = 'omit objects: hazards in this edit:\n'
161
+ for (const f of secrets) msg += ` SECRET [${f.rule}] ${f.text}\n`
162
+ for (const f of injections) msg += ` INJECT [${f.rule}] ${f.text}\n`
163
+ msg += secrets.length
164
+ ? 'Secrets never ship: move them to environment variables or a secrets manager and rotate any real key that was just written.\n'
165
+ : ''
166
+ msg += injections.length
167
+ ? 'Injection-prone patterns need parameterized queries, safe APIs, or an explicit reviewed `omit-allow: <reason>` on the line.\n'
168
+ : ''
169
+ console.error(msg.trimEnd())
170
+ process.exit(2)
171
+ } catch (e) {
172
+ block(
173
+ `omit could not complete the hazard scan (${e?.message ?? e}).\n` +
174
+ 'Blocking the edit rather than accepting a change nothing scanned: a check that did not run is not a check that passed.'
175
+ )
176
+ }
package/hooks/hooks.json CHANGED
@@ -7,13 +7,17 @@
7
7
  {
8
8
  "type": "command",
9
9
  "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/command-sentinel.mjs\""
10
+ },
11
+ {
12
+ "type": "command",
13
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/leak-sentinel.mjs\""
10
14
  }
11
15
  ]
12
16
  }
13
17
  ],
14
18
  "PostToolUse": [
15
19
  {
16
- "matcher": "Edit|Write|MultiEdit",
20
+ "matcher": "Edit|Write|MultiEdit|NotebookEdit",
17
21
  "hooks": [
18
22
  {
19
23
  "type": "command",
@@ -28,6 +32,19 @@
28
32
  "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/lint-sentinel.mjs\""
29
33
  }
30
34
  ]
35
+ },
36
+ {
37
+ "matcher": "Bash|PowerShell",
38
+ "hooks": [
39
+ {
40
+ "type": "command",
41
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/dep-sentinel.mjs\""
42
+ },
43
+ {
44
+ "type": "command",
45
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/hazard-sentinel.mjs\""
46
+ }
47
+ ]
31
48
  }
32
49
  ],
33
50
  "Stop": [
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+ // PreToolUse hook: blocks shell commands that print an EXISTING secret's raw
3
+ // value to stdout before they run — keychain/vault reads, env dumps, cat'ing
4
+ // credential files. The agent's own transcript is not a safe place for a real
5
+ // API key, so this is enforced, not left to the model remembering to redact.
6
+ import { readFileSync } from 'node:fs'
7
+ import { assessLeak } from '../lib/leaks.mjs'
8
+
9
+ if (process.env.OMIT_OFF === '1') process.exit(0)
10
+
11
+ // Exit 2 blocks the tool call; any other non-zero code is a non-blocking error
12
+ // the model can retry straight through. So a hook that did not finish has to
13
+ // exit 2 — a crash at exit 1 is this check silently not happening, and the
14
+ // secret prints. A non-string `command` used to do exactly that: assessLeak
15
+ // threw, node exited 1, the command ran.
16
+ const block = (message) => {
17
+ console.error(message)
18
+ process.exit(2)
19
+ }
20
+
21
+ // Harness-authored payload: unreadable means "not a call I can judge", not
22
+ // evidence the agent did something.
23
+ let data
24
+ try {
25
+ data = JSON.parse(readFileSync(0, 'utf8'))
26
+ } catch {
27
+ process.exit(0)
28
+ }
29
+
30
+ try {
31
+ const command = data.tool_input?.command
32
+ if (command === undefined || command === null || command === '') process.exit(0)
33
+ if (typeof command !== 'string') {
34
+ block(
35
+ 'omit blocks this command: its text was not a string, so it could not be checked for a secret.\n' +
36
+ 'Send the command as a string, or set OMIT_OFF=1 to run without the sentinel.'
37
+ )
38
+ }
39
+
40
+ const findings = assessLeak(command)
41
+ if (findings.length === 0) process.exit(0)
42
+
43
+ console.error(
44
+ 'omit blocks this command: it would print a real secret into the transcript.\n' +
45
+ findings.map((f) => ` [${f.rule}] ${f.reason}`).join('\n') +
46
+ '\nIf a real secret already leaked, treat it as burned and rotate it. ' +
47
+ 'Suppress a reviewed command with: # omit-allow: <reason>'
48
+ )
49
+ process.exit(2)
50
+ } catch (e) {
51
+ block(
52
+ `omit could not assess this command for a leak (${e?.message ?? e}).\n` +
53
+ 'Blocking the command rather than waving it through: a check that did not run is not a check that passed.'
54
+ )
55
+ }
@@ -6,6 +6,15 @@ import { lintFiles } from '../lib/lint.mjs'
6
6
 
7
7
  if (process.env.OMIT_OFF === '1') process.exit(0)
8
8
 
9
+ // Exit 2 blocks the tool call; any other non-zero code is a non-blocking error
10
+ // the model can ignore. A hook that could not run the linter has not reported
11
+ // anything, so it must not exit 0 — and it must not exit 1 either.
12
+ const block = (message) => {
13
+ console.error(message)
14
+ process.exit(2)
15
+ }
16
+
17
+ // Harness-authored payload: unreadable means "not an edit I can judge".
9
18
  let data
10
19
  try {
11
20
  data = JSON.parse(readFileSync(0, 'utf8'))
@@ -13,16 +22,30 @@ try {
13
22
  process.exit(0)
14
23
  }
15
24
 
16
- const file = data.tool_input?.file_path
17
- if (!file) process.exit(0)
25
+ try {
26
+ const ti = data.tool_input ?? {}
27
+ if (ti.file_path !== undefined && typeof ti.file_path !== 'string') {
28
+ block(
29
+ 'omit blocks this edit: the hook was handed a `file_path` that is not a path, so no file could be linted.\n' +
30
+ 'Send the tool input as a string, or set OMIT_OFF=1 to run without the sentinel.'
31
+ )
32
+ }
33
+ const file = ti.file_path
34
+ if (!file) process.exit(0)
18
35
 
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)
36
+ const cwd = data.cwd ?? process.cwd()
37
+ const failing = lintFiles(cwd, [file]).filter((r) => !r.ok)
38
+ if (failing.length === 0) process.exit(0)
22
39
 
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)
40
+ console.error(
41
+ `omit: the linter this repo already configured objects (omission 2: use what exists).\n` +
42
+ failing.map((r) => `[${r.linter}]\n${r.output}`).join('\n') +
43
+ `\nFix these now; do not restate or suppress them.`
44
+ )
45
+ process.exit(2)
46
+ } catch (e) {
47
+ block(
48
+ `omit could not run the repo's linter (${e?.message ?? e}).\n` +
49
+ 'Blocking the edit rather than letting an unchecked file through: a check that did not run is not a check that passed.'
50
+ )
51
+ }
package/lib/danger.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  // Blocks the classic agent disasters before they execute: recursive deletes of
3
3
  // home/system/drive roots, deletes through unset variables, disk overwrites.
4
4
  // Suppress a reviewed command by appending: # omit-allow: <reason>
5
+ import { isAllowSuppressed } from './hazards.mjs'
5
6
 
6
7
  const PROTECTED_TARGETS = [
7
8
  /^\/+$/, // filesystem root
@@ -48,7 +49,14 @@ function deleteTargets(segment) {
48
49
  }
49
50
 
50
51
  export function assessCommand(command) {
51
- if (/omit-allow:/.test(command)) return []
52
+ // A reviewed command is the one escape hatch this file needs: genuine ones
53
+ // exist, and a human who has seen the target is the only judge of that. It is
54
+ // not free text, though — the marker must be the trailing `#`/`//` comment
55
+ // with a reason, so `rm -rf ~; echo "omit-allow:"` buys nothing.
56
+ // omitted: any check that the user was really consulted — the sentinel's own
57
+ // error text asks for that review, and a rule the machine cannot enforce is
58
+ // not worth pretending to enforce.
59
+ if (isAllowSuppressed(command)) return []
52
60
  const findings = []
53
61
  const push = (rule, reason) => findings.push({ rule, reason })
54
62