logs and metrics permissions, tolerant record decode, hive capabilities
This commit is contained in:
parent
b2694b0a92
commit
04598542b6
3 changed files with 226 additions and 8 deletions
|
|
@ -9,6 +9,7 @@ workspace = true
|
|||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
|
||||
mod permission;
|
||||
|
||||
pub use permission::Permission;
|
||||
pub use permission::{Permission, deserialize_known};
|
||||
|
||||
pub mod review;
|
||||
pub mod term_msg;
|
||||
|
|
|
|||
|
|
@ -38,6 +38,15 @@ pub enum Permission {
|
|||
/// thing to manage. Kept under that spelling because every stored grant
|
||||
/// carries it.
|
||||
ManageRootAgent,
|
||||
/// Read access to the log store: what `swarm-logs` queries. Checked by
|
||||
/// the log gateway's `swarm-logs-auth` against the grant swarm-controller
|
||||
/// mirrors into the secret store, so `tools()` is empty. Not in
|
||||
/// [`Permission::DEFAULT`].
|
||||
Logs,
|
||||
/// Read access to the metrics store: what `swarm-metrics` queries.
|
||||
/// Checked like [`Permission::Logs`], for the metrics gateway. Not in
|
||||
/// [`Permission::DEFAULT`].
|
||||
Metrics,
|
||||
}
|
||||
|
||||
impl Permission {
|
||||
|
|
@ -49,6 +58,8 @@ impl Permission {
|
|||
Self::Execution,
|
||||
Self::WebTools,
|
||||
Self::ManageRootAgent,
|
||||
Self::Logs,
|
||||
Self::Metrics,
|
||||
];
|
||||
|
||||
/// What an agent the swarm sets nothing for holds, whatever its name.
|
||||
|
|
@ -69,8 +80,13 @@ impl Permission {
|
|||
// — an out-of-process server has no later enforcement point, so
|
||||
// that gate is the actual security boundary. `WebTools` grants
|
||||
// Claude built-ins (`builtin_tools()`); `ManageRootAgent` grants
|
||||
// bind mounts.
|
||||
Self::Execution | Self::WebTools | Self::ManageRootAgent => &[],
|
||||
// bind mounts; `Logs` and `Metrics` are checked by the store
|
||||
// gateways, not by a tool allowlist.
|
||||
Self::Execution
|
||||
| Self::WebTools
|
||||
| Self::ManageRootAgent
|
||||
| Self::Logs
|
||||
| Self::Metrics => &[],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -115,8 +131,43 @@ impl Permission {
|
|||
Self::Execution => "execution",
|
||||
Self::WebTools => "web_tools",
|
||||
Self::ManageRootAgent => "manage_root_agent",
|
||||
Self::Logs => "logs",
|
||||
Self::Metrics => "metrics",
|
||||
}
|
||||
}
|
||||
|
||||
/// The permission stored under `name`; `None` for a name this build does
|
||||
/// not know.
|
||||
#[must_use]
|
||||
pub fn from_name(name: &str) -> Option<Self> {
|
||||
Self::ALL.iter().copied().find(|p| p.as_str() == name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes a stored permission list, skipping names this build does not know.
|
||||
/// For `#[serde(deserialize_with)]` on a stored record: a permission a newer
|
||||
/// swarm adds must not cost an older reader the rest of the record.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// When the value is not a list of strings.
|
||||
pub fn deserialize_known<'de, D>(d: D) -> Result<Option<Vec<Permission>>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let names: Option<Vec<String>> = Option::deserialize(d)?;
|
||||
Ok(names.map(|names| {
|
||||
names
|
||||
.iter()
|
||||
.filter_map(|name| {
|
||||
let known = Permission::from_name(name);
|
||||
if known.is_none() {
|
||||
tracing::debug!(name, "skipping a permission this build does not know");
|
||||
}
|
||||
known
|
||||
})
|
||||
.collect()
|
||||
}))
|
||||
}
|
||||
|
||||
impl From<Permission> for &'static str {
|
||||
|
|
@ -142,11 +193,46 @@ mod tests {
|
|||
"inbox",
|
||||
"execution",
|
||||
"web_tools",
|
||||
"manage_root_agent"
|
||||
"manage_root_agent",
|
||||
"logs",
|
||||
"metrics"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// Reading stores a log or metric grant is never a default: an agent the
|
||||
/// swarm sets nothing for does not hold them.
|
||||
#[test]
|
||||
fn logs_and_metrics_are_off_by_default() {
|
||||
assert!(!Permission::DEFAULT.contains(&Permission::Logs));
|
||||
assert!(!Permission::DEFAULT.contains(&Permission::Metrics));
|
||||
assert!(Permission::ALL.contains(&Permission::Logs));
|
||||
assert!(Permission::ALL.contains(&Permission::Metrics));
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Record {
|
||||
#[serde(default, deserialize_with = "deserialize_known")]
|
||||
permissions: Option<Vec<Permission>>,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_name_is_skipped_not_a_decode_failure() {
|
||||
let r: Record =
|
||||
serde_json::from_str(r#"{"permissions":["messaging","telepathy","logs"]}"#).unwrap();
|
||||
assert_eq!(
|
||||
r.permissions,
|
||||
Some(vec![Permission::Messaging, Permission::Logs])
|
||||
);
|
||||
// Presence control: an unset field stays unset, `[]` stays empty.
|
||||
let r: Record = serde_json::from_str("{}").unwrap();
|
||||
assert_eq!(r.permissions, None);
|
||||
let r: Record = serde_json::from_str(r#"{"permissions":[]}"#).unwrap();
|
||||
assert_eq!(r.permissions, Some(vec![]));
|
||||
// A list of non-strings is still a decode failure.
|
||||
assert!(serde_json::from_str::<Record>(r#"{"permissions":[1]}"#).is_err());
|
||||
}
|
||||
|
||||
/// The serde form of every variant, pinned independently of `as_str`:
|
||||
/// stored grants decode by it, so it must stay byte-identical.
|
||||
#[test]
|
||||
|
|
@ -158,6 +244,8 @@ mod tests {
|
|||
(Permission::Execution, "\"execution\""),
|
||||
(Permission::WebTools, "\"web_tools\""),
|
||||
(Permission::ManageRootAgent, "\"manage_root_agent\""),
|
||||
(Permission::Logs, "\"logs\""),
|
||||
(Permission::Metrics, "\"metrics\""),
|
||||
];
|
||||
assert_eq!(want.len(), Permission::ALL.len());
|
||||
for (perm, json) in want {
|
||||
|
|
|
|||
Loading…
Reference in a new issue