scrumrun 2.1.1 → 2.3.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/CHANGELOG.md +29 -0
- package/README.md +60 -2
- package/SPEC.md +4 -1
- package/bin/scrumrun.js +62 -4
- package/docs/DEMO.md +67 -0
- package/docs/ERROR-CODES.md +141 -0
- package/docs/INDEX.md +53 -0
- package/docs/QUICKSTART.md +156 -0
- package/lib/commands/manifest.js +3 -1
- package/lib/commands/run-render.js +161 -0
- package/lib/commands/run-stats.js +206 -0
- package/lib/errors.js +98 -0
- package/lib/memory/index.js +1 -1
- package/lib/security/secrets.js +72 -1
- package/lib/v2/conformance.js +10 -4
- package/lib/v2/migration.js +3 -2
- package/lib/v2/transaction.js +59 -0
- package/package.json +2 -2
package/lib/security/secrets.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
const crypto = require("node:crypto");
|
|
4
|
+
const fs = require("node:fs");
|
|
5
|
+
const path = require("node:path");
|
|
4
6
|
|
|
5
7
|
const SECRET_PATTERNS = Object.freeze([
|
|
6
8
|
/\bsk-[A-Za-z0-9_-]{16,}\b/,
|
|
@@ -34,4 +36,73 @@ function secretFingerprints(value) {
|
|
|
34
36
|
return [...found].sort();
|
|
35
37
|
}
|
|
36
38
|
|
|
37
|
-
|
|
39
|
+
function parseFrontmatter(text) {
|
|
40
|
+
const match = String(text || "").match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
41
|
+
if (!match) return { explicit: {}, raw: "" };
|
|
42
|
+
const body = match[1];
|
|
43
|
+
const explicit = {};
|
|
44
|
+
for (const line of body.split(/\r?\n/)) {
|
|
45
|
+
if (!line.trim() || line.trim().startsWith("#")) continue;
|
|
46
|
+
const kv = line.match(/^([A-Za-z0-9_.-]+)\s*:\s*(.*)$/);
|
|
47
|
+
if (!kv) continue;
|
|
48
|
+
const key = kv[1].trim();
|
|
49
|
+
let value = kv[2].trim();
|
|
50
|
+
if (value.startsWith("[") && value.endsWith("]")) {
|
|
51
|
+
value = value.slice(1, -1).split(",").map((s) => s.trim().replace(/^['"]|['"]$/g, "")).filter(Boolean);
|
|
52
|
+
} else if (/^['"].*['"]$/.test(value)) {
|
|
53
|
+
value = value.slice(1, -1);
|
|
54
|
+
} else if (value === "true" || value === "false") {
|
|
55
|
+
value = value === "true";
|
|
56
|
+
}
|
|
57
|
+
explicit[key] = value;
|
|
58
|
+
}
|
|
59
|
+
return { explicit, raw: body };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function loadSecretAllowlist(scrumDir) {
|
|
63
|
+
if (!scrumDir || !fs.existsSync(scrumDir)) return new Set();
|
|
64
|
+
const configFile = path.join(scrumDir, "config.md");
|
|
65
|
+
if (!fs.existsSync(configFile) || !fs.lstatSync(configFile).isFile()) return new Set();
|
|
66
|
+
let text;
|
|
67
|
+
try {
|
|
68
|
+
text = fs.readFileSync(configFile, "utf8");
|
|
69
|
+
} catch {
|
|
70
|
+
return new Set();
|
|
71
|
+
}
|
|
72
|
+
const { explicit } = parseFrontmatter(text);
|
|
73
|
+
const raw = explicit.allow_secrets_in;
|
|
74
|
+
if (!raw) return new Set();
|
|
75
|
+
const list = Array.isArray(raw) ? raw : [String(raw)];
|
|
76
|
+
return new Set(list.map((p) => String(p).replace(/^\.\//, "").trim()).filter(Boolean));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isSecretAllowed(relativePath, allowlist) {
|
|
80
|
+
if (!allowlist || !allowlist.size) return false;
|
|
81
|
+
if (!relativePath) return false;
|
|
82
|
+
const normalized = String(relativePath).replace(/^\.\//, "");
|
|
83
|
+
if (allowlist.has(normalized)) return true;
|
|
84
|
+
for (const entry of allowlist) {
|
|
85
|
+
if (entry.endsWith("/") && normalized.startsWith(entry)) return true;
|
|
86
|
+
if (entry.includes("*")) {
|
|
87
|
+
const re = new RegExp("^" + entry.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$");
|
|
88
|
+
if (re.test(normalized)) return true;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function containsSecretWithAllowlist(value, relativePath, allowlist) {
|
|
95
|
+
if (isSecretAllowed(relativePath, allowlist)) return false;
|
|
96
|
+
return containsSecret(value);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = {
|
|
100
|
+
SECRET_PATTERNS,
|
|
101
|
+
assertNoSecret,
|
|
102
|
+
containsSecret,
|
|
103
|
+
containsSecretWithAllowlist,
|
|
104
|
+
isSecretAllowed,
|
|
105
|
+
loadSecretAllowlist,
|
|
106
|
+
parseFrontmatter,
|
|
107
|
+
secretFingerprints
|
|
108
|
+
};
|
package/lib/v2/conformance.js
CHANGED
|
@@ -9,7 +9,7 @@ const { validateRunLedger } = require("../runtime/run-ledger");
|
|
|
9
9
|
const { indexStatus, mapStatus } = require("../memory/index");
|
|
10
10
|
const { extractEvidence } = require("../memory/markdown");
|
|
11
11
|
const { resolveEvidence } = require("../memory/service");
|
|
12
|
-
const { containsSecret } = require("../security/secrets");
|
|
12
|
+
const { containsSecret, containsSecretWithAllowlist, loadSecretAllowlist } = require("../security/secrets");
|
|
13
13
|
const { pendingTransactionStatus } = require("./transaction");
|
|
14
14
|
const { configWeakeningAttempts, validateGuardrailDocument } = require("../runtime/policy-engine");
|
|
15
15
|
const { auditActiveWorkspace } = require("../runtime/mutation-gateway");
|
|
@@ -35,7 +35,8 @@ const INVARIANTS = Object.freeze([
|
|
|
35
35
|
{ id: "I-18", summary: "unsafe and partial writes fail safely", tests: ["canonical writes reject traversal and symlink paths", "repository refuses conflicting overwrite", "interrupted kernel transaction is recovered"] },
|
|
36
36
|
{ id: "I-19", summary: "code intelligence is derived and fingerprinted", tests: ["language adapters are replaceable", "moves remap by fingerprint"] },
|
|
37
37
|
{ id: "I-20", summary: "learning candidates never block execution", tests: ["post-validation extraction creates candidate insights"] },
|
|
38
|
-
{ id: "I-21", summary: "material mutations are scoped, policy-bound, and fail closed", tests: ["Mutation Gateway rejects bypass and out-of-scope writes", "Run completion rejects unresolved Guardrail obligations"] }
|
|
38
|
+
{ id: "I-21", summary: "material mutations are scoped, policy-bound, and fail closed", tests: ["Mutation Gateway rejects bypass and out-of-scope writes", "Run completion rejects unresolved Guardrail obligations"] },
|
|
39
|
+
{ id: "I-22", summary: "declared search backend matches observed runtime capabilities", tests: ["conformance detects a semantic index that declares fts5 when the runtime does not provide it"] }
|
|
39
40
|
]);
|
|
40
41
|
|
|
41
42
|
function finding(severity, code, message, file = null) {
|
|
@@ -91,7 +92,8 @@ function auditProject(projectRoot) {
|
|
|
91
92
|
counts[kind] = artifacts.length;
|
|
92
93
|
for (const artifact of artifacts) {
|
|
93
94
|
for (const error of artifact.errors) findings.push(finding("high", "ARTIFACT_INVALID", `${path.basename(artifact.file)}: ${error}`, artifact.file));
|
|
94
|
-
|
|
95
|
+
const relativeArtifact = path.relative(scrumDir, artifact.file);
|
|
96
|
+
if (containsSecret(fs.readFileSync(artifact.file, "utf8"))) findings.push(finding("critical", "SECRET_CANONICAL", `Secret-like content detected in ${relativeArtifact}.`, artifact.file));
|
|
95
97
|
if (artifact.record && ids.has(artifact.record.id)) findings.push(finding("critical", "ID_DUPLICATE", `Duplicate artifact id: ${artifact.record.id}`));
|
|
96
98
|
if (artifact.record) ids.add(artifact.record.id);
|
|
97
99
|
}
|
|
@@ -212,7 +214,11 @@ function auditProject(projectRoot) {
|
|
|
212
214
|
}
|
|
213
215
|
try {
|
|
214
216
|
const semantic = indexStatus(projectRoot);
|
|
215
|
-
if (semantic.exists && semantic.
|
|
217
|
+
if (semantic.exists && semantic.backendMismatch) {
|
|
218
|
+
findings.push(finding("high", "SEARCH_BACKEND_MISMATCH", `semantic index declares search_backend=${semantic.searchBackend} but the current Node.js runtime does not provide it; delete .scrumrun/.cache/semantic-index.sqlite to rebuild against the observed capabilities.`));
|
|
219
|
+
} else if (semantic.exists && semantic.stale) {
|
|
220
|
+
findings.push(finding("warning", "INDEX_STALE", "semantic-index.sqlite is stale and will be rebuilt on query."));
|
|
221
|
+
}
|
|
216
222
|
const map = mapStatus(projectRoot, { semanticStatus: semantic });
|
|
217
223
|
if (semantic.exists && map.stale) findings.push(finding("warning", "MAP_STALE", `map.md is stale: ${map.reason || map.error || "unknown reason"}.`));
|
|
218
224
|
} catch (error) {
|
package/lib/v2/migration.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const fs = require("node:fs");
|
|
4
4
|
const path = require("node:path");
|
|
5
5
|
const { extractEvidence } = require("../memory/markdown");
|
|
6
|
-
const { containsSecret } = require("../security/secrets");
|
|
6
|
+
const { containsSecret, containsSecretWithAllowlist, loadSecretAllowlist } = require("../security/secrets");
|
|
7
7
|
const {
|
|
8
8
|
ARTIFACT_TYPES,
|
|
9
9
|
ArtifactRepository,
|
|
@@ -297,10 +297,11 @@ function migrationPlan(projectRoot) {
|
|
|
297
297
|
}));
|
|
298
298
|
const warnings = [];
|
|
299
299
|
const errors = inventory.symlinks.map((entry) => `Symbolic link is not migrated automatically: ${entry.path}`);
|
|
300
|
+
const secretAllowlist = loadSecretAllowlist(scrumDir);
|
|
300
301
|
for (const entry of inventory.files) {
|
|
301
302
|
if (/(^|\/)vault(?:\.local)?\.md$/i.test(entry.path) || !/\.(?:md|json|txt)$/i.test(entry.path)) continue;
|
|
302
303
|
const content = fs.readFileSync(path.join(scrumDir, entry.path), "utf8");
|
|
303
|
-
if (
|
|
304
|
+
if (containsSecretWithAllowlist(content, entry.path, secretAllowlist)) errors.push(`Secret-like content detected outside the local vault: ${entry.path}`);
|
|
304
305
|
}
|
|
305
306
|
const allocator = new IdAllocator(scrumDir);
|
|
306
307
|
const existingArtifacts = Object.fromEntries(Object.keys(ARTIFACT_TYPES).map((kind) => [kind, []]));
|
package/lib/v2/transaction.js
CHANGED
|
@@ -236,6 +236,64 @@ function runKernelTransaction(scrumDir, name, changes, options = {}) {
|
|
|
236
236
|
});
|
|
237
237
|
}
|
|
238
238
|
|
|
239
|
+
function previewPendingRecovery(scrumDir) {
|
|
240
|
+
const preview = { plans: [], errors: [] };
|
|
241
|
+
let entries;
|
|
242
|
+
try {
|
|
243
|
+
entries = pendingTransactions(scrumDir);
|
|
244
|
+
} catch (error) {
|
|
245
|
+
preview.errors.push(error.message);
|
|
246
|
+
return preview;
|
|
247
|
+
}
|
|
248
|
+
for (const { file, transaction } of entries) {
|
|
249
|
+
const plan = { id: transaction.id, name: transaction.name || null, journal: path.basename(file), status: transaction.status, action: null, files: [], warnings: [] };
|
|
250
|
+
const journalErrors = validateJournal(transaction);
|
|
251
|
+
if (journalErrors.length) {
|
|
252
|
+
plan.action = "blocked";
|
|
253
|
+
plan.warnings.push(`journal validation failed: ${journalErrors.join("; ")}`);
|
|
254
|
+
preview.plans.push(plan);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
let unsafePath = null;
|
|
258
|
+
for (const change of transaction.changes) {
|
|
259
|
+
const target = relativeTarget(scrumDir, path.join(scrumDir, change.relative));
|
|
260
|
+
if (target.relative !== change.relative) {
|
|
261
|
+
unsafePath = change.relative;
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (unsafePath) {
|
|
266
|
+
plan.action = "blocked";
|
|
267
|
+
plan.warnings.push(`unsafe transaction target: ${unsafePath}`);
|
|
268
|
+
preview.plans.push(plan);
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
const current = transaction.changes.map((change) => ({ change, value: fileSnapshot(scrumDir, change.relative) }));
|
|
272
|
+
const unexpected = current.filter(({ change, value }) => !sameSnapshot(value, change.before) && !sameSnapshot(value, change.after));
|
|
273
|
+
if (unexpected.length) {
|
|
274
|
+
plan.action = "blocked";
|
|
275
|
+
plan.warnings.push(`would overwrite owner changes at: ${unexpected.map(({ change }) => change.relative).join(", ")}`);
|
|
276
|
+
preview.plans.push(plan);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (transaction.status === "prepared") {
|
|
280
|
+
plan.action = "rollback";
|
|
281
|
+
plan.files = transaction.changes.map((change) => ({ relative: change.relative, from: "current", to: "pre-transaction" }));
|
|
282
|
+
} else {
|
|
283
|
+
const incomplete = current.filter(({ change, value }) => !sameSnapshot(value, change.after));
|
|
284
|
+
if (incomplete.length) {
|
|
285
|
+
plan.action = "blocked";
|
|
286
|
+
plan.warnings.push(`committed transaction is incomplete: ${incomplete.map(({ change }) => change.relative).join(", ")}`);
|
|
287
|
+
} else {
|
|
288
|
+
plan.action = "verify-commit";
|
|
289
|
+
plan.files = transaction.changes.map((change) => ({ relative: change.relative, from: "current", to: "verified-commit" }));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
preview.plans.push(plan);
|
|
293
|
+
}
|
|
294
|
+
return preview;
|
|
295
|
+
}
|
|
296
|
+
|
|
239
297
|
function pendingTransactionStatus(scrumDir) {
|
|
240
298
|
try {
|
|
241
299
|
return { pending: pendingTransactions(scrumDir).map(({ transaction }) => ({ id: transaction.id, name: transaction.name, status: transaction.status })) };
|
|
@@ -247,6 +305,7 @@ function pendingTransactionStatus(scrumDir) {
|
|
|
247
305
|
module.exports = {
|
|
248
306
|
TRANSACTION_SCHEMA,
|
|
249
307
|
pendingTransactionStatus,
|
|
308
|
+
previewPendingRecovery,
|
|
250
309
|
recoverPendingTransactions,
|
|
251
310
|
recoverPendingTransactionsUnlocked,
|
|
252
311
|
runKernelTransaction,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scrumrun",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "Evidence-driven Agile runtime and semantic project memory for AI coding agents.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"scrumrun": "bin/scrumrun.js",
|
|
@@ -55,4 +55,4 @@
|
|
|
55
55
|
"engines": {
|
|
56
56
|
"node": ">=22.13.0"
|
|
57
57
|
}
|
|
58
|
-
}
|
|
58
|
+
}
|