@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,451 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { prompt } from "@oh-my-pi/pi-utils";
4
+ import { parseInternalUrl } from "../../internal-urls/parse";
5
+ import { SecurityProtocolHandler } from "../../internal-urls/security-protocol";
6
+ import validationRequestPrompt from "../../prompts/security/validate-request.md" with { type: "text" };
7
+ import { selectSecurityAccount } from "../../security/auth";
8
+ import { CodexSecurityCloudClient, pullCodexSecurityCloudResults } from "../../security/cloud";
9
+ import type { SecurityDispositionStatus } from "../../security/contracts";
10
+ import type { SecurityPreflightInput } from "../../security/coordinator";
11
+ import { getSecurityCoordinator } from "../../security/coordinator";
12
+ import { importCodexSecurityBundle, importSarifFile } from "../../security/importers";
13
+ import type { SecurityTargetRequest } from "../../security/preflight";
14
+ import { SecurityStore, writeSecurityFileAtomic } from "../../security/store";
15
+ import { shortenPath } from "../../tools/render-utils";
16
+ import { parseCommandArgs } from "../../utils/command-args";
17
+ import type { ParsedSlashCommand, SlashCommandResult, SlashCommandRuntime } from "../types";
18
+ import { commandConsumed, errorMessage, parseSubcommand, usage } from "./parse";
19
+
20
+ interface SecurityPlanCliOptions {
21
+ target: SecurityTargetRequest;
22
+ knowledgeBasePaths: string[];
23
+ outputRoot?: string;
24
+ archiveExisting?: boolean;
25
+ credentialId?: number;
26
+ }
27
+
28
+ const DISPOSITIONS: ReadonlySet<SecurityDispositionStatus> = new Set([
29
+ "open",
30
+ "false_positive",
31
+ "accepted_risk",
32
+ "fixed",
33
+ "wont_fix",
34
+ ]);
35
+
36
+ function coordinatorFor(runtime: SlashCommandRuntime) {
37
+ return getSecurityCoordinator({
38
+ cwd: runtime.cwd,
39
+ settings: runtime.settings,
40
+ authStorage: runtime.session.modelRegistry.authStorage,
41
+ modelRegistry: runtime.session.modelRegistry,
42
+ activeModel: runtime.session.model,
43
+ sessionId: runtime.session.sessionId,
44
+ agentId: runtime.session.getAgentId(),
45
+ asyncJobManager: runtime.session.asyncJobManager,
46
+ });
47
+ }
48
+
49
+ function requireToken(tokens: readonly string[], index: number, flag: string): string {
50
+ const value = tokens[index];
51
+ if (!value || value.startsWith("--")) throw new Error(`${flag} requires a value`);
52
+ return value;
53
+ }
54
+
55
+ function parsePositiveCredential(value: string): number {
56
+ const credentialId = Number(value);
57
+ if (!Number.isSafeInteger(credentialId) || credentialId < 1) throw new Error(`Invalid credential id: ${value}`);
58
+ return credentialId;
59
+ }
60
+
61
+ function parsePlanOptions(rest: string): SecurityPlanCliOptions {
62
+ const tokens = parseCommandArgs(rest);
63
+ const includePaths: string[] = [];
64
+ const excludePaths: string[] = [];
65
+ const knowledgeBasePaths: string[] = [];
66
+ let kind: SecurityTargetRequest["kind"] = "repository";
67
+ let baseRevision: string | undefined;
68
+ let headRevision: string | undefined;
69
+ let outputRoot: string | undefined;
70
+ let archiveExisting = false;
71
+ let credentialId: number | undefined;
72
+ for (let index = 0; index < tokens.length; index++) {
73
+ const token = tokens[index]!;
74
+ switch (token) {
75
+ case "--path":
76
+ includePaths.push(requireToken(tokens, ++index, token));
77
+ // `--path` scopes the target; it never overrides an explicit --diff/--working-tree selection.
78
+ if (kind === "repository") kind = "scoped_path";
79
+ break;
80
+ case "--exclude":
81
+ excludePaths.push(requireToken(tokens, ++index, token));
82
+ break;
83
+ case "--working-tree":
84
+ kind = "working_tree";
85
+ break;
86
+ case "--diff":
87
+ kind = "ref_diff";
88
+ baseRevision = requireToken(tokens, ++index, token);
89
+ headRevision = requireToken(tokens, ++index, token);
90
+ break;
91
+ case "--knowledge-base":
92
+ knowledgeBasePaths.push(requireToken(tokens, ++index, token));
93
+ break;
94
+ case "--output":
95
+ outputRoot = requireToken(tokens, ++index, token);
96
+ break;
97
+ case "--archive-existing":
98
+ archiveExisting = true;
99
+ break;
100
+ case "--credential":
101
+ credentialId = parsePositiveCredential(requireToken(tokens, ++index, token));
102
+ break;
103
+ default:
104
+ throw new Error(`Unknown security plan option: ${token}`);
105
+ }
106
+ }
107
+ const common = { includePaths, excludePaths };
108
+ const target: SecurityTargetRequest =
109
+ kind === "ref_diff"
110
+ ? {
111
+ kind,
112
+ baseRevision: baseRevision ?? "",
113
+ headRevision: headRevision ?? "",
114
+ ...common,
115
+ }
116
+ : kind === "working_tree"
117
+ ? { kind, ...common }
118
+ : kind === "scoped_path"
119
+ ? { kind, ...common }
120
+ : { kind: "repository", ...common };
121
+ return { target, knowledgeBasePaths, outputRoot, archiveExisting, credentialId };
122
+ }
123
+
124
+ async function preflight(runtime: SlashCommandRuntime, rest: string) {
125
+ const options = parsePlanOptions(rest);
126
+ const input: SecurityPreflightInput = {
127
+ target: options.target,
128
+ knowledgeBasePaths: options.knowledgeBasePaths,
129
+ outputRoot: options.outputRoot,
130
+ archiveExisting: options.archiveExisting,
131
+ credentialId: options.credentialId,
132
+ model: runtime.session.model,
133
+ };
134
+ return coordinatorFor(runtime).preflight(input);
135
+ }
136
+
137
+ function scanIdFromInput(value: string): string {
138
+ const trimmed = value.trim();
139
+ const match = trimmed.match(/^security:\/\/scans\/([^/]+)/);
140
+ return match?.[1] ?? trimmed;
141
+ }
142
+
143
+ function findingTarget(value: string): { uri: string; scanId: string; findingId: string } {
144
+ const trimmed = value.trim();
145
+ const uriMatch = trimmed.match(/^security:\/\/scans\/([^/]+)\/findings\/([^/]+)$/);
146
+ if (uriMatch) return { uri: trimmed, scanId: uriMatch[1]!, findingId: uriMatch[2]! };
147
+ const [scanId, findingId] = parseCommandArgs(trimmed);
148
+ if (!scanId || !findingId) throw new Error("validate requires a finding URI or <scan-id> <finding-id>");
149
+ return { uri: `security://scans/${scanId}/findings/${findingId}`, scanId, findingId };
150
+ }
151
+
152
+ async function showResource(runtime: SlashCommandRuntime, rest: string): Promise<void> {
153
+ const raw = rest.trim();
154
+ if (!raw) throw new Error("show requires a scan id or security:// URI");
155
+ const uri = raw.startsWith("security://") ? raw : `security://scans/${scanIdFromInput(raw)}`;
156
+ const handler = new SecurityProtocolHandler(undefined, () => true);
157
+ const resource = await handler.resolve(parseInternalUrl(uri), { cwd: runtime.cwd });
158
+ await runtime.output(resource.content);
159
+ }
160
+
161
+ async function importResults(runtime: SlashCommandRuntime, rest: string): Promise<void> {
162
+ const [source] = parseCommandArgs(rest);
163
+ if (!source) throw new Error("import requires a SARIF file or Codex Security bundle directory");
164
+ const store = await SecurityStore.openForCwd(runtime.cwd);
165
+ const absolute = path.resolve(runtime.cwd, source);
166
+ const stats = await fs.stat(absolute);
167
+ const bundle = stats.isDirectory()
168
+ ? await importCodexSecurityBundle(absolute, { repositoryRoot: store.repositoryRoot })
169
+ : await importSarifFile(absolute, { repositoryRoot: store.repositoryRoot });
170
+ await store.putBundle(bundle);
171
+ await runtime.output(`Imported ${bundle.findings.length} finding(s) as security scan ${bundle.scan.id}.`);
172
+ }
173
+
174
+ async function exportResults(runtime: SlashCommandRuntime, rest: string): Promise<void> {
175
+ const tokens = parseCommandArgs(rest);
176
+ const scanId = tokens[0];
177
+ if (!scanId) throw new Error("export requires <scan-id> --output <path> [--format bundle|sarif|report]");
178
+ let outputPath: string | undefined;
179
+ let format: "bundle" | "sarif" | "report" = "bundle";
180
+ for (let index = 1; index < tokens.length; index++) {
181
+ const token = tokens[index]!;
182
+ if (token === "--output") outputPath = requireToken(tokens, ++index, token);
183
+ else if (token === "--format") {
184
+ const value = requireToken(tokens, ++index, token);
185
+ if (value !== "bundle" && value !== "sarif" && value !== "report") {
186
+ throw new Error(`Unknown export format: ${value}`);
187
+ }
188
+ format = value;
189
+ } else throw new Error(`Unknown export option: ${token}`);
190
+ }
191
+ if (!outputPath) throw new Error("export requires --output <path>");
192
+ const store = await SecurityStore.openForCwd(runtime.cwd);
193
+ const bundle = await store.getBundle(scanIdFromInput(scanId));
194
+ if (!bundle) throw new Error(`Unknown security scan: ${scanId}`);
195
+ let content: string;
196
+ if (format === "sarif") {
197
+ if (!bundle.sarif) throw new Error(`Security scan ${scanId} has no SARIF result`);
198
+ content = `${JSON.stringify(bundle.sarif, null, 2)}\n`;
199
+ } else if (format === "report") {
200
+ if (bundle.report === undefined) throw new Error(`Security scan ${scanId} has no report`);
201
+ content = bundle.report;
202
+ } else {
203
+ content = `${JSON.stringify(bundle, null, 2)}\n`;
204
+ }
205
+ const absolute = path.resolve(runtime.cwd, outputPath);
206
+ await writeSecurityFileAtomic(absolute, content, { hardenParent: false });
207
+ await runtime.output(`Exported security scan ${scanId} to ${shortenPath(absolute)}.`);
208
+ }
209
+
210
+ interface CloudCliOptions {
211
+ credentialId?: number;
212
+ configurationId?: string;
213
+ repositoryId?: string;
214
+ repositoryUrl?: string;
215
+ environmentId?: string;
216
+ lookbackDays?: number | "all";
217
+ }
218
+
219
+ function parseCloudOptions(rest: string, subcommand: string): CloudCliOptions {
220
+ const tokens = parseCommandArgs(rest);
221
+ const options: CloudCliOptions = {};
222
+ let positionalConsumed = false;
223
+ for (let index = 0; index < tokens.length; index++) {
224
+ const token = tokens[index]!;
225
+ switch (token) {
226
+ case "--credential":
227
+ options.credentialId = parsePositiveCredential(requireToken(tokens, ++index, token));
228
+ break;
229
+ case "--repo-id":
230
+ options.repositoryId = requireToken(tokens, ++index, token);
231
+ break;
232
+ case "--repo-url":
233
+ options.repositoryUrl = requireToken(tokens, ++index, token);
234
+ break;
235
+ case "--environment":
236
+ options.environmentId = requireToken(tokens, ++index, token);
237
+ break;
238
+ case "--lookback": {
239
+ const value = requireToken(tokens, ++index, token);
240
+ if (value === "all") {
241
+ options.lookbackDays = value;
242
+ break;
243
+ }
244
+ const days = Number(value);
245
+ if (!Number.isSafeInteger(days) || days < 1) throw new Error(`Invalid lookback: ${value}`);
246
+ options.lookbackDays = days;
247
+ break;
248
+ }
249
+ default:
250
+ if (!token.startsWith("--") && !positionalConsumed && (subcommand === "status" || subcommand === "pull")) {
251
+ options.configurationId = token;
252
+ positionalConsumed = true;
253
+ break;
254
+ }
255
+ throw new Error(`Unknown security cloud option: ${token}`);
256
+ }
257
+ }
258
+ return options;
259
+ }
260
+
261
+ function cloudClientFor(runtime: SlashCommandRuntime, credentialId?: number): CodexSecurityCloudClient {
262
+ const authStorage = runtime.session.modelRegistry.authStorage;
263
+ const account = selectSecurityAccount(authStorage, "openai-codex", credentialId, runtime.session.sessionId);
264
+ return new CodexSecurityCloudClient({ authStorage, account });
265
+ }
266
+
267
+ async function handleCloudCommand(runtime: SlashCommandRuntime, rest: string): Promise<void> {
268
+ const { verb, rest: optionsText } = parseSubcommand(rest);
269
+ const subcommand = verb || "scans";
270
+ const options = parseCloudOptions(optionsText, subcommand);
271
+ const client = cloudClientFor(runtime, options.credentialId);
272
+ switch (subcommand) {
273
+ case "scans": {
274
+ const configurations = await client.listAllConfigurations();
275
+ await runtime.output(
276
+ configurations.length === 0
277
+ ? "No Codex Security cloud scan configurations are available for this account."
278
+ : configurations
279
+ .map(item =>
280
+ [
281
+ item.id,
282
+ item.state ?? "unknown",
283
+ item.currentStep ?? "unknown",
284
+ `repo=${item.repositoryId}`,
285
+ `environment=${item.environmentId}`,
286
+ item.repositoryUrl,
287
+ item.remainingScans === undefined ? "" : `${item.remainingScans} scan(s) remaining`,
288
+ ]
289
+ .filter(Boolean)
290
+ .join(" "),
291
+ )
292
+ .join("\n"),
293
+ );
294
+ return;
295
+ }
296
+ case "start": {
297
+ if (!options.repositoryId || !options.repositoryUrl || !options.environmentId) {
298
+ throw new Error("cloud start requires --repo-id, --repo-url, and --environment");
299
+ }
300
+ const configuration = await client.startScan({
301
+ repositoryId: options.repositoryId,
302
+ repositoryUrl: options.repositoryUrl,
303
+ environmentId: options.environmentId,
304
+ lookbackDays: options.lookbackDays,
305
+ });
306
+ await runtime.output(
307
+ `Codex Security cloud scan ${configuration.id} started for ${configuration.repositoryUrl}. This consumes cloud scan allowance.`,
308
+ );
309
+ return;
310
+ }
311
+ case "status": {
312
+ if (!options.configurationId) throw new Error("cloud status requires a configuration id");
313
+ await runtime.output(JSON.stringify(await client.getStats(options.configurationId), null, 2));
314
+ return;
315
+ }
316
+ case "pull": {
317
+ if (!options.configurationId) throw new Error("cloud pull requires a configuration id");
318
+ const store = await SecurityStore.openForCwd(runtime.cwd);
319
+ const bundle = await pullCodexSecurityCloudResults({
320
+ client,
321
+ configurationId: options.configurationId,
322
+ store,
323
+ });
324
+ await runtime.output(
325
+ `Imported ${bundle.findings.length} Codex Security cloud finding(s) as security scan ${bundle.scan.id}.`,
326
+ );
327
+ return;
328
+ }
329
+ default:
330
+ throw new Error("Usage: /security cloud <scans|start|status|pull>");
331
+ }
332
+ }
333
+
334
+ async function updateDisposition(runtime: SlashCommandRuntime, rest: string): Promise<void> {
335
+ const [scanId, findingId, status, ...rationaleParts] = parseCommandArgs(rest);
336
+ if (!scanId || !findingId || !status) {
337
+ throw new Error("disposition requires <scan-id> <finding-id> <status> [rationale]");
338
+ }
339
+ if (!DISPOSITIONS.has(status as SecurityDispositionStatus)) throw new Error(`Unknown disposition: ${status}`);
340
+ const rationale = rationaleParts.join(" ").trim();
341
+ if (status !== "open" && !rationale) throw new Error(`${status} requires a rationale`);
342
+ const store = await SecurityStore.openForCwd(runtime.cwd);
343
+ const finding = await store.updateDisposition(scanId, findingId, {
344
+ status: status as SecurityDispositionStatus,
345
+ rationale: rationale || undefined,
346
+ updatedAt: new Date().toISOString(),
347
+ actor: "operator",
348
+ });
349
+ await runtime.output(`Finding ${finding.id} disposition is now ${finding.disposition.status}.`);
350
+ }
351
+
352
+ export async function handleSecurityCommand(
353
+ command: ParsedSlashCommand,
354
+ runtime: SlashCommandRuntime,
355
+ ): Promise<SlashCommandResult> {
356
+ if (!runtime.settings.get("security.enabled")) {
357
+ return usage("Security is disabled. Enable security.enabled before using /security.", runtime);
358
+ }
359
+ const { verb, rest } = parseSubcommand(command.args);
360
+ try {
361
+ switch (verb || "scans") {
362
+ case "plan": {
363
+ const plan = await preflight(runtime, rest);
364
+ await runtime.output(`Security plan ${plan.id} is ready. Fingerprint: ${plan.fingerprint}.`);
365
+ return commandConsumed();
366
+ }
367
+ case "scan": {
368
+ const coordinator = coordinatorFor(runtime);
369
+ const planId = rest.trim().startsWith("secplan_") ? rest.trim() : (await preflight(runtime, rest)).id;
370
+ const operation = await coordinator.start({ planId });
371
+ await runtime.output(`Security scan ${operation.scanId} started as ${operation.operationId}.`);
372
+ return commandConsumed();
373
+ }
374
+ case "status": {
375
+ const coordinator = coordinatorFor(runtime);
376
+ const operationId = rest.trim();
377
+ if (operationId) {
378
+ const operation = await coordinator.status(operationId);
379
+ if (!operation) throw new Error(`Unknown security operation: ${operationId}`);
380
+ await runtime.output(JSON.stringify(operation, null, 2));
381
+ } else {
382
+ await runtime.output(JSON.stringify(await coordinator.listOperations(), null, 2));
383
+ }
384
+ return commandConsumed();
385
+ }
386
+ case "cancel": {
387
+ const operationId = rest.trim();
388
+ if (!operationId) throw new Error("cancel requires an operation id");
389
+ await runtime.output(
390
+ (await coordinatorFor(runtime).cancel(operationId))
391
+ ? `Cancellation requested for ${operationId}.`
392
+ : `No cancellable security operation ${operationId}.`,
393
+ );
394
+ return commandConsumed();
395
+ }
396
+ case "scans": {
397
+ const scans = await (await SecurityStore.openForCwd(runtime.cwd)).listScans();
398
+ await runtime.output(
399
+ scans.length === 0
400
+ ? "No security scans are stored for this project."
401
+ : scans
402
+ .map(scan => `${scan.id} ${scan.status} ${scan.findingCount} finding(s) ${scan.producer.name}`)
403
+ .join("\n"),
404
+ );
405
+ return commandConsumed();
406
+ }
407
+ case "show":
408
+ await showResource(runtime, rest);
409
+ return commandConsumed();
410
+ case "import":
411
+ await importResults(runtime, rest);
412
+ return commandConsumed();
413
+ case "export":
414
+ await exportResults(runtime, rest);
415
+ return commandConsumed();
416
+ case "validate": {
417
+ const target = findingTarget(rest);
418
+ return {
419
+ prompt: prompt
420
+ .render(validationRequestPrompt, {
421
+ findingUri: target.uri,
422
+ scanId: target.scanId,
423
+ findingId: target.findingId,
424
+ })
425
+ .trim(),
426
+ };
427
+ }
428
+ case "compare": {
429
+ const [beforeScanId, afterScanId] = parseCommandArgs(rest);
430
+ if (!beforeScanId || !afterScanId) throw new Error("compare requires <before-scan-id> <after-scan-id>");
431
+ const report = await (await SecurityStore.openForCwd(runtime.cwd)).compare(beforeScanId, afterScanId);
432
+ await runtime.output(JSON.stringify(report, null, 2));
433
+ return commandConsumed();
434
+ }
435
+ case "cloud":
436
+ await handleCloudCommand(runtime, rest);
437
+ return commandConsumed();
438
+ case "disposition":
439
+ await updateDisposition(runtime, rest);
440
+ return commandConsumed();
441
+ default:
442
+ return usage(
443
+ "Usage: /security <plan|scan|status|cancel|scans|cloud|show|import|export|validate|compare|disposition>",
444
+ runtime,
445
+ );
446
+ }
447
+ } catch (error) {
448
+ await runtime.output(`Security: ${errorMessage(error)}`);
449
+ return commandConsumed();
450
+ }
451
+ }
@@ -528,6 +528,8 @@ export interface BuildSystemPromptOptions {
528
528
  workspaceTree?: WorkspaceTree | Promise<WorkspaceTree>;
529
529
  /** Whether the local memory://root summary is active. */
530
530
  memoryRootEnabled?: boolean;
531
+ /** Whether the read-only security:// resource namespace is active. */
532
+ securityEnabled?: boolean;
531
533
  /** Active model identifier (e.g. "anthropic/claude-opus-4") used by prompt policy and optionally surfaced. */
532
534
  model?: string;
533
535
  /** Whether to surface `model` in the workstation block. Model-specific prompt policy still uses it. Default: true. */
@@ -585,6 +587,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
585
587
  secretsEnabled = false,
586
588
  workspaceTree: providedWorkspaceTree,
587
589
  memoryRootEnabled = false,
590
+ securityEnabled = false,
588
591
  model,
589
592
  includeModelInPrompt = true,
590
593
  personality = "default",
@@ -866,6 +869,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
866
869
  taskIrcEnabled,
867
870
  secretsEnabled,
868
871
  hasMemoryRoot: memoryRootEnabled,
872
+ securityEnabled,
869
873
  hasObsidian: hasObsidian(),
870
874
  includeWorkspaceTree,
871
875
  renderMermaid,
@@ -12,6 +12,7 @@ import agentFrontmatterTemplate from "../prompts/agents/frontmatter.md" with { t
12
12
  import librarianMd from "../prompts/agents/librarian.md" with { type: "text" };
13
13
  import reviewerMd from "../prompts/agents/reviewer.md" with { type: "text" };
14
14
  import scoutMd from "../prompts/agents/scout.md" with { type: "text" };
15
+ import securityReviewerMd from "../prompts/agents/security-reviewer.md" with { type: "text" };
15
16
  import taskMd from "../prompts/agents/task.md" with { type: "text" };
16
17
  import { AUTO_THINKING } from "../thinking";
17
18
 
@@ -44,6 +45,7 @@ const EMBEDDED_AGENT_DEFS: EmbeddedAgentDef[] = [
44
45
  { fileName: "scout.md", template: scoutMd },
45
46
  { fileName: "designer.md", template: designerMd },
46
47
  { fileName: "reviewer.md", template: reviewerMd },
48
+ { fileName: "security-reviewer.md", template: securityReviewerMd },
47
49
  { fileName: "librarian.md", template: librarianMd },
48
50
  {
49
51
  fileName: "task.md",
@@ -306,6 +306,8 @@ export interface ExecutorOptions {
306
306
  cwd: string;
307
307
  /** Additional workspace directories to seed on the subagent session (multi-root). */
308
308
  additionalDirectories?: string[];
309
+ /** Exact provider credential resolver inherited from the parent session. */
310
+ getApiKey?: CreateAgentSessionOptions["getApiKey"];
309
311
  worktree?: string;
310
312
  agent: AgentDefinition;
311
313
  task: string;
@@ -2783,6 +2785,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2783
2785
  additionalDirectories: worktree !== undefined ? undefined : options.additionalDirectories,
2784
2786
  authStorage,
2785
2787
  modelRegistry,
2788
+ getApiKey: options.getApiKey,
2786
2789
  settings: subagentSettings,
2787
2790
  model,
2788
2791
  modelPattern: model || modelOverride === undefined ? undefined : modelPatterns,
@@ -373,10 +373,12 @@ function buildExecutorOptions(
373
373
  getArtifactsDir: session.getArtifactsDir ?? (() => null),
374
374
  getSessionId: session.getSessionId ?? (() => null),
375
375
  };
376
- const enableMCP = !policy.planMode && (session.enableMCP ?? true);
376
+ const restrictToolNames = policy.planMode || session.restrictToolNames === true;
377
+ const enableMCP = !restrictToolNames && (session.enableMCP ?? true);
377
378
  return {
378
379
  cwd: session.cwd,
379
380
  additionalDirectories: session.additionalDirectories,
381
+ getApiKey: session.getApiKey,
380
382
  agent: policy.effectiveAgent,
381
383
  task: renderSubagentPrompt(request.assignment),
382
384
  assignment: request.assignment.trim(),
@@ -408,7 +410,7 @@ function buildExecutorOptions(
408
410
  enableLsp: policy.enableLsp,
409
411
  enableIrc: policy.enableIrc,
410
412
  maxRuntimeMs: request.maxRuntimeMs,
411
- restrictToolNames: policy.planMode,
413
+ restrictToolNames,
412
414
  keepAlive: request.keepAlive,
413
415
  signal: request.signal,
414
416
  eventBus: session.eventBus,
@@ -424,8 +426,8 @@ function buildExecutorOptions(
424
426
  workspaceTree: session.workspaceTree,
425
427
  promptTemplates: session.promptTemplates,
426
428
  rules: session.rules,
427
- preloadedExtensionPaths: policy.planMode ? [] : session.extensionPaths,
428
- preloadedCustomToolPaths: policy.planMode ? [] : session.customToolPaths,
429
+ preloadedExtensionPaths: restrictToolNames ? [] : session.extensionPaths,
430
+ preloadedCustomToolPaths: restrictToolNames ? [] : session.customToolPaths,
429
431
  localProtocolOptions,
430
432
  parentArtifactManager: session.getArtifactManager?.() ?? undefined,
431
433
  parentHindsightSessionState: session.getHindsightSessionState?.(),
@@ -16,6 +16,7 @@ export const BUILTIN_TOOL_NAMES = [
16
16
  "computer",
17
17
  "checkpoint",
18
18
  "rewind",
19
+ "security_scan",
19
20
  "task",
20
21
  "hub",
21
22
  "todo",
@@ -1,5 +1,5 @@
1
1
  import type { Clipboard, InMemorySnapshotStore } from "@oh-my-pi/hashline";
2
- import type { AgentTelemetryConfig, AgentTool } from "@oh-my-pi/pi-agent-core";
2
+ import type { AgentOptions, AgentTelemetryConfig, AgentTool } from "@oh-my-pi/pi-agent-core";
3
3
  import type { FetchImpl, ImageContent, Model, ServiceTierByFamily, ToolChoice } from "@oh-my-pi/pi-ai";
4
4
  import { logger } from "@oh-my-pi/pi-utils";
5
5
  import type { AsyncJobManager } from "../async/job-manager";
@@ -60,6 +60,7 @@ import { MemoryRetainTool } from "./memory-retain";
60
60
  import { wrapToolWithMetaNotice } from "./output-meta";
61
61
  import { ReadTool } from "./read";
62
62
  import type { PlanProposalHandler } from "./resolve";
63
+ import { SecurityScanTool } from "./security-scan";
63
64
  import { type TodoPhase, TodoTool } from "./todo";
64
65
  import { WriteTool } from "./write";
65
66
  import { isMountableUnderXdev, type XdevState } from "./xdev";
@@ -99,6 +100,7 @@ export * from "./read";
99
100
  export * from "./report-tool-issue";
100
101
  export * from "./resolve";
101
102
  export * from "./review";
103
+ export * from "./security-scan";
102
104
  export * from "./todo";
103
105
  export * from "./tts";
104
106
  export * from "./vibe";
@@ -162,6 +164,8 @@ export interface ToolSession {
162
164
  suppressSpawnAdvisory?: boolean;
163
165
  /** Optional fetch implementation injected into the URL read pipeline (tests, proxies). Defaults to global fetch. */
164
166
  fetch?: FetchImpl;
167
+ /** Provider credential resolver forwarded unchanged to restricted child sessions. */
168
+ getApiKey?: AgentOptions["getApiKey"];
165
169
  /** Skip subprocess-kernel availability checks and warmup */
166
170
  skipPythonPreflight?: boolean;
167
171
  /** Pre-loaded context files (AGENTS.md, etc) */
@@ -191,6 +195,8 @@ export interface ToolSession {
191
195
  customToolPaths?: ToolPathWithSource[];
192
196
  /** Whether LSP integrations are enabled */
193
197
  enableLsp?: boolean;
198
+ /** Whether LSP is limited to navigation and diagnostics. */
199
+ lspReadOnly?: boolean;
194
200
  /** Whether this invocation may expose IRC. `false` removes it even for subagents. */
195
201
  enableIrc?: boolean;
196
202
  /**
@@ -398,6 +404,7 @@ export type ToolFactory = (session: ToolSession) => Tool | null | Promise<Tool |
398
404
  */
399
405
  export const BUILTIN_TOOLS: Record<BuiltinToolName, ToolFactory> = {
400
406
  read: s => new ReadTool(s),
407
+ security_scan: s => new SecurityScanTool(s),
401
408
  bash: s => new BashTool(s),
402
409
  edit: s => new EditTool(s),
403
410
  ast_grep: s => new AstGrepTool(s),
@@ -587,6 +594,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
587
594
  if (name === "ast_edit") return session.settings.get("astEdit.enabled");
588
595
  if (name === "inspect_image") return isInspectImageToolActive(session);
589
596
  if (name === "web_search") return session.settings.get("web_search.enabled");
597
+ if (name === "security_scan") return session.settings.get("security.enabled");
590
598
  if (name === "ask") return session.settings.get("ask.enabled");
591
599
  if (name === "browser") return session.settings.get("browser.enabled");
592
600
  if (name === "computer") return session.settings.get("computer.enabled");
@@ -42,6 +42,7 @@ const INTERNAL_SCHEMES_WITH_SELECTORS: Record<string, true> = {
42
42
  omp: true,
43
43
  pr: true,
44
44
  rule: true,
45
+ security: true,
45
46
  skill: true,
46
47
  ssh: true,
47
48
  vault: true,
@@ -60,6 +61,7 @@ const TOP_LEVEL_INTERNAL_URL_PREFIXES = [
60
61
  "artifact://",
61
62
  "skill://",
62
63
  "rule://",
64
+ "security://",
63
65
  "local://",
64
66
  "mcp://",
65
67
  "ssh://",
@@ -117,6 +119,7 @@ function normalizeAtPrefix(filePath: string): string {
117
119
  withoutAt.startsWith("artifact://") ||
118
120
  withoutAt.startsWith("skill://") ||
119
121
  withoutAt.startsWith("rule://") ||
122
+ withoutAt.startsWith("security://") ||
120
123
  withoutAt.startsWith("local:") ||
121
124
  withoutAt.startsWith("mcp://")
122
125
  ) {