@hasna/hooks 0.10.7 → 0.10.9
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 +39 -1
- package/bin/hooks-mcp.js +52 -19
- package/bin/index.js +239 -80
- package/bin/native-safety-entry.js +207 -17
- package/dist/index.js +52 -19
- package/dist/lib/native-safety.d.ts +4 -0
- package/dist/native-safety.d.ts +6 -0
- package/dist/native-safety.js +18 -0
- package/hooks/hook-workspace-repos-guard/README.md +15 -0
- package/hooks/hook-workspace-repos-guard/src/hook.ts +258 -16
- package/package.json +2 -2
- package/scripts/ensure-profiles-dir.mjs +65 -1
|
@@ -32,6 +32,8 @@
|
|
|
32
32
|
* redirection operands) are resolved against the directory the segment runs
|
|
33
33
|
* in: the command's cwd, narrowed by a plain `cd` only across `&&` (see
|
|
34
34
|
* bashTargets). Descriptor duplication (`2>&1`, `>&2`) is not a write.
|
|
35
|
+
* Each pipeline stage is classified on its own, with its redirections split
|
|
36
|
+
* off, so a later read-only stage never supplies an earlier stage's target.
|
|
35
37
|
* apply_patch tools are inspected through their `*** Add File:` /
|
|
36
38
|
* `*** Update File:` / `*** Delete File:` markers. Parenthesized command
|
|
37
39
|
* groups are unwrapped.
|
|
@@ -215,6 +217,140 @@ function withoutFdDuplication(segment: string): string {
|
|
|
215
217
|
return segment.replace(FD_DUPLICATION, " ");
|
|
216
218
|
}
|
|
217
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Split one command segment into its pipeline stages at every unquoted `|`
|
|
222
|
+
* (and `|&`) that sits outside quotes, backticks, every parenthesis (`$( )`,
|
|
223
|
+
* `$(( ))`, `<( )`, `>( )`, `( )`) and every `${ }` expansion. The `|` of a
|
|
224
|
+
* `>|` clobber redirect is not a pipe, and `||` never reaches here because
|
|
225
|
+
* bashTargets splits on it first. When quoting, parentheses or braces do not
|
|
226
|
+
* balance, the segment is returned whole (fail closed to the whole-segment
|
|
227
|
+
* classification).
|
|
228
|
+
*/
|
|
229
|
+
export function pipelineStages(segment: string): string[] {
|
|
230
|
+
const stages: string[] = [];
|
|
231
|
+
let quote: "'" | '"' | null = null;
|
|
232
|
+
let parens = 0;
|
|
233
|
+
let braces = 0;
|
|
234
|
+
let backtick = false;
|
|
235
|
+
let start = 0;
|
|
236
|
+
for (let i = 0; i < segment.length; i++) {
|
|
237
|
+
const ch = segment[i];
|
|
238
|
+
if (quote === "'") {
|
|
239
|
+
if (ch === "'") quote = null;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (ch === "\\") {
|
|
243
|
+
i++;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (ch === "(") {
|
|
247
|
+
parens++;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (ch === ")") {
|
|
251
|
+
if (--parens < 0) return [segment];
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (ch === "$" && segment[i + 1] === "{") {
|
|
255
|
+
braces++;
|
|
256
|
+
i++;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (ch === "}" && braces > 0) {
|
|
260
|
+
braces--;
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (ch === "\x60") {
|
|
264
|
+
backtick = !backtick;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (quote === '"') {
|
|
268
|
+
if (ch === '"') quote = null;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (ch === "'" || ch === '"') {
|
|
272
|
+
quote = ch;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (ch !== "|" || parens > 0 || braces > 0 || backtick || segment[i - 1] === ">") continue;
|
|
276
|
+
stages.push(segment.slice(start, i));
|
|
277
|
+
if (segment[i + 1] === "&") i++;
|
|
278
|
+
start = i + 1;
|
|
279
|
+
}
|
|
280
|
+
if (quote || parens !== 0 || braces !== 0 || backtick) return [segment];
|
|
281
|
+
stages.push(segment.slice(start));
|
|
282
|
+
return stages;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
interface ShellWord {
|
|
286
|
+
text: string;
|
|
287
|
+
opaque: boolean;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Split a command into shell words, honouring single and double quotes and
|
|
292
|
+
* backslash escapes, and removing the quotes. A word is opaque when its
|
|
293
|
+
* value cannot be read statically: it holds an expansion other than a
|
|
294
|
+
* leading `$HOME`/`${HOME}`, a command substitution, a backtick, a glob or
|
|
295
|
+
* an unbalanced quote.
|
|
296
|
+
*/
|
|
297
|
+
function shellWords(command: string): ShellWord[] {
|
|
298
|
+
const words: ShellWord[] = [];
|
|
299
|
+
let text = "";
|
|
300
|
+
let raw = "";
|
|
301
|
+
let quote: "'" | '"' | null = null;
|
|
302
|
+
let inWord = false;
|
|
303
|
+
const flush = () => {
|
|
304
|
+
if (!inWord) return;
|
|
305
|
+
const body = raw.replace(/^"?\$\{?HOME\}?"?(?=\/|$)/, "");
|
|
306
|
+
// Bash expands `~` only when the whole tilde prefix (up to the first
|
|
307
|
+
// unquoted `/`) is unquoted and unescaped. `~"/x"`, `~\/x` and `~""`
|
|
308
|
+
// name an entry called `~` in the current directory.
|
|
309
|
+
const literalTilde = text.startsWith("~") && !/^~(?:\/|$)/.test(raw);
|
|
310
|
+
words.push({ text: literalTilde ? `./${text}` : text, opaque: /[$\x60*?[]/.test(body) || /[()]/.test(body) });
|
|
311
|
+
text = "";
|
|
312
|
+
raw = "";
|
|
313
|
+
inWord = false;
|
|
314
|
+
};
|
|
315
|
+
for (let i = 0; i < command.length; i++) {
|
|
316
|
+
const ch = command[i];
|
|
317
|
+
if (quote === "'") {
|
|
318
|
+
if (ch === "'") quote = null;
|
|
319
|
+
else text += ch;
|
|
320
|
+
raw += ch;
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
if (ch === "\\" && i + 1 < command.length) {
|
|
324
|
+
const next = command[i + 1];
|
|
325
|
+
// Inside double quotes a backslash escapes only $ ` " \ and newline.
|
|
326
|
+
text += quote === '"' && !/[$\x60"\\\n]/.test(next) ? ch + next : next;
|
|
327
|
+
raw += ch + next;
|
|
328
|
+
inWord = true;
|
|
329
|
+
i++;
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
if (quote === '"') {
|
|
333
|
+
if (ch === '"') quote = null;
|
|
334
|
+
else text += ch;
|
|
335
|
+
raw += ch;
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (/\s/.test(ch)) {
|
|
339
|
+
flush();
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
inWord = true;
|
|
343
|
+
raw += ch;
|
|
344
|
+
if (ch === "'" || ch === '"') quote = ch;
|
|
345
|
+
else text += ch;
|
|
346
|
+
}
|
|
347
|
+
if (quote) {
|
|
348
|
+
raw += "$";
|
|
349
|
+
}
|
|
350
|
+
flush();
|
|
351
|
+
return words;
|
|
352
|
+
}
|
|
353
|
+
|
|
218
354
|
/**
|
|
219
355
|
* Classify the operation of one command segment (a `&&`/`||`/`;`-delimited
|
|
220
356
|
* unit). Git is handled by its subcommand: clean|rm delete, clone|init write,
|
|
@@ -229,13 +365,13 @@ function segmentOperation(segment: string): Operation {
|
|
|
229
365
|
const trimmed = segment.trim();
|
|
230
366
|
if (!trimmed) return "read";
|
|
231
367
|
|
|
232
|
-
if (/(?:^|\s)(?:rm|rmdir|unlink|shred|trash|rmtree|del)(?:\s|$)/.test(trimmed)) return "delete";
|
|
368
|
+
if (/(?:^|\s)\\?(?:[^\s;&|<>()]*\/)?(?:rm|rmdir|unlink|shred|trash|rmtree|del)(?:\s|$)/.test(trimmed)) return "delete";
|
|
233
369
|
if (/\bgit\b/.test(trimmed)) {
|
|
234
370
|
if (/\bgit\b[^;&|]*\b(?:clean|rm)\b/.test(trimmed)) return "delete";
|
|
235
371
|
if (/\bgit\b[^;&|]*\b(?:clone|init)\b/.test(trimmed)) return "write";
|
|
236
372
|
return "read";
|
|
237
373
|
}
|
|
238
|
-
if (/(?:^|\s)(?:mkdir|mkfile|touch|mv|cp|ln|tee|install|dd)(?:\s|$)/.test(trimmed)) return "write";
|
|
374
|
+
if (/(?:^|\s)\\?(?:[^\s;&|<>()]*\/)?(?:mkdir|mkfile|touch|mv|cp|ln|tee|install|dd)(?:\s|$)/.test(trimmed)) return "write";
|
|
239
375
|
if (/\b(?:curl|wget)\b/.test(trimmed)) {
|
|
240
376
|
if (WRITE_FLAGS.test(trimmed)) return "write";
|
|
241
377
|
return "read";
|
|
@@ -364,7 +500,6 @@ export function bashTargets(command: string, home: string, cwd: string): PathTar
|
|
|
364
500
|
for (let i = 0; i < opens; i++) subshells.push({ current: [...current], reachable: new Set(reachable), certain });
|
|
365
501
|
|
|
366
502
|
const segment = unwrapSegment(rawSegment);
|
|
367
|
-
const op = segmentOperation(segment);
|
|
368
503
|
|
|
369
504
|
const plainCd = segment.match(/^cd(?:\s+(?:-[A-Za-z@]+|--))*(?:\s+([^\s;&|<>()\x60]+))?$/);
|
|
370
505
|
const embeddedCd = plainCd ? null : segment.match(/(?:^|\s)cd(?:\s+(?:-[A-Za-z]+|--))*\s+([^\s;&|<>()\x60]+)/);
|
|
@@ -380,27 +515,134 @@ export function bashTargets(command: string, home: string, cwd: string): PathTar
|
|
|
380
515
|
const suffix = roots.map((root) => regexEscape(root.slice(home.length + 1))).join("|");
|
|
381
516
|
const prefixRe = new RegExp(`(?:~|\\$HOME"*|\\$\\{HOME\\}"*|${homeLiteral}"*)/(?:${suffix})`);
|
|
382
517
|
const re = new RegExp(`(${prefixRe.source})([^\\s"';&|<>()\x60]*|$)`, "g");
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
}
|
|
518
|
+
const explicitTargets = (text: string): string[] => {
|
|
519
|
+
const found: string[] = [];
|
|
520
|
+
let m: RegExpExecArray | null;
|
|
521
|
+
re.lastIndex = 0;
|
|
522
|
+
while ((m = re.exec(text)) !== null) found.push(normalize(expandHomeSpelling(m[0], home)).replace(/\/+$/, ""));
|
|
523
|
+
return found;
|
|
524
|
+
};
|
|
390
525
|
|
|
391
|
-
|
|
526
|
+
// Each pipeline stage is classified on its own, with its output
|
|
527
|
+
// redirections split off: a redirect operand is a write on that operand
|
|
528
|
+
// only, so `ls <root> 2>/dev/null | head` reads <root>. A stage's relative
|
|
529
|
+
// trailing operand counts only when that stage's own command writes or
|
|
530
|
+
// deletes, so the last word of a later read-only stage (`| head -60`) is
|
|
531
|
+
// never taken as the write target of an earlier stage.
|
|
532
|
+
const stages = pipelineStages(segment).map((stage) => {
|
|
392
533
|
const operands: string[] = [];
|
|
393
|
-
|
|
534
|
+
// A redirect whose target cannot be read statically (empty, or built
|
|
535
|
+
// from `$...`/`$( )`/backticks) may write anywhere, including a
|
|
536
|
+
// protected path the stage names: the stage then counts as a write.
|
|
537
|
+
let opaqueRedirect = false;
|
|
538
|
+
const commandPart = withoutFdDuplication(stage).replace(OUTPUT_REDIRECT, (_match, operand: string) => {
|
|
539
|
+
if (!operand || /[$\x60]/.test(operand)) opaqueRedirect = true;
|
|
394
540
|
if (operand) operands.push(operand);
|
|
395
541
|
return " ";
|
|
396
542
|
});
|
|
397
543
|
const commandOp = segmentOperation(commandPart);
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
544
|
+
return { commandPart, operands, op: opaqueRedirect && commandOp === "read" ? ("write" as Operation) : commandOp };
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
// A protected path named in one stage can be acted on by another
|
|
548
|
+
// (`find <root>/x | xargs rm -rf`, `printf <root>/x | xargs mkdir`), so
|
|
549
|
+
// every explicit path outside a redirect operand takes the strongest
|
|
550
|
+
// operation of any stage's command. Only redirects are attributed to
|
|
551
|
+
// their own operand.
|
|
552
|
+
const pipelineOp: Operation = stages.some((stage) => stage.op === "delete")
|
|
553
|
+
? "delete"
|
|
554
|
+
: stages.some((stage) => stage.op === "write")
|
|
555
|
+
? "write"
|
|
556
|
+
: "read";
|
|
557
|
+
const bases = current.filter(underRoot);
|
|
558
|
+
for (const stage of stages) {
|
|
559
|
+
let foundExplicit = false;
|
|
560
|
+
for (const path of explicitTargets(stage.commandPart)) {
|
|
561
|
+
foundExplicit = true;
|
|
562
|
+
targets.push({ path, op: pipelineOp });
|
|
563
|
+
}
|
|
564
|
+
// Every redirect operand is a write: an explicit protected path as
|
|
565
|
+
// named, and any other operand resolved against each protected
|
|
566
|
+
// directory the stage can run in, whether or not the stage also names
|
|
567
|
+
// a protected path.
|
|
568
|
+
for (const operand of stage.operands) {
|
|
569
|
+
const explicit = explicitTargets(operand);
|
|
570
|
+
for (const path of explicit) targets.push({ path, op: "write" });
|
|
571
|
+
if (explicit.length > 0) continue;
|
|
572
|
+
for (const base of bases) {
|
|
401
573
|
targets.push({ path: normalize(resolve(base, expandHomeSpelling(operand, home))), op: "write" });
|
|
402
574
|
}
|
|
403
|
-
|
|
575
|
+
}
|
|
576
|
+
if (foundExplicit || stage.op === "read") continue;
|
|
577
|
+
// A writing or deleting stage that names no protected path acts on its
|
|
578
|
+
// trailing operand, resolved against each protected directory it can
|
|
579
|
+
// run in. When no operand can be read statically (quoted, `$( )`), it
|
|
580
|
+
// fails closed to that directory itself.
|
|
581
|
+
// Deletes, mkdir/touch, tee, truncate, install, mv and sed -i act on
|
|
582
|
+
// every operand, so each operand word counts; other writers (cp, ln,
|
|
583
|
+
// ...) act on their last one. A word that cannot be read statically
|
|
584
|
+
// fails closed to the directory.
|
|
585
|
+
const words = shellWords(stage.commandPart);
|
|
586
|
+
// Operands after the command word. Options end at `--` or, as BSD
|
|
587
|
+
// getopt does, at the first operand: every later word is an operand
|
|
588
|
+
// even when it starts with `-`.
|
|
589
|
+
// A delete tool's subcommand word is not an operand: `trash put <path>`
|
|
590
|
+
// and `git [-C dir] rm|clean ...` act on the words after it.
|
|
591
|
+
let operandStart = 1;
|
|
592
|
+
const tool = (words[0]?.text ?? "").split("/").pop();
|
|
593
|
+
if (tool === "trash" && ["put", "rm", "delete", "remove"].includes(words[1]?.text ?? "")) operandStart = 2;
|
|
594
|
+
if (tool === "git") {
|
|
595
|
+
const sub = words.findIndex((word, index) => index > 0 && (word.text === "rm" || word.text === "clean"));
|
|
596
|
+
if (sub > 0) operandStart = sub + 1;
|
|
597
|
+
}
|
|
598
|
+
let endOfOptions = false;
|
|
599
|
+
const nonFlag = words.slice(operandStart).flatMap((word) => {
|
|
600
|
+
if (!endOfOptions && word.text === "--") {
|
|
601
|
+
endOfOptions = true;
|
|
602
|
+
return [];
|
|
603
|
+
}
|
|
604
|
+
if (!endOfOptions && word.text.startsWith("-")) return [];
|
|
605
|
+
endOfOptions = true;
|
|
606
|
+
return [word.opaque ? "." : word.text];
|
|
607
|
+
});
|
|
608
|
+
// mv changes its sources too, so every mv operand counts as a write.
|
|
609
|
+
const everyOperand =
|
|
610
|
+
stage.op === "delete" ||
|
|
611
|
+
/(?:^|\s)\\?(?:[^\s;&|<>()]*\/)?(?:mkdir|mkfile|touch|tee|truncate|install|mv)(?:\s|$)/.test(stage.commandPart) ||
|
|
612
|
+
(/(?:^|\s)sed(?:\s|$)/.test(stage.commandPart) && /(?:^|\s)(?:-i\S*|--in-place\S*)(?:\s|$)/.test(stage.commandPart));
|
|
613
|
+
const operandTargets: Array<{ operand: string; op: Operation }> = [];
|
|
614
|
+
if (everyOperand) {
|
|
615
|
+
for (const operand of nonFlag.length > 0 ? nonFlag : ["."]) operandTargets.push({ operand, op: stage.op });
|
|
616
|
+
} else {
|
|
617
|
+
const relMatch = stage.commandPart.match(REL_OPERAND);
|
|
618
|
+
const last = words.at(-1);
|
|
619
|
+
operandTargets.push({ operand: relMatch ? relMatch[1] : !last || last.opaque ? "." : last.text, op: stage.op });
|
|
620
|
+
// cp/ln/install/mv -t DIR write into DIR: `-t DIR`, `-tDIR`, `-t` in
|
|
621
|
+
// a short-flag cluster (`-rt DIR`, `-rtDIR`) and any abbreviation of
|
|
622
|
+
// `--target-directory[=]DIR` (GNU).
|
|
623
|
+
if (/(?:^|\s)(?:cp|ln|install|mv)(?:\s|$)/.test(stage.commandPart)) {
|
|
624
|
+
words.forEach((word, index) => {
|
|
625
|
+
let value: ShellWord | undefined;
|
|
626
|
+
const short = word.text.match(/^-[A-Za-z]*?t(.*)$/);
|
|
627
|
+
const long = word.text.match(/^(--t[a-z-]*)(?:=(.*))?$/);
|
|
628
|
+
if (short && !word.text.startsWith("--")) {
|
|
629
|
+
value = short[1] ? { text: short[1], opaque: word.opaque } : words[index + 1];
|
|
630
|
+
} else if (long && "--target-directory".startsWith(long[1])) {
|
|
631
|
+
value = long[2] !== undefined ? { text: long[2], opaque: word.opaque } : words[index + 1];
|
|
632
|
+
}
|
|
633
|
+
if (value) operandTargets.push({ operand: value.opaque || !value.text ? "." : value.text, op: stage.op });
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
// An inline script (python3 -c, node -e, bun -e) can write anywhere
|
|
637
|
+
// relative to where it runs, whatever its arguments.
|
|
638
|
+
if (/(?:^|\s)(?:python3?|node|bun)\b[^;&|]*\s+-[ce](?:\s|$)/.test(stage.commandPart)) {
|
|
639
|
+
operandTargets.push({ operand: ".", op: stage.op });
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
for (const base of bases) {
|
|
643
|
+
for (const { operand, op } of operandTargets) {
|
|
644
|
+
targets.push({ path: normalize(resolve(base, expandHomeSpelling(operand, home))), op });
|
|
645
|
+
}
|
|
404
646
|
}
|
|
405
647
|
}
|
|
406
648
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hasna/hooks",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.9",
|
|
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": {
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"@hasna/contracts": "~1.2.1"
|
|
65
65
|
},
|
|
66
66
|
"dependencies": {
|
|
67
|
-
"@hasna/skills": "0.9.
|
|
67
|
+
"@hasna/skills": "0.9.17",
|
|
68
68
|
"@hasna/events": "^0.1.16",
|
|
69
69
|
"@hasna/secrets": "0.4.2",
|
|
70
70
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
@@ -5,7 +5,13 @@
|
|
|
5
5
|
// resolver data root once adopted (HASNA_DATA_HOME set or hooks.db already
|
|
6
6
|
// present there), then the legacy ~/.hasna/hooks default. Best-effort — the
|
|
7
7
|
// package must never fail to install because this script cannot run.
|
|
8
|
+
//
|
|
9
|
+
// It also provisions the required Sumi native-safety binding for the installing
|
|
10
|
+
// user (see the second block below). Same best-effort contract.
|
|
8
11
|
import { existsSync, mkdirSync } from "node:fs";
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { dirname } from "node:path";
|
|
9
15
|
// --- Local path resolver -------------------------------------------------
|
|
10
16
|
// @hasna/paths was deleted (hasna/apps#1535, 2026-09-03); this in-package
|
|
11
17
|
// implementation preserves the resolver contract (XDG / macOS home layout
|
|
@@ -58,7 +64,7 @@ function dataDir(options) {
|
|
|
58
64
|
return pathsResolverResolve("data", options);
|
|
59
65
|
}
|
|
60
66
|
import { homedir } from "node:os";
|
|
61
|
-
import { join, resolve } from "node:path";
|
|
67
|
+
import { join, resolve, sep } from "node:path";
|
|
62
68
|
|
|
63
69
|
const HOME = process.env.HOME || process.env.USERPROFILE || homedir();
|
|
64
70
|
|
|
@@ -97,3 +103,61 @@ try {
|
|
|
97
103
|
} catch {
|
|
98
104
|
// ignore
|
|
99
105
|
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The bun runtime that must execute the provisioning entry: the package's own
|
|
109
|
+
* bin targets are bun bundles (`#!/usr/bin/env bun`) and `installedNativeSafetyCommand`
|
|
110
|
+
* pins `process.execPath`, so the runtime that runs this decides the binding.
|
|
111
|
+
* Well-known install locations first, then PATH; null when neither exists.
|
|
112
|
+
*/
|
|
113
|
+
function resolveBun() {
|
|
114
|
+
const wellKnown = [
|
|
115
|
+
process.env.BUN_INSTALL ? join(process.env.BUN_INSTALL, "bin", "bun") : null,
|
|
116
|
+
join(HOME, ".bun", "bin", "bun"),
|
|
117
|
+
];
|
|
118
|
+
for (const candidate of wellKnown) {
|
|
119
|
+
if (candidate && existsSync(candidate)) return candidate;
|
|
120
|
+
}
|
|
121
|
+
return spawnSync("bun", ["--version"], { encoding: "utf8" }).status === 0 ? "bun" : null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Required Sumi native-safety binding (hasna/apps incidents 798053).
|
|
126
|
+
//
|
|
127
|
+
// Sumi refuses EVERY shell command while
|
|
128
|
+
// `~/.hasna/hooks/native/sumi-trash-guard.json` is absent, and neither a package
|
|
129
|
+
// install nor an upgrade used to create it: a rollout could install this package
|
|
130
|
+
// and still leave every Sumi seat on the station without a shell.
|
|
131
|
+
//
|
|
132
|
+
// The packaged CLI owns the policy (`hooks safety provision`, also applied by
|
|
133
|
+
// `hooks install` and `hooks upgrade`): idempotent, a healthy existing binding is
|
|
134
|
+
// left intact, a stale one is replaced with its predecessor preserved beside it,
|
|
135
|
+
// and an unrecognized one is refused rather than overwritten. This lifecycle
|
|
136
|
+
// surface only reaches installs whose scripts actually run — bun blocks
|
|
137
|
+
// lifecycle scripts for untrusted dependencies and `--ignore-scripts` skips
|
|
138
|
+
// them, which is why the CLI surfaces apply the same policy.
|
|
139
|
+
//
|
|
140
|
+
// Two gates keep a source checkout from touching the operator's home: the
|
|
141
|
+
// package must be an INSTALLED copy (inside a node_modules directory) and its
|
|
142
|
+
// built CLI must exist. Best-effort — install never fails on a native guard.
|
|
143
|
+
try {
|
|
144
|
+
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
145
|
+
const cli = join(packageRoot, "bin", "index.js");
|
|
146
|
+
if (packageRoot.includes(`${sep}node_modules${sep}`) && existsSync(cli)) {
|
|
147
|
+
const bun = resolveBun();
|
|
148
|
+
if (!bun) {
|
|
149
|
+
process.stderr.write("[@hasna/hooks] Sumi native-safety binding not provisioned: no bun runtime found; run `hooks safety provision`.\n");
|
|
150
|
+
} else {
|
|
151
|
+
const provisioned = spawnSync(bun, [cli, "safety", "provision", "--target", "sumi"], {
|
|
152
|
+
encoding: "utf8", timeout: 60_000, env: { ...process.env, HOME },
|
|
153
|
+
});
|
|
154
|
+
let result = null;
|
|
155
|
+
try { result = JSON.parse(provisioned.stdout?.trim() ?? ""); } catch { /* reported below */ }
|
|
156
|
+
if (!result?.ok) {
|
|
157
|
+
process.stderr.write(`[@hasna/hooks] Sumi native-safety binding not provisioned (${result?.code ?? "provision_failed"}); run \`hooks safety provision\` on this station before a Sumi seat needs its shell.\n`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
} catch {
|
|
162
|
+
// ignore
|
|
163
|
+
}
|