@danielblomma/cortex-mcp 1.7.1 → 2.0.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/bin/cortex.mjs +679 -32
- package/bin/style.mjs +349 -0
- package/package.json +4 -3
- package/scaffold/mcp/package-lock.json +834 -671
- package/scaffold/mcp/package.json +1 -1
- package/scaffold/mcp/src/cli/enterprise-setup.ts +124 -0
- package/scaffold/mcp/src/cli/govern.ts +987 -0
- package/scaffold/mcp/src/cli/run.ts +306 -0
- package/scaffold/mcp/src/cli/telemetry-test.ts +158 -0
- package/scaffold/mcp/src/cli/ungoverned-detector.ts +168 -0
- package/scaffold/mcp/src/core/audit/query.ts +81 -0
- package/scaffold/mcp/src/core/audit/writer.ts +68 -0
- package/scaffold/mcp/src/core/config.ts +329 -0
- package/scaffold/mcp/src/core/index.ts +34 -0
- package/scaffold/mcp/src/core/license.ts +202 -0
- package/scaffold/mcp/src/core/policy/enforce.ts +98 -0
- package/scaffold/mcp/src/core/policy/injection.ts +229 -0
- package/scaffold/mcp/src/core/policy/store.ts +197 -0
- package/scaffold/mcp/src/core/rbac/check.ts +40 -0
- package/scaffold/mcp/src/core/telemetry/collector.ts +234 -0
- package/scaffold/mcp/src/core/validators/builtins.ts +711 -0
- package/scaffold/mcp/src/core/validators/config.ts +47 -0
- package/scaffold/mcp/src/core/validators/engine.ts +199 -0
- package/scaffold/mcp/src/core/validators/evaluators/code_comments.ts +294 -0
- package/scaffold/mcp/src/core/validators/evaluators/regex.ts +144 -0
- package/scaffold/mcp/src/daemon/client.ts +155 -0
- package/scaffold/mcp/src/daemon/egress-proxy.ts +331 -0
- package/scaffold/mcp/src/daemon/heartbeat-pusher.ts +147 -0
- package/scaffold/mcp/src/daemon/heartbeat-tracker.ts +223 -0
- package/scaffold/mcp/src/daemon/host-events-pusher.ts +285 -0
- package/scaffold/mcp/src/daemon/main.ts +300 -0
- package/scaffold/mcp/src/daemon/paths.ts +41 -0
- package/scaffold/mcp/src/daemon/protocol.ts +101 -0
- package/scaffold/mcp/src/daemon/server.ts +227 -0
- package/scaffold/mcp/src/daemon/sync-checker.ts +213 -0
- package/scaffold/mcp/src/daemon/ungoverned-scanner.ts +149 -0
- package/scaffold/mcp/src/embed.ts +1 -1
- package/scaffold/mcp/src/embeddings.ts +1 -1
- package/scaffold/mcp/src/enterprise/audit/push.ts +84 -0
- package/scaffold/mcp/src/enterprise/index.ts +415 -0
- package/scaffold/mcp/src/enterprise/model/deploy.ts +33 -0
- package/scaffold/mcp/src/enterprise/policy/sync.ts +146 -0
- package/scaffold/mcp/src/enterprise/privacy/boundary.ts +212 -0
- package/scaffold/mcp/src/enterprise/reviews/push.ts +79 -0
- package/scaffold/mcp/src/enterprise/telemetry/sync.ts +72 -0
- package/scaffold/mcp/src/enterprise/tools/enterprise.ts +1031 -0
- package/scaffold/mcp/src/enterprise/tools/walk.ts +79 -0
- package/scaffold/mcp/src/enterprise/violations/push.ts +102 -0
- package/scaffold/mcp/src/enterprise/workflow/push.ts +60 -0
- package/scaffold/mcp/src/enterprise/workflow/state.ts +535 -0
- package/scaffold/mcp/src/hooks/pre-compact.ts +54 -0
- package/scaffold/mcp/src/hooks/pre-tool-use.ts +96 -0
- package/scaffold/mcp/src/hooks/session-end.ts +73 -0
- package/scaffold/mcp/src/hooks/session-start.ts +78 -0
- package/scaffold/mcp/src/hooks/shared.ts +134 -0
- package/scaffold/mcp/src/hooks/stop.ts +60 -0
- package/scaffold/mcp/src/hooks/user-prompt-submit.ts +64 -0
- package/scaffold/mcp/src/plugin.ts +150 -0
- package/scaffold/mcp/src/server.ts +218 -7
- package/scaffold/mcp/tests/copilot-shim.test.mjs +146 -0
- package/scaffold/mcp/tests/daemon-client.test.mjs +32 -0
- package/scaffold/mcp/tests/egress-proxy.test.mjs +239 -0
- package/scaffold/mcp/tests/enterprise-config.test.mjs +154 -0
- package/scaffold/mcp/tests/govern-install.test.mjs +320 -0
- package/scaffold/mcp/tests/govern-repair.test.mjs +157 -0
- package/scaffold/mcp/tests/govern-status.test.mjs +538 -0
- package/scaffold/mcp/tests/govern.test.mjs +74 -0
- package/scaffold/mcp/tests/heartbeat-pusher.test.mjs +154 -0
- package/scaffold/mcp/tests/heartbeat-tracker.test.mjs +237 -0
- package/scaffold/mcp/tests/host-events-pusher.test.mjs +347 -0
- package/scaffold/mcp/tests/policy-check.test.mjs +220 -0
- package/scaffold/mcp/tests/repo-name.test.mjs +134 -0
- package/scaffold/mcp/tests/run.test.mjs +109 -0
- package/scaffold/mcp/tests/sync-checker.test.mjs +188 -0
- package/scaffold/mcp/tests/ungoverned-detector.test.mjs +191 -0
- package/scaffold/mcp/tests/ungoverned-scanner.test.mjs +198 -0
- package/scaffold/scripts/bootstrap.sh +0 -11
- package/scaffold/scripts/doctor.sh +24 -4
- package/types.js +5 -0
|
@@ -0,0 +1,1031 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { readFileSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { walkProjectFiles } from "./walk.js";
|
|
7
|
+
import type { EnterpriseConfig } from "../../core/config.js";
|
|
8
|
+
import type { TelemetryCollector } from "../../core/telemetry/collector.js";
|
|
9
|
+
import type { AuditWriter } from "../../core/audit/writer.js";
|
|
10
|
+
import type { PolicyStore } from "../../core/policy/store.js";
|
|
11
|
+
import { enforceInjectionPolicy, buildViolationPayload } from "../../core/policy/enforce.js";
|
|
12
|
+
import type { InjectionMatch } from "../../core/policy/injection.js";
|
|
13
|
+
import { getLastPush } from "../telemetry/sync.js";
|
|
14
|
+
import { syncFromCloud, syncFromLocal, getLastSync } from "../policy/sync.js";
|
|
15
|
+
import { queueViolation } from "../violations/push.js";
|
|
16
|
+
import { queueReviewResult } from "../reviews/push.js";
|
|
17
|
+
import { pushWorkflowSnapshot } from "../workflow/push.js";
|
|
18
|
+
import { OUTBOUND_DATA_BOUNDARY } from "../privacy/boundary.js";
|
|
19
|
+
import {
|
|
20
|
+
addWorkflowNote,
|
|
21
|
+
addWorkflowTodo,
|
|
22
|
+
approveWorkflow,
|
|
23
|
+
completeWorkflowTodo,
|
|
24
|
+
loadWorkflowState,
|
|
25
|
+
recordWorkflowReview,
|
|
26
|
+
recordWorkflowUpdate,
|
|
27
|
+
reviewWorkflowPlan,
|
|
28
|
+
setWorkflowPlan,
|
|
29
|
+
startWorkflowImplementation,
|
|
30
|
+
type WorkflowReviewedFileSnapshot,
|
|
31
|
+
} from "../workflow/state.js";
|
|
32
|
+
import { queryAuditLog } from "../../core/audit/query.js";
|
|
33
|
+
import { checkAccess, getAccessDeniedMessage, type Role } from "../../core/rbac/check.js";
|
|
34
|
+
import {
|
|
35
|
+
getGenericEvaluator,
|
|
36
|
+
getValidator,
|
|
37
|
+
runValidators,
|
|
38
|
+
} from "../../core/validators/engine.js";
|
|
39
|
+
import "../../core/validators/builtins.js";
|
|
40
|
+
import { recordToolActivity } from "../index.js";
|
|
41
|
+
|
|
42
|
+
type ToolPayload = Record<string, unknown>;
|
|
43
|
+
|
|
44
|
+
const VALID_ROLES = new Set<Role>(["admin", "developer", "readonly"]);
|
|
45
|
+
|
|
46
|
+
function snapshotReviewedFiles(
|
|
47
|
+
projectRoot: string,
|
|
48
|
+
changedFiles: string[] | undefined,
|
|
49
|
+
): WorkflowReviewedFileSnapshot[] | null {
|
|
50
|
+
if (!changedFiles) return null;
|
|
51
|
+
|
|
52
|
+
return [...new Set(changedFiles)]
|
|
53
|
+
.sort()
|
|
54
|
+
.map((file): WorkflowReviewedFileSnapshot => {
|
|
55
|
+
const abs = join(projectRoot, file);
|
|
56
|
+
try {
|
|
57
|
+
const stat = statSync(abs);
|
|
58
|
+
if (!stat.isFile()) {
|
|
59
|
+
return { path: file, exists: false, hash: null };
|
|
60
|
+
}
|
|
61
|
+
const hash = createHash("sha256")
|
|
62
|
+
.update(readFileSync(abs))
|
|
63
|
+
.digest("hex");
|
|
64
|
+
return { path: file, exists: true, hash };
|
|
65
|
+
} catch {
|
|
66
|
+
return { path: file, exists: false, hash: null };
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function buildToolResult(data: ToolPayload) {
|
|
72
|
+
return {
|
|
73
|
+
content: [
|
|
74
|
+
{
|
|
75
|
+
type: "text" as const,
|
|
76
|
+
text: JSON.stringify(data, null, 2),
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
structuredContent: data,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function accessDenied(role: Role, action: string) {
|
|
84
|
+
return buildToolResult({
|
|
85
|
+
error: getAccessDeniedMessage(role, action),
|
|
86
|
+
role,
|
|
87
|
+
action,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function pushWorkflowStateIfConfigured(
|
|
92
|
+
config: EnterpriseConfig,
|
|
93
|
+
state: ReturnType<typeof loadWorkflowState>
|
|
94
|
+
): Promise<void> {
|
|
95
|
+
const baseUrl = (config.enterprise.base_url || config.enterprise.endpoint).trim();
|
|
96
|
+
const apiKey = config.enterprise.api_key.trim();
|
|
97
|
+
if (!baseUrl || !apiKey) return;
|
|
98
|
+
const result = await pushWorkflowSnapshot(baseUrl, apiKey, state);
|
|
99
|
+
if (!result.success) {
|
|
100
|
+
process.stderr.write(
|
|
101
|
+
`[cortex-enterprise] Workflow snapshot push failed: ${result.error ?? "unknown error"}\n`
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function registerEnterpriseTools(
|
|
107
|
+
server: McpServer,
|
|
108
|
+
collector: TelemetryCollector,
|
|
109
|
+
auditWriter: AuditWriter | null,
|
|
110
|
+
config: EnterpriseConfig,
|
|
111
|
+
contextDir: string,
|
|
112
|
+
policyStore: PolicyStore,
|
|
113
|
+
version: string,
|
|
114
|
+
): void {
|
|
115
|
+
const roleCandidate = config.rbac.enabled ? config.rbac.default_role : "admin";
|
|
116
|
+
const role: Role = VALID_ROLES.has(roleCandidate as Role)
|
|
117
|
+
? (roleCandidate as Role)
|
|
118
|
+
: "readonly";
|
|
119
|
+
if (!VALID_ROLES.has(roleCandidate as Role) && config.rbac.enabled) {
|
|
120
|
+
process.stderr.write(`[cortex-enterprise] Invalid RBAC role '${roleCandidate}', falling back to 'readonly'\n`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── telemetry.status ──
|
|
124
|
+
server.registerTool(
|
|
125
|
+
"telemetry.status",
|
|
126
|
+
{
|
|
127
|
+
description: "Return telemetry configuration and current aggregated metrics.",
|
|
128
|
+
inputSchema: z.object({}),
|
|
129
|
+
},
|
|
130
|
+
async () => {
|
|
131
|
+
if (config.rbac.enabled && !checkAccess(role, "telemetry.status")) {
|
|
132
|
+
return accessDenied(role, "telemetry.status");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const metrics = collector.getMetrics();
|
|
136
|
+
const lastPush = getLastPush();
|
|
137
|
+
|
|
138
|
+
recordToolActivity({
|
|
139
|
+
timestamp: new Date().toISOString(),
|
|
140
|
+
tool: "telemetry.status",
|
|
141
|
+
input: {},
|
|
142
|
+
result_count: 1,
|
|
143
|
+
entities_returned: [],
|
|
144
|
+
rules_applied: [],
|
|
145
|
+
duration_ms: 0,
|
|
146
|
+
event_type: "tool_call",
|
|
147
|
+
evidence_level: "diagnostic",
|
|
148
|
+
resource_type: "telemetry",
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
return buildToolResult({
|
|
152
|
+
enabled: config.telemetry.enabled,
|
|
153
|
+
endpoint: config.telemetry.endpoint || null,
|
|
154
|
+
interval_minutes: config.telemetry.interval_minutes,
|
|
155
|
+
metrics,
|
|
156
|
+
last_push: lastPush,
|
|
157
|
+
boundary: OUTBOUND_DATA_BOUNDARY,
|
|
158
|
+
});
|
|
159
|
+
},
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
// ── audit.query ──
|
|
163
|
+
server.registerTool(
|
|
164
|
+
"audit.query",
|
|
165
|
+
{
|
|
166
|
+
description: "Search the enterprise audit log by date range, tool name, and limit.",
|
|
167
|
+
inputSchema: z.object({
|
|
168
|
+
from: z.string().optional().describe("Start date (YYYY-MM-DD)"),
|
|
169
|
+
to: z.string().optional().describe("End date (YYYY-MM-DD)"),
|
|
170
|
+
tool: z.string().optional().describe("Filter by tool name"),
|
|
171
|
+
event_type: z.string().optional().describe("Filter by audit event type"),
|
|
172
|
+
evidence_level: z.string().optional().describe("Filter by evidence level"),
|
|
173
|
+
status: z.enum(["success", "error"]).optional().describe("Filter by status"),
|
|
174
|
+
session_id: z.string().optional().describe("Filter by session id"),
|
|
175
|
+
limit: z.number().int().positive().max(500).default(50),
|
|
176
|
+
}),
|
|
177
|
+
},
|
|
178
|
+
async (input) => {
|
|
179
|
+
if (config.rbac.enabled && !checkAccess(role, "audit.query")) {
|
|
180
|
+
return accessDenied(role, "audit.query");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const parsed = z.object({
|
|
184
|
+
from: z.string().optional(),
|
|
185
|
+
to: z.string().optional(),
|
|
186
|
+
tool: z.string().optional(),
|
|
187
|
+
event_type: z.string().optional(),
|
|
188
|
+
evidence_level: z.string().optional(),
|
|
189
|
+
status: z.enum(["success", "error"]).optional(),
|
|
190
|
+
session_id: z.string().optional(),
|
|
191
|
+
limit: z.number().int().positive().max(500).default(50),
|
|
192
|
+
}).parse(input ?? {});
|
|
193
|
+
|
|
194
|
+
const entries = queryAuditLog(contextDir, parsed);
|
|
195
|
+
|
|
196
|
+
recordToolActivity({
|
|
197
|
+
timestamp: new Date().toISOString(),
|
|
198
|
+
tool: "audit.query",
|
|
199
|
+
input: parsed as Record<string, unknown>,
|
|
200
|
+
result_count: entries.length,
|
|
201
|
+
entities_returned: [],
|
|
202
|
+
rules_applied: [],
|
|
203
|
+
duration_ms: 0,
|
|
204
|
+
event_type: "tool_call",
|
|
205
|
+
evidence_level: "diagnostic",
|
|
206
|
+
resource_type: "audit",
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
return buildToolResult({
|
|
210
|
+
count: entries.length,
|
|
211
|
+
entries,
|
|
212
|
+
});
|
|
213
|
+
},
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
// ── policy.list ──
|
|
217
|
+
server.registerTool(
|
|
218
|
+
"policy.list",
|
|
219
|
+
{
|
|
220
|
+
description: "List all active policies (org + local merged). Org rules override local rules with same ID.",
|
|
221
|
+
inputSchema: z.object({
|
|
222
|
+
source: z.enum(["all", "org", "local"]).default("all").describe("Filter by policy source"),
|
|
223
|
+
}),
|
|
224
|
+
},
|
|
225
|
+
async (input) => {
|
|
226
|
+
if (config.rbac.enabled && !checkAccess(role, "policy.list")) {
|
|
227
|
+
return accessDenied(role, "policy.list");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const parsed = z.object({
|
|
231
|
+
source: z.enum(["all", "org", "local"]).default("all"),
|
|
232
|
+
}).parse(input ?? {});
|
|
233
|
+
|
|
234
|
+
let policies = policyStore.getMergedPolicies();
|
|
235
|
+
|
|
236
|
+
if (parsed.source !== "all") {
|
|
237
|
+
policies = policies.filter(p => p.source === parsed.source);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
recordToolActivity({
|
|
241
|
+
timestamp: new Date().toISOString(),
|
|
242
|
+
tool: "policy.list",
|
|
243
|
+
input: parsed as Record<string, unknown>,
|
|
244
|
+
result_count: policies.length,
|
|
245
|
+
entities_returned: policies.map(p => p.id),
|
|
246
|
+
rules_applied: [],
|
|
247
|
+
duration_ms: 0,
|
|
248
|
+
event_type: "tool_call",
|
|
249
|
+
evidence_level: "diagnostic",
|
|
250
|
+
resource_type: "policy",
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
return buildToolResult({
|
|
254
|
+
count: policies.length,
|
|
255
|
+
policies: policies.map(p => ({
|
|
256
|
+
id: p.id,
|
|
257
|
+
description: p.description,
|
|
258
|
+
priority: p.priority,
|
|
259
|
+
scope: p.scope,
|
|
260
|
+
enforce: p.enforce,
|
|
261
|
+
source: p.source,
|
|
262
|
+
})),
|
|
263
|
+
});
|
|
264
|
+
},
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
// ── policy.sync ──
|
|
268
|
+
server.registerTool(
|
|
269
|
+
"policy.sync",
|
|
270
|
+
{
|
|
271
|
+
description: "Trigger manual policy sync. Connected: pulls from cloud API. Air-gapped: reloads local org-rules.yaml.",
|
|
272
|
+
inputSchema: z.object({}),
|
|
273
|
+
},
|
|
274
|
+
async () => {
|
|
275
|
+
if (config.rbac.enabled && !checkAccess(role, "policy.sync")) {
|
|
276
|
+
return accessDenied(role, "policy.sync");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
let result;
|
|
280
|
+
if (config.policy.endpoint && config.policy.api_key) {
|
|
281
|
+
result = await syncFromCloud(config.policy.endpoint, config.policy.api_key, policyStore);
|
|
282
|
+
} else {
|
|
283
|
+
result = syncFromLocal(policyStore);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
recordToolActivity({
|
|
287
|
+
timestamp: new Date().toISOString(),
|
|
288
|
+
tool: "policy.sync",
|
|
289
|
+
input: {},
|
|
290
|
+
result_count: result.synced,
|
|
291
|
+
entities_returned: [],
|
|
292
|
+
rules_applied: [],
|
|
293
|
+
duration_ms: 0,
|
|
294
|
+
event_type: "policy_sync",
|
|
295
|
+
evidence_level: "required",
|
|
296
|
+
resource_type: "policy",
|
|
297
|
+
metadata: { synced: result.synced },
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
return buildToolResult(result as unknown as ToolPayload);
|
|
301
|
+
},
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
// ── enterprise.status ──
|
|
305
|
+
server.registerTool(
|
|
306
|
+
"enterprise.status",
|
|
307
|
+
{
|
|
308
|
+
description: "Return Cortex Enterprise overview: version, feature status, and policy health.",
|
|
309
|
+
inputSchema: z.object({}),
|
|
310
|
+
},
|
|
311
|
+
async () => {
|
|
312
|
+
if (config.rbac.enabled && !checkAccess(role, "enterprise.status")) {
|
|
313
|
+
return accessDenied(role, "enterprise.status");
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const lastSyncResult = getLastSync();
|
|
317
|
+
const policies = policyStore.getMergedPolicies();
|
|
318
|
+
|
|
319
|
+
recordToolActivity({
|
|
320
|
+
timestamp: new Date().toISOString(),
|
|
321
|
+
tool: "enterprise.status",
|
|
322
|
+
input: {},
|
|
323
|
+
result_count: policies.length,
|
|
324
|
+
entities_returned: [],
|
|
325
|
+
rules_applied: [],
|
|
326
|
+
duration_ms: 0,
|
|
327
|
+
event_type: "tool_call",
|
|
328
|
+
evidence_level: "diagnostic",
|
|
329
|
+
resource_type: "policy",
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
return buildToolResult({
|
|
333
|
+
edition: "enterprise",
|
|
334
|
+
version,
|
|
335
|
+
features: {
|
|
336
|
+
telemetry: config.telemetry.enabled ? "active" : "disabled",
|
|
337
|
+
policy_sync: config.policy.enabled ? "active" : "disabled",
|
|
338
|
+
audit_log: config.audit.enabled ? "active" : "disabled",
|
|
339
|
+
rbac: config.rbac.enabled ? `active (role: ${role})` : "disabled",
|
|
340
|
+
},
|
|
341
|
+
policies: {
|
|
342
|
+
total: policies.length,
|
|
343
|
+
org: policies.filter(p => p.source === "org").length,
|
|
344
|
+
local: policies.filter(p => p.source === "local").length,
|
|
345
|
+
last_sync: lastSyncResult,
|
|
346
|
+
},
|
|
347
|
+
workflow: loadWorkflowState(contextDir),
|
|
348
|
+
});
|
|
349
|
+
},
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
// ── workflow.status ──
|
|
353
|
+
server.registerTool(
|
|
354
|
+
"workflow.status",
|
|
355
|
+
{
|
|
356
|
+
description:
|
|
357
|
+
"Return the governed workflow state persisted in .context/workflow/state.json.",
|
|
358
|
+
inputSchema: z.object({}),
|
|
359
|
+
},
|
|
360
|
+
async () => {
|
|
361
|
+
if (config.rbac.enabled && !checkAccess(role, "workflow.status")) {
|
|
362
|
+
return accessDenied(role, "workflow.status");
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const state = loadWorkflowState(contextDir);
|
|
366
|
+
|
|
367
|
+
recordToolActivity({
|
|
368
|
+
timestamp: new Date().toISOString(),
|
|
369
|
+
tool: "workflow.status",
|
|
370
|
+
input: {},
|
|
371
|
+
result_count: 1,
|
|
372
|
+
entities_returned: [],
|
|
373
|
+
rules_applied: [],
|
|
374
|
+
duration_ms: 0,
|
|
375
|
+
event_type: "tool_call",
|
|
376
|
+
evidence_level: "diagnostic",
|
|
377
|
+
resource_type: "workflow",
|
|
378
|
+
metadata: {
|
|
379
|
+
phase: state.phase,
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
return buildToolResult(state as unknown as ToolPayload);
|
|
384
|
+
},
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
// ── workflow.plan ──
|
|
388
|
+
server.registerTool(
|
|
389
|
+
"workflow.plan",
|
|
390
|
+
{
|
|
391
|
+
description:
|
|
392
|
+
"Create or update the implementation plan. Resets approval until the plan is reviewed again.",
|
|
393
|
+
inputSchema: z.object({
|
|
394
|
+
title: z.string().min(1).max(200),
|
|
395
|
+
summary: z.string().min(1).max(5000),
|
|
396
|
+
tasks: z.array(z.string().min(1).max(500)).max(50).default([]),
|
|
397
|
+
}),
|
|
398
|
+
},
|
|
399
|
+
async (input) => {
|
|
400
|
+
if (config.rbac.enabled && !checkAccess(role, "workflow.plan")) {
|
|
401
|
+
return accessDenied(role, "workflow.plan");
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const parsed = z.object({
|
|
405
|
+
title: z.string().min(1).max(200),
|
|
406
|
+
summary: z.string().min(1).max(5000),
|
|
407
|
+
tasks: z.array(z.string().min(1).max(500)).max(50).default([]),
|
|
408
|
+
}).parse(input ?? {});
|
|
409
|
+
|
|
410
|
+
const state = setWorkflowPlan(contextDir, parsed);
|
|
411
|
+
await pushWorkflowStateIfConfigured(config, state);
|
|
412
|
+
|
|
413
|
+
recordToolActivity({
|
|
414
|
+
timestamp: new Date().toISOString(),
|
|
415
|
+
tool: "workflow.plan",
|
|
416
|
+
input: parsed as Record<string, unknown>,
|
|
417
|
+
result_count: state.plan.tasks.length,
|
|
418
|
+
entities_returned: [],
|
|
419
|
+
rules_applied: [],
|
|
420
|
+
duration_ms: 0,
|
|
421
|
+
event_type: "workflow_transition",
|
|
422
|
+
evidence_level: "required",
|
|
423
|
+
resource_type: "workflow",
|
|
424
|
+
metadata: {
|
|
425
|
+
phase: state.phase,
|
|
426
|
+
plan_status: state.plan.status,
|
|
427
|
+
},
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
return buildToolResult(state as unknown as ToolPayload);
|
|
431
|
+
},
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
// ── workflow.review_plan ──
|
|
435
|
+
server.registerTool(
|
|
436
|
+
"workflow.review_plan",
|
|
437
|
+
{
|
|
438
|
+
description:
|
|
439
|
+
"Review and approve or reject the current plan before implementation starts.",
|
|
440
|
+
inputSchema: z.object({
|
|
441
|
+
approved: z.boolean(),
|
|
442
|
+
notes: z.string().max(5000).optional(),
|
|
443
|
+
}),
|
|
444
|
+
},
|
|
445
|
+
async (input) => {
|
|
446
|
+
if (config.rbac.enabled && !checkAccess(role, "workflow.review_plan")) {
|
|
447
|
+
return accessDenied(role, "workflow.review_plan");
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const parsed = z.object({
|
|
451
|
+
approved: z.boolean(),
|
|
452
|
+
notes: z.string().max(5000).optional(),
|
|
453
|
+
}).parse(input ?? {});
|
|
454
|
+
|
|
455
|
+
const result = reviewWorkflowPlan(contextDir, parsed);
|
|
456
|
+
if (!result.ok) {
|
|
457
|
+
return buildToolResult({
|
|
458
|
+
error: result.error,
|
|
459
|
+
workflow: result.state,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
await pushWorkflowStateIfConfigured(config, result.state);
|
|
464
|
+
|
|
465
|
+
recordToolActivity({
|
|
466
|
+
timestamp: new Date().toISOString(),
|
|
467
|
+
tool: "workflow.review_plan",
|
|
468
|
+
input: parsed as Record<string, unknown>,
|
|
469
|
+
result_count: 1,
|
|
470
|
+
entities_returned: [],
|
|
471
|
+
rules_applied: [],
|
|
472
|
+
duration_ms: 0,
|
|
473
|
+
event_type: "workflow_transition",
|
|
474
|
+
evidence_level: "required",
|
|
475
|
+
resource_type: "workflow",
|
|
476
|
+
metadata: {
|
|
477
|
+
approved: parsed.approved,
|
|
478
|
+
phase: result.state.phase,
|
|
479
|
+
plan_status: result.state.plan.status,
|
|
480
|
+
},
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
return buildToolResult(result.state as unknown as ToolPayload);
|
|
484
|
+
},
|
|
485
|
+
);
|
|
486
|
+
|
|
487
|
+
// ── workflow.start ──
|
|
488
|
+
server.registerTool(
|
|
489
|
+
"workflow.start",
|
|
490
|
+
{
|
|
491
|
+
description:
|
|
492
|
+
"Mark the workflow as actively implementing after the plan has been approved.",
|
|
493
|
+
inputSchema: z.object({}),
|
|
494
|
+
},
|
|
495
|
+
async () => {
|
|
496
|
+
if (config.rbac.enabled && !checkAccess(role, "workflow.start")) {
|
|
497
|
+
return accessDenied(role, "workflow.start");
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const result = startWorkflowImplementation(contextDir);
|
|
501
|
+
if (!result.ok) {
|
|
502
|
+
return buildToolResult({
|
|
503
|
+
error: result.error,
|
|
504
|
+
workflow: result.state,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
await pushWorkflowStateIfConfigured(config, result.state);
|
|
509
|
+
|
|
510
|
+
recordToolActivity({
|
|
511
|
+
timestamp: new Date().toISOString(),
|
|
512
|
+
tool: "workflow.start",
|
|
513
|
+
input: {},
|
|
514
|
+
result_count: 1,
|
|
515
|
+
entities_returned: [],
|
|
516
|
+
rules_applied: [],
|
|
517
|
+
duration_ms: 0,
|
|
518
|
+
event_type: "workflow_transition",
|
|
519
|
+
evidence_level: "required",
|
|
520
|
+
resource_type: "workflow",
|
|
521
|
+
metadata: {
|
|
522
|
+
phase: result.state.phase,
|
|
523
|
+
},
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
return buildToolResult(result.state as unknown as ToolPayload);
|
|
527
|
+
},
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
// ── workflow.update ──
|
|
531
|
+
server.registerTool(
|
|
532
|
+
"workflow.update",
|
|
533
|
+
{
|
|
534
|
+
description:
|
|
535
|
+
"Record an implementation or iteration update without mutating the plan itself.",
|
|
536
|
+
inputSchema: z.object({
|
|
537
|
+
summary: z.string().min(1).max(5000),
|
|
538
|
+
phase: z.enum(["implementation", "iterating", "plan_review"]).optional(),
|
|
539
|
+
}),
|
|
540
|
+
},
|
|
541
|
+
async (input) => {
|
|
542
|
+
if (config.rbac.enabled && !checkAccess(role, "workflow.update")) {
|
|
543
|
+
return accessDenied(role, "workflow.update");
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const parsed = z.object({
|
|
547
|
+
summary: z.string().min(1).max(5000),
|
|
548
|
+
phase: z.enum(["implementation", "iterating", "plan_review"]).optional(),
|
|
549
|
+
}).parse(input ?? {});
|
|
550
|
+
|
|
551
|
+
const state = recordWorkflowUpdate(contextDir, parsed);
|
|
552
|
+
await pushWorkflowStateIfConfigured(config, state);
|
|
553
|
+
|
|
554
|
+
recordToolActivity({
|
|
555
|
+
timestamp: new Date().toISOString(),
|
|
556
|
+
tool: "workflow.update",
|
|
557
|
+
input: parsed as Record<string, unknown>,
|
|
558
|
+
result_count: 1,
|
|
559
|
+
entities_returned: [],
|
|
560
|
+
rules_applied: [],
|
|
561
|
+
duration_ms: 0,
|
|
562
|
+
event_type: "workflow_transition",
|
|
563
|
+
evidence_level: "required",
|
|
564
|
+
resource_type: "workflow",
|
|
565
|
+
metadata: {
|
|
566
|
+
phase: state.phase,
|
|
567
|
+
},
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
return buildToolResult(state as unknown as ToolPayload);
|
|
571
|
+
},
|
|
572
|
+
);
|
|
573
|
+
|
|
574
|
+
// ── workflow.note ──
|
|
575
|
+
server.registerTool(
|
|
576
|
+
"workflow.note",
|
|
577
|
+
{
|
|
578
|
+
description: "Persist a durable workflow note in .context/workflow/state.json.",
|
|
579
|
+
inputSchema: z.object({
|
|
580
|
+
title: z.string().min(1).max(200),
|
|
581
|
+
details: z.string().min(1).max(5000),
|
|
582
|
+
}),
|
|
583
|
+
},
|
|
584
|
+
async (input) => {
|
|
585
|
+
if (config.rbac.enabled && !checkAccess(role, "workflow.note")) {
|
|
586
|
+
return accessDenied(role, "workflow.note");
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const parsed = z.object({
|
|
590
|
+
title: z.string().min(1).max(200),
|
|
591
|
+
details: z.string().min(1).max(5000),
|
|
592
|
+
}).parse(input ?? {});
|
|
593
|
+
|
|
594
|
+
const state = addWorkflowNote(contextDir, parsed);
|
|
595
|
+
await pushWorkflowStateIfConfigured(config, state);
|
|
596
|
+
|
|
597
|
+
recordToolActivity({
|
|
598
|
+
timestamp: new Date().toISOString(),
|
|
599
|
+
tool: "workflow.note",
|
|
600
|
+
input: parsed as Record<string, unknown>,
|
|
601
|
+
result_count: state.notes.length,
|
|
602
|
+
entities_returned: [],
|
|
603
|
+
rules_applied: [],
|
|
604
|
+
duration_ms: 0,
|
|
605
|
+
event_type: "workflow_transition",
|
|
606
|
+
evidence_level: "required",
|
|
607
|
+
resource_type: "workflow",
|
|
608
|
+
metadata: {
|
|
609
|
+
note_count: state.notes.length,
|
|
610
|
+
phase: state.phase,
|
|
611
|
+
},
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
return buildToolResult(state as unknown as ToolPayload);
|
|
615
|
+
},
|
|
616
|
+
);
|
|
617
|
+
|
|
618
|
+
// ── workflow.todo ──
|
|
619
|
+
server.registerTool(
|
|
620
|
+
"workflow.todo",
|
|
621
|
+
{
|
|
622
|
+
description: "Add or complete workflow TODOs persisted in .context.",
|
|
623
|
+
inputSchema: z.object({
|
|
624
|
+
action: z.enum(["add", "complete"]),
|
|
625
|
+
id: z.number().int().positive().optional(),
|
|
626
|
+
title: z.string().min(1).max(200).optional(),
|
|
627
|
+
details: z.string().max(5000).optional(),
|
|
628
|
+
}),
|
|
629
|
+
},
|
|
630
|
+
async (input) => {
|
|
631
|
+
if (config.rbac.enabled && !checkAccess(role, "workflow.todo")) {
|
|
632
|
+
return accessDenied(role, "workflow.todo");
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const parsed = z.object({
|
|
636
|
+
action: z.enum(["add", "complete"]),
|
|
637
|
+
id: z.number().int().positive().optional(),
|
|
638
|
+
title: z.string().min(1).max(200).optional(),
|
|
639
|
+
details: z.string().max(5000).optional(),
|
|
640
|
+
}).parse(input ?? {});
|
|
641
|
+
|
|
642
|
+
if (parsed.action === "add") {
|
|
643
|
+
if (!parsed.title) {
|
|
644
|
+
return buildToolResult({ error: "title is required when action=add" });
|
|
645
|
+
}
|
|
646
|
+
const state = addWorkflowTodo(contextDir, {
|
|
647
|
+
title: parsed.title,
|
|
648
|
+
details: parsed.details,
|
|
649
|
+
});
|
|
650
|
+
await pushWorkflowStateIfConfigured(config, state);
|
|
651
|
+
|
|
652
|
+
recordToolActivity({
|
|
653
|
+
timestamp: new Date().toISOString(),
|
|
654
|
+
tool: "workflow.todo",
|
|
655
|
+
input: parsed as Record<string, unknown>,
|
|
656
|
+
result_count: state.todos.length,
|
|
657
|
+
entities_returned: [],
|
|
658
|
+
rules_applied: [],
|
|
659
|
+
duration_ms: 0,
|
|
660
|
+
event_type: "workflow_transition",
|
|
661
|
+
evidence_level: "required",
|
|
662
|
+
resource_type: "workflow",
|
|
663
|
+
metadata: {
|
|
664
|
+
action: "add",
|
|
665
|
+
open_todos: state.todos.filter((todo) => todo.status === "open").length,
|
|
666
|
+
},
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
return buildToolResult(state as unknown as ToolPayload);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
if (!parsed.id) {
|
|
673
|
+
return buildToolResult({ error: "id is required when action=complete" });
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const result = completeWorkflowTodo(contextDir, parsed.id);
|
|
677
|
+
if (!result.ok) {
|
|
678
|
+
return buildToolResult({
|
|
679
|
+
error: result.error,
|
|
680
|
+
workflow: result.state,
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
await pushWorkflowStateIfConfigured(config, result.state);
|
|
684
|
+
|
|
685
|
+
recordToolActivity({
|
|
686
|
+
timestamp: new Date().toISOString(),
|
|
687
|
+
tool: "workflow.todo",
|
|
688
|
+
input: parsed as Record<string, unknown>,
|
|
689
|
+
result_count: result.state.todos.length,
|
|
690
|
+
entities_returned: [],
|
|
691
|
+
rules_applied: [],
|
|
692
|
+
duration_ms: 0,
|
|
693
|
+
event_type: "workflow_transition",
|
|
694
|
+
evidence_level: "required",
|
|
695
|
+
resource_type: "workflow",
|
|
696
|
+
metadata: {
|
|
697
|
+
action: "complete",
|
|
698
|
+
completed_id: parsed.id,
|
|
699
|
+
open_todos: result.state.todos.filter((todo) => todo.status === "open").length,
|
|
700
|
+
},
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
return buildToolResult(result.state as unknown as ToolPayload);
|
|
704
|
+
},
|
|
705
|
+
);
|
|
706
|
+
|
|
707
|
+
// ── workflow.approve ──
|
|
708
|
+
server.registerTool(
|
|
709
|
+
"workflow.approve",
|
|
710
|
+
{
|
|
711
|
+
description:
|
|
712
|
+
"Approve the workflow only after the plan is approved and the latest code review passes.",
|
|
713
|
+
inputSchema: z.object({
|
|
714
|
+
notes: z.string().max(5000).optional(),
|
|
715
|
+
}),
|
|
716
|
+
},
|
|
717
|
+
async (input) => {
|
|
718
|
+
if (config.rbac.enabled && !checkAccess(role, "workflow.approve")) {
|
|
719
|
+
return accessDenied(role, "workflow.approve");
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const parsed = z.object({
|
|
723
|
+
notes: z.string().max(5000).optional(),
|
|
724
|
+
}).parse(input ?? {});
|
|
725
|
+
|
|
726
|
+
const result = approveWorkflow(contextDir, parsed.notes);
|
|
727
|
+
if (!result.ok) {
|
|
728
|
+
return buildToolResult({
|
|
729
|
+
error: result.error,
|
|
730
|
+
workflow: result.state,
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
await pushWorkflowStateIfConfigured(config, result.state);
|
|
735
|
+
|
|
736
|
+
recordToolActivity({
|
|
737
|
+
timestamp: new Date().toISOString(),
|
|
738
|
+
tool: "workflow.approve",
|
|
739
|
+
input: parsed as Record<string, unknown>,
|
|
740
|
+
result_count: 1,
|
|
741
|
+
entities_returned: [],
|
|
742
|
+
rules_applied: [],
|
|
743
|
+
duration_ms: 0,
|
|
744
|
+
event_type: "approval",
|
|
745
|
+
evidence_level: "required",
|
|
746
|
+
resource_type: "workflow",
|
|
747
|
+
metadata: {
|
|
748
|
+
approval_status: result.state.approval.status,
|
|
749
|
+
},
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
return buildToolResult(result.state as unknown as ToolPayload);
|
|
753
|
+
},
|
|
754
|
+
);
|
|
755
|
+
|
|
756
|
+
// ── security.scan ──
|
|
757
|
+
server.registerTool(
|
|
758
|
+
"security.scan",
|
|
759
|
+
{
|
|
760
|
+
description:
|
|
761
|
+
"Scan text for prompt injection attempts. Returns a risk score and matched patterns. " +
|
|
762
|
+
"Only active when the prompt-injection-defense policy is enforced.",
|
|
763
|
+
inputSchema: z.object({
|
|
764
|
+
text: z.string().min(1).max(50_000).describe("Text to scan for prompt injection"),
|
|
765
|
+
file_path: z.string().max(500).optional().describe("Source file path (for violation reporting)"),
|
|
766
|
+
}),
|
|
767
|
+
},
|
|
768
|
+
async (input) => {
|
|
769
|
+
if (config.rbac.enabled && !checkAccess(role, "policy.list")) {
|
|
770
|
+
return accessDenied(role, "security.scan");
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const parsed = z.object({
|
|
774
|
+
text: z.string().min(1).max(50_000),
|
|
775
|
+
file_path: z.string().max(500).optional(),
|
|
776
|
+
}).parse(input ?? {});
|
|
777
|
+
|
|
778
|
+
const policies = policyStore.getMergedPolicies();
|
|
779
|
+
const result = enforceInjectionPolicy(parsed.text, policies, { sanitize: true });
|
|
780
|
+
|
|
781
|
+
// Queue violation for push to cortex-web
|
|
782
|
+
if (!result.allowed && result.scan.matches.length > 0) {
|
|
783
|
+
const violation = buildViolationPayload(result.scan.matches, {
|
|
784
|
+
filePath: parsed.file_path,
|
|
785
|
+
});
|
|
786
|
+
queueViolation(violation);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const rulesApplied = result.allowed ? [] : [result.ruleId];
|
|
790
|
+
|
|
791
|
+
recordToolActivity({
|
|
792
|
+
timestamp: new Date().toISOString(),
|
|
793
|
+
tool: "security.scan",
|
|
794
|
+
input: { text_length: parsed.text.length, file_path: parsed.file_path },
|
|
795
|
+
result_count: result.scan.matches.length,
|
|
796
|
+
entities_returned: [],
|
|
797
|
+
rules_applied: rulesApplied,
|
|
798
|
+
duration_ms: 0,
|
|
799
|
+
event_type: "security_scan",
|
|
800
|
+
evidence_level: result.allowed ? "diagnostic" : "required",
|
|
801
|
+
resource_type: "policy",
|
|
802
|
+
metadata: {
|
|
803
|
+
flagged: result.scan.flagged,
|
|
804
|
+
score: result.scan.score,
|
|
805
|
+
allowed: result.allowed,
|
|
806
|
+
},
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
return buildToolResult({
|
|
810
|
+
flagged: result.scan.flagged,
|
|
811
|
+
score: result.scan.score,
|
|
812
|
+
allowed: result.allowed,
|
|
813
|
+
policy_active: !result.allowed || result.scan.score > 0 ? true : policies.some(p => p.id === "prompt-injection-defense" && p.enforce),
|
|
814
|
+
matches: result.scan.matches.map((m: InjectionMatch) => ({
|
|
815
|
+
pattern: m.pattern,
|
|
816
|
+
category: m.category,
|
|
817
|
+
matched: m.matched,
|
|
818
|
+
position: m.position,
|
|
819
|
+
weight: m.weight,
|
|
820
|
+
})),
|
|
821
|
+
sanitized: result.sanitized ?? null,
|
|
822
|
+
});
|
|
823
|
+
},
|
|
824
|
+
);
|
|
825
|
+
|
|
826
|
+
// ── context.review ──
|
|
827
|
+
server.registerTool(
|
|
828
|
+
"context.review",
|
|
829
|
+
{
|
|
830
|
+
description:
|
|
831
|
+
"Run enterprise policy validators against the current project. " +
|
|
832
|
+
"Checks enforced policies (test coverage, file size, external API calls, code review) " +
|
|
833
|
+
"and returns pass/fail results with actionable details.",
|
|
834
|
+
inputSchema: z.object({
|
|
835
|
+
scope: z.enum(["all", "changed"]).default("changed")
|
|
836
|
+
.describe("'changed' validates only git-modified files; 'all' validates everything"),
|
|
837
|
+
include_passed: z.boolean().default(true)
|
|
838
|
+
.describe("Include passing validators in results"),
|
|
839
|
+
}),
|
|
840
|
+
},
|
|
841
|
+
async (input) => {
|
|
842
|
+
if (config.rbac.enabled && !checkAccess(role, "context.review")) {
|
|
843
|
+
return accessDenied(role, "context.review");
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
const parsed = z.object({
|
|
847
|
+
scope: z.enum(["all", "changed"]).default("changed"),
|
|
848
|
+
include_passed: z.boolean().default(true),
|
|
849
|
+
}).parse(input ?? {});
|
|
850
|
+
|
|
851
|
+
const projectRoot = process.env.CORTEX_PROJECT_ROOT?.trim() || process.cwd();
|
|
852
|
+
|
|
853
|
+
// Resolve the file set for this review.
|
|
854
|
+
//
|
|
855
|
+
// scope=changed (default): ask git for the diff. If the working copy
|
|
856
|
+
// is not a git repo — or git otherwise fails — fall back to
|
|
857
|
+
// walking the project so the review doesn't silently pass
|
|
858
|
+
// everything with an empty file list (pre-0.9.1 regression).
|
|
859
|
+
//
|
|
860
|
+
// scope=all: always walk the project. Explicit opt-in for whole-
|
|
861
|
+
// project review; no git dependency.
|
|
862
|
+
let changedFiles: string[] | undefined;
|
|
863
|
+
if (parsed.scope === "changed") {
|
|
864
|
+
// Use `git rev-parse --is-inside-work-tree` to distinguish
|
|
865
|
+
// "valid repo, nothing changed" (→ empty list, reviewer sees
|
|
866
|
+
// nothing to scan) from "not a repo / git broken" (→ walk the
|
|
867
|
+
// project so scope=changed still returns meaningful results
|
|
868
|
+
// when run outside a git checkout).
|
|
869
|
+
let inGitRepo = false;
|
|
870
|
+
try {
|
|
871
|
+
const { execSync } = await import("node:child_process");
|
|
872
|
+
execSync("git rev-parse --is-inside-work-tree", {
|
|
873
|
+
cwd: projectRoot,
|
|
874
|
+
encoding: "utf8",
|
|
875
|
+
timeout: 3000,
|
|
876
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
877
|
+
});
|
|
878
|
+
inGitRepo = true;
|
|
879
|
+
} catch {
|
|
880
|
+
inGitRepo = false;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
if (inGitRepo) {
|
|
884
|
+
try {
|
|
885
|
+
const { execSync } = await import("node:child_process");
|
|
886
|
+
const output = execSync("git diff --name-only HEAD 2>/dev/null || git diff --name-only", {
|
|
887
|
+
cwd: projectRoot,
|
|
888
|
+
encoding: "utf8",
|
|
889
|
+
timeout: 5000,
|
|
890
|
+
});
|
|
891
|
+
changedFiles = output.split("\n").map((f) => f.trim()).filter(Boolean);
|
|
892
|
+
} catch {
|
|
893
|
+
changedFiles = [];
|
|
894
|
+
}
|
|
895
|
+
} else {
|
|
896
|
+
changedFiles = walkProjectFiles(projectRoot);
|
|
897
|
+
}
|
|
898
|
+
} else {
|
|
899
|
+
changedFiles = walkProjectFiles(projectRoot);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// Build enforced policies list, carrying type + config so the
|
|
903
|
+
// engine can dispatch to generic evaluators for cortex-web custom
|
|
904
|
+
// rules. Predefined rules leave type/config null and route to the
|
|
905
|
+
// name-based validator registry.
|
|
906
|
+
const policies = policyStore.getMergedPolicies();
|
|
907
|
+
const skippedPolicies = policies
|
|
908
|
+
.filter((p) => p.enforce)
|
|
909
|
+
.filter((p) => p.id === "require-code-review" || (p.type ? !getGenericEvaluator(p.type) : !getValidator(p.id)))
|
|
910
|
+
.map((p) => {
|
|
911
|
+
if (p.id === "require-code-review") {
|
|
912
|
+
return {
|
|
913
|
+
policy_id: p.id,
|
|
914
|
+
kind: p.kind ?? null,
|
|
915
|
+
type: p.type ?? null,
|
|
916
|
+
reason: "Current context.review invocation is the review being recorded; validate this policy from workflow state on the next run.",
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
return {
|
|
920
|
+
policy_id: p.id,
|
|
921
|
+
kind: p.kind ?? null,
|
|
922
|
+
type: p.type ?? null,
|
|
923
|
+
reason: p.type
|
|
924
|
+
? `No evaluator registered for type "${p.type}"`
|
|
925
|
+
: "No executable validator registered for this policy",
|
|
926
|
+
};
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
const enforced = policies
|
|
930
|
+
.filter((p) => p.enforce)
|
|
931
|
+
.filter((p) => {
|
|
932
|
+
if (p.id === "require-code-review") return false;
|
|
933
|
+
if (p.type) return Boolean(getGenericEvaluator(p.type));
|
|
934
|
+
return Boolean(getValidator(p.id));
|
|
935
|
+
})
|
|
936
|
+
.map((p) => ({
|
|
937
|
+
id: p.id,
|
|
938
|
+
type: p.type ?? null,
|
|
939
|
+
config: p.config ?? null,
|
|
940
|
+
severity: p.severity ?? "block",
|
|
941
|
+
}));
|
|
942
|
+
|
|
943
|
+
const now = new Date().toISOString();
|
|
944
|
+
|
|
945
|
+
const output = await runValidators(enforced, {
|
|
946
|
+
contextDir,
|
|
947
|
+
projectRoot,
|
|
948
|
+
changedFiles,
|
|
949
|
+
}, config.validators);
|
|
950
|
+
|
|
951
|
+
// Filter out passed if requested
|
|
952
|
+
const results = parsed.include_passed
|
|
953
|
+
? output.results
|
|
954
|
+
: output.results.filter((r) => !r.pass);
|
|
955
|
+
|
|
956
|
+
// Queue failures as violations
|
|
957
|
+
for (const r of output.results) {
|
|
958
|
+
if (!r.pass) {
|
|
959
|
+
queueViolation({
|
|
960
|
+
rule_id: r.policy_id,
|
|
961
|
+
severity: r.severity,
|
|
962
|
+
message: r.message.slice(0, 2000),
|
|
963
|
+
metadata: r.detail ? JSON.stringify({ detail: r.detail }).slice(0, 5000) : undefined,
|
|
964
|
+
occurred_at: now,
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
queueReviewResult({
|
|
968
|
+
policy_id: r.policy_id,
|
|
969
|
+
pass: r.pass,
|
|
970
|
+
severity: r.severity,
|
|
971
|
+
message: r.message,
|
|
972
|
+
detail: r.detail,
|
|
973
|
+
reviewed_at: now,
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
recordToolActivity({
|
|
978
|
+
timestamp: now,
|
|
979
|
+
tool: "context.review",
|
|
980
|
+
input: parsed as Record<string, unknown>,
|
|
981
|
+
result_count: output.results.length,
|
|
982
|
+
entities_returned: output.results.map((r) => r.policy_id),
|
|
983
|
+
rules_applied: output.results.filter((r) => !r.pass).map((r) => r.policy_id),
|
|
984
|
+
duration_ms: 0,
|
|
985
|
+
event_type: "review_result",
|
|
986
|
+
evidence_level: "required",
|
|
987
|
+
resource_type: "review",
|
|
988
|
+
metadata: {
|
|
989
|
+
scope: parsed.scope,
|
|
990
|
+
passed: output.summary.passed,
|
|
991
|
+
failed: output.summary.failed,
|
|
992
|
+
warnings: output.results.filter((r) => !r.pass && r.severity === "warning").length,
|
|
993
|
+
skipped: skippedPolicies.length,
|
|
994
|
+
},
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
const reviewedFiles = snapshotReviewedFiles(projectRoot, changedFiles);
|
|
998
|
+
const workflowState = recordWorkflowReview(contextDir, {
|
|
999
|
+
scope: parsed.scope,
|
|
1000
|
+
output,
|
|
1001
|
+
reviewed_files: reviewedFiles,
|
|
1002
|
+
});
|
|
1003
|
+
const lastReview = workflowState.last_review;
|
|
1004
|
+
if (lastReview) {
|
|
1005
|
+
writeFileSync(
|
|
1006
|
+
join(contextDir, "review-status.json"),
|
|
1007
|
+
`${JSON.stringify({
|
|
1008
|
+
reviewed: lastReview.status === "passed",
|
|
1009
|
+
reviewer: "context.review",
|
|
1010
|
+
timestamp: lastReview.reviewed_at,
|
|
1011
|
+
scope: parsed.scope,
|
|
1012
|
+
reviewed_files: reviewedFiles,
|
|
1013
|
+
}, null, 2)}\n`,
|
|
1014
|
+
"utf8",
|
|
1015
|
+
);
|
|
1016
|
+
}
|
|
1017
|
+
await pushWorkflowStateIfConfigured(config, workflowState);
|
|
1018
|
+
|
|
1019
|
+
return buildToolResult({
|
|
1020
|
+
scope: parsed.scope,
|
|
1021
|
+
results,
|
|
1022
|
+
skipped_policies: skippedPolicies,
|
|
1023
|
+
summary: {
|
|
1024
|
+
...output.summary,
|
|
1025
|
+
skipped: skippedPolicies.length,
|
|
1026
|
+
},
|
|
1027
|
+
workflow: workflowState,
|
|
1028
|
+
});
|
|
1029
|
+
},
|
|
1030
|
+
);
|
|
1031
|
+
}
|