use std::io::Read;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use serde_json::json;
use sha2::{Digest, Sha256};
use crate::cli::{StopGuardArgs, StopGuardFormat as Format};
use crate::error::Error;
const MEMORY_DIR: &str = "onepipeline/stop-guard";
const ARM_A_WATCH: &str = "onepipeline watch";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Session(String);
impl Session {
fn named(text: String) -> Option<Self> {
(!text.trim().is_empty() && !text.contains('\0')).then_some(Self(text))
}
fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Stop {
First,
Continuation,
}
impl Stop {
const fn of(continuation: bool) -> Self {
if continuation {
Self::Continuation
} else {
Self::First
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Asked {
pub session: Session,
pub stop: Stop,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct NeutralInput {
session: Option<String>,
#[serde(default)]
continuation: bool,
}
#[derive(Debug, Deserialize)]
struct HookInput {
session_id: Option<String>,
#[serde(default)]
stop_hook_active: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Verdict {
Block(String),
Warn(String),
None,
}
impl Verdict {
pub(crate) fn render(&self, format: Format) -> String {
let object = match (format, self) {
(Format::Neutral, Self::Block(reason)) => json!({"verdict": "block", "reason": reason}),
(Format::Neutral, Self::Warn(message)) => {
json!({"verdict": "warn", "message": message})
}
(Format::Neutral, Self::None) => json!({"verdict": "none"}),
(Format::ClaudeCode | Format::Codex, Self::Block(reason)) => {
json!({"decision": "block", "reason": reason})
}
(Format::ClaudeCode | Format::Codex, Self::Warn(message)) => {
json!({"systemMessage": message})
}
(Format::ClaudeCode | Format::Codex, Self::None) => return String::new(),
};
format!("{object}\n")
}
}
pub(crate) fn asked(args: &StopGuardArgs) -> Option<Asked> {
if let Some(session) = &args.session {
return named(session.clone(), args.continuation);
}
let mut text = String::new();
std::io::stdin().read_to_string(&mut text).ok()?;
let object: serde_json::Value = serde_json::from_str(&text).ok()?;
if !object.is_object() {
return None;
}
match args.format {
Format::Neutral => {
let input: NeutralInput = serde_json::from_value(object).ok()?;
named(input.session?, input.continuation || args.continuation)
}
Format::ClaudeCode | Format::Codex => {
let input: HookInput = serde_json::from_value(object).ok()?;
named(
input.session_id?,
input.stop_hook_active || args.continuation,
)
}
}
}
fn named(session: String, continuation: bool) -> Option<Asked> {
Some(Asked {
session: Session::named(session)?,
stop: Stop::of(continuation),
})
}
pub(crate) fn guard(root: &Path, asked: &Asked) -> (Verdict, Vec<String>) {
let session = asked.session.as_str();
let unwatched = match crate::unwatched::unwatched(root, session) {
Ok(unwatched) => unwatched,
Err(error) => return (unguarded(session, &error), Vec::new()),
};
if unwatched.reported.is_empty() {
return match forget(session) {
Ok(()) => (Verdict::None, unwatched.unresolved),
Err(why) => (unforgotten(session, &why), unwatched.unresolved),
};
}
let report = crate::verbs::render_unwatched(&unwatched);
let unresolved = unwatched.unresolved;
let digest = hex(&Sha256::digest(report.as_bytes()));
if asked.stop == Stop::Continuation {
match remembered(session) {
Err(why) => {
return (
stood_aside(
session,
&report,
&format!("could not read what it last blocked on ({why})"),
),
unresolved,
)
}
Ok(Some(last)) if last == digest => return (Verdict::None, unresolved),
Ok(_) => {}
}
}
if let Err(why) = remember(session, &digest) {
return (
stood_aside(
session,
&report,
&format!("could not record what it would block on ({why})"),
),
unresolved,
);
}
(Verdict::Block(report), unresolved)
}
fn unguarded(session: &str, error: &Error) -> Verdict {
Verdict::Warn(format!(
"stop-guard: this stop is unguarded, because whether a run this session owns is \
unwatched could not be answered ({error}); ask it by hand with `onepipeline unwatched \
--session {session}`."
))
}
fn stood_aside(session: &str, report: &str, because: &str) -> Verdict {
let lines: Vec<&str> = report.lines().collect();
Verdict::Warn(format!(
"stop-guard: {} run(s) this session owns are unwatched and this stop was not refused, \
because whether it had already been refused for the same runs could not be answered \
— the guard {because}; ask it by hand with `onepipeline unwatched --session {session}` \
and arm `{ARM_A_WATCH} <run>` on each:\n{}",
lines.len(),
lines.join("\n")
))
}
fn unforgotten(session: &str, why: &str) -> Verdict {
Verdict::Warn(format!(
"stop-guard: nothing this session owns is unwatched, but the guard could not remove \
what it last blocked on ({why}), so a later continuation over that same report would \
be let through unrefused; remove it by hand, and ask again with `onepipeline unwatched \
--session {session}`."
))
}
fn memory(session: &str) -> Result<PathBuf, String> {
let state = std::env::var_os("XDG_STATE_HOME")
.map(PathBuf::from)
.filter(|path| path.is_absolute());
let root = match state {
Some(state) => state,
None => home()
.ok_or_else(|| {
"neither XDG_STATE_HOME nor a home directory names a state root".to_owned()
})?
.join(".local")
.join("state"),
};
Ok(root
.join(MEMORY_DIR)
.join(hex(&Sha256::digest(session.as_bytes()))))
}
fn home() -> Option<PathBuf> {
["HOME", "USERPROFILE"]
.iter()
.filter_map(std::env::var_os)
.map(PathBuf::from)
.find(|path| path.is_absolute())
}
fn remembered(session: &str) -> Result<Option<String>, String> {
let path = memory(session)?;
match std::fs::read(&path) {
Ok(bytes) => String::from_utf8(bytes)
.ok()
.map(|text| text.trim().to_owned())
.filter(|digest| {
digest.len() == 64
&& digest
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
})
.map(Some)
.ok_or_else(|| format!("{}: not a digest this guard wrote", path.display())),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(format!("{}: {error}", path.display())),
}
}
fn remember(session: &str, digest: &str) -> Result<(), String> {
let path = memory(session)?;
let parent = path
.parent()
.ok_or_else(|| format!("{} has no parent directory", path.display()))?;
std::fs::create_dir_all(parent).map_err(|error| format!("{}: {error}", parent.display()))?;
std::fs::write(&path, format!("{digest}\n"))
.map_err(|error| format!("{}: {error}", path.display()))
}
fn forget(session: &str) -> Result<(), String> {
let path = memory(session)?;
match std::fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(format!("{}: {error}", path.display())),
}
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_verdict_renders_under_every_format() {
let block = Verdict::Block("run-1 ACTIVE nothing has recorded a watch on it\n".into());
let warn = Verdict::Warn("stop-guard: unguarded".into());
for format in [Format::ClaudeCode, Format::Codex] {
assert_eq!(
block.render(format),
"{\"decision\":\"block\",\"reason\":\"run-1 ACTIVE nothing has recorded a watch on it\\n\"}\n"
);
assert_eq!(
warn.render(format),
"{\"systemMessage\":\"stop-guard: unguarded\"}\n"
);
assert_eq!(Verdict::None.render(format), "");
}
assert_eq!(
block.render(Format::Neutral),
"{\"verdict\":\"block\",\"reason\":\"run-1 ACTIVE nothing has recorded a watch on it\\n\"}\n"
);
assert_eq!(
warn.render(Format::Neutral),
"{\"verdict\":\"warn\",\"message\":\"stop-guard: unguarded\"}\n"
);
assert_eq!(
Verdict::None.render(Format::Neutral),
"{\"verdict\":\"none\"}\n"
);
}
#[test]
fn the_hook_payload_is_read_for_its_two_fields_and_the_rest_is_ignored() {
let input: HookInput = serde_json::from_str(
r#"{"session_id":"s-1","stop_hook_active":true,"transcript_path":"/t","cwd":"/c","hook_event_name":"Stop","last_assistant_message":null}"#,
)
.expect("a payload with more fields than this reads");
assert_eq!(input.session_id.as_deref(), Some("s-1"));
assert!(input.stop_hook_active);
assert!(serde_json::from_str::<NeutralInput>(r#"{"session_id":"s-1"}"#).is_err());
let neutral: NeutralInput =
serde_json::from_str(r#"{"session":"s-1"}"#).expect("the neutral object reads");
assert_eq!(neutral.session.as_deref(), Some("s-1"));
assert!(!neutral.continuation);
}
#[test]
fn a_blank_session_names_nobody() {
assert_eq!(named(" ".into(), false), None);
assert_eq!(named("a\0b".into(), false), None);
assert_eq!(
named("s-1".into(), true),
Some(Asked {
session: Session("s-1".into()),
stop: Stop::Continuation
})
);
}
#[test]
fn the_memory_is_keyed_by_a_digest_under_an_absolute_state_root() {
let path = memory("session-x").expect("a memory path");
let name = path
.file_name()
.expect("a file name")
.to_string_lossy()
.into_owned();
assert_eq!(name.len(), 64, "{name}");
assert!(name.bytes().all(|byte| byte.is_ascii_hexdigit()), "{name}");
assert!(
path.to_string_lossy().contains(MEMORY_DIR),
"{}",
path.display()
);
assert!(path.is_absolute() || home().is_none(), "{}", path.display());
}
}