Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ jobs:
with: { packages-dir: dist, verbose: true, skip-existing: true }
brew:
# Formula from the release digests, attached to the release and handed to `publish`, which
# pushes it to the tap (krishhgg/homebrew-tokenstash — must be public) only once the
# pushes it to the tap (krishhgg/homebrew-tokenstash, which must be public) only once the
# release is live. Draft assets 404 for everyone but us, so a formula pushed from here would
# point `brew install` at files nobody can download whenever a later job failed. Never for
# a pre-release tag.
Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ __pycache__/
.codex/
.agents/

# internal, local-only planning docs — never publish
# internal, local-only planning docs. Never publish them.
/tokenstash.md
/PRD.md

Expand Down
16 changes: 8 additions & 8 deletions crates/cli/src/cmd/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ pub fn trust(a: TrustArgs) -> Result<i32> {
const NOTICE: &str = "trust roots are retired: the first time a directory asks for stored keys you approve exactly which ones (one card), and they are silent there afterwards. See `tokenstash workspaces`.";
match a.cmd.unwrap_or(TrustCmd::List) {
TrustCmd::Add { .. } => {
println!("nothing to add — {NOTICE}");
println!("nothing to add because {NOTICE}");
}
TrustCmd::Rm { path } => {
let p = path.canonicalize().unwrap_or(path);
Expand Down Expand Up @@ -249,7 +249,7 @@ pub fn workspaces(a: WorkspacesArgs) -> Result<i32> {
WorkspacesCmd::List => {
let all = app.db.list_workspaces()?;
if all.is_empty() {
println!("no paired directories yet — the first stored key a directory asks for pairs it (one card)");
println!("no paired directories yet. The first stored key a directory asks for pairs it (one card)");
return Ok(0);
}
for w in &all {
Expand All @@ -265,7 +265,7 @@ pub fn workspaces(a: WorkspacesArgs) -> Result<i32> {
let Some(w) = app.db.find_workspace(&path)? else { bail!("{} is not a paired directory", path.display()) };
let n = app.db.revoke_workspace(&w.id)?;
app.db.audit(Some(&w.root), None, "workspace.revoke", None, None, Some(&format!("{n} grants")))?;
println!("✓ revoked {n} grant(s) for {} — values already in its env file stay there; its next request asks again", tokenstash_core::project::short(std::path::Path::new(&w.root)));
println!("✓ revoked {n} grant(s) for {}. Values already in its env file stay there; its next request asks again", tokenstash_core::project::short(std::path::Path::new(&w.root)));
}
WorkspacesCmd::Forget { path } => {
let Some(w) = app.db.find_workspace(&path)? else { bail!("{} is not a paired directory", path.display()) };
Expand Down Expand Up @@ -378,7 +378,7 @@ pub fn rotate(a: RotateArgs) -> Result<i32> {
let t = tokenstash_core::tasks::rotate(&app.ctx(), &project, &agent, &a.name, &identity)?;
let links = util::Links::new(&app.cfg, crate::notify::ensure_inbox(&app.cfg));
crate::notify::desktop(&app.cfg, &format!("Replace {}", a.name), "you asked to rotate it", &util::inbox_notice(&app.cfg, Some(&t.id), &links));
println!("⏳ {}@{identity} marked for rotation — task {} → {}", a.name, t.id, util::inbox_url_tty(&app.cfg, Some(&app.db), Some(&t.id), &links, util::Stream::Stdout));
println!("⏳ {}@{identity} marked for rotation, task {} → {}", a.name, t.id, util::inbox_url_tty(&app.cfg, Some(&app.db), Some(&t.id), &links, util::Stream::Stdout));
println!(" paste the NEW key first; revoke the old one in the dashboard after it says stored");
Ok(tokenstash_core::exit::PENDING)
}
Expand All @@ -402,14 +402,14 @@ pub struct ReportBadArgs {
/// counts from a project that received the key, and letting the caller name one would let a
/// hostile repo borrow another project's standing. Always prints the same line whatever
/// happened: the agent learns the outcome from its next `need` (card vs inject), never from
/// here — otherwise this is a stash-existence oracle.
/// here. Otherwise this is a stash-existence oracle.
pub fn report_bad(a: ReportBadArgs) -> Result<i32> {
let app = App::open()?;
let project = tokenstash_core::project::current();
let agent = util::agent_from(&None);
let identity = resolve_identity(&app, &project, &a.name, &a.identity)?;
let _ = tokenstash_core::tasks::report_bad(&app.ctx(), &project, &agent, &a.name, &identity, a.status)?;
println!("ok — run `tokenstash need {}` again; if the key is dead the user will be asked for a replacement", a.name);
println!("ok, run `tokenstash need {}` again; if the key is dead the user will be asked for a replacement", a.name);
Ok(0)
}

Expand Down Expand Up @@ -461,7 +461,7 @@ pub fn sweep(app: &App, names: &[String], stale_only: bool, print: bool) -> Resu
sweep_where(app, Probe::Network, &|m| (names.is_empty() || names.contains(&m.name)) && (!stale_only || m.stale), print)
}

/// The sweep over exactly the (name, identity) pairs given — what `import` and
/// The sweep over exactly the (name, identity) pairs given, which are what `import` and
/// `--from-env` touched, and nothing else of the same name.
pub fn sweep_pairs(app: &App, pairs: &[(String, String)], print: bool) -> Result<Vec<(String, String, String, bool)>> {
sweep_where(app, Probe::Network, &|m| pairs.iter().any(|(n, i)| n == &m.name && i == &m.identity), print)
Expand Down Expand Up @@ -516,7 +516,7 @@ fn sweep_where(app: &App, probe: Probe, select: &dyn Fn(&tokenstash_core::db::Se
println!("{:<36} {:<10} RESULT", "NAME", "IDENTITY");
for (n, i, st, _) in &rows { println!("{n:<36} {i:<10} {st}"); }
let stale = rows.iter().filter(|r| r.3).count();
if stale > 0 { println!("\n{stale} stale — the next `tokenstash need` for each asks for a replacement (or run `tokenstash rotate NAME`)"); }
if stale > 0 { println!("\n{stale} stale. The next `tokenstash need` for each asks for a replacement (or run `tokenstash rotate NAME`)"); }
}
Ok(rows)
}
Expand Down
6 changes: 3 additions & 3 deletions crates/cli/src/cmd/answer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ pub fn answer(a: AnswerArgs) -> Result<i32> {
AnswerResult::Stored { injected_to, sensitive, liveness, rotation } => {
if let Some(r) = &rotation {
for p in &r.rewritten { println!(" also updated → {}", tokenstash_core::project::short(std::path::Path::new(p))); }
for (p, why) in &r.skipped { println!(" ! still holds the OLD value: {} — {why}", tokenstash_core::project::short(std::path::Path::new(p))); }
for (p, why) in &r.skipped { println!(" ! still holds the OLD value: {}, {why}", tokenstash_core::project::short(std::path::Path::new(p))); }
if !r.skipped.is_empty() { println!(" fix those before revoking the old key"); }
}
println!("✓ {name} stored in the {} stash", app.stash.backend());
Expand All @@ -98,7 +98,7 @@ pub fn answer(a: AnswerArgs) -> Result<i32> {
TaskKind::Approval => {
// Approving is the human's decision and nothing else in the product. Without this
// an agent with a shell reads the card id out of `tokenstash tasks --json` and
// runs `answer <id> --allow-broad` to grant itself the human's keys — the exact
// runs `answer <id> --allow-broad` to grant itself the human's keys, the exact
// thing the inbox's paste-scope token exists to prevent. Denying stays open: it
// can only close the agent's own request, never open one.
util::require_human("answer --allow", "approving a card is your decision, not an agent's")?;
Expand All @@ -116,7 +116,7 @@ pub fn answer(a: AnswerArgs) -> Result<i32> {
match tasks::answer_approval(&ctx, &task, decision, Some(&task.names))? {
AnswerResult::Approved { injected, replaced } => {
println!("✓ approved; injected {}", if injected.is_empty() { "nothing new".into() } else { injected.join(", ") });
if !replaced.is_empty() { println!(" {} rejected by the provider at delivery — a Replace card is waiting", replaced.join(", ")); }
if !replaced.is_empty() { println!(" {} rejected by the provider at delivery. A Replace card is waiting", replaced.join(", ")); }
}
AnswerResult::Denied => println!("✗ denied"),
_ => unreachable!(),
Expand Down
30 changes: 15 additions & 15 deletions crates/cli/src/cmd/bundle.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//! `export` / `import`: move a stash between machines as one passphrase-encrypted file.
//! Human-only, interactive: the passphrase is prompted (never a flag, never an env var — both
//! Human-only, interactive: the passphrase is prompted (never a flag, never an env var, because both
//! land in `ps`, shell history, crash reports), and the export is confirmed twice so a typo
//! cannot brick the only copy.

Expand All @@ -17,7 +17,7 @@ pub struct ExportArgs {
#[arg(short, long)]
pub out: Option<PathBuf>,
/// Instead of writing a bundle: scan a directory tree for env files and import the keys
/// found there into the stash (onboarding). Interactive, for a person at a terminal — the
/// found there into the stash (onboarding). Interactive, for a person at a terminal. The
/// table is an inventory of what you have, and a pty defeats the terminal check.
#[arg(long, value_name = "DIR", conflicts_with = "out")]
pub from_env: Option<PathBuf>,
Expand Down Expand Up @@ -61,7 +61,7 @@ pub fn export(a: ExportArgs) -> Result<i32> {
// `require_human` above already proved stdout is a terminal, so the generated
// passphrase cannot land in a file or a pipe.
let g = bundle::generate_passphrase();
println!("\nGenerated passphrase — write it down now, it is shown once:\n\n {}\n", g.expose_secret());
println!("\nGenerated passphrase. Write it down now, it is shown once:\n\n {}\n", g.expose_secret());
g
} else {
let again = rpassword::prompt_password("again: ")?;
Expand Down Expand Up @@ -94,7 +94,7 @@ fn refuse_bad_destination(p: &Path, env_file: &str) -> Result<PathBuf> {
if tokenstash_core::envfile::is_git_tracked(&root, &resolved) {
bail!("{} is tracked by git; refusing to write a bundle there", resolved.display());
}
eprintln!("note: {} is inside a git repo — make sure the bundle is never committed", resolved.display());
eprintln!("note: {} is inside a git repo, so make sure the bundle is never committed", resolved.display());
}
Ok(resolved)
}
Expand Down Expand Up @@ -153,15 +153,15 @@ pub fn import(a: ImportArgs) -> Result<i32> {
Some(v) if v.expose_secret() == e.value => {
// Same value: nothing to store, but a rotation the user asked for on the
// other machine must not be lost here. Decided now, written in the apply
// step with everything else — planning changes nothing.
// step with everything else. Planning changes nothing.
if e.stale && e.stale_reason.as_deref().unwrap_or("").starts_with(tokenstash_core::db::Db::ROTATE_REASON)
&& !app.db.get_secret(&e.name, &e.identity)?.map(|m| m.stale).unwrap_or(false)
{ Plan::CarryRotation } else { Plan::Skip }
}
Some(_) => {
if a.keep_existing { Plan::Skip } else if a.replace { Plan::Replace } else {
let local = app.db.get_secret(&e.name, &e.identity)?;
print!("{}@{} differs from what this machine has (here: stored {}{}; bundle: stored {}{}) — replace it? [y/N] ",
print!("{}@{} differs from what this machine has (here: stored {}{}; bundle: stored {}{}). Replace it? [y/N] ",
e.name, e.identity,
local.as_ref().map(|m| m.created.clone()).unwrap_or_default(), if local.as_ref().map(|m| m.stale).unwrap_or(false) { ", STALE" } else { "" },
e.created, if e.stale { ", stale" } else { "" });
Expand All @@ -175,7 +175,7 @@ pub fn import(a: ImportArgs) -> Result<i32> {
};
plan.push((p, e));
}
// 3. apply: stash first (the value's home), then the index. No approvals — import is not
// 3. apply: stash first (the value's home), then the index. No approvals, because import is not
// per-project consent. No env-file writes.
let (mut added, mut replaced, mut skipped) = (0, 0, 0);
for (p, e) in &plan {
Expand Down Expand Up @@ -291,7 +291,7 @@ pub struct FromEnvArgs {
}

/// Onboarding: find the keys already scattered across a person's projects and stash the
/// ones they tick. Human-only and interactive by design — the table is an inventory of
/// ones they tick. Human-only and interactive by design, because the table is an inventory of
/// what the person has, and ticking rows is consent. There is no MCP tool for this and
/// no non-interactive switch.
pub fn from_env(a: FromEnvArgs) -> Result<i32> {
Expand All @@ -318,15 +318,15 @@ pub fn from_env(a: FromEnvArgs) -> Result<i32> {
let ambiguous = is_ambiguous(&c.candidates, i);
let (default_on, note) = match (&cand.confidence, &existing) {
(_, Some(v)) if v.expose_secret() == cand.value.expose_secret() => (false, "already in the stash".to_string()),
(_, Some(_)) => (false, "DIFFERS from the stash — you will be asked before it replaces anything".to_string()),
(Confidence::Registry, None) if ambiguous => (false, format!("{} — several different values under this name; pick the real one", cand.provider.clone().unwrap_or_default())),
(_, Some(_)) => (false, "DIFFERS from the stash, and you will be asked before it replaces anything".to_string()),
(Confidence::Registry, None) if ambiguous => (false, format!("{}, several different values under this name; pick the real one", cand.provider.clone().unwrap_or_default())),
(Confidence::Registry, None) => (true, cand.provider.clone().unwrap_or_default()),
(Confidence::RegistryShapeMismatch, None) => (false, format!("{} — does not look like a real key (placeholder?)", cand.provider.clone().unwrap_or_default())),
(Confidence::RegistryShapeMismatch, None) => (false, format!("{}, but it does not look like a real key (placeholder?)", cand.provider.clone().unwrap_or_default())),
(Confidence::Heuristic, None) => (false, "unregistered; looks like a secret".to_string()),
};
ticked.push(default_on);
differs.push(matches!((&cand.confidence, &existing), (_, Some(v)) if v.expose_secret() != cand.value.expose_secret()));
// The row is identified by its number, its name and where it was found — never by any
// The row is identified by its number, its name and where it was found, never by any
// part of the value. A first-and-last-characters preview is still a piece of the key,
// and this table is printed to a terminal an agent may be driving through a pty.
let srcs: Vec<String> = cand.sources.iter().take(3).map(|p| short(p)).collect();
Expand Down Expand Up @@ -372,7 +372,7 @@ pub fn from_env(a: FromEnvArgs) -> Result<i32> {
for &i in &chosen {
let identity = tokenstash_core::envcrawl::identity_among(&c.candidates, i, &a.identity, |j| ticked[j]);
let replaces = app.stash.get(&stash_key(&c.candidates[i].name, &identity))?.map(|v| v.expose_secret() != c.candidates[i].value.expose_secret()).unwrap_or(false);
println!(" {}@{}{}", c.candidates[i].name, identity, if replaces { " (REPLACES the value in the stash — you will be asked)" } else { "" });
println!(" {}@{}{}", c.candidates[i].name, identity, if replaces { " (REPLACES the value in the stash, and you will be asked)" } else { "" });
}
print!("proceed? [y/N] ");
{ use std::io::Write; std::io::stdout().flush()?; }
Expand All @@ -390,7 +390,7 @@ pub fn from_env(a: FromEnvArgs) -> Result<i32> {
bundle::validate_entry(&e).with_context(|| format!("row {} ({}@{})", i + 1, cand.name, identity))?;
if let Some(pat) = tokenstash_core::registry::lookup(&e.name).and_then(|p| p.pattern.as_ref()) {
if !tokenstash_core::validate::matches_pattern(pat, &cand.value)? {
println!(" {}@{}: skipped — the value does not look like a {} key (a paste would be refused too)", e.name, identity, cand.provider.clone().unwrap_or_default());
println!(" {}@{}: skipped because the value does not look like a {} key (a paste would be refused too)", e.name, identity, cand.provider.clone().unwrap_or_default());
continue;
}
}
Expand All @@ -413,7 +413,7 @@ pub fn from_env(a: FromEnvArgs) -> Result<i32> {
// replacing a stash value is a per-key decision, and never a silent one
let local = app.db.get_secret(&cand.name, &identity)?;
if local.as_ref().map(|m| m.stale && m.stale_source.as_deref() == Some(tokenstash_core::db::STALE_ROTATE)).unwrap_or(false) {
println!(" {}@{}: skipped — you asked to rotate this key; paste the NEW one via `tokenstash rotate`, not an old env file", cand.name, identity);
println!(" {}@{}: skipped because you asked to rotate this key; paste the NEW one via `tokenstash rotate`, not an old env file", cand.name, identity);
continue;
}
print!(" {}@{} differs from the stash (stored {}); replace it with the value from {}? [y/N] ", cand.name, identity, local.map(|m| m.created).unwrap_or_default(), short(&cand.sources[0]));
Expand Down
6 changes: 3 additions & 3 deletions crates/cli/src/cmd/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,9 @@ pub fn doctor() -> Result<i32> {

check("registry", true, format!("{} providers", tokenstash_core::registry::count()));
if !cfg.trust_roots.is_empty() {
check("trust roots", true, format!("{} in config, retired in 0.2 — each directory pairs once instead (`tokenstash workspaces`)", cfg.trust_roots.len()));
check("trust roots", true, format!("{} in config, retired in 0.2. Each directory pairs once instead (`tokenstash workspaces`)", cfg.trust_roots.len()));
}
// "Not running" is normal — it starts on demand. Someone else holding the port is not:
// "Not running" is normal because it starts on demand. Someone else holding the port is not:
// that is the case where a human could be sent to paste a key into another process.
let inbox = notify::inbox_state(&cfg);
ok &= check(
Expand Down Expand Up @@ -134,7 +134,7 @@ pub fn doctor() -> Result<i32> {
};
let refused = tokenstash_core::trust::refused_root(&project);
check("this directory", refused.is_none(), format!("{} {}", tokenstash_core::project::short(&project), match (refused, standing) {
(Some(why), _) => format!("is {why} — no keys are delivered here"),
(Some(why), _) => format!("is {why}, so no keys are delivered here"),
(None, Some(n)) => format!("paired, {n} grant(s)"),
(None, None) => "not paired yet → the first stored key asks once".into(),
}));
Expand Down
Loading
Loading