@hasna/hooks 0.10.8 → 0.11.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.
@@ -0,0 +1,355 @@
1
+ // src/native-readiness.ts
2
+ var import_node_crypto = require("node:crypto");
3
+
4
+ // src/lib/readiness-process.ts
5
+ var import_node_child_process = require("node:child_process");
6
+ var supervisor = String.raw`
7
+ const {spawn}=require("node:child_process");
8
+ const spec=JSON.parse(process.argv.at(-1));
9
+ // This runtime was created detached and is its group's live leader. Its
10
+ // independent timer and parent pipe remain effective if the observer exits
11
+ // or its event loop stops responding. Only this freshly created tree is hit.
12
+ const cleanup=()=>{try{process.kill(-process.pid,"SIGKILL")}catch{process.exit(1)}};
13
+ setTimeout(cleanup,spec.timeout);
14
+ process.stdin.once("end",cleanup);
15
+ process.stdin.once("error",cleanup);
16
+ process.stdin.on("data",cleanup);
17
+ process.stdin.resume();
18
+ for(const signal of ["SIGTERM","SIGINT","SIGHUP"])process.once(signal,cleanup);
19
+ let done=false, out=Buffer.alloc(0), err=Buffer.alloc(0);
20
+ function report(status,error){
21
+ if(done)return; done=true;
22
+ process.stdout.write(JSON.stringify({status,error,stdout:out.toString("utf8"),stderr:err.toString("utf8")})+"\n");
23
+ }
24
+ const child=spawn(spec.command,spec.args,{stdio:["ignore","pipe","pipe"],env:process.env});
25
+ for(const [stream,name] of [[child.stdout,"stdout"],[child.stderr,"stderr"]]){
26
+ stream.on("data",bytes=>{
27
+ if(done)return;
28
+ const before=name==="stdout"?out:err;
29
+ if(before.length+bytes.length>spec.maxBuffer){report(null,{code:"ENOBUFS"});return}
30
+ if(name==="stdout")out=Buffer.concat([out,bytes]);else err=Buffer.concat([err,bytes]);
31
+ });
32
+ stream.on("error",()=>report(null,{code:"ECHILD"}));
33
+ }
34
+ child.on("error",()=>report(null,{code:"ECHILD"}));
35
+ // A successful observation includes complete streams. A nested client that
36
+ // keeps them open after its parent exits is a failure requiring group cleanup.
37
+ child.on("exit",()=>setTimeout(()=>report(null,{code:"ECHILD"}),100));
38
+ child.on("close",(status,signal)=>report(status,signal?{code:"ECHILD"}:undefined));
39
+ `;
40
+ function runReadinessProcess(command, args, options) {
41
+ if (!["darwin", "linux"].includes(process.platform)) {
42
+ return Promise.resolve({ status: null, error: { code: "ENOTSUP" }, stdout: "", stderr: "" });
43
+ }
44
+ if (options.signal?.aborted) {
45
+ return Promise.resolve({ status: null, error: { code: "ABORT_ERR" }, stdout: "", stderr: "" });
46
+ }
47
+ return new Promise((resolve) => {
48
+ const child = import_node_child_process.spawn(command, ["--config=/dev/null", "--no-env-file", "--no-macros", "--no-install", "--eval", supervisor, "--", JSON.stringify({ command, args, maxBuffer: options.maxBuffer, timeout: options.timeout })], {
49
+ cwd: options.cwd,
50
+ env: options.env,
51
+ detached: true,
52
+ stdio: ["pipe", "pipe", "pipe"]
53
+ });
54
+ let status = null, error;
55
+ let stopping = false, exited = false, finished = false;
56
+ let stdout = "", stderr = "", control = "";
57
+ const maximumEnvelope = options.maxBuffer * 12 + 1024;
58
+ let cleanup;
59
+ const finish = () => {
60
+ if (finished)
61
+ return;
62
+ finished = true;
63
+ clearTimeout(deadline);
64
+ clearTimeout(cleanup);
65
+ options.signal?.removeEventListener("abort", abort);
66
+ child.stdin?.destroy();
67
+ child.stdout?.destroy();
68
+ child.stderr?.destroy();
69
+ resolve({ status, error, stdout, stderr });
70
+ };
71
+ const stop = (code) => {
72
+ if (code && !finished)
73
+ error ??= { code };
74
+ if (finished || stopping)
75
+ return;
76
+ stopping = true;
77
+ if (exited || !child.pid) {
78
+ error ??= { code: "ECHILD" };
79
+ } else {
80
+ try {
81
+ child.stdin.end();
82
+ } catch {
83
+ error = { code: "ECHILD" };
84
+ }
85
+ }
86
+ cleanup = setTimeout(() => {
87
+ error = { code: "ECHILD" };
88
+ finish();
89
+ }, 1000);
90
+ };
91
+ const abort = () => stop("ABORT_ERR");
92
+ const deadline = setTimeout(() => stop("ETIMEDOUT"), options.timeout);
93
+ options.signal?.addEventListener("abort", abort, { once: true });
94
+ if (options.signal?.aborted)
95
+ abort();
96
+ child.stdin?.on("error", () => {
97
+ if (!stopping)
98
+ stop("ECHILD");
99
+ });
100
+ child.stdout?.on("data", (bytes) => {
101
+ if (stopping)
102
+ return;
103
+ control += bytes.toString("utf8");
104
+ if (Buffer.byteLength(control) > maximumEnvelope)
105
+ return stop("ENOBUFS");
106
+ if (!control.endsWith(`
107
+ `))
108
+ return;
109
+ try {
110
+ const value = JSON.parse(control);
111
+ 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))) {
112
+ return stop("EPROTO");
113
+ }
114
+ status = value.status;
115
+ stdout = value.stdout;
116
+ stderr = value.stderr;
117
+ stop(value.error?.code);
118
+ } catch {
119
+ stop("EPROTO");
120
+ }
121
+ });
122
+ child.stdout?.on("error", () => stop("EPROTO"));
123
+ child.stderr?.on("data", () => stop("ECHILD"));
124
+ child.stderr?.on("error", () => stop("ECHILD"));
125
+ child.on("error", () => {
126
+ error = { code: "ECHILD" };
127
+ stop();
128
+ });
129
+ child.on("exit", () => {
130
+ exited = true;
131
+ if (!stopping)
132
+ stop("ECHILD");
133
+ });
134
+ child.on("close", () => {
135
+ if (!stopping)
136
+ error = { code: "ECHILD" };
137
+ finish();
138
+ });
139
+ });
140
+ }
141
+
142
+ // src/native-readiness.ts
143
+ var import_node_fs = require("node:fs");
144
+ var import_node_os = require("node:os");
145
+ var import_node_path = require("node:path");
146
+
147
+ class ReadinessError extends Error {
148
+ code;
149
+ constructor(code) {
150
+ super(code);
151
+ this.code = code;
152
+ }
153
+ }
154
+ function need(value, code) {
155
+ if (!value)
156
+ throw new ReadinessError(code);
157
+ }
158
+ var sha = (value) => import_node_crypto.createHash("sha256").update(value).digest("hex");
159
+ var digest = (v) => typeof v === "string" && /^[a-f0-9]{64}$/.test(v);
160
+ var absolute = (v) => typeof v === "string" && v.length <= 4096 && import_node_path.isAbsolute(v) && import_node_path.resolve(v) === v && !/[\0\r\n]/.test(v);
161
+ var object = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
162
+ var keys = (v, expected) => object(v) && Object.keys(v).sort().join() === expected.sort().join();
163
+ var pin = (v) => keys(v, ["path", "sha256"]) && absolute(v.path) && digest(v.sha256);
164
+ function isReadinessProvider(value) {
165
+ return value === "claude" || value === "codex" || value === "sumi";
166
+ }
167
+ function inside(home, path) {
168
+ const part = import_node_path.relative(home, path);
169
+ return part !== "" && part !== ".." && !part.startsWith("../") && !import_node_path.isAbsolute(part);
170
+ }
171
+ function parseTrashReadinessBinding(input, provider, home) {
172
+ const b = input;
173
+ need(isReadinessProvider(provider) && absolute(home), "binding_invalid");
174
+ 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");
175
+ return structuredClone(b);
176
+ }
177
+ function trashReadinessBindingPath(provider, home = import_node_os.homedir()) {
178
+ need(isReadinessProvider(provider) && absolute(home), "binding_invalid");
179
+ return import_node_path.join(home, ".hasna", "hooks", "native", "trash-readiness", provider + ".json");
180
+ }
181
+ function readSafe(path, maximum, executable = false) {
182
+ need(absolute(path) && import_node_fs.realpathSync(path) === path, "unsafe_path");
183
+ for (let parent = import_node_path.dirname(path);; parent = import_node_path.dirname(parent)) {
184
+ const stat = import_node_fs.lstatSync(parent);
185
+ need(stat.isDirectory() && [0, process.getuid?.()].includes(stat.uid) && !(stat.mode & 18), "unsafe_path");
186
+ if (parent === import_node_path.dirname(parent))
187
+ break;
188
+ }
189
+ const fd = import_node_fs.openSync(path, import_node_fs.constants.O_RDONLY | import_node_fs.constants.O_NOFOLLOW | import_node_fs.constants.O_NONBLOCK);
190
+ try {
191
+ const stat = import_node_fs.fstatSync(fd);
192
+ need(stat.isFile() && [0, process.getuid?.()].includes(stat.uid) && !(stat.mode & 18) && stat.size <= maximum && (!executable || !!(stat.mode & 64)), "unsafe_path");
193
+ const bytes = Buffer.alloc(stat.size + 1);
194
+ let length = 0;
195
+ while (length < bytes.length) {
196
+ const count = import_node_fs.readSync(fd, bytes, length, bytes.length - length, null);
197
+ if (!count)
198
+ break;
199
+ length += count;
200
+ }
201
+ const after = import_node_fs.fstatSync(fd);
202
+ need(length === stat.size && after.size === stat.size && after.mtimeMs === stat.mtimeMs && after.ctimeMs === stat.ctimeMs, "file_changed");
203
+ return bytes.subarray(0, length);
204
+ } finally {
205
+ import_node_fs.closeSync(fd);
206
+ }
207
+ }
208
+ var safeFailureCodes = new Set([
209
+ "unsupported_platform",
210
+ "station_detection_failed",
211
+ "station_mismatch",
212
+ "hosted_unavailable",
213
+ "authentication_refused",
214
+ "station_setup_required",
215
+ "invalid_status",
216
+ "deadline_exceeded",
217
+ "retention_mismatch",
218
+ "managed_instructions_refused",
219
+ "removal_instruction_missing",
220
+ "instructions_changed",
221
+ "native_config_changed",
222
+ "guard_refused",
223
+ "owner_refused"
224
+ ]);
225
+ async function runTrashReadiness(provider, d = {}) {
226
+ const started = performance.now();
227
+ const result = (code, extra = {}) => ({
228
+ schema: "hasna.hooks.trash-readiness-result/v1",
229
+ provider,
230
+ ready: code === "ready",
231
+ code,
232
+ nativeAdoptionVerified: false,
233
+ ...extra
234
+ });
235
+ let phase = "binding";
236
+ try {
237
+ const home = d.home ?? import_node_fs.realpathSync(import_node_os.homedir()), read = d.read ?? readSafe;
238
+ need(isReadinessProvider(provider), "binding_invalid");
239
+ const bindingPath = trashReadinessBindingPath(provider, home), bindingBytes = read(bindingPath, 65536);
240
+ const b = parseTrashReadinessBinding(JSON.parse(bindingBytes.toString("utf8")), provider, home);
241
+ phase = "consumer";
242
+ const checkPin = (p, maximum, executable = false) => {
243
+ const bytes = read(p.path, maximum, executable);
244
+ need(sha(bytes) === p.sha256, "file_changed");
245
+ return bytes;
246
+ };
247
+ checkPin(b.runtime, 200000000, true);
248
+ const manifestPath = import_node_path.join(b.trash.root, "package.json"), manifestBytes = read(manifestPath, 65536);
249
+ const manifest = JSON.parse(manifestBytes.toString("utf8"));
250
+ need(manifest.name === "@hasna/trash" && manifest.version === b.trash.version && manifest.bin?.trash === "dist/cli/index.js", "package_mismatch");
251
+ const cli = import_node_path.join(b.trash.root, "dist", "cli", "index.js");
252
+ checkPin({ path: cli, sha256: b.trash.cliSha256 }, 64 * 1024 * 1024, true);
253
+ const plan = JSON.parse(checkPin(b.plan, 65536).toString("utf8"));
254
+ 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");
255
+ const args = ["--config=/dev/null", "--no-env-file", "--no-macros", "--no-install", cli, "station", "check", "--plan", b.plan.path];
256
+ const remaining = () => Math.floor(b.timeoutMs - (performance.now() - started));
257
+ const timeout = remaining();
258
+ need(timeout > 0, "deadline_exceeded");
259
+ const child = await (d.spawn ?? runReadinessProcess)(b.runtime.path, args, {
260
+ cwd: home,
261
+ env: { HOME: home, PATH: plan.spec.path, LANG: "C", NO_COLOR: "1" },
262
+ timeout,
263
+ maxBuffer: 16384,
264
+ signal: d.signal
265
+ });
266
+ need(remaining() > 0, "deadline_exceeded");
267
+ if (child.error && "code" in child.error && child.error.code === "ETIMEDOUT")
268
+ return result("deadline_exceeded");
269
+ if (child.error || child.signal || child.status === null)
270
+ return result("execution_failed");
271
+ need(typeof child.stdout === "string" && typeof child.stderr === "string" && Buffer.byteLength(child.stdout) <= 16384 && Buffer.byteLength(child.stderr) <= 16384, "output_invalid");
272
+ let value;
273
+ try {
274
+ value = JSON.parse(child.stdout);
275
+ } catch {
276
+ return result("output_invalid");
277
+ }
278
+ if (child.status !== 0 || value?.configurationReady !== true) {
279
+ return result(safeFailureCodes.has(value?.code) ? value.code : "check_failed");
280
+ }
281
+ 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");
282
+ checkPin(b.plan, 65536);
283
+ checkPin({ path: cli, sha256: b.trash.cliSha256 }, 64 * 1024 * 1024, true);
284
+ checkPin(b.runtime, 200000000, true);
285
+ need(read(bindingPath, 65536).equals(bindingBytes) && read(manifestPath, 65536).equals(manifestBytes), "file_changed");
286
+ need(remaining() > 0, "deadline_exceeded");
287
+ return result("ready", { station: value.station, planDigest: value.planDigest });
288
+ } catch (error) {
289
+ return result(error instanceof ReadinessError ? error.code : phase === "binding" && error?.code === "ENOENT" ? "binding_missing" : "binding_invalid");
290
+ }
291
+ }
292
+ function readinessContext(result) {
293
+ 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.`;
294
+ }
295
+ function readinessSessionStartOutput(result) {
296
+ return { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: readinessContext(result) } };
297
+ }
298
+ async function handleReadinessSessionStart(provider, event, dependencies = {}) {
299
+ const input = event;
300
+ if (!object(input) || input.hook_event_name !== "SessionStart" || !["startup", "resume", "clear", "fork", "compact"].includes(input.source)) {
301
+ return readinessSessionStartOutput({ schema: "hasna.hooks.trash-readiness-result/v1", provider, ready: false, code: "input_invalid", nativeAdoptionVerified: false });
302
+ }
303
+ if (input.source === "compact")
304
+ return {};
305
+ return readinessSessionStartOutput(await runTrashReadiness(provider, dependencies));
306
+ }
307
+
308
+ // hooks/native-readiness-entry.ts
309
+ async function main() {
310
+ const provider = process.argv[2];
311
+ if (!isReadinessProvider(provider)) {
312
+ process.stderr.write(`Trash readiness needs attention (unsupported provider).
313
+ `);
314
+ process.exitCode = 1;
315
+ return;
316
+ }
317
+ const invalid = () => readinessSessionStartOutput({
318
+ schema: "hasna.hooks.trash-readiness-result/v1",
319
+ provider,
320
+ ready: false,
321
+ code: "input_invalid",
322
+ nativeAdoptionVerified: false
323
+ });
324
+ try {
325
+ const input = await new Promise((accept, reject) => {
326
+ const chunks = [];
327
+ let size = 0;
328
+ const finish = (error) => {
329
+ clearTimeout(timer);
330
+ process.stdin.pause();
331
+ process.stdin.removeAllListeners("data");
332
+ process.stdin.removeAllListeners("end");
333
+ process.stdin.removeAllListeners("error");
334
+ error ? reject(error) : accept(Buffer.concat(chunks));
335
+ };
336
+ const timer = setTimeout(() => finish(new Error), 1000);
337
+ process.stdin.on("data", (chunk) => {
338
+ const bytes = Buffer.from(chunk);
339
+ size += bytes.length;
340
+ if (size > 65536)
341
+ finish(new Error);
342
+ else
343
+ chunks.push(bytes);
344
+ });
345
+ process.stdin.once("end", () => finish());
346
+ process.stdin.once("error", () => finish(new Error));
347
+ });
348
+ process.stdout.write(JSON.stringify(await handleReadinessSessionStart(provider, JSON.parse(input.toString("utf8")))) + `
349
+ `);
350
+ } catch {
351
+ process.stdout.write(JSON.stringify(invalid()) + `
352
+ `);
353
+ }
354
+ }
355
+ main();
@@ -1303,7 +1303,7 @@ function segmentOperation(segment) {
1303
1303
  const trimmed = segment.trim();
1304
1304
  if (!trimmed)
1305
1305
  return "read";
1306
- if (/(?:^|\s)(?:rm|rmdir|unlink|shred|trash|rmtree|del)(?:\s|$)/.test(trimmed))
1306
+ if (/(?:^|\s)\\?(?:[^\s;&|<>()]*\/)?(?:rm|rmdir|unlink|shred|trash|rmtree|del)(?:\s|$)/.test(trimmed))
1307
1307
  return "delete";
1308
1308
  if (/\bgit\b/.test(trimmed)) {
1309
1309
  if (/\bgit\b[^;&|]*\b(?:clean|rm)\b/.test(trimmed))
@@ -1312,7 +1312,7 @@ function segmentOperation(segment) {
1312
1312
  return "write";
1313
1313
  return "read";
1314
1314
  }
1315
- if (/(?:^|\s)(?:mkdir|mkfile|touch|mv|cp|ln|tee|install|dd)(?:\s|$)/.test(trimmed))
1315
+ if (/(?:^|\s)\\?(?:[^\s;&|<>()]*\/)?(?:mkdir|mkfile|touch|mv|cp|ln|tee|install|dd)(?:\s|$)/.test(trimmed))
1316
1316
  return "write";
1317
1317
  if (/\b(?:curl|wget)\b/.test(trimmed)) {
1318
1318
  if (WRITE_FLAGS.test(trimmed))
@@ -1455,8 +1455,17 @@ function bashTargets(command, home, cwd) {
1455
1455
  if (foundExplicit || stage.op === "read")
1456
1456
  continue;
1457
1457
  const words = shellWords(stage.commandPart);
1458
+ let operandStart = 1;
1459
+ const tool = (words[0]?.text ?? "").split("/").pop();
1460
+ if (tool === "trash" && ["put", "rm", "delete", "remove"].includes(words[1]?.text ?? ""))
1461
+ operandStart = 2;
1462
+ if (tool === "git") {
1463
+ const sub = words.findIndex((word, index2) => index2 > 0 && (word.text === "rm" || word.text === "clean"));
1464
+ if (sub > 0)
1465
+ operandStart = sub + 1;
1466
+ }
1458
1467
  let endOfOptions = false;
1459
- const nonFlag = words.slice(1).flatMap((word) => {
1468
+ const nonFlag = words.slice(operandStart).flatMap((word) => {
1460
1469
  if (!endOfOptions && word.text === "--") {
1461
1470
  endOfOptions = true;
1462
1471
  return [];
@@ -1466,7 +1475,7 @@ function bashTargets(command, home, cwd) {
1466
1475
  endOfOptions = true;
1467
1476
  return [word.opaque ? "." : word.text];
1468
1477
  });
1469
- const everyOperand = stage.op === "delete" || /(?:^|\s)(?:mkdir|mkfile|touch|tee|truncate|install|mv)(?:\s|$)/.test(stage.commandPart) || /(?:^|\s)sed(?:\s|$)/.test(stage.commandPart) && /(?:^|\s)(?:-i\S*|--in-place\S*)(?:\s|$)/.test(stage.commandPart);
1478
+ const everyOperand = stage.op === "delete" || /(?:^|\s)\\?(?:[^\s;&|<>()]*\/)?(?:mkdir|mkfile|touch|tee|truncate|install|mv)(?:\s|$)/.test(stage.commandPart) || /(?:^|\s)sed(?:\s|$)/.test(stage.commandPart) && /(?:^|\s)(?:-i\S*|--in-place\S*)(?:\s|$)/.test(stage.commandPart);
1470
1479
  const operandTargets = [];
1471
1480
  if (everyOperand) {
1472
1481
  for (const operand of nonFlag.length > 0 ? nonFlag : ["."])