mirror of
https://github.com/openai/codex.git
synced 2026-11-03 08:22:30 +00:00
hook trust metadata and enforcement (#20321)
# Why We want shared hook trust that both the app and the TUI can build on, but the metadata is only useful if runtime behavior agrees with it. This PR adds a single backend trust model for hooks so unmanaged hooks cannot run until the current definition has been reviewed, while managed hooks remain runnable and non-configurable. # What - persist `trusted_hash` alongside hook state in `config.toml` - expose `currentHash` and derived `trustStatus` through `hooks/list` - derive trust from normalized hook definitions so equivalent hooks from `config.toml` and `hooks.json` share the same trust identity - gate unmanaged hooks on trust before they enter the runnable handler set # Reviewer Notes - key file to review is `codex-rs/hooks/src/engine/discovery.rs` - the only **core** change is schema related
This commit is contained in:
1 parent
78421face0
commit
0452dca986
44 files changed
+1659
-383
No files matched your search
@@ -5,6 +5,7 @@
|
||||
//! the main event loop remains single-threaded.
|
||||
|
||||
use super::*;
|
||||
use codex_app_server_protocol::HookTrustStatus;
|
||||
use codex_app_server_protocol::MarketplaceAddParams;
|
||||
use codex_app_server_protocol::MarketplaceAddResponse;
|
||||
use codex_app_server_protocol::MarketplaceRemoveParams;
|
||||
@@ -88,6 +89,47 @@ impl App {
|
||||
});
|
||||
}
|
||||
|
||||
/// Emits the initial hook review warning without delaying the first interactive frame.
|
||||
pub(super) fn refresh_startup_hooks(&mut self, app_server: &AppServerSession) {
|
||||
let request_handle = app_server.request_handle();
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
let cwd = self.config.cwd.to_path_buf();
|
||||
tokio::spawn(async move {
|
||||
let result = fetch_hooks_list(request_handle, cwd.clone()).await;
|
||||
let response = match result {
|
||||
Ok(response) => response,
|
||||
Err(err) => {
|
||||
tracing::warn!("failed to load startup hook review state: {err:#}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let hooks_needing_review = response
|
||||
.data
|
||||
.into_iter()
|
||||
.find(|entry| entry.cwd.as_path() == cwd.as_path())
|
||||
.map(|entry| {
|
||||
entry
|
||||
.hooks
|
||||
.into_iter()
|
||||
.filter(|hook| {
|
||||
matches!(
|
||||
hook.trust_status,
|
||||
HookTrustStatus::Untrusted | HookTrustStatus::Modified
|
||||
)
|
||||
})
|
||||
.count()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if let Some(message) =
|
||||
startup_prompts::hooks_needing_review_warning(hooks_needing_review)
|
||||
{
|
||||
app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_warning_event(message),
|
||||
)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn fetch_plugins_list(&mut self, app_server: &AppServerSession, cwd: PathBuf) {
|
||||
let request_handle = app_server.request_handle();
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
@@ -322,6 +364,23 @@ impl App {
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn trust_hook(
|
||||
&mut self,
|
||||
app_server: &AppServerSession,
|
||||
key: String,
|
||||
current_hash: String,
|
||||
) {
|
||||
let request_handle = app_server.request_handle();
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = write_hook_trust(request_handle, key, current_hash)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|err| format!("Failed to trust hook: {err}"));
|
||||
app_event_tx.send(AppEvent::HookTrusted { result });
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn refresh_plugin_mentions(&mut self) {
|
||||
let config = self.config.clone();
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
@@ -805,6 +864,35 @@ pub(super) async fn write_hook_enabled(
|
||||
.wrap_err("config/batchWrite failed while updating hook enablement in TUI")
|
||||
}
|
||||
|
||||
pub(super) async fn write_hook_trust(
|
||||
request_handle: AppServerRequestHandle,
|
||||
key: String,
|
||||
current_hash: String,
|
||||
) -> Result<ConfigWriteResponse> {
|
||||
let request_id = RequestId::String(format!("hooks-config-write-{}", Uuid::new_v4()));
|
||||
let value = serde_json::json!({
|
||||
key: {
|
||||
"trusted_hash": current_hash,
|
||||
}
|
||||
});
|
||||
request_handle
|
||||
.request_typed(ClientRequest::ConfigBatchWrite {
|
||||
request_id,
|
||||
params: ConfigBatchWriteParams {
|
||||
edits: vec![codex_app_server_protocol::ConfigEdit {
|
||||
key_path: "hooks.state".to_string(),
|
||||
value,
|
||||
merge_strategy: MergeStrategy::Upsert,
|
||||
}],
|
||||
file_path: None,
|
||||
expected_version: None,
|
||||
reload_user_config: true,
|
||||
},
|
||||
})
|
||||
.await
|
||||
.wrap_err("config/batchWrite failed while updating hook trust in TUI")
|
||||
}
|
||||
|
||||
pub(super) fn build_feedback_upload_params(
|
||||
origin_thread_id: Option<ThreadId>,
|
||||
rollout_path: Option<PathBuf>,
|
||||
|
||||
@@ -1699,6 +1699,9 @@ impl App {
|
||||
AppEvent::SetHookEnabled { key, enabled } => {
|
||||
self.set_hook_enabled(app_server, key, enabled);
|
||||
}
|
||||
AppEvent::TrustHook { key, current_hash } => {
|
||||
self.trust_hook(app_server, key, current_hash);
|
||||
}
|
||||
AppEvent::HookEnabledSet {
|
||||
key,
|
||||
enabled,
|
||||
@@ -1723,6 +1726,11 @@ impl App {
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::HookTrusted { result } => {
|
||||
if let Err(err) = result {
|
||||
self.chat_widget.add_error_message(err);
|
||||
}
|
||||
}
|
||||
AppEvent::OpenPermissionsPopup => {
|
||||
self.chat_widget.open_permissions_popup();
|
||||
}
|
||||
|
||||
@@ -77,6 +77,16 @@ pub(super) fn emit_system_bwrap_warning(app_event_tx: &AppEventSender, config: &
|
||||
)));
|
||||
}
|
||||
|
||||
pub(super) fn hooks_needing_review_warning(count: usize) -> Option<String> {
|
||||
match count {
|
||||
0 => None,
|
||||
1 => Some("1 hook needs review before it can run. Open /hooks to review it.".to_string()),
|
||||
count => Some(format!(
|
||||
"{count} hooks need review before they can run. Open /hooks to review them."
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn should_show_model_migration_prompt(
|
||||
current_model: &str,
|
||||
target_model: &str,
|
||||
|
||||
@@ -299,6 +299,17 @@ async fn ignore_same_thread_resume_allows_reattaching_displayed_inactive_thread(
|
||||
assert!(app.transcript_cells.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hooks_needing_review_startup_warning_snapshot() {
|
||||
let message = startup_prompts::hooks_needing_review_warning(/*count*/ 2)
|
||||
.expect("review-needed hooks should produce a startup warning");
|
||||
let rendered = lines_to_single_string(
|
||||
&history_cell::new_warning_event(message).display_lines(/*width*/ 80),
|
||||
);
|
||||
|
||||
assert_app_snapshot!("hooks_needing_review_startup_warning", rendered);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enqueue_primary_thread_session_replays_buffered_approval_after_attach() -> Result<()> {
|
||||
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||||
|
||||
Reference in new issue
Block a user