Skip to content

Commit 97ba7e9

Browse files
Deny OAuth token stores in the secret guard (#983)
* Deny OAuth token stores in the secret guard Codex, xAI, and per-server MCP OAuth tokens live under ~/.corbits but had no denylist entry, so read_file and @mentions could exfiltrate them. Each auth store now enumerates its own filenames, a data-only registry turns them into denylist patterns with backup and sidecar coverage, and mention resolution expands ~ and checks the resolved path at both gates. * Deny backups of OAuth token stores in the secret guard * Deny editor tilde and swap backups of credential files
1 parent edb517f commit 97ba7e9

10 files changed

Lines changed: 417 additions & 20 deletions

docs/ARCHITECTURE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ In TUI chat mode there is no completion gate — the session stays open across t
7474
- `providers.ts` defines the `ProviderCatalogEntry` type and helpers for building TUI provider lists; `profiles.ts` handles profile-level selection logic.
7575
- `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.
7676
- 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.
77+
- 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.
7778

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

src/auth/codex/store.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,11 @@ export function withDefaultCodexExpiry(
3737
return { ...tokens, expiresAt: now + DEFAULT_EXPIRES_IN_S * 1000 };
3838
}
3939

40+
export const CODEX_AUTH_FILENAME = "codex-auth.json";
41+
4042
export function createCodexAuthStore(settingsDirName: string) {
4143
return createAuthStore<CodexTokens>({
42-
filename: "codex-auth.json",
44+
filename: CODEX_AUTH_FILENAME,
4345
settingsDirName,
4446
isTokens: isCodexTokens,
4547
});
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { Glob } from "bun";
3+
import { dirname, join } from "node:path";
4+
import { fileURLToPath } from "node:url";
5+
import { CODEX_AUTH_FILENAME } from "./codex/store.js";
6+
import {
7+
buildCredentialPatterns,
8+
credentialDirDescriptors,
9+
credentialFileDescriptors,
10+
} from "./credential-surface.js";
11+
import { MCP_AUTH_DIRNAME } from "../mcp/auth-store.js";
12+
import { XAI_AUTH_FILENAME } from "./xai/store.js";
13+
14+
const here = dirname(fileURLToPath(import.meta.url));
15+
16+
// Auth-owned credential literals live in exactly these store modules. Test
17+
// fixtures (concurrent-auth.json, test-auth.json) live in *.test.ts, which the
18+
// scan below excludes, so fixture names can never become denylist patterns.
19+
const STORE_FILES = [
20+
join(here, "codex", "store.ts"),
21+
join(here, "xai", "store.ts"),
22+
join(here, "..", "mcp", "auth-store.ts"),
23+
];
24+
25+
async function scanStoreSources(): Promise<Map<string, string>> {
26+
const sources = new Map<string, string>();
27+
const glob = new Glob("**/*.ts");
28+
for await (const entry of glob.scan({ cwd: here, absolute: true })) {
29+
if (entry.endsWith(".test.ts")) continue;
30+
sources.set(entry, await Bun.file(entry).text());
31+
}
32+
for (const file of STORE_FILES) {
33+
if (!sources.has(file)) sources.set(file, await Bun.file(file).text());
34+
}
35+
return sources;
36+
}
37+
38+
function authFileLiterals(source: string): string[] {
39+
const found: string[] = [];
40+
const pattern = /["']([A-Za-z0-9_.-]+-auth\.json)["']/g;
41+
let match: RegExpExecArray | null;
42+
while ((match = pattern.exec(source)) !== null) {
43+
const literal = match[1];
44+
if (literal !== undefined && !found.includes(literal)) found.push(literal);
45+
}
46+
return found;
47+
}
48+
49+
describe("CL-7789 credential-surface coverage", () => {
50+
test("every *-auth.json literal in auth-owned stores is denied", async () => {
51+
const sources = await scanStoreSources();
52+
const patterns = buildCredentialPatterns();
53+
const seen = new Set<string>();
54+
for (const source of sources.values()) {
55+
for (const literal of authFileLiterals(source)) {
56+
seen.add(literal);
57+
const probe = join("~", ".corbits", literal);
58+
expect(
59+
patterns.some((pattern) => pattern.test(probe)),
60+
`${literal} has no denylist pattern`,
61+
).toBe(true);
62+
}
63+
}
64+
expect([...seen].sort()).toEqual(
65+
[CODEX_AUTH_FILENAME, XAI_AUTH_FILENAME].sort(),
66+
);
67+
});
68+
69+
test("every registry descriptor resolves back to a store literal", async () => {
70+
const sources = await scanStoreSources();
71+
const texts = [...sources.values()];
72+
for (const { filename } of credentialFileDescriptors) {
73+
expect(
74+
texts.some((source) => source.includes(`"${filename}"`)),
75+
`${filename} is registered but no store writes it`,
76+
).toBe(true);
77+
}
78+
for (const { dirname } of credentialDirDescriptors) {
79+
expect(
80+
texts.some((source) => source.includes(`"${dirname}"`)),
81+
`${dirname} is registered but no store writes it`,
82+
).toBe(true);
83+
}
84+
expect(texts.some((source) => source.includes(MCP_AUTH_DIRNAME))).toBe(
85+
true,
86+
);
87+
});
88+
});

src/auth/credential-surface.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { SETTINGS_DIR_NAME } from "../branding.js";
2+
3+
// Auth-owned credential surface: every file under the settings directory whose
4+
// bytes are OAuth tokens or provider credentials. The secret-guard plugin owns
5+
// matching (lexical + realpath); mention-resolution consumes the resolved
6+
// check. This module stays data-only — branding plus literals, no store
7+
// factories or homedir calls — so the import direction stays plugins → auth →
8+
// branding with no cycle.
9+
export interface CredentialFileDescriptor {
10+
settingsDirName: string;
11+
filename: string;
12+
}
13+
14+
export interface CredentialDirDescriptor {
15+
settingsDirName: string;
16+
dirname: string;
17+
}
18+
19+
export const credentialFileDescriptors: CredentialFileDescriptor[] = [
20+
{ settingsDirName: SETTINGS_DIR_NAME, filename: "codex-auth.json" },
21+
{ settingsDirName: SETTINGS_DIR_NAME, filename: "xai-auth.json" },
22+
];
23+
24+
export const credentialDirDescriptors: CredentialDirDescriptor[] = [
25+
// Per-server files embed a content sha in the name, so they cannot be
26+
// enumerated — match the whole directory instead.
27+
{ settingsDirName: SETTINGS_DIR_NAME, dirname: "mcp-auth" },
28+
];
29+
30+
// Every basename whose lock/temp sidecar carries full credential bytes
31+
// mid-write. settings.json and permissions.json keep their hand-written base
32+
// patterns in the plugin; their sidecars are still enumerated here so a torn
33+
// write's temp file denies the same as the file itself.
34+
const credentialSidecarBasenames: string[] = [
35+
...credentialFileDescriptors.map((descriptor) => descriptor.filename),
36+
"settings.json",
37+
"permissions.json",
38+
];
39+
40+
function escapeRegExp(value: string): string {
41+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
42+
}
43+
44+
// Guard-only patterns: deny reads/writes of credential-adjacent files without
45+
// rotating, migrating, or encrypting them.
46+
export function buildCredentialPatterns(): RegExp[] {
47+
const dir = escapeRegExp(SETTINGS_DIR_NAME);
48+
const patterns: RegExp[] = [];
49+
for (const { settingsDirName, filename } of credentialFileDescriptors) {
50+
const scope = escapeRegExp(settingsDirName);
51+
patterns.push(new RegExp(`(^|\\/)${scope}\\/${escapeRegExp(filename)}$`));
52+
}
53+
for (const { settingsDirName, dirname } of credentialDirDescriptors) {
54+
const scope = escapeRegExp(settingsDirName);
55+
patterns.push(new RegExp(`(^|\\/)${scope}\\/${escapeRegExp(dirname)}\\/`));
56+
}
57+
for (const basename of credentialSidecarBasenames) {
58+
const base = escapeRegExp(basename);
59+
// Editor backup copies keep full credential bytes next to the live file.
60+
// Scoped to the settings dir and anchored to known basenames, never a
61+
// generic *.bak, *~, or *.swp.
62+
patterns.push(new RegExp(`(^|\\/)${dir}\\/${base}\\.bak[^/]*$`));
63+
patterns.push(new RegExp(`(^|\\/)${dir}\\/${base}~$`));
64+
patterns.push(new RegExp(`(^|\\/)${dir}\\/${base}\\.swp$`));
65+
patterns.push(new RegExp(`(^|\\/)${dir}\\/\\.${base}\\.swp$`));
66+
patterns.push(new RegExp(`(^|\\/)${base}\\.lock$`));
67+
// Writers emit a pid.counter middle segment (auth/store.ts,
68+
// mcp/auth-store.ts), so the middle segment is required; a bare
69+
// `<base>.tmp` has no known writer and stays unmatched.
70+
patterns.push(new RegExp(`(^|\\/)${base}\\.[^/]*\\.tmp$`));
71+
}
72+
return patterns;
73+
}

src/auth/xai/store.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,11 @@ function isXaiTokens(value: unknown): value is XaiTokens {
1919
return !(XaiTokensShape(value) instanceof type.errors);
2020
}
2121

22+
export const XAI_AUTH_FILENAME = "xai-auth.json";
23+
2224
export function createXaiAuthStore(settingsDirName: string) {
2325
return createAuthStore<XaiTokens>({
24-
filename: "xai-auth.json",
26+
filename: XAI_AUTH_FILENAME,
2527
settingsDirName,
2628
isTokens: isXaiTokens,
2729
});

src/mcp/auth-store.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,10 @@ export interface MCPAuthIdentity {
3333
serverURL: string;
3434
}
3535

36+
export const MCP_AUTH_DIRNAME = "mcp-auth";
37+
3638
export function mcpAuthDir(home: string = homedir()): string {
37-
return join(home, SETTINGS_DIR_NAME, "mcp-auth");
39+
return join(home, SETTINGS_DIR_NAME, MCP_AUTH_DIRNAME);
3840
}
3941

4042
function legacyServerSlug(serverName: string): string {

0 commit comments

Comments
 (0)