diff --git a/internal/api/wrap.go b/internal/api/wrap.go index 4e7c276e..6bd9d692 100644 --- a/internal/api/wrap.go +++ b/internal/api/wrap.go @@ -189,7 +189,16 @@ func (a *App) wrapInitCore(s *session.Session, sessionID string, req types.WrapI // macOS intercepts through the system extension rather than a per-process // wrapper, so it takes a different path entirely and returns here. if runtime.GOOS == "darwin" { - return platformWrapInit(a, sessionID, req) + resp, status, err := platformWrapInit(a, sessionID, req) + if err != nil { + return resp, status, err + } + // The client applies these to the agent's environment, as on Linux. + // Before, macOS sent neither, so env_policy and env_inject did + // nothing under wrap. + resp.EnvInject = mergeEnvInject(a.cfg, a.policyEngineFor(s)) + resp.EnvPolicy = a.wrapEnvPolicyWire(s, req) + return resp, status, nil } // Only supported on Linux (seccomp) otherwise diff --git a/internal/cli/wrap_darwin.go b/internal/cli/wrap_darwin.go index 9a5c7913..250ab27b 100644 --- a/internal/cli/wrap_darwin.go +++ b/internal/cli/wrap_darwin.go @@ -8,6 +8,8 @@ import ( "os" "syscall" + "github.com/diffsec/agentmon/internal/envinject" + "github.com/diffsec/agentmon/internal/wrapenv" "github.com/diffsec/agentmon/pkg/types" ) @@ -16,11 +18,15 @@ import ( // The wrap command registers the session with the server, which configures the ESF client // to intercept execs from this session's process tree. func platformSetupWrap(ctx context.Context, wrapResp types.WrapInitResponse, sessID string, agentPath string, agentArgs []string, cfg *clientConfig) (*wrapLaunchConfig, error) { + // env_policy filters the inherited environment, and env_inject is laid + // over the result, in the same order as on Linux. + env := envinject.Apply( + buildWrapEnv(wrapenv.Filter(os.Environ(), wrapResp.EnvPolicy), sessID, cfg.serverAddr, wrapResp.SafeToBypassShellShim), + wrapResp.EnvInject) + if wrapResp.WrapperBinary == "" { // No wrapper needed on macOS — ES interception is handled by System Extension. // The agent runs directly, and the ESF client intercepts its execs. - env := buildWrapEnv(os.Environ(), sessID, cfg.serverAddr, wrapResp.SafeToBypassShellShim) - return &wrapLaunchConfig{ command: agentPath, args: agentArgs, @@ -33,7 +39,6 @@ func platformSetupWrap(ctx context.Context, wrapResp types.WrapInitResponse, ses // If the server returns a wrapper binary (e.g., agentmon-macwrap for sandboxing), // use it as the launch command. - env := buildWrapEnv(os.Environ(), sessID, cfg.serverAddr, wrapResp.SafeToBypassShellShim) for k, v := range wrapResp.WrapperEnv { env = append(env, fmt.Sprintf("%s=%s", k, v)) } diff --git a/internal/cli/wrap_darwin_test.go b/internal/cli/wrap_darwin_test.go new file mode 100644 index 00000000..1f158a20 --- /dev/null +++ b/internal/cli/wrap_darwin_test.go @@ -0,0 +1,35 @@ +//go:build darwin + +package cli + +import ( + "context" + "slices" + "testing" + + "github.com/diffsec/agentmon/pkg/types" +) + +// Before, macOS wrap passed the inherited environment unchanged: the server +// sent no env_policy or env_inject, and the client applied neither. +func TestPlatformSetupWrapAppliesEnvPolicyAndInject(t *testing.T) { + t.Setenv("WRAPTEST_SECRET", "s3cret") + t.Setenv("WRAPTEST_KEEP", "yes") + resp := types.WrapInitResponse{ + EnvPolicy: &types.EnvPolicyWire{Deny: []string{"WRAPTEST_SECRET"}}, + EnvInject: map[string]string{"WRAPTEST_INJECTED": "1", "WRAPTEST_KEEP": "injected"}, + } + lc, err := platformSetupWrap(context.Background(), resp, "sess-1", "/bin/echo", nil, &clientConfig{serverAddr: "http://127.0.0.1:1"}) + if err != nil { + t.Fatal(err) + } + if slices.Contains(lc.env, "WRAPTEST_SECRET=s3cret") { + t.Error("env_policy deny did not remove WRAPTEST_SECRET") + } + if !slices.Contains(lc.env, "WRAPTEST_INJECTED=1") { + t.Error("env_inject did not add WRAPTEST_INJECTED") + } + if !slices.Contains(lc.env, "WRAPTEST_KEEP=injected") || slices.Contains(lc.env, "WRAPTEST_KEEP=yes") { + t.Error("env_inject did not replace WRAPTEST_KEEP") + } +} diff --git a/scripts/test-macos-wrap-env.sh b/scripts/test-macos-wrap-env.sh new file mode 100755 index 00000000..7d93df08 --- /dev/null +++ b/scripts/test-macos-wrap-env.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Hardware test: `agentmon wrap` applies sandbox.env_inject and env_policy to +# the agent's environment on macOS, as it does on Linux. +# +# Before build 1684, the macOS wrap-init reply carried neither, so the agent +# got the caller's environment unchanged. +# +# The daemon configuration needs an env_inject entry. The shipped +# configuration injects BASH_ENV. The env_policy check runs only when +# sandbox.wrap_env_policy.enabled is true. It then expects agent-default to +# remove AWS_SECRET_ACCESS_KEY. +# +# Run it on a Mac where AgentMon.app is installed, its extension is enabled, +# and the daemon is running. Run it before each release. +set -euo pipefail + +A=${AGENTMON:-/Applications/AgentMon.app/Contents/MacOS/agentmon} +CONFIG=${AGENTMON_CONFIG:-"$HOME/Library/Application Support/agentmon/config.yml"} +PY=${PYTHON:-} +if [ -z "$PY" ]; then + for p in /opt/homebrew/bin/python3 /usr/local/bin/python3; do + if [ -x "$p" ]; then PY=$p; break; fi + done +fi +[ -n "$PY" ] || { echo "no python3 found; set PYTHON" >&2; exit 2; } + +WS=$(mktemp -d "${TMPDIR:-/tmp}/amenv.XXXXXX") +WS=$(cd "$WS" && pwd -P) +status=0 +trap 'rm -rf "$WS"' EXIT + +ok() { echo "ok $*"; } +fail() { echo "FAIL $*"; status=1; } + +# The first env_inject key in the daemon configuration, and its value. +read -r KEY VAL < <(awk ' + /^[^ #]/ { insb = ($0 ~ /^sandbox:/) } + insb && /^ env_inject:/ { ine = 1; next } + ine && /^ [A-Za-z_][A-Za-z0-9_]*:/ { + k = $1; sub(":", "", k); v = $2; gsub("\"", "", v); print k, v; exit } + ine && /^ [^ ]/ { ine = 0 } +' "$CONFIG") || true +[ -n "${KEY:-}" ] || { echo "no sandbox.env_inject entry in $CONFIG" >&2; exit 2; } + +got=$(cd "$WS" && env "$KEY=caller-value" AWS_SECRET_ACCESS_KEY=caller-secret \ + "$A" wrap --policy agent-default --report=false -- \ + "$PY" -c "import os; print(os.environ.get('$KEY', '-'), os.environ.get('AWS_SECRET_ACCESS_KEY', '-'))" \ + 2>>"$WS/err" | tail -n 1) || true +read -r injected secret <<<"$got" + +if [ "${injected:-}" = "$VAL" ]; then + ok "env_inject sets $KEY=$VAL under wrap" +else + fail "$KEY under wrap: got '${injected:-}', want '$VAL'" +fi + +if awk '/^ wrap_env_policy:/ { w = 1; next } w && /enabled: *true/ { f = 1 } w && /^ [^ ]/ { w = 0 } END { exit !f }' "$CONFIG"; then + if [ "${secret:-}" = "-" ]; then + ok "env_policy removes AWS_SECRET_ACCESS_KEY under wrap" + else + fail "AWS_SECRET_ACCESS_KEY under wrap: got '${secret:-}', want it removed" + fi +else + echo "skip env_policy: sandbox.wrap_env_policy.enabled is not true" +fi + +if [ $status -ne 0 ] && [ -s "$WS/err" ]; then + echo "wrap stderr:" + sed 's/^/ /' "$WS/err" +fi +exit $status