prism-mcp-server 7.2.0 → 7.3.3
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 +102 -19
- package/dist/cli.js +50 -0
- package/dist/config.js +16 -0
- package/dist/darkfactory/clawInvocation.js +77 -0
- package/dist/darkfactory/runner.js +683 -0
- package/dist/darkfactory/safetyController.js +197 -0
- package/dist/darkfactory/schema.js +4 -0
- package/dist/dashboard/server.js +103 -0
- package/dist/dashboard/ui.js +2668 -1990
- package/dist/dashboard/ui.tmp.js +3475 -0
- package/dist/errors.js +29 -0
- package/dist/hivemindWatchdog.js +197 -4
- package/dist/lifecycle.js +9 -1
- package/dist/server.js +41 -3
- package/dist/storage/sqlite.js +243 -0
- package/dist/storage/supabase.js +195 -3
- package/dist/storage/supabaseMigrations.js +52 -0
- package/dist/tools/index.js +5 -0
- package/dist/tools/pipelineDefinitions.js +131 -0
- package/dist/tools/pipelineHandlers.js +214 -0
- package/dist/tools/routerExperience.js +14 -0
- package/dist/tools/sessionMemoryDefinitions.js +5 -3
- package/dist/verification/clawValidator.js +229 -0
- package/dist/verification/cliHandler.js +325 -0
- package/dist/verification/gatekeeper.js +39 -0
- package/dist/verification/renameDetector.js +170 -0
- package/dist/verification/runner.js +501 -0
- package/dist/verification/schema.js +64 -0
- package/dist/verification/severityPolicy.js +98 -0
- package/package.json +13 -5
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { VALID_ACTION_TYPES } from './schema.js';
|
|
2
|
+
import { PRISM_DARK_FACTORY_MAX_RUNTIME_MS } from '../config.js';
|
|
3
|
+
import { debugLog } from '../utils/logger.js';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
/**
|
|
6
|
+
* Controller strictly enforcing safety and invariant checks across Factory Pipelines.
|
|
7
|
+
*
|
|
8
|
+
* Responsibilities:
|
|
9
|
+
* 1. Iteration limit enforcement (prevents runaway LLM loops)
|
|
10
|
+
* 2. Path scope validation (prevents filesystem escapes)
|
|
11
|
+
* 3. Heartbeat lapse detection (finds zombie pipelines)
|
|
12
|
+
* 4. State machine transition validation (prevents illegal status jumps)
|
|
13
|
+
* 5. System prompt boundary generation (scope injection into LLM calls)
|
|
14
|
+
* 6. Total wall-clock runtime enforcement
|
|
15
|
+
*/
|
|
16
|
+
export class SafetyController {
|
|
17
|
+
/**
|
|
18
|
+
* Defines how long a pipeline can go without a heartbeat before being considered "zombie".
|
|
19
|
+
* Handled by the Dark Factory Runner watchdog.
|
|
20
|
+
*/
|
|
21
|
+
static HEARTBEAT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
|
22
|
+
/**
|
|
23
|
+
* Legal state transitions for the pipeline state machine.
|
|
24
|
+
* Any transition not listed here is rejected by validateTransition().
|
|
25
|
+
*/
|
|
26
|
+
static LEGAL_TRANSITIONS = {
|
|
27
|
+
'PENDING': ['RUNNING', 'ABORTED'], // Queued → Runner promotes or user aborts
|
|
28
|
+
'RUNNING': ['PAUSED', 'ABORTED', 'COMPLETED', 'FAILED'],
|
|
29
|
+
'PAUSED': ['RUNNING', 'ABORTED'],
|
|
30
|
+
'ABORTED': [], // Terminal — no exits
|
|
31
|
+
'COMPLETED': [], // Terminal — no exits
|
|
32
|
+
'FAILED': ['RUNNING'], // Allow retry from failed state
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Legal step transitions for the pipeline execution state machine.
|
|
36
|
+
* FINALIZE is entered from VERIFY when iteration == maxIterations or success.
|
|
37
|
+
*/
|
|
38
|
+
static STEP_ORDER = [
|
|
39
|
+
'INIT', 'PLAN', 'EXECUTE', 'VERIFY', 'FINALIZE'
|
|
40
|
+
];
|
|
41
|
+
/**
|
|
42
|
+
* Prevents runaway LLM invocation loops by enforcing the max iteration envelope.
|
|
43
|
+
*/
|
|
44
|
+
static validateIterationLimit(iteration, spec) {
|
|
45
|
+
return iteration <= spec.maxIterations;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Ensure a target path operates only within the explicitly restricted spec zone.
|
|
49
|
+
* If workingDirectory is missing, the global app root is assumed.
|
|
50
|
+
*/
|
|
51
|
+
static isPathWithinScope(targetPath, spec) {
|
|
52
|
+
if (!spec.workingDirectory)
|
|
53
|
+
return true;
|
|
54
|
+
// Resolve symlinks and protect against ../ escapes.
|
|
55
|
+
const resolvedTarget = path.resolve(targetPath);
|
|
56
|
+
const resolvedWorkspace = path.resolve(spec.workingDirectory);
|
|
57
|
+
// Path Traversal Guard: A naive startsWith() check is vulnerable to
|
|
58
|
+
// prefix collisions — e.g. /app/workspace-hacked passes startsWith('/app/workspace').
|
|
59
|
+
// We require EITHER exact match OR the target starts with workspace + path separator.
|
|
60
|
+
if (resolvedTarget !== resolvedWorkspace && !resolvedTarget.startsWith(resolvedWorkspace + path.sep)) {
|
|
61
|
+
debugLog(`[Safety] Rejecting out-of-scope path resolution: ${targetPath}`);
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Batch-validate an array of ActionPayload objects against the pipeline spec.
|
|
68
|
+
*
|
|
69
|
+
* Checks:
|
|
70
|
+
* 1. Each action has a valid ActionType
|
|
71
|
+
* 2. Each action's targetPath is non-empty
|
|
72
|
+
* 3. Each action's targetPath resolves within workingDirectory (via isPathWithinScope)
|
|
73
|
+
*
|
|
74
|
+
* Returns the first violation message (string) if any action fails,
|
|
75
|
+
* or null if all actions are valid and in-scope.
|
|
76
|
+
*
|
|
77
|
+
* Used by runner.ts after parsing EXECUTE step output — any non-null return
|
|
78
|
+
* terminates the pipeline immediately (fail closed).
|
|
79
|
+
*/
|
|
80
|
+
static validateActionsInScope(actions, spec) {
|
|
81
|
+
if (!Array.isArray(actions) || actions.length === 0) {
|
|
82
|
+
return 'Actions array is empty or not an array';
|
|
83
|
+
}
|
|
84
|
+
for (let i = 0; i < actions.length; i++) {
|
|
85
|
+
const action = actions[i];
|
|
86
|
+
// Validate action type is in the restricted set
|
|
87
|
+
if (!action.type || !VALID_ACTION_TYPES.includes(action.type)) {
|
|
88
|
+
return `Action[${i}]: invalid type "${action.type}" (allowed: ${VALID_ACTION_TYPES.join(', ')})`;
|
|
89
|
+
}
|
|
90
|
+
// Validate targetPath is non-empty
|
|
91
|
+
if (!action.targetPath || typeof action.targetPath !== 'string' || action.targetPath.trim() === '') {
|
|
92
|
+
return `Action[${i}]: targetPath is empty or missing`;
|
|
93
|
+
}
|
|
94
|
+
// Resolve targetPath relative to workingDirectory for scope check
|
|
95
|
+
const resolvedTarget = spec.workingDirectory
|
|
96
|
+
? path.resolve(spec.workingDirectory, action.targetPath)
|
|
97
|
+
: path.resolve(action.targetPath);
|
|
98
|
+
if (!SafetyController.isPathWithinScope(resolvedTarget, spec)) {
|
|
99
|
+
return `Action[${i}]: path "${action.targetPath}" resolves outside permitted scope`;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return null; // All actions valid and in-scope
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Determine whether a pipeline has timed out based on its recorded heartbeat.
|
|
106
|
+
*/
|
|
107
|
+
static isHeartbeatLapsed(state, timeoutOverrideMs) {
|
|
108
|
+
if (!state.last_heartbeat) {
|
|
109
|
+
// Pipeline never heartbeat. Use started_at as fallback
|
|
110
|
+
const diff = Date.now() - new Date(state.started_at).getTime();
|
|
111
|
+
return diff > (timeoutOverrideMs || SafetyController.HEARTBEAT_TIMEOUT_MS);
|
|
112
|
+
}
|
|
113
|
+
const diff = Date.now() - new Date(state.last_heartbeat).getTime();
|
|
114
|
+
return diff > (timeoutOverrideMs || SafetyController.HEARTBEAT_TIMEOUT_MS);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Validate that a status transition is legal under the state machine.
|
|
118
|
+
* Prevents impossible jumps (e.g., COMPLETED → RUNNING) that would
|
|
119
|
+
* corrupt pipeline audit trails.
|
|
120
|
+
*/
|
|
121
|
+
static validateTransition(from, to) {
|
|
122
|
+
const legal = SafetyController.LEGAL_TRANSITIONS[from];
|
|
123
|
+
if (!legal)
|
|
124
|
+
return false;
|
|
125
|
+
return legal.includes(to);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Return the list of legal target statuses for a given source status.
|
|
129
|
+
* Used to build descriptive error messages in storage backends.
|
|
130
|
+
*/
|
|
131
|
+
static getLegalTransitions(from) {
|
|
132
|
+
return SafetyController.LEGAL_TRANSITIONS[from] ?? [];
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Check whether the pipeline has exceeded its total wall-clock runtime.
|
|
136
|
+
* Uses the configurable PRISM_DARK_FACTORY_MAX_RUNTIME_MS (default: 15 min).
|
|
137
|
+
*/
|
|
138
|
+
static isRuntimeExceeded(state) {
|
|
139
|
+
const elapsed = Date.now() - new Date(state.started_at).getTime();
|
|
140
|
+
return elapsed > PRISM_DARK_FACTORY_MAX_RUNTIME_MS;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Generate a scoped system prompt that enforces operational boundaries
|
|
144
|
+
* for all LLM calls within the pipeline. This is the "boundary injection"
|
|
145
|
+
* that prevents the model from operating outside its mandate.
|
|
146
|
+
*
|
|
147
|
+
* Used by clawInvocation.ts instead of inline prompt construction.
|
|
148
|
+
*/
|
|
149
|
+
static generateBoundaryPrompt(spec, state) {
|
|
150
|
+
const lines = [
|
|
151
|
+
`You are Prism Dark Factory, operating in the background as an autonomous code agent.`,
|
|
152
|
+
`You are strictly limited to code actions within the defined scope.`,
|
|
153
|
+
``,
|
|
154
|
+
`── Operational Boundaries ──`,
|
|
155
|
+
`Pipeline ID: ${state.id}`,
|
|
156
|
+
`Project: ${state.project}`,
|
|
157
|
+
`Current Step: ${state.current_step}`,
|
|
158
|
+
`Iteration: ${state.iteration} / ${spec.maxIterations}`,
|
|
159
|
+
`Restricted Workspace: ${spec.workingDirectory || '(unrestricted)'}`,
|
|
160
|
+
];
|
|
161
|
+
if (spec.contextFiles && spec.contextFiles.length > 0) {
|
|
162
|
+
lines.push(`Context Files: ${spec.contextFiles.join(', ')}`);
|
|
163
|
+
}
|
|
164
|
+
lines.push(``, `── Objective ──`, spec.objective, ``, `── Safety Rules ──`, `1. Do NOT modify files outside the Restricted Workspace.`, `2. Do NOT make network requests unless the objective explicitly requires it.`, `3. Do NOT execute destructive operations (rm -rf, DROP TABLE, etc.).`, `4. Respond ONLY with actions relevant to the current step.`, `5. If you cannot complete the step, explain why and stop.`);
|
|
165
|
+
return lines.join('\n');
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Determine the next step in the pipeline execution sequence.
|
|
169
|
+
* Returns null if the pipeline should terminate (FINALIZE reached or iteration exceeded).
|
|
170
|
+
*/
|
|
171
|
+
static getNextStep(currentStep, iteration, spec, verifyPassed) {
|
|
172
|
+
switch (currentStep) {
|
|
173
|
+
case 'INIT':
|
|
174
|
+
return { step: 'PLAN', iteration };
|
|
175
|
+
case 'PLAN':
|
|
176
|
+
return { step: 'EXECUTE', iteration };
|
|
177
|
+
case 'EXECUTE':
|
|
178
|
+
return { step: 'VERIFY', iteration };
|
|
179
|
+
case 'VERIFY':
|
|
180
|
+
if (verifyPassed) {
|
|
181
|
+
return { step: 'FINALIZE', iteration };
|
|
182
|
+
}
|
|
183
|
+
// Verification failed — loop back to PLAN with incremented iteration
|
|
184
|
+
const nextIteration = iteration + 1;
|
|
185
|
+
if (!SafetyController.validateIterationLimit(nextIteration, spec)) {
|
|
186
|
+
// Exceeded max iterations — force finalize with failure
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
return { step: 'PLAN', iteration: nextIteration };
|
|
190
|
+
case 'FINALIZE':
|
|
191
|
+
return null; // Terminal step
|
|
192
|
+
default:
|
|
193
|
+
debugLog(`[Safety] Unknown step "${currentStep}" — forcing termination`);
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
package/dist/dashboard/server.js
CHANGED
|
@@ -782,6 +782,109 @@ return false;}
|
|
|
782
782
|
return res.end(JSON.stringify({ error: err.message || "Failed to search memory" }));
|
|
783
783
|
}
|
|
784
784
|
}
|
|
785
|
+
// ─── API: Dark Factory Pipelines (v7.3) ───────────────────
|
|
786
|
+
// GET /api/pipelines — List pipelines (optional ?status=RUNNING&project=myproj)
|
|
787
|
+
if (url.pathname === "/api/pipelines" && req.method === "GET") {
|
|
788
|
+
try {
|
|
789
|
+
const s = await getStorageSafe();
|
|
790
|
+
if (!s) {
|
|
791
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
792
|
+
return res.end(JSON.stringify({ error: "Storage initializing..." }));
|
|
793
|
+
}
|
|
794
|
+
// Validate status filter against canonical set
|
|
795
|
+
var rawStatus = url.searchParams.get("status") || undefined;
|
|
796
|
+
var VALID_STATUSES = ['PENDING', 'RUNNING', 'PAUSED', 'ABORTED', 'COMPLETED', 'FAILED'];
|
|
797
|
+
if (rawStatus && VALID_STATUSES.indexOf(rawStatus) === -1) {
|
|
798
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
799
|
+
return res.end(JSON.stringify({ error: 'Invalid status filter: "' + rawStatus + '". Valid: ' + VALID_STATUSES.join(', ') }));
|
|
800
|
+
}
|
|
801
|
+
var statusFilter = rawStatus;
|
|
802
|
+
const projectFilter = url.searchParams.get("project") || undefined;
|
|
803
|
+
const pipelines = await s.listPipelines(projectFilter, statusFilter, PRISM_USER_ID);
|
|
804
|
+
// Parse spec JSON for frontend consumption
|
|
805
|
+
const enriched = pipelines.map((p) => {
|
|
806
|
+
let parsedSpec = null;
|
|
807
|
+
try {
|
|
808
|
+
parsedSpec = JSON.parse(p.spec);
|
|
809
|
+
}
|
|
810
|
+
catch { /* corrupt spec */ }
|
|
811
|
+
return { ...p, parsedSpec };
|
|
812
|
+
});
|
|
813
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
814
|
+
return res.end(JSON.stringify({ pipelines: enriched }));
|
|
815
|
+
}
|
|
816
|
+
catch (err) {
|
|
817
|
+
console.error("[Dashboard] Pipeline list error:", err);
|
|
818
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
819
|
+
return res.end(JSON.stringify({ error: "Failed to list pipelines" }));
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
// GET /api/pipelines/:id — Single pipeline detail
|
|
823
|
+
if (url.pathname.startsWith("/api/pipelines/") && !url.pathname.includes("/abort") && req.method === "GET") {
|
|
824
|
+
try {
|
|
825
|
+
const pipelineId = url.pathname.replace("/api/pipelines/", "");
|
|
826
|
+
if (!pipelineId) {
|
|
827
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
828
|
+
return res.end(JSON.stringify({ error: "Missing pipeline ID" }));
|
|
829
|
+
}
|
|
830
|
+
const s = await getStorageSafe();
|
|
831
|
+
if (!s) {
|
|
832
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
833
|
+
return res.end(JSON.stringify({ error: "Storage initializing..." }));
|
|
834
|
+
}
|
|
835
|
+
const pipeline = await s.getPipeline(pipelineId, PRISM_USER_ID);
|
|
836
|
+
if (!pipeline) {
|
|
837
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
838
|
+
return res.end(JSON.stringify({ error: "Pipeline not found" }));
|
|
839
|
+
}
|
|
840
|
+
let parsedSpec = null;
|
|
841
|
+
try {
|
|
842
|
+
parsedSpec = JSON.parse(pipeline.spec);
|
|
843
|
+
}
|
|
844
|
+
catch { /* corrupt spec */ }
|
|
845
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
846
|
+
return res.end(JSON.stringify({ ...pipeline, parsedSpec }));
|
|
847
|
+
}
|
|
848
|
+
catch (err) {
|
|
849
|
+
console.error("[Dashboard] Pipeline detail error:", err);
|
|
850
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
851
|
+
return res.end(JSON.stringify({ error: "Failed to get pipeline" }));
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
// POST /api/pipelines/:id/abort — Dashboard kill switch
|
|
855
|
+
if (url.pathname.match(/^\/api\/pipelines\/[^/]+\/abort$/) && req.method === "POST") {
|
|
856
|
+
try {
|
|
857
|
+
const pipelineId = url.pathname.replace("/api/pipelines/", "").replace("/abort", "");
|
|
858
|
+
const s = await getStorageSafe();
|
|
859
|
+
if (!s) {
|
|
860
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
861
|
+
return res.end(JSON.stringify({ error: "Storage initializing..." }));
|
|
862
|
+
}
|
|
863
|
+
const pipeline = await s.getPipeline(pipelineId, PRISM_USER_ID);
|
|
864
|
+
if (!pipeline) {
|
|
865
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
866
|
+
return res.end(JSON.stringify({ error: "Pipeline not found" }));
|
|
867
|
+
}
|
|
868
|
+
// Already terminated?
|
|
869
|
+
if (["COMPLETED", "FAILED", "ABORTED"].includes(pipeline.status)) {
|
|
870
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
871
|
+
return res.end(JSON.stringify({ ok: true, status: pipeline.status, message: `Pipeline already in terminal state: ${pipeline.status}` }));
|
|
872
|
+
}
|
|
873
|
+
await s.savePipeline({
|
|
874
|
+
...pipeline,
|
|
875
|
+
status: "ABORTED",
|
|
876
|
+
error: "Manually aborted via dashboard kill switch.",
|
|
877
|
+
});
|
|
878
|
+
console.error(`[Dashboard] Pipeline ${pipelineId} aborted via dashboard.`);
|
|
879
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
880
|
+
return res.end(JSON.stringify({ ok: true, status: "ABORTED", message: "Pipeline aborted. Runner will stop on next tick." }));
|
|
881
|
+
}
|
|
882
|
+
catch (err) {
|
|
883
|
+
console.error("[Dashboard] Pipeline abort error:", err);
|
|
884
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
885
|
+
return res.end(JSON.stringify({ error: "Failed to abort pipeline" }));
|
|
886
|
+
}
|
|
887
|
+
}
|
|
785
888
|
if (url.pathname === "/manifest.json" && req.method === "GET") {
|
|
786
889
|
const manifest = {
|
|
787
890
|
name: "Prism Mind Palace",
|