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.
@@ -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
+ }
@@ -0,0 +1,4 @@
1
+ /** All valid action type strings for runtime validation */
2
+ export const VALID_ACTION_TYPES = [
3
+ 'READ_FILE', 'WRITE_FILE', 'PATCH_FILE', 'RUN_TEST'
4
+ ];
@@ -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",