Skip to content

Commit 7f8db2f

Browse files
committed
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.
1 parent edb517f commit 7f8db2f

10 files changed

Lines changed: 366 additions & 19 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 token store is denied only once its store module exports its filename and the registry lists it; the coverage test 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: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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+
// Editor backup copies keep full credential bytes next to the live file.
58+
patterns.push(new RegExp(`(^|\\/)${dir}\\/settings\\.json\\.bak[^/]*$`));
59+
for (const basename of credentialSidecarBasenames) {
60+
const base = escapeRegExp(basename);
61+
patterns.push(new RegExp(`(^|\\/)${base}\\.lock$`));
62+
patterns.push(new RegExp(`(^|\\/)${base}\\.[^/]*\\.tmp$`));
63+
}
64+
return patterns;
65+
}

‎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 {
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import type { ToolCall, ToolResult } from "@intx/types/runtime";
6+
import { resolveAtMentions } from "../tui/mention-resolution.js";
7+
import {
8+
isSensitivePath,
9+
isSensitivePathResolved,
10+
secretGuardPlugin,
11+
} from "./secret-guard-plugin.js";
12+
13+
const CODEX_BYTES =
14+
'{"profiles":{"personal":{"tokens":{"access":"CODEX-FIXTURE-ACCESS"}}}}';
15+
const XAI_BYTES =
16+
'{"profiles":{"personal":{"tokens":{"access":"XAI-FIXTURE-ACCESS"}}}}';
17+
const MCP_BYTES = '{"tokens":{"access_token":"MCP-FIXTURE-ACCESS"}}';
18+
19+
async function fakeHome(): Promise<{
20+
home: string;
21+
codexAuth: string;
22+
xaiAuth: string;
23+
settings: string;
24+
permissions: string;
25+
mcpAuth: string;
26+
cleanup: () => Promise<void>;
27+
}> {
28+
const home = await mkdtemp(join(tmpdir(), "cl7789-fake-home-"));
29+
const configDir = join(home, ".corbits");
30+
const mcpDir = join(configDir, "mcp-auth");
31+
await mkdir(mcpDir, { recursive: true });
32+
const codexAuth = join(configDir, "codex-auth.json");
33+
const xaiAuth = join(configDir, "xai-auth.json");
34+
const settings = join(configDir, "settings.json");
35+
const permissions = join(configDir, "permissions.json");
36+
const mcpAuth = join(
37+
mcpDir,
38+
"my-server-9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00.json",
39+
);
40+
await writeFile(codexAuth, `${CODEX_BYTES}\n`);
41+
await writeFile(xaiAuth, `${XAI_BYTES}\n`);
42+
await writeFile(settings, '{"provider":"codex"}\n');
43+
await writeFile(permissions, '{"approvals":[]}\n');
44+
await writeFile(mcpAuth, `${MCP_BYTES}\n`);
45+
return {
46+
home,
47+
codexAuth,
48+
xaiAuth,
49+
settings,
50+
permissions,
51+
mcpAuth,
52+
cleanup: () => rm(home, { recursive: true, force: true }),
53+
};
54+
}
55+
56+
const next = async (call: ToolCall): Promise<ToolResult> => ({
57+
callId: call.id,
58+
content: "ok",
59+
});
60+
61+
function denyReason(path: string): Promise<ToolResult> {
62+
const plugin = secretGuardPlugin();
63+
const handler = plugin.middleware ? plugin.middleware(next) : next;
64+
return handler(
65+
{ id: "c", name: "read_file", arguments: { path } },
66+
new AbortController().signal,
67+
);
68+
}
69+
70+
describe("CL-7789 default-install credential surface", () => {
71+
test("lexical denylist covers every default credential file", async () => {
72+
const fake = await fakeHome();
73+
try {
74+
for (const path of [
75+
fake.codexAuth,
76+
fake.xaiAuth,
77+
fake.settings,
78+
fake.permissions,
79+
fake.mcpAuth,
80+
]) {
81+
expect(isSensitivePath(path)).toBe(true);
82+
expect(isSensitivePathResolved(path)).toBe(true);
83+
}
84+
} finally {
85+
await fake.cleanup();
86+
}
87+
});
88+
89+
test("read_file hard-denies every default credential file", async () => {
90+
const fake = await fakeHome();
91+
try {
92+
for (const path of [
93+
fake.codexAuth,
94+
fake.xaiAuth,
95+
fake.settings,
96+
fake.permissions,
97+
fake.mcpAuth,
98+
]) {
99+
const result = await denyReason(path);
100+
expect(result.isError).toBe(true);
101+
expect(String(result.content)).toMatch(/sensitive file blocked/);
102+
}
103+
} finally {
104+
await fake.cleanup();
105+
}
106+
});
107+
108+
test("credential backups and sidecars resolve as sensitive", () => {
109+
const home = join(tmpdir(), "cl7789-never-created");
110+
expect(
111+
isSensitivePathResolved(
112+
join(home, ".corbits", "settings.json.bak-2026-01-01"),
113+
),
114+
).toBe(true);
115+
expect(
116+
isSensitivePathResolved(join(home, ".corbits", "codex-auth.json.lock")),
117+
).toBe(true);
118+
expect(
119+
isSensitivePathResolved(
120+
join(home, ".corbits", "xai-auth.json.12345.1.tmp"),
121+
),
122+
).toBe(true);
123+
});
124+
125+
test("@mentions of credential files stay blocked without leaking bytes", async () => {
126+
const fake = await fakeHome();
127+
const cwd = await mkdtemp(join(tmpdir(), "cl7789-mention-cwd-"));
128+
try {
129+
for (const [path, marker] of [
130+
[fake.codexAuth, "CODEX-FIXTURE-ACCESS"],
131+
[fake.mcpAuth, "MCP-FIXTURE-ACCESS"],
132+
] as const) {
133+
const resolved = await resolveAtMentions(`read @${path}`, cwd);
134+
expect(resolved).toContain("(blocked: sensitive path)");
135+
expect(resolved).not.toContain(marker);
136+
}
137+
} finally {
138+
await fake.cleanup();
139+
await rm(cwd, { recursive: true, force: true });
140+
}
141+
});
142+
143+
test("a symlink to a credential file is blocked by mention and by read", async () => {
144+
const fake = await fakeHome();
145+
const cwd = await mkdtemp(join(tmpdir(), "cl7789-symlink-cwd-"));
146+
try {
147+
const link = join(cwd, "link-to-codex-auth");
148+
await symlink(fake.codexAuth, link);
149+
const denied = await denyReason(link);
150+
expect(denied.isError).toBe(true);
151+
expect(String(denied.content)).toMatch(/sensitive file blocked/);
152+
const resolved = await resolveAtMentions(`read @${link}`, cwd);
153+
expect(resolved).toContain("(blocked: sensitive path)");
154+
expect(resolved).not.toContain("CODEX-FIXTURE-ACCESS");
155+
} finally {
156+
await fake.cleanup();
157+
await rm(cwd, { recursive: true, force: true });
158+
}
159+
});
160+
});

‎src/plugins/secret-guard-plugin.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
realpathNearestOr,
1111
UNRESOLVABLE,
1212
} from "../permission/path-restriction.js";
13+
import { buildCredentialPatterns } from "../auth/credential-surface.js";
1314
import { productMutationPaths } from "../agent/product-mutation-tools.js";
1415
import { looksLikePath } from "./path-escape-plugin.js";
1516

@@ -88,6 +89,11 @@ const SENSITIVE_PATTERNS: RegExp[] = [
8889
/(^|\/)\.config\/gcloud\//,
8990
// Azure CLI's credential cache — the equivalent of ~/.aws/credentials.
9091
/(^|\/)\.azure\/(accessTokens|azureProfile)\.json$/,
92+
// The product's own OAuth token stores and credential sidecars come from the
93+
// auth-owned registry, not literals here, so a new store cannot drift off
94+
// the denylist. settings.json/permissions.json keep their hand-written
95+
// patterns above; the registry only adds their lock/temp sidecars.
96+
...buildCredentialPatterns(),
9197
];
9298

9399
export function isSensitivePath(value: string): boolean {

0 commit comments

Comments
 (0)