@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.
- package/CHANGELOG.md +18 -0
- package/dist/{CHANGELOG-be1f2t8h.md → CHANGELOG-dj46zzrm.md} +18 -0
- package/dist/cli.js +3340 -3195
- package/dist/types/config/model-discovery.d.ts +12 -0
- package/dist/types/config/settings-schema.d.ts +10 -0
- package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +23 -0
- package/dist/types/internal-urls/index.d.ts +1 -0
- package/dist/types/internal-urls/security-protocol.d.ts +15 -0
- package/dist/types/internal-urls/types.d.ts +1 -1
- package/dist/types/sdk.d.ts +17 -0
- package/dist/types/security/auth.d.ts +30 -0
- package/dist/types/security/cloud.d.ts +79 -0
- package/dist/types/security/comparison.d.ts +49 -0
- package/dist/types/security/contracts/ids.d.ts +15 -0
- package/dist/types/security/contracts/index.d.ts +4 -0
- package/dist/types/security/contracts/schemas.d.ts +660 -0
- package/dist/types/security/contracts/types.d.ts +234 -0
- package/dist/types/security/contracts/validation.d.ts +5 -0
- package/dist/types/security/coordinator.d.ts +100 -0
- package/dist/types/security/importers/codex-security.d.ts +7 -0
- package/dist/types/security/importers/index.d.ts +2 -0
- package/dist/types/security/importers/sarif.d.ts +9 -0
- package/dist/types/security/index.d.ts +13 -0
- package/dist/types/security/preflight.d.ts +61 -0
- package/dist/types/security/provenance.d.ts +24 -0
- package/dist/types/security/publication.d.ts +78 -0
- package/dist/types/security/remediation.d.ts +27 -0
- package/dist/types/security/resource-output.d.ts +8 -0
- package/dist/types/security/sarif.d.ts +2 -0
- package/dist/types/security/store.d.ts +40 -0
- package/dist/types/slash-commands/helpers/security.d.ts +2 -0
- package/dist/types/system-prompt.d.ts +2 -0
- package/dist/types/task/executor.d.ts +3 -0
- package/dist/types/tools/builtin-names.d.ts +1 -1
- package/dist/types/tools/index.d.ts +6 -1
- package/dist/types/tools/security-scan.d.ts +96 -0
- package/package.json +12 -12
- package/scripts/security-compare.ts +40 -0
- package/src/config/model-discovery.ts +32 -5
- package/src/config/model-registry.ts +4 -0
- package/src/config/settings-schema.ts +12 -0
- package/src/eval/py/prelude.py +7 -3
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +53 -0
- package/src/internal-urls/index.ts +1 -0
- package/src/internal-urls/router.ts +4 -1
- package/src/internal-urls/security-protocol.ts +261 -0
- package/src/internal-urls/types.ts +1 -1
- package/src/lsp/index.ts +3 -0
- package/src/modes/rpc/host-uris.ts +6 -0
- package/src/prompts/agents/security-reviewer.md +75 -0
- package/src/prompts/security/scan-coordinator.md +7 -0
- package/src/prompts/security/scan-request.md +21 -0
- package/src/prompts/security/validate-request.md +8 -0
- package/src/prompts/system/system-prompt.md +3 -0
- package/src/prompts/tools/security-publish.md +1 -0
- package/src/prompts/tools/security-scan.md +1 -0
- package/src/sdk.ts +34 -6
- package/src/security/auth.ts +98 -0
- package/src/security/cloud.ts +686 -0
- package/src/security/comparison.ts +255 -0
- package/src/security/contracts/ids.ts +111 -0
- package/src/security/contracts/index.ts +4 -0
- package/src/security/contracts/schemas.ts +201 -0
- package/src/security/contracts/types.ts +254 -0
- package/src/security/contracts/validation.ts +65 -0
- package/src/security/coordinator.ts +708 -0
- package/src/security/importers/codex-security.ts +387 -0
- package/src/security/importers/index.ts +2 -0
- package/src/security/importers/sarif.ts +357 -0
- package/src/security/index.ts +13 -0
- package/src/security/preflight.ts +405 -0
- package/src/security/provenance.ts +106 -0
- package/src/security/publication.ts +326 -0
- package/src/security/remediation.ts +93 -0
- package/src/security/resource-output.ts +50 -0
- package/src/security/sarif.ts +78 -0
- package/src/security/store.ts +430 -0
- package/src/slash-commands/builtin-registry.ts +21 -0
- package/src/slash-commands/helpers/security.ts +451 -0
- package/src/system-prompt.ts +4 -0
- package/src/task/agents.ts +2 -0
- package/src/task/executor.ts +3 -0
- package/src/task/structured-subagent.ts +6 -4
- package/src/tools/builtin-names.ts +1 -0
- package/src/tools/index.ts +9 -1
- package/src/tools/path-utils.ts +3 -0
- 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
|
+
}
|
package/src/system-prompt.ts
CHANGED
|
@@ -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,
|
package/src/task/agents.ts
CHANGED
|
@@ -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",
|
package/src/task/executor.ts
CHANGED
|
@@ -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
|
|
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
|
|
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:
|
|
428
|
-
preloadedCustomToolPaths:
|
|
429
|
+
preloadedExtensionPaths: restrictToolNames ? [] : session.extensionPaths,
|
|
430
|
+
preloadedCustomToolPaths: restrictToolNames ? [] : session.customToolPaths,
|
|
429
431
|
localProtocolOptions,
|
|
430
432
|
parentArtifactManager: session.getArtifactManager?.() ?? undefined,
|
|
431
433
|
parentHindsightSessionState: session.getHindsightSessionState?.(),
|
package/src/tools/index.ts
CHANGED
|
@@ -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");
|
package/src/tools/path-utils.ts
CHANGED
|
@@ -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
|
) {
|