@delorenj/pjangler 1.3.7 → 1.4.2

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.
Files changed (27) hide show
  1. package/.mise/scripts/link-agentfiles.sh +38 -5
  2. package/README.md +20 -0
  3. package/dist/assets/project-notebook-skill/SHA256SUMS +10 -0
  4. package/dist/assets/project-notebook-skill/SKILL.md +64 -0
  5. package/dist/assets/project-notebook-skill/agents/openai.yaml +6 -0
  6. package/dist/assets/project-notebook-skill/export-manifest.json +56 -0
  7. package/dist/assets/project-notebook-skill/hooks/claude.settings.json +26 -0
  8. package/dist/assets/project-notebook-skill/hooks/hooks.master.json +26 -0
  9. package/dist/assets/project-notebook-skill/hooks/session-end.sh +228 -0
  10. package/dist/assets/project-notebook-skill/hooks/session-start.sh +228 -0
  11. package/dist/assets/project-notebook-skill/references/configuration.md +93 -0
  12. package/dist/assets/project-notebook-skill/references/recovery.md +54 -0
  13. package/dist/assets/project-notebook-skill/scripts/project-hooks.py +865 -0
  14. package/dist/assets/project-notebook-skill/tests/test_project_hooks.py +848 -0
  15. package/dist/index.js +14320 -7847
  16. package/dist/mcp-server.js +13482 -8146
  17. package/package.json +4 -3
  18. package/templates/commonproject/copier.yml +14 -5
  19. package/templates/commonproject/template/.mise/scripts/link-agentfiles.sh +38 -5
  20. package/templates/commonproject/template/.mise/scripts/provision-packs.py +60 -2
  21. package/templates/commonproject/template/.mise/scripts/sync-skills.py +479 -24
  22. package/templates/commonproject/template/mise.toml.jinja +12 -6
  23. package/templates/hermes-agent/template/.gitignore.jinja +1 -0
  24. package/templates/hermes-agent/template/.scripts/70-systemd.sh +11 -7
  25. package/templates/hermes-agent/template/.scripts/_lib.sh +54 -10
  26. package/templates/hermes-agent/template/.scripts/lib/parse-fleet-env.py +29 -2
  27. package/templates/hermes-agent/template/.scripts/providers/plane.sh +18 -2
@@ -0,0 +1,848 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import hashlib
5
+ import importlib.util
6
+ import json
7
+ import os
8
+ import pwd
9
+ import stat
10
+ import subprocess
11
+ import sys
12
+ import tempfile
13
+ import time
14
+ import unittest
15
+ from pathlib import Path
16
+ from unittest import mock
17
+
18
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
19
+ SCRIPT = SKILL_ROOT / "scripts" / "project-hooks.py"
20
+ MASTER_PATH = SKILL_ROOT / "hooks" / "hooks.master.json"
21
+ FRAGMENT_PATH = SKILL_ROOT / "hooks" / "claude.settings.json"
22
+ OWNER_PREFIX = "PJ_HOOK_OWNER=project-notebook.v1 "
23
+ START_COMMAND = (
24
+ "PJ_HOOK_OWNER=project-notebook.v1 "
25
+ '"$HOME/.agents/skills/project-notebook/hooks/session-start.sh"'
26
+ )
27
+ END_COMMAND = (
28
+ 'PJ_HOOK_OWNER=project-notebook.v1 "$HOME/.agents/skills/project-notebook/hooks/session-end.sh"'
29
+ )
30
+
31
+
32
+ def _load_projector():
33
+ spec = importlib.util.spec_from_file_location("project_notebook_hooks_tested", SCRIPT)
34
+ if spec is None or spec.loader is None:
35
+ raise RuntimeError("cannot load projector")
36
+ module = importlib.util.module_from_spec(spec)
37
+ sys.modules[spec.name] = module
38
+ spec.loader.exec_module(module)
39
+ return module
40
+
41
+
42
+ PROJECTOR = _load_projector()
43
+
44
+
45
+ class ProjectHooksTests(unittest.TestCase):
46
+ maxDiff = None
47
+
48
+ def setUp(self) -> None:
49
+ self.temporary = tempfile.TemporaryDirectory()
50
+ self.addCleanup(self.temporary.cleanup)
51
+ self.root = Path(self.temporary.name)
52
+ self.claude = self.root / ".claude"
53
+ self.claude.mkdir(mode=0o700)
54
+ self.target = self.claude / "settings.json"
55
+ self.state_home = self.root / "state"
56
+ self.master = json.loads(MASTER_PATH.read_text(encoding="utf-8"))
57
+
58
+ def run_projector(self, *arguments: str) -> subprocess.CompletedProcess[str]:
59
+ environment = os.environ.copy()
60
+ environment["PYTHONDONTWRITEBYTECODE"] = "1"
61
+ return subprocess.run(
62
+ [sys.executable, "-B", str(SCRIPT), *arguments],
63
+ text=True,
64
+ capture_output=True,
65
+ check=False,
66
+ env=environment,
67
+ )
68
+
69
+ def write_json(self, value: dict, *, compact: bool = False) -> bytes:
70
+ if compact:
71
+ raw = json.dumps(value, separators=(",", ":"), ensure_ascii=False).encode()
72
+ else:
73
+ raw = (json.dumps(value, indent=2, ensure_ascii=False) + "\n").encode()
74
+ self.target.write_bytes(raw)
75
+ return raw
76
+
77
+ def mutation_arguments(self, command: str) -> list[str]:
78
+ return [
79
+ command,
80
+ "--master",
81
+ str(MASTER_PATH),
82
+ "--fragment",
83
+ str(FRAGMENT_PATH),
84
+ "--target",
85
+ str(self.target),
86
+ "--state-home",
87
+ str(self.state_home),
88
+ ]
89
+
90
+ def canonical_hook(self, event: str) -> dict:
91
+ return copy.deepcopy(self.master["hooks"][event][0]["hooks"][0])
92
+
93
+ def make_fake_pj(
94
+ self, *, name: str = "trusted-home", exit_code: int = 0, hang: bool = False
95
+ ) -> tuple[Path, Path]:
96
+ fake_home = self.root / name
97
+ local = fake_home / ".local"
98
+ binary_directory = local / "bin"
99
+ fake_home.mkdir(mode=0o700)
100
+ local.mkdir(mode=0o700)
101
+ binary_directory.mkdir(mode=0o700)
102
+ resolved_directory = fake_home / "apps" / "pjangler" / "dist"
103
+ resolved_directory.mkdir(parents=True, mode=0o770)
104
+ for directory in (
105
+ fake_home / "apps",
106
+ fake_home / "apps" / "pjangler",
107
+ resolved_directory,
108
+ ):
109
+ directory.chmod(0o770)
110
+ resolved_binary = resolved_directory / "pj-real"
111
+ resolved_binary.write_text(
112
+ "const chunks = [];\n"
113
+ "process.stdin.on('data', (chunk) => chunks.push(chunk));\n"
114
+ "process.stdin.on('end', () => {\n"
115
+ " const report = {\n"
116
+ " argv: process.argv.slice(2),\n"
117
+ " bytes: Buffer.concat(chunks).length,\n"
118
+ " env: {\n"
119
+ " HOME: process.env.HOME,\n"
120
+ " USER: process.env.USER,\n"
121
+ " LOGNAME: process.env.LOGNAME,\n"
122
+ " PATH: process.env.PATH,\n"
123
+ " LANG: process.env.LANG,\n"
124
+ " LC_ALL: process.env.LC_ALL,\n"
125
+ " BASH_ENV: process.env.BASH_ENV ?? null,\n"
126
+ " NODE_OPTIONS: process.env.NODE_OPTIONS ?? null,\n"
127
+ " NODE_PATH: process.env.NODE_PATH ?? null,\n"
128
+ " authPresent: Object.hasOwn(process.env, 'OPEN_NOTEBOOK_PASSWORD'),\n"
129
+ " },\n"
130
+ " };\n"
131
+ " console.log(`trusted-pj ${JSON.stringify(report)}`);\n"
132
+ + (" setInterval(() => {}, 10000);\n" if hang else f" process.exit({exit_code});\n")
133
+ + "});\n",
134
+ encoding="utf-8",
135
+ )
136
+ resolved_binary.chmod(0o770)
137
+ launcher = binary_directory / "pj"
138
+ launcher.symlink_to(resolved_binary)
139
+ return fake_home, resolved_binary
140
+
141
+ def snapshot_tree(self, root: Path) -> dict[str, tuple[str, int, bytes | str]]:
142
+ snapshot: dict[str, tuple[str, int, bytes | str]] = {}
143
+ for path in sorted([root, *root.rglob("*")]):
144
+ relative = "." if path == root else str(path.relative_to(root))
145
+ mode = stat.S_IMODE(path.lstat().st_mode)
146
+ if path.is_symlink():
147
+ snapshot[relative] = ("symlink", mode, os.readlink(path))
148
+ elif path.is_dir():
149
+ snapshot[relative] = ("directory", mode, b"")
150
+ else:
151
+ snapshot[relative] = ("file", mode, path.read_bytes())
152
+ return snapshot
153
+
154
+ def run_wrapper(
155
+ self,
156
+ wrapper: str,
157
+ fake_home: Path,
158
+ payload: bytes,
159
+ *,
160
+ path_prefix: Path | None = None,
161
+ extra_environment: dict[str, str] | None = None,
162
+ ) -> subprocess.CompletedProcess[bytes]:
163
+ instrumented_directory = self.root / "instrumented-wrappers"
164
+ instrumented_directory.mkdir(mode=0o700, exist_ok=True)
165
+ source = (SKILL_ROOT / "hooks" / wrapper).read_text(encoding="utf-8")
166
+ identity = pwd.getpwuid(os.getuid())
167
+ original_lookup = "entry = pwd.getpwuid(os.geteuid())"
168
+ replacement_lookup = (
169
+ "entry = pwd.struct_passwd(("
170
+ f"{identity.pw_name!r}, 'x', {identity.pw_uid}, {identity.pw_gid}, '', "
171
+ f"{str(fake_home)!r}, {identity.pw_shell!r}))"
172
+ )
173
+ self.assertIn(original_lookup, source)
174
+ instrumented = instrumented_directory / wrapper
175
+ instrumented.write_text(
176
+ source.replace(original_lookup, replacement_lookup, 1), encoding="utf-8"
177
+ )
178
+ instrumented.chmod(0o700)
179
+ environment = os.environ.copy()
180
+ environment["HOME"] = str(self.root / "attacker-controlled-home")
181
+ environment["USER"] = "attacker-user"
182
+ environment["LOGNAME"] = "attacker-logname"
183
+ environment["PATH"] = (
184
+ f"{path_prefix}:/usr/bin:/bin" if path_prefix is not None else "/usr/bin:/bin"
185
+ )
186
+ if extra_environment:
187
+ environment.update(extra_environment)
188
+ return subprocess.run(
189
+ [str(instrumented)],
190
+ input=payload,
191
+ capture_output=True,
192
+ check=False,
193
+ env=environment,
194
+ )
195
+
196
+ def test_master_fragment_and_wrappers_are_exact(self) -> None:
197
+ self.assertEqual(MASTER_PATH.read_bytes(), FRAGMENT_PATH.read_bytes())
198
+ self.assertEqual(list(self.master["hooks"]), ["SessionStart", "SessionEnd"])
199
+ self.assertNotIn("Stop", self.master["hooks"])
200
+ self.assertEqual(self.canonical_hook("SessionStart")["command"], START_COMMAND)
201
+ self.assertEqual(self.canonical_hook("SessionStart")["timeout"], 3)
202
+ self.assertEqual(self.canonical_hook("SessionEnd")["command"], END_COMMAND)
203
+ self.assertEqual(self.canonical_hook("SessionEnd")["timeout"], 1)
204
+
205
+ start_wrapper = (SKILL_ROOT / "hooks" / "session-start.sh").read_text()
206
+ end_wrapper = (SKILL_ROOT / "hooks" / "session-end.sh").read_text()
207
+ self.assertTrue(start_wrapper.startswith("#!/usr/bin/python3 -I\n"))
208
+ self.assertTrue(end_wrapper.startswith("#!/usr/bin/python3 -I\n"))
209
+ self.assertIn('HOOK_EVENT = "session-start"', start_wrapper)
210
+ self.assertIn('HOOK_EVENT = "session-close"', end_wrapper)
211
+ self.assertIn("REQUEST_LIMIT_BYTES = 1_048_576", start_wrapper + end_wrapper)
212
+ self.assertIn("STREAM_LIMIT_BYTES = REQUEST_LIMIT_BYTES + 1", start_wrapper + end_wrapper)
213
+ self.assertIn("len(payload) > REQUEST_LIMIT_BYTES", start_wrapper + end_wrapper)
214
+ self.assertIn('NODE_BINARY = Path("/usr/bin/node")', start_wrapper + end_wrapper)
215
+ self.assertIn("pwd.getpwuid(os.geteuid())", start_wrapper + end_wrapper)
216
+ self.assertNotIn("#!/bin/bash", start_wrapper + end_wrapper)
217
+ self.assertNotIn("--payload-file", start_wrapper + end_wrapper)
218
+ self.assertNotIn("mktemp", start_wrapper + end_wrapper)
219
+ self.assertNotIn("XDG_STATE_HOME", start_wrapper + end_wrapper)
220
+ self.assertNotIn("notebook hook Stop", start_wrapper + end_wrapper)
221
+
222
+ def test_wrappers_stream_bounded_stdin_to_only_the_fixed_trusted_binary(self) -> None:
223
+ fake_home, _ = self.make_fake_pj()
224
+ self.assertTrue((fake_home / ".local" / "bin" / "pj").is_symlink())
225
+ malicious_directory = self.root / "malicious-path"
226
+ malicious_directory.mkdir(mode=0o700)
227
+ malicious = malicious_directory / "pj"
228
+ malicious.write_text(
229
+ "#!/usr/bin/python3 -I\nraise SystemExit('MALICIOUS')\n", encoding="utf-8"
230
+ )
231
+ malicious.chmod(0o700)
232
+ malicious_node = malicious_directory / "node"
233
+ malicious_node.write_text(
234
+ "#!/usr/bin/python3 -I\nraise SystemExit('MALICIOUS NODE')\n",
235
+ encoding="utf-8",
236
+ )
237
+ malicious_node.chmod(0o700)
238
+ bash_marker = self.root / "bash-env-executed"
239
+ bash_environment = self.root / "bash-environment"
240
+ bash_environment.write_text(f"/usr/bin/touch {bash_marker}\n", encoding="utf-8")
241
+ node_marker = self.root / "node-options-executed"
242
+ node_preload = self.root / "node-preload.js"
243
+ node_preload.write_text(
244
+ f"require('fs').writeFileSync({str(node_marker)!r}, 'executed');\n",
245
+ encoding="utf-8",
246
+ )
247
+ malicious_modules = self.root / "malicious-node-path"
248
+ malicious_modules.mkdir(mode=0o700)
249
+ injected_environment = {
250
+ "BASH_ENV": str(bash_environment),
251
+ "NODE_OPTIONS": f"--require={node_preload}",
252
+ "NODE_PATH": str(malicious_modules),
253
+ "OPEN_NOTEBOOK_PASSWORD": "test-only-placeholder",
254
+ }
255
+ before = self.snapshot_tree(fake_home)
256
+ payload = b"x" * 1048576
257
+
258
+ start = self.run_wrapper(
259
+ "session-start.sh",
260
+ fake_home,
261
+ payload,
262
+ path_prefix=malicious_directory,
263
+ extra_environment=injected_environment,
264
+ )
265
+ self.assertEqual(start.returncode, 0, start.stderr.decode())
266
+ start_report = json.loads(start.stdout.decode().removeprefix("trusted-pj "))
267
+ self.assertEqual(start_report["argv"], ["notebook", "hook", "session-start"])
268
+ self.assertEqual(start_report["bytes"], 1048576)
269
+ identity = pwd.getpwuid(os.getuid())
270
+ self.assertEqual(
271
+ start_report["env"],
272
+ {
273
+ "HOME": str(fake_home),
274
+ "USER": identity.pw_name,
275
+ "LOGNAME": identity.pw_name,
276
+ "PATH": "/usr/bin:/bin",
277
+ "LANG": "C.UTF-8",
278
+ "LC_ALL": "C.UTF-8",
279
+ "BASH_ENV": None,
280
+ "NODE_OPTIONS": None,
281
+ "NODE_PATH": None,
282
+ "authPresent": True,
283
+ },
284
+ )
285
+ self.assertNotIn(b"MALICIOUS", start.stdout + start.stderr)
286
+ self.assertFalse(bash_marker.exists())
287
+ self.assertFalse(node_marker.exists())
288
+ self.assertFalse((self.root / "attacker-controlled-home").exists())
289
+ self.assertEqual(self.snapshot_tree(fake_home), before)
290
+
291
+ end = self.run_wrapper(
292
+ "session-end.sh",
293
+ fake_home,
294
+ payload,
295
+ path_prefix=malicious_directory,
296
+ extra_environment=injected_environment,
297
+ )
298
+ self.assertEqual(end.returncode, 0, end.stderr.decode())
299
+ end_report = json.loads(end.stdout.decode().removeprefix("trusted-pj "))
300
+ self.assertEqual(end_report["argv"], ["notebook", "hook", "session-close"])
301
+ self.assertEqual(end_report["bytes"], 1048576)
302
+ self.assertEqual(end_report["env"], start_report["env"])
303
+ self.assertNotIn(b"MALICIOUS", end.stdout + end.stderr)
304
+ self.assertFalse(bash_marker.exists())
305
+ self.assertFalse(node_marker.exists())
306
+ self.assertEqual(self.snapshot_tree(fake_home), before)
307
+
308
+ oversize_payload = b"x" * (1048576 + 8192)
309
+ for wrapper in ("session-start.sh", "session-end.sh"):
310
+ with self.subTest(wrapper=wrapper):
311
+ oversize = self.run_wrapper(
312
+ wrapper,
313
+ fake_home,
314
+ oversize_payload,
315
+ path_prefix=malicious_directory,
316
+ extra_environment=injected_environment,
317
+ )
318
+ self.assertEqual(oversize.returncode, 0)
319
+ self.assertEqual(oversize.stdout, b"")
320
+ self.assertIn(b"payload exceeds 1048576 bytes", oversize.stderr)
321
+ self.assertFalse(bash_marker.exists())
322
+ self.assertFalse(node_marker.exists())
323
+ self.assertEqual(self.snapshot_tree(fake_home), before)
324
+
325
+ def test_wrappers_fail_open_without_creating_state(self) -> None:
326
+ missing_home = self.root / "missing-pj-home"
327
+ missing_home.mkdir(mode=0o700)
328
+ before_missing = self.snapshot_tree(missing_home)
329
+ skipped = self.run_wrapper(
330
+ "session-start.sh",
331
+ missing_home,
332
+ b'{"disabled":true}',
333
+ )
334
+ self.assertEqual(skipped.returncode, 0)
335
+ self.assertIn(b"skipped; trusted path is unavailable", skipped.stderr)
336
+ self.assertEqual(self.snapshot_tree(missing_home), before_missing)
337
+
338
+ fake_home, binary = self.make_fake_pj(name="failed-home", exit_code=42)
339
+ before_failure = self.snapshot_tree(fake_home)
340
+ failed = self.run_wrapper(
341
+ "session-end.sh",
342
+ fake_home,
343
+ b'{"capture":false}',
344
+ )
345
+ self.assertEqual(failed.returncode, 0)
346
+ self.assertIn(b"failed open", failed.stderr)
347
+ self.assertEqual(self.snapshot_tree(fake_home), before_failure)
348
+
349
+ binary.chmod(0o772)
350
+ before_unsafe = self.snapshot_tree(fake_home)
351
+ unsafe = self.run_wrapper(
352
+ "session-start.sh",
353
+ fake_home,
354
+ b"ignored",
355
+ )
356
+ self.assertEqual(unsafe.returncode, 0)
357
+ self.assertIn(b"permissions are unsafe", unsafe.stderr)
358
+ self.assertNotIn(b"trusted-pj", unsafe.stdout)
359
+ self.assertEqual(self.snapshot_tree(fake_home), before_unsafe)
360
+
361
+ timeout_home, _ = self.make_fake_pj(name="timeout-home", hang=True)
362
+ before_timeout = self.snapshot_tree(timeout_home)
363
+ started = time.monotonic()
364
+ timed_out = self.run_wrapper("session-end.sh", timeout_home, b'{"policy":"disabled"}')
365
+ elapsed = time.monotonic() - started
366
+ self.assertEqual(timed_out.returncode, 0)
367
+ self.assertIn(b"timed out", timed_out.stderr)
368
+ self.assertLess(elapsed, 2.0)
369
+ self.assertEqual(self.snapshot_tree(timeout_home), before_timeout)
370
+
371
+ def test_render_is_deterministic_and_second_render_changes_zero_bytes(self) -> None:
372
+ rendered = self.root / "generated.json"
373
+ first = self.run_projector(
374
+ "render", "--master", str(MASTER_PATH), "--fragment", str(rendered)
375
+ )
376
+ self.assertEqual(first.returncode, 0, first.stderr)
377
+ first_bytes = rendered.read_bytes()
378
+ self.assertEqual(first_bytes, FRAGMENT_PATH.read_bytes())
379
+ first_stat = rendered.stat()
380
+
381
+ second = self.run_projector(
382
+ "render", "--master", str(MASTER_PATH), "--fragment", str(rendered)
383
+ )
384
+ self.assertEqual(second.returncode, 0, second.stderr)
385
+ self.assertIn("up to date", second.stdout)
386
+ self.assertEqual(rendered.read_bytes(), first_bytes)
387
+ self.assertEqual(rendered.stat().st_ino, first_stat.st_ino)
388
+
389
+ def test_check_is_pure_and_reports_all_required_finding_kinds(self) -> None:
390
+ stale = self.canonical_hook("SessionStart")
391
+ stale["timeout"] = 9
392
+ value = {
393
+ "theme": "dark",
394
+ "hooks": {
395
+ "SessionStart": [
396
+ {"hooks": [stale, self.canonical_hook("SessionStart")]},
397
+ {
398
+ "hooks": [
399
+ {
400
+ "type": "command",
401
+ "command": OWNER_PREFIX
402
+ + '"$HOME/.agents/skills/project-notebook/hooks/unknown.sh"',
403
+ "timeout": 1,
404
+ }
405
+ ]
406
+ },
407
+ ],
408
+ "SessionEnd": [
409
+ {
410
+ "hooks": [
411
+ {
412
+ "type": "command",
413
+ "command": START_COMMAND,
414
+ "timeout": 3,
415
+ }
416
+ ]
417
+ }
418
+ ],
419
+ "Stop": [{"hooks": [{"type": "command", "command": "foreign-stop"}]}],
420
+ },
421
+ }
422
+ before = self.write_json(value, compact=True)
423
+ result = self.run_projector(
424
+ "check",
425
+ "--master",
426
+ str(MASTER_PATH),
427
+ "--fragment",
428
+ str(FRAGMENT_PATH),
429
+ "--target",
430
+ str(self.target),
431
+ "--json",
432
+ )
433
+ self.assertEqual(result.returncode, 1, result.stderr)
434
+ report = json.loads(result.stdout)
435
+ kinds = {finding["kind"] for finding in report["findings"]}
436
+ self.assertTrue({"missing", "duplicate", "stale", "foreign-conflict"} <= kinds)
437
+ self.assertEqual(self.target.read_bytes(), before)
438
+ self.assertFalse(self.state_home.exists(), "pure check created recovery state")
439
+
440
+ def test_install_preserves_foreign_objects_order_and_is_byte_idempotent(self) -> None:
441
+ stale = self.canonical_hook("SessionStart")
442
+ stale["timeout"] = 11
443
+ stale["owned_extra"] = "discard"
444
+ stop = [{"matcher": "foreign", "hooks": [{"type": "command", "command": "foreign-stop"}]}]
445
+ value = {
446
+ "theme": "dark",
447
+ "foreign_top": {"nested": [1, 2, 3]},
448
+ "hooks": {
449
+ "SessionStart": [
450
+ {
451
+ "matcher": "startup",
452
+ "condition": {"mode": "interactive"},
453
+ "hooks": [
454
+ {"type": "command", "command": "foreign-before", "timeout": 7},
455
+ stale,
456
+ {"type": "command", "command": "foreign-after", "extra": True},
457
+ ],
458
+ "group_extra": "preserve",
459
+ },
460
+ {"hooks": [self.canonical_hook("SessionStart")]},
461
+ {
462
+ "matcher": "preserve-empty-group",
463
+ "hooks": [self.canonical_hook("SessionStart")],
464
+ "extra": {"preserve": True},
465
+ },
466
+ {
467
+ "label": "tail",
468
+ "hooks": [{"type": "command", "command": "foreign-tail"}],
469
+ },
470
+ ],
471
+ "Stop": stop,
472
+ "Notification": [{"hooks": [{"type": "command", "command": "foreign-notify"}]}],
473
+ },
474
+ }
475
+ before = self.write_json(value, compact=True)
476
+
477
+ first = self.run_projector(*self.mutation_arguments("install"))
478
+ self.assertEqual(first.returncode, 0, first.stderr)
479
+ self.assertIn("changed", first.stdout)
480
+ installed = json.loads(self.target.read_text())
481
+
482
+ self.assertEqual(installed["theme"], value["theme"])
483
+ self.assertEqual(installed["foreign_top"], value["foreign_top"])
484
+ self.assertEqual(installed["hooks"]["Stop"], stop)
485
+ self.assertEqual(installed["hooks"]["Notification"], value["hooks"]["Notification"])
486
+ groups = installed["hooks"]["SessionStart"]
487
+ self.assertEqual(
488
+ [group.get("matcher") for group in groups], ["startup", "preserve-empty-group", None]
489
+ )
490
+ self.assertEqual(groups[0]["condition"], {"mode": "interactive"})
491
+ self.assertEqual(groups[0]["group_extra"], "preserve")
492
+ self.assertEqual(groups[0]["hooks"][0], value["hooks"]["SessionStart"][0]["hooks"][0])
493
+ self.assertEqual(groups[0]["hooks"][1], self.canonical_hook("SessionStart"))
494
+ self.assertEqual(groups[0]["hooks"][2], value["hooks"]["SessionStart"][0]["hooks"][2])
495
+ self.assertEqual(groups[1]["hooks"], [])
496
+ self.assertEqual(groups[1]["extra"], {"preserve": True})
497
+ self.assertEqual(groups[2]["label"], "tail")
498
+ self.assertEqual(installed["hooks"]["SessionEnd"], self.master["hooks"]["SessionEnd"])
499
+
500
+ snapshot = (
501
+ self.state_home
502
+ / "pjangler"
503
+ / "notebook"
504
+ / "v1"
505
+ / "hook-install"
506
+ / "snapshots"
507
+ / f"{hashlib.sha256(before).hexdigest()}.json"
508
+ )
509
+ self.assertEqual(snapshot.read_bytes(), before)
510
+ self.assertEqual(stat.S_IMODE(snapshot.stat().st_mode), 0o600)
511
+ self.assertEqual(stat.S_IMODE(self.target.stat().st_mode), 0o600)
512
+ first_bytes = self.target.read_bytes()
513
+ first_inode = self.target.stat().st_ino
514
+ snapshots_before = sorted(snapshot.parent.iterdir())
515
+
516
+ second = self.run_projector(*self.mutation_arguments("install"))
517
+ self.assertEqual(second.returncode, 0, second.stderr)
518
+ self.assertIn("up to date", second.stdout)
519
+ self.assertEqual(self.target.read_bytes(), first_bytes)
520
+ self.assertEqual(self.target.stat().st_ino, first_inode)
521
+ self.assertEqual(sorted(snapshot.parent.iterdir()), snapshots_before)
522
+
523
+ clean = self.run_projector(
524
+ "check",
525
+ "--master",
526
+ str(MASTER_PATH),
527
+ "--fragment",
528
+ str(FRAGMENT_PATH),
529
+ "--target",
530
+ str(self.target),
531
+ )
532
+ self.assertEqual(clean.returncode, 0, clean.stdout + clean.stderr)
533
+
534
+ def test_install_rereads_target_after_acquiring_lock(self) -> None:
535
+ self.write_json({"foreign": "preflight"})
536
+ late_value = {"foreign": "changed-between-preflight-and-lock", "late": [1, 2]}
537
+ original_read_target = PROJECTOR._read_target
538
+ original_read_target_at = PROJECTOR._read_target_at
539
+ path_calls = 0
540
+ descriptor_calls = 0
541
+
542
+ def mutate_after_preflight(path: Path):
543
+ nonlocal path_calls
544
+ result = original_read_target(path)
545
+ if path == self.target:
546
+ path_calls += 1
547
+ if path_calls == 1:
548
+ self.target.write_text(json.dumps(late_value), encoding="utf-8")
549
+ return result
550
+
551
+ def count_descriptor_read(parent_fd: int, name: str):
552
+ nonlocal descriptor_calls
553
+ descriptor_calls += 1
554
+ return original_read_target_at(parent_fd, name)
555
+
556
+ with (
557
+ mock.patch.object(PROJECTOR, "_read_target", side_effect=mutate_after_preflight),
558
+ mock.patch.object(PROJECTOR, "_read_target_at", side_effect=count_descriptor_read),
559
+ ):
560
+ changed = PROJECTOR._mutate(
561
+ "install", MASTER_PATH, FRAGMENT_PATH, self.target, self.state_home
562
+ )
563
+ self.assertTrue(changed)
564
+ installed = json.loads(self.target.read_text())
565
+ self.assertEqual(installed["foreign"], late_value["foreign"])
566
+ self.assertEqual(installed["late"], late_value["late"])
567
+ self.assertEqual(path_calls, 1)
568
+ self.assertGreaterEqual(descriptor_calls, 4)
569
+
570
+ def test_descriptor_relative_replace_survives_ancestor_symlink_swap(self) -> None:
571
+ self.write_json({"foreign": "pinned-parent"}, compact=True)
572
+ outside = self.root / "outside"
573
+ outside.mkdir(mode=0o700)
574
+ outside_target = outside / "settings.json"
575
+ outside_bytes = b'{"outside":"must-not-change"}\n'
576
+ outside_target.write_bytes(outside_bytes)
577
+ pinned_parent = self.root / "pinned-claude-parent"
578
+ original_replace = os.replace
579
+ swapped = False
580
+
581
+ def swap_parent_then_replace(
582
+ source: str,
583
+ destination: str,
584
+ *,
585
+ src_dir_fd: int | None = None,
586
+ dst_dir_fd: int | None = None,
587
+ ) -> None:
588
+ nonlocal swapped
589
+ if destination == self.target.name and not swapped:
590
+ self.claude.rename(pinned_parent)
591
+ self.claude.symlink_to(outside, target_is_directory=True)
592
+ swapped = True
593
+ original_replace(
594
+ source,
595
+ destination,
596
+ src_dir_fd=src_dir_fd,
597
+ dst_dir_fd=dst_dir_fd,
598
+ )
599
+
600
+ with mock.patch.object(PROJECTOR.os, "replace", side_effect=swap_parent_then_replace):
601
+ changed = PROJECTOR._mutate(
602
+ "install", MASTER_PATH, FRAGMENT_PATH, self.target, self.state_home
603
+ )
604
+
605
+ self.assertTrue(changed)
606
+ self.assertTrue(swapped)
607
+ self.assertTrue(self.claude.is_symlink())
608
+ self.assertEqual(outside_target.read_bytes(), outside_bytes)
609
+ self.assertEqual(sorted(path.name for path in outside.iterdir()), ["settings.json"])
610
+ installed = json.loads((pinned_parent / "settings.json").read_text())
611
+ self.assertEqual(installed["foreign"], "pinned-parent")
612
+ self.assertEqual(installed["hooks"], self.master["hooks"])
613
+ self.assertEqual(
614
+ [path.name for path in pinned_parent.iterdir() if path.name.startswith(".")],
615
+ [],
616
+ )
617
+
618
+ def test_uninstall_removes_only_recognized_same_event_hooks(self) -> None:
619
+ unknown = {
620
+ "type": "command",
621
+ "command": OWNER_PREFIX + '"$HOME/.agents/skills/project-notebook/hooks/custom.sh"',
622
+ "timeout": 5,
623
+ }
624
+ prefix_similar = {
625
+ "type": "command",
626
+ "command": f"echo {START_COMMAND}",
627
+ "timeout": 5,
628
+ }
629
+ mismatched = {"type": "command", "command": END_COMMAND, "timeout": 1}
630
+ value = {
631
+ "hooks": {
632
+ "SessionStart": [
633
+ {"hooks": [self.canonical_hook("SessionStart")]},
634
+ {
635
+ "matcher": "keep-container",
636
+ "hooks": [self.canonical_hook("SessionStart")],
637
+ "condition": "foreign-extra",
638
+ },
639
+ {"hooks": [unknown]},
640
+ {"hooks": [prefix_similar]},
641
+ ],
642
+ "SessionEnd": [{"hooks": [self.canonical_hook("SessionEnd")]}],
643
+ "Stop": [{"matcher": "stop", "hooks": [mismatched]}],
644
+ },
645
+ "other": {"untouched": True},
646
+ }
647
+ self.write_json(value)
648
+ first = self.run_projector(*self.mutation_arguments("uninstall"))
649
+ self.assertEqual(first.returncode, 0, first.stderr)
650
+ uninstalled = json.loads(self.target.read_text())
651
+ self.assertNotIn("SessionEnd", uninstalled["hooks"])
652
+ self.assertEqual(uninstalled["other"], value["other"])
653
+ self.assertEqual(uninstalled["hooks"]["Stop"], value["hooks"]["Stop"])
654
+ remaining = uninstalled["hooks"]["SessionStart"]
655
+ self.assertEqual(remaining[0]["matcher"], "keep-container")
656
+ self.assertEqual(remaining[0]["condition"], "foreign-extra")
657
+ self.assertEqual(remaining[0]["hooks"], [])
658
+ self.assertEqual(remaining[1]["hooks"], [unknown])
659
+ self.assertEqual(remaining[2]["hooks"], [prefix_similar])
660
+
661
+ before_second = self.target.read_bytes()
662
+ inode = self.target.stat().st_ino
663
+ second = self.run_projector(*self.mutation_arguments("uninstall"))
664
+ self.assertEqual(second.returncode, 0, second.stderr)
665
+ self.assertEqual(self.target.read_bytes(), before_second)
666
+ self.assertEqual(self.target.stat().st_ino, inode)
667
+
668
+ def test_invalid_or_symlink_target_is_rejected_before_state_mutation(self) -> None:
669
+ invalid = b'{"hooks": '
670
+ self.target.write_bytes(invalid)
671
+ result = self.run_projector(*self.mutation_arguments("install"))
672
+ self.assertEqual(result.returncode, 2)
673
+ self.assertEqual(self.target.read_bytes(), invalid)
674
+ self.assertFalse(self.state_home.exists())
675
+
676
+ self.target.unlink()
677
+ real_target = self.root / "real-settings.json"
678
+ real_target.write_text("{}\n", encoding="utf-8")
679
+ self.target.symlink_to(real_target)
680
+ result = self.run_projector(*self.mutation_arguments("install"))
681
+ self.assertEqual(result.returncode, 2)
682
+ self.assertEqual(real_target.read_text(), "{}\n")
683
+ self.assertFalse(self.state_home.exists())
684
+
685
+ def test_explicit_null_hook_containers_are_bounded_validation_errors(self) -> None:
686
+ cases = (
687
+ {"theme": "dark", "hooks": None},
688
+ {"hooks": {"SessionStart": [{"matcher": "x", "hooks": None}]}},
689
+ )
690
+ for index, value in enumerate(cases):
691
+ with self.subTest(index=index):
692
+ before = self.write_json(value, compact=True)
693
+ state_home = self.root / f"null-state-{index}"
694
+ result = self.run_projector(
695
+ "install",
696
+ "--master",
697
+ str(MASTER_PATH),
698
+ "--fragment",
699
+ str(FRAGMENT_PATH),
700
+ "--target",
701
+ str(self.target),
702
+ "--state-home",
703
+ str(state_home),
704
+ )
705
+ self.assertEqual(result.returncode, 2)
706
+ self.assertNotIn("Traceback", result.stderr)
707
+ self.assertIn("must be", result.stderr)
708
+ self.assertEqual(self.target.read_bytes(), before)
709
+ self.assertFalse(state_home.exists())
710
+
711
+ def test_all_projector_paths_reject_ancestor_symlinks_before_mutation(self) -> None:
712
+ real_target_parent = self.root / "real-target-parent"
713
+ real_target_parent.mkdir(mode=0o700)
714
+ linked_target_parent = self.root / "linked-target-parent"
715
+ linked_target_parent.symlink_to(real_target_parent, target_is_directory=True)
716
+ linked_target = linked_target_parent / "missing-tail" / "settings.json"
717
+ target_state = self.root / "target-symlink-state"
718
+ target_result = self.run_projector(
719
+ "install",
720
+ "--master",
721
+ str(MASTER_PATH),
722
+ "--fragment",
723
+ str(FRAGMENT_PATH),
724
+ "--target",
725
+ str(linked_target),
726
+ "--state-home",
727
+ str(target_state),
728
+ )
729
+ self.assertEqual(target_result.returncode, 2)
730
+ self.assertNotIn("Traceback", target_result.stderr)
731
+ self.assertFalse((real_target_parent / "missing-tail").exists())
732
+ self.assertFalse(target_state.exists())
733
+
734
+ self.write_json({"foreign": "preserve"}, compact=True)
735
+ target_before = self.target.read_bytes()
736
+ real_state = self.root / "real-state"
737
+ real_state.mkdir(mode=0o700)
738
+ linked_state = self.root / "linked-state"
739
+ linked_state.symlink_to(real_state, target_is_directory=True)
740
+ state_result = self.run_projector(
741
+ "install",
742
+ "--master",
743
+ str(MASTER_PATH),
744
+ "--fragment",
745
+ str(FRAGMENT_PATH),
746
+ "--target",
747
+ str(self.target),
748
+ "--state-home",
749
+ str(linked_state / "missing-tail"),
750
+ )
751
+ self.assertEqual(state_result.returncode, 2)
752
+ self.assertNotIn("Traceback", state_result.stderr)
753
+ self.assertEqual(self.target.read_bytes(), target_before)
754
+ self.assertEqual(list(real_state.iterdir()), [])
755
+
756
+ linked_assets = self.root / "linked-assets"
757
+ linked_assets.symlink_to(SKILL_ROOT / "hooks", target_is_directory=True)
758
+ fragment_state = self.root / "fragment-symlink-state"
759
+ fragment_result = self.run_projector(
760
+ "install",
761
+ "--master",
762
+ str(MASTER_PATH),
763
+ "--fragment",
764
+ str(linked_assets / "claude.settings.json"),
765
+ "--target",
766
+ str(self.target),
767
+ "--state-home",
768
+ str(fragment_state),
769
+ )
770
+ self.assertEqual(fragment_result.returncode, 2)
771
+ self.assertNotIn("Traceback", fragment_result.stderr)
772
+ self.assertEqual(self.target.read_bytes(), target_before)
773
+ self.assertFalse(fragment_state.exists())
774
+
775
+ def make_hook_install(state_home: Path) -> Path:
776
+ current = state_home
777
+ current.mkdir(mode=0o700)
778
+ for name in ("pjangler", "notebook", "v1", "hook-install"):
779
+ current = current / name
780
+ current.mkdir(mode=0o700)
781
+ return current
782
+
783
+ external_lock = self.root / "external-lock"
784
+ external_lock.write_text("foreign", encoding="utf-8")
785
+ lock_state = self.root / "lock-symlink-state"
786
+ lock_install = make_hook_install(lock_state)
787
+ (lock_install / "snapshots").mkdir(mode=0o700)
788
+ (lock_install / "lock").symlink_to(external_lock)
789
+ lock_result = self.run_projector(
790
+ "install",
791
+ "--master",
792
+ str(MASTER_PATH),
793
+ "--fragment",
794
+ str(FRAGMENT_PATH),
795
+ "--target",
796
+ str(self.target),
797
+ "--state-home",
798
+ str(lock_state),
799
+ )
800
+ self.assertEqual(lock_result.returncode, 2)
801
+ self.assertNotIn("Traceback", lock_result.stderr)
802
+ self.assertEqual(external_lock.read_text(), "foreign")
803
+ self.assertEqual(self.target.read_bytes(), target_before)
804
+
805
+ snapshot_state = self.root / "snapshot-symlink-state"
806
+ snapshot_install = make_hook_install(snapshot_state)
807
+ external_snapshots = self.root / "external-snapshots"
808
+ external_snapshots.mkdir(mode=0o700)
809
+ (snapshot_install / "snapshots").symlink_to(external_snapshots, target_is_directory=True)
810
+ snapshot_result = self.run_projector(
811
+ "install",
812
+ "--master",
813
+ str(MASTER_PATH),
814
+ "--fragment",
815
+ str(FRAGMENT_PATH),
816
+ "--target",
817
+ str(self.target),
818
+ "--state-home",
819
+ str(snapshot_state),
820
+ )
821
+ self.assertEqual(snapshot_result.returncode, 2)
822
+ self.assertNotIn("Traceback", snapshot_result.stderr)
823
+ self.assertFalse((snapshot_install / "lock").exists())
824
+ self.assertEqual(list(external_snapshots.iterdir()), [])
825
+ self.assertEqual(self.target.read_bytes(), target_before)
826
+
827
+ def test_absent_target_gets_recoverable_empty_object_snapshot(self) -> None:
828
+ self.claude.rmdir()
829
+ result = self.run_projector(*self.mutation_arguments("install"))
830
+ self.assertEqual(result.returncode, 0, result.stderr)
831
+ self.assertTrue(self.claude.is_dir())
832
+ self.assertEqual(stat.S_IMODE(self.claude.stat().st_mode), 0o700)
833
+ empty_digest = hashlib.sha256(b"{}\n").hexdigest()
834
+ snapshot = (
835
+ self.state_home
836
+ / "pjangler"
837
+ / "notebook"
838
+ / "v1"
839
+ / "hook-install"
840
+ / "snapshots"
841
+ / f"{empty_digest}.json"
842
+ )
843
+ self.assertEqual(snapshot.read_bytes(), b"{}\n")
844
+ self.assertEqual(json.loads(self.target.read_text()), self.master)
845
+
846
+
847
+ if __name__ == "__main__":
848
+ unittest.main()