singleton-pipeline 0.4.0-beta.0

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,208 @@
1
+ import { spawn } from 'node:child_process';
2
+ import path from 'node:path';
3
+ import { extractText, safeJsonParse } from './_shared.js';
4
+
5
+ const DEFAULT_TIMEOUT_MS = Number(process.env.SINGLETON_RUNNER_TIMEOUT_MS) || 10 * 60 * 1000;
6
+
7
+ function buildPrompt(systemPrompt, userPrompt) {
8
+ return [
9
+ '<system>',
10
+ systemPrompt,
11
+ '</system>',
12
+ '',
13
+ '<user>',
14
+ userPrompt,
15
+ '</user>',
16
+ '',
17
+ ].join('\n');
18
+ }
19
+
20
+ function normalizeToolPath(value) {
21
+ return String(value || '').replaceAll('\\', '/').replace(/^\/+/, '').replace(/\/+$/, '');
22
+ }
23
+
24
+ function hasGlob(value) {
25
+ return /[*?[\]{}]/.test(value);
26
+ }
27
+
28
+ function looksLikeFile(value) {
29
+ const base = path.posix.basename(value);
30
+ return Boolean(path.posix.extname(value)) || base === '.env';
31
+ }
32
+
33
+ function toWritePattern(entry) {
34
+ const normalized = normalizeToolPath(entry);
35
+ if (!normalized) return null;
36
+ if (hasGlob(normalized) || looksLikeFile(normalized)) return normalized;
37
+ return `${normalized}/**`;
38
+ }
39
+
40
+ export function buildCopilotPermissionArgs(securityPolicy = {}) {
41
+ const profile = securityPolicy.profile || 'workspace-write';
42
+ const args = [];
43
+
44
+ if (profile === 'dangerous') {
45
+ args.push('--allow-all-tools');
46
+ } else {
47
+ args.push('--allow-tool=read');
48
+ }
49
+
50
+ if (profile === 'restricted-write') {
51
+ for (const entry of securityPolicy.allowedPaths || []) {
52
+ const pattern = toWritePattern(entry);
53
+ if (pattern) args.push(`--allow-tool=write(${pattern})`);
54
+ }
55
+ } else if (profile === 'workspace-write') {
56
+ args.push('--allow-tool=write');
57
+ }
58
+
59
+ if (profile === 'read-only') {
60
+ args.push('--deny-tool=write');
61
+ args.push('--deny-tool=shell');
62
+ } else {
63
+ args.push('--deny-tool=shell(git push)');
64
+ }
65
+
66
+ if (profile !== 'dangerous') {
67
+ args.push('--deny-tool=url');
68
+ }
69
+
70
+ for (const entry of securityPolicy.blockedPaths || []) {
71
+ const pattern = toWritePattern(entry);
72
+ if (pattern) args.push(`--deny-tool=write(${pattern})`);
73
+ }
74
+
75
+ args.push('--deny-tool=memory');
76
+ return args;
77
+ }
78
+
79
+ export function buildCopilotArgs({ prompt, model, runnerAgent, securityPolicy = {} } = {}) {
80
+ const args = [
81
+ '-p',
82
+ prompt,
83
+ '--output-format',
84
+ 'json',
85
+ ...buildCopilotPermissionArgs(securityPolicy),
86
+ ];
87
+ if (runnerAgent) args.push('--agent', runnerAgent);
88
+ if (model) args.push('--model', model);
89
+ return args;
90
+ }
91
+
92
+ export function summarizeCopilotEvents(events) {
93
+ const assistantMessages = events
94
+ .filter((event) => event.type === 'assistant.message')
95
+ .map((event) => extractText(event.data))
96
+ .filter(Boolean);
97
+ const deltaText = events
98
+ .filter((event) => event.type === 'assistant.message_delta')
99
+ .map((event) => event.data?.deltaContent || event.data?.delta || '')
100
+ .filter(Boolean)
101
+ .join('');
102
+ const result = [...events].reverse().find((event) => event.type === 'result') || null;
103
+ const outputTokens = events.reduce((total, event) => {
104
+ if (event.type !== 'assistant.message') return total;
105
+ return total + (Number(event.data?.outputTokens || 0) || 0);
106
+ }, 0);
107
+
108
+ return {
109
+ text: assistantMessages.join('\n').trim() || deltaText.trim(),
110
+ turns: events.filter((event) => event.type === 'assistant.message').length || null,
111
+ outputTokens: outputTokens || null,
112
+ premiumRequests: Number(result?.usage?.premiumRequests || 0) || null,
113
+ result,
114
+ };
115
+ }
116
+
117
+ export function extractCopilotErrorMessage(event) {
118
+ if (!event || typeof event !== 'object') return '';
119
+ if (typeof event.error === 'string') return event.error;
120
+ if (typeof event.message === 'string') return event.message;
121
+ if (typeof event.error?.message === 'string') return event.error.message;
122
+ if (typeof event.error?.data?.message === 'string') return event.error.data.message;
123
+ if (typeof event.data?.message === 'string') return event.data.message;
124
+ return extractText(event);
125
+ }
126
+
127
+ export const copilotRunner = {
128
+ id: 'copilot',
129
+ command: 'copilot',
130
+
131
+ async run({
132
+ cwd,
133
+ systemPrompt,
134
+ userPrompt,
135
+ model,
136
+ runnerAgent,
137
+ securityPolicy,
138
+ timeoutMs = DEFAULT_TIMEOUT_MS,
139
+ }) {
140
+ const prompt = buildPrompt(systemPrompt, userPrompt);
141
+ const args = buildCopilotArgs({ prompt, model, runnerAgent, securityPolicy });
142
+
143
+ const { events, stderr } = await new Promise((resolve, reject) => {
144
+ const child = spawn('copilot', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
145
+ const stdoutChunks = [];
146
+ let stderrText = '';
147
+ let timedOut = false;
148
+
149
+ const timer = setTimeout(() => {
150
+ timedOut = true;
151
+ child.kill('SIGTERM');
152
+ setTimeout(() => child.kill('SIGKILL'), 5000).unref();
153
+ }, timeoutMs);
154
+
155
+ child.stdout.on('data', (d) => stdoutChunks.push(d.toString()));
156
+ child.stderr.on('data', (d) => (stderrText += d.toString()));
157
+ child.on('error', (err) => {
158
+ clearTimeout(timer);
159
+ reject(err);
160
+ });
161
+ child.on('close', (code) => {
162
+ clearTimeout(timer);
163
+ const stdout = stdoutChunks.join('');
164
+ const events = stdout
165
+ .split('\n')
166
+ .map((line) => line.trim())
167
+ .filter(Boolean)
168
+ .map(safeJsonParse)
169
+ .filter(Boolean);
170
+
171
+ if (timedOut) {
172
+ reject(new Error(`copilot timed out after ${Math.round(timeoutMs / 1000)}s`));
173
+ return;
174
+ }
175
+ if (code !== 0) {
176
+ const result = [...events].reverse().find((event) => event.type === 'result');
177
+ const message = extractCopilotErrorMessage(result) || stderrText.trim() || stdout.trim() || 'unknown error';
178
+ reject(new Error(`copilot exited ${code}: ${message}`));
179
+ return;
180
+ }
181
+
182
+ resolve({ events, stderr: stderrText });
183
+ });
184
+ });
185
+
186
+ const summary = summarizeCopilotEvents(events);
187
+ return {
188
+ text: summary.text,
189
+ metadata: {
190
+ provider: 'copilot',
191
+ model: model || null,
192
+ runnerAgent: runnerAgent || null,
193
+ turns: summary.turns,
194
+ costUsd: null,
195
+ tokens: {
196
+ input: null,
197
+ output: summary.outputTokens,
198
+ },
199
+ premiumRequests: summary.premiumRequests,
200
+ raw: {
201
+ events,
202
+ stderr,
203
+ result: summary.result,
204
+ },
205
+ },
206
+ };
207
+ },
208
+ };
@@ -0,0 +1,20 @@
1
+ import { claudeRunner } from './claude.js';
2
+ import { codexRunner } from './codex.js';
3
+ import { copilotRunner } from './copilot.js';
4
+ import { opencodeRunner } from './opencode.js';
5
+
6
+ const RUNNERS = {
7
+ claude: claudeRunner,
8
+ codex: codexRunner,
9
+ copilot: copilotRunner,
10
+ opencode: opencodeRunner,
11
+ };
12
+
13
+ export function getRunner(provider = 'claude') {
14
+ const key = String(provider || 'claude').trim().toLowerCase();
15
+ const runner = RUNNERS[key];
16
+ if (!runner) {
17
+ throw new Error(`Unknown provider: ${provider}`);
18
+ }
19
+ return runner;
20
+ }
@@ -0,0 +1,265 @@
1
+ import { spawn } from 'node:child_process';
2
+ import path from 'node:path';
3
+ import { extractText, findCostUsd, findUsage, safeJsonParse } from './_shared.js';
4
+
5
+ const DEFAULT_TIMEOUT_MS = Number(process.env.SINGLETON_RUNNER_TIMEOUT_MS) || 10 * 60 * 1000;
6
+
7
+ function buildPrompt(systemPrompt, userPrompt) {
8
+ return [
9
+ '<system>',
10
+ systemPrompt,
11
+ '</system>',
12
+ '',
13
+ '<user>',
14
+ userPrompt,
15
+ '</user>',
16
+ '',
17
+ ].join('\n');
18
+ }
19
+
20
+ function isAssistantEvent(event) {
21
+ const type = String(event?.type || '').toLowerCase();
22
+ const role = String(event?.role || event?.data?.role || event?.message?.role || '').toLowerCase();
23
+ return role === 'assistant' || type === 'text' || type.includes('assistant') || type.includes('message');
24
+ }
25
+
26
+ export function extractOpenCodeErrorMessage(event) {
27
+ if (!event || typeof event !== 'object') return '';
28
+ if (typeof event.error === 'string') return event.error;
29
+ if (typeof event.message === 'string') return event.message;
30
+ if (typeof event.error?.message === 'string') return event.error.message;
31
+ if (typeof event.error?.data?.message === 'string') return event.error.data.message;
32
+ if (typeof event.data?.message === 'string') return event.data.message;
33
+ return extractText(event);
34
+ }
35
+
36
+ function normalizePermissionPath(value) {
37
+ return String(value || '').replaceAll('\\', '/').replace(/^\/+/, '').replace(/\/+$/, '');
38
+ }
39
+
40
+ function hasGlob(value) {
41
+ return /[*?[\]{}]/.test(value);
42
+ }
43
+
44
+ function looksLikeFile(value) {
45
+ const base = path.posix.basename(value);
46
+ return Boolean(path.posix.extname(value)) || base === '.env';
47
+ }
48
+
49
+ function toOpenCodePathPattern(entry) {
50
+ const normalized = normalizePermissionPath(entry);
51
+ if (!normalized) return null;
52
+ if (hasGlob(normalized) || looksLikeFile(normalized)) return normalized;
53
+ return `${normalized}/**`;
54
+ }
55
+
56
+ function buildEditPermission(securityPolicy) {
57
+ const profile = securityPolicy?.profile || 'workspace-write';
58
+ const blocked = (securityPolicy?.blockedPaths || [])
59
+ .map(toOpenCodePathPattern)
60
+ .filter(Boolean);
61
+
62
+ if (profile === 'read-only') return 'deny';
63
+ if (profile === 'dangerous') return 'allow';
64
+
65
+ if (profile === 'restricted-write') {
66
+ const edit = { '*': 'deny' };
67
+ for (const entry of securityPolicy?.allowedPaths || []) {
68
+ const pattern = toOpenCodePathPattern(entry);
69
+ if (pattern) edit[pattern] = 'allow';
70
+ }
71
+ for (const pattern of blocked) edit[pattern] = 'deny';
72
+ return edit;
73
+ }
74
+
75
+ if (blocked.length) {
76
+ const edit = { '*': 'allow' };
77
+ for (const pattern of blocked) edit[pattern] = 'deny';
78
+ return edit;
79
+ }
80
+
81
+ return 'allow';
82
+ }
83
+
84
+ export function buildOpenCodePermissionConfig(securityPolicy = {}) {
85
+ const profile = securityPolicy.profile || 'workspace-write';
86
+
87
+ if (profile === 'dangerous') {
88
+ return { permission: 'allow' };
89
+ }
90
+
91
+ const permission = {
92
+ read: 'allow',
93
+ glob: 'allow',
94
+ grep: 'allow',
95
+ edit: buildEditPermission(securityPolicy),
96
+ bash: profile === 'read-only' ? 'deny' : 'ask',
97
+ task: 'deny',
98
+ webfetch: 'deny',
99
+ websearch: 'deny',
100
+ external_directory: 'deny',
101
+ doom_loop: 'ask',
102
+ };
103
+
104
+ return { permission };
105
+ }
106
+
107
+ export function buildOpenCodeConfigContent({ securityPolicy = {}, runnerAgent = '', existingContent = '' } = {}) {
108
+ let config = {};
109
+ if (existingContent) {
110
+ try {
111
+ config = JSON.parse(existingContent);
112
+ } catch {
113
+ config = {};
114
+ }
115
+ }
116
+
117
+ const permissionConfig = buildOpenCodePermissionConfig(securityPolicy);
118
+ config = {
119
+ ...config,
120
+ ...permissionConfig,
121
+ };
122
+
123
+ if (runnerAgent && permissionConfig.permission && typeof permissionConfig.permission === 'object') {
124
+ config.agent = {
125
+ ...(config.agent || {}),
126
+ [runnerAgent]: {
127
+ ...(config.agent?.[runnerAgent] || {}),
128
+ permission: permissionConfig.permission,
129
+ },
130
+ };
131
+ }
132
+
133
+ return JSON.stringify(config);
134
+ }
135
+
136
+ export function buildOpenCodeEnv({ securityPolicy = {}, runnerAgent = '', baseEnv = process.env } = {}) {
137
+ // Note: we deliberately do NOT redirect XDG_DATA_HOME here. OpenCode stores
138
+ // provider auth (anthropic, openai, …) under XDG_DATA_HOME, so isolating
139
+ // it would strip API credentials from the spawned process. Security is
140
+ // enforced via OPENCODE_CONFIG_CONTENT (native permissions injection) and
141
+ // Singleton's post-run snapshot diff — both of which work without isolating
142
+ // OpenCode's data dir.
143
+ return {
144
+ ...baseEnv,
145
+ OPENCODE_CONFIG_CONTENT: buildOpenCodeConfigContent({
146
+ securityPolicy,
147
+ runnerAgent,
148
+ existingContent: baseEnv.OPENCODE_CONFIG_CONTENT || '',
149
+ }),
150
+ };
151
+ }
152
+
153
+ export function buildOpenCodeArgs({ prompt, model, runnerAgent, securityPolicy = {} }) {
154
+ const args = ['run', '--format', 'json'];
155
+ if (model) args.push('--model', model);
156
+ if (runnerAgent) args.push('--agent', runnerAgent);
157
+ if (securityPolicy.profile === 'dangerous') args.push('--dangerously-skip-permissions');
158
+ args.push(prompt);
159
+ return args;
160
+ }
161
+
162
+ export function summarizeOpenCodeEvents(events, stdout = '') {
163
+ const assistantText = events
164
+ .filter(isAssistantEvent)
165
+ .map((event) => extractText(event))
166
+ .filter(Boolean)
167
+ .join('\n')
168
+ .trim();
169
+ const usage = [...events].reverse().map(findUsage).find(Boolean) || null;
170
+ const costUsd = [...events].reverse().map(findCostUsd).find((value) => value !== null) ?? null;
171
+ const turns = events.filter(isAssistantEvent).length || null;
172
+
173
+ return {
174
+ text: assistantText || stdout.trim(),
175
+ turns,
176
+ costUsd,
177
+ tokens: usage,
178
+ };
179
+ }
180
+
181
+ export const opencodeRunner = {
182
+ id: 'opencode',
183
+ command: 'opencode',
184
+
185
+ async run({
186
+ cwd,
187
+ systemPrompt,
188
+ userPrompt,
189
+ model,
190
+ runnerAgent,
191
+ securityPolicy,
192
+ timeoutMs = DEFAULT_TIMEOUT_MS,
193
+ }) {
194
+ const prompt = buildPrompt(systemPrompt, userPrompt);
195
+ const args = buildOpenCodeArgs({ prompt, model, runnerAgent, securityPolicy });
196
+ const env = buildOpenCodeEnv({
197
+ securityPolicy,
198
+ runnerAgent,
199
+ });
200
+
201
+ const runResult = await new Promise((resolve, reject) => {
202
+ const child = spawn('opencode', args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
203
+ const stdoutChunks = [];
204
+ let stderrText = '';
205
+ let timedOut = false;
206
+
207
+ const timer = setTimeout(() => {
208
+ timedOut = true;
209
+ child.kill('SIGTERM');
210
+ setTimeout(() => child.kill('SIGKILL'), 5000).unref();
211
+ }, timeoutMs);
212
+
213
+ child.stdout.on('data', (d) => stdoutChunks.push(d.toString()));
214
+ child.stderr.on('data', (d) => (stderrText += d.toString()));
215
+ child.on('error', (err) => {
216
+ clearTimeout(timer);
217
+ reject(err);
218
+ });
219
+ child.on('close', (code) => {
220
+ clearTimeout(timer);
221
+ const stdout = stdoutChunks.join('');
222
+ const events = stdout
223
+ .split('\n')
224
+ .map((line) => line.trim())
225
+ .filter(Boolean)
226
+ .map(safeJsonParse)
227
+ .filter(Boolean);
228
+
229
+ if (timedOut) {
230
+ reject(new Error(`opencode timed out after ${Math.round(timeoutMs / 1000)}s`));
231
+ return;
232
+ }
233
+ const errorEvent = [...events].reverse().find((event) => event.type === 'error' || event.error);
234
+ if (errorEvent) {
235
+ const message = extractOpenCodeErrorMessage(errorEvent) || stderrText.trim() || stdout.trim() || 'unknown error';
236
+ reject(new Error(`opencode error: ${message}`));
237
+ return;
238
+ }
239
+ if (code !== 0) {
240
+ const result = [...events].reverse().find((event) => event.type === 'result' || event.error || event.message);
241
+ const message = extractOpenCodeErrorMessage(result) || stderrText.trim() || stdout.trim() || 'unknown error';
242
+ reject(new Error(`opencode exited ${code}: ${message}`));
243
+ return;
244
+ }
245
+
246
+ resolve({ events, stdout, stderr: stderrText });
247
+ });
248
+ });
249
+
250
+ const { events, stdout, stderr } = runResult;
251
+ const summary = summarizeOpenCodeEvents(events, stdout);
252
+ return {
253
+ text: summary.text,
254
+ metadata: {
255
+ provider: 'opencode',
256
+ model: model || null,
257
+ runnerAgent: runnerAgent || null,
258
+ turns: summary.turns,
259
+ costUsd: summary.costUsd,
260
+ tokens: summary.tokens,
261
+ raw: { events, stdout, stderr },
262
+ },
263
+ };
264
+ },
265
+ };
@@ -0,0 +1,47 @@
1
+ import fg from 'fast-glob';
2
+ import fs from 'node:fs/promises';
3
+ import { parseAgentFile } from './parser.js';
4
+
5
+ const SOURCES = [
6
+ { kind: 'singleton', pattern: '.singleton/agents/*.md', priority: 3 },
7
+ { kind: 'claude', pattern: '.claude/agents/*.md', priority: 2 },
8
+ { kind: 'copilot', pattern: '.github/agents/*.md', priority: 2 },
9
+ { kind: 'opencode', pattern: '.opencode/agents/*.md', priority: 2 },
10
+ ];
11
+
12
+ function normalizeAgent(agent, source) {
13
+ return {
14
+ ...agent,
15
+ source,
16
+ provider: agent.provider || (['claude', 'copilot', 'opencode'].includes(source) ? source : undefined),
17
+ };
18
+ }
19
+
20
+ export async function scanAgents(root) {
21
+ const selected = new Map();
22
+
23
+ for (const source of SOURCES) {
24
+ const files = await fg([source.pattern], {
25
+ cwd: root,
26
+ absolute: true,
27
+ ignore: source.ignore || [],
28
+ });
29
+
30
+ for (const file of files) {
31
+ const content = await fs.readFile(file, 'utf8');
32
+ const parsed = parseAgentFile(content, file);
33
+ if (!parsed) continue;
34
+
35
+ const agent = normalizeAgent(parsed, source.kind);
36
+ const existing = selected.get(agent.id);
37
+
38
+ if (!existing || source.priority > existing.priority) {
39
+ selected.set(agent.id, { ...agent, priority: source.priority });
40
+ }
41
+ }
42
+ }
43
+
44
+ return [...selected.values()]
45
+ .map(({ priority, ...agent }) => agent)
46
+ .sort((a, b) => a.id.localeCompare(b.id));
47
+ }
@@ -0,0 +1,126 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs/promises';
3
+
4
+ const VALID_PROFILES = new Set(['read-only', 'workspace-write', 'restricted-write', 'dangerous']);
5
+ const DEFAULT_PROFILE = 'workspace-write';
6
+ const DEFAULT_BLOCKED_PATHS = ['.git', 'node_modules', '.env', '.env.*', '.ssh'];
7
+ const DEFAULT_COMMIT_EXCLUDE_PATHS = ['.singleton'];
8
+
9
+ function asList(value) {
10
+ return Array.isArray(value) ? value.filter(Boolean) : [];
11
+ }
12
+
13
+ function isInside(parent, child) {
14
+ const rel = path.relative(parent, child);
15
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
16
+ }
17
+
18
+ function normalizeRel(value) {
19
+ return String(value || '').replaceAll('\\', '/').replace(/^\/+/, '').replace(/\/+$/, '');
20
+ }
21
+
22
+ function matchesPattern(relPath, pattern) {
23
+ const rel = normalizeRel(relPath);
24
+ const pat = normalizeRel(pattern);
25
+ if (!pat) return false;
26
+ if (pat.endsWith('.*')) {
27
+ const prefix = pat.slice(0, -1);
28
+ return rel === pat.slice(0, -2) || rel.startsWith(prefix);
29
+ }
30
+ return rel === pat || rel.startsWith(`${pat}/`);
31
+ }
32
+
33
+ function resolvePath(root, value) {
34
+ return path.isAbsolute(value) ? path.resolve(value) : path.resolve(root, value);
35
+ }
36
+
37
+ export function resolveSecurityPolicy(step = {}, agent = {}) {
38
+ return resolveSecurityPolicyWithConfig(step, agent);
39
+ }
40
+
41
+ export async function loadProjectSecurityConfig(root) {
42
+ const file = path.join(root, '.singleton', 'security.json');
43
+ try {
44
+ const raw = await fs.readFile(file, 'utf8');
45
+ const config = JSON.parse(raw);
46
+ return {
47
+ file,
48
+ defaultProfile: config.default_profile || DEFAULT_PROFILE,
49
+ allowedPaths: asList(config.allowed_paths),
50
+ blockedPaths: asList(config.blocked_paths),
51
+ commit: {
52
+ excludePaths: [
53
+ ...DEFAULT_COMMIT_EXCLUDE_PATHS,
54
+ ...asList(config.commit?.exclude_paths),
55
+ ],
56
+ requireConfirmation: config.commit?.require_confirmation !== false,
57
+ },
58
+ };
59
+ } catch (err) {
60
+ if (err.code !== 'ENOENT') {
61
+ throw new Error(`Invalid project security config: ${file} (${err.message})`);
62
+ }
63
+ return {
64
+ file,
65
+ defaultProfile: DEFAULT_PROFILE,
66
+ allowedPaths: [],
67
+ blockedPaths: [],
68
+ commit: {
69
+ excludePaths: DEFAULT_COMMIT_EXCLUDE_PATHS,
70
+ requireConfirmation: true,
71
+ },
72
+ };
73
+ }
74
+ }
75
+
76
+ export function resolveSecurityPolicyWithConfig(step = {}, agent = {}, projectConfig = {}) {
77
+ const profile = step.security_profile || agent.security_profile || projectConfig.defaultProfile || DEFAULT_PROFILE;
78
+ return {
79
+ profile,
80
+ allowedPaths: asList(step.allowed_paths ?? agent.allowed_paths ?? projectConfig.allowedPaths),
81
+ blockedPaths: [
82
+ ...DEFAULT_BLOCKED_PATHS,
83
+ ...asList(projectConfig.blockedPaths),
84
+ ...asList(step.blocked_paths ?? agent.blocked_paths),
85
+ ],
86
+ };
87
+ }
88
+
89
+ export function validateSecurityPolicy(policy) {
90
+ const errors = [];
91
+ if (!VALID_PROFILES.has(policy.profile)) {
92
+ errors.push(`unknown security_profile "${policy.profile}"`);
93
+ }
94
+ if (policy.profile === 'restricted-write' && policy.allowedPaths.length === 0) {
95
+ errors.push('restricted-write requires at least one allowed_paths entry');
96
+ }
97
+ return errors;
98
+ }
99
+
100
+ export function assertWriteAllowed(absTarget, { root, agentName, outputName, policy }) {
101
+ const absRoot = path.resolve(root);
102
+ const absPath = path.resolve(absTarget);
103
+ const rel = path.relative(absRoot, absPath);
104
+
105
+ if (!isInside(absRoot, absPath)) {
106
+ throw new Error(`Step "${agentName}" output "${outputName}" resolves outside the project root: ${absPath}`);
107
+ }
108
+
109
+ if (policy.profile === 'read-only') {
110
+ throw new Error(`Step "${agentName}" output "${outputName}" is blocked by read-only security_profile: ${rel}`);
111
+ }
112
+
113
+ if (policy.profile !== 'dangerous') {
114
+ const blocked = policy.blockedPaths.find((pattern) => matchesPattern(rel, pattern));
115
+ if (blocked) {
116
+ throw new Error(`Step "${agentName}" output "${outputName}" is blocked by security policy "${blocked}": ${rel}`);
117
+ }
118
+ }
119
+
120
+ if (policy.profile === 'restricted-write') {
121
+ const allowed = policy.allowedPaths.some((entry) => isInside(resolvePath(absRoot, entry), absPath));
122
+ if (!allowed) {
123
+ throw new Error(`Step "${agentName}" output "${outputName}" is outside allowed_paths: ${rel}`);
124
+ }
125
+ }
126
+ }