1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
//! Chained storage for prompted permission grants (issue #2524 item 3).
//!
//! `permission-log.jsonl` captured the right fields per line (`ts_claim`,
//! `conversation_id`, `tool`, `kind`, `target`, `decision`, `scope`) but, like
//! `denial-journal.jsonl` before #2085, was flat/ungrounded: no chain, no CID,
//! silently editable or truncatable. This module gives it the same treatment
//! [`crate::denial_journal`] already got — [`event_journal`]'s [`Journal`],
//! [`JournalLine`] and [`verify_chain`] over a [`PermissionRecord`] payload —
//! reusing the code rather than standing a third journal up beside it.
//!
//! # Two files, one encoding
//!
//! The grant log and the deny log stay **separate files**: merging them would
//! change *when* denial evidence is written (the denial journal is opt-in via
//! [`crate::denial_journal::DENIAL_JOURNAL_PATH_ENV`]; the permission log is
//! unconditional, matching its existing behavior) and would touch the denial
//! journal's own path, env var and tests for no behavioral gain. What this
//! module shares with `denial_journal` — the chain shape, the pre-chain
//! rotation, the append machinery — comes from [`event_journal`] itself, so
//! the two files are one encoding, not two.
//!
//! # Still a review artifact, not authority
//!
//! Same invariant as before migration: nothing reads this log back into
//! authority. The one reader (`/permissions audit`) displays it to a human.
use std::path::Path;
use crate::event_journal::{self, JournalLine};
use crate::PermissionRecord;
/// The chain's reader-side vocabulary, re-exported so a caller does not have
/// to know this journal is built on the event journal's machinery.
pub use crate::event_journal::{head_path, read_head, verify_chain, ChainBreak};
/// One chained permission-decision line: the record, its parent link, and the
/// address that covers both.
pub type PermissionLine = JournalLine<PermissionRecord>;
/// Where a pre-chain journal is moved to when this log adopts the chain.
/// Thin, `PermissionRecord`-specialized wrapper over
/// [`event_journal::pre_chain_path`], shared with every other migrating
/// journal (see [`crate::denial_journal::pre_chain_path`]).
#[must_use]
pub fn pre_chain_path(path: &Path) -> std::path::PathBuf {
event_journal::pre_chain_path(path)
}
/// Append one decision as a chained line, rotating a pre-chain flat log aside
/// on first use and advancing the head ref beside it.
///
/// # Errors
///
/// Propagates a filesystem or canonical-encoding failure.
pub fn append_record(path: &Path, record: PermissionRecord) -> anyhow::Result<PermissionLine> {
event_journal::rotate_pre_chain::<PermissionRecord>(path)?;
let mut journal = event_journal::resume(path);
event_journal::append_to(&mut journal, path, record)
}
/// Parse an append-only journal. Corrupt/partial lines are skipped, same as
/// [`denial_journal::read_jsonl`](crate::denial_journal::read_jsonl) — the
/// gap they leave behind is a [`ChainBreak::BrokenLink`] at the line after,
/// which [`verify_chain`] reports rather than hiding.
#[must_use]
pub fn read_jsonl(body: &str) -> Vec<PermissionLine> {
event_journal::read_jsonl(body)
}
/// Payloads out of chained lines, newest-last (append order) — what a display
/// reader like `/permissions audit` wants, without exposing the chain shape.
#[must_use]
pub fn records(lines: &[PermissionLine]) -> Vec<PermissionRecord> {
lines.iter().map(|l| l.node.payload().clone()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event_journal::Journal;
use crate::DenialKind;
fn rec(target: &str) -> PermissionRecord {
PermissionRecord::new(
"conv-1",
"run_command",
DenialKind::Exec,
target,
"allow",
"once",
)
}
fn chain(n: usize) -> (Journal, Vec<PermissionLine>) {
let mut journal = Journal::new();
let lines = (0..n)
.map(|i| journal.append(rec(&format!("cmd-{i}"))).expect("append"))
.collect();
(journal, lines)
}
#[test]
fn an_intact_chain_of_grants_verifies() {
let (journal, lines) = chain(4);
let head = journal.head().expect("head").to_string();
assert_eq!(verify_chain(&lines, Some(&head)), vec![]);
}
/// RED FIRST: a hand-edited line breaks `verify_chain`, exactly the
/// property the flat file never had.
#[test]
fn a_hand_edited_line_fails_verification() {
let (_, mut lines) = chain(3);
lines[1].node.payload.target = "tampered".to_string();
assert!(verify_chain(&lines, None).contains(&ChainBreak::Edited { index: 1 }));
}
#[test]
fn a_deleted_record_fails_verification_though_every_survivor_is_intact() {
let (_, mut lines) = chain(4);
lines.remove(1);
let breaks = verify_chain(&lines, None);
assert!(breaks.contains(&ChainBreak::BrokenLink { index: 1 }));
assert!(lines.iter().all(PermissionLine::is_intact));
}
#[test]
fn a_truncated_tail_is_caught_only_by_the_head_ref() {
let (journal, mut lines) = chain(5);
let head = journal.head().expect("head").to_string();
lines.truncate(2);
assert_eq!(verify_chain(&lines, None), vec![]);
assert_eq!(
verify_chain(&lines, Some(&head)),
vec![ChainBreak::Truncated {
expected_head: head
}],
);
}
/// One encoding, not two: a legacy flat `PermissionRecord` line is not
/// read back as a chained one.
#[test]
fn a_pre_chain_line_is_not_read_as_a_chained_record() {
let legacy = serde_json::to_string(&rec("legacy")).unwrap();
assert!(read_jsonl(&legacy).is_empty());
}
#[test]
fn append_record_creates_dirs_and_chains_across_calls() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("nested").join("permission-log.jsonl");
let first = append_record(&path, rec("npm")).unwrap();
let second = append_record(&path, rec("docs.rs")).unwrap();
assert_eq!(second.parent().unwrap().to_string(), first.id);
let body = std::fs::read_to_string(&path).unwrap();
let lines = read_jsonl(&body);
assert_eq!(lines.len(), 2);
assert_eq!(records(&lines)[1].target, "docs.rs");
let head = read_head(&path).unwrap();
assert_eq!(head, second.id);
assert_eq!(verify_chain(&lines, Some(&head)), vec![]);
}
/// A flat pre-existing log is rotated aside once, then chaining starts
/// clean — mirrors `denial_journal`'s migration behavior exactly.
#[test]
fn a_pre_existing_flat_log_is_rotated_aside_on_first_chained_append() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("permission-log.jsonl");
std::fs::write(
&path,
format!("{}\n", serde_json::to_string(&rec("old")).unwrap()),
)
.unwrap();
append_record(&path, rec("new")).unwrap();
let pre_chain = event_journal::pre_chain_path(&path);
assert!(pre_chain.exists(), "old flat log kept, moved aside");
let body = std::fs::read_to_string(&path).unwrap();
let lines = read_jsonl(&body);
assert_eq!(
lines.len(),
1,
"chain starts fresh, does not swallow the flat line"
);
assert_eq!(records(&lines)[0].target, "new");
}
}