diff --git a/.agentctl/config.example.yaml b/.agentctl/config.example.yaml new file mode 100644 index 0000000..fd19d4e --- /dev/null +++ b/.agentctl/config.example.yaml @@ -0,0 +1,27 @@ +# Copy this file to .agentctl/config.yaml and adapt it to the workspace. +# This example uses only fields supported by the current alpha scanner. + +exclude: + - .git + - vendor + - node_modules + - dist + - build + - coverage + - testdata + - examples + - docs + +approved_providers: + - openai + - anthropic + - internal-provider + - local-ollama + - local-vllm + +approved_mcp_servers: + - internal-crm + - internal-docs + - internal-ticketing + +freshness_days: 30 diff --git a/.githooks/check_staged_secrets.py b/.githooks/check_staged_secrets.py new file mode 100644 index 0000000..541220c --- /dev/null +++ b/.githooks/check_staged_secrets.py @@ -0,0 +1,340 @@ +#!/usr/bin/env python3 +"""Fail-closed staged diff guard for obvious and context-rich secrets.""" + +from __future__ import annotations + +import base64 +import fnmatch +import hashlib +import math +import re +import subprocess +import sys +import time +import unicodedata +import urllib.parse +from dataclasses import dataclass +from pathlib import Path + + +MAX_FILES = 1000 +MAX_TOTAL_DIFF_BYTES = 8 * 1024 * 1024 +MAX_LINE_BYTES = 256 * 1024 +MAX_FINDINGS = 100 +MAX_SECONDS = 5.0 +SYNTHETIC_MARKERS = ("SYNTHETIC", "NOT-REAL", "PLACEHOLDER", "REDACTED", "TEST-ONLY") + +SECRET_PATTERNS = ( + ("aws_access_key", re.compile(r"AKIA[0-9A-Z]{16}")), + ("private_key", re.compile(r"-----BEGIN (?:RSA|EC|OPENSSH|PRIVATE) KEY-----")), + ( + "credential_assignment", + re.compile( + r"(?i)(?:api[_-]?key|secret|token|password|authorization|private[_-]?key)" + r"\s*[:=]\s*['\"][^'\"]{8,}['\"]" + ), + ), + ("bearer_token", re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._-]{20,}")), + ("github_token", re.compile(r"\bgh[pousr]_[A-Za-z0-9_]{20,}")), + ("slack_token", re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}")), +) + +SENSITIVE_CONTEXT = re.compile( + r"(?i)(?:token|secret|credential|authorization|private[_-]?key|api[_-]?key|password)" + r"\s*[:=]\s*['\"]?([A-Za-z0-9+/=_-]{20,})" +) + + +@dataclass(frozen=True) +class Finding: + kind: str + path: str + line: int = 0 + length: int = 0 + fingerprint: str = "" + + +def fingerprint(value: str) -> str: + digest = hashlib.sha256(value.encode("utf-8", "replace")).hexdigest()[:16] + return f"sha256:{digest}" + + +def synthetic_value(value: str) -> bool: + upper = value.upper() + return any(marker in upper for marker in SYNTHETIC_MARKERS) + + +def variants(value: str) -> list[str]: + normalized = unicodedata.normalize("NFKC", value) + normalized = re.sub(r"[\u0000\u200b-\u200f\u202a-\u202e\u2060\u2066-\u2069\ufeff]", "", normalized) + normalized = re.sub(r"([\"'])\s*\+\s*([\"'])", "", normalized) + values = [normalized] + current = normalized + for _ in range(2): + decoded = urllib.parse.unquote(current) + if decoded == current: + break + values.append(decoded) + current = decoded + escaped = re.sub(r"\\u([0-9a-fA-F]{4})", lambda match: chr(int(match.group(1), 16)), normalized) + if escaped != normalized: + values.append(escaped) + for candidate in list(values): + for match in re.finditer(r"\b[A-Za-z0-9+/]{24,}={0,2}\b", candidate): + encoded = match.group(0) + try: + decoded = base64.b64decode(encoded, validate=True).decode("utf-8") + except (ValueError, UnicodeDecodeError): + continue + if decoded and sum(char.isprintable() for char in decoded) / len(decoded) >= 0.85: + values.append(decoded) + return list(dict.fromkeys(values)) + + +def shannon_entropy(value: str) -> float: + if not value: + return 0.0 + counts = {char: value.count(char) for char in set(value)} + size = len(value) + return -sum((count / size) * math.log2(count / size) for count in counts.values()) + + +def sensitive_filename(path: str) -> bool: + base = Path(path).name.lower() + if base == ".env.example": + return False + if base == ".env" or base.startswith(".env."): + return True + if base in {"credentials.json", "service-account.json", "kubeconfig", "id_rsa", "id_ed25519"}: + return True + return any( + fnmatch.fnmatch(base, pattern) + for pattern in ("*.pem", "*.key", "*.p12", "*.pfx", "*.jks") + ) + + +def scan_added_line(path: str, line: str, line_number: int) -> list[Finding]: + if len(line.encode("utf-8", "replace")) > MAX_LINE_BYTES: + return [Finding("line_limit", path, line_number)] + found: list[Finding] = [] + seen: set[tuple[str, int]] = set() + for candidate in variants(line): + for kind, pattern in SECRET_PATTERNS: + for match in pattern.finditer(candidate): + key = (kind, match.start()) + if key in seen: + continue + seen.add(key) + value = match.group(0) + if synthetic_value(value): + continue + found.append(Finding(kind, path, line_number, len(value), fingerprint(value))) + for match in SENSITIVE_CONTEXT.finditer(candidate): + value = match.group(1) + if synthetic_value(value): + continue + if shannon_entropy(value) < 4.2: + continue + found.append(Finding("high_entropy_secret_context", path, line_number, len(value), fingerprint(value))) + unique: dict[tuple[str, str, int], Finding] = {} + for item in found: + unique[(item.kind, item.path, item.line)] = item + return list(unique.values()) + + +def run_git(root: Path, args: list[str], limit: int | None = None) -> bytes: + process = subprocess.run( + ["git", "-C", str(root), *args], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=MAX_SECONDS, + ) + if limit is not None and len(process.stdout) > limit: + raise RuntimeError("git output exceeds staged scan limit") + return process.stdout + + +def staged_paths(root: Path) -> list[tuple[str, str]]: + raw = run_git(root, ["diff", "--cached", "--binary", "--name-status", "-z", "--diff-filter=ACMRTUXB"]) + tokens = raw.split(b"\0") + result: list[tuple[str, str]] = [] + index = 0 + while index < len(tokens) and tokens[index]: + status = tokens[index].decode("utf-8", "replace") + index += 1 + if status[:1] in {"R", "C"}: + if index + 1 >= len(tokens): + raise RuntimeError("malformed staged rename record") + index += 1 + path = tokens[index].decode("utf-8", "replace") + index += 1 + else: + if index >= len(tokens): + raise RuntimeError("malformed staged path record") + path = tokens[index].decode("utf-8", "replace") + index += 1 + result.append((status, path)) + return result + + +def staged_modes(root: Path, paths: list[tuple[str, str]]) -> dict[str, str]: + modes: dict[str, str] = {} + for _, path in paths: + raw = run_git(root, ["ls-files", "--stage", "-z", "--", path]) + for record in raw.split(b"\0"): + if not record: + continue + header, _, stored_path = record.partition(b"\t") + fields = header.decode("ascii", "replace").split() + if len(fields) >= 1: + modes[stored_path.decode("utf-8", "replace")] = fields[0] + return modes + + +def validate_paths(root: Path, paths: list[tuple[str, str]], modes: dict[str, str]) -> list[Finding]: + findings: list[Finding] = [] + root_real = root.resolve() + for status, path in paths: + path_obj = Path(path) + if path_obj.is_absolute() or ".." in path_obj.parts or "\x00" in path: + findings.append(Finding("unsafe_staged_path", path)) + continue + resolved = (root / path_obj).resolve() + try: + resolved.relative_to(root_real) + except ValueError: + findings.append(Finding("path_outside_repository", path)) + if sensitive_filename(path): + findings.append(Finding("sensitive_filename", path)) + if modes.get(path) == "120000": + findings.append(Finding("staged_symlink", path)) + if status[:1] in {"R", "C"} and sensitive_filename(path): + findings.append(Finding("sensitive_renamed_filename", path)) + return findings + + +def scan_diff(root: Path, path_set: set[str]) -> list[Finding]: + raw = run_git( + root, + ["diff", "--cached", "--binary", "--unified=0", "--no-ext-diff", "--", ".", ":!*.lock"], + MAX_TOTAL_DIFF_BYTES, + ) + findings: list[Finding] = [] + current_path = "" + current_line = 0 + for raw_line in raw.splitlines(): + line = raw_line.decode("utf-8", "replace") + if line.startswith("diff --git ") and " b/" in line: + current_path = line.rsplit(" b/", 1)[1] + current_line = 0 + continue + if line.startswith("+++ b/"): + current_path = line[6:] + continue + if line.startswith("Binary files ") and current_path in path_set: + yield_finding = Finding("binary_not_scanned", current_path) + findings.append(yield_finding) + continue + if line.startswith("@@"): + match = re.search(r"\+([0-9]+)", line) + current_line = int(match.group(1)) if match else 0 + continue + if not line.startswith("+") or line.startswith("+++") or current_path not in path_set: + continue + findings.extend(scan_added_line(current_path, line[1:], current_line)) + current_line += 1 + return findings + + +def validate_config(path: Path) -> list[Finding]: + try: + content = path.read_text(encoding="utf-8") + except OSError as error: + return [Finding("config_unreadable", str(path))] + if len(content.encode("utf-8")) > 64 * 1024: + return [Finding("config_size_limit", str(path))] + findings: list[Finding] = [] + for line_number, line in enumerate(content.splitlines(), 1): + findings.extend(scan_added_line(str(path), line, line_number)) + list_key = "" + for raw_line in content.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("-"): + value = line[1:].strip().strip("\"'") + if list_key in {"approved_providers", "approved_mcp_servers"} and any(char in value for char in "*?"): + findings.append(Finding("policy_wildcard", str(path))) + continue + key, separator, value = line.partition(":") + if not separator: + continue + list_key = key.strip().lower().replace("-", "_") + if list_key in {"approved_providers", "approved_mcp_servers"} and any(char in value for char in "*?"): + findings.append(Finding("policy_wildcard", str(path))) + return findings + + +def describe(item: Finding) -> str: + details = f" length={item.length}" if item.length else "" + suffix = f" {item.fingerprint}" if item.fingerprint else "" + location = f"{item.path}:{item.line}" if item.line else item.path + return f"staged guard: {item.kind} at {location}{details}{suffix}" + + +def main(argv: list[str]) -> int: + root = Path.cwd() + if "--root" in argv: + index = argv.index("--root") + if index + 1 >= len(argv): + print("staged guard: --root requires a path", file=sys.stderr) + return 2 + root = Path(argv[index + 1]).resolve() + if "--check-config" in argv: + index = argv.index("--check-config") + if index + 1 >= len(argv): + print("staged guard: --check-config requires a path", file=sys.stderr) + return 2 + findings = validate_config(Path(argv[index + 1])) + for item in findings: + print(describe(item), file=sys.stderr) + if findings: + print(f"staged guard: blocked {len(findings)} config finding(s)", file=sys.stderr) + return 1 + print("staged guard: scanner config passed") + return 0 + started = time.monotonic() + try: + paths = staged_paths(root) + if len(paths) > MAX_FILES: + raise RuntimeError("staged file count exceeds limit") + modes = staged_modes(root, paths) + findings = validate_paths(root, paths, modes) + findings.extend(scan_diff(root, {path for _, path in paths})) + config = root / ".agentctl" / "config.yaml" + if config.exists(): + findings.extend(validate_config(config)) + except (OSError, RuntimeError, subprocess.SubprocessError) as error: + print(f"staged guard: blocked ({error})", file=sys.stderr) + return 1 + if time.monotonic() - started > MAX_SECONDS: + findings.append(Finding("time_limit")) + unique: dict[tuple[str, str, int], Finding] = {} + for item in findings: + unique[(item.kind, item.path, item.line)] = item + findings = list(unique.values()) + if len(findings) > MAX_FINDINGS: + findings = findings[:MAX_FINDINGS] + findings.append(Finding("finding_limit")) + for item in findings: + print(describe(item), file=sys.stderr) + if findings: + print(f"staged guard: blocked {len(findings)} finding(s)", file=sys.stderr) + return 1 + print(f"staged guard: checked {len(paths)} staged path(s)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..a34a397 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(git rev-parse --show-toplevel)" +REPORT_DIR="${ROOT}/.agentctl/reports" +REPORT="${REPORT_DIR}/pre-commit.sarif" +SECRET_GUARD="${ROOT}/.githooks/check_staged_secrets.py" + +if [[ "${AGENTCTL_ALLOW_FAILURE:-0}" == "1" ]]; then + BRANCH="$(git symbolic-ref --quiet --short HEAD || true)" + if [[ "${CI:-}" == "true" || "${CI:-}" == "1" || "${GITHUB_ACTIONS:-}" == "true" || "${GITHUB_REF_PROTECTED:-}" == "true" || "$BRANCH" == "main" || "$BRANCH" == "master" ]]; then + echo "AGENTCTL_ALLOW_FAILURE is forbidden in CI or protected branches" >&2 + exit 1 + fi + if [[ -z "${AGENTCTL_BYPASS_REASON:-}" ]]; then + echo "Set AGENTCTL_BYPASS_REASON to use local bypass" >&2 + exit 1 + fi + if [[ ! -t 0 && ! -t 1 && ! -t 2 ]]; then + echo "AGENTCTL_ALLOW_FAILURE requires an interactive local terminal" >&2 + exit 1 + fi + echo "WARNING: agentctl pre-commit scan bypassed; reason supplied locally" >&2 + exit 0 +fi + +AGENTCTL="${AGENTCTL_BIN:-}" +if [[ -z "$AGENTCTL" && -x "${ROOT}/.agentctl/bin/agentctl" ]]; then + AGENTCTL="${ROOT}/.agentctl/bin/agentctl" +fi +if [[ -z "$AGENTCTL" ]]; then + AGENTCTL="agentctl" +fi + +if [[ "$AGENTCTL" == */* ]]; then + if [[ ! -x "$AGENTCTL" ]]; then + echo "agentctl is not executable: $AGENTCTL" >&2 + echo "Build .agentctl/bin/agentctl or set AGENTCTL_BIN=/path/to/agentctl" >&2 + exit 1 + fi +elif ! command -v "$AGENTCTL" >/dev/null 2>&1; then + echo "agentctl is not installed or is not in PATH" >&2 + echo "Build .agentctl/bin/agentctl or set AGENTCTL_BIN=/path/to/agentctl" >&2 + exit 1 +fi + +if ! AGENTCTL_VERSION="$("$AGENTCTL" version 2>/dev/null)"; then + echo "agentctl version check failed" >&2 + exit 1 +fi +if [[ "$AGENTCTL_VERSION" != agentctl\ * ]]; then + echo "unexpected agentctl version output" >&2 + exit 1 +fi +if [[ -n "${AGENTCTL_EXPECTED_VERSION:-}" && "$AGENTCTL_VERSION" != "agentctl ${AGENTCTL_EXPECTED_VERSION}" ]]; then + echo "agentctl version does not match AGENTCTL_EXPECTED_VERSION" >&2 + exit 1 +fi + +if [[ ! -f "$SECRET_GUARD" ]]; then + echo "staged secret guard is missing: $SECRET_GUARD" >&2 + exit 1 +fi + +python3 "$SECRET_GUARD" --root "$ROOT" + +if [[ -f "${ROOT}/.agentctl/config.yaml" ]]; then + python3 "$SECRET_GUARD" --root "$ROOT" --check-config "${ROOT}/.agentctl/config.yaml" +fi + +umask 077 +mkdir -p "$REPORT_DIR" +TMP_REPORT="$(mktemp "${REPORT_DIR}/.pre-commit.XXXXXX.sarif")" +FINAL_TMP="${REPORT}.tmp.$$" +trap 'rm -f -- "$TMP_REPORT" "$FINAL_TMP"' EXIT +cd "$ROOT" + +set +e +"$AGENTCTL" scan "$ROOT" \ + --format sarif \ + --fail-on high \ + --output "$TMP_REPORT" +scan_status=$? +set -e + +if [[ ! -s "$TMP_REPORT" ]]; then + echo "agentctl did not produce a SARIF report" >&2 + exit 1 +fi + +set +e +python3 - "$TMP_REPORT" <<'PY' +import json +import sys + +path = sys.argv[1] +try: + with open(path, encoding="utf-8") as handle: + sarif = json.load(handle) +except (OSError, json.JSONDecodeError) as error: + print(f"agentctl produced an unreadable SARIF report: {error}", file=sys.stderr) + sys.exit(1) + +blocking = {"error", "critical", "high"} +violations = [] +for run in sarif.get("runs", []): + for result in run.get("results", []): + level = str(result.get("level", "warning")).lower() + if level in blocking: + violations.append((result.get("ruleId", "unknown"), level)) + +if violations: + print("agentctl blocked the commit:", file=sys.stderr) + for rule, level in violations: + print(f" - {rule}: {level}", file=sys.stderr) + sys.exit(1) + +print("agentctl: no blocking findings") +PY +gate_status=$? +set -e + +if [[ ! -s "$TMP_REPORT" ]]; then + echo "agentctl did not produce a SARIF report" >&2 + exit 1 +fi +install -m 600 "$TMP_REPORT" "$FINAL_TMP" +mv -f -- "$FINAL_TMP" "$REPORT" + +if [[ "$gate_status" -ne 0 ]]; then + exit "$gate_status" +fi +if [[ "$scan_status" -ne 0 ]]; then + echo "agentctl scan failed without a blocking SARIF finding" >&2 + exit "$scan_status" +fi diff --git a/.githooks/test_check_staged_secrets.py b/.githooks/test_check_staged_secrets.py new file mode 100644 index 0000000..2db8bb0 --- /dev/null +++ b/.githooks/test_check_staged_secrets.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 + +import importlib.util +import sys +import unittest +from pathlib import Path + + +MODULE_PATH = Path(__file__).with_name("check_staged_secrets.py") +SPEC = importlib.util.spec_from_file_location("check_staged_secrets", MODULE_PATH) +MODULE = importlib.util.module_from_spec(SPEC) +assert SPEC and SPEC.loader +sys.modules[SPEC.name] = MODULE +SPEC.loader.exec_module(MODULE) + + +class StagedSecretGuardTests(unittest.TestCase): + def test_provider_patterns_are_redacted_to_metadata(self): + provider_prefix = "ghp_" + provider_suffix = "123456789012345678901234567890" + token_key = "tok" + "en:" + findings = MODULE.scan_added_line("config.yaml", f'{token_key} "{provider_prefix}{provider_suffix}"', 4) + self.assertTrue({item.kind for item in findings} >= {"credential_assignment", "github_token"}) + self.assertTrue(all(item.fingerprint.startswith("sha256:") for item in findings)) + + def test_synthetic_markers_are_allowlisted(self): + findings = MODULE.scan_added_line("testdata/fixture.yaml", 'token: "ghp_SYNTHETIC-NOT-REAL"', 2) + self.assertEqual(findings, []) + provider_prefix = "ghp_" + provider_suffix = "123456789012345678901234567890" + token_key = "tok" + "en:" + findings = MODULE.scan_added_line("config.yaml", f'{token_key} "{provider_prefix}{provider_suffix}" # synthetic fixture', 2) + self.assertTrue(findings) + + def test_url_unicode_and_split_variants_are_normalized(self): + encoded_suffix = "%31%32%33%34%35%36%37%38%39%30%31%32%33%34%35%36%37%38%39%30" + token_key = "tok" + "en:" + findings = MODULE.scan_added_line("config.yaml", f'{token_key} "ghp_{encoded_suffix}"', 1) + self.assertTrue(any(item.kind == "credential_assignment" for item in findings)) + + def test_entropy_requires_sensitive_context(self): + self.assertEqual(MODULE.scan_added_line("main.go", 'value = "v3ry-random-but-not-a-secret-value"', 1), []) + entropy_value = "q7H2mN8pR4xT9vK3zL6cW1sY5dF8gJ2" + token_key = "tok" + "en = " + findings = MODULE.scan_added_line("main.go", f'{token_key}"{entropy_value}"', 1) + self.assertTrue(any(item.kind == "high_entropy_secret_context" for item in findings)) + + def test_sensitive_filename_and_example_exception(self): + self.assertTrue(MODULE.sensitive_filename("secrets/service-account.json")) + self.assertTrue(MODULE.sensitive_filename(".env.production")) + self.assertFalse(MODULE.sensitive_filename(".env.example")) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/agentctl-pr.yml b/.github/workflows/agentctl-pr.yml new file mode 100644 index 0000000..01f0338 --- /dev/null +++ b/.github/workflows/agentctl-pr.yml @@ -0,0 +1,73 @@ +name: Agent Control Plane scan + +on: + pull_request: + types: [opened, synchronize, reopened] + workflow_dispatch: + +permissions: + contents: read + security-events: write + +concurrency: + group: agentctl-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + agentctl: + name: Scan AI agents and MCP metadata + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: false + + - name: Build agentctl + run: go build -trimpath -o "$RUNNER_TEMP/agentctl" ./cmd/agentctl + + - name: Check workspace policy + run: | + if [ -f .agentctl/config.yaml ]; then + echo "Using existing .agentctl/config.yaml" + else + echo "No workspace policy found; scanner defaults will be used" + fi + + - name: Run Agent Control Plane scan + run: | + "$RUNNER_TEMP/agentctl" scan . \ + --format sarif \ + --fail-on high \ + --output agentctl.sarif + + - name: Upload SARIF to GitHub Code Scanning + if: ${{ always() && hashFiles('agentctl.sarif') != '' }} + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: agentctl.sarif + category: agent-control-plane + + - name: Print human-readable report + if: ${{ always() }} + run: | + "$RUNNER_TEMP/agentctl" scan . \ + --format text \ + --fail-on none || true + + - name: Upload scan report on failure + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: agentctl-report-${{ github.run_id }} + retention-days: 14 + if-no-files-found: ignore + path: agentctl.sarif diff --git a/.gitignore b/.gitignore index 1ec7380..79b0269 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ coverage/ .venv/ __pycache__/ *.py[cod] +.agentctl/bin/ +.agentctl/reports/ # Local project planning, research and preparation artifacts. # These files remain available in the workspace but are not published to GitHub. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 976df08..2e260a8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,6 +20,24 @@ go run ./cmd/agentctl scan ./testdata/demo --format text go run ./cmd/agentctl scan ./testdata/demo --format sarif --output /tmp/agentctl.sarif ``` +## Optional local pre-commit scan + +The repository includes a fail-closed read-only hook. It checks staged paths and added diff lines for secret-like material, then runs the full local `agentctl` inventory gate. It never executes scanned content. Enable it for this checkout with: + +```bash +mkdir -p .agentctl/bin +go build -trimpath -o .agentctl/bin/agentctl ./cmd/agentctl +git config core.hooksPath .githooks +``` + +The hook writes its SARIF report atomically with mode `0600` to the ignored `.agentctl/reports/` directory. It uses `.agentctl/bin/agentctl` when present, otherwise `agentctl` from `PATH`; set `AGENTCTL_BIN` to override the binary path. The staged guard blocks sensitive filenames, symlinks, unsafe paths, binary content that cannot be inspected locally, provider token patterns and high-entropy values near secret-like keys. It applies bounded Unicode/URL/escape normalization without executing content. + +The hook verifies that the selected binary supports the expected `agentctl version` interface. Set `AGENTCTL_EXPECTED_VERSION` when a checkout requires an exact version pin. + +Use `AGENTCTL_ALLOW_FAILURE=1 AGENTCTL_BYPASS_REASON='local emergency' git commit ...` only for an interactive local emergency. The bypass is rejected when `CI`, `GITHUB_ACTIONS`, `GITHUB_REF_PROTECTED`, `main` or `master` is detected. The reason is required but is not written to the repository or report. + +Copy [.agentctl/config.example.yaml](.agentctl/config.example.yaml) to `.agentctl/config.yaml` when a workspace needs explicit exclusions, approved providers, approved MCP servers or freshness policy. Keep credentials, tokens, private URLs and raw payloads out of the policy file. + ## Scanner changes - Keep discovery read-only and metadata-only. diff --git a/README.md b/README.md index d07e8be..1373110 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ The CLI scans an approved local directory in read-only mode and produces text or - SARIF 2.1.0 output for GitHub Code Scanning and other security tooling; - safe `.mcp.json` and `server.json` metadata such as server name, transport and auth method. -The default policy excludes common non-production paths such as tests, examples, samples, tutorials, fixtures, documentation, schemas and framework library layouts. Use `.agentctl/config.yaml` to add workspace-specific exclusions and approved providers or MCP servers. `agentctl init` never overwrites an existing policy. +The default policy excludes common non-production paths such as tests, examples, samples, tutorials, fixtures, documentation and schemas. Agent definitions under tooling directories such as `.claude` and `.github`, Markdown agent files, and framework library layouts remain scannable when they contain strong agent signals. Use `.agentctl/config.yaml` to add workspace-specific exclusions and approved providers or MCP servers. `agentctl init` never overwrites an existing policy. ## Risk rules @@ -70,7 +70,7 @@ The alpha includes ten explainable rules: | Rule | Detects | |---|---| -| `ACP-001` | Missing agent owner/team | +| `ACP-001` | Missing agent owner/team (informational unless production is explicit) | | `ACP-002` | Runtime agent without source inventory | | `ACP-003` | Shared identity across unrelated agents | | `ACP-004` | Write/admin capability in a read-only use case | @@ -91,6 +91,7 @@ The alpha includes ten explainable rules: - no network calls in the local scan path; - deterministic findings; no LLM in the critical risk-decision path; - bounded file size, file count and total scan input. +- local pre-commit protection checks staged diff additions, sensitive filenames, symlinks and secret-like patterns; CI remains authoritative for full history and artifact checks. ## Current limitations @@ -114,6 +115,8 @@ SARIF is available now. Use `--fail-on high` or `--fail-on critical` to make fin sarif_file: agentctl.sarif ``` +Pull requests in this repository also run `.github/workflows/agentctl-pr.yml`, which builds the local CLI, uploads SARIF to GitHub Code Scanning, and blocks High/Critical findings through the CLI exit code. + ## Development Requirements: Go 1.26 or newer. diff --git a/internal/config/config.go b/internal/config/config.go index a4acea9..dde4b95 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -27,7 +27,7 @@ func Default(workspaceRoot string) Config { Version: "1", WorkspaceRoot: workspaceRoot, FreshnessDays: 30, - Exclude: []string{".agentctl", ".claude", ".github", ".git", ".hg", ".svn", ".storybook", "__tests__", "e2e", "test-servers", "node_modules", "vendor", "dist", "build", ".venv", "__pycache__", "examples", "example", "samples", "sample", "demos", "demo", "tutorials", "tutorial", "docs_src", "tests", "test", "testdata", "fixtures", "benchmarks", "docs", "doc", "schemas", "schema"}, + Exclude: []string{".agentctl", ".git", ".hg", ".svn", ".storybook", "__tests__", "e2e", "test-servers", "node_modules", "vendor", "dist", "build", ".venv", "__pycache__", "examples", "example", "samples", "sample", "demos", "demo", "tutorials", "tutorial", "docs_src", "tests", "test", "testdata", "fixtures", "benchmarks", "docs", "doc", "schemas", "schema"}, ApprovedOwners: []string{}, ApprovedProviders: []string{}, ApprovedMCPServers: []string{}, diff --git a/internal/scan/scan.go b/internal/scan/scan.go index d62b1ee..e1d5771 100644 --- a/internal/scan/scan.go +++ b/internal/scan/scan.go @@ -164,9 +164,7 @@ func Run(root string, options Options) (Report, error) { report.FilesSkipped++ return nil } - if found != nil { - candidates = append(candidates, *found) - } + candidates = append(candidates, found...) return nil }) if err != nil { @@ -199,6 +197,8 @@ func Run(root string, options Options) (Report, error) { identityAgents := map[string][]Agent{} agentItems := map[string]candidate{} + mcpEvidence := map[string]Evidence{} + sourceSeen := map[string]bool{} for _, item := range candidates { sourceType, trustLevel := "repository_file", "observed" if item.kind == "runtime" { @@ -208,9 +208,12 @@ func Run(root string, options Options) (Report, error) { } else if item.kind == "registry" { sourceType, trustLevel = "policy_registry", "declared" } - report.Sources = append(report.Sources, Source{ - ID: stableID("source", item.path), Type: sourceType, Path: item.path, TrustLevel: trustLevel, - }) + if !sourceSeen[item.path] { + report.Sources = append(report.Sources, Source{ + ID: stableID("source", item.path), Type: sourceType, Path: item.path, TrustLevel: trustLevel, + }) + sourceSeen[item.path] = true + } } for _, item := range candidates { @@ -218,6 +221,7 @@ func Run(root string, options Options) (Report, error) { if !containsMCPServer(report.MCPServers, item.mcpServer) { report.MCPServers = append(report.MCPServers, MCPServer{ID: stableID("mcp", strings.ToLower(item.mcpServer)), Name: item.mcpServer, Approved: approvedServers[strings.ToLower(item.mcpServer)], Transport: item.mcpTransport, AuthMethod: item.mcpAuthMethod, Tools: item.mcpTools, SourcePath: item.path}) } + mcpEvidence[strings.ToLower(item.mcpServer)] = Evidence{Path: item.path, Line: item.mcpLine} continue } if item.kind == "registry" { @@ -286,6 +290,9 @@ func Run(root string, options Options) (Report, error) { report.MCPServers = append(report.MCPServers, MCPServer{ID: stableID("mcp", strings.ToLower(item.mcpServer)), Name: item.mcpServer, Approved: approvedServers[strings.ToLower(item.mcpServer)], Transport: item.mcpTransport, AuthMethod: item.mcpAuthMethod, Tools: item.mcpTools, SourcePath: item.path}) } if item.mcpServer != "" { + if _, exists := mcpEvidence[strings.ToLower(item.mcpServer)]; !exists { + mcpEvidence[strings.ToLower(item.mcpServer)] = Evidence{Path: item.path, Line: item.mcpLine} + } report.Relationships = append(report.Relationships, Relationship{ ID: stableID("relationship", agentID+":connects-to:"+item.mcpServer), FromType: "agent", FromID: agentID, EdgeType: "CONNECTS_TO", ToType: "mcp_server", ToID: stableID("mcp", strings.ToLower(item.mcpServer)), @@ -293,7 +300,7 @@ func Run(root string, options Options) (Report, error) { }) } if item.owner == "" { - severity := "Medium" + severity := "Note" if item.environment == "production" { severity = "High" } @@ -320,18 +327,6 @@ func Run(root string, options Options) (Report, error) { RemediationHint: "Remove the write scope or document an explicit approved exception.", }) } - if item.mcpServer != "" && !approvedServers[strings.ToLower(item.mcpServer)] { - report.Findings = append(report.Findings, Finding{ - ID: stableID("finding", agentID+":ACP-005"), - RuleID: "ACP-005", - Severity: "High", - Message: "MCP server is not present in the approved registry", - AgentID: agentID, - Confidence: 0.85, - Evidence: []Evidence{{Path: item.path, Line: item.mcpLine}}, - RemediationHint: "Review the server provenance and add it to the approved registry only after ownership and permission review.", - }) - } if item.productionCredential && strings.EqualFold(item.environment, "development") { report.Findings = append(report.Findings, Finding{ ID: stableID("finding", agentID+":ACP-006"), RuleID: "ACP-006", Severity: "Critical", @@ -378,6 +373,23 @@ func Run(root string, options Options) (Report, error) { }) } } + for _, server := range report.MCPServers { + if server.Approved { + continue + } + evidence := mcpEvidence[strings.ToLower(server.Name)] + mcpID := stableID("mcp", strings.ToLower(server.Name)) + report.Findings = append(report.Findings, Finding{ + ID: stableID("finding", mcpID+":ACP-005"), + RuleID: "ACP-005", + Severity: "High", + Message: "MCP server is not present in the approved registry", + AgentID: mcpID, + Confidence: 0.85, + Evidence: []Evidence{evidence}, + RemediationHint: "Review the server provenance and add it to the approved registry only after ownership and permission review.", + }) + } for identity, agents := range identityAgents { if len(agents) < 2 { @@ -401,12 +413,12 @@ func Run(root string, options Options) (Report, error) { return report, nil } -func inspectFile(path, relative string) (*candidate, error) { +func inspectFile(path, relative string) ([]candidate, error) { if ignoredSourceFile(relative) { return nil, nil } - if candidate, err := inspectJSONMCPMetadata(path, relative); err != nil || candidate != nil { - return candidate, err + if candidates, err := inspectJSONMCPMetadata(path, relative); err != nil || candidates != nil { + return candidates, err } file, err := os.Open(path) if err != nil { @@ -430,15 +442,9 @@ func inspectFile(path, relative string) (*candidate, error) { firstSignal := 0 frameworkSignal := false modelDeclarationSignal := false - scannerImplementation := false for scanner.Scan() { lineNumber++ line := scanner.Text() - if strings.Contains(line, "regexp.MustCompile") { - // Do not let the scanner's own detection vocabulary become inventory. - scannerImplementation = true - continue - } if productionCredentialPattern.MatchString(line) { productionCredential = true if credentialLine == 0 { @@ -554,9 +560,6 @@ func inspectFile(path, relative string) (*candidate, error) { if err := scanner.Err(); err != nil { return nil, err } - if scannerImplementation { - return nil, nil - } if firstSignal == 0 && identity == "" && mcpServer == "" && len(approvedServers) == 0 && len(approvedProviders) == 0 && !runtimeMetadataPath(relative) { return nil, nil } @@ -575,7 +578,7 @@ func inspectFile(path, relative string) (*candidate, error) { if firstSignal == 0 { firstSignal = 1 } - return &candidate{ + return []candidate{{ path: relative, line: firstSignal, name: name, kind: kind, models: models, tools: tools, identity: identity, identityLine: identityLine, mcpServer: mcpServer, mcpLine: mcpLine, mcpTransport: mcpTransport, mcpAuthMethod: mcpAuthMethod, mcpTools: mcpTools, @@ -584,10 +587,10 @@ func inspectFile(path, relative string) (*candidate, error) { modelLine: modelLine, productionCredential: productionCredential, credentialLine: credentialLine, sensitiveTool: sensitiveTool, sensitiveLine: sensitiveLine, approvalMetadata: approvalMetadata, disablePath: disablePath, disableLine: disableLine, verifiedAt: verifiedAt, verifiedLine: verifiedLine, - }, nil + }}, nil } -func inspectJSONMCPMetadata(path, relative string) (*candidate, error) { +func inspectJSONMCPMetadata(path, relative string) ([]candidate, error) { base := strings.ToLower(filepath.Base(relative)) if base != ".mcp.json" && base != "server.json" { return nil, nil @@ -610,17 +613,21 @@ func inspectJSONMCPMetadata(path, relative string) (*candidate, error) { names = append(names, name) } sort.Strings(names) - name := names[0] - entry, _ := servers[name].(map[string]any) - transport := stringValue(entry["type"]) - if transport == "" && stringValue(entry["url"]) != "" { - transport = "http" + candidates := make([]candidate, 0, len(names)) + for _, name := range names { + entry, _ := servers[name].(map[string]any) + transport := stringValue(entry["type"]) + if transport == "" && stringValue(entry["url"]) != "" { + transport = "http" + } + line := lineForValue(data, name) + candidates = append(candidates, candidate{ + path: relative, line: line, name: name, kind: "mcp", + mcpServer: name, mcpLine: line, mcpTransport: transport, + mcpAuthMethod: jsonAuthMethod(entry), + }) } - return &candidate{ - path: relative, line: lineForValue(data, name), name: name, kind: "mcp", - mcpServer: name, mcpLine: lineForValue(data, name), mcpTransport: transport, - mcpAuthMethod: jsonAuthMethod(entry), - }, nil + return candidates, nil } name := stringValue(document["name"]) @@ -643,11 +650,11 @@ func inspectJSONMCPMetadata(path, relative string) (*candidate, error) { transport = stringValue(remote["type"]) } } - return &candidate{ + return []candidate{{ path: relative, line: lineForValue(data, name), name: name, kind: "mcp", mcpServer: name, mcpLine: lineForValue(data, name), mcpTransport: transport, mcpAuthMethod: jsonManifestAuthMethod(document), - }, nil + }}, nil } func stringValue(value any) string { @@ -700,7 +707,7 @@ func supportedFile(path, name string) bool { return true } switch strings.ToLower(filepath.Ext(path)) { - case ".go", ".js", ".jsx", ".ts", ".tsx", ".py", ".rb", ".java", ".rs", ".yaml", ".yml", ".json", ".toml": + case ".go", ".js", ".jsx", ".ts", ".tsx", ".py", ".rb", ".java", ".rs", ".yaml", ".yml", ".json", ".toml", ".md": return true default: return false @@ -709,7 +716,7 @@ func supportedFile(path, name string) bool { func ignoredDirectory(name string) bool { switch name { - case ".agentctl", ".claude", ".github", ".git", ".hg", ".svn", ".storybook", "__tests__", "e2e", "test-servers", "node_modules", "vendor", "dist", "build", ".venv", "__pycache__", "examples", "example", "samples", "sample", "demos", "demo", "tutorials", "tutorial", "docs_src", "tests", "test", "testdata", "fixtures", "benchmarks", "docs", "doc", "schemas", "schema": + case ".agentctl", ".git", ".hg", ".svn", ".storybook", "__tests__", "e2e", "test-servers", "node_modules", "vendor", "dist", "build", ".venv", "__pycache__", "examples", "example", "samples", "sample", "demos", "demo", "tutorials", "tutorial", "docs_src", "tests", "test", "testdata", "fixtures", "benchmarks", "docs", "doc", "schemas", "schema": return true default: return false @@ -734,7 +741,7 @@ func runtimeMetadataPath(relative string) bool { } func isLikelyAgentCandidate(relative, name string, nameExplicit, modelDeclarationSignal, frameworkSignal bool, models []string, identity, mcpServer string, environmentExplicit bool) bool { - if isLibrarySourcePath(relative) && !environmentExplicit { + if isGitHubWorkflowPath(relative) && identity == "" && mcpServer == "" && !environmentExplicit && len(models) == 0 { return false } if nameExplicit && isAgentPath(relative) && isAgentName(name) { @@ -749,6 +756,16 @@ func isLikelyAgentCandidate(relative, name string, nameExplicit, modelDeclaratio return len(models) > 0 && (modelDeclarationSignal || frameworkSignal) } +func isGitHubWorkflowPath(relative string) bool { + parts := strings.Split(strings.ToLower(filepath.ToSlash(relative)), "/") + for index := 0; index+1 < len(parts); index++ { + if parts[index] == ".github" && parts[index+1] == "workflows" { + return true + } + } + return false +} + func staticNameDeclaration(line string) bool { delimiter := ":" parts := strings.SplitN(line, delimiter, 2) @@ -769,16 +786,6 @@ func staticNameDeclaration(line string) bool { return staticNamePattern.MatchString(strings.Trim(right, "`\"'")) } -func isLibrarySourcePath(relative string) bool { - for _, part := range strings.Split(strings.ToLower(filepath.ToSlash(relative)), "/") { - switch part { - case "lib", "libs", "packages": - return true - } - } - return false -} - func isAgentEntryFile(relative string) bool { base := strings.TrimSuffix(strings.ToLower(filepath.Base(relative)), filepath.Ext(relative)) for _, marker := range []string{"agent", "assistant", "copilot", "chatbot", "bot", "worker", "runner"} { @@ -835,7 +842,11 @@ func declarationValue(line string, pattern *regexp.Regexp) string { if len(parts) != 2 { return "unknown" } - value := strings.TrimSpace(strings.Trim(parts[1], "`\"'")) + value := strings.TrimSpace(parts[1]) + value = strings.TrimRight(value, ",;") + value = strings.TrimSpace(strings.Trim(value, "`\"'")) + value = strings.TrimRight(value, ",;") + value = strings.TrimSpace(value) if value == "" || secretPattern.MatchString(value) { return "unknown" } diff --git a/internal/scan/scan_test.go b/internal/scan/scan_test.go index e7a48d0..48e6d0f 100644 --- a/internal/scan/scan_test.go +++ b/internal/scan/scan_test.go @@ -65,7 +65,7 @@ func TestRunDryRunDoesNotParseContent(t *testing.T) { } } -func TestRunIgnoresDocumentationFiles(t *testing.T) { +func TestRunDoesNotInventoryRootDocumentation(t *testing.T) { root := t.TempDir() if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("production agent uses MCP and OpenAI"), 0o600); err != nil { t.Fatal(err) @@ -74,8 +74,8 @@ func TestRunIgnoresDocumentationFiles(t *testing.T) { if err != nil { t.Fatal(err) } - if report.FilesScanned != 0 || len(report.Agents) != 0 || len(report.Findings) != 0 { - t.Fatalf("documentation should not be inventoried: %+v", report) + if report.FilesScanned != 1 || len(report.Agents) != 0 || len(report.Findings) != 0 { + t.Fatalf("root documentation should be scanned without inventorying an agent: %+v", report) } } @@ -153,7 +153,7 @@ func TestRunIgnoresFixturesAndRuntimeImplementationCode(t *testing.T) { } } -func TestRunIgnoresFrameworkLibraryInternalsAndSamples(t *testing.T) { +func TestRunDetectsFrameworkLibraryAgentsAndIgnoresSamples(t *testing.T) { root := t.TempDir() files := map[string]string{ "lib/framework/agents/base.py": "from crewai import Agent\nmodel = \"openai/gpt-4o\"\n", @@ -175,8 +175,81 @@ func TestRunIgnoresFrameworkLibraryInternalsAndSamples(t *testing.T) { if err != nil { t.Fatal(err) } + if len(report.Agents) != 1 || report.Agents[0].SourcePath != "packages/framework/agents/openai_assistant_agent.py" { + t.Fatalf("expected the concrete framework agent entry to be inventoried: %+v", report.Agents) + } + if len(report.Findings) != 1 || report.Findings[0].RuleID != "ACP-001" || report.Findings[0].Severity != "Note" { + t.Fatalf("expected only an informational owner gap: %+v", report.Findings) + } +} + +func TestRunCollectsAllMCPServersAndAppliesApprovalRule(t *testing.T) { + root := t.TempDir() + clientConfig := `{"mcpServers":{"docs":{"type":"http","url":"https://example.test/mcp"},"unknown":{"command":"npx","args":["-y","untrusted-server"]}}}` + if err := os.WriteFile(filepath.Join(root, ".mcp.json"), []byte(clientConfig), 0o600); err != nil { + t.Fatal(err) + } + policyPath := filepath.Join(root, ".agentctl", "config.yaml") + policy := config.Default(".") + policy.ApprovedMCPServers = []string{"docs"} + if err := config.WriteDefault(policyPath, policy); err != nil { + t.Fatal(err) + } + report, err := Run(root, Options{ConfigPath: policyPath}) + if err != nil { + t.Fatal(err) + } + if len(report.MCPServers) != 2 || len(report.Sources) != 1 { + t.Fatalf("expected both MCP servers and one deduplicated source, got servers=%+v sources=%+v", report.MCPServers, report.Sources) + } + if len(report.Findings) != 1 || report.Findings[0].RuleID != "ACP-005" { + t.Fatalf("expected one unapproved MCP finding, got %+v", report.Findings) + } + if report.Findings[0].Evidence[0].Path != ".mcp.json" || report.Findings[0].Evidence[0].Line < 1 { + t.Fatalf("expected MCP source evidence, got %+v", report.Findings[0].Evidence) + } +} + +func TestRunDiscoversAgentMarkdownUnderToolingDirectories(t *testing.T) { + root := t.TempDir() + files := map[string]string{ + ".claude/agents/support-agent.md": "name: support-agent\nmodel: openai\nowner: platform\n", + ".github/agents/release-agent.md": "name: release-agent\nmodel: openai\nowner: release\n", + } + for relative, content := range files { + path := filepath.Join(root, filepath.FromSlash(relative)) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } + report, err := Run(root, Options{}) + if err != nil { + t.Fatal(err) + } + if len(report.Agents) != 2 || len(report.Findings) != 0 { + t.Fatalf("expected two owned Markdown agents without findings: agents=%+v findings=%+v", report.Agents, report.Findings) + } +} + +func TestRunDoesNotInventoryGenericGitHubWorkflow(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, ".github", "workflows", "agentctl-pr.yml") + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + content := "name: Agent Control Plane pull request scan\non: [pull_request]\njobs:\n scan:\n runs-on: ubuntu-latest\n" + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + report, err := Run(root, Options{}) + if err != nil { + t.Fatal(err) + } if len(report.Agents) != 0 || len(report.Findings) != 0 { - t.Fatalf("framework internals or samples were inventoried: %+v", report) + t.Fatalf("generic GitHub workflow was inventoried as an agent: %+v", report) } } @@ -207,11 +280,20 @@ func TestRunRegressionFixtures(t *testing.T) { if err != nil { t.Fatal(err) } - if len(report.Agents) != 1 || report.Agents[0].Name != "Ticket Triage Agent" { + if len(report.Agents) != 2 { t.Fatalf("unexpected regression inventory: %+v", report.Agents) } - if len(report.Findings) != 0 { - t.Fatalf("regression fixtures produced unexpected findings: %+v", report.Findings) + foundRegistryAgent := false + for _, agent := range report.Agents { + if agent.Name == "Ticket Triage Agent" && agent.SourcePath == "registry/agents/ticket-triage-agent.yaml" { + foundRegistryAgent = true + } + } + if !foundRegistryAgent { + t.Fatalf("declarative registry agent was not preserved: %+v", report.Agents) + } + if len(report.Findings) != 1 || report.Findings[0].RuleID != "ACP-001" || report.Findings[0].Severity != "Note" { + t.Fatalf("expected only an informational owner gap for the library agent: %+v", report.Findings) } } diff --git a/testdata/adversarial/manifest.yaml b/testdata/adversarial/manifest.yaml index 9b39209..361bbdb 100644 --- a/testdata/adversarial/manifest.yaml +++ b/testdata/adversarial/manifest.yaml @@ -6,7 +6,7 @@ "root": "testdata/demo", "expected": { "exit_code": 0, - "rule_counts": {"ACP-003": 2, "ACP-005": 2, "ACP-006": 1}, + "rule_counts": {"ACP-003": 2, "ACP-005": 1, "ACP-006": 1}, "min_agents": 3, "min_sources": 6 }, @@ -15,13 +15,13 @@ }, { "id": "ADV-REGRESSION-001", - "description": "Framework internals and examples are ignored while a declarative registry agent remains visible", + "description": "Framework agent entries and examples are separated while a declarative registry agent remains visible", "root": "testdata/regression", "expected": { "exit_code": 0, "rule_counts": {}, - "min_agents": 1, - "min_sources": 1 + "min_agents": 2, + "min_sources": 2 }, "security_invariants": ["deterministic"], "formats": ["json", "sarif"]