@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.
- package/README.md +15 -4
- package/hooks/codewith-native-common.test.ts +1870 -13
- package/hooks/codewith-native-common.ts +2039 -61
- package/hooks/pre-bash/README.md +72 -2
- package/hooks/worktree-guard/README.md +53 -2
- package/hooks/worktree-guard/src/hook.ts +50 -8
- package/package.json +1 -1
package/hooks/pre-bash/README.md
CHANGED
|
@@ -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
|
|
10
|
-
|
|
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
|
|
9
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
86
|
+
`Blocked git ${action} outside a canonical task worktree (${managed.reason || "not under managed root"}).`,
|
|
53
87
|
`Command target cwd: ${targetCwd}`,
|
|
54
|
-
`
|
|
55
|
-
`
|
|
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
|
|
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