@cloverleaf/reference-impl 0.7.4 → 0.8.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/.claude-plugin/plugin.json +1 -1
- package/README.md +45 -0
- package/VERSION +1 -1
- package/config/secret-patterns.json +14 -0
- package/config/security-paths.json +13 -0
- package/dist/cli.mjs +81 -1
- package/dist/rfc-tasks.mjs +83 -0
- package/dist/secret-scan.mjs +75 -0
- package/dist/security-classify.mjs +61 -0
- package/lib/cli.ts +74 -1
- package/lib/rfc-tasks.ts +112 -0
- package/lib/secret-scan.ts +90 -0
- package/lib/security-classify.ts +80 -0
- package/package.json +2 -2
- package/prompts/security-reviewer.md +43 -0
- package/skills/cloverleaf-new-task/SKILL.md +8 -2
- package/skills/cloverleaf-run/SKILL.md +27 -4
- package/skills/cloverleaf-run-plan/SKILL.md +16 -19
- package/skills/cloverleaf-security-review/SKILL.md +71 -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)*
|
|
@@ -166,6 +167,50 @@ npm test # run the Vitest suite
|
|
|
166
167
|
npm run test:watch
|
|
167
168
|
```
|
|
168
169
|
|
|
170
|
+
## Plans vs RFC-direct tasks
|
|
171
|
+
|
|
172
|
+
Cloverleaf has two ways to get from an approved RFC to executed Tasks. Both are first-class; the right one depends on the size and shape of the work.
|
|
173
|
+
|
|
174
|
+
**Plan-task (Discovery flow):** Operator invokes `/cloverleaf-discover`. The Plan agent decomposes the RFC into a Plan with a `task_dag`; the operator approves the decomposition at `task_batch_gate`; tasks are materialised under the Plan; the walker drives them through Delivery. Use this when the work spans ≥3 related tasks AND there's value in reviewing the decomposition before any task materializes (the gate is a checkpoint, not ceremony).
|
|
175
|
+
|
|
176
|
+
**RFC-direct task (skip the Plan layer):** Operator invokes `/cloverleaf-new-task --rfc=<RFC-ID> "<brief>"`. A single task is created with `context.rfc` set and `parent` absent — no Plan, no `task_batch_gate`. Use this for:
|
|
177
|
+
|
|
178
|
+
- **Hotfixes after a Plan has delivered** — a small bug or polish item surfaces after the Plan's tasks are all merged. Creating a new Plan for one task is pure overhead; an RFC-direct task is faster and equally trackable.
|
|
179
|
+
- **Incremental RFC progress without batch decomposition** — operator hasn't yet decided how to decompose the next chunk of work, but a single concrete task is clear. Create it now; defer the Plan formation until later (or skip Plans entirely if the work continues to arrive one task at a time).
|
|
180
|
+
|
|
181
|
+
### Auto-advance: how the walker treats them
|
|
182
|
+
|
|
183
|
+
When the walker (`/cloverleaf-run-plan`) finishes a Plan's final task, it asks `cloverleaf-cli rfc-tasks <repo_root> <RFC-ID>` whether the parent RFC can also advance from `approved` to `completed`. The check considers BOTH sibling Plans AND RFC-direct tasks under the same RFC:
|
|
184
|
+
|
|
185
|
+
- An **in-flight** Plan (`drafting`/`gate-pending`/`approved`) OR standalone task (any non-terminal state) blocks the RFC advance — there's still work pending under this RFC.
|
|
186
|
+
- A **delivered** Plan (`completed`) OR standalone task (`merged`) counts toward the at-least-one-delivered requirement — the RFC must have produced at least one successful piece of work to advance.
|
|
187
|
+
- If all delivered work was rejected/escalated, the operator decides: abandon the RFC, re-decompose, or accept the RFC as not-shippable. The walker won't auto-advance.
|
|
188
|
+
|
|
189
|
+
### Operator visibility
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
cloverleaf-cli rfc-tasks <repo_root> <RFC-ID> # compact JSON
|
|
193
|
+
cloverleaf-cli rfc-tasks <repo_root> <RFC-ID> --pretty # indented for humans
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Returns the RFC's status, all sibling Plans (with their child tasks), all standalone tasks, and a summary block with in-flight/delivered counts plus `can_auto_advance_rfc`. Pure read; no side effects.
|
|
197
|
+
|
|
198
|
+
### The tradeoff to name
|
|
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.
|
|
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
|
+
|
|
169
214
|
## License
|
|
170
215
|
|
|
171
216
|
MIT — see [../LICENSE](../LICENSE).
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.8.0
|
|
@@ -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
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* load-rfc <repoRoot> <id>
|
|
21
21
|
* save-rfc <repoRoot> <filePath>
|
|
22
22
|
* advance-rfc <repoRoot> <id> <toStatus> <agent|human> [gate]
|
|
23
|
+
* rfc-tasks <repoRoot> <rfcId> [--pretty]
|
|
23
24
|
* load-spike <repoRoot> <id>
|
|
24
25
|
* save-spike <repoRoot> <filePath>
|
|
25
26
|
* advance-spike <repoRoot> <id> <toStatus> <agent|human>
|
|
@@ -37,6 +38,8 @@
|
|
|
37
38
|
* walker-default-concurrency [--explain]
|
|
38
39
|
* check-scope <repoRoot> <taskId> --branch <branchName>
|
|
39
40
|
* extend-scope <repoRoot> <taskId> --add <file>... --reason <text>
|
|
41
|
+
* secret-scan <repoRoot> --branch <branch>
|
|
42
|
+
* classify-security <repoRoot> <taskId> [--branch <branch>]
|
|
40
43
|
*/
|
|
41
44
|
import { readFileSync, mkdirSync, copyFileSync, appendFileSync } from 'node:fs';
|
|
42
45
|
import { dirname, join } from 'node:path';
|
|
@@ -63,6 +66,9 @@ import { computeReadyTasks, detectCycle } from './dag-walker.mjs';
|
|
|
63
66
|
import { readWalkState, writeWalkState, walkStatePath } from './walk-state.mjs';
|
|
64
67
|
import { loadWalkerConfig } from './walker-config.mjs';
|
|
65
68
|
import { classifyFiles } from './scope-check.mjs';
|
|
69
|
+
import { computeRfcTasksView } from './rfc-tasks.mjs';
|
|
70
|
+
import { loadSecretPatternsConfig, scanSecrets } from './secret-scan.mjs';
|
|
71
|
+
import { loadSecurityPathsConfig, computeSecurityClassification } from './security-classify.mjs';
|
|
66
72
|
function die(msg, code = 1) {
|
|
67
73
|
process.stderr.write(msg + '\n');
|
|
68
74
|
process.exit(code);
|
|
@@ -87,6 +93,7 @@ function usage(msg) {
|
|
|
87
93
|
' load-rfc <repoRoot> <id>\n' +
|
|
88
94
|
' save-rfc <repoRoot> <filePath>\n' +
|
|
89
95
|
' advance-rfc <repoRoot> <id> <toStatus> <agent|human> [gate]\n' +
|
|
96
|
+
' rfc-tasks <repoRoot> <rfcId> [--pretty]\n' +
|
|
90
97
|
' load-spike <repoRoot> <id>\n' +
|
|
91
98
|
' save-spike <repoRoot> <filePath>\n' +
|
|
92
99
|
' advance-spike <repoRoot> <id> <toStatus> <agent|human>\n' +
|
|
@@ -103,7 +110,9 @@ function usage(msg) {
|
|
|
103
110
|
' walk-state-write <repoRoot> <walkStateJsonPath>\n' +
|
|
104
111
|
' walker-default-concurrency [--explain]\n' +
|
|
105
112
|
' check-scope <repoRoot> <taskId> --branch <branchName>\n' +
|
|
106
|
-
' extend-scope <repoRoot> <taskId> --add <file>... --reason <text>\n'
|
|
113
|
+
' extend-scope <repoRoot> <taskId> --add <file>... --reason <text>\n' +
|
|
114
|
+
' secret-scan <repoRoot> --branch <branch>\n' +
|
|
115
|
+
' classify-security <repoRoot> <taskId> [--branch <branch>]\n');
|
|
107
116
|
process.exit(2);
|
|
108
117
|
}
|
|
109
118
|
const [, , command, ...rest] = process.argv;
|
|
@@ -367,6 +376,25 @@ try {
|
|
|
367
376
|
advanceRfcStatus(repoRoot, id, toStatus, actor, opts);
|
|
368
377
|
break;
|
|
369
378
|
}
|
|
379
|
+
case 'rfc-tasks': {
|
|
380
|
+
const positional = rest.filter((a) => !a.startsWith('--'));
|
|
381
|
+
const flags = rest.filter((a) => a.startsWith('--'));
|
|
382
|
+
const [repoRoot, rfcId] = positional;
|
|
383
|
+
if (!repoRoot || !rfcId)
|
|
384
|
+
usage('rfc-tasks <repoRoot> <rfcId> [--pretty]');
|
|
385
|
+
const pretty = flags.includes('--pretty');
|
|
386
|
+
let view;
|
|
387
|
+
try {
|
|
388
|
+
view = computeRfcTasksView(repoRoot, rfcId);
|
|
389
|
+
}
|
|
390
|
+
catch (err) {
|
|
391
|
+
process.stderr.write(`cloverleaf-cli rfc-tasks: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
392
|
+
process.exit(2);
|
|
393
|
+
}
|
|
394
|
+
process.stdout.write(pretty ? JSON.stringify(view, null, 2) : JSON.stringify(view));
|
|
395
|
+
process.stdout.write('\n');
|
|
396
|
+
break;
|
|
397
|
+
}
|
|
370
398
|
case 'load-spike': {
|
|
371
399
|
const positional = rest.filter((a) => !a.startsWith('--'));
|
|
372
400
|
const flags = rest.filter((a) => a.startsWith('--'));
|
|
@@ -701,6 +729,58 @@ try {
|
|
|
701
729
|
appendFileSync(auditPath, JSON.stringify(auditEntry) + '\n');
|
|
702
730
|
process.exit(0);
|
|
703
731
|
}
|
|
732
|
+
case 'secret-scan': {
|
|
733
|
+
const positional = rest.filter((a) => !a.startsWith('--'));
|
|
734
|
+
const branchIdx = rest.indexOf('--branch');
|
|
735
|
+
const branch = branchIdx >= 0 ? rest[branchIdx + 1] : undefined;
|
|
736
|
+
const [repoRoot] = positional;
|
|
737
|
+
if (!repoRoot || !branch)
|
|
738
|
+
usage('secret-scan <repoRoot> --branch <branch>');
|
|
739
|
+
const cfg = loadSecretPatternsConfig(repoRoot);
|
|
740
|
+
// Scan added/changed lines only (the '+' lines of the diff, minus the +++ header),
|
|
741
|
+
// so we flag what THIS task introduced, not pre-existing secrets.
|
|
742
|
+
const diff = execSync(`git -C ${repoRoot} diff --unified=0 main..${branch}`, { encoding: 'utf-8' });
|
|
743
|
+
const addedByFile = {};
|
|
744
|
+
let curFile = '';
|
|
745
|
+
for (const line of diff.split('\n')) {
|
|
746
|
+
if (line.startsWith('+++ b/')) {
|
|
747
|
+
curFile = line.slice(6);
|
|
748
|
+
addedByFile[curFile] = [];
|
|
749
|
+
}
|
|
750
|
+
else if (line.startsWith('+') && !line.startsWith('+++')) {
|
|
751
|
+
(addedByFile[curFile] ||= []).push(line.slice(1));
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
const findings = Object.entries(addedByFile).flatMap(([f, lines]) => scanSecrets(lines.join('\n'), cfg, f));
|
|
755
|
+
process.stdout.write(JSON.stringify({ findings }) + '\n');
|
|
756
|
+
break;
|
|
757
|
+
}
|
|
758
|
+
case 'classify-security': {
|
|
759
|
+
const positional = rest.filter((a) => !a.startsWith('--'));
|
|
760
|
+
const branchIdx = rest.indexOf('--branch');
|
|
761
|
+
const branch = branchIdx >= 0 ? rest[branchIdx + 1] : undefined;
|
|
762
|
+
const [repoRoot, taskId] = positional;
|
|
763
|
+
if (!repoRoot || !taskId)
|
|
764
|
+
usage('classify-security <repoRoot> <taskId> [--branch <branch>]');
|
|
765
|
+
let task;
|
|
766
|
+
try {
|
|
767
|
+
task = loadTask(repoRoot, taskId);
|
|
768
|
+
}
|
|
769
|
+
catch (err) {
|
|
770
|
+
process.stderr.write(`cloverleaf-cli classify-security: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
771
|
+
process.exit(2);
|
|
772
|
+
}
|
|
773
|
+
const declared = task.security_class === 'high' ? 'high' : 'low';
|
|
774
|
+
const cfg = loadSecurityPathsConfig(repoRoot);
|
|
775
|
+
let changed = [];
|
|
776
|
+
if (branch) {
|
|
777
|
+
const out = execSync(`git -C ${repoRoot} diff --name-only main..${branch}`, { encoding: 'utf-8' });
|
|
778
|
+
changed = out.split('\n').filter(Boolean);
|
|
779
|
+
}
|
|
780
|
+
const result = computeSecurityClassification(declared, changed, cfg);
|
|
781
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
782
|
+
break;
|
|
783
|
+
}
|
|
704
784
|
default:
|
|
705
785
|
usage(`Unknown command: ${command}`);
|
|
706
786
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { plansDir, tasksDir, rfcsDir } from './paths.mjs';
|
|
4
|
+
/**
|
|
5
|
+
* A task is "standalone" (RFC-direct) iff it has no parent (absent or null)
|
|
6
|
+
* AND it has a non-empty context.rfc.id. See
|
|
7
|
+
* docs/superpowers/specs/2026-05-12-rfc-direct-tasks-design.md §"Discriminator".
|
|
8
|
+
*/
|
|
9
|
+
export function isStandaloneTask(task) {
|
|
10
|
+
const parent = task.parent;
|
|
11
|
+
if (parent != null)
|
|
12
|
+
return false;
|
|
13
|
+
const ctx = task.context;
|
|
14
|
+
const rfc = ctx?.rfc;
|
|
15
|
+
return !!(rfc && typeof rfc.id === 'string' && rfc.id.length > 0);
|
|
16
|
+
}
|
|
17
|
+
const PLAN_INFLIGHT = new Set(['drafting', 'gate-pending', 'approved']);
|
|
18
|
+
const TASK_TERMINAL = new Set(['merged', 'rejected', 'escalated']);
|
|
19
|
+
export function computeRfcTasksView(repoRoot, rfcId) {
|
|
20
|
+
const rfcPath = join(rfcsDir(repoRoot), `${rfcId}.json`);
|
|
21
|
+
if (!existsSync(rfcPath)) {
|
|
22
|
+
throw new Error(`rfc ${rfcId} not found at ${rfcPath}`);
|
|
23
|
+
}
|
|
24
|
+
const rfc = JSON.parse(readFileSync(rfcPath, 'utf-8'));
|
|
25
|
+
// Load all plans of this RFC + their child task statuses
|
|
26
|
+
const plans = [];
|
|
27
|
+
const plansDirPath = plansDir(repoRoot);
|
|
28
|
+
if (existsSync(plansDirPath)) {
|
|
29
|
+
for (const f of readdirSync(plansDirPath)) {
|
|
30
|
+
if (!f.endsWith('.json'))
|
|
31
|
+
continue;
|
|
32
|
+
const plan = JSON.parse(readFileSync(join(plansDirPath, f), 'utf-8'));
|
|
33
|
+
if (plan.parent_rfc?.project !== rfc.project || plan.parent_rfc.id !== rfc.id)
|
|
34
|
+
continue;
|
|
35
|
+
const tasks = [];
|
|
36
|
+
for (const node of plan.task_dag?.nodes ?? []) {
|
|
37
|
+
const taskPath = join(tasksDir(repoRoot), `${node.id}.json`);
|
|
38
|
+
if (!existsSync(taskPath))
|
|
39
|
+
continue;
|
|
40
|
+
const t = JSON.parse(readFileSync(taskPath, 'utf-8'));
|
|
41
|
+
tasks.push({ id: t.id, status: t.status });
|
|
42
|
+
}
|
|
43
|
+
plans.push({ project: plan.project, id: plan.id, status: plan.status, tasks });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
plans.sort((a, b) => a.id.localeCompare(b.id));
|
|
47
|
+
// Load standalone tasks: parent absent/null AND context.rfc matches
|
|
48
|
+
const standalone = [];
|
|
49
|
+
const tasksDirPath = tasksDir(repoRoot);
|
|
50
|
+
if (existsSync(tasksDirPath)) {
|
|
51
|
+
for (const f of readdirSync(tasksDirPath)) {
|
|
52
|
+
if (!f.endsWith('.json'))
|
|
53
|
+
continue;
|
|
54
|
+
const t = JSON.parse(readFileSync(join(tasksDirPath, f), 'utf-8'));
|
|
55
|
+
if (!isStandaloneTask(t))
|
|
56
|
+
continue;
|
|
57
|
+
const ctxRfc = t.context.rfc;
|
|
58
|
+
if (ctxRfc.project !== rfc.project || ctxRfc.id !== rfc.id)
|
|
59
|
+
continue;
|
|
60
|
+
standalone.push({ id: t.id, status: t.status });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
standalone.sort((a, b) => a.id.localeCompare(b.id));
|
|
64
|
+
const inflight_plans = plans.filter(p => PLAN_INFLIGHT.has(p.status)).length;
|
|
65
|
+
const inflight_standalone = standalone.filter(t => !TASK_TERMINAL.has(t.status)).length;
|
|
66
|
+
const delivered_plans = plans.filter(p => p.status === 'completed').length;
|
|
67
|
+
const delivered_standalone = standalone.filter(t => t.status === 'merged').length;
|
|
68
|
+
const can_auto_advance_rfc = rfc.status === 'approved' &&
|
|
69
|
+
inflight_plans + inflight_standalone === 0 &&
|
|
70
|
+
delivered_plans + delivered_standalone > 0;
|
|
71
|
+
return {
|
|
72
|
+
rfc: { project: rfc.project, id: rfc.id, status: rfc.status },
|
|
73
|
+
plans,
|
|
74
|
+
standalone_tasks: standalone,
|
|
75
|
+
summary: {
|
|
76
|
+
inflight_plans,
|
|
77
|
+
inflight_standalone,
|
|
78
|
+
delivered_plans,
|
|
79
|
+
delivered_standalone,
|
|
80
|
+
can_auto_advance_rfc,
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
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', 'security-paths.json');
|
|
6
|
+
function globToRegex(pattern) {
|
|
7
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
8
|
+
const regex = escaped
|
|
9
|
+
.replace(/\*\*/g, '\u0000')
|
|
10
|
+
.replace(/\*/g, '[^/]*')
|
|
11
|
+
.replace(/\u0000/g, '.*');
|
|
12
|
+
return new RegExp(`^${regex}$`);
|
|
13
|
+
}
|
|
14
|
+
function normalize(doc) {
|
|
15
|
+
return {
|
|
16
|
+
path_patterns: Array.isArray(doc.path_patterns) ? doc.path_patterns.filter((p) => typeof p === 'string') : [],
|
|
17
|
+
keyword_patterns: Array.isArray(doc.keyword_patterns) ? doc.keyword_patterns.filter((k) => typeof k === 'string') : [],
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function loadDefaultConfig() {
|
|
21
|
+
if (!existsSync(DEFAULT_CONFIG)) {
|
|
22
|
+
throw new Error(`security-paths config not found at ${DEFAULT_CONFIG}`);
|
|
23
|
+
}
|
|
24
|
+
return normalize(JSON.parse(readFileSync(DEFAULT_CONFIG, 'utf-8')));
|
|
25
|
+
}
|
|
26
|
+
export function loadSecurityPathsConfig(repoRoot) {
|
|
27
|
+
const consumerPath = join(repoRoot, '.cloverleaf', 'config', 'security-paths.json');
|
|
28
|
+
if (existsSync(consumerPath)) {
|
|
29
|
+
try {
|
|
30
|
+
return normalize(JSON.parse(readFileSync(consumerPath, 'utf-8')));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// fall through
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return loadDefaultConfig();
|
|
37
|
+
}
|
|
38
|
+
export function matchesSensitivePath(file, cfg) {
|
|
39
|
+
return cfg.path_patterns.some((p) => globToRegex(p).test(file));
|
|
40
|
+
}
|
|
41
|
+
export function matchesSensitiveKeyword(text, cfg) {
|
|
42
|
+
return cfg.keyword_patterns.some((k) => {
|
|
43
|
+
try {
|
|
44
|
+
return new RegExp(k, 'i').test(text);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Compute effective security classification. `declared` is the task's
|
|
53
|
+
* security_class (default low). `changedFiles` (e.g. from `git diff --name-only`)
|
|
54
|
+
* are matched against path_patterns for diff-detection.
|
|
55
|
+
*/
|
|
56
|
+
export function computeSecurityClassification(declared, changedFiles, cfg) {
|
|
57
|
+
const matched = changedFiles.filter((f) => matchesSensitivePath(f, cfg));
|
|
58
|
+
const diff_detected = matched.length > 0;
|
|
59
|
+
const effective = declared === 'high' || diff_detected ? 'high' : 'low';
|
|
60
|
+
return { declared, diff_detected, effective, matched_paths: matched };
|
|
61
|
+
}
|
package/lib/cli.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* load-rfc <repoRoot> <id>
|
|
21
21
|
* save-rfc <repoRoot> <filePath>
|
|
22
22
|
* advance-rfc <repoRoot> <id> <toStatus> <agent|human> [gate]
|
|
23
|
+
* rfc-tasks <repoRoot> <rfcId> [--pretty]
|
|
23
24
|
* load-spike <repoRoot> <id>
|
|
24
25
|
* save-spike <repoRoot> <filePath>
|
|
25
26
|
* advance-spike <repoRoot> <id> <toStatus> <agent|human>
|
|
@@ -37,6 +38,8 @@
|
|
|
37
38
|
* walker-default-concurrency [--explain]
|
|
38
39
|
* check-scope <repoRoot> <taskId> --branch <branchName>
|
|
39
40
|
* extend-scope <repoRoot> <taskId> --add <file>... --reason <text>
|
|
41
|
+
* secret-scan <repoRoot> --branch <branch>
|
|
42
|
+
* classify-security <repoRoot> <taskId> [--branch <branch>]
|
|
40
43
|
*/
|
|
41
44
|
|
|
42
45
|
import { readFileSync, mkdirSync, copyFileSync, appendFileSync, existsSync } from 'node:fs';
|
|
@@ -66,6 +69,9 @@ import { readWalkState, writeWalkState, walkStatePath } from './walk-state.js';
|
|
|
66
69
|
import { loadWalkerConfig } from './walker-config.js';
|
|
67
70
|
import { classifyFiles } from './scope-check.js';
|
|
68
71
|
import type { SiblingScope } from './scope-check.js';
|
|
72
|
+
import { computeRfcTasksView, type RfcTasksView } from './rfc-tasks.js';
|
|
73
|
+
import { loadSecretPatternsConfig, scanSecrets } from './secret-scan.js';
|
|
74
|
+
import { loadSecurityPathsConfig, computeSecurityClassification } from './security-classify.js';
|
|
69
75
|
|
|
70
76
|
function die(msg: string, code = 1): never {
|
|
71
77
|
process.stderr.write(msg + '\n');
|
|
@@ -92,6 +98,7 @@ function usage(msg?: string): never {
|
|
|
92
98
|
' load-rfc <repoRoot> <id>\n' +
|
|
93
99
|
' save-rfc <repoRoot> <filePath>\n' +
|
|
94
100
|
' advance-rfc <repoRoot> <id> <toStatus> <agent|human> [gate]\n' +
|
|
101
|
+
' rfc-tasks <repoRoot> <rfcId> [--pretty]\n' +
|
|
95
102
|
' load-spike <repoRoot> <id>\n' +
|
|
96
103
|
' save-spike <repoRoot> <filePath>\n' +
|
|
97
104
|
' advance-spike <repoRoot> <id> <toStatus> <agent|human>\n' +
|
|
@@ -108,7 +115,9 @@ function usage(msg?: string): never {
|
|
|
108
115
|
' walk-state-write <repoRoot> <walkStateJsonPath>\n' +
|
|
109
116
|
' walker-default-concurrency [--explain]\n' +
|
|
110
117
|
' check-scope <repoRoot> <taskId> --branch <branchName>\n' +
|
|
111
|
-
' extend-scope <repoRoot> <taskId> --add <file>... --reason <text>\n'
|
|
118
|
+
' extend-scope <repoRoot> <taskId> --add <file>... --reason <text>\n' +
|
|
119
|
+
' secret-scan <repoRoot> --branch <branch>\n' +
|
|
120
|
+
' classify-security <repoRoot> <taskId> [--branch <branch>]\n'
|
|
112
121
|
);
|
|
113
122
|
process.exit(2);
|
|
114
123
|
}
|
|
@@ -383,6 +392,24 @@ try {
|
|
|
383
392
|
break;
|
|
384
393
|
}
|
|
385
394
|
|
|
395
|
+
case 'rfc-tasks': {
|
|
396
|
+
const positional = rest.filter((a) => !a.startsWith('--'));
|
|
397
|
+
const flags = rest.filter((a) => a.startsWith('--'));
|
|
398
|
+
const [repoRoot, rfcId] = positional;
|
|
399
|
+
if (!repoRoot || !rfcId) usage('rfc-tasks <repoRoot> <rfcId> [--pretty]');
|
|
400
|
+
const pretty = flags.includes('--pretty');
|
|
401
|
+
let view: RfcTasksView;
|
|
402
|
+
try {
|
|
403
|
+
view = computeRfcTasksView(repoRoot, rfcId);
|
|
404
|
+
} catch (err) {
|
|
405
|
+
process.stderr.write(`cloverleaf-cli rfc-tasks: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
406
|
+
process.exit(2);
|
|
407
|
+
}
|
|
408
|
+
process.stdout.write(pretty ? JSON.stringify(view, null, 2) : JSON.stringify(view));
|
|
409
|
+
process.stdout.write('\n');
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
|
|
386
413
|
case 'load-spike': {
|
|
387
414
|
const positional = rest.filter((a) => !a.startsWith('--'));
|
|
388
415
|
const flags = rest.filter((a) => a.startsWith('--'));
|
|
@@ -723,6 +750,52 @@ try {
|
|
|
723
750
|
process.exit(0);
|
|
724
751
|
}
|
|
725
752
|
|
|
753
|
+
case 'secret-scan': {
|
|
754
|
+
const positional = rest.filter((a) => !a.startsWith('--'));
|
|
755
|
+
const branchIdx = rest.indexOf('--branch');
|
|
756
|
+
const branch = branchIdx >= 0 ? rest[branchIdx + 1] : undefined;
|
|
757
|
+
const [repoRoot] = positional;
|
|
758
|
+
if (!repoRoot || !branch) usage('secret-scan <repoRoot> --branch <branch>');
|
|
759
|
+
const cfg = loadSecretPatternsConfig(repoRoot);
|
|
760
|
+
// Scan added/changed lines only (the '+' lines of the diff, minus the +++ header),
|
|
761
|
+
// so we flag what THIS task introduced, not pre-existing secrets.
|
|
762
|
+
const diff = execSync(`git -C ${repoRoot} diff --unified=0 main..${branch}`, { encoding: 'utf-8' });
|
|
763
|
+
const addedByFile: Record<string, string[]> = {};
|
|
764
|
+
let curFile = '';
|
|
765
|
+
for (const line of diff.split('\n')) {
|
|
766
|
+
if (line.startsWith('+++ b/')) { curFile = line.slice(6); addedByFile[curFile] = []; }
|
|
767
|
+
else if (line.startsWith('+') && !line.startsWith('+++')) { (addedByFile[curFile] ||= []).push(line.slice(1)); }
|
|
768
|
+
}
|
|
769
|
+
const findings = Object.entries(addedByFile).flatMap(([f, lines]) => scanSecrets(lines.join('\n'), cfg, f));
|
|
770
|
+
process.stdout.write(JSON.stringify({ findings }) + '\n');
|
|
771
|
+
break;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
case 'classify-security': {
|
|
775
|
+
const positional = rest.filter((a) => !a.startsWith('--'));
|
|
776
|
+
const branchIdx = rest.indexOf('--branch');
|
|
777
|
+
const branch = branchIdx >= 0 ? rest[branchIdx + 1] : undefined;
|
|
778
|
+
const [repoRoot, taskId] = positional;
|
|
779
|
+
if (!repoRoot || !taskId) usage('classify-security <repoRoot> <taskId> [--branch <branch>]');
|
|
780
|
+
let task;
|
|
781
|
+
try {
|
|
782
|
+
task = loadTask(repoRoot, taskId);
|
|
783
|
+
} catch (err) {
|
|
784
|
+
process.stderr.write(`cloverleaf-cli classify-security: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
785
|
+
process.exit(2);
|
|
786
|
+
}
|
|
787
|
+
const declared = (task as Record<string, unknown>).security_class === 'high' ? 'high' : 'low';
|
|
788
|
+
const cfg = loadSecurityPathsConfig(repoRoot);
|
|
789
|
+
let changed: string[] = [];
|
|
790
|
+
if (branch) {
|
|
791
|
+
const out = execSync(`git -C ${repoRoot} diff --name-only main..${branch}`, { encoding: 'utf-8' });
|
|
792
|
+
changed = out.split('\n').filter(Boolean);
|
|
793
|
+
}
|
|
794
|
+
const result = computeSecurityClassification(declared, changed, cfg);
|
|
795
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
796
|
+
break;
|
|
797
|
+
}
|
|
798
|
+
|
|
726
799
|
default:
|
|
727
800
|
usage(`Unknown command: ${command}`);
|
|
728
801
|
}
|
package/lib/rfc-tasks.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { plansDir, tasksDir, rfcsDir } from './paths.js';
|
|
4
|
+
import type { TaskDoc } from './task.js';
|
|
5
|
+
import type { PlanDoc } from './plan.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A task is "standalone" (RFC-direct) iff it has no parent (absent or null)
|
|
9
|
+
* AND it has a non-empty context.rfc.id. See
|
|
10
|
+
* docs/superpowers/specs/2026-05-12-rfc-direct-tasks-design.md §"Discriminator".
|
|
11
|
+
*/
|
|
12
|
+
export function isStandaloneTask(task: TaskDoc): boolean {
|
|
13
|
+
const parent = (task as Record<string, unknown>).parent;
|
|
14
|
+
if (parent != null) return false;
|
|
15
|
+
const ctx = task.context as Record<string, unknown> | undefined;
|
|
16
|
+
const rfc = ctx?.rfc as { project?: string; id?: string } | undefined;
|
|
17
|
+
return !!(rfc && typeof rfc.id === 'string' && rfc.id.length > 0);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface RfcSummary {
|
|
21
|
+
inflight_plans: number;
|
|
22
|
+
inflight_standalone: number;
|
|
23
|
+
delivered_plans: number;
|
|
24
|
+
delivered_standalone: number;
|
|
25
|
+
can_auto_advance_rfc: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RfcTasksView {
|
|
29
|
+
rfc: { project: string; id: string; status: string };
|
|
30
|
+
plans: Array<{
|
|
31
|
+
project: string;
|
|
32
|
+
id: string;
|
|
33
|
+
status: string;
|
|
34
|
+
tasks: Array<{ id: string; status: string }>;
|
|
35
|
+
}>;
|
|
36
|
+
standalone_tasks: Array<{ id: string; status: string }>;
|
|
37
|
+
summary: RfcSummary;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const PLAN_INFLIGHT = new Set(['drafting', 'gate-pending', 'approved']);
|
|
41
|
+
const TASK_TERMINAL = new Set(['merged', 'rejected', 'escalated']);
|
|
42
|
+
|
|
43
|
+
export function computeRfcTasksView(repoRoot: string, rfcId: string): RfcTasksView {
|
|
44
|
+
const rfcPath = join(rfcsDir(repoRoot), `${rfcId}.json`);
|
|
45
|
+
if (!existsSync(rfcPath)) {
|
|
46
|
+
throw new Error(`rfc ${rfcId} not found at ${rfcPath}`);
|
|
47
|
+
}
|
|
48
|
+
const rfc = JSON.parse(readFileSync(rfcPath, 'utf-8')) as {
|
|
49
|
+
project: string; id: string; status: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Load all plans of this RFC + their child task statuses
|
|
53
|
+
const plans: RfcTasksView['plans'] = [];
|
|
54
|
+
const plansDirPath = plansDir(repoRoot);
|
|
55
|
+
if (existsSync(plansDirPath)) {
|
|
56
|
+
for (const f of readdirSync(plansDirPath)) {
|
|
57
|
+
if (!f.endsWith('.json')) continue;
|
|
58
|
+
const plan = JSON.parse(readFileSync(join(plansDirPath, f), 'utf-8')) as PlanDoc;
|
|
59
|
+
if (plan.parent_rfc?.project !== rfc.project || plan.parent_rfc.id !== rfc.id) continue;
|
|
60
|
+
|
|
61
|
+
const tasks: Array<{ id: string; status: string }> = [];
|
|
62
|
+
for (const node of plan.task_dag?.nodes ?? []) {
|
|
63
|
+
const taskPath = join(tasksDir(repoRoot), `${node.id}.json`);
|
|
64
|
+
if (!existsSync(taskPath)) continue;
|
|
65
|
+
const t = JSON.parse(readFileSync(taskPath, 'utf-8')) as { id: string; status: string };
|
|
66
|
+
tasks.push({ id: t.id, status: t.status });
|
|
67
|
+
}
|
|
68
|
+
plans.push({ project: plan.project, id: plan.id, status: plan.status, tasks });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
plans.sort((a, b) => a.id.localeCompare(b.id));
|
|
72
|
+
|
|
73
|
+
// Load standalone tasks: parent absent/null AND context.rfc matches
|
|
74
|
+
const standalone: Array<{ id: string; status: string }> = [];
|
|
75
|
+
const tasksDirPath = tasksDir(repoRoot);
|
|
76
|
+
if (existsSync(tasksDirPath)) {
|
|
77
|
+
for (const f of readdirSync(tasksDirPath)) {
|
|
78
|
+
if (!f.endsWith('.json')) continue;
|
|
79
|
+
const t = JSON.parse(readFileSync(join(tasksDirPath, f), 'utf-8')) as TaskDoc;
|
|
80
|
+
if (!isStandaloneTask(t)) continue;
|
|
81
|
+
const ctxRfc = (t.context as Record<string, unknown>).rfc as {
|
|
82
|
+
project?: string; id?: string;
|
|
83
|
+
};
|
|
84
|
+
if (ctxRfc.project !== rfc.project || ctxRfc.id !== rfc.id) continue;
|
|
85
|
+
standalone.push({ id: t.id, status: t.status });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
standalone.sort((a, b) => a.id.localeCompare(b.id));
|
|
89
|
+
|
|
90
|
+
const inflight_plans = plans.filter(p => PLAN_INFLIGHT.has(p.status)).length;
|
|
91
|
+
const inflight_standalone = standalone.filter(t => !TASK_TERMINAL.has(t.status)).length;
|
|
92
|
+
const delivered_plans = plans.filter(p => p.status === 'completed').length;
|
|
93
|
+
const delivered_standalone = standalone.filter(t => t.status === 'merged').length;
|
|
94
|
+
|
|
95
|
+
const can_auto_advance_rfc =
|
|
96
|
+
rfc.status === 'approved' &&
|
|
97
|
+
inflight_plans + inflight_standalone === 0 &&
|
|
98
|
+
delivered_plans + delivered_standalone > 0;
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
rfc: { project: rfc.project, id: rfc.id, status: rfc.status },
|
|
102
|
+
plans,
|
|
103
|
+
standalone_tasks: standalone,
|
|
104
|
+
summary: {
|
|
105
|
+
inflight_plans,
|
|
106
|
+
inflight_standalone,
|
|
107
|
+
delivered_plans,
|
|
108
|
+
delivered_standalone,
|
|
109
|
+
can_auto_advance_rfc,
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
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', 'security-paths.json');
|
|
7
|
+
|
|
8
|
+
export interface SecurityPathsConfig { path_patterns: string[]; keyword_patterns: string[]; }
|
|
9
|
+
|
|
10
|
+
function globToRegex(pattern: string): RegExp {
|
|
11
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
12
|
+
const regex = escaped
|
|
13
|
+
.replace(/\*\*/g, '\u0000')
|
|
14
|
+
.replace(/\*/g, '[^/]*')
|
|
15
|
+
.replace(/\u0000/g, '.*');
|
|
16
|
+
return new RegExp(`^${regex}$`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function normalize(doc: Partial<SecurityPathsConfig>): SecurityPathsConfig {
|
|
20
|
+
return {
|
|
21
|
+
path_patterns: Array.isArray(doc.path_patterns) ? doc.path_patterns.filter((p) => typeof p === 'string') : [],
|
|
22
|
+
keyword_patterns: Array.isArray(doc.keyword_patterns) ? doc.keyword_patterns.filter((k) => typeof k === 'string') : [],
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function loadDefaultConfig(): SecurityPathsConfig {
|
|
27
|
+
if (!existsSync(DEFAULT_CONFIG)) {
|
|
28
|
+
throw new Error(`security-paths config not found at ${DEFAULT_CONFIG}`);
|
|
29
|
+
}
|
|
30
|
+
return normalize(JSON.parse(readFileSync(DEFAULT_CONFIG, 'utf-8')) as Partial<SecurityPathsConfig>);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function loadSecurityPathsConfig(repoRoot: string): SecurityPathsConfig {
|
|
34
|
+
const consumerPath = join(repoRoot, '.cloverleaf', 'config', 'security-paths.json');
|
|
35
|
+
if (existsSync(consumerPath)) {
|
|
36
|
+
try {
|
|
37
|
+
return normalize(JSON.parse(readFileSync(consumerPath, 'utf-8')) as Partial<SecurityPathsConfig>);
|
|
38
|
+
} catch {
|
|
39
|
+
// fall through
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return loadDefaultConfig();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function matchesSensitivePath(file: string, cfg: SecurityPathsConfig): boolean {
|
|
46
|
+
return cfg.path_patterns.some((p) => globToRegex(p).test(file));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function matchesSensitiveKeyword(text: string, cfg: SecurityPathsConfig): boolean {
|
|
50
|
+
return cfg.keyword_patterns.some((k) => {
|
|
51
|
+
try {
|
|
52
|
+
return new RegExp(k, 'i').test(text);
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface SecurityClassification {
|
|
60
|
+
declared: 'low' | 'high';
|
|
61
|
+
diff_detected: boolean;
|
|
62
|
+
effective: 'low' | 'high';
|
|
63
|
+
matched_paths: string[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Compute effective security classification. `declared` is the task's
|
|
68
|
+
* security_class (default low). `changedFiles` (e.g. from `git diff --name-only`)
|
|
69
|
+
* are matched against path_patterns for diff-detection.
|
|
70
|
+
*/
|
|
71
|
+
export function computeSecurityClassification(
|
|
72
|
+
declared: 'low' | 'high',
|
|
73
|
+
changedFiles: string[],
|
|
74
|
+
cfg: SecurityPathsConfig,
|
|
75
|
+
): SecurityClassification {
|
|
76
|
+
const matched = changedFiles.filter((f) => matchesSensitivePath(f, cfg));
|
|
77
|
+
const diff_detected = matched.length > 0;
|
|
78
|
+
const effective = declared === 'high' || diff_detected ? 'high' : 'low';
|
|
79
|
+
return { declared, diff_detected, effective, matched_paths: matched };
|
|
80
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloverleaf/reference-impl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
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",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"prepublishOnly": "node scripts/check-standard-prepped.mjs && npm test && npm run build"
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@cloverleaf/standard": "^0.
|
|
52
|
+
"@cloverleaf/standard": "^0.7.0",
|
|
53
53
|
"ajv": "^8.17.1",
|
|
54
54
|
"ajv-formats": "^3.0.1",
|
|
55
55
|
"axe-core": "^4.10.0",
|
|
@@ -0,0 +1,43 @@
|
|
|
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
|
+
## Output
|
|
28
|
+
|
|
29
|
+
Return ONLY a feedback envelope JSON:
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{ "verdict": "pass" | "bounce" | "escalate",
|
|
33
|
+
"summary": "<one-line overall assessment>",
|
|
34
|
+
"findings": [
|
|
35
|
+
{ "severity": "info|warning|error|blocker",
|
|
36
|
+
"message": "<what + why it matters>",
|
|
37
|
+
"location": { "file": "<path>", "line": <n> },
|
|
38
|
+
"suggestion": "<concrete fix>",
|
|
39
|
+
"rule": "<short-id e.g. injection.sql>" }
|
|
40
|
+
] }
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
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
|
|
|
@@ -75,13 +76,18 @@ The user has invoked this skill with a brief. Your job: turn the brief into a st
|
|
|
75
76
|
## Rules
|
|
76
77
|
|
|
77
78
|
- Do not guess at acceptance criteria. If the brief is too vague (e.g., "make it faster" with no target), ask the user a clarifying question before writing the file.
|
|
78
|
-
- **`--rfc=<ID>` flag:** When the brief includes `--rfc=<RFC-ID>`, the task's `context.rfc` is populated from the on-disk RFC document (see step 4).
|
|
79
|
+
- **`--rfc=<ID>` flag:** When the brief includes `--rfc=<RFC-ID>`, the task's `context.rfc` is populated from the on-disk RFC document (see step 4). This is the canonical way to scaffold an **RFC-direct task** (no Plan parent, no `task_batch_gate`) — used for hotfixes after a Plan has delivered, or for incremental RFC progress without forming a Plan. The walker's RFC auto-advance treats RFC-direct tasks as first-class: an in-flight one blocks RFC completion; a merged one counts toward delivery. See `reference-impl/README.md` § "Plans vs RFC-direct tasks" for the full pattern docs and when to pick this over `/cloverleaf-discover`. If `--rfc` is omitted, `context` is left empty.
|
|
79
80
|
- **risk_class inference:** `risk_class` determines the Delivery pipeline (`"low"` → fast lane; `"high"` → full pipeline). Rules:
|
|
80
81
|
1. If the user passed `--risk=high` or `--risk=low` as a flag on the skill invocation, honor it.
|
|
81
82
|
2. Otherwise, set `risk_class: "high"` when the brief OR any acceptance criterion matches (case-insensitive) any of these keywords:
|
|
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,31 @@ 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), write back: load the task, set `security_class: "high"`, save it, then commit `cloverleaf: <TASK-ID> security_class → high (diff-detected)`.
|
|
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
|
+
|
|
24
45
|
## Steps
|
|
25
46
|
|
|
26
47
|
1. Capture TASK-ID.
|
|
@@ -33,7 +54,7 @@ These counters live in-session (not persisted). Rerunning `/cloverleaf-run` rese
|
|
|
33
54
|
|
|
34
55
|
### 4. Fast Lane
|
|
35
56
|
|
|
36
|
-
Initialize `reviewer_bounces = 0`.
|
|
57
|
+
Initialize `reviewer_bounces = 0`, `security_bounces = 0`.
|
|
37
58
|
|
|
38
59
|
Loop:
|
|
39
60
|
a. Inline `/cloverleaf-implement <TASK-ID>` steps.
|
|
@@ -42,11 +63,11 @@ Loop:
|
|
|
42
63
|
d. If `status === "implementing"`: Reviewer bounced. `reviewer_bounces += 1`. If `reviewer_bounces >= MAX_REVIEWER_BOUNCES`, escalate (section 6). Else continue loop.
|
|
43
64
|
e. Else: unexpected state. Report and stop.
|
|
44
65
|
|
|
45
|
-
After loop: inline `/cloverleaf-merge <TASK-ID>`.
|
|
66
|
+
After loop (status `automated-gates`): run the **Security gate (both lanes)** (above). Then inline `/cloverleaf-merge <TASK-ID>`.
|
|
46
67
|
|
|
47
68
|
### 5. Full Pipeline
|
|
48
69
|
|
|
49
|
-
Initialize `reviewer_bounces = 0`, `ui_reviewer_bounces = 0`, `qa_bounces = 0`.
|
|
70
|
+
Initialize `reviewer_bounces = 0`, `ui_reviewer_bounces = 0`, `qa_bounces = 0`, `security_bounces = 0`.
|
|
50
71
|
|
|
51
72
|
5.1. **Implementer → Documenter → Reviewer loop:**
|
|
52
73
|
|
|
@@ -58,6 +79,8 @@ Loop:
|
|
|
58
79
|
e. If `status === "implementing"`: Reviewer bounced. `reviewer_bounces += 1`. If `reviewer_bounces >= MAX_REVIEWER_BOUNCES`, escalate. Else continue loop.
|
|
59
80
|
f. Else: unexpected. Report and stop.
|
|
60
81
|
|
|
82
|
+
**Security gate.** Run the **Security gate (both lanes)** (above) now, before UI-path detection. Then continue to 5.2.
|
|
83
|
+
|
|
61
84
|
5.2. **UI-path detection and conditional UI Review:**
|
|
62
85
|
|
|
63
86
|
```bash
|
|
@@ -88,7 +111,7 @@ Loop:
|
|
|
88
111
|
|
|
89
112
|
- `cloverleaf-cli advance-status <repo_root> <TASK-ID> escalated agent`
|
|
90
113
|
- 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>."
|
|
114
|
+
- 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
115
|
|
|
93
116
|
## Rules
|
|
94
117
|
|
|
@@ -292,36 +292,29 @@ description: Autonomous DAG walker for Cloverleaf Plans. Given a PLAN-ID in stat
|
|
|
292
292
|
|
|
293
293
|
If not every task is merged (some escalated or awaiting), do NOT advance the Plan. Print the partial-completion report from the bullets above.
|
|
294
294
|
|
|
295
|
-
**RFC auto-advance (Standard 0.6.0+).** Immediately after the Plan-advance commit lands,
|
|
295
|
+
**RFC auto-advance (Standard 0.6.0+ + rfc-tasks 0.7.5+).** Immediately after the Plan-advance commit lands, ask `cloverleaf-cli rfc-tasks` whether the parent RFC can also advance to `completed`. The CLI considers BOTH sibling Plans AND RFC-direct (standalone) tasks under the same `parent_rfc` — an in-flight standalone task blocks the advance, a merged standalone task counts toward the at-least-one-delivered requirement.
|
|
296
296
|
|
|
297
297
|
```bash
|
|
298
|
-
PARENT_RFC_PROJECT=$(jq -r '.parent_rfc.project' <repo_root>/.cloverleaf/plans/<PLAN-ID>.json)
|
|
299
298
|
PARENT_RFC_ID=$(jq -r '.parent_rfc.id' <repo_root>/.cloverleaf/plans/<PLAN-ID>.json)
|
|
300
|
-
RFC_STATUS=$(jq -r '.status' <repo_root>/.cloverleaf/rfcs/"$PARENT_RFC_ID".json)
|
|
301
299
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
'[.[] | select(.parent_rfc.project == $proj and .parent_rfc.id == $id and (.status == "drafting" or .status == "gate-pending" or .status == "approved"))] | length' \
|
|
305
|
-
<repo_root>/.cloverleaf/plans/*.json)
|
|
300
|
+
RFC_VIEW=$(cloverleaf-cli rfc-tasks <repo_root> "$PARENT_RFC_ID")
|
|
301
|
+
CAN_ADVANCE=$(echo "$RFC_VIEW" | jq -r '.summary.can_auto_advance_rfc')
|
|
306
302
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
'[.[] | select(.parent_rfc.project == $proj and .parent_rfc.id == $id and .status == "completed")] | length' \
|
|
310
|
-
<repo_root>/.cloverleaf/plans/*.json)
|
|
311
|
-
|
|
312
|
-
if [ "$RFC_STATUS" = "approved" ] && [ "$INFLIGHT" = "0" ] && [ "$COMPLETED" != "0" ]; then
|
|
303
|
+
if [ "$CAN_ADVANCE" = "true" ]; then
|
|
304
|
+
DELIVERED=$(echo "$RFC_VIEW" | jq -r '"\(.summary.delivered_plans) plans, \(.summary.delivered_standalone) standalone tasks"')
|
|
313
305
|
cloverleaf-cli advance-rfc <repo_root> "$PARENT_RFC_ID" completed agent
|
|
314
306
|
git -C <repo_root> add .cloverleaf/rfcs/"$PARENT_RFC_ID".json .cloverleaf/events/
|
|
315
|
-
git -C <repo_root> commit -m "cloverleaf: rfc $PARENT_RFC_ID completed ($
|
|
307
|
+
git -C <repo_root> commit -m "cloverleaf: rfc $PARENT_RFC_ID completed ($DELIVERED delivered, 0 in-flight)"
|
|
316
308
|
echo "✓ RFC $PARENT_RFC_ID advanced approved → completed."
|
|
317
309
|
fi
|
|
318
310
|
```
|
|
319
311
|
|
|
320
|
-
|
|
321
|
-
-
|
|
322
|
-
-
|
|
323
|
-
-
|
|
324
|
-
|
|
312
|
+
Skip conditions encoded in `can_auto_advance_rfc`:
|
|
313
|
+
- RFC is not at `approved` (already terminal, abandoned, or somehow still pre-approval).
|
|
314
|
+
- Any sibling Plan is in-flight (`drafting`, `gate-pending`, or `approved`) OR any standalone task is in a non-terminal state.
|
|
315
|
+
- No Plan reached `completed` AND no standalone task reached `merged` — the RFC has nothing delivered (e.g. all child Plans were rejected); operator must decide whether to abandon or re-decompose.
|
|
316
|
+
|
|
317
|
+
Idempotent: the `rfc.status === "approved"` guard inside `can_auto_advance_rfc` makes re-runs of `/cloverleaf-run-plan` against a fully-merged plan safe.
|
|
325
318
|
|
|
326
319
|
## Next steps (release publishing)
|
|
327
320
|
|
|
@@ -399,3 +392,7 @@ The concrete policy JSON is the same one used during the CLV-16..CLV-20 dogfood
|
|
|
399
392
|
## Notes
|
|
400
393
|
|
|
401
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
|
+
|
|
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,71 @@
|
|
|
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 advance-status <repo_root> <TASK-ID> automated-gates agent
|
|
43
|
+
git -C <repo_root> add .cloverleaf/ && git -C <repo_root> commit -m "cloverleaf: <TASK-ID> security review passed → automated-gates"
|
|
44
|
+
```
|
|
45
|
+
Report: "✓ Security review passed. State → automated-gates."
|
|
46
|
+
|
|
47
|
+
**Bounce:**
|
|
48
|
+
```bash
|
|
49
|
+
echo '<merged-envelope-json>' > /tmp/cloverleaf-fb-s.json
|
|
50
|
+
cloverleaf-cli write-feedback <repo_root> <TASK-ID> /tmp/cloverleaf-fb-s.json
|
|
51
|
+
git -C <repo_root> add .cloverleaf/feedback/ && git -C <repo_root> commit -m "cloverleaf: <TASK-ID> security review feedback"
|
|
52
|
+
cloverleaf-cli advance-status <repo_root> <TASK-ID> implementing agent
|
|
53
|
+
git -C <repo_root> add .cloverleaf/ && git -C <repo_root> commit -m "cloverleaf: <TASK-ID> security review bounced → implementing"
|
|
54
|
+
```
|
|
55
|
+
Report: "✗ Security review bounced. Findings: <summarize by severity>. State → implementing."
|
|
56
|
+
|
|
57
|
+
**Escalate (blocker found):**
|
|
58
|
+
```bash
|
|
59
|
+
echo '<merged-envelope-json>' > /tmp/cloverleaf-fb-s.json
|
|
60
|
+
cloverleaf-cli write-feedback <repo_root> <TASK-ID> /tmp/cloverleaf-fb-s.json
|
|
61
|
+
git -C <repo_root> add .cloverleaf/feedback/ && git -C <repo_root> commit -m "cloverleaf: <TASK-ID> security review feedback"
|
|
62
|
+
cloverleaf-cli advance-status <repo_root> <TASK-ID> escalated agent
|
|
63
|
+
git -C <repo_root> add .cloverleaf/ && git -C <repo_root> commit -m "cloverleaf: <TASK-ID> security review escalated (blocker finding)"
|
|
64
|
+
```
|
|
65
|
+
Report: "⚠ Security review found a BLOCKER. State → escalated. A human must review `.cloverleaf/feedback/` before this can proceed."
|
|
66
|
+
|
|
67
|
+
## Rules
|
|
68
|
+
|
|
69
|
+
- Never push. Read-only on source — the security reviewer does not modify code.
|
|
70
|
+
- A `blocker` (e.g. a leaked credential) ALWAYS escalates to a human; never let the bounce loop silently "fix" it.
|
|
71
|
+
- On illegal state transition, report and stop without partial commits.
|