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,126 @@
|
|
|
1
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
2
|
+
export function evaluateExplicitRequestAuthorization(input) {
|
|
3
|
+
const explicit = input.request.explicitRequest;
|
|
4
|
+
if (explicit === undefined)
|
|
5
|
+
return evaluation(input, 'ask', 'missing-context', ['explicit_request_missing']);
|
|
6
|
+
if (explicit.actor.type !== 'human')
|
|
7
|
+
return evaluation(input, 'ask', 'actor-mismatch', ['explicit_request_actor_mismatch']);
|
|
8
|
+
if (!validIsoTimestamp(explicit.request.requestedAt))
|
|
9
|
+
return evaluation(input, 'ask', 'timestamp-invalid', ['explicit_request_timestamp_invalid']);
|
|
10
|
+
if (explicit.request.runId !== undefined && input.request.runId !== undefined && explicit.request.runId !== input.request.runId)
|
|
11
|
+
return evaluation(input, 'ask', 'scope-mismatch', ['explicit_request_scope_mismatch']);
|
|
12
|
+
if (explicit.parser.exact !== true || explicit.parser.confidence === 'medium' || explicit.parser.confidence === 'low' || hasItems(explicit.parser.ambiguityReasons))
|
|
13
|
+
return evaluation(input, 'ask', 'ambiguous-request', ['explicit_request_ambiguous']);
|
|
14
|
+
if (hasItems(explicit.parser.expansionReasons))
|
|
15
|
+
return evaluation(input, 'ask', 'scope-expanded', ['explicit_request_scope_expanded']);
|
|
16
|
+
if (isSddAcceptanceAttempt(input.request))
|
|
17
|
+
return evaluation(input, 'deny', 'hard-stop', ['sdd_human_acceptance_required']);
|
|
18
|
+
if (input.request.category === 'secrets' || input.risk.reasonCodes.includes('secret_access') || explicit.riskAttributes.secrets === true)
|
|
19
|
+
return evaluation(input, 'deny', 'hard-stop', ['explicit_request_secret_access']);
|
|
20
|
+
if (explicit.requestedOperation.category !== input.request.category)
|
|
21
|
+
return evaluation(input, 'ask', 'category-mismatch', ['explicit_request_category_mismatch']);
|
|
22
|
+
if (normalizeOperation(explicit.requestedOperation.operation) !== normalizeOperation(input.request.operation))
|
|
23
|
+
return evaluation(input, 'ask', 'operation-mismatch', ['explicit_request_operation_mismatch']);
|
|
24
|
+
if (!optionalExact(explicit.requestedOperation.commandId, input.request.commandId))
|
|
25
|
+
return evaluation(input, 'ask', 'scope-mismatch', ['explicit_request_scope_mismatch']);
|
|
26
|
+
if (!optionalExact(explicit.requestedOperation.providerToolName, input.request.providerToolName))
|
|
27
|
+
return evaluation(input, 'ask', 'scope-mismatch', ['explicit_request_scope_mismatch']);
|
|
28
|
+
if (!optionalPathExact(explicit.requestedOperation.workspaceRoot, input.request.workspaceRoot, input.request.workspaceRoot))
|
|
29
|
+
return evaluation(input, 'ask', 'scope-mismatch', ['explicit_request_scope_mismatch']);
|
|
30
|
+
if (!optionalPathExact(explicit.requestedOperation.cwd, input.request.cwd, input.request.workspaceRoot))
|
|
31
|
+
return evaluation(input, 'ask', 'scope-mismatch', ['explicit_request_scope_mismatch']);
|
|
32
|
+
if (!targetSetMatches(input.request, explicit.requestedOperation))
|
|
33
|
+
return evaluation(input, 'ask', 'scope-mismatch', ['explicit_request_scope_mismatch']);
|
|
34
|
+
const riskMismatch = riskMismatchReasons(input.request, input.risk, explicit.riskAttributes);
|
|
35
|
+
if (riskMismatch.length > 0)
|
|
36
|
+
return evaluation(input, 'ask', 'risk-attribute-mismatch', riskMismatch);
|
|
37
|
+
return evaluation(input, 'allow', 'exact-match', ['explicit_request_exact_match']);
|
|
38
|
+
}
|
|
39
|
+
function evaluation(input, decision, matchStatus, reasonCodes) {
|
|
40
|
+
const explicit = input.request.explicitRequest;
|
|
41
|
+
return {
|
|
42
|
+
decision,
|
|
43
|
+
audit: {
|
|
44
|
+
authorization: 'explicit-request',
|
|
45
|
+
matched: decision === 'allow',
|
|
46
|
+
matchStatus,
|
|
47
|
+
...(explicit?.request.id === undefined ? {} : { requestRef: explicit.request.id }),
|
|
48
|
+
...(explicit?.actor === undefined ? {} : { actor: explicit.actor }),
|
|
49
|
+
...(explicit?.request.requestedAt === undefined ? {} : { requestedAt: explicit.request.requestedAt }),
|
|
50
|
+
evaluatedAt: input.evaluatedAt,
|
|
51
|
+
runId: input.request.runId ?? explicit?.request.runId ?? '',
|
|
52
|
+
category: input.request.category,
|
|
53
|
+
operation: input.request.operation,
|
|
54
|
+
scopeSummary: explicit?.requestedOperation.scopeSummary ?? '',
|
|
55
|
+
riskAttributes: explicitRiskAttributes(explicit?.riskAttributes),
|
|
56
|
+
reasonCodes,
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function riskMismatchReasons(request, risk, explicit) {
|
|
61
|
+
const reasons = [];
|
|
62
|
+
if (request.destructive === true && explicit.destructive !== true)
|
|
63
|
+
reasons.push('explicit_request_destructive_not_explicit');
|
|
64
|
+
if (request.privileged === true && explicit.privileged !== true)
|
|
65
|
+
reasons.push('explicit_request_privileged_not_explicit');
|
|
66
|
+
if ((request.external === true || risk.reasonCodes.includes('external_operation')) && explicit.external !== true && explicit.network !== true && explicit.install !== true)
|
|
67
|
+
reasons.push('explicit_request_external_not_explicit');
|
|
68
|
+
if (request.ambiguous === true && explicit.ambiguous !== true)
|
|
69
|
+
reasons.push('explicit_request_ambiguous');
|
|
70
|
+
if (request.globalConfigMutation === true && explicit.globalConfigMutation !== true)
|
|
71
|
+
reasons.push('explicit_request_global_config_not_explicit');
|
|
72
|
+
if (request.providerConfigMutation === true && explicit.providerConfigMutation !== true)
|
|
73
|
+
reasons.push('explicit_request_provider_config_not_explicit');
|
|
74
|
+
if (request.providerConfigMutation !== true && request.globalConfigMutation !== true && risk.reasonCodes.includes('provider_config_write') && explicit.providerConfigMutation !== true)
|
|
75
|
+
reasons.push('explicit_request_provider_config_not_explicit');
|
|
76
|
+
if (request.category === 'network' && explicit.network !== true)
|
|
77
|
+
reasons.push('explicit_request_risk_mismatch');
|
|
78
|
+
if (request.category === 'install' && explicit.install !== true)
|
|
79
|
+
reasons.push('explicit_request_risk_mismatch');
|
|
80
|
+
return [...new Set(reasons)];
|
|
81
|
+
}
|
|
82
|
+
function targetSetMatches(request, explicit) {
|
|
83
|
+
const requested = targetSet(request.targetPath, request.targetPaths, request.workspaceRoot);
|
|
84
|
+
const authorized = targetSet(explicit.targetPath, explicit.targetPaths, request.workspaceRoot ?? explicit.workspaceRoot);
|
|
85
|
+
if (requested.length === 0 && authorized.length === 0)
|
|
86
|
+
return true;
|
|
87
|
+
if (requested.length === 0 || authorized.length === 0)
|
|
88
|
+
return false;
|
|
89
|
+
if (requested.length !== authorized.length)
|
|
90
|
+
return false;
|
|
91
|
+
return requested.every((value, index) => value === authorized[index]);
|
|
92
|
+
}
|
|
93
|
+
function targetSet(targetPath, targetPaths, workspaceRoot) {
|
|
94
|
+
return [...new Set([...(targetPath === undefined ? [] : [targetPath]), ...(targetPaths ?? [])].map((path) => normalizePath(path, workspaceRoot)))].sort();
|
|
95
|
+
}
|
|
96
|
+
function optionalExact(explicit, actual) {
|
|
97
|
+
return actual === undefined ? explicit === undefined : explicit === actual;
|
|
98
|
+
}
|
|
99
|
+
function optionalPathExact(explicit, actual, workspaceRoot) {
|
|
100
|
+
return actual === undefined ? explicit === undefined : explicit !== undefined && normalizePath(explicit, workspaceRoot) === normalizePath(actual, workspaceRoot);
|
|
101
|
+
}
|
|
102
|
+
function normalizePath(path, workspaceRoot) {
|
|
103
|
+
const base = workspaceRoot === undefined ? undefined : resolve(workspaceRoot);
|
|
104
|
+
return resolve(isAbsolute(path) || base === undefined ? path : resolve(base, path));
|
|
105
|
+
}
|
|
106
|
+
function normalizeOperation(value) {
|
|
107
|
+
return value.trim().replace(/\s+/g, ' ');
|
|
108
|
+
}
|
|
109
|
+
function hasItems(values) {
|
|
110
|
+
return values !== undefined && values.length > 0;
|
|
111
|
+
}
|
|
112
|
+
function validIsoTimestamp(value) {
|
|
113
|
+
const timestamp = Date.parse(value);
|
|
114
|
+
return Number.isFinite(timestamp) && new Date(timestamp).toISOString() === value && timestamp <= Date.now() + 60_000;
|
|
115
|
+
}
|
|
116
|
+
function explicitRiskAttributes(value) {
|
|
117
|
+
const output = {};
|
|
118
|
+
for (const key of ['destructive', 'privileged', 'external', 'ambiguous', 'secrets', 'providerConfigMutation', 'globalConfigMutation', 'network', 'install']) {
|
|
119
|
+
output[key] = value?.[key] === true;
|
|
120
|
+
}
|
|
121
|
+
return output;
|
|
122
|
+
}
|
|
123
|
+
function isSddAcceptanceAttempt(request) {
|
|
124
|
+
const text = `${request.category} ${request.operation} ${request.providerToolName ?? ''}`.toLowerCase();
|
|
125
|
+
return /vgxness_sdd_accept_artifact|sdd_accept_artifact|artifact acceptance|accept artifact|acceptedby/.test(text);
|
|
126
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { basename, dirname, isAbsolute, resolve, sep } from 'node:path';
|
|
4
|
+
export function canonicalizeExistingPath(path) {
|
|
5
|
+
try {
|
|
6
|
+
return realpathSync.native(resolve(path));
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function isPathInside(path, root) {
|
|
13
|
+
return path === root || path.startsWith(`${root}${sep}`);
|
|
14
|
+
}
|
|
15
|
+
export function resolveCanonicalContainedPath(input) {
|
|
16
|
+
const root = canonicalizeExistingPath(input.root);
|
|
17
|
+
if (root === undefined)
|
|
18
|
+
return { ok: false, reason: 'root-not-found' };
|
|
19
|
+
const requested = resolve(root, input.requestedPath);
|
|
20
|
+
const existing = canonicalizeExistingPath(requested);
|
|
21
|
+
if (existing !== undefined) {
|
|
22
|
+
if (!isPathInside(existing, root))
|
|
23
|
+
return { ok: false, reason: 'path-escapes-root' };
|
|
24
|
+
return { ok: true, value: { root, path: existing } };
|
|
25
|
+
}
|
|
26
|
+
if (!input.allowNonexistentLeaf)
|
|
27
|
+
return { ok: false, reason: 'path-not-found' };
|
|
28
|
+
const nearest = nearestExistingAncestor(requested);
|
|
29
|
+
if (nearest === undefined)
|
|
30
|
+
return { ok: false, reason: 'path-ancestor-not-found' };
|
|
31
|
+
const canonicalAncestor = canonicalizeExistingPath(nearest);
|
|
32
|
+
if (canonicalAncestor === undefined)
|
|
33
|
+
return { ok: false, reason: 'path-ancestor-not-found' };
|
|
34
|
+
if (!isPathInside(canonicalAncestor, root))
|
|
35
|
+
return { ok: false, reason: 'path-escapes-root' };
|
|
36
|
+
const suffix = requested.slice(nearest.length).replace(/^[/\\]+/u, '').split(/[\\/]+/u).filter(Boolean);
|
|
37
|
+
const canonicalPath = resolve(canonicalAncestor, ...suffix);
|
|
38
|
+
if (!isPathInside(canonicalPath, root))
|
|
39
|
+
return { ok: false, reason: 'path-escapes-root' };
|
|
40
|
+
return { ok: true, value: { root, path: canonicalPath } };
|
|
41
|
+
}
|
|
42
|
+
export function validateSafeGrantRoot(rootPath) {
|
|
43
|
+
if (!isAbsolute(rootPath))
|
|
44
|
+
return { ok: false, reason: 'grant-root-relative' };
|
|
45
|
+
const canonicalRoot = canonicalizeExistingPath(rootPath);
|
|
46
|
+
if (canonicalRoot === undefined)
|
|
47
|
+
return { ok: false, reason: 'grant-root-not-found' };
|
|
48
|
+
const home = canonicalizeExistingPath(homedir()) ?? resolve(homedir());
|
|
49
|
+
if (canonicalRoot === resolve(sep))
|
|
50
|
+
return { ok: false, reason: 'grant-root-filesystem-root' };
|
|
51
|
+
if (canonicalRoot === home)
|
|
52
|
+
return { ok: false, reason: 'grant-root-home-directory' };
|
|
53
|
+
if (basename(canonicalRoot) === '.opencode' || basename(canonicalRoot) === '.claude')
|
|
54
|
+
return { ok: false, reason: 'grant-root-provider-config' };
|
|
55
|
+
if (basename(canonicalRoot) === 'opencode.json' || basename(canonicalRoot) === 'opencode.jsonc')
|
|
56
|
+
return { ok: false, reason: 'grant-root-provider-config' };
|
|
57
|
+
if (isProviderConfigPath(canonicalRoot, process.cwd()))
|
|
58
|
+
return { ok: false, reason: 'grant-root-provider-config' };
|
|
59
|
+
return { ok: true, value: { canonicalRoot } };
|
|
60
|
+
}
|
|
61
|
+
export function isUnsafeGrantRoot(rootPath) {
|
|
62
|
+
return !validateSafeGrantRoot(rootPath).ok;
|
|
63
|
+
}
|
|
64
|
+
export function isProviderConfigPath(path, workspaceRoot) {
|
|
65
|
+
const workspace = resolve(workspaceRoot);
|
|
66
|
+
const absolutePath = isAbsolute(path) ? resolve(path) : resolve(workspace, path);
|
|
67
|
+
const projectOpenCodeDirectory = resolve(workspace, '.opencode');
|
|
68
|
+
if (absolutePath === projectOpenCodeDirectory || isPathInside(absolutePath, projectOpenCodeDirectory))
|
|
69
|
+
return true;
|
|
70
|
+
if (absolutePath === resolve(workspace, 'opencode.json') || absolutePath === resolve(workspace, 'opencode.jsonc'))
|
|
71
|
+
return true;
|
|
72
|
+
const userOpenCodeDirectory = resolve(homedir(), '.config', 'opencode');
|
|
73
|
+
if (absolutePath === userOpenCodeDirectory || isPathInside(absolutePath, userOpenCodeDirectory))
|
|
74
|
+
return true;
|
|
75
|
+
const userClaudeDirectory = resolve(homedir(), '.claude');
|
|
76
|
+
return absolutePath === userClaudeDirectory || isPathInside(absolutePath, userClaudeDirectory);
|
|
77
|
+
}
|
|
78
|
+
function nearestExistingAncestor(path) {
|
|
79
|
+
let current = resolve(path);
|
|
80
|
+
while (!existsSync(current)) {
|
|
81
|
+
const parent = dirname(current);
|
|
82
|
+
if (parent === current)
|
|
83
|
+
return undefined;
|
|
84
|
+
current = parent;
|
|
85
|
+
}
|
|
86
|
+
return current;
|
|
87
|
+
}
|
|
@@ -3,6 +3,7 @@ import { dirname, isAbsolute, resolve, sep } from 'node:path';
|
|
|
3
3
|
import { classifyOperationRisk } from '../governance/risk-classifier.js';
|
|
4
4
|
import { isSddPhase, sddPhases } from '../sdd/schema.js';
|
|
5
5
|
import { commandAllowlistIds } from '../workflows/command-allowlist-adapter.js';
|
|
6
|
+
import { evaluateExplicitRequestAuthorization } from './explicit-request-authorization.js';
|
|
6
7
|
export { isRiskyPermissionCategory, permissionCategories, riskyPermissionCategories } from './schema.js';
|
|
7
8
|
export const permissionDecisions = ['allow', 'ask', 'deny'];
|
|
8
9
|
export const permissionModes = ['allow', 'audit', 'require-preflight', 'deny'];
|
|
@@ -85,30 +86,30 @@ const filesystemCategories = new Set([
|
|
|
85
86
|
export function evaluatePermission(request, policy = defaultPermissionPolicy) {
|
|
86
87
|
const risk = classifyOperationRisk(request);
|
|
87
88
|
if (request.category === 'secrets')
|
|
88
|
-
return result(request, 'deny', 'secret_access', 'Secret access is denied by default.', {}, risk);
|
|
89
|
+
return withExplicitRequestAuthorization(request, result(request, 'deny', 'secret_access', 'Secret access is denied by default.', {}, risk), risk);
|
|
89
90
|
if (request.category === 'external-directory') {
|
|
90
|
-
return result(request, 'deny', 'workspace_boundary', 'External directory access is denied by the foundation policy.', {}, risk);
|
|
91
|
+
return withExplicitRequestAuthorization(request, result(request, 'deny', 'workspace_boundary', 'External directory access is denied by the foundation policy.', {}, risk), risk);
|
|
91
92
|
}
|
|
92
93
|
const hardRisk = hardRiskDecision(request, risk);
|
|
93
94
|
if (hardRisk !== undefined)
|
|
94
|
-
return hardRisk;
|
|
95
|
+
return withExplicitRequestAuthorization(request, hardRisk, risk);
|
|
95
96
|
const workspaceEscape = workspaceEscapeDecision(request);
|
|
96
97
|
if (workspaceEscape !== undefined)
|
|
97
|
-
return workspaceEscape;
|
|
98
|
+
return withExplicitRequestAuthorization(request, workspaceEscape, risk);
|
|
98
99
|
const verifyAllowlistedCommand = verifyAllowlistedCommandDecision(request, risk);
|
|
99
100
|
if (verifyAllowlistedCommand !== undefined)
|
|
100
101
|
return verifyAllowlistedCommand;
|
|
101
102
|
const sddPhaseDecision = phaseMatrixDecision(request);
|
|
102
103
|
if (sddPhaseDecision !== undefined && sddPhaseDecision.mode !== 'allow' && sddPhaseDecision.mode !== 'audit')
|
|
103
|
-
return sddPhaseDecision;
|
|
104
|
+
return withExplicitRequestAuthorization(request, sddPhaseDecision, risk);
|
|
104
105
|
const explicitValidation = explicitValidationDecision(request, risk);
|
|
105
106
|
if (explicitValidation !== undefined)
|
|
106
|
-
return explicitValidation;
|
|
107
|
+
return withExplicitRequestAuthorization(request, explicitValidation, risk);
|
|
107
108
|
if (sddPhaseDecision !== undefined)
|
|
108
109
|
return sddPhaseDecision;
|
|
109
110
|
const boundary = workspaceBoundaryDecision(request);
|
|
110
111
|
if (boundary !== undefined)
|
|
111
|
-
return boundary;
|
|
112
|
+
return withExplicitRequestAuthorization(request, boundary, risk);
|
|
112
113
|
const agentDecision = request.agent?.permissions[request.category];
|
|
113
114
|
const baseRule = policy.rules.find((rule) => rule.category === request.category);
|
|
114
115
|
const baseDecision = agentDecision ?? baseRule?.decision ?? 'ask';
|
|
@@ -118,7 +119,7 @@ export function evaluatePermission(request, policy = defaultPermissionPolicy) {
|
|
|
118
119
|
: (baseRule?.reason ?? `No explicit rule exists for ${request.category}; ask by default.`);
|
|
119
120
|
const flagRisk = riskDecision(request);
|
|
120
121
|
if (flagRisk !== undefined && isLessRestrictive(baseDecision, flagRisk.decision)) {
|
|
121
|
-
return result(request, flagRisk.decision, flagRisk.reason, flagRisk.message, {}, risk);
|
|
122
|
+
return withExplicitRequestAuthorization(request, result(request, flagRisk.decision, flagRisk.reason, flagRisk.message, {}, risk), risk);
|
|
122
123
|
}
|
|
123
124
|
if (risk.defaultDecision === 'audit' && baseDecision !== 'deny') {
|
|
124
125
|
return result(request, 'allow', risk.reasonCodes.includes('provider_readonly_audit') ? 'provider_readonly_audit' : risk.reasonCodes.includes('allowlisted_verification') ? 'allowlisted_command' : baseReason, baseMessage, {
|
|
@@ -127,7 +128,39 @@ export function evaluatePermission(request, policy = defaultPermissionPolicy) {
|
|
|
127
128
|
warnings: risk.reasonCodes.includes('provider_readonly_audit') ? ['provider-readonly-audit-only'] : ['audit-only'],
|
|
128
129
|
}, risk);
|
|
129
130
|
}
|
|
130
|
-
return result(request, baseDecision, baseReason, baseMessage, {}, risk);
|
|
131
|
+
return withExplicitRequestAuthorization(request, result(request, baseDecision, baseReason, baseMessage, {}, risk), risk);
|
|
132
|
+
}
|
|
133
|
+
function withExplicitRequestAuthorization(request, decision, risk) {
|
|
134
|
+
if (request.explicitRequest === undefined)
|
|
135
|
+
return decision;
|
|
136
|
+
const explicit = evaluateExplicitRequestAuthorization({ request, risk, evaluatedAt: new Date().toISOString() });
|
|
137
|
+
if (decision.decision !== 'ask') {
|
|
138
|
+
return { ...decision, explicitRequestAudit: explicit.audit, auditEvidence: [...(decision.auditEvidence ?? []), `explicitRequest:${explicit.audit.matchStatus}`] };
|
|
139
|
+
}
|
|
140
|
+
if (explicit.decision === 'allow') {
|
|
141
|
+
return {
|
|
142
|
+
...decision,
|
|
143
|
+
decision: 'allow',
|
|
144
|
+
reason: 'explicit_request_authorization',
|
|
145
|
+
message: 'Explicit human request authorizes this exact one-off operation.',
|
|
146
|
+
explicitRequestAudit: explicit.audit,
|
|
147
|
+
authorizationMode: 'explicit-request',
|
|
148
|
+
auditEvidence: [...(decision.auditEvidence ?? []), 'explicitRequest:exact-match'],
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
if (explicit.decision === 'deny') {
|
|
152
|
+
return {
|
|
153
|
+
...decision,
|
|
154
|
+
decision: 'deny',
|
|
155
|
+
reason: explicit.audit.reasonCodes.includes('sdd_human_acceptance_required') ? 'sdd_phase_policy' : 'risk_tier_policy',
|
|
156
|
+
message: explicit.audit.reasonCodes.includes('sdd_human_acceptance_required')
|
|
157
|
+
? 'SDD artifact acceptance is human-only and cannot be inferred from explicit-request preflight.'
|
|
158
|
+
: 'Explicit request contains a hard-stop condition and cannot authorize this operation.',
|
|
159
|
+
explicitRequestAudit: explicit.audit,
|
|
160
|
+
auditEvidence: [...(decision.auditEvidence ?? []), `explicitRequest:${explicit.audit.matchStatus}`],
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return { ...decision, explicitRequestAudit: explicit.audit, auditEvidence: [...(decision.auditEvidence ?? []), `explicitRequest:${explicit.audit.matchStatus}`] };
|
|
131
164
|
}
|
|
132
165
|
export function isPathInsideWorkspace(workspaceRoot, targetPath) {
|
|
133
166
|
const root = resolve(workspaceRoot);
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { canonicalizeExistingPath, isPathInside, isProviderConfigPath, resolveCanonicalContainedPath } from './path-boundaries.js';
|
|
2
|
+
const grantableCategories = new Set(['shell', 'test-run', 'git']);
|
|
3
|
+
export function matchTaskScopedCommandGrant(input) {
|
|
4
|
+
const known = new Set(input.knownAllowlistIds);
|
|
5
|
+
const commandId = input.request.commandId ?? commandIdFromOperation(input.request.operation);
|
|
6
|
+
const hardMissReasons = hardRequestMissReasons(input.request, commandId, known);
|
|
7
|
+
const candidateEvidence = input.candidates.map((grant) => evaluateCandidate({ ...input, commandId, hardMissReasons, grant }));
|
|
8
|
+
const matched = candidateEvidence.filter((candidate) => candidate.matched);
|
|
9
|
+
const sorted = [...matched].sort((left, right) => compareMatchedCandidates(left.grantId, right.grantId, input.candidates));
|
|
10
|
+
const selectedGrantId = sorted[0]?.grantId;
|
|
11
|
+
const candidates = candidateEvidence.map((candidate) => ({ ...candidate, selected: candidate.grantId === selectedGrantId }));
|
|
12
|
+
const selected = candidates.find((candidate) => candidate.selected);
|
|
13
|
+
const result = {
|
|
14
|
+
matchedGrantIds: matched.map((candidate) => candidate.grantId).sort(),
|
|
15
|
+
selectedOrder: sorted.map((candidate) => candidate.grantId),
|
|
16
|
+
missReasons: collectMissReasons(candidates),
|
|
17
|
+
candidates,
|
|
18
|
+
};
|
|
19
|
+
if (selectedGrantId !== undefined)
|
|
20
|
+
result.selectedGrantId = selectedGrantId;
|
|
21
|
+
if (selected?.canonicalCwd !== undefined)
|
|
22
|
+
result.canonicalCwd = selected.canonicalCwd;
|
|
23
|
+
if (selected?.matchedExternalRoot !== undefined)
|
|
24
|
+
result.matchedExternalRoot = selected.matchedExternalRoot;
|
|
25
|
+
if (commandId !== undefined)
|
|
26
|
+
result.commandId = commandId;
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
function evaluateCandidate(input) {
|
|
30
|
+
const missReasons = [...input.hardMissReasons];
|
|
31
|
+
const grant = input.grant;
|
|
32
|
+
if (grant.status !== 'active')
|
|
33
|
+
missReasons.push(`${grant.status}-grant`);
|
|
34
|
+
if (Date.parse(grant.expiresAt) <= input.now.getTime())
|
|
35
|
+
missReasons.push('expired');
|
|
36
|
+
if (grant.usedCount >= grant.maxUses)
|
|
37
|
+
missReasons.push('exhausted');
|
|
38
|
+
if (grant.runId !== input.request.runId)
|
|
39
|
+
missReasons.push('run-mismatch');
|
|
40
|
+
if (grant.agentId !== input.request.agentId)
|
|
41
|
+
missReasons.push('agent-mismatch');
|
|
42
|
+
if (grant.taskFingerprint !== input.request.taskFingerprint)
|
|
43
|
+
missReasons.push('task-fingerprint-mismatch');
|
|
44
|
+
if (!grant.categories.some((category) => category === input.request.category))
|
|
45
|
+
missReasons.push('category-mismatch');
|
|
46
|
+
if (input.commandId === undefined || !grant.commandPolicy.ids.includes(input.commandId))
|
|
47
|
+
missReasons.push('command-mismatch');
|
|
48
|
+
const rootMatch = matchRootForRequest(grant.canonicalExternalRoots, input.request.cwd, input.request.targetPaths ?? [], input.workspaceRoot);
|
|
49
|
+
if (!rootMatch.ok)
|
|
50
|
+
missReasons.push(rootMatch.reason);
|
|
51
|
+
const evidence = {
|
|
52
|
+
grantId: grant.id,
|
|
53
|
+
matched: missReasons.length === 0,
|
|
54
|
+
selected: false,
|
|
55
|
+
missReasons,
|
|
56
|
+
};
|
|
57
|
+
if (rootMatch.ok) {
|
|
58
|
+
evidence.canonicalCwd = rootMatch.value.canonicalCwd;
|
|
59
|
+
evidence.matchedExternalRoot = rootMatch.value.matchedExternalRoot;
|
|
60
|
+
}
|
|
61
|
+
if (input.commandId !== undefined)
|
|
62
|
+
evidence.commandId = input.commandId;
|
|
63
|
+
return evidence;
|
|
64
|
+
}
|
|
65
|
+
function hardRequestMissReasons(request, commandId, knownAllowlistIds) {
|
|
66
|
+
const reasons = [];
|
|
67
|
+
if (!grantableCategories.has(request.category))
|
|
68
|
+
reasons.push('unsupported-category');
|
|
69
|
+
if (request.destructive === true)
|
|
70
|
+
reasons.push('destructive-operation');
|
|
71
|
+
if (request.privileged === true)
|
|
72
|
+
reasons.push('privileged-operation');
|
|
73
|
+
if (request.ambiguous === true)
|
|
74
|
+
reasons.push('ambiguous-operation');
|
|
75
|
+
if (commandId === undefined)
|
|
76
|
+
reasons.push('missing-command-id');
|
|
77
|
+
else if (!knownAllowlistIds.has(commandId))
|
|
78
|
+
reasons.push('unknown-command-id');
|
|
79
|
+
return reasons;
|
|
80
|
+
}
|
|
81
|
+
function matchRootForRequest(canonicalRoots, cwd, targetPaths, workspaceRoot) {
|
|
82
|
+
if (isProviderConfigPath(cwd, workspaceRoot))
|
|
83
|
+
return { ok: false, reason: 'provider-config-path-blocked' };
|
|
84
|
+
const canonicalCwd = canonicalizeExistingPath(cwd);
|
|
85
|
+
if (canonicalCwd === undefined)
|
|
86
|
+
return { ok: false, reason: 'cwd-not-found' };
|
|
87
|
+
if (isProviderConfigPath(canonicalCwd, workspaceRoot))
|
|
88
|
+
return { ok: false, reason: 'provider-config-path-blocked' };
|
|
89
|
+
const matchedRoot = canonicalRoots.find((root) => isPathInside(canonicalCwd, root));
|
|
90
|
+
if (matchedRoot === undefined)
|
|
91
|
+
return { ok: false, reason: 'cwd-root-mismatch' };
|
|
92
|
+
for (const targetPath of targetPaths) {
|
|
93
|
+
if (isProviderConfigPath(targetPath, workspaceRoot))
|
|
94
|
+
return { ok: false, reason: 'provider-config-path-blocked' };
|
|
95
|
+
const resolved = resolveCanonicalContainedPath({ root: matchedRoot, requestedPath: targetPath, allowNonexistentLeaf: true });
|
|
96
|
+
if (!resolved.ok)
|
|
97
|
+
return { ok: false, reason: 'target-root-mismatch' };
|
|
98
|
+
if (isProviderConfigPath(resolved.value.path, workspaceRoot))
|
|
99
|
+
return { ok: false, reason: 'provider-config-path-blocked' };
|
|
100
|
+
}
|
|
101
|
+
return { ok: true, value: { canonicalCwd, matchedExternalRoot: matchedRoot } };
|
|
102
|
+
}
|
|
103
|
+
function compareMatchedCandidates(leftId, rightId, grants) {
|
|
104
|
+
const left = grants.find((grant) => grant.id === leftId);
|
|
105
|
+
const right = grants.find((grant) => grant.id === rightId);
|
|
106
|
+
if (left === undefined || right === undefined)
|
|
107
|
+
return leftId.localeCompare(rightId);
|
|
108
|
+
return Date.parse(left.expiresAt) - Date.parse(right.expiresAt) || Date.parse(left.createdAt) - Date.parse(right.createdAt) || left.id.localeCompare(right.id);
|
|
109
|
+
}
|
|
110
|
+
function collectMissReasons(candidates) {
|
|
111
|
+
if (candidates.length === 0)
|
|
112
|
+
return ['no-candidates'];
|
|
113
|
+
return [...new Set(candidates.flatMap((candidate) => candidate.missReasons))].sort();
|
|
114
|
+
}
|
|
115
|
+
function commandIdFromOperation(operation) {
|
|
116
|
+
return /^command-allowlist:([A-Za-z0-9._/-]+)$/u.exec(operation)?.[1];
|
|
117
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { validateSafeGrantRoot } from './path-boundaries.js';
|
|
2
|
+
const grantableCategories = new Set(['shell', 'test-run', 'git']);
|
|
3
|
+
const rejectedCategories = new Set([
|
|
4
|
+
'secrets',
|
|
5
|
+
'external-directory',
|
|
6
|
+
'provider-tool',
|
|
7
|
+
'edit',
|
|
8
|
+
'implementation-edit',
|
|
9
|
+
'git-write',
|
|
10
|
+
'memory-write',
|
|
11
|
+
'install',
|
|
12
|
+
'network',
|
|
13
|
+
]);
|
|
14
|
+
const maxExpiryMs = 24 * 60 * 60 * 1000;
|
|
15
|
+
export function validateTaskScopedCommandGrantCreationInput(input, options) {
|
|
16
|
+
if (!input.runId.trim())
|
|
17
|
+
return validationFailure('Grant runId is required');
|
|
18
|
+
if (!input.agentId.trim())
|
|
19
|
+
return validationFailure('Grant agentId is required');
|
|
20
|
+
if (!input.taskFingerprint.trim())
|
|
21
|
+
return validationFailure('Grant taskFingerprint is required');
|
|
22
|
+
if (input.createdBy.type !== 'human')
|
|
23
|
+
return validationFailure('Grant creator must be a human actor');
|
|
24
|
+
if (!input.createdBy.id.trim())
|
|
25
|
+
return validationFailure('Grant creator id is required');
|
|
26
|
+
const categories = validateCategories(input.categories);
|
|
27
|
+
if (!categories.ok)
|
|
28
|
+
return categories;
|
|
29
|
+
const commandPolicy = validateCommandPolicy(input.commandPolicy, options.knownAllowlistIds);
|
|
30
|
+
if (!commandPolicy.ok)
|
|
31
|
+
return commandPolicy;
|
|
32
|
+
const cwdPatterns = validateOptionalStringList(input.cwdPatterns, 'Grant cwdPatterns');
|
|
33
|
+
if (!cwdPatterns.ok)
|
|
34
|
+
return cwdPatterns;
|
|
35
|
+
const expiry = validateExpiry(input.expiresAt, options.now);
|
|
36
|
+
if (!expiry.ok)
|
|
37
|
+
return expiry;
|
|
38
|
+
if (!Number.isInteger(input.maxUses) || input.maxUses < 1)
|
|
39
|
+
return validationFailure('Grant maxUses must be a positive integer');
|
|
40
|
+
if (!input.audit.creationReason.trim())
|
|
41
|
+
return validationFailure('Grant audit creationReason is required');
|
|
42
|
+
if (!input.audit.approvedScopeSummary.trim())
|
|
43
|
+
return validationFailure('Grant audit approvedScopeSummary is required');
|
|
44
|
+
if (input.externalRoots.length === 0)
|
|
45
|
+
return validationFailure('Grant externalRoots are required');
|
|
46
|
+
const canonicalExternalRoots = [];
|
|
47
|
+
for (const root of input.externalRoots) {
|
|
48
|
+
const safe = validateSafeGrantRoot(root);
|
|
49
|
+
if (!safe.ok)
|
|
50
|
+
return validationFailure(`Grant external root is unsafe: ${safe.reason}`);
|
|
51
|
+
canonicalExternalRoots.push(safe.value.canonicalRoot);
|
|
52
|
+
}
|
|
53
|
+
if (new Set(canonicalExternalRoots).size !== canonicalExternalRoots.length)
|
|
54
|
+
return validationFailure('Grant external roots must be unique');
|
|
55
|
+
const createdBy = { type: 'human', id: input.createdBy.id.trim() };
|
|
56
|
+
const validated = {
|
|
57
|
+
runId: input.runId,
|
|
58
|
+
agentId: input.agentId,
|
|
59
|
+
taskFingerprint: input.taskFingerprint,
|
|
60
|
+
categories: categories.value,
|
|
61
|
+
externalRoots: input.externalRoots,
|
|
62
|
+
canonicalExternalRoots,
|
|
63
|
+
commandPolicy: commandPolicy.value,
|
|
64
|
+
expiresAt: input.expiresAt,
|
|
65
|
+
maxUses: input.maxUses,
|
|
66
|
+
createdBy,
|
|
67
|
+
audit: input.audit,
|
|
68
|
+
};
|
|
69
|
+
if (cwdPatterns.value !== undefined)
|
|
70
|
+
validated.cwdPatterns = cwdPatterns.value;
|
|
71
|
+
if (input.createdBy.displayName !== undefined)
|
|
72
|
+
validated.createdBy.displayName = input.createdBy.displayName;
|
|
73
|
+
return ok(validated);
|
|
74
|
+
}
|
|
75
|
+
function validateCategories(categories) {
|
|
76
|
+
if (!Array.isArray(categories) || categories.length === 0)
|
|
77
|
+
return validationFailure('Grant categories are required');
|
|
78
|
+
if (new Set(categories).size !== categories.length)
|
|
79
|
+
return validationFailure('Grant categories must be unique');
|
|
80
|
+
for (const category of categories) {
|
|
81
|
+
if (rejectedCategories.has(category))
|
|
82
|
+
return validationFailure(`Unsupported grant category: ${category}`);
|
|
83
|
+
if (!grantableCategories.has(category))
|
|
84
|
+
return validationFailure(`Unsupported grant category: ${category}`);
|
|
85
|
+
}
|
|
86
|
+
return ok(categories);
|
|
87
|
+
}
|
|
88
|
+
function validateCommandPolicy(policy, knownAllowlistIds) {
|
|
89
|
+
if (!isRecord(policy))
|
|
90
|
+
return validationFailure('Grant command policy is required');
|
|
91
|
+
if (policy.kind !== 'allowlisted-command-ids')
|
|
92
|
+
return validationFailure('Grant command policy must use allowlisted command ids');
|
|
93
|
+
if (!Array.isArray(policy.ids) || policy.ids.length === 0)
|
|
94
|
+
return validationFailure('Grant command policy ids are required');
|
|
95
|
+
if (!policy.ids.every((id) => typeof id === 'string' && id.trim().length > 0))
|
|
96
|
+
return validationFailure('Grant command policy ids must be non-empty strings');
|
|
97
|
+
if (new Set(policy.ids).size !== policy.ids.length)
|
|
98
|
+
return validationFailure('Grant command policy ids must be unique');
|
|
99
|
+
const known = new Set(knownAllowlistIds);
|
|
100
|
+
for (const id of policy.ids)
|
|
101
|
+
if (!known.has(id))
|
|
102
|
+
return validationFailure(`Unknown allowlisted command id: ${id}`);
|
|
103
|
+
return ok({ kind: 'allowlisted-command-ids', ids: policy.ids });
|
|
104
|
+
}
|
|
105
|
+
function validateOptionalStringList(values, label) {
|
|
106
|
+
if (values === undefined)
|
|
107
|
+
return ok(undefined);
|
|
108
|
+
if (!Array.isArray(values))
|
|
109
|
+
return validationFailure(`${label} must be an array when provided`);
|
|
110
|
+
if (!values.every((value) => typeof value === 'string' && value.trim().length > 0))
|
|
111
|
+
return validationFailure(`${label} must contain non-empty strings`);
|
|
112
|
+
if (new Set(values).size !== values.length)
|
|
113
|
+
return validationFailure(`${label} must be unique`);
|
|
114
|
+
return ok(values);
|
|
115
|
+
}
|
|
116
|
+
function validateExpiry(expiresAt, now) {
|
|
117
|
+
const timestamp = Date.parse(expiresAt);
|
|
118
|
+
if (!Number.isFinite(timestamp) || new Date(timestamp).toISOString() !== expiresAt)
|
|
119
|
+
return validationFailure('Grant expiresAt must be a valid ISO date');
|
|
120
|
+
const delta = timestamp - now.getTime();
|
|
121
|
+
if (delta <= 0)
|
|
122
|
+
return validationFailure('Grant expiresAt must be in the future');
|
|
123
|
+
if (delta > maxExpiryMs)
|
|
124
|
+
return validationFailure('Grant expiresAt must be within 24 hours');
|
|
125
|
+
return ok(undefined);
|
|
126
|
+
}
|
|
127
|
+
function isRecord(value) {
|
|
128
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
129
|
+
}
|
|
130
|
+
function ok(value) {
|
|
131
|
+
return { ok: true, value };
|
|
132
|
+
}
|
|
133
|
+
function validationFailure(message) {
|
|
134
|
+
return { ok: false, error: { code: 'validation_failed', message } };
|
|
135
|
+
}
|