@sriinnu/omit 0.4.1 → 0.6.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/.cursor/rules/omit.mdc +1 -1
- package/.github/workflows/omit.yml +21 -0
- package/.github/workflows/publish.yml +20 -4
- package/.github/workflows/test.yml +36 -0
- package/README.md +135 -16
- package/action.yml +6 -3
- package/bin/omit.mjs +111 -19
- package/hooks/context-sentinel.mjs +38 -0
- package/hooks/dep-sentinel.mjs +3 -1
- package/hooks/hazard-sentinel.mjs +12 -8
- package/hooks/lint-sentinel.mjs +4 -2
- package/hooks/ribhu-adapter.mjs +51 -0
- package/lib/codemode.mjs +198 -0
- package/lib/context.mjs +59 -0
- package/lib/doctor.mjs +50 -0
- package/lib/patch-paths.mjs +28 -0
- package/package.json +26 -4
- package/skills/omit/SKILL.md +22 -2
- package/skills/omit-codemode/SKILL.md +48 -0
package/.cursor/rules/omit.mdc
CHANGED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
name: omit
|
|
2
|
+
|
|
3
|
+
on: pull_request
|
|
4
|
+
|
|
5
|
+
permissions:
|
|
6
|
+
contents: read
|
|
7
|
+
pull-requests: write
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
omit:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
# A fork's PR gets a read-only token, so the comment could only fail there.
|
|
13
|
+
if: github.event.pull_request.head.repo.full_name == github.repository
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
with:
|
|
17
|
+
fetch-depth: 0
|
|
18
|
+
|
|
19
|
+
# `./`, not a tag: this runs the PR's own action.yml end to end, which
|
|
20
|
+
# nothing else does — test.yml only proves the file parses.
|
|
21
|
+
- uses: ./
|
|
@@ -5,6 +5,11 @@ on:
|
|
|
5
5
|
tags:
|
|
6
6
|
- "v*.*.*"
|
|
7
7
|
|
|
8
|
+
# No npm token anywhere. npm's trusted publishing has this workflow prove who
|
|
9
|
+
# it is with a short-lived OIDC token GitHub mints for the run (that is what
|
|
10
|
+
# id-token: write allows), and npm checks it against the publisher configured
|
|
11
|
+
# for the package: sriinnu/omit, publish.yml. A stored token expired twice and
|
|
12
|
+
# stopped two releases after their tags were already pushed.
|
|
8
13
|
permissions:
|
|
9
14
|
contents: read
|
|
10
15
|
id-token: write
|
|
@@ -17,9 +22,21 @@ jobs:
|
|
|
17
22
|
|
|
18
23
|
- uses: actions/setup-node@v4
|
|
19
24
|
with:
|
|
20
|
-
node-version: "
|
|
25
|
+
node-version: "24"
|
|
21
26
|
registry-url: "https://registry.npmjs.org"
|
|
22
27
|
|
|
28
|
+
# Trusted publishing needs npm 11.5.1 or later. An older npm does not
|
|
29
|
+
# fail by saying so: it sends no credentials and the registry answers
|
|
30
|
+
# 404, which reads as a missing package. Say it here instead.
|
|
31
|
+
- name: npm is new enough for trusted publishing
|
|
32
|
+
run: |
|
|
33
|
+
node -e '
|
|
34
|
+
const [major, minor, patch] = process.argv[1].split(".").map(Number)
|
|
35
|
+
const older = major < 11 || (major === 11 && (minor < 5 || (minor === 5 && patch < 1)))
|
|
36
|
+
if (older) { console.error(`npm ${process.argv[1]} cannot do trusted publishing: it needs 11.5.1 or later`); process.exit(1) }
|
|
37
|
+
console.log(`npm ${process.argv[1]}`)
|
|
38
|
+
' "$(npm --version)"
|
|
39
|
+
|
|
23
40
|
- name: Verify tag matches package.json version
|
|
24
41
|
run: |
|
|
25
42
|
TAG_VERSION="${GITHUB_REF_NAME#v}"
|
|
@@ -44,10 +61,9 @@ jobs:
|
|
|
44
61
|
|
|
45
62
|
- name: Publish
|
|
46
63
|
if: steps.check.outputs.already_published == 'false'
|
|
47
|
-
env:
|
|
48
|
-
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
49
64
|
run: |
|
|
50
|
-
|
|
65
|
+
# The provenance attestation comes with trusted publishing; no flag.
|
|
66
|
+
if npm publish --access public; then
|
|
51
67
|
echo "Publish succeeded."
|
|
52
68
|
exit 0
|
|
53
69
|
fi
|
|
@@ -38,3 +38,39 @@ jobs:
|
|
|
38
38
|
assert doc["runs"].get("using") == "composite", "runs.using must be composite"
|
|
39
39
|
print("action.yml parses and carries its required keys")
|
|
40
40
|
PY
|
|
41
|
+
|
|
42
|
+
# Same failure mode as action.yml: Claude Code tolerates an unquoted ": "
|
|
43
|
+
# in a description, but strict YAML loaders (the skills CLI, skill
|
|
44
|
+
# indexers) reject the whole file, and the skill silently disappears.
|
|
45
|
+
- name: skill and rule frontmatter parses
|
|
46
|
+
run: |
|
|
47
|
+
python3 - <<'PY'
|
|
48
|
+
import sys
|
|
49
|
+
try:
|
|
50
|
+
import yaml
|
|
51
|
+
except ImportError:
|
|
52
|
+
print("PyYAML unavailable on this runner — frontmatter was NOT validated")
|
|
53
|
+
sys.exit(0)
|
|
54
|
+
for path in ("skills/omit/SKILL.md", "skills/omit-codemode/SKILL.md", ".cursor/rules/omit.mdc"):
|
|
55
|
+
meta = yaml.safe_load(open(path).read().split("---")[1])
|
|
56
|
+
assert meta.get("description"), f"{path}: frontmatter has no description"
|
|
57
|
+
print(f"{path}: frontmatter parses")
|
|
58
|
+
PY
|
|
59
|
+
|
|
60
|
+
# The sandbox is an optional peer, so `npm test` above never installs it and
|
|
61
|
+
# the end-to-end case there is announced as skipped. This job is where a real
|
|
62
|
+
# script runs in the real sandbox, at the version the lockfile pins.
|
|
63
|
+
codemode:
|
|
64
|
+
runs-on: ubuntu-latest
|
|
65
|
+
steps:
|
|
66
|
+
- uses: actions/checkout@v4
|
|
67
|
+
|
|
68
|
+
- uses: actions/setup-node@v4
|
|
69
|
+
with:
|
|
70
|
+
node-version: "22"
|
|
71
|
+
|
|
72
|
+
- run: npm ci
|
|
73
|
+
|
|
74
|
+
- run: node --test test/codemode.test.mjs
|
|
75
|
+
env:
|
|
76
|
+
OMIT_CODEMODE_REQUIRED: "1"
|
package/README.md
CHANGED
|
@@ -48,7 +48,9 @@ Every other skill in this genre is words the agent can ignore under context pres
|
|
|
48
48
|
| **Final Draft gate** (hook) | The session cannot end with an edited tree and no current `.omit/final-draft.md` net report — and the report is read, not just stat'd. Its files/lines/deps counts are cross-checked against the actual diff, so a stub or a stale draft does not pass. The deletion pass is a gate, not a suggestion. |
|
|
49
49
|
| **Receipts ledger** | Every Fact-Check citation is appended to `.omit/receipts.jsonl` as a claim *plus the evidence that settles it*, and `omit verify` re-checks the lot. Run it on a PR: "17/17 claims survived" is a number a reviewer can act on, and "3 refuted" names exactly which shortcuts were invented. |
|
|
50
50
|
|
|
51
|
-
Hooks install automatically with the Claude Code plugin. Codex
|
|
51
|
+
Hooks install automatically with the Claude Code plugin. For Codex, run `npx @sriinnu/omit hook install codex` to merge hooks into `.codex/hooks.json`; rerunning preserves existing hooks without duplicating omit's entries. Command and leak sentinels check `Bash` before execution. Dependency and hazard sentinels also check shell edits afterward. File sentinels read `apply_patch` from `tool_input.command`, checking all added/updated paths and move destinations while skipping deleted files. Malformed patch envelopes produce an objection. Payload regression tests cover these contracts; live Codex delivery and Stop-gate behavior still require end-to-end verification. These hooks do not impose a token budget or compact transcripts. Escape hatch for humans: `OMIT_OFF=1`.
|
|
52
|
+
|
|
53
|
+
[Ribhu](https://github.com/sriinnu/ribhu) has shell hooks too, with its own file shape and payload: `npx @sriinnu/omit hook install ribhu` writes `.ribhu/hooks.json`, and a small adapter translates Ribhu's payload so the same sentinels run unchanged. Ribhu sends every tool call a code-mode script makes through those hooks, so a script's writes are checked like direct ones. The hooks are tested by firing each installed command the way Ribhu does, with payloads in its shape; they have not yet been watched firing inside a live Ribhu session.
|
|
52
54
|
|
|
53
55
|
## What this does not catch
|
|
54
56
|
|
|
@@ -112,6 +114,7 @@ npx @sriinnu/omit guard "<cmd>" # is this shell command a disaster? (wire into
|
|
|
112
114
|
npx @sriinnu/omit leak "<cmd>" # would this command print a real secret to stdout?
|
|
113
115
|
npx @sriinnu/omit gate # the pre-commit check, callable from anywhere
|
|
114
116
|
npx @sriinnu/omit hook install codex # write .codex/hooks.json — live sentinels inside Codex CLI
|
|
117
|
+
npx @sriinnu/omit hook install ribhu # write .ribhu/hooks.json — live sentinels inside Ribhu
|
|
115
118
|
```
|
|
116
119
|
|
|
117
120
|
And server-side, the GitHub Action comments the verdict on every PR regardless of what wrote the code:
|
|
@@ -119,14 +122,16 @@ And server-side, the GitHub Action comments the verdict on every PR regardless o
|
|
|
119
122
|
```yaml
|
|
120
123
|
# .github/workflows/omit.yml
|
|
121
124
|
on: pull_request
|
|
122
|
-
permissions: { pull-requests: write }
|
|
125
|
+
permissions: { contents: read, pull-requests: write }
|
|
123
126
|
jobs:
|
|
124
127
|
omit:
|
|
125
128
|
runs-on: ubuntu-latest
|
|
129
|
+
# A fork's PR gets a read-only token, so the comment could only fail there.
|
|
130
|
+
if: github.event.pull_request.head.repo.full_name == github.repository
|
|
126
131
|
steps:
|
|
127
132
|
- uses: actions/checkout@v4
|
|
128
133
|
with: { fetch-depth: 0 }
|
|
129
|
-
- uses: sriinnu/omit@
|
|
134
|
+
- uses: sriinnu/omit@v0.6.0
|
|
130
135
|
# with: { exec: true } # execute receipts' `run` snippets to verify them
|
|
131
136
|
# fully. Off by default: a PR's receipts are
|
|
132
137
|
# untrusted code, and this runs on pull requests.
|
|
@@ -148,6 +153,50 @@ jobs:
|
|
|
148
153
|
// the receipts; a single number would just be another uncited claim.
|
|
149
154
|
```
|
|
150
155
|
|
|
156
|
+
## Codemode (experimental)
|
|
157
|
+
|
|
158
|
+
An agent exploring a repo pays for every intermediate result: each read and each grep is a round-trip whose raw output lands in the transcript and is sent again on every later turn. With `omit codemode` the model writes a script, the reads happen inside it, and only what the script returns comes back.
|
|
159
|
+
|
|
160
|
+
```sh
|
|
161
|
+
omit codemode run <<'EOF'
|
|
162
|
+
const libs = (await tools.files({ under: 'lib' })).filter((f) => f.endsWith('.mjs'))
|
|
163
|
+
const sources = await Promise.all(libs.map((path) => tools.read({ path })))
|
|
164
|
+
return Object.fromEntries(libs.map((f, i) => [f, (sources[i].match(/^export /gm) ?? []).length]))
|
|
165
|
+
EOF
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
- **Read-only.** Three tools: `files`, `read`, `grep`. They read the tree through git, so ignored files stay out, and every path is confined to the directory the server started in: no `..`, no absolute path, no symlink out.
|
|
169
|
+
- **Sandboxed.** The script runs in a QuickJS VM compiled to wasm ([`@earendil-works/pi-codemode`](https://www.npmjs.com/package/@earendil-works/pi-codemode), the sandbox behind pi's codemode): no file system, no network, no `process`. `node:vm` is not isolation, and the receipt for this dependency runs the escape to show it.
|
|
170
|
+
- **Secrets stay in.** A script may read a credentials file; it may not return it. Output that matches a secret rule is withheld whole.
|
|
171
|
+
- **Bounded.** A script has 60 seconds, and output past 20,000 characters loses its middle.
|
|
172
|
+
|
|
173
|
+
The sandbox is an optional peer that only this command loads, so `omit` itself still installs zero dependencies. It needs Node 22.19 or newer.
|
|
174
|
+
|
|
175
|
+
```
|
|
176
|
+
npm install -g @sriinnu/omit @earendil-works/pi-codemode
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Any agent with a shell can use it as it stands: `omit codemode run` takes a script on stdin or from a file and prints the answer, and the `omit-codemode` skill (`skills/omit-codemode/SKILL.md`) teaches the agent when to reach for it and what a script can call. Nothing has to be registered.
|
|
180
|
+
|
|
181
|
+
For a host without a shell, or one where you would rather approve a single read-only tool, the same thing is an MCP server on stdio:
|
|
182
|
+
|
|
183
|
+
```
|
|
184
|
+
claude mcp add omit -- omit codemode # Claude Code
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
```toml
|
|
188
|
+
# Codex: ~/.codex/config.toml
|
|
189
|
+
[mcp_servers.omit]
|
|
190
|
+
command = "omit"
|
|
191
|
+
args = ["codemode"]
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
```
|
|
195
|
+
// omitted: write and edit tools: a nested write is not seen by the host's
|
|
196
|
+
// hooks, so it has to carry the hazard, dependency and lint gates itself. Add
|
|
197
|
+
// them once bench/ shows the read side pays for the surface.
|
|
198
|
+
```
|
|
199
|
+
|
|
151
200
|
## The referee (experimental)
|
|
152
201
|
|
|
153
202
|
`bench/` is METHODOLOGY.md made runnable: paired agentic runs of the same tasks under baseline, omit, or **any competing skill**, metrics computed from the actual git diffs, all transcripts kept. The category argues about self-reported numbers; omit ships the measuring instrument. See `bench/README.md`.
|
|
@@ -165,6 +214,66 @@ Say `omit redline` (or any mode) in chat, or use `/omit <mode>` where slash comm
|
|
|
165
214
|
|
|
166
215
|
## Install
|
|
167
216
|
|
|
217
|
+
### Hook health and context controls
|
|
218
|
+
|
|
219
|
+
These features run locally with Node and do not call a model or provider API.
|
|
220
|
+
Use the same CLI with any provider/model; hook installation targets the host,
|
|
221
|
+
not the model. Codex and Claude adapters are included. Other hosts need the
|
|
222
|
+
documented stdin/output contract below; automatic interception is not universal.
|
|
223
|
+
|
|
224
|
+
```sh
|
|
225
|
+
omit doctor # read-only hook inspection; never runs discovered commands
|
|
226
|
+
omit doctor --json # same findings for scripts; exit 1 on errors
|
|
227
|
+
omit init skill # discoverable .agents/skills/omit/SKILL.md; never overwrite
|
|
228
|
+
omit hook install codex --context
|
|
229
|
+
omit hook install claude --context
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
The doctor inspects global Codex hooks plus the current directory's
|
|
233
|
+
`.codex/hooks.json` and `.claude/settings.json`. It reports missing scripts,
|
|
234
|
+
duplicate registrations, malformed matchers, some legacy shell-tool name
|
|
235
|
+
mismatches, and Git `core.hooksPath` overrides. It checks simple Node/Python
|
|
236
|
+
script commands statically; other command shapes are explicitly unchecked.
|
|
237
|
+
It does not resolve all parent layers, host trust, executable availability, or
|
|
238
|
+
prove that hooks fired. `liveDelivery` remains `unverified`.
|
|
239
|
+
|
|
240
|
+
`--context` adds advisory broad-read warnings and a text output guard. Default
|
|
241
|
+
installations keep the existing safety hooks without adding context controls.
|
|
242
|
+
At more than 6,000 characters, a plain-string shell result is archived privately
|
|
243
|
+
under the OS temporary directory and replaced with head/tail excerpts, selected
|
|
244
|
+
error lines, and its full-output path. Set `OMIT_OUTPUT_CHARS` to 1000–100000 to
|
|
245
|
+
change the excerpt character budget. The wrapper/path adds a small overhead.
|
|
246
|
+
Structured results (objects, arrays, MCP content) are left intact. Storage
|
|
247
|
+
failure preserves the original result and emits a diagnostic. Archives can
|
|
248
|
+
contain sensitive output: files use mode 0600 inside a mode-0700 directory on
|
|
249
|
+
POSIX systems. They persist until temporary storage is cleaned; no existing
|
|
250
|
+
transcript is rewritten. Inspect the archive when a missing middle section
|
|
251
|
+
matters; diagnostic extraction is heuristic and not exhaustive.
|
|
252
|
+
|
|
253
|
+
Three consecutive identical command/result pairs in the same cwd/session
|
|
254
|
+
produce one advisory warning. A changed pair resets the counter. Only hashes
|
|
255
|
+
and a bounded counter are stored for this check; it does not prove filesystem
|
|
256
|
+
state is unchanged. Parallel invocations may undercount, so this is not a gate.
|
|
257
|
+
|
|
258
|
+
For another host, invoke `omit context` with JSON on stdin:
|
|
259
|
+
|
|
260
|
+
```json
|
|
261
|
+
{"hook_event_name":"PostToolUse","tool_name":"Bash","session_id":"example","cwd":"/your/project","tool_input":{"command":"your command"},"tool_response":"plain text output"}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Use `PreToolUse` for read-scope advice. Shell names `Bash`, `exec_command`, and
|
|
265
|
+
`shell` are accepted (`tool_input.cmd` is also accepted). `systemMessage` is
|
|
266
|
+
advisory; `decision: "block"` with `reason` requests post-tool feedback replacing
|
|
267
|
+
the result in Codex. Other hosts must translate that response into their own
|
|
268
|
+
replacement API: some only append feedback. Verify delivery and replacement
|
|
269
|
+
before claiming context savings. No model quota or token-saving guarantee is
|
|
270
|
+
inferred from character counts. Set `OMIT_OFF=1` to bypass the hooks; remove only
|
|
271
|
+
the `context-sentinel.mjs` entries to uninstall these optional controls.
|
|
272
|
+
|
|
273
|
+
The packaged skill remains at `skills/omit/SKILL.md`; `omit init skill` copies it
|
|
274
|
+
to the shared project discovery path. Hosts with different discovery paths can
|
|
275
|
+
install the same skill there. No provider credentials are required.
|
|
276
|
+
|
|
168
277
|
New here? **[GETTING-STARTED.md](GETTING-STARTED.md)** has a copy-paste setup for every agent.
|
|
169
278
|
|
|
170
279
|
**Claude Code (plugin marketplace)**: one command pair, gets you the skill plus `/omit` and `/omit-edit`:
|
|
@@ -174,6 +283,12 @@ New here? **[GETTING-STARTED.md](GETTING-STARTED.md)** has a copy-paste setup fo
|
|
|
174
283
|
/plugin install omit@omit
|
|
175
284
|
```
|
|
176
285
|
|
|
286
|
+
**Any SKILL.md-aware agent** (Claude Code, Codex, Cursor, and others, via [skills.sh](https://skills.sh)):
|
|
287
|
+
|
|
288
|
+
```
|
|
289
|
+
npx skills add sriinnu/omit
|
|
290
|
+
```
|
|
291
|
+
|
|
177
292
|
**Global command**: install once from GitHub, use everywhere:
|
|
178
293
|
|
|
179
294
|
```
|
|
@@ -213,9 +328,9 @@ skills/omit/SKILL.md → .claude/skills/omit/SKILL.md (project)
|
|
|
213
328
|
**Anything else**: paste the contents of `AGENTS.md` into the agent's custom-instructions/rules mechanism. It's plain markdown; there is nothing to build.
|
|
214
329
|
|
|
215
330
|
```
|
|
216
|
-
// omitted:
|
|
217
|
-
// discipline, and rule files + skills already deliver it.
|
|
218
|
-
//
|
|
331
|
+
// omitted: the discipline itself over MCP: MCP exposes tools and data; omit is
|
|
332
|
+
// a behavioral discipline, and rule files + skills already deliver it. The one
|
|
333
|
+
// MCP server omit ships is codemode, which is tooling.
|
|
219
334
|
```
|
|
220
335
|
|
|
221
336
|
## Commands (Claude Code)
|
|
@@ -229,21 +344,25 @@ One command, three destinations (npm, GitHub, Homebrew):
|
|
|
229
344
|
|
|
230
345
|
npm run release -- patch # or minor / major
|
|
231
346
|
|
|
232
|
-
`scripts/release.mjs` runs the tests (via `preversion`), bumps
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
347
|
+
`scripts/release.mjs` runs the tests (via `preversion`), bumps the version,
|
|
348
|
+
and lands the bump on main **through a PR** (main takes no direct pushes).
|
|
349
|
+
Once checks pass and it merges, the script tags the merge (signed, per repo
|
|
350
|
+
policy) and pushes the tag — which triggers `publish.yml`, publishing to npm
|
|
351
|
+
**with a provenance attestation**. It then cuts the GitHub release with
|
|
352
|
+
generated notes, waits for the registry to serve the version, and updates the
|
|
353
|
+
`omit` formula in [`sriinnu/homebrew-tap`](https://github.com/sriinnu/homebrew-tap),
|
|
354
|
+
again through a PR. A failed step aborts the release, in order.
|
|
239
355
|
|
|
240
356
|
Never `npm publish` by hand: a local publish cannot attach provenance, and
|
|
241
357
|
npm will not let the same version be republished to add one later. If the CI
|
|
242
358
|
publish fails, fix CI — don't work around it locally.
|
|
243
359
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
360
|
+
There is no npm token to keep alive. `publish.yml` uses npm's
|
|
361
|
+
[trusted publishing](https://docs.npmjs.com/trusted-publishers): the workflow
|
|
362
|
+
proves its identity to npm with a short-lived token GitHub mints for that run,
|
|
363
|
+
and npm checks it against the publisher configured in the package's settings
|
|
364
|
+
(GitHub Actions, `sriinnu/omit`, `publish.yml`). If a publish is refused, that
|
|
365
|
+
form is the thing to check.
|
|
247
366
|
|
|
248
367
|
## Prior art
|
|
249
368
|
|
package/action.yml
CHANGED
|
@@ -34,10 +34,13 @@ runs:
|
|
|
34
34
|
BASE="${{ inputs.base }}"
|
|
35
35
|
if [ -z "$BASE" ]; then BASE="origin/${{ github.event.pull_request.base.ref }}"; fi
|
|
36
36
|
git fetch --depth=1 origin "${{ github.event.pull_request.base.ref }}" || true
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
# Outside the workspace: the redirect creates the file before the audit
|
|
38
|
+
# runs, and the audit counts untracked files — a verdict written into
|
|
39
|
+
# the checkout reports itself as one more changed file.
|
|
40
|
+
node "${{ github.action_path }}/bin/omit.mjs" audit --base "$BASE" --markdown > "$RUNNER_TEMP/omit-verdict.md"
|
|
41
|
+
cat "$RUNNER_TEMP/omit-verdict.md"
|
|
39
42
|
- name: Comment on PR
|
|
40
43
|
shell: bash
|
|
41
44
|
env:
|
|
42
45
|
GH_TOKEN: ${{ github.token }}
|
|
43
|
-
run: gh pr comment ${{ github.event.pull_request.number }} --body-file omit-verdict.md --repo ${{ github.repository }}
|
|
46
|
+
run: gh pr comment ${{ github.event.pull_request.number }} --body-file "$RUNNER_TEMP/omit-verdict.md" --repo ${{ github.repository }}
|
package/bin/omit.mjs
CHANGED
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
// omit leak "<cmd>" would this command print a real secret to stdout?
|
|
11
11
|
// omit hook install add the gate to .git/hooks/pre-commit
|
|
12
12
|
// omit hook install codex write .codex/hooks.json (live sentinels inside Codex CLI)
|
|
13
|
-
|
|
13
|
+
// omit hook install ribhu write .ribhu/hooks.json (live sentinels inside Ribhu)
|
|
14
|
+
// omit codemode run [file] run one sandboxed script over read-only repo tools (stdin without a file)
|
|
15
|
+
// omit codemode the same, as an MCP server on stdio
|
|
16
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync, chmodSync } from 'node:fs'
|
|
14
17
|
import { basename, dirname, join } from 'node:path'
|
|
15
18
|
import { fileURLToPath } from 'node:url'
|
|
16
19
|
import { isManifest, addedDeps, unparsedDependencyFile } from '../lib/deps.mjs'
|
|
@@ -20,6 +23,8 @@ import { findHazards } from '../lib/hazards.mjs'
|
|
|
20
23
|
import { lintFiles } from '../lib/lint.mjs'
|
|
21
24
|
import { assessCommand } from '../lib/danger.mjs'
|
|
22
25
|
import { assessLeak } from '../lib/leaks.mjs'
|
|
26
|
+
import { execute, serve } from '../lib/codemode.mjs'
|
|
27
|
+
import { doctor } from '../lib/doctor.mjs'
|
|
23
28
|
|
|
24
29
|
const cwd = process.cwd()
|
|
25
30
|
const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
@@ -27,6 +32,7 @@ const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
|
27
32
|
// ---------- init ----------
|
|
28
33
|
const targets = {
|
|
29
34
|
agents: [['AGENTS.md', 'AGENTS.md']],
|
|
35
|
+
skill: [['skills/omit/SKILL.md', '.agents/skills/omit/SKILL.md']],
|
|
30
36
|
claude: [[join('skills', 'omit', 'SKILL.md'), join('.claude', 'skills', 'omit', 'SKILL.md')]],
|
|
31
37
|
cursor: [[join('.cursor', 'rules', 'omit.mdc'), join('.cursor', 'rules', 'omit.mdc')]],
|
|
32
38
|
cline: [[join('.clinerules', 'omit.md'), join('.clinerules', 'omit.md')]],
|
|
@@ -510,18 +516,9 @@ function leak(args) {
|
|
|
510
516
|
process.exit(1)
|
|
511
517
|
}
|
|
512
518
|
|
|
513
|
-
//
|
|
514
|
-
//
|
|
515
|
-
|
|
516
|
-
// The PostToolUse file hooks (dep/hazard/lint-sentinel) and the Stop gate are wired
|
|
517
|
-
// too since Codex documents the same events, but Codex's apply_patch tool_input
|
|
518
|
-
// shape for PostToolUse isn't confirmed here — those three no-op safely if the
|
|
519
|
-
// file_path field isn't present, so this is best-effort, not verified parity.
|
|
520
|
-
function hookInstallCodex() {
|
|
521
|
-
const dir = '.codex'
|
|
522
|
-
const hooksPath = join(dir, 'hooks.json')
|
|
523
|
-
mkdirSync(dir, { recursive: true })
|
|
524
|
-
|
|
519
|
+
// An existing hooks file is merged into, never replaced: someone else's hooks
|
|
520
|
+
// live there too. One that cannot be read is refused rather than overwritten.
|
|
521
|
+
function loadHooksDoc(hooksPath) {
|
|
525
522
|
let doc = { hooks: {} }
|
|
526
523
|
if (existsSync(hooksPath)) {
|
|
527
524
|
try {
|
|
@@ -534,10 +531,24 @@ function hookInstallCodex() {
|
|
|
534
531
|
doc.hooks ??= {}
|
|
535
532
|
for (const [event, entry] of Object.entries(doc.hooks)) {
|
|
536
533
|
if (entry !== undefined && !Array.isArray(entry)) {
|
|
537
|
-
console.error(`omit: ${hooksPath} has a malformed "${event}" entry (expected an array
|
|
534
|
+
console.error(`omit: ${hooksPath} has a malformed "${event}" entry (expected an array) — fix or remove it first`)
|
|
538
535
|
process.exit(1)
|
|
539
536
|
}
|
|
540
537
|
}
|
|
538
|
+
return doc
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Codex CLI's hook schema is the same shape as Claude Code's (confirmed against
|
|
542
|
+
// developers.openai.com/codex/hooks: PreToolUse fires with tool_input.command for
|
|
543
|
+
// Bash, exit 2 blocks). Command-sentinel and leak-sentinel run on that shape as-is.
|
|
544
|
+
// File sentinels extract surviving paths from Codex's apply_patch command.
|
|
545
|
+
// Contract tests cover these payloads; live harness delivery is a separate check.
|
|
546
|
+
function hookInstallCodex(options = [], host = 'codex') {
|
|
547
|
+
if (options.some(option => option !== '--context')) die('usage: omit hook install codex [--context]')
|
|
548
|
+
const dir = host === 'claude' ? '.claude' : '.codex'
|
|
549
|
+
const hooksPath = join(dir, host === 'claude' ? 'settings.json' : 'hooks.json')
|
|
550
|
+
mkdirSync(dir, { recursive: true })
|
|
551
|
+
const doc = loadHooksDoc(hooksPath)
|
|
541
552
|
|
|
542
553
|
const scriptCmd = (script) => `node "${join(pkgRoot, 'hooks', script)}"`
|
|
543
554
|
const mergeHook = (event, matcher, script) => {
|
|
@@ -553,16 +564,22 @@ function hookInstallCodex() {
|
|
|
553
564
|
|
|
554
565
|
mergeHook('PreToolUse', 'Bash', 'command-sentinel.mjs')
|
|
555
566
|
mergeHook('PreToolUse', 'Bash', 'leak-sentinel.mjs')
|
|
567
|
+
mergeHook('PostToolUse', 'Bash', 'dep-sentinel.mjs')
|
|
568
|
+
mergeHook('PostToolUse', 'Bash', 'hazard-sentinel.mjs')
|
|
556
569
|
mergeHook('PostToolUse', 'apply_patch|Edit|Write', 'dep-sentinel.mjs')
|
|
557
570
|
mergeHook('PostToolUse', 'apply_patch|Edit|Write', 'hazard-sentinel.mjs')
|
|
558
571
|
mergeHook('PostToolUse', 'apply_patch|Edit|Write', 'lint-sentinel.mjs')
|
|
559
572
|
mergeHook('Stop', '', 'final-draft-gate.mjs')
|
|
573
|
+
if (options.includes('--context')) {
|
|
574
|
+
mergeHook('PreToolUse', 'Bash', 'context-sentinel.mjs')
|
|
575
|
+
mergeHook('PostToolUse', 'Bash', 'context-sentinel.mjs')
|
|
576
|
+
}
|
|
560
577
|
|
|
561
578
|
writeFileSync(hooksPath, JSON.stringify(doc, null, 2) + '\n')
|
|
562
579
|
console.log(`wrote ${hooksPath}`)
|
|
563
580
|
console.log(' verified against Codex\'s documented schema: command sentinel + leak sentinel run on Bash commands (same tool_input.command shape as Claude Code)')
|
|
564
|
-
console.log('
|
|
565
|
-
console.log(
|
|
581
|
+
console.log(' file sentinels cover apply_patch/Edit/Write; dependency and hazard checks also cover Bash edits. Payload tests do not establish live Codex hook delivery.')
|
|
582
|
+
console.log(`\nReview the installed hooks in ${host}; installation does not prove live delivery.`)
|
|
566
583
|
}
|
|
567
584
|
|
|
568
585
|
// The gate goes in the repository's own hooks directory — always, never into
|
|
@@ -658,6 +675,68 @@ function verify() {
|
|
|
658
675
|
// on purpose: a verdict rendered from a diff that could not be read is the
|
|
659
676
|
// failure this whole contract exists to prevent, and a gate that cannot read the
|
|
660
677
|
// change has to block it rather than pass it.
|
|
678
|
+
// Ribhu's hooks.json is not Codex's. Each event holds a flat list of
|
|
679
|
+
// { matcher, command }, the matcher is a regex over Ribhu's own lowercase tool
|
|
680
|
+
// names, and the payload a command receives is shaped differently, so every
|
|
681
|
+
// entry runs a sentinel through hooks/ribhu-adapter.mjs. Ribhu's code mode
|
|
682
|
+
// sends each nested tool call through these same hooks, so a script's writes
|
|
683
|
+
// are checked like direct ones.
|
|
684
|
+
function hookInstallRibhu() {
|
|
685
|
+
const dir = '.ribhu'
|
|
686
|
+
const hooksPath = join(dir, 'hooks.json')
|
|
687
|
+
mkdirSync(dir, { recursive: true })
|
|
688
|
+
const doc = loadHooksDoc(hooksPath)
|
|
689
|
+
|
|
690
|
+
const add = (event, matcher, sentinel, extra) => {
|
|
691
|
+
const command = `node "${join(pkgRoot, 'hooks', 'ribhu-adapter.mjs')}" ${sentinel}`
|
|
692
|
+
doc.hooks[event] ??= []
|
|
693
|
+
if (!doc.hooks[event].some((h) => h?.command === command)) doc.hooks[event].push({ ...(matcher && { matcher }), command, ...extra })
|
|
694
|
+
}
|
|
695
|
+
// Anchored: Ribhu matches with an unanchored regex, and a bare `write`
|
|
696
|
+
// would also catch todo_write.
|
|
697
|
+
const files = 'edit|multi_edit|ast_edit|write'
|
|
698
|
+
add('PreToolUse', '^bash$', 'command-sentinel')
|
|
699
|
+
add('PreToolUse', '^bash$', 'leak-sentinel')
|
|
700
|
+
add('PostToolUse', `^(bash|${files})$`, 'dep-sentinel')
|
|
701
|
+
add('PostToolUse', `^(bash|${files})$`, 'hazard-sentinel')
|
|
702
|
+
// Ribhu gives a hook ten seconds unless told otherwise, and a linter on a
|
|
703
|
+
// large repo takes longer; sixty is the most it allows.
|
|
704
|
+
add('PostToolUse', `^(${files})$`, 'lint-sentinel', { timeoutMs: 60000 })
|
|
705
|
+
add('Stop', undefined, 'final-draft-gate')
|
|
706
|
+
|
|
707
|
+
writeFileSync(hooksPath, JSON.stringify(doc, null, 2) + '\n')
|
|
708
|
+
console.log(`wrote ${hooksPath}`)
|
|
709
|
+
console.log(' command + leak sentinels before bash; dependency + hazard sentinels after bash and file edits; lint after file edits; Final Draft gate on Stop')
|
|
710
|
+
console.log(" checked against Ribhu's hook contract with real payload shapes; not yet verified firing inside a live Ribhu session")
|
|
711
|
+
console.log('\nRibhu runs a repository\'s hooks only once the project is trusted.')
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// ---------- codemode ----------
|
|
715
|
+
// The sandbox is an optional peer, loaded here and nowhere else: every other
|
|
716
|
+
// command has to keep working on a machine that never installed it.
|
|
717
|
+
async function codemode(args) {
|
|
718
|
+
if (args.length && args[0] !== 'run') die('usage: omit codemode [run [file]]')
|
|
719
|
+
let sandbox
|
|
720
|
+
try {
|
|
721
|
+
sandbox = await import('@earendil-works/pi-codemode')
|
|
722
|
+
} catch (e) {
|
|
723
|
+
if (e.code !== 'ERR_MODULE_NOT_FOUND') throw e
|
|
724
|
+
die('omit codemode needs its sandbox, which is not installed. Run: npm install @earendil-works/pi-codemode (it needs Node 22.19 or newer)')
|
|
725
|
+
}
|
|
726
|
+
const root = realpathSync(cwd)
|
|
727
|
+
// `run` is the whole feature for any agent that has a shell: one script in,
|
|
728
|
+
// one answer out, no server to register. Exit 1 marks a failed or withheld
|
|
729
|
+
// result, so a harness can tell it from an answer.
|
|
730
|
+
if (args[0] === 'run') {
|
|
731
|
+
const { text, isError } = await execute(readFileSync(args[1] ?? 0, 'utf8'), { CodemodeSandbox: sandbox.CodemodeSandbox, root })
|
|
732
|
+
console.log(text)
|
|
733
|
+
process.exitCode = isError ? 1 : 0
|
|
734
|
+
return
|
|
735
|
+
}
|
|
736
|
+
const { version } = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8'))
|
|
737
|
+
serve({ sandbox, root, version })
|
|
738
|
+
}
|
|
739
|
+
|
|
661
740
|
const [cmd, ...rest] = process.argv.slice(2)
|
|
662
741
|
try {
|
|
663
742
|
if (cmd === 'audit') audit(rest)
|
|
@@ -667,16 +746,29 @@ try {
|
|
|
667
746
|
else if (cmd === 'guard') guard(rest)
|
|
668
747
|
else if (cmd === 'leak') leak(rest)
|
|
669
748
|
else if (cmd === 'verify') verify()
|
|
670
|
-
else if (cmd === '
|
|
749
|
+
else if (cmd === 'codemode') await codemode(rest)
|
|
750
|
+
else if (cmd === 'context') {
|
|
751
|
+
if (rest.length) die('usage: omit context < hook-payload.json')
|
|
752
|
+
await import('../hooks/context-sentinel.mjs')
|
|
753
|
+
}
|
|
754
|
+
else if (cmd === 'doctor') {
|
|
755
|
+
if (rest.some(arg => arg !== '--json')) die('usage: omit doctor [--json]')
|
|
756
|
+
const report = doctor(cwd)
|
|
757
|
+
console.log(rest.includes('--json') ? JSON.stringify(report, null, 2) : ['omit doctor (read-only; live delivery unverified)', ...report.findings.map(f => `${f.level}: ${f.message}`)].join('\n'))
|
|
758
|
+
if (report.findings.some(f => f.level === 'error')) process.exitCode = 1
|
|
759
|
+
}
|
|
760
|
+
else if (cmd === 'hook' && rest[0] === 'install' && rest[1] === 'codex') hookInstallCodex(rest.slice(2))
|
|
761
|
+
else if (cmd === 'hook' && rest[0] === 'install' && rest[1] === 'claude') hookInstallCodex(rest.slice(2), 'claude')
|
|
762
|
+
else if (cmd === 'hook' && rest[0] === 'install' && rest[1] === 'ribhu') hookInstallRibhu()
|
|
671
763
|
else if (cmd === 'hook' && rest[0] === 'install' && rest[1] === undefined) hookInstall()
|
|
672
764
|
else if (cmd === 'hook' && rest[0] === 'install') {
|
|
673
|
-
console.error(`omit: unrecognized 'hook install' target '${rest[1]}' — usage: omit hook install [codex]`)
|
|
765
|
+
console.error(`omit: unrecognized 'hook install' target '${rest[1]}' — usage: omit hook install [codex|claude|ribhu]`)
|
|
674
766
|
process.exit(1)
|
|
675
767
|
}
|
|
676
768
|
else if (cmd === 'init') init(rest[0])
|
|
677
769
|
else if (targets[cmd]) init(cmd) // back-compat: `omit cursor`
|
|
678
770
|
else {
|
|
679
|
-
console.error('usage: omit <init|audit|check|gate|lint|guard|leak|verify|
|
|
771
|
+
console.error('usage: omit <init|audit|check|gate|lint|guard|leak|verify|codemode|context|doctor [--json]|hook install [codex|claude|ribhu] [--context]>')
|
|
680
772
|
process.exit(cmd ? 1 : 0)
|
|
681
773
|
}
|
|
682
774
|
} catch (e) {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Opt-in context controls. Structured tool results are never replaced.
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import { digest, readWarnings, preview, existingSessionDir, archiveText, repeatCount } from '../lib/context.mjs'
|
|
5
|
+
|
|
6
|
+
if (process.env.OMIT_OFF === '1') process.exit(0)
|
|
7
|
+
try {
|
|
8
|
+
const data = JSON.parse(readFileSync(0, 'utf8'))
|
|
9
|
+
if (!['Bash', 'exec_command', 'shell'].includes(data.tool_name)) process.exit(0)
|
|
10
|
+
const command = data.tool_input?.command ?? data.tool_input?.cmd
|
|
11
|
+
if (data.hook_event_name === 'PreToolUse') {
|
|
12
|
+
const warnings = readWarnings(command)
|
|
13
|
+
if (warnings.length) console.log(JSON.stringify({ systemMessage: `omit: ${warnings.join(' ')}` }))
|
|
14
|
+
process.exit(0)
|
|
15
|
+
}
|
|
16
|
+
if (data.hook_event_name !== 'PostToolUse' || typeof data.tool_response !== 'string') process.exit(0)
|
|
17
|
+
const text = data.tool_response
|
|
18
|
+
const limit = Number(process.env.OMIT_OUTPUT_CHARS ?? 6000)
|
|
19
|
+
if (!Number.isInteger(limit) || limit < 1000 || limit > 100000) throw new Error('OMIT_OUTPUT_CHARS must be 1000..100000')
|
|
20
|
+
if (typeof data.session_id !== 'string' || !data.session_id) throw new Error('missing session_id')
|
|
21
|
+
const dir = existingSessionDir(data.cwd ?? process.cwd(), data.session_id)
|
|
22
|
+
const count = typeof command === 'string' ? repeatCount(dir, digest(`${command}\0${text}`)) : 0
|
|
23
|
+
const warning = count === 3 ? 'omit: three consecutive identical commands returned identical text. Check whether another run adds information.' : ''
|
|
24
|
+
const shortened = preview(text, limit)
|
|
25
|
+
if (!shortened) {
|
|
26
|
+
if (warning) console.log(JSON.stringify({ systemMessage: warning }))
|
|
27
|
+
process.exit(0)
|
|
28
|
+
}
|
|
29
|
+
const path = archiveText(dir, text)
|
|
30
|
+
console.log(JSON.stringify({
|
|
31
|
+
decision: 'block',
|
|
32
|
+
reason: `${shortened.head}\n[omit: ${shortened.omitted} characters outside head/tail; full output: ${path}]\n${shortened.diagnostics ? `[selected diagnostics]\n${shortened.diagnostics}\n` : ''}${shortened.tail}`,
|
|
33
|
+
...(warning ? { systemMessage: warning } : {}),
|
|
34
|
+
}))
|
|
35
|
+
} catch {
|
|
36
|
+
// Preserve the original result if archiving or decoding fails.
|
|
37
|
+
console.log(JSON.stringify({ systemMessage: 'omit: context guard could not complete; original output was not replaced.' }))
|
|
38
|
+
}
|
package/hooks/dep-sentinel.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { isManifest, addedDeps, MANIFESTS } from '../lib/deps.mjs'
|
|
|
11
11
|
import { probe, fileAtRevision, repoRelPath } from '../lib/git.mjs'
|
|
12
12
|
import { execDisabled, flagOn } from '../lib/exec.mjs'
|
|
13
13
|
import { newDepCitations } from '../lib/receipts.mjs'
|
|
14
|
+
import { patchPaths } from '../lib/patch-paths.mjs'
|
|
14
15
|
|
|
15
16
|
if (process.env.OMIT_OFF === '1') process.exit(0)
|
|
16
17
|
|
|
@@ -117,7 +118,8 @@ try {
|
|
|
117
118
|
}
|
|
118
119
|
|
|
119
120
|
const cwd = data.cwd ?? process.cwd()
|
|
120
|
-
const
|
|
121
|
+
const patched = patchPaths(data)
|
|
122
|
+
const targets = patched !== null ? patched.filter(isManifest) :
|
|
121
123
|
typeof ti.file_path === 'string' ? (isManifest(ti.file_path) ? [ti.file_path] : []) : typeof ti.command === 'string' ? manifestsTouched(ti.command) : []
|
|
122
124
|
if (targets.length === 0) process.exit(0) // an edit that cannot be a manifest edit
|
|
123
125
|
|
|
@@ -11,6 +11,7 @@ import { readFileSync, realpathSync } from 'node:fs'
|
|
|
11
11
|
import { basename, resolve } from 'node:path'
|
|
12
12
|
import { probe, git, fileAtRevision, repoRelPath } from '../lib/git.mjs'
|
|
13
13
|
import { findHazards } from '../lib/hazards.mjs'
|
|
14
|
+
import { patchPaths } from '../lib/patch-paths.mjs'
|
|
14
15
|
|
|
15
16
|
if (process.env.OMIT_OFF === '1') process.exit(0)
|
|
16
17
|
|
|
@@ -128,16 +129,19 @@ try {
|
|
|
128
129
|
|
|
129
130
|
const file = typeof ti.file_path === 'string' ? ti.file_path : typeof ti.notebook_path === 'string' ? ti.notebook_path : null
|
|
130
131
|
let findings = []
|
|
131
|
-
|
|
132
|
+
const files = patchPaths(data) ?? (file === null ? [] : [file])
|
|
133
|
+
if (files.length) {
|
|
132
134
|
const root = repoRootOrNull(cwd)
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
135
|
+
for (const file of files) {
|
|
136
|
+
if (/\.lock$/.test(basename(file)) || basename(file) === 'package-lock.json') continue
|
|
137
|
+
const abs = resolve(cwd, file)
|
|
138
|
+
const rel = root === null ? null : relInRoot(root, abs)
|
|
139
|
+
if (!inOmitDir(rel ?? file)) {
|
|
140
|
+
// A notebook's added content is the cell the tool just wrote.
|
|
141
|
+
findings.push(...(typeof ti.new_source === 'string' ? findHazards(ti.new_source.split('\n')) : findHazards(addedLines(abs, rel, root))))
|
|
142
|
+
}
|
|
139
143
|
}
|
|
140
|
-
} else if (typeof ti.command === 'string' && WRITE_SHAPES.test(ti.command)) {
|
|
144
|
+
} else if (data.tool_name !== 'apply_patch' && typeof ti.command === 'string' && WRITE_SHAPES.test(ti.command)) {
|
|
141
145
|
const root = repoRootOrNull(cwd)
|
|
142
146
|
// The command's own text carries what a heredoc writes. Secret rules only:
|
|
143
147
|
// the injection rules are about code landing in a file, and the file check
|
package/hooks/lint-sentinel.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// objects immediately on errors. omit brings no lint rules of its own.
|
|
4
4
|
import { readFileSync } from 'node:fs'
|
|
5
5
|
import { lintFiles } from '../lib/lint.mjs'
|
|
6
|
+
import { patchPaths } from '../lib/patch-paths.mjs'
|
|
6
7
|
|
|
7
8
|
if (process.env.OMIT_OFF === '1') process.exit(0)
|
|
8
9
|
|
|
@@ -31,10 +32,11 @@ try {
|
|
|
31
32
|
)
|
|
32
33
|
}
|
|
33
34
|
const file = ti.file_path
|
|
34
|
-
|
|
35
|
+
const files = patchPaths(data) ?? (file ? [file] : [])
|
|
36
|
+
if (!files.length) process.exit(0)
|
|
35
37
|
|
|
36
38
|
const cwd = data.cwd ?? process.cwd()
|
|
37
|
-
const failing = lintFiles(cwd,
|
|
39
|
+
const failing = lintFiles(cwd, files).filter((r) => !r.ok)
|
|
38
40
|
if (failing.length === 0) process.exit(0)
|
|
39
41
|
|
|
40
42
|
console.error(
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Ribhu's shell hooks hand a command a different payload from the one Claude
|
|
3
|
+
// Code and Codex do:
|
|
4
|
+
//
|
|
5
|
+
// Ribhu: { event, tool, input: { path | command, … }, cwd }
|
|
6
|
+
// sentinels: { tool_input: { file_path | command, … }, cwd }
|
|
7
|
+
//
|
|
8
|
+
// The exit contract is the same on both sides: 2 blocks, and stderr is the
|
|
9
|
+
// reason. So this translates the payload, runs the named sentinel on it, and
|
|
10
|
+
// hands back that sentinel's own status and reason. The sentinels are not
|
|
11
|
+
// touched, which means an objection raised under Ribhu is the same code as
|
|
12
|
+
// one raised under Claude Code, and so is every nested call a Ribhu code-mode
|
|
13
|
+
// script makes: those go through the same hooks as a direct call.
|
|
14
|
+
//
|
|
15
|
+
// node ribhu-adapter.mjs <sentinel> payload on stdin
|
|
16
|
+
//
|
|
17
|
+
// omitted: hook_event_name, tool_name and tool_response: no sentinel reads
|
|
18
|
+
// them today; translate them with the first one that does.
|
|
19
|
+
import { spawnSync } from 'node:child_process'
|
|
20
|
+
import { readFileSync } from 'node:fs'
|
|
21
|
+
import { fileURLToPath } from 'node:url'
|
|
22
|
+
|
|
23
|
+
// load-bearing: the sentinel name comes from a hooks.json, and a cloned repo
|
|
24
|
+
// can carry one. Only these six files are ever run, never a path.
|
|
25
|
+
const SENTINELS = new Set(['command-sentinel', 'leak-sentinel', 'dep-sentinel', 'hazard-sentinel', 'lint-sentinel', 'final-draft-gate'])
|
|
26
|
+
const name = process.argv[2]
|
|
27
|
+
if (!SENTINELS.has(name)) {
|
|
28
|
+
console.error(`omit: the Ribhu adapter has no sentinel called "${name}". It runs: ${[...SENTINELS].join(', ')}`)
|
|
29
|
+
process.exit(1)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A payload this cannot read is passed on as it arrived. Each sentinel already
|
|
33
|
+
// decides what an unreadable payload means, and that decision is not this
|
|
34
|
+
// file's to make differently.
|
|
35
|
+
const raw = readFileSync(0, 'utf8')
|
|
36
|
+
let payload = raw
|
|
37
|
+
try {
|
|
38
|
+
const { input, cwd } = JSON.parse(raw)
|
|
39
|
+
const { path, ...rest } = input !== null && typeof input === 'object' ? input : {}
|
|
40
|
+
payload = JSON.stringify({ cwd, tool_input: path === undefined ? rest : { ...rest, file_path: path } })
|
|
41
|
+
} catch {}
|
|
42
|
+
|
|
43
|
+
const run = spawnSync(process.execPath, [fileURLToPath(new URL(`./${name}.mjs`, import.meta.url))], { input: payload, encoding: 'utf8' })
|
|
44
|
+
if (run.error) {
|
|
45
|
+
console.error(`omit: could not run ${name}: ${run.error.message}`)
|
|
46
|
+
process.exit(1)
|
|
47
|
+
}
|
|
48
|
+
// The sentinel's stdout stops here. Ribhu reads a hook's stdout as a rewrite of
|
|
49
|
+
// the tool's input or output, and nothing a sentinel prints is one.
|
|
50
|
+
process.stderr.write(run.stderr)
|
|
51
|
+
process.exit(run.status ?? 1)
|
package/lib/codemode.mjs
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// omit codemode: one MCP tool that runs a model-written script in a sandbox
|
|
2
|
+
// whose only capability is the read-only tools below.
|
|
3
|
+
//
|
|
4
|
+
// An agent exploring a repo pays for every intermediate result: each file read
|
|
5
|
+
// and each grep is a round-trip whose raw output lands in the transcript and is
|
|
6
|
+
// re-sent on every later turn. Here the reads happen inside the script and only
|
|
7
|
+
// what it returns reaches the model.
|
|
8
|
+
//
|
|
9
|
+
// The sandbox itself is not written here. Isolation is load-bearing, and
|
|
10
|
+
// node:vm is not isolation (the receipt in .omit/receipts.jsonl executes the
|
|
11
|
+
// escape), so it comes from @earendil-works/pi-codemode: a QuickJS VM compiled
|
|
12
|
+
// to wasm with no file system, network or process. It is an optional peer and
|
|
13
|
+
// only this command loads it, so the rest of the CLI stays zero-dependency.
|
|
14
|
+
//
|
|
15
|
+
// omitted: write and edit tools: a nested write is not seen by the host's
|
|
16
|
+
// hooks, so it has to carry the hazard, dependency and lint gates itself; add
|
|
17
|
+
// them once bench/ shows the read side pays for the surface.
|
|
18
|
+
// omitted: a tree walk for directories that are not git repositories: `files`
|
|
19
|
+
// and `grep` read the tree through git, which is what knows what is ignored;
|
|
20
|
+
// add one if a host turns out to run this outside repositories.
|
|
21
|
+
import { execFile } from 'node:child_process'
|
|
22
|
+
import { readFile, realpath, stat } from 'node:fs/promises'
|
|
23
|
+
import { relative, resolve, sep } from 'node:path'
|
|
24
|
+
import { createInterface } from 'node:readline'
|
|
25
|
+
import { promisify } from 'node:util'
|
|
26
|
+
import { findHazards } from './hazards.mjs'
|
|
27
|
+
|
|
28
|
+
const run = promisify(execFile)
|
|
29
|
+
|
|
30
|
+
const TIMEOUT_MS = 60_000
|
|
31
|
+
// What may reach the transcript from one script. Past it the middle is cut: a
|
|
32
|
+
// script that returns this much has not filtered anything.
|
|
33
|
+
const MAX_OUTPUT_CHARS = 20_000
|
|
34
|
+
// A read is one JSON round trip into the VM, so a multi-gigabyte log would be
|
|
35
|
+
// held three times over before the script saw a byte.
|
|
36
|
+
const MAX_READ_BYTES = 8 << 20
|
|
37
|
+
const GIT_BUFFER = 64 << 20
|
|
38
|
+
|
|
39
|
+
// load-bearing: path confinement. The lexical check refuses `..` and absolute
|
|
40
|
+
// paths before the file system is touched; the realpath check refuses a
|
|
41
|
+
// symlink inside the workspace that points out of it.
|
|
42
|
+
async function confine(root, path) {
|
|
43
|
+
if (typeof path !== 'string' || !path) throw new Error('path must be a non-empty string')
|
|
44
|
+
const outside = (full) => full !== root && !full.startsWith(root + sep)
|
|
45
|
+
if (outside(resolve(root, path))) throw new Error(`${path} is outside the workspace`)
|
|
46
|
+
let full
|
|
47
|
+
try {
|
|
48
|
+
full = await realpath(resolve(root, path))
|
|
49
|
+
} catch (e) {
|
|
50
|
+
throw new Error(`${path}: ${e.code === 'ENOENT' ? 'no such file or directory' : e.message}`)
|
|
51
|
+
}
|
|
52
|
+
if (outside(full)) throw new Error(`${path} is outside the workspace`)
|
|
53
|
+
return full
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Async on purpose: the host owns the script's deadline, and a synchronous git
|
|
57
|
+
// call would hold the event loop that enforces it. It also lets a script's
|
|
58
|
+
// Promise.all actually run its calls side by side.
|
|
59
|
+
async function git(root, args) {
|
|
60
|
+
try {
|
|
61
|
+
return (await run('git', args, { cwd: root, encoding: 'utf8', maxBuffer: GIT_BUFFER })).stdout
|
|
62
|
+
} catch (e) {
|
|
63
|
+
if (e.code === 1 && !e.stderr) return '' // `git grep` found nothing
|
|
64
|
+
if (e.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') throw new Error('too much output to hold: narrow the pattern or pass `under`')
|
|
65
|
+
throw new Error(String(e.stderr ?? '').trim() || e.message)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const pathspec = async (root, under) => (under === undefined ? [] : ['--', relative(root, await confine(root, under)) || '.'])
|
|
70
|
+
|
|
71
|
+
export const toolsFor = (root) => [
|
|
72
|
+
{
|
|
73
|
+
name: 'files',
|
|
74
|
+
description: 'List files, tracked and untracked, minus what git ignores.',
|
|
75
|
+
inputSchema: { type: 'object', properties: { under: { type: 'string', description: 'only files below this directory' } } },
|
|
76
|
+
outputSchema: { type: 'array', items: { type: 'string' } },
|
|
77
|
+
execute: async ({ under } = {}) => {
|
|
78
|
+
const out = await git(root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard', ...(await pathspec(root, under))])
|
|
79
|
+
return [...new Set(out.split('\0').filter(Boolean))]
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: 'read',
|
|
84
|
+
description: 'Read one file as text.',
|
|
85
|
+
inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
|
|
86
|
+
outputSchema: { type: 'string' },
|
|
87
|
+
execute: async ({ path } = {}) => {
|
|
88
|
+
const full = await confine(root, path)
|
|
89
|
+
const { size } = await stat(full)
|
|
90
|
+
if (size > MAX_READ_BYTES) throw new Error(`${path} is ${size} bytes, past the ${MAX_READ_BYTES} a read holds: use grep`)
|
|
91
|
+
return readFile(full, 'utf8')
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: 'grep',
|
|
96
|
+
description: 'Search file contents with an extended regular expression.',
|
|
97
|
+
inputSchema: {
|
|
98
|
+
type: 'object',
|
|
99
|
+
properties: { pattern: { type: 'string' }, under: { type: 'string', description: 'only files below this directory' } },
|
|
100
|
+
required: ['pattern'],
|
|
101
|
+
},
|
|
102
|
+
outputSchema: {
|
|
103
|
+
type: 'array',
|
|
104
|
+
items: { type: 'object', properties: { path: { type: 'string' }, line: { type: 'number' }, text: { type: 'string' } }, required: ['path', 'line', 'text'] },
|
|
105
|
+
},
|
|
106
|
+
execute: async ({ pattern, under } = {}) => {
|
|
107
|
+
if (typeof pattern !== 'string' || !pattern) throw new Error('pattern must be a non-empty string')
|
|
108
|
+
// -z: a path is then followed by NUL, never by the `:` a file name may contain.
|
|
109
|
+
const out = await git(root, ['grep', '-nIz', '-E', '--untracked', '-e', pattern, ...(await pathspec(root, under))])
|
|
110
|
+
return out.split('\n').filter(Boolean).map((row) => {
|
|
111
|
+
const [path, line, ...text] = row.split('\0')
|
|
112
|
+
return { path, line: Number(line), text: text.join('\0') }
|
|
113
|
+
})
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
// What a finished script hands back to the model.
|
|
119
|
+
export function shape(text, isError) {
|
|
120
|
+
// load-bearing: secrets stay out of the transcript. A script that read a
|
|
121
|
+
// credentials file must not be able to return it, and the same rules that
|
|
122
|
+
// stop a key landing in a file decide what counts as one.
|
|
123
|
+
const secrets = findHazards(text.split('\n')).filter((h) => h.type === 'secret')
|
|
124
|
+
if (secrets.length) {
|
|
125
|
+
const where = secrets.map((h) => `${h.rule} (output line ${h.line})`).join(', ')
|
|
126
|
+
return { isError: true, text: `omit: output withheld, it carries ${where}. A transcript is not a safe place for a key: filter it out in the script and return only what you need.` }
|
|
127
|
+
}
|
|
128
|
+
if (!text) return { isError, text: '(no output: return a value or call text())' }
|
|
129
|
+
if (text.length <= MAX_OUTPUT_CHARS) return { isError, text }
|
|
130
|
+
const half = MAX_OUTPUT_CHARS / 2
|
|
131
|
+
return {
|
|
132
|
+
isError,
|
|
133
|
+
text: `${text.slice(0, half)}\n[omit: ${text.length - MAX_OUTPUT_CHARS} characters cut here. Return less: count, aggregate or filter in the script.]\n${text.slice(-half)}`,
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function execute(code, { CodemodeSandbox, root }) {
|
|
138
|
+
const sandbox = new CodemodeSandbox({ tools: toolsFor(root), timeoutMs: TIMEOUT_MS })
|
|
139
|
+
try {
|
|
140
|
+
const r = await sandbox.execute(code)
|
|
141
|
+
const parts = r.output.map((item) => (item.type === 'text' ? item.text : `[${item.type} output is not supported]`))
|
|
142
|
+
if (r.ok && r.value !== undefined) parts.push(typeof r.value === 'string' ? r.value : JSON.stringify(r.value, null, 1))
|
|
143
|
+
if (!r.ok) parts.push(`Script error (${r.error.kind}): ${r.error.stack ?? r.error.message}`)
|
|
144
|
+
return shape(parts.join('\n'), !r.ok)
|
|
145
|
+
} finally {
|
|
146
|
+
await sandbox.close()
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const PROTOCOLS = ['2025-06-18', '2025-03-26', '2024-11-05']
|
|
151
|
+
|
|
152
|
+
// MCP over stdio is one JSON-RPC message per line, and three methods are all a
|
|
153
|
+
// tools-only server answers, so the protocol is spoken here rather than
|
|
154
|
+
// imported. stdout carries nothing but those messages.
|
|
155
|
+
export function serve({ sandbox, root, version, stdin = process.stdin, stdout = process.stdout }) {
|
|
156
|
+
const tool = {
|
|
157
|
+
name: 'codemode',
|
|
158
|
+
description:
|
|
159
|
+
'Run JavaScript that explores this repository through read-only tools, and get back only what the script returns. ' +
|
|
160
|
+
'Use it instead of many separate reads and searches: call tools in parallel with Promise.all, filter and count in the script, return the conclusion. ' +
|
|
161
|
+
`\`code\` is the body of an async function: \`await\` and \`return\` work at the top level, text(value) adds to the output, and there is no fs, network or process. Paths are relative to ${root}.\n\n` +
|
|
162
|
+
sandbox.renderDeclarations({ tools: toolsFor(root) }),
|
|
163
|
+
inputSchema: { type: 'object', properties: { code: { type: 'string', description: 'JavaScript source, the body of an async function' } }, required: ['code'] },
|
|
164
|
+
annotations: { readOnlyHint: true },
|
|
165
|
+
}
|
|
166
|
+
const invalid = (message) => Object.assign(new Error(message), { code: -32602 })
|
|
167
|
+
// A Map, not an object literal: `method` is untrusted, and "constructor"
|
|
168
|
+
// would otherwise resolve to something callable.
|
|
169
|
+
const methods = new Map([
|
|
170
|
+
['initialize', (p) => ({ protocolVersion: PROTOCOLS.includes(p?.protocolVersion) ? p.protocolVersion : PROTOCOLS[0], capabilities: { tools: {} }, serverInfo: { name: 'omit', version } })],
|
|
171
|
+
['ping', () => ({})],
|
|
172
|
+
['tools/list', () => ({ tools: [tool] })],
|
|
173
|
+
['tools/call', async (p) => {
|
|
174
|
+
if (p?.name !== tool.name) throw invalid(`unknown tool: ${p?.name}`)
|
|
175
|
+
if (typeof p.arguments?.code !== 'string') throw invalid('`code` must be a string')
|
|
176
|
+
const { text, isError } = await execute(p.arguments.code, { CodemodeSandbox: sandbox.CodemodeSandbox, root })
|
|
177
|
+
return { content: [{ type: 'text', text }], isError }
|
|
178
|
+
}],
|
|
179
|
+
])
|
|
180
|
+
const send = (message) => stdout.write(`${JSON.stringify({ jsonrpc: '2.0', ...message })}\n`)
|
|
181
|
+
createInterface({ input: stdin }).on('line', async (line) => {
|
|
182
|
+
if (!line.trim()) return
|
|
183
|
+
let message
|
|
184
|
+
try {
|
|
185
|
+
message = JSON.parse(line)
|
|
186
|
+
} catch {
|
|
187
|
+
return send({ id: null, error: { code: -32700, message: 'parse error' } })
|
|
188
|
+
}
|
|
189
|
+
if (message?.id === undefined) return // a notification: nothing to answer
|
|
190
|
+
const method = methods.get(message.method)
|
|
191
|
+
if (!method) return send({ id: message.id, error: { code: -32601, message: `method not found: ${message.method}` } })
|
|
192
|
+
try {
|
|
193
|
+
send({ id: message.id, result: await method(message.params) })
|
|
194
|
+
} catch (e) {
|
|
195
|
+
send({ id: message.id, error: { code: Number.isInteger(e.code) ? e.code : -32603, message: e.message } })
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
}
|
package/lib/context.mjs
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
2
|
+
import { mkdirSync, lstatSync, readFileSync, writeFileSync, renameSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
export const digest = text => createHash('sha256').update(text).digest('hex')
|
|
7
|
+
|
|
8
|
+
// Advisory heuristics only: shell syntax is not parsed or rewritten here.
|
|
9
|
+
export function readWarnings(command) {
|
|
10
|
+
if (typeof command !== 'string') return []
|
|
11
|
+
const warnings = []
|
|
12
|
+
if (/\b(cat|rg|grep|find)\b/.test(command) && /(?:node_modules|\.git|coverage|\*\*|\$HOME|~\/)/.test(command)) {
|
|
13
|
+
warnings.push('Broad read: scope the search to source paths and exclude generated directories.')
|
|
14
|
+
}
|
|
15
|
+
if (/^\s*cat\s+[^|;>]+$/.test(command)) warnings.push('Whole-file read: prefer a targeted range or search when only part of the file is needed.')
|
|
16
|
+
if (/\b(?:rg|grep|find)\b.*\s\.(?:\s|$)/.test(command) && !/\|\s*(?:head|tail)\b/.test(command)) warnings.push('Repository-wide read: choose a source subdirectory or bound the returned matches.')
|
|
17
|
+
return warnings
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function preview(text, limit = 6000) {
|
|
21
|
+
if (text.length <= limit) return null
|
|
22
|
+
// Preserve some diagnostic lines even when the failure sits in the middle.
|
|
23
|
+
const diagnostics = (text.match(/^.*\b(?:error|failed|failure|fatal)\b.*$/gim) ?? []).join('\n').slice(0, Math.floor(limit / 5))
|
|
24
|
+
const remaining = limit - diagnostics.length
|
|
25
|
+
const head = Math.ceil(remaining / 2)
|
|
26
|
+
return { head: text.slice(0, head), tail: text.slice(-(remaining - head)), diagnostics, omitted: text.length - remaining }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// load-bearing: private, owner-controlled storage; never follow an existing symlink.
|
|
30
|
+
export function existingSessionDir(cwd, session) {
|
|
31
|
+
const path = join(tmpdir(), `omit-context-${process.getuid?.() ?? 'user'}-${digest(`${cwd}\0${session}`).slice(0, 32)}`)
|
|
32
|
+
try { mkdirSync(path, { mode: 0o700 }) } catch (e) {
|
|
33
|
+
if (e.code !== 'EEXIST') throw e
|
|
34
|
+
const stat = lstatSync(path)
|
|
35
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (process.getuid && stat.uid !== process.getuid()) || (stat.mode & 0o077)) throw new Error('unsafe context storage')
|
|
36
|
+
}
|
|
37
|
+
return path
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function archiveText(dir, text) {
|
|
41
|
+
const path = join(dir, `${randomUUID()}.txt`)
|
|
42
|
+
writeFileSync(path, text, { flag: 'wx', mode: 0o600 })
|
|
43
|
+
return path
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function repeatCount(dir, key) {
|
|
47
|
+
const path = join(dir, 'repeat.json')
|
|
48
|
+
let previous = {}
|
|
49
|
+
try {
|
|
50
|
+
const stat = lstatSync(path)
|
|
51
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024) throw new Error('unsafe repeat state')
|
|
52
|
+
previous = JSON.parse(readFileSync(path, 'utf8'))
|
|
53
|
+
} catch (e) { if (e.code !== 'ENOENT') throw e }
|
|
54
|
+
const count = previous.key === key ? Math.min((previous.count || 0) + 1, 1000000) : 1
|
|
55
|
+
const temporary = join(dir, `${randomUUID()}.json`)
|
|
56
|
+
writeFileSync(temporary, JSON.stringify({ key, count }), { flag: 'wx', mode: 0o600 })
|
|
57
|
+
renameSync(temporary, path)
|
|
58
|
+
return count
|
|
59
|
+
}
|
package/lib/doctor.mjs
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join, isAbsolute, resolve } from 'node:path'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { probe } from './git.mjs'
|
|
5
|
+
|
|
6
|
+
// Read-only: never execute discovered hook commands to test their health.
|
|
7
|
+
export function doctor(cwd, codexHome = process.env.CODEX_HOME || join(homedir(), '.codex')) {
|
|
8
|
+
const findings = []
|
|
9
|
+
const seen = new Set()
|
|
10
|
+
const files = [...new Set([join(codexHome, 'hooks.json'), join(cwd, '.codex', 'hooks.json'), join(cwd, '.claude', 'settings.json')])]
|
|
11
|
+
for (const file of files) {
|
|
12
|
+
if (!existsSync(file)) { findings.push({ level: 'info', file, message: 'No hooks file.' }); continue }
|
|
13
|
+
try {
|
|
14
|
+
const doc = JSON.parse(readFileSync(file, 'utf8'))
|
|
15
|
+
if (!doc.hooks || typeof doc.hooks !== 'object' || Array.isArray(doc.hooks)) throw new Error('invalid hooks object')
|
|
16
|
+
for (const [event, groups] of Object.entries(doc.hooks)) {
|
|
17
|
+
if (!Array.isArray(groups)) throw new Error('invalid hook groups')
|
|
18
|
+
for (const group of groups) {
|
|
19
|
+
if (!Array.isArray(group.hooks)) throw new Error('invalid hooks list')
|
|
20
|
+
if (group.matcher) {
|
|
21
|
+
try { new RegExp(group.matcher) } catch { findings.push({ level: 'error', file, message: `${event}: invalid matcher.` }) }
|
|
22
|
+
if (/exec_command|^exec$|^shell$/.test(group.matcher) && !group.matcher.includes('Bash')) findings.push({ level: 'warning', file, message: `${event}: shell matcher lacks canonical Bash; verify harness aliases.` })
|
|
23
|
+
}
|
|
24
|
+
for (const hook of group.hooks) {
|
|
25
|
+
if (hook.type !== 'command') { findings.push({ level: 'info', file, message: `${event}: non-command hook not inspected.` }); continue }
|
|
26
|
+
if (typeof hook.command !== 'string') throw new Error('missing hook command')
|
|
27
|
+
const key = `${event}\0${group.matcher ?? ''}\0${hook.command}`
|
|
28
|
+
if (seen.has(key)) findings.push({ level: 'warning', file, message: `${event}: duplicate hook registration.` })
|
|
29
|
+
seen.add(key)
|
|
30
|
+
const match = /^(?:\S*\/)?(?:node|python3?)\s+(?:"([^"$]+)"|'([^']+)'|([^\s$;|&]+))\s*$/.exec(hook.command)
|
|
31
|
+
if (!match) { findings.push({ level: 'info', file, message: `${event}: command shape not statically checked.` }); continue }
|
|
32
|
+
const script = match[1] ?? match[2] ?? match[3]
|
|
33
|
+
const path = isAbsolute(script) ? script : resolve(cwd, script)
|
|
34
|
+
findings.push({ level: existsSync(path) ? 'info' : 'error', file, message: `${event}: script ${existsSync(path) ? 'exists' : 'missing'}: ${path}` })
|
|
35
|
+
if (existsSync(path) && path.endsWith('.py')) {
|
|
36
|
+
const source = readFileSync(path, 'utf8')
|
|
37
|
+
const names = /^TOOL_NAMES\s*=\s*\{([^}\n]*)\}/m.exec(source)?.[1]
|
|
38
|
+
if (names && /['"](?:exec|exec_command|shell)['"]/.test(names) && !/['"]Bash['"]/.test(names)) {
|
|
39
|
+
findings.push({ level: 'warning', file, message: `${event}: literal Python TOOL_NAMES omits Bash; synthetic payload testing recommended.` })
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
} catch { findings.push({ level: 'error', file, message: 'Unreadable or malformed hooks configuration.' }) }
|
|
46
|
+
}
|
|
47
|
+
const git = probe(cwd, ['config', '--get', 'core.hooksPath'])
|
|
48
|
+
if (git.ok && git.out.trim()) findings.push({ level: 'warning', message: 'core.hooksPath overrides the repository .git/hooks directory.' })
|
|
49
|
+
return { liveDelivery: 'unverified', scope: 'global Codex and current-directory Codex/Claude hook files; selected script existence and Git routing', findings }
|
|
50
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Codex reports apply_patch as tool_input.command, not file_path.
|
|
2
|
+
// Return null for other tools; deletions have no surviving file to inspect.
|
|
3
|
+
export function patchPaths(data) {
|
|
4
|
+
if (data.tool_name !== 'apply_patch') return null
|
|
5
|
+
const patch = data.tool_input?.command
|
|
6
|
+
// load-bearing: malformed patch payloads must not silently pass inspection.
|
|
7
|
+
if (typeof patch !== 'string') throw new Error('apply_patch requires tool_input.command')
|
|
8
|
+
const lines = patch.trim().split(/\r?\n/)
|
|
9
|
+
if (lines.shift() !== '*** Begin Patch' || lines.pop() !== '*** End Patch') {
|
|
10
|
+
throw new Error('unrecognized apply_patch envelope')
|
|
11
|
+
}
|
|
12
|
+
const paths = new Set()
|
|
13
|
+
let current = null
|
|
14
|
+
for (const line of lines) {
|
|
15
|
+
const header = /^\*\*\* (Add|Update|Delete) File: (.+)$/.exec(line)
|
|
16
|
+
if (header) {
|
|
17
|
+
current = header[1] === 'Delete' ? null : header[2]
|
|
18
|
+
if (current) paths.add(current)
|
|
19
|
+
} else if (line.startsWith('*** Move to: ')) {
|
|
20
|
+
if (!current) throw new Error('patch move without a source file')
|
|
21
|
+
paths.delete(current)
|
|
22
|
+
current = line.slice('*** Move to: '.length)
|
|
23
|
+
if (!current) throw new Error('patch move without a destination')
|
|
24
|
+
paths.add(current)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return [...paths]
|
|
28
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sriinnu/omit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Omit needless code. Editorial discipline for AI coding agents: draft less, cite everything, cut last: enforced by hooks, a pre-commit gate, and a PR bot, whatever agent writes the code.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,8 +9,7 @@
|
|
|
9
9
|
"scripts": {
|
|
10
10
|
"test": "node --test",
|
|
11
11
|
"preversion": "npm test",
|
|
12
|
-
"release": "node scripts/release.mjs"
|
|
13
|
-
"token:sync": "node scripts/sync-token.mjs"
|
|
12
|
+
"release": "node scripts/release.mjs"
|
|
14
13
|
},
|
|
15
14
|
"files": [
|
|
16
15
|
"bin",
|
|
@@ -36,12 +35,35 @@
|
|
|
36
35
|
"code-quality",
|
|
37
36
|
"agent-skill",
|
|
38
37
|
"secrets-detection",
|
|
39
|
-
"pre-commit"
|
|
38
|
+
"pre-commit",
|
|
39
|
+
"agent-skills",
|
|
40
|
+
"claude-skills",
|
|
41
|
+
"skill-md",
|
|
42
|
+
"agents-md",
|
|
43
|
+
"codex",
|
|
44
|
+
"windsurf",
|
|
45
|
+
"cline",
|
|
46
|
+
"github-copilot",
|
|
47
|
+
"llm",
|
|
48
|
+
"ai-coding",
|
|
49
|
+
"code-review",
|
|
50
|
+
"github-action"
|
|
40
51
|
],
|
|
41
52
|
"repository": {
|
|
42
53
|
"type": "git",
|
|
43
54
|
"url": "https://github.com/sriinnu/omit.git"
|
|
44
55
|
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@earendil-works/pi-codemode": "^1.0.3"
|
|
58
|
+
},
|
|
59
|
+
"peerDependencies": {
|
|
60
|
+
"@earendil-works/pi-codemode": "^1.0.3"
|
|
61
|
+
},
|
|
62
|
+
"peerDependenciesMeta": {
|
|
63
|
+
"@earendil-works/pi-codemode": {
|
|
64
|
+
"optional": true
|
|
65
|
+
}
|
|
66
|
+
},
|
|
45
67
|
"publishConfig": {
|
|
46
68
|
"access": "public"
|
|
47
69
|
},
|
package/skills/omit/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: omit
|
|
3
|
-
description: Editorial discipline for AI-written code
|
|
3
|
+
description: "Editorial discipline for AI-written code and local agent guardrails. Use for omit, simplifying code, verifying hook health, or reducing excessive tool output and repeated reads."
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# omit
|
|
@@ -11,7 +11,27 @@ Great software is edited, not written. You are the editor, not just the author:
|
|
|
11
11
|
|
|
12
12
|
> Draft less. Cite everything. Cut last.
|
|
13
13
|
|
|
14
|
-
##
|
|
14
|
+
## Hook health and context controls
|
|
15
|
+
|
|
16
|
+
Use the locally installed `omit` CLI, or `node bin/omit.mjs` from its source
|
|
17
|
+
checkout. These deterministic checks require no model, provider, API key, or
|
|
18
|
+
network call. `omit doctor --json` inspects configuration without executing hook
|
|
19
|
+
commands. Script existence and synthetic tests do not prove live hook delivery.
|
|
20
|
+
|
|
21
|
+
`omit hook install codex --context` or `omit hook install claude --context`
|
|
22
|
+
merges opt-in context hooks into the current project's hook file. This changes
|
|
23
|
+
host configuration; use it when installation is requested. `omit init skill`
|
|
24
|
+
copies this skill to `.agents/skills/omit/SKILL.md` without overwriting a file.
|
|
25
|
+
|
|
26
|
+
The context guard warns about broad reads and three consecutive identical
|
|
27
|
+
command/results. It archives long plain-text shell output privately and returns
|
|
28
|
+
head/tail excerpts; inspect the archive when omitted details matter. Structured
|
|
29
|
+
results are untouched. `OMIT_OUTPUT_CHARS` controls excerpt characters (default
|
|
30
|
+
6000, range 1000–100000), not tokens. It is not a quota cap. `OMIT_OFF=1` disables
|
|
31
|
+
the hooks. Other hosts can invoke `omit context` with compatible JSON on stdin;
|
|
32
|
+
verify their output-replacement contract before enabling truncation.
|
|
33
|
+
|
|
34
|
+
## Editorial modes
|
|
15
35
|
|
|
16
36
|
- **margin**: build as asked; leave notes in the margin where something could have been omitted.
|
|
17
37
|
- **redline** *(default)*: full enforcement: the Seven Omissions, the Fact-Check, the Final Draft.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: omit-codemode
|
|
3
|
+
description: "Explore a repository with one sandboxed script instead of many separate reads and searches, so only the answer reaches the conversation. Use when a task needs three or more reads or greps, a loop over files, or a count or summary across the codebase. Read-only."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# omit codemode
|
|
7
|
+
|
|
8
|
+
*Omit needless context.*
|
|
9
|
+
|
|
10
|
+
Every separate read or grep puts its raw output into the conversation, where it is paid for again on every later turn. Write one script instead: the reads happen inside it, and only what it returns comes back.
|
|
11
|
+
|
|
12
|
+
## Run it
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
omit codemode run <<'EOF'
|
|
16
|
+
const libs = (await tools.files({ under: 'lib' })).filter((f) => f.endsWith('.mjs'))
|
|
17
|
+
const sources = await Promise.all(libs.map((path) => tools.read({ path })))
|
|
18
|
+
return Object.fromEntries(libs.map((f, i) => [f, (sources[i].match(/^export /gm) ?? []).length]))
|
|
19
|
+
EOF
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Quote the delimiter (`'EOF'`) so the shell leaves the script alone. Exit status 1 means the script failed or its result was withheld.
|
|
23
|
+
|
|
24
|
+
## The script
|
|
25
|
+
|
|
26
|
+
The body of an async function: `await` and `return` work at the top level.
|
|
27
|
+
|
|
28
|
+
| Call | Resolves to |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `tools.files({ under? })` | `string[]`: tracked and untracked files, minus what git ignores |
|
|
31
|
+
| `tools.read({ path })` | `string`: one file as text |
|
|
32
|
+
| `tools.grep({ pattern, under? })` | `{ path, line, text }[]`: extended regular expression; no match is `[]` |
|
|
33
|
+
|
|
34
|
+
`text(value)` adds to the output and `return` adds the returned value. A call that fails rejects with an `Error`; use `Promise.allSettled` to keep the calls that worked. Run independent calls together with `Promise.all`.
|
|
35
|
+
|
|
36
|
+
There is nothing else in there: no file system, network, `process` or timers. Paths are relative to the current directory and cannot leave it.
|
|
37
|
+
|
|
38
|
+
## Return the conclusion
|
|
39
|
+
|
|
40
|
+
- Return counts, names and the few lines that matter, not file contents. Past 20,000 characters the middle of the result is cut.
|
|
41
|
+
- A result that contains a secret is withheld whole. Filter it out in the script.
|
|
42
|
+
- A script has 60 seconds.
|
|
43
|
+
|
|
44
|
+
## When not to
|
|
45
|
+
|
|
46
|
+
- One file or one search: use the ordinary tool.
|
|
47
|
+
- Anything that writes. Codemode is read-only: make edits with the ordinary tools, where the sentinels see them.
|
|
48
|
+
- If the command says its sandbox is not installed, give the user the install line it prints and carry on with ordinary tools. Do not work around it.
|