@hasna/hooks 0.10.9 → 0.11.1

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.
@@ -28,10 +28,11 @@ type NativeGuard = {
28
28
  };
29
29
  /** Select only the exact packaged composite definition in native discovery.
30
30
  * A name, a settings file, or a trusted hash by itself is not enough. */
31
- export declare function selectCodexSafety(discovery: unknown, configuration: unknown, home: string, cwd: string, featureEnabled?: boolean, requireReady?: boolean): NativeGuard;
31
+ export declare function selectCodexSafety(discovery: unknown, configuration: unknown, home: string, cwd: string, featureEnabled?: boolean, requireReady?: boolean, readiness?: boolean): NativeGuard;
32
32
  export declare function readCodexSafety(rpc: CodexSafetyRpc, home: string, cwd: string, options?: {
33
33
  includeLayers?: boolean;
34
34
  requireReady?: boolean;
35
+ readiness?: boolean;
35
36
  }): Promise<{
36
37
  guard: NativeGuard;
37
38
  discovery: unknown;
@@ -9,9 +9,8 @@ interface Dependencies {
9
9
  connect?: typeof connectCodexSafety;
10
10
  verify?: typeof verifyNativeSafetyCommand;
11
11
  }
12
- type Options = CodexSafetyOptions;
13
12
  export interface CodexTrustPlan {
14
- schema: "hasna.hooks.codex-safety-trust/v1";
13
+ schema: "hasna.hooks.codex-safety-trust/v1" | "hasna.hooks.codex-readiness-trust/v1";
15
14
  planDigest: string;
16
15
  nativeVersion: string;
17
16
  cwd: string;
@@ -20,24 +19,56 @@ export interface CodexTrustPlan {
20
19
  configPath: string;
21
20
  configVersion: string;
22
21
  action: "unchanged" | "trust_and_enable";
23
- guardVerified: true;
22
+ guardVerified: boolean;
23
+ readinessRunnerVerified?: true;
24
24
  nativeAdoptionVerified: false;
25
25
  }
26
- export declare function planCodexSafetyTrust(options?: Options, dependencies?: Dependencies): Promise<CodexTrustPlan>;
27
- export declare function applyCodexSafetyTrust(options: Options & {
26
+ export declare const planCodexSafetyTrust: (options?: CodexSafetyOptions, dependencies?: Dependencies) => Promise<CodexTrustPlan>;
27
+ export declare const applyCodexSafetyTrust: (options: CodexSafetyOptions & {
28
28
  expectedPlanDigest: string;
29
- }, dependencies?: Dependencies): Promise<{
29
+ }, dependencies?: Dependencies) => Promise<{
30
+ nativeDiscoveryVerified: boolean;
31
+ nativeAdoptionVerified: boolean;
32
+ unrelatedConfigurationPreserved: boolean;
33
+ readinessRunnerVerified?: boolean | undefined;
30
34
  ok: boolean;
31
35
  changed: boolean;
32
36
  operationId: string;
33
37
  planDigest: string;
34
38
  configVersion: any;
35
39
  guardVerified: boolean;
40
+ } | {
41
+ schema: "hasna.hooks.codex-safety-trust/v1" | "hasna.hooks.codex-readiness-trust/v1";
42
+ planDigest: string;
43
+ nativeVersion: string;
44
+ cwd: string;
45
+ key: string;
46
+ currentHash: string;
47
+ configPath: string;
48
+ configVersion: string;
49
+ action: "unchanged" | "trust_and_enable";
50
+ guardVerified: boolean;
51
+ readinessRunnerVerified?: true;
52
+ nativeAdoptionVerified: false;
53
+ ok: boolean;
54
+ changed: boolean;
55
+ }>;
56
+ export declare const planCodexReadinessTrust: (options?: CodexSafetyOptions, dependencies?: Dependencies) => Promise<CodexTrustPlan>;
57
+ export declare const applyCodexReadinessTrust: (options: CodexSafetyOptions & {
58
+ expectedPlanDigest: string;
59
+ }, dependencies?: Dependencies) => Promise<{
36
60
  nativeDiscoveryVerified: boolean;
37
61
  nativeAdoptionVerified: boolean;
38
62
  unrelatedConfigurationPreserved: boolean;
63
+ readinessRunnerVerified?: boolean | undefined;
64
+ ok: boolean;
65
+ changed: boolean;
66
+ operationId: string;
67
+ planDigest: string;
68
+ configVersion: any;
69
+ guardVerified: boolean;
39
70
  } | {
40
- schema: "hasna.hooks.codex-safety-trust/v1";
71
+ schema: "hasna.hooks.codex-safety-trust/v1" | "hasna.hooks.codex-readiness-trust/v1";
41
72
  planDigest: string;
42
73
  nativeVersion: string;
43
74
  cwd: string;
@@ -46,7 +77,8 @@ export declare function applyCodexSafetyTrust(options: Options & {
46
77
  configPath: string;
47
78
  configVersion: string;
48
79
  action: "unchanged" | "trust_and_enable";
49
- guardVerified: true;
80
+ guardVerified: boolean;
81
+ readinessRunnerVerified?: true;
50
82
  nativeAdoptionVerified: false;
51
83
  ok: boolean;
52
84
  changed: boolean;
@@ -72,6 +72,17 @@ export declare function previewNativeSafetyRegistration(target: "codex" | "claud
72
72
  after: string;
73
73
  command: string;
74
74
  };
75
+ /** Fixed bundled startup observer, rendered through the same native writer as
76
+ * safety hooks. The ready-plan binding is installed after native trust, since
77
+ * trusting Codex changes the configuration that the ready plan must capture. */
78
+ export declare function previewNativeReadinessRegistration(target: "codex" | "claude"): {
79
+ before: string | null;
80
+ after: string;
81
+ command: string;
82
+ };
83
+ export declare function installNativeReadinessRegistration(target: "codex" | "claude", expectedSettingsSha256: string): {
84
+ success: boolean;
85
+ };
75
86
  export declare function installHooks(names: string[], options?: InstallOptions): InstallResult[];
76
87
  export declare function getRegisteredHooksForTarget(scope?: Scope, target?: SingleTarget): string[];
77
88
  export declare function getRegisteredHooks(scope?: Scope): string[];
@@ -0,0 +1,14 @@
1
+ import { type ReadinessProvider } from "../native-readiness";
2
+ interface CommandBinding {
3
+ provider: ReadinessProvider;
4
+ home: string;
5
+ runtime: string;
6
+ runtimeSHA256: string;
7
+ worker: string;
8
+ workerSHA256: string;
9
+ }
10
+ export declare function buildNativeReadinessCommand(binding: CommandBinding): string;
11
+ export declare function nativeReadinessCommandBinding(command: string): CommandBinding | undefined;
12
+ export declare function nativeReadinessCommandVerified(command: string): boolean;
13
+ export declare function installedNativeReadinessCommand(provider: ReadinessProvider): string;
14
+ export {};
@@ -8,7 +8,7 @@ export interface NativeRegistrationOptions {
8
8
  target: "codex" | "claude";
9
9
  }
10
10
  export interface NativeRegistrationPlan {
11
- schema: "hasna.hooks.native-safety-registration/v1";
11
+ schema: "hasna.hooks.native-safety-registration/v1" | "hasna.hooks.native-readiness-registration/v1";
12
12
  target: "codex" | "claude";
13
13
  settingsPath: string;
14
14
  resolvedPath: string;
@@ -17,25 +17,55 @@ export interface NativeRegistrationPlan {
17
17
  commandSHA256: string;
18
18
  action: "unchanged" | "register";
19
19
  planDigest: string;
20
- guardVerified: true;
20
+ guardVerified: boolean;
21
+ readinessRunnerVerified?: true;
21
22
  nativeAdoptionVerified: false;
22
23
  }
23
24
  interface Dependencies {
24
25
  verify?: typeof verifyNativeSafetyCommand;
25
26
  }
26
- export declare function planNativeSafetyRegistration(options: NativeRegistrationOptions, dependencies?: Dependencies): Promise<NativeRegistrationPlan>;
27
- export declare function applyNativeSafetyRegistration(options: NativeRegistrationOptions & {
27
+ export declare const planNativeSafetyRegistration: (options: NativeRegistrationOptions, dependencies?: Dependencies) => Promise<NativeRegistrationPlan>;
28
+ export declare const applyNativeSafetyRegistration: (options: NativeRegistrationOptions & {
28
29
  expectedPlanDigest: string;
29
- }, dependencies?: Dependencies): Promise<{
30
+ }, dependencies?: Dependencies) => Promise<{
31
+ nativeAdoptionVerified: boolean;
32
+ readinessRunnerVerified?: boolean | undefined;
30
33
  ok: boolean;
31
34
  changed: boolean;
32
35
  operationId: `${string}-${string}-${string}-${string}-${string}`;
33
36
  planDigest: string;
34
37
  settingsSHA256: string;
35
38
  guardVerified: boolean;
39
+ } | {
40
+ schema: "hasna.hooks.native-safety-registration/v1" | "hasna.hooks.native-readiness-registration/v1";
41
+ target: "codex" | "claude";
42
+ settingsPath: string;
43
+ resolvedPath: string;
44
+ beforeSHA256: string;
45
+ desiredSHA256: string;
46
+ commandSHA256: string;
47
+ action: "unchanged" | "register";
48
+ planDigest: string;
49
+ guardVerified: boolean;
50
+ readinessRunnerVerified?: true;
51
+ nativeAdoptionVerified: false;
52
+ ok: boolean;
53
+ changed: boolean;
54
+ }>;
55
+ export declare const planNativeReadinessRegistration: (options: NativeRegistrationOptions) => Promise<NativeRegistrationPlan>;
56
+ export declare const applyNativeReadinessRegistration: (options: NativeRegistrationOptions & {
57
+ expectedPlanDigest: string;
58
+ }) => Promise<{
36
59
  nativeAdoptionVerified: boolean;
60
+ readinessRunnerVerified?: boolean | undefined;
61
+ ok: boolean;
62
+ changed: boolean;
63
+ operationId: `${string}-${string}-${string}-${string}-${string}`;
64
+ planDigest: string;
65
+ settingsSHA256: string;
66
+ guardVerified: boolean;
37
67
  } | {
38
- schema: "hasna.hooks.native-safety-registration/v1";
68
+ schema: "hasna.hooks.native-safety-registration/v1" | "hasna.hooks.native-readiness-registration/v1";
39
69
  target: "codex" | "claude";
40
70
  settingsPath: string;
41
71
  resolvedPath: string;
@@ -44,7 +74,8 @@ export declare function applyNativeSafetyRegistration(options: NativeRegistratio
44
74
  commandSHA256: string;
45
75
  action: "unchanged" | "register";
46
76
  planDigest: string;
47
- guardVerified: true;
77
+ guardVerified: boolean;
78
+ readinessRunnerVerified?: true;
48
79
  nativeAdoptionVerified: false;
49
80
  ok: boolean;
50
81
  changed: boolean;
@@ -0,0 +1,20 @@
1
+ export interface ReadinessChildResult {
2
+ status: number | null;
3
+ signal?: string | null;
4
+ error?: {
5
+ code?: string;
6
+ };
7
+ stdout: string;
8
+ stderr: string;
9
+ }
10
+ export interface ReadinessProcessOptions {
11
+ cwd: string;
12
+ env: NodeJS.ProcessEnv;
13
+ timeout: number;
14
+ maxBuffer: number;
15
+ signal?: AbortSignal;
16
+ }
17
+ /** Bound the whole freshly spawned read-only check tree, including nested
18
+ * Instructions/Hooks clients. Async execution keeps a shared harness responsive.
19
+ * Even a successful check is cleaned up before its result is returned. */
20
+ export declare function runReadinessProcess(command: string, args: string[], options: ReadinessProcessOptions): Promise<ReadinessChildResult>;
@@ -0,0 +1,68 @@
1
+ import { type ReadinessChildResult, type ReadinessProcessOptions } from "./lib/readiness-process";
2
+ export type ReadinessProvider = "claude" | "codex" | "sumi";
3
+ type Pin = {
4
+ path: string;
5
+ sha256: string;
6
+ };
7
+ export interface TrashReadinessBinding {
8
+ schema: "hasna.hooks.trash-readiness/v1";
9
+ provider: ReadinessProvider;
10
+ home: string;
11
+ runtime: Pin;
12
+ trash: {
13
+ root: string;
14
+ version: string;
15
+ cliSha256: string;
16
+ };
17
+ plan: Pin;
18
+ timeoutMs: number;
19
+ }
20
+ export type ReadinessCode = "ready" | "binding_missing" | "binding_invalid" | "unsafe_path" | "file_changed" | "package_mismatch" | "plan_invalid" | "deadline_exceeded" | "execution_failed" | "output_invalid" | "unsupported_platform" | "station_detection_failed" | "station_mismatch" | "retention_mismatch" | "hosted_unavailable" | "authentication_refused" | "station_setup_required" | "invalid_status" | "managed_instructions_refused" | "removal_instruction_missing" | "native_config_changed" | "instructions_changed" | "guard_refused" | "owner_refused" | "check_failed" | "input_invalid";
21
+ export interface TrashReadinessResult {
22
+ schema: "hasna.hooks.trash-readiness-result/v1";
23
+ provider: ReadinessProvider;
24
+ ready: boolean;
25
+ code: ReadinessCode;
26
+ station?: string;
27
+ planDigest?: string;
28
+ nativeAdoptionVerified: false;
29
+ }
30
+ export declare function isReadinessProvider(value: unknown): value is ReadinessProvider;
31
+ export declare function parseTrashReadinessBinding(input: unknown, provider: ReadinessProvider, home: string): TrashReadinessBinding;
32
+ export declare function trashReadinessBindingPath(provider: ReadinessProvider, home?: string): string;
33
+ /** Opening a pipe must never delay the startup check. Read canonical regular
34
+ * files only, with safe owner/root ancestry and bounded bytes. */
35
+ declare function readSafe(path: string, maximum: number, executable?: boolean): Buffer;
36
+ /** Fixed-schema operator input; never a credential or a free-form command. */
37
+ export declare function readTrashReadinessBindingFile(path: string, provider: ReadinessProvider, home?: string): {
38
+ binding: TrashReadinessBinding;
39
+ raw: Buffer<ArrayBufferLike>;
40
+ sha256: string;
41
+ };
42
+ interface Dependencies {
43
+ home?: string;
44
+ read?: typeof readSafe;
45
+ signal?: AbortSignal;
46
+ spawn?: (runtime: string, args: string[], options: ReadinessProcessOptions) => ReadinessChildResult | Promise<ReadinessChildResult>;
47
+ }
48
+ /** Executes only the selected installed Trash CLI's read-only check. Never
49
+ * forwards either stream: provider bodies and unrelated diagnostics stay out
50
+ * of the native prompt. No retry, repair, binding write or removal occurs. */
51
+ export declare function runTrashReadiness(provider: ReadinessProvider, d?: Dependencies): Promise<TrashReadinessResult>;
52
+ /** Check a proposed binding without installing it. This shares the exact
53
+ * runtime check; only the not-yet-written fixed binding file is supplied from
54
+ * the captured input. Every artifact, plan and service read remains real. */
55
+ export declare function checkTrashReadinessBinding(input: TrashReadinessBinding): Promise<TrashReadinessResult>;
56
+ /** SessionStart is observational, not a removal authorization. A failed check
57
+ * stays an explicit attention diagnostic; the removal guard remains separate. */
58
+ export declare function readinessContext(result: TrashReadinessResult): string;
59
+ export declare function readinessSessionStartOutput(result: TrashReadinessResult): {
60
+ hookSpecificOutput: {
61
+ hookEventName: string;
62
+ additionalContext: string;
63
+ };
64
+ };
65
+ /** Session payloads are untrusted data. Read only the documented event/source;
66
+ * never follow transcript paths, forward prompt text or run on tool events. */
67
+ export declare function handleReadinessSessionStart(provider: ReadinessProvider, event: unknown, dependencies?: Dependencies): Promise<{}>;
68
+ export {};
@@ -0,0 +1,328 @@
1
+ // @bun
2
+ // src/native-readiness.ts
3
+ import { createHash } from "crypto";
4
+
5
+ // src/lib/readiness-process.ts
6
+ import { spawn } from "child_process";
7
+ var supervisor = String.raw`
8
+ const {spawn}=require("node:child_process");
9
+ const spec=JSON.parse(process.argv.at(-1));
10
+ // This runtime was created detached and is its group's live leader. Its
11
+ // independent timer and parent pipe remain effective if the observer exits
12
+ // or its event loop stops responding. Only this freshly created tree is hit.
13
+ const cleanup=()=>{try{process.kill(-process.pid,"SIGKILL")}catch{process.exit(1)}};
14
+ setTimeout(cleanup,spec.timeout);
15
+ process.stdin.once("end",cleanup);
16
+ process.stdin.once("error",cleanup);
17
+ process.stdin.on("data",cleanup);
18
+ process.stdin.resume();
19
+ for(const signal of ["SIGTERM","SIGINT","SIGHUP"])process.once(signal,cleanup);
20
+ let done=false, out=Buffer.alloc(0), err=Buffer.alloc(0);
21
+ function report(status,error){
22
+ if(done)return; done=true;
23
+ process.stdout.write(JSON.stringify({status,error,stdout:out.toString("utf8"),stderr:err.toString("utf8")})+"\n");
24
+ }
25
+ const child=spawn(spec.command,spec.args,{stdio:["ignore","pipe","pipe"],env:process.env});
26
+ for(const [stream,name] of [[child.stdout,"stdout"],[child.stderr,"stderr"]]){
27
+ stream.on("data",bytes=>{
28
+ if(done)return;
29
+ const before=name==="stdout"?out:err;
30
+ if(before.length+bytes.length>spec.maxBuffer){report(null,{code:"ENOBUFS"});return}
31
+ if(name==="stdout")out=Buffer.concat([out,bytes]);else err=Buffer.concat([err,bytes]);
32
+ });
33
+ stream.on("error",()=>report(null,{code:"ECHILD"}));
34
+ }
35
+ child.on("error",()=>report(null,{code:"ECHILD"}));
36
+ // A successful observation includes complete streams. A nested client that
37
+ // keeps them open after its parent exits is a failure requiring group cleanup.
38
+ child.on("exit",()=>setTimeout(()=>report(null,{code:"ECHILD"}),100));
39
+ child.on("close",(status,signal)=>report(status,signal?{code:"ECHILD"}:undefined));
40
+ `;
41
+ function runReadinessProcess(command, args, options) {
42
+ if (!["darwin", "linux"].includes(process.platform)) {
43
+ return Promise.resolve({ status: null, error: { code: "ENOTSUP" }, stdout: "", stderr: "" });
44
+ }
45
+ if (options.signal?.aborted) {
46
+ return Promise.resolve({ status: null, error: { code: "ABORT_ERR" }, stdout: "", stderr: "" });
47
+ }
48
+ return new Promise((resolve) => {
49
+ const child = spawn(command, ["--config=/dev/null", "--no-env-file", "--no-macros", "--no-install", "--eval", supervisor, "--", JSON.stringify({ command, args, maxBuffer: options.maxBuffer, timeout: options.timeout })], {
50
+ cwd: options.cwd,
51
+ env: options.env,
52
+ detached: true,
53
+ stdio: ["pipe", "pipe", "pipe"]
54
+ });
55
+ let status = null, error;
56
+ let stopping = false, exited = false, finished = false;
57
+ let stdout = "", stderr = "", control = "";
58
+ const maximumEnvelope = options.maxBuffer * 12 + 1024;
59
+ let cleanup;
60
+ const finish = () => {
61
+ if (finished)
62
+ return;
63
+ finished = true;
64
+ clearTimeout(deadline);
65
+ clearTimeout(cleanup);
66
+ options.signal?.removeEventListener("abort", abort);
67
+ child.stdin?.destroy();
68
+ child.stdout?.destroy();
69
+ child.stderr?.destroy();
70
+ resolve({ status, error, stdout, stderr });
71
+ };
72
+ const stop = (code) => {
73
+ if (code && !finished)
74
+ error ??= { code };
75
+ if (finished || stopping)
76
+ return;
77
+ stopping = true;
78
+ if (exited || !child.pid) {
79
+ error ??= { code: "ECHILD" };
80
+ } else {
81
+ try {
82
+ child.stdin.end();
83
+ } catch {
84
+ error = { code: "ECHILD" };
85
+ }
86
+ }
87
+ cleanup = setTimeout(() => {
88
+ error = { code: "ECHILD" };
89
+ finish();
90
+ }, 1000);
91
+ };
92
+ const abort = () => stop("ABORT_ERR");
93
+ const deadline = setTimeout(() => stop("ETIMEDOUT"), options.timeout);
94
+ options.signal?.addEventListener("abort", abort, { once: true });
95
+ if (options.signal?.aborted)
96
+ abort();
97
+ child.stdin?.on("error", () => {
98
+ if (!stopping)
99
+ stop("ECHILD");
100
+ });
101
+ child.stdout?.on("data", (bytes) => {
102
+ if (stopping)
103
+ return;
104
+ control += bytes.toString("utf8");
105
+ if (Buffer.byteLength(control) > maximumEnvelope)
106
+ return stop("ENOBUFS");
107
+ if (!control.endsWith(`
108
+ `))
109
+ return;
110
+ try {
111
+ const value = JSON.parse(control);
112
+ if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some((key) => !["status", "error", "stdout", "stderr"].includes(key)) || !(value.status === null || Number.isInteger(value.status) && value.status >= 0 && value.status <= 255) || typeof value.stdout !== "string" || typeof value.stderr !== "string" || Buffer.byteLength(value.stdout) > options.maxBuffer || Buffer.byteLength(value.stderr) > options.maxBuffer || value.error !== undefined && (Object.keys(value.error).join() !== "code" || !["ENOBUFS", "ECHILD"].includes(value.error.code))) {
113
+ return stop("EPROTO");
114
+ }
115
+ status = value.status;
116
+ stdout = value.stdout;
117
+ stderr = value.stderr;
118
+ stop(value.error?.code);
119
+ } catch {
120
+ stop("EPROTO");
121
+ }
122
+ });
123
+ child.stdout?.on("error", () => stop("EPROTO"));
124
+ child.stderr?.on("data", () => stop("ECHILD"));
125
+ child.stderr?.on("error", () => stop("ECHILD"));
126
+ child.on("error", () => {
127
+ error = { code: "ECHILD" };
128
+ stop();
129
+ });
130
+ child.on("exit", () => {
131
+ exited = true;
132
+ if (!stopping)
133
+ stop("ECHILD");
134
+ });
135
+ child.on("close", () => {
136
+ if (!stopping)
137
+ error = { code: "ECHILD" };
138
+ finish();
139
+ });
140
+ });
141
+ }
142
+
143
+ // src/native-readiness.ts
144
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync } from "fs";
145
+ import { homedir } from "os";
146
+ import { dirname, isAbsolute, join, relative, resolve } from "path";
147
+
148
+ class ReadinessError extends Error {
149
+ code;
150
+ constructor(code) {
151
+ super(code);
152
+ this.code = code;
153
+ }
154
+ }
155
+ function need(value, code) {
156
+ if (!value)
157
+ throw new ReadinessError(code);
158
+ }
159
+ var sha = (value) => createHash("sha256").update(value).digest("hex");
160
+ var digest = (v) => typeof v === "string" && /^[a-f0-9]{64}$/.test(v);
161
+ var absolute = (v) => typeof v === "string" && v.length <= 4096 && isAbsolute(v) && resolve(v) === v && !/[\0\r\n]/.test(v);
162
+ var object = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
163
+ var keys = (v, expected) => object(v) && Object.keys(v).sort().join() === expected.sort().join();
164
+ var pin = (v) => keys(v, ["path", "sha256"]) && absolute(v.path) && digest(v.sha256);
165
+ function isReadinessProvider(value) {
166
+ return value === "claude" || value === "codex" || value === "sumi";
167
+ }
168
+ function inside(home, path) {
169
+ const part = relative(home, path);
170
+ return part !== "" && part !== ".." && !part.startsWith("../") && !isAbsolute(part);
171
+ }
172
+ function parseTrashReadinessBinding(input, provider, home) {
173
+ const b = input;
174
+ need(isReadinessProvider(provider) && absolute(home), "binding_invalid");
175
+ need(keys(b, ["schema", "provider", "home", "runtime", "trash", "plan", "timeoutMs"]) && b.schema === "hasna.hooks.trash-readiness/v1" && b.provider === provider && b.home === home && pin(b.runtime) && pin(b.plan) && keys(b.trash, ["root", "version", "cliSha256"]) && absolute(b.trash.root) && typeof b.trash.version === "string" && b.trash.version.length <= 128 && /^\d+\.\d+\.\d+$/.test(b.trash.version) && digest(b.trash.cliSha256) && Number.isInteger(b.timeoutMs) && b.timeoutMs >= 1000 && b.timeoutMs <= 15000 && inside(home, b.plan.path) && inside(home, b.trash.root), "binding_invalid");
176
+ return structuredClone(b);
177
+ }
178
+ function trashReadinessBindingPath(provider, home = homedir()) {
179
+ need(isReadinessProvider(provider) && absolute(home), "binding_invalid");
180
+ return join(home, ".hasna", "hooks", "native", "trash-readiness", provider + ".json");
181
+ }
182
+ function readSafe(path, maximum, executable = false) {
183
+ need(absolute(path) && realpathSync(path) === path, "unsafe_path");
184
+ for (let parent = dirname(path);; parent = dirname(parent)) {
185
+ const stat = lstatSync(parent);
186
+ need(stat.isDirectory() && [0, process.getuid?.()].includes(stat.uid) && !(stat.mode & 18), "unsafe_path");
187
+ if (parent === dirname(parent))
188
+ break;
189
+ }
190
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
191
+ try {
192
+ const stat = fstatSync(fd);
193
+ need(stat.isFile() && [0, process.getuid?.()].includes(stat.uid) && !(stat.mode & 18) && stat.size <= maximum && (!executable || !!(stat.mode & 64)), "unsafe_path");
194
+ const bytes = Buffer.alloc(stat.size + 1);
195
+ let length = 0;
196
+ while (length < bytes.length) {
197
+ const count = readSync(fd, bytes, length, bytes.length - length, null);
198
+ if (!count)
199
+ break;
200
+ length += count;
201
+ }
202
+ const after = fstatSync(fd);
203
+ need(length === stat.size && after.size === stat.size && after.mtimeMs === stat.mtimeMs && after.ctimeMs === stat.ctimeMs, "file_changed");
204
+ return bytes.subarray(0, length);
205
+ } finally {
206
+ closeSync(fd);
207
+ }
208
+ }
209
+ function readTrashReadinessBindingFile(path, provider, home = realpathSync(homedir())) {
210
+ const raw = readSafe(path, 65536);
211
+ return { binding: parseTrashReadinessBinding(JSON.parse(raw.toString("utf8")), provider, home), raw, sha256: sha(raw) };
212
+ }
213
+ var safeFailureCodes = new Set([
214
+ "unsupported_platform",
215
+ "station_detection_failed",
216
+ "station_mismatch",
217
+ "hosted_unavailable",
218
+ "authentication_refused",
219
+ "station_setup_required",
220
+ "invalid_status",
221
+ "deadline_exceeded",
222
+ "retention_mismatch",
223
+ "managed_instructions_refused",
224
+ "removal_instruction_missing",
225
+ "instructions_changed",
226
+ "native_config_changed",
227
+ "guard_refused",
228
+ "owner_refused"
229
+ ]);
230
+ async function runTrashReadiness(provider, d = {}) {
231
+ const started = performance.now();
232
+ const result = (code, extra = {}) => ({
233
+ schema: "hasna.hooks.trash-readiness-result/v1",
234
+ provider,
235
+ ready: code === "ready",
236
+ code,
237
+ nativeAdoptionVerified: false,
238
+ ...extra
239
+ });
240
+ let phase = "binding";
241
+ try {
242
+ const home = d.home ?? realpathSync(homedir()), read = d.read ?? readSafe;
243
+ need(isReadinessProvider(provider), "binding_invalid");
244
+ const bindingPath = trashReadinessBindingPath(provider, home), bindingBytes = read(bindingPath, 65536);
245
+ const b = parseTrashReadinessBinding(JSON.parse(bindingBytes.toString("utf8")), provider, home);
246
+ phase = "consumer";
247
+ const checkPin = (p, maximum, executable = false) => {
248
+ const bytes = read(p.path, maximum, executable);
249
+ need(sha(bytes) === p.sha256, "file_changed");
250
+ return bytes;
251
+ };
252
+ checkPin(b.runtime, 200000000, true);
253
+ const manifestPath = join(b.trash.root, "package.json"), manifestBytes = read(manifestPath, 65536);
254
+ const manifest = JSON.parse(manifestBytes.toString("utf8"));
255
+ need(manifest.name === "@hasna/trash" && manifest.version === b.trash.version && manifest.bin?.trash === "dist/cli/index.js", "package_mismatch");
256
+ const cli = join(b.trash.root, "dist", "cli", "index.js");
257
+ checkPin({ path: cli, sha256: b.trash.cliSha256 }, 64 * 1024 * 1024, true);
258
+ const plan = JSON.parse(checkPin(b.plan, 65536).toString("utf8"));
259
+ need(plan?.schema === "hasna.trash.station-setup-plan/v1" && plan.stage === "ready" && plan.configurationReady === true && plan.nativeAdoptionVerified === false && digest(plan.planDigest) && plan.spec?.provider === provider && plan.spec.home === home && typeof plan.spec.station === "string" && /^[a-z0-9][a-z0-9-]{0,62}$/.test(plan.spec.station) && plan.spec.runtime?.path === b.runtime.path && plan.spec.runtime?.sha256 === b.runtime.sha256 && absolute(plan.spec.cwd) && (plan.spec.cwd === home || inside(home, plan.spec.cwd)) && typeof plan.spec.path === "string" && plan.spec.path.length <= 8192 && plan.spec.path.split(":").every(absolute), "plan_invalid");
260
+ const args = ["--config=/dev/null", "--no-env-file", "--no-macros", "--no-install", cli, "station", "check", "--plan", b.plan.path];
261
+ const remaining = () => Math.floor(b.timeoutMs - (performance.now() - started));
262
+ const timeout = remaining();
263
+ need(timeout > 0, "deadline_exceeded");
264
+ const child = await (d.spawn ?? runReadinessProcess)(b.runtime.path, args, {
265
+ cwd: home,
266
+ env: { HOME: home, PATH: plan.spec.path, LANG: "C", NO_COLOR: "1" },
267
+ timeout,
268
+ maxBuffer: 16384,
269
+ signal: d.signal
270
+ });
271
+ need(remaining() > 0, "deadline_exceeded");
272
+ if (child.error && "code" in child.error && child.error.code === "ETIMEDOUT")
273
+ return result("deadline_exceeded");
274
+ if (child.error || child.signal || child.status === null)
275
+ return result("execution_failed");
276
+ need(typeof child.stdout === "string" && typeof child.stderr === "string" && Buffer.byteLength(child.stdout) <= 16384 && Buffer.byteLength(child.stderr) <= 16384, "output_invalid");
277
+ let value;
278
+ try {
279
+ value = JSON.parse(child.stdout);
280
+ } catch {
281
+ return result("output_invalid");
282
+ }
283
+ if (child.status !== 0 || value?.configurationReady !== true) {
284
+ return result(safeFailureCodes.has(value?.code) ? value.code : "check_failed");
285
+ }
286
+ need(value.schema === "hasna.trash.station-setup-result/v1" && value.station === plan.spec.station && value.provider === provider && value.planDigest === plan.planDigest && value.serviceReady === true && value.guardVerified === true && value.managedInstructionsVerified === true && value.mcpConfigurationVerified === true && value.mcpExecutionVerified === false && value.nativeAdoptionVerified === false && child.stderr.length === 0, "output_invalid");
287
+ checkPin(b.plan, 65536);
288
+ checkPin({ path: cli, sha256: b.trash.cliSha256 }, 64 * 1024 * 1024, true);
289
+ checkPin(b.runtime, 200000000, true);
290
+ need(read(bindingPath, 65536).equals(bindingBytes) && read(manifestPath, 65536).equals(manifestBytes), "file_changed");
291
+ need(remaining() > 0, "deadline_exceeded");
292
+ return result("ready", { station: value.station, planDigest: value.planDigest });
293
+ } catch (error) {
294
+ return result(error instanceof ReadinessError ? error.code : phase === "binding" && error?.code === "ENOENT" ? "binding_missing" : "binding_invalid");
295
+ }
296
+ }
297
+ function checkTrashReadinessBinding(input) {
298
+ const home = realpathSync(homedir()), b = parseTrashReadinessBinding(input, input?.provider, home);
299
+ const path = trashReadinessBindingPath(b.provider, home), raw = Buffer.from(JSON.stringify(b) + `
300
+ `);
301
+ return runTrashReadiness(b.provider, { home, read: (selected, maximum, executable) => selected === path ? raw : readSafe(selected, maximum, executable) });
302
+ }
303
+ function readinessContext(result) {
304
+ return result.ready ? `Trash readiness verified for ${result.station} (${result.provider}). Use Trash for recoverable file removal.` : `Trash readiness needs attention (${result.provider}: ${result.code}). Preserve files; do not fall back to direct deletion. Refresh the station setup before removal.`;
305
+ }
306
+ function readinessSessionStartOutput(result) {
307
+ return { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: readinessContext(result) } };
308
+ }
309
+ async function handleReadinessSessionStart(provider, event, dependencies = {}) {
310
+ const input = event;
311
+ if (!object(input) || input.hook_event_name !== "SessionStart" || !["startup", "resume", "clear", "fork", "compact"].includes(input.source)) {
312
+ return readinessSessionStartOutput({ schema: "hasna.hooks.trash-readiness-result/v1", provider, ready: false, code: "input_invalid", nativeAdoptionVerified: false });
313
+ }
314
+ if (input.source === "compact")
315
+ return {};
316
+ return readinessSessionStartOutput(await runTrashReadiness(provider, dependencies));
317
+ }
318
+ export {
319
+ trashReadinessBindingPath,
320
+ runTrashReadiness,
321
+ readinessSessionStartOutput,
322
+ readinessContext,
323
+ readTrashReadinessBindingFile,
324
+ parseTrashReadinessBinding,
325
+ isReadinessProvider,
326
+ handleReadinessSessionStart,
327
+ checkTrashReadinessBinding
328
+ };
package/dist/sdk/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // @bun
2
- // ../../node_modules/.bun/@hasna+contracts@1.2.2+944b86d8024e79fa/node_modules/@hasna/contracts/dist/client/transport.js
2
+ // node_modules/@hasna/contracts/dist/client/transport.js
3
3
  import { isIP } from "net";
4
4
  import { spawnSync } from "child_process";
5
5
  import { closeSync as closeSync2, fstatSync as fstatSync2, openSync as openSync2, readFileSync as readFileSync2 } from "fs";
@@ -136,11 +136,15 @@ export function inspectTrashBinary(env: NodeJS.ProcessEnv = process.env): TrashB
136
136
  typeof manifest.bin?.trash !== "string" || realpathSync(resolve(packageRoot, manifest.bin.trash)) !== executable) { reject("unrecognized_package"); continue; }
137
137
  try { if (!safeScriptInterpreter(executable, pathValue)) { reject("unsafe_interpreter"); continue; } }
138
138
  catch { reject("unsafe_interpreter"); continue; }
139
+ const remaining = deadline - Date.now();
140
+ if (remaining <= 0) { reject("identity_failed"); break; }
139
141
  const probe = spawnSync(candidate, ["--identity"], {
140
- encoding: "utf8", timeout: Math.max(1, Math.min(500, deadline - Date.now())), maxBuffer: 2_048,
142
+ // A valid cold start may take over 500 ms. Keep the shared search
143
+ // deadline instead of cutting off each eligible candidate early.
144
+ encoding: "utf8", timeout: remaining, maxBuffer: 2_048,
141
145
  env: { PATH: pathValue }, stdio: ["ignore", "pipe", "pipe"],
142
146
  });
143
- if (probe.error || probe.status !== 0) { reject("identity_failed"); continue; }
147
+ if (probe.error || probe.status !== 0 || Date.now() >= deadline) { reject("identity_failed"); continue; }
144
148
  const identity = JSON.parse(probe.stdout);
145
149
  if (identity.name === "@hasna/trash" && identity.version === manifest.version &&
146
150
  identity.guardProtocol === "hasna.trash.guard.v1") return { ok: true, path: candidate, version: manifest.version };