vgxness 1.20.3 → 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.
@@ -1,7 +1,6 @@
1
1
  import { spawn, spawnSync } from 'node:child_process';
2
- import { realpathSync } from 'node:fs';
3
- import { homedir } from 'node:os';
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
- if (!isInside(cwd, workspaceRoot))
17
- return rejected('cwd-escapes-workspace', `Process cwd escapes workspace boundary: ${request.cwd ?? request.workspaceRoot}`, workspaceRoot, cwd);
18
- if (isProviderConfigPath(cwd, workspaceRoot))
19
- return rejected('provider-config-cwd-rejected', `Process cwd targets provider/OpenCode config: ${request.cwd ?? request.workspaceRoot}`, workspaceRoot, cwd);
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) => isProviderConfigPath(targetPath, workspaceRoot));
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 realpathOrUndefined(path) {
203
- try {
204
- return realpathSync.native(resolve(path));
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
- catch {
207
- return undefined;
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 isInside(path, workspaceRoot) {
211
- return path === workspaceRoot || path.startsWith(`${workspaceRoot}${sep}`);
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
- if (absolutePath === resolve(workspaceRoot, 'opencode.json') || absolutePath === resolve(workspaceRoot, 'opencode.jsonc'))
219
- return true;
220
- const userOpenCodeDirectory = resolve(homedir(), '.config', 'opencode');
221
- return absolutePath === userOpenCodeDirectory || isInside(absolutePath, userOpenCodeDirectory);
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
+ }
@@ -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`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vgxness",
3
- "version": "1.20.3",
3
+ "version": "1.20.4",
4
4
  "description": "CLI and MCP control plane for guided AI-agent workflows, SDD, memory, and OpenCode setup.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "repository": {