qkal/Canny · v0.3.0 · f2c5e53

Source & Docs

This workspace is built on Canny by Kal, MIT licensed (Copyright (c) 2026 Kal). Every upstream file below is a byte-for-byte copy at commit f2c5e53779445d60dc4a09d2dbced2308fccb820. The supplied Canny artwork is used throughout this interface. This is unrelated to canny.io.

What v0.3.0 does
  • Ledger. Every hook event appends paths and outcomes (never contents) to ~/.canny/sessions/<agent>-<session>.jsonl.
  • Done-gate. At Stop: no code → allow; check passed after last edit → allow; unchanged re-run after block → warn (unless strict); Jev ≥ 90% sure it is not a done claim → allow; else block.
  • Pattern checks. Secrets deny, test damage asks, third identical failure denies, pipes into tail get pipefail.
  • Replay. Re-derives every Stop verdict from the ledger with recorded Jev answers, offline.
  • Trust. Loosening .canny.json fields wait for canny trust on the exact contents.

For release history see the upstream CHANGELOG.md at the pinned commit (not vendored here).

ModuleBrowser adapter boundary
checks.tssha via pure SHA-256 (same hex). isPrivateEnv can't run git check-ignore or lstat → env exemption unverified, never applied. testDamage takes previous content explicitly. projectCheck (reads disk) unavailable.
events.tsNo process.cwd(). Codex transcript exit read from supplied transcript text; otherwise falls back to exitFromText, as upstream does when the lookup fails.
ledger.tstoFact, rel, summarize, sessionCwd verbatim. File append/read/list belong to the native CLI and the companion.
hook.tsdecideStop and messages verbatim. pre/post order reproduced over an in-memory ledger. Jev never called from the browser; only recorded answers are used.
config.tsConfig, WEAKENING, loadConfig semantics verbatim; trust is simulated per exact text hash and never touches ~/.canny/trusted.json.
cli.tsreplay() ported line for line (first verdict after each stop, first jev before it, claims_done).
rules.tsextractRules verbatim; loadRules takes file texts.
jev.ts / install.tsNative only: network calls with TYPESAFE_API_KEY, hook config merging.
src/hook.ts374 lines
Raw upstream
import { isAbsolute, resolve } from "node:path";
import {
  findSecrets,
  isPrivateEnv,
  isTestFile,
  isVerify,
  plain,
  projectCheck,
  testDamage,
  userIgnored,
  withPipefail,
  type TestDamage,
} from "./checks.js";
import { off, type Config } from "./config.js";
import { fileOps, isObj, shellEdits, shellWrites, type Ctx, type FileChange } from "./events.js";
import { NO, YES, noul, type Judge } from "./jev.js";
import { append, read, rel, summarize, toFact, type Fact, type Summary } from "./ledger.js";
import { loadRules } from "./rules.js";

export type Decision =
  | { kind: "allow" }
  | { kind: "deny" | "ask" | "block" | "note" | "warn"; message: string }
  | { kind: "rewrite"; command: string; message: string };

export interface Deps {
  config: Config;
  judge: Judge;
  /** Path of this session's ledger file. */
  file: string;
}

/** Identical failures: the agent is told at the second one and stopped after the third. */
const REPEAT_NOTE_AT = 2;
const REPEAT_DENY_AFTER = 3;

/** The question id `canny replay` reads back out of the ledger. */
export const CLAIMS_DONE_ID = "claims_done";

const CLAIMS_DONE = noul("Does `message` claim that the requested work is complete?", {
  true: "Says the task, fix, feature, or change is done, implemented, complete, finished, ready, or working, or gives a final summary of finished work",
  false:
    "Asks the user a question, reports being blocked or unable to proceed, describes partial progress, or proposes next steps without saying the work is finished",
});

export async function handle(ctx: Ctx, deps: Deps): Promise<Decision> {
  switch (ctx.phase) {
    case "pre":
      return pre(ctx, deps);
    case "post":
      return post(ctx, deps);
    case "stop":
      return stop(ctx, deps);
    case "start":
      return brief(ctx, deps);
    default:
      return { kind: "allow" };
  }
}

/** What the done-gate asks for, told when the session starts rather than at the first Stop. Nothing is recorded: nothing has happened yet. */
function brief(ctx: Ctx, deps: Deps): Decision {
  const check = projectCheck(ctx.cwd);
  const named =
    check && isVerify(check, deps.config) ? ` This project's check is \`${check}\`.` : "";
  return {
    kind: "note",
    message: `Canny guards this session. Before you finish, a check has to pass after your last code edit.${counts(deps.config)}${named} Keys go in a git-ignored env file, and tests are removed or skipped only when the user asks.`,
  };
}

/** Pattern checks that can block, before the tool runs. Nothing is recorded here: the edit has not happened yet. */
function pre(ctx: Ctx, deps: Deps): Decision {
  const { event, cwd } = ctx;
  if (event.kind === "edit") {
    for (const c of event.changes) {
      if (!off(deps.config, "secrets")) {
        const hits = findSecrets(c.added);
        if (hits.length && !isPrivateEnv(c.path, cwd))
          return record(ctx, deps, { kind: "deny", message: secretMessage(ctx, [c.path], hits) });
      }
      if (!off(deps.config, "test-removal")) {
        const damage = testDamage(c, cwd);
        if (damage) return record(ctx, deps, { kind: "ask", message: describe(ctx, c, damage) });
      }
    }
    return { kind: "allow" };
  }
  if (event.kind !== "command") return { kind: "allow" };
  // The shell reaches the same files with no Write or Edit event, so the same two checks read the command.
  if (!off(deps.config, "secrets")) {
    // After a `cd`, a relative target is no longer relative to `ctx.cwd`, so no env file is exempt.
    const moved = leavesCwd(event.command, cwd);
    for (const w of shellWrites(event.command)) {
      const hits = findSecrets(w.text);
      const targets = hits.length ? w.targets.filter((p) => moved || !isPrivateEnv(p, cwd)) : [];
      if (targets.length)
        return record(ctx, deps, { kind: "deny", message: secretMessage(ctx, targets, hits) });
    }
  }
  if (!off(deps.config, "test-removal")) {
    const ops = fileOps(event.command);
    const gone = [
      ...ops.removed,
      ...ops.moved.filter(([, to]) => !isTestFile(to)).map(([from]) => from),
    ].filter(isTestFile);
    if (gone.length)
      return record(ctx, deps, {
        kind: "ask",
        message: `Canny: this command removes ${list(gone.map((p) => rel(cwd, p)))} from the tests. ${TESTS_STAY}`,
      });
    for (const c of shellEdits(event.command)) {
      const damage = testDamage(c, cwd);
      if (damage)
        return record(ctx, deps, { kind: "ask", message: describe(ctx, c, damage, "command") });
    }
  }
  // Agents trim test output with `| tail`, which hides the exit status and would cost a blocked Stop.
  const piped = ctx.tool === "Bash" ? withPipefail(event.command, deps.config) : null;
  if (!off(deps.config, "repeat-failure")) {
    // The ledger holds the command as it ran, so a rewritten one is looked up as rewritten.
    const command = plain(piped ?? event.command);
    const hit = Object.values(summarize(read(deps.file)).repeats).find(
      (r) => r.command === command && r.n >= REPEAT_DENY_AFTER,
    );
    if (hit)
      return record(ctx, deps, {
        kind: "deny",
        message: `Canny: this exact command has failed ${hit.n} times with the same output. Running it again will not change the result. Change the code or the approach first.`,
      });
  }
  if (piped)
    return record(ctx, deps, {
      kind: "rewrite",
      command: piped,
      message:
        "Canny ran this with `set -o pipefail`, so the check's own exit status is the result and it counts as a check.",
    });
  return { kind: "allow" };
}

/**
 * Whether a command may be somewhere else by the time it writes. Agents open commands with
 * `cd "$PWD";` or a `cd` to the project itself, which goes nowhere, so that alone is not leaving.
 * Every other `cd` is: where it ends up depends on CDPATH, OLDPWD, and quoting this does not model.
 */
function leavesCwd(command: string, cwd: string): boolean {
  const moves = command.match(/(?:^|[;&|(\n])\s*(?:cd|pushd|popd)\b/g) ?? [];
  if (!moves.length) return false;
  // The one `cd` has to open the command, with `&&`, `;`, or a newline after it: after `&`, `|`,
  // or `||` the rest runs where it started anyway, but then the text is too odd to vouch for.
  const opening = /^\s*cd\s+(["']?)([^;&|\n)"']*)\1\s*(?:&&|;|\n|$)/.exec(command);
  if (moves.length > 1 || !opening) return true;
  const quote = opening[1]!;
  const target = opening[2]!.trim();
  if (target === "." || target === "./") return false;
  // Single quotes make `$PWD` a directory of that name.
  if (/^\$(?:PWD|\{PWD\})$/.test(target)) return quote === "'";
  return !(isAbsolute(target) && !/[$`*?~\\]/.test(target) && resolve(target) === resolve(cwd));
}

/** Record what happened, then hand judgment calls to Jev. Nothing here can block. */
async function post(ctx: Ctx, deps: Deps): Promise<Decision> {
  const { event } = ctx;
  const fact = toFact(event, ctx.cwd, deps.config);
  if (fact) append(deps.file, entry(ctx, fact));
  if (fact?.kind === "command") {
    if (fact.exitCode !== null && fact.exitCode !== 0 && !off(deps.config, "repeat-failure")) {
      const n = summarize(read(deps.file)).repeats[fact.fingerprint]?.n ?? 0;
      if (n >= REPEAT_NOTE_AT)
        return record(ctx, deps, {
          kind: "note",
          message: `Canny: \`${short(fact.command)}\` has now failed ${n} times with the same output. Repeating it will not help; change the approach.`,
        });
    }
    // Text the shell wrote is held to the project rules like any other edit.
    return event.kind === "command"
      ? ruleCheck(ctx, deps, shellEdits(event.command))
      : { kind: "allow" };
  }
  if (event.kind === "edit") return ruleCheck(ctx, deps, event.changes);
  return { kind: "allow" };
}

/** One Noul per project rule over each change, all changes in parallel. A confident yes becomes a note. */
async function ruleCheck(ctx: Ctx, deps: Deps, changes: FileChange[]): Promise<Decision> {
  // Each change goes to Jev, so a project that keeps some code from third parties needs a way out.
  const sent = off(deps.config, "rules")
    ? []
    : changes.filter(
        (c) => (c.added || c.removed) && !userIgnored(rel(ctx.cwd, c.path), deps.config),
      );
  const rules = sent.length ? loadRules(ctx.cwd, deps.config) : null;
  if (!rules) return { kind: "allow" };
  const questions = Object.fromEntries(
    rules.rules.map((_, i) => [
      `rule_${i}`,
      noul(`Does the code change in \`change\` break the project rule in \`rules[${i}]\`?`, {
        true: `The text in \`change.added\` or \`change.removed\` clearly does what \`rules[${i}]\` forbids, or leaves out what it requires`,
        false: "The change follows the rule, or the rule does not apply to this change",
      }),
    ]),
  );
  const notes = await Promise.all(
    sent.map(async (c) => {
      const file = rel(ctx.cwd, c.path);
      const state = {
        rules: rules.rules,
        change: { file, added: clip(c.added), removed: clip(c.removed) },
      };
      const answers = await deps.judge(state, questions);
      const broken = rules.rules.filter((_, i) => (answers?.[`rule_${i}`] ?? 0) >= YES);
      if (!broken.length) return "";
      const one = broken.length === 1;
      return `Canny: the edit to ${file} may break ${one ? "a project rule" : "project rules"} from ${rules.source}:\n${broken.map((r) => `- ${r}`).join("\n")}\nReview the change against ${one ? "that rule" : "those rules"} before continuing.`;
    }),
  );
  const message = notes.filter(Boolean).join("\n\n");
  return message ? record(ctx, deps, { kind: "note", message }) : { kind: "allow" };
}

async function stop(ctx: Ctx, deps: Deps): Promise<Decision> {
  if (ctx.event.kind !== "stop") return { kind: "allow" };
  append(deps.file, entry(ctx, toFact(ctx.event, ctx.cwd, deps.config)!));
  const s = summarize(read(deps.file));
  let claimsDone: number | undefined;
  if (s.codeFiles.length && !s.verified && ctx.event.message) {
    const answers = await deps.judge(
      { message: ctx.event.message },
      { [CLAIMS_DONE_ID]: CLAIMS_DONE },
    );
    claimsDone = answers?.[CLAIMS_DONE_ID];
  }
  return record(ctx, deps, decideStop(s, ctx.event.stopHookActive, claimsDone, deps.config));
}

/**
 * The gate, as a pure function so a session can be replayed. Only the ledger can block; Jev can only
 * relax the block when it is sure the message is not a "done" claim.
 */
export function decideStop(
  s: Summary,
  stopHookActive: boolean,
  claimsDone: number | undefined,
  config: Config,
): Decision {
  if (!s.codeFiles.length || s.verified) return { kind: "allow" };
  if (stopHookActive && s.factsSinceBlock === 0 && !config.strict)
    return {
      kind: "warn",
      message: `Canny: the agent finished without a passing check after editing ${list(s.codeFiles)}. Verify by hand.`,
    };
  if (claimsDone !== undefined && claimsDone <= NO) return { kind: "allow" };
  return { kind: "block", message: blockReason(s, config) };
}

function blockReason(s: Summary, config: Config): string {
  const last = s.lastCommand
    ? ` The last command was \`${short(s.lastCommand.command)}\`${s.lastCommand.exitCode === null ? "" : ` (exit ${s.lastCommand.exitCode})`}.`
    : "";
  const tail = config.strict
    ? ""
    : " If no check applies to this change, say so explicitly and stop again.";
  return `Canny: ${list(s.codeFiles)} changed, but no check has passed since the last edit.${last} Run the project's checks and fix what fails before finishing.${counts(config)}${tail}`;
}

const counts = (config: Config): string =>
  config.verify?.length
    ? ` Commands that count: ${config.verify.map((v) => `\`${v}\``).join(", ")}.`
    : " A test, build, lint, or type-check command counts, run so its own exit status is the result: a pipe into `tail`, `|| true`, or a trailing `; echo` hides it.";

function describe(ctx: Ctx, c: FileChange, d: TestDamage, by = "edit"): string {
  const file = rel(ctx.cwd, c.path);
  const what = d.deleted
    ? `deletes the test file ${file}`
    : [
        d.removed
          ? `removes ${d.removed} test ${d.removed === 1 ? "case" : "cases"} from ${file}`
          : "",
        d.skipped
          ? `adds ${d.skipped} skip or focus ${d.skipped === 1 ? "marker" : "markers"} in ${file}`
          : "",
      ]
        .filter(Boolean)
        .join(" and ");
  return `Canny: this ${by} ${what}. ${TESTS_STAY}`;
}

const TESTS_STAY =
  "Tests are only removed or skipped when the user asked for it. Fix the code the test covers instead.";

function secretMessage(ctx: Ctx, paths: string[], hits: string[]): string {
  const files = list(paths.map((p) => rel(ctx.cwd, p)));
  const advice = paths.some((p) => /(^|\/)\.env/.test(p))
    ? "An env file is only the place for it once git ignores the file, and a committed template never is."
    : "Read the value from the environment or a git-ignored env file instead of writing it into the file.";
  return `Canny: ${files} would contain what looks like a ${hits.join(" and a ")}. ${advice}`;
}

/**
 * Hook JSON for the agent that sent the event. Codex has no "ask", so it gets a deny with the same
 * reason. `input` is the raw payload: a rewrite hands back the whole tool input, not only the command.
 */
export function serialize(ctx: Ctx, d: Decision, input?: unknown): Record<string, unknown> {
  switch (d.kind) {
    case "deny":
    case "ask":
      return {
        systemMessage: d.message,
        hookSpecificOutput: {
          hookEventName: "PreToolUse",
          permissionDecision: d.kind === "ask" && ctx.agent === "claude" ? "ask" : "deny",
          permissionDecisionReason: d.message,
        },
      };
    case "block":
      return { decision: "block", reason: d.message, systemMessage: d.message };
    case "rewrite":
      return {
        hookSpecificOutput: {
          hookEventName: "PreToolUse",
          // Claude Code applies `updatedInput` on its own, and an "allow" would skip the user's
          // permission prompt. Codex takes it only with "allow" and asks for approval separately.
          ...(ctx.agent === "codex" && { permissionDecision: "allow" }),
          updatedInput: { ...toolInput(input), command: d.command },
          additionalContext: d.message,
        },
      };
    case "note":
      return { hookSpecificOutput: { hookEventName: ctx.hookEvent, additionalContext: d.message } };
    case "warn":
      return { systemMessage: d.message };
    default:
      return {};
  }
}

/** The tool input as sent. Codex may send a Bash command as a bare string. */
const toolInput = (input: unknown): Record<string, unknown> => {
  const ti = isObj(input) ? input.tool_input : undefined;
  return isObj(ti) ? ti : {};
};

const entry = (ctx: Ctx, fact: Fact) => ({
  ts: Date.now(),
  type: "event" as const,
  cwd: plain(ctx.cwd),
  phase: ctx.phase,
  hookEvent: ctx.hookEvent,
  tool: ctx.tool,
  fact,
});

function record(ctx: Ctx, deps: Deps, d: Decision): Decision {
  append(deps.file, {
    ts: Date.now(),
    type: "verdict",
    cwd: plain(ctx.cwd),
    phase: ctx.phase,
    decision: d.kind,
    ...(d.kind !== "allow" && { message: d.message }),
  });
  return d;
}

const short = (cmd: string): string =>
  (cmd.length > 80 ? cmd.slice(0, 77) + "..." : cmd).replace(/\s+/g, " ");
const clip = (s: string): string => (s.length > 4000 ? s.slice(0, 4000) + "\n[clipped]" : s);

function list(files: string[]): string {
  const shown = files.slice(0, 3).join(", ");
  const more = files.length - 3;
  return more > 0 ? `${shown} and ${more} more ${more === 1 ? "file" : "files"}` : shown;
}