Skip to content
Open
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
31 changes: 28 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ remaining findings across shepherd/NIF, lib, tests, docs, and CI).
deadline, so a wedged peer cannot park the shepherd after reap and skip
cgroup cleanup.

- **Startup failures return promptly.** If the OS rejects the shepherd's
arguments or environment, NetRunner returns
`{:error, {:shepherd_spawn_failed, reason}}` without waiting for the socket
timeout. An immediate child exit remains a valid spawn.

### Changed

- **`NetRunner.Process.Nif` renamed to `NetRunner.Nif`** (internal,
Expand Down Expand Up @@ -85,6 +90,24 @@ remaining findings across shepherd/NIF, lib, tests, docs, and CI).
full-size allocation and copy for large outputs. Default `:binary`
unchanged; the `max_output_exceeded` partial is always a binary.

- **`:cwd` sets the child working directory.** `NetRunner.run/2`, `stream!/2`,
`stream/2`, and `NetRunner.Process.start/3` accept `cwd: path`.
`NetRunner.Daemon` accepts it in `process_opts`. If the shepherd cannot enter
the directory, the spawn returns `{:error, {:shepherd_error, reason}}`.
Relative paths use the BEAM working directory. This option does not change
`PWD`. See ADR-10 in `docs/decisions.md`.

- **`:env` accepts maps and lists of `{name, value}` pairs.** A `nil` or empty
value removes the variable because a port cannot set an empty value. Names
and values must contain valid UTF-8. Character lists fix the double encoding
of non-ASCII values. See ADR-11 in `docs/decisions.md`.

- **`env: {:replace, environment}` defines the complete child environment.**
The shepherd removes every unselected variable before it forks. An empty map
or list gives the child an empty environment. The child uses only the
selected `PATH` to resolve its executable. Untagged `env:` remains an
overlay. See ADR-12 in `docs/decisions.md`.

### Changed

- **Internal read batching.** `run/2`, `stream!/2`, and `Daemon` drain
Expand Down Expand Up @@ -166,8 +189,8 @@ cycle below (numbers are medians on an Apple M1 Max, OTP 29 / erts 17.0.3,

- **`:env` option** (`run/2`, `stream!/2`, `Process.start/3`): a map of
environment variables for the child; a binary value sets, `nil` unsets.
PATH resolution happens before `:env` applies — pass absolute command
paths when overriding `PATH`.
The child's executable uses the modified `PATH`. Pass an
absolute command path when `:env` comes from outside your trust boundary.
- **`stderr: :capture` for `run/2`** — returns the retained stderr tail as a
third tuple element: `{output, exit_status, stderr}`.
- **`NetRunner.Error`** exception with the original reason in `:reason`;
Expand Down Expand Up @@ -196,7 +219,9 @@ cycle below (numbers are medians on an Apple M1 Max, OTP 29 / erts 17.0.3,
empty; rejecting beats silently returning `""`.
- **`:env` values travel as raw bytes** (`execve` semantics): non-UTF-8
values no longer raise from inside spawn, and UTF-8 values are no longer
transcoded to codepoints.
transcoded to codepoints. The Unreleased rules replace this behavior. Port
environment entries are characters, so NetRunner now rejects non-UTF-8
values.
- **`Daemon` stops when its child exits**, with
`{:shutdown, {:exit_status, n}}`, so `restart: :permanent` supervisors
restart it; previously it lingered as a healthy-looking GenServer over a
Expand Down
73 changes: 72 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,76 @@ Output handling options:

Graceful shutdown: on `terminate/2`, sends SIGTERM, waits 5 seconds, then SIGKILL.

## Working Directory

`cwd:` sets the child working directory:

```elixir
{output, 0} = NetRunner.run(~w(git status), cwd: "/srv/checkouts/job_123")
```

Relative paths and executable names use this directory. A relative `cwd:`
value uses the BEAM working directory as its base.

If the shepherd cannot enter the directory, NetRunner returns an error before
the command runs:

```elixir
NetRunner.run(~w(git status), cwd: "/srv/gone")
#=> {:error, {:shepherd_error, "chdir failed: No such file or directory"}}
```

`cwd:` does not change the `PWD` environment variable. Set `PWD` through `env:`
if the child requires it:

```elixir
NetRunner.run(~w(git status), cwd: dir, env: [{"PWD", dir}])
```

## Environment

`env:` adds, overrides, or removes variables in the inherited environment. It
accepts a map or the same list format as `System.cmd/3`:

```elixir
NetRunner.run(~w(codex), env: [{"CODEX_HOME", "/srv/agents/7"}, {"SSH_AUTH_SOCK", nil}])
```

A `nil` or empty value removes a variable. The BEAM cannot set an empty value
through a port.

Names must be non-empty UTF-8 binaries without `=` or NUL. Values must be UTF-8
binaries without NUL, or `nil`. Invalid entries raise `ArgumentError`.

The port option keeps environment values out of the shepherd command line. The
values remain visible to processes that can inspect the shepherd or child
environment. The child uses its `PATH` to resolve the executable:

```elixir
{"found\n", 0} = NetRunner.run(["only_on_this_path"], env: [{"PATH", "/srv/tools"}])
```

The operating system limits the environment size. NetRunner returns
`{:error, {:shepherd_spawn_failed, reason}}` if the environment exceeds that
limit.

### Replacing the inherited environment

Wrap the input in `{:replace, environment}` to define the complete child
environment:

```elixir
NetRunner.run(~w(env), env: {:replace, %{"PATH" => "/srv/tools", "HOME" => "/srv/agents/7"}})
```

The shepherd removes all unselected variables before it forks. It does not
change the BEAM environment. `env: {:replace, %{}}` gives the child an empty
environment.

Replacement mode uses the same validation as overlay mode. The child uses only
the selected `PATH` to resolve its executable. An unknown mode raises
`ArgumentError`.

## cgroup Support (Linux)

Isolate child processes in a cgroup v2 hierarchy for resource control:
Expand Down Expand Up @@ -443,10 +513,11 @@ end, max_concurrency: 20)
| `:output` | atom | `:binary` | Result shape for collected stdout (`run/2` only): `:binary` concatenates chunks into one binary; `:iodata` returns the collected chunks as iodata and skips the final flatten (an extra full-size allocation + copy for large outputs). The `max_output_exceeded` partial is always a binary. |
| `:stderr` | atom | `:consume` | `:consume` (drained internally), `:capture` (drained, and the retained tail is returned as a third tuple element: `{output, exit_status, stderr}`) or `:disabled` |
| `:stderr_tail_bytes` | integer | `8192` | Cap on the retained stderr tail; `0..1_048_576`. `0` retains nothing. |
| `:env` | map | `nil` | Environment variables for the child: `%{"NAME" => "value"}` sets, `%{"NAME" => nil}` unsets. The child's executable is resolved against the **modified** environment, so an `:env`-supplied `PATH` changes which binary runs — pass absolute command paths when `:env` comes from untrusted input. |
| `:env` | map \| list of pairs \| `{:replace, map \| list}` | `nil` | Child environment changes. A binary sets a variable. `nil` removes it. `{:replace, environment}` removes all unselected variables. |
| `:pty` | boolean | `false` | Use pseudo-terminal |
| `:kill_timeout` | integer | `5000` | SIGTERM→SIGKILL escalation timeout in ms |
| `:cgroup_path` | string | `nil` | cgroup v2 path (Linux only); must sit under a `net_runner/` prefix and be under 256 bytes |
| `:cwd` | string | `nil` | Child working directory. Defaults to the BEAM working directory. |

Unknown options raise `ArgumentError` (`Keyword.validate!/2`) instead of being
silently ignored. `stream!/2` accepts the same options minus `:timeout`,
Expand Down
85 changes: 79 additions & 6 deletions c_src/shepherd.c
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,41 @@ static void child_fail(const char *tag, const char *detail) {
_exit(127);
}

static void exec_child(char *cmd, char **args, int allow_execvp) {
if (!allow_execvp) child_fail("execvp", cmd);
execvp(cmd, args);
child_fail("execvp", cmd);
}

extern char **environ;

static char **kept_environment_names = NULL;
static size_t kept_environment_name_count = 0;

static int keep_environment_entry(const char *entry) {
const char *equals = strchr(entry, '=');
if (equals == NULL) return 0;

size_t name_len = (size_t)(equals - entry);
for (size_t i = 0; i < kept_environment_name_count; i++) {
const char *kept = kept_environment_names[i];
if (strlen(kept) == name_len && memcmp(entry, kept, name_len) == 0) {
return 1;
}
}
return 0;
}

static void restrict_environment(void) {
if (kept_environment_names == NULL) return;

char **write = environ;
for (char **read = environ; *read != NULL; read++) {
if (keep_environment_entry(*read)) *write++ = *read;
}
*write = NULL;
}

static void sigchld_handler(int sig) {
(void)sig;
int saved_errno = errno;
Expand Down Expand Up @@ -646,24 +681,29 @@ static int event_loop(int uds_fd, pid_t child_pid, int *stdin_w) {
}

/*
* Usage: shepherd <uds_path> [--kill-timeout <ms>] [--token-fd] <cmd> [args...]
* Usage: shepherd <uds_path> [options] <cmd> [args...]
*
* uds_path: Path to the UDS listener socket created by the BEAM
* --kill-timeout: SIGTERM->SIGKILL escalation timeout in ms (default 5000)
* --token-fd: read the 32-char hex handshake token from fd 3 (the
* BEAM port channel) and send it verbatim as the first
* frame after connect so the BEAM can authenticate us
* --cwd: Directory to run the child in (default: inherited)
* --replace-env: Count followed by the environment names the child keeps
* cmd: Command to execute
* args: Arguments for the command
*/
int main(int argc, char *argv[]) {
if (argc < 3) {
fprintf(stderr,
"usage: shepherd <uds_path> [--kill-timeout <ms>] [--token-fd] <cmd> [args...]\n");
"usage: shepherd <uds_path> [--kill-timeout <ms>] [--token-fd] "
"[--cwd <dir>] [--replace-env <count> <names...>] "
"<cmd> [args...]\n");
return 1;
}

const char *uds_path = argv[1];
const char *child_cwd = NULL;
int cmd_idx = 2;
int token_from_fd = 0;
char token[TOKEN_HEX_LEN + 1] = {0};
Expand All @@ -689,6 +729,25 @@ int main(int argc, char *argv[]) {
* to exactly the same-uid attacker it exists to stop. */
token_from_fd = 1;
cmd_idx += 1;
} else if (strcmp(argv[cmd_idx], "--cwd") == 0 && cmd_idx + 1 < argc) {
if (argv[cmd_idx + 1][0] == '\0') {
fprintf(stderr, "error: --cwd must not be empty\n");
return 1;
}
child_cwd = argv[cmd_idx + 1];
cmd_idx += 2;
} else if (strcmp(argv[cmd_idx], "--replace-env") == 0 && cmd_idx + 1 < argc) {
char *endptr;
long count = strtol(argv[cmd_idx + 1], &endptr, 10);
long available = (long)argc - cmd_idx - 3;
if (*endptr != '\0' || endptr == argv[cmd_idx + 1] ||
count < 0 || count > available) {
fprintf(stderr, "error: invalid --replace-env count\n");
return 1;
}
kept_environment_names = &argv[cmd_idx + 2];
kept_environment_name_count = (size_t)count;
cmd_idx += 2 + (int)count;
} else if (strcmp(argv[cmd_idx], "--cgroup-path") == 0 && cmd_idx + 1 < argc) {
const char *path = argv[cmd_idx + 1];
/* Reject path traversal: no ".." components, no leading "/" */
Expand Down Expand Up @@ -822,6 +881,22 @@ int main(int argc, char *argv[]) {
}
}

/* Keep chdir after authentication so the token remains the first frame.
* Keep it before fork so a failure can use MSG_ERROR. */
if (child_cwd != NULL && chdir(child_cwd) != 0) {
char msg[128];
snprintf(msg, sizeof(msg), "chdir failed: %s", strerror(errno));
send_error(uds_fd, msg);
close(uds_fd);
return 1;
}

restrict_environment();

/* A missing PATH normally enables an execvp default. Replacement mode must not. */
int allow_execvp = kept_environment_names == NULL ||
strchr(cmd, '/') != NULL || getenv("PATH") != NULL;

pid_t child_pid;
int shepherd_stdin_w = -1;

Expand Down Expand Up @@ -898,8 +973,7 @@ int main(int argc, char *argv[]) {
signal(SIGPIPE, SIG_DFL);

setpgid(0, 0);
execvp(cmd, cmd_args);
child_fail("execvp", cmd);
exec_child(cmd, cmd_args, allow_execvp);
}

/* === Shepherd (PTY) === */
Expand Down Expand Up @@ -1078,8 +1152,7 @@ int main(int argc, char *argv[]) {
signal(SIGPIPE, SIG_DFL);

setpgid(0, 0);
execvp(cmd, cmd_args);
child_fail("execvp", cmd);
exec_child(cmd, cmd_args, allow_execvp);
}

/* === Shepherd (pipe) === */
Expand Down
29 changes: 29 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,35 @@ When `pty: true` is passed:
- BEAM dups the FD for independent stdin/stdout NIF resources
- `set_window_size/3` sends `CMD_SET_WINSIZE` to shepherd, which calls `ioctl(TIOCSWINSZ)`

## Working Directory

The `cwd:` option has this flow:
- BEAM accepts a non-empty binary without NUL and passes it to the shepherd as
`--cwd <dir>`
- Shepherd calls `chdir()` after authentication and before `fork()`
- A failed `chdir()` sends `MSG_ERROR` before the shepherd starts a child
- The UDS and cgroup paths are absolute, so the directory change does not
affect them

See ADR-10 in `decisions.md`.

## Environment

The `env:` option has this flow:
- BEAM validates each name and value and passes them to the `Port.open` `env`
option
- The shepherd and child inherit the resulting environment
- The values do not appear in the shepherd command line
- The child uses its `PATH` to resolve the executable
- `nil` and `""` both remove a variable
- Names and values must contain valid UTF-8

Replacement mode also passes the selected names in a shepherd allowlist. After
authentication, the shepherd removes all other variables before `fork()`. The
BEAM environment does not change.

See ADR-11 and ADR-12 in `decisions.md`.

## cgroup Support (Linux Only)

When `cgroup_path:` is set (must sit under a `net_runner/` prefix, < 256
Expand Down
55 changes: 55 additions & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,58 @@ default argument, so there is no second definition to drift.
Regression guard: `test/io_pipelining_test.exs`, "a saturated stdout read
returns full-capacity chunks". Reproduce with
`MIX_ENV=prod mix run bench/claims.exs`, section B.

## ADR-10: Set the Working Directory in the Shepherd

**Context**: Callers sometimes need to set a child working directory. NetRunner
can set it through the `Port.open` `cd:` option, in the child between `fork()`
and `exec()`, or in the shepherd before `fork()`.

**Decision**: Pass `--cwd <dir>` to the shepherd. The shepherd calls `chdir()`
after authentication and before `fork()`.

**Consequences**:
- A failed `chdir()` returns `MSG_ERROR` with `strerror(errno)` before a child
starts.
- `Port.open` with `cd:` cannot report this error through the shepherd.
- A child-side `chdir()` failure would look like an exit status from the child.
- The selected directory controls relative paths and relative executables.
- The shepherd also changes directory. Its later filesystem paths are absolute.

## ADR-11: Pass the Environment through the Port

**Context**: Callers need to change the environment that the child inherits.
NetRunner can pass these values through the shepherd command line or through
the `Port.open` `env:` option.

**Decision**: Use the `Port.open` `env:` option. Validate each name and value in
the BEAM.

**Consequences**:
- Environment values do not appear in the shepherd command line.
- The shepherd and child receive the same environment. `execvp` uses its
`PATH` to resolve the executable.
- A port cannot set a variable to an empty value. `""` and `nil` both remove
the variable.
- Port environment entries are character lists, so names and values must be
valid UTF-8.
- Invalid entries raise `ArgumentError` before NetRunner starts the shepherd.
- An environment over the platform limit returns
`{:shepherd_spawn_failed, reason}`.

## ADR-12: Filter Replacement Environments in the Shepherd

**Context**: An overlay cannot remove inherited variables that the caller does
not know about. Some callers need to define the complete child environment.

**Decision**: Add `env: {:replace, environment}`. Pass values through the port
environment and names through a shepherd allowlist. The shepherd removes all
other variables before `fork()`. Untagged `env:` remains an overlay.

**Consequences**:
- Replacement mode filters the environment that the shepherd received.
- Values use the same port transport and validation in both modes.
- Selected names also appear in the shepherd command line.
- Without `PATH`, the shepherd prevents `execvp` from using a default search
path.
- Selected names count toward both the argument and environment size limits.
Loading