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.
@@ -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
- module.exports = { SECRET_PATTERNS, assertNoSecret, containsSecret, secretFingerprints };
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
+ };
@@ -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
- if (containsSecret(fs.readFileSync(artifact.file, "utf8"))) findings.push(finding("critical", "SECRET_CANONICAL", `Secret-like content detected in ${path.relative(scrumDir, artifact.file)}.`, artifact.file));
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.stale) findings.push(finding("warning", "INDEX_STALE", "semantic-index.sqlite is stale and will be rebuilt on query."));
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) {
@@ -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 (containsSecret(content)) errors.push(`Secret-like content detected outside the local vault: ${entry.path}`);
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, []]));
@@ -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.1.1",
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
+ }