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
1 change: 1 addition & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ In TUI chat mode there is no completion gate — the session stays open across t
- `providers.ts` defines the `ProviderCatalogEntry` type and helpers for building TUI provider lists; `profiles.ts` handles profile-level selection logic.
- `loadConfig` is async (it reads settings files). Parses a leading `exec`/`run` subcommand, flags `--cwd`, `--config`, `--provider`, `--model`, `--dangerously-skip-permissions` (forces this process; TUI `/yolo` persists as the user-global default), `--auto` / `--no-auto` (auto mode defaults on); collects positional arguments as the optional initial task for the TUI or the required prompt for exec.
- Both settings files and the project/global grant store (`.corbits/permissions.json`) are on the secret-guard denylist for path-keyed tools, so the agent cannot `read_file` its own credentials or persist standing auto-approvals. Shell commands that reference them still require explicit operator approval.
- Credential-surface ownership: each auth store module enumerates its own files (`*_AUTH_FILENAME` / `MCP_AUTH_DIRNAME`), the data-only registry in `src/auth/credential-surface.ts` turns them into denylist patterns, `secret-guard-plugin.ts` owns matching (lexical plus realpath), and `@mention` resolution consumes the resolved check — never the registry directly. A new `*-auth.json` token store is denied only once its store module exports its filename and the registry lists it; the coverage test scans store sources for `*-auth.json` literals (registered dirnames get an includes-check instead) and fails the build until both exist.

### TUI Runner (`src/tui/runner.ts`)

Expand Down
4 changes: 3 additions & 1 deletion src/auth/codex/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,11 @@ export function withDefaultCodexExpiry(
return { ...tokens, expiresAt: now + DEFAULT_EXPIRES_IN_S * 1000 };
}

export const CODEX_AUTH_FILENAME = "codex-auth.json";

export function createCodexAuthStore(settingsDirName: string) {
return createAuthStore<CodexTokens>({
filename: "codex-auth.json",
filename: CODEX_AUTH_FILENAME,
settingsDirName,
isTokens: isCodexTokens,
});
Expand Down
88 changes: 88 additions & 0 deletions src/auth/credential-surface-coverage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { describe, expect, test } from "bun:test";
import { Glob } from "bun";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { CODEX_AUTH_FILENAME } from "./codex/store.js";
import {
buildCredentialPatterns,
credentialDirDescriptors,
credentialFileDescriptors,
} from "./credential-surface.js";
import { MCP_AUTH_DIRNAME } from "../mcp/auth-store.js";
import { XAI_AUTH_FILENAME } from "./xai/store.js";

const here = dirname(fileURLToPath(import.meta.url));

// Auth-owned credential literals live in exactly these store modules. Test
// fixtures (concurrent-auth.json, test-auth.json) live in *.test.ts, which the
// scan below excludes, so fixture names can never become denylist patterns.
const STORE_FILES = [
join(here, "codex", "store.ts"),
join(here, "xai", "store.ts"),
join(here, "..", "mcp", "auth-store.ts"),
];

async function scanStoreSources(): Promise<Map<string, string>> {
const sources = new Map<string, string>();
const glob = new Glob("**/*.ts");
for await (const entry of glob.scan({ cwd: here, absolute: true })) {
if (entry.endsWith(".test.ts")) continue;
sources.set(entry, await Bun.file(entry).text());
}
for (const file of STORE_FILES) {
if (!sources.has(file)) sources.set(file, await Bun.file(file).text());
}
return sources;
}

function authFileLiterals(source: string): string[] {
const found: string[] = [];
const pattern = /["']([A-Za-z0-9_.-]+-auth\.json)["']/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(source)) !== null) {
const literal = match[1];
if (literal !== undefined && !found.includes(literal)) found.push(literal);
}
return found;
}

describe("CL-7789 credential-surface coverage", () => {
test("every *-auth.json literal in auth-owned stores is denied", async () => {
const sources = await scanStoreSources();
const patterns = buildCredentialPatterns();
const seen = new Set<string>();
for (const source of sources.values()) {
for (const literal of authFileLiterals(source)) {
seen.add(literal);
const probe = join("~", ".corbits", literal);
expect(
patterns.some((pattern) => pattern.test(probe)),
`${literal} has no denylist pattern`,
).toBe(true);
}
}
expect([...seen].sort()).toEqual(
[CODEX_AUTH_FILENAME, XAI_AUTH_FILENAME].sort(),
);
});

test("every registry descriptor resolves back to a store literal", async () => {
const sources = await scanStoreSources();
const texts = [...sources.values()];
for (const { filename } of credentialFileDescriptors) {
expect(
texts.some((source) => source.includes(`"${filename}"`)),
`${filename} is registered but no store writes it`,
).toBe(true);
}
for (const { dirname } of credentialDirDescriptors) {
expect(
texts.some((source) => source.includes(`"${dirname}"`)),
`${dirname} is registered but no store writes it`,
).toBe(true);
}
expect(texts.some((source) => source.includes(MCP_AUTH_DIRNAME))).toBe(
true,
);
});
});
73 changes: 73 additions & 0 deletions src/auth/credential-surface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { SETTINGS_DIR_NAME } from "../branding.js";

// Auth-owned credential surface: every file under the settings directory whose
// bytes are OAuth tokens or provider credentials. The secret-guard plugin owns
// matching (lexical + realpath); mention-resolution consumes the resolved
// check. This module stays data-only — branding plus literals, no store
// factories or homedir calls — so the import direction stays plugins → auth →
// branding with no cycle.
export interface CredentialFileDescriptor {
settingsDirName: string;
filename: string;
}

export interface CredentialDirDescriptor {
settingsDirName: string;
dirname: string;
}

export const credentialFileDescriptors: CredentialFileDescriptor[] = [
{ settingsDirName: SETTINGS_DIR_NAME, filename: "codex-auth.json" },
{ settingsDirName: SETTINGS_DIR_NAME, filename: "xai-auth.json" },
];

export const credentialDirDescriptors: CredentialDirDescriptor[] = [
// Per-server files embed a content sha in the name, so they cannot be
// enumerated — match the whole directory instead.
{ settingsDirName: SETTINGS_DIR_NAME, dirname: "mcp-auth" },
];

// Every basename whose lock/temp sidecar carries full credential bytes
// mid-write. settings.json and permissions.json keep their hand-written base
// patterns in the plugin; their sidecars are still enumerated here so a torn
// write's temp file denies the same as the file itself.
const credentialSidecarBasenames: string[] = [
...credentialFileDescriptors.map((descriptor) => descriptor.filename),
"settings.json",
"permissions.json",
];

function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

// Guard-only patterns: deny reads/writes of credential-adjacent files without
// rotating, migrating, or encrypting them.
export function buildCredentialPatterns(): RegExp[] {
const dir = escapeRegExp(SETTINGS_DIR_NAME);
const patterns: RegExp[] = [];
for (const { settingsDirName, filename } of credentialFileDescriptors) {
const scope = escapeRegExp(settingsDirName);
patterns.push(new RegExp(`(^|\\/)${scope}\\/${escapeRegExp(filename)}$`));
}
for (const { settingsDirName, dirname } of credentialDirDescriptors) {
const scope = escapeRegExp(settingsDirName);
patterns.push(new RegExp(`(^|\\/)${scope}\\/${escapeRegExp(dirname)}\\/`));
}
for (const basename of credentialSidecarBasenames) {
const base = escapeRegExp(basename);
// Editor backup copies keep full credential bytes next to the live file.
// Scoped to the settings dir and anchored to known basenames, never a
// generic *.bak, *~, or *.swp.
patterns.push(new RegExp(`(^|\\/)${dir}\\/${base}\\.bak[^/]*$`));
patterns.push(new RegExp(`(^|\\/)${dir}\\/${base}~$`));
patterns.push(new RegExp(`(^|\\/)${dir}\\/${base}\\.swp$`));
patterns.push(new RegExp(`(^|\\/)${dir}\\/\\.${base}\\.swp$`));
patterns.push(new RegExp(`(^|\\/)${base}\\.lock$`));
// Writers emit a pid.counter middle segment (auth/store.ts,
// mcp/auth-store.ts), so the middle segment is required; a bare
// `<base>.tmp` has no known writer and stays unmatched.
patterns.push(new RegExp(`(^|\\/)${base}\\.[^/]*\\.tmp$`));
}
return patterns;
}
4 changes: 3 additions & 1 deletion src/auth/xai/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ function isXaiTokens(value: unknown): value is XaiTokens {
return !(XaiTokensShape(value) instanceof type.errors);
}

export const XAI_AUTH_FILENAME = "xai-auth.json";

export function createXaiAuthStore(settingsDirName: string) {
return createAuthStore<XaiTokens>({
filename: "xai-auth.json",
filename: XAI_AUTH_FILENAME,
settingsDirName,
isTokens: isXaiTokens,
});
Expand Down
4 changes: 3 additions & 1 deletion src/mcp/auth-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,10 @@ export interface MCPAuthIdentity {
serverURL: string;
}

export const MCP_AUTH_DIRNAME = "mcp-auth";

export function mcpAuthDir(home: string = homedir()): string {
return join(home, SETTINGS_DIR_NAME, "mcp-auth");
return join(home, SETTINGS_DIR_NAME, MCP_AUTH_DIRNAME);
}

function legacyServerSlug(serverName: string): string {
Expand Down
Loading
Loading