mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
feat: enforce active phase authority
This commit is contained in:
parent
4f8e5dc14f
commit
c59a400406
35 changed files with 1587 additions and 111 deletions
|
|
@ -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
|
||||
|
|
|
|||
14
CHANGELOG.md
14
CHANGELOG.md
|
|
@ -9,6 +9,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### Added
|
||||
|
||||
- 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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
125
server/src/__tests__/run-phase-authority-service.test.ts
Normal file
125
server/src/__tests__/run-phase-authority-service.test.ts
Normal 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>;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 ?? [],
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
166
server/src/services/run-phase-authority-service.ts
Normal file
166
server/src/services/run-phase-authority-service.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue