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.
@@ -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
- console.error("[cclaw] opencode hook timeout: " + hookName + " stderr=" + stderr.slice(-1200));
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
- }, 20_000);
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
- console.error("[cclaw] opencode hook failed: " + hookName + " stderr=" + stderr.slice(-1200));
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
- console.error("[cclaw] opencode unknown event payload keys: " + keys);
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
- const promptOk = await runHookScript("prompt-guard", payload);
440
- const workflowOk = await runHookScript("workflow-guard", payload);
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 OpenCode guard blocked tool.execute.before (prompt/workflow guard non-zero exit)."
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
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cclaw-cli",
3
- "version": "0.48.33",
3
+ "version": "0.48.34",
4
4
  "description": "Installer-first flow toolkit for coding agents",
5
5
  "type": "module",
6
6
  "bin": {