cclaw-cli 0.48.33 → 0.48.35

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,9 @@ 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");
15
+ const configPath = join(runtimeDir, "config.yaml");
13
16
  const flowStatePath = join(stateDir, "flow-state.json");
14
17
  const checkpointPath = join(stateDir, "checkpoint.json");
15
18
  const activityPath = join(stateDir, "stage-activity.jsonl");
@@ -34,6 +37,27 @@ export default function cclawPlugin(ctx) {
34
37
  }
35
38
  }
36
39
 
40
+ /**
41
+ * Diagnostic log used instead of console.error on the hot path.
42
+ * Writing to a file keeps hook failures and unknown events from
43
+ * racing OpenCode's TUI renderer (which causes the overlapping-text
44
+ * artifact users have reported). Best-effort: any I/O failure is
45
+ * swallowed so logging never itself blocks or throws.
46
+ */
47
+ function logToFile(line) {
48
+ try {
49
+ mkdirSync(logsDir, { recursive: true });
50
+ } catch {
51
+ return;
52
+ }
53
+ const timestamp = new Date().toISOString();
54
+ try {
55
+ appendFileSync(pluginLogPath, timestamp + " " + line + "\\n", "utf8");
56
+ } catch {
57
+ // ignore — never let logging fail the hook
58
+ }
59
+ }
60
+
37
61
  async function readFlowState() {
38
62
  try {
39
63
  const raw = await readFile(flowStatePath, "utf8");
@@ -272,6 +296,52 @@ export default function cclawPlugin(ctx) {
272
296
  });
273
297
  }
274
298
 
299
+ const lastHookStderr = new Map();
300
+ function recordHookStderr(hookName, stderr) {
301
+ if (typeof hookName !== "string" || hookName.length === 0) return;
302
+ const trimmed = typeof stderr === "string" ? stderr.trim() : "";
303
+ if (trimmed.length === 0) {
304
+ lastHookStderr.delete(hookName);
305
+ return;
306
+ }
307
+ lastHookStderr.set(hookName, trimmed);
308
+ }
309
+
310
+ /**
311
+ * A hook process can exit non-zero for two very different reasons:
312
+ * (a) the guard legitimately decided to refuse an operation
313
+ * (strict-mode refusal — this is the *only* case we ever want
314
+ * to surface as a block to the user), or
315
+ * (b) the hook infrastructure itself failed — the runtime crashed,
316
+ * a child binary was missing, stderr is a chunk of yargs help
317
+ * from some unrelated process, timeout, etc.
318
+ *
319
+ * Treating (b) as a block is what produces the "guard blocked
320
+ * tool.execute.before" error on a user who did nothing wrong. The
321
+ * heuristic below trims guaranteed-infra signals so only cleanly
322
+ * structured guard output is eligible for a real strict-mode block.
323
+ */
324
+ const INFRA_NOISE_PATTERNS = [
325
+ /^\\s*(Usage|Options|Commands|Examples|Positionals|Aliases):/im,
326
+ /^\\s*--[a-z][a-z0-9-]*\\b.*\\[(string|boolean|number|array)\\]/im,
327
+ /\\bcommand (not found|failed)\\b/i,
328
+ /\\bno such file or directory\\b/i,
329
+ /\\bCannot find module\\b/i,
330
+ /\\bThrowsCompletion\\b/,
331
+ /\\b(Reference|Syntax|Type|Range)Error\\b/,
332
+ /^\\s*at [^\\n]+\\([^)]*:\\d+:\\d+\\)/im,
333
+ /^\\s*node:internal\\b/im
334
+ ];
335
+ function looksLikeInfrastructureFailure(stderr) {
336
+ if (typeof stderr !== "string") return true;
337
+ const trimmed = stderr.trim();
338
+ if (trimmed.length === 0) return true;
339
+ for (const pattern of INFRA_NOISE_PATTERNS) {
340
+ if (pattern.test(trimmed)) return true;
341
+ }
342
+ return false;
343
+ }
344
+
275
345
  async function runHookScript(hookName, payload = {}) {
276
346
  const { spawn } = await import("node:child_process");
277
347
  const hookRuntimePath = join(root, "${RUNTIME_ROOT}/hooks/run-hook.mjs");
@@ -282,6 +352,7 @@ export default function cclawPlugin(ctx) {
282
352
  const finish = (ok) => {
283
353
  if (settled) return;
284
354
  settled = true;
355
+ recordHookStderr(hookName, stderr);
285
356
  resolve(ok);
286
357
  };
287
358
 
@@ -296,13 +367,19 @@ export default function cclawPlugin(ctx) {
296
367
  return;
297
368
  }
298
369
 
370
+ // Tool.execute.before is a user-facing hot path: 20s is far too
371
+ // long to wait on a guard. 5s gives the hook real breathing room
372
+ // (typical runtime is well under 500ms) while capping the worst-
373
+ // case stall at a number the user will still tolerate.
299
374
  const timer = setTimeout(() => {
300
375
  child.kill("SIGKILL");
301
376
  if (stderr.length > 0) {
302
- console.error("[cclaw] opencode hook timeout: " + hookName + " stderr=" + stderr.slice(-1200));
377
+ logToFile("hook timeout: " + hookName + " stderr=" + stderr.slice(-1200));
378
+ } else {
379
+ logToFile("hook timeout: " + hookName + " (no stderr)");
303
380
  }
304
381
  finish(false);
305
- }, 20_000);
382
+ }, 5_000);
306
383
 
307
384
  child.stderr?.on("data", (chunk) => {
308
385
  stderr += String(chunk ?? "");
@@ -318,7 +395,7 @@ export default function cclawPlugin(ctx) {
318
395
  clearTimeout(timer);
319
396
  const ok = code === 0;
320
397
  if (!ok && stderr.length > 0) {
321
- console.error("[cclaw] opencode hook failed: " + hookName + " stderr=" + stderr.slice(-1200));
398
+ logToFile("hook failed: " + hookName + " exit=" + code + " stderr=" + stderr.slice(-1200));
322
399
  }
323
400
  finish(ok);
324
401
  });
@@ -355,6 +432,182 @@ export default function cclawPlugin(ctx) {
355
432
  return { input: input ?? {}, output: output ?? {} };
356
433
  }
357
434
 
435
+ /**
436
+ * Read-only tools cannot mutate state or execute arbitrary code, so
437
+ * running prompt/workflow guards on them is pure overhead and — worse —
438
+ * surfaces a hard block when guards are misconfigured. OpenCode tool
439
+ * names vary (Claude/Codex use PascalCase; opencode-native often uses
440
+ * lowercase), so we normalize and match against a tight allow-list.
441
+ * Anything not in this list (bash, edit, write, patch, task, run, …)
442
+ * still runs through guards.
443
+ */
444
+ const SAFE_READONLY_TOOLS = new Set([
445
+ // Filesystem / search reads — no state mutation possible.
446
+ "read",
447
+ "glob",
448
+ "grep",
449
+ "list",
450
+ "ls",
451
+ "view",
452
+ "find",
453
+ // Network reads — no local state mutation.
454
+ "webfetch",
455
+ "websearch",
456
+ // User-facing question / ask tools: they only ask the human for
457
+ // input and cannot touch the filesystem or execute code. Blocking
458
+ // them strands the plugin mid-decision (see OpenCode's \`question\`).
459
+ "question",
460
+ "ask",
461
+ "askuser",
462
+ "askquestion",
463
+ "ask_question",
464
+ "ask_user",
465
+ "ask_user_question",
466
+ "askuserquestion",
467
+ "request_user_input",
468
+ "requestuserinput",
469
+ "prompt",
470
+ // Thinking / scratchpad tools — pure reasoning with no side effects.
471
+ "think",
472
+ "thinking",
473
+ // Todo bookkeeping tools — they write only inside the harness's own
474
+ // session state, not project files, and blocking them breaks agent
475
+ // planning without protecting anything.
476
+ "todo",
477
+ "todoread",
478
+ "todowrite",
479
+ "todo_read",
480
+ "todo_write"
481
+ ]);
482
+
483
+ function isSafeReadOnlyTool(payload) {
484
+ if (!payload || typeof payload !== "object") return false;
485
+ const candidates = [
486
+ payload.tool,
487
+ payload.name,
488
+ payload.tool_name,
489
+ payload.toolName
490
+ ];
491
+ const inner = payload.input;
492
+ if (inner && typeof inner === "object") {
493
+ candidates.push(inner.tool, inner.name, inner.tool_name, inner.toolName);
494
+ }
495
+ for (const candidate of candidates) {
496
+ if (typeof candidate !== "string" || candidate.length === 0) continue;
497
+ if (SAFE_READONLY_TOOLS.has(candidate.toLowerCase())) return true;
498
+ }
499
+ return false;
500
+ }
501
+
502
+ /**
503
+ * Strictness derived from (in order of precedence): CCLAW_STRICTNESS
504
+ * env override, \`.cclaw/config.yaml\` key \`strictness\`, or the
505
+ * library default of "advisory". The plugin only ever *blocks* tool
506
+ * execution when strictness resolves to "strict"; in advisory mode
507
+ * guard failures are logged and the tool call proceeds. This mirrors
508
+ * the Ralph-loop / hook-runtime semantics of
509
+ * \`DEFAULT_STRICTNESS = advisory\`, so the plugin can no longer
510
+ * accidentally be the stricter half of a mismatched pair.
511
+ */
512
+ function readConfigStrictness() {
513
+ try {
514
+ if (!existsSync(configPath)) return "";
515
+ const { readFileSync } = require("node:fs");
516
+ const raw = readFileSync(configPath, "utf8");
517
+ if (typeof raw !== "string" || raw.length === 0) return "";
518
+ const match = raw.match(/^\\s*strictness\\s*:\\s*([A-Za-z0-9_-]+)/m);
519
+ return match && typeof match[1] === "string" ? match[1].trim().toLowerCase() : "";
520
+ } catch {
521
+ return "";
522
+ }
523
+ }
524
+
525
+ function resolveStrictness() {
526
+ const envRaw = typeof process.env.CCLAW_STRICTNESS === "string"
527
+ ? process.env.CCLAW_STRICTNESS.trim().toLowerCase()
528
+ : "";
529
+ if (envRaw === "strict") return "strict";
530
+ if (envRaw === "advisory" || envRaw === "off" || envRaw === "disabled" || envRaw === "none") {
531
+ return "advisory";
532
+ }
533
+ const fileRaw = readConfigStrictness();
534
+ if (fileRaw === "strict") return "strict";
535
+ return "advisory";
536
+ }
537
+
538
+ /**
539
+ * cclaw considers itself "active" in a project when both the state
540
+ * file and the hook runtime script exist. If either is missing the
541
+ * plugin behaves as a no-op for guards — this project hasn't been
542
+ * initialized (or the install is corrupt) and blocking every tool
543
+ * call would strand the user.
544
+ */
545
+ function isCclawInitialized() {
546
+ try {
547
+ const hookRuntimePath = join(runtimeDir, "hooks/run-hook.mjs");
548
+ return existsSync(flowStatePath) && existsSync(hookRuntimePath);
549
+ } catch {
550
+ return false;
551
+ }
552
+ }
553
+
554
+ let notInitializedAdvised = false;
555
+ function noteNotInitialized() {
556
+ if (notInitializedAdvised) return;
557
+ notInitializedAdvised = true;
558
+ logToFile(
559
+ "guards skipped: cclaw is not initialized in this project. " +
560
+ "Run \`cclaw init\` in " + root + " to activate flow enforcement."
561
+ );
562
+ }
563
+
564
+ /**
565
+ * Escape hatch for a user stuck behind a misbehaving guard chain.
566
+ * Reads a small set of env vars that all mean "turn cclaw off for this
567
+ * session": CCLAW_DISABLE=1 (primary), CCLAW_STRICTNESS=off, or
568
+ * CCLAW_GUARDS=off. Anything truthy disables both guards and the
569
+ * advisory path. Logged once so users can confirm the bypass is in
570
+ * effect without cluttering the TUI.
571
+ */
572
+ const DISABLE_ENV_KEYS = ["CCLAW_DISABLE", "CCLAW_GUARDS", "CCLAW_STRICTNESS"];
573
+ let disabledAdvised = false;
574
+ function isCclawDisabled() {
575
+ for (const key of DISABLE_ENV_KEYS) {
576
+ const raw = process.env[key];
577
+ if (typeof raw !== "string") continue;
578
+ const value = raw.trim().toLowerCase();
579
+ if (value.length === 0) continue;
580
+ if (key === "CCLAW_STRICTNESS") {
581
+ if (value === "off" || value === "disabled" || value === "none") {
582
+ return { disabled: true, key, value };
583
+ }
584
+ continue;
585
+ }
586
+ if (
587
+ value === "1" ||
588
+ value === "true" ||
589
+ value === "yes" ||
590
+ value === "on" ||
591
+ value === "off" ||
592
+ value === "disabled"
593
+ ) {
594
+ if (key === "CCLAW_GUARDS" && (value === "on" || value === "true" || value === "yes" || value === "1")) {
595
+ continue;
596
+ }
597
+ return { disabled: true, key, value };
598
+ }
599
+ }
600
+ return { disabled: false, key: "", value: "" };
601
+ }
602
+ function noteDisabled(reason) {
603
+ if (disabledAdvised) return;
604
+ disabledAdvised = true;
605
+ logToFile(
606
+ "guards disabled by env " + reason.key + "=" + reason.value + ". " +
607
+ "All tool calls will pass through without prompt/workflow checks."
608
+ );
609
+ }
610
+
358
611
  function resolveEventType(payload) {
359
612
  if (typeof payload === "string") return payload;
360
613
  if (payload && typeof payload === "object") {
@@ -407,7 +660,7 @@ export default function cclawPlugin(ctx) {
407
660
  payload && typeof payload === "object"
408
661
  ? Object.keys(payload).slice(0, 10).join(", ")
409
662
  : typeof payload;
410
- console.error("[cclaw] opencode unknown event payload keys: " + keys);
663
+ logToFile("unknown event payload keys: " + keys);
411
664
  }
412
665
  // session.compacted must run pre-compact BEFORE refreshing the bootstrap
413
666
  // cache, otherwise the injected system prompt still shows the pre-compact
@@ -435,12 +688,65 @@ export default function cclawPlugin(ctx) {
435
688
  }
436
689
  },
437
690
  "tool.execute.before": async (input, output) => {
691
+ const disabled = isCclawDisabled();
692
+ if (disabled.disabled) {
693
+ // Explicit user override (CCLAW_DISABLE=1 et al): stay fully out
694
+ // of the way. Any real problem with the guard chain should not
695
+ // prevent the user from unblocking themselves.
696
+ noteDisabled(disabled);
697
+ return;
698
+ }
438
699
  const payload = normalizeToolPayload(input, output);
439
- const promptOk = await runHookScript("prompt-guard", payload);
440
- const workflowOk = await runHookScript("workflow-guard", payload);
700
+ if (isSafeReadOnlyTool(payload)) {
701
+ // Read-only tools bypass guards — they cannot mutate state and
702
+ // blocking them gives users an unusable session when guards are
703
+ // misconfigured or cclaw isn't fully initialized.
704
+ return;
705
+ }
706
+ if (!isCclawInitialized()) {
707
+ // Project has no flow-state or hook runtime: cclaw isn't in use
708
+ // here. Never block the user's tools because of setup they didn't
709
+ // ask for. Surface a single advisory so they can notice.
710
+ noteNotInitialized();
711
+ return;
712
+ }
713
+ const [promptOk, workflowOk] = await Promise.all([
714
+ runHookScript("prompt-guard", payload),
715
+ runHookScript("workflow-guard", payload)
716
+ ]);
441
717
  if (!promptOk || !workflowOk) {
718
+ const failed = !promptOk ? "prompt-guard" : "workflow-guard";
719
+ const rawDetail = lastHookStderr.get(failed) || "";
720
+ const detail = rawDetail.length > 0 ? rawDetail.slice(-400) : "(no stderr captured)";
721
+ if (looksLikeInfrastructureFailure(rawDetail)) {
722
+ // Never let a broken hook runtime or misrouted child-process
723
+ // stderr (yargs help, Node crash, ENOENT, timeout) masquerade
724
+ // as a policy block. Log the infra hit and let the user keep
725
+ // working regardless of strictness.
726
+ logToFile(
727
+ "infra: " + failed + " non-zero exit with non-guard stderr — treated as infrastructure failure, tool allowed. " +
728
+ "stderr=" + detail.replace(/\\s+/g, " ").slice(0, 300)
729
+ );
730
+ return;
731
+ }
732
+ const strictness = resolveStrictness();
733
+ if (strictness !== "strict") {
734
+ // Advisory mode (the default) — every guard refusal is a hint,
735
+ // not a hard stop. Users report the "failure" as a log line
736
+ // and keep working. Only \`strictness: strict\` in config.yaml
737
+ // or CCLAW_STRICTNESS=strict upgrades this to a thrown block.
738
+ logToFile(
739
+ "advisory: " + failed + " flagged tool.execute.before (strictness=" +
740
+ strictness + "). detail=" + detail.replace(/\\s+/g, " ").slice(0, 300)
741
+ );
742
+ return;
743
+ }
442
744
  throw new Error(
443
- "cclaw OpenCode guard blocked tool.execute.before (prompt/workflow guard non-zero exit)."
745
+ "cclaw " + failed + " blocked tool.execute.before.\\n" +
746
+ "Reason: " + detail + "\\n" +
747
+ "Diagnose: run \`cclaw doctor\` in project root.\\n" +
748
+ "Bypass (temporary): export CCLAW_DISABLE=1 before starting OpenCode,\\n" +
749
+ "or set \`strictness: advisory\` in .cclaw/config.yaml."
444
750
  );
445
751
  }
446
752
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cclaw-cli",
3
- "version": "0.48.33",
3
+ "version": "0.48.35",
4
4
  "description": "Installer-first flow toolkit for coding agents",
5
5
  "type": "module",
6
6
  "bin": {