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
23 changes: 21 additions & 2 deletions docs/CLOUD.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,22 @@ await runInCloud({ path: './onboard.flow.ts' }, {
});
```

Each step must grant a delivered secret by name. An omitted `secrets` option
grants none:

```ts
await f.run('node call-native.mjs', { secrets: ['NATIVE_API_TOKEN'] });
await f.agent('review', { task: 'Review the result', secrets: [] });
```

The same `secrets: ['NATIVE_API_TOKEN']` declaration is accepted on a YAML
step. Unknown or unavailable names refuse the run. A direct agent step starts
with a small system environment, its own model login (including delivered
login and Cloud-selected Claude Bedrock login), and trusted repository git/gh setup,
plus only its declared other user-delivered secrets; a Relay-transport agent cannot request run
secrets. On a self-hosted run, set `RELAY_SECRET_ENV_NAMES` to a JSON array of
the secret names before `flows run` so deterministic steps can scope them too.

They travel only with that run's submission: not in the flow source, its
inputs, the receipt, or a schedule (`scheduleInCloud` cannot carry them), and
errors never quote them: a Cloud refusal that contains a value anywhere (its
Expand All @@ -55,9 +71,12 @@ refuses names it owns (run identity, Relay/Relayfile auth, provider keys such as
step rows, transcripts) by name, regardless of what the variable is called,
once AgentWorkforce/cloud#4469 is deployed; before that, only names containing
TOKEN, SECRET, KEY, AUTH or similar are masked, so prefer a name like
`NATIVE_API_TOKEN`. Steps can still read the values, so keep the token
`NATIVE_API_TOKEN`. A same-user step can still inspect the CLI/bootstrap
environment through `/proc/<pid>/environ` on Linux or read agent login files
under `HOME`; per-step env filtering is not process isolation. Keep tokens
short-lived and narrowly scoped. A run that parks on `f.human` and is resumed
does not get them back.
does not get them back. UID separation for step processes is tracked in
AgentWorkforce/cloud#4715 and needs SOC 2 / penetration-test review.

```sh
flows run --cloud examples/cloud-gates/cloud-gates.flow.yaml
Expand Down
27 changes: 26 additions & 1 deletion docs/SURFACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1002,11 +1002,36 @@ that environment, plus `FLOWS_INPUT` and `RELAYFLOW_MEMORY`. Two runs on one
daemon each see their own variables. `flows resume` sends the resuming CLI's
environment, which replaces the run's from then on.

When `RELAY_SECRET_ENV_NAMES` lists delivered secrets, each deterministic
step gets only the names in its `secrets` declaration. For example,
`f.run('node call-api.mjs', { secrets: ['NATIVE_API_TOKEN'] })` receives that
value, while the next `f.run` without a grant does not. The same `secrets`
option is available on `f.agent` and `f.llm` and in declarative steps. Agent
CLIs start from an allowlist of system variables and their selected CLI login.
Their own model login stays available even when delivered as a run secret;
delivered AWS/Bedrock names need Cloud's selected-login marker or a grant.
Generic cloud credentials such as `GOOGLE_API_KEY` and
`GOOGLE_APPLICATION_CREDENTIALS` also need a grant; Gemini's own
`GEMINI_API_KEY` remains login. Other user-delivered names require an explicit grant. Cloud-managed repository
git/gh setup also stays available. A misspelled or unavailable
name refuses the step. Without a secret-name manifest, self-hosted deterministic steps retain
the legacy run environment; set the manifest when supplying secrets.

The environment is held in daemon memory only. It is never written to the
journal, run records, step rows or traces. If the daemon restarts before the
run finishes, the run's steps fall back to the daemon's own environment until a
`flows resume` supplies one again. A CLI older than this feature sends no
environment, and its steps get the daemon's too. `kernel/DAEMON-LIFECYCLE.md`
environment, and its steps get the daemon's too. A step that declares a
secret grant refuses a daemon without the `step_secret_scoping` capability.
Until that daemon upgrade, a plain agent/LLM step keeps its legacy environment
so an SDK upgrade alone does not change a hosted run's existing behavior.
Upgrading the daemon changes plain agent/LLM steps to the clean environment
even when no step declares a grant. With a delivered-name manifest, it also
removes ungranted secrets from `f.run` shell children. Grant any needed
delivered secrets by name. A same-user step can still inspect another
process's `/proc/<pid>/environ` on Linux or read CLI login files under
`HOME`; environment filtering is not process isolation.
`kernel/DAEMON-LIFECYCLE.md`
§3 has the protocol.

A step with no placed workspace still runs in the daemon's working directory,
Expand Down
18 changes: 18 additions & 0 deletions kernel/relayflowd-core/src/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,13 @@ impl RunSpec {
if !ids.insert(step.id.clone()) {
return Err(SpecError::DuplicateStep(step.id.clone()));
}
if step.secrets.iter().any(|name| {
name == "RELAY_SECRET_ENV_NAMES"
|| !name.as_bytes().first().is_some_and(|c| c.is_ascii_alphabetic() || *c == b'_')
|| !name.as_bytes().iter().all(|c| c.is_ascii_alphanumeric() || *c == b'_')
}) || step.secrets.iter().collect::<BTreeSet<_>>().len() != step.secrets.len() {
return Err(SpecError::Malformed(format!("step {}: secrets must be distinct environment variable names", step.id)));
}
if step.max_iterations == 0 {
return Err(SpecError::ZeroIterations(step.id.clone()));
}
Expand All @@ -163,6 +170,13 @@ impl RunSpec {
"step {}: timeout_ms must be positive and fit in i64", step.id
)));
}
if let StepKind::Agent { transport: Some(AgentTransport::Relay), .. } = &step.kind
&& !step.secrets.is_empty()
{
return Err(SpecError::Malformed(format!(
"step {}: relay transport cannot deliver per-step secrets", step.id
)));
}
let cli = match &step.kind {
StepKind::Llm { cli, .. } | StepKind::Agent { cli, .. } => cli,
StepKind::Deterministic { .. } => &None,
Expand Down Expand Up @@ -371,6 +385,7 @@ pub fn external_surface_contains(declared: &str, target: &str) -> bool {
const STEP_COMMON_FIELDS: &[&str] = &[
"id",
"type",
"secrets",
"depends_on",
"input",
"max_iterations",
Expand Down Expand Up @@ -458,6 +473,9 @@ fn reject_unknown_step_fields(value: &Value) -> Result<(), SpecError> {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct StepSpec {
pub id: String,
/// Names only. Values stay in the run's in-memory step environment.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub secrets: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input: Option<crate::input::InputBindings>,
#[serde(default)]
Expand Down
20 changes: 20 additions & 0 deletions kernel/relayflowd-core/src/spec/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,26 @@ fn zero_agent_flow_is_valid() {
assert!(spec.validate().is_ok());
}

#[test]
fn secret_grants_are_names_only_and_relay_transport_cannot_receive_them() {
let spec = RunSpec::parse(&json!({"steps": [{
"id": "shell", "type": "deterministic", "command": "true", "secrets": ["NATIVE_API_TOKEN"]
}]})).unwrap();
assert!(spec.validate().is_ok());
assert_eq!(spec.steps[0].secrets, vec!["NATIVE_API_TOKEN"]);
for names in [json!(["BAD=NAME"]), json!(["NATIVE_API_TOKEN", "NATIVE_API_TOKEN"])] {
let invalid = RunSpec::parse(&json!({"steps": [{
"id": "shell", "type": "deterministic", "command": "true", "secrets": names
}]})).unwrap();
assert!(invalid.validate().is_err());
}
let relay = RunSpec::parse(&json!({"steps": [{
"id": "agent", "type": "agent", "instruction": "review", "transport": "relay",
"secrets": ["NATIVE_API_TOKEN"]
}]})).unwrap();
assert!(relay.validate().is_err());
}

#[test]
fn cycles_are_rejected() {
let spec = RunSpec::parse(&json!({
Expand Down
79 changes: 78 additions & 1 deletion kernel/relayflowd/src/exec_det.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,38 @@ pub(crate) fn execute_placed_with_input(
if let Some(workspace) = workspace {
process.current_dir(workspace);
}
if let Some(env) = env {
let step_env = match env {
Some(env) => match env.for_step(&step.secrets) {
Ok(selected) => Some(selected),
Err(error) => return worker_error(&error),
},
None if !step.secrets.is_empty() => {
return worker_error("step secrets are unavailable without a run environment");
}
None if std::env::var_os("RELAY_SECRET_ENV_NAMES").is_some() => {
let inherited = std::env::vars_os()
.map(|(name, value)| {
let name = name
.into_string()
.map_err(|_| "run environment contains a non-Unicode name".to_owned())?;
let value = value.into_string().map_err(|_| {
format!("run environment variable {name:?} contains a non-Unicode value")
})?;
Ok((name, value))
})
.collect::<Result<std::collections::BTreeMap<_, _>, String>>();
let inherited = match inherited {
Ok(vars) => crate::step_env::StepEnv::from(vars),
Err(error) => return worker_error(&error),
};
match inherited.for_step(&[]) {
Ok(selected) => Some(selected),
Err(error) => return worker_error(&error),
}
}
None => None,
};
if let Some(env) = &step_env {
process.env_clear();
process.envs(env.vars());
}
Expand Down Expand Up @@ -229,9 +260,55 @@ fn worker_error(detail: &str) -> AttemptResult {
#[cfg(test)]
mod tests {
use serde_json::json;
use std::collections::BTreeMap;

use super::*;

#[test]
fn a_shell_step_sees_only_its_granted_delivered_secret() {
let env = crate::step_env::StepEnv::from(BTreeMap::from([
("PATH".into(), "/bin:/usr/bin".into()),
("NATIVE_API_TOKEN".into(), "native-secret".into()),
("OTHER_TOKEN".into(), "other-secret".into()),
(
"RELAY_SECRET_ENV_NAMES".into(),
"[\"NATIVE_API_TOKEN\",\"OTHER_TOKEN\"]".into(),
),
]));
for (grant, expected) in [
(None, "unset:unset"),
(Some("NATIVE_API_TOKEN"), "native-secret:unset"),
] {
let mut value = json!({"id": "shell", "type": "deterministic",
"command": "printf '%s:%s' \"${NATIVE_API_TOKEN-unset}\" \"${OTHER_TOKEN-unset}\""});
if let Some(name) = grant {
value["secrets"] = json!([name]);
}
let step: StepSpec = serde_json::from_value(value).unwrap();
let result = execute_placed_with_input(&step, None, None, None, Some(&env), None);
assert_eq!(result.output["stdout_tail"], expected);
}
}

#[test]
fn a_shell_step_accepts_a_manifest_after_withheld_names_are_removed() {
let env = crate::step_env::StepEnv::from(BTreeMap::from([
("PATH".into(), "/bin:/usr/bin".into()),
("VISIBLE_SECRET".into(), "visible-value".into()),
(
"RELAY_SECRET_ENV_NAMES".into(),
"[\"VISIBLE_SECRET\"]".into(),
),
]));
let step: StepSpec = serde_json::from_value(json!({
"id": "plain", "type": "deterministic", "command": "true"
}))
.unwrap();
let result = execute_placed_with_input(&step, None, None, None, Some(&env), None);
assert_eq!(result.output["exit_code"], 0);
assert_eq!(result.failure_reason, None);
}

#[test]
fn captures_deterministic_output() {
let step: StepSpec = serde_json::from_value(json!({
Expand Down
2 changes: 1 addition & 1 deletion kernel/relayflowd/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ fn handle_request(
let mut hello = json!({
"protocol": PROTOCOL_VERSION,
"server": "relayflowd",
"features": ["reported_cost", "step_env"],
"features": ["reported_cost", "step_env", "step_secret_scoping"],
});
// Names only, so a current SDK omits their values from the env it
// sends; the strip at spawn is what enforces it.
Expand Down
48 changes: 47 additions & 1 deletion kernel/relayflowd/src/step_env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
//! journaled, registered, or traced. A daemon restart therefore forgets them;
//! the next `run.resume` that carries `env` supplies them again.

use std::{collections::BTreeMap, fmt};
use std::{collections::{BTreeMap, BTreeSet}, fmt};

use serde::Deserialize;

Expand All @@ -37,6 +37,33 @@ impl StepEnv {
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
}

/// Select only this step's declared delivered secrets. Other ordinary
/// variables remain available. The manifest and values never reach a
/// deterministic child, and malformed metadata refuses the spawn.
pub fn for_step(&self, grants: &[String]) -> Result<Self, String> {
const MANIFEST: &str = "RELAY_SECRET_ENV_NAMES";
let delivered: BTreeSet<String> = match self.0.get(MANIFEST) {
Some(raw) => {
let names: Vec<String> = serde_json::from_str(raw)
.map_err(|_| "secret name manifest is invalid".to_owned())?;
let set: BTreeSet<String> = names.iter().cloned().collect();
if set.len() != names.len() || names.iter().any(|name| name == MANIFEST || !self.0.contains_key(name)) {
Comment thread
miyaontherelay marked this conversation as resolved.
return Err("secret name manifest contains duplicate, reserved or unavailable names".into());
Comment thread
miyaontherelay marked this conversation as resolved.
}
set
}
None => BTreeSet::new(),
};
for name in grants {
if !self.0.contains_key(name) || (self.0.contains_key(MANIFEST) && !delivered.contains(name)) {
return Err(format!("step secret {name:?} is not available to this run"));
}
}
Ok(Self(self.0.iter()
.filter(|(name, _)| name.as_str() != MANIFEST && (!delivered.contains(*name) || grants.contains(name)))
.map(|(name, value)| (name.clone(), value.clone())).collect()))
}
}

impl From<BTreeMap<String, String>> for StepEnv {
Expand Down Expand Up @@ -78,4 +105,23 @@ mod tests {
let error = env(&[("K", "s3cret\0value")]).validate().unwrap_err();
assert!(!error.contains("s3cret"), "{error}");
}

#[test]
fn only_granted_delivered_secrets_enter_each_step() {
let source = env(&[
("PATH", "/bin"),
("NATIVE_API_TOKEN", "native-secret"),
("OTHER_TOKEN", "other-secret"),
("RELAY_SECRET_ENV_NAMES", "[\"NATIVE_API_TOKEN\",\"OTHER_TOKEN\"]"),
]);
let none = source.for_step(&[]).unwrap();
assert_eq!(none.vars().collect::<Vec<_>>(), vec![("PATH", "/bin")]);
let granted = source.for_step(&["NATIVE_API_TOKEN".to_owned()]).unwrap();
assert_eq!(granted.vars().collect::<Vec<_>>(), vec![("NATIVE_API_TOKEN", "native-secret"), ("PATH", "/bin")]);
let error = source.for_step(&["MISSING".to_owned()]).unwrap_err();
assert!(!error.contains("native-secret"));
let error = source.for_step(&["PATH".to_owned()]).unwrap_err();
assert!(error.contains("PATH"));
assert!(env(&[("RELAY_SECRET_ENV_NAMES", "{invalid")]).for_step(&[]).is_err());
}
}
Loading
Loading