@cloverleaf/reference-impl 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -211,6 +211,8 @@ The **Security Reviewer** (8th agent) runs when a task's effective `security_cla
211
211
 
212
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
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
+
214
216
  ## License
215
217
 
216
218
  MIT — see [../LICENSE](../LICENSE).
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.8.0
1
+ 0.8.1
package/dist/cli.mjs CHANGED
@@ -40,6 +40,7 @@
40
40
  * extend-scope <repoRoot> <taskId> --add <file>... --reason <text>
41
41
  * secret-scan <repoRoot> --branch <branch>
42
42
  * classify-security <repoRoot> <taskId> [--branch <branch>]
43
+ * set-task-field <repoRoot> <taskId> <field> <value>
43
44
  */
44
45
  import { readFileSync, mkdirSync, copyFileSync, appendFileSync } from 'node:fs';
45
46
  import { dirname, join } from 'node:path';
@@ -68,7 +69,7 @@ import { loadWalkerConfig } from './walker-config.mjs';
68
69
  import { classifyFiles } from './scope-check.mjs';
69
70
  import { computeRfcTasksView } from './rfc-tasks.mjs';
70
71
  import { loadSecretPatternsConfig, scanSecrets } from './secret-scan.mjs';
71
- import { loadSecurityPathsConfig, computeSecurityClassification } from './security-classify.mjs';
72
+ import { classifyTaskSecurity } from './security-classify.mjs';
72
73
  function die(msg, code = 1) {
73
74
  process.stderr.write(msg + '\n');
74
75
  process.exit(code);
@@ -112,7 +113,8 @@ function usage(msg) {
112
113
  ' check-scope <repoRoot> <taskId> --branch <branchName>\n' +
113
114
  ' extend-scope <repoRoot> <taskId> --add <file>... --reason <text>\n' +
114
115
  ' secret-scan <repoRoot> --branch <branch>\n' +
115
- ' classify-security <repoRoot> <taskId> [--branch <branch>]\n');
116
+ ' classify-security <repoRoot> <taskId> [--branch <branch>]\n' +
117
+ ' set-task-field <repoRoot> <taskId> <field> <value>\n');
116
118
  process.exit(2);
117
119
  }
118
120
  const [, , command, ...rest] = process.argv;
@@ -762,31 +764,42 @@ try {
762
764
  const [repoRoot, taskId] = positional;
763
765
  if (!repoRoot || !taskId)
764
766
  usage('classify-security <repoRoot> <taskId> [--branch <branch>]');
765
- let task;
767
+ let result;
766
768
  try {
767
- task = loadTask(repoRoot, taskId);
769
+ result = classifyTaskSecurity(repoRoot, taskId, branch ? { branch } : undefined);
768
770
  }
769
771
  catch (err) {
770
772
  process.stderr.write(`cloverleaf-cli classify-security: ${err instanceof Error ? err.message : String(err)}\n`);
771
773
  process.exit(2);
772
774
  }
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
775
  process.stdout.write(JSON.stringify(result) + '\n');
782
776
  break;
783
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
+ }
784
792
  default:
785
793
  usage(`Unknown command: ${command}`);
786
794
  }
787
795
  }
788
796
  catch (err) {
789
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
+ }
790
803
  // Surface "illegal transition" errors with the right language
791
804
  const lower = msg.toLowerCase();
792
805
  if (lower.includes('illegal') || lower.includes('not allowed')) {
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
@@ -40,6 +40,7 @@
40
40
  * extend-scope <repoRoot> <taskId> --add <file>... --reason <text>
41
41
  * secret-scan <repoRoot> --branch <branch>
42
42
  * classify-security <repoRoot> <taskId> [--branch <branch>]
43
+ * set-task-field <repoRoot> <taskId> <field> <value>
43
44
  */
44
45
 
45
46
  import { readFileSync, mkdirSync, copyFileSync, appendFileSync, existsSync } from 'node:fs';
@@ -71,7 +72,7 @@ import { classifyFiles } from './scope-check.js';
71
72
  import type { SiblingScope } from './scope-check.js';
72
73
  import { computeRfcTasksView, type RfcTasksView } from './rfc-tasks.js';
73
74
  import { loadSecretPatternsConfig, scanSecrets } from './secret-scan.js';
74
- import { loadSecurityPathsConfig, computeSecurityClassification } from './security-classify.js';
75
+ import { classifyTaskSecurity } from './security-classify.js';
75
76
 
76
77
  function die(msg: string, code = 1): never {
77
78
  process.stderr.write(msg + '\n');
@@ -117,7 +118,8 @@ function usage(msg?: string): never {
117
118
  ' check-scope <repoRoot> <taskId> --branch <branchName>\n' +
118
119
  ' extend-scope <repoRoot> <taskId> --add <file>... --reason <text>\n' +
119
120
  ' secret-scan <repoRoot> --branch <branch>\n' +
120
- ' classify-security <repoRoot> <taskId> [--branch <branch>]\n'
121
+ ' classify-security <repoRoot> <taskId> [--branch <branch>]\n' +
122
+ ' set-task-field <repoRoot> <taskId> <field> <value>\n'
121
123
  );
122
124
  process.exit(2);
123
125
  }
@@ -777,30 +779,44 @@ try {
777
779
  const branch = branchIdx >= 0 ? rest[branchIdx + 1] : undefined;
778
780
  const [repoRoot, taskId] = positional;
779
781
  if (!repoRoot || !taskId) usage('classify-security <repoRoot> <taskId> [--branch <branch>]');
780
- let task;
782
+ let result;
781
783
  try {
782
- task = loadTask(repoRoot, taskId);
784
+ result = classifyTaskSecurity(repoRoot, taskId, branch ? { branch } : undefined);
783
785
  } catch (err) {
784
786
  process.stderr.write(`cloverleaf-cli classify-security: ${err instanceof Error ? err.message : String(err)}\n`);
785
787
  process.exit(2);
786
788
  }
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
789
  process.stdout.write(JSON.stringify(result) + '\n');
796
790
  break;
797
791
  }
798
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
+
799
810
  default:
800
811
  usage(`Unknown command: ${command}`);
801
812
  }
802
813
  } catch (err: unknown) {
803
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
+ }
804
820
  // Surface "illegal transition" errors with the right language
805
821
  const lower = msg.toLowerCase();
806
822
  if (lower.includes('illegal') || lower.includes('not allowed')) {
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.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "Reference implementation of the Cloverleaf methodology as Claude Code skills. Implements the Tight Loop (Implementer + Reviewer).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -48,6 +48,9 @@
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
55
  "@cloverleaf/standard": "^0.7.0",
53
56
  "ajv": "^8.17.1",
@@ -24,6 +24,18 @@ Examine the changed code for:
24
24
 
25
25
  A deterministic secret scan runs separately; you do NOT need to hunt for hardcoded keys (but flag one if you see it).
26
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
+
27
39
  ## Output
28
40
 
29
41
  Return ONLY a feedback envelope JSON:
@@ -35,13 +35,22 @@ Parse the JSON. If `classify-security` exits non-zero or emits unparseable outpu
35
35
  If `effective == "low"` → skip the gate, proceed with the lane.
36
36
 
37
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)`.
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
39
  - `cloverleaf-cli advance-status <repo_root> <TASK-ID> security-review agent`; commit.
40
40
  - Inline `/cloverleaf-security-review <TASK-ID>` steps. Reload the task:
41
41
  - `status == "automated-gates"` → security review passed; proceed with the lane.
42
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
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
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
+
45
54
  ## Steps
46
55
 
47
56
  1. Capture TASK-ID.
@@ -39,6 +39,8 @@ description: Run the Security Reviewer agent on a task in the `security-review`
39
39
 
40
40
  **Pass:**
41
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"
42
44
  cloverleaf-cli advance-status <repo_root> <TASK-ID> automated-gates agent
43
45
  git -C <repo_root> add .cloverleaf/ && git -C <repo_root> commit -m "cloverleaf: <TASK-ID> security review passed → automated-gates"
44
46
  ```
@@ -49,6 +51,8 @@ description: Run the Security Reviewer agent on a task in the `security-review`
49
51
  echo '<merged-envelope-json>' > /tmp/cloverleaf-fb-s.json
50
52
  cloverleaf-cli write-feedback <repo_root> <TASK-ID> /tmp/cloverleaf-fb-s.json
51
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"
52
56
  cloverleaf-cli advance-status <repo_root> <TASK-ID> implementing agent
53
57
  git -C <repo_root> add .cloverleaf/ && git -C <repo_root> commit -m "cloverleaf: <TASK-ID> security review bounced → implementing"
54
58
  ```
@@ -59,6 +63,8 @@ description: Run the Security Reviewer agent on a task in the `security-review`
59
63
  echo '<merged-envelope-json>' > /tmp/cloverleaf-fb-s.json
60
64
  cloverleaf-cli write-feedback <repo_root> <TASK-ID> /tmp/cloverleaf-fb-s.json
61
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"
62
68
  cloverleaf-cli advance-status <repo_root> <TASK-ID> escalated agent
63
69
  git -C <repo_root> add .cloverleaf/ && git -C <repo_root> commit -m "cloverleaf: <TASK-ID> security review escalated (blocker finding)"
64
70
  ```