@hasna/hooks 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,8 +6,78 @@ This hook is OSS-safe: optional Hasna CLIs are best-effort and missing CLIs fail
6
6
 
7
7
  It also blocks scoped destructive shell operations such as recursive `rm`,
8
8
  `rsync --delete`, destructive `find`, and destructive `git clean` / `git reset
9
- --hard` forms only when the resolved target threatens `~/.hasna`, configured
10
- workspace roots, Hasna division/scope roots, or active repo/worktree roots.
9
+ --hard` forms when the resolved target threatens a protected root.
10
+
11
+ ## Protected roots
12
+
13
+ - `/` and the system directories (`/usr`, `/etc`, `/bin`, `/lib`, `/var`, `/boot`,
14
+ `/home`, `/Users`, and the other FHS and macOS equivalents). Add machine-specific
15
+ entries with `HASNA_PROTECTED_SYSTEM_ROOTS` (colon-separated). `/tmp` is not
16
+ protected — scratch cleanup there is routine.
17
+ - `~/.hasna`, configured workspace roots, Hasna division/scope roots, and active
18
+ repo/worktree roots.
19
+
20
+ These match in *root* mode: wiping a root or its contents (`rm -rf /usr`,
21
+ `rm -rf /usr/*`) blocks, while a targeted delete beneath one
22
+ (`rm -rf /usr/local/lib/my-build`) is allowed.
23
+
24
+ ## Expansions that can collapse to empty
25
+
26
+ A destructive target containing a command substitution, backtick substitution or
27
+ variable expansion is checked twice: as written, and as the shell would render it
28
+ if the expansion returned empty. `rm -rf "$(anything)"/*`, `` rm -rf `cmd`/* ``,
29
+ `rm -rf "$VAR"/*` and `rm -rf "${VAR}"/*` are blocked by shape, whatever the
30
+ expansion is.
31
+
32
+ This exists because of a realized incident: `bun pm cache` exits non-zero with an
33
+ empty stdout when no `package.json` is found walking up from cwd, so
34
+ `rm -rf "$(bun pm cache)"/*` ran as `rm -rf /*`. Redirecting stderr does not help
35
+ — it discards the diagnostic, not the path.
36
+
37
+ Two forms are deliberately not blocked:
38
+
39
+ - `${VAR:?}` / `${VAR:?message}`, which POSIX guarantees non-empty. (`${VAR?}`
40
+ without the colon permits an empty value and is *not* exempt.)
41
+ - A bare `rm -rf "$(cmd)"` with no trailing separator, which degrades to
42
+ `rm -rf ""` — rejected by `rm` without deleting anything.
43
+
44
+ The recommended form is to resolve the path first and assert it:
45
+
46
+ ```bash
47
+ dir="$(bun pm cache)" || exit 1
48
+ case "$dir" in /|"") exit 1;; esac
49
+ rm -rf -- "$dir"
50
+ ```
51
+
52
+ ## Globs
53
+
54
+ A glob threatens a protected root when it can match that root or an ancestor of it, or when it
55
+ wipes the root's contents wholesale. Matching is per path component, so a trailing literal
56
+ bounds the delete: `rm -rf */node_modules` at a monorepo root is allowed, while `rm -rf /*/*`
57
+ is not.
58
+
59
+ A glob directly under a protected root is refused only when it is *unanchored* — when no
60
+ literal text survives once the wildcards are removed. `[a-z]*`, `?*`, `.??*` and `*.*` are
61
+ unanchored and blocked; `*.log`, `tmp-*`, `.turbo*` and `snapshot-[0-9]*` keep their literal
62
+ anchor and are allowed.
63
+
64
+ Bracket expressions that this matcher does not model exactly — POSIX `[:class:]`, `[=equiv=]`,
65
+ `[.collate.]`, backslash escapes, anything unterminated — are treated as **matching**, never as
66
+ not-matching. An under-match would leave a protected root unmatched and allow the delete, so
67
+ ambiguity resolves toward refusing.
68
+
69
+ ## Working directory
70
+
71
+ `cd`, `pushd`, `pushd -n`, `popd` and `cd -` are tracked, per subshell, with a directory stack.
72
+ A `cd` inside `( … )` or a pipeline stage applies within that shell and does not escape it.
73
+
74
+ ## Wrappers
75
+
76
+ Commands are unwrapped before scanning: `bash -c` / `sh -c` / `zsh -c`, `su -c`,
77
+ `runuser -c`, `eval`, and `ssh host '…'`, including nested combinations. `cd` is
78
+ tracked within a command, and a `for VAR in <glob>` binding is followed into
79
+ `rm -rf "$VAR"`. Remote (`ssh`) layers only consider absolute targets, because a
80
+ remote relative path cannot be resolved against the local working directory.
11
81
 
12
82
  ## Install for Codewith
13
83
 
@@ -5,8 +5,59 @@ Codewith-native hook installed as `hooks run worktree-guard`.
5
5
  This hook is OSS-safe: optional Hasna CLIs are best-effort and missing CLIs fail open with concise warnings. Security gates only fail closed when a guarded commit/push scan runs successfully and finds possible secrets.
6
6
 
7
7
  It blocks scoped destructive shell operations and file-tool-like payloads when
8
- the resolved target threatens `~/.hasna`, configured workspace roots, Hasna
9
- division/scope roots, or active repo/worktree roots.
8
+ the resolved target threatens `/` or a system root (`/usr`, `/etc`, `/var`,
9
+ `/home`, …), `~/.hasna`, configured workspace roots, Hasna division/scope roots,
10
+ or active repo/worktree roots.
11
+
12
+ It shares its classifier with `pre-bash`, so it also blocks destructive targets
13
+ whose command substitution or variable expansion could collapse to empty —
14
+ `rm -rf "$(cmd)"/*` and `rm -rf "$VAR"/*`. See
15
+ [`hooks/pre-bash/README.md`](../pre-bash/README.md) for the full rules and the
16
+ recommended safe form.
17
+
18
+ ## Canonical worktree path
19
+
20
+ Git work is expected to happen in a task-specific worktree at the canonical path
21
+ from Hasna Agent Operating Rules rule 8 (published by `@hasna/identities`):
22
+
23
+ ```
24
+ $HOME/.hasna/repos/worktrees/<repo-name>/<worktree-name>
25
+ ```
26
+
27
+ Repo name, then worktree name. Anything else is reported as unmanaged, with a
28
+ reason: a flat single-segment worktree directly under the worktrees root, a
29
+ station-id or machine segment in front of the repo name, and any deeper nesting
30
+ are all rejected. Subdirectories of a canonical worktree are accepted.
31
+
32
+ The classification is grounded in verified git provenance, not path shape. The
33
+ two-segment path must be a real worktree root, no segment may be a symlink, and
34
+ its `.git` must prove it owns its own history: a `.git` file's `gitdir:` target
35
+ has to live under its repository's `worktrees/` directory and point back at this
36
+ control file, and a `.git` directory must not be grafted on by a `commondir`.
37
+
38
+ Shape alone proves nothing. `<root>/<flat-worktree>/<subdir>` has exactly the
39
+ canonical shape, so a `cd` would otherwise launder a forbidden flat worktree into
40
+ a compliant one; and a symlink or a two-line forged `.git` file would aim a
41
+ compliant-looking path at a shared checkout, making `git commit` land there.
42
+
43
+ Override the worktrees root with `HASNA_REPOS_WORKTREES_ROOT`.
44
+
45
+ ## Deprecated: the station-id lease layout
46
+
47
+ The pre-rule-8 layout `<station-id>/<repo-slug>-<hex>/wt_<hex>` is deprecated and
48
+ is never classified as a compliant worktree. Two migration tolerances keep
49
+ existing worktrees working while they are re-homed:
50
+
51
+ - git work there warns instead of being blocked, so in-flight tasks can still land;
52
+ - it keeps its scoped `~/.hasna` write carve-out.
53
+
54
+ Both are temporary. Re-home these worktrees to the canonical path, then set
55
+ `HASNA_HOOKS_LEGACY_WORKTREE_TOLERANCE=0`; the branch is removed after that.
56
+
57
+ The tolerance still requires the same provenance proof as the canonical path — it
58
+ softens the verdict, it does not skip the check. It does key off the path name, so
59
+ a worktree deliberately named to match also gets the warn tier; that is an opt-out
60
+ from a guardrail by a cooperating agent, not a way to reach a shared checkout.
10
61
 
11
62
  ## Install for Codewith
12
63
 
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  import {
4
+ canonicalRepoIdentity,
5
+ canonicalWorktreeTemplate,
4
6
  classifyDangerousOperation,
5
7
  claimCommand,
6
8
  gitCommandInfo,
@@ -10,7 +12,6 @@ import {
10
12
  managedWorktreeInfo,
11
13
  readInput,
12
14
  respond,
13
- runIdFrom,
14
15
  taskIdFrom,
15
16
  warn,
16
17
  type CodewithHookInput,
@@ -40,27 +41,63 @@ export async function evaluate(input: CodewithHookInput): Promise<{ output: Reco
40
41
  const managed = managedWorktreeInfo(targetCwd);
41
42
  if (managed.managed) return { output: { continue: true }, warnings };
42
43
 
44
+ // Migration shim, temporary and deliberately narrow: worktrees created under the
45
+ // pre-rule-8 station-id lease layout are non-compliant, but they are real, active
46
+ // worktrees. Hard-blocking their git work on day one would strand in-flight tasks
47
+ // with no way to land, so this one recognised layout warns instead of blocking.
48
+ // Everything else non-canonical is still blocked. Remove once they are re-homed.
49
+ if (managed.layout === "legacy-station-lease") {
50
+ const message = `${managed.reason}. This layout will stop being tolerated; re-home this worktree.`;
51
+ warnings.push(message);
52
+ return {
53
+ output: {
54
+ continue: true,
55
+ hookSpecificOutput: {
56
+ hookEventName: "PreToolUse",
57
+ additionalContext: `[worktree-guard] ${message}`,
58
+ },
59
+ },
60
+ warnings,
61
+ };
62
+ }
63
+
43
64
  const repoRoot = await gitRepoRoot(targetCwd);
44
- const repo = await gitRemoteSlug(targetCwd) || (repoRoot ? repoRoot.split("/").filter(Boolean).pop() || null : null);
65
+ // Rule 8 resolution order: the repos CLI is the source of truth for <repo-name>.
66
+ // The local checkout directory is the next best guess, and the remote slug is the
67
+ // last resort — for most repos the remote basename is NOT the canonical repo name.
68
+ const identity = await canonicalRepoIdentity(targetCwd);
69
+ const repo = identity.name
70
+ || (repoRoot ? repoRoot.split("/").filter(Boolean).pop() || null : null)
71
+ || (await gitRemoteSlug(targetCwd))?.split("/").filter(Boolean).pop()
72
+ || null;
45
73
  const taskId = taskIdFrom(input);
46
- const runId = runIdFrom(input);
47
- const recommended = claimCommand(repo, taskId, runId);
74
+ const recommended = claimCommand(repo, taskId, identity.defaultBranch);
48
75
  const action = gitInfo?.action || null;
76
+ const canonical = canonicalWorktreeTemplate(managed.root);
77
+ // Rule 8 requires an exact repos-CLI lookup for <repo-name>. When that lookup did
78
+ // not resolve, the name below is a local guess, so say so rather than imply it is
79
+ // authoritative — the repo directory name and the remote basename often differ.
80
+ const unverifiedRepoName = identity.name
81
+ ? null
82
+ : `Confirm <repo-name> with an exact repos CLI lookup first: repos repo ${repo || "<name>"} --json`;
49
83
 
50
84
  if (action) {
51
85
  const reason = [
52
- `Blocked git ${action} outside a managed repos worktree (${managed.reason || "not under managed root"}).`,
86
+ `Blocked git ${action} outside a canonical task worktree (${managed.reason || "not under managed root"}).`,
53
87
  `Command target cwd: ${targetCwd}`,
54
- `Managed root: ${managed.root}`,
55
- `Claim a task worktree first: ${recommended}`,
88
+ `Canonical worktree path (Agent Operating Rules rule 8): ${canonical}`,
89
+ `Create one first: ${recommended}`,
90
+ ...(unverifiedRepoName ? [unverifiedRepoName] : []),
56
91
  ].join(" ");
57
92
  return { output: { decision: "block", reason }, warnings };
58
93
  }
59
94
 
60
95
  if (isFeatureWork(input, command)) {
61
96
  warnings.push([
62
- `Feature work appears to be outside a managed repos worktree (${managed.reason || "not under managed root"}).`,
97
+ `Feature work appears to be outside a canonical task worktree (${managed.reason || "not under managed root"}).`,
98
+ `Canonical worktree path (Agent Operating Rules rule 8): ${canonical}.`,
63
99
  `Use: ${recommended}`,
100
+ ...(unverifiedRepoName ? [unverifiedRepoName] : []),
64
101
  ].join(" "));
65
102
  return {
66
103
  output: {
@@ -91,4 +128,9 @@ export async function run(): Promise<void> {
91
128
 
92
129
  if (import.meta.main) {
93
130
  await run();
131
+ // The verdict is written, so the hook is done. Exit rather than waiting for the
132
+ // event loop to drain: an optional CLI consulted during evaluation may have left a
133
+ // grandchild holding a pipe open, and a hook that has already answered must never
134
+ // keep the caller's PreToolUse path waiting on it.
135
+ process.exit(0);
94
136
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/hooks",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
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": {