@amityco/social-plus-vise 0.4.0
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/LICENSE +51 -0
- package/README.md +92 -0
- package/dist/outcomes.js +574 -0
- package/dist/server.js +810 -0
- package/dist/tools/compliance.js +965 -0
- package/dist/tools/docs.js +312 -0
- package/dist/tools/harness.js +229 -0
- package/dist/tools/integration.js +332 -0
- package/dist/tools/patch.js +67 -0
- package/dist/tools/project.js +908 -0
- package/dist/tools/resolve.js +120 -0
- package/dist/tools/sensors.js +185 -0
- package/dist/types.js +31 -0
- package/dist/version.js +19 -0
- package/package.json +64 -0
- package/rules/design.yaml +66 -0
- package/rules/feed.yaml +126 -0
- package/rules/live-data.yaml +66 -0
- package/rules/push.yaml +95 -0
- package/rules/sdk-lifecycle.yaml +422 -0
- package/rules/security.yaml +162 -0
- package/skills/social-plus-vise/SKILL.md +199 -0
|
@@ -0,0 +1,965 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { classifyOutcome } from "../outcomes.js";
|
|
6
|
+
import { objectInput, optionalStringField, stringField, textResult } from "../types.js";
|
|
7
|
+
import { packageVersion } from "../version.js";
|
|
8
|
+
import { inspectProject, validateSetup } from "./project.js";
|
|
9
|
+
const complianceDirName = "sp-vise";
|
|
10
|
+
const attestationsDirName = "attestations";
|
|
11
|
+
const schemaVersion = 1;
|
|
12
|
+
const confidenceRank = { low: 0, medium: 1, high: 2 };
|
|
13
|
+
export const initComplianceTool = {
|
|
14
|
+
name: "init_compliance",
|
|
15
|
+
description: "Initialize the local sp-vise compliance sidecar for a customer integration request.",
|
|
16
|
+
inputSchema: {
|
|
17
|
+
type: "object",
|
|
18
|
+
properties: {
|
|
19
|
+
repoPath: { type: "string" },
|
|
20
|
+
request: { type: "string" },
|
|
21
|
+
surfacePath: { type: "string" },
|
|
22
|
+
},
|
|
23
|
+
required: ["repoPath", "request"],
|
|
24
|
+
additionalProperties: false,
|
|
25
|
+
},
|
|
26
|
+
async call(input) {
|
|
27
|
+
const args = objectInput(input);
|
|
28
|
+
return textResult(await initCompliance(stringField(args, "repoPath"), stringField(args, "request"), optionalStringField(args, "surfacePath")));
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
export const checkComplianceTool = {
|
|
32
|
+
name: "check_compliance",
|
|
33
|
+
description: "Check compliance rules against the current source and recorded attestations. Read-only.",
|
|
34
|
+
inputSchema: {
|
|
35
|
+
type: "object",
|
|
36
|
+
properties: {
|
|
37
|
+
repoPath: { type: "string" },
|
|
38
|
+
},
|
|
39
|
+
required: ["repoPath"],
|
|
40
|
+
additionalProperties: false,
|
|
41
|
+
},
|
|
42
|
+
async call(input) {
|
|
43
|
+
const args = objectInput(input);
|
|
44
|
+
return textResult(await checkCompliance(stringField(args, "repoPath")));
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
export const syncComplianceTool = {
|
|
48
|
+
name: "sync_compliance",
|
|
49
|
+
description: "Persist current deterministic-pass compliance results into the sp-vise sidecar.",
|
|
50
|
+
inputSchema: {
|
|
51
|
+
type: "object",
|
|
52
|
+
properties: {
|
|
53
|
+
repoPath: { type: "string" },
|
|
54
|
+
},
|
|
55
|
+
required: ["repoPath"],
|
|
56
|
+
additionalProperties: false,
|
|
57
|
+
},
|
|
58
|
+
async call(input) {
|
|
59
|
+
const args = objectInput(input);
|
|
60
|
+
return textResult(await syncCompliance(stringField(args, "repoPath")));
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
export const attestRuleTool = {
|
|
64
|
+
name: "attest_rule",
|
|
65
|
+
description: "Record a host-agent or local-human attestation for one compliance rule.",
|
|
66
|
+
inputSchema: {
|
|
67
|
+
type: "object",
|
|
68
|
+
properties: {
|
|
69
|
+
repoPath: { type: "string" },
|
|
70
|
+
ruleId: { type: "string" },
|
|
71
|
+
confidence: { type: "string" },
|
|
72
|
+
signer: { type: "string" },
|
|
73
|
+
identity: { type: "string" },
|
|
74
|
+
rationale: { type: "string" },
|
|
75
|
+
evidence: { type: "object", additionalProperties: true },
|
|
76
|
+
},
|
|
77
|
+
required: ["repoPath", "ruleId", "confidence", "signer", "rationale", "evidence"],
|
|
78
|
+
additionalProperties: false,
|
|
79
|
+
},
|
|
80
|
+
async call(input) {
|
|
81
|
+
const args = objectInput(input);
|
|
82
|
+
return textResult(await attestRule({
|
|
83
|
+
repoPath: stringField(args, "repoPath"),
|
|
84
|
+
ruleId: stringField(args, "ruleId"),
|
|
85
|
+
confidence: confidenceField(args.confidence),
|
|
86
|
+
signer: signerField(args.signer),
|
|
87
|
+
identity: optionalStringField(args, "identity"),
|
|
88
|
+
rationale: stringField(args, "rationale"),
|
|
89
|
+
evidence: evidenceField(args.evidence),
|
|
90
|
+
}));
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
export const explainRuleTool = {
|
|
94
|
+
name: "explain_rule",
|
|
95
|
+
description: "Explain one compliance rule, including rationale, enforcement, and evidence schema.",
|
|
96
|
+
inputSchema: {
|
|
97
|
+
type: "object",
|
|
98
|
+
properties: {
|
|
99
|
+
ruleId: { type: "string" },
|
|
100
|
+
},
|
|
101
|
+
required: ["ruleId"],
|
|
102
|
+
additionalProperties: false,
|
|
103
|
+
},
|
|
104
|
+
async call(input) {
|
|
105
|
+
const args = objectInput(input);
|
|
106
|
+
return textResult(await explainRule(stringField(args, "ruleId")));
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
export const initEngagementTool = {
|
|
110
|
+
name: "init_engagement",
|
|
111
|
+
description: "Initialize sp-vise/engagement.json with customer, tier, and contracted outcome scope. Local-only metadata; v0.5 will route review-queue uploads using these fields.",
|
|
112
|
+
inputSchema: {
|
|
113
|
+
type: "object",
|
|
114
|
+
properties: {
|
|
115
|
+
repoPath: { type: "string" },
|
|
116
|
+
tier: { type: "string", description: "free | pro | partner. Defaults to free." },
|
|
117
|
+
customerId: { type: "string" },
|
|
118
|
+
scope: { type: "array", items: { type: "string" }, description: "Outcomes covered by this engagement." },
|
|
119
|
+
targetCompletionDate: { type: "string", description: "Optional ISO date YYYY-MM-DD." },
|
|
120
|
+
reviewerName: { type: "string" },
|
|
121
|
+
reviewerEmail: { type: "string" },
|
|
122
|
+
evidenceUploadConsent: { type: "boolean" },
|
|
123
|
+
},
|
|
124
|
+
required: ["repoPath"],
|
|
125
|
+
additionalProperties: false,
|
|
126
|
+
},
|
|
127
|
+
async call(input) {
|
|
128
|
+
const args = objectInput(input);
|
|
129
|
+
return textResult(await initEngagement({
|
|
130
|
+
repoPath: stringField(args, "repoPath"),
|
|
131
|
+
tier: optionalStringField(args, "tier"),
|
|
132
|
+
customerId: optionalStringField(args, "customerId"),
|
|
133
|
+
scope: stringArray(args.scope),
|
|
134
|
+
targetCompletionDate: optionalStringField(args, "targetCompletionDate"),
|
|
135
|
+
reviewerName: optionalStringField(args, "reviewerName"),
|
|
136
|
+
reviewerEmail: optionalStringField(args, "reviewerEmail"),
|
|
137
|
+
evidenceUploadConsent: args.evidenceUploadConsent === true,
|
|
138
|
+
}));
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
export const showEngagementTool = {
|
|
142
|
+
name: "show_engagement",
|
|
143
|
+
description: "Read sp-vise/engagement.json and return the engagement metadata. Read-only.",
|
|
144
|
+
inputSchema: {
|
|
145
|
+
type: "object",
|
|
146
|
+
properties: {
|
|
147
|
+
repoPath: { type: "string" },
|
|
148
|
+
},
|
|
149
|
+
required: ["repoPath"],
|
|
150
|
+
additionalProperties: false,
|
|
151
|
+
},
|
|
152
|
+
async call(input) {
|
|
153
|
+
const args = objectInput(input);
|
|
154
|
+
return textResult(await showEngagement(stringField(args, "repoPath")));
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
function stringArray(value) {
|
|
158
|
+
if (!Array.isArray(value)) {
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
return value.filter((item) => typeof item === "string" && item.trim() !== "");
|
|
162
|
+
}
|
|
163
|
+
const validTiers = new Set(["free", "pro", "partner"]);
|
|
164
|
+
const validOutcomes = new Set(["setup-sdk", "setup-push", "setup-live-data", "add-feed", "troubleshoot", "validate-setup", "unknown"]);
|
|
165
|
+
export async function initEngagement(args) {
|
|
166
|
+
const repoRoot = path.resolve(args.repoPath);
|
|
167
|
+
const engagementFile = engagementPath(repoRoot);
|
|
168
|
+
if (await readJsonIfExists(engagementFile)) {
|
|
169
|
+
throw new Error(`Engagement file already exists at ${engagementFile}. Edit it directly to extend scope, or remove it first to re-init.`);
|
|
170
|
+
}
|
|
171
|
+
const tier = (args.tier ?? "free");
|
|
172
|
+
if (!validTiers.has(tier)) {
|
|
173
|
+
throw new Error(`Unknown tier: ${args.tier}. Expected one of: ${Array.from(validTiers).join(", ")}.`);
|
|
174
|
+
}
|
|
175
|
+
const requestedOutcomes = (args.scope ?? []).map((outcome) => outcome.trim()).filter(Boolean);
|
|
176
|
+
for (const outcome of requestedOutcomes) {
|
|
177
|
+
if (!validOutcomes.has(outcome)) {
|
|
178
|
+
throw new Error(`Unknown outcome in scope: ${outcome}. Expected one of: ${Array.from(validOutcomes).join(", ")}.`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (args.targetCompletionDate && !/^\d{4}-\d{2}-\d{2}$/.test(args.targetCompletionDate)) {
|
|
182
|
+
throw new Error(`target-completion must be YYYY-MM-DD; got "${args.targetCompletionDate}".`);
|
|
183
|
+
}
|
|
184
|
+
const reviewerAssignment = args.reviewerName || args.reviewerEmail
|
|
185
|
+
? { name: args.reviewerName, email: args.reviewerEmail }
|
|
186
|
+
: undefined;
|
|
187
|
+
const engagement = {
|
|
188
|
+
schema_version: schemaVersion,
|
|
189
|
+
foundry_version: packageVersion,
|
|
190
|
+
engagement_id: randomUUID(),
|
|
191
|
+
customer_id: args.customerId,
|
|
192
|
+
tier,
|
|
193
|
+
scope: { outcomes: requestedOutcomes },
|
|
194
|
+
target_completion_date: args.targetCompletionDate,
|
|
195
|
+
reviewer_assignment: reviewerAssignment,
|
|
196
|
+
evidence_upload_consent: args.evidenceUploadConsent === true,
|
|
197
|
+
generated_at: new Date().toISOString(),
|
|
198
|
+
};
|
|
199
|
+
await mkdir(sidecarDir(repoRoot), { recursive: true });
|
|
200
|
+
await writeJson(engagementFile, engagement);
|
|
201
|
+
return {
|
|
202
|
+
status: "initialized",
|
|
203
|
+
engagement_id: engagement.engagement_id,
|
|
204
|
+
tier: engagement.tier,
|
|
205
|
+
scope: engagement.scope,
|
|
206
|
+
customer_id: engagement.customer_id,
|
|
207
|
+
file: engagementFile,
|
|
208
|
+
note: "Engagement metadata is local-only in v0.4.x. v0.5 review queue will route uploads using these fields.",
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
export async function showEngagement(repoPath) {
|
|
212
|
+
const repoRoot = path.resolve(repoPath);
|
|
213
|
+
const engagement = await readEngagement(repoRoot);
|
|
214
|
+
if (!engagement) {
|
|
215
|
+
return { status: "absent", note: "No engagement.json found. Run `vise engagement init` to create one." };
|
|
216
|
+
}
|
|
217
|
+
return { status: "present", ...engagement };
|
|
218
|
+
}
|
|
219
|
+
async function readEngagement(repoRoot) {
|
|
220
|
+
return readJsonIfExists(engagementPath(repoRoot));
|
|
221
|
+
}
|
|
222
|
+
function engagementPath(repoRoot) {
|
|
223
|
+
return path.join(sidecarDir(repoRoot), "engagement.json");
|
|
224
|
+
}
|
|
225
|
+
export async function initCompliance(repoPath, request, surfacePath) {
|
|
226
|
+
const repoRoot = path.resolve(repoPath);
|
|
227
|
+
const inspection = await inspectProject(repoRoot, surfacePath);
|
|
228
|
+
const outcome = classifyOutcome(request);
|
|
229
|
+
const rules = await applicableRules(outcome, inspection.platforms);
|
|
230
|
+
const refs = rules.map(ruleRef);
|
|
231
|
+
const engagement = await readEngagement(repoRoot);
|
|
232
|
+
const compliance = {
|
|
233
|
+
schema_version: schemaVersion,
|
|
234
|
+
foundry_version: packageVersion,
|
|
235
|
+
ruleset_digest: digestJson(refs),
|
|
236
|
+
generated_at: new Date().toISOString(),
|
|
237
|
+
last_synced_at: null,
|
|
238
|
+
outcome,
|
|
239
|
+
engagement_id: engagement?.engagement_id,
|
|
240
|
+
surface: inspection.selectedSurface ? { path: inspection.selectedSurface.path, platforms: inspection.selectedSurface.platforms } : undefined,
|
|
241
|
+
rules: refs,
|
|
242
|
+
};
|
|
243
|
+
await mkdir(attestationsDir(repoRoot), { recursive: true });
|
|
244
|
+
await writeJson(compliancePath(repoRoot), compliance);
|
|
245
|
+
await writeJson(path.join(sidecarDir(repoRoot), "inspection.json"), inspection);
|
|
246
|
+
await writeFile(path.join(sidecarDir(repoRoot), "README.md"), sidecarReadme(compliance), "utf8");
|
|
247
|
+
const warnings = [];
|
|
248
|
+
if (engagement && engagement.scope.outcomes.length > 0 && !engagement.scope.outcomes.includes(outcome)) {
|
|
249
|
+
warnings.push(`Outcome "${outcome}" is not in the engagement scope (${engagement.scope.outcomes.join(", ")}). Compliance was still initialized; extend the scope in engagement.json or re-run vise engagement init.`);
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
status: "initialized",
|
|
253
|
+
sidecar: complianceDirName,
|
|
254
|
+
outcome,
|
|
255
|
+
surfacePath: inspection.selectedSurface?.path,
|
|
256
|
+
rules: refs.length,
|
|
257
|
+
engagement_id: engagement?.engagement_id,
|
|
258
|
+
...(warnings.length > 0 && { warnings }),
|
|
259
|
+
nextStep: "Run vise check, then implement until rules pass deterministically or are attested.",
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
export async function applicableComplianceRuleSummaries(outcome, platforms) {
|
|
263
|
+
return (await applicableRules(outcome, platforms)).map(ruleRef);
|
|
264
|
+
}
|
|
265
|
+
export async function checkCompliance(repoPath) {
|
|
266
|
+
const repoRoot = path.resolve(repoPath);
|
|
267
|
+
const compliance = await readCompliance(repoRoot);
|
|
268
|
+
const rules = await rulesById();
|
|
269
|
+
const drift = contractDrift(compliance, rules);
|
|
270
|
+
if (drift.length > 0) {
|
|
271
|
+
return {
|
|
272
|
+
status: "contract-drift",
|
|
273
|
+
exitCode: 4,
|
|
274
|
+
outcome: compliance.outcome,
|
|
275
|
+
surfacePath: compliance.surface?.path,
|
|
276
|
+
summary: { "contract-drift": drift.length },
|
|
277
|
+
rules: drift,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
const inspection = await inspectProject(repoRoot, compliance.surface?.path === "." ? undefined : compliance.surface?.path);
|
|
281
|
+
const platform = inspection.platforms[0] ?? "unknown";
|
|
282
|
+
const findings = await validateSetup(inspection.effectiveRoot, platform);
|
|
283
|
+
const findingsById = new Map(findings.map((finding) => [finding.ruleId, finding]));
|
|
284
|
+
const attestations = await readAttestations(repoRoot);
|
|
285
|
+
const results = [];
|
|
286
|
+
for (const ref of compliance.rules) {
|
|
287
|
+
const rule = rules.get(ref.rule_id);
|
|
288
|
+
if (!rule) {
|
|
289
|
+
results.push({ ruleId: ref.rule_id, title: ref.rule_id, severity: ref.severity, status: "stale", reason: "Rule is missing from installed Vise." });
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
// Blockers run first. If any external prerequisite is missing, the rule is
|
|
293
|
+
// "blocked" — the host agent cannot fix this alone, so we surface it and
|
|
294
|
+
// stop evaluating this rule. Customer-readable reasons are attached so the
|
|
295
|
+
// user knows what to provide.
|
|
296
|
+
const blockersFired = await runBlockers(rule, inspection.effectiveRoot);
|
|
297
|
+
if (blockersFired.length > 0) {
|
|
298
|
+
results.push({
|
|
299
|
+
ruleId: rule.id,
|
|
300
|
+
title: rule.title,
|
|
301
|
+
severity: rule.severity,
|
|
302
|
+
status: "blocked",
|
|
303
|
+
reason: blockersFired.map((blocker) => blocker.reason).join(" "),
|
|
304
|
+
blockers_fired: blockersFired,
|
|
305
|
+
current_rule: ruleSummary(rule),
|
|
306
|
+
});
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
const finding = deterministicFinding(rule, findingsById);
|
|
310
|
+
if (!finding) {
|
|
311
|
+
results.push({
|
|
312
|
+
ruleId: rule.id,
|
|
313
|
+
title: rule.title,
|
|
314
|
+
severity: rule.severity,
|
|
315
|
+
status: "deterministic-pass",
|
|
316
|
+
evidence: { source: "validate_setup", finding_absent: deterministicFindingIds(rule) },
|
|
317
|
+
});
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
const attestation = attestations.get(rule.id);
|
|
321
|
+
if (attestation) {
|
|
322
|
+
// Deterministic-pass records are historical sync evidence, not waivers.
|
|
323
|
+
// If the current source now produces a finding, the old sync record must
|
|
324
|
+
// not mask code drift; the next `vise sync` will remove it.
|
|
325
|
+
if (attestation.status === "deterministic-pass") {
|
|
326
|
+
results.push({
|
|
327
|
+
ruleId: rule.id,
|
|
328
|
+
title: rule.title,
|
|
329
|
+
severity: rule.severity,
|
|
330
|
+
status: rule.enforcement.attestation.allowed ? "attestation-needed" : "deterministic-fail",
|
|
331
|
+
reason: rule.enforcement.attestation.allowed
|
|
332
|
+
? "Current deterministic check failed; previously synced deterministic-pass evidence is stale."
|
|
333
|
+
: "Current deterministic check failed; this rule does not allow attestation.",
|
|
334
|
+
finding,
|
|
335
|
+
recommendation: finding.recommendation,
|
|
336
|
+
current_rule: ruleSummary(rule),
|
|
337
|
+
});
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
const exactMatch = attestation.rule_digest === ref.rule_digest && attestation.ruleset_digest === compliance.ruleset_digest;
|
|
341
|
+
const grandfathered = !exactMatch && isAttestationGrandfathered(rule, attestation);
|
|
342
|
+
if (exactMatch || grandfathered) {
|
|
343
|
+
const sourceFingerprintStatus = await checkSourceFingerprints(repoRoot, inspection.effectiveRoot, attestation.source_fingerprints ?? []);
|
|
344
|
+
const staleFingerprints = sourceFingerprintStatus.filter((item) => item.status !== "match");
|
|
345
|
+
if (staleFingerprints.length > 0) {
|
|
346
|
+
results.push({
|
|
347
|
+
ruleId: rule.id,
|
|
348
|
+
title: rule.title,
|
|
349
|
+
severity: rule.severity,
|
|
350
|
+
status: rule.enforcement.attestation.allowed ? "attestation-needed" : "deterministic-fail",
|
|
351
|
+
reason: "Recorded attestation source fingerprints changed. Re-check the evidence and record a fresh attestation.",
|
|
352
|
+
finding,
|
|
353
|
+
recommendation: finding.recommendation,
|
|
354
|
+
current_rule: ruleSummary(rule),
|
|
355
|
+
source_fingerprint_status: sourceFingerprintStatus,
|
|
356
|
+
});
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
const deprecated = grandfathered && (rule.deprecated_versions ?? []).includes(attestation.rule_version);
|
|
360
|
+
results.push({
|
|
361
|
+
ruleId: rule.id,
|
|
362
|
+
title: rule.title,
|
|
363
|
+
severity: rule.severity,
|
|
364
|
+
status: "attested",
|
|
365
|
+
reason: grandfathered
|
|
366
|
+
? `Grandfathered ${attestation.signer_claim.kind} attestation from rule v${attestation.rule_version} (current is v${rule.version}; compatible_with allows it).`
|
|
367
|
+
: `Recorded ${attestation.signer_claim.kind} attestation.`,
|
|
368
|
+
attestation: attestationPathFor(rule.id),
|
|
369
|
+
evidence: attestation.evidence,
|
|
370
|
+
...(sourceFingerprintStatus.length > 0 && { source_fingerprint_status: sourceFingerprintStatus }),
|
|
371
|
+
...(deprecated && {
|
|
372
|
+
deprecation_warning: `Rule v${attestation.rule_version} is marked deprecated in the current Vise. Re-attest against v${rule.version} before a future version removes v${attestation.rule_version} from compatible_with.`,
|
|
373
|
+
}),
|
|
374
|
+
});
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
results.push({
|
|
379
|
+
ruleId: rule.id,
|
|
380
|
+
title: rule.title,
|
|
381
|
+
severity: rule.severity,
|
|
382
|
+
status: rule.enforcement.attestation.allowed ? "attestation-needed" : "deterministic-fail",
|
|
383
|
+
reason: rule.enforcement.attestation.allowed ? "Deterministic check failed and no valid attestation exists." : "This rule does not allow attestation.",
|
|
384
|
+
finding,
|
|
385
|
+
recommendation: finding.recommendation,
|
|
386
|
+
current_rule: ruleSummary(rule),
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
const summary = summarize(results);
|
|
390
|
+
const hasBlocked = results.some((result) => result.status === "blocked");
|
|
391
|
+
const hasDeterministicFailure = results.some((result) => result.status === "deterministic-fail");
|
|
392
|
+
const needsAttestation = results.some((result) => result.status === "attestation-needed" || result.status === "stale");
|
|
393
|
+
// Precedence: blocked (exit 3) > deterministic-failures (2) > needs-attestation (1) > green (0).
|
|
394
|
+
// Contract drift (exit 4) is handled earlier and short-circuits the loop.
|
|
395
|
+
// Blocked wins because the agent cannot proceed without customer input;
|
|
396
|
+
// surfacing a smaller failure first would distract from the real blocker.
|
|
397
|
+
return {
|
|
398
|
+
status: hasBlocked
|
|
399
|
+
? "blocked"
|
|
400
|
+
: hasDeterministicFailure
|
|
401
|
+
? "deterministic-failures"
|
|
402
|
+
: needsAttestation
|
|
403
|
+
? "needs-attestation"
|
|
404
|
+
: "green",
|
|
405
|
+
exitCode: hasBlocked ? 3 : hasDeterministicFailure ? 2 : needsAttestation ? 1 : 0,
|
|
406
|
+
outcome: compliance.outcome,
|
|
407
|
+
surfacePath: compliance.surface?.path,
|
|
408
|
+
summary,
|
|
409
|
+
rules: results,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
export async function syncCompliance(repoPath) {
|
|
413
|
+
const repoRoot = path.resolve(repoPath);
|
|
414
|
+
const compliance = await readCompliance(repoRoot);
|
|
415
|
+
const check = await checkCompliance(repoRoot);
|
|
416
|
+
const written = [];
|
|
417
|
+
const removed = [];
|
|
418
|
+
await mkdir(attestationsDir(repoRoot), { recursive: true });
|
|
419
|
+
for (const result of check.rules) {
|
|
420
|
+
const filePath = path.join(attestationsDir(repoRoot), attestationPathFor(result.ruleId));
|
|
421
|
+
if (result.status === "deterministic-pass") {
|
|
422
|
+
const rule = (await rulesById()).get(result.ruleId);
|
|
423
|
+
if (!rule) {
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
const existing = await readJsonIfExists(filePath);
|
|
427
|
+
if (existing?.status === "attested") {
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
const attestation = buildAttestation(compliance, rule, "spf-deterministic", "high", undefined, "Deterministic check passed.", result.evidence ?? {});
|
|
431
|
+
await writeJson(filePath, attestation);
|
|
432
|
+
written.push(filePath);
|
|
433
|
+
}
|
|
434
|
+
else if (result.status === "deterministic-fail" || result.status === "attestation-needed") {
|
|
435
|
+
const existing = await readJsonIfExists(filePath);
|
|
436
|
+
if (existing?.status === "deterministic-pass") {
|
|
437
|
+
await rm(filePath, { force: true });
|
|
438
|
+
removed.push(filePath);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
compliance.last_synced_at = new Date().toISOString();
|
|
443
|
+
await writeJson(compliancePath(repoRoot), compliance);
|
|
444
|
+
return {
|
|
445
|
+
status: "synced",
|
|
446
|
+
written,
|
|
447
|
+
removed,
|
|
448
|
+
last_synced_at: compliance.last_synced_at,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
export async function attestRule(args) {
|
|
452
|
+
const repoRoot = path.resolve(args.repoPath);
|
|
453
|
+
const compliance = await readCompliance(repoRoot);
|
|
454
|
+
const rules = await rulesById();
|
|
455
|
+
const rule = rules.get(args.ruleId);
|
|
456
|
+
if (!rule || !compliance.rules.some((ref) => ref.rule_id === args.ruleId)) {
|
|
457
|
+
throw new Error(`Rule is not applicable in this compliance contract: ${args.ruleId}`);
|
|
458
|
+
}
|
|
459
|
+
if (!rule.enforcement.attestation.allowed) {
|
|
460
|
+
throw new Error(`Rule does not allow attestation: ${args.ruleId}`);
|
|
461
|
+
}
|
|
462
|
+
if (args.signer === "host-agent" && confidenceRank[args.confidence] < confidenceRank[rule.enforcement.attestation.host_agent_min_confidence ?? "high"]) {
|
|
463
|
+
throw new Error(`Host-agent confidence ${args.confidence} is below ${rule.enforcement.attestation.host_agent_min_confidence ?? "high"} for ${args.ruleId}. Use --signer human if a local human accepts responsibility.`);
|
|
464
|
+
}
|
|
465
|
+
if (args.signer === "human" && rule.enforcement.attestation.human_allowed === false) {
|
|
466
|
+
throw new Error(`Rule does not allow local human attestations: ${args.ruleId}`);
|
|
467
|
+
}
|
|
468
|
+
validateEvidence(rule, args.evidence);
|
|
469
|
+
await mkdir(attestationsDir(repoRoot), { recursive: true });
|
|
470
|
+
const sourceFingerprints = await collectSourceFingerprints(repoRoot, sourceRootForCompliance(repoRoot, compliance), args.evidence);
|
|
471
|
+
const attestation = buildAttestation(compliance, rule, args.signer, args.confidence, args.identity, args.rationale, args.evidence, sourceFingerprints);
|
|
472
|
+
const filePath = path.join(attestationsDir(repoRoot), attestationPathFor(args.ruleId));
|
|
473
|
+
await writeJson(filePath, attestation);
|
|
474
|
+
return {
|
|
475
|
+
status: "attested",
|
|
476
|
+
ruleId: args.ruleId,
|
|
477
|
+
destination: filePath,
|
|
478
|
+
signer_claim: attestation.signer_claim,
|
|
479
|
+
source_fingerprints: sourceFingerprints,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
export async function explainRule(ruleId) {
|
|
483
|
+
const rules = await rulesById();
|
|
484
|
+
const rule = rules.get(ruleId);
|
|
485
|
+
if (!rule) {
|
|
486
|
+
throw new Error(`Unknown compliance rule: ${ruleId}`);
|
|
487
|
+
}
|
|
488
|
+
return {
|
|
489
|
+
id: rule.id,
|
|
490
|
+
version: rule.version,
|
|
491
|
+
title: rule.title,
|
|
492
|
+
severity: rule.severity,
|
|
493
|
+
rationale: rule.rationale,
|
|
494
|
+
applies_when: rule.applies_when,
|
|
495
|
+
compatible_with: rule.compatible_with,
|
|
496
|
+
deterministic: rule.enforcement.deterministic ?? [],
|
|
497
|
+
attestation: rule.enforcement.attestation,
|
|
498
|
+
summary: ruleSummary(rule),
|
|
499
|
+
rule_digest: digestRule(rule),
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
export async function statusCompliance(repoPath) {
|
|
503
|
+
const repoRoot = path.resolve(repoPath);
|
|
504
|
+
const check = await checkCompliance(repoPath);
|
|
505
|
+
const engagement = await readEngagement(repoRoot);
|
|
506
|
+
return {
|
|
507
|
+
status: check.status,
|
|
508
|
+
exitCode: check.exitCode,
|
|
509
|
+
outcome: check.outcome,
|
|
510
|
+
surfacePath: check.surfacePath,
|
|
511
|
+
summary: check.summary,
|
|
512
|
+
...(engagement && {
|
|
513
|
+
engagement: {
|
|
514
|
+
engagement_id: engagement.engagement_id,
|
|
515
|
+
tier: engagement.tier,
|
|
516
|
+
customer_id: engagement.customer_id,
|
|
517
|
+
scope: engagement.scope,
|
|
518
|
+
outcome_in_scope: engagement.scope.outcomes.length === 0 || engagement.scope.outcomes.includes(check.outcome),
|
|
519
|
+
target_completion_date: engagement.target_completion_date,
|
|
520
|
+
reviewer_assignment: engagement.reviewer_assignment,
|
|
521
|
+
},
|
|
522
|
+
}),
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
async function applicableRules(outcome, platforms) {
|
|
526
|
+
const files = await loadRuleFiles();
|
|
527
|
+
return files
|
|
528
|
+
.flatMap((file) => file.rules)
|
|
529
|
+
.filter((rule) => {
|
|
530
|
+
const outcomeMatch = !rule.applies_when.outcomes || rule.applies_when.outcomes.includes(outcome);
|
|
531
|
+
const platformMatch = !rule.applies_when.platforms || rule.applies_when.platforms.some((platform) => platforms.includes(platform));
|
|
532
|
+
return outcomeMatch && platformMatch;
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
async function loadRuleFiles() {
|
|
536
|
+
const dir = rulesDir();
|
|
537
|
+
const entries = await readdir(dir);
|
|
538
|
+
const files = entries.filter((entry) => entry.endsWith(".yaml")).sort();
|
|
539
|
+
const loaded = [];
|
|
540
|
+
for (const file of files) {
|
|
541
|
+
loaded.push(JSON.parse(await readFile(path.join(dir, file), "utf8")));
|
|
542
|
+
}
|
|
543
|
+
return loaded;
|
|
544
|
+
}
|
|
545
|
+
async function rulesById() {
|
|
546
|
+
const rules = (await loadRuleFiles()).flatMap((file) => file.rules);
|
|
547
|
+
return new Map(rules.map((rule) => [rule.id, rule]));
|
|
548
|
+
}
|
|
549
|
+
function ruleRef(rule) {
|
|
550
|
+
return {
|
|
551
|
+
rule_id: rule.id,
|
|
552
|
+
rule_version: rule.version,
|
|
553
|
+
rule_digest: digestRule(rule),
|
|
554
|
+
severity: rule.severity,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
function contractDrift(compliance, rules) {
|
|
558
|
+
const results = [];
|
|
559
|
+
const refs = compliance.rules.map((ref) => {
|
|
560
|
+
const rule = rules.get(ref.rule_id);
|
|
561
|
+
return rule ? ruleRef(rule) : ref;
|
|
562
|
+
});
|
|
563
|
+
const installedRulesetDigest = digestJson(refs);
|
|
564
|
+
if (installedRulesetDigest !== compliance.ruleset_digest) {
|
|
565
|
+
results.push({
|
|
566
|
+
ruleId: "contract.ruleset",
|
|
567
|
+
title: "Compliance contract ruleset changed",
|
|
568
|
+
severity: "error",
|
|
569
|
+
status: "stale",
|
|
570
|
+
reason: "Installed Vise ruleset differs from sp-vise/compliance.json. Re-run vise init to accept the new contract.",
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
for (const ref of compliance.rules) {
|
|
574
|
+
const rule = rules.get(ref.rule_id);
|
|
575
|
+
if (!rule) {
|
|
576
|
+
results.push({ ruleId: ref.rule_id, title: ref.rule_id, severity: ref.severity, status: "stale", reason: "Rule is missing from installed Vise." });
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
const digest = digestRule(rule);
|
|
580
|
+
if (digest !== ref.rule_digest) {
|
|
581
|
+
const grandfatherCovers = (rule.compatible_with ?? []).includes(ref.rule_version);
|
|
582
|
+
const reason = grandfatherCovers
|
|
583
|
+
? `Rule digest changed since vise init (v${ref.rule_version} → v${rule.version}; new version is backwards-compatible — re-run vise init; existing attestations will be grandfathered).`
|
|
584
|
+
: `Rule digest changed since vise init (v${ref.rule_version} → v${rule.version}).`;
|
|
585
|
+
results.push({
|
|
586
|
+
ruleId: ref.rule_id,
|
|
587
|
+
title: rule.title,
|
|
588
|
+
severity: ref.severity,
|
|
589
|
+
status: "stale",
|
|
590
|
+
reason,
|
|
591
|
+
current_rule: ruleSummary(rule),
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return results;
|
|
596
|
+
}
|
|
597
|
+
function deterministicFinding(rule, findingsById) {
|
|
598
|
+
for (const id of deterministicFindingIds(rule)) {
|
|
599
|
+
const finding = findingsById.get(id);
|
|
600
|
+
if (finding) {
|
|
601
|
+
return finding;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return undefined;
|
|
605
|
+
}
|
|
606
|
+
function deterministicFindingIds(rule) {
|
|
607
|
+
return (rule.enforcement.deterministic ?? [])
|
|
608
|
+
.filter((check) => check.check === "validator-finding-absent")
|
|
609
|
+
.map((check) => check.finding_rule_id);
|
|
610
|
+
}
|
|
611
|
+
function buildAttestation(compliance, rule, signer, confidence, identity, rationale, evidence, sourceFingerprints = []) {
|
|
612
|
+
const ref = compliance.rules.find((item) => item.rule_id === rule.id);
|
|
613
|
+
if (!ref) {
|
|
614
|
+
throw new Error(`Rule is not in compliance contract: ${rule.id}`);
|
|
615
|
+
}
|
|
616
|
+
const withoutHash = {
|
|
617
|
+
rule_id: rule.id,
|
|
618
|
+
rule_version: rule.version,
|
|
619
|
+
rule_digest: ref.rule_digest,
|
|
620
|
+
ruleset_digest: compliance.ruleset_digest,
|
|
621
|
+
foundry_version: packageVersion,
|
|
622
|
+
status: signer === "spf-deterministic" ? "deterministic-pass" : "attested",
|
|
623
|
+
signer_claim: {
|
|
624
|
+
kind: signer,
|
|
625
|
+
identity: identity ?? (signer === "spf-deterministic" ? "spf" : signer),
|
|
626
|
+
tool_version: signer === "host-agent" ? "unknown" : undefined,
|
|
627
|
+
confidence,
|
|
628
|
+
},
|
|
629
|
+
signature: null,
|
|
630
|
+
evidence,
|
|
631
|
+
source_fingerprints: sourceFingerprints,
|
|
632
|
+
rationale_for_attestation: rationale,
|
|
633
|
+
attested_at: new Date().toISOString(),
|
|
634
|
+
};
|
|
635
|
+
return {
|
|
636
|
+
...withoutHash,
|
|
637
|
+
payload_hash: digestJson(withoutHash),
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
function sourceRootForCompliance(repoRoot, compliance) {
|
|
641
|
+
if (!compliance.surface?.path || compliance.surface.path === ".") {
|
|
642
|
+
return repoRoot;
|
|
643
|
+
}
|
|
644
|
+
return path.resolve(repoRoot, compliance.surface.path);
|
|
645
|
+
}
|
|
646
|
+
async function collectSourceFingerprints(repoRoot, sourceRoot, evidence) {
|
|
647
|
+
const capturedAt = new Date().toISOString();
|
|
648
|
+
const fingerprints = [];
|
|
649
|
+
const seen = new Set();
|
|
650
|
+
for (const item of evidenceSourcePathCandidates(evidence)) {
|
|
651
|
+
const resolved = await resolveEvidenceSourcePath(repoRoot, sourceRoot, item.candidate);
|
|
652
|
+
if (!resolved || seen.has(resolved.relativePath)) {
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
seen.add(resolved.relativePath);
|
|
656
|
+
const fileStat = await stat(resolved.absolutePath);
|
|
657
|
+
fingerprints.push({
|
|
658
|
+
evidence_field: item.evidenceField,
|
|
659
|
+
path: resolved.relativePath,
|
|
660
|
+
sha256: await digestFile(resolved.absolutePath),
|
|
661
|
+
size_bytes: fileStat.size,
|
|
662
|
+
captured_at: capturedAt,
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
return fingerprints;
|
|
666
|
+
}
|
|
667
|
+
async function checkSourceFingerprints(repoRoot, sourceRoot, fingerprints) {
|
|
668
|
+
const statuses = [];
|
|
669
|
+
for (const fingerprint of fingerprints) {
|
|
670
|
+
const resolved = await resolveEvidenceSourcePath(repoRoot, sourceRoot, fingerprint.path);
|
|
671
|
+
if (!resolved) {
|
|
672
|
+
statuses.push({
|
|
673
|
+
evidence_field: fingerprint.evidence_field,
|
|
674
|
+
path: fingerprint.path,
|
|
675
|
+
expected_sha256: fingerprint.sha256,
|
|
676
|
+
status: "missing",
|
|
677
|
+
});
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
const actualSha256 = await digestFile(resolved.absolutePath);
|
|
681
|
+
statuses.push({
|
|
682
|
+
evidence_field: fingerprint.evidence_field,
|
|
683
|
+
path: fingerprint.path,
|
|
684
|
+
expected_sha256: fingerprint.sha256,
|
|
685
|
+
actual_sha256: actualSha256,
|
|
686
|
+
status: actualSha256 === fingerprint.sha256 ? "match" : "changed",
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
return statuses;
|
|
690
|
+
}
|
|
691
|
+
function evidenceSourcePathCandidates(evidence) {
|
|
692
|
+
const candidates = [];
|
|
693
|
+
function visit(value, fieldPath) {
|
|
694
|
+
if (typeof value === "string") {
|
|
695
|
+
for (const candidate of sourcePathCandidatesFromString(value)) {
|
|
696
|
+
candidates.push({ evidenceField: fieldPath, candidate });
|
|
697
|
+
}
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
if (Array.isArray(value)) {
|
|
701
|
+
value.forEach((item, index) => visit(item, `${fieldPath}[${index}]`));
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
if (value && typeof value === "object") {
|
|
705
|
+
for (const [key, nestedValue] of Object.entries(value)) {
|
|
706
|
+
visit(nestedValue, fieldPath ? `${fieldPath}.${key}` : key);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
visit(evidence, "");
|
|
711
|
+
return candidates;
|
|
712
|
+
}
|
|
713
|
+
function sourcePathCandidatesFromString(value) {
|
|
714
|
+
const trimmed = value.trim();
|
|
715
|
+
const candidates = new Set();
|
|
716
|
+
if (looksLikeSourcePath(trimmed)) {
|
|
717
|
+
candidates.add(trimmed);
|
|
718
|
+
}
|
|
719
|
+
const pathPattern = /(?:[A-Za-z0-9_@.-]+\/)+[A-Za-z0-9_@.-]+\.(?:ts|tsx|js|jsx|kt|java|dart|swift|xml|gradle|kts|json|ya?ml|env|plist|pbxproj|podspec)|\b(?:Podfile|Package\.swift|pubspec\.yaml|package\.json|AndroidManifest\.xml)\b/g;
|
|
720
|
+
for (const match of trimmed.matchAll(pathPattern)) {
|
|
721
|
+
candidates.add(match[0]);
|
|
722
|
+
}
|
|
723
|
+
return Array.from(candidates).map(cleanEvidencePathCandidate).filter(Boolean);
|
|
724
|
+
}
|
|
725
|
+
function looksLikeSourcePath(value) {
|
|
726
|
+
return (/[/\\]/.test(value) ||
|
|
727
|
+
/^(?:Podfile|Package\.swift|pubspec\.yaml|package\.json|AndroidManifest\.xml)$/.test(value) ||
|
|
728
|
+
/\.(?:ts|tsx|js|jsx|kt|java|dart|swift|xml|gradle|kts|json|ya?ml|env|plist|pbxproj|podspec)(?::\d+)?$/i.test(value));
|
|
729
|
+
}
|
|
730
|
+
function cleanEvidencePathCandidate(value) {
|
|
731
|
+
return value
|
|
732
|
+
.trim()
|
|
733
|
+
.replace(/^["'`]+|["'`,.;)]+$/g, "")
|
|
734
|
+
.replace(/#L\d+(?:-L\d+)?$/, "")
|
|
735
|
+
.replace(/:\d+(?::\d+)?$/, "");
|
|
736
|
+
}
|
|
737
|
+
async function resolveEvidenceSourcePath(repoRoot, sourceRoot, candidate) {
|
|
738
|
+
const possiblePaths = path.isAbsolute(candidate)
|
|
739
|
+
? [path.resolve(candidate)]
|
|
740
|
+
: [path.resolve(sourceRoot, candidate), path.resolve(repoRoot, candidate)];
|
|
741
|
+
for (const possiblePath of possiblePaths) {
|
|
742
|
+
if (!pathInside(repoRoot, possiblePath)) {
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
try {
|
|
746
|
+
if (!(await stat(possiblePath)).isFile()) {
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
catch {
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
return {
|
|
754
|
+
absolutePath: possiblePath,
|
|
755
|
+
relativePath: path.relative(repoRoot, possiblePath).split(path.sep).join(path.posix.sep),
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
return undefined;
|
|
759
|
+
}
|
|
760
|
+
function pathInside(root, candidate) {
|
|
761
|
+
const relative = path.relative(root, candidate);
|
|
762
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
763
|
+
}
|
|
764
|
+
async function digestFile(filePath) {
|
|
765
|
+
return `sha256:${createHash("sha256").update(await readFile(filePath)).digest("hex")}`;
|
|
766
|
+
}
|
|
767
|
+
export function ruleSummary(rule) {
|
|
768
|
+
return {
|
|
769
|
+
version: rule.version,
|
|
770
|
+
severity: rule.severity,
|
|
771
|
+
title: rule.title,
|
|
772
|
+
compatible_with: rule.compatible_with,
|
|
773
|
+
deprecated_versions: rule.deprecated_versions,
|
|
774
|
+
blockers: (rule.enforcement.blockers ?? []).map((blocker) => ({
|
|
775
|
+
check: blocker.check,
|
|
776
|
+
path: "path" in blocker ? blocker.path : undefined,
|
|
777
|
+
pattern: "pattern" in blocker ? blocker.pattern : undefined,
|
|
778
|
+
reason: blocker.reason,
|
|
779
|
+
})),
|
|
780
|
+
deterministic_check_ids: (rule.enforcement.deterministic ?? []).map((check) => check.finding_rule_id),
|
|
781
|
+
attestation_allowed: rule.enforcement.attestation.allowed,
|
|
782
|
+
host_agent_min_confidence: rule.enforcement.attestation.host_agent_min_confidence,
|
|
783
|
+
human_allowed: rule.enforcement.attestation.human_allowed !== false,
|
|
784
|
+
evidence_field_names: (rule.enforcement.attestation.evidence_required ?? []).map((field) => field.field),
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
export async function runBlockers(rule, root) {
|
|
788
|
+
const fired = [];
|
|
789
|
+
for (const blocker of rule.enforcement.blockers ?? []) {
|
|
790
|
+
if (blocker.check === "file-absent") {
|
|
791
|
+
const filePath = path.resolve(root, blocker.path);
|
|
792
|
+
if (!(await fileExists(filePath))) {
|
|
793
|
+
fired.push({ check: "file-absent", path: blocker.path, reason: blocker.reason });
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
else if (blocker.check === "file-content-missing") {
|
|
797
|
+
const filePath = path.resolve(root, blocker.path);
|
|
798
|
+
let content = "";
|
|
799
|
+
try {
|
|
800
|
+
content = await readFile(filePath, "utf8");
|
|
801
|
+
}
|
|
802
|
+
catch {
|
|
803
|
+
fired.push({ check: "file-content-missing", path: blocker.path, pattern: blocker.pattern, reason: blocker.reason });
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
const re = new RegExp(blocker.pattern);
|
|
807
|
+
if (!re.test(content)) {
|
|
808
|
+
fired.push({ check: "file-content-missing", path: blocker.path, pattern: blocker.pattern, reason: blocker.reason });
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
return fired;
|
|
813
|
+
}
|
|
814
|
+
async function fileExists(filePath) {
|
|
815
|
+
try {
|
|
816
|
+
await stat(filePath);
|
|
817
|
+
return true;
|
|
818
|
+
}
|
|
819
|
+
catch {
|
|
820
|
+
return false;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
export function isAttestationGrandfathered(rule, attestation) {
|
|
824
|
+
if (attestation.rule_id !== rule.id) {
|
|
825
|
+
return false;
|
|
826
|
+
}
|
|
827
|
+
if (attestation.rule_version === rule.version) {
|
|
828
|
+
return false;
|
|
829
|
+
}
|
|
830
|
+
return (rule.compatible_with ?? []).includes(attestation.rule_version);
|
|
831
|
+
}
|
|
832
|
+
function validateEvidence(rule, evidence) {
|
|
833
|
+
for (const field of rule.enforcement.attestation.evidence_required ?? []) {
|
|
834
|
+
if (!(field.field in evidence)) {
|
|
835
|
+
throw new Error(`Missing required evidence field for ${rule.id}: ${field.field}`);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
async function readCompliance(repoRoot) {
|
|
840
|
+
const file = compliancePath(repoRoot);
|
|
841
|
+
try {
|
|
842
|
+
return JSON.parse(await readFile(file, "utf8"));
|
|
843
|
+
}
|
|
844
|
+
catch {
|
|
845
|
+
throw new Error(`No compliance sidecar found. Run vise init first: ${file}`);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
async function readAttestations(repoRoot) {
|
|
849
|
+
const dir = attestationsDir(repoRoot);
|
|
850
|
+
const result = new Map();
|
|
851
|
+
let entries = [];
|
|
852
|
+
try {
|
|
853
|
+
entries = await readdir(dir);
|
|
854
|
+
}
|
|
855
|
+
catch {
|
|
856
|
+
return result;
|
|
857
|
+
}
|
|
858
|
+
for (const entry of entries) {
|
|
859
|
+
if (!entry.endsWith(".json")) {
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
const attestation = await readJsonIfExists(path.join(dir, entry));
|
|
863
|
+
if (attestation?.rule_id) {
|
|
864
|
+
result.set(attestation.rule_id, attestation);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
return result;
|
|
868
|
+
}
|
|
869
|
+
async function readJsonIfExists(filePath) {
|
|
870
|
+
try {
|
|
871
|
+
if (!(await stat(filePath)).isFile()) {
|
|
872
|
+
return undefined;
|
|
873
|
+
}
|
|
874
|
+
return JSON.parse(await readFile(filePath, "utf8"));
|
|
875
|
+
}
|
|
876
|
+
catch {
|
|
877
|
+
return undefined;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
async function writeJson(filePath, value) {
|
|
881
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
882
|
+
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
883
|
+
}
|
|
884
|
+
function summarize(results) {
|
|
885
|
+
const summary = {};
|
|
886
|
+
for (const result of results) {
|
|
887
|
+
summary[result.status] = (summary[result.status] ?? 0) + 1;
|
|
888
|
+
}
|
|
889
|
+
return summary;
|
|
890
|
+
}
|
|
891
|
+
function sidecarReadme(compliance) {
|
|
892
|
+
return [
|
|
893
|
+
"# social.plus Vise Compliance",
|
|
894
|
+
"",
|
|
895
|
+
"This directory records the local compliance contract for this integration.",
|
|
896
|
+
"",
|
|
897
|
+
`- Outcome: ${compliance.outcome}`,
|
|
898
|
+
`- Rules: ${compliance.rules.length}`,
|
|
899
|
+
`- Generated: ${compliance.generated_at}`,
|
|
900
|
+
"",
|
|
901
|
+
"Run `vise check` to verify current status and `vise sync` to persist deterministic-pass evidence.",
|
|
902
|
+
"Host-agent and human attestations include source fingerprints for cited files; `vise check` marks them stale if those files change.",
|
|
903
|
+
"",
|
|
904
|
+
].join("\n");
|
|
905
|
+
}
|
|
906
|
+
function sidecarDir(repoRoot) {
|
|
907
|
+
return path.join(repoRoot, complianceDirName);
|
|
908
|
+
}
|
|
909
|
+
function attestationsDir(repoRoot) {
|
|
910
|
+
return path.join(sidecarDir(repoRoot), attestationsDirName);
|
|
911
|
+
}
|
|
912
|
+
function compliancePath(repoRoot) {
|
|
913
|
+
return path.join(sidecarDir(repoRoot), "compliance.json");
|
|
914
|
+
}
|
|
915
|
+
function attestationPathFor(ruleId) {
|
|
916
|
+
return `${ruleId.replace(/[^a-zA-Z0-9_.-]/g, "_")}.json`;
|
|
917
|
+
}
|
|
918
|
+
function rulesDir() {
|
|
919
|
+
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
920
|
+
return path.resolve(moduleDir, "..", "..", "rules");
|
|
921
|
+
}
|
|
922
|
+
function digestRule(rule) {
|
|
923
|
+
return digestJson({
|
|
924
|
+
id: rule.id,
|
|
925
|
+
version: rule.version,
|
|
926
|
+
title: rule.title,
|
|
927
|
+
severity: rule.severity,
|
|
928
|
+
rationale: rule.rationale,
|
|
929
|
+
applies_when: rule.applies_when,
|
|
930
|
+
enforcement: rule.enforcement,
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
function digestJson(value) {
|
|
934
|
+
return `sha256:${createHash("sha256").update(stableStringify(value)).digest("hex")}`;
|
|
935
|
+
}
|
|
936
|
+
function stableStringify(value) {
|
|
937
|
+
if (Array.isArray(value)) {
|
|
938
|
+
return `[${value.map(stableStringify).join(",")}]`;
|
|
939
|
+
}
|
|
940
|
+
if (value && typeof value === "object") {
|
|
941
|
+
const entries = Object.entries(value)
|
|
942
|
+
.filter(([, entryValue]) => entryValue !== undefined)
|
|
943
|
+
.sort(([a], [b]) => a.localeCompare(b));
|
|
944
|
+
return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`).join(",")}}`;
|
|
945
|
+
}
|
|
946
|
+
return JSON.stringify(value);
|
|
947
|
+
}
|
|
948
|
+
function confidenceField(value) {
|
|
949
|
+
if (value === "high" || value === "medium" || value === "low") {
|
|
950
|
+
return value;
|
|
951
|
+
}
|
|
952
|
+
throw new Error("confidence must be high, medium, or low.");
|
|
953
|
+
}
|
|
954
|
+
function signerField(value) {
|
|
955
|
+
if (value === "host-agent" || value === "human") {
|
|
956
|
+
return value;
|
|
957
|
+
}
|
|
958
|
+
throw new Error("signer must be host-agent or human.");
|
|
959
|
+
}
|
|
960
|
+
function evidenceField(value) {
|
|
961
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
962
|
+
throw new Error("evidence must be an object.");
|
|
963
|
+
}
|
|
964
|
+
return value;
|
|
965
|
+
}
|