feat(exec): add --env to keep credentials off the command line#264
Open
Jisung Chae (jisung-02) wants to merge 12 commits into
Open
feat(exec): add --env to keep credentials off the command line#264Jisung Chae (jisung-02) wants to merge 12 commits into
Jisung Chae (jisung-02) wants to merge 12 commits into
Conversation
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
There was a problem hiding this comment.
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
ParseEnvArghelper and propagated parsed environment variables throughRemoteExecArgs.Env. - Updated
execruntime path to pass the parsed env map into command submission/execution instead of always using an empty map. - Unified
websh--envparsing and updatedexec/webshhelp text to recommend the shell-env form and discourage literalKEY=VALUEfor 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. |
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
--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
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Background
AI agents such as Claude Code connect to remote databases through
alpacon execand 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--envinstead 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
--envwas supported by websh only; exec failed at parse time withunknown flag. Since AI agents primarily use the exec path, they had no way to move credentials off the command line.Changes
Add a shared
ParseEnvArghelper to the exec package that returns an error string instead of callingos.Exit, add an--envcase toParseRemoteExecArgs, and carry the values throughRemoteExecArgs.Env.cmd/exec/exec.gopassesparsed.EnvintoSubmitCommand/RunExecWithApprovalWaitinstead 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.--envdetection matches only the--envand--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
--envparser is unified: websh's--envcase delegates to the sameParseEnvArg, so both commands accept identical forms and produce identical malformed-input errors.ParseEnvArgis a pure parser—it returns the error string, and bothParseRemoteExecArgs(exec) andParseWebshArgs(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 ontoRemoteExecArgsand callsRunRemoteExec. As a result, running a command through websh behaves likealpacon exec: same output, exit-code propagation, sudo-approval handling (including the pending-approval exit code 4 and the presence step-up), and--output jsonbuffering. websh does not parse exec-only flags such as--detach/--wait, so itsWaitis always false and the blocking approval wait never runs; the interactivewebsh SERVERpath (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 localexportthat 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
Envmap; it never enters theShellJoin/Commandstring, stdout, or error/warning messages. The unset-variable warning and the malformed-input error print only the key name, never the value.reRunHintreconstructs the invocation for the pending-approval message and now includes the--envkeys so a re-run stays faithful—but it emits--env=KEYonly, 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.
PGPASSWORDwithpsqlreads the value from the environment rather than argv, so it lands neither in the remote process's argv (visible viaps) nor in terminal output / the session recording.One behavior change: in websh, malformed
--envinput (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
--envparsing in bothParseRemoteExecArgsandParseWebshArgs: both forms (KEY=VALUE, shell-sourcedKEY), quote unwrapping, unset-variable skip, multiple flags accumulating, empty value, malformed input, and--envafter 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--envmatching (TestParseRemoteExecArgs_EnvPrefixIsExact,TestParseWebshArgs_EnvPrefixIsExact), and the keys-only rerun hint (TestReRunHint).Manual end-to-end run against a live workspace (server
web-editor):--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=2accumulates; an unset key warns and is skipped.--envinjection works, andexit 7propagates as exit code 7 through bothexecandwebsh;--output jsonproduces identical output.go build ./...,go test -race ./cmd/exec/... ./cmd/websh/..., andgolangci-lint run ./...all pass.Checklist
go build ./...passesgo test -race ./cmd/exec/... ./cmd/websh/...passesgolangci-lint run ./...reports 0 issuesCloses #263