From 5db8c4f11fb4b151cb166e9ff142d3806c40ae3a Mon Sep 17 00:00:00 2001 From: Linh Ngo Date: Mon, 8 Jun 2026 21:36:27 +0700 Subject: [PATCH 1/3] fix(hooks): session-state exemption now checks filePath key for Create tool FP-1 exemption checked only toolArgs['path'] but Copilot CLI Create tool sends the file path as filePath in toolInput/input, not as path in toolArgs. This caused false-positive tentacle denials when writing research reports to .copilot/session-state/*/research/. Now checks all three locations: toolArgs.path, toolInput.filePath, input.filePath. --- hooks/enforce-tentacle.py | 7 ++++++- hooks/rules/tentacle.py | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/hooks/enforce-tentacle.py b/hooks/enforce-tentacle.py index 4c432538..bac06561 100644 --- a/hooks/enforce-tentacle.py +++ b/hooks/enforce-tentacle.py @@ -219,8 +219,13 @@ def main(): # FP-1: session-state files (e.g. /research outputs) are not project source; # skip threshold check so create/edit to these paths is never blocked. + # Note: Create tool sends path as filePath in toolInput/input, not "path" in toolArgs. if tool_name in ("edit", "create"): - file_path = (data.get("toolArgs") or {}).get("path", "") + file_path = ( + (data.get("toolArgs") or {}).get("path", "") + or (data.get("toolInput") or {}).get("filePath", "") + or (data.get("input") or {}).get("filePath", "") + ) _ss_abs = str(Path.home() / ".copilot" / "session-state") if file_path and (file_path.startswith(_ss_abs) or ".copilot/session-state" in file_path): return diff --git a/hooks/rules/tentacle.py b/hooks/rules/tentacle.py index e6f41ace..e32ca5ee 100644 --- a/hooks/rules/tentacle.py +++ b/hooks/rules/tentacle.py @@ -204,8 +204,13 @@ def evaluate(self, event, data): # FP-1: session-state files (e.g. /research outputs) are not project source; # skip threshold check so create/edit to these paths is never blocked. + # Note: Create tool sends path as filePath in toolInput/input, not "path" in toolArgs. if tool_name in ("edit", "create"): - file_path = tool_args.get("path", "") + file_path = ( + tool_args.get("path", "") + or tool_args.get("filePath", "") + or (data.get("input") or {}).get("filePath", "") + ) if file_path and is_session_path(file_path): return None From ea1d0fa847fbc2f15038727940e15eefb5bff71c Mon Sep 17 00:00:00 2001 From: Linh Ngo Date: Mon, 8 Jun 2026 21:41:34 +0700 Subject: [PATCH 2/3] fix(hooks): add toolArgs.filePath check + regression tests for create via filePath --- hooks/enforce-tentacle.py | 1 + tests/test_hooks.py | 30 +++++++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/hooks/enforce-tentacle.py b/hooks/enforce-tentacle.py index bac06561..b78ed54f 100644 --- a/hooks/enforce-tentacle.py +++ b/hooks/enforce-tentacle.py @@ -223,6 +223,7 @@ def main(): if tool_name in ("edit", "create"): file_path = ( (data.get("toolArgs") or {}).get("path", "") + or (data.get("toolArgs") or {}).get("filePath", "") or (data.get("toolInput") or {}).get("filePath", "") or (data.get("input") or {}).get("filePath", "") ) diff --git a/tests/test_hooks.py b/tests/test_hooks.py index b3278520..57168adf 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -3420,7 +3420,7 @@ def _el_run_edit18(path): f"Expected None, got: {_r19_create!r}", ) - # FP-1: edit to session-state path must be allowed + # FP-1: edit to session-state path (toolArgs.path) must be allowed _r19_edit = _ter19.evaluate( "preToolUse", { @@ -3434,6 +3434,34 @@ def _el_run_edit18(path): f"Expected None, got: {_r19_edit!r}", ) + # FP-1: create to session-state via toolInput.filePath must be allowed + _r19_create_input = _ter19.evaluate( + "preToolUse", + { + "toolName": "create", + "toolInput": {"filePath": _ss_create_path19}, + }, + ) + test( + "FP-1: TentacleEnforceRule create to session-state via toolInput.filePath → None", + _r19_create_input is None, + f"Expected None, got: {_r19_create_input!r}", + ) + + # FP-1: create to session-state via input.filePath must be allowed + _r19_create_input2 = _ter19.evaluate( + "preToolUse", + { + "toolName": "create", + "input": {"filePath": _ss_create_path19}, + }, + ) + test( + "FP-1: TentacleEnforceRule create to session-state via input.filePath → None", + _r19_create_input2 is None, + f"Expected None, got: {_r19_create_input2!r}", + ) + # Non-session edit at threshold must still deny _r19_deny = _ter19.evaluate( "preToolUse", From c1eb78253960edaf0b635576022f0fedf900f26b Mon Sep 17 00:00:00 2001 From: Linh Ngo Date: Mon, 8 Jun 2026 21:50:26 +0700 Subject: [PATCH 3/3] test(hooks): add standalone FP-1 parity tests for filePath variants Add Section 19e functional tests verifying that standalone enforce-tentacle.py correctly exempts session-state writes via toolInput.filePath, input.filePath, and toolArgs.filePath. --- tests/test_hooks.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 57168adf..388e86a1 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -3614,6 +3614,46 @@ def _run_standalone19e(tool_name, path): f"Expected deny output, got: {_out19e_deny!r}", ) + # FP-1 parity: standalone create to session-state via toolInput.filePath + def _run_standalone19e_raw(payload): + import sys as _sys + + _old_in, _old_out = _sys.stdin, _sys.stdout + _sys.stdin = _io19e.StringIO(payload) + _sys.stdout = _io19e.StringIO() + try: + _mod19e.main() + return _sys.stdout.getvalue().strip() + finally: + _sys.stdin, _sys.stdout = _old_in, _old_out + + _out19e_toolInput = _run_standalone19e_raw( + json.dumps({"toolName": "create", "toolInput": {"filePath": _ss_create19e}}) + ) + test( + "FP-1 parity: standalone create to session-state via toolInput.filePath → no output", + _out19e_toolInput == "", + f"Expected no output (allow), got: {_out19e_toolInput!r}", + ) + + _out19e_input = _run_standalone19e_raw( + json.dumps({"toolName": "create", "input": {"filePath": _ss_create19e}}) + ) + test( + "FP-1 parity: standalone create to session-state via input.filePath → no output", + _out19e_input == "", + f"Expected no output (allow), got: {_out19e_input!r}", + ) + + _out19e_toolArgs_filePath = _run_standalone19e_raw( + json.dumps({"toolName": "create", "toolArgs": {"filePath": _ss_create19e}}) + ) + test( + "FP-1 parity: standalone create to session-state via toolArgs.filePath → no output", + _out19e_toolArgs_filePath == "", + f"Expected no output (allow), got: {_out19e_toolArgs_filePath!r}", + ) + test("Section 19e standalone parity tests ran without exception", True) except Exception as _e19e: test("Section 19e standalone parity tests ran without exception", False, str(_e19e))