diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d46b3097b..8a62ea323 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -7,6 +7,7 @@ - Added server-name autocomplete for `/mcp` commands (`enable`, `disable`, `test`, `remove`, `reconnect`, `reauth`, `unauth`) using configured and runtime-discovered MCP servers. - Added `--from-claude` and `--from-codex` session imports, also available from `/resume @claude` and `/resume @codex`. - Added an opt-in OMP-native software-security workflow (`security.enabled`, default off) with immutable scan plans, exact-account Codex subscription affinity, native task-worker review, canonical findings/coverage/SARIF publication, project-scoped history, explicit dispositions, producer-differential comparison, and the read-only `security://` resource namespace. Generic SARIF and official Codex Security bundles normalize into the same OMP-owned store. +- Added explicit Codex Security cloud operations to the opt-in security workflow: list and start account-pinned cloud scans, inspect their progress, and import current findings into OMP's canonical store and `security://` namespace without changing the native scan engine or spoofing official runtime attribution. - Added `--from-claude` and `--from-codex` session imports (including compaction state for Codex), also available from `/resume @claude` and `/resume @codex`. - Added interactive Exa API-key onboarding through `/login exa`, opening the official key dashboard and saving pasted keys for authenticated web search while preserving `EXA_API_KEY` and explicit-selection public MCP fallback behavior ([#1798](https://github.com/can1357/oh-my-pi/issues/1798)). - Added `ExtensionContext.getAsyncJobSnapshot()` so extensions can read the owning session's async-job state without relying on process-global job-manager identity diff --git a/packages/coding-agent/src/prompts/tools/security-scan.md b/packages/coding-agent/src/prompts/tools/security-scan.md index 342a8a93f..aefeeebae 100644 --- a/packages/coding-agent/src/prompts/tools/security-scan.md +++ b/packages/coding-agent/src/prompts/tools/security-scan.md @@ -1 +1 @@ -Plan, start, inspect, or cancel an OMP-native repository security scan. `preflight` creates an immutable plan pinned to the repository snapshot, model, and exact OAuth credential. `start` runs the plan as a background OMP job. `status` and `cancel` operate on the returned operation ID. Security must be enabled in settings. +Plan, start, inspect, cancel, and validate OMP-native repository security scans. `preflight` creates an immutable plan pinned to the repository snapshot, model, and exact OAuth credential. `start` runs the plan as a background OMP job. `status` and `cancel` use the returned operation ID. `cloud_scans` lists Codex Security cloud configurations for the exact selected ChatGPT OAuth account. `cloud_start` creates and enables a cloud scan configuration using `repository_id`, `repository_url`, and `environment_id`; this consumes the account's separate Codex Security cloud allowance and is never a fallback from a native scan. `cloud_status` reads cloud progress. `cloud_pull` imports cloud findings into the canonical OMP security store, where they are available through `security://`. Cloud actions use `cloud_configuration_id` and may use `credential_id` to pin an account. Security must be enabled in settings. diff --git a/packages/coding-agent/src/security/auth.ts b/packages/coding-agent/src/security/auth.ts index d5dd85a5f..859d147d0 100644 --- a/packages/coding-agent/src/security/auth.ts +++ b/packages/coding-agent/src/security/auth.ts @@ -1,4 +1,5 @@ import type { AgentOptions } from "@oh-my-pi/pi-agent-core"; +import type { OAuthAccessResolution } from "@oh-my-pi/pi-ai"; import type { ApiKeyResolver } from "@oh-my-pi/pi-ai/auth-retry"; import type { AuthStorage } from "../session/auth-storage"; import type { SecurityAccountRef } from "./contracts"; @@ -8,7 +9,7 @@ export interface ExactSecurityOAuthOptions { account: SecurityAccountRef; } -function assertIdentityMatches( +export function assertSecurityIdentityMatches( account: SecurityAccountRef, resolution: { credentialId?: number; @@ -29,6 +30,46 @@ function assertIdentityMatches( } } +export function selectSecurityAccount( + authStorage: AuthStorage, + provider: string, + requestedCredentialId?: number, + sessionId?: string, +): SecurityAccountRef { + const accounts = authStorage.listOAuthAccounts(provider, sessionId); + const selected = + requestedCredentialId !== undefined + ? accounts.find(account => account.credentialId === requestedCredentialId) + : (accounts.find(account => account.active) ?? (accounts.length === 1 ? accounts[0] : undefined)); + if (!selected) { + if (accounts.length === 0) throw new Error(`Security scans require a stored OAuth account for ${provider}`); + if (requestedCredentialId !== undefined) { + throw new Error(`Security OAuth credential ${requestedCredentialId} is not available for ${provider}`); + } + throw new Error( + `Multiple OAuth accounts are available for ${provider}; supply credentialId to pin one exact account`, + ); + } + const account: SecurityAccountRef = { provider, credentialId: selected.credentialId }; + if (selected.accountId !== undefined) account.accountId = selected.accountId; + if (selected.email !== undefined) account.email = selected.email; + if (selected.orgId !== undefined) account.organizationId = selected.orgId; + if (selected.orgName !== undefined) account.organizationName = selected.orgName; + return account; +} + +export async function resolveExactSecurityOAuthAccess( + authStorage: AuthStorage, + account: SecurityAccountRef, + options: { forceRefresh: boolean; signal?: AbortSignal }, +): Promise> { + const resolution = await authStorage.getOAuthAccessByCredentialId(account.provider, account.credentialId, options); + if (!resolution) throw new Error("The pinned security OAuth credential is unavailable"); + assertSecurityIdentityMatches(account, resolution); + if (!resolution.ok) throw new Error("The pinned security OAuth credential could not be resolved"); + return resolution; +} + /** * Build a request credential resolver pinned to one durable OAuth row. * @@ -46,17 +87,10 @@ export function createExactSecurityOAuthResolver( } const resolver: ApiKeyResolver = async context => { if (context.lastChance) return undefined; - const resolution = await authStorage.getOAuthAccessByCredentialId(account.provider, account.credentialId, { + const resolution = await resolveExactSecurityOAuthAccess(authStorage, account, { forceRefresh: context.error !== undefined, signal: context.signal, }); - if (!resolution) { - throw new Error("The pinned security OAuth credential is unavailable"); - } - assertIdentityMatches(account, resolution); - if (!resolution.ok) { - throw new Error("The pinned security OAuth credential could not be resolved"); - } return resolution.accessToken; }; return resolver; diff --git a/packages/coding-agent/src/security/cloud.ts b/packages/coding-agent/src/security/cloud.ts new file mode 100644 index 000000000..46533a395 --- /dev/null +++ b/packages/coding-agent/src/security/cloud.ts @@ -0,0 +1,687 @@ +import { createHash } from "node:crypto"; +import type { AuthStorage } from "../session/auth-storage"; +import * as git from "../utils/git"; +import { resolveExactSecurityOAuthAccess } from "./auth"; +import { + createSecurityEvidenceId, + createSecurityFindingFingerprint, + createSecurityFindingId, + createSecurityOccurrenceId, + createSecurityScanId, + type SecurityAccountRef, + type SecurityConfidenceLevel, + type SecurityDispositionStatus, + type SecurityEvidence, + type SecurityFinding, + type SecurityLocation, + type SecurityProducer, + type SecurityProvenance, + type SecurityScanBundle, + type SecuritySeverityLevel, +} from "./contracts"; +import { exportSecurityBundleToSarif } from "./sarif"; +import type { SecurityStore } from "./store"; + +/** + * ChatGPT's Codex Security cloud control plane. This authenticated web-app + * contract is not a public OpenAI API: keep it isolated here, fail closed on + * shape changes, and never use it as a fallback for OMP-native inference. + */ +const DEFAULT_CLOUD_BASE_URL = "https://chatgpt.com/backend-api/aardvark"; +const ALL_FINDING_STATUSES = ["new", "triaged", "in_progress", "fixed", "wontfix", "duplicate", "false_positive"]; +const CLOUD_PRODUCER: SecurityProducer = { + kind: "codex-security-cloud", + name: "Codex Security cloud", + vendor: "OpenAI", +}; + +type JsonObject = Record; + +export type CodexSecurityCloudFetch = (input: string | URL | Request, init?: RequestInit) => Promise; + +export interface CodexSecurityCloudClientOptions { + authStorage: AuthStorage; + account: SecurityAccountRef; + baseUrl?: string; + fetch?: CodexSecurityCloudFetch; +} + +export interface CodexSecurityCloudConfiguration { + id: string; + sourceId?: string; + repositoryId: string; + repositoryUrl: string; + environmentId: string; + state?: string; + currentStep?: string; + scanType?: string; + remainingScans?: number; + totalScans?: number; + createdAt?: string; + updatedAt?: string; +} + +export interface CodexSecurityCloudConfigurationPage { + items: CodexSecurityCloudConfiguration[]; + nextCursor?: string; + totalInAccount?: number; +} + +export interface StartCodexSecurityCloudScanInput { + repositoryId: string; + repositoryUrl: string; + environmentId: string; + lookbackDays?: number | "all"; + maintainerAttackConcerns?: string; + maintainerFocusAreas?: string; + maintainerAdditionalContext?: string; + signal?: AbortSignal; +} + +export interface CodexSecurityCloudStats { + configurationId: string; + sourceConfigurationId?: string; + currentStep?: string; + pendingCommits: number; + finishedCommits: number; + failedCommits: number; + findingCounts: Record; + lastScannedCommit?: string; + lastScannedAt?: string; + updatedAt?: string; +} + +export interface PullCodexSecurityCloudResultsInput { + client: CodexSecurityCloudClient; + configurationId: string; + store: SecurityStore; + signal?: AbortSignal; +} + +function object(value: unknown): JsonObject { + if (!value || typeof value !== "object" || Array.isArray(value)) + throw new Error("Codex Security cloud returned an invalid object"); + return value as JsonObject; +} + +function optionalObject(value: unknown): JsonObject { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonObject) : {}; +} + +function requiredString(value: unknown, field: string): string { + if (typeof value !== "string" || value.length === 0) + throw new Error(`Codex Security cloud response is missing ${field}`); + return value; +} + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function finiteNumber(value: unknown, fallback = 0): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function positiveInteger(value: unknown): number | undefined { + return typeof value === "number" && Number.isInteger(value) && value >= 1 ? value : undefined; +} + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function normalizeConfiguration(value: unknown): CodexSecurityCloudConfiguration { + const raw = object(value); + const scanInput = object(raw.scan_input); + const id = requiredString(raw.hid ?? raw.id, "configuration id"); + const configuration: CodexSecurityCloudConfiguration = { + id, + repositoryId: requiredString(scanInput.repo_id, "repository id"), + repositoryUrl: requiredString(scanInput.repo_url, "repository URL"), + environmentId: requiredString(scanInput.environment_id, "environment id"), + }; + const sourceId = optionalString(raw.id); + if (sourceId && sourceId !== id) configuration.sourceId = sourceId; + const state = optionalString(scanInput.state); + if (state) configuration.state = state; + const currentStep = optionalString(raw.current_step); + if (currentStep) configuration.currentStep = currentStep; + const scanType = optionalString(scanInput.scan_type); + if (scanType) configuration.scanType = scanType; + const remainingScans = typeof raw.scans_remaining === "number" ? raw.scans_remaining : raw.remaining_scans; + if (typeof remainingScans === "number" && Number.isFinite(remainingScans)) + configuration.remainingScans = remainingScans; + if (typeof raw.total_scans === "number" && Number.isFinite(raw.total_scans)) + configuration.totalScans = raw.total_scans; + const createdAt = optionalString(raw.created_at); + if (createdAt) configuration.createdAt = createdAt; + const updatedAt = optionalString(raw.updated_at); + if (updatedAt) configuration.updatedAt = updatedAt; + return configuration; +} + +function jwtSubject(accessToken: string): string { + const payload = accessToken.split(".")[1]; + if (!payload) throw new Error("The selected ChatGPT credential is not a JWT"); + let claims: JsonObject; + try { + claims = object(JSON.parse(Buffer.from(payload, "base64url").toString("utf8"))); + } catch { + throw new Error("The selected ChatGPT credential has an invalid JWT payload"); + } + return requiredString(claims.sub ?? claims.user_id, "authenticated user id"); +} + +export class CodexSecurityCloudHttpError extends Error { + constructor( + readonly status: number, + readonly endpoint: string, + ) { + super(`Codex Security cloud request failed (${status}) at ${endpoint}`); + this.name = "CodexSecurityCloudHttpError"; + } +} + +interface CloudRequestOptions { + method?: "GET" | "POST"; + query?: Record; + body?: JsonObject | ((accessToken: string) => JsonObject); + signal?: AbortSignal; +} + +export class CodexSecurityCloudClient { + readonly #authStorage: AuthStorage; + readonly #account: SecurityAccountRef; + readonly #baseUrl: string; + readonly #fetch: CodexSecurityCloudFetch; + + constructor(options: CodexSecurityCloudClientOptions) { + if (options.account.provider !== "openai-codex") { + throw new Error("Codex Security cloud requires an openai-codex ChatGPT OAuth credential"); + } + this.#authStorage = options.authStorage; + this.#account = options.account; + this.#baseUrl = (options.baseUrl ?? DEFAULT_CLOUD_BASE_URL).replace(/\/$/, ""); + this.#fetch = options.fetch ?? fetch; + } + + async #request(pathname: string, options: CloudRequestOptions = {}): Promise { + const url = new URL(`${this.#baseUrl}/${pathname.replace(/^\//, "")}`); + for (const [key, value] of Object.entries(options.query ?? {})) { + if (value !== undefined) url.searchParams.set(key, String(value)); + } + for (let attempt = 0; attempt < 2; attempt += 1) { + const access = await resolveExactSecurityOAuthAccess(this.#authStorage, this.#account, { + forceRefresh: attempt > 0, + signal: options.signal, + }); + const body = typeof options.body === "function" ? options.body(access.accessToken) : options.body; + const headers: Record = { + Accept: "application/json", + Authorization: `Bearer ${access.accessToken}`, + }; + const accountId = access.accountId ?? this.#account.accountId; + if (accountId) headers["ChatGPT-Account-Id"] = accountId; + if (body) headers["Content-Type"] = "application/json"; + const response = await this.#fetch(url, { + method: options.method ?? "GET", + headers, + body: body ? JSON.stringify(body) : undefined, + signal: options.signal, + }); + if (response.status === 401 && attempt === 0) continue; + if (!response.ok) throw new CodexSecurityCloudHttpError(response.status, url.pathname); + return object(await response.json()); + } + throw new Error("Codex Security cloud authentication refresh failed"); + } + + async listConfigurations( + options: { limit?: number; cursor?: string; signal?: AbortSignal } = {}, + ): Promise { + const raw = await this.#request("scan_configurations", { + query: { limit: options.limit ?? 100, cursor: options.cursor }, + signal: options.signal, + }); + const items = Array.isArray(raw.items) ? raw.items.map(normalizeConfiguration) : []; + const result: CodexSecurityCloudConfigurationPage = { items }; + const nextCursor = optionalString(raw.next_cursor); + if (nextCursor) result.nextCursor = nextCursor; + if (typeof raw.total_in_account === "number") result.totalInAccount = raw.total_in_account; + return result; + } + async listAllConfigurations(signal?: AbortSignal): Promise { + const configurations: CodexSecurityCloudConfiguration[] = []; + let cursor: string | undefined; + do { + const page = await this.listConfigurations({ limit: 500, cursor, signal }); + configurations.push(...page.items); + cursor = page.nextCursor; + } while (cursor); + return configurations; + } + + async getConfiguration(configurationId: string, signal?: AbortSignal): Promise { + let cursor: string | undefined; + do { + const page = await this.listConfigurations({ limit: 500, cursor, signal }); + const found = page.items.find(item => item.id === configurationId || item.sourceId === configurationId); + if (found) return found; + cursor = page.nextCursor; + } while (cursor); + throw new Error(`Unknown Codex Security cloud configuration: ${configurationId}`); + } + + async startScan(input: StartCodexSecurityCloudScanInput): Promise { + if ( + input.lookbackDays !== undefined && + input.lookbackDays !== "all" && + (!Number.isInteger(input.lookbackDays) || input.lookbackDays < 1) + ) { + throw new Error("lookbackDays must be a positive integer or 'all'"); + } + const raw = await this.#request("scan_configurations", { + method: "POST", + signal: input.signal, + body: accessToken => { + const scanInput: JsonObject = { + environment_id: input.environmentId, + lookback_days: input.lookbackDays === "all" ? null : (input.lookbackDays ?? 30), + notification_rules: [], + owner_id: jwtSubject(accessToken), + repo_id: input.repositoryId, + repo_url: input.repositoryUrl, + share_targets: [], + state: "enabled", + }; + if (input.maintainerAttackConcerns) scanInput.maintainer_attack_concerns = input.maintainerAttackConcerns; + if (input.maintainerFocusAreas) scanInput.maintainer_focus_areas = input.maintainerFocusAreas; + if (input.maintainerAdditionalContext) + scanInput.maintainer_additional_context = input.maintainerAdditionalContext; + return { scan_input: scanInput }; + }, + }); + return normalizeConfiguration(raw); + } + + async getStats(configurationId: string, signal?: AbortSignal): Promise { + const raw = await this.#request(`scan_configurations/${encodeURIComponent(configurationId)}/stats`, { signal }); + const result: CodexSecurityCloudStats = { + configurationId, + pendingCommits: finiteNumber(raw.pending_commits), + finishedCommits: finiteNumber(raw.finished_commits), + failedCommits: finiteNumber(raw.failed_commits), + findingCounts: { + critical: finiteNumber(raw.critical_findings), + high: finiteNumber(raw.high_findings), + medium: finiteNumber(raw.medium_findings), + low: finiteNumber(raw.low_findings), + informational: finiteNumber(raw.informational_findings), + }, + }; + const sourceConfigurationId = optionalString(raw.config_id); + if (sourceConfigurationId && sourceConfigurationId !== configurationId) { + result.sourceConfigurationId = sourceConfigurationId; + } + const currentStep = optionalString(raw.current_step); + if (currentStep) result.currentStep = currentStep; + const lastScannedCommit = optionalString(raw.last_scanned_commit_hash); + if (lastScannedCommit) result.lastScannedCommit = lastScannedCommit; + const lastScannedAt = optionalString(raw.last_scanned_commit_dt); + if (lastScannedAt) result.lastScannedAt = lastScannedAt; + const updatedAt = optionalString(raw.updated_at); + if (updatedAt) result.updatedAt = updatedAt; + return result; + } + + async listFindingDetails( + repositoryUrl: string, + configuration: CodexSecurityCloudConfiguration, + signal?: AbortSignal, + ): Promise { + const summaries: JsonObject[] = []; + let cursor: string | undefined; + do { + const page = await this.#request("scan-findings", { + query: { + repo: repositoryUrl, + limit: 500, + cursor, + status: ALL_FINDING_STATUSES.join(","), + }, + signal, + }); + if (Array.isArray(page.items)) summaries.push(...page.items.map(object)); + cursor = optionalString(page.next_cursor); + } while (cursor); + const configurationIds = new Set([configuration.id, configuration.sourceId].filter((id): id is string => !!id)); + const selected = summaries.filter(item => { + const configuredScanId = optionalString(item.configured_scan_id); + return configuredScanId === undefined || configurationIds.has(configuredScanId); + }); + const details: JsonObject[] = []; + for (let index = 0; index < selected.length; index += 8) { + const batch = selected.slice(index, index + 8); + details.push( + ...(await Promise.all( + batch.map(item => { + const id = requiredString(item.hid ?? item.id, "finding id"); + return this.#request(`scan-findings/${encodeURIComponent(id)}`, { signal }); + }), + )), + ); + } + return details; + } +} + +function normalizePath(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const normalized = value.trim().replaceAll("\\", "/").replace(/^\.\//, ""); + if ( + !normalized || + normalized.startsWith("/") || + /^[a-zA-Z]:\//.test(normalized) || + normalized.split("/").includes("..") + ) + return undefined; + return normalized; +} + +function severity(value: unknown): SecuritySeverityLevel { + return value === "critical" || value === "high" || value === "medium" || value === "low" || value === "informational" + ? value + : "informational"; +} + +function confidence(commit: JsonObject): SecurityConfidenceLevel { + const value = commit.validation_confidence; + if (typeof value === "number") return value >= 0.67 ? "high" : value >= 0.34 ? "medium" : "low"; + return commit.validated === true ? "high" : "medium"; +} + +function disposition(value: unknown): SecurityDispositionStatus { + switch (value) { + case "fixed": + return "fixed"; + case "false_positive": + return "false_positive"; + case "wontfix": + return "wont_fix"; + case "duplicate": + return "accepted_risk"; + default: + return "open"; + } +} + +function text(value: unknown): string | undefined { + if (typeof value === "string" && value.length > 0) return value; + return undefined; +} + +function locationsAndEvidence( + commit: JsonObject, + fingerprintSeed: string, +): { locations: SecurityLocation[]; evidence: SecurityEvidence[] } { + const relevantLines = Array.isArray(commit.relevant_lines) ? commit.relevant_lines : []; + const locations: SecurityLocation[] = []; + const evidenceInputs: Array<{ + label: string; + explanation: string; + excerpt?: string; + location?: SecurityLocation; + kind: SecurityEvidence["kind"]; + }> = []; + for (const [index, value] of relevantLines.entries()) { + const line = optionalObject(value); + const sourcePath = normalizePath(line.path); + const startLine = positiveInteger(line.start_line_number); + if (!sourcePath || !startLine) continue; + const location: SecurityLocation = { path: sourcePath, startLine }; + const endLine = positiveInteger(line.end_line_number); + if (endLine && endLine >= startLine) location.endLine = endLine; + locations.push(location); + const entry: (typeof evidenceInputs)[number] = { + kind: "code", + label: `Cloud source evidence ${index + 1}`, + explanation: text(line.comment) ?? "Source location reported by Codex Security cloud.", + location, + }; + const excerpt = text(line.content); + if (excerpt) entry.excerpt = excerpt; + evidenceInputs.push(entry); + } + if (locations.length === 0 && Array.isArray(commit.files_involved)) { + for (const value of commit.files_involved) { + const sourcePath = normalizePath(value); + if (sourcePath) locations.push({ path: sourcePath, startLine: 1, role: "cloud-file" }); + } + } + if (locations.length === 0) + throw new Error("Codex Security cloud finding has no usable repository-relative location"); + const validationReport = text(commit.validation_report) ?? text(commit.fix_check_report); + if (validationReport) { + evidenceInputs.push({ + kind: "validation", + label: "Cloud validation", + explanation: validationReport, + }); + } + const evidence = evidenceInputs.map((item, index) => ({ + id: createSecurityEvidenceId(fingerprintSeed, item.label, index), + ...item, + })); + return { locations, evidence }; +} + +function normalizeFinding( + raw: JsonObject, + scanId: string, + configuration: CodexSecurityCloudConfiguration, + importedAt: string, +): SecurityFinding { + const commit = optionalObject(raw.commit_analysis); + const title = requiredString(commit.title ?? raw.title, "finding title"); + const ruleId = optionalString(commit.rule_id) ?? `codex-security:${sha256(title.trim().toLowerCase()).slice(0, 16)}`; + const category = optionalString(commit.category) ?? "codex-security"; + const cloudId = requiredString(raw.hid ?? raw.id, "finding id"); + const preliminary = locationsAndEvidence(commit, cloudId); + const fingerprint = createSecurityFindingFingerprint({ ruleId, category, locations: preliminary.locations }); + const evidence = preliminary.evidence.map((item, index) => ({ + ...item, + id: createSecurityEvidenceId(fingerprint, item.label, index), + })); + const validationEvidenceIds = evidence.filter(item => item.kind === "validation").map(item => item.id); + const createdAt = optionalString(raw.created_at) ?? importedAt; + const producer = { ...CLOUD_PRODUCER }; + const sourceIds: Record = { cloudConfigurationId: configuration.id, cloudFindingId: cloudId }; + for (const [key, value] of [ + ["cloudSourceFindingId", raw.id], + ["cloudScanId", raw.scan_id], + ["cloudJobId", raw.job_id], + ] as const) { + if (typeof value === "string" && value.length > 0) sourceIds[key] = value; + } + const upstream: NonNullable = { repository: configuration.repositoryUrl }; + const revision = optionalString(commit.commit_hash); + if (revision) upstream.revision = revision; + const provenance: SecurityProvenance = { + producer, + createdAt, + importedAt, + sourceIds, + upstream, + metadata: { + cloudStatus: raw.status ?? null, + bugStatus: commit.bug_status ?? null, + securityRelated: commit.security_related ?? null, + validationMethod: commit.validation_method ?? null, + }, + }; + const findingSeverity: SecurityFinding["severity"] = { level: severity(raw.criticality ?? commit.criticality) }; + const severityRationale = text(raw.criticality_reason); + if (severityRationale) findingSeverity.rationale = severityRationale; + const validation: SecurityFinding["validation"] = { + status: commit.validated === true ? "validated" : "unvalidated", + evidenceIds: validationEvidenceIds, + }; + const validatedAt = optionalString(commit.validation_finished_at); + if (validatedAt) validation.validatedAt = validatedAt; + const validationSummary = text(commit.validation_report); + if (validationSummary) validation.summary = validationSummary; + const findingDisposition: SecurityFinding["disposition"] = { status: disposition(raw.status) }; + const dispositionRationale = text(raw.resolution_reason); + if (dispositionRationale) findingDisposition.rationale = dispositionRationale; + const dispositionUpdatedAt = optionalString(raw.updated_at); + if (dispositionUpdatedAt) findingDisposition.updatedAt = dispositionUpdatedAt; + const finding: SecurityFinding = { + id: createSecurityFindingId(fingerprint), + scanId, + fingerprint, + ruleId, + title, + summary: text(commit.description) ?? text(raw.description) ?? title, + severity: findingSeverity, + confidence: { level: confidence(commit) }, + taxonomy: { category, cwe: [] }, + occurrences: [ + { + id: createSecurityOccurrenceId(fingerprint, preliminary.locations), + locations: preliminary.locations, + evidenceIds: evidence.filter(item => item.kind === "code").map(item => item.id), + }, + ], + evidence, + validation, + disposition: findingDisposition, + provenance, + extensions: { + cloudFindingVersion: raw.version ?? null, + cloudValidationConfidence: commit.validation_confidence ?? null, + }, + }; + const remediation = text(commit.proposed_patch) ?? text(raw.proposed_patch); + if (remediation) finding.remediation = remediation; + return finding; +} + +function repositoryIdentity(value: string): string { + const trimmed = value + .trim() + .replace(/\/+$/, "") + .replace(/\.git$/, ""); + const scpStyle = trimmed.match(/^[^@]+@([^:]+):(.+)$/); + if (scpStyle) return `${scpStyle[1]!.toLowerCase()}/${scpStyle[2]!.replace(/^\/+/, "").toLowerCase()}`; + try { + const parsed = new URL(trimmed); + return `${parsed.hostname.toLowerCase()}/${parsed.pathname.replace(/^\/+/, "").toLowerCase()}`; + } catch { + return trimmed.toLowerCase(); + } +} + +async function assertCloudRepositoryMatchesStore( + configuration: CodexSecurityCloudConfiguration, + store: SecurityStore, + signal?: AbortSignal, +): Promise { + const origin = await git.remote.url(store.repositoryRoot, "origin", signal); + if (!origin) return; + if (repositoryIdentity(origin) !== repositoryIdentity(configuration.repositoryUrl)) { + throw new Error("Codex Security cloud configuration does not match this project's origin remote"); + } +} + +function reportForCloudBundle( + configuration: CodexSecurityCloudConfiguration, + stats: CodexSecurityCloudStats, + findings: SecurityFinding[], +): string { + const lines = [ + "# Codex Security cloud results", + "", + `- Configuration: ${configuration.id}`, + `- Repository: ${configuration.repositoryUrl}`, + `- Current step: ${stats.currentStep ?? configuration.currentStep ?? "unknown"}`, + `- Last scanned commit: ${stats.lastScannedCommit ?? "unknown"}`, + `- Findings imported: ${findings.length}`, + "", + "## Findings", + "", + ]; + for (const finding of findings) lines.push(`- **${finding.severity.level}** ${finding.title} (${finding.id})`); + return `${lines.join("\n")}\n`; +} + +export async function pullCodexSecurityCloudResults( + input: PullCodexSecurityCloudResultsInput, +): Promise { + const importedAt = new Date().toISOString(); + const configuration = await input.client.getConfiguration(input.configurationId, input.signal); + const stats = await input.client.getStats(configuration.id, input.signal); + await assertCloudRepositoryMatchesStore(configuration, input.store, input.signal); + const details = await input.client.listFindingDetails(configuration.repositoryUrl, configuration, input.signal); + const scanId = createSecurityScanId(); + const findings = details.map(item => normalizeFinding(item, scanId, configuration, importedAt)); + const scanSourceIds: Record = { cloudConfigurationId: configuration.id }; + if (configuration.sourceId) scanSourceIds.cloudSourceConfigurationId = configuration.sourceId; + const producer = { ...CLOUD_PRODUCER }; + const revision = stats.lastScannedCommit; + const bundle: SecurityScanBundle = { + scan: { + documentType: "omp-security.scan", + schemaVersion: "1.0", + id: scanId, + projectKey: input.store.projectKey, + status: "completed", + createdAt: importedAt, + completedAt: importedAt, + target: { + kind: "imported", + repositoryRoot: input.store.repositoryRoot, + displayName: configuration.repositoryUrl, + includePaths: [], + excludePaths: [], + treeDigest: sha256(`${configuration.repositoryUrl}\0${revision ?? "unknown"}`), + }, + producer, + provenance: { + producer, + createdAt: configuration.createdAt ?? importedAt, + importedAt, + sourceIds: scanSourceIds, + upstream: { repository: configuration.repositoryUrl }, + metadata: { + cloudCurrentStep: stats.currentStep ?? configuration.currentStep ?? null, + cloudUpdatedAt: stats.updatedAt ?? configuration.updatedAt ?? null, + }, + }, + findingIds: findings.map(item => item.id), + coverage: { + mode: "imported", + completeness: "unknown", + inventoryStrategy: "imported", + includePaths: [], + excludePaths: [], + surfaces: [], + explicitExclusions: [], + deferred: [ + { id: "cloud-coverage", reason: "Cloud coverage receipts are not exposed by the findings API." }, + ], + }, + reportRef: "report.md", + sarifRef: "results.sarif", + }, + findings, + report: reportForCloudBundle(configuration, stats, findings), + }; + if (configuration.createdAt) bundle.scan.startedAt = configuration.createdAt; + if (revision) { + bundle.scan.target.revision = revision; + if (bundle.scan.provenance.upstream) bundle.scan.provenance.upstream.revision = revision; + } + bundle.sarif = exportSecurityBundleToSarif(bundle); + await input.store.putBundle(bundle); + return bundle; +} diff --git a/packages/coding-agent/src/security/contracts/schemas.ts b/packages/coding-agent/src/security/contracts/schemas.ts index edde166e7..7d35357eb 100644 --- a/packages/coding-agent/src/security/contracts/schemas.ts +++ b/packages/coding-agent/src/security/contracts/schemas.ts @@ -4,7 +4,7 @@ const stringRecordSchema = type({ "[string]": "string" }); const unknownRecordSchema = type({ "[string]": "unknown" }); export const securityProducerSchema = type({ - kind: "'omp-native' | 'codex-security-bundle' | 'sarif-import'", + kind: "'omp-native' | 'codex-security-bundle' | 'codex-security-cloud' | 'sarif-import'", name: "string > 0", "version?": "string", "vendor?": "string", diff --git a/packages/coding-agent/src/security/contracts/types.ts b/packages/coding-agent/src/security/contracts/types.ts index 8ecd779b4..613902b40 100644 --- a/packages/coding-agent/src/security/contracts/types.ts +++ b/packages/coding-agent/src/security/contracts/types.ts @@ -5,7 +5,7 @@ export type SecurityCoverageCompleteness = "complete" | "partial" | "unknown"; export type SecurityValidationStatus = "unvalidated" | "validated" | "rejected" | "partial" | "error"; export type SecurityDispositionStatus = "open" | "false_positive" | "accepted_risk" | "fixed" | "wont_fix"; export type SecurityTargetKind = "repository" | "scoped_path" | "ref_diff" | "working_tree" | "imported"; -export type SecurityProducerKind = "omp-native" | "codex-security-bundle" | "sarif-import"; +export type SecurityProducerKind = "omp-native" | "codex-security-bundle" | "codex-security-cloud" | "sarif-import"; export interface SecurityProducer { kind: SecurityProducerKind; diff --git a/packages/coding-agent/src/security/coordinator.ts b/packages/coding-agent/src/security/coordinator.ts index f3e1dfb13..da88584ce 100644 --- a/packages/coding-agent/src/security/coordinator.ts +++ b/packages/coding-agent/src/security/coordinator.ts @@ -15,9 +15,8 @@ import type { AgentSession } from "../session/agent-session"; import type { AuthStorage } from "../session/auth-storage"; import { SessionManager } from "../session/session-manager"; import * as git from "../utils/git"; -import { createExactSecurityOAuthResolver } from "./auth"; +import { createExactSecurityOAuthResolver, selectSecurityAccount } from "./auth"; import type { - SecurityAccountRef, SecurityCoverage, SecurityModelRef, SecurityScan, @@ -224,38 +223,6 @@ function initialBundle( }; } -function resolveAccount( - host: SecurityCoordinatorHost, - model: Model, - requestedCredentialId?: number, -): SecurityAccountRef { - const accounts = host.authStorage.listOAuthAccounts(model.provider, host.sessionId); - const selected = - requestedCredentialId !== undefined - ? accounts.find(account => account.credentialId === requestedCredentialId) - : (accounts.find(account => account.active) ?? (accounts.length === 1 ? accounts[0] : undefined)); - if (!selected) { - if (accounts.length === 0) { - throw new Error(`Security scans require a stored OAuth account for ${model.provider}`); - } - if (requestedCredentialId !== undefined) { - throw new Error(`Security OAuth credential ${requestedCredentialId} is not available for ${model.provider}`); - } - throw new Error( - `Multiple OAuth accounts are available for ${model.provider}; supply credentialId to pin one exact account`, - ); - } - const account: SecurityAccountRef = { - provider: model.provider, - credentialId: selected.credentialId, - }; - if (selected.accountId !== undefined) account.accountId = selected.accountId; - if (selected.email !== undefined) account.email = selected.email; - if (selected.orgId !== undefined) account.organizationId = selected.orgId; - if (selected.orgName !== undefined) account.organizationName = selected.orgName; - return account; -} - async function createDefaultSecuritySession(input: SecurityScanSessionFactoryInput): Promise { const scanSettings = await input.host.settings.cloneForCwd(input.executionRoot); const modelSelector = `${input.model.provider}/${input.model.id}`; @@ -448,7 +415,12 @@ export class SecurityCoordinator { } const model = input.model ?? this.#host.activeModel; if (!model) throw new Error("Security scan preflight requires an active model"); - const account = resolveAccount(this.#host, model, input.credentialId); + const account = selectSecurityAccount( + this.#host.authStorage, + model.provider, + input.credentialId, + this.#host.sessionId, + ); const store = await this.#openStore(this.#host.cwd); const workRoot = path.join(store.projectDirectory, "work"); await fs.mkdir(workRoot, { recursive: true, mode: 0o700 }); diff --git a/packages/coding-agent/src/security/index.ts b/packages/coding-agent/src/security/index.ts index 8e2665c64..35182241b 100644 --- a/packages/coding-agent/src/security/index.ts +++ b/packages/coding-agent/src/security/index.ts @@ -1,4 +1,5 @@ export * from "./auth"; +export * from "./cloud"; export * from "./comparison"; export * from "./contracts"; export * from "./coordinator"; diff --git a/packages/coding-agent/src/slash-commands/helpers/security.ts b/packages/coding-agent/src/slash-commands/helpers/security.ts index 1d8565c0f..a882ce0da 100644 --- a/packages/coding-agent/src/slash-commands/helpers/security.ts +++ b/packages/coding-agent/src/slash-commands/helpers/security.ts @@ -4,6 +4,8 @@ import { prompt } from "@oh-my-pi/pi-utils"; import { parseInternalUrl } from "../../internal-urls/parse"; import { SecurityProtocolHandler } from "../../internal-urls/security-protocol"; import validationRequestPrompt from "../../prompts/security/validate-request.md" with { type: "text" }; +import { selectSecurityAccount } from "../../security/auth"; +import { CodexSecurityCloudClient, pullCodexSecurityCloudResults } from "../../security/cloud"; import type { SecurityDispositionStatus } from "../../security/contracts"; import type { SecurityPreflightInput } from "../../security/coordinator"; import { getSecurityCoordinator } from "../../security/coordinator"; @@ -203,6 +205,130 @@ async function exportResults(runtime: SlashCommandRuntime, rest: string): Promis await runtime.output(`Exported security scan ${scanId} to ${absolute}.`); } +interface CloudCliOptions { + credentialId?: number; + configurationId?: string; + repositoryId?: string; + repositoryUrl?: string; + environmentId?: string; + lookbackDays?: number | "all"; +} + +function parseCloudOptions(rest: string, subcommand: string): CloudCliOptions { + const tokens = parseCommandArgs(rest); + const options: CloudCliOptions = {}; + let positionalConsumed = false; + for (let index = 0; index < tokens.length; index++) { + const token = tokens[index]!; + switch (token) { + case "--credential": + options.credentialId = parsePositiveCredential(requireToken(tokens, ++index, token)); + break; + case "--repo-id": + options.repositoryId = requireToken(tokens, ++index, token); + break; + case "--repo-url": + options.repositoryUrl = requireToken(tokens, ++index, token); + break; + case "--environment": + options.environmentId = requireToken(tokens, ++index, token); + break; + case "--lookback": { + const value = requireToken(tokens, ++index, token); + if (value === "all") { + options.lookbackDays = value; + break; + } + const days = Number(value); + if (!Number.isSafeInteger(days) || days < 1) throw new Error(`Invalid lookback: ${value}`); + options.lookbackDays = days; + break; + } + default: + if (!token.startsWith("--") && !positionalConsumed && (subcommand === "status" || subcommand === "pull")) { + options.configurationId = token; + positionalConsumed = true; + break; + } + throw new Error(`Unknown security cloud option: ${token}`); + } + } + return options; +} + +function cloudClientFor(runtime: SlashCommandRuntime, credentialId?: number): CodexSecurityCloudClient { + const authStorage = runtime.session.modelRegistry.authStorage; + const account = selectSecurityAccount(authStorage, "openai-codex", credentialId, runtime.session.sessionId); + return new CodexSecurityCloudClient({ authStorage, account }); +} + +async function handleCloudCommand(runtime: SlashCommandRuntime, rest: string): Promise { + const { verb, rest: optionsText } = parseSubcommand(rest); + const subcommand = verb || "scans"; + const options = parseCloudOptions(optionsText, subcommand); + const client = cloudClientFor(runtime, options.credentialId); + switch (subcommand) { + case "scans": { + const configurations = await client.listAllConfigurations(); + await runtime.output( + configurations.length === 0 + ? "No Codex Security cloud scan configurations are available for this account." + : configurations + .map(item => + [ + item.id, + item.state ?? "unknown", + item.currentStep ?? "unknown", + `repo=${item.repositoryId}`, + `environment=${item.environmentId}`, + item.repositoryUrl, + item.remainingScans === undefined ? "" : `${item.remainingScans} scan(s) remaining`, + ] + .filter(Boolean) + .join(" "), + ) + .join("\n"), + ); + return; + } + case "start": { + if (!options.repositoryId || !options.repositoryUrl || !options.environmentId) { + throw new Error("cloud start requires --repo-id, --repo-url, and --environment"); + } + const configuration = await client.startScan({ + repositoryId: options.repositoryId, + repositoryUrl: options.repositoryUrl, + environmentId: options.environmentId, + lookbackDays: options.lookbackDays, + }); + await runtime.output( + `Codex Security cloud scan ${configuration.id} started for ${configuration.repositoryUrl}. This consumes cloud scan allowance.`, + ); + return; + } + case "status": { + if (!options.configurationId) throw new Error("cloud status requires a configuration id"); + await runtime.output(JSON.stringify(await client.getStats(options.configurationId), null, 2)); + return; + } + case "pull": { + if (!options.configurationId) throw new Error("cloud pull requires a configuration id"); + const store = await SecurityStore.openForCwd(runtime.cwd); + const bundle = await pullCodexSecurityCloudResults({ + client, + configurationId: options.configurationId, + store, + }); + await runtime.output( + `Imported ${bundle.findings.length} Codex Security cloud finding(s) as security scan ${bundle.scan.id}.`, + ); + return; + } + default: + throw new Error("Usage: /security cloud "); + } +} + async function updateDisposition(runtime: SlashCommandRuntime, rest: string): Promise { const [scanId, findingId, status, ...rationaleParts] = parseCommandArgs(rest); if (!scanId || !findingId || !status) { @@ -304,12 +430,15 @@ export async function handleSecurityCommand( await runtime.output(JSON.stringify(report, null, 2)); return commandConsumed(); } + case "cloud": + await handleCloudCommand(runtime, rest); + return commandConsumed(); case "disposition": await updateDisposition(runtime, rest); return commandConsumed(); default: return usage( - "Usage: /security ", + "Usage: /security ", runtime, ); } diff --git a/packages/coding-agent/src/tools/security-scan.ts b/packages/coding-agent/src/tools/security-scan.ts index 4937d4eb9..9adb38210 100644 --- a/packages/coding-agent/src/tools/security-scan.ts +++ b/packages/coding-agent/src/tools/security-scan.ts @@ -1,6 +1,13 @@ import type { AgentTool, AgentToolResult, ToolTier } from "@oh-my-pi/pi-agent-core"; import { type } from "arktype"; import securityScanDescription from "../prompts/tools/security-scan.md" with { type: "text" }; +import { selectSecurityAccount } from "../security/auth"; +import { + CodexSecurityCloudClient, + type CodexSecurityCloudConfiguration, + type CodexSecurityCloudStats, + pullCodexSecurityCloudResults, +} from "../security/cloud"; import { createSecurityEvidenceId, type SecurityEvidence, type SecurityValidationStatus } from "../security/contracts"; import type { SecurityOperationSnapshot } from "../security/coordinator"; import { getSecurityCoordinator } from "../security/coordinator"; @@ -10,7 +17,8 @@ import type { ToolSession } from "./index"; import { ToolError } from "./tool-errors"; const securityScanSchema = type({ - action: "'preflight' | 'start' | 'status' | 'cancel' | 'validate'", + action: + "'preflight' | 'start' | 'status' | 'cancel' | 'validate' | 'cloud_scans' | 'cloud_start' | 'cloud_status' | 'cloud_pull'", "plan_id?": "string", "operation_id?": "string", "target_kind?": "'repository' | 'scoped_path' | 'ref_diff' | 'working_tree'", @@ -27,6 +35,11 @@ const securityScanSchema = type({ "validation_status?": "'unvalidated' | 'validated' | 'rejected' | 'partial' | 'error'", "validation_summary?": "string", "validation_evidence?": type({ label: "string > 0", explanation: "string" }).array(), + "cloud_configuration_id?": "string", + "repository_id?": "string", + "repository_url?": "string", + "environment_id?": "string", + "lookback_days?": "number.integer >= 1 | 'all'", }); type SecurityScanParams = typeof securityScanSchema.infer; @@ -37,6 +50,10 @@ export interface SecurityScanToolDetails { operation?: SecurityOperationSnapshot; cancelled?: boolean; finding?: { id: string; validationStatus: SecurityValidationStatus }; + cloudConfigurations?: CodexSecurityCloudConfiguration[]; + cloudStats?: CodexSecurityCloudStats; + cloudScan?: { id: string; repositoryUrl: string }; + importedScan?: { id: string; findingCount: number }; } function targetFromParams(params: SecurityScanParams): SecurityTargetRequest { @@ -70,6 +87,17 @@ function requireValue(value: string | undefined, label: string): string { return value.trim(); } +function cloudClientForSession(session: ToolSession, credentialId?: number): CodexSecurityCloudClient { + if (!session.authStorage) throw new ToolError("Codex Security cloud requires the authentication registry"); + const account = selectSecurityAccount( + session.authStorage, + "openai-codex", + credentialId, + session.getSessionId?.() ?? undefined, + ); + return new CodexSecurityCloudClient({ authStorage: session.authStorage, account }); +} + function textResult(text: string, details: SecurityScanToolDetails): AgentToolResult { return { content: [{ type: "text", text }], details }; } @@ -79,7 +107,7 @@ export class SecurityScanTool implements AgentTool + `${item.id} ${item.currentStep ?? "unknown"} repo=${item.repositoryId} environment=${item.environmentId} ${item.repositoryUrl}`, + ) + .join("\n"), + { action: params.action, cloudConfigurations: configurations }, + ); + } + case "cloud_start": { + const configuration = await cloudClientForSession(this.session, params.credential_id).startScan({ + repositoryId: requireValue(params.repository_id, "repository_id"), + repositoryUrl: requireValue(params.repository_url, "repository_url"), + environmentId: requireValue(params.environment_id, "environment_id"), + lookbackDays: params.lookback_days, + signal, + }); + return textResult( + `Codex Security cloud scan ${configuration.id} started for ${configuration.repositoryUrl}. This consumes cloud scan allowance.`, + { + action: params.action, + cloudScan: { id: configuration.id, repositoryUrl: configuration.repositoryUrl }, + }, + ); + } + case "cloud_status": { + const stats = await cloudClientForSession(this.session, params.credential_id).getStats( + requireValue(params.cloud_configuration_id, "cloud_configuration_id"), + signal, + ); + return textResult( + `Codex Security cloud scan ${stats.configurationId}: ${stats.currentStep ?? "unknown"}; ${stats.finishedCommits} finished commit(s), ${stats.pendingCommits} pending.`, + { action: params.action, cloudStats: stats }, + ); + } + case "cloud_pull": { + const store = await SecurityStore.openForCwd(this.session.cwd, { signal }); + const bundle = await pullCodexSecurityCloudResults({ + client: cloudClientForSession(this.session, params.credential_id), + configurationId: requireValue(params.cloud_configuration_id, "cloud_configuration_id"), + store, + signal, + }); + return textResult( + `Imported ${bundle.findings.length} Codex Security cloud finding(s) as security scan ${bundle.scan.id}.`, + { + action: params.action, + importedScan: { id: bundle.scan.id, findingCount: bundle.findings.length }, + }, + ); + } case "validate": { const scanId = requireValue(params.scan_id, "scan_id"); const findingId = requireValue(params.finding_id, "finding_id"); diff --git a/packages/coding-agent/test/security/auth.test.ts b/packages/coding-agent/test/security/auth.test.ts index 0874de8a4..e1c39c20d 100644 --- a/packages/coding-agent/test/security/auth.test.ts +++ b/packages/coding-agent/test/security/auth.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test, vi } from "bun:test"; import type { ApiKeyResolver } from "@oh-my-pi/pi-ai/auth-retry"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; -import { createExactSecurityOAuthResolver } from "../../src/security"; +import { createExactSecurityOAuthResolver, selectSecurityAccount } from "../../src/security"; import type { AuthStorage } from "../../src/session/auth-storage"; function model() { @@ -11,6 +11,21 @@ function model() { } describe("exact security OAuth resolver", () => { + test("selects an explicit credential without account rotation", () => { + const listOAuthAccounts = vi.fn(() => [ + { credentialId: 11, position: 0, active: true, accountId: "workspace-a" }, + { credentialId: 42, position: 1, active: false, accountId: "workspace-b" }, + ]); + const selected = selectSecurityAccount( + { listOAuthAccounts } as unknown as AuthStorage, + "openai-codex", + 42, + "session-a", + ); + expect(selected).toEqual({ provider: "openai-codex", credentialId: 42, accountId: "workspace-b" }); + expect(listOAuthAccounts).toHaveBeenCalledWith("openai-codex", "session-a"); + }); + test("resolves and refreshes only the pinned durable row", async () => { const getOAuthAccessByCredentialId = vi.fn(async (_provider, credentialId, options) => ({ ok: true as const, diff --git a/packages/coding-agent/test/security/cloud.test.ts b/packages/coding-agent/test/security/cloud.test.ts new file mode 100644 index 000000000..8a68adfcf --- /dev/null +++ b/packages/coding-agent/test/security/cloud.test.ts @@ -0,0 +1,302 @@ +import { describe, expect, test } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { $ } from "bun"; +import type { CodexSecurityCloudFetch } from "../../src/security"; +import { + CodexSecurityCloudClient, + CodexSecurityCloudHttpError, + pullCodexSecurityCloudResults, + SecurityStore, +} from "../../src/security"; +import type { AuthStorage } from "../../src/session/auth-storage"; + +const ACCOUNT = { provider: "openai-codex", credentialId: 42, accountId: "workspace-a" } as const; + +function jwt(subject = "user-a"): string { + return `header.${Buffer.from(JSON.stringify({ sub: subject })).toString("base64url")}.signature`; +} + +function json(value: unknown, status = 200): Response { + return new Response(JSON.stringify(value), { status, headers: { "Content-Type": "application/json" } }); +} + +function authStorage(accessToken = jwt()): AuthStorage { + return { + getOAuthAccessByCredentialId: async (_provider: string, credentialId: number) => ({ + ok: true as const, + accessToken, + credentialId, + accountId: "workspace-a", + }), + } as unknown as AuthStorage; +} + +function configuration() { + return { + id: "config-source", + hid: "config-public", + created_at: "2026-07-29T00:00:00.000Z", + updated_at: "2026-07-29T00:05:00.000Z", + current_step: "waiting_for_new_commits", + scans_remaining: 4, + total_scans: 5, + scan_input: { + environment_id: "env-a", + repo_id: "repo-a", + repo_url: "https://github.com/example/repository", + state: "enabled", + }, + }; +} + +describe("Codex Security cloud client", () => { + test("pins one account and refreshes the same credential once after a 401", async () => { + const resolutions: boolean[] = []; + const requests: Array<{ authorization: string | null; accountId: string | null }> = []; + const storage = { + getOAuthAccessByCredentialId: async ( + _provider: string, + credentialId: number, + options: { forceRefresh: boolean }, + ) => { + resolutions.push(options.forceRefresh); + return { + ok: true as const, + accessToken: options.forceRefresh ? "refreshed-token" : "initial-token", + credentialId, + accountId: "workspace-a", + }; + }, + } as unknown as AuthStorage; + let attempt = 0; + const fetchMock: CodexSecurityCloudFetch = async (_input, init) => { + const headers = new Headers(init?.headers); + requests.push({ + authorization: headers.get("Authorization"), + accountId: headers.get("ChatGPT-Account-Id"), + }); + attempt += 1; + return attempt === 1 ? json({}, 401) : json({ items: [configuration()], total_in_account: 1 }); + }; + const client = new CodexSecurityCloudClient({ + authStorage: storage, + account: ACCOUNT, + baseUrl: "https://example.test/backend-api/aardvark", + fetch: fetchMock, + }); + + const page = await client.listConfigurations(); + + expect(resolutions).toEqual([false, true]); + expect(requests).toEqual([ + { authorization: "Bearer initial-token", accountId: "workspace-a" }, + { authorization: "Bearer refreshed-token", accountId: "workspace-a" }, + ]); + expect(page.items[0]).toMatchObject({ + id: "config-public", + sourceId: "config-source", + repositoryId: "repo-a", + environmentId: "env-a", + remainingScans: 4, + }); + }); + + test("creates the documented cloud scan configuration without runtime attribution spoofing", async () => { + let requestUrl = ""; + let requestBody: unknown; + const fetchMock: CodexSecurityCloudFetch = async (input, init) => { + requestUrl = String(input); + requestBody = JSON.parse(String(init?.body)); + return json(configuration()); + }; + const client = new CodexSecurityCloudClient({ + authStorage: authStorage(jwt("user-exact")), + account: ACCOUNT, + baseUrl: "https://example.test/backend-api/aardvark", + fetch: fetchMock, + }); + + await client.startScan({ + repositoryId: "repo-a", + repositoryUrl: "https://github.com/example/repository", + environmentId: "env-a", + lookbackDays: "all", + }); + + expect(requestUrl).toBe("https://example.test/backend-api/aardvark/scan_configurations"); + expect(requestBody).toEqual({ + scan_input: { + environment_id: "env-a", + lookback_days: null, + notification_rules: [], + owner_id: "user-exact", + repo_id: "repo-a", + repo_url: "https://github.com/example/repository", + share_targets: [], + state: "enabled", + }, + }); + expect(JSON.stringify(requestBody)).not.toContain("codex_sdk_ts"); + }); + + test("imports cloud findings into the canonical store and SARIF", async () => { + const repositoryRoot = await fs.mkdtemp(path.join(os.tmpdir(), "omp-cloud-security-repo-")); + const stateRoot = await fs.mkdtemp(path.join(os.tmpdir(), "omp-cloud-security-state-")); + const store = await SecurityStore.open(repositoryRoot, { stateRoot }); + const fetchMock: CodexSecurityCloudFetch = async input => { + const url = new URL(String(input)); + if (url.pathname.endsWith("/scan_configurations")) { + return json({ items: [configuration()], total_in_account: 1 }); + } + if (url.pathname.endsWith("/scan_configurations/config-public/stats")) { + return json({ + config_id: "config-source", + current_step: "waiting_for_new_commits", + pending_commits: 0, + finished_commits: 3, + failed_commits: 0, + critical_findings: 0, + high_findings: 1, + medium_findings: 0, + low_findings: 0, + informational_findings: 0, + last_scanned_commit_hash: "abc123", + last_scanned_commit_dt: "2026-07-29T00:04:00.000Z", + updated_at: "2026-07-29T00:05:00.000Z", + }); + } + if (url.pathname.endsWith("/scan-findings")) { + expect(url.searchParams.get("status")).toBe( + "new,triaged,in_progress,fixed,wontfix,duplicate,false_positive", + ); + return json({ + items: [{ id: "finding-source", hid: "finding-public", configured_scan_id: "config-source" }], + next_cursor: null, + }); + } + if (url.pathname.endsWith("/scan-findings/finding-public")) { + return json({ + id: "finding-source", + hid: "finding-public", + configured_scan_id: "config-source", + scan_id: "cloud-scan-a", + job_id: "cloud-job-a", + created_at: "2026-07-29T00:02:00.000Z", + updated_at: "2026-07-29T00:03:00.000Z", + criticality: "high", + criticality_reason: "Attacker-controlled data reaches a command sink.", + status: "new", + version: 2, + commit_analysis: { + title: "Command injection", + description: "Untrusted input reaches shell execution.", + commit_hash: "abc123", + validated: true, + validation_confidence: 1, + validation_method: "crash", + validation_finished_at: "2026-07-29T00:03:00.000Z", + validation_report: "The exploit reproduced in an isolated environment.", + proposed_patch: "Use argument-array process execution.", + relevant_lines: [ + { + path: "src/command.ts", + start_line_number: 7, + end_line_number: 9, + content: "exec(input)", + comment: "Untrusted input is interpolated into a shell command.", + }, + ], + }, + }); + } + throw new Error(`Unexpected request: ${url}`); + }; + const client = new CodexSecurityCloudClient({ + authStorage: authStorage(), + account: ACCOUNT, + baseUrl: "https://example.test/backend-api/aardvark", + fetch: fetchMock, + }); + + const bundle = await pullCodexSecurityCloudResults({ client, configurationId: "config-public", store }); + + expect(bundle.scan.producer.kind).toBe("codex-security-cloud"); + expect(bundle.scan.target.revision).toBe("abc123"); + expect(bundle.findings).toHaveLength(1); + expect(bundle.findings[0]).toMatchObject({ + title: "Command injection", + severity: { level: "high" }, + confidence: { level: "high" }, + validation: { status: "validated" }, + disposition: { status: "open" }, + remediation: "Use argument-array process execution.", + }); + expect(bundle.findings[0]!.occurrences[0]!.locations[0]).toEqual({ + path: "src/command.ts", + startLine: 7, + endLine: 9, + }); + expect(bundle.findings[0]!.evidence.map(item => item.kind)).toEqual(["code", "validation"]); + expect(bundle.sarif?.runs).toBeArray(); + expect((await store.getBundle(bundle.scan.id))?.findings[0]?.provenance.sourceIds).toMatchObject({ + cloudConfigurationId: "config-public", + cloudFindingId: "finding-public", + cloudScanId: "cloud-scan-a", + }); + expect(JSON.stringify(bundle)).not.toContain("workspace-a"); + }); + + test("refuses to import a cloud configuration for another repository", async () => { + const repositoryRoot = await fs.mkdtemp(path.join(os.tmpdir(), "omp-cloud-security-mismatch-repo-")); + const stateRoot = await fs.mkdtemp(path.join(os.tmpdir(), "omp-cloud-security-mismatch-state-")); + await $`git init --initial-branch=main`.cwd(repositoryRoot).quiet(); + await $`git remote add origin https://github.com/example/different-repository.git`.cwd(repositoryRoot).quiet(); + const store = await SecurityStore.open(repositoryRoot, { stateRoot }); + const fetchMock: CodexSecurityCloudFetch = async input => { + const url = new URL(String(input)); + if (url.pathname.endsWith("/scan_configurations")) { + return json({ items: [configuration()], total_in_account: 1 }); + } + if (url.pathname.endsWith("/scan_configurations/config-public/stats")) { + return json({ + config_id: "config-source", + current_step: "waiting_for_new_commits", + pending_commits: 0, + finished_commits: 1, + failed_commits: 0, + }); + } + throw new Error(`Finding data should not be fetched for a mismatched repository: ${url}`); + }; + const client = new CodexSecurityCloudClient({ + authStorage: authStorage(), + account: ACCOUNT, + baseUrl: "https://example.test/backend-api/aardvark", + fetch: fetchMock, + }); + + await expect(pullCodexSecurityCloudResults({ client, configurationId: "config-public", store })).rejects.toThrow( + "does not match this project's origin remote", + ); + }); + + test("returns a sanitized error without reflecting response bodies", async () => { + const client = new CodexSecurityCloudClient({ + authStorage: authStorage(), + account: ACCOUNT, + baseUrl: "https://example.test/backend-api/aardvark", + fetch: async () => new Response("secret backend detail", { status: 403 }), + }); + let caught: unknown; + try { + await client.listConfigurations(); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(CodexSecurityCloudHttpError); + if (!(caught instanceof Error)) throw new Error("expected cloud HTTP error"); + expect(caught.message).not.toContain("secret backend detail"); + }); +});