Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 8 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cli/bash/commands/basectl/subcommands/setup_common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Homebrew installer from $installer_url>"
return 0
fi

Expand Down
2 changes: 1 addition & 1 deletion cli/bash/commands/basectl/tests/setup.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Homebrew installer from https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh>"* ]]
[[ "$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'"* ]]
Expand Down
44 changes: 39 additions & 5 deletions cli/python/base_dev/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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, ...],
Expand Down
74 changes: 74 additions & 0 deletions cli/python/base_dev/tests/test_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand All @@ -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,
Expand Down Expand Up @@ -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 (
Expand Down
3 changes: 3 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions docs/bootstrap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
43 changes: 43 additions & 0 deletions docs/remote-installer-policy.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading