mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
feat: gate agent launches on workspace trust (#1031)
This commit is contained in:
parent
a7a59ae494
commit
4aba9229c9
29 changed files with 3003 additions and 101 deletions
|
|
@ -344,6 +344,10 @@ src/scripts/run-harness-conformance.ts -- --suite <suite.json>
|
|||
`server/src/services/acp-stdio-adapter.ts`.
|
||||
- **Launch arguments.** Never put credential values in provider commands or arguments; use an
|
||||
allowlisted environment key or run-scoped brokered credential reference.
|
||||
- **Workspace execution trust.** Scan repository-controlled instructions,
|
||||
hooks, MCP servers, workflows, extensions, and provider configuration before
|
||||
launch. Bind the exact inventory and decision to the run launch manifest,
|
||||
then rescan before provider creation. Project policy may narrow trust only.
|
||||
- **Log redaction.** Trace logs and telemetry run through `TRACE_SECRET_PATTERNS` before storage.
|
||||
- **No credentials in PR descriptions, test fixtures, or log snippets.**
|
||||
|
||||
|
|
|
|||
14
CHANGELOG.md
14
CHANGELOG.md
|
|
@ -9,6 +9,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### Added
|
||||
|
||||
- Added a provider-neutral workspace execution trust gate that scans the exact
|
||||
task worktree for repository-controlled agent instructions, provider
|
||||
configuration, MCP servers, hooks, language-server settings, workflows,
|
||||
extensions, skills, and agent definitions before launch. Stable identity
|
||||
combines canonical worktree, repository, Git common-directory, and
|
||||
credential-redacted remote evidence so sibling, nested, moved, and linked
|
||||
worktrees cannot borrow authorization. Append-only trusted, restricted,
|
||||
denied, revoked, and expiring decisions bind to an exact inventory digest;
|
||||
project policy can only narrow them. Executable configuration requires
|
||||
explicit authorization, while model-only instructions can run provisionally
|
||||
only under enforced read-only, no-network, credential-free restricted
|
||||
controls. The immutable launch manifest records redacted inventory and
|
||||
decision evidence, and a final pre-spawn rescan blocks any drift. Added
|
||||
administrator REST and CLI scan, decide, and revoke controls (#878).
|
||||
- Added run-scoped filesystem sandbox enforcement for local ACP, Claude Code,
|
||||
Codex app-server, Codex CLI, and Hermes processes. Required presets compile
|
||||
explicit read, write, deny, dotfile, protected-metadata, temporary, and cache
|
||||
|
|
|
|||
|
|
@ -214,4 +214,83 @@ describe('vk agent runtime capability controls', () => {
|
|||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('scans the exact task workspace execution inventory', async () => {
|
||||
mockApi.mockResolvedValueOnce({
|
||||
inventory: {
|
||||
identity: { digest: `sha256:${'1'.repeat(64)}` },
|
||||
digest: `sha256:${'2'.repeat(64)}`,
|
||||
projectPolicy: { maximumTrust: 'restricted' },
|
||||
entries: [],
|
||||
},
|
||||
});
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommands(program);
|
||||
|
||||
await program.parseAsync(['workspace-trust', 'scan', 'task_1', '--json'], {
|
||||
from: 'user',
|
||||
});
|
||||
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/agents/task_1/workspace-trust');
|
||||
});
|
||||
|
||||
it('records and revokes exact-inventory workspace decisions', async () => {
|
||||
mockApi.mockResolvedValue({
|
||||
id: 'workspace-decision-1',
|
||||
mode: 'trusted',
|
||||
});
|
||||
const digest = `sha256:${'3'.repeat(64)}`;
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommands(program);
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
'workspace-trust',
|
||||
'decide',
|
||||
'task_1',
|
||||
'--mode',
|
||||
'trusted',
|
||||
'--inventory',
|
||||
digest,
|
||||
'--reason',
|
||||
'Reviewed exact inventory',
|
||||
'--json',
|
||||
],
|
||||
{ from: 'user' }
|
||||
);
|
||||
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/agents/task_1/workspace-trust/decisions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
mode: 'trusted',
|
||||
inventoryDigest: digest,
|
||||
reason: 'Reviewed exact inventory',
|
||||
expiresAt: undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
'workspace-trust',
|
||||
'revoke',
|
||||
'task_1',
|
||||
'--inventory',
|
||||
digest,
|
||||
'--reason',
|
||||
'Authorization withdrawn',
|
||||
'--json',
|
||||
],
|
||||
{ from: 'user' }
|
||||
);
|
||||
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/agents/task_1/workspace-trust/revoke', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
inventoryDigest: digest,
|
||||
reason: 'Authorization withdrawn',
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ import type {
|
|||
ConversationLifecycleResult,
|
||||
RunRecoveryRecord,
|
||||
RunLaunchManifestPreview,
|
||||
WorkspaceExecutionTrustDecision,
|
||||
WorkspaceExecutionTrustDecisionMode,
|
||||
WorkspaceExecutionTrustScanResult,
|
||||
} from '@veritas-kanban/shared';
|
||||
|
||||
type ConversationTurnAction = 'resume' | 'follow-up' | 'fork';
|
||||
|
|
@ -248,6 +251,8 @@ export function registerAgentCommands(program: Command): void {
|
|||
console.log(` Digest: ${preview.manifest.digest}`);
|
||||
console.log(` Provider: ${preview.manifest.providerRuntime.provider}`);
|
||||
console.log(` Model: ${preview.manifest.runtime.model ?? 'provider default'}`);
|
||||
console.log(` Workspace trust: ${preview.manifest.workspaceTrust.status}`);
|
||||
console.log(chalk.dim(` ${preview.manifest.workspaceTrust.source}`));
|
||||
console.log(
|
||||
` Enforceable: ${preview.manifest.enforcement.enforceable ? chalk.green('yes') : chalk.red('no')}`
|
||||
);
|
||||
|
|
@ -265,6 +270,119 @@ export function registerAgentCommands(program: Command): void {
|
|||
}
|
||||
});
|
||||
|
||||
const workspaceTrust = program
|
||||
.command('workspace-trust')
|
||||
.description('Inspect and manage repository execution trust');
|
||||
|
||||
workspaceTrust
|
||||
.command('scan <id>')
|
||||
.description('Scan repository-controlled instructions and executable configuration')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
const taskId = await resolveTaskId(id);
|
||||
const result = await api<WorkspaceExecutionTrustScanResult>(
|
||||
`/api/agents/${taskId}/workspace-trust`
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(chalk.bold('Workspace execution trust'));
|
||||
console.log(` Identity: ${result.inventory.identity.digest}`);
|
||||
console.log(` Inventory: ${result.inventory.digest}`);
|
||||
console.log(` Project maximum: ${result.inventory.projectPolicy.maximumTrust}`);
|
||||
console.log(` Current decision: ${result.currentDecision?.mode ?? chalk.yellow('none')}`);
|
||||
if (result.inventory.entries.length === 0) {
|
||||
console.log(chalk.dim(' No recognized repository-controlled components found.'));
|
||||
return;
|
||||
}
|
||||
for (const entry of result.inventory.entries) {
|
||||
console.log(
|
||||
` ${entry.posture === 'executable' ? chalk.red('!') : chalk.yellow('•')} ${entry.relativePath}`
|
||||
);
|
||||
console.log(
|
||||
chalk.dim(
|
||||
` ${entry.kind}; ${entry.posture}; ${entry.requestedCapabilities.join(', ')}`
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
workspaceTrust
|
||||
.command('decide <id>')
|
||||
.description('Record trust, restricted, or denied for an exact scanned inventory')
|
||||
.requiredOption('--mode <mode>', 'Decision mode: trusted, restricted, or denied')
|
||||
.requiredOption('--inventory <digest>', 'Exact inventory digest from workspace-trust scan')
|
||||
.requiredOption('--reason <text>', 'Reason for the decision')
|
||||
.option('--expires-at <timestamp>', 'Optional ISO-8601 expiry')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
const mode = options.mode as WorkspaceExecutionTrustDecisionMode;
|
||||
if (!['trusted', 'restricted', 'denied'].includes(mode)) {
|
||||
throw new Error('Mode must be trusted, restricted, or denied.');
|
||||
}
|
||||
const taskId = await resolveTaskId(id);
|
||||
const decision = await api<WorkspaceExecutionTrustDecision>(
|
||||
`/api/agents/${taskId}/workspace-trust/decisions`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
mode,
|
||||
inventoryDigest: options.inventory,
|
||||
reason: options.reason,
|
||||
expiresAt: options.expiresAt,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(decision, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(chalk.green(`✓ Workspace decision recorded: ${decision.mode}`));
|
||||
console.log(chalk.dim(`Decision ID: ${decision.id}`));
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
workspaceTrust
|
||||
.command('revoke <id>')
|
||||
.description('Revoke the current workspace execution trust decision')
|
||||
.requiredOption('--inventory <digest>', 'Exact current inventory digest')
|
||||
.requiredOption('--reason <text>', 'Reason for revocation')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
const taskId = await resolveTaskId(id);
|
||||
const decision = await api<WorkspaceExecutionTrustDecision>(
|
||||
`/api/agents/${taskId}/workspace-trust/revoke`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
inventoryDigest: options.inventory,
|
||||
reason: options.reason,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(decision, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(chalk.green('✓ Workspace execution trust decision revoked'));
|
||||
console.log(chalk.dim(`Decision ID: ${decision.id}`));
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
const profiles = program
|
||||
.command('profiles')
|
||||
.description('Manage reusable agent profile packages');
|
||||
|
|
|
|||
|
|
@ -736,6 +736,22 @@ cannot enforce is returned as a concrete blocker, and `start` rejects it before
|
|||
pending or task attempt state changes. Declaring `tool.calls` support is not
|
||||
treated as proof that an adapter can enforce a named allowlist.
|
||||
|
||||
Repository-controlled instructions and executable configuration pass through
|
||||
the provider-neutral workspace execution trust gate before Veritas reads
|
||||
repository instructions or creates an attempt. The gate fingerprints the exact
|
||||
task worktree, inventories recognized instructions, hooks, MCP servers,
|
||||
provider overrides, language-server settings, workflows, extensions, skills,
|
||||
and agent definitions, and evaluates them against an actor-attributed operator
|
||||
decision. Executable configuration requires explicit authorization. Model-only
|
||||
instructions may run provisionally only in enforced restricted mode.
|
||||
|
||||
The immutable launch manifest records redacted identity and inventory evidence,
|
||||
the effective decision, project maximum, requested capabilities, and
|
||||
restriction checks. Veritas rescans immediately before provider creation; an
|
||||
identity, inventory, or decision change aborts the launch. Repository policy
|
||||
can only narrow operator trust. See
|
||||
[Workspace Execution Trust](architecture/WORKSPACE-EXECUTION-TRUST.md).
|
||||
|
||||
Codex app-server and Claude Code inject a positive MCP catalog through their
|
||||
native run-scoped configuration. Other task adapters reject non-empty MCP
|
||||
selections. All adapters continue to reject named-tool restrictions they
|
||||
|
|
|
|||
|
|
@ -39,28 +39,29 @@
|
|||
26. [Task Archive](#task-archive)
|
||||
27. [Attachments](#attachments)
|
||||
28. [Agent Permissions](#agent-permissions)
|
||||
29. [Agent Routing](#agent-routing)
|
||||
30. [Sandbox Policies](#sandbox-policies)
|
||||
31. [Shared Resources](#shared-resources)
|
||||
32. [Skill Capability Profiles](#skill-capability-profiles-apiskillscapabilities)
|
||||
33. [Skill Security Scanner](#skill-security-scanner-apiskillssecurity)
|
||||
34. [Doc Freshness](#doc-freshness)
|
||||
35. [Cost Prediction](#cost-prediction)
|
||||
36. [Error Learning](#error-learning)
|
||||
37. [Reflection-to-Memory Promotion](#reflection-to-memory-promotion)
|
||||
38. [External Tracker Introspection](#external-tracker-introspection)
|
||||
39. [Run-scoped Tool Control Plane](#run-scoped-tool-control-plane)
|
||||
40. [Tool Policies](#tool-policies)
|
||||
41. [Watcher Continuation Policies](#watcher-continuation-policies)
|
||||
42. [Traces](#traces)
|
||||
43. [Ceremony Requirements](#ceremony-requirements-apiceremonies)
|
||||
44. [Governance Decision Traces](#governance-decision-traces-apigovernancetraces)
|
||||
45. [Audit](#audit)
|
||||
46. [Maintenance Center](#maintenance-center-apiv1maintenance)
|
||||
47. [Common Workflows](#common-workflows)
|
||||
48. [Versioning & Deprecation](#versioning--deprecation)
|
||||
49. [Rate Limits](#rate-limits)
|
||||
50. [Additional Endpoint Groups](#additional-endpoint-groups)
|
||||
29. [Workspace Execution Trust](#workspace-execution-trust)
|
||||
30. [Agent Routing](#agent-routing)
|
||||
31. [Sandbox Policies](#sandbox-policies)
|
||||
32. [Shared Resources](#shared-resources)
|
||||
33. [Skill Capability Profiles](#skill-capability-profiles-apiskillscapabilities)
|
||||
34. [Skill Security Scanner](#skill-security-scanner-apiskillssecurity)
|
||||
35. [Doc Freshness](#doc-freshness)
|
||||
36. [Cost Prediction](#cost-prediction)
|
||||
37. [Error Learning](#error-learning)
|
||||
38. [Reflection-to-Memory Promotion](#reflection-to-memory-promotion)
|
||||
39. [External Tracker Introspection](#external-tracker-introspection)
|
||||
40. [Run-scoped Tool Control Plane](#run-scoped-tool-control-plane)
|
||||
41. [Tool Policies](#tool-policies)
|
||||
42. [Watcher Continuation Policies](#watcher-continuation-policies)
|
||||
43. [Traces](#traces)
|
||||
44. [Ceremony Requirements](#ceremony-requirements-apiceremonies)
|
||||
45. [Governance Decision Traces](#governance-decision-traces-apigovernancetraces)
|
||||
46. [Audit](#audit)
|
||||
47. [Maintenance Center](#maintenance-center-apiv1maintenance)
|
||||
48. [Common Workflows](#common-workflows)
|
||||
49. [Versioning & Deprecation](#versioning--deprecation)
|
||||
50. [Rate Limits](#rate-limits)
|
||||
51. [Additional Endpoint Groups](#additional-endpoint-groups)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -2158,6 +2159,72 @@ Approve or reject a pending approval request.
|
|||
|
||||
---
|
||||
|
||||
## Workspace Execution Trust
|
||||
|
||||
Scans repository-controlled execution inputs for a task's exact registered
|
||||
worktree and manages append-only operator decisions.
|
||||
|
||||
Mounted at `/api/agents/:taskId/workspace-trust`.
|
||||
|
||||
| Method | Path | Description | Permission |
|
||||
| ------ | ----------------------------------------------- | ---------------------------------------- | -------------- |
|
||||
| `GET` | `/api/agents/:taskId/workspace-trust` | Scan inventory and show current decision | `agent:read` |
|
||||
| `POST` | `/api/agents/:taskId/workspace-trust/decisions` | Record an exact-inventory decision | `admin:manage` |
|
||||
| `POST` | `/api/agents/:taskId/workspace-trust/revoke` | Revoke the current decision | `admin:manage` |
|
||||
|
||||
All routes resolve the worktree from the task record. Callers cannot submit an
|
||||
arbitrary filesystem path.
|
||||
|
||||
### Scan
|
||||
|
||||
```
|
||||
GET /api/agents/TASK-001/workspace-trust
|
||||
```
|
||||
|
||||
The response contains `workspace-execution-trust-inventory/v1`, stable
|
||||
credential-redacted identity evidence, classified entries and requested
|
||||
capabilities, the project maximum, and the latest decision when one exists.
|
||||
Source contents are never returned.
|
||||
|
||||
### Record a Decision
|
||||
|
||||
```
|
||||
POST /api/agents/TASK-001/workspace-trust/decisions
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"inventoryDigest": "sha256:...",
|
||||
"mode": "restricted",
|
||||
"reason": "Reviewed instructions; retain read-only execution",
|
||||
"expiresAt": "2026-08-01T00:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
`mode` accepts `trusted`, `restricted`, or `denied`. `expiresAt` is optional.
|
||||
If the inventory changed after review, the server returns `409` and does not
|
||||
record the decision.
|
||||
|
||||
### Revoke
|
||||
|
||||
```
|
||||
POST /api/agents/TASK-001/workspace-trust/revoke
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"inventoryDigest": "sha256:...",
|
||||
"reason": "Repository ownership changed"
|
||||
}
|
||||
```
|
||||
|
||||
The revocation identifies the superseded decision and remains in the audit
|
||||
history. See
|
||||
[Workspace Execution Trust](architecture/WORKSPACE-EXECUTION-TRUST.md) for
|
||||
identity, inventory, restricted-mode, and launch-binding rules.
|
||||
|
||||
---
|
||||
|
||||
## Agent Routing
|
||||
|
||||
Automatic agent resolution — determines the best agent for a task based on configurable routing rules.
|
||||
|
|
|
|||
|
|
@ -445,27 +445,30 @@ vk project create "rubicon" --color "#7c3aed" --description "Main product"
|
|||
|
||||
Manage AI agents on code tasks.
|
||||
|
||||
| Command | Description |
|
||||
| ----------------------------------------------------------------------------- | --------------------------------------------------------- |
|
||||
| `vk start <id>` | Start an agent; optionally require runtime capabilities |
|
||||
| `vk launch-preview <id>` | Preview effective launch inputs, blockers, and drift |
|
||||
| `vk stop <id>` | Stop a run only when its persisted manifest supports stop |
|
||||
| `vk agent:recovery <id>` | Inspect the latest retry or fallback decision |
|
||||
| `vk agent:cancel-recovery <id> --attempt <id>` | Cancel the exact pending recovery parent |
|
||||
| `vk agent:resume <id> --source-attempt <id> -m <text>` | Resume the exact persisted provider conversation |
|
||||
| `vk agent:follow-up <id> --source-attempt <id> -m <text>` | Start a provider-native follow-up turn |
|
||||
| `vk agent:fork <id> --source-attempt <id> -m <text>` | Fork provider history without mutating its source |
|
||||
| `vk agent:steer <id> --attempt <id> -m <text>` | Steer the exact active provider turn |
|
||||
| `vk agent:interrupt <id> --attempt <id>` | Interrupt the exact active provider turn |
|
||||
| `vk agent:compact <id> --attempt <id>` | Compact a supported provider conversation |
|
||||
| `vk agent:archive <id> --attempt <id>` | Archive a supported provider conversation |
|
||||
| `vk agent:close <id> --attempt <id>` | Close a supported provider conversation |
|
||||
| `vk acp status --json` | Check ACP server-view API and permission readiness |
|
||||
| `vk acp serve --stdio [--task <id>]` | Expose a Veritas-managed task to an ACP v1 client |
|
||||
| `vk agents:pending` | List pending agent requests |
|
||||
| `vk agents:status <id>` | Check agent running status |
|
||||
| `vk agents:complete <id> -s --attempt-id <id> --manifest-digest <sha256:...>` | Mark the matching agent attempt complete (success) |
|
||||
| `vk agents:complete <id> -f --attempt-id <id> --manifest-digest <sha256:...>` | Mark the matching agent attempt complete (failure) |
|
||||
| Command | Description |
|
||||
| ----------------------------------------------------------------------------------- | --------------------------------------------------------- |
|
||||
| `vk start <id>` | Start an agent; optionally require runtime capabilities |
|
||||
| `vk launch-preview <id>` | Preview effective launch inputs, blockers, and drift |
|
||||
| `vk workspace-trust scan <id>` | Inventory repository-controlled execution configuration |
|
||||
| `vk workspace-trust decide <id> --mode <mode> --inventory <digest> --reason <text>` | Authorize or deny one exact inventory |
|
||||
| `vk workspace-trust revoke <id> --inventory <digest> --reason <text>` | Revoke the current exact-inventory decision |
|
||||
| `vk stop <id>` | Stop a run only when its persisted manifest supports stop |
|
||||
| `vk agent:recovery <id>` | Inspect the latest retry or fallback decision |
|
||||
| `vk agent:cancel-recovery <id> --attempt <id>` | Cancel the exact pending recovery parent |
|
||||
| `vk agent:resume <id> --source-attempt <id> -m <text>` | Resume the exact persisted provider conversation |
|
||||
| `vk agent:follow-up <id> --source-attempt <id> -m <text>` | Start a provider-native follow-up turn |
|
||||
| `vk agent:fork <id> --source-attempt <id> -m <text>` | Fork provider history without mutating its source |
|
||||
| `vk agent:steer <id> --attempt <id> -m <text>` | Steer the exact active provider turn |
|
||||
| `vk agent:interrupt <id> --attempt <id>` | Interrupt the exact active provider turn |
|
||||
| `vk agent:compact <id> --attempt <id>` | Compact a supported provider conversation |
|
||||
| `vk agent:archive <id> --attempt <id>` | Archive a supported provider conversation |
|
||||
| `vk agent:close <id> --attempt <id>` | Close a supported provider conversation |
|
||||
| `vk acp status --json` | Check ACP server-view API and permission readiness |
|
||||
| `vk acp serve --stdio [--task <id>]` | Expose a Veritas-managed task to an ACP v1 client |
|
||||
| `vk agents:pending` | List pending agent requests |
|
||||
| `vk agents:status <id>` | Check agent running status |
|
||||
| `vk agents:complete <id> -s --attempt-id <id> --manifest-digest <sha256:...>` | Mark the matching agent attempt complete (success) |
|
||||
| `vk agents:complete <id> -f --attempt-id <id> --manifest-digest <sha256:...>` | Mark the matching agent attempt complete (failure) |
|
||||
|
||||
Require one or more capabilities before launch:
|
||||
|
||||
|
|
@ -488,6 +491,22 @@ argument plan, per-field origins, enforcement blockers, and material drift.
|
|||
It applies the same readiness gate and override rules as start. Attempt IDs and
|
||||
probe timestamps do not count as material drift.
|
||||
|
||||
Inspect workspace execution trust before launching a newly cloned or changed
|
||||
repository:
|
||||
|
||||
```bash
|
||||
vk workspace-trust scan TASK-001
|
||||
vk workspace-trust decide TASK-001 \
|
||||
--mode restricted \
|
||||
--inventory sha256:... \
|
||||
--reason "Reviewed instructions; keep the run read-only"
|
||||
```
|
||||
|
||||
Decision modes are `trusted`, `restricted`, and `denied`. The exact inventory
|
||||
digest from `scan` is required, and stale content is rejected. Decision and
|
||||
revocation commands require an administrator. `launch-preview` reports the
|
||||
effective trust status and any resulting enforcement blocker.
|
||||
|
||||
`--require-capability <capabilities...>` is additive to the baseline launch,
|
||||
profile, sandbox, and budget requirements. The server returns a structured
|
||||
conflict and the CLI exits non-zero when any capability is unsupported,
|
||||
|
|
|
|||
|
|
@ -1794,23 +1794,26 @@ Added in v3.3.2.
|
|||
|
||||
### Agent Commands
|
||||
|
||||
| Command | Description |
|
||||
| ----------------------------------------------------------------------------- | --------------------------------------------------- |
|
||||
| `vk start <id>` | Start an agent on a code task (`--agent` to choose) |
|
||||
| `vk launch-preview <id>` | Preview immutable launch evidence without dispatch |
|
||||
| `vk stop <id>` | Stop a running agent |
|
||||
| `vk agent:resume <id> --source-attempt <id> -m <text>` | Resume an exact provider conversation |
|
||||
| `vk agent:follow-up <id> --source-attempt <id> -m <text>` | Start a native follow-up turn |
|
||||
| `vk agent:fork <id> --source-attempt <id> -m <text>` | Fork native provider history |
|
||||
| `vk agent:steer <id> --attempt <id> -m <text>` | Steer the exact active provider turn |
|
||||
| `vk agent:interrupt <id> --attempt <id>` | Interrupt the exact active attempt |
|
||||
| `vk agent:compact <id> --attempt <id>` | Compact a supported provider conversation |
|
||||
| `vk agent:archive <id> --attempt <id>` | Archive a supported provider conversation |
|
||||
| `vk agent:close <id> --attempt <id>` | Close a supported provider conversation |
|
||||
| `vk agents:pending` | List pending agent requests |
|
||||
| `vk agents:status <id>` | Check agent running status |
|
||||
| `vk agents:complete <id> -s --attempt-id <id> --manifest-digest <sha256:...>` | Mark the matching agent attempt complete (success) |
|
||||
| `vk agents:complete <id> -f --attempt-id <id> --manifest-digest <sha256:...>` | Mark the matching agent attempt complete (failure) |
|
||||
| Command | Description |
|
||||
| ----------------------------------------------------------------------------------- | --------------------------------------------------- |
|
||||
| `vk start <id>` | Start an agent on a code task (`--agent` to choose) |
|
||||
| `vk launch-preview <id>` | Preview immutable launch evidence without dispatch |
|
||||
| `vk workspace-trust scan <id>` | Inventory repository-controlled launch inputs |
|
||||
| `vk workspace-trust decide <id> --mode <mode> --inventory <digest> --reason <text>` | Record an exact-inventory trust decision |
|
||||
| `vk workspace-trust revoke <id> --inventory <digest> --reason <text>` | Revoke the current workspace authorization |
|
||||
| `vk stop <id>` | Stop a running agent |
|
||||
| `vk agent:resume <id> --source-attempt <id> -m <text>` | Resume an exact provider conversation |
|
||||
| `vk agent:follow-up <id> --source-attempt <id> -m <text>` | Start a native follow-up turn |
|
||||
| `vk agent:fork <id> --source-attempt <id> -m <text>` | Fork native provider history |
|
||||
| `vk agent:steer <id> --attempt <id> -m <text>` | Steer the exact active provider turn |
|
||||
| `vk agent:interrupt <id> --attempt <id>` | Interrupt the exact active attempt |
|
||||
| `vk agent:compact <id> --attempt <id>` | Compact a supported provider conversation |
|
||||
| `vk agent:archive <id> --attempt <id>` | Archive a supported provider conversation |
|
||||
| `vk agent:close <id> --attempt <id>` | Close a supported provider conversation |
|
||||
| `vk agents:pending` | List pending agent requests |
|
||||
| `vk agents:status <id>` | Check agent running status |
|
||||
| `vk agents:complete <id> -s --attempt-id <id> --manifest-digest <sha256:...>` | Mark the matching agent attempt complete (success) |
|
||||
| `vk agents:complete <id> -f --attempt-id <id> --manifest-digest <sha256:...>` | Mark the matching agent attempt complete (failure) |
|
||||
|
||||
### Automation Commands
|
||||
|
||||
|
|
@ -1971,6 +1974,28 @@ Defense-in-depth security model with multiple authentication methods and hardene
|
|||
- **Password strength indicator** — Visual strength meter in the Security settings tab (weak/fair/good/strong/very strong)
|
||||
- **Password change** — Change password from the Security settings tab with current password verification
|
||||
|
||||
### Workspace Execution Trust
|
||||
|
||||
- **Pre-launch inventory** - Scans repository-controlled harness instructions,
|
||||
provider configuration, MCP servers, hooks, language-server settings,
|
||||
workflows, extensions, skills, and agent definitions before an executable
|
||||
provider launch.
|
||||
- **Stable identity** - Binds decisions to the canonical worktree, repository,
|
||||
Git common directory, and credential-redacted remote identity instead of a
|
||||
reusable path string.
|
||||
- **Exact authorization** - Trusted, restricted, denied, and revoked records
|
||||
are actor-attributed and inventory-bound. Content drift, expiry, or revocation
|
||||
fails closed.
|
||||
- **Restricted mode** - Requires enforced read-only filesystem access, disabled
|
||||
network, no task credentials, no project tool servers, and no external
|
||||
mutation.
|
||||
- **Immutable launch evidence** - Records only redacted identity, inventory,
|
||||
capability, project-policy, and decision evidence, then rescans immediately
|
||||
before provider creation.
|
||||
|
||||
See
|
||||
[Workspace Execution Trust](architecture/WORKSPACE-EXECUTION-TRUST.md).
|
||||
|
||||
### Network & Headers
|
||||
|
||||
- **CSP headers** — Content Security Policy via [Helmet](https://helmetjs.github.io/) with nonce-based script/style allowlisting and a documented `style-src-attr` exception for runtime React style attributes
|
||||
|
|
|
|||
126
docs/architecture/WORKSPACE-EXECUTION-TRUST.md
Normal file
126
docs/architecture/WORKSPACE-EXECUTION-TRUST.md
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
# Workspace Execution Trust
|
||||
|
||||
Veritas treats repository-controlled agent instructions and executable
|
||||
configuration as an execution boundary. A task worktree is scanned before
|
||||
launch, the exact inventory is evaluated against an operator decision, and the
|
||||
result is bound into the immutable run launch manifest.
|
||||
|
||||
This prevents a cloned, moved, nested, sibling, or modified repository from
|
||||
silently inheriting authorization that was granted to different content.
|
||||
|
||||
## Launch flow
|
||||
|
||||
Every executable task launch follows the same sequence:
|
||||
|
||||
1. Resolve the task's registered Git worktree and canonical repository identity.
|
||||
2. Inventory recognized repository-controlled instructions and executable
|
||||
configuration without following configuration symlinks.
|
||||
3. Evaluate the inventory against the latest operator decision, project maximum,
|
||||
and effective launch restrictions.
|
||||
4. Block untrusted execution before reading repository instructions or creating
|
||||
an attempt.
|
||||
5. Record the redacted identity, exact inventory digest, decision evidence, and
|
||||
requested capabilities in `run-launch-manifest/v1`.
|
||||
6. Rescan immediately before sandbox activation and provider creation. Any
|
||||
identity, inventory, or decision drift aborts the launch.
|
||||
|
||||
The no-configuration result is provisional. Veritas rescans it for every
|
||||
launch, so adding an instruction, hook, MCP server, workflow, extension, or
|
||||
provider configuration cannot inherit the earlier result.
|
||||
|
||||
## Workspace identity
|
||||
|
||||
`workspace-execution-trust/v1` derives identity from the canonical worktree,
|
||||
repository root, Git common directory, and credential-redacted remote identity.
|
||||
The identity survives a symlink alias or directory rename while remaining
|
||||
distinct for sibling clones and linked worktrees. Changing the remote identity
|
||||
also changes the trust identity.
|
||||
|
||||
Authorization never flows from a parent, child, sibling, or different remote
|
||||
repository based on path proximity.
|
||||
|
||||
## Inventory
|
||||
|
||||
The scanner classifies entries as:
|
||||
|
||||
- `declarative-only`: project policy that can only narrow trust.
|
||||
- `model-influencing`: agent instructions, provider instructions, agent
|
||||
definitions, and skills.
|
||||
- `executable`: MCP/tool server configuration, provider overrides, runtime
|
||||
hooks, language-server settings, workflows, extension configuration, and
|
||||
custom Git hooks.
|
||||
|
||||
Recognized sources include root harness instructions; GitHub Copilot
|
||||
instructions, workflows, and MCP configuration; Claude settings, agents,
|
||||
commands, skills, hooks, and MCP configuration; Codex configuration, rules, and
|
||||
skills; Cursor rules; VS Code tasks, settings, extensions, and MCP
|
||||
configuration; development-container configuration; `.envrc`; and supported
|
||||
Buzz, Grok Build, and generic agent definition directories.
|
||||
|
||||
Each inventory entry stores only its relative path, classification, requested
|
||||
capabilities, byte length, symlink state, canonical path digest, and content
|
||||
fingerprint. File contents and local absolute paths are not copied into the
|
||||
launch manifest.
|
||||
|
||||
The scanner fails closed when a recognized file exceeds 2 MiB, the inventory
|
||||
exceeds 2,000 entries, recursive discovery exceeds its bounded depth, or the
|
||||
worktree cannot be resolved as a valid Git repository.
|
||||
|
||||
## Decisions and effective modes
|
||||
|
||||
Operator decisions are append-only and bound to both the workspace identity and
|
||||
exact inventory digest.
|
||||
|
||||
| Mode | Effect |
|
||||
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `trusted` | Allows the exact reviewed inventory under the normal launch policy. |
|
||||
| `restricted` | Denies inventoried executable configuration and allows only an enforced read-only, no-network launch without task credentials, project tool servers, or external mutation. |
|
||||
| `denied` | Blocks the workspace until an operator records a later decision. |
|
||||
| `revoked` | Withdraws the latest active authorization without deleting its audit history. |
|
||||
|
||||
Executable configuration always requires an explicit decision. Model-only
|
||||
instructions may run provisionally in restricted mode when every restricted
|
||||
boundary is enforceable. Expired, revoked, stale, or inventory-mismatched
|
||||
authorization cannot permit a launch.
|
||||
|
||||
Decision creation and revocation require `admin:manage`. Every mutation records
|
||||
the authenticated actor, reason, exact inventory digest, timestamp, and
|
||||
superseded decision where applicable.
|
||||
|
||||
## Project maximum
|
||||
|
||||
A repository may add `.veritas-kanban/workspace-trust.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": "workspace-trust-policy/v1",
|
||||
"maximumTrust": "restricted"
|
||||
}
|
||||
```
|
||||
|
||||
`maximumTrust` accepts `trusted`, `restricted`, or `denied`. The file can only
|
||||
narrow an operator decision. An invalid or symlinked project policy is treated
|
||||
as `denied`.
|
||||
|
||||
## Operator workflow
|
||||
|
||||
```bash
|
||||
vk workspace-trust scan TASK-001
|
||||
vk workspace-trust decide TASK-001 \
|
||||
--mode trusted \
|
||||
--inventory sha256:... \
|
||||
--reason "Reviewed repository execution configuration"
|
||||
vk launch-preview TASK-001 --json
|
||||
```
|
||||
|
||||
To withdraw an authorization:
|
||||
|
||||
```bash
|
||||
vk workspace-trust revoke TASK-001 \
|
||||
--inventory sha256:... \
|
||||
--reason "Repository ownership changed"
|
||||
```
|
||||
|
||||
Use `--json` on any workspace-trust command for the complete versioned record.
|
||||
See [API Reference](../API-REFERENCE.md#workspace-execution-trust) for the REST
|
||||
surface.
|
||||
|
|
@ -109,6 +109,18 @@ credential-redacted remote identity before any push. Integration uses a
|
|||
detached temporary worktree and a non-force push, so the configured primary
|
||||
checkout is not mutated.
|
||||
|
||||
Repository-controlled execution is a separate trust boundary from worktree
|
||||
ownership. Before an executable provider launch, Veritas inventories recognized
|
||||
agent instructions, provider configuration, MCP servers, hooks, language-server
|
||||
settings, workflows, extensions, skills, and agent definitions. Executable
|
||||
configuration requires an explicit, actor-attributed decision for the exact
|
||||
workspace identity and inventory digest. Model-only instructions can run
|
||||
provisionally only under the enforced restricted profile. The inventory and
|
||||
decision are bound into the immutable launch manifest and rescanned before
|
||||
provider creation; drift fails closed. A repository-owned policy can only
|
||||
narrow trust. See
|
||||
[Workspace Execution Trust](architecture/WORKSPACE-EXECUTION-TRUST.md).
|
||||
|
||||
### v5 Auth Context
|
||||
|
||||
Authenticated REST requests and WebSocket connections now carry a shared auth
|
||||
|
|
|
|||
|
|
@ -643,6 +643,38 @@
|
|||
"source": "cli/src/commands/agents.ts",
|
||||
"denialReason": "Previewing effective launch evidence requires task and agent read access."
|
||||
},
|
||||
{
|
||||
"id": "cli:agents:workspace-trust",
|
||||
"kind": "cli",
|
||||
"classification": "authenticated-read",
|
||||
"permissions": ["task:read", "agent:read"],
|
||||
"source": "cli/src/commands/agents.ts",
|
||||
"denialReason": "Workspace trust command discovery requires task and agent read access."
|
||||
},
|
||||
{
|
||||
"id": "cli:agents:scan",
|
||||
"kind": "cli",
|
||||
"classification": "authenticated-read",
|
||||
"permissions": ["task:read", "agent:read"],
|
||||
"source": "cli/src/commands/agents.ts",
|
||||
"denialReason": "Scanning workspace execution configuration requires task and agent read access."
|
||||
},
|
||||
{
|
||||
"id": "cli:agents:decide",
|
||||
"kind": "cli",
|
||||
"classification": "owner-admin",
|
||||
"permissions": ["task:read", "admin:manage"],
|
||||
"source": "cli/src/commands/agents.ts",
|
||||
"denialReason": "Recording a workspace execution trust decision requires administrator authority."
|
||||
},
|
||||
{
|
||||
"id": "cli:agents:revoke",
|
||||
"kind": "cli",
|
||||
"classification": "owner-admin",
|
||||
"permissions": ["task:read", "admin:manage"],
|
||||
"source": "cli/src/commands/agents.ts",
|
||||
"denialReason": "Revoking workspace execution trust requires administrator authority."
|
||||
},
|
||||
{
|
||||
"id": "cli:agents:stop",
|
||||
"kind": "cli",
|
||||
|
|
|
|||
|
|
@ -200,6 +200,7 @@ import type {
|
|||
import { calculateRunToolCatalogDigest } from '../utils/tool-control-plane-digest.js';
|
||||
import type { FilesystemSandboxService } from '../services/filesystem-sandbox-service.js';
|
||||
import type { SandboxPolicyService } from '../services/sandbox-policy-service.js';
|
||||
import type { WorkspaceExecutionTrustService } from '../services/workspace-execution-trust-service.js';
|
||||
|
||||
const fixtureDir = path.join(path.dirname(fileURLToPath(import.meta.url)), 'fixtures', 'codex');
|
||||
|
||||
|
|
@ -243,7 +244,11 @@ function testableService(
|
|||
approvalBroker?: RunApprovalBrokerService,
|
||||
toolControlPlane?: ToolControlPlaneService,
|
||||
filesystemSandbox?: Pick<FilesystemSandboxService, 'compile' | 'activate' | 'cleanup' | 'wrap'>,
|
||||
sandboxPolicies?: Pick<SandboxPolicyService, 'dryRunWithTrace'>
|
||||
sandboxPolicies?: Pick<SandboxPolicyService, 'dryRunWithTrace'>,
|
||||
workspaceExecutionTrust: Pick<
|
||||
WorkspaceExecutionTrustService,
|
||||
'scan' | 'evaluateForLaunch' | 'assertFresh'
|
||||
> = testWorkspaceExecutionTrust()
|
||||
): TestableClawdbotAgentService {
|
||||
const completionEvidence = testCompletionEvidence();
|
||||
const taskEnvelopes = new TaskEnvelopeService(completionEvidence);
|
||||
|
|
@ -263,12 +268,53 @@ function testableService(
|
|||
undefined,
|
||||
undefined,
|
||||
filesystemSandbox,
|
||||
sandboxPolicies
|
||||
sandboxPolicies,
|
||||
workspaceExecutionTrust
|
||||
) as unknown as TestableClawdbotAgentService;
|
||||
service.logsDir = tmpDir;
|
||||
return service;
|
||||
}
|
||||
|
||||
function testWorkspaceExecutionTrust(): Pick<
|
||||
WorkspaceExecutionTrustService,
|
||||
'scan' | 'evaluateForLaunch' | 'assertFresh'
|
||||
> {
|
||||
const identity = {
|
||||
schemaVersion: 'workspace-execution-trust/v1' as const,
|
||||
digest: `sha256:${'1'.repeat(64)}`,
|
||||
canonicalWorkspacePathDigest: `sha256:${'2'.repeat(64)}`,
|
||||
canonicalRepositoryRootDigest: `sha256:${'3'.repeat(64)}`,
|
||||
gitCommonDirectoryDigest: `sha256:${'4'.repeat(64)}`,
|
||||
remoteIdentityDigest: `sha256:${'5'.repeat(64)}`,
|
||||
};
|
||||
const inventory = {
|
||||
schemaVersion: 'workspace-execution-trust-inventory/v1' as const,
|
||||
digest: `sha256:${'6'.repeat(64)}`,
|
||||
scannerRevision: 1,
|
||||
scannedAt: '2026-07-23T20:00:00.000Z',
|
||||
identity,
|
||||
entries: [],
|
||||
projectPolicy: {
|
||||
maximumTrust: 'trusted' as const,
|
||||
valid: true,
|
||||
},
|
||||
};
|
||||
const evaluation = {
|
||||
schemaVersion: 'workspace-execution-trust/v1' as const,
|
||||
status: 'not-required' as const,
|
||||
source: 'No repository-controlled execution components were discovered.',
|
||||
requiresExplicitDecision: false,
|
||||
identity,
|
||||
inventory,
|
||||
restrictionChecks: [],
|
||||
};
|
||||
return {
|
||||
scan: vi.fn(async () => ({ inventory })),
|
||||
evaluateForLaunch: vi.fn(async () => evaluation),
|
||||
assertFresh: vi.fn(async () => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
function testRunSupervisor(): RunSupervisorService {
|
||||
return {
|
||||
register: vi.fn(async (input: { attemptId: string }) => ({
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ const {
|
|||
mockGetRunEvents,
|
||||
mockGetTask,
|
||||
mockTelemetryEmit,
|
||||
mockScanWorkspaceTrust,
|
||||
mockRecordWorkspaceTrustDecision,
|
||||
mockRevokeWorkspaceTrustDecision,
|
||||
} = vi.hoisted(() => ({
|
||||
mockStartAgent: vi.fn(),
|
||||
mockPreviewAgentLaunch: vi.fn(),
|
||||
|
|
@ -42,6 +45,9 @@ const {
|
|||
mockGetRunEvents: vi.fn(),
|
||||
mockGetTask: vi.fn(),
|
||||
mockTelemetryEmit: vi.fn(),
|
||||
mockScanWorkspaceTrust: vi.fn(),
|
||||
mockRecordWorkspaceTrustDecision: vi.fn(),
|
||||
mockRevokeWorkspaceTrustDecision: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/clawdbot-agent-service.js', () => ({
|
||||
|
|
@ -85,6 +91,14 @@ vi.mock('../../services/telemetry-service.js', () => ({
|
|||
getTelemetryService: () => ({ emit: mockTelemetryEmit }),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/workspace-execution-trust-service.js', () => ({
|
||||
getWorkspaceExecutionTrustService: () => ({
|
||||
scan: mockScanWorkspaceTrust,
|
||||
recordDecision: mockRecordWorkspaceTrustDecision,
|
||||
revoke: mockRevokeWorkspaceTrustDecision,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { agentRoutes } from '../../routes/agents.js';
|
||||
|
||||
function auth(overrides: Partial<AuthContext> = {}): AuthContext {
|
||||
|
|
@ -174,8 +188,20 @@ describe('agent local capability enforcement', () => {
|
|||
id: 'task_1',
|
||||
project: 'veritas',
|
||||
attempt: { id: 'attempt_1', agent: 'codex' },
|
||||
git: { worktreePath: '/tmp/task_1' },
|
||||
});
|
||||
mockTelemetryEmit.mockImplementation(async (event) => ({ id: 'event_1', ...event }));
|
||||
mockScanWorkspaceTrust.mockResolvedValue({
|
||||
inventory: { digest: `sha256:${'1'.repeat(64)}` },
|
||||
});
|
||||
mockRecordWorkspaceTrustDecision.mockResolvedValue({
|
||||
id: 'workspace-decision-1',
|
||||
mode: 'trusted',
|
||||
});
|
||||
mockRevokeWorkspaceTrustDecision.mockResolvedValue({
|
||||
id: 'workspace-decision-2',
|
||||
mode: 'revoked',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects remote sessions without a local-agent capability before starting agents', async () => {
|
||||
|
|
@ -239,6 +265,51 @@ describe('agent local capability enforcement', () => {
|
|||
expect(mockPreviewAgentLaunch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('scans and mutates trust only for the task registered worktree', async () => {
|
||||
const app = createApp(
|
||||
auth({
|
||||
userId: 'operator_1',
|
||||
clientMode: 'desktop-local',
|
||||
capabilities: ['desktop:local'],
|
||||
})
|
||||
);
|
||||
const inventoryDigest = `sha256:${'1'.repeat(64)}`;
|
||||
|
||||
const scan = await request(app).get('/api/agents/task_1/workspace-trust');
|
||||
expect(scan.status).toBe(200);
|
||||
expect(mockScanWorkspaceTrust).toHaveBeenCalledWith('/tmp/task_1');
|
||||
|
||||
const decision = await request(app).post('/api/agents/task_1/workspace-trust/decisions').send({
|
||||
mode: 'trusted',
|
||||
inventoryDigest,
|
||||
reason: 'Reviewed exact inventory',
|
||||
});
|
||||
expect(decision.status).toBe(201);
|
||||
expect(mockRecordWorkspaceTrustDecision).toHaveBeenCalledWith(
|
||||
'/tmp/task_1',
|
||||
{
|
||||
mode: 'trusted',
|
||||
inventoryDigest,
|
||||
reason: 'Reviewed exact inventory',
|
||||
},
|
||||
'operator_1'
|
||||
);
|
||||
|
||||
const revoke = await request(app).post('/api/agents/task_1/workspace-trust/revoke').send({
|
||||
inventoryDigest,
|
||||
reason: 'Authorization withdrawn',
|
||||
});
|
||||
expect(revoke.status).toBe(200);
|
||||
expect(mockRevokeWorkspaceTrustDecision).toHaveBeenCalledWith(
|
||||
'/tmp/task_1',
|
||||
{
|
||||
inventoryDigest,
|
||||
reason: 'Authorization withdrawn',
|
||||
},
|
||||
'operator_1'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the same readiness validation evidence for preview as start', async () => {
|
||||
const { AgentReadinessError } = await import('../../services/clawdbot-agent-service.js');
|
||||
const readiness = {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,10 @@ import {
|
|||
type RunLaunchManifestCompileInput,
|
||||
} from '../services/run-launch-manifest-service.js';
|
||||
import { calculateProviderRuntimeManifestDigest } from '../utils/provider-runtime-manifest-digest.js';
|
||||
import { verifyRunLaunchManifestDigest } from '../utils/run-launch-manifest-digest.js';
|
||||
import {
|
||||
calculateRunLaunchManifestDigest,
|
||||
verifyRunLaunchManifestDigest,
|
||||
} from '../utils/run-launch-manifest-digest.js';
|
||||
import { calculateRunToolCatalogDigest } from '../utils/tool-control-plane-digest.js';
|
||||
import { providerRuntimeManifestFixture } from './fixtures/provider-runtime-manifest.js';
|
||||
|
||||
|
|
@ -155,6 +158,36 @@ const sandboxPolicy: SandboxPolicyDryRunResult = {
|
|||
warnings: [],
|
||||
};
|
||||
|
||||
const workspaceTrustIdentity = {
|
||||
schemaVersion: 'workspace-execution-trust/v1',
|
||||
digest: `sha256:${'1'.repeat(64)}`,
|
||||
canonicalWorkspacePathDigest: `sha256:${'2'.repeat(64)}`,
|
||||
canonicalRepositoryRootDigest: `sha256:${'3'.repeat(64)}`,
|
||||
gitCommonDirectoryDigest: `sha256:${'4'.repeat(64)}`,
|
||||
remoteIdentityDigest: `sha256:${'5'.repeat(64)}`,
|
||||
} as const;
|
||||
|
||||
const workspaceTrust: RunLaunchManifestCompileInput['workspaceTrust'] = {
|
||||
schemaVersion: 'workspace-execution-trust/v1',
|
||||
status: 'not-required',
|
||||
source: 'No repository-controlled execution components were discovered.',
|
||||
requiresExplicitDecision: false,
|
||||
identity: workspaceTrustIdentity,
|
||||
inventory: {
|
||||
schemaVersion: 'workspace-execution-trust-inventory/v1',
|
||||
digest: `sha256:${'6'.repeat(64)}`,
|
||||
scannerRevision: 1,
|
||||
scannedAt: '2026-07-23T20:00:00.000Z',
|
||||
identity: workspaceTrustIdentity,
|
||||
entries: [],
|
||||
projectPolicy: {
|
||||
maximumTrust: 'trusted',
|
||||
valid: true,
|
||||
},
|
||||
},
|
||||
restrictionChecks: [],
|
||||
};
|
||||
|
||||
function input(
|
||||
overrides: Partial<RunLaunchManifestCompileInput> = {}
|
||||
): RunLaunchManifestCompileInput {
|
||||
|
|
@ -262,10 +295,7 @@ function input(
|
|||
limits: { totalTokens: 50_000 },
|
||||
hardAction: 'require-approval',
|
||||
},
|
||||
workspaceTrust: {
|
||||
status: 'not-required',
|
||||
source: 'No repository-controlled executable components were selected.',
|
||||
},
|
||||
workspaceTrust,
|
||||
origins: [
|
||||
{
|
||||
field: 'runtime.model',
|
||||
|
|
@ -407,6 +437,24 @@ describe('RunLaunchManifestService', () => {
|
|||
expect(() => manifest.runtime.args.push('--tamper')).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('keeps legacy v1 launch manifests readable without treating them as fresh trust evidence', () => {
|
||||
const current = new RunLaunchManifestService().compile(input());
|
||||
const { digest: _digest, ...currentPayload } = current;
|
||||
const legacyPayload = {
|
||||
...currentPayload,
|
||||
workspaceTrust: {
|
||||
status: 'trusted' as const,
|
||||
source: 'Legacy workspace trust evidence.',
|
||||
},
|
||||
};
|
||||
const legacy = {
|
||||
...legacyPayload,
|
||||
digest: calculateRunLaunchManifestDigest(legacyPayload),
|
||||
};
|
||||
|
||||
expect(parseRunLaunchManifest(legacy).workspaceTrust).toEqual(legacyPayload.workspaceTrust);
|
||||
});
|
||||
|
||||
it('rejects filesystem evidence linked to a different provider manifest', () => {
|
||||
const compileInput = input();
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,21 @@ describe('shared API permission metadata', () => {
|
|||
).toEqual(['agent:read']);
|
||||
});
|
||||
|
||||
it('keeps workspace trust scans read-scoped and decisions admin-scoped', () => {
|
||||
expect(
|
||||
getApiPermissionRequirement('/api/agents/task_1/workspace-trust', {
|
||||
method: 'GET',
|
||||
}).permissions
|
||||
).toEqual(['agent:read']);
|
||||
for (const action of ['decisions', 'revoke']) {
|
||||
expect(
|
||||
getApiPermissionRequirement(`/api/agents/task_1/workspace-trust/${action}`, {
|
||||
method: 'POST',
|
||||
}).permissions
|
||||
).toEqual(['admin:manage']);
|
||||
}
|
||||
});
|
||||
|
||||
it('separates conversation steering from lifecycle mutation authority', () => {
|
||||
expect(
|
||||
getApiPermissionRequirement('/api/agents/task_1/conversation/steer', {
|
||||
|
|
|
|||
437
server/src/__tests__/workspace-execution-trust-service.test.ts
Normal file
437
server/src/__tests__/workspace-execution-trust-service.test.ts
Normal file
|
|
@ -0,0 +1,437 @@
|
|||
import { execFile } from 'node:child_process';
|
||||
import { mkdtemp, mkdir, rename, rm, symlink, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import type {
|
||||
RunLaunchWorkspaceTrust,
|
||||
WorkspaceExecutionTrustEvaluation,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { InMemoryWorkspaceExecutionTrustRepository } from '../storage/workspace-execution-trust-repository.js';
|
||||
import {
|
||||
WorkspaceExecutionTrustService,
|
||||
type WorkspaceExecutionTrustLaunchConstraints,
|
||||
} from '../services/workspace-execution-trust-service.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const temporaryRoots: string[] = [];
|
||||
|
||||
async function createRepository(name = 'repo'): Promise<string> {
|
||||
const parent = await mkdtemp(path.join(os.tmpdir(), 'vk-workspace-trust-'));
|
||||
temporaryRoots.push(parent);
|
||||
const repository = path.join(parent, name);
|
||||
await mkdir(repository);
|
||||
await execFileAsync('git', ['init', repository]);
|
||||
await execFileAsync('git', [
|
||||
'-C',
|
||||
repository,
|
||||
'remote',
|
||||
'add',
|
||||
'origin',
|
||||
'https://github.com/example/project.git',
|
||||
]);
|
||||
return repository;
|
||||
}
|
||||
|
||||
function restrictedConstraints(
|
||||
overrides: Partial<WorkspaceExecutionTrustLaunchConstraints> = {}
|
||||
): WorkspaceExecutionTrustLaunchConstraints {
|
||||
return {
|
||||
sandboxMode: 'read-only',
|
||||
networkAccessEnabled: false,
|
||||
taskCredentialReferences: [],
|
||||
filesystemEnforcement: 'enforced',
|
||||
selectedToolServerCount: 0,
|
||||
externalMutationAllowed: false,
|
||||
projectExecutableConfigurationBlocked: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function service(repository = new InMemoryWorkspaceExecutionTrustRepository()) {
|
||||
return new WorkspaceExecutionTrustService({
|
||||
repository,
|
||||
audit: async () => {},
|
||||
now: () => new Date('2026-07-25T00:00:00.000Z'),
|
||||
});
|
||||
}
|
||||
|
||||
function launchTrust(evaluation: WorkspaceExecutionTrustEvaluation): RunLaunchWorkspaceTrust {
|
||||
return {
|
||||
schemaVersion: evaluation.schemaVersion,
|
||||
status: evaluation.status,
|
||||
source: evaluation.source,
|
||||
policyVersion: evaluation.decision?.policyVersion ?? 1,
|
||||
identityDigest: evaluation.identity.digest,
|
||||
inventoryDigest: evaluation.inventory.digest,
|
||||
inventoryEntryCount: evaluation.inventory.entries.length,
|
||||
containsExecutableConfiguration: evaluation.inventory.entries.some(
|
||||
(entry) => entry.posture === 'executable'
|
||||
),
|
||||
requestedCapabilities: [
|
||||
...new Set(evaluation.inventory.entries.flatMap((entry) => entry.requestedCapabilities)),
|
||||
],
|
||||
...(evaluation.decision
|
||||
? {
|
||||
decisionId: evaluation.decision.id,
|
||||
decisionMode: evaluation.decision.mode,
|
||||
}
|
||||
: {}),
|
||||
inventory: evaluation.inventory.entries.map((entry) => ({
|
||||
id: entry.id,
|
||||
pathDigest: entry.canonicalPathDigest,
|
||||
kind: entry.kind,
|
||||
posture: entry.posture,
|
||||
sourceFingerprint: entry.sourceFingerprint,
|
||||
requestedCapabilities: entry.requestedCapabilities,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryRoots.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))
|
||||
);
|
||||
});
|
||||
|
||||
describe('WorkspaceExecutionTrustService', () => {
|
||||
it('provisionally allows a clean workspace and rescans newly added executable config', async () => {
|
||||
const repository = await createRepository();
|
||||
const trust = service();
|
||||
|
||||
const clean = await trust.evaluateForLaunch({
|
||||
workspacePath: repository,
|
||||
constraints: restrictedConstraints({ sandboxMode: 'workspace-write' }),
|
||||
});
|
||||
expect(clean.status).toBe('not-required');
|
||||
expect(clean.inventory.entries).toEqual([]);
|
||||
|
||||
await writeFile(
|
||||
path.join(repository, '.mcp.json'),
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
malicious: { command: 'sh', args: ['-c', 'echo should-not-run'] },
|
||||
},
|
||||
})
|
||||
);
|
||||
const changed = await trust.evaluateForLaunch({
|
||||
workspacePath: repository,
|
||||
constraints: restrictedConstraints(),
|
||||
});
|
||||
expect(changed.status).toBe('untrusted');
|
||||
expect(changed.requiresExplicitDecision).toBe(true);
|
||||
expect(changed.inventory.digest).not.toBe(clean.inventory.digest);
|
||||
expect(changed.inventory.entries[0]).toMatchObject({
|
||||
relativePath: '.mcp.json',
|
||||
kind: 'tool-server-configuration',
|
||||
posture: 'executable',
|
||||
});
|
||||
|
||||
await trust.recordDecision(
|
||||
repository,
|
||||
{
|
||||
inventoryDigest: changed.inventory.digest,
|
||||
mode: 'restricted',
|
||||
reason: 'Allow only with executable configuration denied.',
|
||||
},
|
||||
'admin'
|
||||
);
|
||||
const notBlocked = await trust.evaluateForLaunch({
|
||||
workspacePath: repository,
|
||||
constraints: restrictedConstraints({
|
||||
projectExecutableConfigurationBlocked: false,
|
||||
}),
|
||||
});
|
||||
expect(notBlocked.status).toBe('untrusted');
|
||||
expect(
|
||||
notBlocked.restrictionChecks.find(
|
||||
(check) => check.id === 'project-executable-configuration-blocked'
|
||||
)?.satisfied
|
||||
).toBe(false);
|
||||
|
||||
const blocked = await trust.evaluateForLaunch({
|
||||
workspacePath: repository,
|
||||
constraints: restrictedConstraints(),
|
||||
});
|
||||
expect(blocked.status).toBe('restricted');
|
||||
});
|
||||
|
||||
it('binds trust to the exact inventory and rejects later changes', async () => {
|
||||
const repository = await createRepository();
|
||||
await writeFile(path.join(repository, 'AGENTS.md'), 'Use repository instructions.\n');
|
||||
const trust = service();
|
||||
const scan = await trust.scan(repository);
|
||||
|
||||
const decision = await trust.recordDecision(
|
||||
repository,
|
||||
{
|
||||
inventoryDigest: scan.inventory.digest,
|
||||
mode: 'trusted',
|
||||
reason: 'Reviewed repository instructions and configuration.',
|
||||
},
|
||||
'admin'
|
||||
);
|
||||
const trusted = await trust.evaluateForLaunch({
|
||||
workspacePath: repository,
|
||||
constraints: restrictedConstraints({ sandboxMode: 'workspace-write' }),
|
||||
});
|
||||
expect(trusted.status).toBe('trusted');
|
||||
expect(trusted.decision?.id).toBe(decision.id);
|
||||
|
||||
await writeFile(path.join(repository, '.codex', 'config.toml'), 'model = "example"\n').catch(
|
||||
async () => {
|
||||
await mkdir(path.join(repository, '.codex'), { recursive: true });
|
||||
await writeFile(path.join(repository, '.codex', 'config.toml'), 'model = "example"\n');
|
||||
}
|
||||
);
|
||||
const stale = await trust.evaluateForLaunch({
|
||||
workspacePath: repository,
|
||||
constraints: restrictedConstraints(),
|
||||
});
|
||||
expect(stale.status).toBe('untrusted');
|
||||
expect(stale.requiresExplicitDecision).toBe(true);
|
||||
expect(stale.source).toContain('changed after the recorded trust decision');
|
||||
});
|
||||
|
||||
it('keeps explicit distrust effective when repository contents change', async () => {
|
||||
const repository = await createRepository();
|
||||
await writeFile(path.join(repository, 'AGENTS.md'), 'Untrusted instructions.\n');
|
||||
const trust = service();
|
||||
const scan = await trust.scan(repository);
|
||||
await trust.recordDecision(
|
||||
repository,
|
||||
{
|
||||
inventoryDigest: scan.inventory.digest,
|
||||
mode: 'denied',
|
||||
reason: 'Repository source is not approved.',
|
||||
},
|
||||
'admin'
|
||||
);
|
||||
await writeFile(path.join(repository, 'CLAUDE.md'), 'New instructions.\n');
|
||||
|
||||
const evaluation = await trust.evaluateForLaunch({
|
||||
workspacePath: repository,
|
||||
constraints: restrictedConstraints(),
|
||||
});
|
||||
expect(evaluation.status).toBe('untrusted');
|
||||
expect(evaluation.requiresExplicitDecision).toBe(false);
|
||||
expect(evaluation.source).toContain('distrust decision');
|
||||
});
|
||||
|
||||
it('permits model instructions only when every restricted boundary is enforced', async () => {
|
||||
const repository = await createRepository();
|
||||
await writeFile(path.join(repository, 'AGENTS.md'), 'Read-only guidance.\n');
|
||||
const trust = service();
|
||||
|
||||
const restricted = await trust.evaluateForLaunch({
|
||||
workspacePath: repository,
|
||||
constraints: restrictedConstraints(),
|
||||
});
|
||||
expect(restricted.status).toBe('restricted');
|
||||
expect(restricted.requiresExplicitDecision).toBe(false);
|
||||
|
||||
const writable = await trust.evaluateForLaunch({
|
||||
workspacePath: repository,
|
||||
constraints: restrictedConstraints({ sandboxMode: 'workspace-write' }),
|
||||
});
|
||||
expect(writable.status).toBe('untrusted');
|
||||
expect(
|
||||
writable.restrictionChecks.find((check) => check.id === 'filesystem-read-only')?.satisfied
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('lets project policy narrow trust but never widen it', async () => {
|
||||
const repository = await createRepository();
|
||||
await mkdir(path.join(repository, '.veritas-kanban'), { recursive: true });
|
||||
await writeFile(path.join(repository, 'AGENTS.md'), 'Repository instructions.\n');
|
||||
await writeFile(
|
||||
path.join(repository, '.veritas-kanban', 'workspace-trust.json'),
|
||||
JSON.stringify({
|
||||
schemaVersion: 'workspace-trust-policy/v1',
|
||||
maximumTrust: 'restricted',
|
||||
})
|
||||
);
|
||||
const trust = service();
|
||||
const scan = await trust.scan(repository);
|
||||
|
||||
await expect(
|
||||
trust.recordDecision(
|
||||
repository,
|
||||
{
|
||||
inventoryDigest: scan.inventory.digest,
|
||||
mode: 'trusted',
|
||||
reason: 'Attempted trust widening.',
|
||||
},
|
||||
'admin'
|
||||
)
|
||||
).rejects.toThrow('does not permit a trusted decision');
|
||||
|
||||
await trust.recordDecision(
|
||||
repository,
|
||||
{
|
||||
inventoryDigest: scan.inventory.digest,
|
||||
mode: 'restricted',
|
||||
reason: 'Respect project maximum.',
|
||||
},
|
||||
'admin'
|
||||
);
|
||||
const evaluation = await trust.evaluateForLaunch({
|
||||
workspacePath: repository,
|
||||
constraints: restrictedConstraints(),
|
||||
});
|
||||
expect(evaluation.status).toBe('restricted');
|
||||
});
|
||||
|
||||
it('revokes an active decision without requiring a restart', async () => {
|
||||
const repository = await createRepository();
|
||||
await writeFile(path.join(repository, 'AGENTS.md'), 'Repository instructions.\n');
|
||||
const trust = service();
|
||||
const scan = await trust.scan(repository);
|
||||
const active = await trust.recordDecision(
|
||||
repository,
|
||||
{
|
||||
inventoryDigest: scan.inventory.digest,
|
||||
mode: 'trusted',
|
||||
reason: 'Temporary review approval.',
|
||||
},
|
||||
'admin'
|
||||
);
|
||||
|
||||
const revoked = await trust.revoke(
|
||||
repository,
|
||||
{
|
||||
inventoryDigest: scan.inventory.digest,
|
||||
reason: 'Approval withdrawn.',
|
||||
},
|
||||
'admin'
|
||||
);
|
||||
expect(revoked.mode).toBe('revoked');
|
||||
expect(revoked.supersedesDecisionId).toBe(active.id);
|
||||
const evaluation = await trust.evaluateForLaunch({
|
||||
workspacePath: repository,
|
||||
constraints: restrictedConstraints(),
|
||||
});
|
||||
expect(evaluation.status).toBe('restricted');
|
||||
expect(evaluation.decision?.mode).toBe('revoked');
|
||||
});
|
||||
|
||||
it('uses filesystem identity to distinguish siblings while surviving symlink and rename', async () => {
|
||||
const first = await createRepository('first');
|
||||
const parent = path.dirname(first);
|
||||
const linked = path.join(parent, 'linked');
|
||||
await symlink(first, linked, 'dir');
|
||||
const repository = new InMemoryWorkspaceExecutionTrustRepository();
|
||||
const original = await repository.inspect(first);
|
||||
const throughSymlink = await repository.inspect(linked);
|
||||
expect(throughSymlink.identity.digest).toBe(original.identity.digest);
|
||||
|
||||
await execFileAsync('git', [
|
||||
'-C',
|
||||
first,
|
||||
'remote',
|
||||
'set-url',
|
||||
'origin',
|
||||
'https://temporary-token@github.com/example/project.git',
|
||||
]);
|
||||
const afterCredentialRotation = await repository.inspect(first);
|
||||
expect(afterCredentialRotation.identity.digest).toBe(original.identity.digest);
|
||||
|
||||
const moved = path.join(parent, 'moved');
|
||||
await rename(first, moved);
|
||||
const afterMove = await repository.inspect(moved);
|
||||
expect(afterMove.identity.digest).toBe(original.identity.digest);
|
||||
expect(afterMove.digest).toBe(original.digest);
|
||||
|
||||
const sibling = path.join(parent, 'sibling');
|
||||
await mkdir(sibling);
|
||||
await execFileAsync('git', ['init', sibling]);
|
||||
await execFileAsync('git', [
|
||||
'-C',
|
||||
sibling,
|
||||
'remote',
|
||||
'add',
|
||||
'origin',
|
||||
'https://github.com/example/project.git',
|
||||
]);
|
||||
const siblingInventory = await repository.inspect(sibling);
|
||||
expect(siblingInventory.identity.digest).not.toBe(original.identity.digest);
|
||||
|
||||
const nested = path.join(moved, 'nested');
|
||||
await mkdir(nested);
|
||||
await expect(repository.inspect(nested)).rejects.toThrow(
|
||||
'requires the registered Git worktree root'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not follow configuration symlinks outside the workspace', async () => {
|
||||
const repository = await createRepository();
|
||||
const outside = path.join(path.dirname(repository), 'outside-mcp.json');
|
||||
await writeFile(outside, '{"secret":"must-not-be-read"}\n');
|
||||
await symlink(outside, path.join(repository, '.mcp.json'));
|
||||
const outsideDirectory = path.join(path.dirname(repository), 'outside-buzz');
|
||||
await mkdir(outsideDirectory);
|
||||
await writeFile(
|
||||
path.join(outsideDirectory, 'config.toml'),
|
||||
'secret = "must-not-be-read-either"\n'
|
||||
);
|
||||
await symlink(outsideDirectory, path.join(repository, '.buzz'));
|
||||
|
||||
const inventory = await new InMemoryWorkspaceExecutionTrustRepository().inspect(repository);
|
||||
const entry = inventory.entries.find((candidate) => candidate.relativePath === '.mcp.json');
|
||||
expect(entry).toMatchObject({
|
||||
symbolicLink: true,
|
||||
posture: 'executable',
|
||||
});
|
||||
expect(entry?.requestedCapabilities).toContain('filesystem.external-read');
|
||||
expect(inventory.entries).toContainEqual(
|
||||
expect.objectContaining({
|
||||
relativePath: '.buzz',
|
||||
kind: 'unknown-executable',
|
||||
posture: 'executable',
|
||||
symbolicLink: true,
|
||||
})
|
||||
);
|
||||
expect(JSON.stringify(inventory)).not.toContain('must-not-be-read');
|
||||
expect(JSON.stringify(inventory)).not.toContain('must-not-be-read-either');
|
||||
});
|
||||
|
||||
it('treats harness configuration as executable and rechecks decision state before spawn', async () => {
|
||||
const repository = await createRepository();
|
||||
await mkdir(path.join(repository, '.buzz'), { recursive: true });
|
||||
await writeFile(path.join(repository, '.buzz', 'config.toml'), 'command = "unsafe"\n');
|
||||
const trust = service();
|
||||
const scan = await trust.scan(repository);
|
||||
expect(scan.inventory.entries[0]).toMatchObject({
|
||||
relativePath: '.buzz/config.toml',
|
||||
kind: 'provider-configuration',
|
||||
posture: 'executable',
|
||||
});
|
||||
await trust.recordDecision(
|
||||
repository,
|
||||
{
|
||||
inventoryDigest: scan.inventory.digest,
|
||||
mode: 'trusted',
|
||||
reason: 'Reviewed executable harness configuration.',
|
||||
},
|
||||
'admin'
|
||||
);
|
||||
const trusted = await trust.evaluateForLaunch({
|
||||
workspacePath: repository,
|
||||
constraints: restrictedConstraints({ sandboxMode: 'workspace-write' }),
|
||||
});
|
||||
const expected = launchTrust(trusted);
|
||||
await trust.revoke(
|
||||
repository,
|
||||
{
|
||||
inventoryDigest: scan.inventory.digest,
|
||||
reason: 'Authorization withdrawn before spawn.',
|
||||
},
|
||||
'admin'
|
||||
);
|
||||
|
||||
await expect(trust.assertFresh(repository, expected)).rejects.toThrow(
|
||||
'decision changed before provider activation'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -28,8 +28,14 @@ import type { AuthenticatedRequest } from '../middleware/auth.js';
|
|||
import { ProviderRuntimeCapabilityIdSchema } from '../schemas/provider-runtime-manifest-schemas.js';
|
||||
import { TaskCommitPolicySchema } from '../schemas/task-envelope-schemas.js';
|
||||
import { RunEventQuerySchema } from '../schemas/run-event-schemas.js';
|
||||
import {
|
||||
workspaceExecutionTrustDecisionInputSchema,
|
||||
workspaceExecutionTrustRevokeInputSchema,
|
||||
} from '../schemas/workspace-execution-trust-schemas.js';
|
||||
import { getWorkspaceExecutionTrustService } from '../services/workspace-execution-trust-service.js';
|
||||
|
||||
const router: RouterType = Router();
|
||||
const workspaceExecutionTrust = getWorkspaceExecutionTrustService();
|
||||
|
||||
// Validation schemas
|
||||
const AgentTypeSchema = z.string().min(1).max(50);
|
||||
|
|
@ -180,6 +186,57 @@ const reportTokensSchema = z.object({
|
|||
agent: AgentTypeSchema.optional(),
|
||||
});
|
||||
|
||||
async function taskWorkspacePath(taskId: string): Promise<string> {
|
||||
const task = await getTaskService().getTask(taskId);
|
||||
if (!task) throw new NotFoundError(`Task ${taskId} not found`);
|
||||
if (!task.git?.worktreePath) {
|
||||
throw new ValidationError('Task must have an active worktree for workspace trust.');
|
||||
}
|
||||
return task.git.worktreePath;
|
||||
}
|
||||
|
||||
// GET /api/agents/:taskId/workspace-trust - Scan the exact task worktree and show its decision.
|
||||
router.get(
|
||||
'/:taskId/workspace-trust',
|
||||
requireLocalAgentCapability,
|
||||
asyncHandler(async (req, res) => {
|
||||
res.json(
|
||||
await workspaceExecutionTrust.scan(await taskWorkspacePath(req.params.taskId as string))
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
// POST /api/agents/:taskId/workspace-trust/decisions - Record an exact-inventory decision.
|
||||
router.post(
|
||||
'/:taskId/workspace-trust/decisions',
|
||||
requireLocalAgentCapability,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const input = workspaceExecutionTrustDecisionInputSchema.parse(req.body);
|
||||
const decision = await workspaceExecutionTrust.recordDecision(
|
||||
await taskWorkspacePath(req.params.taskId as string),
|
||||
input,
|
||||
requestActor(req)
|
||||
);
|
||||
res.status(201).json(decision);
|
||||
})
|
||||
);
|
||||
|
||||
// POST /api/agents/:taskId/workspace-trust/revoke - Revoke the current decision.
|
||||
router.post(
|
||||
'/:taskId/workspace-trust/revoke',
|
||||
requireLocalAgentCapability,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const input = workspaceExecutionTrustRevokeInputSchema.parse(req.body);
|
||||
res.json(
|
||||
await workspaceExecutionTrust.revoke(
|
||||
await taskWorkspacePath(req.params.taskId as string),
|
||||
input,
|
||||
requestActor(req)
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
// POST /api/agents/:taskId/launch-preview - Compile effective launch evidence without dispatch.
|
||||
router.post(
|
||||
'/:taskId/launch-preview',
|
||||
|
|
|
|||
|
|
@ -44,6 +44,11 @@ export const agentRoutingAccess = routeAccess('agent:read', 'admin:manage', [
|
|||
]);
|
||||
export const agentTaskAccess = routeAccess('agent:read', 'task:write', [
|
||||
{ methods: ['POST'], path: /^\/[^/]+\/(start|stop)\/?$/, permissions: 'agent:write' },
|
||||
{
|
||||
methods: ['POST'],
|
||||
path: /^\/[^/]+\/workspace-trust\/(?:decisions|revoke)\/?$/,
|
||||
permissions: 'admin:manage',
|
||||
},
|
||||
]);
|
||||
export const agentStatusAccess = routeAccess('agent:read', 'telemetry:write');
|
||||
export const reportAccess = routeAccess('report:read', 'report:read');
|
||||
|
|
|
|||
|
|
@ -299,12 +299,57 @@ export const RunLaunchManifestSchema = z
|
|||
})
|
||||
.strict(),
|
||||
budget: AgentBudgetPolicySchema,
|
||||
workspaceTrust: z
|
||||
.object({
|
||||
status: z.enum(['trusted', 'untrusted', 'not-required']),
|
||||
source: safeTextSchema,
|
||||
})
|
||||
.strict(),
|
||||
workspaceTrust: z.union([
|
||||
z
|
||||
.object({
|
||||
schemaVersion: z.literal('workspace-execution-trust/v1'),
|
||||
status: z.enum(['trusted', 'restricted', 'untrusted', 'not-required']),
|
||||
source: safeTextSchema,
|
||||
policyVersion: z.number().int().positive().max(10_000),
|
||||
identityDigest: digestSchema,
|
||||
inventoryDigest: digestSchema,
|
||||
inventoryEntryCount: z.number().int().nonnegative().max(2_000),
|
||||
containsExecutableConfiguration: z.boolean(),
|
||||
requestedCapabilities: z.array(identifierSchema).max(256),
|
||||
decisionId: identifierSchema.optional(),
|
||||
decisionMode: z.enum(['trusted', 'restricted', 'denied', 'revoked']).optional(),
|
||||
decisionExpiresAt: z.string().datetime({ offset: true }).optional(),
|
||||
inventory: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: identifierSchema,
|
||||
pathDigest: digestSchema,
|
||||
kind: z.enum([
|
||||
'agent-instruction',
|
||||
'provider-instruction',
|
||||
'provider-configuration',
|
||||
'tool-server-configuration',
|
||||
'runtime-hook',
|
||||
'language-server-configuration',
|
||||
'workflow-configuration',
|
||||
'extension-configuration',
|
||||
'agent-definition',
|
||||
'skill-definition',
|
||||
'project-trust-policy',
|
||||
'unknown-executable',
|
||||
]),
|
||||
posture: z.enum(['declarative-only', 'model-influencing', 'executable']),
|
||||
sourceFingerprint: digestSchema,
|
||||
requestedCapabilities: z.array(identifierSchema).max(64),
|
||||
})
|
||||
.strict()
|
||||
)
|
||||
.max(2_000),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
status: z.enum(['trusted', 'untrusted', 'not-required']),
|
||||
source: safeTextSchema,
|
||||
})
|
||||
.strict(),
|
||||
]),
|
||||
origins: z
|
||||
.array(
|
||||
z
|
||||
|
|
|
|||
159
server/src/schemas/workspace-execution-trust-schemas.ts
Normal file
159
server/src/schemas/workspace-execution-trust-schemas.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { z } from 'zod';
|
||||
import {
|
||||
WORKSPACE_EXECUTION_TRUST_DECISION_SCHEMA_VERSION,
|
||||
WORKSPACE_EXECUTION_TRUST_INVENTORY_SCHEMA_VERSION,
|
||||
WORKSPACE_EXECUTION_TRUST_POLICY_VERSION,
|
||||
WORKSPACE_EXECUTION_TRUST_SCHEMA_VERSION,
|
||||
} from '@veritas-kanban/shared';
|
||||
|
||||
const digestSchema = z.string().regex(/^sha256:[a-f0-9]{64}$/);
|
||||
const safeTextSchema = z.string().trim().min(1).max(4_096);
|
||||
const identifierSchema = z.string().trim().min(1).max(240);
|
||||
|
||||
export const workspaceExecutionTrustPostureSchema = z.enum([
|
||||
'declarative-only',
|
||||
'model-influencing',
|
||||
'executable',
|
||||
]);
|
||||
|
||||
export const workspaceExecutionTrustComponentKindSchema = z.enum([
|
||||
'agent-instruction',
|
||||
'provider-instruction',
|
||||
'provider-configuration',
|
||||
'tool-server-configuration',
|
||||
'runtime-hook',
|
||||
'language-server-configuration',
|
||||
'workflow-configuration',
|
||||
'extension-configuration',
|
||||
'agent-definition',
|
||||
'skill-definition',
|
||||
'project-trust-policy',
|
||||
'unknown-executable',
|
||||
]);
|
||||
|
||||
export const workspaceExecutionTrustIdentitySchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(WORKSPACE_EXECUTION_TRUST_SCHEMA_VERSION),
|
||||
digest: digestSchema,
|
||||
canonicalWorkspacePathDigest: digestSchema,
|
||||
canonicalRepositoryRootDigest: digestSchema,
|
||||
gitCommonDirectoryDigest: digestSchema,
|
||||
remoteIdentityDigest: digestSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const workspaceExecutionTrustInventoryEntrySchema = z
|
||||
.object({
|
||||
id: identifierSchema,
|
||||
relativePath: z.string().min(1).max(4_096),
|
||||
canonicalPathDigest: digestSchema,
|
||||
scope: z.enum(['workspace-root', 'workspace-descendant', 'git-common-directory']),
|
||||
kind: workspaceExecutionTrustComponentKindSchema,
|
||||
posture: workspaceExecutionTrustPostureSchema,
|
||||
sourceFingerprint: digestSchema,
|
||||
byteLength: z
|
||||
.number()
|
||||
.int()
|
||||
.nonnegative()
|
||||
.max(2 * 1024 * 1024),
|
||||
symbolicLink: z.boolean(),
|
||||
requestedCapabilities: z.array(identifierSchema).max(64),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const workspaceExecutionTrustProjectPolicySchema = z
|
||||
.object({
|
||||
maximumTrust: z.enum(['trusted', 'restricted', 'denied']),
|
||||
sourceFingerprint: digestSchema.optional(),
|
||||
valid: z.boolean(),
|
||||
diagnostic: safeTextSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const workspaceExecutionTrustInventorySchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(WORKSPACE_EXECUTION_TRUST_INVENTORY_SCHEMA_VERSION),
|
||||
digest: digestSchema,
|
||||
scannerRevision: z.number().int().positive().max(10_000),
|
||||
scannedAt: z.string().datetime({ offset: true }),
|
||||
identity: workspaceExecutionTrustIdentitySchema,
|
||||
entries: z.array(workspaceExecutionTrustInventoryEntrySchema).max(2_000),
|
||||
projectPolicy: workspaceExecutionTrustProjectPolicySchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const workspaceExecutionTrustDecisionSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(WORKSPACE_EXECUTION_TRUST_DECISION_SCHEMA_VERSION),
|
||||
id: identifierSchema,
|
||||
identityDigest: digestSchema,
|
||||
inventoryDigest: digestSchema,
|
||||
mode: z.enum(['trusted', 'restricted', 'denied', 'revoked']),
|
||||
actor: safeTextSchema.max(240),
|
||||
reason: safeTextSchema,
|
||||
policyVersion: z.literal(WORKSPACE_EXECUTION_TRUST_POLICY_VERSION),
|
||||
createdAt: z.string().datetime({ offset: true }),
|
||||
expiresAt: z.string().datetime({ offset: true }).optional(),
|
||||
supersedesDecisionId: identifierSchema.optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((decision, context) => {
|
||||
if (decision.expiresAt && Date.parse(decision.expiresAt) <= Date.parse(decision.createdAt)) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['expiresAt'],
|
||||
message: 'expiresAt must be later than createdAt',
|
||||
});
|
||||
}
|
||||
if (decision.mode === 'revoked' && !decision.supersedesDecisionId) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['supersedesDecisionId'],
|
||||
message: 'revoked decisions must identify the superseded decision',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const workspaceExecutionTrustRestrictionCheckSchema = z
|
||||
.object({
|
||||
id: identifierSchema,
|
||||
satisfied: z.boolean(),
|
||||
detail: safeTextSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const workspaceExecutionTrustEvaluationSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(WORKSPACE_EXECUTION_TRUST_SCHEMA_VERSION),
|
||||
status: z.enum(['trusted', 'restricted', 'untrusted', 'not-required']),
|
||||
source: safeTextSchema,
|
||||
requiresExplicitDecision: z.boolean(),
|
||||
identity: workspaceExecutionTrustIdentitySchema,
|
||||
inventory: workspaceExecutionTrustInventorySchema,
|
||||
decision: workspaceExecutionTrustDecisionSchema.optional(),
|
||||
restrictionChecks: z.array(workspaceExecutionTrustRestrictionCheckSchema).max(32),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const workspaceExecutionTrustDecisionInputSchema = z
|
||||
.object({
|
||||
inventoryDigest: digestSchema,
|
||||
mode: z.enum(['trusted', 'restricted', 'denied']),
|
||||
reason: safeTextSchema.max(1_000),
|
||||
expiresAt: z.string().datetime({ offset: true }).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const workspaceExecutionTrustRevokeInputSchema = z
|
||||
.object({
|
||||
inventoryDigest: digestSchema,
|
||||
reason: safeTextSchema.max(1_000),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const workspaceExecutionTrustProjectFileSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal('workspace-trust-policy/v1'),
|
||||
maximumTrust: z.enum(['trusted', 'restricted', 'denied']),
|
||||
})
|
||||
.strict();
|
||||
|
|
@ -120,6 +120,8 @@ import type {
|
|||
ProviderRuntimeCapabilityEvidence,
|
||||
RunApprovalActionClass,
|
||||
RunApprovalRiskClass,
|
||||
WorkspaceExecutionTrustEvaluation,
|
||||
WorkspaceExecutionTrustScanResult,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { createLogger } from '../lib/logger.js';
|
||||
import { ConflictError, NotFoundError } from '../middleware/error-handler.js';
|
||||
|
|
@ -234,6 +236,10 @@ import {
|
|||
getFilesystemSandboxService,
|
||||
type FilesystemSandboxLaunchPlan,
|
||||
} from './filesystem-sandbox-service.js';
|
||||
import {
|
||||
getWorkspaceExecutionTrustService,
|
||||
type WorkspaceExecutionTrustService,
|
||||
} from './workspace-execution-trust-service.js';
|
||||
const log = createLogger('clawdbot-agent-service');
|
||||
|
||||
const TRACE_SECRET_PATTERNS: Array<[RegExp, string]> = [
|
||||
|
|
@ -489,6 +495,10 @@ export class ClawdbotAgentService {
|
|||
'compile' | 'activate' | 'cleanup' | 'wrap'
|
||||
>;
|
||||
private sandboxPolicies: Pick<SandboxPolicyService, 'dryRunWithTrace'>;
|
||||
private workspaceExecutionTrust: Pick<
|
||||
WorkspaceExecutionTrustService,
|
||||
'scan' | 'evaluateForLaunch' | 'assertFresh'
|
||||
>;
|
||||
private logsDir: string;
|
||||
|
||||
constructor(
|
||||
|
|
@ -510,7 +520,11 @@ export class ClawdbotAgentService {
|
|||
FilesystemSandboxService,
|
||||
'compile' | 'activate' | 'cleanup' | 'wrap'
|
||||
> = getFilesystemSandboxService(),
|
||||
sandboxPolicies: Pick<SandboxPolicyService, 'dryRunWithTrace'> = getSandboxPolicyService()
|
||||
sandboxPolicies: Pick<SandboxPolicyService, 'dryRunWithTrace'> = getSandboxPolicyService(),
|
||||
workspaceExecutionTrust: Pick<
|
||||
WorkspaceExecutionTrustService,
|
||||
'scan' | 'evaluateForLaunch' | 'assertFresh'
|
||||
> = getWorkspaceExecutionTrustService()
|
||||
) {
|
||||
this.configService = new ConfigService();
|
||||
this.taskService = new TaskService();
|
||||
|
|
@ -536,6 +550,7 @@ export class ClawdbotAgentService {
|
|||
this.runRecoveryPolicy = runRecoveryPolicy;
|
||||
this.filesystemSandbox = filesystemSandbox;
|
||||
this.sandboxPolicies = sandboxPolicies;
|
||||
this.workspaceExecutionTrust = workspaceExecutionTrust;
|
||||
this.logsDir = getLogsDir();
|
||||
this.ensureLogsDir();
|
||||
}
|
||||
|
|
@ -1485,15 +1500,26 @@ export class ClawdbotAgentService {
|
|||
const startedAt = new Date().toISOString();
|
||||
const logPath = path.join(this.logsDir, `${taskId}_${attemptId}.md`);
|
||||
const worktreePath = this.expandPath(task.git.worktreePath);
|
||||
const workspaceTrustScan = await this.workspaceExecutionTrust.scan(worktreePath);
|
||||
const trustSandbox = this.workspaceTrustSandboxPolicy(sandboxPolicy.result, workspaceTrustScan);
|
||||
const filesystemSandboxPlan = await this.filesystemSandbox.compile({
|
||||
taskId,
|
||||
attemptId,
|
||||
provider,
|
||||
workspacePath: worktreePath,
|
||||
sandboxPolicy: sandboxPolicy.result,
|
||||
sandboxPolicy: trustSandbox.policy,
|
||||
providerRuntimeManifestDigest: providerRuntimeManifest.digest,
|
||||
providerCommand: launchAgentConfig?.command,
|
||||
});
|
||||
const workspaceTrustEvaluation = await this.workspaceExecutionTrust.evaluateForLaunch({
|
||||
workspacePath: worktreePath,
|
||||
constraints: this.workspaceTrustConstraints(
|
||||
trustSandbox.policy,
|
||||
filesystemSandboxPlan,
|
||||
profileLaunch,
|
||||
trustSandbox.projectExecutableConfigurationBlocked
|
||||
),
|
||||
});
|
||||
const taskEnvelope = await this.taskEnvelopes.build({
|
||||
task,
|
||||
attemptId,
|
||||
|
|
@ -1506,7 +1532,7 @@ export class ClawdbotAgentService {
|
|||
legacyAutoCommitOnComplete: config.features?.agents.autoCommitOnComplete,
|
||||
}),
|
||||
profileInstructions: profileLaunch?.instructions,
|
||||
networkAccessEnabled: sandboxPolicy.result.effective.networkAccessEnabled,
|
||||
networkAccessEnabled: trustSandbox.policy.effective.networkAccessEnabled,
|
||||
executionPolicy: task.executionPolicy,
|
||||
});
|
||||
const toolPolicy = await this.resolveLaunchToolPolicy(profileLaunch);
|
||||
|
|
@ -1551,13 +1577,14 @@ export class ClawdbotAgentService {
|
|||
profileLaunch,
|
||||
readiness,
|
||||
overrideReason,
|
||||
sandboxPolicy: sandboxPolicy.result,
|
||||
sandboxPolicy: trustSandbox.policy,
|
||||
budgetPolicy,
|
||||
budgetModelOverride: budgetEvaluation.modelOverride,
|
||||
budgetSources,
|
||||
options,
|
||||
runToolCatalog,
|
||||
filesystemSandboxPlan,
|
||||
workspaceTrustEvaluation,
|
||||
});
|
||||
const parentAttempt = await this.resolveParentAttempt(task, options.parentAttemptId);
|
||||
return {
|
||||
|
|
@ -1769,15 +1796,26 @@ export class ClawdbotAgentService {
|
|||
}
|
||||
const logPath = path.join(this.logsDir, `${taskId}_${attemptId}.md`);
|
||||
const worktreePath = this.expandPath(task.git.worktreePath);
|
||||
const workspaceTrustScan = await this.workspaceExecutionTrust.scan(worktreePath);
|
||||
const trustSandbox = this.workspaceTrustSandboxPolicy(sandboxPolicy.result, workspaceTrustScan);
|
||||
const filesystemSandboxPlan = await this.filesystemSandbox.compile({
|
||||
taskId,
|
||||
attemptId,
|
||||
provider,
|
||||
workspacePath: worktreePath,
|
||||
sandboxPolicy: sandboxPolicy.result,
|
||||
sandboxPolicy: trustSandbox.policy,
|
||||
providerRuntimeManifestDigest: providerRuntimeManifest.digest,
|
||||
providerCommand: launchAgentConfig?.command,
|
||||
});
|
||||
const workspaceTrustEvaluation = await this.workspaceExecutionTrust.evaluateForLaunch({
|
||||
workspacePath: worktreePath,
|
||||
constraints: this.workspaceTrustConstraints(
|
||||
trustSandbox.policy,
|
||||
filesystemSandboxPlan,
|
||||
profileLaunch,
|
||||
trustSandbox.projectExecutableConfigurationBlocked
|
||||
),
|
||||
});
|
||||
const commitPolicy = resolveTaskCommitPolicy({
|
||||
runPolicy: options.commitPolicy,
|
||||
taskPolicy: task.executionPolicy,
|
||||
|
|
@ -1791,7 +1829,7 @@ export class ClawdbotAgentService {
|
|||
providerRuntimeManifest,
|
||||
commitPolicy,
|
||||
profileInstructions: profileLaunch?.instructions,
|
||||
networkAccessEnabled: sandboxPolicy.result.effective.networkAccessEnabled,
|
||||
networkAccessEnabled: trustSandbox.policy.effective.networkAccessEnabled,
|
||||
executionPolicy: task.executionPolicy,
|
||||
});
|
||||
const toolPolicy = await this.resolveLaunchToolPolicy(profileLaunch);
|
||||
|
|
@ -1857,13 +1895,14 @@ export class ClawdbotAgentService {
|
|||
profileLaunch,
|
||||
readiness,
|
||||
overrideReason,
|
||||
sandboxPolicy: sandboxPolicy.result,
|
||||
sandboxPolicy: trustSandbox.policy,
|
||||
budgetPolicy,
|
||||
budgetModelOverride: budgetEvaluation.modelOverride,
|
||||
budgetSources,
|
||||
options,
|
||||
runToolCatalog,
|
||||
filesystemSandboxPlan,
|
||||
workspaceTrustEvaluation,
|
||||
});
|
||||
const parentAttempt = await this.resolveParentAttempt(
|
||||
task,
|
||||
|
|
@ -2190,6 +2229,10 @@ export class ClawdbotAgentService {
|
|||
harnessSupport: this.harnessTelemetry(harnessSupport),
|
||||
});
|
||||
|
||||
await this.workspaceExecutionTrust.assertFresh(
|
||||
worktreePath,
|
||||
runLaunchManifest.workspaceTrust
|
||||
);
|
||||
await this.filesystemSandbox.activate(filesystemSandboxPlan);
|
||||
await adapter.start({
|
||||
task,
|
||||
|
|
@ -7989,6 +8032,73 @@ export class ClawdbotAgentService {
|
|||
.map((f) => f.replace(`${taskId}_`, '').replace('.md', ''));
|
||||
}
|
||||
|
||||
private workspaceTrustConstraints(
|
||||
sandboxPolicy: SandboxPolicyDryRunResult,
|
||||
filesystemSandboxPlan: FilesystemSandboxLaunchPlan,
|
||||
profileLaunch: AgentProfileResolvedLaunch | undefined,
|
||||
projectExecutableConfigurationBlocked: boolean
|
||||
) {
|
||||
const allowedTools = profileLaunch?.profile.tools?.allowed ?? [];
|
||||
const requiredPermissions = profileLaunch?.profile.permissions?.required ?? [];
|
||||
const externalMutationAllowed = [...allowedTools, ...requiredPermissions].some((value) =>
|
||||
/(?:deploy|publish|release|github|webhook|external|mutation|write-api)/i.test(value)
|
||||
);
|
||||
return {
|
||||
sandboxMode: sandboxPolicy.effective.sandboxMode,
|
||||
networkAccessEnabled: sandboxPolicy.effective.networkAccessEnabled,
|
||||
taskCredentialReferences: [...sandboxPolicy.effective.credentialRefs],
|
||||
filesystemEnforcement: filesystemSandboxPlan.evidence.state,
|
||||
selectedToolServerCount: profileLaunch?.profile.tools?.mcpServers?.length ?? 0,
|
||||
externalMutationAllowed,
|
||||
projectExecutableConfigurationBlocked,
|
||||
};
|
||||
}
|
||||
|
||||
private workspaceTrustSandboxPolicy(
|
||||
policy: SandboxPolicyDryRunResult,
|
||||
scan: WorkspaceExecutionTrustScanResult
|
||||
): {
|
||||
policy: SandboxPolicyDryRunResult;
|
||||
projectExecutableConfigurationBlocked: boolean;
|
||||
} {
|
||||
const executableEntries = scan.inventory.entries.filter(
|
||||
(entry) => entry.posture === 'executable'
|
||||
);
|
||||
if (executableEntries.length === 0) {
|
||||
return { policy, projectExecutableConfigurationBlocked: true };
|
||||
}
|
||||
const decision = scan.currentDecision;
|
||||
const decisionIsCurrent =
|
||||
decision && decision.mode !== 'revoked' && decision.inventoryDigest === scan.inventory.digest;
|
||||
const restricted =
|
||||
decisionIsCurrent &&
|
||||
(decision.mode === 'restricted' ||
|
||||
(decision.mode === 'trusted' &&
|
||||
scan.inventory.projectPolicy.maximumTrust === 'restricted'));
|
||||
if (!restricted || executableEntries.some((entry) => entry.relativePath.startsWith('git:'))) {
|
||||
return { policy, projectExecutableConfigurationBlocked: false };
|
||||
}
|
||||
const deniedPaths = [
|
||||
...new Set([
|
||||
...policy.preset.filesystem.deniedPaths,
|
||||
...executableEntries.map((entry) => entry.relativePath),
|
||||
]),
|
||||
].sort();
|
||||
return {
|
||||
policy: {
|
||||
...policy,
|
||||
preset: {
|
||||
...policy.preset,
|
||||
filesystem: {
|
||||
...policy.preset.filesystem,
|
||||
deniedPaths,
|
||||
},
|
||||
},
|
||||
},
|
||||
projectExecutableConfigurationBlocked: true,
|
||||
};
|
||||
}
|
||||
|
||||
private async compileRunLaunchManifest(input: {
|
||||
task: Task;
|
||||
taskEnvelope: TaskEnvelope;
|
||||
|
|
@ -8022,6 +8132,7 @@ export class ClawdbotAgentService {
|
|||
options: AgentStartOptions;
|
||||
runToolCatalog?: RunToolCatalog;
|
||||
filesystemSandboxPlan: FilesystemSandboxLaunchPlan;
|
||||
workspaceTrustEvaluation: WorkspaceExecutionTrustEvaluation;
|
||||
}): Promise<RunLaunchManifest> {
|
||||
const profile = input.profileLaunch?.profile;
|
||||
const toolCatalogDelivery = input.launchAgentConfig
|
||||
|
|
@ -8094,9 +8205,10 @@ export class ClawdbotAgentService {
|
|||
const worktreePath = input.task.git?.worktreePath
|
||||
? this.expandPath(input.task.git.worktreePath)
|
||||
: undefined;
|
||||
const repositoryInstructions = worktreePath
|
||||
? ((await this.workspaceFiles.readOptionalText(worktreePath, 'AGENTS.md')) ?? '')
|
||||
: '';
|
||||
const repositoryInstructions =
|
||||
worktreePath && input.workspaceTrustEvaluation.status !== 'untrusted'
|
||||
? ((await this.workspaceFiles.readOptionalText(worktreePath, 'AGENTS.md')) ?? '')
|
||||
: '';
|
||||
const hasRepositoryInstructions = Boolean(repositoryInstructions.trim());
|
||||
const instructions = [
|
||||
{
|
||||
|
|
@ -8612,12 +8724,11 @@ export class ClawdbotAgentService {
|
|||
},
|
||||
{
|
||||
field: 'workspaceTrust',
|
||||
scope: 'system-default',
|
||||
source:
|
||||
selectedSharedResources.length > 0
|
||||
? 'workspace-trust:resources-blocked'
|
||||
: 'workspace-trust:not-required',
|
||||
precedence: 0,
|
||||
scope: input.workspaceTrustEvaluation.decision ? 'workspace' : 'system-default',
|
||||
source: input.workspaceTrustEvaluation.decision
|
||||
? `workspace-trust-decision:${input.workspaceTrustEvaluation.decision.id}`
|
||||
: `workspace-trust-scan:${input.workspaceTrustEvaluation.inventory.digest}`,
|
||||
precedence: input.workspaceTrustEvaluation.decision ? 200 : 0,
|
||||
},
|
||||
{
|
||||
field: 'enforcement',
|
||||
|
|
@ -8704,13 +8815,7 @@ export class ClawdbotAgentService {
|
|||
enabled: false,
|
||||
scope: 'run',
|
||||
},
|
||||
workspaceTrust: {
|
||||
status: 'not-required',
|
||||
source:
|
||||
selectedSharedResources.length > 0
|
||||
? 'Referenced profile files and workflow entrypoints are not loaded by the current adapter and are blocked as unavailable resources.'
|
||||
: 'No repository-controlled executable profile components were selected.',
|
||||
},
|
||||
workspaceTrust: input.workspaceTrustEvaluation,
|
||||
origins,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,13 +18,16 @@ import type {
|
|||
RunLaunchRouting,
|
||||
RunLaunchRuntime,
|
||||
RunLaunchTools,
|
||||
RunLaunchWorkspaceTrust,
|
||||
RunLaunchFilesystemSandboxEvidence,
|
||||
SandboxPolicyDryRunResult,
|
||||
TaskEnvelope,
|
||||
TaskReadinessSummary,
|
||||
WorkspaceExecutionTrustEvaluation,
|
||||
} from '@veritas-kanban/shared';
|
||||
import {
|
||||
RUN_LAUNCH_MANIFEST_SCHEMA_VERSION,
|
||||
WORKSPACE_EXECUTION_TRUST_POLICY_VERSION,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { RUN_LAUNCH_MANIFEST_SCHEMA_VERSION } from '@veritas-kanban/shared';
|
||||
import { ConflictError, ValidationError } from '../middleware/error-handler.js';
|
||||
import { parseProviderRuntimeManifest } from '../schemas/provider-runtime-manifest-schemas.js';
|
||||
import { parseRunLaunchManifest } from '../schemas/run-launch-manifest-schemas.js';
|
||||
|
|
@ -72,7 +75,7 @@ export interface RunLaunchManifestCompileInput {
|
|||
filesystemSandbox?: RunLaunchFilesystemSandboxEvidence;
|
||||
runToolCatalog?: RunToolCatalog;
|
||||
budgetPolicy: AgentBudgetPolicy;
|
||||
workspaceTrust: RunLaunchWorkspaceTrust;
|
||||
workspaceTrust: WorkspaceExecutionTrustEvaluation;
|
||||
origins: RunLaunchManifestOrigin[];
|
||||
}
|
||||
|
||||
|
|
@ -265,8 +268,39 @@ export class RunLaunchManifestService {
|
|||
sandbox,
|
||||
budget: structuredClone(input.budgetPolicy),
|
||||
workspaceTrust: {
|
||||
...input.workspaceTrust,
|
||||
schemaVersion: input.workspaceTrust.schemaVersion,
|
||||
status: input.workspaceTrust.status,
|
||||
source: sanitizeProviderRuntimeDiagnostic(input.workspaceTrust.source),
|
||||
policyVersion:
|
||||
input.workspaceTrust.decision?.policyVersion ?? WORKSPACE_EXECUTION_TRUST_POLICY_VERSION,
|
||||
identityDigest: input.workspaceTrust.identity.digest,
|
||||
inventoryDigest: input.workspaceTrust.inventory.digest,
|
||||
inventoryEntryCount: input.workspaceTrust.inventory.entries.length,
|
||||
containsExecutableConfiguration: input.workspaceTrust.inventory.entries.some(
|
||||
(entry) => entry.posture === 'executable'
|
||||
),
|
||||
requestedCapabilities: [
|
||||
...new Set(
|
||||
input.workspaceTrust.inventory.entries.flatMap((entry) => entry.requestedCapabilities)
|
||||
),
|
||||
].sort(),
|
||||
...(input.workspaceTrust.decision
|
||||
? {
|
||||
decisionId: input.workspaceTrust.decision.id,
|
||||
decisionMode: input.workspaceTrust.decision.mode,
|
||||
...(input.workspaceTrust.decision.expiresAt
|
||||
? { decisionExpiresAt: input.workspaceTrust.decision.expiresAt }
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
inventory: input.workspaceTrust.inventory.entries.map((entry) => ({
|
||||
id: entry.id,
|
||||
pathDigest: entry.canonicalPathDigest,
|
||||
kind: entry.kind,
|
||||
posture: entry.posture,
|
||||
sourceFingerprint: entry.sourceFingerprint,
|
||||
requestedCapabilities: [...entry.requestedCapabilities].sort(),
|
||||
})),
|
||||
},
|
||||
origins: [...input.origins]
|
||||
.map((origin) => ({
|
||||
|
|
|
|||
430
server/src/services/workspace-execution-trust-service.ts
Normal file
430
server/src/services/workspace-execution-trust-service.ts
Normal file
|
|
@ -0,0 +1,430 @@
|
|||
import { nanoid } from 'nanoid';
|
||||
import {
|
||||
WORKSPACE_EXECUTION_TRUST_DECISION_SCHEMA_VERSION,
|
||||
WORKSPACE_EXECUTION_TRUST_POLICY_VERSION,
|
||||
WORKSPACE_EXECUTION_TRUST_SCHEMA_VERSION,
|
||||
type RunLaunchWorkspaceTrust,
|
||||
type WorkspaceExecutionTrustDecision,
|
||||
type WorkspaceExecutionTrustDecisionInput,
|
||||
type WorkspaceExecutionTrustEvaluation,
|
||||
type WorkspaceExecutionTrustInventory,
|
||||
type WorkspaceExecutionTrustProjectMaximum,
|
||||
type WorkspaceExecutionTrustRestrictionCheck,
|
||||
type WorkspaceExecutionTrustRevokeInput,
|
||||
type WorkspaceExecutionTrustScanResult,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { ConflictError, ValidationError } from '../middleware/error-handler.js';
|
||||
import {
|
||||
workspaceExecutionTrustDecisionInputSchema,
|
||||
workspaceExecutionTrustDecisionSchema,
|
||||
workspaceExecutionTrustEvaluationSchema,
|
||||
workspaceExecutionTrustRevokeInputSchema,
|
||||
} from '../schemas/workspace-execution-trust-schemas.js';
|
||||
import {
|
||||
FileWorkspaceExecutionTrustRepository,
|
||||
type WorkspaceExecutionTrustRepository,
|
||||
} from '../storage/index.js';
|
||||
import { auditLog, type AuditEvent } from './audit-service.js';
|
||||
|
||||
export interface WorkspaceExecutionTrustLaunchConstraints {
|
||||
sandboxMode: 'read-only' | 'workspace-write' | 'danger-full-access';
|
||||
networkAccessEnabled: boolean;
|
||||
taskCredentialReferences: string[];
|
||||
filesystemEnforcement: 'enforced' | 'native' | 'advisory' | 'unavailable';
|
||||
selectedToolServerCount: number;
|
||||
externalMutationAllowed: boolean;
|
||||
projectExecutableConfigurationBlocked: boolean;
|
||||
}
|
||||
|
||||
export interface WorkspaceExecutionTrustServiceOptions {
|
||||
repository?: WorkspaceExecutionTrustRepository;
|
||||
audit?: (event: AuditEvent) => Promise<void>;
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
const TRUST_RANK: Record<WorkspaceExecutionTrustProjectMaximum, number> = {
|
||||
denied: 0,
|
||||
restricted: 1,
|
||||
trusted: 2,
|
||||
};
|
||||
|
||||
export class WorkspaceExecutionTrustService {
|
||||
private readonly repository: WorkspaceExecutionTrustRepository;
|
||||
private readonly audit: (event: AuditEvent) => Promise<void>;
|
||||
private readonly now: () => Date;
|
||||
|
||||
constructor(options: WorkspaceExecutionTrustServiceOptions = {}) {
|
||||
this.repository = options.repository ?? new FileWorkspaceExecutionTrustRepository();
|
||||
this.audit = options.audit ?? auditLog;
|
||||
this.now = options.now ?? (() => new Date());
|
||||
}
|
||||
|
||||
async scan(workspacePath: string): Promise<WorkspaceExecutionTrustScanResult> {
|
||||
const inventory = await this.repository.inspect(workspacePath);
|
||||
const currentDecision = await this.currentDecision(inventory.identity.digest);
|
||||
return {
|
||||
inventory,
|
||||
...(currentDecision ? { currentDecision } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async evaluateForLaunch(input: {
|
||||
workspacePath: string;
|
||||
constraints: WorkspaceExecutionTrustLaunchConstraints;
|
||||
}): Promise<WorkspaceExecutionTrustEvaluation> {
|
||||
const { inventory, currentDecision } = await this.scan(input.workspacePath);
|
||||
const activeEntries = inventory.entries.filter((entry) => entry.posture !== 'declarative-only');
|
||||
const containsExecutable = activeEntries.some((entry) => entry.posture === 'executable');
|
||||
const restrictionChecks = this.restrictionChecks(input.constraints);
|
||||
const restrictionsSatisfied = restrictionChecks.every((check) => check.satisfied);
|
||||
const projectMaximum = inventory.projectPolicy.maximumTrust;
|
||||
|
||||
if (currentDecision?.mode === 'denied') {
|
||||
return this.evaluation({
|
||||
inventory,
|
||||
decision: currentDecision,
|
||||
restrictionChecks,
|
||||
status: 'untrusted',
|
||||
source: 'An active operator distrust decision blocks this workspace.',
|
||||
requiresExplicitDecision: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (activeEntries.length === 0) {
|
||||
if (projectMaximum === 'denied') {
|
||||
return this.evaluation({
|
||||
inventory,
|
||||
decision: currentDecision,
|
||||
restrictionChecks,
|
||||
status: 'untrusted',
|
||||
source:
|
||||
inventory.projectPolicy.diagnostic ??
|
||||
'The project trust policy denies execution in this workspace.',
|
||||
requiresExplicitDecision: false,
|
||||
});
|
||||
}
|
||||
return this.evaluation({
|
||||
inventory,
|
||||
decision: currentDecision,
|
||||
restrictionChecks,
|
||||
status: 'not-required',
|
||||
source:
|
||||
'The current scan found no repository-controlled model or executable configuration. This provisional allow is rescanned before every launch.',
|
||||
requiresExplicitDecision: false,
|
||||
});
|
||||
}
|
||||
|
||||
const decisionIsCurrent =
|
||||
currentDecision &&
|
||||
currentDecision.mode !== 'revoked' &&
|
||||
currentDecision.inventoryDigest === inventory.digest;
|
||||
if (currentDecision && !decisionIsCurrent && currentDecision.mode !== 'revoked') {
|
||||
return this.evaluation({
|
||||
inventory,
|
||||
decision: currentDecision,
|
||||
restrictionChecks,
|
||||
status: 'untrusted',
|
||||
source:
|
||||
'The repository-controlled configuration changed after the recorded trust decision. Review the new inventory and record a new decision.',
|
||||
requiresExplicitDecision: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (decisionIsCurrent && currentDecision && currentDecision.mode !== 'revoked') {
|
||||
const effectiveMaximum =
|
||||
TRUST_RANK[projectMaximum] < TRUST_RANK[currentDecision.mode]
|
||||
? projectMaximum
|
||||
: currentDecision.mode;
|
||||
if (effectiveMaximum === 'denied') {
|
||||
return this.evaluation({
|
||||
inventory,
|
||||
decision: currentDecision,
|
||||
restrictionChecks,
|
||||
status: 'untrusted',
|
||||
source:
|
||||
inventory.projectPolicy.diagnostic ??
|
||||
'The effective project and operator trust policy denies execution.',
|
||||
requiresExplicitDecision: false,
|
||||
});
|
||||
}
|
||||
if (effectiveMaximum === 'trusted') {
|
||||
return this.evaluation({
|
||||
inventory,
|
||||
decision: currentDecision,
|
||||
restrictionChecks,
|
||||
status: 'trusted',
|
||||
source: 'The exact scanned inventory is covered by an active operator trust decision.',
|
||||
requiresExplicitDecision: false,
|
||||
});
|
||||
}
|
||||
if (restrictionsSatisfied) {
|
||||
return this.evaluation({
|
||||
inventory,
|
||||
decision: currentDecision,
|
||||
restrictionChecks,
|
||||
status: 'restricted',
|
||||
source:
|
||||
'The exact scanned inventory is authorized only under the enforced restricted launch profile.',
|
||||
requiresExplicitDecision: false,
|
||||
});
|
||||
}
|
||||
return this.evaluation({
|
||||
inventory,
|
||||
decision: currentDecision,
|
||||
restrictionChecks,
|
||||
status: 'untrusted',
|
||||
source:
|
||||
'The workspace is authorized only in restricted mode, but the selected launch does not enforce every restricted-mode boundary.',
|
||||
requiresExplicitDecision: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (!containsExecutable && projectMaximum !== 'denied' && restrictionsSatisfied) {
|
||||
return this.evaluation({
|
||||
inventory,
|
||||
decision: currentDecision,
|
||||
restrictionChecks,
|
||||
status: 'restricted',
|
||||
source:
|
||||
'Only model-influencing repository instructions were found, and the launch satisfies the provisional restricted profile.',
|
||||
requiresExplicitDecision: false,
|
||||
});
|
||||
}
|
||||
|
||||
return this.evaluation({
|
||||
inventory,
|
||||
decision: currentDecision,
|
||||
restrictionChecks,
|
||||
status: 'untrusted',
|
||||
source: containsExecutable
|
||||
? 'Repository-controlled executable configuration requires an explicit decision before non-interactive launch.'
|
||||
: 'Repository-controlled model instructions require either explicit trust or an enforceable restricted launch profile.',
|
||||
requiresExplicitDecision: containsExecutable,
|
||||
});
|
||||
}
|
||||
|
||||
async recordDecision(
|
||||
workspacePath: string,
|
||||
input: WorkspaceExecutionTrustDecisionInput,
|
||||
actor: string
|
||||
): Promise<WorkspaceExecutionTrustDecision> {
|
||||
const parsed = workspaceExecutionTrustDecisionInputSchema.parse(input);
|
||||
const inventory = await this.repository.inspect(workspacePath);
|
||||
if (inventory.digest !== parsed.inventoryDigest) {
|
||||
throw new ConflictError('Workspace execution configuration changed after it was reviewed.', {
|
||||
expectedInventoryDigest: parsed.inventoryDigest,
|
||||
currentInventoryDigest: inventory.digest,
|
||||
});
|
||||
}
|
||||
if (parsed.mode === 'trusted' && inventory.projectPolicy.maximumTrust !== 'trusted') {
|
||||
throw new ConflictError(
|
||||
'Project policy can narrow workspace trust and does not permit a trusted decision.',
|
||||
{
|
||||
projectMaximumTrust: inventory.projectPolicy.maximumTrust,
|
||||
}
|
||||
);
|
||||
}
|
||||
if (parsed.mode === 'restricted' && inventory.projectPolicy.maximumTrust === 'denied') {
|
||||
throw new ConflictError('Project policy denies execution in this workspace.');
|
||||
}
|
||||
const now = this.now();
|
||||
if (parsed.expiresAt && Date.parse(parsed.expiresAt) <= now.getTime()) {
|
||||
throw new ValidationError('Workspace trust expiry must be in the future.');
|
||||
}
|
||||
const previous = await this.currentDecision(inventory.identity.digest);
|
||||
const decision = workspaceExecutionTrustDecisionSchema.parse({
|
||||
schemaVersion: WORKSPACE_EXECUTION_TRUST_DECISION_SCHEMA_VERSION,
|
||||
id: `workspace_trust_${nanoid(12)}`,
|
||||
identityDigest: inventory.identity.digest,
|
||||
inventoryDigest: inventory.digest,
|
||||
mode: parsed.mode,
|
||||
actor: actor.trim() || 'operator',
|
||||
reason: parsed.reason,
|
||||
policyVersion: WORKSPACE_EXECUTION_TRUST_POLICY_VERSION,
|
||||
createdAt: now.toISOString(),
|
||||
...(parsed.expiresAt ? { expiresAt: parsed.expiresAt } : {}),
|
||||
...(previous ? { supersedesDecisionId: previous.id } : {}),
|
||||
});
|
||||
const saved = await this.repository.appendDecision(decision);
|
||||
await this.audit({
|
||||
action: 'workspace_execution_trust.decision_recorded',
|
||||
actor: saved.actor,
|
||||
resource: saved.identityDigest,
|
||||
details: {
|
||||
decisionId: saved.id,
|
||||
mode: saved.mode,
|
||||
inventoryDigest: saved.inventoryDigest,
|
||||
expiresAt: saved.expiresAt,
|
||||
},
|
||||
});
|
||||
return saved;
|
||||
}
|
||||
|
||||
async revoke(
|
||||
workspacePath: string,
|
||||
input: WorkspaceExecutionTrustRevokeInput,
|
||||
actor: string
|
||||
): Promise<WorkspaceExecutionTrustDecision> {
|
||||
const parsed = workspaceExecutionTrustRevokeInputSchema.parse(input);
|
||||
const inventory = await this.repository.inspect(workspacePath);
|
||||
if (inventory.digest !== parsed.inventoryDigest) {
|
||||
throw new ConflictError(
|
||||
'Workspace execution configuration changed after the revoke request was prepared.',
|
||||
{
|
||||
expectedInventoryDigest: parsed.inventoryDigest,
|
||||
currentInventoryDigest: inventory.digest,
|
||||
}
|
||||
);
|
||||
}
|
||||
const current = await this.currentDecision(inventory.identity.digest);
|
||||
if (!current || current.mode === 'revoked') {
|
||||
throw new ConflictError('No active workspace execution trust decision exists to revoke.');
|
||||
}
|
||||
const decision = workspaceExecutionTrustDecisionSchema.parse({
|
||||
schemaVersion: WORKSPACE_EXECUTION_TRUST_DECISION_SCHEMA_VERSION,
|
||||
id: `workspace_trust_${nanoid(12)}`,
|
||||
identityDigest: inventory.identity.digest,
|
||||
inventoryDigest: inventory.digest,
|
||||
mode: 'revoked',
|
||||
actor: actor.trim() || 'operator',
|
||||
reason: parsed.reason,
|
||||
policyVersion: WORKSPACE_EXECUTION_TRUST_POLICY_VERSION,
|
||||
createdAt: this.now().toISOString(),
|
||||
supersedesDecisionId: current.id,
|
||||
});
|
||||
const saved = await this.repository.appendDecision(decision);
|
||||
await this.audit({
|
||||
action: 'workspace_execution_trust.decision_revoked',
|
||||
actor: saved.actor,
|
||||
resource: saved.identityDigest,
|
||||
details: {
|
||||
decisionId: saved.id,
|
||||
supersedesDecisionId: saved.supersedesDecisionId,
|
||||
inventoryDigest: saved.inventoryDigest,
|
||||
},
|
||||
});
|
||||
return saved;
|
||||
}
|
||||
|
||||
async assertFresh(workspacePath: string, expected: RunLaunchWorkspaceTrust): Promise<void> {
|
||||
if (!('schemaVersion' in expected)) {
|
||||
throw new ConflictError(
|
||||
'Legacy launch evidence does not contain workspace execution trust inventory.'
|
||||
);
|
||||
}
|
||||
const inventory = await this.repository.inspect(workspacePath);
|
||||
if (
|
||||
inventory.identity.digest !== expected.identityDigest ||
|
||||
inventory.digest !== expected.inventoryDigest
|
||||
) {
|
||||
throw new ConflictError(
|
||||
'Workspace execution trust evidence changed before provider activation.',
|
||||
{
|
||||
expectedIdentityDigest: expected.identityDigest,
|
||||
currentIdentityDigest: inventory.identity.digest,
|
||||
expectedInventoryDigest: expected.inventoryDigest,
|
||||
currentInventoryDigest: inventory.digest,
|
||||
}
|
||||
);
|
||||
}
|
||||
const current = await this.currentDecision(inventory.identity.digest);
|
||||
if (current?.id !== expected.decisionId || current?.mode !== expected.decisionMode) {
|
||||
throw new ConflictError(
|
||||
'Workspace execution trust decision changed before provider activation.',
|
||||
{
|
||||
expectedDecisionId: expected.decisionId,
|
||||
currentDecisionId: current?.id,
|
||||
expectedDecisionMode: expected.decisionMode,
|
||||
currentDecisionMode: current?.mode,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private evaluation(input: {
|
||||
inventory: WorkspaceExecutionTrustInventory;
|
||||
decision?: WorkspaceExecutionTrustDecision;
|
||||
restrictionChecks: WorkspaceExecutionTrustRestrictionCheck[];
|
||||
status: WorkspaceExecutionTrustEvaluation['status'];
|
||||
source: string;
|
||||
requiresExplicitDecision: boolean;
|
||||
}): WorkspaceExecutionTrustEvaluation {
|
||||
return workspaceExecutionTrustEvaluationSchema.parse({
|
||||
schemaVersion: WORKSPACE_EXECUTION_TRUST_SCHEMA_VERSION,
|
||||
status: input.status,
|
||||
source: input.source,
|
||||
requiresExplicitDecision: input.requiresExplicitDecision,
|
||||
identity: input.inventory.identity,
|
||||
inventory: input.inventory,
|
||||
...(input.decision ? { decision: input.decision } : {}),
|
||||
restrictionChecks: input.restrictionChecks,
|
||||
});
|
||||
}
|
||||
|
||||
private restrictionChecks(
|
||||
constraints: WorkspaceExecutionTrustLaunchConstraints
|
||||
): WorkspaceExecutionTrustRestrictionCheck[] {
|
||||
return [
|
||||
{
|
||||
id: 'filesystem-read-only',
|
||||
satisfied: constraints.sandboxMode === 'read-only',
|
||||
detail: 'Restricted mode requires a read-only workspace sandbox.',
|
||||
},
|
||||
{
|
||||
id: 'filesystem-enforced',
|
||||
satisfied:
|
||||
constraints.filesystemEnforcement === 'enforced' ||
|
||||
constraints.filesystemEnforcement === 'native',
|
||||
detail:
|
||||
'Restricted mode requires an enforceable host or provider-native filesystem boundary.',
|
||||
},
|
||||
{
|
||||
id: 'network-disabled',
|
||||
satisfied: !constraints.networkAccessEnabled,
|
||||
detail: 'Restricted mode disables provider network access.',
|
||||
},
|
||||
{
|
||||
id: 'task-credentials-blocked',
|
||||
satisfied: constraints.taskCredentialReferences.length === 0,
|
||||
detail: 'Restricted mode exposes no raw task-integration credential references.',
|
||||
},
|
||||
{
|
||||
id: 'project-tool-servers-blocked',
|
||||
satisfied: constraints.selectedToolServerCount === 0,
|
||||
detail: 'Restricted mode loads no project-scoped tool servers.',
|
||||
},
|
||||
{
|
||||
id: 'project-executable-configuration-blocked',
|
||||
satisfied: constraints.projectExecutableConfigurationBlocked,
|
||||
detail:
|
||||
'Restricted mode denies repository-controlled executable configuration to the provider.',
|
||||
},
|
||||
{
|
||||
id: 'external-mutation-blocked',
|
||||
satisfied: !constraints.externalMutationAllowed,
|
||||
detail: 'Restricted mode disables external mutations.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private async currentDecision(
|
||||
identityDigest: string
|
||||
): Promise<WorkspaceExecutionTrustDecision | undefined> {
|
||||
const decisions = await this.repository.listDecisions(identityDigest);
|
||||
const latest = decisions.at(-1);
|
||||
if (!latest || latest.mode === 'revoked') return latest;
|
||||
if (latest.expiresAt && Date.parse(latest.expiresAt) <= this.now().getTime()) return undefined;
|
||||
return latest;
|
||||
}
|
||||
}
|
||||
|
||||
let singleton: WorkspaceExecutionTrustService | null = null;
|
||||
|
||||
export function getWorkspaceExecutionTrustService(): WorkspaceExecutionTrustService {
|
||||
singleton ??= new WorkspaceExecutionTrustService();
|
||||
return singleton;
|
||||
}
|
||||
|
||||
export function resetWorkspaceExecutionTrustServiceForTests(): void {
|
||||
singleton = null;
|
||||
}
|
||||
|
|
@ -29,9 +29,14 @@ export type {
|
|||
ToolControlPlaneRepository,
|
||||
SetupContextRepository,
|
||||
WorkspaceFileRepository,
|
||||
WorkspaceExecutionTrustRepository,
|
||||
StorageProvider,
|
||||
} from './interfaces.js';
|
||||
export { LocalWorkspaceFileRepository } from './workspace-file-repository.js';
|
||||
export {
|
||||
FileWorkspaceExecutionTrustRepository,
|
||||
InMemoryWorkspaceExecutionTrustRepository,
|
||||
} from './workspace-execution-trust-repository.js';
|
||||
export {
|
||||
FileWorktreeManifestRepository,
|
||||
InMemoryWorktreeManifestRepository,
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ import type {
|
|||
RunToolCatalog,
|
||||
ToolServerDefinition,
|
||||
ToolServerDiscovery,
|
||||
WorkspaceExecutionTrustDecision,
|
||||
WorkspaceExecutionTrustInventory,
|
||||
} from '@veritas-kanban/shared';
|
||||
import type { Activity, ActivityType } from '../services/activity-service.js';
|
||||
import type {
|
||||
|
|
@ -318,6 +320,18 @@ export interface WorkspaceFileRepository {
|
|||
readOptionalText(workspaceRoot: string, relativePath: string): Promise<string | null>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workspace Execution Trust Repository
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface WorkspaceExecutionTrustRepository {
|
||||
inspect(workspacePath: string): Promise<WorkspaceExecutionTrustInventory>;
|
||||
listDecisions(identityDigest?: string): Promise<WorkspaceExecutionTrustDecision[]>;
|
||||
appendDecision(
|
||||
decision: WorkspaceExecutionTrustDecision
|
||||
): Promise<WorkspaceExecutionTrustDecision>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Run Event Journal Repository
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
773
server/src/storage/workspace-execution-trust-repository.ts
Normal file
773
server/src/storage/workspace-execution-trust-repository.ts
Normal file
|
|
@ -0,0 +1,773 @@
|
|||
import { execFile } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { lstat, readFile, readlink, readdir, realpath } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
WORKSPACE_EXECUTION_TRUST_INVENTORY_SCHEMA_VERSION,
|
||||
WORKSPACE_EXECUTION_TRUST_SCHEMA_VERSION,
|
||||
type WorkspaceExecutionTrustComponentKind,
|
||||
type WorkspaceExecutionTrustDecision,
|
||||
type WorkspaceExecutionTrustInventory,
|
||||
type WorkspaceExecutionTrustInventoryEntry,
|
||||
type WorkspaceExecutionTrustPosture,
|
||||
type WorkspaceExecutionTrustProjectPolicy,
|
||||
} from '@veritas-kanban/shared';
|
||||
import {
|
||||
workspaceExecutionTrustDecisionSchema,
|
||||
workspaceExecutionTrustInventorySchema,
|
||||
workspaceExecutionTrustProjectFileSchema,
|
||||
} from '../schemas/workspace-execution-trust-schemas.js';
|
||||
import { withFileLock } from '../services/file-lock.js';
|
||||
import { getRuntimeDir } from '../utils/paths.js';
|
||||
import { ensureWithinBase } from '../utils/sanitize.js';
|
||||
import { atomicWriteFile, fileExists, mkdir } from './fs-helpers.js';
|
||||
import type { WorkspaceExecutionTrustRepository } from './interfaces.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const STATE_SCHEMA_VERSION = 'workspace-execution-trust-state/v1' as const;
|
||||
const SCANNER_REVISION = 1;
|
||||
const MAX_FILE_BYTES = 2 * 1024 * 1024;
|
||||
const MAX_INVENTORY_ENTRIES = 2_000;
|
||||
const MAX_DIRECTORY_DEPTH = 12;
|
||||
|
||||
const stateSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(STATE_SCHEMA_VERSION),
|
||||
decisions: z.array(workspaceExecutionTrustDecisionSchema).max(10_000),
|
||||
})
|
||||
.strict();
|
||||
|
||||
interface WorkspaceExecutionTrustState {
|
||||
schemaVersion: typeof STATE_SCHEMA_VERSION;
|
||||
decisions: WorkspaceExecutionTrustDecision[];
|
||||
}
|
||||
|
||||
interface ComponentDescriptor {
|
||||
kind: WorkspaceExecutionTrustComponentKind;
|
||||
posture: WorkspaceExecutionTrustPosture;
|
||||
requestedCapabilities: string[];
|
||||
}
|
||||
|
||||
interface Candidate extends ComponentDescriptor {
|
||||
absolutePath: string;
|
||||
relativePath: string;
|
||||
scope: WorkspaceExecutionTrustInventoryEntry['scope'];
|
||||
}
|
||||
|
||||
const EXACT_COMPONENTS = new Map<string, ComponentDescriptor>([
|
||||
[
|
||||
'AGENTS.md',
|
||||
{
|
||||
kind: 'agent-instruction',
|
||||
posture: 'model-influencing',
|
||||
requestedCapabilities: ['model.instructions'],
|
||||
},
|
||||
],
|
||||
[
|
||||
'CLAUDE.md',
|
||||
{
|
||||
kind: 'provider-instruction',
|
||||
posture: 'model-influencing',
|
||||
requestedCapabilities: ['model.instructions'],
|
||||
},
|
||||
],
|
||||
[
|
||||
'GEMINI.md',
|
||||
{
|
||||
kind: 'provider-instruction',
|
||||
posture: 'model-influencing',
|
||||
requestedCapabilities: ['model.instructions'],
|
||||
},
|
||||
],
|
||||
[
|
||||
'.github/copilot-instructions.md',
|
||||
{
|
||||
kind: 'provider-instruction',
|
||||
posture: 'model-influencing',
|
||||
requestedCapabilities: ['model.instructions'],
|
||||
},
|
||||
],
|
||||
[
|
||||
'.mcp.json',
|
||||
{
|
||||
kind: 'tool-server-configuration',
|
||||
posture: 'executable',
|
||||
requestedCapabilities: ['process.spawn', 'tool.mcp'],
|
||||
},
|
||||
],
|
||||
[
|
||||
'.codex/config.toml',
|
||||
{
|
||||
kind: 'provider-configuration',
|
||||
posture: 'executable',
|
||||
requestedCapabilities: ['provider.override', 'process.spawn', 'tool.mcp'],
|
||||
},
|
||||
],
|
||||
[
|
||||
'.claude/settings.json',
|
||||
{
|
||||
kind: 'provider-configuration',
|
||||
posture: 'executable',
|
||||
requestedCapabilities: ['provider.override', 'process.spawn', 'runtime.hook'],
|
||||
},
|
||||
],
|
||||
[
|
||||
'.claude/settings.local.json',
|
||||
{
|
||||
kind: 'provider-configuration',
|
||||
posture: 'executable',
|
||||
requestedCapabilities: ['provider.override', 'process.spawn', 'runtime.hook'],
|
||||
},
|
||||
],
|
||||
[
|
||||
'.claude/mcp.json',
|
||||
{
|
||||
kind: 'tool-server-configuration',
|
||||
posture: 'executable',
|
||||
requestedCapabilities: ['process.spawn', 'tool.mcp'],
|
||||
},
|
||||
],
|
||||
[
|
||||
'.copilot/mcp-config.json',
|
||||
{
|
||||
kind: 'tool-server-configuration',
|
||||
posture: 'executable',
|
||||
requestedCapabilities: ['process.spawn', 'tool.mcp'],
|
||||
},
|
||||
],
|
||||
[
|
||||
'.github/copilot/mcp.json',
|
||||
{
|
||||
kind: 'tool-server-configuration',
|
||||
posture: 'executable',
|
||||
requestedCapabilities: ['process.spawn', 'tool.mcp'],
|
||||
},
|
||||
],
|
||||
[
|
||||
'.vscode/mcp.json',
|
||||
{
|
||||
kind: 'tool-server-configuration',
|
||||
posture: 'executable',
|
||||
requestedCapabilities: ['process.spawn', 'tool.mcp'],
|
||||
},
|
||||
],
|
||||
[
|
||||
'.vscode/settings.json',
|
||||
{
|
||||
kind: 'language-server-configuration',
|
||||
posture: 'executable',
|
||||
requestedCapabilities: ['language-server.start', 'process.spawn'],
|
||||
},
|
||||
],
|
||||
[
|
||||
'.vscode/tasks.json',
|
||||
{
|
||||
kind: 'workflow-configuration',
|
||||
posture: 'executable',
|
||||
requestedCapabilities: ['process.spawn', 'workflow.execute'],
|
||||
},
|
||||
],
|
||||
[
|
||||
'.vscode/extensions.json',
|
||||
{
|
||||
kind: 'extension-configuration',
|
||||
posture: 'executable',
|
||||
requestedCapabilities: ['extension.install', 'extension.load'],
|
||||
},
|
||||
],
|
||||
[
|
||||
'.devcontainer/devcontainer.json',
|
||||
{
|
||||
kind: 'extension-configuration',
|
||||
posture: 'executable',
|
||||
requestedCapabilities: ['container.start', 'process.spawn'],
|
||||
},
|
||||
],
|
||||
[
|
||||
'.envrc',
|
||||
{
|
||||
kind: 'runtime-hook',
|
||||
posture: 'executable',
|
||||
requestedCapabilities: ['environment.mutate', 'process.spawn'],
|
||||
},
|
||||
],
|
||||
[
|
||||
'.veritas-kanban/workspace-trust.json',
|
||||
{
|
||||
kind: 'project-trust-policy',
|
||||
posture: 'declarative-only',
|
||||
requestedCapabilities: ['policy.narrow'],
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
function emptyState(): WorkspaceExecutionTrustState {
|
||||
return { schemaVersion: STATE_SCHEMA_VERSION, decisions: [] };
|
||||
}
|
||||
|
||||
function sha256(value: string | Buffer): string {
|
||||
return `sha256:${createHash('sha256').update(value).digest('hex')}`;
|
||||
}
|
||||
|
||||
function fingerprintRemoteIdentity(remote: string): string {
|
||||
const withoutCredentials = remote
|
||||
.trim()
|
||||
.replace(/([a-z][a-z0-9+.-]*:\/\/)[^/@\s]+@/gi, '$1')
|
||||
.replace(/\/\/[^/@\s]+@/g, '//')
|
||||
.replace(/([?&](?:access_token|api_key|token|key|secret|password)=)[^&#\s]+/gi, '$1[redacted]');
|
||||
return sha256(withoutCredentials);
|
||||
}
|
||||
|
||||
function normalizedRelativePath(root: string, target: string): string {
|
||||
return path.relative(root, target).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function descriptorForRecursivePath(relativePath: string): ComponentDescriptor | null {
|
||||
if (/^\.github\/instructions\/.+\.instructions\.md$/i.test(relativePath)) {
|
||||
return {
|
||||
kind: 'provider-instruction',
|
||||
posture: 'model-influencing',
|
||||
requestedCapabilities: ['model.instructions'],
|
||||
};
|
||||
}
|
||||
if (/^\.github\/workflows\/.+\.(?:ya?ml)$/i.test(relativePath)) {
|
||||
return {
|
||||
kind: 'workflow-configuration',
|
||||
posture: 'executable',
|
||||
requestedCapabilities: ['external.mutation', 'workflow.execute'],
|
||||
};
|
||||
}
|
||||
if (/^\.cursor\/rules\/.+\.mdc$/i.test(relativePath)) {
|
||||
return {
|
||||
kind: 'provider-instruction',
|
||||
posture: 'model-influencing',
|
||||
requestedCapabilities: ['model.instructions'],
|
||||
};
|
||||
}
|
||||
if (/^\.claude\/agents\/.+\.(?:md|json)$/i.test(relativePath)) {
|
||||
return {
|
||||
kind: 'agent-definition',
|
||||
posture: 'model-influencing',
|
||||
requestedCapabilities: ['agent.load', 'model.instructions'],
|
||||
};
|
||||
}
|
||||
if (/^\.claude\/commands\/.+\.md$/i.test(relativePath)) {
|
||||
return {
|
||||
kind: 'provider-instruction',
|
||||
posture: 'model-influencing',
|
||||
requestedCapabilities: ['model.instructions', 'workflow.execute'],
|
||||
};
|
||||
}
|
||||
if (/^\.claude\/skills\/.+\/SKILL\.md$/i.test(relativePath)) {
|
||||
return {
|
||||
kind: 'skill-definition',
|
||||
posture: 'model-influencing',
|
||||
requestedCapabilities: ['model.instructions', 'skill.load'],
|
||||
};
|
||||
}
|
||||
if (/^\.claude\/hooks\/.+/i.test(relativePath)) {
|
||||
return {
|
||||
kind: 'runtime-hook',
|
||||
posture: 'executable',
|
||||
requestedCapabilities: ['process.spawn', 'runtime.hook'],
|
||||
};
|
||||
}
|
||||
if (/^\.codex\/skills\/.+\/SKILL\.md$/i.test(relativePath)) {
|
||||
return {
|
||||
kind: 'skill-definition',
|
||||
posture: 'model-influencing',
|
||||
requestedCapabilities: ['model.instructions', 'skill.load'],
|
||||
};
|
||||
}
|
||||
if (/^\.codex\/rules\/.+\.rules$/i.test(relativePath)) {
|
||||
return {
|
||||
kind: 'provider-configuration',
|
||||
posture: 'executable',
|
||||
requestedCapabilities: ['command.policy', 'provider.override'],
|
||||
};
|
||||
}
|
||||
if (/^\.(?:agent|agents|buzz|grok-build)\/.+\.md$/i.test(relativePath)) {
|
||||
return {
|
||||
kind: 'agent-definition',
|
||||
posture: 'model-influencing',
|
||||
requestedCapabilities: ['agent.load', 'model.instructions'],
|
||||
};
|
||||
}
|
||||
if (/^\.(?:agent|agents|buzz|grok-build)\/.+\.(?:json|toml|ya?ml)$/i.test(relativePath)) {
|
||||
return {
|
||||
kind: 'provider-configuration',
|
||||
posture: 'executable',
|
||||
requestedCapabilities: ['process.spawn', 'provider.override'],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function gitValue(root: string, args: string[]): Promise<string> {
|
||||
try {
|
||||
const result = await execFileAsync('git', ['-C', root, ...args], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 1024 * 1024,
|
||||
timeout: 10_000,
|
||||
});
|
||||
return result.stdout.trim();
|
||||
} catch (error) {
|
||||
const diagnostic = error instanceof Error ? error.message : 'git command failed';
|
||||
throw new Error(`Workspace execution trust requires a valid Git worktree: ${diagnostic}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function readBoundedFingerprint(filePath: string, size: number): Promise<string> {
|
||||
if (size > MAX_FILE_BYTES) {
|
||||
throw new Error(
|
||||
`Workspace execution configuration exceeds the ${MAX_FILE_BYTES}-byte scan limit.`
|
||||
);
|
||||
}
|
||||
return sha256(await readFile(filePath));
|
||||
}
|
||||
|
||||
async function inventoryEntry(
|
||||
root: string,
|
||||
candidate: Candidate
|
||||
): Promise<WorkspaceExecutionTrustInventoryEntry | null> {
|
||||
let fileStat;
|
||||
try {
|
||||
fileStat = await lstat(candidate.absolutePath);
|
||||
} catch (error) {
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
(error.code === 'ENOENT' || error.code === 'ENOTDIR')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!fileStat.isFile() && !fileStat.isSymbolicLink()) return null;
|
||||
const symbolicLink = fileStat.isSymbolicLink();
|
||||
let sourceFingerprint: string;
|
||||
const canonicalPathDigest = sha256(
|
||||
JSON.stringify({
|
||||
scope: candidate.scope,
|
||||
relativePath: candidate.relativePath,
|
||||
})
|
||||
);
|
||||
let requestedCapabilities = [...candidate.requestedCapabilities];
|
||||
let posture = candidate.posture;
|
||||
let kind = candidate.kind;
|
||||
|
||||
if (symbolicLink) {
|
||||
sourceFingerprint = sha256(await readlink(candidate.absolutePath));
|
||||
requestedCapabilities = [...new Set([...requestedCapabilities, 'filesystem.external-read'])];
|
||||
posture = 'executable';
|
||||
kind = kind === 'project-trust-policy' ? 'unknown-executable' : kind;
|
||||
} else {
|
||||
const canonicalPath = await realpath(candidate.absolutePath);
|
||||
ensureWithinBase(root, canonicalPath);
|
||||
sourceFingerprint = await readBoundedFingerprint(canonicalPath, fileStat.size);
|
||||
}
|
||||
|
||||
const material = {
|
||||
relativePath: candidate.relativePath,
|
||||
sourceFingerprint,
|
||||
canonicalPathDigest,
|
||||
kind,
|
||||
posture,
|
||||
symbolicLink,
|
||||
};
|
||||
return {
|
||||
id: `workspace_component_${sha256(JSON.stringify(material)).slice('sha256:'.length, 25)}`,
|
||||
relativePath: candidate.relativePath,
|
||||
canonicalPathDigest,
|
||||
scope: candidate.scope,
|
||||
kind,
|
||||
posture,
|
||||
sourceFingerprint,
|
||||
byteLength: symbolicLink
|
||||
? Buffer.byteLength(await readlink(candidate.absolutePath))
|
||||
: fileStat.size,
|
||||
symbolicLink,
|
||||
requestedCapabilities: requestedCapabilities.sort(),
|
||||
};
|
||||
}
|
||||
|
||||
async function recursiveCandidates(root: string, startRelativePath: string): Promise<Candidate[]> {
|
||||
const start = path.resolve(root, startRelativePath);
|
||||
ensureWithinBase(root, start);
|
||||
const candidates: Candidate[] = [];
|
||||
|
||||
async function visit(directory: string, depth: number): Promise<void> {
|
||||
if (depth > MAX_DIRECTORY_DEPTH) {
|
||||
throw new Error('Workspace execution configuration exceeds the supported directory depth.');
|
||||
}
|
||||
let directoryStat;
|
||||
try {
|
||||
directoryStat = await lstat(directory);
|
||||
} catch (error) {
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
(error.code === 'ENOENT' || error.code === 'ENOTDIR')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (directoryStat.isSymbolicLink()) {
|
||||
candidates.push({
|
||||
absolutePath: directory,
|
||||
relativePath: normalizedRelativePath(root, directory),
|
||||
scope: 'workspace-descendant',
|
||||
kind: 'unknown-executable',
|
||||
posture: 'executable',
|
||||
requestedCapabilities: ['filesystem.external-read', 'process.spawn'],
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!directoryStat.isDirectory()) return;
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(directory, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
(error.code === 'ENOENT' || error.code === 'ENOTDIR')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
const absolutePath = path.join(directory, entry.name);
|
||||
const relativePath = normalizedRelativePath(root, absolutePath);
|
||||
const descriptor = descriptorForRecursivePath(relativePath);
|
||||
if (entry.isSymbolicLink()) {
|
||||
candidates.push({
|
||||
...(descriptor ?? {
|
||||
kind: 'unknown-executable',
|
||||
posture: 'executable',
|
||||
requestedCapabilities: ['filesystem.external-read', 'process.spawn'],
|
||||
}),
|
||||
absolutePath,
|
||||
relativePath,
|
||||
scope: 'workspace-descendant',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
await visit(absolutePath, depth + 1);
|
||||
continue;
|
||||
}
|
||||
if (!descriptor) continue;
|
||||
candidates.push({
|
||||
...descriptor,
|
||||
absolutePath,
|
||||
relativePath,
|
||||
scope: 'workspace-descendant',
|
||||
});
|
||||
if (candidates.length > MAX_INVENTORY_ENTRIES) {
|
||||
throw new Error('Workspace execution configuration inventory exceeds the bounded limit.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await visit(start, 0);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
async function gitHookCandidates(root: string): Promise<Candidate[]> {
|
||||
const hooksPath = await gitValue(root, ['config', '--local', '--get', 'core.hooksPath']).catch(
|
||||
() => ''
|
||||
);
|
||||
if (!hooksPath) return [];
|
||||
const resolved = path.isAbsolute(hooksPath)
|
||||
? path.resolve(hooksPath)
|
||||
: path.resolve(root, hooksPath);
|
||||
if (!resolved.startsWith(`${root}${path.sep}`) && resolved !== root) {
|
||||
return [
|
||||
{
|
||||
absolutePath: resolved,
|
||||
relativePath: 'git:core.hooksPath',
|
||||
scope: 'git-common-directory',
|
||||
kind: 'runtime-hook',
|
||||
posture: 'executable',
|
||||
requestedCapabilities: ['filesystem.external-read', 'process.spawn', 'runtime.hook'],
|
||||
},
|
||||
];
|
||||
}
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(resolved, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') return [];
|
||||
throw error;
|
||||
}
|
||||
return entries
|
||||
.filter((entry) => entry.isFile() || entry.isSymbolicLink())
|
||||
.map((entry) => {
|
||||
const absolutePath = path.join(resolved, entry.name);
|
||||
return {
|
||||
absolutePath,
|
||||
relativePath: normalizedRelativePath(root, absolutePath),
|
||||
scope: 'workspace-descendant' as const,
|
||||
kind: 'runtime-hook' as const,
|
||||
posture: 'executable' as const,
|
||||
requestedCapabilities: ['process.spawn', 'runtime.hook'],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function inspectWorkspace(workspacePath: string): Promise<WorkspaceExecutionTrustInventory> {
|
||||
const canonicalWorkspacePath = await realpath(workspacePath);
|
||||
const repositoryRootRaw = await gitValue(canonicalWorkspacePath, [
|
||||
'rev-parse',
|
||||
'--show-toplevel',
|
||||
]);
|
||||
const canonicalRepositoryRoot = await realpath(repositoryRootRaw);
|
||||
ensureWithinBase(canonicalRepositoryRoot, canonicalWorkspacePath);
|
||||
if (canonicalWorkspacePath !== canonicalRepositoryRoot) {
|
||||
throw new Error(
|
||||
'Workspace execution trust requires the registered Git worktree root, not a nested path.'
|
||||
);
|
||||
}
|
||||
const commonDirectoryRaw = await gitValue(canonicalRepositoryRoot, [
|
||||
'rev-parse',
|
||||
'--git-common-dir',
|
||||
]);
|
||||
const canonicalCommonDirectory = await realpath(
|
||||
path.isAbsolute(commonDirectoryRaw)
|
||||
? commonDirectoryRaw
|
||||
: path.resolve(canonicalRepositoryRoot, commonDirectoryRaw)
|
||||
);
|
||||
const remoteIdentity = await gitValue(canonicalRepositoryRoot, [
|
||||
'config',
|
||||
'--get',
|
||||
'remote.origin.url',
|
||||
]).catch(() => 'no-origin-remote');
|
||||
const [workspaceStat, rootStat, commonStat] = await Promise.all([
|
||||
lstat(canonicalWorkspacePath),
|
||||
lstat(canonicalRepositoryRoot),
|
||||
lstat(canonicalCommonDirectory),
|
||||
]);
|
||||
if (!workspaceStat.isDirectory() || !rootStat.isDirectory() || !commonStat.isDirectory()) {
|
||||
throw new Error('Workspace execution trust identity requires regular directories.');
|
||||
}
|
||||
const identityMaterial = {
|
||||
workspaceDevice: String(workspaceStat.dev),
|
||||
workspaceInode: String(workspaceStat.ino),
|
||||
repositoryDevice: String(rootStat.dev),
|
||||
repositoryInode: String(rootStat.ino),
|
||||
commonDevice: String(commonStat.dev),
|
||||
commonInode: String(commonStat.ino),
|
||||
remoteIdentityDigest: fingerprintRemoteIdentity(remoteIdentity),
|
||||
};
|
||||
const identity = {
|
||||
schemaVersion: WORKSPACE_EXECUTION_TRUST_SCHEMA_VERSION,
|
||||
digest: sha256(JSON.stringify(identityMaterial)),
|
||||
canonicalWorkspacePathDigest: sha256(canonicalWorkspacePath),
|
||||
canonicalRepositoryRootDigest: sha256(canonicalRepositoryRoot),
|
||||
gitCommonDirectoryDigest: sha256(canonicalCommonDirectory),
|
||||
remoteIdentityDigest: identityMaterial.remoteIdentityDigest,
|
||||
} as const;
|
||||
|
||||
const candidates: Candidate[] = [...EXACT_COMPONENTS.entries()].map(
|
||||
([relativePath, descriptor]) => ({
|
||||
...descriptor,
|
||||
absolutePath: path.resolve(canonicalWorkspacePath, relativePath),
|
||||
relativePath,
|
||||
scope: 'workspace-root',
|
||||
})
|
||||
);
|
||||
for (const directory of [
|
||||
'.github/instructions',
|
||||
'.github/workflows',
|
||||
'.cursor/rules',
|
||||
'.claude/agents',
|
||||
'.claude/commands',
|
||||
'.claude/skills',
|
||||
'.claude/hooks',
|
||||
'.codex/skills',
|
||||
'.codex/rules',
|
||||
'.agent',
|
||||
'.agents',
|
||||
'.buzz',
|
||||
'.grok-build',
|
||||
]) {
|
||||
candidates.push(...(await recursiveCandidates(canonicalWorkspacePath, directory)));
|
||||
}
|
||||
candidates.push(...(await gitHookCandidates(canonicalWorkspacePath)));
|
||||
|
||||
const inventory = new Map<string, WorkspaceExecutionTrustInventoryEntry>();
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.relativePath === 'git:core.hooksPath') {
|
||||
const sourceFingerprint = sha256(candidate.absolutePath);
|
||||
inventory.set(candidate.relativePath, {
|
||||
id: `workspace_component_${sourceFingerprint.slice('sha256:'.length, 25)}`,
|
||||
relativePath: candidate.relativePath,
|
||||
canonicalPathDigest: sourceFingerprint,
|
||||
scope: candidate.scope,
|
||||
kind: candidate.kind,
|
||||
posture: candidate.posture,
|
||||
sourceFingerprint,
|
||||
byteLength: Buffer.byteLength(candidate.absolutePath),
|
||||
symbolicLink: false,
|
||||
requestedCapabilities: [...candidate.requestedCapabilities].sort(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const entry = await inventoryEntry(canonicalWorkspacePath, candidate);
|
||||
if (entry) inventory.set(entry.relativePath, entry);
|
||||
if (inventory.size > MAX_INVENTORY_ENTRIES) {
|
||||
throw new Error('Workspace execution configuration inventory exceeds the bounded limit.');
|
||||
}
|
||||
}
|
||||
const entries = [...inventory.values()].sort((left, right) =>
|
||||
left.relativePath.localeCompare(right.relativePath)
|
||||
);
|
||||
|
||||
let projectPolicy: WorkspaceExecutionTrustProjectPolicy = {
|
||||
maximumTrust: 'trusted',
|
||||
valid: true,
|
||||
};
|
||||
const policyEntry = entries.find(
|
||||
(entry) => entry.relativePath === '.veritas-kanban/workspace-trust.json'
|
||||
);
|
||||
if (policyEntry) {
|
||||
if (policyEntry.symbolicLink) {
|
||||
projectPolicy = {
|
||||
maximumTrust: 'denied',
|
||||
sourceFingerprint: policyEntry.sourceFingerprint,
|
||||
valid: false,
|
||||
diagnostic: 'The project trust policy must be a regular file inside the workspace.',
|
||||
};
|
||||
} else {
|
||||
try {
|
||||
const policy = workspaceExecutionTrustProjectFileSchema.parse(
|
||||
JSON.parse(
|
||||
await readFile(
|
||||
path.resolve(canonicalWorkspacePath, '.veritas-kanban/workspace-trust.json'),
|
||||
'utf8'
|
||||
)
|
||||
)
|
||||
);
|
||||
projectPolicy = {
|
||||
maximumTrust: policy.maximumTrust,
|
||||
sourceFingerprint: policyEntry.sourceFingerprint,
|
||||
valid: true,
|
||||
};
|
||||
} catch {
|
||||
projectPolicy = {
|
||||
maximumTrust: 'denied',
|
||||
sourceFingerprint: policyEntry.sourceFingerprint,
|
||||
valid: false,
|
||||
diagnostic: 'The project trust policy is invalid and therefore narrows trust to denied.',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const material = {
|
||||
scannerRevision: SCANNER_REVISION,
|
||||
identityDigest: identity.digest,
|
||||
entries: entries.map((entry) => ({
|
||||
relativePath: entry.relativePath,
|
||||
canonicalPathDigest: entry.canonicalPathDigest,
|
||||
kind: entry.kind,
|
||||
posture: entry.posture,
|
||||
sourceFingerprint: entry.sourceFingerprint,
|
||||
byteLength: entry.byteLength,
|
||||
symbolicLink: entry.symbolicLink,
|
||||
requestedCapabilities: entry.requestedCapabilities,
|
||||
})),
|
||||
projectPolicy,
|
||||
};
|
||||
return workspaceExecutionTrustInventorySchema.parse({
|
||||
schemaVersion: WORKSPACE_EXECUTION_TRUST_INVENTORY_SCHEMA_VERSION,
|
||||
digest: sha256(JSON.stringify(material)),
|
||||
scannerRevision: SCANNER_REVISION,
|
||||
scannedAt: new Date().toISOString(),
|
||||
identity,
|
||||
entries,
|
||||
projectPolicy,
|
||||
});
|
||||
}
|
||||
|
||||
export class InMemoryWorkspaceExecutionTrustRepository implements WorkspaceExecutionTrustRepository {
|
||||
private decisions: WorkspaceExecutionTrustDecision[] = [];
|
||||
|
||||
inspect(workspacePath: string): Promise<WorkspaceExecutionTrustInventory> {
|
||||
return inspectWorkspace(workspacePath);
|
||||
}
|
||||
|
||||
async listDecisions(identityDigest?: string): Promise<WorkspaceExecutionTrustDecision[]> {
|
||||
return structuredClone(
|
||||
this.decisions.filter(
|
||||
(decision) => !identityDigest || decision.identityDigest === identityDigest
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async appendDecision(
|
||||
decision: WorkspaceExecutionTrustDecision
|
||||
): Promise<WorkspaceExecutionTrustDecision> {
|
||||
const parsed = workspaceExecutionTrustDecisionSchema.parse(decision);
|
||||
if (this.decisions.some((entry) => entry.id === parsed.id)) {
|
||||
throw new Error('Workspace execution trust decision ID already exists.');
|
||||
}
|
||||
this.decisions.push(parsed);
|
||||
return structuredClone(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
export class FileWorkspaceExecutionTrustRepository implements WorkspaceExecutionTrustRepository {
|
||||
private readonly statePath: string;
|
||||
|
||||
constructor(statePath = path.join(getRuntimeDir(), 'workspace-execution-trust', 'state.json')) {
|
||||
this.statePath = statePath;
|
||||
ensureWithinBase(path.dirname(statePath), statePath);
|
||||
}
|
||||
|
||||
inspect(workspacePath: string): Promise<WorkspaceExecutionTrustInventory> {
|
||||
return inspectWorkspace(workspacePath);
|
||||
}
|
||||
|
||||
async listDecisions(identityDigest?: string): Promise<WorkspaceExecutionTrustDecision[]> {
|
||||
const state = await this.read();
|
||||
return structuredClone(
|
||||
state.decisions.filter(
|
||||
(decision) => !identityDigest || decision.identityDigest === identityDigest
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async appendDecision(
|
||||
decision: WorkspaceExecutionTrustDecision
|
||||
): Promise<WorkspaceExecutionTrustDecision> {
|
||||
const parsed = workspaceExecutionTrustDecisionSchema.parse(decision);
|
||||
await mkdir(path.dirname(this.statePath), { recursive: true });
|
||||
return withFileLock(this.statePath, async () => {
|
||||
const state = await this.read();
|
||||
if (state.decisions.some((entry) => entry.id === parsed.id)) {
|
||||
throw new Error('Workspace execution trust decision ID already exists.');
|
||||
}
|
||||
state.decisions.push(parsed);
|
||||
const normalized = stateSchema.parse(state);
|
||||
await atomicWriteFile(this.statePath, `${JSON.stringify(normalized, null, 2)}\n`);
|
||||
return structuredClone(parsed);
|
||||
});
|
||||
}
|
||||
|
||||
private async read(): Promise<WorkspaceExecutionTrustState> {
|
||||
if (!(await fileExists(this.statePath))) return emptyState();
|
||||
return stateSchema.parse(JSON.parse(await readFile(this.statePath, 'utf8')));
|
||||
}
|
||||
}
|
||||
|
|
@ -64,3 +64,4 @@ export * from './run-approval.types.js';
|
|||
export * from './conversation-lifecycle.types.js';
|
||||
export * from './tool-control-plane.types.js';
|
||||
export * from './acp.types.js';
|
||||
export * from './workspace-execution-trust.types.js';
|
||||
|
|
|
|||
|
|
@ -228,11 +228,37 @@ export interface RunLaunchSandbox {
|
|||
filesystem?: RunLaunchFilesystemSandboxEvidence;
|
||||
}
|
||||
|
||||
export interface RunLaunchWorkspaceTrust {
|
||||
export interface RunLaunchWorkspaceTrustEvidence {
|
||||
schemaVersion: import('./workspace-execution-trust.types.js').WorkspaceExecutionTrustEvaluation['schemaVersion'];
|
||||
status: import('./workspace-execution-trust.types.js').WorkspaceExecutionTrustStatus;
|
||||
source: string;
|
||||
policyVersion: number;
|
||||
identityDigest: string;
|
||||
inventoryDigest: string;
|
||||
inventoryEntryCount: number;
|
||||
containsExecutableConfiguration: boolean;
|
||||
requestedCapabilities: string[];
|
||||
decisionId?: string;
|
||||
decisionMode?: import('./workspace-execution-trust.types.js').WorkspaceExecutionTrustDecisionMode;
|
||||
decisionExpiresAt?: string;
|
||||
inventory: Array<{
|
||||
id: string;
|
||||
pathDigest: string;
|
||||
kind: import('./workspace-execution-trust.types.js').WorkspaceExecutionTrustComponentKind;
|
||||
posture: import('./workspace-execution-trust.types.js').WorkspaceExecutionTrustPosture;
|
||||
sourceFingerprint: string;
|
||||
requestedCapabilities: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LegacyRunLaunchWorkspaceTrust {
|
||||
status: 'trusted' | 'untrusted' | 'not-required';
|
||||
source: string;
|
||||
}
|
||||
|
||||
export type RunLaunchWorkspaceTrust =
|
||||
RunLaunchWorkspaceTrustEvidence | LegacyRunLaunchWorkspaceTrust;
|
||||
|
||||
export interface RunLaunchManifestOrigin {
|
||||
field: string;
|
||||
scope: RunLaunchManifestOriginScope;
|
||||
|
|
|
|||
119
shared/src/types/workspace-execution-trust.types.ts
Normal file
119
shared/src/types/workspace-execution-trust.types.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
export const WORKSPACE_EXECUTION_TRUST_SCHEMA_VERSION = 'workspace-execution-trust/v1' as const;
|
||||
export const WORKSPACE_EXECUTION_TRUST_INVENTORY_SCHEMA_VERSION =
|
||||
'workspace-execution-trust-inventory/v1' as const;
|
||||
export const WORKSPACE_EXECUTION_TRUST_DECISION_SCHEMA_VERSION =
|
||||
'workspace-execution-trust-decision/v1' as const;
|
||||
export const WORKSPACE_EXECUTION_TRUST_POLICY_VERSION = 1 as const;
|
||||
|
||||
export type WorkspaceExecutionTrustPosture =
|
||||
'declarative-only' | 'model-influencing' | 'executable';
|
||||
|
||||
export type WorkspaceExecutionTrustComponentKind =
|
||||
| 'agent-instruction'
|
||||
| 'provider-instruction'
|
||||
| 'provider-configuration'
|
||||
| 'tool-server-configuration'
|
||||
| 'runtime-hook'
|
||||
| 'language-server-configuration'
|
||||
| 'workflow-configuration'
|
||||
| 'extension-configuration'
|
||||
| 'agent-definition'
|
||||
| 'skill-definition'
|
||||
| 'project-trust-policy'
|
||||
| 'unknown-executable';
|
||||
|
||||
export type WorkspaceExecutionTrustComponentScope =
|
||||
'workspace-root' | 'workspace-descendant' | 'git-common-directory';
|
||||
|
||||
export type WorkspaceExecutionTrustDecisionMode = 'trusted' | 'restricted' | 'denied' | 'revoked';
|
||||
|
||||
export type WorkspaceExecutionTrustStatus = 'trusted' | 'restricted' | 'untrusted' | 'not-required';
|
||||
|
||||
export type WorkspaceExecutionTrustProjectMaximum = 'trusted' | 'restricted' | 'denied';
|
||||
|
||||
export interface WorkspaceExecutionTrustIdentity {
|
||||
schemaVersion: typeof WORKSPACE_EXECUTION_TRUST_SCHEMA_VERSION;
|
||||
digest: string;
|
||||
canonicalWorkspacePathDigest: string;
|
||||
canonicalRepositoryRootDigest: string;
|
||||
gitCommonDirectoryDigest: string;
|
||||
remoteIdentityDigest: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceExecutionTrustInventoryEntry {
|
||||
id: string;
|
||||
relativePath: string;
|
||||
canonicalPathDigest: string;
|
||||
scope: WorkspaceExecutionTrustComponentScope;
|
||||
kind: WorkspaceExecutionTrustComponentKind;
|
||||
posture: WorkspaceExecutionTrustPosture;
|
||||
sourceFingerprint: string;
|
||||
byteLength: number;
|
||||
symbolicLink: boolean;
|
||||
requestedCapabilities: string[];
|
||||
}
|
||||
|
||||
export interface WorkspaceExecutionTrustProjectPolicy {
|
||||
maximumTrust: WorkspaceExecutionTrustProjectMaximum;
|
||||
sourceFingerprint?: string;
|
||||
valid: boolean;
|
||||
diagnostic?: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceExecutionTrustInventory {
|
||||
schemaVersion: typeof WORKSPACE_EXECUTION_TRUST_INVENTORY_SCHEMA_VERSION;
|
||||
digest: string;
|
||||
scannerRevision: number;
|
||||
scannedAt: string;
|
||||
identity: WorkspaceExecutionTrustIdentity;
|
||||
entries: WorkspaceExecutionTrustInventoryEntry[];
|
||||
projectPolicy: WorkspaceExecutionTrustProjectPolicy;
|
||||
}
|
||||
|
||||
export interface WorkspaceExecutionTrustDecision {
|
||||
schemaVersion: typeof WORKSPACE_EXECUTION_TRUST_DECISION_SCHEMA_VERSION;
|
||||
id: string;
|
||||
identityDigest: string;
|
||||
inventoryDigest: string;
|
||||
mode: WorkspaceExecutionTrustDecisionMode;
|
||||
actor: string;
|
||||
reason: string;
|
||||
policyVersion: typeof WORKSPACE_EXECUTION_TRUST_POLICY_VERSION;
|
||||
createdAt: string;
|
||||
expiresAt?: string;
|
||||
supersedesDecisionId?: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceExecutionTrustRestrictionCheck {
|
||||
id: string;
|
||||
satisfied: boolean;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceExecutionTrustEvaluation {
|
||||
schemaVersion: typeof WORKSPACE_EXECUTION_TRUST_SCHEMA_VERSION;
|
||||
status: WorkspaceExecutionTrustStatus;
|
||||
source: string;
|
||||
requiresExplicitDecision: boolean;
|
||||
identity: WorkspaceExecutionTrustIdentity;
|
||||
inventory: WorkspaceExecutionTrustInventory;
|
||||
decision?: WorkspaceExecutionTrustDecision;
|
||||
restrictionChecks: WorkspaceExecutionTrustRestrictionCheck[];
|
||||
}
|
||||
|
||||
export interface WorkspaceExecutionTrustScanResult {
|
||||
inventory: WorkspaceExecutionTrustInventory;
|
||||
currentDecision?: WorkspaceExecutionTrustDecision;
|
||||
}
|
||||
|
||||
export interface WorkspaceExecutionTrustDecisionInput {
|
||||
inventoryDigest: string;
|
||||
mode: Exclude<WorkspaceExecutionTrustDecisionMode, 'revoked'>;
|
||||
reason: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceExecutionTrustRevokeInput {
|
||||
inventoryDigest: string;
|
||||
reason: string;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue