@cloverleaf/reference-impl 0.7.5 → 0.8.1

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