diff --git a/INSTALL.md b/INSTALL.md index 54e205d..564f813 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -53,6 +53,25 @@ isolated while exposing `sccfm-cli` and `sccfm-cli-interactive` on your `PATH`. pipx install cisco-sccfm-devkit ``` +For an explicitly requested CLI-only installation on macOS or Linux, Homebrew +is also supported: + +```bash +brew tap CiscoDevNet/tap +brew trust --formula CiscoDevNet/tap/sccfm-cli # required once on Homebrew 6.0+ +brew install CiscoDevNet/tap/sccfm-cli +``` + +Homebrew releases earlier than 6.0 do not provide `brew trust`; omit that line +there. Formula-specific trust avoids trusting every current and future item in +the third-party tap. + +The Homebrew formula installs the CLI and Python library, but not the +`cisco.sccfm` Ansible collection by itself. The agent plugin's setup skill can +keep this CLI and add a private, version-matched Ansible companion under +`~/.sccfm-agent-plugin/ansible-runtime`; that directory is not added to `PATH`, +so the Homebrew CLI remains authoritative. + ### Install with pip Use `pip` when you are installing into an existing virtual environment: diff --git a/README.md b/README.md index eb2224c..6a6d9f7 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,22 @@ keeps the CLI isolated while exposing `sccfm-cli` and `sccfm-cli-interactive` on pipx install cisco-sccfm-devkit ``` +Homebrew remains available as an optional CLI-only install on macOS and Linux: + +```bash +brew tap CiscoDevNet/tap +brew trust --formula CiscoDevNet/tap/sccfm-cli # required once on Homebrew 6.0+ +brew install CiscoDevNet/tap/sccfm-cli +``` + +Omit `brew trust` on Homebrew releases earlier than 6.0. Formula-specific trust +avoids trusting every current and future item in the third-party tap. + +Homebrew does not install the `cisco.sccfm` Ansible collection by itself. The +agent plugin's setup skill can preserve the Homebrew CLI and add a private, +version-matched Ansible companion without exposing another `sccfm-cli` on +`PATH`. + Installing into a virtual environment with `pip` is also supported: ```bash diff --git a/cisco_sccfm_cli/commands/tests/test_sccfm_cli_skill.py b/cisco_sccfm_cli/commands/tests/test_sccfm_cli_skill.py index af4e72d..aa85c10 100644 --- a/cisco_sccfm_cli/commands/tests/test_sccfm_cli_skill.py +++ b/cisco_sccfm_cli/commands/tests/test_sccfm_cli_skill.py @@ -52,7 +52,7 @@ def test_cisco_sccfm_cli_skill_should_cover_schema_driven_operation() -> None: "Class B", "Class C", "EXECUTE ", - "SCCFM_APPROVAL_COMMAND: ", + "Do not emit a separate machine-readable marker", "Match User Intent Conservatively", "schema export", "not validated against live state", @@ -79,6 +79,7 @@ def test_cisco_sccfm_cli_skill_should_cover_schema_driven_operation() -> None: for fragment in expected_fragments: assert fragment in body + assert "SCCFM_APPROVAL_COMMAND:" not in body assert "sccfm-cli-interactive" in body assert "SCCFM_API_TOKEN" not in body diff --git a/cisco_sccfm_core/tests/test_agent_plugin.py b/cisco_sccfm_core/tests/test_agent_plugin.py index 4feeb4c..7617b90 100644 --- a/cisco_sccfm_core/tests/test_agent_plugin.py +++ b/cisco_sccfm_core/tests/test_agent_plugin.py @@ -59,7 +59,34 @@ def sample_schema() -> dict[str, object]: { "path": ["objects", "network", "delete"], "readonly": False, - "options": [], + "options": [ + { + "name": "uid", + "aliases": ["--uid"], + "is_flag": False, + "nargs": 1, + }, + { + "name": "check", + "aliases": ["--check"], + "is_flag": True, + "nargs": 1, + }, + { + "name": "api_token", + "aliases": ["--api-token"], + "is_flag": False, + "nargs": 1, + "sensitive": True, + }, + ], + "constraints": [ + { + "type": "mode", + "option": "check", + "effect": ("Preflight only; do not perform the SCCFM-changing operation."), + } + ], }, ], } @@ -157,6 +184,136 @@ def test_install_plan_uses_one_pipx_environment_and_matching_versions(tmp_path: ] +def test_homebrew_plan_uses_a_private_matching_ansible_runtime( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + setup_runtime = load_setup_runtime() + monkeypatch.setattr(setup_runtime.Path, "home", classmethod(lambda cls: tmp_path)) + collection_base = tmp_path / ".ansible" / "collections" + runtime = tmp_path / ".sccfm-agent-plugin" / "ansible-runtime" + + assert setup_runtime.homebrew_ansible_install_commands( + "0.40.0", "python3.12", collection_base + ) == [ + ["python3.12", "-m", "venv", str(runtime)], + [ + str(runtime / "bin" / "python"), + "-m", + "pip", + "install", + "--disable-pip-version-check", + "--upgrade", + "ansible-core>=2.20,<2.22", + "cisco-sccfm-devkit==0.40.0", + ], + [ + str(runtime / "bin" / "ansible-galaxy"), + "collection", + "install", + "cisco.sccfm:==0.40.0", + "--force", + "--collections-path", + str(collection_base), + ], + ] + + +def test_homebrew_install_state_records_the_private_ansible_runtime( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + setup_runtime = load_setup_runtime() + monkeypatch.setattr(setup_runtime.Path, "home", classmethod(lambda cls: tmp_path)) + collection_path = setup_runtime.expected_collection_path() + + setup_runtime.write_install_state( + collection_path, + "0.40.0", + runtime_kind=setup_runtime.HOMEBREW_ANSIBLE_RUNTIME_KIND, + ) + + assert setup_runtime.load_install_state() == { + "ansible_runtime_path": str(setup_runtime.expected_ansible_runtime_path()), + "collection_path": str(collection_path), + "runtime_kind": "homebrew-ansible", + "schema_version": 2, + "version": "0.40.0", + } + + +def test_legacy_install_state_defaults_to_the_pipx_runtime( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + setup_runtime = load_setup_runtime() + monkeypatch.setattr(setup_runtime.Path, "home", classmethod(lambda cls: tmp_path)) + state_path = setup_runtime.install_state_path() + state_path.parent.mkdir(parents=True) + state_path.write_text( + json.dumps( + { + "schema_version": 1, + "collection_path": str(setup_runtime.expected_collection_path()), + "version": "0.39.5", + } + ) + ) + + assert setup_runtime.load_install_state()["runtime_kind"] == "pipx" + + +def test_homebrew_install_state_selects_the_private_ansible_commands( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + setup_runtime = load_setup_runtime() + monkeypatch.setattr(setup_runtime.Path, "home", classmethod(lambda cls: tmp_path)) + collection_path = setup_runtime.expected_collection_path() + ansible_doc = setup_runtime.ansible_runtime_executable("ansible-doc") + ansible_doc.parent.mkdir(parents=True) + ansible_doc.touch() + setup_runtime.write_install_state( + collection_path, + "0.40.0", + runtime_kind=setup_runtime.HOMEBREW_ANSIBLE_RUNTIME_KIND, + ) + + assert setup_runtime.ansible_command_path("ansible-doc") == str(ansible_doc) + + +def test_homebrew_install_creates_only_the_private_ansible_runtime( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + setup_runtime = load_setup_runtime() + monkeypatch.setattr(setup_runtime.Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.setattr( + setup_runtime, + "command_path", + lambda name: "/usr/local/bin/python3.12" if name == "python3.12" else None, + ) + monkeypatch.setattr( + setup_runtime, + "homebrew_formula_installation", + lambda: {"versions": ["0.40.0"]}, + ) + commands: list[list[str]] = [] + + def run_install(command: list[str], check: bool) -> None: + assert check is True + commands.append(command) + runtime = setup_runtime.expected_ansible_runtime_path() + if command[1:3] == ["-m", "venv"]: + (runtime / "bin").mkdir(parents=True) + for executable in ("python", "ansible-galaxy", "ansible-doc"): + (runtime / "bin" / executable).touch() + if "collection" in command: + setup_runtime.expected_collection_path().mkdir(parents=True) + + monkeypatch.setattr(setup_runtime.subprocess, "run", run_install) + + setup_runtime.install("0.40.0", "python3.12", confirmed=True) + + assert all(command[0] != "pipx" for command in commands) + assert setup_runtime.load_install_state()["runtime_kind"] == "homebrew-ansible" + + @pytest.mark.parametrize("version", ["0.39", "0.39.3rc1", "latest", "0.39.3; echo unsafe"]) def test_install_plan_rejects_non_stable_versions(version: str) -> None: setup_runtime = load_setup_runtime() @@ -231,28 +388,67 @@ def test_collection_metadata_prefers_the_recorded_copy_when_two_roots_exist( def test_pipx_package_discovery_normalizes_package_names( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + setup_runtime = load_setup_runtime() + pipx_home = tmp_path / "pipx" + metadata_path = pipx_home / "venvs" / "cisco_sccfm_devkit" / "pipx_metadata.json" + metadata_path.parent.mkdir(parents=True) + metadata_path.write_text(json.dumps({"main_package": {"package": "cisco-sccfm-devkit"}})) + monkeypatch.setenv("PIPX_HOME", str(pipx_home)) + + assert setup_runtime.pipx_package_environment() == metadata_path.parent + assert setup_runtime.pipx_package_installed() is True + + +def test_homebrew_formula_discovery_uses_the_canonical_tap( monkeypatch: pytest.MonkeyPatch, ) -> None: setup_runtime = load_setup_runtime() - monkeypatch.setattr(setup_runtime, "command_path", lambda name: f"/bin/{name}") + monkeypatch.setattr( + setup_runtime, + "command_path", + lambda name: "/opt/homebrew/bin/brew" if name == "brew" else None, + ) + + def fake_run_capture( + command: list[str], *, environment: dict[str, str] | None = None, limit: int = 1000 + ) -> dict[str, object]: + del environment, limit + if "--full-name" in command: + return {"ok": True, "exit_code": 0, "output": "ciscodevnet/tap/sccfm-cli"} + return {"ok": True, "exit_code": 0, "output": "sccfm-cli 0.39.3"} + + monkeypatch.setattr(setup_runtime, "run_capture", fake_run_capture) + + assert setup_runtime.homebrew_formula_installation() == { + "formula": "ciscodevnet/tap/sccfm-cli", + "versions": ["0.39.3"], + "command": [ + "/opt/homebrew/bin/brew", + "uninstall", + "ciscodevnet/tap/sccfm-cli", + ], + "environment": {"HOMEBREW_NO_AUTOREMOVE": "1"}, + } + + +def test_homebrew_formula_discovery_ignores_a_different_tap( + monkeypatch: pytest.MonkeyPatch, +) -> None: + setup_runtime = load_setup_runtime() + monkeypatch.setattr(setup_runtime, "command_path", lambda name: "/bin/brew") monkeypatch.setattr( setup_runtime, "run_capture", - lambda command, limit=1000: { + lambda command, limit: { "ok": True, - "output": json.dumps( - { - "venvs": { - "cisco_sccfm_devkit": { - "metadata": {"main_package": {"package": "cisco-sccfm-devkit"}} - } - } - } - ), + "exit_code": 0, + "output": "example/tap/sccfm-cli", }, ) - assert setup_runtime.pipx_package_installed() is True + assert setup_runtime.homebrew_formula_installation() is None def test_uninstall_plan_preserves_profiles_by_default( @@ -283,8 +479,11 @@ def test_uninstall_plan_preserves_profiles_by_default( } -def test_uninstall_plan_refuses_an_unmanaged_cli(monkeypatch: pytest.MonkeyPatch) -> None: +def test_uninstall_plan_refuses_an_unmanaged_cli( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: setup_runtime = load_setup_runtime() + monkeypatch.setattr(setup_runtime.Path, "home", classmethod(lambda cls: tmp_path)) monkeypatch.setattr(setup_runtime, "discover_collection_paths", lambda: []) monkeypatch.setattr(setup_runtime, "command_path", lambda name: f"/bin/{name}") monkeypatch.setattr(setup_runtime, "pipx_package_installed", lambda: False) @@ -392,16 +591,408 @@ def test_uninstall_deletes_profiles_only_with_explicit_option( assert not profile_path.exists() -def test_setup_skill_documents_safe_teardown_contract() -> None: +def test_setup_skill_routes_teardown_to_the_uninstall_skill() -> None: + skill = (PLUGIN_ROOT / "skills" / "sccfm-setup" / "SKILL.md").read_text() + + assert "Route uninstall, teardown, and complete-cleanup requests" in skill + assert "`sccfm-uninstall` skill" in skill + + +def test_setup_skill_supports_pipx_and_homebrew_companion_paths() -> None: skill = (PLUGIN_ROOT / "skills" / "sccfm-setup" / "SKILL.md").read_text() - assert "uninstall-plan" in skill + assert "pipx is the canonical installation method" in skill + assert "This skill never installs through Homebrew" in skill + assert "brew install CiscoDevNet/tap/sccfm-cli" not in skill + assert "Bash(brew *)" not in skill + assert "Homebrew CLI" in skill + assert "ansible-runtime" in skill + assert "without exposing a second `sccfm-cli`" in skill + + +def test_setup_skill_uses_a_fast_install_path_and_exact_config_command() -> None: + skill = (PLUGIN_ROOT / "skills" / "sccfm-setup" / "SKILL.md").read_text() + normalized_skill = " ".join(skill.split()) + + assert "Do not run the full doctor before installation" in normalized_skill + assert "query the PyPI and Ansible Galaxy release metadata in parallel" in normalized_skill + assert "run exactly one helper command" in normalized_skill + assert "Do not run connectivity checks before the user configures a profile" in normalized_skill + assert "sccfm-cli --profile default configure --region us" in skill + assert "never return placeholders" in normalized_skill + + +def test_cli_skill_keeps_homebrew_scoped_to_cli_only_installation() -> None: + skill = (PLUGIN_ROOT / "skills" / "sccfm-cli" / "SKILL.md").read_text() + + assert "Optional CLI-only Homebrew installation" in skill + assert "brew tap CiscoDevNet/tap" in skill + assert "brew trust --formula CiscoDevNet/tap/sccfm-cli" in skill + assert "brew install CiscoDevNet/tap/sccfm-cli" in skill + assert "INSTALL SCCFM CLI WITH HOMEBREW" in skill + assert "Homebrew installs the CLI and Python library only" in skill + assert "`cisco.sccfm` Ansible collection" in skill + + +def test_uninstall_skill_documents_discovered_cleanup_contract() -> None: + skill = (PLUGIN_ROOT / "skills" / "sccfm-uninstall" / "SKILL.md").read_text() + + assert "cleanup-plan --json" in skill + assert "ciscodevnet/tap/sccfm-cli" in skill + assert "--include-editable" in skill assert "UNINSTALL SCCFM" in skill - assert "UNINSTALL SCCFM AND DELETE PROFILES" in skill - assert "preserves `~/.sccfm-cli/config.json` by default" in skill + assert "UNINSTALL SCCFM AND PROFILES" in skill + assert "UNINSTALL SCCFM AND DELETE PROFILES" not in skill + assert "--plan-digest --yes" in skill assert "codex plugin remove sccfm@sccfm-devkit" in skill +def test_cleanup_collection_discovery_validates_the_standard_manifest( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + setup_runtime = load_setup_runtime() + monkeypatch.setattr(setup_runtime.Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.setattr(setup_runtime, "command_path", lambda name: None) + collection_path = setup_runtime.expected_collection_path() + collection_path.mkdir(parents=True) + (collection_path / "MANIFEST.json").write_text( + json.dumps({"collection_info": {"namespace": "cisco", "name": "sccfm"}}) + ) + + assert setup_runtime.discover_cleanup_collection_paths() == [collection_path] + + +def test_cleanup_plan_preserves_editable_python_installs_by_default( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + setup_runtime = load_setup_runtime() + monkeypatch.setattr(setup_runtime.Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.setattr(setup_runtime, "discover_cleanup_collection_paths", lambda: []) + monkeypatch.setattr(setup_runtime, "pipx_package_environment", lambda: None) + monkeypatch.setattr(setup_runtime, "homebrew_formula_installation", lambda: None) + editable_installation = { + "interpreter": "/work/.venv/bin/python", + "version": "0.39.5", + "location": "/work/.venv/lib/python3.12/site-packages", + "environment": "/work/.venv", + "editable": True, + "source": "file:///work/sccfm-devkit", + "command": [ + "/work/.venv/bin/python", + "-m", + "pip", + "uninstall", + "--yes", + "cisco-sccfm-devkit", + ], + } + monkeypatch.setattr( + setup_runtime, + "discover_python_installations", + lambda include_cli_candidate: [editable_installation], + ) + + plan = setup_runtime.cleanup_plan(remove_profiles=False, include_editable=False) + + assert plan["python_installations"] == [] + assert plan["preserved_python_installations"] == [editable_installation] + assert len(plan["plan_digest"]) == 64 + + +def test_cleanup_plan_includes_editable_python_installs_only_after_opt_in( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + setup_runtime = load_setup_runtime() + monkeypatch.setattr(setup_runtime.Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.setattr(setup_runtime, "discover_cleanup_collection_paths", lambda: []) + monkeypatch.setattr(setup_runtime, "pipx_package_environment", lambda: None) + monkeypatch.setattr(setup_runtime, "homebrew_formula_installation", lambda: None) + editable_installation = { + "interpreter": "/work/.venv/bin/python", + "version": "0.39.5", + "location": "/work/.venv/lib/python3.12/site-packages", + "environment": "/work/.venv", + "editable": True, + "source": "file:///work/sccfm-devkit", + "command": ["/work/.venv/bin/python", "-m", "pip", "uninstall"], + } + monkeypatch.setattr( + setup_runtime, + "discover_python_installations", + lambda include_cli_candidate: [editable_installation], + ) + + plan = setup_runtime.cleanup_plan(remove_profiles=False, include_editable=True) + + assert plan["python_installations"] == [editable_installation] + assert plan["preserved_python_installations"] == [] + + +def test_cleanup_plan_includes_homebrew_and_skips_cli_python_discovery( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + setup_runtime = load_setup_runtime() + monkeypatch.setattr(setup_runtime.Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.setattr(setup_runtime, "discover_cleanup_collection_paths", lambda: []) + monkeypatch.setattr(setup_runtime, "pipx_package_environment", lambda: None) + homebrew_installation = { + "formula": "ciscodevnet/tap/sccfm-cli", + "versions": ["0.39.3"], + "command": ["/opt/homebrew/bin/brew", "uninstall", "ciscodevnet/tap/sccfm-cli"], + "environment": {"HOMEBREW_NO_AUTOREMOVE": "1"}, + } + monkeypatch.setattr( + setup_runtime, + "homebrew_formula_installation", + lambda: homebrew_installation, + ) + discovery_modes: list[bool] = [] + + def record_discovery_mode(include_cli_candidate: bool) -> list[dict[str, object]]: + discovery_modes.append(include_cli_candidate) + return [] + + monkeypatch.setattr( + setup_runtime, + "discover_python_installations", + record_discovery_mode, + ) + + plan = setup_runtime.cleanup_plan(remove_profiles=False, include_editable=False) + + assert plan["homebrew_installation"] == homebrew_installation + assert discovery_modes == [False] + + +def test_cleanup_plan_removes_the_owned_homebrew_ansible_runtime( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + setup_runtime = load_setup_runtime() + monkeypatch.setattr(setup_runtime.Path, "home", classmethod(lambda cls: tmp_path)) + collection_path = setup_runtime.expected_collection_path() + runtime_path = setup_runtime.expected_ansible_runtime_path() + runtime_path.mkdir(parents=True) + setup_runtime.write_install_state( + collection_path, + "0.40.0", + runtime_kind=setup_runtime.HOMEBREW_ANSIBLE_RUNTIME_KIND, + ) + monkeypatch.setattr(setup_runtime, "discover_cleanup_collection_paths", lambda: []) + monkeypatch.setattr(setup_runtime, "pipx_package_environment", lambda: None) + monkeypatch.setattr(setup_runtime, "homebrew_formula_installation", lambda: None) + monkeypatch.setattr( + setup_runtime, + "discover_python_installations", + lambda include_cli_candidate: [], + ) + + plan = setup_runtime.cleanup_plan(remove_profiles=False, include_editable=False) + + assert plan["ansible_runtime"] == { + "action": "delete", + "path": str(runtime_path), + "exists": True, + } + + +def test_cleanup_can_recover_an_incomplete_homebrew_ansible_runtime( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + setup_runtime = load_setup_runtime() + monkeypatch.setattr(setup_runtime.Path, "home", classmethod(lambda cls: tmp_path)) + runtime_path = setup_runtime.expected_ansible_runtime_path() + runtime_path.mkdir(parents=True) + setup_runtime.write_install_state( + setup_runtime.expected_collection_path(), + "0.40.0", + runtime_kind=setup_runtime.HOMEBREW_ANSIBLE_RUNTIME_KIND, + ) + monkeypatch.setattr( + setup_runtime, + "command_path", + lambda name: "/usr/local/bin/ansible-galaxy" if name == "ansible-galaxy" else None, + ) + + assert setup_runtime.discover_cleanup_collection_paths() == [] + + +def test_cleanup_plan_does_not_schedule_the_pipx_environment_twice( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + setup_runtime = load_setup_runtime() + monkeypatch.setattr(setup_runtime.Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.setattr(setup_runtime, "discover_cleanup_collection_paths", lambda: []) + pipx_environment = tmp_path / "pipx" / "venvs" / "cisco-sccfm-devkit" + monkeypatch.setattr( + setup_runtime, + "pipx_package_environment", + lambda: pipx_environment, + ) + monkeypatch.setattr( + setup_runtime, + "command_path", + lambda name: "/bin/pipx" if name == "pipx" else None, + ) + monkeypatch.setattr(setup_runtime, "homebrew_formula_installation", lambda: None) + pipx_python_installation = { + "environment": str(pipx_environment), + "editable": False, + } + monkeypatch.setattr( + setup_runtime, + "discover_python_installations", + lambda include_cli_candidate: [pipx_python_installation], + ) + + plan = setup_runtime.cleanup_plan(remove_profiles=False, include_editable=False) + + assert plan["pipx_command"] == ["/bin/pipx", "uninstall", "cisco-sccfm-devkit"] + assert plan["python_installations"] == [] + + +def test_install_plan_completes_a_homebrew_install_without_pipx( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + setup_runtime = load_setup_runtime() + monkeypatch.setattr(setup_runtime.Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.setattr( + setup_runtime, + "homebrew_formula_installation", + lambda: {"versions": ["0.39.3"]}, + ) + + setup_runtime.print_plan("0.39.3", "python3.12") + + output = capsys.readouterr().out + assert "python3.12 -m venv" in output + assert "cisco-sccfm-devkit==0.39.3" in output + assert "cisco.sccfm:==0.39.3" in output + assert "pipx install" not in output + + +def test_install_plan_rejects_a_version_different_from_homebrew( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + setup_runtime = load_setup_runtime() + monkeypatch.setattr(setup_runtime.Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.setattr( + setup_runtime, + "homebrew_formula_installation", + lambda: {"versions": ["0.40.0"]}, + ) + + with pytest.raises(SystemExit, match="not 0.40.1"): + setup_runtime.print_plan("0.40.1", "python3.12") + + +def test_cleanup_rejects_a_changed_plan_digest(monkeypatch: pytest.MonkeyPatch) -> None: + setup_runtime = load_setup_runtime() + monkeypatch.setattr( + setup_runtime, + "cleanup_plan", + lambda remove_profiles, include_editable: {"plan_digest": "a" * 64}, + ) + + with pytest.raises(RuntimeError, match="targets changed after review"): + setup_runtime.cleanup( + remove_profiles=True, + include_editable=False, + plan_digest="b" * 64, + confirmed=True, + ) + + +def test_cleanup_requires_confirmation() -> None: + setup_runtime = load_setup_runtime() + + with pytest.raises(SystemExit, match="Refusing to clean up"): + setup_runtime.cleanup( + remove_profiles=True, + include_editable=False, + plan_digest="0" * 64, + confirmed=False, + ) + + +def test_cleanup_executes_the_reviewed_order_and_deletes_the_profile( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + setup_runtime = load_setup_runtime() + monkeypatch.setattr(setup_runtime.Path, "home", classmethod(lambda cls: tmp_path)) + collection_path = setup_runtime.expected_collection_path() + runtime_path = setup_runtime.expected_ansible_runtime_path() + profile_path = setup_runtime.profile_store_path() + profile_path.parent.mkdir(parents=True) + profile_path.write_text("secret") + events: list[str] = [] + plan = { + "schema_version": 4, + "options": {"include_editable": False, "remove_profiles": True}, + "collection_paths": [str(collection_path)], + "preserved_collection_paths": [], + "install_state": {"action": "absent", "path": "unused", "exists": False}, + "ansible_runtime": { + "action": "delete", + "path": str(runtime_path), + "exists": True, + }, + "homebrew_installation": { + "formula": "ciscodevnet/tap/sccfm-cli", + "versions": ["0.39.3"], + "command": ["brew", "uninstall", "ciscodevnet/tap/sccfm-cli"], + "environment": {"HOMEBREW_NO_AUTOREMOVE": "1"}, + }, + "pipx_command": ["pipx", "uninstall", "cisco-sccfm-devkit"], + "python_installations": [ + {"command": ["/python", "-m", "pip", "uninstall", "cisco-sccfm-devkit"]} + ], + "preserved_python_installations": [], + "profile": {"action": "delete", "path": str(profile_path), "exists": True}, + } + plan["plan_digest"] = setup_runtime.cleanup_plan_digest(plan) + monkeypatch.setattr( + setup_runtime, + "cleanup_plan", + lambda remove_profiles, include_editable: plan, + ) + monkeypatch.setattr( + setup_runtime, + "validate_collection_before_removal", + lambda path: events.append(f"validate:{path}"), + ) + monkeypatch.setattr( + setup_runtime.shutil, + "rmtree", + lambda path: events.append(f"collection:{path}"), + ) + monkeypatch.setattr( + setup_runtime.subprocess, + "run", + lambda command, check, env=None: events.append( + f"command:{' '.join(command)}:{env.get('HOMEBREW_NO_AUTOREMOVE') if env else '-'}" + ), + ) + + setup_runtime.cleanup( + remove_profiles=True, + include_editable=False, + plan_digest=plan["plan_digest"], + confirmed=True, + ) + + assert events == [ + f"validate:{collection_path}", + f"collection:{collection_path}", + f"collection:{runtime_path}", + "command:pipx uninstall cisco-sccfm-devkit:-", + "command:brew uninstall ciscodevnet/tap/sccfm-cli:1", + "command:/python -m pip uninstall cisco-sccfm-devkit:-", + ] + assert not profile_path.exists() + + def test_profile_diagnostics_expose_metadata_without_secret_contents( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: @@ -425,10 +1016,17 @@ def test_profile_diagnostics_expose_metadata_without_secret_contents( "sccfm-cli status", "sccfm-cli --profile default --silent status --format json", "sccfm-cli schema export --format json", + "sccfm-cli objects network delete --uid example --check", + "ansible-playbook --syntax-check playbook.yml", + "ANSIBLE_LOCAL_TEMP=/tmp ansible-playbook --syntax-check playbook.yml", + ( + "/Users/example/.sccfm-agent-plugin/ansible-runtime/bin/ansible-playbook " + "--syntax-check playbook.yml" + ), "command -v sccfm-cli", ], ) -def test_guard_allows_schema_proven_readonly_commands(command: str) -> None: +def test_guard_allows_proven_readonly_commands(command: str) -> None: guard = load_command_guard() classification, _reason = guard.classify_command(command, sample_schema()) @@ -440,14 +1038,18 @@ def test_guard_allows_schema_proven_readonly_commands(command: str) -> None: "command", [ "sccfm-cli inventory devices delete --uid example", + "sccfm-cli objects network delete --uid example --check --api-token secret", "sccfm-cli schema export --output schema.json", "sccfm-cli status | tee status.txt", "env DEBUG=1 sccfm-cli status", "SCCFM_CONFIG=/tmp/test sccfm-cli inventory devices delete --uid example", "DEBUG=1 ansible-playbook change.yml", + "ANSIBLE_LOCAL_TEMP=relative ansible-playbook --syntax-check playbook.yml", + "ANSIBLE_LOCAL_TEMP=/tmp ansible-playbook change.yml", "nohup sccfm-cli inventory devices delete --uid example", "nice ansible-playbook change.yml", "ansible-playbook change.yml", + "/Users/example/.sccfm-agent-plugin/ansible-runtime/bin/ansible-playbook change.yml", "ansible-galaxy collection install cisco.sccfm", ], ) @@ -461,6 +1063,21 @@ def test_guard_requires_review_for_mutating_local_write_or_composed_commands( assert classification == "review" +@pytest.mark.parametrize( + "command", + [ + "sccfm-cli objects network delete --uid=--check", + "sccfm-cli objects network delete --uid --check", + ], +) +def test_guard_does_not_treat_an_option_value_named_check_as_preflight(command: str) -> None: + guard = load_command_guard() + + classification, _reason = guard.classify_command(command, sample_schema()) + + assert classification == "review" + + @pytest.mark.parametrize( "command", [ @@ -486,6 +1103,8 @@ def test_guard_ignores_unrelated_commands(command: str) -> None: "env DEBUG=1 sccfm-cli inventory devices delete --uid example", "SCCFM_CONFIG=/tmp/test sccfm-cli inventory devices delete --uid example", "DEBUG=1 ansible-playbook change.yml", + "ansible-playbook --syntax-check playbook.yml", + "ANSIBLE_LOCAL_TEMP=relative ansible-playbook change.yml", "nohup sccfm-cli inventory devices delete --uid example", "nice ansible-playbook change.yml", "sccfm-cli inventory devices unknown --uid example", @@ -498,6 +1117,36 @@ def test_guard_rejects_unsafe_or_unverifiable_approval_commands(command: str) -> assert guard.approval_eligible(command, sample_schema()) is False +def test_guard_allows_approval_for_safe_temp_prefixed_ansible_execution() -> None: + guard = load_command_guard() + + assert ( + guard.approval_eligible( + "ANSIBLE_LOCAL_TEMP=/tmp ansible-playbook change.yml", sample_schema() + ) + is True + ) + + +def test_syntax_check_proceeds_without_an_approval_receipt(tmp_path: Path) -> None: + guard = load_command_guard() + + decision = guard.process_tool_use( + { + "session_id": "syntax-check", + "tool_input": { + "command": ("ANSIBLE_LOCAL_TEMP=/tmp ansible-playbook --syntax-check playbook.yml") + }, + }, + "codex", + tmp_path, + sample_schema(), + ) + + assert decision is None + assert not guard.approval_path(tmp_path, "syntax-check").exists() + + def test_exact_approval_command_requires_a_standalone_message() -> None: guard = load_command_guard() command = "sccfm-cli inventory devices delete --uid example" @@ -508,20 +1157,15 @@ def test_exact_approval_command_requires_a_standalone_message() -> None: assert guard.exact_approval_command("EXECUTE ") is None -def test_planned_command_requires_one_standalone_marker() -> None: +def test_planned_command_requires_one_standalone_execute_instruction() -> None: guard = load_command_guard() command = "sccfm-cli inventory devices delete --uid example" - assert guard.planned_command(f"Plan ready.\nSCCFM_APPROVAL_COMMAND: {command}") == command - assert guard.planned_command(f"SCCFM_APPROVAL_COMMAND: {command}\nSummary") == command - assert ( - guard.planned_command( - f"SCCFM_APPROVAL_COMMAND: {command}\nSCCFM_APPROVAL_COMMAND: {command} --check" - ) - is None - ) - assert guard.planned_command("No approval marker") is None - assert guard.planned_command("SCCFM_APPROVAL_COMMAND: ") is None + assert guard.planned_command(f"Plan ready.\nEXECUTE {command}") == command + assert guard.planned_command(f"EXECUTE {command}\nSummary") == command + assert guard.planned_command(f"EXECUTE {command}\nEXECUTE {command} --check") is None + assert guard.planned_command("No approval instruction") is None + assert guard.planned_command("EXECUTE ") is None def test_guard_detects_the_host_from_plugin_environment(monkeypatch: pytest.MonkeyPatch) -> None: @@ -601,7 +1245,7 @@ def test_assistant_plan_records_only_one_eligible_exact_command(tmp_path: Path) guard.process_assistant_plan( { "session_id": "planned", - "last_assistant_message": f"Plan ready.\nSCCFM_APPROVAL_COMMAND: {command}", + "last_assistant_message": f"Plan ready.\nEXECUTE {command}", }, tmp_path, sample_schema(), @@ -613,7 +1257,7 @@ def test_assistant_plan_records_only_one_eligible_exact_command(tmp_path: Path) guard.process_assistant_plan( { "session_id": "readonly", - "last_assistant_message": "SCCFM_APPROVAL_COMMAND: sccfm-cli status", + "last_assistant_message": "EXECUTE sccfm-cli status", }, tmp_path, sample_schema(), @@ -623,7 +1267,9 @@ def test_assistant_plan_records_only_one_eligible_exact_command(tmp_path: Path) assert not guard.plan_path(tmp_path, "readonly").exists() -def test_latest_assistant_message_without_a_valid_marker_clears_stale_plan(tmp_path: Path) -> None: +def test_latest_assistant_message_without_a_valid_instruction_clears_stale_plan( + tmp_path: Path, +) -> None: guard = load_command_guard() command = "sccfm-cli inventory devices delete --uid example" guard.store_plan(tmp_path, "session", command) @@ -725,7 +1371,7 @@ def test_codex_approved_command_proceeds_and_consumes_receipt(tmp_path: Path) -> assert not guard.approval_path(tmp_path, "codex-session").exists() -def test_claude_approved_command_requests_host_confirmation(tmp_path: Path) -> None: +def test_claude_approved_command_proceeds_and_consumes_receipt(tmp_path: Path) -> None: guard = load_command_guard() command = "ansible-playbook -i inventory.yml change.yml" guard.store_approval(tmp_path, "claude-session", command) @@ -737,7 +1383,7 @@ def test_claude_approved_command_requests_host_confirmation(tmp_path: Path) -> N sample_schema(), ) - assert decision["hookSpecificOutput"]["permissionDecision"] == "ask" + assert decision is None assert not guard.approval_path(tmp_path, "claude-session").exists() diff --git a/cisco_sccfm_core/tests/test_sccfm_ansible_skill.py b/cisco_sccfm_core/tests/test_sccfm_ansible_skill.py index 8f03f6b..2a1faa9 100644 --- a/cisco_sccfm_core/tests/test_sccfm_ansible_skill.py +++ b/cisco_sccfm_core/tests/test_sccfm_ansible_skill.py @@ -58,6 +58,12 @@ def test_sccfm_ansible_skill_is_ansible_doc_driven() -> None: assert "Do not hardcode module names" in skill assert "All module and plugin knowledge comes from" in normalized_skill assert "only hardcoded bootstrap commands" in skill + assert "Do not enumerate unrelated plugin types" in normalized_skill + assert "One full-doc call containing all plausible candidates" in normalized_skill + assert "do not run the list command again" in normalized_skill + assert "ANSIBLE_LOCAL_TEMP=/tmp" in skill + assert "never requires an `EXECUTE` confirmation" in normalized_skill + assert "Do not create or edit `ansible.cfg`" in normalized_skill assert '"dist/cisco-sccfm-$(poetry version --short).tar.gz" --force' in skill assert "dist/cisco-sccfm-*.tar.gz" not in skill assert "only to detect a stale" in normalized_skill @@ -84,7 +90,8 @@ def test_sccfm_ansible_skill_documents_safety_and_secret_rules() -> None: assert "module_defaults: group/cisco.sccfm.all" in skill assert "supports_check_mode=True" in skill assert "EXECUTE " in skill - assert "SCCFM_APPROVAL_COMMAND: " in skill + assert "Do not emit a separate machine-readable marker" in normalized_skill + assert "SCCFM_APPROVAL_COMMAND:" not in skill def test_sccfm_ansible_skill_only_documents_canonical_profile_auth() -> None: diff --git a/docs/agent-plugin.md b/docs/agent-plugin.md index 4e37039..2e2081e 100644 --- a/docs/agent-plugin.md +++ b/docs/agent-plugin.md @@ -7,12 +7,13 @@ title: SCC Firewall Manager Agent Plugin The `sccfm` plugin gives Claude Code and Codex a supported way to install, configure, inspect, and operate Cisco Security Cloud Control Firewall Manager -from natural-language requests. It packages three focused skills rather than one +from natural-language requests. It packages four focused skills rather than one large general-purpose instruction file: | Component | Responsibility | |---|---| -| `sccfm-setup` | Diagnose prerequisites, plan a version-matched installation, guide authentication, verify the runtime, and safely remove managed setup artifacts. | +| `sccfm-setup` | Diagnose prerequisites, plan a version-matched installation, guide authentication, and verify the runtime. | +| `sccfm-uninstall` | Discover and safely remove managed or legacy runtime artifacts after a digest-bound plan and explicit confirmation. | | `sccfm-cli` | Discover the installed CLI schema and generate or execute validated CLI commands. | | `sccfm-ansible` | Discover the installed collection with `ansible-doc` and generate or execute validated Ansible automation. | | Cross-agent command guard | Require explicit authorization when a shell command is not proven read-only. | @@ -43,8 +44,8 @@ The first release is intended to provide one installable package that: The setup skill can: -- detect Python 3.12, `pipx`, `sccfm-cli`, `ansible-doc`, and - `ansible-galaxy`; +- detect Python 3.12, Homebrew, `pipx`, `sccfm-cli`, `ansible-doc`, and + `ansible-galaxy`, including the canonical SCCFM Homebrew formula and version; - report whether an SCCFM profile exists without reading or displaying its contents; - export CLI schema metadata and discover the installed Ansible collection; @@ -52,18 +53,42 @@ The setup skill can: - produce an exact installation plan without executing it; - install a selected stable version after the user types `INSTALL SCCFM X.Y.Z`; and -- plan and remove the Galaxy collection and managed pipx environment after - explicit teardown confirmation; and - verify schema discovery, collection discovery, authentication readiness, and a harmless read-only operation. -The managed installation uses `pipx` for the Python package, injects -`ansible-core` into the same isolated environment, and installs the identical -`cisco.sccfm` Galaxy collection version. Keeping Ansible and -`cisco_sccfm_core` in the same Python environment prevents module import -failures. The collection is installed at the standard per-user Galaxy path and -the helper stores an ownership record for that exact directory. It refuses to -overwrite a pre-existing collection that it cannot prove it owns. +Explicit install and upgrade requests use a fast path: minimal prerequisite +checks, one parallel PyPI/Galaxy version lookup, one reviewed helper install, +and one local discovery verification. The full doctor is reserved for health +checks, diagnosis, and repair. After installation, the setup response ends with +an exact `sccfm-cli --profile ... configure --region ...` command using the +resolved profile and region; it never leaves profile or region placeholders for +the user to fill in. + +The setup skill supports two version-aligned layouts. Without the canonical +Homebrew CLI, it uses `pipx` for the Python package and injects `ansible-core` +into that same isolated environment. With the Homebrew CLI, setup keeps it and +creates a private Ansible companion at +`~/.sccfm-agent-plugin/ansible-runtime`. The companion contains `ansible-core` +and the exact matching `cisco-sccfm-devkit` library, but is not activated or +added to `PATH`, so the Homebrew CLI remains authoritative. + +Both layouts install the identical `cisco.sccfm` Galaxy collection version at +the standard per-user path. Keeping each Ansible controller with +`cisco_sccfm_core` prevents module import failures. The helper stores an +ownership record for the collection and any Homebrew companion, and refuses to +overwrite paths it cannot prove it owns. Homebrew installation itself remains +an optional CLI-only operation documented by the `sccfm-cli` skill. + +### Runtime uninstall and cleanup + +The uninstall skill can discover the canonical `ciscodevnet/tap/sccfm-cli` +Homebrew formula, its helper-owned Ansible companion, the managed pipx +environment, non-editable Python installs, and the standard per-user Galaxy +collection. It preserves editable development installs and collection copies +outside the standard path unless the user explicitly expands the reviewed +plan. Each plan includes a digest; execution recomputes discovery and aborts +when the targets have changed. Profile deletion is optional and the helper +never reads profile contents. ### Authentication guidance @@ -150,18 +175,21 @@ the plan. Claude Code and Codex load the conventional shared `hooks/hooks.json` manifest, with a root `hooks.json` compatibility copy kept in sync. Both use the same -host-aware guard. The agent places -`SCCFM_APPROVAL_COMMAND: ` on a standalone line only after -presenting a complete mutation plan. The `Stop` hook stores that planned -command's SHA-256 digest, never its contents. A later standalone exact-command +guard. After presenting a complete mutation plan, the agent shows exactly one +standalone `EXECUTE ` confirmation line. The `Stop` hook +derives the planned command from that visible line and stores its SHA-256 digest, +never its contents. A later standalone exact-command confirmation creates a ten-minute, one-use execution receipt only when its digest matches the previously stored plan. Edited commands—including adding or removing `--check`—cannot authorize themselves. Mutating, locally-writing, and -Ansible execution commands are blocked without a matching receipt. Claude -requests interactive host approval after consuming the receipt; Codex continues -through its native sandbox and permission flow. If the agent does not attempt the -command in that turn, the `Stop` hook clears the unused receipt. Schema-proven -read-only commands continue without a receipt. Compound, nested, unknown, and +Ansible execution commands are blocked without a matching receipt. After the +receipt is consumed, execution continues through the host's normal permission +flow. If the agent does not attempt the command in that turn, the `Stop` hook +clears the unused receipt. Schema-proven read-only commands and schema-declared +preflight-only modes continue without a receipt. Local +`ansible-playbook --syntax-check` validation also continues without a receipt, +including when it uses only an absolute `ANSIBLE_LOCAL_TEMP` override for a +sandbox-writable temporary directory. Compound, nested, unknown, and sensitive-argv commands cannot receive a receipt. ## End-user workflow @@ -207,8 +235,8 @@ operations. ### 4. Make natural-language requests The user describes the desired outcome. The plugin automatically routes setup -questions to `sccfm-setup`, CLI tasks to `sccfm-cli`, and playbook or collection -tasks to `sccfm-ansible`. +questions to `sccfm-setup`, teardown to `sccfm-uninstall`, CLI tasks to +`sccfm-cli`, and playbook or collection tasks to `sccfm-ansible`. ### 5. Review changes before execution @@ -220,48 +248,50 @@ approval. ### 6. Uninstall and teardown Plugin removal and runtime teardown are separate operations. `/plugin uninstall` -or `codex plugin remove` removes the agent plugin but leaves its pipx environment, -Galaxy collection, and profile store behind. +or `codex plugin remove` removes the agent plugin but leaves Homebrew or pipx CLI +installs, the Galaxy collection, and the profile store behind. While the plugin is still installed, ask: ```text -Uninstall the SCCFM runtime installed by this plugin. +Completely uninstall SCCFM from this machine. ``` -The setup skill resolves its plugin root and runs the plan-only helper: +The uninstall skill resolves its plugin root and runs the plan-only helper: ```bash -python3 scripts/setup_runtime.py uninstall-plan +python3 scripts/setup_runtime.py cleanup-plan --json ``` -The plan validates the `cisco.sccfm` directories positively reported by -`ansible-galaxy`, then selects for removal only the path matching the helper's -ownership record at `~/.sccfm-agent-plugin/runtime.json`. It verifies that the -CLI belongs to the managed pipx environment and preserves every unowned Galaxy -copy plus `~/.sccfm-cli/config.json` by default. After the user sends the exact -confirmation `UNINSTALL SCCFM`, the agent runs: +The plan discovers the canonical SCCFM Homebrew formula, managed pipx and +non-editable Python installs, validates the standard `cisco.sccfm` collection, +and preserves other Galaxy copies plus editable development installs. It also +returns a digest that binds execution to the reviewed targets. After the user +sends `UNINSTALL SCCFM`, the agent runs: ```bash -python3 scripts/setup_runtime.py uninstall --yes +python3 scripts/setup_runtime.py cleanup --plan-digest --yes ``` -Removal order matters: the helper removes its recorded Galaxy collection while -`ansible-galaxy` is still available, deletes the ownership record, then -uninstalls `cisco-sccfm-devkit` with pipx. It refuses to guess an installation -path, remove another reported collection copy, or remove an unmanaged CLI. +Removal order matters: the helper removes the standard Galaxy collection while +discovery tools are still available, deletes the ownership record, then removes +reviewed pipx, Homebrew, and Python installs. It refuses to guess a path, remove +a same-named formula from another tap, delete another reported collection copy, +or execute when the plan digest has changed. Homebrew teardown sets +`HOMEBREW_NO_AUTOREMOVE=1`, preventing automatic removal of dependencies that +were not part of the reviewed plan. To also delete named profiles and their API tokens, the user must request that separately. The agent shows: ```bash -python3 scripts/setup_runtime.py uninstall-plan --remove-profiles +python3 scripts/setup_runtime.py cleanup-plan --remove-profiles --json ``` -and requires `UNINSTALL SCCFM AND DELETE PROFILES` before running: +and requires `UNINSTALL SCCFM AND PROFILES` before running: ```bash -python3 scripts/setup_runtime.py uninstall --remove-profiles --yes +python3 scripts/setup_runtime.py cleanup --remove-profiles --plan-digest --yes ``` The helper deletes only the canonical profile file and never reads or displays @@ -360,9 +390,9 @@ Expected behavior: 3. Run the schema-declared check or preflight mode. 4. Present the profile, target, intended change, preflight result, and exact command. -5. Emit `SCCFM_APPROVAL_COMMAND: ` followed by that exact command on a standalone - line, then ask for `EXECUTE ` followed by the same command. -6. Execute only after that message and host approval. +5. Show exactly one standalone `EXECUTE ` confirmation line followed by that + exact command. +6. Execute only after the user sends that exact message. ### Generate an Ansible playbook @@ -395,10 +425,9 @@ Expected behavior: 2. Inspect the inventory and show the exact target count. 3. Validate syntax and run check mode when supported. 4. Present a plan and request the first confirmation. -5. Emit `SCCFM_APPROVAL_COMMAND: ` followed by the exact `ansible-playbook` - command on a standalone line, then request a separate `EXECUTE ` message - containing the same command. -6. Execute only after both confirmations and host approval. +5. Show exactly one standalone `EXECUTE ` confirmation line followed by the + exact `ansible-playbook` command. +6. Execute only after both confirmations. ## Deliberate boundaries @@ -426,6 +455,6 @@ python3 plugins/sccfm/scripts/sync_skills.py python3 plugins/sccfm/scripts/sync_skills.py --check ``` -The plugin and all three skills must pass their validators. The setup helper, +The plugin and all four skills must pass their validators. The runtime helper, command guard, manifest alignment, secret-safe diagnostics, and copied-skill integrity are covered by automated tests. diff --git a/plugins/sccfm/.claude-plugin/plugin.json b/plugins/sccfm/.claude-plugin/plugin.json index 7c93c46..5145a9c 100644 --- a/plugins/sccfm/.claude-plugin/plugin.json +++ b/plugins/sccfm/.claude-plugin/plugin.json @@ -2,8 +2,8 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "sccfm", "displayName": "SCC Firewall Manager", - "description": "Guided setup and safety-aware operation for sccfm-cli and the cisco.sccfm Ansible collection.", - "version": "0.1.0", + "description": "Guided setup, teardown, and safety-aware operation for sccfm-cli and the cisco.sccfm Ansible collection.", + "version": "0.1.1", "author": { "name": "Cisco DevNet", "url": "https://developer.cisco.com" @@ -11,11 +11,5 @@ "homepage": "https://ciscodevnet.github.io/sccfm-devkit/", "repository": "https://github.com/CiscoDevNet/sccfm-devkit", "license": "Apache-2.0", - "keywords": [ - "cisco", - "sccfm", - "firewall-manager", - "security", - "ansible" - ] + "keywords": ["cisco", "sccfm", "firewall-manager", "security", "ansible"] } diff --git a/plugins/sccfm/.codex-plugin/plugin.json b/plugins/sccfm/.codex-plugin/plugin.json index e6d2a7d..2479170 100644 --- a/plugins/sccfm/.codex-plugin/plugin.json +++ b/plugins/sccfm/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "sccfm", - "version": "0.1.0", - "description": "Install, configure, and safely operate Cisco SCC Firewall Manager from AI coding agents.", + "version": "0.1.1", + "description": "Install, configure, remove, and safely operate Cisco SCC Firewall Manager from AI coding agents.", "author": { "name": "Cisco DevNet", "url": "https://developer.cisco.com" @@ -9,30 +9,21 @@ "homepage": "https://ciscodevnet.github.io/sccfm-devkit/", "repository": "https://github.com/CiscoDevNet/sccfm-devkit", "license": "Apache-2.0", - "keywords": [ - "cisco", - "sccfm", - "firewall-manager", - "security", - "ansible" - ], + "keywords": ["cisco", "sccfm", "firewall-manager", "security", "ansible"], "skills": "./skills/", "interface": { "displayName": "SCC Firewall Manager", "shortDescription": "Set up and safely operate SCC Firewall Manager", - "longDescription": "Guides installation and authentication, discovers the live sccfm-cli and cisco.sccfm Ansible schemas, executes verified read-only operations, and gates mutating operations behind an explicit reviewed plan.", + "longDescription": "Guides installation, authentication, and safe teardown; discovers the live sccfm-cli and cisco.sccfm Ansible schemas; executes verified read-only operations; and gates mutating operations behind an explicit reviewed plan.", "developerName": "Cisco DevNet", "category": "Security", - "capabilities": [ - "Interactive", - "Read", - "Write" - ], + "capabilities": ["Interactive", "Read", "Write"], "websiteURL": "https://developer.cisco.com/docs/cisco-security-cloud-control-firewall-manager/", "defaultPrompt": [ "Set up SCC Firewall Manager for this machine.", "Check whether my SCCFM CLI and Ansible setup is healthy.", - "List my SCCFM devices using a read-only operation." + "List my SCCFM devices using a read-only operation.", + "Completely uninstall SCCFM from this machine." ] } } diff --git a/plugins/sccfm/README.md b/plugins/sccfm/README.md index 5758a4f..dee23fd 100644 --- a/plugins/sccfm/README.md +++ b/plugins/sccfm/README.md @@ -5,7 +5,7 @@ the `cisco.sccfm` Ansible collection. It supports Claude Code and Codex from the same source tree. See the [complete capability and end-user guide](../../docs/agent-plugin.md) for -the three-skill design, safety model, workflows, and examples. +the four-skill design, safety model, workflows, and examples. ## Install in Claude Code @@ -28,35 +28,47 @@ Codex to set up SCC Firewall Manager. ## What setup does -The guided setup checks prerequisites, proposes an exact version-matched -installation plan, installs the CLI and Ansible runtime only after confirmation, -and explains how to enter an SCCFM API token through the CLI's masked local -prompt. Tokens are never requested in chat. - -The recommended runtime keeps `sccfm-cli`, `ansible-core`, and -`cisco_sccfm_core` in the same pipx environment, then installs the identical -`cisco.sccfm` collection version from Ansible Galaxy. The helper uses the -standard per-user Galaxy path and records the exact collection directory it -owns; it refuses to overwrite an existing unowned copy. +The guided setup uses a fast path for explicit installation requests: it checks +only required prerequisites, resolves one version shared by PyPI and Ansible +Galaxy, installs the reviewed CLI and Ansible runtime after confirmation, and +verifies local discovery once. Full diagnostics are reserved for check and +repair requests. It finishes with the exact profile configuration command so +the API token is entered through the CLI's masked local prompt. Tokens are never +requested in chat. + +Without an existing Homebrew CLI, setup keeps `sccfm-cli`, `ansible-core`, and +`cisco_sccfm_core` in one pipx environment. With the canonical Homebrew CLI, it +preserves that installation and creates a private matching Ansible companion at +`~/.sccfm-agent-plugin/ansible-runtime`. The companion is not added to `PATH`, +so it cannot shadow or duplicate the user-facing Homebrew CLI. + +Both paths install the identical `cisco.sccfm` collection version from Ansible +Galaxy at the standard per-user path. The helper records every directory it +owns and refuses to overwrite an existing unowned copy. Homebrew installation +itself remains an explicitly requested `sccfm-cli` skill operation. ## Uninstall and teardown -Removing the plugin does not remove the pipx environment, the Galaxy collection, -or local SCCFM profiles. Ask the installed plugin: +Removing the plugin does not remove a Homebrew or pipx CLI installation, the +Galaxy collection, or local SCCFM profiles. Ask the installed plugin: ```text -Uninstall the SCCFM runtime installed by this plugin. +Completely uninstall SCCFM from this machine. ``` -The setup skill first shows a validated removal plan. After the exact -confirmation `UNINSTALL SCCFM`, it removes the discovered `cisco.sccfm` -collection only when it matches the helper's ownership record, then runs `pipx -uninstall cisco-sccfm-devkit`. Other reported Galaxy copies and the profile -store at `~/.sccfm-cli/config.json` are preserved by default. +The uninstall skill first shows a validated removal plan with a digest. It can +remove the canonical `ciscodevnet/tap/sccfm-cli` Homebrew formula, the managed +pipx environment, positively discovered non-editable Python installs, and the +standard per-user `cisco.sccfm` collection. Galaxy copies outside the standard +path and editable development installs are preserved by default. After the +exact confirmation `UNINSTALL SCCFM`, the helper recomputes the plan and refuses +to proceed if any target changed. Profile deletion is separate. Request it explicitly and confirm with -`UNINSTALL SCCFM AND DELETE PROFILES`; the setup helper then removes the profile -file after the runtime. It never reads or displays the profile contents. +`UNINSTALL SCCFM AND PROFILES`; the uninstall helper then removes the +profile file after the runtime. It never reads or displays the profile contents. +Removing an editable development install also requires an explicit choice before +the plan is confirmed. Only after runtime teardown, remove the plugin: @@ -86,16 +98,19 @@ recursive-delete command. Claude Code and Codex load the conventional shared `hooks/hooks.json` manifest, with a root `hooks.json` compatibility copy kept in sync. Both use the same -host-aware command guard. When the agent presents a complete mutation plan, its -final response includes `SCCFM_APPROVAL_COMMAND: `. The -`Stop` hook records only that command's hash. A later standalone -`EXECUTE ` message creates a ten-minute, one-use receipt +command guard. When the agent presents a complete mutation plan, it shows +exactly one standalone `EXECUTE ` confirmation line. The +`Stop` hook derives the command from that visible line and records only its hash. +A later identical standalone `EXECUTE ` message creates a +ten-minute, one-use receipt only when it exactly matches the previously recorded plan. Editing the command, -including adding or removing `--check`, cannot create a receipt. Claude then -requests interactive host approval; Codex continues through its native command -permission flow. An unused receipt is cleared when that agent turn ends. -Schema-proven read-only CLI commands do not need a receipt. Compound, nested, -unknown, or sensitive-argv commands fail closed. +including adding or removing `--check`, cannot create a receipt. Execution then +continues through the host's normal permission flow. An unused receipt is +cleared when that agent turn ends. Schema-proven read-only CLI commands and +schema-declared preflight-only modes do not need a receipt. Local +`ansible-playbook --syntax-check` validation also proceeds without one, +including with an absolute `ANSIBLE_LOCAL_TEMP` override for sandboxed hosts. +Compound, nested, unknown, or sensitive-argv commands fail closed. ## Local development diff --git a/plugins/sccfm/hooks/sccfm_guard.py b/plugins/sccfm/hooks/sccfm_guard.py index 4c49aa5..c5a4ee5 100644 --- a/plugins/sccfm/hooks/sccfm_guard.py +++ b/plugins/sccfm/hooks/sccfm_guard.py @@ -22,6 +22,7 @@ SCCFM_EXECUTABLE = "sccfm-cli" ANSIBLE_REVIEW_COMMANDS = {"ansible-galaxy", "ansible-playbook"} +SAFE_ANSIBLE_ENVIRONMENT = {"ANSIBLE_LOCAL_TEMP"} SHELL_CONTROL_CHARACTERS = frozenset(";&|<>") SHELL_SUBSTITUTION_MARKERS = ("$", "`") SHELL_WRAPPER_EXECUTABLES = { @@ -37,7 +38,6 @@ "zsh", } APPROVAL_PREFIX = "EXECUTE " -PLANNED_COMMAND_PREFIX = "SCCFM_APPROVAL_COMMAND: " APPROVAL_TTL_SECONDS = 600 PLAN_TTL_SECONDS = 3600 Host = Literal["claude", "codex"] @@ -122,6 +122,27 @@ def is_executable_discovery(tokens: Sequence[str]) -> bool: ) +def strip_safe_ansible_environment(tokens: Sequence[str]) -> list[str] | None: + remaining = list(tokens) + seen_names: set[str] = set() + while remaining and is_assignment_word(remaining[0]): + name, _separator, value = remaining.pop(0).partition("=") + local_path = Path(value) + if ( + name not in SAFE_ANSIBLE_ENVIRONMENT + or name in seen_names + or not local_path.is_absolute() + or ".." in local_path.parts + ): + return None + seen_names.add(name) + if seen_names and ( + not remaining or executable_name(remaining[0]) not in ANSIBLE_REVIEW_COMMANDS + ): + return None + return remaining + + def load_schema() -> dict[str, Any] | None: executable = shutil.which(SCCFM_EXECUTABLE) if executable is None: @@ -164,6 +185,47 @@ def strip_global_options(tokens: Sequence[str], schema: dict[str, Any]) -> list[ return remaining +def enabled_command_flag( + arguments: Sequence[str], command: dict[str, Any], option_name: str +) -> bool: + options_by_alias = { + alias: option + for option in command.get("options", []) + for alias in option.get("aliases", []) + } + argument_index = 0 + while argument_index < len(arguments): + token = arguments[argument_index] + flag, separator, _value = token.partition("=") + option = options_by_alias.get(flag) + if option is None: + argument_index += 1 + continue + if not separator and option.get("is_flag") is True and option.get("name") == option_name: + return True + if not separator and option.get("is_flag") is not True: + nargs = option.get("nargs", 1) + argument_index += nargs if isinstance(nargs, int) and nargs > 0 else 1 + argument_index += 1 + return False + + +def is_schema_declared_preflight(arguments: Sequence[str], command: dict[str, Any]) -> bool: + for constraint in command.get("constraints", []): + effect = constraint.get("effect", "") + option_name = constraint.get("option") + if ( + constraint.get("type") == "mode" + and isinstance(option_name, str) + and isinstance(effect, str) + and "preflight only" in effect.casefold() + and "do not perform" in effect.casefold() + and enabled_command_flag(arguments, command, option_name) + ): + return True + return False + + def classify_sccfm(tokens: Sequence[str], schema: dict[str, Any]) -> tuple[str, str]: executable_indexes = [ index for index, token in enumerate(tokens) if executable_name(token) == SCCFM_EXECUTABLE @@ -186,6 +248,9 @@ def classify_sccfm(tokens: Sequence[str], schema: dict[str, Any]) -> tuple[str, path = command.get("path", []) if list(remaining[: len(path)]) != path: continue + arguments = remaining[len(path) :] + if is_schema_declared_preflight(arguments, command): + return "readonly", f"Schema-declared SCCFM preflight: {' '.join(path)}" if not command.get("readonly", False): return "review", f"Mutating SCCFM command: {' '.join(path)}" if path == ["schema", "export"] and not ({"--output", "-o"} & set(remaining)): @@ -204,7 +269,12 @@ def classify_command(command: str, schema: dict[str, Any] | None = None) -> tupl return "unrelated", "Command is outside the SCCFM guard scope" if tokens is None: return "review", "Compound or unparseable SCCFM command requires review" - executable = executable_name(tokens[0]) if tokens else "" + execution_tokens = strip_safe_ansible_environment(tokens) + if execution_tokens is None: + return "review", "Command environment or composition could not be proven safe" + executable = executable_name(execution_tokens[0]) if execution_tokens else "" + if executable == "ansible-playbook" and "--syntax-check" in execution_tokens[1:]: + return "readonly", "Ansible local syntax check" if executable in ANSIBLE_REVIEW_COMMANDS: return "review", f"{executable} can change local or managed state" if executable != SCCFM_EXECUTABLE: @@ -212,6 +282,8 @@ def classify_command(command: str, schema: dict[str, Any] | None = None) -> tupl active_schema = schema if schema is not None else load_schema() if active_schema is None: return "review", "The installed SCCFM schema was unavailable" + if uses_sensitive_flag(tokens, active_schema): + return "review", "SCCFM command includes a sensitive option" return classify_sccfm(tokens, active_schema) @@ -235,9 +307,13 @@ def approval_eligible(command: str, schema: dict[str, Any] | None = None) -> boo tokens = shell_tokens(command) if not tokens: return False - executable = executable_name(tokens[0]) + execution_tokens = strip_safe_ansible_environment(tokens) + if not execution_tokens: + return False + executable = executable_name(execution_tokens[0]) if executable in ANSIBLE_REVIEW_COMMANDS: - return True + classification, _reason = classify_command(command, schema) + return classification == "review" if executable != SCCFM_EXECUTABLE: return False active_schema = schema if schema is not None else load_schema() @@ -381,9 +457,9 @@ def exact_approval_command(prompt: str) -> str | None: def planned_command(message: str) -> str | None: candidates = [ - line.removeprefix(PLANNED_COMMAND_PREFIX).strip() + line.removeprefix(APPROVAL_PREFIX).strip() for line in message.splitlines() - if line.startswith(PLANNED_COMMAND_PREFIX) + if line.startswith(APPROVAL_PREFIX) ] if len(candidates) != 1: return None @@ -403,17 +479,6 @@ def deny_decision(reason: str) -> dict[str, Any]: } -def ask_decision(reason: str) -> dict[str, Any]: - return { - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "permissionDecision": "ask", - "permissionDecisionReason": reason, - "additionalContext": "The exact command approval was verified and consumed.", - } - } - - def process_user_prompt( event: dict[str, Any], state_directory: Path, schema: dict[str, Any] | None = None ) -> bool: @@ -467,7 +532,7 @@ def process_tool_use( ) if not approved: return deny_decision(reason) - return ask_decision(reason) if host == "claude" else None + return None def parse_arguments() -> argparse.Namespace: diff --git a/plugins/sccfm/scripts/setup_runtime.py b/plugins/sccfm/scripts/setup_runtime.py index 476f750..e804605 100644 --- a/plugins/sccfm/scripts/setup_runtime.py +++ b/plugins/sccfm/scripts/setup_runtime.py @@ -8,6 +8,8 @@ from __future__ import annotations import argparse +import hashlib +import hmac import json import os import re @@ -24,10 +26,44 @@ COLLECTION_NAME = "cisco.sccfm" COLLECTION_NAMESPACE = "cisco" COLLECTION_PACKAGE = "sccfm" +HOMEBREW_FORMULA = "ciscodevnet/tap/sccfm-cli" +HOMEBREW_UNINSTALL_ENVIRONMENT = {"HOMEBREW_NO_AUTOREMOVE": "1"} ANSIBLE_CORE_SPEC = "ansible-core>=2.20,<2.22" VERSION_PATTERN = re.compile(r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$") PACKAGE_NORMALIZATION_PATTERN = re.compile(r"[-_.]+") -INSTALL_STATE_SCHEMA_VERSION = 1 +INSTALL_STATE_SCHEMA_VERSION = 2 +LEGACY_INSTALL_STATE_SCHEMA_VERSION = 1 +CLEANUP_PLAN_SCHEMA_VERSION = 4 +CLEANUP_DIGEST_PATTERN = re.compile(r"^[0-9a-f]{64}$") +PIPX_RUNTIME_KIND = "pipx" +HOMEBREW_ANSIBLE_RUNTIME_KIND = "homebrew-ansible" +PYTHON_INSTALLATION_SCRIPT = """ +import importlib.metadata +import json +import sys + +try: + distribution = importlib.metadata.distribution("cisco-sccfm-devkit") +except importlib.metadata.PackageNotFoundError: + raise SystemExit(3) + +direct_url = {} +direct_url_text = distribution.read_text("direct_url.json") +if direct_url_text: + try: + direct_url = json.loads(direct_url_text) + except json.JSONDecodeError: + direct_url = {} + +dir_info = direct_url.get("dir_info", {}) if isinstance(direct_url, dict) else {} +print(json.dumps({ + "version": distribution.version, + "location": str(distribution.locate_file("")), + "environment": sys.prefix, + "editable": bool(dir_info.get("editable")) if isinstance(dir_info, dict) else False, + "source": direct_url.get("url") if isinstance(direct_url, dict) else None, +})) +""" def command_path(name: str) -> str | None: @@ -90,8 +126,16 @@ def expected_collection_path() -> Path: ) +def agent_runtime_root_path() -> Path: + return Path.home() / ".sccfm-agent-plugin" + + +def expected_ansible_runtime_path() -> Path: + return agent_runtime_root_path() / "ansible-runtime" + + def install_state_path() -> Path: - return Path.home() / ".sccfm-agent-plugin" / "runtime.json" + return agent_runtime_root_path() / "runtime.json" def load_install_state() -> dict[str, Any] | None: @@ -104,10 +148,10 @@ def load_install_state() -> dict[str, Any] | None: payload = json.loads(state_path.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError) as error: raise RuntimeError(f"runtime ownership state is invalid: {state_path}: {error}") from error - if ( - not isinstance(payload, dict) - or payload.get("schema_version") != INSTALL_STATE_SCHEMA_VERSION - ): + if not isinstance(payload, dict) or payload.get("schema_version") not in { + LEGACY_INSTALL_STATE_SCHEMA_VERSION, + INSTALL_STATE_SCHEMA_VERSION, + }: raise RuntimeError(f"runtime ownership state has an unsupported format: {state_path}") collection_path = payload.get("collection_path") version = payload.get("version") @@ -120,28 +164,57 @@ def load_install_state() -> dict[str, Any] | None: ) if not VERSION_PATTERN.fullmatch(version): raise RuntimeError(f"runtime ownership state contains an invalid version: {version}") + runtime_kind = payload.get("runtime_kind", PIPX_RUNTIME_KIND) + if runtime_kind not in {PIPX_RUNTIME_KIND, HOMEBREW_ANSIBLE_RUNTIME_KIND}: + raise RuntimeError(f"runtime ownership state has an invalid runtime kind: {runtime_kind}") + payload["runtime_kind"] = runtime_kind + if runtime_kind == HOMEBREW_ANSIBLE_RUNTIME_KIND: + runtime_path = payload.get("ansible_runtime_path") + if not isinstance(runtime_path, str): + raise RuntimeError(f"runtime ownership state has no Ansible runtime path: {state_path}") + recorded_runtime = Path(runtime_path).expanduser() + if ( + not recorded_runtime.is_absolute() + or recorded_runtime != expected_ansible_runtime_path() + ): + raise RuntimeError( + "runtime ownership state points outside the managed Ansible runtime: " + f"{recorded_runtime}" + ) return payload -def write_install_state(collection_path: Path, version: str) -> None: +def write_install_state( + collection_path: Path, + version: str, + *, + runtime_kind: str = PIPX_RUNTIME_KIND, +) -> None: if collection_path != expected_collection_path(): raise RuntimeError(f"refusing to own an unexpected collection path: {collection_path}") + if runtime_kind not in {PIPX_RUNTIME_KIND, HOMEBREW_ANSIBLE_RUNTIME_KIND}: + raise ValueError(f"unsupported runtime kind: {runtime_kind}") + if not VERSION_PATTERN.fullmatch(version): + raise ValueError("version must be a stable X.Y.Z release") state_path = install_state_path() + if state_path.parent.is_symlink() or ( + state_path.parent.exists() and not state_path.parent.is_dir() + ): + raise RuntimeError(f"runtime ownership directory is unsafe: {state_path.parent}") state_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) if os.name != "nt": state_path.parent.chmod(0o700) temporary_path = state_path.with_suffix(".tmp") + payload = { + "schema_version": INSTALL_STATE_SCHEMA_VERSION, + "collection_path": str(collection_path), + "runtime_kind": runtime_kind, + "version": version, + } + if runtime_kind == HOMEBREW_ANSIBLE_RUNTIME_KIND: + payload["ansible_runtime_path"] = str(expected_ansible_runtime_path()) temporary_path.write_text( - json.dumps( - { - "schema_version": INSTALL_STATE_SCHEMA_VERSION, - "collection_path": str(collection_path), - "version": version, - }, - indent=2, - sort_keys=True, - ) - + "\n", + json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8", ) if os.name != "nt": @@ -161,6 +234,21 @@ def remove_install_state() -> None: pass +def ansible_runtime_executable(name: str, runtime_path: Path | None = None) -> Path: + runtime = runtime_path or expected_ansible_runtime_path() + directory = "Scripts" if os.name == "nt" else "bin" + suffix = ".exe" if os.name == "nt" else "" + return runtime / directory / f"{name}{suffix}" + + +def ansible_command_path(name: str) -> str | None: + install_state = load_install_state() + if install_state is not None and install_state["runtime_kind"] == HOMEBREW_ANSIBLE_RUNTIME_KIND: + executable = ansible_runtime_executable(name) + return str(executable) if executable.is_file() else None + return command_path(name) + + def schema_metadata() -> dict[str, Any]: if command_path("sccfm-cli") is None: return {"ok": False, "error": "sccfm-cli is not on PATH"} @@ -180,11 +268,12 @@ def schema_metadata() -> dict[str, Any]: def collection_listing(environment: dict[str, str]) -> dict[str, Any]: - if command_path("ansible-galaxy") is None: - raise RuntimeError("ansible-galaxy is not on PATH") + ansible_galaxy = ansible_command_path("ansible-galaxy") + if ansible_galaxy is None: + raise RuntimeError("the selected ansible-galaxy executable is unavailable") result = run_capture( - ["ansible-galaxy", "collection", "list", COLLECTION_NAME, "--format", "json"], + [ansible_galaxy, "collection", "list", COLLECTION_NAME, "--format", "json"], environment=environment, limit=0, ) @@ -294,9 +383,10 @@ def doctor_report() -> dict[str, Any]: for name, path in python_candidates.items() if path is not None } - commands = { - name: command_path(name) for name in ("pipx", "sccfm-cli", "ansible-doc", "ansible-galaxy") - } + commands = {name: command_path(name) for name in ("brew", "pipx", "sccfm-cli")} + commands.update( + {name: ansible_command_path(name) for name in ("ansible-doc", "ansible-galaxy")} + ) schema = schema_metadata() report: dict[str, Any] = { "python_candidates": python_candidates, @@ -305,6 +395,16 @@ def doctor_report() -> dict[str, Any]: "profile": profile_metadata(), "schema": schema, } + try: + homebrew_installation = homebrew_formula_installation() + except RuntimeError as error: + report["homebrew"] = {"ok": False, "error": str(error)} + else: + report["homebrew"] = { + "ok": True, + "installed": homebrew_installation is not None, + "installation": homebrew_installation, + } report["cli_version"] = schema.get("version") if schema.get("ok") else None with tempfile.TemporaryDirectory(prefix="sccfm-agent-doctor-") as temporary_directory: ansible_environment = os.environ.copy() @@ -312,7 +412,7 @@ def doctor_report() -> dict[str, Any]: report["collection"] = collection_metadata(ansible_environment) if commands["ansible-doc"]: report["ansible_discovery"] = run_capture( - ["ansible-doc", "-j", "-l", "-t", "module", COLLECTION_NAME], + [commands["ansible-doc"], "-j", "-l", "-t", "module", COLLECTION_NAME], environment=ansible_environment, ) cli_version = report["cli_version"] @@ -363,36 +463,118 @@ def install_commands( ] +def homebrew_ansible_install_commands( + version: str, + python_command: str, + collection_base: Path | None = None, +) -> list[list[str]]: + if not VERSION_PATTERN.fullmatch(version): + raise ValueError("version must be a stable X.Y.Z release") + runtime_path = expected_ansible_runtime_path() + runtime_python = ansible_runtime_executable("python", runtime_path) + runtime_galaxy = ansible_runtime_executable("ansible-galaxy", runtime_path) + return [ + [python_command, "-m", "venv", str(runtime_path)], + [ + str(runtime_python), + "-m", + "pip", + "install", + "--disable-pip-version-check", + "--upgrade", + ANSIBLE_CORE_SPEC, + f"{PACKAGE_NAME}=={version}", + ], + [ + str(runtime_galaxy), + "collection", + "install", + f"{COLLECTION_NAME}:=={version}", + "--force", + "--collections-path", + str(collection_base or collection_install_base_path()), + ], + ] + + +def require_homebrew_version(installation: dict[str, Any], version: str) -> None: + versions = installation.get("versions", []) + if version not in versions: + installed_versions = ", ".join(str(value) for value in versions) or "unknown" + raise SystemExit( + f"Homebrew manages {HOMEBREW_FORMULA} {installed_versions}, not {version}; " + "use the installed CLI version for the matching Ansible runtime" + ) + + +def validate_managed_directory(path: Path, label: str, *, owned: bool) -> None: + if not path.exists() and not path.is_symlink(): + return + if path.is_symlink() or not path.is_dir(): + raise SystemExit(f"Managed {label} target is unsafe: {path}") + if not owned: + raise SystemExit(f"Refusing to overwrite an existing unowned {label}: {path}") + + +def validate_install_targets(runtime_kind: str) -> None: + runtime_root = agent_runtime_root_path() + if runtime_root.is_symlink() or (runtime_root.exists() and not runtime_root.is_dir()): + raise SystemExit(f"Managed runtime root is unsafe: {runtime_root}") + install_state = load_install_state() + owned = install_state is not None + if install_state is not None and install_state["runtime_kind"] != runtime_kind: + raise SystemExit( + "The existing runtime ownership record uses " + f"{install_state['runtime_kind']}; clean it up before installing {runtime_kind}" + ) + validate_managed_directory(expected_collection_path(), "collection", owned=owned) + if runtime_kind == HOMEBREW_ANSIBLE_RUNTIME_KIND: + validate_managed_directory( + expected_ansible_runtime_path(), + "Ansible runtime", + owned=owned, + ) + + def print_plan(version: str, python_command: str) -> None: - for command in install_commands(version, python_command): + homebrew_installation = homebrew_formula_installation() + if homebrew_installation is not None: + require_homebrew_version(homebrew_installation, version) + validate_install_targets(HOMEBREW_ANSIBLE_RUNTIME_KIND) + commands = homebrew_ansible_install_commands(version, python_command) + else: + validate_install_targets(PIPX_RUNTIME_KIND) + commands = install_commands(version, python_command) + for command in commands: print(shlex.join(command)) def install(version: str, python_command: str, confirmed: bool) -> None: if not confirmed: raise SystemExit("Refusing to install without --yes after user confirmation") - if command_path("pipx") is None: - raise SystemExit("pipx is required; install pipx before continuing") if command_path(python_command) is None: raise SystemExit(f"Python runtime is not on PATH: {python_command}") - target_path = expected_collection_path() - install_state = load_install_state() - if target_path.exists() or target_path.is_symlink(): - if target_path.is_symlink() or not target_path.is_dir(): - raise SystemExit(f"Managed collection target is unsafe: {target_path}") - if install_state is None: - raise SystemExit( - "Refusing to overwrite an existing collection that is not owned by this helper: " - f"{target_path}" - ) - elif install_state is not None: - raise SystemExit( - "Runtime ownership state exists but its collection is missing; " - f"remove or repair {install_state_path()} before reinstalling" + homebrew_installation = homebrew_formula_installation() + if homebrew_installation is not None: + require_homebrew_version(homebrew_installation, version) + validate_install_targets(HOMEBREW_ANSIBLE_RUNTIME_KIND) + commands = homebrew_ansible_install_commands(version, python_command) + write_install_state( + expected_collection_path(), + version, + runtime_kind=HOMEBREW_ANSIBLE_RUNTIME_KIND, ) - for command in install_commands(version, python_command): + runtime_kind = HOMEBREW_ANSIBLE_RUNTIME_KIND + else: + if command_path("pipx") is None: + raise SystemExit("pipx is required; install pipx before continuing") + validate_install_targets(PIPX_RUNTIME_KIND) + commands = install_commands(version, python_command) + runtime_kind = PIPX_RUNTIME_KIND + for command in commands: print(f"Running: {shlex.join(command)}") subprocess.run(command, check=True) + target_path = expected_collection_path() installed_path = validated_collection_path( str(collection_install_base_path() / "ansible_collections") ) @@ -400,7 +582,12 @@ def install(version: str, python_command: str, confirmed: bool) -> None: raise RuntimeError( f"collection was installed outside the expected managed path: {installed_path}" ) - write_install_state(installed_path, version) + if runtime_kind == HOMEBREW_ANSIBLE_RUNTIME_KIND: + ansible_doc = ansible_runtime_executable("ansible-doc") + if not ansible_doc.is_file(): + raise RuntimeError(f"managed Ansible runtime is incomplete: {ansible_doc}") + else: + write_install_state(installed_path, version, runtime_kind=runtime_kind) def discover_collection_paths() -> list[Path]: @@ -425,36 +612,443 @@ def partition_collection_paths(collection_paths: Sequence[Path]) -> tuple[list[P return [managed_path], [path for path in collection_paths if path != managed_path] +def pipx_package_environment() -> Path | None: + actual_home = Path.home() + configured_pipx_home = os.environ.get("PIPX_HOME") + if configured_pipx_home: + pipx_home = Path(configured_pipx_home).expanduser() + if not pipx_home.is_dir(): + return None + else: + pipx_home_candidates = ( + actual_home / ".local" / "share" / "pipx", + actual_home / ".local" / "pipx", + actual_home / ".pipx", + actual_home / "Library" / "Application Support" / "pipx", + ) + discovered_pipx_home = next( + (candidate for candidate in pipx_home_candidates if candidate.is_dir()), + None, + ) + if discovered_pipx_home is None: + return None + pipx_home = discovered_pipx_home + environments_path = pipx_home / "venvs" + if environments_path.is_symlink(): + raise RuntimeError(f"pipx environments path must not be a symlink: {environments_path}") + if not environments_path.is_dir(): + return None + expected_name = normalized_package_name(PACKAGE_NAME) + for environment_path in environments_path.iterdir(): + if environment_path.is_symlink() or not environment_path.is_dir(): + continue + metadata_path = environment_path / "pipx_metadata.json" + if metadata_path.is_symlink() or not metadata_path.is_file(): + continue + try: + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as error: + raise RuntimeError(f"pipx metadata is invalid: {metadata_path}: {error}") from error + main_package = metadata.get("main_package", {}) if isinstance(metadata, dict) else {} + package = main_package.get("package") if isinstance(main_package, dict) else None + if isinstance(package, str) and normalized_package_name(package) == expected_name: + return environment_path.resolve(strict=False) + return None + + def pipx_package_installed() -> bool: - if command_path("pipx") is None: - return False - result = run_capture(["pipx", "list", "--json"], limit=0) + return pipx_package_environment() is not None + + +def homebrew_formula_installation() -> dict[str, Any] | None: + brew = command_path("brew") + if brew is None: + return None + installed_formulae = run_capture([brew, "list", "--formula", "--full-name"], limit=0) + if not installed_formulae["ok"]: + detail = ( + installed_formulae.get("error") + or installed_formulae.get("output") + or "formula discovery failed" + ) + raise RuntimeError(f"cannot inspect Homebrew formulae: {detail}") + if HOMEBREW_FORMULA not in str(installed_formulae["output"]).splitlines(): + return None + version_result = run_capture( + [brew, "list", "--formula", "--versions", HOMEBREW_FORMULA], + limit=0, + ) + if not version_result["ok"]: + detail = version_result.get("error") or version_result.get("output") or "version failed" + raise RuntimeError(f"cannot inspect Homebrew formula version: {detail}") + fields = str(version_result["output"]).split() + versions = fields[1:] if fields and fields[0] == "sccfm-cli" else [] + if not versions: + raise RuntimeError( + f"Homebrew reports {HOMEBREW_FORMULA} but did not return an installed version" + ) + return { + "formula": HOMEBREW_FORMULA, + "versions": versions, + "command": [brew, "uninstall", HOMEBREW_FORMULA], + "environment": HOMEBREW_UNINSTALL_ENVIRONMENT, + } + + +def environment_python_paths(environment_path: Path) -> list[Path]: + if os.name == "nt": + return [environment_path / "Scripts" / "python.exe"] + return [ + environment_path / "bin" / "python", + environment_path / "bin" / "python3", + environment_path / "bin" / "python3.12", + ] + + +def resolved_command_path(name: str) -> Path | None: + executable = command_path(name) + if executable is None: + return None + path = Path(executable).expanduser() + pyenv = command_path("pyenv") + if path.parent.name == "shims" and pyenv is not None: + result = run_capture([pyenv, "which", name], limit=0) + if result["ok"]: + resolved = Path(str(result["output"])).expanduser() + if resolved.is_absolute() and resolved.is_file(): + return resolved + if not path.is_absolute() or not path.is_file(): + return None + return path + + +def candidate_python_paths(*, include_cli_candidate: bool) -> list[Path]: + candidates: list[Path] = [] + + def add_candidate(path: Path | None) -> None: + if path is None or not path.is_absolute() or not path.is_file(): + return + if path not in candidates: + candidates.append(path) + + for name in ("python3.12", "python3", "python"): + add_candidate(resolved_command_path(name)) + + pyenv = command_path("pyenv") + if pyenv is not None: + result = run_capture([pyenv, "prefix", "--all"], limit=0) + if result["ok"]: + for prefix in str(result["output"]).splitlines(): + prefix_path = Path(prefix.strip()).expanduser() + if not prefix_path.is_absolute(): + continue + for python_path in environment_python_paths(prefix_path): + add_candidate(python_path) + + virtual_environment = os.environ.get("VIRTUAL_ENV") + environment_paths = [Path.cwd() / ".venv"] + if virtual_environment: + environment_paths.append(Path(virtual_environment).expanduser()) + if include_cli_candidate: + cli_path = resolved_command_path("sccfm-cli") + if cli_path is not None and cli_path.parent.name in {"bin", "Scripts"}: + environment_paths.append(cli_path.parent.parent) + for environment_path in environment_paths: + if not environment_path.is_absolute(): + environment_path = environment_path.resolve(strict=False) + for python_path in environment_python_paths(environment_path): + add_candidate(python_path) + return candidates + + +def inspect_python_installation(interpreter: Path) -> dict[str, Any] | None: + result = run_capture([str(interpreter), "-c", PYTHON_INSTALLATION_SCRIPT], limit=0) + if result.get("exit_code") == 3: + return None if not result["ok"]: - raise RuntimeError(str(result.get("error") or result.get("output") or "pipx list failed")) + detail = result.get("error") or result.get("output") or "inspection failed" + raise RuntimeError(f"cannot inspect Python environment {interpreter}: {detail}") try: payload = json.loads(str(result["output"])) except json.JSONDecodeError as error: - raise RuntimeError(f"pipx output was not JSON: {error}") from error + raise RuntimeError( + f"Python environment inspection was not JSON for {interpreter}: {error}" + ) from error if not isinstance(payload, dict): - raise RuntimeError("pipx output was not a JSON object") - environments = payload.get("venvs", {}) - if not isinstance(environments, dict): - raise RuntimeError("pipx output did not contain a venvs object") - expected_name = normalized_package_name(PACKAGE_NAME) - for environment_name, environment in environments.items(): - if ( - isinstance(environment_name, str) - and normalized_package_name(environment_name) == expected_name - ): - return True - if not isinstance(environment, dict): + raise RuntimeError(f"Python environment inspection was invalid for {interpreter}") + version = payload.get("version") + location = payload.get("location") + environment = payload.get("environment") + if not all(isinstance(value, str) for value in (version, location, environment)): + raise RuntimeError(f"Python environment inspection was incomplete for {interpreter}") + source = payload.get("source") + return { + "interpreter": str(interpreter), + "version": version, + "location": location, + "environment": environment, + "editable": payload.get("editable") is True, + "source": source if isinstance(source, str) else None, + "command": [ + str(interpreter), + "-m", + "pip", + "uninstall", + "--yes", + PACKAGE_NAME, + ], + } + + +def discover_python_installations(*, include_cli_candidate: bool) -> list[dict[str, Any]]: + installations: dict[str, dict[str, Any]] = {} + for interpreter in candidate_python_paths(include_cli_candidate=include_cli_candidate): + installation = inspect_python_installation(interpreter) + if installation is None: continue - metadata = environment.get("metadata", {}) - main_package = metadata.get("main_package", {}) if isinstance(metadata, dict) else {} - package = main_package.get("package") if isinstance(main_package, dict) else None - if isinstance(package, str) and normalized_package_name(package) == expected_name: - return True - return False + location = str(installation["location"]) + installations.setdefault(location, installation) + return [installations[key] for key in sorted(installations)] + + +def collection_identity_matches(collection_path: Path) -> bool: + manifest_path = collection_path / "MANIFEST.json" + if manifest_path.is_symlink() or not manifest_path.is_file(): + return False + try: + payload = json.loads(manifest_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return False + collection_info = payload.get("collection_info", {}) if isinstance(payload, dict) else {} + return bool( + isinstance(collection_info, dict) + and collection_info.get("namespace") == COLLECTION_NAMESPACE + and collection_info.get("name") == COLLECTION_PACKAGE + ) + + +def discover_cleanup_collection_paths() -> list[Path]: + if ansible_command_path("ansible-galaxy") is not None: + return discover_collection_paths() + collection_path = expected_collection_path() + if not collection_path.exists() and not collection_path.is_symlink(): + return [] + if collection_path.is_symlink() or not collection_path.is_dir(): + raise RuntimeError(f"standard collection path is unsafe: {collection_path}") + if not collection_identity_matches(collection_path): + raise RuntimeError( + "cannot positively identify the collection without ansible-galaxy: " + f"{collection_path}" + ) + return [collection_path] + + +def partition_cleanup_collection_paths( + collection_paths: Sequence[Path], +) -> tuple[list[Path], list[Path]]: + install_state = load_install_state() + managed_path = Path(str(install_state["collection_path"])) if install_state else None + if managed_path is not None and managed_path not in collection_paths: + if managed_path.exists() or managed_path.is_symlink(): + raise RuntimeError( + "the recorded managed collection is not reported by ansible-galaxy: " + f"{managed_path}" + ) + removable: list[Path] = [] + preserved: list[Path] = [] + for path in collection_paths: + if path == expected_collection_path() or path == managed_path: + removable.append(path) + else: + preserved.append(path) + return removable, preserved + + +def managed_ansible_runtime_metadata() -> dict[str, Any]: + install_state = load_install_state() + runtime_path = expected_ansible_runtime_path() + if install_state is None or install_state["runtime_kind"] != HOMEBREW_ANSIBLE_RUNTIME_KIND: + return {"action": "preserve", "path": str(runtime_path), "exists": runtime_path.exists()} + if runtime_path.is_symlink() or (runtime_path.exists() and not runtime_path.is_dir()): + raise RuntimeError(f"managed Ansible runtime is unsafe: {runtime_path}") + return { + "action": "delete" if runtime_path.exists() else "absent", + "path": str(runtime_path), + "exists": runtime_path.exists(), + } + + +def cleanup_plan_digest(plan: dict[str, Any]) -> str: + digest_payload = {key: value for key, value in plan.items() if key != "plan_digest"} + encoded = json.dumps(digest_payload, separators=(",", ":"), sort_keys=True).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def cleanup_plan(remove_profiles: bool, include_editable: bool) -> dict[str, Any]: + collection_paths, preserved_collection_paths = partition_cleanup_collection_paths( + discover_cleanup_collection_paths() + ) + managed_environment = pipx_package_environment() + managed_environment_installed = managed_environment is not None + pipx_executable = command_path("pipx") + if managed_environment_installed and pipx_executable is None: + raise RuntimeError( + "the SCCFM pipx environment exists but pipx is unavailable; " + "install pipx before cleanup" + ) + pipx_command: list[str] | None = None + if managed_environment_installed: + assert pipx_executable is not None + pipx_command = [pipx_executable, "uninstall", PACKAGE_NAME] + homebrew_installation = homebrew_formula_installation() + python_installations = discover_python_installations( + include_cli_candidate=not managed_environment_installed and homebrew_installation is None + ) + if managed_environment is not None: + python_installations = [ + installation + for installation in python_installations + if Path(str(installation["environment"])).resolve(strict=False) != managed_environment + ] + removable_python = [ + installation + for installation in python_installations + if include_editable or not installation["editable"] + ] + preserved_python = [ + installation + for installation in python_installations + if installation["editable"] and not include_editable + ] + plan: dict[str, Any] = { + "schema_version": CLEANUP_PLAN_SCHEMA_VERSION, + "options": { + "include_editable": include_editable, + "remove_profiles": remove_profiles, + }, + "collection_paths": [str(path) for path in collection_paths], + "preserved_collection_paths": [str(path) for path in preserved_collection_paths], + "install_state": { + "action": "delete" if install_state_path().exists() else "absent", + "path": str(install_state_path()), + "exists": install_state_path().exists(), + }, + "ansible_runtime": managed_ansible_runtime_metadata(), + "homebrew_installation": homebrew_installation, + "pipx_command": pipx_command, + "python_installations": removable_python, + "preserved_python_installations": preserved_python, + "profile": { + "action": "delete" if remove_profiles else "preserve", + "path": str(profile_store_path()), + "exists": profile_store_path().exists(), + }, + } + plan["plan_digest"] = cleanup_plan_digest(plan) + return plan + + +def print_cleanup_plan(remove_profiles: bool, include_editable: bool, as_json: bool) -> None: + plan = cleanup_plan(remove_profiles, include_editable) + if as_json: + print(json.dumps(plan, indent=2, sort_keys=True)) + return + for path in plan["collection_paths"]: + print(f"Remove Ansible collection: {path}") + for path in plan["preserved_collection_paths"]: + print(f"Preserve Ansible collection outside the standard managed path: {path}") + if plan["pipx_command"]: + print(f"Run: {shlex.join(plan['pipx_command'])}") + ansible_runtime = plan["ansible_runtime"] + if ansible_runtime["action"] == "delete": + print(f"Remove managed Ansible runtime: {ansible_runtime['path']}") + elif ansible_runtime["exists"]: + print(f"Preserve unowned Ansible runtime: {ansible_runtime['path']}") + homebrew_installation = plan["homebrew_installation"] + if homebrew_installation: + versions = ", ".join(homebrew_installation["versions"]) + environment = " ".join( + f"{name}={value}" for name, value in homebrew_installation["environment"].items() + ) + print( + f"Remove Homebrew formula {homebrew_installation['formula']} {versions}: " + f"{environment} {shlex.join(homebrew_installation['command'])}" + ) + for installation in plan["python_installations"]: + print( + f"Remove Python package {installation['version']} from " + f"{installation['environment']}: {shlex.join(installation['command'])}" + ) + for installation in plan["preserved_python_installations"]: + print( + f"Preserve editable Python package {installation['version']} from " + f"{installation['source'] or installation['environment']}" + ) + profile = plan["profile"] + print(f"{str(profile['action']).capitalize()} profile store: {profile['path']}") + print(f"Plan digest: {plan['plan_digest']}") + + +def validate_collection_before_removal(collection_path: Path) -> None: + if collection_path != expected_collection_path(): + raise RuntimeError( + f"refusing to remove collection outside the standard path: {collection_path}" + ) + validated = validated_collection_path(str(collection_path.parents[1])) + if validated != collection_path: + raise RuntimeError(f"collection path changed after planning: {collection_path}") + + +def cleanup( + remove_profiles: bool, + include_editable: bool, + plan_digest: str, + confirmed: bool, +) -> None: + if not confirmed: + raise SystemExit("Refusing to clean up without --yes after user confirmation") + if not CLEANUP_DIGEST_PATTERN.fullmatch(plan_digest): + raise SystemExit("Cleanup requires the 64-character digest from the reviewed plan") + plan = cleanup_plan(remove_profiles, include_editable) + current_digest = str(plan["plan_digest"]) + if not hmac.compare_digest(plan_digest, current_digest): + raise RuntimeError( + "cleanup targets changed after review; generate and confirm a new cleanup plan" + ) + for collection_path in plan["collection_paths"]: + path = Path(collection_path) + validate_collection_before_removal(path) + print(f"Removing Ansible collection: {path}") + shutil.rmtree(path) + ansible_runtime = plan["ansible_runtime"] + if ansible_runtime["action"] == "delete": + runtime_path = Path(str(ansible_runtime["path"])) + if runtime_path != expected_ansible_runtime_path() or runtime_path.is_symlink(): + raise RuntimeError(f"managed Ansible runtime changed after planning: {runtime_path}") + print(f"Removing managed Ansible runtime: {runtime_path}") + shutil.rmtree(runtime_path) + if plan["install_state"]["action"] == "delete": + remove_install_state() + pipx_command = plan["pipx_command"] + if pipx_command: + print(f"Running: {shlex.join(pipx_command)}") + subprocess.run(pipx_command, check=True) + homebrew_installation = plan["homebrew_installation"] + if homebrew_installation: + homebrew_command = homebrew_installation["command"] + homebrew_environment = os.environ.copy() + homebrew_environment.update(homebrew_installation["environment"]) + print("Running without Homebrew dependency autoremove: " f"{shlex.join(homebrew_command)}") + subprocess.run(homebrew_command, check=True, env=homebrew_environment) + for installation in plan["python_installations"]: + command = installation["command"] + print(f"Running: {shlex.join(command)}") + subprocess.run(command, check=True) + if remove_profiles: + print(f"Removing profile store: {profile_store_path()}") + remove_profile_store() + else: + print(f"Preserving profile store: {profile_store_path()}") def uninstall_plan(remove_profiles: bool) -> dict[str, Any]: @@ -575,6 +1169,17 @@ def main() -> None: uninstall_parser.add_argument("--remove-profiles", action="store_true") uninstall_parser.add_argument("--yes", action="store_true") + cleanup_plan_parser = subparsers.add_parser("cleanup-plan") + cleanup_plan_parser.add_argument("--remove-profiles", action="store_true") + cleanup_plan_parser.add_argument("--include-editable", action="store_true") + cleanup_plan_parser.add_argument("--json", action="store_true") + + cleanup_parser = subparsers.add_parser("cleanup") + cleanup_parser.add_argument("--remove-profiles", action="store_true") + cleanup_parser.add_argument("--include-editable", action="store_true") + cleanup_parser.add_argument("--plan-digest", required=True) + cleanup_parser.add_argument("--yes", action="store_true") + arguments = parser.parse_args() if arguments.action == "doctor": report = doctor_report() @@ -596,6 +1201,25 @@ def main() -> None: uninstall(arguments.remove_profiles, arguments.yes) except RuntimeError as error: raise SystemExit(f"Cannot safely uninstall: {error}") from error + elif arguments.action == "cleanup-plan": + try: + print_cleanup_plan( + arguments.remove_profiles, + arguments.include_editable, + arguments.json, + ) + except RuntimeError as error: + raise SystemExit(f"Cannot safely plan cleanup: {error}") from error + elif arguments.action == "cleanup": + try: + cleanup( + arguments.remove_profiles, + arguments.include_editable, + arguments.plan_digest, + arguments.yes, + ) + except RuntimeError as error: + raise SystemExit(f"Cannot safely clean up: {error}") from error if __name__ == "__main__": diff --git a/plugins/sccfm/skills/sccfm-ansible/SKILL.md b/plugins/sccfm/skills/sccfm-ansible/SKILL.md index a39cfa3..8b5f0bc 100644 --- a/plugins/sccfm/skills/sccfm-ansible/SKILL.md +++ b/plugins/sccfm/skills/sccfm-ansible/SKILL.md @@ -1,7 +1,7 @@ --- name: sccfm-ansible description: Use the cisco.sccfm Ansible collection for SCC Firewall Manager by discovering modules, inventory plugins, and lookup plugins with ansible-doc at runtime, validating parameters, auth, check mode, and safety before generating or running playbooks. Use for cisco.sccfm Ansible modules, inventory, lookups, vault, and playbook workflows. Do NOT use for sccfm-cli commands; use the sccfm-cli skill instead. Do not use for Jira/Confluence work, architecture design, or non-Ansible tasks. -allowed-tools: "Bash(command -v *) Bash(source cisco_sccfm_scripts/activate.sh) Bash(poetry version --short) Bash(ansible-doc *) Bash(ansible-playbook *) Bash(ansible-inventory *) Bash(ansible-vault *) Bash(ansible-galaxy *) Bash(build-ansible-collection) Bash(sccfm-cli *) Bash(sccfm-cli-interactive *) Bash(jq *) Read Grep Glob Write Edit" +allowed-tools: "Bash(command -v *) Bash(source cisco_sccfm_scripts/activate.sh) Bash(poetry version --short) Bash(ansible-doc *) Bash(ansible-playbook *) Bash(ansible-inventory *) Bash(ansible-vault *) Bash(ansible-galaxy *) Bash(~/.sccfm-agent-plugin/ansible-runtime/bin/ansible-* *) Bash(build-ansible-collection) Bash(sccfm-cli *) Bash(sccfm-cli-interactive *) Bash(jq *) Read Grep Glob Write Edit" --- # SCC Firewall Manager Ansible Collection @@ -129,20 +129,38 @@ before execution. ### Step A: Resolve Ansible and the Collection Follow these checks in order: -1. Run `command -v ansible-doc`. -2. If you are inside this repository, `ansible-doc` is missing, and - `cisco_sccfm_scripts/activate.sh` exists, run `source cisco_sccfm_scripts/activate.sh` once for the - shell session, then resolve again. Do not use `poetry run`. -3. Run collection discovery: +1. On Unix, first check whether + `~/.sccfm-agent-plugin/ansible-runtime/bin/ansible-doc` exists. If it does, + it is the setup helper's companion for a Homebrew CLI. Use that absolute + `ansible-doc` path and the companion `ansible-playbook`, `ansible-inventory`, + `ansible-vault`, and `ansible-galaxy` paths for the entire request. Do not + activate the virtual environment or add it to `PATH`; this keeps the + Homebrew `sccfm-cli` authoritative. Otherwise use the ordinary command names. +2. Infer the one plugin type needed by the request: `module`, `inventory`, or + `lookup`. A playbook that calls SCCFM API operations needs module discovery + only. Do not enumerate unrelated plugin types. +3. Start with the matching collection-list command. Its success proves both + that `ansible-doc` is available and that the requested collection type is + discoverable: ```bash + # Run only the line matching the requested plugin type. ansible-doc -j -l -t module cisco.sccfm ansible-doc -j -l -t inventory cisco.sccfm ansible-doc -j -l -t lookup cisco.sccfm ``` -4. If discovery fails and you are inside this repository, run both commands, - then rerun discovery: +4. On a sandboxed Unix host that cannot write `~/.ansible/tmp`, prefix Ansible + discovery and validation commands with `ANSIBLE_LOCAL_TEMP=/tmp` from the + first call. `/tmp` already exists and Ansible creates and removes its own + private child directory, so do not create an `ansible.cfg` or probe the + unwritable default first. +5. If `ansible-doc` is missing and you are inside this repository, + `cisco_sccfm_scripts/activate.sh` exists, run + `source cisco_sccfm_scripts/activate.sh` once for the shell session, then + retry the selected discovery command. Do not use `poetry run`. +6. If discovery reports that `cisco.sccfm` is missing and you are inside this + repository, run both commands, then retry only the selected discovery: ```bash build-ansible-collection @@ -150,13 +168,13 @@ Follow these checks in order: "dist/cisco-sccfm-$(poetry version --short).tar.gz" --force ``` -5. If discovery succeeds and you are inside this repository, compare discovered - FQCNs with the corresponding files under `sccfm-ansible/plugins/modules/`, - `sccfm-ansible/plugins/inventory/`, or `sccfm-ansible/plugins/lookup/` only to - detect a stale installed collection. If source plugins are missing from - `ansible-doc`, build and install the generated tarball as above, then rerun - discovery. Do not use source filenames as the runtime schema. -6. If you are outside this repository, install or modify local Ansible state only +7. If discovery succeeds and you are inside this repository, compare the + discovered FQCNs only with the source directory for the selected plugin type + under `sccfm-ansible/plugins/`. Use this only to detect a stale installed + collection. If source plugins are missing from `ansible-doc`, build and + install the generated tarball as above, then rerun the selected discovery. + Do not use source filenames as the runtime schema. +8. If you are outside this repository, install or modify local Ansible state only when the user explicitly asks for setup. Otherwise, stop and explain that the `cisco.sccfm` collection is not installed. @@ -164,28 +182,24 @@ Re-discover if the virtualenv, collection install, or branch changes. ### Step B: Discover Runtime Schema -Export the module list once per session: +Reuse the selected list output from Step A; do not run the list command again. -```bash -ansible-doc -j -l -t module cisco.sccfm -``` - -For a matched module, fetch full JSON docs: +Fetch full JSON docs for every plausible module candidate in one call: ```bash -ansible-doc -j cisco.sccfm. +ansible-doc -j cisco.sccfm. [cisco.sccfm. ...] ``` -For dynamic inventory work, list inventory plugins, then fetch the matched plugin docs: +For dynamic inventory work, reuse the inventory list from Step A, then fetch +the matched plugin docs: ```bash -ansible-doc -j -l -t inventory cisco.sccfm ansible-doc -j -t inventory ``` -For lookup work, list lookup plugins, then fetch the matched plugin docs: +For lookup work, reuse the lookup list from Step A, then fetch the matched +plugin docs: ```bash -ansible-doc -j -l -t lookup cisco.sccfm ansible-doc -j -t lookup ``` @@ -202,6 +216,16 @@ Parse the JSON output. Use these fields as the schema: Cache the discovered JSON in memory for the session. Do not use stale docs after building or reinstalling the collection. +For the common Generate-Only module-playbook path, the expected fast flow is: + +1. One module-list call. +2. One full-doc call containing all plausible candidates. +3. Write the playbook once. +4. One local syntax check. + +Do not run profile connectivity checks, inventory discovery, lookup discovery, +live business operations, or check mode for a read-only Generate-Only request. + If discovery fails, stop and report the error. Do not guess what the collection supports. @@ -359,7 +383,16 @@ ansible-playbook --syntax-check ``` Use `--syntax-check` on generated playbooks whenever a playbook file exists and -the user did not forbid local validation. +the user did not forbid local validation. It is local validation, not execution +of the business playbook, and never requires an `EXECUTE` confirmation. On a +sandboxed Unix host that cannot write `~/.ansible/tmp`, use the safe temporary +directory from Step A: + +```bash +ANSIBLE_LOCAL_TEMP=/tmp ansible-playbook --syntax-check +``` + +Do not create or edit `ansible.cfg` solely to work around the sandbox. ### Inventory Validation @@ -451,24 +484,21 @@ shell command from the reviewed plan, prefixed with `EXECUTE `: EXECUTE ``` -When requesting this confirmation, also emit exactly one machine-readable plan -marker as a standalone line outside any code fence: - -```text -SCCFM_APPROVAL_COMMAND: -``` - -Replace the placeholder with the same command shown in the plan, without the -`EXECUTE ` prefix. Emit this marker only when the plan is complete and ready for -confirmation. Do not emit it in Generate-Only mode, for a check-mode-only plan, -or after the playbook has run. The plugin's Stop hook records only its digest so -that a later user confirmation cannot authorize a different command. +Show exactly one standalone +`EXECUTE ` confirmation line outside any +code fence when the plan is complete and ready for confirmation. Keep the +confirmation on one physical line; use the command's working directory and a +short relative path when needed. Do not emit a separate machine-readable marker. +The plugin's Stop hook derives the planned command from that visible line and +records only its digest so that a later user confirmation cannot authorize a +different command. Do not request confirmation in Generate-Only mode, for a +check-mode-only plan, or after the playbook has run. The text after `EXECUTE ` must exactly match the command the agent will submit to the shell, including inventory, playbook, limit, check-mode, and other CLI options. Require it as a standalone user message without a code fence or explanation. Never accept an edited, abbreviated, or compound command. The -plugin command guard compares it with the previously recorded plan marker, +plugin command guard compares it with the previously recorded plan command, creates a ten-minute receipt only for an exact match, and consumes that receipt after one matching execution attempt or clears it when the turn ends. Keep the module FQCN and target summary in the reviewed plan even though the approval diff --git a/plugins/sccfm/skills/sccfm-cli/SKILL.md b/plugins/sccfm/skills/sccfm-cli/SKILL.md index ede465b..34f4653 100644 --- a/plugins/sccfm/skills/sccfm-cli/SKILL.md +++ b/plugins/sccfm/skills/sccfm-cli/SKILL.md @@ -120,18 +120,43 @@ Follow these checks in order: Store the invocation prefix for the rest of the session. Do not switch invocation modes mid-session unless the user explicitly asks. -#### Installing via Homebrew +#### Optional CLI-only Homebrew installation -Only do this if the user explicitly asked for installation or setup. +Use Homebrew only when the user explicitly asks for Homebrew or a CLI-only +installation. Route complete CLI-plus-Ansible runtime setup to `sccfm-setup`, +whose canonical managed path uses pipx and a version-matched Galaxy collection. 1. Verify `brew` is in `PATH`. If not, stop and tell the user to install Homebrew. -2. Use `brew search` or the project release docs to discover the exact - tap/formula. -3. Install only the exact documented formula. Do not invent a Homebrew package - name. -4. If no Homebrew formula is published for the release, say so and use the - documented wheel or `pipx` install path instead. +2. Show the exact setup commands below and require the standalone confirmation + `INSTALL SCCFM CLI WITH HOMEBREW` before running any of them. `brew tap` and + `brew trust` change local Homebrew state, so do not infer confirmation from a + general setup request. + + ```bash + brew tap CiscoDevNet/tap + brew trust --formula CiscoDevNet/tap/sccfm-cli # required once on Homebrew 6.0+ + brew install CiscoDevNet/tap/sccfm-cli + ``` + + On Homebrew earlier than 6.0, omit the unsupported `brew trust` command. + Trust only the SCCFM formula; do not trust the whole tap unless the user + explicitly asks to trust every current and future item it contains. +3. After confirmation, tap and trust as applicable, then inspect the exact + `CiscoDevNet/tap/sccfm-cli` formula and show its available version. If the + user requested a version, continue only when the formula provides that exact + stable version. +4. Explain that Homebrew installs the CLI and Python library only. It does not + install the `cisco.sccfm` Ansible collection by itself. If the user wants + Ansible too, route the follow-up to `sccfm-setup`; it keeps the Homebrew CLI + and adds a private version-matched Ansible companion without exposing a + second CLI. +5. Install only after the tap, trust, and requested-version checks succeed. +6. After installation, verify the canonical full formula name and version with + Homebrew, resolve `sccfm-cli` on `PATH`, and export its schema. Stop if another + installation shadows the Homebrew executable. +7. If no Homebrew formula is published for the requested release, say so and + offer the managed `sccfm-setup` path instead. ### Step B: Verify Credentials @@ -431,24 +456,19 @@ the reviewed plan, prefixed with `EXECUTE `. Use this format: EXECUTE ``` -When requesting this confirmation, also emit exactly one machine-readable plan -marker as a standalone line outside any code fence: - -```text -SCCFM_APPROVAL_COMMAND: -``` - -Replace the placeholder with the same command shown in the plan, without the -`EXECUTE ` prefix. Emit this marker only when the plan is complete and ready for -confirmation. Do not emit it in Generate-Only mode, for a preflight-only plan, -or after the command has run. The plugin's Stop hook records only its digest so -that a later user confirmation cannot authorize a different command. +Show exactly one standalone `EXECUTE ` confirmation line +outside any code fence when the plan is complete and ready for confirmation. +Do not emit a separate machine-readable marker. The plugin's Stop hook derives +the planned command from that visible line and records only its digest so that a +later user confirmation cannot authorize a different command. Do not request +confirmation in Generate-Only mode, for a schema-declared preflight-only plan, +or after the command has run. The text after `EXECUTE ` must exactly match the command the agent will submit to the shell, including global options, command options, quoting, and targets. Require it as a standalone user message without a code fence or explanation. Never accept an edited, abbreviated, or compound command. The plugin command -guard compares it with the previously recorded plan marker, creates a ten-minute +guard compares it with the previously recorded plan command, creates a ten-minute receipt only for an exact match, and consumes that receipt after one matching execution attempt or clears it when the turn ends. diff --git a/plugins/sccfm/skills/sccfm-setup/SKILL.md b/plugins/sccfm/skills/sccfm-setup/SKILL.md index 07a41d0..5c6125c 100644 --- a/plugins/sccfm/skills/sccfm-setup/SKILL.md +++ b/plugins/sccfm/skills/sccfm-setup/SKILL.md @@ -1,23 +1,68 @@ --- name: sccfm-setup -description: Set up, repair, or remove the local Cisco SCC Firewall Manager agent runtime, including sccfm-cli, the matching cisco.sccfm Ansible collection, named-profile authentication, verification, and safe teardown. Use for first-time setup, installation, upgrades, authentication guidance, setup diagnostics, uninstall, or teardown. Do not use for ordinary SCCFM operations after setup; use sccfm-cli or sccfm-ansible instead. -allowed-tools: "Bash(python3 *) Bash(pipx *) Bash(sccfm-cli *) Bash(ansible-doc *) Bash(ansible-galaxy *) Read" +description: Set up or repair Cisco SCC Firewall Manager with either a complete pipx runtime or a managed Ansible companion for an existing Homebrew CLI, plus matching versions, named-profile authentication, and verification. Use for first-time setup, upgrades, authentication guidance, or setup diagnostics. Use sccfm-cli for an explicitly requested CLI-only Homebrew installation, sccfm-uninstall for teardown, and sccfm-cli or sccfm-ansible for ordinary operations. +allowed-tools: "Bash(command -v *) Bash(python3 *) Bash(pipx *) Bash(sccfm-cli *) Bash(ansible-doc *) Bash(ansible-galaxy *) Bash(~/.sccfm-agent-plugin/ansible-runtime/bin/ansible-doc *) WebSearch WebFetch Read" --- # SCC Firewall Manager Setup -Guide the user through a safe, resumable setup. Keep secrets out of chat and do -not install or replace software until the user approves the exact plan. +Install the requested runtime with the fewest necessary discovery steps. Keep +secrets out of chat and do not install or replace software until the user +approves the exact plan. ## Setup modes - **Check:** inspect the current runtime without changing it. -- **Install or upgrade:** install one stable, matching CLI and collection version. +- **Install or upgrade:** install one stable, matching CLI and collection version + while preserving an existing canonical Homebrew CLI when present. - **Authenticate:** configure a named SCCFM profile through the CLI's hidden prompt. - **Repair:** rerun checks and change only the failed component. -- **Uninstall:** remove the managed Galaxy collection and pipx environment, preserving profiles by default. -## 1. Inspect first +## 1. Choose the shortest path + +For an explicit first-time install or upgrade request, use the fast path below. +Do not run the full doctor before installation unless a prerequisite, ownership +check, or install command fails. Use the full doctor for check, diagnosis, and +repair requests. + +Before requesting install confirmation, resolve the intended profile and +region. Use profile `default` when the user did not request another profile. +Never guess the region. If it is missing, ask only for the SCCFM region and +continue once it is known. + +### Fast install path + +1. Run `command -v python3.12`, `command -v pipx`, and `command -v sccfm-cli` in + parallel when possible. Python 3.12 or later is required for both paths; + `pipx` is required only when no canonical Homebrew CLI is installed. +2. If `sccfm-cli` already exists, export its schema and use its stable version + as the candidate. Otherwise use a stable version supplied by the user, or + query the PyPI and Ansible Galaxy release metadata in parallel and select the + highest stable version present in both. Verify that the candidate exists for + both `cisco-sccfm-devkit` and `cisco.sccfm`. Never mix versions. +3. Generate the exact plan once: + + ```bash + python3 scripts/setup_runtime.py plan --version X.Y.Z --python python3.12 + ``` + +4. The helper automatically chooses the complete pipx path or, for the + canonical Homebrew formula, a private Ansible companion path. Summarize only + the retained or installed packages, version, destination, and exact + confirmation. + Require `INSTALL SCCFM X.Y.Z`, then run exactly one helper command: + + ```bash + python3 scripts/setup_runtime.py install --version X.Y.Z --python python3.12 --yes + ``` + +5. Verify the CLI schema export and Ansible collection discovery once. For a + Homebrew CLI, use the absolute managed `ansible-doc` path reported by the + helper; do not activate its virtual environment or expose its `sccfm-cli`. + Do not run connectivity checks before the user configures a profile, and do + not rerun the full doctor after a successful clean install. + +### Check and repair path Resolve this skill's plugin root, then run: @@ -26,19 +71,24 @@ python3 scripts/setup_runtime.py doctor --json ``` Summarize missing commands, detected versions, schema availability, collection -discovery, and whether a profile file exists. Never read or display the profile -file contents. +discovery, whether a profile file exists, and whether the CLI is managed by the +canonical `ciscodevnet/tap/sccfm-cli` Homebrew formula. Never read or display +the profile file contents. -Python 3.12 or later and `pipx` are prerequisites for the managed installation. -If either is missing, explain the smallest platform-appropriate installation -step and wait for approval before changing the machine. +Python 3.12 or later is required. `pipx` is also required for a complete install +when no Homebrew CLI is present. If a required prerequisite is missing, explain +the smallest platform-appropriate installation step and wait for approval +before changing the machine. -## 2. Plan installation +This skill never installs through Homebrew. If the user explicitly wants a +CLI-only Homebrew installation, route that request to `sccfm-cli`. When the +doctor finds the canonical formula, keep it and repair or install only its +helper-owned Ansible companion. Do not install a second CLI with pipx. + +## 2. Detailed installation and repair rules Use a stable release that exists for both `cisco-sccfm-devkit` on PyPI and -`cisco.sccfm` on Ansible Galaxy. Do not guess a version or mix versions. If the -user did not select one, inspect the official release sources and propose the -latest matching stable version. +`cisco.sccfm` on Ansible Galaxy. Do not guess a version or mix versions. Select an available Python 3.12 executable from the doctor report. Generate the exact plan without executing it: @@ -47,14 +97,25 @@ exact plan without executing it: python3 scripts/setup_runtime.py plan --version X.Y.Z --python python3.12 ``` -Explain that the plan installs the Python package with `pipx`, injects Ansible -into that same environment so modules can import `cisco_sccfm_core`, and installs -the collection at the identical version. The helper installs the collection at -the standard per-user path -`~/.ansible/collections/ansible_collections/cisco/sccfm` and records that exact -owned path in `~/.sccfm-agent-plugin/runtime.json`. If the target collection -already exists without that ownership record, stop and ask the user to resolve -the pre-existing installation; never overwrite or adopt it automatically. +The helper supports two version-aligned layouts: + +- **No Homebrew CLI:** pipx is the canonical installation method. The helper + installs `cisco-sccfm-devkit`, injects Ansible into the same environment, and + installs the identical Galaxy collection version. +- **Canonical Homebrew CLI present:** keep that CLI and its profile behavior. + The helper creates `~/.sccfm-agent-plugin/ansible-runtime`, installs + `ansible-core` and the exact matching `cisco-sccfm-devkit` library there, and + installs the same `cisco.sccfm` collection version. The companion virtual + environment is never activated or added to `PATH`, so it provides Ansible and + `cisco_sccfm_core` without exposing a second `sccfm-cli`. + +Both layouts install the collection at +`~/.ansible/collections/ansible_collections/cisco/sccfm` and record every +helper-owned path in `~/.sccfm-agent-plugin/runtime.json`. If the collection or +Homebrew companion directory already exists without that ownership record, +stop and ask the user to resolve it; never overwrite or adopt it automatically. +If the requested version differs from the installed Homebrew CLI version, stop +instead of producing a mixed runtime. Require the exact confirmation `INSTALL SCCFM X.Y.Z`. Only then run: @@ -70,30 +131,43 @@ unreviewed branch, draft release, or mismatched artifact set. SCCFM API tokens belong in the canonical named-profile store, never in chat, shell history, playbooks, `.env` files, or Ansible Vault. -Ask which profile name and region the user wants. Then tell the user to run the -schema-documented configure command locally so the token is entered through its -hidden prompt. The default-profile shape is: +After installation, finish by telling the user exactly which configure command +to run locally so the token is entered through its hidden prompt. Always include +the resolved profile and canonical region; never return placeholders such as +`` or ``. ```bash -sccfm-cli configure --region +sccfm-cli --profile default configure --region us ``` -For a non-default profile, place the schema-declared global profile option before -the command path. Derive accepted regions and all option names from -`sccfm-cli schema export --format json`; do not invent them. Tokens are created -in the SCC Firewall Manager UI or the linked Cisco developer authentication -flow. Never ask the user to paste a token into the conversation. +The command above illustrates the exact shape only. Substitute the profile and +region resolved for the current request before showing it. Quote a profile when +its name requires shell quoting. Place the schema-declared global profile option +before the command path. Derive accepted regions and option names from +`sccfm-cli schema export --format json`; do not invent them. + +Make the configure command the final actionable instruction in the setup +response and explain in one sentence that it prompts securely for the token. +Do not execute it through a non-interactive agent shell. Tokens are created in +the SCC Firewall Manager UI or the linked Cisco developer authentication flow. +Never ask the user to paste a token into the conversation. The same profile is consumed by `sccfm-cli` and the `cisco.sccfm` collection. Ansible Vault remains only for playbook-specific device secrets. ## 4. Verify -Rerun the doctor. Then use the `sccfm-cli` skill to discover and run the -schema-declared read-only connectivity/status operation. Use the `sccfm-ansible` -skill to verify module, inventory, and lookup discovery through `ansible-doc`. +For check and repair requests, rerun the doctor after changes. Then use the +`sccfm-cli` skill to discover and run the schema-declared read-only +connectivity/status operation. Use the `sccfm-ansible` skill to verify module, +inventory, and lookup discovery through the selected `ansible-doc`. When the +doctor reports a managed Homebrew companion, use its absolute Ansible command +paths for all subsequent Ansible work. -Setup is complete only when: +For a clean install without a profile, runtime installation is complete when +the CLI schema and Ansible discovery checks succeed; finish by showing the exact +configure command. Authenticated setup is complete after the user runs that +command and the following checks succeed: - CLI schema export succeeds; - CLI, Python package, and Ansible collection versions match; @@ -104,53 +178,18 @@ Setup is complete only when: If a check fails, stop at that component. Do not reinstall everything or retry authentication with another profile unless the user chooses that action. -## 5. Uninstall and teardown - -Teardown must happen before the plugin itself is removed, because uninstalling -the plugin does not remove the pipx environment, Galaxy collection, or profile -store. Resolve this skill's plugin root and generate a removal plan: - -```bash -python3 scripts/setup_runtime.py uninstall-plan -``` - -The helper discovers `cisco.sccfm` through `ansible-galaxy`, validates each -reported collection path, selects only the path matching its runtime ownership -record, confirms that `sccfm-cli` belongs to the managed pipx environment, and -preserves unowned collection copies. The helper preserves `~/.sccfm-cli/config.json` by default. -Show the full plan and require the exact confirmation `UNINSTALL SCCFM`. Only -then run: - -```bash -python3 scripts/setup_runtime.py uninstall --yes -``` - -The helper removes only its recorded Galaxy collection before uninstalling the -pipx environment. If discovery or ownership validation fails, stop; never guess -a collection directory, delete another reported copy, or construct a broad -recursive-delete command. - -Deleting named profiles and their API tokens is a separate destructive choice. -Only when the user explicitly asks to delete them, generate the expanded plan: - -```bash -python3 scripts/setup_runtime.py uninstall-plan --remove-profiles -``` - -Require the exact confirmation `UNINSTALL SCCFM AND DELETE PROFILES`, then run: - -```bash -python3 scripts/setup_runtime.py uninstall --remove-profiles --yes -``` +## 5. Removal and teardown -After runtime teardown succeeds, tell the user how to remove the plugin. Use -`/plugin uninstall sccfm@sccfm-devkit` in Claude Code or -`codex plugin remove sccfm@sccfm-devkit` in Codex. Marketplace removal is -optional and separate. +Route uninstall, teardown, and complete-cleanup requests to the +`sccfm-uninstall` skill. It handles both helper-managed and positively +discovered legacy installations with a separate destructive confirmation. ## Safety boundary -This skill manages setup and teardown only. After setup, route CLI work to +This skill manages either the complete pipx setup or the Ansible companion for +an existing canonical Homebrew CLI. Route the Homebrew installation itself to +`sccfm-cli` and teardown to `sccfm-uninstall`. +After setup, route CLI work to `sccfm-cli` and Ansible work to `sccfm-ansible`. Those skills may execute verified read-only operations. Mutating operations require a reviewed plan, the exact command, and the explicit confirmation phrase defined by the operational diff --git a/plugins/sccfm/skills/sccfm-uninstall/SKILL.md b/plugins/sccfm/skills/sccfm-uninstall/SKILL.md new file mode 100644 index 0000000..a50e5f3 --- /dev/null +++ b/plugins/sccfm/skills/sccfm-uninstall/SKILL.md @@ -0,0 +1,93 @@ +--- +name: sccfm-uninstall +description: Safely discover and remove local SCC Firewall Manager runtime artifacts, including Homebrew, pipx, or legacy Python sccfm-cli installs, the managed Ansible companion, the standard cisco.sccfm collection, and optional named profiles. Use for uninstall, teardown, or complete SCCFM cleanup. Do not use for installation, repair, CLI operations, or Ansible automation. +allowed-tools: "Bash(python3 *) Read" +--- + +# SCC Firewall Manager Uninstall + +Remove SCCFM runtime artifacts before removing this plugin. Use only the packaged +helper; do not construct manual `pip uninstall` or recursive-delete commands. + +## 1. Generate the reviewed plan + +Resolve this skill's plugin root, then run: + +```bash +python3 scripts/setup_runtime.py cleanup-plan --json +``` + +Add `--remove-profiles` only when the user explicitly asks to delete named +profiles and their stored API tokens. The helper reports profile metadata but +never reads or displays profile contents. + +The plan discovers the canonical `ciscodevnet/tap/sccfm-cli` Homebrew formula, +the managed pipx environment, the helper-owned Homebrew Ansible companion, +non-editable Python installs, and the positively identified standard Galaxy +collection. It reports each installation method and version independently so +multiple installs can be reviewed and removed together. Never infer Homebrew +ownership from an executable path or remove a same-named formula from another +tap. The reviewed Homebrew command disables automatic dependency removal so it +mutates only the SCCFM formula. + +The helper preserves collections outside the standard per-user path. It also +preserves editable Python installs by default; if any are reported, explain +their source and ask whether the user also wants to remove those development +installs. Only after that explicit choice, regenerate the plan with +`--include-editable`. + +Show the complete plan, including preserved artifacts and `plan_digest`. Do not +continue if discovery or path validation fails. + +## 2. Require exact confirmation + +When profiles are preserved, require the standalone confirmation: + +```text +UNINSTALL SCCFM +``` + +When profiles and their API tokens will be deleted, require: + +```text +UNINSTALL SCCFM AND PROFILES +``` + +`AND PROFILES` explicitly authorizes deletion of the named-profile store and +its API tokens. The confirmation authorizes only the reviewed plan. It does not +authorize different targets, editable installs that were not included in that +plan, or plugin removal. + +## 3. Execute the same plan + +After confirmation, use the same options and the exact digest returned by the +plan: + +```bash +python3 scripts/setup_runtime.py cleanup --plan-digest --yes +``` + +Include `--remove-profiles` and `--include-editable` exactly when they appeared +in the reviewed plan. The helper recomputes discovery and refuses to proceed if +the target set changed after review. + +The helper removes the standard Galaxy collection and its owned Ansible +companion first, then reviewed pipx, Homebrew, and discovered Python packages, +and finally the profile when requested. Never bypass a helper refusal with +direct filesystem deletion. + +## 4. Verify and remove the plugin separately + +Run: + +```bash +python3 scripts/setup_runtime.py doctor --json +``` + +Teardown is complete only when the requested CLI packages, collection, and +profile are absent. A preserved editable install is not an error when the user +chose to keep it. + +After successful teardown, tell the user that plugin removal is separate. Use +`/plugin uninstall sccfm@sccfm-devkit` in Claude Code or +`codex plugin remove sccfm@sccfm-devkit` in Codex. diff --git a/sccfm-ansible/CHANGELOG.rst b/sccfm-ansible/CHANGELOG.rst index b531c6e..78cc35b 100644 --- a/sccfm-ansible/CHANGELOG.rst +++ b/sccfm-ansible/CHANGELOG.rst @@ -4,6 +4,14 @@ Cisco SCCFM Collection Release Notes .. contents:: Topics +v0.41.0 +======== + +Minor Changes +------------- + +- Improved the SCCFM agent plugin with faster version-aligned setup, optional Homebrew CLI installation, a managed Ansible companion for Homebrew installs, safer exact-command approvals, and digest-bound runtime cleanup. + v0.40.2 ======== diff --git a/sccfm-ansible/changelogs/changelog.yaml b/sccfm-ansible/changelogs/changelog.yaml index d347a38..099eaa7 100644 --- a/sccfm-ansible/changelogs/changelog.yaml +++ b/sccfm-ansible/changelogs/changelog.yaml @@ -2,6 +2,15 @@ ancestor: null # sccfm-release-retarget-seed: 0.39.0 releases: + 0.41.0: + changes: + minor_changes: + - Improved the SCCFM agent plugin with faster version-aligned setup, + optional Homebrew CLI installation, a managed Ansible companion for + Homebrew installs, safer exact-command approvals, and digest-bound + runtime cleanup. + fragments: [] + release_date: '2026-09-02' 0.40.2: changes: bugfixes: diff --git a/skills/sccfm-ansible/SKILL.md b/skills/sccfm-ansible/SKILL.md index a39cfa3..8b5f0bc 100644 --- a/skills/sccfm-ansible/SKILL.md +++ b/skills/sccfm-ansible/SKILL.md @@ -1,7 +1,7 @@ --- name: sccfm-ansible description: Use the cisco.sccfm Ansible collection for SCC Firewall Manager by discovering modules, inventory plugins, and lookup plugins with ansible-doc at runtime, validating parameters, auth, check mode, and safety before generating or running playbooks. Use for cisco.sccfm Ansible modules, inventory, lookups, vault, and playbook workflows. Do NOT use for sccfm-cli commands; use the sccfm-cli skill instead. Do not use for Jira/Confluence work, architecture design, or non-Ansible tasks. -allowed-tools: "Bash(command -v *) Bash(source cisco_sccfm_scripts/activate.sh) Bash(poetry version --short) Bash(ansible-doc *) Bash(ansible-playbook *) Bash(ansible-inventory *) Bash(ansible-vault *) Bash(ansible-galaxy *) Bash(build-ansible-collection) Bash(sccfm-cli *) Bash(sccfm-cli-interactive *) Bash(jq *) Read Grep Glob Write Edit" +allowed-tools: "Bash(command -v *) Bash(source cisco_sccfm_scripts/activate.sh) Bash(poetry version --short) Bash(ansible-doc *) Bash(ansible-playbook *) Bash(ansible-inventory *) Bash(ansible-vault *) Bash(ansible-galaxy *) Bash(~/.sccfm-agent-plugin/ansible-runtime/bin/ansible-* *) Bash(build-ansible-collection) Bash(sccfm-cli *) Bash(sccfm-cli-interactive *) Bash(jq *) Read Grep Glob Write Edit" --- # SCC Firewall Manager Ansible Collection @@ -129,20 +129,38 @@ before execution. ### Step A: Resolve Ansible and the Collection Follow these checks in order: -1. Run `command -v ansible-doc`. -2. If you are inside this repository, `ansible-doc` is missing, and - `cisco_sccfm_scripts/activate.sh` exists, run `source cisco_sccfm_scripts/activate.sh` once for the - shell session, then resolve again. Do not use `poetry run`. -3. Run collection discovery: +1. On Unix, first check whether + `~/.sccfm-agent-plugin/ansible-runtime/bin/ansible-doc` exists. If it does, + it is the setup helper's companion for a Homebrew CLI. Use that absolute + `ansible-doc` path and the companion `ansible-playbook`, `ansible-inventory`, + `ansible-vault`, and `ansible-galaxy` paths for the entire request. Do not + activate the virtual environment or add it to `PATH`; this keeps the + Homebrew `sccfm-cli` authoritative. Otherwise use the ordinary command names. +2. Infer the one plugin type needed by the request: `module`, `inventory`, or + `lookup`. A playbook that calls SCCFM API operations needs module discovery + only. Do not enumerate unrelated plugin types. +3. Start with the matching collection-list command. Its success proves both + that `ansible-doc` is available and that the requested collection type is + discoverable: ```bash + # Run only the line matching the requested plugin type. ansible-doc -j -l -t module cisco.sccfm ansible-doc -j -l -t inventory cisco.sccfm ansible-doc -j -l -t lookup cisco.sccfm ``` -4. If discovery fails and you are inside this repository, run both commands, - then rerun discovery: +4. On a sandboxed Unix host that cannot write `~/.ansible/tmp`, prefix Ansible + discovery and validation commands with `ANSIBLE_LOCAL_TEMP=/tmp` from the + first call. `/tmp` already exists and Ansible creates and removes its own + private child directory, so do not create an `ansible.cfg` or probe the + unwritable default first. +5. If `ansible-doc` is missing and you are inside this repository, + `cisco_sccfm_scripts/activate.sh` exists, run + `source cisco_sccfm_scripts/activate.sh` once for the shell session, then + retry the selected discovery command. Do not use `poetry run`. +6. If discovery reports that `cisco.sccfm` is missing and you are inside this + repository, run both commands, then retry only the selected discovery: ```bash build-ansible-collection @@ -150,13 +168,13 @@ Follow these checks in order: "dist/cisco-sccfm-$(poetry version --short).tar.gz" --force ``` -5. If discovery succeeds and you are inside this repository, compare discovered - FQCNs with the corresponding files under `sccfm-ansible/plugins/modules/`, - `sccfm-ansible/plugins/inventory/`, or `sccfm-ansible/plugins/lookup/` only to - detect a stale installed collection. If source plugins are missing from - `ansible-doc`, build and install the generated tarball as above, then rerun - discovery. Do not use source filenames as the runtime schema. -6. If you are outside this repository, install or modify local Ansible state only +7. If discovery succeeds and you are inside this repository, compare the + discovered FQCNs only with the source directory for the selected plugin type + under `sccfm-ansible/plugins/`. Use this only to detect a stale installed + collection. If source plugins are missing from `ansible-doc`, build and + install the generated tarball as above, then rerun the selected discovery. + Do not use source filenames as the runtime schema. +8. If you are outside this repository, install or modify local Ansible state only when the user explicitly asks for setup. Otherwise, stop and explain that the `cisco.sccfm` collection is not installed. @@ -164,28 +182,24 @@ Re-discover if the virtualenv, collection install, or branch changes. ### Step B: Discover Runtime Schema -Export the module list once per session: +Reuse the selected list output from Step A; do not run the list command again. -```bash -ansible-doc -j -l -t module cisco.sccfm -``` - -For a matched module, fetch full JSON docs: +Fetch full JSON docs for every plausible module candidate in one call: ```bash -ansible-doc -j cisco.sccfm. +ansible-doc -j cisco.sccfm. [cisco.sccfm. ...] ``` -For dynamic inventory work, list inventory plugins, then fetch the matched plugin docs: +For dynamic inventory work, reuse the inventory list from Step A, then fetch +the matched plugin docs: ```bash -ansible-doc -j -l -t inventory cisco.sccfm ansible-doc -j -t inventory ``` -For lookup work, list lookup plugins, then fetch the matched plugin docs: +For lookup work, reuse the lookup list from Step A, then fetch the matched +plugin docs: ```bash -ansible-doc -j -l -t lookup cisco.sccfm ansible-doc -j -t lookup ``` @@ -202,6 +216,16 @@ Parse the JSON output. Use these fields as the schema: Cache the discovered JSON in memory for the session. Do not use stale docs after building or reinstalling the collection. +For the common Generate-Only module-playbook path, the expected fast flow is: + +1. One module-list call. +2. One full-doc call containing all plausible candidates. +3. Write the playbook once. +4. One local syntax check. + +Do not run profile connectivity checks, inventory discovery, lookup discovery, +live business operations, or check mode for a read-only Generate-Only request. + If discovery fails, stop and report the error. Do not guess what the collection supports. @@ -359,7 +383,16 @@ ansible-playbook --syntax-check ``` Use `--syntax-check` on generated playbooks whenever a playbook file exists and -the user did not forbid local validation. +the user did not forbid local validation. It is local validation, not execution +of the business playbook, and never requires an `EXECUTE` confirmation. On a +sandboxed Unix host that cannot write `~/.ansible/tmp`, use the safe temporary +directory from Step A: + +```bash +ANSIBLE_LOCAL_TEMP=/tmp ansible-playbook --syntax-check +``` + +Do not create or edit `ansible.cfg` solely to work around the sandbox. ### Inventory Validation @@ -451,24 +484,21 @@ shell command from the reviewed plan, prefixed with `EXECUTE `: EXECUTE ``` -When requesting this confirmation, also emit exactly one machine-readable plan -marker as a standalone line outside any code fence: - -```text -SCCFM_APPROVAL_COMMAND: -``` - -Replace the placeholder with the same command shown in the plan, without the -`EXECUTE ` prefix. Emit this marker only when the plan is complete and ready for -confirmation. Do not emit it in Generate-Only mode, for a check-mode-only plan, -or after the playbook has run. The plugin's Stop hook records only its digest so -that a later user confirmation cannot authorize a different command. +Show exactly one standalone +`EXECUTE ` confirmation line outside any +code fence when the plan is complete and ready for confirmation. Keep the +confirmation on one physical line; use the command's working directory and a +short relative path when needed. Do not emit a separate machine-readable marker. +The plugin's Stop hook derives the planned command from that visible line and +records only its digest so that a later user confirmation cannot authorize a +different command. Do not request confirmation in Generate-Only mode, for a +check-mode-only plan, or after the playbook has run. The text after `EXECUTE ` must exactly match the command the agent will submit to the shell, including inventory, playbook, limit, check-mode, and other CLI options. Require it as a standalone user message without a code fence or explanation. Never accept an edited, abbreviated, or compound command. The -plugin command guard compares it with the previously recorded plan marker, +plugin command guard compares it with the previously recorded plan command, creates a ten-minute receipt only for an exact match, and consumes that receipt after one matching execution attempt or clears it when the turn ends. Keep the module FQCN and target summary in the reviewed plan even though the approval diff --git a/skills/sccfm-cli/SKILL.md b/skills/sccfm-cli/SKILL.md index ede465b..34f4653 100644 --- a/skills/sccfm-cli/SKILL.md +++ b/skills/sccfm-cli/SKILL.md @@ -120,18 +120,43 @@ Follow these checks in order: Store the invocation prefix for the rest of the session. Do not switch invocation modes mid-session unless the user explicitly asks. -#### Installing via Homebrew +#### Optional CLI-only Homebrew installation -Only do this if the user explicitly asked for installation or setup. +Use Homebrew only when the user explicitly asks for Homebrew or a CLI-only +installation. Route complete CLI-plus-Ansible runtime setup to `sccfm-setup`, +whose canonical managed path uses pipx and a version-matched Galaxy collection. 1. Verify `brew` is in `PATH`. If not, stop and tell the user to install Homebrew. -2. Use `brew search` or the project release docs to discover the exact - tap/formula. -3. Install only the exact documented formula. Do not invent a Homebrew package - name. -4. If no Homebrew formula is published for the release, say so and use the - documented wheel or `pipx` install path instead. +2. Show the exact setup commands below and require the standalone confirmation + `INSTALL SCCFM CLI WITH HOMEBREW` before running any of them. `brew tap` and + `brew trust` change local Homebrew state, so do not infer confirmation from a + general setup request. + + ```bash + brew tap CiscoDevNet/tap + brew trust --formula CiscoDevNet/tap/sccfm-cli # required once on Homebrew 6.0+ + brew install CiscoDevNet/tap/sccfm-cli + ``` + + On Homebrew earlier than 6.0, omit the unsupported `brew trust` command. + Trust only the SCCFM formula; do not trust the whole tap unless the user + explicitly asks to trust every current and future item it contains. +3. After confirmation, tap and trust as applicable, then inspect the exact + `CiscoDevNet/tap/sccfm-cli` formula and show its available version. If the + user requested a version, continue only when the formula provides that exact + stable version. +4. Explain that Homebrew installs the CLI and Python library only. It does not + install the `cisco.sccfm` Ansible collection by itself. If the user wants + Ansible too, route the follow-up to `sccfm-setup`; it keeps the Homebrew CLI + and adds a private version-matched Ansible companion without exposing a + second CLI. +5. Install only after the tap, trust, and requested-version checks succeed. +6. After installation, verify the canonical full formula name and version with + Homebrew, resolve `sccfm-cli` on `PATH`, and export its schema. Stop if another + installation shadows the Homebrew executable. +7. If no Homebrew formula is published for the requested release, say so and + offer the managed `sccfm-setup` path instead. ### Step B: Verify Credentials @@ -431,24 +456,19 @@ the reviewed plan, prefixed with `EXECUTE `. Use this format: EXECUTE ``` -When requesting this confirmation, also emit exactly one machine-readable plan -marker as a standalone line outside any code fence: - -```text -SCCFM_APPROVAL_COMMAND: -``` - -Replace the placeholder with the same command shown in the plan, without the -`EXECUTE ` prefix. Emit this marker only when the plan is complete and ready for -confirmation. Do not emit it in Generate-Only mode, for a preflight-only plan, -or after the command has run. The plugin's Stop hook records only its digest so -that a later user confirmation cannot authorize a different command. +Show exactly one standalone `EXECUTE ` confirmation line +outside any code fence when the plan is complete and ready for confirmation. +Do not emit a separate machine-readable marker. The plugin's Stop hook derives +the planned command from that visible line and records only its digest so that a +later user confirmation cannot authorize a different command. Do not request +confirmation in Generate-Only mode, for a schema-declared preflight-only plan, +or after the command has run. The text after `EXECUTE ` must exactly match the command the agent will submit to the shell, including global options, command options, quoting, and targets. Require it as a standalone user message without a code fence or explanation. Never accept an edited, abbreviated, or compound command. The plugin command -guard compares it with the previously recorded plan marker, creates a ten-minute +guard compares it with the previously recorded plan command, creates a ten-minute receipt only for an exact match, and consumes that receipt after one matching execution attempt or clears it when the turn ends.