diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 731f0981d..f2b50e471 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -321,7 +321,7 @@ Provider and model configuration lives in JSON settings files. The global file h - Per-repo: `.corbits/settings.json` — **selection only**, e.g. `{ "provider": "firepass", "model": "fp-small" }`. Any other key (notably `apiKey` or `baseURL`) is rejected by the loader, and the file is gitignored. It is also on the secret-guard denylist for path-keyed tools, as is the global file, so the agent cannot `read_file` its own credentials (shell references still require explicit operator approval). The project grant store (`.corbits/permissions.json`) is on the same denylist so the agent cannot write a standing auto-approval. An unconfirmed project file still contributes no grants. - `corbits exec --permissions ` replaces the user-global and provider-model grants for that process only. The file is `{ "approvals": [ { "tool": "run_shell", "pattern": "bun install*" } ] }` (`providerModel` optional). It is not merged with `~/.corbits/permissions.json` and is not a settings key. A missing `approvals` key is an empty replacement; a `providerModels` map in that file is not applied. Session grants stay in memory and project grants still use the project permissions file. While the flag is set, global and provider-model Allow Always append to that file's `approvals` array, and `/permissions` revoke removes the row from it. Neither path reads or writes `~/.corbits/permissions.json`. A missing, unreadable, or invalid file, or a wildcard-only pattern, fails at start. + `corbits exec --permissions ` replaces the user-global and provider-model grants for that process only. The file is `{ "approvals": [ { "tool": "run_shell", "pattern": "bun install*" } ] }` (`providerModel` optional). It is not merged with `~/.corbits/permissions.json` and is not a settings key. A missing `approvals` key is an empty replacement; a `providerModels` map in that file is not applied. Session grants stay in memory and project grants still use the project permissions file. While the flag is set, global and provider-model Allow Always append to that file's `approvals` array, and `/permissions` revoke removes the row from it. Neither path reads or writes `~/.corbits/permissions.json`. A missing, unreadable, or invalid file, or a wildcard-only pattern, fails at start. Every load (startup validation, seed, `/permissions` reseed) logs the canonical realpath target and approval count. `baseURL` is editable provider metadata, but it still belongs in the global provider definition rather than the per-repo selection file. `apiKey` is secret and must never be projected into TUI display-only provider lists. @@ -425,27 +425,27 @@ This release changes no settings, OAuth-store, or session persistence format and Printed by `corbits --help` / `-h` from `CLI_HELP_TEXT` in `src/config/index.ts` (that constant is the source of truth; keep this table in sync when flags change). -| Verb / Flag | Default | Description | -| ------------------------------------------ | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| _(no verb)_ | — | Interactive session; optional trailing task text | -| `exec` / `run` / `-p` | — | Run a prompt (non-interactive / one-shot). `-p` is the same path as `exec` and may appear in any flag position. | -| `resume` / `continue` | — | Open the session picker for this folder (project-keyed to this checkout's git toplevel). Lists the 10 most recently persisted sessions, completed included. Type to filter. | -| `--resume []` | — | Interactive: open the session picker, or reopen a specific session when an id is given. With `exec` / `-p` the id is required, that session is continued, and the new prompt is sent (no picker). | -| `resume ` | — | Reopen a specific session in the TUI (does not auto-send a prompt) | -| `exec --resume ` | — | Headless: load that session and send ``, then exit. Missing or unreadable ids error and do not create a session. `--resume` without an id errors. | -| `-p --resume ` | — | Same headless continue path as `exec --resume` | -| `resume --pick` / `--list` | — | Interactive session picker | -| `--cwd ` | `process.cwd()` | Working directory | -| `--config ` | `~/.corbits/settings.json` | Active settings file for provider definitions and TUI persistence such as `/yolo`; composes with (does not exclude) home-level codex/xai OAuth credentials | -| `--permissions ` | — | Process-only grant file. Its `approvals` replace user-global and provider-model grants for this process and are not merged with `~/.corbits/permissions.json`. Missing, unreadable, invalid JSON, or a wildcard-only pattern fails at start. | -| `--provider ` | from settings | Select a configured provider | -| `--model ` | provider default | Select a model for the active provider | -| `--profile ` | — | Settings profile | -| `--dangerously-skip-permissions`, `--yolo` | false | Process-only aliases that auto-allow anything not denied by the authorization layer (gate + pre-gate workspace sandboxes; secret-guard / authz hard denies remain). `/yolo [on\|off\|toggle]` persists to the active settings file instead. | -| `--auto` | true (default) | Force auto mode on (workspace writes + unconstrained shell without prompts) | -| `--no-auto` | false | Start with auto mode off (ask on every consequential action); no in-session key toggles it | -| `--help`, `-h` | — | Show help (exit 0 via `CliHelpError`) | -| `--version`, `-V` | — | Print `corbits v` plus the running `package.json` version (exit 0 via `CliVersionError`) | +| Verb / Flag | Default | Description | +| ------------------------------------------ | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| _(no verb)_ | — | Interactive session; optional trailing task text | +| `exec` / `run` / `-p` | — | Run a prompt (non-interactive / one-shot). `-p` is the same path as `exec` and may appear in any flag position. | +| `resume` / `continue` | — | Open the session picker for this folder (project-keyed to this checkout's git toplevel). Lists the 10 most recently persisted sessions, completed included. Type to filter. | +| `--resume []` | — | Interactive: open the session picker, or reopen a specific session when an id is given. With `exec` / `-p` the id is required, that session is continued, and the new prompt is sent (no picker). | +| `resume ` | — | Reopen a specific session in the TUI (does not auto-send a prompt) | +| `exec --resume ` | — | Headless: load that session and send ``, then exit. Missing or unreadable ids error and do not create a session. `--resume` without an id errors. | +| `-p --resume ` | — | Same headless continue path as `exec --resume` | +| `resume --pick` / `--list` | — | Interactive session picker | +| `--cwd ` | `process.cwd()` | Working directory | +| `--config ` | `~/.corbits/settings.json` | Active settings file for provider definitions and TUI persistence such as `/yolo`; composes with (does not exclude) home-level codex/xai OAuth credentials | +| `--permissions ` | — | Process-only grant file. Its `approvals` replace user-global and provider-model grants for this process and are not merged with `~/.corbits/permissions.json`. Session and project grants still apply; project entries still need trust confirmation. Missing, unreadable, invalid JSON, or a wildcard-only pattern fails at start. | +| `--provider ` | from settings | Select a configured provider | +| `--model ` | provider default | Select a model for the active provider | +| `--profile ` | — | Settings profile | +| `--dangerously-skip-permissions`, `--yolo` | false | Process-only aliases that auto-allow anything not denied by the authorization layer (gate + pre-gate workspace sandboxes; secret-guard / authz hard denies remain). `/yolo [on\|off\|toggle]` persists to the active settings file instead. | +| `--auto` | true (default) | Force auto mode on (workspace writes + unconstrained shell without prompts) | +| `--no-auto` | false | Start with auto mode off (ask on every consequential action); no in-session key toggles it | +| `--help`, `-h` | — | Show help (exit 0 via `CliHelpError`) | +| `--version`, `-V` | — | Print `corbits v` plus the running `package.json` version (exit 0 via `CliVersionError`) | Positional arguments after flags are joined into the optional initial task delivered when the TUI mounts. With no positional task, the operator starts from an empty prompt. diff --git a/src/config/index.ts b/src/config/index.ts index 8c49a0183..9d8f60ce8 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -622,7 +622,8 @@ export interface Config { /** * Absolute path from `--permissions`. When set, that file's approvals * replace the user-global and provider-model grant lists for this process - * only. Not a settings key. + * only. Session and project layers still apply, and project-file entries + * still need trust confirmation. Not a settings key. */ permissionsPath?: string; globalDefaultProvider?: string; @@ -733,7 +734,9 @@ Continue verbs (project-keyed to this checkout's git toplevel): Flags: --cwd working directory (default: process.cwd()) --config settings file (default: ~/.corbits/settings.json) - --permissions grant file replacing ~/.corbits/permissions.json for this process + --permissions grant file replacing global + provider-model grants + for this process only (session + project layers still + apply; project entries still need trust confirmation) --provider configured provider name --model model for the active provider --profile settings profile diff --git a/src/permission/permissions-file.test.ts b/src/permission/permissions-file.test.ts new file mode 100644 index 000000000..c7a469cdf --- /dev/null +++ b/src/permission/permissions-file.test.ts @@ -0,0 +1,94 @@ +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import { mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getLogger } from "@intx/log"; + +import { LOG_NAMESPACE_ROOT } from "../branding.js"; +import { canonicalGrantFilePath, loadPermissionsFile } from "./store.js"; + +const grantsFileLog = getLogger([ + LOG_NAMESPACE_ROOT, + "permission", + "grants-file", +]); + +describe("grant-file canonical logging", () => { + let dir = ""; + + beforeEach(() => { + spyOn(grantsFileLog, "info"); + }); + + afterEach(async () => { + mock.restore(); + if (dir !== "") await rm(dir, { recursive: true, force: true }); + dir = ""; + }); + + test("loading through a symlink logs the canonical realpath target", async () => { + dir = await mkdtemp(join(tmpdir(), "grants-file-")); + const target = join(dir, "grants.json"); + await writeFile( + target, + JSON.stringify({ + approvals: [ + { tool: "run_shell", pattern: "bun install*" }, + { + tool: "run_shell", + pattern: "scoped cargo *", + providerModel: "openai:gpt-5", + }, + ], + }), + ); + const link = join(dir, "link.json"); + await symlink(target, link); + + const approvals = await loadPermissionsFile(link); + + expect(approvals).toHaveLength(2); + expect(grantsFileLog.info).toHaveBeenCalledWith( + "Grant file loaded: {path} ({count} approvals)", + { path: await realpath(link), count: 2 }, + ); + }); + + test("a file without an approvals key logs an empty load", async () => { + dir = await mkdtemp(join(tmpdir(), "grants-file-")); + const path = join(dir, "grants.json"); + await writeFile(path, JSON.stringify({})); + + const approvals = await loadPermissionsFile(path); + + expect(approvals).toEqual([]); + expect(grantsFileLog.info).toHaveBeenCalledWith( + "Grant file loaded: {path} ({count} approvals)", + { path: await realpath(path), count: 0 }, + ); + }); + + test("a missing file fails closed without a load log", async () => { + dir = await mkdtemp(join(tmpdir(), "grants-file-")); + + await expect( + loadPermissionsFile(join(dir, "missing.json")), + ).rejects.toThrow(/not found/); + expect(grantsFileLog.info).not.toHaveBeenCalled(); + }); + + test("canonicalGrantFilePath falls back to the given path when unreadable", async () => { + dir = await mkdtemp(join(tmpdir(), "grants-file-")); + const missing = join(dir, "missing.json"); + + await expect(canonicalGrantFilePath(missing)).resolves.toBe(missing); + }); +}); diff --git a/src/permission/store.ts b/src/permission/store.ts index ac4c3aa5f..03b234dc9 100644 --- a/src/permission/store.ts +++ b/src/permission/store.ts @@ -1,13 +1,14 @@ import { homedir } from "node:os"; import { join } from "node:path"; -import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { mkdir, readFile, realpath, rename, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; import { type } from "arktype"; +import { getLogger } from "@intx/log"; import type { Approval } from "./types.js"; import { sessionDir } from "../session/index.js"; -import { SETTINGS_DIR_NAME } from "../branding.js"; +import { LOG_NAMESPACE_ROOT, SETTINGS_DIR_NAME } from "../branding.js"; import { isProjectGrantTrusted, reconcileProjectGrants, @@ -300,6 +301,24 @@ export async function loadProviderModelApprovals( // unreadable, and invalid files must fail closed: the persisted-store readers // treat ENOENT as an empty list and drop bad entries, which would silently // change the caller's grant set. +const grantsFileLog = getLogger([ + LOG_NAMESPACE_ROOT, + "permission", + "grants-file", +]); + +// The canonical grant-file target for logs: a symlinked --permissions path +// and its target must not read as two different grant sources. Falls back to +// the given path when the file is missing or unreadable — the load below +// still fails closed with its own error. +export async function canonicalGrantFilePath(path: string): Promise { + try { + return await realpath(path); + } catch { + return path; + } +} + export async function loadPermissionsFile(path: string): Promise { let raw: string; try { @@ -322,8 +341,17 @@ export async function loadPermissionsFile(path: string): Promise { throw new Error(`--permissions file must be a JSON object: ${path}`); } const approvals = (parsed as Record).approvals; - if (approvals === undefined) return []; - return parseApprovalList(approvals, path); + // loadConfig, seed, and /permissions list and reseed each call this loader + // on their own, so every load logs. A single process-lifetime line would + // hide a later read. + const canonicalPath = await canonicalGrantFilePath(path); + const list = + approvals === undefined ? [] : parseApprovalList(approvals, path); + grantsFileLog.info("Grant file loaded: {path} ({count} approvals)", { + path: canonicalPath, + count: list.length, + }); + return list; } // The process grant list is either the --permissions file or the user stores.