fix(cli): hook enforcement follow-up — every preflight entrance, both-scope Pi carrier, string-safe Codex feature flag - #1407
Conversation
… carrier counts from both scopes, and the Codex feature flag is parsed TOML The preflight now also runs before filtered add reconciliation and the TUI inline update's prune pass. A globally deployed pi-hooks backs a project hook, as Pi loads both scopes. The hooks feature flag comes from a real TOML parse, so the same lines inside a multiline string decide nothing. Claude-Session: https://claude.ai/code/session_012epxJEzGqT7q3qcFhdZUt5
|
ⓘ Qodo reviews are paused because your trial has ended. Ask your workspace admin to add credits to resume reviews. Manage billing |
ApprovabilityVerdict: Needs human review This PR introduces behavioral changes beyond simple fixes: enabling global Pi carriers to back project hooks, changing Codex config parsing from line-based to proper TOML, and adding new preflight validation that can refuse operations. While well-tested, these runtime behavior changes warrant human review. You can customize Macroscope's approvability policy. Learn more. |
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
This PR tightens hook contract enforcement and update atomicity across add, refresh, and TUI inline updates, while improving Codex config feature detection to avoid false positives from embedded examples.
Changes:
- Add preflight checks to refuse uncovered hook events before any mutation (including prune/reconcile paths).
- Switch Codex hooks feature detection to a real TOML parse and harden line-based merges against multiline-string decoys.
- Expand test coverage for the above regressions (Pi carrier scope, Codex feature decoys, atomic filtered add/inline update).
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| tools/size-ratchet-baseline.tsv | Updates size baselines for the touched CLI modules/tests. |
| cli/tests/hook_contract.rs | Adds regression tests for Pi carrier enforcement and contract/atomicity edge cases. |
| cli/src/tui/disk_mutations/tests.rs | Adds inline-update regression tests around uncovered events and agent regeneration behavior. |
| cli/src/tui/disk_mutations.rs | Refuses uncovered hook events before the prune pass during inline updates. |
| cli/src/installer/hooks/enforcement.rs | Treats globally installed pi-hooks as backing project-scope hooks for enforcement summaries. |
| cli/src/installer/hooks.rs | Uses TOML parsing for feature detection and adds multiline-string skipping in line merges. |
| cli/src/commands/refresh.rs | Exposes uncovered_hook_event and ignores name filters when regenerating agents (since they consume all hooks). |
| cli/src/commands/add.rs | Adds uncovered-event preflight before writing any new items and before reconciliation touches agents. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3018386002
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "Codex (@codex) review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "Codex (@codex) address that feedback".
…check runs once per scope Claude-Session: https://claude.ai/code/session_012epxJEzGqT7q3qcFhdZUt5
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.
Suppressed comments (2)
cli/src/installer/hooks.rs:802
- When
stateisNone, this scanner will treat"""/'''appearing inside TOML comments (after#) as starting a multiline string, which can cause subsequent real TOML structure lines to be skipped and preventmerge_codex_hooks_feature/codex_features_statefrom applying correct updates. Consider ignoring/comment-stripping the portion after#when outside a multiline string, so delimiters in comments can’t togglestring_state.
fn advance_toml_string_state(line: &str, mut state: Option<&'static str>) -> Option<&'static str> {
let mut rest = line;
loop {
match state {
Some(delimiter) => match rest.find(delimiter) {
Some(idx) => {
rest = &rest[idx + delimiter.len()..];
state = None;
}
None => return state,
},
None => {
let double = rest.find("\"\"\"");
let single = rest.find("'''");
let (idx, delimiter) = match (double, single) {
(None, None) => return None,
(Some(d), None) => (d, "\"\"\""),
(None, Some(s)) => (s, "'''"),
(Some(d), Some(s)) if d < s => (d, "\"\"\""),
(_, Some(s)) => (s, "'''"),
};
rest = &rest[idx + delimiter.len()..];
state = Some(delimiter);
}
}
}
}
cli/src/installer/hooks/enforcement.rs:133
- This can call
is_pi_extension_operationaltwice for the project-scope case (global == false). If that function hits the filesystem (likely), consider caching the result(s) in locals (e.g., computeproject_operationalandglobal_operationalonce) to avoid duplicate IO during summary generation.
crate::pi_extension::is_pi_extension_operational(PI_HOOKS_PACKAGE, global)
|| (!global
&& crate::pi_extension::is_pi_extension_operational(PI_HOOKS_PACKAGE, true));
…r; a hook batch preflights every locked hook before pruning Claude-Session: https://claude.ai/code/session_012epxJEzGqT7q3qcFhdZUt5
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
cli/src/commands/add.rs:2382
- The new preflight silently treats a lock-file load failure as “empty lock” via
unwrap_or_default(), which can skip the uncovered-hook-event refusal and allow theaddto proceed in a potentially inconsistent state. Since this preflight is intended to enforce atomic safety, it should not ignore lock parse/IO errors; instead, propagate the error (or fail the add with a clear message) so the user can repair the lock rather than bypassing this safety check.
let lock = LockFile::load(&config::lock_file_path(global)).unwrap_or_default();
let records = crate::refresh_sources::resolve_source_records_without_update(&lock);
let all_hooks = crate::refresh_sources::all_source_hooks(
&crate::refresh_sources::load_refresh_sources(&records.sources),
);
if let Some((name, error)) =
crate::commands::refresh::uncovered_hook_event(&lock, &all_hooks, None)
{
anyhow::bail!("hook {name}: {error}");
}
cli/src/installer/hooks.rs:776
- The new multiline-string tracking is covered for full-line comment and multiline-string “decoy” cases, but there isn’t coverage for delimiters appearing in inline comments (e.g.,
key = 1 # """) or inside regular string literals (e.g.,key = "\"\"\""). Adding targeted tests for these cases would prevent regressions whereadvance_toml_string_stateincorrectly flips state and causes the feature writer/state detection to ignore real TOML structure.
fn advance_toml_string_state(line: &str, mut state: Option<&'static str>) -> Option<&'static str> {
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5a17a586f3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "Codex (@codex) review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "Codex (@codex) address that feedback".
|
Merge queue ejected this PR ( Ejecting merge-group run: https://github.com/vanillagreencom/vstack/actions/runs/32002645006 ( Failing job(s): No usable same-named comparison on the PR head (checks absent, skipped, or still running) — no flake-vs-genuine call is available; inspect the failing run before re-arming. Automated by merge-queue-ejection-alert (VST-196). This alert never re-arms auto-merge. |
… a quoted delimiter opens nothing Claude-Session: https://claude.ai/code/session_012epxJEzGqT7q3qcFhdZUt5
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.
Suppressed comments (3)
cli/src/installer/hooks.rs:828
- The multiline-string scanner will treat any occurrence of
"""as a closing delimiter while inside a basic multiline string. In TOML, it’s valid to include sequences of quotes where the first quote is escaped (e.g.\"""), which should not terminate the multiline string; this implementation can incorrectly exit “string mode” early and then misinterpret subsequent lines as TOML structure. Consider either (a) tracking escapes while inSome("\"\"\"")and only treating"""as a terminator when it’s not escaped (accounting for an odd/even run of backslashes before the first quote), or (b) using a TOML-aware editor/parser for this transformation to avoid implementing string lexing rules manually.
/// Multiline-string state for a line-oriented TOML walk: `None` outside,
/// `Some(delimiter)` inside. Escapes are not tracked — TOML forbids an
/// unescaped `"""` inside a basic multiline string, and the configs this
/// walks are ones this tool and its users write.
fn advance_toml_string_state(line: &str, mut state: Option<&'static str>) -> Option<&'static str> {
fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack
.windows(needle.len())
.position(|window| window == needle)
}
let bytes = line.as_bytes();
let mut i = 0;
// A single-line string or a comment is content: a delimiter inside one
// opens nothing. Escapes count only in basic (double-quoted) strings.
let mut single_line: Option<u8> = None;
while i < bytes.len() {
match state {
Some(delimiter) => match find_bytes(&bytes[i..], delimiter.as_bytes()) {
Some(idx) => {
i += idx + delimiter.len();
state = None;
}
None => return state,
},
None => match single_line {
Some(quote) => {
if quote == b'"' && bytes[i] == b'\\' {
i += 2;
continue;
}
if bytes[i] == quote {
single_line = None;
}
i += 1;
}
None => {
if bytes[i..].starts_with(b"\"\"\"") {
state = Some("\"\"\"");
i += 3;
} else if bytes[i..].starts_with(b"'''") {
state = Some("'''");
i += 3;
} else {
match bytes[i] {
b'#' => return None,
b'"' => single_line = Some(b'"'),
b'\'' => single_line = Some(b'\''),
_ => {}
}
i += 1;
}
}
},
}
}
state
}
cli/src/commands/add.rs:2383
- The uncovered-hook-event preflight is duplicated (same
uncovered_hook_eventcall pattern and error wrapping) in two places. It would be more maintainable to extract this into a small helper (e.g., “validate_no_uncovered_hook_events(lock, global, sources…)”) so future adjustments to preflight behavior or error formatting don’t risk diverging between add-time checking and reconcile-time checking.
// Reconciliation later renders every agent with every locked hook, so an
// already-installed hook whose source left the contract fails the add
// here, before this add's own items are written.
{
let lock = LockFile::load(&config::lock_file_path(global)).unwrap_or_default();
let records = crate::refresh_sources::resolve_source_records_without_update(&lock);
let all_hooks = crate::refresh_sources::all_source_hooks(
&crate::refresh_sources::load_refresh_sources(&records.sources),
);
if let Some((name, error)) =
crate::commands::refresh::uncovered_hook_event(&lock, &all_hooks, None)
{
anyhow::bail!("hook {name}: {error}");
}
}
cli/src/commands/add.rs:3059
- The uncovered-hook-event preflight is duplicated (same
uncovered_hook_eventcall pattern and error wrapping) in two places. It would be more maintainable to extract this into a small helper (e.g., “validate_no_uncovered_hook_events(lock, global, sources…)”) so future adjustments to preflight behavior or error formatting don’t risk diverging between add-time checking and reconcile-time checking.
// Reconciliation regenerates every agent from every locked hook, so a
// definition install would refuse fails it before any agent is touched.
if let Some((name, error)) =
crate::commands::refresh::uncovered_hook_event(&lock, &all_hooks, None)
{
anyhow::bail!("hook {name}: {error}");
}
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fa8f6b1487
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "Codex (@codex) review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "Codex (@codex) address that feedback".
|
Merge queue ejected this PR ( Ejecting merge-group run: https://github.com/vanillagreencom/vstack/actions/runs/32003095181 ( Failing job(s): No usable same-named comparison on the PR head (checks absent, skipped, or still running) — no flake-vs-genuine call is available; inspect the failing run before re-arming. Automated by merge-queue-ejection-alert (VST-196). This alert never re-arms auto-merge. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
cli/src/commands/add.rs:2386
unwrap_or_default()suppresses lockfile read/parse errors, which can cause this new uncovered-event preflight to be silently skipped and allowaddto proceed in situations where the lock is present but unreadable/corrupt. Prefer propagating the error (or failing the add) so behavior is deterministic and the safety check can’t be bypassed due to an I/O/formatting problem.
let lock = LockFile::load(&config::lock_file_path(global)).unwrap_or_default();
let reconciles_agents = !selected_agents.is_empty()
|| lock
.entries
.values()
.any(|entry| entry.kind == config::ItemKind::Agent);
if reconciles_agents {
let records = crate::refresh_sources::resolve_source_records_without_update(&lock);
let all_hooks = crate::refresh_sources::all_source_hooks(
&crate::refresh_sources::load_refresh_sources(&records.sources),
);
if let Some((name, error)) =
cli/src/installer/hooks.rs:776
- The new multiline-string tracking is a correctness boundary for
merge_codex_hooks_feature/codex_features_stateand currently lacks a regression test for escaped delimiter sequences inside basic multiline strings (e.g., content that includes\\\"\"\"). Adding a focused test case for this scenario would help ensure the feature-writer doesn’t mis-detect string termination and accidentally rewrite real structure below.
fn advance_toml_string_state(line: &str, mut state: Option<&'static str>) -> Option<&'static str> {
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c7602de6b0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "Codex (@codex) review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "Codex (@codex) address that feedback".
…t follow-up origin/main landed e66ac8f (#1407) while this PR sat in the merge queue — the follow-up to VST-283 that this branch reconciled with one round ago, landing in the same files. Resolved as one three-way merge over the final states, no rebase. Two conflicts: `cli/src/installer/hooks.rs` and `tools/size-ratchet-baseline.tsv`. MAIN'S CAPABILITY, TAKEN WHOLE - `add`'s uncovered-event preflight now guards every entrance: before this add's own items are written whenever anything will reconcile agents, and again inside `reconcile_agents` before any agent is touched. An add that reconciles no agent is not held hostage by a hook nothing consumes. - `refresh::hook_preflight_filter` is the one home for the filter rule — regenerating ANY agent consumes every locked hook, so a filter that admits an agent is ignored for hooks. Refresh and the TUI's inline update both call it; `uncovered_hook_event` is `pub(crate)` for those callers. - The TUI inline update preflights before its prune pass, and a hook batch preflights every locked hook, because the batch later expands to every agent. - `enforcement::summary` counts a globally deployed pi-hooks carrier as backing a project hook, because Pi loads packages from both scopes. WHERE THE TWO OVERLAPPED, ONE IMPLEMENTATION SURVIVED Main's "string-safe Codex feature flag" and this branch's parser-backed Codex config are one fix arrived at twice. This branch's survived in both halves, because it is the strict superset and main's rewrite was converging on it — a real parse instead of a scanner taught to skip strings. - Reader: `hooks/codex.rs::codex_hooks_feature_state`, a full `toml_edit::DocumentMut` parse, and the same parse the writer makes. Main's incoming `codex_hooks_feature_enabled` — rewritten in e66ac8f from a line walker to a `toml::Value` parse — is deleted. It answers the same question minus `CodexHooksFeature::Unreadable`, the state `check` and `verify` report by name. The previous reconciliation deleted it once; this merge does not resurrect it. - Writer: `merge_codex_hooks_feature` edits the parsed document through `toml_edit`, so a `[features]` header or a `codex_hooks = …` line inside a string is content and never structure. Main's incoming `advance_toml_string_state` — a hand-rolled scanner for multiline strings, single-line strings and comments, threaded through `merge_codex_hooks_feature` and `codex_features_state` — is deleted along with the line walkers it existed to make safe: `codex_features_state`, `CodexFeaturesState`, `DeprecatedCodexHooksFeature`, `is_toml_table_header`, `toml_assignment_key` and `toml_assignment_value`. - `codex_hook_prose_present` fell inside the same conflict hull and is deleted again in favour of `codex_hook_prose`. The proof is main's own tests, taken unmodified and passing against this branch's implementation: `a_features_example_inside_a_string_does_not_enable_codex_hooks`, `a_reinstall_does_not_corrupt_feature_examples_inside_strings`, `a_comment_showing_a_delimiter_does_not_derail_the_feature_writer` and `a_delimiter_inside_a_single_line_string_does_not_derail_the_feature_writer`. The split stands. All 102 of main's `installer/hooks.rs` lines are Codex config reader/writer, a concern `hooks/codex.rs` already owns, so nothing was reassembled and no seam file grew; the only seam file this merge touches is `hooks/enforcement.rs`, for main's both-scope Pi carrier. TESTS 936 tests, up from this branch's 925 and main's 782 — the exact union of both sets by name. Nothing deleted, nothing renamed, nothing invented: 929 pass, 7 ignored (840 lib + 2 add_noninteractive_tty + 15 check_contract + 7 growth_guards_git_hooks + 38 hook_contract + 24 hook_lifecycle + 3 source_refusal_warning), against this branch's 918/7 and main's 775/7 from a clean checkout. The union is taken on test names: 77 of main's tests differ from this branch only in module path, from this branch's earlier file splits, and are present under their new paths. The 11 names this merge adds are main's, all passing. Eight in `cli/tests/hook_contract.rs` — the four above plus `a_filtered_add_refuses_before_installing_anything`, `a_filtered_add_refuses_to_reconcile_from_an_uncovered_event`, `an_add_with_no_agents_ignores_an_uncovered_hook_it_cannot_consume` and `a_globally_installed_pi_carrier_backs_a_project_hook` — and three in `tui/disk_mutations/tests.rs`: `inline_update_refuses_an_uncovered_event_before_pruning`, `inline_update_of_an_agent_refuses_an_unselected_uncovered_hook` and `inline_update_of_a_hook_refuses_an_unselected_uncovered_hook_before_pruning`. The same 7 tests are ignored on both sides. One incoming line changed, in main's new test: `&[installed_rogue.clone()]` to `std::slice::from_ref(&installed_rogue)`, which `clippy::cloned_ref_to_slice_refs` rejects under clippy 1.96 — a clean checkout of main fails the same lint. The size-ratchet baseline is re-derived from the merged tree, not picked from either side; `--update` finds nothing left to tighten. `refresh.rs 1680`, `disk_mutations/tests.rs 1717` and `hook_contract.rs 1548` stay exactly as main set them, because the merged files measure exactly that. `add.rs` is the one row the merge made wrong on both sides — this branch's 1740 plus main's 31 lines is 1771. Rows for the files this branch split (`agent.rs`, `installer/hooks.rs`, `config/tests.rs` and the rest) stay as this branch left them. Validated: cargo test, cargo clippy --all-targets -D warnings, cargo fmt --check, size-ratchet (1509 files, threshold 1000), preflight --base origin/main (clean, 74 files), cli/scripts/integration-check.sh (29), skills/orch/tests/run-all.sh (57 across 37 files), hooks/tests (50 + 55 + 55 + 43) and the pi-hooks bun suite (93). Claude-Session: https://claude.ai/code/session_01MmBDMLngdEbAcgEuPYcBmi
Phase 6 of the v1-parity plan. Pi has no per-hook artifact; the pi-hooks extension package hosts native listeners, and hook content rides in the registry vstack now renders beside them — hooks/<name>.sh plus hooks.json, keyed by the listeners Pi actually fires (tool call, tool result, turn end, session start). An event outside that map installs nothing on Pi and says so; a stale advisory claim is worse than none. The capability row moves from unsupported to enforced-through-the-carrier, and the surfaces that label an installation read carrier reality: pi_ext::carrier checks every settings layer Pi loads, so a project-installed hook with only a global carrier is enforced (the v1 #1407 lesson, arriving as a test), and a scope with no carrier anywhere gets the downgrade said per item with the fix named. The session-start drift report rides the same mechanism — the drift hook now declares for Pi too, same script, same kill-switch, and a resumed or reloaded session never repeats it. Claude-Session: https://claude.ai/code/session_01GwNAzNp5ZMNLvwsJJdyHre
Follow-up to #1401, carrying the review findings accepted there plus what the pre-push cross-model review surfaced. Every fix has a pin that ran red first.
addbefore the first mutation (an installed hook whose source left the contract previously let the add write its items, then die in reconciliation) and the TUI inline update before ITS prune. When a filter admits any agent, the hook preflight ignores the filter — regenerating an agent consumes every locked hook, selected or not.@vanillagreen/pi-hooksnow backs a project hook instead of readingunsupported.[features]block inside a multiline string decides nothing; an unparsable file enables nothing), and the line-oriented feature writer now tracks multiline-string state, so a reinstall can no longer rewrite or delete text inside a user'sdeveloper_instructions.Declined from the same review round, consistent with #1401: capability-resolved Pi support (the
harnesses:allowlist is the documented per-port contract).Proof:
tools/validate-changedall lanes green,preflight --base origin/mainclean,size-ratchetOK, full cargo suite green; second-opinion review ran pre-push and its three actionable blockers are the fixes above.https://claude.ai/code/session_012epxJEzGqT7q3qcFhdZUt5