vgxness 1.20.2 → 1.20.4
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 +16 -15
- package/dist/agents/canonical-agent-projection.js +3 -2
- package/dist/agents/manager-profile-overlay-service.js +13 -5
- 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/mcp/control-plane.js +34 -0
- package/dist/mcp/schema.js +84 -0
- package/dist/mcp/stdio-server.js +6 -0
- package/dist/mcp/validation.js +214 -1
- package/dist/memory/sqlite/migrations/019_task_scoped_command_grants.sql +37 -0
- package/dist/permissions/path-boundaries.js +87 -0
- 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 +60 -1
- package/dist/runs/sandbox-process-execution.js +41 -30
- package/dist/runs/task-scoped-command-grant-service.js +216 -0
- package/dist/sdd/sdd-continuation-plan.js +1 -1
- package/dist/workflows/command-allowlist-adapter.js +50 -0
- package/docs/safety.md +23 -0
- package/package.json +1 -1
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { isAbsolute, resolve, sep } from 'node:path';
|
|
2
|
+
import { isAbsolute, sep } from 'node:path';
|
|
3
|
+
import { canonicalizeExistingPath, isPathInside, isProviderConfigPath } from '../permissions/path-boundaries.js';
|
|
5
4
|
const defaultTimeoutMs = 10_000;
|
|
6
5
|
const defaultOutputLimitBytes = 64 * 1024;
|
|
7
6
|
const allowedEnvKey = /^[A-Z_][A-Z0-9_]*$/u;
|
|
@@ -13,10 +12,11 @@ export function buildSandboxExecutionPlan(request) {
|
|
|
13
12
|
const cwd = realpathOrUndefined(request.cwd ?? request.workspaceRoot);
|
|
14
13
|
if (cwd === undefined)
|
|
15
14
|
return rejected('cwd-not-found', `Process cwd does not exist: ${request.cwd ?? request.workspaceRoot}`, workspaceRoot);
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
const externalCwd = externalCwdAuthorization({ cwd, workspaceRoot, request });
|
|
16
|
+
if (!externalCwd.ok)
|
|
17
|
+
return rejected(externalCwd.code, externalCwd.message, workspaceRoot, cwd, externalCwd.approvedExternalRoot, request.externalAuthorization);
|
|
18
|
+
if (isProviderConfigPathForAnyRoot(cwd, workspaceRoot, externalCwd.approvedExternalRoot))
|
|
19
|
+
return rejected('provider-config-cwd-rejected', `Process cwd targets provider/OpenCode config: ${request.cwd ?? request.workspaceRoot}`, workspaceRoot, cwd, externalCwd.approvedExternalRoot, request.externalAuthorization);
|
|
20
20
|
if (request.command.length === 0)
|
|
21
21
|
return rejected('empty-command', 'Process command is required.', workspaceRoot, cwd);
|
|
22
22
|
if (isAbsolute(request.command) || request.command.includes(sep) || shellMetaCharacters.test(request.command)) {
|
|
@@ -31,9 +31,9 @@ export function buildSandboxExecutionPlan(request) {
|
|
|
31
31
|
const targetPaths = request.targetPaths ?? [];
|
|
32
32
|
if (!targetPaths.every((targetPath) => typeof targetPath === 'string'))
|
|
33
33
|
return rejected('invalid-target-paths', 'Target paths must be an array of strings.', workspaceRoot, cwd);
|
|
34
|
-
const providerConfigTargetPath = targetPaths.find((targetPath) =>
|
|
34
|
+
const providerConfigTargetPath = targetPaths.find((targetPath) => isProviderConfigPathForAnyRoot(targetPath, workspaceRoot, cwd, externalCwd.approvedExternalRoot));
|
|
35
35
|
if (providerConfigTargetPath !== undefined)
|
|
36
|
-
return rejected('provider-config-target-path-rejected', `Process plan target path targets provider/OpenCode config: ${providerConfigTargetPath}`, workspaceRoot, cwd);
|
|
36
|
+
return rejected('provider-config-target-path-rejected', `Process plan target path targets provider/OpenCode config: ${providerConfigTargetPath}`, workspaceRoot, cwd, externalCwd.approvedExternalRoot, request.externalAuthorization);
|
|
37
37
|
const env = request.env ?? {};
|
|
38
38
|
const envKeys = Object.keys(env).sort();
|
|
39
39
|
const invalidEnvKey = envKeys.find((key) => !allowedEnvKey.test(key));
|
|
@@ -45,9 +45,9 @@ export function buildSandboxExecutionPlan(request) {
|
|
|
45
45
|
const outputLimitBytes = request.outputLimitBytes ?? defaultOutputLimitBytes;
|
|
46
46
|
if (!Number.isInteger(outputLimitBytes) || outputLimitBytes < 1 || outputLimitBytes > 1024 * 1024)
|
|
47
47
|
return rejected('invalid-output-limit', 'Output limit must be an integer between 1 byte and 1 MiB.', workspaceRoot, cwd);
|
|
48
|
-
const evidence = acceptedEvidence(workspaceRoot, cwd, [
|
|
48
|
+
const evidence = acceptedEvidence(workspaceRoot, cwd, externalCwd.approvedExternalRoot, request.externalAuthorization, [
|
|
49
49
|
'workspace-root-realpath-resolved',
|
|
50
|
-
'cwd-realpath-inside-workspace',
|
|
50
|
+
externalCwd.approvedExternalRoot === undefined ? 'cwd-realpath-inside-workspace' : 'cwd-realpath-inside-approved-external-root',
|
|
51
51
|
'command-is-executable-name-not-shell-string',
|
|
52
52
|
'command-allowlisted',
|
|
53
53
|
'argv-array-only',
|
|
@@ -156,7 +156,7 @@ function bufferToLimitedString(buffer, limit) {
|
|
|
156
156
|
const source = typeof buffer === 'string' ? Buffer.from(buffer) : buffer;
|
|
157
157
|
return source.subarray(0, limit).toString('utf8');
|
|
158
158
|
}
|
|
159
|
-
function acceptedEvidence(workspaceRoot, cwd, checks) {
|
|
159
|
+
function acceptedEvidence(workspaceRoot, cwd, approvedExternalRoot, externalAuthorization, checks) {
|
|
160
160
|
return {
|
|
161
161
|
strategy: 'bounded-process',
|
|
162
162
|
enforceable: true,
|
|
@@ -169,6 +169,9 @@ function acceptedEvidence(workspaceRoot, cwd, checks) {
|
|
|
169
169
|
providerConfigWritesBlocked: true,
|
|
170
170
|
workspaceRoot,
|
|
171
171
|
cwd,
|
|
172
|
+
externalCwdAuthorized: approvedExternalRoot !== undefined,
|
|
173
|
+
...(approvedExternalRoot === undefined ? {} : { approvedExternalRoot }),
|
|
174
|
+
...(externalAuthorization === undefined || approvedExternalRoot === undefined ? {} : { externalAuthorization }),
|
|
172
175
|
capabilities: {
|
|
173
176
|
sandboxEnforceable: false,
|
|
174
177
|
processExecutionEnforceable: true,
|
|
@@ -193,32 +196,40 @@ function acceptedEvidence(workspaceRoot, cwd, checks) {
|
|
|
193
196
|
validation: { accepted: true, checks },
|
|
194
197
|
};
|
|
195
198
|
}
|
|
196
|
-
function rejected(code, message, workspaceRoot, cwd) {
|
|
199
|
+
function rejected(code, message, workspaceRoot, cwd, approvedExternalRoot, externalAuthorization) {
|
|
197
200
|
const evidence = workspaceRoot === undefined || cwd === undefined
|
|
198
201
|
? undefined
|
|
199
|
-
: { ...acceptedEvidence(workspaceRoot, cwd, []), enforceable: false, validation: { accepted: false, checks: [code] } };
|
|
202
|
+
: { ...acceptedEvidence(workspaceRoot, cwd, approvedExternalRoot, externalAuthorization, []), enforceable: false, validation: { accepted: false, checks: [code] } };
|
|
200
203
|
return { ok: false, error: { code, message, ...(evidence === undefined ? {} : { evidence }) } };
|
|
201
204
|
}
|
|
202
|
-
function
|
|
203
|
-
|
|
204
|
-
return
|
|
205
|
+
function externalCwdAuthorization(input) {
|
|
206
|
+
if (isInside(input.cwd, input.workspaceRoot))
|
|
207
|
+
return { ok: true };
|
|
208
|
+
if (input.request.externalAuthorization === undefined) {
|
|
209
|
+
return { ok: false, code: 'cwd-escapes-workspace', message: `Process cwd escapes workspace boundary: ${input.request.cwd ?? input.request.workspaceRoot}` };
|
|
205
210
|
}
|
|
206
|
-
|
|
207
|
-
|
|
211
|
+
const roots = input.request.approvedExternalRoots ?? [];
|
|
212
|
+
for (const root of roots) {
|
|
213
|
+
const canonicalRoot = realpathOrUndefined(root);
|
|
214
|
+
if (canonicalRoot !== undefined && isInside(input.cwd, canonicalRoot))
|
|
215
|
+
return { ok: true, approvedExternalRoot: canonicalRoot };
|
|
208
216
|
}
|
|
217
|
+
return {
|
|
218
|
+
ok: false,
|
|
219
|
+
code: 'cwd-not-approved-external-root',
|
|
220
|
+
message: `Process cwd is outside the workspace and not inside an approved external root: ${input.request.cwd ?? input.request.workspaceRoot}`,
|
|
221
|
+
};
|
|
209
222
|
}
|
|
210
|
-
function
|
|
211
|
-
|
|
212
|
-
}
|
|
213
|
-
function isProviderConfigPath(path, workspaceRoot) {
|
|
214
|
-
const absolutePath = resolve(workspaceRoot, path);
|
|
215
|
-
const projectOpenCodeDirectory = resolve(workspaceRoot, '.opencode');
|
|
216
|
-
if (absolutePath === projectOpenCodeDirectory || isInside(absolutePath, projectOpenCodeDirectory))
|
|
223
|
+
function isProviderConfigPathForAnyRoot(path, workspaceRoot, ...additionalRoots) {
|
|
224
|
+
if (isProviderConfigPath(path, workspaceRoot))
|
|
217
225
|
return true;
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
return
|
|
226
|
+
return additionalRoots.some((root) => root !== undefined && isProviderConfigPath(path, root));
|
|
227
|
+
}
|
|
228
|
+
function realpathOrUndefined(path) {
|
|
229
|
+
return canonicalizeExistingPath(path);
|
|
230
|
+
}
|
|
231
|
+
function isInside(path, workspaceRoot) {
|
|
232
|
+
return isPathInside(path, workspaceRoot);
|
|
222
233
|
}
|
|
223
234
|
function limitedEnv(input) {
|
|
224
235
|
const env = { PATH: process.env.PATH ?? '' };
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { matchTaskScopedCommandGrant } from '../permissions/task-scoped-command-grant-matcher.js';
|
|
2
|
+
import { validateTaskScopedCommandGrantCreationInput } from '../permissions/task-scoped-command-grant-validation.js';
|
|
3
|
+
import { commandAllowlistIds } from '../workflows/command-allowlist-adapter.js';
|
|
4
|
+
import { TaskScopedCommandGrantRepository } from './repositories/task-scoped-command-grants.js';
|
|
5
|
+
import { RunRepository } from './repositories/runs.js';
|
|
6
|
+
import { isTerminalRunStatus } from './schema.js';
|
|
7
|
+
const grantableCategories = new Set(['shell', 'test-run', 'git']);
|
|
8
|
+
export class TaskScopedCommandGrantService {
|
|
9
|
+
runs;
|
|
10
|
+
grants;
|
|
11
|
+
constructor(database) {
|
|
12
|
+
this.runs = new RunRepository(database);
|
|
13
|
+
this.grants = new TaskScopedCommandGrantRepository(database);
|
|
14
|
+
}
|
|
15
|
+
create(input) {
|
|
16
|
+
const run = this.runs.getById(input.runId);
|
|
17
|
+
if (!run.ok)
|
|
18
|
+
return run;
|
|
19
|
+
if (isTerminalRunStatus(run.value.status))
|
|
20
|
+
return validationFailure(`Cannot create task-scoped command grants for terminal run status: ${run.value.status}`);
|
|
21
|
+
if (input.agentId !== run.value.selectedAgentId)
|
|
22
|
+
return validationFailure('Grant agentId must match the run selected agent');
|
|
23
|
+
const validated = validateTaskScopedCommandGrantCreationInput(input, { knownAllowlistIds: commandAllowlistIds(), now: input.createdAt === undefined ? new Date() : new Date(input.createdAt) });
|
|
24
|
+
if (!validated.ok)
|
|
25
|
+
return validated;
|
|
26
|
+
const createInput = validated.value;
|
|
27
|
+
return this.grants.create(createInput);
|
|
28
|
+
}
|
|
29
|
+
list(filters = {}) {
|
|
30
|
+
return this.grants.list(filters);
|
|
31
|
+
}
|
|
32
|
+
get(id) {
|
|
33
|
+
return this.grants.get(id);
|
|
34
|
+
}
|
|
35
|
+
revoke(input) {
|
|
36
|
+
return this.grants.revoke(input);
|
|
37
|
+
}
|
|
38
|
+
previewMatch(input) {
|
|
39
|
+
return this.evaluateMatch(input, false);
|
|
40
|
+
}
|
|
41
|
+
tryAuthorizeAsk(input) {
|
|
42
|
+
if (input.baseDecision.decision !== 'ask')
|
|
43
|
+
return { ok: true, value: { authorized: false, evaluation: emptyEvaluation(false, ['base-decision-not-ask']) } };
|
|
44
|
+
const preview = this.evaluateMatch({
|
|
45
|
+
runId: input.run.id,
|
|
46
|
+
agentId: input.request.agentId ?? input.request.agent?.id ?? input.run.selectedAgentId,
|
|
47
|
+
category: input.request.category,
|
|
48
|
+
operation: input.request.operation,
|
|
49
|
+
...(input.request.taskFingerprint === undefined ? {} : { taskFingerprint: input.request.taskFingerprint }),
|
|
50
|
+
...(input.request.commandId === undefined ? {} : { commandId: input.request.commandId }),
|
|
51
|
+
...(input.request.workspaceRoot === undefined ? {} : { workspaceRoot: input.request.workspaceRoot }),
|
|
52
|
+
...(input.request.cwd === undefined ? {} : { cwd: input.request.cwd }),
|
|
53
|
+
...taskGrantTargetPaths(input.request),
|
|
54
|
+
...(input.request.destructive === undefined ? {} : { destructive: input.request.destructive }),
|
|
55
|
+
...(input.request.privileged === undefined ? {} : { privileged: input.request.privileged }),
|
|
56
|
+
...(input.request.ambiguous === undefined ? {} : { ambiguous: input.request.ambiguous }),
|
|
57
|
+
...(input.now === undefined ? {} : { now: input.now }),
|
|
58
|
+
}, true);
|
|
59
|
+
if (!preview.ok)
|
|
60
|
+
return preview;
|
|
61
|
+
if (preview.value.selectedGrantId === undefined)
|
|
62
|
+
return { ok: true, value: { authorized: false, evaluation: preview.value } };
|
|
63
|
+
const before = this.grants.get(preview.value.selectedGrantId);
|
|
64
|
+
if (!before.ok)
|
|
65
|
+
return before;
|
|
66
|
+
const consumeInput = {
|
|
67
|
+
grantId: preview.value.selectedGrantId,
|
|
68
|
+
now: (input.now ?? new Date()).toISOString(),
|
|
69
|
+
...(preview.value.commandId === undefined ? {} : { commandId: preview.value.commandId }),
|
|
70
|
+
...(preview.value.canonicalCwd === undefined ? {} : { canonicalCwd: preview.value.canonicalCwd }),
|
|
71
|
+
...(preview.value.matchedExternalRoot === undefined ? {} : { matchedExternalRoot: preview.value.matchedExternalRoot }),
|
|
72
|
+
};
|
|
73
|
+
const consumed = this.grants.consumeUse(consumeInput);
|
|
74
|
+
if (!consumed.ok) {
|
|
75
|
+
return {
|
|
76
|
+
ok: true,
|
|
77
|
+
value: {
|
|
78
|
+
authorized: false,
|
|
79
|
+
evaluation: { ...preview.value, matched: false, missReasons: [...new Set([...preview.value.missReasons, 'concurrent-consume-lost'])].sort() },
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const evaluation = {
|
|
84
|
+
...preview.value,
|
|
85
|
+
matched: true,
|
|
86
|
+
useCountBefore: before.value.usedCount,
|
|
87
|
+
useCountAfter: consumed.value.usedCount,
|
|
88
|
+
};
|
|
89
|
+
const event = this.runs.appendEvent({
|
|
90
|
+
runId: input.run.id,
|
|
91
|
+
kind: 'evidence',
|
|
92
|
+
title: 'Task-scoped command grant authorized preflight ask',
|
|
93
|
+
payload: grantEvaluationPayload(evaluation),
|
|
94
|
+
relatedType: 'task-scoped-command-grant',
|
|
95
|
+
relatedId: consumed.value.id,
|
|
96
|
+
});
|
|
97
|
+
if (!event.ok)
|
|
98
|
+
return { ok: false, error: event.error };
|
|
99
|
+
const decision = {
|
|
100
|
+
...input.baseDecision,
|
|
101
|
+
decision: 'allow',
|
|
102
|
+
reason: 'task_scoped_grant',
|
|
103
|
+
message: `Task-scoped grant ${consumed.value.id} authorizes this bounded external command preflight.`,
|
|
104
|
+
auditEvidence: [...(input.baseDecision.auditEvidence ?? []), `taskScopedGrant:${consumed.value.id}`, ...(evaluation.commandId === undefined ? [] : [`commandAllowlistId:${evaluation.commandId}`])],
|
|
105
|
+
};
|
|
106
|
+
return { ok: true, value: { authorized: true, decision, grant: consumed.value, evaluation, event: event.value } };
|
|
107
|
+
}
|
|
108
|
+
evaluateMatch(input, requireEligibleAskMetadata) {
|
|
109
|
+
const missing = requiredMissReasons(input);
|
|
110
|
+
if (missing.length > 0)
|
|
111
|
+
return { ok: true, value: emptyEvaluation(true, missing) };
|
|
112
|
+
if (!grantableCategories.has(input.category))
|
|
113
|
+
return { ok: true, value: emptyEvaluation(true, ['unsupported-category']) };
|
|
114
|
+
if (requireEligibleAskMetadata && (input.destructive === true || input.privileged === true || input.ambiguous === true)) {
|
|
115
|
+
return { ok: true, value: emptyEvaluation(true, ['hard-risk-override']) };
|
|
116
|
+
}
|
|
117
|
+
const run = this.runs.getById(input.runId);
|
|
118
|
+
if (!run.ok)
|
|
119
|
+
return run;
|
|
120
|
+
if (input.agentId !== run.value.selectedAgentId)
|
|
121
|
+
return { ok: true, value: emptyEvaluation(true, ['agent-mismatch']) };
|
|
122
|
+
const agentId = input.agentId;
|
|
123
|
+
const taskFingerprint = input.taskFingerprint;
|
|
124
|
+
const cwd = input.cwd;
|
|
125
|
+
const workspaceRoot = input.workspaceRoot;
|
|
126
|
+
if (agentId === undefined || taskFingerprint === undefined || cwd === undefined || workspaceRoot === undefined) {
|
|
127
|
+
return { ok: true, value: emptyEvaluation(true, requiredMissReasons(input)) };
|
|
128
|
+
}
|
|
129
|
+
const candidates = this.grants.list({ runId: input.runId, agentId, status: 'active' });
|
|
130
|
+
if (!candidates.ok)
|
|
131
|
+
return candidates;
|
|
132
|
+
const matched = matchTaskScopedCommandGrant({
|
|
133
|
+
request: {
|
|
134
|
+
runId: input.runId,
|
|
135
|
+
agentId,
|
|
136
|
+
taskFingerprint,
|
|
137
|
+
category: input.category,
|
|
138
|
+
operation: input.operation,
|
|
139
|
+
...(input.commandId === undefined ? {} : { commandId: input.commandId }),
|
|
140
|
+
cwd,
|
|
141
|
+
...(input.targetPaths === undefined ? {} : { targetPaths: input.targetPaths }),
|
|
142
|
+
...(input.destructive === undefined ? {} : { destructive: input.destructive }),
|
|
143
|
+
...(input.privileged === undefined ? {} : { privileged: input.privileged }),
|
|
144
|
+
...(input.ambiguous === undefined ? {} : { ambiguous: input.ambiguous }),
|
|
145
|
+
},
|
|
146
|
+
candidates: candidates.value,
|
|
147
|
+
knownAllowlistIds: commandAllowlistIds(),
|
|
148
|
+
now: input.now ?? new Date(),
|
|
149
|
+
workspaceRoot,
|
|
150
|
+
});
|
|
151
|
+
return { ok: true, value: evaluationFromMatch(matched) };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
export function grantEvaluationPayload(evaluation) {
|
|
155
|
+
return {
|
|
156
|
+
evaluated: evaluation.evaluated,
|
|
157
|
+
matched: evaluation.matched,
|
|
158
|
+
selectedGrantId: evaluation.selectedGrantId ?? null,
|
|
159
|
+
matchedGrantIds: evaluation.matchedGrantIds,
|
|
160
|
+
missReasons: evaluation.missReasons,
|
|
161
|
+
commandId: evaluation.commandId ?? null,
|
|
162
|
+
canonicalCwd: evaluation.canonicalCwd ?? null,
|
|
163
|
+
matchedExternalRoot: evaluation.matchedExternalRoot ?? null,
|
|
164
|
+
useCountBefore: evaluation.useCountBefore ?? null,
|
|
165
|
+
useCountAfter: evaluation.useCountAfter ?? null,
|
|
166
|
+
candidates: evaluation.candidates,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
function evaluationFromMatch(match) {
|
|
170
|
+
return {
|
|
171
|
+
evaluated: true,
|
|
172
|
+
matched: match.selectedGrantId !== undefined,
|
|
173
|
+
...(match.selectedGrantId === undefined ? {} : { selectedGrantId: match.selectedGrantId }),
|
|
174
|
+
matchedGrantIds: match.matchedGrantIds,
|
|
175
|
+
missReasons: match.missReasons,
|
|
176
|
+
...(match.canonicalCwd === undefined ? {} : { canonicalCwd: match.canonicalCwd }),
|
|
177
|
+
...(match.matchedExternalRoot === undefined ? {} : { matchedExternalRoot: match.matchedExternalRoot }),
|
|
178
|
+
...(match.commandId === undefined ? {} : { commandId: match.commandId }),
|
|
179
|
+
candidates: match.candidates.map((candidate) => ({
|
|
180
|
+
grantId: candidate.grantId,
|
|
181
|
+
matched: candidate.matched,
|
|
182
|
+
selected: candidate.selected,
|
|
183
|
+
missReasons: candidate.missReasons,
|
|
184
|
+
commandId: candidate.commandId ?? null,
|
|
185
|
+
canonicalCwd: candidate.canonicalCwd ?? null,
|
|
186
|
+
matchedExternalRoot: candidate.matchedExternalRoot ?? null,
|
|
187
|
+
})),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
function emptyEvaluation(evaluated, missReasons) {
|
|
191
|
+
return { evaluated, matched: false, matchedGrantIds: [], missReasons, candidates: [] };
|
|
192
|
+
}
|
|
193
|
+
function requiredMissReasons(input) {
|
|
194
|
+
const reasons = [];
|
|
195
|
+
if (input.runId.trim().length === 0)
|
|
196
|
+
reasons.push('missing-run-id');
|
|
197
|
+
if (input.agentId === undefined || input.agentId.trim().length === 0)
|
|
198
|
+
reasons.push('missing-agent-id');
|
|
199
|
+
if (input.taskFingerprint === undefined || input.taskFingerprint.trim().length === 0)
|
|
200
|
+
reasons.push('missing-task-fingerprint');
|
|
201
|
+
if (input.workspaceRoot === undefined || input.workspaceRoot.trim().length === 0)
|
|
202
|
+
reasons.push('missing-workspace-root');
|
|
203
|
+
if (input.cwd === undefined || input.cwd.trim().length === 0)
|
|
204
|
+
reasons.push('missing-cwd');
|
|
205
|
+
return reasons;
|
|
206
|
+
}
|
|
207
|
+
function taskGrantTargetPaths(request) {
|
|
208
|
+
if (request.targetPaths !== undefined)
|
|
209
|
+
return { targetPaths: request.targetPaths };
|
|
210
|
+
if (request.targetPath !== undefined)
|
|
211
|
+
return { targetPaths: [request.targetPath] };
|
|
212
|
+
return {};
|
|
213
|
+
}
|
|
214
|
+
function validationFailure(message) {
|
|
215
|
+
return { ok: false, error: { code: 'validation_failed', message } };
|
|
216
|
+
}
|
|
@@ -115,7 +115,7 @@ function draftPhaseAction(project, change, phase, reason) {
|
|
|
115
115
|
id: `sdd.${change}.${phase}.draft`,
|
|
116
116
|
label: `Prepare ${phase} draft through normal SDD phase flow`,
|
|
117
117
|
title: `Prepare ${phase} draft`,
|
|
118
|
-
description: 'The next SDD phase appears runnable; draft generation must still use the normal phase workflow and any required preflight
|
|
118
|
+
description: 'The next SDD phase appears runnable; draft generation must still use the normal phase workflow and any required preflight gates. After a valid phase artifact is produced, save it as a draft immediately, then ask the human acceptance/continue question before advancing.',
|
|
119
119
|
kind: 'draft-phase',
|
|
120
120
|
category: 'sdd-phase',
|
|
121
121
|
phase,
|
|
@@ -43,6 +43,10 @@ export class CommandAllowlistAdapter {
|
|
|
43
43
|
outputLimitBytes: entry.outputLimitBytes,
|
|
44
44
|
targetPaths: options.targetPaths ?? [],
|
|
45
45
|
};
|
|
46
|
+
if (options.approvedExternalRoots !== undefined && options.externalAuthorization !== undefined) {
|
|
47
|
+
this.request.approvedExternalRoots = options.approvedExternalRoots;
|
|
48
|
+
this.request.externalAuthorization = options.externalAuthorization;
|
|
49
|
+
}
|
|
46
50
|
const plan = buildSandboxExecutionPlan(this.request);
|
|
47
51
|
if (!plan.ok)
|
|
48
52
|
throw new Error(`${plan.error.code}: ${plan.error.message}`);
|
|
@@ -67,6 +71,18 @@ export class CommandAllowlistAdapter {
|
|
|
67
71
|
shellDisabled: result.value.evidence.shellDisabled,
|
|
68
72
|
argvArrayOnly: result.value.evidence.argvArrayOnly,
|
|
69
73
|
providerConfigWritesBlocked: result.value.evidence.providerConfigWritesBlocked,
|
|
74
|
+
externalCwdAuthorized: result.value.evidence.externalCwdAuthorized,
|
|
75
|
+
...(result.value.evidence.approvedExternalRoot === undefined ? {} : { approvedExternalRoot: result.value.evidence.approvedExternalRoot }),
|
|
76
|
+
...(result.value.evidence.externalAuthorization === undefined
|
|
77
|
+
? {}
|
|
78
|
+
: {
|
|
79
|
+
externalAuthorization: {
|
|
80
|
+
kind: result.value.evidence.externalAuthorization.kind,
|
|
81
|
+
grantId: result.value.evidence.externalAuthorization.grantId,
|
|
82
|
+
runId: result.value.evidence.externalAuthorization.runId,
|
|
83
|
+
agentId: result.value.evidence.externalAuthorization.agentId,
|
|
84
|
+
},
|
|
85
|
+
}),
|
|
70
86
|
};
|
|
71
87
|
const output = {
|
|
72
88
|
commandId: this.commandId,
|
|
@@ -136,6 +152,11 @@ export class AllowlistedApplyProgressCommandExecutor {
|
|
|
136
152
|
const cwd = this.options.cwd ?? contract.value.cwd;
|
|
137
153
|
if (cwd !== undefined)
|
|
138
154
|
adapterOptions.cwd = cwd;
|
|
155
|
+
const externalGrant = grantAuthorizationFromContract(request, contract.value);
|
|
156
|
+
if (externalGrant !== undefined) {
|
|
157
|
+
adapterOptions.approvedExternalRoots = externalGrant.approvedExternalRoots;
|
|
158
|
+
adapterOptions.externalAuthorization = externalGrant.externalAuthorization;
|
|
159
|
+
}
|
|
139
160
|
if (this.options.allowlist !== undefined)
|
|
140
161
|
adapterOptions.allowlist = this.options.allowlist;
|
|
141
162
|
if (this.options.runner !== undefined)
|
|
@@ -193,6 +214,9 @@ function applyProgressCommandContract(input) {
|
|
|
193
214
|
const rawTargets = input.targetPaths;
|
|
194
215
|
if (rawTargets !== undefined && (!Array.isArray(rawTargets) || !rawTargets.every((item) => typeof item === 'string')))
|
|
195
216
|
return validationFailure('Allowlisted command targetPaths must be an array of strings.');
|
|
217
|
+
const grantEvaluation = parseGrantEvaluation(input.grantEvaluation);
|
|
218
|
+
if (!grantEvaluation.ok)
|
|
219
|
+
return grantEvaluation;
|
|
196
220
|
return {
|
|
197
221
|
ok: true,
|
|
198
222
|
value: {
|
|
@@ -200,9 +224,35 @@ function applyProgressCommandContract(input) {
|
|
|
200
224
|
...(workspaceRoot === undefined ? {} : { workspaceRoot }),
|
|
201
225
|
...(cwd === undefined ? {} : { cwd }),
|
|
202
226
|
targetPaths: rawTargets === undefined ? [] : rawTargets,
|
|
227
|
+
...(grantEvaluation.value === undefined ? {} : { grantEvaluation: grantEvaluation.value }),
|
|
203
228
|
},
|
|
204
229
|
};
|
|
205
230
|
}
|
|
231
|
+
function parseGrantEvaluation(input) {
|
|
232
|
+
if (input === undefined)
|
|
233
|
+
return { ok: true, value: undefined };
|
|
234
|
+
if (!isObject(input))
|
|
235
|
+
return validationFailure('Allowlisted command grantEvaluation must be an object when provided.');
|
|
236
|
+
if (input.matched !== true)
|
|
237
|
+
return { ok: true, value: undefined };
|
|
238
|
+
if (typeof input.selectedGrantId !== 'string' || input.selectedGrantId.trim().length === 0)
|
|
239
|
+
return validationFailure('Matched allowlisted command grantEvaluation requires selectedGrantId.');
|
|
240
|
+
if (typeof input.matchedExternalRoot !== 'string' || input.matchedExternalRoot.trim().length === 0)
|
|
241
|
+
return validationFailure('Matched allowlisted command grantEvaluation requires matchedExternalRoot.');
|
|
242
|
+
return { ok: true, value: { matched: true, selectedGrantId: input.selectedGrantId, matchedExternalRoot: input.matchedExternalRoot } };
|
|
243
|
+
}
|
|
244
|
+
function grantAuthorizationFromContract(request, contract) {
|
|
245
|
+
const evaluation = contract.grantEvaluation;
|
|
246
|
+
if (evaluation === undefined)
|
|
247
|
+
return undefined;
|
|
248
|
+
const agentId = request.operation.agentId ?? request.operation.agent?.id;
|
|
249
|
+
if (agentId === undefined || agentId.trim().length === 0)
|
|
250
|
+
return undefined;
|
|
251
|
+
return {
|
|
252
|
+
approvedExternalRoots: [evaluation.matchedExternalRoot],
|
|
253
|
+
externalAuthorization: { kind: 'task-scoped-command-grant', grantId: evaluation.selectedGrantId, runId: request.runId, agentId },
|
|
254
|
+
};
|
|
255
|
+
}
|
|
206
256
|
function isObject(value) {
|
|
207
257
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
208
258
|
}
|
package/docs/safety.md
CHANGED
|
@@ -68,6 +68,29 @@ The experimental `vgxness code` runtime approval flow was removed with `src/code
|
|
|
68
68
|
|
|
69
69
|
The gateway is conservative by default: a non-`read` tool in a non-`apply-progress` phase defaults to `ask`. Even with `--approval-policy allow`, the gateway can re-promote to `ask` for risky categories or workspace boundary issues.
|
|
70
70
|
|
|
71
|
+
## Task-scoped external command grants
|
|
72
|
+
|
|
73
|
+
Task-scoped command grants are a narrow V1 escape hatch for a human to authorize a bounded external working directory for an allowlisted command preflight. They only convert an eligible `ask` decision to an audited `allow`; they never convert `deny` or hard-risk requests to `allow`, and apply/implementation work still requires the normal SDD governance gates.
|
|
74
|
+
|
|
75
|
+
Operator-facing grant commands live under the `runs` command surface:
|
|
76
|
+
|
|
77
|
+
- `runs task-grant-create` creates an auditable, human-scoped grant lifecycle record through the run service.
|
|
78
|
+
- `runs task-grant-list` lists grant metadata for an existing run scope.
|
|
79
|
+
- `runs task-grant-get` reads one grant by ID.
|
|
80
|
+
- `runs task-grant-revoke` revokes an existing grant without deleting its audit record.
|
|
81
|
+
- `runs task-grant-preview` checks whether current grant metadata would match a proposed bounded command request.
|
|
82
|
+
|
|
83
|
+
`task-grant-preview` is diagnostic only: it is read-only, does not authorize execution, does not consume grant usage, and does not append authorization evidence events. A negative preview is still a successful preview result (`matched: false`) when the input is valid.
|
|
84
|
+
|
|
85
|
+
`task-grant-create` and `task-grant-revoke` are grant lifecycle mutations. They are auditable metadata operations, require explicit actor/reason context, and must go through `TaskScopedCommandGrantService`; they are not shortcuts around run preflight or SDD governance.
|
|
86
|
+
|
|
87
|
+
Grant lifecycle commands never execute shell, test, or git commands. They only create, read, revoke, or preview grant records. The service remains the policy authority for run eligibility, categories, allowlisted command IDs, root/cwd matching, expiry, usage limits, and hard-risk exclusions.
|
|
88
|
+
|
|
89
|
+
- V1 supports allowlisted command IDs only (`command-allowlist:<id>`). Arbitrary shell strings and executable path/argv grants are rejected.
|
|
90
|
+
- Grantable categories are `shell`, `test-run`, and `git`. `install`, `network`, `secrets`, `external-directory`, `provider-tool`, `edit`, `implementation-edit`, `git-write`, and `memory-write` fail closed.
|
|
91
|
+
- Every grant requires a human creation actor, at least one external root, a future expiry, and a positive `maxUses` limit. Preview matching is read-only and does not increment use count.
|
|
92
|
+
- Grants cannot be created for terminal runs (`completed`, `failed`, `blocked`, `cancelled`), but existing grants may still be listed, read, or revoked for audit cleanup.
|
|
93
|
+
|
|
71
94
|
## Redaction
|
|
72
95
|
|
|
73
96
|
Export redaction helpers remain in `src/export/redaction.ts`.
|