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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions src/contract-deny.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] }) =>
Expand Down
7 changes: 6 additions & 1 deletion src/contract-deny.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? []
Expand Down Expand Up @@ -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)
Expand Down
Loading