no-cli-shim.sh 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596
  1. #!/usr/bin/env bash
  2. # Keep the codegraph CLI out of an eval arm, so the MCP server is the ONLY way
  3. # the agent can reach codegraph. Sourced by run-all.sh and ab-new-vs-baseline.sh.
  4. #
  5. # . "$HARNESS/no-cli-shim.sh"
  6. # cg_no_cli_setup "$OUT" # -> sets $ARM_PATH and $ARM_SETTINGS
  7. # PATH="$ARM_PATH" claude … --settings "$ARM_SETTINGS"
  8. #
  9. # Why this exists, in both harnesses:
  10. #
  11. # with/without (run-all.sh) The without-arm gets an empty MCP config but still
  12. # has Bash, and the target repo carries the .codegraph/ index. Agents find that:
  13. # 14 of 15 without-arm runs in one 7-repo pass ran `codegraph explore` through
  14. # Bash (one via `ls .codegraph && codegraph explore …`), so that arm was
  15. # measuring codegraph-over-CLI, not codegraph-absent.
  16. #
  17. # new/baseline (ab-new-vs-baseline.sh) Both arms are codegraph-on, so a CLI
  18. # call is not a with/without leak — it is an ATTRIBUTION leak, and it breaks all
  19. # three feedback metrics at once. Output that arrives through Bash is charged to
  20. # Bash (understating occupancy), and an explore issued through the CLI is not a
  21. # tool call at all, so it never reaches the sufficiency classifier or the
  22. # allocation parse. A run that shells out silently drops calls from the numbers.
  23. #
  24. # Two layers, because one was not enough:
  25. #
  26. # 1. PATH. The binary usually shares a directory with tools the run needs
  27. # (claude itself lives next to it here), so dropping the whole directory is
  28. # not an option. Substitute an equivalent directory IN PLACE: symlinks to
  29. # every entry except codegraph, keeping PATH order and precedence intact.
  30. # 2. A PreToolUse hook. An agent denied `codegraph` ran
  31. # `find / -maxdepth 4 -iname "*codegraph*"`, found the binary, and invoked it
  32. # by ABSOLUTE PATH — so block the invocation itself. Written into the output
  33. # dir as a run artifact rather than a repo file, same as the MCP configs.
  34. #
  35. # Neither layer is a substitute for the counter: parse-run.mjs flags any Bash
  36. # command that named codegraph, separating attempts it blocked (no output entered
  37. # the window) from calls that RETURNED output. Prevention fails silently the next
  38. # time the binary lands somewhere new; the counter does not.
  39. # Command positions only: `grep codegraph x`, `ls .codegraph` and
  40. # `which codegraph` are looking, not using, and pass through.
  41. CG_CMD_RE='(^|[;&|(]|&&|\|\||\$\(|`)[[:space:]]*([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+)*[A-Za-z0-9_./~-]*codegraph([[:space:]]|$)'
  42. cg_no_cli_setup() {
  43. local out="${1:?cg_no_cli_setup <out-dir>}"
  44. local shim="$out/nocg-bin"
  45. rm -rf "$shim"; mkdir -p "$shim"
  46. local built="" d e
  47. local IFS=:
  48. for d in $PATH; do
  49. [ -n "$d" ] || continue
  50. if [ -x "$d/codegraph" ]; then
  51. for e in "$d"/*; do
  52. [ "$(basename "$e")" = codegraph ] && continue
  53. ln -sf "$e" "$shim/" 2>/dev/null
  54. done
  55. d="$shim"
  56. fi
  57. built="${built:+$built:}$d"
  58. done
  59. unset IFS
  60. ARM_PATH="$built"
  61. if PATH="$ARM_PATH" command -v codegraph >/dev/null 2>&1; then
  62. echo "WARNING: 'codegraph' is still on the arm PATH — runs will be contaminated"
  63. fi
  64. for e in claude node; do
  65. PATH="$ARM_PATH" command -v "$e" >/dev/null || { echo "sanitized PATH lost '$e' — refusing to run"; return 1; }
  66. done
  67. command -v jq >/dev/null || { echo "jq is required for the CLI-block hook — install it or the arms will be contaminated"; return 1; }
  68. cat > "$out/no-cli-hook.sh" <<HOOK
  69. #!/usr/bin/env bash
  70. # Deny Bash invocations of the codegraph CLI so the MCP server stays the A/B's
  71. # single variable. Looking for it is fine; running it is not.
  72. set -uo pipefail
  73. cmd="\$(cat | jq -r '.tool_input.command // empty' 2>/dev/null)"
  74. if printf '%s' "\$cmd" | grep -Eq '$CG_CMD_RE'; then
  75. msg="The codegraph CLI is not available in this session. Answer using the tools you have."
  76. jq -n --arg m "\$msg" '{reason:\$m, hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:\$m}}'
  77. fi
  78. exit 0
  79. HOOK
  80. chmod +x "$out/no-cli-hook.sh"
  81. cat > "$out/hook-settings.json" <<JSON
  82. {"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"bash $out/no-cli-hook.sh"}]}]}}
  83. JSON
  84. ARM_SETTINGS="$out/hook-settings.json"
  85. # Prove the hook denies a real invocation and lets a mere mention through.
  86. cg_no_cli_probe() { printf '{"tool_input":{"command":%s}}' "$2" | bash "$1/no-cli-hook.sh" | grep -c deny; }
  87. [ "$(cg_no_cli_probe "$out" '"/Users/x/.local/bin/codegraph explore \"q\""')" = 1 ] || { echo "hook fails to block an absolute-path invocation"; return 1; }
  88. [ "$(cg_no_cli_probe "$out" '"grep -rn codegraph src/"')" = 0 ] || { echo "hook over-blocks a plain mention"; return 1; }
  89. return 0
  90. }