@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 +2 -0
- package/VERSION +1 -1
- package/dist/cli.mjs +25 -12
- package/dist/security-classify.mjs +0 -0
- package/dist/task.mjs +53 -2
- package/dist/work-item.mjs +10 -1
- package/lib/cli.ts +28 -12
- package/lib/security-classify.ts +0 -0
- package/lib/task.ts +67 -2
- package/lib/work-item.ts +11 -1
- package/package.json +4 -1
- package/prompts/security-reviewer.md +12 -0
- package/skills/cloverleaf-run/SKILL.md +10 -1
- package/skills/cloverleaf-security-review/SKILL.md +6 -0
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.
|
|
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 {
|
|
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
|
|
767
|
+
let result;
|
|
766
768
|
try {
|
|
767
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/dist/work-item.mjs
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.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 {
|
|
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
|
|
782
|
+
let result;
|
|
781
783
|
try {
|
|
782
|
-
|
|
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')) {
|
package/lib/security-classify.ts
CHANGED
|
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
|
-
|
|
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
|
|
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.
|
|
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),
|
|
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
|
```
|