scrumrun 2.1.0 → 2.1.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 +23 -0
- package/CORE.md +6 -2
- package/MIGRATION-1-to-2.md +2 -2
- package/README.md +12 -4
- package/SPEC.md +5 -2
- package/bin/scrumrun.js +56 -0
- package/docs/COMMANDS.md +6 -2
- package/docs/RELEASE-SCORECARD.md +11 -11
- package/docs/RELEASE.md +7 -7
- package/docs/SCHEMA.md +3 -1
- package/docs/SEMANTIC-MEMORY.md +1 -1
- package/docs/TROUBLESHOOTING.md +1 -1
- package/lib/commands/manifest.js +4 -1
- package/lib/commands/render.js +1 -0
- package/lib/memory/index.js +135 -34
- package/lib/memory/service.js +3 -0
- package/lib/runtime/mutation-gateway.js +434 -0
- package/lib/runtime/orchestrator.js +35 -3
- package/lib/runtime/policy-engine.js +85 -2
- package/lib/runtime/request-engine.js +4 -0
- package/lib/runtime/review-service.js +92 -0
- package/lib/runtime/run-ledger.js +228 -6
- package/lib/runtime/workspace-state.js +146 -0
- package/lib/security/secrets.js +15 -1
- package/lib/v2/artifacts.js +3 -0
- package/lib/v2/conformance.js +20 -1
- package/lib/v2/migration.js +6 -2
- package/lib/v2/run-ledger-migration.js +30 -2
- package/lib/v2/schema.js +10 -3
- package/package.json +1 -1
- package/templates/project/.scrumrun/method.json +4 -1
- package/templates/project/AGENTS.md +2 -1
- package/templates/project-lean/AGENTS.md +3 -1
- package/templates/shared/skills/scrumrun/SKILL.md +6 -2
|
@@ -9,9 +9,13 @@ const ENFORCEMENTS = new Set([
|
|
|
9
9
|
"builtin:approval-gate",
|
|
10
10
|
"builtin:read-only-path",
|
|
11
11
|
"builtin:owner-work",
|
|
12
|
+
"builtin:canonical-write",
|
|
13
|
+
"builtin:canonical-truth",
|
|
14
|
+
"builtin:memory-candidate",
|
|
12
15
|
"builtin:migration-integrity",
|
|
13
16
|
"builtin:review-gate"
|
|
14
17
|
]);
|
|
18
|
+
const SCOPES = new Set(["all", "intake", "execution", "mutation", "canonical", "memory", "migration", "validation", "learning", "completion", "commit", "release", "logs"]);
|
|
15
19
|
|
|
16
20
|
function normalized(value) {
|
|
17
21
|
return String(value || "").normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
|
|
@@ -66,7 +70,21 @@ function parseGuardrails(content) {
|
|
|
66
70
|
const status = normalized(fields.status || "active").replace(/[._-]+$/, "");
|
|
67
71
|
const enforcement = normalized(fields.enforcement || inferEnforcement(title, rule));
|
|
68
72
|
const scope = (fields.scope || "all").split(/\s*,\s*/).map((value) => normalized(value)).filter(Boolean);
|
|
69
|
-
return {
|
|
73
|
+
return {
|
|
74
|
+
id: heading[1],
|
|
75
|
+
title,
|
|
76
|
+
rule,
|
|
77
|
+
status,
|
|
78
|
+
enforcement,
|
|
79
|
+
scope,
|
|
80
|
+
source: fields.source || null,
|
|
81
|
+
explicit: {
|
|
82
|
+
status: Object.prototype.hasOwnProperty.call(fields, "status"),
|
|
83
|
+
enforcement: Object.prototype.hasOwnProperty.call(fields, "enforcement"),
|
|
84
|
+
scope: Object.prototype.hasOwnProperty.call(fields, "scope"),
|
|
85
|
+
rule: Object.prototype.hasOwnProperty.call(fields, "rule")
|
|
86
|
+
}
|
|
87
|
+
};
|
|
70
88
|
});
|
|
71
89
|
}
|
|
72
90
|
|
|
@@ -103,7 +121,7 @@ function requestMentionsPath(request, configuredPath) {
|
|
|
103
121
|
}
|
|
104
122
|
|
|
105
123
|
function evaluation(guardrail, status, code, message, evidence = []) {
|
|
106
|
-
return { guardrail: guardrail.id, status, code, message, enforcement: guardrail.enforcement, evidence };
|
|
124
|
+
return { guardrail: guardrail.id, status, code, message, enforcement: guardrail.enforcement, scope: guardrail.scope, evidence };
|
|
107
125
|
}
|
|
108
126
|
|
|
109
127
|
function evaluateGuardrail(guardrail, context) {
|
|
@@ -128,6 +146,12 @@ function evaluateGuardrail(guardrail, context) {
|
|
|
128
146
|
}
|
|
129
147
|
case "builtin:owner-work":
|
|
130
148
|
return evaluation(guardrail, "deferred", "OWNER_WORK", "Mutation-time hashes, scoped staging, and transaction preconditions enforce owner-work preservation.");
|
|
149
|
+
case "builtin:canonical-write":
|
|
150
|
+
return evaluation(guardrail, "deferred", "CANONICAL_WRITE", "Canonical writes require the lossless transaction gateway and post-write validation.");
|
|
151
|
+
case "builtin:canonical-truth":
|
|
152
|
+
return evaluation(guardrail, "deferred", "CANONICAL_TRUTH", "Completion must prove that generated caches did not replace canonical Markdown truth.");
|
|
153
|
+
case "builtin:memory-candidate":
|
|
154
|
+
return evaluation(guardrail, "deferred", "MEMORY_CANDIDATE", "Learning and memory mutations must preserve candidate-first AI knowledge.");
|
|
131
155
|
case "builtin:migration-integrity":
|
|
132
156
|
return evaluation(guardrail, /migrat|migrac/.test(requestText) ? "deferred" : "passed", "MIGRATION_INTEGRITY", /migrat|migrac/.test(requestText) ? "The explicit migration preflight/apply/rollback gate must enforce this Guardrail." : "Request does not invoke migration.");
|
|
133
157
|
case "builtin:review-gate":
|
|
@@ -150,15 +174,48 @@ function evaluatePolicy(context) {
|
|
|
150
174
|
evaluations.push({ guardrail: "I-11", status: "blocked", code: "SECRET_BOUNDARY", message: "Request appears to contain a secret; remove or redact it.", enforcement: "builtin", evidence: ["request"] });
|
|
151
175
|
}
|
|
152
176
|
const blocked = evaluations.filter((item) => item.status === "blocked");
|
|
177
|
+
const postApprovalScopes = new Set(["all", "execution", "mutation", "canonical", "memory", "migration", "validation", "learning", "completion", "commit", "release", "logs"]);
|
|
178
|
+
const continuousEnforcement = new Set(["builtin:secret-boundary", "builtin:owner-work", "builtin:read-only-path", "builtin:canonical-write", "builtin:canonical-truth", "builtin:memory-candidate"]);
|
|
179
|
+
const obligations = evaluations
|
|
180
|
+
.filter((item) => item.status === "deferred" || (item.status === "passed" && continuousEnforcement.has(item.enforcement) && (item.scope || []).some((scope) => postApprovalScopes.has(scope))))
|
|
181
|
+
.map((item) => ({
|
|
182
|
+
guardrail: item.guardrail,
|
|
183
|
+
code: item.code,
|
|
184
|
+
enforcement: item.enforcement,
|
|
185
|
+
scope: item.scope,
|
|
186
|
+
gate: obligationGate(item)
|
|
187
|
+
}));
|
|
153
188
|
return {
|
|
154
189
|
status: blocked.length ? "blocked" : "passed",
|
|
155
190
|
checked: [...new Set(evaluations.map((item) => item.guardrail))],
|
|
156
191
|
deferred: [...new Set(evaluations.filter((item) => item.status === "deferred").map((item) => item.guardrail))],
|
|
192
|
+
obligations,
|
|
157
193
|
evaluations,
|
|
158
194
|
violations: blocked.map((item) => `${item.guardrail} ${item.code}: ${item.message}`)
|
|
159
195
|
};
|
|
160
196
|
}
|
|
161
197
|
|
|
198
|
+
function obligationGate(evaluationResult) {
|
|
199
|
+
const scopes = evaluationResult.scope || [];
|
|
200
|
+
for (const gate of ["mutation", "migration", "validation", "learning", "completion", "commit", "release", "memory", "canonical"]) {
|
|
201
|
+
if (scopes.includes(gate)) return gate;
|
|
202
|
+
}
|
|
203
|
+
switch (evaluationResult.enforcement) {
|
|
204
|
+
case "builtin:owner-work":
|
|
205
|
+
case "builtin:read-only-path":
|
|
206
|
+
case "builtin:secret-boundary":
|
|
207
|
+
return "mutation";
|
|
208
|
+
case "builtin:migration-integrity":
|
|
209
|
+
return "migration";
|
|
210
|
+
case "builtin:memory-candidate":
|
|
211
|
+
return "learning";
|
|
212
|
+
case "builtin:review-gate":
|
|
213
|
+
return "commit";
|
|
214
|
+
default:
|
|
215
|
+
return "completion";
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
162
219
|
function validateGuardrailDocument(content) {
|
|
163
220
|
const records = parseGuardrails(content);
|
|
164
221
|
const errors = [];
|
|
@@ -169,14 +226,40 @@ function validateGuardrailDocument(content) {
|
|
|
169
226
|
if (!GUARDRAIL_STATUSES.has(record.status)) errors.push(`${record.id} has invalid status: ${record.status || "missing"}`);
|
|
170
227
|
if (!record.rule.trim()) errors.push(`${record.id} has no rule content`);
|
|
171
228
|
if (!ENFORCEMENTS.has(record.enforcement)) errors.push(`${record.id} has unknown enforcement: ${record.enforcement}`);
|
|
229
|
+
if (!record.scope.length) errors.push(`${record.id} has no scope`);
|
|
230
|
+
for (const scope of record.scope) if (!SCOPES.has(scope)) errors.push(`${record.id} has unknown scope: ${scope}`);
|
|
172
231
|
}
|
|
173
232
|
return { records, errors };
|
|
174
233
|
}
|
|
175
234
|
|
|
235
|
+
function normalizeGuardrailDocument(content) {
|
|
236
|
+
const source = String(content || "");
|
|
237
|
+
const records = parseGuardrails(source);
|
|
238
|
+
const headings = [...source.matchAll(/^## (GR-\d{3,})\s*[-—:]\s*([^\r\n]+)$/gm)];
|
|
239
|
+
let next = source;
|
|
240
|
+
for (let index = headings.length - 1; index >= 0; index--) {
|
|
241
|
+
const heading = headings[index];
|
|
242
|
+
const record = records.find((item) => item.id === heading[1]);
|
|
243
|
+
if (!record) continue;
|
|
244
|
+
const missing = [];
|
|
245
|
+
if (!record.explicit.status) missing.push(`Status: ${record.status}`);
|
|
246
|
+
if (!record.explicit.enforcement) missing.push(`Enforcement: ${record.enforcement}`);
|
|
247
|
+
if (!record.explicit.scope) missing.push(`Scope: ${record.scope.join(", ") || "all"}`);
|
|
248
|
+
if (!record.explicit.rule) missing.push(`Rule: ${record.rule}`);
|
|
249
|
+
if (!missing.length) continue;
|
|
250
|
+
const lineEnd = next.indexOf("\n", heading.index);
|
|
251
|
+
const insertAt = lineEnd < 0 ? next.length : lineEnd + 1;
|
|
252
|
+
next = `${next.slice(0, insertAt)}\n${missing.join("\n")}\n${next.slice(insertAt)}`;
|
|
253
|
+
}
|
|
254
|
+
return next.endsWith("\n") ? next : `${next}\n`;
|
|
255
|
+
}
|
|
256
|
+
|
|
176
257
|
module.exports = {
|
|
177
258
|
configWeakeningAttempts,
|
|
178
259
|
evaluatePolicy,
|
|
179
260
|
inferEnforcement,
|
|
261
|
+
normalizeGuardrailDocument,
|
|
262
|
+
obligationGate,
|
|
180
263
|
normalized,
|
|
181
264
|
parseGuardrails,
|
|
182
265
|
readOnlyPaths,
|
|
@@ -4,6 +4,7 @@ const { sha256 } = require("../v2/artifacts");
|
|
|
4
4
|
const { buildContextPackage } = require("./context");
|
|
5
5
|
const { containsSecret } = require("../security/secrets");
|
|
6
6
|
const { evaluatePolicy, normalized } = require("./policy-engine");
|
|
7
|
+
const { workspaceState } = require("./workspace-state");
|
|
7
8
|
|
|
8
9
|
function assessRisk(request) {
|
|
9
10
|
const text = normalized(request);
|
|
@@ -71,6 +72,7 @@ function encodeApproval(plan) {
|
|
|
71
72
|
classification: plan.classification,
|
|
72
73
|
risk: plan.risk,
|
|
73
74
|
policy: plan.policy,
|
|
75
|
+
workspaceFingerprint: plan.workspaceFingerprint,
|
|
74
76
|
issuedAt: plan.issuedAt
|
|
75
77
|
};
|
|
76
78
|
const encoded = Buffer.from(stableJson(payload)).toString("base64url");
|
|
@@ -98,6 +100,7 @@ function planRequest(projectRoot, request) {
|
|
|
98
100
|
if (!normalized) throw new Error("A non-empty request is required for intake.");
|
|
99
101
|
if (normalized.length > 10000) throw new Error("Request exceeds the 10,000 character intake limit.");
|
|
100
102
|
const context = buildContextPackage(projectRoot, normalized);
|
|
103
|
+
const workspaceFingerprint = workspaceState(projectRoot).fingerprint;
|
|
101
104
|
const policy = applyPolicy(context);
|
|
102
105
|
const risk = assessRisk(normalized);
|
|
103
106
|
const classification = classifyRequest(normalized);
|
|
@@ -106,6 +109,7 @@ function planRequest(projectRoot, request) {
|
|
|
106
109
|
pipeline: ["received", "contextualizing", "policy", "risk", "classification", "planning", policy.status === "passed" ? "awaiting_approval" : "blocked"],
|
|
107
110
|
request: normalized,
|
|
108
111
|
context,
|
|
112
|
+
workspaceFingerprint,
|
|
109
113
|
policy,
|
|
110
114
|
risk,
|
|
111
115
|
classification,
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const path = require("node:path");
|
|
4
|
+
const { ArtifactRepository, METHOD_VERSION, withArtifactLock } = require("../v2/artifacts");
|
|
5
|
+
const { auditProject } = require("../v2/conformance");
|
|
6
|
+
const { assertCanonicalWrite } = require("./mutation-gateway");
|
|
7
|
+
|
|
8
|
+
function today() {
|
|
9
|
+
return new Date().toISOString().slice(0, 10);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function oneLine(value, label) {
|
|
13
|
+
const text = String(value || "").replace(/\s+/g, " ").trim();
|
|
14
|
+
if (!text) throw new Error(`${label} is required.`);
|
|
15
|
+
if (text.length > 500) throw new Error(`${label} exceeds 500 characters.`);
|
|
16
|
+
return text;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function nextReviewId(repository) {
|
|
20
|
+
const highest = repository.list("review").reduce((max, artifact) => {
|
|
21
|
+
const match = artifact.record.id.match(/-(\d+)$/);
|
|
22
|
+
return match ? Math.max(max, Number(match[1])) : max;
|
|
23
|
+
}, 0);
|
|
24
|
+
return `REV-${String(highest + 1).padStart(3, "0")}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function recordArtifactReviewUnlocked(projectRoot, options) {
|
|
28
|
+
const scrumDir = path.join(projectRoot, ".scrumrun");
|
|
29
|
+
const repository = new ArtifactRepository(scrumDir);
|
|
30
|
+
const taskId = oneLine(options.task, "task");
|
|
31
|
+
const task = repository.read("task", taskId);
|
|
32
|
+
if (!task || task.errors.length) throw new Error(`Review Task is missing or invalid: ${taskId}`);
|
|
33
|
+
const runId = options.run ? oneLine(options.run, "run") : null;
|
|
34
|
+
if (runId) {
|
|
35
|
+
const run = repository.read("run", runId);
|
|
36
|
+
if (!run || run.errors.length) throw new Error(`Review Run is missing or invalid: ${runId}`);
|
|
37
|
+
if (run.record.task !== taskId) throw new Error(`${runId} belongs to ${run.record.task}, not ${taskId}.`);
|
|
38
|
+
}
|
|
39
|
+
const title = oneLine(options.title || `Artifact conformance review for ${runId || taskId}`, "title");
|
|
40
|
+
const suppliedEvidence = (Array.isArray(options.evidence) ? options.evidence : []).map((item) => oneLine(item, "evidence"));
|
|
41
|
+
const audit = auditProject(projectRoot);
|
|
42
|
+
const created = today();
|
|
43
|
+
const record = {
|
|
44
|
+
id: nextReviewId(repository),
|
|
45
|
+
kind: "review",
|
|
46
|
+
status: audit.passed ? "passed" : "failed",
|
|
47
|
+
created,
|
|
48
|
+
updated: created,
|
|
49
|
+
method: METHOD_VERSION,
|
|
50
|
+
task: taskId
|
|
51
|
+
};
|
|
52
|
+
const findings = audit.findings.length
|
|
53
|
+
? audit.findings.map((finding) => `- ${finding.severity}: ${finding.code} — ${finding.message}`)
|
|
54
|
+
: ["- No conformance findings."];
|
|
55
|
+
const evidence = [
|
|
56
|
+
`- Artifact audit: ${audit.invariants} invariants; ${audit.findings.length} finding(s); passed=${audit.passed}.`,
|
|
57
|
+
...(runId ? [`- Run under review: ${runId}.`] : []),
|
|
58
|
+
...suppliedEvidence.map((item) => `- ${item}`)
|
|
59
|
+
];
|
|
60
|
+
const verdict = audit.passed
|
|
61
|
+
? "Passed. The machine-readable artifact audit has no blocking finding."
|
|
62
|
+
: "Failed. Blocking conformance findings must be resolved in a separately authorized mutation.";
|
|
63
|
+
const body = [
|
|
64
|
+
`# ${title}`,
|
|
65
|
+
"",
|
|
66
|
+
"## Scope",
|
|
67
|
+
"",
|
|
68
|
+
`Review canonical project conformance for ${runId || taskId}.`,
|
|
69
|
+
"",
|
|
70
|
+
"## Evidence",
|
|
71
|
+
"",
|
|
72
|
+
...evidence,
|
|
73
|
+
"",
|
|
74
|
+
"## Findings",
|
|
75
|
+
"",
|
|
76
|
+
...findings,
|
|
77
|
+
"",
|
|
78
|
+
"## Verdict",
|
|
79
|
+
"",
|
|
80
|
+
verdict
|
|
81
|
+
].join("\n");
|
|
82
|
+
assertCanonicalWrite(projectRoot, "record-artifact-review", [title, ...suppliedEvidence, body]);
|
|
83
|
+
repository.write(record, body);
|
|
84
|
+
return { review: repository.read("review", record.id), audit };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function recordArtifactReview(projectRoot, options = {}) {
|
|
88
|
+
const scrumDir = path.join(projectRoot, ".scrumrun");
|
|
89
|
+
return withArtifactLock(scrumDir, "review-artifact", () => recordArtifactReviewUnlocked(projectRoot, options));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = { recordArtifactReview };
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
+
const crypto = require("node:crypto");
|
|
4
|
+
|
|
3
5
|
const {
|
|
4
6
|
ARTIFACT_TRANSITIONS,
|
|
7
|
+
GUARDRAIL_RESULTS,
|
|
5
8
|
RUN_EVENT_TYPES,
|
|
6
9
|
RUN_EVIDENCE_KINDS,
|
|
7
10
|
RUN_LEDGER_VERSION
|
|
@@ -57,7 +60,8 @@ function makeRunEvent({
|
|
|
57
60
|
from,
|
|
58
61
|
to,
|
|
59
62
|
reason,
|
|
60
|
-
evidence = []
|
|
63
|
+
evidence = [],
|
|
64
|
+
...details
|
|
61
65
|
}) {
|
|
62
66
|
return {
|
|
63
67
|
schema: RUN_LEDGER_VERSION,
|
|
@@ -70,7 +74,8 @@ function makeRunEvent({
|
|
|
70
74
|
from: from === undefined ? null : from,
|
|
71
75
|
to,
|
|
72
76
|
reason,
|
|
73
|
-
evidence: normalizeEvidence(evidence)
|
|
77
|
+
evidence: normalizeEvidence(evidence),
|
|
78
|
+
...details
|
|
74
79
|
};
|
|
75
80
|
}
|
|
76
81
|
|
|
@@ -92,7 +97,10 @@ function createRunBody(record, {
|
|
|
92
97
|
occurredAt = instant(),
|
|
93
98
|
actor = "owner",
|
|
94
99
|
reason = "Explicit approval accepted.",
|
|
95
|
-
evidence = null
|
|
100
|
+
evidence = null,
|
|
101
|
+
obligations = [],
|
|
102
|
+
policyFingerprint = null,
|
|
103
|
+
workspaceBaseline = null
|
|
96
104
|
} = {}) {
|
|
97
105
|
const eventEvidence = evidence || [{
|
|
98
106
|
kind: "approval",
|
|
@@ -107,9 +115,133 @@ function createRunBody(record, {
|
|
|
107
115
|
from: "created",
|
|
108
116
|
to: "executing",
|
|
109
117
|
reason,
|
|
110
|
-
evidence: eventEvidence
|
|
118
|
+
evidence: eventEvidence,
|
|
119
|
+
...(policyFingerprint ? { policy_fingerprint: policyFingerprint } : {}),
|
|
120
|
+
...(workspaceBaseline ? { workspace_baseline: workspaceBaseline } : {})
|
|
111
121
|
});
|
|
112
|
-
|
|
122
|
+
const declarations = obligations.map((obligation, index) => makeRunEvent({
|
|
123
|
+
runId: record.id,
|
|
124
|
+
sequence: index + 2,
|
|
125
|
+
type: "guardrail",
|
|
126
|
+
occurredAt,
|
|
127
|
+
actor: "policy-engine",
|
|
128
|
+
from: "executing",
|
|
129
|
+
to: "executing",
|
|
130
|
+
reason: `Guardrail obligation declared for ${obligation.gate}.`,
|
|
131
|
+
evidence: [{ kind: "guardrail", ref: obligation.guardrail, summary: `${obligation.code} must pass at ${obligation.gate}.` }],
|
|
132
|
+
guardrail: obligation.guardrail,
|
|
133
|
+
code: obligation.code,
|
|
134
|
+
enforcement: obligation.enforcement,
|
|
135
|
+
gate: obligation.gate,
|
|
136
|
+
result: "pending",
|
|
137
|
+
scope: obligation.scope || []
|
|
138
|
+
}));
|
|
139
|
+
const events = [event, ...declarations];
|
|
140
|
+
return { body: `# ${title}\n\n${eventsSection(events)}`, event, events };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function validateWorkspaceState(value, label) {
|
|
144
|
+
const errors = [];
|
|
145
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return [`${label} must be an object`];
|
|
146
|
+
if (value.schema !== 1) errors.push(`${label}.schema must be 1`);
|
|
147
|
+
if (!['git', 'files'].includes(value.mode)) errors.push(`${label}.mode must be git or files`);
|
|
148
|
+
if (!/^[a-f0-9]{64}$/.test(value.fingerprint || "")) errors.push(`${label}.fingerprint must be sha256`);
|
|
149
|
+
if (!Array.isArray(value.files)) errors.push(`${label}.files must be an array`);
|
|
150
|
+
const paths = new Set();
|
|
151
|
+
for (const item of Array.isArray(value.files) ? value.files : []) {
|
|
152
|
+
if (!item || typeof item !== "object" || typeof item.path !== "string" || !item.path || item.path.startsWith("/") || item.path.split("/").includes("..")) {
|
|
153
|
+
errors.push(`${label}.files contains an unsafe path`);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (paths.has(item.path)) errors.push(`${label}.files contains duplicate path: ${item.path}`);
|
|
157
|
+
paths.add(item.path);
|
|
158
|
+
if (item.sha256 !== null && !/^[a-f0-9]{64}$/.test(item.sha256 || "")) errors.push(`${label}.${item.path}.sha256 is invalid`);
|
|
159
|
+
if (!["file", "missing", "symlink", "other", "clean"].includes(item.kind)) errors.push(`${label}.${item.path}.kind is invalid`);
|
|
160
|
+
if (item.mode !== null && (!Number.isInteger(item.mode) || item.mode < 0 || item.mode > 0o777)) errors.push(`${label}.${item.path}.mode is invalid`);
|
|
161
|
+
if (!["text", "binary", "too-large", "not-applicable"].includes(item.scan)) errors.push(`${label}.${item.path}.scan is invalid`);
|
|
162
|
+
if (item.kind === "file" && !["text", "binary", "too-large"].includes(item.scan)) errors.push(`${label}.${item.path}.scan is invalid for a file`);
|
|
163
|
+
if (item.kind !== "file" && item.scan !== "not-applicable") errors.push(`${label}.${item.path}.scan must be not-applicable`);
|
|
164
|
+
}
|
|
165
|
+
const canonical = value && {
|
|
166
|
+
schema: value.schema,
|
|
167
|
+
mode: value.mode,
|
|
168
|
+
head: value.head || null,
|
|
169
|
+
files: Array.isArray(value.files) ? value.files.map((item) => ({ path: item.path, sha256: item.sha256, kind: item.kind, mode: item.mode, scan: item.scan })) : []
|
|
170
|
+
};
|
|
171
|
+
const fingerprint = crypto.createHash("sha256").update(stableJson(canonical)).digest("hex");
|
|
172
|
+
if (value && value.fingerprint !== fingerprint) errors.push(`${label}.fingerprint does not match its workspace state`);
|
|
173
|
+
return errors;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function validateGuardrailEvent(event, declared) {
|
|
177
|
+
const errors = [];
|
|
178
|
+
if (!/^GR-\d{3,}$/.test(event.guardrail || "")) errors.push(`${event.id}.guardrail is invalid`);
|
|
179
|
+
if (!GUARDRAIL_RESULTS.includes(event.result)) errors.push(`${event.id}.result is invalid: ${event.result || "missing"}`);
|
|
180
|
+
if (typeof event.code !== "string" || !event.code.trim()) errors.push(`${event.id}.code is required`);
|
|
181
|
+
if (typeof event.enforcement !== "string" || !event.enforcement.trim()) errors.push(`${event.id}.enforcement is required`);
|
|
182
|
+
if (typeof event.gate !== "string" || !event.gate.trim()) errors.push(`${event.id}.gate is required`);
|
|
183
|
+
if (!Array.isArray(event.scope)) errors.push(`${event.id}.scope must be an array`);
|
|
184
|
+
if (event.result === "pending") {
|
|
185
|
+
if (declared.has(event.guardrail)) errors.push(`${event.id} redeclares ${event.guardrail}`);
|
|
186
|
+
declared.set(event.guardrail, { code: event.code, enforcement: event.enforcement, gate: event.gate, scope: event.scope || [] });
|
|
187
|
+
} else if (!declared.has(event.guardrail)) {
|
|
188
|
+
errors.push(`${event.id} resolves undeclared ${event.guardrail}`);
|
|
189
|
+
} else {
|
|
190
|
+
const original = declared.get(event.guardrail);
|
|
191
|
+
for (const field of ["code", "enforcement", "gate"]) {
|
|
192
|
+
if (event[field] !== original[field]) errors.push(`${event.id}.${field} disagrees with the ${event.guardrail} declaration`);
|
|
193
|
+
}
|
|
194
|
+
if (stableJson(event.scope || []) !== stableJson(original.scope)) errors.push(`${event.id}.scope disagrees with the ${event.guardrail} declaration`);
|
|
195
|
+
}
|
|
196
|
+
return errors;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function stableJson(value) {
|
|
200
|
+
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
|
201
|
+
if (value && typeof value === "object") return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
|
|
202
|
+
return JSON.stringify(value);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function safeEventPath(value) {
|
|
206
|
+
return typeof value === "string" && value && !value.startsWith("/") && !value.split("/").includes("..") && value !== ".scrumrun" && !value.startsWith(".scrumrun/");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function validateMutationEvent(event, expectedWorkspace, policyFingerprint) {
|
|
210
|
+
const errors = [];
|
|
211
|
+
if (!/^MUT-[A-Za-z0-9-]{8,}$/.test(event.mutation_id || "")) errors.push(`${event.id}.mutation_id is invalid`);
|
|
212
|
+
if (!Array.isArray(event.paths) || !event.paths.length) errors.push(`${event.id}.paths must be a non-empty array`);
|
|
213
|
+
if (!Array.isArray(event.changes) || !event.changes.length) errors.push(`${event.id}.changes must be a non-empty array`);
|
|
214
|
+
if (!/^[a-f0-9]{64}$/.test(event.workspace_before || "")) errors.push(`${event.id}.workspace_before must be sha256`);
|
|
215
|
+
if (expectedWorkspace && event.workspace_before !== expectedWorkspace) errors.push(`${event.id}.workspace_before breaks the mutation chain`);
|
|
216
|
+
if (event.policy_fingerprint !== policyFingerprint) errors.push(`${event.id}.policy_fingerprint disagrees with Run approval`);
|
|
217
|
+
const paths = new Set();
|
|
218
|
+
for (const value of Array.isArray(event.paths) ? event.paths : []) {
|
|
219
|
+
if (!safeEventPath(value)) errors.push(`${event.id}.paths contains an unsafe path`);
|
|
220
|
+
if (paths.has(value)) errors.push(`${event.id}.paths contains duplicate path: ${value}`);
|
|
221
|
+
paths.add(value);
|
|
222
|
+
}
|
|
223
|
+
const changed = new Set();
|
|
224
|
+
for (const change of Array.isArray(event.changes) ? event.changes : []) {
|
|
225
|
+
if (!change || !safeEventPath(change.path)) {
|
|
226
|
+
errors.push(`${event.id}.changes contains an unsafe path`);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (changed.has(change.path)) errors.push(`${event.id}.changes contains duplicate path: ${change.path}`);
|
|
230
|
+
changed.add(change.path);
|
|
231
|
+
if (![...paths].some((allowed) => change.path === allowed || change.path.startsWith(`${allowed}/`))) errors.push(`${event.id}.${change.path} is outside declared paths`);
|
|
232
|
+
for (const field of ["before_sha256", "after_sha256"]) {
|
|
233
|
+
if (change[field] !== null && !/^[a-f0-9]{64}$/.test(change[field] || "")) errors.push(`${event.id}.${change.path}.${field} is invalid`);
|
|
234
|
+
}
|
|
235
|
+
for (const field of ["before_scan", "after_scan"]) {
|
|
236
|
+
if (!["text", "binary", "too-large", "not-applicable"].includes(change[field])) errors.push(`${event.id}.${change.path}.${field} is invalid`);
|
|
237
|
+
}
|
|
238
|
+
for (const field of ["before_mode", "after_mode"]) {
|
|
239
|
+
if (change[field] !== null && (!Number.isInteger(change[field]) || change[field] < 0 || change[field] > 0o777)) errors.push(`${event.id}.${change.path}.${field} is invalid`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (!event.workspace_after) errors.push(`${event.id}.workspace_after is required`);
|
|
243
|
+
else errors.push(...validateWorkspaceState(event.workspace_after, `${event.id}.workspace_after`));
|
|
244
|
+
return errors;
|
|
113
245
|
}
|
|
114
246
|
|
|
115
247
|
function parseRunLedger(body) {
|
|
@@ -158,6 +290,10 @@ function validateRunLedger(record, body) {
|
|
|
158
290
|
const ids = new Set();
|
|
159
291
|
let previous = null;
|
|
160
292
|
let previousTime = null;
|
|
293
|
+
const declared = new Map();
|
|
294
|
+
const obligationResults = new Map();
|
|
295
|
+
let expectedWorkspace = events[0] && events[0].workspace_baseline && events[0].workspace_baseline.fingerprint;
|
|
296
|
+
const policyFingerprint = events[0] && events[0].policy_fingerprint;
|
|
161
297
|
for (let index = 0; index < events.length; index++) {
|
|
162
298
|
const event = events[index];
|
|
163
299
|
const expectedSequence = index + 1;
|
|
@@ -188,6 +324,18 @@ function validateRunLedger(record, body) {
|
|
|
188
324
|
if (!event.evidence.some((item) => item && ["migration", "legacy"].includes(item.kind))) {
|
|
189
325
|
errors.push(`${event.id} snapshot requires migration or legacy evidence`);
|
|
190
326
|
}
|
|
327
|
+
} else if (["guardrail", "mutation"].includes(event.type)) {
|
|
328
|
+
if (!previous) errors.push(`${event.id} cannot precede the initial Run event`);
|
|
329
|
+
const currentState = previous && previous.to;
|
|
330
|
+
if (event.from !== currentState || event.to !== currentState) errors.push(`${event.id} must not change Run state ${currentState || "unknown"}`);
|
|
331
|
+
if (event.type === "guardrail") {
|
|
332
|
+
errors.push(...validateGuardrailEvent(event, declared));
|
|
333
|
+
if (event.result !== "pending") obligationResults.set(event.guardrail, event.result);
|
|
334
|
+
else obligationResults.set(event.guardrail, "pending");
|
|
335
|
+
} else {
|
|
336
|
+
errors.push(...validateMutationEvent(event, expectedWorkspace, policyFingerprint));
|
|
337
|
+
if (event.workspace_after && event.workspace_after.fingerprint) expectedWorkspace = event.workspace_after.fingerprint;
|
|
338
|
+
}
|
|
191
339
|
} else {
|
|
192
340
|
const expectedFrom = index === 0 ? "created" : previous.to;
|
|
193
341
|
if (event.type !== "transition") errors.push(`${event.id} snapshot is allowed only as the first event`);
|
|
@@ -202,6 +350,12 @@ function validateRunLedger(record, body) {
|
|
|
202
350
|
if (!["snapshot"].includes(event.type) && !event.evidence.length) errors.push(`${event.id} transition requires evidence`);
|
|
203
351
|
previous = event;
|
|
204
352
|
}
|
|
353
|
+
if (record.workspace === 1 && events[0] && events[0].type !== "snapshot") {
|
|
354
|
+
errors.push(...validateWorkspaceState(events[0].workspace_baseline, `${events[0].id}.workspace_baseline`));
|
|
355
|
+
}
|
|
356
|
+
if (record.guardrails === 1 && events[0] && events[0].type !== "snapshot") {
|
|
357
|
+
if (!/^[a-f0-9]{64}$/.test(events[0].policy_fingerprint || "")) errors.push(`${events[0].id}.policy_fingerprint must be sha256`);
|
|
358
|
+
}
|
|
205
359
|
if (previous && previous.to !== record.status) errors.push(`Run status ${record.status} disagrees with final event state ${previous.to}`);
|
|
206
360
|
if (previous && record.updated !== String(previous.occurred_at || "").slice(0, 10)) {
|
|
207
361
|
errors.push(`Run updated date ${record.updated} disagrees with final event timestamp`);
|
|
@@ -211,8 +365,73 @@ function validateRunLedger(record, body) {
|
|
|
211
365
|
const event = events.find((item) => item.type === "transition" && item.to === required);
|
|
212
366
|
if (!event || !event.evidence.length) errors.push(`completed Run requires evidenced ${required} transition`);
|
|
213
367
|
}
|
|
368
|
+
for (const guardrail of declared.keys()) {
|
|
369
|
+
if (obligationResults.get(guardrail) !== "passed") errors.push(`completed Run has unresolved Guardrail obligation: ${guardrail}`);
|
|
370
|
+
}
|
|
214
371
|
}
|
|
215
|
-
return { events, errors };
|
|
372
|
+
return { events, errors, obligations: [...declared.keys()].map((guardrail) => ({ guardrail, status: obligationResults.get(guardrail) || "pending" })) };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function appendAuxiliaryEvent(record, body, details, options = {}) {
|
|
376
|
+
const current = validateRunLedger(record, body);
|
|
377
|
+
if (current.errors.length) throw new Error(`Invalid Run ledger: ${current.errors.join("; ")}`);
|
|
378
|
+
const occurredAt = options.occurredAt ? instant(options.occurredAt) : instant();
|
|
379
|
+
const nextRecord = { ...record, updated: occurredAt.slice(0, 10) };
|
|
380
|
+
const evidence = normalizeEvidence(options.evidence || []);
|
|
381
|
+
if (!evidence.length) throw new Error(`${details.type} event requires structured evidence.`);
|
|
382
|
+
const event = makeRunEvent({
|
|
383
|
+
runId: record.id,
|
|
384
|
+
sequence: current.events.length + 1,
|
|
385
|
+
type: details.type,
|
|
386
|
+
occurredAt,
|
|
387
|
+
actor: options.actor || "agent",
|
|
388
|
+
from: record.status,
|
|
389
|
+
to: record.status,
|
|
390
|
+
reason: String(options.note || evidence.map((item) => item.summary || item.ref).filter(Boolean).join("; ")).trim(),
|
|
391
|
+
evidence,
|
|
392
|
+
...details
|
|
393
|
+
});
|
|
394
|
+
const nextBody = `${String(body).trimEnd()}\n\n${renderRunEvent(event)}\n`;
|
|
395
|
+
const validated = validateRunLedger(nextRecord, nextBody);
|
|
396
|
+
if (validated.errors.length) throw new Error(`${details.type} event failed validation: ${validated.errors.join("; ")}`);
|
|
397
|
+
return { body: nextBody, record: nextRecord, event, events: validated.events, obligations: validated.obligations };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function appendGuardrailEvent(record, body, obligation, result, options = {}) {
|
|
401
|
+
if (!GUARDRAIL_RESULTS.includes(result) || result === "pending") throw new Error(`Guardrail result must be passed or blocked.`);
|
|
402
|
+
return appendAuxiliaryEvent(record, body, {
|
|
403
|
+
type: "guardrail",
|
|
404
|
+
guardrail: obligation.guardrail,
|
|
405
|
+
code: obligation.code,
|
|
406
|
+
enforcement: obligation.enforcement,
|
|
407
|
+
gate: obligation.gate,
|
|
408
|
+
result,
|
|
409
|
+
scope: obligation.scope || []
|
|
410
|
+
}, options);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function appendMutationEvent(record, body, mutation, options = {}) {
|
|
414
|
+
return appendAuxiliaryEvent(record, body, { type: "mutation", ...mutation }, options);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function guardrailState(record, body) {
|
|
418
|
+
const ledger = validateRunLedger(record, body);
|
|
419
|
+
if (ledger.errors.length) throw new Error(`Invalid Run ledger: ${ledger.errors.join("; ")}`);
|
|
420
|
+
const declarations = new Map();
|
|
421
|
+
const results = new Map();
|
|
422
|
+
for (const event of ledger.events.filter((item) => item.type === "guardrail")) {
|
|
423
|
+
if (event.result === "pending") declarations.set(event.guardrail, event);
|
|
424
|
+
else results.set(event.guardrail, event);
|
|
425
|
+
}
|
|
426
|
+
return [...declarations.values()].map((event) => ({
|
|
427
|
+
guardrail: event.guardrail,
|
|
428
|
+
code: event.code,
|
|
429
|
+
enforcement: event.enforcement,
|
|
430
|
+
gate: event.gate,
|
|
431
|
+
scope: event.scope || [],
|
|
432
|
+
status: results.has(event.guardrail) ? results.get(event.guardrail).result : "pending",
|
|
433
|
+
resultEvent: results.get(event.guardrail) || null
|
|
434
|
+
}));
|
|
216
435
|
}
|
|
217
436
|
|
|
218
437
|
function appendRunEvent(record, body, nextRecord, {
|
|
@@ -309,12 +528,15 @@ function migrateLegacyRun(record, body, { legacyHash, backupRef }) {
|
|
|
309
528
|
}
|
|
310
529
|
|
|
311
530
|
module.exports = {
|
|
531
|
+
appendGuardrailEvent,
|
|
532
|
+
appendMutationEvent,
|
|
312
533
|
appendRunEvent,
|
|
313
534
|
createRunBody,
|
|
314
535
|
dateInstant,
|
|
315
536
|
eventId,
|
|
316
537
|
eventsSection,
|
|
317
538
|
evidenceForTransition,
|
|
539
|
+
guardrailState,
|
|
318
540
|
instant,
|
|
319
541
|
makeRunEvent,
|
|
320
542
|
migrateLegacyRun,
|