@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.
@@ -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
- let m: RegExpExecArray | null;
384
- let foundExplicit = false;
385
- while ((m = re.exec(segment)) !== null) {
386
- foundExplicit = true;
387
- const expanded = expandHomeSpelling(m[0], home);
388
- targets.push({ path: normalize(expanded).replace(/\/+$/, ""), op });
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
- if (!foundExplicit && (op === "delete" || op === "write")) {
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
- const commandPart = withoutFdDuplication(segment).replace(OUTPUT_REDIRECT, (_match, operand: string) => {
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
- const relMatch = commandOp === "read" ? null : commandPart.match(REL_OPERAND);
399
- for (const base of current.filter(underRoot)) {
400
- for (const operand of operands) {
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
- if (relMatch) targets.push({ path: normalize(resolve(base, relMatch[1])), op: commandOp });
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.7",
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.14",
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
+ }