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;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct Scope {
session: String,
shape: String,
tool: String,
cwd: PathBuf,
command: String,
pattern: String,
reason: String,
}
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(),
shape: format!("{:?}", call.shape),
tool: tool.into(),
cwd,
command: call.cue.clone(),
pattern: rule.pattern.clone(),
reason: rule.reason.clone(),
})
}
}
#[derive(Debug, 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 mut bytes = [0u8; 16];
File::open("/dev/urandom")?.read_exact(&mut bytes)?;
let id: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
if self.requests.iter().any(|request| request.id == id) {
bail!("approval id collision; retry the command");
}
self.requests.push(Request {
id: id.clone(),
scope,
created: now,
approved: false,
});
self.save()?;
Ok(Some(id))
}
fn approve(&mut self, id: &str) -> Result<String> {
if id.len() != 32 || !id.bytes().all(|byte| byte.is_ascii_hexdigit()) {
bail!("approval id must be the 32 hexadecimal characters printed by the hook");
}
let request = self
.requests
.iter_mut()
.find(|request| request.id == id)
.context(
"approval request is unknown, expired, or consumed; retry the original command",
)?;
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, session: &str) -> Result<String> {
let ours = self
.requests
.iter()
.any(|request| request.id == id && request.scope.session == session);
if !ours {
bail!("approval {id} was not asked in this conversation");
}
self.approve(id)
}
}
fn approved_ids(prompt: &str) -> Vec<String> {
let words: Vec<&str> = prompt
.split(|c: char| c.is_whitespace() || c == '`' || c == '"' || c == '\'')
.filter(|w| !w.is_empty())
.collect();
words
.windows(2)
.filter(|w| w[0].eq_ignore_ascii_case("approve"))
.map(|w| {
w[1].trim_end_matches(['.', ',', ';', ':', '!'])
.to_ascii_lowercase()
})
.filter(|id| id.len() == 32 && id.bytes().all(|b| b.is_ascii_hexdigit()))
.collect()
}
pub fn approve_from_prompt(prompt: &str, session: Option<&str>) -> Option<String> {
approve_from_prompt_at(prompt, session, &root(), now())
}
fn approve_from_prompt_at(
prompt: &str,
session: Option<&str>,
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 = (|| {
let session = session
.filter(|s| !s.is_empty())
.context("approval needs a conversation id")?;
Store::open(
root,
now.as_ref()
.map_err(|e| anyhow::anyhow!("{e:#}"))
.copied()?,
)?
.approve_in(&id, session)
})();
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")
}
pub fn approve(id: &str) -> Result<String> {
let terminal = unsafe { libc::isatty(0) } == 1;
if crate::under_a_runner() || !terminal {
bail!(
"approve: consent is the person's. This runs under an agent runner or without a \
terminal; the person runs `ljos approve {id}` in a terminal of their own"
);
}
Store::open(&root(), now()?)?.approve(id)
}
pub fn hook_output(input: &str, call: &HookCall, context: &str, verdict: Option<&Rule>) -> String {
hook_output_at(input, call, context, verdict, &root(), now())
}
fn hook_output_at(
input: &str,
call: &HookCall,
context: &str,
verdict: Option<&Rule>,
root: &Path,
now: Result<u64>,
) -> 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) => hook_output_ruled(call, context, None),
result => {
let mut pending = rule.clone();
let detail = match result {
Ok(Some(id)) => format!("Ask the person to reply `approve {id}` in this conversation, or to run `ljos approve {id}` in a terminal of their own (it refuses under an agent); retry this command once they have. The grant is for one attempt in this directory and conversation 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;
fn ask() -> Rule {
Rule {
pattern: "git push*".into(),
verdict: "ask".into(),
reason: "A push needs consent.".into(),
}
}
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))
}
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 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", Some("conversation-1"), &root, Ok(101)).is_none()
);
let elsewhere = approve_from_prompt_at(
&format!("approve {id}"),
Some("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."),
Some("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 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 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);
}
}