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.
- package/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +20 -0
- package/.codex-plugin/plugin.json +36 -0
- package/LICENSE +21 -0
- package/README.md +376 -0
- package/agents/clean-architect.md +27 -0
- package/agents/clean-qa-editor.md +27 -0
- package/agents/contaminated-manager-verifier.md +35 -0
- package/agents/contaminated-source-analyst.md +26 -0
- package/bin/install.js +535 -0
- package/examples/codex/.codex/agents/clean-architect.toml +17 -0
- package/examples/codex/.codex/agents/clean-qa-editor.toml +17 -0
- package/examples/codex/.codex/agents/contaminated-manager-verifier.toml +21 -0
- package/examples/codex/.codex/agents/contaminated-source-analyst.toml +17 -0
- package/hooks/check-artifact-leakage.py +317 -0
- package/hooks/clean-room-hook.py +88 -0
- package/hooks/clean_room_paths.py +130 -0
- package/hooks/deny-clean-room-shell.py +30 -0
- package/hooks/deny-clean-source-read.py +104 -0
- package/hooks/deny-contaminated-clean-write.py +134 -0
- package/hooks/hooks.json +44 -0
- package/hooks/require-clean-room-env.py +127 -0
- package/hooks/validate-handoff-package.py +140 -0
- package/hooks/validate-json-schema.py +283 -0
- package/lib/fs-utils.cjs +123 -0
- package/lib/hooks.cjs +214 -0
- package/package.json +49 -0
- package/plugin.json +20 -0
- package/skills/attended/SKILL.md +25 -0
- package/skills/clean-room/SKILL.md +134 -0
- package/skills/clean-room/assets/behavior-spec.schema.json +367 -0
- package/skills/clean-room/assets/contamination-incident.schema.json +60 -0
- package/skills/clean-room/assets/coverage-ledger.schema.json +139 -0
- package/skills/clean-room/assets/evidence-ledger.schema.json +80 -0
- package/skills/clean-room/assets/handoff-package.schema.json +114 -0
- package/skills/clean-room/assets/qc-report.schema.json +248 -0
- package/skills/clean-room/assets/skeleton-manifest.schema.json +239 -0
- package/skills/clean-room/assets/source-index.schema.json +622 -0
- package/skills/clean-room/assets/task-manifest.schema.json +593 -0
- package/skills/clean-room/examples/README.md +18 -0
- package/skills/clean-room/examples/minimal-spec-package/behavior-spec.json +61 -0
- package/skills/clean-room/examples/minimal-spec-package/coverage-ledger.json +27 -0
- package/skills/clean-room/examples/minimal-spec-package/evidence-ledger.json +17 -0
- package/skills/clean-room/examples/minimal-spec-package/handoff-package.json +26 -0
- package/skills/clean-room/examples/minimal-spec-package/qc-report.json +25 -0
- package/skills/clean-room/examples/minimal-spec-package/skeleton-manifest.json +45 -0
- package/skills/clean-room/examples/minimal-spec-package/source-index.json +156 -0
- package/skills/clean-room/examples/minimal-spec-package/task-manifest.json +220 -0
- package/skills/clean-room/references/LEAKAGE-RULES.md +92 -0
- package/skills/clean-room/references/PROCESS.md +185 -0
- package/skills/clean-room/references/SPEC-SCHEMA.md +185 -0
- package/skills/clean-room/references/TARGET-LANGUAGE-GUIDE.md +43 -0
- package/skills/clean-room/scripts/build_source_index.py +1253 -0
- package/skills/clean-room/scripts/clean_room_tool_manager.py +199 -0
- package/skills/clean-room/scripts/clean_room_tooling.py +370 -0
- 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())
|
package/hooks/hooks.json
ADDED
|
@@ -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())
|