@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 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 schema is `hasna.hooks.trash-readiness/v1`. It contains the exact
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). Use 45000 for a normal
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.158.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 (meta3.matcher) {
35050
- entry.matcher = meta3.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.matcher)) {
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 (meta3.matcher) {
34050
- entry.matcher = meta3.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 record3 = { schema: "hasna.hooks.native-safety.v1", harness: "sumi", command: options.command ?? installedNativeSafetyCommand("trash-guard") };
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.target, readiness: true }), applyNativeReadinessRegistration = (options) => applyRegistration({ target: options.target, expectedPlanDigest: options.expectedPlanDigest, readiness: true });
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.matcher)) {
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 === "^(Bash|apply_patch|ApplyPatch|functions\\.apply_patch)$" && handler.timeout === 10 && (handler.async === undefined || handler.async === false) && handler.asyncRewake !== true, "guard_definition_invalid");
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.matcher)) {
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 claude");
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) {