workflow-toolkit 1.0.1

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 (142) hide show
  1. package/.agents/skills/ponytail/SKILL.md +111 -0
  2. package/.agents/skills/ponytail-audit/SKILL.md +37 -0
  3. package/.agents/skills/ponytail-debt/SKILL.md +41 -0
  4. package/.agents/skills/ponytail-gain/SKILL.md +47 -0
  5. package/.agents/skills/ponytail-help/SKILL.md +70 -0
  6. package/.agents/skills/ponytail-review/SKILL.md +52 -0
  7. package/.agents/skills/prompt-review/SKILL.md +60 -0
  8. package/.agents/skills/wtk/SKILL.md +64 -0
  9. package/.agents/skills/wtk/references/artifacts.md +82 -0
  10. package/.agents/skills/wtk/references/evidence.md +68 -0
  11. package/.agents/skills/wtk/references/git.md +75 -0
  12. package/.agents/skills/wtk/references/test-contract.md +76 -0
  13. package/.agents/skills/wtk/references/validation.md +127 -0
  14. package/.agents/skills/wtk-config/SKILL.md +102 -0
  15. package/.agents/skills/wtk-config/assets/agents/claude/deep-reviewer.md +40 -0
  16. package/.agents/skills/wtk-config/assets/agents/claude/designer.md +57 -0
  17. package/.agents/skills/wtk-config/assets/agents/claude/explorer.md +41 -0
  18. package/.agents/skills/wtk-config/assets/agents/claude/implementer.md +57 -0
  19. package/.agents/skills/wtk-config/assets/agents/claude/planner.md +68 -0
  20. package/.agents/skills/wtk-config/assets/agents/claude/verifier.md +80 -0
  21. package/.agents/skills/wtk-config/assets/agents/codex/deep-reviewer.toml +41 -0
  22. package/.agents/skills/wtk-config/assets/agents/codex/designer.toml +55 -0
  23. package/.agents/skills/wtk-config/assets/agents/codex/explorer.toml +35 -0
  24. package/.agents/skills/wtk-config/assets/agents/codex/implementer.toml +52 -0
  25. package/.agents/skills/wtk-config/assets/agents/codex/planner.toml +66 -0
  26. package/.agents/skills/wtk-config/assets/agents/codex/verifier.toml +78 -0
  27. package/.agents/skills/wtk-config/assets/agents/cursor/deep-reviewer.md +38 -0
  28. package/.agents/skills/wtk-config/assets/agents/cursor/designer.md +55 -0
  29. package/.agents/skills/wtk-config/assets/agents/cursor/explorer.md +39 -0
  30. package/.agents/skills/wtk-config/assets/agents/cursor/implementer.md +55 -0
  31. package/.agents/skills/wtk-config/assets/agents/cursor/planner.md +66 -0
  32. package/.agents/skills/wtk-config/assets/agents/cursor/verifier.md +79 -0
  33. package/.agents/skills/wtk-config/scripts/ad-index.py +123 -0
  34. package/.agents/skills/wtk-config/scripts/repository_intelligence.py +671 -0
  35. package/.agents/skills/wtk-config/scripts/workflow_config.py +972 -0
  36. package/.agents/skills/wtk-deep-review/SKILL.md +164 -0
  37. package/.agents/skills/wtk-deep-review/assets/PROMPT.md +56 -0
  38. package/.agents/skills/wtk-deep-review/assets/REVIEW_UI.html +1340 -0
  39. package/.agents/skills/wtk-deep-review/assets/findings.schema.json +113 -0
  40. package/.agents/skills/wtk-deep-review/references/context-pack.md +88 -0
  41. package/.agents/skills/wtk-deep-review/references/orchestration.md +164 -0
  42. package/.agents/skills/wtk-deep-review/references/output-contracts.md +149 -0
  43. package/.agents/skills/wtk-deep-review/references/publish-github.md +83 -0
  44. package/.agents/skills/wtk-deep-review/references/state-and-learnings.md +64 -0
  45. package/.agents/skills/wtk-deep-review/references/subagent-runtimes.md +33 -0
  46. package/.agents/skills/wtk-deep-review/references/taxonomy.md +70 -0
  47. package/.agents/skills/wtk-deep-review/scripts/_common.py +371 -0
  48. package/.agents/skills/wtk-deep-review/scripts/build_jobs.py +561 -0
  49. package/.agents/skills/wtk-deep-review/scripts/build_knowledge.py +349 -0
  50. package/.agents/skills/wtk-deep-review/scripts/build_manifest.py +479 -0
  51. package/.agents/skills/wtk-deep-review/scripts/graft_context.py +160 -0
  52. package/.agents/skills/wtk-deep-review/scripts/graphify_context.py +75 -0
  53. package/.agents/skills/wtk-deep-review/scripts/merge_findings.py +313 -0
  54. package/.agents/skills/wtk-deep-review/scripts/render_html.py +240 -0
  55. package/.agents/skills/wtk-deep-review/scripts/render_review.py +282 -0
  56. package/.agents/skills/wtk-deep-review/scripts/run_jobs.py +436 -0
  57. package/.agents/skills/wtk-deep-review/scripts/token_metrics.py +369 -0
  58. package/.agents/skills/wtk-discover/SKILL.md +270 -0
  59. package/.agents/skills/wtk-discover/references/document-format.md +149 -0
  60. package/.agents/skills/wtk-implement/SKILL.md +95 -0
  61. package/.agents/skills/wtk-implement/references/checklist-format.md +116 -0
  62. package/.agents/skills/wtk-implement/references/screens.md +129 -0
  63. package/.agents/skills/wtk-implement/references/test-policy.md +152 -0
  64. package/.agents/skills/wtk-implement/references/verify.md +297 -0
  65. package/.agents/skills/wtk-knowledge-check/SKILL.md +16 -0
  66. package/.agents/skills/wtk-knowledge-check/scripts/check.ts +502 -0
  67. package/.agents/skills/wtk-knowledge-check/scripts/cli.ts +32 -0
  68. package/.agents/skills/wtk-knowledge-check/scripts/frontmatter.ts +44 -0
  69. package/.agents/skills/wtk-lean/.skill-meta.json +6 -0
  70. package/.agents/skills/wtk-lean/NOTICE.md +18 -0
  71. package/.agents/skills/wtk-lean/SKILL.md +137 -0
  72. package/.agents/skills/wtk-lean/references/build.md +168 -0
  73. package/.agents/skills/wtk-lean/references/checks.md +253 -0
  74. package/.agents/skills/wtk-lean/references/memory.md +156 -0
  75. package/.agents/skills/wtk-lean/references/plan.md +514 -0
  76. package/.agents/skills/wtk-lean/references/verify.md +336 -0
  77. package/.agents/skills/wtk-lean/scripts/check_commit.py +121 -0
  78. package/.agents/skills/wtk-lean/scripts/fixtures/checks.md +98 -0
  79. package/.agents/skills/wtk-lean/scripts/fixtures/plan.md +119 -0
  80. package/.agents/skills/wtk-lean/scripts/fixtures/verification.md +41 -0
  81. package/.agents/skills/wtk-lean/scripts/lessons.py +412 -0
  82. package/.agents/skills/wtk-lean/scripts/selftest.py +401 -0
  83. package/.agents/skills/wtk-lean/scripts/validate_checks.py +417 -0
  84. package/.agents/skills/wtk-lean/scripts/validate_plan.py +580 -0
  85. package/.agents/skills/wtk-lean/scripts/validate_verification.py +357 -0
  86. package/.agents/skills/wtk-plan/SKILL.md +102 -0
  87. package/.agents/skills/wtk-plan/references/document-format.md +222 -0
  88. package/.agents/skills/wtk-qa/SKILL.md +14 -0
  89. package/.agents/skills/wtk-qa-execute/SKILL.md +115 -0
  90. package/.agents/skills/wtk-qa-execute/references/fix-loop.md +23 -0
  91. package/.agents/skills/wtk-qa-execute/references/session-protocol.md +25 -0
  92. package/.agents/skills/wtk-qa-plan/SKILL.md +111 -0
  93. package/.agents/skills/wtk-qa-plan/references/profile.md +35 -0
  94. package/.agents/skills/wtk-ship/SKILL.md +49 -0
  95. package/.agents/skills/wtk-ship/remediation.py +170 -0
  96. package/.agents/skills/wtk-ship/scripts/close_feature.py +83 -0
  97. package/.agents/skills/wtk-ship/scripts/review_convergence.py +373 -0
  98. package/.wtk.toml.example +93 -0
  99. package/AGENTS.md +129 -0
  100. package/NOTICE.md +12 -0
  101. package/README.md +440 -0
  102. package/bin/wtk.js +25 -0
  103. package/docs/toolkit/README.md +56 -0
  104. package/docs/toolkit/decisions.md +61 -0
  105. package/docs/toolkit/guidelines/CONTEXT-BUDGET.md +64 -0
  106. package/docs/toolkit/guidelines/DX.md +67 -0
  107. package/docs/toolkit/guidelines/FRONTEND.md +73 -0
  108. package/docs/toolkit/guidelines/KNOWLEDGE-WIKI.md +128 -0
  109. package/docs/toolkit/guidelines/MODELING.md +134 -0
  110. package/docs/toolkit/guidelines/QA-EXECUTION.md +46 -0
  111. package/docs/toolkit/guidelines/QA-SCENARIOS.md +165 -0
  112. package/docs/toolkit/guidelines/REVIEW-ROUNDS.md +160 -0
  113. package/docs/toolkit/guidelines/SECURITY.md +160 -0
  114. package/docs/toolkit/guidelines/UI-UX.md +117 -0
  115. package/docs/toolkit/guidelines/WORKFLOW-MEMORY.md +73 -0
  116. package/docs/toolkit/guidelines.md +50 -0
  117. package/docs/toolkit/loop.md +77 -0
  118. package/docs/toolkit/purpose.md +51 -0
  119. package/docs/toolkit/repository-intelligence.md +53 -0
  120. package/docs/toolkit/reviews.md +77 -0
  121. package/knowledge/AGENTS.md +316 -0
  122. package/knowledge/raw/README.md +17 -0
  123. package/package.json +74 -0
  124. package/scripts/install_security_skills.py +816 -0
  125. package/scripts/installer/engine.js +254 -0
  126. package/scripts/installer/knowledge.js +28 -0
  127. package/scripts/installer/packets.js +77 -0
  128. package/scripts/installer/terminal.js +98 -0
  129. package/scripts/installer/transaction.js +75 -0
  130. package/skills-lock.json +92 -0
  131. package/templates/adoption/agents/core.md +23 -0
  132. package/templates/adoption/agents/quality.md +3 -0
  133. package/templates/adoption/knowledge/wiki/architecture/index.md +3 -0
  134. package/templates/adoption/knowledge/wiki/decisions/index.md +3 -0
  135. package/templates/adoption/knowledge/wiki/design/index.md +3 -0
  136. package/templates/adoption/knowledge/wiki/domain/index.md +3 -0
  137. package/templates/adoption/knowledge/wiki/index.md +3 -0
  138. package/templates/adoption/knowledge/wiki/log.md +3 -0
  139. package/templates/adoption/knowledge/wiki/open-questions/index.md +3 -0
  140. package/templates/adoption/knowledge/wiki/product/index.md +3 -0
  141. package/templates/adoption/knowledge/wiki/research/index.md +3 -0
  142. package/templates/adoption/product/AGENT-CONTEXT.md +25 -0
@@ -0,0 +1,816 @@
1
+ #!/usr/bin/env python3
2
+ """Install the workflow's pinned external security skills into a consumer."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import hashlib
8
+ import json
9
+ import os
10
+ import secrets
11
+ import shutil
12
+ import stat
13
+ import subprocess
14
+ import sys
15
+ import tempfile
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+
19
+ CLI_VERSION = "1.5.23"
20
+ LOCK_VERSION = 1
21
+ FIXED_SYSTEM_BIN_DIRS = ("/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin")
22
+ SKILLS = {
23
+ "security-implementation": (
24
+ "antoniofulg/security-lifecycle",
25
+ "skills/security-implementation/SKILL.md",
26
+ "8b356c2c8f17a788cb14db86316b3add39f620e1",
27
+ ),
28
+ "security-review": (
29
+ "antoniofulg/security-lifecycle",
30
+ "skills/security-review/SKILL.md",
31
+ "8b356c2c8f17a788cb14db86316b3add39f620e1",
32
+ ),
33
+ "security-spec": (
34
+ "antoniofulg/security-lifecycle",
35
+ "skills/security-spec/SKILL.md",
36
+ "8b356c2c8f17a788cb14db86316b3add39f620e1",
37
+ ),
38
+ "security-threat-model": (
39
+ "antoniofulg/security-lifecycle",
40
+ "skills/security-threat-model/SKILL.md",
41
+ "8b356c2c8f17a788cb14db86316b3add39f620e1",
42
+ ),
43
+ }
44
+
45
+
46
+ class InstallationError(RuntimeError):
47
+ """A validation or installation failure that must fail closed."""
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class LockedSkill:
52
+ name: str
53
+ source: str
54
+ skill_path: str
55
+ cli_version: str
56
+ ref: str
57
+ computed_hash: str
58
+
59
+
60
+ def read_locked_skills(pack_root: Path) -> list[LockedSkill]:
61
+ try:
62
+ document = json.loads((pack_root / "skills-lock.json").read_text(encoding="utf-8"))
63
+ except (OSError, json.JSONDecodeError) as exc:
64
+ raise InstallationError(f"cannot read skills-lock.json: {exc}") from exc
65
+
66
+ entries = document.get("skills")
67
+ if not isinstance(entries, dict):
68
+ raise InstallationError("skills-lock.json has no skills object")
69
+
70
+ locked: list[LockedSkill] = []
71
+ for name, (expected_source, expected_path, expected_ref) in SKILLS.items():
72
+ entry = entries.get(name)
73
+ if not isinstance(entry, dict):
74
+ raise InstallationError(f"missing allowlisted lock entry: {name}")
75
+ if entry.get("sourceType") != "github":
76
+ raise InstallationError(f"{name} must use sourceType github")
77
+ if entry.get("source") != expected_source:
78
+ raise InstallationError(f"{name} has an unapproved source")
79
+ if entry.get("skillPath") != expected_path:
80
+ raise InstallationError(f"{name} has a non-canonical skillPath")
81
+ if entry.get("cliVersion") != CLI_VERSION:
82
+ raise InstallationError(f"{name} has an unsupported CLI version")
83
+ ref = entry.get("ref")
84
+ if not isinstance(ref, str) or len(ref) != 40 or any(c not in "0123456789abcdef" for c in ref):
85
+ raise InstallationError(f"{name} must use a lowercase 40-hex commit ref")
86
+ if ref != expected_ref:
87
+ raise InstallationError(f"{name} has an unapproved commit ref")
88
+ computed_hash = entry.get("computedHash")
89
+ if not isinstance(computed_hash, str) or len(computed_hash) != 64 or any(
90
+ c not in "0123456789abcdef" for c in computed_hash
91
+ ):
92
+ raise InstallationError(f"{name} must use a lowercase 64-hex computedHash")
93
+ locked.append(LockedSkill(name, expected_source, expected_path, CLI_VERSION, ref, computed_hash))
94
+ return locked
95
+
96
+
97
+ def tree_hash(directory: Path) -> str:
98
+ """Match skills@1.5.23's path-then-bytes recursive folder hash."""
99
+
100
+ files: list[tuple[str, Path]] = []
101
+ for path in directory.rglob("*"):
102
+ relative = path.relative_to(directory)
103
+ if any(part in {".git", "node_modules"} for part in relative.parts):
104
+ continue
105
+ if path.is_file() and not path.is_symlink():
106
+ files.append((relative.as_posix(), path))
107
+ digest = hashlib.sha256()
108
+ for relative, path in sorted(files):
109
+ digest.update(relative.encode())
110
+ digest.update(path.read_bytes())
111
+ return digest.hexdigest()
112
+
113
+
114
+ def validate_tree_entries(directory: Path) -> None:
115
+ """Reject links and special files in an untrusted staged skill."""
116
+
117
+ for entry in os.scandir(directory):
118
+ if entry.name in {".git", "node_modules"}:
119
+ raise InstallationError(f"staged skill contains forbidden entry: {entry.path}")
120
+ info = entry.stat(follow_symlinks=False)
121
+ mode = info.st_mode
122
+ if stat.S_ISLNK(mode):
123
+ raise InstallationError(f"staged skill contains symlink: {entry.path}")
124
+ if stat.S_ISDIR(mode):
125
+ validate_tree_entries(Path(entry.path))
126
+ elif not stat.S_ISREG(mode):
127
+ raise InstallationError(f"staged skill contains non-regular entry: {entry.path}")
128
+ elif info.st_nlink != 1:
129
+ raise InstallationError(f"staged skill contains hardlinked file: {entry.path}")
130
+
131
+
132
+ def plan_lines(locked: list[LockedSkill], target: Path) -> list[str]:
133
+ lines = [
134
+ "External security skill installation is not authorized.",
135
+ "Plan (no network access and no target writes):",
136
+ ]
137
+ for skill in locked:
138
+ lines.append(" " + " ".join(cli_command(skill, "bunx")))
139
+ lines.append(f"Re-run for the authorized target: {target} --yes")
140
+ return lines
141
+
142
+
143
+ def snapshot_path(root: Path, relative: str, snapshot_root: Path) -> None:
144
+ source = root / relative
145
+ if not source.exists() and not source.is_symlink():
146
+ return
147
+ destination = snapshot_root / relative
148
+ destination.parent.mkdir(parents=True, exist_ok=True)
149
+ if source.is_symlink():
150
+ destination.symlink_to(os.readlink(source))
151
+ elif source.is_dir():
152
+ shutil.copytree(source, destination, symlinks=True)
153
+ else:
154
+ shutil.copy2(source, destination)
155
+
156
+
157
+ def open_directory_chain(root: Path, parts: tuple[str, ...], create: bool = False) -> int:
158
+ flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
159
+ fd = os.open(root, flags)
160
+ try:
161
+ for part in parts:
162
+ try:
163
+ child = os.open(part, flags, dir_fd=fd)
164
+ except FileNotFoundError:
165
+ if not create:
166
+ raise
167
+ os.mkdir(part, 0o700, dir_fd=fd)
168
+ child = os.open(part, flags, dir_fd=fd)
169
+ os.close(fd)
170
+ fd = child
171
+ return fd
172
+ except BaseException:
173
+ os.close(fd)
174
+ raise
175
+
176
+
177
+ def remove_leaf(parent_fd: int, name: str) -> None:
178
+ try:
179
+ mode = os.stat(name, dir_fd=parent_fd, follow_symlinks=False).st_mode
180
+ except FileNotFoundError:
181
+ return
182
+ if stat.S_ISDIR(mode) and not stat.S_ISLNK(mode):
183
+ shutil.rmtree(name, dir_fd=parent_fd)
184
+ else:
185
+ os.unlink(name, dir_fd=parent_fd)
186
+
187
+
188
+ def restore_path(root: Path, relative: str, snapshot_root: Path) -> None:
189
+ parts = Path(relative).parts
190
+ try:
191
+ parent_fd = open_directory_chain(root, parts[:-1], create=True)
192
+ except OSError as exc:
193
+ raise InstallationError(f"rollback path escapes target: {relative}") from exc
194
+ leaf = parts[-1]
195
+ try:
196
+ remove_leaf(parent_fd, leaf)
197
+ saved = snapshot_root / relative
198
+ if not saved.exists() and not saved.is_symlink():
199
+ return
200
+ if saved.is_symlink():
201
+ os.symlink(os.readlink(saved), leaf, dir_fd=parent_fd)
202
+ return
203
+ if saved.is_dir():
204
+ staged = tempfile.mkdtemp(prefix="restore-", dir=snapshot_root)
205
+ shutil.rmtree(staged)
206
+ shutil.copytree(saved, staged, symlinks=True)
207
+ else:
208
+ staged_handle, staged_name = tempfile.mkstemp(prefix="restore-", dir=snapshot_root)
209
+ os.close(staged_handle)
210
+ shutil.copy2(saved, staged_name)
211
+ staged = staged_name
212
+ os.replace(staged, leaf, dst_dir_fd=parent_fd)
213
+ finally:
214
+ os.close(parent_fd)
215
+
216
+
217
+ def path_is_within(path: Path, root: Path) -> bool:
218
+ try:
219
+ path.resolve(strict=False).relative_to(root.resolve())
220
+ except ValueError:
221
+ return False
222
+ return True
223
+
224
+
225
+ def validate_managed_paths(target: Path, relatives: list[str]) -> None:
226
+ root = target.resolve()
227
+ for relative in relatives:
228
+ current = root
229
+ for part in Path(relative).parts:
230
+ current /= part
231
+ if current.is_symlink() and not path_is_within(current, root):
232
+ raise InstallationError(f"managed path escapes target through symlink: {relative}")
233
+
234
+
235
+ class TargetLock:
236
+ """An atomic target-local directory lock held for the entire transaction."""
237
+
238
+ def __init__(self, target: Path) -> None:
239
+ self.path = target / ".my-workflow-security-skills.lock"
240
+ self.token = secrets.token_hex(16)
241
+
242
+ def __enter__(self) -> "TargetLock":
243
+ try:
244
+ self.path.mkdir(mode=0o700)
245
+ except FileExistsError as exc:
246
+ if self._recover_stale():
247
+ try:
248
+ self.path.mkdir(mode=0o700)
249
+ except FileExistsError:
250
+ raise InstallationError("another security-skill installation is active for this target") from exc
251
+ else:
252
+ raise InstallationError("another security-skill installation is active for this target") from exc
253
+ except OSError as exc:
254
+ raise InstallationError(f"cannot acquire target installation lock: {exc}") from exc
255
+ try:
256
+ (self.path / "owner").write_text(
257
+ f"pid={os.getpid()}\ntoken={self.token}\n", encoding="utf-8"
258
+ )
259
+ except OSError as exc:
260
+ self._release()
261
+ raise InstallationError(f"cannot initialize target installation lock: {exc}") from exc
262
+ return self
263
+
264
+ def __exit__(self, _exc_type: object, _exc: object, _traceback: object) -> None:
265
+ self._release()
266
+
267
+ def _recover_stale(self) -> bool:
268
+ if self.path.is_symlink() or not self.path.is_dir():
269
+ return False
270
+ owner = self.path / "owner"
271
+ try:
272
+ values = dict(
273
+ line.split("=", 1)
274
+ for line in owner.read_text(encoding="utf-8").splitlines()
275
+ if "=" in line
276
+ )
277
+ pid = int(values["pid"])
278
+ except (OSError, ValueError, KeyError):
279
+ return False
280
+ try:
281
+ os.kill(pid, 0)
282
+ except ProcessLookupError:
283
+ try:
284
+ owner.unlink()
285
+ self.path.rmdir()
286
+ return True
287
+ except OSError:
288
+ return False
289
+ except PermissionError:
290
+ return False
291
+ return False
292
+
293
+ def _release(self) -> None:
294
+ owner = self.path / "owner"
295
+ try:
296
+ if f"token={self.token}" not in owner.read_text(encoding="utf-8").splitlines():
297
+ return
298
+ owner.unlink()
299
+ self.path.rmdir()
300
+ except (FileNotFoundError, OSError):
301
+ pass
302
+
303
+
304
+ def remove_empty_install_parents(target: Path) -> None:
305
+ for relative in (".agents/skills", ".agents", ".claude/skills", ".claude"):
306
+ directory = target / relative
307
+ if directory.is_dir() and not directory.is_symlink():
308
+ try:
309
+ directory.rmdir()
310
+ except OSError:
311
+ pass
312
+
313
+
314
+ def rollback_paths(target: Path, affected: list[str], snapshot: Path) -> None:
315
+ try:
316
+ validate_managed_paths(target, affected)
317
+ except InstallationError:
318
+ return
319
+ for relative in affected:
320
+ restore_path(target, relative, snapshot)
321
+
322
+
323
+ def root_skills_object(content: str) -> tuple[int, int]:
324
+ """Locate the root object's top-level skills member without matching nested keys."""
325
+
326
+ decoder = json.JSONDecoder()
327
+ root_start = 0
328
+ while root_start < len(content) and content[root_start].isspace():
329
+ root_start += 1
330
+ try:
331
+ root, root_end = decoder.raw_decode(content, root_start)
332
+ except json.JSONDecodeError as exc:
333
+ raise InstallationError(f"installed lock has invalid root object: {exc}") from exc
334
+ if not isinstance(root, dict) or content[root_start] != "{":
335
+ raise InstallationError("installed lock has no root object")
336
+
337
+ cursor = root_start + 1
338
+ while True:
339
+ while cursor < root_end and content[cursor].isspace():
340
+ cursor += 1
341
+ if cursor >= root_end - 1:
342
+ break
343
+ try:
344
+ key, cursor = decoder.raw_decode(content, cursor)
345
+ except json.JSONDecodeError as exc:
346
+ raise InstallationError(f"installed lock has invalid root key: {exc}") from exc
347
+ while cursor < root_end and content[cursor].isspace():
348
+ cursor += 1
349
+ if cursor >= root_end or content[cursor] != ":":
350
+ raise InstallationError("installed lock has invalid root entry")
351
+ cursor += 1
352
+ while cursor < root_end and content[cursor].isspace():
353
+ cursor += 1
354
+ value_start = cursor
355
+ try:
356
+ value, value_end = decoder.raw_decode(content, value_start)
357
+ except json.JSONDecodeError as exc:
358
+ raise InstallationError(f"installed lock has invalid root entry: {exc}") from exc
359
+ if key == "skills":
360
+ if not isinstance(value, dict):
361
+ raise InstallationError("installed lock has no skills object")
362
+ return value_start, value_end
363
+ cursor = value_end
364
+ while cursor < root_end and content[cursor].isspace():
365
+ cursor += 1
366
+ if cursor < root_end and content[cursor] == ",":
367
+ cursor += 1
368
+ elif cursor < root_end - 1:
369
+ raise InstallationError("installed lock has invalid root separators")
370
+ raise InstallationError("installed lock has no skills object")
371
+
372
+
373
+ def merge_managed_lock_entries(content: str, locked: list[LockedSkill]) -> str:
374
+ """Replace only managed skill members while retaining unrelated member bytes."""
375
+
376
+ decoder = json.JSONDecoder()
377
+ object_start, object_end = root_skills_object(content)
378
+ try:
379
+ skills = decoder.raw_decode(content, object_start)[0]
380
+ except json.JSONDecodeError as exc:
381
+ raise InstallationError(f"installed lock has invalid skills object: {exc}") from exc
382
+ if not isinstance(skills, dict):
383
+ raise InstallationError("installed lock has no skills object")
384
+
385
+ cursor = object_start + 1
386
+ members: list[tuple[str, str]] = []
387
+ while True:
388
+ while cursor < object_end and content[cursor].isspace():
389
+ cursor += 1
390
+ if cursor >= object_end - 1:
391
+ break
392
+ key_start = cursor
393
+ try:
394
+ key, cursor = decoder.raw_decode(content, cursor)
395
+ except json.JSONDecodeError as exc:
396
+ raise InstallationError(f"installed lock has invalid skill key: {exc}") from exc
397
+ while cursor < object_end and content[cursor].isspace():
398
+ cursor += 1
399
+ if cursor >= object_end or content[cursor] != ":":
400
+ raise InstallationError("installed lock has invalid skill entry")
401
+ cursor += 1
402
+ while cursor < object_end and content[cursor].isspace():
403
+ cursor += 1
404
+ try:
405
+ _, value_end = decoder.raw_decode(content, cursor)
406
+ except json.JSONDecodeError as exc:
407
+ raise InstallationError(f"installed lock has invalid skill entry: {exc}") from exc
408
+ members.append((key, content[key_start:value_end]))
409
+ cursor = value_end
410
+ while cursor < object_end and content[cursor].isspace():
411
+ cursor += 1
412
+ if cursor < object_end and content[cursor] == ",":
413
+ cursor += 1
414
+ elif cursor < object_end - 1:
415
+ raise InstallationError("installed lock has invalid skill separators")
416
+
417
+ managed_names = {skill.name for skill in locked}
418
+ preserved = [" " + raw for name, raw in members if name not in managed_names]
419
+ for skill in locked:
420
+ entry = {
421
+ "source": skill.source,
422
+ "sourceType": "github",
423
+ "skillPath": skill.skill_path,
424
+ "cliVersion": skill.cli_version,
425
+ "ref": skill.ref,
426
+ "computedHash": skill.computed_hash,
427
+ }
428
+ raw = json.dumps({skill.name: entry}, indent=2)[1:-1].strip()
429
+ preserved.append("\n".join(" " + line for line in raw.splitlines()))
430
+
431
+ replacement = "{\n" + ",\n".join(preserved) + "\n }"
432
+ merged = content[:object_start] + replacement + content[object_end:]
433
+ try:
434
+ json.loads(merged)
435
+ except json.JSONDecodeError as exc:
436
+ raise InstallationError(f"merged lock is invalid: {exc}") from exc
437
+ return merged
438
+
439
+
440
+ def merge_lock(target: Path, locked: list[LockedSkill]) -> None:
441
+ target_fd = open_directory_chain(target, tuple())
442
+ try:
443
+ try:
444
+ lock_fd = os.open(
445
+ "skills-lock.json",
446
+ os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0),
447
+ dir_fd=target_fd,
448
+ )
449
+ try:
450
+ content = os.read(lock_fd, 10 * 1024 * 1024).decode()
451
+ finally:
452
+ os.close(lock_fd)
453
+ document = json.loads(content)
454
+ except FileNotFoundError:
455
+ document = {"version": LOCK_VERSION, "skills": {}}
456
+ content = json.dumps(document, indent=2) + "\n"
457
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
458
+ raise InstallationError(f"installed lock is invalid: {exc}") from exc
459
+ if not isinstance(document, dict) or not isinstance(document.get("skills"), dict):
460
+ raise InstallationError("installed lock has no skills object")
461
+ document["version"] = document.get("version", LOCK_VERSION)
462
+ merged_content = merge_managed_lock_entries(content, locked)
463
+ staged_handle, staged_name = tempfile.mkstemp(prefix="skills-lock-", dir=target)
464
+ try:
465
+ with os.fdopen(staged_handle, "w", encoding="utf-8") as staged_file:
466
+ staged_file.write(merged_content)
467
+ staged_file.flush()
468
+ os.fsync(staged_file.fileno())
469
+ os.replace(staged_name, "skills-lock.json", dst_dir_fd=target_fd)
470
+ except BaseException:
471
+ try:
472
+ os.unlink(staged_name)
473
+ except FileNotFoundError:
474
+ pass
475
+ raise
476
+ finally:
477
+ os.close(target_fd)
478
+
479
+
480
+ def lexical_path_is_within(path: Path, root: Path) -> bool:
481
+ try:
482
+ Path(os.path.abspath(path)).relative_to(Path(os.path.abspath(root)))
483
+ except ValueError:
484
+ return False
485
+ return True
486
+
487
+
488
+ def validate_tool_candidate(candidate: Path, untrusted_roots: tuple[Path, ...]) -> tuple[str, tuple[str, ...]]:
489
+ lexical = Path(os.path.abspath(candidate))
490
+ if any(lexical_path_is_within(lexical, root) for root in untrusted_roots):
491
+ raise InstallationError(f"unsafe {candidate.name} executable location")
492
+ try:
493
+ resolved = candidate.resolve(strict=True)
494
+ info = resolved.stat()
495
+ except OSError as exc:
496
+ raise InstallationError(f"invalid {candidate.name} executable: {candidate}") from exc
497
+ if any(path_is_within(resolved, root) for root in untrusted_roots):
498
+ raise InstallationError(f"unsafe {candidate.name} executable target")
499
+ if not stat.S_ISREG(info.st_mode) or not os.access(resolved, os.X_OK):
500
+ raise InstallationError(f"invalid {candidate.name} executable: {candidate}")
501
+ directories = (str(lexical.parent), str(resolved.parent))
502
+ # Validate the resolved target, but preserve the caller's original absolute
503
+ # candidate for execution. Host shims (mise/asdf/Homebrew) dispatch based
504
+ # on argv[0] and may fail when replaced by their realpath.
505
+ return str(lexical), directories
506
+
507
+
508
+ def resolve_active_binary(
509
+ name: str,
510
+ original_path: str,
511
+ untrusted_roots: tuple[Path, ...],
512
+ ) -> tuple[str, tuple[str, ...]]:
513
+ """Resolve active caller tooling before child environment scrubbing."""
514
+
515
+ for raw_directory in original_path.split(os.pathsep):
516
+ directory = Path(raw_directory or os.curdir).absolute()
517
+ candidate = directory / name
518
+ if candidate.exists() or candidate.is_symlink():
519
+ return validate_tool_candidate(candidate, untrusted_roots)
520
+ for directory in FIXED_SYSTEM_BIN_DIRS:
521
+ candidate = Path(directory) / name
522
+ if candidate.exists() or candidate.is_symlink():
523
+ return validate_tool_candidate(candidate, untrusted_roots)
524
+ raise InstallationError(f"trusted {name} executable unavailable")
525
+
526
+
527
+ def cli_command(skill: LockedSkill, bunx: str) -> list[str]:
528
+ return [
529
+ bunx,
530
+ "--bun",
531
+ "--no-install",
532
+ "skills",
533
+ "add",
534
+ f"{skill.source}#{skill.ref}",
535
+ "--skill",
536
+ skill.name,
537
+ "--agent",
538
+ "universal",
539
+ "--copy",
540
+ "--yes",
541
+ ]
542
+
543
+
544
+ def cli_version_command(bunx: str) -> list[str]:
545
+ """Ask the locally resolvable skills binary for its exact version."""
546
+
547
+ return [bunx, "--bun", "--no-install", "skills", "--version"]
548
+
549
+
550
+ def verify_cli_version(bunx: str, environment: dict[str, str], cwd: Path) -> None:
551
+ """Fail closed before any mutating skills command can run."""
552
+
553
+ result = subprocess.run(
554
+ cli_version_command(bunx),
555
+ cwd=cwd,
556
+ check=False,
557
+ capture_output=True,
558
+ text=True,
559
+ env=environment,
560
+ )
561
+ if result.returncode != 0:
562
+ raise InstallationError("skills CLI preflight failed")
563
+ if result.stdout.strip() != CLI_VERSION:
564
+ raise InstallationError(
565
+ f"skills CLI preflight reported {result.stdout.strip()!r}, expected {CLI_VERSION}"
566
+ )
567
+
568
+
569
+ def child_environment(
570
+ wrapper_root: Path | None = None,
571
+ tool_directories: tuple[str, ...] = (),
572
+ ) -> dict[str, str]:
573
+ """Pass execution, locale, temporary-directory, and home settings only."""
574
+
575
+ allowed = ("PATH", "HOME", "TMPDIR", "TMP", "TEMP", "LANG", "LC_ALL", "LC_CTYPE")
576
+ environment = {name: os.environ[name] for name in allowed if name in os.environ}
577
+ fixed_path = []
578
+ for directory in (*tool_directories, *FIXED_SYSTEM_BIN_DIRS):
579
+ if directory not in fixed_path:
580
+ fixed_path.append(directory)
581
+ environment["PATH"] = os.pathsep.join(fixed_path)
582
+ if wrapper_root is not None:
583
+ environment["PATH"] = f"{wrapper_root}{os.pathsep}{environment['PATH']}"
584
+ return environment
585
+
586
+
587
+ def pinned_git_environment(
588
+ wrapper_root: Path,
589
+ real_git: str,
590
+ tool_directories: tuple[str, ...],
591
+ ) -> dict[str, str]:
592
+ """Make skills@1.5.23's shallow clone accept a pinned commit ref."""
593
+
594
+ wrapper = wrapper_root / "git"
595
+ wrapper.write_text(
596
+ "#!/usr/bin/env python3\n"
597
+ "import subprocess, sys\n"
598
+ f"real_git = {real_git!r}\n"
599
+ "args = sys.argv[1:]\n"
600
+ "if 'clone' in args:\n"
601
+ " ref = None\n"
602
+ " stripped = []\n"
603
+ " index = 0\n"
604
+ " while index < len(args):\n"
605
+ " if args[index] == '--branch' and index + 1 < len(args):\n"
606
+ " ref = args[index + 1]\n"
607
+ " index += 2\n"
608
+ " continue\n"
609
+ " stripped.append(args[index])\n"
610
+ " index += 1\n"
611
+ " if ref and len(ref) == 40 and all(c in '0123456789abcdef' for c in ref):\n"
612
+ " result = subprocess.run([real_git, *stripped])\n"
613
+ " if result.returncode == 0:\n"
614
+ " checkout = stripped[-1]\n"
615
+ " fetched = subprocess.run([real_git, '-C', checkout, 'fetch', '--depth=1', 'origin', ref])\n"
616
+ " if fetched.returncode == 0:\n"
617
+ " return_code = subprocess.run([real_git, '-C', checkout, 'checkout', '--detach', 'FETCH_HEAD']).returncode\n"
618
+ " raise SystemExit(return_code)\n"
619
+ " result = fetched\n"
620
+ " raise SystemExit(result.returncode)\n"
621
+ "raise SystemExit(subprocess.run([real_git, *args]).returncode)\n",
622
+ encoding="utf-8",
623
+ )
624
+ wrapper.chmod(0o755)
625
+ return child_environment(wrapper_root, tool_directories)
626
+
627
+
628
+ def verify_installation(target: Path, locked: list[LockedSkill]) -> None:
629
+ validate_managed_paths(target, [
630
+ *(f".agents/skills/{skill.name}" for skill in locked),
631
+ *(f".claude/skills/{skill.name}" for skill in locked),
632
+ ])
633
+ for skill in locked:
634
+ installed = target / ".agents" / "skills" / skill.name
635
+ claude = target / ".claude" / "skills" / skill.name
636
+ if not installed.is_dir():
637
+ raise InstallationError(f"{skill.name} was not installed in .agents/skills")
638
+ validate_tree_entries(installed)
639
+ if tree_hash(installed) != skill.computed_hash:
640
+ raise InstallationError(f"{skill.name} failed its pinned tree hash")
641
+ if not claude.is_symlink() or claude.resolve() != installed.resolve():
642
+ raise InstallationError(f"{skill.name} has no matching Claude link")
643
+
644
+
645
+ def ensure_staged_claude_link(staging: Path, skill: LockedSkill) -> None:
646
+ """Create the project-owned Claude link after the universal CLI copy."""
647
+
648
+ validate_managed_paths(
649
+ staging,
650
+ [f".agents/skills/{skill.name}", f".claude/skills/{skill.name}"],
651
+ )
652
+ installed = staging / ".agents" / "skills" / skill.name
653
+ claude = staging / ".claude" / "skills" / skill.name
654
+ if claude.exists() or claude.is_symlink():
655
+ if not claude.is_symlink() or claude.resolve() != installed.resolve():
656
+ raise InstallationError(f"{skill.name} has an invalid Claude link")
657
+ return
658
+ claude.parent.mkdir(parents=True, exist_ok=True)
659
+ claude.symlink_to(Path("../../.agents/skills") / skill.name)
660
+
661
+
662
+ def publish_installation(staging: Path, target: Path, locked: list[LockedSkill]) -> None:
663
+ validate_managed_paths(target, [".agents/skills", ".claude/skills"])
664
+ agents_fd = open_directory_chain(target, (".agents", "skills"), create=True)
665
+ try:
666
+ claude_fd = open_directory_chain(target, (".claude", "skills"), create=True)
667
+ except BaseException:
668
+ os.close(agents_fd)
669
+ raise
670
+ try:
671
+ for skill in locked:
672
+ staged_skill = staging / ".agents" / "skills" / skill.name
673
+ staged_claude = staging / ".claude" / "skills" / skill.name
674
+ if not staged_skill.is_dir() or staged_skill.is_symlink():
675
+ raise InstallationError(f"staged skill is not a regular directory: {skill.name}")
676
+ if not staged_claude.is_symlink():
677
+ raise InstallationError(f"staged Claude path is not a symlink: {skill.name}")
678
+ remove_leaf(agents_fd, skill.name)
679
+ os.replace(staged_skill, skill.name, dst_dir_fd=agents_fd)
680
+ remove_leaf(claude_fd, skill.name)
681
+ os.replace(staged_claude, skill.name, dst_dir_fd=claude_fd)
682
+ finally:
683
+ os.close(agents_fd)
684
+ os.close(claude_fd)
685
+
686
+
687
+ def transaction_directory(target: Path, prefix: str) -> Path:
688
+ """Create transaction storage on target's filesystem for atomic rollback."""
689
+
690
+ return Path(tempfile.mkdtemp(prefix=prefix, dir=target))
691
+
692
+
693
+ def perform_installation(target: Path, locked: list[LockedSkill], pack_root: Path) -> None:
694
+ affected = [
695
+ *(f".agents/skills/{skill.name}" for skill in locked),
696
+ *(f".claude/skills/{skill.name}" for skill in locked),
697
+ "skills-lock.json",
698
+ ]
699
+ snapshot = transaction_directory(target, "my-workflow-security-install-")
700
+ try:
701
+ for relative in affected:
702
+ snapshot_path(target, relative, snapshot)
703
+ # Keep the CLI's working directory under the pack so Bun resolves the
704
+ # locked `skills` package from pack_root/node_modules. The target
705
+ # remains untouched until the descriptor-relative publish step.
706
+ staging = Path(tempfile.mkdtemp(prefix="my-workflow-security-staging-", dir=pack_root))
707
+ try:
708
+ (snapshot / "git-wrapper").mkdir(parents=True, exist_ok=True)
709
+ original_path = os.environ.get("PATH", "")
710
+ # Keep both lexical and canonical spellings. On macOS `/var` may
711
+ # be a symlink to `/private/var`; the lexical candidate guard must
712
+ # still reject a tool named inside the target before resolution.
713
+ untrusted_roots = (
714
+ Path(os.path.abspath(target)),
715
+ target.resolve(),
716
+ Path(os.path.abspath(staging)),
717
+ staging.resolve(),
718
+ Path(os.path.abspath(pack_root)),
719
+ pack_root.resolve(),
720
+ )
721
+ bunx, bunx_directories = resolve_active_binary("bunx", original_path, untrusted_roots)
722
+ git, git_directories = resolve_active_binary("git", original_path, untrusted_roots)
723
+ environment = pinned_git_environment(
724
+ snapshot / "git-wrapper",
725
+ git,
726
+ tuple(dict.fromkeys((*bunx_directories, *git_directories))),
727
+ )
728
+ environment["HOME"] = str(staging)
729
+ environment["CODEX_HOME"] = str(staging / ".codex")
730
+ environment["XDG_STATE_HOME"] = str(staging / ".state")
731
+ verify_cli_version(bunx, environment, staging)
732
+ for skill in locked:
733
+ command = cli_command(skill, bunx)
734
+ result = subprocess.run(command, cwd=staging, check=False, env=environment)
735
+ if result.returncode != 0:
736
+ raise InstallationError(f"skills CLI failed for {skill.name}")
737
+ ensure_staged_claude_link(staging, skill)
738
+ staging_managed = [
739
+ *(f".agents/skills/{skill.name}" for skill in locked),
740
+ *(f".claude/skills/{skill.name}" for skill in locked),
741
+ ]
742
+ validate_managed_paths(staging, staging_managed)
743
+ verify_installation(staging, locked)
744
+ validate_managed_paths(target, affected)
745
+ publish_installation(staging, target, locked)
746
+ verify_installation(target, locked)
747
+ merge_lock(target, locked)
748
+ except (OSError, InstallationError):
749
+ rollback_paths(target, affected, snapshot)
750
+ remove_empty_install_parents(target)
751
+ raise
752
+ finally:
753
+ shutil.rmtree(staging, ignore_errors=True)
754
+ finally:
755
+ shutil.rmtree(snapshot, ignore_errors=True)
756
+
757
+
758
+ def install(pack_root: Path, target: Path, authorized: bool) -> int:
759
+ locked = read_locked_skills(pack_root)
760
+ if not authorized:
761
+ print("\n".join(plan_lines(locked, target)))
762
+ return 2
763
+ if not target.is_dir():
764
+ raise InstallationError(f"not a target directory: {target}")
765
+
766
+ managed = [
767
+ *(f".agents/skills/{skill.name}" for skill in locked),
768
+ *(f".claude/skills/{skill.name}" for skill in locked),
769
+ "skills-lock.json",
770
+ ".my-workflow-security-skills.lock",
771
+ ]
772
+ try:
773
+ validate_managed_paths(target, managed)
774
+ with TargetLock(target):
775
+ perform_installation(target, locked, pack_root)
776
+ except (OSError, InstallationError) as exc:
777
+ names = ", ".join(skill.name for skill in locked)
778
+ print(f"Security skills unavailable: {names}", file=sys.stderr)
779
+ print(
780
+ "Security gate remains uncovered; do not treat SECURITY.md as covered.",
781
+ file=sys.stderr,
782
+ )
783
+ print(f"Installation failed: {exc}", file=sys.stderr)
784
+ return 1
785
+
786
+ print(f"Installed pinned security skills into {target / '.agents' / 'skills'}")
787
+ print("Claude links point to the shared .agents/skills tree.")
788
+ return 0
789
+
790
+
791
+ def parse_args(argv: list[str]) -> argparse.Namespace:
792
+ parser = argparse.ArgumentParser(description=__doc__)
793
+ parser.add_argument("target", type=Path)
794
+ parser.add_argument("--yes", action="store_true", help="authorize network access and target writes")
795
+ return parser.parse_args(argv)
796
+
797
+
798
+ def main(argv: list[str] | None = None) -> int:
799
+ args = parse_args(sys.argv[1:] if argv is None else argv)
800
+ pack_root = Path(__file__).resolve().parent.parent
801
+ try:
802
+ # Preserve the caller's lexical target spelling for candidate-root
803
+ # checks; internal path operations canonicalize where required.
804
+ return install(pack_root, args.target, args.yes)
805
+ except InstallationError as exc:
806
+ print(f"Security skills unavailable: {', '.join(SKILLS)}", file=sys.stderr)
807
+ print(
808
+ "Security gate remains uncovered; do not treat SECURITY.md as covered.",
809
+ file=sys.stderr,
810
+ )
811
+ print(f"Installation refused: {exc}", file=sys.stderr)
812
+ return 1
813
+
814
+
815
+ if __name__ == "__main__":
816
+ raise SystemExit(main())