Files
tobi__qmd/test/update-hook-trust-cli.test.ts
Chirag Honnyal f8352d7379 fix(update): don't run a checked-in .qmd config's update commands unattended (#886) (#887)
`findLocalConfigPath` adopts a project-local `.qmd/index.yml` for any
command run anywhere inside the tree, and `updateCollections` runs each
collection's `update:` field through `bash -c`. Together that means
cloning a repository and running `qmd update` executes shell commands
chosen by whoever wrote the repo — no prompt, and the "Running update
command: ..." line is printed as the process spawns, so the user reads
it after the payload has run.

Gate hooks that come from a project-local config behind an approval,
using the model direnv and `git safe.directory` converged on:

- On a TTY, list the commands and ask. Approving records the config
  path and a digest of the command set in `<config dir>/trusted.json`.
- Non-interactive (agents, CI, MCP): skip the hooks and keep indexing.
  Indexing is what the caller asked for, and failing the whole command
  would only push people toward a blanket opt-out.
- The digest is order-independent but covers every command, so editing
  a hook — or a `git pull` that rewrites one — asks again.
- `qmd trust` / `trust list` / `trust revoke` manage approvals, and
  `qmd collection update-cmd` records trust as it writes, since the user
  just typed that command.
- `QMD_TRUST_UPDATE_HOOKS=1` opts unattended runs back in.

Config in the user's own config dir is unaffected — those hooks run
exactly as before.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 14:35:39 -04:00

138 lines
5.0 KiB
TypeScript

/**
* End-to-end trust gate for project-local `update:` hooks (#886).
*
* Spawns real `qmd update` processes against a fixture that plays the part of a
* freshly cloned repository shipping its own `.qmd/index.yml`.
*/
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { spawn } from "node:child_process";
const thisDir = dirname(fileURLToPath(import.meta.url));
const projectRoot = join(thisDir, "..");
const qmdScript = join(projectRoot, "src", "cli", "qmd.ts");
const isBunRuntime = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined";
const tsxCli = join(projectRoot, "node_modules", "tsx", "dist", "cli.mjs");
const runnerArgs = isBunRuntime ? [qmdScript] : [tsxCli, qmdScript];
let projectDir: string;
let configDir: string;
/** Written by the hook if — and only if — it was allowed to run. */
let markerPath: string;
function runQmd(
args: string[],
env: Record<string, string> = {},
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
return new Promise((resolve, reject) => {
const proc = spawn(process.execPath, [...runnerArgs, ...args], {
cwd: projectDir,
env: {
...process.env,
QMD_CONFIG_DIR: configDir,
PWD: projectDir,
QMD_DOCTOR_DEVICE_PROBE: "0",
...env,
},
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
proc.stdout.on("data", (d: Buffer) => { stdout += d.toString(); });
proc.stderr.on("data", (d: Buffer) => { stderr += d.toString(); });
proc.on("error", reject);
proc.on("close", (code) => resolve({ stdout, stderr, exitCode: code ?? 1 }));
});
}
function writeLocalConfig(command: string): void {
writeFileSync(
join(projectDir, ".qmd", "index.yml"),
[
"collections:",
" docs:",
" path: ./docs",
' pattern: "**/*.md"',
` update: ${JSON.stringify(command)}`,
"",
].join("\n"),
"utf-8",
);
}
beforeEach(() => {
projectDir = mkdtempSync(join(tmpdir(), "qmd-hostile-repo-"));
configDir = mkdtempSync(join(tmpdir(), "qmd-hostile-cfg-"));
mkdirSync(join(projectDir, ".qmd"), { recursive: true });
mkdirSync(join(projectDir, "docs"), { recursive: true });
writeFileSync(join(projectDir, "docs", "readme.md"), "# Readme\n\nSome indexable content.\n", "utf-8");
// Hooks run with the collection directory as cwd, so a relative redirect
// keeps the fixture free of path-quoting concerns.
markerPath = join(projectDir, "docs", "hook-ran.txt");
writeLocalConfig("echo ran > hook-ran.txt");
});
afterEach(() => {
for (const dir of [projectDir, configDir]) {
// Best effort: Windows holds the SQLite WAL handles briefly after exit.
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
}
});
describe("qmd update with a checked-in .qmd config", () => {
test("does not run the repo's update command unattended, but still indexes", async () => {
const result = await runQmd(["update"]);
expect(existsSync(markerPath)).toBe(false);
expect(result.stdout).toContain("defines update commands");
expect(result.stdout).toContain("qmd trust");
// The caller asked for an index refresh; they get one.
expect(result.stdout).toContain("Indexed: 1 new");
expect(result.exitCode).toBe(0);
}, 120_000);
test("runs it after `qmd trust`", async () => {
const trust = await runQmd(["trust"]);
expect(trust.stdout).toContain("Trusted");
const result = await runQmd(["update"]);
expect(result.stdout).toContain("Running update command");
expect(existsSync(markerPath)).toBe(true);
}, 120_000);
test("re-arms the gate when the command changes after approval", async () => {
await runQmd(["trust"]);
// Stand-in for a `git pull` that rewrites the hook under an approval.
writeLocalConfig("echo pwned > hook-ran.txt && echo extra");
const result = await runQmd(["update"]);
expect(existsSync(markerPath)).toBe(false);
expect(result.stdout).toContain("defines update commands");
}, 120_000);
test("`qmd trust revoke` puts the gate back", async () => {
await runQmd(["trust"]);
const revoke = await runQmd(["trust", "revoke"]);
expect(revoke.stdout).toContain("Revoked trust");
const result = await runQmd(["update"]);
expect(existsSync(markerPath)).toBe(false);
}, 120_000);
test("QMD_TRUST_UPDATE_HOOKS=1 opts unattended runs back in", async () => {
const result = await runQmd(["update"], { QMD_TRUST_UPDATE_HOOKS: "1" });
expect(result.stdout).toContain("Running update command");
expect(existsSync(markerPath)).toBe(true);
}, 120_000);
test("`qmd trust list` reports the approved config", async () => {
await runQmd(["trust"]);
const list = await runQmd(["trust", "list"]);
expect(list.stdout).toContain(join(projectDir, ".qmd", "index.yml"));
}, 120_000);
});