diff --git a/claude/README.md b/claude/README.md index 286c1aa..e299d1f 100644 --- a/claude/README.md +++ b/claude/README.md @@ -135,6 +135,41 @@ or the CLI call fails, the hook exits silently without affecting the session. | **PostCompact** | Records the compacted summary as a structured event and buffers a short post-compaction note for later recall. | | **SessionEnd** | Reuses the session-final memory capture path so Claude can flush the final checkpoint even when `Stop` is not the last lifecycle event observed. | +### Permission and approval policy + +The PreToolUse hook applies a **tokenized** check against the actual `akm` argv (using a shared assessor that the OpenCode plugin also calls). It only fires on real shell invocations of `akm ` — it does not match the phrase appearing in commit messages, release notes, or heredoc bodies. The full list of subcommands it blocks pending approval is in `shared/risky-command.ts`. + +For most users, the plugin's hook is enough. If you prefer Claude Code's built-in permission dialog (a one-time approve/deny prompt rather than a hook stderr block), add the matching rules to your **own** settings — Claude Code plugins cannot ship default permission rules, so this step is opt-in and manual. + +Add this block to `~/.claude/settings.json` (user-wide) or `.claude/settings.json` (current project): + +```json +{ + "permissions": { + "ask": [ + "Bash(akm accept:*)", + "Bash(akm reject:*)", + "Bash(akm proposal accept:*)", + "Bash(akm proposal reject:*)", + "Bash(akm remove:*)", + "Bash(akm upgrade:*)", + "Bash(akm vault create:*)", + "Bash(akm vault set:*)", + "Bash(akm vault unset:*)", + "Bash(akm vault load:*)", + "Bash(akm vault show:*)" + ] + } +} +``` + +Notes: + +- The `Bash(pattern:*)` matcher is **prefix-anchored** and only matches the actual bash invocation. It does not trip on the same phrase appearing inside argv (commit messages, heredoc bodies, etc.). +- Patterns that cannot be expressed as a simple prefix — for example "`akm save` with `--push` anywhere in the flags" — are not coverable by this list. The plugin hook keeps handling those. +- `akm remember` payload secret-scanning is content-aware (it inspects the literal arguments for things that look like secrets). The plugin hook keeps doing this; native permission rules cannot. +- If you want a command to skip both the plugin hook and the permission dialog entirely, put it under `permissions.allow` instead of `permissions.ask` — but only do this for commands you genuinely want to auto-approve. + ### Environment overrides | Variable | Default | Purpose | diff --git a/claude/hooks/akm-hook.ts b/claude/hooks/akm-hook.ts index ac16e66..d41868f 100644 --- a/claude/hooks/akm-hook.ts +++ b/claude/hooks/akm-hook.ts @@ -10,6 +10,7 @@ import { appendMemoryEvent, getEventLogPath, readJsonl, type AkmMemoryEvent } fr import { shouldRecall } from "../shared/recall-policy" import { redactObject, redactSecrets } from "../shared/redaction" import { extractAkmRefsFromString } from "../shared/ref-extraction" +import { assessRiskyAkmCommand } from "../shared/risky-command" const COMMAND = process.argv[2] ?? "" const MODE = process.argv[3] ?? "" @@ -455,20 +456,24 @@ function extractPostToolFields(raw: string, mode: string): { toolName: string; c } function assessRiskyClaudeCommand(command: string): string | undefined { + // Tokenized assessment of canonical risky `akm` subcommands (shared with the + // OpenCode plugin). Token-aware matching avoids false positives on prose + // mentions of these phrases inside commit messages, heredoc bodies, or + // release notes. + const assessment = assessRiskyAkmCommand(command) + if (assessment) { + return `${assessment.reason} ${assessment.approval} This requires explicit user approval.` + } + + // Content-scan check: `akm remember` with a payload that looks like it + // contains secrets. This is intentionally regex-based — we want to fire on + // *any* occurrence of a secret in the argv, even if `akm remember` itself + // is being invoked safely. The redactor uses its own heuristics; we just + // gate on its verdict. const normalized = command.trim() - if (!normalized) return undefined - if (/\bakm\s+(?:proposal\s+)?(accept|reject)\b/.test(normalized)) return "Proposal acceptance/rejection requires explicit user approval." - if (/\bakm\s+save\b[\s\S]*--push\b/.test(normalized)) return "`akm save --push` requires explicit user approval." - if (/\bakm\s+remove\b/.test(normalized)) return "`akm remove` is destructive and requires explicit user approval." - if (/\bakm\s+update\b[\s\S]*--all\b/.test(normalized)) return "`akm update --all` requires explicit user approval." - if (/\bakm\s+upgrade\b(?:[\s\S]*--force\b|\b)/.test(normalized)) return "`akm upgrade` requires explicit user approval." - if (/\bakm\s+vault\s+(create|set|unset|load)\b/.test(normalized)) { - if (/^\s*eval\s+["']?\$\(akm\s+vault\s+load\s+/.test(normalized)) return undefined - return "Vault create/set/unset/load requires explicit approval, and raw `akm vault load` output must not be exposed in logs or chat." + if (/\bakm\s+remember\b/.test(normalized) && redactSecrets(normalized).redacted) { + return "Raw `akm remember` payload appears to include secrets; redact it before writing memory." } - if (/\bakm\s+config\s+set\s+(?:llm\.features\.|registries|searchPaths|stashDir)/.test(normalized)) return "AKM config mutations require explicit user approval." - if (/\bakm\s+(?:add|wiki\s+register)\b[\s\S]*--trust\b/.test(normalized)) return "Trusted source registration requires explicit user approval." - if (/\bakm\s+remember\b/.test(normalized) && redactSecrets(normalized).redacted) return "Raw `akm remember` payload appears to include secrets; redact it before writing memory." return undefined } diff --git a/claude/shared/risky-command.ts b/claude/shared/risky-command.ts new file mode 100644 index 0000000..d302b4a --- /dev/null +++ b/claude/shared/risky-command.ts @@ -0,0 +1 @@ +export * from "../../shared/risky-command" diff --git a/opencode/index.ts b/opencode/index.ts index 2a0ee8d..c394c7d 100644 --- a/opencode/index.ts +++ b/opencode/index.ts @@ -10,6 +10,7 @@ import { appendMemoryEvent, getEventLogPath, readJsonl, type AkmMemoryEvent } fr import { shouldRecall } from "../shared/recall-policy" import { redactObject, redactSecrets } from "../shared/redaction" import { extractAkmRefsFromString } from "../shared/ref-extraction" +import { assessRiskyAkmCommand, blockedCommandMessage, splitArguments, type RiskyCommandAssessment } from "../shared/risky-command" let resolvedAkmCommand = "akm" const moduleDir = path.dirname(fileURLToPath(import.meta.url)) @@ -979,52 +980,9 @@ async function recordRetrospectiveFeedback(client: LogCapableClient, sessionID: retrospectiveState.set(sessionID, { recentRefs }) } -type RiskyCommandAssessment = { - category: string - reason: string - approval: string -} - -function assessRiskyAkmCommand(command: string): RiskyCommandAssessment | undefined { - const args = splitArguments(command) - const akmIndex = args.findIndex((arg) => arg === "akm" || arg.endsWith("/akm") || arg.endsWith("\\akm.exe")) - if (akmIndex === -1) return undefined - const tokens = args.slice(akmIndex + 1) - if ((tokens[0] === "proposal" && tokens[1] === "accept") || tokens[0] === "accept") { - return { category: "proposal-accept", reason: "Proposal acceptance changes curated AKM content.", approval: "Ask the user to approve `akm accept `." } - } - if ((tokens[0] === "proposal" && tokens[1] === "reject") || tokens[0] === "reject") { - return { category: "proposal-reject", reason: "Proposal rejection is a durable curation decision.", approval: "Ask the user to approve `akm reject --reason \"...\"`." } - } - if (tokens[0] === "save" && tokens.includes("--push")) { - return { category: "save-push", reason: "Pushing stash changes must be explicitly approved.", approval: "Ask the user to approve `akm save --push`." } - } - if (tokens[0] === "remove") { - return { category: "remove", reason: "Removing AKM sources is destructive.", approval: "Ask the user to approve the exact `akm remove ...` command." } - } - if (tokens[0] === "vault" && ["show", "load", "set", "unset"].includes(tokens[1] ?? "")) { - return { category: `vault-${tokens[1]}`, reason: "Vault access or mutation is sensitive.", approval: `Ask the user to approve the exact \`akm vault ${tokens[1]} ...\` command.` } - } - if (tokens[0] === "config" && tokens[1] === "set" && (tokens[2]?.startsWith("llm.features.") ?? false)) { - return { category: "config-llm-features", reason: "Changing AKM LLM feature flags alters autonomous behavior.", approval: "Ask the user to approve the exact `akm config set llm.features.* ...` command." } - } - if (tokens[0] === "update" && tokens.includes("--all")) { - return { category: "update-all", reason: "Updating all AKM kits changes many assets at once.", approval: "Ask the user to approve `akm update --all`." } - } - if (tokens[0] === "upgrade") { - return { category: "upgrade", reason: "Upgrading the AKM CLI changes the toolchain.", approval: "Ask the user to approve the exact `akm upgrade` command." } - } - return undefined -} - -function blockedCommandMessage(command: string, assessment: RiskyCommandAssessment): string { - return [ - `Blocked risky AKM command: ${command}`, - assessment.reason, - assessment.approval, - "Retry only after explicit user approval in this conversation.", - ].join("\n") -} +// Risk assessment for `akm` CLI invocations moved to shared/risky-command.ts. +// Imported above so both the Claude Code plugin and the OpenCode plugin +// apply the same rules and stay in sync. function queueFeedback( client: LogCapableClient, @@ -2327,16 +2285,7 @@ async function getParentSessionID( } } -function splitArguments(raw: string): string[] { - if (!raw.trim()) return [] - const args: string[] = [] - const re = /"([^"]*)"|'([^']*)'|`([^`]*)`|(\S+)/g - let match: RegExpExecArray | null - while ((match = re.exec(raw)) !== null) { - args.push(match[1] ?? match[2] ?? match[3] ?? match[4] ?? "") - } - return args -} +// splitArguments moved to shared/risky-command.ts (imported at top of file). function renderCommandTemplate(template: string, rawArguments: string): string { const args = splitArguments(rawArguments) diff --git a/shared/risky-command.ts b/shared/risky-command.ts new file mode 100644 index 0000000..8674d1f --- /dev/null +++ b/shared/risky-command.ts @@ -0,0 +1,142 @@ +// Tokenized assessment of risky `akm` CLI invocations. +// +// Shared between the Claude Code plugin (claude/hooks/akm-hook.ts) and the +// OpenCode plugin (opencode/index.ts) so both harnesses block the same set of +// commands using the same logic. +// +// Why tokenized matching: a regex against the raw command string (e.g. +// /\bakm\s+vault\s+set\b/) fires on any prose mention — commit messages, +// release notes, heredoc bodies, README quotes — because the input is the +// entire bash argv concatenated. Tokenized matching uses splitArguments() to +// recover the shell argv, locates the actual `akm` executable token, then +// inspects subcommand positions. Only real invocations trip it; prose does +// not. + +export type RiskyCommandAssessment = { + category: string + reason: string + approval: string +} + +// Quote-aware shell tokenizer. Splits on whitespace but respects single, +// double, and backtick quotes. Intentionally simple: does not handle escaped +// quotes inside the same quote style, $(...) expansion, or process +// substitution. Sufficient for "did the user invoke `akm `" checks; not +// a full shell parser. +export function splitArguments(raw: string): string[] { + if (!raw.trim()) return [] + const args: string[] = [] + const re = /"([^"]*)"|'([^']*)'|`([^`]*)`|(\S+)/g + let match: RegExpExecArray | null + while ((match = re.exec(raw)) !== null) { + args.push(match[1] ?? match[2] ?? match[3] ?? match[4] ?? "") + } + return args +} + +// Documented loader pattern: `eval "$(akm vault load vault:)"`. The +// outer `eval` is the real invocation; the inner `akm vault load` is the +// vault read that the user has explicitly opted into by piping to eval. +// Treat as safe — the user wrote this exact form. +const EVAL_VAULT_LOAD_PATTERN = /^\s*eval\s+["']?\$\(akm\s+vault\s+load\s+/ + +export function assessRiskyAkmCommand(command: string): RiskyCommandAssessment | undefined { + const normalized = command.trim() + if (!normalized) return undefined + + if (EVAL_VAULT_LOAD_PATTERN.test(normalized)) return undefined + + const args = splitArguments(normalized) + const akmIndex = args.findIndex((arg) => arg === "akm" || arg.endsWith("/akm") || arg.endsWith("\\akm.exe")) + if (akmIndex === -1) return undefined + + const tokens = args.slice(akmIndex + 1) + const sub = tokens[0] + const subSub = tokens[1] + + if ((sub === "proposal" && subSub === "accept") || sub === "accept") { + return { + category: "proposal-accept", + reason: "Proposal acceptance changes curated AKM content.", + approval: "Ask the user to approve `akm accept `.", + } + } + + if ((sub === "proposal" && subSub === "reject") || sub === "reject") { + return { + category: "proposal-reject", + reason: "Proposal rejection is a durable curation decision.", + approval: 'Ask the user to approve `akm reject --reason "..."`.', + } + } + + if (sub === "save" && tokens.includes("--push")) { + return { + category: "save-push", + reason: "Pushing stash changes must be explicitly approved.", + approval: "Ask the user to approve `akm save --push`.", + } + } + + if (sub === "remove") { + return { + category: "remove", + reason: "Removing AKM sources is destructive.", + approval: "Ask the user to approve the exact `akm remove ...` command.", + } + } + + if (sub === "vault" && (subSub === "create" || subSub === "set" || subSub === "unset" || subSub === "load" || subSub === "show")) { + return { + category: `vault-${subSub}`, + reason: "Vault access or mutation is sensitive.", + approval: `Ask the user to approve the exact \`akm vault ${subSub} ...\` command.`, + } + } + + if (sub === "config" && subSub === "set" && tokens[2]) { + const key = tokens[2] + if (key.startsWith("llm.features.") || key === "registries" || key === "searchPaths" || key === "stashDir") { + return { + category: "config-mutation", + reason: "AKM config mutations alter autonomous behavior or trust boundaries.", + approval: `Ask the user to approve the exact \`akm config set ${key} ...\` command.`, + } + } + } + + if (sub === "update" && tokens.includes("--all")) { + return { + category: "update-all", + reason: "Updating all AKM kits changes many assets at once.", + approval: "Ask the user to approve `akm update --all`.", + } + } + + if (sub === "upgrade") { + return { + category: "upgrade", + reason: "Upgrading the AKM CLI changes the toolchain.", + approval: "Ask the user to approve the exact `akm upgrade` command.", + } + } + + if ((sub === "add" || (sub === "wiki" && subSub === "register")) && tokens.includes("--trust")) { + return { + category: "trust-registration", + reason: "Trusted source registration bypasses safety checks.", + approval: "Ask the user to approve the exact command before adding a trusted source.", + } + } + + return undefined +} + +export function blockedCommandMessage(command: string, assessment: RiskyCommandAssessment): string { + return [ + `Blocked risky AKM command: ${command}`, + assessment.reason, + assessment.approval, + "Retry only after explicit user approval in this conversation.", + ].join("\n") +}