feat: enforce active phase authority

This commit is contained in:
Brad Groux 2026-07-25 03:24:29 -05:00
parent 4f8e5dc14f
commit c59a400406
35 changed files with 1587 additions and 111 deletions

View file

@ -199,8 +199,11 @@ Do not run `npm install`, `yarn`, or `bun install`. If lockfile conflicts arise,
control, and provider handoff must bind the effective phase before attempt
mutation. Descendants inherit and intersect the exact parent launch or
transition evidence and cannot widen it. Explicit phases fail closed when any
required dimension is not enforceable. Tool-command and external-action
enforcement remain the separate #1033 delivery boundary.
required dimension is not enforceable. Run tool catalogs are filtered by the
launch phase, mediated calls re-check the active phase, and approvals bind the
exact phase evidence and transition sequence. ACP stdio is the only current
adapter with enforceable command and external-action mediation; other
adapters return typed blockers for explicit phases.
- Credential-bound tool servers persist only exact definition/scope digests and
safe target names in `run-tool-catalog/v1`. Discovery strips their source
environment/header values, native provider injection omits them, and

View file

@ -9,6 +9,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Enforced active phase authority across run tool discovery, mediated
invocation, approvals, completion evidence, REST, CLI, and the task run
timeline. MCP `readOnlyHint` annotations now classify external reads while
unannotated tools fail closed as mutations; disallowed tools and credentials
stay out of launch catalogs, and hidden or stale calls are rejected against
the current transition evidence before dispatch. Approval decisions cannot
outlive or widen their bound phase, and completion records identify the
effective phase plus every authority-expanding transition. Redacted support
bundles include bounded phase identities, authority counts, source kinds,
transition expansions, and completion bindings without exporting exact
paths, credentials, or full digests. ACP stdio exposes the required
pre-execution mediation; adapters without equivalent command and
external-action controls return typed blockers for explicit phases. Legacy
attempts remain readable without invented transition state (#1033).
- Propagated immutable phase authority through task previews and starts,
workflow steps, retries and fallbacks, provider changes, conversation resume,
follow-up and fork operations, and active-run controls. Every executable

View file

@ -17,6 +17,7 @@ import type {
RunApprovalRequest,
RunRecoveryRecord,
RunLaunchManifestPreview,
RunPhaseAuthoritySnapshot,
WorkspaceExecutionTrustDecision,
WorkspaceExecutionTrustDecisionMode,
WorkspaceExecutionTrustScanResult,
@ -93,7 +94,11 @@ function printConversationResult(
}
function phaseIdentityLabel(record: PhaseTransitionRecord): string {
const identity = record.effectiveEvidence.identity;
return phaseEvidenceIdentityLabel(record.effectiveEvidence);
}
function phaseEvidenceIdentityLabel(evidence: PhaseCapabilityEvidence): string {
const identity = evidence.identity;
return identity.mode === 'legacy'
? 'legacy'
: `${identity.phase} (${identity.profileId}@${identity.profileVersion})`;
@ -625,6 +630,7 @@ export function registerAgentCommands(program: Command): void {
try {
const taskId = await resolveTaskId(id);
const result = await api<{
phase: RunPhaseAuthoritySnapshot | null;
current: PhaseTransitionRecord | null;
history: PhaseTransitionRecord[];
}>(
@ -632,14 +638,16 @@ export function registerAgentCommands(program: Command): void {
);
if (options.json) {
console.log(JSON.stringify(result, null, 2));
} else if (!result.current) {
console.log(chalk.dim('No durable phase transition is recorded for this run'));
} else if (!result.phase) {
console.log(chalk.dim('This legacy run has no phase authority evidence'));
} else {
console.log(chalk.cyan(`Phase: ${phaseIdentityLabel(result.current)}`));
console.log(chalk.dim(`Sequence: ${result.current.sequence}`));
console.log(chalk.dim(`Evidence: ${result.current.effectiveEvidence.digest}`));
console.log(chalk.dim(`Manifest: ${result.current.manifestDigest}`));
if (result.current.emergencyOverride) {
console.log(
chalk.cyan(`Phase: ${phaseEvidenceIdentityLabel(result.phase.effectiveEvidence)}`)
);
console.log(chalk.dim(`Sequence: ${result.phase.transitionSequence}`));
console.log(chalk.dim(`Evidence: ${result.phase.effectiveEvidence.digest}`));
console.log(chalk.dim(`Manifest: ${result.phase.manifestDigest}`));
if (result.current?.emergencyOverride) {
console.log(
chalk.yellow(
`Emergency override expires: ${result.current.emergencyOverride.expiresAt}`
@ -672,17 +680,20 @@ export function registerAgentCommands(program: Command): void {
try {
const taskId = await resolveTaskId(id);
const state = await api<{
phase: RunPhaseAuthoritySnapshot | null;
current: PhaseTransitionRecord | null;
history: PhaseTransitionRecord[];
}>(`/api/agents/${taskId}/phase?attemptId=${encodeURIComponent(options.attempt)}`);
const fromEvidence = options.fromEvidence
? readPhaseEvidence(options.fromEvidence)
: undefined;
const priorEvidence = state.current?.effectiveEvidence ?? fromEvidence;
const priorEvidence =
state.phase?.effectiveEvidence ?? state.current?.effectiveEvidence ?? fromEvidence;
if (!priorEvidence) {
throw new Error('The first transition requires --from-evidence');
}
const manifestDigest = state.current?.manifestDigest ?? options.manifest;
const manifestDigest =
state.phase?.manifestDigest ?? state.current?.manifestDigest ?? options.manifest;
if (!manifestDigest) {
throw new Error('The first transition requires --manifest');
}

View file

@ -2403,9 +2403,10 @@ current transition evidence before attempt mutation. Descendants, retries,
fallbacks, and provider changes cannot widen parent authority. An unsupported
phase dimension returns `409 Conflict` with typed `phase-*` enforcement
blockers. Omitting `phase` creates explicit legacy evidence only when no
profile-authoritative parent exists. During the staged v6.x rollout, explicit
phase starts remain blocked until #1033 supplies command and external-action
enforcement; launch preview exposes that blocker without mutation.
profile-authoritative parent exists. ACP stdio can enforce the required
pre-execution command and external-action mediation. Other adapters return
typed blockers for explicit phases instead of treating prompts or
post-execution events as enforcement.
Codex app-server and Claude Code accept a positive MCP catalog through native,
run-scoped configuration. Other task adapters reject non-empty MCP selections.
@ -2420,8 +2421,12 @@ GET /api/agents/:taskId/phase?attemptId=attempt_123&limit=100
POST /api/agents/:taskId/phase/transitions
```
The GET endpoint returns `current` plus bounded append-only `history` for one
exact run. The POST body is a compare-and-set request:
The GET endpoint returns a server-owned `phase` snapshot plus compatible
`current` and bounded append-only `history` fields for one exact run. The
snapshot includes the launch phase, effective evidence, manifest digest,
transition sequence, and history. A launch with no transition is therefore
visible before the first journal record. The POST body is a compare-and-set
request:
```json
{
@ -2644,9 +2649,11 @@ Every terminal path persists one digest-bound `completion-result/v1` on the
current attempt and attempt history. It includes `digest`, `idempotencyKey`,
`completedAt`, `terminalSource`, envelope/runtime bindings, normalized status,
bounded redacted claims, harness evidence, attributable files and artifacts,
verification, side effects, and continuation. Exact duplicate callbacks
return success without mutating the task again, including after restart.
Conflicting terminal claims return `409 Conflict`.
verification, side effects, continuation, and phase evidence when the launch
was phase-bound. Phase evidence identifies the launch digest, final effective
authority, transition sequence, and every authority-expanding transition.
Exact duplicate callbacks return success without mutating the task again,
including after restart. Conflicting terminal claims return `409 Conflict`.
Startup reconciliation also persists `interrupted` completion results for
harness-owned process or stream attempts that were still running when the
server restarted. OpenClaw attempts remain eligible for their authoritative
@ -5328,7 +5335,11 @@ POST /api/v1/maintenance/debug-bundle
Creates a redacted debug bundle under the runtime debug-bundles directory and
returns the output path plus a manifest of included categories, excluded
sensitive categories, redaction rules, and redacted file metadata.
sensitive categories, redaction rules, and redacted file metadata. The
`phase-authority.json` member includes at most 200 phase-bound runs with phase
identity, authority scope counts, source kinds, transition expansion counts,
and completion bindings. Exact authority scopes, paths, credential
references, and full digests are not exported.
#### SQLite Export and Import

View file

@ -445,33 +445,33 @@ vk project create "rubicon" --color "#7c3aed" --description "Main product"
Manage AI agents on code tasks.
| Command | Description |
| ----------------------------------------------------------------------------------- | --------------------------------------------------------- |
| `vk start <id> [--phase <phase>]` | Start an agent; optionally bind an execution phase |
| `vk launch-preview <id> [--phase <phase>]` | 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:phase <id> --attempt <id>` | Read durable phase state and transition history |
| `vk agent:transition-phase <id> ...` | Apply or request approval for one exact phase transition |
| `vk agent:decide-phase-approval <approvalId> ...` | Approve or reject an exact pending phase expansion |
| `vk agent:resume <id> --source-attempt <id> -m <text> [--phase <phase>]` | Resume the exact persisted provider conversation |
| `vk agent:follow-up <id> --source-attempt <id> -m <text> [--phase <phase>]` | Start a provider-native follow-up turn |
| `vk agent:fork <id> --source-attempt <id> -m <text> [--phase <phase>]` | 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> [--phase <phase>]` | Start an agent; optionally bind an execution phase |
| `vk launch-preview <id> [--phase <phase>]` | 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:phase <id> --attempt <id>` | Read effective launch phase, sources, and transition history |
| `vk agent:transition-phase <id> ...` | Apply or request approval for one exact phase transition |
| `vk agent:decide-phase-approval <approvalId> ...` | Approve or reject an exact pending phase expansion |
| `vk agent:resume <id> --source-attempt <id> -m <text> [--phase <phase>]` | Resume the exact persisted provider conversation |
| `vk agent:follow-up <id> --source-attempt <id> -m <text> [--phase <phase>]` | Start a provider-native follow-up turn |
| `vk agent:fork <id> --source-attempt <id> -m <text> [--phase <phase>]` | 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:
@ -575,6 +575,10 @@ vk agent:transition-phase TASK-001 \
--json
```
`agent:phase` reports the launch phase even before the first transition. Human
output distinguishes parent, agent-profile, sandbox, tool-catalog, and launch
policy sources; `--json` returns the server-owned snapshot unchanged.
The first transition also requires `--from-evidence <file>` and
`--manifest <sha256:...>`. Later requests read the current journal record and
automatically bind its sequence, evidence digest, and manifest. Narrowing

View file

@ -349,8 +349,12 @@ First-class support for autonomous coding agents.
durably restore the prior phase. Task and workflow launches, retries and
fallbacks, provider changes, conversation continuations, and active-run
controls bind the effective phase and exact parent evidence before attempt
mutation. Tool-command and external-action enforcement remain tracked in
#1033. See
mutation. Run tool catalogs omit disallowed tools and credentials, mediated
calls re-check active transition evidence, approvals cannot outlive their
bound phase, and completion plus the task timeline expose the same
server-owned evidence. ACP stdio provides pre-execution command and external
action mediation; other adapters fail explicit phases closed when equivalent
controls are unavailable. See
[Phase Capability Profiles](architecture/PHASE-CAPABILITY-PROFILES.md) and
[Phase Transition Journal](architecture/PHASE-TRANSITION-JOURNAL.md).
- **Provider runtime manifests** — Every executable adapter records a versioned, evidence-backed capability snapshot and digest on the attempt, history, trace, and log; provider version skew reruns conformance and unsupported configured providers fail closed instead of falling back to OpenClaw

View file

@ -34,7 +34,10 @@ in Settings -> Maintenance and is backed by `/api/v1/maintenance`.
- Destructive cleanup must require explicit confirmation and must never delete
active task worktrees or current run state silently.
- Debug bundles include redacted log tails, health metadata, storage summaries,
lifecycle policy metadata, and work-product preview metadata.
lifecycle policy metadata, work-product preview metadata, and a bounded
`phase-authority.json` diagnostic export. Phase diagnostics retain identities,
source kinds, scope counts, transition expansions, and completion bindings
while omitting exact scopes, paths, credential references, and full digests.
- Maintenance summaries and log-tail responses redact local log paths before
returning data to the UI.
- SQLite posture diagnostics omit the database path, mount point, and mount

View file

@ -3,13 +3,14 @@
Issue #1034 establishes the provider-neutral authority contract for execution
phases. It defines what a phase may request and how Veritas computes the
effective result. Issue #1035 adds durable active-run transitions and operator
controls; propagation and tool enforcement remain in later #875 slices.
controls. Issues #1036 and #1033 bind that evidence through launch,
continuation, tools, approvals, completion, API, CLI, and operator UI surfaces.
The compiler remains intentionally pure. It does not mutate an active attempt,
persist a transition, filter a tool catalog, or claim that a provider enforced
the result. The separate
persist a transition, or filter a tool catalog. The separate
[Phase Transition Journal](PHASE-TRANSITION-JOURNAL.md) owns active state
changes and their evidence.
changes and their evidence; launch and tool services consume the compiled
result.
## Contract
@ -109,6 +110,28 @@ Only the harness API may perform this write. A provider shell, hook, MCP tool,
or redirection must not translate the exception into a general filesystem
grant.
## Legacy migration
Existing attempts and workflow history are not rewritten. A launch with no
explicit phase and no profile-authoritative parent remains in `legacy` mode;
its existing sandbox, provider, profile, and tool policies still apply.
Readers expose that identity without inventing a transition journal.
Migrate one execution path at a time:
1. Add `phase` to the API or CLI launch, or to an agent workflow step.
2. Run launch preview against the exact provider, agent profile, sandbox, and
tool selection.
3. Resolve typed enforcement blockers instead of weakening the phase.
4. Start the run only after preview is enforceable, then use `agent:phase` to
inspect the server-owned evidence.
Agent profile packages remain independent narrowing sources. They do not
silently select or widen a phase, so existing packages need no schema rewrite.
ACP stdio is the current adapter for explicit phase execution. Keep other
adapters in legacy mode until their runtime exposes equivalent pre-execution
command and external-action mediation.
## Delivery boundary
The delivered phase control plane now includes:
@ -117,9 +140,13 @@ The delivered phase control plane now includes:
#1034
- Durable transition state, approvals, emergency override expiry, restart
recovery, REST, and CLI controls from #1035
- Launch, descendant, retry, fallback, resume, fork, and handoff propagation
from #1036
- Phase-filtered tool catalogs, stale-call rejection, phase-bound approvals,
completion evidence, and shared REST, CLI, and UI projections from #1033
The remaining tracking-epic slices add launch, descendant, retry, resume, and
handoff propagation in #1036, then tool enforcement, evidence surfaces, and UI
in #1033. Until those slices land, a durable transition is authoritative
Veritas state, not a claim that every provider process or tool has enforced the
new phase.
Provider enforcement remains capability-bound. ACP stdio exposes a
pre-execution permission path for command and external actions. Adapters that
cannot prove equivalent mediation return typed blockers for explicit phases;
Veritas does not substitute prompt instructions or post-execution events for
enforcement.

View file

@ -3,7 +3,8 @@
Issue #1035 adds the durable state machine that moves one active run between
compiled phase capability profiles. It builds on the
[Phase Capability Profiles](PHASE-CAPABILITY-PROFILES.md) contract without
claiming launch or tool enforcement that belongs to #1036 and #1033.
duplicating the launch propagation and tool enforcement delivered by #1036 and
#1033.
## Durable record
@ -121,8 +122,12 @@ expiry.
## Delivery boundary
The journal makes phase state, approval, expiry, restart recovery, and operator
control durable. Issue #1036 will bind compiled evidence into launch,
descendant, retry, resume, and handoff behavior. Issue #1033 will enforce the
active evidence at tool invocation and expose it through evidence and UI
surfaces. Until those slices land, clients must not describe the journal alone
as complete provider-side phase enforcement.
control durable. Launch propagation binds it into descendants, retries,
continuations, and handoffs. Tool catalogs, mediated invocation, approvals,
completion results, and the run timeline consume the same server-owned active
projection.
The journal still does not prove provider enforcement by itself. ACP stdio
supplies the current pre-execution mediation contract. An adapter without
equivalent command and external-action controls fails an explicit phase launch
closed.

View file

@ -44,6 +44,7 @@ The catalog binds:
- task and attempt IDs;
- provider, provider-runtime digest, and task-envelope digest;
- active launch phase evidence when the run is phase-controlled;
- definition and discovery digests;
- required or optional readiness; and
- each tool's `allow`, `deny`, or `approval` decision.
@ -86,6 +87,25 @@ Approval-required tools are deliberately disabled there because native calls
would bypass the Veritas approval broker. Those tools use the mediated
`call_run_tool` path.
## Phase Authority
MCP discovery maps the standard `annotations.readOnlyHint: true` value to an
external read. Missing or false annotations classify the tool as an external
mutation. A phase-controlled catalog includes only tools and credential
bindings allowed by the launch evidence. Approval-required phase dimensions
also stay out of native provider configuration so they cannot bypass Veritas.
Mediated invocation resolves the current server-owned phase again before
dispatch. It rejects a hidden tool call, a credential reference outside the
active phase, or a catalog compiled from different phase evidence. A
transition can therefore narrow a running attempt immediately without relying
on the provider to refresh a cached tool list.
Every phase-bound approval records the exact manifest digest, phase evidence
digest, identity, transition sequence, dimension, and requested scopes. The
broker re-checks those values at decision time, so an older approval cannot
authorize an action after narrowing or authorize a wider scope.
## Mediated Invocation
A call must provide the exact task, active attempt, server, tool, arguments,

View file

@ -1741,6 +1741,50 @@ describe('ClawdbotAgentService Codex providers', () => {
expect(mockUpdateTask).not.toHaveBeenCalled();
});
it('does not treat Codex untrusted approvals as complete phase command mediation', async () => {
mockGetConfig.mockResolvedValue({
agents: [
{
type: 'codex-app-server',
name: 'OpenAI Codex app-server',
command: 'codex',
args: [],
enabled: true,
provider: 'codex-app-server',
model: 'gpt-5.6',
},
],
});
mockCheckAgent.mockImplementation(async (agent: AgentConfig) => ({
type: agent.type,
name: agent.name,
enabled: agent.enabled,
configured: true,
command: agent.command,
executableFound: true,
executablePath: '/opt/homebrew/bin/codex',
providerVersion: 'codex-cli 0.145.0',
providerVersionSource: 'codex --version',
authenticated: true,
healthy: true,
checkedAt: '2026-07-23T00:00:00.000Z',
}));
const service = testableService(tmpDir);
const preview = await service.previewAgentLaunch(task.id, 'codex-app-server', {
phase: 'explore',
});
expect(preview.manifest.enforcement.blockers).toContainEqual(
expect.objectContaining({
code: 'phase-required-authority-unsupported',
field: 'phase.evidence.command.execute',
})
);
expect(mockSpawn).not.toHaveBeenCalled();
expect(mockUpdateTask).not.toHaveBeenCalled();
});
it('compares replay and fork previews with compatible and incompatible parent manifests', async () => {
const service = testableService(tmpDir);
const parent = await service.previewAgentLaunch(task.id, 'codex');

View file

@ -147,7 +147,22 @@ describe('MaintenanceService', () => {
});
it('creates a redacted debug bundle manifest and redacted log tails', async () => {
const service = new MaintenanceService();
const service = new MaintenanceService(async () => ({
generatedAt: '2026-07-25T00:00:00.000Z',
status: 'ok',
truncated: false,
records: [
{
taskId: 'task-phase',
attemptId: 'attempt-phase',
effective: {
identity: { mode: 'profile', phase: 'verify' },
digest: 'sha256:123456789012',
},
authorityExpansions: [{ sequence: 2, dimensions: ['filesystem.write'] }],
},
],
}));
const bundle = await service.createDebugBundle();
const manifest = JSON.parse(
@ -158,11 +173,17 @@ describe('MaintenanceService', () => {
'utf-8'
);
const summary = await fs.readFile(path.join(bundle.outputPath, 'summary.json'), 'utf-8');
const phaseAuthority = JSON.parse(
await fs.readFile(path.join(bundle.outputPath, 'phase-authority.json'), 'utf-8')
) as { records: Array<Record<string, unknown>> };
const bundleText = await readDirectoryText(bundle.outputPath);
expect(bundle.redacted).toBe(true);
expect(manifest.includedCategories).toEqual(
expect.arrayContaining(['health', 'storage', 'redacted-log-tails'])
expect.arrayContaining(['health', 'storage', 'phase-authority', 'redacted-log-tails'])
);
expect(phaseAuthority.records).toContainEqual(
expect.objectContaining({ taskId: 'task-phase', attemptId: 'attempt-phase' })
);
expect(manifest.files.find((file) => file.id === 'server')?.path).toContain('[redacted-logs]');
expect(serverLog).not.toContain('sk_supersecret1234567890');

View file

@ -1,7 +1,10 @@
import { describe, expect, it } from 'vitest';
import {
COMPLETION_RESULT_SCHEMA_VERSION,
PHASE_AUTHORITY_DIMENSIONS,
TASK_ENVELOPE_SCHEMA_VERSION,
type PhaseAuthorityDimension,
type PhaseAuthoritySource,
type Task,
type TaskEnvelope,
} from '@veritas-kanban/shared';
@ -13,6 +16,10 @@ import {
} from '../services/provider-completion-service.js';
import { TaskEnvelopeService } from '../services/task-envelope-service.js';
import { verifyCompletionResultDigest } from '../utils/completion-result-digest.js';
import {
compilePhaseCapabilityAuthority,
getBuiltInPhaseCapabilityProfile,
} from '../services/phase-capability-service.js';
const completedAt = '2026-07-23T18:00:00.000Z';
@ -167,6 +174,34 @@ describe('ProviderCompletionService', () => {
expect(duplicate.digest).toBe(result.digest);
});
it('preserves effective phase and authority-expansion evidence', async () => {
const taskEnvelope = await envelope();
const effectiveEvidence = phaseEvidence();
const result = await completionService().complete({
task: task(),
taskEnvelope,
claim: {
terminalSource: 'process',
status: 'success',
summary: 'Phase-controlled run completed.',
},
phase: {
launchEvidenceDigest: effectiveEvidence.digest,
effectiveEvidence,
transitionSequence: 0,
authorityExpansions: [],
},
});
expect(result.phase).toMatchObject({
launchEvidenceDigest: effectiveEvidence.digest,
effectiveEvidence: { digest: effectiveEvidence.digest },
transitionSequence: 0,
authorityExpansions: [],
});
expect(parseCompletionResultForEnvelope(result, taskEnvelope)).toEqual(result);
});
it.each([
['callback', 'success', 'success'],
['remote-session', 'blocked', 'blocked'],
@ -294,3 +329,36 @@ describe('ProviderCompletionService', () => {
);
});
});
function phaseEvidence() {
return compilePhaseCapabilityAuthority({
profile: getBuiltInPhaseCapabilityProfile('implement'),
sources: {
parent: phaseSource('parent', 'parent'),
agentProfile: phaseSource('agent-profile', 'agent-profile'),
sandbox: phaseSource('sandbox', 'sandbox'),
toolCatalog: phaseSource('tool-catalog', 'tool-catalog'),
launchPolicy: phaseSource('launch-policy', 'launch-policy'),
},
});
}
function phaseSource<K extends PhaseAuthoritySource['kind']>(
id: string,
kind: K
): PhaseAuthoritySource & { kind: K } {
return {
id,
kind,
authority: phaseDimensions(() => ['*']),
enforcement: phaseDimensions(() => 'enforced' as const),
};
}
function phaseDimensions<T>(
value: (dimension: PhaseAuthorityDimension) => T
): Record<PhaseAuthorityDimension, T> {
return Object.fromEntries(
PHASE_AUTHORITY_DIMENSIONS.map((dimension) => [dimension, value(dimension)])
) as Record<PhaseAuthorityDimension, T>;
}

View file

@ -13,20 +13,23 @@ import {
getBuiltInPhaseCapabilityProfile,
} from '../../services/phase-capability-service.js';
const { mockGetCurrent, mockList, mockTransition } = vi.hoisted(() => ({
mockGetCurrent: vi.fn(),
mockList: vi.fn(),
const { mockGetPhase, mockTransition } = vi.hoisted(() => ({
mockGetPhase: vi.fn(),
mockTransition: vi.fn(),
}));
vi.mock('../../services/phase-transition-service.js', () => ({
getPhaseTransitionService: () => ({
getCurrent: mockGetCurrent,
list: mockList,
transition: mockTransition,
}),
}));
vi.mock('../../services/run-phase-authority-service.js', () => ({
getRunPhaseAuthorityService: () => ({
get: mockGetPhase,
}),
}));
vi.mock('../../services/clawdbot-agent-service.js', () => ({
AgentReadinessError: class AgentReadinessError extends Error {},
clawdbotAgentService: {},
@ -49,8 +52,7 @@ import { agentRoutes } from '../../routes/agents.js';
describe('phase transition routes', () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetCurrent.mockResolvedValue(null);
mockList.mockResolvedValue([]);
mockGetPhase.mockResolvedValue(null);
});
it('reads current state and bounded history for one exact run', async () => {
@ -59,9 +61,8 @@ describe('phase transition routes', () => {
.query({ attemptId: 'attempt-1', limit: 25 });
expect(response.status).toBe(200);
expect(response.body).toEqual({ current: null, history: [] });
expect(mockGetCurrent).toHaveBeenCalledWith('local', 'task-1', 'attempt-1');
expect(mockList).toHaveBeenCalledWith('local', 'task-1', 'attempt-1', 25);
expect(response.body).toEqual({ phase: null, current: null, history: [] });
expect(mockGetPhase).toHaveBeenCalledWith('local', 'task-1', 'attempt-1', 25);
});
it('forwards a validated compare-and-set transition and actor authority', async () => {

View file

@ -17,6 +17,7 @@ import {
type CreateRunApprovalRequestInput,
} from '../services/run-approval-broker-service.js';
import type { RunEventJournalService } from '../services/run-event-journal-service.js';
import type { RunPhaseAuthorityService } from '../services/run-phase-authority-service.js';
class InMemoryRunApprovalRepository implements RunApprovalRepository {
readonly requests = new Map<string, RunApprovalRequest>();
@ -117,7 +118,11 @@ function requestInput(
};
}
function service(repository = new InMemoryRunApprovalRepository(), now?: () => Date) {
function service(
repository = new InMemoryRunApprovalRepository(),
now?: () => Date,
phaseAuthority?: Pick<RunPhaseAuthorityService, 'getActive' | 'assertScopes'>
) {
const append = vi.fn(async () => ({ appended: true, event: {} }));
const broadcast = vi.fn();
return {
@ -129,6 +134,7 @@ function service(repository = new InMemoryRunApprovalRepository(), now?: () => D
now,
journal: { append } as unknown as RunEventJournalService,
broadcast,
...(phaseAuthority ? { phaseAuthority } : {}),
}),
};
}
@ -264,6 +270,57 @@ describe('RunApprovalBrokerService', () => {
).rejects.toMatchObject({ statusCode: 409, code: 'CONFLICT' });
});
it('rejects approval when the bound phase snapshot is stale', async () => {
const phaseAuthority = {
getActive: vi.fn(async () => ({
manifestDigest: `sha256:${'1'.repeat(64)}`,
transitionSequence: 3,
effectiveEvidence: { digest: `sha256:${'2'.repeat(64)}` },
})),
assertScopes: vi.fn(),
} as unknown as Pick<RunPhaseAuthorityService, 'getActive' | 'assertScopes'>;
const fixture = service(new InMemoryRunApprovalRepository(), undefined, phaseAuthority);
const pending = await fixture.broker.request(
requestInput({
phase: {
evidenceDigest: `sha256:${'3'.repeat(64)}`,
manifestDigest: `sha256:${'1'.repeat(64)}`,
identity: {
mode: 'profile',
phase: 'implement',
profileId: 'builtin-implement',
profileVersion: 1,
},
transitionSequence: 2,
requirements: [
{
dimension: 'command.execute',
requestedScopes: ['test'],
},
],
},
})
);
await expect(
fixture.broker.decide(
pending.id,
{
decision: 'approved',
expectedRevision: pending.revision,
expectedActionHash: pending.actionHash,
},
{
id: 'reviewer',
type: 'user',
authMethod: 'session',
workspaceId: 'local',
}
)
).rejects.toMatchObject({ statusCode: 409, code: 'CONFLICT' });
expect(phaseAuthority.assertScopes).not.toHaveBeenCalled();
});
it('blocks mobile decisions unless the exact request is explicitly mobile-safe', async () => {
const fixture = service();
const unsafe = await fixture.broker.request(requestInput());

View file

@ -0,0 +1,125 @@
import { describe, expect, it, vi } from 'vitest';
import {
PHASE_AUTHORITY_DIMENSIONS,
type PhaseAuthorityDimension,
type PhaseAuthoritySource,
type Task,
} from '@veritas-kanban/shared';
import {
compilePhaseCapabilityAuthority,
getBuiltInPhaseCapabilityProfile,
} from '../services/phase-capability-service.js';
import { RunPhaseAuthorityService } from '../services/run-phase-authority-service.js';
const MANIFEST_DIGEST = `sha256:${'1'.repeat(64)}`;
describe('RunPhaseAuthorityService', () => {
it('keeps a legacy attempt readable without inventing phase or transition evidence', async () => {
const task = {
id: 'task-legacy',
attempt: {
id: 'attempt-legacy',
status: 'complete',
},
} as unknown as Task;
const getCurrent = vi.fn();
const list = vi.fn();
const service = new RunPhaseAuthorityService({
tasks: { findById: vi.fn(async () => task) },
transitions: { getCurrent, list },
});
await expect(service.get('local', task.id, 'attempt-legacy')).resolves.toBeNull();
expect(getCurrent).not.toHaveBeenCalled();
expect(list).not.toHaveBeenCalled();
});
it('projects launch authority and binds exact action requirements', async () => {
const launchEvidence = evidence('implement');
const task = {
id: 'task-phase',
attempt: {
id: 'attempt-phase',
status: 'running',
runLaunchManifest: {
digest: MANIFEST_DIGEST,
phase: {
evidence: launchEvidence,
sourceReferences: [
{
sourceId: 'parent:none',
kind: 'parent',
originScope: 'system-default',
sourceDigest: `sha256:${'2'.repeat(64)}`,
},
],
},
},
},
} as unknown as Task;
const service = new RunPhaseAuthorityService({
tasks: { findById: vi.fn(async () => task) },
transitions: {
getCurrent: vi.fn(async () => null),
list: vi.fn(async () => []),
},
});
const snapshot = await service.getActive('local', task.id, 'attempt-phase');
if (!snapshot) throw new Error('Expected phase authority snapshot');
expect(snapshot).toMatchObject({
manifestDigest: MANIFEST_DIGEST,
transitionSequence: 0,
effectiveEvidence: { digest: launchEvidence.digest },
});
expect(
service.binding(snapshot, [
{ dimension: 'command.execute', requestedScopes: ['test'] },
{ dimension: 'external.action', requestedScopes: ['read'] },
])
).toMatchObject({
evidenceDigest: launchEvidence.digest,
transitionSequence: 0,
requirements: [
{ dimension: 'command.execute', requestedScopes: ['test'] },
{ dimension: 'external.action', requestedScopes: ['read'] },
],
});
expect(() => service.assertScopes(snapshot, 'external.action', ['mutate'])).toThrow(
'Active phase authority denies this action.'
);
});
});
function evidence(phase: 'implement') {
return compilePhaseCapabilityAuthority({
profile: getBuiltInPhaseCapabilityProfile(phase),
sources: {
parent: source('parent', 'parent'),
agentProfile: source('agent-profile', 'agent-profile'),
sandbox: source('sandbox', 'sandbox'),
toolCatalog: source('tool-catalog', 'tool-catalog'),
launchPolicy: source('launch-policy', 'launch-policy'),
},
});
}
function source<K extends PhaseAuthoritySource['kind']>(
id: string,
kind: K
): PhaseAuthoritySource & { kind: K } {
return {
id,
kind,
authority: dimensions(() => ['*']),
enforcement: dimensions(() => 'enforced' as const),
};
}
function dimensions<T>(
value: (dimension: PhaseAuthorityDimension) => T
): Record<PhaseAuthorityDimension, T> {
return Object.fromEntries(
PHASE_AUTHORITY_DIMENSIONS.map((dimension) => [dimension, value(dimension)])
) as Record<PhaseAuthorityDimension, T>;
}

View file

@ -7,6 +7,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import type {
CredentialDefinition,
CredentialDefinitionInput,
PhaseCapabilityEvidence,
RunEventEnvelope,
ToolServerDefinition,
ToolServerDefinitionInput,
@ -17,6 +18,7 @@ import {
EnvironmentCredentialSecretSource,
} from '../services/credential-broker-service.js';
import type { RunApprovalBrokerService } from '../services/run-approval-broker-service.js';
import type { RunPhaseAuthorityService } from '../services/run-phase-authority-service.js';
import { RunEventJournalService } from '../services/run-event-journal-service.js';
import { FileRunEventRepository } from '../storage/run-event-repository.js';
import { InMemoryCredentialBrokerRepository } from '../storage/credential-broker-repository.js';
@ -114,6 +116,7 @@ function fixture(
CredentialBrokerService,
'getDefinition' | 'issueLease' | 'withCredential'
>;
phaseAuthority?: Pick<RunPhaseAuthorityService, 'getActive' | 'assertScopes' | 'binding'>;
} = {}
) {
const requests: Array<{ method: string; params: Record<string, unknown> }> = [];
@ -196,6 +199,7 @@ function fixture(
>),
now: () => new Date('2026-07-24T12:00:00.000Z'),
environment: options.environment ?? {},
...(options.phaseAuthority ? { phaseAuthority: options.phaseAuthority } : {}),
});
return { service, open, requests, append, requestApproval };
}
@ -424,6 +428,126 @@ describe('ToolControlPlaneService', () => {
expect(await service.getRunCatalog('task-tools', 'attempt-tools')).toEqual(runCatalog);
});
it('removes mutating MCP tools from a read-only phase catalog', async () => {
const { service } = fixture({
tools: [
{
name: 'lookup',
annotations: { readOnlyHint: true },
inputSchema: { type: 'object' },
},
{
name: 'update',
inputSchema: { type: 'object' },
},
],
});
await service.createDefinition(definition());
const runCatalog = await service.prepareRunCatalog({
taskId: 'task-tools',
attemptId: 'attempt-tools',
provider: 'codex-app-server',
providerRuntimeManifestDigest: DIGEST,
taskEnvelopeDigest: DIGEST,
serverIds: ['fixture'],
phaseEvidence: {
digest: DIGEST,
effectiveAuthority: {
'filesystem.read': ['<workspace>'],
'filesystem.write': [],
'command.execute': ['inspect'],
'network.egress': [],
'credential.access': [],
'external.action': ['read'],
'artifact.plan.write': [],
},
approvalRequiredDimensions: [],
} as PhaseCapabilityEvidence,
});
expect(runCatalog?.entries[0]?.tools).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: 'lookup',
externalAction: 'read',
decision: 'allow',
}),
expect.objectContaining({
name: 'update',
externalAction: 'mutate',
decision: 'deny',
}),
])
);
if (!runCatalog) throw new Error('Expected a phase-filtered run catalog');
expect(await service.providerConfig(runCatalog)).toMatchObject({
fixture: {
enabled_tools: ['lookup'],
disabled_tools: ['update'],
},
});
});
it('rejects a hidden tool call after the active phase narrows', async () => {
const phaseAuthority = {
getActive: vi.fn(async () => ({
launch: {
evidence: {
digest: DIGEST,
},
},
effectiveEvidence: {
approvalRequiredDimensions: [],
},
})),
assertScopes: vi.fn(() => {
throw new Error('Active phase authority denies this action.');
}),
binding: vi.fn(),
} as unknown as Pick<RunPhaseAuthorityService, 'getActive' | 'assertScopes' | 'binding'>;
const { service, requests } = fixture({
tools: [{ name: 'update', inputSchema: { type: 'object' } }],
phaseAuthority,
});
await service.createDefinition(definition());
await service.prepareRunCatalog({
taskId: 'task-tools',
attemptId: 'attempt-tools',
provider: 'codex-app-server',
providerRuntimeManifestDigest: DIGEST,
taskEnvelopeDigest: DIGEST,
serverIds: ['fixture'],
phaseEvidence: {
digest: DIGEST,
effectiveAuthority: {
'filesystem.read': ['<workspace>'],
'filesystem.write': ['<workspace>'],
'command.execute': ['publish'],
'network.egress': ['*'],
'credential.access': ['*'],
'external.action': ['read', 'mutate'],
'artifact.plan.write': [],
},
approvalRequiredDimensions: [],
} as PhaseCapabilityEvidence,
});
await expect(
service.invoke(
{
taskId: 'task-tools',
attemptId: 'attempt-tools',
serverId: 'fixture',
tool: 'update',
arguments: {},
operationId: 'stale-phase-call',
},
'agent-a'
)
).rejects.toThrow('Active phase authority denies this action.');
expect(requests.some((request) => request.method === 'tools/call')).toBe(false);
});
it('maps an all-allow run catalog to ACP session MCP configuration', async () => {
const { service } = fixture();
const runCatalog = await catalog(service);

View file

@ -42,6 +42,7 @@ import {
getPhaseTransitionService,
type PhaseTransitionActorContext,
} from '../services/phase-transition-service.js';
import { getRunPhaseAuthorityService } from '../services/run-phase-authority-service.js';
const router: RouterType = Router();
const workspaceExecutionTrust = getWorkspaceExecutionTrustService();
@ -431,19 +432,17 @@ router.get(
asyncHandler(async (req: AuthenticatedRequest, res) => {
const query = phaseReadQuerySchema.parse(req.query);
const workspaceId = req.auth?.workspaceId || 'local';
const service = getPhaseTransitionService();
const current = await service.getCurrent(
workspaceId,
req.params.taskId as string,
query.attemptId
);
const history = await service.list(
const phase = await getRunPhaseAuthorityService().get(
workspaceId,
req.params.taskId as string,
query.attemptId,
query.limit
);
res.json({ current, history });
res.json({
phase,
current: phase?.current ?? null,
history: phase?.history ?? [],
});
})
);

View file

@ -1,8 +1,10 @@
import { z } from 'zod';
import {
EXECUTABLE_AGENT_PROVIDERS,
PHASE_AUTHORITY_DIMENSIONS,
RUN_APPROVAL_ACTION_CLASSES,
RUN_APPROVAL_SCHEMA_VERSION,
PHASE_NAMES,
type RunApprovalActor,
type RunApprovalDecisionInput,
type RunApprovalRequest,
@ -12,6 +14,19 @@ import {
const IdentifierSchema = z.string().trim().min(1).max(240);
const IsoTimestampSchema = z.string().datetime();
const DigestSchema = z.string().regex(/^sha256:[a-f0-9]{64}$/);
const PhaseScopeSchema = z.string().trim().min(1).max(2_048);
const ApprovalPhaseIdentitySchema = z.discriminatedUnion('mode', [
z.object({ mode: z.literal('legacy'), phase: z.literal('legacy') }).strict(),
z
.object({
mode: z.literal('profile'),
phase: z.enum(PHASE_NAMES),
profileId: IdentifierSchema,
profileVersion: z.number().int().positive().max(10_000),
})
.strict(),
]);
const JsonValueSchema: z.ZodType<RunEventJsonValue> = z.lazy(() =>
z.union([
@ -70,6 +85,35 @@ export const RunApprovalRequestSchema: z.ZodType<RunApprovalRequest> = z
turnId: IdentifierSchema.optional(),
itemId: IdentifierSchema.optional(),
mobileSafe: z.boolean(),
phase: z
.object({
evidenceDigest: DigestSchema,
manifestDigest: DigestSchema,
identity: ApprovalPhaseIdentitySchema,
transitionSequence: z.number().int().nonnegative(),
requirements: z
.array(
z
.object({
dimension: z.enum(PHASE_AUTHORITY_DIMENSIONS),
requestedScopes: z
.array(PhaseScopeSchema)
.min(1)
.max(100)
.refine((values) => new Set(values).size === values.length),
})
.strict()
)
.min(1)
.max(PHASE_AUTHORITY_DIMENSIONS.length)
.refine(
(requirements) =>
new Set(requirements.map((requirement) => requirement.dimension)).size ===
requirements.length
),
})
.strict()
.optional(),
status: z.enum(['pending', 'approved', 'rejected', 'expired', 'cancelled']),
revision: z.number().int().positive(),
createdAt: IsoTimestampSchema,

View file

@ -16,6 +16,10 @@ import {
type CompletionResult,
type TaskEnvelope,
} from '@veritas-kanban/shared';
import {
phaseCapabilityEvidenceSchema,
phaseTransitionRecordSchema,
} from './phase-capability-schemas.js';
import { verifyTaskEnvelopeDigest } from '../utils/task-envelope-digest.js';
import { verifyCompletionResultDigest } from '../utils/completion-result-digest.js';
@ -277,6 +281,15 @@ export const CompletionResultSchema = z
})
.strict()
.nullable(),
phase: z
.object({
launchEvidenceDigest: digestSchema,
effectiveEvidence: phaseCapabilityEvidenceSchema,
transitionSequence: z.number().int().nonnegative(),
authorityExpansions: z.array(phaseTransitionRecordSchema).max(100),
})
.strict()
.optional(),
})
.strict()
.superRefine((result, ctx) => {

View file

@ -149,6 +149,7 @@ const discoveryToolSchema = z
description: z.string().max(4_000).optional(),
inputSchema,
inputSchemaDigest: digestSchema,
externalAction: z.enum(['read', 'mutate']).optional(),
})
.strict();
@ -224,6 +225,7 @@ export const runToolCatalogSchema: z.ZodType<RunToolCatalog> = z
provider: z.enum(EXECUTABLE_AGENT_PROVIDERS),
providerRuntimeManifestDigest: digestSchema,
taskEnvelopeDigest: digestSchema,
phaseEvidenceDigest: digestSchema.optional(),
entries: z.array(catalogEntrySchema).max(100),
createdAt: z.string().datetime(),
digest: digestSchema,

View file

@ -93,6 +93,7 @@ import type {
TaskEnvelope,
TaskTerminalSource,
CompletionResult,
CompletionPhaseAuthorityEvidence,
HarnessSupportStatus,
HarnessSupportTelemetry,
RunLaunchManifest,
@ -102,6 +103,7 @@ import type {
RunLaunchRuntime,
RunLaunchPhaseAuthority,
PhaseName,
PhaseAuthorityDimension,
PhaseCapabilityEvidence,
CredentialRunRevocationRequest,
CredentialLeaseTerminalReason,
@ -198,6 +200,7 @@ import {
CodexAppServerRpcClient,
parseCodexAppServerLine,
type CodexAppServerClassification,
type CodexAppServerBrokerRequest,
type CodexAppServerTerminalResult,
type CodexAppServerUsage,
} from './codex-app-server-adapter.js';
@ -247,6 +250,7 @@ import {
PhaseLaunchAuthorityService,
type PhaseLaunchParentSnapshot,
} from './phase-launch-authority-service.js';
import { RunPhaseAuthorityService } from './run-phase-authority-service.js';
import {
getPhaseTransitionService,
type PhaseTransitionService,
@ -515,7 +519,9 @@ export class ClawdbotAgentService {
'scan' | 'evaluateForLaunch' | 'assertFresh'
>;
private phaseAuthority: PhaseLaunchAuthorityService;
private phaseTransitions?: Pick<PhaseTransitionService, 'getCurrent'>;
private phaseTransitions?: Pick<PhaseTransitionService, 'getCurrent'> &
Partial<Pick<PhaseTransitionService, 'list'>>;
private runPhaseAuthority: RunPhaseAuthorityService;
private logsDir: string;
constructor(
@ -543,7 +549,8 @@ export class ClawdbotAgentService {
'scan' | 'evaluateForLaunch' | 'assertFresh'
> = getWorkspaceExecutionTrustService(),
phaseAuthority = new PhaseLaunchAuthorityService(),
phaseTransitions?: Pick<PhaseTransitionService, 'getCurrent'>
phaseTransitions?: Pick<PhaseTransitionService, 'getCurrent'> &
Partial<Pick<PhaseTransitionService, 'list'>>
) {
this.configService = new ConfigService();
this.taskService = new TaskService();
@ -572,6 +579,25 @@ export class ClawdbotAgentService {
this.workspaceExecutionTrust = workspaceExecutionTrust;
this.phaseAuthority = phaseAuthority;
this.phaseTransitions = phaseTransitions;
const transitionAuthority = phaseTransitions
? {
getCurrent: (...args: Parameters<PhaseTransitionService['getCurrent']>) =>
phaseTransitions.getCurrent(...args),
list: (...args: Parameters<PhaseTransitionService['list']>) =>
phaseTransitions.list?.(...args) ?? Promise.resolve([]),
}
: {
getCurrent: (...args: Parameters<PhaseTransitionService['getCurrent']>) =>
getPhaseTransitionService().getCurrent(...args),
list: (...args: Parameters<PhaseTransitionService['list']>) =>
getPhaseTransitionService().list(...args),
};
this.runPhaseAuthority = new RunPhaseAuthorityService({
tasks: {
findById: (id) => this.taskService.getTask(id),
},
transitions: transitionAuthority,
});
this.logsDir = getLogsDir();
this.ensureLogsDir();
}
@ -1572,6 +1598,16 @@ export class ClawdbotAgentService {
executionPolicy: task.executionPolicy,
});
const toolPolicy = await this.resolveLaunchToolPolicy(profileLaunch);
const launchPhaseAuthority = this.compileLaunchPhaseAuthority({
requestedPhase: options.phase,
parentPhase,
profileLaunch,
sandboxPolicy: trustSandbox.policy,
providerRuntimeManifest,
filesystemSandboxPlan,
provider,
toolCatalogSelected: (profileLaunch?.profile.tools?.mcpServers?.length ?? 0) > 0,
});
const runToolCatalog = await this.toolControlPlane.prepareRunCatalog({
taskId,
attemptId,
@ -1586,6 +1622,9 @@ export class ClawdbotAgentService {
deniedTools: toolPolicy.denied,
cwd: worktreePath,
persist: false,
...(launchPhaseAuthority.evidence.identity.mode === 'profile'
? { phaseEvidence: launchPhaseAuthority.evidence }
: {}),
});
const taskTransport = adapter.renderTaskEnvelope({
taskEnvelope,
@ -1622,6 +1661,7 @@ export class ClawdbotAgentService {
filesystemSandboxPlan,
workspaceTrustEvaluation,
parentPhase,
phaseAuthority: launchPhaseAuthority,
});
return {
manifest,
@ -1886,6 +1926,16 @@ export class ClawdbotAgentService {
executionPolicy: task.executionPolicy,
});
const toolPolicy = await this.resolveLaunchToolPolicy(profileLaunch);
const launchPhaseAuthority = this.compileLaunchPhaseAuthority({
requestedPhase: options.phase,
parentPhase,
profileLaunch,
sandboxPolicy: trustSandbox.policy,
providerRuntimeManifest,
filesystemSandboxPlan,
provider,
toolCatalogSelected: (profileLaunch?.profile.tools?.mcpServers?.length ?? 0) > 0,
});
const runToolCatalog = await this.toolControlPlane.prepareRunCatalog({
taskId,
attemptId,
@ -1899,6 +1949,9 @@ export class ClawdbotAgentService {
),
deniedTools: toolPolicy.denied,
cwd: worktreePath,
...(launchPhaseAuthority.evidence.identity.mode === 'profile'
? { phaseEvidence: launchPhaseAuthority.evidence }
: {}),
});
// Validate path segments for log file
@ -1957,6 +2010,7 @@ export class ClawdbotAgentService {
filesystemSandboxPlan,
workspaceTrustEvaluation,
parentPhase,
phaseAuthority: launchPhaseAuthority,
});
const runLaunchManifestDrift = parentAttempt?.runLaunchManifest
? diffRunLaunchManifests(runLaunchManifest, parentAttempt.runLaunchManifest)
@ -2848,6 +2902,11 @@ export class ClawdbotAgentService {
task,
taskEnvelope: attempt.taskEnvelope,
claim,
...(await this.completionPhaseEvidence(
task.id,
attempt.id,
attempt.runLaunchManifest?.phase
)),
});
const completedAttempt: TaskAttempt = {
...attempt,
@ -3153,6 +3212,11 @@ export class ClawdbotAgentService {
task: taskBeforeCompletion,
taskEnvelope: pending.taskEnvelope,
claim,
...(await this.completionPhaseEvidence(
taskId,
attemptId,
pending.runLaunchManifest.phase
)),
});
const status: AttemptStatus = completionResult.status === 'success' ? 'complete' : 'failed';
const completedAttempt: TaskAttempt = {
@ -5062,6 +5126,12 @@ export class ClawdbotAgentService {
}
const actionClass = acpApprovalActionClass(request.toolCall.kind);
const riskClass = acpApprovalRisk(request.toolCall.kind);
const phase = await this.bindPhaseApproval(
task.id,
attemptId,
pending.runLaunchManifest,
phaseRequirementsForAcpRequest(request)
);
const approval = await this.approvalBroker.request({
workspaceId: 'local',
taskId: task.id,
@ -5093,6 +5163,7 @@ export class ClawdbotAgentService {
kind: option.kind,
})),
},
...(phase ? { phase } : {}),
});
let decision: Awaited<ReturnType<RunApprovalBrokerService['awaitDecision']>>;
try {
@ -5116,6 +5187,49 @@ export class ClawdbotAgentService {
return { outcome: { outcome: 'cancelled' } };
}
private async bindPhaseApproval(
taskId: string,
attemptId: string,
manifest: RunLaunchManifest,
requirements: Array<{
dimension: PhaseAuthorityDimension;
requestedScopes: string[];
}>
) {
if (manifest.phase?.evidence.identity.mode !== 'profile' || requirements.length === 0) {
return undefined;
}
const authority = this.runPhaseAuthority;
const snapshot = await authority.getActive('local', taskId, attemptId, 1);
if (!snapshot) {
throw new ConflictError('Phase-controlled approval lost its active authority snapshot.', {
taskId,
attemptId,
});
}
return authority.binding(snapshot, requirements);
}
private async completionPhaseEvidence(
taskId: string,
attemptId: string,
launchPhase?: RunLaunchPhaseAuthority
): Promise<{ phase: CompletionPhaseAuthorityEvidence } | Record<string, never>> {
if (!launchPhase) return {};
const snapshot = await this.runPhaseAuthority.get('local', taskId, attemptId, 100);
if (!snapshot) return {};
return {
phase: {
launchEvidenceDigest: snapshot.launch.evidence.digest,
effectiveEvidence: snapshot.effectiveEvidence,
transitionSequence: snapshot.transitionSequence,
authorityExpansions: snapshot.history.filter((record) =>
record.authorityDelta.entries.some((entry) => entry.addedScopes.length > 0)
),
},
};
}
private async startCodexAppServer(
task: Task,
agentConfig: AgentConfig | undefined,
@ -8205,6 +8319,41 @@ export class ClawdbotAgentService {
};
}
private compileLaunchPhaseAuthority(input: {
requestedPhase?: PhaseName;
parentPhase?: PhaseLaunchParentSnapshot;
profileLaunch?: AgentProfileResolvedLaunch;
sandboxPolicy: SandboxPolicyDryRunResult;
providerRuntimeManifest: ProviderRuntimeManifest;
filesystemSandboxPlan: FilesystemSandboxLaunchPlan;
provider: ExecutableAgentProvider;
toolCatalogSelected: boolean;
}): RunLaunchPhaseAuthority {
const actionMediation = input.provider === 'acp-stdio';
return this.phaseAuthority.compile({
requestedPhase: input.requestedPhase,
parent: input.parentPhase,
...(input.profileLaunch
? {
agentProfile: {
id: input.profileLaunch.profile.id,
version: input.profileLaunch.profile.version,
},
}
: {}),
sandboxPolicy: input.sandboxPolicy,
providerRuntimeManifest: input.providerRuntimeManifest,
filesystemSandbox: input.filesystemSandboxPlan.evidence,
selectedHost: input.provider === 'openclaw' ? 'openclaw-gateway' : 'local-process',
...(input.toolCatalogSelected ? { toolCatalogId: 'run' } : {}),
executionEnforcement: {
commandExecute: actionMediation ? 'enforced' : 'unsupported',
externalAction: actionMediation ? 'enforced' : 'unsupported',
planArtifactWrite: 'enforced',
},
});
}
private async compileRunLaunchManifest(input: {
task: Task;
taskEnvelope: TaskEnvelope;
@ -8240,6 +8389,7 @@ export class ClawdbotAgentService {
filesystemSandboxPlan: FilesystemSandboxLaunchPlan;
workspaceTrustEvaluation: WorkspaceExecutionTrustEvaluation;
parentPhase?: PhaseLaunchParentSnapshot;
phaseAuthority?: RunLaunchPhaseAuthority;
}): Promise<RunLaunchManifest> {
const profile = input.profileLaunch?.profile;
const toolCatalogDelivery = input.launchAgentConfig
@ -8268,23 +8418,18 @@ export class ClawdbotAgentService {
: []),
];
const selectedHost = input.provider === 'openclaw' ? 'openclaw-gateway' : 'local-process';
const phase = this.phaseAuthority.compile({
requestedPhase: input.options.phase,
parent: input.parentPhase,
...(profile
? {
agentProfile: {
id: profile.id,
version: profile.version,
},
}
: {}),
sandboxPolicy: input.sandboxPolicy,
providerRuntimeManifest: input.providerRuntimeManifest,
filesystemSandbox: input.filesystemSandboxPlan.evidence,
selectedHost,
toolCatalogId: input.runToolCatalog?.digest,
});
const phase =
input.phaseAuthority ??
this.compileLaunchPhaseAuthority({
requestedPhase: input.options.phase,
parentPhase: input.parentPhase,
profileLaunch: input.profileLaunch,
sandboxPolicy: input.sandboxPolicy,
providerRuntimeManifest: input.providerRuntimeManifest,
filesystemSandboxPlan: input.filesystemSandboxPlan,
provider: input.provider,
toolCatalogSelected: Boolean(input.runToolCatalog),
});
const runtime = this.buildRunLaunchRuntime(
input.provider,
input.launchAgentConfig,
@ -9573,6 +9718,94 @@ function acpApprovalRisk(kind: string | null | undefined): RunApprovalRiskClass
return 'medium';
}
function phaseRequirementsForAcpRequest(
request: AcpRequestPermissionRequest
): Array<{ dimension: PhaseAuthorityDimension; requestedScopes: string[] }> {
const kind = request.toolCall.kind?.toLowerCase();
if (kind === 'read' || kind === 'search') {
return [{ dimension: 'filesystem.read', requestedScopes: ['<workspace>'] }];
}
if (kind === 'edit' || kind === 'delete' || kind === 'move') {
return [{ dimension: 'filesystem.write', requestedScopes: ['<workspace>'] }];
}
if (kind === 'execute') {
const commandClass = classifyPhaseCommand(request.toolCall.rawInput);
return [
{ dimension: 'command.execute', requestedScopes: [commandClass] },
...(commandClass === 'publish'
? [
{
dimension: 'external.action' as const,
requestedScopes: ['mutate'],
},
]
: []),
];
}
if (kind === 'fetch') {
return [
{ dimension: 'network.egress', requestedScopes: ['*'] },
{ dimension: 'external.action', requestedScopes: ['read'] },
];
}
if (kind === 'think') return [];
return [{ dimension: 'external.action', requestedScopes: ['mutate'] }];
}
function classifyPhaseCommand(value: unknown): string {
const command = commandText(value).toLowerCase();
if (!command) return 'unclassified';
if (
/\b(?:git\s+push|npm\s+publish|pnpm\s+publish|gh\s+(?:issue|pr|release|api)\s+(?:create|edit|close|merge|comment|delete)|curl\b[^|\\n]*(?:-[^-\\s]*x|--request)\s+(?:post|put|patch|delete))\b/.test(
command
)
) {
return 'publish';
}
if (
/\b(?:apply_patch|git\s+(?:add|commit|reset|checkout|switch|rebase|merge|cherry-pick)|rm|mv|cp|mkdir|touch|install|unlink)\b/.test(
command
)
) {
return 'mutate';
}
if (/\b(?:prettier|eslint)\b.*(?:--write|--fix)\b/.test(command)) return 'format';
if (
/\b(?:vitest|jest|playwright|pytest|cargo\s+test|go\s+test|pnpm\s+(?:run\s+)?test)\b/.test(
command
)
) {
return 'test';
}
if (
/\b(?:tsc|vite\s+build|cargo\s+build|go\s+build|pnpm\s+(?:run\s+)?(?:build|typecheck|lint))\b/.test(
command
)
) {
return 'build';
}
if (
/^(?:\s*(?:pwd|ls|cat|head|tail|sed|rg|grep|find|stat|wc|which|command\s+-v|git\s+(?:status|diff|log|show|rev-parse)|gh\s+(?:issue|pr|release|run)\s+(?:view|list|status))\b)/.test(
command
)
) {
return 'inspect';
}
return 'unclassified';
}
function commandText(value: unknown): string {
if (typeof value === 'string') return value;
if (Array.isArray(value)) return value.filter((item) => typeof item === 'string').join(' ');
if (!value || typeof value !== 'object') return '';
const record = value as Record<string, unknown>;
for (const key of ['command', 'cmd', 'script', 'input']) {
const candidate = commandText(record[key]);
if (candidate) return candidate;
}
return '';
}
// Export singleton
export const clawdbotAgentService = new ClawdbotAgentService(
undefined,

View file

@ -1,6 +1,8 @@
import fs from 'fs/promises';
import path from 'path';
import {
PHASE_AUTHORITY_DIMENSIONS,
type PhaseCapabilityEvidence,
type MaintenanceCleanupPreviewItem,
type MaintenanceDebugBundle,
type MaintenanceHealthCheck,
@ -26,6 +28,8 @@ import {
} from '../utils/paths.js';
import { redactString } from '../lib/redact.js';
import { getSqliteStorageDiagnostics } from '../storage/sqlite/database.js';
import { getStorage } from '../storage/index.js';
import { getRunPhaseAuthorityService } from './run-phase-authority-service.js';
interface DirectoryStats {
bytes: number;
@ -40,6 +44,16 @@ interface LogSourceDefinition {
}
const MAX_TAIL_LINES = 500;
const MAX_PHASE_DIAGNOSTIC_RUNS = 200;
interface PhaseAuthorityDiagnosticExport {
generatedAt: string;
status: 'ok' | 'unavailable';
truncated: boolean;
records: Array<Record<string, unknown>>;
}
type PhaseAuthorityDiagnosticCollector = () => Promise<PhaseAuthorityDiagnosticExport>;
const MAINTENANCE_CONTENT_REDACTIONS: [RegExp, string][] = [
[
@ -61,6 +75,10 @@ const MAINTENANCE_CONTENT_REDACTIONS: [RegExp, string][] = [
];
export class MaintenanceService {
constructor(
private readonly collectPhaseAuthority: PhaseAuthorityDiagnosticCollector = collectPhaseAuthorityDiagnostics
) {}
async buildSummary(): Promise<MaintenanceSummary> {
const generatedAt = new Date().toISOString();
const sqlite = getSqliteStorageDiagnostics();
@ -242,6 +260,7 @@ export class MaintenanceService {
await fs.mkdir(path.join(bundleDir, 'logs'), { recursive: true });
const summary = await this.buildSummary();
const phaseAuthority = await this.collectPhaseAuthority();
const logTails: MaintenanceLogTail[] = [];
for (const source of summary.logs.filter((entry) => entry.exists)) {
const tail = await this.tailLog(source.id, 200);
@ -254,7 +273,14 @@ export class MaintenanceService {
}
const manifest: MaintenanceDebugBundle['manifest'] = {
includedCategories: ['health', 'storage', 'lifecycle', 'work-products', 'redacted-log-tails'],
includedCategories: [
'health',
'storage',
'lifecycle',
'work-products',
'phase-authority',
'redacted-log-tails',
],
excludedCategories: [
'raw tokens',
'token hashes',
@ -277,6 +303,11 @@ export class MaintenanceService {
JSON.stringify(this.redactMaintenanceValue(summary), null, 2),
'utf-8'
);
await fs.writeFile(
path.join(bundleDir, 'phase-authority.json'),
JSON.stringify(this.redactMaintenanceValue(phaseAuthority), null, 2),
'utf-8'
);
await fs.writeFile(
path.join(bundleDir, 'manifest.json'),
JSON.stringify(manifest, null, 2),
@ -627,6 +658,113 @@ export class MaintenanceService {
}
}
async function collectPhaseAuthorityDiagnostics(): Promise<PhaseAuthorityDiagnosticExport> {
const generatedAt = new Date().toISOString();
let tasks;
try {
tasks = await getStorage().tasks.findAll();
} catch {
return { generatedAt, status: 'unavailable', truncated: false, records: [] };
}
const candidates = tasks.flatMap((task) => {
const attempts = [...(task.attempts ?? []), ...(task.attempt ? [task.attempt] : [])];
const unique = new Map(attempts.map((attempt) => [attempt.id, attempt]));
return [...unique.values()]
.filter((attempt) => attempt.runLaunchManifest?.phase)
.map((attempt) => ({ task, attempt }));
});
const selected = candidates.slice(0, MAX_PHASE_DIAGNOSTIC_RUNS);
const phaseAuthority = getRunPhaseAuthorityService();
const records = await Promise.all(
selected.map(async ({ task, attempt }) => {
try {
const snapshot = await phaseAuthority.get('local', task.id, attempt.id, 100);
if (!snapshot) return { taskId: task.id, attemptId: attempt.id, status: 'legacy' };
return {
taskId: task.id,
attemptId: attempt.id,
provider: attempt.provider,
status: 'available',
launch: phaseEvidenceDiagnostic(snapshot.launch.evidence),
effective: phaseEvidenceDiagnostic(snapshot.effectiveEvidence),
transitionSequence: snapshot.transitionSequence,
sources: snapshot.launch.sourceReferences.map((source) => ({
kind: source.kind,
originScope: source.originScope,
digest: digestFingerprint(source.sourceDigest),
})),
authorityExpansions: snapshot.history
.filter((transition) =>
transition.authorityDelta.entries.some((entry) => entry.addedScopes.length > 0)
)
.map((transition) => ({
sequence: transition.sequence,
policyDecision: transition.policyDecision,
dimensions: transition.authorityDelta.entries
.filter((entry) => entry.addedScopes.length > 0)
.map((entry) => ({
dimension: entry.dimension,
addedScopeCount: entry.addedScopes.length,
})),
...(transition.emergencyOverride
? { overrideExpiresAt: transition.emergencyOverride.expiresAt }
: {}),
})),
...(attempt.completionResult?.phase
? {
completion: {
launchEvidenceDigest: digestFingerprint(
attempt.completionResult.phase.launchEvidenceDigest
),
effective: phaseEvidenceDiagnostic(
attempt.completionResult.phase.effectiveEvidence
),
transitionSequence: attempt.completionResult.phase.transitionSequence,
authorityExpansionCount:
attempt.completionResult.phase.authorityExpansions.length,
},
}
: {}),
};
} catch {
return { taskId: task.id, attemptId: attempt.id, status: 'unavailable' };
}
})
);
return {
generatedAt,
status: 'ok',
truncated: candidates.length > selected.length,
records,
};
}
function phaseEvidenceDiagnostic(evidence: PhaseCapabilityEvidence) {
return {
identity: evidence.identity,
status: evidence.status,
digest: digestFingerprint(evidence.digest),
authority: Object.fromEntries(
PHASE_AUTHORITY_DIMENSIONS.map((dimension) => [
dimension,
{
scopeCount: evidence.effectiveAuthority[dimension].length,
wildcard: evidence.effectiveAuthority[dimension].includes('*'),
},
])
),
blockers: evidence.blockers.map((blocker) => ({
code: blocker.code,
dimension: blocker.dimension,
})),
};
}
function digestFingerprint(digest: string): string {
return digest.slice(0, 19);
}
let singleton: MaintenanceService | null = null;
export function getMaintenanceService(): MaintenanceService {

View file

@ -297,7 +297,7 @@ function toolCatalogSource(
): PhaseCapabilityCompilerSources['toolCatalog'] {
const configured = input.executionEnforcement;
return {
id: input.toolCatalogId ? `tool-catalog:${safeId(input.toolCatalogId)}` : 'tool-catalog:none',
id: input.toolCatalogId ? 'tool-catalog:run' : 'tool-catalog:none',
kind: 'tool-catalog',
authority: cloneAuthority(WILDCARD_AUTHORITY),
enforcement: {

View file

@ -2,6 +2,7 @@ import {
COMPLETION_RESULT_SCHEMA_VERSION,
TASK_ENVELOPE_SCHEMA_VERSION,
type CompletionResult,
type CompletionPhaseAuthorityEvidence,
type Task,
type TaskCompletionArtifact,
type TaskCompletionBlocker,
@ -85,6 +86,7 @@ export interface CompleteProviderRunInput {
task: Task;
taskEnvelope: TaskEnvelope;
claim: ProviderTerminalClaim;
phase?: CompletionPhaseAuthorityEvidence;
}
type TerminalEvidenceSource = Pick<CompletionEvidenceSource, 'captureCompletionEvidence'>;
@ -197,6 +199,7 @@ export class ProviderCompletionService {
: sideEffect
),
continuation: claim.continuation,
...(input.phase ? { phase: structuredClone(input.phase) } : {}),
};
const result = parseCompletionResultForEnvelope(
{

View file

@ -8,6 +8,7 @@ import {
type RunApprovalListQuery,
type RunApprovalRequest,
type RunApprovalRequestKind,
type RunApprovalPhaseBinding,
type RunApprovalRiskClass,
type RunEventJsonValue,
} from '@veritas-kanban/shared';
@ -25,6 +26,7 @@ import { FileRunApprovalRepository } from '../storage/run-approval-repository.js
import { getStorage, getStorageTypeFromEnv } from '../storage/index.js';
import { broadcastRunApprovalChange } from './broadcast-service.js';
import { RunEventJournalService } from './run-event-journal-service.js';
import type { RunPhaseAuthorityService } from './run-phase-authority-service.js';
const DEFAULT_APPROVAL_TTL_MS = 5 * 60 * 1_000;
const MIN_APPROVAL_TTL_MS = 1_000;
@ -58,6 +60,7 @@ export interface CreateRunApprovalRequestInput {
turnId?: string;
itemId?: string;
mobileSafe?: boolean;
phase?: RunApprovalPhaseBinding;
ttlMs?: number;
}
@ -76,6 +79,7 @@ export interface RunApprovalBrokerServiceOptions {
journal?: RunEventJournalService;
now?: () => Date;
broadcast?: (request: RunApprovalRequest) => void;
phaseAuthority?: Pick<RunPhaseAuthorityService, 'getActive' | 'assertScopes'>;
}
let fileRepository: FileRunApprovalRepository | undefined;
@ -124,6 +128,7 @@ export class RunApprovalBrokerService {
policyReason: input.policyReason,
evidenceRevision: input.evidenceRevision,
mobileSafe: input.mobileSafe ?? false,
phase: input.phase,
exactAction,
})
)
@ -172,6 +177,7 @@ export class RunApprovalBrokerService {
turnId: input.turnId,
itemId: input.itemId,
mobileSafe: input.mobileSafe ?? false,
phase: input.phase,
status: 'pending',
revision: 1,
createdAt: now.toISOString(),
@ -253,6 +259,34 @@ export class RunApprovalBrokerService {
actionClass: current.actionClass,
});
}
if (input.decision === 'approved' && current.phase) {
const phaseAuthority =
this.options.phaseAuthority ??
(await import('./run-phase-authority-service.js')).getRunPhaseAuthorityService();
const active = await phaseAuthority.getActive(
current.workspaceId,
current.taskId,
current.attemptId,
1
);
if (
!active ||
active.manifestDigest !== current.phase.manifestDigest ||
active.effectiveEvidence.digest !== current.phase.evidenceDigest ||
active.transitionSequence !== current.phase.transitionSequence
) {
throw new ConflictError('Run approval phase evidence is stale.', {
approvalId: current.id,
expectedPhaseEvidenceDigest: current.phase.evidenceDigest,
activePhaseEvidenceDigest: active?.effectiveEvidence.digest,
expectedTransitionSequence: current.phase.transitionSequence,
activeTransitionSequence: active?.transitionSequence,
});
}
for (const requirement of current.phase.requirements) {
phaseAuthority.assertScopes(active, requirement.dimension, requirement.requestedScopes);
}
}
const decidedAt = this.now().toISOString();
const persistedResponse = input.responseData ? { _provided: true } : undefined;

View file

@ -0,0 +1,166 @@
import {
type PhaseAuthorityDimension,
type RunApprovalPhaseBinding,
type RunPhaseAuthoritySnapshot,
type Task,
type TaskAttempt,
} from '@veritas-kanban/shared';
import { ConflictError, ForbiddenError, NotFoundError } from '../middleware/error-handler.js';
import type { TaskRepository } from '../storage/interfaces.js';
import { getStorage } from '../storage/index.js';
import { verifyPhaseCapabilityEvidenceDigest } from './phase-capability-service.js';
import {
getPhaseTransitionService,
type PhaseTransitionService,
} from './phase-transition-service.js';
export interface RunPhaseAuthorityServiceOptions {
tasks?: Pick<TaskRepository, 'findById'>;
transitions?: Pick<PhaseTransitionService, 'getCurrent' | 'list'>;
}
/**
* Resolves the one server-owned phase projection used by execution and reader
* surfaces. It never infers a phase for legacy attempts.
*/
export class RunPhaseAuthorityService {
constructor(private readonly options: RunPhaseAuthorityServiceOptions = {}) {}
async get(
workspaceId: string,
taskId: string,
attemptId: string,
historyLimit = 100
): Promise<RunPhaseAuthoritySnapshot | null> {
const task = await (this.options.tasks ?? getStorage().tasks).findById(taskId);
if (!task) throw new NotFoundError('Task not found.');
return this.project(workspaceId, task, attemptId, historyLimit);
}
async getActive(
workspaceId: string,
taskId: string,
attemptId: string,
historyLimit = 100
): Promise<RunPhaseAuthoritySnapshot | null> {
const task = await (this.options.tasks ?? getStorage().tasks).findById(taskId);
if (!task) throw new NotFoundError('Task not found.');
if (task.attempt?.id !== attemptId || task.attempt.status !== 'running') {
throw new ConflictError('Phase authority does not match the active running attempt.', {
taskId,
attemptId,
activeAttemptId: task.attempt?.id,
activeStatus: task.attempt?.status,
});
}
return this.project(workspaceId, task, attemptId, historyLimit);
}
private async project(
workspaceId: string,
task: Task,
attemptId: string,
historyLimit: number
): Promise<RunPhaseAuthoritySnapshot | null> {
const taskId = task.id;
const attempt = findAttempt(task.attempt, task.attempts, attemptId);
if (!attempt) throw new NotFoundError('Run attempt not found.');
const manifest = attempt.runLaunchManifest;
if (!manifest?.phase) return null;
if (!verifyPhaseCapabilityEvidenceDigest(manifest.phase.evidence)) {
throw new ConflictError('Launch phase evidence failed integrity validation.', {
taskId,
attemptId,
manifestDigest: manifest.digest,
});
}
const transitions = this.options.transitions ?? getPhaseTransitionService();
const current = await transitions.getCurrent(workspaceId, taskId, attemptId);
const history = await transitions.list(workspaceId, taskId, attemptId, historyLimit);
if (current?.manifestDigest && current.manifestDigest !== manifest.digest) {
throw new ConflictError('Active phase transition does not match launch evidence.', {
taskId,
attemptId,
transitionManifestDigest: current.manifestDigest,
launchManifestDigest: manifest.digest,
});
}
const effectiveEvidence = current?.effectiveEvidence ?? manifest.phase.evidence;
if (!verifyPhaseCapabilityEvidenceDigest(effectiveEvidence)) {
throw new ConflictError('Effective phase evidence failed integrity validation.', {
taskId,
attemptId,
evidenceDigest: effectiveEvidence.digest,
});
}
return {
taskId,
attemptId,
manifestDigest: manifest.digest,
launch: structuredClone(manifest.phase),
effectiveEvidence: structuredClone(effectiveEvidence),
transitionSequence: current?.sequence ?? 0,
current: current ? structuredClone(current) : null,
history: structuredClone(history),
};
}
assertScopes(
snapshot: RunPhaseAuthoritySnapshot,
dimension: PhaseAuthorityDimension,
requestedScopes: string[]
): void {
const requested = [...new Set(requestedScopes)].sort();
const allowed = snapshot.effectiveEvidence.effectiveAuthority[dimension];
const denied = requested.filter((scope) => !allowed.includes('*') && !allowed.includes(scope));
if (requested.length === 0 || denied.length > 0) {
throw new ForbiddenError('Active phase authority denies this action.', {
phase: snapshot.effectiveEvidence.identity,
phaseEvidenceDigest: snapshot.effectiveEvidence.digest,
dimension,
requestedScopes: requested,
effectiveScopes: allowed,
deniedScopes: denied,
});
}
}
binding(
snapshot: RunPhaseAuthoritySnapshot,
requirements: Array<{
dimension: PhaseAuthorityDimension;
requestedScopes: string[];
}>
): RunApprovalPhaseBinding {
for (const requirement of requirements) {
this.assertScopes(snapshot, requirement.dimension, requirement.requestedScopes);
}
return {
evidenceDigest: snapshot.effectiveEvidence.digest,
manifestDigest: snapshot.manifestDigest,
identity: structuredClone(snapshot.effectiveEvidence.identity),
transitionSequence: snapshot.transitionSequence,
requirements: requirements.map((requirement) => ({
dimension: requirement.dimension,
requestedScopes: [...new Set(requirement.requestedScopes)].sort(),
})),
};
}
}
function findAttempt(
current: TaskAttempt | undefined,
history: TaskAttempt[] | undefined,
attemptId: string
): TaskAttempt | undefined {
if (current?.id === attemptId) return current;
return history?.find((attempt) => attempt.id === attemptId);
}
let singleton: RunPhaseAuthorityService | undefined;
export function getRunPhaseAuthorityService(): RunPhaseAuthorityService {
singleton ??= new RunPhaseAuthorityService();
return singleton;
}

View file

@ -10,6 +10,7 @@ import {
type AcpMcpServer,
type CredentialAction,
type CredentialDefinition,
type PhaseCapabilityEvidence,
type RunToolCatalog,
type RunToolCatalogEntry,
type RunToolCredentialBinding,
@ -51,6 +52,10 @@ import {
getCredentialBrokerService,
type CredentialBrokerService,
} from './credential-broker-service.js';
import {
getRunPhaseAuthorityService,
type RunPhaseAuthorityService,
} from './run-phase-authority-service.js';
const MCP_PROTOCOL_VERSION = '2025-06-18';
const MAX_RPC_BYTES = 4 * 1024 * 1024;
@ -92,6 +97,7 @@ export interface PrepareRunToolCatalogInput {
deniedTools?: string[];
cwd?: string;
persist?: boolean;
phaseEvidence?: PhaseCapabilityEvidence;
}
export interface ToolControlPlaneServiceOptions {
@ -105,6 +111,7 @@ export interface ToolControlPlaneServiceOptions {
>;
now?: () => Date;
environment?: NodeJS.ProcessEnv;
phaseAuthority?: Pick<RunPhaseAuthorityService, 'getActive' | 'assertScopes' | 'binding'>;
}
let fileRepository: FileToolControlPlaneRepository | undefined;
@ -127,6 +134,10 @@ export class ToolControlPlaneService {
>;
private readonly now: () => Date;
private readonly environment: NodeJS.ProcessEnv;
private readonly phaseAuthority: Pick<
RunPhaseAuthorityService,
'getActive' | 'assertScopes' | 'binding'
>;
private readonly sessions = new Map<string, Promise<RpcSession>>();
private readonly validators = new Map<string, ValidateFunction>();
@ -138,6 +149,7 @@ export class ToolControlPlaneService {
this.approvals = options.approvals ?? new RunApprovalBrokerService();
this.credentialBroker = options.credentialBroker ?? getCredentialBrokerService();
this.now = options.now ?? (() => new Date());
this.phaseAuthority = options.phaseAuthority ?? getRunPhaseAuthorityService();
}
async listDefinitions(): Promise<ToolServerDefinition[]> {
@ -312,12 +324,16 @@ export class ToolControlPlaneService {
denied.has(qualifiedName);
const approval =
definition.approvalMode === 'always' ||
matchesTool(definition.approvalRequiredTools, tool.name, qualifiedName);
matchesTool(definition.approvalRequiredTools, tool.name, qualifiedName) ||
input.phaseEvidence?.approvalRequiredDimensions.includes('external.action') === true;
const phaseAllowsAction =
!input.phaseEvidence ||
authorityAllows(input.phaseEvidence, 'external.action', tool.externalAction ?? 'mutate');
return {
...tool,
qualifiedName,
decision:
!definitionAllows || !runAllows || isDenied
!definitionAllows || !runAllows || isDenied || !phaseAllowsAction
? ('deny' as const)
: approval
? ('approval' as const)
@ -344,6 +360,22 @@ export class ToolControlPlaneService {
entries.push(degradedEntry(definition, message, discovery));
continue;
}
if (input.phaseEvidence && credentialBindings.length > 0) {
const credentialAllowed = credentialBindings.every((binding) =>
authorityAllows(
input.phaseEvidence as PhaseCapabilityEvidence,
'credential.access',
binding.credentialReference
)
);
const credentialApproval =
input.phaseEvidence.approvalRequiredDimensions.includes('credential.access');
for (const tool of catalogTools) {
if (tool.decision === 'deny') continue;
if (!credentialAllowed) tool.decision = 'deny';
else if (credentialApproval) tool.decision = 'approval';
}
}
entries.push({
serverId: definition.id,
serverVersion: definition.version,
@ -364,6 +396,7 @@ export class ToolControlPlaneService {
provider: input.provider,
providerRuntimeManifestDigest: input.providerRuntimeManifestDigest,
taskEnvelopeDigest: input.taskEnvelopeDigest,
...(input.phaseEvidence ? { phaseEvidenceDigest: input.phaseEvidence.digest } : {}),
entries,
createdAt: this.now().toISOString(),
digest: 'sha256:'.padEnd(71, '0'),
@ -439,9 +472,52 @@ export class ToolControlPlaneService {
: undefined;
const credentialApprovalRequired =
credentialBound && (await this.credentialApprovalRequired(entry));
const phaseSnapshot = catalog.phaseEvidenceDigest
? await this.phaseAuthority.getActive('local', request.taskId, request.attemptId, 1)
: null;
if (
catalog.phaseEvidenceDigest &&
(!phaseSnapshot || phaseSnapshot.launch.evidence.digest !== catalog.phaseEvidenceDigest)
) {
throw new ConflictError('Run tool catalog phase evidence does not match this attempt.', {
catalogPhaseEvidenceDigest: catalog.phaseEvidenceDigest,
launchPhaseEvidenceDigest: phaseSnapshot?.launch.evidence.digest,
});
}
const phaseRequirements = phaseSnapshot
? [
{
dimension: 'external.action' as const,
requestedScopes: [tool.externalAction ?? 'mutate'],
},
...((entry.credentialBindings ?? []).length > 0
? [
{
dimension: 'credential.access' as const,
requestedScopes: [
...new Set(
(entry.credentialBindings ?? []).map((binding) => binding.credentialReference)
),
].sort(),
},
]
: []),
]
: [];
for (const requirement of phaseRequirements) {
this.phaseAuthority.assertScopes(
phaseSnapshot as NonNullable<typeof phaseSnapshot>,
requirement.dimension,
requirement.requestedScopes
);
}
const phaseApprovalRequired =
phaseSnapshot?.effectiveEvidence.approvalRequiredDimensions.some((dimension) =>
phaseRequirements.some((requirement) => requirement.dimension === dimension)
) === true;
let approvedRequestId: string | undefined;
if (tool.decision === 'approval' || credentialApprovalRequired) {
if (tool.decision === 'approval' || credentialApprovalRequired || phaseApprovalRequired) {
const providerRequestId = credentialActionFingerprint
? `tool:${request.operationId}:${credentialActionFingerprint}`
: `tool:${request.operationId}`;
@ -465,6 +541,9 @@ export class ToolControlPlaneService {
evidenceRevision: catalog.digest,
providerRequestId,
mobileSafe: false,
...(phaseSnapshot
? { phase: this.phaseAuthority.binding(phaseSnapshot, phaseRequirements) }
: {}),
});
if (request.approvalId && request.approvalId !== approval.id) {
throw new ConflictError('Approval identity does not match this exact tool call.');
@ -1494,6 +1573,10 @@ function normalizeDiscoveredTool(tool: Record<string, unknown>) {
: {};
assertBoundedJson(inputSchema, MAX_SCHEMA_BYTES, `Input schema for ${name}`);
assertSafeSchema(inputSchema, `Input schema for ${name}`);
const annotations =
tool.annotations && typeof tool.annotations === 'object' && !Array.isArray(tool.annotations)
? (tool.annotations as Record<string, unknown>)
: undefined;
return {
name,
...(typeof tool.description === 'string'
@ -1501,6 +1584,7 @@ function normalizeDiscoveredTool(tool: Record<string, unknown>) {
: {}),
inputSchema,
inputSchemaDigest: calculateSchemaDigest(inputSchema),
externalAction: annotations?.readOnlyHint === true ? ('read' as const) : ('mutate' as const),
};
}
@ -1576,6 +1660,15 @@ function matchesTool(list: string[], name: string, qualifiedName: string): boole
return list.includes('*') || list.includes(name) || list.includes(qualifiedName);
}
function authorityAllows(
evidence: PhaseCapabilityEvidence,
dimension: 'external.action' | 'credential.access',
scope: string
): boolean {
const allowed = evidence.effectiveAuthority[dimension];
return allowed.includes('*') || allowed.includes(scope);
}
function minimalProcessEnvironment(
source: NodeJS.ProcessEnv,
selectedKeys: string[]

View file

@ -252,3 +252,39 @@ export interface PhaseTransitionResult {
approval?: RunApprovalRequest;
targetEvidenceDigest: string;
}
/**
* Server-owned projection used by run detail, approvals, completion evidence,
* and diagnostic exports. The launch snapshot remains immutable while
* `effectiveEvidence` follows the append-only transition journal.
*/
export interface RunPhaseAuthoritySnapshot {
taskId: string;
attemptId: string;
manifestDigest: string;
launch: import('./run-launch-manifest.types.js').RunLaunchPhaseAuthority;
effectiveEvidence: PhaseCapabilityEvidence;
transitionSequence: number;
current: PhaseTransitionRecord | null;
history: PhaseTransitionRecord[];
}
/** Exact active phase snapshot bound into a durable approval request. */
export interface RunApprovalPhaseBinding {
evidenceDigest: string;
manifestDigest: string;
identity: PhaseIdentity;
transitionSequence: number;
requirements: Array<{
dimension: PhaseAuthorityDimension;
requestedScopes: string[];
}>;
}
/** Phase evidence preserved with the normalized provider completion result. */
export interface CompletionPhaseAuthorityEvidence {
launchEvidenceDigest: string;
effectiveEvidence: PhaseCapabilityEvidence;
transitionSequence: number;
authorityExpansions: PhaseTransitionRecord[];
}

View file

@ -60,6 +60,8 @@ export interface RunApprovalRequest {
turnId?: string;
itemId?: string;
mobileSafe: boolean;
/** Present when approval authority is constrained by an active run phase. */
phase?: import('./phase-capability.types.js').RunApprovalPhaseBinding;
status: RunApprovalStatus;
revision: number;
createdAt: string;

View file

@ -258,4 +258,6 @@ export interface CompletionResult {
verification: TaskCompletionVerification[];
sideEffects: TaskCompletionSideEffect[];
continuation: TaskContinuationHandle | null;
/** Present for phase-controlled runs compiled after phase enforcement shipped. */
phase?: import('./phase-capability.types.js').CompletionPhaseAuthorityEvidence;
}

View file

@ -62,6 +62,11 @@ export interface ToolDiscoveryEntry {
description?: string;
inputSchema: Record<string, unknown>;
inputSchemaDigest: string;
/**
* MCP readOnlyHint normalized into a fail-closed external-action class.
* Missing, false, or malformed annotations are treated as mutation.
*/
externalAction?: 'read' | 'mutate';
}
export interface ToolServerDiscovery {
@ -110,6 +115,7 @@ export interface RunToolCatalogEntry {
description?: string;
inputSchema: Record<string, unknown>;
inputSchemaDigest: string;
externalAction?: 'read' | 'mutate';
decision: RunToolPolicyDecision;
}>;
error?: string;
@ -128,6 +134,8 @@ export interface RunToolCatalog {
provider: ExecutableAgentProvider;
providerRuntimeManifestDigest: string;
taskEnvelopeDigest: string;
/** Launch phase evidence used to filter this immutable catalog. */
phaseEvidenceDigest?: string;
entries: RunToolCatalogEntry[];
createdAt: string;
digest: string;

View file

@ -22,6 +22,7 @@ const mocks = vi.hoisted(() => ({
useAgentRunTraces: vi.fn(),
useActiveRuns: vi.fn(),
usePendingAgentApprovals: vi.fn(),
useAgentPhase: vi.fn(),
decideApprovalMutateAsync: vi.fn(),
useRecentRuns: vi.fn(),
useTaskTelemetryEvents: vi.fn(),
@ -36,6 +37,7 @@ vi.mock('@/hooks/useAgentRunTimeline', () => ({
vi.mock('@/hooks/useAgent', () => ({
usePendingAgentApprovals: mocks.usePendingAgentApprovals,
useAgentPhase: mocks.useAgentPhase,
useDecideRunApproval: () => ({
mutateAsync: mocks.decideApprovalMutateAsync,
isPending: false,
@ -338,6 +340,7 @@ describe('agent run timeline Mantine surface', () => {
mocks.useAgentRunTraces.mockReturnValue({ data: [trace], isLoading: false });
mocks.useActiveRuns.mockReturnValue({ data: [workflowRun], isLoading: false });
mocks.usePendingAgentApprovals.mockReturnValue({ data: [approval], isLoading: false });
mocks.useAgentPhase.mockReturnValue({ data: null, isLoading: false });
mocks.useRecentRuns.mockReturnValue({ data: [], isLoading: false });
mocks.useTaskTelemetryEvents.mockReturnValue({ data: telemetryEvents, isLoading: false });
mocks.useTaskNotifications.mockReturnValue({ data: [notification], isLoading: false });
@ -502,6 +505,60 @@ describe('agent run timeline Mantine surface', () => {
expect(highlighted.querySelector('[data-highlighted="true"]')).toBeDefined();
});
it('renders the server-owned phase and authority sources', () => {
mocks.useAgentPhase.mockReturnValue({
data: {
effectiveEvidence: {
identity: {
mode: 'profile',
phase: 'implement',
profileId: 'builtin-implement',
profileVersion: 1,
},
status: 'allowed',
digest: `sha256:${'1'.repeat(64)}`,
blockers: [],
},
transitionSequence: 2,
current: {
emergencyOverride: {
expiresAt: '2026-07-26T00:00:00.000Z',
},
},
history: [],
launch: {
sourceReferences: [
{
kind: 'parent',
originScope: 'parent',
sourceDigest: `sha256:${'2'.repeat(64)}`,
},
{
kind: 'agent-profile',
originScope: 'agent-profile',
sourceDigest: `sha256:${'4'.repeat(64)}`,
},
{
kind: 'sandbox',
originScope: 'run',
sourceDigest: `sha256:${'3'.repeat(64)}`,
},
],
},
},
isLoading: false,
});
renderWithProviders(<AgentRunTimelinePanel task={task} />);
expect(screen.getByText('Effective phase authority')).toBeDefined();
expect(screen.getByText('implement')).toBeDefined();
expect(screen.getByText('inherited: parent')).toBeDefined();
expect(screen.getByText('profile: agent-profile')).toBeDefined();
expect(screen.getByText('sandbox: run')).toBeDefined();
expect(screen.getByText(/Override until/)).toBeDefined();
});
it('pages long timelines so replay rendering stays bounded', async () => {
const user = userEvent.setup();
const longTrace: AgentRunTrace = {

View file

@ -47,6 +47,7 @@ import type {
} from '@veritas-kanban/shared';
import {
useDecideRunApproval,
useAgentPhase,
usePendingAgentApprovals,
type AgentApprovalRequest,
} from '@/hooks/useAgent';
@ -112,6 +113,12 @@ const SOURCE_COLORS: Record<AgentRunTimelineEventSource, string> = {
stored: 'green',
};
function phaseSourceLabel(kind: string): string {
if (kind === 'parent') return 'inherited';
if (kind === 'agent-profile') return 'profile';
return kind;
}
const EVENT_ICONS: Record<AgentRunTimelineEventType, React.ElementType> = {
approval: CheckCircle2,
command: Terminal,
@ -1069,6 +1076,11 @@ export function AgentRunTimelinePanel({
const [selectedAttemptId, setSelectedAttemptId] = useState<string | null>(
initialAttemptId ?? task.attempt?.id ?? null
);
const { data: phase, isLoading: phaseLoading } = useAgentPhase(
task.id,
selectedAttemptId ?? undefined,
hasLiveAttempt && selectedAttemptId === task.attempt?.id
);
const [filter, setFilter] = useState<AgentRunTimelineEventType | 'all'>('all');
const [visibleCount, setVisibleCount] = useState(TIMELINE_PAGE_SIZE);
const highlightedEventRef = useRef<HTMLDivElement | null>(null);
@ -1151,8 +1163,10 @@ export function AgentRunTimelinePanel({
workProductsLoading ||
notificationsLoading ||
approvalsLoading ||
phaseLoading ||
activeRunsLoading ||
recentRunsLoading;
const phaseIdentity = phase?.effectiveEvidence.identity;
const source = sourceForTrace(selectedTrace);
const linkedWorkProducts = workProducts.filter(
(product) => !selectedAttemptId || product.sourceRunId === selectedAttemptId
@ -1184,6 +1198,58 @@ export function AgentRunTimelinePanel({
return (
<Stack gap="md">
{phase && (
<Paper withBorder p="md" radius="md">
<Stack gap="sm">
<Group justify="space-between" align="flex-start">
<div>
<Group gap="xs">
<ShieldCheck className="h-4 w-4" />
<Text fw={700}>Effective phase authority</Text>
<Badge color={phase.effectiveEvidence.status === 'blocked' ? 'red' : 'blue'}>
{phaseIdentity?.mode === 'profile' ? phaseIdentity.phase : 'legacy'}
</Badge>
</Group>
<Text size="xs" c="dimmed" mt={4}>
Sequence {phase.transitionSequence} · evidence{' '}
{phase.effectiveEvidence.digest.slice(0, 19)}
</Text>
</div>
{phase.current?.emergencyOverride && (
<Badge color="red" variant="light">
Override until{' '}
{new Date(phase.current.emergencyOverride.expiresAt).toLocaleString()}
</Badge>
)}
</Group>
<Group gap="xs" wrap="wrap">
{phase.launch.sourceReferences.map((reference) => (
<Badge key={`${reference.kind}:${reference.sourceDigest}`} variant="outline">
{phaseSourceLabel(reference.kind)}: {reference.originScope}
</Badge>
))}
</Group>
{phase.effectiveEvidence.blockers.map((blocker) => (
<Text key={`${blocker.code}:${blocker.dimension ?? ''}`} size="sm" c="red">
{blocker.code}: {blocker.message}
</Text>
))}
{phase.history.length > 0 && (
<Stack gap={4}>
<Text size="sm" fw={600}>
Transition history
</Text>
{phase.history.slice(0, 8).map((transition) => (
<Text key={transition.id} size="xs" c="dimmed">
#{transition.sequence} {transition.policyDecision} by{' '}
{transition.actor.label ?? transition.actor.id}: {transition.reason}
</Text>
))}
</Stack>
)}
</Stack>
</Paper>
)}
{pendingTaskApprovals.map((approval) => (
<Paper key={approval.id} withBorder p="md" radius="md">
<Stack gap="xs">
@ -1203,6 +1269,16 @@ export function AgentRunTimelinePanel({
Bound action {approval.actionHash.slice(0, 12)} · expires{' '}
{new Date(approval.expiresAt).toLocaleString()}
</Text>
{approval.phase && (
<Text size="xs" c="dimmed">
Phase{' '}
{approval.phase.identity.mode === 'profile'
? approval.phase.identity.phase
: 'legacy'}{' '}
· sequence {approval.phase.transitionSequence} ·{' '}
{approval.phase.requirements.map((requirement) => requirement.dimension).join(', ')}
</Text>
)}
<Group justify="flex-end">
<Button
color="red"
@ -1251,6 +1327,17 @@ export function AgentRunTimelinePanel({
{pendingDecision.approval.policyReason && (
<Text size="sm">Policy reason: {pendingDecision.approval.policyReason}</Text>
)}
{pendingDecision.approval.phase && (
<Text size="sm">
Phase-bound authority: sequence {pendingDecision.approval.phase.transitionSequence},{' '}
{pendingDecision.approval.phase.requirements
.map(
(requirement) =>
`${requirement.dimension}=${requirement.requestedScopes.join('|')}`
)
.join(', ')}
</Text>
)}
<Code block>{pendingDecision.approval.actionHash}</Code>
{decideApproval.error && (
<Text size="sm" c="red">

View file

@ -12,6 +12,7 @@ import type {
ProviderRuntimeCapabilityId,
RunApprovalDecisionInput,
RunApprovalRequest,
RunPhaseAuthoritySnapshot,
TaskCommitPolicy,
} from '@veritas-kanban/shared';
import type { ConversationTurnRequest } from '@/lib/api/agent';
@ -183,6 +184,22 @@ export function useAgentAttempts(taskId: string | undefined) {
});
}
export function useAgentPhase(
taskId: string | undefined,
attemptId: string | undefined,
live = false
) {
return useQuery({
queryKey: ['agent', 'phase', taskId, attemptId],
queryFn: () =>
apiFetch<{ phase: RunPhaseAuthoritySnapshot | null }>(
`${API_BASE}/agents/${encodeURIComponent(requiredQueryParam(taskId, 'taskId'))}/phase?attemptId=${encodeURIComponent(requiredQueryParam(attemptId, 'attemptId'))}`
).then((result) => result.phase),
enabled: !!taskId && !!attemptId,
refetchInterval: live ? 2_000 : false,
});
}
export function useAgentLog(taskId: string | undefined, attemptId: string | undefined) {
return useQuery({
queryKey: ['agent', 'log', taskId, attemptId],