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,283 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Lightweight JSON artifact validator for bundled clean-room schemas."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from clean_room_paths import checked_write_paths, load_payload, path_under_env
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
SCHEMA_BY_ARTIFACT = {
|
|
18
|
+
"task-manifest": "task-manifest.schema.json",
|
|
19
|
+
"behavior-spec": "behavior-spec.schema.json",
|
|
20
|
+
"skeleton-manifest": "skeleton-manifest.schema.json",
|
|
21
|
+
"qc-report": "qc-report.schema.json",
|
|
22
|
+
"coverage-ledger": "coverage-ledger.schema.json",
|
|
23
|
+
"evidence-ledger": "evidence-ledger.schema.json",
|
|
24
|
+
"source-index": "source-index.schema.json",
|
|
25
|
+
"handoff-package": "handoff-package.schema.json",
|
|
26
|
+
"contamination-incident": "contamination-incident.schema.json",
|
|
27
|
+
}
|
|
28
|
+
CLEAN_ROOM_AUXILIARY_JSON_ALLOWLIST_ENV = "CLEAN_ROOM_AUXILIARY_JSON_ALLOWLIST"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def schema_dir() -> Path:
|
|
32
|
+
configured = os.environ.get("CLEAN_ROOM_SCHEMA_DIR")
|
|
33
|
+
if configured:
|
|
34
|
+
return Path(configured).expanduser().resolve()
|
|
35
|
+
return Path(__file__).resolve().parents[1] / "skills" / "clean-room" / "assets"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def artifact_kind(path: Path, data: dict) -> str | None:
|
|
39
|
+
name = path.name.removesuffix(".json")
|
|
40
|
+
if name in SCHEMA_BY_ARTIFACT:
|
|
41
|
+
return name
|
|
42
|
+
if "spec_id" in data:
|
|
43
|
+
return "behavior-spec"
|
|
44
|
+
if "manifest_id" in data:
|
|
45
|
+
return "skeleton-manifest"
|
|
46
|
+
if "report_id" in data:
|
|
47
|
+
return "qc-report"
|
|
48
|
+
if "package_id" in data:
|
|
49
|
+
return "handoff-package"
|
|
50
|
+
if "incident_id" in data:
|
|
51
|
+
return "contamination-incident"
|
|
52
|
+
if "index_id" in data and data.get("domain") == "contaminated" and "recommended_batches" in data:
|
|
53
|
+
return "source-index"
|
|
54
|
+
if "ledger_id" in data:
|
|
55
|
+
if data.get("domain") == "contaminated" and "entries" in data:
|
|
56
|
+
return "evidence-ledger"
|
|
57
|
+
if {"source_units", "behavior_spec_refs", "coverage_status"} & data.keys():
|
|
58
|
+
return "coverage-ledger"
|
|
59
|
+
if data.get("from_domain") == "contaminated" and data.get("to_domain") == "clean" and "artifacts" in data:
|
|
60
|
+
return "handoff-package"
|
|
61
|
+
for kind in SCHEMA_BY_ARTIFACT:
|
|
62
|
+
if kind in name:
|
|
63
|
+
return kind
|
|
64
|
+
if "task_id" in data:
|
|
65
|
+
return "task-manifest"
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def auxiliary_json_allowed(path: Path) -> tuple[bool, list[str]]:
|
|
70
|
+
errors: list[str] = []
|
|
71
|
+
for item in os.environ.get(CLEAN_ROOM_AUXILIARY_JSON_ALLOWLIST_ENV, "").split(os.pathsep):
|
|
72
|
+
if not item:
|
|
73
|
+
continue
|
|
74
|
+
try:
|
|
75
|
+
allowed = Path(item).expanduser().resolve()
|
|
76
|
+
except OSError as exc:
|
|
77
|
+
errors.append(f"{CLEAN_ROOM_AUXILIARY_JSON_ALLOWLIST_ENV} has invalid path {item!r}: {exc}")
|
|
78
|
+
continue
|
|
79
|
+
if path == allowed:
|
|
80
|
+
return True, errors
|
|
81
|
+
return False, errors
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def resolve_ref(root_schema: dict, ref: str) -> dict:
|
|
85
|
+
if not ref.startswith("#/"):
|
|
86
|
+
raise ValueError(f"unsupported external schema ref {ref}")
|
|
87
|
+
current: Any = root_schema
|
|
88
|
+
for part in ref[2:].split("/"):
|
|
89
|
+
key = part.replace("~1", "/").replace("~0", "~")
|
|
90
|
+
if not isinstance(current, dict) or key not in current:
|
|
91
|
+
raise ValueError(f"unresolvable schema ref {ref}")
|
|
92
|
+
current = current[key]
|
|
93
|
+
if not isinstance(current, dict):
|
|
94
|
+
raise ValueError(f"schema ref {ref} did not resolve to an object")
|
|
95
|
+
return current
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def path_label(path: tuple[str | int, ...]) -> str:
|
|
99
|
+
if not path:
|
|
100
|
+
return "<root>"
|
|
101
|
+
return "/" + "/".join(str(part) for part in path)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def type_matches(value: Any, expected: str) -> bool:
|
|
105
|
+
if expected == "object":
|
|
106
|
+
return isinstance(value, dict)
|
|
107
|
+
if expected == "array":
|
|
108
|
+
return isinstance(value, list)
|
|
109
|
+
if expected == "string":
|
|
110
|
+
return isinstance(value, str)
|
|
111
|
+
if expected == "boolean":
|
|
112
|
+
return isinstance(value, bool)
|
|
113
|
+
if expected == "null":
|
|
114
|
+
return value is None
|
|
115
|
+
if expected == "integer":
|
|
116
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
117
|
+
if expected == "number":
|
|
118
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
119
|
+
return True
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def valid_date_time(value: str) -> bool:
|
|
123
|
+
candidate = value[:-1] + "+00:00" if value.endswith("Z") else value
|
|
124
|
+
try:
|
|
125
|
+
datetime.fromisoformat(candidate)
|
|
126
|
+
except ValueError:
|
|
127
|
+
return False
|
|
128
|
+
return True
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def validate_value(value: Any, schema: dict, root_schema: dict, path: tuple[str | int, ...] = ()) -> list[str]:
|
|
132
|
+
errors: list[str] = []
|
|
133
|
+
if "$ref" in schema:
|
|
134
|
+
try:
|
|
135
|
+
errors.extend(validate_value(value, resolve_ref(root_schema, schema["$ref"]), root_schema, path))
|
|
136
|
+
except ValueError as exc:
|
|
137
|
+
errors.append(f"{path_label(path)}: {exc}")
|
|
138
|
+
return errors
|
|
139
|
+
schema = {key: item for key, item in schema.items() if key != "$ref"}
|
|
140
|
+
if not schema:
|
|
141
|
+
return errors
|
|
142
|
+
|
|
143
|
+
all_of = schema.get("allOf")
|
|
144
|
+
if isinstance(all_of, list):
|
|
145
|
+
for index, subschema in enumerate(all_of):
|
|
146
|
+
if isinstance(subschema, dict):
|
|
147
|
+
errors.extend(validate_value(value, subschema, root_schema, path))
|
|
148
|
+
else:
|
|
149
|
+
errors.append(f"{path_label(path)}: allOf[{index}] is not a schema object")
|
|
150
|
+
|
|
151
|
+
if_schema = schema.get("if")
|
|
152
|
+
if isinstance(if_schema, dict) and not validate_value(value, if_schema, root_schema, path):
|
|
153
|
+
then_schema = schema.get("then")
|
|
154
|
+
if isinstance(then_schema, dict):
|
|
155
|
+
errors.extend(validate_value(value, then_schema, root_schema, path))
|
|
156
|
+
|
|
157
|
+
if "const" in schema and value != schema["const"]:
|
|
158
|
+
errors.append(f"{path_label(path)}: expected const {schema['const']!r}")
|
|
159
|
+
if "enum" in schema and value not in schema["enum"]:
|
|
160
|
+
errors.append(f"{path_label(path)}: expected one of {schema['enum']!r}")
|
|
161
|
+
|
|
162
|
+
expected_type = schema.get("type")
|
|
163
|
+
if isinstance(expected_type, str) and not type_matches(value, expected_type):
|
|
164
|
+
errors.append(f"{path_label(path)}: expected {expected_type}")
|
|
165
|
+
return errors
|
|
166
|
+
if isinstance(expected_type, list) and not any(type_matches(value, item) for item in expected_type):
|
|
167
|
+
errors.append(f"{path_label(path)}: expected one of types {expected_type!r}")
|
|
168
|
+
return errors
|
|
169
|
+
|
|
170
|
+
if isinstance(value, dict):
|
|
171
|
+
required = schema.get("required", [])
|
|
172
|
+
if isinstance(required, list):
|
|
173
|
+
for field in required:
|
|
174
|
+
if isinstance(field, str) and field not in value:
|
|
175
|
+
errors.append(f"{path_label(path)}: missing required field {field!r}")
|
|
176
|
+
|
|
177
|
+
properties = schema.get("properties", {})
|
|
178
|
+
if isinstance(properties, dict):
|
|
179
|
+
for field, field_schema in properties.items():
|
|
180
|
+
if field in value and isinstance(field_schema, dict):
|
|
181
|
+
errors.extend(validate_value(value[field], field_schema, root_schema, path + (field,)))
|
|
182
|
+
if schema.get("additionalProperties") is False:
|
|
183
|
+
for field in sorted(set(value) - set(properties)):
|
|
184
|
+
errors.append(f"{path_label(path + (field,))}: additional property is not allowed")
|
|
185
|
+
|
|
186
|
+
if isinstance(value, list):
|
|
187
|
+
min_items = schema.get("minItems")
|
|
188
|
+
if isinstance(min_items, int) and len(value) < min_items:
|
|
189
|
+
errors.append(f"{path_label(path)}: fewer than minItems {min_items}")
|
|
190
|
+
max_items = schema.get("maxItems")
|
|
191
|
+
if isinstance(max_items, int) and len(value) > max_items:
|
|
192
|
+
errors.append(f"{path_label(path)}: more than maxItems {max_items}")
|
|
193
|
+
if schema.get("uniqueItems") is True:
|
|
194
|
+
seen: set[str] = set()
|
|
195
|
+
for item in value:
|
|
196
|
+
marker = json.dumps(item, sort_keys=True, separators=(",", ":"))
|
|
197
|
+
if marker in seen:
|
|
198
|
+
errors.append(f"{path_label(path)}: duplicate item violates uniqueItems")
|
|
199
|
+
break
|
|
200
|
+
seen.add(marker)
|
|
201
|
+
item_schema = schema.get("items")
|
|
202
|
+
if isinstance(item_schema, dict):
|
|
203
|
+
for index, item in enumerate(value):
|
|
204
|
+
errors.extend(validate_value(item, item_schema, root_schema, path + (index,)))
|
|
205
|
+
|
|
206
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
207
|
+
minimum = schema.get("minimum")
|
|
208
|
+
if isinstance(minimum, (int, float)) and value < minimum:
|
|
209
|
+
errors.append(f"{path_label(path)}: less than minimum {minimum}")
|
|
210
|
+
maximum = schema.get("maximum")
|
|
211
|
+
if isinstance(maximum, (int, float)) and value > maximum:
|
|
212
|
+
errors.append(f"{path_label(path)}: greater than maximum {maximum}")
|
|
213
|
+
|
|
214
|
+
if isinstance(value, str):
|
|
215
|
+
min_length = schema.get("minLength")
|
|
216
|
+
if isinstance(min_length, int) and len(value) < min_length:
|
|
217
|
+
errors.append(f"{path_label(path)}: shorter than minLength {min_length}")
|
|
218
|
+
pattern = schema.get("pattern")
|
|
219
|
+
if isinstance(pattern, str) and re.search(pattern, value) is None:
|
|
220
|
+
errors.append(f"{path_label(path)}: does not match pattern {pattern!r}")
|
|
221
|
+
if schema.get("format") == "date-time" and not valid_date_time(value):
|
|
222
|
+
errors.append(f"{path_label(path)}: invalid date-time")
|
|
223
|
+
|
|
224
|
+
return errors
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def main() -> int:
|
|
228
|
+
payload, payload_error = load_payload()
|
|
229
|
+
if payload_error:
|
|
230
|
+
print(f"clean-room schema check failed: {payload_error}", file=sys.stderr)
|
|
231
|
+
return 1
|
|
232
|
+
paths, path_errors = checked_write_paths(payload, "clean-room schema check")
|
|
233
|
+
if path_errors:
|
|
234
|
+
for error in path_errors:
|
|
235
|
+
print(f"clean-room schema check failed: {error}", file=sys.stderr)
|
|
236
|
+
return 1
|
|
237
|
+
for path in paths:
|
|
238
|
+
if path.suffix.lower() != ".json" or not path.is_file():
|
|
239
|
+
continue
|
|
240
|
+
in_clean_root = path_under_env(path, "CLEAN_ROOM_CLEAN_ROOTS")
|
|
241
|
+
try:
|
|
242
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
243
|
+
except json.JSONDecodeError as exc:
|
|
244
|
+
print(f"clean-room JSON parse failed for {path}: {exc}", file=sys.stderr)
|
|
245
|
+
return 1
|
|
246
|
+
if not isinstance(data, dict):
|
|
247
|
+
if in_clean_root:
|
|
248
|
+
print(f"clean-room schema check failed for {path}: clean JSON artifact must be an object", file=sys.stderr)
|
|
249
|
+
return 1
|
|
250
|
+
continue
|
|
251
|
+
kind = artifact_kind(path, data)
|
|
252
|
+
if not kind:
|
|
253
|
+
allowed, allowlist_errors = auxiliary_json_allowed(path)
|
|
254
|
+
if allowlist_errors:
|
|
255
|
+
for error in allowlist_errors:
|
|
256
|
+
print(f"clean-room schema check failed: {error}", file=sys.stderr)
|
|
257
|
+
return 1
|
|
258
|
+
if in_clean_root and not allowed:
|
|
259
|
+
print(f"clean-room schema check failed for {path}: unrecognized clean JSON artifact", file=sys.stderr)
|
|
260
|
+
return 1
|
|
261
|
+
continue
|
|
262
|
+
if in_clean_root and kind == "source-index":
|
|
263
|
+
print(f"clean-room schema check failed for {path}: source-index.json is contaminated-only", file=sys.stderr)
|
|
264
|
+
return 1
|
|
265
|
+
schema_path = schema_dir() / SCHEMA_BY_ARTIFACT[kind]
|
|
266
|
+
try:
|
|
267
|
+
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
|
268
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
269
|
+
print(f"clean-room schema load failed for {schema_path}: {exc}", file=sys.stderr)
|
|
270
|
+
return 1
|
|
271
|
+
errors = validate_value(data, schema, schema)
|
|
272
|
+
if errors:
|
|
273
|
+
print(f"clean-room schema check failed for {path}:", file=sys.stderr)
|
|
274
|
+
for error in errors[:20]:
|
|
275
|
+
print(f" {error}", file=sys.stderr)
|
|
276
|
+
if len(errors) > 20:
|
|
277
|
+
print(f" ... {len(errors) - 20} more error(s)", file=sys.stderr)
|
|
278
|
+
return 1
|
|
279
|
+
return 0
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
if __name__ == "__main__":
|
|
283
|
+
raise SystemExit(main())
|
package/lib/fs-utils.cjs
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
function sha256Bytes(bytes) {
|
|
8
|
+
return crypto.createHash('sha256').update(bytes).digest('hex');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function fileHash(filePath) {
|
|
12
|
+
return sha256Bytes(fs.readFileSync(filePath));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function normalizeRelativePath(relPath) {
|
|
16
|
+
if (typeof relPath !== 'string' || relPath.trim() === '' || relPath.includes('\0')) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
if (path.isAbsolute(relPath) || path.win32.isAbsolute(relPath)) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
const normalized = relPath.replace(/\\/g, '/');
|
|
23
|
+
const parts = normalized.split('/');
|
|
24
|
+
if (parts.some((part) => part === '' || part === '.' || part === '..')) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
return parts.join('/');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function resolveInside(root, relPath) {
|
|
31
|
+
const normalized = normalizeRelativePath(relPath);
|
|
32
|
+
if (!normalized) {
|
|
33
|
+
throw new Error(`invalid relative path: ${relPath}`);
|
|
34
|
+
}
|
|
35
|
+
const rootPath = path.resolve(root);
|
|
36
|
+
const fullPath = path.resolve(rootPath, normalized);
|
|
37
|
+
if (fullPath !== rootPath && !fullPath.startsWith(rootPath + path.sep)) {
|
|
38
|
+
throw new Error(`path escapes install root: ${relPath}`);
|
|
39
|
+
}
|
|
40
|
+
return fullPath;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function atomicWriteFile(filePath, data, options = {}) {
|
|
44
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
45
|
+
const suffix = `${process.pid}-${Date.now()}-${crypto.randomUUID()}`;
|
|
46
|
+
const tmpPath = `${filePath}.tmp-${suffix}`;
|
|
47
|
+
try {
|
|
48
|
+
fs.writeFileSync(tmpPath, data, options);
|
|
49
|
+
fs.renameSync(tmpPath, filePath);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
try {
|
|
52
|
+
fs.rmSync(tmpPath, { force: true });
|
|
53
|
+
} catch {
|
|
54
|
+
// Best effort cleanup only.
|
|
55
|
+
}
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readJsonFile(filePath, fallback) {
|
|
61
|
+
if (!fs.existsSync(filePath)) {
|
|
62
|
+
return fallback;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
66
|
+
} catch (err) {
|
|
67
|
+
throw new Error(`${filePath} is not valid JSON: ${err.message}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function writeJsonFile(filePath, value) {
|
|
72
|
+
atomicWriteFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function listFiles(root, options = {}) {
|
|
76
|
+
if (!fs.existsSync(root)) {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
const ignoreNames = new Set(options.ignoreNames || []);
|
|
80
|
+
const files = [];
|
|
81
|
+
function walk(dir, relBase) {
|
|
82
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
83
|
+
for (const entry of entries) {
|
|
84
|
+
if (ignoreNames.has(entry.name)) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const fullPath = path.join(dir, entry.name);
|
|
88
|
+
const relPath = relBase ? `${relBase}/${entry.name}` : entry.name;
|
|
89
|
+
if (entry.isDirectory()) {
|
|
90
|
+
walk(fullPath, relPath);
|
|
91
|
+
} else if (entry.isFile()) {
|
|
92
|
+
files.push(relPath);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
walk(root, '');
|
|
97
|
+
return files.sort();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function removeEmptyParents(startDir, stopDir) {
|
|
101
|
+
let current = path.resolve(startDir);
|
|
102
|
+
const stop = path.resolve(stopDir);
|
|
103
|
+
while (current !== stop && current.startsWith(stop + path.sep)) {
|
|
104
|
+
try {
|
|
105
|
+
fs.rmdirSync(current);
|
|
106
|
+
} catch {
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
current = path.dirname(current);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = {
|
|
114
|
+
atomicWriteFile,
|
|
115
|
+
fileHash,
|
|
116
|
+
listFiles,
|
|
117
|
+
normalizeRelativePath,
|
|
118
|
+
readJsonFile,
|
|
119
|
+
removeEmptyParents,
|
|
120
|
+
resolveInside,
|
|
121
|
+
sha256Bytes,
|
|
122
|
+
writeJsonFile,
|
|
123
|
+
};
|
package/lib/hooks.cjs
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
const { readJsonFile, writeJsonFile } = require('./fs-utils.cjs');
|
|
7
|
+
|
|
8
|
+
const HOOK_EVENTS = new Set(['PreToolUse', 'PostToolUse', 'UserPromptSubmit', 'SessionStart']);
|
|
9
|
+
|
|
10
|
+
const CLEAN_ROOM_HOOKS = [
|
|
11
|
+
{
|
|
12
|
+
event: 'PreToolUse',
|
|
13
|
+
matcher: 'Bash|Shell',
|
|
14
|
+
checks: ['require-clean-room-env.py', 'deny-clean-room-shell.py'],
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
event: 'PreToolUse',
|
|
18
|
+
matcher: 'Read|Glob|Grep',
|
|
19
|
+
checks: ['require-clean-room-env.py', 'deny-clean-source-read.py'],
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
event: 'PreToolUse',
|
|
23
|
+
matcher: 'Write|Edit|MultiEdit',
|
|
24
|
+
checks: ['require-clean-room-env.py', 'deny-contaminated-clean-write.py'],
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
event: 'PostToolUse',
|
|
28
|
+
matcher: 'Write|Edit|MultiEdit',
|
|
29
|
+
checks: ['require-clean-room-env.py', 'check-artifact-leakage.py', 'validate-json-schema.py'],
|
|
30
|
+
},
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
function shellQuote(value) {
|
|
34
|
+
const text = String(value);
|
|
35
|
+
if (text === '') {
|
|
36
|
+
return "''";
|
|
37
|
+
}
|
|
38
|
+
return `'${text.replace(/'/g, "'\"'\"'")}'`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function buildHookCommand({ pythonPath, wrapperPath, mode, checks }) {
|
|
42
|
+
const parts = [
|
|
43
|
+
shellQuote(pythonPath),
|
|
44
|
+
shellQuote(wrapperPath),
|
|
45
|
+
'--mode',
|
|
46
|
+
mode,
|
|
47
|
+
];
|
|
48
|
+
for (const check of checks) {
|
|
49
|
+
parts.push('--check', check);
|
|
50
|
+
}
|
|
51
|
+
return parts.join(' ');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function buildHookEntries({ pythonPath, wrapperPath, mode }) {
|
|
55
|
+
return CLEAN_ROOM_HOOKS.map((entry) => ({
|
|
56
|
+
event: entry.event,
|
|
57
|
+
matcher: entry.matcher,
|
|
58
|
+
hook: {
|
|
59
|
+
type: 'command',
|
|
60
|
+
command: buildHookCommand({
|
|
61
|
+
pythonPath,
|
|
62
|
+
wrapperPath,
|
|
63
|
+
mode,
|
|
64
|
+
checks: entry.checks,
|
|
65
|
+
}),
|
|
66
|
+
timeout: 10,
|
|
67
|
+
statusMessage: 'Checking clean-room guardrails',
|
|
68
|
+
},
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isManagedHook(hook) {
|
|
73
|
+
return !!(
|
|
74
|
+
hook &&
|
|
75
|
+
typeof hook === 'object' &&
|
|
76
|
+
typeof hook.command === 'string' &&
|
|
77
|
+
hook.command.includes('clean-room-hook.py')
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function hasTopLevelHookEvents(value) {
|
|
82
|
+
return Object.keys(value || {}).some((key) => HOOK_EVENTS.has(key));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function hookTableFor(value) {
|
|
86
|
+
if (value && typeof value.hooks === 'object' && value.hooks !== null && !Array.isArray(value.hooks)) {
|
|
87
|
+
return value.hooks;
|
|
88
|
+
}
|
|
89
|
+
if (hasTopLevelHookEvents(value)) {
|
|
90
|
+
return value;
|
|
91
|
+
}
|
|
92
|
+
if (!value.hooks || typeof value.hooks !== 'object' || Array.isArray(value.hooks)) {
|
|
93
|
+
value.hooks = {};
|
|
94
|
+
}
|
|
95
|
+
return value.hooks;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function removeManagedHookEntriesFromTable(table) {
|
|
99
|
+
let changed = false;
|
|
100
|
+
for (const event of Object.keys(table)) {
|
|
101
|
+
if (!Array.isArray(table[event])) {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const nextEntries = [];
|
|
105
|
+
for (const entry of table[event]) {
|
|
106
|
+
if (!entry || typeof entry !== 'object' || !Array.isArray(entry.hooks)) {
|
|
107
|
+
nextEntries.push(entry);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const hooks = entry.hooks.filter((hook) => !isManagedHook(hook));
|
|
111
|
+
if (hooks.length !== entry.hooks.length) {
|
|
112
|
+
changed = true;
|
|
113
|
+
}
|
|
114
|
+
if (hooks.length > 0) {
|
|
115
|
+
nextEntries.push({ ...entry, hooks });
|
|
116
|
+
} else {
|
|
117
|
+
changed = true;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (nextEntries.length > 0) {
|
|
121
|
+
table[event] = nextEntries;
|
|
122
|
+
} else {
|
|
123
|
+
delete table[event];
|
|
124
|
+
}
|
|
125
|
+
if (nextEntries.length !== table[event]?.length) {
|
|
126
|
+
changed = true;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return changed;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function mergeHookEntries(configPath, entries, options = {}) {
|
|
133
|
+
const original = readJsonFile(configPath, {});
|
|
134
|
+
if (!original || typeof original !== 'object' || Array.isArray(original)) {
|
|
135
|
+
throw new Error(`${configPath} must contain a JSON object`);
|
|
136
|
+
}
|
|
137
|
+
const next = structuredClone(original);
|
|
138
|
+
const table = hookTableFor(next);
|
|
139
|
+
removeManagedHookEntriesFromTable(table);
|
|
140
|
+
for (const entry of entries) {
|
|
141
|
+
if (!Array.isArray(table[entry.event])) {
|
|
142
|
+
table[entry.event] = [];
|
|
143
|
+
}
|
|
144
|
+
table[entry.event].push({
|
|
145
|
+
matcher: entry.matcher,
|
|
146
|
+
hooks: [entry.hook],
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
if (!options.dryRun) {
|
|
150
|
+
writeJsonFile(configPath, next);
|
|
151
|
+
}
|
|
152
|
+
return next;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function removeHookEntries(configPath, options = {}) {
|
|
156
|
+
if (!fs.existsSync(configPath)) {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
const original = readJsonFile(configPath, {});
|
|
160
|
+
if (!original || typeof original !== 'object' || Array.isArray(original)) {
|
|
161
|
+
throw new Error(`${configPath} must contain a JSON object`);
|
|
162
|
+
}
|
|
163
|
+
const next = structuredClone(original);
|
|
164
|
+
const table = hookTableFor(next);
|
|
165
|
+
const changed = removeManagedHookEntriesFromTable(table);
|
|
166
|
+
if (changed && !options.dryRun) {
|
|
167
|
+
writeJsonFile(configPath, next);
|
|
168
|
+
}
|
|
169
|
+
return changed ? next : original;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function renderPackageHooksJson(mode) {
|
|
173
|
+
const hooks = {};
|
|
174
|
+
for (const entry of CLEAN_ROOM_HOOKS) {
|
|
175
|
+
if (!Array.isArray(hooks[entry.event])) {
|
|
176
|
+
hooks[entry.event] = [];
|
|
177
|
+
}
|
|
178
|
+
hooks[entry.event].push({
|
|
179
|
+
matcher: entry.matcher,
|
|
180
|
+
hooks: [
|
|
181
|
+
{
|
|
182
|
+
type: 'command',
|
|
183
|
+
command: [
|
|
184
|
+
'python3',
|
|
185
|
+
'hooks/clean-room-hook.py',
|
|
186
|
+
'--mode',
|
|
187
|
+
mode,
|
|
188
|
+
...entry.checks.flatMap((check) => ['--check', check]),
|
|
189
|
+
].join(' '),
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return `${JSON.stringify({ hooks }, null, 2)}\n`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function configPathForRuntime(runtime, targetRoot) {
|
|
198
|
+
if (runtime === 'codex') {
|
|
199
|
+
return path.join(targetRoot, 'hooks.json');
|
|
200
|
+
}
|
|
201
|
+
if (runtime === 'claude') {
|
|
202
|
+
return path.join(targetRoot, 'settings.json');
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
module.exports = {
|
|
208
|
+
buildHookEntries,
|
|
209
|
+
configPathForRuntime,
|
|
210
|
+
renderPackageHooksJson,
|
|
211
|
+
removeHookEntries,
|
|
212
|
+
mergeHookEntries,
|
|
213
|
+
shellQuote,
|
|
214
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "clean-room-skill",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Spec-first clean-room workflow for authorized source analysis without replacement code.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"clean-room-skill": "bin/install.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"bin",
|
|
10
|
+
"lib",
|
|
11
|
+
"skills",
|
|
12
|
+
"agents",
|
|
13
|
+
"hooks",
|
|
14
|
+
"examples",
|
|
15
|
+
"plugin.json",
|
|
16
|
+
".codex-plugin",
|
|
17
|
+
".claude-plugin",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE",
|
|
20
|
+
"!**/__pycache__",
|
|
21
|
+
"!**/*.pyc",
|
|
22
|
+
"!**/*.pyo",
|
|
23
|
+
"!**/.DS_Store"
|
|
24
|
+
],
|
|
25
|
+
"keywords": [
|
|
26
|
+
"clean-room",
|
|
27
|
+
"specification",
|
|
28
|
+
"reverse-engineering",
|
|
29
|
+
"compliance",
|
|
30
|
+
"codex",
|
|
31
|
+
"claude"
|
|
32
|
+
],
|
|
33
|
+
"author": {
|
|
34
|
+
"name": "whit3rabbit"
|
|
35
|
+
},
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"homepage": "https://github.com/whit3rabbit/clean-room-skill",
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "git+https://github.com/whit3rabbit/clean-room-skill.git"
|
|
41
|
+
},
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=20"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"test": "node --test",
|
|
47
|
+
"test:install": "node --test tests/install.test.js"
|
|
48
|
+
}
|
|
49
|
+
}
|
package/plugin.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "clean-room",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Spec-first clean-room workflow for authorized source analysis without replacement code.",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "whit3rabbit"
|
|
7
|
+
},
|
|
8
|
+
"homepage": "https://github.com/whit3rabbit/clean-room-skill",
|
|
9
|
+
"repository": "https://github.com/whit3rabbit/clean-room-skill",
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"keywords": [
|
|
12
|
+
"clean-room",
|
|
13
|
+
"specification",
|
|
14
|
+
"reverse-engineering",
|
|
15
|
+
"compliance"
|
|
16
|
+
],
|
|
17
|
+
"skills": "./skills/",
|
|
18
|
+
"agents": "./agents/",
|
|
19
|
+
"hooks": "./hooks/hooks.json"
|
|
20
|
+
}
|