Watch
1
0
Fork
You've already forked souveraine-lens
0

lens: name the agents, open a door, and move notes

The switcher was a native select of nineteen rows, four of them labelled by
eight hex characters — every name was in a cadence's agent.json all along.

Observe opens for five minutes on a written reason plus the lockscreen
credential. polkit and sudo both answered "authorized" here with no prompt at
all, so neither holds the gate; writes commit as "amend (human)" in her own
history. Notes move between folders, and a rename takes its links with it.
This commit is contained in:
Fimeg 2026-08-24 12:15:56 -04:00
commit 216faea032
7 changed files with 1548 additions and 137 deletions

114
DESIGN.md
View file

@ -28,11 +28,90 @@ history, because a memory written a commit at a time is only honestly read
that way.
That asymmetry is enforced in the process, not the page. `Mode::Tend` allows
`save`, `create` and `sync`; `Mode::Observe` refuses all three by name before
the op reaches the vault. A surface that merely hides a button has gated
`save`, `create`, `move` and `sync`; `Mode::Observe` refuses all four by name
before the op reaches the vault. A surface that merely hides a button has gated
nothing — and `saf/identity/02-agent-principal.md` is explicit that readable
does not mean owned.
## The door into the cloister
The human owns the machine, so the refusal was never a permission — the files
are his uid, and `$EDITOR` was always one command away. What the read-only mode
actually bought was that a reach into her memory could not happen *by accident*,
in the same motion as tending his own garden. Keeping that and adding no door
was the wrong trade: it moved the amendment out of the lens, where it leaves no
mark she can read, and into a text editor, where it looks exactly like her own
writing in her own history.
So the door exists, it is loud, and it signs the work:
- **A written reason, first.** Refused if empty. The doctrine's break-glass
grant has the same rule for the same purpose — a reach you cannot explain is
one you should not be making.
- **A live credential, second.** `souveraine-stepup`, the shell's own PAM
service, which "must accept exactly the credentials the lockscreen accepts,
no more". Not polkit and not sudo: on this laptop, on 2026-08-24, both
answered *authorized* for `org.souveraine.stepup` with no prompt of any kind
— polkit in 3.6s through an agent nobody saw, sudo from a live timestamp.
A ceremony that never asks is the formality `saf/identity/02-agent-principal.md`
names by that word. libpam is `dlopen`ed rather than linked, so a machine
without it still reads every tree and refuses only the amend.
- **A grant that expires.** Five minutes, in memory, one tree at a time, the
shell's `StepUpAuth` default. Revoked on flip, on relinquish, on expiry, and
with the process. Leaving her memory gives the hand back.
- **`sync` stays refused, grant or no grant.** Amending her memory is one act;
pushing her archive to a remote under the human's credentials is another, and
the ceremony bought only the first. Her own sync carries the amendment out.
- **The archive says so.** Every write under a grant commits as
`amend (human): …` with the reason in the body. She reads her own history;
that line is how she finds out, and the lens's history strip is where the
human sees the same thing from the other side.
The band above the graph changes colour and stops saying *guest*. The two
states must not be mistakable for one another at a glance.
## The grammar of linking
`[[wikilinks]]` are the whole grammar and that is deliberate, but a vault drifts
in a specific way: the thought connected two notes and the file never said so.
The prior art converges on that gap from different directions.
- **Zettelkasten proper** put the structure in the *name* — Luhmann's `1a1`
folgezettel encodes where a note sits in a line of argument. Precise, and it
asks a person to know the shape before they write.
- **Obsidian** keeps the names free and pays for it with two panes: backlinks,
and **unlinked mentions** — notes whose title appears in this note's prose
without a link. The second is what turns a pile back into a graph, and it is
the single most copied idea in the space.
- **Roam and Logseq** went below the note: block references and transclusion,
where the unit of linking is a line rather than a file. Powerful, and it makes
the markdown stop being plain markdown, which this vault will not trade.
- **Dendron and Foam** put the hierarchy back in the filename
(`proj.souveraine.lens`), replacing folders with a naming convention.
- **Andy Matuschak's evergreen notes** argue the opposite of all of it: title
the note as an API, link densely, and let folders wither.
- **Dataview and Logseq properties** add *typed* links — `[[supports::x]]`,
`[[refutes::y]]` — so the edge carries a claim, not just an adjacency.
What the lens takes:
- **Unlinked mentions, built.** Titles of four characters or more that appear in
this note's prose on a word boundary, unlinked, longest first, six at most.
One tap rewrites the first occurrence in place and commits it. This is the
answer to "systematically linking": the system finds the edge the writing
already implied and asks for one keystroke.
- **A link indicator, built.** The drawer head carries `n in · n out · n loose`,
so the note's standing in the graph is legible before scrolling. A dead
`[[link]]` wears a `⊕` where it stands — an invitation, not an error.
- **Link-preserving moves, built.** A rename rewrites `[[old-stem]]` across the
tree in the same commit, aliases and `#anchors` intact. A link that named the
note's *title* still resolves and is left alone.
- **Typed links, not built.** They change the grammar every note is written in
from here on, which is the human's call and not the lens's. If they land, they
land as `[[verb::target]]` parsed alongside the plain form, with the verb
colouring the edge on the canvas — the graph is where a typed link would
finally pay for itself.
Consequences, held as rules:
- The lens never syncs the garden's notes into the SouveraineOS documentation
@ -72,10 +151,10 @@ document; every piece of data arrives over the IPC bridge and the bridge is
the only conversation:
- Page → process: `window.ipc.postMessage({id, op, root, ...})` — `graph`,
`note`, `save`, `create`, `sync`, `status`, `history`, `diff`, plus
`roots` / `rescan` / `flip` for the switcher. `root` names which tree the
op is about and defaults to the active one; the page holds keys, never
paths.
`note`, `save`, `create`, `move`, `sync`, `status`, `history`, `diff`, plus
`roots` / `rescan` / `flip` for the switcher and `amend` / `relinquish` for
the step-up. `root` names which tree the op is about and defaults to the
active one; the page holds keys, never paths.
- Process → page: `window.__lens.reply(id, payload)` via `evaluate_script`;
the shell's reach is `window.__lens.open(rel)` and
`window.__lens.refresh()`.
@ -103,11 +182,24 @@ accent. What the lens adds of its own:
- The **graph** at the heart: drag, wheel to zoom, drag the void to pan,
click to open, double-click the void to plant. Hover lights a node's
neighbourhood and dims the rest.
- The **switcher**: a page-drawn popover, not a native select. Nineteen trees
in a `<select>` rendered in the compositor's own menu, outside the skin, with
four agents labelled by eight hex characters. Now: the garden, then one card
per agent — a colour and monogram derived from her id, her own line about
herself, her note count and how long since she last wrote — with her cadences
as pills beneath. The name comes from `persona.name`, then her root's
`agent.json`, then her cadences' (`"Annie (reflection)"` names its parent).
Six agents on this machine; four had no name until that last hop.
- The **rail** (left): search across title, tags and description; the hint
line counts notes, links and solos.
line counts notes, links and solos. Folders remember what you opened, per
tree, and a tree past forty notes starts collapsed — her memory carries
thirty-nine folders and every one of them open is a wall. Drag a note onto a
folder to move it; the folder counts are recursive.
- The **drawer** (right): the note rendered, its backlinks — "linked from"
and "links to" — and an orphan nudge that offers the freshest five notes
to link the alone one to, one click away.
and "links to" — **mentioned, not linked**, an orphan nudge that offers the
freshest five notes to link the alone one to, and a `n in · n out · n loose`
indicator in the head. **Move** renames or refolders the note and rewrites
the links that named it.
- The **`[[` picker**: typed inside the editor, it mirrors the caret and
lists matching notes; enter inserts, and a dead query becomes a
"plant and link it" act. A link button in the drawer head does the same
@ -158,6 +250,10 @@ built on a laptop and driven over a socket; the missing step is that it be a
## Future
- **A right-click context menu.** Move, rename, link, plant, open history —
the verbs exist and each one currently costs a trip to the drawer head or the
palette. The rail and the canvas are where a hand already is.
- The shared-task lane, when TASK-68's authority shape lands: the agent
assigns human tasks through the vault; the lens surfaces them as notes.
- Typed links, if the grammar is worth changing — see above.
- A LICENSE for this repo, matching the substrate's AGPL-3.0.

View file

@ -21,8 +21,12 @@ any markdown directory at it — the lens asks no format of its own.
- drag nodes, wheel to zoom, drag the void to pan
- click a node to open the note, double-click the void to plant a new one
- `Notes` lists the garden; search filters it; `Sync` pushes to the remote
(ssh/gpg credentials are the user's own, like the substrate's memfs sync)
- `Notes` lists the garden; search filters it; drag a note onto a folder to
move it; `Sync` pushes to the remote (ssh/gpg credentials are the user's own,
like the substrate's memfs sync)
- the tree button opens the switcher: the garden, then every agent's memory,
read-only. `Amend…` opens one of those for five minutes against your own
lockscreen credential, and every write lands in her history saying so
## The seam

View file

@ -13,7 +13,9 @@ url="https://gitea.wiuf.net/Fimeg/souveraine-lens"
license=('AGPL-3.0-or-later')
# The wry host links the system webview and GTK; the runtime needs what the
# build links. webkit2gtk-4.1 and gtk3 pull the windowing stack they need.
depends=('gcc-libs' 'gtk3' 'webkit2gtk-4.1')
# pam is dlopened, not linked — declared so the step-up is not a surprise
# refusal on a machine that happens to lack it.
depends=('gcc-libs' 'gtk3' 'webkit2gtk-4.1' 'pam')
options=('!strip')
source=('souveraine-lens-binary' 'org.souveraine.lens.desktop'
'org.souveraine.lens.svg' 'LICENSE')

View file

@ -6,12 +6,14 @@
//! the page is a single embedded document that talks to this process over
//! the IPC bridge and to nothing else.
mod stepup;
mod ui;
mod vault;
use anyhow::{Context, Result};
use std::io::{BufRead, BufReader, Write};
use std::path::PathBuf;
use std::time::{Duration, Instant};
use serde_json::json;
use tao::event::{Event, StartCause, WindowEvent};
@ -184,6 +186,7 @@ fn run() -> Result<()> {
// and says so, rather than silently landing in the garden.
if roots.get(&key).is_some() {
roots.active = key.clone();
roots.grant = None;
let _ = webview.evaluate_script(&format!(
"window.__lens.flip({});",
serde_json::to_string(&key).unwrap_or_else(|_| "\"garden\"".into())
@ -269,10 +272,32 @@ fn serve_ipc(path: &std::path::Path, proxy: EventLoopProxy<LensEvent>) {
}
}
/// The human's hand, opened for one tree, for a while. Minted only by a live
/// PAM conversation; held in memory and nowhere else, so it dies with the
/// window. Five minutes is the shell's own StepUpAuth default.
struct Grant {
root: String,
reason: String,
at: Instant,
}
const GRANT_TTL: Duration = Duration::from_secs(300);
impl Grant {
fn live(&self) -> bool {
self.at.elapsed() < GRANT_TTL
}
fn remaining(&self) -> u64 {
GRANT_TTL.saturating_sub(self.at.elapsed()).as_secs()
}
}
/// Every tree the lens can stand in, and which one it is standing in now.
struct Roots {
list: Vec<vault::Root>,
active: String,
grant: Option<Grant>,
}
impl Roots {
@ -284,6 +309,33 @@ impl Roots {
Self {
list,
active: "garden".to_string(),
grant: None,
}
}
/// Drop a grant that has run out. Called before every op, so an expired
/// grant is never the thing that lets a write through.
fn sweep(&mut self) {
if self.grant.as_ref().is_some_and(|g| !g.live()) {
self.grant = None;
}
}
fn grant_for(&self, key: &str) -> Option<&Grant> {
self.grant.as_ref().filter(|g| g.root == key && g.live())
}
/// Sync stays refused even under a grant: amending her memory is one act,
/// pushing her archive to a remote is another, and the ceremony bought
/// only the first. DESIGN.md carries the argument.
fn may_write(&self, root: &vault::Root, op: &str) -> bool {
root.mode.writable() || (op != "sync" && self.grant_for(&root.key).is_some())
}
fn grant_state(&self, key: &str) -> serde_json::Value {
match self.grant_for(key) {
Some(g) => json!({ "reason": &g.reason, "remaining": g.remaining() }),
None => serde_json::Value::Null,
}
}
@ -321,6 +373,11 @@ impl Roots {
if self.get(&self.active.clone()).is_none() {
self.active = "garden".to_string();
}
if let Some(key) = self.grant.as_ref().map(|g| g.root.clone()) {
if self.get(&key).is_none() {
self.grant = None;
}
}
}
}
@ -336,16 +393,27 @@ fn handle(webview: &wry::WebView, roots: &mut Roots, body: &str) -> Result<()> {
let rel = msg.get("rel").and_then(|v| v.as_str()).unwrap_or("").to_string();
let root_key = msg.get("root").and_then(|v| v.as_str()).map(|s| s.to_string());
// Ops that change a tree are refused outright on an Observe root. Hiding
// the button in the page is presentation; this is the gate.
let writes = matches!(op, "save" | "create" | "sync");
roots.sweep();
// Ops that change a tree are refused on an Observe root unless a live
// step-up grant names it. Hiding the button in the page is presentation;
// this is the gate, and it is the only thing standing between an agent's
// memory and a surface that forgot whose it was.
let writes = matches!(op, "save" | "create" | "sync" | "move");
if writes {
let r = roots.resolve(root_key.as_deref());
if !r.mode.writable() {
let refusal = json!({
"ok": false,
"error": format!("{} is read-only — this is {}'s memory, not the garden", r.key, r.label),
});
if !roots.may_write(r, op) {
let refusal = if op == "sync" && roots.grant_for(&r.key).is_some() {
json!({
"ok": false, "code": "not_permitted",
"error": format!("amending {}\u{2019}s memory is one act; pushing her archive is another", r.label),
})
} else {
json!({
"ok": false, "code": "read_only",
"error": format!("{} is read-only — this is {}\u{2019}s memory, not the garden", r.key, r.label),
})
};
let script = format!(
"window.__lens.reply({}, {});",
id,
@ -356,6 +424,59 @@ fn handle(webview: &wry::WebView, roots: &mut Roots, body: &str) -> Result<()> {
}
}
// The ceremony. A written reason and the human's own credential, checked
// this instant against the shell's PAM service — never a cached authority,
// which is exactly what polkit and sudo would have handed back.
if op == "amend" {
let want = root_key.clone().unwrap_or_else(|| roots.active.clone());
let reason = msg.get("reason").and_then(|v| v.as_str()).unwrap_or("").trim().to_string();
let secret = msg.get("secret").and_then(|v| v.as_str()).unwrap_or("");
let payload = if roots.get(&want).is_none() {
json!({ "ok": false, "code": "invalid_argument", "error": format!("unknown root: {want}") })
} else if reason.is_empty() {
json!({ "ok": false, "code": "invalid_argument", "error": "an amendment needs a reason — she will read it" })
} else {
match stepup::authenticate(secret) {
Ok(()) => {
let label = roots.get(&want).map(|r| r.label.clone()).unwrap_or_default();
roots.grant = Some(Grant {
root: want.clone(),
reason: reason.clone(),
at: Instant::now(),
});
emit("amend_granted", json!({ "root": &want, "reason": &reason }));
json!({ "ok": true, "remaining": GRANT_TTL.as_secs(), "label": label, "reason": reason })
}
Err(e) => {
emit("amend_refused", json!({ "root": &want }));
json!({ "ok": false, "code": "not_permitted", "error": format!("{e:#}") })
}
}
};
let script = format!(
"window.__lens.reply({}, {});",
id,
serde_json::to_string(&payload).unwrap_or_else(|_| "{}".into())
);
webview.evaluate_script(&script).context("replying to the page")?;
return Ok(());
}
if op == "relinquish" {
let had = roots.grant.take().is_some();
if had {
emit("amend_relinquished", json!({}));
}
let payload = json!({ "ok": true, "released": had });
let script = format!(
"window.__lens.reply({}, {});",
id,
serde_json::to_string(&payload).unwrap_or_else(|_| "{}".into())
);
webview.evaluate_script(&script).context("replying to the page")?;
return Ok(());
}
if op == "roots" {
let payload = json!({ "ok": true, "roots": roots.infos(), "active": &roots.active });
let script = format!(
@ -384,6 +505,9 @@ fn handle(webview: &wry::WebView, roots: &mut Roots, body: &str) -> Result<()> {
let known = roots.get(&want).is_some();
if known {
roots.active = want.clone();
// Leaving the tree gives the hand back. A grant that survived the
// walk to another agent's memory would be a door left open.
roots.grant = None;
}
let payload = if known {
json!({ "ok": true, "active": &roots.active })
@ -402,6 +526,9 @@ fn handle(webview: &wry::WebView, roots: &mut Roots, body: &str) -> Result<()> {
let root = roots.resolve(root_key.as_deref());
let vault = &root.vault;
let mode = root.mode;
let grant = roots.grant_state(&root.key);
// The reason travels into her archive with every write the grant allows.
let amend = roots.grant_for(&root.key).map(|g| g.reason.clone());
let result: serde_json::Value = match op {
"graph" => match vault.scan() {
@ -409,6 +536,7 @@ fn handle(webview: &wry::WebView, roots: &mut Roots, body: &str) -> Result<()> {
Ok(s) => json!({
"ok": true, "graph": g, "status": s,
"root": &root.key, "label": &root.label, "mode": mode,
"grant": grant,
}),
Err(e) => json!({ "ok": false, "error": format!("{e:#}") }),
},
@ -424,7 +552,7 @@ fn handle(webview: &wry::WebView, roots: &mut Roots, body: &str) -> Result<()> {
"save" => {
let rel = msg.get("rel").and_then(|v| v.as_str()).unwrap_or("");
let raw = msg.get("raw").and_then(|v| v.as_str()).unwrap_or("");
match vault.save_note(rel, raw) {
match vault.save_note(rel, raw, amend.as_deref()) {
Ok(()) => json!({ "ok": true }),
Err(e) => json!({ "ok": false, "error": format!("{e:#}") }),
}
@ -432,11 +560,21 @@ fn handle(webview: &wry::WebView, roots: &mut Roots, body: &str) -> Result<()> {
"create" => {
let title = msg.get("title").and_then(|v| v.as_str()).unwrap_or("");
let folder = msg.get("folder").and_then(|v| v.as_str());
match vault.create_note_in(title, folder) {
match vault.create_note_in_as(title, folder, amend.as_deref()) {
Ok(rel) => json!({ "ok": true, "rel": rel }),
Err(e) => json!({ "ok": false, "error": format!("{e:#}") }),
}
}
// Moving a note is a folder change, a rename, or both — one verb,
// because to the vault they are the same git operation.
"move" => {
let rel = msg.get("rel").and_then(|v| v.as_str()).unwrap_or("");
let to = msg.get("to").and_then(|v| v.as_str()).unwrap_or("");
match vault.move_note(rel, to, amend.as_deref()) {
Ok(relinked) => json!({ "ok": true, "rel": to, "relinked": relinked.len() }),
Err(e) => json!({ "ok": false, "error": format!("{e:#}") }),
}
}
"sync" => match vault.sync() {
Ok(out) => json!({ "ok": true, "out": out }),
Err(e) => json!({ "ok": false, "error": format!("{e:#}") }),
@ -445,6 +583,7 @@ fn handle(webview: &wry::WebView, roots: &mut Roots, body: &str) -> Result<()> {
Ok(s) => json!({
"ok": true, "status": s,
"root": &root.key, "label": &root.label, "mode": mode,
"grant": grant,
}),
Err(e) => json!({ "ok": false, "error": format!("{e:#}") }),
},
@ -485,6 +624,7 @@ fn handle(webview: &wry::WebView, roots: &mut Roots, body: &str) -> Result<()> {
"note" => Some(("note_opened", rel)),
"save" => Some(("note_saved", rel)),
"create" => Some(("note_created", rel)),
"move" => Some(("note_moved", rel)),
"sync" => Some(("synced", String::new())),
_ => None,
};

207
src/stepup.rs Normal file
View file

@ -0,0 +1,207 @@
//! Step-up — the human's own credential, asked at the moment of the reach.
//!
//! `souveraine-stepup` is the shell's PAM service and accepts exactly what the
//! lockscreen accepts. polkit and sudo both answered "authorized" on this
//! laptop with no prompt at all, which is why neither holds this gate;
//! DESIGN.md carries the argument. libpam is opened at runtime rather than
//! linked, so a machine without it still reads every tree and refuses only
//! the amend.
use anyhow::{anyhow, bail, Context, Result};
use std::ffi::{c_char, c_int, c_void, CStr, CString};
use std::ptr;
/// The service file is root-owned system config shipped by the souveraine
/// package. Absent, `pam_start` fails and the amend is refused — never a
/// fall-through to something weaker.
const SERVICE: &str = "souveraine-stepup";
const PAM_SUCCESS: c_int = 0;
const PAM_PROMPT_ECHO_OFF: c_int = 1;
const PAM_PROMPT_ECHO_ON: c_int = 2;
const PAM_CONV_ERR: c_int = 19;
const RTLD_NOW: c_int = 2;
#[repr(C)]
struct PamMessage {
msg_style: c_int,
msg: *const c_char,
}
#[repr(C)]
struct PamResponse {
resp: *mut c_char,
resp_retcode: c_int,
}
type ConvFn = unsafe extern "C" fn(
c_int,
*const *const PamMessage,
*mut *mut PamResponse,
*mut c_void,
) -> c_int;
#[repr(C)]
struct PamConv {
conv: ConvFn,
appdata: *mut c_void,
}
type StartFn =
unsafe extern "C" fn(*const c_char, *const c_char, *const PamConv, *mut *mut c_void) -> c_int;
type StepFn = unsafe extern "C" fn(*mut c_void, c_int) -> c_int;
type StrerrorFn = unsafe extern "C" fn(*mut c_void, c_int) -> *const c_char;
extern "C" {
fn dlopen(file: *const c_char, flags: c_int) -> *mut c_void;
fn dlsym(handle: *mut c_void, sym: *const c_char) -> *mut c_void;
fn calloc(n: usize, size: usize) -> *mut c_void;
fn strdup(s: *const c_char) -> *mut c_char;
fn getuid() -> u32;
}
struct Pam {
start: StartFn,
authenticate: StepFn,
acct_mgmt: StepFn,
end: StepFn,
strerror: StrerrorFn,
}
impl Pam {
fn open() -> Result<Self> {
unsafe {
let handle = dlopen(c"libpam.so.0".as_ptr(), RTLD_NOW);
if handle.is_null() {
bail!("libpam.so.0 is not on this machine");
}
let sym = |name: &CStr| -> Result<*mut c_void> {
let p = dlsym(handle, name.as_ptr());
if p.is_null() {
return Err(anyhow!("libpam has no {}", name.to_string_lossy()));
}
Ok(p)
};
Ok(Pam {
start: std::mem::transmute::<*mut c_void, StartFn>(sym(c"pam_start")?),
authenticate: std::mem::transmute::<*mut c_void, StepFn>(sym(c"pam_authenticate")?),
acct_mgmt: std::mem::transmute::<*mut c_void, StepFn>(sym(c"pam_acct_mgmt")?),
end: std::mem::transmute::<*mut c_void, StepFn>(sym(c"pam_end")?),
strerror: std::mem::transmute::<*mut c_void, StrerrorFn>(sym(c"pam_strerror")?),
})
}
}
}
/// Answer PAM's prompts from one stashed secret. A panic must never cross the
/// FFI boundary, so nothing in here can panic: no indexing, no unwrap.
unsafe extern "C" fn converse(
n: c_int,
msgs: *const *const PamMessage,
out: *mut *mut PamResponse,
appdata: *mut c_void,
) -> c_int {
if n <= 0 || msgs.is_null() || out.is_null() || appdata.is_null() {
return PAM_CONV_ERR;
}
let secret = &*(appdata as *const CString);
let array = calloc(n as usize, std::mem::size_of::<PamResponse>()) as *mut PamResponse;
if array.is_null() {
return PAM_CONV_ERR;
}
for i in 0..n as isize {
let msg = *msgs.offset(i);
let mut reply: *mut c_char = ptr::null_mut();
if !msg.is_null() && matches!((*msg).msg_style, PAM_PROMPT_ECHO_OFF | PAM_PROMPT_ECHO_ON) {
let text = if (*msg).msg.is_null() {
String::new()
} else {
CStr::from_ptr((*msg).msg).to_string_lossy().to_lowercase()
};
// The fingerprint factor asks first, and an empty answer is how the
// shell declines it — PAM then falls through to the password stack,
// which is the credential this gate is actually asking for.
reply = if text.contains("fingerprint") {
strdup(c"".as_ptr())
} else {
strdup(secret.as_ptr())
};
}
(*array.offset(i)).resp = reply;
(*array.offset(i)).resp_retcode = 0;
}
*out = array;
PAM_SUCCESS
}
/// Prove the human is at the keyboard. Ok means the credential was accepted
/// this instant — never a cached grant, never a boolean from anywhere else.
pub fn authenticate(secret: &str) -> Result<()> {
let secret_c = CString::new(secret).context("a passphrase cannot carry a nul byte")?;
let user = current_user().context("this process has no local account name")?;
let pam = Pam::open()?;
let service = CString::new(SERVICE)?;
let user_c = CString::new(user)?;
let conv = PamConv {
conv: converse,
appdata: &secret_c as *const CString as *mut c_void,
};
unsafe {
let mut handle: *mut c_void = ptr::null_mut();
let rc = (pam.start)(service.as_ptr(), user_c.as_ptr(), &conv, &mut handle);
if rc != PAM_SUCCESS || handle.is_null() {
bail!("pam_start refused — is /etc/pam.d/{SERVICE} installed?");
}
let rc = (pam.authenticate)(handle, 0);
let rc = if rc == PAM_SUCCESS {
(pam.acct_mgmt)(handle, 0)
} else {
rc
};
let reason = CStr::from_ptr((pam.strerror)(handle, rc))
.to_string_lossy()
.into_owned();
(pam.end)(handle, rc);
if rc != PAM_SUCCESS {
bail!("{reason}");
}
}
Ok(())
}
/// The account this process is running as, read from the kernel's answer
/// rather than an environment variable a caller could set.
fn current_user() -> Result<String> {
let uid = unsafe { getuid() };
let passwd = std::fs::read_to_string("/etc/passwd").unwrap_or_default();
for line in passwd.lines() {
let mut f = line.split(':');
let (Some(name), Some(_), Some(id)) = (f.next(), f.next(), f.next()) else {
continue;
};
if id.parse::<u32>() == Ok(uid) {
return Ok(name.to_string());
}
}
std::env::var("USER").with_context(|| format!("uid {uid} is in no local passwd entry"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_nul_in_the_passphrase_is_refused_before_pam() {
let err = authenticate("before\0after").unwrap_err();
assert!(format!("{err:#}").contains("nul byte"));
}
#[test]
fn the_process_knows_its_own_account() {
// Every CI runner has a passwd entry for the uid it runs as; a machine
// where it does not is exactly where the fallback matters.
assert!(current_user().is_ok());
}
}

788
src/ui.rs

File diff suppressed because it is too large Load diff

View file

@ -6,7 +6,7 @@
//! to git. No database, no format of its own: the vault is files, and git is
//! the archive.
use anyhow::{Context, Result};
use anyhow::{bail, Context, Result};
use serde::Serialize;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
@ -374,8 +374,11 @@ rel.rsplit('/').next().unwrap_or(&rel).to_string()
})
}
/// Save raw editor content back to the vault.
pub fn save_note(&self, rel: &str, raw: &str) -> Result<()> {
/// Save raw editor content back to the vault, and say in the archive who
/// reached in. `amend` carries the human's written reason when this tree
/// is not his to tend — her history is the only place she can find out,
/// so it is never a silent write.
pub fn save_note(&self, rel: &str, raw: &str, amend: Option<&str>) -> Result<()> {
let path = self.root.join(rel);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
@ -395,14 +398,12 @@ rel.rsplit('/').next().unwrap_or(&rel).to_string()
} else {
doc.title
};
self.commit(
&[rel],
&if is_new {
format!("note (new): {title}")
} else {
format!("note: {title}")
},
)?;
let message = match amend {
Some(reason) => amend_message(&title, reason),
None if is_new => format!("note (new): {title}"),
None => format!("note: {title}"),
};
self.commit(&[rel], &message)?;
Ok(())
}
@ -416,6 +417,15 @@ rel.rsplit('/').next().unwrap_or(&rel).to_string()
/// that could climb out of the vault is refused rather than silently
/// re-rooted — a garden keeps its roots under it.
pub fn create_note_in(&self, title: &str, folder: Option<&str>) -> Result<String> {
self.create_note_in_as(title, folder, None)
}
pub fn create_note_in_as(
&self,
title: &str,
folder: Option<&str>,
amend: Option<&str>,
) -> Result<String> {
let stem = slug_for_filename(title);
let stem = if stem.is_empty() { "note".to_string() } else { stem };
let rel = match folder.map(|f| f.trim().trim_matches('/')).filter(|f| !f.is_empty()) {
@ -448,10 +458,122 @@ rel.rsplit('/').next().unwrap_or(&rel).to_string()
} else {
subject
};
self.commit(&[rel.as_str()], &format!("note: {subject}"))?;
let message = match amend {
Some(reason) => amend_message(&subject, reason),
None => format!("note: {subject}"),
};
self.commit(&[rel.as_str()], &message)?;
Ok(rel)
}
/// Move a note, and keep the links that point at it. A folder change alone
/// breaks nothing — links resolve by title or stem — but a stem change
/// orphans every `[[old-stem]]` in the tree, so those are rewritten in the
/// same commit rather than left dead.
pub fn move_note(&self, from: &str, to: &str, amend: Option<&str>) -> Result<Vec<String>> {
let to = to.trim().trim_matches('/');
if to.is_empty() || !to.ends_with(".md") {
bail!("a note is a .md file, and {to:?} is not one");
}
if let Some((dir, _)) = to.rsplit_once('/') {
if !folder_is_safe(dir) {
bail!("folder \"{dir}\" is not a safe path");
}
}
if to == from {
return Ok(Vec::new());
}
let src = self.root.join(from);
let dst = self.root.join(to);
if !src.exists() {
bail!("{from} is not in this tree");
}
if dst.exists() {
bail!("{to} already exists");
}
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent)?;
}
let tracked = self.root.join(".git").exists()
&& self.git(&["ls-files", "--error-unmatch", "--", from]).is_ok();
if tracked {
self.git(&["mv", "--", from, to])?;
} else {
std::fs::rename(&src, &dst).with_context(|| format!("moving {from} to {to}"))?;
}
let old_stem = stem_of(from);
let new_stem = stem_of(to);
let relinked = if old_stem == new_stem {
Vec::new()
} else {
self.retarget_links(&old_stem, &new_stem)?
};
let message = match amend {
Some(reason) => amend_message(&format!("moved {from} → {to}"), reason),
None => format!("move: {from} → {to}"),
};
let mut paths = vec![from.to_string(), to.to_string()];
paths.extend(relinked.iter().cloned());
let refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();
self.commit(&refs, &message)?;
Ok(relinked)
}
/// Rewrite `[[old]]` to `[[new]]` wherever the target names the old stem,
/// aliases and `#anchors` intact. Returns the notes that changed.
fn retarget_links(&self, old_stem: &str, new_stem: &str) -> Result<Vec<String>> {
let mut docs: Vec<(String, Doc, u64)> = Vec::new();
self.walk(&self.root, &mut docs)?;
let mut changed = Vec::new();
for (rel, _, _) in &docs {
let path = self.root.join(rel);
let Ok(raw) = std::fs::read_to_string(&path) else {
continue;
};
let out = rewrite_wikilinks(&raw, old_stem, new_stem);
if out != raw {
std::fs::write(&path, &out).with_context(|| format!("relinking {rel}"))?;
changed.push(rel.clone());
}
}
Ok(changed)
}
/// How many notes, without parsing one. The switcher asks this of every
/// tree at once, so it counts files rather than walking documents.
pub fn note_count_fast(&self) -> usize {
fn count(dir: &Path) -> usize {
let Ok(entries) = std::fs::read_dir(dir) else {
return 0;
};
let mut n = 0;
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with('.') || SKIP_DIRS.contains(&name.as_ref()) {
continue;
}
let path = entry.path();
if path.is_dir() {
n += count(&path);
} else if path.extension().is_some_and(|e| e == "md") {
n += 1;
}
}
n
}
count(&self.root)
}
/// When this tree last took a commit — the switcher's "how alive is she".
pub fn last_commit_at(&self) -> u64 {
self.git(&["log", "-1", "--format=%ct"])
.ok()
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0)
}
fn commit(&self, paths: &[&str], message: &str) -> Result<()> {
if !self.root.join(".git").exists() {
return Ok(());
@ -610,6 +732,57 @@ rel.rsplit('/').next().unwrap_or(&rel).to_string()
}
}
/// The archive says who reached in. She reads her own history, and a human
/// edit wearing the same subject as her own writing is the failure this avoids.
fn amend_message(subject: &str, reason: &str) -> String {
let head: String = subject.chars().take(40).collect();
let reason = reason.split_whitespace().collect::<Vec<_>>().join(" ");
format!("amend (human): {}\n\nReached in through the lens. Reason: {reason}\n", head.trim_end())
}
fn stem_of(rel: &str) -> String {
rel.rsplit('/')
.next()
.unwrap_or(rel)
.trim_end_matches(".md")
.to_string()
}
/// Replace `[[old]]` targets with `new`, keeping `|alias` and `#anchor`. A link
/// that named the note's title rather than its file still resolves, so it is
/// left alone.
fn rewrite_wikilinks(text: &str, old_stem: &str, new_stem: &str) -> String {
let want = old_stem.to_lowercase();
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(open) = rest.find("[[") {
let Some(close) = rest[open + 2..].find("]]") else {
break;
};
let inner = &rest[open + 2..open + 2 + close];
out.push_str(&rest[..open + 2]);
let (target, tail) = match inner.split_once('|') {
Some((t, alias)) => (t, format!("|{alias}")),
None => (inner, String::new()),
};
let (note, anchor) = match target.split_once('#') {
Some((n, a)) => (n, format!("#{a}")),
None => (target, String::new()),
};
let leaf = note.trim().rsplit('/').next().unwrap_or("").to_lowercase();
if leaf == want {
out.push_str(new_stem);
out.push_str(&anchor);
out.push_str(&tail);
} else {
out.push_str(inner);
}
rest = &rest[open + 2 + close..];
}
out.push_str(rest);
out
}
/// A folder path is safe when no component is empty, `.` or `..` and none
/// carries a backslash — nothing that could step outside the vault.
fn folder_is_safe(f: &str) -> bool {
@ -803,10 +976,46 @@ mod tests {
assert_eq!(found.len(), 1, "system/ and schedules/ are not agents");
assert_eq!(found[0].key, "agent:abc123");
assert_eq!(found[0].label, "Vanguard");
assert_eq!(found[0].about, "the forward edge");
assert_eq!(found[0].mode, Mode::Observe, "an agent tree is never tended");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_escaped_dash_reaches_the_face_as_a_dash() {
// Exactly what Souveraine's config.yaml holds: a backslash, a u, and
// four hex digits — never the character itself.
let on_disk = "\"Sovereign agent \\u2014 persistent\"";
assert!(on_disk.contains("\\u2014"), "the fixture must carry the escape, not the dash");
assert_eq!(unquote(on_disk), "Sovereign agent \u{2014} persistent");
assert_eq!(unquote("'plain'"), "plain");
assert_eq!(unquote(r#""a \qb""#), "a qb");
assert_eq!(unquote(r#""bad \uZZZZ""#), "bad \\uZZZZ", "a broken escape stays visible");
}
#[test]
fn a_nameless_agent_is_named_by_her_cadences() {
let dir = std::env::temp_dir().join(format!("lens-cadname-{}", std::process::id()));
let root = dir.join(".souveraine").join("agents").join("xyz789");
std::fs::create_dir_all(root.join("memory").join(".git")).unwrap();
let reflection = root.join("cadences").join("reflection");
std::fs::create_dir_all(reflection.join("memory").join(".git")).unwrap();
std::fs::write(
reflection.join("agent.json"),
r#"{"name": "Annie (reflection)", "description": "The reflection cadence of Annie"}"#,
)
.unwrap();
let found = discover_agents(&dir);
// No config.yaml and no agent.json at her root — the cadence knows.
let primary = found.iter().find(|r| r.key == "agent:xyz789").unwrap();
assert_eq!(primary.group, "Annie", "a uuid fragment is not a name");
assert_eq!(primary.label, "Annie");
let cadence = found.iter().find(|r| r.key == "agent:xyz789/reflection").unwrap();
assert_eq!(cadence.group, "Annie", "her cadences group under her");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_memory_without_git_is_not_a_root() {
let dir = std::env::temp_dir().join(format!("lens-nogit-{}", std::process::id()));
@ -820,6 +1029,60 @@ mod tests {
assert!(Mode::Tend.writable());
assert!(!Mode::Observe.writable());
}
#[test]
fn a_move_keeps_the_links_pointing_at_it() {
let dir = std::env::temp_dir().join(format!("lens-move-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("index.md"), "# Home\n\nSee [[old-name]] and [[old-name#Anchor|the alias]].\n").unwrap();
std::fs::write(dir.join("old-name.md"), "# Old Name\n").unwrap();
let vault = Vault::new(dir.clone());
// A folder change alone leaves every link alone.
vault.move_note("old-name.md", "notes/old-name.md", None).unwrap();
let home = std::fs::read_to_string(dir.join("index.md")).unwrap();
assert!(home.contains("[[old-name]]"));
// A stem change rewrites them, alias and anchor intact.
let relinked = vault.move_note("notes/old-name.md", "notes/new-name.md", None).unwrap();
assert_eq!(relinked, vec!["index.md".to_string()], "only the notes that changed");
let home = std::fs::read_to_string(dir.join("index.md")).unwrap();
assert!(home.contains("[[new-name]]"), "got {home}");
assert!(home.contains("[[new-name#Anchor|the alias]]"), "got {home}");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_move_refuses_what_would_leave_the_vault() {
let dir = std::env::temp_dir().join(format!("lens-move-x-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.md"), "# A\n").unwrap();
std::fs::write(dir.join("b.md"), "# B\n").unwrap();
let vault = Vault::new(dir.clone());
assert!(vault.move_note("a.md", "../a.md", None).is_err());
assert!(vault.move_note("a.md", "x/../../a.md", None).is_err());
assert!(vault.move_note("a.md", "a", None).is_err(), "a note is a .md file");
assert!(vault.move_note("a.md", "b.md", None).is_err(), "never over an existing note");
assert!(vault.move_note("ghost.md", "c.md", None).is_err());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn an_amend_says_so_in_the_archive() {
let m = amend_message("her note", "fixed the date I got wrong");
assert!(m.starts_with("amend (human): her note"));
assert!(m.lines().next().unwrap().len() < 60);
assert!(m.contains("Reason: fixed the date I got wrong"));
}
#[test]
fn a_link_named_by_title_survives_a_stem_rename() {
let text = "[[Old Name]] and [[old-name]] and [[sub/old-name|alias]]";
let out = rewrite_wikilinks(text, "old-name", "new-name");
assert!(out.contains("[[Old Name]]"), "the title still resolves: {out}");
assert!(out.contains("[[new-name]]"), "{out}");
assert!(out.contains("[[new-name|alias]]"), "{out}");
}
}
// ── Roots ──────────────────────────────────────────────────────────────────
@ -850,6 +1113,14 @@ pub struct Root {
pub key: String,
pub label: String,
pub mode: Mode,
/// The being this tree belongs to. Her cadences carry their own labels;
/// the group is what holds them together as one agent in the switcher.
pub group: String,
/// The agent's own line about herself, from `persona.description`.
pub about: String,
/// The agent id the tree belongs to — one identity across her cadences,
/// which is what lets the switcher colour them as one being.
pub agent: String,
pub vault: Vault,
}
@ -863,6 +1134,11 @@ pub struct RootInfo {
pub group: String,
/// Which cadence inside that group — "primary", "reflection", "archivist".
pub cadence: String,
pub about: String,
pub agent: String,
pub notes: usize,
/// Unix seconds of the tree's last commit; 0 when git has nothing to say.
pub updated: u64,
}
impl Root {
@ -871,24 +1147,31 @@ impl Root {
key: "garden".into(),
label: "Garden".into(),
mode: Mode::Tend,
group: "garden".into(),
about: "yours to tend".into(),
agent: String::new(),
vault,
}
}
pub fn info(&self) -> RootInfo {
let (group, cadence) = match self.key.strip_prefix("agent:") {
None => ("garden".to_string(), "vault".to_string()),
let cadence = match self.key.strip_prefix("agent:") {
None => "vault".to_string(),
Some(rest) => match rest.split_once('/') {
Some((_, cad)) => (self.label.clone(), cad.to_string()),
None => (self.label.clone(), "primary".to_string()),
Some((_, cad)) => cad.to_string(),
None => "primary".to_string(),
},
};
RootInfo {
key: self.key.clone(),
label: self.label.clone(),
mode: self.mode,
group,
group: self.group.clone(),
cadence,
about: self.about.clone(),
agent: self.agent.clone(),
notes: self.vault.note_count_fast(),
updated: self.vault.last_commit_at(),
}
}
}
@ -920,6 +1203,31 @@ fn persona_from_config(path: &Path) -> (Option<String>, Option<String>) {
(name, desc)
}
/// Who she is, when her own root says nothing. Four of the six agents on this
/// machine carry no `config.yaml` and no `agent.json` at all — but each of
/// their cadences does, and it names the parent: `"Annie (reflection)"`. The
/// suffix comes off and the name is hers. Filesystem only; the lens never asks
/// the server who anyone is.
fn name_from_cadences(dir: &Path) -> Option<String> {
let mut dirs: Vec<PathBuf> = std::fs::read_dir(dir.join("cadences"))
.ok()?
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect();
dirs.sort();
for cadence in dirs {
let Some(name) = name_from_agent_json(&cadence.join("agent.json")) else {
continue;
};
let base = name.split(" (").next().unwrap_or(&name).trim();
if !base.is_empty() {
return Some(base.to_string());
}
}
None
}
/// `agent.json` carries a display name for the cadence trees.
fn name_from_agent_json(path: &Path) -> Option<String> {
let raw = std::fs::read_to_string(path).ok()?;
@ -934,11 +1242,47 @@ fn name_from_agent_json(path: &Path) -> Option<String> {
None
}
/// Strip the quotes and decode what a double-quoted YAML scalar may carry.
/// Souveraine's own `config.yaml` holds `—` as six literal characters,
/// and a description that reads "Sovereign agent — persistent" in the
/// switcher is the escape leaking into the face.
fn unquote(s: &str) -> String {
let s = s.trim();
let s = s.strip_prefix('"').and_then(|r| r.strip_suffix('"')).unwrap_or(s);
let s = s.strip_prefix('\'').and_then(|r| r.strip_suffix('\'')).unwrap_or(s);
s.to_string()
if let Some(inner) = s.strip_prefix('"').and_then(|r| r.strip_suffix('"')) {
return unescape(inner);
}
s.strip_prefix('\'')
.and_then(|r| r.strip_suffix('\''))
.unwrap_or(s)
.to_string()
}
fn unescape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
match chars.next() {
Some('u') => {
let hex: String = chars.by_ref().take(4).collect();
match u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) {
Some(decoded) => out.push(decoded),
None => {
out.push_str("\\u");
out.push_str(&hex);
}
}
}
Some('n') => out.push('\n'),
Some('t') => out.push('\t'),
Some(other) => out.push(other),
None => out.push('\\'),
}
}
out
}
/// Directories under `agents/` that are not agents.
@ -969,8 +1313,12 @@ pub fn discover_agents(home: &Path) -> Vec<Root> {
continue;
}
let (name, _desc) = persona_from_config(&dir.join("config.yaml"));
let display = name.unwrap_or_else(|| short_id(id));
let (name, desc) = persona_from_config(&dir.join("config.yaml"));
let display = name
.or_else(|| name_from_agent_json(&dir.join("agent.json")))
.or_else(|| name_from_cadences(&dir))
.unwrap_or_else(|| short_id(id));
let about = desc.unwrap_or_default();
let primary = dir.join("memory");
if primary.join(".git").is_dir() {
@ -978,6 +1326,9 @@ pub fn discover_agents(home: &Path) -> Vec<Root> {
key: format!("agent:{id}"),
label: display.clone(),
mode: Mode::Observe,
group: display.clone(),
about: about.clone(),
agent: id.to_string(),
vault: Vault::new(primary),
});
}
@ -1001,6 +1352,9 @@ pub fn discover_agents(home: &Path) -> Vec<Root> {
key: format!("agent:{id}/{cname}"),
label,
mode: Mode::Observe,
group: display.clone(),
about: about.clone(),
agent: id.to_string(),
vault: Vault::new(mem),
});
}