Skip to content

feat(interp): host-binary exec via host:<name>=<path> [DEMO ONLY]#228

Draft
julesmcrt wants to merge 6 commits intojules.macret/host-remediation/truncatefrom
jules.macret/host-remediation/logrotate-host-exec
Draft

feat(interp): host-binary exec via host:<name>=<path> [DEMO ONLY]#228
julesmcrt wants to merge 6 commits intojules.macret/host-remediation/truncatefrom
jules.macret/host-remediation/logrotate-host-exec

Conversation

@julesmcrt
Copy link
Copy Markdown
Collaborator

⚠️ Demo only — do not merge

This PR fundamentally changes rshell's threat model and is not intended to ship as-is. It exists so that step 6 of the host-remediation demo (running logrotate -f /etc/logrotate.d/app from inside a restricted-shell-driven remediation flow) has something concrete to point at and provoke a design conversation around.

A real product version of this would need: a proper RFC, an audit trail, argv constraints / lint rules per binary, a configuration story that survives review by Security, a multi-binary scaffolding decision, cross-platform support, and (likely) a separate option/permission model rather than overloading AllowedCommands.

What this adds

A new host:<name>=<absolute-path> syntax for AllowedCommands. When dispatch sees a non-builtin command name matching <name>, the binary at <absolute-path> is exec'd directly:

  • exec.CommandContext(ctx, path, args[1:]...) — context cancel kills the child via SIGKILL (Go's default Cancel).
  • cmd.Stdin = r.stdin, cmd.Stdout = r.stdout, cmd.Stderr = r.stderr — full passthrough, so existing pipe/redirect plumbing on the runner inherits naturally.
  • cmd.Dir = r.Dir — runs in the runner's cwd.
  • cmd.Env — minimal: only PATH, HOME, LANG are forwarded; everything else from the parent process env is stripped.
  • Exit code propagated as r.exit.code so $? works. Process killed by signal (e.g. cancel) maps to 130; any non-ExitError start failure surfaces to stderr and returns 127.
  • Builtins always win — host: is consulted only when the name is not a known builtin.
  • New telemetry tag rshell.command.host_exec=true so the demo narrative ("look, the agent reached for the host") is visible in trace data.

Linux-only. On darwin/windows the dispatch returns 127 with host execution not supported on this platform.

Shape choice

I chose Shape A (extend AllowedCommands with a host: namespace) over Shape B (a separate HostCommands(map[string]string) option). Reasoning:

  • Operators already think of "what commands can this shell run?" in terms of AllowedCommands. Mixing host binaries into the same surface keeps that mental model intact for the demo and means CLI users only have to learn one flag (--allowed-commands) for both rshell builtins and host binaries.
  • The =<path> suffix makes host entries visually distinct from rshell:<name> entries, and the namespace prefix keeps the parsing unambiguous.
  • A real product version may well prefer Shape B for cleaner separation of concerns; that's a design decision worth having explicitly rather than baking in here.

Out of scope (deferred until a real design pass)

  • Argv filtering / metacharacter handling — the demo just runs logrotate -f <fixed-path>, and the flag-parser sandbox already prevents shell metacharacters from reaching args.
  • Audit logging / structured emission of every host-exec event.
  • PATH lookup — host paths must be absolute, hardcoded in the allowlist.
  • Multiple binaries in one allowlist entry, glob matching, etc.
  • Cross-platform support (windows, darwin).
  • Operator-friendly configuration (today: comma-separated CLI flag).

CI: known failures

TestInterpAllowedSymbols (in analysis/) will fail because os/exec is in permanentlyBanned, and os.LookupEnv is not on interpAllowedSymbols. This is by design — those allowlists exist precisely to gate decisions like this one. Per AGENTS.md, the verified/allowed_symbols label is for human manual approval and is not added in this PR.

Verification

  • make fmt — clean.

  • go test ./interp/... ./cmd/rshell/... — pass on darwin (Linux-only host_exec_test.go cross-compiles cleanly).

  • Manual smoke (darwin, builtin echo path):

    $ go run ./cmd/rshell --allowed-commands rshell:cat,host:echo=/bin/echo -c 'echo hello world'
    hello world
    
  • Manual smoke (darwin, host stub path):

    $ go run ./cmd/rshell --allowed-commands host:somecmd=/usr/bin/true -c 'somecmd'
    rshell: somecmd: host execution not supported on this platform
    

Tests added

interp/host_exec_test.go (Linux-only):

  • stdout plumbed (/bin/echo)
  • exit code propagated (/usr/bin/false → 1)
  • stdin plumbed (/bin/cat reading from a piped stdin)
  • non-allowlisted name rejected with "command not allowed", exit 127
  • context cancel kills /bin/sleep 30 within 2s
  • env filtering: PATH, HOME, LANG forwarded; sentinel var stripped

Scenario tests intentionally skipped — the docker harness adds variability and bash-comparison is meaningless when the whole point is to invoke a host binary.

Test plan

  • make fmt clean
  • go test ./interp/... ./cmd/rshell/... passes
  • Manual smoke on darwin (builtin path, host stub path)
  • Manual smoke on Linux with logrotate -f /etc/logrotate.d/app (deferred to demo runtime)

🤖 Generated with Claude Code

@julesmcrt
Copy link
Copy Markdown
Collaborator Author

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3a9876fc1b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread interp/runner_exec.go Outdated
julesmcrt added a commit that referenced this pull request May 5, 2026
…builtins

Address Codex review (PR #228): an entry like "host:cat=/bin/false"
flipped isAllowed=true and the isKnown branch then ran the cat builtin
with stdin/AllowedPaths access, never executing the host binary. Compute
isKnown before isAllowed and gate the host entry on !isKnown so a
collision with a builtin name no longer launders builtin access.
host_exec span tag is now keyed on hostDispatch (the actually-exec'd
case) rather than mere entry presence.

Includes a cross-platform regression test that exercises the gate
without invoking the host-exec stub.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@julesmcrt
Copy link
Copy Markdown
Collaborator Author

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c4205dd775

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread interp/host_exec_test.go Outdated
Comment thread interp/allowed_commands_test.go
julesmcrt added a commit that referenced this pull request May 6, 2026
…solute paths on Windows

Address Codex review (PR #228, two comments).

P1 — host_exec_test.go: the tests bound host:echo, host:cat,
host:false to /bin/echo, /bin/cat, /usr/bin/false respectively, but
echo/cat/false are all rshell builtins. After the !isKnown gate fix in
c4205dd, those entries no longer authorize the name (builtins win),
so the tests rejected with exit 127 / "command not allowed" before
ever invoking the host binary. Switched to non-builtin command names
(hostecho, hostcat, hostfalse) bound to the same binaries, so dispatch
actually reaches runHostCommand. Verified by running the test binary
on debian:bookworm-slim — all nine TestHostExec* now pass.

Also rewrote TestHostExecBuiltinTakesPrecedence to point host:echo at
/usr/bin/false: if the host path were erroneously preferred, the
script would exit 1; because the builtin wins it exits 0. The previous
form pointed both at /bin/echo, which produced identical output and
could not distinguish the two paths.

P2 — allowed_commands_test.go: the cross-platform host-entry tests
used /bin/false and /usr/bin/true as host paths. AllowedCommands
validates with filepath.IsAbs, which is false on Windows for paths
without a drive letter or UNC prefix, so those tests fail at New()
during interp.New on Windows. Switched to filepath.Join(t.TempDir(),
"fake-binary"), which is absolute on every OS and is never actually
exec'd (the tests assert the dispatch gate, not the exec).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@julesmcrt
Copy link
Copy Markdown
Collaborator Author

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 93376921ce

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread interp/host_exec_linux.go
julesmcrt added a commit that referenced this pull request May 6, 2026
…gate

Address Codex review (PR #228, host_exec_linux.go:64).

When MaxExecutionTime or the parent context expired while a host
binary was running, exec.CommandContext killed the child and cmd.Run
returned *exec.ExitError with ExitCode() == -1. The previous code
mapped that to exit 130 and returned, leaving r.exit.err nil — so
Run() returned ExitStatus(130) instead of context.DeadlineExceeded /
context.Canceled. Net effect: cmd/rshell's timeout path never printed
"execution timed out" or returned 124, and run-span telemetry classed
the run as success.

Now check ctx.Err() before mapping the signal to a numeric code: when
the context expired, propagate the context error via r.exit.fatal so
Run() returns it directly. The numeric return becomes symbolic (only
observed when r.exit.err is nil).

Test: new TestHostExecDeadlineSurfacesAsDeadlineExceeded asserts that
a 100ms ctx.WithTimeout against `sleep 30` returns
context.DeadlineExceeded within 2s; updated
TestHostExecContextCancellationKillsProcess to assert
context.Canceled is the propagated error. Both pass on
debian:bookworm-slim.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@julesmcrt
Copy link
Copy Markdown
Collaborator Author

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 05e34d2474

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread interp/host_exec_linux.go Outdated
julesmcrt added a commit that referenced this pull request May 6, 2026
…ess env

Address Codex review (PR #228, host_exec_linux.go:95).

Previously filterHostEnv() called os.LookupEnv(name) for each
allowlisted name — the ambient Go process env. That broke three
guarantees:

  1. The runner's documented "no host environment inherited by
     default" property: a runner constructed with the empty default
     Env still leaked PATH/HOME/LANG to host binaries from the
     surrounding Go process.
  2. interp.Env(...) caller-provided values: the runner env was
     ignored entirely, so the host binary saw the wrong PATH/HOME/LANG.
  3. Inline command assignments (PATH=/safe hostcmd): again ignored.

filterHostEnv is now a method on Runner that reads from r.writeEnv —
the same overlay that builtins read from. call() applies inline
assigns to r.writeEnv before dispatching, so the host binary sees the
post-assignment view.

New tests on debian:bookworm-slim:
  - TestHostExecDefaultEnvIsEmpty: with no interp.Env set, ambient
    PATH/HOME/LANG must NOT appear in the host binary's env.
  - TestHostExecInlineAssignmentTakesEffect: PATH=/inline-override env
    must show PATH=/inline-override and not the runner's initial PATH.

Existing TestHostExecEnvFilteredToAllowlist /
TestHostExecForwardsHomeAndLang updated to plant env via interp.Env
rather than t.Setenv.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@julesmcrt
Copy link
Copy Markdown
Collaborator Author

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 77bc3d56cc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread interp/host_exec_linux.go Outdated
julesmcrt added a commit that referenced this pull request May 6, 2026
Address Codex review (PR #228, host_exec_linux.go:101).

Previously filterHostEnv accepted any Declared() variable in
hostEnvAllowlist. That diverged from bash: a script-level assignment
without a prior export, e.g.

    LANG=fresh
    hostcmd

is shell-local in bash and is NOT propagated to child processes.
Verified with `bash -c 'CUSTOM_VAR=/tmp; env | grep CUSTOM_VAR'`
which prints nothing.

filterHostEnv now requires both vr.Declared() and vr.Exported. The
two valid sources of host-forwarded env are unaffected:

  - interp.Env(...) values are stored as exported (that is their
    purpose as the initial environment).
  - Inline command assignments (`PATH=/safe hostcmd`) propagate
    because call() at runner_exec.go:110 forces vr.Exported = true
    before dispatch.

New test TestHostExecUnexportedAssignmentNotForwarded verifies the
divergent case ('LANG=should-not-leak; env' must not show LANG in
the host binary's environment). Passes on debian:bookworm-slim.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@julesmcrt
Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Swish!

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@julesmcrt julesmcrt force-pushed the jules.macret/host-remediation/truncate branch from 2acb2e8 to 8cbe726 Compare May 7, 2026 12:18
julesmcrt and others added 6 commits May 7, 2026 14:18
Add a parallel "host:" namespace to AllowedCommands so a single
allowlisted host binary (e.g. host:logrotate=/usr/sbin/logrotate) can be
exec'd directly. Plumbs stdin/stdout/stderr, propagates exit code, kills
via SIGKILL on context cancel, forwards only PATH/HOME/LANG to the
child. Linux-only; non-linux platforms return 127.

This fundamentally changes rshell's threat model — the entire reason
rshell exists is to *not* execute host binaries. This entry-point exists
for the host-remediation demo (step 6: logrotate -f) and is not intended
to merge to main without a real design pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…builtins

Address Codex review (PR #228): an entry like "host:cat=/bin/false"
flipped isAllowed=true and the isKnown branch then ran the cat builtin
with stdin/AllowedPaths access, never executing the host binary. Compute
isKnown before isAllowed and gate the host entry on !isKnown so a
collision with a builtin name no longer launders builtin access.
host_exec span tag is now keyed on hostDispatch (the actually-exec'd
case) rather than mere entry presence.

Includes a cross-platform regression test that exercises the gate
without invoking the host-exec stub.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…solute paths on Windows

Address Codex review (PR #228, two comments).

P1 — host_exec_test.go: the tests bound host:echo, host:cat,
host:false to /bin/echo, /bin/cat, /usr/bin/false respectively, but
echo/cat/false are all rshell builtins. After the !isKnown gate fix in
c4205dd, those entries no longer authorize the name (builtins win),
so the tests rejected with exit 127 / "command not allowed" before
ever invoking the host binary. Switched to non-builtin command names
(hostecho, hostcat, hostfalse) bound to the same binaries, so dispatch
actually reaches runHostCommand. Verified by running the test binary
on debian:bookworm-slim — all nine TestHostExec* now pass.

Also rewrote TestHostExecBuiltinTakesPrecedence to point host:echo at
/usr/bin/false: if the host path were erroneously preferred, the
script would exit 1; because the builtin wins it exits 0. The previous
form pointed both at /bin/echo, which produced identical output and
could not distinguish the two paths.

P2 — allowed_commands_test.go: the cross-platform host-entry tests
used /bin/false and /usr/bin/true as host paths. AllowedCommands
validates with filepath.IsAbs, which is false on Windows for paths
without a drive letter or UNC prefix, so those tests fail at New()
during interp.New on Windows. Switched to filepath.Join(t.TempDir(),
"fake-binary"), which is absolute on every OS and is never actually
exec'd (the tests assert the dispatch gate, not the exec).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…gate

Address Codex review (PR #228, host_exec_linux.go:64).

When MaxExecutionTime or the parent context expired while a host
binary was running, exec.CommandContext killed the child and cmd.Run
returned *exec.ExitError with ExitCode() == -1. The previous code
mapped that to exit 130 and returned, leaving r.exit.err nil — so
Run() returned ExitStatus(130) instead of context.DeadlineExceeded /
context.Canceled. Net effect: cmd/rshell's timeout path never printed
"execution timed out" or returned 124, and run-span telemetry classed
the run as success.

Now check ctx.Err() before mapping the signal to a numeric code: when
the context expired, propagate the context error via r.exit.fatal so
Run() returns it directly. The numeric return becomes symbolic (only
observed when r.exit.err is nil).

Test: new TestHostExecDeadlineSurfacesAsDeadlineExceeded asserts that
a 100ms ctx.WithTimeout against `sleep 30` returns
context.DeadlineExceeded within 2s; updated
TestHostExecContextCancellationKillsProcess to assert
context.Canceled is the propagated error. Both pass on
debian:bookworm-slim.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ess env

Address Codex review (PR #228, host_exec_linux.go:95).

Previously filterHostEnv() called os.LookupEnv(name) for each
allowlisted name — the ambient Go process env. That broke three
guarantees:

  1. The runner's documented "no host environment inherited by
     default" property: a runner constructed with the empty default
     Env still leaked PATH/HOME/LANG to host binaries from the
     surrounding Go process.
  2. interp.Env(...) caller-provided values: the runner env was
     ignored entirely, so the host binary saw the wrong PATH/HOME/LANG.
  3. Inline command assignments (PATH=/safe hostcmd): again ignored.

filterHostEnv is now a method on Runner that reads from r.writeEnv —
the same overlay that builtins read from. call() applies inline
assigns to r.writeEnv before dispatching, so the host binary sees the
post-assignment view.

New tests on debian:bookworm-slim:
  - TestHostExecDefaultEnvIsEmpty: with no interp.Env set, ambient
    PATH/HOME/LANG must NOT appear in the host binary's env.
  - TestHostExecInlineAssignmentTakesEffect: PATH=/inline-override env
    must show PATH=/inline-override and not the runner's initial PATH.

Existing TestHostExecEnvFilteredToAllowlist /
TestHostExecForwardsHomeAndLang updated to plant env via interp.Env
rather than t.Setenv.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address Codex review (PR #228, host_exec_linux.go:101).

Previously filterHostEnv accepted any Declared() variable in
hostEnvAllowlist. That diverged from bash: a script-level assignment
without a prior export, e.g.

    LANG=fresh
    hostcmd

is shell-local in bash and is NOT propagated to child processes.
Verified with `bash -c 'CUSTOM_VAR=/tmp; env | grep CUSTOM_VAR'`
which prints nothing.

filterHostEnv now requires both vr.Declared() and vr.Exported. The
two valid sources of host-forwarded env are unaffected:

  - interp.Env(...) values are stored as exported (that is their
    purpose as the initial environment).
  - Inline command assignments (`PATH=/safe hostcmd`) propagate
    because call() at runner_exec.go:110 forces vr.Exported = true
    before dispatch.

New test TestHostExecUnexportedAssignmentNotForwarded verifies the
divergent case ('LANG=should-not-leak; env' must not show LANG in
the host binary's environment). Passes on debian:bookworm-slim.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@julesmcrt julesmcrt force-pushed the jules.macret/host-remediation/logrotate-host-exec branch from a78f561 to d4bd28b Compare May 7, 2026 12:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant