@hasna/hooks 0.4.1 → 0.6.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 +96 -12
- package/bin/index.js +2089 -470
- package/bin/serve.js +5248 -0
- package/dist/cf/provision.d.ts +24 -0
- package/dist/config.d.ts +18 -0
- package/dist/db/legacy-import.d.ts +1 -1
- package/dist/db/migrations/004_hooks_table.d.ts +9 -0
- package/dist/db/pg-migrations.d.ts +1 -1
- package/dist/db/storage-sync.d.ts +26 -6
- package/dist/index.d.ts +18 -2
- package/dist/index.js +5341 -286
- package/dist/lib/custom-install.d.ts +19 -0
- package/dist/lib/manifest.d.ts +70 -0
- package/dist/lib/resolve.d.ts +19 -0
- package/dist/lib/run.d.ts +37 -0
- package/dist/lib/store.d.ts +69 -0
- package/dist/lib/sync.d.ts +35 -0
- package/dist/serve.d.ts +36 -0
- package/dist/storage.d.ts +2 -2
- package/dist/storage.js +133 -42
- package/hooks/codewith-native-common.test.ts +1521 -3
- package/hooks/codewith-native-common.ts +1627 -37
- package/hooks/hook-scanoutput/README.md +151 -0
- package/hooks/hook-scanoutput/package.json +12 -0
- package/hooks/hook-scanoutput/src/hook.test.ts +217 -0
- package/hooks/hook-scanoutput/src/hook.ts +319 -0
- package/hooks/mention-context/README.md +109 -0
- package/hooks/mention-context/package.json +9 -0
- package/hooks/mention-context/src/hasna-mention-context.py +1218 -0
- package/hooks/mention-context/src/hasna-mention-warm.py +521 -0
- package/hooks/mention-context/src/hook.test.ts +68 -0
- package/hooks/mention-context/src/test_run_capture.py +345 -0
- package/hooks/pre-bash/README.md +72 -2
- package/hooks/worktree-guard/README.md +9 -2
- package/package.json +9 -5
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Regression suite for the shared-temp-file race in hasna-mention-context.py.
|
|
3
|
+
|
|
4
|
+
THE DEFECT. `run_capture` derived its output path from its `tag` argument alone,
|
|
5
|
+
and `probe_local_head` passed a constant `tag="gitlog"`. Repository probes run
|
|
6
|
+
concurrently against one shared temp directory, so every mentioned repository's
|
|
7
|
+
`git log` wrote to and read back the same `gitlog.out` and the reader got
|
|
8
|
+
whatever the last writer left. The emitted sha was always a real sha from a real
|
|
9
|
+
repository, just the wrong one.
|
|
10
|
+
|
|
11
|
+
WHY THESE TESTS ARE TIMED RATHER THAN LOOPED. A race reproduced by "run it many
|
|
12
|
+
times and hope" passes on broken code whenever the interleaving happens not to
|
|
13
|
+
occur, which makes it worthless as a gate. Both tests below instead FORCE the
|
|
14
|
+
losing interleaving with a controlled delay, so they fail on the unfixed code on
|
|
15
|
+
every run rather than on some runs.
|
|
16
|
+
|
|
17
|
+
The construction, which is the whole idea:
|
|
18
|
+
|
|
19
|
+
EARLY WRITER, LATE READER writes at t=0, exits and reads at t=SLOW
|
|
20
|
+
LATE WRITER, EARLY READER writes and exits and reads at t=FAST
|
|
21
|
+
|
|
22
|
+
t=0 both open(path,"wb") -> truncate
|
|
23
|
+
t=0 early's process writes its marker
|
|
24
|
+
t=FAST late's process overwrites the same path, then reads -> its own value
|
|
25
|
+
t=SLOW early reads -> LATE'S VALUE, not its own <- the defect
|
|
26
|
+
|
|
27
|
+
With one file per call the two never interact and both read their own output.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import importlib.util
|
|
31
|
+
import os
|
|
32
|
+
import shutil
|
|
33
|
+
import subprocess
|
|
34
|
+
import sys
|
|
35
|
+
import tempfile
|
|
36
|
+
import threading
|
|
37
|
+
import unittest
|
|
38
|
+
|
|
39
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
40
|
+
HOOK_PATH = os.path.join(HERE, "hasna-mention-context.py")
|
|
41
|
+
|
|
42
|
+
# Timings. SLOW must stay comfortably under probe_local_head's hardcoded 2.0 s
|
|
43
|
+
# subprocess timeout, or a slow machine turns a real pass into a spurious
|
|
44
|
+
# failure. The gap between them is the margin that makes the interleaving
|
|
45
|
+
# deterministic; 0.55 s absorbs a great deal of scheduler jitter.
|
|
46
|
+
SLOW_S = 0.80
|
|
47
|
+
FAST_S = 0.25
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_hook():
|
|
51
|
+
"""Import the hook by path. Its filename is not a legal module name, and it
|
|
52
|
+
guards main() behind __name__ == "__main__", so importing runs no hook."""
|
|
53
|
+
spec = importlib.util.spec_from_file_location("hasna_mention_context", HOOK_PATH)
|
|
54
|
+
if spec is None or spec.loader is None:
|
|
55
|
+
raise RuntimeError("could not load a module spec for %s" % HOOK_PATH)
|
|
56
|
+
mod = importlib.util.module_from_spec(spec)
|
|
57
|
+
spec.loader.exec_module(mod)
|
|
58
|
+
return mod
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
mod = load_hook()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def run_pair(first, second):
|
|
65
|
+
"""Run two callables on two threads released simultaneously by a barrier.
|
|
66
|
+
|
|
67
|
+
Returns {name: value}. A barrier rather than two bare start() calls, because
|
|
68
|
+
the interleaving depends on both calls entering the region together.
|
|
69
|
+
"""
|
|
70
|
+
results = {}
|
|
71
|
+
errors = {}
|
|
72
|
+
barrier = threading.Barrier(2)
|
|
73
|
+
|
|
74
|
+
def wrap(name, fn):
|
|
75
|
+
def run():
|
|
76
|
+
try:
|
|
77
|
+
barrier.wait(timeout=10)
|
|
78
|
+
results[name] = fn()
|
|
79
|
+
except BaseException as exc: # noqa: BLE001 - surfaced below
|
|
80
|
+
errors[name] = exc
|
|
81
|
+
return run
|
|
82
|
+
|
|
83
|
+
threads = [threading.Thread(target=wrap(n, f), daemon=True)
|
|
84
|
+
for n, f in (first, second)]
|
|
85
|
+
for t in threads:
|
|
86
|
+
t.start()
|
|
87
|
+
for t in threads:
|
|
88
|
+
t.join(timeout=30)
|
|
89
|
+
for t in threads:
|
|
90
|
+
if t.is_alive():
|
|
91
|
+
raise AssertionError("a probe thread did not finish within 30 s")
|
|
92
|
+
if errors:
|
|
93
|
+
raise AssertionError("probe thread raised: %r" % (errors,))
|
|
94
|
+
return results
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class RunCaptureIsolation(unittest.TestCase):
|
|
98
|
+
"""Level 1 — run_capture itself, no git, fully hermetic."""
|
|
99
|
+
|
|
100
|
+
def test_concurrent_captures_sharing_a_tag_do_not_cross(self):
|
|
101
|
+
tmpdir = tempfile.mkdtemp(prefix="mention-context-test-")
|
|
102
|
+
self.addCleanup(shutil.rmtree, tmpdir, ignore_errors=True)
|
|
103
|
+
|
|
104
|
+
# Equal-length markers, so that on the unfixed code the overwrite is
|
|
105
|
+
# clean and the failure message shows one whole wrong value rather than
|
|
106
|
+
# two values spliced at a byte boundary.
|
|
107
|
+
early_mark = "AAAAAAAA"
|
|
108
|
+
late_mark = "BBBBBBBB"
|
|
109
|
+
|
|
110
|
+
def capture(mark, pre_sleep, post_sleep):
|
|
111
|
+
script = (
|
|
112
|
+
"import sys, time\n"
|
|
113
|
+
"time.sleep(%r)\n"
|
|
114
|
+
"sys.stdout.write(%r)\n"
|
|
115
|
+
"sys.stdout.flush()\n"
|
|
116
|
+
"time.sleep(%r)\n" % (pre_sleep, mark, post_sleep)
|
|
117
|
+
)
|
|
118
|
+
# A python child rather than a shell one-liner: the write and the
|
|
119
|
+
# flush are explicit, so "the bytes reached the file before the
|
|
120
|
+
# sleep" is a property of the test rather than of shell builtin
|
|
121
|
+
# buffering.
|
|
122
|
+
def call():
|
|
123
|
+
return mod.run_capture(
|
|
124
|
+
[sys.executable, "-c", script],
|
|
125
|
+
timeout=30.0, tmpdir=tmpdir, tag="gitlog",
|
|
126
|
+
)
|
|
127
|
+
return call
|
|
128
|
+
|
|
129
|
+
results = run_pair(
|
|
130
|
+
("early", capture(early_mark, 0.0, SLOW_S)), # writes t=0, reads t=SLOW
|
|
131
|
+
("late", capture(late_mark, FAST_S, 0.0)), # writes and reads t=FAST
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
early_rc, early_out = results["early"]
|
|
135
|
+
late_rc, late_out = results["late"]
|
|
136
|
+
|
|
137
|
+
self.assertEqual(early_rc, 0, "early capture did not run: rc=%r" % (early_rc,))
|
|
138
|
+
self.assertEqual(late_rc, 0, "late capture did not run: rc=%r" % (late_rc,))
|
|
139
|
+
|
|
140
|
+
# This is the assertion the defect breaks. On the unfixed code the early
|
|
141
|
+
# caller reads the LATE caller's marker, because both used gitlog.out.
|
|
142
|
+
self.assertEqual(
|
|
143
|
+
early_out, early_mark,
|
|
144
|
+
"the early caller read %r but produced %r — a concurrent call's "
|
|
145
|
+
"output was attributed to it (shared temp path)"
|
|
146
|
+
% (early_out, early_mark),
|
|
147
|
+
)
|
|
148
|
+
self.assertEqual(
|
|
149
|
+
late_out, late_mark,
|
|
150
|
+
"the late caller read %r but produced %r" % (late_out, late_mark),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def test_capture_paths_are_distinct_for_the_same_tag(self):
|
|
154
|
+
"""The invariant directly: one tag, two calls, two files.
|
|
155
|
+
|
|
156
|
+
Kept separate from the timed test because it states the property in one
|
|
157
|
+
line and cannot be confused by machine load.
|
|
158
|
+
"""
|
|
159
|
+
tmpdir = tempfile.mkdtemp(prefix="mention-context-test-")
|
|
160
|
+
self.addCleanup(shutil.rmtree, tmpdir, ignore_errors=True)
|
|
161
|
+
|
|
162
|
+
for _ in range(2):
|
|
163
|
+
rc, _out = mod.run_capture([sys.executable, "-c", "pass"],
|
|
164
|
+
timeout=30.0, tmpdir=tmpdir, tag="gitlog")
|
|
165
|
+
self.assertEqual(rc, 0)
|
|
166
|
+
|
|
167
|
+
outs = [n for n in os.listdir(tmpdir) if n.endswith(".out")]
|
|
168
|
+
self.assertEqual(
|
|
169
|
+
len(outs), 2,
|
|
170
|
+
"two calls with tag='gitlog' produced %d .out file(s) (%r); each "
|
|
171
|
+
"call must own its path or concurrent calls overwrite each other"
|
|
172
|
+
% (len(outs), sorted(outs)),
|
|
173
|
+
)
|
|
174
|
+
self.assertTrue(
|
|
175
|
+
all(n.startswith("gitlog") for n in outs),
|
|
176
|
+
"the tag must survive as a readable prefix, got %r" % (sorted(outs),),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class ProbeLocalHeadIsolation(unittest.TestCase):
|
|
181
|
+
"""Level 2 — probe_local_head end to end, real git, real repositories.
|
|
182
|
+
|
|
183
|
+
The repositories are built here rather than borrowed from the machine, so
|
|
184
|
+
the test is hermetic and the expected shas are known exactly.
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
def setUp(self):
|
|
188
|
+
self.git = shutil.which("git")
|
|
189
|
+
if not self.git:
|
|
190
|
+
# Fail rather than skip. A skipped test is indistinguishable from a
|
|
191
|
+
# passing one in the summary line, and this suite exists to gate a
|
|
192
|
+
# defect that already shipped once.
|
|
193
|
+
self.fail("git is not on PATH; this regression test cannot run")
|
|
194
|
+
self.root = tempfile.mkdtemp(prefix="mention-context-repos-")
|
|
195
|
+
self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
|
|
196
|
+
|
|
197
|
+
def make_repo(self, name, content):
|
|
198
|
+
path = os.path.join(self.root, name)
|
|
199
|
+
os.makedirs(path)
|
|
200
|
+
# Identity is supplied through the environment of these throwaway
|
|
201
|
+
# fixture processes only. Nothing here writes git config, and no real
|
|
202
|
+
# repository is touched.
|
|
203
|
+
env = dict(os.environ)
|
|
204
|
+
env.update({
|
|
205
|
+
"GIT_AUTHOR_NAME": "mention-context test",
|
|
206
|
+
"GIT_AUTHOR_EMAIL": "test@example.invalid",
|
|
207
|
+
"GIT_COMMITTER_NAME": "mention-context test",
|
|
208
|
+
"GIT_COMMITTER_EMAIL": "test@example.invalid",
|
|
209
|
+
"GIT_CONFIG_GLOBAL": os.path.join(self.root, "no-such-gitconfig"),
|
|
210
|
+
"GIT_CONFIG_SYSTEM": os.path.join(self.root, "no-such-gitconfig"),
|
|
211
|
+
})
|
|
212
|
+
def git(*args):
|
|
213
|
+
cp = subprocess.run([self.git, "-C", path] + list(args), env=env,
|
|
214
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
215
|
+
if cp.returncode != 0:
|
|
216
|
+
self.fail("fixture git %r failed rc=%d: %s"
|
|
217
|
+
% (args, cp.returncode, cp.stderr.decode("utf-8", "replace")))
|
|
218
|
+
return cp.stdout.decode("utf-8", "replace")
|
|
219
|
+
git("init", "-q")
|
|
220
|
+
with open(os.path.join(path, "file.txt"), "w") as f:
|
|
221
|
+
f.write(content)
|
|
222
|
+
git("add", "file.txt")
|
|
223
|
+
git("commit", "-q", "-m", "fixture commit for " + name)
|
|
224
|
+
sha = git("log", "-1", "--format=%h").strip()
|
|
225
|
+
return path, sha
|
|
226
|
+
|
|
227
|
+
def write_timing_wrapper(self, slow_repo_path):
|
|
228
|
+
"""A git wrapper that makes one repository the early-writer/late-reader.
|
|
229
|
+
|
|
230
|
+
For the slow repository: run git (its output lands in the capture file
|
|
231
|
+
immediately, flushed by git's own exit) and only then sleep before
|
|
232
|
+
exiting, so the read happens late. For every other repository: sleep
|
|
233
|
+
first, so its write lands in between.
|
|
234
|
+
"""
|
|
235
|
+
wrapper = os.path.join(self.root, "git-timing-wrapper")
|
|
236
|
+
with open(wrapper, "w") as f:
|
|
237
|
+
f.write(
|
|
238
|
+
"#!/bin/sh\n"
|
|
239
|
+
'case "$*" in\n'
|
|
240
|
+
" *%s*) %s \"$@\"; ec=$?; sleep %s; exit $ec ;;\n"
|
|
241
|
+
" *) sleep %s; exec %s \"$@\" ;;\n"
|
|
242
|
+
"esac\n" % (os.path.basename(slow_repo_path), self.git,
|
|
243
|
+
SLOW_S, FAST_S, self.git)
|
|
244
|
+
)
|
|
245
|
+
os.chmod(wrapper, 0o755)
|
|
246
|
+
return wrapper
|
|
247
|
+
|
|
248
|
+
def test_concurrent_probes_report_their_own_repositorys_head(self):
|
|
249
|
+
alpha_path, alpha_sha = self.make_repo("alpha", "alpha content\n")
|
|
250
|
+
beta_path, beta_sha = self.make_repo("beta", "beta content\n")
|
|
251
|
+
self.assertNotEqual(
|
|
252
|
+
alpha_sha, beta_sha,
|
|
253
|
+
"fixture repositories must have different heads for this test to "
|
|
254
|
+
"be able to detect wrong attribution at all",
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
tmpdir = tempfile.mkdtemp(prefix="mention-context-test-")
|
|
258
|
+
self.addCleanup(shutil.rmtree, tmpdir, ignore_errors=True)
|
|
259
|
+
|
|
260
|
+
wrapper = self.write_timing_wrapper(beta_path)
|
|
261
|
+
original_git_bin = mod.GIT_BIN
|
|
262
|
+
mod.GIT_BIN = wrapper
|
|
263
|
+
self.addCleanup(setattr, mod, "GIT_BIN", original_git_bin)
|
|
264
|
+
|
|
265
|
+
results = run_pair(
|
|
266
|
+
("beta", lambda: mod.probe_local_head(beta_path, tmpdir)),
|
|
267
|
+
("alpha", lambda: mod.probe_local_head(alpha_path, tmpdir)),
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
self.assertIsNotNone(
|
|
271
|
+
results["beta"],
|
|
272
|
+
"probe_local_head returned None for beta; the probe did not run, "
|
|
273
|
+
"so this test proved nothing about attribution",
|
|
274
|
+
)
|
|
275
|
+
self.assertIsNotNone(results["alpha"], "probe_local_head returned None for alpha")
|
|
276
|
+
|
|
277
|
+
# beta is the early writer and late reader: on the unfixed code it reads
|
|
278
|
+
# alpha's line out of the shared gitlog.out and reports alpha's sha.
|
|
279
|
+
self.assertEqual(
|
|
280
|
+
results["beta"]["sha"], beta_sha,
|
|
281
|
+
"beta reported %r; its own head is %r and alpha's is %r — one "
|
|
282
|
+
"repository's head was attributed to another"
|
|
283
|
+
% (results["beta"]["sha"], beta_sha, alpha_sha),
|
|
284
|
+
)
|
|
285
|
+
self.assertEqual(
|
|
286
|
+
results["alpha"]["sha"], alpha_sha,
|
|
287
|
+
"alpha reported %r, expected %r"
|
|
288
|
+
% (results["alpha"]["sha"], alpha_sha),
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def colliding_heads(pairs):
|
|
293
|
+
"""Field canary, NOT the gate — see the class below for why.
|
|
294
|
+
|
|
295
|
+
`pairs` is [(resolved_checkout, sha), ...]. Returns the shas claimed by more
|
|
296
|
+
than one DISTINCT checkout. Comparing resolved paths rather than mention
|
|
297
|
+
tokens is what stops it firing on `hasna/loops` and `hasna/open-loops`, which
|
|
298
|
+
are two tokens naming one checkout and legitimately share a head.
|
|
299
|
+
"""
|
|
300
|
+
by_sha = {}
|
|
301
|
+
for checkout, sha in pairs:
|
|
302
|
+
if not checkout or not sha:
|
|
303
|
+
continue
|
|
304
|
+
by_sha.setdefault(sha, set()).add(os.path.realpath(checkout))
|
|
305
|
+
return sorted(sha for sha, paths in by_sha.items() if len(paths) > 1)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class DuplicateShaCanary(unittest.TestCase):
|
|
309
|
+
"""The duplicate-sha check, with its own blind spot pinned down in a test.
|
|
310
|
+
|
|
311
|
+
Two unrelated repositories cannot share a commit sha, so a single emitted
|
|
312
|
+
block claiming one sha for two checkouts is self-evidently corrupt — no
|
|
313
|
+
fixtures needed, which makes this cheap to run against real output in the
|
|
314
|
+
field.
|
|
315
|
+
|
|
316
|
+
It is NOT the acceptance gate, and the test below is what stops anyone
|
|
317
|
+
promoting it to one: it detects COLLISION TO A COMMON VALUE while the defect
|
|
318
|
+
is WRONG ATTRIBUTION, and a clean swap between two concurrent writers is
|
|
319
|
+
pairwise distinct and entirely wrong.
|
|
320
|
+
"""
|
|
321
|
+
|
|
322
|
+
def test_catches_the_duplicate_case(self):
|
|
323
|
+
pairs = [("/repos/open-loops", "82a3acf"), ("/repos/open-logs", "82a3acf")]
|
|
324
|
+
self.assertEqual(colliding_heads(pairs), ["82a3acf"])
|
|
325
|
+
|
|
326
|
+
def test_is_blind_to_the_swap_case(self):
|
|
327
|
+
# The 17:48 observation: each value real, each attributed to the wrong
|
|
328
|
+
# repository, pairwise distinct. This assertion records a limitation on
|
|
329
|
+
# purpose; if it ever starts failing the canary got stronger and this
|
|
330
|
+
# test should be revisited, not deleted quietly.
|
|
331
|
+
pairs = [("/repos/open-loops", "27cffd7"), ("/repos/open-logs", "82a3acf")]
|
|
332
|
+
self.assertEqual(
|
|
333
|
+
colliding_heads(pairs), [],
|
|
334
|
+
"the canary is expected to MISS a clean swap; it is a supplement to "
|
|
335
|
+
"the concurrency tests above, never a replacement",
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
def test_does_not_fire_when_two_tokens_name_one_checkout(self):
|
|
339
|
+
# `hasna/loops` and `hasna/open-loops` both resolve to open-loops.
|
|
340
|
+
pairs = [("/repos/open-loops", "82a3acf"), ("/repos/open-loops", "82a3acf")]
|
|
341
|
+
self.assertEqual(colliding_heads(pairs), [])
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
if __name__ == "__main__":
|
|
345
|
+
unittest.main(verbosity=2)
|
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,15 @@ 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.
|
|
10
17
|
|
|
11
18
|
## Canonical worktree path
|
|
12
19
|
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hasna/hooks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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": {
|
|
7
|
-
"hooks": "bin/index.js"
|
|
7
|
+
"hooks": "bin/index.js",
|
|
8
|
+
"hooks-serve": "bin/serve.js"
|
|
8
9
|
},
|
|
9
10
|
"exports": {
|
|
10
11
|
".": {
|
|
@@ -19,7 +20,7 @@
|
|
|
19
20
|
"main": "./dist/index.js",
|
|
20
21
|
"types": "./dist/index.d.ts",
|
|
21
22
|
"scripts": {
|
|
22
|
-
"build": "rm -rf dist && bun build ./src/cli/index.tsx --outdir ./bin --target bun --external pg --external ink --external react --external chalk --external conf --external @modelcontextprotocol/sdk --external zod && bun build ./src/index.ts ./src/storage.ts --outdir ./dist --target bun --external pg && bun run build:types",
|
|
23
|
+
"build": "rm -rf dist bin && bun build ./src/cli/index.tsx --outdir ./bin --target bun --external pg --external ink --external react --external chalk --external conf --external @modelcontextprotocol/sdk --external zod && bun build ./src/serve.ts --outdir ./bin --target bun --external pg && bun build ./src/index.ts ./src/storage.ts --outdir ./dist --target bun --external pg && bun run build:types",
|
|
23
24
|
"build:types": "tsc -p tsconfig.build.json",
|
|
24
25
|
"dev": "bun run ./src/cli/index.tsx",
|
|
25
26
|
"test": "bun test",
|
|
@@ -47,10 +48,12 @@
|
|
|
47
48
|
"@types/bun": "latest",
|
|
48
49
|
"@types/pg": "^8.15.6",
|
|
49
50
|
"@types/react": "^18.2.0",
|
|
50
|
-
"typescript": "^5"
|
|
51
|
+
"typescript": "^5",
|
|
52
|
+
"bun-types": "1.3.14"
|
|
51
53
|
},
|
|
52
54
|
"dependencies": {
|
|
53
55
|
"@hasna/events": "^0.1.6",
|
|
56
|
+
"@hasna/secrets": "^0.2.22",
|
|
54
57
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
55
58
|
"chalk": "^5.3.0",
|
|
56
59
|
"commander": "^12.1.0",
|
|
@@ -60,7 +63,8 @@
|
|
|
60
63
|
"ink-spinner": "^5.0.0",
|
|
61
64
|
"ink-text-input": "^6.0.0",
|
|
62
65
|
"pg": "^8.16.3",
|
|
63
|
-
"react": "^18.2.0"
|
|
66
|
+
"react": "^18.2.0",
|
|
67
|
+
"zod": "3.25.76"
|
|
64
68
|
},
|
|
65
69
|
"engines": {
|
|
66
70
|
"bun": ">=1.0.0"
|