cclaw-cli 0.48.33 → 0.48.34
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/dist/content/opencode-plugin.js +190 -8
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@ import { RUNTIME_ROOT } from "../constants.js";
|
|
|
2
2
|
import { META_SKILL_NAME } from "./meta-skill.js";
|
|
3
3
|
export function opencodePluginJs(_options = {}) {
|
|
4
4
|
return `// cclaw OpenCode plugin — generated by cclaw sync
|
|
5
|
-
import { existsSync, mkdirSync } from "node:fs";
|
|
5
|
+
import { appendFileSync, existsSync, mkdirSync } from "node:fs";
|
|
6
6
|
import { readFile, stat } from "node:fs/promises";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
|
|
@@ -10,6 +10,8 @@ export default function cclawPlugin(ctx) {
|
|
|
10
10
|
const root = ctx.directory || process.cwd();
|
|
11
11
|
const runtimeDir = join(root, "${RUNTIME_ROOT}");
|
|
12
12
|
const stateDir = join(runtimeDir, "state");
|
|
13
|
+
const logsDir = join(runtimeDir, "logs");
|
|
14
|
+
const pluginLogPath = join(logsDir, "opencode-plugin.log");
|
|
13
15
|
const flowStatePath = join(stateDir, "flow-state.json");
|
|
14
16
|
const checkpointPath = join(stateDir, "checkpoint.json");
|
|
15
17
|
const activityPath = join(stateDir, "stage-activity.jsonl");
|
|
@@ -34,6 +36,27 @@ export default function cclawPlugin(ctx) {
|
|
|
34
36
|
}
|
|
35
37
|
}
|
|
36
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Diagnostic log used instead of console.error on the hot path.
|
|
41
|
+
* Writing to a file keeps hook failures and unknown events from
|
|
42
|
+
* racing OpenCode's TUI renderer (which causes the overlapping-text
|
|
43
|
+
* artifact users have reported). Best-effort: any I/O failure is
|
|
44
|
+
* swallowed so logging never itself blocks or throws.
|
|
45
|
+
*/
|
|
46
|
+
function logToFile(line) {
|
|
47
|
+
try {
|
|
48
|
+
mkdirSync(logsDir, { recursive: true });
|
|
49
|
+
} catch {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const timestamp = new Date().toISOString();
|
|
53
|
+
try {
|
|
54
|
+
appendFileSync(pluginLogPath, timestamp + " " + line + "\\n", "utf8");
|
|
55
|
+
} catch {
|
|
56
|
+
// ignore — never let logging fail the hook
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
37
60
|
async function readFlowState() {
|
|
38
61
|
try {
|
|
39
62
|
const raw = await readFile(flowStatePath, "utf8");
|
|
@@ -272,6 +295,17 @@ export default function cclawPlugin(ctx) {
|
|
|
272
295
|
});
|
|
273
296
|
}
|
|
274
297
|
|
|
298
|
+
const lastHookStderr = new Map();
|
|
299
|
+
function recordHookStderr(hookName, stderr) {
|
|
300
|
+
if (typeof hookName !== "string" || hookName.length === 0) return;
|
|
301
|
+
const trimmed = typeof stderr === "string" ? stderr.trim() : "";
|
|
302
|
+
if (trimmed.length === 0) {
|
|
303
|
+
lastHookStderr.delete(hookName);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
lastHookStderr.set(hookName, trimmed);
|
|
307
|
+
}
|
|
308
|
+
|
|
275
309
|
async function runHookScript(hookName, payload = {}) {
|
|
276
310
|
const { spawn } = await import("node:child_process");
|
|
277
311
|
const hookRuntimePath = join(root, "${RUNTIME_ROOT}/hooks/run-hook.mjs");
|
|
@@ -282,6 +316,7 @@ export default function cclawPlugin(ctx) {
|
|
|
282
316
|
const finish = (ok) => {
|
|
283
317
|
if (settled) return;
|
|
284
318
|
settled = true;
|
|
319
|
+
recordHookStderr(hookName, stderr);
|
|
285
320
|
resolve(ok);
|
|
286
321
|
};
|
|
287
322
|
|
|
@@ -296,13 +331,19 @@ export default function cclawPlugin(ctx) {
|
|
|
296
331
|
return;
|
|
297
332
|
}
|
|
298
333
|
|
|
334
|
+
// Tool.execute.before is a user-facing hot path: 20s is far too
|
|
335
|
+
// long to wait on a guard. 5s gives the hook real breathing room
|
|
336
|
+
// (typical runtime is well under 500ms) while capping the worst-
|
|
337
|
+
// case stall at a number the user will still tolerate.
|
|
299
338
|
const timer = setTimeout(() => {
|
|
300
339
|
child.kill("SIGKILL");
|
|
301
340
|
if (stderr.length > 0) {
|
|
302
|
-
|
|
341
|
+
logToFile("hook timeout: " + hookName + " stderr=" + stderr.slice(-1200));
|
|
342
|
+
} else {
|
|
343
|
+
logToFile("hook timeout: " + hookName + " (no stderr)");
|
|
303
344
|
}
|
|
304
345
|
finish(false);
|
|
305
|
-
},
|
|
346
|
+
}, 5_000);
|
|
306
347
|
|
|
307
348
|
child.stderr?.on("data", (chunk) => {
|
|
308
349
|
stderr += String(chunk ?? "");
|
|
@@ -318,7 +359,7 @@ export default function cclawPlugin(ctx) {
|
|
|
318
359
|
clearTimeout(timer);
|
|
319
360
|
const ok = code === 0;
|
|
320
361
|
if (!ok && stderr.length > 0) {
|
|
321
|
-
|
|
362
|
+
logToFile("hook failed: " + hookName + " exit=" + code + " stderr=" + stderr.slice(-1200));
|
|
322
363
|
}
|
|
323
364
|
finish(ok);
|
|
324
365
|
});
|
|
@@ -355,6 +396,118 @@ export default function cclawPlugin(ctx) {
|
|
|
355
396
|
return { input: input ?? {}, output: output ?? {} };
|
|
356
397
|
}
|
|
357
398
|
|
|
399
|
+
/**
|
|
400
|
+
* Read-only tools cannot mutate state or execute arbitrary code, so
|
|
401
|
+
* running prompt/workflow guards on them is pure overhead and — worse —
|
|
402
|
+
* surfaces a hard block when guards are misconfigured. OpenCode tool
|
|
403
|
+
* names vary (Claude/Codex use PascalCase; opencode-native often uses
|
|
404
|
+
* lowercase), so we normalize and match against a tight allow-list.
|
|
405
|
+
* Anything not in this list (bash, edit, write, patch, task, run, …)
|
|
406
|
+
* still runs through guards.
|
|
407
|
+
*/
|
|
408
|
+
const SAFE_READONLY_TOOLS = new Set([
|
|
409
|
+
"read",
|
|
410
|
+
"glob",
|
|
411
|
+
"grep",
|
|
412
|
+
"list",
|
|
413
|
+
"ls",
|
|
414
|
+
"view",
|
|
415
|
+
"webfetch",
|
|
416
|
+
"websearch"
|
|
417
|
+
]);
|
|
418
|
+
|
|
419
|
+
function isSafeReadOnlyTool(payload) {
|
|
420
|
+
if (!payload || typeof payload !== "object") return false;
|
|
421
|
+
const candidates = [
|
|
422
|
+
payload.tool,
|
|
423
|
+
payload.name,
|
|
424
|
+
payload.tool_name,
|
|
425
|
+
payload.toolName
|
|
426
|
+
];
|
|
427
|
+
const inner = payload.input;
|
|
428
|
+
if (inner && typeof inner === "object") {
|
|
429
|
+
candidates.push(inner.tool, inner.name, inner.tool_name, inner.toolName);
|
|
430
|
+
}
|
|
431
|
+
for (const candidate of candidates) {
|
|
432
|
+
if (typeof candidate !== "string" || candidate.length === 0) continue;
|
|
433
|
+
if (SAFE_READONLY_TOOLS.has(candidate.toLowerCase())) return true;
|
|
434
|
+
}
|
|
435
|
+
return false;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* cclaw considers itself "active" in a project when both the state
|
|
440
|
+
* file and the hook runtime script exist. If either is missing the
|
|
441
|
+
* plugin behaves as a no-op for guards — this project hasn't been
|
|
442
|
+
* initialized (or the install is corrupt) and blocking every tool
|
|
443
|
+
* call would strand the user.
|
|
444
|
+
*/
|
|
445
|
+
function isCclawInitialized() {
|
|
446
|
+
try {
|
|
447
|
+
const hookRuntimePath = join(runtimeDir, "hooks/run-hook.mjs");
|
|
448
|
+
return existsSync(flowStatePath) && existsSync(hookRuntimePath);
|
|
449
|
+
} catch {
|
|
450
|
+
return false;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
let notInitializedAdvised = false;
|
|
455
|
+
function noteNotInitialized() {
|
|
456
|
+
if (notInitializedAdvised) return;
|
|
457
|
+
notInitializedAdvised = true;
|
|
458
|
+
logToFile(
|
|
459
|
+
"guards skipped: cclaw is not initialized in this project. " +
|
|
460
|
+
"Run \`cclaw init\` in " + root + " to activate flow enforcement."
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Escape hatch for a user stuck behind a misbehaving guard chain.
|
|
466
|
+
* Reads a small set of env vars that all mean "turn cclaw off for this
|
|
467
|
+
* session": CCLAW_DISABLE=1 (primary), CCLAW_STRICTNESS=off, or
|
|
468
|
+
* CCLAW_GUARDS=off. Anything truthy disables both guards and the
|
|
469
|
+
* advisory path. Logged once so users can confirm the bypass is in
|
|
470
|
+
* effect without cluttering the TUI.
|
|
471
|
+
*/
|
|
472
|
+
const DISABLE_ENV_KEYS = ["CCLAW_DISABLE", "CCLAW_GUARDS", "CCLAW_STRICTNESS"];
|
|
473
|
+
let disabledAdvised = false;
|
|
474
|
+
function isCclawDisabled() {
|
|
475
|
+
for (const key of DISABLE_ENV_KEYS) {
|
|
476
|
+
const raw = process.env[key];
|
|
477
|
+
if (typeof raw !== "string") continue;
|
|
478
|
+
const value = raw.trim().toLowerCase();
|
|
479
|
+
if (value.length === 0) continue;
|
|
480
|
+
if (key === "CCLAW_STRICTNESS") {
|
|
481
|
+
if (value === "off" || value === "disabled" || value === "none") {
|
|
482
|
+
return { disabled: true, key, value };
|
|
483
|
+
}
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
if (
|
|
487
|
+
value === "1" ||
|
|
488
|
+
value === "true" ||
|
|
489
|
+
value === "yes" ||
|
|
490
|
+
value === "on" ||
|
|
491
|
+
value === "off" ||
|
|
492
|
+
value === "disabled"
|
|
493
|
+
) {
|
|
494
|
+
if (key === "CCLAW_GUARDS" && (value === "on" || value === "true" || value === "yes" || value === "1")) {
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
return { disabled: true, key, value };
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return { disabled: false, key: "", value: "" };
|
|
501
|
+
}
|
|
502
|
+
function noteDisabled(reason) {
|
|
503
|
+
if (disabledAdvised) return;
|
|
504
|
+
disabledAdvised = true;
|
|
505
|
+
logToFile(
|
|
506
|
+
"guards disabled by env " + reason.key + "=" + reason.value + ". " +
|
|
507
|
+
"All tool calls will pass through without prompt/workflow checks."
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
|
|
358
511
|
function resolveEventType(payload) {
|
|
359
512
|
if (typeof payload === "string") return payload;
|
|
360
513
|
if (payload && typeof payload === "object") {
|
|
@@ -407,7 +560,7 @@ export default function cclawPlugin(ctx) {
|
|
|
407
560
|
payload && typeof payload === "object"
|
|
408
561
|
? Object.keys(payload).slice(0, 10).join(", ")
|
|
409
562
|
: typeof payload;
|
|
410
|
-
|
|
563
|
+
logToFile("unknown event payload keys: " + keys);
|
|
411
564
|
}
|
|
412
565
|
// session.compacted must run pre-compact BEFORE refreshing the bootstrap
|
|
413
566
|
// cache, otherwise the injected system prompt still shows the pre-compact
|
|
@@ -435,12 +588,41 @@ export default function cclawPlugin(ctx) {
|
|
|
435
588
|
}
|
|
436
589
|
},
|
|
437
590
|
"tool.execute.before": async (input, output) => {
|
|
591
|
+
const disabled = isCclawDisabled();
|
|
592
|
+
if (disabled.disabled) {
|
|
593
|
+
// Explicit user override (CCLAW_DISABLE=1 et al): stay fully out
|
|
594
|
+
// of the way. Any real problem with the guard chain should not
|
|
595
|
+
// prevent the user from unblocking themselves.
|
|
596
|
+
noteDisabled(disabled);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
438
599
|
const payload = normalizeToolPayload(input, output);
|
|
439
|
-
|
|
440
|
-
|
|
600
|
+
if (isSafeReadOnlyTool(payload)) {
|
|
601
|
+
// Read-only tools bypass guards — they cannot mutate state and
|
|
602
|
+
// blocking them gives users an unusable session when guards are
|
|
603
|
+
// misconfigured or cclaw isn't fully initialized.
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
if (!isCclawInitialized()) {
|
|
607
|
+
// Project has no flow-state or hook runtime: cclaw isn't in use
|
|
608
|
+
// here. Never block the user's tools because of setup they didn't
|
|
609
|
+
// ask for. Surface a single advisory so they can notice.
|
|
610
|
+
noteNotInitialized();
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
const [promptOk, workflowOk] = await Promise.all([
|
|
614
|
+
runHookScript("prompt-guard", payload),
|
|
615
|
+
runHookScript("workflow-guard", payload)
|
|
616
|
+
]);
|
|
441
617
|
if (!promptOk || !workflowOk) {
|
|
618
|
+
const failed = !promptOk ? "prompt-guard" : "workflow-guard";
|
|
619
|
+
const rawDetail = lastHookStderr.get(failed) || "";
|
|
620
|
+
const detail = rawDetail.length > 0 ? rawDetail.slice(-400) : "(no stderr captured)";
|
|
442
621
|
throw new Error(
|
|
443
|
-
"cclaw
|
|
622
|
+
"cclaw " + failed + " blocked tool.execute.before.\\n" +
|
|
623
|
+
"Reason: " + detail + "\\n" +
|
|
624
|
+
"Diagnose: run \`cclaw doctor\` in project root.\\n" +
|
|
625
|
+
"Bypass (temporary): export CCLAW_DISABLE=1 before starting OpenCode."
|
|
444
626
|
);
|
|
445
627
|
}
|
|
446
628
|
},
|