@hasna/hooks 0.10.5 → 0.10.7
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 +73 -0
- package/bin/hooks-mcp.js +48 -25
- package/bin/index.js +7722 -6718
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1030 -49
- package/dist/lib/claude-safety-check.d.ts +32 -0
- package/dist/lib/codex-safety-check.d.ts +58 -0
- package/dist/lib/codex-safety-trust.d.ts +54 -0
- package/dist/lib/codex-settings.d.ts +5 -1
- package/dist/lib/installer.d.ts +8 -0
- package/dist/lib/native-safety-registration.d.ts +52 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -92,6 +92,79 @@ Repair unsafe installation permissions with a verified package installer, then
|
|
|
92
92
|
check again. This check alone does not prove hosted authentication, native hook
|
|
93
93
|
trust or a running agent's interception.
|
|
94
94
|
|
|
95
|
+
## Planned safety registration
|
|
96
|
+
|
|
97
|
+
For repeatable global setup, use the owning installer through its plan/apply
|
|
98
|
+
commands. Planning leaves native settings unchanged and returns a compact digest.
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
hooks safety registration-plan --target codex
|
|
102
|
+
hooks safety registration-apply --target codex --expected-plan-digest PLAN_SHA256
|
|
103
|
+
# The same two commands support --target claude.
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Apply checks the exact predecessor, retains an owner-only original and intent,
|
|
107
|
+
and reads back the result. An identical registration returns unchanged without
|
|
108
|
+
rewriting its settings. The planner honors `CODEX_HOME` and `CLAUDE_CONFIG_DIR`;
|
|
109
|
+
conflicting Hooks path overrides refuse. Missing native settings directories are
|
|
110
|
+
created only on apply. Disabled or asynchronous guards, unsafe paths, duplicate
|
|
111
|
+
JSON keys and numbers that cannot be preserved exactly refuse before a write.
|
|
112
|
+
Managed default Claude settings continue through Skills' coordinated writer.
|
|
113
|
+
|
|
114
|
+
The SDK exports `planNativeSafetyRegistration` and
|
|
115
|
+
`applyNativeSafetyRegistration`. Both return `nativeAdoptionVerified: false`.
|
|
116
|
+
A changed or uncertain apply requires a fresh read-only plan and inspection of
|
|
117
|
+
its recorded operation; no automatic retry or direct overwrite is performed.
|
|
118
|
+
|
|
119
|
+
After registration, Codex trust uses its supported native configuration writer:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
hooks safety trust-plan --target codex --cwd /path/to/project --codex-command /absolute/path/to/native/codex
|
|
123
|
+
hooks safety trust-apply --target codex --cwd /path/to/project --codex-command /absolute/path/to/native/codex --expected-plan-digest PLAN_SHA256
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The exact definition is verified before trusting and enabling only that guard.
|
|
127
|
+
Unrelated configuration is preserved, and an already trusted, enabled guard
|
|
128
|
+
does not cause a write. A disabled native hooks feature is a refusal, not an
|
|
129
|
+
implicit feature change. Neither registration nor trust proves that an existing
|
|
130
|
+
agent has adopted the change.
|
|
131
|
+
|
|
132
|
+
For Claude, `hooks safety verify --target claude --cwd /path/to/project` checks
|
|
133
|
+
the selected settings and bundled supervisor. Its result explicitly leaves
|
|
134
|
+
runtime-policy verification, native discovery and native adoption false;
|
|
135
|
+
MDM/server policy, workspace trust and launch flags require native acceptance.
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
## Codex safety readiness
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
hooks safety verify --target codex --cwd /path/to/project --codex-command /absolute/path/to/native/codex
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
This bounded, read-only check starts its own fresh Codex app-server and asks the
|
|
145
|
+
native consumer for the selected project's hook definitions and feature state.
|
|
146
|
+
It requires exactly one enabled, trusted, synchronous bundled Trash guard, checks
|
|
147
|
+
its installed supervisor by classifying data, and verifies that the native
|
|
148
|
+
definition stayed unchanged. It returns compact JSON and a nonzero exit on
|
|
149
|
+
missing, disabled, untrusted, changed or unsafe state. No deletion is executed.
|
|
150
|
+
|
|
151
|
+
The command accepts a native Linux ELF or macOS Mach-O executable for the host
|
|
152
|
+
architecture (arm64 or x64); script launchers are refused before execution. When
|
|
153
|
+
Codex is installed through npm, pass the platform package's native binary rather
|
|
154
|
+
than its JavaScript launcher. Executable ownership, permissions, ancestry and
|
|
155
|
+
bytes are checked. Supported protocol versions are 0.153.0, 0.154.0, 0.155.0,
|
|
156
|
+
0.155.1 and 0.156.1; other versions refuse pending compatibility validation.
|
|
157
|
+
The version probe has a five-second forced timeout and native discovery a
|
|
158
|
+
fifteen-second lifetime budget. Neither step restarts an existing agent.
|
|
159
|
+
|
|
160
|
+
Success reports `nativeDiscoveryVerified: true`, `guardVerified: true` and
|
|
161
|
+
`nativeAdoptionVerified: false`. It does not enroll trust, change configuration,
|
|
162
|
+
authenticate Trash, verify MCP or Instructions, or prove interception by an
|
|
163
|
+
already-running session. Complete those separate setup checks and a fresh native
|
|
164
|
+
interception proof before declaring station support. Use this small readiness
|
|
165
|
+
check after installation, meaningful upgrades and during fresh-agent startup;
|
|
166
|
+
never substitute repeated deletion smoke tests for a startup check.
|
|
167
|
+
|
|
95
168
|
## Optional Mementos prompt context
|
|
96
169
|
|
|
97
170
|
`hooks install mementos-context --target codex` registers a native prompt hook.
|
package/bin/hooks-mcp.js
CHANGED
|
@@ -3104,7 +3104,7 @@ function checkScriptHash(name, actual) {
|
|
|
3104
3104
|
// src/lib/codex-settings.ts
|
|
3105
3105
|
import { closeSync as closeSync2, existsSync as existsSync8, lstatSync as lstatSync2, mkdirSync as mkdirSync4, openSync as openSync2, readFileSync as readFileSync5, renameSync as renameSync2, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
|
|
3106
3106
|
import { dirname as dirname4 } from "path";
|
|
3107
|
-
import { randomUUID } from "crypto";
|
|
3107
|
+
import { createHash as createHash3, randomUUID } from "crypto";
|
|
3108
3108
|
var snapshots = new WeakMap;
|
|
3109
3109
|
function current(path) {
|
|
3110
3110
|
try {
|
|
@@ -3139,11 +3139,24 @@ function readCodexSettings(path) {
|
|
|
3139
3139
|
snapshots.set(settings, { path, raw });
|
|
3140
3140
|
return settings;
|
|
3141
3141
|
}
|
|
3142
|
-
function
|
|
3142
|
+
function assertSettingsPreimage(raw, expected) {
|
|
3143
|
+
if (expected === undefined)
|
|
3144
|
+
return;
|
|
3145
|
+
if (!/^(?:absent|[a-f0-9]{64})$/.test(expected) || (raw === null ? "absent" : createHash3("sha256").update(raw).digest("hex")) !== expected) {
|
|
3146
|
+
throw new Error("Hook settings changed since planning; no settings were written.");
|
|
3147
|
+
}
|
|
3148
|
+
}
|
|
3149
|
+
function writeCodexSettings(path, settings, expectedSha256) {
|
|
3143
3150
|
const snapshot = snapshots.get(settings);
|
|
3144
3151
|
if (!snapshot || snapshot.path !== path)
|
|
3145
3152
|
throw new Error("Codex settings require a complete current read before writing.");
|
|
3146
|
-
|
|
3153
|
+
assertSettingsPreimage(snapshot.raw, expectedSha256);
|
|
3154
|
+
writeHookSettingsPreimage(path, settings, snapshot.raw);
|
|
3155
|
+
snapshots.set(settings, { path, raw: JSON.stringify(settings, null, 2) + `
|
|
3156
|
+
` });
|
|
3157
|
+
}
|
|
3158
|
+
function writeHookSettingsPreimage(path, settings, before) {
|
|
3159
|
+
mkdirSync4(dirname4(path), { recursive: true, mode: 448 });
|
|
3147
3160
|
const lock = `${path}.hooks-write.lock`;
|
|
3148
3161
|
let fd;
|
|
3149
3162
|
try {
|
|
@@ -3153,15 +3166,14 @@ function writeCodexSettings(path, settings) {
|
|
|
3153
3166
|
}
|
|
3154
3167
|
const temp = `${path}.${randomUUID()}.tmp`;
|
|
3155
3168
|
try {
|
|
3156
|
-
if (current(path) !==
|
|
3157
|
-
throw new Error("
|
|
3169
|
+
if (current(path) !== before)
|
|
3170
|
+
throw new Error("Hook settings changed since reading; re-read before retrying.");
|
|
3158
3171
|
const next = JSON.stringify(settings, null, 2) + `
|
|
3159
3172
|
`;
|
|
3160
3173
|
writeFileSync3(temp, next, { flag: "wx", mode: 384 });
|
|
3161
|
-
if (current(path) !==
|
|
3162
|
-
throw new Error("
|
|
3174
|
+
if (current(path) !== before)
|
|
3175
|
+
throw new Error("Hook settings changed since reading; re-read before retrying.");
|
|
3163
3176
|
renameSync2(temp, path);
|
|
3164
|
-
snapshots.set(settings, { path, raw: next });
|
|
3165
3177
|
} finally {
|
|
3166
3178
|
if (existsSync8(temp))
|
|
3167
3179
|
unlinkSync(temp);
|
|
@@ -3179,7 +3191,7 @@ import { homedir as homedir4 } from "os";
|
|
|
3179
3191
|
import { join as join8, resolve as resolve3 } from "path";
|
|
3180
3192
|
import { homedir as pathsResolverHomedir2 } from "os";
|
|
3181
3193
|
import { join as pathsResolverJoin2 } from "path";
|
|
3182
|
-
import { createHash as
|
|
3194
|
+
import { createHash as createHash4 } from "crypto";
|
|
3183
3195
|
import { existsSync as existsSync52, readdirSync as readdirSync3, readFileSync as readFileSync42, statSync as statSync3 } from "fs";
|
|
3184
3196
|
import { join as join52, sep as sep2 } from "path";
|
|
3185
3197
|
import { basename as basename2, isAbsolute as isAbsolute22, resolve as resolve22 } from "path";
|
|
@@ -3194,7 +3206,7 @@ import { dirname as dirname42, isAbsolute as isAbsolute4, join as join82, resolv
|
|
|
3194
3206
|
import { createHash as createHash32 } from "crypto";
|
|
3195
3207
|
import { closeSync as closeSync3, constants as constants2, fstatSync as fstatSync2, lstatSync as lstatSync5, openSync as openSync3, readSync } from "fs";
|
|
3196
3208
|
import { dirname as dirname5, isAbsolute as isAbsolute5, resolve as resolve5 } from "path";
|
|
3197
|
-
import { createHash as
|
|
3209
|
+
import { createHash as createHash42 } from "crypto";
|
|
3198
3210
|
import { lstatSync as lstatSync6, readlinkSync } from "fs";
|
|
3199
3211
|
import { dirname as dirname6, isAbsolute as isAbsolute6, join as join9, parse, resolve as resolve6, sep as sep22 } from "path";
|
|
3200
3212
|
import { closeSync as closeSync12, constants as constants9, fstatSync as fstatSync12, lstatSync as lstatSync17, openSync as openSync12, opendirSync as opendirSync5, readSync as readSync8 } from "fs";
|
|
@@ -10322,7 +10334,7 @@ function* bundleHashParts(files) {
|
|
|
10322
10334
|
yield new TextEncoder().encode("\x00");
|
|
10323
10335
|
}
|
|
10324
10336
|
function hashBundleFiles(files) {
|
|
10325
|
-
const hash =
|
|
10337
|
+
const hash = createHash4(CONTENT_HASH_ALGORITHM);
|
|
10326
10338
|
for (const part of bundleHashParts(files))
|
|
10327
10339
|
hash.update(part);
|
|
10328
10340
|
return hash.digest("hex");
|
|
@@ -11158,7 +11170,7 @@ function hashDiscoveryPathFile(path, budget, changes) {
|
|
|
11158
11170
|
const after = trace(path);
|
|
11159
11171
|
if (JSON.stringify(before.rows) !== JSON.stringify(after.rows) || before.file !== after.file)
|
|
11160
11172
|
throw new Error("Discovery path input changed during read");
|
|
11161
|
-
return
|
|
11173
|
+
return createHash42("sha256").update(JSON.stringify({ version: 1, path, rows: before.rows, content })).digest("hex");
|
|
11162
11174
|
}
|
|
11163
11175
|
var package_default = {
|
|
11164
11176
|
name: "@hasna/skills",
|
|
@@ -34389,9 +34401,9 @@ function getTargetSettingsDir(target) {
|
|
|
34389
34401
|
}
|
|
34390
34402
|
function getGlobalSettingsPathOverride(target) {
|
|
34391
34403
|
if (target === "codex")
|
|
34392
|
-
return process.env.HASNA_HOOKS_CODEX_SETTINGS_PATH;
|
|
34404
|
+
return process.env.HASNA_HOOKS_CODEX_SETTINGS_PATH ?? (process.env.CODEX_HOME ? resolve7(process.env.CODEX_HOME, "hooks.json") : undefined);
|
|
34393
34405
|
if (target === "claude")
|
|
34394
|
-
return process.env.HASNA_HOOKS_CLAUDE_SETTINGS_PATH;
|
|
34406
|
+
return process.env.HASNA_HOOKS_CLAUDE_SETTINGS_PATH ?? (process.env.CLAUDE_CONFIG_DIR ? resolve7(process.env.CLAUDE_CONFIG_DIR, "settings.json") : undefined);
|
|
34395
34407
|
if (target === "gemini")
|
|
34396
34408
|
return process.env.HASNA_HOOKS_GEMINI_SETTINGS_PATH;
|
|
34397
34409
|
return;
|
|
@@ -34435,7 +34447,7 @@ function readSettings2(scope = "global", target = "claude") {
|
|
|
34435
34447
|
return value;
|
|
34436
34448
|
}
|
|
34437
34449
|
} catch (error61) {
|
|
34438
|
-
console.warn(
|
|
34450
|
+
console.warn("[hooks] Cannot read hook settings; repair the selected file before installation.");
|
|
34439
34451
|
}
|
|
34440
34452
|
const empty = {};
|
|
34441
34453
|
if (!existsSync10(path))
|
|
@@ -34447,10 +34459,16 @@ function settingsIdentity(path) {
|
|
|
34447
34459
|
return realpathSync5(path);
|
|
34448
34460
|
return existsSync10(dirname7(path)) ? join10(realpathSync5(dirname7(path)), basename3(path)) : resolve7(path);
|
|
34449
34461
|
}
|
|
34450
|
-
function writeSettings(settings, scope = "global", target = "claude") {
|
|
34462
|
+
function writeSettings(settings, scope = "global", target = "claude", expectedSettingsSha256) {
|
|
34451
34463
|
const path = getSettingsPath(scope, target);
|
|
34452
34464
|
if (target === "codex")
|
|
34453
|
-
return writeCodexSettings(path, settings);
|
|
34465
|
+
return writeCodexSettings(path, settings, expectedSettingsSha256);
|
|
34466
|
+
const captured = settingsPreimages.get(settings);
|
|
34467
|
+
if (expectedSettingsSha256 !== undefined) {
|
|
34468
|
+
if (!captured || captured.path !== path)
|
|
34469
|
+
throw new Error("Hook settings require a valid captured preimage.");
|
|
34470
|
+
assertSettingsPreimage(captured.text, expectedSettingsSha256);
|
|
34471
|
+
}
|
|
34454
34472
|
if (target === "claude" && settingsIdentity(path) === settingsIdentity(join10(homedir5(), ".claude/settings.json"))) {
|
|
34455
34473
|
const before = settingsPreimages.get(settings);
|
|
34456
34474
|
if (!before || before.path !== path)
|
|
@@ -34462,6 +34480,8 @@ function writeSettings(settings, scope = "global", target = "claude") {
|
|
|
34462
34480
|
`, { home: homedir5() }))
|
|
34463
34481
|
return;
|
|
34464
34482
|
}
|
|
34483
|
+
if (expectedSettingsSha256 !== undefined)
|
|
34484
|
+
return writeHookSettingsPreimage(path, settings, captured.text);
|
|
34465
34485
|
const dir = dirname7(path);
|
|
34466
34486
|
if (!existsSync10(dir)) {
|
|
34467
34487
|
mkdirSync6(dir, { recursive: true });
|
|
@@ -34619,7 +34639,7 @@ function detectRewriteConflict(name, scope, target, lookup = getHook) {
|
|
|
34619
34639
|
}
|
|
34620
34640
|
return;
|
|
34621
34641
|
}
|
|
34622
|
-
function installForTarget(name, scope, overwrite, target, profile, codewithMode = "fragment", codewithConfigPath, mementos) {
|
|
34642
|
+
function installForTarget(name, scope, overwrite, target, profile, codewithMode = "fragment", codewithConfigPath, mementos, expectedSettingsSha256) {
|
|
34623
34643
|
const shortName = shortHookName(name);
|
|
34624
34644
|
if (isNativeSafetyName(shortName) && (profile || target === "gemini" || target === "codewith" && codewithMode === "write")) {
|
|
34625
34645
|
return { hook: shortName, success: false, error: "Bundled native safety guards support Claude/Codex and managed Codewith fragments, without registry profiles.", target };
|
|
@@ -34686,7 +34706,7 @@ function installForTarget(name, scope, overwrite, target, profile, codewithMode
|
|
|
34686
34706
|
if (rewriteConflict) {
|
|
34687
34707
|
return { hook: shortName, success: false, error: `Refused: ${rewriteConflict}`, scope, target };
|
|
34688
34708
|
}
|
|
34689
|
-
registerHook(shortName, scope, target, profile, mementos);
|
|
34709
|
+
registerHook(shortName, scope, target, profile, mementos, (settings, selectedScope, selectedTarget) => writeSettings(settings, selectedScope, selectedTarget, expectedSettingsSha256));
|
|
34690
34710
|
return {
|
|
34691
34711
|
hook: shortName,
|
|
34692
34712
|
success: true,
|
|
@@ -34705,7 +34725,10 @@ function installForTarget(name, scope, overwrite, target, profile, codewithMode
|
|
|
34705
34725
|
}
|
|
34706
34726
|
}
|
|
34707
34727
|
function installHook(name, options = {}) {
|
|
34708
|
-
const { scope = "global", overwrite = false, target = "claude", profile, codewithMode = "fragment", codewithConfigPath, mementos } = options;
|
|
34728
|
+
const { scope = "global", overwrite = false, target = "claude", profile, codewithMode = "fragment", codewithConfigPath, mementos, expectedSettingsSha256 } = options;
|
|
34729
|
+
if (expectedSettingsSha256 !== undefined && (!/^(?:absent|[a-f0-9]{64})$/.test(expectedSettingsSha256) || !isNativeSafetyName(shortHookName(name)) || target !== "codex" && target !== "claude")) {
|
|
34730
|
+
return { hook: name, success: false, target, error: "An exact settings predecessor requires a Claude or Codex native safety guard." };
|
|
34731
|
+
}
|
|
34709
34732
|
try {
|
|
34710
34733
|
validateMementosRegistration(name, mementos);
|
|
34711
34734
|
} catch (error61) {
|
|
@@ -34746,9 +34769,9 @@ function installHook(name, options = {}) {
|
|
|
34746
34769
|
}
|
|
34747
34770
|
return { ...claudeResult, target: "all" };
|
|
34748
34771
|
}
|
|
34749
|
-
return installForTarget(name, scope, overwrite, target, profile, codewithMode, codewithConfigPath, mementos);
|
|
34772
|
+
return installForTarget(name, scope, overwrite, target, profile, codewithMode, codewithConfigPath, mementos, expectedSettingsSha256);
|
|
34750
34773
|
}
|
|
34751
|
-
function registerHook(name, scope = "global", target = "claude", profile, mementos) {
|
|
34774
|
+
function registerHook(name, scope = "global", target = "claude", profile, mementos, write = writeSettings) {
|
|
34752
34775
|
const meta3 = isNativeSafetyName(name) ? getHook(name) : resolveHookMeta(name);
|
|
34753
34776
|
if (!meta3)
|
|
34754
34777
|
return;
|
|
@@ -34777,7 +34800,7 @@ function registerHook(name, scope = "global", target = "claude", profile, mement
|
|
|
34777
34800
|
}
|
|
34778
34801
|
const retained = name === "mementos-context" && mementos === undefined ? retainedMementosRegistration(Object.values(settings.hooks).flatMap((groups) => Array.isArray(groups) ? groups.flatMap((group) => Array.isArray(group.hooks) ? group.hooks.map((hook) => hook.command).filter((command) => typeof command === "string") : []) : [])) : undefined;
|
|
34779
34802
|
const hookCommand = isNativeSafetyName(name) ? installedNativeSafetyCommand(name) : (profile ? `hooks run ${name} --profile ${profile}` : `hooks run ${name}`) + mementosCommandSuffix(mementos ?? retained);
|
|
34780
|
-
if (target === "codex") {
|
|
34803
|
+
if (target === "codex" || target === "claude" && isNativeSafetyName(name)) {
|
|
34781
34804
|
const positions = [];
|
|
34782
34805
|
for (const [event, groups] of Object.entries(settings.hooks)) {
|
|
34783
34806
|
for (const entry of groups) {
|
|
@@ -34803,7 +34826,7 @@ function registerHook(name, scope = "global", target = "claude", profile, mement
|
|
|
34803
34826
|
if (typeof meta3.timeoutSeconds === "number" && meta3.timeoutSeconds > 0)
|
|
34804
34827
|
entry.hooks[index].timeout = meta3.timeoutSeconds;
|
|
34805
34828
|
}
|
|
34806
|
-
|
|
34829
|
+
write(settings, scope, target);
|
|
34807
34830
|
return;
|
|
34808
34831
|
}
|
|
34809
34832
|
}
|
|
@@ -34823,7 +34846,7 @@ function registerHook(name, scope = "global", target = "claude", profile, mement
|
|
|
34823
34846
|
}
|
|
34824
34847
|
settings.hooks[eventKey].push(entry);
|
|
34825
34848
|
}
|
|
34826
|
-
|
|
34849
|
+
write(settings, scope, target);
|
|
34827
34850
|
}
|
|
34828
34851
|
function removeHookFromAllEvents(settings, name) {
|
|
34829
34852
|
if (!settings.hooks)
|