Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions claude/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <subcommand>` — 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 |
Expand Down
29 changes: 17 additions & 12 deletions claude/hooks/akm-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] ?? ""
Expand Down Expand Up @@ -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
}

Expand Down
1 change: 1 addition & 0 deletions claude/shared/risky-command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "../../shared/risky-command"
61 changes: 5 additions & 56 deletions opencode/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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 <id>`." }
}
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 <id> --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,
Expand Down Expand Up @@ -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)
Expand Down
142 changes: 142 additions & 0 deletions shared/risky-command.ts
Original file line number Diff line number Diff line change
@@ -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 <sub>`" 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:<name>)"`. 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 <id>`.",
}
}

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 <id> --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")
}
Loading