feat: gate agent launches on workspace trust (#1031)

This commit is contained in:
Brad Groux 2026-07-25 00:48:28 -05:00 committed by GitHub
parent a7a59ae494
commit 4aba9229c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 3003 additions and 101 deletions

View file

@ -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.**

View file

@ -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

View file

@ -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',
}),
});
});
});

View file

@ -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');

View file

@ -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

View file

@ -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.

View file

@ -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,

View file

@ -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

View 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.

View file

@ -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

View file

@ -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",

View file

@ -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 }) => ({

View file

@ -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 = {

View file

@ -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();

View file

@ -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', {

View 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'
);
});
});

View file

@ -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',

View file

@ -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');

View file

@ -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

View 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();

View file

@ -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,
});
}

View file

@ -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) => ({

View 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;
}

View file

@ -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,

View file

@ -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
// ---------------------------------------------------------------------------

View 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')));
}
}

View file

@ -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';

View file

@ -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;

View 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;
}