feat(security): integrate Codex Security cloud scans
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Extract<OAuthAccessResolution, { ok: true }>> {
|
||||
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;
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
export type CodexSecurityCloudFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
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<SecuritySeverityLevel, number>;
|
||||
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<string, string | number | undefined>;
|
||||
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<JsonObject> {
|
||||
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<string, string> = {
|
||||
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<CodexSecurityCloudConfigurationPage> {
|
||||
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<CodexSecurityCloudConfiguration[]> {
|
||||
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<CodexSecurityCloudConfiguration> {
|
||||
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<CodexSecurityCloudConfiguration> {
|
||||
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<CodexSecurityCloudStats> {
|
||||
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<JsonObject[]> {
|
||||
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<string, string> = { 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<SecurityProvenance["upstream"]> = { 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<void> {
|
||||
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<SecurityScanBundle> {
|
||||
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<string, string> = { 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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<AgentSession> {
|
||||
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 });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./auth";
|
||||
export * from "./cloud";
|
||||
export * from "./comparison";
|
||||
export * from "./contracts";
|
||||
export * from "./coordinator";
|
||||
|
||||
@@ -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<void> {
|
||||
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 <scans|start|status|pull>");
|
||||
}
|
||||
}
|
||||
|
||||
async function updateDisposition(runtime: SlashCommandRuntime, rest: string): Promise<void> {
|
||||
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 <plan|scan|status|cancel|scans|show|import|export|validate|compare|disposition>",
|
||||
"Usage: /security <plan|scan|status|cancel|scans|cloud|show|import|export|validate|compare|disposition>",
|
||||
runtime,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<SecurityScanToolDetails> {
|
||||
return { content: [{ type: "text", text }], details };
|
||||
}
|
||||
@@ -79,7 +107,7 @@ export class SecurityScanTool implements AgentTool<typeof securityScanSchema, Se
|
||||
readonly approval: ToolTier = "exec";
|
||||
readonly label = "Security Scan";
|
||||
readonly loadMode = "discoverable";
|
||||
readonly summary = "Plan, run, and validate an OMP-native software-security scan";
|
||||
readonly summary = "Run OMP-native scans and explicit Codex Security cloud operations";
|
||||
readonly description = securityScanDescription.trim();
|
||||
readonly parameters = securityScanSchema;
|
||||
readonly strict = true;
|
||||
@@ -160,6 +188,65 @@ export class SecurityScanTool implements AgentTool<typeof securityScanSchema, Se
|
||||
},
|
||||
);
|
||||
}
|
||||
case "cloud_scans": {
|
||||
const configurations = await cloudClientForSession(
|
||||
this.session,
|
||||
params.credential_id,
|
||||
).listAllConfigurations(signal);
|
||||
return textResult(
|
||||
configurations.length === 0
|
||||
? "No Codex Security cloud scan configurations are available."
|
||||
: configurations
|
||||
.map(
|
||||
item =>
|
||||
`${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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user