@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.
- package/.clinerules/omit.md +1 -1
- package/.cursor/rules/omit.mdc +1 -1
- package/.github/copilot-instructions.md +15 -0
- package/.github/workflows/publish.yml +69 -0
- package/.github/workflows/test.yml +40 -0
- package/.windsurf/rules/omit.md +1 -1
- package/AGENTS.md +11 -2
- package/README.md +72 -15
- package/action.yml +18 -1
- package/bench/run.mjs +25 -4
- package/bin/omit.mjs +518 -58
- package/hooks/command-sentinel.mjs +39 -11
- package/hooks/dep-sentinel.mjs +184 -30
- package/hooks/final-draft-gate.mjs +231 -37
- package/hooks/hazard-sentinel.mjs +157 -39
- package/hooks/hooks.json +18 -1
- package/hooks/leak-sentinel.mjs +55 -0
- package/hooks/lint-sentinel.mjs +34 -11
- package/lib/danger.mjs +9 -1
- package/lib/deps.mjs +341 -45
- package/lib/exec.mjs +18 -0
- package/lib/git.mjs +107 -0
- package/lib/hazards.mjs +29 -4
- package/lib/leaks.mjs +313 -0
- package/lib/lint.mjs +85 -5
- package/lib/receipts.mjs +356 -0
- package/package.json +5 -1
- package/skills/omit/SKILL.md +25 -13
|
@@ -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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
43
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
msg += injections.length
|
|
55
|
-
|
|
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
|
+
}
|
package/hooks/lint-sentinel.mjs
CHANGED
|
@@ -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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
|