diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 6865c10571..d6f02b4422 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -30,13 +30,25 @@ state as before; confirmation does not widen trusted path-removal authority. Credential-free removal remains local-only, with or without a saved URL. `TestInstallSHUninstallRetainsCredentialsWhenSavedURLIsMissing` runs the -production admission, credential-presence, record-confirmation and initial -state-directory cleanup functions through `agent_uninstall_state.py`. Its -seventeen confined cases use private files and service/transport doubles, -not a privileged installer or native service manager. These checks do not +production argument parser, token-file ingress, admission, credential-presence, +record-confirmation and initial state-directory cleanup through +`agent_uninstall_state.py`. Its forty-eight confined cases use private files +and service/transport doubles, not a privileged installer or native service +manager. The legacy shell reader's root ownership is projected onto the private +fixture owner; root refusal runs as the ordinary proof user. These checks do not establish Windows, actual TLS, saved-state discovery, a published fix or whole-issue installed removal acceptance. +Reading `--token-file` is read-only, including in non-interactive mode and +before permission checks. Supplying a credential does not grant ownership of +its file: a missing URL, root refusal, refused or unknown server result, or +identity mismatch cannot erase it. Confirmed removal retains a caller-owned +file outside the authorised runtime state directory; tokens inside that state +are removed only by its existing post-confirmation path-authorised cleanup. +Installer-created temporary credentials still belong to the installer cleanup +trap. Generated setup commands already remove their own private bootstrap +files; this does not remove that caller-owned cleanup or widen token/TLS trust. + ### Selected agent credential files retain their own authority diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 76208eb687..90b59db6ed 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -30,13 +30,25 @@ state as before; confirmation does not widen trusted path-removal authority. Credential-free removal remains local-only, with or without a saved URL. `TestInstallSHUninstallRetainsCredentialsWhenSavedURLIsMissing` runs the -production admission, credential-presence, record-confirmation and initial -state-directory cleanup functions through `agent_uninstall_state.py`. Its -seventeen confined cases use private files and service/transport doubles, -not a privileged installer or native service manager. These checks do not +production argument parser, token-file ingress, admission, credential-presence, +record-confirmation and initial state-directory cleanup through +`agent_uninstall_state.py`. Its forty-eight confined cases use private files +and service/transport doubles, not a privileged installer or native service +manager. The legacy shell reader's root ownership is projected onto the private +fixture owner; root refusal runs as the ordinary proof user. These checks do not establish Windows, actual TLS, saved-state discovery, a published fix or whole-issue installed removal acceptance. +Reading `--token-file` is read-only, including in non-interactive mode and +before permission checks. Supplying a credential does not grant ownership of +its file: a missing URL, root refusal, refused or unknown server result, or +identity mismatch cannot erase it. Confirmed removal retains a caller-owned +file outside the authorised runtime state directory; tokens inside that state +are removed only by its existing post-confirmation path-authorised cleanup. +Installer-created temporary credentials still belong to the installer cleanup +trap. Generated setup commands already remove their own private bootstrap +files; this does not remove that caller-owned cleanup or widen token/TLS trust. + ### Docker server update plans preserve the saved deployment diff --git a/scripts/install.sh b/scripts/install.sh index 9ed853a20f..429bb241f1 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -588,7 +588,7 @@ Options: --retarget Point an existing agent at --url using saved identity and token --uninstall Remove the agent --non-interactive Skip TTY prompts (for automated/scripted installs) - --token-file Read token from file (alternative to --token) + --token-file Read token from a private file; the caller owns its cleanup --pulse-url Alias for --url --preflight-only Run preflight checks and exit (no install) --output Output format: text (default) or json @@ -5214,10 +5214,9 @@ if [[ -n "$TOKEN_FILE_PATH" ]]; then if [[ -z "$PULSE_TOKEN" ]]; then fail "Token file must be a readable private regular file containing one bounded token: ${TOKEN_FILE_PATH}" "$EXIT_MISSING_ARGS" fi - # Clean up token file after reading in non-interactive mode (deploy bootstrap tokens are one-time use) - if [[ "$NON_INTERACTIVE" == "true" ]]; then - rm -f "$TOKEN_FILE_PATH" 2>/dev/null || true - fi + # Reading a caller-supplied credential does not grant ownership of its + # file. Keep it available after refusal and recovery; callers clean up + # their own bootstrap files, while the installer trap owns only TMP_FILES. fi if [[ -n "$ACTION_TOKEN_FILE_PATH" ]]; then diff --git a/scripts/installtests/agent_uninstall_state.py b/scripts/installtests/agent_uninstall_state.py index 530f5c4693..0f2b20e35a 100644 --- a/scripts/installtests/agent_uninstall_state.py +++ b/scripts/installtests/agent_uninstall_state.py @@ -4,7 +4,9 @@ Production credential detection, record confirmation and directory-authority functions remain intact. Only transport and service/process operations are private doubles; deletion is confined to three owned temporary directories. -These controls are not native installation, TLS or provider acceptance. +The argument parser and token-file ingress also execute before the removal gate. +Root-trust is projected onto the private fixture owner for the shell reader; +these controls are not native installation, TLS or provider acceptance. """ import os @@ -38,7 +40,9 @@ def snapshot(root): class UnixUninstallState(unittest.TestCase): source = SOURCE.read_text() - def exercise(self, credential="runtime", url="", reply="confirmed", authority=True): + def exercise(self, credential="runtime", url="", reply="confirmed", authority=True, + input_token=False, interactive=False, root_refusal=False, action="uninstall", + ingress_only=False): source = self.source # Keep the real entry and all initial credential/service/state teardown. # Stop before unrelated platform boot/log cleanup; never run it on a host. @@ -73,6 +77,26 @@ class UnixUninstallState(unittest.TestCase): path.write_text("" if credential == "empty" else "synthetic-collector-credential\n") path.chmod(0 if credential == "unreadable" else 0o600) before = snapshot(root) + ingress = "" + arguments = [] + if input_token: + parser = source.split("# --- Parse Arguments ---\n", 1)[1].split( + '\nif [[ "$RETARGET_ONLY"', 1)[0] + token_reader = source.split("# Read token from file if --token-file was provided\n", 1)[1].split( + '\nif [[ -n "$ACTION_TOKEN_FILE_PATH" ]]', 1)[0] + ingress = parser + "\n" + token_reader + arguments = {"uninstall": ["--uninstall"], "install": [], + "update": ["--update"], "runner": ["--uninstall-action-runner"]}[action] + arguments += ["--token-file", str(path)] + if not interactive: + arguments.append("--non-interactive") + if url: + arguments += ["--url", url] + if root_refusal: + if os.geteuid() == 0: + self.fail("root-refusal control requires the ordinary proof user") + ingress += "\n" + source.split("# --- Check Root ---\n", 1)[1].split( + '\nif [[ "$SAFE_PROFILE_ACTION" == "inspect" ]]', 1)[0] values = {"STATE_DIR": state, "PRIVILEGED_HELPER_STATE_DIR": helper, "PRIVILEGED_HELPER_CREDENTIAL_DIR": helper, "SAFE_PROFILE_STATE_DIR": profile, "STATE_DIR_REMOVAL_AUTHORITY": state if authority else "", @@ -83,14 +107,28 @@ class UnixUninstallState(unittest.TestCase): "EVENTS": root / "events", "SERVICE": service, "REPLY": reply} setup = "\n".join(f"{key}={shlex.quote(str(value))}" for key, value in values.items()) functions = "\n".join(function(source, name) for name in names) + if input_token: + functions += "\n" + function(source, "read_collector_token_file_safely") # A whitelist protects the fixture even if production cleanup changes. shell = r''' set -euo pipefail UNINSTALL=true AGENT_NAME=pulse-agent EXIT_GENERAL=1 EXIT_MISSING_ARGS=16 OUTPUT_FORMAT=text NON_INTERACTIVE=true +TOKEN_FILE_PATH="" UNINSTALL_ACTION_RUNNER=false UPDATE_ONLY=false +RETARGET_ONLY=false PREFLIGHT_ONLY=false SAFE_PROFILE_ACTION="" log_info() { :; } log_warn() { printf 'warning:%s\n' "$*"; } log_error() { printf 'error:%s\n' "$*"; } +# Project only the fixture's ownership onto the legacy root-file boundary. +# The production reader still performs its ordinary bounded read. +collector_lifecycle_binary() { return 1; } +trusted_private_lifecycle_regular_file() { + [[ -f "$1" && ! -L "$1" ]] || return 1 + local mode="" owner="" + mode=$(stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1") || return 1 + owner=$(stat -c '%u' "$1" 2>/dev/null || stat -f '%u' "$1") || return 1 + [[ "$owner" == "$EUID" && "$mode" =~ ^[0-7]{3,4}$ ]] && (( (8#$mode & 8#077) == 0 )) +} recover_agent_id_from_state_file() { printf 'owned-agent\n'; } run_collector_lifecycle_command() { printf 'request:%s\n' "$*" >> "$EVENTS" @@ -109,14 +147,25 @@ teardown_action_runner_service() { printf 'runner-stop\n' >> "$EVENTS"; } teardown_privileged_helper_service() { printf 'helper-stop\n' >> "$EVENTS"; } teardown_systemd_agent_service() { printf 'service-stop\n' >> "$EVENTS"; printf 'stopped\n' > "$SERVICE"; } rm() { + if [[ $# == 2 && "$1" == -f && -n "$TOKEN_FILE_PATH" && "$2" == "$TOKEN_FILE_PATH" ]]; then + [[ "$2" == "$RUNTIME_TOKEN_FILE" || "$2" == "$STATE_DIR/runtime.token" || "$2" == "$STATE_DIR/token" ]] || return 94 + printf 'input-token-remove\n' >> "$EVENTS" + command rm "$@" + return + fi [[ $# == 3 && "$1" == -rf && "$2" == -- ]] || return 92 [[ "$3" == "$STATE_DIR" || "$3" == "$PRIVILEGED_HELPER_STATE_DIR" || "$3" == "$SAFE_PROFILE_STATE_DIR" ]] || return 93 printf 'state-remove\n' >> "$EVENTS" command rm "$@" } ''' + invocation = "" + if input_token: + invocation = "UNINSTALL=false NON_INTERACTIVE=false\nset -- " + shlex.join(arguments) + "\n" + ingress + "\n" + if ingress_only: + invocation += "exit 0\n" result = subprocess.run(["bash", "-c", shell + "\n" + setup + "\n" + functions + - "\nmain() {\n" + entry + "}\nmain\n"], + "\nmain() {\n" + invocation + entry + "}\nmain\n"], capture_output=True, text=True, timeout=5) events = (root / "events").read_text() if (root / "events").exists() else "" after = snapshot(root) @@ -169,6 +218,67 @@ rm() { for name in ("state", "state/runtime.token", "state/agent-id", "state/connection.env"): self.assertEqual(after[name], before[name]) + def assert_retained(self, result, events, before, after, expected_exit, expected_events=""): + self.assertEqual(result.returncode, expected_exit, result.stdout + result.stderr) + self.assertNotIn("synthetic-collector-credential", result.stdout + result.stderr) + self.assertEqual(events, expected_events) + self.assertEqual(after, before) + + def test_noninteractive_input_token_survives_missing_url_refusal(self): + for kind in ("selected", "runtime", "legacy"): + with self.subTest(credential=kind): + result, events, before, after = self.exercise(kind, input_token=True) + self.assertIn("same trusted Pulse instance", result.stdout) + self.assert_retained(result, events, before, after, 16) + + def test_noninteractive_input_token_survives_unconfirmed_removal(self): + for kind in ("selected", "runtime", "legacy"): + for reply in ("refused", "lost-response", "empty", "mismatch"): + with self.subTest(credential=kind, reply=reply): + result, events, before, after = self.exercise( + kind, url="https://pulse.invalid", reply=reply, input_token=True) + self.assert_retained(result, events, before, after, 1, + "request:collector-uninstall --agent-id owned-agent --hostname owned-host\n") + + def test_confirmed_removal_retains_caller_owned_input_token(self): + result, events, before, after = self.exercise( + "selected", url="https://pulse.invalid", input_token=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(events.splitlines()[0], + "request:collector-uninstall --agent-id owned-agent --hostname owned-host") + self.assertNotIn("input-token-remove", events) + self.assertEqual(set(after), {"service", "unrelated", "selected-token"}) + self.assertEqual(after["selected-token"], before["selected-token"]) + self.assertEqual(after["unrelated"], before["unrelated"]) + + def test_confirmed_removal_deletes_runtime_token_only_with_authorised_state(self): + for kind in ("runtime", "legacy"): + with self.subTest(credential=kind): + result, events, before, after = self.exercise( + kind, url="https://pulse.invalid", input_token=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(events.splitlines()[0], + "request:collector-uninstall --agent-id owned-agent --hostname owned-host") + self.assertNotIn("input-token-remove", events) + self.assertEqual(set(after), {"service", "unrelated"}) + self.assertEqual(after["unrelated"], before["unrelated"]) + + def test_noninteractive_input_token_survives_root_refusal(self): + for action in ("uninstall", "install", "update", "runner"): + for kind in ("selected", "runtime", "legacy"): + with self.subTest(action=action, credential=kind): + result, events, before, after = self.exercise( + kind, input_token=True, root_refusal=True, action=action) + self.assertIn("This script must be run as root", result.stdout) + self.assert_retained(result, events, before, after, 1) + + def test_interactive_input_token_is_read_only_at_ingress(self): + # Compare interactive ingress without invoking the unrelated TTY exit + # prompt in a subprocess with no controlling terminal. + result, events, before, after = self.exercise( + "selected", input_token=True, interactive=True, ingress_only=True) + self.assert_retained(result, events, before, after, 0) + if __name__ == "__main__": unittest.main() diff --git a/scripts/installtests/install_sh_test.go b/scripts/installtests/install_sh_test.go index 1feefce099..e2b05197ef 100644 --- a/scripts/installtests/install_sh_test.go +++ b/scripts/installtests/install_sh_test.go @@ -8289,12 +8289,18 @@ func TestInstallSHUninstallRetainsCredentialsWhenSavedURLIsMissing(t *testing.T) "test_exact_confirmation_precedes_process_service_and_state_teardown", "test_credential_free_local_only_removal_needs_no_url_or_request", "test_server_confirmation_does_not_grant_unknown_path_removal", + "test_noninteractive_input_token_survives_missing_url_refusal", + "test_noninteractive_input_token_survives_unconfirmed_removal", + "test_confirmed_removal_retains_caller_owned_input_token", + "test_confirmed_removal_deletes_runtime_token_only_with_authorised_state", + "test_noninteractive_input_token_survives_root_refusal", + "test_interactive_input_token_is_read_only_at_ingress", } { if !strings.Contains(string(output), name+" (__main__.UnixUninstallState."+name+") ... ok") { t.Fatalf("Unix removal control %s did not pass:\n%s", name, output) } } - if !strings.Contains(string(output), "\nRan 5 tests in ") || !strings.Contains(string(output), "\nOK\n") { + if !strings.Contains(string(output), "\nRan 11 tests in ") || !strings.Contains(string(output), "\nOK\n") { t.Fatalf("incomplete Unix removal state verdict:\n%s", output) } t.Logf("%s", output)