Optional Claude Code 2.1.274 and 2.1.280 function hooks for Signet-eval policy and output redaction

This thin TypeScript plugin delegates policy and sanitation to the existing Rust binary. It starts no service. Legacy Claude command hooks, Codex, Antigravity and OpenCode remain separate: they do not import this adapter, poll it, or require Claude's function API. Removing this directory removes the modern adapter.
The adapter targets the early-access API emitted by Claude Code 2.1.274 and 2.1.280 with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1. The installer accepts those exact builds, not a version range. The 3.13.0 recovery adapter passed real-host fixtures on both builds with isolated configuration and a synthetic provider: installed-plugin behavior, confirmed disablement, disable-during-call, policy refusal detail, and shell error detail with exactly one observable execution. The callback contract also passes 56 recovery and 8 host-error checks. These probes do not qualify a production model provider. The adapter/binary pairing revision is claude-functions-2.1.280-v1; install the rebuilt binary and its embedded adapter together. The flag also works through settings.json's env object in an isolated 2.1.274 probe; no shell-profile change is required for that activation path. Settings-hook events are named under classic. in this API (classic.PreToolUse); 2.1.274 rejects a module that registers the bare PreToolUse name used on 2.1.263. Use /plugin-types in an isolated directory to obtain the host declarations. The host's module checker is stricter than ordinary TypeScript.
PR #15 qualified the preceding adapter on both exact hosts on 2026-09-23 with isolated configuration and a synthetic provider. Its independent installer tests remain in tests/test_pr15_behavior.py and run in Linux/macOS CI. Intermediate builds remain rejected; those historical probes do not qualify every subsequent adapter change or any production model provider.
Run those installer checks after a release build by setting SIGNET_EVAL_BINARY to the absolute release executable path and invoking the Python test file.
Nothing in a build or package installation enables this adapter. Its enabled option defaults to false, which registers no hooks. Do not install the modern and legacy Claude handlers together. The installer below retires recognized registrations; unfamiliar wrappers require manual review. Keep other clients' adapters as needed. The plugin detects common legacy command registrations in Claude user/project settings and refuses coexistence. Arbitrary shell wrappers and externally supplied settings require operator inspection.
The binary embeds all runtime assets; no source checkout or third package is needed. After installing/upgrading Signet-eval, explicitly choose the modern adapter with:
signet-eval integration install-modern
This checks the exact Claude version allowlist, installs complete assets under CLAUDE_CONFIG_DIR/skills/signet-eval-functions (normally ~/.claude/skills), sets the function flag in Claude settings, and configures the adapter with this binary's absolute path. It retires only exact recognized Signet Claude command handlers, retaining foreign handlers in shared groups. Unknown Signet wrappers, malformed settings and linked/unowned installation targets require manual review. Existing settings and previous plugin files are retained under the Claude config directory's signet-adapter-backups; restore both to roll back. Restart Claude.
Installing the adapter does not enable enforcement: global/session disabled state is preserved. When globally disabled, the installed plugin is neutral. Other clients' settings are not touched. Default hook mode remains legacy until this explicit installation command is run. An older Signet binary without the integration protocol must be upgraded before selecting modern Kindex coexistence; otherwise retain the legacy integration instead of bypassing an active policy.
To try it from a reviewed source checkout after cargo build, start a new Claude session with the explicit plugin directory and options:
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude \
--plugin-dir /absolute/path/to/signet-eval/adapters/claude-function \
--settings '{"pluginConfigs":{"signet-eval-functions":{"options":{"enabled":true,"executable":"/absolute/path/to/signet-eval/target/debug/signet-eval"}}}}'
This is an opt-in invocation, not an installer. Global/session disabled state is still honored. Every enforcement callback reads the current session ID and actual working directory and requests a fresh owner description. This works when session.start was skipped, including hot reload and resumed sessions; startup failure is reported but never stored as a permanent conflict. The adapter checks protocol version 1 and owner signet-eval before trusting any description. Confirmed disabled state delegates neutrally even if the adapter revision is stale or legacy handlers remain. Active state requires the matching adapter revision and zero detected legacy handlers on every callback.
Missing binaries, malformed replies, invalid policy, paused enforcement and adapter conflicts block tool effects with fixed diagnostic categories. Diagnostics never copy subprocess stderr or arbitrary describe-response reason text. Valid policy ask/deny decisions retain their policy explanation. Run signet-eval status in a terminal to inspect the selected owner. Each subsequent callback checks again, so restoring the owner or correcting a conflict recovers without a restart. An engine policy allow delegates to the normal host permission flow; it does not synthesize allow:true. Policy ask and deny remain ask and deny; advisory context does not grant permission.
The prompt channel remains available for human recovery. When owner checks fail, the adapter displays the failure and still attempts the vault-independent sanitizer. If sanitation also fails, it forwards the original prompt with an explicit warning that Signet redaction is unavailable. This is an availability tradeoff for recovery prompts; avoid entering secrets while that warning is present. Confirmed disabled state forwards the original prompt neutrally.
When the host returns an error or no typed result, the adapter preserves its sanitized error text in the denial channel, including the underlying policy explanation. It never turns an error into a successful typed tool result.
Once the host tool call has returned, a sanitizer failure withholds its result and reports the post-execution phase. It never calls the tool again. Host exceptions propagate as host exceptions rather than being mislabeled as a pre-execution Signet outage; inspect actual effects before retrying.
Claude may skip a plugin that fails to load, throws outside a handled callback, or violates its hook schema. Plugins are therefore a cooperative host boundary, not an independent mandatory security monitor.
The Rust signet-redaction-v1 sanitizer masks known credential formats, private keys, labeled credentials, and sensitive structured fields. It preserves hashes and non-secret structural values. It does not treat every high-entropy string, email address, or IP address as a secret; arbitrary secrets cannot be guaranteed detectable. It neither stores a secret-handle map nor reinserts credentials into arbitrary commands.
| Controlled surface | Behavior |
|---|---|
| Action ledger / MCP proxy parameter summaries | Sanitize structured data before truncation and persistence |
| Preflight violation summaries | Sanitize at the vault SQL sink |
| New preflight definitions | Reject detected secrets; never rewrite constraint semantics |
| Admission ledger | Store input/scope digests and receipts, never raw task arguments |
| Reported task outcomes | Sanitize before persistence; keep original digest as evidence binding |
Function prompt.submit | Sanitize model/main-transcript prompt projection when available; warn and preserve recovery prompts if sanitation fails |
Function tool.call | Replace successful result with a fresh sanitized, host-validated result; return safe error text on failed calls |
This is not all-log or all-transcript protection. The tested Claude path writes the original prompt to a queue/enqueue transcript row before prompt.submit. Assistant output, host telemetry/debug timing, third-party logs and existing transcripts/backups are not covered. Legacy command hooks cannot rewrite tool output. Historical disk cleanup and credential rotation are separate operations. integration describe always reports host_redaction.ready:false: CLI presence does not prove a plugin is loaded in this session. Kindex owns sanitation of its own data sinks and must not register a duplicate host redactor.
Explicit native Read, Grep and Glob can inspect hook settings. Write, Edit and Bash still require the settings-change check. A shell command merely mentioning settings.json can still ask: this adapter does not attempt to prove arbitrary shell text read-only. Other binary/directory self-protection is unchanged.
The locked protect_signet_binary rule uses protected_binary_reference() to inspect command fields (command, cmd, CommandLine) and file targets (file_path, path, target_file, TargetFile, destination). It does not scan arbitrary content, issue text, or repository identifiers for the product name. A non-match grants no permission: remaining policy rules still evaluate.
The guard recognizes /opt/homebrew/bin, /usr/local/bin, and .cargo/bin executable locations and the running owner’s actual executable path, including custom install directories and renamed binaries. Matching covers slash/backslash forms, common home-directory spellings, .exe names, and simple redundant dot/cancelled path segments. References to those installed paths are conservatively protected even in shell read commands. Direct executable invocations (including through ordinary env, command, exec, sudo, nice, nohup, timeout, setsid, stdbuf, ionice, time, leading variable assignments, and literal sh/bash/zsh/dash/ksh command strings using -c, -lc, or -ec) and common file mutations targeting a relative executable basename are also protected, since the working directory could be an installation directory. Structured relative executable targets are protected for the same reason.
Command checks also scan a conservative quote/escape-stripped projection after checking the original text; this can only add denials. The original input still reaches every other rule, and structured target paths are never transformed.
This is a bounded lexical guard, not shell parsing or resolved-path security. Quoted shell examples containing command separators can conservatively match; encoded commands, arbitrary wrappers, other non-running custom installations, shell expansion and filesystem aliases are not completely modeled. Existing directory, symlink, process, identity and destructive-operation rules remain independent.
All four integration subcommands accept JSON stdin and return one JSON object. Inputs are bounded to 4 MiB; invalid input returns fixed errors without echoing it. Protocol version is 1; adapter revision is claude-functions-2.1.280-v1.
describe accepts {session_id, project_path, agent}. project_path is the absolute actual worktree directory, not the main checkout returned by Claude's repository metadata. The report includes owner, binary/adapter/sanitizer version, effective policy SHA256, disabled/paused/invalid state, legacy-handler count, and scope-bound task-enforcement readiness. It neither changes settings nor creates or migrates a vault database. Missing scope cannot claim task readiness.
adjudicate accepts:
{
"protocol_version": 1,
"operation_id": "stable-caller-id",
"session_id": "session-id",
"project_path": "/absolute/worktree",
"agent": "claude",
"policy_revision": "sha256-from-describe",
"source_tool": "TaskCreate",
"source_input": {"subject": "Follow up"},
"target_tool": "kindex.task.create",
"input": {
"operation": "create",
"args": {"title": "Follow up", "operation_id": "stable-caller-id"},
"scope": {"session_id": "session-id", "project_path": "/absolute/worktree", "agent": "claude"},
"source_input": {"subject": "Follow up"}
}
}
Targets are create, get, list, update, complete, cancel, claim, release, and reconcile. Exact native task-state mappings or canonical Kindex target identities are required; Agent, TaskOutput, TaskStop, arbitrary MCP destinations, and arbitrary processes are not delegation inputs.
Only the compiled prefer_persistent_task_store rule is omitted when evaluating the admitted native source. All other source rules, the canonical MCP target, the semantic target and active preflight constraints remain enforced. Any refusal wins. ASK requires separate human authorization; this protocol cannot grant it. No advisory command runs during admission.
Native calls require their exact original source_input, identical in the outer envelope and input.source_input. Source rules see native fields such as subject; target rules see task fields such as title. This raw source crosses only the trusted local policy stdin; the integration does not write its plaintext to its logs, model providers or data stores. This does not control operating-system process inspection, memory, swap or crash dumps. Sanitized target arguments describe the actual effect. The receipt input digest binds both, with an additional source-input digest. Substituting a redacted source would silently weaken policy rules matching the original value.
An allow is durably committed to SIGNET_DIR/integration.db before being returned. The receipt binds operation, exact input digest, canonical scope, effective policy revision and a 30-second admission lifetime. Concurrent exact retries return one receipt; changed input under the same scoped ID conflicts. IDs are namespaced by project, session, agent and optional explicit profile. Disabled/changed policy and expired authorization refuse new effects. Kindex must query its committed result receipt before seeking fresh admission for a retry; never retry by fuzzy title.
Digests use SHA256 over sorted-key, compact UTF-8 JSON without Unicode normalization. Tool identities and scope identifiers are exact/ASCII-validated; free-text policy conditions evaluate the original Unicode text. Digests are not encryption: a receipt holder can test guesses for low-entropy input. The local SQLite ledger is not hash-chained or tamper-evident against its filesystem owner. Kindex validates the owner, protocol, input/scope/source/target binding, revision and lifetime before a new task effect; Signet validates the exact stored receipt when accepting an outcome.
record-result accepts {protocol_version:1, operation_id, receipt, task_receipt}. The Signet receipt must exactly match the ledger, and the reported task receipt must carry the same operation ID. It accepts delayed delivery after expiry or disablement, but conflicting outcomes fail. Its evidence is explicitly reported_task_receipt, not verified successful execution. Kindex commits this delivery to an outbox atomically with the task result so an unavailable Signet process does not turn a committed task into a duplicate retry.
cargo test
python3 tests/claude_function_host.py
python3 tests/claude_function_host.py --installed-plugin
python3 tests/claude_function_host.py --kindex-plugin /absolute/kindex/src/kindex/claude_modern
The host fixture uses the installed Claude executable, a local synthetic provider, synthetic secrets and new temporary Git/config/vault directories. It retains artifacts for inspection and does not load user hooks, MCP servers or credentials.
hooks/signet.ts 157 lines1import type { Register } from "claude-code";
2
3const REVISION = "claude-functions-2.1.280-v1";
4
5// Only fixed categories leave this parser: owner-provided error/reason text is
6// never a diagnostic. Disabled is neutral even across adapter upgrades.
7function ownerState(stdout: string): string {
8 try {
9 const state = JSON.parse(stdout);
10 if (!state || typeof state !== "object" || Array.isArray(state)) return "invalid_response";
11 if (state.protocol_version !== 1) return "protocol_mismatch";
12 if (state.owner !== "signet-eval") return "owner_mismatch";
13 if (typeof state.active !== "boolean" || typeof state.disabled !== "boolean" || (state.active && state.disabled)) return "invalid_response";
14 if (state.disabled) return "disabled";
15 if (!state.active) {
16 if (state.reason === "policy_invalid") return "policy_invalid";
17 if (state.reason === "policy_unreadable") return "policy_unreadable";
18 if (state.reason === "policy_integrity_failed") return "policy_integrity_failed";
19 if (state.reason === "vault_unavailable") return "vault_unavailable";
20 if (state.reason === "paused") return "paused";
21 return "owner_inactive";
22 }
23 if (state.adapter_revision !== REVISION) return "adapter_revision_mismatch";
24 if (state.legacy_handlers !== 0) return "legacy_handler_conflict";
25 return "active";
26 } catch { return "invalid_response"; }
27}
28
29function blocked(state: string): string {
30 return `Signet-eval: ${state}. Tool execution is blocked. Run signet-eval status in a terminal to inspect the selected owner; callbacks recheck automatically.`;
31}
32
33export const register: Register = (on, options) => {
34 if (options.enabled !== true) return;
35 const executable = String(options.executable ?? "signet-eval");
36 on("session.start", async ($, e, next) => {
37 let state = "scope_unavailable";
38 try {
39 const sessionId = await $.session.id();
40 const cwd = await $.session.cwd();
41 if (sessionId && cwd) {
42 state = "owner_unavailable";
43 const output = await $.process.run([executable,"integration","describe"], {
44 stdin:JSON.stringify({session_id:sessionId,agent:sessionId,project_path:cwd}),timeoutMs:5000,
45 });
46 if (output.exitCode === 0) state = ownerState(output.stdout);
47 }
48 } catch { /* Report this attempt; never latch failure into later callbacks. */ }
49 $.ui.status(state === "active" ? "Signet-eval: policy + output redaction" : state === "disabled" ? "Signet-eval: disabled" : blocked(state));
50 return next(e);
51 });
52 on("prompt.submit", async ($, e, next) => {
53 let state = "scope_unavailable";
54 try {
55 const sessionId = await $.session.id();
56 const cwd = await $.session.cwd();
57 if (sessionId && cwd) {
58 state = "owner_unavailable";
59 const output = await $.process.run([executable,"integration","describe"], {
60 stdin:JSON.stringify({session_id:sessionId,agent:sessionId,project_path:cwd}),timeoutMs:5000,
61 });
62 if (output.exitCode === 0) state = ownerState(output.stdout);
63 }
64 } catch { /* Human recovery stays available while tools remain guarded. */ }
65 if (state === "disabled") {
66 $.ui.status("Signet-eval: disabled");
67 return next(e);
68 }
69 $.ui.status(state === "active" ? "Signet-eval: policy + output redaction" : blocked(state));
70 // The sanitizer needs no vault and can still work during a policy outage.
71 let text = e.text;
72 let sanitized = false;
73 try {
74 const output = await $.process.run([executable,"integration","redact"], {stdin:JSON.stringify({value:e.text}),timeoutMs:5000});
75 if (output.exitCode === 0) {
76 const response = JSON.parse(output.stdout);
77 if (response?.protocol_version === 1 && response.owner === "signet-eval" && response.sanitizer_revision === "signet-redaction-v1" && typeof response.value === "string") {
78 text = response.value;
79 sanitized = true;
80 }
81 }
82 } catch { /* Preserve the recovery prompt with an explicit warning below. */ }
83 if (!sanitized) $.ui.status(`Signet-eval: prompt_sanitizer_unavailable. This prompt will be sent without Signet redaction. ${state === "active" ? "Tool calls will recheck policy." : blocked(state)}`);
84 return next({...e,text});
85 });
86 on("classic.PreToolUse", async ($, e, next) => {
87 let state = "scope_unavailable";
88 let sessionId = "";
89 let cwd = "";
90 try {
91 sessionId = await $.session.id();
92 cwd = await $.session.cwd();
93 if (sessionId && cwd) {
94 state = "owner_unavailable";
95 const output = await $.process.run([executable,"integration","describe"], {
96 stdin:JSON.stringify({session_id:sessionId,agent:sessionId,project_path:cwd}),timeoutMs:5000,
97 });
98 if (output.exitCode === 0) state = ownerState(output.stdout);
99 }
100 } catch { /* No tool effect has run. */ }
101 if (state === "disabled") return next(e);
102 if (state !== "active") return {deny:blocked(state)};
103 let context = "";
104 let failure = "policy_owner_unavailable";
105 try {
106 const {tool, tool_use_id, ...tool_input} = e;
107 const output = await $.process.run([executable], {
108 stdin:JSON.stringify({hook_event_name:"PreToolUse",tool_name:tool,tool_input,session_id:sessionId,cwd}),timeoutMs:10000,
109 });
110 if (output.exitCode !== 0) return {deny:blocked(failure)};
111 failure = "invalid_policy_response";
112 const decision = JSON.parse(output.stdout)?.hookSpecificOutput;
113 if (decision?.permissionDecision === "deny") return {deny:typeof decision.permissionDecisionReason === "string" ? decision.permissionDecisionReason : "Signet-eval: policy_denied. The selected policy denied this tool call."};
114 if (decision?.permissionDecision === "ask") return {ask:typeof decision.permissionDecisionReason === "string" ? decision.permissionDecisionReason : "Signet-eval: policy_approval_required. The selected policy requires human approval."};
115 if (decision?.permissionDecision !== "allow") return {deny:blocked(failure)};
116 if (typeof decision.additionalContext === "string") context = decision.additionalContext;
117 } catch { return {deny:blocked(failure)}; }
118 // An engine allow does not grant host permission or replace host failures.
119 const result = await next(e);
120 return context ? {...result,additionalContext:[...(result.additionalContext ?? []),context]} : result;
121 });
122 on("tool.call", async ($, e, next) => {
123 let state = "scope_unavailable";
124 try {
125 const sessionId = await $.session.id();
126 const cwd = await $.session.cwd();
127 if (sessionId && cwd) {
128 state = "owner_unavailable";
129 const output = await $.process.run([executable,"integration","describe"], {
130 stdin:JSON.stringify({session_id:sessionId,agent:sessionId,project_path:cwd}),timeoutMs:5000,
131 });
132 if (output.exitCode === 0) state = ownerState(output.stdout);
133 }
134 } catch { /* No tool effect has run. */ }
135 if (state === "disabled") return next(e);
136 if (state !== "active") return {deny:blocked(state)};
137 // Keep the host invocation outside the owner/sanitizer error handlers.
138 const result = await next(e);
139 let failure = "output_sanitizer_unavailable";
140 try {
141 const sanitized = await $.process.run([executable,"integration","redact"], {stdin:JSON.stringify({value:result}),timeoutMs:5000});
142 if (sanitized.exitCode !== 0) return {deny:"Signet-eval: output_sanitizer_unavailable. Tool ran, but its result was withheld. Do not retry the tool without checking its effects."};
143 failure = "invalid_sanitizer_response";
144 const output = JSON.parse(sanitized.stdout);
145 if (output?.protocol_version !== 1 || output.owner !== "signet-eval" || output.sanitizer_revision !== "signet-redaction-v1" || !output.value || typeof output.value !== "object" || Array.isArray(output.value)) {
146 return {deny:"Signet-eval: invalid_sanitizer_response. Tool ran, but its result was withheld. Do not retry the tool without checking its effects."};
147 }
148 const safe = output.value;
149 if (typeof safe.deny === "string") return {deny:safe.deny};
150 // Fresh builtin answers are output-schema-validated even after core failed.
151 // Use the host's error channel instead of treating error text as a typed result.
152 if (safe.deny !== undefined || safe.isError || safe.result === undefined) return {deny:typeof safe.text === "string" ? safe.text : "Signet-eval: tool_result_unavailable. The host returned without a successful tool result; inspect its effects before retrying."};
153 return {result:safe.result,...(safe.context ? {context:safe.context} : {})};
154 } catch { return {deny:`Signet-eval: ${failure}. Tool ran, but its result was withheld. Do not retry the tool without checking its effects.`}; }
155 });
156};
157