effect, cli: per-member BYOK credentials and the redaction audit (phase 6)
commit f74f036
effect, cli: per-member BYOK credentials and the redaction audit (phase 6)
Credentials are deployment state, never repository data
(effect.deployment-property): a CredentialStore is read once when the
HostedRoot opens, from the file GIT_ENTS_CREDENTIALS_FILE names — one
member-id/var-name/secret line per member, the var name refused at
parse time unless it is a POSIX env name, since it is folded verbatim
into the export the shell-script backends build. Injection is the new
SandboxInputs::env, honored uniformly: SpriteExecutor and Docker fold
single-quote-escaped exports into the launch script (round-trip proven
through a real sh), the unsandboxed backend uses Command::envs, and
ordinary effects pass an empty slice.
agent-exec and agent-plan look up the session member’s credential
before anything else happens — no workdir, no sideband files, sandbox
never launched (proven by panic-on-run stubs). A missing credential is
a completed fail, not an infra error: which member has which credential
is a fixed deployment property, so the queue retrying would never fix
it. agent-exec finalizes Failed with no branch via a two-transition
proposal; agent-plan records the failure and leaves the session
Planning, dispatchable again once configured. Workdirs are removed
after both run paths.
The audit (tests/agent_credentials.rs) drives a full session to Done
with a sentinel key and sweeps every reachable git object from the
session ref, both result refs, and the branch tip, plus the scratch
dir: clean. Its honest counterpart proves the boundary — a malicious
command CAN echo its own env into its transcript and branch; the
guarantee is that the worker machinery never persists the credential
itself, not that a hostile sandbox cannot exfiltrate what it was
handed. The web Planner stays a stub; nothing credential-shaped
reaches ents-web.
No reviews of this commit yet — record a verdict below.
Start a review
crates/cli/git-ents/src/agent_worker.rs
@@ -55,6 +55,22 @@
//! branch's tree reflects genuine sandbox output; this is called out again
//! in the module-level doc of `ents-effect`'s `sprite` module as a known
//! gap, not silently assumed away here.
+//!
+//! # The BYOK credential seam (`roots.config-isolation`, Phase 6)
+//!
+//! A session's own `meta.member` names whose credential is injected: BYOK
+//! means each member's runs are billed and rate-limited against their own
+//! Anthropic account, never a shared one. [`credentials`] is looked up
+//! *after* the claim succeeds (so the claim itself — "this worker owns this
+//! session now" — is unconditional) but *before* anything else about the
+//! run happens: no workdir is checked out, no plan file written, nothing
+//! executed. A member with no configured credential is not retried as an
+//! infrastructure hiccup — which member has which credential is a fixed
+//! property of this deployment, not a transient failure, so retrying the
+//! exact same run changes nothing (`effect.result-taxonomy`'s own
+//! pass/fail split: this is a genuine, deterministic `fail`, landed via the
+//! ordinary finalize with no result branch, since no sandbox ever ran to
+//! produce one).
use std::path::{Path, PathBuf};
@@ -71,6 +87,7 @@
use gix_ref_store::RefStore;
use crate::commands::commit_tree;
+use crate::credentials::CredentialStore;
use crate::error::{Error, Result};
/// `agent-exec`'s own effect name — re-exported so a caller deciding
@@ -140,7 +157,7 @@
/// session stays `Running`, and the queue's own retry policy is what
/// revisits it (`effect.result-taxonomy`) — or building and sending the
/// finalize proposal.
-// @relation(effect.execution, effect.results-writeback, effect.result-taxonomy, receive.multi-ref-atomicity, scope=function)
+// @relation(effect.execution, effect.results-writeback, effect.result-taxonomy, receive.multi-ref-atomicity, roots.config-isolation, scope=function)
#[expect(
clippy::too_many_arguments,
reason = "one input per materialization/identity step, mirrors ents_effect::run::run_one's \
@@ -160,6 +177,7 @@
author: &gix::actor::Signature,
sign: &dyn Fn(&[u8]) -> String,
mode: Mode,
+ credentials: &CredentialStore,
) -> Result<AgentRunOutcome>
where
O: Find + Write,
@@ -204,6 +222,31 @@
return Ok(AgentRunOutcome::ClaimLost);
}
+ // BYOK: this session's own member must have a configured credential
+ // before anything else about the run happens — no workdir, no plan
+ // file, no sandbox launch. Missing is a deterministic property of this
+ // deployment, not a transient hiccup a retry would fix, so it lands as
+ // an ordinary completed `fail`, exactly like the sandbox reporting a
+ // nonzero exit below, just without ever having produced a result
+ // branch (`roots.config-isolation`, `effect.result-taxonomy`).
+ let Some(credential) = credentials.get(&session.meta.member) else {
+ return finish_without_running(
+ refs,
+ objects,
+ events,
+ &results_ref,
+ oid,
+ &id,
+ format!(
+ "no credential configured for member {}",
+ session.meta.member
+ ),
+ &identity,
+ mode,
+ );
+ };
+ let env = [(credential.var.clone(), credential.secret.clone())];
+
// 3. Run the sandbox against the declared base ref's tip.
let base_ref: gix::refs::FullName =
session
@@ -240,6 +283,7 @@
workdir: &workdir,
toolchains,
command,
+ env: &env,
};
// An `Err` here is an infrastructure failure the sandbox itself never
// turned into a completed pass/fail (`effect.result-taxonomy`): it
@@ -260,6 +304,18 @@
let _ = std::fs::remove_file(&plan_path);
let output_tree = ents_effect::materialize::write_tree(objects, &workdir)?;
+ // The workdir's job is done once its tree is captured: wipe it rather
+ // than leave it sitting on disk under `scratch` for the rest of this
+ // process's life (a BYOK credential may have reached it via the
+ // sandbox's own env, `SandboxInputs::env`, or a file the command
+ // wrote). Best-effort, like the plan-file removal above.
+ #[expect(
+ clippy::let_underscore_must_use,
+ reason = "best-effort cleanup; a failure here is not actionable and must not fail an \
+ otherwise-successful run"
+ )]
+ let _ = std::fs::remove_dir_all(&workdir);
+
let branch_name = result_branch_name(&session.meta.member, &id);
let branch_ref: gix::refs::FullName =
format!("refs/heads/{branch_name}")
@@ -323,6 +379,60 @@
Ok(AgentRunOutcome::Finished { id, outcome })
}
+/// Finalize a claimed session as `Failed` *without ever running the
+/// sandbox at all* — the BYOK missing-credential path
+/// (`roots.config-isolation`): no workdir is checked out, so there is no
+/// output tree and no result branch (`FinishAgentSession::result_branch`
+/// stays `None` — nothing ran to produce one). Lands the session's
+/// terminal state and this effect's own `fail` result atomically, exactly
+/// like [`run_agent_exec`]'s own finalize minus the branch transition.
+#[expect(
+ clippy::too_many_arguments,
+ reason = "one input per identity/write step, mirrors run_agent_exec's own finalize shape"
+)]
+fn finish_without_running<O: Find + Write>(
+ refs: &dyn RefStore,
+ objects: &O,
+ events: &dyn EventSink,
+ results_ref: &gix::refs::FullName,
+ oid: ObjectId,
+ id: &str,
+ detail: String,
+ identity: &Identity<'_>,
+ mode: Mode,
+) -> Result<AgentRunOutcome> {
+ let finish = FinishAgentSession {
+ outcome: FinishOutcome::Failed(detail.clone()),
+ result_branch: None,
+ thread: vec![detail.into_bytes()],
+ };
+ let (finish_transition, finish_tip) =
+ ents_forge::agent::finish_transition(refs, objects, id, finish, identity)?;
+
+ let record = ResultRecord::new(AGENT_EXEC_NAME, oid, ResultStatus::Fail);
+ let (result_transition, result_tip) = ents_receive::entity_transition(
+ refs,
+ objects,
+ results_ref,
+ &record,
+ identity,
+ "Record agent-exec result",
+ )?;
+
+ // Finalize = one atomic multi-ref proposal, same as the ordinary case,
+ // just two transitions instead of three (`receive.multi-ref-atomicity`).
+ let proposal = Proposal {
+ transitions: vec![finish_transition, result_transition],
+ objects: vec![finish_tip, result_tip],
+ auth: None,
+ };
+ let outcome = ents_receive::receive(refs, objects, events, &proposal, mode)?;
+ Ok(AgentRunOutcome::Finished {
+ id: id.to_owned(),
+ outcome,
+ })
+}
+
/// Record a cheap `pass` for `oid` on the canonical `agent-exec` results
/// ref — the no-op path both [`Dispatch::NoOp`] and a lost claim race take.
#[expect(
@@ -586,6 +696,19 @@
&*self.sign
}
+ /// A credential store configured for `queued_session`'s own member
+ /// (`jdc`) — every test that expects the run to actually reach the
+ /// sandbox uses this rather than [`CredentialStore::empty`].
+ fn credentials(&self) -> CredentialStore {
+ CredentialStore::from_pairs([(
+ MemberId::new("jdc"),
+ crate::credentials::Credential {
+ var: "ANTHROPIC_API_KEY".to_owned(),
+ secret: "sk-ant-test-token".to_owned(),
+ },
+ )])
+ }
+
/// A brand-new, queued session (ready, plan confirmed) — the only
/// precondition [`super::run_agent_exec`]'s `Dispatch::Claim` arm
/// runs against.
@@ -686,6 +809,7 @@
&author,
fixture.sign_fn(),
Mode::Advisory,
+ &CredentialStore::empty(),
)
.expect("dispatch never touches the sandbox for a no-op");
assert!(matches!(run, AgentRunOutcome::NoOp));
@@ -739,6 +863,7 @@
&author,
fixture.sign_fn(),
Mode::Advisory,
+ &CredentialStore::empty(),
)
.expect("a lost race is not an error");
assert!(matches!(run, AgentRunOutcome::ClaimLost));
@@ -777,6 +902,7 @@
&author,
fixture.sign_fn(),
Mode::Advisory,
+ &fixture.credentials(),
)
.expect("runs and finalizes");
let AgentRunOutcome::Finished {
@@ -840,6 +966,15 @@
!checkout_dir.path().join(AGENT_PLAN_FILE).exists(),
"the sideband plan file must never leak into the pushed branch"
);
+
+ // (d) the scratch workdir this run used is cleaned up afterward —
+ // nothing about a completed run should sit on disk for the rest of
+ // this process's life (see `run_agent_exec`'s own `remove_dir_all`
+ // after `write_tree`).
+ assert!(
+ !scratch.path().join(oid.to_string()).exists(),
+ "the run's own scratch workdir must not survive the run"
+ );
}
#[rstest]
@@ -868,6 +1003,7 @@
&author,
fixture.sign_fn(),
Mode::Advisory,
+ &fixture.credentials(),
)
.expect("runs and finalizes");
@@ -890,6 +1026,76 @@
assert_eq!(record.status, ResultStatus::Fail);
}
+ /// A member with no configured credential (`roots.config-isolation`)
+ /// gets a completed, deterministic `fail` — never a retryable
+ /// infrastructure error, and the sandbox is never even invoked
+ /// (`StubExecutor::run` would panic-visibly via its mutation if
+ /// called; instead this test hands a `panic`-on-run executor to prove
+ /// it never runs).
+ #[rstest]
+ // @relation(roots.config-isolation, effect.result-taxonomy, scope=function, role=Verifies)
+ fn a_member_with_no_configured_credential_fails_without_ever_running_the_sandbox() {
+ struct PanicsIfRun;
+ impl Executor for PanicsIfRun {
+ #[expect(
+ clippy::panic_in_result_fn,
+ reason = "the panic itself is this test's assertion: the sandbox must never run"
+ )]
+ fn run(
+ &self,
+ _inputs: &SandboxInputs<'_>,
+ ) -> ents_effect::Result<ents_effect::RunOutput> {
+ panic!("the sandbox must never launch without a configured credential");
+ }
+ }
+
+ let fixture = Fixture::new();
+ let (oid, id) = fixture.queued_session();
+
+ let scratch = tempfile::tempdir().expect("tempdir");
+ let author = fixture.author();
+ let run = run_agent_exec(
+ &fixture.refs,
+ &fixture.objects,
+ &NullEventSink,
+ &PanicsIfRun,
+ scratch.path(),
+ &[],
+ "true",
+ oid,
+ MemberId::new("worker"),
+ "sprite-1".to_owned(),
+ &author,
+ fixture.sign_fn(),
+ Mode::Advisory,
+ &CredentialStore::empty(),
+ )
+ .expect("a missing credential is a completed finalize, not an error");
+ assert!(matches!(run, AgentRunOutcome::Finished { .. }));
+
+ let session = ents_forge::agent::show(&fixture.refs, &fixture.objects, &id).expect("shows");
+ assert!(
+ matches!(session.meta.status, ents_forge::agent::Status::Failed(_)),
+ "a missing credential must land the session as Failed, not leave it Running"
+ );
+ assert!(
+ session.meta.result_branch.is_none(),
+ "no sandbox ran, so there is nothing for a result branch to carry"
+ );
+
+ let results_ref =
+ ents_model::namespace::result_ref(AGENT_EXEC_NAME, &short_oid(oid)).expect("valid");
+ let result_tip = fixture
+ .refs
+ .get(results_ref.as_ref())
+ .expect("readable")
+ .expect("result landed");
+ let result_tree = commit_tree(&fixture.objects, result_tip).expect("tree");
+ let record: ResultRecord =
+ facet_git_tree::deserialize(&result_tree, &fixture.objects).expect("decodes");
+ assert_eq!(record.status, ResultStatus::Fail);
+ }
+
#[rstest]
// @relation(effect.result-taxonomy, scope=function, role=Verifies)
fn a_sandbox_infrastructure_failure_publishes_nothing_and_leaves_the_session_running() {
@@ -912,6 +1118,7 @@
&author,
fixture.sign_fn(),
Mode::Advisory,
+ &fixture.credentials(),
)
.expect_err("an infra failure must propagate, not silently finalize");
assert!(matches!(error, Error::Effect(_)));
crates/cli/git-ents/src/lib.rs
@@ -76,6 +76,7 @@
pub mod agent_worker;
pub mod cli;
pub mod commands;
+pub mod credentials;
pub mod error;
pub mod exe;
pub mod hook;
crates/cli/git-ents/src/plan_worker.rs
@@ -43,6 +43,18 @@
//! drafting is read-only context gathering, not a code change, so there is
//! no result branch to push and nothing to clean up before an object write
//! that never happens.
+//!
+//! # The BYOK credential seam (`roots.config-isolation`, Phase 6)
+//!
+//! Drafting a plan is itself an agent run against the session's own member
+//! (`AgentSession::meta::member`), so it needs that member's credential
+//! injected exactly like `agent_worker::run_agent_exec` does — looked up
+//! *before* anything about the drafting attempt happens (no workdir, no
+//! prompt file written). A member with no configured credential is not an
+//! infrastructure hiccup (`effect.result-taxonomy`): it is recorded through
+//! the same [`record_result`] path a failed drafting attempt already takes,
+//! `Fail` on this effect's own results ref, the session left untouched and
+//! still dispatchable once the deployment gets a credential configured.
use std::path::{Path, PathBuf};
@@ -57,6 +69,7 @@
use gix_ref_store::RefStore;
use crate::commands::commit_tree;
+use crate::credentials::CredentialStore;
use crate::error::{Error, Result};
/// `agent-plan`'s own effect name — re-exported so a caller deciding which
@@ -121,7 +134,7 @@
/// untouched, and the queue's own retry policy is what revisits it,
/// `effect.result-taxonomy`), reading back the drafted plan file, or
/// building and sending the finalize proposal.
-// @relation(effect.execution, effect.results-writeback, effect.result-taxonomy, receive.multi-ref-atomicity, scope=function)
+// @relation(effect.execution, effect.results-writeback, effect.result-taxonomy, receive.multi-ref-atomicity, roots.config-isolation, scope=function)
#[expect(
clippy::too_many_arguments,
reason = "one input per materialization/identity step, mirrors run_agent_exec's own shape"
@@ -138,6 +151,7 @@
author: &gix::actor::Signature,
sign: &dyn Fn(&[u8]) -> String,
mode: Mode,
+ credentials: &CredentialStore,
) -> Result<AgentPlanOutcome>
where
O: Find + Write,
@@ -168,6 +182,27 @@
sign,
};
+ // BYOK: this session's own member must have a configured credential
+ // before a drafting attempt happens at all — no workdir, no prompt
+ // file. Missing is a deterministic deployment property, not a
+ // transient hiccup, so it is recorded exactly like a drafting command
+ // that ran and reported failure (`effect.result-taxonomy`).
+ let Some(credential) = credentials.get(&session.meta.member) else {
+ record_result(
+ refs,
+ objects,
+ events,
+ &results_ref,
+ oid,
+ ResultStatus::Fail,
+ author,
+ sign,
+ mode,
+ )?;
+ return Ok(AgentPlanOutcome::DraftFailed);
+ };
+ let env = [(credential.var.clone(), credential.secret.clone())];
+
// Read-only context gathering against the declared base ref: no output
// tree is ever captured back from this checkout (unlike `agent-exec`'s
// run), since drafting a plan is never a code change.
@@ -209,6 +244,7 @@
workdir: &workdir,
toolchains,
command,
+ env: &env,
};
// An `Err` here is an infrastructure failure the sandbox itself never
// turned into a completed pass/fail: it propagates as-is, past every
@@ -223,6 +259,12 @@
// ever moving it toward a terminal `Status::Failed` — that variant
// names a session's own run failing, not a drafting attempt that
// may simply be retried on a future commit.
+ #[expect(
+ clippy::let_underscore_must_use,
+ reason = "best-effort cleanup; a failure here is not actionable and must not fail an \
+ otherwise-recorded result"
+ )]
+ let _ = std::fs::remove_dir_all(&workdir);
record_result(
refs,
objects,
@@ -243,6 +285,18 @@
source,
})?;
+ // The workdir's job is done once the draft is read back: wipe it
+ // rather than leave it (and whatever the sandbox's env or the
+ // command's own output wrote into it) sitting on disk for the rest of
+ // this process's life. Best-effort, like `agent_worker`'s identical
+ // cleanup.
+ #[expect(
+ clippy::let_underscore_must_use,
+ reason = "best-effort cleanup; a failure here is not actionable and must not fail an \
+ otherwise-successful draft"
+ )]
+ let _ = std::fs::remove_dir_all(&workdir);
+
let (draft_transition, draft_tip) = match ents_forge::agent::draft_plan_transition(
refs,
objects,
@@ -523,6 +577,19 @@
&*self.sign
}
+ /// A credential store configured for `planning_session`'s own
+ /// member (`jdc`) — every test that expects drafting to actually
+ /// reach the sandbox uses this rather than [`CredentialStore::empty`].
+ fn credentials(&self) -> CredentialStore {
+ CredentialStore::from_pairs([(
+ MemberId::new("jdc"),
+ crate::credentials::Credential {
+ var: "ANTHROPIC_API_KEY".to_owned(),
+ secret: "sk-ant-test-token".to_owned(),
+ },
+ )])
+ }
+
/// A brand-new, `planning` session with a prompt and no plan — the
/// only precondition [`super::run_agent_plan`]'s
/// [`PlanDispatch::Draft`] arm runs against.
@@ -593,6 +660,7 @@
&author,
fixture.sign_fn(),
Mode::Advisory,
+ &CredentialStore::empty(),
)
.expect("dispatch never touches the sandbox for a no-op");
assert!(matches!(run, AgentPlanOutcome::NoOp));
@@ -639,6 +707,7 @@
&author,
fixture.sign_fn(),
Mode::Advisory,
+ &fixture.credentials(),
)
.expect("drafts and finalizes");
let AgentPlanOutcome::Drafted {
@@ -675,6 +744,77 @@
facet_git_tree::deserialize(&result_tree, &fixture.objects).expect("decodes");
assert_eq!(record.status, ResultStatus::Pass);
assert_eq!(record.target(), oid);
+
+ // The scratch workdir this drafting run used is cleaned up
+ // afterward, mirroring `agent_worker::run_agent_exec`'s identical
+ // guarantee.
+ assert!(
+ !scratch.path().join(oid.to_string()).exists(),
+ "the drafting run's own scratch workdir must not survive the run"
+ );
+ }
+
+ /// A member with no configured credential (`roots.config-isolation`)
+ /// never reaches the sandbox at all: recorded as a completed `fail` on
+ /// this effect's own results ref, the session left untouched — the same
+ /// outcome shape [`AgentPlanOutcome::DraftFailed`] already covers for a
+ /// drafting command that ran and failed, since a missing credential is
+ /// exactly as deterministic and exactly as retry-proof.
+ #[rstest]
+ // @relation(roots.config-isolation, effect.result-taxonomy, scope=function, role=Verifies)
+ fn a_member_with_no_configured_credential_never_runs_the_sandbox() {
+ struct PanicsIfRun;
+ impl Executor for PanicsIfRun {
+ #[expect(
+ clippy::panic_in_result_fn,
+ reason = "the panic itself is this test's assertion: drafting must never run a \
+ sandbox"
+ )]
+ fn run(
+ &self,
+ _inputs: &SandboxInputs<'_>,
+ ) -> ents_effect::Result<ents_effect::RunOutput> {
+ panic!("drafting must never launch a sandbox without a configured credential");
+ }
+ }
+
+ let fixture = Fixture::new();
+ let (oid, id) = fixture.planning_session();
+
+ let scratch = tempfile::tempdir().expect("tempdir");
+ let author = fixture.author();
+ let run = run_agent_plan(
+ &fixture.refs,
+ &fixture.objects,
+ &NullEventSink,
+ &PanicsIfRun,
+ scratch.path(),
+ &[],
+ "true",
+ oid,
+ &author,
+ fixture.sign_fn(),
+ Mode::Advisory,
+ &CredentialStore::empty(),
+ )
+ .expect("a missing credential is a completed result, not an error");
+ assert!(matches!(run, AgentPlanOutcome::DraftFailed));
+
+ let session = ents_forge::agent::show(&fixture.refs, &fixture.objects, &id).expect("shows");
+ assert_eq!(session.meta.status, ents_forge::agent::Status::Planning);
+ assert!(session.plan.is_none());
+
+ let results_ref =
+ ents_model::namespace::result_ref(AGENT_PLAN_NAME, &short_oid(oid)).expect("valid");
+ let result_tip = fixture
+ .refs
+ .get(results_ref.as_ref())
+ .expect("readable")
+ .expect("result landed");
+ let result_tree = commit_tree(&fixture.objects, result_tip).expect("tree");
+ let record: ResultRecord =
+ facet_git_tree::deserialize(&result_tree, &fixture.objects).expect("decodes");
+ assert_eq!(record.status, ResultStatus::Fail);
}
#[rstest]
@@ -701,6 +841,7 @@
&author,
fixture.sign_fn(),
Mode::Advisory,
+ &fixture.credentials(),
)
.expect("runs");
assert!(matches!(run, AgentPlanOutcome::DraftFailed));
@@ -742,6 +883,7 @@
&author,
fixture.sign_fn(),
Mode::Advisory,
+ &fixture.credentials(),
)
.expect_err("an infra failure must propagate, not silently finalize");
assert!(matches!(error, Error::Effect(_)));
@@ -800,6 +942,7 @@
&author,
fixture.sign_fn(),
Mode::Advisory,
+ &fixture.credentials(),
)
.expect("a lost race is not an error");
assert!(matches!(run, AgentPlanOutcome::DraftLost));
crates/cli/git-ents/src/root.rs
@@ -53,6 +53,7 @@
use ents_receive::{Mode, NullEventSink};
use gix_ref_store::LooseRefStore;
+use crate::credentials::CredentialStore;
use crate::error::{Error, Result};
/// A real, on-disk object store: the repository's own odb, opened for
@@ -188,6 +189,12 @@
/// The fixed `Executor` this root wires (`roots.single-node-hosted`): a
/// `SpriteExecutor` targeting [`HOSTED_WORKER_NAME`].
pub executor: Box<dyn Executor>,
+ /// Per-member BYOK credentials (`roots.config-isolation`), read once
+ /// from [`crate::credentials::CREDENTIALS_FILE_VAR`] — the seam
+ /// [`crate::agent_worker::run_agent_exec`]/
+ /// [`crate::plan_worker::run_agent_plan`] resolve a session's own
+ /// member's credential from before injecting it into a sandbox launch.
+ pub credentials: CredentialStore,
}
/// The Sprite name (and commit author name) the single-node hosted root's
@@ -208,19 +215,23 @@
///
/// [`Error::Repo`] or [`Error::Refs`] if `path` is not a git
/// repository; [`Error::Receive`] if the reconciliation scan itself
- /// fails to read repository state.
+ /// fails to read repository state; [`Error::Io`]/[`Error::InvalidArgument`]
+ /// if [`crate::credentials::CREDENTIALS_FILE_VAR`] names a credentials
+ /// file that cannot be read or is malformed.
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref().to_owned();
let refs = LooseRefStore::open(&path)?;
let objects = QuarantineObjects::open(&path)?;
let events = ents_receive::MemoryEventSink::default();
ents_receive::reconcile(&refs, &objects, &events)?;
+ let credentials = CredentialStore::from_env()?;
Ok(Self {
path,
refs,
objects,
events,
executor: Box::new(ents_effect::SpriteExecutor::new(HOSTED_WORKER_NAME)),
+ credentials,
})
}
crates/cli/git-ents/tests/agent.rs
@@ -322,6 +322,17 @@
assert_eq!(planning.meta.status, Status::Planning);
assert!(planning.plan.is_none());
+ // BYOK (`roots.config-isolation`): both the headless draft and the
+ // exec run below need this session's own member's credential
+ // configured, or they fail closed rather than run.
+ let credentials = git_ents::credentials::CredentialStore::from_pairs([(
+ planning.meta.member.clone(),
+ git_ents::credentials::Credential {
+ var: "ANTHROPIC_API_KEY".to_owned(),
+ secret: "sk-ant-test-token".to_owned(),
+ },
+ )]);
+
// 2. The `agent-plan` effect drafts headlessly.
let session_ref = ents_model::namespace::agent_session_ref(&id).expect("valid");
let tip = root
@@ -351,6 +362,7 @@
&worker_author,
&|payload| signer.sign(payload),
root.mode(),
+ &credentials,
)
.expect("drafts");
assert!(matches!(
@@ -397,6 +409,7 @@
&worker_author,
&|payload| signer.sign(payload),
Mode::Advisory,
+ &credentials,
)
.expect("claims and runs");
assert!(matches!(
crates/kernel/ents-effect/src/docker.rs
@@ -73,20 +73,34 @@
/// use ents_effect::executor::EXIT_MARKER;
/// use std::path::Path;
///
-/// let args = run_args(Path::new("/tmp/s/work"), &[], "cargo test");
+/// let args = run_args(Path::new("/tmp/s/work"), &[], "cargo test", &[]);
/// assert!(args.contains(&"/tmp/s/work:/work".to_owned()));
/// assert!(args.contains(&"debian:stable-slim".to_owned()));
/// let script = args.last().expect("has a script");
/// assert!(script.contains("cargo test") && script.contains(EXIT_MARKER));
/// ```
#[must_use]
-pub fn run_args(workdir: &Path, toolchains: &[(String, PathBuf)], command: &str) -> Vec<String> {
+pub fn run_args(
+ workdir: &Path,
+ toolchains: &[(String, PathBuf)],
+ command: &str,
+ env: &[(String, String)],
+) -> Vec<String> {
let mut args = vec![
"run".to_owned(),
"--rm".to_owned(),
"-v".to_owned(),
format!("{}:{WORKDIR}", workdir.display()),
];
+ // Passed as literal argv entries, never through a shell — `docker run`
+ // itself sets these on the container's environment, so a secret (a
+ // BYOK credential, `SandboxInputs::env`) needs no shell quoting at all
+ // here (unlike `crate::sprite::SpriteExecutor`, which ships the whole
+ // command as one remote shell-script string).
+ for (var, secret) in env {
+ args.push("-e".to_owned());
+ args.push(format!("{var}={secret}"));
+ }
let mut sandbox_dirs = Vec::with_capacity(toolchains.len());
for (name, host_dir) in toolchains {
let sandbox_dir = format!("{TOOLCHAINS_DIR}/{name}/bin");
@@ -115,7 +129,12 @@
// @relation(effect.result-taxonomy, scope=function)
fn run(&self, inputs: &SandboxInputs<'_>) -> Result<RunOutput> {
ensure_docker()?;
- let args = run_args(inputs.workdir, inputs.toolchains, inputs.command);
+ let args = run_args(
+ inputs.workdir,
+ inputs.toolchains,
+ inputs.command,
+ inputs.env,
+ );
let output = Command::new("docker")
.args(&args)
.output()
@@ -150,7 +169,7 @@
#[rstest]
// @relation(effect.execution, scope=function, role=Verifies)
fn run_args_binds_the_workdir() {
- let args = run_args(Path::new("/tmp/s/work"), &[], "cargo test");
+ let args = run_args(Path::new("/tmp/s/work"), &[], "cargo test", &[]);
assert_eq!(
args,
vec![
@@ -171,7 +190,7 @@
#[rstest]
// @relation(effect.result-taxonomy, scope=function, role=Verifies)
fn run_args_script_completes_with_the_exit_marker() {
- let args = run_args(Path::new("/w"), &[], "true");
+ let args = run_args(Path::new("/w"), &[], "true", &[]);
let script = args.last().expect("has a script");
assert!(
script.contains(crate::executor::EXIT_MARKER),
@@ -184,7 +203,7 @@
// @relation(effect.execution, effect.toolchains, scope=function, role=Verifies)
fn run_args_binds_each_toolchain_read_only_and_activates_it() {
let toolchains = vec![("rust".to_owned(), PathBuf::from("/cache/rust/bin"))];
- let args = run_args(Path::new("/w"), &toolchains, "cargo test");
+ let args = run_args(Path::new("/w"), &toolchains, "cargo test", &[]);
assert!(args.contains(&"/cache/rust/bin:/toolchains/rust/bin:ro".to_owned()));
let last = args.last().expect("has a command");
assert!(last.contains("export PATH=/toolchains/rust/bin:$PATH; cargo test"));
@@ -193,10 +212,23 @@
#[rstest]
// @relation(effect.execution, scope=function, role=Verifies)
fn run_args_uses_the_minimal_base_image() {
- let args = run_args(Path::new("/w"), &[], "true");
+ let args = run_args(Path::new("/w"), &[], "true", &[]);
assert_eq!(
args.get(args.len().saturating_sub(4)).map(String::as_str),
Some(IMAGE)
);
}
+
+ #[rstest]
+ // @relation(roots.config-isolation, scope=function, role=Verifies)
+ fn run_args_passes_env_as_literal_docker_flags_not_shell_text() {
+ let env = vec![("ANTHROPIC_API_KEY".to_owned(), "sk-ant-abc".to_owned())];
+ let args = run_args(Path::new("/w"), &[], "true", &env);
+ assert!(args.contains(&"-e".to_owned()));
+ assert!(args.contains(&"ANTHROPIC_API_KEY=sk-ant-abc".to_owned()));
+ // Never folded into the shell script itself — that's the sprite
+ // backend's own quoting concern, not docker's.
+ let script = args.last().expect("has a script");
+ assert!(!script.contains("sk-ant-abc"));
+ }
}
crates/kernel/ents-effect/src/executor.rs
@@ -29,6 +29,14 @@
/// The run command, exactly as stored on the effect definition
/// (`model.effect-definition`).
pub command: &'a str,
+ /// Extra environment variables to inject into the launched command,
+ /// deployment state a composition root resolves (a per-member BYOK
+ /// credential, `roots.config-isolation`) and hands down for exactly
+ /// this one run — never read from repository data, never written back
+ /// to it. Every backend injects these the same way it launches the
+ /// command at all; empty for every ordinary effect run, which has
+ /// nothing to inject.
+ pub env: &'a [(String, String)],
}
/// What a completed run reported. Only `Pass` or `Fail`
@@ -83,7 +91,7 @@
/// }
///
/// let dir = tempfile::tempdir().expect("tempdir");
-/// let inputs = SandboxInputs { workdir: dir.path(), toolchains: &[], command: "true" };
+/// let inputs = SandboxInputs { workdir: dir.path(), toolchains: &[], command: "true", env: &[] };
/// let output = AlwaysPass.run(&inputs).expect("infallible");
/// assert_eq!(output.status, RunStatus::Pass);
/// ```
@@ -125,6 +133,49 @@
format!("export PATH={path}:$PATH; {command}")
}
+/// Prefix `command` with an `export` for each of `env`'s pairs, single-quoted
+/// so an arbitrary secret value (a BYOK credential,
+/// [`SandboxInputs::env`]) round-trips through a `sh -c` script unmodified
+/// regardless of its own content — used by every backend that ships the
+/// command as one shell-script string to a remote or containerized shell
+/// ([`crate::sprite::SpriteExecutor`], [`crate::docker::DockerExecutor`]).
+/// The unsandboxed backend needs no such quoting: it sets the child
+/// process's environment directly via `Command::envs`, never folding a
+/// secret into shell source at all.
+///
+/// # Examples
+///
+/// ```
+/// use ents_effect::executor::inject_env;
+///
+/// let env = vec![("ANTHROPIC_API_KEY".to_owned(), "sk-ant-abc".to_owned())];
+/// assert_eq!(
+/// inject_env("run-agent", &env),
+/// "export ANTHROPIC_API_KEY='sk-ant-abc'; run-agent"
+/// );
+/// assert_eq!(inject_env("run-agent", &[]), "run-agent");
+/// ```
+#[must_use]
+pub fn inject_env(command: &str, env: &[(String, String)]) -> String {
+ if env.is_empty() {
+ return command.to_owned();
+ }
+ let exports = env
+ .iter()
+ .map(|(var, secret)| format!("export {var}={}", shell_single_quote(secret)))
+ .collect::<Vec<_>>()
+ .join("; ");
+ format!("{exports}; {command}")
+}
+
+/// Single-quote `value` for a POSIX shell, escaping any embedded `'` by
+/// closing the quote, emitting an escaped literal quote, and reopening it —
+/// the standard `'\''` trick, so a credential containing an apostrophe (or
+/// any other shell metacharacter) still round-trips as one literal string.
+fn shell_single_quote(value: &str) -> String {
+ format!("'{}'", value.replace('\'', r"'\''"))
+}
+
/// The sentinel [`wrap_exit_marker`] appends after the wrapped command, so
/// a CLI-driven backend can tell "the command completed and exited with
/// this status" apart from "the CLI or its transport failed" — the
@@ -227,6 +278,38 @@
assert_eq!(activate("run", &[]), "run");
}
+ #[rstest]
+ // @relation(roots.config-isolation, scope=function, role=Verifies)
+ fn inject_env_is_identity_with_no_env() {
+ assert_eq!(inject_env("run", &[]), "run");
+ }
+
+ #[rstest]
+ // @relation(roots.config-isolation, scope=function, role=Verifies)
+ fn inject_env_exports_every_pair_before_the_command() {
+ let env = vec![
+ ("A".to_owned(), "one".to_owned()),
+ ("B".to_owned(), "two".to_owned()),
+ ];
+ assert_eq!(
+ inject_env("run", &env),
+ "export A='one'; export B='two'; run"
+ );
+ }
+
+ #[rstest]
+ // @relation(roots.config-isolation, scope=function, role=Verifies)
+ fn inject_env_round_trips_a_secret_with_an_embedded_quote_through_a_real_shell() {
+ let env = vec![("SECRET".to_owned(), "it's a secret".to_owned())];
+ let script = inject_env("printf '%s' \"$SECRET\"", &env);
+ let output = std::process::Command::new("sh")
+ .arg("-c")
+ .arg(&script)
+ .output()
+ .expect("sh runs");
+ assert_eq!(String::from_utf8_lossy(&output.stdout), "it's a secret");
+ }
+
#[rstest]
#[case::pass("out\n__ENTS_EFFECT_EXIT=0\n", Some((RunStatus::Pass, "out")))]
#[case::fail("out\n__ENTS_EFFECT_EXIT=1\n", Some((RunStatus::Fail, "out")))]
crates/kernel/ents-effect/src/run.rs
@@ -170,6 +170,11 @@
workdir: &workdir,
toolchains,
command: &effect.run,
+ // An ordinary effect run has no per-member credential to inject
+ // (`roots.config-isolation`'s BYOK seam is `agent_worker`'s and
+ // `plan_worker`'s own concern, in `git-ents`); every backend still
+ // accepts the field uniformly, it is simply empty here.
+ env: &[],
};
let output = executor.run(&inputs)?;
let status = match output.status {
crates/kernel/ents-effect/src/sprite.rs
@@ -25,7 +25,7 @@
use crate::error::{Error, Result};
use crate::executor::{
- Executor, RunOutput, SandboxInputs, activate, parse_exit_marker, wrap_exit_marker,
+ Executor, RunOutput, SandboxInputs, activate, inject_env, parse_exit_marker, wrap_exit_marker,
};
/// Where the workdir is unpacked inside the Sprite.
@@ -273,13 +273,19 @@
}
/// The in-Sprite script for one run: enter the synced workdir, run the
-/// activated command wrapped by [`wrap_exit_marker`]. A `cd` failure (the
-/// workdir sync silently lost) exits before the marker can print, so it
-/// surfaces as infrastructure, not as a recorded `fail` — the same
-/// discrimination the marker gives a dying transport
-/// (`effect.result-taxonomy`).
-fn run_script(activated: &str) -> String {
- format!("cd {WORKDIR} || exit 70\n{}", wrap_exit_marker(activated))
+/// activated command — with `env`'s pairs exported first
+/// ([`inject_env`], `roots.config-isolation`: a per-member BYOK credential
+/// the composition root resolved, injected only here, at sandbox launch,
+/// never written to any tree this crate builds) — wrapped by
+/// [`wrap_exit_marker`]. A `cd` failure (the workdir sync silently lost)
+/// exits before the marker can print, so it surfaces as infrastructure, not
+/// as a recorded `fail` — the same discrimination the marker gives a dying
+/// transport (`effect.result-taxonomy`).
+fn run_script(activated: &str, env: &[(String, String)]) -> String {
+ format!(
+ "cd {WORKDIR} || exit 70\n{}",
+ wrap_exit_marker(&inject_env(activated, env))
+ )
}
impl Executor for SpriteExecutor {
@@ -296,7 +302,7 @@
sandbox_dirs.push((toolchain_name.clone(), sandbox_dir));
}
- let script = run_script(&activate(inputs.command, &sandbox_dirs));
+ let script = run_script(&activate(inputs.command, &sandbox_dirs), inputs.env);
let output = Command::new("sprite")
.args(["exec", "-s", &self.name, "--", "sh", "-c", &script])
.output()
@@ -340,7 +346,7 @@
#[rstest]
// @relation(effect.result-taxonomy, scope=function, role=Verifies)
fn run_script_gates_the_exit_marker_on_a_successful_cd() {
- let script = run_script("cargo test");
+ let script = run_script("cargo test", &[]);
// A failed cd exits before the marker can print, so a lost workdir
// sync surfaces as infrastructure, never as a recorded fail.
assert!(script.starts_with("cd /work || exit 70\n"));
@@ -348,6 +354,14 @@
assert!(script.contains("cargo test"));
}
+ #[rstest]
+ // @relation(roots.config-isolation, scope=function, role=Verifies)
+ fn run_script_exports_env_before_the_activated_command() {
+ let env = vec![("ANTHROPIC_API_KEY".to_owned(), "sk-ant-abc".to_owned())];
+ let script = run_script("cargo test", &env);
+ assert!(script.contains("export ANTHROPIC_API_KEY='sk-ant-abc'; cargo test"));
+ }
+
#[rstest]
// @relation(effect.execution, scope=function, role=Verifies)
fn unpack_script_kills_orphans_before_wiping_the_destination() {
crates/kernel/ents-effect/src/unsandboxed.rs
@@ -31,6 +31,13 @@
.arg("-c")
.arg(activate(inputs.command, &dirs))
.current_dir(inputs.workdir)
+ // Set directly on the child's environment rather than folded
+ // into the shell script `activate` built above: a secret (a
+ // BYOK credential, `SandboxInputs::env`) never needs shell
+ // quoting at all this way, unlike the sandboxed backends that
+ // must ship the whole command as one script string
+ // (`crate::executor::inject_env`).
+ .envs(inputs.env.iter().cloned())
.output()
.map_err(|e| Error::Spawn {
program: "sh".to_owned(),
@@ -65,6 +72,7 @@
workdir: dir.path(),
toolchains: &[],
command: "true",
+ env: &[],
};
let output = UnsandboxedExecutor.run(&inputs).expect("runs");
assert_eq!(output.status, RunStatus::Pass);
@@ -78,6 +86,7 @@
workdir: dir.path(),
toolchains: &[],
command: "false",
+ env: &[],
};
let output = UnsandboxedExecutor
.run(&inputs)
@@ -96,9 +105,25 @@
workdir: dir.path(),
toolchains: &toolchains,
command: "echo $PATH",
+ env: &[],
};
let output = UnsandboxedExecutor.run(&inputs).expect("runs");
assert!(output.log.contains("bin"));
let _: Vec<(String, PathBuf)> = toolchains;
}
+
+ #[rstest]
+ // @relation(roots.config-isolation, scope=function, role=Verifies)
+ fn unsandboxed_injects_env_directly_on_the_child_process() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let env = vec![("ENTS_TEST_CREDENTIAL".to_owned(), "sk-ant-test".to_owned())];
+ let inputs = SandboxInputs {
+ workdir: dir.path(),
+ toolchains: &[],
+ command: "printf '%s' \"$ENTS_TEST_CREDENTIAL\"",
+ env: &env,
+ };
+ let output = UnsandboxedExecutor.run(&inputs).expect("runs");
+ assert_eq!(output.log, "sk-ant-test");
+ }
}
crates/cli/git-ents/src/credentials.rs
@@ -1,0 +1,263 @@
+//! Per-member BYOK credentials (`roots.config-isolation`): read from the
+//! hosted composition root's own deployment config, injected into a
+//! sandbox's environment at launch ([`crate::agent_worker::run_agent_exec`],
+//! [`crate::plan_worker::run_agent_plan`]) — never repository data
+//! (`effect.deployment-property`), never written to any tree this crate
+//! builds.
+//!
+//! # Shape
+//!
+//! A member's credential is a secret value plus the environment variable
+//! name a sandboxed agent command expects it under (an Anthropic API key or
+//! subscription token, e.g. `ANTHROPIC_API_KEY`) — every member may use
+//! their own credential and their own variable name, since BYOK means each
+//! member's runs are billed and rate-limited against their own account.
+//!
+//! # Where it comes from
+//!
+//! [`CredentialStore::from_env`] mirrors the two deployment-config
+//! conventions this crate already has for the hosted root: an env var
+//! naming a *path* the process reads once at startup
+//! ([`crate::sign::resolve_key_path`]'s own `user.signingkey`-or-default
+//! shape for the worker's signing key), and an env var carrying a
+//! deployment secret directly ([`ents_effect::sprite::SPRITES_TOKEN_VAR`],
+//! read once by [`ents_effect::sprite::ensure_auth`]). Since one member's
+//! credential is itself secret material like the Sprite token, and there
+//! may be many members, this store takes the file-path shape:
+//! [`CREDENTIALS_FILE_VAR`] names a file the deployment operator provisions
+//! (one line per member), read once when a [`crate::root::HostedRoot`]
+//! opens and handed down by reference from there — never re-read per run,
+//! never read by any code but this composition root
+//! (`roots.config-isolation`: "Configuration MUST select trait
+//! implementations only at the composition root and MUST NOT leak past
+//! it").
+
+use std::collections::HashMap;
+use std::path::Path;
+
+use ents_model::MemberId;
+
+use crate::error::{Error, Result};
+
+/// The env var naming the credentials file's path on the hosted deployment.
+/// Unset means "no member has a credential" — the common case for a
+/// repository with no agent sessions configured at all, and for every
+/// non-hosted root ([`crate::root::LocalRoot`] never runs `agent-exec`/
+/// `agent-plan` today, so it never constructs a [`CredentialStore`]).
+pub const CREDENTIALS_FILE_VAR: &str = "GIT_ENTS_CREDENTIALS_FILE";
+
+/// One member's injected credential: the secret value, and the environment
+/// variable name a sandbox's launched command expects it under.
+#[derive(Debug, Clone)]
+pub struct Credential {
+ /// The environment variable name to inject the secret as, e.g.
+ /// `ANTHROPIC_API_KEY`.
+ pub var: String,
+ /// The secret value itself — a member's own Anthropic API key or
+ /// subscription token (BYOK).
+ pub secret: String,
+}
+
+/// Per-member credentials, keyed by [`MemberId`] — deployment state
+/// (`effect.deployment-property`), constructed only at a composition root
+/// ([`crate::root::HostedRoot::open`]) and handed by reference into
+/// [`crate::agent_worker::run_agent_exec`]/[`crate::plan_worker::run_agent_plan`],
+/// never persisted to any git object.
+#[derive(Debug, Clone, Default)]
+pub struct CredentialStore {
+ by_member: HashMap<MemberId, Credential>,
+}
+
+impl CredentialStore {
+ /// An empty store: no member has a credential configured.
+ #[must_use]
+ pub fn empty() -> Self {
+ Self::default()
+ }
+
+ /// Build a store directly from `(member, credential)` pairs — the shape
+ /// a test fixture uses; production code reaches a [`CredentialStore`]
+ /// only through [`Self::from_env`].
+ #[must_use]
+ pub fn from_pairs(entries: impl IntoIterator<Item = (MemberId, Credential)>) -> Self {
+ Self {
+ by_member: entries.into_iter().collect(),
+ }
+ }
+
+ /// Load from [`CREDENTIALS_FILE_VAR`]'s named file, or [`Self::empty`]
+ /// if that env var is unset.
+ ///
+ /// # Errors
+ ///
+ /// See [`Self::load`].
+ pub fn from_env() -> Result<Self> {
+ match std::env::var_os(CREDENTIALS_FILE_VAR) {
+ Some(path) => Self::load(Path::new(&path)),
+ None => Ok(Self::empty()),
+ }
+ }
+
+ /// Parse the store from `path`'s contents: one
+ /// `<member-id>\t<var-name>\t<secret>` line per member, blank lines and
+ /// `#`-prefixed lines ignored.
+ ///
+ /// # Errors
+ ///
+ /// [`Error::Io`] if `path` cannot be read; [`Error::InvalidArgument`] for
+ /// a line that is not exactly three tab-separated fields, or whose
+ /// var-name field is not a POSIX environment variable name
+ /// (`[A-Za-z_][A-Za-z0-9_]*`) — the name is folded verbatim into the
+ /// `export` statement [`ents_effect::executor::inject_env`] builds for
+ /// shell-script backends, so anything else would corrupt the sandbox
+ /// script (only the secret *value* is quote-escaped there, on the
+ /// grounds that the operator-authored name, unlike the member-supplied
+ /// secret, has no reason to ever contain metacharacters).
+ pub fn load(path: &Path) -> Result<Self> {
+ let text = std::fs::read_to_string(path).map_err(|source| Error::Io {
+ path: path.to_owned(),
+ source,
+ })?;
+ let mut by_member = HashMap::new();
+ for (number, line) in text.lines().enumerate() {
+ let line = line.trim();
+ if line.is_empty() || line.starts_with('#') {
+ continue;
+ }
+ let mut fields = line.splitn(3, '\t');
+ let (Some(member), Some(var), Some(secret)) =
+ (fields.next(), fields.next(), fields.next())
+ else {
+ return Err(Error::InvalidArgument(format!(
+ "{}:{}: malformed credential line (expected \
+ <member-id>\\t<var-name>\\t<secret>)",
+ path.display(),
+ number.saturating_add(1)
+ )));
+ };
+ if !is_env_var_name(var) {
+ return Err(Error::InvalidArgument(format!(
+ "{}:{}: {var:?} is not a valid environment variable name \
+ ([A-Za-z_][A-Za-z0-9_]*)",
+ path.display(),
+ number.saturating_add(1)
+ )));
+ }
+ by_member.insert(
+ MemberId::new(member),
+ Credential {
+ var: var.to_owned(),
+ secret: secret.to_owned(),
+ },
+ );
+ }
+ Ok(Self { by_member })
+ }
+
+ /// `member`'s configured credential, if the deployment has one.
+ #[must_use]
+ pub fn get(&self, member: &MemberId) -> Option<&Credential> {
+ self.by_member.get(member)
+ }
+}
+
+/// Whether `name` is a POSIX environment variable name:
+/// `[A-Za-z_][A-Za-z0-9_]*` (see [`CredentialStore::load`]'s own doc for
+/// why anything else is refused at parse time).
+fn is_env_var_name(name: &str) -> bool {
+ let mut chars = name.chars();
+ chars
+ .next()
+ .is_some_and(|first| first.is_ascii_alphabetic() || first == '_')
+ && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::expect_used, reason = "unit test")]
+
+ use rstest::rstest;
+
+ use super::*;
+
+ #[rstest]
+ // @relation(roots.config-isolation, scope=function, role=Verifies)
+ fn empty_store_has_no_credential_for_anyone() {
+ let store = CredentialStore::empty();
+ assert!(store.get(&MemberId::new("jdc")).is_none());
+ }
+
+ #[rstest]
+ // @relation(roots.config-isolation, scope=function, role=Verifies)
+ fn from_pairs_looks_up_by_member() {
+ let store = CredentialStore::from_pairs([(
+ MemberId::new("jdc"),
+ Credential {
+ var: "ANTHROPIC_API_KEY".to_owned(),
+ secret: "sk-ant-abc".to_owned(),
+ },
+ )]);
+ let credential = store.get(&MemberId::new("jdc")).expect("configured");
+ assert_eq!(credential.var, "ANTHROPIC_API_KEY");
+ assert_eq!(credential.secret, "sk-ant-abc");
+ assert!(store.get(&MemberId::new("someone-else")).is_none());
+ }
+
+ #[rstest]
+ // @relation(roots.config-isolation, scope=function, role=Verifies)
+ fn load_parses_one_credential_per_line_and_skips_comments_and_blanks() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let path = dir.path().join("credentials");
+ std::fs::write(
+ &path,
+ "# deployment credentials\n\njdc\tANTHROPIC_API_KEY\tsk-ant-abc\n\nmallory\tANTHROPIC_API_KEY\tsk-ant-xyz\n",
+ )
+ .expect("write");
+
+ let store = CredentialStore::load(&path).expect("parses");
+ assert_eq!(
+ store.get(&MemberId::new("jdc")).expect("configured").secret,
+ "sk-ant-abc"
+ );
+ assert_eq!(
+ store
+ .get(&MemberId::new("mallory"))
+ .expect("configured")
+ .secret,
+ "sk-ant-xyz"
+ );
+ }
+
+ #[rstest]
+ // @relation(roots.config-isolation, scope=function, role=Verifies)
+ fn load_rejects_a_malformed_line() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let path = dir.path().join("credentials");
+ std::fs::write(&path, "jdc\tANTHROPIC_API_KEY\n").expect("write");
+
+ let error = CredentialStore::load(&path).expect_err("missing the secret field");
+ assert!(matches!(error, Error::InvalidArgument(_)));
+ }
+
+ #[rstest]
+ #[case::shell_metacharacters("KEY; rm -rf /")]
+ #[case::leading_digit("1KEY")]
+ #[case::empty("")]
+ // @relation(roots.config-isolation, scope=function, role=Verifies)
+ fn load_rejects_a_var_name_that_is_not_a_posix_env_name(#[case] var: &str) {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let path = dir.path().join("credentials");
+ std::fs::write(&path, format!("jdc\t{var}\tsk-ant-abc\n")).expect("write");
+
+ let error = CredentialStore::load(&path).expect_err("the var name is folded into shell");
+ assert!(matches!(error, Error::InvalidArgument(_)));
+ }
+
+ // `from_env`'s own body is a two-arm match over `CREDENTIALS_FILE_VAR`
+ // delegating straight to `load` (covered above) or `empty` (covered
+ // above); it is deliberately not exercised here via
+ // `std::env::set_var`/`remove_var`, since mutating process-global env
+ // state is unsound to race against other tests in the same process
+ // (`std::env`'s own safety docs) — the two branches it can take are
+ // both already proven correct on their own.
+}
crates/cli/git-ents/tests/agent_credentials.rs
@@ -1,0 +1,539 @@
+//! Phase 6's redaction audit (`docs/agent-sessions-plan.adoc`): a per-member
+//! BYOK credential (`roots.config-isolation`) is injected into a sandbox's
+//! environment at launch (`SandboxInputs::env`) and must never be written
+//! to repository data — every persisted artifact of a completed session
+//! must be free of it.
+//!
+//! Two tests, deliberately asymmetric, spell out exactly what this system
+//! guarantees and what it does not:
+//!
+//! - [`a_well_behaved_command_never_leaks_its_injected_credential`] proves
+//! the actual guarantee: the worker-side machinery
+//! (`git_ents::plan_worker::run_agent_plan`,
+//! `git_ents::agent_worker::run_agent_exec`) never itself writes the
+//! credential into any persisted artifact — every git object reachable
+//! from the session ref, both effects' own result refs, and the result
+//! branch, plus the on-disk scratch workdir left behind, are all swept
+//! for the sentinel and found clean.
+//! - [`a_malicious_command_can_still_exfiltrate_its_own_env`] documents,
+//! honestly, the boundary that guarantee stops at: a command that
+//! deliberately echoes its own environment into its own log output or a
+//! file inside the workdir it controls gets that byte range faithfully
+//! recorded onto the result branch and the session's transcript — the
+//! system's job is injecting the credential at launch and never touching
+//! repository data with it itself, not sanitizing an adversarial
+//! command's own reported output. This test does not claim the system
+//! prevents that; it demonstrates the boundary so nobody has to take the
+//! first test's guarantee on faith beyond what it actually covers.
+
+#![allow(clippy::expect_used, reason = "integration test")]
+
+mod common;
+
+use std::collections::HashSet;
+use std::sync::Mutex;
+
+use ents_effect::executor::SandboxInputs;
+use ents_effect::run::short_oid;
+use ents_effect::{Executor, RunOutput, RunStatus};
+use ents_forge::agent::Status;
+use ents_model::MemberId;
+use ents_receive::Mode;
+use git_ents::commands::agent;
+use git_ents::credentials::{Credential, CredentialStore};
+use git_ents::root::LocalRoot;
+use git_ents::{agent_worker, plan_worker};
+use gix_hash::ObjectId;
+use gix_object::{Commit, CommitRef, Find, Kind, TreeRef};
+use gix_ref_store::{Expected, RefEdit, RefStore, RefStoreRead as _};
+
+/// The sentinel credential value: distinctive enough that any accidental
+/// match is meaningful, shaped like a real Anthropic API key so this test
+/// exercises the actual string shape a BYOK credential would have.
+const SENTINEL: &str = "sk-ant-SENTINEL-0000000000000000-do-not-persist";
+const VAR: &str = "ANTHROPIC_API_KEY";
+
+/// Write an empty-tree commit and move `refname` to it directly through the
+/// ref store — mirrors `tests/agent.rs`'s own identical helper (this
+/// crate's own convention for a small per-file duplicate rather than a
+/// shared `tests/common` addition for a single-use helper).
+fn advance_branch(
+ refs: &dyn RefStore,
+ objects: &impl gix_object::Write,
+ refname: &str,
+ seconds: i64,
+) -> ObjectId {
+ let empty_tree = objects.write(&gix_object::Tree::empty()).expect("tree");
+ let actor = gix::actor::Signature {
+ name: "test".into(),
+ email: "test@ents.test".into(),
+ time: gix::date::Time { seconds, offset: 0 },
+ };
+ let commit = Commit {
+ tree: empty_tree,
+ parents: Default::default(),
+ author: actor.clone(),
+ committer: actor,
+ encoding: None,
+ message: "advance".into(),
+ extra_headers: Vec::new(),
+ };
+ let mut raw = Vec::new();
+ gix_object::WriteTo::write_to(&commit, &mut raw).expect("serialize");
+ let oid = objects.write_buf(Kind::Commit, &raw).expect("write");
+
+ let name: gix::refs::FullName = refname.try_into().expect("valid refname");
+ refs.transaction(&[RefEdit {
+ name,
+ expected: Expected::Any,
+ new: Some(oid),
+ }])
+ .expect("moves the ref");
+ oid
+}
+
+/// Recursively collect every blob's bytes and every commit's message,
+/// reachable from `start` by following *every* parent (not just the first)
+/// and every tree entry — a full reachability sweep of one git object
+/// graph, not a single-tip spot check.
+fn reachable_text(
+ objects: &impl Find,
+ start: ObjectId,
+ seen: &mut HashSet<ObjectId>,
+ out: &mut Vec<Vec<u8>>,
+) {
+ if !seen.insert(start) {
+ return;
+ }
+ let mut buf = Vec::new();
+ let Some(data) = Find::try_find(objects, &start, &mut buf).expect("object store reads") else {
+ return;
+ };
+ match data.kind {
+ Kind::Commit => {
+ let commit = CommitRef::from_bytes(data.data, start.kind()).expect("commit parses");
+ out.push(commit.message.to_vec());
+ let tree = commit.tree();
+ let parents: Vec<ObjectId> = commit.parents().collect();
+ drop(commit);
+ reachable_text(objects, tree, seen, out);
+ for parent in parents {
+ reachable_text(objects, parent, seen, out);
+ }
+ }
+ Kind::Tree => {
+ let tree = TreeRef::from_bytes(data.data, start.kind()).expect("tree parses");
+ let children: Vec<ObjectId> = tree
+ .entries
+ .iter()
+ .map(|entry| entry.oid.to_owned())
+ .collect();
+ drop(tree);
+ for child in children {
+ reachable_text(objects, child, seen, out);
+ }
+ }
+ Kind::Blob => out.push(data.data.to_vec()),
+ Kind::Tag => {}
+ }
+}
+
+/// Whether `bytes` contains the sentinel credential, as a raw byte
+/// substring (not a UTF-8 string comparison) so this catches the sentinel
+/// regardless of what else surrounds it in a blob.
+fn contains_sentinel(bytes: &[u8]) -> bool {
+ let needle = SENTINEL.as_bytes();
+ bytes.windows(needle.len()).any(|window| window == needle)
+}
+
+/// Assert that every object reachable from `tip` (including `tip` itself)
+/// is free of the sentinel — `label` names what ref this tip came from, for
+/// a legible failure.
+fn assert_no_sentinel(objects: &impl Find, tip: ObjectId, label: &str) {
+ let mut seen = HashSet::new();
+ let mut blobs = Vec::new();
+ reachable_text(objects, tip, &mut seen, &mut blobs);
+ for blob in &blobs {
+ assert!(
+ !contains_sentinel(blob),
+ "{label}: the sentinel credential leaked into a persisted git object reachable from {tip}"
+ );
+ }
+}
+
+/// A stub `Executor` for both `agent-plan` and `agent-exec`: records every
+/// `env` pair it was launched with (so the test can confirm the credential
+/// really was injected), and either behaves like an ordinary well-behaved
+/// agent command (never echoes its own env anywhere) or, when `leak` is
+/// set, deliberately writes its own env into a file in the workdir and
+/// into its own log output — standing in for a buggy or malicious command,
+/// never for the ordinary case.
+struct RecordingExecutor {
+ seen_env: Mutex<Vec<(String, String)>>,
+ leak: bool,
+ /// When set, write `plan_worker::AGENT_PLAN_DRAFT_FILE` so this stub
+ /// also satisfies `run_agent_plan`'s own contract.
+ drafts: bool,
+}
+
+impl Executor for RecordingExecutor {
+ #[expect(
+ clippy::unwrap_in_result,
+ reason = "test fixture: a poisoned mutex or a workdir write failure here is a broken \
+ test, not a condition under test"
+ )]
+ fn run(&self, inputs: &SandboxInputs<'_>) -> ents_effect::Result<RunOutput> {
+ self.seen_env
+ .lock()
+ .expect("uncontended in this test")
+ .extend(inputs.env.iter().cloned());
+ if self.drafts {
+ std::fs::write(
+ inputs.workdir.join(plan_worker::AGENT_PLAN_DRAFT_FILE),
+ "1. reproduce\n2. fix\n3. verify",
+ )
+ .expect("write draft");
+ }
+ if self.leak {
+ // Deliberately misbehaves: writes its own env into a file it
+ // controls, and echoes it into its own reported output.
+ let logged = inputs
+ .env
+ .iter()
+ .map(|(k, v)| format!("{k}={v}"))
+ .collect::<Vec<_>>()
+ .join("\n");
+ std::fs::write(inputs.workdir.join("leaked-by-the-command.txt"), &logged)
+ .expect("write");
+ return Ok(RunOutput {
+ status: RunStatus::Pass,
+ log: logged,
+ });
+ }
+ Ok(RunOutput {
+ status: RunStatus::Pass,
+ log: "ordinary agent output, no secrets here".to_owned(),
+ })
+ }
+}
+
+/// Fixture: a fresh session, drafted and confirmed via a non-leaking
+/// `RecordingExecutor`, with a `CredentialStore` configured for the
+/// session's own member under [`SENTINEL`] — everything both tests share
+/// before diverging on the `agent-exec` executor's own behavior.
+struct Drafted {
+ // Kept alive for this fixture's whole lifetime: `root` only borrows this
+ // repository's path, it does not own the `TempDir` -- dropping `fixture`
+ // early would delete the on-disk repository out from under `root`.
+ _fixture: common::Fixture,
+ root: LocalRoot,
+ id: String,
+ credentials: CredentialStore,
+ worker_author: gix::actor::Signature,
+ signer: git_ents::sign::Signer,
+ scratch: tempfile::TempDir,
+ /// The dequeued `planning` oid the headless draft ran against --
+ /// `agent-plan`'s own results ref is keyed by this, not by `queued_tip`.
+ planning_tip: ObjectId,
+ queued_tip: ObjectId,
+}
+
+fn draft_and_confirm(seed: u8) -> Drafted {
+ let fixture = common::Fixture::new(seed);
+ let root = LocalRoot::open(fixture.path()).expect("opens");
+ advance_branch(&root.refs, &root.objects, "refs/heads/main", 100);
+
+ let id = agent::new(
+ &root,
+ "fix the flaky test".to_owned(),
+ "claude-sonnet-5".to_owned(),
+ vec![],
+ "refs/heads/main".to_owned(),
+ "manual".to_owned(),
+ None,
+ Some(fixture.key_path.clone()),
+ )
+ .expect("starts a session");
+ let member = agent::show(&root, &id).expect("shows").meta.member;
+
+ let credentials = CredentialStore::from_pairs([(
+ member,
+ Credential {
+ var: VAR.to_owned(),
+ secret: SENTINEL.to_owned(),
+ },
+ )]);
+
+ let session_ref = ents_model::namespace::agent_session_ref(&id).expect("valid");
+ let planning_tip = root
+ .refs
+ .get(session_ref.as_ref())
+ .expect("readable")
+ .expect("exists");
+ let worker_author = gix::actor::Signature {
+ name: "worker".into(),
+ email: "worker@ents.test".into(),
+ time: gix::date::Time {
+ seconds: 200,
+ offset: 0,
+ },
+ };
+ let signer = git_ents::sign::Signer::load(&fixture.key_path).expect("loads");
+ let scratch = tempfile::tempdir().expect("tempdir");
+
+ // Drafting is itself a credentialed run: a non-leaking executor here
+ // keeps this fixture's own drafting step out of the redaction sweep's
+ // way (its own credential handling is proven independently below).
+ let draft_executor = RecordingExecutor {
+ seen_env: Mutex::new(Vec::new()),
+ leak: false,
+ drafts: true,
+ };
+ let plan_run = plan_worker::run_agent_plan(
+ &root.refs,
+ &root.objects,
+ &root.events,
+ &draft_executor,
+ scratch.path(),
+ &[],
+ "true",
+ planning_tip,
+ &worker_author,
+ &|payload| signer.sign(payload),
+ root.mode(),
+ &credentials,
+ )
+ .expect("drafts");
+ assert!(matches!(
+ plan_run,
+ plan_worker::AgentPlanOutcome::Drafted { .. }
+ ));
+ assert!(
+ draft_executor
+ .seen_env
+ .lock()
+ .expect("uncontended in this test")
+ .iter()
+ .any(|(k, v)| k == VAR && v == SENTINEL),
+ "drafting must receive the injected credential too"
+ );
+
+ agent::confirm(&root, &id, None, Some(fixture.key_path.clone())).expect("confirms");
+ let queued_ref = ents_model::namespace::agent_session_ref(&id).expect("valid");
+ let queued_tip = root
+ .refs
+ .get(queued_ref.as_ref())
+ .expect("readable")
+ .expect("exists");
+
+ Drafted {
+ _fixture: fixture,
+ root,
+ id,
+ credentials,
+ worker_author,
+ signer,
+ scratch,
+ planning_tip,
+ queued_tip,
+ }
+}
+
+/// The system's actual guarantee: worker-side machinery never itself writes
+/// the injected BYOK credential into any persisted artifact. Sweeps the
+/// session ref, both effects' own result refs, the result branch, and the
+/// on-disk scratch remnants after the run.
+// @relation(roots.config-isolation, effect.deployment-property, scope=function, role=Verifies)
+#[test]
+fn a_well_behaved_command_never_leaks_its_injected_credential() {
+ let d = draft_and_confirm(60);
+
+ let executor = RecordingExecutor {
+ seen_env: Mutex::new(Vec::new()),
+ leak: false,
+ drafts: false,
+ };
+ let run = agent_worker::run_agent_exec(
+ &d.root.refs,
+ &d.root.objects,
+ &d.root.events,
+ &executor,
+ d.scratch.path(),
+ &[],
+ "true",
+ d.queued_tip,
+ MemberId::new("worker"),
+ "sprite-1".to_owned(),
+ &d.worker_author,
+ &|payload| d.signer.sign(payload),
+ Mode::Advisory,
+ &d.credentials,
+ )
+ .expect("claims and runs");
+ assert!(matches!(
+ run,
+ agent_worker::AgentRunOutcome::Finished { .. }
+ ));
+ assert!(
+ executor
+ .seen_env
+ .lock()
+ .expect("uncontended in this test")
+ .iter()
+ .any(|(k, v)| k == VAR && v == SENTINEL),
+ "the agent-exec run must actually receive the injected credential -- otherwise this \
+ test would trivially pass by never exercising the seam at all"
+ );
+
+ let session = agent::show(&d.root, &d.id).expect("shows");
+ assert_eq!(session.meta.status, Status::Done);
+
+ // (a) the session ref: meta + plan + confirm + thread, all in the
+ // tip's own tree (this design's typed-entity trees are rewritten whole
+ // each commit, so the tip already carries the full thread history) --
+ // plus every ancestor commit back to genesis, for a full sweep.
+ let session_ref = ents_model::namespace::agent_session_ref(&d.id).expect("valid");
+ let session_tip = d
+ .root
+ .refs
+ .get(session_ref.as_ref())
+ .expect("readable")
+ .expect("exists");
+ assert_no_sentinel(&d.root.objects, session_tip, "the session ref");
+
+ // (b) the agent-exec effect's own result ref.
+ let exec_results_ref =
+ ents_model::namespace::result_ref(agent_worker::AGENT_EXEC_NAME, &short_oid(d.queued_tip))
+ .expect("valid");
+ let exec_results_tip = d
+ .root
+ .refs
+ .get(exec_results_ref.as_ref())
+ .expect("readable")
+ .expect("exists");
+ assert_no_sentinel(
+ &d.root.objects,
+ exec_results_tip,
+ "the agent-exec result ref",
+ );
+
+ // (c) the agent-plan effect's own result ref, from the drafting step.
+ let plan_results_ref =
+ ents_model::namespace::result_ref(plan_worker::AGENT_PLAN_NAME, &short_oid(d.planning_tip))
+ .expect("valid");
+ let plan_results_tip = d
+ .root
+ .refs
+ .get(plan_results_ref.as_ref())
+ .expect("readable")
+ .expect("the drafting step recorded a result");
+ assert_no_sentinel(
+ &d.root.objects,
+ plan_results_tip,
+ "the agent-plan result ref",
+ );
+
+ // (d) the result branch: the full tree contents the sandbox's own
+ // output was captured into.
+ let branch_name = session
+ .meta
+ .result_branch
+ .clone()
+ .expect("a result branch was recorded");
+ let branch_ref: gix::refs::FullName = format!("refs/heads/{branch_name}")
+ .try_into()
+ .expect("valid");
+ let branch_tip = d
+ .root
+ .refs
+ .get(branch_ref.as_ref())
+ .expect("readable")
+ .expect("exists");
+ assert_no_sentinel(&d.root.objects, branch_tip, "the result branch");
+
+ // (e) the on-disk scratch workdir remnants: `run_agent_exec` cleans its
+ // own workdir up after capturing the output tree, so nothing should be
+ // left under `scratch/<oid>` at all -- belt and suspenders alongside
+ // the git-object sweep above.
+ assert!(
+ !d.scratch.path().join(d.queued_tip.to_string()).exists(),
+ "the run's own scratch workdir must not survive the run"
+ );
+}
+
+/// Documents the honest boundary the guarantee above stops at: a command
+/// that deliberately echoes its own environment into its own reported
+/// output, or into a file inside the workdir it controls, gets that byte
+/// range faithfully recorded onto the result branch and the session's own
+/// transcript. This is not a bug the system fails to prevent -- the
+/// transcript *is* the command's own stdout/stderr, and the result branch
+/// *is* whatever the command left in its workdir; scrubbing either would
+/// mean not recording what actually ran. The system's actual guarantee
+/// (proven above) is narrower and does not cover this: it never injects the
+/// credential into repository data itself, but it cannot stop an
+/// adversarial command from exfiltrating its own environment through its
+/// own reported output.
+// @relation(roots.config-isolation, effect.deployment-property, scope=function, role=Verifies)
+#[test]
+fn a_malicious_command_can_still_exfiltrate_its_own_env() {
+ let d = draft_and_confirm(61);
+
+ let executor = RecordingExecutor {
+ seen_env: Mutex::new(Vec::new()),
+ leak: true,
+ drafts: false,
+ };
+ let run = agent_worker::run_agent_exec(
+ &d.root.refs,
+ &d.root.objects,
+ &d.root.events,
+ &executor,
+ d.scratch.path(),
+ &[],
+ "true",
+ d.queued_tip,
+ MemberId::new("worker"),
+ "sprite-1".to_owned(),
+ &d.worker_author,
+ &|payload| d.signer.sign(payload),
+ Mode::Advisory,
+ &d.credentials,
+ )
+ .expect("claims and runs");
+ assert!(matches!(
+ run,
+ agent_worker::AgentRunOutcome::Finished { .. }
+ ));
+
+ let session = agent::show(&d.root, &d.id).expect("shows");
+ assert!(
+ session.thread.iter().any(|blob| contains_sentinel(blob)),
+ "the leaking command's own echoed log line lands in the transcript verbatim -- the \
+ transcript is the command's own reported output, which this system faithfully records"
+ );
+
+ let branch_name = session
+ .meta
+ .result_branch
+ .clone()
+ .expect("a result branch was recorded");
+ let branch_ref: gix::refs::FullName = format!("refs/heads/{branch_name}")
+ .try_into()
+ .expect("valid");
+ let branch_tip = d
+ .root
+ .refs
+ .get(branch_ref.as_ref())
+ .expect("readable")
+ .expect("exists");
+ let mut seen = HashSet::new();
+ let mut blobs = Vec::new();
+ reachable_text(&d.root.objects, branch_tip, &mut seen, &mut blobs);
+ assert!(
+ blobs.iter().any(|blob| contains_sentinel(blob)),
+ "the leaking command's own written file shows up on the result branch -- that file is \
+ the command's own workdir output, which this system faithfully captures; it is not \
+ something the worker-side machinery could have scrubbed without also discarding \
+ genuine run output"
+ );
+}