@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 +2 -0
- package/VERSION +1 -1
- package/dist/cli.mjs +50 -16
- package/dist/scope-check.mjs +7 -2
- package/dist/security-classify.mjs +0 -0
- package/dist/task.mjs +53 -2
- package/dist/work-item.mjs +10 -1
- package/lib/cli.ts +59 -16
- package/lib/scope-check.ts +7 -2
- 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.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 {
|
|
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.
|
|
654
|
-
|
|
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
|
|
788
|
+
let result;
|
|
766
789
|
try {
|
|
767
|
-
|
|
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')) {
|
package/dist/scope-check.mjs
CHANGED
|
@@ -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
|
-
|
|
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,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 {
|
|
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.
|
|
665
|
-
|
|
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
|
|
809
|
+
let result;
|
|
781
810
|
try {
|
|
782
|
-
|
|
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')) {
|
package/lib/scope-check.ts
CHANGED
|
@@ -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
|
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.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),
|
|
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
|
```
|