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.
- package/dist/agents/canonical-agent-manifest.js +11 -10
- 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/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/workflows/command-allowlist-adapter.js +50 -0
- package/docs/safety.md +23 -0
- package/package.json +1 -1
package/dist/mcp/validation.js
CHANGED
|
@@ -2,6 +2,7 @@ import { isRiskyPermissionCategory, permissionCategories } from '../permissions/
|
|
|
2
2
|
import { parseOperationRetryPolicy } from '../runs/operation-retry.js';
|
|
3
3
|
import { normalizeSddPhaseInput, sddPhases } from '../sdd/schema.js';
|
|
4
4
|
import { workflowIds } from '../workflows/schema.js';
|
|
5
|
+
import { commandAllowlistIds } from '../workflows/command-allowlist-adapter.js';
|
|
5
6
|
import { supportedTypeMessage, verificationChangeTypes } from '../verification/index.js';
|
|
6
7
|
import { errorEnvelope, isVgxMcpToolName, } from './schema.js';
|
|
7
8
|
const scopes = ['project', 'personal'];
|
|
@@ -9,6 +10,8 @@ const agentModes = ['agent', 'subagent'];
|
|
|
9
10
|
const memoryTypes = ['architecture', 'decision', 'bugfix', 'pattern', 'config', 'discovery', 'learning', 'preference', 'manual'];
|
|
10
11
|
const activityKinds = ['prompt', 'tool_call', 'artifact', 'summary', 'error'];
|
|
11
12
|
const runStatuses = ['created', 'planned', 'running', 'needs-human', 'completed', 'failed', 'blocked', 'cancelled'];
|
|
13
|
+
const taskGrantStatuses = ['active', 'revoked', 'expired', 'exhausted'];
|
|
14
|
+
const taskGrantCategories = ['shell', 'test-run', 'git'];
|
|
12
15
|
const finalRunStatuses = ['completed', 'failed', 'blocked', 'cancelled'];
|
|
13
16
|
const runOutcomes = ['success', 'partial', 'failure', 'blocked', 'cancelled'];
|
|
14
17
|
const permissionDecisions = ['allow', 'ask', 'deny'];
|
|
@@ -132,6 +135,16 @@ export function validateVgxMcpToolCall(call) {
|
|
|
132
135
|
return validationSuccess(tool.value, validateRunGetInput(input, tool.value));
|
|
133
136
|
case 'vgxness_run_preflight':
|
|
134
137
|
return validationSuccess(tool.value, validateRunPreflightInput(input, tool.value));
|
|
138
|
+
case 'vgxness_run_task_grant_create':
|
|
139
|
+
return validationSuccess(tool.value, validateRunTaskGrantCreateInput(input, tool.value));
|
|
140
|
+
case 'vgxness_run_task_grant_list':
|
|
141
|
+
return validationSuccess(tool.value, validateRunTaskGrantListInput(input, tool.value));
|
|
142
|
+
case 'vgxness_run_task_grant_get':
|
|
143
|
+
return validationSuccess(tool.value, validateRunTaskGrantGetInput(input, tool.value));
|
|
144
|
+
case 'vgxness_run_task_grant_revoke':
|
|
145
|
+
return validationSuccess(tool.value, validateRunTaskGrantRevokeInput(input, tool.value));
|
|
146
|
+
case 'vgxness_run_task_grant_preview_match':
|
|
147
|
+
return validationSuccess(tool.value, validateRunTaskGrantPreviewMatchInput(input, tool.value));
|
|
135
148
|
case 'vgxness_run_start':
|
|
136
149
|
return validationSuccess(tool.value, validateRunStartInput(input, tool.value));
|
|
137
150
|
case 'vgxness_run_checkpoint':
|
|
@@ -1565,6 +1578,10 @@ function validateRunPreflightInput(input, tool) {
|
|
|
1565
1578
|
'agentId',
|
|
1566
1579
|
'workspaceRoot',
|
|
1567
1580
|
'targetPath',
|
|
1581
|
+
'taskFingerprint',
|
|
1582
|
+
'commandId',
|
|
1583
|
+
'cwd',
|
|
1584
|
+
'targetPaths',
|
|
1568
1585
|
'providerToolName',
|
|
1569
1586
|
'agent',
|
|
1570
1587
|
'sandboxStrategy',
|
|
@@ -1590,9 +1607,14 @@ function validateRunPreflightInput(input, tool) {
|
|
|
1590
1607
|
return workflow;
|
|
1591
1608
|
if (workflow.value !== undefined)
|
|
1592
1609
|
result.workflow = workflow.value;
|
|
1593
|
-
const copied = copyOptionalStrings(result, record.value, tool, ['phase', 'agentId', 'workspaceRoot', 'targetPath', 'providerToolName']);
|
|
1610
|
+
const copied = copyOptionalStrings(result, record.value, tool, ['phase', 'agentId', 'workspaceRoot', 'targetPath', 'taskFingerprint', 'commandId', 'cwd', 'providerToolName']);
|
|
1594
1611
|
if (!copied.ok)
|
|
1595
1612
|
return copied;
|
|
1613
|
+
const targetPaths = readOptionalStringArray(record.value, 'targetPaths', tool);
|
|
1614
|
+
if (!targetPaths.ok)
|
|
1615
|
+
return targetPaths;
|
|
1616
|
+
if (targetPaths.value !== undefined)
|
|
1617
|
+
result.targetPaths = targetPaths.value;
|
|
1596
1618
|
const sandboxStrategy = readOptionalOneOf(record.value, 'sandboxStrategy', ['worktree'], tool);
|
|
1597
1619
|
if (!sandboxStrategy.ok)
|
|
1598
1620
|
return sandboxStrategy;
|
|
@@ -1617,6 +1639,135 @@ function validateRunPreflightInput(input, tool) {
|
|
|
1617
1639
|
}
|
|
1618
1640
|
return { ok: true, value: result };
|
|
1619
1641
|
}
|
|
1642
|
+
function validateRunTaskGrantCreateInput(input, tool) {
|
|
1643
|
+
const record = inputRecord(input, tool, [
|
|
1644
|
+
'runId',
|
|
1645
|
+
'agentId',
|
|
1646
|
+
'taskFingerprint',
|
|
1647
|
+
'categories',
|
|
1648
|
+
'externalRoots',
|
|
1649
|
+
'cwdPatterns',
|
|
1650
|
+
'commandPolicy',
|
|
1651
|
+
'expiresAt',
|
|
1652
|
+
'maxUses',
|
|
1653
|
+
'createdBy',
|
|
1654
|
+
'creationReason',
|
|
1655
|
+
'approvedScopeSummary',
|
|
1656
|
+
]);
|
|
1657
|
+
if (!record.ok)
|
|
1658
|
+
return record;
|
|
1659
|
+
const result = {};
|
|
1660
|
+
const copied = copyRequiredStrings(result, record.value, tool, ['runId', 'agentId', 'taskFingerprint', 'expiresAt', 'creationReason', 'approvedScopeSummary']);
|
|
1661
|
+
if (!copied.ok)
|
|
1662
|
+
return copied;
|
|
1663
|
+
const categories = readRequiredOneOfArray(record.value, 'categories', taskGrantCategories, tool);
|
|
1664
|
+
if (!categories.ok)
|
|
1665
|
+
return categories;
|
|
1666
|
+
result.categories = categories.value;
|
|
1667
|
+
const externalRoots = readRequiredStringArray(record.value, 'externalRoots', tool);
|
|
1668
|
+
if (!externalRoots.ok)
|
|
1669
|
+
return externalRoots;
|
|
1670
|
+
result.externalRoots = externalRoots.value;
|
|
1671
|
+
const cwdPatterns = readOptionalStringArray(record.value, 'cwdPatterns', tool);
|
|
1672
|
+
if (!cwdPatterns.ok)
|
|
1673
|
+
return cwdPatterns;
|
|
1674
|
+
if (cwdPatterns.value !== undefined)
|
|
1675
|
+
result.cwdPatterns = cwdPatterns.value;
|
|
1676
|
+
const commandPolicy = readTaskGrantCommandPolicy(record.value.commandPolicy, tool);
|
|
1677
|
+
if (!commandPolicy.ok)
|
|
1678
|
+
return commandPolicy;
|
|
1679
|
+
result.commandPolicy = commandPolicy.value;
|
|
1680
|
+
const createdBy = readTaskGrantActor(record.value.createdBy, tool, ['human']);
|
|
1681
|
+
if (!createdBy.ok)
|
|
1682
|
+
return createdBy;
|
|
1683
|
+
result.createdBy = createdBy.value;
|
|
1684
|
+
if (typeof record.value.maxUses !== 'number' || !Number.isSafeInteger(record.value.maxUses) || record.value.maxUses < 1)
|
|
1685
|
+
return validationFailure('maxUses must be a positive safe integer', tool);
|
|
1686
|
+
result.maxUses = record.value.maxUses;
|
|
1687
|
+
const expiresAt = Date.parse(result.expiresAt ?? '');
|
|
1688
|
+
if (!Number.isFinite(expiresAt) || new Date(expiresAt).toISOString() !== result.expiresAt)
|
|
1689
|
+
return validationFailure('expiresAt must be a valid ISO date', tool);
|
|
1690
|
+
const expiryDelta = expiresAt - Date.now();
|
|
1691
|
+
if (expiryDelta <= 0)
|
|
1692
|
+
return validationFailure('expiresAt must be in the future', tool);
|
|
1693
|
+
if (expiryDelta > 24 * 60 * 60 * 1000)
|
|
1694
|
+
return validationFailure('expiresAt must be within 24 hours', tool);
|
|
1695
|
+
return { ok: true, value: result };
|
|
1696
|
+
}
|
|
1697
|
+
function validateRunTaskGrantListInput(input, tool) {
|
|
1698
|
+
const record = inputRecord(input, tool, ['runId', 'agentId', 'status', 'limit']);
|
|
1699
|
+
if (!record.ok)
|
|
1700
|
+
return record;
|
|
1701
|
+
const result = {};
|
|
1702
|
+
const copied = copyOptionalStrings(result, record.value, tool, ['runId', 'agentId']);
|
|
1703
|
+
if (!copied.ok)
|
|
1704
|
+
return copied;
|
|
1705
|
+
const status = readOptionalOneOf(record.value, 'status', taskGrantStatuses, tool);
|
|
1706
|
+
if (!status.ok)
|
|
1707
|
+
return status;
|
|
1708
|
+
if (status.value !== undefined)
|
|
1709
|
+
result.status = status.value;
|
|
1710
|
+
const limit = readOptionalBoundedLimit(record.value, tool);
|
|
1711
|
+
if (!limit.ok)
|
|
1712
|
+
return limit;
|
|
1713
|
+
if (limit.value !== undefined)
|
|
1714
|
+
result.limit = limit.value;
|
|
1715
|
+
return { ok: true, value: result };
|
|
1716
|
+
}
|
|
1717
|
+
function validateRunTaskGrantGetInput(input, tool) {
|
|
1718
|
+
const record = inputRecord(input, tool, ['grantId']);
|
|
1719
|
+
if (!record.ok)
|
|
1720
|
+
return record;
|
|
1721
|
+
const grantId = readNonEmptyString(record.value, 'grantId', tool);
|
|
1722
|
+
if (!grantId.ok)
|
|
1723
|
+
return grantId;
|
|
1724
|
+
return { ok: true, value: { grantId: grantId.value } };
|
|
1725
|
+
}
|
|
1726
|
+
function validateRunTaskGrantRevokeInput(input, tool) {
|
|
1727
|
+
const record = inputRecord(input, tool, ['grantId', 'actor', 'reason']);
|
|
1728
|
+
if (!record.ok)
|
|
1729
|
+
return record;
|
|
1730
|
+
const grantId = readNonEmptyString(record.value, 'grantId', tool);
|
|
1731
|
+
if (!grantId.ok)
|
|
1732
|
+
return grantId;
|
|
1733
|
+
const reason = readNonEmptyString(record.value, 'reason', tool);
|
|
1734
|
+
if (!reason.ok)
|
|
1735
|
+
return reason;
|
|
1736
|
+
const actor = readTaskGrantActor(record.value.actor, tool, ['human', 'system']);
|
|
1737
|
+
if (!actor.ok)
|
|
1738
|
+
return actor;
|
|
1739
|
+
return { ok: true, value: { grantId: grantId.value, actor: actor.value, reason: reason.value } };
|
|
1740
|
+
}
|
|
1741
|
+
function validateRunTaskGrantPreviewMatchInput(input, tool) {
|
|
1742
|
+
const record = inputRecord(input, tool, ['runId', 'agentId', 'taskFingerprint', 'category', 'operation', 'workspaceRoot', 'cwd', 'targetPaths']);
|
|
1743
|
+
if (!record.ok)
|
|
1744
|
+
return record;
|
|
1745
|
+
const runId = readNonEmptyString(record.value, 'runId', tool);
|
|
1746
|
+
if (!runId.ok)
|
|
1747
|
+
return runId;
|
|
1748
|
+
const taskFingerprint = readNonEmptyString(record.value, 'taskFingerprint', tool);
|
|
1749
|
+
if (!taskFingerprint.ok)
|
|
1750
|
+
return taskFingerprint;
|
|
1751
|
+
const category = readRequiredOneOf(record.value, 'category', permissionCategories, tool);
|
|
1752
|
+
if (!category.ok)
|
|
1753
|
+
return category;
|
|
1754
|
+
const operation = readNonEmptyString(record.value, 'operation', tool);
|
|
1755
|
+
if (!operation.ok)
|
|
1756
|
+
return operation;
|
|
1757
|
+
const cwd = readNonEmptyString(record.value, 'cwd', tool);
|
|
1758
|
+
if (!cwd.ok)
|
|
1759
|
+
return cwd;
|
|
1760
|
+
const result = { runId: runId.value, taskFingerprint: taskFingerprint.value, category: category.value, operation: operation.value, cwd: cwd.value };
|
|
1761
|
+
const copied = copyOptionalStrings(result, record.value, tool, ['agentId', 'workspaceRoot']);
|
|
1762
|
+
if (!copied.ok)
|
|
1763
|
+
return copied;
|
|
1764
|
+
const targetPaths = readOptionalStringArray(record.value, 'targetPaths', tool);
|
|
1765
|
+
if (!targetPaths.ok)
|
|
1766
|
+
return targetPaths;
|
|
1767
|
+
if (targetPaths.value !== undefined)
|
|
1768
|
+
result.targetPaths = targetPaths.value;
|
|
1769
|
+
return { ok: true, value: result };
|
|
1770
|
+
}
|
|
1620
1771
|
function validateRunStartInput(input, tool) {
|
|
1621
1772
|
const record = inputRecord(input, tool, ['project', 'userIntent', 'workflow', 'phase', 'selectedAgentId', 'providerAdapter', 'model']);
|
|
1622
1773
|
if (!record.ok)
|
|
@@ -1905,6 +2056,68 @@ function readRequiredOneOf(record, field, values, tool) {
|
|
|
1905
2056
|
return validationFailure(`${field} must be one of: ${values.join(', ')}`, tool);
|
|
1906
2057
|
return { ok: true, value: value.value };
|
|
1907
2058
|
}
|
|
2059
|
+
function readRequiredOneOfArray(record, field, values, tool) {
|
|
2060
|
+
const array = readRequiredStringArray(record, field, tool);
|
|
2061
|
+
if (!array.ok)
|
|
2062
|
+
return array;
|
|
2063
|
+
for (const item of array.value) {
|
|
2064
|
+
if (!values.includes(item))
|
|
2065
|
+
return validationFailure(`${field} must contain only: ${values.join(', ')}`, tool);
|
|
2066
|
+
}
|
|
2067
|
+
return { ok: true, value: array.value };
|
|
2068
|
+
}
|
|
2069
|
+
function readRequiredStringArray(record, field, tool) {
|
|
2070
|
+
if (record[field] === undefined)
|
|
2071
|
+
return validationFailure(`${field} is required`, tool);
|
|
2072
|
+
const values = readOptionalStringArray(record, field, tool);
|
|
2073
|
+
if (!values.ok)
|
|
2074
|
+
return values;
|
|
2075
|
+
if (values.value === undefined || values.value.length === 0)
|
|
2076
|
+
return validationFailure(`${field} must contain at least one item`, tool);
|
|
2077
|
+
if (new Set(values.value).size !== values.value.length)
|
|
2078
|
+
return validationFailure(`${field} must be unique`, tool);
|
|
2079
|
+
return { ok: true, value: values.value };
|
|
2080
|
+
}
|
|
2081
|
+
function readTaskGrantCommandPolicy(value, tool) {
|
|
2082
|
+
const record = asRecord(value, tool, 'commandPolicy must be an object');
|
|
2083
|
+
if (!record.ok)
|
|
2084
|
+
return record;
|
|
2085
|
+
const unexpected = Object.keys(record.value).find((key) => !['kind', 'ids'].includes(key));
|
|
2086
|
+
if (unexpected !== undefined)
|
|
2087
|
+
return validationFailure(`Unexpected commandPolicy field: ${unexpected}`, tool);
|
|
2088
|
+
const kind = readRequiredOneOf(record.value, 'kind', ['allowlisted-command-ids'], tool);
|
|
2089
|
+
if (!kind.ok)
|
|
2090
|
+
return kind;
|
|
2091
|
+
const ids = readRequiredStringArray(record.value, 'ids', tool);
|
|
2092
|
+
if (!ids.ok)
|
|
2093
|
+
return ids;
|
|
2094
|
+
const known = new Set(commandAllowlistIds());
|
|
2095
|
+
for (const id of ids.value)
|
|
2096
|
+
if (!known.has(id))
|
|
2097
|
+
return validationFailure(`Unknown allowlisted command id: ${id}`, tool);
|
|
2098
|
+
return { ok: true, value: { kind: kind.value, ids: ids.value } };
|
|
2099
|
+
}
|
|
2100
|
+
function readTaskGrantActor(value, tool, allowedTypes) {
|
|
2101
|
+
const record = asRecord(value, tool, 'actor must be an object');
|
|
2102
|
+
if (!record.ok)
|
|
2103
|
+
return record;
|
|
2104
|
+
const unexpected = Object.keys(record.value).find((key) => !['type', 'id', 'displayName'].includes(key));
|
|
2105
|
+
if (unexpected !== undefined)
|
|
2106
|
+
return validationFailure(`Unexpected actor field: ${unexpected}`, tool);
|
|
2107
|
+
const type = readRequiredOneOf(record.value, 'type', allowedTypes, tool);
|
|
2108
|
+
if (!type.ok)
|
|
2109
|
+
return type;
|
|
2110
|
+
const id = readNonEmptyString(record.value, 'id', tool);
|
|
2111
|
+
if (!id.ok)
|
|
2112
|
+
return id;
|
|
2113
|
+
const actor = { type: type.value, id: id.value };
|
|
2114
|
+
const displayName = readOptionalNonEmptyString(record.value, 'displayName', tool);
|
|
2115
|
+
if (!displayName.ok)
|
|
2116
|
+
return displayName;
|
|
2117
|
+
if (displayName.value !== undefined)
|
|
2118
|
+
actor.displayName = displayName.value;
|
|
2119
|
+
return { ok: true, value: actor };
|
|
2120
|
+
}
|
|
1908
2121
|
function readOptionalOneOf(record, field, values, tool) {
|
|
1909
2122
|
if (record[field] === undefined)
|
|
1910
2123
|
return { ok: true, value: undefined };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS task_scoped_command_grants (
|
|
2
|
+
id TEXT PRIMARY KEY,
|
|
3
|
+
run_id TEXT NOT NULL,
|
|
4
|
+
agent_id TEXT NOT NULL,
|
|
5
|
+
task_fingerprint TEXT NOT NULL,
|
|
6
|
+
|
|
7
|
+
categories_json TEXT NOT NULL,
|
|
8
|
+
external_roots_json TEXT NOT NULL,
|
|
9
|
+
canonical_external_roots_json TEXT NOT NULL,
|
|
10
|
+
cwd_patterns_json TEXT,
|
|
11
|
+
command_policy_json TEXT NOT NULL,
|
|
12
|
+
|
|
13
|
+
expires_at TEXT NOT NULL,
|
|
14
|
+
max_uses INTEGER NOT NULL,
|
|
15
|
+
used_count INTEGER NOT NULL DEFAULT 0,
|
|
16
|
+
status TEXT NOT NULL,
|
|
17
|
+
|
|
18
|
+
created_by_json TEXT NOT NULL,
|
|
19
|
+
created_at TEXT NOT NULL,
|
|
20
|
+
|
|
21
|
+
revoked_by_json TEXT,
|
|
22
|
+
revoked_at TEXT,
|
|
23
|
+
revoke_reason TEXT,
|
|
24
|
+
|
|
25
|
+
audit_json TEXT NOT NULL,
|
|
26
|
+
|
|
27
|
+
FOREIGN KEY(run_id) REFERENCES runs(id)
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
CREATE INDEX IF NOT EXISTS idx_task_scoped_command_grants_run
|
|
31
|
+
ON task_scoped_command_grants(run_id);
|
|
32
|
+
|
|
33
|
+
CREATE INDEX IF NOT EXISTS idx_task_scoped_command_grants_match
|
|
34
|
+
ON task_scoped_command_grants(run_id, agent_id, task_fingerprint, status);
|
|
35
|
+
|
|
36
|
+
CREATE INDEX IF NOT EXISTS idx_task_scoped_command_grants_status
|
|
37
|
+
ON task_scoped_command_grants(status);
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|