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.
- package/dist/content/opencode-plugin.js +314 -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,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
|
-
|
|
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
|
-
},
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
440
|
-
|
|
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
|
|
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
|
},
|