vgxness 1.20.3 → 1.20.5
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/dist/agents/canonical-agent-manifest.js +14 -12
- package/dist/agents/canonical-agent-projection.js +3 -2
- package/dist/agents/manager-profile-overlay-service.js +13 -5
- package/dist/cli/bun-bin.js +0 -0
- package/dist/cli/cli-flags.js +47 -1
- package/dist/cli/commands/run-permission-dispatcher.js +152 -1
- package/dist/cli/dispatcher.js +6 -1
- package/dist/governance/risk-classifier.js +2 -0
- package/dist/mcp/control-plane.js +34 -0
- package/dist/mcp/schema.js +144 -0
- package/dist/mcp/stdio-server.js +6 -0
- package/dist/mcp/validation.js +359 -2
- package/dist/memory/sqlite/migrations/019_task_scoped_command_grants.sql +37 -0
- package/dist/permissions/explicit-request-authorization.js +126 -0
- package/dist/permissions/path-boundaries.js +87 -0
- package/dist/permissions/policy-evaluator.js +42 -9
- package/dist/permissions/task-scoped-command-grant-matcher.js +117 -0
- package/dist/permissions/task-scoped-command-grant-validation.js +135 -0
- package/dist/runs/repositories/task-scoped-command-grants.js +462 -0
- package/dist/runs/run-service.js +91 -1
- package/dist/runs/sandbox-process-execution.js +41 -30
- package/dist/runs/task-scoped-command-grant-service.js +217 -0
- package/dist/workflows/command-allowlist-adapter.js +50 -0
- package/docs/safety.md +23 -0
- package/package.json +1 -1
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
const grantableCategories = new Set(['shell', 'test-run', 'git']);
|
|
3
|
+
const grantStatuses = new Set(['active', 'revoked', 'expired', 'exhausted']);
|
|
4
|
+
export class TaskScopedCommandGrantRepository {
|
|
5
|
+
db;
|
|
6
|
+
constructor(db) {
|
|
7
|
+
this.db = db;
|
|
8
|
+
}
|
|
9
|
+
create(input) {
|
|
10
|
+
const validation = validateCreate(input);
|
|
11
|
+
if (!validation.ok)
|
|
12
|
+
return validation;
|
|
13
|
+
const run = this.getRunId(input.runId);
|
|
14
|
+
if (!run.ok)
|
|
15
|
+
return run;
|
|
16
|
+
const result = this.db.transaction(() => {
|
|
17
|
+
const id = randomUUID();
|
|
18
|
+
const createdAt = new Date().toISOString();
|
|
19
|
+
this.db.connection
|
|
20
|
+
.prepare(`
|
|
21
|
+
INSERT INTO task_scoped_command_grants(
|
|
22
|
+
id, run_id, agent_id, task_fingerprint, categories_json, external_roots_json,
|
|
23
|
+
canonical_external_roots_json, cwd_patterns_json, command_policy_json, expires_at,
|
|
24
|
+
max_uses, used_count, status, created_by_json, created_at, audit_json
|
|
25
|
+
) VALUES (
|
|
26
|
+
@id, @runId, @agentId, @taskFingerprint, @categoriesJson, @externalRootsJson,
|
|
27
|
+
@canonicalExternalRootsJson, @cwdPatternsJson, @commandPolicyJson, @expiresAt,
|
|
28
|
+
@maxUses, 0, 'active', @createdByJson, @createdAt, @auditJson
|
|
29
|
+
)
|
|
30
|
+
`)
|
|
31
|
+
.run({
|
|
32
|
+
id,
|
|
33
|
+
runId: input.runId,
|
|
34
|
+
agentId: input.agentId,
|
|
35
|
+
taskFingerprint: input.taskFingerprint,
|
|
36
|
+
categoriesJson: JSON.stringify(input.categories),
|
|
37
|
+
externalRootsJson: JSON.stringify(input.externalRoots),
|
|
38
|
+
canonicalExternalRootsJson: JSON.stringify(input.canonicalExternalRoots),
|
|
39
|
+
cwdPatternsJson: input.cwdPatterns === undefined ? null : JSON.stringify(input.cwdPatterns),
|
|
40
|
+
commandPolicyJson: JSON.stringify(input.commandPolicy),
|
|
41
|
+
expiresAt: input.expiresAt,
|
|
42
|
+
maxUses: input.maxUses,
|
|
43
|
+
createdByJson: JSON.stringify(input.createdBy),
|
|
44
|
+
createdAt,
|
|
45
|
+
auditJson: JSON.stringify(input.audit),
|
|
46
|
+
});
|
|
47
|
+
this.insertEvent({
|
|
48
|
+
runId: input.runId,
|
|
49
|
+
title: 'Task-scoped command grant created',
|
|
50
|
+
payload: {
|
|
51
|
+
grantId: id,
|
|
52
|
+
agentId: input.agentId,
|
|
53
|
+
taskFingerprint: input.taskFingerprint,
|
|
54
|
+
categories: input.categories,
|
|
55
|
+
commandPolicy: { kind: input.commandPolicy.kind, ids: input.commandPolicy.ids },
|
|
56
|
+
expiresAt: input.expiresAt,
|
|
57
|
+
maxUses: input.maxUses,
|
|
58
|
+
approvedScopeSummary: input.audit.approvedScopeSummary,
|
|
59
|
+
},
|
|
60
|
+
relatedId: id,
|
|
61
|
+
});
|
|
62
|
+
const created = this.get(id);
|
|
63
|
+
if (!created.ok)
|
|
64
|
+
return created;
|
|
65
|
+
return ok(created.value);
|
|
66
|
+
});
|
|
67
|
+
return result.ok ? result.value : fail(result.error.message, result.error.cause, result.error.code);
|
|
68
|
+
}
|
|
69
|
+
get(id) {
|
|
70
|
+
if (!id.trim())
|
|
71
|
+
return validationFailure('Grant id is required');
|
|
72
|
+
try {
|
|
73
|
+
const row = this.db.connection.prepare('SELECT * FROM task_scoped_command_grants WHERE id=?').get(id);
|
|
74
|
+
if (row === undefined)
|
|
75
|
+
return missing(`Task-scoped command grant not found: ${id}`);
|
|
76
|
+
return mapGrantRow(row);
|
|
77
|
+
}
|
|
78
|
+
catch (cause) {
|
|
79
|
+
return fail('Failed to read task-scoped command grant', cause);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
list(filters = {}) {
|
|
83
|
+
if (filters.limit !== undefined && (!Number.isInteger(filters.limit) || filters.limit < 1))
|
|
84
|
+
return validationFailure('Grant list limit must be positive');
|
|
85
|
+
if (filters.status !== undefined && !grantStatuses.has(filters.status))
|
|
86
|
+
return validationFailure(`Unsupported grant status: ${filters.status}`);
|
|
87
|
+
try {
|
|
88
|
+
const where = [];
|
|
89
|
+
const parameters = {};
|
|
90
|
+
if (filters.runId !== undefined) {
|
|
91
|
+
where.push('run_id=@runId');
|
|
92
|
+
parameters.runId = filters.runId;
|
|
93
|
+
}
|
|
94
|
+
if (filters.agentId !== undefined) {
|
|
95
|
+
where.push('agent_id=@agentId');
|
|
96
|
+
parameters.agentId = filters.agentId;
|
|
97
|
+
}
|
|
98
|
+
if (filters.status !== undefined) {
|
|
99
|
+
where.push('status=@status');
|
|
100
|
+
parameters.status = filters.status;
|
|
101
|
+
}
|
|
102
|
+
if (filters.limit !== undefined)
|
|
103
|
+
parameters.limit = filters.limit;
|
|
104
|
+
const rows = this.db.connection
|
|
105
|
+
.prepare(`
|
|
106
|
+
SELECT * FROM task_scoped_command_grants
|
|
107
|
+
${where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''}
|
|
108
|
+
ORDER BY created_at DESC, id ASC
|
|
109
|
+
${filters.limit === undefined ? '' : 'LIMIT @limit'}
|
|
110
|
+
`)
|
|
111
|
+
.all(parameters);
|
|
112
|
+
const grants = [];
|
|
113
|
+
for (const row of rows) {
|
|
114
|
+
const mapped = mapGrantRow(row);
|
|
115
|
+
if (!mapped.ok)
|
|
116
|
+
return mapped;
|
|
117
|
+
grants.push(mapped.value);
|
|
118
|
+
}
|
|
119
|
+
return ok(grants);
|
|
120
|
+
}
|
|
121
|
+
catch (cause) {
|
|
122
|
+
return fail('Failed to list task-scoped command grants', cause);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
revoke(input) {
|
|
126
|
+
const validation = validateRevoke(input);
|
|
127
|
+
if (!validation.ok)
|
|
128
|
+
return validation;
|
|
129
|
+
const existing = this.get(input.grantId);
|
|
130
|
+
if (!existing.ok)
|
|
131
|
+
return existing;
|
|
132
|
+
if (existing.value.status !== 'active')
|
|
133
|
+
return validationFailure(`Grant is not active: ${existing.value.status}`);
|
|
134
|
+
const result = this.db.transaction(() => {
|
|
135
|
+
const revokedAt = input.now ?? new Date().toISOString();
|
|
136
|
+
const update = this.db.connection
|
|
137
|
+
.prepare(`
|
|
138
|
+
UPDATE task_scoped_command_grants
|
|
139
|
+
SET status='revoked', revoked_by_json=@revokedByJson, revoked_at=@revokedAt, revoke_reason=@reason
|
|
140
|
+
WHERE id=@id AND status='active'
|
|
141
|
+
`)
|
|
142
|
+
.run({ id: input.grantId, revokedByJson: JSON.stringify(input.actor), revokedAt, reason: input.reason });
|
|
143
|
+
if (update.changes === 0)
|
|
144
|
+
return validationFailure(`Grant is already inactive or changed by another writer: ${input.grantId}`);
|
|
145
|
+
this.insertEvent({
|
|
146
|
+
runId: existing.value.runId,
|
|
147
|
+
title: 'Task-scoped command grant revoked',
|
|
148
|
+
payload: {
|
|
149
|
+
grantId: input.grantId,
|
|
150
|
+
actor: { type: input.actor.type, id: input.actor.id, ...(input.actor.displayName === undefined ? {} : { displayName: input.actor.displayName }) },
|
|
151
|
+
reason: input.reason,
|
|
152
|
+
revokedAt,
|
|
153
|
+
},
|
|
154
|
+
relatedId: input.grantId,
|
|
155
|
+
});
|
|
156
|
+
return this.get(input.grantId);
|
|
157
|
+
});
|
|
158
|
+
return result.ok ? result.value : fail(result.error.message, result.error.cause, result.error.code);
|
|
159
|
+
}
|
|
160
|
+
consumeUse(input) {
|
|
161
|
+
if (!input.grantId.trim())
|
|
162
|
+
return validationFailure('Grant id is required');
|
|
163
|
+
if (!isIsoDate(input.now))
|
|
164
|
+
return validationFailure('Consume timestamp must be a valid ISO date');
|
|
165
|
+
const existing = this.get(input.grantId);
|
|
166
|
+
if (!existing.ok)
|
|
167
|
+
return existing;
|
|
168
|
+
const result = this.db.transaction(() => {
|
|
169
|
+
const update = this.db.connection
|
|
170
|
+
.prepare(`
|
|
171
|
+
UPDATE task_scoped_command_grants
|
|
172
|
+
SET
|
|
173
|
+
used_count = used_count + 1,
|
|
174
|
+
status = CASE
|
|
175
|
+
WHEN used_count + 1 >= max_uses THEN 'exhausted'
|
|
176
|
+
ELSE status
|
|
177
|
+
END
|
|
178
|
+
WHERE id = @id
|
|
179
|
+
AND status = 'active'
|
|
180
|
+
AND used_count < max_uses
|
|
181
|
+
AND expires_at > @now
|
|
182
|
+
`)
|
|
183
|
+
.run({ id: input.grantId, now: input.now });
|
|
184
|
+
if (update.changes === 0) {
|
|
185
|
+
this.markExpiredIfNeeded(input.grantId, input.now);
|
|
186
|
+
return validationFailure(`Grant cannot be consumed because it is inactive, expired, exhausted, or concurrently consumed: ${input.grantId}`);
|
|
187
|
+
}
|
|
188
|
+
const consumed = this.get(input.grantId);
|
|
189
|
+
if (!consumed.ok)
|
|
190
|
+
return consumed;
|
|
191
|
+
this.insertEvent({
|
|
192
|
+
runId: consumed.value.runId,
|
|
193
|
+
kind: 'evidence',
|
|
194
|
+
title: 'Task-scoped command grant used',
|
|
195
|
+
payload: {
|
|
196
|
+
grantId: consumed.value.id,
|
|
197
|
+
usedCountBefore: existing.value.usedCount,
|
|
198
|
+
usedCountAfter: consumed.value.usedCount,
|
|
199
|
+
status: consumed.value.status,
|
|
200
|
+
commandId: input.commandId ?? null,
|
|
201
|
+
canonicalCwd: input.canonicalCwd ?? null,
|
|
202
|
+
matchedExternalRoot: input.matchedExternalRoot ?? null,
|
|
203
|
+
},
|
|
204
|
+
relatedId: consumed.value.id,
|
|
205
|
+
});
|
|
206
|
+
return ok(consumed.value);
|
|
207
|
+
});
|
|
208
|
+
return result.ok ? result.value : fail(result.error.message, result.error.cause, result.error.code);
|
|
209
|
+
}
|
|
210
|
+
getRunId(runId) {
|
|
211
|
+
try {
|
|
212
|
+
const row = this.db.connection.prepare('SELECT id FROM runs WHERE id=?').get(runId);
|
|
213
|
+
return row === undefined ? missing(`Run not found: ${runId}`) : ok(row.id);
|
|
214
|
+
}
|
|
215
|
+
catch (cause) {
|
|
216
|
+
return fail('Failed to read run for task-scoped command grant', cause);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
markExpiredIfNeeded(grantId, now) {
|
|
220
|
+
this.db.connection
|
|
221
|
+
.prepare(`
|
|
222
|
+
UPDATE task_scoped_command_grants
|
|
223
|
+
SET status='expired'
|
|
224
|
+
WHERE id=@id AND status='active' AND expires_at <= @now
|
|
225
|
+
`)
|
|
226
|
+
.run({ id: grantId, now });
|
|
227
|
+
}
|
|
228
|
+
insertEvent(input) {
|
|
229
|
+
const next = this.nextSequence(input.runId);
|
|
230
|
+
const event = {
|
|
231
|
+
id: randomUUID(),
|
|
232
|
+
runId: input.runId,
|
|
233
|
+
sequence: next,
|
|
234
|
+
kind: input.kind ?? 'timeline',
|
|
235
|
+
title: input.title,
|
|
236
|
+
payloadJson: JSON.stringify(input.payload),
|
|
237
|
+
relatedType: 'task-scoped-command-grant',
|
|
238
|
+
relatedId: input.relatedId,
|
|
239
|
+
createdAt: new Date().toISOString(),
|
|
240
|
+
};
|
|
241
|
+
this.db.connection
|
|
242
|
+
.prepare(`
|
|
243
|
+
INSERT INTO run_events(id, run_id, sequence, kind, title, payload_json, related_type, related_id, created_at)
|
|
244
|
+
VALUES (@id, @runId, @sequence, @kind, @title, @payloadJson, @relatedType, @relatedId, @createdAt)
|
|
245
|
+
`)
|
|
246
|
+
.run(event);
|
|
247
|
+
return {
|
|
248
|
+
id: event.id,
|
|
249
|
+
runId: event.runId,
|
|
250
|
+
sequence: event.sequence,
|
|
251
|
+
kind: event.kind,
|
|
252
|
+
title: event.title,
|
|
253
|
+
payload: JSON.parse(event.payloadJson),
|
|
254
|
+
relatedType: event.relatedType,
|
|
255
|
+
relatedId: event.relatedId,
|
|
256
|
+
createdAt: event.createdAt,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
nextSequence(runId) {
|
|
260
|
+
const row = this.db.connection.prepare('SELECT COALESCE(MAX(sequence), 0) + 1 AS sequence FROM run_events WHERE run_id=?').get(runId);
|
|
261
|
+
return row.sequence;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function validateCreate(input) {
|
|
265
|
+
if (!input.runId.trim())
|
|
266
|
+
return validationFailure('Grant runId is required');
|
|
267
|
+
if (!input.agentId.trim())
|
|
268
|
+
return validationFailure('Grant agentId is required');
|
|
269
|
+
if (!input.taskFingerprint.trim())
|
|
270
|
+
return validationFailure('Grant taskFingerprint is required');
|
|
271
|
+
if (input.createdBy.type !== 'human')
|
|
272
|
+
return validationFailure('Grant creator must be a human actor');
|
|
273
|
+
if (!input.createdBy.id.trim())
|
|
274
|
+
return validationFailure('Grant creator id is required');
|
|
275
|
+
if (!Array.isArray(input.categories) || input.categories.length === 0)
|
|
276
|
+
return validationFailure('Grant categories are required');
|
|
277
|
+
if (new Set(input.categories).size !== input.categories.length)
|
|
278
|
+
return validationFailure('Grant categories must be unique');
|
|
279
|
+
for (const category of input.categories)
|
|
280
|
+
if (!grantableCategories.has(category))
|
|
281
|
+
return validationFailure(`Unsupported grant category: ${category}`);
|
|
282
|
+
if (!Array.isArray(input.externalRoots))
|
|
283
|
+
return validationFailure('Grant externalRoots must be an array');
|
|
284
|
+
if (!Array.isArray(input.canonicalExternalRoots))
|
|
285
|
+
return validationFailure('Grant canonicalExternalRoots must be an array');
|
|
286
|
+
if (input.cwdPatterns !== undefined && !Array.isArray(input.cwdPatterns))
|
|
287
|
+
return validationFailure('Grant cwdPatterns must be an array when provided');
|
|
288
|
+
const commandPolicy = validateCommandPolicy(input.commandPolicy);
|
|
289
|
+
if (!commandPolicy.ok)
|
|
290
|
+
return commandPolicy;
|
|
291
|
+
if (!isIsoDate(input.expiresAt))
|
|
292
|
+
return validationFailure('Grant expiresAt must be a valid ISO date');
|
|
293
|
+
if (!Number.isInteger(input.maxUses) || input.maxUses < 1)
|
|
294
|
+
return validationFailure('Grant maxUses must be a positive integer');
|
|
295
|
+
if (!input.audit.creationReason.trim())
|
|
296
|
+
return validationFailure('Grant audit creationReason is required');
|
|
297
|
+
if (!input.audit.approvedScopeSummary.trim())
|
|
298
|
+
return validationFailure('Grant audit approvedScopeSummary is required');
|
|
299
|
+
return ok(undefined);
|
|
300
|
+
}
|
|
301
|
+
function validateCommandPolicy(policy) {
|
|
302
|
+
if (policy.kind !== 'allowlisted-command-ids')
|
|
303
|
+
return validationFailure('Grant command policy must use allowlisted command ids');
|
|
304
|
+
if (!Array.isArray(policy.ids) || policy.ids.length === 0)
|
|
305
|
+
return validationFailure('Grant command policy ids are required');
|
|
306
|
+
if (new Set(policy.ids).size !== policy.ids.length)
|
|
307
|
+
return validationFailure('Grant command policy ids must be unique');
|
|
308
|
+
for (const id of policy.ids)
|
|
309
|
+
if (!id.trim())
|
|
310
|
+
return validationFailure('Grant command policy ids must be non-empty');
|
|
311
|
+
return ok(undefined);
|
|
312
|
+
}
|
|
313
|
+
function validateRevoke(input) {
|
|
314
|
+
if (!input.grantId.trim())
|
|
315
|
+
return validationFailure('Grant id is required');
|
|
316
|
+
if (input.actor.type !== 'human' && input.actor.type !== 'system')
|
|
317
|
+
return validationFailure('Grant revoke actor must be human or system');
|
|
318
|
+
if (!input.actor.id.trim())
|
|
319
|
+
return validationFailure('Grant revoke actor id is required');
|
|
320
|
+
if (!input.reason.trim())
|
|
321
|
+
return validationFailure('Grant revoke reason is required');
|
|
322
|
+
if (input.now !== undefined && !isIsoDate(input.now))
|
|
323
|
+
return validationFailure('Grant revoke timestamp must be a valid ISO date');
|
|
324
|
+
return ok(undefined);
|
|
325
|
+
}
|
|
326
|
+
function mapGrantRow(row) {
|
|
327
|
+
const status = readString(row.status);
|
|
328
|
+
if (!grantStatuses.has(status))
|
|
329
|
+
return validationFailure(`Persisted grant has unsupported status: ${status}`);
|
|
330
|
+
const categories = parseJsonArray(row.categories_json, isTaskScopedGrantCategory, 'categories_json');
|
|
331
|
+
if (!categories.ok)
|
|
332
|
+
return categories;
|
|
333
|
+
const externalRoots = parseJsonArray(row.external_roots_json, isString, 'external_roots_json');
|
|
334
|
+
if (!externalRoots.ok)
|
|
335
|
+
return externalRoots;
|
|
336
|
+
const canonicalExternalRoots = parseJsonArray(row.canonical_external_roots_json, isString, 'canonical_external_roots_json');
|
|
337
|
+
if (!canonicalExternalRoots.ok)
|
|
338
|
+
return canonicalExternalRoots;
|
|
339
|
+
const cwdPatterns = row.cwd_patterns_json === null ? ok(undefined) : parseJsonArray(row.cwd_patterns_json, isString, 'cwd_patterns_json');
|
|
340
|
+
if (!cwdPatterns.ok)
|
|
341
|
+
return cwdPatterns;
|
|
342
|
+
const commandPolicy = parseJsonObject(row.command_policy_json, isCommandPolicy, 'command_policy_json');
|
|
343
|
+
if (!commandPolicy.ok)
|
|
344
|
+
return commandPolicy;
|
|
345
|
+
const createdBy = parseJsonObject(row.created_by_json, isHumanActor, 'created_by_json');
|
|
346
|
+
if (!createdBy.ok)
|
|
347
|
+
return createdBy;
|
|
348
|
+
const revokedBy = row.revoked_by_json === null ? ok(undefined) : parseJsonObject(row.revoked_by_json, isRevocationActor, 'revoked_by_json');
|
|
349
|
+
if (!revokedBy.ok)
|
|
350
|
+
return revokedBy;
|
|
351
|
+
const audit = parseJsonObject(row.audit_json, isGrantAudit, 'audit_json');
|
|
352
|
+
if (!audit.ok)
|
|
353
|
+
return audit;
|
|
354
|
+
const grant = {
|
|
355
|
+
id: readString(row.id),
|
|
356
|
+
runId: readString(row.run_id),
|
|
357
|
+
agentId: readString(row.agent_id),
|
|
358
|
+
taskFingerprint: readString(row.task_fingerprint),
|
|
359
|
+
categories: categories.value,
|
|
360
|
+
externalRoots: externalRoots.value,
|
|
361
|
+
canonicalExternalRoots: canonicalExternalRoots.value,
|
|
362
|
+
commandPolicy: commandPolicy.value,
|
|
363
|
+
expiresAt: readString(row.expires_at),
|
|
364
|
+
maxUses: readNumber(row.max_uses),
|
|
365
|
+
usedCount: readNumber(row.used_count),
|
|
366
|
+
status: status,
|
|
367
|
+
createdBy: createdBy.value,
|
|
368
|
+
createdAt: readString(row.created_at),
|
|
369
|
+
audit: audit.value,
|
|
370
|
+
};
|
|
371
|
+
if (cwdPatterns.value !== undefined)
|
|
372
|
+
grant.cwdPatterns = cwdPatterns.value;
|
|
373
|
+
if (revokedBy.value !== undefined)
|
|
374
|
+
grant.revokedBy = revokedBy.value;
|
|
375
|
+
const revokedAt = readNullableString(row.revoked_at);
|
|
376
|
+
if (revokedAt !== undefined)
|
|
377
|
+
grant.revokedAt = revokedAt;
|
|
378
|
+
const revokeReason = readNullableString(row.revoke_reason);
|
|
379
|
+
if (revokeReason !== undefined)
|
|
380
|
+
grant.revokeReason = revokeReason;
|
|
381
|
+
return ok(grant);
|
|
382
|
+
}
|
|
383
|
+
function parseJsonArray(value, predicate, column) {
|
|
384
|
+
const parsed = parseJson(value, column);
|
|
385
|
+
if (!parsed.ok)
|
|
386
|
+
return parsed;
|
|
387
|
+
if (!Array.isArray(parsed.value) || !parsed.value.every(predicate))
|
|
388
|
+
return validationFailure(`Persisted grant ${column} is invalid`);
|
|
389
|
+
return ok(parsed.value);
|
|
390
|
+
}
|
|
391
|
+
function parseJsonObject(value, predicate, column) {
|
|
392
|
+
const parsed = parseJson(value, column);
|
|
393
|
+
if (!parsed.ok)
|
|
394
|
+
return parsed;
|
|
395
|
+
if (!predicate(parsed.value))
|
|
396
|
+
return validationFailure(`Persisted grant ${column} is invalid`);
|
|
397
|
+
return ok(parsed.value);
|
|
398
|
+
}
|
|
399
|
+
function parseJson(value, column) {
|
|
400
|
+
if (typeof value !== 'string')
|
|
401
|
+
return validationFailure(`Persisted grant ${column} is not JSON text`);
|
|
402
|
+
try {
|
|
403
|
+
return ok(JSON.parse(value));
|
|
404
|
+
}
|
|
405
|
+
catch (cause) {
|
|
406
|
+
return fail(`Persisted grant ${column} is invalid JSON`, cause);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
function isTaskScopedGrantCategory(value) {
|
|
410
|
+
return typeof value === 'string' && grantableCategories.has(value);
|
|
411
|
+
}
|
|
412
|
+
function isCommandPolicy(value) {
|
|
413
|
+
if (!isRecord(value))
|
|
414
|
+
return false;
|
|
415
|
+
return value.kind === 'allowlisted-command-ids' && Array.isArray(value.ids) && value.ids.every(isString);
|
|
416
|
+
}
|
|
417
|
+
function isHumanActor(value) {
|
|
418
|
+
return isRecord(value) && value.type === 'human' && typeof value.id === 'string' && optionalString(value.displayName);
|
|
419
|
+
}
|
|
420
|
+
function isRevocationActor(value) {
|
|
421
|
+
return isRecord(value) && (value.type === 'human' || value.type === 'system') && typeof value.id === 'string' && optionalString(value.displayName);
|
|
422
|
+
}
|
|
423
|
+
function isGrantAudit(value) {
|
|
424
|
+
return isRecord(value) && typeof value.creationReason === 'string' && typeof value.approvedScopeSummary === 'string';
|
|
425
|
+
}
|
|
426
|
+
function isRecord(value) {
|
|
427
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
428
|
+
}
|
|
429
|
+
function isString(value) {
|
|
430
|
+
return typeof value === 'string';
|
|
431
|
+
}
|
|
432
|
+
function optionalString(value) {
|
|
433
|
+
return value === undefined || typeof value === 'string';
|
|
434
|
+
}
|
|
435
|
+
function readString(value) {
|
|
436
|
+
return typeof value === 'string' ? value : '';
|
|
437
|
+
}
|
|
438
|
+
function readNullableString(value) {
|
|
439
|
+
return typeof value === 'string' ? value : undefined;
|
|
440
|
+
}
|
|
441
|
+
function readNumber(value) {
|
|
442
|
+
return typeof value === 'number' ? value : Number(value);
|
|
443
|
+
}
|
|
444
|
+
function isIsoDate(value) {
|
|
445
|
+
const timestamp = Date.parse(value);
|
|
446
|
+
return Number.isFinite(timestamp) && new Date(timestamp).toISOString() === value;
|
|
447
|
+
}
|
|
448
|
+
function ok(value) {
|
|
449
|
+
return { ok: true, value };
|
|
450
|
+
}
|
|
451
|
+
function missing(message) {
|
|
452
|
+
return { ok: false, error: { code: 'not_found', message } };
|
|
453
|
+
}
|
|
454
|
+
function validationFailure(message) {
|
|
455
|
+
return { ok: false, error: { code: 'validation_failed', message } };
|
|
456
|
+
}
|
|
457
|
+
function fail(message, cause, code = 'validation_failed') {
|
|
458
|
+
const error = { code, message };
|
|
459
|
+
if (cause !== undefined)
|
|
460
|
+
error.cause = cause;
|
|
461
|
+
return { ok: false, error };
|
|
462
|
+
}
|
package/dist/runs/run-service.js
CHANGED
|
@@ -8,6 +8,7 @@ import { AllowlistedApplyProgressCommandExecutor } from '../workflows/command-al
|
|
|
8
8
|
import { RunRepository, } from './repositories/runs.js';
|
|
9
9
|
import { buildRunInsights, buildRunOperatorResumePlan } from './run-insights.js';
|
|
10
10
|
import { buildResumeAfterApprovalResult } from './resume-after-approval-result.js';
|
|
11
|
+
import { grantEvaluationPayload, TaskScopedCommandGrantService } from './task-scoped-command-grant-service.js';
|
|
11
12
|
const preflightSafety = {
|
|
12
13
|
operationExecuted: false,
|
|
13
14
|
executorInvoked: false,
|
|
@@ -28,8 +29,10 @@ const resumeGateSafety = {
|
|
|
28
29
|
};
|
|
29
30
|
export class RunService {
|
|
30
31
|
runs;
|
|
32
|
+
taskScopedCommandGrants;
|
|
31
33
|
constructor(database) {
|
|
32
34
|
this.runs = new RunRepository(database);
|
|
35
|
+
this.taskScopedCommandGrants = new TaskScopedCommandGrantService(database);
|
|
33
36
|
}
|
|
34
37
|
createRun(input) {
|
|
35
38
|
return this.runs.create({ ...input, phase: canonicalRunPhase(input.workflow, input.phase) ?? input.phase });
|
|
@@ -164,6 +167,9 @@ export class RunService {
|
|
|
164
167
|
resolveApproval(input) {
|
|
165
168
|
return this.runs.resolveApproval(input);
|
|
166
169
|
}
|
|
170
|
+
taskScopedCommandGrantService() {
|
|
171
|
+
return this.taskScopedCommandGrants;
|
|
172
|
+
}
|
|
167
173
|
abandonReservedOperationAttempt(input) {
|
|
168
174
|
return this.runs.abandonOperationAttempt(input);
|
|
169
175
|
}
|
|
@@ -358,6 +364,8 @@ export class RunService {
|
|
|
358
364
|
requestedOperation: operationMetadata(resolved.value),
|
|
359
365
|
decisionEventId: permission.value.event.id,
|
|
360
366
|
approvalId: permission.value.approval?.id ?? null,
|
|
367
|
+
authorizationMode: permission.value.decision.authorizationMode ?? null,
|
|
368
|
+
explicitRequestAudit: (permission.value.decision.explicitRequestAudit ?? null),
|
|
361
369
|
plan: plan,
|
|
362
370
|
workspaceStrategy: workspaceStrategy,
|
|
363
371
|
...(sandboxDecision === undefined ? {} : { sandboxDecision: sandboxDecision }),
|
|
@@ -390,6 +398,8 @@ export class RunService {
|
|
|
390
398
|
const audit = { permissionEventId: event.id, planEventId: planEvent.id };
|
|
391
399
|
if (approval !== undefined)
|
|
392
400
|
audit.approvalId = approval.id;
|
|
401
|
+
if (decision.explicitRequestAudit !== undefined)
|
|
402
|
+
audit.explicitRequestAudit = decision.explicitRequestAudit;
|
|
393
403
|
const sandbox = plan.sandbox;
|
|
394
404
|
const sandboxRejected = sandbox?.decision === 'rejected';
|
|
395
405
|
const sandboxReason = sandboxRejected ? [{ code: sandbox.reason ?? 'sandbox_boundary', message: sandbox.audit.validationSummary }] : [];
|
|
@@ -398,6 +408,7 @@ export class RunService {
|
|
|
398
408
|
permissionEventId: event.id,
|
|
399
409
|
planEventId: planEvent.id,
|
|
400
410
|
...(approval === undefined ? {} : { approvalId: approval.id }),
|
|
411
|
+
...(decision.explicitRequestAudit === undefined ? {} : { explicitRequestAudit: decision.explicitRequestAudit }),
|
|
401
412
|
});
|
|
402
413
|
return {
|
|
403
414
|
ok: true,
|
|
@@ -766,6 +777,8 @@ export class RunService {
|
|
|
766
777
|
riskReasonCodes: reusedDecision.riskReasonCodes ?? [],
|
|
767
778
|
boundary: (reusedDecision.boundary ?? null),
|
|
768
779
|
auditEvidence: reusedDecision.auditEvidence ?? [],
|
|
780
|
+
explicitRequestAudit: (reusedDecision.explicitRequestAudit ?? null),
|
|
781
|
+
authorizationMode: reusedDecision.authorizationMode ?? (reusedDecision.decision === 'allow' && decision.decision === 'ask' ? 'approval-reuse' : null),
|
|
769
782
|
requiresHumanApproval: reusedDecision.decision === 'ask',
|
|
770
783
|
approvalStatus: prior.value.approval.status,
|
|
771
784
|
reusedApprovalId: prior.value.approval.id,
|
|
@@ -781,6 +794,44 @@ export class RunService {
|
|
|
781
794
|
: { ok: false, error: event.error };
|
|
782
795
|
}
|
|
783
796
|
}
|
|
797
|
+
const grantAuthorization = decision.decision === 'ask' ? this.taskScopedCommandGrants.tryAuthorizeAsk({ run: run.value, request: inputWithRunContext, baseDecision: decision }) : undefined;
|
|
798
|
+
if (grantAuthorization !== undefined) {
|
|
799
|
+
if (!grantAuthorization.ok)
|
|
800
|
+
return grantAuthorization;
|
|
801
|
+
if (grantAuthorization.value.authorized) {
|
|
802
|
+
const grantedDecision = grantAuthorization.value.decision;
|
|
803
|
+
const event = this.runs.appendEvent({
|
|
804
|
+
runId: inputWithRunContext.runId,
|
|
805
|
+
kind: 'permission-decision',
|
|
806
|
+
title: `Permission ${grantedDecision.decision}: ${inputWithRunContext.category} ${inputWithRunContext.operation}`,
|
|
807
|
+
payload: {
|
|
808
|
+
runId: inputWithRunContext.runId,
|
|
809
|
+
category: inputWithRunContext.category,
|
|
810
|
+
operation: inputWithRunContext.operation,
|
|
811
|
+
workflow: inputWithRunContext.workflow ?? null,
|
|
812
|
+
phase: inputWithRunContext.phase ?? null,
|
|
813
|
+
requestedOperation: operationMetadata(inputWithRunContext),
|
|
814
|
+
agent,
|
|
815
|
+
decision: grantedDecision.decision,
|
|
816
|
+
reasons: [{ code: grantedDecision.reason, message: grantedDecision.message }],
|
|
817
|
+
riskTier: grantedDecision.riskTier ?? null,
|
|
818
|
+
riskReasonCodes: grantedDecision.riskReasonCodes ?? [],
|
|
819
|
+
boundary: (grantedDecision.boundary ?? null),
|
|
820
|
+
auditEvidence: grantedDecision.auditEvidence ?? [],
|
|
821
|
+
explicitRequestAudit: (grantedDecision.explicitRequestAudit ?? null),
|
|
822
|
+
authorizationMode: grantedDecision.authorizationMode ?? null,
|
|
823
|
+
requiresHumanApproval: false,
|
|
824
|
+
approvalStatus: 'not-required',
|
|
825
|
+
grantEvaluation: grantEvaluationPayload(grantAuthorization.value.evaluation),
|
|
826
|
+
grantUseEventId: grantAuthorization.value.event.id,
|
|
827
|
+
timestamp,
|
|
828
|
+
},
|
|
829
|
+
relatedType: 'task-scoped-command-grant',
|
|
830
|
+
relatedId: grantAuthorization.value.grant.id,
|
|
831
|
+
});
|
|
832
|
+
return event.ok ? { ok: true, value: { decision: grantedDecision, event: event.value } } : { ok: false, error: event.error };
|
|
833
|
+
}
|
|
834
|
+
}
|
|
784
835
|
const event = this.runs.appendEvent({
|
|
785
836
|
runId: inputWithRunContext.runId,
|
|
786
837
|
kind: 'permission-decision',
|
|
@@ -799,8 +850,11 @@ export class RunService {
|
|
|
799
850
|
riskReasonCodes: decision.riskReasonCodes ?? [],
|
|
800
851
|
boundary: (decision.boundary ?? null),
|
|
801
852
|
auditEvidence: decision.auditEvidence ?? [],
|
|
853
|
+
explicitRequestAudit: (decision.explicitRequestAudit ?? null),
|
|
854
|
+
authorizationMode: decision.authorizationMode ?? null,
|
|
802
855
|
requiresHumanApproval: decision.decision === 'ask',
|
|
803
856
|
approvalStatus: decision.decision === 'ask' ? 'pending' : 'not-required',
|
|
857
|
+
...(grantAuthorization === undefined ? {} : { grantEvaluation: grantEvaluationPayload(grantAuthorization.value.evaluation) }),
|
|
804
858
|
timestamp,
|
|
805
859
|
},
|
|
806
860
|
relatedType: 'permission',
|
|
@@ -881,6 +935,7 @@ export class RunService {
|
|
|
881
935
|
permissionEventId: audit.permissionEventId,
|
|
882
936
|
planEventId: audit.planEventId,
|
|
883
937
|
approvalId: audit.approvalId ?? null,
|
|
938
|
+
explicitRequestAudit: (audit.explicitRequestAudit ?? null),
|
|
884
939
|
executorInvoked: false,
|
|
885
940
|
operationExecuted: false,
|
|
886
941
|
timestamp: new Date().toISOString(),
|
|
@@ -1215,6 +1270,14 @@ function operationMetadata(input) {
|
|
|
1215
1270
|
metadata.phase = input.phase;
|
|
1216
1271
|
if (input.agentId !== undefined)
|
|
1217
1272
|
metadata.agentId = input.agentId;
|
|
1273
|
+
if (input.taskFingerprint !== undefined)
|
|
1274
|
+
metadata.taskFingerprint = input.taskFingerprint;
|
|
1275
|
+
if (input.commandId !== undefined)
|
|
1276
|
+
metadata.commandId = input.commandId;
|
|
1277
|
+
if (input.cwd !== undefined)
|
|
1278
|
+
metadata.cwd = input.cwd;
|
|
1279
|
+
if (input.targetPaths !== undefined)
|
|
1280
|
+
metadata.targetPaths = input.targetPaths;
|
|
1218
1281
|
if (input.destructive !== undefined)
|
|
1219
1282
|
metadata.destructive = input.destructive;
|
|
1220
1283
|
if (input.external !== undefined)
|
|
@@ -1223,6 +1286,10 @@ function operationMetadata(input) {
|
|
|
1223
1286
|
metadata.privileged = input.privileged;
|
|
1224
1287
|
if (input.ambiguous !== undefined)
|
|
1225
1288
|
metadata.ambiguous = input.ambiguous;
|
|
1289
|
+
if (input.providerConfigMutation !== undefined)
|
|
1290
|
+
metadata.providerConfigMutation = input.providerConfigMutation;
|
|
1291
|
+
if (input.globalConfigMutation !== undefined)
|
|
1292
|
+
metadata.globalConfigMutation = input.globalConfigMutation;
|
|
1226
1293
|
if (input.input !== undefined)
|
|
1227
1294
|
metadata.input = input.input;
|
|
1228
1295
|
return metadata;
|
|
@@ -1253,7 +1320,22 @@ function operationMetadataCompatible(preflightOperation, requestedOperation) {
|
|
|
1253
1320
|
return jsonEqual(preflightOperation, requestedOperation);
|
|
1254
1321
|
if (preflightOperation.category !== requestedOperation.category || preflightOperation.name !== requestedOperation.name)
|
|
1255
1322
|
return false;
|
|
1256
|
-
const strictKeys = [
|
|
1323
|
+
const strictKeys = [
|
|
1324
|
+
'workspaceRoot',
|
|
1325
|
+
'targetPath',
|
|
1326
|
+
'providerToolName',
|
|
1327
|
+
'sandboxStrategy',
|
|
1328
|
+
'taskFingerprint',
|
|
1329
|
+
'commandId',
|
|
1330
|
+
'cwd',
|
|
1331
|
+
'targetPaths',
|
|
1332
|
+
'destructive',
|
|
1333
|
+
'external',
|
|
1334
|
+
'privileged',
|
|
1335
|
+
'ambiguous',
|
|
1336
|
+
'providerConfigMutation',
|
|
1337
|
+
'globalConfigMutation',
|
|
1338
|
+
];
|
|
1257
1339
|
for (const key of strictKeys) {
|
|
1258
1340
|
if (key in preflightOperation && key in requestedOperation && !jsonEqual(preflightOperation[key], requestedOperation[key]))
|
|
1259
1341
|
return false;
|
|
@@ -1336,6 +1418,14 @@ function operationFromPendingExecution(payload) {
|
|
|
1336
1418
|
operation.phase = requested.phase;
|
|
1337
1419
|
if (typeof requested.agentId === 'string')
|
|
1338
1420
|
operation.agentId = requested.agentId;
|
|
1421
|
+
if (typeof requested.taskFingerprint === 'string')
|
|
1422
|
+
operation.taskFingerprint = requested.taskFingerprint;
|
|
1423
|
+
if (typeof requested.commandId === 'string')
|
|
1424
|
+
operation.commandId = requested.commandId;
|
|
1425
|
+
if (typeof requested.cwd === 'string')
|
|
1426
|
+
operation.cwd = requested.cwd;
|
|
1427
|
+
if (Array.isArray(requested.targetPaths) && requested.targetPaths.every((item) => typeof item === 'string'))
|
|
1428
|
+
operation.targetPaths = requested.targetPaths;
|
|
1339
1429
|
if (typeof requested.destructive === 'boolean')
|
|
1340
1430
|
operation.destructive = requested.destructive;
|
|
1341
1431
|
if (typeof requested.external === 'boolean')
|