clean-room-skill 0.1.11 → 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 +35 -8
- package/agents/clean-architect.md +7 -1
- package/agents/clean-implementer-verifier-shell.md +4 -0
- package/agents/clean-polish-reviewer.md +3 -0
- package/agents/clean-qa-editor.md +4 -0
- package/agents/contaminated-handoff-sanitizer.md +3 -0
- package/agents/contaminated-manager-verifier.md +10 -1
- package/agents/contaminated-source-analyst.md +8 -1
- package/bin/install.js +11 -1621
- package/docs/ARCHITECTURE.md +7 -1
- package/docs/HOOKS.md +14 -10
- package/docs/REFERENCE.md +31 -6
- package/examples/codex/.codex/agents/clean-architect.toml +7 -5
- package/examples/codex/.codex/agents/clean-polish-reviewer.toml +2 -2
- package/examples/codex/.codex/agents/clean-qa-editor.toml +3 -2
- package/examples/codex/.codex/agents/contaminated-handoff-sanitizer.toml +2 -2
- package/examples/codex/.codex/agents/contaminated-manager-verifier.toml +10 -4
- package/examples/codex/.codex/agents/contaminated-source-analyst.toml +7 -3
- package/hooks/validate-json-schema.py +14 -0
- 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 -1998
- 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 +6 -6
- package/skills/clean-room/assets/coverage-ledger.schema.json +95 -0
- package/skills/clean-room/assets/task-manifest.schema.json +25 -0
- package/skills/clean-room/examples/contaminated-side/task-manifest.json +14 -2
- package/skills/clean-room/references/CONTROLLER-LOOP.md +5 -0
- package/skills/clean-room/references/PROCESS.md +12 -4
- package/skills/clean-room/references/SPEC-SCHEMA.md +11 -2
- package/skills/refocus/SKILL.md +2 -0
- package/skills/unattended/SKILL.md +2 -0
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,
|
|
@@ -7,6 +7,7 @@ const {
|
|
|
7
7
|
listFiles,
|
|
8
8
|
readJsonFile,
|
|
9
9
|
} = require('./fs-utils.cjs');
|
|
10
|
+
const { OPENCODE_PLUGIN_MARKER } = require('./hooks.cjs');
|
|
10
11
|
const { resolveRuntimeLayout } = require('./runtime-layout.cjs');
|
|
11
12
|
|
|
12
13
|
const PACKAGE_ROOT = path.resolve(__dirname, '..');
|
|
@@ -104,6 +105,168 @@ function generatePluginManifest() {
|
|
|
104
105
|
return `${JSON.stringify(manifest, null, 2)}\n`;
|
|
105
106
|
}
|
|
106
107
|
|
|
108
|
+
function generateOpenCodePlugin(layout, hookMode) {
|
|
109
|
+
const wrapperPath = path.join(layout.targetRoot, 'hooks', 'clean-room', 'clean-room-hook.py');
|
|
110
|
+
const mode = hookMode === 'strict' ? 'strict' : 'safe';
|
|
111
|
+
return `import { spawn } from "node:child_process"
|
|
112
|
+
|
|
113
|
+
const CLEAN_ROOM_OPENCODE_PLUGIN_MARKER = ${JSON.stringify(OPENCODE_PLUGIN_MARKER)}
|
|
114
|
+
const CLEAN_ROOM_HOOK_MODE = ${JSON.stringify(mode)}
|
|
115
|
+
const CLEAN_ROOM_HOOK_WRAPPER = ${JSON.stringify(wrapperPath)}
|
|
116
|
+
const CLEAN_ROOM_HOOK_PYTHON = process.env.CLEAN_ROOM_HOOK_PYTHON || "python3"
|
|
117
|
+
const CLEAN_ROOM_HOOK_TIMEOUT_MS = 30_000
|
|
118
|
+
const MAX_HOOK_OUTPUT_CHARS = 256 * 1024
|
|
119
|
+
|
|
120
|
+
const CHECKS = {
|
|
121
|
+
shell: ["require-clean-room-env.py", "deny-clean-room-shell.py"],
|
|
122
|
+
read: ["require-clean-room-env.py", "deny-clean-source-read.py"],
|
|
123
|
+
write: ["require-clean-room-env.py", "deny-contaminated-clean-write.py"],
|
|
124
|
+
postWrite: [
|
|
125
|
+
"require-clean-room-env.py",
|
|
126
|
+
"check-artifact-leakage.py",
|
|
127
|
+
"validate-json-schema.py",
|
|
128
|
+
"validate-handoff-package.py",
|
|
129
|
+
],
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const SHELL_TOOLS = new Set([
|
|
133
|
+
"bash",
|
|
134
|
+
"shell",
|
|
135
|
+
"powershell",
|
|
136
|
+
"terminal",
|
|
137
|
+
"exec_command",
|
|
138
|
+
"shell_command",
|
|
139
|
+
"writestdin",
|
|
140
|
+
"write_stdin",
|
|
141
|
+
])
|
|
142
|
+
|
|
143
|
+
const READ_TOOLS = new Set([
|
|
144
|
+
"read",
|
|
145
|
+
"glob",
|
|
146
|
+
"grep",
|
|
147
|
+
"list",
|
|
148
|
+
"ls",
|
|
149
|
+
"lsp",
|
|
150
|
+
"notebookread",
|
|
151
|
+
"viewimage",
|
|
152
|
+
"view_image",
|
|
153
|
+
])
|
|
154
|
+
|
|
155
|
+
const DIRECTORY_READ_TOOLS = new Set(["glob", "grep", "list", "ls", "lsp"])
|
|
156
|
+
const WRITE_TOOLS = new Set(["write", "edit", "multiedit", "notebookedit", "applypatch", "apply_patch"])
|
|
157
|
+
const PATH_KEYS = ["file_path", "filePath", "path", "notebook_path", "notebookPath"]
|
|
158
|
+
|
|
159
|
+
function normalizeTool(tool) {
|
|
160
|
+
return String(tool || "").toLowerCase().replace(/[^a-z0-9_]/g, "")
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function objectArgs(value) {
|
|
164
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {}
|
|
165
|
+
return { ...value }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function cwdFor(args, directory, worktree) {
|
|
169
|
+
if (typeof args.cwd === "string" && args.cwd) return args.cwd
|
|
170
|
+
if (typeof directory === "string" && directory) return directory
|
|
171
|
+
if (typeof worktree === "string" && worktree) return worktree
|
|
172
|
+
return process.cwd()
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function withDirectoryFallbackPath(tool, args, cwd) {
|
|
176
|
+
if (!DIRECTORY_READ_TOOLS.has(tool)) return args
|
|
177
|
+
if (PATH_KEYS.some((key) => typeof args[key] === "string" && args[key])) return args
|
|
178
|
+
return { ...args, path: cwd }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function hookPayload(input, args, directory, worktree) {
|
|
182
|
+
const tool = input?.tool
|
|
183
|
+
const normalized = normalizeTool(tool)
|
|
184
|
+
const cwd = cwdFor(args, directory, worktree)
|
|
185
|
+
return {
|
|
186
|
+
tool_name: tool,
|
|
187
|
+
tool,
|
|
188
|
+
tool_input: withDirectoryFallbackPath(normalized, args, cwd),
|
|
189
|
+
cwd,
|
|
190
|
+
opencode: {
|
|
191
|
+
sessionID: input?.sessionID,
|
|
192
|
+
callID: input?.callID,
|
|
193
|
+
},
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function hookArgs(checks) {
|
|
198
|
+
const args = [CLEAN_ROOM_HOOK_WRAPPER, "--mode", CLEAN_ROOM_HOOK_MODE]
|
|
199
|
+
for (const check of checks) args.push("--check", check)
|
|
200
|
+
return args
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function appendBounded(current, chunk) {
|
|
204
|
+
if (current.length >= MAX_HOOK_OUTPUT_CHARS) return current
|
|
205
|
+
return (current + String(chunk)).slice(0, MAX_HOOK_OUTPUT_CHARS)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function runHook(label, checks, payload) {
|
|
209
|
+
return new Promise((resolve, reject) => {
|
|
210
|
+
const child = spawn(CLEAN_ROOM_HOOK_PYTHON, hookArgs(checks), {
|
|
211
|
+
env: process.env,
|
|
212
|
+
shell: false,
|
|
213
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
214
|
+
})
|
|
215
|
+
let stdout = ""
|
|
216
|
+
let stderr = ""
|
|
217
|
+
let settled = false
|
|
218
|
+
const timer = setTimeout(() => {
|
|
219
|
+
settled = true
|
|
220
|
+
child.kill("SIGTERM")
|
|
221
|
+
reject(new Error(\`clean-room \${label} hook timed out after \${CLEAN_ROOM_HOOK_TIMEOUT_MS}ms\`))
|
|
222
|
+
}, CLEAN_ROOM_HOOK_TIMEOUT_MS)
|
|
223
|
+
child.stdout.on("data", (chunk) => {
|
|
224
|
+
stdout = appendBounded(stdout, chunk)
|
|
225
|
+
})
|
|
226
|
+
child.stderr.on("data", (chunk) => {
|
|
227
|
+
stderr = appendBounded(stderr, chunk)
|
|
228
|
+
})
|
|
229
|
+
child.on("error", (error) => {
|
|
230
|
+
if (settled) return
|
|
231
|
+
settled = true
|
|
232
|
+
clearTimeout(timer)
|
|
233
|
+
reject(error)
|
|
234
|
+
})
|
|
235
|
+
child.on("close", (status, signal) => {
|
|
236
|
+
if (settled) return
|
|
237
|
+
settled = true
|
|
238
|
+
clearTimeout(timer)
|
|
239
|
+
if (status === 0) {
|
|
240
|
+
resolve()
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
const detail = (stderr || stdout || \`status \${status}\${signal ? \`, signal \${signal}\` : ""}\`).trim()
|
|
244
|
+
reject(new Error(\`clean-room \${label} hook denied tool use: \${detail}\`))
|
|
245
|
+
})
|
|
246
|
+
child.stdin.end(JSON.stringify(payload))
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export const CleanRoomPlugin = async ({ directory, worktree }) => {
|
|
251
|
+
return {
|
|
252
|
+
"tool.execute.before": async (input, output) => {
|
|
253
|
+
const tool = normalizeTool(input?.tool)
|
|
254
|
+
const payload = hookPayload(input, objectArgs(output?.args), directory, worktree)
|
|
255
|
+
if (SHELL_TOOLS.has(tool)) await runHook("shell", CHECKS.shell, payload)
|
|
256
|
+
if (READ_TOOLS.has(tool)) await runHook("read", CHECKS.read, payload)
|
|
257
|
+
if (WRITE_TOOLS.has(tool)) await runHook("write", CHECKS.write, payload)
|
|
258
|
+
},
|
|
259
|
+
"tool.execute.after": async (input) => {
|
|
260
|
+
const tool = normalizeTool(input?.tool)
|
|
261
|
+
if (!WRITE_TOOLS.has(tool)) return
|
|
262
|
+
const payload = hookPayload(input, objectArgs(input?.args), directory, worktree)
|
|
263
|
+
await runHook("post-write", CHECKS.postWrite, payload)
|
|
264
|
+
},
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
`;
|
|
268
|
+
}
|
|
269
|
+
|
|
107
270
|
function addCommandWrappers(desired, artifact) {
|
|
108
271
|
const skillsRoot = path.join(PACKAGE_ROOT, artifact.source);
|
|
109
272
|
const entries = fs.readdirSync(skillsRoot, { withFileTypes: true });
|
|
@@ -117,7 +280,15 @@ function addCommandWrappers(desired, artifact) {
|
|
|
117
280
|
}
|
|
118
281
|
}
|
|
119
282
|
|
|
120
|
-
function
|
|
283
|
+
function shouldInstallArtifact(artifact, hookMode) {
|
|
284
|
+
if (!Array.isArray(artifact.hookModes)) return true;
|
|
285
|
+
return artifact.hookModes.includes(hookMode);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function addArtifact(desired, artifact, layout, hookMode) {
|
|
289
|
+
if (!shouldInstallArtifact(artifact, hookMode)) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
121
292
|
if (artifact.kind === 'skills' || artifact.kind === 'agents') {
|
|
122
293
|
addTree(desired, artifact.source, artifact.destSubpath);
|
|
123
294
|
return;
|
|
@@ -136,6 +307,10 @@ function addArtifact(desired, artifact) {
|
|
|
136
307
|
desired.set(artifact.destSubpath, Buffer.from(generatePluginManifest(), 'utf8'));
|
|
137
308
|
return;
|
|
138
309
|
}
|
|
310
|
+
if (artifact.kind === 'opencode-plugin') {
|
|
311
|
+
desired.set(artifact.destSubpath, Buffer.from(generateOpenCodePlugin(layout, hookMode), 'utf8'));
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
139
314
|
throw new Error(`unsupported artifact kind: ${artifact.kind}`);
|
|
140
315
|
}
|
|
141
316
|
|
|
@@ -147,12 +322,11 @@ function layoutFromInput(runtimeOrLayout, scope, configDir) {
|
|
|
147
322
|
}
|
|
148
323
|
|
|
149
324
|
function buildDesiredFiles(runtimeOrLayout, hookMode, scope = 'global', configDir = null) {
|
|
150
|
-
|
|
151
|
-
void hookMode;
|
|
325
|
+
hookMode = hookMode || 'safe';
|
|
152
326
|
const layout = layoutFromInput(runtimeOrLayout, scope, configDir);
|
|
153
327
|
const desired = new Map();
|
|
154
328
|
for (const artifact of layout.artifacts) {
|
|
155
|
-
addArtifact(desired, artifact);
|
|
329
|
+
addArtifact(desired, artifact, layout, hookMode);
|
|
156
330
|
}
|
|
157
331
|
return desired;
|
|
158
332
|
}
|