interlockd 0.1.2__tar.gz → 0.1.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (269) hide show
  1. interlockd-0.1.4/.github/CODEOWNERS +2 -0
  2. {interlockd-0.1.2 → interlockd-0.1.4}/.github/pull_request_template.md +1 -0
  3. interlockd-0.1.4/.github/workflows/ci.yml +134 -0
  4. {interlockd-0.1.2 → interlockd-0.1.4}/CHANGELOG.md +15 -0
  5. {interlockd-0.1.2 → interlockd-0.1.4}/CONTRIBUTING.md +12 -5
  6. {interlockd-0.1.2 → interlockd-0.1.4}/PKG-INFO +7 -5
  7. {interlockd-0.1.2 → interlockd-0.1.4}/README.md +6 -4
  8. interlockd-0.1.4/adapters/gemini-cli/PAYLOADS.md +65 -0
  9. interlockd-0.1.4/adapters/windsurf/PAYLOADS.md +49 -0
  10. {interlockd-0.1.2 → interlockd-0.1.4}/corpus/external/README.md +2 -2
  11. {interlockd-0.1.2 → interlockd-0.1.4}/docs/GETTING-STARTED.md +2 -1
  12. {interlockd-0.1.2 → interlockd-0.1.4}/docs/USER-GUIDE.md +6 -3
  13. {interlockd-0.1.2 → interlockd-0.1.4}/examples/github-workflow.yml +1 -1
  14. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/__init__.py +1 -1
  15. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/api.py +37 -3
  16. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/auth.py +3 -3
  17. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/bench.py +5 -5
  18. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/capture.py +24 -8
  19. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/cli.py +56 -47
  20. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/config.py +3 -2
  21. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/control.py +2 -2
  22. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/corpus.py +2 -2
  23. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/det/describe.py +1 -0
  24. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/det/flow.py +38 -1
  25. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/det/mandate.py +1 -1
  26. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/det/memory.py +6 -2
  27. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/det/packs.py +21 -16
  28. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/gateway.py +5 -5
  29. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/insights.py +12 -9
  30. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/integrations/client.py +3 -3
  31. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/judge_setup.py +4 -4
  32. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/judges/local_probe.py +28 -0
  33. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/mcp_http.py +3 -3
  34. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/mcp_proxy.py +5 -5
  35. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/normalize/gemini_cli.py +2 -2
  36. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/normalize/more.py +5 -2
  37. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/ogr.py +2 -2
  38. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/onboard.py +18 -18
  39. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/otel.py +2 -2
  40. interlockd-0.1.4/interlockd/paths.py +96 -0
  41. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/pipeline.py +50 -0
  42. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/policy/__init__.py +1 -1
  43. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/policy/workspace.py +1 -1
  44. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/scan.py +5 -5
  45. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/service.py +72 -13
  46. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/shims/claude-code/interlock-hook.sh +4 -2
  47. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/shims/kiro/interlock-hook.mjs +1 -1
  48. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/shims/kiro/interlock-hook.sh +1 -1
  49. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/store/spool.py +30 -9
  50. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/testkit.py +4 -4
  51. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/transcript/kiro.py +1 -1
  52. {interlockd-0.1.2 → interlockd-0.1.4}/pyproject.toml +1 -1
  53. interlockd-0.1.4/scripts/check-hygiene.sh +26 -0
  54. {interlockd-0.1.2 → interlockd-0.1.4}/sdk/js/package.json +2 -2
  55. {interlockd-0.1.2 → interlockd-0.1.4}/sdk/js/src/index.js +1 -1
  56. {interlockd-0.1.2 → interlockd-0.1.4}/tests/conftest.py +24 -2
  57. interlockd-0.1.4/tests/fixtures/gemini-cli/post-read-benign.json +15 -0
  58. interlockd-0.1.4/tests/fixtures/gemini-cli/post-read-inject.json +15 -0
  59. interlockd-0.1.4/tests/fixtures/gemini-cli/post-shell-benign.json +16 -0
  60. interlockd-0.1.4/tests/fixtures/gemini-cli/post-shell-rot13-inject.json +16 -0
  61. interlockd-0.1.4/tests/fixtures/gemini-cli/pre-read-benign.json +11 -0
  62. interlockd-0.1.4/tests/fixtures/gemini-cli/pre-shell-benign.json +12 -0
  63. interlockd-0.1.4/tests/fixtures/gemini-cli/pre-shell-block.json +12 -0
  64. interlockd-0.1.4/tests/fixtures/windsurf/post-shell-benign.json +10 -0
  65. interlockd-0.1.4/tests/fixtures/windsurf/pre-shell-benign.json +10 -0
  66. interlockd-0.1.4/tests/fixtures/windsurf/pre-shell-block.json +10 -0
  67. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_aibom.py +2 -2
  68. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_audit_chain.py +4 -4
  69. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_calibration.py +12 -12
  70. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_capture.py +37 -5
  71. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_claude_agent_sdk.py +1 -1
  72. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_claude_code.py +21 -15
  73. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_control.py +2 -2
  74. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_cursor_gemini.py +23 -10
  75. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_examples.py +2 -2
  76. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_gateway.py +19 -18
  77. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_insights.py +7 -6
  78. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_interlock_test.py +3 -3
  79. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_js_sdk.py +6 -6
  80. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_judge.py +1 -1
  81. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_judge_setup.py +3 -3
  82. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_local_probe.py +34 -1
  83. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_managed_policy.py +2 -2
  84. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_mcp_policy.py +1 -1
  85. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_mcp_proxy.py +4 -4
  86. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_mcp_server.py +11 -9
  87. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_more_adapters.py +65 -10
  88. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_ogr.py +3 -3
  89. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_onboard.py +19 -17
  90. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_opencode.py +5 -4
  91. interlockd-0.1.4/tests/test_paths_windows.py +64 -0
  92. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_repo_policy.py +5 -5
  93. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_sandbox.py +8 -8
  94. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_scan.py +14 -12
  95. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_shim_sh.py +4 -4
  96. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_spool.py +1 -1
  97. interlockd-0.1.4/tests/test_text_encoding.py +28 -0
  98. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_transcript.py +3 -3
  99. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_unix_socket.py +3 -3
  100. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_version.py +8 -4
  101. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_watch.py +10 -10
  102. interlockd-0.1.2/.github/workflows/ci.yml +0 -66
  103. interlockd-0.1.2/adapters/gemini-cli/PAYLOADS.md +0 -36
  104. interlockd-0.1.2/adapters/windsurf/PAYLOADS.md +0 -15
  105. interlockd-0.1.2/interlockd/paths.py +0 -63
  106. {interlockd-0.1.2 → interlockd-0.1.4}/.github/ISSUE_TEMPLATE/agent.yml +0 -0
  107. {interlockd-0.1.2 → interlockd-0.1.4}/.github/ISSUE_TEMPLATE/bug.yml +0 -0
  108. {interlockd-0.1.2 → interlockd-0.1.4}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  109. {interlockd-0.1.2 → interlockd-0.1.4}/.github/ISSUE_TEMPLATE/decision.yml +0 -0
  110. {interlockd-0.1.2 → interlockd-0.1.4}/.github/dependabot.yml +0 -0
  111. {interlockd-0.1.2 → interlockd-0.1.4}/.github/workflows/release.yml +0 -0
  112. {interlockd-0.1.2 → interlockd-0.1.4}/.gitignore +0 -0
  113. {interlockd-0.1.2 → interlockd-0.1.4}/CODE_OF_CONDUCT.md +0 -0
  114. {interlockd-0.1.2 → interlockd-0.1.4}/LICENSE +0 -0
  115. {interlockd-0.1.2 → interlockd-0.1.4}/NOTICE +0 -0
  116. {interlockd-0.1.2 → interlockd-0.1.4}/SECURITY.md +0 -0
  117. {interlockd-0.1.2 → interlockd-0.1.4}/action.yml +0 -0
  118. {interlockd-0.1.2 → interlockd-0.1.4}/adapters/README.md +0 -0
  119. {interlockd-0.1.2 → interlockd-0.1.4}/adapters/antigravity/PAYLOADS.md +0 -0
  120. {interlockd-0.1.2 → interlockd-0.1.4}/adapters/claude-code/PAYLOADS.md +0 -0
  121. {interlockd-0.1.2 → interlockd-0.1.4}/adapters/claude-code/README.md +0 -0
  122. {interlockd-0.1.2 → interlockd-0.1.4}/adapters/codex/PAYLOADS.md +0 -0
  123. {interlockd-0.1.2 → interlockd-0.1.4}/adapters/codex/README.md +0 -0
  124. {interlockd-0.1.2 → interlockd-0.1.4}/adapters/copilot-cli/PAYLOADS.md +0 -0
  125. {interlockd-0.1.2 → interlockd-0.1.4}/adapters/cursor/PAYLOADS.md +0 -0
  126. {interlockd-0.1.2 → interlockd-0.1.4}/adapters/kiro/PAYLOADS.md +0 -0
  127. {interlockd-0.1.2 → interlockd-0.1.4}/adapters/opencode/PAYLOADS.md +0 -0
  128. {interlockd-0.1.2 → interlockd-0.1.4}/benchmarks/agentdojo_template.py +0 -0
  129. {interlockd-0.1.2 → interlockd-0.1.4}/benchmarks/injecagent.py +0 -0
  130. {interlockd-0.1.2 → interlockd-0.1.4}/benchmarks/nl2bash.py +0 -0
  131. {interlockd-0.1.2 → interlockd-0.1.4}/corpus/README.md +0 -0
  132. {interlockd-0.1.2 → interlockd-0.1.4}/corpus/_corpus.yaml +0 -0
  133. {interlockd-0.1.2 → interlockd-0.1.4}/corpus/archetypes/_corpus.yaml +0 -0
  134. {interlockd-0.1.2 → interlockd-0.1.4}/corpus/archetypes/exfil_and_secrets.jsonl +0 -0
  135. {interlockd-0.1.2 → interlockd-0.1.4}/corpus/archetypes/git_and_history.jsonl +0 -0
  136. {interlockd-0.1.2 → interlockd-0.1.4}/corpus/archetypes/injection_persistence_supplychain.jsonl +0 -0
  137. {interlockd-0.1.2 → interlockd-0.1.4}/corpus/archetypes/judge_confirmation.jsonl +0 -0
  138. {interlockd-0.1.2 → interlockd-0.1.4}/corpus/archetypes/judge_exfil.jsonl +0 -0
  139. {interlockd-0.1.2 → interlockd-0.1.4}/corpus/archetypes/judge_injection.jsonl +0 -0
  140. {interlockd-0.1.2 → interlockd-0.1.4}/corpus/archetypes/judge_intent.jsonl +0 -0
  141. {interlockd-0.1.2 → interlockd-0.1.4}/corpus/archetypes/slow_hijack.jsonl +0 -0
  142. {interlockd-0.1.2 → interlockd-0.1.4}/corpus/external/samples/agentdojo.sample.json +0 -0
  143. {interlockd-0.1.2 → interlockd-0.1.4}/corpus/external/samples/agentdrift.sample.json +0 -0
  144. {interlockd-0.1.2 → interlockd-0.1.4}/corpus/external/samples/injecagent.sample.json +0 -0
  145. {interlockd-0.1.2 → interlockd-0.1.4}/corpus/ogr-seed-v0/README.md +0 -0
  146. {interlockd-0.1.2 → interlockd-0.1.4}/corpus/ogr-seed-v0/seed-v0.jsonl +0 -0
  147. {interlockd-0.1.2 → interlockd-0.1.4}/demo/demo.sh +0 -0
  148. {interlockd-0.1.2 → interlockd-0.1.4}/demo/demo.tape +0 -0
  149. {interlockd-0.1.2 → interlockd-0.1.4}/docs/ADAPTERS.md +0 -0
  150. {interlockd-0.1.2 → interlockd-0.1.4}/docs/BENCHMARKS.md +0 -0
  151. {interlockd-0.1.2 → interlockd-0.1.4}/docs/PERFORMANCE.md +0 -0
  152. {interlockd-0.1.2 → interlockd-0.1.4}/docs/THREAT-MODEL.md +0 -0
  153. {interlockd-0.1.2 → interlockd-0.1.4}/docs/mandate-example.cedar +0 -0
  154. {interlockd-0.1.2 → interlockd-0.1.4}/docs/policy-example.yaml +0 -0
  155. {interlockd-0.1.2 → interlockd-0.1.4}/docs/reports/M3-judge-calibration.md +0 -0
  156. {interlockd-0.1.2 → interlockd-0.1.4}/docs/reports/ogr-seed-v0-results.jsonl +0 -0
  157. {interlockd-0.1.2 → interlockd-0.1.4}/docs/reports/ogr-seed-v0-results.md +0 -0
  158. {interlockd-0.1.2 → interlockd-0.1.4}/examples/agents/README.md +0 -0
  159. {interlockd-0.1.2 → interlockd-0.1.4}/examples/agents/claude_agent_sdk_agent.py +0 -0
  160. {interlockd-0.1.2 → interlockd-0.1.4}/examples/agents/crewai_crew.py +0 -0
  161. {interlockd-0.1.2 → interlockd-0.1.4}/examples/agents/google_adk_agent.py +0 -0
  162. {interlockd-0.1.2 → interlockd-0.1.4}/examples/agents/langgraph_agent.py +0 -0
  163. {interlockd-0.1.2 → interlockd-0.1.4}/examples/agents/openai_agents.py +0 -0
  164. {interlockd-0.1.2 → interlockd-0.1.4}/examples/agents/semantic_kernel_agent.py +0 -0
  165. {interlockd-0.1.2 → interlockd-0.1.4}/examples/agents/strands_agent.py +0 -0
  166. {interlockd-0.1.2 → interlockd-0.1.4}/examples/policies/ci.yaml +0 -0
  167. {interlockd-0.1.2 → interlockd-0.1.4}/examples/policies/mcp-locked-down.yaml +0 -0
  168. {interlockd-0.1.2 → interlockd-0.1.4}/examples/policies/strict-team.yaml +0 -0
  169. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/__main__.py +0 -0
  170. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/aibom.py +0 -0
  171. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/calibration.py +0 -0
  172. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/compose.py +0 -0
  173. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/data/packs/_corpus.yaml +0 -0
  174. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/data/packs/egress.jsonl +0 -0
  175. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/data/packs/fileops.jsonl +0 -0
  176. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/data/packs/git.jsonl +0 -0
  177. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/data/packs/packages.jsonl +0 -0
  178. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/data/packs/persistence.jsonl +0 -0
  179. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/data/packs/secrets.jsonl +0 -0
  180. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/data/packs/shell.jsonl +0 -0
  181. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/det/__init__.py +0 -0
  182. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/det/engine.py +0 -0
  183. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/det/mcp.py +0 -0
  184. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/det/oauth.py +0 -0
  185. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/det/shellparse.py +0 -0
  186. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/egress.py +0 -0
  187. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/ids.py +0 -0
  188. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/integrations/__init__.py +0 -0
  189. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/integrations/claude_agent_sdk.py +0 -0
  190. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/integrations/crewai.py +0 -0
  191. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/integrations/google_adk.py +0 -0
  192. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/integrations/langchain.py +0 -0
  193. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/integrations/openai_agents.py +0 -0
  194. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/integrations/semantic_kernel.py +0 -0
  195. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/integrations/strands.py +0 -0
  196. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/judges/__init__.py +0 -0
  197. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/judges/guardrails.py +0 -0
  198. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/judges/projector.py +0 -0
  199. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/judges/strands_decider.py +0 -0
  200. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/ledger.py +0 -0
  201. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/mcp_server.py +0 -0
  202. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/models.py +0 -0
  203. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/normalize/__init__.py +0 -0
  204. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/normalize/claude_code.py +0 -0
  205. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/normalize/cursor.py +0 -0
  206. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/normalize/kiro.py +0 -0
  207. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/plugins.py +0 -0
  208. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/policy/managed.py +0 -0
  209. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/policy_lint.py +0 -0
  210. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/render.py +0 -0
  211. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/sandbox.py +0 -0
  212. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/schema/verdict.schema.json +0 -0
  213. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/selftest.py +0 -0
  214. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/shims/opencode/interlock.js +0 -0
  215. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/store/__init__.py +0 -0
  216. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/store/drainer.py +0 -0
  217. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/store/dynamodb.py +0 -0
  218. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/store/memory.py +0 -0
  219. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/tiers.py +0 -0
  220. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/transcript/__init__.py +0 -0
  221. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/transcript/claude_code.py +0 -0
  222. {interlockd-0.1.2 → interlockd-0.1.4}/interlockd/watch.py +0 -0
  223. {interlockd-0.1.2 → interlockd-0.1.4}/packaging/homebrew/Formula/interlock.rb +0 -0
  224. {interlockd-0.1.2 → interlockd-0.1.4}/packaging/homebrew/README.md +0 -0
  225. {interlockd-0.1.2 → interlockd-0.1.4}/packaging/homebrew/make_formula.py +0 -0
  226. {interlockd-0.1.2 → interlockd-0.1.4}/sdk/js/README.md +0 -0
  227. {interlockd-0.1.2 → interlockd-0.1.4}/sdk/js/src/index.d.ts +0 -0
  228. {interlockd-0.1.2 → interlockd-0.1.4}/tests/fake_mcp_server.py +0 -0
  229. {interlockd-0.1.2 → interlockd-0.1.4}/tests/fixtures/claude-code/post-bash-inject.json +0 -0
  230. {interlockd-0.1.2 → interlockd-0.1.4}/tests/fixtures/claude-code/post-read-inject.json +0 -0
  231. {interlockd-0.1.2 → interlockd-0.1.4}/tests/fixtures/claude-code/pre-bash-block.json +0 -0
  232. {interlockd-0.1.2 → interlockd-0.1.4}/tests/fixtures/claude-code/pre-bash-inject.json +0 -0
  233. {interlockd-0.1.2 → interlockd-0.1.4}/tests/fixtures/codex/post-bash-benign.json +0 -0
  234. {interlockd-0.1.2 → interlockd-0.1.4}/tests/fixtures/codex/post-bash-inject.json +0 -0
  235. {interlockd-0.1.2 → interlockd-0.1.4}/tests/fixtures/codex/pre-bash-benign.json +0 -0
  236. {interlockd-0.1.2 → interlockd-0.1.4}/tests/fixtures/codex/pre-bash-block.json +0 -0
  237. {interlockd-0.1.2 → interlockd-0.1.4}/tests/fixtures/copilot-cli/post-bash-inject.json +0 -0
  238. {interlockd-0.1.2 → interlockd-0.1.4}/tests/fixtures/copilot-cli/pre-bash-benign.json +0 -0
  239. {interlockd-0.1.2 → interlockd-0.1.4}/tests/fixtures/copilot-cli/pre-bash-block.json +0 -0
  240. {interlockd-0.1.2 → interlockd-0.1.4}/tests/fixtures/cursor/post-read-benign.json +0 -0
  241. {interlockd-0.1.2 → interlockd-0.1.4}/tests/fixtures/cursor/post-shell-benign.json +0 -0
  242. {interlockd-0.1.2 → interlockd-0.1.4}/tests/fixtures/cursor/pre-read-benign.json +0 -0
  243. {interlockd-0.1.2 → interlockd-0.1.4}/tests/fixtures/cursor/pre-shell-benign.json +0 -0
  244. {interlockd-0.1.2 → interlockd-0.1.4}/tests/fixtures/cursor/pre-shell-block.json +0 -0
  245. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_api.py +0 -0
  246. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_bench.py +0 -0
  247. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_corpus.py +0 -0
  248. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_crewai.py +0 -0
  249. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_dynamodb.py +0 -0
  250. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_egress.py +0 -0
  251. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_flow.py +0 -0
  252. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_google_adk.py +0 -0
  253. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_homebrew_formula.py +0 -0
  254. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_langchain.py +0 -0
  255. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_mandate.py +0 -0
  256. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_mcp_http.py +0 -0
  257. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_mcp_oauth.py +0 -0
  258. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_memory.py +0 -0
  259. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_normalize_kiro.py +0 -0
  260. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_openai_agents.py +0 -0
  261. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_otel.py +0 -0
  262. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_packs.py +0 -0
  263. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_pipeline.py +0 -0
  264. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_plugins.py +0 -0
  265. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_policy_lint.py +0 -0
  266. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_selftest.py +0 -0
  267. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_semantic_kernel.py +0 -0
  268. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_strands.py +0 -0
  269. {interlockd-0.1.2 → interlockd-0.1.4}/tests/test_tiers.py +0 -0
@@ -0,0 +1,2 @@
1
+ # Reviews for every change go to the maintainers team (Settings → Branches requires a code-owner review).
2
+ * @interlockd/maintainers
@@ -2,6 +2,7 @@
2
2
 
3
3
  ## How it was verified
4
4
 
5
+ - [ ] Every commit is signed off (`git commit -s`, see CONTRIBUTING.md)
5
6
  - [ ] Tests (`pytest -q`, `interlock test`)
6
7
  - [ ] Live against the real agent (which one, which version)
7
8
 
@@ -0,0 +1,134 @@
1
+ name: ci
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ hygiene:
13
+ # nothing identifying a real machine, account or person; no private-tooling leftovers (scripts/check-hygiene.sh)
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v7
17
+ - run: scripts/check-hygiene.sh
18
+
19
+ dco:
20
+ # every commit in a pull request carries a Developer Certificate of Origin sign-off (git commit -s); bots exempt
21
+ if: github.event_name == 'pull_request'
22
+ runs-on: ubuntu-latest
23
+ steps:
24
+ - uses: actions/checkout@v7
25
+ with: { fetch-depth: 0 }
26
+ - name: Signed-off-by on every commit
27
+ env:
28
+ BASE: ${{ github.event.pull_request.base.sha }}
29
+ HEAD: ${{ github.event.pull_request.head.sha }}
30
+ run: |
31
+ commits=$(git rev-list "$BASE..$HEAD") # fails the job if the range can't be read: never pass unchecked
32
+ bad=0
33
+ for c in $commits; do
34
+ case "$(git log -1 --format=%ae "$c")" in *"[bot]@users.noreply.github.com") continue ;; esac
35
+ if ! git log -1 --format=%B "$c" | grep -qE '^Signed-off-by: .+ <.+>'; then
36
+ echo "::error::$(git log -1 --format='%h %s' "$c"): no Signed-off-by line. Fix with: git rebase --signoff $BASE && git push --force-with-lease"
37
+ bad=1
38
+ fi
39
+ done
40
+ exit $bad
41
+
42
+ windows:
43
+ # native Windows: daemon, policy, audit, scan, SDKs (agent hooks and the sandbox stay macOS/Linux; WSL2)
44
+ runs-on: windows-latest
45
+ defaults:
46
+ run:
47
+ shell: bash
48
+ steps:
49
+ - uses: actions/checkout@v7
50
+ - uses: astral-sh/setup-uv@v7
51
+ - name: Install
52
+ run: |
53
+ uv venv --python 3.12
54
+ uv pip install -e ".[dev]" --python .venv/Scripts/python.exe
55
+ - name: Unit tests
56
+ run: .venv/Scripts/python.exe -m pytest -q -p no:cacheprovider
57
+ - name: Rule corpus gate
58
+ run: .venv/Scripts/interlock.exe test
59
+ - name: Live daemon on Windows (start, decide, audit, stop)
60
+ run: |
61
+ export INTERLOCK_HOME="$RUNNER_TEMP/ih" IL=.venv/Scripts/interlock.exe
62
+ $IL enforce && $IL start && $IL status
63
+ .venv/Scripts/python.exe - <<'PY'
64
+ import os
65
+ from interlockd.integrations.client import InterlockClient
66
+ c = InterlockClient()
67
+ cases = [("Bash", {"command": "git push --force origin main"}, "block"),
68
+ ("Write", {"file_path": os.path.expanduser(r"~\.CLAUDE\settings.json"), "content": "{}"}, "block"),
69
+ ("Read", {"file_path": "README.md"}, "allow")]
70
+ for tool, args, want in cases:
71
+ v = c.decide("pre_action", tool, args, cwd=os.getcwd())
72
+ print(tool, args, "->", v and v["disposition"])
73
+ assert v and v["disposition"] == want, v
74
+ PY
75
+ $IL audit verify
76
+ $IL stop
77
+ sleep 2
78
+ if curl -sf http://127.0.0.1:7391/interlock/v1/health; then echo "daemon still running after stop"; exit 1; fi
79
+
80
+ test:
81
+ runs-on: ${{ matrix.os }}
82
+ strategy:
83
+ matrix:
84
+ os: [ubuntu-latest, macos-latest]
85
+ steps:
86
+ - uses: actions/checkout@v7
87
+ - uses: astral-sh/setup-uv@v7
88
+ - name: Install
89
+ run: |
90
+ uv venv --python 3.12
91
+ uv pip install -e ".[dev]" --python .venv/bin/python
92
+ - name: Lint
93
+ run: .venv/bin/ruff check .
94
+ - name: Unit tests
95
+ run: .venv/bin/pytest -q
96
+ - name: Rule corpus gate
97
+ run: .venv/bin/interlock test
98
+ - name: Evaluation corpus gate
99
+ run: .venv/bin/interlock test --corpus corpus/archetypes
100
+ - name: Linux sandbox (bubblewrap)
101
+ if: runner.os == 'Linux'
102
+ run: |
103
+ sudo apt-get install -y -qq bubblewrap
104
+ # Ubuntu 24.04 restricts unprivileged user namespaces through AppArmor; bubblewrap needs them
105
+ sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
106
+ .venv/bin/pytest -q tests/test_sandbox.py -rs
107
+ - name: Framework integrations against a real daemon
108
+ run: |
109
+ uv pip install --python .venv/bin/python strands-agents langchain-core openai-agents google-adk claude-agent-sdk semantic-kernel
110
+ .venv/bin/pytest -q tests/test_strands.py tests/test_langchain.py tests/test_openai_agents.py tests/test_google_adk.py tests/test_claude_agent_sdk.py tests/test_semantic_kernel.py
111
+ - name: CrewAI integration (own environment)
112
+ run: |
113
+ uv venv /tmp/crewai --python 3.12
114
+ uv pip install --python /tmp/crewai/bin/python -e ".[dev]" crewai
115
+ /tmp/crewai/bin/pytest -q tests/test_crewai.py
116
+ - name: Wheel smoke test
117
+ run: |
118
+ uv build --wheel -o dist
119
+ uv venv /tmp/wheel --python 3.12
120
+ uv pip install --python /tmp/wheel/bin/python dist/*.whl
121
+ cd /tmp && /tmp/wheel/bin/interlock test
122
+
123
+ action:
124
+ runs-on: ubuntu-latest
125
+ steps:
126
+ - uses: actions/checkout@v7
127
+ - name: Plant a risky MCP config to prove the scan sees it
128
+ run: |
129
+ mkdir -p .vscode
130
+ echo '{"servers":{"sh":{"command":"bash","args":["-c","node s.js"]}}}' > .vscode/mcp.json
131
+ - uses: ./
132
+ with: { fail-on: never, test-policy: "true" }
133
+ - name: SARIF has the finding
134
+ run: python3 -c "import json;r=json.load(open('interlock.sarif'))['runs'][0]['results'];print(len(r),'results');assert any(x['ruleId']=='mcp.inline_shell' for x in r)"
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.4 (2026-10-24)
4
+
5
+ - Windsurf adapter verified live on Windsurf (Devin.app) 3.10.67: a refused force push, an injected README refused before Cascade read it, and a `cat` of it refused (`flow.reread_withheld`). Windsurf's read hooks carry no contents, so the daemon reads the file itself (256 KiB, never a sensitive path). Fixed live: Windsurf sends `cwd: null`, so a repository policy never applied; the hook's own directory is used now. Windsurf runs each hook twice; the repeat is answered from the first decision.
6
+ - `interlock capture` pairs Windsurf's calls with their results (its report showed a false "block held: no").
7
+ - Contributing: pull requests are reviewed and merged in this repository, with a DCO sign-off on each commit (`git commit -s`, checked in CI); CI also runs `scripts/check-hygiene.sh`, which keeps real home paths, account ids and keys out of the repository.
8
+
9
+ ## 0.1.3 (2026-10-24)
10
+
11
+ - **Security fix (macOS, Windows):** path rules now match without case where the file system ignores it. On macOS (default APFS) a write to `~/.CLAUDE/settings.json`, `.GIT/hooks/pre-commit` or `.GitHub/Workflows/ci.yml` changes the real file but was allowed. Linux is unchanged.
12
+ - Native Windows, first part: the daemon (`interlock start | stop | status`, a detached background process), policy, audit log, `scan`, `report`, `explain`, `test` and the Python and JavaScript SDKs run on Windows without WSL2. Paths are matched the same way on every OS (`C:\Users\me\.claude\settings.json` hits the same rules as `~/.claude/settings.json`), and the Startup folder and PowerShell profiles count as persistence. Agent hooks, `interlock init`, the gateway, the sandbox and the local judge still need macOS or Linux (on Windows, WSL2); those commands say so. Verified on a Windows CI runner: the full test suite, and a live daemon (start, decisions through the Python client, `audit verify`, stop).
13
+ - Gemini CLI adapter verified live on Gemini CLI 0.65.0: a refused force push and a withheld injected README; captured payloads replayed in tests.
14
+ - Withheld content can't be read back another way: the local probe also scores ROT13 and base64 decodings of each tool result, and once a file is withheld, a shell call naming it in the same session asks (`flow.reread_withheld`). Found live: Gemini CLI re-read a withheld README through ROT13 "without triggering policy".
15
+ - `interlock capture`: `--scratch` projects push nowhere; for agents that send no tool-call id (Gemini CLI), the report pairs a refused call with a later result by session and input.
16
+ - Text files are read and written as UTF-8 on every platform.
17
+
3
18
  ## 0.1.2 (2026-10-24)
4
19
 
5
20
  - Managed policy: `<interlock home>/managed-policy.yaml` is applied last and can only tighten decisions (it can switch enforce on, never loosen anything). It is reloaded without a restart; a file that doesn't parse is ignored and reported by `interlock policy lint`. For device management and centrally managed machines.
@@ -12,7 +12,8 @@ uv venv --python 3.12 && uv pip install -e ".[dev]" --python .venv/bin/python
12
12
  .venv/bin/interlock test --corpus corpus/archetypes # evaluation corpus
13
13
  ```
14
14
 
15
- CI runs all four on every pull request.
15
+ CI runs all four on every pull request, on Linux, macOS and Windows, plus `scripts/check-hygiene.sh` (no real home
16
+ paths, account ids or keys in committed files; run it before you push).
16
17
 
17
18
  ## What to contribute
18
19
 
@@ -22,12 +23,18 @@ CI runs all four on every pull request.
22
23
 
23
24
  ## Pull requests
24
25
 
25
- Open pull requests against `main` here as usual. This repository is published from the maintainers' development
26
- repository, so a merged change reaches `main` through the next published commit, with you credited as author (and
27
- `Co-authored-by` on the published commit); your PR is closed with a link to it rather than merged with the button.
26
+ Fork, branch, and open a pull request against `main`. A maintainer reviews it; when CI is green and the review is
27
+ done, it is merged here.
28
28
 
29
+ - **Sign off every commit** (`git commit -s`). This adds `Signed-off-by: Your Name <you@example.com>` and certifies the
30
+ [Developer Certificate of Origin](https://developercertificate.org/): that you wrote the change or otherwise have the
31
+ right to submit it under this project's license. CI checks it; to fix a branch, `git rebase --signoff main` and
32
+ force-push. There is no separate contributor agreement.
33
+ - Contributions are licensed under Apache-2.0, the same license as the project (inbound = outbound). The project
34
+ stays Apache-2.0.
29
35
  - Keep changes focused; include tests.
30
36
  - Describe what you verified and how (live against the tool, or tests only).
31
- - By contributing you agree your contribution is licensed under Apache-2.0.
37
+ - **Security issues**: don't open a pull request or issue; report privately (see [SECURITY.md](SECURITY.md)). Fixes
38
+ are prepared in a private security advisory and released before details are public.
32
39
 
33
40
  By taking part you agree to the [code of conduct](CODE_OF_CONDUCT.md).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: interlockd
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Interlock daemon: a pre/post-action interlock for coding agents
5
5
  License: Apache-2.0
6
6
  License-File: LICENSE
@@ -56,8 +56,8 @@ pipx install interlockd # or: uv tool install interlockd, or: brew install i
56
56
  interlock init # finds your agents, installs hooks, starts the daemon (shadow mode)
57
57
  ```
58
58
 
59
- Runs on macOS and Linux (Python 3.12+). On Windows, use it inside WSL2 and run your agents from the WSL terminal;
60
- native Windows support is planned.
59
+ Runs on macOS and Linux (Python 3.12+). On Windows, use it inside WSL2 and run your agents from the WSL terminal.
60
+ Natively on Windows, the daemon and the Python and JavaScript SDKs work today (`interlock start`); agent hooks are next.
61
61
 
62
62
  New here? [docs/GETTING-STARTED.md](docs/GETTING-STARTED.md) walks through shadow mode, enforcing, MCP servers, your own agents and CI in about ten minutes.
63
63
 
@@ -79,7 +79,9 @@ What the gate decides (enforce mode, nothing executed):
79
79
  | Kiro (IDE) | ✅ verified live |
80
80
  | Cursor (CLI) | ✅ verified live: blocks before execution |
81
81
  | GitHub Copilot CLI | ✅ verified live: blocks before execution, withholds injected tool results |
82
- | Gemini CLI, Windsurf, Antigravity CLI, OpenCode | built from their documented hooks/plugins; live verification in progress |
82
+ | Gemini CLI | ✅ verified live: blocks before execution, withholds injected tool results |
83
+ | Windsurf (Devin.app) | ✅ verified live: blocks before execution, refuses to read injected files |
84
+ | Antigravity CLI, OpenCode | built from their documented hooks/plugins; live verification in progress |
83
85
  | Your own agents (Claude Agent SDK, Strands, LangChain / LangGraph, OpenAI Agents SDK, Google ADK, CrewAI, Semantic Kernel) | ✅ in-process: `guard_options(options)`, `InterlockHooks()`, `guard_tools(tools)`, `guard_agent_tools(agent)`, `guard_agent(agent)`, `crewai.install()`, `guard_kernel(kernel)` |
84
86
  | JavaScript / TypeScript agents (Vercel AI SDK, Claude Agent SDK for TS, Mastra) | ✅ `@interlockd/sdk`: `guardTools(tools)`, `guardOptions(options)` ([sdk/js](sdk/js)) |
85
87
  | Any MCP client | ✅ the MCP gateway (`interlock gateway wrap`) gates every stdio and remote (HTTP) MCP server, hooks or not |
@@ -129,7 +131,7 @@ The full threat model is in [docs/THREAT-MODEL.md](docs/THREAT-MODEL.md). Latenc
129
131
 
130
132
  ```yaml
131
133
  - uses: actions/checkout@v7
132
- - uses: interlockd/interlock@v0.1.2 # scan MCP/agent configs, test .interlock/policy.yaml
134
+ - uses: interlockd/interlock@v0.1.4 # scan MCP/agent configs, test .interlock/policy.yaml
133
135
  with: { fail-on: high }
134
136
  - uses: github/codeql-action/upload-sarif@v3
135
137
  if: always()
@@ -12,8 +12,8 @@ pipx install interlockd # or: uv tool install interlockd, or: brew install i
12
12
  interlock init # finds your agents, installs hooks, starts the daemon (shadow mode)
13
13
  ```
14
14
 
15
- Runs on macOS and Linux (Python 3.12+). On Windows, use it inside WSL2 and run your agents from the WSL terminal;
16
- native Windows support is planned.
15
+ Runs on macOS and Linux (Python 3.12+). On Windows, use it inside WSL2 and run your agents from the WSL terminal.
16
+ Natively on Windows, the daemon and the Python and JavaScript SDKs work today (`interlock start`); agent hooks are next.
17
17
 
18
18
  New here? [docs/GETTING-STARTED.md](docs/GETTING-STARTED.md) walks through shadow mode, enforcing, MCP servers, your own agents and CI in about ten minutes.
19
19
 
@@ -35,7 +35,9 @@ What the gate decides (enforce mode, nothing executed):
35
35
  | Kiro (IDE) | ✅ verified live |
36
36
  | Cursor (CLI) | ✅ verified live: blocks before execution |
37
37
  | GitHub Copilot CLI | ✅ verified live: blocks before execution, withholds injected tool results |
38
- | Gemini CLI, Windsurf, Antigravity CLI, OpenCode | built from their documented hooks/plugins; live verification in progress |
38
+ | Gemini CLI | ✅ verified live: blocks before execution, withholds injected tool results |
39
+ | Windsurf (Devin.app) | ✅ verified live: blocks before execution, refuses to read injected files |
40
+ | Antigravity CLI, OpenCode | built from their documented hooks/plugins; live verification in progress |
39
41
  | Your own agents (Claude Agent SDK, Strands, LangChain / LangGraph, OpenAI Agents SDK, Google ADK, CrewAI, Semantic Kernel) | ✅ in-process: `guard_options(options)`, `InterlockHooks()`, `guard_tools(tools)`, `guard_agent_tools(agent)`, `guard_agent(agent)`, `crewai.install()`, `guard_kernel(kernel)` |
40
42
  | JavaScript / TypeScript agents (Vercel AI SDK, Claude Agent SDK for TS, Mastra) | ✅ `@interlockd/sdk`: `guardTools(tools)`, `guardOptions(options)` ([sdk/js](sdk/js)) |
41
43
  | Any MCP client | ✅ the MCP gateway (`interlock gateway wrap`) gates every stdio and remote (HTTP) MCP server, hooks or not |
@@ -85,7 +87,7 @@ The full threat model is in [docs/THREAT-MODEL.md](docs/THREAT-MODEL.md). Latenc
85
87
 
86
88
  ```yaml
87
89
  - uses: actions/checkout@v7
88
- - uses: interlockd/interlock@v0.1.2 # scan MCP/agent configs, test .interlock/policy.yaml
90
+ - uses: interlockd/interlock@v0.1.4 # scan MCP/agent configs, test .interlock/policy.yaml
89
91
  with: { fail-on: high }
90
92
  - uses: github/codeql-action/upload-sarif@v3
91
93
  if: always()
@@ -0,0 +1,65 @@
1
+ # Gemini CLI hooks (measured)
2
+
3
+ **Status: verified live on Gemini CLI 0.65.0** (2026-10-24, headless `gemini -p --approval-mode yolo`, API-key auth,
4
+ `interlock capture --scratch`). Sources for the contract: https://geminicli.com/docs/hooks/ and /docs/hooks/reference/.
5
+ Redacted captures are in `daemon/tests/fixtures/gemini-cli/` and replayed by `test_live_captured_payloads`.
6
+
7
+ Note: Google replaced Gemini CLI with Antigravity CLI for unpaid-tier and Google One users on 2026-06-18; Gemini CLI
8
+ still runs with an API key. Antigravity's hook contract is separate (`adapters/antigravity/`).
9
+
10
+ ## What Interlock registers
11
+
12
+ `~/.gemini/settings.json` (other settings and hooks preserved):
13
+
14
+ ```json
15
+ {"hooks": {
16
+ "BeforeTool": [{"matcher": ".*", "hooks": [{"name": "interlock", "type": "command", "command": "sh ~/.interlock/adapters/gemini-cli/interlock-hook.sh", "timeout": 5000}]}],
17
+ "AfterTool": [{"matcher": ".*", "hooks": [{"name": "interlock", "type": "command", "command": "sh ~/.interlock/adapters/gemini-cli/interlock-hook.sh", "timeout": 5000}]}]}}
18
+ ```
19
+
20
+ `timeout` is in milliseconds. The hooks fired without `/hooks enable`. Shell is `run_shell_command`; files are
21
+ `read_file`, `list_directory`, ...; MCP tools are `mcp_<server>_<tool>` (not exercised in the capture).
22
+
23
+ ## Measured input
24
+
25
+ | Event | Keys |
26
+ |---|---|
27
+ | `BeforeTool` | `session_id`, `transcript_path`, `cwd`, `hook_event_name`, `timestamp`, `tool_name`, `tool_input` |
28
+ | `AfterTool` | the above plus `tool_response {llmContent, returnDisplay}` |
29
+
30
+ Differences from the docs: `timestamp` is present (undocumented); `mcp_context` is absent for built-in tools; there is
31
+ **no tool-call id**, so `interlock capture report` pairs a refused call with a later result by session and
32
+ `tool_input`. Shell results arrive wrapped by Gemini itself: `llmContent` is `<untrusted_context>\nOutput: ...\n</untrusted_context>`.
33
+ The shell tool adds a `description` key to `tool_input`.
34
+
35
+ ## Output, and what was observed
36
+
37
+ | Goal | Interlock emits | Observed |
38
+ |---|---|---|
39
+ | Refuse a call | `{"decision": "deny", "reason": "..."}` | ✅ `git push --force origin main` not executed; Gemini printed "Tool execution blocked: [interlock] ..." and no AfterTool followed |
40
+ | Ask the user | — | no ask in the contract; Interlock degrades ask to deny with a notice |
41
+ | Withhold an injected result | `{"decision": "deny", "reason": "<notice>"}` on AfterTool | ✅ `read_file README.md`: "Tool result blocked: [interlock] ..."; the model saw only the notice |
42
+ | Warn after a result | `{"hookSpecificOutput": {"hookEventName": "AfterTool", "additionalContext": "..."}}` | not exercised live (unit-tested) |
43
+ | No opinion | no output, exit 0 | ✅ benign calls ran normally |
44
+
45
+ ## Agent behaviour worth knowing
46
+
47
+ After the README was withheld, Gemini went looking for the reason: `env | grep -i INTERLOCK`, `which interlock`,
48
+ `list_directory ~/.interlock` (refused by Gemini's own workspace sandbox), then a python one-liner described as
49
+ "Inspect obfuscated text of README.md to understand content without triggering policy", which printed the README in
50
+ ROT13. That output was not flagged at the time. Two fixes followed:
51
+
52
+ - the local probe also scores ROT13 and base64 decodings of each tool result (`decoded_views` in `judges/local_probe.py`);
53
+ - `flow.reread_withheld`: once a file's contents are withheld, a shell (or other non-read) call naming that file in the
54
+ same session is asked about, which Gemini receives as a deny.
55
+
56
+ `post-shell-rot13-inject.json` is that capture; it is withheld now.
57
+
58
+ ## Running it
59
+
60
+ - Headless mode needs a trusted folder: set `GEMINI_CLI_TRUST_WORKSPACE=true` (or `--skip-trust`); otherwise Gemini
61
+ exits before any tool runs and YOLO is overridden to default.
62
+ - Gemini CLI runs on Node; a Node whose CA store cannot verify `generativelanguage.googleapis.com` fails with
63
+ `TypeError: fetch failed sending request` and nine retries. That is the Node install, not the hooks.
64
+ - Free-tier keys: `gemini-2.5-flash` returned "no longer available to new users"; `gemini-3.8-flash` worked (20 requests
65
+ per day).
@@ -0,0 +1,49 @@
1
+ # Windsurf (Cascade) hooks (measured)
2
+
3
+ **Status: verified live on Windsurf 3.10.67** (2026-10-24; the app now ships as **Devin.app**, bundle id
4
+ `com.exafunction.windsurf`; docs at https://docs.devin.ai/desktop/cascade/hooks). Captured with `interlock capture`
5
+ and project-level hooks in a scratch workspace; redacted payloads in `daemon/tests/fixtures/windsurf/`.
6
+
7
+ ## What Interlock registers
8
+
9
+ `interlock init` adds to `~/.codeium/windsurf/hooks.json` (your own hooks are kept): `pre_run_command`,
10
+ `pre_mcp_tool_use`, `pre_read_code`, `pre_write_code`, each
11
+ `{"command": "sh ~/.interlock/adapters/windsurf/interlock-hook.sh pre", "show_output": true}`.
12
+
13
+ Windsurf also merges system (`/Library/Application Support/Devin/hooks.json`) and workspace (`.devin/hooks.json`,
14
+ legacy `.windsurf/hooks.json`) files. Measured: **both** workspace files load when both exist, although the docs say
15
+ the legacy one is skipped. Hooks don't run in Restricted Mode (trust the folder).
16
+
17
+ ## Measured input
18
+
19
+ Every event: `agent_action_name`, `trajectory_id` (one per Cascade conversation), `execution_id` (one per Cascade
20
+ turn, shared by every call in it), `timestamp`, `tool_info`. No `model_name` arrived.
21
+
22
+ | Event | `tool_info` |
23
+ |---|---|
24
+ | `pre_run_command` / `post_run_command` | `command_line`, `cwd` — **`cwd` is always `null`** |
25
+ | `pre_read_code` / `post_read_code` | `file_path` (absolute) — **no content** |
26
+ | `post_run_command` | same as pre — **no output** |
27
+
28
+ Hooks run with the workspace as their working directory. The shim sends that directory (`adapter.cwd`) and the daemon
29
+ uses it when `tool_info.cwd` is null; without it a repository policy in the workspace was never found and an
30
+ enforcing scratch policy did not apply.
31
+
32
+ **Every hook runs twice**, ~60 ms apart, with identical payloads. The daemon answers the repeat from the first
33
+ decision (keyed on `execution_id`, event and `tool_info`, 5 s), so it is recorded and counted once.
34
+
35
+ ## Output, and what was observed
36
+
37
+ | Goal | Interlock emits | Observed |
38
+ |---|---|---|
39
+ | Refuse a call | exit 2, reason on stderr | ✅ `git push --force origin main` not run; Cascade showed "Hook block" and quoted the reason |
40
+ | Refuse an injected file before it is read | exit 2 on `pre_read_code` (see below) | ✅ README.md refused; Cascade reported "the file was not read because it appears to contain instructions addressed to the agent" |
41
+ | Read a withheld file another way | exit 2 (`flow.reread_withheld`, ask → refusal) | ✅ `cat README.md` refused; Cascade asked the user to confirm |
42
+ | Withhold or annotate a result | — | not possible: post hooks can't change anything and carry no result |
43
+ | No opinion | exit 0 | ✅ `ls -la` ran |
44
+
45
+ ## Reads are probed from disk
46
+
47
+ Because no hook carries file contents, the daemon reads the file named by `pre_read_code` itself (up to 256 KiB, regular
48
+ files only, never a sensitive path) and runs the injection probe on it. An injected file is refused before Cascade
49
+ reads it, and the path is remembered for `flow.reread_withheld`. Shell output can't be checked on Windsurf.
@@ -22,7 +22,7 @@ Prove the pipeline end-to-end before touching a real dataset. Each sample is han
22
22
  importer's expected shape and imports with **0 skipped**:
23
23
 
24
24
  ```sh
25
- cd ~/Documents/codes/interlock/interlock-AI/daemon
25
+ # from the repository root
26
26
  .venv/bin/interlock corpus import --from agentdojo corpus/external/samples/agentdojo.sample.json --out corpus/external/imported/agentdojo.jsonl
27
27
  .venv/bin/interlock corpus import --from injecagent corpus/external/samples/injecagent.sample.json --out corpus/external/imported/injecagent.jsonl
28
28
  .venv/bin/interlock corpus import --from agentdrift corpus/external/samples/agentdrift.sample.json --out corpus/external/imported/agentdrift.jsonl
@@ -79,7 +79,7 @@ Always confirm the field mapping against a real sample before a full import (the
79
79
  defensive but best-effort). Import writes **unlabelled** cases:
80
80
 
81
81
  ```sh
82
- cd ~/Documents/codes/interlock/interlock-AI/daemon
82
+ # from the repository root
83
83
  .venv/bin/interlock corpus import --from agentdojo corpus/external/raw/agentdojo.json --out corpus/external/imported/agentdojo.jsonl
84
84
  .venv/bin/interlock corpus import --from injecagent corpus/external/raw/injecagent.json --out corpus/external/imported/injecagent.jsonl
85
85
  .venv/bin/interlock corpus import --from agentdrift corpus/external/raw/agentdrift.json --out corpus/external/imported/agentdrift.jsonl
@@ -9,7 +9,8 @@ pipx install interlockd # or: uv tool install interlockd, or: brew instal
9
9
  interlock init
10
10
  ```
11
11
 
12
- macOS and Linux. On Windows, do everything here inside WSL2 (Ubuntu) and run your agents from the WSL terminal.
12
+ macOS and Linux. On Windows, do everything here inside WSL2 (Ubuntu) and run your agents from the WSL terminal. (Natively on
13
+ Windows, the daemon and the SDKs already work: see the user guide, §2.)
13
14
 
14
15
  `init` lists everything it will change and asks first. It then:
15
16
 
@@ -27,7 +27,7 @@ This guide covers what Interlock does today and how to run it. It grows with the
27
27
  3. **Mandate.** An optional Cedar policy expresses what the agent is authorized to do (which remotes, which branches, which capabilities, how many destructive operations per session). Denials become findings.
28
28
  4. **Composition.** The strongest action wins (`block` > `ask` > `model` > `flag` > `allow`). A denial budget escalates to the user after 3 consecutive or 20 total denials in a session.
29
29
  5. **Judge.** Rules routed to `model` are decided by a decision model (Strands Decider 2B, open weights) answering "did the user authorize this action?" when a judge backend is configured; without one they are recorded as unjudged. `interlock judge install` sets it up on this machine in one step (a private environment in `~/.interlock/judge`, about 5 GB of weights, and `judge.primary` in your policy); the daemon then starts and supervises it, in the background, and `interlock judge status` / `interlock judge remove` do what they say.
30
- 6. **Post-action probe.** Tool results are checked for instructions addressed to the agent. The **local probe** (heuristics, plus an optional classifier such as PromptGuard 2) is on by default and needs no AWS or network. Amazon Bedrock Guardrails (`judge.probe.enabled`) and the judge's `injection_present` are optional extra detectors. All detectors compose deny-wins: the strongest signal sets `warn` or `withhold`.
30
+ 6. **Post-action probe.** Tool results are checked for instructions addressed to the agent. The **local probe** (heuristics, plus an optional classifier such as PromptGuard 2) is on by default and needs no AWS or network; it also scores ROT13 and base64 decodings of each result, because an agent whose read was withheld may print the same file encoded. Amazon Bedrock Guardrails (`judge.probe.enabled`) and the judge's `injection_present` are optional extra detectors. All detectors compose deny-wins: the strongest signal sets `warn` or `withhold`.
31
31
 
32
32
  ### Shadow and enforce
33
33
 
@@ -35,7 +35,7 @@ A new install runs in **shadow** mode: every decision is computed and recorded,
35
35
 
36
36
  ## 2. Install and run
37
37
 
38
- Prerequisites: macOS or Linux, Python 3.12 or newer, `curl`, and at least one supported agent (§4). On Windows, install and run it inside WSL2 (Ubuntu), with your agents run from the WSL terminal; Windows-native agents (an IDE running on Windows itself) aren't covered yet. Linux: the background service is a systemd user service; `interlock sandbox` needs `bubblewrap`.
38
+ Prerequisites: macOS or Linux, Python 3.12 or newer, `curl`, and at least one supported agent (§4). On Windows, install and run it inside WSL2 (Ubuntu), with your agents run from the WSL terminal. Natively on Windows, `interlock start` runs the daemon for the Python and JavaScript SDKs, with the same policy, audit log, `scan`, `report` and `test`; `init`, `install`, `gateway`, `sandbox`, `selftest`, `capture` and `judge` need macOS or Linux for now and say so. Windows-native agents (an IDE running on Windows itself) aren't covered yet. Linux: the background service is a systemd user service; `interlock sandbox` needs `bubblewrap`.
39
39
 
40
40
  ```sh
41
41
  pipx install interlockd # or: uv tool install interlockd
@@ -281,6 +281,8 @@ flow:
281
281
 
282
282
  **Memory poisoning.** Agent instruction files (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`, `.cursorrules`, `.github/copilot-instructions.md`, `.cursor/rules/`, ...) and MCP memory servers are read back into every future session. Writes to them are recorded (`memory.instruction_file_write`); content that reads like instructions to the agent asks (`memory.injected_instructions`); and once a session has taken in untrusted content, writing them asks (`flow.memory_poisoning`), so an injected instruction can't quietly make itself permanent.
283
283
 
284
+ **Re-reading a withheld file.** Once a file's contents are withheld as injected, a shell (or other non-read) call that names the same file later in the session asks (`flow.reread_withheld`). Measured on Gemini CLI: with its README withheld, the agent printed it through a ROT13 one-liner "without triggering policy". A plain read tool is not affected; its result is probed again.
285
+
284
286
  Sessions are the agent's own session id for hook traffic, and the launching agent's process for MCP gateway traffic, so taint never crosses between agents.
285
287
 
286
288
  ### Rule catalog
@@ -636,7 +638,8 @@ plugin into Interlock's own environment, e.g. `pipx inject interlockd <package>`
636
638
  ## 8. Known limitations
637
639
 
638
640
  - The optional judge is off by default. When enabled, some of its thresholds are still being calibrated; expect an `ask` on some actions you plainly requested (answering the confirmation resolves it).
639
- - Post-action withholding is verified on Claude Code and Codex. Cursor can replace results only for MCP tools; other Cursor results get a warning.
641
+ - Post-action withholding is verified on Claude Code, Codex, Copilot CLI and Gemini CLI. Cursor can replace results only for MCP tools; other Cursor results get a warning.
642
+ - Windsurf's hooks carry no tool results: Interlock reads the file named by a read hook itself and refuses an injected one before Cascade reads it, but shell and MCP output can't be checked or withheld there. There is no ask, so an `ask` is a refusal.
640
643
  - Kiro has no native ask and ignores PostToolUse output, so on Kiro an `ask` renders as a block with a notice, and post-action warnings are record-only (`interlock log`, review index).
641
644
  - The Decider server binds loopback without auth and handles one request at a time. It is a dev-grade local dependency.
642
645
  - `interlock log` shows rule ids and probabilities, not prose rationales. The explainer is planned.
@@ -9,7 +9,7 @@ jobs:
9
9
  runs-on: ubuntu-latest
10
10
  steps:
11
11
  - uses: actions/checkout@v7
12
- - uses: interlockd/interlock@v0.1.2
12
+ - uses: interlockd/interlock@v0.1.4
13
13
  with: { fail-on: high }
14
14
  - uses: github/codeql-action/upload-sarif@v3
15
15
  if: always()
@@ -1,3 +1,3 @@
1
1
  """Interlock daemon package."""
2
2
 
3
- __version__ = "0.1.2"
3
+ __version__ = "0.1.4"
@@ -18,10 +18,12 @@ OGR conformance (design §5.2), served at the bare /v1 prefix the spec uses:
18
18
 
19
19
  from __future__ import annotations
20
20
 
21
+ import hashlib
21
22
  import json
22
23
  import logging
23
24
  import socketserver
24
25
  import threading
26
+ import time
25
27
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
26
28
  from typing import Any
27
29
  from urllib.parse import parse_qs, urlparse
@@ -51,6 +53,20 @@ from .render import (
51
53
  log = logging.getLogger("interlockd.api")
52
54
 
53
55
 
56
+
57
+ # Measured on Windsurf (Devin.app) 3.10.67: every hook runs twice, ~60 ms apart, with the same execution_id. The
58
+ # repeat gets the first answer instead of a second decision (no duplicate audit record, no double denial count).
59
+ REPEAT_TTL_S = 5.0
60
+ _REPEATS: dict[tuple[str, str, str], tuple[float, bytes]] = {}
61
+
62
+
63
+ def _windsurf_repeat_key(path: str, body: dict) -> tuple[str, str, str] | None:
64
+ p = body.get("payload") if path == "/interlock/v1/hooks/windsurf" else None
65
+ if not isinstance(p, dict) or not p.get("execution_id"):
66
+ return None
67
+ digest = hashlib.sha256(json.dumps(p.get("tool_info"), sort_keys=True, default=str).encode()).hexdigest()
68
+ return str(p["execution_id"]), str(p.get("agent_action_name")), digest
69
+
54
70
  class _Server(ThreadingHTTPServer):
55
71
  """HTTPServer.server_bind resolves the host with socket.getfqdn(), a reverse-DNS lookup that can stall for tens
56
72
  of seconds on machines with slow DNS (seen on CI runners). The daemon binds loopback; it never needs the name."""
@@ -61,8 +77,9 @@ class _Server(ThreadingHTTPServer):
61
77
  self.server_name, self.server_port = str(host), port
62
78
 
63
79
 
64
- class _UnixServer(socketserver.ThreadingMixIn, socketserver.UnixStreamServer):
65
- """The same API on a Unix socket created with mode 0600: only the owning user can connect at all."""
80
+ class _UnixServer(socketserver.ThreadingMixIn, getattr(socketserver, "UnixStreamServer", socketserver.TCPServer)):
81
+ """The same API on a Unix socket created with mode 0600: only the owning user can connect at all.
82
+ (Windows Python has no UnixStreamServer: the base falls back so this module imports, and nothing creates one.)"""
66
83
  daemon_threads = True
67
84
 
68
85
  def server_bind(self):
@@ -85,6 +102,8 @@ class ApiServer:
85
102
  handler = self._make_handler()
86
103
  self.httpd = _Server((host, port), handler)
87
104
  self.httpd.daemon_threads = True
105
+ if unix_socket and not hasattr(socketserver, "UnixStreamServer"):
106
+ raise OSError("Unix sockets are not available on this platform")
88
107
  self.unix = _UnixServer(unix_socket, handler) if unix_socket else None
89
108
  self._thread: threading.Thread | None = None
90
109
  self._unix_thread: threading.Thread | None = None
@@ -254,8 +273,9 @@ class ApiServer:
254
273
  elif u.path in ("/interlock/v1/hooks/copilot-cli", "/interlock/v1/hooks/windsurf", "/interlock/v1/hooks/antigravity"):
255
274
  fn = {"copilot-cli": normalize_copilot, "windsurf": normalize_windsurf,
256
275
  "antigravity": normalize_antigravity}[u.path.rsplit("/", 1)[-1]]
276
+ extra = {"hook_cwd": shim.get("cwd")} if fn is normalize_windsurf and isinstance(shim.get("cwd"), str) else {}
257
277
  event = fn(body.get("payload") or {}, event=str(shim.get("event") or "pre"),
258
- agent_id=server.daemon.settings.agent_id, adapter_version=str(shim.get("version", "0")))
278
+ agent_id=server.daemon.settings.agent_id, adapter_version=str(shim.get("version", "0")), **extra)
259
279
  elif u.path == "/interlock/v1/hooks/gemini-cli":
260
280
  event = normalize_gemini_cli(body.get("payload") or {}, agent_id=server.daemon.settings.agent_id,
261
281
  adapter_version=str(shim.get("version", "0")))
@@ -272,6 +292,16 @@ class ApiServer:
272
292
  except Exception as e: # noqa: BLE001 boundary: malformed hook payloads must never take the daemon down
273
293
  self._send(400, {"error": f"could not normalize payload: {type(e).__name__}"})
274
294
  return
295
+ repeat_key = _windsurf_repeat_key(u.path, body)
296
+ if repeat_key is not None:
297
+ hit = _REPEATS.get(repeat_key)
298
+ if hit is not None and time.monotonic() - hit[0] < REPEAT_TTL_S:
299
+ self.send_response(200) # the same hook call again: same answer, not a second decision
300
+ self.send_header("Content-Type", "text/plain; charset=utf-8")
301
+ self.send_header("Content-Length", str(len(hit[1])))
302
+ self.end_headers()
303
+ self.wfile.write(hit[1])
304
+ return
275
305
  try:
276
306
  verdict = server.daemon.decide(event)
277
307
  except Exception as e:
@@ -290,6 +320,10 @@ class ApiServer:
290
320
 
291
321
  if fmt == "text":
292
322
  data = render_text(verdict).encode()
323
+ if repeat_key is not None:
324
+ if len(_REPEATS) > 512:
325
+ _REPEATS.clear()
326
+ _REPEATS[repeat_key] = (time.monotonic(), data)
293
327
  captured(data)
294
328
  self.send_response(200)
295
329
  self.send_header("Content-Type", "text/plain; charset=utf-8")
@@ -10,10 +10,10 @@ from pathlib import Path
10
10
 
11
11
  def load_or_create_token(path: Path) -> str:
12
12
  if path.exists():
13
- return path.read_text().strip()
13
+ return path.read_text(encoding="utf-8").strip()
14
14
  path.parent.mkdir(parents=True, exist_ok=True)
15
15
  token = secrets.token_urlsafe(32)
16
- fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
16
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0), 0o600)
17
17
  with os.fdopen(fd, "w") as f:
18
18
  f.write(token + "\n")
19
19
  return token
@@ -31,7 +31,7 @@ def rotate_token(path: Path) -> str:
31
31
  token = secrets.token_urlsafe(32)
32
32
  tmp = path.with_name(path.name + ".new")
33
33
  tmp.unlink(missing_ok=True)
34
- fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
34
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0), 0o600)
35
35
  with os.fdopen(fd, "w") as f:
36
36
  f.write(token + "\n")
37
37
  os.replace(tmp, path)
@@ -42,9 +42,9 @@ def run(rounds: int = 3, http_calls: int = 300, shim_calls: int = 100) -> dict[s
42
42
  from .testkit import default_corpus_dir, load_cases
43
43
 
44
44
  out: dict[str, dict] = {}
45
- home = Path(tempfile.mkdtemp(prefix="ilb", dir="/tmp")) # short path: the Unix socket has a length limit
45
+ home = Path(tempfile.mkdtemp(prefix="ilb", dir="/tmp" if os.name != "nt" else None)) # short: socket path limit
46
46
  try:
47
- (home / "token").write_text("tok\n")
47
+ (home / "token").write_text("tok\n", encoding="utf-8")
48
48
  s = Settings(home=home)
49
49
  pol = Policy(); pol.mode = "enforce"; pol.trust.workspace_roots = [str(home)]
50
50
  d = Daemon(s, spool=Spool(s.spool_dir, fsync=False), repo=MemoryRepository(), policy=pol)
@@ -67,10 +67,10 @@ def run(rounds: int = 3, http_calls: int = 300, shim_calls: int = 100) -> dict[s
67
67
  t0 = time.perf_counter(); d.decide(ev); t.append((time.perf_counter() - t0) * 1000)
68
68
  out["probe 4 KB"] = _pct(t)
69
69
 
70
- sock = str(home / "interlockd.sock")
70
+ sock = str(home / "interlockd.sock") if s.unix_socket else None # no Unix sockets on Windows
71
71
  api = ApiServer(d, "tok", "127.0.0.1", 0, unix_socket=sock); api.start_background()
72
72
  host, port = api.address
73
- (home / "daemon.json").write_text(json.dumps({"listen": f"{host}:{port}"}))
73
+ (home / "daemon.json").write_text(json.dumps({"listen": f"{host}:{port}"}), encoding="utf-8")
74
74
  payload = {"session_id": "b", "cwd": str(home), "hook_event_name": "PreToolUse", "tool_name": "Bash",
75
75
  "tool_input": {"command": "ls -la"}, "tool_use_id": "t"}
76
76
  body = json.dumps({"adapter": {"name": "claude-code", "version": "bench"}, "payload": payload}).encode()
@@ -82,7 +82,7 @@ def run(rounds: int = 3, http_calls: int = 300, shim_calls: int = 100) -> dict[s
82
82
  out["http"] = _pct(t)
83
83
 
84
84
  if shutil.which("curl"):
85
- shim = home / "shim.sh"; shim.write_text(CLAUDE_SHIM_SRC.read_text().replace("__HARNESS__", "claude-code"))
85
+ shim = home / "shim.sh"; shim.write_text(CLAUDE_SHIM_SRC.read_text(encoding="utf-8").replace("__HARNESS__", "claude-code"), encoding="utf-8")
86
86
  for label, extra in (("shim (tcp)", {"INTERLOCK_URL": f"http://{host}:{port}"}), ("shim (unix)", {})):
87
87
  env = {**{k: v for k, v in os.environ.items() if k != "INTERLOCK_URL"}, "INTERLOCK_HOME": str(home), **extra}
88
88
  t = []