@oh-my-pi/pi-coding-agent 17.2.0 → 17.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/{CHANGELOG-be1f2t8h.md → CHANGELOG-dj46zzrm.md} +18 -0
  3. package/dist/cli.js +3340 -3195
  4. package/dist/types/config/model-discovery.d.ts +12 -0
  5. package/dist/types/config/settings-schema.d.ts +10 -0
  6. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +23 -0
  7. package/dist/types/internal-urls/index.d.ts +1 -0
  8. package/dist/types/internal-urls/security-protocol.d.ts +15 -0
  9. package/dist/types/internal-urls/types.d.ts +1 -1
  10. package/dist/types/sdk.d.ts +17 -0
  11. package/dist/types/security/auth.d.ts +30 -0
  12. package/dist/types/security/cloud.d.ts +79 -0
  13. package/dist/types/security/comparison.d.ts +49 -0
  14. package/dist/types/security/contracts/ids.d.ts +15 -0
  15. package/dist/types/security/contracts/index.d.ts +4 -0
  16. package/dist/types/security/contracts/schemas.d.ts +660 -0
  17. package/dist/types/security/contracts/types.d.ts +234 -0
  18. package/dist/types/security/contracts/validation.d.ts +5 -0
  19. package/dist/types/security/coordinator.d.ts +100 -0
  20. package/dist/types/security/importers/codex-security.d.ts +7 -0
  21. package/dist/types/security/importers/index.d.ts +2 -0
  22. package/dist/types/security/importers/sarif.d.ts +9 -0
  23. package/dist/types/security/index.d.ts +13 -0
  24. package/dist/types/security/preflight.d.ts +61 -0
  25. package/dist/types/security/provenance.d.ts +24 -0
  26. package/dist/types/security/publication.d.ts +78 -0
  27. package/dist/types/security/remediation.d.ts +27 -0
  28. package/dist/types/security/resource-output.d.ts +8 -0
  29. package/dist/types/security/sarif.d.ts +2 -0
  30. package/dist/types/security/store.d.ts +40 -0
  31. package/dist/types/slash-commands/helpers/security.d.ts +2 -0
  32. package/dist/types/system-prompt.d.ts +2 -0
  33. package/dist/types/task/executor.d.ts +3 -0
  34. package/dist/types/tools/builtin-names.d.ts +1 -1
  35. package/dist/types/tools/index.d.ts +6 -1
  36. package/dist/types/tools/security-scan.d.ts +96 -0
  37. package/package.json +12 -12
  38. package/scripts/security-compare.ts +40 -0
  39. package/src/config/model-discovery.ts +32 -5
  40. package/src/config/model-registry.ts +4 -0
  41. package/src/config/settings-schema.ts +12 -0
  42. package/src/eval/py/prelude.py +7 -3
  43. package/src/extensibility/legacy-pi-coding-agent-shim.ts +53 -0
  44. package/src/internal-urls/index.ts +1 -0
  45. package/src/internal-urls/router.ts +4 -1
  46. package/src/internal-urls/security-protocol.ts +261 -0
  47. package/src/internal-urls/types.ts +1 -1
  48. package/src/lsp/index.ts +3 -0
  49. package/src/modes/rpc/host-uris.ts +6 -0
  50. package/src/prompts/agents/security-reviewer.md +75 -0
  51. package/src/prompts/security/scan-coordinator.md +7 -0
  52. package/src/prompts/security/scan-request.md +21 -0
  53. package/src/prompts/security/validate-request.md +8 -0
  54. package/src/prompts/system/system-prompt.md +3 -0
  55. package/src/prompts/tools/security-publish.md +1 -0
  56. package/src/prompts/tools/security-scan.md +1 -0
  57. package/src/sdk.ts +34 -6
  58. package/src/security/auth.ts +98 -0
  59. package/src/security/cloud.ts +686 -0
  60. package/src/security/comparison.ts +255 -0
  61. package/src/security/contracts/ids.ts +111 -0
  62. package/src/security/contracts/index.ts +4 -0
  63. package/src/security/contracts/schemas.ts +201 -0
  64. package/src/security/contracts/types.ts +254 -0
  65. package/src/security/contracts/validation.ts +65 -0
  66. package/src/security/coordinator.ts +708 -0
  67. package/src/security/importers/codex-security.ts +387 -0
  68. package/src/security/importers/index.ts +2 -0
  69. package/src/security/importers/sarif.ts +357 -0
  70. package/src/security/index.ts +13 -0
  71. package/src/security/preflight.ts +405 -0
  72. package/src/security/provenance.ts +106 -0
  73. package/src/security/publication.ts +326 -0
  74. package/src/security/remediation.ts +93 -0
  75. package/src/security/resource-output.ts +50 -0
  76. package/src/security/sarif.ts +78 -0
  77. package/src/security/store.ts +430 -0
  78. package/src/slash-commands/builtin-registry.ts +21 -0
  79. package/src/slash-commands/helpers/security.ts +451 -0
  80. package/src/system-prompt.ts +4 -0
  81. package/src/task/agents.ts +2 -0
  82. package/src/task/executor.ts +3 -0
  83. package/src/task/structured-subagent.ts +6 -4
  84. package/src/tools/builtin-names.ts +1 -0
  85. package/src/tools/index.ts +9 -1
  86. package/src/tools/path-utils.ts +3 -0
  87. package/src/tools/security-scan.ts +287 -0
@@ -0,0 +1,430 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { getSecurityProjectDir, isEnoent } from "@oh-my-pi/pi-utils";
4
+ import { withFileLock } from "../config/file-lock";
5
+ import * as git from "../utils/git";
6
+ import { compareSecurityLineage } from "./comparison";
7
+ import type {
8
+ SecurityComparisonReport,
9
+ SecurityDisposition,
10
+ SecurityEvidence,
11
+ SecurityFinding,
12
+ SecurityScan,
13
+ SecurityScanBundle,
14
+ SecurityScanPlan,
15
+ SecurityValidation,
16
+ } from "./contracts";
17
+ import {
18
+ encodeSecurityProjectKey,
19
+ parseSecurityFinding,
20
+ parseSecurityScan,
21
+ parseSecurityScanBundle,
22
+ parseSecurityScanPlan,
23
+ } from "./contracts";
24
+ import { createPublicSecurityScan, redactPrivateSecurityMetadata } from "./provenance";
25
+ import { exportSecurityBundleToSarif } from "./sarif";
26
+
27
+ const STORE_SCHEMA_VERSION = 1;
28
+ const PRIVATE_DIRECTORY_MODE = 0o700;
29
+ const PRIVATE_FILE_MODE = 0o600;
30
+
31
+ /** Serialize project-store read/modify/write transactions within this process. */
32
+ const SECURITY_STORE_WRITE_CHAINS = new Map<string, Promise<unknown>>();
33
+
34
+ async function withSecurityStoreWrite<T>(key: string, operation: () => Promise<T>): Promise<T> {
35
+ const lockTarget = path.join(key, "index.json");
36
+ const run = (SECURITY_STORE_WRITE_CHAINS.get(key) ?? Promise.resolve()).then(() =>
37
+ withFileLock(lockTarget, operation, { staleMs: 60_000, retries: 200, retryDelayMs: 50 }),
38
+ );
39
+ const guarded = run.catch(() => undefined);
40
+ SECURITY_STORE_WRITE_CHAINS.set(key, guarded);
41
+ try {
42
+ return await run;
43
+ } finally {
44
+ if (SECURITY_STORE_WRITE_CHAINS.get(key) === guarded) SECURITY_STORE_WRITE_CHAINS.delete(key);
45
+ }
46
+ }
47
+
48
+ interface SecurityStoreIndex {
49
+ schemaVersion: 1;
50
+ projectKey: string;
51
+ repositoryRoot: string;
52
+ scanIds: string[];
53
+ planIds: string[];
54
+ updatedAt: string;
55
+ }
56
+
57
+ export interface SecurityScanSummary {
58
+ id: string;
59
+ status: SecurityScan["status"];
60
+ createdAt: string;
61
+ completedAt?: string;
62
+ producer: SecurityScan["producer"];
63
+ findingCount: number;
64
+ target: SecurityScan["target"];
65
+ }
66
+
67
+ export interface SecurityStoreOptions {
68
+ stateRoot?: string;
69
+ signal?: AbortSignal;
70
+ }
71
+
72
+ async function ensurePrivateDirectory(directory: string): Promise<void> {
73
+ await fs.mkdir(directory, { recursive: true, mode: PRIVATE_DIRECTORY_MODE });
74
+ if (process.platform !== "win32") await fs.chmod(directory, PRIVATE_DIRECTORY_MODE);
75
+ }
76
+
77
+ export interface SecurityFileWriteOptions {
78
+ hardenParent?: boolean;
79
+ }
80
+
81
+ export async function writeSecurityFileAtomic(
82
+ filePath: string,
83
+ content: string,
84
+ options: SecurityFileWriteOptions = {},
85
+ ): Promise<void> {
86
+ if (options.hardenParent ?? true) {
87
+ await ensurePrivateDirectory(path.dirname(filePath));
88
+ } else {
89
+ await fs.mkdir(path.dirname(filePath), { recursive: true, mode: PRIVATE_DIRECTORY_MODE });
90
+ }
91
+ const temporaryPath = `${filePath}.${process.pid}.${Bun.randomUUIDv7()}.tmp`;
92
+ try {
93
+ // Bun.write cannot create exclusively; `wx` keeps concurrent atomic writers from sharing a temp file.
94
+ await fs.writeFile(temporaryPath, content, { encoding: "utf-8", mode: PRIVATE_FILE_MODE, flag: "wx" });
95
+ if (process.platform !== "win32") await fs.chmod(temporaryPath, PRIVATE_FILE_MODE);
96
+ try {
97
+ await fs.rename(temporaryPath, filePath);
98
+ } catch (error) {
99
+ const code = error instanceof Error && "code" in error ? String(error.code) : "";
100
+ if (process.platform !== "win32" || (code !== "EEXIST" && code !== "EPERM")) throw error;
101
+ await fs.rm(filePath, { force: true });
102
+ await fs.rename(temporaryPath, filePath);
103
+ }
104
+ } finally {
105
+ await fs.rm(temporaryPath, { force: true }).catch(() => undefined);
106
+ }
107
+ }
108
+ export async function writeSecurityBundleToDirectory(directory: string, input: SecurityScanBundle): Promise<void> {
109
+ const bundle = parseSecurityScanBundle(input);
110
+ const root = path.resolve(directory);
111
+ await ensurePrivateDirectory(root);
112
+ await writeSecurityFileAtomic(path.join(root, "findings.json"), `${JSON.stringify(bundle.findings, null, 2)}\n`);
113
+ if (bundle.report !== undefined) {
114
+ await writeSecurityFileAtomic(path.join(root, "report.md"), bundle.report);
115
+ } else {
116
+ await fs.rm(path.join(root, "report.md"), { force: true });
117
+ }
118
+ if (bundle.sarif !== undefined) {
119
+ await writeSecurityFileAtomic(path.join(root, "results.sarif"), `${JSON.stringify(bundle.sarif, null, 2)}\n`);
120
+ } else {
121
+ await fs.rm(path.join(root, "results.sarif"), { force: true });
122
+ }
123
+ await writeSecurityFileAtomic(
124
+ path.join(root, "provenance.json"),
125
+ `${JSON.stringify(redactPrivateSecurityMetadata(bundle.scan.provenance), null, 2)}\n`,
126
+ );
127
+ // The scan manifest is the commit marker for directory consumers.
128
+ await writeSecurityFileAtomic(
129
+ path.join(root, "scan.json"),
130
+ `${JSON.stringify(createPublicSecurityScan(bundle.scan), null, 2)}\n`,
131
+ );
132
+ }
133
+
134
+ async function readJsonFile(filePath: string): Promise<unknown> {
135
+ return JSON.parse(await Bun.file(filePath).text()) as unknown;
136
+ }
137
+
138
+ async function readOptionalText(filePath: string): Promise<string | undefined> {
139
+ try {
140
+ return await Bun.file(filePath).text();
141
+ } catch (error) {
142
+ if (isEnoent(error)) return undefined;
143
+ throw error;
144
+ }
145
+ }
146
+
147
+ export class SecurityStore {
148
+ readonly #repositoryRoot: string;
149
+ readonly #projectKey: string;
150
+ readonly #projectDirectory: string;
151
+
152
+ constructor(repositoryRoot: string, projectKey: string, projectDirectory: string) {
153
+ this.#repositoryRoot = repositoryRoot;
154
+ this.#projectKey = projectKey;
155
+ this.#projectDirectory = projectDirectory;
156
+ }
157
+
158
+ static async open(repositoryRoot: string, options: SecurityStoreOptions = {}): Promise<SecurityStore> {
159
+ const canonicalRoot = await fs.realpath(path.resolve(repositoryRoot)).catch(() => path.resolve(repositoryRoot));
160
+ const projectKey = encodeSecurityProjectKey(canonicalRoot);
161
+ const projectDirectory = options.stateRoot
162
+ ? path.join(path.resolve(options.stateRoot), projectKey)
163
+ : getSecurityProjectDir(projectKey);
164
+ await ensurePrivateDirectory(projectDirectory);
165
+ const store = new SecurityStore(canonicalRoot, projectKey, projectDirectory);
166
+ await withSecurityStoreWrite(projectDirectory, () => store.#ensureIndex());
167
+ return store;
168
+ }
169
+
170
+ static async openForCwd(cwd: string, options: SecurityStoreOptions = {}): Promise<SecurityStore> {
171
+ const resolvedCwd = path.resolve(cwd);
172
+ const repositoryRoot = (await git.repo.root(resolvedCwd, options.signal)) ?? resolvedCwd;
173
+ return SecurityStore.open(repositoryRoot, options);
174
+ }
175
+
176
+ get repositoryRoot(): string {
177
+ return this.#repositoryRoot;
178
+ }
179
+
180
+ get projectKey(): string {
181
+ return this.#projectKey;
182
+ }
183
+
184
+ get projectDirectory(): string {
185
+ return this.#projectDirectory;
186
+ }
187
+
188
+ #scanDirectory(scanId: string): string {
189
+ if (!/^secscan_[a-zA-Z0-9]+$/.test(scanId)) throw new Error(`Invalid security scan id: ${scanId}`);
190
+ return path.join(this.#projectDirectory, "scans", scanId);
191
+ }
192
+
193
+ #planPath(planId: string): string {
194
+ if (!/^secplan_[a-zA-Z0-9]+$/.test(planId)) throw new Error(`Invalid security plan id: ${planId}`);
195
+ return path.join(this.#projectDirectory, "plans", `${planId}.json`);
196
+ }
197
+
198
+ #indexPath(): string {
199
+ return path.join(this.#projectDirectory, "index.json");
200
+ }
201
+
202
+ async #ensureIndex(): Promise<void> {
203
+ try {
204
+ await this.#readIndex();
205
+ } catch (error) {
206
+ if (!isEnoent(error)) throw error;
207
+ await this.#writeIndex({
208
+ schemaVersion: STORE_SCHEMA_VERSION,
209
+ projectKey: this.#projectKey,
210
+ repositoryRoot: this.#repositoryRoot,
211
+ scanIds: [],
212
+ planIds: [],
213
+ updatedAt: new Date().toISOString(),
214
+ });
215
+ }
216
+ }
217
+
218
+ async #readIndex(): Promise<SecurityStoreIndex> {
219
+ const value = (await readJsonFile(this.#indexPath())) as Partial<SecurityStoreIndex>;
220
+ if (value.schemaVersion !== STORE_SCHEMA_VERSION || value.projectKey !== this.#projectKey) {
221
+ throw new Error(`Unsupported security store index at ${this.#indexPath()}`);
222
+ }
223
+ if (!Array.isArray(value.scanIds) || !value.scanIds.every(id => typeof id === "string")) {
224
+ throw new Error(`Invalid security store scan index at ${this.#indexPath()}`);
225
+ }
226
+ if (
227
+ value.planIds !== undefined &&
228
+ (!Array.isArray(value.planIds) || !value.planIds.every(id => typeof id === "string"))
229
+ ) {
230
+ throw new Error(`Invalid security store plan index at ${this.#indexPath()}`);
231
+ }
232
+ return { ...value, planIds: value.planIds ?? [] } as SecurityStoreIndex;
233
+ }
234
+
235
+ async #writeIndex(index: SecurityStoreIndex): Promise<void> {
236
+ await writeSecurityFileAtomic(this.#indexPath(), `${JSON.stringify(index, null, 2)}\n`);
237
+ }
238
+
239
+ async #putBundleUnlocked(input: SecurityScanBundle): Promise<void> {
240
+ const bundle = parseSecurityScanBundle(input);
241
+ if (bundle.scan.projectKey !== this.#projectKey) {
242
+ throw new Error(`Security scan project key ${bundle.scan.projectKey} does not match ${this.#projectKey}`);
243
+ }
244
+ const scanDirectory = this.#scanDirectory(bundle.scan.id);
245
+ await ensurePrivateDirectory(scanDirectory);
246
+ await writeSecurityFileAtomic(
247
+ path.join(scanDirectory, "findings.json"),
248
+ `${JSON.stringify(bundle.findings, null, 2)}\n`,
249
+ );
250
+ if (bundle.report !== undefined) {
251
+ await writeSecurityFileAtomic(path.join(scanDirectory, "report.md"), bundle.report);
252
+ } else {
253
+ await fs.rm(path.join(scanDirectory, "report.md"), { force: true });
254
+ }
255
+ if (bundle.sarif !== undefined) {
256
+ await writeSecurityFileAtomic(
257
+ path.join(scanDirectory, "results.sarif"),
258
+ `${JSON.stringify(bundle.sarif, null, 2)}\n`,
259
+ );
260
+ } else {
261
+ await fs.rm(path.join(scanDirectory, "results.sarif"), { force: true });
262
+ }
263
+ // The scan manifest is the commit marker: readers never observe it before
264
+ // its findings and optional artifacts have been written atomically.
265
+ await writeSecurityFileAtomic(path.join(scanDirectory, "scan.json"), `${JSON.stringify(bundle.scan, null, 2)}\n`);
266
+ const index = await this.#readIndex();
267
+ if (!index.scanIds.includes(bundle.scan.id)) index.scanIds.push(bundle.scan.id);
268
+ index.updatedAt = new Date().toISOString();
269
+ await this.#writeIndex(index);
270
+ }
271
+
272
+ async putBundle(input: SecurityScanBundle): Promise<void> {
273
+ await withSecurityStoreWrite(this.#projectDirectory, () => this.#putBundleUnlocked(input));
274
+ }
275
+
276
+ async putPlan(input: SecurityScanPlan): Promise<void> {
277
+ await withSecurityStoreWrite(this.#projectDirectory, async () => {
278
+ const plan = parseSecurityScanPlan(input);
279
+ if (plan.repositoryRoot !== this.#repositoryRoot) {
280
+ throw new Error(`Security plan repository ${plan.repositoryRoot} does not match ${this.#repositoryRoot}`);
281
+ }
282
+ await writeSecurityFileAtomic(this.#planPath(plan.id), `${JSON.stringify(plan, null, 2)}\n`);
283
+ const index = await this.#readIndex();
284
+ if (!index.planIds.includes(plan.id)) index.planIds.push(plan.id);
285
+ index.updatedAt = new Date().toISOString();
286
+ await this.#writeIndex(index);
287
+ });
288
+ }
289
+
290
+ async getPlan(planId: string): Promise<SecurityScanPlan | null> {
291
+ try {
292
+ return parseSecurityScanPlan(await readJsonFile(this.#planPath(planId)));
293
+ } catch (error) {
294
+ if (isEnoent(error)) return null;
295
+ throw error;
296
+ }
297
+ }
298
+
299
+ async listPlans(): Promise<SecurityScanPlan[]> {
300
+ const index = await this.#readIndex();
301
+ const plans: SecurityScanPlan[] = [];
302
+ for (const planId of [...index.planIds].reverse()) {
303
+ const plan = await this.getPlan(planId);
304
+ if (plan) plans.push(plan);
305
+ }
306
+ return plans;
307
+ }
308
+
309
+ async getScan(scanId: string): Promise<SecurityScan | null> {
310
+ try {
311
+ return parseSecurityScan(await readJsonFile(path.join(this.#scanDirectory(scanId), "scan.json")));
312
+ } catch (error) {
313
+ if (isEnoent(error)) return null;
314
+ throw error;
315
+ }
316
+ }
317
+
318
+ async #getBundleUnlocked(scanId: string): Promise<SecurityScanBundle | null> {
319
+ const scan = await this.getScan(scanId);
320
+ if (!scan) return null;
321
+ const rawFindings = await readJsonFile(path.join(this.#scanDirectory(scanId), "findings.json"));
322
+ if (!Array.isArray(rawFindings)) throw new Error(`Invalid findings list for ${scanId}`);
323
+ const findings = rawFindings.map(parseSecurityFinding);
324
+ const report = await readOptionalText(path.join(this.#scanDirectory(scanId), "report.md"));
325
+ const sarifText = await readOptionalText(path.join(this.#scanDirectory(scanId), "results.sarif"));
326
+ const bundle: SecurityScanBundle = { scan, findings };
327
+ if (report !== undefined) bundle.report = report;
328
+ if (sarifText !== undefined) bundle.sarif = JSON.parse(sarifText) as Record<string, unknown>;
329
+ return parseSecurityScanBundle(bundle);
330
+ }
331
+
332
+ async getBundle(scanId: string): Promise<SecurityScanBundle | null> {
333
+ return withSecurityStoreWrite(this.#projectDirectory, () => this.#getBundleUnlocked(scanId));
334
+ }
335
+
336
+ async listScans(): Promise<SecurityScanSummary[]> {
337
+ const index = await this.#readIndex();
338
+ const summaries: SecurityScanSummary[] = [];
339
+ for (const scanId of [...index.scanIds].reverse()) {
340
+ const bundle = await this.getBundle(scanId);
341
+ if (!bundle) continue;
342
+ summaries.push({
343
+ id: bundle.scan.id,
344
+ status: bundle.scan.status,
345
+ createdAt: bundle.scan.createdAt,
346
+ completedAt: bundle.scan.completedAt,
347
+ producer: bundle.scan.producer,
348
+ findingCount: bundle.findings.length,
349
+ target: bundle.scan.target,
350
+ });
351
+ }
352
+ return summaries;
353
+ }
354
+
355
+ async getFinding(scanId: string, findingId: string): Promise<SecurityFinding | null> {
356
+ const bundle = await this.getBundle(scanId);
357
+ return bundle?.findings.find(finding => finding.id === findingId) ?? null;
358
+ }
359
+
360
+ async updateDisposition(
361
+ scanId: string,
362
+ findingId: string,
363
+ disposition: SecurityDisposition,
364
+ ): Promise<SecurityFinding> {
365
+ return withSecurityStoreWrite(this.#projectDirectory, async () => {
366
+ const bundle = await this.#getBundleUnlocked(scanId);
367
+ if (!bundle) throw new Error(`Unknown security scan: ${scanId}`);
368
+ const index = bundle.findings.findIndex(finding => finding.id === findingId);
369
+ if (index < 0) throw new Error(`Unknown security finding: ${findingId}`);
370
+ const canonicalDisposition: SecurityDisposition = { status: disposition.status };
371
+ if (disposition.rationale !== undefined) canonicalDisposition.rationale = disposition.rationale;
372
+ if (disposition.updatedAt !== undefined) canonicalDisposition.updatedAt = disposition.updatedAt;
373
+ if (disposition.actor !== undefined) canonicalDisposition.actor = disposition.actor;
374
+ const updated = { ...bundle.findings[index], disposition: canonicalDisposition };
375
+ bundle.findings[index] = parseSecurityFinding(updated);
376
+ if (bundle.sarif !== undefined) bundle.sarif = exportSecurityBundleToSarif(bundle);
377
+ await this.#putBundleUnlocked(bundle);
378
+ return bundle.findings[index];
379
+ });
380
+ }
381
+
382
+ async updateValidation(
383
+ scanId: string,
384
+ findingId: string,
385
+ validation: SecurityValidation,
386
+ evidence: readonly SecurityEvidence[] = [],
387
+ ): Promise<SecurityFinding> {
388
+ return withSecurityStoreWrite(this.#projectDirectory, async () => {
389
+ const bundle = await this.#getBundleUnlocked(scanId);
390
+ if (!bundle) throw new Error(`Unknown security scan: ${scanId}`);
391
+ const index = bundle.findings.findIndex(finding => finding.id === findingId);
392
+ if (index < 0) throw new Error(`Unknown security finding: ${findingId}`);
393
+ const finding = bundle.findings[index];
394
+ const evidenceById = new Map(finding.evidence.map(item => [item.id, item]));
395
+ for (const item of evidence) evidenceById.set(item.id, item);
396
+ const canonicalValidation: SecurityValidation = {
397
+ status: validation.status,
398
+ evidenceIds: [...new Set(validation.evidenceIds)],
399
+ };
400
+ if (validation.summary !== undefined) canonicalValidation.summary = validation.summary;
401
+ if (validation.validatedAt !== undefined) canonicalValidation.validatedAt = validation.validatedAt;
402
+ for (const evidenceId of canonicalValidation.evidenceIds) {
403
+ if (!evidenceById.has(evidenceId)) {
404
+ throw new Error(`Unknown security validation evidence: ${evidenceId}`);
405
+ }
406
+ }
407
+ bundle.findings[index] = parseSecurityFinding({
408
+ ...finding,
409
+ evidence: [...evidenceById.values()],
410
+ validation: canonicalValidation,
411
+ });
412
+ if (bundle.sarif !== undefined) bundle.sarif = exportSecurityBundleToSarif(bundle);
413
+ await this.#putBundleUnlocked(bundle);
414
+ return bundle.findings[index];
415
+ });
416
+ }
417
+
418
+ async compare(beforeScanId: string, afterScanId: string): Promise<SecurityComparisonReport> {
419
+ const before = await this.getBundle(beforeScanId);
420
+ const after = await this.getBundle(afterScanId);
421
+ if (!before) throw new Error(`Unknown security scan: ${beforeScanId}`);
422
+ if (!after) throw new Error(`Unknown security scan: ${afterScanId}`);
423
+ return compareSecurityLineage(before, after);
424
+ }
425
+
426
+ async storeDigest(): Promise<string> {
427
+ const index = await this.#readIndex();
428
+ return Bun.SHA256.hash(JSON.stringify(index), "hex");
429
+ }
430
+ }
@@ -63,6 +63,7 @@ import { createMarketplaceManager } from "./helpers/marketplace-manager";
63
63
  import { handleMcpAcp } from "./helpers/mcp";
64
64
  import { commandConsumed, errorMessage, parseSlashCommand, parseSubcommand, usage } from "./helpers/parse";
65
65
  import { describeRedeemOutcome, type ResetUsageAccount, toResetUsageAccounts } from "./helpers/reset-usage";
66
+ import { handleSecurityCommand } from "./helpers/security";
66
67
  import { matchSessionPinAccounts, toSessionPinAccounts } from "./helpers/session-pin";
67
68
  import { handleSshAcp } from "./helpers/ssh";
68
69
  import { launchStatsDashboard, parseStatsDashboardArgs } from "./helpers/stats-dashboard";
@@ -374,6 +375,26 @@ function formatWorkspaceDirectories(runtime: SlashCommandRuntime, note?: string)
374
375
  }
375
376
 
376
377
  const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
378
+ {
379
+ name: "security",
380
+ description: "Plan, run, inspect, import, and compare OMP-native security scans",
381
+ allowArgs: true,
382
+ acpInputHint: "<plan|scan|status|cancel|scans|show|import|export|validate|compare|disposition>",
383
+ subcommands: [
384
+ { name: "plan", description: "Create an immutable security scan plan" },
385
+ { name: "scan", description: "Start a planned or newly planned native scan" },
386
+ { name: "status", description: "Show native scan operation status" },
387
+ { name: "cancel", description: "Cancel a running native scan" },
388
+ { name: "scans", description: "List stored project security scans" },
389
+ { name: "show", description: "Render a scan or security:// resource" },
390
+ { name: "import", description: "Import SARIF or a Codex Security bundle" },
391
+ { name: "export", description: "Export a canonical bundle, SARIF, or report" },
392
+ { name: "validate", description: "Validate one finding with OMP-native tools" },
393
+ { name: "compare", description: "Compare finding lineage across two scans" },
394
+ { name: "disposition", description: "Set a finding disposition with rationale" },
395
+ ],
396
+ handle: handleSecurityCommand,
397
+ },
377
398
  {
378
399
  name: "settings",
379
400
  description: "Open settings menu",