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.
Files changed (58) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/README.md +32 -5
  5. package/agents/clean-architect.md +3 -0
  6. package/agents/clean-implementer-verifier-shell.md +3 -0
  7. package/agents/clean-polish-reviewer.md +3 -0
  8. package/agents/clean-qa-editor.md +3 -0
  9. package/agents/contaminated-handoff-sanitizer.md +3 -0
  10. package/agents/contaminated-manager-verifier.md +3 -0
  11. package/agents/contaminated-source-analyst.md +3 -0
  12. package/bin/install.js +11 -1621
  13. package/docs/ARCHITECTURE.md +1 -1
  14. package/docs/HOOKS.md +14 -10
  15. package/docs/REFERENCE.md +24 -4
  16. package/examples/codex/.codex/agents/clean-architect.toml +3 -3
  17. package/examples/codex/.codex/agents/clean-polish-reviewer.toml +2 -2
  18. package/examples/codex/.codex/agents/clean-qa-editor.toml +2 -2
  19. package/examples/codex/.codex/agents/contaminated-handoff-sanitizer.toml +2 -2
  20. package/examples/codex/.codex/agents/contaminated-manager-verifier.toml +3 -3
  21. package/examples/codex/.codex/agents/contaminated-source-analyst.toml +2 -2
  22. package/lib/bootstrap.cjs +5 -1
  23. package/lib/doctor.cjs +157 -5
  24. package/lib/hooks.cjs +18 -0
  25. package/lib/install-artifacts.cjs +178 -4
  26. package/lib/install-claude-plugin.cjs +374 -0
  27. package/lib/install-cli.cjs +99 -0
  28. package/lib/install-operations.cjs +376 -0
  29. package/lib/install-options.cjs +149 -0
  30. package/lib/install-runtime-selection.cjs +180 -0
  31. package/lib/install-status.cjs +292 -0
  32. package/lib/install-tui.cjs +359 -0
  33. package/lib/preflight-bootstrap.cjs +39 -0
  34. package/lib/preflight-cli.cjs +95 -0
  35. package/lib/preflight-constants.cjs +25 -0
  36. package/lib/preflight-output.cjs +37 -0
  37. package/lib/preflight-paths.cjs +67 -0
  38. package/lib/preflight-template.cjs +103 -0
  39. package/lib/preflight-validation.cjs +276 -0
  40. package/lib/preflight.cjs +18 -461
  41. package/lib/run-clean-artifacts.cjs +276 -0
  42. package/lib/run-cli.cjs +90 -0
  43. package/lib/run-constants.cjs +171 -0
  44. package/lib/run-controller.cjs +247 -0
  45. package/lib/run-coverage.cjs +350 -0
  46. package/lib/run-hooks.cjs +96 -0
  47. package/lib/run-manifest.cjs +111 -0
  48. package/lib/run-progress.cjs +160 -0
  49. package/lib/run-results.cjs +433 -0
  50. package/lib/run-roots.cjs +230 -0
  51. package/lib/run-stages.cjs +409 -0
  52. package/lib/run.cjs +4 -2254
  53. package/lib/runtime-layout.cjs +12 -5
  54. package/package.json +8 -2
  55. package/plugin.json +1 -1
  56. package/skills/attended/SKILL.md +2 -0
  57. package/skills/clean-room/SKILL.md +2 -2
  58. package/skills/unattended/SKILL.md +2 -0
@@ -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 Claude clean-room homes; safe hooks are compatibility-only between runs and begin enforcing when init/onboarding launches role sessions with clean-room environment variables.
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 only for Codex and Claude Code.
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` | Unsupported, copy only |
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 Claude, 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 or Claude and fails closed even without clean-room role environment. Use only in dedicated clean-room runtime homes. |
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&#124;Shell&#124;PowerShell&#124;Monitor&#124;exec_command&#124;shell_command&#124;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 Claude hooks:
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 the generated entries.
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 Claude clean-room homes.
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, contains generated clean-room hooks, uses absolute wrapper paths, uses the requested safe or strict mode, and that smoke payloads fail 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.
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
- model_reasoning_effort = "medium"
5
- enabled_skills = ["clean-room"]
4
+ model = "gpt-5.5"
5
+ model_reasoning_effort = "high"
6
6
 
7
- instructions = """
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
- instructions = """
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
- instructions = """
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
- instructions = """
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
- model_reasoning_effort = "medium"
5
- enabled_skills = ["clean-room"]
4
+ model = "gpt-5.5"
5
+ model_reasoning_effort = "high"
6
6
 
7
- instructions = """
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
- instructions = """
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(' strict hooks are only for dedicated clean-room Codex or Claude homes');
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 { CLEAN_ROOM_HOOKS, configPathForRuntime } = require('./hooks.cjs');
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 claude');
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 = shellSplit(command);
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 = shellSplit(command);
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,