From ea7ca6ea1d42f0c972422224224d369de9a46397 Mon Sep 17 00:00:00 2001 From: hasna-drain Date: Wed, 29 Jul 2026 12:50:27 +0000 Subject: [PATCH 1/3] chore: begin drain OPE15-00031 From 98c85ff0615972809af4f82a91a60b245e92eaec Mon Sep 17 00:00:00 2001 From: hasna-drain Date: Wed, 29 Jul 2026 12:55:34 +0000 Subject: [PATCH 2/3] OPE15-00031: OPE15-00031: Scan repo for files over 1000 LOC and create granular r --- hooks/codewith-native-common.ts | 3187 +---------------- hooks/codewith-native-common/base.ts | 184 + .../dangerous-operation.ts | 487 +++ .../destructive-targets.ts | 525 +++ hooks/codewith-native-common/git-command.ts | 407 +++ .../codewith-native-common/managed-targets.ts | 218 ++ .../codewith-native-common/protected-paths.ts | 571 +++ .../shell-expansions.ts | 402 +++ hooks/codewith-native-common/worktrees.ts | 421 +++ src/cli/commands/core.tsx | 651 ++++ src/cli/commands/docs.ts | 289 ++ src/cli/commands/helpers.ts | 100 + src/cli/commands/log.ts | 170 + src/cli/commands/mcp.ts | 28 + src/cli/commands/storage.ts | 98 + src/cli/index.tsx | 1320 +------ 16 files changed, 4619 insertions(+), 4439 deletions(-) create mode 100644 hooks/codewith-native-common/base.ts create mode 100644 hooks/codewith-native-common/dangerous-operation.ts create mode 100644 hooks/codewith-native-common/destructive-targets.ts create mode 100644 hooks/codewith-native-common/git-command.ts create mode 100644 hooks/codewith-native-common/managed-targets.ts create mode 100644 hooks/codewith-native-common/protected-paths.ts create mode 100644 hooks/codewith-native-common/shell-expansions.ts create mode 100644 hooks/codewith-native-common/worktrees.ts create mode 100644 src/cli/commands/core.tsx create mode 100644 src/cli/commands/docs.ts create mode 100644 src/cli/commands/helpers.ts create mode 100644 src/cli/commands/log.ts create mode 100644 src/cli/commands/mcp.ts create mode 100644 src/cli/commands/storage.ts diff --git a/hooks/codewith-native-common.ts b/hooks/codewith-native-common.ts index a23eeab..b16b091 100644 --- a/hooks/codewith-native-common.ts +++ b/hooks/codewith-native-common.ts @@ -1,3130 +1,57 @@ -import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync, writeSync } from "fs"; -import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from "path"; -import { homedir, tmpdir } from "os"; - -export interface CodewithHookInput { - session_id?: string; - cwd?: string; - hook_event_name?: string; - model?: string; - permission_mode?: string; - source?: string; - prompt?: string; - tool_name?: string; - tool_input?: Record; - tool_use_id?: string; - transcript_path?: string | null; - turn_id?: string; - last_assistant_message?: string | null; - stop_hook_active?: boolean; - agent_id?: string; - agent_type?: string; - agent?: unknown; - [key: string]: unknown; -} - -export interface CodewithHookOutput { - continue?: boolean; - decision?: "approve" | "block"; - reason?: string; - stopReason?: string; - suppressOutput?: boolean; - systemMessage?: string; - hookSpecificOutput?: { - hookEventName: "SessionStart" | "UserPromptSubmit" | "SubagentStart" | "PreToolUse"; - additionalContext?: string; - permissionDecision?: "allow" | "deny" | "ask"; - permissionDecisionReason?: string; - updatedInput?: unknown; - }; -} - -export interface CommandResult { - exitCode: number | null; - stdout: string; - stderr: string; - timedOut: boolean; -} - -export function readInput(): CodewithHookInput { - try { - const raw = readFileSync(0, "utf-8").trim(); - if (!raw) return {}; - return JSON.parse(raw) as CodewithHookInput; - } catch { - return {}; - } -} - -export function respond(output: CodewithHookOutput): void { - // Written synchronously: `process.stdout.write` is async on a pipe, so a verdict - // larger than the pipe buffer is silently truncated if the process exits before it - // drains — and a truncated verdict is unparseable, so the caller sees no decision. - const payload = `${JSON.stringify(output)}\n`; - try { - writeSync(1, payload); - } catch { - process.stdout.write(payload); - } -} - -export function warn(message: string): void { - process.stderr.write(`[hooks] ${message}\n`); -} - -export function cap(text: string, max = 6000): string { - if (text.length <= max) return text; - return `${text.slice(0, max)}\n[truncated ${text.length - max} bytes]`; -} - -export function commandExists(command: string, env: NodeJS.ProcessEnv = process.env): boolean { - const pathValue = env.PATH || ""; - for (const dir of pathValue.split(":")) { - if (!dir) continue; - if (existsSync(join(dir, command))) return true; - } - return false; -} - -export async function runCommand( - argv: string[], - options: { cwd?: string; timeoutMs?: number; env?: NodeJS.ProcessEnv } = {} -): Promise { - const timeoutMs = options.timeoutMs ?? 5000; - const env = options.env ?? process.env; - let proc: ReturnType | null = null; - let timedOut = false; - try { - proc = Bun.spawn(argv, { - cwd: options.cwd, - env, - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }); - const timer = setTimeout(() => { - timedOut = true; - try { proc?.kill(); } catch {} - }, timeoutMs); - const [stdout, stderr, exitCode] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - proc.exited.catch(() => null), - ]); - clearTimeout(timer); - return { exitCode, stdout, stderr, timedOut }; - } catch (error) { - return { exitCode: null, stdout: "", stderr: error instanceof Error ? error.message : String(error), timedOut }; - } -} - -export function getCommand(input: CodewithHookInput): string { - const command = input.tool_input?.command; - return typeof command === "string" ? command : ""; -} - -export function isBashPreToolUse(input: CodewithHookInput): boolean { - return input.hook_event_name === "PreToolUse" && input.tool_name === "Bash"; -} - -export interface GitCommandInfo { - action: "commit" | "push"; - targetCwd: string; - gitDir?: string; - workTree?: string; -} - -// `$( ... )` and backtick substitutions are one operand of the surrounding command: -// their inner `;`, `|` and whitespace are not separators. Tokenizing them atomically is -// what lets the expansion-collapse rule below see `$(cmd)/*` as a single target token. -// If a substitution is left unterminated the command is malformed, so both tokenizers -// re-run with substitution tracking disabled rather than swallow the rest of the input. -function splitShellSegmentsPass( - command: string, - atomicSubstitutions: boolean -): { segments: string[]; isolation: boolean[]; depths: number[]; groups: number[]; piped: boolean[]; shortCircuit: boolean[]; unterminated: boolean } { - const segments: string[] = []; - const isolation: boolean[] = []; - const depths: number[] = []; - const groups: number[] = []; - const pipedFlags: boolean[] = []; - const shortCircuitFlags: boolean[] = []; - let precededByShortCircuit = false; - let current = ""; - let quote: "'" | '"' | null = null; - let escaped = false; - let substitutionDepth = 0; - let substitutionQuote: "'" | '"' | null = null; - let inBacktick = false; - let parenDepth = 0; - let pipedFromPrevious = false; - // Every `(` opens a NEW shell. Two siblings are both depth 1 but are different processes, - // so depth alone cannot identify a frame. - let groupCounter = 0; - const groupStack: number[] = [0]; - - const flush = (nextSeparator: string | null) => { - if (current.trim()) { - segments.push(current.trim()); - // A stage of a pipeline runs in its own process, as does anything inside `( … )`. - isolation.push(parenDepth > 0 || pipedFromPrevious || nextSeparator === "|"); - depths.push(parenDepth); - groups.push(groupStack[groupStack.length - 1] ?? 0); - pipedFlags.push(pipedFromPrevious || nextSeparator === "|"); - shortCircuitFlags.push(precededByShortCircuit); - } - current = ""; - pipedFromPrevious = nextSeparator === "|"; - }; - - for (let i = 0; i < command.length; i += 1) { - const ch = command[i]; - if (escaped) { - current += ch; - escaped = false; - continue; - } - if (ch === "\\" && quote !== "'") { - escaped = true; - current += ch; - continue; - } - if (atomicSubstitutions && substitutionDepth > 0) { - current += ch; - // Quotes inside the body are tracked so a quoted paren is not read as structure. - if (substitutionQuote) { - if (ch === substitutionQuote) substitutionQuote = null; - } else if (ch === "'" || ch === '"') { - substitutionQuote = ch; - } else if (ch === "(") substitutionDepth += 1; - else if (ch === ")") substitutionDepth -= 1; - continue; - } - if (atomicSubstitutions && inBacktick) { - current += ch; - if (ch === "`") inBacktick = false; - continue; - } - if (atomicSubstitutions && quote !== "'" && ch === "$" && command[i + 1] === "(") { - current += "$("; - substitutionDepth = 1; - i += 1; - continue; - } - if (atomicSubstitutions && quote !== "'" && ch === "`") { - current += ch; - inBacktick = true; - continue; - } - if (quote) { - current += ch; - if (ch === quote) quote = null; - continue; - } - if (ch === "'" || ch === "\"") { - quote = ch; - current += ch; - continue; - } - if (ch === ";" || ch === "|" || ch === "&" || ch === "(" || ch === ")" || ch === "\n") { - const doubled = (ch === "|" || ch === "&") && command[i + 1] === ch; - // `||` and `&&` are sequencing, not a pipe. - flush(ch === "|" && !doubled ? "|" : null); - // `a && X=1` and `a || X=1` run X= only if the left side decided so. - precededByShortCircuit = doubled && (ch === "|" || ch === "&"); - if (ch === "(") { - parenDepth += 1; - groupCounter += 1; - groupStack.push(groupCounter); - } else if (ch === ")") { - parenDepth = Math.max(0, parenDepth - 1); - if (groupStack.length > 1) groupStack.pop(); - } - if (doubled) i += 1; - continue; - } - current += ch; - } - - flush(null); - return { segments, isolation, depths, groups, piped: pipedFlags, shortCircuit: shortCircuitFlags, unterminated: substitutionDepth > 0 || inBacktick }; -} - -function splitShellSegments(command: string): string[] { - return splitShellSegmentsDetailed(command).map((segment) => segment.text); -} - -/** A segment plus whether a `cd` in it changes the working directory of later segments. */ -interface ShellSegment { - text: string; - /** Subshell nesting depth of this segment; a `cd` applies to this depth and deeper. */ - depth: number; - /** Identity of the subshell this segment runs in; siblings at one depth differ. */ - group: number; - /** This segment is a pipeline stage, so its `cd` affects nothing outside the stage. */ - piped: boolean; - /** Reached only via `&&` / `||`, so whether it ran depends on the previous command. */ - shortCircuit: boolean; - /** - * True when the segment runs in a subshell `( … )` or as a stage of a pipeline. A `cd` - * there affects only that child process, so treating it as persistent silently moves the - * guard's idea of cwd away from the directory the later `rm` actually runs in. - */ - isolated: boolean; -} - -const segmentCache = new Map(); -const MAX_SEGMENT_CACHE = 16; - -function splitShellSegmentsDetailed(command: string): ShellSegment[] { - const cached = segmentCache.get(command); - if (cached) return cached; - const computed = splitShellSegmentsUncached(command); - if (segmentCache.size >= MAX_SEGMENT_CACHE) { - const oldest = segmentCache.keys().next().value; - if (oldest !== undefined) segmentCache.delete(oldest); - } - segmentCache.set(command, computed); - return computed; -} - -function splitShellSegmentsUncached(command: string): ShellSegment[] { - const pass = splitShellSegmentsPass(command, true); - const chosen = pass.unterminated ? splitShellSegmentsPass(command, false) : pass; - return chosen.segments.map((text, index) => ({ - text, - depth: chosen.depths[index] ?? 0, - group: chosen.groups[index] ?? 0, - piped: chosen.piped[index] ?? false, - shortCircuit: chosen.shortCircuit[index] ?? false, - isolated: chosen.isolation[index] ?? false, - })); -} - -function shellWordsPass(segment: string, atomicSubstitutions: boolean): { words: string[]; unterminated: boolean } { - const words: string[] = []; - let current = ""; - let quote: "'" | '"' | null = null; - let escaped = false; - let substitutionDepth = 0; - let substitutionQuote: "'" | '"' | null = null; - let inBacktick = false; - - const push = () => { - if (current.length > 0) { - words.push(current); - current = ""; - } - }; - - for (let i = 0; i < segment.length; i += 1) { - const ch = segment[i]; - if (escaped) { - // A backslash before a glob metacharacter is part of the pattern, not shell quoting. - current += /[[\]*?]/.test(ch) ? `\\${ch}` : ch; - escaped = false; - continue; - } - // Substitution bodies are copied verbatim - quotes, spaces AND backslashes. Consuming the - // escape here strips the backslash, and findExpansions then re-counts `\'` or `\(` as - // structure on the de-escaped text, which reopened the bug the quote fix closed. - if (atomicSubstitutions && substitutionDepth > 0) { - current += ch; - if (ch === "\\") { - current += segment[i + 1] ?? ""; - i += 1; - } else if (substitutionQuote) { - if (ch === substitutionQuote) substitutionQuote = null; - } else if (ch === "'" || ch === '"') { - substitutionQuote = ch; - } else if (ch === "(") substitutionDepth += 1; - else if (ch === ")") substitutionDepth -= 1; - continue; - } - if (atomicSubstitutions && inBacktick) { - current += ch; - if (ch === "\\") { current += segment[i + 1] ?? ""; i += 1; } - else if (ch === "`") inBacktick = false; - continue; - } - if (ch === "\\" && quote !== "'") { - escaped = true; - continue; - } - if (atomicSubstitutions && quote !== "'" && ch === "$" && segment[i + 1] === "(") { - current += "$("; - substitutionDepth = 1; - i += 1; - continue; - } - if (atomicSubstitutions && quote !== "'" && ch === "`") { - current += ch; - inBacktick = true; - continue; - } - if (quote) { - if (ch === quote) { - quote = null; - } else { - current += ch; - } - continue; - } - if (ch === "'" || ch === "\"") { - quote = ch; - continue; - } - if (/\s/.test(ch)) { - push(); - continue; - } - current += ch; - } - push(); - return { words, unterminated: substitutionDepth > 0 || inBacktick }; -} - -function shellWords(segment: string): string[] { - const atomic = shellWordsPass(segment, true); - if (!atomic.unterminated) return atomic.words; - return shellWordsPass(segment, false).words; -} - -function expandHome(path: string): string { - // One home for every form. `~` used homedir() while `$HOME` and every protected root used - // process.env.HOME, so wherever the two differ the target and the rule were resolved against - // different directories and `rm -rf ~/.hasna` missed the ~/.hasna rule entirely. - const home = process.env.HOME || homedir(); - if (path === "~") return home; - if (path.startsWith("~/")) return join(home, path.slice(2)); - if (path === "$HOME" || path === "${HOME}") return home; - if (path.startsWith("$HOME/")) return join(home, path.slice("$HOME/".length)); - if (path.startsWith("${HOME}/")) return join(home, path.slice("${HOME}/".length)); - return path; -} - -function resolveFrom(cwd: string, path: string): string { - const expanded = expandHome(path); - return isAbsolute(expanded) ? resolve(expanded) : resolve(cwd, expanded); -} - -function optionValue(token: string, next: string | undefined, option: string): { value?: string; consumed: number } | null { - if (token === option) return { value: next, consumed: next === undefined ? 1 : 2 }; - if (token.startsWith(`${option}=`)) return { value: token.slice(option.length + 1), consumed: 1 }; - return null; -} - -function shortOptionValue(token: string, next: string | undefined, option: string): { value?: string; consumed: number } | null { - if (token === option) return { value: next, consumed: next === undefined ? 1 : 2 }; - if (token.startsWith(option) && token.length > option.length) return { value: token.slice(option.length), consumed: 1 }; - return null; -} - -function isGitToken(token: string): boolean { - return token === "git" || token.endsWith("/git"); -} - -function gitInfoFromTokens(tokens: string[], baseCwd: string): GitCommandInfo | null { - const gitIndex = tokens.findIndex(isGitToken); - if (gitIndex === -1) return null; - - let i = gitIndex + 1; - let cwd = resolve(baseCwd); - let gitDir: string | undefined; - let workTree: string | undefined; - - while (i < tokens.length) { - const token = tokens[i]; - - const cDir = shortOptionValue(token, tokens[i + 1], "-C"); - if (cDir) { - if (cDir.value) cwd = resolveFrom(cwd, cDir.value); - i += cDir.consumed; - continue; - } - - const config = shortOptionValue(token, tokens[i + 1], "-c"); - if (config) { - i += config.consumed; - continue; - } - - const gitDirValue = optionValue(token, tokens[i + 1], "--git-dir"); - if (gitDirValue) { - if (gitDirValue.value) gitDir = resolveFrom(cwd, gitDirValue.value); - i += gitDirValue.consumed; - continue; - } - - const workTreeValue = optionValue(token, tokens[i + 1], "--work-tree"); - if (workTreeValue) { - if (workTreeValue.value) workTree = resolveFrom(cwd, workTreeValue.value); - i += workTreeValue.consumed; - continue; - } - - const namespaceValue = optionValue(token, tokens[i + 1], "--namespace"); - if (namespaceValue) { - i += namespaceValue.consumed; - continue; - } - - const execPathValue = optionValue(token, tokens[i + 1], "--exec-path"); - if (execPathValue) { - i += execPathValue.consumed; - continue; - } - - if (token === "--config-env") { - i += tokens[i + 1] === undefined ? 1 : 2; - continue; - } - - if (token === "--") { - i += 1; - continue; - } - - if (token.startsWith("-")) { - i += 1; - continue; - } - - if (token === "commit" || token === "push") { - const targetCwd = workTree || (gitDir ? (gitDir.endsWith(`${sep}.git`) || gitDir.endsWith("/.git") ? dirname(gitDir) : gitDir) : cwd); - return { action: token, targetCwd, ...(gitDir ? { gitDir } : {}), ...(workTree ? { workTree } : {}) }; - } - return null; - } - - return null; -} - -export function gitCommandInfo(command: string, baseCwd: string = process.cwd()): GitCommandInfo | null { - for (const segment of splitShellSegments(command)) { - const tokens = shellWords(segment); - const info = gitInfoFromTokens(tokens, baseCwd); - if (info) return info; - } - return null; -} - -export function isGitCommitOrPush(command: string): boolean { - return gitCommandInfo(command) !== null; -} - -export function isGitPushOrCommitCommand(command: string): "commit" | "push" | null { - return gitCommandInfo(command)?.action || null; -} - -export function isRiskyOperation(command: string): boolean { - const patterns = [ - /(^|[;&|()\s])(?:npm|pnpm|yarn|bun)\s+publish\b/, - /(^|[;&|()\s])gh\s+release\b/, - /(^|[;&|()\s])terraform\s+(?:apply|destroy|import)\b/, - /(^|[;&|()\s])tofu\s+(?:apply|destroy|import)\b/, - /(^|[;&|()\s])kubectl\s+(?:apply|delete|rollout|scale)\b/, - /(^|[;&|()\s])aws\s+[^;&|]*\bdeploy\b/, - /(^|[;&|()\s])(?:drizzle|prisma|sequelize|knex)\s+[^;&|]*\bmigrat(?:e|ion)\b/, - /(^|[;&|()\s])(?:migrate|migration)\b/, - /\bdeploy(?:ment)?\b/, - ]; - return patterns.some((pattern) => pattern.test(command)); -} - -export interface DangerousOperationMatch { - block: boolean; - reason?: string; - targetPath?: string; - protectedPath?: string; - protectedLabel?: string; - operation?: string; -} - -interface ProtectedPathRule { - root: string; - label: string; - mode: "tree" | "root"; -} - -interface ProtectedPathContext { - rules: ProtectedPathRule[]; - workspaceRoots: string[]; - currentManagedRepoRoot: string | null; -} - -function splitPathList(value: unknown): string[] { - if (typeof value === "string") return value.split(":").map((v) => v.trim()).filter(Boolean); - if (!Array.isArray(value)) return []; - return value.flatMap((item) => splitPathList(item)); -} - -function inputPathList(input: CodewithHookInput, ...keys: string[]): string[] { - const out: string[] = []; - for (const key of keys) out.push(...splitPathList(input[key])); - return out; -} - -function uniqueResolved(paths: string[], cwd: string): string[] { - return [...new Set(paths.map((path) => resolveFrom(cwd, path)))]; -} - -function workspaceRootsFor(input: CodewithHookInput, cwd: string): string[] { - const home = process.env.HOME || homedir(); - const candidates = [ - ...inputPathList(input, "workspace_roots", "workspaceRoots", "workspace_root", "workspaceRoot"), - ...splitPathList(process.env.CODEWITH_WORKSPACE_ROOTS), - ...splitPathList(process.env.HASNA_WORKSPACE_ROOTS), - join(home, "workspace"), - join(home, "Workspace"), - ]; - return uniqueResolved(candidates, cwd); -} - -function activeRootsFor(input: CodewithHookInput, cwd: string): string[] { - const candidates = [ - ...inputPathList(input, "active_repo_roots", "activeRepoRoots", "active_worktree_roots", "activeWorktreeRoots"), - ...splitPathList(process.env.HASNA_ACTIVE_REPO_ROOTS), - ...splitPathList(process.env.HASNA_ACTIVE_WORKTREE_ROOTS), - ]; - return uniqueResolved(candidates, cwd); -} - -/** - * Filesystem roots a recursive delete must never target wholesale: the FHS system - * directories plus their macOS equivalents, and `/` itself. - * - * `/` is here because of the 2026-07-24 station02 incident: `rm -rf "$(bun pm cache)"/*` - * ran as `rm -rf /*` after the substitution collapsed to empty, freed ~700 GB and - * permanently destroyed one repository's only source copy. Every entry is matched in - * "root" mode, so `rm -rf /usr` and `rm -rf /usr/*` block while `rm -rf /usr/local/lib/mine` - * stays allowed - the guard is about wholesale wipes, not targeted deletes. - * - * `/tmp` is deliberately absent: scratch cleanup there is routine and bounded. - * Machine-specific additions come from HASNA_PROTECTED_SYSTEM_ROOTS (colon-separated). - */ -export const SYSTEM_PROTECTED_ROOTS: readonly string[] = [ - "/", - "/bin", - "/boot", - "/dev", - "/etc", - "/home", - "/lib", - "/lib32", - "/lib64", - "/libx32", - "/opt", - "/proc", - "/root", - "/run", - "/sbin", - "/srv", - "/sys", - "/usr", - "/var", - "/Applications", - "/Library", - "/System", - "/Users", - "/Volumes", - "/private", -]; - -function systemProtectedRulesFor(cwd: string): ProtectedPathRule[] { - const roots = uniqueResolved( - [...SYSTEM_PROTECTED_ROOTS, ...splitPathList(process.env.HASNA_PROTECTED_SYSTEM_ROOTS)], - cwd - ); - return roots.map((root) => ({ - root, - label: root === sep ? "filesystem root /" : `system root ${root}`, - mode: "root" as const, - })); -} - -function hasnaDivisionRuleFor(target: string, workspaceRoot: string): ProtectedPathRule | null { - const rel = relative(resolve(workspaceRoot), resolve(target)); - if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return null; - const parts = rel.split(sep).filter(Boolean); - if (!parts[0]?.startsWith("hasna")) return null; - if (parts.length === 1) { - return { root: resolve(workspaceRoot, parts[0]), label: `Hasna division root ${parts[0]}`, mode: "root" }; - } - if (parts.length === 2) { - return { root: resolve(workspaceRoot, parts[0], parts[1]), label: `Hasna top-level scope ${parts[0]}/${parts[1]}`, mode: "root" }; - } - return null; -} - -async function protectedPathContextFor(input: CodewithHookInput, cwd: string): Promise { - const home = process.env.HOME || homedir(); - const rules: ProtectedPathRule[] = [ - // System roots first so a root wipe is reported as the root wipe it is, rather than as - // whichever Hasna path happened to sit underneath it. Overlapping paths are deduplicated - // below with the Hasna rule's more specific label winning. - ...systemProtectedRulesFor(cwd), - { root: join(home, ".hasna"), label: "Hasna state root ~/.hasna", mode: "tree" }, - ]; - const workspaceRoots = workspaceRootsFor(input, cwd); - - for (const root of workspaceRoots) { - rules.push({ root, label: "workspace root", mode: "root" }); - } - - const repoRoot = await gitRepoRoot(cwd); - if (repoRoot) rules.push({ root: repoRoot, label: "active repository root", mode: "root" }); - - for (const root of activeRootsFor(input, cwd)) { - rules.push({ root, label: "active repository or worktree root", mode: "root" }); - } - - const worktreesRoot = resolve(defaultWorktreesRoot()); - const isCurrentManagedRepo = repoRoot !== null - && isInsidePath(cwd, worktreesRoot) - && isInsidePath(repoRoot, worktreesRoot); - const currentManagedRepoRoot = isCurrentManagedRepo ? resolve(repoRoot) : null; - - return { - rules: [...new Map(rules.map((rule) => [resolve(rule.root), { ...rule, root: resolve(rule.root) }])).values()], - workspaceRoots, - currentManagedRepoRoot, - }; -} - -function threatensProtectedPath(targetPath: string, rule: ProtectedPathRule): boolean { - const target = resolve(targetPath); - const root = resolve(rule.root); - if (rule.mode === "tree") { - return isInsidePath(target, root) || isInsidePath(root, target); - } - return target === root || isInsidePath(root, target); -} - -function mutatesProtectedPath(targetPath: string, rule: ProtectedPathRule): boolean { - const target = resolve(targetPath); - const root = resolve(rule.root); - if (rule.mode === "tree") return isInsidePath(target, root); - return target === root; -} - -// A trailing glob that matches every entry, so `dir/*` destroys all of `dir`. -const CATCH_ALL_GLOB = /^(?:\*|\*\*|\.\*|\.\[!\.\]\*)$/; - -/** - * Match one glob path component against one literal name, without a regular expression. - * - * Written as a linear matcher on purpose, for two reasons that both bit this branch: - * - * - Regex ESCAPING of `[`/`]` made `[e]tc` compile to a literal no directory can equal, so - * `rm -rf /[e]tc` - which bash expands to `/etc` - matched no protected root. - * - Regex COMPILATION of `*` as `[^/]*` backtracked exponentially: a ~70-character protected - * root component with a dozen `*b` groups took over 45s against this hook's 20s timeout, - * and a timed-out hook fails open. Two fail-opens in the same helper. - * - * A two-pointer wildcard match is O(pattern x name) worst case with no backtracking blowup, - * and bracket handling is explicit rather than delegated to regex syntax that does not mean - * the same thing. Unmatched constructs fall back to "matches", never to "does not match": - * an under-match is silent and fails open, which is exactly how `[e]tc` got through. - */ -function bracketExpressionEnd(pattern: string, open: number): number { - let i = open + 1; - if (pattern[i] === "!" || pattern[i] === "^") i += 1; - // A `]` in first position is a literal member, not the terminator. - if (pattern[i] === "]") i += 1; - while (i < pattern.length) { - const ch = pattern[i]; - if (ch === "\\") { i += 2; continue; } - if (ch === "[" && (pattern[i + 1] === ":" || pattern[i + 1] === "=" || pattern[i + 1] === ".")) { - const kind = pattern[i + 1]; - const classClose = pattern.indexOf(`${kind}]`, i + 2); - const plainClose = pattern.indexOf("]", i + 2); - // The `:]` must come before the next plain `]`, or this is not a class and that `]` - // closes the bracket. Searching to end-of-component let a `:]` belonging to a LATER - // bracket be taken as this one's, swallowing the real terminator - so `[u[:]` absorbed - // the next expression and `/[u[:][[:alpha:]]r`, which bash expands to /usr, matched - // nothing at all. 158 commands onto live system roots were allowed by that one line. - if (classClose === -1 || (plainClose !== -1 && plainClose < classClose)) { - return plainClose; - } - i = classClose + 2; - continue; - } - if (ch === "]") return i; - i += 1; - } - return -1; -} - -/** - * Does this bracket expression match `ch`? - * - * Returns TRUE whenever the expression contains anything this matcher does not model exactly. - * That direction is the entire design, and it is the correction for six consecutive rounds of - * one defect: every bracket bug on this branch has been an UNDER-match, and an under-match - * means a protected root goes unmatched and the delete is allowed. `[e]tc`, `[[:lower:]]`, - * `[e[:]tc`, `[![:foo:]]` and `[a\]e]` each named a real path in bash while the guard held a - * pattern that could match nothing at all. - * - * Over-matching costs a false block on a construct almost nobody writes. Under-matching costs - * a filesystem. So POSIX classes, equivalence and collating classes, and backslash escapes are - * all treated as matching rather than as not-matching. - */ -function bracketMatches(pattern: string, open: number, close: number, ch: string): boolean { - const body = pattern.slice(open + 1, close); - const negated = body.startsWith("!") || body.startsWith("^"); - const members = negated ? body.slice(1) : body; - - // Anything not modelled exactly: fail closed by matching. - if (/\\|\[[:=.]/.test(members)) return true; - - let matched = false; - let first = true; - for (let i = 0; i < members.length; i += 1) { - const member = members[i]; - if (member === "]" && !first) break; - if (members[i + 1] === "-" && i + 2 < members.length && members[i + 2] !== "]") { - if (ch >= member && ch <= members[i + 2]) matched = true; - i += 2; - } else if (ch === member) { - matched = true; - } - first = false; - } - return negated ? !matched : matched; -} - -/** - * Two-pointer wildcard match. Backtracking is limited to the last `*`, so it stays linear in - * practice - the compiled-regex version it replaced backtracked exponentially and blew past - * this hook's 20s timeout, which fails open. - * - * An unterminated or unparseable bracket makes the REST of the component match anything, - * rather than degrading `[` to a literal. The literal reading is an under-match, and - * `rm -rf /[e[:]tc` - which bash expands to `/etc` - slipped through on exactly that path. - */ -function globMatches(pattern: string, name: string): boolean { - let p = 0; - let n = 0; - let starPattern = -1; - let starName = 0; - - while (n < name.length) { - const ch = pattern[p]; - - if (p < pattern.length && ch === "*") { - starPattern = p; - starName = n; - p += 1; - continue; - } - if (p < pattern.length && ch === "?") { - p += 1; - n += 1; - continue; - } - if (p < pattern.length && ch === "[") { - const close = bracketExpressionEnd(pattern, p); - if (close === -1) return true; - if (bracketMatches(pattern, p, close, name[n])) { - p = close + 1; - n += 1; - continue; - } - } else if (p < pattern.length) { - const literal = ch === "\\" && p + 1 < pattern.length ? pattern[p + 1] : ch; - const width = ch === "\\" && p + 1 < pattern.length ? 2 : 1; - if (literal === name[n]) { - p += width; - n += 1; - continue; - } - } - - if (starPattern === -1) return false; - starName += 1; - n = starName; - p = starPattern + 1; - } - - while (pattern[p] === "*") p += 1; - return p >= pattern.length; -} - -/** - * Does this glob keep no literal text that anchors it, so it can match essentially any name? - * `[a-z]*`, `?*`, `.??*` and `*.*` are unanchored; `*.log` and `tmp-*` are anchored. - */ -function isUnanchoredGlob(pattern: string): boolean { - if (!/[*?[]/.test(pattern)) return false; - // Computed once, not per bracket: a `[` with no `]` anywhere is a literal character, so a - // directory named `backup[2026` is anchored by its own name and is not a sweep. - const bracketsArePatterns = pattern.includes("]"); - let residue = ""; - for (let i = 0; i < pattern.length; i += 1) { - const ch = pattern[i]; - if (ch === "\\" && i + 1 < pattern.length) { residue += pattern[i + 1]; i += 1; continue; } - if (ch === "*" || ch === "?") continue; - if (ch === "[" && bracketsArePatterns) { - const close = bracketExpressionEnd(pattern, i); - // Unparseable: the rest matches anything, so nothing after it can anchor. Returning - // here also keeps this linear - re-scanning to end-of-pattern from every `[` was - // quadratic, and a 20k-bracket flood took 22s against the 20s timeout, failing open. - if (close === -1) return true; - i = close; - continue; - } - residue += ch; - } - // A leading dot does not anchor: `.??*` sweeps a directory just as `*` does. Nor does - // punctuation alone: `*.*` takes every dotted entry at the root. - return residue.replace(/^\./, "").replace(/[.\-_]/g, "").length === 0; -} - - -/** Does this component actually glob, or is it a literal that merely contains a bracket? */ -function componentIsPattern(component: string): boolean { - if (/[*?]/.test(component)) return true; - return component.includes("[") && component.includes("]"); -} - -export function globComponentMatches(pattern: string, literal: string): boolean { - if (!/[*?[]/.test(pattern)) return pattern === literal; - // `[` with no `]` anywhere and no other wildcard is a literal bracket, not an expression. - // Without this, a directory genuinely named `backup[2026` was escalated to "wipes the - // repository root" - fail-closed matching has to stop where bash stops globbing. - if (!pattern.includes("]") && !/[*?]/.test(pattern)) return pattern === literal; - // Fail closed on an ambiguous BOUNDARY, not just ambiguous contents. - // - // This is the defect that survived eight review rounds. Bracket CONTENTS already failed - // closed, but the boundary was still computed exactly - and every disagreement with bash - // about where a bracket ENDS misaligns the rest of the component and silently reports "no - // match", which allows the delete. Round 6 searched to end-of-component for the class - // terminator and swallowed later brackets; round 7 stopped at the first plain `]`, which is - // backwards (inside `[:`, a plain `]` does not terminate) and reopened the class net worse: - // 220 -> 380 live root-wipe escapes. - // - // Every one of those 380 contained `[:`, `[=` or `[.`. Plain brackets, ranges, negation, - // `*`, `?` and backslash escapes were measured clean across 44,867 dangerous patterns. So - // the guard stops trying to locate a boundary it cannot pin down: a component containing a - // POSIX class, equivalence class or collating symbol matches anything. - if (/\[[:=.]/.test(pattern)) return true; - if (CATCH_ALL_GLOB.test(pattern)) return true; - return globMatches(pattern, literal); -} - -/** - * Could this glob pattern match `root` itself, or an ancestor of it? - * - * If it can, every expansion that lands there takes `root` with it. A pattern DEEPER than - * `root` cannot: `*​/node_modules` from a repo root deletes `/node_modules`, never the - * repo root, which is why matching only on "the first glob's parent directory" wrongly blocked - * `rm -rf *​/node_modules` - a daily monorepo command, and exactly the kind of false positive - * that gets a guard switched off. - */ -function globPatternCovers(patternParts: string[], rootParts: string[]): boolean { - if (patternParts.length > rootParts.length) return false; - return patternParts.every((part, index) => globComponentMatches(part, rootParts[index])); -} - -/** Components of the pattern up to, but not including, its first glob component. */ -function literalPrefixOf(parts: string[]): string { - const globIndex = parts.findIndex((part) => /[*?[]/.test(part)); - return (globIndex === -1 ? parts : parts.slice(0, globIndex)).join(sep) || sep; -} - -function pathHasGlob(targetPath: string): boolean { - return /[*?[]/.test(resolve(targetPath)); -} - -/** - * Does a glob delete threaten this rule? - * - * Two ways, and both are needed: - * (a) the pattern can match the protected root or an ancestor of it - `rm -rf /*` matches - * `/home`, `rm -rf /*​/*` matches `/home/hasna`; - * (b) the pattern is a wholesale wipe of the root's own contents - `rm -rf /home/*`, whose - * last component is a catch-all and whose prefix covers `/home`. - * For a tree rule, a pattern sitting inside the tree also threatens it. - */ -function globThreatensRule(targetPath: string, rule: ProtectedPathRule): boolean { - const parts = resolve(targetPath).split(sep); - const rootParts = resolve(rule.root).split(sep); - - if (rule.mode === "tree") { - if (isInsidePath(literalPrefixOf(parts), rule.root)) return true; - // A pattern deeper than the root can still land inside it: `~/.h*/repos` matches - // ~/.hasna/repos. literalPrefixOf stops before the first glob, so it misses this. - if (parts.length > rootParts.length && globPatternCovers(parts.slice(0, rootParts.length), rootParts)) { - return true; - } - } - if (globPatternCovers(parts, rootParts)) return true; - - const last = parts[parts.length - 1]; - if (CATCH_ALL_GLOB.test(last) && globPatternCovers(parts.slice(0, -1), rootParts)) return true; - - // A glob in the last component sweeps the contents of its own parent. When that parent IS - // the protected root AND the pattern is unanchored, the sweep guts the root: `rm -rf [a-z]*` - // or `?*` at a repo root take almost everything. - // - // "Unanchored" means no literal character survives once wildcards are removed. That - // distinction is the whole point: `*.log`, `tmp-*`, `.turbo*` and `snapshot-[0-9]*` are - // anchored by their literal text and cannot take the root, and blocking them - which the - // blunt any-metacharacter version did - re-broke twelve everyday repo-root cleanups. A - // guard that blocks routine work gets switched off. - if (isUnanchoredGlob(last) && mutatesProtectedPath(parts.slice(0, -1).join(sep) || sep, rule)) return true; - - // A catch-all in the FIRST component sweeps every top-level directory: `/*/bin` deletes - // /usr/bin, /var/bin and the rest, and a trailing literal makes the pattern deeper than any - // single root, so component matching alone misses it. Scoped to the filesystem root so - // ordinary sweeps deeper down - `/opt/*/logs`, `/var/*/tmp`, `*/node_modules` - stay allowed. - // At the filesystem root, ANY glob in the first component reaches several top-level - // directories: `/*r*/lib` matched 11 of 25 entries on the reference machine, and `/?*/bin` - // and `/[a-z]*/bin` reach /usr/bin exactly as `/*/bin` does. A single literal character is - // not an anchor at this depth, so the sweep rule does not ask for one. The cost is refusing - // `rm -rf /tmp*/x`, which is rare and safe to spell out literally. - if (rule.root === sep && parts.length > 1 && componentIsPattern(parts[1])) return true; - - return false; -} - -const MAX_BRACE_EXPANSIONS = 64; -const MAX_BRACE_ROUNDS = 16; - -/** Expand only the leftmost brace group of a token; null when there is none to expand. */ -function expandLeftmostBrace(token: string): string[] | null { - // Skip `${…}` parameter expansions when looking for an alternation: their brace is not a - // brace group, and treating it as one abandoned expansion for the whole token, so - // `rm -rf "${HOME}"/{,.hasna}` was never expanded at all. - let open = -1; - for (let i = 0; i < token.length; i += 1) { - if (token[i] !== "{") continue; - if (i > 0 && token[i - 1] === "$") { - let depth = 0; - for (; i < token.length; i += 1) { - if (token[i] === "{") depth += 1; - else if (token[i] === "}") { depth -= 1; if (depth === 0) break; } - } - continue; - } - open = i; - break; - } - if (open === -1) return null; - - let depth = 0; - let close = -1; - const parts: string[] = []; - let current = ""; - for (let i = open; i < token.length; i += 1) { - const ch = token[i]; - if (ch === "\\") { current += ch + (token[i + 1] ?? ""); i += 1; continue; } - if (ch === "{") { - depth += 1; - if (depth === 1) continue; - } else if (ch === "}") { - depth -= 1; - if (depth === 0) { close = i; break; } - } else if (ch === "," && depth === 1) { - parts.push(current); - current = ""; - continue; - } - current += ch; - } - if (close === -1 || parts.length === 0) return null; - parts.push(current); - - const prefix = token.slice(0, open); - const suffix = token.slice(close + 1); - return parts.map((part) => `${prefix}${part}${suffix}`); -} - -/** - * Expand `{a,b}` alternations, so `rm -rf /{bin,etc,home}` is seen as the three root deletes - * it performs rather than as one literal path. - * - * Expansion is breadth-first and abandoned the moment it exceeds the cap, because brace - * expansion is combinatorial: `/{a,b}` repeated 26 times is 2^26 paths. A recursive version - * that capped only the finished list took 19.75s on that input, past this hook's 20s timeout - * - and a hook that times out fails open, so a long enough brace string would have switched - * the guard off and then run the delete. - * - * Abandoning does NOT return the raw token. Doing that was itself a bypass: - * `rm -rf /{a0,…,a69,etc}` exceeded the cap and the unexpanded token resolved to a literal - * path matching no protected root. Instead the brace-free prefix is returned as a catch-all - * wipe, which is what an unbounded alternation under that prefix actually is - every - * expansion is necessarily a child of it. - */ -function braceAbandonFallback(token: string): string[] { - const open = token.indexOf("{"); - const prefix = open === -1 ? token : token.slice(0, open); - const base = prefix.endsWith(sep) || prefix === "" ? prefix : `${prefix}${sep}`; - const fallback = [`${base}*`]; - // An alternative that is itself absolute is NOT a child of the prefix: `rm -rf {/etc,a0,…}` - // expands to `rm -rf /etc a0 …`, so the prefix-based fallback would miss `/etc` entirely. - if (/[{,]\s*\//.test(token)) fallback.push(`${sep}*`); - return fallback; -} - -function expandBraces(token: string): string[] { - if (!token.includes("{")) return [token]; - - let frontier = [token]; - for (let round = 0; round < MAX_BRACE_ROUNDS; round += 1) { - const next: string[] = []; - let expandedAny = false; - for (const item of frontier) { - const parts = expandLeftmostBrace(item); - if (parts === null) { - next.push(item); - continue; - } - expandedAny = true; - for (const part of parts) { - if (next.length >= MAX_BRACE_EXPANSIONS) return braceAbandonFallback(token); - next.push(part); - } - } - if (!expandedAny) return next; - frontier = next; - } - return braceAbandonFallback(token); -} - -// `${VAR:?}` / `${VAR:?message}` aborts the shell when VAR is unset *or* empty, so this -// form cannot collapse. It is the POSIX way to assert a path is present, and blocking it -// would punish exactly the defensive code this guard asks for. `${VAR?}` without the colon -// is NOT exempt: it permits an empty value, which is the whole hazard. -const GUARDED_EXPANSION = /^\$\{[A-Za-z_][A-Za-z0-9_]*:\?/; -const NON_EMPTY_PLACEHOLDER = "__hooks_guarded_expansion__"; -const MAX_EXPANSION_NESTING = 32; - -// Builtins whose effect on a variable this scan cannot follow at all. Any of them clears -// every guarantee, because guessing in the permissive direction is how `$X` stayed certified -// non-empty while the shell had already emptied it. -const OPAQUE_BUILTINS = new Set(["eval", "source", ".", "trap", "coproc", "exec"]); - -// Compound-command keywords that can precede an assignment in the same segment. -const COMPOUND_KEYWORDS = new Set(["{", "}", "then", "do", "else", "elif", "fi", "done", "!"]); - -// Sentinel marking PWD as reassigned, so $PWD stops being treated as shell-maintained. -const PWD_REASSIGNED = "\u0000PWD-REASSIGNED"; - -// Builtins that bind a BARE name, with no `=` in sight: `read D`, `getopts o D`. -const NAME_BINDING_BUILTINS = new Set(["read", "getopts", "mapfile", "readarray"]); - -// Builtins that take `NAME=value` operands. A BARE name here does not change the variable - -// `export X` merely exports the existing value - so bare names must not withdraw anything. -const VALUE_BINDING_BUILTINS = new Set(["export", "declare", "typeset", "readonly", "local", "let"]); - -/** One shell expansion found in a token, with its exact source span. */ -interface FoundExpansion { - text: string; - start: number; - end: number; -} - -/** - * Locate shell expansions by scanning with a depth counter rather than by regex. - * - * A regex has to fix a nesting depth, and every fixed depth is a bypass: - * `$(dirname "$(dirname "$(bun pm cache)")")` is three deep, and `${A:-${B}}` nests braces. - */ -function findExpansions(token: string): FoundExpansion[] { - const found: FoundExpansion[] = []; - for (let i = 0; i < token.length; i += 1) { - if (token[i] === "\\") { - i += 1; - continue; - } - if (token[i] === "`") { - const end = token.indexOf("`", i + 1); - if (end === -1) break; - found.push({ text: token.slice(i, end + 1), start: i, end: end + 1 }); - i = end; - continue; - } - if (token[i] !== "$") continue; - - const next = token[i + 1]; - if (next === "(" || next === "{") { - const open = next; - const close = open === "(" ? ")" : "}"; - let depth = 0; - let quote: "'" | '"' | null = null; - let j = i + 1; - for (; j < token.length; j += 1) { - const ch = token[j]; - // An escaped character is data whether or not a quote is open: `$(echo \')`. - if (ch === "\\") { j += 1; continue; } - // A paren inside quotes is data, not structure: `awk -F'(' '{print $2}'`. - if (quote) { - if (ch === quote) quote = null; - continue; - } - if (ch === "'" || ch === '"') { quote = ch; continue; } - if (ch === open) depth += 1; - else if (ch === close) { - depth -= 1; - if (depth === 0) break; - } - } - if (depth !== 0) break; - found.push({ text: token.slice(i, j + 1), start: i, end: j + 1 }); - i = j; - continue; - } - const simple = token.slice(i).match(/^\$(?:[A-Za-z_][A-Za-z0-9_]*|[0-9@*?#$!-])/); - if (simple) { - found.push({ text: simple[0], start: i, end: i + simple[0].length }); - i += simple[0].length - 1; - } - } - return found; -} - -/** - * True when the shell cannot hand this expansion back empty. - * - * Every entry is a guarantee, not a guess. Getting this wrong in the permissive direction - * reopens the incident; getting it wrong in the strict direction blocks routine cleanup, - * which gets the guard switched off. Both failures are real, so only provable cases qualify. - */ -function expansionCannotBeEmpty(text: string, nonEmptyNames: ReadonlySet): boolean { - // ${VAR:?} / ${VAR:?message} - POSIX aborts on unset or empty. - if (GUARDED_EXPANSION.test(text)) return true; - - // ${VAR:-default} with a non-empty default. `:-` substitutes the default when VAR is unset - // OR empty, so the result is non-empty. Plain `${VAR-default}` does NOT qualify: it only - // covers unset, so a set-but-empty VAR still yields "". - // $PWD and $(pwd) are maintained by the shell, but only while nothing reassigns PWD. - if (text === "$PWD" || text === "${PWD}" || /^\$\(\s*pwd\s*\)$/.test(text) || /^`\s*pwd\s*`$/.test(text)) { - return !nonEmptyNames.has(PWD_REASSIGNED); - } - - // Assigned a non-empty literal earlier in this same command. - const name = text.match(/^\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?$/); - return name !== null && nonEmptyNames.has(name[1]); -} - -/** - * Value an expansion is guaranteed to take when the variable is unset or empty, or null when - * there is no such guarantee. - * - * `${VAR:-default}` substitutes the default whenever VAR is unset OR empty, so the worst case - * is the default itself - and the default is used verbatim rather than assumed harmless. - * `${A:-/}` therefore collapses to `/` and blocks, where treating "has a default" as "is safe" - * let it through. Plain `${VAR-default}` does NOT qualify: it only covers unset, so a - * set-but-empty VAR still yields "". - */ -function expansionFallbackValue( - text: string, - nonEmptyNames: ReadonlySet, - depth = 0 -): string | null { - const withDefault = text.match(/^\$\{[A-Za-z_][A-Za-z0-9_]*:-([\s\S]*)\}$/); - if (!withDefault) return null; - // Bounded because this recurses once per nesting level while re-scanning the remainder: - // `${A:-${A:- … }}` 40k deep overflowed the stack, the hook caught it and answered - // {"continue":true}, and the `rm -rf /*` in the same command was never classified at all. - // Past the cap there is no guarantee left to prove, so the value is treated as collapsible, - // which blocks rather than allows. - if (depth >= MAX_EXPANSION_NESTING) return ""; - const fallback = withDefault[1]; - if (fallback.length === 0) return ""; - - let value = ""; - let cursor = 0; - for (const inner of findExpansions(fallback)) { - value += fallback.slice(cursor, inner.start); - const nested = expansionFallbackValue(inner.text, nonEmptyNames, depth + 1); - if (nested !== null) value += nested; - else if (expansionCannotBeEmpty(inner.text, nonEmptyNames)) value += NON_EMPTY_PLACEHOLDER; - cursor = inner.end; - } - value += fallback.slice(cursor); - return value; -} - -/** - * The shape that destroyed station02 on 2026-07-24. - * - * `bun pm cache` writes its path to stdout on success, but exits 1 with an empty stdout - * when no package.json is found walking up from cwd. `rm -rf "$(bun pm cache)"/*` therefore - * became `rm -rf /*`. Redirecting stderr does not help: the redirect discards the - * diagnostic, not the path. The hazard is not this command - it is any expansion the shell - * may hand back empty, immediately followed by a path separator. - * - * Returns the token with every expansion replaced by the empty string, i.e. the worst case - * the shell can produce. Returns null when: - * - the token contains no expansion; or - * - the collapse is not absolute. A bare `rm -rf "$(cmd)"` collapses to `rm -rf ""`, which - * POSIX rm rejects with "cannot remove ''" and a non-zero exit without deleting anything, - * and blocking it would break routine `rm -rf "$tmpdir"` cleanup for no safety gain. A - * relative collapse stays inside cwd and is already covered by the ordinary target check. - * The whole catastrophic class is the one where the collapse leaves a leading `/`. - */ -export function emptyExpansionCollapse( - token: string, - nonEmptyNames: ReadonlySet = new Set() -): string | null { - if (!/[$`]/.test(token)) return null; - const expansions = findExpansions(token); - if (expansions.length === 0) return null; - - let sawCollapsible = false; - let collapsed = ""; - let cursor = 0; - for (const expansion of expansions) { - collapsed += token.slice(cursor, expansion.start); - const fallback = expansionFallbackValue(expansion.text, nonEmptyNames); - if (fallback !== null) { - // The default IS the worst case, so the resulting path still has to be checked - - // `${A:-/}` yields `/`, which is the whole hazard, not a reason to skip the check. - collapsed += fallback; - sawCollapsible = true; - } else if (expansionCannotBeEmpty(expansion.text, nonEmptyNames)) { - collapsed += NON_EMPTY_PLACEHOLDER; - } else { - sawCollapsible = true; - } - cursor = expansion.end; - } - collapsed += token.slice(cursor); - - if (!sawCollapsible || !collapsed.startsWith("/")) return null; - return collapsed; -} - -/** - * One set per segment: the variables provably non-empty at the moment that segment runs. - * - * Built in a SINGLE forward pass. The previous version recomputed the whole segmentation and - * rescanned every preceding segment on each call, and was called once per chunk - O(segments²). - * 36 KB of `:; ` padding took 25.6s against this hook's 20s timeout, and a timed-out hook fails - * open, so padding alone turned a blocked `rm -rf /*` into an unguarded one. That is the same - * fail-open the wrapper caps were written to stop, reopened along a different axis. - * - * Every relaxation here is a way past the guard, so each condition is a guarantee: - * - * X=/tmp/build rm -rf "$X"/* a PREFIX assignment applies to the command's own - * environment, not to the expansion, which bash performs - * first; `$X` is still empty - * rm -rf "$X"/* ; X=/tmp/build an assignment AFTER the delete counted - * X=/tmp/build; X=$(cmd); rm … a later reassignment to something collapsible - * X=/tmp/build; X=; rm … an explicit empty reassignment - * X=/tmp/build; unset X; rm … an unset - * (X=/tmp/build); rm … a subshell-scoped assignment escaping its subshell - * X=/tmp/build | cat; rm … a pipeline-stage assignment doing the same - */ -function assignmentWalker(command: string): { at: (segmentIndex: number) => ReadonlySet } { - const segments = splitShellSegmentsDetailed(command); - let cursor = 0; - let current = new Set(); - - // Advances a SINGLE set forward and hands it out only when a segment actually contains a - // delete. Materialising one snapshot per segment was O(segments x names): 30k distinct - // names took 24.4s against the 20s timeout, and a timed-out hook fails open. Almost every - // command has one delete, so almost every command now copies nothing. - const at = (segmentIndex: number): ReadonlySet => { - while (cursor < segmentIndex && cursor < segments.length) { - applySegment(segments[cursor]); - cursor += 1; - } - return current; - }; - - // Depth of open `if` / `while` / `until` / `case` blocks. Everything inside one may not run. - let conditionalDepth = 0; - // Brace-group nesting, and the depth at which a `&&`/`||` right-hand side was entered. - let braceDepth = 0; - let conditionalBraceDepth = 0; - - function applySegment({ text, depth, isolated, shortCircuit }: ShellSegment): void { - - - // `{ X=; }`, `then X=`, `do X=` - strip the compound-command keyword so the assignment - // inside is seen. cwdTrackedSegments already did this; this scan did not, so - // `X=/tmp/build; { X=; }; rm -rf "$X"/*` kept X certified while bash emptied it. - const rawTokens = shellWords(text); - const tokens = rawTokens.filter((token, index) => !(index === 0 && COMPOUND_KEYWORDS.has(token))); - // An assignment that may never execute must not CERTIFY, though it must still WITHDRAW - - // the branch might run. Conditionality is a property of context, so it is tracked across - // segments rather than read off the first token of this one. Deriving it from "a compound - // keyword was stripped from token 0" closed about 5% of the class: inserting one statement - // (`if false; then A=1; X=/tmp/build; fi`) or using `&&`/`||`/`case` restored certification, - // and 297 of those shapes were bash-proven `rm -rf /*`. - // - // A brace group `{ …; }` is NOT conditional - bash runs it in the current shell - so it is - // deliberately excluded here even though its keyword is stripped for tokenizing. - // `for` opens because `done` closes it - omitting it while keeping `done` a closer let any - // `for` loop inside a conditional zero the counter. `elif` does NOT open: `fi` closes an - // if/elif/else chain exactly once, so counting elif left the depth permanently above zero - // and nothing after the block could ever certify. - const OPENERS = new Set(["if", "while", "until", "case", "select", "for"]); - const CLOSERS = new Set(["fi", "done", "esac"]); - // Keywords that introduce the NEXT command rather than being one, so the real command - // token sits behind them: `then for f in …` opens a loop that `done` will close. - const INTRODUCERS = new Set(["then", "do", "else", "elif", "!", "{", "}", "("]); - - // Only a keyword in COMMAND POSITION is a keyword. `echo done`, `touch fi` and a `fi` - // inside a heredoc body or after `#` are ordinary words, and treating them as closers - // decremented the counter and re-certified the branch. - let leadingIndex = 0; - while (leadingIndex < rawTokens.length && INTRODUCERS.has(rawTokens[leadingIndex])) leadingIndex += 1; - const leading = rawTokens[leadingIndex]; - const introducer = rawTokens[0]; - - if (leading !== undefined) { - if (OPENERS.has(leading)) conditionalDepth += 1; - else if (CLOSERS.has(leading)) conditionalDepth = Math.max(0, conditionalDepth - 1); - } - - // `&&`/`||` govern the WHOLE right-hand side, including a brace group. Marking only the - // first segment after the operator let `false && { A=1; X=/tmp/build; }` certify X. - if (introducer === "{") braceDepth += 1; - if (introducer === "}" || rawTokens[rawTokens.length - 1] === "}") { - braceDepth = Math.max(0, braceDepth - 1); - if (braceDepth < conditionalBraceDepth) conditionalBraceDepth = 0; - } - if (shortCircuit && braceDepth > 0 && conditionalBraceDepth === 0) conditionalBraceDepth = braceDepth; - - // Keyword accounting happened above, deliberately BEFORE this return: `if ls /opt | grep - // -q node; then …; CACHE=…; fi` marks the `if` segment isolated (it is followed by `|`), - // so returning first swallowed the opener while its `fi` still decremented - and the - // assignment after it certified. That is the realized incident shape. - if (depth > 0 || isolated) return; - - const conditional = conditionalDepth > 0 - || shortCircuit - || conditionalBraceDepth > 0 - || (leading !== undefined && OPENERS.has(leading)) - || (introducer !== undefined && (introducer === "then" || introducer === "do" || introducer === "elif")); - // A function body runs later and elsewhere, so nothing in it can be relied on. - if (/^[A-Za-z_][A-Za-z0-9_]*\s*\(\s*\)/.test(text) || rawTokens[0] === "function") { - current = new Set(); - return; - } - if (tokens.length === 0) return; - - if (tokens[0] === "unset") { - for (const name of tokens.slice(1)) { - if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) current.delete(name); - } - return; - } - - // Any construct that can rebind a name withdraws the guarantee. Scanned across ALL - // tokens, not just the first: `IFS= read -r D` hides the builtin behind a prefix - // assignment and `while read D` behind a keyword, and both kept D certified non-empty. - // - // Single pass, no slicing. Allocating `tokens.slice(position + 1)` per token made this - // O(tokens^2): 20k `export A=1 ` took 20.9s against the 20s timeout, and a timed-out hook - // fails open - the fourth time a bound in this file reopened that same hole. - // - // WITHDRAWAL is scanned at any position, because a rebinding can hide anywhere. - // CERTIFICATION is granted only from token 0, because a mention is not an execution: - // `# export CACHE=/tmp/x` in a comment certified CACHE as non-empty, which is the realized - // incident shape exactly - a documented cleanup script is the likeliest way to write it. - const withdraw = (name: string) => { - if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) return; - current.delete(name); - }; - - let pendingNameBinder = false; - let pendingValueBinder = false; - let valueBinderIsCommand = false; - let sawNameref = false; - let opaque = false; - let sawNonAssignment = false; - - for (const [position, token] of tokens.entries()) { - if (OPAQUE_BUILTINS.has(token)) { opaque = true; break; } - - if (NAME_BINDING_BUILTINS.has(token) || token === "for") { - pendingNameBinder = true; - pendingValueBinder = false; - sawNonAssignment = true; - continue; - } - if (VALUE_BINDING_BUILTINS.has(token)) { - pendingValueBinder = true; - pendingNameBinder = false; - // Only a builtin in command position can actually bind anything. - valueBinderIsCommand = position === 0; - sawNameref = false; - sawNonAssignment = true; - continue; - } - if (token === "printf") { sawNonAssignment = true; continue; } - if (token === "-v") { pendingNameBinder = true; continue; } - if (token === "-n" && pendingValueBinder) { sawNameref = true; continue; } - - if (pendingNameBinder) { - if (!token.startsWith("-")) withdraw(token); - continue; - } - if (pendingValueBinder) { - if (token.startsWith("-")) continue; - const bound = token.match(/^([A-Za-z_][A-Za-z0-9_]*)=([\s\S]*)$/); - if (!bound) continue; - withdraw(bound[1]); - // `declare -n D=E` aliases D to E, so D's value is E's, not this literal. - if (!conditional && valueBinderIsCommand && !sawNameref && bound[2].length > 0 && !/[$`]/.test(bound[2])) { - current.add(bound[1]); - } - continue; - } - - // Plain `NAME=value`, only while still in the command's assignment prefix. - const assignment = token.match(/^([A-Za-z_][A-Za-z0-9_]*)=([\s\S]*)$/); - if (!assignment) { sawNonAssignment = true; continue; } - if (sawNonAssignment) continue; - const [, name, value] = assignment; - // A PREFIX assignment applies to the command's environment, not to this expansion. - const isPrefixAssignment = position < tokens.length - 1 - && !/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[position + 1] ?? ""); - current.delete(name); - if (name === "PWD") current.add(PWD_REASSIGNED); - if (isPrefixAssignment) continue; - if (!conditional && value.length > 0 && !/[$`]/.test(value)) current.add(name); - } - - if (opaque) current = new Set(); - } - - return { at }; -} - -function shouldSkipHasnaTreeRule(targetPath: string, rule: ProtectedPathRule, currentManagedRepoRoot: string | null): boolean { - if (rule.label !== "Hasna state root ~/.hasna") return false; - if (!currentManagedRepoRoot) return false; - const target = resolve(targetPath); - return isInsidePath(target, currentManagedRepoRoot); -} - -function isMissingPathError(error: unknown): boolean { - return error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT"; -} - -function hasUnsafeTargetComponent(worktreesRoot: string, target: string): boolean { - const relativeTarget = relative(worktreesRoot, target); - const parts = relativeTarget.split(sep).filter(Boolean); - if (parts.some((part) => part.toLowerCase() === ".git")) return true; - - const filesystemRoot = parse(target).root; - const absoluteParts = relative(filesystemRoot, target).split(sep).filter(Boolean); - let probe = filesystemRoot; - try { - if (lstatSync(probe).isSymbolicLink()) return true; - } catch { - return true; - } - for (const part of absoluteParts) { - probe = join(probe, part); - try { - const metadata = lstatSync(probe); - if (metadata.isSymbolicLink()) return true; - if (probe === target && metadata.isFile() && metadata.nlink > 1) return true; - } catch (error) { - if (isMissingPathError(error)) return false; - return true; - } - } - return false; -} - -/** - * Candidate worktree roots for an absolute target, canonical shape first. - * - * The canonical root sits at `//` - * (CANONICAL_WORKTREE_SEGMENTS). The deprecated station-id lease layout sits one - * level deeper. Order matters: a canonical worktree that happens to contain a - * subdirectory must resolve to the canonical root, never to the subdirectory. - */ -function managedWorktreeRootCandidates(worktreesRoot: string, target: string): string[] { - const parts = relative(worktreesRoot, target).split(sep).filter(Boolean); - const depths = [CANONICAL_WORKTREE_SEGMENTS, LEGACY_LEASE_WORKTREE_SEGMENTS]; - return depths - .filter((depth) => parts.length >= depth) - .map((depth) => resolve(worktreesRoot, ...parts.slice(0, depth))); -} - -/** - * Worktree roots that could own `target`, for the scoped dangerous-operation - * carve-out only. - * - * This is a structural lookup ("could a real managed worktree own this path?"), - * not a policy check ("is this path canonical?"). It therefore keeps the - * deprecated station-id lease layout as a candidate, so that worktrees created - * before rule 8 keep their `~/.hasna` write carve-out during migration. Policy - * enforcement lives in managedWorktreeInfo() / worktree-guard. - * - * Both depths are returned when both are plausible, because path shape alone - * cannot tell a canonical root from a legacy lease container. Each candidate is - * still verified against Git provenance by the caller, which fails closed. - */ -function managedLeaseRootCandidates(worktreesRoot: string, target: string): string[] { - return managedWorktreeRootCandidates(worktreesRoot, target).filter((candidate) => { - const info = managedWorktreeInfo(candidate); - return info.managed || info.layout === "legacy-station-lease"; - }); -} - -async function verifiedLinkedWorktreeRoot(leaseRoot: string): Promise { - const controlFile = join(leaseRoot, ".git"); - try { - const metadata = lstatSync(controlFile); - if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.nlink !== 1) return null; - } catch { - return null; - } - - if (!commandExists("git")) return null; - const result = await runCommand([ - "git", - "rev-parse", - "--show-toplevel", - "--absolute-git-dir", - "--git-common-dir", - ], { cwd: leaseRoot, timeoutMs: 2000 }); - if (result.exitCode !== 0) return null; - const [repoRootRaw, gitDirRaw, commonDirRaw] = result.stdout.trim().split(/\r?\n/); - if (!repoRootRaw || !gitDirRaw || !commonDirRaw) return null; - - const repoRoot = resolve(repoRootRaw); - const gitDir = resolveFrom(leaseRoot, gitDirRaw); - const commonDir = resolveFrom(leaseRoot, commonDirRaw); - if (repoRoot !== resolve(leaseRoot)) return null; - try { - const physicalGitDir = realpathSync(gitDir); - const physicalCommonDir = realpathSync(commonDir); - const physicalWorktreesDir = realpathSync(join(commonDir, "worktrees")); - if (physicalGitDir === physicalWorktreesDir || !isInsidePath(physicalGitDir, physicalWorktreesDir)) return null; - if (dirname(physicalWorktreesDir) !== physicalCommonDir) return null; - - const commondirPointer = readFileSync(join(gitDir, "commondir"), "utf-8").trim(); - const gitdirPointer = readFileSync(join(gitDir, "gitdir"), "utf-8").trim(); - if (!commondirPointer || !gitdirPointer) return null; - if (realpathSync(resolveFrom(gitDir, commondirPointer)) !== physicalCommonDir) return null; - const expectedControlFile = resolve(controlFile); - const backPointer = resolveFrom(gitDir, gitdirPointer); - if (backPointer !== expectedControlFile) return null; - if (realpathSync(backPointer) !== realpathSync(expectedControlFile)) return null; - } catch { - return null; - } - return repoRoot; -} - -async function managedRepoRootForAbsoluteTarget( - targetPath: string, - repoRootCache: Map>, -): Promise { - if (!isAbsolute(targetPath)) return null; - const worktreesRoot = resolve(defaultWorktreesRoot()); - const target = resolve(targetPath); - if (target === worktreesRoot || !isInsidePath(target, worktreesRoot)) return null; - if (hasUnsafeTargetComponent(worktreesRoot, target)) return null; - - let physicalWorktreesRoot: string; - try { - physicalWorktreesRoot = realpathSync(worktreesRoot); - } catch { - return null; - } - - for (const leaseRoot of managedLeaseRootCandidates(worktreesRoot, target)) { - const repoRoot = await verifiedManagedRepoRoot(leaseRoot, target, physicalWorktreesRoot, repoRootCache); - if (repoRoot) return repoRoot; - } - return null; -} - -async function verifiedManagedRepoRoot( - leaseRoot: string, - target: string, - physicalWorktreesRoot: string, - repoRootCache: Map>, -): Promise { - let repoRootPromise = repoRootCache.get(leaseRoot); - if (!repoRootPromise) { - repoRootPromise = verifiedLinkedWorktreeRoot(leaseRoot); - repoRootCache.set(leaseRoot, repoRootPromise); - } - const repoRoot = await repoRootPromise; - if (!repoRoot) return null; - const resolvedRepoRoot = resolve(repoRoot); - if (resolvedRepoRoot !== resolve(leaseRoot)) return null; - try { - const physicalRepoRoot = realpathSync(resolvedRepoRoot); - if (physicalRepoRoot === physicalWorktreesRoot || !isInsidePath(physicalRepoRoot, physicalWorktreesRoot)) return null; - const probe = dirname(target); - let existingProbe = probe; - while (true) { - try { - lstatSync(existingProbe); - break; - } catch (error) { - if (!isMissingPathError(error)) return null; - } - const parent = dirname(existingProbe); - if (parent === existingProbe || !isInsidePath(parent, resolvedRepoRoot)) return null; - existingProbe = parent; - } - const physicalProbe = realpathSync(existingProbe); - const missingSuffix = relative(existingProbe, target); - if (!missingSuffix || missingSuffix === ".." || missingSuffix.startsWith(`..${sep}`) || isAbsolute(missingSuffix)) return null; - const physicalTarget = existsSync(target) - ? realpathSync(target) - : resolve(physicalProbe, missingSuffix); - if (physicalTarget === physicalRepoRoot || !isInsidePath(physicalTarget, physicalRepoRoot)) return null; - } catch { - return null; - } - return resolvedRepoRoot; -} - -function threatensRule(targetPath: string, rule: ProtectedPathRule, currentManagedRepoRoot: string | null): boolean { - if (shouldSkipHasnaTreeRule(targetPath, rule, currentManagedRepoRoot)) return false; - if (pathHasGlob(targetPath)) return globThreatensRule(targetPath, rule); - return threatensProtectedPath(targetPath, rule); -} - -function mutatesRule(targetPath: string, rule: ProtectedPathRule, currentManagedRepoRoot: string | null): boolean { - if (shouldSkipHasnaTreeRule(targetPath, rule, currentManagedRepoRoot)) return false; - return mutatesProtectedPath(targetPath, rule); -} - -interface DestructiveShellTarget { - path: string; - operation: string; - /** Same target with every shell expansion collapsed to empty; see emptyExpansionCollapse. */ - collapsed?: string; - /** Target of a command sent to another host, so relative paths cannot be resolved here. */ - remote?: boolean; - /** Working directory in effect for this target, after any `cd` earlier in the command. */ - baseCwd?: string; -} - -// `$PWD`, `${PWD}`, `$(pwd)` and `` `pwd` `` all stand for the working directory the guard is -// already tracking. They are certified non-empty, so no collapse fires - which left them as -// opaque path components matching no protected root, and `rm -rf "$PWD"/*` was allowed where -// the identical `rm -rf *` blocked. -const PWD_EXPANSION = /\$\{PWD\}|\$PWD|\$\(\s*pwd\s*\)|`\s*pwd\s*`/g; - -function substituteWorkingDirectory(path: string, cwd: string): string { - return PWD_EXPANSION.test(path) ? path.replace(PWD_EXPANSION, cwd) : path; -} - -function destructiveTarget( - path: string, - operation: string, - nonEmptyNames: ReadonlySet = new Set() -): DestructiveShellTarget { - const collapsed = emptyExpansionCollapse(path, nonEmptyNames); - return collapsed === null ? { path, operation } : { path, operation, collapsed }; -} - -function rmCommandTargets(command: string): DestructiveShellTarget[] { - const targets: DestructiveShellTarget[] = []; - for (const segment of splitShellSegments(command)) { - const tokens = shellWords(segment); - const rmIndex = tokens.findIndex((token) => token === "rm" || token.endsWith("/rm")); - if (rmIndex === -1) continue; - - let recursive = false; - let force = false; - let afterOptions = false; - const segmentTargets: string[] = []; - - for (let i = rmIndex + 1; i < tokens.length; i += 1) { - const token = tokens[i]; - if (!afterOptions && token === "--") { - afterOptions = true; - continue; - } - if (!afterOptions && token.startsWith("--")) { - if (token === "--recursive" || token === "--dir") recursive = true; - if (token === "--force") force = true; - continue; - } - if (!afterOptions && /^-[A-Za-z]+$/.test(token)) { - if (token.includes("r") || token.includes("R")) recursive = true; - if (token.includes("f")) force = true; - continue; - } - segmentTargets.push(token); - } - - if (recursive) { - targets.push(...segmentTargets.map((path) => destructiveTarget(path, force ? "rm -rf" : "rm -r"))); - } - } - return targets; -} - -const RSYNC_OPTIONS_WITH_VALUE = new Set([ - "-e", - "--rsh", - "--exclude", - "--exclude-from", - "--include", - "--include-from", - "--filter", - "--files-from", - "--rsync-path", - "--out-format", - "--log-file", - "--password-file", - "--backup-dir", - "--partial-dir", - "--compare-dest", - "--copy-dest", - "--link-dest", -]); - -function optionTakesValue(token: string, options: Set): boolean { - if (options.has(token)) return true; - const eq = token.indexOf("="); - return eq === -1 ? false : options.has(token.slice(0, eq)); -} - -function rsyncDeleteTargets(command: string): DestructiveShellTarget[] { - const targets: DestructiveShellTarget[] = []; - for (const segment of splitShellSegments(command)) { - const tokens = shellWords(segment); - const rsyncIndex = tokens.findIndex((token) => token === "rsync" || token.endsWith("/rsync")); - if (rsyncIndex === -1) continue; - - let hasDelete = false; - let afterOptions = false; - const operands: string[] = []; - - for (let i = rsyncIndex + 1; i < tokens.length; i += 1) { - const token = tokens[i]; - if (!afterOptions && token === "--") { - afterOptions = true; - continue; - } - if (!afterOptions && (token === "--delete" || token.startsWith("--delete-"))) { - hasDelete = true; - continue; - } - if (!afterOptions && token.startsWith("-")) { - if (optionTakesValue(token, RSYNC_OPTIONS_WITH_VALUE) && !token.includes("=")) i += 1; - continue; - } - operands.push(token); - } - - if (hasDelete && operands.length > 0) { - targets.push(destructiveTarget(operands[operands.length - 1], "rsync --delete")); - } - } - return targets; -} - -function findDestructiveTargets(command: string): DestructiveShellTarget[] { - const targets: DestructiveShellTarget[] = []; - for (const segment of splitShellSegments(command)) { - const tokens = shellWords(segment); - const findIndex = tokens.findIndex((token) => token === "find" || token.endsWith("/find")); - if (findIndex === -1) continue; - - let hasDelete = false; - let hasExecRm = false; - const roots: string[] = []; - - for (let i = findIndex + 1; i < tokens.length; i += 1) { - const token = tokens[i]; - if (token === "-H" || token === "-L" || token === "-P") continue; - if (token === "-O") { - i += 1; - continue; - } - if (token.startsWith("-") || token === "!" || token === "(" || token === ")") break; - roots.push(token); - } - - for (let i = findIndex + 1; i < tokens.length; i += 1) { - const token = tokens[i]; - if (token === "-delete") hasDelete = true; - if (token === "-exec" || token === "-execdir") { - const next = tokens[i + 1]; - if (next === "rm" || next?.endsWith("/rm")) hasExecRm = true; - } - } - - if (hasDelete || hasExecRm) { - targets.push(...(roots.length > 0 ? roots : ["."]).map((path) => destructiveTarget( - path, - hasDelete ? "find -delete" : "find -exec rm" - ))); - } - } - return targets; -} - -function gitTargetCwdFromTokens(tokens: string[], baseCwd: string): { gitIndex: number; commandIndex: number; targetCwd: string } | null { - const gitIndex = tokens.findIndex(isGitToken); - if (gitIndex === -1) return null; - - let i = gitIndex + 1; - let cwd = resolve(baseCwd); - let gitDir: string | undefined; - let workTree: string | undefined; - - while (i < tokens.length) { - const token = tokens[i]; - - const cDir = shortOptionValue(token, tokens[i + 1], "-C"); - if (cDir) { - if (cDir.value) cwd = resolveFrom(cwd, cDir.value); - i += cDir.consumed; - continue; - } - - const config = shortOptionValue(token, tokens[i + 1], "-c"); - if (config) { - i += config.consumed; - continue; - } - - const gitDirValue = optionValue(token, tokens[i + 1], "--git-dir"); - if (gitDirValue) { - if (gitDirValue.value) gitDir = resolveFrom(cwd, gitDirValue.value); - i += gitDirValue.consumed; - continue; - } - - const workTreeValue = optionValue(token, tokens[i + 1], "--work-tree"); - if (workTreeValue) { - if (workTreeValue.value) workTree = resolveFrom(cwd, workTreeValue.value); - i += workTreeValue.consumed; - continue; - } - - const namespaceValue = optionValue(token, tokens[i + 1], "--namespace"); - if (namespaceValue) { - i += namespaceValue.consumed; - continue; - } - - const execPathValue = optionValue(token, tokens[i + 1], "--exec-path"); - if (execPathValue) { - i += execPathValue.consumed; - continue; - } - - if (token === "--config-env") { - i += tokens[i + 1] === undefined ? 1 : 2; - continue; - } - - if (token === "--") { - i += 1; - continue; - } - - if (token.startsWith("-")) { - i += 1; - continue; - } - - const targetCwd = workTree || (gitDir ? (gitDir.endsWith(`${sep}.git`) || gitDir.endsWith("/.git") ? dirname(gitDir) : gitDir) : cwd); - return { gitIndex, commandIndex: i, targetCwd }; - } - - return null; -} - -function gitDestructiveTargets(command: string, baseCwd: string): DestructiveShellTarget[] { - const targets: DestructiveShellTarget[] = []; - for (const segment of splitShellSegments(command)) { - const tokens = shellWords(segment); - const git = gitTargetCwdFromTokens(tokens, baseCwd); - if (!git) continue; - - const commandName = tokens[git.commandIndex]; - if (commandName === "reset" && tokens.slice(git.commandIndex + 1).includes("--hard")) { - targets.push({ path: git.targetCwd, operation: "git reset --hard" }); - continue; - } - - if (commandName !== "clean") continue; - - let force = false; - let recursive = false; - const pathspecs: string[] = []; - for (let i = git.commandIndex + 1; i < tokens.length; i += 1) { - const token = tokens[i]; - if (token === "--") continue; - if (token === "-f" || token === "--force") { - force = true; - continue; - } - if (token === "-d") { - recursive = true; - continue; - } - if (/^-[A-Za-z]+$/.test(token)) { - if (token.includes("f")) force = true; - if (token.includes("d")) recursive = true; - continue; - } - if (token.startsWith("-")) continue; - pathspecs.push(token); - } - - if (force && recursive) { - targets.push(...(pathspecs.length > 0 ? pathspecs : ["."]).map((path) => ({ - path: resolveFrom(git.targetCwd, path), - operation: "git clean -xfd", - }))); - } - } - return targets; -} - -const SHELL_INTERPRETERS = new Set(["sh", "bash", "zsh", "dash", "ksh", "ash", "mksh", "busybox"]); - -// Also take a script via `-c`, but with a username operand in front of the flag. -const USER_SWITCH_COMMANDS = new Set(["su", "runuser"]); - -// ssh options that consume the following argument, so the first bare operand really is the host. -const SSH_OPTIONS_WITH_VALUE = new Set([ - "-B", "-b", "-c", "-D", "-E", "-e", "-F", "-I", "-i", "-J", "-L", "-l", "-m", - "-O", "-o", "-P", "-p", "-Q", "-R", "-S", "-W", "-w", -]); - -interface ShellCommandLayer { - command: string; - /** True once the layer is being executed on another host via ssh. */ - remote: boolean; -} - -function commandName(token: string): string { - return token.includes("/") ? token.slice(token.lastIndexOf("/") + 1) : token; -} - -function isShellInterpreterToken(token: string): boolean { - return SHELL_INTERPRETERS.has(commandName(token)); -} - -// Shell options that consume the following word, so its value is not mistaken for the script -// operand. Without this, `bash -o errexit -c '...'` reads `errexit` as the script file and the -// `-c` script is never scanned. -const SHELL_OPTIONS_WITH_VALUE = new Set(["-o", "+o", "--rcfile", "--init-file"]); - -/** - * Script passed via `-c`. For a shell, the first bare operand is the script *file* and the - * scan stops there; `su`/`runuser` take a username operand first, so one is skipped. - */ -function interpreterScriptFrom(tokens: string[], shellIndex: number, allowedOperands = 0): string | null { - let operands = 0; - for (let i = shellIndex + 1; i < tokens.length; i += 1) { - const token = tokens[i]; - // -c, and combined short forms such as -lc / -euxc. - if (/^-[A-Za-z]*c$/.test(token)) return tokens[i + 1] ?? null; - if (SHELL_OPTIONS_WITH_VALUE.has(token)) { - i += 1; - continue; - } - if (!token.startsWith("-")) { - operands += 1; - if (operands > allowedOperands) return null; - } - } - return null; -} - -/** - * Bodies of `$( … )` and backtick substitutions, as scripts in their own right. - * - * Required because the tokenizer treats substitutions atomically so the collapse rule can see - * them whole. Without feeding the bodies back in, `echo $(rm -rf /*)` contains no `rm` token - * at all and every rule misses it - the delete runs, its output is simply discarded. - */ -function substitutionBodies(segment: string, onTruncated?: () => void): string[] { - const bodies: string[] = []; - const visit = (text: string, depth: number): void => { - // Exhausting this bound must not silently drop a delete: `${x:-${x:- … $(rm -rf /*)}}` - // nested past the old hardcoded 4 was never classified at all. - if (depth > MAX_EXPANSION_NESTING) { - onTruncated?.(); - return; - } - for (const expansion of findExpansions(text)) { - if (expansion.text.startsWith("$(") || expansion.text.startsWith("`")) { - const body = (expansion.text.startsWith("`") - ? expansion.text.slice(1, -1) - : expansion.text.slice(2, -1)).trim(); - if (body.length > 0) { - bodies.push(body); - visit(body, depth + 1); - } - continue; - } - // `${x:-$(rm -rf /*)}` runs the substitution when x is unset. findExpansions returns - // the outer ${...} and swallows the inner one, so the body has to be re-scanned. - if (expansion.text.startsWith("${")) visit(expansion.text.slice(2, -1), depth + 1); - } - }; - visit(segment, 0); - return bodies; -} - -function sshRemoteCommandFrom(tokens: string[], sshIndex: number): string | null { - for (let i = sshIndex + 1; i < tokens.length; i += 1) { - const token = tokens[i]; - if (token === "--") continue; - if (token.startsWith("-")) { - if (SSH_OPTIONS_WITH_VALUE.has(token)) i += 1; - continue; - } - // First bare operand is [user@]host; everything after it is the remote command. - const remote = tokens.slice(i + 1).join(" ").trim(); - return remote.length > 0 ? remote : null; - } - return null; -} - -/** - * Scripts this command hands to another interpreter or to another host. - * - * Required, not optional: the realized 2026-07-24 incident arrived as - * `ssh station02 bash -c '...'`, and the `rm` token only exists inside the quoted script. - * A scan of the outer command alone sees `ssh`, `bash` and a single opaque operand. - */ -function isSshToken(token: string): boolean { - return token === "ssh" || token.endsWith("/ssh"); -} - -function wrappedShellLayers(command: string, remote: boolean, onTruncated?: () => void): ShellCommandLayer[] { - const layers: ShellCommandLayer[] = []; - for (const segment of splitShellSegments(command)) { - const tokens = shellWords(segment); - // `ssh host bash -c '...'`: everything after the ssh token executes on the other machine. - let sshSeen = false; - for (let i = 0; i < tokens.length; i += 1) { - const token = tokens[i]; - if (isShellInterpreterToken(token) || USER_SWITCH_COMMANDS.has(commandName(token))) { - const allowedOperands = USER_SWITCH_COMMANDS.has(commandName(token)) ? 1 : 0; - const script = interpreterScriptFrom(tokens, i, allowedOperands); - if (script) layers.push({ command: script, remote: remote || sshSeen }); - continue; - } - if (token === "eval") { - const script = tokens.slice(i + 1).join(" ").trim(); - if (script) layers.push({ command: script, remote: remote || sshSeen }); - continue; - } - if (isSshToken(token)) { - sshSeen = true; - const script = sshRemoteCommandFrom(tokens, i); - if (script) layers.push({ command: script, remote: true }); - } - } - - // A substitution body executes wherever it appears, including in assignments and in - // arguments to commands that do nothing with the result. - for (const body of substitutionBodies(segment, onTruncated)) { - layers.push({ command: body, remote: remote || sshSeen }); - } - } - return layers; -} - -const MAX_WRAPPER_DEPTH = 8; -const MAX_SHELL_LAYERS = 256; - -function shellCommandLayers(command: string): { layers: ShellCommandLayer[]; truncated: boolean } { - const layers: ShellCommandLayer[] = [{ command, remote: false }]; - const seen = new Set([command]); - let frontier: ShellCommandLayer[] = layers; - let truncated = false; - - for (let depth = 0; depth < MAX_WRAPPER_DEPTH; depth += 1) { - const next: ShellCommandLayer[] = []; - for (const layer of frontier) { - for (const inner of wrappedShellLayers(layer.command, layer.remote, () => { truncated = true; })) { - if (seen.has(inner.command)) continue; - if (layers.length + next.length >= MAX_SHELL_LAYERS) { - truncated = true; - continue; - } - seen.add(inner.command); - next.push(inner); - } - } - if (next.length === 0) break; - layers.push(...next); - frontier = next; - // More wrappers remain below the depth limit. - if (depth === MAX_WRAPPER_DEPTH - 1 && next.some((layer) => wrappedShellLayers(layer.command, layer.remote).length > 0)) { - truncated = true; - } - } - - return { layers, truncated }; -} - -// Verbs whose presence makes an unanalysable command unsafe to wave through. -// `rm` followed ANYWHERE by a recursive flag. Anchoring it to the very next token missed -// `rm -f -r /*`, `rm -v -f -r /*`, `rm --one-file-system -rf /*` and `rm -rf`, each of -// which sailed past the oversized-command gate unanalysed. -const DESTRUCTIVE_VERB = /(?:^|[^\w.-])(?:[\w/.-]*\/)?(?:rm\b[^;&|\n]*?(?:\s-[A-Za-z]*[rR][A-Za-z]*(?=[\s=;&|]|$)|\s--recursive\b|\s--dir\b)|rsync\s[^;&|]*--delete|find\s[^;&|]*(?:-delete|-execdir?\s)|git\s[^;&|]*(?:clean\s+-\S*[fd]|reset\s+--hard))/; - -/** - * A command too deeply wrapped or too wide to analyse within the caps is refused when it - * contains a destructive verb, instead of being allowed by default. - * - * The caps exist so a pathological command cannot stall the hook past its 20s timeout - and - * a timed-out hook fails open. But dropping work silently turns "too complex to analyse" - * into "allowed", which is the same passes-silently-while-protecting-nothing failure this - * guard exists to prevent. Padding with 32 dummy `sh -c` wrappers pushed the real delete - * past the cap and it returned continue. - */ -function truncatedAnalysisBlockReason(command: string): string | null { - if (!DESTRUCTIVE_VERB.test(command)) return null; - return [ - "Blocked: this command nests more shell wrappers than the safety guard can analyse,", - "and it contains a recursive delete. The guard refuses rather than guess, because an", - "unanalysable delete is exactly the shape that destroyed a machine on 2026-07-24.", - "Run the delete directly instead of through nested bash -c / ssh / eval wrappers,", - "with a literal, non-empty target path.", - ].join(" "); -} - -/** - * Remote layers run against another machine's filesystem, so a relative or cwd-derived - * target here would be a guess. Absolute targets (including `~` / `$HOME` forms, which the - * fleet shares) and empty-collapse targets (always absolute by construction) still apply. - */ -function keepRemoteTarget(target: DestructiveShellTarget): boolean { - return target.collapsed !== undefined || isAbsolute(expandHome(target.path)); -} - -interface CommandChunk { - segment: string; - /** Index of this segment in the layer, so assignment visibility can be ordered. */ - segmentIndex: number; - /** Working directories this segment may run in: the tracked cwd, plus the cwd a `cd` - * whose operand collapsed to empty would have left behind. */ - cwds: string[]; - /** An absolute `cd` inside this layer fixed the directory, so it is known even remotely. */ - explicitCwd: boolean; -} - -const MAX_CWD_VARIANTS = 4; -// Linux PATH_MAX. A tracked cwd longer than this cannot correspond to a real directory. -const MAX_TRACKED_CWD_LENGTH = 4096; -// Beyond this many `cd`s the guard stops modelling the shell and fails closed; see below. -const MAX_CD_OPERATIONS = 2000; -// Far above any command a person or agent writes; below the size where tokenizing alone -// exceeds the hook's 20s budget. -// Measured on this file's own paths: 1 MB -> 264ms, 16 MB -> 3.5s, 64 MB -> 14.3s against a -// 20s budget. The previous 1 MB threshold bought nothing and cost the fail-closed property. -const MAX_ANALYSABLE_COMMAND_LENGTH = 32_000_000; - -/** - * Raised whenever the guard stops being able to model the command exactly. - * - * Every bound in this file must funnel through here. Three bounds added in one round each - * invented their own fallback - skip the operand, keep the last directory, add `/` to the - * candidate set - and all three turned into root wipes, because "I cannot model this" was - * quietly answered as "so carry on". A degraded analysis carrying a recursive delete is - * refused instead. - */ -interface AnalysisState { - degraded: boolean; -} - -/** - * Segments of one layer paired with the working directories in effect when they run. - * - * Without this, `cd / && rm -rf *` reads as a glob over wherever the agent started, which is - * the cheapest possible way around a guard that only inspects the literal target. The - * collapsed variant covers `cd "$(cmd)"/ && rm -rf ./*`, which is the incident's shape moved - * one command to the left. - */ -function cwdTrackedSegments(command: string, baseCwd: string, nonEmptyNames: ReadonlySet, analysis: AnalysisState): CommandChunk[] { - const chunks: CommandChunk[] = []; - const home = process.env.HOME || homedir(); - // One entry per subshell nesting depth. A `cd` inside `( … )` DOES apply to the rest of - // that subshell - it just does not escape to the parent - so skipping isolated `cd` - // outright left `(cd / && rm -rf *)`, the standard "cd without moving my shell" idiom, - // completely unguarded. Depth 0 is the parent shell. - let cdOperations = 0; - let stack: Array<{ group: number; cwds: string[]; previous: string[]; dirStack: string[][]; explicit: boolean }> = [ - { group: 0, cwds: [baseCwd], previous: [baseCwd], dirStack: [], explicit: false }, - ]; - - const frameFor = (depth: number, group: number) => { - // Leaving a subshell discards everything it did. - if (stack.length > depth + 1) stack = stack.slice(0, depth + 1); - while (stack.length <= depth) { - const parent = stack[stack.length - 1]; - stack.push({ group, cwds: parent.cwds, previous: parent.previous, dirStack: [...parent.dirStack], explicit: parent.explicit }); - } - // A DIFFERENT group at the same depth is a sibling subshell - a separate process that - // never saw the previous one's `cd`. Reusing the frame let `(cd /elsewhere); (rm -rf *)` - // point the guard at an attacker-chosen directory while bash deleted the real cwd. - const frame = stack[depth]; - if (frame.group !== group) { - const parent = stack[depth - 1] ?? stack[0]; - stack[depth] = { group, cwds: parent.cwds, previous: parent.previous, dirStack: [...parent.dirStack], explicit: parent.explicit }; - } - return stack[depth]; - }; - - splitShellSegmentsDetailed(command).forEach(({ text: segment, depth, group, piped, shortCircuit }, segmentIndex) => { - const frame = frameFor(depth, group); - // A leading `{` from a brace group is not part of the command. - const tokens = shellWords(segment).filter((token, index) => !(index === 0 && (token === "{" || token === "}"))); - const verb = tokens[0]; - - // `popd` returns the shell to where `pushd` came from. It was unhandled, so the pushd - // target stayed as the tracked cwd for the rest of the command and - // `pushd /tmp; popd; rm -rf *` deleted the original directory unguarded. - if (verb === "popd") { - if (piped) return; - const restored = frame.dirStack.pop(); - if (restored) { - frame.previous = frame.cwds; - frame.cwds = restored; - frame.explicit = restored.some((dir) => dir !== baseCwd); - } - return; - } - - if (verb === "cd" || verb === "pushd") { - // A `cd` in a pipeline stage runs in its own process and moves nothing else. One - // reached via `&&`/`||` may not run at all: `cd /home/hasna; false && cd /tmp; - // rm -rf *` left the guard in /tmp while bash stayed in the home directory. - if (piped) return; - if (shortCircuit) { - analysis.degraded = true; - return; - } - // `pushd -n` records the directory WITHOUT moving the shell, so the tracked cwd must - // not follow it. Previously `-n` was read as the directory operand. - if (verb === "pushd" && tokens.includes("-n")) return; - // `pushd` saves the current directory before moving. - if (verb === "pushd") frame.dirStack.push(frame.cwds); - // Skip cd's own flags (-P, -L, --) to reach the directory operand. - let i = 1; - while (i < tokens.length && (tokens[i] === "-P" || tokens[i] === "-L" || tokens[i] === "-e" || tokens[i] === "-@" || tokens[i] === "--")) i += 1; - const operand = tokens[i]; - const priorCwds = frame.cwds; - cdOperations += 1; - // Both cd bounds below mark the analysis degraded rather than inventing a fallback. - // Skipping an over-long operand allowed `cd ////…(4200); rm -rf *`, and adding `/` to - // the candidate set caught only sweep targets - `cd /home/hasna; cd .x2000; cd ..; - // rm -rf hasna` still destroyed the Hasna home. - // Once the budget is spent the guard can no longer model a chain of RELATIVE cds. It - // must not simply keep the last known directory - that was the fail-open the PATH_MAX - // cap produced - so `/` joins the candidate set and any relative delete is judged - // against the filesystem root too. `rm -rf *` then blocks; `rm -rf dist` still resolves - // to /dist and passes. - // - // An ABSOLUTE cd is never dropped: it is a real landing the guard can still model - // exactly, and skipping it lost `cd ~` after a flood, which allowed `rm -rf .hasna`. - if (cdOperations > MAX_CD_OPERATIONS && !isAbsolute(expandHome(operand ?? ""))) { - analysis.degraded = true; - return; - } - - if (operand === undefined || operand === "~") { - frame.cwds = [home]; - frame.explicit = true; - } else if (operand === "-" || operand === "$OLDPWD" || operand === "${OLDPWD}") { - frame.cwds = frame.previous; - frame.explicit = frame.previous.some((dir) => dir !== baseCwd); - } else { - const collapsed = emptyExpansionCollapse(operand, nonEmptyNames); - const next = new Set(); - for (const current of frame.cwds) { - // Only operands that can GROW the path are capped. - // - // `..` and `.` shrink or hold, and skipping them froze the model permanently: after - // one crossing, `cd d0 … cd d1999; cd ..x2100` left the guard on the long path while - // bash had walked back to `/`, so `rm -rf *` was allowed. That was a fail-open - // introduced by the cap itself - the seventh time a bound in this file produced one. - // - // An absolute operand replaces the path, but resolving a 4KB operand 70k times still - // took 24s against the 20s timeout, so its own length is capped too. No real - // directory exceeds PATH_MAX, which is why this is a correctness bound and not just - // a throttle. - const expanded = expandHome(operand); - const shrinksOnly = /^[./]+$/.test(expanded); - const wouldGrow = !shrinksOnly && !isAbsolute(expanded); - if (wouldGrow && current.length > MAX_TRACKED_CWD_LENGTH) { - analysis.degraded = true; - next.add(current); - continue; - } - if (expanded.length > MAX_TRACKED_CWD_LENGTH) { - analysis.degraded = true; - next.add(current); - continue; - } - next.add(resolveFrom(current, operand)); - if (collapsed !== null) next.add(resolveFrom(current, collapsed)); - } - frame.cwds = [...next].slice(0, MAX_CWD_VARIANTS); - if (isAbsolute(expandHome(operand)) || collapsed !== null) frame.explicit = true; - } - frame.previous = priorCwds; - return; - } - - chunks.push({ segment, segmentIndex, cwds: frame.cwds, explicitCwd: frame.explicit }); - }); - return chunks; -} - -/** - * `for d in /*; do rm -rf "$d"; done` deletes the filesystem root one entry at a time while - * the delete's own target is an innocuous `$d`. Only exact `$VAR` / `${VAR}` targets bound by - * a `for ... in` in the same layer are substituted, so this cannot fire on unrelated commands. - */ -function forLoopBindings(command: string): Map { - const bindings = new Map(); - for (const segment of splitShellSegments(command)) { - const tokens = shellWords(segment); - const forIndex = tokens.indexOf("for"); - if (forIndex === -1) continue; - const name = tokens[forIndex + 1]; - if (!name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) continue; - if (tokens[forIndex + 2] !== "in") continue; - const words = tokens.slice(forIndex + 3).filter((token) => token !== "do"); - if (words.length > 0) bindings.set(name, words); - } - return bindings; -} - -function loopBoundWords(path: string, bindings: Map): string[] | null { - const match = path.match(/^\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?$/); - if (!match) return null; - return bindings.get(match[1]) ?? null; -} - -function destructiveShellTargets(command: string, cwd: string, analysis: AnalysisState): DestructiveShellTarget[] { - const targets: DestructiveShellTarget[] = []; - for (const layer of shellCommandLayers(command).layers) { - const bindings = forLoopBindings(layer.command); - - const assignments = assignmentWalker(layer.command); - - for (const chunk of cwdTrackedSegments(layer.command, cwd, new Set(), analysis)) { - const raw = [ - ...rmCommandTargets(chunk.segment), - ...rsyncDeleteTargets(chunk.segment), - ...findDestructiveTargets(chunk.segment), - ...gitDestructiveTargets(chunk.segment, chunk.cwds[0]), - ]; - if (raw.length === 0) continue; - // The walker advances one set IN PLACE - that is what removed the O(segments x names) - // copy, not this lookup being lazy. - const nonEmptyNames = assignments.at(chunk.segmentIndex); - - const expanded = raw.flatMap((target) => { - const words = loopBoundWords(target.path, bindings); - const paths = words ?? expandBraces(target.path); - return paths.length === 1 && paths[0] === target.path && words === null - ? [destructiveTarget(target.path, target.operation, nonEmptyNames)] - : paths.map((word) => destructiveTarget(word, target.operation, nonEmptyNames)); - }); - - const chunkTargets = expanded.flatMap((target) => - chunk.cwds.map((chunkCwd) => ({ - ...target, - path: substituteWorkingDirectory(target.path, chunkCwd), - baseCwd: chunkCwd, - })) - ); - - if (!layer.remote) { - targets.push(...chunkTargets); - continue; - } - targets.push( - ...chunkTargets - .filter((target) => chunk.explicitCwd || keepRemoteTarget(target)) - .map((target) => ({ ...target, remote: true })) - ); - } - } - return targets; -} - -function isApplyPatchTool(toolName: string): boolean { - return toolName === "apply_patch" || toolName === "ApplyPatch" || toolName === "functions.apply_patch"; -} - -function extractFileToolPaths(input: CodewithHookInput): Array<{ path: string; operation: string }> { - if (input.hook_event_name !== "PreToolUse") return []; - const toolName = typeof input.tool_name === "string" ? input.tool_name : ""; - const toolInput = input.tool_input && typeof input.tool_input === "object" ? input.tool_input : {}; - const paths: Array<{ path: string; operation: string }> = []; - - const addString = (value: unknown, operation: string) => { - if (typeof value === "string" && value.trim()) paths.push({ path: value, operation }); - }; - const addPathFields = (obj: Record, operation: string) => { - for (const key of ["file_path", "path", "target_path", "old_path", "new_path", "notebook_path"]) { - addString(obj[key], operation); - } - }; - - if (["Write", "Edit", "MultiEdit", "NotebookEdit"].includes(toolName)) { - addPathFields(toolInput, `${toolName} file mutation`); - } else if (/^(?:apply_patch|ApplyPatch|functions\.apply_patch|mcp__.*|.*(?:write|edit|delete|remove|move).*file.*)$/i.test(toolName)) { - addPathFields(toolInput, `${toolName} file mutation`); - } - - const patch = toolInput.patch ?? toolInput.input ?? toolInput.content ?? toolInput.command; - if (isApplyPatchTool(toolName) && typeof patch === "string") { - for (const line of patch.split(/\r?\n/)) { - const match = line.match(/^\*\*\* (?:(?:Add|Update|Delete) File|Move to): (.+)$/); - if (match?.[1]) paths.push({ path: match[1].trim(), operation: "apply_patch file mutation" }); - } - } - - const files = toolInput.files; - if (Array.isArray(files)) { - for (const item of files) { - if (typeof item === "string") addString(item, `${toolName} file mutation`); - else if (item && typeof item === "object") addPathFields(item as Record, `${toolName} file mutation`); - } - } - - return paths; -} - -function scopedBlockReason(operation: string, targetPath: string, rule: ProtectedPathRule, remote?: boolean): string { - // A POSIX class, equivalence class or collating symbol makes the pattern's extent - // unpinnable, so the guard treats it as matching anything. Saying only "targets /" would be - // wrong and confusing when the command reads `rm -rf /var[.]log` - the operator needs to - // know it was refused for being unanalysable, not for naming the filesystem root. - const unpinnable = /\[[:=.]/.test(targetPath) - ? [ - "This target contains a POSIX character class, equivalence class or collating symbol,", - "whose extent cannot be determined without replicating the shell exactly. It is therefore", - "treated as matching any name. Use a literal path, or a plain glob, if this was not intended.", - ] - : []; - return [ - `Blocked scoped dangerous operation: ${operation} targets ${targetPath}${remote ? " on a remote host" : ""}.`, - `Protected scope: ${rule.label} (${rule.root}).`, - ...unpinnable, - "This guard is scoped; destructive commands outside protected roots are not blocked.", - "Delete a specific named subdirectory instead of the root or its contents.", - ].join(" "); -} - -function collapseBlockReason( - operation: string, - rawTarget: string, - collapsedTarget: string, - rule: ProtectedPathRule, - remote?: boolean -): string { - return [ - `Blocked unsafe expansion in a destructive command: ${operation} target ${rawTarget}`, - `collapses to ${collapsedTarget}${remote ? " on a remote host" : ""} when the expansion returns empty`, - "(a command substitution that fails or prints nothing, or an unset variable),", - `which would destroy ${rule.label} (${rule.root}).`, - "This is the 2026-07-24 station02 failure: `bun pm cache` exits non-zero with empty stdout when no", - "package.json is found walking up from cwd, so `rm -rf \"$(bun pm cache)\"/*` ran as `rm -rf /*`.", - "Redirecting stderr does not help - it discards the diagnostic, not the path.", - "Safe alternative: resolve the path first, verify it is non-empty and not a protected root, then delete it,", - 'e.g. `dir="$(bun pm cache)" || exit 1; case "$dir" in /|"") exit 1;; esac; rm -rf -- "$dir"`.', - "This guard blocks the shape, not the command: any expansion immediately followed by `/` can collapse to the filesystem root.", - ].join(" "); -} - -export async function classifyDangerousOperation(input: CodewithHookInput): Promise { - if (input.hook_event_name !== "PreToolUse") return { block: false }; - const cwd = input.cwd || process.cwd(); - const { rules, workspaceRoots, currentManagedRepoRoot } = await protectedPathContextFor(input, cwd); - - if (input.tool_name === "Bash") { - const command = getCommand(input); - const analysis: AnalysisState = { degraded: false }; - - // A command large enough that merely tokenizing it blows the hook's 20s budget cannot be - // analysed at all, and a timed-out hook fails open. 70k repetitions of `cd /<4KB>` is a - // 280 MB string: no per-rule bound helps, because the cost is reading the input. Refuse it - // when it carries a recursive delete, rather than letting the timeout decide. - if (command.length > MAX_ANALYSABLE_COMMAND_LENGTH) { - // Decided here either way. Falling through to the full scan for a command with no - // delete in it still spent 46s tokenizing, which stalls every Bash call behind the - // hook's timeout for no benefit. - const reason = truncatedAnalysisBlockReason(command); - return reason ? { block: true, operation: "oversized command", reason } : { block: false }; - } - - if (shellCommandLayers(command).truncated) { - const reason = truncatedAnalysisBlockReason(command); - if (reason) { - return { block: true, operation: "unanalysable nested command", reason }; - } - } - - for (const target of destructiveShellTargets(command, cwd, analysis)) { - const targetCwd = target.baseCwd ?? cwd; - const targetPath = resolveFrom(targetCwd, target.path); - const rulesFor = (path: string) => { - const extraRule = workspaceRoots.map((root) => hasnaDivisionRuleFor(path, root)).find((rule): rule is ProtectedPathRule => Boolean(rule)); - return extraRule ? [...rules, extraRule] : rules; - }; - - for (const rule of rulesFor(targetPath)) { - if (threatensRule(targetPath, rule, currentManagedRepoRoot)) { - return { - block: true, - targetPath, - protectedPath: rule.root, - protectedLabel: rule.label, - operation: target.operation, - reason: scopedBlockReason(target.operation, targetPath, rule, target.remote), - }; - } - } - - // Second pass over the same target as the shell would produce it if every expansion - // came back empty. The managed-worktree escape hatch is not applied here: an empty - // collapse leaves the worktree entirely, so it can never be the intended target. - if (target.collapsed === undefined) continue; - const collapsedPath = resolveFrom(targetCwd, target.collapsed); - for (const rule of rulesFor(collapsedPath)) { - if (threatensRule(collapsedPath, rule, null)) { - return { - block: true, - targetPath: collapsedPath, - protectedPath: rule.root, - protectedLabel: rule.label, - operation: target.operation, - reason: collapseBlockReason(target.operation, target.path, collapsedPath, rule, target.remote), - }; - } - } - } - - // Raised during the scan above by any bound that stopped modelling the command - // exactly. Checked here rather than at each bound so there is ONE fail-closed answer: - // three bounds that each invented their own fallback all became root wipes. - if (analysis.degraded) { - const reason = truncatedAnalysisBlockReason(command); - if (reason) { - return { block: true, operation: "unanalysable command", reason }; - } - } - } - - const managedRepoRootCache = new Map>(); - const worktreesRoot = resolve(defaultWorktreesRoot()); - for (const candidate of extractFileToolPaths(input)) { - const targetPath = resolveFrom(cwd, candidate.path); - const hasUnsafeManagedComponent = isInsidePath(targetPath, worktreesRoot) - && hasUnsafeTargetComponent(worktreesRoot, targetPath); - const targetManagedRepoRoot = hasUnsafeManagedComponent - ? null - : await managedRepoRootForAbsoluteTarget(targetPath, managedRepoRootCache); - const exemptManagedRepoRoot = hasUnsafeManagedComponent - ? null - : targetManagedRepoRoot; - const extraRule = workspaceRoots.map((root) => hasnaDivisionRuleFor(targetPath, root)).find((rule): rule is ProtectedPathRule => Boolean(rule)); - const allRules = extraRule ? [...rules, extraRule] : rules; - for (const rule of allRules) { - if (mutatesRule(targetPath, rule, exemptManagedRepoRoot)) { - return { - block: true, - targetPath, - protectedPath: rule.root, - protectedLabel: rule.label, - operation: candidate.operation, - reason: scopedBlockReason(candidate.operation, targetPath, rule), - }; - } - } - } - - return { block: false }; -} - -export function cacheDir(): string { - return process.env.HASNA_HOOKS_CACHE_DIR || join(tmpdir(), "hasna-hooks-codewith"); -} - -export function cachePath(name: string): string { - return join(cacheDir(), `${name.replace(/[^a-zA-Z0-9_.-]/g, "-")}.json`); -} - -export function readCache(name: string, ttlMs: number): T | null { - try { - const path = cachePath(name); - if (!existsSync(path)) return null; - const parsed = JSON.parse(readFileSync(path, "utf-8")) as { ts: number; value: T }; - if (!parsed.ts || Date.now() - parsed.ts > ttlMs) return null; - return parsed.value; - } catch { - return null; - } -} - -export function writeCache(name: string, value: T): void { - try { - const path = cachePath(name); - mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, JSON.stringify({ ts: Date.now(), value }, null, 2)); - } catch {} -} - -export function safeJsonSummary(raw: string): string { - return cap(raw.replace(/((?:secret|token|password|api[_-]?key)\s*[:=]\s*)["']?[^"'\s,}]+/gi, "$1"), 6000); -} - -export function getAgentName(input: CodewithHookInput): string | null { - const agent = input.agent && typeof input.agent === "object" ? input.agent as Record : null; - const candidates = [ - process.env.HOOKS_AGENT_NAME, - process.env.CODEWITH_AGENT_NAME, - process.env.CONVERSATIONS_AGENT_ID, - typeof agent?.name === "string" ? agent.name : undefined, - typeof agent?.agent_id === "string" ? agent.agent_id : undefined, - typeof agent?.id === "string" ? agent.id : undefined, - typeof input.agent_id === "string" ? input.agent_id : undefined, - ]; - for (const candidate of candidates) { - if (candidate && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{1,80}$/.test(candidate)) return candidate; - } - return null; -} - -export function isTopLevelSession(input: CodewithHookInput): boolean { - if (process.env.CODEWITH_SUBAGENT === "1" || process.env.HASNA_SUBAGENT === "1") return false; - if (typeof input.agent_type === "string" && /subagent/i.test(input.agent_type)) return false; - return true; -} - -export function defaultWorktreesRoot(): string { - // Same home for every resolution in this file; see expandHome. - return process.env.HASNA_REPOS_WORKTREES_ROOT - || join(process.env.HOME || homedir(), ".hasna", "repos", "worktrees"); -} - -export function isInsidePath(child: string, parent: string): boolean { - const rel = relative(resolve(parent), resolve(child)); - return rel === "" || (!!rel && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)); -} - -/** - * Canonical managed-worktree path shape. - * - * Source of truth: Hasna Agent Operating Rules rule 8, as published by the - * @hasna/identities 0.4.4 global agent rules, verbatim: - * - * "must happen in a task-specific worktree at - * $HOME/.hasna/repos/worktrees// - * (repo name then worktree name; no station-id or machine segment, - * never flat under the worktrees root)" - * - * So, relative to the worktrees root, a compliant worktree root is exactly two - * segments deep: /. - */ -export const CANONICAL_WORKTREE_SEGMENTS = 2; - -/** - * Depth of the DEPRECATED station-id lease layout - * (`/-/wt_`) that predates rule 8. - * - * Read-only migration tolerance: it is never a compliant target shape, and it is - * never reported as `managed`. It is recognised only so that (a) guard messages - * can name it precisely and (b) the scoped dangerous-operation carve-out keeps - * working for worktrees created before the canonical shape was mandated. - */ -export const LEGACY_LEASE_WORKTREE_SEGMENTS = 3; - -// Any ordinary directory name, bounded by the filesystem's own limit rather than an -// allowlist — repo and worktree names are user data, and an over-narrow pattern would -// reject legitimate work (real fleet names include `_base`). Refused: a leading `.`, -// so `.`, `..` and `.git` can never be read as a segment; a leading `-`, so a segment -// can never read as an option in the remediation command; and control characters. -const WORKTREE_SEGMENT_PATTERN = /^[^.\-\/\x00-\x1f][^\/\x00-\x1f]{0,254}$/; -const LEGACY_LEASE_REPO_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*-[0-9a-fA-F]{7,16}$/; -const LEGACY_LEASE_ID_PATTERN = /^wt_[0-9a-fA-F]{16,64}$/; - -/** - * Whether the deprecated station-id lease layout still gets its migration tolerance. - * - * Default on, so the change does not strand worktrees created before rule 8. It is a - * kill switch, not a policy knob: the layout is non-compliant either way, and the - * tolerance only softens the verdict from blocked to warned. Set - * `HASNA_HOOKS_LEGACY_WORKTREE_TOLERANCE=0` once those worktrees are re-homed; the - * whole branch goes away after that. - * - * Known limitation while it is on: the tolerance keys off the path name, so a newly - * created worktree deliberately named to match also gets the warn tier. That is an - * opt-out from a guardrail by a cooperating agent, not a security boundary — the - * boundary is the provenance proof above, which applies to both tiers. - */ -export function legacyWorktreeToleranceEnabled(): boolean { - return process.env.HASNA_HOOKS_LEGACY_WORKTREE_TOLERANCE !== "0"; -} - -export type ManagedWorktreeLayout = "canonical" | "legacy-station-lease"; - -export interface ManagedWorktreeInfo { - managed: boolean; - /** The worktrees root the path was classified against. */ - root: string; - /** Recognised layout, set for compliant and for deprecated-but-recognised paths. */ - layout?: ManagedWorktreeLayout; - /** True when the layout is recognised but no longer permitted by rule 8. */ - deprecated?: boolean; - repo?: string; - worktree?: string; - /** Absolute path of the worktree root that owns `cwd`. */ - worktreeRoot?: string; - reason?: string; -} - -/** The canonical worktree path template, for user-facing guard messages. */ -export function canonicalWorktreeTemplate(root: string = defaultWorktreesRoot()): string { - return join(root, "", ""); -} - -/** - * Prove that `worktreeRoot` owns its own git history, synchronously. - * - * Shape is not evidence and neither is the mere presence of `.git`. A `.git` file is - * two lines of text: pointing it at a shared checkout's `.git` grafts a second working - * tree onto that checkout, so `git commit`/`git push` from the forged directory lands - * on the shared checkout — the exact outcome rule 10 forbids. So a `.git` file must - * carry real linked-worktree provenance: - * - * - its `gitdir:` target must live under `/worktrees/`, and - * - that target's `gitdir` back-pointer must resolve to this very control file. - * - * A `.git` directory is accepted only as a self-contained repository. A `commondir` - * grafts it onto another repository's history outright, and symlinked `objects` or - * `refs` graft it onto another repository's refs — reaching the same end state as a - * forged `.git` file without writing anything inside the victim. - * - * This is a structural proof only. It is deliberately close to, but not the same as, - * the async verifiedLinkedWorktreeRoot() used for the write carve-out, which is - * stricter still (regular-file control file, nlink === 1, worktrees dir directly - * under the common dir). - */ -function worktreeProvenanceReason(worktreeRoot: string): string | null { - const controlPath = join(worktreeRoot, ".git"); - let control; - try { - control = lstatSync(controlPath); - } catch { - return `${worktreeRoot} is not a git worktree root (no .git)`; - } - if (control.isSymbolicLink()) return `worktree .git is a symlink at ${worktreeRoot}`; - - if (control.isDirectory()) { - if (existsSync(join(controlPath, "commondir"))) { - return `worktree .git is grafted onto another repository at ${worktreeRoot}`; - } - if (!existsSync(join(controlPath, "HEAD"))) return `worktree .git is not a repository at ${worktreeRoot}`; - // A self-contained repository owns its object and ref storage. Symlinking either - // into another repository makes commits here land on that repository's refs. - for (const store of ["objects", "refs"]) { - let metadata; - try { - metadata = lstatSync(join(controlPath, store)); - } catch { - return `worktree .git is missing ${store} at ${worktreeRoot}`; - } - if (!metadata.isDirectory() || metadata.isSymbolicLink()) { - return `worktree .git ${store} is grafted onto another repository at ${worktreeRoot}`; - } - } - return null; - } - if (!control.isFile()) return `worktree .git is not a file or directory at ${worktreeRoot}`; - - try { - const pointer = readFileSync(controlPath, "utf-8").trim(); - const match = pointer.match(/^gitdir:\s*(.+)$/); - if (!match?.[1]) return `worktree .git is not a git worktree pointer at ${worktreeRoot}`; - const gitDir = resolveFrom(worktreeRoot, match[1].trim()); - const commonDir = resolveFrom(gitDir, readFileSync(join(gitDir, "commondir"), "utf-8").trim()); - const physicalGitDir = realpathSync(gitDir); - const physicalWorktreesDir = realpathSync(join(commonDir, "worktrees")); - if (physicalGitDir === physicalWorktreesDir || !isInsidePath(physicalGitDir, physicalWorktreesDir)) { - return `worktree .git points outside its repository's worktrees directory at ${worktreeRoot}`; - } - const backPointer = resolveFrom(gitDir, readFileSync(join(gitDir, "gitdir"), "utf-8").trim()); - if (realpathSync(backPointer) !== realpathSync(controlPath)) { - return `worktree .git is not registered by its repository at ${worktreeRoot}`; - } - } catch { - return `worktree .git provenance could not be verified at ${worktreeRoot}`; - } - return null; -} - -/** - * Verify that `//` is a real, non-symlinked, provenance-checked - * git worktree root. - * - * Path shape alone is not evidence: `//` has exactly the - * same shape as `//`, so without this check a `cd` into any - * subdirectory of a flat worktree would launder it into a compliant-looking path. - * Symlinks are refused at every level (hence lstat, not existsSync, which follows - * them) because a symlinked segment can aim a canonical-looking path at a shared - * checkout. - */ -function groundedWorktreeRootReason(root: string, segments: string[]): string | null { - let probe = resolve(root); - for (const segment of segments) { - probe = join(probe, segment); - let metadata; - try { - metadata = lstatSync(probe); - } catch { - return `no worktree exists at ${probe}`; - } - if (metadata.isSymbolicLink()) return `worktree path traverses a symlink at ${probe}`; - if (!metadata.isDirectory()) return `worktree path is not a directory at ${probe}`; - } - return worktreeProvenanceReason(probe); -} - -/** - * Classify a path against the canonical managed-worktree shape (rule 8). - * - * Accepted: a real git worktree root at `//`, - * and any path inside it. Rejected, each with a reason: paths outside the worktrees - * root, the root itself, flat single-segment worktrees, station-id/machine segments, - * deeper nesting, and canonical-shaped paths that are not actually a worktree root - * (invented, symlinked, or a subdirectory of a flat worktree). - */ -export function managedWorktreeInfo(cwd: string): ManagedWorktreeInfo { - const root = defaultWorktreesRoot(); - const canonical = canonicalWorktreeTemplate(root); - if (!isInsidePath(cwd, root)) return { managed: false, root, reason: "outside worktrees root" }; - - const parts = relative(resolve(root), resolve(cwd)).split(sep).filter(Boolean); - if (parts.length === 0) { - return { managed: false, root, reason: `path is the worktrees root itself; canonical worktrees live at ${canonical}` }; - } - if (parts.length < CANONICAL_WORKTREE_SEGMENTS) { - return { - managed: false, - root, - reason: `worktree is flat under the worktrees root, which rule 8 forbids; canonical shape is ${canonical}`, - }; - } - - const [repo, worktree] = parts; - for (const [label, segment] of [["repo-name", repo], ["worktree-name", worktree]] as const) { - if (!segment || !WORKTREE_SEGMENT_PATTERN.test(segment)) { - return { managed: false, root, reason: `${label} segment is malformed; canonical shape is ${canonical}` }; - } - } - - // A canonical classification must be grounded in a real worktree root at depth 2, - // never in path shape alone: at depth 2 the shape is ambiguous with a subdirectory - // of a forbidden flat worktree, and at any depth it is ambiguous with an invented - // or symlinked path. - const worktreeRoot = resolve(root, repo!, worktree!); - const rootReason = groundedWorktreeRootReason(root, [repo!, worktree!]); - if (!rootReason) { - return { managed: true, root, layout: "canonical", repo, worktree, worktreeRoot }; - } - - if (parts.length === CANONICAL_WORKTREE_SEGMENTS) { - return { managed: false, root, reason: `${rootReason}; canonical shape is ${canonical}` }; - } - - // Recognised at or inside a legacy lease root, mirroring how a canonical worktree - // covers its own subdirectories — an agent cwd'd into `src/` of a legacy worktree - // is in the same non-compliant worktree, and must get the same migration message. - // - // The migration tolerance grants a weaker verdict than "blocked", so it has to clear - // the same grounding as the canonical branch. Otherwise the lease name pattern is a - // forgery kit: two directories named to match would launder a symlinked or grafted - // path into a warn-and-allow. - if (legacyWorktreeToleranceEnabled() - && parts.length >= LEGACY_LEASE_WORKTREE_SEGMENTS - && LEGACY_LEASE_REPO_PATTERN.test(parts[1]!) - && LEGACY_LEASE_ID_PATTERN.test(parts[2]!)) { - // The layout has two historical variants: the checkout sits at the lease dir, or - // one level below it in a `repo/` child. Try both, nothing deeper. - for (const depth of [LEGACY_LEASE_WORKTREE_SEGMENTS, LEGACY_LEASE_WORKTREE_SEGMENTS + 1]) { - if (parts.length < depth) break; - const segments = parts.slice(0, depth); - if (groundedWorktreeRootReason(root, segments)) continue; - return { - managed: false, - root, - layout: "legacy-station-lease", - deprecated: true, - worktreeRoot: resolve(root, ...segments), - reason: `deprecated station-id lease layout /-/wt_; rule 8 forbids a station-id or machine segment — re-home to ${canonical}`, - }; - } - } - - return { - managed: false, - root, - reason: `worktree root is ${parts.length} segments under the worktrees root (station-id/machine segment or extra nesting); rule 8 requires the worktree to be created at exactly ${canonical}`, - }; -} - -export async function gitRepoRoot(cwd: string): Promise { - if (!commandExists("git")) return null; - const result = await runCommand(["git", "rev-parse", "--show-toplevel"], { cwd, timeoutMs: 2000 }); - if (result.exitCode !== 0) return null; - return result.stdout.trim() || null; -} - -export async function gitRemoteSlug(cwd: string): Promise { - if (!commandExists("git")) return null; - const result = await runCommand(["git", "remote", "get-url", "origin"], { cwd, timeoutMs: 2000 }); - if (result.exitCode !== 0) return null; - const remote = result.stdout.trim(); - if (!remote) return null; - const match = remote.match(/[:/]([^/:\s]+\/[^/\s]+?)(?:\.git)?$/); - return match?.[1] || null; -} - -/** `origin` normalised to the `host/org/name` form the repos CLI resolves exactly. */ -export async function gitRemoteHostSlug(cwd: string): Promise { - if (!commandExists("git")) return null; - const result = await runCommand(["git", "remote", "get-url", "origin"], { cwd, timeoutMs: 2000 }); - if (result.exitCode !== 0) return null; - const remote = result.stdout.trim().replace(/\.git$/, ""); - if (!remote) return null; - const match = remote.match(/^(?:[a-z+]+:\/\/)?(?:[^@/]+@)?([^/:\s]+)[:/](.+)$/i); - const host = match?.[1]; - const path = match?.[2]?.replace(/^\/+/, ""); - if (!host || !path || !/^[^/\s]+\/[^/\s]+$/.test(path)) return null; - return `${host}/${path}`; -} - -export interface CanonicalRepoIdentity { - /** The repo name that forms the `` segment of the canonical path. */ - name: string | null; - defaultBranch: string | null; -} - -/** - * Resolve the canonical repo name via the repos CLI, as rule 8 requires: - * "Locate repos with the repos CLI (`repos repo --json` for the exact - * lookup; never fuzzy `repos cd` or 'did you mean' output for targeting)". - * - * This matters because the repos-CLI name is frequently NOT the git remote - * basename — on this fleet 46 of 50 indexed repos differ (`open-hooks` is - * `github.com/hasna/hooks`, `open-mailery` is `.../emails`). Deriving the - * canonical path segment from the remote would send every agent to the wrong - * directory, so the remote is only ever used as the exact lookup key. - * - * `--remote host/org/name` is the exact-match form, so no fuzzy "did you mean" - * output can be mistaken for a hit. OSS-safe: a missing or failing repos CLI - * yields nulls and the caller falls back to local information. - */ -export async function canonicalRepoIdentity(cwd: string): Promise { - const empty: CanonicalRepoIdentity = { name: null, defaultBranch: null }; - if (!commandExists("repos")) return empty; - const remote = await gitRemoteHostSlug(cwd); - if (!remote) return empty; - // Hard ceiling on the lookup. runCommand's timeout kills the direct child but still - // awaits its pipes, which a forking CLI can hold open indefinitely; this hook sits on - // the PreToolUse path, so it must degrade to local information rather than stall. - const result = await Promise.race([ - runCommand(["repos", "repo", "--remote", remote, "--json"], { cwd, timeoutMs: 1000 }), - new Promise((done) => setTimeout(() => done(null), 1500).unref?.()), - ]); - if (!result || result.exitCode !== 0) return empty; - try { - const parsed = JSON.parse(result.stdout) as { name?: unknown; default_branch?: unknown; path?: unknown }; - const name = typeof parsed.name === "string" && parsed.name ? parsed.name : null; - const defaultBranch = typeof parsed.default_branch === "string" && parsed.default_branch - ? parsed.default_branch - : null; - - // The index holds worktree directories as first-class rows, so an exact remote - // match can resolve to a worktree rather than the repo. Such a row's name is a - // worktree name and its default_branch is that worktree's branch — both wrong for - // the canonical path. When the row lives under the worktrees root, the real repo - // name is its first segment there; the branch is not recoverable, so drop it. - const worktreesRoot = resolve(defaultWorktreesRoot()); - const rowPath = typeof parsed.path === "string" && parsed.path ? resolve(parsed.path) : null; - if (rowPath && isInsidePath(rowPath, worktreesRoot) && rowPath !== worktreesRoot) { - const segment = relative(worktreesRoot, rowPath).split(sep).filter(Boolean)[0]; - return { name: segment || null, defaultBranch: null }; - } - return { name, defaultBranch }; - } catch { - return empty; - } -} - -/** - * Remediation command for work happening outside a canonical worktree. - * - * Rule 8: create the worktree at `//`, - * named after the todos task where one exists, then `repos scan`. The repos CLI - * has no worktree verb, so `git worktree` is the creation path. - * - * `repo` must be a canonical repo name (see canonicalRepoIdentity) — never a - * remote slug, which names a different directory for most repos. - * - * This is the boundary where names become a command an operator may paste, so every - * interpolated value is validated here rather than trusted from its source: a repo - * name is attacker-influenced via the remote, and a task id is unvalidated hook input. - * Anything unsafe degrades to the explicit placeholder instead of being emitted. - */ -const SAFE_COMMAND_VALUE = /^[a-zA-Z0-9_][a-zA-Z0-9_.\/-]{0,120}$/; - -export function claimCommand(repo: string | null, taskId: string | null, defaultBranch: string | null = null): string { - // A repo name is one path segment: a slug would silently add a third segment. - const safeRepo = repo && SAFE_COMMAND_VALUE.test(repo) && !repo.includes("/") ? repo : null; - const safeTask = taskId && SAFE_COMMAND_VALUE.test(taskId) ? taskId : null; - const safeBase = defaultBranch && SAFE_COMMAND_VALUE.test(defaultBranch) ? defaultBranch : null; - - const repoName = safeRepo || ""; - const worktreeName = safeTask || ""; - const path = join(defaultWorktreesRoot(), repoName, worktreeName); - return `git worktree add -b ${worktreeName} ${path} origin/${safeBase || ""} && repos scan`; -} - -export function taskIdFrom(input: CodewithHookInput): string | null { - const candidates = [ - process.env.HASNA_TASK_ID, - process.env.TASK_ID, - process.env.CODEWITH_TASK_ID, - typeof input.task_id === "string" ? input.task_id : undefined, - ]; - return candidates.find(Boolean) || null; -} - -export function runIdFrom(input: CodewithHookInput): string | null { - const candidates = [ - process.env.HASNA_RUN_ID, - process.env.RUN_ID, - process.env.CODEWITH_RUN_ID, - typeof input.run_id === "string" ? input.run_id : undefined, - input.turn_id, - input.session_id, - ]; - return candidates.find((v): v is string => typeof v === "string" && v.length > 0) || null; -} - -export function redactGitleaksOutput(_stdout: string, _stderr: string): string { - return "Staged secrets scan found possible credential(s). Details redacted; run gitleaks locally to inspect."; -} +export type { CodewithHookInput, CodewithHookOutput, CommandResult } from "./codewith-native-common/base"; +export { + readInput, + respond, + warn, + cap, + commandExists, + runCommand, + getCommand, + isBashPreToolUse, + cacheDir, + cachePath, + readCache, + writeCache, + safeJsonSummary, + getAgentName, + isTopLevelSession, +} from "./codewith-native-common/base"; + +export type { GitCommandInfo } from "./codewith-native-common/git-command"; +export { + gitCommandInfo, + isGitCommitOrPush, + isGitPushOrCommitCommand, + isRiskyOperation, +} from "./codewith-native-common/git-command"; + +export type { DangerousOperationMatch } from "./codewith-native-common/protected-paths"; +export { + SYSTEM_PROTECTED_ROOTS, + globComponentMatches, +} from "./codewith-native-common/protected-paths"; +export { emptyExpansionCollapse } from "./codewith-native-common/shell-expansions"; +export { classifyDangerousOperation } from "./codewith-native-common/dangerous-operation"; + +export type { + ManagedWorktreeLayout, + ManagedWorktreeInfo, + CanonicalRepoIdentity, +} from "./codewith-native-common/worktrees"; +export { + defaultWorktreesRoot, + isInsidePath, + CANONICAL_WORKTREE_SEGMENTS, + LEGACY_LEASE_WORKTREE_SEGMENTS, + legacyWorktreeToleranceEnabled, + canonicalWorktreeTemplate, + managedWorktreeInfo, + gitRepoRoot, + gitRemoteSlug, + gitRemoteHostSlug, + canonicalRepoIdentity, + claimCommand, + taskIdFrom, + runIdFrom, + redactGitleaksOutput, +} from "./codewith-native-common/worktrees"; diff --git a/hooks/codewith-native-common/base.ts b/hooks/codewith-native-common/base.ts new file mode 100644 index 0000000..d497895 --- /dev/null +++ b/hooks/codewith-native-common/base.ts @@ -0,0 +1,184 @@ +import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync, writeSync } from "fs"; +import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from "path"; +import { homedir, tmpdir } from "os"; + +export interface CodewithHookInput { + session_id?: string; + cwd?: string; + hook_event_name?: string; + model?: string; + permission_mode?: string; + source?: string; + prompt?: string; + tool_name?: string; + tool_input?: Record; + tool_use_id?: string; + transcript_path?: string | null; + turn_id?: string; + last_assistant_message?: string | null; + stop_hook_active?: boolean; + agent_id?: string; + agent_type?: string; + agent?: unknown; + [key: string]: unknown; +} + +export interface CodewithHookOutput { + continue?: boolean; + decision?: "approve" | "block"; + reason?: string; + stopReason?: string; + suppressOutput?: boolean; + systemMessage?: string; + hookSpecificOutput?: { + hookEventName: "SessionStart" | "UserPromptSubmit" | "SubagentStart" | "PreToolUse"; + additionalContext?: string; + permissionDecision?: "allow" | "deny" | "ask"; + permissionDecisionReason?: string; + updatedInput?: unknown; + }; +} + +export interface CommandResult { + exitCode: number | null; + stdout: string; + stderr: string; + timedOut: boolean; +} + +export function readInput(): CodewithHookInput { + try { + const raw = readFileSync(0, "utf-8").trim(); + if (!raw) return {}; + return JSON.parse(raw) as CodewithHookInput; + } catch { + return {}; + } +} + +export function respond(output: CodewithHookOutput): void { + // Written synchronously: `process.stdout.write` is async on a pipe, so a verdict + // larger than the pipe buffer is silently truncated if the process exits before it + // drains — and a truncated verdict is unparseable, so the caller sees no decision. + const payload = `${JSON.stringify(output)}\n`; + try { + writeSync(1, payload); + } catch { + process.stdout.write(payload); + } +} + +export function warn(message: string): void { + process.stderr.write(`[hooks] ${message}\n`); +} + +export function cap(text: string, max = 6000): string { + if (text.length <= max) return text; + return `${text.slice(0, max)}\n[truncated ${text.length - max} bytes]`; +} + +export function commandExists(command: string, env: NodeJS.ProcessEnv = process.env): boolean { + const pathValue = env.PATH || ""; + for (const dir of pathValue.split(":")) { + if (!dir) continue; + if (existsSync(join(dir, command))) return true; + } + return false; +} + +export async function runCommand( + argv: string[], + options: { cwd?: string; timeoutMs?: number; env?: NodeJS.ProcessEnv } = {} +): Promise { + const timeoutMs = options.timeoutMs ?? 5000; + const env = options.env ?? process.env; + let proc: ReturnType | null = null; + let timedOut = false; + try { + proc = Bun.spawn(argv, { + cwd: options.cwd, + env, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const timer = setTimeout(() => { + timedOut = true; + try { proc?.kill(); } catch {} + }, timeoutMs); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited.catch(() => null), + ]); + clearTimeout(timer); + return { exitCode, stdout, stderr, timedOut }; + } catch (error) { + return { exitCode: null, stdout: "", stderr: error instanceof Error ? error.message : String(error), timedOut }; + } +} + +export function getCommand(input: CodewithHookInput): string { + const command = input.tool_input?.command; + return typeof command === "string" ? command : ""; +} + +export function isBashPreToolUse(input: CodewithHookInput): boolean { + return input.hook_event_name === "PreToolUse" && input.tool_name === "Bash"; +} + +export function cacheDir(): string { + return process.env.HASNA_HOOKS_CACHE_DIR || join(tmpdir(), "hasna-hooks-codewith"); +} + +export function cachePath(name: string): string { + return join(cacheDir(), `${name.replace(/[^a-zA-Z0-9_.-]/g, "-")}.json`); +} + +export function readCache(name: string, ttlMs: number): T | null { + try { + const path = cachePath(name); + if (!existsSync(path)) return null; + const parsed = JSON.parse(readFileSync(path, "utf-8")) as { ts: number; value: T }; + if (!parsed.ts || Date.now() - parsed.ts > ttlMs) return null; + return parsed.value; + } catch { + return null; + } +} + +export function writeCache(name: string, value: T): void { + try { + const path = cachePath(name); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify({ ts: Date.now(), value }, null, 2)); + } catch {} +} + +export function safeJsonSummary(raw: string): string { + return cap(raw.replace(/((?:secret|token|password|api[_-]?key)\s*[:=]\s*)["']?[^"'\s,}]+/gi, "$1"), 6000); +} + +export function getAgentName(input: CodewithHookInput): string | null { + const agent = input.agent && typeof input.agent === "object" ? input.agent as Record : null; + const candidates = [ + process.env.HOOKS_AGENT_NAME, + process.env.CODEWITH_AGENT_NAME, + process.env.CONVERSATIONS_AGENT_ID, + typeof agent?.name === "string" ? agent.name : undefined, + typeof agent?.agent_id === "string" ? agent.agent_id : undefined, + typeof agent?.id === "string" ? agent.id : undefined, + typeof input.agent_id === "string" ? input.agent_id : undefined, + ]; + for (const candidate of candidates) { + if (candidate && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{1,80}$/.test(candidate)) return candidate; + } + return null; +} + +export function isTopLevelSession(input: CodewithHookInput): boolean { + if (process.env.CODEWITH_SUBAGENT === "1" || process.env.HASNA_SUBAGENT === "1") return false; + if (typeof input.agent_type === "string" && /subagent/i.test(input.agent_type)) return false; + return true; +} + diff --git a/hooks/codewith-native-common/dangerous-operation.ts b/hooks/codewith-native-common/dangerous-operation.ts new file mode 100644 index 0000000..540cee1 --- /dev/null +++ b/hooks/codewith-native-common/dangerous-operation.ts @@ -0,0 +1,487 @@ +import { isAbsolute, resolve } from "path"; +import { homedir } from "os"; +import { getCommand, type CodewithHookInput } from "./base"; +import { + expandHome, + resolveFrom, + shellWords, + splitShellSegments, + splitShellSegmentsDetailed, +} from "./git-command"; +import { + expandBraces, + hasnaDivisionRuleFor, + protectedPathContextFor, + type DangerousOperationMatch, + type ProtectedPathRule, +} from "./protected-paths"; +import { assignmentWalker, emptyExpansionCollapse } from "./shell-expansions"; +import { + hasUnsafeTargetComponent, + managedRepoRootForAbsoluteTarget, + mutatesRule, + threatensRule, +} from "./managed-targets"; +import { + destructiveTarget, + findDestructiveTargets, + gitDestructiveTargets, + keepRemoteTarget, + rmCommandTargets, + rsyncDeleteTargets, + shellCommandLayers, + substituteWorkingDirectory, + truncatedAnalysisBlockReason, + type DestructiveShellTarget, +} from "./destructive-targets"; +import { defaultWorktreesRoot, isInsidePath } from "./worktrees"; + +interface CommandChunk { + segment: string; + /** Index of this segment in the layer, so assignment visibility can be ordered. */ + segmentIndex: number; + /** Working directories this segment may run in: the tracked cwd, plus the cwd a `cd` + * whose operand collapsed to empty would have left behind. */ + cwds: string[]; + /** An absolute `cd` inside this layer fixed the directory, so it is known even remotely. */ + explicitCwd: boolean; +} + +const MAX_CWD_VARIANTS = 4; +// Linux PATH_MAX. A tracked cwd longer than this cannot correspond to a real directory. +const MAX_TRACKED_CWD_LENGTH = 4096; +// Beyond this many `cd`s the guard stops modelling the shell and fails closed; see below. +const MAX_CD_OPERATIONS = 2000; +// Far above any command a person or agent writes; below the size where tokenizing alone +// exceeds the hook's 20s budget. +// Measured on this file's own paths: 1 MB -> 264ms, 16 MB -> 3.5s, 64 MB -> 14.3s against a +// 20s budget. The previous 1 MB threshold bought nothing and cost the fail-closed property. +const MAX_ANALYSABLE_COMMAND_LENGTH = 32_000_000; + +/** + * Raised whenever the guard stops being able to model the command exactly. + * + * Every bound in this file must funnel through here. Three bounds added in one round each + * invented their own fallback - skip the operand, keep the last directory, add `/` to the + * candidate set - and all three turned into root wipes, because "I cannot model this" was + * quietly answered as "so carry on". A degraded analysis carrying a recursive delete is + * refused instead. + */ +interface AnalysisState { + degraded: boolean; +} + +/** + * Segments of one layer paired with the working directories in effect when they run. + * + * Without this, `cd / && rm -rf *` reads as a glob over wherever the agent started, which is + * the cheapest possible way around a guard that only inspects the literal target. The + * collapsed variant covers `cd "$(cmd)"/ && rm -rf ./*`, which is the incident's shape moved + * one command to the left. + */ +function cwdTrackedSegments(command: string, baseCwd: string, nonEmptyNames: ReadonlySet, analysis: AnalysisState): CommandChunk[] { + const chunks: CommandChunk[] = []; + const home = process.env.HOME || homedir(); + // One entry per subshell nesting depth. A `cd` inside `( … )` DOES apply to the rest of + // that subshell - it just does not escape to the parent - so skipping isolated `cd` + // outright left `(cd / && rm -rf *)`, the standard "cd without moving my shell" idiom, + // completely unguarded. Depth 0 is the parent shell. + let cdOperations = 0; + let stack: Array<{ group: number; cwds: string[]; previous: string[]; dirStack: string[][]; explicit: boolean }> = [ + { group: 0, cwds: [baseCwd], previous: [baseCwd], dirStack: [], explicit: false }, + ]; + + const frameFor = (depth: number, group: number) => { + // Leaving a subshell discards everything it did. + if (stack.length > depth + 1) stack = stack.slice(0, depth + 1); + while (stack.length <= depth) { + const parent = stack[stack.length - 1]; + stack.push({ group, cwds: parent.cwds, previous: parent.previous, dirStack: [...parent.dirStack], explicit: parent.explicit }); + } + // A DIFFERENT group at the same depth is a sibling subshell - a separate process that + // never saw the previous one's `cd`. Reusing the frame let `(cd /elsewhere); (rm -rf *)` + // point the guard at an attacker-chosen directory while bash deleted the real cwd. + const frame = stack[depth]; + if (frame.group !== group) { + const parent = stack[depth - 1] ?? stack[0]; + stack[depth] = { group, cwds: parent.cwds, previous: parent.previous, dirStack: [...parent.dirStack], explicit: parent.explicit }; + } + return stack[depth]; + }; + + splitShellSegmentsDetailed(command).forEach(({ text: segment, depth, group, piped, shortCircuit }, segmentIndex) => { + const frame = frameFor(depth, group); + // A leading `{` from a brace group is not part of the command. + const tokens = shellWords(segment).filter((token, index) => !(index === 0 && (token === "{" || token === "}"))); + const verb = tokens[0]; + + // `popd` returns the shell to where `pushd` came from. It was unhandled, so the pushd + // target stayed as the tracked cwd for the rest of the command and + // `pushd /tmp; popd; rm -rf *` deleted the original directory unguarded. + if (verb === "popd") { + if (piped) return; + const restored = frame.dirStack.pop(); + if (restored) { + frame.previous = frame.cwds; + frame.cwds = restored; + frame.explicit = restored.some((dir) => dir !== baseCwd); + } + return; + } + + if (verb === "cd" || verb === "pushd") { + // A `cd` in a pipeline stage runs in its own process and moves nothing else. One + // reached via `&&`/`||` may not run at all: `cd /home/hasna; false && cd /tmp; + // rm -rf *` left the guard in /tmp while bash stayed in the home directory. + if (piped) return; + if (shortCircuit) { + analysis.degraded = true; + return; + } + // `pushd -n` records the directory WITHOUT moving the shell, so the tracked cwd must + // not follow it. Previously `-n` was read as the directory operand. + if (verb === "pushd" && tokens.includes("-n")) return; + // `pushd` saves the current directory before moving. + if (verb === "pushd") frame.dirStack.push(frame.cwds); + // Skip cd's own flags (-P, -L, --) to reach the directory operand. + let i = 1; + while (i < tokens.length && (tokens[i] === "-P" || tokens[i] === "-L" || tokens[i] === "-e" || tokens[i] === "-@" || tokens[i] === "--")) i += 1; + const operand = tokens[i]; + const priorCwds = frame.cwds; + cdOperations += 1; + // Both cd bounds below mark the analysis degraded rather than inventing a fallback. + // Skipping an over-long operand allowed `cd ////…(4200); rm -rf *`, and adding `/` to + // the candidate set caught only sweep targets - `cd /home/hasna; cd .x2000; cd ..; + // rm -rf hasna` still destroyed the Hasna home. + // Once the budget is spent the guard can no longer model a chain of RELATIVE cds. It + // must not simply keep the last known directory - that was the fail-open the PATH_MAX + // cap produced - so `/` joins the candidate set and any relative delete is judged + // against the filesystem root too. `rm -rf *` then blocks; `rm -rf dist` still resolves + // to /dist and passes. + // + // An ABSOLUTE cd is never dropped: it is a real landing the guard can still model + // exactly, and skipping it lost `cd ~` after a flood, which allowed `rm -rf .hasna`. + if (cdOperations > MAX_CD_OPERATIONS && !isAbsolute(expandHome(operand ?? ""))) { + analysis.degraded = true; + return; + } + + if (operand === undefined || operand === "~") { + frame.cwds = [home]; + frame.explicit = true; + } else if (operand === "-" || operand === "$OLDPWD" || operand === "${OLDPWD}") { + frame.cwds = frame.previous; + frame.explicit = frame.previous.some((dir) => dir !== baseCwd); + } else { + const collapsed = emptyExpansionCollapse(operand, nonEmptyNames); + const next = new Set(); + for (const current of frame.cwds) { + // Only operands that can GROW the path are capped. + // + // `..` and `.` shrink or hold, and skipping them froze the model permanently: after + // one crossing, `cd d0 … cd d1999; cd ..x2100` left the guard on the long path while + // bash had walked back to `/`, so `rm -rf *` was allowed. That was a fail-open + // introduced by the cap itself - the seventh time a bound in this file produced one. + // + // An absolute operand replaces the path, but resolving a 4KB operand 70k times still + // took 24s against the 20s timeout, so its own length is capped too. No real + // directory exceeds PATH_MAX, which is why this is a correctness bound and not just + // a throttle. + const expanded = expandHome(operand); + const shrinksOnly = /^[./]+$/.test(expanded); + const wouldGrow = !shrinksOnly && !isAbsolute(expanded); + if (wouldGrow && current.length > MAX_TRACKED_CWD_LENGTH) { + analysis.degraded = true; + next.add(current); + continue; + } + if (expanded.length > MAX_TRACKED_CWD_LENGTH) { + analysis.degraded = true; + next.add(current); + continue; + } + next.add(resolveFrom(current, operand)); + if (collapsed !== null) next.add(resolveFrom(current, collapsed)); + } + frame.cwds = [...next].slice(0, MAX_CWD_VARIANTS); + if (isAbsolute(expandHome(operand)) || collapsed !== null) frame.explicit = true; + } + frame.previous = priorCwds; + return; + } + + chunks.push({ segment, segmentIndex, cwds: frame.cwds, explicitCwd: frame.explicit }); + }); + return chunks; +} + +/** + * `for d in /*; do rm -rf "$d"; done` deletes the filesystem root one entry at a time while + * the delete's own target is an innocuous `$d`. Only exact `$VAR` / `${VAR}` targets bound by + * a `for ... in` in the same layer are substituted, so this cannot fire on unrelated commands. + */ +function forLoopBindings(command: string): Map { + const bindings = new Map(); + for (const segment of splitShellSegments(command)) { + const tokens = shellWords(segment); + const forIndex = tokens.indexOf("for"); + if (forIndex === -1) continue; + const name = tokens[forIndex + 1]; + if (!name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) continue; + if (tokens[forIndex + 2] !== "in") continue; + const words = tokens.slice(forIndex + 3).filter((token) => token !== "do"); + if (words.length > 0) bindings.set(name, words); + } + return bindings; +} + +function loopBoundWords(path: string, bindings: Map): string[] | null { + const match = path.match(/^\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?$/); + if (!match) return null; + return bindings.get(match[1]) ?? null; +} + +function destructiveShellTargets(command: string, cwd: string, analysis: AnalysisState): DestructiveShellTarget[] { + const targets: DestructiveShellTarget[] = []; + for (const layer of shellCommandLayers(command).layers) { + const bindings = forLoopBindings(layer.command); + + const assignments = assignmentWalker(layer.command); + + for (const chunk of cwdTrackedSegments(layer.command, cwd, new Set(), analysis)) { + const raw = [ + ...rmCommandTargets(chunk.segment), + ...rsyncDeleteTargets(chunk.segment), + ...findDestructiveTargets(chunk.segment), + ...gitDestructiveTargets(chunk.segment, chunk.cwds[0]), + ]; + if (raw.length === 0) continue; + // The walker advances one set IN PLACE - that is what removed the O(segments x names) + // copy, not this lookup being lazy. + const nonEmptyNames = assignments.at(chunk.segmentIndex); + + const expanded = raw.flatMap((target) => { + const words = loopBoundWords(target.path, bindings); + const paths = words ?? expandBraces(target.path); + return paths.length === 1 && paths[0] === target.path && words === null + ? [destructiveTarget(target.path, target.operation, nonEmptyNames)] + : paths.map((word) => destructiveTarget(word, target.operation, nonEmptyNames)); + }); + + const chunkTargets = expanded.flatMap((target) => + chunk.cwds.map((chunkCwd) => ({ + ...target, + path: substituteWorkingDirectory(target.path, chunkCwd), + baseCwd: chunkCwd, + })) + ); + + if (!layer.remote) { + targets.push(...chunkTargets); + continue; + } + targets.push( + ...chunkTargets + .filter((target) => chunk.explicitCwd || keepRemoteTarget(target)) + .map((target) => ({ ...target, remote: true })) + ); + } + } + return targets; +} + +function isApplyPatchTool(toolName: string): boolean { + return toolName === "apply_patch" || toolName === "ApplyPatch" || toolName === "functions.apply_patch"; +} + +function extractFileToolPaths(input: CodewithHookInput): Array<{ path: string; operation: string }> { + if (input.hook_event_name !== "PreToolUse") return []; + const toolName = typeof input.tool_name === "string" ? input.tool_name : ""; + const toolInput = input.tool_input && typeof input.tool_input === "object" ? input.tool_input : {}; + const paths: Array<{ path: string; operation: string }> = []; + + const addString = (value: unknown, operation: string) => { + if (typeof value === "string" && value.trim()) paths.push({ path: value, operation }); + }; + const addPathFields = (obj: Record, operation: string) => { + for (const key of ["file_path", "path", "target_path", "old_path", "new_path", "notebook_path"]) { + addString(obj[key], operation); + } + }; + + if (["Write", "Edit", "MultiEdit", "NotebookEdit"].includes(toolName)) { + addPathFields(toolInput, `${toolName} file mutation`); + } else if (/^(?:apply_patch|ApplyPatch|functions\.apply_patch|mcp__.*|.*(?:write|edit|delete|remove|move).*file.*)$/i.test(toolName)) { + addPathFields(toolInput, `${toolName} file mutation`); + } + + const patch = toolInput.patch ?? toolInput.input ?? toolInput.content ?? toolInput.command; + if (isApplyPatchTool(toolName) && typeof patch === "string") { + for (const line of patch.split(/\r?\n/)) { + const match = line.match(/^\*\*\* (?:(?:Add|Update|Delete) File|Move to): (.+)$/); + if (match?.[1]) paths.push({ path: match[1].trim(), operation: "apply_patch file mutation" }); + } + } + + const files = toolInput.files; + if (Array.isArray(files)) { + for (const item of files) { + if (typeof item === "string") addString(item, `${toolName} file mutation`); + else if (item && typeof item === "object") addPathFields(item as Record, `${toolName} file mutation`); + } + } + + return paths; +} + +function scopedBlockReason(operation: string, targetPath: string, rule: ProtectedPathRule, remote?: boolean): string { + // A POSIX class, equivalence class or collating symbol makes the pattern's extent + // unpinnable, so the guard treats it as matching anything. Saying only "targets /" would be + // wrong and confusing when the command reads `rm -rf /var[.]log` - the operator needs to + // know it was refused for being unanalysable, not for naming the filesystem root. + const unpinnable = /\[[:=.]/.test(targetPath) + ? [ + "This target contains a POSIX character class, equivalence class or collating symbol,", + "whose extent cannot be determined without replicating the shell exactly. It is therefore", + "treated as matching any name. Use a literal path, or a plain glob, if this was not intended.", + ] + : []; + return [ + `Blocked scoped dangerous operation: ${operation} targets ${targetPath}${remote ? " on a remote host" : ""}.`, + `Protected scope: ${rule.label} (${rule.root}).`, + ...unpinnable, + "This guard is scoped; destructive commands outside protected roots are not blocked.", + "Delete a specific named subdirectory instead of the root or its contents.", + ].join(" "); +} + +function collapseBlockReason( + operation: string, + rawTarget: string, + collapsedTarget: string, + rule: ProtectedPathRule, + remote?: boolean +): string { + return [ + `Blocked unsafe expansion in a destructive command: ${operation} target ${rawTarget}`, + `collapses to ${collapsedTarget}${remote ? " on a remote host" : ""} when the expansion returns empty`, + "(a command substitution that fails or prints nothing, or an unset variable),", + `which would destroy ${rule.label} (${rule.root}).`, + "This is the 2026-07-24 station02 failure: `bun pm cache` exits non-zero with empty stdout when no", + "package.json is found walking up from cwd, so `rm -rf \"$(bun pm cache)\"/*` ran as `rm -rf /*`.", + "Redirecting stderr does not help - it discards the diagnostic, not the path.", + "Safe alternative: resolve the path first, verify it is non-empty and not a protected root, then delete it,", + 'e.g. `dir="$(bun pm cache)" || exit 1; case "$dir" in /|"") exit 1;; esac; rm -rf -- "$dir"`.', + "This guard blocks the shape, not the command: any expansion immediately followed by `/` can collapse to the filesystem root.", + ].join(" "); +} + +export async function classifyDangerousOperation(input: CodewithHookInput): Promise { + if (input.hook_event_name !== "PreToolUse") return { block: false }; + const cwd = input.cwd || process.cwd(); + const { rules, workspaceRoots, currentManagedRepoRoot } = await protectedPathContextFor(input, cwd); + + if (input.tool_name === "Bash") { + const command = getCommand(input); + const analysis: AnalysisState = { degraded: false }; + + // A command large enough that merely tokenizing it blows the hook's 20s budget cannot be + // analysed at all, and a timed-out hook fails open. 70k repetitions of `cd /<4KB>` is a + // 280 MB string: no per-rule bound helps, because the cost is reading the input. Refuse it + // when it carries a recursive delete, rather than letting the timeout decide. + if (command.length > MAX_ANALYSABLE_COMMAND_LENGTH) { + // Decided here either way. Falling through to the full scan for a command with no + // delete in it still spent 46s tokenizing, which stalls every Bash call behind the + // hook's timeout for no benefit. + const reason = truncatedAnalysisBlockReason(command); + return reason ? { block: true, operation: "oversized command", reason } : { block: false }; + } + + if (shellCommandLayers(command).truncated) { + const reason = truncatedAnalysisBlockReason(command); + if (reason) { + return { block: true, operation: "unanalysable nested command", reason }; + } + } + + for (const target of destructiveShellTargets(command, cwd, analysis)) { + const targetCwd = target.baseCwd ?? cwd; + const targetPath = resolveFrom(targetCwd, target.path); + const rulesFor = (path: string) => { + const extraRule = workspaceRoots.map((root) => hasnaDivisionRuleFor(path, root)).find((rule): rule is ProtectedPathRule => Boolean(rule)); + return extraRule ? [...rules, extraRule] : rules; + }; + + for (const rule of rulesFor(targetPath)) { + if (threatensRule(targetPath, rule, currentManagedRepoRoot)) { + return { + block: true, + targetPath, + protectedPath: rule.root, + protectedLabel: rule.label, + operation: target.operation, + reason: scopedBlockReason(target.operation, targetPath, rule, target.remote), + }; + } + } + + // Second pass over the same target as the shell would produce it if every expansion + // came back empty. The managed-worktree escape hatch is not applied here: an empty + // collapse leaves the worktree entirely, so it can never be the intended target. + if (target.collapsed === undefined) continue; + const collapsedPath = resolveFrom(targetCwd, target.collapsed); + for (const rule of rulesFor(collapsedPath)) { + if (threatensRule(collapsedPath, rule, null)) { + return { + block: true, + targetPath: collapsedPath, + protectedPath: rule.root, + protectedLabel: rule.label, + operation: target.operation, + reason: collapseBlockReason(target.operation, target.path, collapsedPath, rule, target.remote), + }; + } + } + } + + // Raised during the scan above by any bound that stopped modelling the command + // exactly. Checked here rather than at each bound so there is ONE fail-closed answer: + // three bounds that each invented their own fallback all became root wipes. + if (analysis.degraded) { + const reason = truncatedAnalysisBlockReason(command); + if (reason) { + return { block: true, operation: "unanalysable command", reason }; + } + } + } + + const managedRepoRootCache = new Map>(); + const worktreesRoot = resolve(defaultWorktreesRoot()); + for (const candidate of extractFileToolPaths(input)) { + const targetPath = resolveFrom(cwd, candidate.path); + const hasUnsafeManagedComponent = isInsidePath(targetPath, worktreesRoot) + && hasUnsafeTargetComponent(worktreesRoot, targetPath); + const targetManagedRepoRoot = hasUnsafeManagedComponent + ? null + : await managedRepoRootForAbsoluteTarget(targetPath, managedRepoRootCache); + const exemptManagedRepoRoot = hasUnsafeManagedComponent + ? null + : targetManagedRepoRoot; + const extraRule = workspaceRoots.map((root) => hasnaDivisionRuleFor(targetPath, root)).find((rule): rule is ProtectedPathRule => Boolean(rule)); + const allRules = extraRule ? [...rules, extraRule] : rules; + for (const rule of allRules) { + if (mutatesRule(targetPath, rule, exemptManagedRepoRoot)) { + return { + block: true, + targetPath, + protectedPath: rule.root, + protectedLabel: rule.label, + operation: candidate.operation, + reason: scopedBlockReason(candidate.operation, targetPath, rule), + }; + } + } + } + + return { block: false }; +} diff --git a/hooks/codewith-native-common/destructive-targets.ts b/hooks/codewith-native-common/destructive-targets.ts new file mode 100644 index 0000000..40e887b --- /dev/null +++ b/hooks/codewith-native-common/destructive-targets.ts @@ -0,0 +1,525 @@ +import { dirname, isAbsolute, resolve, sep } from "path"; +import { + expandHome, + isGitToken, + optionValue, + resolveFrom, + shellWords, + shortOptionValue, + splitShellSegments, +} from "./git-command"; +import { + emptyExpansionCollapse, + findExpansions, + MAX_EXPANSION_NESTING, +} from "./shell-expansions"; + +export interface DestructiveShellTarget { + path: string; + operation: string; + /** Same target with every shell expansion collapsed to empty; see emptyExpansionCollapse. */ + collapsed?: string; + /** Target of a command sent to another host, so relative paths cannot be resolved here. */ + remote?: boolean; + /** Working directory in effect for this target, after any `cd` earlier in the command. */ + baseCwd?: string; +} + +// `$PWD`, `${PWD}`, `$(pwd)` and `` `pwd` `` all stand for the working directory the guard is +// already tracking. They are certified non-empty, so no collapse fires - which left them as +// opaque path components matching no protected root, and `rm -rf "$PWD"/*` was allowed where +// the identical `rm -rf *` blocked. +const PWD_EXPANSION = /\$\{PWD\}|\$PWD|\$\(\s*pwd\s*\)|`\s*pwd\s*`/g; + +export function substituteWorkingDirectory(path: string, cwd: string): string { + return PWD_EXPANSION.test(path) ? path.replace(PWD_EXPANSION, cwd) : path; +} + +export function destructiveTarget( + path: string, + operation: string, + nonEmptyNames: ReadonlySet = new Set() +): DestructiveShellTarget { + const collapsed = emptyExpansionCollapse(path, nonEmptyNames); + return collapsed === null ? { path, operation } : { path, operation, collapsed }; +} + +export function rmCommandTargets(command: string): DestructiveShellTarget[] { + const targets: DestructiveShellTarget[] = []; + for (const segment of splitShellSegments(command)) { + const tokens = shellWords(segment); + const rmIndex = tokens.findIndex((token) => token === "rm" || token.endsWith("/rm")); + if (rmIndex === -1) continue; + + let recursive = false; + let force = false; + let afterOptions = false; + const segmentTargets: string[] = []; + + for (let i = rmIndex + 1; i < tokens.length; i += 1) { + const token = tokens[i]; + if (!afterOptions && token === "--") { + afterOptions = true; + continue; + } + if (!afterOptions && token.startsWith("--")) { + if (token === "--recursive" || token === "--dir") recursive = true; + if (token === "--force") force = true; + continue; + } + if (!afterOptions && /^-[A-Za-z]+$/.test(token)) { + if (token.includes("r") || token.includes("R")) recursive = true; + if (token.includes("f")) force = true; + continue; + } + segmentTargets.push(token); + } + + if (recursive) { + targets.push(...segmentTargets.map((path) => destructiveTarget(path, force ? "rm -rf" : "rm -r"))); + } + } + return targets; +} + +const RSYNC_OPTIONS_WITH_VALUE = new Set([ + "-e", + "--rsh", + "--exclude", + "--exclude-from", + "--include", + "--include-from", + "--filter", + "--files-from", + "--rsync-path", + "--out-format", + "--log-file", + "--password-file", + "--backup-dir", + "--partial-dir", + "--compare-dest", + "--copy-dest", + "--link-dest", +]); + +function optionTakesValue(token: string, options: Set): boolean { + if (options.has(token)) return true; + const eq = token.indexOf("="); + return eq === -1 ? false : options.has(token.slice(0, eq)); +} + +export function rsyncDeleteTargets(command: string): DestructiveShellTarget[] { + const targets: DestructiveShellTarget[] = []; + for (const segment of splitShellSegments(command)) { + const tokens = shellWords(segment); + const rsyncIndex = tokens.findIndex((token) => token === "rsync" || token.endsWith("/rsync")); + if (rsyncIndex === -1) continue; + + let hasDelete = false; + let afterOptions = false; + const operands: string[] = []; + + for (let i = rsyncIndex + 1; i < tokens.length; i += 1) { + const token = tokens[i]; + if (!afterOptions && token === "--") { + afterOptions = true; + continue; + } + if (!afterOptions && (token === "--delete" || token.startsWith("--delete-"))) { + hasDelete = true; + continue; + } + if (!afterOptions && token.startsWith("-")) { + if (optionTakesValue(token, RSYNC_OPTIONS_WITH_VALUE) && !token.includes("=")) i += 1; + continue; + } + operands.push(token); + } + + if (hasDelete && operands.length > 0) { + targets.push(destructiveTarget(operands[operands.length - 1], "rsync --delete")); + } + } + return targets; +} + +export function findDestructiveTargets(command: string): DestructiveShellTarget[] { + const targets: DestructiveShellTarget[] = []; + for (const segment of splitShellSegments(command)) { + const tokens = shellWords(segment); + const findIndex = tokens.findIndex((token) => token === "find" || token.endsWith("/find")); + if (findIndex === -1) continue; + + let hasDelete = false; + let hasExecRm = false; + const roots: string[] = []; + + for (let i = findIndex + 1; i < tokens.length; i += 1) { + const token = tokens[i]; + if (token === "-H" || token === "-L" || token === "-P") continue; + if (token === "-O") { + i += 1; + continue; + } + if (token.startsWith("-") || token === "!" || token === "(" || token === ")") break; + roots.push(token); + } + + for (let i = findIndex + 1; i < tokens.length; i += 1) { + const token = tokens[i]; + if (token === "-delete") hasDelete = true; + if (token === "-exec" || token === "-execdir") { + const next = tokens[i + 1]; + if (next === "rm" || next?.endsWith("/rm")) hasExecRm = true; + } + } + + if (hasDelete || hasExecRm) { + targets.push(...(roots.length > 0 ? roots : ["."]).map((path) => destructiveTarget( + path, + hasDelete ? "find -delete" : "find -exec rm" + ))); + } + } + return targets; +} + +function gitTargetCwdFromTokens(tokens: string[], baseCwd: string): { gitIndex: number; commandIndex: number; targetCwd: string } | null { + const gitIndex = tokens.findIndex(isGitToken); + if (gitIndex === -1) return null; + + let i = gitIndex + 1; + let cwd = resolve(baseCwd); + let gitDir: string | undefined; + let workTree: string | undefined; + + while (i < tokens.length) { + const token = tokens[i]; + + const cDir = shortOptionValue(token, tokens[i + 1], "-C"); + if (cDir) { + if (cDir.value) cwd = resolveFrom(cwd, cDir.value); + i += cDir.consumed; + continue; + } + + const config = shortOptionValue(token, tokens[i + 1], "-c"); + if (config) { + i += config.consumed; + continue; + } + + const gitDirValue = optionValue(token, tokens[i + 1], "--git-dir"); + if (gitDirValue) { + if (gitDirValue.value) gitDir = resolveFrom(cwd, gitDirValue.value); + i += gitDirValue.consumed; + continue; + } + + const workTreeValue = optionValue(token, tokens[i + 1], "--work-tree"); + if (workTreeValue) { + if (workTreeValue.value) workTree = resolveFrom(cwd, workTreeValue.value); + i += workTreeValue.consumed; + continue; + } + + const namespaceValue = optionValue(token, tokens[i + 1], "--namespace"); + if (namespaceValue) { + i += namespaceValue.consumed; + continue; + } + + const execPathValue = optionValue(token, tokens[i + 1], "--exec-path"); + if (execPathValue) { + i += execPathValue.consumed; + continue; + } + + if (token === "--config-env") { + i += tokens[i + 1] === undefined ? 1 : 2; + continue; + } + + if (token === "--") { + i += 1; + continue; + } + + if (token.startsWith("-")) { + i += 1; + continue; + } + + const targetCwd = workTree || (gitDir ? (gitDir.endsWith(`${sep}.git`) || gitDir.endsWith("/.git") ? dirname(gitDir) : gitDir) : cwd); + return { gitIndex, commandIndex: i, targetCwd }; + } + + return null; +} + +export function gitDestructiveTargets(command: string, baseCwd: string): DestructiveShellTarget[] { + const targets: DestructiveShellTarget[] = []; + for (const segment of splitShellSegments(command)) { + const tokens = shellWords(segment); + const git = gitTargetCwdFromTokens(tokens, baseCwd); + if (!git) continue; + + const commandName = tokens[git.commandIndex]; + if (commandName === "reset" && tokens.slice(git.commandIndex + 1).includes("--hard")) { + targets.push({ path: git.targetCwd, operation: "git reset --hard" }); + continue; + } + + if (commandName !== "clean") continue; + + let force = false; + let recursive = false; + const pathspecs: string[] = []; + for (let i = git.commandIndex + 1; i < tokens.length; i += 1) { + const token = tokens[i]; + if (token === "--") continue; + if (token === "-f" || token === "--force") { + force = true; + continue; + } + if (token === "-d") { + recursive = true; + continue; + } + if (/^-[A-Za-z]+$/.test(token)) { + if (token.includes("f")) force = true; + if (token.includes("d")) recursive = true; + continue; + } + if (token.startsWith("-")) continue; + pathspecs.push(token); + } + + if (force && recursive) { + targets.push(...(pathspecs.length > 0 ? pathspecs : ["."]).map((path) => ({ + path: resolveFrom(git.targetCwd, path), + operation: "git clean -xfd", + }))); + } + } + return targets; +} + +const SHELL_INTERPRETERS = new Set(["sh", "bash", "zsh", "dash", "ksh", "ash", "mksh", "busybox"]); + +// Also take a script via `-c`, but with a username operand in front of the flag. +const USER_SWITCH_COMMANDS = new Set(["su", "runuser"]); + +// ssh options that consume the following argument, so the first bare operand really is the host. +const SSH_OPTIONS_WITH_VALUE = new Set([ + "-B", "-b", "-c", "-D", "-E", "-e", "-F", "-I", "-i", "-J", "-L", "-l", "-m", + "-O", "-o", "-P", "-p", "-Q", "-R", "-S", "-W", "-w", +]); + +interface ShellCommandLayer { + command: string; + /** True once the layer is being executed on another host via ssh. */ + remote: boolean; +} + +function commandName(token: string): string { + return token.includes("/") ? token.slice(token.lastIndexOf("/") + 1) : token; +} + +function isShellInterpreterToken(token: string): boolean { + return SHELL_INTERPRETERS.has(commandName(token)); +} + +// Shell options that consume the following word, so its value is not mistaken for the script +// operand. Without this, `bash -o errexit -c '...'` reads `errexit` as the script file and the +// `-c` script is never scanned. +const SHELL_OPTIONS_WITH_VALUE = new Set(["-o", "+o", "--rcfile", "--init-file"]); + +/** + * Script passed via `-c`. For a shell, the first bare operand is the script *file* and the + * scan stops there; `su`/`runuser` take a username operand first, so one is skipped. + */ +function interpreterScriptFrom(tokens: string[], shellIndex: number, allowedOperands = 0): string | null { + let operands = 0; + for (let i = shellIndex + 1; i < tokens.length; i += 1) { + const token = tokens[i]; + // -c, and combined short forms such as -lc / -euxc. + if (/^-[A-Za-z]*c$/.test(token)) return tokens[i + 1] ?? null; + if (SHELL_OPTIONS_WITH_VALUE.has(token)) { + i += 1; + continue; + } + if (!token.startsWith("-")) { + operands += 1; + if (operands > allowedOperands) return null; + } + } + return null; +} + +/** + * Bodies of `$( … )` and backtick substitutions, as scripts in their own right. + * + * Required because the tokenizer treats substitutions atomically so the collapse rule can see + * them whole. Without feeding the bodies back in, `echo $(rm -rf /*)` contains no `rm` token + * at all and every rule misses it - the delete runs, its output is simply discarded. + */ +function substitutionBodies(segment: string, onTruncated?: () => void): string[] { + const bodies: string[] = []; + const visit = (text: string, depth: number): void => { + // Exhausting this bound must not silently drop a delete: `${x:-${x:- … $(rm -rf /*)}}` + // nested past the old hardcoded 4 was never classified at all. + if (depth > MAX_EXPANSION_NESTING) { + onTruncated?.(); + return; + } + for (const expansion of findExpansions(text)) { + if (expansion.text.startsWith("$(") || expansion.text.startsWith("`")) { + const body = (expansion.text.startsWith("`") + ? expansion.text.slice(1, -1) + : expansion.text.slice(2, -1)).trim(); + if (body.length > 0) { + bodies.push(body); + visit(body, depth + 1); + } + continue; + } + // `${x:-$(rm -rf /*)}` runs the substitution when x is unset. findExpansions returns + // the outer ${...} and swallows the inner one, so the body has to be re-scanned. + if (expansion.text.startsWith("${")) visit(expansion.text.slice(2, -1), depth + 1); + } + }; + visit(segment, 0); + return bodies; +} + +function sshRemoteCommandFrom(tokens: string[], sshIndex: number): string | null { + for (let i = sshIndex + 1; i < tokens.length; i += 1) { + const token = tokens[i]; + if (token === "--") continue; + if (token.startsWith("-")) { + if (SSH_OPTIONS_WITH_VALUE.has(token)) i += 1; + continue; + } + // First bare operand is [user@]host; everything after it is the remote command. + const remote = tokens.slice(i + 1).join(" ").trim(); + return remote.length > 0 ? remote : null; + } + return null; +} + +/** + * Scripts this command hands to another interpreter or to another host. + * + * Required, not optional: the realized 2026-07-24 incident arrived as + * `ssh station02 bash -c '...'`, and the `rm` token only exists inside the quoted script. + * A scan of the outer command alone sees `ssh`, `bash` and a single opaque operand. + */ +function isSshToken(token: string): boolean { + return token === "ssh" || token.endsWith("/ssh"); +} + +function wrappedShellLayers(command: string, remote: boolean, onTruncated?: () => void): ShellCommandLayer[] { + const layers: ShellCommandLayer[] = []; + for (const segment of splitShellSegments(command)) { + const tokens = shellWords(segment); + // `ssh host bash -c '...'`: everything after the ssh token executes on the other machine. + let sshSeen = false; + for (let i = 0; i < tokens.length; i += 1) { + const token = tokens[i]; + if (isShellInterpreterToken(token) || USER_SWITCH_COMMANDS.has(commandName(token))) { + const allowedOperands = USER_SWITCH_COMMANDS.has(commandName(token)) ? 1 : 0; + const script = interpreterScriptFrom(tokens, i, allowedOperands); + if (script) layers.push({ command: script, remote: remote || sshSeen }); + continue; + } + if (token === "eval") { + const script = tokens.slice(i + 1).join(" ").trim(); + if (script) layers.push({ command: script, remote: remote || sshSeen }); + continue; + } + if (isSshToken(token)) { + sshSeen = true; + const script = sshRemoteCommandFrom(tokens, i); + if (script) layers.push({ command: script, remote: true }); + } + } + + // A substitution body executes wherever it appears, including in assignments and in + // arguments to commands that do nothing with the result. + for (const body of substitutionBodies(segment, onTruncated)) { + layers.push({ command: body, remote: remote || sshSeen }); + } + } + return layers; +} + +const MAX_WRAPPER_DEPTH = 8; +const MAX_SHELL_LAYERS = 256; + +export function shellCommandLayers(command: string): { layers: ShellCommandLayer[]; truncated: boolean } { + const layers: ShellCommandLayer[] = [{ command, remote: false }]; + const seen = new Set([command]); + let frontier: ShellCommandLayer[] = layers; + let truncated = false; + + for (let depth = 0; depth < MAX_WRAPPER_DEPTH; depth += 1) { + const next: ShellCommandLayer[] = []; + for (const layer of frontier) { + for (const inner of wrappedShellLayers(layer.command, layer.remote, () => { truncated = true; })) { + if (seen.has(inner.command)) continue; + if (layers.length + next.length >= MAX_SHELL_LAYERS) { + truncated = true; + continue; + } + seen.add(inner.command); + next.push(inner); + } + } + if (next.length === 0) break; + layers.push(...next); + frontier = next; + // More wrappers remain below the depth limit. + if (depth === MAX_WRAPPER_DEPTH - 1 && next.some((layer) => wrappedShellLayers(layer.command, layer.remote).length > 0)) { + truncated = true; + } + } + + return { layers, truncated }; +} + +// Verbs whose presence makes an unanalysable command unsafe to wave through. +// `rm` followed ANYWHERE by a recursive flag. Anchoring it to the very next token missed +// `rm -f -r /*`, `rm -v -f -r /*`, `rm --one-file-system -rf /*` and `rm -rf`, each of +// which sailed past the oversized-command gate unanalysed. +const DESTRUCTIVE_VERB = /(?:^|[^\w.-])(?:[\w/.-]*\/)?(?:rm\b[^;&|\n]*?(?:\s-[A-Za-z]*[rR][A-Za-z]*(?=[\s=;&|]|$)|\s--recursive\b|\s--dir\b)|rsync\s[^;&|]*--delete|find\s[^;&|]*(?:-delete|-execdir?\s)|git\s[^;&|]*(?:clean\s+-\S*[fd]|reset\s+--hard))/; + +/** + * A command too deeply wrapped or too wide to analyse within the caps is refused when it + * contains a destructive verb, instead of being allowed by default. + * + * The caps exist so a pathological command cannot stall the hook past its 20s timeout - and + * a timed-out hook fails open. But dropping work silently turns "too complex to analyse" + * into "allowed", which is the same passes-silently-while-protecting-nothing failure this + * guard exists to prevent. Padding with 32 dummy `sh -c` wrappers pushed the real delete + * past the cap and it returned continue. + */ +export function truncatedAnalysisBlockReason(command: string): string | null { + if (!DESTRUCTIVE_VERB.test(command)) return null; + return [ + "Blocked: this command nests more shell wrappers than the safety guard can analyse,", + "and it contains a recursive delete. The guard refuses rather than guess, because an", + "unanalysable delete is exactly the shape that destroyed a machine on 2026-07-24.", + "Run the delete directly instead of through nested bash -c / ssh / eval wrappers,", + "with a literal, non-empty target path.", + ].join(" "); +} + +/** + * Remote layers run against another machine's filesystem, so a relative or cwd-derived + * target here would be a guess. Absolute targets (including `~` / `$HOME` forms, which the + * fleet shares) and empty-collapse targets (always absolute by construction) still apply. + */ +export function keepRemoteTarget(target: DestructiveShellTarget): boolean { + return target.collapsed !== undefined || isAbsolute(expandHome(target.path)); +} diff --git a/hooks/codewith-native-common/git-command.ts b/hooks/codewith-native-common/git-command.ts new file mode 100644 index 0000000..65b17d0 --- /dev/null +++ b/hooks/codewith-native-common/git-command.ts @@ -0,0 +1,407 @@ +import { dirname, isAbsolute, join, resolve, sep } from "path"; +import { homedir } from "os"; + +export interface GitCommandInfo { + action: "commit" | "push"; + targetCwd: string; + gitDir?: string; + workTree?: string; +} + +// `$( ... )` and backtick substitutions are one operand of the surrounding command: +// their inner `;`, `|` and whitespace are not separators. Tokenizing them atomically is +// what lets the expansion-collapse rule below see `$(cmd)/*` as a single target token. +// If a substitution is left unterminated the command is malformed, so both tokenizers +// re-run with substitution tracking disabled rather than swallow the rest of the input. +function splitShellSegmentsPass( + command: string, + atomicSubstitutions: boolean +): { segments: string[]; isolation: boolean[]; depths: number[]; groups: number[]; piped: boolean[]; shortCircuit: boolean[]; unterminated: boolean } { + const segments: string[] = []; + const isolation: boolean[] = []; + const depths: number[] = []; + const groups: number[] = []; + const pipedFlags: boolean[] = []; + const shortCircuitFlags: boolean[] = []; + let precededByShortCircuit = false; + let current = ""; + let quote: "'" | '"' | null = null; + let escaped = false; + let substitutionDepth = 0; + let substitutionQuote: "'" | '"' | null = null; + let inBacktick = false; + let parenDepth = 0; + let pipedFromPrevious = false; + // Every `(` opens a NEW shell. Two siblings are both depth 1 but are different processes, + // so depth alone cannot identify a frame. + let groupCounter = 0; + const groupStack: number[] = [0]; + + const flush = (nextSeparator: string | null) => { + if (current.trim()) { + segments.push(current.trim()); + // A stage of a pipeline runs in its own process, as does anything inside `( … )`. + isolation.push(parenDepth > 0 || pipedFromPrevious || nextSeparator === "|"); + depths.push(parenDepth); + groups.push(groupStack[groupStack.length - 1] ?? 0); + pipedFlags.push(pipedFromPrevious || nextSeparator === "|"); + shortCircuitFlags.push(precededByShortCircuit); + } + current = ""; + pipedFromPrevious = nextSeparator === "|"; + }; + + for (let i = 0; i < command.length; i += 1) { + const ch = command[i]; + if (escaped) { + current += ch; + escaped = false; + continue; + } + if (ch === "\\" && quote !== "'") { + escaped = true; + current += ch; + continue; + } + if (atomicSubstitutions && substitutionDepth > 0) { + current += ch; + // Quotes inside the body are tracked so a quoted paren is not read as structure. + if (substitutionQuote) { + if (ch === substitutionQuote) substitutionQuote = null; + } else if (ch === "'" || ch === '"') { + substitutionQuote = ch; + } else if (ch === "(") substitutionDepth += 1; + else if (ch === ")") substitutionDepth -= 1; + continue; + } + if (atomicSubstitutions && inBacktick) { + current += ch; + if (ch === "`") inBacktick = false; + continue; + } + if (atomicSubstitutions && quote !== "'" && ch === "$" && command[i + 1] === "(") { + current += "$("; + substitutionDepth = 1; + i += 1; + continue; + } + if (atomicSubstitutions && quote !== "'" && ch === "`") { + current += ch; + inBacktick = true; + continue; + } + if (quote) { + current += ch; + if (ch === quote) quote = null; + continue; + } + if (ch === "'" || ch === "\"") { + quote = ch; + current += ch; + continue; + } + if (ch === ";" || ch === "|" || ch === "&" || ch === "(" || ch === ")" || ch === "\n") { + const doubled = (ch === "|" || ch === "&") && command[i + 1] === ch; + // `||` and `&&` are sequencing, not a pipe. + flush(ch === "|" && !doubled ? "|" : null); + // `a && X=1` and `a || X=1` run X= only if the left side decided so. + precededByShortCircuit = doubled && (ch === "|" || ch === "&"); + if (ch === "(") { + parenDepth += 1; + groupCounter += 1; + groupStack.push(groupCounter); + } else if (ch === ")") { + parenDepth = Math.max(0, parenDepth - 1); + if (groupStack.length > 1) groupStack.pop(); + } + if (doubled) i += 1; + continue; + } + current += ch; + } + + flush(null); + return { segments, isolation, depths, groups, piped: pipedFlags, shortCircuit: shortCircuitFlags, unterminated: substitutionDepth > 0 || inBacktick }; +} + +export function splitShellSegments(command: string): string[] { + return splitShellSegmentsDetailed(command).map((segment) => segment.text); +} + +/** A segment plus whether a `cd` in it changes the working directory of later segments. */ +export interface ShellSegment { + text: string; + /** Subshell nesting depth of this segment; a `cd` applies to this depth and deeper. */ + depth: number; + /** Identity of the subshell this segment runs in; siblings at one depth differ. */ + group: number; + /** This segment is a pipeline stage, so its `cd` affects nothing outside the stage. */ + piped: boolean; + /** Reached only via `&&` / `||`, so whether it ran depends on the previous command. */ + shortCircuit: boolean; + /** + * True when the segment runs in a subshell `( … )` or as a stage of a pipeline. A `cd` + * there affects only that child process, so treating it as persistent silently moves the + * guard's idea of cwd away from the directory the later `rm` actually runs in. + */ + isolated: boolean; +} + +const segmentCache = new Map(); +const MAX_SEGMENT_CACHE = 16; + +export function splitShellSegmentsDetailed(command: string): ShellSegment[] { + const cached = segmentCache.get(command); + if (cached) return cached; + const computed = splitShellSegmentsUncached(command); + if (segmentCache.size >= MAX_SEGMENT_CACHE) { + const oldest = segmentCache.keys().next().value; + if (oldest !== undefined) segmentCache.delete(oldest); + } + segmentCache.set(command, computed); + return computed; +} + +function splitShellSegmentsUncached(command: string): ShellSegment[] { + const pass = splitShellSegmentsPass(command, true); + const chosen = pass.unterminated ? splitShellSegmentsPass(command, false) : pass; + return chosen.segments.map((text, index) => ({ + text, + depth: chosen.depths[index] ?? 0, + group: chosen.groups[index] ?? 0, + piped: chosen.piped[index] ?? false, + shortCircuit: chosen.shortCircuit[index] ?? false, + isolated: chosen.isolation[index] ?? false, + })); +} + +function shellWordsPass(segment: string, atomicSubstitutions: boolean): { words: string[]; unterminated: boolean } { + const words: string[] = []; + let current = ""; + let quote: "'" | '"' | null = null; + let escaped = false; + let substitutionDepth = 0; + let substitutionQuote: "'" | '"' | null = null; + let inBacktick = false; + + const push = () => { + if (current.length > 0) { + words.push(current); + current = ""; + } + }; + + for (let i = 0; i < segment.length; i += 1) { + const ch = segment[i]; + if (escaped) { + // A backslash before a glob metacharacter is part of the pattern, not shell quoting. + current += /[[\]*?]/.test(ch) ? `\\${ch}` : ch; + escaped = false; + continue; + } + // Substitution bodies are copied verbatim - quotes, spaces AND backslashes. Consuming the + // escape here strips the backslash, and findExpansions then re-counts `\'` or `\(` as + // structure on the de-escaped text, which reopened the bug the quote fix closed. + if (atomicSubstitutions && substitutionDepth > 0) { + current += ch; + if (ch === "\\") { + current += segment[i + 1] ?? ""; + i += 1; + } else if (substitutionQuote) { + if (ch === substitutionQuote) substitutionQuote = null; + } else if (ch === "'" || ch === '"') { + substitutionQuote = ch; + } else if (ch === "(") substitutionDepth += 1; + else if (ch === ")") substitutionDepth -= 1; + continue; + } + if (atomicSubstitutions && inBacktick) { + current += ch; + if (ch === "\\") { current += segment[i + 1] ?? ""; i += 1; } + else if (ch === "`") inBacktick = false; + continue; + } + if (ch === "\\" && quote !== "'") { + escaped = true; + continue; + } + if (atomicSubstitutions && quote !== "'" && ch === "$" && segment[i + 1] === "(") { + current += "$("; + substitutionDepth = 1; + i += 1; + continue; + } + if (atomicSubstitutions && quote !== "'" && ch === "`") { + current += ch; + inBacktick = true; + continue; + } + if (quote) { + if (ch === quote) { + quote = null; + } else { + current += ch; + } + continue; + } + if (ch === "'" || ch === "\"") { + quote = ch; + continue; + } + if (/\s/.test(ch)) { + push(); + continue; + } + current += ch; + } + push(); + return { words, unterminated: substitutionDepth > 0 || inBacktick }; +} + +export function shellWords(segment: string): string[] { + const atomic = shellWordsPass(segment, true); + if (!atomic.unterminated) return atomic.words; + return shellWordsPass(segment, false).words; +} + +export function expandHome(path: string): string { + // One home for every form. `~` used homedir() while `$HOME` and every protected root used + // process.env.HOME, so wherever the two differ the target and the rule were resolved against + // different directories and `rm -rf ~/.hasna` missed the ~/.hasna rule entirely. + const home = process.env.HOME || homedir(); + if (path === "~") return home; + if (path.startsWith("~/")) return join(home, path.slice(2)); + if (path === "$HOME" || path === "${HOME}") return home; + if (path.startsWith("$HOME/")) return join(home, path.slice("$HOME/".length)); + if (path.startsWith("${HOME}/")) return join(home, path.slice("${HOME}/".length)); + return path; +} + +export function resolveFrom(cwd: string, path: string): string { + const expanded = expandHome(path); + return isAbsolute(expanded) ? resolve(expanded) : resolve(cwd, expanded); +} + +export function optionValue(token: string, next: string | undefined, option: string): { value?: string; consumed: number } | null { + if (token === option) return { value: next, consumed: next === undefined ? 1 : 2 }; + if (token.startsWith(`${option}=`)) return { value: token.slice(option.length + 1), consumed: 1 }; + return null; +} + +export function shortOptionValue(token: string, next: string | undefined, option: string): { value?: string; consumed: number } | null { + if (token === option) return { value: next, consumed: next === undefined ? 1 : 2 }; + if (token.startsWith(option) && token.length > option.length) return { value: token.slice(option.length), consumed: 1 }; + return null; +} + +export function isGitToken(token: string): boolean { + return token === "git" || token.endsWith("/git"); +} + +function gitInfoFromTokens(tokens: string[], baseCwd: string): GitCommandInfo | null { + const gitIndex = tokens.findIndex(isGitToken); + if (gitIndex === -1) return null; + + let i = gitIndex + 1; + let cwd = resolve(baseCwd); + let gitDir: string | undefined; + let workTree: string | undefined; + + while (i < tokens.length) { + const token = tokens[i]; + + const cDir = shortOptionValue(token, tokens[i + 1], "-C"); + if (cDir) { + if (cDir.value) cwd = resolveFrom(cwd, cDir.value); + i += cDir.consumed; + continue; + } + + const config = shortOptionValue(token, tokens[i + 1], "-c"); + if (config) { + i += config.consumed; + continue; + } + + const gitDirValue = optionValue(token, tokens[i + 1], "--git-dir"); + if (gitDirValue) { + if (gitDirValue.value) gitDir = resolveFrom(cwd, gitDirValue.value); + i += gitDirValue.consumed; + continue; + } + + const workTreeValue = optionValue(token, tokens[i + 1], "--work-tree"); + if (workTreeValue) { + if (workTreeValue.value) workTree = resolveFrom(cwd, workTreeValue.value); + i += workTreeValue.consumed; + continue; + } + + const namespaceValue = optionValue(token, tokens[i + 1], "--namespace"); + if (namespaceValue) { + i += namespaceValue.consumed; + continue; + } + + const execPathValue = optionValue(token, tokens[i + 1], "--exec-path"); + if (execPathValue) { + i += execPathValue.consumed; + continue; + } + + if (token === "--config-env") { + i += tokens[i + 1] === undefined ? 1 : 2; + continue; + } + + if (token === "--") { + i += 1; + continue; + } + + if (token.startsWith("-")) { + i += 1; + continue; + } + + if (token === "commit" || token === "push") { + const targetCwd = workTree || (gitDir ? (gitDir.endsWith(`${sep}.git`) || gitDir.endsWith("/.git") ? dirname(gitDir) : gitDir) : cwd); + return { action: token, targetCwd, ...(gitDir ? { gitDir } : {}), ...(workTree ? { workTree } : {}) }; + } + return null; + } + + return null; +} + +export function gitCommandInfo(command: string, baseCwd: string = process.cwd()): GitCommandInfo | null { + for (const segment of splitShellSegments(command)) { + const tokens = shellWords(segment); + const info = gitInfoFromTokens(tokens, baseCwd); + if (info) return info; + } + return null; +} + +export function isGitCommitOrPush(command: string): boolean { + return gitCommandInfo(command) !== null; +} + +export function isGitPushOrCommitCommand(command: string): "commit" | "push" | null { + return gitCommandInfo(command)?.action || null; +} + +export function isRiskyOperation(command: string): boolean { + const patterns = [ + /(^|[;&|()\s])(?:npm|pnpm|yarn|bun)\s+publish\b/, + /(^|[;&|()\s])gh\s+release\b/, + /(^|[;&|()\s])terraform\s+(?:apply|destroy|import)\b/, + /(^|[;&|()\s])tofu\s+(?:apply|destroy|import)\b/, + /(^|[;&|()\s])kubectl\s+(?:apply|delete|rollout|scale)\b/, + /(^|[;&|()\s])aws\s+[^;&|]*\bdeploy\b/, + /(^|[;&|()\s])(?:drizzle|prisma|sequelize|knex)\s+[^;&|]*\bmigrat(?:e|ion)\b/, + /(^|[;&|()\s])(?:migrate|migration)\b/, + /\bdeploy(?:ment)?\b/, + ]; + return patterns.some((pattern) => pattern.test(command)); +} diff --git a/hooks/codewith-native-common/managed-targets.ts b/hooks/codewith-native-common/managed-targets.ts new file mode 100644 index 0000000..7a8cf08 --- /dev/null +++ b/hooks/codewith-native-common/managed-targets.ts @@ -0,0 +1,218 @@ +import { existsSync, lstatSync, readFileSync, realpathSync } from "fs"; +import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "path"; +import { commandExists, runCommand } from "./base"; +import { resolveFrom } from "./git-command"; +import { + globThreatensRule, + mutatesProtectedPath, + pathHasGlob, + threatensProtectedPath, + type ProtectedPathRule, +} from "./protected-paths"; +import { + CANONICAL_WORKTREE_SEGMENTS, + LEGACY_LEASE_WORKTREE_SEGMENTS, + defaultWorktreesRoot, + isInsidePath, + managedWorktreeInfo, +} from "./worktrees"; + +function shouldSkipHasnaTreeRule(targetPath: string, rule: ProtectedPathRule, currentManagedRepoRoot: string | null): boolean { + if (rule.label !== "Hasna state root ~/.hasna") return false; + if (!currentManagedRepoRoot) return false; + const target = resolve(targetPath); + return isInsidePath(target, currentManagedRepoRoot); +} + +function isMissingPathError(error: unknown): boolean { + return error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT"; +} + +export function hasUnsafeTargetComponent(worktreesRoot: string, target: string): boolean { + const relativeTarget = relative(worktreesRoot, target); + const parts = relativeTarget.split(sep).filter(Boolean); + if (parts.some((part) => part.toLowerCase() === ".git")) return true; + + const filesystemRoot = parse(target).root; + const absoluteParts = relative(filesystemRoot, target).split(sep).filter(Boolean); + let probe = filesystemRoot; + try { + if (lstatSync(probe).isSymbolicLink()) return true; + } catch { + return true; + } + for (const part of absoluteParts) { + probe = join(probe, part); + try { + const metadata = lstatSync(probe); + if (metadata.isSymbolicLink()) return true; + if (probe === target && metadata.isFile() && metadata.nlink > 1) return true; + } catch (error) { + if (isMissingPathError(error)) return false; + return true; + } + } + return false; +} + +/** + * Candidate worktree roots for an absolute target, canonical shape first. + * + * The canonical root sits at `//` + * (CANONICAL_WORKTREE_SEGMENTS). The deprecated station-id lease layout sits one + * level deeper. Order matters: a canonical worktree that happens to contain a + * subdirectory must resolve to the canonical root, never to the subdirectory. + */ +function managedWorktreeRootCandidates(worktreesRoot: string, target: string): string[] { + const parts = relative(worktreesRoot, target).split(sep).filter(Boolean); + const depths = [CANONICAL_WORKTREE_SEGMENTS, LEGACY_LEASE_WORKTREE_SEGMENTS]; + return depths + .filter((depth) => parts.length >= depth) + .map((depth) => resolve(worktreesRoot, ...parts.slice(0, depth))); +} + +/** + * Worktree roots that could own `target`, for the scoped dangerous-operation + * carve-out only. + * + * This is a structural lookup ("could a real managed worktree own this path?"), + * not a policy check ("is this path canonical?"). It therefore keeps the + * deprecated station-id lease layout as a candidate, so that worktrees created + * before rule 8 keep their `~/.hasna` write carve-out during migration. Policy + * enforcement lives in managedWorktreeInfo() / worktree-guard. + * + * Both depths are returned when both are plausible, because path shape alone + * cannot tell a canonical root from a legacy lease container. Each candidate is + * still verified against Git provenance by the caller, which fails closed. + */ +function managedLeaseRootCandidates(worktreesRoot: string, target: string): string[] { + return managedWorktreeRootCandidates(worktreesRoot, target).filter((candidate) => { + const info = managedWorktreeInfo(candidate); + return info.managed || info.layout === "legacy-station-lease"; + }); +} + +async function verifiedLinkedWorktreeRoot(leaseRoot: string): Promise { + const controlFile = join(leaseRoot, ".git"); + try { + const metadata = lstatSync(controlFile); + if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.nlink !== 1) return null; + } catch { + return null; + } + + if (!commandExists("git")) return null; + const result = await runCommand([ + "git", + "rev-parse", + "--show-toplevel", + "--absolute-git-dir", + "--git-common-dir", + ], { cwd: leaseRoot, timeoutMs: 2000 }); + if (result.exitCode !== 0) return null; + const [repoRootRaw, gitDirRaw, commonDirRaw] = result.stdout.trim().split(/\r?\n/); + if (!repoRootRaw || !gitDirRaw || !commonDirRaw) return null; + + const repoRoot = resolve(repoRootRaw); + const gitDir = resolveFrom(leaseRoot, gitDirRaw); + const commonDir = resolveFrom(leaseRoot, commonDirRaw); + if (repoRoot !== resolve(leaseRoot)) return null; + try { + const physicalGitDir = realpathSync(gitDir); + const physicalCommonDir = realpathSync(commonDir); + const physicalWorktreesDir = realpathSync(join(commonDir, "worktrees")); + if (physicalGitDir === physicalWorktreesDir || !isInsidePath(physicalGitDir, physicalWorktreesDir)) return null; + if (dirname(physicalWorktreesDir) !== physicalCommonDir) return null; + + const commondirPointer = readFileSync(join(gitDir, "commondir"), "utf-8").trim(); + const gitdirPointer = readFileSync(join(gitDir, "gitdir"), "utf-8").trim(); + if (!commondirPointer || !gitdirPointer) return null; + if (realpathSync(resolveFrom(gitDir, commondirPointer)) !== physicalCommonDir) return null; + const expectedControlFile = resolve(controlFile); + const backPointer = resolveFrom(gitDir, gitdirPointer); + if (backPointer !== expectedControlFile) return null; + if (realpathSync(backPointer) !== realpathSync(expectedControlFile)) return null; + } catch { + return null; + } + return repoRoot; +} + +export async function managedRepoRootForAbsoluteTarget( + targetPath: string, + repoRootCache: Map>, +): Promise { + if (!isAbsolute(targetPath)) return null; + const worktreesRoot = resolve(defaultWorktreesRoot()); + const target = resolve(targetPath); + if (target === worktreesRoot || !isInsidePath(target, worktreesRoot)) return null; + if (hasUnsafeTargetComponent(worktreesRoot, target)) return null; + + let physicalWorktreesRoot: string; + try { + physicalWorktreesRoot = realpathSync(worktreesRoot); + } catch { + return null; + } + + for (const leaseRoot of managedLeaseRootCandidates(worktreesRoot, target)) { + const repoRoot = await verifiedManagedRepoRoot(leaseRoot, target, physicalWorktreesRoot, repoRootCache); + if (repoRoot) return repoRoot; + } + return null; +} + +async function verifiedManagedRepoRoot( + leaseRoot: string, + target: string, + physicalWorktreesRoot: string, + repoRootCache: Map>, +): Promise { + let repoRootPromise = repoRootCache.get(leaseRoot); + if (!repoRootPromise) { + repoRootPromise = verifiedLinkedWorktreeRoot(leaseRoot); + repoRootCache.set(leaseRoot, repoRootPromise); + } + const repoRoot = await repoRootPromise; + if (!repoRoot) return null; + const resolvedRepoRoot = resolve(repoRoot); + if (resolvedRepoRoot !== resolve(leaseRoot)) return null; + try { + const physicalRepoRoot = realpathSync(resolvedRepoRoot); + if (physicalRepoRoot === physicalWorktreesRoot || !isInsidePath(physicalRepoRoot, physicalWorktreesRoot)) return null; + const probe = dirname(target); + let existingProbe = probe; + while (true) { + try { + lstatSync(existingProbe); + break; + } catch (error) { + if (!isMissingPathError(error)) return null; + } + const parent = dirname(existingProbe); + if (parent === existingProbe || !isInsidePath(parent, resolvedRepoRoot)) return null; + existingProbe = parent; + } + const physicalProbe = realpathSync(existingProbe); + const missingSuffix = relative(existingProbe, target); + if (!missingSuffix || missingSuffix === ".." || missingSuffix.startsWith(`..${sep}`) || isAbsolute(missingSuffix)) return null; + const physicalTarget = existsSync(target) + ? realpathSync(target) + : resolve(physicalProbe, missingSuffix); + if (physicalTarget === physicalRepoRoot || !isInsidePath(physicalTarget, physicalRepoRoot)) return null; + } catch { + return null; + } + return resolvedRepoRoot; +} + +export function threatensRule(targetPath: string, rule: ProtectedPathRule, currentManagedRepoRoot: string | null): boolean { + if (shouldSkipHasnaTreeRule(targetPath, rule, currentManagedRepoRoot)) return false; + if (pathHasGlob(targetPath)) return globThreatensRule(targetPath, rule); + return threatensProtectedPath(targetPath, rule); +} + +export function mutatesRule(targetPath: string, rule: ProtectedPathRule, currentManagedRepoRoot: string | null): boolean { + if (shouldSkipHasnaTreeRule(targetPath, rule, currentManagedRepoRoot)) return false; + return mutatesProtectedPath(targetPath, rule); +} diff --git a/hooks/codewith-native-common/protected-paths.ts b/hooks/codewith-native-common/protected-paths.ts new file mode 100644 index 0000000..b10fe6d --- /dev/null +++ b/hooks/codewith-native-common/protected-paths.ts @@ -0,0 +1,571 @@ +import { isAbsolute, join, relative, resolve, sep } from "path"; +import { homedir } from "os"; +import type { CodewithHookInput } from "./base"; +import { resolveFrom } from "./git-command"; +import { defaultWorktreesRoot, gitRepoRoot, isInsidePath } from "./worktrees"; + +export interface DangerousOperationMatch { + block: boolean; + reason?: string; + targetPath?: string; + protectedPath?: string; + protectedLabel?: string; + operation?: string; +} + +export interface ProtectedPathRule { + root: string; + label: string; + mode: "tree" | "root"; +} + +export interface ProtectedPathContext { + rules: ProtectedPathRule[]; + workspaceRoots: string[]; + currentManagedRepoRoot: string | null; +} + +function splitPathList(value: unknown): string[] { + if (typeof value === "string") return value.split(":").map((v) => v.trim()).filter(Boolean); + if (!Array.isArray(value)) return []; + return value.flatMap((item) => splitPathList(item)); +} + +function inputPathList(input: CodewithHookInput, ...keys: string[]): string[] { + const out: string[] = []; + for (const key of keys) out.push(...splitPathList(input[key])); + return out; +} + +function uniqueResolved(paths: string[], cwd: string): string[] { + return [...new Set(paths.map((path) => resolveFrom(cwd, path)))]; +} + +function workspaceRootsFor(input: CodewithHookInput, cwd: string): string[] { + const home = process.env.HOME || homedir(); + const candidates = [ + ...inputPathList(input, "workspace_roots", "workspaceRoots", "workspace_root", "workspaceRoot"), + ...splitPathList(process.env.CODEWITH_WORKSPACE_ROOTS), + ...splitPathList(process.env.HASNA_WORKSPACE_ROOTS), + join(home, "workspace"), + join(home, "Workspace"), + ]; + return uniqueResolved(candidates, cwd); +} + +function activeRootsFor(input: CodewithHookInput, cwd: string): string[] { + const candidates = [ + ...inputPathList(input, "active_repo_roots", "activeRepoRoots", "active_worktree_roots", "activeWorktreeRoots"), + ...splitPathList(process.env.HASNA_ACTIVE_REPO_ROOTS), + ...splitPathList(process.env.HASNA_ACTIVE_WORKTREE_ROOTS), + ]; + return uniqueResolved(candidates, cwd); +} + +/** + * Filesystem roots a recursive delete must never target wholesale: the FHS system + * directories plus their macOS equivalents, and `/` itself. + * + * `/` is here because of the 2026-07-24 station02 incident: `rm -rf "$(bun pm cache)"/*` + * ran as `rm -rf /*` after the substitution collapsed to empty, freed ~700 GB and + * permanently destroyed one repository's only source copy. Every entry is matched in + * "root" mode, so `rm -rf /usr` and `rm -rf /usr/*` block while `rm -rf /usr/local/lib/mine` + * stays allowed - the guard is about wholesale wipes, not targeted deletes. + * + * `/tmp` is deliberately absent: scratch cleanup there is routine and bounded. + * Machine-specific additions come from HASNA_PROTECTED_SYSTEM_ROOTS (colon-separated). + */ +export const SYSTEM_PROTECTED_ROOTS: readonly string[] = [ + "/", + "/bin", + "/boot", + "/dev", + "/etc", + "/home", + "/lib", + "/lib32", + "/lib64", + "/libx32", + "/opt", + "/proc", + "/root", + "/run", + "/sbin", + "/srv", + "/sys", + "/usr", + "/var", + "/Applications", + "/Library", + "/System", + "/Users", + "/Volumes", + "/private", +]; + +function systemProtectedRulesFor(cwd: string): ProtectedPathRule[] { + const roots = uniqueResolved( + [...SYSTEM_PROTECTED_ROOTS, ...splitPathList(process.env.HASNA_PROTECTED_SYSTEM_ROOTS)], + cwd + ); + return roots.map((root) => ({ + root, + label: root === sep ? "filesystem root /" : `system root ${root}`, + mode: "root" as const, + })); +} + +export function hasnaDivisionRuleFor(target: string, workspaceRoot: string): ProtectedPathRule | null { + const rel = relative(resolve(workspaceRoot), resolve(target)); + if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return null; + const parts = rel.split(sep).filter(Boolean); + if (!parts[0]?.startsWith("hasna")) return null; + if (parts.length === 1) { + return { root: resolve(workspaceRoot, parts[0]), label: `Hasna division root ${parts[0]}`, mode: "root" }; + } + if (parts.length === 2) { + return { root: resolve(workspaceRoot, parts[0], parts[1]), label: `Hasna top-level scope ${parts[0]}/${parts[1]}`, mode: "root" }; + } + return null; +} + +export async function protectedPathContextFor(input: CodewithHookInput, cwd: string): Promise { + const home = process.env.HOME || homedir(); + const rules: ProtectedPathRule[] = [ + // System roots first so a root wipe is reported as the root wipe it is, rather than as + // whichever Hasna path happened to sit underneath it. Overlapping paths are deduplicated + // below with the Hasna rule's more specific label winning. + ...systemProtectedRulesFor(cwd), + { root: join(home, ".hasna"), label: "Hasna state root ~/.hasna", mode: "tree" }, + ]; + const workspaceRoots = workspaceRootsFor(input, cwd); + + for (const root of workspaceRoots) { + rules.push({ root, label: "workspace root", mode: "root" }); + } + + const repoRoot = await gitRepoRoot(cwd); + if (repoRoot) rules.push({ root: repoRoot, label: "active repository root", mode: "root" }); + + for (const root of activeRootsFor(input, cwd)) { + rules.push({ root, label: "active repository or worktree root", mode: "root" }); + } + + const worktreesRoot = resolve(defaultWorktreesRoot()); + const isCurrentManagedRepo = repoRoot !== null + && isInsidePath(cwd, worktreesRoot) + && isInsidePath(repoRoot, worktreesRoot); + const currentManagedRepoRoot = isCurrentManagedRepo ? resolve(repoRoot) : null; + + return { + rules: [...new Map(rules.map((rule) => [resolve(rule.root), { ...rule, root: resolve(rule.root) }])).values()], + workspaceRoots, + currentManagedRepoRoot, + }; +} + +export function threatensProtectedPath(targetPath: string, rule: ProtectedPathRule): boolean { + const target = resolve(targetPath); + const root = resolve(rule.root); + if (rule.mode === "tree") { + return isInsidePath(target, root) || isInsidePath(root, target); + } + return target === root || isInsidePath(root, target); +} + +export function mutatesProtectedPath(targetPath: string, rule: ProtectedPathRule): boolean { + const target = resolve(targetPath); + const root = resolve(rule.root); + if (rule.mode === "tree") return isInsidePath(target, root); + return target === root; +} + +// A trailing glob that matches every entry, so `dir/*` destroys all of `dir`. +const CATCH_ALL_GLOB = /^(?:\*|\*\*|\.\*|\.\[!\.\]\*)$/; + +/** + * Match one glob path component against one literal name, without a regular expression. + * + * Written as a linear matcher on purpose, for two reasons that both bit this branch: + * + * - Regex ESCAPING of `[`/`]` made `[e]tc` compile to a literal no directory can equal, so + * `rm -rf /[e]tc` - which bash expands to `/etc` - matched no protected root. + * - Regex COMPILATION of `*` as `[^/]*` backtracked exponentially: a ~70-character protected + * root component with a dozen `*b` groups took over 45s against this hook's 20s timeout, + * and a timed-out hook fails open. Two fail-opens in the same helper. + * + * A two-pointer wildcard match is O(pattern x name) worst case with no backtracking blowup, + * and bracket handling is explicit rather than delegated to regex syntax that does not mean + * the same thing. Unmatched constructs fall back to "matches", never to "does not match": + * an under-match is silent and fails open, which is exactly how `[e]tc` got through. + */ +function bracketExpressionEnd(pattern: string, open: number): number { + let i = open + 1; + if (pattern[i] === "!" || pattern[i] === "^") i += 1; + // A `]` in first position is a literal member, not the terminator. + if (pattern[i] === "]") i += 1; + while (i < pattern.length) { + const ch = pattern[i]; + if (ch === "\\") { i += 2; continue; } + if (ch === "[" && (pattern[i + 1] === ":" || pattern[i + 1] === "=" || pattern[i + 1] === ".")) { + const kind = pattern[i + 1]; + const classClose = pattern.indexOf(`${kind}]`, i + 2); + const plainClose = pattern.indexOf("]", i + 2); + // The `:]` must come before the next plain `]`, or this is not a class and that `]` + // closes the bracket. Searching to end-of-component let a `:]` belonging to a LATER + // bracket be taken as this one's, swallowing the real terminator - so `[u[:]` absorbed + // the next expression and `/[u[:][[:alpha:]]r`, which bash expands to /usr, matched + // nothing at all. 158 commands onto live system roots were allowed by that one line. + if (classClose === -1 || (plainClose !== -1 && plainClose < classClose)) { + return plainClose; + } + i = classClose + 2; + continue; + } + if (ch === "]") return i; + i += 1; + } + return -1; +} + +/** + * Does this bracket expression match `ch`? + * + * Returns TRUE whenever the expression contains anything this matcher does not model exactly. + * That direction is the entire design, and it is the correction for six consecutive rounds of + * one defect: every bracket bug on this branch has been an UNDER-match, and an under-match + * means a protected root goes unmatched and the delete is allowed. `[e]tc`, `[[:lower:]]`, + * `[e[:]tc`, `[![:foo:]]` and `[a\]e]` each named a real path in bash while the guard held a + * pattern that could match nothing at all. + * + * Over-matching costs a false block on a construct almost nobody writes. Under-matching costs + * a filesystem. So POSIX classes, equivalence and collating classes, and backslash escapes are + * all treated as matching rather than as not-matching. + */ +function bracketMatches(pattern: string, open: number, close: number, ch: string): boolean { + const body = pattern.slice(open + 1, close); + const negated = body.startsWith("!") || body.startsWith("^"); + const members = negated ? body.slice(1) : body; + + // Anything not modelled exactly: fail closed by matching. + if (/\\|\[[:=.]/.test(members)) return true; + + let matched = false; + let first = true; + for (let i = 0; i < members.length; i += 1) { + const member = members[i]; + if (member === "]" && !first) break; + if (members[i + 1] === "-" && i + 2 < members.length && members[i + 2] !== "]") { + if (ch >= member && ch <= members[i + 2]) matched = true; + i += 2; + } else if (ch === member) { + matched = true; + } + first = false; + } + return negated ? !matched : matched; +} + +/** + * Two-pointer wildcard match. Backtracking is limited to the last `*`, so it stays linear in + * practice - the compiled-regex version it replaced backtracked exponentially and blew past + * this hook's 20s timeout, which fails open. + * + * An unterminated or unparseable bracket makes the REST of the component match anything, + * rather than degrading `[` to a literal. The literal reading is an under-match, and + * `rm -rf /[e[:]tc` - which bash expands to `/etc` - slipped through on exactly that path. + */ +function globMatches(pattern: string, name: string): boolean { + let p = 0; + let n = 0; + let starPattern = -1; + let starName = 0; + + while (n < name.length) { + const ch = pattern[p]; + + if (p < pattern.length && ch === "*") { + starPattern = p; + starName = n; + p += 1; + continue; + } + if (p < pattern.length && ch === "?") { + p += 1; + n += 1; + continue; + } + if (p < pattern.length && ch === "[") { + const close = bracketExpressionEnd(pattern, p); + if (close === -1) return true; + if (bracketMatches(pattern, p, close, name[n])) { + p = close + 1; + n += 1; + continue; + } + } else if (p < pattern.length) { + const literal = ch === "\\" && p + 1 < pattern.length ? pattern[p + 1] : ch; + const width = ch === "\\" && p + 1 < pattern.length ? 2 : 1; + if (literal === name[n]) { + p += width; + n += 1; + continue; + } + } + + if (starPattern === -1) return false; + starName += 1; + n = starName; + p = starPattern + 1; + } + + while (pattern[p] === "*") p += 1; + return p >= pattern.length; +} + +/** + * Does this glob keep no literal text that anchors it, so it can match essentially any name? + * `[a-z]*`, `?*`, `.??*` and `*.*` are unanchored; `*.log` and `tmp-*` are anchored. + */ +function isUnanchoredGlob(pattern: string): boolean { + if (!/[*?[]/.test(pattern)) return false; + // Computed once, not per bracket: a `[` with no `]` anywhere is a literal character, so a + // directory named `backup[2026` is anchored by its own name and is not a sweep. + const bracketsArePatterns = pattern.includes("]"); + let residue = ""; + for (let i = 0; i < pattern.length; i += 1) { + const ch = pattern[i]; + if (ch === "\\" && i + 1 < pattern.length) { residue += pattern[i + 1]; i += 1; continue; } + if (ch === "*" || ch === "?") continue; + if (ch === "[" && bracketsArePatterns) { + const close = bracketExpressionEnd(pattern, i); + // Unparseable: the rest matches anything, so nothing after it can anchor. Returning + // here also keeps this linear - re-scanning to end-of-pattern from every `[` was + // quadratic, and a 20k-bracket flood took 22s against the 20s timeout, failing open. + if (close === -1) return true; + i = close; + continue; + } + residue += ch; + } + // A leading dot does not anchor: `.??*` sweeps a directory just as `*` does. Nor does + // punctuation alone: `*.*` takes every dotted entry at the root. + return residue.replace(/^\./, "").replace(/[.\-_]/g, "").length === 0; +} + + +/** Does this component actually glob, or is it a literal that merely contains a bracket? */ +function componentIsPattern(component: string): boolean { + if (/[*?]/.test(component)) return true; + return component.includes("[") && component.includes("]"); +} + +export function globComponentMatches(pattern: string, literal: string): boolean { + if (!/[*?[]/.test(pattern)) return pattern === literal; + // `[` with no `]` anywhere and no other wildcard is a literal bracket, not an expression. + // Without this, a directory genuinely named `backup[2026` was escalated to "wipes the + // repository root" - fail-closed matching has to stop where bash stops globbing. + if (!pattern.includes("]") && !/[*?]/.test(pattern)) return pattern === literal; + // Fail closed on an ambiguous BOUNDARY, not just ambiguous contents. + // + // This is the defect that survived eight review rounds. Bracket CONTENTS already failed + // closed, but the boundary was still computed exactly - and every disagreement with bash + // about where a bracket ENDS misaligns the rest of the component and silently reports "no + // match", which allows the delete. Round 6 searched to end-of-component for the class + // terminator and swallowed later brackets; round 7 stopped at the first plain `]`, which is + // backwards (inside `[:`, a plain `]` does not terminate) and reopened the class net worse: + // 220 -> 380 live root-wipe escapes. + // + // Every one of those 380 contained `[:`, `[=` or `[.`. Plain brackets, ranges, negation, + // `*`, `?` and backslash escapes were measured clean across 44,867 dangerous patterns. So + // the guard stops trying to locate a boundary it cannot pin down: a component containing a + // POSIX class, equivalence class or collating symbol matches anything. + if (/\[[:=.]/.test(pattern)) return true; + if (CATCH_ALL_GLOB.test(pattern)) return true; + return globMatches(pattern, literal); +} + +/** + * Could this glob pattern match `root` itself, or an ancestor of it? + * + * If it can, every expansion that lands there takes `root` with it. A pattern DEEPER than + * `root` cannot: `*​/node_modules` from a repo root deletes `/node_modules`, never the + * repo root, which is why matching only on "the first glob's parent directory" wrongly blocked + * `rm -rf *​/node_modules` - a daily monorepo command, and exactly the kind of false positive + * that gets a guard switched off. + */ +function globPatternCovers(patternParts: string[], rootParts: string[]): boolean { + if (patternParts.length > rootParts.length) return false; + return patternParts.every((part, index) => globComponentMatches(part, rootParts[index])); +} + +/** Components of the pattern up to, but not including, its first glob component. */ +function literalPrefixOf(parts: string[]): string { + const globIndex = parts.findIndex((part) => /[*?[]/.test(part)); + return (globIndex === -1 ? parts : parts.slice(0, globIndex)).join(sep) || sep; +} + +export function pathHasGlob(targetPath: string): boolean { + return /[*?[]/.test(resolve(targetPath)); +} + +/** + * Does a glob delete threaten this rule? + * + * Two ways, and both are needed: + * (a) the pattern can match the protected root or an ancestor of it - `rm -rf /*` matches + * `/home`, `rm -rf /*​/*` matches `/home/hasna`; + * (b) the pattern is a wholesale wipe of the root's own contents - `rm -rf /home/*`, whose + * last component is a catch-all and whose prefix covers `/home`. + * For a tree rule, a pattern sitting inside the tree also threatens it. + */ +export function globThreatensRule(targetPath: string, rule: ProtectedPathRule): boolean { + const parts = resolve(targetPath).split(sep); + const rootParts = resolve(rule.root).split(sep); + + if (rule.mode === "tree") { + if (isInsidePath(literalPrefixOf(parts), rule.root)) return true; + // A pattern deeper than the root can still land inside it: `~/.h*/repos` matches + // ~/.hasna/repos. literalPrefixOf stops before the first glob, so it misses this. + if (parts.length > rootParts.length && globPatternCovers(parts.slice(0, rootParts.length), rootParts)) { + return true; + } + } + if (globPatternCovers(parts, rootParts)) return true; + + const last = parts[parts.length - 1]; + if (CATCH_ALL_GLOB.test(last) && globPatternCovers(parts.slice(0, -1), rootParts)) return true; + + // A glob in the last component sweeps the contents of its own parent. When that parent IS + // the protected root AND the pattern is unanchored, the sweep guts the root: `rm -rf [a-z]*` + // or `?*` at a repo root take almost everything. + // + // "Unanchored" means no literal character survives once wildcards are removed. That + // distinction is the whole point: `*.log`, `tmp-*`, `.turbo*` and `snapshot-[0-9]*` are + // anchored by their literal text and cannot take the root, and blocking them - which the + // blunt any-metacharacter version did - re-broke twelve everyday repo-root cleanups. A + // guard that blocks routine work gets switched off. + if (isUnanchoredGlob(last) && mutatesProtectedPath(parts.slice(0, -1).join(sep) || sep, rule)) return true; + + // A catch-all in the FIRST component sweeps every top-level directory: `/*/bin` deletes + // /usr/bin, /var/bin and the rest, and a trailing literal makes the pattern deeper than any + // single root, so component matching alone misses it. Scoped to the filesystem root so + // ordinary sweeps deeper down - `/opt/*/logs`, `/var/*/tmp`, `*/node_modules` - stay allowed. + // At the filesystem root, ANY glob in the first component reaches several top-level + // directories: `/*r*/lib` matched 11 of 25 entries on the reference machine, and `/?*/bin` + // and `/[a-z]*/bin` reach /usr/bin exactly as `/*/bin` does. A single literal character is + // not an anchor at this depth, so the sweep rule does not ask for one. The cost is refusing + // `rm -rf /tmp*/x`, which is rare and safe to spell out literally. + if (rule.root === sep && parts.length > 1 && componentIsPattern(parts[1])) return true; + + return false; +} + +const MAX_BRACE_EXPANSIONS = 64; +const MAX_BRACE_ROUNDS = 16; + +/** Expand only the leftmost brace group of a token; null when there is none to expand. */ +function expandLeftmostBrace(token: string): string[] | null { + // Skip `${…}` parameter expansions when looking for an alternation: their brace is not a + // brace group, and treating it as one abandoned expansion for the whole token, so + // `rm -rf "${HOME}"/{,.hasna}` was never expanded at all. + let open = -1; + for (let i = 0; i < token.length; i += 1) { + if (token[i] !== "{") continue; + if (i > 0 && token[i - 1] === "$") { + let depth = 0; + for (; i < token.length; i += 1) { + if (token[i] === "{") depth += 1; + else if (token[i] === "}") { depth -= 1; if (depth === 0) break; } + } + continue; + } + open = i; + break; + } + if (open === -1) return null; + + let depth = 0; + let close = -1; + const parts: string[] = []; + let current = ""; + for (let i = open; i < token.length; i += 1) { + const ch = token[i]; + if (ch === "\\") { current += ch + (token[i + 1] ?? ""); i += 1; continue; } + if (ch === "{") { + depth += 1; + if (depth === 1) continue; + } else if (ch === "}") { + depth -= 1; + if (depth === 0) { close = i; break; } + } else if (ch === "," && depth === 1) { + parts.push(current); + current = ""; + continue; + } + current += ch; + } + if (close === -1 || parts.length === 0) return null; + parts.push(current); + + const prefix = token.slice(0, open); + const suffix = token.slice(close + 1); + return parts.map((part) => `${prefix}${part}${suffix}`); +} + +/** + * Expand `{a,b}` alternations, so `rm -rf /{bin,etc,home}` is seen as the three root deletes + * it performs rather than as one literal path. + * + * Expansion is breadth-first and abandoned the moment it exceeds the cap, because brace + * expansion is combinatorial: `/{a,b}` repeated 26 times is 2^26 paths. A recursive version + * that capped only the finished list took 19.75s on that input, past this hook's 20s timeout + * - and a hook that times out fails open, so a long enough brace string would have switched + * the guard off and then run the delete. + * + * Abandoning does NOT return the raw token. Doing that was itself a bypass: + * `rm -rf /{a0,…,a69,etc}` exceeded the cap and the unexpanded token resolved to a literal + * path matching no protected root. Instead the brace-free prefix is returned as a catch-all + * wipe, which is what an unbounded alternation under that prefix actually is - every + * expansion is necessarily a child of it. + */ +function braceAbandonFallback(token: string): string[] { + const open = token.indexOf("{"); + const prefix = open === -1 ? token : token.slice(0, open); + const base = prefix.endsWith(sep) || prefix === "" ? prefix : `${prefix}${sep}`; + const fallback = [`${base}*`]; + // An alternative that is itself absolute is NOT a child of the prefix: `rm -rf {/etc,a0,…}` + // expands to `rm -rf /etc a0 …`, so the prefix-based fallback would miss `/etc` entirely. + if (/[{,]\s*\//.test(token)) fallback.push(`${sep}*`); + return fallback; +} + +export function expandBraces(token: string): string[] { + if (!token.includes("{")) return [token]; + + let frontier = [token]; + for (let round = 0; round < MAX_BRACE_ROUNDS; round += 1) { + const next: string[] = []; + let expandedAny = false; + for (const item of frontier) { + const parts = expandLeftmostBrace(item); + if (parts === null) { + next.push(item); + continue; + } + expandedAny = true; + for (const part of parts) { + if (next.length >= MAX_BRACE_EXPANSIONS) return braceAbandonFallback(token); + next.push(part); + } + } + if (!expandedAny) return next; + frontier = next; + } + return braceAbandonFallback(token); +} + +// `${VAR:?}` / `${VAR:?message}` aborts the shell when VAR is unset *or* empty, so this +// form cannot collapse. It is the POSIX way to assert a path is present, and blocking it +// would punish exactly the defensive code this guard asks for. `${VAR?}` without the colon +// is NOT exempt: it permits an empty value, which is the whole hazard. diff --git a/hooks/codewith-native-common/shell-expansions.ts b/hooks/codewith-native-common/shell-expansions.ts new file mode 100644 index 0000000..06bf6e9 --- /dev/null +++ b/hooks/codewith-native-common/shell-expansions.ts @@ -0,0 +1,402 @@ +import { shellWords, splitShellSegmentsDetailed, type ShellSegment } from "./git-command"; + +const GUARDED_EXPANSION = /^\$\{[A-Za-z_][A-Za-z0-9_]*:\?/; +const NON_EMPTY_PLACEHOLDER = "__hooks_guarded_expansion__"; +export const MAX_EXPANSION_NESTING = 32; + +// Builtins whose effect on a variable this scan cannot follow at all. Any of them clears +// every guarantee, because guessing in the permissive direction is how `$X` stayed certified +// non-empty while the shell had already emptied it. +const OPAQUE_BUILTINS = new Set(["eval", "source", ".", "trap", "coproc", "exec"]); + +// Compound-command keywords that can precede an assignment in the same segment. +const COMPOUND_KEYWORDS = new Set(["{", "}", "then", "do", "else", "elif", "fi", "done", "!"]); + +// Sentinel marking PWD as reassigned, so $PWD stops being treated as shell-maintained. +const PWD_REASSIGNED = "\u0000PWD-REASSIGNED"; + +// Builtins that bind a BARE name, with no `=` in sight: `read D`, `getopts o D`. +const NAME_BINDING_BUILTINS = new Set(["read", "getopts", "mapfile", "readarray"]); + +// Builtins that take `NAME=value` operands. A BARE name here does not change the variable - +// `export X` merely exports the existing value - so bare names must not withdraw anything. +const VALUE_BINDING_BUILTINS = new Set(["export", "declare", "typeset", "readonly", "local", "let"]); + +/** One shell expansion found in a token, with its exact source span. */ +interface FoundExpansion { + text: string; + start: number; + end: number; +} + +/** + * Locate shell expansions by scanning with a depth counter rather than by regex. + * + * A regex has to fix a nesting depth, and every fixed depth is a bypass: + * `$(dirname "$(dirname "$(bun pm cache)")")` is three deep, and `${A:-${B}}` nests braces. + */ +export function findExpansions(token: string): FoundExpansion[] { + const found: FoundExpansion[] = []; + for (let i = 0; i < token.length; i += 1) { + if (token[i] === "\\") { + i += 1; + continue; + } + if (token[i] === "`") { + const end = token.indexOf("`", i + 1); + if (end === -1) break; + found.push({ text: token.slice(i, end + 1), start: i, end: end + 1 }); + i = end; + continue; + } + if (token[i] !== "$") continue; + + const next = token[i + 1]; + if (next === "(" || next === "{") { + const open = next; + const close = open === "(" ? ")" : "}"; + let depth = 0; + let quote: "'" | '"' | null = null; + let j = i + 1; + for (; j < token.length; j += 1) { + const ch = token[j]; + // An escaped character is data whether or not a quote is open: `$(echo \')`. + if (ch === "\\") { j += 1; continue; } + // A paren inside quotes is data, not structure: `awk -F'(' '{print $2}'`. + if (quote) { + if (ch === quote) quote = null; + continue; + } + if (ch === "'" || ch === '"') { quote = ch; continue; } + if (ch === open) depth += 1; + else if (ch === close) { + depth -= 1; + if (depth === 0) break; + } + } + if (depth !== 0) break; + found.push({ text: token.slice(i, j + 1), start: i, end: j + 1 }); + i = j; + continue; + } + const simple = token.slice(i).match(/^\$(?:[A-Za-z_][A-Za-z0-9_]*|[0-9@*?#$!-])/); + if (simple) { + found.push({ text: simple[0], start: i, end: i + simple[0].length }); + i += simple[0].length - 1; + } + } + return found; +} + +/** + * True when the shell cannot hand this expansion back empty. + * + * Every entry is a guarantee, not a guess. Getting this wrong in the permissive direction + * reopens the incident; getting it wrong in the strict direction blocks routine cleanup, + * which gets the guard switched off. Both failures are real, so only provable cases qualify. + */ +function expansionCannotBeEmpty(text: string, nonEmptyNames: ReadonlySet): boolean { + // ${VAR:?} / ${VAR:?message} - POSIX aborts on unset or empty. + if (GUARDED_EXPANSION.test(text)) return true; + + // ${VAR:-default} with a non-empty default. `:-` substitutes the default when VAR is unset + // OR empty, so the result is non-empty. Plain `${VAR-default}` does NOT qualify: it only + // covers unset, so a set-but-empty VAR still yields "". + // $PWD and $(pwd) are maintained by the shell, but only while nothing reassigns PWD. + if (text === "$PWD" || text === "${PWD}" || /^\$\(\s*pwd\s*\)$/.test(text) || /^`\s*pwd\s*`$/.test(text)) { + return !nonEmptyNames.has(PWD_REASSIGNED); + } + + // Assigned a non-empty literal earlier in this same command. + const name = text.match(/^\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?$/); + return name !== null && nonEmptyNames.has(name[1]); +} + +/** + * Value an expansion is guaranteed to take when the variable is unset or empty, or null when + * there is no such guarantee. + * + * `${VAR:-default}` substitutes the default whenever VAR is unset OR empty, so the worst case + * is the default itself - and the default is used verbatim rather than assumed harmless. + * `${A:-/}` therefore collapses to `/` and blocks, where treating "has a default" as "is safe" + * let it through. Plain `${VAR-default}` does NOT qualify: it only covers unset, so a + * set-but-empty VAR still yields "". + */ +function expansionFallbackValue( + text: string, + nonEmptyNames: ReadonlySet, + depth = 0 +): string | null { + const withDefault = text.match(/^\$\{[A-Za-z_][A-Za-z0-9_]*:-([\s\S]*)\}$/); + if (!withDefault) return null; + // Bounded because this recurses once per nesting level while re-scanning the remainder: + // `${A:-${A:- … }}` 40k deep overflowed the stack, the hook caught it and answered + // {"continue":true}, and the `rm -rf /*` in the same command was never classified at all. + // Past the cap there is no guarantee left to prove, so the value is treated as collapsible, + // which blocks rather than allows. + if (depth >= MAX_EXPANSION_NESTING) return ""; + const fallback = withDefault[1]; + if (fallback.length === 0) return ""; + + let value = ""; + let cursor = 0; + for (const inner of findExpansions(fallback)) { + value += fallback.slice(cursor, inner.start); + const nested = expansionFallbackValue(inner.text, nonEmptyNames, depth + 1); + if (nested !== null) value += nested; + else if (expansionCannotBeEmpty(inner.text, nonEmptyNames)) value += NON_EMPTY_PLACEHOLDER; + cursor = inner.end; + } + value += fallback.slice(cursor); + return value; +} + +/** + * The shape that destroyed station02 on 2026-07-24. + * + * `bun pm cache` writes its path to stdout on success, but exits 1 with an empty stdout + * when no package.json is found walking up from cwd. `rm -rf "$(bun pm cache)"/*` therefore + * became `rm -rf /*`. Redirecting stderr does not help: the redirect discards the + * diagnostic, not the path. The hazard is not this command - it is any expansion the shell + * may hand back empty, immediately followed by a path separator. + * + * Returns the token with every expansion replaced by the empty string, i.e. the worst case + * the shell can produce. Returns null when: + * - the token contains no expansion; or + * - the collapse is not absolute. A bare `rm -rf "$(cmd)"` collapses to `rm -rf ""`, which + * POSIX rm rejects with "cannot remove ''" and a non-zero exit without deleting anything, + * and blocking it would break routine `rm -rf "$tmpdir"` cleanup for no safety gain. A + * relative collapse stays inside cwd and is already covered by the ordinary target check. + * The whole catastrophic class is the one where the collapse leaves a leading `/`. + */ +export function emptyExpansionCollapse( + token: string, + nonEmptyNames: ReadonlySet = new Set() +): string | null { + if (!/[$`]/.test(token)) return null; + const expansions = findExpansions(token); + if (expansions.length === 0) return null; + + let sawCollapsible = false; + let collapsed = ""; + let cursor = 0; + for (const expansion of expansions) { + collapsed += token.slice(cursor, expansion.start); + const fallback = expansionFallbackValue(expansion.text, nonEmptyNames); + if (fallback !== null) { + // The default IS the worst case, so the resulting path still has to be checked - + // `${A:-/}` yields `/`, which is the whole hazard, not a reason to skip the check. + collapsed += fallback; + sawCollapsible = true; + } else if (expansionCannotBeEmpty(expansion.text, nonEmptyNames)) { + collapsed += NON_EMPTY_PLACEHOLDER; + } else { + sawCollapsible = true; + } + cursor = expansion.end; + } + collapsed += token.slice(cursor); + + if (!sawCollapsible || !collapsed.startsWith("/")) return null; + return collapsed; +} + +/** + * One set per segment: the variables provably non-empty at the moment that segment runs. + * + * Built in a SINGLE forward pass. The previous version recomputed the whole segmentation and + * rescanned every preceding segment on each call, and was called once per chunk - O(segments²). + * 36 KB of `:; ` padding took 25.6s against this hook's 20s timeout, and a timed-out hook fails + * open, so padding alone turned a blocked `rm -rf /*` into an unguarded one. That is the same + * fail-open the wrapper caps were written to stop, reopened along a different axis. + * + * Every relaxation here is a way past the guard, so each condition is a guarantee: + * + * X=/tmp/build rm -rf "$X"/* a PREFIX assignment applies to the command's own + * environment, not to the expansion, which bash performs + * first; `$X` is still empty + * rm -rf "$X"/* ; X=/tmp/build an assignment AFTER the delete counted + * X=/tmp/build; X=$(cmd); rm … a later reassignment to something collapsible + * X=/tmp/build; X=; rm … an explicit empty reassignment + * X=/tmp/build; unset X; rm … an unset + * (X=/tmp/build); rm … a subshell-scoped assignment escaping its subshell + * X=/tmp/build | cat; rm … a pipeline-stage assignment doing the same + */ +export function assignmentWalker(command: string): { at: (segmentIndex: number) => ReadonlySet } { + const segments = splitShellSegmentsDetailed(command); + let cursor = 0; + let current = new Set(); + + // Advances a SINGLE set forward and hands it out only when a segment actually contains a + // delete. Materialising one snapshot per segment was O(segments x names): 30k distinct + // names took 24.4s against the 20s timeout, and a timed-out hook fails open. Almost every + // command has one delete, so almost every command now copies nothing. + const at = (segmentIndex: number): ReadonlySet => { + while (cursor < segmentIndex && cursor < segments.length) { + applySegment(segments[cursor]); + cursor += 1; + } + return current; + }; + + // Depth of open `if` / `while` / `until` / `case` blocks. Everything inside one may not run. + let conditionalDepth = 0; + // Brace-group nesting, and the depth at which a `&&`/`||` right-hand side was entered. + let braceDepth = 0; + let conditionalBraceDepth = 0; + + function applySegment({ text, depth, isolated, shortCircuit }: ShellSegment): void { + + + // `{ X=; }`, `then X=`, `do X=` - strip the compound-command keyword so the assignment + // inside is seen. cwdTrackedSegments already did this; this scan did not, so + // `X=/tmp/build; { X=; }; rm -rf "$X"/*` kept X certified while bash emptied it. + const rawTokens = shellWords(text); + const tokens = rawTokens.filter((token, index) => !(index === 0 && COMPOUND_KEYWORDS.has(token))); + // An assignment that may never execute must not CERTIFY, though it must still WITHDRAW - + // the branch might run. Conditionality is a property of context, so it is tracked across + // segments rather than read off the first token of this one. Deriving it from "a compound + // keyword was stripped from token 0" closed about 5% of the class: inserting one statement + // (`if false; then A=1; X=/tmp/build; fi`) or using `&&`/`||`/`case` restored certification, + // and 297 of those shapes were bash-proven `rm -rf /*`. + // + // A brace group `{ …; }` is NOT conditional - bash runs it in the current shell - so it is + // deliberately excluded here even though its keyword is stripped for tokenizing. + // `for` opens because `done` closes it - omitting it while keeping `done` a closer let any + // `for` loop inside a conditional zero the counter. `elif` does NOT open: `fi` closes an + // if/elif/else chain exactly once, so counting elif left the depth permanently above zero + // and nothing after the block could ever certify. + const OPENERS = new Set(["if", "while", "until", "case", "select", "for"]); + const CLOSERS = new Set(["fi", "done", "esac"]); + // Keywords that introduce the NEXT command rather than being one, so the real command + // token sits behind them: `then for f in …` opens a loop that `done` will close. + const INTRODUCERS = new Set(["then", "do", "else", "elif", "!", "{", "}", "("]); + + // Only a keyword in COMMAND POSITION is a keyword. `echo done`, `touch fi` and a `fi` + // inside a heredoc body or after `#` are ordinary words, and treating them as closers + // decremented the counter and re-certified the branch. + let leadingIndex = 0; + while (leadingIndex < rawTokens.length && INTRODUCERS.has(rawTokens[leadingIndex])) leadingIndex += 1; + const leading = rawTokens[leadingIndex]; + const introducer = rawTokens[0]; + + if (leading !== undefined) { + if (OPENERS.has(leading)) conditionalDepth += 1; + else if (CLOSERS.has(leading)) conditionalDepth = Math.max(0, conditionalDepth - 1); + } + + // `&&`/`||` govern the WHOLE right-hand side, including a brace group. Marking only the + // first segment after the operator let `false && { A=1; X=/tmp/build; }` certify X. + if (introducer === "{") braceDepth += 1; + if (introducer === "}" || rawTokens[rawTokens.length - 1] === "}") { + braceDepth = Math.max(0, braceDepth - 1); + if (braceDepth < conditionalBraceDepth) conditionalBraceDepth = 0; + } + if (shortCircuit && braceDepth > 0 && conditionalBraceDepth === 0) conditionalBraceDepth = braceDepth; + + // Keyword accounting happened above, deliberately BEFORE this return: `if ls /opt | grep + // -q node; then …; CACHE=…; fi` marks the `if` segment isolated (it is followed by `|`), + // so returning first swallowed the opener while its `fi` still decremented - and the + // assignment after it certified. That is the realized incident shape. + if (depth > 0 || isolated) return; + + const conditional = conditionalDepth > 0 + || shortCircuit + || conditionalBraceDepth > 0 + || (leading !== undefined && OPENERS.has(leading)) + || (introducer !== undefined && (introducer === "then" || introducer === "do" || introducer === "elif")); + // A function body runs later and elsewhere, so nothing in it can be relied on. + if (/^[A-Za-z_][A-Za-z0-9_]*\s*\(\s*\)/.test(text) || rawTokens[0] === "function") { + current = new Set(); + return; + } + if (tokens.length === 0) return; + + if (tokens[0] === "unset") { + for (const name of tokens.slice(1)) { + if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) current.delete(name); + } + return; + } + + // Any construct that can rebind a name withdraws the guarantee. Scanned across ALL + // tokens, not just the first: `IFS= read -r D` hides the builtin behind a prefix + // assignment and `while read D` behind a keyword, and both kept D certified non-empty. + // + // Single pass, no slicing. Allocating `tokens.slice(position + 1)` per token made this + // O(tokens^2): 20k `export A=1 ` took 20.9s against the 20s timeout, and a timed-out hook + // fails open - the fourth time a bound in this file reopened that same hole. + // + // WITHDRAWAL is scanned at any position, because a rebinding can hide anywhere. + // CERTIFICATION is granted only from token 0, because a mention is not an execution: + // `# export CACHE=/tmp/x` in a comment certified CACHE as non-empty, which is the realized + // incident shape exactly - a documented cleanup script is the likeliest way to write it. + const withdraw = (name: string) => { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) return; + current.delete(name); + }; + + let pendingNameBinder = false; + let pendingValueBinder = false; + let valueBinderIsCommand = false; + let sawNameref = false; + let opaque = false; + let sawNonAssignment = false; + + for (const [position, token] of tokens.entries()) { + if (OPAQUE_BUILTINS.has(token)) { opaque = true; break; } + + if (NAME_BINDING_BUILTINS.has(token) || token === "for") { + pendingNameBinder = true; + pendingValueBinder = false; + sawNonAssignment = true; + continue; + } + if (VALUE_BINDING_BUILTINS.has(token)) { + pendingValueBinder = true; + pendingNameBinder = false; + // Only a builtin in command position can actually bind anything. + valueBinderIsCommand = position === 0; + sawNameref = false; + sawNonAssignment = true; + continue; + } + if (token === "printf") { sawNonAssignment = true; continue; } + if (token === "-v") { pendingNameBinder = true; continue; } + if (token === "-n" && pendingValueBinder) { sawNameref = true; continue; } + + if (pendingNameBinder) { + if (!token.startsWith("-")) withdraw(token); + continue; + } + if (pendingValueBinder) { + if (token.startsWith("-")) continue; + const bound = token.match(/^([A-Za-z_][A-Za-z0-9_]*)=([\s\S]*)$/); + if (!bound) continue; + withdraw(bound[1]); + // `declare -n D=E` aliases D to E, so D's value is E's, not this literal. + if (!conditional && valueBinderIsCommand && !sawNameref && bound[2].length > 0 && !/[$`]/.test(bound[2])) { + current.add(bound[1]); + } + continue; + } + + // Plain `NAME=value`, only while still in the command's assignment prefix. + const assignment = token.match(/^([A-Za-z_][A-Za-z0-9_]*)=([\s\S]*)$/); + if (!assignment) { sawNonAssignment = true; continue; } + if (sawNonAssignment) continue; + const [, name, value] = assignment; + // A PREFIX assignment applies to the command's environment, not to this expansion. + const isPrefixAssignment = position < tokens.length - 1 + && !/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[position + 1] ?? ""); + current.delete(name); + if (name === "PWD") current.add(PWD_REASSIGNED); + if (isPrefixAssignment) continue; + if (!conditional && value.length > 0 && !/[$`]/.test(value)) current.add(name); + } + + if (opaque) current = new Set(); + } + + return { at }; +} diff --git a/hooks/codewith-native-common/worktrees.ts b/hooks/codewith-native-common/worktrees.ts new file mode 100644 index 0000000..6296a60 --- /dev/null +++ b/hooks/codewith-native-common/worktrees.ts @@ -0,0 +1,421 @@ +import { existsSync, lstatSync, readFileSync, realpathSync } from "fs"; +import { isAbsolute, join, relative, resolve, sep } from "path"; +import { homedir } from "os"; +import { commandExists, runCommand, type CodewithHookInput } from "./base"; +import { resolveFrom } from "./git-command"; + +export function defaultWorktreesRoot(): string { + // Same home for every resolution in this file; see expandHome. + return process.env.HASNA_REPOS_WORKTREES_ROOT + || join(process.env.HOME || homedir(), ".hasna", "repos", "worktrees"); +} + +export function isInsidePath(child: string, parent: string): boolean { + const rel = relative(resolve(parent), resolve(child)); + return rel === "" || (!!rel && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)); +} + +/** + * Canonical managed-worktree path shape. + * + * Source of truth: Hasna Agent Operating Rules rule 8, as published by the + * @hasna/identities 0.4.4 global agent rules, verbatim: + * + * "must happen in a task-specific worktree at + * $HOME/.hasna/repos/worktrees// + * (repo name then worktree name; no station-id or machine segment, + * never flat under the worktrees root)" + * + * So, relative to the worktrees root, a compliant worktree root is exactly two + * segments deep: /. + */ +export const CANONICAL_WORKTREE_SEGMENTS = 2; + +/** + * Depth of the DEPRECATED station-id lease layout + * (`/-/wt_`) that predates rule 8. + * + * Read-only migration tolerance: it is never a compliant target shape, and it is + * never reported as `managed`. It is recognised only so that (a) guard messages + * can name it precisely and (b) the scoped dangerous-operation carve-out keeps + * working for worktrees created before the canonical shape was mandated. + */ +export const LEGACY_LEASE_WORKTREE_SEGMENTS = 3; + +// Any ordinary directory name, bounded by the filesystem's own limit rather than an +// allowlist — repo and worktree names are user data, and an over-narrow pattern would +// reject legitimate work (real fleet names include `_base`). Refused: a leading `.`, +// so `.`, `..` and `.git` can never be read as a segment; a leading `-`, so a segment +// can never read as an option in the remediation command; and control characters. +const WORKTREE_SEGMENT_PATTERN = /^[^.\-\/\x00-\x1f][^\/\x00-\x1f]{0,254}$/; +const LEGACY_LEASE_REPO_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*-[0-9a-fA-F]{7,16}$/; +const LEGACY_LEASE_ID_PATTERN = /^wt_[0-9a-fA-F]{16,64}$/; + +/** + * Whether the deprecated station-id lease layout still gets its migration tolerance. + * + * Default on, so the change does not strand worktrees created before rule 8. It is a + * kill switch, not a policy knob: the layout is non-compliant either way, and the + * tolerance only softens the verdict from blocked to warned. Set + * `HASNA_HOOKS_LEGACY_WORKTREE_TOLERANCE=0` once those worktrees are re-homed; the + * whole branch goes away after that. + * + * Known limitation while it is on: the tolerance keys off the path name, so a newly + * created worktree deliberately named to match also gets the warn tier. That is an + * opt-out from a guardrail by a cooperating agent, not a security boundary — the + * boundary is the provenance proof above, which applies to both tiers. + */ +export function legacyWorktreeToleranceEnabled(): boolean { + return process.env.HASNA_HOOKS_LEGACY_WORKTREE_TOLERANCE !== "0"; +} + +export type ManagedWorktreeLayout = "canonical" | "legacy-station-lease"; + +export interface ManagedWorktreeInfo { + managed: boolean; + /** The worktrees root the path was classified against. */ + root: string; + /** Recognised layout, set for compliant and for deprecated-but-recognised paths. */ + layout?: ManagedWorktreeLayout; + /** True when the layout is recognised but no longer permitted by rule 8. */ + deprecated?: boolean; + repo?: string; + worktree?: string; + /** Absolute path of the worktree root that owns `cwd`. */ + worktreeRoot?: string; + reason?: string; +} + +/** The canonical worktree path template, for user-facing guard messages. */ +export function canonicalWorktreeTemplate(root: string = defaultWorktreesRoot()): string { + return join(root, "", ""); +} + +/** + * Prove that `worktreeRoot` owns its own git history, synchronously. + * + * Shape is not evidence and neither is the mere presence of `.git`. A `.git` file is + * two lines of text: pointing it at a shared checkout's `.git` grafts a second working + * tree onto that checkout, so `git commit`/`git push` from the forged directory lands + * on the shared checkout — the exact outcome rule 10 forbids. So a `.git` file must + * carry real linked-worktree provenance: + * + * - its `gitdir:` target must live under `/worktrees/`, and + * - that target's `gitdir` back-pointer must resolve to this very control file. + * + * A `.git` directory is accepted only as a self-contained repository. A `commondir` + * grafts it onto another repository's history outright, and symlinked `objects` or + * `refs` graft it onto another repository's refs — reaching the same end state as a + * forged `.git` file without writing anything inside the victim. + * + * This is a structural proof only. It is deliberately close to, but not the same as, + * the async verifiedLinkedWorktreeRoot() used for the write carve-out, which is + * stricter still (regular-file control file, nlink === 1, worktrees dir directly + * under the common dir). + */ +function worktreeProvenanceReason(worktreeRoot: string): string | null { + const controlPath = join(worktreeRoot, ".git"); + let control; + try { + control = lstatSync(controlPath); + } catch { + return `${worktreeRoot} is not a git worktree root (no .git)`; + } + if (control.isSymbolicLink()) return `worktree .git is a symlink at ${worktreeRoot}`; + + if (control.isDirectory()) { + if (existsSync(join(controlPath, "commondir"))) { + return `worktree .git is grafted onto another repository at ${worktreeRoot}`; + } + if (!existsSync(join(controlPath, "HEAD"))) return `worktree .git is not a repository at ${worktreeRoot}`; + // A self-contained repository owns its object and ref storage. Symlinking either + // into another repository makes commits here land on that repository's refs. + for (const store of ["objects", "refs"]) { + let metadata; + try { + metadata = lstatSync(join(controlPath, store)); + } catch { + return `worktree .git is missing ${store} at ${worktreeRoot}`; + } + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + return `worktree .git ${store} is grafted onto another repository at ${worktreeRoot}`; + } + } + return null; + } + if (!control.isFile()) return `worktree .git is not a file or directory at ${worktreeRoot}`; + + try { + const pointer = readFileSync(controlPath, "utf-8").trim(); + const match = pointer.match(/^gitdir:\s*(.+)$/); + if (!match?.[1]) return `worktree .git is not a git worktree pointer at ${worktreeRoot}`; + const gitDir = resolveFrom(worktreeRoot, match[1].trim()); + const commonDir = resolveFrom(gitDir, readFileSync(join(gitDir, "commondir"), "utf-8").trim()); + const physicalGitDir = realpathSync(gitDir); + const physicalWorktreesDir = realpathSync(join(commonDir, "worktrees")); + if (physicalGitDir === physicalWorktreesDir || !isInsidePath(physicalGitDir, physicalWorktreesDir)) { + return `worktree .git points outside its repository's worktrees directory at ${worktreeRoot}`; + } + const backPointer = resolveFrom(gitDir, readFileSync(join(gitDir, "gitdir"), "utf-8").trim()); + if (realpathSync(backPointer) !== realpathSync(controlPath)) { + return `worktree .git is not registered by its repository at ${worktreeRoot}`; + } + } catch { + return `worktree .git provenance could not be verified at ${worktreeRoot}`; + } + return null; +} + +/** + * Verify that `//` is a real, non-symlinked, provenance-checked + * git worktree root. + * + * Path shape alone is not evidence: `//` has exactly the + * same shape as `//`, so without this check a `cd` into any + * subdirectory of a flat worktree would launder it into a compliant-looking path. + * Symlinks are refused at every level (hence lstat, not existsSync, which follows + * them) because a symlinked segment can aim a canonical-looking path at a shared + * checkout. + */ +function groundedWorktreeRootReason(root: string, segments: string[]): string | null { + let probe = resolve(root); + for (const segment of segments) { + probe = join(probe, segment); + let metadata; + try { + metadata = lstatSync(probe); + } catch { + return `no worktree exists at ${probe}`; + } + if (metadata.isSymbolicLink()) return `worktree path traverses a symlink at ${probe}`; + if (!metadata.isDirectory()) return `worktree path is not a directory at ${probe}`; + } + return worktreeProvenanceReason(probe); +} + +/** + * Classify a path against the canonical managed-worktree shape (rule 8). + * + * Accepted: a real git worktree root at `//`, + * and any path inside it. Rejected, each with a reason: paths outside the worktrees + * root, the root itself, flat single-segment worktrees, station-id/machine segments, + * deeper nesting, and canonical-shaped paths that are not actually a worktree root + * (invented, symlinked, or a subdirectory of a flat worktree). + */ +export function managedWorktreeInfo(cwd: string): ManagedWorktreeInfo { + const root = defaultWorktreesRoot(); + const canonical = canonicalWorktreeTemplate(root); + if (!isInsidePath(cwd, root)) return { managed: false, root, reason: "outside worktrees root" }; + + const parts = relative(resolve(root), resolve(cwd)).split(sep).filter(Boolean); + if (parts.length === 0) { + return { managed: false, root, reason: `path is the worktrees root itself; canonical worktrees live at ${canonical}` }; + } + if (parts.length < CANONICAL_WORKTREE_SEGMENTS) { + return { + managed: false, + root, + reason: `worktree is flat under the worktrees root, which rule 8 forbids; canonical shape is ${canonical}`, + }; + } + + const [repo, worktree] = parts; + for (const [label, segment] of [["repo-name", repo], ["worktree-name", worktree]] as const) { + if (!segment || !WORKTREE_SEGMENT_PATTERN.test(segment)) { + return { managed: false, root, reason: `${label} segment is malformed; canonical shape is ${canonical}` }; + } + } + + // A canonical classification must be grounded in a real worktree root at depth 2, + // never in path shape alone: at depth 2 the shape is ambiguous with a subdirectory + // of a forbidden flat worktree, and at any depth it is ambiguous with an invented + // or symlinked path. + const worktreeRoot = resolve(root, repo!, worktree!); + const rootReason = groundedWorktreeRootReason(root, [repo!, worktree!]); + if (!rootReason) { + return { managed: true, root, layout: "canonical", repo, worktree, worktreeRoot }; + } + + if (parts.length === CANONICAL_WORKTREE_SEGMENTS) { + return { managed: false, root, reason: `${rootReason}; canonical shape is ${canonical}` }; + } + + // Recognised at or inside a legacy lease root, mirroring how a canonical worktree + // covers its own subdirectories — an agent cwd'd into `src/` of a legacy worktree + // is in the same non-compliant worktree, and must get the same migration message. + // + // The migration tolerance grants a weaker verdict than "blocked", so it has to clear + // the same grounding as the canonical branch. Otherwise the lease name pattern is a + // forgery kit: two directories named to match would launder a symlinked or grafted + // path into a warn-and-allow. + if (legacyWorktreeToleranceEnabled() + && parts.length >= LEGACY_LEASE_WORKTREE_SEGMENTS + && LEGACY_LEASE_REPO_PATTERN.test(parts[1]!) + && LEGACY_LEASE_ID_PATTERN.test(parts[2]!)) { + // The layout has two historical variants: the checkout sits at the lease dir, or + // one level below it in a `repo/` child. Try both, nothing deeper. + for (const depth of [LEGACY_LEASE_WORKTREE_SEGMENTS, LEGACY_LEASE_WORKTREE_SEGMENTS + 1]) { + if (parts.length < depth) break; + const segments = parts.slice(0, depth); + if (groundedWorktreeRootReason(root, segments)) continue; + return { + managed: false, + root, + layout: "legacy-station-lease", + deprecated: true, + worktreeRoot: resolve(root, ...segments), + reason: `deprecated station-id lease layout /-/wt_; rule 8 forbids a station-id or machine segment — re-home to ${canonical}`, + }; + } + } + + return { + managed: false, + root, + reason: `worktree root is ${parts.length} segments under the worktrees root (station-id/machine segment or extra nesting); rule 8 requires the worktree to be created at exactly ${canonical}`, + }; +} + +export async function gitRepoRoot(cwd: string): Promise { + if (!commandExists("git")) return null; + const result = await runCommand(["git", "rev-parse", "--show-toplevel"], { cwd, timeoutMs: 2000 }); + if (result.exitCode !== 0) return null; + return result.stdout.trim() || null; +} + +export async function gitRemoteSlug(cwd: string): Promise { + if (!commandExists("git")) return null; + const result = await runCommand(["git", "remote", "get-url", "origin"], { cwd, timeoutMs: 2000 }); + if (result.exitCode !== 0) return null; + const remote = result.stdout.trim(); + if (!remote) return null; + const match = remote.match(/[:/]([^/:\s]+\/[^/\s]+?)(?:\.git)?$/); + return match?.[1] || null; +} + +/** `origin` normalised to the `host/org/name` form the repos CLI resolves exactly. */ +export async function gitRemoteHostSlug(cwd: string): Promise { + if (!commandExists("git")) return null; + const result = await runCommand(["git", "remote", "get-url", "origin"], { cwd, timeoutMs: 2000 }); + if (result.exitCode !== 0) return null; + const remote = result.stdout.trim().replace(/\.git$/, ""); + if (!remote) return null; + const match = remote.match(/^(?:[a-z+]+:\/\/)?(?:[^@/]+@)?([^/:\s]+)[:/](.+)$/i); + const host = match?.[1]; + const path = match?.[2]?.replace(/^\/+/, ""); + if (!host || !path || !/^[^/\s]+\/[^/\s]+$/.test(path)) return null; + return `${host}/${path}`; +} + +export interface CanonicalRepoIdentity { + /** The repo name that forms the `` segment of the canonical path. */ + name: string | null; + defaultBranch: string | null; +} + +/** + * Resolve the canonical repo name via the repos CLI, as rule 8 requires: + * "Locate repos with the repos CLI (`repos repo --json` for the exact + * lookup; never fuzzy `repos cd` or 'did you mean' output for targeting)". + * + * This matters because the repos-CLI name is frequently NOT the git remote + * basename — on this fleet 46 of 50 indexed repos differ (`open-hooks` is + * `github.com/hasna/hooks`, `open-mailery` is `.../emails`). Deriving the + * canonical path segment from the remote would send every agent to the wrong + * directory, so the remote is only ever used as the exact lookup key. + * + * `--remote host/org/name` is the exact-match form, so no fuzzy "did you mean" + * output can be mistaken for a hit. OSS-safe: a missing or failing repos CLI + * yields nulls and the caller falls back to local information. + */ +export async function canonicalRepoIdentity(cwd: string): Promise { + const empty: CanonicalRepoIdentity = { name: null, defaultBranch: null }; + if (!commandExists("repos")) return empty; + const remote = await gitRemoteHostSlug(cwd); + if (!remote) return empty; + // Hard ceiling on the lookup. runCommand's timeout kills the direct child but still + // awaits its pipes, which a forking CLI can hold open indefinitely; this hook sits on + // the PreToolUse path, so it must degrade to local information rather than stall. + const result = await Promise.race([ + runCommand(["repos", "repo", "--remote", remote, "--json"], { cwd, timeoutMs: 1000 }), + new Promise((done) => setTimeout(() => done(null), 1500).unref?.()), + ]); + if (!result || result.exitCode !== 0) return empty; + try { + const parsed = JSON.parse(result.stdout) as { name?: unknown; default_branch?: unknown; path?: unknown }; + const name = typeof parsed.name === "string" && parsed.name ? parsed.name : null; + const defaultBranch = typeof parsed.default_branch === "string" && parsed.default_branch + ? parsed.default_branch + : null; + + // The index holds worktree directories as first-class rows, so an exact remote + // match can resolve to a worktree rather than the repo. Such a row's name is a + // worktree name and its default_branch is that worktree's branch — both wrong for + // the canonical path. When the row lives under the worktrees root, the real repo + // name is its first segment there; the branch is not recoverable, so drop it. + const worktreesRoot = resolve(defaultWorktreesRoot()); + const rowPath = typeof parsed.path === "string" && parsed.path ? resolve(parsed.path) : null; + if (rowPath && isInsidePath(rowPath, worktreesRoot) && rowPath !== worktreesRoot) { + const segment = relative(worktreesRoot, rowPath).split(sep).filter(Boolean)[0]; + return { name: segment || null, defaultBranch: null }; + } + return { name, defaultBranch }; + } catch { + return empty; + } +} + +/** + * Remediation command for work happening outside a canonical worktree. + * + * Rule 8: create the worktree at `//`, + * named after the todos task where one exists, then `repos scan`. The repos CLI + * has no worktree verb, so `git worktree` is the creation path. + * + * `repo` must be a canonical repo name (see canonicalRepoIdentity) — never a + * remote slug, which names a different directory for most repos. + * + * This is the boundary where names become a command an operator may paste, so every + * interpolated value is validated here rather than trusted from its source: a repo + * name is attacker-influenced via the remote, and a task id is unvalidated hook input. + * Anything unsafe degrades to the explicit placeholder instead of being emitted. + */ +const SAFE_COMMAND_VALUE = /^[a-zA-Z0-9_][a-zA-Z0-9_.\/-]{0,120}$/; + +export function claimCommand(repo: string | null, taskId: string | null, defaultBranch: string | null = null): string { + // A repo name is one path segment: a slug would silently add a third segment. + const safeRepo = repo && SAFE_COMMAND_VALUE.test(repo) && !repo.includes("/") ? repo : null; + const safeTask = taskId && SAFE_COMMAND_VALUE.test(taskId) ? taskId : null; + const safeBase = defaultBranch && SAFE_COMMAND_VALUE.test(defaultBranch) ? defaultBranch : null; + + const repoName = safeRepo || ""; + const worktreeName = safeTask || ""; + const path = join(defaultWorktreesRoot(), repoName, worktreeName); + return `git worktree add -b ${worktreeName} ${path} origin/${safeBase || ""} && repos scan`; +} + +export function taskIdFrom(input: CodewithHookInput): string | null { + const candidates = [ + process.env.HASNA_TASK_ID, + process.env.TASK_ID, + process.env.CODEWITH_TASK_ID, + typeof input.task_id === "string" ? input.task_id : undefined, + ]; + return candidates.find(Boolean) || null; +} + +export function runIdFrom(input: CodewithHookInput): string | null { + const candidates = [ + process.env.HASNA_RUN_ID, + process.env.RUN_ID, + process.env.CODEWITH_RUN_ID, + typeof input.run_id === "string" ? input.run_id : undefined, + input.turn_id, + input.session_id, + ]; + return candidates.find((v): v is string => typeof v === "string" && v.length > 0) || null; +} + +export function redactGitleaksOutput(_stdout: string, _stderr: string): string { + return "Staged secrets scan found possible credential(s). Details redacted; run gitleaks locally to inspect."; +} diff --git a/src/cli/commands/core.tsx b/src/cli/commands/core.tsx new file mode 100644 index 0000000..103a276 --- /dev/null +++ b/src/cli/commands/core.tsx @@ -0,0 +1,651 @@ +import type { Command } from "commander"; +import { render } from "ink"; +import chalk from "chalk"; +import { existsSync, readFileSync } from "fs"; +import { join } from "path"; +import { App } from "../components/App.js"; +import { + HOOKS, + CATEGORIES, + getHooksByCategory, + searchHooks, + getHook, +} from "../../lib/registry.js"; +import { + installHook, + getInstalledHooks, + getRegisteredHooks, + getRegisteredHooksForTarget, + removeHook, + hookExists, + getHookPath, + getSettingsPath, +} from "../../lib/installer.js"; +import { createProfile, getProfile, touchProfile } from "../../lib/profiles.js"; +import { + hookSummaryLine, + parseLimit, + printDisclosureHint, + resolveScope, + resolveTarget, + suggestHooks, +} from "./helpers.js"; + +export function registerCoreCommands(program: Command): void { + .command("interactive", { isDefault: true }) + .alias("i") + .description("Interactive hook browser") + .action(() => { + render(); + }); + +// Init command — register a new agent profile +program + .command("init") + .description("Register a new agent profile with a unique ID") + .option("-a, --agent ", "Agent type: claude, gemini, custom", "claude") + .option("-n, --name ", "Optional display name for the agent") + .option("-j, --json", "Output as JSON", false) + .action((options: { agent: string; name?: string; json: boolean }) => { + const agentType = options.agent as "claude" | "gemini" | "custom"; + if (!["claude", "gemini", "custom"].includes(agentType)) { + if (options.json) { + console.log(JSON.stringify({ error: `Invalid agent type: ${options.agent}`, valid: ["claude", "gemini", "custom"] })); + } else { + console.log(chalk.red(`Invalid agent type: ${options.agent}`)); + console.log(chalk.dim("Valid types: claude, gemini, custom")); + } + return; + } + + const profile = createProfile({ agent_type: agentType, name: options.name }); + + if (options.json) { + console.log(JSON.stringify(profile)); + return; + } + + console.log(chalk.green(`\n✓ Agent profile created\n`)); + console.log(` ${chalk.dim("Agent ID:")} ${chalk.bold(profile.agent_id)}`); + console.log(` ${chalk.dim("Type:")} ${profile.agent_type}`); + if (profile.name) { + console.log(` ${chalk.dim("Name:")} ${profile.name}`); + } + console.log(` ${chalk.dim("Profile:")} ~/.hasna/hooks/profiles/${profile.agent_id}.json`); + console.log(); + console.log(chalk.dim(" Install hooks with this profile:")); + console.log(` hooks install gitguard --profile ${profile.agent_id}`); + console.log(); + }); + +// Run command — executes a hook, called by AI coding agents via settings.json +program + .command("run") + .argument("", "Hook to run") + .option("--profile ", "Agent profile ID") + .description("Execute a hook (called by AI coding agents)") + .action(async (hook: string, options: { profile?: string }) => { + const meta = getHook(hook); + if (!meta) { + console.error(JSON.stringify({ error: `Hook '${hook}' not found` })); + process.exit(1); + } + + const hookDir = getHookPath(hook); + const hookScript = join(hookDir, "src", "hook.ts"); + + if (!existsSync(hookScript)) { + console.error(JSON.stringify({ error: `Hook script not found: ${hookScript}` })); + process.exit(1); + } + + // Read stdin (agent passes hook context as JSON) + const stdin = await new Response(Bun.stdin.stream()).text(); + + // If profile specified, inject agent data into the hook input + let hookStdin = stdin; + if (options.profile) { + const profile = getProfile(options.profile); + if (profile) { + touchProfile(options.profile); + try { + const input = JSON.parse(stdin); + input.agent = { + agent_id: profile.agent_id, + agent_type: profile.agent_type, + name: profile.name, + preferences: profile.preferences, + }; + hookStdin = JSON.stringify(input); + } catch { + // If stdin is not valid JSON, pass through unmodified + } + } + } + + // Execute the hook script with bun, passing stdin through + const proc = Bun.spawn(["bun", "run", hookScript], { + stdin: new Response(hookStdin), + stdout: "pipe", + stderr: "pipe", + env: process.env, + }); + + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + const exitCode = await proc.exited; + + if (stdout) process.stdout.write(stdout); + if (stderr) process.stderr.write(stderr); + process.exit(exitCode); + }); + +// Install command +program + .command("install") + .alias("add") + .argument("[hooks...]", "Hooks to install") + .option("-o, --overwrite", "Overwrite existing hooks", false) + .option("-a, --all", "Install all available hooks", false) + .option("-c, --category ", "Install all hooks in a category") + .option("-g, --global", "Install globally (~/.claude/settings.json)", false) + .option("-p, --project", "Install for current project (.claude/settings.json)", false) + .option("-t, --target ", "Agent target: claude, gemini, codewith, all (default: claude)", "claude") + .option("--profile ", "Agent profile ID to scope hooks to") + .option("--dry-run", "Preview what would be installed without writing to settings", false) + .option("--apply-codewith", "Explicitly append Codewith TOML to a config file (prefer open-configs for managed configs)", false) + .option("--codewith-config ", "Explicit Codewith config path required with --apply-codewith") + .option("-j, --json", "Output as JSON", false) + .description("Install one or more hooks") + .action((hooks: string[], options) => { + const scope = resolveScope(options); + const target = resolveTarget(options); + let toInstall: string[] = hooks; + + if (options.all) { + toInstall = HOOKS.map((h) => h.name); + } else if (options.category) { + const category = CATEGORIES.find( + (c) => c.toLowerCase() === options.category.toLowerCase() + ); + if (!category) { + if (options.json) { + console.log(JSON.stringify({ error: `Unknown category: ${options.category}`, available: [...CATEGORIES] })); + } else { + console.log(chalk.red(`Unknown category: ${options.category}`)); + console.log(chalk.dim(`Available: ${CATEGORIES.join(", ")}`)); + } + return; + } + toInstall = getHooksByCategory(category).map((h) => h.name); + } + + if (toInstall.length === 0) { + render(); + return; + } + + if (options.applyCodewith && (target === "codewith" || target === "all") && !options.codewithConfig) { + const message = "--apply-codewith requires --codewith-config ; refusing to write default ~/.codewith/config.toml."; + if (options.json) { + console.log(JSON.stringify({ error: message, scope, target, applied: false })); + } else { + console.log(chalk.red(message)); + } + return; + } + + // Dry-run: preview what would be installed + if (options.dryRun) { + const known = toInstall.filter((n) => getHook(n)); + const unknown = toInstall.filter((n) => !getHook(n)); + if (options.json) { + console.log(JSON.stringify({ dryRun: true, would_install: known, unknown, scope, target, mode: target === "codewith" ? "fragment" : "write" })); + return; + } + console.log(chalk.bold(`\nDry run — would install (${scope}, ${target}):\n`)); + for (const name of known) { + const meta = getHook(name)!; + console.log(chalk.cyan(` ${name}`) + chalk.dim(` [${meta.event}${meta.matcher ? ` ${meta.matcher}` : ""}]`)); + } + if (unknown.length > 0) { + console.log(); + for (const name of unknown) { + const suggestions = suggestHooks(name); + console.log(chalk.red(` ✗ unknown: ${name}`) + (suggestions.length ? chalk.dim(` — did you mean: ${suggestions.join(", ")}?`) : "")); + } + } + return; + } + + const results = []; + for (const name of toInstall) { + // Did-you-mean for unknown hooks + if (!getHook(name)) { + const suggestions = suggestHooks(name); + const hint = suggestions.length ? ` — did you mean: ${suggestions.join(", ")}?` : ""; + results.push({ hook: name, success: false, error: `Hook '${name}' not found${hint}` }); + continue; + } + const result = installHook(name, { + scope, + overwrite: options.overwrite, + target, + profile: options.profile, + codewithMode: options.applyCodewith ? "write" : "fragment", + codewithConfigPath: options.codewithConfig, + }); + results.push(result); + } + + if (options.json) { + console.log(JSON.stringify({ + installed: results.filter((r) => r.success).map((r) => r.hook), + failed: results.filter((r) => !r.success).map((r) => ({ hook: r.hook, error: r.error })), + fragments: results.filter((r) => r.success && r.fragment).map((r) => ({ hook: r.hook, fragment: r.fragment, applied: r.applied, configPath: r.configPath, note: r.note })), + total: results.length, + success: results.filter((r) => r.success).length, + scope, + target, + applied: results.some((r) => r.applied), + })); + return; + } + + const settingsFile = target === "codewith" + ? (options.applyCodewith ? options.codewithConfig : "TOML fragment only (open-configs should apply)") + : scope === "project" ? ".claude/settings.json" : "~/.claude/settings.json"; + console.log(chalk.bold(`\nInstalling hooks (${scope}, ${target})...\n`)); + for (const result of results) { + if (result.success) { + const meta = getHook(result.hook); + console.log(chalk.green(`✓ ${result.hook}`)); + if (meta) { + console.log( + chalk.dim(` ${meta.event}${meta.matcher ? ` [${meta.matcher}]` : ""} → hooks run ${result.hook}`) + ); + } + if (result.conflict) { + console.log(chalk.yellow(` ⚠ Warning: ${result.conflict}`)); + } + if (result.fragment && target === "codewith") { + console.log(chalk.dim(" Codewith TOML fragment:")); + console.log(chalk.cyan(result.fragment.trimEnd().split("\n").map((line) => ` ${line}`).join("\n"))); + if (result.note) console.log(chalk.yellow(` ⚠ ${result.note}`)); + } + } else { + console.log(chalk.red(`✗ ${result.hook}: ${result.error}`)); + } + } + console.log(chalk.dim(`\nRegistered in ${settingsFile}`)); + }); + +// List command +program + .command("list") + .alias("ls") + .option("-c, --category ", "Filter by category") + .option("-a, --all", "Show all available hooks", false) + .option("-i, --installed", "Show only installed hooks", false) + .option("-r, --registered", "Show registered hooks", false) + .option("-g, --global", "Check global settings", false) + .option("-p, --project", "Check project settings", false) + .option("-t, --target ", "Agent target: claude, gemini, codewith (default: claude)", "claude") + .option("-n, --limit ", "Max rows to show in compact output", "20") + .option("--verbose", "Show descriptions and full detail columns", false) + .option("-j, --json", "Output as JSON", false) + .description("List available or installed hooks") + .action((options) => { + const scope = resolveScope(options); + const limit = options.all ? Number.MAX_SAFE_INTEGER : parseLimit(options.limit, 20, 200); + + if (options.registered || options.installed) { + const target = (options.target === "gemini" ? "gemini" : options.target === "codewith" ? "codewith" : "claude") as "claude" | "gemini" | "codewith"; + const registered = getRegisteredHooksForTarget(scope, target); + if (options.json) { + console.log(JSON.stringify(registered.map((name) => { + const meta = getHook(name); + return { name, event: meta?.event, version: meta?.version, description: meta?.description, scope, target }; + }))); + return; + } + if (registered.length === 0) { + console.log(chalk.dim(`No hooks registered (${scope}, ${target})`)); + return; + } + const visible = registered.slice(0, limit); + console.log(chalk.bold(`\nRegistered hooks — ${scope}/${target} (${registered.length}, showing ${visible.length}):\n`)); + for (const name of visible) { + const meta = getHook(name); + if (meta) console.log(hookSummaryLine(meta, { verbose: options.verbose })); + else console.log(` ${chalk.cyan(name)} ${chalk.dim("[unknown]")}`); + } + printDisclosureHint(registered.length - visible.length, "hooks info ", { includeAll: true }); + return; + } + + if (options.category) { + const category = CATEGORIES.find( + (c) => c.toLowerCase() === options.category.toLowerCase() + ); + if (!category) { + if (options.json) { + console.log(JSON.stringify({ error: `Unknown category: ${options.category}`, available: [...CATEGORIES] })); + } else { + console.log(chalk.red(`Unknown category: ${options.category}`)); + console.log(chalk.dim(`Available: ${CATEGORIES.join(", ")}`)); + } + return; + } + const hooks = getHooksByCategory(category); + if (options.json) { + console.log(JSON.stringify(hooks)); + return; + } + const visible = hooks.slice(0, limit); + console.log(chalk.bold(`\n${category} (${hooks.length}, showing ${visible.length}):\n`)); + for (const h of visible) console.log(hookSummaryLine(h, { verbose: options.verbose })); + printDisclosureHint(hooks.length - visible.length, "hooks info ", { includeAll: true }); + return; + } + + // Show all by category + if (options.json) { + const result: Record = {}; + for (const category of CATEGORIES) { + result[category] = getHooksByCategory(category); + } + console.log(JSON.stringify(result)); + return; + } + + const visible = HOOKS.slice(0, limit); + console.log(chalk.bold(`\nAvailable hooks (${HOOKS.length}, showing ${visible.length}):\n`)); + for (const h of visible) console.log(hookSummaryLine(h, { verbose: options.verbose })); + printDisclosureHint(HOOKS.length - visible.length, "hooks info ", { includeAll: true }); + }); + +// Search command +program + .command("search") + .argument("", "Search term") + .option("-n, --limit ", "Max rows to show in compact output", "10") + .option("--verbose", "Show descriptions for search results", false) + .option("-j, --json", "Output as JSON", false) + .description("Search for hooks") + .action((query: string, options: { limit: string; verbose: boolean; json: boolean }) => { + const results = searchHooks(query); + if (options.json) { + console.log(JSON.stringify(results)); + return; + } + if (results.length === 0) { + console.log(chalk.dim(`No hooks found for "${query}"`)); + return; + } + const limit = parseLimit(options.limit, 10, 100); + const visible = results.slice(0, limit); + console.log(chalk.bold(`\nFound ${results.length} hook(s), showing ${visible.length}:\n`)); + for (const h of visible) console.log(hookSummaryLine(h, { verbose: options.verbose })); + printDisclosureHint(results.length - visible.length, "hooks info "); + }); + +// Remove command +program + .command("remove") + .alias("rm") + .argument("", "Hook to remove") + .option("-g, --global", "Remove from global settings", false) + .option("-p, --project", "Remove from project settings", false) + .option("-t, --target ", "Agent target: claude, gemini, codewith, all (default: claude)", "claude") + .option("-j, --json", "Output as JSON", false) + .description("Remove an installed hook") + .action((hook: string, options: { global?: boolean; project?: boolean; target?: string; json: boolean }) => { + const scope = resolveScope(options); + const target = resolveTarget(options); + + // Did-you-mean for unknown hook names + if (!getHook(hook)) { + const suggestions = suggestHooks(hook); + const hint = suggestions.length ? ` — did you mean: ${suggestions.join(", ")}?` : ""; + if (options.json) { + console.log(JSON.stringify({ hook, removed: false, scope, target, error: `Hook '${hook}' not found${hint}`, suggestions })); + } else { + console.log(chalk.red(`✗ Hook '${hook}' not found${hint}`)); + } + return; + } + + const removed = removeHook(hook, scope, target); + if (options.json) { + console.log(JSON.stringify({ hook, removed, scope, target })); + return; + } + if (removed) { + console.log(chalk.green(`✓ Removed ${hook} (${scope}, ${target})`)); + } else { + console.log(chalk.red(`✗ ${hook} is not installed (${scope}, ${target})`)); + } + }); + +// Categories command +program + .command("categories") + .option("-j, --json", "Output as JSON", false) + .description("List all categories") + .action((options: { json: boolean }) => { + if (options.json) { + const result = CATEGORIES.map((cat) => ({ + name: cat, + count: getHooksByCategory(cat).length, + })); + console.log(JSON.stringify(result)); + return; + } + console.log(chalk.bold("\nCategories:\n")); + for (const category of CATEGORIES) { + const count = getHooksByCategory(category).length; + console.log(` ${category} (${count})`); + } + }); + +// Info command +program + .command("info") + .argument("", "Hook name") + .option("-j, --json", "Output as JSON", false) + .description("Show detailed info about a hook") + .action((hook: string, options: { json: boolean }) => { + const meta = getHook(hook); + if (!meta) { + const suggestions = suggestHooks(hook); + const hint = suggestions.length ? ` — did you mean: ${suggestions.join(", ")}?` : ""; + if (options.json) { + console.log(JSON.stringify({ error: `Hook '${hook}' not found${hint}`, suggestions })); + } else { + console.log(chalk.red(`Hook '${hook}' not found${hint}`)); + } + return; + } + + const globalInstalled = getRegisteredHooks("global").includes(meta.name); + const projectInstalled = getRegisteredHooks("project").includes(meta.name); + + if (options.json) { + console.log(JSON.stringify({ ...meta, global: globalInstalled, project: projectInstalled })); + return; + } + + console.log(chalk.bold(`\n${meta.displayName}\n`)); + console.log(` ${meta.description}`); + console.log(); + console.log(` ${chalk.dim("Category:")} ${meta.category}`); + console.log(` ${chalk.dim("Version:")} ${meta.version}`); + console.log(` ${chalk.dim("Event:")} ${meta.event}`); + console.log(` ${chalk.dim("Matcher:")} ${meta.matcher || "(none)"}`); + console.log(` ${chalk.dim("Tags:")} ${meta.tags.join(", ")}`); + console.log(` ${chalk.dim("Command:")} hooks run ${meta.name}`); + console.log(); + + if (globalInstalled) { + console.log(chalk.green(" ● Installed globally")); + } else { + console.log(chalk.dim(" ○ Not installed globally")); + } + + if (projectInstalled) { + console.log(chalk.green(" ● Installed in project")); + } else { + console.log(chalk.dim(" ○ Not installed in project")); + } + }); + +// Doctor command +program + .command("doctor") + .option("-g, --global", "Check global settings", false) + .option("-p, --project", "Check project settings", false) + .option("-j, --json", "Output as JSON", false) + .description("Check health of installed hooks") + .action((options: { global?: boolean; project?: boolean; json: boolean }) => { + const scope = resolveScope(options); + const settingsPath = getSettingsPath(scope); + const issues: { hook: string; issue: string; severity: "error" | "warning" }[] = []; + const healthy: string[] = []; + + const settingsExist = existsSync(settingsPath); + if (!settingsExist) { + issues.push({ hook: "(settings)", issue: `${settingsPath} not found`, severity: "warning" }); + } + + const registered = getRegisteredHooks(scope); + + for (const name of registered) { + const meta = getHook(name); + let hookHealthy = true; + + // Check hook exists in the package + if (!hookExists(name)) { + issues.push({ hook: name, issue: "Hook not found in @hasna/hooks package", severity: "error" }); + hookHealthy = false; + continue; + } + + // Check hook has source + const hookDir = getHookPath(name); + const hookScript = join(hookDir, "src", "hook.ts"); + if (!existsSync(hookScript)) { + issues.push({ hook: name, issue: "Missing src/hook.ts in package", severity: "error" }); + hookHealthy = false; + } + + // Verify correct event registration + if (meta && settingsExist) { + try { + const settings = JSON.parse(readFileSync(settingsPath, "utf-8")); + const eventHooks = settings.hooks?.[meta.event] || []; + const found = eventHooks.some((entry: any) => + entry.hooks?.some((h: any) => { + const match = h.command?.match(/^hooks run ([\w-]+)/); + return match && match[1] === name; + }) + ); + if (!found) { + issues.push({ hook: name, issue: `Not registered under correct event (${meta.event})`, severity: "error" }); + hookHealthy = false; + } + } catch {} + } + + if (hookHealthy) { + healthy.push(name); + } + } + + if (options.json) { + console.log(JSON.stringify({ healthy: issues.length === 0, healthy_hooks: healthy, issues, registered, scope })); + return; + } + + console.log(chalk.bold(`\nHook Health Check (${scope})\n`)); + + if (registered.length === 0) { + console.log(chalk.dim(" No hooks registered.")); + console.log(chalk.dim(" Run: hooks install gitguard")); + return; + } + + if (healthy.length > 0) { + console.log(chalk.green(` ✓ ${healthy.length} hook(s) healthy:`)); + for (const name of healthy) { + console.log(chalk.green(` ${name}`)); + } + } + + if (issues.length > 0) { + console.log(); + for (const issue of issues) { + const icon = issue.severity === "error" ? chalk.red("✗") : chalk.yellow("!"); + console.log(` ${icon} ${chalk.cyan(issue.hook)}: ${issue.issue}`); + } + } + + if (issues.length === 0) { + console.log(chalk.green("\n All hooks healthy!")); + } + + console.log(); + }); + +// Update command +program + .command("update") + .argument("[hooks...]", "Hooks to update (defaults to all installed)") + .option("-g, --global", "Update global hooks", false) + .option("-p, --project", "Update project hooks", false) + .option("-j, --json", "Output as JSON", false) + .description("Re-register hooks (picks up new package version)") + .action((hooks: string[], options: { global?: boolean; project?: boolean; json: boolean }) => { + const scope = resolveScope(options); + const installed = getInstalledHooks(scope); + const toUpdate = hooks.length > 0 ? hooks : installed; + + if (toUpdate.length === 0) { + if (options.json) { + console.log(JSON.stringify({ updated: [], error: "No hooks installed" })); + } else { + console.log(chalk.dim("No hooks installed to update.")); + } + return; + } + + const results = []; + for (const name of toUpdate) { + if (!installed.includes(name)) { + results.push({ hook: name, success: false, error: "Not installed" }); + continue; + } + const result = installHook(name, { scope, overwrite: true }); + results.push(result); + } + + if (options.json) { + console.log(JSON.stringify({ + updated: results.filter((r) => r.success).map((r) => r.hook), + failed: results.filter((r) => !r.success).map((r) => ({ hook: r.hook, error: r.error })), + })); + return; + } + + console.log(chalk.bold("\nUpdating hooks...\n")); + for (const result of results) { + if (result.success) { + console.log(chalk.green(`✓ ${result.hook} updated`)); + } else { + console.log(chalk.red(`✗ ${result.hook}: ${result.error}`)); + } + } + }); + +// Docs command +} diff --git a/src/cli/commands/docs.ts b/src/cli/commands/docs.ts new file mode 100644 index 0000000..58a2352 --- /dev/null +++ b/src/cli/commands/docs.ts @@ -0,0 +1,289 @@ +import type { Command } from "commander"; +import chalk from "chalk"; +import { existsSync, readFileSync } from "fs"; +import { join } from "path"; +import { getHook } from "../../lib/registry.js"; +import { getHookPath } from "../../lib/installer.js"; +import { exportProfiles, importProfiles } from "../../lib/profiles.js"; +import { readmePreview } from "./helpers.js"; + +export function registerDocsCommands(program: Command, version: string): void { +program + .command("docs") + .argument("[hook]", "Hook name (shows general docs if omitted)") + .option("--verbose", "Print full hook README content", false) + .option("-j, --json", "Output as JSON", false) + .description("Show documentation for hooks") + .action((hook: string | undefined, options: { verbose: boolean; json: boolean }) => { + if (hook) { + const meta = getHook(hook); + if (!meta) { + if (options.json) { + console.log(JSON.stringify({ error: `Hook '${hook}' not found` })); + } else { + console.log(chalk.red(`Hook '${hook}' not found`)); + } + return; + } + + const hookPath = getHookPath(hook); + const readmePath = join(hookPath, "README.md"); + let readme = ""; + if (existsSync(readmePath)) { + readme = readFileSync(readmePath, "utf-8"); + } + + if (options.json) { + console.log(JSON.stringify({ ...meta, readme })); + return; + } + + console.log(chalk.bold(`\n${meta.displayName} v${meta.version}\n`)); + console.log(` ${meta.description}\n`); + console.log(chalk.bold(" Configuration:")); + console.log(` Event: ${meta.event}`); + console.log(` Matcher: ${meta.matcher || "(all tools)"}`); + console.log(` Command: hooks run ${meta.name}`); + console.log(); + console.log(chalk.bold(" Install:")); + console.log(` hooks install ${meta.name} # global`); + console.log(` hooks install ${meta.name} --project # project only`); + console.log(); + + if (readme && options.verbose) { + console.log(chalk.bold(" README:\n")); + for (const line of readme.split("\n")) { + console.log(` ${line}`); + } + } else if (readme) { + const preview = readmePreview(readme); + if (preview) { + console.log(chalk.bold(" README Preview:\n")); + console.log(` ${preview}\n`); + } + console.log(chalk.dim(` README has ${readme.split("\n").length} lines. Use hooks docs ${meta.name} --verbose for the full README, or --json for machine-readable output.`)); + } + return; + } + + // General docs + const generalDocs = { + overview: "Hooks are scripts that run at specific points in an AI coding agent session. Install @hasna/hooks globally, then register hooks — no files are copied to your project.", + events: { + SessionStart: "Fires when a session starts or resumes. Codewith can inject context via hookSpecificOutput.additionalContext.", + UserPromptSubmit: "Codewith-native event when a user prompt is submitted; can block obvious injection attempts.", + PreToolUse: "Fires before a tool executes. Can block the operation by returning { \"decision\": \"block\" }.", + PostToolUse: "Fires after a tool executes. Runs asynchronously, cannot block.", + Stop: "Fires at turn end in Codewith and when other agents finish responding. Useful for notifications and cleanup.", + Notification: "Fires on notification events like context compaction.", + SessionEnd: "Fires when a session terminates. Useful for cleanup and final announcements.", + }, + installation: { + global: "hooks install gitguard", + project: "hooks install gitguard --project", + codewith: "hooks install session-start --target codewith # emits TOML for open-configs to apply", + category: "hooks install --category \"Git Safety\"", + all: "hooks install --all", + }, + management: { + list: "hooks list", + listInstalled: "hooks list --installed", + search: "hooks search ", + info: "hooks info ", + remove: "hooks remove ", + update: "hooks update", + doctor: "hooks doctor", + docs: "hooks docs ", + }, + howItWorks: { + install: "bun install -g @hasna/hooks", + register: "hooks install gitguard → writes to ~/.claude/settings.json; hooks install session-start --target codewith emits a TOML fragment", + execution: "Agent runs 'hooks run gitguard' → executes hook from global package", + noFileCopy: "No files are copied to your project. Hooks run from the global @hasna/hooks package.", + }, + }; + + if (options.json) { + console.log(JSON.stringify(generalDocs)); + return; + } + + console.log(chalk.bold("\n@hasna/hooks Documentation\n")); + + console.log(chalk.bold(" Overview\n")); + console.log(` ${generalDocs.overview}\n`); + + console.log(chalk.bold(" How It Works\n")); + for (const [label, desc] of Object.entries(generalDocs.howItWorks)) { + console.log(` ${chalk.dim(label + ":")} ${desc}`); + } + + console.log(chalk.bold("\n Hook Events\n")); + for (const [event, desc] of Object.entries(generalDocs.events)) { + console.log(` ${chalk.cyan(event)}`); + console.log(` ${desc}\n`); + } + + console.log(chalk.bold(" Installation\n")); + for (const [label, cmd] of Object.entries(generalDocs.installation)) { + console.log(` ${chalk.dim(label + ":")} ${cmd}`); + } + + console.log(chalk.bold("\n Management\n")); + for (const [label, cmd] of Object.entries(generalDocs.management)) { + console.log(` ${chalk.dim(label + ":")} ${cmd}`); + } + + console.log(chalk.bold("\n Hook-Specific Docs\n")); + console.log(` hooks docs Compact hook docs`); + console.log(` hooks docs --verbose Full hook README`); + console.log(` hooks docs --json Machine-readable documentation`); + console.log(); + }); + +// Upgrade command — self-update the @hasna/hooks package +program + .command("upgrade") + .option("-c, --check", "Check for updates without installing", false) + .option("-j, --json", "Output as JSON", false) + .description("Update the @hasna/hooks package to the latest version") + .action(async (options: { check: boolean; json: boolean }) => { + const current = version; + + // Detect package manager: prefer bun, fallback to npm + let pm = "npm"; + try { + const which = Bun.spawnSync(["which", "bun"]); + if (which.exitCode === 0) pm = "bun"; + } catch {} + + if (options.check) { + // Fetch latest version from npm registry + const proc = Bun.spawnSync(["npm", "view", "@hasna/hooks", "version"]); + const latest = new TextDecoder().decode(proc.stdout).trim(); + + if (!latest) { + if (options.json) { + console.log(JSON.stringify({ error: "Failed to fetch latest version" })); + } else { + console.log(chalk.red("Failed to fetch latest version from npm registry.")); + } + process.exit(1); + } + + const upToDate = current === latest; + if (options.json) { + console.log(JSON.stringify({ current, latest, upToDate })); + } else if (upToDate) { + console.log(chalk.green(`✓ Already on latest version (${current})`)); + } else { + console.log(chalk.yellow(`Update available: ${current} → ${latest}`)); + console.log(chalk.dim(` Run: hooks upgrade`)); + } + return; + } + + // Perform the upgrade + const installCmd = pm === "bun" + ? ["bun", "install", "-g", "@hasna/hooks@latest"] + : ["npm", "install", "-g", "@hasna/hooks@latest"]; + + if (!options.json) { + console.log(chalk.bold(`\nUpgrading @hasna/hooks (${pm})...\n`)); + console.log(chalk.dim(` $ ${installCmd.join(" ")}\n`)); + } + + const proc = Bun.spawn(installCmd, { + stdout: options.json ? "pipe" : "inherit", + stderr: options.json ? "pipe" : "inherit", + env: process.env, + }); + + const exitCode = await proc.exited; + + if (exitCode !== 0) { + if (options.json) { + console.log(JSON.stringify({ current, updated: false, error: `${pm} exited with code ${exitCode}` })); + } else { + console.log(chalk.red(`\n✗ Upgrade failed (exit code ${exitCode})`)); + } + process.exit(exitCode); + } + + // Check new version + const versionProc = Bun.spawnSync(["npm", "view", "@hasna/hooks", "version"]); + const latest = new TextDecoder().decode(versionProc.stdout).trim() || "unknown"; + + if (options.json) { + console.log(JSON.stringify({ current, latest, updated: true })); + } else { + console.log(chalk.green(`\n✓ Upgraded: ${current} → ${latest}`)); + } + }); + +// Profile export command +program + .command("profile-export") + .description("Export all agent profiles as JSON (for backup/cross-machine setup)") + .option("-o, --output ", "Write to file instead of stdout") + .option("-j, --json", "Output as JSON (default: true)", false) + .action(async (options: { output?: string; json: boolean }) => { + const profiles = exportProfiles(); + const json = JSON.stringify(profiles, null, 2); + if (options.output) { + const { writeFileSync } = await import("fs"); + writeFileSync(options.output, json + "\n"); + console.log(chalk.green(`✓ Exported ${profiles.length} profile(s) to ${options.output}`)); + } else { + console.log(json); + } + }); + +// Profile import command +program + .command("profile-import") + .argument("", "JSON file to import profiles from (use - for stdin)") + .description("Import agent profiles from a JSON export file") + .option("-j, --json", "Output result as JSON", false) + .action(async (file: string, options: { json: boolean }) => { + let raw: string; + if (file === "-") { + raw = await new Response(Bun.stdin.stream()).text(); + } else { + const { readFileSync } = await import("fs"); + try { + raw = readFileSync(file, "utf-8"); + } catch { + if (options.json) { + console.log(JSON.stringify({ error: `Cannot read file: ${file}` })); + } else { + console.log(chalk.red(`✗ Cannot read file: ${file}`)); + } + return; + } + } + + let profiles: any[]; + try { + const parsed = JSON.parse(raw); + profiles = Array.isArray(parsed) ? parsed : [parsed]; + } catch { + if (options.json) { + console.log(JSON.stringify({ error: "Invalid JSON" })); + } else { + console.log(chalk.red("✗ Invalid JSON")); + } + return; + } + + const result = importProfiles(profiles); + if (options.json) { + console.log(JSON.stringify(result)); + } else { + console.log(chalk.green(`✓ Imported ${result.imported} profile(s)`)); + if (result.skipped > 0) console.log(chalk.dim(` Skipped ${result.skipped} (already exist or invalid)`)); + } + }); + +// Log command group — query hook events from SQLite +} diff --git a/src/cli/commands/helpers.ts b/src/cli/commands/helpers.ts new file mode 100644 index 0000000..3f82c00 --- /dev/null +++ b/src/cli/commands/helpers.ts @@ -0,0 +1,100 @@ +import chalk from "chalk"; +import { HOOKS, type HookMeta } from "../../lib/registry.js"; +import { getSettingsPath, type ConcreteTarget, type Scope, type Target } from "../../lib/installer.js"; + +export function resolveScope(options: { global?: boolean; project?: boolean }): Scope { + if (options.project) return "project"; + return "global"; +} + +export function resolveTarget(options: { target?: string }): Target { + if (options.target === "gemini") return "gemini"; + if (options.target === "codewith") return "codewith"; + if (options.target === "all") return "all"; + return "claude"; +} + +export function resolveConcreteTarget(options: { target?: string }): ConcreteTarget { + if (options.target === "gemini") return "gemini"; + if (options.target === "codewith") return "codewith"; + return "claude"; +} + +export function formatSettingsPath(scope: Scope, target: Target): string { + if (target === "all") return "target-specific settings"; + const actual = getSettingsPath(scope, target); + if (scope === "project") { + if (target === "codewith") return ".codewith/config.toml"; + if (target === "gemini") return ".gemini/settings.json"; + return ".claude/settings.json"; + } + if (target === "codewith") { + return process.env.HASNA_HOOKS_CODEWITH_CONFIG_PATH ? "$HASNA_HOOKS_CODEWITH_CONFIG_PATH" : "~/.codewith/config.toml"; + } + if (target === "gemini") return "~/.gemini/settings.json"; + return actual === getSettingsPath("global", "claude") ? "~/.claude/settings.json" : actual; +} + +export function parseLimit(value: string | undefined, fallback: number, max: number): number { + const parsed = value ? parseInt(value, 10) : fallback; + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + return Math.min(parsed, max); +} + +export function truncateText(value: string | undefined, max = 96): string { + const text = (value ?? "").replace(/\s+/g, " ").trim(); + if (text.length <= max) return text; + return `${text.slice(0, Math.max(0, max - 3))}...`; +} + +export function readmePreview(readme: string, max = 280): string | undefined { + const blocks = readme + .split(/\n\s*\n/) + .map((part) => part.trim()) + .filter(Boolean) + .filter((part) => !part.startsWith("#")) + .filter((part) => !part.startsWith("```")) + .filter((part) => !/^\[!\[/.test(part)); + const preview = blocks[0] + ?? readme.split("\n").map((line) => line.trim()).find((line) => line && !line.startsWith("#")); + return preview ? truncateText(preview, max) : undefined; +} + +export function hookSummaryLine(hook: HookMeta, options: { verbose?: boolean } = {}): string { + const matcher = hook.matcher ? ` ${hook.matcher}` : ""; + const description = options.verbose ? ` - ${truncateText(hook.description, 110)}` : ""; + return ` ${chalk.cyan(hook.name.padEnd(17))} ${chalk.dim(`[${hook.event}${matcher}]`)} ${chalk.dim(hook.category)}${description}`; +} + +export function printDisclosureHint(hidden: number, detailCommand: string, options: { includeAll?: boolean } = {}): void { + const rowControls = options.includeAll ? "--limit, --all, --verbose" : "--limit, --verbose"; + if (hidden > 0) { + console.log(chalk.dim(`\n Showing a compact subset. ${hidden} more hidden; use ${rowControls}, or ${detailCommand}.`)); + } else { + console.log(chalk.dim(`\n Use --verbose or ${detailCommand} for details.`)); + } +} + +/** Levenshtein distance for did-you-mean suggestions */ +export function editDistance(a: string, b: string): number { + const m = a.length, n = b.length; + const dp: number[][] = Array.from({ length: m + 1 }, (_, i) => [i, ...Array(n).fill(0)]); + for (let j = 0; j <= n; j++) dp[0][j] = j; + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + dp[i][j] = a[i - 1] === b[j - 1] + ? dp[i - 1][j - 1] + : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]); + } + } + return dp[m][n]; +} + +export function suggestHooks(name: string, max = 3): string[] { + return HOOKS + .map((h) => ({ name: h.name, dist: editDistance(name.toLowerCase(), h.name.toLowerCase()) })) + .filter(({ dist }) => dist <= 4) + .sort((a, b) => a.dist - b.dist) + .slice(0, max) + .map(({ name: n }) => n); +} diff --git a/src/cli/commands/log.ts b/src/cli/commands/log.ts new file mode 100644 index 0000000..e4bee58 --- /dev/null +++ b/src/cli/commands/log.ts @@ -0,0 +1,170 @@ +import type { Command } from "commander"; +import chalk from "chalk"; +import { truncateText } from "./helpers.js"; + +export function registerLogCommands(program: Command): void { +const logCmd = program + .command("log") + .description("Query hook event logs from SQLite (~/.hasna/hooks/hooks.db)"); + +logCmd + .command("list") + .description("List hook events") + .option("--hook ", "Filter by hook name") + .option("--session ", "Filter by session ID") + .option("-n, --limit ", "Number of rows to show", "50") + .option("-j, --json", "Output as JSON", false) + .action(async (options: { hook?: string; session?: string; limit: string; json: boolean }) => { + const { getDb } = await import("../db/index.js"); + const db = getDb(); + const limit = parseInt(options.limit) || 50; + + let sql = "SELECT * FROM hook_events WHERE 1=1"; + const params: string[] = []; + + if (options.hook) { sql += " AND hook_name = ?"; params.push(options.hook); } + if (options.session) { sql += " AND session_id LIKE ?"; params.push(`${options.session}%`); } + sql += " ORDER BY timestamp DESC LIMIT ?"; + params.push(String(limit)); + + const rows = db.query(sql).all(...params) as any[]; + + if (options.json) { console.log(JSON.stringify(rows, null, 2)); return; } + if (rows.length === 0) { console.log(chalk.dim("No events found.")); return; } + + console.log(chalk.bold(`\n Hook Events (${rows.length})\n`)); + for (const row of rows) { + const ts = row.timestamp.slice(0, 19).replace("T", " "); + const err = row.error ? chalk.red(` ERR: ${truncateText(row.error, 60)}`) : ""; + const tool = row.tool_name ? chalk.dim(` [${row.tool_name}]`) : ""; + console.log(` ${chalk.dim(ts)} ${chalk.cyan(row.hook_name.padEnd(14))}${tool}${err}`); + } + console.log(chalk.dim("\n Compact rows shown. Use --json for full event records or --limit to change row count.")); + }); + +logCmd + .command("search ") + .description("Search hook events by tool_input or error text") + .option("-n, --limit ", "Number of rows to show", "50") + .option("-j, --json", "Output as JSON", false) + .action(async (text: string, options: { limit: string; json: boolean }) => { + const { getDb } = await import("../db/index.js"); + const db = getDb(); + const limit = parseInt(options.limit) || 50; + const q = `%${text}%`; + const rows = db.query( + "SELECT * FROM hook_events WHERE tool_input LIKE ? OR error LIKE ? ORDER BY timestamp DESC LIMIT ?" + ).all(q, q, limit) as any[]; + + if (options.json) { console.log(JSON.stringify(rows, null, 2)); return; } + if (rows.length === 0) { console.log(chalk.dim(`No events matching "${text}".`)); return; } + + console.log(chalk.bold(`\n Search results for "${text}" (${rows.length})\n`)); + for (const row of rows) { + const ts = row.timestamp.slice(0, 19).replace("T", " "); + const snippet = truncateText(row.tool_input || row.error || "", 80); + console.log(` ${chalk.dim(ts)} ${chalk.cyan(row.hook_name.padEnd(14))} ${chalk.dim(snippet)}`); + } + console.log(chalk.dim("\n Compact rows shown. Use --json for full event records or --limit to change row count.")); + }); + +logCmd + .command("tail") + .description("Show most recent hook events") + .option("-n ", "Number of rows", "20") + .option("-j, --json", "Output as JSON", false) + .action(async (options: { n: string; json: boolean }) => { + const { getDb } = await import("../db/index.js"); + const db = getDb(); + const limit = parseInt(options.n) || 20; + const rows = db.query( + "SELECT * FROM hook_events ORDER BY timestamp DESC LIMIT ?" + ).all(limit) as any[]; + + if (options.json) { console.log(JSON.stringify(rows, null, 2)); return; } + if (rows.length === 0) { console.log(chalk.dim("No events yet.")); return; } + + console.log(chalk.bold(`\n Last ${rows.length} events\n`)); + for (const row of rows) { + const ts = row.timestamp.slice(0, 19).replace("T", " "); + const err = row.error ? chalk.red(` ✗ ${truncateText(row.error, 60)}`) : ""; + const tool = row.tool_name ? chalk.dim(` [${row.tool_name}]`) : ""; + console.log(` ${chalk.dim(ts)} ${chalk.cyan(row.hook_name.padEnd(14))}${tool}${err}`); + } + console.log(chalk.dim("\n Compact rows shown. Use --json for full event records or -n to change row count.")); + }); + +logCmd + .command("errors") + .description("Show hook events that contain errors") + .option("--since ", "Only show errors since this duration (e.g. 1h, 30m, 7d)", "24h") + .option("-n, --limit ", "Number of rows to show", "50") + .option("-j, --json", "Output as JSON", false) + .action(async (options: { since: string; limit: string; json: boolean }) => { + const { getDb } = await import("../db/index.js"); + const db = getDb(); + const limit = parseInt(options.limit) || 50; + + // Parse duration string to milliseconds + function parseDuration(s: string): number { + const m = s.match(/^(\d+)(s|m|h|d)$/); + if (!m) return 24 * 60 * 60 * 1000; + const n = parseInt(m[1]); + switch (m[2]) { + case "s": return n * 1000; + case "m": return n * 60 * 1000; + case "h": return n * 60 * 60 * 1000; + case "d": return n * 24 * 60 * 60 * 1000; + default: return 24 * 60 * 60 * 1000; + } + } + + const since = new Date(Date.now() - parseDuration(options.since)).toISOString(); + const rows = db.query( + "SELECT * FROM hook_events WHERE error IS NOT NULL AND timestamp >= ? ORDER BY timestamp DESC LIMIT ?" + ).all(since, limit) as any[]; + + if (options.json) { console.log(JSON.stringify(rows, null, 2)); return; } + if (rows.length === 0) { console.log(chalk.dim(`No errors in the last ${options.since}.`)); return; } + + console.log(chalk.bold(`\n Errors (last ${options.since}, ${rows.length} found)\n`)); + for (const row of rows) { + const ts = row.timestamp.slice(0, 19).replace("T", " "); + console.log(` ${chalk.dim(ts)} ${chalk.cyan(row.hook_name.padEnd(14))} ${chalk.red(truncateText(row.error, 100))}`); + } + console.log(chalk.dim("\n Compact rows shown. Use --json for full event records or --limit to change row count.")); + }); + +logCmd + .command("clear") + .description("Delete hook event logs") + .option("--hook ", "Only delete events for this hook") + .option("-y, --yes", "Skip confirmation prompt", false) + .action(async (options: { hook?: string; yes: boolean }) => { + const { getDb } = await import("../db/index.js"); + const db = getDb(); + + const countRow = options.hook + ? db.query("SELECT COUNT(*) as n FROM hook_events WHERE hook_name = ?").get(options.hook) as any + : db.query("SELECT COUNT(*) as n FROM hook_events").get() as any; + const count = countRow?.n ?? 0; + + if (count === 0) { console.log(chalk.dim("Nothing to clear.")); return; } + + if (!options.yes) { + const scope = options.hook ? `hook "${options.hook}"` : "all hooks"; + console.log(chalk.yellow(`About to delete ${count} event(s) for ${scope}.`)); + console.log(chalk.dim("Re-run with --yes to confirm.")); + return; + } + + if (options.hook) { + db.run("DELETE FROM hook_events WHERE hook_name = ?", [options.hook]); + } else { + db.run("DELETE FROM hook_events"); + } + + console.log(chalk.green(`✓ Cleared ${count} event(s).`)); + }); + +} diff --git a/src/cli/commands/mcp.ts b/src/cli/commands/mcp.ts new file mode 100644 index 0000000..e8c13ba --- /dev/null +++ b/src/cli/commands/mcp.ts @@ -0,0 +1,28 @@ +import type { Command } from "commander"; + +export function registerMcpCommand(program: Command): void { +program + .command("mcp") + .option("-s, --stdio", "Use stdio transport (one process per agent)", false) + .option("--sse", "Use legacy SSE transport (port 39427)", false) + .option("--http", "Use Streamable HTTP transport (explicit; this is also the default)", false) + .option("-p, --port ", "Port for HTTP/SSE transport (defaults to 8847 for HTTP, 39427 for SSE)") + .description("Start MCP server for AI agent integration (default: shared Streamable HTTP)") + .action(async (options: { stdio: boolean; sse: boolean; http: boolean; port?: string }) => { + if (options.stdio) { + const { startStdioServer } = await import("../mcp/server.js"); + await startStdioServer(); + } else if (options.sse) { + const { startSSEServer } = await import("../mcp/server.js"); + await startSSEServer(options.port ? parseInt(options.port) : 39427); + } else { + // Default: shared Streamable HTTP server (one process per MCP, many agents). + const { createHooksServer } = await import("../mcp/server.js"); + const { resolveMcpHttpPort, startMcpHttpServer } = await import("../mcp/http.js"); + const args = options.port ? ["--port", options.port] : []; + startMcpHttpServer({ name: "hooks", port: resolveMcpHttpPort(args), buildServer: createHooksServer }); + } + }); +registerEventsCommands(program, { source: "hooks" }); + +} diff --git a/src/cli/commands/storage.ts b/src/cli/commands/storage.ts new file mode 100644 index 0000000..f0c4381 --- /dev/null +++ b/src/cli/commands/storage.ts @@ -0,0 +1,98 @@ +import type { Command } from "commander"; +import chalk from "chalk"; + +export function registerStorageCommands(program: Command): void { +const storageCmd = program + .command("storage") + .description("Sync local hook data with storage PostgreSQL"); + +storageCmd + .command("status") + .description("Show storage sync status") + .option("-j, --json", "Output as JSON", false) + .action(async (options: { json: boolean }) => { + const { getStorageStatus } = await import("../storage.js"); + const status = getStorageStatus(); + if (options.json) { + console.log(JSON.stringify(status, null, 2)); + return; + } + console.log(chalk.bold("\n Storage Status\n")); + console.log(` Configured: ${status.configured ? chalk.green(`yes (${status.activeEnv})`) : chalk.red("no")}`); + console.log(` Mode: ${status.mode}`); + console.log(` Tables: ${status.tables.join(", ")}`); + console.log(` Sync rows: ${status.sync.length}`); + }); + +storageCmd + .command("push") + .description("Push local hook data to storage PostgreSQL") + .option("-t, --tables ", "Comma-separated table names") + .option("-j, --json", "Output as JSON", false) + .action(async (options: { tables?: string; json: boolean }) => { + try { + const { parseStorageTables, storagePush } = await import("../storage.js"); + const results = await storagePush({ tables: parseStorageTables(options.tables) }); + if (options.json) { + console.log(JSON.stringify(results, null, 2)); + return; + } + const written = results.reduce((sum, result) => sum + result.rowsWritten, 0); + console.log(chalk.green(`✓ Pushed ${written} row(s)`)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (options.json) console.log(JSON.stringify({ error: message })); + else console.error(chalk.red(`✗ ${message}`)); + process.exitCode = 1; + } + }); + +storageCmd + .command("pull") + .description("Pull hook data from storage PostgreSQL to local SQLite") + .option("-t, --tables ", "Comma-separated table names") + .option("-j, --json", "Output as JSON", false) + .action(async (options: { tables?: string; json: boolean }) => { + try { + const { parseStorageTables, storagePull } = await import("../storage.js"); + const results = await storagePull({ tables: parseStorageTables(options.tables) }); + if (options.json) { + console.log(JSON.stringify(results, null, 2)); + return; + } + const written = results.reduce((sum, result) => sum + result.rowsWritten, 0); + console.log(chalk.green(`✓ Pulled ${written} row(s)`)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (options.json) console.log(JSON.stringify({ error: message })); + else console.error(chalk.red(`✗ ${message}`)); + process.exitCode = 1; + } + }); + +storageCmd + .command("sync") + .description("Bidirectional storage sync: pull then push") + .option("-t, --tables ", "Comma-separated table names") + .option("-j, --json", "Output as JSON", false) + .action(async (options: { tables?: string; json: boolean }) => { + try { + const { parseStorageTables, storageSync } = await import("../storage.js"); + const result = await storageSync({ tables: parseStorageTables(options.tables) }); + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + const pulled = result.pull.reduce((sum, entry) => sum + entry.rowsWritten, 0); + const pushed = result.push.reduce((sum, entry) => sum + entry.rowsWritten, 0); + console.log(chalk.green(`✓ Synced ${pulled} pulled row(s), ${pushed} pushed row(s)`)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (options.json) console.log(JSON.stringify({ error: message })); + else console.error(chalk.red(`✗ ${message}`)); + process.exitCode = 1; + } + }); + +// MCP server command +} diff --git a/src/cli/index.tsx b/src/cli/index.tsx index cfaffcf..86bbd13 100644 --- a/src/cli/index.tsx +++ b/src/cli/index.tsx @@ -1,1330 +1,32 @@ #!/usr/bin/env bun import { registerEventsCommands } from "@hasna/events/commander"; -import React from "react"; -import { render } from "ink"; import { Command } from "commander"; -import chalk from "chalk"; import { existsSync, readFileSync } from "fs"; -import { join, dirname } from "path"; +import { dirname, join } from "path"; import { fileURLToPath } from "url"; -import { homedir } from "os"; +import { registerCoreCommands } from "./commands/core.js"; +import { registerDocsCommands } from "./commands/docs.js"; +import { registerLogCommands } from "./commands/log.js"; +import { registerMcpCommand } from "./commands/mcp.js"; +import { registerStorageCommands } from "./commands/storage.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); -// Resolve package.json from both source (src/cli/) and built (bin/) locations const pkgPath = existsSync(join(__dirname, "..", "package.json")) ? join(__dirname, "..", "package.json") : join(__dirname, "..", "..", "package.json"); const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); -import { App } from "./components/App.js"; -import { - HOOKS, - CATEGORIES, - getHooksByCategory, - searchHooks, - getHook, - type HookMeta, -} from "../lib/registry.js"; -import { - installHook, - getInstalledHooks, - getRegisteredHooks, - getRegisteredHooksForTarget, - removeHook, - hookExists, - getHookPath, - getSettingsPath, - type ConcreteTarget, - type Scope, - type Target, -} from "../lib/installer.js"; -import { - createProfile, - getProfile, - listProfiles, - touchProfile, - exportProfiles, - importProfiles, -} from "../lib/profiles.js"; const program = new Command(); - -function resolveScope(options: { global?: boolean; project?: boolean }): Scope { - if (options.project) return "project"; - return "global"; -} - -function resolveTarget(options: { target?: string }): Target { - if (options.target === "gemini") return "gemini"; - if (options.target === "codewith") return "codewith"; - if (options.target === "all") return "all"; - return "claude"; -} - -function resolveConcreteTarget(options: { target?: string }): ConcreteTarget { - if (options.target === "gemini") return "gemini"; - if (options.target === "codewith") return "codewith"; - return "claude"; -} - -function formatSettingsPath(scope: Scope, target: Target): string { - if (target === "all") return "target-specific settings"; - const actual = getSettingsPath(scope, target); - if (scope === "project") { - if (target === "codewith") return ".codewith/config.toml"; - if (target === "gemini") return ".gemini/settings.json"; - return ".claude/settings.json"; - } - if (target === "codewith") { - return process.env.HASNA_HOOKS_CODEWITH_CONFIG_PATH ? "$HASNA_HOOKS_CODEWITH_CONFIG_PATH" : "~/.codewith/config.toml"; - } - if (target === "gemini") return "~/.gemini/settings.json"; - return actual === getSettingsPath("global", "claude") ? "~/.claude/settings.json" : actual; -} - -function parseLimit(value: string | undefined, fallback: number, max: number): number { - const parsed = value ? parseInt(value, 10) : fallback; - if (!Number.isFinite(parsed) || parsed <= 0) return fallback; - return Math.min(parsed, max); -} - -function truncateText(value: string | undefined, max = 96): string { - const text = (value ?? "").replace(/\s+/g, " ").trim(); - if (text.length <= max) return text; - return `${text.slice(0, Math.max(0, max - 3))}...`; -} - -function readmePreview(readme: string, max = 280): string | undefined { - const blocks = readme - .split(/\n\s*\n/) - .map((part) => part.trim()) - .filter(Boolean) - .filter((part) => !part.startsWith("#")) - .filter((part) => !part.startsWith("```")) - .filter((part) => !/^\[!\[/.test(part)); - const preview = blocks[0] - ?? readme.split("\n").map((line) => line.trim()).find((line) => line && !line.startsWith("#")); - return preview ? truncateText(preview, max) : undefined; -} - -function hookSummaryLine(hook: HookMeta, options: { verbose?: boolean } = {}): string { - const matcher = hook.matcher ? ` ${hook.matcher}` : ""; - const description = options.verbose ? ` - ${truncateText(hook.description, 110)}` : ""; - return ` ${chalk.cyan(hook.name.padEnd(17))} ${chalk.dim(`[${hook.event}${matcher}]`)} ${chalk.dim(hook.category)}${description}`; -} - -function printDisclosureHint(hidden: number, detailCommand: string, options: { includeAll?: boolean } = {}): void { - const rowControls = options.includeAll ? "--limit, --all, --verbose" : "--limit, --verbose"; - if (hidden > 0) { - console.log(chalk.dim(`\n Showing a compact subset. ${hidden} more hidden; use ${rowControls}, or ${detailCommand}.`)); - } else { - console.log(chalk.dim(`\n Use --verbose or ${detailCommand} for details.`)); - } -} - -/** Levenshtein distance for did-you-mean suggestions */ -function editDistance(a: string, b: string): number { - const m = a.length, n = b.length; - const dp: number[][] = Array.from({ length: m + 1 }, (_, i) => [i, ...Array(n).fill(0)]); - for (let j = 0; j <= n; j++) dp[0][j] = j; - for (let i = 1; i <= m; i++) { - for (let j = 1; j <= n; j++) { - dp[i][j] = a[i - 1] === b[j - 1] - ? dp[i - 1][j - 1] - : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]); - } - } - return dp[m][n]; -} - -function suggestHooks(name: string, max = 3): string[] { - return HOOKS - .map((h) => ({ name: h.name, dist: editDistance(name.toLowerCase(), h.name.toLowerCase()) })) - .filter(({ dist }) => dist <= 4) - .sort((a, b) => a.dist - b.dist) - .slice(0, max) - .map(({ name: n }) => n); -} - program .name("hooks") .description("Install hooks for AI coding agents") .version(pkg.version); -// Interactive mode (default) -program - .command("interactive", { isDefault: true }) - .alias("i") - .description("Interactive hook browser") - .action(() => { - render(); - }); - -// Init command — register a new agent profile -program - .command("init") - .description("Register a new agent profile with a unique ID") - .option("-a, --agent ", "Agent type: claude, gemini, custom", "claude") - .option("-n, --name ", "Optional display name for the agent") - .option("-j, --json", "Output as JSON", false) - .action((options: { agent: string; name?: string; json: boolean }) => { - const agentType = options.agent as "claude" | "gemini" | "custom"; - if (!["claude", "gemini", "custom"].includes(agentType)) { - if (options.json) { - console.log(JSON.stringify({ error: `Invalid agent type: ${options.agent}`, valid: ["claude", "gemini", "custom"] })); - } else { - console.log(chalk.red(`Invalid agent type: ${options.agent}`)); - console.log(chalk.dim("Valid types: claude, gemini, custom")); - } - return; - } - - const profile = createProfile({ agent_type: agentType, name: options.name }); - - if (options.json) { - console.log(JSON.stringify(profile)); - return; - } - - console.log(chalk.green(`\n✓ Agent profile created\n`)); - console.log(` ${chalk.dim("Agent ID:")} ${chalk.bold(profile.agent_id)}`); - console.log(` ${chalk.dim("Type:")} ${profile.agent_type}`); - if (profile.name) { - console.log(` ${chalk.dim("Name:")} ${profile.name}`); - } - console.log(` ${chalk.dim("Profile:")} ~/.hasna/hooks/profiles/${profile.agent_id}.json`); - console.log(); - console.log(chalk.dim(" Install hooks with this profile:")); - console.log(` hooks install gitguard --profile ${profile.agent_id}`); - console.log(); - }); - -// Run command — executes a hook, called by AI coding agents via settings.json -program - .command("run") - .argument("", "Hook to run") - .option("--profile ", "Agent profile ID") - .description("Execute a hook (called by AI coding agents)") - .action(async (hook: string, options: { profile?: string }) => { - const meta = getHook(hook); - if (!meta) { - console.error(JSON.stringify({ error: `Hook '${hook}' not found` })); - process.exit(1); - } - - const hookDir = getHookPath(hook); - const hookScript = join(hookDir, "src", "hook.ts"); - - if (!existsSync(hookScript)) { - console.error(JSON.stringify({ error: `Hook script not found: ${hookScript}` })); - process.exit(1); - } - - // Read stdin (agent passes hook context as JSON) - const stdin = await new Response(Bun.stdin.stream()).text(); - - // If profile specified, inject agent data into the hook input - let hookStdin = stdin; - if (options.profile) { - const profile = getProfile(options.profile); - if (profile) { - touchProfile(options.profile); - try { - const input = JSON.parse(stdin); - input.agent = { - agent_id: profile.agent_id, - agent_type: profile.agent_type, - name: profile.name, - preferences: profile.preferences, - }; - hookStdin = JSON.stringify(input); - } catch { - // If stdin is not valid JSON, pass through unmodified - } - } - } - - // Execute the hook script with bun, passing stdin through - const proc = Bun.spawn(["bun", "run", hookScript], { - stdin: new Response(hookStdin), - stdout: "pipe", - stderr: "pipe", - env: process.env, - }); - - const stdout = await new Response(proc.stdout).text(); - const stderr = await new Response(proc.stderr).text(); - const exitCode = await proc.exited; - - if (stdout) process.stdout.write(stdout); - if (stderr) process.stderr.write(stderr); - process.exit(exitCode); - }); - -// Install command -program - .command("install") - .alias("add") - .argument("[hooks...]", "Hooks to install") - .option("-o, --overwrite", "Overwrite existing hooks", false) - .option("-a, --all", "Install all available hooks", false) - .option("-c, --category ", "Install all hooks in a category") - .option("-g, --global", "Install globally (~/.claude/settings.json)", false) - .option("-p, --project", "Install for current project (.claude/settings.json)", false) - .option("-t, --target ", "Agent target: claude, gemini, codewith, all (default: claude)", "claude") - .option("--profile ", "Agent profile ID to scope hooks to") - .option("--dry-run", "Preview what would be installed without writing to settings", false) - .option("--apply-codewith", "Explicitly append Codewith TOML to a config file (prefer open-configs for managed configs)", false) - .option("--codewith-config ", "Explicit Codewith config path required with --apply-codewith") - .option("-j, --json", "Output as JSON", false) - .description("Install one or more hooks") - .action((hooks: string[], options) => { - const scope = resolveScope(options); - const target = resolveTarget(options); - let toInstall: string[] = hooks; - - if (options.all) { - toInstall = HOOKS.map((h) => h.name); - } else if (options.category) { - const category = CATEGORIES.find( - (c) => c.toLowerCase() === options.category.toLowerCase() - ); - if (!category) { - if (options.json) { - console.log(JSON.stringify({ error: `Unknown category: ${options.category}`, available: [...CATEGORIES] })); - } else { - console.log(chalk.red(`Unknown category: ${options.category}`)); - console.log(chalk.dim(`Available: ${CATEGORIES.join(", ")}`)); - } - return; - } - toInstall = getHooksByCategory(category).map((h) => h.name); - } - - if (toInstall.length === 0) { - render(); - return; - } - - if (options.applyCodewith && (target === "codewith" || target === "all") && !options.codewithConfig) { - const message = "--apply-codewith requires --codewith-config ; refusing to write default ~/.codewith/config.toml."; - if (options.json) { - console.log(JSON.stringify({ error: message, scope, target, applied: false })); - } else { - console.log(chalk.red(message)); - } - return; - } - - // Dry-run: preview what would be installed - if (options.dryRun) { - const known = toInstall.filter((n) => getHook(n)); - const unknown = toInstall.filter((n) => !getHook(n)); - if (options.json) { - console.log(JSON.stringify({ dryRun: true, would_install: known, unknown, scope, target, mode: target === "codewith" ? "fragment" : "write" })); - return; - } - console.log(chalk.bold(`\nDry run — would install (${scope}, ${target}):\n`)); - for (const name of known) { - const meta = getHook(name)!; - console.log(chalk.cyan(` ${name}`) + chalk.dim(` [${meta.event}${meta.matcher ? ` ${meta.matcher}` : ""}]`)); - } - if (unknown.length > 0) { - console.log(); - for (const name of unknown) { - const suggestions = suggestHooks(name); - console.log(chalk.red(` ✗ unknown: ${name}`) + (suggestions.length ? chalk.dim(` — did you mean: ${suggestions.join(", ")}?`) : "")); - } - } - return; - } - - const results = []; - for (const name of toInstall) { - // Did-you-mean for unknown hooks - if (!getHook(name)) { - const suggestions = suggestHooks(name); - const hint = suggestions.length ? ` — did you mean: ${suggestions.join(", ")}?` : ""; - results.push({ hook: name, success: false, error: `Hook '${name}' not found${hint}` }); - continue; - } - const result = installHook(name, { - scope, - overwrite: options.overwrite, - target, - profile: options.profile, - codewithMode: options.applyCodewith ? "write" : "fragment", - codewithConfigPath: options.codewithConfig, - }); - results.push(result); - } - - if (options.json) { - console.log(JSON.stringify({ - installed: results.filter((r) => r.success).map((r) => r.hook), - failed: results.filter((r) => !r.success).map((r) => ({ hook: r.hook, error: r.error })), - fragments: results.filter((r) => r.success && r.fragment).map((r) => ({ hook: r.hook, fragment: r.fragment, applied: r.applied, configPath: r.configPath, note: r.note })), - total: results.length, - success: results.filter((r) => r.success).length, - scope, - target, - applied: results.some((r) => r.applied), - })); - return; - } - - const settingsFile = target === "codewith" - ? (options.applyCodewith ? options.codewithConfig : "TOML fragment only (open-configs should apply)") - : scope === "project" ? ".claude/settings.json" : "~/.claude/settings.json"; - console.log(chalk.bold(`\nInstalling hooks (${scope}, ${target})...\n`)); - for (const result of results) { - if (result.success) { - const meta = getHook(result.hook); - console.log(chalk.green(`✓ ${result.hook}`)); - if (meta) { - console.log( - chalk.dim(` ${meta.event}${meta.matcher ? ` [${meta.matcher}]` : ""} → hooks run ${result.hook}`) - ); - } - if (result.conflict) { - console.log(chalk.yellow(` ⚠ Warning: ${result.conflict}`)); - } - if (result.fragment && target === "codewith") { - console.log(chalk.dim(" Codewith TOML fragment:")); - console.log(chalk.cyan(result.fragment.trimEnd().split("\n").map((line) => ` ${line}`).join("\n"))); - if (result.note) console.log(chalk.yellow(` ⚠ ${result.note}`)); - } - } else { - console.log(chalk.red(`✗ ${result.hook}: ${result.error}`)); - } - } - console.log(chalk.dim(`\nRegistered in ${settingsFile}`)); - }); - -// List command -program - .command("list") - .alias("ls") - .option("-c, --category ", "Filter by category") - .option("-a, --all", "Show all available hooks", false) - .option("-i, --installed", "Show only installed hooks", false) - .option("-r, --registered", "Show registered hooks", false) - .option("-g, --global", "Check global settings", false) - .option("-p, --project", "Check project settings", false) - .option("-t, --target ", "Agent target: claude, gemini, codewith (default: claude)", "claude") - .option("-n, --limit ", "Max rows to show in compact output", "20") - .option("--verbose", "Show descriptions and full detail columns", false) - .option("-j, --json", "Output as JSON", false) - .description("List available or installed hooks") - .action((options) => { - const scope = resolveScope(options); - const limit = options.all ? Number.MAX_SAFE_INTEGER : parseLimit(options.limit, 20, 200); - - if (options.registered || options.installed) { - const target = (options.target === "gemini" ? "gemini" : options.target === "codewith" ? "codewith" : "claude") as "claude" | "gemini" | "codewith"; - const registered = getRegisteredHooksForTarget(scope, target); - if (options.json) { - console.log(JSON.stringify(registered.map((name) => { - const meta = getHook(name); - return { name, event: meta?.event, version: meta?.version, description: meta?.description, scope, target }; - }))); - return; - } - if (registered.length === 0) { - console.log(chalk.dim(`No hooks registered (${scope}, ${target})`)); - return; - } - const visible = registered.slice(0, limit); - console.log(chalk.bold(`\nRegistered hooks — ${scope}/${target} (${registered.length}, showing ${visible.length}):\n`)); - for (const name of visible) { - const meta = getHook(name); - if (meta) console.log(hookSummaryLine(meta, { verbose: options.verbose })); - else console.log(` ${chalk.cyan(name)} ${chalk.dim("[unknown]")}`); - } - printDisclosureHint(registered.length - visible.length, "hooks info ", { includeAll: true }); - return; - } - - if (options.category) { - const category = CATEGORIES.find( - (c) => c.toLowerCase() === options.category.toLowerCase() - ); - if (!category) { - if (options.json) { - console.log(JSON.stringify({ error: `Unknown category: ${options.category}`, available: [...CATEGORIES] })); - } else { - console.log(chalk.red(`Unknown category: ${options.category}`)); - console.log(chalk.dim(`Available: ${CATEGORIES.join(", ")}`)); - } - return; - } - const hooks = getHooksByCategory(category); - if (options.json) { - console.log(JSON.stringify(hooks)); - return; - } - const visible = hooks.slice(0, limit); - console.log(chalk.bold(`\n${category} (${hooks.length}, showing ${visible.length}):\n`)); - for (const h of visible) console.log(hookSummaryLine(h, { verbose: options.verbose })); - printDisclosureHint(hooks.length - visible.length, "hooks info ", { includeAll: true }); - return; - } - - // Show all by category - if (options.json) { - const result: Record = {}; - for (const category of CATEGORIES) { - result[category] = getHooksByCategory(category); - } - console.log(JSON.stringify(result)); - return; - } - - const visible = HOOKS.slice(0, limit); - console.log(chalk.bold(`\nAvailable hooks (${HOOKS.length}, showing ${visible.length}):\n`)); - for (const h of visible) console.log(hookSummaryLine(h, { verbose: options.verbose })); - printDisclosureHint(HOOKS.length - visible.length, "hooks info ", { includeAll: true }); - }); - -// Search command -program - .command("search") - .argument("", "Search term") - .option("-n, --limit ", "Max rows to show in compact output", "10") - .option("--verbose", "Show descriptions for search results", false) - .option("-j, --json", "Output as JSON", false) - .description("Search for hooks") - .action((query: string, options: { limit: string; verbose: boolean; json: boolean }) => { - const results = searchHooks(query); - if (options.json) { - console.log(JSON.stringify(results)); - return; - } - if (results.length === 0) { - console.log(chalk.dim(`No hooks found for "${query}"`)); - return; - } - const limit = parseLimit(options.limit, 10, 100); - const visible = results.slice(0, limit); - console.log(chalk.bold(`\nFound ${results.length} hook(s), showing ${visible.length}:\n`)); - for (const h of visible) console.log(hookSummaryLine(h, { verbose: options.verbose })); - printDisclosureHint(results.length - visible.length, "hooks info "); - }); - -// Remove command -program - .command("remove") - .alias("rm") - .argument("", "Hook to remove") - .option("-g, --global", "Remove from global settings", false) - .option("-p, --project", "Remove from project settings", false) - .option("-t, --target ", "Agent target: claude, gemini, codewith, all (default: claude)", "claude") - .option("-j, --json", "Output as JSON", false) - .description("Remove an installed hook") - .action((hook: string, options: { global?: boolean; project?: boolean; target?: string; json: boolean }) => { - const scope = resolveScope(options); - const target = resolveTarget(options); - - // Did-you-mean for unknown hook names - if (!getHook(hook)) { - const suggestions = suggestHooks(hook); - const hint = suggestions.length ? ` — did you mean: ${suggestions.join(", ")}?` : ""; - if (options.json) { - console.log(JSON.stringify({ hook, removed: false, scope, target, error: `Hook '${hook}' not found${hint}`, suggestions })); - } else { - console.log(chalk.red(`✗ Hook '${hook}' not found${hint}`)); - } - return; - } - - const removed = removeHook(hook, scope, target); - if (options.json) { - console.log(JSON.stringify({ hook, removed, scope, target })); - return; - } - if (removed) { - console.log(chalk.green(`✓ Removed ${hook} (${scope}, ${target})`)); - } else { - console.log(chalk.red(`✗ ${hook} is not installed (${scope}, ${target})`)); - } - }); - -// Categories command -program - .command("categories") - .option("-j, --json", "Output as JSON", false) - .description("List all categories") - .action((options: { json: boolean }) => { - if (options.json) { - const result = CATEGORIES.map((cat) => ({ - name: cat, - count: getHooksByCategory(cat).length, - })); - console.log(JSON.stringify(result)); - return; - } - console.log(chalk.bold("\nCategories:\n")); - for (const category of CATEGORIES) { - const count = getHooksByCategory(category).length; - console.log(` ${category} (${count})`); - } - }); - -// Info command -program - .command("info") - .argument("", "Hook name") - .option("-j, --json", "Output as JSON", false) - .description("Show detailed info about a hook") - .action((hook: string, options: { json: boolean }) => { - const meta = getHook(hook); - if (!meta) { - const suggestions = suggestHooks(hook); - const hint = suggestions.length ? ` — did you mean: ${suggestions.join(", ")}?` : ""; - if (options.json) { - console.log(JSON.stringify({ error: `Hook '${hook}' not found${hint}`, suggestions })); - } else { - console.log(chalk.red(`Hook '${hook}' not found${hint}`)); - } - return; - } - - const globalInstalled = getRegisteredHooks("global").includes(meta.name); - const projectInstalled = getRegisteredHooks("project").includes(meta.name); - - if (options.json) { - console.log(JSON.stringify({ ...meta, global: globalInstalled, project: projectInstalled })); - return; - } - - console.log(chalk.bold(`\n${meta.displayName}\n`)); - console.log(` ${meta.description}`); - console.log(); - console.log(` ${chalk.dim("Category:")} ${meta.category}`); - console.log(` ${chalk.dim("Version:")} ${meta.version}`); - console.log(` ${chalk.dim("Event:")} ${meta.event}`); - console.log(` ${chalk.dim("Matcher:")} ${meta.matcher || "(none)"}`); - console.log(` ${chalk.dim("Tags:")} ${meta.tags.join(", ")}`); - console.log(` ${chalk.dim("Command:")} hooks run ${meta.name}`); - console.log(); - - if (globalInstalled) { - console.log(chalk.green(" ● Installed globally")); - } else { - console.log(chalk.dim(" ○ Not installed globally")); - } - - if (projectInstalled) { - console.log(chalk.green(" ● Installed in project")); - } else { - console.log(chalk.dim(" ○ Not installed in project")); - } - }); - -// Doctor command -program - .command("doctor") - .option("-g, --global", "Check global settings", false) - .option("-p, --project", "Check project settings", false) - .option("-j, --json", "Output as JSON", false) - .description("Check health of installed hooks") - .action((options: { global?: boolean; project?: boolean; json: boolean }) => { - const scope = resolveScope(options); - const settingsPath = getSettingsPath(scope); - const issues: { hook: string; issue: string; severity: "error" | "warning" }[] = []; - const healthy: string[] = []; - - const settingsExist = existsSync(settingsPath); - if (!settingsExist) { - issues.push({ hook: "(settings)", issue: `${settingsPath} not found`, severity: "warning" }); - } - - const registered = getRegisteredHooks(scope); - - for (const name of registered) { - const meta = getHook(name); - let hookHealthy = true; - - // Check hook exists in the package - if (!hookExists(name)) { - issues.push({ hook: name, issue: "Hook not found in @hasna/hooks package", severity: "error" }); - hookHealthy = false; - continue; - } - - // Check hook has source - const hookDir = getHookPath(name); - const hookScript = join(hookDir, "src", "hook.ts"); - if (!existsSync(hookScript)) { - issues.push({ hook: name, issue: "Missing src/hook.ts in package", severity: "error" }); - hookHealthy = false; - } - - // Verify correct event registration - if (meta && settingsExist) { - try { - const settings = JSON.parse(readFileSync(settingsPath, "utf-8")); - const eventHooks = settings.hooks?.[meta.event] || []; - const found = eventHooks.some((entry: any) => - entry.hooks?.some((h: any) => { - const match = h.command?.match(/^hooks run ([\w-]+)/); - return match && match[1] === name; - }) - ); - if (!found) { - issues.push({ hook: name, issue: `Not registered under correct event (${meta.event})`, severity: "error" }); - hookHealthy = false; - } - } catch {} - } - - if (hookHealthy) { - healthy.push(name); - } - } - - if (options.json) { - console.log(JSON.stringify({ healthy: issues.length === 0, healthy_hooks: healthy, issues, registered, scope })); - return; - } - - console.log(chalk.bold(`\nHook Health Check (${scope})\n`)); - - if (registered.length === 0) { - console.log(chalk.dim(" No hooks registered.")); - console.log(chalk.dim(" Run: hooks install gitguard")); - return; - } - - if (healthy.length > 0) { - console.log(chalk.green(` ✓ ${healthy.length} hook(s) healthy:`)); - for (const name of healthy) { - console.log(chalk.green(` ${name}`)); - } - } - - if (issues.length > 0) { - console.log(); - for (const issue of issues) { - const icon = issue.severity === "error" ? chalk.red("✗") : chalk.yellow("!"); - console.log(` ${icon} ${chalk.cyan(issue.hook)}: ${issue.issue}`); - } - } - - if (issues.length === 0) { - console.log(chalk.green("\n All hooks healthy!")); - } - - console.log(); - }); - -// Update command -program - .command("update") - .argument("[hooks...]", "Hooks to update (defaults to all installed)") - .option("-g, --global", "Update global hooks", false) - .option("-p, --project", "Update project hooks", false) - .option("-j, --json", "Output as JSON", false) - .description("Re-register hooks (picks up new package version)") - .action((hooks: string[], options: { global?: boolean; project?: boolean; json: boolean }) => { - const scope = resolveScope(options); - const installed = getInstalledHooks(scope); - const toUpdate = hooks.length > 0 ? hooks : installed; - - if (toUpdate.length === 0) { - if (options.json) { - console.log(JSON.stringify({ updated: [], error: "No hooks installed" })); - } else { - console.log(chalk.dim("No hooks installed to update.")); - } - return; - } - - const results = []; - for (const name of toUpdate) { - if (!installed.includes(name)) { - results.push({ hook: name, success: false, error: "Not installed" }); - continue; - } - const result = installHook(name, { scope, overwrite: true }); - results.push(result); - } - - if (options.json) { - console.log(JSON.stringify({ - updated: results.filter((r) => r.success).map((r) => r.hook), - failed: results.filter((r) => !r.success).map((r) => ({ hook: r.hook, error: r.error })), - })); - return; - } - - console.log(chalk.bold("\nUpdating hooks...\n")); - for (const result of results) { - if (result.success) { - console.log(chalk.green(`✓ ${result.hook} updated`)); - } else { - console.log(chalk.red(`✗ ${result.hook}: ${result.error}`)); - } - } - }); - -// Docs command -program - .command("docs") - .argument("[hook]", "Hook name (shows general docs if omitted)") - .option("--verbose", "Print full hook README content", false) - .option("-j, --json", "Output as JSON", false) - .description("Show documentation for hooks") - .action((hook: string | undefined, options: { verbose: boolean; json: boolean }) => { - if (hook) { - const meta = getHook(hook); - if (!meta) { - if (options.json) { - console.log(JSON.stringify({ error: `Hook '${hook}' not found` })); - } else { - console.log(chalk.red(`Hook '${hook}' not found`)); - } - return; - } - - const hookPath = getHookPath(hook); - const readmePath = join(hookPath, "README.md"); - let readme = ""; - if (existsSync(readmePath)) { - readme = readFileSync(readmePath, "utf-8"); - } - - if (options.json) { - console.log(JSON.stringify({ ...meta, readme })); - return; - } - - console.log(chalk.bold(`\n${meta.displayName} v${meta.version}\n`)); - console.log(` ${meta.description}\n`); - console.log(chalk.bold(" Configuration:")); - console.log(` Event: ${meta.event}`); - console.log(` Matcher: ${meta.matcher || "(all tools)"}`); - console.log(` Command: hooks run ${meta.name}`); - console.log(); - console.log(chalk.bold(" Install:")); - console.log(` hooks install ${meta.name} # global`); - console.log(` hooks install ${meta.name} --project # project only`); - console.log(); - - if (readme && options.verbose) { - console.log(chalk.bold(" README:\n")); - for (const line of readme.split("\n")) { - console.log(` ${line}`); - } - } else if (readme) { - const preview = readmePreview(readme); - if (preview) { - console.log(chalk.bold(" README Preview:\n")); - console.log(` ${preview}\n`); - } - console.log(chalk.dim(` README has ${readme.split("\n").length} lines. Use hooks docs ${meta.name} --verbose for the full README, or --json for machine-readable output.`)); - } - return; - } - - // General docs - const generalDocs = { - overview: "Hooks are scripts that run at specific points in an AI coding agent session. Install @hasna/hooks globally, then register hooks — no files are copied to your project.", - events: { - SessionStart: "Fires when a session starts or resumes. Codewith can inject context via hookSpecificOutput.additionalContext.", - UserPromptSubmit: "Codewith-native event when a user prompt is submitted; can block obvious injection attempts.", - PreToolUse: "Fires before a tool executes. Can block the operation by returning { \"decision\": \"block\" }.", - PostToolUse: "Fires after a tool executes. Runs asynchronously, cannot block.", - Stop: "Fires at turn end in Codewith and when other agents finish responding. Useful for notifications and cleanup.", - Notification: "Fires on notification events like context compaction.", - SessionEnd: "Fires when a session terminates. Useful for cleanup and final announcements.", - }, - installation: { - global: "hooks install gitguard", - project: "hooks install gitguard --project", - codewith: "hooks install session-start --target codewith # emits TOML for open-configs to apply", - category: "hooks install --category \"Git Safety\"", - all: "hooks install --all", - }, - management: { - list: "hooks list", - listInstalled: "hooks list --installed", - search: "hooks search ", - info: "hooks info ", - remove: "hooks remove ", - update: "hooks update", - doctor: "hooks doctor", - docs: "hooks docs ", - }, - howItWorks: { - install: "bun install -g @hasna/hooks", - register: "hooks install gitguard → writes to ~/.claude/settings.json; hooks install session-start --target codewith emits a TOML fragment", - execution: "Agent runs 'hooks run gitguard' → executes hook from global package", - noFileCopy: "No files are copied to your project. Hooks run from the global @hasna/hooks package.", - }, - }; - - if (options.json) { - console.log(JSON.stringify(generalDocs)); - return; - } - - console.log(chalk.bold("\n@hasna/hooks Documentation\n")); - - console.log(chalk.bold(" Overview\n")); - console.log(` ${generalDocs.overview}\n`); - - console.log(chalk.bold(" How It Works\n")); - for (const [label, desc] of Object.entries(generalDocs.howItWorks)) { - console.log(` ${chalk.dim(label + ":")} ${desc}`); - } - - console.log(chalk.bold("\n Hook Events\n")); - for (const [event, desc] of Object.entries(generalDocs.events)) { - console.log(` ${chalk.cyan(event)}`); - console.log(` ${desc}\n`); - } - - console.log(chalk.bold(" Installation\n")); - for (const [label, cmd] of Object.entries(generalDocs.installation)) { - console.log(` ${chalk.dim(label + ":")} ${cmd}`); - } - - console.log(chalk.bold("\n Management\n")); - for (const [label, cmd] of Object.entries(generalDocs.management)) { - console.log(` ${chalk.dim(label + ":")} ${cmd}`); - } - - console.log(chalk.bold("\n Hook-Specific Docs\n")); - console.log(` hooks docs Compact hook docs`); - console.log(` hooks docs --verbose Full hook README`); - console.log(` hooks docs --json Machine-readable documentation`); - console.log(); - }); - -// Upgrade command — self-update the @hasna/hooks package -program - .command("upgrade") - .option("-c, --check", "Check for updates without installing", false) - .option("-j, --json", "Output as JSON", false) - .description("Update the @hasna/hooks package to the latest version") - .action(async (options: { check: boolean; json: boolean }) => { - const current = pkg.version; - - // Detect package manager: prefer bun, fallback to npm - let pm = "npm"; - try { - const which = Bun.spawnSync(["which", "bun"]); - if (which.exitCode === 0) pm = "bun"; - } catch {} - - if (options.check) { - // Fetch latest version from npm registry - const proc = Bun.spawnSync(["npm", "view", "@hasna/hooks", "version"]); - const latest = new TextDecoder().decode(proc.stdout).trim(); - - if (!latest) { - if (options.json) { - console.log(JSON.stringify({ error: "Failed to fetch latest version" })); - } else { - console.log(chalk.red("Failed to fetch latest version from npm registry.")); - } - process.exit(1); - } - - const upToDate = current === latest; - if (options.json) { - console.log(JSON.stringify({ current, latest, upToDate })); - } else if (upToDate) { - console.log(chalk.green(`✓ Already on latest version (${current})`)); - } else { - console.log(chalk.yellow(`Update available: ${current} → ${latest}`)); - console.log(chalk.dim(` Run: hooks upgrade`)); - } - return; - } - - // Perform the upgrade - const installCmd = pm === "bun" - ? ["bun", "install", "-g", "@hasna/hooks@latest"] - : ["npm", "install", "-g", "@hasna/hooks@latest"]; - - if (!options.json) { - console.log(chalk.bold(`\nUpgrading @hasna/hooks (${pm})...\n`)); - console.log(chalk.dim(` $ ${installCmd.join(" ")}\n`)); - } - - const proc = Bun.spawn(installCmd, { - stdout: options.json ? "pipe" : "inherit", - stderr: options.json ? "pipe" : "inherit", - env: process.env, - }); - - const exitCode = await proc.exited; - - if (exitCode !== 0) { - if (options.json) { - console.log(JSON.stringify({ current, updated: false, error: `${pm} exited with code ${exitCode}` })); - } else { - console.log(chalk.red(`\n✗ Upgrade failed (exit code ${exitCode})`)); - } - process.exit(exitCode); - } - - // Check new version - const versionProc = Bun.spawnSync(["npm", "view", "@hasna/hooks", "version"]); - const latest = new TextDecoder().decode(versionProc.stdout).trim() || "unknown"; - - if (options.json) { - console.log(JSON.stringify({ current, latest, updated: true })); - } else { - console.log(chalk.green(`\n✓ Upgraded: ${current} → ${latest}`)); - } - }); - -// Profile export command -program - .command("profile-export") - .description("Export all agent profiles as JSON (for backup/cross-machine setup)") - .option("-o, --output ", "Write to file instead of stdout") - .option("-j, --json", "Output as JSON (default: true)", false) - .action(async (options: { output?: string; json: boolean }) => { - const profiles = exportProfiles(); - const json = JSON.stringify(profiles, null, 2); - if (options.output) { - const { writeFileSync } = await import("fs"); - writeFileSync(options.output, json + "\n"); - console.log(chalk.green(`✓ Exported ${profiles.length} profile(s) to ${options.output}`)); - } else { - console.log(json); - } - }); - -// Profile import command -program - .command("profile-import") - .argument("", "JSON file to import profiles from (use - for stdin)") - .description("Import agent profiles from a JSON export file") - .option("-j, --json", "Output result as JSON", false) - .action(async (file: string, options: { json: boolean }) => { - let raw: string; - if (file === "-") { - raw = await new Response(Bun.stdin.stream()).text(); - } else { - const { readFileSync } = await import("fs"); - try { - raw = readFileSync(file, "utf-8"); - } catch { - if (options.json) { - console.log(JSON.stringify({ error: `Cannot read file: ${file}` })); - } else { - console.log(chalk.red(`✗ Cannot read file: ${file}`)); - } - return; - } - } - - let profiles: any[]; - try { - const parsed = JSON.parse(raw); - profiles = Array.isArray(parsed) ? parsed : [parsed]; - } catch { - if (options.json) { - console.log(JSON.stringify({ error: "Invalid JSON" })); - } else { - console.log(chalk.red("✗ Invalid JSON")); - } - return; - } - - const result = importProfiles(profiles); - if (options.json) { - console.log(JSON.stringify(result)); - } else { - console.log(chalk.green(`✓ Imported ${result.imported} profile(s)`)); - if (result.skipped > 0) console.log(chalk.dim(` Skipped ${result.skipped} (already exist or invalid)`)); - } - }); - -// Log command group — query hook events from SQLite -const logCmd = program - .command("log") - .description("Query hook event logs from SQLite (~/.hasna/hooks/hooks.db)"); - -logCmd - .command("list") - .description("List hook events") - .option("--hook ", "Filter by hook name") - .option("--session ", "Filter by session ID") - .option("-n, --limit ", "Number of rows to show", "50") - .option("-j, --json", "Output as JSON", false) - .action(async (options: { hook?: string; session?: string; limit: string; json: boolean }) => { - const { getDb } = await import("../db/index.js"); - const db = getDb(); - const limit = parseInt(options.limit) || 50; - - let sql = "SELECT * FROM hook_events WHERE 1=1"; - const params: string[] = []; - - if (options.hook) { sql += " AND hook_name = ?"; params.push(options.hook); } - if (options.session) { sql += " AND session_id LIKE ?"; params.push(`${options.session}%`); } - sql += " ORDER BY timestamp DESC LIMIT ?"; - params.push(String(limit)); - - const rows = db.query(sql).all(...params) as any[]; - - if (options.json) { console.log(JSON.stringify(rows, null, 2)); return; } - if (rows.length === 0) { console.log(chalk.dim("No events found.")); return; } - - console.log(chalk.bold(`\n Hook Events (${rows.length})\n`)); - for (const row of rows) { - const ts = row.timestamp.slice(0, 19).replace("T", " "); - const err = row.error ? chalk.red(` ERR: ${truncateText(row.error, 60)}`) : ""; - const tool = row.tool_name ? chalk.dim(` [${row.tool_name}]`) : ""; - console.log(` ${chalk.dim(ts)} ${chalk.cyan(row.hook_name.padEnd(14))}${tool}${err}`); - } - console.log(chalk.dim("\n Compact rows shown. Use --json for full event records or --limit to change row count.")); - }); - -logCmd - .command("search ") - .description("Search hook events by tool_input or error text") - .option("-n, --limit ", "Number of rows to show", "50") - .option("-j, --json", "Output as JSON", false) - .action(async (text: string, options: { limit: string; json: boolean }) => { - const { getDb } = await import("../db/index.js"); - const db = getDb(); - const limit = parseInt(options.limit) || 50; - const q = `%${text}%`; - const rows = db.query( - "SELECT * FROM hook_events WHERE tool_input LIKE ? OR error LIKE ? ORDER BY timestamp DESC LIMIT ?" - ).all(q, q, limit) as any[]; - - if (options.json) { console.log(JSON.stringify(rows, null, 2)); return; } - if (rows.length === 0) { console.log(chalk.dim(`No events matching "${text}".`)); return; } - - console.log(chalk.bold(`\n Search results for "${text}" (${rows.length})\n`)); - for (const row of rows) { - const ts = row.timestamp.slice(0, 19).replace("T", " "); - const snippet = truncateText(row.tool_input || row.error || "", 80); - console.log(` ${chalk.dim(ts)} ${chalk.cyan(row.hook_name.padEnd(14))} ${chalk.dim(snippet)}`); - } - console.log(chalk.dim("\n Compact rows shown. Use --json for full event records or --limit to change row count.")); - }); - -logCmd - .command("tail") - .description("Show most recent hook events") - .option("-n ", "Number of rows", "20") - .option("-j, --json", "Output as JSON", false) - .action(async (options: { n: string; json: boolean }) => { - const { getDb } = await import("../db/index.js"); - const db = getDb(); - const limit = parseInt(options.n) || 20; - const rows = db.query( - "SELECT * FROM hook_events ORDER BY timestamp DESC LIMIT ?" - ).all(limit) as any[]; - - if (options.json) { console.log(JSON.stringify(rows, null, 2)); return; } - if (rows.length === 0) { console.log(chalk.dim("No events yet.")); return; } - - console.log(chalk.bold(`\n Last ${rows.length} events\n`)); - for (const row of rows) { - const ts = row.timestamp.slice(0, 19).replace("T", " "); - const err = row.error ? chalk.red(` ✗ ${truncateText(row.error, 60)}`) : ""; - const tool = row.tool_name ? chalk.dim(` [${row.tool_name}]`) : ""; - console.log(` ${chalk.dim(ts)} ${chalk.cyan(row.hook_name.padEnd(14))}${tool}${err}`); - } - console.log(chalk.dim("\n Compact rows shown. Use --json for full event records or -n to change row count.")); - }); - -logCmd - .command("errors") - .description("Show hook events that contain errors") - .option("--since ", "Only show errors since this duration (e.g. 1h, 30m, 7d)", "24h") - .option("-n, --limit ", "Number of rows to show", "50") - .option("-j, --json", "Output as JSON", false) - .action(async (options: { since: string; limit: string; json: boolean }) => { - const { getDb } = await import("../db/index.js"); - const db = getDb(); - const limit = parseInt(options.limit) || 50; - - // Parse duration string to milliseconds - function parseDuration(s: string): number { - const m = s.match(/^(\d+)(s|m|h|d)$/); - if (!m) return 24 * 60 * 60 * 1000; - const n = parseInt(m[1]); - switch (m[2]) { - case "s": return n * 1000; - case "m": return n * 60 * 1000; - case "h": return n * 60 * 60 * 1000; - case "d": return n * 24 * 60 * 60 * 1000; - default: return 24 * 60 * 60 * 1000; - } - } - - const since = new Date(Date.now() - parseDuration(options.since)).toISOString(); - const rows = db.query( - "SELECT * FROM hook_events WHERE error IS NOT NULL AND timestamp >= ? ORDER BY timestamp DESC LIMIT ?" - ).all(since, limit) as any[]; - - if (options.json) { console.log(JSON.stringify(rows, null, 2)); return; } - if (rows.length === 0) { console.log(chalk.dim(`No errors in the last ${options.since}.`)); return; } - - console.log(chalk.bold(`\n Errors (last ${options.since}, ${rows.length} found)\n`)); - for (const row of rows) { - const ts = row.timestamp.slice(0, 19).replace("T", " "); - console.log(` ${chalk.dim(ts)} ${chalk.cyan(row.hook_name.padEnd(14))} ${chalk.red(truncateText(row.error, 100))}`); - } - console.log(chalk.dim("\n Compact rows shown. Use --json for full event records or --limit to change row count.")); - }); - -logCmd - .command("clear") - .description("Delete hook event logs") - .option("--hook ", "Only delete events for this hook") - .option("-y, --yes", "Skip confirmation prompt", false) - .action(async (options: { hook?: string; yes: boolean }) => { - const { getDb } = await import("../db/index.js"); - const db = getDb(); - - const countRow = options.hook - ? db.query("SELECT COUNT(*) as n FROM hook_events WHERE hook_name = ?").get(options.hook) as any - : db.query("SELECT COUNT(*) as n FROM hook_events").get() as any; - const count = countRow?.n ?? 0; - - if (count === 0) { console.log(chalk.dim("Nothing to clear.")); return; } - - if (!options.yes) { - const scope = options.hook ? `hook "${options.hook}"` : "all hooks"; - console.log(chalk.yellow(`About to delete ${count} event(s) for ${scope}.`)); - console.log(chalk.dim("Re-run with --yes to confirm.")); - return; - } - - if (options.hook) { - db.run("DELETE FROM hook_events WHERE hook_name = ?", [options.hook]); - } else { - db.run("DELETE FROM hook_events"); - } - - console.log(chalk.green(`✓ Cleared ${count} event(s).`)); - }); - -const storageCmd = program - .command("storage") - .description("Sync local hook data with storage PostgreSQL"); - -storageCmd - .command("status") - .description("Show storage sync status") - .option("-j, --json", "Output as JSON", false) - .action(async (options: { json: boolean }) => { - const { getStorageStatus } = await import("../storage.js"); - const status = getStorageStatus(); - if (options.json) { - console.log(JSON.stringify(status, null, 2)); - return; - } - console.log(chalk.bold("\n Storage Status\n")); - console.log(` Configured: ${status.configured ? chalk.green(`yes (${status.activeEnv})`) : chalk.red("no")}`); - console.log(` Mode: ${status.mode}`); - console.log(` Tables: ${status.tables.join(", ")}`); - console.log(` Sync rows: ${status.sync.length}`); - }); - -storageCmd - .command("push") - .description("Push local hook data to storage PostgreSQL") - .option("-t, --tables ", "Comma-separated table names") - .option("-j, --json", "Output as JSON", false) - .action(async (options: { tables?: string; json: boolean }) => { - try { - const { parseStorageTables, storagePush } = await import("../storage.js"); - const results = await storagePush({ tables: parseStorageTables(options.tables) }); - if (options.json) { - console.log(JSON.stringify(results, null, 2)); - return; - } - const written = results.reduce((sum, result) => sum + result.rowsWritten, 0); - console.log(chalk.green(`✓ Pushed ${written} row(s)`)); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (options.json) console.log(JSON.stringify({ error: message })); - else console.error(chalk.red(`✗ ${message}`)); - process.exitCode = 1; - } - }); - -storageCmd - .command("pull") - .description("Pull hook data from storage PostgreSQL to local SQLite") - .option("-t, --tables ", "Comma-separated table names") - .option("-j, --json", "Output as JSON", false) - .action(async (options: { tables?: string; json: boolean }) => { - try { - const { parseStorageTables, storagePull } = await import("../storage.js"); - const results = await storagePull({ tables: parseStorageTables(options.tables) }); - if (options.json) { - console.log(JSON.stringify(results, null, 2)); - return; - } - const written = results.reduce((sum, result) => sum + result.rowsWritten, 0); - console.log(chalk.green(`✓ Pulled ${written} row(s)`)); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (options.json) console.log(JSON.stringify({ error: message })); - else console.error(chalk.red(`✗ ${message}`)); - process.exitCode = 1; - } - }); - -storageCmd - .command("sync") - .description("Bidirectional storage sync: pull then push") - .option("-t, --tables ", "Comma-separated table names") - .option("-j, --json", "Output as JSON", false) - .action(async (options: { tables?: string; json: boolean }) => { - try { - const { parseStorageTables, storageSync } = await import("../storage.js"); - const result = await storageSync({ tables: parseStorageTables(options.tables) }); - if (options.json) { - console.log(JSON.stringify(result, null, 2)); - return; - } - const pulled = result.pull.reduce((sum, entry) => sum + entry.rowsWritten, 0); - const pushed = result.push.reduce((sum, entry) => sum + entry.rowsWritten, 0); - console.log(chalk.green(`✓ Synced ${pulled} pulled row(s), ${pushed} pushed row(s)`)); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (options.json) console.log(JSON.stringify({ error: message })); - else console.error(chalk.red(`✗ ${message}`)); - process.exitCode = 1; - } - }); - -// MCP server command -program - .command("mcp") - .option("-s, --stdio", "Use stdio transport (one process per agent)", false) - .option("--sse", "Use legacy SSE transport (port 39427)", false) - .option("--http", "Use Streamable HTTP transport (explicit; this is also the default)", false) - .option("-p, --port ", "Port for HTTP/SSE transport (defaults to 8847 for HTTP, 39427 for SSE)") - .description("Start MCP server for AI agent integration (default: shared Streamable HTTP)") - .action(async (options: { stdio: boolean; sse: boolean; http: boolean; port?: string }) => { - if (options.stdio) { - const { startStdioServer } = await import("../mcp/server.js"); - await startStdioServer(); - } else if (options.sse) { - const { startSSEServer } = await import("../mcp/server.js"); - await startSSEServer(options.port ? parseInt(options.port) : 39427); - } else { - // Default: shared Streamable HTTP server (one process per MCP, many agents). - const { createHooksServer } = await import("../mcp/server.js"); - const { resolveMcpHttpPort, startMcpHttpServer } = await import("../mcp/http.js"); - const args = options.port ? ["--port", options.port] : []; - startMcpHttpServer({ name: "hooks", port: resolveMcpHttpPort(args), buildServer: createHooksServer }); - } - }); +registerCoreCommands(program); +registerDocsCommands(program, pkg.version); +registerLogCommands(program); +registerStorageCommands(program); +registerMcpCommand(program); registerEventsCommands(program, { source: "hooks" }); program.parse(); From 8845c1a8a67190eea89b877e45e3acda189329bc Mon Sep 17 00:00:00 2001 From: Andrei Date: Fri, 31 Jul 2026 19:58:50 +0300 Subject: [PATCH 3/3] fix(cli): repair split command module imports Agent: Augustus --- src/cli/commands/core.tsx | 1 + src/cli/commands/log.ts | 10 +++++----- src/cli/commands/mcp.ts | 9 ++++----- src/cli/commands/storage.ts | 8 ++++---- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/cli/commands/core.tsx b/src/cli/commands/core.tsx index 103a276..ead8189 100644 --- a/src/cli/commands/core.tsx +++ b/src/cli/commands/core.tsx @@ -32,6 +32,7 @@ import { } from "./helpers.js"; export function registerCoreCommands(program: Command): void { + program .command("interactive", { isDefault: true }) .alias("i") .description("Interactive hook browser") diff --git a/src/cli/commands/log.ts b/src/cli/commands/log.ts index e4bee58..4951ff6 100644 --- a/src/cli/commands/log.ts +++ b/src/cli/commands/log.ts @@ -15,7 +15,7 @@ logCmd .option("-n, --limit ", "Number of rows to show", "50") .option("-j, --json", "Output as JSON", false) .action(async (options: { hook?: string; session?: string; limit: string; json: boolean }) => { - const { getDb } = await import("../db/index.js"); + const { getDb } = await import("../../db/index.js"); const db = getDb(); const limit = parseInt(options.limit) || 50; @@ -48,7 +48,7 @@ logCmd .option("-n, --limit ", "Number of rows to show", "50") .option("-j, --json", "Output as JSON", false) .action(async (text: string, options: { limit: string; json: boolean }) => { - const { getDb } = await import("../db/index.js"); + const { getDb } = await import("../../db/index.js"); const db = getDb(); const limit = parseInt(options.limit) || 50; const q = `%${text}%`; @@ -74,7 +74,7 @@ logCmd .option("-n ", "Number of rows", "20") .option("-j, --json", "Output as JSON", false) .action(async (options: { n: string; json: boolean }) => { - const { getDb } = await import("../db/index.js"); + const { getDb } = await import("../../db/index.js"); const db = getDb(); const limit = parseInt(options.n) || 20; const rows = db.query( @@ -101,7 +101,7 @@ logCmd .option("-n, --limit ", "Number of rows to show", "50") .option("-j, --json", "Output as JSON", false) .action(async (options: { since: string; limit: string; json: boolean }) => { - const { getDb } = await import("../db/index.js"); + const { getDb } = await import("../../db/index.js"); const db = getDb(); const limit = parseInt(options.limit) || 50; @@ -141,7 +141,7 @@ logCmd .option("--hook ", "Only delete events for this hook") .option("-y, --yes", "Skip confirmation prompt", false) .action(async (options: { hook?: string; yes: boolean }) => { - const { getDb } = await import("../db/index.js"); + const { getDb } = await import("../../db/index.js"); const db = getDb(); const countRow = options.hook diff --git a/src/cli/commands/mcp.ts b/src/cli/commands/mcp.ts index e8c13ba..48e657d 100644 --- a/src/cli/commands/mcp.ts +++ b/src/cli/commands/mcp.ts @@ -10,19 +10,18 @@ program .description("Start MCP server for AI agent integration (default: shared Streamable HTTP)") .action(async (options: { stdio: boolean; sse: boolean; http: boolean; port?: string }) => { if (options.stdio) { - const { startStdioServer } = await import("../mcp/server.js"); + const { startStdioServer } = await import("../../mcp/server.js"); await startStdioServer(); } else if (options.sse) { - const { startSSEServer } = await import("../mcp/server.js"); + const { startSSEServer } = await import("../../mcp/server.js"); await startSSEServer(options.port ? parseInt(options.port) : 39427); } else { // Default: shared Streamable HTTP server (one process per MCP, many agents). - const { createHooksServer } = await import("../mcp/server.js"); - const { resolveMcpHttpPort, startMcpHttpServer } = await import("../mcp/http.js"); + const { createHooksServer } = await import("../../mcp/server.js"); + const { resolveMcpHttpPort, startMcpHttpServer } = await import("../../mcp/http.js"); const args = options.port ? ["--port", options.port] : []; startMcpHttpServer({ name: "hooks", port: resolveMcpHttpPort(args), buildServer: createHooksServer }); } }); -registerEventsCommands(program, { source: "hooks" }); } diff --git a/src/cli/commands/storage.ts b/src/cli/commands/storage.ts index f0c4381..46446a9 100644 --- a/src/cli/commands/storage.ts +++ b/src/cli/commands/storage.ts @@ -11,7 +11,7 @@ storageCmd .description("Show storage sync status") .option("-j, --json", "Output as JSON", false) .action(async (options: { json: boolean }) => { - const { getStorageStatus } = await import("../storage.js"); + const { getStorageStatus } = await import("../../storage.js"); const status = getStorageStatus(); if (options.json) { console.log(JSON.stringify(status, null, 2)); @@ -31,7 +31,7 @@ storageCmd .option("-j, --json", "Output as JSON", false) .action(async (options: { tables?: string; json: boolean }) => { try { - const { parseStorageTables, storagePush } = await import("../storage.js"); + const { parseStorageTables, storagePush } = await import("../../storage.js"); const results = await storagePush({ tables: parseStorageTables(options.tables) }); if (options.json) { console.log(JSON.stringify(results, null, 2)); @@ -54,7 +54,7 @@ storageCmd .option("-j, --json", "Output as JSON", false) .action(async (options: { tables?: string; json: boolean }) => { try { - const { parseStorageTables, storagePull } = await import("../storage.js"); + const { parseStorageTables, storagePull } = await import("../../storage.js"); const results = await storagePull({ tables: parseStorageTables(options.tables) }); if (options.json) { console.log(JSON.stringify(results, null, 2)); @@ -77,7 +77,7 @@ storageCmd .option("-j, --json", "Output as JSON", false) .action(async (options: { tables?: string; json: boolean }) => { try { - const { parseStorageTables, storageSync } = await import("../storage.js"); + const { parseStorageTables, storageSync } = await import("../../storage.js"); const result = await storageSync({ tables: parseStorageTables(options.tables) }); if (options.json) { console.log(JSON.stringify(result, null, 2));