@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.
@@ -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.7.4",
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.7.4
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
  }
@@ -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.7.4",
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.6.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). Use this when scaffolding a standalone task linked to an RFC without a Plan parent — e.g., a hotfix-task pattern off an open RFC, as practiced by claw-crypto's CC-43/44 and CC-045..052. If `--rfc` is omitted, `context` is left empty; the task can be attached to a Plan later via Plan formation, or `context.rfc` can be edited in by hand.
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, check whether the parent RFC can also advance to `completed`. Read the just-completed plan's `parent_rfc` (project + id), then scan all sibling plans of that RFC:
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
- # Count sibling Plans still in-flight (drafting, gate-pending, or approved)
303
- INFLIGHT=$(jq -s --arg proj "$PARENT_RFC_PROJECT" --arg id "$PARENT_RFC_ID" \
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
- # Count sibling Plans in completed (need at least one to advance)
308
- COMPLETED=$(jq -s --arg proj "$PARENT_RFC_PROJECT" --arg id "$PARENT_RFC_ID" \
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 ($COMPLETED sibling plans completed, 0 in-flight)"
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
- Auto-advance rules in plain language:
321
- - Skip entirely if the RFC's `status` is not `approved` (already terminal, already abandoned, or somehow still pre-approval — leave it).
322
- - Skip if any sibling Plan is still in-flight (`drafting`, `gate-pending`, or `approved`) more work is pending on this RFC.
323
- - Skip if no sibling Plan is `completed` (all `rejected`) — the RFC's decomposition was uniformly rejected; operator must decide whether to abandon or re-decompose.
324
- - Otherwise advance the RFC to `completed`. Idempotent: the status guard makes re-runs safe.
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.