Skip to content

Commit 7d2ff5b

Browse files
linus131313claude
andcommitted
Add integrations: GitHub Action, pre-commit, Claude Code hook
Three adoption paths for the attestation + policy engine: - action.yml: composite GitHub Action that attests on PR, signs with GitHub OIDC keyless sigstore, posts the policy report to the job summary, and fails the job when the policy blocks - .pre-commit-hooks.yaml + integrations.pre_commit: refuses commits to MCP config files when the repo policy blocks - integrations.claude_code_hook: PreToolUse JSON hook for Claude Code that denies tool calls absent from the current attestation or exceeding the B4 privilege threshold - New entry points: mcp-gov-pre-commit, mcp-gov-hook 54/54 tests green, coverage 81%, mypy strict + ruff clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f728e47 commit 7d2ff5b

7 files changed

Lines changed: 349 additions & 0 deletions

File tree

.pre-commit-hooks.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
- id: mcp-governance-check
2+
name: MCP governance policy check
3+
description: >-
4+
Refuse the commit if the MCP configuration violates the active
5+
policy. See github.com/linus131313/mcp-governance-kit.
6+
entry: mcp-gov-pre-commit
7+
language: python
8+
additional_dependencies: ["mcp-governance-kit"]
9+
files: '(\.mcp\.json|claude_desktop_config\.json|\.cursor/mcp\.json)$'
10+
pass_filenames: true

action.yml

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
name: "MCP Governance Check"
2+
description: "Attest your MCP configuration and enforce a policy on every PR."
3+
branding:
4+
icon: "shield"
5+
color: "blue"
6+
7+
inputs:
8+
config-path:
9+
description: "Path to the MCP config file (e.g. .mcp.json)."
10+
required: true
11+
default: ".mcp.json"
12+
policy-path:
13+
description: "Path to a policy YAML file."
14+
required: true
15+
default: "policies/default.yaml"
16+
host-id:
17+
description: "Stable host identifier recorded in the attestation."
18+
required: false
19+
default: "${{ github.repository }}"
20+
fail-on-warn:
21+
description: "If 'true', WARN results also fail the job."
22+
required: false
23+
default: "false"
24+
sign:
25+
description: "Sign the attestation with sigstore keyless (GitHub OIDC)."
26+
required: false
27+
default: "true"
28+
29+
outputs:
30+
attestation-path:
31+
description: "Path to the produced attestation JSON."
32+
value: ${{ steps.run.outputs.attestation-path }}
33+
tcs:
34+
description: "Tool Graph Capability Score of the attestation."
35+
value: ${{ steps.run.outputs.tcs }}
36+
37+
runs:
38+
using: "composite"
39+
steps:
40+
- name: Setup Python
41+
uses: actions/setup-python@v5
42+
with:
43+
python-version: "3.12"
44+
45+
- name: Install mcp-governance-kit
46+
shell: bash
47+
run: |
48+
python -m pip install --upgrade pip
49+
if [[ "${{ inputs.sign }}" == "true" ]]; then
50+
pip install "mcp-governance-kit[sign]"
51+
else
52+
pip install mcp-governance-kit
53+
fi
54+
55+
- name: Attest and check
56+
id: run
57+
shell: bash
58+
env:
59+
FAIL_ON_WARN: ${{ inputs.fail-on-warn }}
60+
run: |
61+
set -euo pipefail
62+
ATTEST_PATH="${RUNNER_TEMP}/attestation.json"
63+
SIGN_FLAG=""
64+
if [[ "${{ inputs.sign }}" == "true" ]]; then
65+
SIGN_FLAG="--sign"
66+
fi
67+
mcp-gov attest "${{ inputs.config-path }}" \
68+
--host-id "${{ inputs.host-id }}" \
69+
--out "$ATTEST_PATH" \
70+
$SIGN_FLAG
71+
TCS_VALUE=$(python -c "import json; print(json.load(open('$ATTEST_PATH'))['tcs']['value'])")
72+
echo "attestation-path=$ATTEST_PATH" >> "$GITHUB_OUTPUT"
73+
echo "tcs=$TCS_VALUE" >> "$GITHUB_OUTPUT"
74+
75+
set +e
76+
REPORT=$(mcp-gov check "$ATTEST_PATH" --policy "${{ inputs.policy-path }}")
77+
RC=$?
78+
set -e
79+
echo "$REPORT"
80+
{
81+
echo "## MCP Governance Report"
82+
echo
83+
echo "**TCS:** $TCS_VALUE"
84+
echo
85+
echo '```json'
86+
echo "$REPORT"
87+
echo '```'
88+
} >> "$GITHUB_STEP_SUMMARY"
89+
90+
if [[ "$RC" -ne 0 ]]; then
91+
echo "::error::policy evaluation BLOCKED"
92+
exit 1
93+
fi
94+
if [[ "$FAIL_ON_WARN" == "true" ]]; then
95+
if echo "$REPORT" | python -c "import sys, json; r=json.load(sys.stdin); sys.exit(0 if r['warnings']==0 else 1)"; then
96+
exit 0
97+
else
98+
echo "::error::policy evaluation produced warnings (fail-on-warn=true)"
99+
exit 1
100+
fi
101+
fi

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ Paper = "https://github.com/linus131313/mcp-governance-kit/blob/main/paper/gover
7171

7272
[project.scripts]
7373
mcp-gov = "mcp_governance_kit.cli:app"
74+
mcp-gov-pre-commit = "mcp_governance_kit.integrations.pre_commit:main"
75+
mcp-gov-hook = "mcp_governance_kit.integrations.claude_code_hook:main"
7476

7577
[tool.hatch.version]
7678
source = "vcs"
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""Integrations: GitHub Action (composite), pre-commit hook, Claude Code hook."""
2+
3+
from __future__ import annotations
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""Claude Code ``PreToolUse`` hook.
2+
3+
Installed as a JSON-based hook in ``~/.claude/settings.json``, this
4+
script reads the pending tool-use event from stdin, looks up the tool
5+
in the current attestation for the workspace, and permits or denies
6+
the call based on the active policy.
7+
8+
Hook wire format (PreToolUse):
9+
10+
{
11+
"tool_name": "<string>",
12+
"tool_input": {...},
13+
"session_id": "<string>",
14+
"transcript_path": "<path>",
15+
"cwd": "<path>"
16+
}
17+
18+
The hook writes a JSON response to stdout with a ``decision`` field
19+
("allow" | "deny") and a short reason. A non-zero exit code converts to
20+
a denial with Claude Code's default behaviour.
21+
"""
22+
23+
from __future__ import annotations
24+
25+
import json
26+
import sys
27+
from pathlib import Path
28+
29+
from mcp_governance_kit.attest import Attestation
30+
from mcp_governance_kit.breakpoints import Severity, b4_privilege
31+
from mcp_governance_kit.policy import Policy
32+
33+
34+
def _load_context(cwd: Path) -> tuple[Attestation | None, Policy | None]:
35+
att_path = cwd / ".mcp-governance" / "attestation.json"
36+
policy_path = cwd / ".mcp-governance" / "policy.yaml"
37+
attestation = (
38+
Attestation.model_validate_json(att_path.read_text(encoding="utf-8"))
39+
if att_path.exists()
40+
else None
41+
)
42+
policy = Policy.load(policy_path) if policy_path.exists() else None
43+
return attestation, policy
44+
45+
46+
def main() -> int:
47+
try:
48+
event = json.load(sys.stdin)
49+
except json.JSONDecodeError:
50+
print(json.dumps({"decision": "allow", "reason": "no event"}))
51+
return 0
52+
53+
cwd = Path(event.get("cwd") or ".")
54+
attestation, policy = _load_context(cwd)
55+
tool_name = event.get("tool_name", "")
56+
57+
if attestation is None or policy is None:
58+
print(json.dumps({"decision": "allow", "reason": "no attestation in workspace"}))
59+
return 0
60+
61+
bound = {t.name for t in attestation.tools}
62+
if tool_name and tool_name not in bound and tool_name.split("__")[-1] not in bound:
63+
print(
64+
json.dumps(
65+
{
66+
"decision": "deny",
67+
"reason": (
68+
f"tool '{tool_name}' not present in current attestation "
69+
f"({attestation.attestation_id}); re-attest with mcp-gov attest"
70+
),
71+
}
72+
)
73+
)
74+
return 2
75+
76+
# Re-run the privilege check with the attestation's stored TCS value.
77+
priv = b4_privilege(
78+
attestation,
79+
max_tcs=policy.max_tcs,
80+
warn_tcs=policy.warn_tcs,
81+
require_approval_for_execute=policy.require_approval_for_execute,
82+
execute_approved=policy.execute_approved,
83+
)
84+
if priv.severity is Severity.BLOCK:
85+
print(
86+
json.dumps(
87+
{
88+
"decision": "deny",
89+
"reason": f"B4 blocked: {priv.summary}",
90+
}
91+
)
92+
)
93+
return 2
94+
95+
print(json.dumps({"decision": "allow", "reason": "within policy"}))
96+
return 0
97+
98+
99+
if __name__ == "__main__": # pragma: no cover
100+
sys.exit(main())
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""Pre-commit hook entry point.
2+
3+
Runs :func:`build_attestation` on each changed MCP config file and
4+
evaluates the project's policy. Returns a non-zero exit code when any
5+
check blocks, so the commit is refused.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import os
11+
import sys
12+
from pathlib import Path
13+
14+
from mcp_governance_kit.attest import build_attestation
15+
from mcp_governance_kit.policy import Policy, evaluate
16+
17+
18+
def _find_policy(repo_root: Path) -> Path | None:
19+
override = os.environ.get("MCP_GOV_POLICY")
20+
if override:
21+
return Path(override)
22+
for candidate in (
23+
repo_root / "policies" / "default.yaml",
24+
repo_root / ".mcp-governance.yaml",
25+
):
26+
if candidate.exists():
27+
return candidate
28+
return None
29+
30+
31+
def main(argv: list[str] | None = None) -> int:
32+
files = [Path(a) for a in (argv or sys.argv[1:]) if a]
33+
if not files:
34+
return 0
35+
36+
repo_root = Path.cwd()
37+
policy_path = _find_policy(repo_root)
38+
if policy_path is None:
39+
print(
40+
"[mcp-gov] no policy found (looked for policies/default.yaml, "
41+
".mcp-governance.yaml). Skipping check.",
42+
file=sys.stderr,
43+
)
44+
return 0
45+
46+
policy = Policy.load(policy_path)
47+
exit_code = 0
48+
for path in files:
49+
try:
50+
att = build_attestation(path, host_id=os.environ.get("USER", "local"))
51+
except Exception as exc: # pragma: no cover - user-facing
52+
print(f"[mcp-gov] failed to parse {path}: {exc}", file=sys.stderr)
53+
exit_code = 2
54+
continue
55+
report = evaluate(att, policy)
56+
status = "BLOCK" if report.blocked else ("WARN" if report.warnings else "OK")
57+
print(f"[mcp-gov] {path}: {status} tcs={att.tcs.value}")
58+
if report.blocked:
59+
for r in report.results:
60+
if r.severity.value == "block":
61+
print(f" - {r.check_id} {r.title}: {r.summary}")
62+
exit_code = 1
63+
64+
return exit_code
65+
66+
67+
if __name__ == "__main__": # pragma: no cover
68+
sys.exit(main())

tests/unit/test_integrations.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""Integration hooks — pre-commit and Claude Code."""
2+
3+
from __future__ import annotations
4+
5+
import io
6+
import json
7+
from pathlib import Path
8+
9+
import pytest
10+
11+
from mcp_governance_kit.integrations.claude_code_hook import main as hook_main
12+
from mcp_governance_kit.integrations.pre_commit import main as pre_commit_main
13+
14+
ROOT = Path(__file__).resolve().parent.parent.parent
15+
EXAMPLES = ROOT / "examples"
16+
POLICIES = ROOT / "policies"
17+
18+
19+
def test_pre_commit_pass_on_c3(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
20+
workspace = tmp_path / "repo"
21+
(workspace / "policies").mkdir(parents=True)
22+
(workspace / "policies" / "default.yaml").write_text(
23+
(POLICIES / "developer.yaml").read_text(encoding="utf-8"),
24+
encoding="utf-8",
25+
)
26+
mcp_json = workspace / ".mcp.json"
27+
mcp_json.write_text(
28+
(EXAMPLES / "c3-developer.mcp.json").read_text(encoding="utf-8"),
29+
encoding="utf-8",
30+
)
31+
monkeypatch.chdir(workspace)
32+
rc = pre_commit_main([str(mcp_json)])
33+
assert rc == 0
34+
35+
36+
def test_pre_commit_blocks_full_stack_under_default(
37+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
38+
) -> None:
39+
workspace = tmp_path / "repo"
40+
(workspace / "policies").mkdir(parents=True)
41+
(workspace / "policies" / "default.yaml").write_text(
42+
(POLICIES / "default.yaml").read_text(encoding="utf-8"),
43+
encoding="utf-8",
44+
)
45+
mcp_json = workspace / ".mcp.json"
46+
mcp_json.write_text(
47+
(EXAMPLES / "c4-full-stack.mcp.json").read_text(encoding="utf-8"),
48+
encoding="utf-8",
49+
)
50+
monkeypatch.chdir(workspace)
51+
rc = pre_commit_main([str(mcp_json)])
52+
assert rc == 1
53+
54+
55+
def test_claude_code_hook_allows_when_no_workspace_context(
56+
tmp_path: Path,
57+
monkeypatch: pytest.MonkeyPatch,
58+
capsys: pytest.CaptureFixture[str],
59+
) -> None:
60+
event = {"tool_name": "list_issues", "cwd": str(tmp_path)}
61+
monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps(event)))
62+
rc = hook_main()
63+
assert rc == 0
64+
out = capsys.readouterr().out
65+
assert json.loads(out)["decision"] == "allow"

0 commit comments

Comments
 (0)