Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions sdk/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,9 @@ changing their original severity or evidence. The SDK exposes `classifySeverity(
[severity classification](docs/cli.md#classify-finding-severity) for selection,
checkpointing, and publication behavior.

For canonical records without a saved scan, use `classify-severity --records`
or `classifySeverityRecords()` with [host-provided execution](docs/classify-severity-records.md).

### Suggest finding owners

Use `suggest-owners` or the SDK's `suggestOwners()` to suggest contributors based
Expand Down
89 changes: 89 additions & 0 deletions sdk/typescript/docs/classify-severity-records.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Classify canonical records with host-provided execution

`codex-security classify-severity --records` accepts canonical reports without a
repository, saved scan, synthetic provenance, local Codex login, or filesystem
state. It uses the same bidirectional JSON-RPC transport as [record deduplication](dedupe-records.md).
Use `--records` alone; saved-scan classification remains unchanged.

The SDK exports `classifySeverityRecords`, `classifySeverityRecordsInputSchema`,
and their input, result, and review-request types. The protocol and input have
version `1`. This is an additive capability: pin an artifact containing this
implementation until it is included in a published release. A prior CLI that does
not advertise `--records` in `classify-severity --help` cannot run this protocol.

## Immutable inputs

Send one newline-delimited JSON-RPC request with `method: "run"` and these params:

```ts
{
version: 1,
rubric: { revision: 4, markdown: rubricMarkdown, sha256: rubricSha256 },
model: "your-authorized-model",
effort: "high",
records: [{ id: "canonical-one", revision: 7, findingJson, inputSha256 }]
}
```

`findingJson` is the exact JSON text of an object with nonblank `findingId`,
`title`, and `summary`. `findingId` must equal the canonical `id`. Optional
`severity.level` uses `critical`, `high`, `medium`, `low`, or `informational`.
Other report fields are preserved as evidence. Missing legacy titles or summaries
must be resolved by the caller; do not invent evidence or scan metadata.

Both hashes are lowercase SHA-256 hex of the exact UTF-8 string bytes, without
normalizing newlines, whitespace, Unicode, object key order, or JSON numbers.
Compute `inputSha256` from `findingJson`, not from its parsed/reserialized object.
For example, Node uses `createHash("sha256").update(text, "utf8").digest("hex")`;
Python uses `hashlib.sha256(text.encode("utf-8")).hexdigest()`.
The CLI recomputes both hashes before requesting any review. Rubric revisions are
positive safe integers; record revisions are nonnegative safe integers. Duplicate
canonical IDs and invalid records reject the entire input before host work.
The implementation snapshots caller input before awaiting model execution and
preserves the validated report JSON text verbatim in the model prompt, including
numeric evidence that JavaScript cannot represent exactly.

## Host execution contract

For each record the CLI emits `method: "review.run"`, with a unique request ID
and params containing `requestId`, `stage: "severity-classification"`, explicit
`model` and `effort`, `trustedInstructions`, untrusted report/rubric `prompt`, and
an output JSON `schema`. The host must authorize the requested model and effort,
create a fresh independent review context, preserve the instruction/data boundary,
and disable tools, repository access, browsing, and source inspection.
A managed-agent host can execute this request directly in its existing session
infrastructure; it does not need a repository checkout. The CLI does not provide
credentials, create sessions, or select an authentication fallback.

Reply with the same JSON-RPC ID and `result` set to the schema-conforming assessment
object. Do not wrap the assessment in a string or an array. An assessment contains
`findingId`, `decision` (`assessed` or `excluded`), normalized `level`, original
`rubricLabel`, `rationale`, separate `confidence`, and nullable `reviewTrigger`.
Excluded assessments require null level and label; assessed ones require both.

The host owns model budgets, transport limits, timeouts, and session cancellation.
Use the transport's `cancel` notification with the active run ID and also stop
any remote execution. EOF or a broken pipe means disconnection, not successful
completion. No review is retried automatically, including after an ambiguous
host execution error.

## Results and persistence

A completed result contains `version: 1`, `status: "completed"`, the rubric's
`revision` and `sha256`, and `assessments`. Each assessment replaces model-provided
`findingId` with the verified canonical `id`, `revision`, and `inputSha256`.
The original report and severity are never rewritten.

Invalid or mismatched model output and host failures return `status: "unresolved"`,
an empty `assessments` array, and an error with `recordId` and stable code
`invalid_assessment` or `review_failed`. Earlier assessments in that invocation
are discarded. Invalid input, protocol errors, and cancellation use JSON-RPC error
responses. Exit codes are 0 for completion, 1 for unresolved review, and 2 for
input/protocol failure (130/143 for SIGINT/SIGTERM).

Prefer one canonical record per workflow invocation when the host needs independent
retries and partial success. The host should retain immutable rubric/report
snapshots, compare every echoed binding, and conditionally persist only computed
prioritization fields if the expected canonical version and rubric still apply.
Keep manual edits and original severity intact. Repeating the same inputs binds
results to the same hashes; it does not promise deterministic model answers.
6 changes: 5 additions & 1 deletion sdk/typescript/docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -1059,7 +1059,11 @@ codex-security classify-severity --scan latest --rubric /path/to/policy.md --jso
codex-security classify-severity --scan-dir /path/to/completed-scan --json
```

Use exactly one of `--scan` (ID, prefix, or `latest`) and `--scan-dir`.
For repository-free canonical records, use `classify-severity --records` alone.
The [records protocol](classify-severity-records.md) accepts immutable Markdown and
report snapshots and delegates model execution to the host.

For saved findings, use exactly one of `--scan` (ID, prefix, or `latest`) and `--scan-dir`.
Omitting `--rubric` inherits original severity without model calls. Rubrics and
repeatable `--knowledge-base` context accept text, PDF, DOCX, and directories.
Each finding is classified in a separate read-only turn using its report and
Expand Down
1 change: 1 addition & 0 deletions sdk/typescript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"docs/cli.md",
"docs/findings-service.md",
"docs/dedupe-records.md",
"docs/classify-severity-records.md",
"docs/hosted-scans.md",
"docs/validate-records.md"
],
Expand Down
1 change: 1 addition & 0 deletions sdk/typescript/scripts/check-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ function validatePackagePaths(entries) {
"package/docs/cli.md",
"package/docs/findings-service.md",
"package/docs/dedupe-records.md",
"package/docs/classify-severity-records.md",
"package/docs/hosted-scans.md",
"package/docs/validate-records.md",
"package/schemas/hosted-scan-v2.fixture.json",
Expand Down
3 changes: 3 additions & 0 deletions sdk/typescript/scripts/package-dist-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ const distModules = [
"cli-scan-logs-json",
"cli-signals",
"classify-severity",
"classify-severity-records",
"codeowners",
"classify-scan-severity",
"severity-store",
"severity-review",
"cloud-publish",
"codex-prompt",
"codex-sdk-environment",
Expand Down Expand Up @@ -43,6 +45,7 @@ const distModules = [
"finding-catalogue",
"findings-import",
"github",
"host-records-protocol",
"index",
"import-scan",
"knowledge-base",
Expand Down
214 changes: 214 additions & 0 deletions sdk/typescript/src/classify-severity-records.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import { SCAN_SEVERITIES } from "./scan-settings.js";
import { createHash, randomUUID } from "node:crypto";
import { z } from "zod";
import { abortable } from "./targets.js";
import {
parseSeverityDecision,
severityDecisionSchema,
severityReviewInstructions,
type SeverityDecision,
} from "./severity-review.js";

const text = z
.string()
.refine((value) => value.trim().length > 0, "Must not be blank.");
const digest = z.string().regex(/^[a-f0-9]{64}$/u);
const revision = z.number().int().nonnegative();
const reportSchema = z
.object({
findingId: text,
title: text,
summary: text,
occurrenceId: text.optional(),
severity: z
.object({
level: z.enum(SCAN_SEVERITIES),
rationale: z.string().optional(),
changeConditions: z.string().optional(),
})
.passthrough()
.optional(),
})
.passthrough();

/** Versioned input for repository-free classification of canonical records. */
export const classifySeverityRecordsInputSchema = z
.object({
version: z.literal(1),
rubric: z
.object({
revision: revision.positive(),
markdown: text,
sha256: digest,
})
.strict(),
model: text,
effort: z.enum([
"minimal",
"low",
"medium",
"high",
"xhigh",
"max",
"ultra",
]),
records: z.array(
z
.object({
id: text,
revision,
findingJson: text,
inputSha256: digest,
})
.strict(),
),
})
.strict();

export type ClassifySeverityRecordsInput = z.infer<
typeof classifySeverityRecordsInputSchema
>;

export interface SeverityClassificationReviewRequest {
requestId: string;
stage: "severity-classification";
model: string;
effort: ClassifySeverityRecordsInput["effort"];
trustedInstructions: string;
prompt: string;
schema: unknown;
}

export interface ClassifySeverityRecordsOptions {
reviewRunner: {
run(
request: SeverityClassificationReviewRequest,
options?: { signal?: AbortSignal },
): Promise<unknown>;
};
signal?: AbortSignal;
}

export type CanonicalSeverityAssessment = Omit<
SeverityDecision,
"findingId"
> & {
id: string;
revision: number;
inputSha256: string;
};

interface RecordsResultBinding {
version: 1;
rubric: { revision: number; sha256: string };
}

export type ClassifySeverityRecordsResult = RecordsResultBinding &
(
| { status: "completed"; assessments: CanonicalSeverityAssessment[] }
| {
status: "unresolved";
assessments: [];
error: {
recordId: string;
code: "review_failed" | "invalid_assessment";
message: string;
};
}
);

function sha256(value: string): string {
return createHash("sha256").update(value, "utf8").digest("hex");
}

/**
* Classify immutable host-provided reports. No filesystem, auth, local model,
* repository access, retries, or persistence. The host owns those lifecycles.
*/
export async function classifySeverityRecords(
input: ClassifySeverityRecordsInput,
options: ClassifySeverityRecordsOptions,
): Promise<ClassifySeverityRecordsResult> {
options.signal?.throwIfAborted();
const data = classifySeverityRecordsInputSchema.parse(structuredClone(input));
if (sha256(data.rubric.markdown) !== data.rubric.sha256)
throw new Error("Rubric SHA-256 does not match its exact UTF-8 Markdown.");
const ids = new Set<string>();
for (const record of data.records) {
if (ids.has(record.id))
throw new Error("Canonical record IDs must be unique.");
ids.add(record.id);
if (sha256(record.findingJson) !== record.inputSha256)
throw new Error("Finding SHA-256 does not match its exact UTF-8 JSON.");
const report = reportSchema.parse(JSON.parse(record.findingJson));
if (report.findingId !== record.id)
throw new Error(
"The report findingId must equal its canonical record ID.",
);
}
const binding: RecordsResultBinding = {
version: 1,
rubric: { revision: data.rubric.revision, sha256: data.rubric.sha256 },
};
const assessments: CanonicalSeverityAssessment[] = [];
for (const record of data.records) {
options.signal?.throwIfAborted();
let response: unknown;
try {
response = await abortable(() => {
options.signal?.throwIfAborted();
return options.reviewRunner.run(
{
requestId: randomUUID(),
stage: "severity-classification",
model: data.model,
effort: data.effort,
trustedInstructions: severityReviewInstructions,
prompt: `{"rubric":${JSON.stringify([data.rubric.markdown])},"knowledgeBase":null,"finding":${record.findingJson}}`,
schema: z.toJSONSchema(severityDecisionSchema),
},
{ signal: options.signal },
);
}, options.signal);
} catch (error) {
options.signal?.throwIfAborted();
return {
...binding,
status: "unresolved",
assessments: [],
error: {
recordId: record.id,
code: "review_failed",
message:
error instanceof Error ? error.message : "Host review failed.",
},
};
}
options.signal?.throwIfAborted();
try {
const { findingId: _findingId, ...decision } = parseSeverityDecision(
response,
record.id,
);
assessments.push({
...decision,
id: record.id,
revision: record.revision,
inputSha256: record.inputSha256,
});
} catch (error) {
return {
...binding,
status: "unresolved",
assessments: [],
error: {
recordId: record.id,
code: "invalid_assessment",
message:
error instanceof Error ? error.message : "Invalid assessment.",
},
};
}
}
return { ...binding, status: "completed", assessments };
}
Loading
Loading