use std::fs::{self, File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
use std::os::fd::AsRawFd;
use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{hook_output_ruled, HookCall, Rule};
const TTL_SECONDS: u64 = 15 * 60;
const MAX_REQUESTS: usize = 256;
const MAX_STORE_BYTES: u64 = 2 * 1024 * 1024;
const TAG_WORDS: [&str; 32] = [
"ash", "bay", "birch", "cedar", "elm", "fern", "fir", "grove", "hazel", "holly", "ivy",
"larch", "leaf", "lime", "maple", "moss", "oak", "olive", "palm", "pine", "plum", "reed",
"river", "rowan", "sage", "shore", "stone", "teak", "thorn", "vale", "wave", "yew",
];
fn request_tag(id: &str) -> Option<String> {
if id.len() != 32 || !id.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return None;
}
let word = usize::from(u8::from_str_radix(&id[..2], 16).ok()?) % TAG_WORDS.len();
Some(format!(
"{}-{}",
TAG_WORDS[word],
id[2..8].to_ascii_lowercase()
))
}
fn valid_reference(reference: &str) -> bool {
if reference.len() == 32 && reference.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return true;
}
reference.split_once('-').is_some_and(|(word, suffix)| {
TAG_WORDS.contains(&word)
&& suffix.len() == 6
&& suffix.bytes().all(|byte| byte.is_ascii_hexdigit())
})
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Scope {
session: String,
#[serde(default)]
seat: String,
shape: String,
tool: String,
cwd: PathBuf,
command: String,
pattern: String,
reason: String,
}
impl PartialEq for Scope {
fn eq(&self, other: &Self) -> bool {
self.session == other.session
&& self.shape == other.shape
&& self.tool == other.tool
&& self.cwd == other.cwd
&& self.command == other.command
&& self.pattern == other.pattern
&& self.reason == other.reason
}
}
impl Eq for Scope {}
impl Scope {
fn from_hook(input: &str, call: &HookCall, rule: &Rule) -> Result<Self> {
let value: Value = serde_json::from_str(input)?;
let session = call
.session
.as_deref()
.filter(|s| !s.is_empty())
.context("approval needs a conversation id")?;
let tool = value["tool_name"]
.as_str()
.or_else(|| value["toolName"].as_str())
.filter(|s| !s.is_empty())
.context("approval needs a tool name")?;
let base = value["cwd"]
.as_str()
.context("approval needs the working directory")?;
if !Path::new(base).is_absolute() {
bail!("approval needs an absolute working directory");
}
let args = value
.get("tool_input")
.filter(|v| !v.is_null())
.or_else(|| value.get("toolInput"));
let directory = args
.and_then(|args| args.get("workdir").or_else(|| args.get("cwd")))
.filter(|v| !v.is_null());
let cwd = match directory {
Some(v) => Path::new(base).join(v.as_str().context("invalid tool working directory")?),
None => PathBuf::from(base),
};
let cwd = fs::canonicalize(cwd).context("approval working directory is unavailable")?;
if !cwd.is_dir() || call.cue.is_empty() {
bail!("approval needs a directory and a command");
}
Ok(Self {
session: session.into(),
seat: crate::seat_name(),
shape: format!("{:?}", call.shape),
tool: tool.into(),
cwd,
command: call.cue.clone(),
pattern: rule.pattern.clone(),
reason: rule.reason.clone(),
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Request {
id: String,
scope: Scope,
created: u64,
approved: bool,
}
impl Request {
fn fresh(&self, now: u64) -> bool {
now.checked_sub(self.created)
.is_some_and(|age| age < TTL_SECONDS)
}
}
struct Store {
file: File,
requests: Vec<Request>,
}
impl Drop for Store {
fn drop(&mut self) {
unsafe { libc::flock(self.file.as_raw_fd(), libc::LOCK_UN) };
}
}
impl Store {
fn open(root: &Path, now: u64) -> Result<Self> {
fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(root)?;
let dir = fs::symlink_metadata(root)?;
let uid = unsafe { libc::geteuid() };
if !dir.is_dir() || dir.uid() != uid || dir.mode() & 0o077 != 0 {
bail!("approval directory must be owned by this user with mode 0700");
}
let mut file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.custom_flags(libc::O_NOFOLLOW)
.open(root.join("requests.json"))?;
let metadata = file.metadata()?;
if !metadata.is_file() || metadata.uid() != uid || metadata.mode() & 0o077 != 0 {
bail!("approval store must be owned by this user with mode 0600");
}
if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 {
bail!(
"approval store is busy: {}",
std::io::Error::last_os_error()
);
}
if file.metadata()?.len() > MAX_STORE_BYTES {
bail!("approval store exceeds its size limit");
}
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)?;
let mut requests: Vec<Request> = if bytes.is_empty() {
Vec::new()
} else {
serde_json::from_slice(&bytes).context("invalid approval store")?
};
requests.retain(|request| request.fresh(now));
Ok(Self { file, requests })
}
fn save(&mut self) -> Result<()> {
let bytes = serde_json::to_vec(&self.requests)?;
if bytes.len() as u64 > MAX_STORE_BYTES {
bail!("approval store exceeds its size limit");
}
self.file.seek(SeekFrom::Start(0))?;
self.file.write_all(&bytes)?;
self.file.set_len(bytes.len() as u64)?;
self.file.sync_data()?;
Ok(())
}
fn request_or_consume(&mut self, scope: Scope, now: u64) -> Result<Option<String>> {
if let Some(index) = self
.requests
.iter()
.position(|request| request.scope == scope)
{
if self.requests[index].approved {
self.requests.remove(index);
self.save()?;
return Ok(None);
}
return Ok(Some(self.requests[index].id.clone()));
}
if self.requests.len() >= MAX_REQUESTS {
bail!("too many pending approvals; expired requests clear after fifteen minutes");
}
let id = self.allocate_id(&mut File::open("/dev/urandom")?)?;
self.requests.push(Request {
id: id.clone(),
scope,
created: now,
approved: false,
});
self.save()?;
Ok(Some(id))
}
fn allocate_id(&self, random: &mut impl Read) -> Result<String> {
for _ in 0..32 {
let mut bytes = [0u8; 16];
random.read_exact(&mut bytes)?;
let id: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
let tag = request_tag(&id).expect("a generated request id is hexadecimal");
if self.requests.iter().any(|request| {
request.id == id || request_tag(&request.id).as_deref() == Some(tag.as_str())
}) {
continue;
}
return Ok(id);
}
bail!("could not allocate a unique approval tag; retry the command")
}
fn request_index(&self, reference: &str) -> Result<usize> {
let reference = reference.trim().to_ascii_lowercase();
if !valid_reference(&reference) {
bail!("use the short approval tag or full request id printed by the hook");
}
let mut matches = self.requests.iter().enumerate().filter(|(_, request)| {
request.id.eq_ignore_ascii_case(&reference)
|| request_tag(&request.id).as_deref() == Some(reference.as_str())
});
let (index, _) = matches.next().context(
"approval request is unknown, expired, or consumed; retry the original command",
)?;
if matches.next().is_some() {
bail!("approval tag is ambiguous; use the full request id");
}
Ok(index)
}
fn pending(&self, id: &str, session: &str) -> Result<Request> {
self.pending_for(id, session, "", Path::new(""))
}
fn pending_for(&self, id: &str, session: &str, seat: &str, cwd: &Path) -> Result<Request> {
let request = &self.requests[self.request_index(id)?];
let conversation_ok =
request.scope.session == session || same_conversation(&request.scope.session, session);
let place_ok =
!seat.is_empty() && request.scope.seat == seat && same_dir(&request.scope.cwd, cwd);
if !conversation_ok && !place_ok {
bail!("approval belongs to a different conversation");
}
if request.approved {
bail!("request is already approved; retry the original command");
}
Ok(request.clone())
}
fn confirm(&mut self, pending: &Request) -> Result<String> {
let current = self.pending(&pending.id, &pending.scope.session)?;
if current.scope != pending.scope || current.created != pending.created {
bail!("approval request changed while awaiting consent");
}
self.approve(&pending.id)
}
fn approve(&mut self, id: &str) -> Result<String> {
let index = self.request_index(id)?;
let request = &mut self.requests[index];
if request.approved {
bail!("request is already approved; retry the original command");
}
request.approved = true;
let answer = format!(
"Approved once: {}\nDirectory: {}\nConversation: {}\nRetry the original command.\n",
request.scope.command,
request.scope.cwd.display(),
request.scope.session
);
self.save()?;
Ok(answer)
}
}
impl Store {
fn approve_in(&mut self, id: &str, sessions: &[String]) -> Result<String> {
let request = &self.requests[self.request_index(id)?];
if !sessions
.iter()
.any(|session| same_conversation(&request.scope.session, session))
{
bail!("approval {id} was not asked in this conversation");
}
self.approve(id)
}
}
fn same_dir(a: &Path, b: &Path) -> bool {
if a.as_os_str().is_empty() || b.as_os_str().is_empty() {
return false;
}
let left = fs::canonicalize(a).unwrap_or_else(|_| a.to_path_buf());
let right = fs::canonicalize(b).unwrap_or_else(|_| b.to_path_buf());
left == right
}
fn same_conversation(stored: &str, given: &str) -> bool {
if stored == given {
return true;
}
let (short, long) = if stored.len() <= given.len() {
(stored, given)
} else {
(given, stored)
};
short.len() >= 32 && long.contains(short)
}
fn stamped_session_ids() -> Vec<String> {
std::env::vars()
.filter(|(key, value)| {
(key.ends_with("_SESSION_ID")
|| key.ends_with("_THREAD_ID")
|| key.ends_with("_CONVERSATION_ID"))
&& key != "XDG_SESSION_ID"
&& key != "BLE_SESSION_ID"
&& value.trim().len() >= 8
})
.map(|(_, value)| value.trim().to_string())
.collect()
}
fn approved_ids(prompt: &str) -> Vec<String> {
let prompt = prompt.trim();
if let Some(body) = prompt.strip_prefix("<send_user_message_question_reply>") {
let Some(body) = body.strip_suffix("</send_user_message_question_reply>") else {
return Vec::new();
};
let Ok(replies) = serde_json::from_str::<Vec<Value>>(body) else {
return Vec::new();
};
return replies
.iter()
.filter_map(|reply| reply.get("answer").and_then(Value::as_str))
.flat_map(approval_directives)
.collect();
}
approval_directives(prompt)
}
fn approval_directives(prompt: &str) -> Vec<String> {
prompt
.trim()
.lines()
.take(1)
.filter_map(|line| {
let mut words = line.split_whitespace();
if !words.next()?.eq_ignore_ascii_case("approve") {
return None;
}
let reference = words
.next()?
.trim_start_matches(['`', '\"', '\''])
.trim_end_matches(['`', '\"', '\'', '.', ',', ';', ':', '!'])
.to_ascii_lowercase();
valid_reference(&reference).then_some(reference)
})
.collect()
}
pub fn approve_from_prompt(prompt: &str, session: Option<&str>) -> Option<String> {
let mut sessions = Vec::new();
if let Some(session) = session.map(str::trim).filter(|s| !s.is_empty()) {
sessions.push(session.to_string());
}
for id in stamped_session_ids() {
if !sessions.iter().any(|have| have == &id) {
sessions.push(id);
}
}
approve_from_prompt_at(prompt, &sessions, &root(), now())
}
fn approve_from_prompt_at(
prompt: &str,
sessions: &[String],
root: &Path,
now: Result<u64>,
) -> Option<String> {
let ids = approved_ids(prompt);
if ids.is_empty() {
return None;
}
let mut said = String::new();
for id in ids {
let line = (|| {
if sessions.iter().all(|session| session.is_empty()) {
anyhow::bail!("approval needs a conversation id");
}
Store::open(
root,
now.as_ref()
.map_err(|e| anyhow::anyhow!("{e:#}"))
.copied()?,
)?
.approve_in(&id, sessions)
})();
match line {
Ok(text) => said.push_str(&format!("The person approved in this chat. {text}")),
Err(e) => said.push_str(&format!("approve {id}: {e:#}\n")),
}
}
Some(said)
}
fn now() -> Result<u64> {
Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs())
}
fn root() -> PathBuf {
crate::runtime_dir().join("approvals")
}
fn terminal_consent(is_terminal: bool) -> Result<()> {
if is_terminal {
return Ok(());
}
bail!(
"approve: consent is the person's. This needs a terminal; the person runs \
`ljos approve` in a terminal of their own"
)
}
pub fn approve(id: &str) -> Result<String> {
let terminal = unsafe { libc::isatty(0) } == 1;
terminal_consent(terminal)?;
Store::open(&root(), now()?)?.approve(id)
}
pub async fn request_approval(id: &str, peer: &rmcp::Peer<rmcp::RoleServer>) -> Result<String> {
let session = crate::conversation_id().unwrap_or_else(crate::holder_name);
let seat = crate::seat_name();
let cwd = std::env::current_dir()
.ok()
.and_then(|dir| fs::canonicalize(dir).ok())
.unwrap_or_else(|| PathBuf::from("."));
request_approval_at(id, &session, &seat, &cwd, peer, &root()).await
}
async fn request_approval_at(
id: &str,
session: &str,
seat: &str,
cwd: &Path,
peer: &rmcp::Peer<rmcp::RoleServer>,
root: &Path,
) -> Result<String> {
use rmcp::model::{ElicitRequestParams, ElicitationSchema};
let started = now()?;
let pending = Store::open(root, started)?.pending_for(id, session, seat, cwd)?;
let info = peer
.peer_info()
.context("approval client has not initialized")?;
let supports_form = info.capabilities.elicitation.as_ref().is_some_and(|cap| {
cap.form.is_some() || cap.url.is_none()
});
let tag = request_tag(&pending.id).unwrap_or_else(|| pending.id.clone());
if !supports_form {
bail!("this client cannot display a consent form; run `ljos approve {tag}` in your own terminal");
}
let schema = ElicitationSchema::from_json_schema(
serde_json::json!({
"type": "object",
"properties": {
"approve": {
"type": "boolean",
"title": "Allow this command once",
"description": "Consent to the exact command and directory shown above.",
"default": false
}
},
"required": ["approve"]
})
.as_object()
.expect("object schema")
.clone(),
)?;
let message = format!(
"Allow one attempt of this command?\nCommand (JSON): {}\nDirectory: {}\nConversation: {}\nRule: {}\nReason: {}\nRequest: {}\nThe grant expires fifteen minutes after the request was created.",
serde_json::to_string(&pending.scope.command)?,
serde_json::to_string(&pending.scope.cwd)?,
pending.scope.session, pending.scope.pattern, pending.scope.reason,
tag
);
let remaining = TTL_SECONDS.saturating_sub(started.saturating_sub(pending.created));
let answer = peer
.create_elicitation_with_timeout(
ElicitRequestParams::FormElicitationParams {
meta: None,
message,
requested_schema: schema,
},
Some(std::time::Duration::from_secs(remaining)),
)
.await
.context("client confirmation failed; command remains blocked")?;
if !consents(&answer) {
bail!("consent was not granted; command remains blocked");
}
Store::open(root, now()?)?.confirm(&pending)
}
fn consents(answer: &rmcp::model::ElicitResult) -> bool {
answer.action == rmcp::model::ElicitationAction::Accept
&& answer.content.as_ref().and_then(|v| v.get("approve")) == Some(&Value::Bool(true))
}
pub fn hook_output(input: &str, call: &HookCall, context: &str, verdict: Option<&Rule>) -> String {
let mut granted = false;
let out = hook_output_at(input, call, context, verdict, &root(), now(), &mut granted);
if let (true, Some(rule)) = (granted, verdict) {
crate::log_bypass("approval", "one-use grant", &call.cue, rule);
}
out
}
fn hook_output_at(
input: &str,
call: &HookCall,
context: &str,
verdict: Option<&Rule>,
root: &Path,
now: Result<u64>,
granted: &mut bool,
) -> String {
let Some(rule) = verdict.filter(|rule| rule.verdict == "ask") else {
return hook_output_ruled(call, context, verdict);
};
if call.event != "PreToolUse" || call.shape.asks() {
return hook_output_ruled(call, context, verdict);
}
let result = (|| {
let scope = Scope::from_hook(input, call, rule)?;
let now = now?;
Store::open(root, now)?.request_or_consume(scope, now)
})();
match result {
Ok(None) => {
*granted = true;
hook_output_ruled(call, context, None)
}
result => {
let mut pending = rule.clone();
let detail = match result {
Ok(Some(id)) => {
let tag = request_tag(&id).unwrap_or(id);
format!("Reply `approve {tag}` in this conversation, call the ljos_request_approval MCP tool with id {tag} for a consent form, or run `ljos approve {tag}` in your own terminal. Retry only after consent is recorded. The grant is for one attempt of this command in this directory and expires fifteen minutes after the request.")
}
Err(error) => format!(
"Approval could not be recorded: {error:#}. The command remains blocked."
),
Ok(None) => unreachable!(),
};
pending.reason = format!("{} {detail}", rule.reason);
hook_output_ruled(call, context, Some(&pending))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hook_call;
#[tokio::test]
async fn approval_uses_the_full_call_id_with_a_named_holder() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("approvals");
let input = input(temp.path());
let id = request_id(&output(&input, &ask(), &root, 100));
crate::as_thread(Some("conversation-1".into()), async {
let session = crate::conversation_id().unwrap();
assert_eq!(session, "conversation-1");
assert_ne!(session, crate::holder_name());
assert!(Store::open(&root, 101)
.unwrap()
.pending(&id, &session)
.is_ok());
crate::as_thread(Some("conversation-2".into()), async {
let other = crate::conversation_id().unwrap();
assert!(Store::open(&root, 102)
.unwrap()
.pending(&id, &other)
.is_err());
})
.await;
assert_eq!(crate::conversation_id().as_deref(), Some("conversation-1"));
})
.await;
}
fn ask() -> Rule {
Rule {
pattern: "git push*".into(),
verdict: "ask".into(),
reason: "A push needs consent.".into(),
}
}
fn one(id: &str) -> Vec<String> {
vec![id.to_string()]
}
fn input(cwd: &Path) -> String {
serde_json::json!({"hook_event_name":"PreToolUse", "turn_id":"turn-1",
"session_id":"conversation-1", "cwd":cwd, "tool_name":"Bash",
"tool_input":{"command":"git push origin main"}})
.to_string()
}
fn output(input: &str, rule: &Rule, root: &Path, now: u64) -> String {
hook_output_at(
input,
&hook_call(input),
"",
Some(rule),
root,
Ok(now),
&mut false,
)
}
fn request_id(output: &str) -> String {
let value: Value = serde_json::from_str(output).unwrap();
assert_eq!(value["hookSpecificOutput"]["permissionDecision"], "deny");
let reason = value["hookSpecificOutput"]["permissionDecisionReason"]
.as_str()
.unwrap();
reason
.split("ljos approve ")
.nth(1)
.unwrap()
.split('`')
.next()
.unwrap()
.into()
}
#[test]
fn a_question_quoted_in_a_reply_cannot_grant_consent() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("approvals");
let input = input(temp.path());
let tag = request_id(&output(&input, &ask(), &root, 100));
let full = Store::open(&root, 101).unwrap().requests[0].id.clone();
let packet = |answer: &str| {
format!(
"<send_user_message_question_reply>\n{}\n</send_user_message_question_reply>",
serde_json::json!([{"answer": answer,
"question": format!("Reply approve {full} to permit the command."),
"questionItemId": "confirmation-1"}]),
)
};
for answer in [
"no".to_string(),
"ok".to_string(),
format!("approve {}", &full[..26]),
format!("do not approve {full}"),
format!("no\napprove {full}"),
] {
let result =
approve_from_prompt_at(&packet(&answer), &one("conversation-1"), &root, Ok(102));
assert!(result.is_none(), "{answer}: {result:?}");
assert!(!Store::open(&root, 103).unwrap().requests[0].approved);
}
for quoted in [
format!("Do not approve {full}"),
format!("The question says approve {full}"),
format!("> approve {tag}"),
] {
assert!(
approve_from_prompt_at("ed, &one("conversation-1"), &root, Ok(104)).is_none()
);
}
let result = approve_from_prompt_at(
&packet(&format!("approve {tag} and retry")),
&one("conversation-1"),
&root,
Ok(105),
)
.unwrap();
assert!(result.contains("Approved once"), "{result}");
assert!(output(&input, &ask(), &root, 106).is_empty());
}
#[test]
fn short_tags_and_full_ids_share_one_grant() {
for use_tag in [false, true] {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("approvals");
let input = input(temp.path());
let tag = request_id(&output(&input, &ask(), &root, 100));
assert!(tag.len() <= 12 && tag.contains('-'), "{tag}");
let full = Store::open(&root, 101).unwrap().requests[0].id.clone();
assert_eq!(full.len(), 32);
let reference = if use_tag { &tag } else { &full };
let answer = approve_from_prompt_at(
&format!("APPROVE {}.", reference.to_ascii_uppercase()),
&one("conversation-1"),
&root,
Ok(102),
)
.unwrap();
assert!(answer.contains("Approved once"), "{answer}");
assert!(output(&input, &ask(), &root, 103).is_empty());
let mut store = Store::open(&root, 104).unwrap();
assert!(store.approve(&tag).is_err());
assert!(store.approve(&full).is_err());
}
}
#[test]
fn short_tag_references_match_exactly() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("approvals");
let input = input(temp.path());
let tag = request_id(&output(&input, &ask(), &root, 100));
let mut store = Store::open(&root, 101).unwrap();
let mut changed = tag.clone();
let last = changed.pop().unwrap();
changed.push(if last == '0' { '1' } else { '0' });
for invalid in [
tag[..tag.len() - 1].to_string(),
format!("{tag}0"),
changed,
store.requests[0].id[..31].to_string(),
"é".repeat(16),
] {
assert!(
store.pending(&invalid, "conversation-1").is_err(),
"{invalid}"
);
assert!(store.approve(&invalid).is_err(), "{invalid}");
assert!(!store.requests[0].approved);
}
}
#[test]
fn allocation_resamples_a_short_tag_collision() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("approvals");
let input = input(temp.path());
request_id(&output(&input, &ask(), &root, 100));
let mut store = Store::open(&root, 101).unwrap();
store.requests[0].id = "00112233000000000000000000000000".into();
let mut bytes = vec![0u8; 32];
bytes[..4].copy_from_slice(&[0x20, 0x11, 0x22, 0x33]);
bytes[16] = 1;
let allocated = store.allocate_id(&mut std::io::Cursor::new(bytes)).unwrap();
assert_eq!(allocated, "01000000000000000000000000000000");
assert_eq!(store.requests.len(), 1);
assert!(!store.requests[0].approved);
store.requests[0].id = "0".repeat(32);
assert!(store
.allocate_id(&mut std::io::Cursor::new(vec![0u8; 16 * 32]))
.is_err());
assert_eq!(store.requests.len(), 1);
}
#[test]
fn approval_allows_one_matching_retry_and_cannot_be_replayed() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("approvals");
let input = input(temp.path());
let id = request_id(&output(&input, &ask(), &root, 100));
assert_eq!(request_id(&output(&input, &ask(), &root, 101)), id);
Store::open(&root, 102).unwrap().approve(&id).unwrap();
assert!(output(&input, &ask(), &root, 103).is_empty());
assert!(Store::open(&root, 104).unwrap().approve(&id).is_err());
let other = request_id(&output(&input, &ask(), &root, 105));
assert_ne!(id, other);
assert!(Store::open(&root, 106).unwrap().approve(&id).is_err());
}
#[test]
fn the_person_approves_in_the_chat_for_this_conversation_only() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("approvals");
let input = input(temp.path());
let id = request_id(&output(&input, &ask(), &root, 100));
assert!(
approve_from_prompt_at("carry on", &one("conversation-1"), &root, Ok(101)).is_none()
);
let elsewhere = approve_from_prompt_at(
&format!("approve {id}"),
&one("conversation-2"),
&root,
Ok(101),
)
.unwrap();
assert!(
elsewhere.contains("not asked in this conversation"),
"{elsewhere}"
);
assert_eq!(
request_id(&output(&input, &ask(), &root, 102)),
id,
"still pending"
);
let said = approve_from_prompt_at(
&format!("Approve `{id}`, then retry."),
&one("conversation-1"),
&root,
Ok(103),
)
.unwrap();
assert!(said.contains("Approved once"), "{said}");
assert!(
output(&input, &ask(), &root, 104).is_empty(),
"the retry runs once"
);
assert_ne!(request_id(&output(&input, &ask(), &root, 105)), id);
}
#[test]
fn a_longer_spelling_of_the_same_conversation_grants() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("approvals");
let cwd = temp.path();
let session = "01a10b08-49eb-73e3-9a01-87e9d1f6ae2e";
let body = serde_json::json!({"hook_event_name":"PreToolUse", "turn_id":"turn-1",
"session_id": session, "cwd": cwd, "tool_name":"Bash",
"tool_input":{"command":"git push origin main"}})
.to_string();
let id = request_id(&output(&body, &ask(), &root, 100));
let said = approve_from_prompt_at(
&format!("approve {id}"),
&one(&format!("/tmp/work/{session}/prompt")),
&root,
Ok(101),
)
.unwrap();
assert!(said.contains("Approved once"), "{said}");
}
#[test]
fn a_chat_reply_to_an_expired_request_reports_expiry() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("approvals");
let input = input(temp.path());
let id = request_id(&output(&input, &ask(), &root, 100));
let said = approve_from_prompt_at(
&format!("approve {id}"),
&one("conversation-1"),
&root,
Ok(100 + TTL_SECONDS),
)
.unwrap();
assert!(said.contains("unknown, expired, or consumed"), "{said}");
assert!(!said.contains("not asked in this conversation"), "{said}");
assert_ne!(
request_id(&output(&input, &ask(), &root, 101 + TTL_SECONDS)),
id
);
}
#[test]
fn grants_are_bound_to_command_directory_conversation_tool_and_rule() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("approvals");
let other = tempfile::tempdir().unwrap();
let original = input(temp.path());
let id = request_id(&output(&original, &ask(), &root, 100));
Store::open(&root, 101).unwrap().approve(&id).unwrap();
for (pointer, replacement) in [
("/tool_input/command", "git push fork main"),
("/cwd", other.path().to_str().unwrap()),
("/session_id", "conversation-2"),
("/tool_name", "terminal"),
] {
let mut changed: Value = serde_json::from_str(&original).unwrap();
*changed.pointer_mut(pointer).unwrap() = replacement.into();
assert_ne!(
request_id(&output(&changed.to_string(), &ask(), &root, 102)),
id
);
}
for rule in [
Rule {
pattern: "git *".into(),
..ask()
},
Rule {
reason: "A different approval requirement.".into(),
..ask()
},
] {
assert_ne!(request_id(&output(&original, &rule, &root, 103)), id);
}
assert!(output(&original, &ask(), &root, 104).is_empty());
}
#[test]
fn a_tool_workdir_is_part_of_the_scope() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("approvals");
let other = tempfile::tempdir().unwrap();
let original = input(temp.path());
let id = request_id(&output(&original, &ask(), &root, 100));
Store::open(&root, 101).unwrap().approve(&id).unwrap();
let mut changed: Value = serde_json::from_str(&original).unwrap();
changed["tool_input"]["workdir"] = serde_json::json!(other.path());
assert_ne!(
request_id(&output(&changed.to_string(), &ask(), &root, 102)),
id
);
assert!(output(&original, &ask(), &root, 103).is_empty());
}
#[test]
fn expired_and_future_dated_requests_do_not_authorize() {
for approval_time in [99, 100 + TTL_SECONDS] {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("approvals");
let input = input(temp.path());
let id = request_id(&output(&input, &ask(), &root, 100));
assert!(Store::open(&root, approval_time)
.unwrap()
.approve(&id)
.is_err());
}
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("approvals");
let input = input(temp.path());
let id = request_id(&output(&input, &ask(), &root, 100));
Store::open(&root, 101).unwrap().approve(&id).unwrap();
assert_ne!(
request_id(&output(&input, &ask(), &root, 100 + TTL_SECONDS)),
id
);
}
#[test]
fn deny_verdicts_win_over_an_approved_request() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("approvals");
let input = input(temp.path());
let id = request_id(&output(&input, &ask(), &root, 100));
Store::open(&root, 101).unwrap().approve(&id).unwrap();
for pattern in ["ljos-policyd", "git push*"] {
let deny = Rule {
pattern: pattern.into(),
verdict: "deny".into(),
reason: "Denied.".into(),
};
assert_eq!(
output(&input, &deny, &root, 102),
hook_output_ruled(&hook_call(&input), "", Some(&deny))
);
}
assert!(output(&input, &ask(), &root, 103).is_empty());
}
#[test]
fn a_native_ask_does_not_create_a_local_grant() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("approvals");
let mut value: Value = serde_json::from_str(&input(temp.path())).unwrap();
value.as_object_mut().unwrap().remove("turn_id");
let input = value.to_string();
let response: Value = serde_json::from_str(&output(&input, &ask(), &root, 100)).unwrap();
assert_eq!(response["hookSpecificOutput"]["permissionDecision"], "ask");
assert!(!root.exists());
}
#[test]
fn a_grok_ask_is_the_permission_prompt_and_mints_no_request() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("approvals");
let input = serde_json::json!({
"hookEventName": "pre_tool_use",
"hook_event_name": "PreToolUse",
"sessionId": "conversation-1",
"cwd": temp.path(),
"toolName": "run_terminal_command",
"toolInput": {"command": "git push origin main"}
})
.to_string();
let call = hook_call(&input);
assert_eq!(call.shape, crate::HookShape::CamelCase);
assert!(call.shape.asks());
let response: Value = serde_json::from_str(&output(&input, &ask(), &root, 100)).unwrap();
assert_eq!(response["decision"], "ask");
assert_eq!(response["hookSpecificOutput"]["permissionDecision"], "ask");
let reason = response["reason"].as_str().unwrap();
assert!(!reason.contains("ljos approve"));
assert!(!root.exists());
}
#[test]
fn approval_does_not_depend_on_the_retry_turn_id() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("approvals");
let input = input(temp.path());
let id = request_id(&output(&input, &ask(), &root, 100));
Store::open(&root, 101).unwrap().approve(&id).unwrap();
let mut retry: Value = serde_json::from_str(&input).unwrap();
retry["turn_id"] = "turn-2".into();
assert!(output(&retry.to_string(), &ask(), &root, 102).is_empty());
}
#[test]
fn missing_scope_and_corrupt_state_keep_the_command_blocked() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("approvals");
let original = input(temp.path());
for key in ["session_id", "cwd", "tool_name"] {
let mut value: Value = serde_json::from_str(&original).unwrap();
value.as_object_mut().unwrap().remove(key);
let response: Value =
serde_json::from_str(&output(&value.to_string(), &ask(), &root, 100)).unwrap();
assert_eq!(response["hookSpecificOutput"]["permissionDecision"], "deny");
assert!(!root.exists());
}
request_id(&output(&original, &ask(), &root, 101));
fs::write(root.join("requests.json"), "not json").unwrap();
let response: Value = serde_json::from_str(&output(&original, &ask(), &root, 102)).unwrap();
assert_eq!(response["hookSpecificOutput"]["permissionDecision"], "deny");
}
#[test]
fn a_duplicate_descriptor_does_not_retain_a_finished_transaction() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("approvals");
let store = Store::open(&root, 100).unwrap();
let inherited = store.file.try_clone().unwrap();
drop(store);
let next = Store::open(&root, 101).unwrap();
assert!(next.requests.is_empty());
drop(inherited);
}
#[test]
fn simultaneous_retries_consume_at_most_one_grant() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("approvals");
let input = input(temp.path());
let id = request_id(&output(&input, &ask(), &root, 100));
Store::open(&root, 101).unwrap().approve(&id).unwrap();
let barrier = std::sync::Arc::new(std::sync::Barrier::new(8));
let workers: Vec<_> = (0..8)
.map(|_| {
let root = root.clone();
let input = input.clone();
let barrier = barrier.clone();
std::thread::spawn(move || {
barrier.wait();
output(&input, &ask(), &root, 102).is_empty()
})
})
.collect();
let allowed = workers
.into_iter()
.map(|worker| worker.join().unwrap())
.filter(|allowed| *allowed)
.count();
assert_eq!(allowed, 1);
}
#[test]
fn client_consent_requires_accept_and_a_true_boolean() {
use rmcp::model::{ElicitResult, ElicitationAction};
for action in [
ElicitationAction::Accept,
ElicitationAction::Decline,
ElicitationAction::Cancel,
] {
for content in [
None,
Some(serde_json::json!({})),
Some(serde_json::json!({"approve":false})),
Some(serde_json::json!({"approve":"true"})),
Some(serde_json::json!({"approve":1})),
Some(serde_json::json!({"approve":true})),
] {
let mut answer = ElicitResult::new(action.clone());
answer.content = content.clone();
assert_eq!(
consents(&answer),
action == ElicitationAction::Accept
&& content == Some(serde_json::json!({"approve":true}))
);
}
}
}
#[test]
fn a_grant_matches_seat_and_directory_when_the_conversation_id_differs() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("approvals");
let input = input(temp.path());
let id = request_id(&output(&input, &ask(), &root, 100));
let seat = crate::seat_name();
let cwd = fs::canonicalize(temp.path()).unwrap();
let store = Store::open(&root, 101).unwrap();
assert!(store.pending(&id, "not-this-conversation").is_err());
store
.pending_for(&id, "not-this-conversation", &seat, &cwd)
.expect("the same seat, directory and request id grant");
assert!(store
.pending_for(&id, "not-this-conversation", "someone-else", &cwd)
.is_err());
let elsewhere = tempfile::tempdir().unwrap();
assert!(store
.pending_for(
&id,
"not-this-conversation",
&seat,
&fs::canonicalize(elsewhere.path()).unwrap(),
)
.is_err());
}
#[test]
fn a_terminal_grants_even_when_a_runner_is_in_the_environment() {
assert!(terminal_consent(true).is_ok());
let err = terminal_consent(false).unwrap_err().to_string();
assert!(err.contains("terminal"), "{err}");
}
#[test]
fn a_confirmation_rechecks_conversation_contents_expiry_and_single_use() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("approvals");
let input = input(temp.path());
let id = request_id(&output(&input, &ask(), &root, 100));
let pending = {
let store = Store::open(&root, 101).unwrap();
assert!(store.pending(&id, "conversation-2").is_err());
store.pending(&id, "conversation-1").unwrap()
};
assert_eq!(request_id(&output(&input, &ask(), &root, 102)), id);
for changed in ["command", "directory", "rule", "created"] {
let mut snapshot = pending.clone();
match changed {
"command" => snapshot.scope.command.push_str(" --force"),
"directory" => snapshot.scope.cwd = temp.path().join("elsewhere"),
"rule" => snapshot.scope.reason.push_str(" changed"),
"created" => snapshot.created += 1,
_ => unreachable!(),
}
assert!(Store::open(&root, 103).unwrap().confirm(&snapshot).is_err());
}
Store::open(&root, 104).unwrap().confirm(&pending).unwrap();
assert!(Store::open(&root, 105).unwrap().confirm(&pending).is_err());
assert!(output(&input, &ask(), &root, 106).is_empty());
assert!(Store::open(&root, 107).unwrap().confirm(&pending).is_err());
let id = request_id(&output(&input, &ask(), &root, 200));
let pending = Store::open(&root, 201)
.unwrap()
.pending(&id, "conversation-1")
.unwrap();
assert!(Store::open(&root, 200 + TTL_SECONDS)
.unwrap()
.confirm(&pending)
.is_err());
assert_ne!(
request_id(&output(&input, &ask(), &root, 200 + TTL_SECONDS)),
id
);
}
}