@hasna/hooks 0.12.3 → 0.12.4

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.
@@ -1,8 +1,30 @@
1
+ /** A definite refusal raised before any Claude settings byte is written.
2
+ * `settings_unreadable` (raised by the installer's writer, also for Gemini
3
+ * settings): the file exists but was not read and parsed as a JSON object. */
4
+ export declare class ClaudeSettingsRefusal extends Error {
5
+ readonly code: "skills_policy_unresolved" | "settings_unreadable";
6
+ constructor(code: "skills_policy_unresolved" | "settings_unreadable", message: string);
7
+ }
8
+ /** Skills is in use for the Claude settings under `home` when at least one holds:
9
+ * 1. the current settings text has a hook handler (`hooks.<event>[].hooks[].command`,
10
+ * or a flat `hooks.<event>[].command`) matching SKILLS_HOOK_COMMAND; or
11
+ * 2. the Skills Claude bridge `<home>/.claude/skills/skills-cli/SKILL.md` exists,
12
+ * as any file type. A lookup error other than ENOENT/ENOTDIR counts as present.
13
+ * Returns the evidence found; an empty list means Skills is genuinely absent. */
14
+ export declare function claudeSkillsUsage(before: string | null, home: string): string[];
15
+ /** Every location the bundled Skills package (@hasna/skills 0.10.28, `getDataRoot`)
16
+ * considers for `agent-policy.json`, in its resolution order, with the outcome
17
+ * at each. Only called after Skills found no policy at the selected location. */
18
+ export declare function skillsPolicyLocations(dataDir?: string): string[];
1
19
  /** The owning Skills writer commits Claude settings and discovery together.
2
- * A missing policy permits ordinary standalone installation. Any other refusal
3
- * propagates before settings are written; it never selects a fallback writer.
20
+ * A missing policy permits ordinary standalone installation only while Skills is
21
+ * genuinely absent (`claudeSkillsUsage`). When Skills is in use but its policy
22
+ * cannot be resolved, it refuses with `skills_policy_unresolved` and writes
23
+ * nothing. Any other refusal propagates before settings are written; it never
24
+ * selects a fallback writer. `beforeWrite` runs once, immediately before the
25
+ * coordinated write starts.
4
26
  */
5
27
  export declare function coordinateClaudeSettings(before: string | null, replacement: string, options: {
6
28
  home: string;
7
29
  dataDir?: string;
8
- }): boolean;
30
+ }, beforeWrite?: () => void): boolean;
@@ -1,9 +1,16 @@
1
1
  import { connectCodexSafety, type CodexSafetyOptions } from "./codex-safety-check.js";
2
2
  import { verifyNativeSafetyCommand } from "../native-safety.js";
3
3
  export declare class CodexTrustError extends Error {
4
- readonly code: "input_invalid" | "native_changed" | "native_config_invalid" | "preimage_unsafe" | "trust_outcome_uncertain";
4
+ readonly code: "input_invalid" | "native_changed" | "native_config_invalid" | "preimage_unsafe" | "trust_refused" | "trust_outcome_uncertain";
5
5
  readonly operationId?: string | undefined;
6
- constructor(code: "input_invalid" | "native_changed" | "native_config_invalid" | "preimage_unsafe" | "trust_outcome_uncertain", operationId?: string | undefined);
6
+ readonly refusal?: {
7
+ code: string;
8
+ message: string;
9
+ } | undefined;
10
+ constructor(code: "input_invalid" | "native_changed" | "native_config_invalid" | "preimage_unsafe" | "trust_refused" | "trust_outcome_uncertain", operationId?: string | undefined, refusal?: {
11
+ code: string;
12
+ message: string;
13
+ } | undefined);
7
14
  }
8
15
  interface Dependencies {
9
16
  connect?: typeof connectCodexSafety;
@@ -2,6 +2,9 @@ export declare function readCodexSettings(path: string): Record<string, any>;
2
2
  /** Preserve the exact read version. Never write trust state or config.toml. */
3
3
  export declare function capturedCodexSettings(settings: Record<string, any>): string | null;
4
4
  export declare function assertSettingsPreimage(raw: string | null, expected?: string): void;
5
- export declare function writeCodexSettings(path: string, settings: Record<string, any>, expectedSha256?: string): void;
6
- /** Shared standalone writer. Managed Claude settings still use Skills. */
7
- export declare function writeHookSettingsPreimage(path: string, settings: Record<string, any>, before: string | null): void;
5
+ export declare function writeCodexSettings(path: string, settings: Record<string, any>, expectedSha256?: string, beforeWrite?: () => void): void;
6
+ /** Shared standalone writer. Managed Claude settings still use Skills.
7
+ * `beforeWrite` runs once, after every predecessor check and immediately before
8
+ * the replacement is renamed over `path`; a failure before it is a definite
9
+ * refusal that left `path` unchanged. */
10
+ export declare function writeHookSettingsPreimage(path: string, settings: Record<string, any>, before: string | null, beforeWrite?: () => void): void;
@@ -28,6 +28,8 @@ export interface InstallResult {
28
28
  applied?: boolean;
29
29
  note?: string;
30
30
  configPath?: string;
31
+ /** Stable refusal code, when the failure carries one (e.g. skills_policy_unresolved). */
32
+ code?: string;
31
33
  }
32
34
  export interface InstallOptions {
33
35
  /** Optional exact predecessor for a planned native safety registration. */
@@ -46,6 +48,12 @@ export interface InstallOptions {
46
48
  codewithMode?: CodewithInstallMode;
47
49
  /** Explicit Codewith config path for the direct-write mode and tests. */
48
50
  codewithConfigPath?: string;
51
+ /**
52
+ * Single JSON-settings target only: runs once, immediately before the
53
+ * selected writer may change the settings file. A failure reported before it
54
+ * runs is a definite refusal that left the settings unchanged.
55
+ */
56
+ beforeWrite?: () => void;
49
57
  }
50
58
  export declare function getSettingsPath(scope?: Scope, target?: SingleTarget, codewithConfigPath?: string): string;
51
59
  export declare function getHookPath(name: string): string;
@@ -80,7 +88,7 @@ export declare function previewNativeReadinessRegistration(target: "codex" | "cl
80
88
  after: string;
81
89
  command: string;
82
90
  };
83
- export declare function installNativeReadinessRegistration(target: "codex" | "claude", expectedSettingsSha256: string): {
91
+ export declare function installNativeReadinessRegistration(target: "codex" | "claude", expectedSettingsSha256: string, beforeWrite?: () => void): {
84
92
  success: boolean;
85
93
  };
86
94
  export declare function installHooks(names: string[], options?: InstallOptions): InstallResult[];
@@ -1,8 +1,14 @@
1
1
  import { verifyNativeSafetyCommand } from "../native-safety.js";
2
+ /** The definite cause of an apply that stopped before its writer could change the target. */
3
+ export interface RegistrationRefusal {
4
+ code: string;
5
+ message: string;
6
+ }
2
7
  export declare class NativeRegistrationError extends Error {
3
- readonly code: "input_invalid" | "settings_unsafe" | "settings_invalid" | "settings_changed" | "hooks_disabled" | "registration_conflict" | "guard_failed" | "apply_outcome_uncertain";
8
+ readonly code: "input_invalid" | "settings_unsafe" | "settings_invalid" | "settings_changed" | "hooks_disabled" | "registration_conflict" | "guard_failed" | "apply_refused" | "apply_outcome_uncertain";
4
9
  readonly operationId?: string | undefined;
5
- constructor(code: "input_invalid" | "settings_unsafe" | "settings_invalid" | "settings_changed" | "hooks_disabled" | "registration_conflict" | "guard_failed" | "apply_outcome_uncertain", operationId?: string | undefined);
10
+ readonly refusal?: RegistrationRefusal | undefined;
11
+ constructor(code: "input_invalid" | "settings_unsafe" | "settings_invalid" | "settings_changed" | "hooks_disabled" | "registration_conflict" | "guard_failed" | "apply_refused" | "apply_outcome_uncertain", operationId?: string | undefined, refusal?: RegistrationRefusal | undefined);
6
12
  }
7
13
  export interface NativeRegistrationOptions {
8
14
  target: "codex" | "claude";
@@ -55,11 +55,14 @@ export declare function readNativeSafetyRegistration(home?: string): {
55
55
  export declare function nativeSafetyBindingIntegrityReason(home?: string): string | undefined;
56
56
  /** Register only the bundled composite guard. Updates preserve the exact prior
57
57
  * record and require its digest. A lock coordinates package-owned writers; an
58
- * unexpected preimage or a retained interrupted lock requires reconciliation. */
58
+ * unexpected preimage or a retained interrupted lock requires reconciliation.
59
+ * `beforeWrite` runs once, after the last predecessor check and immediately
60
+ * before the record is replaced; a failure before it left the record unchanged. */
59
61
  export declare function registerNativeSafety(options?: {
60
62
  home?: string;
61
63
  expectedSHA256?: string;
62
64
  command?: string;
65
+ beforeWrite?: () => void;
63
66
  }): {
64
67
  ok: true;
65
68
  changed: boolean;
@@ -301,8 +301,10 @@ function registerNativeSafety(options = {}) {
301
301
  if (before) {
302
302
  if (hash(readOwned(path)) !== before.sha256)
303
303
  throw new NativeSafetyError("binding_changed");
304
+ options.beforeWrite?.();
304
305
  renameSync(temporary, path);
305
306
  } else {
307
+ options.beforeWrite?.();
306
308
  linkSync(temporary, path);
307
309
  unlinkSync(temporary);
308
310
  }
@@ -1,6 +1,6 @@
1
- import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync, writeSync } from "fs";
2
- import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from "path";
3
- import { homedir, tmpdir } from "os";
1
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync, writeSync } from "node:fs";
2
+ import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
3
+ import { homedir, tmpdir } from "node:os";
4
4
 
5
5
  export interface CodewithHookInput {
6
6
  session_id?: string;
@@ -66,7 +66,10 @@ export async function run(): Promise<void> {
66
66
  if (!("continue" in verdict && verdict.continue === true)) respond(verdict);
67
67
  }
68
68
 
69
+ // Standalone run only. No top-level await: the CommonJS native safety worker
70
+ // (scripts/build-native-worker.ts) bundles this module, and its parser refuses
71
+ // top-level await even where import.meta.main=false makes the block dead.
72
+ // A rejected run still exits non-zero, as an awaited one did.
69
73
  if (import.meta.main) {
70
- await run();
71
- process.exit(0);
74
+ void run().then(() => process.exit(0));
72
75
  }
@@ -46,8 +46,8 @@
46
46
  * command that contains a delete-verb word and stays silent otherwise.
47
47
  */
48
48
 
49
- import { homedir } from "os";
50
- import { isAbsolute, join, normalize, resolve, sep } from "path";
49
+ import { homedir } from "node:os";
50
+ import { isAbsolute, join, normalize, resolve, sep } from "node:path";
51
51
  import { BINARY_FAILURES, findTrashBinary, inspectTrashBinary, type TrashBinaryInspection } from "./binary.js";
52
52
  export { findTrashBinary, inspectTrashBinary } from "./binary.js";
53
53
  import {
@@ -1297,7 +1297,10 @@ export async function run(): Promise<void> {
1297
1297
  }
1298
1298
  }
1299
1299
 
1300
+ // Standalone run only. No top-level await: the CommonJS native safety worker
1301
+ // (scripts/build-native-worker.ts) bundles this module, and its parser refuses
1302
+ // top-level await even where import.meta.main=false makes the block dead.
1303
+ // A rejected run still exits non-zero, as an awaited one did.
1300
1304
  if (import.meta.main) {
1301
- await run();
1302
- process.exit(0);
1305
+ void run().then(() => process.exit(0));
1303
1306
  }
@@ -51,9 +51,9 @@
51
51
  * or evaluation error so a guard defect cannot wedge the agent.
52
52
  */
53
53
 
54
- import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, statSync } from "fs";
55
- import { homedir } from "os";
56
- import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from "path";
54
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, statSync } from "node:fs";
55
+ import { homedir } from "node:os";
56
+ import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
57
57
  import {
58
58
  commandWordIndex,
59
59
  decodeWord,
@@ -1213,7 +1213,10 @@ export async function run(): Promise<void> {
1213
1213
  }
1214
1214
  }
1215
1215
 
1216
+ // Standalone run only. No top-level await: the CommonJS native safety worker
1217
+ // (scripts/build-native-worker.ts) bundles this module, and its parser refuses
1218
+ // top-level await even where import.meta.main=false makes the block dead.
1219
+ // A rejected run still exits non-zero, as an awaited one did.
1216
1220
  if (import.meta.main) {
1217
- await run();
1218
- process.exit(0);
1221
+ void run().then(() => process.exit(0));
1219
1222
  }
@@ -0,0 +1,75 @@
1
+ import { homedir } from "node:os";
2
+ import { isAbsolute } from "node:path";
3
+ import { evaluate as evaluateTrash, findTrashBinary, inspectTrashBinary, scanCommand } from "./hook-trash-guard/src/hook";
4
+ import { evaluate as evaluateRepos } from "./hook-workspace-repos-guard/src/hook";
5
+ import { evaluate as evaluateSignedLinks } from "./hook-signed-link-guard/src/hook";
6
+ import type { CodewithHookInput, CodewithHookOutput } from "./codewith-native-common";
7
+
8
+ // The native safety composite: trash-guard, workspace-repos-guard and
9
+ // signed-link-guard judged together for one capability name. Both the v1 entry
10
+ // (hooks/native-safety-entry.ts) and the v2 worker (hooks/native-safety-worker.ts)
11
+ // call it, so one guard decision has one implementation. It reads no stdin,
12
+ // writes no stdout and never exits: the caller owns the process.
13
+
14
+ export type NativeSafetyCompositeGuard = "trash-guard" | "workspace-repos-guard";
15
+
16
+ /** Refuses an unknown capability name exactly as the v1 entry always has. */
17
+ export function assertCompositeGuard(guard: unknown): asserts guard is NativeSafetyCompositeGuard {
18
+ if (guard !== "trash-guard" && guard !== "workspace-repos-guard") throw new Error("Invalid safety capability");
19
+ }
20
+
21
+ /**
22
+ * Judge one PreToolUse input for `guard`. Returns the exact text the v1 entry
23
+ * writes to stdout (`{"verdict":null}` or `{"verdict":{hookSpecificOutput}}`),
24
+ * and throws where the v1 entry exits non-zero.
25
+ */
26
+ export function evaluateComposite(guard: string, inputText: string): string {
27
+ assertCompositeGuard(guard);
28
+ const name = guard;
29
+ const input = JSON.parse(inputText) as CodewithHookInput;
30
+ if (!input || Array.isArray(input) || input.hook_event_name !== "PreToolUse" || typeof input.tool_name !== "string"
31
+ || !input.tool_input || typeof input.tool_input !== "object" || Array.isArray(input.tool_input)
32
+ || typeof input.cwd !== "string" || !isAbsolute(input.cwd)) throw new Error("Invalid safety input");
33
+ if (input.tool_name === "Bash" && typeof input.tool_input.command !== "string") throw new Error("Invalid shell input");
34
+ // Claude Code's Monitor runs a shell command and streams its stdout to the
35
+ // model; a websocket Monitor carries `ws` and no command.
36
+ if (input.tool_name === "Monitor" && input.tool_input.command !== undefined && typeof input.tool_input.command !== "string") throw new Error("Invalid monitor input");
37
+ const requestedExecution = (input as CodewithHookInput & { hasna_execution?: { schema: string; agent: string } }).hasna_execution;
38
+ if (requestedExecution !== undefined && (!requestedExecution || Object.keys(requestedExecution).sort().join() !== "agent,schema"
39
+ || requestedExecution.schema !== "hasna.hooks.execution.v1" || typeof requestedExecution.agent !== "string"
40
+ || !/^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$/.test(requestedExecution.agent))) throw new Error("Invalid execution capability");
41
+ const execution = requestedExecution === undefined ? undefined : {
42
+ runtime: process.execPath, home: homedir(), path: process.env.PATH!, agent: requestedExecution.agent,
43
+ };
44
+ if (execution && (!isAbsolute(execution.runtime) || !execution.path)) throw new Error("Invalid bound execution context");
45
+ if (["apply_patch", "ApplyPatch", "functions.apply_patch"].includes(input.tool_name)) {
46
+ if (input.tool_input.command !== undefined && input.tool_input.patch !== undefined && input.tool_input.command !== input.tool_input.patch) throw new Error("Conflicting patch aliases");
47
+ const patch = input.tool_input.command ?? input.tool_input.patch;
48
+ if (typeof patch !== "string") throw new Error("Invalid patch input");
49
+ input.tool_input = { ...input.tool_input, command: patch, patch };
50
+ }
51
+ // Every guard judges a Monitor command exactly as it judges the same Bash command.
52
+ const monitor = input.tool_name === "Monitor";
53
+ const shellCommand = input.tool_name === "Bash" || (monitor && typeof input.tool_input.command === "string");
54
+ const evaluated: CodewithHookInput = monitor && shellCommand ? { ...input, tool_name: "Bash" } : input;
55
+ const deny = (reason: string) => ({ hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "deny" as const, permissionDecisionReason: reason } });
56
+ const repos = evaluateRepos(evaluated).output;
57
+ // Every shell command, whatever the capability name: the guard reaches each
58
+ // registration of this entry. Its refusal outranks a Trash rewrite, because an
59
+ // allowed rewrite would also run the composite gh read.
60
+ const signedLinks = evaluateSignedLinks(evaluated);
61
+ const handoff = shellCommand && scanCommand(evaluated.tool_input!.command as string, { home: homedir(), cwd: input.cwd }).handoffHits.length > 0;
62
+ let trash: CodewithHookOutput = name === "trash-guard" && (!monitor || shellCommand)
63
+ ? evaluateTrash(evaluated, { home: homedir(), cwd: input.cwd, findTrash: findTrashBinary, inspectTrash: inspectTrashBinary, execution }) : { continue: true };
64
+ // A Trash rewrite is proven only for Bash. Under Monitor the deletion is
65
+ // refused rather than rewritten: a rewrite the harness dropped would run the
66
+ // raw command.
67
+ if (monitor && trash.hookSpecificOutput?.permissionDecision === "allow") {
68
+ trash = deny("[trash-guard] A deletion inside a Monitor command is refused; run it with the Bash tool, where it is redirected into trash.");
69
+ }
70
+ const verdict: CodewithHookOutput = repos.decision === "block" ? deny(repos.reason!)
71
+ : signedLinks.hookSpecificOutput ? signedLinks
72
+ : handoff ? deny("[workspace-repos-guard] Deletion under a protected repository checkout is refused.")
73
+ : trash;
74
+ return JSON.stringify({ verdict: "continue" in verdict ? null : verdict });
75
+ }
@@ -1,57 +1,11 @@
1
- import { homedir } from "node:os";
2
- import { isAbsolute } from "node:path";
3
- import { evaluate as evaluateTrash, findTrashBinary, inspectTrashBinary, scanCommand } from "./hook-trash-guard/src/hook";
4
- import { evaluate as evaluateRepos } from "./hook-workspace-repos-guard/src/hook";
5
- import { evaluate as evaluateSignedLinks } from "./hook-signed-link-guard/src/hook";
6
- import type { CodewithHookInput, CodewithHookOutput } from "./codewith-native-common";
1
+ import { assertCompositeGuard, evaluateComposite } from "./native-safety-composite";
7
2
 
3
+ // The v1 native safety entry: a thin wrapper over the composite. Its contract is
4
+ // fixed by the v1 supervisor (src/lib/native-safety.ts): the capability name is
5
+ // argv[1], the hook input is stdin, the verdict is one JSON write to stdout, and
6
+ // any failure exits non-zero.
8
7
  // Bundled with import.meta.main=false, so the original standalone run wrappers
9
8
  // cannot swallow exceptions or emit a second response. Only this entry runs.
10
9
  const name = process.argv[1];
11
- if (name !== "trash-guard" && name !== "workspace-repos-guard") throw new Error("Invalid safety capability");
12
- const input = JSON.parse(await Bun.stdin.text()) as CodewithHookInput;
13
- if (!input || Array.isArray(input) || input.hook_event_name !== "PreToolUse" || typeof input.tool_name !== "string"
14
- || !input.tool_input || typeof input.tool_input !== "object" || Array.isArray(input.tool_input)
15
- || typeof input.cwd !== "string" || !isAbsolute(input.cwd)) throw new Error("Invalid safety input");
16
- if (input.tool_name === "Bash" && typeof input.tool_input.command !== "string") throw new Error("Invalid shell input");
17
- // Claude Code's Monitor runs a shell command and streams its stdout to the
18
- // model; a websocket Monitor carries `ws` and no command.
19
- if (input.tool_name === "Monitor" && input.tool_input.command !== undefined && typeof input.tool_input.command !== "string") throw new Error("Invalid monitor input");
20
- const requestedExecution = (input as CodewithHookInput & { hasna_execution?: { schema: string; agent: string } }).hasna_execution;
21
- if (requestedExecution !== undefined && (!requestedExecution || Object.keys(requestedExecution).sort().join() !== "agent,schema"
22
- || requestedExecution.schema !== "hasna.hooks.execution.v1" || typeof requestedExecution.agent !== "string"
23
- || !/^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$/.test(requestedExecution.agent))) throw new Error("Invalid execution capability");
24
- const execution = requestedExecution === undefined ? undefined : {
25
- runtime: process.execPath, home: homedir(), path: process.env.PATH!, agent: requestedExecution.agent,
26
- };
27
- if (execution && (!isAbsolute(execution.runtime) || !execution.path)) throw new Error("Invalid bound execution context");
28
- if (["apply_patch", "ApplyPatch", "functions.apply_patch"].includes(input.tool_name)) {
29
- if (input.tool_input.command !== undefined && input.tool_input.patch !== undefined && input.tool_input.command !== input.tool_input.patch) throw new Error("Conflicting patch aliases");
30
- const patch = input.tool_input.command ?? input.tool_input.patch;
31
- if (typeof patch !== "string") throw new Error("Invalid patch input");
32
- input.tool_input = { ...input.tool_input, command: patch, patch };
33
- }
34
- // Every guard judges a Monitor command exactly as it judges the same Bash command.
35
- const monitor = input.tool_name === "Monitor";
36
- const shellCommand = input.tool_name === "Bash" || (monitor && typeof input.tool_input.command === "string");
37
- const evaluated: CodewithHookInput = monitor && shellCommand ? { ...input, tool_name: "Bash" } : input;
38
- const deny = (reason: string) => ({ hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "deny" as const, permissionDecisionReason: reason } });
39
- const repos = evaluateRepos(evaluated).output;
40
- // Every shell command, whatever the capability name: the guard reaches each
41
- // registration of this entry. Its refusal outranks a Trash rewrite, because an
42
- // allowed rewrite would also run the composite gh read.
43
- const signedLinks = evaluateSignedLinks(evaluated);
44
- const handoff = shellCommand && scanCommand(evaluated.tool_input!.command as string, { home: homedir(), cwd: input.cwd }).handoffHits.length > 0;
45
- let trash: CodewithHookOutput = name === "trash-guard" && (!monitor || shellCommand)
46
- ? evaluateTrash(evaluated, { home: homedir(), cwd: input.cwd, findTrash: findTrashBinary, inspectTrash: inspectTrashBinary, execution }) : { continue: true };
47
- // A Trash rewrite is proven only for Bash. Under Monitor the deletion is
48
- // refused rather than rewritten: a rewrite the harness dropped would run the
49
- // raw command.
50
- if (monitor && trash.hookSpecificOutput?.permissionDecision === "allow") {
51
- trash = deny("[trash-guard] A deletion inside a Monitor command is refused; run it with the Bash tool, where it is redirected into trash.");
52
- }
53
- const verdict: CodewithHookOutput = repos.decision === "block" ? deny(repos.reason!)
54
- : signedLinks.hookSpecificOutput ? signedLinks
55
- : handoff ? deny("[workspace-repos-guard] Deletion under a protected repository checkout is refused.")
56
- : trash;
57
- process.stdout.write(JSON.stringify({ verdict: "continue" in verdict ? null : verdict }));
10
+ assertCompositeGuard(name);
11
+ process.stdout.write(evaluateComposite(name, await Bun.stdin.text()));
@@ -0,0 +1,19 @@
1
+ import { evaluateComposite } from "./native-safety-composite";
2
+
3
+ // The native safety worker, ABI v2 (docs/adr/0001-stable-native-safety-launcher.md
4
+ // section 3.4). scripts/build-native-worker.ts bundles it into
5
+ // bin/native-safety-worker.cjs: CommonJS for Node's module shape, requiring only
6
+ // node: builtins, with no top-level await and no top-level side effects. It reads
7
+ // no stdin, writes no stdout and never exits; the loader that evaluates the
8
+ // verified bundle owns the process.
9
+
10
+ /** Replaced at build time with the package.json version; never set at run time. */
11
+ declare const HASNA_HOOKS_NATIVE_SAFETY_WORKER_VERSION: string;
12
+
13
+ export const abi = 2;
14
+ export const version: string = HASNA_HOOKS_NATIVE_SAFETY_WORKER_VERSION;
15
+
16
+ /** The v1 entry's exact stdout text for this input; throws where the v1 entry exits non-zero. */
17
+ export function evaluate(guard: string, inputText: string): string {
18
+ return evaluateComposite(guard, inputText);
19
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/hooks",
3
- "version": "0.12.3",
3
+ "version": "0.12.4",
4
4
  "description": "Open source hooks library for AI coding agents - Install safety, quality, and automation hooks with a single command",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,7 +33,7 @@
33
33
  "main": "./dist/index.js",
34
34
  "types": "./dist/index.d.ts",
35
35
  "scripts": {
36
- "build": "rm -rf dist bin && bun build ./src/cli/index.tsx --outdir ./bin --target bun --external pg --external ink --external react --external chalk --external conf --external @modelcontextprotocol/sdk --external zod && bun build ./src/serve.ts --outdir ./bin --target bun --external pg && bun build ./src/mcp/hooks-mcp.ts --outdir ./bin --target bun --external pg --external chalk --external conf --external @modelcontextprotocol/sdk --external zod && bun build ./src/sdk/index.ts --outdir ./dist/sdk --target bun && bun build ./src/index.ts ./src/storage.ts --outdir ./dist --target bun --external pg && bun run build:native-safety && bun run build:types",
36
+ "build": "rm -rf dist bin && bun build ./src/cli/index.tsx --outdir ./bin --target bun --external pg --external ink --external react --external chalk --external conf --external @modelcontextprotocol/sdk --external zod && bun build ./src/serve.ts --outdir ./bin --target bun --external pg && bun build ./src/mcp/hooks-mcp.ts --outdir ./bin --target bun --external pg --external chalk --external conf --external @modelcontextprotocol/sdk --external zod && bun build ./src/sdk/index.ts --outdir ./dist/sdk --target bun && bun build ./src/index.ts ./src/storage.ts --outdir ./dist --target bun --external pg && bun run build:native-safety && bun run build:native-safety-worker && bun run build:types",
37
37
  "build:types": "tsc -p tsconfig.build.json",
38
38
  "generate:sdk": "bun run scripts/generate-sdk.ts",
39
39
  "verify:generated": "bun run scripts/verify-generated-artifacts.ts",
@@ -46,7 +46,8 @@
46
46
  "scan:artifact": "bun scripts/artifact-scan.ts",
47
47
  "prepack": "bun run build && bun run scan:artifact",
48
48
  "build:native-safety": "bun build ./hooks/native-safety-entry.ts --outdir ./bin --target bun --minify --define import.meta.main=false && bun build ./src/native-safety.ts --outdir ./dist --target bun && bun run build:native-readiness",
49
- "build:native-readiness": "bun build ./hooks/native-readiness-entry.ts --outdir ./bin --target node --format=cjs && bun build ./src/native-readiness.ts --outdir ./dist --target bun"
49
+ "build:native-readiness": "bun build ./hooks/native-readiness-entry.ts --outdir ./bin --target node --format=cjs && bun build ./src/native-readiness.ts --outdir ./dist --target bun",
50
+ "build:native-safety-worker": "bun scripts/build-native-worker.ts"
50
51
  },
51
52
  "keywords": [
52
53
  "claude-code",
@@ -69,7 +70,7 @@
69
70
  "@hasna/contracts": "~1.2.1"
70
71
  },
71
72
  "dependencies": {
72
- "@hasna/skills": "0.9.17",
73
+ "@hasna/skills": "0.10.28",
73
74
  "@hasna/events": "^0.1.16",
74
75
  "@hasna/secrets": "0.4.2",
75
76
  "@modelcontextprotocol/sdk": "^1.26.0",
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Build the native safety worker, ABI v2 (docs/adr/0001-stable-native-safety-launcher.md
4
+ * sections 3.3 and 3.4), into bin/native-safety-worker.cjs.
5
+ *
6
+ * The launcher's loader evaluates the verified bytes as a CommonJS function body,
7
+ * `new Function("module", "exports", "require", source)`, and passes a `require`
8
+ * that accepts only `node:` builtins. A bundle that needs anything else fails at
9
+ * every guarded call, so this build refuses it instead. Each check below fails
10
+ * the build, and nothing is written:
11
+ * - a `require(...)` whose specifier is not a literal `node:` builtin;
12
+ * - any `bun:` specifier, static import or export, dynamic `import()` or
13
+ * `import.meta`, none of which the loader's CommonJS shape can serve;
14
+ * - a top-level `await` (including `for await`);
15
+ * - a `require` used other than as a direct call, which would hide a specifier;
16
+ * - the version placeholder left unreplaced by the define;
17
+ * - a bundle that does not compile in the loader's function shape.
18
+ *
19
+ * Usage: bun scripts/build-native-worker.ts [--outfile <path>]
20
+ */
21
+ import { createHash } from "node:crypto";
22
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
23
+ import { builtinModules } from "node:module";
24
+ import { dirname, join, resolve } from "node:path";
25
+ import ts from "typescript";
26
+
27
+ export const WORKER_ENTRY = "hooks/native-safety-worker.ts";
28
+ export const WORKER_OUTFILE = "bin/native-safety-worker.cjs";
29
+ /** The identifier hooks/native-safety-worker.ts declares; the build replaces it with package.json's version. */
30
+ export const WORKER_VERSION_DEFINE = "HASNA_HOOKS_NATIVE_SAFETY_WORKER_VERSION";
31
+
32
+ const BUILTINS = new Set(builtinModules);
33
+
34
+ /** Whether `specifier` is exactly `node:<builtin>`, the only form the loader's require accepts. */
35
+ export function isNodeBuiltinSpecifier(specifier: string): boolean {
36
+ if (!specifier.startsWith("node:")) return false;
37
+ const name = specifier.slice("node:".length);
38
+ return /^[a-z][a-z0-9_]*(?:\/[a-z][a-z0-9_]*)*$/.test(name) && BUILTINS.has(name);
39
+ }
40
+
41
+ function literalText(node: ts.Node | undefined): string | undefined {
42
+ return node && (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) ? node.text : undefined;
43
+ }
44
+
45
+ /** Whether an identifier named `require` sits in a property-name position rather than referring to a binding. */
46
+ function isPropertyName(node: ts.Identifier): boolean {
47
+ const parent = node.parent;
48
+ return (ts.isPropertyAccessExpression(parent) && parent.name === node)
49
+ || ((ts.isPropertyAssignment(parent) || ts.isMethodDeclaration(parent) || ts.isPropertyDeclaration(parent)
50
+ || ts.isGetAccessorDeclaration(parent) || ts.isSetAccessorDeclaration(parent)) && parent.name === node);
51
+ }
52
+
53
+ /**
54
+ * Every reason `source` cannot run under the v2 loader. Empty means the bundle
55
+ * passed. Parsed, not pattern-matched: a `require(` or `await` inside a string
56
+ * or a comment is data and is not reported.
57
+ */
58
+ export function workerBundleProblems(source: string): string[] {
59
+ const problems: string[] = [];
60
+ const file = ts.createSourceFile("native-safety-worker.cjs", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
61
+ const at = (node: ts.Node) => {
62
+ const { line, character } = file.getLineAndCharacterOfPosition(node.getStart(file));
63
+ return `${line + 1}:${character + 1}`;
64
+ };
65
+ const specifier = (node: ts.Node, text: string, how: string) => {
66
+ if (text.startsWith("bun:")) problems.push(`bun: import ${JSON.stringify(text)} via ${how} at ${at(node)}`);
67
+ else if (how !== "require") problems.push(`${how} of ${JSON.stringify(text)}; the loader serves only require at ${at(node)}`);
68
+ else if (!isNodeBuiltinSpecifier(text)) problems.push(`require of non-node: specifier ${JSON.stringify(text)} at ${at(node)}`);
69
+ };
70
+ const visit = (node: ts.Node, inFunction: boolean): void => {
71
+ if (!inFunction && ts.isAwaitExpression(node)) problems.push(`top-level await at ${at(node)}`);
72
+ if (!inFunction && ts.isForOfStatement(node) && node.awaitModifier) problems.push(`top-level for await at ${at(node)}`);
73
+ if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier) {
74
+ specifier(node, literalText(node.moduleSpecifier) ?? "<non-literal>", ts.isImportDeclaration(node) ? "static import" : "re-export");
75
+ } else if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node) || ts.isExportAssignment(node)
76
+ || (ts.canHaveModifiers(node) && ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword))) {
77
+ problems.push(`ESM syntax at ${at(node)}`);
78
+ }
79
+ if (ts.isMetaProperty(node) && node.keywordToken === ts.SyntaxKind.ImportKeyword) problems.push(`import.meta at ${at(node)}`);
80
+ if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
81
+ const text = node.arguments.length === 1 ? literalText(node.arguments[0]) : undefined;
82
+ specifier(node, text ?? "<non-literal>", "dynamic import()");
83
+ }
84
+ if (ts.isIdentifier(node) && node.text === WORKER_VERSION_DEFINE) problems.push(`version define not applied (${WORKER_VERSION_DEFINE}) at ${at(node)}`);
85
+ if (ts.isIdentifier(node) && node.text === "require" && !isPropertyName(node)) {
86
+ const call = node.parent;
87
+ if (ts.isCallExpression(call) && call.expression === node) {
88
+ const text = call.arguments.length === 1 ? literalText(call.arguments[0]) : undefined;
89
+ if (text === undefined) problems.push(`require with a non-literal specifier at ${at(call)}`);
90
+ else specifier(call, text, "require");
91
+ } else {
92
+ problems.push(`require used other than as a direct call at ${at(node)}`);
93
+ }
94
+ }
95
+ const scope = inFunction || ts.isFunctionLike(node) || ts.isClassStaticBlockDeclaration(node);
96
+ ts.forEachChild(node, (child) => visit(child, scope));
97
+ };
98
+ visit(file, false);
99
+ try {
100
+ // Compile, never call: the loader's exact CommonJS function shape.
101
+ new Function("module", "exports", "require", source);
102
+ } catch (error) {
103
+ problems.push(`does not compile as the loader's CommonJS function body: ${error instanceof Error ? error.message : String(error)}`);
104
+ }
105
+ return problems;
106
+ }
107
+
108
+ export interface NativeSafetyWorkerBuild { outfile: string; bytes: number; sha256: string; version: string }
109
+
110
+ /** Bundle, check and write the worker. Writes nothing when a check fails. */
111
+ export async function buildNativeSafetyWorker(options: { packageDir?: string; outfile?: string } = {}): Promise<NativeSafetyWorkerBuild> {
112
+ const packageDir = resolve(options.packageDir ?? join(import.meta.dir, ".."));
113
+ const outfile = resolve(packageDir, options.outfile ?? WORKER_OUTFILE);
114
+ const version: unknown = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8")).version;
115
+ if (typeof version !== "string" || !/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) throw new Error("package.json has no valid version for the native safety worker");
116
+ const result = await Bun.build({
117
+ entrypoints: [join(packageDir, WORKER_ENTRY)],
118
+ target: "node",
119
+ format: "cjs",
120
+ minify: true,
121
+ define: { "import.meta.main": "false", [WORKER_VERSION_DEFINE]: JSON.stringify(version) },
122
+ // Report the bundler's own messages (a parse error such as a top-level
123
+ // await in CommonJS) instead of a bare "Bundle failed".
124
+ throw: false,
125
+ });
126
+ if (!result.success) throw new Error(`native safety worker build failed:\n${result.logs.map(String).join("\n")}`);
127
+ if (result.outputs.length !== 1) throw new Error(`native safety worker build produced ${result.outputs.length} outputs, expected 1`);
128
+ const source = await result.outputs[0]!.text();
129
+ const problems = workerBundleProblems(source);
130
+ if (problems.length > 0) throw new Error(`native safety worker bundle refused:\n${problems.map((problem) => ` - ${problem}`).join("\n")}`);
131
+ mkdirSync(dirname(outfile), { recursive: true });
132
+ // A new file is never group- or world-writable, whatever the umask: the launcher's pin check refuses such a worker.
133
+ writeFileSync(outfile, source, { mode: 0o644 });
134
+ return { outfile, bytes: Buffer.byteLength(source), sha256: createHash("sha256").update(source).digest("hex"), version };
135
+ }
136
+
137
+ if (import.meta.main) {
138
+ const args = process.argv.slice(2);
139
+ let outfile: string | undefined;
140
+ if (args.length === 2 && args[0] === "--outfile" && args[1]) outfile = resolve(args[1]);
141
+ else if (args.length !== 0) {
142
+ console.error("usage: bun scripts/build-native-worker.ts [--outfile <path>]");
143
+ process.exit(2);
144
+ }
145
+ try {
146
+ const built = await buildNativeSafetyWorker({ outfile });
147
+ console.log(`native-safety-worker.cjs ${built.version}: ${built.bytes} bytes, sha256 ${built.sha256} -> ${built.outfile}`);
148
+ } catch (error) {
149
+ console.error(error instanceof Error ? error.message : String(error));
150
+ process.exit(1);
151
+ }
152
+ }