Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
9214da8
feat(canonical): admit JSON on the bytes before it is hashed or signed
astrogilda Sep 20, 2026
557cae2
feat(cli): admit a grant at the boundary where its bytes arrive
astrogilda Sep 20, 2026
3e707a7
complete fix for file inode duplication (#206)
leefaus Sep 21, 2026
2be8987
fix(switch): keep name-conflicted files that are in the target view's…
graywolf336 Sep 21, 2026
a0c1dea
fix(agent): record session-touched deletions after plugin claim loss …
geekgonecrazy Sep 21, 2026
36fcf33
fix(record): resolve name conflicts as namespace patches (#203)
graywolf336 Sep 21, 2026
156102f
fix(view): --parent creates a draft (overlay) child — never an empty …
graywolf336 Sep 21, 2026
fe51d4b
chore(harness): unique test harness numbering so 39_view_switch becom…
graywolf336 Sep 21, 2026
0692978
fix(cli): support safe unrecord by hash or prefix (#196)
vinceblock99 Sep 21, 2026
7d59890
refactor(change): resolve hash prefixes via the shared repository res…
graywolf336 Sep 21, 2026
e5d1d3b
Bump version to 0.18.3
github-actions[bot] Sep 21, 2026
134f271
fix(canonical): make the declared depth cap the only depth cap
astrogilda Sep 22, 2026
d073ce6
feat(agent): add --from-skills to source skills inputs from a local c…
graywolf336 Sep 24, 2026
22de671
feat(diff): add --json, and point a clean working copy at -c (#218)
graywolf336 Sep 25, 2026
ba802e0
feat(agent): select a delegated identity for hook recording (#219)
geekgonecrazy Sep 25, 2026
511ab8f
fix(triage): default <VIEW> to the current view and --into to its par…
graywolf336 Sep 25, 2026
02d56e0
feat(change): Ed25519 signing for recorded changes (#214)
geekgonecrazy Sep 26, 2026
1779710
refactor(canonical): delegate JCS to a from-spec canonicalizer with v…
astrogilda Sep 19, 2026
695c6c7
style(canonical): apply rustfmt to the JCS vector tests
astrogilda Sep 25, 2026
b696e36
Merge dev into feat/jcs-admit-ingest-boundary
graywolf336 Sep 28, 2026
63e5f79
fix(canonical): refuse to store a grant its own readers would refuse
astrogilda Sep 30, 2026
3cdeb28
docs(canonical): stop saying load_for_delegate logs each skip
astrogilda Sep 30, 2026
9fa8a31
test(canonical): fail the build if a Value can carry an unformattable…
astrogilda Sep 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 83 additions & 38 deletions Cargo.lock

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ members = [
]

[workspace.package]
version = "0.18.2"
version = "0.18.3"
edition = "2021"
authors = ["Atomic Contributors"]
license = "Apache-2.0"
Expand All @@ -39,6 +39,12 @@ atomic-teams = { path = "atomic-teams" }
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# Admission control for JSON that will be hashed, signed or verified. Runs on
# the raw bytes, ahead of the parse, so the faults a parsed value can no longer
# report -- a repeated member, nesting past the depth cap, a string that is not
# a sequence of Unicode scalar values, a number outside the profile -- are
# refused with a named error rather than collapsed or rounded.
jcs-admit = "0.1"
postcard = { version = "1.0", features = ["alloc"] }
toml = "0.8"
semver = "1"
Expand Down
305 changes: 275 additions & 30 deletions atomic-agent/src/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,19 +143,7 @@ pub fn active_delegation_urn(
agent_identity: Option<&str>,
identity_dir: Option<&Path>,
) -> Option<String> {
let name = agent_identity
.map(str::to_string)
.or_else(|| std::env::var(AGENT_IDENTITY_ENV).ok())
.map(|n| n.trim().to_string())
.filter(|n| !n.is_empty())?;

let store = match identity_dir {
Some(dir) => atomic_identity::IdentityStore::open(dir),
None => atomic_identity::IdentityStore::open_default(),
}
.ok()?;

let identity = store.load_by_name(&name).ok()?;
let (store, identity) = load_selected_agent_identity(agent_identity, identity_dir)?;
atomic_canonical::delegation::active_for_delegate(&store, &identity)
.map(|d| d.delegation.id.to_urn())
}
Expand All @@ -166,26 +154,37 @@ pub fn active_delegation_urn(
/// gets both authenticated pushes and correctly attributed changes.
pub const AGENT_IDENTITY_ENV: &str = "ATOMIC_AGENT_IDENTITY";

/// Build an author from a delegated agent identity, if one is configured and
/// resolvable.
/// Open the identity store at the given directory, or the default one.
///
/// Returns `None` — rather than failing — whenever the identity is missing or
/// unreadable. Recording a turn must not break because an agent identity was
/// mistyped; falling back to the plus-tag author keeps the work attributed to
/// *someone* and leaves a debug log explaining why it is not keyed.
fn delegated_agent_author(options: &AgentAuthorOptions<'_>) -> Option<Author> {
let name = options
.agent_identity
.clone()
/// Returns `None` rather than failing: every caller here is on a
/// best-effort path where recording must continue without identity data.
fn open_identity_store(identity_dir: Option<&Path>) -> Option<atomic_identity::IdentityStore> {
match identity_dir {
Some(dir) => atomic_identity::IdentityStore::open(dir).ok(),
None => atomic_identity::IdentityStore::open_default().ok(),
}
}

/// The currently-selected delegated agent identity, if one resolves.
///
/// Shared by the author path ([`delegated_agent_author`]) and the signing
/// path ([`resolve_turn_signer`]) so attribution and proof can never name
/// different identities: same name chain (explicit option, else
/// [`AGENT_IDENTITY_ENV`]), same store, same refusal of human identities.
///
/// Returns the store alongside the identity — callers need it to load the
/// keypair without re-opening (and possibly disagreeing about) the store.
fn load_selected_agent_identity(
agent_identity: Option<&str>,
identity_dir: Option<&Path>,
) -> Option<(atomic_identity::IdentityStore, atomic_identity::Identity)> {
let name = agent_identity
.map(str::to_string)
.or_else(|| std::env::var(AGENT_IDENTITY_ENV).ok())
.map(|n| n.trim().to_string())
.filter(|n| !n.is_empty())?;

let store = match options.identity_dir.as_deref() {
Some(dir) => atomic_identity::IdentityStore::open(dir),
None => atomic_identity::IdentityStore::open_default(),
}
.ok()?;
let store = open_identity_store(identity_dir)?;

let identity = match store.load_by_name(&name) {
Ok(identity) => identity,
Expand All @@ -203,6 +202,79 @@ fn delegated_agent_author(options: &AgentAuthorOptions<'_>) -> Option<Author> {
return None;
}

Some((store, identity))
}

/// Resolve the signer for a recorded turn: the identity whose public key
/// the header author claims.
///
/// The levels mirror [`resolve_agent_author`] exactly, so the signature
/// always proves the header's key claim:
///
/// 1. A **selected delegated agent identity** — keyed attribution claims
/// its public key, so the turn signs with its secret key. If the
/// identity resolves but its keypair is not on disk (a
/// verification-only identity), the turn records **unsigned** rather
/// than silently signing as someone else — a signature by a different
/// key would contradict the header's claim.
/// 2. The **default identity** — the plus-tag path claims the human's
/// public key, so the turn signs with the human's secret key, exactly
/// like `atomic record` does.
/// 3. Neither — `None`; the change records unsigned (legacy behavior).
///
/// Never fails: a turn must not fail to record over identity selection.
pub fn resolve_turn_signer(
agent_identity: Option<&str>,
identity_dir: Option<&Path>,
) -> Option<atomic_repository::record::SigningIdentity> {
use atomic_canonical::did::did_for_public_key;
use atomic_identity::{Identity, KeyPair};

fn signing_identity(
identity: &Identity,
keypair: &KeyPair,
) -> atomic_repository::record::SigningIdentity {
atomic_repository::record::SigningIdentity {
signer_did: did_for_public_key(&identity.public_key),
secret_key: *keypair.secret.as_bytes(),
}
}

// Level 1: the selected delegated identity — claim and proof must agree.
if let Some((store, identity)) = load_selected_agent_identity(agent_identity, identity_dir) {
return match store.load_keypair(&identity.id, None) {
Ok(keypair) => Some(signing_identity(&identity, &keypair)),
Err(e) => {
log::debug!(
"Agent identity '{}' has no usable local keypair ({e}); \
recording unsigned rather than signing as someone else",
identity.name
);
None
}
};
}

// Level 2: plus-tag attribution claims the default identity's key.
let store = open_identity_store(identity_dir)?;
let identity = store.get_default().ok()??;
let keypair = store.load_keypair(&identity.id, None).ok()?;
Some(signing_identity(&identity, &keypair))
}

/// Build an author from a delegated agent identity, if one is configured and
/// resolvable.
///
/// Returns `None` — rather than failing — whenever the identity is missing or
/// unreadable. Recording a turn must not break because an agent identity was
/// mistyped; falling back to the plus-tag author keeps the work attributed to
/// *someone* and leaves a debug log explaining why it is not keyed.
fn delegated_agent_author(options: &AgentAuthorOptions<'_>) -> Option<Author> {
let (_, identity) = load_selected_agent_identity(
options.agent_identity.as_deref(),
options.identity_dir.as_deref(),
)?;

let session_short = extract_session_short(options.session_id);
let tag = format!(
"{}+{}",
Expand Down Expand Up @@ -494,6 +566,11 @@ fn extract_toml_string_value(line: &str) -> Option<String> {
/// * `agent_name` — Agent registry key (e.g., "claude-code")
/// * `agent_display_name` — Human-readable name (e.g., "Claude Code")
/// * `session_id` — Session identifier for the `+tag` suffix
/// * `agent_identity` — Name of a delegated agent identity to sign as,
/// resolved by the CLI hook handler (env var > global setting > active
/// server profile). `None` falls back to `ATOMIC_AGENT_IDENTITY` and then
/// to the plus-tag path, so an environment with nothing configured
/// behaves exactly as before agent identities existed.
///
/// # Returns
///
Expand All @@ -502,13 +579,18 @@ fn extract_toml_string_value(line: &str) -> Option<String> {
///
/// - With identity: `claude+60f5 <lee@atomic.dev>` (with public key ref)
/// - Without: `Claude Code` (no email)
pub fn build_agent_author(agent_name: &str, agent_display_name: &str, session_id: &str) -> Author {
pub fn build_agent_author(
agent_name: &str,
agent_display_name: &str,
session_id: &str,
agent_identity: Option<&str>,
) -> Author {
let options = AgentAuthorOptions {
agent_name,
agent_display_name,
session_id,
identity_dir: None,
agent_identity: None,
agent_identity: agent_identity.map(str::to_string),
};
resolve_agent_author(&options)
}
Expand Down Expand Up @@ -822,6 +904,140 @@ mod tests {
);
}

// resolve_turn_signer

/// Test fixture: a store holding a human default identity (with key)
/// and a delegated agent identity (with key).
fn signer_test_store(
dir: &Path,
) -> (
atomic_identity::KeyPair,
atomic_identity::Identity,
atomic_identity::KeyPair,
atomic_identity::Identity,
) {
use atomic_identity::{Identity, IdentityStore, IdentityType, KeyPair};

let mut store = IdentityStore::open(dir).unwrap();

let human_key = KeyPair::generate();
let human = Identity::builder("alice")
.email("alice@example.com")
.public_key(human_key.public.clone())
.build()
.unwrap();
store.save_with_keypair(&human, &human_key, None).unwrap();
store.set_default(&human.id).unwrap();

let agent_key = KeyPair::generate();
let agent = Identity::builder("alice+claude")
.identity_type(IdentityType::Agent)
.email("alice+claude@example.com")
.public_key(agent_key.public.clone())
.delegated_by(human.id)
.build()
.unwrap();
store.save_with_keypair(&agent, &agent_key, None).unwrap();

(human_key, human, agent_key, agent)
}

/// The whole point of the feature: a selected agent identity signs with
/// its OWN key, so the signature proves the header's key claim. The
/// human's key must not sign work attributed to the agent.
#[test]
fn a_selected_agent_identity_signs_with_its_own_key() {
use atomic_canonical::did::did_for_public_key;

let dir = TempDir::new().unwrap();
let (human_key, _, agent_key, agent) = signer_test_store(dir.path());

let signer = resolve_turn_signer(Some("alice+claude"), Some(dir.path())).expect("signer");

assert_eq!(
signer.signer_did,
did_for_public_key(&agent.public_key),
"signer DID must name the agent identity"
);
assert_eq!(signer.secret_key, *agent_key.secret.as_bytes());
assert_ne!(
signer.secret_key,
*human_key.secret.as_bytes(),
"the human's key must never sign agent-attributed work"
);
}

/// A verification-only agent identity (no keypair on disk) must not fall
/// through to signing as someone else: the header claims the agent's
/// key, so a signature by any other key would contradict the claim.
#[test]
fn a_keyless_selected_identity_records_unsigned_not_as_someone_else() {
use atomic_identity::{Identity, IdentityStore, IdentityType};

let dir = TempDir::new().unwrap();
let mut store = IdentityStore::open(dir.path()).unwrap();

let human_key = atomic_identity::KeyPair::generate();
let human = Identity::builder("alice")
.email("alice@example.com")
.public_key(human_key.public.clone())
.build()
.unwrap();
store.save_with_keypair(&human, &human_key, None).unwrap();
store.set_default(&human.id).unwrap();

let agent_key = atomic_identity::KeyPair::generate();
let agent = Identity::builder("alice+claude")
.identity_type(IdentityType::Agent)
.public_key(agent_key.public.clone())
.delegated_by(human.id)
.build()
.unwrap();
// Saved WITHOUT the keypair — the store knows the identity and its
// public key, but not the secret.
store.save(&agent).unwrap();

assert!(resolve_turn_signer(Some("alice+claude"), Some(dir.path())).is_none());
}

/// Plus-tag attribution claims the default identity's public key, so
/// that is what signs when no agent identity is selected — the same
/// identity `atomic record` would sign with.
#[test]
fn with_no_selection_the_default_identity_signs() {
use atomic_canonical::did::did_for_public_key;

let dir = TempDir::new().unwrap();
let (human_key, human, _, _) = signer_test_store(dir.path());

let signer = resolve_turn_signer(None, Some(dir.path())).expect("signer");

assert_eq!(signer.signer_did, did_for_public_key(&human.public_key));
assert_eq!(signer.secret_key, *human_key.secret.as_bytes());
}

/// A human identity passed as the agent identity is refused at level 1 —
/// attribution falls to the plus-tag path, so the signer must be the
/// default identity, matching the header's claim.
#[test]
fn a_human_identity_selected_falls_back_to_the_default_signer() {
let dir = TempDir::new().unwrap();
let (human_key, _, _, _) = signer_test_store(dir.path());

// "alice" is the human default, not an agent identity.
let signer = resolve_turn_signer(Some("alice"), Some(dir.path())).expect("signer");
assert_eq!(signer.secret_key, *human_key.secret.as_bytes());
}

/// No identities at all: no signer, and the turn records unsigned —
/// legacy behavior, unchanged.
#[test]
fn an_empty_store_has_no_signer() {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join("identities")).unwrap();
assert!(resolve_turn_signer(None, Some(dir.path().join("identities").as_path())).is_none());
}

// resolve_agent_author (integration)

#[test]
Expand Down Expand Up @@ -943,13 +1159,42 @@ identity_type = "user"
"claude-code",
"Claude Code",
"60f5cbd2-aa23-40ee-9085-4375dd186ce7",
None,
);

// Can't guarantee identity store exists in test env, so just verify
// the author is valid (either tagged or fallback)
assert!(!author.name.is_empty());
}

/// A name that does not resolve must degrade to a usable author, not an
/// error — recording a turn must never fail over identity selection.
#[test]
fn build_agent_author_never_fails_on_an_unresolvable_name() {
let author = build_agent_author("open-code", "OpenCode", "sess1234", Some("no-such-agent"));
assert!(!author.name.is_empty());
}

/// The delegated name must reach the resolver: `build_agent_author` is a
/// thin wrapper over `resolve_agent_author`, so the same inputs — with
/// and without a delegated name — must produce identical authors.
#[test]
fn build_agent_author_threads_the_delegated_name() {
for name in [None, Some("fred+opencode")] {
let via_wrapper = build_agent_author("open-code", "OpenCode", "sess1234", name);
let via_resolver = resolve_agent_author(&AgentAuthorOptions {
agent_name: "open-code",
agent_display_name: "OpenCode",
session_id: "sess1234",
identity_dir: None,
agent_identity: name.map(str::to_string),
});
assert_eq!(via_wrapper.name, via_resolver.name);
assert_eq!(via_wrapper.email, via_resolver.email);
assert_eq!(via_wrapper.identity, via_resolver.identity);
}
}

// extract_toml_string_value

#[test]
Expand Down
Loading
Loading