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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ggshield/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
17 changes: 17 additions & 0 deletions ggshield/cmd/skill/__init__.py
Original file line number Diff line number Diff line change
@@ -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."""
40 changes: 40 additions & 0 deletions ggshield/cmd/skill/install.py
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions ggshield/cmd/skill/targets.py
Original file line number Diff line number Diff line change
@@ -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"
38 changes: 38 additions & 0 deletions ggshield/cmd/skill/uninstall.py
Original file line number Diff line number Diff line change
@@ -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
40 changes: 40 additions & 0 deletions ggshield/cmd/skill/update.py
Original file line number Diff line number Diff line change
@@ -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
Empty file.
170 changes: 170 additions & 0 deletions ggshield/resources/claude_skill/SKILL.md
Original file line number Diff line number Diff line change
@@ -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 <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 <sha>
```

### Scan a Docker image

```bash
ggshield secret scan docker <image>
```

## 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 <file>` | Write output to a file |
| `--json` | Output results as JSON |
| `--minimum-severity <level>` | 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 <files>
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
```
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
40 changes: 40 additions & 0 deletions tests/functional/test_skill.py
Original file line number Diff line number Diff line change
@@ -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
Empty file.
Loading
Loading