@cloverleaf/reference-impl 0.7.5 → 0.8.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/.claude-plugin/plugin.json +1 -1
- package/README.md +15 -0
- package/VERSION +1 -1
- package/config/secret-patterns.json +14 -0
- package/config/security-paths.json +13 -0
- package/dist/cli.mjs +72 -1
- package/dist/secret-scan.mjs +75 -0
- package/dist/security-classify.mjs +0 -0
- package/dist/task.mjs +53 -2
- package/dist/work-item.mjs +10 -1
- package/lib/cli.ts +69 -1
- package/lib/secret-scan.ts +90 -0
- package/lib/security-classify.ts +0 -0
- package/lib/task.ts +67 -2
- package/lib/work-item.ts +11 -1
- package/package.json +5 -2
- package/prompts/security-reviewer.md +55 -0
- package/skills/cloverleaf-new-task/SKILL.md +7 -1
- package/skills/cloverleaf-run/SKILL.md +36 -4
- package/skills/cloverleaf-run-plan/SKILL.md +2 -0
- package/skills/cloverleaf-security-review/SKILL.md +77 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cloverleaf",
|
|
3
3
|
"description": "Cloverleaf reference implementation — Claude Code skills for task scaffolding and the Delivery pipeline (implementer, documenter, reviewer, UI reviewer with multi-viewport visual diff, QA, merge, release).",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.8.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Renato D'Arrigo",
|
|
7
7
|
"email": "renato.darrigo@gmail.com"
|
package/README.md
CHANGED
|
@@ -43,6 +43,7 @@ v0.2 implements both paths of the Delivery track:
|
|
|
43
43
|
- `/cloverleaf-ui-review` — run UI Reviewer *(new in v0.2)*
|
|
44
44
|
- `/cloverleaf-approve-baselines` — human baseline-approval gate; clears `baselines_pending` and advances `ui-review → qa` *(new in CLV-19)*
|
|
45
45
|
- `/cloverleaf-qa` — run QA *(new in v0.2)*
|
|
46
|
+
- `/cloverleaf-security-review` — Security Reviewer: deterministic secret scan + LLM vulnerability judgment; runs when `security_class` is `high`
|
|
46
47
|
- `/cloverleaf-merge` — human gate (branches on state)
|
|
47
48
|
- `/cloverleaf-run` — orchestrator (dispatches by `risk_class`)
|
|
48
49
|
- `/cloverleaf-release` — publish a new `@cloverleaf/reference-impl` release; runs pre-flight checks then executes `git tag -a` / `git push origin main` / `git push origin <tag>` / `npm publish` / `gh release create`; accepts `[--dry-run] [--yes]` *(new in CLV-63)*
|
|
@@ -198,6 +199,20 @@ Returns the RFC's status, all sibling Plans (with their child tasks), all standa
|
|
|
198
199
|
|
|
199
200
|
Skipping the Plan = skipping `task_batch_gate`. That's the right tradeoff for hotfixes (one task; no decomposition to review) and for one-task-at-a-time incremental work. It's the wrong tradeoff for a large multi-task scope where the human's review of the decomposition is the load-bearing checkpoint. Plans are a checkpoint, not ceremony.
|
|
200
201
|
|
|
202
|
+
## Security review
|
|
203
|
+
|
|
204
|
+
The **Security Reviewer** (8th agent) runs when a task's effective `security_class` is `high`, off the `automated-gates` hub in both lanes — so it covers fast-lane backend work, not just full-pipeline UI work.
|
|
205
|
+
|
|
206
|
+
**What triggers it.** `security_class` (`low`/`high`, independent of the UI-keyed `risk_class`) is inferred at task creation from sensitive markers (keywords + paths) and re-checked against the actual diff at review time (defense in depth — a task whose brief never says "credential" but whose diff touches `engine/exchange.py` is caught). Override at creation with `--security=high|low`.
|
|
207
|
+
|
|
208
|
+
**Two passes.** (A) a deterministic secret scan (`cloverleaf-cli secret-scan`) over the diff's added lines — cloud keys, tokens, PEM headers, credentialed connection strings; (B) an LLM judgment pass reasoning about injection, broken authz, unsafe deserialization, SSRF, missing input validation, weak crypto.
|
|
209
|
+
|
|
210
|
+
**Routing.** Findings merge into one feedback envelope; the max severity sets the verdict — any `blocker` (e.g. a leaked credential) → `escalated` (a human must review); `error`/`warning` → `implementing` (the Implementer fixes); clean → `automated-gates` → onward.
|
|
211
|
+
|
|
212
|
+
**Customizing.** Both pattern sets are consumer-overridable: `.cloverleaf/config/security-paths.json` (sensitive paths + keywords) and `.cloverleaf/config/secret-patterns.json` (secret regexes + placeholder excludes).
|
|
213
|
+
|
|
214
|
+
**Mechanical enforcement (v0.8.1+).** As of v0.8.1, the security-review state is mechanically enforced via the `security_gate` annotation on the Standard 0.7.1 state machine. `advance-status` re-runs `classify-security` on every guarded transition and refuses bypass attempts with exit code 2, naming the required recovery action (advance to `security-review`, run the Security Reviewer, write a `pass` verdict, then retry). This is a belt-and-suspenders complement to the orchestrator prose — the CLI enforces the invariant even if the driving LLM omits the bookkeeping step.
|
|
215
|
+
|
|
201
216
|
## License
|
|
202
217
|
|
|
203
218
|
MIT — see [../LICENSE](../LICENSE).
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.8.1
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"patterns": [
|
|
3
|
+
{ "name": "aws-access-key-id", "regex": "AKIA[0-9A-Z]{16}", "severity": "blocker" },
|
|
4
|
+
{ "name": "github-token", "regex": "gh[pousr]_[A-Za-z0-9]{36,}", "severity": "blocker" },
|
|
5
|
+
{ "name": "github-pat", "regex": "github_pat_[A-Za-z0-9_]{60,}", "severity": "blocker" },
|
|
6
|
+
{ "name": "slack-token", "regex": "xox[baprs]-[A-Za-z0-9-]{10,}", "severity": "blocker" },
|
|
7
|
+
{ "name": "stripe-live-key", "regex": "sk_live_[A-Za-z0-9]{24,}", "severity": "blocker" },
|
|
8
|
+
{ "name": "google-api-key", "regex": "AIza[0-9A-Za-z_-]{35}", "severity": "blocker" },
|
|
9
|
+
{ "name": "private-key-header", "regex": "-----BEGIN (RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----", "severity": "blocker" },
|
|
10
|
+
{ "name": "credentialed-conn-string", "regex": "(postgres(ql)?|mysql|mongodb(\\+srv)?|redis|amqp)://[^:@/\\s]+:[^@/\\s]+@", "severity": "error" },
|
|
11
|
+
{ "name": "generic-secret-assignment", "regex": "(?i)(password|passwd|secret|api[_-]?key|access[_-]?token)\\s*[=:]\\s*[\"'][^\"'$<{][^\"']{7,}[\"']", "severity": "error" }
|
|
12
|
+
],
|
|
13
|
+
"placeholder_excludes": ["\\$\\{", "process\\.env", "<[^>]+>", "(?i)[=:\\s][\"']?(changeme|example|placeholder|xxx+|your[-_])"]
|
|
14
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"path_patterns": [
|
|
3
|
+
"**/*.env*", "**/secrets*", "**/*secret*", "**/*credential*",
|
|
4
|
+
"**/*.pem", "**/*.key", "**/id_rsa*", "**/auth*",
|
|
5
|
+
"**/deploy*.sh", "**/*.sql", "**/migrations/**",
|
|
6
|
+
"**/prompts/**", "**/skills/**", "**/*policy*.json"
|
|
7
|
+
],
|
|
8
|
+
"keyword_patterns": [
|
|
9
|
+
"secret", "credential", "api[ _-]?key", "\\btoken\\b", "password",
|
|
10
|
+
"private key", "oauth", "exchange", "binance", "\\bsql\\b",
|
|
11
|
+
"\\beval\\b", "subprocess", "deserialize"
|
|
12
|
+
]
|
|
13
|
+
}
|
package/dist/cli.mjs
CHANGED
|
@@ -38,6 +38,9 @@
|
|
|
38
38
|
* walker-default-concurrency [--explain]
|
|
39
39
|
* check-scope <repoRoot> <taskId> --branch <branchName>
|
|
40
40
|
* extend-scope <repoRoot> <taskId> --add <file>... --reason <text>
|
|
41
|
+
* secret-scan <repoRoot> --branch <branch>
|
|
42
|
+
* classify-security <repoRoot> <taskId> [--branch <branch>]
|
|
43
|
+
* set-task-field <repoRoot> <taskId> <field> <value>
|
|
41
44
|
*/
|
|
42
45
|
import { readFileSync, mkdirSync, copyFileSync, appendFileSync } from 'node:fs';
|
|
43
46
|
import { dirname, join } from 'node:path';
|
|
@@ -65,6 +68,8 @@ import { readWalkState, writeWalkState, walkStatePath } from './walk-state.mjs';
|
|
|
65
68
|
import { loadWalkerConfig } from './walker-config.mjs';
|
|
66
69
|
import { classifyFiles } from './scope-check.mjs';
|
|
67
70
|
import { computeRfcTasksView } from './rfc-tasks.mjs';
|
|
71
|
+
import { loadSecretPatternsConfig, scanSecrets } from './secret-scan.mjs';
|
|
72
|
+
import { classifyTaskSecurity } from './security-classify.mjs';
|
|
68
73
|
function die(msg, code = 1) {
|
|
69
74
|
process.stderr.write(msg + '\n');
|
|
70
75
|
process.exit(code);
|
|
@@ -106,7 +111,10 @@ function usage(msg) {
|
|
|
106
111
|
' walk-state-write <repoRoot> <walkStateJsonPath>\n' +
|
|
107
112
|
' walker-default-concurrency [--explain]\n' +
|
|
108
113
|
' check-scope <repoRoot> <taskId> --branch <branchName>\n' +
|
|
109
|
-
' extend-scope <repoRoot> <taskId> --add <file>... --reason <text>\n'
|
|
114
|
+
' extend-scope <repoRoot> <taskId> --add <file>... --reason <text>\n' +
|
|
115
|
+
' secret-scan <repoRoot> --branch <branch>\n' +
|
|
116
|
+
' classify-security <repoRoot> <taskId> [--branch <branch>]\n' +
|
|
117
|
+
' set-task-field <repoRoot> <taskId> <field> <value>\n');
|
|
110
118
|
process.exit(2);
|
|
111
119
|
}
|
|
112
120
|
const [, , command, ...rest] = process.argv;
|
|
@@ -723,12 +731,75 @@ try {
|
|
|
723
731
|
appendFileSync(auditPath, JSON.stringify(auditEntry) + '\n');
|
|
724
732
|
process.exit(0);
|
|
725
733
|
}
|
|
734
|
+
case 'secret-scan': {
|
|
735
|
+
const positional = rest.filter((a) => !a.startsWith('--'));
|
|
736
|
+
const branchIdx = rest.indexOf('--branch');
|
|
737
|
+
const branch = branchIdx >= 0 ? rest[branchIdx + 1] : undefined;
|
|
738
|
+
const [repoRoot] = positional;
|
|
739
|
+
if (!repoRoot || !branch)
|
|
740
|
+
usage('secret-scan <repoRoot> --branch <branch>');
|
|
741
|
+
const cfg = loadSecretPatternsConfig(repoRoot);
|
|
742
|
+
// Scan added/changed lines only (the '+' lines of the diff, minus the +++ header),
|
|
743
|
+
// so we flag what THIS task introduced, not pre-existing secrets.
|
|
744
|
+
const diff = execSync(`git -C ${repoRoot} diff --unified=0 main..${branch}`, { encoding: 'utf-8' });
|
|
745
|
+
const addedByFile = {};
|
|
746
|
+
let curFile = '';
|
|
747
|
+
for (const line of diff.split('\n')) {
|
|
748
|
+
if (line.startsWith('+++ b/')) {
|
|
749
|
+
curFile = line.slice(6);
|
|
750
|
+
addedByFile[curFile] = [];
|
|
751
|
+
}
|
|
752
|
+
else if (line.startsWith('+') && !line.startsWith('+++')) {
|
|
753
|
+
(addedByFile[curFile] ||= []).push(line.slice(1));
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
const findings = Object.entries(addedByFile).flatMap(([f, lines]) => scanSecrets(lines.join('\n'), cfg, f));
|
|
757
|
+
process.stdout.write(JSON.stringify({ findings }) + '\n');
|
|
758
|
+
break;
|
|
759
|
+
}
|
|
760
|
+
case 'classify-security': {
|
|
761
|
+
const positional = rest.filter((a) => !a.startsWith('--'));
|
|
762
|
+
const branchIdx = rest.indexOf('--branch');
|
|
763
|
+
const branch = branchIdx >= 0 ? rest[branchIdx + 1] : undefined;
|
|
764
|
+
const [repoRoot, taskId] = positional;
|
|
765
|
+
if (!repoRoot || !taskId)
|
|
766
|
+
usage('classify-security <repoRoot> <taskId> [--branch <branch>]');
|
|
767
|
+
let result;
|
|
768
|
+
try {
|
|
769
|
+
result = classifyTaskSecurity(repoRoot, taskId, branch ? { branch } : undefined);
|
|
770
|
+
}
|
|
771
|
+
catch (err) {
|
|
772
|
+
process.stderr.write(`cloverleaf-cli classify-security: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
773
|
+
process.exit(2);
|
|
774
|
+
}
|
|
775
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
776
|
+
break;
|
|
777
|
+
}
|
|
778
|
+
case 'set-task-field': {
|
|
779
|
+
const [repoRoot, taskId, field, value] = rest;
|
|
780
|
+
if (!repoRoot || !taskId || !field || value === undefined)
|
|
781
|
+
usage('set-task-field requires <repoRoot> <taskId> <field> <value>');
|
|
782
|
+
const ALLOWED_FIELDS = new Set(['security_review_verdict']);
|
|
783
|
+
if (!ALLOWED_FIELDS.has(field)) {
|
|
784
|
+
die(`set-task-field: unknown field '${field}'. Allowed fields: ${Array.from(ALLOWED_FIELDS).join(', ')}`);
|
|
785
|
+
}
|
|
786
|
+
const task = loadTask(repoRoot, taskId);
|
|
787
|
+
const parsed = value === 'null' ? null : value;
|
|
788
|
+
task[field] = parsed;
|
|
789
|
+
saveTask(repoRoot, task);
|
|
790
|
+
break;
|
|
791
|
+
}
|
|
726
792
|
default:
|
|
727
793
|
usage(`Unknown command: ${command}`);
|
|
728
794
|
}
|
|
729
795
|
}
|
|
730
796
|
catch (err) {
|
|
731
797
|
const msg = err instanceof Error ? err.message : String(err);
|
|
798
|
+
// SECURITY_GATE errors: write the bare validator message to stderr and exit 2
|
|
799
|
+
if (err.code === 'SECURITY_GATE') {
|
|
800
|
+
process.stderr.write(msg + '\n');
|
|
801
|
+
process.exit(2);
|
|
802
|
+
}
|
|
732
803
|
// Surface "illegal transition" errors with the right language
|
|
733
804
|
const lower = msg.toLowerCase();
|
|
734
805
|
if (lower.includes('illegal') || lower.includes('not allowed')) {
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
5
|
+
const DEFAULT_CONFIG = join(here, '..', 'config', 'secret-patterns.json');
|
|
6
|
+
function normalize(doc) {
|
|
7
|
+
const raw = Array.isArray(doc.patterns) ? doc.patterns : [];
|
|
8
|
+
const validSeverities = new Set(['error', 'blocker']);
|
|
9
|
+
const patterns = raw.filter((p) => typeof p.name === 'string' && p.name.length > 0 &&
|
|
10
|
+
typeof p.regex === 'string' && p.regex.length > 0 &&
|
|
11
|
+
validSeverities.has(p.severity));
|
|
12
|
+
return {
|
|
13
|
+
patterns,
|
|
14
|
+
placeholder_excludes: Array.isArray(doc.placeholder_excludes)
|
|
15
|
+
? doc.placeholder_excludes.filter((s) => typeof s === 'string')
|
|
16
|
+
: [],
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function loadSecretPatternsConfig(repoRoot) {
|
|
20
|
+
const consumerPath = join(repoRoot, '.cloverleaf', 'config', 'secret-patterns.json');
|
|
21
|
+
const path = existsSync(consumerPath) ? consumerPath : DEFAULT_CONFIG;
|
|
22
|
+
if (!existsSync(path))
|
|
23
|
+
throw new Error(`secret-patterns config not found at ${path}`);
|
|
24
|
+
return normalize(JSON.parse(readFileSync(path, 'utf-8')));
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Scan text (typically the added/changed lines of a diff) for secrets.
|
|
28
|
+
* A line that matches any placeholder_exclude is skipped entirely (env refs,
|
|
29
|
+
* templates, obvious placeholders), keeping precision high.
|
|
30
|
+
*/
|
|
31
|
+
function compileRegex(pattern) {
|
|
32
|
+
// Support (?i) inline flag prefix (not valid in JS) by converting to the 'i' flag.
|
|
33
|
+
// Only a *leading* (?i) is supported; any occurrence elsewhere is almost certainly
|
|
34
|
+
// a mistake (it would be a JS syntax error) — reject it with a clear message.
|
|
35
|
+
const original = pattern;
|
|
36
|
+
let flags = '';
|
|
37
|
+
if (pattern.startsWith('(?i)')) {
|
|
38
|
+
pattern = pattern.slice(4);
|
|
39
|
+
flags = 'i';
|
|
40
|
+
}
|
|
41
|
+
if (pattern.includes('(?i)')) {
|
|
42
|
+
throw new Error('secret-scan: inline (?i) flag is only supported as a leading prefix: ' + original);
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
return new RegExp(pattern, flags);
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
throw new Error('secret-scan: invalid pattern regex /' + pattern + '/: ' + err.message);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export function scanSecrets(text, config, file) {
|
|
52
|
+
const excludes = config.placeholder_excludes.map((p) => compileRegex(p));
|
|
53
|
+
const compiled = config.patterns.map((p) => ({ ...p, re: compileRegex(p.regex) }));
|
|
54
|
+
const findings = [];
|
|
55
|
+
const lines = text.split('\n');
|
|
56
|
+
lines.forEach((line, idx) => {
|
|
57
|
+
// Deliberate line-level tradeoff: a line matching any exclude pattern is
|
|
58
|
+
// skipped entirely. If a real secret happens to share a line with a
|
|
59
|
+
// placeholder token (e.g. a comment), it becomes a false-negative.
|
|
60
|
+
// This keeps precision high and avoids alert fatigue from generated files.
|
|
61
|
+
if (excludes.some((re) => re.test(line)))
|
|
62
|
+
return;
|
|
63
|
+
for (const p of compiled) {
|
|
64
|
+
if (p.re.test(line)) {
|
|
65
|
+
findings.push({
|
|
66
|
+
severity: p.severity,
|
|
67
|
+
rule: p.name,
|
|
68
|
+
message: `Possible hardcoded secret (${p.name}) on line ${idx + 1}`,
|
|
69
|
+
location: { ...(file ? { file } : {}), line: idx + 1 },
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
return findings;
|
|
75
|
+
}
|
|
Binary file
|
package/dist/task.mjs
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
3
4
|
import { tasksDir, projectsDir } from './paths.mjs';
|
|
4
5
|
import { validateOrThrow } from './validate.mjs';
|
|
5
6
|
import { advanceWorkItemStatus, loadStateMachine } from './work-item.mjs';
|
|
7
|
+
import { classifyTaskSecurity } from './security-classify.mjs';
|
|
6
8
|
export function loadTask(repoRoot, taskId) {
|
|
7
9
|
const path = join(tasksDir(repoRoot), `${taskId}.json`);
|
|
8
10
|
if (!existsSync(path))
|
|
@@ -22,9 +24,40 @@ export function loadProject(repoRoot, projectId) {
|
|
|
22
24
|
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
23
25
|
}
|
|
24
26
|
export function advanceStatus(repoRoot, taskId, toStatus, actor, options = {}) {
|
|
25
|
-
|
|
27
|
+
let task = loadTask(repoRoot, taskId);
|
|
26
28
|
const from = task.status;
|
|
27
29
|
const sm = loadStateMachine('task');
|
|
30
|
+
const targetTransition = sm.transitions.find((t) => t.from === from && t.to === toStatus);
|
|
31
|
+
// Security-gate writeback: classify the task's security and upgrade if needed.
|
|
32
|
+
if (targetTransition?.security_gate) {
|
|
33
|
+
let classification;
|
|
34
|
+
try {
|
|
35
|
+
classification = classifyTaskSecurity(repoRoot, taskId);
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
process.stderr.write(`cloverleaf-cli advance-status: classify-security errored (${err instanceof Error ? err.message : String(err)}); treating effective security_class as "high".\n`);
|
|
39
|
+
classification = {
|
|
40
|
+
declared: (task.security_class === 'high' ? 'high' : 'low'),
|
|
41
|
+
diff_detected: true,
|
|
42
|
+
effective: 'high',
|
|
43
|
+
matched_paths: [],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
if (classification.declared === 'low' && classification.effective === 'high') {
|
|
47
|
+
const upgraded = { ...task, security_class: 'high' };
|
|
48
|
+
saveTask(repoRoot, upgraded);
|
|
49
|
+
const taskFilePath = join(tasksDir(repoRoot), `${taskId}.json`);
|
|
50
|
+
try {
|
|
51
|
+
execFileSync('git', ['-C', repoRoot, 'add', taskFilePath], { stdio: 'pipe' });
|
|
52
|
+
execFileSync('git', ['-C', repoRoot, 'commit', '-m', `cloverleaf: ${taskId} security_class → high (diff-detected)`], { stdio: 'pipe' });
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// No-op: commit is best-effort when running outside a git repo (e.g., test environments).
|
|
56
|
+
}
|
|
57
|
+
// Mutate in-memory so the validator sees the upgraded value.
|
|
58
|
+
task = { ...upgraded };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
28
61
|
const riskClass = options.path === 'fast_lane' ? 'low'
|
|
29
62
|
: options.path === 'full_pipeline' ? 'high'
|
|
30
63
|
: (task.risk_class ?? 'low');
|
|
@@ -34,11 +67,18 @@ export function advanceStatus(repoRoot, taskId, toStatus, actor, options = {}) {
|
|
|
34
67
|
project: task.project,
|
|
35
68
|
status: task.status,
|
|
36
69
|
risk_class: riskClass,
|
|
70
|
+
security_class: task.security_class,
|
|
71
|
+
security_review_verdict: task.security_review_verdict,
|
|
37
72
|
context: { rfc: { project: task.project, id: task.id } },
|
|
38
73
|
definition_of_done: task.definition_of_done,
|
|
39
74
|
acceptance_criteria: task.acceptance_criteria,
|
|
40
75
|
};
|
|
41
|
-
const
|
|
76
|
+
const resetsVerdict = targetTransition?.resets_security_verdict === true;
|
|
77
|
+
const proposed = {
|
|
78
|
+
...task,
|
|
79
|
+
status: toStatus,
|
|
80
|
+
...(resetsVerdict ? { security_review_verdict: null } : {}),
|
|
81
|
+
};
|
|
42
82
|
advanceWorkItemStatus({
|
|
43
83
|
repoRoot,
|
|
44
84
|
workItemType: 'task',
|
|
@@ -54,5 +94,16 @@ export function advanceStatus(repoRoot, taskId, toStatus, actor, options = {}) {
|
|
|
54
94
|
gate: options.gate,
|
|
55
95
|
path: options.path,
|
|
56
96
|
});
|
|
97
|
+
// After a successful status change + verdict reset, emit a single commit covering both.
|
|
98
|
+
if (resetsVerdict) {
|
|
99
|
+
const taskFilePath = join(tasksDir(repoRoot), `${taskId}.json`);
|
|
100
|
+
try {
|
|
101
|
+
execFileSync('git', ['-C', repoRoot, 'add', taskFilePath], { stdio: 'pipe' });
|
|
102
|
+
execFileSync('git', ['-C', repoRoot, 'commit', '-m', `cloverleaf: ${taskId} status ${from} → ${toStatus}; security_review_verdict → null (rework)`], { stdio: 'pipe' });
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// No-op: commit is best-effort when running outside a git repo (e.g., test environments).
|
|
106
|
+
}
|
|
107
|
+
}
|
|
57
108
|
return proposed;
|
|
58
109
|
}
|
package/dist/work-item.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
4
|
import { emitStatusTransition, formatReason } from './events.mjs';
|
|
5
|
-
import { validateStatusTransitionLegality } from '@cloverleaf/standard/validators/index.js';
|
|
5
|
+
import { validateStatusTransitionLegality, validateSecurityGate } from '@cloverleaf/standard/validators/index.js';
|
|
6
6
|
const req = createRequire(import.meta.url);
|
|
7
7
|
export function loadStateMachine(type) {
|
|
8
8
|
const pkgPath = req.resolve('@cloverleaf/standard/package.json');
|
|
@@ -28,6 +28,15 @@ export function advanceWorkItemStatus(params) {
|
|
|
28
28
|
const msgs = result.violations.map((v) => v.message).join('; ');
|
|
29
29
|
throw new Error(`Illegal transition ${from} → ${to}: ${msgs}`);
|
|
30
30
|
}
|
|
31
|
+
if (workItemType === 'task') {
|
|
32
|
+
const sgResult = validateSecurityGate(event, stateMachine, validateFixture);
|
|
33
|
+
if (!sgResult.ok) {
|
|
34
|
+
const msgs = sgResult.violations.map((v) => v.message).join('; ');
|
|
35
|
+
const err = new Error(msgs);
|
|
36
|
+
err.code = 'SECURITY_GATE';
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
31
40
|
const emittedPath = emitStatusTransition(repoRoot, {
|
|
32
41
|
project,
|
|
33
42
|
workItemType,
|
package/lib/cli.ts
CHANGED
|
@@ -38,6 +38,9 @@
|
|
|
38
38
|
* walker-default-concurrency [--explain]
|
|
39
39
|
* check-scope <repoRoot> <taskId> --branch <branchName>
|
|
40
40
|
* extend-scope <repoRoot> <taskId> --add <file>... --reason <text>
|
|
41
|
+
* secret-scan <repoRoot> --branch <branch>
|
|
42
|
+
* classify-security <repoRoot> <taskId> [--branch <branch>]
|
|
43
|
+
* set-task-field <repoRoot> <taskId> <field> <value>
|
|
41
44
|
*/
|
|
42
45
|
|
|
43
46
|
import { readFileSync, mkdirSync, copyFileSync, appendFileSync, existsSync } from 'node:fs';
|
|
@@ -68,6 +71,8 @@ import { loadWalkerConfig } from './walker-config.js';
|
|
|
68
71
|
import { classifyFiles } from './scope-check.js';
|
|
69
72
|
import type { SiblingScope } from './scope-check.js';
|
|
70
73
|
import { computeRfcTasksView, type RfcTasksView } from './rfc-tasks.js';
|
|
74
|
+
import { loadSecretPatternsConfig, scanSecrets } from './secret-scan.js';
|
|
75
|
+
import { classifyTaskSecurity } from './security-classify.js';
|
|
71
76
|
|
|
72
77
|
function die(msg: string, code = 1): never {
|
|
73
78
|
process.stderr.write(msg + '\n');
|
|
@@ -111,7 +116,10 @@ function usage(msg?: string): never {
|
|
|
111
116
|
' walk-state-write <repoRoot> <walkStateJsonPath>\n' +
|
|
112
117
|
' walker-default-concurrency [--explain]\n' +
|
|
113
118
|
' check-scope <repoRoot> <taskId> --branch <branchName>\n' +
|
|
114
|
-
' extend-scope <repoRoot> <taskId> --add <file>... --reason <text>\n'
|
|
119
|
+
' extend-scope <repoRoot> <taskId> --add <file>... --reason <text>\n' +
|
|
120
|
+
' secret-scan <repoRoot> --branch <branch>\n' +
|
|
121
|
+
' classify-security <repoRoot> <taskId> [--branch <branch>]\n' +
|
|
122
|
+
' set-task-field <repoRoot> <taskId> <field> <value>\n'
|
|
115
123
|
);
|
|
116
124
|
process.exit(2);
|
|
117
125
|
}
|
|
@@ -744,11 +752,71 @@ try {
|
|
|
744
752
|
process.exit(0);
|
|
745
753
|
}
|
|
746
754
|
|
|
755
|
+
case 'secret-scan': {
|
|
756
|
+
const positional = rest.filter((a) => !a.startsWith('--'));
|
|
757
|
+
const branchIdx = rest.indexOf('--branch');
|
|
758
|
+
const branch = branchIdx >= 0 ? rest[branchIdx + 1] : undefined;
|
|
759
|
+
const [repoRoot] = positional;
|
|
760
|
+
if (!repoRoot || !branch) usage('secret-scan <repoRoot> --branch <branch>');
|
|
761
|
+
const cfg = loadSecretPatternsConfig(repoRoot);
|
|
762
|
+
// Scan added/changed lines only (the '+' lines of the diff, minus the +++ header),
|
|
763
|
+
// so we flag what THIS task introduced, not pre-existing secrets.
|
|
764
|
+
const diff = execSync(`git -C ${repoRoot} diff --unified=0 main..${branch}`, { encoding: 'utf-8' });
|
|
765
|
+
const addedByFile: Record<string, string[]> = {};
|
|
766
|
+
let curFile = '';
|
|
767
|
+
for (const line of diff.split('\n')) {
|
|
768
|
+
if (line.startsWith('+++ b/')) { curFile = line.slice(6); addedByFile[curFile] = []; }
|
|
769
|
+
else if (line.startsWith('+') && !line.startsWith('+++')) { (addedByFile[curFile] ||= []).push(line.slice(1)); }
|
|
770
|
+
}
|
|
771
|
+
const findings = Object.entries(addedByFile).flatMap(([f, lines]) => scanSecrets(lines.join('\n'), cfg, f));
|
|
772
|
+
process.stdout.write(JSON.stringify({ findings }) + '\n');
|
|
773
|
+
break;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
case 'classify-security': {
|
|
777
|
+
const positional = rest.filter((a) => !a.startsWith('--'));
|
|
778
|
+
const branchIdx = rest.indexOf('--branch');
|
|
779
|
+
const branch = branchIdx >= 0 ? rest[branchIdx + 1] : undefined;
|
|
780
|
+
const [repoRoot, taskId] = positional;
|
|
781
|
+
if (!repoRoot || !taskId) usage('classify-security <repoRoot> <taskId> [--branch <branch>]');
|
|
782
|
+
let result;
|
|
783
|
+
try {
|
|
784
|
+
result = classifyTaskSecurity(repoRoot, taskId, branch ? { branch } : undefined);
|
|
785
|
+
} catch (err) {
|
|
786
|
+
process.stderr.write(`cloverleaf-cli classify-security: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
787
|
+
process.exit(2);
|
|
788
|
+
}
|
|
789
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
790
|
+
break;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
case 'set-task-field': {
|
|
794
|
+
const [repoRoot, taskId, field, value] = rest;
|
|
795
|
+
if (!repoRoot || !taskId || !field || value === undefined)
|
|
796
|
+
usage('set-task-field requires <repoRoot> <taskId> <field> <value>');
|
|
797
|
+
const ALLOWED_FIELDS = new Set(['security_review_verdict']);
|
|
798
|
+
if (!ALLOWED_FIELDS.has(field)) {
|
|
799
|
+
die(
|
|
800
|
+
`set-task-field: unknown field '${field}'. Allowed fields: ${Array.from(ALLOWED_FIELDS).join(', ')}`
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
const task = loadTask(repoRoot, taskId);
|
|
804
|
+
const parsed: unknown = value === 'null' ? null : value;
|
|
805
|
+
(task as Record<string, unknown>)[field] = parsed;
|
|
806
|
+
saveTask(repoRoot, task);
|
|
807
|
+
break;
|
|
808
|
+
}
|
|
809
|
+
|
|
747
810
|
default:
|
|
748
811
|
usage(`Unknown command: ${command}`);
|
|
749
812
|
}
|
|
750
813
|
} catch (err: unknown) {
|
|
751
814
|
const msg = err instanceof Error ? err.message : String(err);
|
|
815
|
+
// SECURITY_GATE errors: write the bare validator message to stderr and exit 2
|
|
816
|
+
if ((err as { code?: string }).code === 'SECURITY_GATE') {
|
|
817
|
+
process.stderr.write(msg + '\n');
|
|
818
|
+
process.exit(2);
|
|
819
|
+
}
|
|
752
820
|
// Surface "illegal transition" errors with the right language
|
|
753
821
|
const lower = msg.toLowerCase();
|
|
754
822
|
if (lower.includes('illegal') || lower.includes('not allowed')) {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const DEFAULT_CONFIG = join(here, '..', 'config', 'secret-patterns.json');
|
|
7
|
+
|
|
8
|
+
export interface SecretPattern { name: string; regex: string; severity: 'error' | 'blocker'; }
|
|
9
|
+
export interface SecretPatternsConfig { patterns: SecretPattern[]; placeholder_excludes: string[]; }
|
|
10
|
+
|
|
11
|
+
export interface SecretFinding {
|
|
12
|
+
severity: 'error' | 'blocker';
|
|
13
|
+
message: string;
|
|
14
|
+
rule: string;
|
|
15
|
+
location?: { file?: string; line?: number };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function normalize(doc: Partial<SecretPatternsConfig>): SecretPatternsConfig {
|
|
19
|
+
const raw = Array.isArray(doc.patterns) ? doc.patterns : [];
|
|
20
|
+
const validSeverities = new Set(['error', 'blocker']);
|
|
21
|
+
const patterns = raw.filter(
|
|
22
|
+
(p): p is SecretPattern =>
|
|
23
|
+
typeof (p as SecretPattern).name === 'string' && (p as SecretPattern).name.length > 0 &&
|
|
24
|
+
typeof (p as SecretPattern).regex === 'string' && (p as SecretPattern).regex.length > 0 &&
|
|
25
|
+
validSeverities.has((p as SecretPattern).severity),
|
|
26
|
+
);
|
|
27
|
+
return {
|
|
28
|
+
patterns,
|
|
29
|
+
placeholder_excludes: Array.isArray(doc.placeholder_excludes)
|
|
30
|
+
? (doc.placeholder_excludes as unknown[]).filter((s): s is string => typeof s === 'string')
|
|
31
|
+
: [],
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function loadSecretPatternsConfig(repoRoot: string): SecretPatternsConfig {
|
|
36
|
+
const consumerPath = join(repoRoot, '.cloverleaf', 'config', 'secret-patterns.json');
|
|
37
|
+
const path = existsSync(consumerPath) ? consumerPath : DEFAULT_CONFIG;
|
|
38
|
+
if (!existsSync(path)) throw new Error(`secret-patterns config not found at ${path}`);
|
|
39
|
+
return normalize(JSON.parse(readFileSync(path, 'utf-8')) as Partial<SecretPatternsConfig>);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Scan text (typically the added/changed lines of a diff) for secrets.
|
|
44
|
+
* A line that matches any placeholder_exclude is skipped entirely (env refs,
|
|
45
|
+
* templates, obvious placeholders), keeping precision high.
|
|
46
|
+
*/
|
|
47
|
+
function compileRegex(pattern: string): RegExp {
|
|
48
|
+
// Support (?i) inline flag prefix (not valid in JS) by converting to the 'i' flag.
|
|
49
|
+
// Only a *leading* (?i) is supported; any occurrence elsewhere is almost certainly
|
|
50
|
+
// a mistake (it would be a JS syntax error) — reject it with a clear message.
|
|
51
|
+
const original = pattern;
|
|
52
|
+
let flags = '';
|
|
53
|
+
if (pattern.startsWith('(?i)')) {
|
|
54
|
+
pattern = pattern.slice(4);
|
|
55
|
+
flags = 'i';
|
|
56
|
+
}
|
|
57
|
+
if (pattern.includes('(?i)')) {
|
|
58
|
+
throw new Error('secret-scan: inline (?i) flag is only supported as a leading prefix: ' + original);
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
return new RegExp(pattern, flags);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
throw new Error('secret-scan: invalid pattern regex /' + pattern + '/: ' + (err as Error).message);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function scanSecrets(text: string, config: SecretPatternsConfig, file?: string): SecretFinding[] {
|
|
68
|
+
const excludes = config.placeholder_excludes.map((p) => compileRegex(p));
|
|
69
|
+
const compiled = config.patterns.map((p) => ({ ...p, re: compileRegex(p.regex) }));
|
|
70
|
+
const findings: SecretFinding[] = [];
|
|
71
|
+
const lines = text.split('\n');
|
|
72
|
+
lines.forEach((line, idx) => {
|
|
73
|
+
// Deliberate line-level tradeoff: a line matching any exclude pattern is
|
|
74
|
+
// skipped entirely. If a real secret happens to share a line with a
|
|
75
|
+
// placeholder token (e.g. a comment), it becomes a false-negative.
|
|
76
|
+
// This keeps precision high and avoids alert fatigue from generated files.
|
|
77
|
+
if (excludes.some((re) => re.test(line))) return;
|
|
78
|
+
for (const p of compiled) {
|
|
79
|
+
if (p.re.test(line)) {
|
|
80
|
+
findings.push({
|
|
81
|
+
severity: p.severity,
|
|
82
|
+
rule: p.name,
|
|
83
|
+
message: `Possible hardcoded secret (${p.name}) on line ${idx + 1}`,
|
|
84
|
+
location: { ...(file ? { file } : {}), line: idx + 1 },
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
return findings;
|
|
90
|
+
}
|
|
Binary file
|
package/lib/task.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
3
4
|
import { tasksDir, projectsDir } from './paths.js';
|
|
4
5
|
import type { Task as SMTask } from '@cloverleaf/standard/validators/index.js';
|
|
5
6
|
import { validateOrThrow } from './validate.js';
|
|
6
7
|
import { advanceWorkItemStatus, loadStateMachine } from './work-item.js';
|
|
8
|
+
import { classifyTaskSecurity } from './security-classify.js';
|
|
7
9
|
|
|
8
10
|
export interface TaskDoc {
|
|
9
11
|
type: 'task';
|
|
@@ -12,6 +14,8 @@ export interface TaskDoc {
|
|
|
12
14
|
title: string;
|
|
13
15
|
status: string;
|
|
14
16
|
risk_class: 'low' | 'high';
|
|
17
|
+
security_class?: 'low' | 'high';
|
|
18
|
+
security_review_verdict?: 'pass' | 'bounce' | 'escalate' | null;
|
|
15
19
|
owner: { kind: 'agent' | 'human' | 'system'; id: string };
|
|
16
20
|
acceptance_criteria: string[];
|
|
17
21
|
definition_of_done: string[];
|
|
@@ -51,10 +55,47 @@ export function advanceStatus(
|
|
|
51
55
|
actor: 'agent' | 'human',
|
|
52
56
|
options: { gate?: string; path?: 'fast_lane' | 'full_pipeline' } = {}
|
|
53
57
|
): TaskDoc {
|
|
54
|
-
|
|
58
|
+
let task = loadTask(repoRoot, taskId);
|
|
55
59
|
const from = task.status;
|
|
56
60
|
const sm = loadStateMachine('task');
|
|
57
61
|
|
|
62
|
+
const targetTransition = sm.transitions.find((t) => t.from === from && t.to === toStatus);
|
|
63
|
+
|
|
64
|
+
// Security-gate writeback: classify the task's security and upgrade if needed.
|
|
65
|
+
if (targetTransition?.security_gate) {
|
|
66
|
+
let classification;
|
|
67
|
+
try {
|
|
68
|
+
classification = classifyTaskSecurity(repoRoot, taskId);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
process.stderr.write(
|
|
71
|
+
`cloverleaf-cli advance-status: classify-security errored (${err instanceof Error ? err.message : String(err)}); treating effective security_class as "high".\n`
|
|
72
|
+
);
|
|
73
|
+
classification = {
|
|
74
|
+
declared: (task.security_class === 'high' ? 'high' : 'low') as 'low' | 'high',
|
|
75
|
+
diff_detected: true,
|
|
76
|
+
effective: 'high' as const,
|
|
77
|
+
matched_paths: [],
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
if (classification.declared === 'low' && classification.effective === 'high') {
|
|
81
|
+
const upgraded: TaskDoc = { ...task, security_class: 'high' };
|
|
82
|
+
saveTask(repoRoot, upgraded);
|
|
83
|
+
const taskFilePath = join(tasksDir(repoRoot), `${taskId}.json`);
|
|
84
|
+
try {
|
|
85
|
+
execFileSync('git', ['-C', repoRoot, 'add', taskFilePath], { stdio: 'pipe' });
|
|
86
|
+
execFileSync(
|
|
87
|
+
'git',
|
|
88
|
+
['-C', repoRoot, 'commit', '-m', `cloverleaf: ${taskId} security_class → high (diff-detected)`],
|
|
89
|
+
{ stdio: 'pipe' }
|
|
90
|
+
);
|
|
91
|
+
} catch {
|
|
92
|
+
// No-op: commit is best-effort when running outside a git repo (e.g., test environments).
|
|
93
|
+
}
|
|
94
|
+
// Mutate in-memory so the validator sees the upgraded value.
|
|
95
|
+
task = { ...upgraded };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
58
99
|
const riskClass: 'low' | 'high' =
|
|
59
100
|
options.path === 'fast_lane' ? 'low'
|
|
60
101
|
: options.path === 'full_pipeline' ? 'high'
|
|
@@ -66,12 +107,20 @@ export function advanceStatus(
|
|
|
66
107
|
project: task.project,
|
|
67
108
|
status: task.status,
|
|
68
109
|
risk_class: riskClass,
|
|
110
|
+
security_class: task.security_class,
|
|
111
|
+
security_review_verdict: task.security_review_verdict,
|
|
69
112
|
context: { rfc: { project: task.project, id: task.id } },
|
|
70
113
|
definition_of_done: task.definition_of_done,
|
|
71
114
|
acceptance_criteria: task.acceptance_criteria,
|
|
72
115
|
};
|
|
73
116
|
|
|
74
|
-
const
|
|
117
|
+
const resetsVerdict = targetTransition?.resets_security_verdict === true;
|
|
118
|
+
const proposed: TaskDoc = {
|
|
119
|
+
...task,
|
|
120
|
+
status: toStatus,
|
|
121
|
+
...(resetsVerdict ? { security_review_verdict: null } : {}),
|
|
122
|
+
};
|
|
123
|
+
|
|
75
124
|
advanceWorkItemStatus({
|
|
76
125
|
repoRoot,
|
|
77
126
|
workItemType: 'task',
|
|
@@ -87,5 +136,21 @@ export function advanceStatus(
|
|
|
87
136
|
gate: options.gate,
|
|
88
137
|
path: options.path,
|
|
89
138
|
});
|
|
139
|
+
|
|
140
|
+
// After a successful status change + verdict reset, emit a single commit covering both.
|
|
141
|
+
if (resetsVerdict) {
|
|
142
|
+
const taskFilePath = join(tasksDir(repoRoot), `${taskId}.json`);
|
|
143
|
+
try {
|
|
144
|
+
execFileSync('git', ['-C', repoRoot, 'add', taskFilePath], { stdio: 'pipe' });
|
|
145
|
+
execFileSync(
|
|
146
|
+
'git',
|
|
147
|
+
['-C', repoRoot, 'commit', '-m', `cloverleaf: ${taskId} status ${from} → ${toStatus}; security_review_verdict → null (rework)`],
|
|
148
|
+
{ stdio: 'pipe' }
|
|
149
|
+
);
|
|
150
|
+
} catch {
|
|
151
|
+
// No-op: commit is best-effort when running outside a git repo (e.g., test environments).
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
90
155
|
return proposed;
|
|
91
156
|
}
|
package/lib/work-item.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
4
|
import { emitStatusTransition, formatReason } from './events.js';
|
|
5
|
-
import { validateStatusTransitionLegality } from '@cloverleaf/standard/validators/index.js';
|
|
5
|
+
import { validateStatusTransitionLegality, validateSecurityGate } from '@cloverleaf/standard/validators/index.js';
|
|
6
6
|
import type { StatusTransitions } from '@cloverleaf/standard/validators/index.js';
|
|
7
7
|
|
|
8
8
|
const req = createRequire(import.meta.url);
|
|
@@ -56,6 +56,16 @@ export function advanceWorkItemStatus<T>(params: AdvanceWorkItemParams<T>): Adva
|
|
|
56
56
|
throw new Error(`Illegal transition ${from} → ${to}: ${msgs}`);
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
if (workItemType === 'task') {
|
|
60
|
+
const sgResult = validateSecurityGate(event, stateMachine, validateFixture as never);
|
|
61
|
+
if (!sgResult.ok) {
|
|
62
|
+
const msgs = sgResult.violations.map((v) => v.message).join('; ');
|
|
63
|
+
const err = new Error(msgs);
|
|
64
|
+
(err as Error & { code?: string }).code = 'SECURITY_GATE';
|
|
65
|
+
throw err;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
59
69
|
const emittedPath = emitStatusTransition(repoRoot, {
|
|
60
70
|
project,
|
|
61
71
|
workItemType,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloverleaf/reference-impl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "Reference implementation of the Cloverleaf methodology as Claude Code skills. Implements the Tight Loop (Implementer + Reviewer).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -48,8 +48,11 @@
|
|
|
48
48
|
"acceptance:walker": "bash scripts/acceptance-walker.sh",
|
|
49
49
|
"prepublishOnly": "node scripts/check-standard-prepped.mjs && npm test && npm run build"
|
|
50
50
|
},
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"@cloverleaf/standard": "^0.7.1"
|
|
53
|
+
},
|
|
51
54
|
"dependencies": {
|
|
52
|
-
"@cloverleaf/standard": "^0.
|
|
55
|
+
"@cloverleaf/standard": "^0.7.0",
|
|
53
56
|
"ajv": "^8.17.1",
|
|
54
57
|
"ajv-formats": "^3.0.1",
|
|
55
58
|
"axe-core": "^4.10.0",
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Security Reviewer
|
|
2
|
+
|
|
3
|
+
You are a security-minded senior engineer reviewing a code change for vulnerabilities. You are read-only — do not modify code. Judge ONLY the diff provided.
|
|
4
|
+
|
|
5
|
+
## Task
|
|
6
|
+
{{task}}
|
|
7
|
+
|
|
8
|
+
## Branch / base
|
|
9
|
+
Branch `{{branch}}` against `{{base_branch}}`. Repo root `{{repo_root}}`.
|
|
10
|
+
|
|
11
|
+
## Diff
|
|
12
|
+
{{diff}}
|
|
13
|
+
|
|
14
|
+
## What to judge
|
|
15
|
+
|
|
16
|
+
Examine the changed code for:
|
|
17
|
+
- **Injection** — SQL, command/shell, path traversal, template/SSTI. Any user/external input reaching an interpreter, query, filesystem path, or subprocess without parameterization/escaping.
|
|
18
|
+
- **Broken or missing authorization** — endpoints/handlers that skip an access check; privilege boundaries crossed.
|
|
19
|
+
- **Unsafe deserialization** — `pickle`, `yaml.load`, `eval`, untrusted JSON→object with prototype risks.
|
|
20
|
+
- **SSRF / unsafe outbound** — server-side requests built from untrusted input.
|
|
21
|
+
- **Missing input validation** — unchecked sizes, types, ranges on external input that reaches sensitive sinks.
|
|
22
|
+
- **Unsafe file ops** — path joins from untrusted input, world-writable perms, temp-file races.
|
|
23
|
+
- **Weak crypto / weak defaults** — MD5/SHA1 for security, hardcoded IVs, disabled TLS verification, permissive CORS.
|
|
24
|
+
|
|
25
|
+
A deterministic secret scan runs separately; you do NOT need to hunt for hardcoded keys (but flag one if you see it).
|
|
26
|
+
|
|
27
|
+
## Return contract
|
|
28
|
+
|
|
29
|
+
The response envelope **must** include a top-level `verdict` field (enum: `"pass"`, `"bounce"`, or `"escalate"`). The verdict is derived from the maximum severity across all findings:
|
|
30
|
+
|
|
31
|
+
| Max severity across findings | verdict |
|
|
32
|
+
|------------------------------|---------|
|
|
33
|
+
| any `blocker` | `"escalate"` |
|
|
34
|
+
| any `error` or `warning` | `"bounce"` |
|
|
35
|
+
| `info` only, or no findings | `"pass"` |
|
|
36
|
+
|
|
37
|
+
The host skill (`cloverleaf-security-review`) persists the verdict on the task via `cloverleaf-cli set-task-field <repo_root> <TASK-ID> security_review_verdict <verdict>`. You (the security reviewer agent) do not write to the task file — you only emit the envelope. The skill reads your `verdict` field and records it.
|
|
38
|
+
|
|
39
|
+
## Output
|
|
40
|
+
|
|
41
|
+
Return ONLY a feedback envelope JSON:
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{ "verdict": "pass" | "bounce" | "escalate",
|
|
45
|
+
"summary": "<one-line overall assessment>",
|
|
46
|
+
"findings": [
|
|
47
|
+
{ "severity": "info|warning|error|blocker",
|
|
48
|
+
"message": "<what + why it matters>",
|
|
49
|
+
"location": { "file": "<path>", "line": <n> },
|
|
50
|
+
"suggestion": "<concrete fix>",
|
|
51
|
+
"rule": "<short-id e.g. injection.sql>" }
|
|
52
|
+
] }
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Severity guidance: `blocker` = exploitable now / credential exposure (→ human escalation). `error` = real vulnerability the implementer must fix. `warning` = weakness worth fixing. `info` = advisory, non-blocking. Set `verdict`: `escalate` if any `blocker`; else `bounce` if any `error`/`warning`; else `pass`. Be precise — false alarms erode trust. If the diff is inert (docs/tests/config with no security surface), return `{ "verdict": "pass", "summary": "no security-relevant surface", "findings": [] }`.
|
|
@@ -35,7 +35,8 @@ The user has invoked this skill with a brief. Your job: turn the brief into a st
|
|
|
35
35
|
"context": <see "context.rfc injection" below>,
|
|
36
36
|
"acceptance_criteria": ["<criterion 1>", "<criterion 2>", "..."],
|
|
37
37
|
"definition_of_done": ["<terminal statement of completion>"],
|
|
38
|
-
"risk_class": "low"
|
|
38
|
+
"risk_class": "low",
|
|
39
|
+
"security_class": "<see security_class inference below>"
|
|
39
40
|
}
|
|
40
41
|
```
|
|
41
42
|
|
|
@@ -82,6 +83,11 @@ The user has invoked this skill with a brief. Your job: turn the brief into a st
|
|
|
82
83
|
`site/`, `UI`, `page`, `component`, `style`, `visual`, `layout`, `render`, `display`, `accessibility`, `a11y`, `responsive`, `.astro`, `.css`, `.html`
|
|
83
84
|
3. Also set `risk_class: "high"` for breaking APIs or cross-project work (v0.1.1 behavior, retained).
|
|
84
85
|
4. Default: `risk_class: "low"`.
|
|
86
|
+
- **`security_class` inference:** `security_class` (`"low"|"high"`, independent of `risk_class`) gates the Security Reviewer. Rules:
|
|
87
|
+
1. If the user passed `--security=high` or `--security=low`, honor it.
|
|
88
|
+
2. Otherwise set `"high"` when the brief, any acceptance criterion, OR any `scope.files_touched` entry matches a sensitive marker from `security-paths.json` (keyword patterns like `secret`, `credential`, `api key`, `token`, `password`, `exchange`, `sql`, `eval`, `subprocess`; or path patterns like `**/*.env*`, `**/secrets*`, `**/deploy*.sh`, `**/*.sql`, `**/auth*`). Consumers override the marker set at `.cloverleaf/config/security-paths.json`.
|
|
89
|
+
3. Default: `"low"`.
|
|
90
|
+
- After writing the task, report the chosen `security_class` and why, e.g. `Security class: high → security review (matched keyword "credential"). Override with --security=low if desired.` Note: even when `low`, the orchestrator re-checks the actual diff at review time and routes to security-review if it touches sensitive paths (defense in depth).
|
|
85
91
|
- After writing the task, report the chosen risk_class and how it was determined, e.g.:
|
|
86
92
|
> "Risk class: `high` → full pipeline (matched keyword `component` in acceptance criterion). Override with `--risk=low` if desired."
|
|
87
93
|
- Users can manually edit `risk_class` in the task JSON before running `/cloverleaf-run`.
|
|
@@ -17,10 +17,40 @@ Do NOT `git checkout main` from a walker worktree — main is held by the primar
|
|
|
17
17
|
MAX_REVIEWER_BOUNCES = 3
|
|
18
18
|
MAX_UI_REVIEWER_BOUNCES = 3
|
|
19
19
|
MAX_QA_BOUNCES = 3
|
|
20
|
+
MAX_SECURITY_BOUNCES = 3
|
|
20
21
|
```
|
|
21
22
|
|
|
22
23
|
These counters live in-session (not persisted). Rerunning `/cloverleaf-run` resets.
|
|
23
24
|
|
|
25
|
+
## Security gate (both lanes)
|
|
26
|
+
|
|
27
|
+
Run this immediately after the task reaches `automated-gates` (Reviewer passed) and BEFORE the lane's next move (fast lane: merge; full pipeline: detect-ui-paths). Initialize `security_bounces = 0` at orchestrator start (alongside the other bounce counters).
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
cloverleaf-cli classify-security <repo_root> <TASK-ID> --branch cloverleaf/<TASK-ID>
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Parse the JSON. If `classify-security` exits non-zero or emits unparseable output, do NOT silently skip security review (fail-open is unsafe for a security gate). Warn to the user and treat the task as `effective: "high"` — i.e. proceed into security-review anyway (fail toward more scrutiny). If `/cloverleaf-security-review` then cannot run either (e.g. branch/tooling broken), surface the failure and stop rather than merging unreviewed.
|
|
34
|
+
|
|
35
|
+
If `effective == "low"` → skip the gate, proceed with the lane.
|
|
36
|
+
|
|
37
|
+
If `effective == "high"`:
|
|
38
|
+
- If `declared == "low"` (under-classification: `diff_detected` true), you may proactively run `classify-security` to confirm, but the writeback to `security_class: "high"` is now mechanical — the CLI handles it automatically when `advance-status` moves the task to `security-review`. No manual scripting of the writeback is required.
|
|
39
|
+
- `cloverleaf-cli advance-status <repo_root> <TASK-ID> security-review agent`; commit.
|
|
40
|
+
- Inline `/cloverleaf-security-review <TASK-ID>` steps. Reload the task:
|
|
41
|
+
- `status == "automated-gates"` → security review passed; proceed with the lane.
|
|
42
|
+
- `status == "implementing"` → bounced. `security_bounces += 1`. If `security_bounces >= MAX_SECURITY_BOUNCES`, escalate (section 6). Else re-enter the implement→review loop (fast lane section 4 / full pipeline section 5.1), which re-runs the security gate on its next pass.
|
|
43
|
+
- `status == "escalated"` → the security reviewer found a blocker; stop and surface to the user (a human must review `.cloverleaf/feedback/`). This is the security reviewer's own escalation, distinct from a bounce-budget exhaustion.
|
|
44
|
+
|
|
45
|
+
### Refusal and recover
|
|
46
|
+
|
|
47
|
+
In v0.8.1, `advance-status` from `automated-gates` to any post-gate state (`ui-review`, `qa`, `merged`) may exit with **exit code 2** when the task is high-security and has no pass verdict recorded (`security_review_verdict` is absent or not `"pass"`). This is a **security-gate refusal** — the CLI is enforcing that high-security tasks must pass security review before proceeding.
|
|
48
|
+
|
|
49
|
+
Recovery sequence:
|
|
50
|
+
1. Advance the task to `security-review` first: `cloverleaf-cli advance-status <repo_root> <TASK-ID> security-review agent`; commit.
|
|
51
|
+
2. Run `/cloverleaf-security-review <TASK-ID>` to execute the security review.
|
|
52
|
+
3. Retry the original `advance-status` call. If the review passed, the CLI will now allow the transition.
|
|
53
|
+
|
|
24
54
|
## Steps
|
|
25
55
|
|
|
26
56
|
1. Capture TASK-ID.
|
|
@@ -33,7 +63,7 @@ These counters live in-session (not persisted). Rerunning `/cloverleaf-run` rese
|
|
|
33
63
|
|
|
34
64
|
### 4. Fast Lane
|
|
35
65
|
|
|
36
|
-
Initialize `reviewer_bounces = 0`.
|
|
66
|
+
Initialize `reviewer_bounces = 0`, `security_bounces = 0`.
|
|
37
67
|
|
|
38
68
|
Loop:
|
|
39
69
|
a. Inline `/cloverleaf-implement <TASK-ID>` steps.
|
|
@@ -42,11 +72,11 @@ Loop:
|
|
|
42
72
|
d. If `status === "implementing"`: Reviewer bounced. `reviewer_bounces += 1`. If `reviewer_bounces >= MAX_REVIEWER_BOUNCES`, escalate (section 6). Else continue loop.
|
|
43
73
|
e. Else: unexpected state. Report and stop.
|
|
44
74
|
|
|
45
|
-
After loop: inline `/cloverleaf-merge <TASK-ID>`.
|
|
75
|
+
After loop (status `automated-gates`): run the **Security gate (both lanes)** (above). Then inline `/cloverleaf-merge <TASK-ID>`.
|
|
46
76
|
|
|
47
77
|
### 5. Full Pipeline
|
|
48
78
|
|
|
49
|
-
Initialize `reviewer_bounces = 0`, `ui_reviewer_bounces = 0`, `qa_bounces = 0`.
|
|
79
|
+
Initialize `reviewer_bounces = 0`, `ui_reviewer_bounces = 0`, `qa_bounces = 0`, `security_bounces = 0`.
|
|
50
80
|
|
|
51
81
|
5.1. **Implementer → Documenter → Reviewer loop:**
|
|
52
82
|
|
|
@@ -58,6 +88,8 @@ Loop:
|
|
|
58
88
|
e. If `status === "implementing"`: Reviewer bounced. `reviewer_bounces += 1`. If `reviewer_bounces >= MAX_REVIEWER_BOUNCES`, escalate. Else continue loop.
|
|
59
89
|
f. Else: unexpected. Report and stop.
|
|
60
90
|
|
|
91
|
+
**Security gate.** Run the **Security gate (both lanes)** (above) now, before UI-path detection. Then continue to 5.2.
|
|
92
|
+
|
|
61
93
|
5.2. **UI-path detection and conditional UI Review:**
|
|
62
94
|
|
|
63
95
|
```bash
|
|
@@ -88,7 +120,7 @@ Loop:
|
|
|
88
120
|
|
|
89
121
|
- `cloverleaf-cli advance-status <repo_root> <TASK-ID> escalated agent`
|
|
90
122
|
- Commit: `git add .cloverleaf/ && git commit -m "cloverleaf: <TASK-ID> escalated (bounce budget exhausted)"`.
|
|
91
|
-
- Report: "✗ Escalated `<TASK-ID>`. Review `.cloverleaf/feedback/` and either refine the task or take over manually. Counters: reviewer=<N>, ui_reviewer=<N>, qa=<N>."
|
|
123
|
+
- Report: "✗ Escalated `<TASK-ID>`. Review `.cloverleaf/feedback/` and either refine the task or take over manually. Counters: reviewer=<N>, ui_reviewer=<N>, qa=<N>, security=<N>."
|
|
92
124
|
|
|
93
125
|
## Rules
|
|
94
126
|
|
|
@@ -394,3 +394,5 @@ The concrete policy JSON is the same one used during the CLV-16..CLV-20 dogfood
|
|
|
394
394
|
**Vocab dependency.** The walker reads `notification_contract.vocabulary` from the `start_session` response advisorily — if the expected tokens (`DONE`, `NEEDS-INPUT`) are absent it warns to stderr but continues. The authoritative source of truth for which tokens Session B will emit is the SDK's `--driven-tokens` flag passed when claw-drive spawns the session. A mismatch between the contract and the actual flag signals a version skew between the claw-drive server and the reference-impl skill; upgrade both components together to keep them in sync.
|
|
395
395
|
|
|
396
396
|
**RFC-direct task participation in RFC auto-advance.** Tasks with no `parent` field but with `context.rfc` set (created via `/cloverleaf-new-task --rfc=<RFC-ID>`) are *first-class* participants in the `can_auto_advance_rfc` check. They block the advance when in-flight; they count toward delivery when merged. See `cloverleaf-cli rfc-tasks <repo_root> <RFC-ID>` for the categorized view this dispatch reads from, or `reference-impl/README.md` § "Plans vs RFC-direct tasks" for the user-facing pattern docs.
|
|
397
|
+
|
|
398
|
+
**Security-review escalations.** When a task's effective `security_class` is high, `/cloverleaf-run` routes it through `security-review` off the automated-gates hub. A `blocker` finding (e.g. a leaked credential) advances the task to `escalated`, which surfaces through the walker's existing escalation path — expect and surface these like any other escalation; do not auto-retry a security blocker.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cloverleaf-security-review
|
|
3
|
+
description: Run the Security Reviewer agent on a task in the `security-review` state. Hybrid two-pass (deterministic secret scan + LLM vulnerability judgment); emits a feedback envelope; advances to automated-gates (pass), implementing (bounce), or escalated (blocker). Usage — /cloverleaf-security-review <TASK-ID>.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Cloverleaf — security review
|
|
7
|
+
|
|
8
|
+
## Steps
|
|
9
|
+
|
|
10
|
+
0. Pre-flight: stay on the current branch (do NOT checkout main in walker worktrees). Clean stale temp:
|
|
11
|
+
```bash
|
|
12
|
+
rm -f /tmp/cloverleaf-fb-s.json
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
1. Capture the TASK-ID argument.
|
|
16
|
+
|
|
17
|
+
2. Load the task: `cloverleaf-cli load-task <repo_root> <TASK-ID>`. Verify `status === "security-review"`. If not, report the current status and stop.
|
|
18
|
+
|
|
19
|
+
3. Confirm the branch `cloverleaf/<TASK-ID>` exists: `git rev-parse --verify cloverleaf/<TASK-ID>`. If missing, report the discrepancy and stop. Compute the diff for the subagent (do NOT check out): `git diff main..cloverleaf/<TASK-ID>`.
|
|
20
|
+
|
|
21
|
+
4. **Pass A — deterministic secret scan:**
|
|
22
|
+
```bash
|
|
23
|
+
cloverleaf-cli secret-scan <repo_root> --branch cloverleaf/<TASK-ID>
|
|
24
|
+
```
|
|
25
|
+
Capture the `findings[]` array (each has `severity` of `error`/`blocker`, plus `rule`, `message`, `location`).
|
|
26
|
+
|
|
27
|
+
5. **Pass B — LLM judgment:** dispatch a subagent via the Task tool:
|
|
28
|
+
- `subagent_type`: `general-purpose`
|
|
29
|
+
- `model`: `sonnet`
|
|
30
|
+
- Prompt: contents of `$(cloverleaf-cli plugin-root)/prompts/security-reviewer.md` with substitutions for `{{task}}`, `{{branch}}`, `{{base_branch}}`, `{{repo_root}}`, `{{diff}}`.
|
|
31
|
+
Parse the subagent's feedback envelope (`verdict` + `findings[]`).
|
|
32
|
+
|
|
33
|
+
6. **Merge + derive verdict.** Concatenate Pass A findings + Pass B findings into one `findings[]`. Derive the final verdict from the max severity across ALL findings:
|
|
34
|
+
- any `blocker` → `verdict: "escalate"`
|
|
35
|
+
- else any `error` or `warning` → `verdict: "bounce"`
|
|
36
|
+
- else (only `info`, or none) → `verdict: "pass"`
|
|
37
|
+
|
|
38
|
+
7. **Branch on verdict:**
|
|
39
|
+
|
|
40
|
+
**Pass:**
|
|
41
|
+
```bash
|
|
42
|
+
cloverleaf-cli set-task-field <repo_root> <TASK-ID> security_review_verdict pass
|
|
43
|
+
git -C <repo_root> add .cloverleaf/ && git -C <repo_root> commit -m "cloverleaf: <TASK-ID> security_review_verdict → pass"
|
|
44
|
+
cloverleaf-cli advance-status <repo_root> <TASK-ID> automated-gates agent
|
|
45
|
+
git -C <repo_root> add .cloverleaf/ && git -C <repo_root> commit -m "cloverleaf: <TASK-ID> security review passed → automated-gates"
|
|
46
|
+
```
|
|
47
|
+
Report: "✓ Security review passed. State → automated-gates."
|
|
48
|
+
|
|
49
|
+
**Bounce:**
|
|
50
|
+
```bash
|
|
51
|
+
echo '<merged-envelope-json>' > /tmp/cloverleaf-fb-s.json
|
|
52
|
+
cloverleaf-cli write-feedback <repo_root> <TASK-ID> /tmp/cloverleaf-fb-s.json
|
|
53
|
+
git -C <repo_root> add .cloverleaf/feedback/ && git -C <repo_root> commit -m "cloverleaf: <TASK-ID> security review feedback"
|
|
54
|
+
cloverleaf-cli set-task-field <repo_root> <TASK-ID> security_review_verdict bounce
|
|
55
|
+
git -C <repo_root> add .cloverleaf/ && git -C <repo_root> commit -m "cloverleaf: <TASK-ID> security_review_verdict → bounce"
|
|
56
|
+
cloverleaf-cli advance-status <repo_root> <TASK-ID> implementing agent
|
|
57
|
+
git -C <repo_root> add .cloverleaf/ && git -C <repo_root> commit -m "cloverleaf: <TASK-ID> security review bounced → implementing"
|
|
58
|
+
```
|
|
59
|
+
Report: "✗ Security review bounced. Findings: <summarize by severity>. State → implementing."
|
|
60
|
+
|
|
61
|
+
**Escalate (blocker found):**
|
|
62
|
+
```bash
|
|
63
|
+
echo '<merged-envelope-json>' > /tmp/cloverleaf-fb-s.json
|
|
64
|
+
cloverleaf-cli write-feedback <repo_root> <TASK-ID> /tmp/cloverleaf-fb-s.json
|
|
65
|
+
git -C <repo_root> add .cloverleaf/feedback/ && git -C <repo_root> commit -m "cloverleaf: <TASK-ID> security review feedback"
|
|
66
|
+
cloverleaf-cli set-task-field <repo_root> <TASK-ID> security_review_verdict escalate
|
|
67
|
+
git -C <repo_root> add .cloverleaf/ && git -C <repo_root> commit -m "cloverleaf: <TASK-ID> security_review_verdict → escalate"
|
|
68
|
+
cloverleaf-cli advance-status <repo_root> <TASK-ID> escalated agent
|
|
69
|
+
git -C <repo_root> add .cloverleaf/ && git -C <repo_root> commit -m "cloverleaf: <TASK-ID> security review escalated (blocker finding)"
|
|
70
|
+
```
|
|
71
|
+
Report: "⚠ Security review found a BLOCKER. State → escalated. A human must review `.cloverleaf/feedback/` before this can proceed."
|
|
72
|
+
|
|
73
|
+
## Rules
|
|
74
|
+
|
|
75
|
+
- Never push. Read-only on source — the security reviewer does not modify code.
|
|
76
|
+
- A `blocker` (e.g. a leaked credential) ALWAYS escalates to a human; never let the bounce loop silently "fix" it.
|
|
77
|
+
- On illegal state transition, report and stop without partial commits.
|