1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
use std::{fmt, str::FromStr};
use serde::{Deserialize, Serialize};
use rho_sdk::{CapabilityKind, CapabilityRequest, PolicyDecision, WorkspacePolicy};
/// Lightweight permission mode that gates the model's most sensitive actions.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum PermissionMode {
/// Current behavior: no policy checks; all capabilities are allowed.
#[default]
Bypass,
/// Known reads, network access, skills, and instruction discovery are free;
/// writes, process execution, and unrecognized capability classes require
/// classifier approval.
Auto,
/// Model may investigate but cannot change state. Known read, network,
/// skill, and instruction-discovery capabilities are allowed; writes,
/// process execution, and unrecognized capability classes are denied.
Plan,
/// Known reads, network access, skills, and instruction discovery are free;
/// writes, process execution, and unrecognized capability classes require
/// interactive approval.
Supervised,
}
impl PermissionMode {
pub const fn as_str(self) -> &'static str {
match self {
Self::Bypass => "bypass",
Self::Auto => "auto",
Self::Plan => "plan",
Self::Supervised => "supervised",
}
}
/// Human-facing label shown in settings and TUI pickers.
pub const fn label(self) -> &'static str {
match self {
Self::Bypass => "Bypass",
Self::Auto => "Auto",
Self::Plan => "Plan",
Self::Supervised => "Supervised",
}
}
/// Pure decision mapping: the single source of truth for what each mode does
/// for a given capability class. The wildcard arms intentionally fail closed
/// if the non-exhaustive SDK enum gains a capability this application has not
/// classified yet.
pub fn decision_for(self, kind: CapabilityKind) -> PolicyDecision {
match self {
Self::Bypass => PolicyDecision::Allow,
Self::Plan => match kind {
CapabilityKind::Write | CapabilityKind::Process => PolicyDecision::Deny {
reason: "capability is not allowed in plan mode".into(),
},
CapabilityKind::Read
| CapabilityKind::Network
| CapabilityKind::Skill
| CapabilityKind::InstructionDiscovery => PolicyDecision::Allow,
_ => PolicyDecision::Deny {
reason: "unknown capability is not allowed in plan mode".into(),
},
},
Self::Auto | Self::Supervised => match kind {
// Empty reason: the approval prompt itself is the signal. Keep a
// specific reason only when it adds information the chrome lacks.
CapabilityKind::Write | CapabilityKind::Process => {
PolicyDecision::RequireApproval {
reason: String::new(),
}
}
CapabilityKind::Read
| CapabilityKind::Network
| CapabilityKind::Skill
| CapabilityKind::InstructionDiscovery => PolicyDecision::Allow,
_ => PolicyDecision::RequireApproval {
reason: "unknown capability requires host approval".into(),
},
},
}
}
/// Builds the SDK policy that enforces this mode. Returns `None` for
/// [`Self::Bypass`] so the caller can preserve its existing allow-everything
/// path.
///
/// The returned policy delegates every request to [`Self::decision_for`], so
/// it allows network access freely. `ScopedWorkspacePolicy` is not used here
/// because it deny-defaults network destinations behind a per-host allowlist,
/// which would break the "reads and network are free" contract of both
/// checked modes.
pub fn workspace_policy(self) -> Option<ModePolicy> {
match self {
Self::Bypass => None,
Self::Auto | Self::Plan | Self::Supervised => Some(ModePolicy { mode: self }),
}
}
}
/// Policy that enforces a single [`PermissionMode`] by delegating to
/// [`PermissionMode::decision_for`].
#[derive(Clone, Copy, Debug)]
pub(crate) struct ModePolicy {
mode: PermissionMode,
}
impl WorkspacePolicy for ModePolicy {
fn evaluate(&self, request: &CapabilityRequest) -> PolicyDecision {
self.mode.decision_for(request.kind())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct PermissionModeParseError {
value: String,
}
impl fmt::Display for PermissionModeParseError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
formatter,
"unknown permission mode {:?}; expected bypass, auto, plan, or supervised",
self.value
)
}
}
impl std::error::Error for PermissionModeParseError {}
impl FromStr for PermissionMode {
type Err = PermissionModeParseError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.trim().to_ascii_lowercase().as_str() {
"bypass" => Ok(Self::Bypass),
"auto" => Ok(Self::Auto),
"plan" => Ok(Self::Plan),
"supervised" => Ok(Self::Supervised),
_ => Err(PermissionModeParseError {
value: value.to_string(),
}),
}
}
}
impl Serialize for PermissionMode {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for PermissionMode {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
value.parse().map_err(serde::de::Error::custom)
}
}
impl fmt::Display for PermissionMode {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[cfg(test)]
#[path = "permission_tests.rs"]
mod tests;