Bind plugin hook approval to plugin contents (#54619)

## Why

A plugin's scripts can change without changing its hook declaration. Declaration-only approval therefore lets changed plugin code run under an existing hook approval.

## What changed

- Fingerprint plugin bundle contents and include the content hash in the approval value returned by `hooks/list`. Persist it as `trusted_content_hash`, keeping `trusted_hash` compatible with older clients.
- Verify contents before each command or MCP hook execution, within the hook's timeout, and require renewed approval when approved contents change.
- Migrate legacy declaration-only approvals to a shared, immutable content baseline per plugin and config home.
- Exclude generated Python bytecode, `.venv`, and `.egg-info` entries from fingerprints. Run app-server hook listing on blocking tasks.

## Testing

Extend integration coverage for plugin upgrades and rollbacks requiring approval, same-turn script changes preventing further hook execution, shared legacy approval baselines, and generated Python artifacts preserving trust.

GitOrigin-RevId: faaf7658210d8adb2534ddd614c72ec993920840
This commit is contained in:
jif
2026-10-16 14:38:19 +00:00
committed by copyberry
parent 4eb4a65407
commit 2bf2bdf82c
31 changed files with 652 additions and 115 deletions
@@ -112,6 +112,7 @@ async fn hook_shell_startup_does_not_stop_on_controlling_terminal() {
)]);
let handler = ConfiguredHandler {
builtin: false,
plugin_trust: None,
event_name: HookEventName::SessionStart,
matcher: None,
timeout_sec: 2,
@@ -216,6 +217,7 @@ async fn cmd_shell_runs_quoted_hook_command_path() {
let env = HashMap::new();
let handler = ConfiguredHandler {
builtin: false,
plugin_trust: None,
event_name: HookEventName::SessionStart,
matcher: None,
timeout_sec: 10,
@@ -266,6 +268,7 @@ async fn fast_exiting_hook_preserves_stdout_when_stdin_is_not_consumed() {
let env = HashMap::new();
let handler = ConfiguredHandler {
builtin: false,
plugin_trust: None,
event_name: HookEventName::SessionStart,
matcher: None,
timeout_sec: 10,
@@ -354,6 +357,7 @@ async fn command_hook_does_not_expose_configured_noise_auth_token() {
]);
let handler = ConfiguredHandler {
builtin: false,
plugin_trust: None,
event_name: HookEventName::SessionStart,
matcher: None,
timeout_sec: 10,
@@ -492,6 +496,7 @@ fn write_handler(temp: &TempDir, source: &str) -> ConfiguredHandler {
std::fs::write(&script_path, source).expect("write async test hook");
ConfiguredHandler {
builtin: false,
plugin_trust: None,
event_name: HookEventName::UserPromptSubmit,
matcher: None,
timeout_sec: 10,