clean-room-skill 0.1.12 → 0.1.13
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 +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +32 -5
- package/agents/clean-architect.md +3 -0
- package/agents/clean-implementer-verifier-shell.md +3 -0
- package/agents/clean-polish-reviewer.md +3 -0
- package/agents/clean-qa-editor.md +3 -0
- package/agents/contaminated-handoff-sanitizer.md +3 -0
- package/agents/contaminated-manager-verifier.md +3 -0
- package/agents/contaminated-source-analyst.md +3 -0
- package/bin/install.js +11 -1621
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/HOOKS.md +14 -10
- package/docs/REFERENCE.md +24 -4
- package/examples/codex/.codex/agents/clean-architect.toml +3 -3
- package/examples/codex/.codex/agents/clean-polish-reviewer.toml +2 -2
- package/examples/codex/.codex/agents/clean-qa-editor.toml +2 -2
- package/examples/codex/.codex/agents/contaminated-handoff-sanitizer.toml +2 -2
- package/examples/codex/.codex/agents/contaminated-manager-verifier.toml +3 -3
- package/examples/codex/.codex/agents/contaminated-source-analyst.toml +2 -2
- package/lib/bootstrap.cjs +5 -1
- package/lib/doctor.cjs +157 -5
- package/lib/hooks.cjs +18 -0
- package/lib/install-artifacts.cjs +178 -4
- package/lib/install-claude-plugin.cjs +374 -0
- package/lib/install-cli.cjs +99 -0
- package/lib/install-operations.cjs +376 -0
- package/lib/install-options.cjs +149 -0
- package/lib/install-runtime-selection.cjs +180 -0
- package/lib/install-status.cjs +292 -0
- package/lib/install-tui.cjs +359 -0
- package/lib/preflight-bootstrap.cjs +39 -0
- package/lib/preflight-cli.cjs +95 -0
- package/lib/preflight-constants.cjs +25 -0
- package/lib/preflight-output.cjs +37 -0
- package/lib/preflight-paths.cjs +67 -0
- package/lib/preflight-template.cjs +103 -0
- package/lib/preflight-validation.cjs +276 -0
- package/lib/preflight.cjs +18 -461
- package/lib/run-clean-artifacts.cjs +276 -0
- package/lib/run-cli.cjs +90 -0
- package/lib/run-constants.cjs +171 -0
- package/lib/run-controller.cjs +247 -0
- package/lib/run-coverage.cjs +350 -0
- package/lib/run-hooks.cjs +96 -0
- package/lib/run-manifest.cjs +111 -0
- package/lib/run-progress.cjs +160 -0
- package/lib/run-results.cjs +433 -0
- package/lib/run-roots.cjs +230 -0
- package/lib/run-stages.cjs +409 -0
- package/lib/run.cjs +4 -2254
- package/lib/runtime-layout.cjs +12 -5
- package/package.json +8 -2
- package/plugin.json +1 -1
- package/skills/attended/SKILL.md +2 -0
- package/skills/clean-room/SKILL.md +2 -2
- package/skills/unattended/SKILL.md +2 -0
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -288,7 +288,7 @@ Note: Even though clean and source-denied roles (such as Agent 1.5, 2, 3, and 4)
|
|
|
288
288
|
|
|
289
289
|
## Guardrails and Hooks
|
|
290
290
|
|
|
291
|
-
The architecture relies on agent/tool hook scaffolding located in `hooks/` to enforce boundary rules dynamically during agent sessions. Use installer-generated Codex or Claude hook configs with absolute wrapper paths. Static cwd-relative plugin hook declarations are not treated as an enforcement boundary. Use strict hooks for dedicated Codex or
|
|
291
|
+
The architecture relies on agent/tool hook scaffolding located in `hooks/` to enforce boundary rules dynamically during agent sessions. Use installer-generated Codex or Claude hook configs with absolute wrapper paths, or the generated OpenCode local plugin bridge. Static cwd-relative plugin hook declarations are not treated as an enforcement boundary. Use strict hooks for dedicated Codex, Claude, or OpenCode clean-room homes; safe hooks are compatibility-only between runs and begin enforcing when init/onboarding launches role sessions with clean-room environment variables.
|
|
292
292
|
|
|
293
293
|
Matcher coverage depends on the host runtime emitting hook events for the tool invocation. Hosts that do not emit a pre/post tool event for a file, terminal, or resource tool are not protected by adding that tool name to the generated hook config. Run `clean-room-skill doctor --runtime codex --hooks=strict --coverage` or the Claude equivalent after install.
|
|
294
294
|
|
package/docs/HOOKS.md
CHANGED
|
@@ -6,7 +6,7 @@ The hooks are engineering guardrails. They reduce accidental cross-domain reads
|
|
|
6
6
|
|
|
7
7
|
## Install Locations
|
|
8
8
|
|
|
9
|
-
The installer copies the Python hook files for every supported runtime layout. Runtime hook registration is verified
|
|
9
|
+
The installer copies the Python hook files for every supported runtime layout. Runtime hook registration is verified for Codex, Claude Code, and OpenCode.
|
|
10
10
|
|
|
11
11
|
| Runtime | Hook files copied to | Active hook config |
|
|
12
12
|
| --- | --- | --- |
|
|
@@ -14,7 +14,7 @@ The installer copies the Python hook files for every supported runtime layout. R
|
|
|
14
14
|
| Claude Code | `<targetRoot>/hooks/clean-room/*.py` | `<targetRoot>/settings.json` |
|
|
15
15
|
| Antigravity | `<targetRoot>/hooks/clean-room/*.py` | Unsupported, copy only |
|
|
16
16
|
| Gemini CLI | `<targetRoot>/hooks/clean-room/*.py` | Unsupported, copy only |
|
|
17
|
-
| OpenCode | `<targetRoot>/hooks/clean-room/*.py` |
|
|
17
|
+
| OpenCode | `<targetRoot>/hooks/clean-room/*.py` | `<targetRoot>/plugins/clean-room.ts` |
|
|
18
18
|
| Kilo | `<targetRoot>/hooks/clean-room/*.py` | Unsupported, copy only |
|
|
19
19
|
| Cursor | `<targetRoot>/hooks/clean-room/*.py` | Unsupported, copy only |
|
|
20
20
|
| GitHub Copilot | `<targetRoot>/hooks/clean-room/*.py` | Unsupported, copy only |
|
|
@@ -31,8 +31,8 @@ Codex uses `CODEX_HOME` or `~/.codex` for global installs. Claude Code uses `CLA
|
|
|
31
31
|
|
|
32
32
|
| Mode | Behavior |
|
|
33
33
|
| --- | --- |
|
|
34
|
-
| `safe` | Default. Registers hooks for Codex or
|
|
35
|
-
| `strict` | Registers hooks for Codex or
|
|
34
|
+
| `safe` | Default. Registers hooks for Codex, Claude, or OpenCode, but `clean-room-hook.py` no-ops until a clean-room role environment is present or `CLEAN_ROOM_HOOK_ENFORCE` is truthy. |
|
|
35
|
+
| `strict` | Registers hooks for Codex, Claude, or OpenCode and fails closed even without clean-room role environment. Use only in dedicated clean-room runtime homes. |
|
|
36
36
|
| `copy-only` | Copies hook files without modifying runtime hook config. This is also the effective behavior for runtimes without verified hook registration support. |
|
|
37
37
|
|
|
38
38
|
`--no-hooks` is an alias for `--hooks=copy-only`.
|
|
@@ -41,6 +41,8 @@ Codex uses `CODEX_HOME` or `~/.codex` for global installs. Claude Code uses `CLA
|
|
|
41
41
|
|
|
42
42
|
When hook mode is `safe` or `strict`, the installer registers four managed hook entries for Codex and Claude. Each entry invokes the installed `clean-room-hook.py` wrapper with an absolute Python path, an absolute wrapper path, the requested hook mode, and one or more `--check` scripts.
|
|
43
43
|
|
|
44
|
+
For OpenCode, the installer writes a generated local plugin at `<targetRoot>/plugins/clean-room.ts`. OpenCode auto-loads that plugin from its config directory. The plugin subscribes to `tool.execute.before` and `tool.execute.after`, translates OpenCode tool payloads into the existing clean-room hook payload shape, and invokes the installed Python wrapper with `shell: false`. `copy-only` omits this plugin.
|
|
45
|
+
|
|
44
46
|
| Event | Matcher | Checks |
|
|
45
47
|
| --- | --- | --- |
|
|
46
48
|
| `PreToolUse` | <code>Bash|Shell|PowerShell|Monitor|exec_command|shell_command|write_stdin</code> | `require-clean-room-env.py`, `deny-clean-room-shell.py` |
|
|
@@ -205,26 +207,28 @@ The hook policy is deny-by-default during active clean-room role sessions.
|
|
|
205
207
|
|
|
206
208
|
## Verification
|
|
207
209
|
|
|
208
|
-
Use `doctor` after installing Codex or
|
|
210
|
+
Use `doctor` after installing Codex, Claude, or OpenCode hooks:
|
|
209
211
|
|
|
210
212
|
```bash
|
|
211
213
|
clean-room-skill doctor --runtime codex --hooks=safe
|
|
212
214
|
clean-room-skill doctor --runtime codex --hooks=strict
|
|
213
215
|
clean-room-skill doctor --runtime codex --hooks=strict --coverage
|
|
214
216
|
clean-room-skill doctor --runtime claude --hooks=strict --coverage
|
|
217
|
+
clean-room-skill doctor --runtime opencode --hooks=strict --coverage
|
|
215
218
|
```
|
|
216
219
|
|
|
217
220
|
Add `--config-dir <path>` when checking a non-default runtime config root.
|
|
218
221
|
|
|
219
222
|
`doctor` verifies that:
|
|
220
223
|
|
|
221
|
-
- The hook config exists.
|
|
222
|
-
- Exactly four managed clean-room hook entries are present.
|
|
223
|
-
- Managed commands use absolute Python and wrapper paths.
|
|
224
|
+
- The hook config or OpenCode local plugin exists.
|
|
225
|
+
- Exactly four managed clean-room hook entries are present for Codex and Claude.
|
|
226
|
+
- Managed Codex and Claude commands use absolute Python and wrapper paths.
|
|
227
|
+
- The OpenCode plugin declares `tool.execute.before`, `tool.execute.after`, an absolute wrapper path, and `shell: false`.
|
|
224
228
|
- The requested safe or strict mode is configured.
|
|
225
229
|
- Safe mode no-ops without clean-room environment.
|
|
226
230
|
- Strict mode and enforced safe mode fail without required environment.
|
|
227
231
|
- Smoke payloads fail for source reads, source writes, shell bypasses, and malformed post-write JSON.
|
|
228
|
-
- `--coverage` prints matcher and check coverage for
|
|
232
|
+
- `--coverage` prints matcher and check coverage for generated hook config entries or OpenCode plugin coverage.
|
|
229
233
|
|
|
230
|
-
`doctor` is a smoke test. It does not prove host event coverage, legal sufficiency, or full runtime isolation.
|
|
234
|
+
`doctor` is a smoke test. It does not prove host event coverage, legal sufficiency, or full runtime isolation. For OpenCode, it verifies the generated plugin bridge and Python guardrail checks, not every OpenCode tool surface.
|
package/docs/REFERENCE.md
CHANGED
|
@@ -64,12 +64,12 @@ Verified:
|
|
|
64
64
|
|
|
65
65
|
- Codex
|
|
66
66
|
- Claude Code
|
|
67
|
+
- OpenCode
|
|
67
68
|
|
|
68
69
|
Layout-only or experimental:
|
|
69
70
|
|
|
70
71
|
- Antigravity
|
|
71
72
|
- Gemini CLI
|
|
72
|
-
- OpenCode
|
|
73
73
|
- Kilo
|
|
74
74
|
- Cursor
|
|
75
75
|
- GitHub Copilot
|
|
@@ -80,7 +80,18 @@ Layout-only or experimental:
|
|
|
80
80
|
- Hermes Agent
|
|
81
81
|
- CodeBuddy
|
|
82
82
|
|
|
83
|
-
Layout-only installs write files to expected runtime locations, but this repository does not verify that those hosts load the files or emit all hook events needed for clean-room enforcement.
|
|
83
|
+
Layout-only installs write files to expected runtime locations, but this repository does not verify that those hosts load the files or emit all hook events needed for clean-room enforcement. OpenCode installs are verified through a generated local plugin bridge at `plugins/clean-room.ts`; `doctor` verifies that bridge and the Python guardrails, not every OpenCode tool surface.
|
|
84
|
+
|
|
85
|
+
### Pi Package Compatibility
|
|
86
|
+
|
|
87
|
+
Pi can install this package and load the bundled skills from the package metadata:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
pi install npm:clean-room-skill@latest
|
|
91
|
+
pi install https://github.com/whit3rabbit/clean-room-skill
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Pi invokes skills as `/skill:<name>`. Use `/skill:init` for the setup pass, `/skill:clean-room` for the startup wizard, `/skill:attended` for attended controller mode, and `/skill:unattended` for bounded unattended mode. Pi support is package compatibility only: it does not add a `--pi` installer target, does not participate in `--all`, and does not register clean-room hooks. Clean-room safety still depends on role separation, path isolation, schema validation, and supported hook runtimes.
|
|
84
95
|
|
|
85
96
|
Global install roots:
|
|
86
97
|
|
|
@@ -103,12 +114,20 @@ Global install roots:
|
|
|
103
114
|
|
|
104
115
|
Local installs use each runtime's project config directory. Antigravity local installs write `.agents/plugins/clean-room/`.
|
|
105
116
|
|
|
117
|
+
## Agent Metadata Compatibility
|
|
118
|
+
|
|
119
|
+
Runtime agent metadata is intentionally runtime-specific. Claude Code Markdown agents support documented `model`, `effort`, `color`, and optional `memory` frontmatter. Clean-room role agents use `model`, `effort`, and `color` only. They do not use persistent `memory`, because clean-room state must come from durable artifacts, role-session briefs, and fresh role sessions rather than runtime recall.
|
|
120
|
+
|
|
121
|
+
Codex TOML agents support documented session config fields such as `model`, `model_reasoning_effort`, `developer_instructions`, `sandbox_mode`, `mcp_servers`, and `skills.config`. Do not copy Claude aliases such as `sonnet` or `opus`, Claude `color`, or Claude `memory` fields into Codex TOML templates.
|
|
122
|
+
|
|
123
|
+
Codex hooks support `updatedInput`, but clean-room hook enforcement should stay fail-closed through exit status and explicit deny decisions. Do not rewrite clean-room tool calls in hooks; command mutation makes boundary behavior harder to review and test.
|
|
124
|
+
|
|
106
125
|
## Hook Modes And Doctor
|
|
107
126
|
|
|
108
127
|
Hook modes:
|
|
109
128
|
|
|
110
129
|
- `safe`: default. Copies hooks and registers a wrapper that no-ops until role sessions provide clean-room environment variables. `CLEAN_ROOM_HOOK_ENFORCE=1` remains available for explicit smoke tests.
|
|
111
|
-
- `strict`: fail-closed mode for dedicated Codex or
|
|
130
|
+
- `strict`: fail-closed mode for dedicated Codex, Claude, or OpenCode clean-room homes.
|
|
112
131
|
- `copy-only`: copies hook files without runtime hook registration.
|
|
113
132
|
|
|
114
133
|
Smoke test generated hook registration:
|
|
@@ -117,11 +136,12 @@ Smoke test generated hook registration:
|
|
|
117
136
|
clean-room-skill doctor --runtime codex --hooks=safe
|
|
118
137
|
clean-room-skill doctor --runtime codex --hooks=strict
|
|
119
138
|
clean-room-skill doctor --runtime codex --hooks=strict --coverage
|
|
139
|
+
clean-room-skill doctor --runtime opencode --hooks=strict --coverage
|
|
120
140
|
```
|
|
121
141
|
|
|
122
142
|
Use `--runtime claude` for Claude Code, and add `--config-dir <path>` when testing an alternate config root.
|
|
123
143
|
|
|
124
|
-
`doctor` checks that Codex or Claude hook config exists,
|
|
144
|
+
`doctor` checks that Codex or Claude hook config exists, or that the OpenCode local plugin exists. It verifies generated clean-room hooks or plugin wiring, absolute wrapper paths, the requested safe or strict mode, and smoke payload failures for missing environment, source reads, source writes, shell use, and malformed post-write JSON. Safe mode also verifies no-op behavior without clean-room env.
|
|
125
145
|
|
|
126
146
|
It does not prove legal sufficiency, full runtime hook event coverage, host-side feature enablement, or full JSON Schema conformance.
|
|
127
147
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
name = "clean-architect"
|
|
2
2
|
description = "Plans clean implementation from approved clean behavior specs and the clean destination foundation."
|
|
3
3
|
sandbox_mode = "workspace-write"
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
model = "gpt-5.5"
|
|
5
|
+
model_reasoning_effort = "high"
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
developer_instructions = """
|
|
8
8
|
Act as Agent 2 in the clean-room pipeline.
|
|
9
9
|
Run only from the clean workspace.
|
|
10
10
|
Before tool use, require CLEAN_ROOM_ROLE=clean-architect, CLEAN_ROOM_CLEAN_ROOTS, CLEAN_ROOM_IMPLEMENTATION_ROOTS, CLEAN_ROOM_SOURCE_ROOTS, CLEAN_ROOM_CONTAMINATED_ARTIFACT_ROOTS, CLEAN_ROOM_ALLOWED_READ_ROOTS, and CLEAN_ROOM_SCHEMA_DIR.
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
name = "clean-polish-reviewer"
|
|
2
2
|
description = "Performs final clean code polish, repo hygiene, verification review, and constrained implementation-root commit."
|
|
3
3
|
sandbox_mode = "workspace-write"
|
|
4
|
+
model = "gpt-5.4-mini"
|
|
4
5
|
model_reasoning_effort = "high"
|
|
5
|
-
enabled_skills = ["clean-room"]
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
developer_instructions = """
|
|
8
8
|
Act as Agent 4 in the clean-room pipeline.
|
|
9
9
|
Run only in the clean domain after Agent 3 has produced terminal implementation and QC reports.
|
|
10
10
|
Before tool use, require CLEAN_ROOM_ROLE=clean-polish-reviewer, CLEAN_ROOM_CLEAN_ROOTS, CLEAN_ROOM_IMPLEMENTATION_ROOTS, CLEAN_ROOM_SOURCE_ROOTS, CLEAN_ROOM_CONTAMINATED_ARTIFACT_ROOTS, CLEAN_ROOM_ALLOWED_READ_ROOTS, and CLEAN_ROOM_SCHEMA_DIR.
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
name = "clean-qa-editor"
|
|
2
2
|
description = "Implements the clean plan, verifies clean destination code, and emits one terminal report for Agent 0."
|
|
3
3
|
sandbox_mode = "workspace-write"
|
|
4
|
+
model = "gpt-5.4-mini"
|
|
4
5
|
model_reasoning_effort = "high"
|
|
5
|
-
enabled_skills = ["clean-room"]
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
developer_instructions = """
|
|
8
8
|
Act as Agent 3 in the clean-room pipeline.
|
|
9
9
|
Run only in the clean domain.
|
|
10
10
|
Before tool use, require CLEAN_ROOM_ROLE=clean-qa-editor, CLEAN_ROOM_CLEAN_ROOTS, CLEAN_ROOM_IMPLEMENTATION_ROOTS, CLEAN_ROOM_SOURCE_ROOTS, CLEAN_ROOM_CONTAMINATED_ARTIFACT_ROOTS, CLEAN_ROOM_ALLOWED_READ_ROOTS, and CLEAN_ROOM_SCHEMA_DIR.
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
name = "contaminated-handoff-sanitizer"
|
|
2
2
|
description = "Reviews Agent 1 draft specs from a fresh source-denied contaminated context and approves only scrubbed handoff artifacts."
|
|
3
3
|
sandbox_mode = "workspace-write"
|
|
4
|
+
model = "gpt-5.4-mini"
|
|
4
5
|
model_reasoning_effort = "high"
|
|
5
|
-
enabled_skills = ["clean-room"]
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
developer_instructions = """
|
|
8
8
|
Act as Agent 1.5 in the clean-room pipeline.
|
|
9
9
|
Operate in the contaminated domain, but without source access or Agent 1 source-reading chat history.
|
|
10
10
|
Before tool use, require CLEAN_ROOM_ROLE=contaminated-handoff-sanitizer, CLEAN_ROOM_CONTAMINATED_ARTIFACT_ROOTS, CLEAN_ROOM_SOURCE_ROOTS, CLEAN_ROOM_CLEAN_ROOTS, CLEAN_ROOM_IMPLEMENTATION_ROOTS, CLEAN_ROOM_ALLOWED_READ_ROOTS, and CLEAN_ROOM_SCHEMA_DIR.
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
name = "contaminated-manager-verifier"
|
|
2
2
|
description = "Consumes contaminated source indexes, tracks source coverage, and emits only abstract clean-room delta tickets."
|
|
3
3
|
sandbox_mode = "workspace-write"
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
model = "gpt-5.5"
|
|
5
|
+
model_reasoning_effort = "high"
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
developer_instructions = """
|
|
8
8
|
Act as Agent 0 in the clean-room pipeline.
|
|
9
9
|
Operate only in the contaminated domain.
|
|
10
10
|
Read authorized source and contaminated ledgers as needed.
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
name = "contaminated-source-analyst"
|
|
2
2
|
description = "Reads authorized source and writes neutral draft task slices plus behavior specs with evidence references."
|
|
3
3
|
sandbox_mode = "workspace-write"
|
|
4
|
+
model = "gpt-5.4-mini"
|
|
4
5
|
model_reasoning_effort = "medium"
|
|
5
|
-
enabled_skills = ["clean-room"]
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
developer_instructions = """
|
|
8
8
|
Act as Agent 1 in the clean-room pipeline.
|
|
9
9
|
Operate only in the contaminated domain.
|
|
10
10
|
Before reading source, require active task-manifest.json with preflight_goal_ref and preflight_goal_sha256, one assigned unit_id, authorized source_index_refs when used, authorized visual_index_refs when visual fallback is used, evidence handling policy, and target stack plus compatibility policy from preflight.
|
package/lib/bootstrap.cjs
CHANGED
|
@@ -408,7 +408,11 @@ function printInitResult(options) {
|
|
|
408
408
|
console.log(' install safe hooks: npx clean-room-skill@latest --claude --global --hooks=safe --yes');
|
|
409
409
|
console.log(' start in Claude Code: /clean-room:init, then /clean-room or /clean-room:attended');
|
|
410
410
|
console.log(' uninstall runtime install: npx clean-room-skill@latest --claude --global --uninstall --yes');
|
|
411
|
-
console.log('
|
|
411
|
+
console.log(' Pi:');
|
|
412
|
+
console.log(' install package skills: pi install npm:clean-room-skill@latest');
|
|
413
|
+
console.log(' start in Pi: /skill:init, then /skill:clean-room or /skill:attended');
|
|
414
|
+
console.log(' Pi package install does not register clean-room hooks');
|
|
415
|
+
console.log(' strict hooks are only for dedicated clean-room Codex, Claude, or OpenCode homes');
|
|
412
416
|
}
|
|
413
417
|
|
|
414
418
|
function runInit(argv, context = {}) {
|
package/lib/doctor.cjs
CHANGED
|
@@ -6,11 +6,16 @@ const path = require('node:path');
|
|
|
6
6
|
const { spawnSync } = require('node:child_process');
|
|
7
7
|
|
|
8
8
|
const { readJsonFile } = require('./fs-utils.cjs');
|
|
9
|
-
const {
|
|
9
|
+
const {
|
|
10
|
+
CLEAN_ROOM_HOOKS,
|
|
11
|
+
configPathForRuntime,
|
|
12
|
+
hasManagedOpenCodePlugin,
|
|
13
|
+
pluginPathForRuntime,
|
|
14
|
+
} = require('./hooks.cjs');
|
|
10
15
|
const { resolveRuntimeLayout } = require('./runtime-layout.cjs');
|
|
11
16
|
|
|
12
17
|
const HOOK_MODES = new Set(['safe', 'strict']);
|
|
13
|
-
const RUNTIMES = new Set(['codex', 'claude']);
|
|
18
|
+
const RUNTIMES = new Set(['codex', 'claude', 'opencode']);
|
|
14
19
|
const MAX_SPAWN_OUTPUT_CHARS = 2000;
|
|
15
20
|
const MAX_SPAWN_OUTPUT_BYTES = 256 * 1024;
|
|
16
21
|
const DOCTOR_TIMEOUT_MS = envPositiveInteger('CLEAN_ROOM_DOCTOR_TIMEOUT_MS', 10_000);
|
|
@@ -66,7 +71,7 @@ function parseDoctorArgs(argv) {
|
|
|
66
71
|
}
|
|
67
72
|
}
|
|
68
73
|
if (!RUNTIMES.has(options.runtime)) {
|
|
69
|
-
throw new Error('doctor --runtime must be codex or
|
|
74
|
+
throw new Error('doctor --runtime must be codex, claude, or opencode');
|
|
70
75
|
}
|
|
71
76
|
if (!HOOK_MODES.has(options.hookMode)) {
|
|
72
77
|
throw new Error('doctor --hooks must be safe or strict');
|
|
@@ -215,7 +220,7 @@ function smokeEnv(layout, tmpRoot, role) {
|
|
|
215
220
|
}
|
|
216
221
|
|
|
217
222
|
function runHookCommand(command, payload, env, cwd) {
|
|
218
|
-
const parts =
|
|
223
|
+
const parts = commandParts(command);
|
|
219
224
|
return spawnSync(parts[0], parts.slice(1), {
|
|
220
225
|
cwd,
|
|
221
226
|
env,
|
|
@@ -228,7 +233,7 @@ function runHookCommand(command, payload, env, cwd) {
|
|
|
228
233
|
}
|
|
229
234
|
|
|
230
235
|
function runHookCommandRaw(command, input, env, cwd) {
|
|
231
|
-
const parts =
|
|
236
|
+
const parts = commandParts(command);
|
|
232
237
|
return spawnSync(parts[0], parts.slice(1), {
|
|
233
238
|
cwd,
|
|
234
239
|
env,
|
|
@@ -240,6 +245,10 @@ function runHookCommandRaw(command, input, env, cwd) {
|
|
|
240
245
|
});
|
|
241
246
|
}
|
|
242
247
|
|
|
248
|
+
function commandParts(command) {
|
|
249
|
+
return Array.isArray(command) ? command : shellSplit(command);
|
|
250
|
+
}
|
|
251
|
+
|
|
243
252
|
function spawnOutputSnippet(value) {
|
|
244
253
|
const text = String(value || '').trim();
|
|
245
254
|
if (!text) return null;
|
|
@@ -324,9 +333,152 @@ function assertStrictCoverage(entries) {
|
|
|
324
333
|
}
|
|
325
334
|
}
|
|
326
335
|
|
|
336
|
+
function hookCommandParts(wrapperPath, hookMode, checks) {
|
|
337
|
+
const parts = ['python3', wrapperPath, '--mode', hookMode];
|
|
338
|
+
for (const check of checks) {
|
|
339
|
+
parts.push('--check', check);
|
|
340
|
+
}
|
|
341
|
+
return parts;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function extractStringConstant(content, name) {
|
|
345
|
+
const match = content.match(new RegExp(`const\\s+${name}\\s*=\\s*("(?:\\\\.|[^"\\\\])*")`));
|
|
346
|
+
if (!match) {
|
|
347
|
+
throw new Error(`OpenCode plugin is missing ${name}`);
|
|
348
|
+
}
|
|
349
|
+
return JSON.parse(match[1]);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function assertOpenCodePlugin(layout, hookMode) {
|
|
353
|
+
const pluginPath = pluginPathForRuntime(layout.runtime, layout.targetRoot);
|
|
354
|
+
if (!pluginPath || !fs.existsSync(pluginPath)) {
|
|
355
|
+
throw new Error(`OpenCode plugin does not exist: ${pluginPath}`);
|
|
356
|
+
}
|
|
357
|
+
if (!hasManagedOpenCodePlugin(pluginPath)) {
|
|
358
|
+
throw new Error(`OpenCode plugin is not managed by clean-room-skill: ${pluginPath}`);
|
|
359
|
+
}
|
|
360
|
+
const content = fs.readFileSync(pluginPath, 'utf8');
|
|
361
|
+
if (!content.includes('"tool.execute.before"')) {
|
|
362
|
+
throw new Error('OpenCode plugin is missing tool.execute.before hook');
|
|
363
|
+
}
|
|
364
|
+
if (!content.includes('"tool.execute.after"')) {
|
|
365
|
+
throw new Error('OpenCode plugin is missing tool.execute.after hook');
|
|
366
|
+
}
|
|
367
|
+
if (!content.includes('shell: false')) {
|
|
368
|
+
throw new Error('OpenCode plugin must spawn hook checks with shell: false');
|
|
369
|
+
}
|
|
370
|
+
const wrapperPath = extractStringConstant(content, 'CLEAN_ROOM_HOOK_WRAPPER');
|
|
371
|
+
if (!path.isAbsolute(wrapperPath) || path.basename(wrapperPath) !== 'clean-room-hook.py') {
|
|
372
|
+
throw new Error('OpenCode plugin wrapper path is not absolute');
|
|
373
|
+
}
|
|
374
|
+
if (!fs.existsSync(wrapperPath)) {
|
|
375
|
+
throw new Error(`OpenCode plugin wrapper does not exist: ${wrapperPath}`);
|
|
376
|
+
}
|
|
377
|
+
const observedMode = extractStringConstant(content, 'CLEAN_ROOM_HOOK_MODE');
|
|
378
|
+
if (observedMode !== hookMode) {
|
|
379
|
+
throw new Error(`OpenCode plugin does not use --mode ${hookMode}`);
|
|
380
|
+
}
|
|
381
|
+
for (const required of CLEAN_ROOM_HOOKS) {
|
|
382
|
+
for (const check of required.checks) {
|
|
383
|
+
if (!content.includes(check)) {
|
|
384
|
+
throw new Error(`OpenCode plugin is missing check ${check}`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return { pluginPath, wrapperPath };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function printOpenCodeCoverage(plugin, hookMode) {
|
|
392
|
+
console.log('clean-room OpenCode plugin coverage:');
|
|
393
|
+
console.log(' ok tool.execute.before shell/read/write');
|
|
394
|
+
console.log(' ok tool.execute.after write');
|
|
395
|
+
console.log(` wrapper: ${plugin.wrapperPath}`);
|
|
396
|
+
console.log(' unsupported surfaces: OpenCode tools that do not emit tool.execute.* events are not covered');
|
|
397
|
+
console.log(` strict required: ${hookMode === 'strict' ? 'yes' : 'no'}`);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function runOpenCodeDoctor(options, layout) {
|
|
401
|
+
const plugin = assertOpenCodePlugin(layout, options.hookMode);
|
|
402
|
+
const pathEnv = { PATH: process.env.PATH || '' };
|
|
403
|
+
if (options.coverage) {
|
|
404
|
+
printOpenCodeCoverage(plugin, options.hookMode);
|
|
405
|
+
}
|
|
406
|
+
const shellCommand = hookCommandParts(plugin.wrapperPath, options.hookMode, CLEAN_ROOM_HOOKS[0].checks);
|
|
407
|
+
const readCommand = hookCommandParts(plugin.wrapperPath, options.hookMode, CLEAN_ROOM_HOOKS[1].checks);
|
|
408
|
+
const writeCommand = hookCommandParts(plugin.wrapperPath, options.hookMode, CLEAN_ROOM_HOOKS[2].checks);
|
|
409
|
+
const postWriteCommand = hookCommandParts(plugin.wrapperPath, options.hookMode, CLEAN_ROOM_HOOKS[3].checks);
|
|
410
|
+
|
|
411
|
+
if (options.hookMode === 'safe') {
|
|
412
|
+
const safe = runHookCommandRaw(shellCommand, '', pathEnv, layout.targetRoot);
|
|
413
|
+
if (safe.status !== 0) {
|
|
414
|
+
throw new Error(`safe OpenCode hook did not no-op without clean-room env: ${describeSpawn(safe)}`);
|
|
415
|
+
}
|
|
416
|
+
assertHookFails(
|
|
417
|
+
shellCommand,
|
|
418
|
+
{},
|
|
419
|
+
{ ...pathEnv, CLEAN_ROOM_HOOK_ENFORCE: '1' },
|
|
420
|
+
layout.targetRoot,
|
|
421
|
+
'enforced safe OpenCode',
|
|
422
|
+
/environment check failed/
|
|
423
|
+
);
|
|
424
|
+
} else {
|
|
425
|
+
assertHookFails(shellCommand, {}, pathEnv, layout.targetRoot, 'strict OpenCode', /environment check failed/);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'clean-room-doctor-'));
|
|
429
|
+
try {
|
|
430
|
+
const cleanEnv = { ...pathEnv, ...smokeEnv(layout, tmpRoot, 'clean-architect') };
|
|
431
|
+
const qaEnv = {
|
|
432
|
+
...pathEnv,
|
|
433
|
+
...smokeEnv(layout, tmpRoot, 'clean-qa-editor'),
|
|
434
|
+
CLEAN_ROOM_ALLOW_AGENT3_SHELL: '1',
|
|
435
|
+
};
|
|
436
|
+
const sourceFile = path.join(cleanEnv.CLEAN_ROOM_SOURCE_ROOTS, 'secret.txt');
|
|
437
|
+
const cleanBadJson = path.join(cleanEnv.CLEAN_ROOM_CLEAN_ROOTS, 'behavior-spec.json');
|
|
438
|
+
fs.writeFileSync(sourceFile, 'secret\n');
|
|
439
|
+
fs.writeFileSync(cleanBadJson, '{\n');
|
|
440
|
+
|
|
441
|
+
assertHookFails(readCommand, {
|
|
442
|
+
tool_name: 'read',
|
|
443
|
+
tool: 'read',
|
|
444
|
+
tool_input: { filePath: sourceFile },
|
|
445
|
+
cwd: layout.targetRoot,
|
|
446
|
+
}, cleanEnv, layout.targetRoot, 'OpenCode read', /source-root/);
|
|
447
|
+
assertHookFails(writeCommand, {
|
|
448
|
+
tool_name: 'write',
|
|
449
|
+
tool: 'write',
|
|
450
|
+
tool_input: { filePath: sourceFile },
|
|
451
|
+
cwd: layout.targetRoot,
|
|
452
|
+
}, cleanEnv, layout.targetRoot, 'OpenCode write', /source-root/);
|
|
453
|
+
assertHookFails(shellCommand, {
|
|
454
|
+
tool_name: 'bash',
|
|
455
|
+
tool: 'bash',
|
|
456
|
+
tool_input: { cwd: qaEnv.CLEAN_ROOM_IMPLEMENTATION_ROOTS, command: `cat ${sourceFile}` },
|
|
457
|
+
cwd: qaEnv.CLEAN_ROOM_IMPLEMENTATION_ROOTS,
|
|
458
|
+
}, qaEnv, layout.targetRoot, 'OpenCode shell', /policy denied shell tool use|source-root/);
|
|
459
|
+
assertHookFails(postWriteCommand, {
|
|
460
|
+
tool_name: 'write',
|
|
461
|
+
tool: 'write',
|
|
462
|
+
tool_input: { filePath: cleanBadJson },
|
|
463
|
+
cwd: layout.targetRoot,
|
|
464
|
+
}, cleanEnv, layout.targetRoot, 'OpenCode post-write', /JSON parse failed/);
|
|
465
|
+
} finally {
|
|
466
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
console.log(`clean-room doctor passed for ${options.runtime}`);
|
|
470
|
+
console.log(` plugin: ${plugin.pluginPath}`);
|
|
471
|
+
console.log(' managed plugin hooks: tool.execute.before, tool.execute.after');
|
|
472
|
+
console.log(` mode: ${options.hookMode}`);
|
|
473
|
+
return { pluginPath: plugin.pluginPath, managedHooks: 2 };
|
|
474
|
+
}
|
|
475
|
+
|
|
327
476
|
function runDoctor(argv) {
|
|
328
477
|
const options = parseDoctorArgs(argv);
|
|
329
478
|
const layout = resolveRuntimeLayout(options.runtime, options.scope, { configDir: options.configDir });
|
|
479
|
+
if (layout.hookRegistration === 'local-plugin') {
|
|
480
|
+
return runOpenCodeDoctor(options, layout);
|
|
481
|
+
}
|
|
330
482
|
const configPath = configPathForRuntime(layout.runtime, layout.targetRoot);
|
|
331
483
|
if (!configPath) {
|
|
332
484
|
throw new Error(`doctor is not supported for ${layout.runtime}`);
|
package/lib/hooks.cjs
CHANGED
|
@@ -7,6 +7,7 @@ const { readJsonFile, writeJsonFile } = require('./fs-utils.cjs');
|
|
|
7
7
|
|
|
8
8
|
const HOOK_EVENTS = new Set(['PreToolUse', 'PostToolUse', 'UserPromptSubmit', 'SessionStart']);
|
|
9
9
|
const CLEAN_ROOM_HOOK_TIMEOUT_SECONDS = 30;
|
|
10
|
+
const OPENCODE_PLUGIN_MARKER = 'clean-room-skill-opencode-plugin-v1';
|
|
10
11
|
|
|
11
12
|
const CLEAN_ROOM_HOOKS = [
|
|
12
13
|
{
|
|
@@ -163,6 +164,20 @@ function hasManagedHookEntries(configPath) {
|
|
|
163
164
|
return false;
|
|
164
165
|
}
|
|
165
166
|
|
|
167
|
+
function pluginPathForRuntime(runtime, targetRoot) {
|
|
168
|
+
if (runtime === 'opencode') {
|
|
169
|
+
return path.join(targetRoot, 'plugins', 'clean-room.ts');
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function hasManagedOpenCodePlugin(pluginPath) {
|
|
175
|
+
if (!pluginPath || !fs.existsSync(pluginPath)) {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
return fs.readFileSync(pluginPath, 'utf8').includes(OPENCODE_PLUGIN_MARKER);
|
|
179
|
+
}
|
|
180
|
+
|
|
166
181
|
function mergedHookConfig(configPath, entries) {
|
|
167
182
|
const original = readJsonFile(configPath, {});
|
|
168
183
|
if (!original || typeof original !== 'object' || Array.isArray(original)) {
|
|
@@ -231,6 +246,9 @@ module.exports = {
|
|
|
231
246
|
CLEAN_ROOM_HOOK_TIMEOUT_SECONDS,
|
|
232
247
|
configPathForRuntime,
|
|
233
248
|
hasManagedHookEntries,
|
|
249
|
+
hasManagedOpenCodePlugin,
|
|
250
|
+
OPENCODE_PLUGIN_MARKER,
|
|
251
|
+
pluginPathForRuntime,
|
|
234
252
|
removeHookEntries,
|
|
235
253
|
mergeHookEntries,
|
|
236
254
|
shellQuote,
|