@hasna/hooks 0.10.6 → 0.10.8

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,32 @@
1
+ import { verifyNativeSafetyCommand } from "../native-safety.js";
2
+ export declare class ClaudeSafetyError extends Error {
3
+ readonly code: "settings_unsafe" | "settings_invalid" | "settings_changed" | "hooks_disabled" | "guard_missing" | "guard_ambiguous" | "guard_definition_invalid" | "guard_failed";
4
+ constructor(code: "settings_unsafe" | "settings_invalid" | "settings_changed" | "hooks_disabled" | "guard_missing" | "guard_ambiguous" | "guard_definition_invalid" | "guard_failed");
5
+ }
6
+ export interface ClaudeSafetyOptions {
7
+ home?: string;
8
+ cwd?: string;
9
+ /** Exact user settings path, for the native CLAUDE_CONFIG_DIR override. */
10
+ userSettingsPath?: string;
11
+ /** An already-resolved main-checkout local file may be outside cwd. */
12
+ localSettingsPath?: string;
13
+ }
14
+ /** This does not claim to reproduce Claude's MDM/server/CLI policy merge.
15
+ * It checks these selected native settings sources and their exact bytes. */
16
+ export declare function verifyClaudeSafetyConfiguration(options?: ClaudeSafetyOptions, dependencies?: {
17
+ verify?: typeof verifyNativeSafetyCommand;
18
+ }): Promise<{
19
+ ok: true;
20
+ target: "claude";
21
+ cwd: string;
22
+ configurationVerified: boolean;
23
+ guardVerified: boolean;
24
+ sources: {
25
+ path: string;
26
+ sha256: string | null;
27
+ }[];
28
+ guardSHA256: string;
29
+ runtimePolicyVerified: boolean;
30
+ nativeDiscoveryVerified: boolean;
31
+ nativeAdoptionVerified: boolean;
32
+ }>;
@@ -0,0 +1,58 @@
1
+ import { verifyNativeSafetyCommand } from "../native-safety.js";
2
+ export type CodexSafetyCode = "native_unavailable" | "native_unsafe_executable" | "native_executable_required" | "native_unsupported" | "native_failed" | "native_discovery_failed" | "hooks_disabled" | "guard_missing" | "guard_ambiguous" | "guard_definition_invalid" | "guard_disabled" | "guard_untrusted" | "guard_changed" | "guard_failed";
3
+ export declare class CodexSafetyError extends Error {
4
+ readonly code: CodexSafetyCode;
5
+ constructor(code: CodexSafetyCode);
6
+ }
7
+ export interface CodexSafetyRpc {
8
+ version: string;
9
+ request(method: "hooks/list" | "config/read" | "experimentalFeature/list" | "config/batchWrite", params: unknown): Promise<unknown>;
10
+ close(): Promise<void>;
11
+ }
12
+ export type CodexSafetyOptions = {
13
+ command?: string;
14
+ home?: string;
15
+ cwd?: string;
16
+ codexHome?: string;
17
+ };
18
+ /** Owns only its fresh app-server subprocess. It never reloads a running agent,
19
+ * enrolls trust, starts a model turn or forwards native diagnostic text. */
20
+ export declare function connectCodexSafety(options: Required<Pick<CodexSafetyOptions, "home">> & CodexSafetyOptions): Promise<CodexSafetyRpc>;
21
+ type NativeGuard = {
22
+ key: string;
23
+ command: string;
24
+ currentHash: string;
25
+ sourcePath: string;
26
+ enabled: boolean;
27
+ trustStatus: string;
28
+ };
29
+ /** Select only the exact packaged composite definition in native discovery.
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;
32
+ export declare function readCodexSafety(rpc: CodexSafetyRpc, home: string, cwd: string, options?: {
33
+ includeLayers?: boolean;
34
+ requireReady?: boolean;
35
+ }): Promise<{
36
+ guard: NativeGuard;
37
+ discovery: unknown;
38
+ configuration: unknown;
39
+ }>;
40
+ /** Two native observations surround stateless guard classification. No requested
41
+ * file operation is executed and no Hooks registry credential is consulted. */
42
+ export declare function verifyCodexNativeSafety(options?: CodexSafetyOptions, dependencies?: {
43
+ connect?: typeof connectCodexSafety;
44
+ verify?: typeof verifyNativeSafetyCommand;
45
+ }): Promise<{
46
+ ok: true;
47
+ target: "codex";
48
+ nativeVersion: string;
49
+ cwd: string;
50
+ key: string;
51
+ currentHash: string;
52
+ enabled: boolean;
53
+ trusted: boolean;
54
+ guardVerified: boolean;
55
+ nativeDiscoveryVerified: boolean;
56
+ nativeAdoptionVerified: boolean;
57
+ }>;
58
+ export {};
@@ -0,0 +1,54 @@
1
+ import { connectCodexSafety, type CodexSafetyOptions } from "./codex-safety-check.js";
2
+ import { verifyNativeSafetyCommand } from "../native-safety.js";
3
+ export declare class CodexTrustError extends Error {
4
+ readonly code: "input_invalid" | "native_changed" | "native_config_invalid" | "preimage_unsafe" | "trust_outcome_uncertain";
5
+ readonly operationId?: string | undefined;
6
+ constructor(code: "input_invalid" | "native_changed" | "native_config_invalid" | "preimage_unsafe" | "trust_outcome_uncertain", operationId?: string | undefined);
7
+ }
8
+ interface Dependencies {
9
+ connect?: typeof connectCodexSafety;
10
+ verify?: typeof verifyNativeSafetyCommand;
11
+ }
12
+ type Options = CodexSafetyOptions;
13
+ export interface CodexTrustPlan {
14
+ schema: "hasna.hooks.codex-safety-trust/v1";
15
+ planDigest: string;
16
+ nativeVersion: string;
17
+ cwd: string;
18
+ key: string;
19
+ currentHash: string;
20
+ configPath: string;
21
+ configVersion: string;
22
+ action: "unchanged" | "trust_and_enable";
23
+ guardVerified: true;
24
+ nativeAdoptionVerified: false;
25
+ }
26
+ export declare function planCodexSafetyTrust(options?: Options, dependencies?: Dependencies): Promise<CodexTrustPlan>;
27
+ export declare function applyCodexSafetyTrust(options: Options & {
28
+ expectedPlanDigest: string;
29
+ }, dependencies?: Dependencies): Promise<{
30
+ ok: boolean;
31
+ changed: boolean;
32
+ operationId: string;
33
+ planDigest: string;
34
+ configVersion: any;
35
+ guardVerified: boolean;
36
+ nativeDiscoveryVerified: boolean;
37
+ nativeAdoptionVerified: boolean;
38
+ unrelatedConfigurationPreserved: boolean;
39
+ } | {
40
+ schema: "hasna.hooks.codex-safety-trust/v1";
41
+ planDigest: string;
42
+ nativeVersion: string;
43
+ cwd: string;
44
+ key: string;
45
+ currentHash: string;
46
+ configPath: string;
47
+ configVersion: string;
48
+ action: "unchanged" | "trust_and_enable";
49
+ guardVerified: true;
50
+ nativeAdoptionVerified: false;
51
+ ok: boolean;
52
+ changed: boolean;
53
+ }>;
54
+ export {};
@@ -1,3 +1,7 @@
1
1
  export declare function readCodexSettings(path: string): Record<string, any>;
2
2
  /** Preserve the exact read version. Never write trust state or config.toml. */
3
- export declare function writeCodexSettings(path: string, settings: Record<string, any>): void;
3
+ export declare function capturedCodexSettings(settings: Record<string, any>): string | null;
4
+ export declare function assertSettingsPreimage(raw: string | null, expected?: string): void;
5
+ export declare function writeCodexSettings(path: string, settings: Record<string, any>, expectedSha256?: string): void;
6
+ /** Shared standalone writer. Managed Claude settings still use Skills. */
7
+ export declare function writeHookSettingsPreimage(path: string, settings: Record<string, any>, before: string | null): void;
@@ -30,6 +30,8 @@ export interface InstallResult {
30
30
  configPath?: string;
31
31
  }
32
32
  export interface InstallOptions {
33
+ /** Optional exact predecessor for a planned native safety registration. */
34
+ expectedSettingsSha256?: string;
33
35
  /** Explicit persistent opt-in for mementos-context; omitted updates retain its current choice. */
34
36
  mementos?: MementosRegistration;
35
37
  scope?: Scope;
@@ -64,6 +66,12 @@ export declare function detectRewriteConflict(name: string, scope: Scope, target
64
66
  /** Injected for tests: the rewrite claim is a registry concern, the overlap is not. */
65
67
  lookup?: (hookName: string) => HookMeta | undefined): string | undefined;
66
68
  export declare function installHook(name: string, options?: InstallOptions): InstallResult;
69
+ /** Render through the installer without writing settings or trust state. */
70
+ export declare function previewNativeSafetyRegistration(target: "codex" | "claude", scope?: Scope): {
71
+ before: string | null;
72
+ after: string;
73
+ command: string;
74
+ };
67
75
  export declare function installHooks(names: string[], options?: InstallOptions): InstallResult[];
68
76
  export declare function getRegisteredHooksForTarget(scope?: Scope, target?: SingleTarget): string[];
69
77
  export declare function getRegisteredHooks(scope?: Scope): string[];
@@ -0,0 +1,52 @@
1
+ import { verifyNativeSafetyCommand } from "../native-safety.js";
2
+ export declare class NativeRegistrationError extends Error {
3
+ readonly code: "input_invalid" | "settings_unsafe" | "settings_invalid" | "settings_changed" | "hooks_disabled" | "registration_conflict" | "guard_failed" | "apply_outcome_uncertain";
4
+ readonly operationId?: string | undefined;
5
+ constructor(code: "input_invalid" | "settings_unsafe" | "settings_invalid" | "settings_changed" | "hooks_disabled" | "registration_conflict" | "guard_failed" | "apply_outcome_uncertain", operationId?: string | undefined);
6
+ }
7
+ export interface NativeRegistrationOptions {
8
+ target: "codex" | "claude";
9
+ }
10
+ export interface NativeRegistrationPlan {
11
+ schema: "hasna.hooks.native-safety-registration/v1";
12
+ target: "codex" | "claude";
13
+ settingsPath: string;
14
+ resolvedPath: string;
15
+ beforeSHA256: string;
16
+ desiredSHA256: string;
17
+ commandSHA256: string;
18
+ action: "unchanged" | "register";
19
+ planDigest: string;
20
+ guardVerified: true;
21
+ nativeAdoptionVerified: false;
22
+ }
23
+ interface Dependencies {
24
+ verify?: typeof verifyNativeSafetyCommand;
25
+ }
26
+ export declare function planNativeSafetyRegistration(options: NativeRegistrationOptions, dependencies?: Dependencies): Promise<NativeRegistrationPlan>;
27
+ export declare function applyNativeSafetyRegistration(options: NativeRegistrationOptions & {
28
+ expectedPlanDigest: string;
29
+ }, dependencies?: Dependencies): Promise<{
30
+ ok: boolean;
31
+ changed: boolean;
32
+ operationId: `${string}-${string}-${string}-${string}-${string}`;
33
+ planDigest: string;
34
+ settingsSHA256: string;
35
+ guardVerified: boolean;
36
+ nativeAdoptionVerified: boolean;
37
+ } | {
38
+ schema: "hasna.hooks.native-safety-registration/v1";
39
+ target: "codex" | "claude";
40
+ settingsPath: string;
41
+ resolvedPath: string;
42
+ beforeSHA256: string;
43
+ desiredSHA256: string;
44
+ commandSHA256: string;
45
+ action: "unchanged" | "register";
46
+ planDigest: string;
47
+ guardVerified: true;
48
+ nativeAdoptionVerified: false;
49
+ ok: boolean;
50
+ changed: boolean;
51
+ }>;
52
+ export {};
@@ -12,5 +12,9 @@ export declare function isNativeSafetyName(name: string): name is NativeSafetyNa
12
12
  export declare function buildNativeSafetyCommand(binding: NativeSafetyBinding): string;
13
13
  export declare function nativeSafetyRegistration(command: string): NativeSafetyName | undefined;
14
14
  export declare function nativeSafetyCommandBinding(command: string): NativeSafetyBinding | undefined;
15
+ /** Whether a saved supervisor command's pinned runtime and worker still verify
16
+ * on disk. `undefined` means the command is not a native-safety binding at all.
17
+ * Read-only: it never registers, rewrites or executes anything. */
18
+ export declare function nativeSafetyBindingVerified(command: string): boolean | undefined;
15
19
  /** Built-in capability only: no registry, custom source, profile or app store. */
16
20
  export declare function installedNativeSafetyCommand(name: NativeSafetyName): string;
@@ -43,6 +43,12 @@ export declare function readNativeSafetyRegistration(home?: string): {
43
43
  sha256: string;
44
44
  path: string;
45
45
  };
46
+ /** Bounded, non-leaking explanation for a refused binding: which integrity
47
+ * property the saved record fails. A binding that a third party could rewrite
48
+ * can point the supervisor at a different worker, so its owner and mode are part
49
+ * of the trust decision, not decoration. Read-only — it never repairs, rewrites
50
+ * or executes anything, and returns undefined when the record looks intact. */
51
+ export declare function nativeSafetyBindingIntegrityReason(home?: string): string | undefined;
46
52
  /** Register only the bundled composite guard. Updates preserve the exact prior
47
53
  * record and require its digest. A lock coordinates package-owned writers; an
48
54
  * unexpected preimage or a retained interrupted lock requires reconciliation. */
@@ -210,6 +210,23 @@ function readNativeSafetyRegistration(home = homedir2()) {
210
210
  throw new NativeSafetyError(error?.code === "ENOENT" ? "binding_missing" : "binding_invalid");
211
211
  }
212
212
  }
213
+ function nativeSafetyBindingIntegrityReason(home = homedir2()) {
214
+ try {
215
+ const path = join2(canonicalHome(home), ".hasna/hooks/native", filename);
216
+ const stat = lstatSync2(path);
217
+ if (!stat.isFile())
218
+ return "the saved binding is not a regular file";
219
+ if (!owner(stat.uid))
220
+ return "the saved binding is not owned by the current user";
221
+ if (stat.mode & 63)
222
+ return `the saved binding is accessible by group or others (mode ${(stat.mode & 511).toString(8).padStart(3, "0")})`;
223
+ if (stat.size > 32768)
224
+ return "the saved binding exceeds its size bound";
225
+ return;
226
+ } catch (error) {
227
+ return error?.code === "ENOENT" ? "the saved binding is missing" : undefined;
228
+ }
229
+ }
213
230
  function registerNativeSafety(options = {}) {
214
231
  const home = canonicalHome(options.home ?? homedir2());
215
232
  const record = { schema: "hasna.hooks.native-safety.v1", harness: "sumi", command: options.command ?? installedNativeSafetyCommand("trash-guard") };
@@ -395,6 +412,7 @@ export {
395
412
  verifyNativeSafetyCommand,
396
413
  registerNativeSafety,
397
414
  readNativeSafetyRegistration,
415
+ nativeSafetyBindingIntegrityReason,
398
416
  evaluateNativeSafetyForExecution,
399
417
  evaluateNativeSafety,
400
418
  NativeSafetyError
@@ -45,6 +45,18 @@ a newline or `||` the `cd` may have failed, so the earlier directories stay
45
45
  in scope. A `cd` inside `( ... )`, a pipeline or the background does not move
46
46
  it, and neither does a `cd` whose target cannot be resolved statically.
47
47
  Descriptor duplication (`2>&1`, `>&2`, `1>&-`) is not a write.
48
+ Each pipeline stage is classified on its own, with its output redirections
49
+ split off: `ls <root> 2>/dev/null | head` reads `<root>`, and the trailing
50
+ word of a later read-only stage (`| head -60`) is never taken as the write
51
+ target of an earlier stage. Pipes inside quotes or command substitutions are
52
+ not stage boundaries.
53
+ A protected path named anywhere in a pipeline takes the strongest operation
54
+ of any stage (`find <root>/x | xargs rm -rf` is a delete). Deletes and
55
+ `mkdir`/`touch` check every operand; other writers check their last one.
56
+ An operand or redirect target that cannot be read statically (a variable
57
+ other than `$HOME`, `$( )`, backticks, a glob) fails closed to the directory
58
+ the command runs in, so `rm -rf "$TMPDIR/x"` run from a protected checkout is
59
+ refused. Use a literal absolute path for deletes outside the roots.
48
60
 
49
61
  ## Configuration
50
62
 
@@ -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,
@@ -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,125 @@ 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
+ let endOfOptions = false;
590
+ const nonFlag = words.slice(1).flatMap((word) => {
591
+ if (!endOfOptions && word.text === "--") {
592
+ endOfOptions = true;
593
+ return [];
594
+ }
595
+ if (!endOfOptions && word.text.startsWith("-")) return [];
596
+ endOfOptions = true;
597
+ return [word.opaque ? "." : word.text];
598
+ });
599
+ // mv changes its sources too, so every mv operand counts as a write.
600
+ const everyOperand =
601
+ stage.op === "delete" ||
602
+ /(?:^|\s)(?:mkdir|mkfile|touch|tee|truncate|install|mv)(?:\s|$)/.test(stage.commandPart) ||
603
+ (/(?:^|\s)sed(?:\s|$)/.test(stage.commandPart) && /(?:^|\s)(?:-i\S*|--in-place\S*)(?:\s|$)/.test(stage.commandPart));
604
+ const operandTargets: Array<{ operand: string; op: Operation }> = [];
605
+ if (everyOperand) {
606
+ for (const operand of nonFlag.length > 0 ? nonFlag : ["."]) operandTargets.push({ operand, op: stage.op });
607
+ } else {
608
+ const relMatch = stage.commandPart.match(REL_OPERAND);
609
+ const last = words.at(-1);
610
+ operandTargets.push({ operand: relMatch ? relMatch[1] : !last || last.opaque ? "." : last.text, op: stage.op });
611
+ // cp/ln/install/mv -t DIR write into DIR: `-t DIR`, `-tDIR`, `-t` in
612
+ // a short-flag cluster (`-rt DIR`, `-rtDIR`) and any abbreviation of
613
+ // `--target-directory[=]DIR` (GNU).
614
+ if (/(?:^|\s)(?:cp|ln|install|mv)(?:\s|$)/.test(stage.commandPart)) {
615
+ words.forEach((word, index) => {
616
+ let value: ShellWord | undefined;
617
+ const short = word.text.match(/^-[A-Za-z]*?t(.*)$/);
618
+ const long = word.text.match(/^(--t[a-z-]*)(?:=(.*))?$/);
619
+ if (short && !word.text.startsWith("--")) {
620
+ value = short[1] ? { text: short[1], opaque: word.opaque } : words[index + 1];
621
+ } else if (long && "--target-directory".startsWith(long[1])) {
622
+ value = long[2] !== undefined ? { text: long[2], opaque: word.opaque } : words[index + 1];
623
+ }
624
+ if (value) operandTargets.push({ operand: value.opaque || !value.text ? "." : value.text, op: stage.op });
625
+ });
626
+ }
627
+ // An inline script (python3 -c, node -e, bun -e) can write anywhere
628
+ // relative to where it runs, whatever its arguments.
629
+ if (/(?:^|\s)(?:python3?|node|bun)\b[^;&|]*\s+-[ce](?:\s|$)/.test(stage.commandPart)) {
630
+ operandTargets.push({ operand: ".", op: stage.op });
631
+ }
632
+ }
633
+ for (const base of bases) {
634
+ for (const { operand, op } of operandTargets) {
635
+ targets.push({ path: normalize(resolve(base, expandHomeSpelling(operand, home))), op });
636
+ }
404
637
  }
405
638
  }
406
639
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/hooks",
3
- "version": "0.10.6",
3
+ "version": "0.10.8",
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",