clean-room-skill 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/.claude-plugin/marketplace.json +19 -0
  2. package/.claude-plugin/plugin.json +20 -0
  3. package/.codex-plugin/plugin.json +36 -0
  4. package/LICENSE +21 -0
  5. package/README.md +376 -0
  6. package/agents/clean-architect.md +27 -0
  7. package/agents/clean-qa-editor.md +27 -0
  8. package/agents/contaminated-manager-verifier.md +35 -0
  9. package/agents/contaminated-source-analyst.md +26 -0
  10. package/bin/install.js +535 -0
  11. package/examples/codex/.codex/agents/clean-architect.toml +17 -0
  12. package/examples/codex/.codex/agents/clean-qa-editor.toml +17 -0
  13. package/examples/codex/.codex/agents/contaminated-manager-verifier.toml +21 -0
  14. package/examples/codex/.codex/agents/contaminated-source-analyst.toml +17 -0
  15. package/hooks/check-artifact-leakage.py +317 -0
  16. package/hooks/clean-room-hook.py +88 -0
  17. package/hooks/clean_room_paths.py +130 -0
  18. package/hooks/deny-clean-room-shell.py +30 -0
  19. package/hooks/deny-clean-source-read.py +104 -0
  20. package/hooks/deny-contaminated-clean-write.py +134 -0
  21. package/hooks/hooks.json +44 -0
  22. package/hooks/require-clean-room-env.py +127 -0
  23. package/hooks/validate-handoff-package.py +140 -0
  24. package/hooks/validate-json-schema.py +283 -0
  25. package/lib/fs-utils.cjs +123 -0
  26. package/lib/hooks.cjs +214 -0
  27. package/package.json +49 -0
  28. package/plugin.json +20 -0
  29. package/skills/attended/SKILL.md +25 -0
  30. package/skills/clean-room/SKILL.md +134 -0
  31. package/skills/clean-room/assets/behavior-spec.schema.json +367 -0
  32. package/skills/clean-room/assets/contamination-incident.schema.json +60 -0
  33. package/skills/clean-room/assets/coverage-ledger.schema.json +139 -0
  34. package/skills/clean-room/assets/evidence-ledger.schema.json +80 -0
  35. package/skills/clean-room/assets/handoff-package.schema.json +114 -0
  36. package/skills/clean-room/assets/qc-report.schema.json +248 -0
  37. package/skills/clean-room/assets/skeleton-manifest.schema.json +239 -0
  38. package/skills/clean-room/assets/source-index.schema.json +622 -0
  39. package/skills/clean-room/assets/task-manifest.schema.json +593 -0
  40. package/skills/clean-room/examples/README.md +18 -0
  41. package/skills/clean-room/examples/minimal-spec-package/behavior-spec.json +61 -0
  42. package/skills/clean-room/examples/minimal-spec-package/coverage-ledger.json +27 -0
  43. package/skills/clean-room/examples/minimal-spec-package/evidence-ledger.json +17 -0
  44. package/skills/clean-room/examples/minimal-spec-package/handoff-package.json +26 -0
  45. package/skills/clean-room/examples/minimal-spec-package/qc-report.json +25 -0
  46. package/skills/clean-room/examples/minimal-spec-package/skeleton-manifest.json +45 -0
  47. package/skills/clean-room/examples/minimal-spec-package/source-index.json +156 -0
  48. package/skills/clean-room/examples/minimal-spec-package/task-manifest.json +220 -0
  49. package/skills/clean-room/references/LEAKAGE-RULES.md +92 -0
  50. package/skills/clean-room/references/PROCESS.md +185 -0
  51. package/skills/clean-room/references/SPEC-SCHEMA.md +185 -0
  52. package/skills/clean-room/references/TARGET-LANGUAGE-GUIDE.md +43 -0
  53. package/skills/clean-room/scripts/build_source_index.py +1253 -0
  54. package/skills/clean-room/scripts/clean_room_tool_manager.py +199 -0
  55. package/skills/clean-room/scripts/clean_room_tooling.py +370 -0
  56. package/skills/unattended/SKILL.md +26 -0
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env python3
2
+ """Enforce clean-room write roots for contaminated and clean roles."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+
11
+
12
+ CONTAMINATED_ROLES = {"contaminated-manager-verifier", "contaminated-source-analyst"}
13
+ CLEAN_ROLES = {"clean-architect", "clean-qa-editor"}
14
+
15
+
16
+ def load_payload() -> dict:
17
+ raw = sys.stdin.read()
18
+ if not raw.strip():
19
+ return {}
20
+ try:
21
+ data = json.loads(raw)
22
+ except json.JSONDecodeError:
23
+ return {}
24
+ return data if isinstance(data, dict) else {}
25
+
26
+
27
+ def configured_roots(name: str) -> list[Path]:
28
+ value = os.environ.get(name, "")
29
+ roots = []
30
+ for item in value.split(os.pathsep):
31
+ if item:
32
+ roots.append(Path(item).expanduser().resolve())
33
+ return roots
34
+
35
+
36
+ def candidate_paths(payload: dict) -> list[Path]:
37
+ tool_input = payload.get("tool_input")
38
+ if not isinstance(tool_input, dict):
39
+ tool_input = {}
40
+ values = []
41
+ for key in ("file_path", "path", "cwd"):
42
+ value = tool_input.get(key) or payload.get(key)
43
+ if isinstance(value, str):
44
+ values.append(value)
45
+ paths = []
46
+ for value in values:
47
+ try:
48
+ paths.append(Path(value).expanduser().resolve())
49
+ except OSError:
50
+ continue
51
+ return paths
52
+
53
+
54
+ def is_under(path: Path, root: Path) -> bool:
55
+ return path == root or root in path.parents
56
+
57
+
58
+ def main() -> int:
59
+ role = os.environ.get("CLEAN_ROOM_ROLE", "")
60
+ if role not in CONTAMINATED_ROLES and role not in CLEAN_ROLES:
61
+ return 0
62
+ paths = candidate_paths(load_payload())
63
+ if not paths:
64
+ print(
65
+ f"clean-room policy denied role {role} write with no resolved path",
66
+ file=sys.stderr,
67
+ )
68
+ return 1
69
+
70
+ source_roots = configured_roots("CLEAN_ROOM_SOURCE_ROOTS")
71
+ clean_roots = configured_roots("CLEAN_ROOM_CLEAN_ROOTS")
72
+ contaminated_artifact_roots = configured_roots("CLEAN_ROOM_CONTAMINATED_ARTIFACT_ROOTS")
73
+
74
+ if role in CLEAN_ROLES:
75
+ allowed_read_roots = configured_roots("CLEAN_ROOM_ALLOWED_READ_ROOTS")
76
+ for path in paths:
77
+ if any(is_under(path, root) for root in source_roots):
78
+ print(
79
+ f"clean-room policy denied clean role {role} writing source path {path}",
80
+ file=sys.stderr,
81
+ )
82
+ return 1
83
+ if any(is_under(path, root) for root in allowed_read_roots) and not any(
84
+ is_under(path, root) for root in clean_roots
85
+ ):
86
+ print(
87
+ f"clean-room policy denied clean role {role} writing read-only allowed-read path {path}",
88
+ file=sys.stderr,
89
+ )
90
+ return 1
91
+ if not clean_roots:
92
+ print(
93
+ f"clean-room policy denied clean role {role} writing {path}: no clean write roots configured",
94
+ file=sys.stderr,
95
+ )
96
+ return 1
97
+ if not any(is_under(path, root) for root in clean_roots):
98
+ print(
99
+ f"clean-room policy denied clean role {role} writing outside clean roots: {path}",
100
+ file=sys.stderr,
101
+ )
102
+ return 1
103
+ return 0
104
+
105
+ for path in paths:
106
+ if any(is_under(path, root) for root in clean_roots):
107
+ print(
108
+ f"clean-room policy denied contaminated role {role} writing clean path {path}",
109
+ file=sys.stderr,
110
+ )
111
+ return 1
112
+ if any(is_under(path, root) for root in source_roots):
113
+ print(
114
+ f"clean-room policy denied contaminated role {role} writing source path {path}",
115
+ file=sys.stderr,
116
+ )
117
+ return 1
118
+ if not contaminated_artifact_roots:
119
+ print(
120
+ f"clean-room policy denied contaminated role {role} writing {path}: no contaminated artifact roots configured",
121
+ file=sys.stderr,
122
+ )
123
+ return 1
124
+ if not any(is_under(path, root) for root in contaminated_artifact_roots):
125
+ print(
126
+ f"clean-room policy denied contaminated role {role} writing outside contaminated artifact roots: {path}",
127
+ file=sys.stderr,
128
+ )
129
+ return 1
130
+ return 0
131
+
132
+
133
+ if __name__ == "__main__":
134
+ raise SystemExit(main())
@@ -0,0 +1,44 @@
1
+ {
2
+ "hooks": {
3
+ "PreToolUse": [
4
+ {
5
+ "matcher": "Bash|Shell",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "python3 hooks/clean-room-hook.py --mode safe --check require-clean-room-env.py --check deny-clean-room-shell.py"
10
+ }
11
+ ]
12
+ },
13
+ {
14
+ "matcher": "Read|Glob|Grep",
15
+ "hooks": [
16
+ {
17
+ "type": "command",
18
+ "command": "python3 hooks/clean-room-hook.py --mode safe --check require-clean-room-env.py --check deny-clean-source-read.py"
19
+ }
20
+ ]
21
+ },
22
+ {
23
+ "matcher": "Write|Edit|MultiEdit",
24
+ "hooks": [
25
+ {
26
+ "type": "command",
27
+ "command": "python3 hooks/clean-room-hook.py --mode safe --check require-clean-room-env.py --check deny-contaminated-clean-write.py"
28
+ }
29
+ ]
30
+ }
31
+ ],
32
+ "PostToolUse": [
33
+ {
34
+ "matcher": "Write|Edit|MultiEdit",
35
+ "hooks": [
36
+ {
37
+ "type": "command",
38
+ "command": "python3 hooks/clean-room-hook.py --mode safe --check require-clean-room-env.py --check check-artifact-leakage.py --check validate-json-schema.py --check validate-handoff-package.py"
39
+ }
40
+ ]
41
+ }
42
+ ]
43
+ }
44
+ }
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env python3
2
+ """Require explicit clean-room role and root configuration before tool use."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from clean_room_paths import paths_overlap
11
+
12
+
13
+ ROLES = {
14
+ "contaminated-manager-verifier",
15
+ "contaminated-source-analyst",
16
+ "clean-architect",
17
+ "clean-qa-editor",
18
+ }
19
+ CLEAN_ROLES = {"clean-architect", "clean-qa-editor"}
20
+ NONEMPTY_VARS = (
21
+ "CLEAN_ROOM_ROLE",
22
+ "CLEAN_ROOM_SOURCE_ROOTS",
23
+ "CLEAN_ROOM_CONTAMINATED_ARTIFACT_ROOTS",
24
+ "CLEAN_ROOM_CLEAN_ROOTS",
25
+ "CLEAN_ROOM_SCHEMA_DIR",
26
+ )
27
+ ROOT_VARS = (
28
+ "CLEAN_ROOM_SOURCE_ROOTS",
29
+ "CLEAN_ROOM_CONTAMINATED_ARTIFACT_ROOTS",
30
+ "CLEAN_ROOM_CLEAN_ROOTS",
31
+ "CLEAN_ROOM_SCHEMA_DIR",
32
+ "CLEAN_ROOM_ALLOWED_READ_ROOTS",
33
+ )
34
+
35
+
36
+ def split_roots(value: str) -> list[str]:
37
+ return [item for item in value.split(os.pathsep) if item]
38
+
39
+
40
+ def validate_roots(name: str, require_existing: bool = False) -> list[str]:
41
+ value = os.environ.get(name, "")
42
+ errors = []
43
+ if name in NONEMPTY_VARS and not split_roots(value):
44
+ errors.append(f"{name} must not be empty")
45
+ return errors
46
+ for item in split_roots(value):
47
+ try:
48
+ path = Path(item).expanduser().resolve()
49
+ except OSError as exc:
50
+ errors.append(f"{name} has invalid path {item!r}: {exc}")
51
+ continue
52
+ if require_existing and not path.exists():
53
+ errors.append(f"{name} path does not exist: {path}")
54
+ return errors
55
+
56
+
57
+ def resolved_roots(name: str) -> tuple[list[Path], list[str]]:
58
+ roots: list[Path] = []
59
+ errors: list[str] = []
60
+ for item in split_roots(os.environ.get(name, "")):
61
+ try:
62
+ roots.append(Path(item).expanduser().resolve())
63
+ except OSError as exc:
64
+ errors.append(f"{name} has invalid path {item!r}: {exc}")
65
+ return roots, errors
66
+
67
+
68
+ def reject_overlaps(left_name: str, right_name: str, message: str) -> list[str]:
69
+ left_roots, left_errors = resolved_roots(left_name)
70
+ right_roots, right_errors = resolved_roots(right_name)
71
+ errors = left_errors + right_errors
72
+ for left in left_roots:
73
+ for right in right_roots:
74
+ if paths_overlap(left, right):
75
+ errors.append(f"{message}: {left_name}={left} overlaps {right_name}={right}")
76
+ return errors
77
+
78
+
79
+ def main() -> int:
80
+ role = os.environ.get("CLEAN_ROOM_ROLE", "")
81
+ missing = [name for name in NONEMPTY_VARS if name not in os.environ]
82
+ errors = [f"{name} is not set" for name in missing]
83
+ if role and role not in ROLES:
84
+ errors.append(f"CLEAN_ROOM_ROLE must be one of {', '.join(sorted(ROLES))}")
85
+ for name in ROOT_VARS:
86
+ if name in os.environ:
87
+ errors.extend(validate_roots(name, require_existing=name == "CLEAN_ROOM_SCHEMA_DIR"))
88
+ if role in CLEAN_ROLES and "CLEAN_ROOM_ALLOWED_READ_ROOTS" not in os.environ:
89
+ errors.append("CLEAN_ROOM_ALLOWED_READ_ROOTS is not set for clean role")
90
+ errors.extend(
91
+ reject_overlaps(
92
+ "CLEAN_ROOM_SOURCE_ROOTS",
93
+ "CLEAN_ROOM_CLEAN_ROOTS",
94
+ "source roots and clean roots must be separate",
95
+ )
96
+ )
97
+ errors.extend(
98
+ reject_overlaps(
99
+ "CLEAN_ROOM_SOURCE_ROOTS",
100
+ "CLEAN_ROOM_CONTAMINATED_ARTIFACT_ROOTS",
101
+ "source roots and contaminated artifact roots must be separate",
102
+ )
103
+ )
104
+ errors.extend(
105
+ reject_overlaps(
106
+ "CLEAN_ROOM_CLEAN_ROOTS",
107
+ "CLEAN_ROOM_CONTAMINATED_ARTIFACT_ROOTS",
108
+ "clean roots and contaminated artifact roots must be separate",
109
+ )
110
+ )
111
+ errors.extend(
112
+ reject_overlaps(
113
+ "CLEAN_ROOM_ALLOWED_READ_ROOTS",
114
+ "CLEAN_ROOM_SOURCE_ROOTS",
115
+ "allowed clean read roots must not expose source roots",
116
+ )
117
+ )
118
+ if errors:
119
+ print("clean-room environment check failed:", file=sys.stderr)
120
+ for error in errors:
121
+ print(f" {error}", file=sys.stderr)
122
+ return 1
123
+ return 0
124
+
125
+
126
+ if __name__ == "__main__":
127
+ raise SystemExit(main())
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env python3
2
+ """Verify clean-room handoff package paths and hashes."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import hashlib
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from clean_room_paths import checked_write_paths, env_roots, load_payload, path_is_under
13
+
14
+
15
+ def sha256_file(path: Path) -> str:
16
+ digest = hashlib.sha256()
17
+ with path.open("rb") as handle:
18
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
19
+ digest.update(chunk)
20
+ return digest.hexdigest()
21
+
22
+
23
+ def is_handoff_package(path: Path, data: object) -> bool:
24
+ if not isinstance(data, dict):
25
+ return False
26
+ return (
27
+ path.name == "handoff-package.json"
28
+ or data.get("package_id") is not None
29
+ or (
30
+ data.get("from_domain") == "contaminated"
31
+ and data.get("to_domain") == "clean"
32
+ and isinstance(data.get("artifacts"), list)
33
+ )
34
+ )
35
+
36
+
37
+ def resolve_artifact_path(raw_path: str, clean_roots: list[Path]) -> tuple[Path | None, list[str]]:
38
+ errors: list[str] = []
39
+ path = Path(raw_path).expanduser()
40
+ if path.is_absolute():
41
+ resolved = path.resolve()
42
+ if not any(path_is_under(resolved, root) for root in clean_roots):
43
+ errors.append(f"artifact path is outside CLEAN_ROOM_CLEAN_ROOTS: {resolved}")
44
+ return resolved, errors
45
+
46
+ matches = [(root / path).resolve() for root in clean_roots if (root / path).is_file()]
47
+ if len(matches) > 1:
48
+ errors.append(f"artifact path is ambiguous across clean roots: {raw_path}")
49
+ return None, errors
50
+ if matches:
51
+ return matches[0], errors
52
+ if not clean_roots:
53
+ errors.append("CLEAN_ROOM_CLEAN_ROOTS must be set to verify handoff artifacts")
54
+ return None, errors
55
+ return (clean_roots[0] / path).resolve(), errors
56
+
57
+
58
+ def validate_artifact(
59
+ item: Any,
60
+ clean_roots: list[Path],
61
+ blocked_roots: list[Path],
62
+ ) -> list[str]:
63
+ errors: list[str] = []
64
+ if not isinstance(item, dict):
65
+ return ["handoff artifact entry must be an object"]
66
+ raw_path = item.get("path")
67
+ if not isinstance(raw_path, str) or not raw_path:
68
+ return ["handoff artifact path must be a non-empty string"]
69
+ if Path(raw_path).name == "source-index.json" or item.get("artifact_type") == "source-index":
70
+ errors.append("source-index.json must not be included in a clean handoff package")
71
+
72
+ artifact_path, path_errors = resolve_artifact_path(raw_path, clean_roots)
73
+ errors.extend(path_errors)
74
+ if artifact_path is None:
75
+ return errors
76
+ if not any(path_is_under(artifact_path, root) for root in clean_roots):
77
+ errors.append(f"artifact path is outside CLEAN_ROOM_CLEAN_ROOTS: {artifact_path}")
78
+ if any(path_is_under(artifact_path, root) for root in blocked_roots):
79
+ errors.append(f"artifact path points into a contaminated or source root: {artifact_path}")
80
+ if not artifact_path.is_file():
81
+ errors.append(f"referenced artifact does not exist: {artifact_path}")
82
+ return errors
83
+
84
+ expected_sha = item.get("sha256")
85
+ if not isinstance(expected_sha, str) or len(expected_sha) != 64:
86
+ errors.append(f"artifact sha256 must be a 64-character hex string: {raw_path}")
87
+ return errors
88
+ actual_sha = sha256_file(artifact_path)
89
+ if actual_sha.lower() != expected_sha.lower():
90
+ errors.append(f"artifact sha256 mismatch for {artifact_path}")
91
+ return errors
92
+
93
+
94
+ def validate_handoff(path: Path) -> list[str]:
95
+ try:
96
+ data = json.loads(path.read_text(encoding="utf-8"))
97
+ except json.JSONDecodeError as exc:
98
+ return [f"JSON parse failed for {path}: {exc}"]
99
+ if not is_handoff_package(path, data):
100
+ return []
101
+ if not isinstance(data, dict):
102
+ return [f"handoff package must be an object: {path}"]
103
+
104
+ clean_roots = env_roots("CLEAN_ROOM_CLEAN_ROOTS")
105
+ blocked_roots = env_roots("CLEAN_ROOM_SOURCE_ROOTS") + env_roots("CLEAN_ROOM_CONTAMINATED_ARTIFACT_ROOTS")
106
+ artifacts = data.get("artifacts")
107
+ if not isinstance(artifacts, list):
108
+ return [f"handoff package artifacts must be an array: {path}"]
109
+ errors: list[str] = []
110
+ for item in artifacts:
111
+ errors.extend(validate_artifact(item, clean_roots, blocked_roots))
112
+ return errors
113
+
114
+
115
+ def main() -> int:
116
+ payload, payload_error = load_payload()
117
+ if payload_error:
118
+ print(f"clean-room handoff integrity failed: {payload_error}", file=sys.stderr)
119
+ return 1
120
+ paths, path_errors = checked_write_paths(payload, "clean-room handoff integrity")
121
+ if path_errors:
122
+ for error in path_errors:
123
+ print(f"clean-room handoff integrity failed: {error}", file=sys.stderr)
124
+ return 1
125
+ for path in paths:
126
+ if path.suffix.lower() != ".json" or not path.is_file():
127
+ continue
128
+ errors = validate_handoff(path)
129
+ if errors:
130
+ print(f"clean-room handoff integrity failed for {path}:", file=sys.stderr)
131
+ for error in errors[:20]:
132
+ print(f" {error}", file=sys.stderr)
133
+ if len(errors) > 20:
134
+ print(f" ... {len(errors) - 20} more error(s)", file=sys.stderr)
135
+ return 1
136
+ return 0
137
+
138
+
139
+ if __name__ == "__main__":
140
+ raise SystemExit(main())