diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dfdbca..3b9962b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ Notable changes to `@wirecat/cli-testing`, one section per version, newest first Every entry says what changed as a caller sees it, why, and what to watch for — the rules are [`docs/dev/CONVENTIONS.md`](docs/dev/CONVENTIONS.md#the-changelog). +## Unreleased + +### Fixed + +- **`deny-keys` no longer counts sign-in and sign-out as writes that need a permission key.** A read-only CLI + whose only remote writes were `session start` and `account remove` failed with "no command that writes to the + service names a permission key", though those commands have no key on purpose (cli-base WC-PERM-5). cli-core's + `logsInOrOut` mark is not in `commands --json`, so the check leaves out the paths every CLI puts it on: + `session start`, `session end`, `account remove` and `setup`. A sign-in command elsewhere still counts as a + write. A CLI with any other remote write still fails when none of its writes names a key. gmeet-cli's contracts + pass with this change. + ## 0.7.1 — 13.11.2026 ### Fixed diff --git a/src/contract-deny.test.ts b/src/contract-deny.test.ts index a339673..c7a0240 100644 --- a/src/contract-deny.test.ts +++ b/src/contract-deny.test.ts @@ -176,6 +176,37 @@ describe("deny contracts", () => { expect(readsOnly.results.find((item) => item.id === "deny-keys")?.failure).toContain("no command that writes") }) + it("passes a read-only CLI whose only writes sign in or out, and fails one with a write that names no key", async () => { + const signIn = [ + { path: ["session", "start"], effects: { remote: "write" }, lifetime: "interactive", mcp: false }, + { path: ["account", "remove"], effects: { remote: "write" }, mcp: false }, + { path: ["setup"], effects: { remote: "write" }, lifetime: "interactive", mcp: false }, + ] + const list = { path: ["meetings", "list"], effects: { remote: "read" } } + const create = { path: ["meetings", "create"], effects: { remote: "write" } } + const answering = (commands: unknown[]) => ({ + run: (invocation: { args: string[] }) => + invocation.args.includes("commands") + ? { exit: 0, stdout: JSON.stringify({ commands }), stderr: "" } + : invocation.args.includes("config") + ? { exit: 0, stdout: '{"item":{"config_file":"config.json"}}', stderr: "" } + : invocation.args.includes("--dry-run") + ? { + exit: 0, + stdout: JSON.stringify({ + item: { permission: { key: invocation.args.includes("list") ? "meetings.list" : null } }, + }), + stderr: "", + } + : { exit: 5, stdout: "", stderr: '{"error":{"code":"permission_error"}}' }, + toolSession: async () => session(() => refused), + }) + const readOnly = await runContracts(plan(fakeCli(true)), answering([...signIn, list])) + expect(readOnly.results.find((item) => item.id === "deny-keys")).toEqual({ id: "deny-keys", passed: true }) + const unkeyed = await runContracts(plan(fakeCli(true)), answering([...signIn, list, create])) + expect(unkeyed.results.find((item) => item.id === "deny-keys")?.failure).toContain("no command that writes") + }) + it("fails a refusal that is not permission_error, or that prints on stdout", async () => { const refusing = (stdout: string, stderr: string) => ({ run: (invocation: { args: string[] }) => diff --git a/src/contract-deny.ts b/src/contract-deny.ts index dcfcd69..0b26e02 100644 --- a/src/contract-deny.ts +++ b/src/contract-deny.ts @@ -70,6 +70,10 @@ const remoteLeaves = (nodes: Node[]): Node[] => : [], ) +// cli-core's `logsInOrOut` mark never reaches `commands --json`; every CLI puts it on these paths (WC-PERM-5). +const SIGN_IN_OR_OUT = new Set(["session start", "session end", "account remove", "setup"]) +const signsInOrOut = (path: string[] = []): boolean => SIGN_IN_OR_OUT.has(path.join(" ")) + /** The words after the command's path in its first example, or a placeholder for each required one. */ export const argsFor = (node: Node): string[] => { const path = node.path ?? [] @@ -165,7 +169,8 @@ export const runDenyContracts = async ( }) if (!keyed.some((command) => !command.write)) throw new Error("no command that reads the service names a permission key in its --dry-run") - if (leaves.some((node) => node.effects?.remote === "write") && !keyed.some((command) => command.write)) + const writes = leaves.some((node) => node.effects?.remote === "write" && !signsInOrOut(node.path)) + if (writes && !keyed.some((command) => command.write && !signsInOrOut(command.path))) throw new Error("no command that writes to the service names a permission key in its --dry-run") }) for (const command of keyed)