@hasna/hooks 0.10.1 → 0.10.3

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,8 @@
1
+ /** The owning Skills writer commits Claude settings and discovery together.
2
+ * A missing policy permits ordinary standalone installation. Any other refusal
3
+ * propagates before settings are written; it never selects a fallback writer.
4
+ */
5
+ export declare function coordinateClaudeSettings(before: string | null, replacement: string, options: {
6
+ home: string;
7
+ dataDir?: string;
8
+ }): boolean;
package/dist/sdk/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // @bun
2
- // ../contracts/dist/client/transport.js
2
+ // ../../node_modules/.bun/@hasna+contracts@1.2.2+944b86d8024e79fa/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";
@@ -32,17 +32,39 @@ Home spellings (`~`, `$HOME`, `${HOME}`, quoted or not, including split-quote
32
32
  forms like `"$HOME"/.hasna/repos/clones`, which Bash treats identically to the
33
33
  unquoted spelling) are expanded before classification; `apply_patch` tools
34
34
  are inspected through their `Add File` / `Update File` / `Delete File`
35
- markers; Bash relative operands are resolved against the command's cwd when
36
- it sits under the clones root; parenthesized command groups
37
- (`(cd ... && rm -rf ...)`) are unwrapped.
35
+ markers; parenthesized command groups (`(cd ... && rm -rf ...)`) are
36
+ unwrapped.
37
+
38
+ Bash relative operands (a trailing bare name, `.`, `..`, and the operand of
39
+ an output redirection such as `> file` or `>> file`) are resolved against the
40
+ directory the command segment runs in, and only count when that directory is
41
+ under a protected root. That directory starts as the command's cwd. A plain
42
+ `cd DIR` moves it for the segments joined to it by `&&`, so
43
+ `cd /tmp && printf x > out` is not attributed to the session cwd. After `;`,
44
+ a newline or `||` the `cd` may have failed, so the earlier directories stay
45
+ in scope. A `cd` inside `( ... )`, a pipeline or the background does not move
46
+ it, and neither does a `cd` whose target cannot be resolved statically.
47
+ Descriptor duplication (`2>&1`, `>&2`, `1>&-`) is not a write.
38
48
 
39
49
  ## Configuration
40
50
 
41
- Allowed orgs default to `hasna,hasnaxyz,hasna-products` and are overridable
42
- with the `WORKSPACE_REPOS_GUARD_ORGS` env var (comma-separated). Private
43
- workspace orgs must be added per-install via that var; they are never part
44
- of the public default. The home directory is resolved with `os.homedir()` —
45
- never hardcoded.
51
+ Allowed orgs default to `hasna,hasnaxyz,hasna-products`. Private workspace
52
+ orgs must be added per-install; they are never part of the public default.
53
+ There are two per-install routes, checked in this order:
54
+
55
+ 1. The `WORKSPACE_REPOS_GUARD_ORGS` env var (comma-separated), for installs
56
+ that run the hook with the harness environment (`hooks run`).
57
+ 2. The file `$HOME/.hasna/hooks/config/workspace-repos-guard-orgs`. The
58
+ native safety registration (`hooks safety install`) runs the guard with
59
+ only `HOME` and `PATH`, so the env var never reaches it and this file is
60
+ the route that does. List the org names separated by commas or whitespace;
61
+ `#` starts a comment. The list replaces the default, so include the
62
+ default orgs you still need. The file and every directory from it up to
63
+ `HOME` must be owned by the user or root and not writable by group or
64
+ others. A missing, untrusted or malformed file is ignored and the default
65
+ applies.
66
+
67
+ The home directory is resolved with `os.homedir()` — never hardcoded.
46
68
 
47
69
  ## Failure mode
48
70
 
@@ -28,22 +28,28 @@
28
28
  *
29
29
  * Home spellings (~, $HOME, ${HOME}, including quoted forms) are expanded
30
30
  * before classification in Bash targets, file-tool paths, cd operands and
31
- * apply_patch file markers. Bash relative operands (`.`, `..`, bare names)
32
- * are resolved against the command's cwd when it sits under the repos root,
33
- * and against an explicit `cd` into the root when one is present. apply_patch
34
- * tools are inspected through their `*** Add File:` / `*** Update File:` /
35
- * `*** Delete File:` markers. Parenthesized command groups are unwrapped.
31
+ * apply_patch file markers. Bash relative operands (`.`, `..`, bare names,
32
+ * redirection operands) are resolved against the directory the segment runs
33
+ * in: the command's cwd, narrowed by a plain `cd` only across `&&` (see
34
+ * bashTargets). Descriptor duplication (`2>&1`, `>&2`) is not a write.
35
+ * apply_patch tools are inspected through their `*** Add File:` /
36
+ * `*** Update File:` / `*** Delete File:` markers. Parenthesized command
37
+ * groups are unwrapped.
36
38
  *
37
39
  * Allowed orgs default to hasna,hasnaxyz,hasna-products and are overridable
38
- * with the WORKSPACE_REPOS_GUARD_ORGS env var (comma-separated). Private
39
- * workspace orgs must be added per-install via that var; they are never part
40
+ * with the WORKSPACE_REPOS_GUARD_ORGS env var (comma-separated) or, where
41
+ * that var cannot reach the guard (the native safety supervisor passes only
42
+ * HOME and PATH), the per-install file
43
+ * $HOME/.hasna/hooks/config/workspace-repos-guard-orgs. Private workspace
44
+ * orgs must be added per-install through one of those; they are never part
40
45
  * of the public default.
41
46
  * Home is resolved with os.homedir(); never hardcoded. Fail-open on any parse
42
47
  * or evaluation error so a guard defect cannot wedge the agent.
43
48
  */
44
49
 
50
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, statSync } from "fs";
45
51
  import { homedir } from "os";
46
- import { isAbsolute, join, normalize, relative, resolve, sep } from "path";
52
+ import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from "path";
47
53
  import {
48
54
  getCommand,
49
55
  readInput,
@@ -78,15 +84,67 @@ function protectedRoots(home: string): string[] {
78
84
  return [reposRoot(home), legacyRoot(home)];
79
85
  }
80
86
 
81
- export function resolveAllowedOrgs(env: NodeJS.ProcessEnv = process.env): Set<string> {
87
+ /**
88
+ * Per-install allowed-orgs file, read from the guard's own HOME. The native
89
+ * safety supervisor runs this guard with only HOME and PATH, so the
90
+ * WORKSPACE_REPOS_GUARD_ORGS override never reaches it there; this file is the
91
+ * per-install route that does. Org names are separated by commas or
92
+ * whitespace and `#` starts a comment. The file and every directory from it
93
+ * up to HOME must be owned by the user or root and not writable by group or
94
+ * others (the supervisor's own bundle rule). A missing, untrusted, oversized
95
+ * or malformed file is ignored, which leaves the narrower default list.
96
+ */
97
+ export function allowedOrgsFile(home: string): string {
98
+ return join(home, ".hasna", "hooks", "config", "workspace-repos-guard-orgs");
99
+ }
100
+
101
+ const ORG_NAME = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/;
102
+
103
+ function ownedAndClosed(stat: { uid: number; mode: number }): boolean {
104
+ const uid = process.getuid?.();
105
+ return uid !== undefined && (stat.uid === 0 || stat.uid === uid) && (stat.mode & 0o022) === 0;
106
+ }
107
+
108
+ function readAllowedOrgsFile(home: string): Set<string> | null {
109
+ const file = allowedOrgsFile(home);
110
+ let text: string;
111
+ try {
112
+ const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
113
+ try {
114
+ const stat = fstatSync(fd);
115
+ if (!stat.isFile() || !ownedAndClosed(stat) || stat.size > 4096) return null;
116
+ text = readFileSync(fd, "utf8");
117
+ } finally {
118
+ closeSync(fd);
119
+ }
120
+ const top = normalize(home);
121
+ for (let dir = dirname(file); dir !== top; dir = dirname(dir)) {
122
+ const stat = lstatSync(dir);
123
+ if (!stat.isDirectory() || !ownedAndClosed(stat) || dir === dirname(dir)) return null;
124
+ }
125
+ if (!ownedAndClosed(statSync(top))) return null;
126
+ } catch {
127
+ return null;
128
+ }
129
+ const names = text.replace(/#[^\n]*/g, "").split(/[\s,]+/).filter(Boolean);
130
+ return names.length > 0 && names.every((name) => ORG_NAME.test(name)) ? new Set(names) : null;
131
+ }
132
+
133
+ /**
134
+ * Allowed orgs: the WORKSPACE_REPOS_GUARD_ORGS env var when set, else the
135
+ * per-install file under `home`, else the public default.
136
+ */
137
+ export function resolveAllowedOrgs(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): Set<string> {
82
138
  const raw = env.WORKSPACE_REPOS_GUARD_ORGS;
83
- if (typeof raw !== "string" || !raw.trim()) return new Set(DEFAULT_ORGS);
84
- return new Set(
85
- raw
86
- .split(",")
87
- .map((s) => s.trim())
88
- .filter(Boolean)
89
- );
139
+ if (typeof raw === "string" && raw.trim()) {
140
+ return new Set(
141
+ raw
142
+ .split(",")
143
+ .map((s) => s.trim())
144
+ .filter(Boolean)
145
+ );
146
+ }
147
+ return readAllowedOrgsFile(home) ?? new Set(DEFAULT_ORGS);
90
148
  }
91
149
 
92
150
  type Operation = "read" | "write" | "delete";
@@ -139,6 +197,24 @@ export function classifyPath(target: string, root: string, orgs: Set<string>, op
139
197
 
140
198
  const WRITE_FLAGS = /\b(?:-o|--output|-O|--output-document|-out)\b/;
141
199
 
200
+ /**
201
+ * Descriptor duplication or close (`2>&1`, `>&2`, `1>&-`, `0<&3`). It only
202
+ * rewires descriptors that are already open and names no file, so it is
203
+ * never a write.
204
+ */
205
+ const FD_DUPLICATION = /\d*[<>]&\s*(?:\d+|-)(?=$|[\s;&|)])/g;
206
+
207
+ /**
208
+ * An output redirection and its file operand: `>`, `>>`, `>|`, `&>`, `&>>`,
209
+ * `<>` and `>&word`, optionally fd-prefixed (`2>`, `2>>`). Apply it only after
210
+ * FD_DUPLICATION is removed, so a remaining `>&word` names a file.
211
+ */
212
+ const OUTPUT_REDIRECT = /(?:\d+|&)?(?:>>|>\||>&|<>|>)\s*([^\s;&|<>()\x60]*)/g;
213
+
214
+ function withoutFdDuplication(segment: string): string {
215
+ return segment.replace(FD_DUPLICATION, " ");
216
+ }
217
+
142
218
  /**
143
219
  * Classify the operation of one command segment (a `&&`/`||`/`;`-delimited
144
220
  * unit). Git is handled by its subcommand: clean|rm delete, clone|init write,
@@ -146,7 +222,8 @@ const WRITE_FLAGS = /\b(?:-o|--output|-O|--output-document|-out)\b/;
146
222
  * in-place flag is a stream filter (redirection is caught separately);
147
223
  * rsync/scp/truncate and inline interpreters (python3 -c, node -e, bun -e)
148
224
  * are treated as writes — conservative: they only matter when a path under
149
- * the protected root is also present.
225
+ * the protected root is also present. An output redirection is a write;
226
+ * descriptor duplication (`2>&1`) and `&` are not.
150
227
  */
151
228
  function segmentOperation(segment: string): Operation {
152
229
  const trimmed = segment.trim();
@@ -171,7 +248,7 @@ function segmentOperation(segment: string): Operation {
171
248
  } else if (/(?:^|\s)(?:python3?|node|bun)\b[^;&|]*\s+-[ce](?:\s|$)/.test(trimmed)) {
172
249
  return "write";
173
250
  }
174
- if (/(?:^|[;&|])\s*echo\b/.test(trimmed) || /[>&]/.test(trimmed)) return "write";
251
+ if (/(?:^|[;&|])\s*echo\b/.test(trimmed) || />/.test(withoutFdDuplication(trimmed))) return "write";
175
252
  return "read";
176
253
  }
177
254
 
@@ -224,34 +301,79 @@ function regexEscape(text: string): string {
224
301
  return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
225
302
  }
226
303
 
304
+ /**
305
+ * Directories a `cd` operand leads to from each candidate cwd, or null when
306
+ * the operand cannot be resolved statically (`cd -`, variables, globs,
307
+ * command substitution). A bare `cd` goes home.
308
+ */
309
+ function cdDestinations(operand: string | undefined, bases: string[], home: string): string[] | null {
310
+ if (operand === undefined) return [home];
311
+ let word = expandHomeSpelling(operand, home);
312
+ const quoted = word.match(/^(['"])(.*)\1$/);
313
+ if (quoted) word = quoted[2];
314
+ if (!word || word === "-" || /[$\x60*?[\]{}~\\'"]/.test(word)) return null;
315
+ return [...new Set(bases.map((base) => resolve(base, word)))];
316
+ }
317
+
318
+ const REL_OPERAND = /(?:^|\s)(\.\.?|[^\s"';&|<>()\x60/]+(?:\/[^\s"';&|<>()\x60]*)?)\s*$/;
319
+
227
320
  /**
228
321
  * Extract protected-repo-checkout targets from a Bash command. Recognises
229
322
  * explicit `~/...`, `$HOME/...`, `${HOME}/...` and literal-home references
230
323
  * (expanded before classification) under EITHER protected root (the canonical
231
- * clones root or the legacy workspace/repos root), plus a trailing relative
232
- * operand (`.`, `..`, a bare name) when the command `cd`s into one of the
233
- * protected roots or the command's cwd already sits under one. Tokens outside
234
- * both protected roots are ignored; reads are returned so callers can decide
235
- * (they are never blocked).
324
+ * clones root or the legacy workspace/repos root). When a writing or deleting
325
+ * segment names no protected path, its relative operands are resolved against
326
+ * every directory the segment can run in, and count only when that directory
327
+ * sits under a protected root: the redirection operands (`> file`), plus the
328
+ * trailing operand (`.`, `..`, a bare name) when the command itself writes or
329
+ * deletes. Tokens outside both protected roots are ignored; reads are
330
+ * returned so callers can decide (they are never blocked).
331
+ *
332
+ * The directory a segment runs in starts as the command's cwd. A plain
333
+ * `cd DIR` narrows it for the segments after it only while they are joined
334
+ * by `&&` from the start of their and-or list, because only then do they run
335
+ * after a successful cd. After `;`, a newline or `||` the cd may have failed,
336
+ * so every directory reached so far stays in scope; a cd inside `( ... )`, a
337
+ * pipeline or the background does not change the outer cwd, and a cd that
338
+ * cannot be resolved statically never narrows it.
236
339
  */
237
340
  export function bashTargets(command: string, home: string, cwd: string): PathTarget[] {
238
341
  if (!command) return [];
239
342
  const roots = protectedRoots(home);
240
- const segments = command.split(/\s*&&\s*|\s*\|\|\s*|;\s*|\n+/);
343
+ const underRoot = (path: string) => roots.some((root) => path === root || path.startsWith(`${root}${sep}`));
344
+ const parts = command.split(/(\s*&&\s*|\s*\|\|\s*|;\s*|\n+)/);
241
345
 
242
346
  const targets: PathTarget[] = [];
243
- let cwdUnderRepos: string | null = null;
347
+ // Every directory the current segment can run in, every directory any
348
+ // segment so far can have left the shell in, and whether the current and-or
349
+ // list has only been joined by `&&` so far.
350
+ let current = [cwd];
351
+ let reachable = new Set([cwd]);
352
+ let certain = true;
353
+ const subshells: Array<{ current: string[]; reachable: Set<string>; certain: boolean }> = [];
354
+
355
+ for (let index = 0; index < parts.length; index += 2) {
356
+ const separator = index === 0 ? "" : parts[index - 1].trim();
357
+ if (separator === "||") certain = false;
358
+ else if (separator !== "&&") certain = true;
359
+ if (separator !== "&&" || !certain) current = [...reachable];
360
+
361
+ const rawSegment = parts[index];
362
+ const opens = (rawSegment.match(/^[\s({]*/)?.[0].match(/\(/g) ?? []).length;
363
+ const closes = (rawSegment.match(/[\s)}]*$/)?.[0].match(/\)/g) ?? []).length;
364
+ for (let i = 0; i < opens; i++) subshells.push({ current: [...current], reachable: new Set(reachable), certain });
244
365
 
245
- for (const rawSegment of segments) {
246
366
  const segment = unwrapSegment(rawSegment);
247
367
  const op = segmentOperation(segment);
248
368
 
249
- const cdMatch = segment.match(/(?:^|\s)cd(?:\s+(?:-[A-Za-z]+|--))*\s+([^\s;&|<>()\x60]+)/);
250
- if (cdMatch) {
251
- const cdTarget = expandHomeSpelling(cdMatch[1], home);
252
- if (cdTarget && roots.some((root) => cdTarget === root || cdTarget.startsWith(`${root}${sep}`))) {
253
- cwdUnderRepos = cdTarget;
254
- }
369
+ const plainCd = segment.match(/^cd(?:\s+(?:-[A-Za-z@]+|--))*(?:\s+([^\s;&|<>()\x60]+))?$/);
370
+ const embeddedCd = plainCd ? null : segment.match(/(?:^|\s)cd(?:\s+(?:-[A-Za-z]+|--))*\s+([^\s;&|<>()\x60]+)/);
371
+ if (embeddedCd) {
372
+ // A cd sharing its segment (pipeline, background, compound command) may
373
+ // or may not have moved the shell: widen, never narrow.
374
+ for (const dir of cdDestinations(embeddedCd[1], current, home) ?? []) reachable.add(dir);
375
+ certain = false;
376
+ current = [...reachable];
255
377
  }
256
378
 
257
379
  const homeLiteral = regexEscape(home);
@@ -267,16 +389,32 @@ export function bashTargets(command: string, home: string, cwd: string): PathTar
267
389
  }
268
390
 
269
391
  if (!foundExplicit && (op === "delete" || op === "write")) {
270
- const base =
271
- cwdUnderRepos ??
272
- (roots.some((root) => cwd === root || cwd.startsWith(`${root}${sep}`)) ? cwd : null);
273
- if (base) {
274
- const relMatch = segment.match(/(?:^|\s)(\.\.?|[^\s"';&|<>()\x60/]+(?:\/[^\s"';&|<>()\x60]*)?)\s*$/);
275
- if (relMatch) {
276
- targets.push({ path: normalize(resolve(base, relMatch[1])), op });
392
+ const operands: string[] = [];
393
+ const commandPart = withoutFdDuplication(segment).replace(OUTPUT_REDIRECT, (_match, operand: string) => {
394
+ if (operand) operands.push(operand);
395
+ return " ";
396
+ });
397
+ 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) {
401
+ targets.push({ path: normalize(resolve(base, expandHomeSpelling(operand, home))), op: "write" });
277
402
  }
403
+ if (relMatch) targets.push({ path: normalize(resolve(base, relMatch[1])), op: commandOp });
278
404
  }
279
405
  }
406
+
407
+ if (plainCd) {
408
+ const next = cdDestinations(plainCd[1], current, home);
409
+ if (next) {
410
+ for (const dir of next) reachable.add(dir);
411
+ if (certain) current = next;
412
+ } else {
413
+ certain = false;
414
+ }
415
+ }
416
+
417
+ for (let i = 0; i < closes && subshells.length > 0; i++) ({ current, reachable, certain } = subshells.pop()!);
280
418
  }
281
419
 
282
420
  return targets;
@@ -299,14 +437,15 @@ export function patchTargets(patch: string, cwd: string): PathTarget[] {
299
437
  return targets;
300
438
  }
301
439
 
302
- export function evaluate(input: CodewithHookInput): { output: CodewithHookOutput; warnings: string[] } {
440
+ /** `options.home` defaults to os.homedir(); tests pass a private home. */
441
+ export function evaluate(input: CodewithHookInput, options: { home?: string } = {}): { output: CodewithHookOutput; warnings: string[] } {
303
442
  const warnings: string[] = [];
304
443
  if (input.hook_event_name !== "PreToolUse") return { output: { continue: true }, warnings };
305
444
 
306
445
  const tool = typeof input.tool_name === "string" ? input.tool_name : "";
307
- const home = homedir();
446
+ const home = options.home ?? homedir();
308
447
  const roots = protectedRoots(home);
309
- const orgs = resolveAllowedOrgs();
448
+ const orgs = resolveAllowedOrgs(process.env, home);
310
449
 
311
450
  if (tool === "Bash") {
312
451
  const command = getCommand(input);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/hooks",
3
- "version": "0.10.1",
3
+ "version": "0.10.3",
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": {
@@ -60,6 +60,7 @@
60
60
  "@hasna/contracts": "~1.2.1"
61
61
  },
62
62
  "dependencies": {
63
+ "@hasna/skills": "0.9.14",
63
64
  "@hasna/events": "^0.1.16",
64
65
  "@hasna/secrets": "0.4.2",
65
66
  "@modelcontextprotocol/sdk": "^1.26.0",