@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,326 @@
|
|
|
1
|
+
import { type } from "arktype";
|
|
2
|
+
import type { ToolDefinition } from "../extensibility/extensions";
|
|
3
|
+
import securityPublishDescription from "../prompts/tools/security-publish.md" with { type: "text" };
|
|
4
|
+
import type {
|
|
5
|
+
SecurityCoverage,
|
|
6
|
+
SecurityEvidence,
|
|
7
|
+
SecurityFinding,
|
|
8
|
+
SecurityLocation,
|
|
9
|
+
SecurityScan,
|
|
10
|
+
SecurityScanBundle,
|
|
11
|
+
SecurityScanPlan,
|
|
12
|
+
} from "./contracts";
|
|
13
|
+
import {
|
|
14
|
+
createSecurityEvidenceId,
|
|
15
|
+
createSecurityFindingFingerprint,
|
|
16
|
+
createSecurityFindingId,
|
|
17
|
+
createSecurityOccurrenceId,
|
|
18
|
+
} from "./contracts";
|
|
19
|
+
import { pathMatchesSecurityScope } from "./preflight";
|
|
20
|
+
import { createNativeSecurityProducer, createNativeSecurityProvenance } from "./provenance";
|
|
21
|
+
import { exportSecurityBundleToSarif } from "./sarif";
|
|
22
|
+
import { type SecurityStore, writeSecurityBundleToDirectory } from "./store";
|
|
23
|
+
|
|
24
|
+
const publishLocationSchema = type({
|
|
25
|
+
path: type("string > 0").describe("repository-relative source path"),
|
|
26
|
+
start_line: type("number.integer >= 1").describe("1-indexed first source line"),
|
|
27
|
+
"end_line?": type("number.integer >= 1").describe("1-indexed last source line"),
|
|
28
|
+
"start_column?": type("number.integer >= 1").describe("1-indexed first source column"),
|
|
29
|
+
"end_column?": type("number.integer >= 1").describe("1-indexed last source column"),
|
|
30
|
+
"role?": type("string").describe("entrypoint, root_control, sink, or supporting role"),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const publishEvidenceSchema = type({
|
|
34
|
+
label: "string > 0",
|
|
35
|
+
explanation: "string",
|
|
36
|
+
"excerpt?": "string",
|
|
37
|
+
"location?": publishLocationSchema,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const publishFindingSchema = type({
|
|
41
|
+
rule_id: "string > 0",
|
|
42
|
+
title: "string > 0",
|
|
43
|
+
summary: "string",
|
|
44
|
+
severity: "'critical' | 'high' | 'medium' | 'low' | 'informational'",
|
|
45
|
+
confidence: "'high' | 'medium' | 'low'",
|
|
46
|
+
category: "string > 0",
|
|
47
|
+
"anchor?": "string",
|
|
48
|
+
"cwe?": "string[]",
|
|
49
|
+
locations: publishLocationSchema.array().atLeastLength(1),
|
|
50
|
+
"evidence?": publishEvidenceSchema.array(),
|
|
51
|
+
"remediation?": "string",
|
|
52
|
+
"validation?": "'unvalidated' | 'validated' | 'partial'",
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const publishSurfaceSchema = type({
|
|
56
|
+
label: "string > 0",
|
|
57
|
+
disposition: "'reported' | 'no_issue_found' | 'rejected' | 'not_applicable' | 'needs_follow_up'",
|
|
58
|
+
"risk_area?": "string",
|
|
59
|
+
"notes?": "string",
|
|
60
|
+
"receipt_refs?": "string[]",
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const publishDeferredSchema = type({
|
|
64
|
+
reason: "string > 0",
|
|
65
|
+
"paths?": "string[]",
|
|
66
|
+
"surface_ids?": "string[]",
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
export const securityPublishSchema = type({
|
|
70
|
+
findings: publishFindingSchema.array(),
|
|
71
|
+
coverage: {
|
|
72
|
+
completeness: "'complete' | 'partial' | 'unknown'",
|
|
73
|
+
"surfaces?": publishSurfaceSchema.array(),
|
|
74
|
+
"explicit_exclusions?": type({ pattern: "string", reason: "string" }).array(),
|
|
75
|
+
"deferred?": publishDeferredSchema.array(),
|
|
76
|
+
"open_questions?": type({ question: "string > 0", "follow_up_prompt?": "string" }).array(),
|
|
77
|
+
},
|
|
78
|
+
report: "string",
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
export type SecurityPublishParams = typeof securityPublishSchema.infer;
|
|
82
|
+
|
|
83
|
+
export interface SecurityPublishDetails {
|
|
84
|
+
scanId: string;
|
|
85
|
+
findingCount: number;
|
|
86
|
+
status: "completed";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface SecurityPublicationOptions {
|
|
90
|
+
plan: SecurityScanPlan;
|
|
91
|
+
scanId: string;
|
|
92
|
+
store: SecurityStore;
|
|
93
|
+
startedAt: string;
|
|
94
|
+
sessionId?: string;
|
|
95
|
+
operationId?: string;
|
|
96
|
+
onPublished?: (bundle: SecurityScanBundle) => void | Promise<void>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function normalizePublishedPath(input: string): string {
|
|
100
|
+
const normalized = input.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
101
|
+
const segments = normalized.split("/");
|
|
102
|
+
if (
|
|
103
|
+
!normalized ||
|
|
104
|
+
normalized.startsWith("/") ||
|
|
105
|
+
/^[a-zA-Z]:\//.test(normalized) ||
|
|
106
|
+
segments.some(segment => segment === "..")
|
|
107
|
+
) {
|
|
108
|
+
throw new Error(`Security finding paths must be repository-relative: ${input}`);
|
|
109
|
+
}
|
|
110
|
+
return normalized;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function toLocation(
|
|
114
|
+
input: SecurityPublishParams["findings"][number]["locations"][number],
|
|
115
|
+
plan: SecurityScanPlan,
|
|
116
|
+
): SecurityLocation {
|
|
117
|
+
const normalizedPath = normalizePublishedPath(input.path);
|
|
118
|
+
if (!pathMatchesSecurityScope(normalizedPath, plan.target.includePaths, plan.target.excludePaths)) {
|
|
119
|
+
throw new Error(`Security finding path is outside the immutable scan scope: ${input.path}`);
|
|
120
|
+
}
|
|
121
|
+
const location: SecurityLocation = {
|
|
122
|
+
path: normalizedPath,
|
|
123
|
+
startLine: input.start_line,
|
|
124
|
+
};
|
|
125
|
+
if (input.end_line !== undefined) location.endLine = input.end_line;
|
|
126
|
+
if (input.start_column !== undefined) location.startColumn = input.start_column;
|
|
127
|
+
if (input.end_column !== undefined) location.endColumn = input.end_column;
|
|
128
|
+
if (input.role !== undefined) location.role = input.role;
|
|
129
|
+
return location;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function coverageMode(plan: SecurityScanPlan): SecurityCoverage["mode"] {
|
|
133
|
+
switch (plan.target.kind) {
|
|
134
|
+
case "ref_diff":
|
|
135
|
+
return "diff";
|
|
136
|
+
case "working_tree":
|
|
137
|
+
return "working_tree";
|
|
138
|
+
case "scoped_path":
|
|
139
|
+
return "scoped_path";
|
|
140
|
+
default:
|
|
141
|
+
return "repository";
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function inventoryStrategy(plan: SecurityScanPlan): SecurityCoverage["inventoryStrategy"] {
|
|
146
|
+
switch (plan.target.kind) {
|
|
147
|
+
case "ref_diff":
|
|
148
|
+
return "diff";
|
|
149
|
+
case "scoped_path":
|
|
150
|
+
return "scoped_path";
|
|
151
|
+
default:
|
|
152
|
+
return "repository";
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function buildFinding(
|
|
157
|
+
input: SecurityPublishParams["findings"][number],
|
|
158
|
+
options: SecurityPublicationOptions,
|
|
159
|
+
createdAt: string,
|
|
160
|
+
): SecurityFinding {
|
|
161
|
+
const locations = input.locations.map(location => toLocation(location, options.plan));
|
|
162
|
+
const fingerprint = createSecurityFindingFingerprint({
|
|
163
|
+
ruleId: input.rule_id,
|
|
164
|
+
category: input.category,
|
|
165
|
+
anchor: input.anchor,
|
|
166
|
+
locations,
|
|
167
|
+
});
|
|
168
|
+
const evidence: SecurityEvidence[] = (input.evidence ?? []).map((item, index) => {
|
|
169
|
+
const entry: SecurityEvidence = {
|
|
170
|
+
id: createSecurityEvidenceId(fingerprint, item.label, index),
|
|
171
|
+
kind: "code",
|
|
172
|
+
label: item.label,
|
|
173
|
+
explanation: item.explanation,
|
|
174
|
+
};
|
|
175
|
+
if (item.excerpt !== undefined) entry.excerpt = item.excerpt;
|
|
176
|
+
if (item.location !== undefined) entry.location = toLocation(item.location, options.plan);
|
|
177
|
+
return entry;
|
|
178
|
+
});
|
|
179
|
+
const finding: SecurityFinding = {
|
|
180
|
+
id: createSecurityFindingId(fingerprint),
|
|
181
|
+
scanId: options.scanId,
|
|
182
|
+
fingerprint,
|
|
183
|
+
ruleId: input.rule_id,
|
|
184
|
+
title: input.title,
|
|
185
|
+
summary: input.summary,
|
|
186
|
+
severity: { level: input.severity },
|
|
187
|
+
confidence: { level: input.confidence },
|
|
188
|
+
taxonomy: { category: input.category, cwe: input.cwe ?? [] },
|
|
189
|
+
occurrences: [
|
|
190
|
+
{
|
|
191
|
+
id: createSecurityOccurrenceId(fingerprint, locations),
|
|
192
|
+
locations,
|
|
193
|
+
evidenceIds: evidence.map(item => item.id),
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
evidence,
|
|
197
|
+
validation: { status: input.validation ?? "unvalidated", evidenceIds: [] },
|
|
198
|
+
disposition: { status: "open" },
|
|
199
|
+
provenance: createNativeSecurityProvenance({
|
|
200
|
+
createdAt,
|
|
201
|
+
account: options.plan.account,
|
|
202
|
+
planFingerprint: options.plan.fingerprint,
|
|
203
|
+
workflowFingerprint: options.plan.workflowFingerprint,
|
|
204
|
+
sessionId: options.sessionId,
|
|
205
|
+
}),
|
|
206
|
+
};
|
|
207
|
+
if (input.anchor !== undefined) finding.anchor = input.anchor;
|
|
208
|
+
if (input.remediation !== undefined) finding.remediation = input.remediation;
|
|
209
|
+
return finding;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function buildCoverage(params: SecurityPublishParams, plan: SecurityScanPlan): SecurityCoverage {
|
|
213
|
+
const surfaces: SecurityCoverage["surfaces"] = (params.coverage.surfaces ?? []).map((surface, index) => {
|
|
214
|
+
const entry: SecurityCoverage["surfaces"][number] = {
|
|
215
|
+
id: `surface-${index + 1}`,
|
|
216
|
+
label: surface.label,
|
|
217
|
+
disposition: surface.disposition,
|
|
218
|
+
receiptRefs: surface.receipt_refs ?? [],
|
|
219
|
+
};
|
|
220
|
+
if (surface.risk_area !== undefined) entry.riskArea = surface.risk_area;
|
|
221
|
+
if (surface.notes !== undefined) entry.notes = surface.notes;
|
|
222
|
+
return entry;
|
|
223
|
+
});
|
|
224
|
+
const deferred: SecurityCoverage["deferred"] = (params.coverage.deferred ?? []).map((item, index) => {
|
|
225
|
+
const entry: SecurityCoverage["deferred"][number] = {
|
|
226
|
+
id: `deferred-${index + 1}`,
|
|
227
|
+
reason: item.reason,
|
|
228
|
+
};
|
|
229
|
+
if (item.paths !== undefined) entry.paths = item.paths;
|
|
230
|
+
if (item.surface_ids !== undefined) entry.surfaceIds = item.surface_ids;
|
|
231
|
+
return entry;
|
|
232
|
+
});
|
|
233
|
+
const coverage: SecurityCoverage = {
|
|
234
|
+
mode: coverageMode(plan),
|
|
235
|
+
completeness: params.coverage.completeness,
|
|
236
|
+
inventoryStrategy: inventoryStrategy(plan),
|
|
237
|
+
includePaths: [...plan.target.includePaths],
|
|
238
|
+
excludePaths: [...plan.target.excludePaths],
|
|
239
|
+
surfaces,
|
|
240
|
+
explicitExclusions: params.coverage.explicit_exclusions ?? [],
|
|
241
|
+
deferred,
|
|
242
|
+
};
|
|
243
|
+
if (params.coverage.open_questions !== undefined) {
|
|
244
|
+
coverage.openQuestions = params.coverage.open_questions.map(item => {
|
|
245
|
+
const question: NonNullable<SecurityCoverage["openQuestions"]>[number] = { question: item.question };
|
|
246
|
+
if (item.follow_up_prompt !== undefined) question.followUpPrompt = item.follow_up_prompt;
|
|
247
|
+
return question;
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
return coverage;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function createSecurityPublicationTool(
|
|
254
|
+
options: SecurityPublicationOptions,
|
|
255
|
+
): ToolDefinition<typeof securityPublishSchema, SecurityPublishDetails> {
|
|
256
|
+
let published = false;
|
|
257
|
+
return {
|
|
258
|
+
name: "security_publish",
|
|
259
|
+
label: "Publish Security Scan",
|
|
260
|
+
description: securityPublishDescription.trim(),
|
|
261
|
+
parameters: securityPublishSchema,
|
|
262
|
+
approval: "write",
|
|
263
|
+
strict: true,
|
|
264
|
+
async execute(_toolCallId, params) {
|
|
265
|
+
if (published) throw new Error(`Security scan ${options.scanId} has already been published`);
|
|
266
|
+
published = true;
|
|
267
|
+
let persisted = false;
|
|
268
|
+
try {
|
|
269
|
+
const completedAt = new Date().toISOString();
|
|
270
|
+
const findingsByFingerprint = new Map<string, SecurityFinding>();
|
|
271
|
+
for (const input of params.findings) {
|
|
272
|
+
const finding = buildFinding(input, options, completedAt);
|
|
273
|
+
if (!findingsByFingerprint.has(finding.fingerprint)) {
|
|
274
|
+
findingsByFingerprint.set(finding.fingerprint, finding);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const findings = [...findingsByFingerprint.values()];
|
|
278
|
+
const producer = createNativeSecurityProducer();
|
|
279
|
+
const provenance = createNativeSecurityProvenance({
|
|
280
|
+
createdAt: options.startedAt,
|
|
281
|
+
account: options.plan.account,
|
|
282
|
+
planFingerprint: options.plan.fingerprint,
|
|
283
|
+
workflowFingerprint: options.plan.workflowFingerprint,
|
|
284
|
+
sessionId: options.sessionId,
|
|
285
|
+
operationId: options.operationId,
|
|
286
|
+
});
|
|
287
|
+
const scan: SecurityScan = {
|
|
288
|
+
documentType: "omp-security.scan",
|
|
289
|
+
schemaVersion: "1.0",
|
|
290
|
+
id: options.scanId,
|
|
291
|
+
projectKey: options.store.projectKey,
|
|
292
|
+
status: "completed",
|
|
293
|
+
createdAt: options.plan.createdAt,
|
|
294
|
+
startedAt: options.startedAt,
|
|
295
|
+
completedAt,
|
|
296
|
+
plan: options.plan,
|
|
297
|
+
target: options.plan.target,
|
|
298
|
+
producer,
|
|
299
|
+
provenance,
|
|
300
|
+
findingIds: findings.map(finding => finding.id),
|
|
301
|
+
coverage: buildCoverage(params, options.plan),
|
|
302
|
+
reportRef: "report.md",
|
|
303
|
+
sarifRef: "results.sarif",
|
|
304
|
+
};
|
|
305
|
+
const provisional: SecurityScanBundle = { scan, findings, report: params.report };
|
|
306
|
+
const bundle: SecurityScanBundle = { ...provisional, sarif: exportSecurityBundleToSarif(provisional) };
|
|
307
|
+
await writeSecurityBundleToDirectory(options.plan.output.root, bundle);
|
|
308
|
+
await options.store.putBundle(bundle);
|
|
309
|
+
persisted = true;
|
|
310
|
+
await options.onPublished?.(bundle);
|
|
311
|
+
return {
|
|
312
|
+
content: [
|
|
313
|
+
{
|
|
314
|
+
type: "text",
|
|
315
|
+
text: `Published security scan ${options.scanId} with ${findings.length} finding(s).`,
|
|
316
|
+
},
|
|
317
|
+
],
|
|
318
|
+
details: { scanId: options.scanId, findingCount: findings.length, status: "completed" },
|
|
319
|
+
};
|
|
320
|
+
} catch (error) {
|
|
321
|
+
if (!persisted) published = false;
|
|
322
|
+
throw error;
|
|
323
|
+
}
|
|
324
|
+
},
|
|
325
|
+
};
|
|
326
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { IsoBackendKind } from "@oh-my-pi/pi-natives";
|
|
2
|
+
import type { IsolationContext } from "../task/isolation-runner";
|
|
3
|
+
import { prepareIsolationContext } from "../task/isolation-runner";
|
|
4
|
+
import type { IsolationHandle, WorktreeBaseline } from "../task/worktree";
|
|
5
|
+
import { cleanupIsolation, ensureIsolation } from "../task/worktree";
|
|
6
|
+
|
|
7
|
+
export interface SecurityRemediationRequest {
|
|
8
|
+
cwd: string;
|
|
9
|
+
findingIds: string[];
|
|
10
|
+
isolationId?: string;
|
|
11
|
+
preferredBackend?: IsoBackendKind;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SecurityRemediationWorkspace {
|
|
15
|
+
id: string;
|
|
16
|
+
repositoryRoot: string;
|
|
17
|
+
worktreePath: string;
|
|
18
|
+
findingIds: string[];
|
|
19
|
+
backend: IsoBackendKind;
|
|
20
|
+
fellBack: boolean;
|
|
21
|
+
fallbackReason: string | null;
|
|
22
|
+
cleanup(): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SecurityRemediationDependencies {
|
|
26
|
+
prepareContext?: (cwd: string) => Promise<IsolationContext>;
|
|
27
|
+
createIsolation?: (repositoryRoot: string, id: string, preferred?: IsoBackendKind) => Promise<IsolationHandle>;
|
|
28
|
+
cleanupIsolation?: (handle: IsolationHandle) => Promise<void>;
|
|
29
|
+
createId?: () => string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function createRemediationId(): string {
|
|
33
|
+
return `security-remediation-${Bun.randomUUIDv7().replaceAll("-", "")}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function repoBaselineDirty(baseline: WorktreeBaseline): string[] {
|
|
37
|
+
const dirty: string[] = [];
|
|
38
|
+
if (baseline.root.staged.trim()) dirty.push("staged changes");
|
|
39
|
+
if (baseline.root.unstaged.trim()) dirty.push("unstaged changes");
|
|
40
|
+
if (baseline.root.untracked.length > 0 || baseline.root.untrackedPatch.trim()) dirty.push("untracked files");
|
|
41
|
+
for (const nested of baseline.nested) {
|
|
42
|
+
if (
|
|
43
|
+
nested.baseline.staged.trim() ||
|
|
44
|
+
nested.baseline.unstaged.trim() ||
|
|
45
|
+
nested.baseline.untracked.length > 0 ||
|
|
46
|
+
nested.baseline.untrackedPatch.trim()
|
|
47
|
+
) {
|
|
48
|
+
dirty.push(`dirty nested repository ${nested.relativePath}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return dirty;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function assertSecurityRemediationBaselineClean(baseline: WorktreeBaseline): void {
|
|
55
|
+
const dirty = repoBaselineDirty(baseline);
|
|
56
|
+
if (dirty.length === 0) return;
|
|
57
|
+
throw new Error(
|
|
58
|
+
[
|
|
59
|
+
`Security remediation refuses a dirty working tree (${dirty.join(", ")}).`,
|
|
60
|
+
"Commit or stash the changes before creating an isolated remediation workspace.",
|
|
61
|
+
].join(" "),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function prepareSecurityRemediationWorkspace(
|
|
66
|
+
request: SecurityRemediationRequest,
|
|
67
|
+
dependencies: SecurityRemediationDependencies = {},
|
|
68
|
+
): Promise<SecurityRemediationWorkspace> {
|
|
69
|
+
const findingIds = [...new Set(request.findingIds.map(id => id.trim()).filter(Boolean))];
|
|
70
|
+
if (findingIds.length === 0) throw new Error("Security remediation requires at least one finding id");
|
|
71
|
+
const prepareContext = dependencies.prepareContext ?? prepareIsolationContext;
|
|
72
|
+
const createIsolation = dependencies.createIsolation ?? ensureIsolation;
|
|
73
|
+
const disposeIsolation = dependencies.cleanupIsolation ?? cleanupIsolation;
|
|
74
|
+
const context = await prepareContext(request.cwd);
|
|
75
|
+
assertSecurityRemediationBaselineClean(context.baseline);
|
|
76
|
+
const id = request.isolationId?.trim() || dependencies.createId?.() || createRemediationId();
|
|
77
|
+
const handle = await createIsolation(context.repoRoot, id, request.preferredBackend);
|
|
78
|
+
let cleaned = false;
|
|
79
|
+
return {
|
|
80
|
+
id,
|
|
81
|
+
repositoryRoot: context.repoRoot,
|
|
82
|
+
worktreePath: handle.mergedDir,
|
|
83
|
+
findingIds,
|
|
84
|
+
backend: handle.backend,
|
|
85
|
+
fellBack: handle.fellBack,
|
|
86
|
+
fallbackReason: handle.fallbackReason,
|
|
87
|
+
async cleanup() {
|
|
88
|
+
if (cleaned) return;
|
|
89
|
+
cleaned = true;
|
|
90
|
+
await disposeIsolation(handle);
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { sanitizeText } from "@oh-my-pi/pi-utils";
|
|
2
|
+
import type { InternalResource } from "../internal-urls";
|
|
3
|
+
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, truncateHead } from "../session/streaming-output";
|
|
4
|
+
|
|
5
|
+
export interface SecurityResourceOptions {
|
|
6
|
+
url: string;
|
|
7
|
+
content: string;
|
|
8
|
+
contentType: InternalResource["contentType"];
|
|
9
|
+
isDirectory?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function boundedJson(content: string): { content: string; truncated: boolean } {
|
|
13
|
+
const sanitized = sanitizeText(content);
|
|
14
|
+
const truncated = truncateHead(sanitized, { maxBytes: DEFAULT_MAX_BYTES, maxLines: DEFAULT_MAX_LINES });
|
|
15
|
+
if (!truncated.truncated) return { content: sanitized, truncated: false };
|
|
16
|
+
return {
|
|
17
|
+
content: `${JSON.stringify(
|
|
18
|
+
{
|
|
19
|
+
truncated: true,
|
|
20
|
+
originalBytes: truncated.totalBytes,
|
|
21
|
+
originalLines: truncated.totalLines,
|
|
22
|
+
preview: truncated.content,
|
|
23
|
+
},
|
|
24
|
+
null,
|
|
25
|
+
2,
|
|
26
|
+
)}\n`,
|
|
27
|
+
truncated: true,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createSecurityResource(options: SecurityResourceOptions): InternalResource {
|
|
32
|
+
const bounded =
|
|
33
|
+
options.contentType === "application/json"
|
|
34
|
+
? boundedJson(options.content)
|
|
35
|
+
: (() => {
|
|
36
|
+
const sanitized = sanitizeText(options.content);
|
|
37
|
+
const truncated = truncateHead(sanitized, { maxBytes: DEFAULT_MAX_BYTES, maxLines: DEFAULT_MAX_LINES });
|
|
38
|
+
return { content: truncated.content, truncated: truncated.truncated };
|
|
39
|
+
})();
|
|
40
|
+
return {
|
|
41
|
+
url: options.url,
|
|
42
|
+
content: bounded.content,
|
|
43
|
+
contentType: options.contentType,
|
|
44
|
+
size: Buffer.byteLength(bounded.content),
|
|
45
|
+
isDirectory: options.isDirectory,
|
|
46
|
+
notes: bounded.truncated
|
|
47
|
+
? [`Security resource truncated to ${DEFAULT_MAX_LINES} lines / ${DEFAULT_MAX_BYTES} bytes.`]
|
|
48
|
+
: undefined,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { pathToFileURL } from "node:url";
|
|
2
|
+
import type { SecurityFinding, SecurityScanBundle } from "./contracts";
|
|
3
|
+
|
|
4
|
+
function sarifLevel(finding: SecurityFinding): "error" | "warning" | "note" | "none" {
|
|
5
|
+
switch (finding.severity.level) {
|
|
6
|
+
case "critical":
|
|
7
|
+
case "high":
|
|
8
|
+
return "error";
|
|
9
|
+
case "medium":
|
|
10
|
+
return "warning";
|
|
11
|
+
case "low":
|
|
12
|
+
return "note";
|
|
13
|
+
default:
|
|
14
|
+
return "none";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function exportSecurityBundleToSarif(bundle: SecurityScanBundle): Record<string, unknown> {
|
|
19
|
+
const rules = new Map<string, SecurityFinding>();
|
|
20
|
+
for (const finding of bundle.findings) {
|
|
21
|
+
if (!rules.has(finding.ruleId)) rules.set(finding.ruleId, finding);
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
25
|
+
version: "2.1.0",
|
|
26
|
+
runs: [
|
|
27
|
+
{
|
|
28
|
+
tool: {
|
|
29
|
+
driver: {
|
|
30
|
+
name: bundle.scan.producer.name,
|
|
31
|
+
version: bundle.scan.producer.version,
|
|
32
|
+
informationUri: "https://omp.sh",
|
|
33
|
+
rules: [...rules.values()].map(finding => ({
|
|
34
|
+
id: finding.ruleId,
|
|
35
|
+
name: finding.ruleId,
|
|
36
|
+
shortDescription: { text: finding.title },
|
|
37
|
+
fullDescription: { text: finding.summary },
|
|
38
|
+
properties: {
|
|
39
|
+
tags: [...finding.taxonomy.cwe, ...(finding.taxonomy.tags ?? [])],
|
|
40
|
+
"security-severity": finding.severity.score,
|
|
41
|
+
},
|
|
42
|
+
})),
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
results: bundle.findings.map(finding => ({
|
|
46
|
+
ruleId: finding.ruleId,
|
|
47
|
+
level: sarifLevel(finding),
|
|
48
|
+
message: { text: finding.summary },
|
|
49
|
+
locations: finding.occurrences.flatMap(occurrence =>
|
|
50
|
+
occurrence.locations.map(location => ({
|
|
51
|
+
physicalLocation: {
|
|
52
|
+
artifactLocation: { uri: location.path, uriBaseId: "%SRCROOT%" },
|
|
53
|
+
region: {
|
|
54
|
+
startLine: location.startLine,
|
|
55
|
+
endLine: location.endLine,
|
|
56
|
+
startColumn: location.startColumn,
|
|
57
|
+
endColumn: location.endColumn,
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
})),
|
|
61
|
+
),
|
|
62
|
+
fingerprints: { "omp-security/v1": finding.fingerprint },
|
|
63
|
+
properties: {
|
|
64
|
+
findingId: finding.id,
|
|
65
|
+
confidence: finding.confidence.level,
|
|
66
|
+
validation: finding.validation.status,
|
|
67
|
+
disposition: finding.disposition.status,
|
|
68
|
+
category: finding.taxonomy.category,
|
|
69
|
+
"security-severity": finding.severity.score,
|
|
70
|
+
},
|
|
71
|
+
})),
|
|
72
|
+
originalUriBaseIds: {
|
|
73
|
+
"%SRCROOT%": { uri: pathToFileURL(bundle.scan.target.repositoryRoot).href.replace(/\/?$/, "/") },
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
};
|
|
78
|
+
}
|