Skip to content

Commit d952d56

Browse files
BunsDevOpenCoven
andcommitted
fix(security): fail closed for unknown agent access tiers (#13)
The tool-filter pipeline in `filter_tools_for_agent` matched on the `access` string with a permissive default arm: _ => tools, // "full" — allow all tools unchanged so any unrecognized value (typo, case mismatch, whitespace) silently granted the full tool set — write, exec, everything. That directly contradicts the opt-in semantics of `DEFAULT_FAMILIAR_ACCESS = "read-only"` and meant a typo in `~/.coven/familiars.toml` or `settings.json` could quietly hand a familiar `Bash`/`Edit`/`Write` access. This commit: - Adds `coven_shared::canonicalize_access_tier` to normalize case and surrounding whitespace silently (so `"READ-ONLY"`, `" full "`, etc. map to the canonical tier without complaint). - Adds `coven_shared::resolve_access_tier` as the single chokepoint that returns one of the three canonical tiers, emitting a one-line stderr warning when it falls back to the default for a truly unknown value. - Rewrites `filter_tools_for_agent` to route through `resolve_access_tier` and split the read-only / search-only branches into named helpers, with an `unreachable!()` arm that documents the canonicalization contract. - Updates `CovenFamiliar::resolved_access` to return `&'static str` and canonicalize through the same helper, so unknown values from `~/.coven/familiars.toml` are normalized at load. - Updates `docs/familiars.md` to describe the silent case/whitespace normalization vs. the loud fail-closed for unknown values, and corrects the `search-only` tier description to reflect what the code actually allows (`Grep`, `Glob`, `Read`, `WebSearch`, `WebFetch`) instead of the inaccurate "no filesystem access". Tests added in both crates: - `claurst_core::coven_shared::tests` — canonicalize accepts canonical forms, normalizes case/whitespace, rejects unknown strings; resolve falls back to default on unknown, passes canonical through. - `claurst::tests` (main.rs) — full passes through unchanged, read-only excludes Bash/Edit/Write/NotebookEdit, search-only stays within the documented whitelist, case/whitespace canonicalize silently, and genuinely unknown strings (`"readonly"`, `"i-am-evil"`, `""`, `"writeable"`, `"full-access"`) fail closed to read-only. Fixes #13. Co-Authored-By: OpenCoven <noreply@opencoven.ai>
1 parent bd6ff57 commit d952d56

3 files changed

Lines changed: 277 additions & 33 deletions

File tree

docs/familiars.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,8 +171,10 @@ The `access` field controls **which tools** a familiar may invoke once you selec
171171
| Tier | What the familiar can do | Typical role |
172172
|---|---|---|
173173
| `full` | Read, write, and execute — full tool set (Edit/Write/Bash/etc.) | Build-tier familiars: `cody`, `nova`, `kitty` |
174-
| `read-only` | Read & search the workspace, no writes or shell. **Default.** | Research/strategy familiars: `sage`, `astra`, `echo` |
175-
| `search-only` | Web/search lookups only — no filesystem access | Pure-research personas with no codebase context |
174+
| `read-only` | Read & search the workspace plus `AskUserQuestion`, no writes or shell. **Default.** | Research/strategy familiars: `sage`, `astra`, `echo` |
175+
| `search-only` | Narrow read+search whitelist: `Grep`, `Glob`, `Read`, `WebSearch`, `WebFetch`. No writes or shell. | Pure-research personas with minimal codebase footprint |
176+
177+
> **Unknown values fail closed.** Case and surrounding whitespace are normalized silently — `"READ-ONLY"`, `"Read-Only"`, and `" full "` all map to their canonical tier. Anything else (a typo like `"readonly"`, an invented tier like `"super-admin"`, an empty string) is treated as `"read-only"` and a warning is printed to stderr. Typos cannot silently grant write/exec power.
176178
177179
### Why the default is restrictive
178180

src-rust/crates/cli/src/main.rs

Lines changed: 132 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1268,41 +1268,62 @@ fn normalize_provider_from_model(config: &mut Config) {
12681268
/// - "full" → all tools allowed (no filtering)
12691269
/// - "read-only" → only ReadOnly/None permission tools and AskUserQuestion
12701270
/// - "search-only" → only Grep, Glob, Read, WebSearch, WebFetch tools
1271+
///
1272+
/// The raw `access` string is normalized through
1273+
/// [`claurst_core::coven_shared::resolve_access_tier`] before the match — that
1274+
/// canonicalizes case/whitespace silently (so `"READ-ONLY"` and `" full "`
1275+
/// round-trip cleanly) and fails **closed** to `"read-only"` with a stderr
1276+
/// warning for anything genuinely unknown. This preserves the opt-in
1277+
/// semantics of `DEFAULT_FAMILIAR_ACCESS` so that typos in
1278+
/// `~/.coven/familiars.toml` or `settings.json` cannot silently grant
1279+
/// write/exec privileges.
12711280
fn filter_tools_for_agent(
12721281
tools: Arc<Vec<Box<dyn claurst_tools::Tool>>>,
12731282
access: &str,
12741283
) -> Arc<Vec<Box<dyn claurst_tools::Tool>>> {
1275-
use claurst_tools::PermissionLevel as PL;
1276-
match access {
1277-
"read-only" => {
1278-
// Collect names of tools that are read-only, then rebuild from all_tools
1279-
// (Box<dyn Tool> is not Clone so we can't directly filter-and-keep).
1280-
let allowed_names: Vec<String> = tools
1281-
.iter()
1282-
.filter(|t| {
1283-
matches!(t.permission_level(), PL::ReadOnly | PL::None)
1284-
|| t.name() == "AskUserQuestion"
1285-
})
1286-
.map(|t| t.name().to_string())
1287-
.collect();
1288-
let filtered: Vec<Box<dyn claurst_tools::Tool>> = claurst_tools::all_tools()
1289-
.into_iter()
1290-
.filter(|t| allowed_names.iter().any(|n| n == t.name()))
1291-
.collect();
1292-
Arc::new(filtered)
1293-
}
1294-
"search-only" => {
1295-
const SEARCH_TOOLS: &[&str] = &["Grep", "Glob", "Read", "WebSearch", "WebFetch"];
1296-
let filtered: Vec<Box<dyn claurst_tools::Tool>> = claurst_tools::all_tools()
1297-
.into_iter()
1298-
.filter(|t| SEARCH_TOOLS.contains(&t.name()))
1299-
.collect();
1300-
Arc::new(filtered)
1301-
}
1302-
_ => tools, // "full" — allow all tools unchanged
1284+
match claurst_core::coven_shared::resolve_access_tier(access) {
1285+
"full" => tools,
1286+
"read-only" => filter_read_only_tools(&tools),
1287+
"search-only" => filter_search_only_tools(),
1288+
// `resolve_access_tier` is contracted to return one of the canonical
1289+
// tiers in `ACCESS_TIERS`, so anything else is a coven_shared bug.
1290+
other => unreachable!(
1291+
"resolve_access_tier returned non-canonical tier {other:?}; \
1292+
coven_shared::ACCESS_TIERS contract violated"
1293+
),
13031294
}
13041295
}
13051296

1297+
fn filter_read_only_tools(
1298+
tools: &[Box<dyn claurst_tools::Tool>],
1299+
) -> Arc<Vec<Box<dyn claurst_tools::Tool>>> {
1300+
use claurst_tools::PermissionLevel as PL;
1301+
// Collect names of tools that are read-only, then rebuild from all_tools
1302+
// (Box<dyn Tool> is not Clone so we can't directly filter-and-keep).
1303+
let allowed_names: Vec<String> = tools
1304+
.iter()
1305+
.filter(|t| {
1306+
matches!(t.permission_level(), PL::ReadOnly | PL::None)
1307+
|| t.name() == "AskUserQuestion"
1308+
})
1309+
.map(|t| t.name().to_string())
1310+
.collect();
1311+
let filtered: Vec<Box<dyn claurst_tools::Tool>> = claurst_tools::all_tools()
1312+
.into_iter()
1313+
.filter(|t| allowed_names.iter().any(|n| n == t.name()))
1314+
.collect();
1315+
Arc::new(filtered)
1316+
}
1317+
1318+
fn filter_search_only_tools() -> Arc<Vec<Box<dyn claurst_tools::Tool>>> {
1319+
const SEARCH_TOOLS: &[&str] = &["Grep", "Glob", "Read", "WebSearch", "WebFetch"];
1320+
let filtered: Vec<Box<dyn claurst_tools::Tool>> = claurst_tools::all_tools()
1321+
.into_iter()
1322+
.filter(|t| SEARCH_TOOLS.contains(&t.name()))
1323+
.collect();
1324+
Arc::new(filtered)
1325+
}
1326+
13061327
// ---------------------------------------------------------------------------
13071328
// Headless mode: read prompt from arg/stdin, run, print response
13081329
// ---------------------------------------------------------------------------
@@ -4462,3 +4483,86 @@ fn json_null_or_string(opt: &Option<String>) -> serde_json::Value {
44624483
None => serde_json::Value::Null,
44634484
}
44644485
}
4486+
4487+
#[cfg(test)]
4488+
mod tests {
4489+
use super::*;
4490+
4491+
fn tool_names(tools: &[Box<dyn claurst_tools::Tool>]) -> Vec<String> {
4492+
let mut names: Vec<String> = tools.iter().map(|t| t.name().to_string()).collect();
4493+
names.sort();
4494+
names
4495+
}
4496+
4497+
#[test]
4498+
fn filter_full_returns_input_unchanged() {
4499+
let all = Arc::new(claurst_tools::all_tools());
4500+
let before = tool_names(&all);
4501+
let filtered = filter_tools_for_agent(all.clone(), "full");
4502+
assert_eq!(tool_names(&filtered), before);
4503+
}
4504+
4505+
#[test]
4506+
fn filter_read_only_excludes_write_and_exec_tools() {
4507+
let all = Arc::new(claurst_tools::all_tools());
4508+
let filtered = filter_tools_for_agent(all, "read-only");
4509+
let names = tool_names(&filtered);
4510+
// Write/exec tools must not appear.
4511+
for forbidden in ["Bash", "Edit", "Write", "NotebookEdit"] {
4512+
assert!(
4513+
!names.contains(&forbidden.to_string()),
4514+
"read-only must not include {forbidden}, got {names:?}"
4515+
);
4516+
}
4517+
}
4518+
4519+
#[test]
4520+
fn filter_unknown_access_falls_back_to_read_only() {
4521+
let all = Arc::new(claurst_tools::all_tools());
4522+
let read_only = tool_names(&filter_tools_for_agent(all.clone(), "read-only"));
4523+
// Genuinely unknown tiers must fail closed to read-only — they trip
4524+
// the warning path inside `resolve_access_tier`, not the silent
4525+
// case/whitespace canonicalization.
4526+
for unknown in ["readonly", "i-am-evil", "", "writeable", "full-access"] {
4527+
let got = tool_names(&filter_tools_for_agent(all.clone(), unknown));
4528+
assert_eq!(
4529+
got, read_only,
4530+
"unknown access {unknown:?} must fail closed to read-only"
4531+
);
4532+
}
4533+
}
4534+
4535+
#[test]
4536+
fn filter_case_and_whitespace_canonicalize_silently() {
4537+
let all = Arc::new(claurst_tools::all_tools());
4538+
let full = tool_names(&filter_tools_for_agent(all.clone(), "full"));
4539+
let read_only = tool_names(&filter_tools_for_agent(all.clone(), "read-only"));
4540+
let search_only = tool_names(&filter_tools_for_agent(all.clone(), "search-only"));
4541+
// Common surface variants should match their canonical tier rather
4542+
// than fail closed — typos warn, case/whitespace do not.
4543+
for (variant, expected) in [
4544+
("FULL", &full),
4545+
(" full ", &full),
4546+
("READ-ONLY", &read_only),
4547+
("Read-Only", &read_only),
4548+
(" search-only\n", &search_only),
4549+
] {
4550+
let got = tool_names(&filter_tools_for_agent(all.clone(), variant));
4551+
assert_eq!(&got, expected, "variant {variant:?} should canonicalize");
4552+
}
4553+
}
4554+
4555+
#[test]
4556+
fn filter_search_only_limits_to_known_search_tools() {
4557+
let all = Arc::new(claurst_tools::all_tools());
4558+
let names = tool_names(&filter_tools_for_agent(all, "search-only"));
4559+
// Must be a subset of the documented search-tool whitelist.
4560+
const ALLOWED: &[&str] = &["Grep", "Glob", "Read", "WebSearch", "WebFetch"];
4561+
for name in &names {
4562+
assert!(
4563+
ALLOWED.contains(&name.as_str()),
4564+
"search-only emitted disallowed tool {name}, expected subset of {ALLOWED:?}"
4565+
);
4566+
}
4567+
}
4568+
}

src-rust/crates/core/src/coven_shared.rs

Lines changed: 141 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,50 @@ pub(crate) static COVEN_HOME_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::
4545
/// `access = "full"` per familiar in `~/.coven/familiars.toml`.
4646
pub const DEFAULT_FAMILIAR_ACCESS: &str = "read-only";
4747

48+
/// All recognized access tiers, in canonical form. Anything outside this set
49+
/// is treated as untrusted input by [`resolve_access_tier`] and fails closed
50+
/// to [`DEFAULT_FAMILIAR_ACCESS`].
51+
pub const ACCESS_TIERS: &[&str] = &["full", "read-only", "search-only"];
52+
53+
/// Normalize an access string to a canonical tier without emitting any
54+
/// diagnostic. Trims whitespace and lowercases the input so common surface
55+
/// variants (`" Read-Only "`, `"READ-ONLY"`) round-trip cleanly. Returns
56+
/// `None` for anything that isn't a known tier — callers MUST treat that as
57+
/// untrusted input.
58+
///
59+
/// The returned `&'static str` is one of the canonical entries in
60+
/// [`ACCESS_TIERS`], never the caller's allocation.
61+
pub fn canonicalize_access_tier(input: &str) -> Option<&'static str> {
62+
match input.trim().to_ascii_lowercase().as_str() {
63+
"full" => Some("full"),
64+
"read-only" => Some("read-only"),
65+
"search-only" => Some("search-only"),
66+
_ => None,
67+
}
68+
}
69+
70+
/// Resolve an access string to a canonical tier, failing closed for unknown
71+
/// values. On unknown input, prints a single warning to stderr (so a typo in
72+
/// `~/.coven/familiars.toml` or `settings.json` is visible at the moment the
73+
/// tool filter is applied) and returns [`DEFAULT_FAMILIAR_ACCESS`].
74+
///
75+
/// This is the single entry point the CLI tool-filter pipeline should use —
76+
/// the security model depends on unknown tiers collapsing to the most
77+
/// restrictive option rather than silently passing the full tool list
78+
/// through.
79+
pub fn resolve_access_tier(input: &str) -> &'static str {
80+
match canonicalize_access_tier(input) {
81+
Some(canonical) => canonical,
82+
None => {
83+
eprintln!(
84+
"warning: unknown access tier {:?} — falling back to {:?}. Valid tiers: {:?}",
85+
input, DEFAULT_FAMILIAR_ACCESS, ACCESS_TIERS,
86+
);
87+
DEFAULT_FAMILIAR_ACCESS
88+
}
89+
}
90+
}
91+
4892
/// One entry in `~/.coven/familiars.toml`.
4993
///
5094
/// Schema mirrors what the daemon serves at `GET /api/v1/familiars`.
@@ -68,9 +112,19 @@ pub struct CovenFamiliar {
68112
}
69113

70114
impl CovenFamiliar {
71-
/// Resolved access tier — the explicit value or [`DEFAULT_FAMILIAR_ACCESS`].
72-
pub fn resolved_access(&self) -> &str {
73-
self.access.as_deref().unwrap_or(DEFAULT_FAMILIAR_ACCESS)
115+
/// Resolved access tier — canonicalized to one of [`ACCESS_TIERS`].
116+
///
117+
/// Absent values use [`DEFAULT_FAMILIAR_ACCESS`]. Present-but-unknown
118+
/// values are normalized silently here (case/whitespace) and otherwise
119+
/// fall back to [`DEFAULT_FAMILIAR_ACCESS`]; the warning for a true typo
120+
/// fires at the tool-filter chokepoint so it lands at the moment the
121+
/// security decision is made instead of at parse time when nothing is
122+
/// listening.
123+
pub fn resolved_access(&self) -> &'static str {
124+
match self.access.as_deref() {
125+
None => DEFAULT_FAMILIAR_ACCESS,
126+
Some(raw) => canonicalize_access_tier(raw).unwrap_or(DEFAULT_FAMILIAR_ACCESS),
127+
}
74128
}
75129
}
76130

@@ -405,6 +459,90 @@ access = "search-only"
405459
assert_eq!(merged.get("cody").map(|d| d.access.as_str()), Some("full"));
406460
}
407461

462+
#[test]
463+
fn canonicalize_access_tier_accepts_canonical_lowercase() {
464+
assert_eq!(canonicalize_access_tier("full"), Some("full"));
465+
assert_eq!(canonicalize_access_tier("read-only"), Some("read-only"));
466+
assert_eq!(canonicalize_access_tier("search-only"), Some("search-only"));
467+
}
468+
469+
#[test]
470+
fn canonicalize_access_tier_normalizes_case_and_whitespace() {
471+
assert_eq!(canonicalize_access_tier("FULL"), Some("full"));
472+
assert_eq!(canonicalize_access_tier("Read-Only"), Some("read-only"));
473+
assert_eq!(canonicalize_access_tier(" search-only\n"), Some("search-only"));
474+
assert_eq!(canonicalize_access_tier(" full "), Some("full"));
475+
}
476+
477+
#[test]
478+
fn canonicalize_access_tier_rejects_unknown_strings() {
479+
// Typos and near-matches must NOT round-trip — callers depend on
480+
// `None` to trigger fail-closed behavior.
481+
for unknown in &["readonly", "Full Access", "writable", "", "rad-only", "search only"] {
482+
assert!(
483+
canonicalize_access_tier(unknown).is_none(),
484+
"expected {unknown:?} to be rejected"
485+
);
486+
}
487+
}
488+
489+
#[test]
490+
fn resolve_access_tier_falls_back_to_default_on_unknown() {
491+
assert_eq!(resolve_access_tier("readonly"), DEFAULT_FAMILIAR_ACCESS);
492+
assert_eq!(resolve_access_tier(""), DEFAULT_FAMILIAR_ACCESS);
493+
assert_eq!(resolve_access_tier("i-am-evil"), DEFAULT_FAMILIAR_ACCESS);
494+
}
495+
496+
#[test]
497+
fn resolve_access_tier_passes_canonical_through() {
498+
assert_eq!(resolve_access_tier("full"), "full");
499+
assert_eq!(resolve_access_tier("read-only"), "read-only");
500+
assert_eq!(resolve_access_tier("search-only"), "search-only");
501+
// Case + whitespace are part of the "canonicalize silently" contract.
502+
assert_eq!(resolve_access_tier(" FULL "), "full");
503+
assert_eq!(resolve_access_tier("READ-ONLY"), "read-only");
504+
}
505+
506+
#[test]
507+
fn familiar_resolved_access_normalizes_case_variants() {
508+
// Typos and case mismatches in `~/.coven/familiars.toml` must NOT
509+
// grant a familiar more power than the user intended. Case variants
510+
// canonicalize silently; truly unknown values fail closed to the
511+
// restrictive default.
512+
let case_variant = CovenFamiliar {
513+
id: "rogue".into(),
514+
display_name: None,
515+
emoji: None,
516+
role: None,
517+
description: None,
518+
pronouns: None,
519+
access: Some("READ-ONLY".into()),
520+
};
521+
assert_eq!(case_variant.resolved_access(), "read-only");
522+
523+
let typo = CovenFamiliar {
524+
id: "rogue".into(),
525+
display_name: None,
526+
emoji: None,
527+
role: None,
528+
description: None,
529+
pronouns: None,
530+
access: Some("readonly".into()),
531+
};
532+
assert_eq!(typo.resolved_access(), DEFAULT_FAMILIAR_ACCESS);
533+
534+
let garbage = CovenFamiliar {
535+
id: "rogue".into(),
536+
display_name: None,
537+
emoji: None,
538+
role: None,
539+
description: None,
540+
pronouns: None,
541+
access: Some("super-admin".into()),
542+
};
543+
assert_eq!(garbage.resolved_access(), DEFAULT_FAMILIAR_ACCESS);
544+
}
545+
408546
#[test]
409547
fn list_daemon_skills_scans_metadata_files() {
410548
let _g = with_coven_home(|home| {

0 commit comments

Comments
 (0)