diff --git a/ggshield/__main__.py b/ggshield/__main__.py index 0baf27b776..8b462cd84c 100644 --- a/ggshield/__main__.py +++ b/ggshield/__main__.py @@ -19,6 +19,7 @@ from ggshield.cmd.quota import quota_cmd from ggshield.cmd.secret import secret_group from ggshield.cmd.secret.scan import scan_group +from ggshield.cmd.skill import skill_group from ggshield.cmd.status import status_cmd from ggshield.cmd.utils.common_options import add_common_options from ggshield.cmd.utils.context_obj import ContextObj @@ -92,6 +93,7 @@ def _load_plugins() -> PluginRegistry: "config": config_group, "plugin": plugin_group, "secret": secret_group, + "skill": skill_group, "install": install_cmd, "quota": quota_cmd, "api-status": status_cmd, diff --git a/ggshield/cmd/skill/__init__.py b/ggshield/cmd/skill/__init__.py new file mode 100644 index 0000000000..88e2d5bc7a --- /dev/null +++ b/ggshield/cmd/skill/__init__.py @@ -0,0 +1,17 @@ +from typing import Any + +import click + +from ggshield.cmd.utils.common_options import add_common_options + +from .install import install_cmd +from .uninstall import uninstall_cmd +from .update import update_cmd + + +@click.group( + commands={"install": install_cmd, "update": update_cmd, "uninstall": uninstall_cmd} +) +@add_common_options() +def skill_group(**kwargs: Any) -> None: + """Manage the ggshield AI assistant skill.""" diff --git a/ggshield/cmd/skill/install.py b/ggshield/cmd/skill/install.py new file mode 100644 index 0000000000..3580b0ad65 --- /dev/null +++ b/ggshield/cmd/skill/install.py @@ -0,0 +1,40 @@ +import shutil +from pathlib import Path +from typing import Any + +import click + +from ggshield.cmd.utils.common_options import add_common_options +from ggshield.core.errors import UnexpectedError + +from .targets import TARGET_CHOICES, get_skill_path + + +_BUNDLED_SKILL = Path(__file__).parents[2] / "resources" / "claude_skill" / "SKILL.md" + + +@click.command() +@click.option( + "--target", + type=click.Choice(TARGET_CHOICES), + default="claude", + show_default=True, + help="AI coding assistant to install the skill for.", +) +@click.option("--force", "-f", is_flag=True, help="Overwrite existing skill.") +@add_common_options() +def install_cmd(target: str, force: bool, **kwargs: Any) -> int: + """Install the ggshield skill for an AI coding assistant.""" + dest = get_skill_path(target) + + if dest.exists() and not force: + raise UnexpectedError( + f"ggshield skill is already installed at {dest}. Use --force to overwrite." + ) + + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(_BUNDLED_SKILL, dest) + click.echo( + f"ggshield skill installed at {click.style(str(dest), fg='yellow', bold=True)}" + ) + return 0 diff --git a/ggshield/cmd/skill/targets.py b/ggshield/cmd/skill/targets.py new file mode 100644 index 0000000000..d350c137f7 --- /dev/null +++ b/ggshield/cmd/skill/targets.py @@ -0,0 +1,17 @@ +import os +from pathlib import Path +from typing import Dict, Tuple + + +TARGETS: Dict[str, Tuple[Path, str]] = { + "claude": (Path.home() / ".claude", "CLAUDE_CONFIG_DIR"), + "cursor": (Path.home() / ".cursor", "CURSOR_CONFIG_DIR"), +} + +TARGET_CHOICES = list(TARGETS.keys()) + + +def get_skill_path(target: str) -> Path: + default_dir, env_var = TARGETS[target] + base = Path(os.environ[env_var]) if env_var in os.environ else default_dir + return base / "skills" / "ggshield" / "SKILL.md" diff --git a/ggshield/cmd/skill/uninstall.py b/ggshield/cmd/skill/uninstall.py new file mode 100644 index 0000000000..c16052aed8 --- /dev/null +++ b/ggshield/cmd/skill/uninstall.py @@ -0,0 +1,38 @@ +from typing import Any + +import click + +from ggshield.cmd.utils.common_options import add_common_options + +from .targets import TARGET_CHOICES, get_skill_path + + +@click.command() +@click.option( + "--target", + type=click.Choice(TARGET_CHOICES), + default="claude", + show_default=True, + help="AI coding assistant to uninstall the skill from.", +) +@add_common_options() +def uninstall_cmd(target: str, **kwargs: Any) -> int: + """Uninstall the ggshield skill.""" + dest = get_skill_path(target) + + if not dest.exists(): + click.echo("ggshield skill is not installed.") + return 0 + + dest.unlink() + + # Remove parent dir if empty + try: + dest.parent.rmdir() + except OSError: + pass + + click.echo( + f"ggshield skill uninstalled from {click.style(str(dest), fg='yellow', bold=True)}" + ) + return 0 diff --git a/ggshield/cmd/skill/update.py b/ggshield/cmd/skill/update.py new file mode 100644 index 0000000000..c0296c61c0 --- /dev/null +++ b/ggshield/cmd/skill/update.py @@ -0,0 +1,40 @@ +import shutil +from pathlib import Path +from typing import Any + +import click + +from ggshield.cmd.utils.common_options import add_common_options + +from .targets import TARGET_CHOICES, get_skill_path + + +_BUNDLED_SKILL = Path(__file__).parents[2] / "resources" / "claude_skill" / "SKILL.md" + + +@click.command() +@click.option( + "--target", + type=click.Choice(TARGET_CHOICES), + default="claude", + show_default=True, + help="AI coding assistant to update the skill for.", +) +@add_common_options() +def update_cmd(target: str, **kwargs: Any) -> int: + """Update the installed ggshield skill.""" + dest = get_skill_path(target) + fresh = not dest.exists() + + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(_BUNDLED_SKILL, dest) + + if fresh: + click.echo( + f"ggshield skill installed at {click.style(str(dest), fg='yellow', bold=True)}" + ) + else: + click.echo( + f"ggshield skill updated at {click.style(str(dest), fg='yellow', bold=True)}" + ) + return 0 diff --git a/ggshield/resources/__init__.py b/ggshield/resources/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ggshield/resources/claude_skill/SKILL.md b/ggshield/resources/claude_skill/SKILL.md new file mode 100644 index 0000000000..e30e889d55 --- /dev/null +++ b/ggshield/resources/claude_skill/SKILL.md @@ -0,0 +1,170 @@ +--- +name: ggshield +description: >- + Use when working with ggshield or GitGuardian secret detection. + Triggers: "ggshield", "scan for secrets", "secret detection", "GitGuardian" +version: 1.0.0 +tools: Read, Bash, Glob, Grep +--- + +# ggshield — GitGuardian Secret Detection CLI + +ggshield is the CLI for GitGuardian's secret detection engine. It scans code, files, and git history for leaked credentials and secrets. + +## Authentication + +Before scanning, authenticate with GitGuardian: + +```bash +ggshield auth login +``` + +This opens a browser for OAuth login. Use `--method token` for headless environments: + +```bash +ggshield auth login --method token +``` + +Check authentication status: + +```bash +ggshield api-status +``` + +## Secret Scanning + +### Scan a directory or file + +```bash +ggshield secret scan path +``` + +### Scan git diff (staged changes) + +```bash +ggshield secret scan pre-commit +``` + +### Scan a git range + +```bash +ggshield secret scan commit-range HEAD~5..HEAD +``` + +### Scan a specific commit + +```bash +ggshield secret scan commit +``` + +### Scan a Docker image + +```bash +ggshield secret scan docker +``` + +## Understanding Output + +When secrets are found, ggshield reports: + +- **Detector name**: the type of secret (e.g., `github_token`, `aws_access_key`) +- **Severity**: `critical`, `high`, `medium`, `low`, `info` +- **File and line**: location of the secret +- **Match**: the matched value (partially redacted) + +Exit codes: + +- `0`: no secrets found (or `--exit-zero` is set) +- `1`: secrets found +- `128`: unexpected error + +## Remediation + +### Ignore a detected secret + +To mark a secret as a known false positive: + +```bash +ggshield secret ignore --last-found +``` + +Or add `# ggignore` on the same line as the secret in the source file. + +### Rotate credentials + +When a real secret is found: + +1. Revoke the credential in the relevant service immediately +2. Generate a new credential +3. Update references in the codebase +4. Consider using a secrets manager (Vault, AWS Secrets Manager, etc.) + +## Git Hooks + +### Install as a pre-commit hook (local) + +```bash +ggshield install --mode local +``` + +### Install as a pre-push hook + +```bash +ggshield install --mode local --hook-type pre-push +``` + +### Install globally (all repos) + +```bash +ggshield install --mode global +``` + +## Common Flags + +| Flag | Description | +| ---------------------------- | ----------------------------------------------------------------- | +| `--exit-zero` | Always return exit code 0 (useful in CI to not block on findings) | +| `--output ` | Write output to a file | +| `--json` | Output results as JSON | +| `--minimum-severity ` | Only report secrets at or above this severity | +| `--ignore-known-secrets` | Skip secrets already present in the GitGuardian dashboard | + +## Configuration + +ggshield reads from `.gitguardian.yaml` in the repo root or `~/.gitguardian.yaml` globally. + +Example `.gitguardian.yaml`: + +```yaml +exit-zero: false +minimum-severity: medium +ignore-paths: + - tests/fixtures/ +``` + +## Common Workflows + +### Check staged changes before commit + +```bash +git add +ggshield secret scan pre-commit +``` + +### Scan entire repository history + +```bash +ggshield secret scan repo . +``` + +### CI integration (non-blocking) + +```bash +ggshield secret scan ci --exit-zero +``` + +### View scan results as JSON + +```bash +ggshield secret scan path . --json +``` diff --git a/pyproject.toml b/pyproject.toml index d87b37f8d2..7b45f6bf0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,9 @@ Homepage = "https://github.com/GitGuardian/ggshield" [project.scripts] ggshield = "ggshield.__main__:main" +[tool.pdm.build] +includes = ["ggshield/resources/"] + [build-system] requires = ["pdm-backend"] build-backend = "pdm.backend" diff --git a/tests/functional/test_skill.py b/tests/functional/test_skill.py new file mode 100644 index 0000000000..e4b0abb573 --- /dev/null +++ b/tests/functional/test_skill.py @@ -0,0 +1,40 @@ +from click.testing import CliRunner + +from ggshield.__main__ import cli + + +def test_skill_lifecycle(tmp_path, monkeypatch): + """Full install/update/uninstall lifecycle against a real temp directory.""" + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path)) + runner = CliRunner() + skill_path = tmp_path / "skills" / "ggshield" / "SKILL.md" + + # Install + result = runner.invoke(cli, ["skill", "install"]) + assert result.exit_code == 0, result.output + assert skill_path.is_file() + original_content = skill_path.read_text() + assert "ggshield" in original_content.lower() + + # Reinstall without force should fail + result = runner.invoke(cli, ["skill", "install"]) + assert result.exit_code != 0 + assert skill_path.read_text() == original_content # unchanged + + # Force reinstall should succeed + result = runner.invoke(cli, ["skill", "install", "--force"]) + assert result.exit_code == 0, result.output + + # Update should succeed and overwrite + result = runner.invoke(cli, ["skill", "update"]) + assert result.exit_code == 0, result.output + assert skill_path.is_file() + + # Uninstall + result = runner.invoke(cli, ["skill", "uninstall"]) + assert result.exit_code == 0, result.output + assert not skill_path.exists() + + # Uninstall again is a no-op + result = runner.invoke(cli, ["skill", "uninstall"]) + assert result.exit_code == 0, result.output diff --git a/tests/unit/cmd/skill/__init__.py b/tests/unit/cmd/skill/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/cmd/skill/test_skill.py b/tests/unit/cmd/skill/test_skill.py new file mode 100644 index 0000000000..0dd8d5a2f6 --- /dev/null +++ b/tests/unit/cmd/skill/test_skill.py @@ -0,0 +1,81 @@ +from pathlib import Path + +from ggshield.__main__ import cli +from ggshield.cmd.skill.targets import get_skill_path +from ggshield.core.errors import ExitCode +from tests.unit.conftest import assert_invoke_exited_with, assert_invoke_ok + + +class TestSkillInstall: + def test_install_creates_skill_file(self, cli_runner, tmp_path, monkeypatch): + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path)) + result = cli_runner.invoke(cli, ["skill", "install"]) + assert_invoke_ok(result) + assert (tmp_path / "skills" / "ggshield" / "SKILL.md").is_file() + assert "installed" in result.output.lower() + + def test_install_already_exists_no_force(self, cli_runner, tmp_path, monkeypatch): + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path)) + cli_runner.invoke(cli, ["skill", "install"]) + result = cli_runner.invoke(cli, ["skill", "install"]) + assert_invoke_exited_with(result, ExitCode.UNEXPECTED_ERROR) + assert ( + "already installed" in result.output.lower() or "--force" in result.output + ) + + def test_install_force_overwrites(self, cli_runner, tmp_path, monkeypatch): + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path)) + cli_runner.invoke(cli, ["skill", "install"]) + result = cli_runner.invoke(cli, ["skill", "install", "--force"]) + assert_invoke_ok(result) + + def test_install_cursor_target(self, cli_runner, tmp_path, monkeypatch): + monkeypatch.setenv("CURSOR_CONFIG_DIR", str(tmp_path)) + result = cli_runner.invoke(cli, ["skill", "install", "--target", "cursor"]) + assert_invoke_ok(result) + assert (tmp_path / "skills" / "ggshield" / "SKILL.md").is_file() + + def test_install_invalid_target(self, cli_runner): + result = cli_runner.invoke(cli, ["skill", "install", "--target", "vscode"]) + assert result.exit_code != 0 + + +class TestSkillUpdate: + def test_update_overwrites_existing(self, cli_runner, tmp_path, monkeypatch): + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path)) + cli_runner.invoke(cli, ["skill", "install"]) + result = cli_runner.invoke(cli, ["skill", "update"]) + assert_invoke_ok(result) + assert "updated" in result.output.lower() + + def test_update_when_not_installed(self, cli_runner, tmp_path, monkeypatch): + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path)) + result = cli_runner.invoke(cli, ["skill", "update"]) + assert_invoke_ok(result) + assert (tmp_path / "skills" / "ggshield" / "SKILL.md").is_file() + + +class TestSkillUninstall: + def test_uninstall_removes_file(self, cli_runner, tmp_path, monkeypatch): + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path)) + cli_runner.invoke(cli, ["skill", "install"]) + result = cli_runner.invoke(cli, ["skill", "uninstall"]) + assert_invoke_ok(result) + assert not (tmp_path / "skills" / "ggshield" / "SKILL.md").exists() + + def test_uninstall_when_not_installed(self, cli_runner, tmp_path, monkeypatch): + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path)) + result = cli_runner.invoke(cli, ["skill", "uninstall"]) + assert_invoke_ok(result) + + +class TestTargetResolution: + def test_env_var_overrides_default(self, tmp_path, monkeypatch): + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path)) + path = get_skill_path("claude") + assert path == tmp_path / "skills" / "ggshield" / "SKILL.md" + + def test_default_path_when_no_env_var(self, monkeypatch): + monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False) + path = get_skill_path("claude") + assert path == Path.home() / ".claude" / "skills" / "ggshield" / "SKILL.md"