-
Notifications
You must be signed in to change notification settings - Fork 12
fix(cluster): isolate runtime credentials and proxy identity #143
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
122 changes: 122 additions & 0 deletions
122
cluster/images/session-runtime/assert-omp-credentials-absent.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| import { Database } from "bun:sqlite"; | ||
| import { afterEach, describe, expect, test } from "bun:test"; | ||
| import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; | ||
| import { tmpdir } from "node:os"; | ||
| import path from "node:path"; | ||
| import { assertOMPProfileCredentialsAbsent } from "./assert-omp-credentials-absent"; | ||
|
|
||
| const roots: string[] = []; | ||
|
|
||
| afterEach(async () => { | ||
| await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); | ||
| }); | ||
|
|
||
| async function fixture(): Promise<{ root: string; home: string; agentDir: string; databasePath: string }> { | ||
| const root = await mkdtemp(path.join(tmpdir(), "t4-omp-credential-check-")); | ||
| roots.push(root); | ||
| const home = path.join(root, "home"); | ||
| const agentDir = path.join(home, ".omp", "profiles", "session-a", "agent"); | ||
| await mkdir(agentDir, { recursive: true }); | ||
| return { root, home, agentDir, databasePath: path.join(agentDir, "agent.db") }; | ||
| } | ||
|
|
||
| function createPinnedSchema(databasePath: string): Database { | ||
| const database = new Database(databasePath); | ||
| database.run(` | ||
| CREATE TABLE auth_schema_version (id INTEGER PRIMARY KEY CHECK (id = 1), version INTEGER NOT NULL); | ||
| INSERT INTO auth_schema_version(id, version) VALUES (1, 6); | ||
| CREATE TABLE auth_credentials ( | ||
| id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| provider TEXT NOT NULL, | ||
| credential_type TEXT NOT NULL, | ||
| data TEXT NOT NULL, | ||
| disabled_cause TEXT DEFAULT NULL, | ||
| identity_key TEXT DEFAULT NULL, | ||
| created_at INTEGER NOT NULL DEFAULT 0, | ||
| updated_at INTEGER NOT NULL DEFAULT 0 | ||
| ); | ||
| CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at INTEGER NOT NULL DEFAULT 0); | ||
| CREATE TABLE history (id INTEGER PRIMARY KEY, value TEXT NOT NULL); | ||
| `); | ||
| return database; | ||
| } | ||
|
|
||
| describe("OMP durable credential preflight", () => { | ||
| test("accepts the pinned schema only when credential state is absent", async () => { | ||
| const { home, agentDir, databasePath } = await fixture(); | ||
| const database = createPinnedSchema(databasePath); | ||
| database.query("INSERT INTO settings(key, value) VALUES (?, ?)").run("theme", JSON.stringify("dark")); | ||
| database.query("INSERT INTO history(id, value) VALUES (?, ?)").run(1, "unrelated session state"); | ||
| database.close(); | ||
|
|
||
| await expect(assertOMPProfileCredentialsAbsent({ agentDir, home })).resolves.toBeUndefined(); | ||
| const verified = new Database(databasePath, { create: false, readonly: true }); | ||
| expect(verified.query("SELECT value FROM settings WHERE key = 'theme'").get()).toEqual({ value: '"dark"' }); | ||
| expect(verified.query("SELECT value FROM history WHERE id = 1").get()).toEqual({ value: "unrelated session state" }); | ||
| verified.close(); | ||
| }); | ||
|
|
||
| test("rejects credential rows without mutating them", async () => { | ||
| const { home, agentDir, databasePath } = await fixture(); | ||
| const database = createPinnedSchema(databasePath); | ||
| database.query("INSERT INTO auth_credentials(provider, credential_type, data) VALUES (?, ?, ?)").run( | ||
| "anthropic", | ||
| "api_key", | ||
| JSON.stringify({ key: "must-remain-until-an-operator-removes-it" }), | ||
| ); | ||
| database.close(); | ||
|
|
||
| await expect(assertOMPProfileCredentialsAbsent({ agentDir, home })).rejects.toThrow( | ||
| "OMP auth credentials must be absent", | ||
| ); | ||
| const verified = new Database(databasePath, { create: false, readonly: true }); | ||
| expect((verified.query("SELECT COUNT(*) AS count FROM auth_credentials").get() as { count: number }).count).toBe(1); | ||
| verified.close(); | ||
| }); | ||
|
|
||
| test("rejects secret settings and broker files without deleting them", async () => { | ||
| const { home, agentDir, databasePath } = await fixture(); | ||
| const database = createPinnedSchema(databasePath); | ||
| database.query("INSERT INTO settings(key, value) VALUES (?, ?)").run("auth.broker.token", "present"); | ||
| database.close(); | ||
| await expect(assertOMPProfileCredentialsAbsent({ agentDir, home })).rejects.toThrow( | ||
| "OMP secret settings must be absent", | ||
| ); | ||
|
|
||
| const tokenPath = path.join(home, ".omp", "auth-broker.token"); | ||
| await writeFile(tokenPath, "present", { mode: 0o600 }); | ||
| await expect(assertOMPProfileCredentialsAbsent({ agentDir, home })).rejects.toThrow( | ||
| "credential state is present: auth-broker.token", | ||
| ); | ||
| }); | ||
|
|
||
| test("fails closed for unknown schemas and symlinked profile directories", async () => { | ||
| const { root, home, agentDir, databasePath } = await fixture(); | ||
| const database = new Database(databasePath); | ||
| database.run("CREATE TABLE auth_credentials (id INTEGER PRIMARY KEY, data TEXT NOT NULL)"); | ||
| database.close(); | ||
| await expect(assertOMPProfileCredentialsAbsent({ agentDir, home })).rejects.toThrow( | ||
| "unsupported auth_credentials schema", | ||
| ); | ||
|
|
||
| const linkedHome = path.join(root, "linked-home"); | ||
| await symlink(home, linkedHome); | ||
| await expect( | ||
| assertOMPProfileCredentialsAbsent({ | ||
| agentDir: path.join(linkedHome, ".omp", "profiles", "session-a", "agent"), | ||
| home: linkedHome, | ||
| }), | ||
| ).rejects.toThrow("OMP profile path must be a real directory"); | ||
| }); | ||
|
|
||
| test("rejects a symlinked broker cache without following it", async () => { | ||
| const { root, home, agentDir } = await fixture(); | ||
| const outsideCache = path.join(root, "outside-cache"); | ||
| await mkdir(outsideCache); | ||
| await symlink(outsideCache, path.join(home, ".omp", "cache")); | ||
|
|
||
| await expect(assertOMPProfileCredentialsAbsent({ agentDir, home })).rejects.toThrow( | ||
| "OMP profile path must be a real directory: cache", | ||
| ); | ||
| }); | ||
| }); |
152 changes: 152 additions & 0 deletions
152
cluster/images/session-runtime/assert-omp-credentials-absent.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,152 @@ | ||
| import { Database } from "bun:sqlite"; | ||
| import { lstat } from "node:fs/promises"; | ||
| import path from "node:path"; | ||
|
|
||
| const AUTH_SCHEMA_VERSION = 6; | ||
| const AUTH_COLUMNS = [ | ||
| "id", | ||
| "provider", | ||
| "credential_type", | ||
| "data", | ||
| "disabled_cause", | ||
| "identity_key", | ||
| "created_at", | ||
| "updated_at", | ||
| ] as const; | ||
| const SETTINGS_COLUMNS = ["key", "value", "updated_at"] as const; | ||
| const SECRET_SETTING_KEYS = [ | ||
| "auth.broker.token", | ||
| "hindsight.apiToken", | ||
| "searxng.token", | ||
| "dev.autoqaPush.token", | ||
| ] as const; | ||
|
|
||
| type ColumnRow = { name?: unknown }; | ||
| type CountRow = { count?: unknown }; | ||
| type VersionRow = { version?: unknown }; | ||
|
|
||
| function tableExists(database: Database, table: string): boolean { | ||
| const row = database | ||
| .query("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?") | ||
| .get(table) as { present?: unknown } | null; | ||
| return row?.present === 1; | ||
| } | ||
|
|
||
| function requireExactColumns(database: Database, table: string, expected: readonly string[]): void { | ||
| const rows = database.query(`PRAGMA table_info(${table})`).all() as ColumnRow[]; | ||
| const actual = rows.map(row => row.name); | ||
| if ( | ||
| actual.length !== expected.length || | ||
| actual.some((column, index) => typeof column !== "string" || column !== expected[index]) | ||
| ) { | ||
| throw new Error(`unsupported ${table} schema`); | ||
| } | ||
| } | ||
|
|
||
| async function requireDirectory(directoryPath: string): Promise<void> { | ||
| const metadata = await lstat(directoryPath); | ||
| if (!metadata.isDirectory() || metadata.isSymbolicLink()) { | ||
| throw new Error(`OMP profile path must be a real directory: ${path.basename(directoryPath)}`); | ||
| } | ||
| } | ||
|
|
||
| async function requireDirectoryChain(home: string, agentDir: string): Promise<void> { | ||
| await requireDirectory(home); | ||
| const relativeAgentDir = path.relative(home, agentDir); | ||
| if ( | ||
| relativeAgentDir === "" || | ||
| relativeAgentDir.startsWith(`..${path.sep}`) || | ||
| path.isAbsolute(relativeAgentDir) | ||
| ) { | ||
| throw new Error("OMP credential check paths are outside the profile home"); | ||
| } | ||
| let current = home; | ||
| for (const segment of relativeAgentDir.split(path.sep)) { | ||
| current = path.join(current, segment); | ||
| await requireDirectory(current); | ||
| } | ||
| } | ||
|
|
||
| async function requireAbsent(filePath: string): Promise<void> { | ||
| try { | ||
| await lstat(filePath); | ||
| } catch (error) { | ||
| if ((error as NodeJS.ErrnoException).code === "ENOENT") return; | ||
| throw error; | ||
| } | ||
| throw new Error(`credential state is present: ${path.basename(filePath)}`); | ||
| } | ||
|
|
||
| export async function assertOMPProfileCredentialsAbsent(input: { | ||
| readonly agentDir: string; | ||
| readonly home: string; | ||
| }): Promise<void> { | ||
| if (!path.isAbsolute(input.agentDir) || !path.isAbsolute(input.home)) { | ||
| throw new Error("OMP credential check paths must be absolute"); | ||
| } | ||
| const agentDir = path.resolve(input.agentDir); | ||
| const home = path.resolve(input.home); | ||
| const expectedProfilesRoot = path.join(home, ".omp", "profiles") + path.sep; | ||
| if (!agentDir.startsWith(expectedProfilesRoot)) { | ||
| throw new Error("OMP credential check paths are outside the profile home"); | ||
| } | ||
| await requireDirectoryChain(home, agentDir); | ||
|
|
||
| const databasePath = path.join(agentDir, "agent.db"); | ||
| const tokenPath = path.join(home, ".omp", "auth-broker.token"); | ||
| const cachePath = path.join(home, ".omp", "cache"); | ||
| const snapshotPath = path.join(cachePath, "auth-broker-snapshot.enc"); | ||
| await requireAbsent(tokenPath); | ||
| try { | ||
| await requireDirectory(cachePath); | ||
| await requireAbsent(snapshotPath); | ||
| } catch (error) { | ||
| if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; | ||
| } | ||
|
|
||
| let databaseMetadata; | ||
| try { | ||
| databaseMetadata = await lstat(databasePath); | ||
| } catch (error) { | ||
| if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; | ||
| for (const sidecar of [`${databasePath}-wal`, `${databasePath}-shm`]) await requireAbsent(sidecar); | ||
| return; | ||
| } | ||
| if (!databaseMetadata.isFile() || databaseMetadata.isSymbolicLink()) { | ||
| throw new Error("agent.db must be a regular file"); | ||
| } | ||
|
|
||
| const database = new Database(databasePath, { create: false, readonly: true }); | ||
| try { | ||
| if (tableExists(database, "auth_credentials")) { | ||
| requireExactColumns(database, "auth_credentials", AUTH_COLUMNS); | ||
| if (!tableExists(database, "auth_schema_version")) throw new Error("auth schema version is missing"); | ||
| requireExactColumns(database, "auth_schema_version", ["id", "version"]); | ||
| const version = database.query("SELECT version FROM auth_schema_version WHERE id = 1").get() as VersionRow | null; | ||
| if (version?.version !== AUTH_SCHEMA_VERSION) throw new Error("unsupported auth schema version"); | ||
| const count = database.query("SELECT COUNT(*) AS count FROM auth_credentials").get() as CountRow; | ||
| if (typeof count.count !== "number" || count.count !== 0) { | ||
| throw new Error("OMP auth credentials must be absent"); | ||
| } | ||
| } | ||
| if (tableExists(database, "settings")) { | ||
| requireExactColumns(database, "settings", SETTINGS_COLUMNS); | ||
| const placeholders = SECRET_SETTING_KEYS.map(() => "?").join(", "); | ||
| const count = database | ||
| .query(`SELECT COUNT(*) AS count FROM settings WHERE key IN (${placeholders})`) | ||
| .get(...SECRET_SETTING_KEYS) as CountRow; | ||
| if (typeof count.count !== "number" || count.count !== 0) { | ||
| throw new Error("OMP secret settings must be absent"); | ||
| } | ||
| } | ||
| } finally { | ||
| database.close(); | ||
| } | ||
| } | ||
|
|
||
| if (import.meta.main) { | ||
| const [agentDir, home] = process.argv.slice(2); | ||
| if (!agentDir || !home) throw new Error("usage: assert-omp-credentials-absent <agent-dir> <home>"); | ||
| await assertOMPProfileCredentialsAbsent({ agentDir, home }); | ||
| process.stdout.write(JSON.stringify({ component: "session-runtime", result: "credential_state_absent" }) + "\n"); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The durable credential preflight checks
$HOME/.omp/auth-broker.token, but OMP's profile-local broker token is stored at$HOME/.omp/profiles/<profile>/auth-broker.token(as the repository's maintainer installer does). Thus a session whose selected profile contains that token will pass this check and start OMP with a reusable broker credential accessible to the arbitrary-code session workload. Derive and check the profile root fromagentDir(the parent ofagent) instead.Useful? React with 👍 / 👎.