~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

Owners give their runners secrets with knotless secret

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:run/owner-secrets
.changeset/owner-secrets.mdnew+7−0
@@ -0,0 +1,7 @@
---
default: minor
---
# Owners give their runners secrets
`knotless secret <url> set <name> [--ref <pattern>]...` gives a repository's runners a secret, read from stdin, sealed to the key each runner publishes so only it can read it, and left at the appview. `knotless secret <url> ls` lists them, never a value, and `rm` takes one back. The MCP server has a `secret` tool to match. A runner reads them for each run whose workflow lists a name its secrets file does not grant. `knotless runners <url> remove` deletes the secrets sealed to that runner, and `add` says which secrets must be set again to reach it.
.changeset/sealed-secrets-appview.mdnew+7−0
@@ -0,0 +1,7 @@
---
default: minor
---
# The appview keeps secrets sealed to a runner
knotless-serve answers `dev.knotless.putSecret`, `deleteSecret` and `listSecrets`: an owner leaves a repository's runners secrets, each sealed to one runner's key, and the appview holds only ciphertext. knotless's login asks for these methods, so a session from before asks again at its next login.
crates/knotless/src/cli.rs+44−0
@@ -129,6 +129,13 @@ const USAGE: &str = "usage: knotless <command>
rules <url> the protected branches
runners <url> [add|remove <handle>] the accounts whose checks of the
repository readers show
secret <url> set <name> [--ref <pattern>]...
give the runners a secret, read from
stdin and sealed to each, so only it
reads it; --ref limits it to pushes
to those refs
secret <url> ls | rm <name> the secrets given, never their values,
or take one back
agents <url> [add|remove <handle>] the accounts, such as a coding agent,
whose pulls run as they move, as code
no one vouches for
@@ -1269,6 +1276,43 @@ async fn command(
Some(other) => bail!("unexpected {other}"),
}
}
"secret" => {
let url = args.url()?;
match args.rest.next().map(String::as_str) {
Some("set") => {
let name = args.next("<name>")?;
let mut refs = Vec::new();
while let Some(flag) = args.rest.next() {
match flag.as_str() {
"--ref" => refs.push(args.next("<pattern>")?.to_string()),
other => bail!("unexpected {other}"),
}
}
let mut value = Vec::new();
stdin.read_to_end(&mut value)?;
let value = crate::secrets::trim_newline(&value);
let runners = crate::secrets::set(ctx, &url, name, &refs, value).await?;
writeln!(out, "sealed {name} to {}", runners.join(", "))?;
}
Some("ls" | "list") => {
for s in crate::secrets::of(ctx, &url).await? {
let refs = if s.refs.is_empty() {
"*".to_string()
} else {
s.refs.join(",")
};
writeln!(out, "{}\t{}\t{refs}\t{}", s.name, s.runner, s.updated_at)?;
}
}
Some("rm") => {
let name = args.next("<name>")?;
crate::secrets::remove(ctx, &url, name).await?;
writeln!(out, "removed {name}")?;
}
Some(other) => bail!("unexpected {other}"),
None => bail!("secret takes set, ls or rm"),
}
}
"agents" => {
let url = args.url()?;
match args.rest.next().map(String::as_str) {
crates/knotless/src/invites.rs+1−1
@@ -23,7 +23,7 @@ pub const OUTPUT: &str = "dev.knotless.putRunOutput";
pub const WRITES: &str = "dev.knotless.awaitSpaceWrites";
/// The appview, and the DID its service tokens are for.
async fn appview(ctx: &Ctx) -> Result<(Xrpc, String)> {
pub(crate) async fn appview(ctx: &Ctx) -> Result<(Xrpc, String)> {
let url = ctx
.env
.appview
crates/knotless/src/lib.rs+1−0
@@ -54,6 +54,7 @@ pub mod remote;
pub mod revert;
pub mod reviews;
pub mod sealed;
pub mod secrets;
pub mod session;
pub mod social;
pub mod space;
crates/knotless/src/manage.rs+12−2
@@ -666,10 +666,20 @@ pub async fn set_runner(ctx: &Ctx, url: &RepoUrl, who: &str, add: bool) -> Resul
&auth.auth(),
)
.await?;
// Those sealed to a runner leaving go with it; one joining has none
// of those set before.
let key = &record.key;
let said = match add {
true => crate::secrets::unsealed_for(ctx, &auth, remote.did(), key, &who.did).await,
false => {
crate::secrets::forget(ctx, &auth, key, &who.did).await;
None
}
};
if remote.space.is_none() || !add {
return Ok(None);
return Ok(said);
}
Ok(invite(ctx, &auth, &[who.did], &repo.rkey).await)
Ok(invite(ctx, &auth, &[who.did], &repo.rkey).await.or(said))
}
/// Asks the repository's runners, as its owner, to run the ref `refname`,
crates/knotless/src/oauth.rs+3−0
@@ -88,6 +88,9 @@ pub static SCOPE: LazyLock<String> = LazyLock::new(|| {
crate::invites::LIST,
crate::invites::OUTPUT,
crate::invites::WRITES,
crate::secrets::PUT,
crate::secrets::DELETE,
crate::secrets::LIST,
crate::notifications::LIST,
crate::notifications::SEEN,
crate::notifications::GET_PREFERENCES,
crates/knotless/src/secrets.rsnew+281−0
@@ -0,0 +1,281 @@
//! Secrets an owner gives a repository's runners, each sealed to a runner's
//! key ([`crate::sealed`]) and left at the appview, which holds only
//! ciphertext. The owner puts, lists and deletes them; a runner lists those
//! sealed to it, to open. Each signs for itself with a service token.
use crate::auth;
use crate::crypto::PublicKey;
use crate::env::Ctx;
use crate::invites::appview;
use crate::records::{RepoRecord, RerunVar};
use crate::remote::Remote;
use crate::sealed::{self, Bound};
use crate::session::{self, Session};
use crate::url::RepoUrl;
use crate::xrpc::Auth;
use anyhow::{Context, Result, bail, ensure};
use base64::Engine;
use base64::engine::general_purpose::STANDARD_NO_PAD;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
pub const PUT: &str = "dev.knotless.putSecret";
pub const DELETE: &str = "dev.knotless.deleteSecret";
pub const LIST: &str = "dev.knotless.listSecrets";
/// The longest a secret's value is, in bytes, before it is sealed.
pub const VALUE_MAX: usize = 16 * 1024;
/// The most refs one is limited to.
pub const REFS_MAX: usize = 16;
/// A secret left with one runner, as the appview lists it.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Secret {
pub name: String,
pub runner: String,
/// The refs whose pushes it reaches, none for every push.
#[serde(default, with = "patterns")]
pub refs: Vec<String>,
/// The runner's key it is sealed to, a `did:key`.
pub key: String,
/// The sealed value, listed to the runner alone.
#[serde(default, with = "bytes", skip_serializing_if = "Option::is_none")]
pub sealed: Option<Vec<u8>>,
pub updated_at: String,
}
/// Refs as the lexicons hold them: `[{"pattern": "refs/heads/main"}]`.
pub mod patterns {
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Serialize, Deserialize)]
struct Pattern {
pattern: String,
}
pub fn serialize<S: Serializer>(refs: &[String], s: S) -> Result<S::Ok, S::Error> {
let all = refs.iter().map(|p| Pattern { pattern: p.clone() });
s.collect_seq(all)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<String>, D::Error> {
let all = Vec::<Pattern>::deserialize(d)?;
Ok(all.into_iter().map(|p| p.pattern).collect())
}
}
/// Bytes as atproto's JSON writes them: `{"$bytes": base64}`.
pub mod bytes {
use super::*;
use serde::{Deserializer, Serializer};
#[derive(Serialize, Deserialize)]
struct Lex {
#[serde(rename = "$bytes")]
bytes: String,
}
pub fn serialize<S: Serializer>(b: &Option<Vec<u8>>, s: S) -> Result<S::Ok, S::Error> {
let b = b.as_deref().unwrap_or_default();
Lex {
bytes: STANDARD_NO_PAD.encode(b),
}
.serialize(s)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Vec<u8>>, D::Error> {
let lex = Option::<Lex>::deserialize(d)?;
let decoded = lex.map(|l| STANDARD_NO_PAD.decode(l.bytes.trim_end_matches('=')));
decoded.transpose().map_err(serde::de::Error::custom)
}
}
/// Calls the procedure `nsid` as `me`.
async fn call(ctx: &Ctx, me: &Session, nsid: &str, body: &Value) -> Result<Value> {
let (xrpc, did) = appview(ctx).await?;
let token = auth::service_token(ctx, me, &did, nsid).await?;
let auth = Auth::Bearer(token);
xrpc.procedure(nsid, Some(body), Some(&auth)).await
}
/// Leaves `secret`, sealed, for `me`'s repository `repo`, by its key.
pub async fn put(ctx: &Ctx, me: &Session, repo: &str, secret: &Secret) -> Result<()> {
let mut body = serde_json::to_value(secret)?;
body["repo"] = repo.into();
let fields = ["repo", "runner", "name", "refs", "key", "sealed"];
body.as_object_mut()
.expect("a secret is an object")
.retain(|k, _| fields.contains(&k.as_str()));
call(ctx, me, PUT, &body).await.map(drop)
}
/// Deletes those of `me`'s repository `repo` of `name`, sealed to
/// `runner`, or both; with neither, all of them. Says how many it deleted.
pub async fn delete(
ctx: &Ctx,
me: &Session,
repo: &str,
name: Option<&str>,
runner: Option<&str>,
) -> Result<u64> {
let mut body = json!({ "repo": repo });
let only = [("name", name), ("runner", runner)];
for (field, value) in only.into_iter().filter_map(|(f, v)| Some((f, v?))) {
body[field] = value.into();
}
let done = call(ctx, me, DELETE, &body).await?;
Ok(done["deleted"].as_u64().unwrap_or_default())
}
/// Those left for `owner`'s repository `repo` that `me` may see: every
/// one, without its value, to its owner; those sealed to it, to a runner.
pub async fn list(ctx: &Ctx, me: &Session, owner: &str, repo: &str) -> Result<Vec<Secret>> {
#[derive(Deserialize)]
struct Page {
secrets: Vec<Secret>,
}
let (xrpc, did) = appview(ctx).await?;
let token = auth::service_token(ctx, me, &did, LIST).await?;
let params = [("owner", owner), ("repo", repo)];
let page: Page = xrpc
.query(LIST, &params, Some(&Auth::Bearer(token)))
.await?;
Ok(page.secrets)
}
/// `value` without the one line ending a shell's `echo` or a file ends it
/// with, which a token never means to hold.
pub fn trim_newline(value: &[u8]) -> &[u8] {
let value = value.strip_suffix(b"\n").unwrap_or(value);
value.strip_suffix(b"\r").unwrap_or(value)
}
/// The key `runner` publishes for owners to seal its secrets to, as its
/// `dev.knotless.runnerKey` record holds it.
pub async fn runner_key(ctx: &Ctx, runner: &str) -> Result<PublicKey> {
let pds = crate::identity::resolve_pds(ctx, runner).await?;
let xrpc = crate::xrpc::Xrpc::new(ctx.http.clone(), &pds);
let params = [
("repo", runner),
("collection", sealed::KEY_NSID),
("rkey", sealed::KEY_RKEY),
];
let record = xrpc.query::<Value>("com.atproto.repo.getRecord", &params, None);
let record = record.await.with_context(|| {
format!("{runner} publishes no key to seal secrets to: it runs a knotless-run from before they were sealed")
})?;
let key = record["value"]["key"].as_str().unwrap_or_default();
PublicKey::from_did_key(key).with_context(|| format!("{runner}'s key is not a did:key"))
}
/// The repository `url` names, as its owner: its remote, session, and
/// record.
async fn owned(ctx: &Ctx, url: &RepoUrl) -> Result<(Remote, Session, RepoRecord)> {
ensure!(
ctx.env.appview.is_some(),
"secrets are left at an appview, and KNOTLESS_APPVIEW is set empty"
);
let remote = Remote::open(ctx, url).await?;
let me = session::ensure(ctx, &remote.identity).await?;
let Some(repo) = remote.repo().await? else {
bail!("{url}: no such repository");
};
Ok((remote, me, repo.value))
}
/// Seals `value` as the secret `name`, limited to `refs` if any, to each
/// runner the repository at `url` names, and leaves it at the appview.
/// Gives the runners it was sealed to.
pub async fn set(
ctx: &Ctx,
url: &RepoUrl,
name: &str,
refs: &[String],
value: &[u8],
) -> Result<Vec<String>> {
ensure!(
RerunVar::valid_name(name),
"a secret's name is a shell variable's, not one starting KNOTLESS_RUN, not {name:?}"
);
ensure!(
refs.len() <= REFS_MAX,
"a secret is limited to at most {REFS_MAX} refs"
);
ensure!(
value.len() <= VALUE_MAX,
"a secret's value is at most {VALUE_MAX} bytes"
);
let (remote, me, repo) = owned(ctx, url).await?;
let runners: Vec<String> = repo.runners.into_iter().map(|r| r.did).collect();
ensure!(
!runners.is_empty(),
"{url} names no runners to give it to: add one with `knotless runners {url} add <handle>`"
);
for runner in &runners {
let key = runner_key(ctx, runner).await?;
let bound = Bound {
owner: remote.did(),
repo: &repo.key,
runner,
name,
refs,
};
let secret = Secret {
name: name.into(),
runner: runner.clone(),
refs: refs.to_vec(),
key: key.to_did_key(),
sealed: Some(sealed::seal(&key, &bound, value)?),
updated_at: String::new(),
};
put(ctx, &me, &repo.key, &secret).await?;
}
Ok(runners)
}
/// The secrets left for the repository at `url`, as its owner sees them.
pub async fn of(ctx: &Ctx, url: &RepoUrl) -> Result<Vec<Secret>> {
let (remote, me, repo) = owned(ctx, url).await?;
list(ctx, &me, remote.did(), &repo.key).await
}
/// Deletes the secret `name` of the repository at `url`, for every
/// runner. Says how many runners' copies went.
pub async fn remove(ctx: &Ctx, url: &RepoUrl, name: &str) -> Result<u64> {
let (_, me, repo) = owned(ctx, url).await?;
let gone = delete(ctx, &me, &repo.key, Some(name), None).await?;
ensure!(gone > 0, "{url} has no secret {name}");
Ok(gone)
}
/// What a runner's leaving means for the secrets of the repository with
/// the key `repo`: those sealed to it are deleted. As far as it goes: one
/// left behind, when the appview does not answer, reaches no run, as the
/// runner runs a repository only while it names it.
pub async fn forget(ctx: &Ctx, me: &Session, repo: &str, runner: &str) {
// With no appview, none were left at one.
if ctx.env.appview.is_some() {
let _ = delete(ctx, me, repo, None, Some(runner)).await;
}
}
/// What a runner's joining means for the secrets of `owner`'s repository
/// with the key `repo`: none set before is sealed to it, as nothing keeps
/// their values to seal again. A warning naming them, if there are any.
pub async fn unsealed_for(
ctx: &Ctx,
me: &Session,
owner: &str,
repo: &str,
runner: &str,
) -> Option<String> {
let all = list(ctx, me, owner, repo).await.unwrap_or_default();
let mut names: Vec<&str> = all.iter().map(|s| s.name.as_str()).collect();
names.dedup();
let names = names.join(", ");
let set_again =
format!("{runner} gets the secrets set before only once each is set again: {names}");
(!names.is_empty()).then_some(set_again)
}
crates/knotless-mcp/src/lib.rs+41−0
@@ -296,6 +296,22 @@ pub struct Runners {
pub remove: Option<String>,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct Secrets {
/// `handle/name`, `did/name` or `knotless://handle/name`.
pub repo: String,
/// As its owner, give the runners the secret of this name, a shell
/// variable's, as a workflow's `secrets` lists it, with `value`.
pub set: Option<String>,
/// The secret's value, with `set`.
pub value: Option<String>,
/// With `set`, the refs whose pushes it reaches, as a trigger's `refs`
/// match (`refs/heads/main`, `refs/tags/v*`); every push if none.
pub refs: Option<Vec<String>>,
/// As its owner, take back the secret of this name.
pub remove: Option<String>,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct Agents {
/// `handle/name`, `did/name` or `knotless://handle/name`.
@@ -2723,6 +2739,31 @@ impl Server {
)
}
#[tool(
description = "The secrets a repository's owner gives its runners, as its workflows' `secrets` list them: each sealed to every runner the repository names, so only those runners read it, and the appview that holds it holds only ciphertext. Its owner sets one, by name with its value, limited to some refs' pushes if given, replacing one of the same name, or removes one. A value given here passes through this conversation: for a token that should not, the owner runs `knotless secret <url> set <name>` with the value on stdin. A runner added later gets one only once it is set again. Lists them after any change, by name, runner and refs, never a value."
)]
async fn secret(&self, Parameters(p): Parameters<Secrets>) -> Result<CallToolResult, McpError> {
answer(
async {
let url = repo_url(&p.repo)?;
if let Some(name) = &p.set {
let value = p
.value
.as_deref()
.ok_or_else(|| anyhow::anyhow!("set takes a value"))?;
let refs = p.refs.clone().unwrap_or_default();
knotless::secrets::set(&self.ctx, &url, name, &refs, value.as_bytes()).await?;
}
if let Some(name) = &p.remove {
knotless::secrets::remove(&self.ctx, &url, name).await?;
}
let listed = knotless::secrets::of(&self.ctx, &url).await?;
Ok(serde_json::to_value(listed)?)
}
.await,
)
}
#[tool(
description = "The agents a repository names: accounts, such as a coding agent, whose pulls its runners check as they move, as they do a collaborator's, but as code no one vouches for: no secrets, caches apart, nothing kept, even if the agent is a collaborator too. Its owner adds or removes one, by handle. Lists them after any change."
)]
crates/knotless-mcp/tests/mcp.rs+24−0
@@ -939,6 +939,30 @@ fn agents_fork_review_and_merge() {
let (o, c) = (Client::new(&owner), Client::new(&contrib));
let repo = c_repo(&o.json("create_repo", json!({"name": "reviewed"})));
// Its owner gives its runner a secret, sealed to the key it publishes,
// and lists it, never its value, then takes it back.
let dir = tempfile::tempdir().unwrap();
let keys = knotless::sealed::RunnerKeys::open_or_make(&dir.path().join("k.json")).unwrap();
let nsid = knotless::sealed::KEY_NSID;
let key = json!({"$type": nsid, "key": keys.public().to_did_key(), "createdAt": "2026-10-29T00:00:00.000Z"});
contrib.put_record(nsid, knotless::sealed::KEY_RKEY, key);
o.json(
"runners",
json!({"repo": repo, "add": contrib.account.handle}),
);
let (text, err) = o.call("secret", json!({"repo": repo, "set": "TOKEN"}));
assert!(err && text.contains("set takes a value"), "{text}");
let set = json!({"repo": repo, "set": "TOKEN", "value": "v4lue", "refs": ["refs/heads/main"]});
let listed = o.json("secret", set);
assert_eq!(listed[0]["name"], "TOKEN", "{listed}");
assert_eq!(listed[0]["refs"], json!([{"pattern": "refs/heads/main"}]));
assert!(listed[0].get("sealed").is_none(), "{listed}");
let gone = o.json("secret", json!({"repo": repo, "remove": "TOKEN"}));
assert_eq!(gone, json!([]));
o.json(
"runners",
json!({"repo": repo, "remove": contrib.account.handle}),
);
o.json("commit", json!({"repo": repo, "branch": "main", "message": "start", "changes": [{"path": "a", "content": "a\n"}]}));
let fork = c_repo(&c.json("fork", json!({"repo": repo})));
assert_eq!(fork, format!("{}/reviewed", contrib.account.handle));
crates/knotless-run/src/lib.rs+51−6
crates/knotless-run/tests/run.rs+26−2
crates/knotless-serve/src/index.rs+67−0
crates/knotless-serve/src/lib.rs+4−0
crates/knotless-serve/src/secrets.rsnew+128−0
crates/knotless-serve/tests/forge.rs+225−0
docs/design/access.md+1−1
docs/design/runner.md+18−4
docs/design/security.md+9−0
docs/guide/workflows.md+14−1
docs/reference/api.md+15−0
docs/reference/cli.md+3−1
docs/reference/mcp.md+1−0
docs/reference/workflows.md+7−1
lexicons/dev/knotless/deleteSecret.jsonnew+32−0
lexicons/dev/knotless/listSecrets.jsonnew+49−0
lexicons/dev/knotless/putSecret.jsonnew+29−0