review: memory writes from a secret session are not written to Honcho
The journal now records each call's class (absent on the owner's undo and on older lines, which still read). The mirror turns everything a secret write would conclude or send to the fleet into one Skip, after its deletions, which still go; a note whose own header says secret is skipped the same way. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use bxcore::{SessionId, Timestamp};
|
use bxcore::{DataClass, SessionId, Timestamp};
|
||||||
|
|
||||||
use crate::files::Store;
|
use crate::files::Store;
|
||||||
use crate::honcho::ledger::{self, Action, Item, Ledger, Record, Workspace};
|
use crate::honcho::ledger::{self, Action, Item, Ledger, Record, Workspace};
|
||||||
@@ -18,6 +18,9 @@ pub const OWNER: &str = "owner";
|
|||||||
/// The part of an edit that has no Boxmaker session to stamp its conclusion with.
|
/// The part of an edit that has no Boxmaker session to stamp its conclusion with.
|
||||||
pub const NO_SESSION: &str = "the edit has no Boxmaker session";
|
pub const NO_SESSION: &str = "the edit has no Boxmaker session";
|
||||||
|
|
||||||
|
/// Why a write from a `secret` session sends nothing but its deletions (M5c spec, section 3).
|
||||||
|
pub const SECRET_WRITE: &str = "the write is from a secret session";
|
||||||
|
|
||||||
/// `before` and `after` as multisets of entry text: what `before` has more of (dropped, in
|
/// `before` and `after` as multisets of entry text: what `before` has more of (dropped, in
|
||||||
/// `before`'s order), and what `after` has more of (added, in `after`'s order).
|
/// `before`'s order), and what `after` has more of (added, in `after`'s order).
|
||||||
pub fn diff(before: &[String], after: &[String]) -> (Vec<String>, Vec<String>) {
|
pub fn diff(before: &[String], after: &[String]) -> (Vec<String>, Vec<String>) {
|
||||||
@@ -138,8 +141,25 @@ fn note_actions(
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `record` came from a `secret` session: by the class the journal gives the call, or, for
|
||||||
|
/// a line from before it did, by the class any of its notes was written with.
|
||||||
|
fn is_secret(record: &journal::Record, notes: &[notes::Note]) -> bool {
|
||||||
|
record.class.as_deref() == Some(notes::class_name(DataClass::Secret))
|
||||||
|
|| notes.iter().any(|n| {
|
||||||
|
matches!(
|
||||||
|
n.kind,
|
||||||
|
Kind::Noted {
|
||||||
|
class: DataClass::Secret,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// What `record` asks of Honcho, in the order to send it: an identity edit's, or a write saved as
|
/// What `record` asks of Honcho, in the order to send it: an identity edit's, or a write saved as
|
||||||
/// notes. Anything else (a rejection) asks nothing.
|
/// notes. Anything else (a rejection) asks nothing. From a `secret` session only the deletions
|
||||||
|
/// go, in their order, and everything else the write asked is one `Skip` after them: deleting
|
||||||
|
/// leaks nothing and keeps Honcho consistent with the files.
|
||||||
pub fn actions(
|
pub fn actions(
|
||||||
record: &journal::Record,
|
record: &journal::Record,
|
||||||
session: Option<&SessionId>,
|
session: Option<&SessionId>,
|
||||||
@@ -147,10 +167,22 @@ pub fn actions(
|
|||||||
owner: &str,
|
owner: &str,
|
||||||
) -> Vec<Action> {
|
) -> Vec<Action> {
|
||||||
let notes = notes_of(record);
|
let notes = notes_of(record);
|
||||||
match &record.file {
|
let all = match &record.file {
|
||||||
Some(file) => identity_actions(record, file, ¬es, session, agent, owner),
|
Some(file) => identity_actions(record, file, ¬es, session, agent, owner),
|
||||||
None => note_actions(record, ¬es, session, agent),
|
None => note_actions(record, ¬es, session, agent),
|
||||||
|
};
|
||||||
|
if !is_secret(record, ¬es) {
|
||||||
|
return all;
|
||||||
}
|
}
|
||||||
|
let (mut kept, held): (Vec<Action>, Vec<Action>) = all
|
||||||
|
.into_iter()
|
||||||
|
.partition(|a| matches!(a, Action::Unconclude { .. }));
|
||||||
|
if !held.is_empty() {
|
||||||
|
kept.push(Action::Skip {
|
||||||
|
why: SECRET_WRITE.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
kept
|
||||||
}
|
}
|
||||||
|
|
||||||
/// With `[honcho]`: decides each journalled edit into the ledger.
|
/// With `[honcho]`: decides each journalled edit into the ledger.
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ pub struct Record {
|
|||||||
/// For an undo, the edit it reverted.
|
/// For an undo, the edit it reverted.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub undoes: Option<String>,
|
pub undoes: Option<String>,
|
||||||
|
/// The call's class (`public`, `private` or `secret`, as on the wire): what its session had
|
||||||
|
/// read. `None` for the owner's undo and for lines from before it was recorded.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub class: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn file_of(name: &str) -> Option<MemoryFile> {
|
fn file_of(name: &str) -> Option<MemoryFile> {
|
||||||
@@ -132,6 +136,7 @@ pub fn record(store: &Store, ctx: &Context, outcome: &Outcome) -> Result<String,
|
|||||||
untrusted: ctx.untrusted,
|
untrusted: ctx.untrusted,
|
||||||
flag: outcome.flag.map(str::to_string),
|
flag: outcome.flag.map(str::to_string),
|
||||||
undoes: None,
|
undoes: None,
|
||||||
|
class: Some(crate::notes::class_name(ctx.taint).to_string()),
|
||||||
},
|
},
|
||||||
ctx.now,
|
ctx.now,
|
||||||
)
|
)
|
||||||
@@ -227,6 +232,7 @@ pub fn undo(store: &Store, id: &str, now: Timestamp) -> Result<String, String> {
|
|||||||
untrusted: false,
|
untrusted: false,
|
||||||
flag: None,
|
flag: None,
|
||||||
undoes: Some(id.to_string()),
|
undoes: Some(id.to_string()),
|
||||||
|
class: None,
|
||||||
},
|
},
|
||||||
now,
|
now,
|
||||||
)?;
|
)?;
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ pub fn minute(time: Timestamp) -> String {
|
|||||||
format!("{}Z", s.get(..16).unwrap_or(&s))
|
format!("{}Z", s.get(..16).unwrap_or(&s))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn class_name(class: DataClass) -> &'static str {
|
/// A class as the notes and the journal spell it, the same as on the wire.
|
||||||
|
pub fn class_name(class: DataClass) -> &'static str {
|
||||||
match class {
|
match class {
|
||||||
DataClass::Public => "public",
|
DataClass::Public => "public",
|
||||||
DataClass::Private => "private",
|
DataClass::Private => "private",
|
||||||
|
|||||||
@@ -246,6 +246,7 @@ pub fn reject(store: &Store, id: &str, now: Timestamp) -> Result<String, String>
|
|||||||
untrusted: false,
|
untrusted: false,
|
||||||
flag: None,
|
flag: None,
|
||||||
undoes: Some(id.to_string()),
|
undoes: Some(id.to_string()),
|
||||||
|
class: None,
|
||||||
},
|
},
|
||||||
now,
|
now,
|
||||||
)?;
|
)?;
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ fn record(id: &str, action: &str, file: Option<&str>, before: &[&str], after: &[
|
|||||||
untrusted: false,
|
untrusted: false,
|
||||||
flag: None,
|
flag: None,
|
||||||
undoes: None,
|
undoes: None,
|
||||||
|
class: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
//! Memory writes from a `secret` session are never written to Honcho (M5c spec, section 3; review
|
||||||
|
//! of M5c-1, finding 1): the journal records the call's class, and the mirror turns what such a
|
||||||
|
//! write would conclude or send to the fleet into one `Skip`, while its deletions still go.
|
||||||
|
|
||||||
|
#[path = "support/tmp.rs"]
|
||||||
|
mod tmp;
|
||||||
|
|
||||||
|
use bxcore::{DataClass, MemoryArgs, SessionId, Timestamp};
|
||||||
|
use memoryd::edit::{Context, apply};
|
||||||
|
use memoryd::files::Store;
|
||||||
|
use memoryd::honcho::ledger::{Action, Workspace};
|
||||||
|
use memoryd::honcho::mirror::{SECRET_WRITE, actions};
|
||||||
|
use memoryd::journal::{self, Record};
|
||||||
|
use memoryd::notes::{Kind, Note};
|
||||||
|
use tmp::TempDir;
|
||||||
|
|
||||||
|
const AGENT: &str = "boxmaker0";
|
||||||
|
const OWNER_PEER: &str = "kyle";
|
||||||
|
|
||||||
|
fn v(texts: &[&str]) -> Vec<String> {
|
||||||
|
texts.iter().map(|t| t.to_string()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sid(s: &str) -> SessionId {
|
||||||
|
SessionId::new(s).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn noted(class: DataClass, untrusted: bool, text: &str) -> String {
|
||||||
|
Note {
|
||||||
|
kind: Kind::Noted { class, untrusted },
|
||||||
|
time: "2026-09-28T10:00Z".to_string(),
|
||||||
|
session: "mm-a".to_string(),
|
||||||
|
text: text.to_string(),
|
||||||
|
}
|
||||||
|
.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record(file: Option<&str>, before: &[&str], after: &[&str], class: Option<&str>) -> Record {
|
||||||
|
Record {
|
||||||
|
id: "e1".to_string(),
|
||||||
|
time: "2026-09-28T10:00:00.000Z".to_string(),
|
||||||
|
session: "mm-a".to_string(),
|
||||||
|
call: 1,
|
||||||
|
action: "add".to_string(),
|
||||||
|
file: file.map(str::to_string),
|
||||||
|
to: file.unwrap_or("notes.md").to_string(),
|
||||||
|
before: v(before),
|
||||||
|
after: v(after),
|
||||||
|
notes: Vec::new(),
|
||||||
|
untrusted: false,
|
||||||
|
flag: None,
|
||||||
|
undoes: None,
|
||||||
|
class: class.map(str::to_string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn of(record: &Record) -> Vec<Action> {
|
||||||
|
actions(record, Some(&sid("mm-a")), AGENT, OWNER_PEER)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn skip() -> Vec<Action> {
|
||||||
|
vec![Action::Skip {
|
||||||
|
why: SECRET_WRITE.to_string(),
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_secret_sessions_notes_are_skipped() {
|
||||||
|
let mut clean = record(None, &[], &[], Some("secret"));
|
||||||
|
clean.action = "note".to_string();
|
||||||
|
clean.notes = vec![
|
||||||
|
noted(DataClass::Secret, false, "The vault code is 4411."),
|
||||||
|
noted(DataClass::Secret, false, "And a second one."),
|
||||||
|
];
|
||||||
|
assert_eq!(of(&clean), skip(), "no conclusion, one Skip for the record");
|
||||||
|
|
||||||
|
let mut untrusted = clean.clone();
|
||||||
|
untrusted.untrusted = true;
|
||||||
|
untrusted.notes = vec![noted(DataClass::Secret, true, "The page says 3.2.")];
|
||||||
|
assert_eq!(of(&untrusted), skip(), "no fleet message");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_note_marked_secret_is_skipped_without_the_records_class() {
|
||||||
|
// A line from before the journal had a class: the note's own header still says secret.
|
||||||
|
let mut old = record(None, &[], &[], None);
|
||||||
|
old.notes = vec![noted(DataClass::Secret, false, "A secret fact.")];
|
||||||
|
assert_eq!(of(&old), skip());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_secret_identity_add_is_skipped() {
|
||||||
|
let add = record(
|
||||||
|
Some("user.md"),
|
||||||
|
&["x"],
|
||||||
|
&["x", "Kyle's PIN is 1234."],
|
||||||
|
Some("secret"),
|
||||||
|
);
|
||||||
|
assert_eq!(of(&add), skip());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_secret_identity_remove_still_deletes() {
|
||||||
|
let remove = record(Some("memory.md"), &["x", "gone"], &["x"], Some("secret"));
|
||||||
|
assert_eq!(
|
||||||
|
of(&remove),
|
||||||
|
vec![Action::Unconclude {
|
||||||
|
workspace: Workspace::Trusted,
|
||||||
|
peer: AGENT.to_string(),
|
||||||
|
text: "gone".to_string(),
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
// A replace: the deletion first, then one Skip in place of the conclusion.
|
||||||
|
let replace = record(Some("user.md"), &["old"], &["new"], Some("secret"));
|
||||||
|
assert_eq!(
|
||||||
|
of(&replace),
|
||||||
|
vec![
|
||||||
|
Action::Unconclude {
|
||||||
|
workspace: Workspace::Trusted,
|
||||||
|
peer: OWNER_PEER.to_string(),
|
||||||
|
text: "old".to_string(),
|
||||||
|
},
|
||||||
|
Action::Skip {
|
||||||
|
why: SECRET_WRITE.to_string()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_and_private_writes_are_unchanged() {
|
||||||
|
for class in ["public", "private"] {
|
||||||
|
let add = record(Some("user.md"), &[], &["Prefers UTC."], Some(class));
|
||||||
|
assert_eq!(
|
||||||
|
of(&add),
|
||||||
|
vec![Action::Conclude {
|
||||||
|
workspace: Workspace::Trusted,
|
||||||
|
peer: OWNER_PEER.to_string(),
|
||||||
|
session: sid("mm-a"),
|
||||||
|
text: "Prefers UTC.".to_string(),
|
||||||
|
}],
|
||||||
|
"{class}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn old_journal_lines_without_a_class_still_read() {
|
||||||
|
let line = r#"{"id":"2026-09-24-0001","time":"2026-09-24T20:14:37.123Z","session":"mm-s1","call":7,"action":"add","file":"memory.md","to":"memory.md","before":[],"after":["a"],"notes":[],"untrusted":false}"#;
|
||||||
|
let record: Record = serde_json::from_str(line).unwrap();
|
||||||
|
assert_eq!(record.class, None);
|
||||||
|
assert!(!serde_json::to_string(&record).unwrap().contains("class"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_journal_records_the_calls_class() {
|
||||||
|
let dir = TempDir::new("mirror-secret-journal");
|
||||||
|
let starter = dir.write("etc/soul.md", "Soul.\n");
|
||||||
|
let store = Store::new(&dir.path().join("memory"));
|
||||||
|
store.init(&starter).unwrap();
|
||||||
|
for (taint, name) in [
|
||||||
|
(DataClass::Public, "public"),
|
||||||
|
(DataClass::Private, "private"),
|
||||||
|
(DataClass::Secret, "secret"),
|
||||||
|
] {
|
||||||
|
let ctx = Context {
|
||||||
|
session: "mm-s1".to_string(),
|
||||||
|
call: 7,
|
||||||
|
taint,
|
||||||
|
untrusted: false,
|
||||||
|
now: Timestamp::parse("2026-09-24T20:14:37.123Z").unwrap(),
|
||||||
|
};
|
||||||
|
let args: MemoryArgs = serde_json::from_str(&format!(
|
||||||
|
r#"{{"action":"add","file":"memory","text":"from a {name} session"}}"#
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let outcome = apply(&store, &args, &ctx).unwrap();
|
||||||
|
journal::record(&store, &ctx, &outcome).unwrap();
|
||||||
|
let last = journal::records(&store).unwrap().pop().unwrap();
|
||||||
|
assert_eq!(last.class.as_deref(), Some(name));
|
||||||
|
}
|
||||||
|
// An owner's undo has no call, so no class.
|
||||||
|
let last = journal::records(&store).unwrap().pop().unwrap();
|
||||||
|
journal::undo(
|
||||||
|
&store,
|
||||||
|
&last.id,
|
||||||
|
Timestamp::parse("2026-09-24T20:15:00.000Z").unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let undo = journal::records(&store).unwrap().pop().unwrap();
|
||||||
|
assert_eq!((undo.action.as_str(), undo.class), ("undo", None));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user