diff --git a/README.md b/README.md index 5314a7d..8518ad8 100644 --- a/README.md +++ b/README.md @@ -690,18 +690,14 @@ basectl doctor --profile ai ``` AI coding tools are intentionally not part of the plain `dev` or `sre` profile. -`basectl setup --profile ai` runs the official Codex CLI installer from -`https://chatgpt.com/codex/install.sh` and the official Claude Code installer -from `https://claude.ai/install.sh` only when that profile is explicitly -requested. Base checks tool presence and version output, but it does not manage -accounts, credentials, model access, or organization policy. - -If Homebrew is missing, `basectl setup` uses Homebrew's official installer URL -at `https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh`. This is -a deliberate trust decision: Base stays aligned with Homebrew's supported -bootstrap command instead of pinning and maintaining a reviewed installer -commit. Teams that require stricter supply-chain controls should install -Homebrew through their managed device process before running Base. +`basectl setup --profile ai` uses official remote installers only when that +profile is explicitly requested. Base checks tool presence and version output, +but it does not manage accounts, credentials, model access, or organization +policy. + +For the allowed Homebrew, Codex CLI, and Claude Code installer URLs, dry-run +behavior, non-interactive behavior, and managed-device guidance, see +[Remote Installer Policy](docs/remote-installer-policy.md). On macOS, `basectl setup` sends a best-effort notification when setup completes or fails after running for at least 30 seconds. Notifications are skipped during diff --git a/cli/bash/commands/basectl/subcommands/setup_common.sh b/cli/bash/commands/basectl/subcommands/setup_common.sh index d39ac72..11a0dea 100644 --- a/cli/bash/commands/basectl/subcommands/setup_common.sh +++ b/cli/bash/commands/basectl/subcommands/setup_common.sh @@ -453,7 +453,7 @@ setup_install_homebrew() { fi if setup_is_dry_run; then - log_info "[DRY-RUN] Would install Homebrew using the official installer." + log_info "[DRY-RUN] Would run: /bin/bash -c " return 0 fi diff --git a/cli/bash/commands/basectl/tests/setup.bats b/cli/bash/commands/basectl/tests/setup.bats index 391e41e..399b350 100644 --- a/cli/bash/commands/basectl/tests/setup.bats +++ b/cli/bash/commands/basectl/tests/setup.bats @@ -610,7 +610,7 @@ EOF run_base_command setup --dry-run [ "$status" -eq 0 ] - [[ "$output" == *"[DRY-RUN] Would install Homebrew using the official installer."* ]] + [[ "$output" == *"[DRY-RUN] Would run: /bin/bash -c "* ]] [[ "$output" == *"[DRY-RUN] Would install Xcode Command Line Tools and wait for installation to complete."* ]] [[ "$output" == *"[DRY-RUN] Would install Python formula 'python@3.13' via Homebrew."* ]] [[ "$output" != *"BATS formula 'bats-core'"* ]] diff --git a/cli/python/base_dev/engine.py b/cli/python/base_dev/engine.py index b5619ae..a5bab91 100644 --- a/cli/python/base_dev/engine.py +++ b/cli/python/base_dev/engine.py @@ -41,7 +41,8 @@ class AITool: name: str display_name: str version_args: tuple[str, ...] - installer_command: tuple[str, ...] + installer_url: str + installer_shell: str class ProfileError(ValueError): @@ -53,15 +54,21 @@ class ProfileError(ValueError): name="codex", display_name="Codex CLI", version_args=("--version",), - installer_command=("sh", "-c", "curl -fsSL https://chatgpt.com/codex/install.sh | sh"), + installer_url="https://chatgpt.com/codex/install.sh", + installer_shell="sh", ), AITool( name="claude", display_name="Claude Code", version_args=("--version",), - installer_command=("sh", "-c", "curl -fsSL https://claude.ai/install.sh | bash"), + installer_url="https://claude.ai/install.sh", + installer_shell="bash", ), ) +AI_REMOTE_INSTALLER_ALLOWLIST = ( + "https://chatgpt.com/codex/install.sh", + "https://claude.ai/install.sh", +) def main(argv: list[str] | None = None) -> int: @@ -243,11 +250,13 @@ def setup_ai_tools(ctx: base_cli.Context, dry_run: bool) -> int: if check.ok: ctx.log.info("%s", check.message) continue + installer_command = ai_tool_installer_command(tool) + log_ai_remote_installer_policy(ctx, tool) ctx.log.info("Installing %s.", tool.display_name) if dry_run: - dry_run_command(ctx, list(tool.installer_command)) + dry_run_command(ctx, list(installer_command)) else: - run_command(ctx, list(tool.installer_command)) + run_command(ctx, list(installer_command)) except ArtifactError as exc: ctx.log.error(str(exc)) return 1 @@ -256,6 +265,31 @@ def setup_ai_tools(ctx: base_cli.Context, dry_run: bool) -> int: return 0 +def ai_remote_installer_urls() -> tuple[str, ...]: + return AI_REMOTE_INSTALLER_ALLOWLIST + + +def validate_ai_remote_installer(tool: AITool) -> None: + if tool.installer_url not in AI_REMOTE_INSTALLER_ALLOWLIST: + raise ArtifactError( + "Remote installer URL is not allowlisted for Base 'ai' profile: " + f"{tool.installer_url}" + ) + + +def ai_tool_installer_command(tool: AITool) -> tuple[str, ...]: + validate_ai_remote_installer(tool) + return ("sh", "-c", f"curl -fsSL {tool.installer_url} | {tool.installer_shell}") + + +def log_ai_remote_installer_policy(ctx: base_cli.Context, tool: AITool) -> None: + ctx.log.info( + "Remote installer policy: %s uses allowlisted installer %s; execution requires explicit --profile ai.", + tool.display_name, + tool.installer_url, + ) + + def check_profile_manifests( ctx: base_cli.Context, profile_manifests: tuple[ProfileManifest, ...], diff --git a/cli/python/base_dev/tests/test_engine.py b/cli/python/base_dev/tests/test_engine.py index dbd1291..016da8f 100644 --- a/cli/python/base_dev/tests/test_engine.py +++ b/cli/python/base_dev/tests/test_engine.py @@ -91,6 +91,22 @@ def test_normalize_profiles_rejects_empty_profile_list_entries(self) -> None: with self.assertRaisesRegex(engine.ProfileError, "Profile list must not contain empty entries"): engine.normalize_profiles(("dev,,sre",)) + def test_ai_remote_installer_urls_are_allowlisted(self) -> None: + self.assertEqual( + engine.ai_remote_installer_urls(), + ( + "https://chatgpt.com/codex/install.sh", + "https://claude.ai/install.sh", + ), + ) + self.assertEqual( + [engine.ai_tool_installer_command(tool) for tool in engine.AI_TOOLS], + [ + ("sh", "-c", "curl -fsSL https://chatgpt.com/codex/install.sh | sh"), + ("sh", "-c", "curl -fsSL https://claude.ai/install.sh | bash"), + ], + ) + @unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") def test_setup_profile_sre_uses_sre_manifest(self) -> None: status, _stdout, stderr = run_engine(["setup", "--profile", "sre", "--dry-run"]) @@ -110,6 +126,16 @@ def test_setup_profile_ai_dry_run_prints_official_installers(self) -> None: ) self.assertEqual(status, 0) + self.assertIn( + "Remote installer policy: Codex CLI uses allowlisted installer " + "https://chatgpt.com/codex/install.sh; execution requires explicit --profile ai.", + stderr, + ) + self.assertIn( + "Remote installer policy: Claude Code uses allowlisted installer " + "https://claude.ai/install.sh; execution requires explicit --profile ai.", + stderr, + ) self.assertIn( "[DRY-RUN] Would run: sh -c 'curl -fsSL https://chatgpt.com/codex/install.sh | sh'", stderr, @@ -137,6 +163,54 @@ def test_setup_profile_ai_skips_installed_tools(self) -> None: self.assertNotIn("chatgpt.com/codex/install.sh", stderr) self.assertNotIn("claude.ai/install.sh", stderr) + @unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") + def test_setup_default_dry_run_does_not_include_ai_remote_installers(self) -> None: + status, _stdout, stderr = run_engine(["setup", "--dry-run"]) + + self.assertEqual(status, 0) + self.assertNotIn("chatgpt.com/codex/install.sh", stderr) + self.assertNotIn("claude.ai/install.sh", stderr) + + def test_setup_ai_tools_rejects_unallowlisted_remote_installer(self) -> None: + tool = engine.AITool( + name="bad-ai", + display_name="Bad AI", + version_args=("--version",), + installer_url="https://example.invalid/install.sh", + installer_shell="sh", + ) + ctx = mock.Mock() + + with ( + mock.patch("base_dev.engine.AI_TOOLS", (tool,)), + mock.patch("base_dev.engine.check_ai_tool", return_value=engine.DevCheck("bad-ai", False, "missing", "")), + mock.patch("base_dev.engine.run_command") as run_command, + ): + status = engine.setup_ai_tools(ctx, dry_run=False) + + self.assertEqual(status, 1) + self.assertIn("Remote installer URL is not allowlisted", ctx.log.error.call_args.args[0]) + run_command.assert_not_called() + + def test_setup_ai_tools_noninteractive_explicit_profile_runs_allowlisted_installers(self) -> None: + ctx = mock.Mock() + + with ( + mock.patch.dict(os.environ, {"CI": "true"}), + mock.patch("base_dev.engine.check_ai_tool", return_value=engine.DevCheck("tool", False, "missing", "")), + mock.patch("base_dev.engine.run_command") as run_command, + ): + status = engine.setup_ai_tools(ctx, dry_run=False) + + self.assertEqual(status, 0) + self.assertEqual( + [call.args[1] for call in run_command.call_args_list], + [ + ["sh", "-c", "curl -fsSL https://chatgpt.com/codex/install.sh | sh"], + ["sh", "-c", "curl -fsSL https://claude.ai/install.sh | bash"], + ], + ) + @unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") def test_check_profile_sre_reports_sre_fix_guidance(self) -> None: with ( diff --git a/docs/README.md b/docs/README.md index 75b8905..bc3a803 100644 --- a/docs/README.md +++ b/docs/README.md @@ -59,6 +59,9 @@ reference. The filename should answer "what is this about?" - [Repository Baseline](repo-baseline.md) documents `basectl repo init`, `basectl repo check`, and `basectl repo configure` for standardizing new Base-managed repositories. +- [Remote Installer Policy](remote-installer-policy.md) defines the allowed + remote shell installer URLs, opt-in boundaries, dry-run behavior, and logging + expectations for setup paths. - [Workspace Manifest](workspace-manifest.md) defines the future team-shared repo-set contract and its relationship to discovered local projects. - [Setup Hooks Boundary](setup-hooks.md) records why Base does not support diff --git a/docs/bootstrap.md b/docs/bootstrap.md index ed67b0e..812824e 100644 --- a/docs/bootstrap.md +++ b/docs/bootstrap.md @@ -94,12 +94,15 @@ AI coding tools stay behind an explicit opt-in profile: The `ai` profile installs Codex CLI and Claude Code with their official installers. Base checks tool availability and version output, but it does not -configure accounts, credentials, model access, or organization policy. +configure accounts, credentials, model access, or organization policy. See +[Remote Installer Policy](remote-installer-policy.md) for the allowed URLs, +dry-run behavior, non-interactive behavior, and managed-device guidance. ## Relationship To Other Install Paths Use `bootstrap.sh` when the machine may not have Homebrew, Git, or a supported -Bash yet. +Bash yet. Homebrew bootstrap follows the remote installer trust model described +in [Remote Installer Policy](remote-installer-policy.md). Use Homebrew directly when Homebrew is already installed and Base should be managed like a normal formula: diff --git a/docs/remote-installer-policy.md b/docs/remote-installer-policy.md new file mode 100644 index 0000000..38c7d4e --- /dev/null +++ b/docs/remote-installer-policy.md @@ -0,0 +1,43 @@ +# Remote Installer Policy + +Base may run remote shell installers only when they are defined by Base itself, +documented here, and reached through the setup surface that owns that trust +decision. + +## Allowed Remote Installers + +| Installer | URL | Where Base may use it | Opt-in | +| --- | --- | --- | --- | +| Homebrew | `https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh` | `bootstrap.sh`, `install.sh`, and `basectl setup` when Homebrew is missing on macOS | First-mile setup path; `bootstrap.sh --no-homebrew-install` can refuse this path | +| Codex CLI | `https://chatgpt.com/codex/install.sh` | `basectl setup --profile ai` | Explicit `--profile ai` | +| Claude Code | `https://claude.ai/install.sh` | `basectl setup --profile ai` | Explicit `--profile ai` | + +Project manifests cannot declare arbitrary remote shell installers. + +## Dry-Run And Non-Interactive Behavior + +`--dry-run` prints planned remote installer commands without downloading or +executing installer content. + +The `ai` profile does not prompt separately after `--profile ai` is selected. +That explicit profile flag is the opt-in boundary, so scripted and +non-interactive setup stays deterministic. + +## Managed Workstations And Pinned Installers + +Base intentionally follows Homebrew's official mutable installer entry point +instead of pinning a reviewed commit. Teams that require pinned, mirrored, or +managed installer content should install Homebrew and optional AI tools through +their workstation management system before running Base. + +Base does not yet provide a manifest field for pinned remote installers. + +## Logging And Redaction + +AI profile installers run through Base's Python command runner, which preserves +live output and writes redacted stdout/stderr tails to persistent logs and +failure summaries. + +Homebrew first-mile installers run before the Python setup layer may exist. +Their output is shown live by the shell installer path and is not rewritten by +Base. diff --git a/docs/superpowers/plans/2026-06-09-remote-installer-policy.md b/docs/superpowers/plans/2026-06-09-remote-installer-policy.md new file mode 100644 index 0000000..95984d3 --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-remote-installer-policy.md @@ -0,0 +1,373 @@ +# Remote Installer Policy Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Define and enforce Base's current remote installer trust policy for setup and prerequisite profiles. + +**Architecture:** Keep first-mile Homebrew trust documented in docs and shell dry-run output, while enforcing AI profile installer policy in `cli/python/base_dev/engine.py`. AI installer commands will be derived from structured metadata and an allowlist instead of being stored as opaque shell strings. + +**Tech Stack:** Bash setup/bootstrap scripts, Python `dataclasses`, existing `base_dev` profile layer, `unittest`, BATS, Markdown docs. + +--- + +## File Structure + +- Modify `cli/python/base_dev/engine.py`: add structured AI remote installer metadata, allowlist helpers, policy logging, and command derivation. +- Modify `cli/python/base_dev/tests/test_engine.py`: add failing tests for allowlist enforcement, dry-run policy output, non-interactive explicit opt-in, and default profile exclusion. +- Modify `cli/bash/commands/basectl/subcommands/setup_common.sh`: align `basectl setup --dry-run` Homebrew installer output with bootstrap/install URL-bearing dry-run output. +- Modify `cli/bash/commands/basectl/tests/setup.bats`: assert the URL-bearing Homebrew dry-run message. +- Add `docs/remote-installer-policy.md`: canonical user-facing policy. +- Modify `docs/README.md`: add the policy page to the documentation map. +- Modify `README.md`: replace the inline trust-policy paragraphs with a concise pointer to the policy page. +- Modify `docs/bootstrap.md`: link contributor setup and first-mile notes to the policy. +- Add `docs/superpowers/specs/2026-06-09-remote-installer-policy-design.md`: design record. +- Add `docs/superpowers/plans/2026-06-09-remote-installer-policy.md`: this implementation plan. + +## Task 1: Failing AI Policy Tests + +**Files:** +- Modify: `cli/python/base_dev/tests/test_engine.py` + +- [ ] **Step 1: Add a test for central AI installer policy metadata** + +Add to `DevManifestTests`: + +```python + def test_ai_remote_installer_urls_are_allowlisted(self) -> None: + self.assertEqual( + engine.ai_remote_installer_urls(), + ( + "https://chatgpt.com/codex/install.sh", + "https://claude.ai/install.sh", + ), + ) + self.assertEqual( + [engine.ai_tool_installer_command(tool) for tool in engine.AI_TOOLS], + [ + ("sh", "-c", "curl -fsSL https://chatgpt.com/codex/install.sh | sh"), + ("sh", "-c", "curl -fsSL https://claude.ai/install.sh | bash"), + ], + ) +``` + +- [ ] **Step 2: Add dry-run policy output and default-profile exclusion tests** + +Update `test_setup_profile_ai_dry_run_prints_official_installers` so it also +asserts policy context: + +```python + self.assertIn( + "Remote installer policy: Codex CLI uses allowlisted installer " + "https://chatgpt.com/codex/install.sh; execution requires explicit --profile ai.", + stderr, + ) + self.assertIn( + "Remote installer policy: Claude Code uses allowlisted installer " + "https://claude.ai/install.sh; execution requires explicit --profile ai.", + stderr, + ) +``` + +Add: + +```python + @unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") + def test_setup_default_dry_run_does_not_include_ai_remote_installers(self) -> None: + status, _stdout, stderr = run_engine(["setup", "--dry-run"]) + + self.assertEqual(status, 0) + self.assertNotIn("chatgpt.com/codex/install.sh", stderr) + self.assertNotIn("claude.ai/install.sh", stderr) +``` + +- [ ] **Step 3: Add allowlist rejection and non-interactive explicit opt-in tests** + +Add: + +```python + def test_setup_ai_tools_rejects_unallowlisted_remote_installer(self) -> None: + tool = engine.AITool( + name="bad-ai", + display_name="Bad AI", + version_args=("--version",), + installer_url="https://example.invalid/install.sh", + installer_shell="sh", + ) + ctx = mock.Mock() + + with ( + mock.patch("base_dev.engine.AI_TOOLS", (tool,)), + mock.patch("base_dev.engine.check_ai_tool", return_value=engine.DevCheck("bad-ai", False, "missing", "")), + mock.patch("base_dev.engine.run_command") as run_command, + ): + status = engine.setup_ai_tools(ctx, dry_run=False) + + self.assertEqual(status, 1) + self.assertIn("Remote installer URL is not allowlisted", ctx.log.error.call_args.args[0]) + run_command.assert_not_called() + + def test_setup_ai_tools_noninteractive_explicit_profile_runs_allowlisted_installers(self) -> None: + ctx = mock.Mock() + + with ( + mock.patch.dict(os.environ, {"CI": "true"}), + mock.patch("base_dev.engine.check_ai_tool", return_value=engine.DevCheck("tool", False, "missing", "")), + mock.patch("base_dev.engine.run_command") as run_command, + ): + status = engine.setup_ai_tools(ctx, dry_run=False) + + self.assertEqual(status, 0) + self.assertEqual( + [call.args[1] for call in run_command.call_args_list], + [ + ["sh", "-c", "curl -fsSL https://chatgpt.com/codex/install.sh | sh"], + ["sh", "-c", "curl -fsSL https://claude.ai/install.sh | bash"], + ], + ) +``` + +- [ ] **Step 4: Run focused tests and verify RED** + +Run: + +```bash +PYTHONPATH=lib/python:cli/python /Users/rameshhp/.base.d/base/.venv/bin/python -m unittest cli/python/base_dev/tests/test_engine.py +``` + +Expected: FAIL because `AITool` has no `installer_url`, `ai_remote_installer_urls()` +does not exist, and dry-run policy context is not emitted. + +## Task 2: AI Remote Installer Policy Implementation + +**Files:** +- Modify: `cli/python/base_dev/engine.py` + +- [ ] **Step 1: Replace opaque AI installer commands with structured metadata** + +Change `AITool` to: + +```python +@dataclass(frozen=True) +class AITool: + name: str + display_name: str + version_args: tuple[str, ...] + installer_url: str + installer_shell: str +``` + +Update `AI_TOOLS` so Codex uses `installer_url="https://chatgpt.com/codex/install.sh"` and +`installer_shell="sh"`, and Claude uses `installer_url="https://claude.ai/install.sh"` and +`installer_shell="bash"`. + +- [ ] **Step 2: Add allowlist and command helpers** + +Add: + +```python +AI_REMOTE_INSTALLER_ALLOWLIST = ( + "https://chatgpt.com/codex/install.sh", + "https://claude.ai/install.sh", +) + + +def ai_remote_installer_urls() -> tuple[str, ...]: + return AI_REMOTE_INSTALLER_ALLOWLIST + + +def validate_ai_remote_installer(tool: AITool) -> None: + if tool.installer_url not in AI_REMOTE_INSTALLER_ALLOWLIST: + raise ArtifactError( + "Remote installer URL is not allowlisted for Base 'ai' profile: " + f"{tool.installer_url}" + ) + + +def ai_tool_installer_command(tool: AITool) -> tuple[str, ...]: + validate_ai_remote_installer(tool) + return ("sh", "-c", f"curl -fsSL {tool.installer_url} | {tool.installer_shell}") +``` + +- [ ] **Step 3: Log policy context before installer execution** + +Add: + +```python +def log_ai_remote_installer_policy(ctx: base_cli.Context, tool: AITool) -> None: + ctx.log.info( + "Remote installer policy: %s uses allowlisted installer %s; execution requires explicit --profile ai.", + tool.display_name, + tool.installer_url, + ) +``` + +Update `setup_ai_tools()` so it computes `installer_command = ai_tool_installer_command(tool)`, +logs policy context, and then calls `dry_run_command()` or `run_command()` with +`list(installer_command)`. + +- [ ] **Step 4: Run focused tests and verify GREEN** + +Run: + +```bash +PYTHONPATH=lib/python:cli/python /Users/rameshhp/.base.d/base/.venv/bin/python -m unittest cli/python/base_dev/tests/test_engine.py +``` + +Expected: all tests in the file pass. + +## Task 3: Remote Installer Policy Documentation + +**Files:** +- Add: `docs/remote-installer-policy.md` +- Modify: `docs/README.md` +- Modify: `README.md` +- Modify: `docs/bootstrap.md` + +- [ ] **Step 1: Add the canonical policy page** + +Create `docs/remote-installer-policy.md` with: + +```markdown +# Remote Installer Policy + +Base may run remote shell installers only when they are defined by Base itself, +documented here, and reached through the setup surface that owns that trust +decision. + +## Allowed Remote Installers + +| Installer | URL | Where Base may use it | Opt-in | +| --- | --- | --- | --- | +| Homebrew | `https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh` | `bootstrap.sh`, `install.sh`, and `basectl setup` when Homebrew is missing on macOS | First-mile setup path; `bootstrap.sh --no-homebrew-install` can refuse this path | +| Codex CLI | `https://chatgpt.com/codex/install.sh` | `basectl setup --profile ai` | Explicit `--profile ai` | +| Claude Code | `https://claude.ai/install.sh` | `basectl setup --profile ai` | Explicit `--profile ai` | + +Project manifests cannot declare arbitrary remote shell installers. + +## Dry-Run And Non-Interactive Behavior + +`--dry-run` prints planned remote installer commands without downloading or +executing installer content. + +The `ai` profile does not prompt separately after `--profile ai` is selected. +That explicit profile flag is the opt-in boundary, so scripted and +non-interactive setup stays deterministic. + +## Managed Workstations And Pinned Installers + +Base intentionally follows Homebrew's official mutable installer entry point +instead of pinning a reviewed commit. Teams that require pinned, mirrored, or +managed installer content should install Homebrew and optional AI tools through +their workstation management system before running Base. + +Base does not yet provide a manifest field for pinned remote installers. + +## Logging And Redaction + +AI profile installers run through Base's Python command runner, which preserves +live output and writes redacted stdout/stderr tails to persistent logs and +failure summaries. + +Homebrew first-mile installers run before the Python setup layer may exist. +Their output is shown live by the shell installer path and is not rewritten by +Base. +``` + +- [ ] **Step 2: Link the policy from docs map, README, and bootstrap docs** + +In `docs/README.md`, add Remote Installer Policy to Feature And Boundary +Documents. + +In `README.md`, replace the inline AI/Homebrew trust paragraphs with a short +summary and link to `docs/remote-installer-policy.md`. + +In `docs/bootstrap.md`, link the AI profile and first-mile Homebrew discussion +to `remote-installer-policy.md`. + +- [ ] **Step 3: Run documentation whitespace check** + +Run: + +```bash +git diff --check +``` + +Expected: no output. + +## Task 4: Homebrew Setup Dry-Run Alignment + +**Files:** +- Modify: `cli/bash/commands/basectl/subcommands/setup_common.sh` +- Modify: `cli/bash/commands/basectl/tests/setup.bats` + +- [ ] **Step 1: Update the setup dry-run message** + +In `setup_install_homebrew()`, change the dry-run log line to: + +```bash +log_info "[DRY-RUN] Would run: /bin/bash -c " +``` + +- [ ] **Step 2: Update the setup Bats assertion** + +In `basectl setup supports dry-run without making changes`, assert: + +```bash +[[ "$output" == *"[DRY-RUN] Would run: /bin/bash -c "* ]] +``` + +- [ ] **Step 3: Run the focused Bats test file** + +Run: + +```bash +env -u BASE_HOME -u BASE_PROJECT -u BASE_PROJECT_ROOT -u BASE_PROJECT_MANIFEST -u BASE_PROJECT_VENV_DIR bats cli/bash/commands/basectl/tests/setup.bats +``` + +Expected: setup Bats tests pass. + +## Task 5: Full Validation And Commit + +**Files:** +- All changed files. + +- [ ] **Step 1: Run focused tests** + +Run: + +```bash +PYTHONPATH=lib/python:cli/python /Users/rameshhp/.base.d/base/.venv/bin/python -m unittest cli/python/base_dev/tests/test_engine.py +``` + +Expected: OK. + +- [ ] **Step 2: Run full Base validation** + +Run: + +```bash +env -u BASE_HOME ./bin/base-test +``` + +Expected: Python tests pass, BATS tests pass. + +- [ ] **Step 3: Run final diff check** + +Run: + +```bash +git diff --check +``` + +Expected: no output. + +- [ ] **Step 4: Commit implementation** + +Run: + +```bash +git add cli/python/base_dev/engine.py cli/python/base_dev/tests/test_engine.py docs/remote-installer-policy.md docs/README.md README.md docs/bootstrap.md +git commit -m "Define remote installer setup policy" +``` + +Expected: commit succeeds. diff --git a/docs/superpowers/specs/2026-06-09-remote-installer-policy-design.md b/docs/superpowers/specs/2026-06-09-remote-installer-policy-design.md new file mode 100644 index 0000000..54c5613 --- /dev/null +++ b/docs/superpowers/specs/2026-06-09-remote-installer-policy-design.md @@ -0,0 +1,81 @@ +# Remote Installer Policy Design + +Issue: #513 + +## Goal + +Base should have one explicit policy for remote shell installers used during +bootstrap and prerequisite profile setup. Users should be able to see which +URLs Base may execute, what opt-in is required, how dry-run behaves, and what +logging/redaction guarantees apply. + +## Scope + +This change covers the remote installers Base currently owns: + +- Homebrew official installer: + `https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh` +- Codex CLI official installer: + `https://chatgpt.com/codex/install.sh` +- Claude Code official installer: + `https://claude.ai/install.sh` + +It does not add arbitrary manifest setup hooks, project-defined remote +installers, pinned installer support, or managed-device provisioning +integration. Those remain future work. + +## Policy + +Base allows only documented, internally defined remote shell installers. +Project manifests cannot declare remote shell installers. + +Homebrew is the only first-mile installer Base may run by default, and only +when Homebrew is missing on a macOS setup path. That trust decision stays +aligned with Homebrew's supported mutable `install/HEAD/install.sh` entry +point. Teams that need pinned or reviewed installer content should provision +Homebrew outside Base before running `basectl setup`. + +AI tool installers are not part of default setup, `dev`, or `sre`. They require +explicit `--profile ai` selection. That explicit profile flag is the consent +boundary; Base will not add a second interactive prompt in this slice. Keeping +non-interactive behavior deterministic is more valuable than adding a prompt +that CI or scripted setup must bypass. + +## Runtime Behavior + +`basectl setup --profile ai --dry-run` shows the planned remote installer +commands and does not download or execute installer content. + +`basectl setup --profile ai` may execute only allowlisted AI installer URLs. The +Python `base_dev` profile layer will derive installer commands from structured +tool metadata and reject any AI tool whose URL is not on the allowlist. + +`basectl check --profile ai` and `basectl doctor --profile ai` remain read-only: +they check whether the tools exist and can report versions, but they do not +download or execute installers. + +## Logging And Redaction + +AI profile installer processes run through `base_setup.process.run_command()`. +Their live terminal output remains visible to the user. Persistent debug logs +and failure summaries use the redacted subprocess output added for #508. + +Homebrew first-mile installers run in shell bootstrap/setup paths before the +Python setup layer may exist. Base dry-run output names the official installer +URL without downloading it. Live installer output is not rewritten by Base; a +managed-device or pinned-installer environment should install Homebrew before +Base if it needs stronger logging or review controls. + +## Tests + +Add focused `base_dev` tests for: + +- AI installer URLs are centrally allowlisted. +- `--profile ai --dry-run` prints the allowlisted remote installer commands and + policy context. +- Non-interactive setup remains deterministic when `--profile ai` is explicit. +- An AI tool with an unallowlisted remote installer URL fails before + `run_command()` is called. +- Default `setup --dry-run` does not include AI remote installer URLs. + +Run the focused `base_dev` tests first, then `env -u BASE_HOME ./bin/base-test`.