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.
- package/.agents/skills/ponytail/SKILL.md +111 -0
- package/.agents/skills/ponytail-audit/SKILL.md +37 -0
- package/.agents/skills/ponytail-debt/SKILL.md +41 -0
- package/.agents/skills/ponytail-gain/SKILL.md +47 -0
- package/.agents/skills/ponytail-help/SKILL.md +70 -0
- package/.agents/skills/ponytail-review/SKILL.md +52 -0
- package/.agents/skills/prompt-review/SKILL.md +60 -0
- package/.agents/skills/wtk/SKILL.md +64 -0
- package/.agents/skills/wtk/references/artifacts.md +82 -0
- package/.agents/skills/wtk/references/evidence.md +68 -0
- package/.agents/skills/wtk/references/git.md +75 -0
- package/.agents/skills/wtk/references/test-contract.md +76 -0
- package/.agents/skills/wtk/references/validation.md +127 -0
- package/.agents/skills/wtk-config/SKILL.md +102 -0
- package/.agents/skills/wtk-config/assets/agents/claude/deep-reviewer.md +40 -0
- package/.agents/skills/wtk-config/assets/agents/claude/designer.md +57 -0
- package/.agents/skills/wtk-config/assets/agents/claude/explorer.md +41 -0
- package/.agents/skills/wtk-config/assets/agents/claude/implementer.md +57 -0
- package/.agents/skills/wtk-config/assets/agents/claude/planner.md +68 -0
- package/.agents/skills/wtk-config/assets/agents/claude/verifier.md +80 -0
- package/.agents/skills/wtk-config/assets/agents/codex/deep-reviewer.toml +41 -0
- package/.agents/skills/wtk-config/assets/agents/codex/designer.toml +55 -0
- package/.agents/skills/wtk-config/assets/agents/codex/explorer.toml +35 -0
- package/.agents/skills/wtk-config/assets/agents/codex/implementer.toml +52 -0
- package/.agents/skills/wtk-config/assets/agents/codex/planner.toml +66 -0
- package/.agents/skills/wtk-config/assets/agents/codex/verifier.toml +78 -0
- package/.agents/skills/wtk-config/assets/agents/cursor/deep-reviewer.md +38 -0
- package/.agents/skills/wtk-config/assets/agents/cursor/designer.md +55 -0
- package/.agents/skills/wtk-config/assets/agents/cursor/explorer.md +39 -0
- package/.agents/skills/wtk-config/assets/agents/cursor/implementer.md +55 -0
- package/.agents/skills/wtk-config/assets/agents/cursor/planner.md +66 -0
- package/.agents/skills/wtk-config/assets/agents/cursor/verifier.md +79 -0
- package/.agents/skills/wtk-config/scripts/ad-index.py +123 -0
- package/.agents/skills/wtk-config/scripts/repository_intelligence.py +671 -0
- package/.agents/skills/wtk-config/scripts/workflow_config.py +972 -0
- package/.agents/skills/wtk-deep-review/SKILL.md +164 -0
- package/.agents/skills/wtk-deep-review/assets/PROMPT.md +56 -0
- package/.agents/skills/wtk-deep-review/assets/REVIEW_UI.html +1340 -0
- package/.agents/skills/wtk-deep-review/assets/findings.schema.json +113 -0
- package/.agents/skills/wtk-deep-review/references/context-pack.md +88 -0
- package/.agents/skills/wtk-deep-review/references/orchestration.md +164 -0
- package/.agents/skills/wtk-deep-review/references/output-contracts.md +149 -0
- package/.agents/skills/wtk-deep-review/references/publish-github.md +83 -0
- package/.agents/skills/wtk-deep-review/references/state-and-learnings.md +64 -0
- package/.agents/skills/wtk-deep-review/references/subagent-runtimes.md +33 -0
- package/.agents/skills/wtk-deep-review/references/taxonomy.md +70 -0
- package/.agents/skills/wtk-deep-review/scripts/_common.py +371 -0
- package/.agents/skills/wtk-deep-review/scripts/build_jobs.py +561 -0
- package/.agents/skills/wtk-deep-review/scripts/build_knowledge.py +349 -0
- package/.agents/skills/wtk-deep-review/scripts/build_manifest.py +479 -0
- package/.agents/skills/wtk-deep-review/scripts/graft_context.py +160 -0
- package/.agents/skills/wtk-deep-review/scripts/graphify_context.py +75 -0
- package/.agents/skills/wtk-deep-review/scripts/merge_findings.py +313 -0
- package/.agents/skills/wtk-deep-review/scripts/render_html.py +240 -0
- package/.agents/skills/wtk-deep-review/scripts/render_review.py +282 -0
- package/.agents/skills/wtk-deep-review/scripts/run_jobs.py +436 -0
- package/.agents/skills/wtk-deep-review/scripts/token_metrics.py +369 -0
- package/.agents/skills/wtk-discover/SKILL.md +270 -0
- package/.agents/skills/wtk-discover/references/document-format.md +149 -0
- package/.agents/skills/wtk-implement/SKILL.md +95 -0
- package/.agents/skills/wtk-implement/references/checklist-format.md +116 -0
- package/.agents/skills/wtk-implement/references/screens.md +129 -0
- package/.agents/skills/wtk-implement/references/test-policy.md +152 -0
- package/.agents/skills/wtk-implement/references/verify.md +297 -0
- package/.agents/skills/wtk-knowledge-check/SKILL.md +16 -0
- package/.agents/skills/wtk-knowledge-check/scripts/check.ts +502 -0
- package/.agents/skills/wtk-knowledge-check/scripts/cli.ts +32 -0
- package/.agents/skills/wtk-knowledge-check/scripts/frontmatter.ts +44 -0
- package/.agents/skills/wtk-lean/.skill-meta.json +6 -0
- package/.agents/skills/wtk-lean/NOTICE.md +18 -0
- package/.agents/skills/wtk-lean/SKILL.md +137 -0
- package/.agents/skills/wtk-lean/references/build.md +168 -0
- package/.agents/skills/wtk-lean/references/checks.md +253 -0
- package/.agents/skills/wtk-lean/references/memory.md +156 -0
- package/.agents/skills/wtk-lean/references/plan.md +514 -0
- package/.agents/skills/wtk-lean/references/verify.md +336 -0
- package/.agents/skills/wtk-lean/scripts/check_commit.py +121 -0
- package/.agents/skills/wtk-lean/scripts/fixtures/checks.md +98 -0
- package/.agents/skills/wtk-lean/scripts/fixtures/plan.md +119 -0
- package/.agents/skills/wtk-lean/scripts/fixtures/verification.md +41 -0
- package/.agents/skills/wtk-lean/scripts/lessons.py +412 -0
- package/.agents/skills/wtk-lean/scripts/selftest.py +401 -0
- package/.agents/skills/wtk-lean/scripts/validate_checks.py +417 -0
- package/.agents/skills/wtk-lean/scripts/validate_plan.py +580 -0
- package/.agents/skills/wtk-lean/scripts/validate_verification.py +357 -0
- package/.agents/skills/wtk-plan/SKILL.md +102 -0
- package/.agents/skills/wtk-plan/references/document-format.md +222 -0
- package/.agents/skills/wtk-qa/SKILL.md +14 -0
- package/.agents/skills/wtk-qa-execute/SKILL.md +115 -0
- package/.agents/skills/wtk-qa-execute/references/fix-loop.md +23 -0
- package/.agents/skills/wtk-qa-execute/references/session-protocol.md +25 -0
- package/.agents/skills/wtk-qa-plan/SKILL.md +111 -0
- package/.agents/skills/wtk-qa-plan/references/profile.md +35 -0
- package/.agents/skills/wtk-ship/SKILL.md +49 -0
- package/.agents/skills/wtk-ship/remediation.py +170 -0
- package/.agents/skills/wtk-ship/scripts/close_feature.py +83 -0
- package/.agents/skills/wtk-ship/scripts/review_convergence.py +373 -0
- package/.wtk.toml.example +93 -0
- package/AGENTS.md +129 -0
- package/NOTICE.md +12 -0
- package/README.md +440 -0
- package/bin/wtk.js +25 -0
- package/docs/toolkit/README.md +56 -0
- package/docs/toolkit/decisions.md +61 -0
- package/docs/toolkit/guidelines/CONTEXT-BUDGET.md +64 -0
- package/docs/toolkit/guidelines/DX.md +67 -0
- package/docs/toolkit/guidelines/FRONTEND.md +73 -0
- package/docs/toolkit/guidelines/KNOWLEDGE-WIKI.md +128 -0
- package/docs/toolkit/guidelines/MODELING.md +134 -0
- package/docs/toolkit/guidelines/QA-EXECUTION.md +46 -0
- package/docs/toolkit/guidelines/QA-SCENARIOS.md +165 -0
- package/docs/toolkit/guidelines/REVIEW-ROUNDS.md +160 -0
- package/docs/toolkit/guidelines/SECURITY.md +160 -0
- package/docs/toolkit/guidelines/UI-UX.md +117 -0
- package/docs/toolkit/guidelines/WORKFLOW-MEMORY.md +73 -0
- package/docs/toolkit/guidelines.md +50 -0
- package/docs/toolkit/loop.md +77 -0
- package/docs/toolkit/purpose.md +51 -0
- package/docs/toolkit/repository-intelligence.md +53 -0
- package/docs/toolkit/reviews.md +77 -0
- package/knowledge/AGENTS.md +316 -0
- package/knowledge/raw/README.md +17 -0
- package/package.json +74 -0
- package/scripts/install_security_skills.py +816 -0
- package/scripts/installer/engine.js +254 -0
- package/scripts/installer/knowledge.js +28 -0
- package/scripts/installer/packets.js +77 -0
- package/scripts/installer/terminal.js +98 -0
- package/scripts/installer/transaction.js +75 -0
- package/skills-lock.json +92 -0
- package/templates/adoption/agents/core.md +23 -0
- package/templates/adoption/agents/quality.md +3 -0
- package/templates/adoption/knowledge/wiki/architecture/index.md +3 -0
- package/templates/adoption/knowledge/wiki/decisions/index.md +3 -0
- package/templates/adoption/knowledge/wiki/design/index.md +3 -0
- package/templates/adoption/knowledge/wiki/domain/index.md +3 -0
- package/templates/adoption/knowledge/wiki/index.md +3 -0
- package/templates/adoption/knowledge/wiki/log.md +3 -0
- package/templates/adoption/knowledge/wiki/open-questions/index.md +3 -0
- package/templates/adoption/knowledge/wiki/product/index.md +3 -0
- package/templates/adoption/knowledge/wiki/research/index.md +3 -0
- 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())
|