credence-governor-claude-code 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- credence_governor_claude_code-0.1.0/PKG-INFO +149 -0
- credence_governor_claude_code-0.1.0/README.md +131 -0
- credence_governor_claude_code-0.1.0/credence_governor_claude_code/__init__.py +15 -0
- credence_governor_claude_code-0.1.0/credence_governor_claude_code/client.py +38 -0
- credence_governor_claude_code-0.1.0/credence_governor_claude_code/effectors.py +41 -0
- credence_governor_claude_code-0.1.0/credence_governor_claude_code/hook.py +72 -0
- credence_governor_claude_code-0.1.0/credence_governor_claude_code/install.py +92 -0
- credence_governor_claude_code-0.1.0/credence_governor_claude_code/transcript.py +175 -0
- credence_governor_claude_code-0.1.0/credence_governor_claude_code.egg-info/PKG-INFO +149 -0
- credence_governor_claude_code-0.1.0/credence_governor_claude_code.egg-info/SOURCES.txt +17 -0
- credence_governor_claude_code-0.1.0/credence_governor_claude_code.egg-info/dependency_links.txt +1 -0
- credence_governor_claude_code-0.1.0/credence_governor_claude_code.egg-info/entry_points.txt +3 -0
- credence_governor_claude_code-0.1.0/credence_governor_claude_code.egg-info/requires.txt +5 -0
- credence_governor_claude_code-0.1.0/credence_governor_claude_code.egg-info/top_level.txt +1 -0
- credence_governor_claude_code-0.1.0/pyproject.toml +38 -0
- credence_governor_claude_code-0.1.0/setup.cfg +4 -0
- credence_governor_claude_code-0.1.0/tests/test_effectors.py +36 -0
- credence_governor_claude_code-0.1.0/tests/test_roundtrip.py +69 -0
- credence_governor_claude_code-0.1.0/tests/test_transcript.py +114 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: credence-governor-claude-code
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Claude Code adapter for credence-governor — a thin PreToolUse hook that Bayesian-governs tool calls (proceed/block/ask) via the governor daemon.
|
|
5
|
+
Author: Guy Freeman
|
|
6
|
+
License-Expression: AGPL-3.0-or-later
|
|
7
|
+
Project-URL: Homepage, https://github.com/gfrmin/credence-governor
|
|
8
|
+
Project-URL: Repository, https://github.com/gfrmin/credence-governor
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest>=7.4; extra == "dev"
|
|
16
|
+
Requires-Dist: ruff; extra == "dev"
|
|
17
|
+
Requires-Dist: credence-governor-core; extra == "dev"
|
|
18
|
+
|
|
19
|
+
# credence-governor — Claude Code adapter
|
|
20
|
+
|
|
21
|
+
A thin **Claude Code** adapter for [credence-governor](../../README.md): a
|
|
22
|
+
`PreToolUse` subprocess hook that Bayesian-governs tool calls (`proceed` /
|
|
23
|
+
`block` / `ask`) by asking the governor daemon, which runs the one EU-max reasoner
|
|
24
|
+
over the Credence engine.
|
|
25
|
+
|
|
26
|
+
The adapter carries **no probabilistic code and no feature logic**. Its only job is
|
|
27
|
+
to translate Claude Code's native hook events into the harness-neutral wire shape and
|
|
28
|
+
render the daemon's decision. Feature & taint extraction is server-side in the daemon
|
|
29
|
+
(single-sourced — DRY).
|
|
30
|
+
|
|
31
|
+
## How it works
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
Claude Code ──PreToolUse JSON on stdin──▶ credence-governor-claude-code (this hook)
|
|
35
|
+
│ transcript_path JSONL → neutral messages
|
|
36
|
+
▼ POST /decide (tool, input, session)
|
|
37
|
+
governor daemon (credence-governor-core)
|
|
38
|
+
│ server-side feature/taint extraction
|
|
39
|
+
▼ one EU-max decision over the skin wire
|
|
40
|
+
credence-skin engine (Julia)
|
|
41
|
+
◀──permissionDecision JSON on stdout──────┘
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
- **`transcript.py`** — replays the session transcript (`transcript_path`) into the
|
|
45
|
+
neutral `messages` list the extractors expect (`user` / `assistant` / `tool_call` /
|
|
46
|
+
`tool_result`). The proposed call — already persisted to the transcript before
|
|
47
|
+
`PreToolUse` fires — is excluded so loop counts see priors only. Sidechain
|
|
48
|
+
(subagent) turns are excluded by default.
|
|
49
|
+
- **`client.py`** — `POST /decide` (stdlib `urllib`).
|
|
50
|
+
- **`effectors.py`** — maps the governor action to a `PreToolUse` decision.
|
|
51
|
+
- **`hook.py`** — the entry point (`credence-governor-claude-code`).
|
|
52
|
+
|
|
53
|
+
## Overlay semantics (the governor never weakens the host)
|
|
54
|
+
|
|
55
|
+
| governor action | Claude Code decision | effect |
|
|
56
|
+
|---|---|---|
|
|
57
|
+
| `proceed` | *(nothing emitted)* | defer to Claude Code's own permission rules |
|
|
58
|
+
| `block` | `deny` + reason | refuse regardless of native rules |
|
|
59
|
+
| `ask` | `ask` + reason | force a user prompt even if native rules would auto-allow |
|
|
60
|
+
|
|
61
|
+
`permissionDecision: "allow"` is **never** emitted — that would bypass Claude Code's
|
|
62
|
+
own safety prompts. The governor can only *add* friction, not remove it.
|
|
63
|
+
|
|
64
|
+
**Fail-open is absolute.** A malformed event, a daemon that is down, a timeout, or any
|
|
65
|
+
error prints nothing and exits 0 — the call proceeds through Claude Code's normal
|
|
66
|
+
flow. Run with `CREDENCE_GOVERNOR_DEBUG=1` to log decisions/failures to stderr.
|
|
67
|
+
|
|
68
|
+
## Capability boundary (Claude Code, hook transport)
|
|
69
|
+
|
|
70
|
+
| Dimension | Supported |
|
|
71
|
+
|---|---|
|
|
72
|
+
| Waste gating (loops / repetition) | ✅ |
|
|
73
|
+
| Safety (taint-flow / exfil / injected imperatives) | ✅ |
|
|
74
|
+
| Routing / model selection | ❌ (no model-resolve hook) |
|
|
75
|
+
| Cost / dollars-saved | ◑ observe-only |
|
|
76
|
+
| Online ask-response learning | ◌ deferred — see below |
|
|
77
|
+
|
|
78
|
+
v1 gates **`PreToolUse` only**. `PostToolUse` / `UserPromptSubmit` are intentionally
|
|
79
|
+
not wired: neither carries a clean approval label, and treating "the call completed"
|
|
80
|
+
as belief evidence would be a second learning path (a constitutional violation), not
|
|
81
|
+
just a weak one. The warm-trained brain governs; the daemon logs every decision.
|
|
82
|
+
Online ask-learning on Claude Code (inferring the user's reply from the next
|
|
83
|
+
transcript turn) is an open design question, deferred.
|
|
84
|
+
|
|
85
|
+
## Install
|
|
86
|
+
|
|
87
|
+
**1. Start the daemon** — every adapter shares one local daemon; see the
|
|
88
|
+
[core README](../../packages/governor_core) or the
|
|
89
|
+
[repo quickstart](../../README.md#install). In short, from a clone of the repo:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
pip install -e packages/governor_core -e ~/git/credence/apps/skin/clients/python
|
|
93
|
+
CREDENCE_ENGINE_DIR=~/git/credence credence-governor-daemon # or CREDENCE_SKIN_COMMAND="docker run … credence-skin@<digest>"
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
**2. Register the hook** — two ways; both end at the same `PreToolUse` hook.
|
|
97
|
+
|
|
98
|
+
### Method A — as a Claude Code plugin (recommended; no `pip install`)
|
|
99
|
+
|
|
100
|
+
The hook is pure stdlib, so the plugin **bundles** it and runs it via
|
|
101
|
+
`${CLAUDE_PLUGIN_ROOT}` — there's nothing to `pip install`. Inside Claude Code:
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
/plugin marketplace add gfrmin/credence-governor
|
|
105
|
+
/plugin install credence-governor-claude-code@credence-governor
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The hook is active in new sessions (run `/reload-plugins` to pick it up in the current
|
|
109
|
+
one). Manage or remove it from the `/plugin` menu. The plugin reuses the *same*
|
|
110
|
+
`credence_governor_claude_code` package this directory ships — `plugin_hook.py` is a
|
|
111
|
+
thin launcher that puts it on `sys.path` and calls the same entry point the pip console
|
|
112
|
+
script uses (one implementation, two install paths). Manifests:
|
|
113
|
+
[`.claude-plugin/plugin.json`](.claude-plugin/plugin.json),
|
|
114
|
+
[`hooks/hooks.json`](hooks/hooks.json), and the repo-root
|
|
115
|
+
[`.claude-plugin/marketplace.json`](../../.claude-plugin/marketplace.json).
|
|
116
|
+
|
|
117
|
+
### Method B — as a pip package + settings hook
|
|
118
|
+
|
|
119
|
+
If you'd rather manage it with pip (or script the registration):
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
pip install -e adapters/claude-code # not yet on PyPI; once published: pip install credence-governor-claude-code
|
|
123
|
+
credence-governor-cc-install # → ~/.claude/settings.json (idempotent)
|
|
124
|
+
# --project → ./.claude/settings.json · --uninstall → remove
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Either way the hook has **no runtime dependencies** (pure stdlib); `credence-governor-core`
|
|
128
|
+
is needed only for the daemon and the parity test. Static config example:
|
|
129
|
+
[`settings.example.json`](settings.example.json).
|
|
130
|
+
|
|
131
|
+
### Environment
|
|
132
|
+
|
|
133
|
+
| Variable | Default | Meaning |
|
|
134
|
+
|---|---|---|
|
|
135
|
+
| `CREDENCE_GOVERNOR_URL` | `http://127.0.0.1:8787` | governor daemon base URL |
|
|
136
|
+
| `CREDENCE_GOVERNOR_TIMEOUT` | `5.0` | per-call `/decide` timeout (s); on timeout → fail open |
|
|
137
|
+
| `CREDENCE_GOVERNOR_PROFILE` | *(none)* | utility profile passed to the daemon (e.g. `flow-guard`) |
|
|
138
|
+
| `CREDENCE_GOVERNOR_DEBUG` | *(off)* | log decisions/failures to stderr |
|
|
139
|
+
|
|
140
|
+
## Tests
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
python -m pytest tests -q
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Pure-stdlib at runtime; the round-trip parity test (`test_roundtrip.py`) imports
|
|
147
|
+
`credence-governor-core` to assert the wire payload deserialises through the daemon's
|
|
148
|
+
`event_and_session_from_payload` to the Session the extractors expect — it is skipped
|
|
149
|
+
if the core isn't importable.
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# credence-governor — Claude Code adapter
|
|
2
|
+
|
|
3
|
+
A thin **Claude Code** adapter for [credence-governor](../../README.md): a
|
|
4
|
+
`PreToolUse` subprocess hook that Bayesian-governs tool calls (`proceed` /
|
|
5
|
+
`block` / `ask`) by asking the governor daemon, which runs the one EU-max reasoner
|
|
6
|
+
over the Credence engine.
|
|
7
|
+
|
|
8
|
+
The adapter carries **no probabilistic code and no feature logic**. Its only job is
|
|
9
|
+
to translate Claude Code's native hook events into the harness-neutral wire shape and
|
|
10
|
+
render the daemon's decision. Feature & taint extraction is server-side in the daemon
|
|
11
|
+
(single-sourced — DRY).
|
|
12
|
+
|
|
13
|
+
## How it works
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
Claude Code ──PreToolUse JSON on stdin──▶ credence-governor-claude-code (this hook)
|
|
17
|
+
│ transcript_path JSONL → neutral messages
|
|
18
|
+
▼ POST /decide (tool, input, session)
|
|
19
|
+
governor daemon (credence-governor-core)
|
|
20
|
+
│ server-side feature/taint extraction
|
|
21
|
+
▼ one EU-max decision over the skin wire
|
|
22
|
+
credence-skin engine (Julia)
|
|
23
|
+
◀──permissionDecision JSON on stdout──────┘
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
- **`transcript.py`** — replays the session transcript (`transcript_path`) into the
|
|
27
|
+
neutral `messages` list the extractors expect (`user` / `assistant` / `tool_call` /
|
|
28
|
+
`tool_result`). The proposed call — already persisted to the transcript before
|
|
29
|
+
`PreToolUse` fires — is excluded so loop counts see priors only. Sidechain
|
|
30
|
+
(subagent) turns are excluded by default.
|
|
31
|
+
- **`client.py`** — `POST /decide` (stdlib `urllib`).
|
|
32
|
+
- **`effectors.py`** — maps the governor action to a `PreToolUse` decision.
|
|
33
|
+
- **`hook.py`** — the entry point (`credence-governor-claude-code`).
|
|
34
|
+
|
|
35
|
+
## Overlay semantics (the governor never weakens the host)
|
|
36
|
+
|
|
37
|
+
| governor action | Claude Code decision | effect |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| `proceed` | *(nothing emitted)* | defer to Claude Code's own permission rules |
|
|
40
|
+
| `block` | `deny` + reason | refuse regardless of native rules |
|
|
41
|
+
| `ask` | `ask` + reason | force a user prompt even if native rules would auto-allow |
|
|
42
|
+
|
|
43
|
+
`permissionDecision: "allow"` is **never** emitted — that would bypass Claude Code's
|
|
44
|
+
own safety prompts. The governor can only *add* friction, not remove it.
|
|
45
|
+
|
|
46
|
+
**Fail-open is absolute.** A malformed event, a daemon that is down, a timeout, or any
|
|
47
|
+
error prints nothing and exits 0 — the call proceeds through Claude Code's normal
|
|
48
|
+
flow. Run with `CREDENCE_GOVERNOR_DEBUG=1` to log decisions/failures to stderr.
|
|
49
|
+
|
|
50
|
+
## Capability boundary (Claude Code, hook transport)
|
|
51
|
+
|
|
52
|
+
| Dimension | Supported |
|
|
53
|
+
|---|---|
|
|
54
|
+
| Waste gating (loops / repetition) | ✅ |
|
|
55
|
+
| Safety (taint-flow / exfil / injected imperatives) | ✅ |
|
|
56
|
+
| Routing / model selection | ❌ (no model-resolve hook) |
|
|
57
|
+
| Cost / dollars-saved | ◑ observe-only |
|
|
58
|
+
| Online ask-response learning | ◌ deferred — see below |
|
|
59
|
+
|
|
60
|
+
v1 gates **`PreToolUse` only**. `PostToolUse` / `UserPromptSubmit` are intentionally
|
|
61
|
+
not wired: neither carries a clean approval label, and treating "the call completed"
|
|
62
|
+
as belief evidence would be a second learning path (a constitutional violation), not
|
|
63
|
+
just a weak one. The warm-trained brain governs; the daemon logs every decision.
|
|
64
|
+
Online ask-learning on Claude Code (inferring the user's reply from the next
|
|
65
|
+
transcript turn) is an open design question, deferred.
|
|
66
|
+
|
|
67
|
+
## Install
|
|
68
|
+
|
|
69
|
+
**1. Start the daemon** — every adapter shares one local daemon; see the
|
|
70
|
+
[core README](../../packages/governor_core) or the
|
|
71
|
+
[repo quickstart](../../README.md#install). In short, from a clone of the repo:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
pip install -e packages/governor_core -e ~/git/credence/apps/skin/clients/python
|
|
75
|
+
CREDENCE_ENGINE_DIR=~/git/credence credence-governor-daemon # or CREDENCE_SKIN_COMMAND="docker run … credence-skin@<digest>"
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**2. Register the hook** — two ways; both end at the same `PreToolUse` hook.
|
|
79
|
+
|
|
80
|
+
### Method A — as a Claude Code plugin (recommended; no `pip install`)
|
|
81
|
+
|
|
82
|
+
The hook is pure stdlib, so the plugin **bundles** it and runs it via
|
|
83
|
+
`${CLAUDE_PLUGIN_ROOT}` — there's nothing to `pip install`. Inside Claude Code:
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
/plugin marketplace add gfrmin/credence-governor
|
|
87
|
+
/plugin install credence-governor-claude-code@credence-governor
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The hook is active in new sessions (run `/reload-plugins` to pick it up in the current
|
|
91
|
+
one). Manage or remove it from the `/plugin` menu. The plugin reuses the *same*
|
|
92
|
+
`credence_governor_claude_code` package this directory ships — `plugin_hook.py` is a
|
|
93
|
+
thin launcher that puts it on `sys.path` and calls the same entry point the pip console
|
|
94
|
+
script uses (one implementation, two install paths). Manifests:
|
|
95
|
+
[`.claude-plugin/plugin.json`](.claude-plugin/plugin.json),
|
|
96
|
+
[`hooks/hooks.json`](hooks/hooks.json), and the repo-root
|
|
97
|
+
[`.claude-plugin/marketplace.json`](../../.claude-plugin/marketplace.json).
|
|
98
|
+
|
|
99
|
+
### Method B — as a pip package + settings hook
|
|
100
|
+
|
|
101
|
+
If you'd rather manage it with pip (or script the registration):
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
pip install -e adapters/claude-code # not yet on PyPI; once published: pip install credence-governor-claude-code
|
|
105
|
+
credence-governor-cc-install # → ~/.claude/settings.json (idempotent)
|
|
106
|
+
# --project → ./.claude/settings.json · --uninstall → remove
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Either way the hook has **no runtime dependencies** (pure stdlib); `credence-governor-core`
|
|
110
|
+
is needed only for the daemon and the parity test. Static config example:
|
|
111
|
+
[`settings.example.json`](settings.example.json).
|
|
112
|
+
|
|
113
|
+
### Environment
|
|
114
|
+
|
|
115
|
+
| Variable | Default | Meaning |
|
|
116
|
+
|---|---|---|
|
|
117
|
+
| `CREDENCE_GOVERNOR_URL` | `http://127.0.0.1:8787` | governor daemon base URL |
|
|
118
|
+
| `CREDENCE_GOVERNOR_TIMEOUT` | `5.0` | per-call `/decide` timeout (s); on timeout → fail open |
|
|
119
|
+
| `CREDENCE_GOVERNOR_PROFILE` | *(none)* | utility profile passed to the daemon (e.g. `flow-guard`) |
|
|
120
|
+
| `CREDENCE_GOVERNOR_DEBUG` | *(off)* | log decisions/failures to stderr |
|
|
121
|
+
|
|
122
|
+
## Tests
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
python -m pytest tests -q
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Pure-stdlib at runtime; the round-trip parity test (`test_roundtrip.py`) imports
|
|
129
|
+
`credence-governor-core` to assert the wire payload deserialises through the daemon's
|
|
130
|
+
`event_and_session_from_payload` to the Session the extractors expect — it is skipped
|
|
131
|
+
if the core isn't importable.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""credence-governor-claude-code — the Claude Code adapter.
|
|
2
|
+
|
|
3
|
+
A thin subprocess-hook shim: it translates Claude Code's native PreToolUse events
|
|
4
|
+
into the harness-neutral wire shape and asks the governor daemon (credence-governor-core)
|
|
5
|
+
for a proceed/block/ask decision. Carries no probabilistic code and no feature logic
|
|
6
|
+
— extraction is server-side in the daemon (DRY). The adapter's only job is
|
|
7
|
+
native-events -> neutral schema (+ render the decision).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from .hook import handle, main
|
|
13
|
+
from .transcript import messages_from_transcript, session_from_hook
|
|
14
|
+
|
|
15
|
+
__all__ = ["main", "handle", "session_from_hook", "messages_from_transcript"]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""client.py — POST the neutral event to the governor daemon's `/decide`.
|
|
2
|
+
|
|
3
|
+
Zero-dependency (urllib): the hook is a short-lived subprocess that must start fast
|
|
4
|
+
and never block a tool call for long. The caller treats every failure as fail-open.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import urllib.request
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
DEFAULT_URL = "http://127.0.0.1:8787"
|
|
15
|
+
DEFAULT_TIMEOUT = 5.0
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def base_url() -> str:
|
|
19
|
+
return os.environ.get("CREDENCE_GOVERNOR_URL", DEFAULT_URL)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def timeout_s() -> float:
|
|
23
|
+
try:
|
|
24
|
+
return float(os.environ.get("CREDENCE_GOVERNOR_TIMEOUT", DEFAULT_TIMEOUT))
|
|
25
|
+
except (TypeError, ValueError):
|
|
26
|
+
return DEFAULT_TIMEOUT
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def decide(payload: dict[str, Any]) -> dict[str, Any]:
|
|
30
|
+
"""POST /decide and return the daemon's JSON ({action, features, event_id}).
|
|
31
|
+
Raises on transport/HTTP error — the caller fails open."""
|
|
32
|
+
url = base_url().rstrip("/") + "/decide"
|
|
33
|
+
data = json.dumps(payload).encode("utf-8")
|
|
34
|
+
req = urllib.request.Request(
|
|
35
|
+
url, data=data, headers={"content-type": "application/json"}, method="POST"
|
|
36
|
+
)
|
|
37
|
+
with urllib.request.urlopen(req, timeout=timeout_s()) as resp: # noqa: S310 (localhost daemon)
|
|
38
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""effectors.py — a governor action -> a Claude Code PreToolUse decision.
|
|
2
|
+
|
|
3
|
+
The governor is an OVERLAY on Claude Code's native permission system, so it may only
|
|
4
|
+
ever ADD restriction, never remove it:
|
|
5
|
+
|
|
6
|
+
proceed -> None (emit nothing: defer to Claude Code's own permission rules)
|
|
7
|
+
block -> deny (refuse regardless of native rules)
|
|
8
|
+
ask -> ask (force a user prompt even if native rules would auto-allow)
|
|
9
|
+
|
|
10
|
+
We deliberately never emit permissionDecision "allow": that would bypass Claude
|
|
11
|
+
Code's native safety prompts, i.e. the governance layer weakening the host. A
|
|
12
|
+
returned None means "print nothing", which leaves the call to the normal flow.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
_DECISION = {"proceed": None, "block": "deny", "ask": "ask"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _reason(action: str, tool_name: str) -> str:
|
|
23
|
+
tool = tool_name or "this tool"
|
|
24
|
+
if action == "block":
|
|
25
|
+
return f"credence-governor refused `{tool}`: low expected utility under the current belief (likely a loop or unsafe action)."
|
|
26
|
+
return f"credence-governor wants confirmation before `{tool}` runs."
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def pretooluse_output(action: str, tool_name: str = "") -> dict[str, Any] | None:
|
|
30
|
+
"""The JSON to print on stdout for a PreToolUse hook, or None to print nothing
|
|
31
|
+
(proceed / unknown action -> defer to Claude Code)."""
|
|
32
|
+
decision = _DECISION.get(action)
|
|
33
|
+
if decision is None:
|
|
34
|
+
return None
|
|
35
|
+
return {
|
|
36
|
+
"hookSpecificOutput": {
|
|
37
|
+
"hookEventName": "PreToolUse",
|
|
38
|
+
"permissionDecision": decision,
|
|
39
|
+
"permissionDecisionReason": _reason(action, tool_name),
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""hook.py — the Claude Code subprocess-hook entry point.
|
|
2
|
+
|
|
3
|
+
Registered as a `PreToolUse` hook (see install.py / settings.example.json). On each
|
|
4
|
+
proposed tool call Claude Code pipes a JSON event on stdin; we translate it to the
|
|
5
|
+
neutral wire shape, ask the governor daemon for a decision, and print a PreToolUse
|
|
6
|
+
permission decision on stdout.
|
|
7
|
+
|
|
8
|
+
v1 gates PreToolUse only. PostToolUse / UserPromptSubmit are intentionally NOT wired:
|
|
9
|
+
neither carries a clean approval label, and recording "the call completed" as belief
|
|
10
|
+
evidence would be a second learning path (Invariant 1) — wrong, not just weak. Online
|
|
11
|
+
ask-learning on Claude Code is an open design question (transcript-inference), so v1
|
|
12
|
+
is observation-only: the warm-trained brain governs, the daemon logs every decision.
|
|
13
|
+
|
|
14
|
+
Fail-open is absolute: any malformed input, daemon-down, timeout, or non-PreToolUse
|
|
15
|
+
event prints nothing and exits 0, leaving the call to Claude Code's normal flow. The
|
|
16
|
+
governor can add friction (deny / ask) but never removes the host's own safety.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from . import client, effectors, transcript
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _debug(msg: str) -> None:
|
|
30
|
+
if os.environ.get("CREDENCE_GOVERNOR_DEBUG"):
|
|
31
|
+
sys.stderr.write(f"credence-governor: {msg}\n")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _read_stdin() -> dict[str, Any]:
|
|
35
|
+
raw = sys.stdin.read()
|
|
36
|
+
return json.loads(raw) if raw and raw.strip() else {}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def handle(hook_input: dict[str, Any]) -> dict[str, Any] | None:
|
|
40
|
+
"""Pure core: a PreToolUse hook payload -> the stdout JSON (or None to defer).
|
|
41
|
+
Raises on daemon failure so `main` can fail open around it."""
|
|
42
|
+
if (hook_input.get("hook_event_name") or "") != "PreToolUse":
|
|
43
|
+
return None # v1 gates only PreToolUse
|
|
44
|
+
payload = transcript.session_from_hook(hook_input)
|
|
45
|
+
profile = os.environ.get("CREDENCE_GOVERNOR_PROFILE")
|
|
46
|
+
if profile:
|
|
47
|
+
payload["profile"] = profile
|
|
48
|
+
result = client.decide(payload)
|
|
49
|
+
action = str(result.get("action") or "proceed")
|
|
50
|
+
_debug(f"{payload['tool_name']} -> {action} ({result.get('features')})")
|
|
51
|
+
return effectors.pretooluse_output(action, payload["tool_name"])
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def main() -> int:
|
|
55
|
+
try:
|
|
56
|
+
hook_input = _read_stdin()
|
|
57
|
+
except (ValueError, OSError) as err:
|
|
58
|
+
_debug(f"unreadable stdin (failing open): {err}")
|
|
59
|
+
return 0
|
|
60
|
+
try:
|
|
61
|
+
out = handle(hook_input)
|
|
62
|
+
except Exception as err: # fail-open: daemon down, timeout, anything
|
|
63
|
+
_debug(f"decision error (failing open): {err}")
|
|
64
|
+
return 0
|
|
65
|
+
if out is not None:
|
|
66
|
+
sys.stdout.write(json.dumps(out))
|
|
67
|
+
sys.stdout.flush()
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__":
|
|
72
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""install.py — idempotently register (or remove) the PreToolUse hook in a Claude
|
|
2
|
+
Code settings.json.
|
|
3
|
+
|
|
4
|
+
credence-governor-cc-install # -> ~/.claude/settings.json
|
|
5
|
+
credence-governor-cc-install --project # -> ./.claude/settings.json
|
|
6
|
+
credence-governor-cc-install --path X # -> X
|
|
7
|
+
credence-governor-cc-install --uninstall
|
|
8
|
+
|
|
9
|
+
The hook command defaults to the installed console script `credence-governor-claude-code`.
|
|
10
|
+
Existing settings are preserved; we only touch hooks.PreToolUse, and dedupe by command.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
HOOK_COMMAND = "credence-governor-claude-code"
|
|
22
|
+
_MATCHER = "*" # all tools
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _load(path: str) -> dict[str, Any]:
|
|
26
|
+
if not os.path.exists(path):
|
|
27
|
+
return {}
|
|
28
|
+
with open(path, encoding="utf-8") as fh:
|
|
29
|
+
text = fh.read().strip()
|
|
30
|
+
return json.loads(text) if text else {}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _save(path: str, data: dict[str, Any]) -> None:
|
|
34
|
+
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
|
35
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
36
|
+
json.dump(data, fh, indent=2)
|
|
37
|
+
fh.write("\n")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _command_present(entries: list[dict[str, Any]], command: str) -> bool:
|
|
41
|
+
for entry in entries:
|
|
42
|
+
for h in entry.get("hooks", []):
|
|
43
|
+
if h.get("type") == "command" and h.get("command") == command:
|
|
44
|
+
return True
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def add(settings: dict[str, Any], command: str = HOOK_COMMAND) -> dict[str, Any]:
|
|
49
|
+
hooks = settings.setdefault("hooks", {})
|
|
50
|
+
pre = hooks.setdefault("PreToolUse", [])
|
|
51
|
+
if not _command_present(pre, command):
|
|
52
|
+
pre.append({"matcher": _MATCHER, "hooks": [{"type": "command", "command": command}]})
|
|
53
|
+
return settings
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def remove(settings: dict[str, Any], command: str = HOOK_COMMAND) -> dict[str, Any]:
|
|
57
|
+
pre = settings.get("hooks", {}).get("PreToolUse", [])
|
|
58
|
+
for entry in pre:
|
|
59
|
+
entry["hooks"] = [
|
|
60
|
+
h for h in entry.get("hooks", []) if not (h.get("type") == "command" and h.get("command") == command)
|
|
61
|
+
]
|
|
62
|
+
settings.get("hooks", {})["PreToolUse"] = [e for e in pre if e.get("hooks")]
|
|
63
|
+
return settings
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _target(args: argparse.Namespace) -> str:
|
|
67
|
+
if args.path:
|
|
68
|
+
return args.path
|
|
69
|
+
if args.project:
|
|
70
|
+
return os.path.join(os.getcwd(), ".claude", "settings.json")
|
|
71
|
+
return os.path.join(os.path.expanduser("~"), ".claude", "settings.json")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def main() -> int:
|
|
75
|
+
ap = argparse.ArgumentParser(description="Register the credence-governor PreToolUse hook.")
|
|
76
|
+
ap.add_argument("--project", action="store_true", help="install into ./.claude/settings.json")
|
|
77
|
+
ap.add_argument("--path", help="explicit settings.json path")
|
|
78
|
+
ap.add_argument("--command", default=HOOK_COMMAND, help="hook command to register")
|
|
79
|
+
ap.add_argument("--uninstall", action="store_true", help="remove the hook instead")
|
|
80
|
+
args = ap.parse_args()
|
|
81
|
+
|
|
82
|
+
path = _target(args)
|
|
83
|
+
settings = _load(path)
|
|
84
|
+
settings = remove(settings, args.command) if args.uninstall else add(settings, args.command)
|
|
85
|
+
_save(path, settings)
|
|
86
|
+
verb = "removed from" if args.uninstall else "installed into"
|
|
87
|
+
sys.stdout.write(f"credence-governor PreToolUse hook {verb} {path}\n")
|
|
88
|
+
return 0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
if __name__ == "__main__":
|
|
92
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""transcript.py — Claude Code transcript JSONL -> the neutral wire `session`.
|
|
2
|
+
|
|
3
|
+
The adapter's whole job is native-events -> the harness-neutral schema the daemon
|
|
4
|
+
extracts from (see ../../packages/governor_core/credence_governor_core/schema.py).
|
|
5
|
+
A Claude Code PreToolUse hook hands us `transcript_path` (a JSONL of the session so
|
|
6
|
+
far) + the proposed `tool_name`/`tool_input`. We replay the transcript into the
|
|
7
|
+
`messages` list the server-side extractors expect:
|
|
8
|
+
|
|
9
|
+
user — a real user prompt (drives time-since-user)
|
|
10
|
+
assistant — model prose (kept only as a turn marker)
|
|
11
|
+
tool_call — an assistant `tool_use` block (drives repetition / parent-tool / loops)
|
|
12
|
+
tool_result — a user `tool_result` block (the taint SOURCE for safety)
|
|
13
|
+
|
|
14
|
+
Two correctness points the extractors depend on:
|
|
15
|
+
• the PROPOSED call must NOT appear in `messages` (repetition/identical_call count
|
|
16
|
+
PRIOR calls only). Claude Code persists the assistant `tool_use` to the transcript
|
|
17
|
+
*before* firing PreToolUse, so we drop the trailing tool_call iff it matches.
|
|
18
|
+
• sidechain (subagent) turns are excluded by default — they would pollute the main
|
|
19
|
+
thread's loop counts with a subagent's unrelated calls.
|
|
20
|
+
|
|
21
|
+
Pure stdlib, no governor-core import: the hook must start fast and fail open. The
|
|
22
|
+
shared contract is the JSON key set, not shared code.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
from typing import Any, Iterable
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _iter_jsonl(path: str) -> Iterable[dict[str, Any]]:
|
|
33
|
+
with open(path, encoding="utf-8") as fh:
|
|
34
|
+
for line in fh:
|
|
35
|
+
line = line.strip()
|
|
36
|
+
if not line:
|
|
37
|
+
continue
|
|
38
|
+
try:
|
|
39
|
+
rec = json.loads(line)
|
|
40
|
+
except (ValueError, TypeError):
|
|
41
|
+
continue
|
|
42
|
+
if isinstance(rec, dict):
|
|
43
|
+
yield rec
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _blocks(content: Any) -> list[Any]:
|
|
47
|
+
"""Normalise a message `content` to a list of blocks (a bare string is one
|
|
48
|
+
text block)."""
|
|
49
|
+
if isinstance(content, str):
|
|
50
|
+
return [{"type": "text", "text": content}]
|
|
51
|
+
if isinstance(content, list):
|
|
52
|
+
return content
|
|
53
|
+
return []
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def result_to_text(content: Any) -> str:
|
|
57
|
+
"""Flatten a tool_result's content to plain text so taint tokens read cleanly.
|
|
58
|
+
Claude Code tool_result content is a string or a list of `{type:text,text}` (and
|
|
59
|
+
occasionally nested `{content:[...]}`)."""
|
|
60
|
+
if content is None:
|
|
61
|
+
return ""
|
|
62
|
+
if isinstance(content, str):
|
|
63
|
+
return content
|
|
64
|
+
if isinstance(content, list):
|
|
65
|
+
parts: list[str] = []
|
|
66
|
+
for b in content:
|
|
67
|
+
if isinstance(b, dict):
|
|
68
|
+
if b.get("type") == "text" and isinstance(b.get("text"), str):
|
|
69
|
+
parts.append(b["text"])
|
|
70
|
+
elif "content" in b:
|
|
71
|
+
parts.append(result_to_text(b["content"]))
|
|
72
|
+
elif isinstance(b, str):
|
|
73
|
+
parts.append(b)
|
|
74
|
+
return "\n".join(p for p in parts if p)
|
|
75
|
+
try:
|
|
76
|
+
return json.dumps(content)
|
|
77
|
+
except (TypeError, ValueError):
|
|
78
|
+
return str(content)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _has_text(blocks: list[Any]) -> bool:
|
|
82
|
+
for b in blocks:
|
|
83
|
+
if isinstance(b, dict) and b.get("type") == "text" and (b.get("text") or "").strip():
|
|
84
|
+
return True
|
|
85
|
+
if isinstance(b, str) and b.strip():
|
|
86
|
+
return True
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def messages_from_transcript(path: str, include_sidechain: bool = False) -> list[dict[str, Any]]:
|
|
91
|
+
"""Replay a Claude Code transcript into neutral wire messages, in order."""
|
|
92
|
+
out: list[dict[str, Any]] = []
|
|
93
|
+
for rec in _iter_jsonl(path):
|
|
94
|
+
if rec.get("type") not in ("user", "assistant"):
|
|
95
|
+
continue
|
|
96
|
+
if rec.get("isSidechain") and not include_sidechain:
|
|
97
|
+
continue
|
|
98
|
+
ts = rec.get("timestamp")
|
|
99
|
+
message = rec.get("message") or {}
|
|
100
|
+
role = message.get("role")
|
|
101
|
+
blocks = _blocks(message.get("content"))
|
|
102
|
+
|
|
103
|
+
if role == "assistant":
|
|
104
|
+
for b in blocks:
|
|
105
|
+
if not isinstance(b, dict):
|
|
106
|
+
continue
|
|
107
|
+
if b.get("type") == "tool_use":
|
|
108
|
+
out.append(
|
|
109
|
+
{
|
|
110
|
+
"role": "tool_call",
|
|
111
|
+
"tool_name": b.get("name"),
|
|
112
|
+
"input": b.get("input"),
|
|
113
|
+
"timestamp": ts,
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
elif b.get("type") == "text" and (b.get("text") or "").strip():
|
|
117
|
+
out.append({"role": "assistant", "timestamp": ts})
|
|
118
|
+
elif role == "user":
|
|
119
|
+
for b in blocks:
|
|
120
|
+
if isinstance(b, dict) and b.get("type") == "tool_result":
|
|
121
|
+
out.append(
|
|
122
|
+
{"role": "tool_result", "result": result_to_text(b.get("content")), "timestamp": ts}
|
|
123
|
+
)
|
|
124
|
+
# A user record carrying tool_result blocks is a tool delivery, not a
|
|
125
|
+
# human turn; only emit a user message when there is genuine user text.
|
|
126
|
+
if _has_text(blocks):
|
|
127
|
+
out.append({"role": "user", "timestamp": ts})
|
|
128
|
+
return out
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def drop_proposed(messages: list[dict[str, Any]], tool_name: str, tool_input: Any) -> list[dict[str, Any]]:
|
|
132
|
+
"""Remove the trailing tool_call iff it is the proposed call (already persisted
|
|
133
|
+
to the transcript by the time PreToolUse fires). Only the LAST tool_call is a
|
|
134
|
+
candidate; if it doesn't match (transcript not yet flushed) nothing is dropped."""
|
|
135
|
+
for i in range(len(messages) - 1, -1, -1):
|
|
136
|
+
if messages[i].get("role") == "tool_call":
|
|
137
|
+
if messages[i].get("tool_name") == tool_name and messages[i].get("input") == tool_input:
|
|
138
|
+
del messages[i]
|
|
139
|
+
break
|
|
140
|
+
return messages
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def find_project_root(cwd: str) -> str:
|
|
144
|
+
"""Nearest enclosing dir containing a `.git` (file or dir), or "" if none —
|
|
145
|
+
feeds the working-directory-relative feature (project-root/subdir/outside)."""
|
|
146
|
+
cur = os.path.abspath(cwd) if cwd else ""
|
|
147
|
+
while cur and cur != os.path.dirname(cur):
|
|
148
|
+
if os.path.exists(os.path.join(cur, ".git")):
|
|
149
|
+
return cur
|
|
150
|
+
cur = os.path.dirname(cur)
|
|
151
|
+
return ""
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def session_from_hook(hook_input: dict[str, Any]) -> dict[str, Any]:
|
|
155
|
+
"""A PreToolUse hook payload -> the `/decide` request body the daemon extracts
|
|
156
|
+
from (neutral AgentToolEvent + Session, snake_case keys)."""
|
|
157
|
+
cwd = hook_input.get("cwd") or ""
|
|
158
|
+
transcript = hook_input.get("transcript_path") or ""
|
|
159
|
+
tool_name = hook_input.get("tool_name") or ""
|
|
160
|
+
tool_input = hook_input.get("tool_input")
|
|
161
|
+
|
|
162
|
+
messages: list[dict[str, Any]] = []
|
|
163
|
+
if transcript and os.path.exists(transcript):
|
|
164
|
+
messages = messages_from_transcript(transcript)
|
|
165
|
+
drop_proposed(messages, tool_name, tool_input)
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
"tool_name": tool_name,
|
|
169
|
+
"input": tool_input,
|
|
170
|
+
"session": {
|
|
171
|
+
"cwd": cwd,
|
|
172
|
+
"project_root": find_project_root(cwd),
|
|
173
|
+
"messages": messages,
|
|
174
|
+
},
|
|
175
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: credence-governor-claude-code
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Claude Code adapter for credence-governor — a thin PreToolUse hook that Bayesian-governs tool calls (proceed/block/ask) via the governor daemon.
|
|
5
|
+
Author: Guy Freeman
|
|
6
|
+
License-Expression: AGPL-3.0-or-later
|
|
7
|
+
Project-URL: Homepage, https://github.com/gfrmin/credence-governor
|
|
8
|
+
Project-URL: Repository, https://github.com/gfrmin/credence-governor
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest>=7.4; extra == "dev"
|
|
16
|
+
Requires-Dist: ruff; extra == "dev"
|
|
17
|
+
Requires-Dist: credence-governor-core; extra == "dev"
|
|
18
|
+
|
|
19
|
+
# credence-governor — Claude Code adapter
|
|
20
|
+
|
|
21
|
+
A thin **Claude Code** adapter for [credence-governor](../../README.md): a
|
|
22
|
+
`PreToolUse` subprocess hook that Bayesian-governs tool calls (`proceed` /
|
|
23
|
+
`block` / `ask`) by asking the governor daemon, which runs the one EU-max reasoner
|
|
24
|
+
over the Credence engine.
|
|
25
|
+
|
|
26
|
+
The adapter carries **no probabilistic code and no feature logic**. Its only job is
|
|
27
|
+
to translate Claude Code's native hook events into the harness-neutral wire shape and
|
|
28
|
+
render the daemon's decision. Feature & taint extraction is server-side in the daemon
|
|
29
|
+
(single-sourced — DRY).
|
|
30
|
+
|
|
31
|
+
## How it works
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
Claude Code ──PreToolUse JSON on stdin──▶ credence-governor-claude-code (this hook)
|
|
35
|
+
│ transcript_path JSONL → neutral messages
|
|
36
|
+
▼ POST /decide (tool, input, session)
|
|
37
|
+
governor daemon (credence-governor-core)
|
|
38
|
+
│ server-side feature/taint extraction
|
|
39
|
+
▼ one EU-max decision over the skin wire
|
|
40
|
+
credence-skin engine (Julia)
|
|
41
|
+
◀──permissionDecision JSON on stdout──────┘
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
- **`transcript.py`** — replays the session transcript (`transcript_path`) into the
|
|
45
|
+
neutral `messages` list the extractors expect (`user` / `assistant` / `tool_call` /
|
|
46
|
+
`tool_result`). The proposed call — already persisted to the transcript before
|
|
47
|
+
`PreToolUse` fires — is excluded so loop counts see priors only. Sidechain
|
|
48
|
+
(subagent) turns are excluded by default.
|
|
49
|
+
- **`client.py`** — `POST /decide` (stdlib `urllib`).
|
|
50
|
+
- **`effectors.py`** — maps the governor action to a `PreToolUse` decision.
|
|
51
|
+
- **`hook.py`** — the entry point (`credence-governor-claude-code`).
|
|
52
|
+
|
|
53
|
+
## Overlay semantics (the governor never weakens the host)
|
|
54
|
+
|
|
55
|
+
| governor action | Claude Code decision | effect |
|
|
56
|
+
|---|---|---|
|
|
57
|
+
| `proceed` | *(nothing emitted)* | defer to Claude Code's own permission rules |
|
|
58
|
+
| `block` | `deny` + reason | refuse regardless of native rules |
|
|
59
|
+
| `ask` | `ask` + reason | force a user prompt even if native rules would auto-allow |
|
|
60
|
+
|
|
61
|
+
`permissionDecision: "allow"` is **never** emitted — that would bypass Claude Code's
|
|
62
|
+
own safety prompts. The governor can only *add* friction, not remove it.
|
|
63
|
+
|
|
64
|
+
**Fail-open is absolute.** A malformed event, a daemon that is down, a timeout, or any
|
|
65
|
+
error prints nothing and exits 0 — the call proceeds through Claude Code's normal
|
|
66
|
+
flow. Run with `CREDENCE_GOVERNOR_DEBUG=1` to log decisions/failures to stderr.
|
|
67
|
+
|
|
68
|
+
## Capability boundary (Claude Code, hook transport)
|
|
69
|
+
|
|
70
|
+
| Dimension | Supported |
|
|
71
|
+
|---|---|
|
|
72
|
+
| Waste gating (loops / repetition) | ✅ |
|
|
73
|
+
| Safety (taint-flow / exfil / injected imperatives) | ✅ |
|
|
74
|
+
| Routing / model selection | ❌ (no model-resolve hook) |
|
|
75
|
+
| Cost / dollars-saved | ◑ observe-only |
|
|
76
|
+
| Online ask-response learning | ◌ deferred — see below |
|
|
77
|
+
|
|
78
|
+
v1 gates **`PreToolUse` only**. `PostToolUse` / `UserPromptSubmit` are intentionally
|
|
79
|
+
not wired: neither carries a clean approval label, and treating "the call completed"
|
|
80
|
+
as belief evidence would be a second learning path (a constitutional violation), not
|
|
81
|
+
just a weak one. The warm-trained brain governs; the daemon logs every decision.
|
|
82
|
+
Online ask-learning on Claude Code (inferring the user's reply from the next
|
|
83
|
+
transcript turn) is an open design question, deferred.
|
|
84
|
+
|
|
85
|
+
## Install
|
|
86
|
+
|
|
87
|
+
**1. Start the daemon** — every adapter shares one local daemon; see the
|
|
88
|
+
[core README](../../packages/governor_core) or the
|
|
89
|
+
[repo quickstart](../../README.md#install). In short, from a clone of the repo:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
pip install -e packages/governor_core -e ~/git/credence/apps/skin/clients/python
|
|
93
|
+
CREDENCE_ENGINE_DIR=~/git/credence credence-governor-daemon # or CREDENCE_SKIN_COMMAND="docker run … credence-skin@<digest>"
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
**2. Register the hook** — two ways; both end at the same `PreToolUse` hook.
|
|
97
|
+
|
|
98
|
+
### Method A — as a Claude Code plugin (recommended; no `pip install`)
|
|
99
|
+
|
|
100
|
+
The hook is pure stdlib, so the plugin **bundles** it and runs it via
|
|
101
|
+
`${CLAUDE_PLUGIN_ROOT}` — there's nothing to `pip install`. Inside Claude Code:
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
/plugin marketplace add gfrmin/credence-governor
|
|
105
|
+
/plugin install credence-governor-claude-code@credence-governor
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The hook is active in new sessions (run `/reload-plugins` to pick it up in the current
|
|
109
|
+
one). Manage or remove it from the `/plugin` menu. The plugin reuses the *same*
|
|
110
|
+
`credence_governor_claude_code` package this directory ships — `plugin_hook.py` is a
|
|
111
|
+
thin launcher that puts it on `sys.path` and calls the same entry point the pip console
|
|
112
|
+
script uses (one implementation, two install paths). Manifests:
|
|
113
|
+
[`.claude-plugin/plugin.json`](.claude-plugin/plugin.json),
|
|
114
|
+
[`hooks/hooks.json`](hooks/hooks.json), and the repo-root
|
|
115
|
+
[`.claude-plugin/marketplace.json`](../../.claude-plugin/marketplace.json).
|
|
116
|
+
|
|
117
|
+
### Method B — as a pip package + settings hook
|
|
118
|
+
|
|
119
|
+
If you'd rather manage it with pip (or script the registration):
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
pip install -e adapters/claude-code # not yet on PyPI; once published: pip install credence-governor-claude-code
|
|
123
|
+
credence-governor-cc-install # → ~/.claude/settings.json (idempotent)
|
|
124
|
+
# --project → ./.claude/settings.json · --uninstall → remove
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Either way the hook has **no runtime dependencies** (pure stdlib); `credence-governor-core`
|
|
128
|
+
is needed only for the daemon and the parity test. Static config example:
|
|
129
|
+
[`settings.example.json`](settings.example.json).
|
|
130
|
+
|
|
131
|
+
### Environment
|
|
132
|
+
|
|
133
|
+
| Variable | Default | Meaning |
|
|
134
|
+
|---|---|---|
|
|
135
|
+
| `CREDENCE_GOVERNOR_URL` | `http://127.0.0.1:8787` | governor daemon base URL |
|
|
136
|
+
| `CREDENCE_GOVERNOR_TIMEOUT` | `5.0` | per-call `/decide` timeout (s); on timeout → fail open |
|
|
137
|
+
| `CREDENCE_GOVERNOR_PROFILE` | *(none)* | utility profile passed to the daemon (e.g. `flow-guard`) |
|
|
138
|
+
| `CREDENCE_GOVERNOR_DEBUG` | *(off)* | log decisions/failures to stderr |
|
|
139
|
+
|
|
140
|
+
## Tests
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
python -m pytest tests -q
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Pure-stdlib at runtime; the round-trip parity test (`test_roundtrip.py`) imports
|
|
147
|
+
`credence-governor-core` to assert the wire payload deserialises through the daemon's
|
|
148
|
+
`event_and_session_from_payload` to the Session the extractors expect — it is skipped
|
|
149
|
+
if the core isn't importable.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
credence_governor_claude_code/__init__.py
|
|
4
|
+
credence_governor_claude_code/client.py
|
|
5
|
+
credence_governor_claude_code/effectors.py
|
|
6
|
+
credence_governor_claude_code/hook.py
|
|
7
|
+
credence_governor_claude_code/install.py
|
|
8
|
+
credence_governor_claude_code/transcript.py
|
|
9
|
+
credence_governor_claude_code.egg-info/PKG-INFO
|
|
10
|
+
credence_governor_claude_code.egg-info/SOURCES.txt
|
|
11
|
+
credence_governor_claude_code.egg-info/dependency_links.txt
|
|
12
|
+
credence_governor_claude_code.egg-info/entry_points.txt
|
|
13
|
+
credence_governor_claude_code.egg-info/requires.txt
|
|
14
|
+
credence_governor_claude_code.egg-info/top_level.txt
|
|
15
|
+
tests/test_effectors.py
|
|
16
|
+
tests/test_roundtrip.py
|
|
17
|
+
tests/test_transcript.py
|
credence_governor_claude_code-0.1.0/credence_governor_claude_code.egg-info/dependency_links.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
credence_governor_claude_code
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "credence-governor-claude-code"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Claude Code adapter for credence-governor — a thin PreToolUse hook that Bayesian-governs tool calls (proceed/block/ask) via the governor daemon."
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
license = "AGPL-3.0-or-later"
|
|
12
|
+
authors = [{ name = "Guy Freeman" }]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3.11",
|
|
15
|
+
"Programming Language :: Python :: 3.12",
|
|
16
|
+
"Programming Language :: Python :: 3.13",
|
|
17
|
+
]
|
|
18
|
+
# Runtime is pure stdlib (json, urllib, os): the hook must start fast and fail open.
|
|
19
|
+
# The shared contract with the daemon is the JSON key set, not shared code.
|
|
20
|
+
dependencies = []
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/gfrmin/credence-governor"
|
|
24
|
+
Repository = "https://github.com/gfrmin/credence-governor"
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
credence-governor-claude-code = "credence_governor_claude_code.hook:main"
|
|
28
|
+
credence-governor-cc-install = "credence_governor_claude_code.install:main"
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
# governor-core is a TEST-only dep: the round-trip parity test asserts our wire
|
|
32
|
+
# payload deserialises through the daemon's event_and_session_from_payload to the
|
|
33
|
+
# Session the server-side extractors expect.
|
|
34
|
+
dev = ["pytest>=7.4", "ruff", "credence-governor-core"]
|
|
35
|
+
|
|
36
|
+
[tool.setuptools.packages.find]
|
|
37
|
+
where = ["."]
|
|
38
|
+
include = ["credence_governor_claude_code*"]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Action -> PreToolUse decision. The governor may only add restriction:
|
|
2
|
+
proceed defers (None), block denies, ask prompts; "allow" is never emitted."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from credence_governor_claude_code.effectors import pretooluse_output
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_proceed_defers_to_host():
|
|
10
|
+
assert pretooluse_output("proceed", "Bash") is None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_unknown_action_defers():
|
|
14
|
+
assert pretooluse_output("weird", "Bash") is None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_block_denies_with_reason():
|
|
18
|
+
out = pretooluse_output("block", "Bash")
|
|
19
|
+
hso = out["hookSpecificOutput"]
|
|
20
|
+
assert hso["hookEventName"] == "PreToolUse"
|
|
21
|
+
assert hso["permissionDecision"] == "deny"
|
|
22
|
+
assert "Bash" in hso["permissionDecisionReason"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_ask_prompts_with_reason():
|
|
26
|
+
out = pretooluse_output("ask", "Write")
|
|
27
|
+
hso = out["hookSpecificOutput"]
|
|
28
|
+
assert hso["permissionDecision"] == "ask"
|
|
29
|
+
assert "Write" in hso["permissionDecisionReason"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_never_emits_allow():
|
|
33
|
+
for action in ("proceed", "block", "ask", "weird"):
|
|
34
|
+
out = pretooluse_output(action, "X")
|
|
35
|
+
if out is not None:
|
|
36
|
+
assert out["hookSpecificOutput"]["permissionDecision"] != "allow"
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Wire-contract parity: the payload the adapter POSTs must deserialise through the
|
|
2
|
+
daemon's `event_and_session_from_payload` and feed the server-side extractors to the
|
|
3
|
+
right feature buckets. This is the only place the two sides are tied together — at
|
|
4
|
+
the JSON contract, not at shared code. Skipped if governor-core isn't importable."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
# Make the sibling core package importable without an install.
|
|
14
|
+
_CORE = os.path.normpath(
|
|
15
|
+
os.path.join(os.path.dirname(__file__), "..", "..", "..", "packages", "governor_core")
|
|
16
|
+
)
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
if _CORE not in sys.path:
|
|
20
|
+
sys.path.insert(0, _CORE)
|
|
21
|
+
|
|
22
|
+
core = pytest.importorskip("credence_governor_core")
|
|
23
|
+
|
|
24
|
+
from credence_governor_claude_code.transcript import session_from_hook # noqa: E402
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _write(tmp_path, records) -> str:
|
|
28
|
+
p = os.path.join(tmp_path, "t.jsonl")
|
|
29
|
+
with open(p, "w", encoding="utf-8") as fh:
|
|
30
|
+
for r in records:
|
|
31
|
+
fh.write(json.dumps(r) + "\n")
|
|
32
|
+
return p
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_payload_deserialises_and_extracts(tmp_path):
|
|
36
|
+
from credence_governor_core import extract_features, extract_safety
|
|
37
|
+
from credence_governor_core.schema import event_and_session_from_payload
|
|
38
|
+
|
|
39
|
+
def tool_use(cmd, ts):
|
|
40
|
+
return {"type": "assistant", "timestamp": ts, "isSidechain": False,
|
|
41
|
+
"message": {"role": "assistant",
|
|
42
|
+
"content": [{"type": "tool_use", "id": "t", "name": "Bash",
|
|
43
|
+
"input": {"command": cmd}}]}}
|
|
44
|
+
|
|
45
|
+
# Three prior identical Bash(ls) calls + the proposed fourth -> a loop.
|
|
46
|
+
records = [
|
|
47
|
+
{"type": "user", "timestamp": "2026-06-25T10:00:00.000Z", "isSidechain": False,
|
|
48
|
+
"message": {"role": "user", "content": "loop ls please"}},
|
|
49
|
+
tool_use("ls", "2026-06-25T10:00:01.000Z"),
|
|
50
|
+
tool_use("ls", "2026-06-25T10:00:02.000Z"),
|
|
51
|
+
tool_use("ls", "2026-06-25T10:00:03.000Z"),
|
|
52
|
+
tool_use("ls", "2026-06-25T10:00:04.000Z"), # the proposed call (dropped)
|
|
53
|
+
]
|
|
54
|
+
path = _write(tmp_path, records)
|
|
55
|
+
payload = session_from_hook(
|
|
56
|
+
{"hook_event_name": "PreToolUse", "cwd": str(tmp_path), "transcript_path": path,
|
|
57
|
+
"tool_name": "Bash", "tool_input": {"command": "ls"}}
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
event, session = event_and_session_from_payload(payload)
|
|
61
|
+
assert event.tool_name == "Bash"
|
|
62
|
+
# The proposed call was excluded -> exactly the three priors remain.
|
|
63
|
+
assert sum(1 for m in session.messages if m.role == "tool_call") == 3
|
|
64
|
+
|
|
65
|
+
feats = {**extract_features(event, session), **extract_safety(event, session)}
|
|
66
|
+
# Three identical priors -> rep-3plus / ident-3plus (the loop signal the daemon decides on).
|
|
67
|
+
assert feats["recent-repetition-count"] == "rep-3plus"
|
|
68
|
+
assert feats["recent-identical-call-count"] == "ident-3plus"
|
|
69
|
+
assert feats["parent-tool-call-name"] == "bash"
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Transcript replay: Claude Code JSONL -> neutral wire messages.
|
|
2
|
+
|
|
3
|
+
The fixtures are minimal hand-written transcript records matching the real schema
|
|
4
|
+
(type=user/assistant, message.content blocks, top-level timestamp/isSidechain).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
from credence_governor_claude_code.transcript import (
|
|
13
|
+
drop_proposed,
|
|
14
|
+
messages_from_transcript,
|
|
15
|
+
result_to_text,
|
|
16
|
+
session_from_hook,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _write(tmp_path, records) -> str:
|
|
21
|
+
p = os.path.join(tmp_path, "transcript.jsonl")
|
|
22
|
+
with open(p, "w", encoding="utf-8") as fh:
|
|
23
|
+
for r in records:
|
|
24
|
+
fh.write(json.dumps(r) + "\n")
|
|
25
|
+
return p
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _user(text, ts="2026-06-25T10:00:00.000Z", sidechain=False):
|
|
29
|
+
return {"type": "user", "timestamp": ts, "isSidechain": sidechain,
|
|
30
|
+
"message": {"role": "user", "content": text}}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _tool_use(name, inp, ts="2026-06-25T10:00:01.000Z", sidechain=False):
|
|
34
|
+
return {"type": "assistant", "timestamp": ts, "isSidechain": sidechain,
|
|
35
|
+
"message": {"role": "assistant",
|
|
36
|
+
"content": [{"type": "tool_use", "id": "t1", "name": name, "input": inp}]}}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _tool_result(content, ts="2026-06-25T10:00:02.000Z", sidechain=False):
|
|
40
|
+
return {"type": "user", "timestamp": ts, "isSidechain": sidechain,
|
|
41
|
+
"message": {"role": "user",
|
|
42
|
+
"content": [{"type": "tool_result", "tool_use_id": "t1", "content": content}]}}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_roles_and_order(tmp_path):
|
|
46
|
+
path = _write(tmp_path, [
|
|
47
|
+
_user("hello"),
|
|
48
|
+
_tool_use("Bash", {"command": "ls"}),
|
|
49
|
+
_tool_result("a.txt\nb.txt"),
|
|
50
|
+
])
|
|
51
|
+
msgs = messages_from_transcript(path)
|
|
52
|
+
assert [m["role"] for m in msgs] == ["user", "tool_call", "tool_result"]
|
|
53
|
+
assert msgs[1]["tool_name"] == "Bash"
|
|
54
|
+
assert msgs[1]["input"] == {"command": "ls"}
|
|
55
|
+
assert msgs[2]["result"] == "a.txt\nb.txt"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_sidechain_excluded_by_default(tmp_path):
|
|
59
|
+
path = _write(tmp_path, [
|
|
60
|
+
_user("hi"),
|
|
61
|
+
_tool_use("Read", {"file_path": "x"}, sidechain=True),
|
|
62
|
+
])
|
|
63
|
+
assert [m["role"] for m in messages_from_transcript(path)] == ["user"]
|
|
64
|
+
assert len(messages_from_transcript(path, include_sidechain=True)) == 2
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_tool_result_list_content_flattened():
|
|
68
|
+
text = result_to_text([{"type": "text", "text": "alpha"}, {"type": "text", "text": "beta"}])
|
|
69
|
+
assert text == "alpha\nbeta"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_drop_proposed_removes_matching_trailing_call():
|
|
73
|
+
msgs = [
|
|
74
|
+
{"role": "tool_call", "tool_name": "Bash", "input": {"command": "ls"}},
|
|
75
|
+
{"role": "tool_call", "tool_name": "Bash", "input": {"command": "pwd"}},
|
|
76
|
+
]
|
|
77
|
+
drop_proposed(msgs, "Bash", {"command": "pwd"})
|
|
78
|
+
assert [m["input"] for m in msgs] == [{"command": "ls"}]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_drop_proposed_keeps_nonmatching_trailing_call():
|
|
82
|
+
msgs = [{"role": "tool_call", "tool_name": "Bash", "input": {"command": "ls"}}]
|
|
83
|
+
drop_proposed(msgs, "Bash", {"command": "different"})
|
|
84
|
+
assert len(msgs) == 1
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_session_from_hook_excludes_proposed_call(tmp_path):
|
|
88
|
+
# The proposed Bash(pwd) is already persisted to the transcript when PreToolUse
|
|
89
|
+
# fires; the wire `messages` must NOT contain it (repetition counts priors only).
|
|
90
|
+
path = _write(tmp_path, [
|
|
91
|
+
_user("go"),
|
|
92
|
+
_tool_use("Bash", {"command": "ls"}),
|
|
93
|
+
_tool_result("ok"),
|
|
94
|
+
_tool_use("Bash", {"command": "pwd"}),
|
|
95
|
+
])
|
|
96
|
+
payload = session_from_hook(
|
|
97
|
+
{"hook_event_name": "PreToolUse", "cwd": str(tmp_path),
|
|
98
|
+
"transcript_path": path, "tool_name": "Bash", "tool_input": {"command": "pwd"}}
|
|
99
|
+
)
|
|
100
|
+
assert payload["tool_name"] == "Bash"
|
|
101
|
+
assert payload["input"] == {"command": "pwd"}
|
|
102
|
+
calls = [m for m in payload["session"]["messages"] if m["role"] == "tool_call"]
|
|
103
|
+
assert [c["input"] for c in calls] == [{"command": "ls"}]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_project_root_detected(tmp_path):
|
|
107
|
+
os.makedirs(os.path.join(tmp_path, ".git"))
|
|
108
|
+
sub = os.path.join(tmp_path, "src")
|
|
109
|
+
os.makedirs(sub)
|
|
110
|
+
payload = session_from_hook(
|
|
111
|
+
{"hook_event_name": "PreToolUse", "cwd": sub, "transcript_path": "",
|
|
112
|
+
"tool_name": "Read", "tool_input": {}}
|
|
113
|
+
)
|
|
114
|
+
assert payload["session"]["project_root"] == str(tmp_path)
|