scrumrun 2.1.0 → 2.2.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 +27 -0
- package/CORE.md +6 -2
- package/MIGRATION-1-to-2.md +2 -2
- package/README.md +13 -5
- package/SPEC.md +7 -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 +86 -1
- package/lib/v2/artifacts.js +3 -0
- package/lib/v2/conformance.js +23 -3
- package/lib/v2/migration.js +9 -4
- package/lib/v2/run-ledger-migration.js +30 -2
- package/lib/v2/schema.js +10 -3
- package/package.json +2 -2
- 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
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const { execFileSync } = require("node:child_process");
|
|
6
|
+
const { assertNoSymlinkPath, sha256 } = require("../v2/artifacts");
|
|
7
|
+
const { secretFingerprints } = require("../security/secrets");
|
|
8
|
+
|
|
9
|
+
const SKIP_DIRECTORIES = new Set([".git", ".scrumrun", "node_modules"]);
|
|
10
|
+
|
|
11
|
+
function posix(value) {
|
|
12
|
+
return value.split(path.sep).join("/");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function stable(value) {
|
|
16
|
+
if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`;
|
|
17
|
+
if (value && typeof value === "object") return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stable(value[key])}`).join(",")}}`;
|
|
18
|
+
return JSON.stringify(value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizedRelative(projectRoot, value) {
|
|
22
|
+
const root = path.resolve(projectRoot);
|
|
23
|
+
const text = String(value || "").trim().replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/$/, "");
|
|
24
|
+
if (!text || text === "." || path.posix.isAbsolute(text) || text.split("/").includes("..")) throw new Error(`Unsafe mutation path: ${value || "missing"}`);
|
|
25
|
+
if (text === ".git" || text.startsWith(".git/") || text === ".scrumrun" || text.startsWith(".scrumrun/")) {
|
|
26
|
+
throw new Error(`Mutation Gateway cannot authorize control path: ${text}`);
|
|
27
|
+
}
|
|
28
|
+
const target = path.resolve(root, text);
|
|
29
|
+
if (!target.startsWith(`${root}${path.sep}`)) throw new Error(`Mutation path escapes project: ${text}`);
|
|
30
|
+
assertNoSymlinkPath(root, target);
|
|
31
|
+
return posix(path.relative(root, target));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function fileValue(projectRoot, relative) {
|
|
35
|
+
const target = path.join(projectRoot, relative);
|
|
36
|
+
let stat;
|
|
37
|
+
try {
|
|
38
|
+
stat = fs.lstatSync(target);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (error.code === "ENOENT") return { path: relative, sha256: null, kind: "missing", mode: null, scan: "not-applicable", secrets: [] };
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
if (stat.isSymbolicLink()) {
|
|
44
|
+
const link = fs.readlinkSync(target);
|
|
45
|
+
return { path: relative, sha256: sha256(`symlink\0${link}`), kind: "symlink", mode: stat.mode & 0o777, scan: "not-applicable", secrets: [] };
|
|
46
|
+
}
|
|
47
|
+
if (!stat.isFile()) return { path: relative, sha256: sha256(`non-file\0${stat.mode}`), kind: "other", mode: stat.mode & 0o777, scan: "not-applicable", secrets: [] };
|
|
48
|
+
const content = fs.readFileSync(target);
|
|
49
|
+
const scan = content.length > 10 * 1024 * 1024 ? "too-large" : content.includes(0) ? "binary" : "text";
|
|
50
|
+
const secrets = scan === "text" ? secretFingerprints(content.toString("utf8")) : [];
|
|
51
|
+
return { path: relative, sha256: sha256(content), kind: "file", mode: stat.mode & 0o777, scan, secrets };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function gitOutput(projectRoot, args) {
|
|
55
|
+
return execFileSync("git", args, { cwd: projectRoot, encoding: "buffer", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 64 * 1024 * 1024 });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function gitState(projectRoot) {
|
|
59
|
+
let top;
|
|
60
|
+
try {
|
|
61
|
+
top = gitOutput(projectRoot, ["rev-parse", "--show-toplevel"]).toString("utf8").trim();
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
if (path.resolve(top) !== path.resolve(projectRoot)) return null;
|
|
66
|
+
const head = gitOutput(projectRoot, ["rev-parse", "--verify", "HEAD"]).toString("utf8").trim();
|
|
67
|
+
const flagged = gitOutput(projectRoot, ["ls-files", "-v", "-z", "--", "."]).toString("utf8").split("\0").filter(Boolean)
|
|
68
|
+
.filter((entry) => /^[a-zS] /.test(entry));
|
|
69
|
+
if (flagged.length) throw new Error(`Git workspace contains hidden index flags (assume-unchanged or skip-worktree): ${flagged.slice(0, 10).map((entry) => entry.slice(2)).join(", ")}.`);
|
|
70
|
+
const tracked = gitOutput(projectRoot, ["diff", "--name-only", "-z", "--no-renames", "HEAD", "--", "."]);
|
|
71
|
+
const untracked = gitOutput(projectRoot, ["ls-files", "--others", "--exclude-standard", "-z", "--", "."]);
|
|
72
|
+
const ignored = gitOutput(projectRoot, ["ls-files", "--others", "--ignored", "--exclude-standard", "-z", "--", "."]);
|
|
73
|
+
const paths = new Set(Buffer.concat([tracked, untracked, ignored]).toString("utf8").split("\0").filter(Boolean)
|
|
74
|
+
.map((entry) => entry.replace(/\\/g, "/"))
|
|
75
|
+
.filter((entry) => {
|
|
76
|
+
const segments = entry.split("/");
|
|
77
|
+
return entry !== ".scrumrun" && !entry.startsWith(".scrumrun/") && !segments.includes("node_modules") && !segments.includes(".git");
|
|
78
|
+
}));
|
|
79
|
+
if (paths.size > 20000) throw new Error("Git workspace exceeds the 20,000-file Mutation Gateway limit after ignored-file coverage.");
|
|
80
|
+
const files = [...paths].sort().map((relative) => fileValue(projectRoot, relative));
|
|
81
|
+
return { schema: 1, mode: "git", head, files };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function fileState(projectRoot) {
|
|
85
|
+
const files = [];
|
|
86
|
+
function visit(directory, relative = "") {
|
|
87
|
+
const entries = fs.readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name));
|
|
88
|
+
for (const entry of entries) {
|
|
89
|
+
if (entry.isDirectory() && SKIP_DIRECTORIES.has(entry.name)) continue;
|
|
90
|
+
const next = relative ? `${relative}/${entry.name}` : entry.name;
|
|
91
|
+
const target = path.join(directory, entry.name);
|
|
92
|
+
if (entry.isDirectory()) visit(target, next);
|
|
93
|
+
else files.push(fileValue(projectRoot, next));
|
|
94
|
+
if (files.length > 20000) throw new Error("Non-git workspace exceeds the 20,000-file Mutation Gateway limit.");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
visit(projectRoot);
|
|
98
|
+
return { schema: 1, mode: "files", head: null, files };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function workspaceState(projectRoot) {
|
|
102
|
+
const root = path.resolve(projectRoot);
|
|
103
|
+
const state = gitState(root) || fileState(root);
|
|
104
|
+
const canonical = {
|
|
105
|
+
schema: state.schema,
|
|
106
|
+
mode: state.mode,
|
|
107
|
+
head: state.head,
|
|
108
|
+
files: state.files.map(({ path: file, sha256: hash, kind, mode, scan }) => ({ path: file, sha256: hash, kind, mode, scan }))
|
|
109
|
+
};
|
|
110
|
+
return { ...state, fingerprint: sha256(stable(canonical)) };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function changesBetween(before, after) {
|
|
114
|
+
if (!before || !after || before.mode !== after.mode || before.head !== after.head) throw new Error("Workspace baseline mode or Git HEAD changed during the Run.");
|
|
115
|
+
const left = new Map(before.files.map((item) => [item.path, item]));
|
|
116
|
+
const right = new Map(after.files.map((item) => [item.path, item]));
|
|
117
|
+
return [...new Set([...left.keys(), ...right.keys()])].sort().flatMap((file) => {
|
|
118
|
+
const previous = left.get(file) || { sha256: null, kind: "clean", mode: null, scan: "not-applicable", secrets: [] };
|
|
119
|
+
const next = right.get(file) || { sha256: null, kind: "clean", mode: null, scan: "not-applicable", secrets: [] };
|
|
120
|
+
if (previous.sha256 === next.sha256 && previous.kind === next.kind && previous.mode === next.mode) return [];
|
|
121
|
+
return [{
|
|
122
|
+
path: file,
|
|
123
|
+
before_sha256: previous.sha256,
|
|
124
|
+
after_sha256: next.sha256,
|
|
125
|
+
before_kind: previous.kind,
|
|
126
|
+
after_kind: next.kind,
|
|
127
|
+
before_mode: previous.mode,
|
|
128
|
+
after_mode: next.mode,
|
|
129
|
+
before_scan: previous.scan,
|
|
130
|
+
after_scan: next.scan,
|
|
131
|
+
new_secret_fingerprints: (next.secrets || []).filter((value) => !(previous.secrets || []).includes(value))
|
|
132
|
+
}];
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function pathAuthorized(relative, allowed) {
|
|
137
|
+
return allowed.some((entry) => relative === entry || relative.startsWith(`${entry}/`));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
module.exports = {
|
|
141
|
+
changesBetween,
|
|
142
|
+
normalizedRelative,
|
|
143
|
+
pathAuthorized,
|
|
144
|
+
stable,
|
|
145
|
+
workspaceState
|
|
146
|
+
};
|
package/lib/security/secrets.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
+
const crypto = require("node:crypto");
|
|
4
|
+
const fs = require("node:fs");
|
|
5
|
+
const path = require("node:path");
|
|
6
|
+
|
|
3
7
|
const SECRET_PATTERNS = Object.freeze([
|
|
4
8
|
/\bsk-[A-Za-z0-9_-]{16,}\b/,
|
|
5
9
|
/\bAKIA[0-9A-Z]{16}\b/,
|
|
@@ -20,4 +24,85 @@ function assertNoSecret(values, message = "Secret-like content is forbidden outs
|
|
|
20
24
|
if (list.some(containsSecret)) throw new Error(message);
|
|
21
25
|
}
|
|
22
26
|
|
|
23
|
-
|
|
27
|
+
function secretFingerprints(value) {
|
|
28
|
+
const text = String(value || "");
|
|
29
|
+
const found = new Set();
|
|
30
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
31
|
+
const matcher = new RegExp(pattern.source, `${pattern.flags.replace(/g/g, "")}g`);
|
|
32
|
+
for (const match of text.matchAll(matcher)) {
|
|
33
|
+
found.add(crypto.createHash("sha256").update(match[0]).digest("hex"));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return [...found].sort();
|
|
37
|
+
}
|
|
38
|
+
|
|
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/artifacts.js
CHANGED
|
@@ -114,6 +114,9 @@ function validateArtifact(record, expectedKind = null) {
|
|
|
114
114
|
if (field === "ledger" && record[field] !== undefined && record[field] !== 1) {
|
|
115
115
|
errors.push(`${record.kind}.${field} must be 1`);
|
|
116
116
|
}
|
|
117
|
+
if (["guardrails", "workspace"].includes(field) && record[field] !== undefined && record[field] !== 1) {
|
|
118
|
+
errors.push(`${record.kind}.${field} must be 1`);
|
|
119
|
+
}
|
|
117
120
|
}
|
|
118
121
|
return errors;
|
|
119
122
|
}
|
package/lib/v2/conformance.js
CHANGED
|
@@ -9,9 +9,10 @@ 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
|
+
const { auditActiveWorkspace } = require("../runtime/mutation-gateway");
|
|
15
16
|
|
|
16
17
|
const INVARIANTS = Object.freeze([
|
|
17
18
|
{ id: "I-01", summary: "pre-approval work is read-only", tests: ["intake builds bounded context without writing"] },
|
|
@@ -33,7 +34,8 @@ const INVARIANTS = Object.freeze([
|
|
|
33
34
|
{ id: "I-17", summary: "ambiguous migration is preserved and warned", tests: ["partial v1 layout is preserved"] },
|
|
34
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"] },
|
|
35
36
|
{ id: "I-19", summary: "code intelligence is derived and fingerprinted", tests: ["language adapters are replaceable", "moves remap by fingerprint"] },
|
|
36
|
-
{ id: "I-20", summary: "learning candidates never block execution", tests: ["post-validation extraction creates candidate insights"] }
|
|
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"] }
|
|
37
39
|
]);
|
|
38
40
|
|
|
39
41
|
function finding(severity, code, message, file = null) {
|
|
@@ -66,6 +68,14 @@ function auditProject(projectRoot) {
|
|
|
66
68
|
if (!guardrailValidation.records.length) findings.push(finding("high", "GUARDRAILS_EMPTY", "No stable-id Guardrail is defined."));
|
|
67
69
|
if (!guardrailValidation.records.some((record) => record.status === "active")) findings.push(finding("high", "GUARDRAILS_INACTIVE", "No active stable-id Guardrail is defined."));
|
|
68
70
|
for (const error of guardrailValidation.errors) findings.push(finding("high", "GUARDRAIL_INVALID", error, guardrailsFile));
|
|
71
|
+
const strictGuardrails = methodMarker && methodMarker.schemas && methodMarker.schemas.guardrails === 1;
|
|
72
|
+
if (strictGuardrails) {
|
|
73
|
+
for (const record of guardrailValidation.records) {
|
|
74
|
+
for (const field of ["status", "enforcement", "scope", "rule"]) {
|
|
75
|
+
if (!record.explicit[field]) findings.push(finding("high", "GUARDRAIL_FIELD_INFERRED", `${record.id} must explicitly declare ${field}.`, guardrailsFile));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
69
79
|
const configFile = path.join(scrumDir, "config.md");
|
|
70
80
|
const config = fs.existsSync(configFile) && fs.lstatSync(configFile).isFile() ? fs.readFileSync(configFile, "utf8") : "";
|
|
71
81
|
for (const error of configWeakeningAttempts(config)) findings.push(finding("high", "CONFIG_WEAKENS_POLICY", error, configFile));
|
|
@@ -81,7 +91,8 @@ function auditProject(projectRoot) {
|
|
|
81
91
|
counts[kind] = artifacts.length;
|
|
82
92
|
for (const artifact of artifacts) {
|
|
83
93
|
for (const error of artifact.errors) findings.push(finding("high", "ARTIFACT_INVALID", `${path.basename(artifact.file)}: ${error}`, artifact.file));
|
|
84
|
-
|
|
94
|
+
const relativeArtifact = path.relative(scrumDir, artifact.file);
|
|
95
|
+
if (containsSecret(fs.readFileSync(artifact.file, "utf8"))) findings.push(finding("critical", "SECRET_CANONICAL", `Secret-like content detected in ${relativeArtifact}.`, artifact.file));
|
|
85
96
|
if (artifact.record && ids.has(artifact.record.id)) findings.push(finding("critical", "ID_DUPLICATE", `Duplicate artifact id: ${artifact.record.id}`));
|
|
86
97
|
if (artifact.record) ids.add(artifact.record.id);
|
|
87
98
|
}
|
|
@@ -102,6 +113,15 @@ function auditProject(projectRoot) {
|
|
|
102
113
|
const declared = methodMarker && methodMarker.schemas && methodMarker.schemas.run_ledger === RUN_LEDGER_VERSION;
|
|
103
114
|
findings.push(finding(declared ? "high" : "warning", "RUN_LEDGER_LEGACY", `${run.record.id} requires the Run ledger ${RUN_LEDGER_VERSION} migration.`, run.file));
|
|
104
115
|
}
|
|
116
|
+
const secureRuns = methodMarker && methodMarker.schemas && methodMarker.schemas.run_obligations === 1 && methodMarker.schemas.mutation_gateway === 1;
|
|
117
|
+
const active = run.record && !["completed", "failed", "blocked"].includes(run.record.status);
|
|
118
|
+
if (secureRuns && active && (run.record.guardrails !== 1 || run.record.workspace !== 1)) {
|
|
119
|
+
findings.push(finding("high", "RUN_SECURITY_SCHEMA", `${run.record.id} must be retried or migrated to Guardrail/workspace schema 1.`, run.file));
|
|
120
|
+
}
|
|
121
|
+
if (run.record && run.record.workspace === 1) {
|
|
122
|
+
const bypass = auditActiveWorkspace(projectRoot, run);
|
|
123
|
+
if (bypass) findings.push(finding("critical", "MUTATION_BYPASS", `${run.record.id}: ${bypass}`, run.file));
|
|
124
|
+
}
|
|
105
125
|
}
|
|
106
126
|
const byId = new Map(Object.values(records).flat().filter((artifact) => artifact.record).map((artifact) => [artifact.record.id, artifact]));
|
|
107
127
|
for (const task of records.task || []) {
|
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,
|
|
@@ -16,6 +16,7 @@ const {
|
|
|
16
16
|
sha256,
|
|
17
17
|
validateArtifact
|
|
18
18
|
} = require("./artifacts");
|
|
19
|
+
const { inferEnforcement, normalizeGuardrailDocument } = require("../runtime/policy-engine");
|
|
19
20
|
const { RUN_LEDGER_VERSION } = require("./schema");
|
|
20
21
|
const { migrateLegacyRun } = require("../runtime/run-ledger");
|
|
21
22
|
|
|
@@ -296,10 +297,11 @@ function migrationPlan(projectRoot) {
|
|
|
296
297
|
}));
|
|
297
298
|
const warnings = [];
|
|
298
299
|
const errors = inventory.symlinks.map((entry) => `Symbolic link is not migrated automatically: ${entry.path}`);
|
|
300
|
+
const secretAllowlist = loadSecretAllowlist(scrumDir);
|
|
299
301
|
for (const entry of inventory.files) {
|
|
300
302
|
if (/(^|\/)vault(?:\.local)?\.md$/i.test(entry.path) || !/\.(?:md|json|txt)$/i.test(entry.path)) continue;
|
|
301
303
|
const content = fs.readFileSync(path.join(scrumDir, entry.path), "utf8");
|
|
302
|
-
if (
|
|
304
|
+
if (containsSecretWithAllowlist(content, entry.path, secretAllowlist)) errors.push(`Secret-like content detected outside the local vault: ${entry.path}`);
|
|
303
305
|
}
|
|
304
306
|
const allocator = new IdAllocator(scrumDir);
|
|
305
307
|
const existingArtifacts = Object.fromEntries(Object.keys(ARTIFACT_TYPES).map((kind) => [kind, []]));
|
|
@@ -817,7 +819,7 @@ function migrationPlan(projectRoot) {
|
|
|
817
819
|
const golden = readText(scrumDir, "golden-rules.md");
|
|
818
820
|
const current = readText(scrumDir, "guardrails.md");
|
|
819
821
|
if (/^# ScrumRun Project Guardrails\b/m.test(current) && /^#{2,6} GR-\d{3,}\b/m.test(current)) {
|
|
820
|
-
const canonical = current.replace(/^#{3,6}(?= GR-\d{3,}\b)/gm, "##");
|
|
822
|
+
const canonical = normalizeGuardrailDocument(current.replace(/^#{3,6}(?= GR-\d{3,}\b)/gm, "##"));
|
|
821
823
|
if (canonical !== current) {
|
|
822
824
|
generated.set("guardrails.md", canonical.endsWith("\n") ? canonical : `${canonical}\n`);
|
|
823
825
|
const mapping = mappings.find((item) => item.source === "guardrails.md" && !item.anchor);
|
|
@@ -848,6 +850,9 @@ function migrationPlan(projectRoot) {
|
|
|
848
850
|
`## GR-${String(index + 1).padStart(3, "0")} - ${rule}`,
|
|
849
851
|
"",
|
|
850
852
|
"Status: active",
|
|
853
|
+
`Enforcement: ${inferEnforcement(rule, rule)}`,
|
|
854
|
+
"Scope: all",
|
|
855
|
+
`Rule: ${rule}`,
|
|
851
856
|
`Source: \`${source}\``,
|
|
852
857
|
""
|
|
853
858
|
]),
|
|
@@ -925,7 +930,7 @@ function migrationPlan(projectRoot) {
|
|
|
925
930
|
generated.set("method.json", `${JSON.stringify({
|
|
926
931
|
method: METHOD_VERSION,
|
|
927
932
|
layout: "v2",
|
|
928
|
-
schemas: { run_ledger: RUN_LEDGER_VERSION },
|
|
933
|
+
schemas: { run_ledger: RUN_LEDGER_VERSION, guardrails: 1, run_obligations: 1, mutation_gateway: 1 },
|
|
929
934
|
migrated_from: sourceLayout,
|
|
930
935
|
migration: MIGRATION_NAME
|
|
931
936
|
}, null, 2)}\n`);
|
|
@@ -15,6 +15,7 @@ const {
|
|
|
15
15
|
} = require("./artifacts");
|
|
16
16
|
const { RUN_LEDGER_VERSION } = require("./schema");
|
|
17
17
|
const { migrateLegacyRun, validateRunLedger } = require("../runtime/run-ledger");
|
|
18
|
+
const { normalizeGuardrailDocument, validateGuardrailDocument } = require("../runtime/policy-engine");
|
|
18
19
|
|
|
19
20
|
const MIGRATION_NAME = "run-ledger-v1";
|
|
20
21
|
const MIGRATION_DIR = path.join(".migration", MIGRATION_NAME);
|
|
@@ -49,6 +50,27 @@ function planRunLedgerMigration(projectRoot) {
|
|
|
49
50
|
const warnings = [];
|
|
50
51
|
const errors = [];
|
|
51
52
|
|
|
53
|
+
const guardrailsFile = assertNoSymlinkPath(scrumDir, path.join(scrumDir, "guardrails.md"));
|
|
54
|
+
if (fs.existsSync(guardrailsFile) && fs.lstatSync(guardrailsFile).isFile()) {
|
|
55
|
+
const before = fs.readFileSync(guardrailsFile, "utf8");
|
|
56
|
+
const after = normalizeGuardrailDocument(before);
|
|
57
|
+
const validation = validateGuardrailDocument(after);
|
|
58
|
+
if (validation.errors.length) errors.push(`guardrails.md: ${validation.errors.join("; ")}`);
|
|
59
|
+
else if (before !== after) {
|
|
60
|
+
changes.push({
|
|
61
|
+
relative: "guardrails.md",
|
|
62
|
+
before,
|
|
63
|
+
after,
|
|
64
|
+
beforeSha256: sha256(before),
|
|
65
|
+
afterSha256: sha256(after),
|
|
66
|
+
mode: "guardrail-schema"
|
|
67
|
+
});
|
|
68
|
+
warnings.push("guardrails.md: inferred legacy policy fields were made explicit without deleting source prose.");
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
errors.push("guardrails.md is missing or unsafe.");
|
|
72
|
+
}
|
|
73
|
+
|
|
52
74
|
for (const artifact of repository.list("run")) {
|
|
53
75
|
if (!artifact.record || artifact.errors.length) {
|
|
54
76
|
errors.push(`${path.relative(scrumDir, artifact.file)}: ${artifact.errors.join("; ")}`);
|
|
@@ -79,7 +101,13 @@ function planRunLedgerMigration(projectRoot) {
|
|
|
79
101
|
|
|
80
102
|
const markerValue = {
|
|
81
103
|
...marker.value,
|
|
82
|
-
schemas: {
|
|
104
|
+
schemas: {
|
|
105
|
+
...(marker.value.schemas || {}),
|
|
106
|
+
run_ledger: RUN_LEDGER_VERSION,
|
|
107
|
+
guardrails: 1,
|
|
108
|
+
run_obligations: 1,
|
|
109
|
+
mutation_gateway: 1
|
|
110
|
+
}
|
|
83
111
|
};
|
|
84
112
|
const markerAfter = `${JSON.stringify(markerValue, null, 2)}\n`;
|
|
85
113
|
if (marker.content !== markerAfter) {
|
|
@@ -112,7 +140,7 @@ function reportRunLedgerMigration(plan, status = plan.status) {
|
|
|
112
140
|
: "- No changes required.";
|
|
113
141
|
const warnings = plan.warnings.length ? plan.warnings.map((warning) => `- ${warning}`).join("\n") : "- None.";
|
|
114
142
|
const errors = plan.errors.length ? plan.errors.map((error) => `- ${error}`).join("\n") : "- None.";
|
|
115
|
-
return `# ScrumRun
|
|
143
|
+
return `# ScrumRun Kernel Schema Migration\n\nStatus: ${status}\nRun ledger schema: ${RUN_LEDGER_VERSION}\nGuardrail schema: 1\nMutation Gateway schema: 1\nSource fingerprint: \`${plan.fingerprint}\`\nChanged files: ${plan.changes.length}\n\n## Mappings\n\n${mappings}\n\n## Warnings\n\n${warnings}\n\n## Blockers\n\n${errors}\n`;
|
|
116
144
|
}
|
|
117
145
|
|
|
118
146
|
function manifestFile(scrumDir) {
|
package/lib/v2/schema.js
CHANGED
|
@@ -9,7 +9,7 @@ function deepFreeze(value) {
|
|
|
9
9
|
const METHOD_VERSION = "2.0.0";
|
|
10
10
|
const RUN_LEDGER_VERSION = 1;
|
|
11
11
|
|
|
12
|
-
const RUN_EVENT_TYPES = deepFreeze(["transition", "snapshot"]);
|
|
12
|
+
const RUN_EVENT_TYPES = deepFreeze(["transition", "snapshot", "guardrail", "mutation"]);
|
|
13
13
|
const RUN_EVIDENCE_KINDS = deepFreeze([
|
|
14
14
|
"approval",
|
|
15
15
|
"command",
|
|
@@ -21,9 +21,13 @@ const RUN_EVIDENCE_KINDS = deepFreeze([
|
|
|
21
21
|
"risk",
|
|
22
22
|
"note",
|
|
23
23
|
"migration",
|
|
24
|
-
"legacy"
|
|
24
|
+
"legacy",
|
|
25
|
+
"guardrail",
|
|
26
|
+
"mutation"
|
|
25
27
|
]);
|
|
26
28
|
|
|
29
|
+
const GUARDRAIL_RESULTS = deepFreeze(["pending", "passed", "blocked"]);
|
|
30
|
+
|
|
27
31
|
const ARTIFACT_TYPES = deepFreeze({
|
|
28
32
|
feature: {
|
|
29
33
|
prefix: "FEAT",
|
|
@@ -109,7 +113,9 @@ const STRUCTURAL_RELATIONS = deepFreeze({
|
|
|
109
113
|
|
|
110
114
|
const SCALAR_FIELDS = deepFreeze({
|
|
111
115
|
attempt: { kinds: ["run"], required: true, type: "positive integer", meaning: "monotonic execution-attempt number within one Task" },
|
|
112
|
-
ledger: { kinds: ["run"], required: false, type: `integer ${RUN_LEDGER_VERSION}`, meaning: "canonical Run event-ledger schema; required for newly authored Runs" }
|
|
116
|
+
ledger: { kinds: ["run"], required: false, type: `integer ${RUN_LEDGER_VERSION}`, meaning: "canonical Run event-ledger schema; required for newly authored Runs" },
|
|
117
|
+
guardrails: { kinds: ["run"], required: false, type: "integer 1", meaning: "append-only Guardrail obligation schema" },
|
|
118
|
+
workspace: { kinds: ["run"], required: false, type: "integer 1", meaning: "workspace mutation-gateway schema" }
|
|
113
119
|
});
|
|
114
120
|
|
|
115
121
|
const TRUTH_OWNERSHIP = deepFreeze({
|
|
@@ -137,6 +143,7 @@ module.exports = {
|
|
|
137
143
|
ARTIFACT_TRANSITIONS,
|
|
138
144
|
ARTIFACT_TYPES,
|
|
139
145
|
AUTHORITY,
|
|
146
|
+
GUARDRAIL_RESULTS,
|
|
140
147
|
METHOD_VERSION,
|
|
141
148
|
RUN_EVENT_TYPES,
|
|
142
149
|
RUN_EVIDENCE_KINDS,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scrumrun",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.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
|
+
}
|
|
@@ -20,7 +20,8 @@ After approval:
|
|
|
20
20
|
- Run is one execution attempt and follows `executing → validating → learning → completed|failed|blocked`;
|
|
21
21
|
- a retry creates a new Run and preserves the old one;
|
|
22
22
|
- learning proposes evidence-backed Knowledge, Decisions, or candidate Insights.
|
|
23
|
+
- every application/source edit requires a path-scoped Mutation Gateway permit, immediate hash recording, and resolution of the Run's Guardrail obligations before completion.
|
|
23
24
|
|
|
24
|
-
Never bypass guardrails, overwrite owner work, treat generated state/cache as truth, auto-confirm AI knowledge, auto-migrate a v1 project, or print vault values.
|
|
25
|
+
Never bypass guardrails or edit around the Mutation Gateway, overwrite owner work, treat generated state/cache as truth, auto-confirm AI knowledge, auto-migrate a v1 project, or print vault values.
|
|
25
26
|
|
|
26
27
|
If `/sc` is unavailable, follow the equivalent workflow in `.scrumrun/core.md` manually.
|
|
@@ -13,6 +13,8 @@ Do not scan every Task, Run, Sprint, Feature, or Memory file by default. Generat
|
|
|
13
13
|
|
|
14
14
|
Natural-language product work begins as read-only intake. Explicit approval creates/updates a Task and creates one Run. A Sprint exists only for a real batch/timebox. Run state is `executing → validating → learning → completed|failed|blocked`; retries preserve prior Runs.
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
Every application/source edit requires a short-lived path-scoped Mutation Gateway permit and immediate hash recording in the active Run. Resolve all persisted Guardrail obligations before completion; policy/workspace drift fails closed.
|
|
17
|
+
|
|
18
|
+
`.scrumrun/guardrails.md` is canonical policy. Never bypass it or the Mutation Gateway, overwrite owner work, auto-confirm AI knowledge, auto-migrate v1 state, or print vault values.
|
|
17
19
|
|
|
18
20
|
Use `/sc <noun> <subject> <action> [args]`; if `/sc` is unavailable, follow `.scrumrun/core.md` manually.
|
|
@@ -80,6 +80,8 @@ Evaluate every active Guardrail into a structured `passed`, `blocked`, or `defer
|
|
|
80
80
|
|
|
81
81
|
Do not create canonical artifacts, change status, edit application code, or treat ambiguous acknowledgement as approval. Temporary context may exist only in ignored disposable cache.
|
|
82
82
|
|
|
83
|
+
The approval token binds both canonical context and a complete workspace fingerprint. Any canonical or source change after planning invalidates it and requires a new intake.
|
|
84
|
+
|
|
83
85
|
## Approved execution
|
|
84
86
|
|
|
85
87
|
Explicit approval creates/updates the Task and creates a Run. The Run follows:
|
|
@@ -93,7 +95,7 @@ During execution:
|
|
|
93
95
|
|
|
94
96
|
1. keep the change inside the approved Task scope;
|
|
95
97
|
2. preserve existing owner work and unrelated dirty files;
|
|
96
|
-
3. enforce guardrails before every material mutation;
|
|
98
|
+
3. enforce guardrails before every material mutation: obtain a short-lived path-scoped Mutation Gateway permit before editing and record its verified before/after hashes immediately afterward;
|
|
97
99
|
4. validate in proportion to risk;
|
|
98
100
|
5. record exactly one structured `RUN-NNN-EVT-NNN` event per state transition, with RFC3339 time, actor, reason, and typed evidence;
|
|
99
101
|
6. run configured reviewers;
|
|
@@ -102,6 +104,8 @@ During execution:
|
|
|
102
104
|
|
|
103
105
|
Never overwrite a prior attempt. Never mark work complete because time/token budget ended.
|
|
104
106
|
|
|
107
|
+
Every deferred policy result is an append-only Run obligation. Unrecorded workspace drift, policy drift, an expired/missing permit, out-of-scope changes, unsafe symlinks, newly introduced secret-like content, or an unresolved obligation blocks validation/completion. The ignored permit cache is disposable; deleting it invalidates outstanding permits and never creates authority.
|
|
108
|
+
|
|
105
109
|
Run is the sole operational-history authority. Task synchronizes current status without copying Run events. Validation, learning, completion, failure, block, and resume require a reason or structured evidence; completion also requires evidenced validation and learning. Early v2 prose Runs are migrated explicitly, with deterministic chains recovered and uncertain history represented as an evidenced snapshot.
|
|
106
110
|
|
|
107
111
|
Linked canonical writes use the ignored durable transaction journal. An interrupted prepared mutation rolls back before the next approved mutation; a committed journal is verified and finalized. Audit remains read-only and reports pending recovery. Use `doctor --recover` only when explicitly requested, and never overwrite bytes changed after interruption.
|
|
@@ -155,7 +159,7 @@ Dry-run must not write project data. Apply requires a hashed inventory, byte-exa
|
|
|
155
159
|
- `task`: add/list/show/run/audit/cancel/retry atomic work; use `type: fix` for fixes and `status: backlog` for parked work.
|
|
156
160
|
- `sprint`: add/list/show/start/complete/block a real Task batch/timebox.
|
|
157
161
|
- `feature`: add/list/show/activate/complete long-lived initiatives.
|
|
158
|
-
- `run`: list/show/validate/learn/complete/resume/fail/block concrete Task attempts.
|
|
162
|
+
- `run`: list/show/authorize-mutation/record-mutation/satisfy-guardrail/validate/learn/complete/resume/fail/block concrete Task attempts.
|
|
159
163
|
- `intake <request>`: execute the read-only request pipeline.
|
|
160
164
|
- `challenge <question>`: deep read-only analysis with evidence, risks, options, and recommendation.
|
|
161
165
|
|