@cloverleaf/reference-impl 0.8.0 → 0.8.2

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.2
package/dist/cli.mjs CHANGED
@@ -40,10 +40,11 @@
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';
46
- import { execSync } from 'node:child_process';
47
+ import { execSync, execFileSync } from 'node:child_process';
47
48
  import { loadTask, saveTask } from './task.mjs';
48
49
  import { advanceStatus } from './task.mjs';
49
50
  import { emitGateDecision } from './events.mjs';
@@ -65,10 +66,10 @@ import { buildBaselinePath } from './visual-diff.mjs';
65
66
  import { computeReadyTasks, detectCycle } from './dag-walker.mjs';
66
67
  import { readWalkState, writeWalkState, walkStatePath } from './walk-state.mjs';
67
68
  import { loadWalkerConfig } from './walker-config.mjs';
68
- import { classifyFiles } from './scope-check.mjs';
69
+ import { classifyFiles, normalizePath } 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;
@@ -650,8 +652,29 @@ try {
650
652
  catch {
651
653
  // No diff is fine (empty branch)
652
654
  }
653
- // 4. Classify and output
654
- const result = classifyFiles(taskDoc, modifiedFiles, siblingScopes);
655
+ // 4. Compute sharedFiles via git check-attr (merge=union honors user's multi-writer intent)
656
+ let sharedFiles = new Set();
657
+ if (modifiedFiles.length > 0) {
658
+ try {
659
+ const out = execFileSync('git', ['-C', repoRoot, 'check-attr', '-z', 'merge', '--', ...modifiedFiles], { encoding: 'utf-8' });
660
+ // -z output: NUL-separated triplets: path\0attr\0value\0
661
+ const parts = out.split('\0');
662
+ for (let i = 0; i + 2 < parts.length; i += 3) {
663
+ const path = parts[i];
664
+ const attr = parts[i + 1];
665
+ const value = parts[i + 2];
666
+ if (attr === 'merge' && value === 'union') {
667
+ sharedFiles.add(normalizePath(path));
668
+ }
669
+ }
670
+ }
671
+ catch (err) {
672
+ process.stderr.write(`cloverleaf-cli check-scope: git check-attr failed (${err instanceof Error ? err.message : String(err)}); proceeding with no shared-file annotations.\n`);
673
+ sharedFiles = new Set();
674
+ }
675
+ }
676
+ // 5. Classify and output
677
+ const result = classifyFiles(taskDoc, modifiedFiles, siblingScopes, sharedFiles);
655
678
  process.stdout.write(JSON.stringify(result) + '\n');
656
679
  process.exit(0);
657
680
  }
@@ -762,31 +785,42 @@ try {
762
785
  const [repoRoot, taskId] = positional;
763
786
  if (!repoRoot || !taskId)
764
787
  usage('classify-security <repoRoot> <taskId> [--branch <branch>]');
765
- let task;
788
+ let result;
766
789
  try {
767
- task = loadTask(repoRoot, taskId);
790
+ result = classifyTaskSecurity(repoRoot, taskId, branch ? { branch } : undefined);
768
791
  }
769
792
  catch (err) {
770
793
  process.stderr.write(`cloverleaf-cli classify-security: ${err instanceof Error ? err.message : String(err)}\n`);
771
794
  process.exit(2);
772
795
  }
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
796
  process.stdout.write(JSON.stringify(result) + '\n');
782
797
  break;
783
798
  }
799
+ case 'set-task-field': {
800
+ const [repoRoot, taskId, field, value] = rest;
801
+ if (!repoRoot || !taskId || !field || value === undefined)
802
+ usage('set-task-field requires <repoRoot> <taskId> <field> <value>');
803
+ const ALLOWED_FIELDS = new Set(['security_review_verdict']);
804
+ if (!ALLOWED_FIELDS.has(field)) {
805
+ die(`set-task-field: unknown field '${field}'. Allowed fields: ${Array.from(ALLOWED_FIELDS).join(', ')}`);
806
+ }
807
+ const task = loadTask(repoRoot, taskId);
808
+ const parsed = value === 'null' ? null : value;
809
+ task[field] = parsed;
810
+ saveTask(repoRoot, task);
811
+ break;
812
+ }
784
813
  default:
785
814
  usage(`Unknown command: ${command}`);
786
815
  }
787
816
  }
788
817
  catch (err) {
789
818
  const msg = err instanceof Error ? err.message : String(err);
819
+ // SECURITY_GATE errors: write the bare validator message to stderr and exit 2
820
+ if (err.code === 'SECURITY_GATE') {
821
+ process.stderr.write(msg + '\n');
822
+ process.exit(2);
823
+ }
790
824
  // Surface "illegal transition" errors with the right language
791
825
  const lower = msg.toLowerCase();
792
826
  if (lower.includes('illegal') || lower.includes('not allowed')) {
@@ -37,7 +37,7 @@
37
37
  * - Strip trailing `/` (preserving a non-empty result)
38
38
  * - Return the resulting string (may be empty for degenerate inputs; caller filters)
39
39
  */
40
- function normalizePath(p) {
40
+ export function normalizePath(p) {
41
41
  let s = p.trim().replace(/\\/g, '/');
42
42
  // Strip leading `./` repeatedly
43
43
  while (s.startsWith('./')) {
@@ -71,7 +71,7 @@ function normalizePaths(paths) {
71
71
  * @param siblingScopes - Other tasks in the same Plan with their declared files.
72
72
  * @returns - { contested, own, extension } with arrays sorted by file path.
73
73
  */
74
- export function classifyFiles(taskDoc, modifiedFiles, siblingScopes) {
74
+ export function classifyFiles(taskDoc, modifiedFiles, siblingScopes, sharedFiles) {
75
75
  // 1. Normalize own files from taskDoc.scope.files_touched
76
76
  const scope = taskDoc['scope'];
77
77
  const rawOwn = Array.isArray(scope?.['files_touched'])
@@ -117,6 +117,11 @@ export function classifyFiles(taskDoc, modifiedFiles, siblingScopes) {
117
117
  if (ownSet.has(f)) {
118
118
  own.push(f);
119
119
  }
120
+ else if (sharedFiles?.has(f)) {
121
+ // merge=union (or other shared-intent annotation): never contested.
122
+ // Falls into extension so post-merge auto-extend picks it up.
123
+ extension.push(f);
124
+ }
120
125
  else if (siblingMap.has(f)) {
121
126
  const owners = siblingMap.get(f);
122
127
  contested.push({ file: f, owner: owners[0] }); // lex-smallest wins
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,11 +40,12 @@
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';
46
47
  import { dirname, join } from 'node:path';
47
- import { execSync } from 'node:child_process';
48
+ import { execSync, execFileSync } from 'node:child_process';
48
49
  import { loadTask, saveTask } from './task.js';
49
50
  import { advanceStatus } from './task.js';
50
51
  import { emitGateDecision } from './events.js';
@@ -67,11 +68,11 @@ import { buildBaselinePath } from './visual-diff.js';
67
68
  import { computeReadyTasks, detectCycle } from './dag-walker.js';
68
69
  import { readWalkState, writeWalkState, walkStatePath } from './walk-state.js';
69
70
  import { loadWalkerConfig } from './walker-config.js';
70
- import { classifyFiles } from './scope-check.js';
71
+ import { classifyFiles, normalizePath } 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
  }
@@ -661,8 +663,35 @@ try {
661
663
  // No diff is fine (empty branch)
662
664
  }
663
665
 
664
- // 4. Classify and output
665
- const result = classifyFiles(taskDoc, modifiedFiles, siblingScopes);
666
+ // 4. Compute sharedFiles via git check-attr (merge=union honors user's multi-writer intent)
667
+ let sharedFiles = new Set<string>();
668
+ if (modifiedFiles.length > 0) {
669
+ try {
670
+ const out = execFileSync(
671
+ 'git',
672
+ ['-C', repoRoot, 'check-attr', '-z', 'merge', '--', ...modifiedFiles],
673
+ { encoding: 'utf-8' },
674
+ );
675
+ // -z output: NUL-separated triplets: path\0attr\0value\0
676
+ const parts = out.split('\0');
677
+ for (let i = 0; i + 2 < parts.length; i += 3) {
678
+ const path = parts[i];
679
+ const attr = parts[i + 1];
680
+ const value = parts[i + 2];
681
+ if (attr === 'merge' && value === 'union') {
682
+ sharedFiles.add(normalizePath(path));
683
+ }
684
+ }
685
+ } catch (err) {
686
+ process.stderr.write(
687
+ `cloverleaf-cli check-scope: git check-attr failed (${err instanceof Error ? err.message : String(err)}); proceeding with no shared-file annotations.\n`,
688
+ );
689
+ sharedFiles = new Set();
690
+ }
691
+ }
692
+
693
+ // 5. Classify and output
694
+ const result = classifyFiles(taskDoc, modifiedFiles, siblingScopes, sharedFiles);
666
695
  process.stdout.write(JSON.stringify(result) + '\n');
667
696
  process.exit(0);
668
697
  }
@@ -777,30 +806,44 @@ try {
777
806
  const branch = branchIdx >= 0 ? rest[branchIdx + 1] : undefined;
778
807
  const [repoRoot, taskId] = positional;
779
808
  if (!repoRoot || !taskId) usage('classify-security <repoRoot> <taskId> [--branch <branch>]');
780
- let task;
809
+ let result;
781
810
  try {
782
- task = loadTask(repoRoot, taskId);
811
+ result = classifyTaskSecurity(repoRoot, taskId, branch ? { branch } : undefined);
783
812
  } catch (err) {
784
813
  process.stderr.write(`cloverleaf-cli classify-security: ${err instanceof Error ? err.message : String(err)}\n`);
785
814
  process.exit(2);
786
815
  }
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
816
  process.stdout.write(JSON.stringify(result) + '\n');
796
817
  break;
797
818
  }
798
819
 
820
+ case 'set-task-field': {
821
+ const [repoRoot, taskId, field, value] = rest;
822
+ if (!repoRoot || !taskId || !field || value === undefined)
823
+ usage('set-task-field requires <repoRoot> <taskId> <field> <value>');
824
+ const ALLOWED_FIELDS = new Set(['security_review_verdict']);
825
+ if (!ALLOWED_FIELDS.has(field)) {
826
+ die(
827
+ `set-task-field: unknown field '${field}'. Allowed fields: ${Array.from(ALLOWED_FIELDS).join(', ')}`
828
+ );
829
+ }
830
+ const task = loadTask(repoRoot, taskId);
831
+ const parsed: unknown = value === 'null' ? null : value;
832
+ (task as Record<string, unknown>)[field] = parsed;
833
+ saveTask(repoRoot, task);
834
+ break;
835
+ }
836
+
799
837
  default:
800
838
  usage(`Unknown command: ${command}`);
801
839
  }
802
840
  } catch (err: unknown) {
803
841
  const msg = err instanceof Error ? err.message : String(err);
842
+ // SECURITY_GATE errors: write the bare validator message to stderr and exit 2
843
+ if ((err as { code?: string }).code === 'SECURITY_GATE') {
844
+ process.stderr.write(msg + '\n');
845
+ process.exit(2);
846
+ }
804
847
  // Surface "illegal transition" errors with the right language
805
848
  const lower = msg.toLowerCase();
806
849
  if (lower.includes('illegal') || lower.includes('not allowed')) {
@@ -56,7 +56,7 @@ export interface SiblingScope {
56
56
  * - Strip trailing `/` (preserving a non-empty result)
57
57
  * - Return the resulting string (may be empty for degenerate inputs; caller filters)
58
58
  */
59
- function normalizePath(p: string): string {
59
+ export function normalizePath(p: string): string {
60
60
  let s = p.trim().replace(/\\/g, '/');
61
61
  // Strip leading `./` repeatedly
62
62
  while (s.startsWith('./')) {
@@ -93,7 +93,8 @@ function normalizePaths(paths: string[]): string[] {
93
93
  export function classifyFiles(
94
94
  taskDoc: TaskDoc,
95
95
  modifiedFiles: string[],
96
- siblingScopes: SiblingScope[]
96
+ siblingScopes: SiblingScope[],
97
+ sharedFiles?: Set<string>
97
98
  ): ClassifyResult {
98
99
  // 1. Normalize own files from taskDoc.scope.files_touched
99
100
  const scope = taskDoc['scope'] as Record<string, unknown> | undefined;
@@ -138,6 +139,10 @@ export function classifyFiles(
138
139
  for (const f of filteredModified) {
139
140
  if (ownSet.has(f)) {
140
141
  own.push(f);
142
+ } else if (sharedFiles?.has(f)) {
143
+ // merge=union (or other shared-intent annotation): never contested.
144
+ // Falls into extension so post-merge auto-extend picks it up.
145
+ extension.push(f);
141
146
  } else if (siblingMap.has(f)) {
142
147
  const owners = siblingMap.get(f)!;
143
148
  contested.push({ file: f, owner: owners[0] }); // lex-smallest wins
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.2",
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
  ```