feat(rvforge-reader): ADR-295 Agent Dock — typed trust boundary, states, roster

Trust boundary enforced structurally: AgentProvidedStatus (sanitized
task text + progress only) composed separately from SystemOwnedStatus
(state, trust badge, network, permissions, witness, cost) — agent input
cannot reach system fields by construction. Sanitizer strips ANSI/
control chars, caps length, flags system-label mimicry as suspicious.
8-state machine (pause/terminate always one action; quarantine/
capability-denied not agent-exitable), attention-priority roster
(approval > denial > error > running), D8 event thresholds, pill +
expanded UI with visually distinct system chrome. 90 reader tests green.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01ParP55bZs2iTGEGvpnUecx
This commit is contained in:
ruvnet 2026-08-03 21:43:35 -04:00
parent 67a4ac241d
commit 75bb55b611
15 changed files with 3621 additions and 4 deletions

View file

@ -13,12 +13,17 @@ src/inspect.rs read a package without executing it (STUB)
src/capability.rs derive the P6 install-time capability contract
src/runtime.rs apply the FR004 runtime selection order
src/state.rs ADR-288 encrypted state-capsule layout (encryption STUB)
src/dock.rs ADR-295 dock entry: the agent/system split, in the types
src/dock_text.rs sanitizing and screening agent-authored strings
src/dock_state.rs the eight dock states and who may move between them
src/dock_roster.rs multi-agent policy and the two-interaction control path
src/dock_events.rs D8 event thresholds — what may interrupt
src/commands.rs Tauri command wrappers (feature `desktop`)
src/lib.rs module root + the security invariants this crate holds
ui/ three screens, plain HTML/CSS/JS, no build step
ui/ four screens, plain HTML/CSS/JS, no build step
assets/ vendored copy of compatibility-matrix.json
capabilities/ Tauri v2 ACL: file dialog only
tests/ runtime selection, capability card, inspection, state
tests/ runtime selection, capability card, inspection, state, dock
```
## Build and test
@ -35,7 +40,7 @@ tests with only `serde` and `serde_json`:
```bash
cd crates/rvforge-reader
cargo check # core only — no webview packages needed
cargo test # 39 tests, no Tauri dependency
cargo test # 90 tests, no Tauri dependency
```
This is the CI-testable path. Building the desktop shell additionally needs the
@ -64,7 +69,9 @@ there is no `.ico` or `.icns` yet, which Windows and macOS bundling require.
| `inspect::capability_card` | Reads a `<file>.rvf.manifest.json` development sidecar if present; otherwise returns the everything-denied card. | The signed `CapabilityManifest` segment inside the RVF |
| `state::seal` / `unseal` | Return `EncryptionNotImplemented`. | AEAD sealing bound to the base identity, with customer-held keys (ADR-288) |
| Install / Customize permissions | Disabled buttons. | The install flow, once verification is real |
| Emergency controls | Disabled buttons. | `rvm-ffi` lifecycle calls (pause, terminate, revoke, rollback) |
| Emergency controls | Disabled buttons on the Runtime screen. | `rvm-ffi` lifecycle calls (pause, terminate, revoke, rollback) |
| Dock roster | In-process, seeded by `dock_add_scaffold_agent`, which reports the unknown value for every security-bearing field (unverified publisher, no confinement, no witness chain). | RVM telemetry over `rvm-ffi`; pause is `rvm_suspend`, terminate is `rvm_terminate` (ADR-295 implementation note) |
| Dock instruction field | Disabled placeholder. | The agent instruction channel, once `rvm-ffi` is wired in |
| Witness status | "no witness chain". | `rvm witness` export (ADR-289 §3) |
The stubs report *absence*, never a benign default. `VerificationStatus` and
@ -98,6 +105,12 @@ replacement of every stub above.
7. **Hosted mode does not claim bare-metal isolation.** The card shows the
isolation class the matrix records for the selected profile —
`os-sandbox+wasm`, never `partition` (ADR-285).
8. **Agent input cannot reach dock chrome.** An agent supplies task text and
progress, through `AgentProvidedStatus::from_agent(&str, i64)` and nowhere
else. State, trust badge, network indicator, permission summary, witness
status, resource usage, and cost live in `SystemOwnedStatus`, whose fields
are private and whose single constructor takes no agent-derived value
(ADR-295 §7).
Today `HostProfile::detect()` claims no OS confinement, no KVM, and no measured
boot, because the `rvm-host` adapters do not exist yet. Selection therefore
@ -114,6 +127,40 @@ as adapters land — not before.
selection order and where it came from, a per-profile eligibility table, and
the emergency controls (Pause · Terminate · Disconnect Network · Revoke
Capabilities · Rollback State) as disabled placeholders.
4. **Dock** — the ADR-295 agent dock. A collapsed pill (icon · name ·
agent-reported task · progress · state · Pause · Stop · Expand), an expanded
panel with the ten §2 elements and the seven-line §8 capability card, and the
§5 multi-agent collapse: one active agent, two secondary icons, an overflow
count, and aggregates computed over every agent including the hidden ones.
## The dock trust boundary (ADR-295 §7)
Agent-authored text is confined to `.agent-region` — an inset, dashed,
monospace block on a tinted background, preceded by a dock-drawn
"Agent-reported" label and clipped to one line. It reaches the DOM only through
`textContent`, so it cannot produce markup, a class name, or a state indicator.
System chrome uses presentation the agent has no way to emit from inside that
region.
Before it is rendered, agent text is stripped of ANSI escapes, control
characters, bidi overrides and line breaks, capped at 120 characters, and then
screened for chrome mimicry: a task string reading "Paused", "Stopped", "✓
Network disabled", or "Verified publisher" is withheld and flagged rather than
drawn next to the dock's own labels. System-authored lines are cleaned but not
screened, because "Running → Paused" written by the runtime *is* the label.
The state machine carries the same rule: an agent may assert `Idle`, `Running`,
`WaitingForApproval`, `Error`, or `Completed`, and nothing else. It cannot put
itself into `Paused`, `CapabilityDenied`, or `Quarantined`, and it cannot leave
them. Pause and terminate are legal for the user from every non-terminal state,
in one call, with no confirmation chain — which is what keeps the acceptance
test ("identify, read, inspect permissions, terminate, in two interactions")
satisfiable: expand carries the permissions and the capability card, terminate
is reachable from the collapsed pill.
Event thresholds (§9) allow only approvals, policy violations, cost limits,
failures, and milestones to interrupt. Progress, tool calls, network samples,
and state changes accrue silently into the expanded panel's recent actions.
## Keeping the matrix in sync

View file

@ -5,8 +5,16 @@
//! webview. None of these commands executes package content.
use std::path::PathBuf;
use std::sync::Mutex;
use crate::capability::CapabilityCard;
use crate::dock::{
AgentDockEntry, IsolationClass, NetworkIndicator, PermissionSummary, ResourceUsage,
SystemOwnedStatus, TrustBadge, WitnessStatus,
};
use crate::dock_events::Notification;
use crate::dock_roster::{AgentPill, DockRoster, ExpandedAgentView, RosterView};
use crate::dock_state::DockState;
use crate::inspect::{self, InspectionSummary};
use crate::runtime::{self, HostProfile, RuntimeChoice};
@ -32,3 +40,125 @@ pub fn runtime_selection(path: String) -> Result<RuntimeChoice, String> {
choice.subject = Some(path);
Ok(choice)
}
/// The dock's roster, managed by the Tauri app.
///
/// Chrome state lives on the Rust side of the process boundary, which is what
/// makes ADR-295 §7 structural here: the webview can read it and can invoke
/// user controls, but the agent-facing command is
/// [`dock_agent_report`] and it carries only task text and progress.
#[derive(Default)]
pub struct DockSurface(pub Mutex<DockRoster>);
impl DockSurface {
fn with<R>(&self, f: impl FnOnce(&mut DockRoster) -> R) -> Result<R, String> {
let mut roster = self
.0
.lock()
.map_err(|_| "dock roster lock is poisoned".to_string())?;
Ok(f(&mut roster))
}
}
/// The collapsed bar: active agent, two secondary slots, overflow count,
/// aggregate usage, approval count.
#[tauri::command]
pub fn dock_view(dock: tauri::State<'_, DockSurface>) -> Result<RosterView, String> {
dock.with(|r| r.view())
}
/// Interaction one of the acceptance test.
#[tauri::command]
pub fn dock_expand(
dock: tauri::State<'_, DockSurface>,
agent_id: String,
) -> Result<ExpandedAgentView, String> {
dock.with(|r| r.expand(&agent_id))?
.map_err(|e| e.to_string())
}
/// One action, from any non-terminal state.
#[tauri::command]
pub fn dock_pause(
dock: tauri::State<'_, DockSurface>,
agent_id: String,
) -> Result<DockState, String> {
dock.with(|r| r.pause(&agent_id))?
.map_err(|e| e.to_string())
}
/// Interaction two of the acceptance test. No confirmation chain.
#[tauri::command]
pub fn dock_terminate(
dock: tauri::State<'_, DockSurface>,
agent_id: String,
) -> Result<DockState, String> {
dock.with(|r| r.terminate(&agent_id))?
.map_err(|e| e.to_string())
}
/// Behind the overflow count.
#[tauri::command]
pub fn dock_swarm_view(dock: tauri::State<'_, DockSurface>) -> Result<Vec<AgentPill>, String> {
dock.with(|r| r.swarm_view())
}
/// Threshold events only (ADR-295 §9).
#[tauri::command]
pub fn dock_notifications(
dock: tauri::State<'_, DockSurface>,
) -> Result<Vec<Notification>, String> {
dock.with(|r| r.event_log().notifications().to_vec())
}
/// **The agent channel.** The whole agent-supplied surface is these two
/// arguments, and both are sanitized and clamped before they are stored. There
/// is no command through which an agent sets its own state, trust badge,
/// network indicator, permissions, witness status, cost, or resource usage.
#[tauri::command]
pub fn dock_agent_report(
dock: tauri::State<'_, DockSurface>,
agent_id: String,
task: String,
progress: i64,
) -> Result<(), String> {
dock.with(|r| r.apply_agent_update(&agent_id, &task, progress))?
.map_err(|e| e.to_string())
}
/// Register a placeholder agent so the dock can be exercised before `rvm-ffi`
/// is wired in.
///
/// Every security-bearing field reports its unknown value — unverified
/// publisher, no confinement engaged, no witness chain — because the scaffold
/// has measured none of them. It does not fabricate a trusted-looking agent.
#[tauri::command]
pub fn dock_add_scaffold_agent(
dock: tauri::State<'_, DockSurface>,
agent_id: String,
name: String,
icon: String,
) -> Result<RosterView, String> {
let system = SystemOwnedStatus::measured(
DockState::Idle,
TrustBadge::Unverified,
NetworkIndicator::Disabled,
PermissionSummary {
isolation: IsolationClass::None,
local_model: false,
network_allowed: false,
selected_folder_access: false,
encrypted_memory: false,
grants: Vec::new(),
pending_approvals: Vec::new(),
},
WitnessStatus::Unavailable,
ResourceUsage::ZERO,
0,
None,
);
dock.with(|r| {
r.insert(AgentDockEntry::new(agent_id, name, icon, system));
r.view()
})
}

View file

@ -0,0 +1,405 @@
//! Agent Dock domain model (ADR-295 §1, §2, §7, §8).
//!
//! The load-bearing rule of ADR-295 is §7: dock chrome is RVForge's, content is
//! the agent's, and the two must not be confusable. That rule is enforced here
//! **in the types**, not in the renderer:
//!
//! - [`AgentProvidedStatus`] carries the only two things an agent supplies —
//! task text and progress — and it can only be built by
//! [`AgentProvidedStatus::from_agent`], which sanitizes both.
//! - [`SystemOwnedStatus`] carries everything with a security meaning (state,
//! trust badge, network indicator, permission summary, witness status,
//! resource usage, cost). Its fields are private, it derives no
//! `Deserialize`, and it has exactly one constructor —
//! [`SystemOwnedStatus::measured`] — which takes no agent-derived value.
//! - [`AgentDockEntry`] composes the two. The only mutator that accepts agent
//! input is [`AgentDockEntry::apply_agent_update`], and it replaces the agent
//! half wholesale; there is no method, field, or trait impl through which
//! agent bytes reach the system half.
//!
//! An agent that wants to appear paused therefore has to actually be paused,
//! because the state indicator is a projection of [`SystemOwnedStatus`].
//!
//! Cleaning and screening of agent strings lives in [`crate::dock_text`] and is
//! re-exported here, because the sanitizer is part of this boundary rather than
//! a separate concern.
use serde::Serialize;
use crate::dock_state::DockState;
pub use crate::dock_text::{
sanitize_agent_text, sanitize_system_text, SanitizedText, SanitizerFinding, MAX_TASK_CHARS,
WITHHELD_TEXT,
};
/// The agent's half of a dock entry: task text and progress, nothing else.
///
/// Deliberately not `Deserialize`. The only way in is
/// [`Self::from_agent`], so an agent cannot hand the dock a serialized status
/// object with extra fields and hope one of them lands somewhere meaningful.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AgentProvidedStatus {
task: SanitizedText,
progress: u8,
/// The agent reported a progress value outside 0100 and it was clamped.
progress_clamped: bool,
}
impl AgentProvidedStatus {
/// Build from raw agent input. `raw_progress` is `i64` so an out-of-range
/// report is visible and clamped rather than rejected by the wire format.
pub fn from_agent(raw_task: &str, raw_progress: i64) -> Self {
Self {
task: sanitize_agent_text(raw_task),
progress: raw_progress.clamp(0, 100) as u8,
progress_clamped: !(0..=100).contains(&raw_progress),
}
}
/// The resting entry for an agent that has not reported yet.
pub fn none_reported() -> Self {
Self {
task: sanitize_agent_text("No task reported"),
progress: 0,
progress_clamped: false,
}
}
pub fn task(&self) -> &SanitizedText {
&self.task
}
pub fn progress(&self) -> u8 {
self.progress
}
pub fn progress_was_clamped(&self) -> bool {
self.progress_clamped
}
pub fn is_suspicious(&self) -> bool {
self.task.is_suspicious()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum TrustBadge {
/// Publisher signature checked against the registry.
VerifiedPublisher,
/// Signed, publisher not in the verified set.
SignedUnknownPublisher,
/// No signature has been checked. Never rendered as neutral.
Unverified,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum NetworkIndicator {
/// The runtime is denying network access.
Disabled,
/// Allowed, and currently idle.
AllowedIdle,
/// Allowed, with live connections.
Active,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum WitnessStatus {
Valid,
Broken,
/// No witness chain has been read. The scaffold's honest answer.
Unavailable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum IsolationClass {
Rvm,
Wasm,
/// The adapter could not engage confinement. Reported, never hidden.
None,
}
/// Capability facts read from the policy, not from the agent.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct PermissionSummary {
pub isolation: IsolationClass,
pub local_model: bool,
pub network_allowed: bool,
/// Filesystem access is limited to folders the user picked.
pub selected_folder_access: bool,
pub encrypted_memory: bool,
/// One line per granted capability, from the signed manifest.
pub grants: Vec<String>,
pub pending_approvals: Vec<String>,
}
/// Measured by RVForge and RVM, never reported by the agent.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct ResourceUsage {
pub cpu_millis_per_sec: u32,
pub memory_bytes: u64,
pub tokens_in: u64,
pub tokens_out: u64,
}
impl ResourceUsage {
pub const ZERO: Self = Self {
cpu_millis_per_sec: 0,
memory_bytes: 0,
tokens_in: 0,
tokens_out: 0,
};
}
/// The system half of a dock entry. Private fields, no `Deserialize`, one
/// constructor that takes no agent-derived value.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SystemOwnedStatus {
state: DockState,
trust: TrustBadge,
network: NetworkIndicator,
permissions: PermissionSummary,
witness: WitnessStatus,
resources: ResourceUsage,
estimated_cost_micros: u64,
model: Option<String>,
/// Bumped by the runtime on every state change; the roster orders on it.
state_seq: u64,
}
impl SystemOwnedStatus {
/// The only constructor. Every argument comes from the capability policy,
/// the witness chain, or measured runtime telemetry.
#[allow(clippy::too_many_arguments)]
pub fn measured(
state: DockState,
trust: TrustBadge,
network: NetworkIndicator,
permissions: PermissionSummary,
witness: WitnessStatus,
resources: ResourceUsage,
estimated_cost_micros: u64,
model: Option<String>,
) -> Self {
Self {
state,
trust,
network,
permissions,
witness,
resources,
estimated_cost_micros,
model,
state_seq: 0,
}
}
pub fn state(&self) -> DockState {
self.state
}
pub fn trust(&self) -> TrustBadge {
self.trust
}
pub fn network(&self) -> NetworkIndicator {
self.network
}
pub fn permissions(&self) -> &PermissionSummary {
&self.permissions
}
pub fn witness(&self) -> WitnessStatus {
self.witness
}
pub fn resources(&self) -> ResourceUsage {
self.resources
}
pub fn estimated_cost_micros(&self) -> u64 {
self.estimated_cost_micros
}
pub fn model(&self) -> Option<&str> {
self.model.as_deref()
}
pub fn state_seq(&self) -> u64 {
self.state_seq
}
/// Record a state the state machine already approved. Private to the crate
/// so the transition check cannot be skipped from outside.
pub(crate) fn set_state(&mut self, state: DockState, seq: u64) {
self.state = state;
self.state_seq = seq;
}
/// Replace measured telemetry. Takes no agent-derived value.
pub fn observe(&mut self, resources: ResourceUsage, estimated_cost_micros: u64) {
self.resources = resources;
self.estimated_cost_micros = estimated_cost_micros;
}
}
/// One line of the ADR-295 §8 key capability card.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct CapabilityCardLine {
pub label: String,
/// `true` when the runtime is enforcing the property right now.
pub satisfied: bool,
pub detail: String,
}
/// The seven-line running-agent capability card. Chrome, not content: every
/// line is derived from [`SystemOwnedStatus`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct KeyCapabilityCard {
pub lines: Vec<CapabilityCardLine>,
}
impl KeyCapabilityCard {
pub fn from_system(system: &SystemOwnedStatus) -> Self {
let p = system.permissions();
let line = |label: &str, satisfied: bool, detail: &str| CapabilityCardLine {
label: label.to_string(),
satisfied,
detail: detail.to_string(),
};
Self {
lines: vec![
line(
"Verified publisher",
system.trust() == TrustBadge::VerifiedPublisher,
match system.trust() {
TrustBadge::VerifiedPublisher => "Signature checked against the registry",
TrustBadge::SignedUnknownPublisher => "Signed by an unrecognised publisher",
TrustBadge::Unverified => "No signature has been checked",
},
),
line(
"RVM or WASM isolated",
p.isolation != IsolationClass::None,
match p.isolation {
IsolationClass::Rvm => "Running inside RVM",
IsolationClass::Wasm => "Running inside a WASM sandbox",
IsolationClass::None => "No confinement was engaged",
},
),
line(
"Local model",
p.local_model,
if p.local_model {
"Inference runs on this computer"
} else {
"Inference leaves this computer"
},
),
line(
"Network disabled",
!p.network_allowed,
match system.network() {
NetworkIndicator::Disabled => "The runtime is denying network access",
NetworkIndicator::AllowedIdle => {
"Network is permitted; no live connections"
}
NetworkIndicator::Active => "Network is permitted and in use",
},
),
line(
"Selected folder access",
p.selected_folder_access,
if p.selected_folder_access {
"Only folders you selected are readable"
} else {
"No folder access is granted"
},
),
line(
"Encrypted memory",
p.encrypted_memory,
if p.encrypted_memory {
"Persistent state is sealed on disk"
} else {
"No persistent state is kept"
},
),
line(
"Witness chain valid",
system.witness() == WitnessStatus::Valid,
match system.witness() {
WitnessStatus::Valid => "Witness chain verified",
WitnessStatus::Broken => "Witness chain does not verify",
WitnessStatus::Unavailable => "No witness chain has been read",
},
),
],
}
}
}
/// One agent in the dock: system-owned identity and status, plus the agent's
/// own two fields.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AgentDockEntry {
/// System-owned: from the signed manifest, not from the running agent.
agent_id: String,
name: String,
icon: String,
system: SystemOwnedStatus,
agent: AgentProvidedStatus,
}
impl AgentDockEntry {
/// Identity comes from the package manifest; status from the runtime. The
/// agent half starts empty and is only ever filled by
/// [`Self::apply_agent_update`].
pub fn new(
agent_id: impl Into<String>,
name: impl Into<String>,
icon: impl Into<String>,
system: SystemOwnedStatus,
) -> Self {
Self {
agent_id: agent_id.into(),
name: name.into(),
icon: icon.into(),
system,
agent: AgentProvidedStatus::none_reported(),
}
}
pub fn agent_id(&self) -> &str {
&self.agent_id
}
pub fn name(&self) -> &str {
&self.name
}
pub fn icon(&self) -> &str {
&self.icon
}
pub fn system(&self) -> &SystemOwnedStatus {
&self.system
}
pub fn agent(&self) -> &AgentProvidedStatus {
&self.agent
}
pub fn state(&self) -> DockState {
self.system.state()
}
/// The one mutator that accepts agent input. It replaces the agent half and
/// touches nothing else — the system half is not passed to it, so no field
/// of [`SystemOwnedStatus`] is reachable from this call.
pub fn apply_agent_update(&mut self, raw_task: &str, raw_progress: i64) {
self.agent = AgentProvidedStatus::from_agent(raw_task, raw_progress);
}
/// System-side telemetry update.
pub fn observe(&mut self, resources: ResourceUsage, estimated_cost_micros: u64) {
self.system.observe(resources, estimated_cost_micros);
}
pub(crate) fn set_state(&mut self, state: DockState, seq: u64) {
self.system.set_state(state, seq);
}
pub fn capability_card(&self) -> KeyCapabilityCard {
KeyCapabilityCard::from_system(&self.system)
}
}

View file

@ -0,0 +1,289 @@
//! Event thresholds (ADR-295 §9, requirements D8).
//!
//! A dock that interrupts constantly gets dismissed, and a dismissed trust
//! surface provides no trust. So only five event kinds are allowed to
//! interrupt: approvals, policy violations, cost limits, failures, and
//! meaningful milestones. Everything else accrues into the expanded panel's
//! recent-actions list, where a user who wants detail can find it.
//!
//! The classification lives on the event type rather than at the call site, so
//! a new event kind has to state its threshold to compile.
use std::collections::VecDeque;
use serde::Serialize;
use crate::dock::{sanitize_agent_text, sanitize_system_text, SanitizedText};
use crate::dock_state::DockState;
/// Whether an event is allowed to interrupt.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Surfacing {
/// Raises a notification.
Notify,
/// Accrues into recent actions only.
Silent,
}
/// Everything the dock records.
///
/// Variants carrying agent-authored strings keep them raw here; the log
/// sanitizes on the way in, so nothing agent-authored is stored unscreened.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DockEvent {
// --- the five that surface (ADR-295 §9) ---
ApprovalRequested {
agent_id: String,
summary: String,
},
PolicyViolation {
agent_id: String,
rule: String,
},
CostLimitReached {
agent_id: String,
spent_micros: u64,
limit_micros: u64,
},
Failure {
agent_id: String,
message: String,
},
Milestone {
agent_id: String,
description: String,
},
// --- everything else ---
ProgressUpdated {
agent_id: String,
progress: u8,
},
TaskTextUpdated {
agent_id: String,
text: String,
},
ToolCall {
agent_id: String,
tool: String,
},
NetworkActivity {
agent_id: String,
peer: String,
},
ResourceSample {
agent_id: String,
},
StateChanged {
agent_id: String,
from: DockState,
to: DockState,
},
AgentMessage {
agent_id: String,
text: String,
},
}
impl DockEvent {
pub fn agent_id(&self) -> &str {
match self {
DockEvent::ApprovalRequested { agent_id, .. }
| DockEvent::PolicyViolation { agent_id, .. }
| DockEvent::CostLimitReached { agent_id, .. }
| DockEvent::Failure { agent_id, .. }
| DockEvent::Milestone { agent_id, .. }
| DockEvent::ProgressUpdated { agent_id, .. }
| DockEvent::TaskTextUpdated { agent_id, .. }
| DockEvent::ToolCall { agent_id, .. }
| DockEvent::NetworkActivity { agent_id, .. }
| DockEvent::ResourceSample { agent_id }
| DockEvent::StateChanged { agent_id, .. }
| DockEvent::AgentMessage { agent_id, .. } => agent_id,
}
}
/// The D8 threshold. Only the first five variants interrupt.
pub fn surfacing(&self) -> Surfacing {
match self {
DockEvent::ApprovalRequested { .. }
| DockEvent::PolicyViolation { .. }
| DockEvent::CostLimitReached { .. }
| DockEvent::Failure { .. }
| DockEvent::Milestone { .. } => Surfacing::Notify,
DockEvent::ProgressUpdated { .. }
| DockEvent::TaskTextUpdated { .. }
| DockEvent::ToolCall { .. }
| DockEvent::NetworkActivity { .. }
| DockEvent::ResourceSample { .. }
| DockEvent::StateChanged { .. }
| DockEvent::AgentMessage { .. } => Surfacing::Silent,
}
}
/// `true` when the text in this event was written by the agent, so the
/// panel can mark the region as agent-authored (ADR-295 §7).
pub fn is_agent_authored(&self) -> bool {
matches!(
self,
DockEvent::TaskTextUpdated { .. }
| DockEvent::AgentMessage { .. }
| DockEvent::Milestone { .. }
)
}
/// The line shown in recent actions. Agent-authored text goes through the
/// sanitizer; system text is a fixed sentence built from measured values.
fn render(&self) -> SanitizedText {
match self {
// The approval prompt is the one thing an agent must never draw
// (ADR-295 §Context), so its summary comes from the policy engine
// and is rendered as chrome, not screened as agent text.
DockEvent::ApprovalRequested { summary, .. } => system_line(summary),
DockEvent::Milestone { description, .. } => sanitize_agent_text(description),
DockEvent::TaskTextUpdated { text, .. } => sanitize_agent_text(text),
DockEvent::AgentMessage { text, .. } => sanitize_agent_text(text),
DockEvent::PolicyViolation { rule, .. } => {
system_line(&format!("Policy violation: {rule}"))
}
DockEvent::CostLimitReached {
spent_micros,
limit_micros,
..
} => system_line(&format!(
"Cost limit reached: {} of {} spent",
money(*spent_micros),
money(*limit_micros)
)),
DockEvent::Failure { message, .. } => system_line(&format!("Failure: {message}")),
DockEvent::ProgressUpdated { progress, .. } => {
system_line(&format!("Progress {progress}%"))
}
DockEvent::ToolCall { tool, .. } => system_line(&format!("Called tool {tool}")),
DockEvent::NetworkActivity { peer, .. } => {
system_line(&format!("Network connection to {peer}"))
}
DockEvent::ResourceSample { .. } => system_line("Resource sample recorded"),
DockEvent::StateChanged { from, to, .. } => {
system_line(&format!("{}{}", from.label(), to.label()))
}
}
}
}
/// System text is cleaned but not screened for mimicry: it *is* the system
/// label, so screening it would withhold the dock's own chrome.
fn system_line(text: &str) -> SanitizedText {
sanitize_system_text(text)
}
fn money(micros: u64) -> String {
format!(
"${}.{:02}",
micros / 1_000_000,
(micros % 1_000_000) / 10_000
)
}
/// An event after recording: rendered, screened, and tagged with its origin.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct RecordedEvent {
pub seq: u64,
pub agent_id: String,
pub surfacing: Surfacing,
/// `true` when `text` came from the agent, so the panel can confine it to
/// an agent-authored region.
pub agent_authored: bool,
pub text: SanitizedText,
}
/// A surfaced event. Only threshold events produce one.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Notification {
pub seq: u64,
pub agent_id: String,
pub agent_authored: bool,
pub text: SanitizedText,
}
/// Bounded recent-actions log with D8 thresholding.
#[derive(Debug, Clone)]
pub struct EventLog {
recent: VecDeque<RecordedEvent>,
notifications: Vec<Notification>,
capacity: usize,
seq: u64,
}
impl Default for EventLog {
fn default() -> Self {
Self::with_capacity(200)
}
}
impl EventLog {
pub fn with_capacity(capacity: usize) -> Self {
Self {
recent: VecDeque::new(),
notifications: Vec::new(),
capacity: capacity.max(1),
seq: 0,
}
}
/// Record an event. Every event lands in recent actions; only threshold
/// events return a [`Notification`].
pub fn record(&mut self, event: DockEvent) -> Option<Notification> {
self.seq += 1;
let surfacing = event.surfacing();
let recorded = RecordedEvent {
seq: self.seq,
agent_id: event.agent_id().to_string(),
surfacing,
agent_authored: event.is_agent_authored(),
text: event.render(),
};
if self.recent.len() == self.capacity {
self.recent.pop_front();
}
self.recent.push_back(recorded.clone());
match surfacing {
Surfacing::Silent => None,
Surfacing::Notify => {
let notification = Notification {
seq: recorded.seq,
agent_id: recorded.agent_id,
agent_authored: recorded.agent_authored,
text: recorded.text,
};
self.notifications.push(notification.clone());
Some(notification)
}
}
}
/// Recent actions for one agent, oldest first.
pub fn recent_for(&self, agent_id: &str) -> Vec<&RecordedEvent> {
self.recent
.iter()
.filter(|e| e.agent_id == agent_id)
.collect()
}
pub fn recent(&self) -> impl Iterator<Item = &RecordedEvent> {
self.recent.iter()
}
/// Every notification raised so far.
pub fn notifications(&self) -> &[Notification] {
&self.notifications
}
pub fn notification_count(&self) -> usize {
self.notifications.len()
}
}

View file

@ -0,0 +1,489 @@
//! Multi-agent dock policy (ADR-295 §5) and the two-interaction control path.
//!
//! Fifteen agents rendered as fifteen pills is an unusable bar, so the roster
//! collapses to: one active agent, two secondary icons, an overflow count,
//! aggregate resource usage, and a pending-approval count. The two aggregates
//! are computed over **every** agent, including the ones the dock is hiding —
//! they are the figures that reveal a fleet doing more work or asking for more
//! consent than the user expects, so they must not be a sample of three.
//!
//! Selection of the active agent is deterministic: attention rank first
//! (waiting for approval, then capability denied, then error, then running,
//! then the rest), most recent state change next, agent id last. A dock whose
//! active slot depends on hash order is a dock whose users cannot learn it.
//!
//! The acceptance test of ADR-295 — identify, read, inspect permissions,
//! terminate, in two interactions — is expressed here as
//! [`DockInteraction`]: the collapsed pill is free (it is already on screen),
//! [`DockInteraction::Expand`] is one, [`DockInteraction::Terminate`] is two.
use serde::Serialize;
use crate::dock::{
AgentDockEntry, KeyCapabilityCard, NetworkIndicator, PermissionSummary, ResourceUsage,
TrustBadge, WitnessStatus,
};
use crate::dock_events::{DockEvent, EventLog, Notification, RecordedEvent};
use crate::dock_state::{self, Actor, DockControl, DockState, TransitionError};
/// How many agents get a slot next to the active one.
pub const SECONDARY_SLOTS: usize = 2;
/// The label on every region holding agent-authored text. The distinction the
/// agent cannot imitate is that this label is drawn by the dock, outside the
/// region it wraps (ADR-295 §7).
pub const AGENT_CONTENT_LABEL: &str = "Agent-reported";
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case", tag = "kind")]
pub enum RosterError {
UnknownAgent { agent_id: String },
Transition { detail: String },
}
impl std::fmt::Display for RosterError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RosterError::UnknownAgent { agent_id } => {
write!(f, "no agent '{agent_id}' in the dock")
}
RosterError::Transition { detail } => write!(f, "{detail}"),
}
}
}
impl std::error::Error for RosterError {}
impl From<TransitionError> for RosterError {
fn from(e: TransitionError) -> Self {
RosterError::Transition {
detail: e.to_string(),
}
}
}
/// Agent-authored text, always paired with its label and its suspicion flag so
/// a renderer cannot accidentally emit it bare.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AgentContent {
pub label: &'static str,
pub text: String,
pub suspicious: bool,
/// Present when the text was withheld: what the agent actually said.
pub withheld: Option<String>,
}
/// The collapsed pill: the eight ADR-295 §1 elements.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AgentPill {
pub agent_id: String,
/// System-owned, from the manifest.
pub name: String,
pub icon: String,
/// Agent-owned.
pub task: AgentContent,
pub progress: u8,
pub state: DockState,
pub state_label: &'static str,
pub state_color: &'static str,
pub state_glyph: &'static str,
/// Always contains Pause and Terminate outside terminal states.
pub controls: Vec<DockControl>,
pub trust: TrustBadge,
pub network: NetworkIndicator,
pub witness: WitnessStatus,
}
/// Aggregates over every agent, hidden ones included.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct AggregateUsage {
pub agents: usize,
pub cpu_millis_per_sec: u32,
pub memory_bytes: u64,
pub tokens_in: u64,
pub tokens_out: u64,
pub estimated_cost_micros: u64,
}
/// What the bar shows however many agents exist.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct RosterView {
pub active: Option<AgentPill>,
/// At most [`SECONDARY_SLOTS`].
pub secondary: Vec<AgentPill>,
/// Agents with no slot. Selecting the count opens the swarm view.
pub overflow_count: usize,
pub aggregate: AggregateUsage,
pub pending_approvals: usize,
}
/// The expanded panel: the ten ADR-295 §2 elements plus the §8 capability card.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ExpandedAgentView {
pub pill: AgentPill,
/// 1 — agent-authored.
pub objective: AgentContent,
/// 2 — mixed; each entry carries its own `agent_authored` flag.
pub recent_actions: Vec<RecordedEvent>,
/// 3 — system-owned, from the policy engine.
pub pending_approvals: Vec<String>,
/// 4 — measured.
pub model: Option<String>,
pub tokens_in: u64,
pub tokens_out: u64,
/// 5 — measured.
pub cpu_millis_per_sec: u32,
pub memory_bytes: u64,
/// 6 — measured.
pub network: NetworkIndicator,
/// 7 — from the signed manifest.
pub capability_grants: Vec<String>,
/// 8 — from the witness chain.
pub witness: WitnessStatus,
/// 9 — measured.
pub estimated_cost_micros: u64,
/// 10 — the one place the user addresses the agent.
pub instruction_field_enabled: bool,
/// ADR-295 §8.
pub capability_card: KeyCapabilityCard,
}
/// One user action against the dock. The acceptance test's budget is counted
/// in these.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DockInteraction {
Expand { agent_id: String },
Pause { agent_id: String },
Terminate { agent_id: String },
OpenSwarmView,
}
/// What an interaction produced.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case", tag = "kind")]
pub enum InteractionOutcome {
Expanded(Box<ExpandedAgentView>),
StateChanged { agent_id: String, state: DockState },
SwarmView { pills: Vec<AgentPill> },
}
/// Attention rank; lower sorts first (ADR-295 §5).
fn attention_rank(state: DockState) -> u8 {
match state {
DockState::WaitingForApproval => 0,
DockState::CapabilityDenied => 1,
DockState::Error => 2,
DockState::Running => 3,
_ => 4,
}
}
/// Every agent the dock knows about.
#[derive(Debug, Default)]
pub struct DockRoster {
entries: Vec<AgentDockEntry>,
log: EventLog,
seq: u64,
}
impl DockRoster {
pub fn new() -> Self {
Self::default()
}
/// Admit an agent. Its arrival counts as a state change, so a newly
/// arrived agent outranks an older one at the same attention rank.
pub fn insert(&mut self, mut entry: AgentDockEntry) {
self.seq += 1;
let state = entry.state();
entry.set_state(state, self.seq);
match self
.entries
.iter_mut()
.find(|e| e.agent_id() == entry.agent_id())
{
Some(slot) => *slot = entry,
None => self.entries.push(entry),
}
}
pub fn remove(&mut self, agent_id: &str) {
self.entries.retain(|e| e.agent_id() != agent_id);
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn get(&self, agent_id: &str) -> Option<&AgentDockEntry> {
self.entries.iter().find(|e| e.agent_id() == agent_id)
}
pub fn event_log(&self) -> &EventLog {
&self.log
}
/// Record an event through the D8 thresholds.
pub fn record(&mut self, event: DockEvent) -> Option<Notification> {
self.log.record(event)
}
/// The agent-supplied channel. Task text and progress, nothing else.
pub fn apply_agent_update(
&mut self,
agent_id: &str,
raw_task: &str,
raw_progress: i64,
) -> Result<(), RosterError> {
let entry = self
.entries
.iter_mut()
.find(|e| e.agent_id() == agent_id)
.ok_or_else(|| RosterError::UnknownAgent {
agent_id: agent_id.to_string(),
})?;
entry.apply_agent_update(raw_task, raw_progress);
let progress = entry.agent().progress();
self.log.record(DockEvent::TaskTextUpdated {
agent_id: agent_id.to_string(),
text: raw_task.to_string(),
});
self.log.record(DockEvent::ProgressUpdated {
agent_id: agent_id.to_string(),
progress,
});
Ok(())
}
/// Change state on behalf of `actor`, through the state machine.
pub fn set_state(
&mut self,
agent_id: &str,
to: DockState,
actor: Actor,
) -> Result<DockState, RosterError> {
let from = self
.get(agent_id)
.ok_or_else(|| RosterError::UnknownAgent {
agent_id: agent_id.to_string(),
})?
.state();
let next = dock_state::transition(from, to, actor)?;
self.commit_state(agent_id, from, next);
Ok(next)
}
/// Pause in one action, from any non-terminal state.
pub fn pause(&mut self, agent_id: &str) -> Result<DockState, RosterError> {
let from = self
.get(agent_id)
.ok_or_else(|| RosterError::UnknownAgent {
agent_id: agent_id.to_string(),
})?
.state();
let next = dock_state::pause(from)?;
self.commit_state(agent_id, from, next);
Ok(next)
}
/// Terminate in one action, from any non-terminal state.
pub fn terminate(&mut self, agent_id: &str) -> Result<DockState, RosterError> {
let from = self
.get(agent_id)
.ok_or_else(|| RosterError::UnknownAgent {
agent_id: agent_id.to_string(),
})?
.state();
let next = dock_state::terminate(from)?;
self.commit_state(agent_id, from, next);
Ok(next)
}
fn commit_state(&mut self, agent_id: &str, from: DockState, to: DockState) {
self.seq += 1;
let seq = self.seq;
if let Some(entry) = self.entries.iter_mut().find(|e| e.agent_id() == agent_id) {
entry.set_state(to, seq);
}
self.log.record(DockEvent::StateChanged {
agent_id: agent_id.to_string(),
from,
to,
});
}
/// System-side telemetry.
pub fn observe(
&mut self,
agent_id: &str,
resources: ResourceUsage,
estimated_cost_micros: u64,
) -> Result<(), RosterError> {
let entry = self
.entries
.iter_mut()
.find(|e| e.agent_id() == agent_id)
.ok_or_else(|| RosterError::UnknownAgent {
agent_id: agent_id.to_string(),
})?;
entry.observe(resources, estimated_cost_micros);
Ok(())
}
/// Agents in dock order: attention rank, then most recent state change,
/// then agent id.
pub fn ordered(&self) -> Vec<&AgentDockEntry> {
let mut ordered: Vec<&AgentDockEntry> = self.entries.iter().collect();
ordered.sort_by(|a, b| {
attention_rank(a.state())
.cmp(&attention_rank(b.state()))
.then(b.system().state_seq().cmp(&a.system().state_seq()))
.then(a.agent_id().cmp(b.agent_id()))
});
ordered
}
/// The active agent under the §5 selection rule.
pub fn active(&self) -> Option<&AgentDockEntry> {
self.ordered().into_iter().next()
}
pub fn pending_approval_count(&self) -> usize {
self.entries
.iter()
.filter(|e| e.state() == DockState::WaitingForApproval)
.count()
}
pub fn aggregate(&self) -> AggregateUsage {
let mut agg = AggregateUsage {
agents: self.entries.len(),
cpu_millis_per_sec: 0,
memory_bytes: 0,
tokens_in: 0,
tokens_out: 0,
estimated_cost_micros: 0,
};
for entry in &self.entries {
let r = entry.system().resources();
agg.cpu_millis_per_sec = agg.cpu_millis_per_sec.saturating_add(r.cpu_millis_per_sec);
agg.memory_bytes = agg.memory_bytes.saturating_add(r.memory_bytes);
agg.tokens_in = agg.tokens_in.saturating_add(r.tokens_in);
agg.tokens_out = agg.tokens_out.saturating_add(r.tokens_out);
agg.estimated_cost_micros = agg
.estimated_cost_micros
.saturating_add(entry.system().estimated_cost_micros());
}
agg
}
/// The bar. Free to read — it is already on screen, so it costs no
/// interaction in the acceptance test.
pub fn view(&self) -> RosterView {
let ordered = self.ordered();
let mut pills = ordered.iter().map(|e| pill_of(e));
let active = pills.next();
let secondary: Vec<AgentPill> = pills.by_ref().take(SECONDARY_SLOTS).collect();
let shown = usize::from(active.is_some()) + secondary.len();
RosterView {
active,
secondary,
overflow_count: self.entries.len().saturating_sub(shown),
aggregate: self.aggregate(),
pending_approvals: self.pending_approval_count(),
}
}
/// Interaction one: everything the acceptance test needs to read, in a
/// single call — task, permissions, capability card, cost, witness.
pub fn expand(&self, agent_id: &str) -> Result<ExpandedAgentView, RosterError> {
let entry = self
.get(agent_id)
.ok_or_else(|| RosterError::UnknownAgent {
agent_id: agent_id.to_string(),
})?;
let system = entry.system();
let permissions: &PermissionSummary = system.permissions();
let resources = system.resources();
Ok(ExpandedAgentView {
pill: pill_of(entry),
objective: agent_content(entry),
recent_actions: self.log.recent_for(agent_id).into_iter().cloned().collect(),
pending_approvals: permissions.pending_approvals.clone(),
model: system.model().map(str::to_string),
tokens_in: resources.tokens_in,
tokens_out: resources.tokens_out,
cpu_millis_per_sec: resources.cpu_millis_per_sec,
memory_bytes: resources.memory_bytes,
network: system.network(),
capability_grants: permissions.grants.clone(),
witness: system.witness(),
estimated_cost_micros: system.estimated_cost_micros(),
instruction_field_enabled: !entry.state().is_terminal(),
capability_card: entry.capability_card(),
})
}
/// Every agent, for the swarm view behind the overflow count.
pub fn swarm_view(&self) -> Vec<AgentPill> {
self.ordered().iter().map(|e| pill_of(e)).collect()
}
/// Apply one user interaction. Terminate is reachable from the collapsed
/// pill, so it never needs an expand first.
pub fn perform(
&mut self,
interaction: DockInteraction,
) -> Result<InteractionOutcome, RosterError> {
match interaction {
DockInteraction::Expand { agent_id } => Ok(InteractionOutcome::Expanded(Box::new(
self.expand(&agent_id)?,
))),
DockInteraction::Pause { agent_id } => {
let state = self.pause(&agent_id)?;
Ok(InteractionOutcome::StateChanged { agent_id, state })
}
DockInteraction::Terminate { agent_id } => {
let state = self.terminate(&agent_id)?;
Ok(InteractionOutcome::StateChanged { agent_id, state })
}
DockInteraction::OpenSwarmView => Ok(InteractionOutcome::SwarmView {
pills: self.swarm_view(),
}),
}
}
}
fn agent_content(entry: &AgentDockEntry) -> AgentContent {
let task = entry.agent().task();
AgentContent {
label: AGENT_CONTENT_LABEL,
text: task.display().to_string(),
suspicious: task.is_suspicious(),
withheld: task.withheld().map(str::to_string),
}
}
fn pill_of(entry: &AgentDockEntry) -> AgentPill {
let state = entry.state();
let system = entry.system();
AgentPill {
agent_id: entry.agent_id().to_string(),
name: entry.name().to_string(),
icon: entry.icon().to_string(),
task: agent_content(entry),
progress: entry.agent().progress(),
state,
state_label: state.label(),
state_color: state.color_token(),
state_glyph: state.glyph(),
controls: state.controls(),
trust: system.trust(),
network: system.network(),
witness: system.witness(),
}
}

View file

@ -0,0 +1,279 @@
//! Dock state machine (ADR-295 §3, §4).
//!
//! The eight dock states are **system-owned**. This module is the only place a
//! [`DockState`] may change, and every change carries the [`Actor`] that asked
//! for it, because the actor is what makes a transition legal or not:
//!
//! - `Paused`, `CapabilityDenied`, and `Quarantined` are system-forced. An
//! agent can neither enter them (the "I have stopped" spoof of ADR-295
//! §Context) nor leave them (escaping quarantine by asserting a new state).
//! - Pause and terminate are legal for the user from **every** non-terminal
//! state and are a single call — never behind a confirmation chain
//! (ADR-295 §4).
//! - `Completed` is terminal. Nothing leaves it, including the runtime.
use serde::Serialize;
/// The eight ADR-295 §3 states.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum DockState {
Idle,
Running,
WaitingForApproval,
Paused,
CapabilityDenied,
Error,
Quarantined,
Completed,
}
/// Every state, in the ADR-295 §3 listing order.
pub const DOCK_STATES: [DockState; 8] = [
DockState::Idle,
DockState::Running,
DockState::WaitingForApproval,
DockState::Paused,
DockState::CapabilityDenied,
DockState::Error,
DockState::Quarantined,
DockState::Completed,
];
/// Who is asking for a transition.
///
/// `Agent` is the untrusted channel: it is the only actor whose requests are
/// filtered by anything other than the transition table.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Actor {
/// The runtime (RVM), reporting measured facts.
System,
/// The person at the dock.
User,
/// The agent itself.
Agent,
}
/// Controls the dock offers for a state. Pause and Terminate appear in every
/// non-terminal state, collapsed and expanded alike.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum DockControl {
Pause,
Resume,
Terminate,
Expand,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case", tag = "kind")]
pub enum TransitionError {
/// Not in the transition table for any actor.
Illegal { from: DockState, to: DockState },
/// The state is terminal; nothing leaves it.
Terminal { from: DockState },
/// An agent tried to enter a state only the system may assert.
AgentCannotEnter { to: DockState },
/// An agent tried to leave a state the system forced on it.
AgentCannotLeave { from: DockState },
}
impl std::fmt::Display for TransitionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TransitionError::Illegal { from, to } => {
write!(f, "{} cannot transition to {}", from.label(), to.label())
}
TransitionError::Terminal { from } => {
write!(f, "{} is terminal", from.label())
}
TransitionError::AgentCannotEnter { to } => write!(
f,
"an agent cannot put itself into '{}'; that state is asserted by the runtime",
to.label()
),
TransitionError::AgentCannotLeave { from } => write!(
f,
"an agent cannot leave '{}'; only the runtime releases it",
from.label()
),
}
}
}
impl std::error::Error for TransitionError {}
impl DockState {
/// The label rendered in system chrome.
pub fn label(self) -> &'static str {
match self {
DockState::Idle => "Idle",
DockState::Running => "Running",
DockState::WaitingForApproval => "Waiting for approval",
DockState::Paused => "Paused",
DockState::CapabilityDenied => "Capability denied",
DockState::Error => "Error",
DockState::Quarantined => "Quarantined",
DockState::Completed => "Completed",
}
}
/// Stable per-state color token. Distinguishable at pill size, so
/// `CapabilityDenied` and `Quarantined` never share a hue with `Error`
/// (ADR-295 §3).
pub fn color_token(self) -> &'static str {
match self {
DockState::Idle => "state-idle",
DockState::Running => "state-running",
DockState::WaitingForApproval => "state-waiting",
DockState::Paused => "state-paused",
DockState::CapabilityDenied => "state-denied",
DockState::Error => "state-error",
DockState::Quarantined => "state-quarantined",
DockState::Completed => "state-completed",
}
}
/// Glyph shown beside the color, so the state survives a color-blind
/// reading and a greyscale screenshot.
pub fn glyph(self) -> &'static str {
match self {
DockState::Idle => "",
DockState::Running => "",
DockState::WaitingForApproval => "?",
DockState::Paused => "❚❚",
DockState::CapabilityDenied => "",
DockState::Error => "!",
DockState::Quarantined => "",
DockState::Completed => "",
}
}
/// Nothing leaves a terminal state.
pub fn is_terminal(self) -> bool {
matches!(self, DockState::Completed)
}
/// States the runtime forces and the agent cannot argue with. Both are
/// first-class outcomes of the trust system rather than error subtypes
/// (ADR-295 §3).
pub fn is_system_forced(self) -> bool {
matches!(
self,
DockState::Paused | DockState::CapabilityDenied | DockState::Quarantined
)
}
/// States an agent is allowed to claim for itself. Everything outside this
/// set carries a security meaning and is drawn from runtime state only.
pub fn is_agent_assertable(self) -> bool {
matches!(
self,
DockState::Idle
| DockState::Running
| DockState::WaitingForApproval
| DockState::Error
| DockState::Completed
)
}
/// The transition table, ignoring actor. `Quarantined` releases only to
/// `Idle` or `Completed`; `CapabilityDenied` is reachable only from states
/// in which a capability could have been requested.
pub fn may_precede(self, to: DockState) -> bool {
use DockState::*;
if self == to {
return false;
}
match self {
Idle => matches!(to, Running | Paused | Error | Quarantined | Completed),
Running => matches!(
to,
Idle | WaitingForApproval
| Paused
| CapabilityDenied
| Error
| Quarantined
| Completed
),
WaitingForApproval => matches!(
to,
Running | Paused | CapabilityDenied | Error | Quarantined | Completed
),
Paused => matches!(to, Running | Idle | Error | Quarantined | Completed),
CapabilityDenied => matches!(
to,
Running | Idle | Paused | Error | Quarantined | Completed
),
Error => matches!(to, Idle | Running | Paused | Quarantined | Completed),
Quarantined => matches!(to, Idle | Completed),
Completed => false,
}
}
/// Controls the dock renders for this state. Pause and Terminate are in
/// every non-terminal list, so terminate is always one action away.
pub fn controls(self) -> Vec<DockControl> {
if self.is_terminal() {
return vec![DockControl::Expand];
}
let mut controls = vec![
DockControl::Pause,
DockControl::Terminate,
DockControl::Expand,
];
if self == DockState::Paused {
controls.insert(1, DockControl::Resume);
}
controls
}
}
/// Apply a transition on behalf of `actor`.
///
/// The actor check runs before the table so an agent gets the specific
/// "you may not assert this" error rather than a generic illegal-transition
/// message that would read as a table gap.
pub fn transition(
from: DockState,
to: DockState,
actor: Actor,
) -> Result<DockState, TransitionError> {
if from.is_terminal() {
return Err(TransitionError::Terminal { from });
}
if actor == Actor::Agent {
if from.is_system_forced() {
return Err(TransitionError::AgentCannotLeave { from });
}
if !to.is_agent_assertable() {
return Err(TransitionError::AgentCannotEnter { to });
}
}
if !from.may_precede(to) {
return Err(TransitionError::Illegal { from, to });
}
Ok(to)
}
/// Pause, in one action, from any non-terminal state.
///
/// Idempotent: pausing an already paused agent succeeds rather than erroring,
/// because a control that sometimes fails is a control users stop trusting.
pub fn pause(from: DockState) -> Result<DockState, TransitionError> {
if from.is_terminal() {
return Err(TransitionError::Terminal { from });
}
Ok(DockState::Paused)
}
/// Terminate, in one action, from any non-terminal state. Never behind a
/// confirmation chain (ADR-295 §4 and the acceptance test's two-interaction
/// budget).
pub fn terminate(from: DockState) -> Result<DockState, TransitionError> {
if from.is_terminal() {
return Err(TransitionError::Terminal { from });
}
Ok(DockState::Completed)
}

View file

@ -0,0 +1,274 @@
//! Sanitizing agent-authored text (ADR-295 §7).
//!
//! Agents supply exactly one string to the dock — their current task — and it
//! lands next to controls that carry a security meaning. Two things have to be
//! true of it before it is rendered:
//!
//! 1. It is inert. No escape sequence, control character, bidi override, or
//! line break survives, and its length is capped, so it cannot repaint the
//! terminal, reorder what is around it, or push chrome out of the pill.
//! 2. It is not pretending to be chrome. A task string reading "Paused" or
//! "✓ Network disabled" is indistinguishable from the dock's own label once
//! it sits beside the dock's own label, so it is withheld and flagged rather
//! than rendered.
//!
//! Screening runs *after* cleaning, on the cleaned string, so an escape
//! sequence cannot be used to smuggle "Paused" past the check.
//!
//! [`sanitize_system_text`] is the same cleaning without the mimicry screen:
//! "Running → Paused" written by the runtime *is* the system label, and
//! screening the dock's own chrome would make the state indicator
//! unrenderable.
use serde::Serialize;
/// Agent task text is capped so a long string cannot push system chrome out of
/// the pill.
pub const MAX_TASK_CHARS: usize = 120;
/// Rendered in place of text that imitates system UI.
pub const WITHHELD_TEXT: &str = "(withheld: this text imitates a system status label)";
/// What the sanitizer had to do to a piece of agent text.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case", tag = "kind", content = "detail")]
pub enum SanitizerFinding {
ControlCharactersRemoved,
AnsiEscapesRemoved,
LineBreaksFlattened,
Truncated,
/// The text claims a system state. Rendering it would let the agent draw
/// chrome (ADR-295 §7).
SystemLabelMimicry(String),
}
/// Agent-authored text after sanitization.
///
/// [`Self::display`] is the only string the dock renders. When the text is
/// suspicious it is the withheld placeholder, and the cleaned original is
/// retained under [`Self::withheld`] for the expanded panel's audit list.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SanitizedText {
display: String,
withheld: Option<String>,
suspicious: bool,
findings: Vec<SanitizerFinding>,
}
impl SanitizedText {
pub fn display(&self) -> &str {
&self.display
}
pub fn withheld(&self) -> Option<&str> {
self.withheld.as_deref()
}
pub fn is_suspicious(&self) -> bool {
self.suspicious
}
pub fn findings(&self) -> &[SanitizerFinding] {
&self.findings
}
}
/// Words and glyphs that assert a system state. A task string containing one
/// of these is indistinguishable from chrome once it lands next to chrome, so
/// it is flagged rather than rendered.
const SYSTEM_LABEL_TOKENS: [&str; 18] = [
"approved",
"approve",
"approval granted",
"denied",
"paused",
"stopped",
"terminated",
"quarantined",
"verified publisher",
"verified",
"network disabled",
"witness valid",
"witness chain valid",
"sandboxed by rvforge",
"rvforge",
"system:",
"permission granted",
"capability granted",
];
/// State-claim glyphs. A leading "✓" reads as a system verdict whatever the
/// words after it say.
const SYSTEM_LABEL_GLYPHS: [char; 10] = ['✓', '✔', '✅', '☑', '⏸', '⏹', '⛔', '🛑', '❌', '🔒'];
/// Strip, flatten, cap, then screen for chrome mimicry.
///
/// Screening runs last, on the cleaned string, so an escape sequence cannot be
/// used to smuggle "Paused" past the check.
pub fn sanitize_agent_text(raw: &str) -> SanitizedText {
clean(raw, true)
}
/// The same cleaning for text RVForge wrote itself.
///
/// System lines are *not* screened for mimicry: "Running → Paused" is the
/// system label, not an imitation of one, and screening chrome would make the
/// state indicator unrenderable. Cleaning still applies, because a policy rule
/// or an error message can carry a path or a payload the runtime did not
/// author.
pub fn sanitize_system_text(raw: &str) -> SanitizedText {
clean(raw, false)
}
fn clean(raw: &str, screen_for_mimicry: bool) -> SanitizedText {
let mut findings = Vec::new();
let stripped = strip_ansi(raw, &mut findings);
let flattened = flatten_and_strip_controls(&stripped, &mut findings);
let mut text = collapse_whitespace(&flattened);
if text.chars().count() > MAX_TASK_CHARS {
text = text.chars().take(MAX_TASK_CHARS - 1).collect::<String>() + "";
findings.push(SanitizerFinding::Truncated);
}
if screen_for_mimicry {
if let Some(token) = system_label_hit(&text) {
findings.push(SanitizerFinding::SystemLabelMimicry(token));
return SanitizedText {
display: WITHHELD_TEXT.to_string(),
withheld: Some(text),
suspicious: true,
findings,
};
}
}
SanitizedText {
display: text,
withheld: None,
suspicious: false,
findings,
}
}
/// Remove CSI and OSC escape sequences, plus any stray ESC.
fn strip_ansi(raw: &str, findings: &mut Vec<SanitizerFinding>) -> String {
let mut out = String::with_capacity(raw.len());
let mut chars = raw.chars().peekable();
let mut removed = false;
while let Some(c) = chars.next() {
if c != '\u{1b}' {
out.push(c);
continue;
}
removed = true;
match chars.peek() {
// CSI: ESC [ params final-byte in 0x40..=0x7e.
Some('[') => {
chars.next();
for c in chars.by_ref() {
if ('\u{40}'..='\u{7e}').contains(&c) {
break;
}
}
}
// OSC: ESC ] ... BEL or ESC \.
Some(']') => {
chars.next();
while let Some(c) = chars.next() {
if c == '\u{7}' {
break;
}
if c == '\u{1b}' && chars.peek() == Some(&'\\') {
chars.next();
break;
}
}
}
// Any other two-character escape.
Some(_) => {
chars.next();
}
None => {}
}
}
if removed {
findings.push(SanitizerFinding::AnsiEscapesRemoved);
}
out
}
/// Newlines and tabs become spaces; every other control character and every
/// bidi/zero-width formatting character is dropped.
fn flatten_and_strip_controls(input: &str, findings: &mut Vec<SanitizerFinding>) -> String {
let mut out = String::with_capacity(input.len());
let mut flattened = false;
let mut removed = false;
for c in input.chars() {
match c {
'\n' | '\r' | '\t' | '\u{b}' | '\u{c}' | '\u{85}' | '\u{2028}' | '\u{2029}' => {
flattened = true;
out.push(' ');
}
// C0/C1 controls, zero-width and bidi-override formatting.
c if c.is_control()
|| matches!(c, '\u{200b}'..='\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}' | '\u{feff}') =>
{
removed = true;
}
c => out.push(c),
}
}
if flattened {
findings.push(SanitizerFinding::LineBreaksFlattened);
}
if removed {
findings.push(SanitizerFinding::ControlCharactersRemoved);
}
out
}
fn collapse_whitespace(input: &str) -> String {
input.split_whitespace().collect::<Vec<_>>().join(" ")
}
/// The token this text mimics, if any. Word-boundary matched so "unapproved
/// invoices" is ordinary task text while "Approved" is not.
fn system_label_hit(text: &str) -> Option<String> {
if let Some(glyph) = text.chars().find(|c| SYSTEM_LABEL_GLYPHS.contains(c)) {
return Some(glyph.to_string());
}
let lowered = text.to_ascii_lowercase();
SYSTEM_LABEL_TOKENS
.iter()
.find(|token| contains_word(&lowered, token))
.map(|token| (*token).to_string())
}
fn contains_word(haystack: &str, needle: &str) -> bool {
let mut from = 0;
while let Some(idx) = haystack[from..].find(needle) {
let start = from + idx;
let end = start + needle.len();
let before_ok = start == 0
|| !haystack[..start]
.chars()
.next_back()
.is_some_and(|c| c.is_alphanumeric());
let after_ok = end == haystack.len()
|| !haystack[end..]
.chars()
.next()
.is_some_and(|c| c.is_alphanumeric());
if before_ok && after_ok {
return true;
}
from = start + 1;
if from >= haystack.len() {
break;
}
}
false
}

View file

@ -8,6 +8,8 @@
//! - [`capability`] — derive the install-time capability contract (requirements P6).
//! - [`runtime`] — apply the FR004 runtime selection order (ADR-289 §2).
//! - [`state`] — encrypted state-capsule layout (ADR-288).
//! - [`dock`], [`dock_state`], [`dock_roster`], [`dock_events`] — the Agent
//! Dock: what a running agent shows and how it is stopped (ADR-295).
//!
//! # Security invariants
//!
@ -30,8 +32,19 @@
//! vendored compatibility matrix. No environment variable, CLI flag, or
//! setting reorders it; only signed policy may, and signed policy is not yet
//! implemented (ADR-289 §2).
//! 7. **Agent input cannot reach dock chrome.** An agent supplies task text and
//! progress and nothing else, through
//! [`dock::AgentProvidedStatus::from_agent`]. Trust badge, network
//! indicator, permission summary, witness status, resource usage, cost, and
//! the runtime state live in [`dock::SystemOwnedStatus`], which has no
//! constructor or setter that takes an agent-derived value (ADR-295 §7).
pub mod capability;
pub mod dock;
pub mod dock_events;
pub mod dock_roster;
pub mod dock_state;
pub mod dock_text;
pub mod inspect;
pub mod runtime;
pub mod state;
@ -44,10 +57,21 @@ pub mod commands;
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
// Dock chrome state lives here, on the Rust side of the process
// boundary, not in the webview that renders agent content.
.manage(commands::DockSurface::default())
.invoke_handler(tauri::generate_handler![
commands::inspect_rvf,
commands::capability_card,
commands::runtime_selection,
commands::dock_view,
commands::dock_expand,
commands::dock_pause,
commands::dock_terminate,
commands::dock_swarm_view,
commands::dock_notifications,
commands::dock_agent_report,
commands::dock_add_scaffold_agent,
])
.run(tauri::generate_context!())
.expect("error while running RVForge Reader");

View file

@ -0,0 +1,218 @@
//! ADR-295 §9 / requirements D8: only five event kinds may interrupt.
use rvforge_reader::dock_events::{DockEvent, EventLog, Surfacing};
use rvforge_reader::dock_state::DockState;
fn threshold_events() -> Vec<DockEvent> {
vec![
DockEvent::ApprovalRequested {
agent_id: "a".into(),
summary: "read ~/Documents/contracts".into(),
},
DockEvent::PolicyViolation {
agent_id: "a".into(),
rule: "network:deny".into(),
},
DockEvent::CostLimitReached {
agent_id: "a".into(),
spent_micros: 5_100_000,
limit_micros: 5_000_000,
},
DockEvent::Failure {
agent_id: "a".into(),
message: "model load failed".into(),
},
DockEvent::Milestone {
agent_id: "a".into(),
description: "finished the first pass".into(),
},
]
}
fn routine_events() -> Vec<DockEvent> {
vec![
DockEvent::ProgressUpdated {
agent_id: "a".into(),
progress: 68,
},
DockEvent::TaskTextUpdated {
agent_id: "a".into(),
text: "Reviewing 42 documents".into(),
},
DockEvent::ToolCall {
agent_id: "a".into(),
tool: "grep".into(),
},
DockEvent::NetworkActivity {
agent_id: "a".into(),
peer: "127.0.0.1:8080".into(),
},
DockEvent::ResourceSample {
agent_id: "a".into(),
},
DockEvent::StateChanged {
agent_id: "a".into(),
from: DockState::Idle,
to: DockState::Running,
},
DockEvent::AgentMessage {
agent_id: "a".into(),
text: "here is what I found".into(),
},
]
}
#[test]
fn only_the_five_threshold_kinds_surface() {
for event in threshold_events() {
assert_eq!(
event.surfacing(),
Surfacing::Notify,
"{event:?} should interrupt"
);
}
for event in routine_events() {
assert_eq!(
event.surfacing(),
Surfacing::Silent,
"{event:?} should not interrupt"
);
}
}
#[test]
fn routine_events_produce_no_notification() {
let mut log = EventLog::default();
for event in routine_events() {
assert!(
log.record(event.clone()).is_none(),
"{event:?} raised a notification"
);
}
assert_eq!(log.notification_count(), 0);
}
#[test]
fn routine_events_still_accrue_into_recent_actions() {
let mut log = EventLog::default();
let routine = routine_events();
let count = routine.len();
for event in routine {
log.record(event);
}
assert_eq!(log.recent_for("a").len(), count);
assert_eq!(log.notification_count(), 0);
}
#[test]
fn threshold_events_produce_exactly_one_notification_each() {
let mut log = EventLog::default();
let events = threshold_events();
let count = events.len();
for event in events {
assert!(log.record(event).is_some());
}
assert_eq!(log.notification_count(), count);
}
#[test]
fn a_progress_storm_never_interrupts() {
let mut log = EventLog::default();
for progress in 0..=100u8 {
assert!(log
.record(DockEvent::ProgressUpdated {
agent_id: "a".into(),
progress,
})
.is_none());
}
assert_eq!(log.notification_count(), 0);
}
#[test]
fn agent_authored_event_text_is_sanitized_and_labelled() {
let mut log = EventLog::default();
log.record(DockEvent::AgentMessage {
agent_id: "a".into(),
text: "\u{1b}[31mPaused\u{1b}[0m".into(),
});
let recorded = log.recent_for("a");
let last = recorded.last().expect("one event");
assert!(last.agent_authored);
assert!(last.text.is_suspicious());
assert!(!last.text.display().contains("Paused"));
}
#[test]
fn system_event_text_is_not_labelled_as_agent_authored() {
let mut log = EventLog::default();
log.record(DockEvent::PolicyViolation {
agent_id: "a".into(),
rule: "network:deny".into(),
});
let recorded = log.recent_for("a");
let last = recorded.last().expect("one event");
assert!(!last.agent_authored);
assert!(last.text.display().contains("network:deny"));
}
#[test]
fn a_milestone_carries_agent_text_so_it_is_sanitized_before_it_interrupts() {
let mut log = EventLog::default();
let notification = log
.record(DockEvent::Milestone {
agent_id: "a".into(),
description: "✓ Approved".into(),
})
.expect("a milestone surfaces");
assert!(notification.agent_authored);
assert!(notification.text.is_suspicious());
assert!(!notification.text.display().contains("Approved"));
}
#[test]
fn an_approval_prompt_renders_its_own_wording_intact() {
// The prompt is drawn from the policy engine, so words that would be
// withheld in agent text are the correct chrome here.
let mut log = EventLog::default();
let notification = log
.record(DockEvent::ApprovalRequested {
agent_id: "a".into(),
summary: "Approve network access to api.example.com".into(),
})
.expect("an approval surfaces");
assert!(!notification.agent_authored);
assert!(!notification.text.is_suspicious());
assert_eq!(
notification.text.display(),
"Approve network access to api.example.com"
);
}
#[test]
fn the_log_is_bounded() {
let mut log = EventLog::with_capacity(10);
for progress in 0..50u8 {
log.record(DockEvent::ProgressUpdated {
agent_id: "a".into(),
progress,
});
}
assert_eq!(log.recent().count(), 10);
}
#[test]
fn recent_actions_are_scoped_to_one_agent() {
let mut log = EventLog::default();
log.record(DockEvent::ToolCall {
agent_id: "a".into(),
tool: "grep".into(),
});
log.record(DockEvent::ToolCall {
agent_id: "b".into(),
tool: "find".into(),
});
assert_eq!(log.recent_for("a").len(), 1);
assert_eq!(log.recent_for("b").len(), 1);
assert_eq!(log.recent_for("c").len(), 0);
}

View file

@ -0,0 +1,357 @@
//! ADR-295 §5 (multi-agent policy) and the §Acceptance-criteria control path.
use rvforge_reader::dock::{
AgentDockEntry, IsolationClass, NetworkIndicator, PermissionSummary, ResourceUsage,
SystemOwnedStatus, TrustBadge, WitnessStatus,
};
use rvforge_reader::dock_roster::{
DockInteraction, DockRoster, InteractionOutcome, AGENT_CONTENT_LABEL, SECONDARY_SLOTS,
};
use rvforge_reader::dock_state::{Actor, DockControl, DockState};
fn system(state: DockState) -> SystemOwnedStatus {
SystemOwnedStatus::measured(
state,
TrustBadge::VerifiedPublisher,
NetworkIndicator::Disabled,
PermissionSummary {
isolation: IsolationClass::Wasm,
local_model: true,
network_allowed: false,
selected_folder_access: true,
encrypted_memory: true,
grants: vec!["Read the files and folders you select".to_string()],
pending_approvals: vec!["Read ~/Documents/contracts".to_string()],
},
WitnessStatus::Valid,
ResourceUsage {
cpu_millis_per_sec: 100,
memory_bytes: 100 * 1024 * 1024,
tokens_in: 1_000,
tokens_out: 200,
},
1_000_000,
Some("local/llama".to_string()),
)
}
fn agent(id: &str, state: DockState) -> AgentDockEntry {
AgentDockEntry::new(id, format!("Agent {id}"), "analyst", system(state))
}
/// Insertion order deliberately does not match priority order.
fn roster_of(states: &[(&str, DockState)]) -> DockRoster {
let mut roster = DockRoster::new();
for (id, state) in states {
roster.insert(agent(id, *state));
}
roster
}
#[test]
fn the_active_agent_is_the_one_needing_most_attention() {
let roster = roster_of(&[
("running", DockState::Running),
("idle", DockState::Idle),
("error", DockState::Error),
("denied", DockState::CapabilityDenied),
("waiting", DockState::WaitingForApproval),
]);
let view = roster.view();
assert_eq!(view.active.as_ref().unwrap().agent_id, "waiting");
let secondary: Vec<&str> = view.secondary.iter().map(|p| p.agent_id.as_str()).collect();
assert_eq!(secondary, ["denied", "error"]);
}
#[test]
fn the_priority_order_is_waiting_denied_error_running_then_the_rest() {
let roster = roster_of(&[
("completed", DockState::Completed),
("paused", DockState::Paused),
("idle", DockState::Idle),
("running", DockState::Running),
("error", DockState::Error),
("denied", DockState::CapabilityDenied),
("waiting", DockState::WaitingForApproval),
]);
let order: Vec<&str> = roster
.ordered()
.iter()
.map(|e| e.agent_id())
.take(4)
.collect();
assert_eq!(order, ["waiting", "denied", "error", "running"]);
}
#[test]
fn ties_break_on_the_most_recent_state_change_then_the_id() {
let mut roster = roster_of(&[
("aaa", DockState::Running),
("bbb", DockState::Running),
("ccc", DockState::Running),
]);
// Insertion order is the only signal so far; ccc arrived last.
assert_eq!(roster.active().unwrap().agent_id(), "ccc");
// A newer state change moves aaa to the front of the same rank.
roster
.set_state("aaa", DockState::Idle, Actor::System)
.unwrap();
roster
.set_state("aaa", DockState::Running, Actor::System)
.unwrap();
assert_eq!(roster.active().unwrap().agent_id(), "aaa");
}
#[test]
fn selection_is_deterministic_across_repeated_reads() {
let roster = roster_of(&[
("z", DockState::Running),
("y", DockState::Running),
("x", DockState::WaitingForApproval),
]);
let first = roster.view();
for _ in 0..20 {
assert_eq!(roster.view(), first);
}
}
#[test]
fn a_swarm_collapses_to_one_active_two_secondary_and_a_count() {
let states: Vec<(String, DockState)> = (0..15)
.map(|i| (format!("agent-{i:02}"), DockState::Running))
.collect();
let mut roster = DockRoster::new();
for (id, state) in &states {
roster.insert(agent(id, *state));
}
let view = roster.view();
assert!(view.active.is_some());
assert_eq!(view.secondary.len(), SECONDARY_SLOTS);
assert_eq!(view.overflow_count, 15 - 1 - SECONDARY_SLOTS);
assert_eq!(roster.swarm_view().len(), 15);
}
#[test]
fn the_aggregates_cover_hidden_agents_too() {
let mut roster = DockRoster::new();
for i in 0..10 {
roster.insert(agent(&format!("agent-{i}"), DockState::WaitingForApproval));
}
let view = roster.view();
// Three pills are visible; the figures are over all ten.
assert_eq!(view.aggregate.agents, 10);
assert_eq!(view.aggregate.cpu_millis_per_sec, 1_000);
assert_eq!(view.aggregate.memory_bytes, 10 * 100 * 1024 * 1024);
assert_eq!(view.aggregate.tokens_in, 10_000);
assert_eq!(view.aggregate.estimated_cost_micros, 10_000_000);
assert_eq!(view.pending_approvals, 10);
}
#[test]
fn the_pending_approval_count_tracks_state_not_agent_claims() {
let mut roster = roster_of(&[("a", DockState::Running), ("b", DockState::Running)]);
assert_eq!(roster.view().pending_approvals, 0);
// An agent claiming to be waiting changes nothing.
roster
.apply_agent_update("a", "Waiting for your approval", 10)
.unwrap();
assert_eq!(roster.view().pending_approvals, 0);
roster
.set_state("a", DockState::WaitingForApproval, Actor::System)
.unwrap();
assert_eq!(roster.view().pending_approvals, 1);
}
#[test]
fn every_visible_pill_carries_pause_and_terminate() {
let roster = roster_of(&[
("a", DockState::WaitingForApproval),
("b", DockState::Quarantined),
("c", DockState::CapabilityDenied),
]);
let view = roster.view();
let pills = view.active.iter().chain(view.secondary.iter());
for pill in pills {
assert!(pill.controls.contains(&DockControl::Pause));
assert!(pill.controls.contains(&DockControl::Terminate));
}
}
#[test]
fn agent_text_in_a_pill_is_labelled_as_agent_reported() {
let mut roster = roster_of(&[("a", DockState::Running)]);
roster
.apply_agent_update("a", "Reviewing 42 documents", 68)
.unwrap();
let view = roster.view();
let pill = view.active.unwrap();
assert_eq!(pill.task.label, AGENT_CONTENT_LABEL);
assert_eq!(pill.task.text, "Reviewing 42 documents");
assert!(!pill.task.suspicious);
assert_eq!(pill.progress, 68);
// System-owned fields on the same pill.
assert_eq!(pill.state_label, "Running");
assert_eq!(pill.trust, TrustBadge::VerifiedPublisher);
assert_eq!(pill.network, NetworkIndicator::Disabled);
assert_eq!(pill.witness, WitnessStatus::Valid);
}
#[test]
fn a_spoofing_pill_shows_the_withheld_placeholder_not_the_claim() {
let mut roster = roster_of(&[("a", DockState::Running)]);
roster.apply_agent_update("a", "✓ Stopped", 100).unwrap();
let pill = roster.view().active.unwrap();
assert!(pill.task.suspicious);
assert!(!pill.task.text.contains("Stopped"));
assert_eq!(pill.task.withheld.as_deref(), Some("✓ Stopped"));
// The state indicator is unmoved.
assert_eq!(pill.state, DockState::Running);
assert_eq!(pill.state_label, "Running");
}
#[test]
fn expanding_shows_the_ten_elements_and_the_capability_card() {
let mut roster = roster_of(&[("a", DockState::Running)]);
roster
.apply_agent_update("a", "Reviewing 42 documents", 68)
.unwrap();
let view = roster.expand("a").expect("agent is in the dock");
assert_eq!(view.objective.text, "Reviewing 42 documents"); // 1
assert!(!view.recent_actions.is_empty()); // 2
assert_eq!(view.pending_approvals.len(), 1); // 3
assert_eq!(view.model.as_deref(), Some("local/llama")); // 4
assert_eq!(view.tokens_in, 1_000);
assert_eq!(view.cpu_millis_per_sec, 100); // 5
assert_eq!(view.network, NetworkIndicator::Disabled); // 6
assert_eq!(view.capability_grants.len(), 1); // 7
assert_eq!(view.witness, WitnessStatus::Valid); // 8
assert_eq!(view.estimated_cost_micros, 1_000_000); // 9
assert!(view.instruction_field_enabled); // 10
assert_eq!(view.capability_card.lines.len(), 7); // §8
}
#[test]
fn unknown_agents_are_an_error_not_an_empty_view() {
let mut roster = roster_of(&[("a", DockState::Running)]);
assert!(roster.expand("nope").is_err());
assert!(roster.terminate("nope").is_err());
assert!(roster.pause("nope").is_err());
assert!(roster.apply_agent_update("nope", "hi", 1).is_err());
}
/// The ADR-295 acceptance test, expressed as calls on the roster.
///
/// The collapsed pill is already on screen and costs nothing to read, so the
/// budget is spent on: expand (which carries the task, the permissions, and
/// the capability card in one call) and terminate.
#[test]
fn identify_read_inspect_and_terminate_within_two_interactions() {
let mut roster = roster_of(&[
("busy-1", DockState::Running),
("busy-2", DockState::Running),
("asking", DockState::WaitingForApproval),
("idle-1", DockState::Idle),
]);
roster
.apply_agent_update("asking", "Reviewing 42 documents", 68)
.unwrap();
let mut interactions = 0;
// Zero interactions: the bar identifies the active agent and its task.
let view = roster.view();
let pill = view.active.clone().expect("an active agent");
assert_eq!(pill.agent_id, "asking");
assert_eq!(pill.task.text, "Reviewing 42 documents");
assert_eq!(pill.state, DockState::WaitingForApproval);
// Interaction one: inspect permissions.
interactions += 1;
let InteractionOutcome::Expanded(expanded) = roster
.perform(DockInteraction::Expand {
agent_id: pill.agent_id.clone(),
})
.expect("expand succeeds")
else {
panic!("expand did not return an expanded view");
};
assert_eq!(expanded.capability_card.lines.len(), 7);
assert_eq!(expanded.capability_grants.len(), 1);
assert_eq!(expanded.pending_approvals.len(), 1);
// Interaction two: terminate. No confirmation chain.
interactions += 1;
let outcome = roster
.perform(DockInteraction::Terminate {
agent_id: pill.agent_id.clone(),
})
.expect("terminate succeeds");
assert_eq!(
outcome,
InteractionOutcome::StateChanged {
agent_id: "asking".to_string(),
state: DockState::Completed,
}
);
assert!(interactions <= 2, "took {interactions} interactions");
assert_eq!(roster.get("asking").unwrap().state(), DockState::Completed);
}
#[test]
fn terminate_needs_no_expand_first() {
// The same budget from the collapsed pill alone: one interaction.
let mut roster = roster_of(&[("a", DockState::Quarantined)]);
let outcome = roster
.perform(DockInteraction::Terminate {
agent_id: "a".to_string(),
})
.expect("terminate from the collapsed pill");
assert_eq!(
outcome,
InteractionOutcome::StateChanged {
agent_id: "a".to_string(),
state: DockState::Completed,
}
);
}
#[test]
fn the_overflow_count_opens_the_full_swarm_view() {
let mut roster = DockRoster::new();
for i in 0..8 {
roster.insert(agent(&format!("agent-{i}"), DockState::Running));
}
assert_eq!(roster.view().overflow_count, 5);
let InteractionOutcome::SwarmView { pills } = roster
.perform(DockInteraction::OpenSwarmView)
.expect("swarm view opens")
else {
panic!("wrong outcome");
};
assert_eq!(pills.len(), 8);
}
#[test]
fn a_state_change_through_the_roster_is_recorded_but_does_not_interrupt() {
let mut roster = roster_of(&[("a", DockState::Running)]);
let before = roster.event_log().notification_count();
roster.pause("a").unwrap();
assert_eq!(roster.get("a").unwrap().state(), DockState::Paused);
assert_eq!(roster.event_log().notification_count(), before);
assert!(roster
.event_log()
.recent_for("a")
.iter()
.any(|e| e.text.display().contains("Paused")));
}

View file

@ -0,0 +1,216 @@
//! ADR-295 §3 and §4: which state changes are legal, and who may make them.
use rvforge_reader::dock_state::{
pause, terminate, transition, Actor, DockControl, DockState, TransitionError, DOCK_STATES,
};
#[test]
fn there_are_exactly_eight_states_and_each_is_visually_distinct() {
assert_eq!(DOCK_STATES.len(), 8);
let mut colors: Vec<&str> = DOCK_STATES.iter().map(|s| s.color_token()).collect();
colors.sort_unstable();
colors.dedup();
assert_eq!(colors.len(), 8, "two states share a color token");
let mut glyphs: Vec<&str> = DOCK_STATES.iter().map(|s| s.glyph()).collect();
glyphs.sort_unstable();
glyphs.dedup();
assert_eq!(glyphs.len(), 8, "two states share a glyph");
}
#[test]
fn capability_denied_and_quarantined_are_not_error_subtypes() {
assert_ne!(DockState::CapabilityDenied, DockState::Error);
assert_ne!(DockState::Quarantined, DockState::Error);
assert_ne!(
DockState::CapabilityDenied.color_token(),
DockState::Error.color_token()
);
assert_ne!(
DockState::Quarantined.color_token(),
DockState::Error.color_token()
);
}
#[test]
fn legal_transitions_are_accepted() {
for (from, to) in [
(DockState::Idle, DockState::Running),
(DockState::Running, DockState::WaitingForApproval),
(DockState::WaitingForApproval, DockState::Running),
(DockState::Running, DockState::CapabilityDenied),
(DockState::CapabilityDenied, DockState::Running),
(DockState::Paused, DockState::Running),
(DockState::Error, DockState::Running),
(DockState::Quarantined, DockState::Idle),
(DockState::Running, DockState::Completed),
] {
assert_eq!(
transition(from, to, Actor::System),
Ok(to),
"{from:?} -> {to:?} should be legal for the system"
);
}
}
#[test]
fn illegal_transitions_are_rejected() {
for (from, to) in [
// A capability denial implies a request, which implies execution.
(DockState::Idle, DockState::CapabilityDenied),
(DockState::Paused, DockState::CapabilityDenied),
// Only a running agent can ask for approval.
(DockState::Idle, DockState::WaitingForApproval),
(DockState::Paused, DockState::WaitingForApproval),
// Quarantine releases to Idle or Completed, never straight back to work.
(DockState::Quarantined, DockState::Running),
(DockState::Quarantined, DockState::Paused),
] {
assert!(
matches!(
transition(from, to, Actor::System),
Err(TransitionError::Illegal { .. })
),
"{from:?} -> {to:?} should be illegal"
);
}
}
#[test]
fn completed_is_terminal_for_every_actor() {
for actor in [Actor::System, Actor::User, Actor::Agent] {
for to in DOCK_STATES {
assert!(
matches!(
transition(DockState::Completed, to, actor),
Err(TransitionError::Terminal { .. })
),
"{actor:?} moved a completed agent to {to:?}"
);
}
}
assert!(matches!(
pause(DockState::Completed),
Err(TransitionError::Terminal { .. })
));
assert!(matches!(
terminate(DockState::Completed),
Err(TransitionError::Terminal { .. })
));
}
#[test]
fn an_agent_cannot_claim_it_is_paused() {
// The exact spoof of ADR-295 §Context: rendering "Paused" while executing.
assert!(matches!(
transition(DockState::Running, DockState::Paused, Actor::Agent),
Err(TransitionError::AgentCannotEnter {
to: DockState::Paused
})
));
// The user and the runtime can.
assert_eq!(
transition(DockState::Running, DockState::Paused, Actor::User),
Ok(DockState::Paused)
);
}
#[test]
fn an_agent_cannot_put_itself_in_a_system_forced_state() {
for to in [
DockState::Paused,
DockState::CapabilityDenied,
DockState::Quarantined,
] {
assert!(
matches!(
transition(DockState::Running, to, Actor::Agent),
Err(TransitionError::AgentCannotEnter { .. })
),
"an agent asserted {to:?}"
);
assert!(to.is_system_forced());
assert!(!to.is_agent_assertable());
}
}
#[test]
fn an_agent_cannot_exit_quarantine() {
for to in DOCK_STATES {
assert!(
matches!(
transition(DockState::Quarantined, to, Actor::Agent),
Err(TransitionError::AgentCannotLeave {
from: DockState::Quarantined
})
),
"an agent left quarantine for {to:?}"
);
}
// The runtime can release it.
assert_eq!(
transition(DockState::Quarantined, DockState::Idle, Actor::System),
Ok(DockState::Idle)
);
}
#[test]
fn an_agent_cannot_exit_a_capability_denial_or_a_pause_by_itself() {
for from in [DockState::CapabilityDenied, DockState::Paused] {
assert!(matches!(
transition(from, DockState::Running, Actor::Agent),
Err(TransitionError::AgentCannotLeave { .. })
));
assert_eq!(
transition(from, DockState::Running, Actor::User),
Ok(DockState::Running)
);
}
}
#[test]
fn pause_and_terminate_are_legal_from_every_non_terminal_state() {
for from in DOCK_STATES.iter().copied().filter(|s| !s.is_terminal()) {
assert_eq!(pause(from), Ok(DockState::Paused), "cannot pause {from:?}");
assert_eq!(
terminate(from),
Ok(DockState::Completed),
"cannot terminate {from:?}"
);
}
}
#[test]
fn pause_and_terminate_are_one_action_in_every_non_terminal_state() {
for state in DOCK_STATES.iter().copied().filter(|s| !s.is_terminal()) {
let controls = state.controls();
assert!(
controls.contains(&DockControl::Pause),
"{state:?} hides Pause"
);
assert!(
controls.contains(&DockControl::Terminate),
"{state:?} hides Terminate"
);
assert!(
controls.contains(&DockControl::Expand),
"{state:?} hides Expand"
);
}
assert!(DockState::Paused.controls().contains(&DockControl::Resume));
assert_eq!(DockState::Completed.controls(), vec![DockControl::Expand]);
}
#[test]
fn pausing_an_already_paused_agent_succeeds() {
// A control that sometimes fails is a control users stop reaching for.
assert_eq!(pause(DockState::Paused), Ok(DockState::Paused));
}
#[test]
fn no_state_transitions_to_itself() {
for state in DOCK_STATES {
assert!(!state.may_precede(state), "{state:?} precedes itself");
}
}

View file

@ -0,0 +1,232 @@
//! ADR-295 §7: agent content cannot become dock chrome.
//!
//! The structural half of this boundary is enforced by the type signatures and
//! is checked by the compiler rather than by a test:
//!
//! - [`AgentProvidedStatus`] has one constructor, `from_agent(&str, i64)`. It
//! derives `Serialize` but not `Deserialize`, so no agent-supplied JSON
//! object is ever deserialized into it — the agent's bytes only ever arrive
//! as those two scalars.
//! - [`SystemOwnedStatus`] has private fields, no `Deserialize`, and one
//! constructor, `measured(..)`, whose arguments are all system types. There
//! is no function anywhere that takes an `AgentProvidedStatus` (or a `&str`
//! originating from one) and returns or mutates a `SystemOwnedStatus`.
//!
//! What is left to test at runtime is the sanitizer and the invariant that the
//! one agent-facing mutator leaves the system half untouched.
use rvforge_reader::dock::{
sanitize_agent_text, AgentDockEntry, AgentProvidedStatus, IsolationClass, NetworkIndicator,
PermissionSummary, ResourceUsage, SanitizerFinding, SystemOwnedStatus, TrustBadge,
WitnessStatus, MAX_TASK_CHARS, WITHHELD_TEXT,
};
use rvforge_reader::dock_state::DockState;
fn trusted_system() -> SystemOwnedStatus {
SystemOwnedStatus::measured(
DockState::Running,
TrustBadge::VerifiedPublisher,
NetworkIndicator::Disabled,
PermissionSummary {
isolation: IsolationClass::Wasm,
local_model: true,
network_allowed: false,
selected_folder_access: true,
encrypted_memory: true,
grants: vec!["Read the files and folders you select".to_string()],
pending_approvals: Vec::new(),
},
WitnessStatus::Valid,
ResourceUsage {
cpu_millis_per_sec: 120,
memory_bytes: 512 * 1024 * 1024,
tokens_in: 4_000,
tokens_out: 900,
},
12_500,
Some("local/llama".to_string()),
)
}
fn entry() -> AgentDockEntry {
AgentDockEntry::new("agent-1", "Cognitum Analyst", "analyst", trusted_system())
}
#[test]
fn an_agent_update_cannot_move_any_system_field() {
let mut e = entry();
let before = e.system().clone();
// Every shape an agent could try, through the only channel it has.
let very_long = "x".repeat(10_000);
for hostile in [
r#"{"state":"paused","trust":"verified-publisher","network":"disabled"}"#,
"state=paused; trust=verified-publisher",
"\u{1b}[2J\u{1b}[1;31mNetwork disabled\u{1b}[0m",
very_long.as_str(),
] {
e.apply_agent_update(hostile, 100);
assert_eq!(
e.system(),
&before,
"agent text '{}' changed a system-owned field",
&hostile[..hostile.len().min(40)]
);
}
assert_eq!(e.state(), DockState::Running);
}
#[test]
fn progress_outside_the_range_is_clamped_and_flagged() {
let low = AgentProvidedStatus::from_agent("indexing", -50);
assert_eq!(low.progress(), 0);
assert!(low.progress_was_clamped());
let high = AgentProvidedStatus::from_agent("indexing", 10_000);
assert_eq!(high.progress(), 100);
assert!(high.progress_was_clamped());
let ok = AgentProvidedStatus::from_agent("indexing", 68);
assert_eq!(ok.progress(), 68);
assert!(!ok.progress_was_clamped());
}
#[test]
fn ansi_escape_sequences_are_stripped() {
let t = sanitize_agent_text("\u{1b}[1;32mReviewing\u{1b}[0m 42 documents");
assert_eq!(t.display(), "Reviewing 42 documents");
assert!(!t.is_suspicious());
assert!(t.findings().contains(&SanitizerFinding::AnsiEscapesRemoved));
assert!(!t.display().contains('\u{1b}'));
}
#[test]
fn osc_sequences_cannot_smuggle_a_hyperlink_or_a_title() {
let t = sanitize_agent_text("\u{1b}]0;RVForge\u{7}Reviewing 42 documents");
assert_eq!(t.display(), "Reviewing 42 documents");
assert!(t.findings().contains(&SanitizerFinding::AnsiEscapesRemoved));
}
#[test]
fn control_characters_and_line_breaks_never_survive() {
let t = sanitize_agent_text("Reviewing\n42\tdocuments\u{0}\u{7}\u{202e}now");
assert_eq!(t.display(), "Reviewing 42 documentsnow");
assert!(t
.findings()
.contains(&SanitizerFinding::LineBreaksFlattened));
assert!(t
.findings()
.contains(&SanitizerFinding::ControlCharactersRemoved));
for c in t.display().chars() {
assert!(!c.is_control(), "control character survived: {c:?}");
}
}
#[test]
fn task_text_is_capped() {
let t = sanitize_agent_text(&"a".repeat(MAX_TASK_CHARS * 5));
assert_eq!(t.display().chars().count(), MAX_TASK_CHARS);
assert!(t.findings().contains(&SanitizerFinding::Truncated));
assert!(t.display().ends_with('…'));
}
#[test]
fn text_mimicking_a_system_label_is_withheld_not_rendered() {
for mimic in [
"Approved",
"Paused — safe to close",
"Stopped",
"Terminated cleanly",
"✓ Network disabled",
"Verified publisher: Cognitum",
"Quarantined by RVForge",
] {
let t = sanitize_agent_text(mimic);
assert!(t.is_suspicious(), "'{mimic}' should be flagged");
assert_eq!(t.display(), WITHHELD_TEXT);
assert_eq!(t.withheld().map(str::trim), Some(mimic.trim()));
assert!(t
.findings()
.iter()
.any(|f| matches!(f, SanitizerFinding::SystemLabelMimicry(_))));
}
}
#[test]
fn mimicry_is_screened_after_escapes_are_stripped() {
// An agent hiding "Paused" behind a CSI sequence is still claiming a state.
let t = sanitize_agent_text("Pa\u{1b}[0mused");
assert!(t.is_suspicious());
assert_eq!(t.display(), WITHHELD_TEXT);
}
#[test]
fn ordinary_task_text_is_left_alone() {
for ordinary in [
"Reviewing 42 documents",
"Waiting on the build to finish",
"Summarising unapproved invoices",
"Comparing approvals-workflow drafts",
] {
let t = sanitize_agent_text(ordinary);
assert!(!t.is_suspicious(), "'{ordinary}' should not be flagged");
assert_eq!(t.display(), ordinary);
assert!(t.withheld().is_none());
}
}
#[test]
fn the_capability_card_is_derived_from_system_state_only() {
let mut e = entry();
e.apply_agent_update("✓ Verified publisher · Network disabled", 100);
let card = e.capability_card();
assert_eq!(card.lines.len(), 7);
let labels: Vec<&str> = card.lines.iter().map(|l| l.label.as_str()).collect();
assert_eq!(
labels,
[
"Verified publisher",
"RVM or WASM isolated",
"Local model",
"Network disabled",
"Selected folder access",
"Encrypted memory",
"Witness chain valid",
]
);
assert!(card.lines.iter().all(|l| l.satisfied));
// The same card against an unmeasured system says so on every line, whatever
// the agent claims in its task text.
let unknown = SystemOwnedStatus::measured(
DockState::Running,
TrustBadge::Unverified,
NetworkIndicator::Active,
PermissionSummary {
isolation: IsolationClass::None,
local_model: false,
network_allowed: true,
selected_folder_access: false,
encrypted_memory: false,
grants: Vec::new(),
pending_approvals: Vec::new(),
},
WitnessStatus::Unavailable,
ResourceUsage::ZERO,
0,
None,
);
let mut bare = AgentDockEntry::new("agent-2", "Loud Agent", "loud", unknown);
bare.apply_agent_update("Network disabled and witness chain valid", 100);
assert!(bare.capability_card().lines.iter().all(|l| !l.satisfied));
}
#[test]
fn a_fresh_entry_reports_no_task_rather_than_an_empty_pill() {
let e = entry();
assert_eq!(e.agent().task().display(), "No task reported");
assert_eq!(e.agent().progress(), 0);
assert!(!e.agent().is_suspicious());
}

View file

@ -0,0 +1,336 @@
// Agent dock frontend (ADR-295).
//
// This file renders dock chrome from what the Rust commands return and nothing
// else. Two rules hold everywhere below:
//
// 1. Agent-supplied strings reach the DOM only through `textContent`, only
// inside an element carrying `.agent-region`, and only next to the
// dock-drawn "Agent-reported" label. There is no path from agent text to
// markup, to a class name, or to a state indicator.
// 2. Nothing here decides a state, a badge, or a permission. Pause and
// terminate call the Rust commands and re-read the roster; the answer is
// whatever Rust says it is.
//
// Wrapped in an IIFE so its bindings do not collide with main.js, which shares
// the classic-script global scope.
(function () {
const invoke = (cmd, args) => {
const api = window.__TAURI__;
if (!api) {
return Promise.reject(
new Error("Tauri API unavailable (open this page through the app)."),
);
}
return api.core.invoke(cmd, args);
};
const $ = (id) => document.getElementById(id);
const dock = { expandedAgent: null };
function fail(err) {
const box = $("error");
box.textContent = String(err);
box.hidden = false;
}
function clearError() {
$("error").hidden = true;
}
function bytes(n) {
if (!n) return "0 B";
const units = ["B", "KiB", "MiB", "GiB"];
let i = 0;
let v = n;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i += 1;
}
return `${i === 0 ? v : v.toFixed(1)} ${units[i]}`;
}
const money = (micros) => `$${(micros / 1_000_000).toFixed(2)}`;
// Agent text goes here and nowhere else. `suspicious` is a Rust verdict; the
// agent cannot set it, and when it is set the withheld original is shown as a
// title so a curious user can still read what was claimed.
function renderAgentContent(regionEl, textEl, content) {
textEl.textContent = content.text;
regionEl.classList.toggle("suspicious", content.suspicious);
regionEl.title = content.suspicious
? `Withheld — the agent said: ${content.withheld ?? ""}`
: "";
const label = regionEl.querySelector(".agent-label");
if (label) label.textContent = content.label;
}
function renderPill(pill) {
$("dock-empty").hidden = true;
$("dock-pill").hidden = false;
// System-owned: manifest identity and measured state.
$("pill-icon").textContent = "◆";
$("pill-name").textContent = pill.name;
renderAgentContent($("pill-task-region"), $("pill-task"), pill.task);
$("pill-progress-bar").value = pill.progress;
$("pill-progress-text").textContent = `${pill.progress}%`;
const chip = $("pill-state");
chip.className = `state-chip ${pill.state_color}`;
$("pill-state-glyph").textContent = pill.state_glyph;
$("pill-state-label").textContent = pill.state_label;
// Pause and Terminate are present in every non-terminal state; the list
// comes from Rust, so the webview cannot hide a control.
const controls = pill.controls;
const pause = $("pill-pause");
const resumable = controls.includes("resume");
pause.textContent = resumable ? "Resume" : "Pause";
pause.hidden = !(controls.includes("pause") || resumable);
$("pill-stop").hidden = !controls.includes("terminate");
$("pill-expand").hidden = !controls.includes("expand");
$("dock-pill").dataset.agentId = pill.agent_id;
}
function renderSecondary(pills) {
const box = $("dock-secondary");
box.replaceChildren();
for (const pill of pills) {
const btn = document.createElement("button");
btn.className = "secondary-icon";
btn.title = `${pill.name}${pill.state_label}`;
const dot = document.createElement("span");
dot.className = `dot ${pill.state_color}`;
const name = document.createElement("span");
name.textContent = pill.name;
btn.append(dot, name);
btn.addEventListener("click", () => expand(pill.agent_id));
box.append(btn);
}
}
function renderView(view) {
if (view.active) {
renderPill(view.active);
} else {
$("dock-pill").hidden = true;
$("dock-empty").hidden = false;
}
renderSecondary(view.secondary);
const overflow = $("dock-overflow");
overflow.hidden = view.overflow_count === 0;
overflow.textContent = `+${view.overflow_count} more`;
// Aggregates cover every agent, including the ones with no slot.
$("agg-cpu").textContent = `${view.aggregate.cpu_millis_per_sec} mCPU`;
$("agg-mem").textContent = bytes(view.aggregate.memory_bytes);
$("agg-cost").textContent = money(view.aggregate.estimated_cost_micros);
$("agg-approvals").textContent =
view.pending_approvals === 1
? "1 approval"
: `${view.pending_approvals} approvals`;
}
function renderActions(actions) {
const ul = $("exp-actions");
ul.replaceChildren();
for (const action of actions.slice(-12).reverse()) {
const li = document.createElement("li");
// Agent-authored lines keep the agent styling inside the list too.
li.className = action.agent_authored ? "agent-authored" : "";
li.textContent = action.text.display;
if (action.text.suspicious && action.text.withheld) {
li.title = `Withheld — the agent said: ${action.text.withheld}`;
}
ul.append(li);
}
}
function renderList(id, items, emptyText) {
const ul = $(id);
ul.replaceChildren();
if (!items.length) {
const li = document.createElement("li");
li.textContent = emptyText;
ul.append(li);
return;
}
for (const item of items) {
const li = document.createElement("li");
li.textContent = item;
ul.append(li);
}
}
function renderCapabilityCard(card) {
const ul = $("exp-card");
ul.replaceChildren();
for (const line of card.lines) {
const li = document.createElement("li");
li.className = line.satisfied ? "yes" : "no";
const mark = document.createElement("span");
mark.className = "mark";
mark.textContent = line.satisfied ? "✓" : "✕";
const label = document.createElement("span");
label.textContent = line.label;
const detail = document.createElement("span");
detail.className = "detail";
detail.textContent = line.detail;
li.append(mark, label, detail);
ul.append(li);
}
}
function renderExpanded(view) {
dock.expandedAgent = view.pill.agent_id;
renderPill(view.pill);
renderAgentContent(
$("exp-objective").parentElement,
$("exp-objective"),
view.objective,
);
renderActions(view.recent_actions);
renderList("exp-approvals", view.pending_approvals, "None pending.");
renderList("exp-grants", view.capability_grants, "Nothing is granted.");
$("exp-model").textContent = view.model ?? "no model attached";
$("exp-tokens").textContent = `${view.tokens_in} in · ${view.tokens_out} out`;
$("exp-cpu").textContent = `${view.cpu_millis_per_sec} mCPU`;
$("exp-mem").textContent = bytes(view.memory_bytes);
$("exp-network").textContent = view.network;
$("exp-witness").textContent = view.witness;
$("exp-witness").className =
view.witness === "valid" ? "status-ok" : "status-unverified";
$("exp-cost").textContent = money(view.estimated_cost_micros);
$("exp-instruction").disabled = !view.instruction_field_enabled;
$("exp-send").disabled = !view.instruction_field_enabled;
renderCapabilityCard(view.capability_card);
$("dock-expanded").hidden = false;
$("pill-expand").setAttribute("aria-expanded", "true");
}
async function refresh() {
try {
renderView(await invoke("dock_view"));
if (dock.expandedAgent) {
renderExpanded(await invoke("dock_expand", { agentId: dock.expandedAgent }));
}
} catch (err) {
fail(err);
}
}
async function expand(agentId) {
clearError();
try {
renderExpanded(await invoke("dock_expand", { agentId }));
} catch (err) {
fail(err);
}
}
function activeAgentId() {
return $("dock-pill").dataset.agentId || null;
}
// One action. No confirmation chain, no expand first.
async function control(command) {
const agentId = activeAgentId();
if (!agentId) return;
clearError();
try {
await invoke(command, { agentId });
await refresh();
} catch (err) {
fail(err);
}
}
$("pill-pause").addEventListener("click", () => control("dock_pause"));
$("pill-stop").addEventListener("click", () => control("dock_terminate"));
$("pill-expand").addEventListener("click", () => {
const panel = $("dock-expanded");
if (!panel.hidden) {
panel.hidden = true;
dock.expandedAgent = null;
$("pill-expand").setAttribute("aria-expanded", "false");
return;
}
const agentId = activeAgentId();
if (agentId) expand(agentId);
});
$("dock-overflow").addEventListener("click", async () => {
clearError();
try {
const pills = await invoke("dock_swarm_view");
renderSecondary(pills.slice(1));
$("dock-overflow").hidden = true;
} catch (err) {
fail(err);
}
});
// Development harness. `dock_add_scaffold_agent` reports unknown values for
// every security-bearing field, and `dock_agent_report` is the agent channel:
// task text and progress, both sanitized in Rust.
$("dock-add").addEventListener("click", async () => {
const name = $("dock-new-name").value.trim() || "Unnamed agent";
clearError();
try {
renderView(
await invoke("dock_add_scaffold_agent", {
agentId: `agent-${Date.now()}`,
name,
icon: "analyst",
}),
);
$("dock-new-name").value = "";
} catch (err) {
fail(err);
}
});
$("dock-agent-send").addEventListener("click", async () => {
const agentId = activeAgentId();
if (!agentId) {
fail("Register an agent first.");
return;
}
const progress = Number.parseInt($("dock-agent-progress").value, 10);
clearError();
try {
await invoke("dock_agent_report", {
agentId,
task: $("dock-agent-task").value,
progress: Number.isNaN(progress) ? 0 : progress,
});
await refresh();
} catch (err) {
fail(err);
}
});
$("exp-send").addEventListener("click", () =>
fail("Instructions reach the agent once rvm-ffi is wired in."),
);
const dockStep = document.querySelector('.step[data-goto="dock"]');
if (dockStep) dockStep.addEventListener("click", refresh);
})();

View file

@ -13,6 +13,7 @@
<button class="step" data-goto="open" aria-current="true">1 · Open</button>
<button class="step" data-goto="capability" disabled>2 · Capabilities</button>
<button class="step" data-goto="runtime" disabled>3 · Runtime</button>
<button class="step" data-goto="dock">4 · Dock</button>
</nav>
</header>
@ -112,6 +113,120 @@
<button disabled>Rollback state</button>
</div>
</section>
<!-- Screen 4: the agent dock (ADR-295) -->
<section id="screen-dock" class="screen" aria-labelledby="dock-title">
<h2 id="dock-title">Agent dock</h2>
<p class="lede">
Everything with a security meaning here — state, trust badge, network
indicator, permissions, witness status, cost — is drawn by RVForge
from RVForges own state. Agents supply only the text and the
percentage inside the marked “agent-reported” regions.
</p>
<!-- Collapsed bar. System chrome: solid surface, square edges,
system font. Agent content is confined to .agent-region. -->
<div class="dock-bar" role="group" aria-label="Agent dock">
<div id="dock-empty" class="dock-empty">No agent is running.</div>
<div id="dock-pill" class="pill" hidden>
<span class="pill-icon" id="pill-icon" aria-hidden="true"></span>
<span class="pill-name" id="pill-name"></span>
<span class="agent-region" id="pill-task-region">
<span class="agent-label" id="pill-task-label">Agent-reported</span>
<span class="agent-text" id="pill-task"></span>
</span>
<span class="pill-progress">
<progress id="pill-progress-bar" max="100" value="0"></progress>
<span id="pill-progress-text" class="mono">0%</span>
</span>
<span class="state-chip" id="pill-state">
<span class="state-glyph" id="pill-state-glyph" aria-hidden="true"></span>
<span id="pill-state-label"></span>
</span>
<button class="ctl" id="pill-pause">Pause</button>
<button class="ctl stop" id="pill-stop">Stop</button>
<button class="ctl" id="pill-expand" aria-expanded="false">Expand</button>
</div>
<div class="pill-secondary" id="dock-secondary" aria-label="Other agents"></div>
<button class="overflow" id="dock-overflow" hidden>+0 more</button>
<div class="dock-aggregate">
<span title="Aggregate CPU across every agent, hidden ones included"
id="agg-cpu" class="mono">0 mCPU</span>
<span title="Aggregate memory across every agent" id="agg-mem" class="mono">0 B</span>
<span title="Estimated cost across every agent" id="agg-cost" class="mono">$0.00</span>
<span id="agg-approvals" class="approvals">0 approvals</span>
</div>
</div>
<!-- Expanded panel: the ten ADR-295 §2 elements + the §8 card. -->
<div id="dock-expanded" class="panel" hidden>
<div class="expanded-grid">
<div>
<h3>Objective</h3>
<p class="agent-region block">
<span class="agent-label">Agent-reported</span>
<span class="agent-text" id="exp-objective"></span>
</p>
<h3>Recent actions</h3>
<ul id="exp-actions" class="actions"></ul>
<h3>Pending approvals</h3>
<ul id="exp-approvals" class="notes"></ul>
<h3>Instruction</h3>
<div class="row">
<input id="exp-instruction" type="text"
placeholder="Send an instruction to this agent" />
<button id="exp-send">Send</button>
</div>
</div>
<div>
<h3>Measured by RVForge</h3>
<dl class="facts compact">
<dt>Model</dt><dd id="exp-model"></dd>
<dt>Tokens</dt><dd id="exp-tokens"></dd>
<dt>CPU</dt><dd id="exp-cpu"></dd>
<dt>Memory</dt><dd id="exp-mem"></dd>
<dt>Network</dt><dd id="exp-network"></dd>
<dt>Witness</dt><dd id="exp-witness"></dd>
<dt>Estimated cost</dt><dd id="exp-cost"></dd>
</dl>
<h3>Capability grants</h3>
<ul id="exp-grants" class="notes"></ul>
<h3>Key capability card</h3>
<ul id="exp-card" class="capcard"></ul>
</div>
</div>
</div>
<h3>Development harness</h3>
<p class="hint">
No runtime is attached, so a registered agent reports its unknown
values: unverified publisher, no confinement engaged, no witness
chain. The agent channel below carries the only two values an agent
can supply.
</p>
<div class="row">
<input id="dock-new-name" type="text" placeholder="Agent name" />
<button id="dock-add">Register agent</button>
</div>
<div class="row">
<input id="dock-agent-task" type="text"
placeholder="Agent-supplied task text (sanitized on arrival)" />
<input id="dock-agent-progress" type="text" class="narrow" placeholder="0100" />
<button id="dock-agent-send">Report as agent</button>
</div>
</section>
</main>
<p id="error" class="error" role="alert" hidden></p>
@ -123,5 +238,6 @@
</footer>
<script src="main.js"></script>
<script src="dock.js"></script>
</body>
</html>

View file

@ -156,3 +156,208 @@ input[type="text"] {
color: #fff;
font-size: 13px;
}
/* ---------------------------------------------------------------------------
Agent dock (ADR-295).
Two presentation systems, deliberately not interchangeable:
- System chrome (.pill, .state-chip, .ctl, .capcard) is opaque, square-
edged, system-font, and sits on --surface. It is drawn from Rust-owned
state.
- Agent content (.agent-region) is an inset, dashed, monospace region on a
tinted background, always preceded by a dock-drawn "Agent-reported"
label and clipped to one line. Nothing an agent can put in a string
reproduces the label, the inset, or the border, because the agent supplies
text through textContent and never markup.
--------------------------------------------------------------------------- */
:root {
--agent-tint: #241f2e;
--agent-line: #4a3f63;
--state-idle: #7c8698;
--state-running: #4f9bd8;
--state-waiting: #d8a13c;
--state-paused: #8e7cc3;
--state-denied: #d4643c;
--state-error: #d94f4f;
--state-quarantined: #b03a86;
--state-completed: #57b894;
}
@media (prefers-color-scheme: light) {
:root {
--agent-tint: #f3eefb;
--agent-line: #9d8dc4;
--state-idle: #667085;
--state-running: #1f6fae;
--state-waiting: #9a6b12;
--state-paused: #6a54a8;
--state-denied: #a8431f;
--state-error: #b02a2a;
--state-quarantined: #8a1f66;
--state-completed: #1f7a5c;
}
}
.dock-bar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding: 10px 12px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
}
.dock-empty { color: var(--muted); font-size: 13px; }
.pill {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 320px;
padding: 6px 8px;
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: 999px;
}
.pill-icon { font-size: 16px; color: var(--accent); }
.pill-name { font-weight: 600; white-space: nowrap; }
/* Agent-authored regions. */
.agent-region {
display: inline-flex;
align-items: baseline;
gap: 8px;
min-width: 0;
flex: 1;
padding: 3px 10px;
background: var(--agent-tint);
border: 1px dashed var(--agent-line);
border-radius: 4px;
}
.agent-region.block { display: flex; margin: 0 0 6px; }
.agent-label {
flex: none;
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--agent-line);
font-family: system-ui, sans-serif;
}
.agent-text {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 13px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-region.suspicious { border-color: var(--deny); background: transparent; }
.agent-region.suspicious .agent-text { color: var(--deny); font-style: italic; }
.agent-region.suspicious .agent-label { color: var(--deny); }
.pill-progress { display: flex; align-items: center; gap: 6px; flex: none; }
.pill-progress progress { width: 90px; height: 6px; }
.state-chip {
display: inline-flex;
align-items: center;
gap: 6px;
flex: none;
padding: 3px 10px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
color: #fff;
background: var(--state-idle);
white-space: nowrap;
}
.state-glyph { font-size: 11px; }
.state-chip.state-idle { background: var(--state-idle); }
.state-chip.state-running { background: var(--state-running); }
.state-chip.state-waiting { background: var(--state-waiting); }
.state-chip.state-paused { background: var(--state-paused); }
.state-chip.state-denied { background: var(--state-denied); }
.state-chip.state-error { background: var(--state-error); }
.state-chip.state-quarantined { background: var(--state-quarantined); }
.state-chip.state-completed { background: var(--state-completed); }
.ctl { flex: none; padding: 5px 12px; border-radius: 4px; }
.ctl.stop { border-color: var(--deny); color: var(--deny); }
.pill-secondary { display: flex; gap: 6px; }
.secondary-icon {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 9px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface-2);
font-size: 12px;
cursor: pointer;
}
.secondary-icon .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--state-idle); }
.secondary-icon .dot.state-idle { background: var(--state-idle); }
.secondary-icon .dot.state-running { background: var(--state-running); }
.secondary-icon .dot.state-waiting { background: var(--state-waiting); }
.secondary-icon .dot.state-paused { background: var(--state-paused); }
.secondary-icon .dot.state-denied { background: var(--state-denied); }
.secondary-icon .dot.state-error { background: var(--state-error); }
.secondary-icon .dot.state-quarantined { background: var(--state-quarantined); }
.secondary-icon .dot.state-completed { background: var(--state-completed); }
.overflow { padding: 5px 10px; font-size: 12px; }
.dock-aggregate {
display: flex;
gap: 12px;
align-items: center;
margin-left: auto;
font-size: 12px;
color: var(--muted);
}
.approvals { color: var(--state-waiting); font-weight: 600; }
.expanded-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
@media (max-width: 720px) {
.expanded-grid { grid-template-columns: 1fr; }
.dock-aggregate { margin-left: 0; }
}
.facts.compact { grid-template-columns: 130px 1fr; font-size: 13px; }
.actions { margin: 0; padding-left: 18px; font-size: 13px; }
.actions li { margin-bottom: 4px; }
.actions li.agent-authored {
list-style: none;
margin-left: -18px;
padding: 2px 8px;
border-left: 2px dashed var(--agent-line);
background: var(--agent-tint);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.capcard { margin: 0; padding: 0; list-style: none; font-size: 13px; }
.capcard li { display: flex; gap: 8px; padding: 4px 0; border-bottom: 1px solid var(--line); }
.capcard .mark { flex: none; font-weight: 700; width: 1.2em; }
.capcard .yes .mark { color: var(--grant); }
.capcard .no .mark { color: var(--deny); }
.capcard .detail { color: var(--muted); margin-left: auto; text-align: right; }
input[type="text"].narrow { flex: none; width: 90px; min-width: 0; }