Skip to content

feat(opencode): upgrade plugin to full hook-guard port (search + read + strict) - #3660

Open
hybridindie wants to merge 1 commit into
Graphify-Labs:v8from
hybridindie:feat/opencode-hook-guard-port
Open

hybridindie wants to merge 1 commit into
Graphify-Labs:v8from
hybridindie:feat/opencode-hook-guard-port

Conversation

@hybridindie

Copy link
Copy Markdown

Summary

The shipped OpenCode plugin (_OPENCODE_PLUGIN_JS) is a once-per-session bash reminder. The Claude Code harness meanwhile gets the full guard pair (graphify hook-guard <search|read>: executed-token search analysis, source-extension/in-project/staleness gating for reads, opt-in strict mode). This PR closes that gap by porting the Python guards 1:1 to the OpenCode plugin surface, so an agent in OpenCode gets the same graph-first orientation as an agent in Claude Code.

Built and verified in a real OpenCode session (godot-mcp, 180-tool MCP server, graphify-out/ with 164 files) before extraction — the decision logic is line-for-line the same as cli.py:_run_hook_guard.

What the plugin now does

Transport adaptation

opencode has no PreToolUse additionalContext channel, so the same guard decisions ride two transports:

Test plan

  • New tests/test_opencode_plugin_hook.py extracts the plugin body, loads it in node, and drives tool.execute.before/tool.execute.after:
    • the same parametrized fire/quiet cases as test_hook_guard_token_match.py (18 fire, 12 quiet, 4 heredoc/quoted cases) so the two harnesses stay in lockstep
    • orientation-echo once-per-session budget, read-guard gating, staleness softening, strict once-per-session (incl. new session re-arm), no-graph no-op, and shell-inertness of the echo
    • one test pinned to a genuine port bug found and fixed while writing it: stripHeredocs originally reset the regex lastIndex to 0, re-matching the same opener and eating the line after the terminator (a real search after a heredoc stopped firing); now mirrors cli.py's advancing search(text, nl_idx + 1)
  • tests/test_install.py: two shape tests updated for the new echo construction (single-quoted echo '…' ;, and the no-backtick check now scopes to the echoed _ECHO constants — the tool.execute.after nudges ride the tool result and are never shell-interpreted, so they may contain backticks)
  • Full pytest -q on v8 @ c7ec108 (shallow clone): 5635 passed; the only failures are pre-existing on a clean checkout (skillgen tests need full git history for pinned refs; ambient OLLAMA_* env vars in the shell affect retry tests; a timing-sensitive TS perf test flaked in the full run and passes in isolation). Affected suites — test_install.py (111), test_install_strings.py (7), test_hook_guard.py, test_hook_strict.py, test_search_hook.py, test_read_hook.py, plus the new 38-case file — all green.
  • Plugin behavior additionally verified end-to-end in a live OpenCode session before extraction (13/13 behavioral probes).

Checklist

  • Behavior mirrors cli.py guards with cited issue numbers
  • GRAPHIFY_OUT honored
  • Fails open on any error
  • Shared session-marker + query-stamp files with the Claude hook
  • Tests pin the JS to the Python contract; all affected suites pass

… + strict)

Replaces the once-per-session bash reminder with a JS port of the Claude
Code PreToolUse guard pair (graphify hook-guard <search|read>):

- Search guard: fires on the bash command's executed tokens — heredoc
  bodies and quoted spans dropped, wrappers skipped, git grep / VAR=x
  grep counted (the Graphify-Labs#3121 contract, mirrored from cli.py
  _bash_invokes_search).
- Read/glob guard: source-extension + in-project gating, output-dir
  reads exempt, files newer than graph.json (or a .needs_update marker)
  soften to the stale nudge (the Graphify-Labs#1840 contract).
- Strict mode (GRAPHIFY_HOOK_STRICT=1, GRAPHIFY_HOOK_STRICT_TTL): a
  forceful once-per-session reminder for reads of files the graph
  indexes, suppressed while cache/last_query_stamp is fresh. Shares the
  same hook_sessions markers as the Claude hook, so the once-per-session
  budget spans harnesses. The Claude deny degrades to a strong reminder:
  opencode plugins cannot block a tool call from the before hook.
- Transport: opencode has no PreToolUse additionalContext channel, so
  bash nudges ride a shell-inert echo '…' ; prepend (no backticks, $ or
  single quotes — Graphify-Labs#1413) and read/grep/glob nudges are appended to the
  tool result via tool.execute.after.
- Honors GRAPHIFY_OUT like the git hook gates (Graphify-Labs#3546).
- New tests/test_opencode_plugin_hook.py drives the extracted plugin in
  node with the same parametrized cases as test_hook_guard_token_match,
  plus orientation budget, staleness, strict once-per-session, and
  shell-inertness pins. Updated the two test_install.py shape tests for
  the new echo construction.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 3 advisory finding(s) below merit a look before merge.

Formal verification. 1 change(s) alter behavior, breaking input(s) attached.

Behavior changes: \_match\_cited\_file changes behavior, here is the input that shows it.

The verifier found a concrete input on which \_match\_cited\_file behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"cited":"'\./'","doc\_file":"'\.mdx'","source\_files":"\{1, 2, 3\}"\}, the old code produced None but the new code produces raises AttributeError. Paste that input straight into a regression test.


Graphify review — findings

Ports the OpenCode integration from a once-per-session bash reminder to a full JS reimplementation of graphify's Claude Code hook-guard search/read guards, generated by graphify install. The search guard fires on the bash command's executed tokens (heredoc bodies and quoted spans dropped, wrappers skipped, git grep/VAR=x grep counted), the read/glob guard gates on source-extension, in-project, and staleness checks, and opt-in strict mode (GRAPHIFY_HOOK_STRICT) emits a once-per-session reminder sharing the same hook_sessions markers and last_query_stamp as the Claude hook so the budget spans harnesses. Since opencode lacks a PreToolUse additionalContext channel, bash nudges ride a shell-inert echo '…' ; prepend while read/grep/glob nudges append to the tool result, and the Claude deny degrades to a forceful reminder because tool.execute.before cannot block a call; everything fails open.

Worth a look

  • Stray top-level name breaks test module importtests/test_opencode_plugin_hook.py:309 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Per-session Set grows without evictiongraphify/install.py · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Wrapper parsing misses timeout-wrapped searchesgraphify/install.py:1512 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 757 functions depend on the 525 functions this change touches.

Health — this change adds coupling hotspots:

  • new: dispatch_command() — 2 callers, 125 callees
  • new: codebuddy_install() — 20 callers, 5 callees
  • new: claude_install() — 19 callers, 4 callees
  • new: _copy_skill_file() — 12 callers, 6 callees
  • new: gemini_install() — 10 callers, 7 callees
  • new: claude_uninstall() — 17 callers, 4 callees
  • new: _project_uninstall() — 5 callers, 13 callees
  • new: dispatch_install_cli() — 2 callers, 31 callees
  • …and 14 more — each is listed as a finding

Verification — 757 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 676 function(s) in the blast radius were not formally verified this run

Test selection

Test selection

284 of 284 test file(s) selected (100%) via static blast radius.

Escalated to a full run for safety — the selection is not trustworthy on its own (see below). CI should run the whole suite.

  • tests/test_affected_cli.py — impact, full-run-safety
  • tests/test_affected_member_seed.py — full-run-safety
  • tests/test_agents_platform.py — impact, full-run-safety
  • tests/test_analyze.py — full-run-safety
  • tests/test_anthropic_custom_endpoint.py — full-run-safety
  • tests/test_antigravity_install.py — full-run-safety
  • tests/test_apm_fallback_version.py — full-run-safety
  • tests/test_architecture_doc.py — full-run-safety
  • tests/test_astro_extraction.py — full-run-safety
  • tests/test_astro_import_ids.py — full-run-safety
  • tests/test_atomic_canvas_export.py — full-run-safety
  • tests/test_atomic_version_stamp.py — impact, full-run-safety
  • tests/test_atomic_writes.py — full-run-safety
  • tests/test_backend_env_isolation.py — full-run-safety
  • tests/test_backend_extras.py — full-run-safety
  • tests/test_benchmark.py — full-run-safety
  • tests/test_benchmark_raw_graph.py — full-run-safety
  • tests/test_build.py — full-run-safety
  • tests/test_build_merge_dedup_scope.py — full-run-safety
  • tests/test_build_merge_hyperedges_and_prune.py — full-run-safety
  • tests/test_build_merge_shrink_guard.py — full-run-safety
  • tests/test_builtin_global_type_refs.py — full-run-safety
  • tests/test_cache.py — full-run-safety
  • tests/test_callflow_html.py — full-run-safety
  • tests/test_cargo_introspect.py — full-run-safety
  • tests/test_carried_hyperedge_remap.py — full-run-safety
  • tests/test_case_sensitive_resolution.py — full-run-safety
  • tests/test_charmap_encoding.py — full-run-safety
  • tests/test_chunking.py — full-run-safety
  • tests/test_cjs_module_extension.py — full-run-safety
  • tests/test_claude_cli_backend.py — full-run-safety
  • tests/test_claude_md.py — impact, full-run-safety
  • tests/test_cli_broken_pipe.py — full-run-safety
  • tests/test_cli_export.py — full-run-safety
  • tests/test_cli_help.py — full-run-safety
  • tests/test_cluster.py — full-run-safety
  • tests/test_codebuddy.py — impact, full-run-safety
  • tests/test_community_hub_labels.py — full-run-safety
  • tests/test_community_labels_skill.py — full-run-safety
  • tests/test_confidence.py — full-run-safety
  • tests/test_corrupt_graph_json.py — full-run-safety
  • tests/test_cpp_nested_and_cli.py — full-run-safety
  • tests/test_cpp_objc_cross_file_calls.py — full-run-safety
  • tests/test_cpp_preprocess.py — full-run-safety
  • tests/test_cross_extension_reexport_self_cycle.py — full-run-safety
  • tests/test_cross_language_call_resolution.py — full-run-safety
  • tests/test_cross_repo_external_call_guards.py — full-run-safety
  • tests/test_cross_repo_member_calls.py — full-run-safety
  • tests/test_cross_repo_shared_types.py — full-run-safety
  • tests/test_csharp_call_site_generic_args.py — full-run-safety
  • … and 234 more

non-code file(s) changed (CHANGELOG.md) → running the full suite for safety (a code graph can't see config/fixture/data deps)

changed code file(s) with no mapped test (CHANGELOG.md) — a coverage gap or a missing link — running the full suite rather than only the selected tests

Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.

Formal verification

Behavior changes: \_match\_cited\_file changes behavior, here is the input that shows it.

The verifier found a concrete input on which \_match\_cited\_file behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"cited":"'\./'","doc\_file":"'\.mdx'","source\_files":"\{1, 2, 3\}"\}, the old code produced None but the new code produces raises AttributeError. Paste that input straight into a regression test.

Could not verify: Could not verify build\_from\_json.

The verifier did not have enough to check build\_from\_json, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: no capturable inputs from the test suite; property tier: not verifiable: all 6 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly NameError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify prefix\_graph\_for\_global.

The verifier did not have enough to check prefix\_graph\_for\_global, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: no capturable inputs from the test suite; property tier: not verifiable: all 200 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous

Could not verify: Could not verify dispatch\_command.

The verifier did not have enough to check dispatch\_command, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: no capturable inputs from the test suite; property tier: not verifiable: all 23 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)

Could not verify: Could not verify extract\_elixir.

The verifier did not have enough to check extract\_elixir, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: no capturable inputs from the test suite; property tier: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set

No difference found (not proven): No behavior difference found in \_code\_span\_mention (not a proof).

The verifier ran both versions of \_code\_span\_mention on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify extract\_markdown.

The verifier did not have enough to check extract\_markdown, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: no capturable inputs from the test suite; property tier: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify extract\_rust.

The verifier did not have enough to check extract\_rust, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: no capturable inputs from the test suite; property tier: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set

No difference found (not proven): No behavior difference found in resolve\_markdown\_mentions (not a proof).

The verifier ran both versions of resolve\_markdown\_mentions on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify resolve\_ruby\_member\_calls.

The verifier did not have enough to check resolve\_ruby\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: no capturable inputs from the test suite; property tier: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous

Could not verify: Could not verify \_rebuild\_code.

The verifier did not have enough to check \_rebuild\_code, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: no capturable inputs from the test suite; property tier: parameter `watch_path` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify \_reconcile\_markdown\_links.

The verifier did not have enough to check \_reconcile\_markdown\_links, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: no capturable inputs from the test suite; property tier: parameter `project_root` is annotated `Path` — outside the synthesizable primitive/collection set

· 22 more finding(s) on lines outside this diff (see the check run).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant