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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,28 @@ is `0`, minor versions may carry breaking changes — they are called out here.

## [Unreleased]

### Security
- **Exec children no longer inherit the runtime's credentials.** `sh -c` children of
the `exec-*` drivers and the agent CLI got `VEJAS_APPROVAL_TOKEN`, `VEJAS_TOKEN`,
`VAULT_TOKEN` and every `VEJAS_SECRET_*`. They now get what the manifest declares
in `ENV`, so a recipe that read one of these by inheritance has to declare it. An
exec connector stays trusted code: this is hygiene, not a sandbox.

### Fixed
- **The panel can write when `VEJAS_TOKEN` is set.** Only Approve and Reject worked
there: every other write got a 401, with no way to give the token. The panel now
asks once and keeps it in the page's memory.
- `http-in` writes its response in one segment instead of one per piece of the
format string.
- `bench/flow-only.sh` and `bench/detect.sh` no longer leave the runtime and NATS
running: under `set -e`, the status of the first killed process ended the trap.

### Changed
- `scripts/deploy-docs.sh` publishes the book from a commit, and `RELEASING.md` gains
the step: the book at vejas.dev/docs had not moved since 2026-08-24. A release now
pushes master first and tags once CI is green. Site and book backups stay outside
the served directory, where a stale page was reachable.

## [0.5.0] — 2026-11-07

The runtime leaves the deprecated sync NATS client. If your NATS user has a publish
Expand Down
15 changes: 14 additions & 1 deletion RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ Versions follow SemVer; the git tag `vX.Y.Z` is the release trigger.
3. Commit: `release: vX.Y.Z`.
4. Tag and push:
```bash
git push origin master # wait for ci and audit, then:
git tag -a vX.Y.Z -m "vX.Y.Z"
git push origin master --follow-tags
git push origin vX.Y.Z
```

Pushing the tag runs `.github/workflows/release.yml`, which:
Expand All @@ -22,6 +23,18 @@ Pushing the tag runs `.github/workflows/release.yml`, which:
`ghcr.io/cpoder/vejas-runtime:vX.Y.Z` and `:latest`;
- creates the GitHub Release with the CHANGELOG section as its notes.

5. Publish what readers see. Nothing does this for you:
```bash
scripts/deploy-docs.sh vX.Y.Z # the book at vejas.dev/docs, built from the tag
scripts/deploy-site.sh # vejas.dev, when site/ changed
```
`pages.yml` carries `site/` to GitHub Pages only. vejas.dev is a static
directory on a box shared with production, so these two stay manual. Before this
step existed the book stayed on 2026-08-24 through nine releases.

Master goes first because a tag publishes at once: a red `ci` on a released commit
can only be fixed forward.

## Version discipline (ADR-0030)

The public HTTP API, `docs/CONTROL.md` and `docs/SUBJECTS.md` are a
Expand Down
4 changes: 2 additions & 2 deletions bench/compare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ bench/compare/run-n8n.sh 20 32 # n8n (official image, tune
| e2e latency p50 | 859 ms | 14 ms sustained / **2 ms** uncongested | 1 ms | 2 350 ms |
| Flow-hop rate (no HTTP) | 171/s | **8 110/s** (9 948/s over 10 flows) | — | — |
| Cold start | 15 ms | **11–13 ms** | 391 ms | 7–17 s (container) |
| RSS under load | **6–8 MB** | **6–8 MB** (49 MB at 50 flows) | 202 MB | 1.2–1.3 GB |
| Distribution size | 3.9 MB binary | **6.2 MB binary / 201 MB image** | 338 MB binary | 372 MB image |
| RSS under load | **6–8 MB** | **10 MB** (38 MB at 50 flows) | 202 MB | 1.2–1.3 GB |
| Distribution size | 3.9 MB binary | **11.2 MB binary / 118 MB image** | 338 MB binary | 372 MB image |
| Persistence | every hop (JetStream) | **every hop (JetStream)** | in-flight only | per-execution DB (disabled for this run) |

**n8n fairness notes.** Single instance from the official image, tuned per
Expand Down
4 changes: 2 additions & 2 deletions bench/detect.sh
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ STORE=$(mktemp -d); ROOT=$(mktemp -d)
cleanup() {
# wait for them, do not merely signal them: a run that leaves its port held
# makes the NEXT run fail silently, which is how a matrix grows holes.
[ -n "${RUNTIME_PID:-}" ] && { kill "$RUNTIME_PID" 2>/dev/null; wait "$RUNTIME_PID" 2>/dev/null; }
[ -n "${NATS_PID:-}" ] && { kill "$NATS_PID" 2>/dev/null; wait "$NATS_PID" 2>/dev/null; }
[ -n "${RUNTIME_PID:-}" ] && { kill "$RUNTIME_PID" 2>/dev/null || true; wait "$RUNTIME_PID" 2>/dev/null || true; }
[ -n "${NATS_PID:-}" ] && { kill "$NATS_PID" 2>/dev/null || true; wait "$NATS_PID" 2>/dev/null || true; }
rm -rf "$STORE" "$ROOT"
}
trap cleanup EXIT
Expand Down
4 changes: 3 additions & 1 deletion bench/flow-only.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ STORE=$(mktemp -d)
ROOT=$(mktemp -d)
cleanup() {
for pid in "${SUB_PID:-}" "${RUNTIME_PID:-}" "${NATS_PID:-}"; do
[ -n "$pid" ] && { kill "$pid" 2>/dev/null; wait "$pid" 2>/dev/null; }
# `|| true`: under set -e the status of a killed process would end the trap
# at the first pid and leave the runtime and NATS running.
[ -n "$pid" ] && { kill "$pid" 2>/dev/null || true; wait "$pid" 2>/dev/null || true; }
done
rm -rf "$STORE" "$ROOT"
}
Expand Down
71 changes: 51 additions & 20 deletions core/src/connectors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,33 @@ fn suffix_allowed(suffix: &str, allow: &[String]) -> bool {
.any(|e| suffix == e || suffix.starts_with(&format!("{e}.")))
}

/// Is this variable a credential of the runtime itself?
fn is_runtime_credential(name: &str) -> bool {
matches!(name, "VEJAS_TOKEN" | "VEJAS_APPROVAL_TOKEN" | "VAULT_TOKEN")
|| name.starts_with("VEJAS_SECRET_")
}

/// Keep the runtime's own credentials out of a child's environment. A child gets
/// what its manifest declares in `ENV`, and an agent CLI what it needs, not the
/// approval token or every secret by inheritance, where an `env` in a log or an
/// agent reading its environment sends them on. This is hygiene and not a
/// sandbox: a child runs as the same user and reaches the same bus.
pub fn without_runtime_credentials(cmd: &mut std::process::Command) {
for (name, _) in std::env::vars_os() {
if name.to_str().is_some_and(is_runtime_credential) {
cmd.env_remove(name);
}
}
}

/// `sh -c <cmd>` for an exec driver.
fn shell(cmd: &str) -> std::process::Command {
let mut child = std::process::Command::new("sh");
child.arg("-c").arg(cmd);
without_runtime_credentials(&mut child);
child
}

/// One place in a bounded pool of per-connection or per-request threads, given
/// back when the thread ends, a panic included.
pub struct Slot(Arc<AtomicUsize>);
Expand Down Expand Up @@ -398,11 +425,13 @@ fn handle_http_in(
}
}
};
let _ = write!(
sock,
// One buffer, one write: with TCP_NODELAY `write!` puts each piece of the
// format string in a segment of its own.
let response = format!(
"HTTP/1.1 {code}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: keep-alive\r\n\r\n{json}",
json.len()
);
let _ = sock.write_all(response.as_bytes());
if eof {
break; // client sent its last request, then closed
}
Expand Down Expand Up @@ -1576,9 +1605,7 @@ impl Driver for ExecSource {
waited += 250;
if waited >= interval * 1000 {
waited = 0;
let out = std::process::Command::new("sh")
.arg("-c")
.arg(&cmd)
let out = shell(&cmd)
.output()
.map_err(|e| e.to_string())?;
if !out.status.success() {
Expand Down Expand Up @@ -1617,9 +1644,7 @@ impl Driver for ExecSink {
let name = ctx.name.clone();
let env = ctx.config.env_vars();
run_sink(ctx, &subject, move |data| {
let mut child = std::process::Command::new("sh")
.arg("-c")
.arg(&cmd)
let mut child = shell(&cmd)
.envs(env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
Expand Down Expand Up @@ -1654,7 +1679,7 @@ impl Driver for ExecStreamSource {
}
fn run(&self, ctx: &Ctx) -> Result<(), String> {
use std::io::{BufRead, BufReader};
use std::process::{Command, Stdio};
use std::process::Stdio;
use std::sync::mpsc::{sync_channel, RecvTimeoutError};

let cmd = ctx.config.str("CMD").ok_or("CMD required")?;
Expand Down Expand Up @@ -1688,9 +1713,7 @@ impl Driver for ExecStreamSource {
eprintln!("[{}] resuming at OFFSET={start}", ctx.name);
run_env.push(("OFFSET".to_string(), start));
}
let mut child = Command::new("sh")
.arg("-c")
.arg(&cmd)
let mut child = shell(&cmd)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::inherit()) // child status/errors flow to our logs
Expand Down Expand Up @@ -2012,7 +2035,7 @@ impl Driver for ExecRpc {
}
fn run(&self, ctx: &Ctx) -> Result<(), String> {
use std::io::{BufRead, BufReader, Write};
use std::process::{Command, Stdio};
use std::process::Stdio;

let cmd = ctx.config.str("CMD").ok_or("CMD required")?;
let subject = ctx
Expand All @@ -2028,9 +2051,7 @@ impl Driver for ExecRpc {
let env = ctx.config.env_vars();
let nc = bus::connect(&ctx.nats_url)?;

let mut child = Command::new("sh")
.arg("-c")
.arg(&cmd)
let mut child = shell(&cmd)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
Expand Down Expand Up @@ -2084,12 +2105,10 @@ impl Driver for ExecRpc {
}
fn probe(&self, ctx: &Ctx) -> Result<String, String> {
use std::io::{BufRead, BufReader, Write};
use std::process::{Command, Stdio};
use std::process::Stdio;
let cmd = ctx.config.str("CMD").ok_or("CMD required")?;
let env = ctx.config.env_vars();
let mut child = Command::new("sh")
.arg("-c")
.arg(&cmd)
let mut child = shell(&cmd)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
Expand Down Expand Up @@ -2240,6 +2259,18 @@ fn next_url(base: &str, body: &serde_json::Value, field: &str) -> Option<String>

#[cfg(test)]
mod tests {
#[test]
fn a_child_inherits_no_runtime_credential() {
std::env::set_var("VEJAS_APPROVAL_TOKEN", "the-approval-token");
std::env::set_var("VEJAS_SECRET_DEMO_KEY", "a-secret");
std::env::set_var("VEJAS_UNRELATED", "kept");
let out = shell("env").env("DECLARED", "from-the-manifest").output().unwrap();
let env = String::from_utf8_lossy(&out.stdout);
assert!(!env.contains("the-approval-token") && !env.contains("a-secret"), "{env}");
assert!(env.contains("VEJAS_UNRELATED=kept"), "{env}");
assert!(env.contains("DECLARED=from-the-manifest"), "{env}");
}

fn letter(pending: &mut HashMap<String, DlqAnnouncement>, unit: &str) {
let a = pending.entry(unit.to_string()).or_default();
a.count += 1;
Expand Down
4 changes: 3 additions & 1 deletion core/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2619,7 +2619,9 @@ const NO_AGENT_CLI: &str = "this deployment has no agent CLI (VEJAS_AGENT_CMD):
/// Ask the agent CLI for a file and return its content (markdown fences stripped).
fn ask_agent(prompt: String) -> Result<String, String> {
let agent = env::var("VEJAS_AGENT_CMD").unwrap_or_else(|_| "claude".into());
let out = Command::new(agent)
let mut command = Command::new(agent);
connectors::without_runtime_credentials(&mut command);
let out = command
.arg("-p")
.arg(prompt)
.output()
Expand Down
28 changes: 18 additions & 10 deletions core/src/panel.html
Original file line number Diff line number Diff line change
Expand Up @@ -275,9 +275,19 @@ <h2>Secrets</h2>
if (s === 'true') return true; if (s === 'false') return false;
if (s !== '' && !isNaN(Number(s))) return Number(s); return s; };

// With VEJAS_TOKEN set every write wants it. The panel asks once and keeps it in
// this page's memory, never in storage: a reload asks again.
let writeToken = '';
const send = (url, payload, headers = {}) => fetch(url, { method: 'POST',
headers: {'Content-Type': 'application/json',
...(writeToken ? {Authorization: `Bearer ${writeToken}`} : {}), ...headers},
body: JSON.stringify(payload) });
async function post(url, payload) {
const res = await fetch(url, { method: 'POST',
headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) });
let res = await send(url, payload);
if (res.status === 401 && !writeToken) {
writeToken = (prompt('Write token (VEJAS_TOKEN)') || '').trim();
if (writeToken) res = await send(url, payload);
}
if (!res.ok) throw new Error(await res.text());
return res;
}
Expand Down Expand Up @@ -905,19 +915,17 @@ <h2>Secrets</h2>
setInterval(loadEvents, 4000);

// In governed mode a replay or a discard is a human act (ADR-0024): the server
// answers 409 or 401 and the approval token goes with the retry.
// answers 409 or 401 and the approval token goes with the retry. Outside it the
// same prompt takes the write token, so the one value is sent as both.
async function dlqAction(path, body) {
const send = tok => fetch(path, { method: 'POST',
headers: { 'Content-Type': 'application/json', ...(tok ? { 'X-Approval-Token': tok } : {}) },
body: JSON.stringify(body) });
try {
let tok = ($('#approval-token').value || '').trim();
let res = await send(tok);
let res = await send(path, body, tok ? {'X-Approval-Token': tok} : {});
if ((res.status === 409 || res.status === 401) && !tok) {
tok = (prompt('Approval token (governed mode)') || '').trim();
tok = (prompt('Approval token in governed mode, otherwise the write token') || '').trim();
if (!tok) return;
$('#approval-token').value = tok;
res = await send(tok);
res = await send(path, body, {'X-Approval-Token': tok, Authorization: `Bearer ${tok}`});
if (res.ok) $('#approval-token').value = tok;
}
if (!res.ok) throw new Error(await res.text());
toast('Done — reloaded');
Expand Down
4 changes: 2 additions & 2 deletions docs/adr/0015-dead-letter-queue.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,8 @@ untrusted content and this event is what wakes things up. One event per unit per
`VEJAS_DLQ_EVENT_SECS` (default 60), with `count` for the letters it stands for,
so that a burst wakes a reader once and loses nothing. A letter whose subject is
the event subject is not announced, so a failing reader cannot feed itself. An
agent started from an `exec-*` connector would inherit the runtime's environment,
so the documented route out is `http-out`.
agent started from an `exec-*` connector would run as the runtime's user, on the
same bus, so the documented route out is `http-out`.

## Alternatives considered

Expand Down
8 changes: 4 additions & 4 deletions docs/adr/0024-proposal-queue.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,10 @@ purge destroys what the agent's work is judged on, and dead letters are
untrusted content that agent reads. No proposal kind carries them, so in
governed mode they sit on `VEJAS_APPROVAL_TOKEN`: refused over MCP, and
`POST /dlq/replay|purge` want the `X-Approval-Token` header. On those routes
and on approve/reject, a valid approval token also answers the write gate. The
panel holds that token and never `VEJAS_TOKEN`, so with both set, the documented
production posture, the human could not approve from the panel (401 from the
write gate) until this change.
and on approve/reject, a valid approval token also answers the write gate. A
human approves with that token and not with `VEJAS_TOKEN`, so with both set, the
documented production posture, approving from the panel failed with a 401 from
the write gate until this change.

## The loop this enables (the acte-6 recipe)

Expand Down
2 changes: 1 addition & 1 deletion docs/book/src/concepts/agents-mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
There is no builder UI to click together — the flow-writing interface *is*
an agent, and the runtime *is* the MCP server (`POST /mcp`, ADR-0006). No
sidecar, no plugin: the platform's own write path, test path and telemetry
are the [29 MCP tools](../reference/mcp-tools.md).
are the [MCP tools](../reference/mcp-tools.md).

The loop an agent follows (enforced by the tool descriptions themselves —
the generation contract):
Expand Down
3 changes: 2 additions & 1 deletion docs/book/src/getting-started/panel.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ turns them into a flow developer.
transcoding tables, queue names — is editable in place. Saving goes
through the same audited write path agents use (`/surface/set`), and the
unit restarts itself. Credential-shaped values are masked (single-sourced
pattern, [ADR-0017](../decisions.md)).
pattern, [ADR-0017](../decisions.md)). With `VEJAS_TOKEN` set, the panel asks
for it on the first write and keeps it in the page's memory only.
- **Read the rules.** The rules view projects each flow's decision logic as
faithful sentences — whole-sentence or verbatim code, never a half
translation ([the rules view](../guides/rules-view.md)).
Expand Down
4 changes: 2 additions & 2 deletions docs/book/src/guides/dlq-replay.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ happens next is a flow or a sink like any other:
[`dlq_webhook.vjs.example`](https://github.com/cpoder/vejas/blob/master/docs/examples/dlq_webhook.vjs.example)
posts the event to a URL: a chat channel, or a runner that starts your agent
with the agent's token alone. Do not start an agent from an `exec-*` connector:
a child inherits the runtime's environment, `VEJAS_APPROVAL_TOKEN` and
`VEJAS_SECRET_*` included.
the runtime's credentials are out of a child's environment, but it runs as the
runtime's user and reaches the same bus, far more than a proposal needs.

## The loop that makes it self-healing

Expand Down
4 changes: 2 additions & 2 deletions docs/book/src/guides/governed-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ the `X-Approval-Token` header. The panel asks for the token when it needs it.
lists the queue.
2. **Human decides** — in the panel's **Approval queue** card, or
`POST /proposals/{id}/approve|reject` with the `X-Approval-Token`
header, which is enough on its own even when `VEJAS_TOKEN` is set: the
panel never holds the agent's token. The card shows the evidence; a proposal with none is flagged
header, which is enough on its own even when `VEJAS_TOKEN` is set. The card
shows the evidence; a proposal with none is flagged
**"⚠ No evidence"** loud — approving blind is a deliberate act.
3. **On approve** — the change executes exactly as a normal promote would
(hot-reload, or a [cluster-wide version](change-safely.md) in 60 ms) and
Expand Down
2 changes: 1 addition & 1 deletion docs/examples/dlq_webhook.vjs.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
#
# The URL is a chat webhook, or a runner of your own that starts your agent with
# the agent's VEJAS_TOKEN alone. Keep the agent out of the runtime: a child of an
# exec-* connector inherits its environment, VEJAS_APPROVAL_TOKEN included.
# exec-* connector runs as its user and reaches the same bus.
driver "http-out"
SUBJECT = "vx.dlq.events"
URL = secret("ops/dlq_webhook_url")
2 changes: 1 addition & 1 deletion docs/launch/distribution-kit.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Title alternates (kept for reference):
> records the change with its actor).
>
> One Rust binary on NATS/JetStream, no other dependency. Measured, reproducible in
> bench/: cold start 11–13ms, 6–8MB RSS, end-to-end p50 2ms uncongested, every hop
> bench/: cold start 11–13ms, 10MB RSS, end-to-end p50 2ms uncongested, every hop
> persisted, cluster promote 60ms lossless. Twenty-one connector recipes admitted
> by CI (MQTT/RabbitMQ against real brokers; IBM MQ, Kafka).
>
Expand Down
Loading
Loading