diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 00000000..7ba189da --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "sccfm-devkit", + "interface": { + "displayName": "Cisco SCC Firewall Manager" + }, + "plugins": [ + { + "name": "sccfm", + "source": { + "source": "local", + "path": "./plugins/sccfm" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Security" + } + ] +} diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 00000000..d985ee85 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,24 @@ +{ + "name": "sccfm-devkit", + "description": "Cisco SCC Firewall Manager plugins for AI coding agents", + "owner": { + "name": "Cisco DevNet", + "url": "https://developer.cisco.com" + }, + "plugins": [ + { + "name": "sccfm", + "source": "./plugins/sccfm", + "displayName": "SCC Firewall Manager", + "description": "Install, configure, and safely operate sccfm-cli and the cisco.sccfm Ansible collection.", + "category": "security", + "tags": [ + "cisco", + "sccfm", + "firewall-manager", + "security", + "ansible" + ] + } + ] +} diff --git a/.gitignore b/.gitignore index 752e137c..ea7e457b 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,9 @@ results/ # Local Codex/agent runtime metadata .agents/* +!.agents/plugins/ +.agents/plugins/* +!.agents/plugins/marketplace.json !.agents/skills/ .agents/skills/* !.agents/skills/sccfm-cli diff --git a/README.md b/README.md index 9c30cfeb..eb2224c5 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ package with the `sccfm-cli` and `sccfm-cli-interactive` commands, a reusable ## Table of Contents - [Getting started](#getting-started) +- [Claude Code and Codex plugin](#claude-code-and-codex-plugin) - [Commands](#commands) - [Python library](#python-library) - [Ansible collection](#ansible-collection) @@ -38,6 +39,42 @@ Python 3.12.4, `.venv/` hosts the project runtime, and `.venv/.poetry/` hosts Po was created by an older version of the script that installed Poetry into the project runtime, remove `.venv/` once and rerun the setup script. +## Claude Code and Codex plugin + +The repository contains an `sccfm` agent plugin for Claude Code and Codex. It +bundles guided installation and authentication setup with the canonical +`sccfm-cli` and `cisco.sccfm` Ansible skills. + +Claude Code: + +```text +/plugin marketplace add CiscoDevNet/sccfm-devkit +/plugin install sccfm@sccfm-devkit +``` + +Codex: + +```bash +codex plugin marketplace add CiscoDevNet/sccfm-devkit +codex plugin add sccfm@sccfm-devkit +``` + +After installation, ask the agent to `Set up SCC Firewall Manager for this +machine.` The setup checks Python and `pipx`, proposes a version-matched CLI and +Ansible installation plan, waits for the exact `INSTALL SCCFM X.Y.Z` +confirmation, and directs token entry to the CLI's hidden local prompt rather +than chat. + +The operational skills can execute schema-proven read-only commands. Mutating +commands require a typed `EXECUTE ` confirmation; broad or +bulk changes require two confirmations. Claude Code and Codex load a shared +pre-command hook backed by the same one-use approval guard. See +[`plugins/sccfm/README.md`](https://github.com/CiscoDevNet/sccfm-devkit/blob/main/plugins/sccfm/README.md) +for installation and local validation, and +[`docs/agent-plugin.md`](https://ciscodevnet.github.io/sccfm-devkit/agent-plugin.html) +for the complete capability and +end-user workflow. + ## Commands - `sccfm-cli configure --region REGION [--config-path PATH]`: Stores the SCCFM region (`int`, `us`, `eu`, `apj`, `au`, `uae`, `in`, or `ci`) and API token in the canonical named-profile store. The token comes from `SCCFM_API_TOKEN` or an interactive hidden prompt. Direct `--api-token` input remains available for compatibility but can expose the token in shell history and process listings. On POSIX systems, the default directory uses mode `0700` and the file uses `0600`; on Windows, the store inherits the user's profile-directory ACLs. 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 42442302..af4e72d5 100644 --- a/cisco_sccfm_cli/commands/tests/test_sccfm_cli_skill.py +++ b/cisco_sccfm_cli/commands/tests/test_sccfm_cli_skill.py @@ -51,7 +51,8 @@ def test_cisco_sccfm_cli_skill_should_cover_schema_driven_operation() -> None: "Class A", "Class B", "Class C", - "EXECUTE sccfm-cli ", + "EXECUTE ", + "SCCFM_APPROVAL_COMMAND: ", "Match User Intent Conservatively", "schema export", "not validated against live state", diff --git a/cisco_sccfm_core/tests/test_agent_plugin.py b/cisco_sccfm_core/tests/test_agent_plugin.py new file mode 100644 index 00000000..4feeb4c2 --- /dev/null +++ b/cisco_sccfm_core/tests/test_agent_plugin.py @@ -0,0 +1,755 @@ +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the cross-agent SCCFM plugin package.""" + +from __future__ import annotations + +import importlib.util +import json +import os +from pathlib import Path +from types import ModuleType + +import pytest + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +PLUGIN_ROOT = REPOSITORY_ROOT / "plugins" / "sccfm" + + +def load_setup_runtime() -> ModuleType: + module_path = PLUGIN_ROOT / "scripts" / "setup_runtime.py" + specification = importlib.util.spec_from_file_location("sccfm_setup_runtime", module_path) + assert specification is not None + assert specification.loader is not None + module = importlib.util.module_from_spec(specification) + specification.loader.exec_module(module) + return module + + +def load_command_guard() -> ModuleType: + module_path = PLUGIN_ROOT / "hooks" / "sccfm_guard.py" + specification = importlib.util.spec_from_file_location("sccfm_command_guard", module_path) + assert specification is not None + assert specification.loader is not None + module = importlib.util.module_from_spec(specification) + specification.loader.exec_module(module) + return module + + +def sample_schema() -> dict[str, object]: + return { + "global_options": [ + {"aliases": ["--profile"], "is_flag": False}, + {"aliases": ["--silent"], "is_flag": True}, + ], + "commands": [ + {"path": ["status"], "readonly": True, "side_effects": []}, + { + "path": ["schema", "export"], + "readonly": True, + "side_effects": ["May write --output"], + }, + { + "path": ["inventory", "devices", "delete"], + "readonly": False, + "options": [{"aliases": ["--api-token"], "sensitive": True}], + }, + { + "path": ["objects", "network", "delete"], + "readonly": False, + "options": [], + }, + ], + } + + +def test_plugin_manifests_and_marketplaces_are_aligned() -> None: + codex_manifest = json.loads((PLUGIN_ROOT / ".codex-plugin" / "plugin.json").read_text()) + claude_manifest = json.loads((PLUGIN_ROOT / ".claude-plugin" / "plugin.json").read_text()) + codex_marketplace = json.loads( + (REPOSITORY_ROOT / ".agents" / "plugins" / "marketplace.json").read_text() + ) + claude_marketplace = json.loads( + (REPOSITORY_ROOT / ".claude-plugin" / "marketplace.json").read_text() + ) + + assert codex_manifest["name"] == claude_manifest["name"] == "sccfm" + assert codex_manifest["version"] == claude_manifest["version"] + assert codex_marketplace["plugins"][0]["name"] == "sccfm" + assert codex_marketplace["plugins"][0]["source"]["path"] == "./plugins/sccfm" + assert claude_marketplace["plugins"][0]["name"] == "sccfm" + assert claude_marketplace["plugins"][0]["source"] == "./plugins/sccfm" + + +def test_codex_and_claude_hook_manifests_enforce_the_same_events() -> None: + codex_hooks = json.loads((PLUGIN_ROOT / "hooks.json").read_text()) + claude_hooks = json.loads((PLUGIN_ROOT / "hooks" / "hooks.json").read_text()) + + assert ( + set(codex_hooks["hooks"]) + == set(claude_hooks["hooks"]) + == { + "PreToolUse", + "Stop", + "UserPromptSubmit", + } + ) + assert codex_hooks["hooks"]["PreToolUse"][0]["matcher"] == "Bash" + assert claude_hooks["hooks"]["PreToolUse"][0]["matcher"] == "Bash" + + codex_commands = json.dumps(codex_hooks["hooks"]) + claude_commands = json.dumps(claude_hooks["hooks"]) + assert "./hooks/sccfm_guard.py" in codex_commands + assert "--record-plan" in codex_commands + assert "--host" not in codex_commands + assert "${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-.}}/hooks/sccfm_guard.py" in claude_commands + assert "--record-plan" in claude_commands + assert "--host" not in claude_commands + for manifest in (codex_hooks, claude_hooks): + for event_groups in manifest["hooks"].values(): + for event_group in event_groups: + for handler in event_group["hooks"]: + assert handler["commandWindows"].startswith("py -3 ") + assert "aligned" in codex_hooks["description"] + assert "aligned" in claude_hooks["description"] + + +@pytest.mark.parametrize("skill_name", ["sccfm-cli", "sccfm-ansible"]) +def test_distributed_skills_match_canonical_sources(skill_name: str) -> None: + canonical = REPOSITORY_ROOT / "skills" / skill_name / "SKILL.md" + distributed = PLUGIN_ROOT / "skills" / skill_name / "SKILL.md" + + assert distributed.read_bytes() == canonical.read_bytes() + + +def test_install_plan_uses_one_pipx_environment_and_matching_versions(tmp_path: Path) -> None: + setup_runtime = load_setup_runtime() + collection_base = tmp_path / "collections" + + assert setup_runtime.install_commands("0.39.3", "python3.12", collection_base) == [ + [ + "pipx", + "install", + "--python", + "python3.12", + "--force", + "cisco-sccfm-devkit==0.39.3", + ], + [ + "pipx", + "inject", + "--include-apps", + "--force", + "cisco-sccfm-devkit", + "ansible-core>=2.20,<2.22", + ], + [ + "ansible-galaxy", + "collection", + "install", + "cisco.sccfm:==0.39.3", + "--force", + "--collections-path", + str(collection_base), + ], + ] + + +@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() + + with pytest.raises(ValueError, match="stable X.Y.Z"): + setup_runtime.install_commands(version, "python3.12") + + +def test_collection_installations_accept_only_reported_collection_layout(tmp_path: Path) -> None: + setup_runtime = load_setup_runtime() + collection_root = tmp_path / "ansible_collections" + collection_path = collection_root / "cisco" / "sccfm" + collection_path.mkdir(parents=True) + + result = setup_runtime.collection_installations( + {str(collection_root): {"cisco.sccfm": {"version": "0.39.5"}}} + ) + + assert result == [{"path": str(collection_path), "version": "0.39.5"}] + + +def test_collection_installations_reject_unexpected_root(tmp_path: Path) -> None: + setup_runtime = load_setup_runtime() + collection_root = tmp_path / "collections" + (collection_root / "cisco" / "sccfm").mkdir(parents=True) + + with pytest.raises(ValueError, match="unexpected collection root"): + setup_runtime.collection_installations( + {str(collection_root): {"cisco.sccfm": {"version": "0.39.5"}}} + ) + + +@pytest.mark.skipif(os.name == "nt", reason="symlink semantics differ on Windows") +def test_collection_installations_reject_symlinked_root(tmp_path: Path) -> None: + setup_runtime = load_setup_runtime() + real_root = tmp_path / "real" / "ansible_collections" + (real_root / "cisco" / "sccfm").mkdir(parents=True) + linked_root = tmp_path / "ansible_collections" + linked_root.symlink_to(real_root, target_is_directory=True) + + with pytest.raises(ValueError, match="must not be a symlink"): + setup_runtime.collection_installations( + {str(linked_root): {"cisco.sccfm": {"version": "0.39.5"}}} + ) + + +def test_collection_metadata_prefers_the_recorded_copy_when_two_roots_exist( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + setup_runtime = load_setup_runtime() + monkeypatch.setattr(setup_runtime.Path, "home", classmethod(lambda cls: tmp_path)) + managed_path = setup_runtime.expected_collection_path() + managed_root = managed_path.parents[1] + unmanaged_root = tmp_path / "vendor" / "ansible_collections" + (unmanaged_root / "cisco" / "sccfm").mkdir(parents=True) + managed_path.mkdir(parents=True) + setup_runtime.write_install_state(managed_path, "0.39.5") + monkeypatch.setattr( + setup_runtime, + "collection_listing", + lambda environment: { + str(unmanaged_root): {"cisco.sccfm": {"version": "0.39.0"}}, + str(managed_root): {"cisco.sccfm": {"version": "0.39.5"}}, + }, + ) + + metadata = setup_runtime.collection_metadata({}) + + assert metadata["version"] == "0.39.5" + assert metadata["selected_path"] == str(managed_path) + assert metadata["managed"] is True + + +def test_pipx_package_discovery_normalizes_package_names( + monkeypatch: pytest.MonkeyPatch, +) -> None: + setup_runtime = load_setup_runtime() + monkeypatch.setattr(setup_runtime, "command_path", lambda name: f"/bin/{name}") + monkeypatch.setattr( + setup_runtime, + "run_capture", + lambda command, limit=1000: { + "ok": True, + "output": json.dumps( + { + "venvs": { + "cisco_sccfm_devkit": { + "metadata": {"main_package": {"package": "cisco-sccfm-devkit"}} + } + } + } + ), + }, + ) + + assert setup_runtime.pipx_package_installed() is True + + +def test_uninstall_plan_preserves_profiles_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)) + collection_path = setup_runtime.expected_collection_path() + collection_path.mkdir(parents=True) + setup_runtime.write_install_state(collection_path, "0.39.5") + monkeypatch.setattr(setup_runtime, "discover_collection_paths", lambda: [collection_path]) + monkeypatch.setattr( + setup_runtime, + "command_path", + lambda name: f"/bin/{name}" if name in {"pipx", "sccfm-cli"} else None, + ) + monkeypatch.setattr(setup_runtime, "pipx_package_installed", lambda: True) + + plan = setup_runtime.uninstall_plan(remove_profiles=False) + + assert plan["collection_paths"] == [str(collection_path)] + assert plan["preserved_collection_paths"] == [] + assert plan["pipx_command"] == ["pipx", "uninstall", "cisco-sccfm-devkit"] + assert plan["profile"] == { + "action": "preserve", + "path": str(tmp_path / ".sccfm-cli" / "config.json"), + "exists": False, + } + + +def test_uninstall_plan_refuses_an_unmanaged_cli(monkeypatch: pytest.MonkeyPatch) -> None: + setup_runtime = load_setup_runtime() + 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) + + with pytest.raises(RuntimeError, match="not owned by the managed pipx environment"): + setup_runtime.uninstall_plan(remove_profiles=False) + + +def test_uninstall_plan_removes_only_the_recorded_collection_when_two_roots_exist( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + setup_runtime = load_setup_runtime() + monkeypatch.setattr(setup_runtime.Path, "home", classmethod(lambda cls: tmp_path)) + managed_path = setup_runtime.expected_collection_path() + unmanaged_path = tmp_path / "vendor" / "ansible_collections" / "cisco" / "sccfm" + managed_path.mkdir(parents=True) + unmanaged_path.mkdir(parents=True) + setup_runtime.write_install_state(managed_path, "0.39.5") + monkeypatch.setattr( + setup_runtime, + "discover_collection_paths", + lambda: [managed_path, unmanaged_path], + ) + monkeypatch.setattr( + setup_runtime, + "command_path", + lambda name: f"/bin/{name}" if name in {"pipx", "sccfm-cli"} else None, + ) + monkeypatch.setattr(setup_runtime, "pipx_package_installed", lambda: True) + + plan = setup_runtime.uninstall_plan(remove_profiles=False) + + assert plan["collection_paths"] == [str(managed_path)] + assert plan["preserved_collection_paths"] == [str(unmanaged_path)] + + +def test_uninstall_requires_confirmation() -> None: + setup_runtime = load_setup_runtime() + + with pytest.raises(SystemExit, match="Refusing to uninstall"): + setup_runtime.uninstall(remove_profiles=False, confirmed=False) + + +def test_uninstall_removes_collection_before_pipx_and_preserves_profile( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + setup_runtime = load_setup_runtime() + collection_path = tmp_path / "ansible_collections" / "cisco" / "sccfm" + profile_path = tmp_path / ".sccfm-cli" / "config.json" + profile_path.parent.mkdir() + profile_path.write_text("secret") + events: list[str] = [] + monkeypatch.setattr( + setup_runtime, + "uninstall_plan", + lambda remove_profiles: { + "collection_paths": [str(collection_path)], + "preserved_collection_paths": [], + "pipx_command": ["pipx", "uninstall", "cisco-sccfm-devkit"], + "profile": {"action": "preserve", "path": str(profile_path), "exists": True}, + }, + ) + monkeypatch.setattr( + setup_runtime.shutil, + "rmtree", + lambda path: events.append(f"collection:{path}"), + ) + monkeypatch.setattr( + setup_runtime.subprocess, + "run", + lambda command, check: events.append(f"command:{' '.join(command)}"), + ) + monkeypatch.setattr(setup_runtime.Path, "home", classmethod(lambda cls: tmp_path)) + + setup_runtime.uninstall(remove_profiles=False, confirmed=True) + + assert events == [ + f"collection:{collection_path}", + "command:pipx uninstall cisco-sccfm-devkit", + ] + assert profile_path.exists() + + +def test_uninstall_deletes_profiles_only_with_explicit_option( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + setup_runtime = load_setup_runtime() + profile_path = tmp_path / ".sccfm-cli" / "config.json" + profile_path.parent.mkdir() + profile_path.write_text("secret") + monkeypatch.setattr( + setup_runtime, + "uninstall_plan", + lambda remove_profiles: { + "collection_paths": [], + "preserved_collection_paths": [], + "pipx_command": None, + "profile": {"action": "delete", "path": str(profile_path), "exists": True}, + }, + ) + monkeypatch.setattr(setup_runtime.Path, "home", classmethod(lambda cls: tmp_path)) + + setup_runtime.uninstall(remove_profiles=True, confirmed=True) + + assert not profile_path.exists() + + +def test_setup_skill_documents_safe_teardown_contract() -> None: + skill = (PLUGIN_ROOT / "skills" / "sccfm-setup" / "SKILL.md").read_text() + + assert "uninstall-plan" 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 "codex plugin remove sccfm@sccfm-devkit" in skill + + +def test_profile_diagnostics_expose_metadata_without_secret_contents( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + setup_runtime = load_setup_runtime() + profile_path = tmp_path / ".sccfm-cli" / "config.json" + profile_path.parent.mkdir() + profile_path.write_text('{"token": "must-not-appear"}') + profile_path.chmod(0o600) + monkeypatch.setattr(setup_runtime.Path, "home", classmethod(lambda cls: tmp_path)) + + result = setup_runtime.profile_metadata() + + assert result["configured"] is True + assert result["secure_permissions"] is True + assert "must-not-appear" not in json.dumps(result) + + +@pytest.mark.parametrize( + "command", + [ + "sccfm-cli status", + "sccfm-cli --profile default --silent status --format json", + "sccfm-cli schema export --format json", + "command -v sccfm-cli", + ], +) +def test_guard_allows_schema_proven_readonly_commands(command: str) -> None: + guard = load_command_guard() + + classification, _reason = guard.classify_command(command, sample_schema()) + + assert classification == "readonly" + + +@pytest.mark.parametrize( + "command", + [ + "sccfm-cli inventory devices delete --uid example", + "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", + "nohup sccfm-cli inventory devices delete --uid example", + "nice ansible-playbook change.yml", + "ansible-playbook change.yml", + "ansible-galaxy collection install cisco.sccfm", + ], +) +def test_guard_requires_review_for_mutating_local_write_or_composed_commands( + command: str, +) -> None: + guard = load_command_guard() + + classification, _reason = guard.classify_command(command, sample_schema()) + + assert classification == "review" + + +@pytest.mark.parametrize( + "command", + [ + "git status", + "sed -n '1,240p' skills/sccfm-cli/SKILL.md", + "rg sccfm-cli docs/agent-plugin.md", + ], +) +def test_guard_ignores_unrelated_commands(command: str) -> None: + guard = load_command_guard() + + classification, _reason = guard.classify_command(command, sample_schema()) + + assert classification == "unrelated" + + +@pytest.mark.parametrize( + "command", + [ + "sccfm-cli inventory devices delete --uid $(cat target.txt)", + "sccfm-cli inventory devices delete --uid `cat target.txt`", + "sccfm-cli inventory devices delete --uid example && echo changed", + "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", + "nohup sccfm-cli inventory devices delete --uid example", + "nice ansible-playbook change.yml", + "sccfm-cli inventory devices unknown --uid example", + "sccfm-cli inventory devices delete --uid example --api-token secret", + ], +) +def test_guard_rejects_unsafe_or_unverifiable_approval_commands(command: str) -> None: + guard = load_command_guard() + + assert guard.approval_eligible(command, sample_schema()) is False + + +def test_exact_approval_command_requires_a_standalone_message() -> None: + guard = load_command_guard() + command = "sccfm-cli inventory devices delete --uid example" + + assert guard.exact_approval_command(f"EXECUTE {command}") == command + assert guard.exact_approval_command(f"EXECUTE {command}\nplease") is None + assert guard.exact_approval_command(f"Please EXECUTE {command}") is None + assert guard.exact_approval_command("EXECUTE ") is None + + +def test_planned_command_requires_one_standalone_marker() -> 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 + + +def test_guard_detects_the_host_from_plugin_environment(monkeypatch: pytest.MonkeyPatch) -> None: + guard = load_command_guard() + monkeypatch.delenv("CLAUDE_PLUGIN_ROOT", raising=False) + + assert guard.detected_host() == "codex" + + monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", "/plugin") + assert guard.detected_host() == "claude" + + +def test_approval_receipt_is_hashed_private_and_one_use(tmp_path: Path) -> None: + guard = load_command_guard() + command = "sccfm-cli inventory devices delete --uid example" + + guard.store_approval(tmp_path, "session-one", command, now=100.0) + + receipt_path = guard.approval_path(tmp_path, "session-one") + receipt_text = receipt_path.read_text() + assert command not in receipt_text + assert guard.command_digest(command) in receipt_text + if os.name != "nt": + assert receipt_path.stat().st_mode & 0o777 == 0o600 + assert receipt_path.parent.stat().st_mode & 0o777 == 0o700 + assert guard.consume_approval(tmp_path, "session-one", command, now=101.0) is True + assert guard.consume_approval(tmp_path, "session-one", command, now=102.0) is False + + +def test_approval_receipt_expires_and_is_consumed_on_mismatch(tmp_path: Path) -> None: + guard = load_command_guard() + command = "sccfm-cli inventory devices delete --uid example" + + guard.store_approval(tmp_path, "expired", command, now=100.0) + assert guard.consume_approval(tmp_path, "expired", command, now=701.0) is False + assert not guard.approval_path(tmp_path, "expired").exists() + + guard.store_approval(tmp_path, "mismatch", command, now=100.0) + assert guard.consume_approval(tmp_path, "mismatch", f"{command} --force", now=101.0) is False + assert not guard.approval_path(tmp_path, "mismatch").exists() + + +def test_plan_receipt_is_hashed_private_and_consumed_only_on_match(tmp_path: Path) -> None: + guard = load_command_guard() + command = "sccfm-cli inventory devices delete --uid example" + + guard.store_plan(tmp_path, "session-one", command, now=100.0) + + receipt_path = guard.plan_path(tmp_path, "session-one") + receipt_text = receipt_path.read_text() + assert command not in receipt_text + assert guard.command_digest(command) in receipt_text + assert ( + guard.consume_matching_plan(tmp_path, "session-one", f"{command} --check", now=101.0) + is False + ) + assert receipt_path.exists() + assert guard.consume_matching_plan(tmp_path, "session-one", command, now=102.0) is True + assert not receipt_path.exists() + + +def test_plan_receipt_expires(tmp_path: Path) -> None: + guard = load_command_guard() + command = "sccfm-cli inventory devices delete --uid example" + + guard.store_plan(tmp_path, "expired", command, now=100.0) + + assert guard.consume_matching_plan(tmp_path, "expired", command, now=3701.0) is False + assert not guard.plan_path(tmp_path, "expired").exists() + + +def test_assistant_plan_records_only_one_eligible_exact_command(tmp_path: Path) -> None: + guard = load_command_guard() + command = "sccfm-cli inventory devices delete --uid example" + + assert ( + guard.process_assistant_plan( + { + "session_id": "planned", + "last_assistant_message": f"Plan ready.\nSCCFM_APPROVAL_COMMAND: {command}", + }, + tmp_path, + sample_schema(), + ) + is True + ) + assert guard.plan_path(tmp_path, "planned").exists() + assert ( + guard.process_assistant_plan( + { + "session_id": "readonly", + "last_assistant_message": "SCCFM_APPROVAL_COMMAND: sccfm-cli status", + }, + tmp_path, + sample_schema(), + ) + is False + ) + 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: + guard = load_command_guard() + command = "sccfm-cli inventory devices delete --uid example" + guard.store_plan(tmp_path, "session", command) + guard.store_approval(tmp_path, "session", command) + + assert ( + guard.process_assistant_plan( + {"session_id": "session", "last_assistant_message": "The plan was cancelled."}, + tmp_path, + sample_schema(), + ) + is False + ) + assert not guard.plan_path(tmp_path, "session").exists() + assert not guard.approval_path(tmp_path, "session").exists() + + +def test_user_prompt_promotes_only_the_previously_planned_exact_command(tmp_path: Path) -> None: + guard = load_command_guard() + command = "sccfm-cli inventory devices delete --uid example" + + assert ( + guard.process_user_prompt( + {"session_id": "unplanned", "prompt": f"EXECUTE {command}"}, + tmp_path, + sample_schema(), + ) + is False + ) + guard.store_plan(tmp_path, "approved", command) + assert ( + guard.process_user_prompt( + {"session_id": "approved", "prompt": f"EXECUTE {command}"}, + tmp_path, + sample_schema(), + ) + is True + ) + assert guard.approval_path(tmp_path, "approved").exists() + assert not guard.plan_path(tmp_path, "approved").exists() + + +def test_removing_check_from_planned_command_cannot_authorize_mutation(tmp_path: Path) -> None: + guard = load_command_guard() + command = "sccfm-cli objects network delete " "--uid 2405870d-7348-4b69-9580-a3de165b1671" + planned_command = f"{command} --check" + session_id = "edited-confirmation" + guard.store_plan(tmp_path, session_id, planned_command) + + assert ( + guard.process_user_prompt( + {"session_id": session_id, "prompt": f"EXECUTE {command}"}, + tmp_path, + sample_schema(), + ) + is False + ) + assert not guard.approval_path(tmp_path, session_id).exists() + assert guard.plan_path(tmp_path, session_id).exists() + + decision = guard.process_tool_use( + {"session_id": session_id, "tool_input": {"command": command}}, + "codex", + tmp_path, + sample_schema(), + ) + + assert decision["hookSpecificOutput"]["permissionDecision"] == "deny" + + +@pytest.mark.parametrize("host", ["claude", "codex"]) +def test_unapproved_mutating_command_is_denied(host: str, tmp_path: Path) -> None: + guard = load_command_guard() + command = "sccfm-cli inventory devices delete --uid example" + + decision = guard.process_tool_use( + {"session_id": "session", "tool_input": {"command": command}}, + host, + tmp_path, + sample_schema(), + ) + + assert decision["hookSpecificOutput"]["permissionDecision"] == "deny" + + +def test_codex_approved_command_proceeds_and_consumes_receipt(tmp_path: Path) -> None: + guard = load_command_guard() + command = "sccfm-cli inventory devices delete --uid example" + guard.store_approval(tmp_path, "codex-session", command) + + decision = guard.process_tool_use( + {"session_id": "codex-session", "tool_input": {"command": command}}, + "codex", + tmp_path, + sample_schema(), + ) + + assert decision is None + assert not guard.approval_path(tmp_path, "codex-session").exists() + + +def test_claude_approved_command_requests_host_confirmation(tmp_path: Path) -> None: + guard = load_command_guard() + command = "ansible-playbook -i inventory.yml change.yml" + guard.store_approval(tmp_path, "claude-session", command) + + decision = guard.process_tool_use( + {"session_id": "claude-session", "tool_input": {"command": command}}, + "claude", + tmp_path, + sample_schema(), + ) + + assert decision["hookSpecificOutput"]["permissionDecision"] == "ask" + assert not guard.approval_path(tmp_path, "claude-session").exists() + + +def test_readonly_command_does_not_need_or_consume_approval(tmp_path: Path) -> None: + guard = load_command_guard() + + assert ( + guard.process_tool_use( + {"session_id": "session", "tool_input": {"command": "sccfm-cli status"}}, + "codex", + tmp_path, + sample_schema(), + ) + is None + ) diff --git a/cisco_sccfm_core/tests/test_sccfm_ansible_skill.py b/cisco_sccfm_core/tests/test_sccfm_ansible_skill.py index 29c38b04..8f03f6bd 100644 --- a/cisco_sccfm_core/tests/test_sccfm_ansible_skill.py +++ b/cisco_sccfm_core/tests/test_sccfm_ansible_skill.py @@ -83,7 +83,8 @@ def test_sccfm_ansible_skill_documents_safety_and_secret_rules() -> None: assert "may be presented" in normalized_skill assert "module_defaults: group/cisco.sccfm.all" in skill assert "supports_check_mode=True" in skill - assert "EXECUTE cisco.sccfm " in skill + assert "EXECUTE " in skill + assert "SCCFM_APPROVAL_COMMAND: " 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 new file mode 100644 index 00000000..4e37039e --- /dev/null +++ b/docs/agent-plugin.md @@ -0,0 +1,431 @@ +--- +layout: page +title: SCC Firewall Manager Agent Plugin +--- + +# 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 +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-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. | + +The plugin does not contain an SCCFM API token, duplicate the SCCFM API, or +provide a separate MCP server. It teaches the agent to use the published CLI and +Ansible collection safely. + +## Goals + +The first release is intended to provide one installable package that: + +- supports Claude Code and Codex from the same repository; +- installs compatible CLI and Ansible artifacts instead of letting their + versions drift; +- guides users through local token configuration without asking them to paste a + token into chat; +- discovers commands, flags, modules, and parameters from the installed tools; +- runs verified read-only operations with minimal friction; +- requires review and explicit confirmation before changing SCCFM or a managed + device; and +- fails closed when the command, target, credentials, or safety classification + is unclear. + +## Capabilities + +### Runtime setup and repair + +The setup skill can: + +- detect Python 3.12, `pipx`, `sccfm-cli`, `ansible-doc`, and + `ansible-galaxy`; +- report whether an SCCFM profile exists without reading or displaying its + contents; +- export CLI schema metadata and discover the installed Ansible collection; +- detect a mismatch between the CLI and collection versions; +- 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. + +### Authentication guidance + +The setup skill directs users to the CLI's hidden local prompt. The API token is +stored in the canonical named-profile store used by both `sccfm-cli` and the +Ansible collection. + +The agent must not: + +- ask for the token in chat; +- place it on the command line; +- echo or log it; +- copy it into a playbook, `.env` file, or Ansible Vault; or +- inspect the contents of the profile store during diagnostics. + +Ansible Vault remains appropriate for playbook-specific secrets such as managed +device passwords, but not for the SCCFM API token. + +### CLI operations + +The CLI skill exports `sccfm-cli schema export --format json` once per session +and treats that schema as the source of truth. It can: + +- match a natural-language request to a command path; +- validate required options and option constraints; +- normalize regions using schema-declared values; +- translate supported natural-language filters into schema-declared queries; +- select named profiles; +- generate a command without executing it; +- run a verified read-only command; and +- preflight and plan a mutating command before asking for confirmation. + +It does not guess missing commands, flags, query fields, targets, or defaults. + +### Ansible operations + +The Ansible skill uses `ansible-doc` as its runtime schema. It can: + +- discover modules, inventory plugins, and lookup plugins; +- inspect required parameters, choices, examples, return values, and secret + fields; +- generate playbooks and inventory configuration; +- run syntax checks and inventory validation; +- run documented read-only automation; +- use check mode for mutations when the module supports it; and +- present an execution plan before a mutating playbook runs. + +When the documentation does not prove an Ansible action is read-only, the skill +classifies it as mutating. + +### Generate-only mode + +Users can ask for a command or playbook without allowing execution. The agent +may still perform local schema discovery and harmless validation unless the user +also prohibits those checks. It then returns the exact command or playbook and +states whether it was validated against live state. + +## Safety model + +Every operation is assigned one of three classes: + +| Class | Meaning | Agent behavior | +|---|---|---| +| A | Read-only with no local writes | May execute after command, profile, and target validation. | +| B | Read-only against SCCFM but writes a local profile, file, or export | Requires explicit opt-in and an explicit destination when applicable. | +| C | May modify SCCFM, a managed device, credentials, deployment state, or other local state | Requires a plan, preflight when available, exact targets, and typed confirmation. | + +For a CLI mutation, the final confirmation has this shape: + +```text +EXECUTE +``` + +For an Ansible mutation, it has this shape: + +```text +EXECUTE +``` + +Production, upgrade, credential, broad-target, and bulk mutations require two +separate confirmations: approval of the plan followed by the exact `EXECUTE` +message. The text after `EXECUTE ` must exactly match the shell command shown in +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 +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 +sensitive-argv commands cannot receive a receipt. + +## End-user workflow + +### 1. Install the plugin + +Claude Code: + +```text +/plugin marketplace add CiscoDevNet/sccfm-devkit +/plugin install sccfm@sccfm-devkit +``` + +Codex: + +```bash +codex plugin marketplace add CiscoDevNet/sccfm-devkit +codex plugin add sccfm@sccfm-devkit +``` + +These GitHub installation commands become the supported public path after the +plugin changes are merged into the repository's default branch. + +### 2. Ask for setup + +The user can start with: + +```text +Set up SCC Firewall Manager for this machine. +``` + +The agent first runs diagnostics. If installation or repair is required, it +shows the exact commands and selected version. Nothing is installed until the +user sends the requested `INSTALL SCCFM X.Y.Z` message. + +### 3. Configure a profile locally + +The agent asks for a profile name and region, then directs the user to a local +CLI configuration flow. Token entry happens in the CLI's hidden prompt, not in +the agent conversation. The resulting profile is shared by CLI and Ansible +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`. + +### 5. Review changes before execution + +For mutations, the agent resolves the exact target, runs available preflight or +check-mode validation, explains the intended effect, and displays the exact +command. The command runs only after the required confirmation message and host +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. + +While the plugin is still installed, ask: + +```text +Uninstall the SCCFM runtime installed by this plugin. +``` + +The setup skill resolves its plugin root and runs the plan-only helper: + +```bash +python3 scripts/setup_runtime.py uninstall-plan +``` + +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: + +```bash +python3 scripts/setup_runtime.py uninstall --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. + +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 +``` + +and requires `UNINSTALL SCCFM AND DELETE PROFILES` before running: + +```bash +python3 scripts/setup_runtime.py uninstall --remove-profiles --yes +``` + +The helper deletes only the canonical profile file and never reads or displays +its contents. After teardown succeeds, remove the plugin: + +Claude Code: + +```text +/plugin uninstall sccfm@sccfm-devkit +``` + +Codex: + +```bash +codex plugin remove sccfm@sccfm-devkit +``` + +Marketplace removal is optional and separate. + +## Examples + +### Check the installation + +User: + +```text +Check whether my SCCFM CLI and Ansible setup is healthy. +``` + +Expected behavior: + +1. Inspect prerequisites and versions without changing the machine. +2. Confirm whether the profile file exists without reading its contents. +3. Export CLI schema metadata and run Ansible discovery. +4. Report missing dependencies or version drift with the smallest corrective + action. + +### Run a read-only CLI request + +User: + +```text +Show the SCCFM subsystem status for my default profile. +``` + +Expected behavior: + +1. Export and inspect the live CLI schema. +2. Verify that the matched command is read-only and requires no local write. +3. Validate the default profile. +4. Run the command and summarize its result. + +### List devices using a named profile + +User: + +```text +Using my lab profile, list the first 20 SCCFM devices as JSON. +``` + +Expected behavior: + +1. Resolve the schema-declared device-list operation. +2. Place the global profile option before the command path. +3. Use only schema-declared pagination and output options. +4. Execute the read-only request and summarize the relevant fields. + +### Generate a mutation without running it + +User: + +```text +Generate the command to change the boot image for ASA branch-01. Do not run it. +``` + +Expected behavior: + +1. Select generate-only mode. +2. Resolve the command and required parameters from the live schema. +3. Perform read-only target resolution or preflight when allowed. +4. Return the exact command, clearly marked as not executed. +5. Do not request an `EXECUTE` confirmation. + +### Execute a mutation + +User: + +```text +Change the boot image for ASA branch-01 to disk0:/asa-new.bin. +``` + +Expected behavior: + +1. Prove the command is mutating from the live schema. +2. Resolve `branch-01` to an unambiguous target. +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. + +### Generate an Ansible playbook + +User: + +```text +Create an Ansible playbook that lists SCCFM network objects using my staging +profile, but do not run it. +``` + +Expected behavior: + +1. Discover the matching module and parameters with `ansible-doc`. +2. Use the named profile through the collection's documented profile mechanism. +3. Avoid embedding the SCCFM token. +4. Generate the playbook and run a local syntax check when permitted. +5. Return the file and execution command without contacting SCCFM. + +### Plan a broad Ansible change + +User: + +```text +Update this access rule across the production target group with Ansible. +``` + +Expected behavior: + +1. Discover and classify the module as mutating. +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. + +## Deliberate boundaries + +The first release does not: + +- publish or rotate SCCFM API tokens; +- execute an ambiguous or unclassified operation; +- bypass Claude Code or Codex permissions; +- guarantee transactional rollback for SCCFM changes; +- install prerelease or mismatched artifacts; +- automatically retry failed mutations; +- replace the generated CLI and Ansible reference documentation; or +- provide identical hook enforcement on every agent host. + +The skills are the portable policy layer. Host permissions, sandboxing, and the +shared Claude Code/Codex hooks provide additional enforcement where available. + +## Maintenance model + +The canonical operational skills remain under `skills/`. Before release, the +plugin copies must be synchronized and checked: + +```bash +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, +command guard, manifest alignment, secret-safe diagnostics, and copied-skill +integrity are covered by automated tests. diff --git a/docs/index.md b/docs/index.md index e40bd74a..4ce4ffce 100644 --- a/docs/index.md +++ b/docs/index.md @@ -7,6 +7,7 @@ These references are generated from the source code on every merge to `main`. - [CLI Reference](cli/index.html) - [Ansible Reference](ansible/index.html) +- [Claude Code and Codex Plugin](agent-plugin.html) ## Repository README diff --git a/plugins/sccfm/.claude-plugin/plugin.json b/plugins/sccfm/.claude-plugin/plugin.json new file mode 100644 index 00000000..7c93c46d --- /dev/null +++ b/plugins/sccfm/.claude-plugin/plugin.json @@ -0,0 +1,21 @@ +{ + "$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", + "author": { + "name": "Cisco DevNet", + "url": "https://developer.cisco.com" + }, + "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" + ] +} diff --git a/plugins/sccfm/.codex-plugin/plugin.json b/plugins/sccfm/.codex-plugin/plugin.json new file mode 100644 index 00000000..e6d2a7d7 --- /dev/null +++ b/plugins/sccfm/.codex-plugin/plugin.json @@ -0,0 +1,38 @@ +{ + "name": "sccfm", + "version": "0.1.0", + "description": "Install, configure, and safely operate Cisco SCC Firewall Manager from AI coding agents.", + "author": { + "name": "Cisco DevNet", + "url": "https://developer.cisco.com" + }, + "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" + ], + "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.", + "developerName": "Cisco DevNet", + "category": "Security", + "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." + ] + } +} diff --git a/plugins/sccfm/README.md b/plugins/sccfm/README.md new file mode 100644 index 00000000..5758a4f1 --- /dev/null +++ b/plugins/sccfm/README.md @@ -0,0 +1,111 @@ +# SCC Firewall Manager agent plugin + +This plugin packages guided setup and safety-aware operation for `sccfm-cli` and +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. + +## Install in Claude Code + +```text +/plugin marketplace add CiscoDevNet/sccfm-devkit +/plugin install sccfm@sccfm-devkit +``` + +Then ask: `Set up SCC Firewall Manager for this machine.` + +## Install in Codex + +```bash +codex plugin marketplace add CiscoDevNet/sccfm-devkit +codex plugin add sccfm@sccfm-devkit +``` + +Alternatively, open `/plugins` and select **SCC Firewall Manager**. Then ask +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. + +## Uninstall and teardown + +Removing the plugin does not remove the pipx environment, the Galaxy collection, +or local SCCFM profiles. Ask the installed plugin: + +```text +Uninstall the SCCFM runtime installed by this plugin. +``` + +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. + +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. + +Only after runtime teardown, remove the plugin: + +Claude Code: + +```text +/plugin uninstall sccfm@sccfm-devkit +``` + +Codex: + +```bash +codex plugin remove sccfm@sccfm-devkit +``` + +Removing the `sccfm-devkit` marketplace is optional and separate. The setup +helper intentionally validates Galaxy paths instead of publishing a broad +recursive-delete command. + +## Operation safety + +- Verified read-only operations may run automatically. +- Read-only exports or local writes require an explicit destination and opt-in. +- Mutating operations show their preflight result, targets, and exact command, + then require a standalone `EXECUTE ` message. +- Broad, production, upgrade, and bulk changes require two confirmations. + +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 +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. + +## Local development + +Keep the distributed operational skills synchronized with their canonical +repository copies: + +```bash +python3 plugins/sccfm/scripts/sync_skills.py +python3 plugins/sccfm/scripts/sync_skills.py --check +``` + +Validate the Codex plugin with the bundled plugin creator validator and run the +repository tests before publishing. diff --git a/plugins/sccfm/hooks.json b/plugins/sccfm/hooks.json new file mode 100644 index 00000000..926a3e89 --- /dev/null +++ b/plugins/sccfm/hooks.json @@ -0,0 +1,42 @@ +{ + "description": "Codex compatibility SCCFM command gate. Keep this policy aligned with hooks/hooks.json.", + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 ./hooks/sccfm_guard.py --record-plan", + "commandWindows": "py -3 .\\hooks\\sccfm_guard.py --record-plan", + "timeout": 30 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 ./hooks/sccfm_guard.py --record-approval", + "commandWindows": "py -3 .\\hooks\\sccfm_guard.py --record-approval", + "timeout": 30 + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "python3 ./hooks/sccfm_guard.py", + "commandWindows": "py -3 .\\hooks\\sccfm_guard.py", + "timeout": 30 + } + ] + } + ] + } +} diff --git a/plugins/sccfm/hooks/hooks.json b/plugins/sccfm/hooks/hooks.json new file mode 100644 index 00000000..8c7feb2e --- /dev/null +++ b/plugins/sccfm/hooks/hooks.json @@ -0,0 +1,42 @@ +{ + "description": "Shared Claude Code and Codex SCCFM command gate. Keep this policy aligned with ../hooks.json.", + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-.}}/hooks/sccfm_guard.py\" --record-plan", + "commandWindows": "py -3 \"%CLAUDE_PLUGIN_ROOT%\\hooks\\sccfm_guard.py\" --record-plan", + "timeout": 30 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-.}}/hooks/sccfm_guard.py\" --record-approval", + "commandWindows": "py -3 \"%CLAUDE_PLUGIN_ROOT%\\hooks\\sccfm_guard.py\" --record-approval", + "timeout": 30 + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-.}}/hooks/sccfm_guard.py\"", + "commandWindows": "py -3 \"%CLAUDE_PLUGIN_ROOT%\\hooks\\sccfm_guard.py\"", + "timeout": 30 + } + ] + } + ] + } +} diff --git a/plugins/sccfm/hooks/sccfm_guard.py b/plugins/sccfm/hooks/sccfm_guard.py new file mode 100644 index 00000000..4c49aa59 --- /dev/null +++ b/plugins/sccfm/hooks/sccfm_guard.py @@ -0,0 +1,504 @@ +#!/usr/bin/env python3 +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +"""Gate risky SCCFM shell commands with short-lived, exact-command approvals.""" + +from __future__ import annotations + +import argparse +import hashlib +import hmac +import json +import os +import shlex +import shutil +import subprocess +import sys +import time +from pathlib import Path +from typing import Any, Literal, Sequence, cast + +SCCFM_EXECUTABLE = "sccfm-cli" +ANSIBLE_REVIEW_COMMANDS = {"ansible-galaxy", "ansible-playbook"} +SHELL_CONTROL_CHARACTERS = frozenset(";&|<>") +SHELL_SUBSTITUTION_MARKERS = ("$", "`") +SHELL_WRAPPER_EXECUTABLES = { + "bash", + "command", + "env", + "nohup", + "nice", + "sh", + "sudo", + "time", + "xargs", + "zsh", +} +APPROVAL_PREFIX = "EXECUTE " +PLANNED_COMMAND_PREFIX = "SCCFM_APPROVAL_COMMAND: " +APPROVAL_TTL_SECONDS = 600 +PLAN_TTL_SECONDS = 3600 +Host = Literal["claude", "codex"] + + +def shell_tokens(command: str) -> list[str] | None: + if any(marker in command for marker in SHELL_SUBSTITUTION_MARKERS): + return None + try: + lexer = shlex.shlex(command, posix=True, punctuation_chars=";&|<>") + lexer.whitespace_split = True + lexer.commenters = "" + tokens = list(lexer) + except ValueError: + return None + if any(set(token) <= SHELL_CONTROL_CHARACTERS for token in tokens): + return None + return tokens + + +def executable_name(token: str) -> str: + return Path(token).name + + +def is_assignment_word(token: str) -> bool: + name, separator, _value = token.partition("=") + return bool( + separator + and name + and (name[0].isalpha() or name[0] == "_") + and all(character.isalnum() or character == "_" for character in name[1:]) + ) + + +def contains_guarded_executable(command: str) -> bool: + try: + lexer = shlex.shlex(command, posix=True, punctuation_chars=";&|<>") + lexer.whitespace_split = True + lexer.commenters = "" + tokens = list(lexer) + except ValueError: + return any( + executable in command + for executable in (SCCFM_EXECUTABLE, *sorted(ANSIBLE_REVIEW_COMMANDS)) + ) + guarded_executables = {SCCFM_EXECUTABLE, *ANSIBLE_REVIEW_COMMANDS} + execution_index = 0 + while execution_index < len(tokens) and is_assignment_word(tokens[execution_index]): + execution_index += 1 + if ( + execution_index < len(tokens) + and executable_name(tokens[execution_index]) in guarded_executables + ): + return True + for index, token in enumerate(tokens[1:], start=1): + if executable_name(token) not in guarded_executables: + continue + previous = tokens[index - 1] + if set(previous) <= SHELL_CONTROL_CHARACTERS or previous in {"-exec", "-execdir"}: + return True + first_executable = ( + executable_name(tokens[execution_index]) if execution_index < len(tokens) else "" + ) + if first_executable in SHELL_WRAPPER_EXECUTABLES: + return any( + executable_name(token) in guarded_executables + or ( + first_executable in {"bash", "sh", "zsh"} + and any(executable in token for executable in guarded_executables) + ) + for token in tokens[execution_index + 1 :] + ) + return False + + +def is_executable_discovery(tokens: Sequence[str]) -> bool: + guarded_executables = {SCCFM_EXECUTABLE, *ANSIBLE_REVIEW_COMMANDS} + return bool( + len(tokens) >= 3 + and list(tokens[:2]) == ["command", "-v"] + and all(executable_name(token) in guarded_executables for token in tokens[2:]) + ) + + +def load_schema() -> dict[str, Any] | None: + executable = shutil.which(SCCFM_EXECUTABLE) + if executable is None: + return None + try: + result = subprocess.run( + [executable, "schema", "export", "--format", "json"], + check=False, + capture_output=True, + text=True, + timeout=20, + ) + if result.returncode != 0: + return None + payload = json.loads(result.stdout) + except (json.JSONDecodeError, OSError, subprocess.TimeoutExpired): + return None + return payload if isinstance(payload, dict) else None + + +def strip_global_options(tokens: Sequence[str], schema: dict[str, Any]) -> list[str] | None: + options: dict[str, dict[str, Any]] = {} + for option in schema.get("global_options", []): + for alias in option.get("aliases", []): + options[alias] = option + + remaining = list(tokens) + while remaining and remaining[0].startswith("-"): + token = remaining.pop(0) + if token in {"--help", "-h"}: + return ["--help"] + flag, separator, _value = token.partition("=") + option = options.get(flag) + if option is None: + return None + if not separator and not option.get("is_flag", False): + if not remaining: + return None + remaining.pop(0) + return remaining + + +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 + ] + if len(executable_indexes) != 1 or executable_indexes[0] != 0: + return "review", "SCCFM command composition could not be proven safe" + + remaining = strip_global_options(tokens[1:], schema) + if remaining is None: + return "review", "SCCFM global options could not be classified" + if remaining == ["--help"] or not remaining: + return "readonly", "SCCFM help invocation" + + commands = sorted( + schema.get("commands", []), + key=lambda command: len(command.get("path", [])), + reverse=True, + ) + for command in commands: + path = command.get("path", []) + if list(remaining[: len(path)]) != path: + continue + if not command.get("readonly", False): + return "review", f"Mutating SCCFM command: {' '.join(path)}" + if path == ["schema", "export"] and not ({"--output", "-o"} & set(remaining)): + return "readonly", "Schema export to standard output" + if command.get("side_effects"): + return "review", f"SCCFM command has local side effects: {' '.join(path)}" + return "readonly", f"Read-only SCCFM command: {' '.join(path)}" + return "review", "SCCFM command path is absent from the installed schema" + + +def classify_command(command: str, schema: dict[str, Any] | None = None) -> tuple[str, str]: + tokens = shell_tokens(command) + if tokens is not None and is_executable_discovery(tokens): + return "readonly", "SCCFM executable discovery" + if not contains_guarded_executable(command): + 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 "" + if executable in ANSIBLE_REVIEW_COMMANDS: + return "review", f"{executable} can change local or managed state" + if executable != SCCFM_EXECUTABLE: + return "review", "Nested SCCFM invocation requires review" + active_schema = schema if schema is not None else load_schema() + if active_schema is None: + return "review", "The installed SCCFM schema was unavailable" + return classify_sccfm(tokens, active_schema) + + +def sensitive_flags(schema: dict[str, Any]) -> set[str]: + flags: set[str] = set() + command_options = [ + option for command in schema.get("commands", []) for option in command.get("options", []) + ] + for option in [*schema.get("global_options", []), *command_options]: + if option.get("sensitive"): + flags.update(option.get("aliases", [])) + return flags + + +def uses_sensitive_flag(tokens: Sequence[str], schema: dict[str, Any]) -> bool: + protected_flags = sensitive_flags(schema) + return any(token.partition("=")[0] in protected_flags for token in tokens) + + +def approval_eligible(command: str, schema: dict[str, Any] | None = None) -> bool: + tokens = shell_tokens(command) + if not tokens: + return False + executable = executable_name(tokens[0]) + if executable in ANSIBLE_REVIEW_COMMANDS: + return True + if executable != SCCFM_EXECUTABLE: + return False + active_schema = schema if schema is not None else load_schema() + if active_schema is None or uses_sensitive_flag(tokens, active_schema): + return False + classification, reason = classify_sccfm(tokens, active_schema) + return classification == "review" and reason.startswith( + ("Mutating SCCFM command:", "SCCFM command has local side effects:") + ) + + +def plugin_data_directory() -> Path | None: + configured = os.environ.get("PLUGIN_DATA") or os.environ.get("CLAUDE_PLUGIN_DATA") + return Path(configured) if configured else None + + +def detected_host() -> Host: + return "claude" if os.environ.get("CLAUDE_PLUGIN_ROOT") else "codex" + + +def approval_path(state_directory: Path, session_id: str) -> Path: + session_digest = hashlib.sha256(session_id.encode()).hexdigest() + return state_directory / "sccfm-command-approvals" / f"{session_digest}.json" + + +def plan_path(state_directory: Path, session_id: str) -> Path: + session_digest = hashlib.sha256(session_id.encode()).hexdigest() + return state_directory / "sccfm-command-plans" / f"{session_digest}.json" + + +def command_digest(command: str) -> str: + return hashlib.sha256(command.encode()).hexdigest() + + +def store_command_digest( + receipt_path: Path, + command: str, + ttl_seconds: int, + *, + now: float | None = None, +) -> None: + directory = receipt_path.parent + directory.mkdir(mode=0o700, parents=True, exist_ok=True) + if os.name != "nt": + directory.chmod(0o700) + receipt = { + "command_sha256": command_digest(command), + "expires_at": (time.time() if now is None else now) + ttl_seconds, + } + temporary_path = receipt_path.with_suffix(".tmp") + temporary_path.write_text(json.dumps(receipt), encoding="utf-8") + if os.name != "nt": + temporary_path.chmod(0o600) + temporary_path.replace(receipt_path) + + +def store_approval( + state_directory: Path, session_id: str, command: str, *, now: float | None = None +) -> None: + store_command_digest( + approval_path(state_directory, session_id), + command, + APPROVAL_TTL_SECONDS, + now=now, + ) + + +def store_plan( + state_directory: Path, session_id: str, command: str, *, now: float | None = None +) -> None: + store_command_digest( + plan_path(state_directory, session_id), + command, + PLAN_TTL_SECONDS, + now=now, + ) + + +def remove_receipt(receipt_path: Path) -> bool: + try: + receipt_path.unlink() + except FileNotFoundError: + return True + except OSError: + return False + return True + + +def consume_approval( + state_directory: Path, session_id: str, command: str, *, now: float | None = None +) -> bool: + receipt_path = approval_path(state_directory, session_id) + try: + receipt = json.loads(receipt_path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return False + if not remove_receipt(receipt_path): + return False + current_time = time.time() if now is None else now + expected_digest = receipt.get("command_sha256") + expires_at = receipt.get("expires_at") + return bool( + isinstance(expected_digest, str) + and isinstance(expires_at, (int, float)) + and current_time <= expires_at + and hmac.compare_digest(expected_digest, command_digest(command)) + ) + + +def consume_matching_plan( + state_directory: Path, session_id: str, command: str, *, now: float | None = None +) -> bool: + receipt_path = plan_path(state_directory, session_id) + try: + receipt = json.loads(receipt_path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return False + current_time = time.time() if now is None else now + expected_digest = receipt.get("command_sha256") + expires_at = receipt.get("expires_at") + valid = bool( + isinstance(expected_digest, str) + and isinstance(expires_at, (int, float)) + and current_time <= expires_at + ) + if not valid: + remove_receipt(receipt_path) + return False + if not hmac.compare_digest(expected_digest, command_digest(command)): + return False + return remove_receipt(receipt_path) + + +def exact_approval_command(prompt: str) -> str | None: + normalized = prompt.strip() + if "\n" in normalized or not normalized.startswith(APPROVAL_PREFIX): + return None + command = normalized.removeprefix(APPROVAL_PREFIX).strip() + return command or None + + +def planned_command(message: str) -> str | None: + candidates = [ + line.removeprefix(PLANNED_COMMAND_PREFIX).strip() + for line in message.splitlines() + if line.startswith(PLANNED_COMMAND_PREFIX) + ] + if len(candidates) != 1: + return None + return candidates[0] or None + + +def deny_decision(reason: str) -> dict[str, Any]: + return { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": ( + f"{reason}. The user must send the exact `EXECUTE ` " + "from the reviewed plan in a separate message." + ), + } + } + + +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: + prompt = event.get("prompt") + session_id = event.get("session_id") + if not isinstance(prompt, str) or not isinstance(session_id, str): + return False + command = exact_approval_command(prompt) + if command is None or not approval_eligible(command, schema): + return False + if not consume_matching_plan(state_directory, session_id, command): + return False + store_approval(state_directory, session_id, command) + return True + + +def process_assistant_plan( + event: dict[str, Any], state_directory: Path, schema: dict[str, Any] | None = None +) -> bool: + message = event.get("last_assistant_message") + session_id = event.get("session_id") + if not isinstance(message, str) or not isinstance(session_id, str): + return False + remove_receipt(approval_path(state_directory, session_id)) + command = planned_command(message) + if command is None or not approval_eligible(command, schema): + remove_receipt(plan_path(state_directory, session_id)) + return False + store_plan(state_directory, session_id, command) + return True + + +def process_tool_use( + event: dict[str, Any], + host: Host, + state_directory: Path | None, + schema: dict[str, Any] | None = None, +) -> dict[str, Any] | None: + command = event.get("tool_input", {}).get("command") + if not isinstance(command, str): + return None + classification, reason = classify_command(command, schema) + if classification != "review": + return None + session_id = event.get("session_id") + approved = bool( + state_directory is not None + and isinstance(session_id, str) + and approval_eligible(command, schema) + and consume_approval(state_directory, session_id, command) + ) + if not approved: + return deny_decision(reason) + return ask_decision(reason) if host == "claude" else None + + +def parse_arguments() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--host", choices=("claude", "codex")) + action = parser.add_mutually_exclusive_group() + action.add_argument("--record-plan", action="store_true") + action.add_argument("--record-approval", action="store_true") + return parser.parse_args() + + +def main() -> None: + arguments = parse_arguments() + try: + event = json.load(sys.stdin) + except json.JSONDecodeError: + return + state_directory = plugin_data_directory() + if arguments.record_plan: + if state_directory is not None: + process_assistant_plan(event, state_directory) + return + if arguments.record_approval: + if state_directory is not None: + process_user_prompt(event, state_directory) + return + host = detected_host() if arguments.host is None else cast(Host, arguments.host) + decision = process_tool_use(event, host, state_directory) + if decision is not None: + print(json.dumps(decision)) + + +if __name__ == "__main__": + main() diff --git a/plugins/sccfm/scripts/setup_runtime.py b/plugins/sccfm/scripts/setup_runtime.py new file mode 100644 index 00000000..476f7502 --- /dev/null +++ b/plugins/sccfm/scripts/setup_runtime.py @@ -0,0 +1,602 @@ +#!/usr/bin/env python3 +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +"""Plan, install, inspect, and remove the local SCCFM agent runtime.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shlex +import shutil +import stat +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Any, Sequence + +PACKAGE_NAME = "cisco-sccfm-devkit" +COLLECTION_NAME = "cisco.sccfm" +COLLECTION_NAMESPACE = "cisco" +COLLECTION_PACKAGE = "sccfm" +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 + + +def command_path(name: str) -> str | None: + return shutil.which(name) + + +def normalized_package_name(name: str) -> str: + return PACKAGE_NORMALIZATION_PATTERN.sub("-", name).lower() + + +def run_capture( + command: Sequence[str], *, environment: dict[str, str] | None = None, limit: int = 1000 +) -> dict[str, Any]: + try: + result = subprocess.run( + command, + check=False, + capture_output=True, + text=True, + timeout=30, + env=environment, + ) + except (OSError, subprocess.TimeoutExpired) as error: + return {"ok": False, "error": str(error)} + + output = (result.stdout or result.stderr).strip() + return { + "ok": result.returncode == 0, + "exit_code": result.returncode, + "output": output[:limit] if limit else output, + } + + +def profile_metadata() -> dict[str, Any]: + profile_path = profile_store_path() + if not profile_path.exists(): + return {"configured": False, "path": str(profile_path)} + + metadata: dict[str, Any] = {"configured": True, "path": str(profile_path)} + if sys.platform != "win32": + metadata["mode"] = stat.filemode(profile_path.stat().st_mode) + metadata["secure_permissions"] = stat.S_IMODE(profile_path.stat().st_mode) == 0o600 + return metadata + + +def profile_store_path() -> Path: + return Path.home() / ".sccfm-cli" / "config.json" + + +def collection_install_base_path() -> Path: + return (Path.home() / ".ansible" / "collections").resolve(strict=False) + + +def expected_collection_path() -> Path: + return ( + collection_install_base_path() + / "ansible_collections" + / COLLECTION_NAMESPACE + / COLLECTION_PACKAGE + ) + + +def install_state_path() -> Path: + return Path.home() / ".sccfm-agent-plugin" / "runtime.json" + + +def load_install_state() -> dict[str, Any] | None: + state_path = install_state_path() + if not state_path.exists() and not state_path.is_symlink(): + return None + if state_path.is_symlink() or not state_path.is_file(): + raise RuntimeError(f"runtime ownership state is not a regular file: {state_path}") + try: + 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 + ): + raise RuntimeError(f"runtime ownership state has an unsupported format: {state_path}") + collection_path = payload.get("collection_path") + version = payload.get("version") + if not isinstance(collection_path, str) or not isinstance(version, str): + raise RuntimeError(f"runtime ownership state is incomplete: {state_path}") + recorded_path = Path(collection_path).expanduser() + if not recorded_path.is_absolute() or recorded_path != expected_collection_path(): + raise RuntimeError( + f"runtime ownership state points outside the managed collection path: {recorded_path}" + ) + if not VERSION_PATTERN.fullmatch(version): + raise RuntimeError(f"runtime ownership state contains an invalid version: {version}") + return payload + + +def write_install_state(collection_path: Path, version: str) -> None: + if collection_path != expected_collection_path(): + raise RuntimeError(f"refusing to own an unexpected collection path: {collection_path}") + state_path = install_state_path() + 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") + 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", + encoding="utf-8", + ) + if os.name != "nt": + temporary_path.chmod(0o600) + temporary_path.replace(state_path) + + +def remove_install_state() -> None: + state_path = install_state_path() + try: + state_path.unlink() + except FileNotFoundError: + return + try: + state_path.parent.rmdir() + except OSError: + pass + + +def schema_metadata() -> dict[str, Any]: + if command_path("sccfm-cli") is None: + return {"ok": False, "error": "sccfm-cli is not on PATH"} + + result = run_capture(["sccfm-cli", "schema", "export", "--format", "json"], limit=0) + if not result["ok"]: + return result + try: + payload = json.loads(str(result["output"])) + except json.JSONDecodeError as error: + return {"ok": False, "error": f"schema output was not JSON: {error}"} + return { + "ok": True, + "version": payload.get("version"), + "command_count": len(payload.get("commands", [])), + } + + +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") + + result = run_capture( + ["ansible-galaxy", "collection", "list", COLLECTION_NAME, "--format", "json"], + environment=environment, + limit=0, + ) + if not result["ok"]: + raise RuntimeError( + str(result.get("error") or result.get("output") or "collection list failed") + ) + try: + payload = json.loads(str(result["output"])) + except json.JSONDecodeError as error: + raise RuntimeError(f"collection output was not JSON: {error}") from error + if not isinstance(payload, dict): + raise RuntimeError("collection output was not a JSON object") + return payload + + +def validated_collection_path(collection_root: str) -> Path: + root = Path(collection_root).expanduser() + if not root.is_absolute(): + raise ValueError(f"collection root is not absolute: {collection_root}") + if root.is_symlink(): + raise ValueError(f"collection root must not be a symlink: {collection_root}") + try: + resolved_root = root.resolve(strict=True) + except OSError as error: + raise ValueError(f"collection root does not exist: {collection_root}") from error + if resolved_root.name != "ansible_collections": + raise ValueError(f"unexpected collection root: {resolved_root}") + + namespace_path = resolved_root / COLLECTION_NAMESPACE + collection_path = namespace_path / COLLECTION_PACKAGE + if namespace_path.is_symlink() or collection_path.is_symlink(): + raise ValueError(f"collection path must not contain symlinks: {collection_path}") + if not collection_path.is_dir(): + raise ValueError(f"reported collection path is not a directory: {collection_path}") + return collection_path + + +def collection_installations(payload: dict[str, Any]) -> list[dict[str, str]]: + installations: list[dict[str, str]] = [] + for root, collections in payload.items(): + if not isinstance(root, str) or not isinstance(collections, dict): + continue + metadata = collections.get(COLLECTION_NAME) + if not isinstance(metadata, dict): + continue + version = metadata.get("version") + installations.append( + { + "path": str(validated_collection_path(root)), + "version": version if isinstance(version, str) else "unknown", + } + ) + return installations + + +def collection_metadata(environment: dict[str, str]) -> dict[str, Any]: + try: + installations = collection_installations(collection_listing(environment)) + install_state = load_install_state() + except (RuntimeError, ValueError) as error: + return {"ok": False, "error": str(error)} + + if not installations: + return { + "ok": True, + "installed": False, + "installations": 0, + "paths": [], + } + selected_installation = installations[0] + managed = False + if install_state is not None: + managed_path = str(install_state["collection_path"]) + selected_installation = next( + ( + installation + for installation in installations + if installation["path"] == managed_path + ), + {}, + ) + if not selected_installation: + return { + "ok": False, + "error": ( + "the recorded managed collection is not reported by ansible-galaxy: " + f"{managed_path}" + ), + } + managed = True + return { + "ok": True, + "installed": True, + "version": selected_installation["version"], + "managed": managed, + "selected_path": selected_installation["path"], + "installations": len(installations), + "paths": [installation["path"] for installation in installations], + } + + +def doctor_report() -> dict[str, Any]: + python_candidates = {name: command_path(name) for name in ("python3.12", "python3", "python")} + python_versions = { + name: run_capture([path, "--version"]) + 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") + } + schema = schema_metadata() + report: dict[str, Any] = { + "python_candidates": python_candidates, + "python_versions": python_versions, + "commands": commands, + "profile": profile_metadata(), + "schema": schema, + } + 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() + ansible_environment["ANSIBLE_LOCAL_TEMP"] = temporary_directory + report["collection"] = collection_metadata(ansible_environment) + if commands["ansible-doc"]: + report["ansible_discovery"] = run_capture( + ["ansible-doc", "-j", "-l", "-t", "module", COLLECTION_NAME], + environment=ansible_environment, + ) + cli_version = report["cli_version"] + collection_version = report["collection"].get("version") + report["versions_match"] = bool(cli_version and cli_version == collection_version) + report["operational"] = bool( + schema.get("ok") + and report["collection"].get("ok") + and report["collection"].get("installed") + and report.get("ansible_discovery", {}).get("ok") + and report["versions_match"] + and report["profile"].get("configured") + ) + return report + + +def 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") + return [ + [ + "pipx", + "install", + "--python", + python_command, + "--force", + f"{PACKAGE_NAME}=={version}", + ], + [ + "pipx", + "inject", + "--include-apps", + "--force", + PACKAGE_NAME, + ANSIBLE_CORE_SPEC, + ], + [ + "ansible-galaxy", + "collection", + "install", + f"{COLLECTION_NAME}:=={version}", + "--force", + "--collections-path", + str(collection_base or collection_install_base_path()), + ], + ] + + +def print_plan(version: str, python_command: str) -> None: + for command in install_commands(version, python_command): + 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" + ) + for command in install_commands(version, python_command): + print(f"Running: {shlex.join(command)}") + subprocess.run(command, check=True) + installed_path = validated_collection_path( + str(collection_install_base_path() / "ansible_collections") + ) + if installed_path != target_path: + raise RuntimeError( + f"collection was installed outside the expected managed path: {installed_path}" + ) + write_install_state(installed_path, version) + + +def discover_collection_paths() -> list[Path]: + with tempfile.TemporaryDirectory(prefix="sccfm-agent-uninstall-") as temporary_directory: + ansible_environment = os.environ.copy() + ansible_environment["ANSIBLE_LOCAL_TEMP"] = temporary_directory + return [ + Path(installation["path"]) + for installation in collection_installations(collection_listing(ansible_environment)) + ] + + +def partition_collection_paths(collection_paths: Sequence[Path]) -> tuple[list[Path], list[Path]]: + install_state = load_install_state() + if install_state is None: + return [], list(collection_paths) + managed_path = Path(str(install_state["collection_path"])) + if managed_path not in collection_paths: + raise RuntimeError( + "the recorded managed collection is not reported by ansible-galaxy: " f"{managed_path}" + ) + return [managed_path], [path for path in collection_paths if path != managed_path] + + +def pipx_package_installed() -> bool: + if command_path("pipx") is None: + return False + result = run_capture(["pipx", "list", "--json"], limit=0) + if not result["ok"]: + raise RuntimeError(str(result.get("error") or result.get("output") or "pipx list failed")) + try: + payload = json.loads(str(result["output"])) + except json.JSONDecodeError as error: + raise RuntimeError(f"pipx output was not JSON: {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): + 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 + + +def uninstall_plan(remove_profiles: bool) -> dict[str, Any]: + collection_paths, preserved_collection_paths = partition_collection_paths( + discover_collection_paths() + ) + pipx_path = command_path("pipx") + cli_path = command_path("sccfm-cli") + if pipx_path is None and cli_path is not None: + raise RuntimeError( + "sccfm-cli is installed but pipx is unavailable; refusing to guess how it was installed" + ) + managed_environment_installed = pipx_package_installed() if pipx_path is not None else False + if cli_path is not None and not managed_environment_installed: + raise RuntimeError( + "sccfm-cli is not owned by the managed pipx environment; refusing to remove it" + ) + return { + "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 collection_paths else "absent", + "path": str(install_state_path()), + "exists": install_state_path().exists(), + }, + "pipx_command": ( + ["pipx", "uninstall", PACKAGE_NAME] if managed_environment_installed else None + ), + "profile": { + "action": "delete" if remove_profiles else "preserve", + "path": str(profile_store_path()), + "exists": profile_store_path().exists(), + }, + } + + +def print_uninstall_plan(remove_profiles: bool, as_json: bool) -> None: + plan = uninstall_plan(remove_profiles) + if as_json: + print(json.dumps(plan, indent=2, sort_keys=True)) + return + + collection_paths = plan["collection_paths"] + if collection_paths: + for path in collection_paths: + print(f"Remove Ansible collection: {path}") + else: + print(f"No helper-managed Ansible collection is installed: {COLLECTION_NAME}") + for path in plan["preserved_collection_paths"]: + print(f"Preserve unmanaged Ansible collection: {path}") + install_state = plan["install_state"] + if install_state["action"] == "delete": + print(f"Remove runtime ownership state: {install_state['path']}") + pipx_command = plan["pipx_command"] + if pipx_command: + print(f"Run: {shlex.join(pipx_command)}") + else: + print(f"pipx environment is not installed: {PACKAGE_NAME}") + profile = plan["profile"] + print(f"{str(profile['action']).capitalize()} profile store: {profile['path']}") + + +def remove_profile_store() -> None: + profile_path = profile_store_path() + if not profile_path.exists() and not profile_path.is_symlink(): + return + if profile_path.is_dir() and not profile_path.is_symlink(): + raise RuntimeError(f"profile store is unexpectedly a directory: {profile_path}") + profile_path.unlink() + try: + profile_path.parent.rmdir() + except OSError: + pass + + +def uninstall(remove_profiles: bool, confirmed: bool) -> None: + if not confirmed: + raise SystemExit("Refusing to uninstall without --yes after user confirmation") + plan = uninstall_plan(remove_profiles) + for collection_path in plan["collection_paths"]: + path = Path(collection_path) + print(f"Removing Ansible collection: {path}") + shutil.rmtree(path) + if plan["collection_paths"]: + remove_install_state() + pipx_command = plan["pipx_command"] + if pipx_command: + print(f"Running: {shlex.join(pipx_command)}") + subprocess.run(pipx_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 main() -> None: + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="action", required=True) + + doctor_parser = subparsers.add_parser("doctor") + doctor_parser.add_argument("--json", action="store_true") + + plan_parser = subparsers.add_parser("plan") + plan_parser.add_argument("--version", required=True) + plan_parser.add_argument("--python", default="python3.12") + + install_parser = subparsers.add_parser("install") + install_parser.add_argument("--version", required=True) + install_parser.add_argument("--python", default="python3.12") + install_parser.add_argument("--yes", action="store_true") + + uninstall_plan_parser = subparsers.add_parser("uninstall-plan") + uninstall_plan_parser.add_argument("--remove-profiles", action="store_true") + uninstall_plan_parser.add_argument("--json", action="store_true") + + uninstall_parser = subparsers.add_parser("uninstall") + uninstall_parser.add_argument("--remove-profiles", action="store_true") + uninstall_parser.add_argument("--yes", action="store_true") + + arguments = parser.parse_args() + if arguments.action == "doctor": + report = doctor_report() + if arguments.json: + print(json.dumps(report, indent=2, sort_keys=True)) + else: + print(json.dumps(report, indent=2, sort_keys=True)) + elif arguments.action == "plan": + print_plan(arguments.version, arguments.python) + elif arguments.action == "install": + install(arguments.version, arguments.python, arguments.yes) + elif arguments.action == "uninstall-plan": + try: + print_uninstall_plan(arguments.remove_profiles, arguments.json) + except RuntimeError as error: + raise SystemExit(f"Cannot safely plan uninstall: {error}") from error + elif arguments.action == "uninstall": + try: + uninstall(arguments.remove_profiles, arguments.yes) + except RuntimeError as error: + raise SystemExit(f"Cannot safely uninstall: {error}") from error + + +if __name__ == "__main__": + main() diff --git a/plugins/sccfm/scripts/sync_skills.py b/plugins/sccfm/scripts/sync_skills.py new file mode 100644 index 00000000..75600652 --- /dev/null +++ b/plugins/sccfm/scripts/sync_skills.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +"""Synchronize the plugin's distributed skills with the repository sources.""" + +from __future__ import annotations + +import argparse +import filecmp +import shutil +from pathlib import Path + +PLUGIN_ROOT = Path(__file__).resolve().parents[1] +REPOSITORY_ROOT = PLUGIN_ROOT.parents[1] +SOURCE_ROOT = REPOSITORY_ROOT / "skills" +TARGET_ROOT = PLUGIN_ROOT / "skills" +SKILLS = ("sccfm-cli", "sccfm-ansible") + + +def skill_matches(name: str) -> bool: + source = SOURCE_ROOT / name + target = TARGET_ROOT / name + comparison = filecmp.dircmp(source, target) + return not ( + comparison.left_only + or comparison.right_only + or comparison.diff_files + or comparison.funny_files + or any(not child.same_files for child in comparison.subdirs.values()) + ) + + +def synchronize() -> None: + TARGET_ROOT.mkdir(parents=True, exist_ok=True) + for name in SKILLS: + source = SOURCE_ROOT / name + target = TARGET_ROOT / name + if not source.is_dir(): + raise SystemExit(f"Missing canonical skill: {source}") + if target.exists(): + shutil.rmtree(target) + shutil.copytree(source, target) + print(f"Synchronized {name}") + + +def check() -> None: + stale = [name for name in SKILLS if not skill_matches(name)] + if stale: + raise SystemExit("Plugin skills are stale: " + ", ".join(stale)) + print("Plugin skills match canonical sources") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--check", action="store_true") + arguments = parser.parse_args() + if arguments.check: + check() + else: + synchronize() + + +if __name__ == "__main__": + main() diff --git a/plugins/sccfm/skills/sccfm-ansible/SKILL.md b/plugins/sccfm/skills/sccfm-ansible/SKILL.md new file mode 100644 index 00000000..a39cfa33 --- /dev/null +++ b/plugins/sccfm/skills/sccfm-ansible/SKILL.md @@ -0,0 +1,549 @@ +--- +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" +--- + +# SCC Firewall Manager Ansible Collection + +Generate or run `cisco.sccfm` Ansible automation by dynamically discovering the +installed collection with `ansible-doc`. Treat `ansible-doc` JSON output as the +schema for modules, inventory plugins, options, examples, return values, and +secret handling. Do not hardcode module names, parameters, examples, choices, or +behavior. + +This skill operates against customer SCC Firewall Manager environments and +managed devices. Optimize for customer safety first and convenience second. + +## Scope: Ansible vs. CLI + +This skill covers only the `cisco.sccfm` Ansible collection (modules, inventory +plugins, and lookup plugins). For `sccfm-cli` command-line invocations, use the +`sccfm-cli` skill. For requests spanning both surfaces, apply each skill only to +its respective operations. + +## Core Rules + +1. Run `ansible-doc` before writing, running, or answering detailed questions + about any `cisco.sccfm` module, inventory plugin, or lookup plugin. +2. Prefer stopping over guessing. If module match, target identity, region, + credentials, inventory, or safety class is ambiguous, ask the user or switch + to Generate-Only. +3. Never improvise module names, parameters, defaults, target lists, inventory + files, vault paths, or output paths. +4. Never ask the user to paste secrets into chat. +5. Use the canonical SCCFM profile store for API tokens and Ansible Vault for + playbook-specific secrets such as device passwords. Never put secrets directly + in playbooks. +6. Treat any task as mutating unless `ansible-doc`, examples, and source context + prove it is read-only. +7. Use fully qualified collection names, such as `cisco.sccfm.`, in + playbooks. + +## Execution Modes + +Select one execution mode for each user request. + +### Mode 1: Execute + +Use this mode when the user asks you to perform the Ansible operation. + +- You may run discovery, syntax checks, inventory checks, dry runs, and readonly + playbooks, subject to the safety rules below. +- Never execute a mutating playbook immediately. Build a plan, run check mode + or another preflight when available, and require confirmation. +- If credentials are missing or intent is ambiguous, default to Generate-Only. + +### Mode 2: Generate-Only + +Use this mode when the user says anything like "show me the playbook", +"generate the playbook", "show me the command", "do not run it", "don't run +it", "I will run it myself", or "command only". + +In Generate-Only mode: + +- Never execute the final business playbook or inventory query. +- You may still run `ansible-doc` because discovery depends on it. +- You may run local syntax checks on generated playbooks when no live + credentials are required and the user did not forbid all execution. +- If the user forbids all command execution and no cached `ansible-doc` schema + is available, stop and explain that safe generation requires module + discovery. +- Always return exact commands or playbook snippets in fenced code blocks. +- Mark generated automation as `not validated against live state` when live + preflight was not allowed or credentials were unavailable. + +## Safety Model + +Classify the playbook or command before execution. Ansible does not expose the +same explicit `readonly` schema field as `sccfm-cli`, so classify from +`ansible-doc` descriptions, examples, options, return docs, and source only when +needed. If classification is unclear, use Class C. + +### Class A: Readonly, no local writes + +Use Class A only when the matched module or inventory action is clearly +read-only and the invocation does not write local files. + +Signals include: + +- `ansible-doc` describes list, get, show, inspect, validate, or health-check + behavior. +- The task does not create, update, delete, onboard, deploy, trigger, clear, + execute arbitrary device commands, change credentials, or change managed + device state. +- The command does not redirect output to a file and does not use modules such + as local copy/template/file/write operations. + +These commands are safe to execute after discovery and credential validation. + +### Class B: Readonly, local-write/export side effects + +Use Class B when the operation is read-only against SCCFM but writes local data. + +Examples include saving inventory output, writing reports, creating local +playbook artifacts, or exporting customer data to a path. Require explicit user +opt-in and an explicit destination path before executing. Never rely on a +default path for customer data. + +### Class C: Mutating SCCFM or managed devices + +Use Class C when the operation may modify SCCFM, a managed device, local +credential state, or any deployment/upgrade workflow. + +Signals include: + +- `ansible-doc` describes creating, updating, deleting, adding, removing, + onboarding, deploying, triggering, clearing, applying, editing, executing CLI + commands, changing passwords, changing boot images, or changing defaults. +- The task writes SCCFM objects, device configuration, licensing/deployment + state, shun state, object overrides, access rules, local users, firmware, or + credentials. +- The source or docs are unclear. + +Class C requires a plan, preflight when possible, and explicit confirmation +before execution. + +## Prerequisites + +### 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: + + ```bash + 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: + + ```bash + build-ansible-collection + ansible-galaxy collection install \ + "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 + when the user explicitly asks for setup. Otherwise, stop and explain that the + `cisco.sccfm` collection is not installed. + +Re-discover if the virtualenv, collection install, or branch changes. + +### Step B: Discover Runtime Schema + +Export the module list once per session: + +```bash +ansible-doc -j -l -t module cisco.sccfm +``` + +For a matched module, fetch full JSON docs: + +```bash +ansible-doc -j cisco.sccfm. +``` + +For dynamic inventory work, list inventory plugins, 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: + +```bash +ansible-doc -j -l -t lookup cisco.sccfm +ansible-doc -j -t lookup +``` + +Parse the JSON output. Use these fields as the schema: + +- module or plugin FQCN +- short description and description +- `doc.options`: parameter names, types, required flags, defaults, choices, + `elements`, `env`, and `no_log` +- examples +- return values +- plugin type and inventory or lookup options + +Cache the discovered JSON in memory for the session. Do not use stale docs after +building or reinstalling the collection. + +If discovery fails, stop and report the error. Do not guess what the collection +supports. + +The discovery commands above are the only hardcoded bootstrap commands. They are +the Ansible equivalent of schema export: all module, inventory plugin, lookup +plugin, parameter, example, and return-value knowledge must come from the +discovered `ansible-doc` JSON. + +### Step C: Verify Credentials Without Exposing Secrets + +Use the matched docs to identify profile options. SCCFM modules and inventory +use the canonical named profile store shared with `sccfm-cli`. + +Rules: + +1. Prefer `module_defaults: group/cisco.sccfm.all:` when selecting a non-default profile. +2. Configure SCCFM profiles with `sccfm-cli --profile configure`. +3. Use Ansible Vault for device passwords and other playbook-specific secrets, + never for the SCCFM API token. +4. Never ask for token or password contents in chat. +5. Never print decrypted vault contents. +6. Never write real secrets to tracked files. +7. If credentials are missing, tell the user which local profile configuration + command to run without asking them to paste the token into chat. +8. Use Write/Edit only for non-secret playbook, inventory, vars template, or + documentation artifacts. +9. Treat a lookup result as a secret when `field` is omitted (it defaults to + `api_token`) or explicitly set to `field=api_token`. Use the result only + inside a task with `no_log: true`; never print, export, log, or return it in + chat. Only an explicitly non-secret field such as `field=region` may be + presented. + +Use `sccfm-cli configure` or the `configure-profile` option in +`sccfm-cli-interactive` for local SCCFM credential setup only when the user +explicitly asks for it. + +## Step 1: Match User Intent Conservatively + +Derive the user's request into this structured shape before matching modules: + +- requested action +- target object or device type +- target identity, query, inventory group, or host pattern +- desired state or operation +- region +- whether they asked to read, export, or modify +- whether they want a playbook, an ad hoc command, inventory output, or a dry run + +Then match modules using this algorithm: + +1. Filter the discovered module list by exact tokens in FQCN, short + description, and description. +2. Fetch full docs for every plausible candidate. +3. Reject candidates whose documented behavior conflicts with the user's intent. +4. Prefer modules whose documented action and object type both match exactly. +5. Use inventory plugins only for inventory/discovery requests and lookup plugins + only for lookup requests. +6. If exactly one candidate remains, use it. +7. If multiple plausible candidates remain, show the candidates and ask the user + to choose. +8. If no candidate matches, say so clearly and stop. + +Never choose between ambiguous modules by vibe. Ask or stop. + +## Step 2: Build the Playbook or Command + +Construct automation strictly from the matched `ansible-doc` entry. + +### Required Inputs + +1. Check every option where `required` is true. +2. Gather values from the user's request, inventory variables, group vars, or + existing variable files. +3. If a required value is missing, ask for it or leave a clear placeholder in + Generate-Only mode. +4. Enforce `choices`, `type`, and `elements` exactly as documented. + +### Natural-Language Filters and Queries +If the user describes a filter in natural language, such as "online ASAs", +"devices named branch-*", or "FTDs not on the recommended version", translate it +only through documented Ansible options and discovered inventory variables. + +1. Use only options exposed by the matched `ansible-doc` entry. +2. If an option named `query` exists, read its current description before using + it. Do not assume it accepts Lucene, field:value filters, or the same syntax + as `sccfm-cli`. +3. If the docs describe only a narrow query behavior, such as a name filter, + generate only that documented behavior. +4. If the user wants filtering by inventory host metadata, first discover the + inventory plugin docs, then verify host variables from generated docs or + source context; use only verified host variables. +5. If no documented option or host variable supports the requested filter, ask + for the exact supported filter or propose a readonly list/inventory step plus + local post-filtering. Do not invent query fields or values. + +### Auth Pattern + +For SCCFM modules, prefer this shape when selecting a non-default profile: + +```yaml +module_defaults: + group/cisco.sccfm.all: + profile: production +``` + +Omit `profile` when using the configured `default` profile. Do not place a region +or SCCFM API token in a task, variable file, environment lookup, or vault. + +### Play Targets + +Use `hosts: localhost` and `gather_facts: false` for SCCFM API operations unless +the user specifically wants to target hosts from the dynamic inventory. + +### Ansible Command Shape + +Build commands in this shape: + +```text +ansible-playbook [ansible CLI options] +ansible-inventory -i [ansible inventory options] +``` + +Module parameters belong in YAML under `cisco.sccfm.`. Inventory +plugin options belong in the inventory YAML file. Never turn module or inventory +plugin parameters into `ansible-playbook` CLI flags unless Ansible itself +documents that flag. + +For inventory-driven tasks: + +1. Discover the inventory plugin docs. +2. Build an inventory file with only options documented by the plugin. +3. Validate it with `ansible-inventory` before running playbooks when + credentials are available. + +### Secret Parameters + +For every option where `no_log: true` is documented, or whose name or description indicates a token, password, key, or secret: + +- Use a vault variable, environment lookup, or placeholder. +- Do not place real values in generated artifacts. +- Do not print resolved values. + +## Step 3: Validate Before Execution + +Run validation appropriate to the selected mode and safety class. + +### Always Safe Validation + +These do not contact SCCFM: + +```bash +ansible-doc -j -l -t module cisco.sccfm +ansible-doc -j cisco.sccfm. +ansible-playbook --syntax-check +``` + +Use `--syntax-check` on generated playbooks whenever a playbook file exists and +the user did not forbid local validation. + +### Inventory Validation + +When credentials are available and the user requested inventory behavior: + +```bash +ansible-inventory -i --graph --playbook-dir +ansible-inventory -i --list --playbook-dir +``` + +If credentials are missing, validate only the file shape and mark it as not +validated against live SCCFM. + +### Check Mode + +For Class C playbooks, run check mode before execution whenever the matched +module supports it and credentials are available: + +```bash +ansible-playbook -i --check +``` + +If `ansible-doc` does not expose check-mode support, inspect the module source +only when you are in this repository. Look for `supports_check_mode=True` and a +real `module.check_mode` path. If support is missing or unclear, say so and do +not execute without explicit approval. + +## Step 4: Execution Policy + +Apply these rules after selecting execution mode. + +### Class A: Readonly, No Local Writes + +In Execute mode, run the playbook or inventory command after validation if: + +- the module or inventory match is unambiguous +- region and credentials are available +- required options are satisfied +- the operation is documented as read-only + +In Generate-Only mode, return the exact playbook and command, and state whether +it was syntax-checked or live-validated. + +### Class B: Readonly, Local Writes or Exports + +In Execute mode, before executing: + +1. Confirm the user wants the local write/export. +2. Require an explicit destination path. +3. State what will be written and where. +4. Do not use schema or example defaults for customer data paths. + +In Generate-Only mode, require an explicit destination path before generating the +write/export command or playbook. + +### Class C: Mutating SCCFM or Managed Devices + +In Execute mode, never execute immediately. Use this workflow: + +1. Validate the module match, region, credentials, and all required options. +2. Resolve targets to an unambiguous host pattern, UID, object identifier, or + exact target count. +3. Run `ansible-playbook --syntax-check`. +4. Run `ansible-playbook --check` when supported and credentials are available. +5. If check mode is unavailable or not meaningful, say so explicitly and stop + unless the user approves proceeding without it. +6. Present an execution plan containing: + - module FQCN + - region + - inventory or host pattern + - target selector or target count + - intended change + - check-mode/preflight result + - exact command that will be executed +7. Require explicit confirmation before execution. + +In Generate-Only mode, validate as far as allowed, mark whether syntax check and +live preflight were performed, return the exact playbook/command, and do not +execute the mutating playbook. + +#### Confirmation Rules for Mutating Playbooks + +These confirmation rules apply only in Execute mode. + +For any Class C playbook, require the user to send the exact `ansible-playbook` +shell command from the reviewed plan, prefixed with `EXECUTE `: + +```text +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. + +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, +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 +binds the shell command itself. + +For production, deployment, upgrade, credential, or bulk mutations, require two +confirmations: + +1. A first confirmation that they want to proceed with the plan. +2. A second message containing `EXECUTE ` followed by the exact shell command. + +#### Red Lines for Mutating Playbooks + +Never execute Class C automation when any of these is true: + +- the module match is ambiguous +- region or credentials are ambiguous +- targets are vague or unresolved +- a bulk target list has not been inspected +- required parameters are missing +- secrets would be exposed in chat or committed to disk +- check mode is unavailable and the user has not explicitly accepted that risk +- the user gave a vague instruction like "fix it" or "do this everywhere" +- you cannot state the exact intended change in one sentence + +## Step 5: Parse and Present Results + +Parse JSON output when available, using Ansible's JSON callback when useful, and +summarize only what answers the request. + +Result rules: + +- For a scalar answer, state it directly. +- For small structured results, summarize important fields. +- For tabular results, use a markdown table when it improves clarity. +- For exported files, confirm the path and summarize what was written without + dumping customer data unless the user explicitly asks. +- For failures, report the useful Ansible error details without exposing + secrets, suggest the smallest corrective action, and do not auto-retry + mutating tasks. + +## Development Changes + +When modifying or adding Ansible modules in this repository: + +1. Read the matched module source and its tests. +2. Keep all module functions typed. +3. Use `base_argument_spec()` for shared `profile` and `config_path` options. +4. Set `supports_check_mode=True` on every module. +5. Implement a meaningful `module.check_mode` path for mutating modules. +6. Keep secrets marked `no_log=True`. +7. Run `build-ansible-collection` after changes that affect docs or installed + module behavior. +8. Verify with `ansible-doc -j cisco.sccfm.`. +9. Run targeted module tests, then broader tests based on risk: + + ```bash + pytest sccfm-ansible/plugins/modules/tests/ -v + ``` + +10. Run live e2e tests only when credentials and a suitable sandbox are + available. + +## Important Rules + +1. Never hardcode modules or plugins. All module and plugin knowledge comes from + `ansible-doc`. +2. Never fabricate options. Only use parameters listed in the matched docs. +3. Always use FQCNs. +4. Always protect playbook-specific secrets with Vault or placeholders; keep + SCCFM API tokens in the canonical profile store. +5. Never guess between ambiguous modules, targets, or regions. +6. Never rely on default local output paths for customer data. +7. Never execute mutating automation without the confirmation workflow. +8. Use check mode for mutating automation whenever the module supports it. +9. If a command fails, report it clearly and stop. Do not auto-retry unless the + user explicitly asks. +10. In Generate-Only mode, never execute the final business playbook. diff --git a/plugins/sccfm/skills/sccfm-cli/SKILL.md b/plugins/sccfm/skills/sccfm-cli/SKILL.md new file mode 100644 index 00000000..ede465ba --- /dev/null +++ b/plugins/sccfm/skills/sccfm-cli/SKILL.md @@ -0,0 +1,538 @@ +--- +name: sccfm-cli +description: Use the customer-facing SCC Firewall Manager CLI by discovering the live schema, validating command metadata, and either executing or generating safe sccfm-cli commands without hardcoded command knowledge. Use for SCCFM CLI workflows; use the sccfm-ansible skill instead for cisco.sccfm Ansible playbooks, inventories, and modules. +allowed-tools: "Bash(command -v *) Bash(sccfm-cli *) Bash(source cisco_sccfm_scripts/activate.sh) Bash(brew *) Bash(pipx *) Bash(jq *) Read Grep Glob" +--- + +# SCC Firewall Manager CLI + +Execute or generate `sccfm-cli` commands by dynamically discovering available +operations from the CLI schema. All command knowledge comes from the schema. Do +not hardcode command names, options, examples, or behavior. + +This skill operates against customer SCC Firewall Manager environments. Optimize +for customer safety first and convenience second. + +## Scope: CLI vs. Ansible + +This skill covers only the `sccfm-cli` command-line tool. For `cisco.sccfm` +Ansible playbooks, inventory files, modules, or the inventory plugin, use the +`sccfm-ansible` skill. For requests spanning both surfaces, apply each skill only +to its respective operations. + +## Core Rules + +1. Treat every operation as customer-impacting until the schema proves otherwise. +2. Prefer stopping over guessing. If command match, target identity, region, + profile, or safety class is ambiguous, ask the user or switch to + generate-only mode. +3. Never improvise command names, flags, defaults, target lists, or file paths. +4. Never ask the user to paste secrets into chat. +5. Always use the schema's `readonly` flag. +6. Use canonical schema values in generated commands. + +## Execution Modes + +Select one execution mode for each user request. + +### Mode 1: Execute + +Use this mode by default when the user asks you to perform the operation. + +- You may run the matched business command, subject to the safety rules in this + skill. +- You may run schema discovery, readonly validation, or preflight commands when + needed. +- If intent is ambiguous, default to Generate-Only. + +### Mode 2: Generate-Only + +Use this mode when the user says anything like "show me the command", +"generate the command", "do not run it", "don't run it", "I will run it myself", +or "command only". + +In Generate-Only mode: + +- Never execute the final business command. +- You may still run schema export because command discovery depends on it. +- You may run readonly validation or preflight commands unless the user + explicitly said not to run anything at all. +- If the user forbids all command execution and no cached schema is available, + stop and explain that safe generation requires schema discovery. +- If readonly validation or preflight is not allowed, mark the command as + `not validated against live state`. +- Always return the exact command in a fenced `bash` block. +- Never ask for mutating confirmation phrases because the agent is not executing + the command. + +## Safety Model + +Classify every matched command before execution. + +### Class A: Readonly, no local writes + +- `readonly: true` +- The invocation does not write to a local file, profile, export destination, or + config path. + +These commands are safe to execute after validation. + +### Class B: Readonly, local-write/export side effects + +- `readonly: true` +- `side_effects` or option metadata indicates a local write, export, + destination, output path, config path, or profile write. + +These commands do not mutate SCC Firewall Manager, but they still have side +effects and may spill customer data. Require explicit user opt-in and an explicit +destination path. Never rely on a schema default output location for exported +customer data. + +### Class C: Mutating + +- `readonly: false` + +These commands may modify SCC Firewall Manager or a managed device. They require +preflight where available, a plan, unambiguous targets, and explicit +confirmation. Never execute them on a vague instruction. + +## Prerequisites + +### Platform + +The CLI is Python 3.12 based and supports normal Python installs on macOS, +Linux, and Windows. Prefer macOS or Linux shells for direct agent operation; on +Windows, use the documented Python install path or WSL when shell features are +needed. + +### Step A: Resolve the CLI Binary + +Follow these checks in order: + +1. Run `command -v sccfm-cli`. + - If found, the invocation prefix is `sccfm-cli`. +2. If you are inside this repository, the binary is not on `PATH`, 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. Only install or perform setup when the user explicitly asks for it. +4. Otherwise, stop and tell the user the CLI is not installed or not on `PATH`. + +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 + +Only do this if the user explicitly asked for installation or setup. + +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. + +### Step B: Verify Credentials + +This skill uses customer SCC Firewall Manager API-token/profile auth, not +internal SystemDB tokens. + +Use the selected command's `auth` object: + +- If `auth.requires_profile` is false, skip profile verification. +- If `auth.requires_profile` is true, verify a configured customer profile is + available before executing. +- Profiles contain a region and API token. Tokens come from developer.cisco.com + or the SCC Firewall Manager UI. +- The canonical profile store is `~/.sccfm-cli/config.json`, shared by + `sccfm-cli`, `sccfm-cli-interactive`, and the `cisco.sccfm` Ansible collection. + Do not configure SCCFM tokens through `.env`, inline Ansible values, or Ansible Vault. + +#### Secret Handling Rules + +1. Never ask the user to paste a token into chat. +2. Never echo a token back to the user. +3. Never log tokens or include them in final answers. +4. Never use internal SystemDB credentials. +5. If a profile is missing, guide the user to run the documented configuration + flow locally. The token must come from its hidden prompt or schema-declared + environment source, never from a generated argv option. +6. Only configure a profile yourself when the user explicitly provides a secure, + local mechanism for the token. + +#### Credential Verification Algorithm + +Before executing any command where `auth.requires_profile` is true: + +1. Determine the profile from the user's request, global options, or schema + defaults. +2. Run a readonly profile/connectivity check only if the schema exposes one and + the selected execution mode allows validation. +3. If validation succeeds, proceed with command construction. +4. If no validation command is available, proceed only if a profile is already + configured or the user explicitly provides the profile name to use. +5. If the profile is missing or invalid, stop and tell the user to configure a + customer SCC Firewall Manager API token locally. +6. Do not ask for token contents, do not print token values, and do not retry + with alternate credentials unless the user explicitly selects them. + +AWS credentials and internal SystemDB tokens are out of scope for `sccfm-cli`. + +#### Canonical Region Mapping + +Normalize user-friendly region names before using them. The canonical values are +the choices exposed by the schema for the region option. + +Common aliases: + +- United States or USA -> `us` +- Europe -> `eu` +- Asia Pacific/Japan -> `apj` +- Australia or `aus` -> `au` +- United Arab Emirates -> `uae` +- India -> `in` +- CI -> `ci` +- integration or internal -> `int` + +Only use the normalized value if it is present in the schema choices. If the user +supplies any other region string, say it is not recognized and show the valid +schema choices. + +## Step 1: Discover Available Commands + +Export the schema once per session: + +```bash +sccfm-cli schema export --format json +``` + +This is the only hardcoded command exception. It bootstraps schema discovery; all +other command names, flags, options, examples, and behavior must come from the +exported schema. + +Parse the JSON output. The schema contains: + +- `commands`: available leaf operations +- `command`: full executable command text +- `path`: command path segments +- `description`: human-readable behavior +- `readonly`: whether the command mutates SCC Firewall Manager +- `side_effects`: local or remote side effects +- `auth`: auth requirements +- `option_groups`: inter-option constraints +- `constraints`: validation and preflight constraints +- `global_options`: flags that must appear before the command path +- `options`: accepted flags, types, defaults, choices, sensitivity, environment sources, and + descriptions +- `examples`: declared usage examples, if any + +Cache the schema in memory for the session. Do not re-export unless: + +- the user explicitly asks you to refresh it +- the CLI binary or invocation prefix changed +- the environment changed +- a command is missing + +If schema export fails, stop and report the error. Do not guess what the CLI +supports. + +## Step 2: Match User Intent Conservatively + +Derive the user's request into this structured shape before matching commands: + +- requested action +- target object type +- target identity or target list +- region or profile +- whether they asked to read, export, or modify + +Then match commands using this algorithm: + +1. Filter schema commands whose `command`, `path`, or `description` directly + match the requested action and object type. +2. Prefer exact token matches in `path` over looser description matches. +3. If the user names a concrete subtype and the schema has a subtype-specific + path segment for it, prefer that command over a generic command plus a + `deviceType` query. +4. Reject any command whose safety category conflicts with the user's intent. +5. If exactly one command remains, use it. +6. If multiple plausible commands remain, show the candidates and ask the user + to choose. +7. If no command matches, say so clearly and stop. + +Never guess between multiple commands. Fail closed on ambiguity. + +## Step 3: Build the Invocation + +Construct the command strictly from the matched schema entry. + +### Required Inputs + +1. Check every option where `required` is true. +2. Gather values from the user's request. +3. If a required value is missing, ask for it. Do not invent it. + +### Option Placement + +Build commands in this order: + +```text +sccfm-cli +``` + +Global options come from the root schema's `global_options` list and must be +placed before the command path. Command options come from the matched command's +`options` list and must be placed after the command path. + +Never place a `global_options` flag after the leaf command. Click will reject it. + +### Option Groups + +Apply every `option_groups` entry and every `constraints` entry exactly as +described in the schema. See "Parsing `option_groups`" below. + +### Output Format + +In Execute mode, pass JSON output only when the selected command schema declares +an output format option whose values include `json`. + +In Generate-Only mode, omit JSON output unless the user explicitly requested it +or the schema requires it. + +### Natural-Language Query Filters + +If the user describes a filter in natural language, such as "online ASAs" or +"objects named web-*", build a `--query` value only from the matched command's +`queryable_fields` and `field_notes` metadata. + +1. Match the user's words to field names, allowed values, aliases, or examples + declared in `queryable_fields`. +2. Use the exact field spelling and value casing from the schema metadata. +3. If `field_notes` says a command automatically adds a filter, do not duplicate + that filter in the generated query. +4. If no schema-declared field matches the requested filter, ask the user for the + exact Lucene query instead of guessing. +5. Never pass bare words like `online` as a query unless the schema explicitly + documents that form. + +### Region + +Always pass canonical region values from schema choices, not friendly aliases. + +### Optional Flags + +Only pass an optional flag if one of these is true: + +- the user explicitly requested it +- it is required to satisfy a schema constraint +- it is required for safe machine-readable execution in Execute mode + +Do not add optional flags because they seem convenient. + +### Sensitive and Risky Flags + +1. Never include API tokens in chat output. +2. Treat every option with `sensitive: true` as a secret even when its name is neutral. Never put + its value on argv or in a generated command. Prefer the schema-declared `envvar`, a hidden local + prompt, or another documented non-argv source. +3. When a sensitive value is required, tell the user which environment variable or local prompt + the command uses without asking for or displaying the value. +4. Do not pass diagnostic or verbose flags unless the user explicitly asked for + diagnostic output on a failed readonly command. +5. Do not pass local output/export/config path options unless the user explicitly + asked for local writes and provided the destination path. +6. Never rely on schema default output paths for customer data exports. + +### Target Identity Rules + +For Class C commands, require an explicit, unambiguous target selector before +execution. + +- If the user gives a broad search, friendly name, or query and the schema + exposes a readonly lookup that can resolve the target set, run that lookup + first when allowed. +- Present the resolved target or exact target count before asking for + confirmation. +- Do not mutate based on vague wording like "all of them" unless the target set + has been enumerated and confirmed. + +### Bulk Input Rules + +If a command uses a file or list input for bulk work: + +1. Inspect the file before execution. +2. Count non-empty targets. +3. Surface duplicates or obviously malformed lines to the user. +4. Tell the user the exact target count. +5. Never create a bulk file for a mutating operation unless the user explicitly + asked you to. + +## Step 4: Execution Policy + +Apply these rules after selecting execution mode. + +### Class A: Readonly, No Local Writes + +In Execute mode, run the command after validation if: + +- the command match is unambiguous +- region/profile is resolved +- auth requirements are satisfied +- all schema constraints are satisfied + +In Generate-Only mode, validate the invocation as far as the selected mode +allows, then return the exact command. + +### Class B: Readonly, Local Writes or Exports + +In Execute mode, before executing: + +1. Confirm the user wants a local export or local write. +2. Require an explicit destination path. +3. State what will be written and where. +4. Do not use schema defaults for file locations. + +In Generate-Only mode, require an explicit destination path before generating the +command, then state what will be written locally if the user runs it. + +### Class C: Mutating Commands + +In Execute mode, never execute a mutating command immediately. Use this workflow: + +1. Validate the command match and canonical region/profile. +2. Validate credentials using the command's `auth` metadata. +3. Resolve targets to an unambiguous selector or exact target count. +4. For bulk work, inspect the target list and present the exact count. +5. If the schema exposes a preflight-only mode through a `check` option or a + `mode` constraint, run the preflight first and present the result. +6. If no suitable preflight exists, say so explicitly and stop unless the user + explicitly approves proceeding without preflight. +7. Present an execution plan containing: + - command path + - target selector or target count + - auth profile + - intended change + - preflight result, if available + - exact command that will be executed +8. Require explicit confirmation before execution. + +In Generate-Only mode, validate as far as allowed, mark whether live preflight +was performed, return the exact command, and do not execute it. + +#### Confirmation Rules for Mutating Commands + +These confirmation rules apply only in Execute mode. + +For any Class C command, require the user to send the exact shell command from +the reviewed plan, prefixed with `EXECUTE `. Use this format: + +```text +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. + +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 +receipt only for an exact match, and consumes that receipt after one matching +execution attempt or clears it when the turn ends. + +For bulk or broad-target mutations, require two confirmations: + +1. A first confirmation that they want to proceed with the plan. +2. A second message containing `EXECUTE ` followed by the exact shell command. + +#### Red Lines for Mutating Commands + +Never execute a Class C command when any of these is true: + +- the command match is ambiguous +- the region or profile is ambiguous +- the target is vague or unresolved +- a bulk file has not been inspected +- credentials are missing or would expose secrets in chat +- the user gave a vague instruction like "fix it" or "do this everywhere" +- you cannot state the exact intended change in one sentence + +## Step 5: Parse and Present Results + +Parse JSON output when available and summarize only the data needed to answer the +request. + +### Result Presentation + +- For a single boolean or scalar answer, state it directly. +- For small structured results, summarize the important fields. +- For tabular results, use a markdown table when that improves clarity. +- For exported data, confirm the output path and summarize what was written + without dumping sensitive data into chat unless the user explicitly asks. + +### Errors + +If the command exits non-zero: + +1. Report the failure clearly. +2. Include useful stderr details when they do not expose secrets. +3. Suggest the smallest corrective action. +4. Do not retry automatically, especially for mutating commands. + +## Parsing `option_groups` + +Each item in `option_groups` defines an explicit constraint. Enforce the schema +literally. + +### Mutually Exclusive Groups (`"mutually_exclusive": true`) + +The listed options cannot be used together. + +- If `required` is true, exactly one must be present. +- Otherwise, at most one may be present. + +### Dependency Groups (`"dependent": true`) + +The listed options require another option named in `requires`. + +### Other Constraints + +Also enforce every entry in `constraints`, including required-any, +required-unless, exactly-one-unless, value-prefix, dependent, conditional, and +preflight mode constraints. + +### Validation Rules + +Before executing any command: + +1. Check every mutually exclusive group. +2. Check every dependency group. +3. Check every command constraint. +4. If validation fails, explain the exact conflict and stop. +5. Do not silently add missing options unless the user supplied the needed value. +6. Do not silently remove conflicting flags. + +## Important Rules + +1. Never hardcode commands. All command knowledge comes from schema export. +2. Never fabricate options. Only use options listed in the matched schema entry. +3. Always pass canonical region values. +4. Never ask the user to paste secrets into chat. +5. Never guess between ambiguous commands or targets. +6. Never rely on schema default export paths for customer data. +7. Never execute a mutating command without the required confirmation workflow. +8. If a command fails, report it clearly and stop. Do not auto-retry unless the + user explicitly asks. +9. In Generate-Only mode, never execute the final business command. diff --git a/plugins/sccfm/skills/sccfm-setup/SKILL.md b/plugins/sccfm/skills/sccfm-setup/SKILL.md new file mode 100644 index 00000000..07a41d09 --- /dev/null +++ b/plugins/sccfm/skills/sccfm-setup/SKILL.md @@ -0,0 +1,157 @@ +--- +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" +--- + +# 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. + +## Setup modes + +- **Check:** inspect the current runtime without changing it. +- **Install or upgrade:** install one stable, matching CLI and collection version. +- **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 + +Resolve this skill's plugin root, then run: + +```bash +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. + +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. + +## 2. Plan installation + +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. + +Select an available Python 3.12 executable from the doctor report. Generate the +exact plan without executing it: + +```bash +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. + +Require the exact confirmation `INSTALL SCCFM X.Y.Z`. Only then run: + +```bash +python3 scripts/setup_runtime.py install --version X.Y.Z --python python3.12 --yes +``` + +Do not use `--yes` before receiving that confirmation. Do not install from an +unreviewed branch, draft release, or mismatched artifact set. + +## 3. Configure authentication + +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: + +```bash +sccfm-cli configure --region +``` + +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 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`. + +Setup is complete only when: + +- CLI schema export succeeds; +- CLI, Python package, and Ansible collection versions match; +- the selected profile passes a read-only connectivity check; +- `ansible-doc` discovers the installed collection; and +- one harmless read-only operation succeeds, if the user permits live validation. + +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 +``` + +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. + +## Safety boundary + +This skill manages setup and teardown only. 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 +skill. diff --git a/sccfm-ansible/CHANGELOG.rst b/sccfm-ansible/CHANGELOG.rst index 6cc517be..1e2198e1 100644 --- a/sccfm-ansible/CHANGELOG.rst +++ b/sccfm-ansible/CHANGELOG.rst @@ -4,6 +4,14 @@ Cisco SCCFM Collection Release Notes .. contents:: Topics +v0.40.0 +======== + +Minor Changes +------------- + +- Added an installable agent plugin for Claude Code and Codex with guided SCCFM runtime setup, synchronized CLI and Ansible skills, and exact-command approval guardrails for mutating operations. + v0.39.5 ======== diff --git a/sccfm-ansible/changelogs/changelog.yaml b/sccfm-ansible/changelogs/changelog.yaml index 89c1a820..f73d7a9d 100644 --- a/sccfm-ansible/changelogs/changelog.yaml +++ b/sccfm-ansible/changelogs/changelog.yaml @@ -2,6 +2,14 @@ ancestor: null # sccfm-release-retarget-seed: 0.39.0 releases: + 0.40.0: + changes: + minor_changes: + - Added an installable agent plugin for Claude Code and Codex with guided + SCCFM runtime setup, synchronized CLI and Ansible skills, and exact-command + approval guardrails for mutating operations. + fragments: [] + release_date: '2026-08-26' 0.39.5: changes: minor_changes: diff --git a/skills/sccfm-ansible/SKILL.md b/skills/sccfm-ansible/SKILL.md index 39c59794..a39cfa33 100644 --- a/skills/sccfm-ansible/SKILL.md +++ b/skills/sccfm-ansible/SKILL.md @@ -444,18 +444,41 @@ execute the mutating playbook. These confirmation rules apply only in Execute mode. -For any Class C playbook, require the user to send the exact confirmation phrase -you provide: +For any Class C playbook, require the user to send the exact `ansible-playbook` +shell command from the reviewed plan, prefixed with `EXECUTE `: ```text -EXECUTE cisco.sccfm +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. + +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, +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 +binds the shell command itself. + For production, deployment, upgrade, credential, or bulk mutations, require two confirmations: 1. A first confirmation that they want to proceed with the plan. -2. A second message containing the exact `EXECUTE ...` phrase. +2. A second message containing `EXECUTE ` followed by the exact shell command. #### Red Lines for Mutating Playbooks diff --git a/skills/sccfm-cli/SKILL.md b/skills/sccfm-cli/SKILL.md index c3728fdb..ede465ba 100644 --- a/skills/sccfm-cli/SKILL.md +++ b/skills/sccfm-cli/SKILL.md @@ -1,8 +1,6 @@ --- name: sccfm-cli description: Use the customer-facing SCC Firewall Manager CLI by discovering the live schema, validating command metadata, and either executing or generating safe sccfm-cli commands without hardcoded command knowledge. Use for SCCFM CLI workflows; use the sccfm-ansible skill instead for cisco.sccfm Ansible playbooks, inventories, and modules. -when_to_use: When the user asks to use, install, configure, inspect, or generate commands for sccfm-cli or SCC Firewall Manager CLI workflows. -argument-hint: "[describe the SCCFM CLI task]" allowed-tools: "Bash(command -v *) Bash(sccfm-cli *) Bash(source cisco_sccfm_scripts/activate.sh) Bash(brew *) Bash(pipx *) Bash(jq *) Read Grep Glob" --- @@ -426,17 +424,38 @@ was performed, return the exact command, and do not execute it. These confirmation rules apply only in Execute mode. -For any Class C command, require the user to send the exact confirmation phrase -you provide. Use this format: +For any Class C command, require the user to send the exact shell command from +the reviewed plan, prefixed with `EXECUTE `. Use this format: ```text -EXECUTE sccfm-cli +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. + +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 +receipt only for an exact match, and consumes that receipt after one matching +execution attempt or clears it when the turn ends. + For bulk or broad-target mutations, require two confirmations: 1. A first confirmation that they want to proceed with the plan. -2. A second message containing the exact `EXECUTE ...` phrase. +2. A second message containing `EXECUTE ` followed by the exact shell command. #### Red Lines for Mutating Commands