Skip to content

feat(exec): add --env to keep credentials off the command line#264

Open
Jisung Chae (jisung-02) wants to merge 12 commits into
mainfrom
263-exec-env-support
Open

feat(exec): add --env to keep credentials off the command line#264
Jisung Chae (jisung-02) wants to merge 12 commits into
mainfrom
263-exec-env-support

Conversation

@jisung-02

@jisung-02 Jisung Chae (jisung-02) commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Background

AI agents such as Claude Code connect to remote databases through alpacon exec and often pass credentials directly on the command line (e.g. mysql -p<password>). Those commands are recorded verbatim in the audit log, which can expose customer secrets. Passing the value through --env instead keeps it off the alpacon command line. The safest form is to set the secret as a shell environment variable and pass only its key name (--env="DB_PASS"), so neither the alpacon command line nor the audit log ever contains the actual value, only a reference.

Previously --env was supported by websh only; exec failed at parse time with unknown flag. Since AI agents primarily use the exec path, they had no way to move credentials off the command line.

Changes

Add a shared ParseEnvArg helper to the exec package that returns an error string instead of calling os.Exit, add an --env case to ParseRemoteExecArgs, and carry the values through RemoteExecArgs.Env. cmd/exec/exec.go passes parsed.Env into SubmitCommand/RunExecWithApprovalWait instead of an empty map.

The supported forms match websh. --env="KEY" reads the value from the current shell (only the key name is on the command line); --env="KEY=VALUE" sets a literal value.

--env detection matches only the --env and --env= forms, so an unrelated flag like --env-file=... is rejected as an unknown flag by exec (and, in websh, falls through to the server-name slot, which has no unknown-flag rejection) rather than being swallowed and reported as a malformed --env.

Shared parsing and execution across exec and websh

The --env parser is unified: websh's --env case delegates to the same ParseEnvArg, so both commands accept identical forms and produce identical malformed-input errors. ParseEnvArg is a pure parser—it returns the error string, and both ParseRemoteExecArgs (exec) and ParseWebshArgs (websh) surface it the same way, through their normal return path rather than exiting inside the parser.

The execution path is also shared. The post-parse body of exec's command is extracted into RunRemoteExec, and websh command mode (websh SERVER COMMAND) now maps its parsed args onto RemoteExecArgs and calls RunRemoteExec. As a result, running a command through websh behaves like alpacon exec: same output, exit-code propagation, sudo-approval handling (including the pending-approval exit code 4 and the presence step-up), and --output json buffering. websh does not parse exec-only flags such as --detach/--wait, so its Wait is always false and the blocking approval wait never runs; the interactive websh SERVER path (no command) is unchanged and keeps its own sudo MFA listener.

The exec and websh help text recommends the shell-env form (--env="KEY"), warns that the --env="KEY=VALUE" form writes the value on the command line and into the audit log, and scopes the secrecy claim to the alpacon command line (the local export that sets the value is still on the shell command line).

Safety

The whole point of the feature is to keep the secret off the alpacon command line and out of the audit log, so I verified it is never re-exposed. The secret value flows only through the Env map; it never enters the ShellJoin/Command string, stdout, or error/warning messages. The unset-variable warning and the malformed-input error print only the key name, never the value.

reRunHint reconstructs the invocation for the pending-approval message and now includes the --env keys so a re-run stays faithful—but it emits --env=KEY only, never --env=KEY=VALUE. The re-run re-reads each value from the shell, so no value lands in the hint on stderr or in the logs.

The help examples use patterns that do not re-expose the value. PGPASSWORD with psql reads the value from the environment rather than argv, so it lands neither in the remote process's argv (visible via ps) nor in terminal output / the session recording.

One behavior change: in websh, malformed --env input (an empty key, e.g. --env=) now fails the command instead of the previous warn-and-skip. Failing loudly on a malformed credential flag is safer than silently dropping it, and it is consistent with exec.

Testing

Table-driven coverage for --env parsing in both ParseRemoteExecArgs and ParseWebshArgs: both forms (KEY=VALUE, shell-sourced KEY), quote unwrapping, unset-variable skip, multiple flags accumulating, empty value, malformed input, and --env after the server name being treated as a command arg. It also asserts the shell-sourced value lands in the env map and never appears in the parsed command string. Dedicated regression tests cover the secret-not-echoed-on-malformed-input path (TestParseRemoteExecArgs_EnvErrorHidesSecret, TestParseWebshArgs_EnvErrorHidesSecret), the exact --env matching (TestParseRemoteExecArgs_EnvPrefixIsExact, TestParseWebshArgs_EnvPrefixIsExact), and the keys-only rerun hint (TestReRunHint).

Manual end-to-end run against a live workspace (server web-editor):

  • Parse-time (no server call): --env-file=... rejected as unknown flag in exec; malformed --env='=hunter2' returns the format error with the value never echoed, in both exec and websh.
  • --env="PGPASSWORD" injects the shell value into the remote process (echo "$PGPASSWORD" prints it) with only the key on the local command line; --env=A=1 --env=B=2 accumulates; an unset key warns and is skipped.
  • websh command mode matches exec: --env injection works, and exit 7 propagates as exit code 7 through both exec and websh; --output json produces identical output.

go build ./..., go test -race ./cmd/exec/... ./cmd/websh/..., and golangci-lint run ./... all pass.

Checklist

  • go build ./... passes
  • go test -race ./cmd/exec/... ./cmd/websh/... passes
  • golangci-lint run ./... reports 0 issues
  • exec/websh help text updated
  • Manual e2e verified against a live workspace

Closes #263

AI agents connect to remote databases via `alpacon exec` and pass
credentials on the command line (e.g. `mysql -p<password>`), which the
audit log records verbatim. exec had no --env flag, so there was no way
to move those secrets out of the recorded command.

Add a shared ParseEnvArg helper in the exec package and a --env case to
ParseRemoteExecArgs, carrying values through the existing env map into
SubmitCommand. --env=KEY reads the value from the caller's shell (only the
key name is on the command line); --env=KEY=VALUE sets a literal. websh now
delegates to the same helper, replacing its own extractEnvValue so both
commands parse --env identically.

Constraint: exec parser runs under DisableFlagParsing and is unit-tested as a pure function, so ParseEnvArg returns an error string instead of calling os.Exit
Constraint: websh imports the exec package (not the reverse), so the shared helper lives in exec
Rejected: duplicate the env parser in exec — two sources of truth for one flag
Rejected: keep websh's lenient warn-and-skip on malformed --env — silently dropping a credential flag is worse than failing loudly
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: keep the --env value flowing only through the env map, never into the command string, or it lands back in the audit log
exec and websh both document --env but listed --env=KEY and
--env=KEY=VALUE as equals. Recommend the shell-env form (--env=KEY, only
the key on the command line) and warn that the literal form writes the
value into the audit log, so credentials must never be passed that way.

The examples read the secret with PGPASSWORD/psql, which takes the value
from the environment rather than argv, so the demonstrated pattern does
not re-expose it on the remote command line or in the session recording.

Rejected: `mysql -p"$DB_PASS"` and `echo "$DB_PASS"` examples — put the value back into remote argv and the session recording, undercutting the flag's purpose
Confidence: high
Scope-risk: narrow
Reversibility: clean
Table-driven cases for both --env forms (literal KEY=VALUE, shell-sourced
KEY), quote unwrapping, unset-variable skip, multiple flags accumulating,
empty value, malformed input, and --env after the server name being a
command arg. Asserts the shell-sourced value lands in the env map and
never appears in the parsed command string.

Confidence: high
Scope-risk: narrow
Reversibility: clean
Copilot AI review requested due to automatic review settings July 10, 2026 02:42

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR adds --env support to alpacon exec (matching websh) so AI agents can pass secrets via environment variables instead of embedding them in the remote command string that gets audited/logged.

Changes:

  • Added a shared ParseEnvArg helper and propagated parsed environment variables through RemoteExecArgs.Env.
  • Updated exec runtime path to pass the parsed env map into command submission/execution instead of always using an empty map.
  • Unified websh --env parsing and updated exec/websh help text to recommend the shell-env form and discourage literal KEY=VALUE for secrets.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
cmd/websh/websh.go Delegates --env parsing to shared helper; updates help/examples to recommend shell-env secret passing.
cmd/exec/parse.go Adds RemoteExecArgs.Env and implements ParseEnvArg to parse --env=KEY / --env=KEY=VALUE.
cmd/exec/parse_test.go Adds table-driven tests for --env parsing behavior and ensures secret values don’t enter the command string.
cmd/exec/exec.go Plumbs parsed env map into exec submission/execution and updates CLI help/examples.

Comment thread cmd/exec/parse.go
The malformed --env error formatted the raw argument with %q, so a botched
secret-bearing token like --env="=hunter2" printed the value to stderr and any
capturing logs—defeating the feature's purpose of keeping credentials off the
command line. Describe only the expected format instead, and lock it in with a
test asserting the error omits the value. websh inherits the fix via the shared
ParseEnvArg helper.

Constraint: --env exists to keep credentials out of the audit log and stderr
Rejected: redacting the key portion only — the empty-key case still risks leaking the value half
Confidence: high
Scope-risk: narrow
Reversibility: clean
Pull the post-parse execution path (output-format handling, work-session
resolution, detach submit, approval-wait run, pending-approval and result
handling) out of the Run closure into RunRemoteExec so websh command mode
can share it. No behavior change for exec.

Confidence: high
Scope-risk: narrow
Reversibility: clean
ParseWebshArgs handled a malformed --env by calling CliErrorWithExit inside
the parser, unlike every other error path in it that returns an error.
Returning the error keeps the parser exit-free, matches the surrounding
convention, and makes the no-secret-echo guarantee testable from websh's
side; a regression test locks it in. User-facing behavior is unchanged
(same message, exit 1 via the Run handler).

Constraint: malformed --env errors must never echo the raw token (secret leak)
Confidence: high
Scope-risk: narrow
Reversibility: clean
websh SERVER COMMAND previously used a reduced execution path
(RunCommandWithRetry only), so a sudo approval denial surfaced as a plain
exit-1 failure, no MFA presence step-up was offered, and --output json was
not buffered. Delegate to RunRemoteExec so command mode behaves exactly
like alpacon exec: pending-approval signal with exit code 4, presence
step-up, and JSON buffering. Parsing is untouched, so the websh flag
surface and compatibility are unaffected. Document the alias behavior and
exit code 4 in the websh help text and README.

Constraint: websh imports cmd/exec, so the shared runner must live in the exec package
Rejected: full alias (feeding raw args to exec's parser) — merges flag surfaces and changes websh parsing compatibility (unknown flags, --detach)
Rejected: deprecating websh command mode — breaking for existing users
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: keep websh command mode delegating to RunRemoteExec; do not reintroduce a separate execution path

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Comment thread cmd/websh/websh.go Outdated
Comment thread cmd/exec/parse.go Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Comment thread cmd/exec/exec.go
--env detection used HasPrefix(arg, "--env"), so an unrelated flag like
--env-file=secrets matched and returned "invalid --env argument" instead
of being rejected as an unknown flag. Match only "--env" and "--env="
forms, mirroring --output and --work-session. Applied in both the exec and
websh parsers (shared behavior); regression tests cover the --env-file case
in each.

Confidence: high
Scope-risk: narrow
Reversibility: clean
The command-mode help text claimed it "behaves exactly like 'alpacon
exec'", but websh does not parse exec-only flags (--detach, --wait), so
that implied full flag compatibility that does not exist. Reword to say
command execution shares exec's runner and output semantics, and state the
exec-only flags are not accepted. Correct the delegation comment for the
same reason: Wait is always false in websh, so the blocking approval wait
never runs—the shared behavior is the pending-approval exit code,
sudo-denial hints, and JSON buffering. Also trims the RunRemoteExec
docstring.

Confidence: high
Scope-risk: narrow
Reversibility: clean
reRunHint reconstructed the exec invocation for a human to re-run after
approval but dropped every --env flag. With --env now supported, a command
run as `exec --env=PGPASSWORD ...` that lands pending approval was hinted
back without the env key, so copy/pasting it would fail (the value is no
longer supplied). Emit `--env=KEY` for each key, sorted for stable output.
Keys only, never values: the rerun re-reads each value from the shell, so
a secret never lands in the hint on stderr or in the logs.

Constraint: rerun hint must never echo an --env value (secret leak)
Confidence: high
Scope-risk: narrow
Reversibility: clean

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.

Comment thread cmd/exec/exec.go Outdated
Comment thread cmd/exec/exec.go Outdated
Comment thread cmd/websh/websh.go Outdated
Comment thread cmd/websh/websh.go Outdated
The --env help and examples said the secret stays "off the command line"
/ "off every command line", but the shown `export KEY=...` still puts the
value on the local shell command line (and in shell history). Reword to
"off the alpacon command line" so the guarantee is accurate: --env keeps
the value out of the alpacon invocation and the audit log (session
recording for websh), not out of every command line. Covers both --env
flag descriptions and both worked examples in exec and websh.

Confidence: high
Scope-risk: narrow
Reversibility: clean

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.

The reRunHint docstring listed only "server, optional user/group, and
command", but the function also reconstructs the work-session and the
--env keys added for rerun fidelity. Update the enumeration to match.

Confidence: high
Scope-risk: narrow
Reversibility: clean
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.

Add --env support to exec and warn against command-line credentials in exec/websh help

2 participants