@hasna/hooks 0.11.6 → 0.12.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/README.md +129 -4
- package/bin/hooks-mcp.js +37 -5
- package/bin/index.js +153 -15
- package/bin/native-safety-entry.js +15 -1718
- package/bin/serve.js +27 -0
- package/dist/index.js +38 -5
- package/dist/lib/native-safety-registration.d.ts +49 -6
- package/dist/lib/registry.d.ts +15 -0
- package/dist/native-safety.d.ts +4 -0
- package/dist/native-safety.js +8 -4
- package/hooks/hook-signed-link-guard/README.md +228 -0
- package/hooks/hook-signed-link-guard/package.json +12 -0
- package/hooks/hook-signed-link-guard/src/classify.ts +1280 -0
- package/hooks/hook-signed-link-guard/src/hook.ts +72 -0
- package/hooks/hook-signed-link-guard/src/jq.ts +545 -0
- package/hooks/hook-signed-link-guard/src/links.ts +136 -0
- package/hooks/hook-signed-link-output/README.md +47 -0
- package/hooks/hook-signed-link-output/package.json +12 -0
- package/hooks/hook-signed-link-output/src/hook.ts +111 -0
- package/hooks/hook-trash-guard/src/hook.ts +204 -80
- package/hooks/native-safety-entry.ts +28 -7
- package/package.json +2 -2
- package/scripts/validate-package.ts +12 -2
package/README.md
CHANGED
|
@@ -33,8 +33,20 @@ hooks safety startup registration-plan --target codex
|
|
|
33
33
|
hooks safety startup registration-apply --target codex --expected-plan-digest SHA256
|
|
34
34
|
hooks safety startup trust-plan --target codex --codex-command /absolute/native/codex --cwd /absolute/project
|
|
35
35
|
hooks safety startup trust-apply --target codex --codex-command /absolute/native/codex --cwd /absolute/project --expected-plan-digest SHA256
|
|
36
|
+
|
|
37
|
+
hooks safety startup registration-plan --target sumi
|
|
38
|
+
hooks safety startup registration-apply --target sumi --expected-plan-digest SHA256
|
|
36
39
|
```
|
|
37
40
|
|
|
41
|
+
Sumi runs the observer inside its own process from the bundled SDK, switched on
|
|
42
|
+
by Sumi's own `experimental.trash_readiness` setting, so there is no startup
|
|
43
|
+
command to register. For Sumi, the startup registration re-points the Hooks-owned
|
|
44
|
+
guard record, `~/.hasna/hooks/native/sumi-trash-guard.json`, at this installed
|
|
45
|
+
package. Sumi's startup check verifies that record. The registration uses the same
|
|
46
|
+
read-only plan, the exact plan digest and a retained predecessor as the other
|
|
47
|
+
targets. Unlike `hooks safety provision`, it also moves a different record that
|
|
48
|
+
still verifies. It never writes Sumi's configuration.
|
|
49
|
+
|
|
38
50
|
After registration and trust, use Trash's station setup flow to produce a new
|
|
39
51
|
ready plan. Native configuration changes must precede this plan because its
|
|
40
52
|
digest binds the selected configuration. Bind that plan explicitly:
|
|
@@ -44,9 +56,15 @@ hooks safety startup bind --target codex --binding /absolute/binding.json --expe
|
|
|
44
56
|
hooks safety startup check --target codex
|
|
45
57
|
```
|
|
46
58
|
|
|
47
|
-
The binding
|
|
59
|
+
The full binding-file contract is in
|
|
60
|
+
[docs/trash-readiness.md](docs/trash-readiness.md). It covers where each field
|
|
61
|
+
comes from, the compare-and-swap digest, the output and refusal codes, and how
|
|
62
|
+
to probe a result. The binding schema is `hasna.hooks.trash-readiness/v1`. It contains the exact
|
|
48
63
|
provider and home, `runtime:{path,sha256}`, `trash:{root,version,cliSha256}`,
|
|
49
|
-
`plan:{path,sha256}`, and `timeoutMs` (1000–60000).
|
|
64
|
+
`plan:{path,sha256}`, and `timeoutMs` (1000–60000). The plan is the ready plan
|
|
65
|
+
that `trash station plan --out` wrote, and `runtime` is that plan's `spec.runtime`.
|
|
66
|
+
`--expected-binding-sha256` is the SHA-256 of the installed binding file, or
|
|
67
|
+
`absent`. Use 45000 for a normal
|
|
50
68
|
whole-startup budget, configurable within those bounds. Existing bindings retain
|
|
51
69
|
their explicit budget until a guarded update. Claude and Codex registrations
|
|
52
70
|
allow 65 seconds for the observer's 60-second maximum plus runner overhead.
|
|
@@ -64,7 +82,10 @@ supervises only its newly created check process group, including nested clients,
|
|
|
64
82
|
and cleans it up on completion, cancellation or timeout. Selecting `sumi`
|
|
65
83
|
identifies that consumer; it does not register
|
|
66
84
|
a Sumi lifecycle event. A Sumi adapter must call the observer through its own
|
|
67
|
-
supported startup path and verify delivery separately.
|
|
85
|
+
supported startup path and verify delivery separately. A running harness keeps
|
|
86
|
+
the SDK version it started with. A 45000 ms binding needs a consumer built with
|
|
87
|
+
0.11.4 or later, because older consumers cap `timeoutMs` at 15000 and report
|
|
88
|
+
`binding_invalid`.
|
|
68
89
|
|
|
69
90
|
```bash
|
|
70
91
|
hooks --help
|
|
@@ -121,6 +142,37 @@ reinstalling the registration and reviewing the new native trust definition. Uns
|
|
|
121
142
|
installation paths are refused before settings are written. Codex also requires
|
|
122
143
|
trust through `/hooks`; registration alone does not prove interception.
|
|
123
144
|
|
|
145
|
+
The same bundled entry also evaluates the `signed-link-guard` for every Bash
|
|
146
|
+
and Monitor command (Claude Code's `Monitor` tool runs a shell command and
|
|
147
|
+
streams its stdout to the model), under either capability name (`trash-guard`
|
|
148
|
+
or `workspace-repos-guard`): a `gh` read that would print composite GitHub
|
|
149
|
+
content (bodies, comments, reviews, check links and details, raw issue and
|
|
150
|
+
pull request objects) is refused before it runs with the reason
|
|
151
|
+
`composite output withheld: signed-link shape; use bounded scalar projections per class disposition 799310`.
|
|
152
|
+
Bounded scalar reads such as `gh pr view <n> --json number,state,headRefOid`,
|
|
153
|
+
`gh pr checks <n> --json name,state,bucket` and `gh api … --jq .head.sha` pass,
|
|
154
|
+
and so does every `gh` write, including a `--body "$(cat <<'EOF' … EOF)"`
|
|
155
|
+
body. A plain `gh pr view <n>` and a plain or `--watch` `gh pr checks <n>` are
|
|
156
|
+
refused, because they print the body and the check links; the guard's README
|
|
157
|
+
lists the scalar alternatives. See `hooks/hook-signed-link-guard/README.md` for
|
|
158
|
+
the full rule list and its limits. The guard is also a catalog hook
|
|
159
|
+
(`hooks install signed-link-guard`), and `signed-link-output` is an optional
|
|
160
|
+
Claude PostToolUse backstop that replaces signed-link URLs in tool output where
|
|
161
|
+
the running Claude Code supports output replacement.
|
|
162
|
+
|
|
163
|
+
Monitor commands get every check a Bash command gets. A deletion inside a
|
|
164
|
+
Monitor command is refused instead of rewritten into Trash, because the
|
|
165
|
+
rewrite is proven only for the Bash tool; run deletions with Bash. Claude
|
|
166
|
+
registrations therefore match
|
|
167
|
+
`^(Bash|Monitor|apply_patch|ApplyPatch|functions\.apply_patch)$` (the
|
|
168
|
+
repository guard also matches `Write`, `Edit`, `MultiEdit` and
|
|
169
|
+
`NotebookEdit`). Re-registering with `--overwrite` or `registration-apply`
|
|
170
|
+
updates a registration that still has the matcher without `Monitor` in place,
|
|
171
|
+
and the Claude safety check reports that older matcher as not installed.
|
|
172
|
+
Because a registration pins the worker's digest, an installed registration
|
|
173
|
+
keeps running the previous worker until it is re-registered for the new
|
|
174
|
+
install; see "Rolling out a new native worker" below.
|
|
175
|
+
|
|
124
176
|
For the default global Claude settings path, a managed Skills installation
|
|
125
177
|
coordinates hook changes and discovery witnesses in one guarded update. Existing
|
|
126
178
|
drift or concurrent edits refuse installation before an uncoordinated write;
|
|
@@ -188,6 +240,79 @@ runtime-policy verification, native discovery and native adoption false;
|
|
|
188
240
|
MDM/server policy, workspace trust and launch flags require native acceptance.
|
|
189
241
|
|
|
190
242
|
|
|
243
|
+
## Rolling out a new native worker
|
|
244
|
+
|
|
245
|
+
Every native registration pins the SHA-256 of `bin/native-safety-entry.js`, so
|
|
246
|
+
a release that changes the worker reaches a harness only when that harness is
|
|
247
|
+
re-registered against the new install. Run each step with the new version's
|
|
248
|
+
CLI (`hooks --version`), after installing that version:
|
|
249
|
+
|
|
250
|
+
```bash
|
|
251
|
+
# Claude Code
|
|
252
|
+
hooks safety registration-plan --target claude
|
|
253
|
+
hooks safety registration-apply --target claude --expected-plan-digest PLAN_SHA256
|
|
254
|
+
# Only where the repository guard is registered under its own name:
|
|
255
|
+
hooks safety install workspace-repos-guard --target claude --overwrite
|
|
256
|
+
|
|
257
|
+
# Codex (a changed definition runs only after it is trusted again)
|
|
258
|
+
hooks safety registration-plan --target codex
|
|
259
|
+
hooks safety registration-apply --target codex --expected-plan-digest PLAN_SHA256
|
|
260
|
+
hooks safety trust-plan --target codex --cwd /path/to/project --codex-command /absolute/path/to/native/codex
|
|
261
|
+
hooks safety trust-apply --target codex --cwd /path/to/project --codex-command /absolute/path/to/native/codex --expected-plan-digest PLAN_SHA256
|
|
262
|
+
|
|
263
|
+
# Sumi
|
|
264
|
+
hooks safety verify --target sumi # prints the current binding's sha256
|
|
265
|
+
hooks safety install trash-guard --target sumi --expected-binding-sha256 CURRENT_SHA256
|
|
266
|
+
hooks safety verify --target sumi --execution
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
`hooks safety provision --target sumi`, which the package's lifecycle script
|
|
270
|
+
also runs, keeps a binding that still verifies, so it does not move Sumi to a
|
|
271
|
+
worker installed in a different tree; the explicit install above does. That
|
|
272
|
+
install refuses `--overwrite` with `input_invalid`: the expected digest is its
|
|
273
|
+
upgrade guard. A binding that no longer verifies (its worker was rewritten)
|
|
274
|
+
cannot report its digest; `hooks safety provision --target sumi` re-registers
|
|
275
|
+
that one and keeps the previous record beside it. Where
|
|
276
|
+
`hooks safety startup registration-plan` and `registration-apply` accept
|
|
277
|
+
`--target sumi` (hasna/apps#2932), that registration re-points the same record
|
|
278
|
+
through a read-only plan and an exact plan digest, and it supersedes the install
|
|
279
|
+
form above.
|
|
280
|
+
|
|
281
|
+
A running Sumi service keeps the readiness code bundled into its own build, so
|
|
282
|
+
Sumi's startup readiness warning clears only once the service runs a Sumi build
|
|
283
|
+
of 0.2.45 or later. Restarting the service is an owner decision and is not part
|
|
284
|
+
of this rollout.
|
|
285
|
+
|
|
286
|
+
Installing over the running version is a half-registration hazard.
|
|
287
|
+
`bun add -g @hasna/hooks@<version>` (or `npm install -g`) rewrites the worker
|
|
288
|
+
in place in the global package tree. Every registration still pinned to the
|
|
289
|
+
old digest then refuses every shell and patch command in its harness, since
|
|
290
|
+
the supervisor fails closed on changed bytes, until it is re-registered; the
|
|
291
|
+
lifecycle script re-provisions only Sumi. Until the last registration is
|
|
292
|
+
applied, some harnesses run the new guard and the others refuse everything.
|
|
293
|
+
Install each version into its own tree (the fleet's
|
|
294
|
+
`~/.hasna/hooks/runtime/<version>-copyfile` layout) and register from there,
|
|
295
|
+
so every old registration keeps verifying its own tree until it is moved. If
|
|
296
|
+
an in-place global upgrade cannot be avoided, re-register all three targets
|
|
297
|
+
straight after it.
|
|
298
|
+
|
|
299
|
+
The supervisor passes the worker to the runtime as one `--eval` argument, and
|
|
300
|
+
Linux limits a single argument to 131,072 bytes (32 pages of 4 KiB). The
|
|
301
|
+
worker is therefore built minified, and the package validation refuses a
|
|
302
|
+
worker at or above that size.
|
|
303
|
+
|
|
304
|
+
Known limits of the native entry:
|
|
305
|
+
|
|
306
|
+
- The signed-link guard judges command text: interpreters and script files
|
|
307
|
+
that run `gh` themselves, shell aliases and functions from earlier commands,
|
|
308
|
+
`gh` extensions, direct HTTP clients such as `curl` and `GH_DEBUG` set in the
|
|
309
|
+
harness environment are outside it (details in the guard's README).
|
|
310
|
+
- Codex's input into a running unified exec session (`write_stdin`) has not
|
|
311
|
+
been verified to pass through PreToolUse.
|
|
312
|
+
- The repository guard still reads a here-document body line by line, so prose
|
|
313
|
+
in a `--body "$(cat <<'EOF' … EOF)"` body that mentions a delete command
|
|
314
|
+
beside an unbalanced quote can still be refused by that guard.
|
|
315
|
+
|
|
191
316
|
## Codex safety readiness
|
|
192
317
|
|
|
193
318
|
```bash
|
|
@@ -206,7 +331,7 @@ architecture (arm64 or x64); script launchers are refused before execution. When
|
|
|
206
331
|
Codex is installed through npm, pass the platform package's native binary rather
|
|
207
332
|
than its JavaScript launcher. Executable ownership, permissions, ancestry and
|
|
208
333
|
bytes are checked. Supported protocol versions are 0.153.0, 0.154.0,
|
|
209
|
-
0.154.0-alpha.6.1, 0.155.0, 0.155.1, 0.156.1, 0.157.0, 0.157.1 and 0.
|
|
334
|
+
0.154.0-alpha.6.1, 0.155.0, 0.155.1, 0.156.1, 0.157.0, 0.157.1, 0.158.0 and 0.159.0;
|
|
210
335
|
other versions refuse pending compatibility validation. Updating Codex can
|
|
211
336
|
therefore require a compatible Hooks release before native safety verification
|
|
212
337
|
and trust provisioning succeed. Keep existing hook definitions and trust intact
|
package/bin/hooks-mcp.js
CHANGED
|
@@ -135,6 +135,9 @@ var init_app_home = __esm(() => {
|
|
|
135
135
|
});
|
|
136
136
|
|
|
137
137
|
// src/lib/registry.ts
|
|
138
|
+
function matcherFor(meta, target) {
|
|
139
|
+
return meta.targetMatchers?.[target] ?? meta.matcher;
|
|
140
|
+
}
|
|
138
141
|
function getHooksByCategory(category) {
|
|
139
142
|
return HOOKS.filter((h) => h.category === category);
|
|
140
143
|
}
|
|
@@ -148,7 +151,7 @@ function searchHooks(query) {
|
|
|
148
151
|
function getHook(name) {
|
|
149
152
|
return HOOKS.find((h) => h.name === name);
|
|
150
153
|
}
|
|
151
|
-
var CATEGORIES, HOOKS;
|
|
154
|
+
var CLAUDE_TRASH_GUARD_MATCHER = "^(Bash|Monitor|apply_patch|ApplyPatch|functions\\.apply_patch)$", CATEGORIES, HOOKS;
|
|
152
155
|
var init_registry = __esm(() => {
|
|
153
156
|
init_app_home();
|
|
154
157
|
CATEGORIES = [
|
|
@@ -213,6 +216,8 @@ var init_registry = __esm(() => {
|
|
|
213
216
|
category: "Git Safety",
|
|
214
217
|
event: "PreToolUse",
|
|
215
218
|
matcher: "^(Bash|Write|Edit|MultiEdit|NotebookEdit|apply_patch|ApplyPatch|functions\\.apply_patch)$",
|
|
219
|
+
targetMatchers: { claude: "^(Bash|Monitor|Write|Edit|MultiEdit|NotebookEdit|apply_patch|ApplyPatch|functions\\.apply_patch)$" },
|
|
220
|
+
legacyMatchers: ["^(Bash|Write|Edit|MultiEdit|NotebookEdit|apply_patch|ApplyPatch|functions\\.apply_patch)$"],
|
|
216
221
|
tags: ["workspace", "repos", "structure", "guard", "safety", "orgs", "multi-agent"]
|
|
217
222
|
},
|
|
218
223
|
{
|
|
@@ -223,6 +228,8 @@ var init_registry = __esm(() => {
|
|
|
223
228
|
category: "Git Safety",
|
|
224
229
|
event: "PreToolUse",
|
|
225
230
|
matcher: "^(Bash|apply_patch|ApplyPatch|functions\\.apply_patch)$",
|
|
231
|
+
targetMatchers: { claude: CLAUDE_TRASH_GUARD_MATCHER },
|
|
232
|
+
legacyMatchers: ["^(Bash|apply_patch|ApplyPatch|functions\\.apply_patch)$"],
|
|
226
233
|
tags: ["rm", "delete", "trash", "recoverable", "guard", "safety", "bash"],
|
|
227
234
|
rewritesInput: true,
|
|
228
235
|
timeoutSeconds: 10
|
|
@@ -327,6 +334,28 @@ var init_registry = __esm(() => {
|
|
|
327
334
|
matcher: "Bash",
|
|
328
335
|
tags: ["security", "credentials", "secrets", "detection", "audit"]
|
|
329
336
|
},
|
|
337
|
+
{
|
|
338
|
+
name: "signed-link-guard",
|
|
339
|
+
displayName: "Signed Link Guard",
|
|
340
|
+
description: "Refuses gh reads that would print composite GitHub content (bodies, comments, reviews, commit messages, check links and details, raw issue/PR/check API objects) where signed action links appear; bounded scalar projections, diffs, lists and writes pass",
|
|
341
|
+
version: "0.1.0",
|
|
342
|
+
category: "Security",
|
|
343
|
+
event: "PreToolUse",
|
|
344
|
+
matcher: "^(Bash|Monitor)$",
|
|
345
|
+
tags: ["github", "gh", "signed-links", "capabilities", "transcript", "guard", "security"],
|
|
346
|
+
timeoutSeconds: 10
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
name: "signed-link-output",
|
|
350
|
+
displayName: "Signed Link Output",
|
|
351
|
+
description: "Claude PostToolUse backstop: replaces signed-link URLs in tool output with a redaction marker (updatedToolOutput, where the running Claude Code supports it) and tells the model not to repeat or store the link. It runs after the tool, so it cannot undo display or other persistence",
|
|
352
|
+
version: "0.1.0",
|
|
353
|
+
category: "Security",
|
|
354
|
+
event: "PostToolUse",
|
|
355
|
+
matcher: "^(Bash|WebFetch|mcp__.*)$",
|
|
356
|
+
tags: ["github", "signed-links", "capabilities", "redaction", "transcript", "security"],
|
|
357
|
+
timeoutSeconds: 10
|
|
358
|
+
},
|
|
330
359
|
{
|
|
331
360
|
name: "phonenotify",
|
|
332
361
|
displayName: "Phone Notify",
|
|
@@ -34989,6 +35018,7 @@ function registerHook(name, scope = "global", target = "claude", profile, mement
|
|
|
34989
35018
|
if (uniqueEventKeys.length === 0) {
|
|
34990
35019
|
throw new Error(`Hook '${name}' has no installable events for target '${target}'`);
|
|
34991
35020
|
}
|
|
35021
|
+
const matcher = matcherFor(meta3, target);
|
|
34992
35022
|
const settings = readSettings2(scope, target);
|
|
34993
35023
|
if (!settings.hooks)
|
|
34994
35024
|
settings.hooks = {};
|
|
@@ -35023,10 +35053,12 @@ function registerHook(name, scope = "global", target = "claude", profile, mement
|
|
|
35023
35053
|
}
|
|
35024
35054
|
}
|
|
35025
35055
|
if (positions.length) {
|
|
35026
|
-
if (positions.length !== uniqueEventKeys.length || uniqueEventKeys.some((event) => positions.filter((position) => position.event === event).length !== 1) || positions.some((position) => (position.entry.matcher ?? "") !== (meta3.matcher ?? ""))) {
|
|
35056
|
+
if (positions.length !== uniqueEventKeys.length || uniqueEventKeys.some((event) => positions.filter((position) => position.event === event).length !== 1) || positions.some((position) => (position.entry.matcher ?? "") !== matcher && !(meta3.legacyMatchers ?? []).includes(position.entry.matcher ?? ""))) {
|
|
35027
35057
|
throw new Error("Codex hook events, matchers or duplicate registrations require explicit reconciliation; no settings were changed.");
|
|
35028
35058
|
}
|
|
35029
35059
|
for (const { entry, index } of positions) {
|
|
35060
|
+
if (matcher)
|
|
35061
|
+
entry.matcher = matcher;
|
|
35030
35062
|
entry.hooks[index] = { ...entry.hooks[index], type: "command", command: hookCommand };
|
|
35031
35063
|
if (typeof meta3.timeoutSeconds === "number" && meta3.timeoutSeconds > 0)
|
|
35032
35064
|
entry.hooks[index].timeout = meta3.timeoutSeconds;
|
|
@@ -35046,8 +35078,8 @@ function registerHook(name, scope = "global", target = "claude", profile, mement
|
|
|
35046
35078
|
const entry = {
|
|
35047
35079
|
hooks: [hookEntry]
|
|
35048
35080
|
};
|
|
35049
|
-
if (
|
|
35050
|
-
entry.matcher =
|
|
35081
|
+
if (matcher) {
|
|
35082
|
+
entry.matcher = matcher;
|
|
35051
35083
|
}
|
|
35052
35084
|
settings.hooks[eventKey].push(entry);
|
|
35053
35085
|
}
|
|
@@ -35983,7 +36015,7 @@ function createHooksServer() {
|
|
|
35983
36015
|
if (meta3 && settingsExist) {
|
|
35984
36016
|
try {
|
|
35985
36017
|
const settings = JSON.parse(readFileSync9(settingsPath, "utf-8"));
|
|
35986
|
-
if (!hookRegisteredInSettings(settings, name, meta3.event, meta3
|
|
36018
|
+
if (!hookRegisteredInSettings(settings, name, meta3.event, matcherFor(meta3, "claude"))) {
|
|
35987
36019
|
const eventName = meta3.event.split(":")[0];
|
|
35988
36020
|
issues.push({ hook: name, issue: `Not registered under correct event (${eventName})`, severity: "error" });
|
|
35989
36021
|
hookHealthy = false;
|
package/bin/index.js
CHANGED
|
@@ -165,6 +165,9 @@ var init_app_home = __esm(() => {
|
|
|
165
165
|
});
|
|
166
166
|
|
|
167
167
|
// src/lib/registry.ts
|
|
168
|
+
function matcherFor(meta, target) {
|
|
169
|
+
return meta.targetMatchers?.[target] ?? meta.matcher;
|
|
170
|
+
}
|
|
168
171
|
function getHooksByCategory(category) {
|
|
169
172
|
return HOOKS.filter((h) => h.category === category);
|
|
170
173
|
}
|
|
@@ -178,7 +181,7 @@ function searchHooks(query) {
|
|
|
178
181
|
function getHook(name) {
|
|
179
182
|
return HOOKS.find((h) => h.name === name);
|
|
180
183
|
}
|
|
181
|
-
var CATEGORIES, HOOKS;
|
|
184
|
+
var CLAUDE_TRASH_GUARD_MATCHER = "^(Bash|Monitor|apply_patch|ApplyPatch|functions\\.apply_patch)$", CATEGORIES, HOOKS;
|
|
182
185
|
var init_registry = __esm(() => {
|
|
183
186
|
init_app_home();
|
|
184
187
|
CATEGORIES = [
|
|
@@ -243,6 +246,8 @@ var init_registry = __esm(() => {
|
|
|
243
246
|
category: "Git Safety",
|
|
244
247
|
event: "PreToolUse",
|
|
245
248
|
matcher: "^(Bash|Write|Edit|MultiEdit|NotebookEdit|apply_patch|ApplyPatch|functions\\.apply_patch)$",
|
|
249
|
+
targetMatchers: { claude: "^(Bash|Monitor|Write|Edit|MultiEdit|NotebookEdit|apply_patch|ApplyPatch|functions\\.apply_patch)$" },
|
|
250
|
+
legacyMatchers: ["^(Bash|Write|Edit|MultiEdit|NotebookEdit|apply_patch|ApplyPatch|functions\\.apply_patch)$"],
|
|
246
251
|
tags: ["workspace", "repos", "structure", "guard", "safety", "orgs", "multi-agent"]
|
|
247
252
|
},
|
|
248
253
|
{
|
|
@@ -253,6 +258,8 @@ var init_registry = __esm(() => {
|
|
|
253
258
|
category: "Git Safety",
|
|
254
259
|
event: "PreToolUse",
|
|
255
260
|
matcher: "^(Bash|apply_patch|ApplyPatch|functions\\.apply_patch)$",
|
|
261
|
+
targetMatchers: { claude: CLAUDE_TRASH_GUARD_MATCHER },
|
|
262
|
+
legacyMatchers: ["^(Bash|apply_patch|ApplyPatch|functions\\.apply_patch)$"],
|
|
256
263
|
tags: ["rm", "delete", "trash", "recoverable", "guard", "safety", "bash"],
|
|
257
264
|
rewritesInput: true,
|
|
258
265
|
timeoutSeconds: 10
|
|
@@ -357,6 +364,28 @@ var init_registry = __esm(() => {
|
|
|
357
364
|
matcher: "Bash",
|
|
358
365
|
tags: ["security", "credentials", "secrets", "detection", "audit"]
|
|
359
366
|
},
|
|
367
|
+
{
|
|
368
|
+
name: "signed-link-guard",
|
|
369
|
+
displayName: "Signed Link Guard",
|
|
370
|
+
description: "Refuses gh reads that would print composite GitHub content (bodies, comments, reviews, commit messages, check links and details, raw issue/PR/check API objects) where signed action links appear; bounded scalar projections, diffs, lists and writes pass",
|
|
371
|
+
version: "0.1.0",
|
|
372
|
+
category: "Security",
|
|
373
|
+
event: "PreToolUse",
|
|
374
|
+
matcher: "^(Bash|Monitor)$",
|
|
375
|
+
tags: ["github", "gh", "signed-links", "capabilities", "transcript", "guard", "security"],
|
|
376
|
+
timeoutSeconds: 10
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
name: "signed-link-output",
|
|
380
|
+
displayName: "Signed Link Output",
|
|
381
|
+
description: "Claude PostToolUse backstop: replaces signed-link URLs in tool output with a redaction marker (updatedToolOutput, where the running Claude Code supports it) and tells the model not to repeat or store the link. It runs after the tool, so it cannot undo display or other persistence",
|
|
382
|
+
version: "0.1.0",
|
|
383
|
+
category: "Security",
|
|
384
|
+
event: "PostToolUse",
|
|
385
|
+
matcher: "^(Bash|WebFetch|mcp__.*)$",
|
|
386
|
+
tags: ["github", "signed-links", "capabilities", "redaction", "transcript", "security"],
|
|
387
|
+
timeoutSeconds: 10
|
|
388
|
+
},
|
|
360
389
|
{
|
|
361
390
|
name: "phonenotify",
|
|
362
391
|
displayName: "Phone Notify",
|
|
@@ -33989,6 +34018,7 @@ function registerHook(name, scope = "global", target = "claude", profile, mement
|
|
|
33989
34018
|
if (uniqueEventKeys.length === 0) {
|
|
33990
34019
|
throw new Error(`Hook '${name}' has no installable events for target '${target}'`);
|
|
33991
34020
|
}
|
|
34021
|
+
const matcher = matcherFor(meta3, target);
|
|
33992
34022
|
const settings = readSettings2(scope, target);
|
|
33993
34023
|
if (!settings.hooks)
|
|
33994
34024
|
settings.hooks = {};
|
|
@@ -34023,10 +34053,12 @@ function registerHook(name, scope = "global", target = "claude", profile, mement
|
|
|
34023
34053
|
}
|
|
34024
34054
|
}
|
|
34025
34055
|
if (positions.length) {
|
|
34026
|
-
if (positions.length !== uniqueEventKeys.length || uniqueEventKeys.some((event) => positions.filter((position) => position.event === event).length !== 1) || positions.some((position) => (position.entry.matcher ?? "") !== (meta3.matcher ?? ""))) {
|
|
34056
|
+
if (positions.length !== uniqueEventKeys.length || uniqueEventKeys.some((event) => positions.filter((position) => position.event === event).length !== 1) || positions.some((position) => (position.entry.matcher ?? "") !== matcher && !(meta3.legacyMatchers ?? []).includes(position.entry.matcher ?? ""))) {
|
|
34027
34057
|
throw new Error("Codex hook events, matchers or duplicate registrations require explicit reconciliation; no settings were changed.");
|
|
34028
34058
|
}
|
|
34029
34059
|
for (const { entry, index } of positions) {
|
|
34060
|
+
if (matcher)
|
|
34061
|
+
entry.matcher = matcher;
|
|
34030
34062
|
entry.hooks[index] = { ...entry.hooks[index], type: "command", command: hookCommand };
|
|
34031
34063
|
if (typeof meta3.timeoutSeconds === "number" && meta3.timeoutSeconds > 0)
|
|
34032
34064
|
entry.hooks[index].timeout = meta3.timeoutSeconds;
|
|
@@ -34046,8 +34078,8 @@ function registerHook(name, scope = "global", target = "claude", profile, mement
|
|
|
34046
34078
|
const entry = {
|
|
34047
34079
|
hooks: [hookEntry]
|
|
34048
34080
|
};
|
|
34049
|
-
if (
|
|
34050
|
-
entry.matcher =
|
|
34081
|
+
if (matcher) {
|
|
34082
|
+
entry.matcher = matcher;
|
|
34051
34083
|
}
|
|
34052
34084
|
settings.hooks[eventKey].push(entry);
|
|
34053
34085
|
}
|
|
@@ -34466,11 +34498,16 @@ function syncDirectory(path) {
|
|
|
34466
34498
|
closeSync6(fd);
|
|
34467
34499
|
}
|
|
34468
34500
|
}
|
|
34501
|
+
function nativeSafetyRecordText(command) {
|
|
34502
|
+
const record3 = { schema: "hasna.hooks.native-safety.v1", harness: "sumi", command };
|
|
34503
|
+
return JSON.stringify(record3) + `
|
|
34504
|
+
`;
|
|
34505
|
+
}
|
|
34469
34506
|
function readNativeSafetyRegistration(home = homedir8()) {
|
|
34470
34507
|
try {
|
|
34471
34508
|
home = canonicalHome(home);
|
|
34472
34509
|
const path = join12(home, ".hasna/hooks/native", filename), text3 = readOwned(path);
|
|
34473
|
-
return { record: parseRecord(text3, home), sha256: hash(text3), path };
|
|
34510
|
+
return { record: parseRecord(text3, home), sha256: hash(text3), path, text: text3 };
|
|
34474
34511
|
} catch (error61) {
|
|
34475
34512
|
if (error61 instanceof NativeSafetyError)
|
|
34476
34513
|
throw error61;
|
|
@@ -34496,9 +34533,7 @@ function nativeSafetyBindingIntegrityReason(home = homedir8()) {
|
|
|
34496
34533
|
}
|
|
34497
34534
|
function registerNativeSafety(options = {}) {
|
|
34498
34535
|
const home = canonicalHome(options.home ?? homedir8());
|
|
34499
|
-
const
|
|
34500
|
-
const text3 = JSON.stringify(record3) + `
|
|
34501
|
-
`;
|
|
34536
|
+
const text3 = nativeSafetyRecordText(options.command ?? installedNativeSafetyCommand("trash-guard"));
|
|
34502
34537
|
parseRecord(text3, home);
|
|
34503
34538
|
for (const dir of [join12(home, ".hasna"), join12(home, ".hasna/hooks"), join12(home, ".hasna/hooks/native")]) {
|
|
34504
34539
|
try {
|
|
@@ -34954,13 +34989,115 @@ async function applyRegistration(options, dependencies = {}) {
|
|
|
34954
34989
|
throw new NativeRegistrationError("apply_outcome_uncertain", operationId);
|
|
34955
34990
|
}
|
|
34956
34991
|
}
|
|
34992
|
+
function inspectSumi(dependencies) {
|
|
34993
|
+
let home, command;
|
|
34994
|
+
try {
|
|
34995
|
+
home = realpathSync7(dependencies.home ?? homedir9());
|
|
34996
|
+
} catch {
|
|
34997
|
+
throw new NativeRegistrationError("settings_unsafe");
|
|
34998
|
+
}
|
|
34999
|
+
try {
|
|
35000
|
+
command = dependencies.command ?? installedNativeSafetyCommand("trash-guard");
|
|
35001
|
+
} catch {
|
|
35002
|
+
throw new NativeRegistrationError("guard_failed");
|
|
35003
|
+
}
|
|
35004
|
+
const binding = nativeSafetyCommandBinding(command);
|
|
35005
|
+
need5(binding?.name === "trash-guard" && binding.home === home, "input_invalid");
|
|
35006
|
+
const directory2 = join13(home, ".hasna", "hooks", "native"), path = join13(directory2, "sumi-trash-guard.json");
|
|
35007
|
+
let existing = directory2;
|
|
35008
|
+
while (!lstatSync9(existing, { throwIfNoEntry: false }))
|
|
35009
|
+
existing = dirname11(existing);
|
|
35010
|
+
safeDirectory2(existing);
|
|
35011
|
+
let before;
|
|
35012
|
+
try {
|
|
35013
|
+
before = readNativeSafetyRegistration(home);
|
|
35014
|
+
} catch (error61) {
|
|
35015
|
+
if (!(error61 instanceof NativeSafetyError) || error61.code !== "binding_missing")
|
|
35016
|
+
throw new NativeRegistrationError(nativeSafetyBindingIntegrityReason(home) ? "settings_unsafe" : "settings_invalid");
|
|
35017
|
+
}
|
|
35018
|
+
const beforeSHA256 = before?.sha256 ?? "absent", desiredSHA256 = sha4(nativeSafetyRecordText(command));
|
|
35019
|
+
const base = {
|
|
35020
|
+
schema: "hasna.hooks.native-readiness-registration/v1",
|
|
35021
|
+
target: "sumi",
|
|
35022
|
+
settingsPath: path,
|
|
35023
|
+
resolvedPath: path,
|
|
35024
|
+
beforeSHA256,
|
|
35025
|
+
desiredSHA256,
|
|
35026
|
+
commandSHA256: sha4(command),
|
|
35027
|
+
action: beforeSHA256 === desiredSHA256 ? "unchanged" : "register",
|
|
35028
|
+
guardVerified: true,
|
|
35029
|
+
nativeAdoptionVerified: false
|
|
35030
|
+
};
|
|
35031
|
+
const plan = { ...base, planDigest: sha4(canonical4(base)) };
|
|
35032
|
+
return { home, command, before, plan };
|
|
35033
|
+
}
|
|
35034
|
+
async function planSumiRegistration(dependencies) {
|
|
35035
|
+
const before = inspectSumi(dependencies);
|
|
35036
|
+
try {
|
|
35037
|
+
await (dependencies.verify ?? verifyNativeSafetyCommand)(before.command, before.home);
|
|
35038
|
+
} catch {
|
|
35039
|
+
throw new NativeRegistrationError("guard_failed");
|
|
35040
|
+
}
|
|
35041
|
+
need5(inspectSumi(dependencies).plan.planDigest === before.plan.planDigest, "settings_changed");
|
|
35042
|
+
return before.plan;
|
|
35043
|
+
}
|
|
35044
|
+
async function applySumiRegistration(expectedPlanDigest, dependencies) {
|
|
35045
|
+
need5(typeof expectedPlanDigest === "string" && /^[a-f0-9]{64}$/.test(expectedPlanDigest), "input_invalid");
|
|
35046
|
+
const plan = await planSumiRegistration(dependencies);
|
|
35047
|
+
need5(plan.planDigest === expectedPlanDigest, "settings_changed");
|
|
35048
|
+
if (plan.action === "unchanged")
|
|
35049
|
+
return { ok: true, changed: false, ...plan };
|
|
35050
|
+
const current2 = inspectSumi(dependencies);
|
|
35051
|
+
need5(current2.plan.planDigest === plan.planDigest, "settings_changed");
|
|
35052
|
+
const operationId = randomUUID5();
|
|
35053
|
+
let directory2 = current2.home;
|
|
35054
|
+
for (const part of [".hasna", "hooks", "native", "trash-readiness-registration", operationId]) {
|
|
35055
|
+
directory2 = join13(directory2, part);
|
|
35056
|
+
try {
|
|
35057
|
+
mkdirSync11(directory2, { mode: 448 });
|
|
35058
|
+
syncDirectory2(dirname11(directory2));
|
|
35059
|
+
} catch (error61) {
|
|
35060
|
+
if (error61.code !== "EEXIST")
|
|
35061
|
+
throw new NativeRegistrationError("settings_unsafe");
|
|
35062
|
+
}
|
|
35063
|
+
safeDirectory2(directory2);
|
|
35064
|
+
}
|
|
35065
|
+
if (current2.before)
|
|
35066
|
+
exclusive(join13(directory2, "settings.before"), current2.before.text);
|
|
35067
|
+
exclusive(join13(directory2, "intent.json"), JSON.stringify({ operationId, plan }) + `
|
|
35068
|
+
`);
|
|
35069
|
+
try {
|
|
35070
|
+
need5(inspectSumi(dependencies).plan.planDigest === plan.planDigest, "settings_changed");
|
|
35071
|
+
const registered = registerNativeSafety({ home: current2.home, command: current2.command, expectedSHA256: plan.beforeSHA256 });
|
|
35072
|
+
const after = readNativeSafetyRegistration(current2.home);
|
|
35073
|
+
need5(registered.changed && registered.sha256 === plan.desiredSHA256 && after.sha256 === plan.desiredSHA256 && after.record.command === current2.command, "apply_outcome_uncertain");
|
|
35074
|
+
await (dependencies.verify ?? verifyNativeSafetyCommand)(after.record.command, current2.home);
|
|
35075
|
+
need5(readNativeSafetyRegistration(current2.home).sha256 === plan.desiredSHA256, "apply_outcome_uncertain");
|
|
35076
|
+
const receipt = {
|
|
35077
|
+
ok: true,
|
|
35078
|
+
changed: true,
|
|
35079
|
+
operationId,
|
|
35080
|
+
planDigest: plan.planDigest,
|
|
35081
|
+
settingsSHA256: after.sha256,
|
|
35082
|
+
guardVerified: true,
|
|
35083
|
+
...current2.before ? { backup: plan.settingsPath + ".before-" + plan.beforeSHA256 } : {},
|
|
35084
|
+
nativeAdoptionVerified: false
|
|
35085
|
+
};
|
|
35086
|
+
exclusive(join13(directory2, "receipt.json"), JSON.stringify(receipt) + `
|
|
35087
|
+
`);
|
|
35088
|
+
return receipt;
|
|
35089
|
+
} catch {
|
|
35090
|
+
throw new NativeRegistrationError("apply_outcome_uncertain", operationId);
|
|
35091
|
+
}
|
|
35092
|
+
}
|
|
34957
35093
|
var NativeRegistrationError, need5 = (value, code2) => {
|
|
34958
35094
|
if (!value)
|
|
34959
35095
|
throw new NativeRegistrationError(code2);
|
|
34960
|
-
}, sha4 = (value) => createHash17("sha256").update(value).digest("hex"), planNativeSafetyRegistration = (options, dependencies = {}) => planRegistration({ target: options.target }, dependencies), applyNativeSafetyRegistration = (options, dependencies = {}) => applyRegistration({ target: options.target, expectedPlanDigest: options.expectedPlanDigest }, dependencies), planNativeReadinessRegistration = (options) => planRegistration({ target: options
|
|
35096
|
+
}, sha4 = (value) => createHash17("sha256").update(value).digest("hex"), planNativeSafetyRegistration = (options, dependencies = {}) => planRegistration({ target: options.target }, dependencies), applyNativeSafetyRegistration = (options, dependencies = {}) => applyRegistration({ target: options.target, expectedPlanDigest: options.expectedPlanDigest }, dependencies), planNativeReadinessRegistration = (options, sumiDependencies = {}) => options?.target === "sumi" ? planSumiRegistration(sumiDependencies) : planRegistration({ target: options?.target, readiness: true }), applyNativeReadinessRegistration = (options, sumiDependencies = {}) => options?.target === "sumi" ? applySumiRegistration(options.expectedPlanDigest, sumiDependencies) : applyRegistration({ target: options?.target, expectedPlanDigest: options?.expectedPlanDigest, readiness: true });
|
|
34961
35097
|
var init_native_safety_registration = __esm(() => {
|
|
34962
35098
|
init_installer();
|
|
34963
35099
|
init_native_safety2();
|
|
35100
|
+
init_native_safety();
|
|
34964
35101
|
init_native_readiness_command();
|
|
34965
35102
|
init_registration();
|
|
34966
35103
|
NativeRegistrationError = class NativeRegistrationError extends Error {
|
|
@@ -35254,7 +35391,7 @@ var init_codex_safety_check = __esm(() => {
|
|
|
35254
35391
|
init_native_readiness_command();
|
|
35255
35392
|
init_native_safety2();
|
|
35256
35393
|
init_native_readiness();
|
|
35257
|
-
versions = new Set(["codex-cli 0.153.0", "codex-cli 0.154.0", "codex-cli 0.154.0-alpha.6.1", "codex-cli 0.155.0", "codex-cli 0.155.1", "codex-cli 0.156.1", "codex-cli 0.157.0", "codex-cli 0.157.1", "codex-cli 0.158.0"]);
|
|
35394
|
+
versions = new Set(["codex-cli 0.153.0", "codex-cli 0.154.0", "codex-cli 0.154.0-alpha.6.1", "codex-cli 0.155.0", "codex-cli 0.155.1", "codex-cli 0.156.1", "codex-cli 0.157.0", "codex-cli 0.157.1", "codex-cli 0.158.0", "codex-cli 0.159.0"]);
|
|
35258
35395
|
CodexSafetyError = class CodexSafetyError extends Error {
|
|
35259
35396
|
code;
|
|
35260
35397
|
constructor(code2) {
|
|
@@ -42558,7 +42695,7 @@ function createHooksServer() {
|
|
|
42558
42695
|
if (meta3 && settingsExist) {
|
|
42559
42696
|
try {
|
|
42560
42697
|
const settings = JSON.parse(readFileSync26(settingsPath, "utf-8"));
|
|
42561
|
-
if (!hookRegisteredInSettings(settings, name, meta3.event, meta3
|
|
42698
|
+
if (!hookRegisteredInSettings(settings, name, meta3.event, matcherFor(meta3, "claude"))) {
|
|
42562
42699
|
const eventName = meta3.event.split(":")[0];
|
|
42563
42700
|
issues.push({ hook: name, issue: `Not registered under correct event (${eventName})`, severity: "error" });
|
|
42564
42701
|
hookHealthy = false;
|
|
@@ -43519,6 +43656,7 @@ import { resolve as resolve23 } from "path";
|
|
|
43519
43656
|
// src/lib/claude-safety-check.ts
|
|
43520
43657
|
init_native_safety();
|
|
43521
43658
|
init_native_safety2();
|
|
43659
|
+
init_registry();
|
|
43522
43660
|
import { constants as constants15, closeSync as closeSync15, fstatSync as fstatSync7, lstatSync as lstatSync10, openSync as openSync15, readFileSync as readFileSync12, realpathSync as realpathSync8 } from "fs";
|
|
43523
43661
|
import { createHash as createHash19 } from "crypto";
|
|
43524
43662
|
import { dirname as dirname17, isAbsolute as isAbsolute17, join as join17, resolve as resolve18 } from "path";
|
|
@@ -43602,7 +43740,7 @@ async function verifyClaudeSafetyConfiguration(options = {}, dependencies = {})
|
|
|
43602
43740
|
need7(!/^(?:hooks run trash-guard|hook-trash-guard)(?:\s|$)/.test(handler.command.trim()), "guard_definition_invalid");
|
|
43603
43741
|
if (binding?.name !== "trash-guard")
|
|
43604
43742
|
continue;
|
|
43605
|
-
need7(binding.home === home && event === "PreToolUse" && handler.type === "command" && group.matcher ===
|
|
43743
|
+
need7(binding.home === home && event === "PreToolUse" && handler.type === "command" && group.matcher === CLAUDE_TRASH_GUARD_MATCHER && handler.timeout === 10 && (handler.async === undefined || handler.async === false) && handler.asyncRewake !== true, "guard_definition_invalid");
|
|
43606
43744
|
guards.push({ command: handler.command, path: file2.path });
|
|
43607
43745
|
}
|
|
43608
43746
|
}
|
|
@@ -47645,7 +47783,7 @@ program3.command("doctor").option("-g, --global", "Check global settings", false
|
|
|
47645
47783
|
if (meta3 && settingsExist) {
|
|
47646
47784
|
try {
|
|
47647
47785
|
const settings = JSON.parse(readFileSync27(settingsPath, "utf-8"));
|
|
47648
|
-
if (!hookRegisteredInSettings(settings, name, meta3.event, meta3
|
|
47786
|
+
if (!hookRegisteredInSettings(settings, name, meta3.event, matcherFor(meta3, "claude"))) {
|
|
47649
47787
|
const eventName = meta3.event.split(":")[0];
|
|
47650
47788
|
issues.push({ hook: name, issue: `Not registered under correct event (${eventName})`, severity: "error" });
|
|
47651
47789
|
hookHealthy = false;
|
|
@@ -48513,13 +48651,13 @@ startup.command("check").requiredOption("--target <target>", "codex, claude or s
|
|
|
48513
48651
|
process.exitCode = 1;
|
|
48514
48652
|
});
|
|
48515
48653
|
for (const action of ["registration-plan", "registration-apply"]) {
|
|
48516
|
-
const command = startup.command(action).requiredOption("--target <target>", "codex or
|
|
48654
|
+
const command = startup.command(action).requiredOption("--target <target>", "codex, claude or sumi (Sumi: its native guard record)");
|
|
48517
48655
|
if (action === "registration-apply")
|
|
48518
48656
|
command.requiredOption("--expected-plan-digest <sha256>", "Exact registration-plan digest");
|
|
48519
48657
|
command.action(async (options) => {
|
|
48520
48658
|
try {
|
|
48521
48659
|
const { planNativeReadinessRegistration: planNativeReadinessRegistration2, applyNativeReadinessRegistration: applyNativeReadinessRegistration2 } = await Promise.resolve().then(() => (init_native_safety_registration(), exports_native_safety_registration));
|
|
48522
|
-
if (options.target !== "codex" && options.target !== "claude")
|
|
48660
|
+
if (options.target !== "codex" && options.target !== "claude" && options.target !== "sumi")
|
|
48523
48661
|
throw new NativeRegistrationError("input_invalid");
|
|
48524
48662
|
console.log(JSON.stringify(action === "registration-plan" ? await planNativeReadinessRegistration2({ target: options.target }) : await applyNativeReadinessRegistration2({ target: options.target, expectedPlanDigest: options.expectedPlanDigest })));
|
|
48525
48663
|
} catch (error61) {
|