diff --git a/augment/hooks/test_augment_hooks.py b/augment/hooks/test_augment_hooks.py index b90827ae..57de1217 100644 --- a/augment/hooks/test_augment_hooks.py +++ b/augment/hooks/test_augment_hooks.py @@ -440,6 +440,117 @@ def test_posttooluse_canonicalization(self): }) self.assertEqual(mcp2["tool_name"], "mcp__unknown__unknown") + def test_bash_command_attaches_file_content_as_sibling(self): + """A launch-process (Bash) turn attaches file_content for existing files + referenced in the command — an absolute path, as a SIBLING on the tool_use + object (never inside tool_input).""" + d = tempfile.mkdtemp() + self.addCleanup(lambda: __import__('shutil').rmtree(d, ignore_errors=True)) + fp = os.path.join(d, 'app.py') + with open(fp, 'w') as f: + f.write('print(1)\n') + tu = unbound._augment_posttooluse_to_exchange({ + "tool_name": "launch-process", + "tool_input": {"command": f"cat {fp}"}, + "tool_output": "print(1)", "tool_use_id": "tb", + }) + self.assertEqual(tu["tool_name"], "Bash") + self.assertEqual(tu["tool_input"], {"command": f"cat {fp}"}) # file_content NOT inside + self.assertEqual(tu["file_content"][0]["path"], os.path.realpath(fp)) # absolute realpath + self.assertEqual(tu["file_content"][0]["content"], "print(1)\n") + + def test_bash_command_skips_binary_and_missing_files(self): + """A command referencing a binary or non-existent file adds no file_content + or file_path — we only send files whose text content we can actually read.""" + d = tempfile.mkdtemp() + self.addCleanup(lambda: __import__('shutil').rmtree(d, ignore_errors=True)) + img = os.path.join(d, 'img.png') + with open(img, 'wb') as f: + f.write(b'\x89PNG\r\n\x00\x00binary') + tu = unbound._augment_posttooluse_to_exchange({ + "tool_name": "launch-process", + "tool_input": {"command": f"open {img} /nope/missing.txt"}, + "tool_use_id": "tb2", + }) + self.assertEqual(tu["tool_name"], "Bash") + self.assertNotIn("file_content", tu) + self.assertNotIn("file_path", tu) + + def test_bash_command_relative_path_resolved_via_cwd(self): + """A relative path in a command resolves to an absolute path via the turn's cwd.""" + d = tempfile.mkdtemp() + self.addCleanup(lambda: __import__('shutil').rmtree(d, ignore_errors=True)) + with open(os.path.join(d, 'rel.txt'), 'w') as f: + f.write('data\n') + tu = unbound._augment_posttooluse_to_exchange({ + "tool_name": "launch-process", + "tool_input": {"command": "git add rel.txt"}, + "cwd": d, "tool_use_id": "tr", + }) + self.assertEqual(tu["file_path"], os.path.realpath(os.path.join(d, 'rel.txt'))) + self.assertEqual(tu["file_content"][0]["content"], "data\n") + + def test_bash_command_truncates_large_file(self): + """A >64KB text file referenced in a command is truncated with the flag set.""" + d = tempfile.mkdtemp() + self.addCleanup(lambda: __import__('shutil').rmtree(d, ignore_errors=True)) + big = os.path.join(d, 'big.log') + with open(big, 'w') as f: + f.write('A' * (80 * 1024)) + tu = unbound._augment_posttooluse_to_exchange({ + "tool_name": "launch-process", + "tool_input": {"command": f"tail {big}"}, + "tool_use_id": "tg", + }) + entry = tu["file_content"][0] + self.assertTrue(entry["truncated"]) + self.assertLessEqual(len(entry["content"].encode("utf-8")), 64 * 1024) + + def test_proc_sys_paths_are_excluded(self): + """/proc and /sys pseudo-filesystem paths are never treated as readable files.""" + self.assertTrue(unbound._is_excluded_path('/proc/self/environ')) + self.assertTrue(unbound._is_excluded_path('/sys/kernel/x')) + self.assertFalse(unbound._is_excluded_path('/home/u/proc_notes.txt')) + + def test_only_proc_sys_excluded_other_files_read(self): + """Only /proc and /sys are excluded; every other file (including sensitive ones + like .env or SSH keys) is read — we scan all files to detect data leaks.""" + for p in ('/h/proj/.env', '/h/.ssh/id_rsa', '/h/.aws/credentials', + '/etc/ssl/x.pem', '/h/app.py', '/h/README.md', 'C:\\Users\\x\\.env'): + self.assertFalse(unbound._is_excluded_path(p), p) + for p in ('/proc/self/environ', '/sys/kernel/x'): + self.assertTrue(unbound._is_excluded_path(p), p) + + def test_inline_content_read_for_normal_path(self): + """Inline content is attached for a normal path; only /proc/sys is dropped.""" + self.assertIsNotNone(unbound._make_file_entry('/proj/app.py', '/proj', inline_content='x')) + self.assertIsNone(unbound._make_file_entry('/proc/self/environ', None, inline_content='x')) + + def test_relative_path_uses_turn_cwd_not_process_cwd(self): + """A relative token resolves against the turn's cwd, never the hook's process cwd.""" + import shutil as _sh + proc = tempfile.mkdtemp() + ws = tempfile.mkdtemp() + self.addCleanup(lambda: (_sh.rmtree(proc, ignore_errors=True), _sh.rmtree(ws, ignore_errors=True))) + with open(os.path.join(proc, 'x.txt'), 'w') as f: + f.write('DECOY') + with open(os.path.join(ws, 'x.txt'), 'w') as f: + f.write('REAL') + old = os.getcwd() + os.chdir(proc) + self.addCleanup(os.chdir, old) + self.assertEqual(unbound._resolve_existing_file('x.txt', ws), + os.path.realpath(os.path.join(ws, 'x.txt'))) + self.assertIsNone(unbound._resolve_existing_file('x.txt', None)) + + def test_symlink_into_proc_is_excluded(self): + """A symlink pointing into /proc must not bypass the guard (realpath dereferences it).""" + d = tempfile.mkdtemp() + self.addCleanup(lambda: __import__('shutil').rmtree(d, ignore_errors=True)) + link = os.path.join(d, 'sneaky') + os.symlink('/proc/self/environ', link) + self.assertIsNone(unbound._resolve_existing_file(link, None)) + def test_multi_turn_does_not_cross_attach_tool_calls(self): # Two turns in one session: turn 1 (PostToolUse + Stop), then turn 2 # (PostToolUse). The turn-2 Stop exchange must include ONLY turn 2's call. diff --git a/augment/hooks/unbound.py b/augment/hooks/unbound.py index 5fe4d5d8..006ba2cb 100644 --- a/augment/hooks/unbound.py +++ b/augment/hooks/unbound.py @@ -14,6 +14,11 @@ import platform from urllib.parse import urlsplit, urlunsplit +# file-content telemetry caps +_MAX_FILE_CONTENT_BYTES = 64 * 1024 # per-file cap +_MAX_FILE_CONTENT_TOTAL_BYTES = 128 * 1024 # total across all files in one tool call +_MAX_FILE_CONTENT_FILES = 5 # max files per tool call + UNBOUND_GATEWAY_URL = os.environ.get( "UNBOUND_GATEWAY_URL", "https://api.getunbound.ai" @@ -1195,6 +1200,18 @@ def process_pre_tool_use(event: Dict, api_key: str) -> Dict: metadata['file_path'] = tool_input[key] break + # Attach the target file's contents for pre-tool telemetry. Write-style tools + # carry the new text inline (file may not exist on disk yet); others read disk. + if metadata.get('file_path'): + _attach_file_content( + metadata, + metadata['file_path'], + event.get('cwd'), + tool_input.get('content') if isinstance(tool_input.get('content'), str) else None, + ) + elif AUGMENT_TOOL_FAMILY.get(tool_name) == 'Bash' and tool_input.get('command'): + _attach_command_file_content(metadata, tool_input.get('command'), event.get('cwd')) + if is_mcp: # mcp_metadata is set only when the matcher has includeMCPMetadata AND the # surface populates it (the VS Code extension sends null). Prefer it; else @@ -1356,15 +1373,22 @@ def _io_response(): server, tool = r_server, r_tool server = server or 'unknown' tool = tool or raw_name or 'unknown' - return { + mcp_result = { 'type': 'PostToolUse', 'tool_name': f'mcp__{server}__{tool}', 'tool_input': tool_input, 'tool_response': _io_response(), 'tool_use_id': ev.get('tool_use_id'), } + mcp_path = None + if isinstance(tool_input, dict): + mcp_path = tool_input.get('file_path') or tool_input.get('path') + if mcp_path: + _attach_file_content(mcp_result, mcp_path, ev.get('cwd')) + return mcp_result canonical = AUGMENT_TOOL_FAMILY.get(raw_name, raw_name) + fc_path, fc_inline = '', None if canonical == 'Bash': canon_input = {'command': tool_input.get('command', '')} @@ -1373,6 +1397,12 @@ def _io_response(): path = (tool_input.get('file_path') or tool_input.get('path') or tool_input.get('filePath') or first_change.get('path') or '') canon_input = {'file_path': path} + # Reuse post-execution captured content (file_changes / tool_output) as + # inline_content so we don't re-read disk; fall back to disk only if absent. + inline = (first_change.get('content') or tool_input.get('content') + or (tool_output if canonical == 'Read' else None)) + fc_path = path + fc_inline = inline if isinstance(inline, str) else None if canonical == 'Read': tool_response = {'content': tool_output} if tool_output else {} else: @@ -1395,13 +1425,20 @@ def _io_response(): canon_input = tool_input tool_response = _io_response() - return { + result = { 'type': 'PostToolUse', 'tool_name': canonical, 'tool_input': canon_input, 'tool_response': tool_response, 'tool_use_id': ev.get('tool_use_id'), } + # file_content rides as a sibling on the tool_use object (uniform with the + # other tools), never inside tool_input. + if fc_path: + _attach_file_content(result, fc_path, ev.get('cwd'), fc_inline) + elif canonical == 'Bash' and canon_input.get('command'): + _attach_command_file_content(result, canon_input['command'], ev.get('cwd')) + return result def build_llm_exchange(event: Dict, post_tool_events: List[Dict], model: Optional[str] = None) -> Optional[Dict]: @@ -2009,6 +2046,171 @@ def _resolve_cwd(event: Dict) -> Optional[str]: return None +def _cap_file_text(text): + """Return (text, truncated) with text capped to _MAX_FILE_CONTENT_BYTES of UTF-8.""" + encoded = text.encode('utf-8') + if len(encoded) <= _MAX_FILE_CONTENT_BYTES: + return text, False + return encoded[:_MAX_FILE_CONTENT_BYTES].decode('utf-8', errors='ignore'), True + + +def _abspath(path, cwd): + """Resolve path to a normalized absolute path (cwd-joined if relative), or None.""" + try: + if not path or not isinstance(path, str): + return None + p = os.path.expanduser(path) + if not os.path.isabs(p): + if not cwd: + return None + p = os.path.join(cwd, p) + return os.path.normpath(p) + except Exception: + return None + + +def _is_excluded_path(abspath): + """Exclude only the /proc and /sys pseudo-filesystems (kernel/process state, not real files).""" + if not isinstance(abspath, str): + return False + return abspath.startswith(('/proc/', '/sys/')) or abspath in ('/proc', '/sys') + + +def _resolve_existing_file(path, cwd): + """Absolute realpath of an existing file. Absolute paths are used directly; a relative + path resolves against the tool turn's cwd only (never the hook's own process cwd).""" + try: + if not path or not isinstance(path, str): + return None + expanded = os.path.expanduser(path) + if os.path.isabs(expanded): + cand = expanded + elif cwd: + cand = os.path.join(cwd, expanded) + else: + return None + real = os.path.realpath(cand) + if os.path.isfile(real) and not _is_excluded_path(real): + return real + return None + except Exception: + return None + + +def _read_file_text(abspath): + """Read a file as capped UTF-8 text -> (text, truncated), or None if binary/unreadable.""" + try: + # Read content of all files including sensitive files to identify and prevent data leaks. + with open(abspath, 'rb') as f: + raw = f.read(_MAX_FILE_CONTENT_BYTES + 1) + truncated = len(raw) > _MAX_FILE_CONTENT_BYTES + raw = raw[:_MAX_FILE_CONTENT_BYTES] + if b'\x00' in raw: + return None + if not truncated: + try: + return raw.decode('utf-8'), False + except UnicodeDecodeError: + return None + for _ in range(4): # tail may split a multibyte char; trim up to 3 bytes + try: + return raw.decode('utf-8'), True + except UnicodeDecodeError: + raw = raw[:-1] + return None + except Exception: + return None + + +def _make_file_entry(path, cwd, inline_content=None): + """Build one {path, content, truncated} entry (absolute path, readable text), or None. + Write-style tools pass new text as inline_content; otherwise the file must exist and be text.""" + try: + if isinstance(inline_content, str): + abspath = _abspath(path, cwd) + if abspath is None or _is_excluded_path(abspath) or _is_excluded_path(os.path.realpath(abspath)): + return None + content, truncated = _cap_file_text(inline_content) + return {'path': abspath, 'content': content, 'truncated': truncated} + abspath = _resolve_existing_file(path, cwd) + if abspath is None: + return None + res = _read_file_text(abspath) + if res is None: + return None + content, truncated = res + return {'path': abspath, 'content': content, 'truncated': truncated} + except Exception: + return None + + +def _append_file_entry(entries, path, cwd, inline_content=None): + """Append one entry to entries, honoring the file-count/total-byte caps and skipping dups.""" + try: + if len(entries) >= _MAX_FILE_CONTENT_FILES: + return + entry = _make_file_entry(path, cwd, inline_content) + if entry is None or any(e.get('path') == entry['path'] for e in entries): + return + total = sum(len((e.get('content') or '').encode('utf-8')) for e in entries) + if total + len((entry.get('content') or '').encode('utf-8')) > _MAX_FILE_CONTENT_TOTAL_BYTES: + return + entries.append(entry) + except Exception: + return + + +def _extract_command_file_paths(command, cwd): + """Absolute paths of existing files referenced as tokens in a shell command (str or argv list). + Only tokens that resolve to a real file are kept; flags/dirs/devices are skipped.""" + paths = [] + try: + if isinstance(command, list): + command = ' '.join(t for t in command if isinstance(t, str)) + if not command or not isinstance(command, str): + return paths + seen = set() + for raw_tok in command.split()[:256]: + tok = raw_tok.strip().strip('"\'').lstrip('<>') + if not tok or tok.startswith('-'): + continue + abspath = _resolve_existing_file(tok, cwd) + if not abspath or abspath in seen: + continue + seen.add(abspath) + paths.append(abspath) + if len(paths) >= _MAX_FILE_CONTENT_FILES: + break + except Exception: + return paths + return paths + + +def _attach_file_content(target, file_path, cwd, inline_content=None): + """Attach target['file_content'] (list of {path, content, truncated}) for a file tool.""" + try: + entries = target.get('file_content') or [] + _append_file_entry(entries, file_path, cwd, inline_content) + if entries: + target['file_content'] = entries + except Exception: + return + + +def _attach_command_file_content(target, command, cwd): + """Attach file_path + file_content for the existing files referenced in a shell command.""" + try: + entries = target.get('file_content') or [] + for abspath in _extract_command_file_paths(command, cwd): + _append_file_entry(entries, abspath, cwd) + if entries: + target['file_content'] = entries + if not target.get('file_path'): + target['file_path'] = entries[0]['path'] + except Exception: + return + + def main(): global _cached_api_key api_key = get_api_key() diff --git a/claude-code/hooks/unbound.py b/claude-code/hooks/unbound.py index b837192d..b82b3d56 100644 --- a/claude-code/hooks/unbound.py +++ b/claude-code/hooks/unbound.py @@ -14,6 +14,11 @@ import tempfile import platform +# file-content telemetry caps +_MAX_FILE_CONTENT_BYTES = 64 * 1024 # per-file cap +_MAX_FILE_CONTENT_TOTAL_BYTES = 128 * 1024 # total across all files in one tool call +_MAX_FILE_CONTENT_FILES = 5 # max files per tool call + UNBOUND_GATEWAY_URL = os.environ.get( "UNBOUND_GATEWAY_URL", "https://api.getunbound.ai" @@ -825,26 +830,6 @@ def _is_uuid(name: str) -> bool: return bool(name) and bool(_MCP_UUID_RE.match(name)) -_CLAUDE_SESSION_SUBDIRS = ('claude-code-sessions', 'local-agent-mode-sessions') - - -def _claude_session_dirs() -> list: - try: - home = Path.home() - if sys.platform == 'darwin': - base = home / 'Library' / 'Application Support' / 'Claude' - elif sys.platform.startswith('win'): - appdata = os.environ.get('APPDATA') - if not appdata: - return [] - base = Path(appdata) / 'Claude' - else: - base = home / '.config' / 'Claude' - return [base / sub for sub in _CLAUDE_SESSION_SUBDIRS] - except Exception: - return [] - - _HOOK_SCRIPT_RUNTIMES = { 'node', 'nodejs', 'bun', 'deno', 'python', 'python2', 'python3', 'py', 'ruby', 'dart', 'php', 'perl', 'rscript', @@ -918,36 +903,42 @@ def _compute_script_hash(command: Optional[str], args: Optional[List], cwd: Opti return None -def _session_file_created_at(path) -> float: - try: - st = path.stat() - return getattr(st, 'st_birthtime', None) or st.st_mtime - except Exception: - return 0.0 +_CLAUDE_SESSION_SUBDIRS = ('claude-code-sessions', 'local-agent-mode-sessions') -def _resolve_claude_code_session_connector(server_uuid: str) -> Optional[tuple]: - if not _is_uuid(server_uuid): +def _session_file_from_cwd(cwd: Optional[str]) -> Optional[Path]: + if not cwd: + return None + normalised = cwd.replace('\\', '/').rstrip('/') + suffix = '/outputs' + if not normalised.endswith(suffix): return None + candidate = Path(normalised[:-len(suffix)] + '.json') try: - latest = None - latest_ts = -1.0 - for base in _claude_session_dirs(): - if not base or not base.exists(): - continue + resolved = candidate.resolve() + except Exception: + return None + for support in _claude_desktop_support_dirs(): + for sub in _CLAUDE_SESSION_SUBDIRS: try: - candidates = base.glob('*/*/local_*.json') + resolved.relative_to((support / sub).resolve()) + return candidate except Exception: continue - for f in candidates: - ts = _session_file_created_at(f) - if ts > latest_ts: - latest_ts, latest = ts, f - if latest is None: - return None + return None + + +def _resolve_claude_code_session_connector(server_uuid: str, cwd: Optional[str] = None) -> Optional[tuple]: + if not _is_uuid(server_uuid): + return None + session_file = _session_file_from_cwd(cwd) + if session_file is None: + return None + try: try: - data = json.loads(latest.read_text(encoding='utf-8')) - except Exception: + data = json.loads(session_file.read_text(encoding='utf-8')) + except Exception as exc: + log_error(f"mcp cc-session resolve miss (unreadable {session_file}): {exc}", 'mcp_connector') return None for entry in (data.get('remoteMcpServersConfig') or []): if isinstance(entry, dict) and (entry.get('uuid') or '').lower() == server_uuid.lower(): @@ -960,6 +951,7 @@ def _resolve_claude_code_session_connector(server_uuid: str) -> Optional[tuple]: cfg["url"] = url cfg["type"] = "http" return (name, cfg) + log_error(f"mcp cc-session resolve miss: {server_uuid}", 'mcp_connector') return None except Exception as exc: log_error(f"mcp cc-session resolve error: {server_uuid}: {exc}", 'mcp_connector') @@ -1002,6 +994,171 @@ def _read_script_body_b64(command, args, cwd): return None +def _cap_file_text(text): + """Return (text, truncated) with text capped to _MAX_FILE_CONTENT_BYTES of UTF-8.""" + encoded = text.encode('utf-8') + if len(encoded) <= _MAX_FILE_CONTENT_BYTES: + return text, False + return encoded[:_MAX_FILE_CONTENT_BYTES].decode('utf-8', errors='ignore'), True + + +def _abspath(path, cwd): + """Resolve path to a normalized absolute path (cwd-joined if relative), or None.""" + try: + if not path or not isinstance(path, str): + return None + p = os.path.expanduser(path) + if not os.path.isabs(p): + if not cwd: + return None + p = os.path.join(cwd, p) + return os.path.normpath(p) + except Exception: + return None + + +def _is_excluded_path(abspath): + """Exclude only the /proc and /sys pseudo-filesystems (kernel/process state, not real files).""" + if not isinstance(abspath, str): + return False + return abspath.startswith(('/proc/', '/sys/')) or abspath in ('/proc', '/sys') + + +def _resolve_existing_file(path, cwd): + """Absolute realpath of an existing file. Absolute paths are used directly; a relative + path resolves against the tool turn's cwd only (never the hook's own process cwd).""" + try: + if not path or not isinstance(path, str): + return None + expanded = os.path.expanduser(path) + if os.path.isabs(expanded): + cand = expanded + elif cwd: + cand = os.path.join(cwd, expanded) + else: + return None + real = os.path.realpath(cand) + if os.path.isfile(real) and not _is_excluded_path(real): + return real + return None + except Exception: + return None + + +def _read_file_text(abspath): + """Read a file as capped UTF-8 text -> (text, truncated), or None if binary/unreadable.""" + try: + # Read content of all files including sensitive files to identify and prevent data leaks. + with open(abspath, 'rb') as f: + raw = f.read(_MAX_FILE_CONTENT_BYTES + 1) + truncated = len(raw) > _MAX_FILE_CONTENT_BYTES + raw = raw[:_MAX_FILE_CONTENT_BYTES] + if b'\x00' in raw: + return None + if not truncated: + try: + return raw.decode('utf-8'), False + except UnicodeDecodeError: + return None + for _ in range(4): # tail may split a multibyte char; trim up to 3 bytes + try: + return raw.decode('utf-8'), True + except UnicodeDecodeError: + raw = raw[:-1] + return None + except Exception: + return None + + +def _make_file_entry(path, cwd, inline_content=None): + """Build one {path, content, truncated} entry (absolute path, readable text), or None. + Write-style tools pass new text as inline_content; otherwise the file must exist and be text.""" + try: + if isinstance(inline_content, str): + abspath = _abspath(path, cwd) + if abspath is None or _is_excluded_path(abspath) or _is_excluded_path(os.path.realpath(abspath)): + return None + content, truncated = _cap_file_text(inline_content) + return {'path': abspath, 'content': content, 'truncated': truncated} + abspath = _resolve_existing_file(path, cwd) + if abspath is None: + return None + res = _read_file_text(abspath) + if res is None: + return None + content, truncated = res + return {'path': abspath, 'content': content, 'truncated': truncated} + except Exception: + return None + + +def _append_file_entry(entries, path, cwd, inline_content=None): + """Append one entry to entries, honoring the file-count/total-byte caps and skipping dups.""" + try: + if len(entries) >= _MAX_FILE_CONTENT_FILES: + return + entry = _make_file_entry(path, cwd, inline_content) + if entry is None or any(e.get('path') == entry['path'] for e in entries): + return + total = sum(len((e.get('content') or '').encode('utf-8')) for e in entries) + if total + len((entry.get('content') or '').encode('utf-8')) > _MAX_FILE_CONTENT_TOTAL_BYTES: + return + entries.append(entry) + except Exception: + return + + +def _extract_command_file_paths(command, cwd): + """Absolute paths of existing files referenced as tokens in a shell command (str or argv list). + Only tokens that resolve to a real file are kept; flags/dirs/devices are skipped.""" + paths = [] + try: + if isinstance(command, list): + command = ' '.join(t for t in command if isinstance(t, str)) + if not command or not isinstance(command, str): + return paths + seen = set() + for raw_tok in command.split()[:256]: + tok = raw_tok.strip().strip('"\'').lstrip('<>') + if not tok or tok.startswith('-'): + continue + abspath = _resolve_existing_file(tok, cwd) + if not abspath or abspath in seen: + continue + seen.add(abspath) + paths.append(abspath) + if len(paths) >= _MAX_FILE_CONTENT_FILES: + break + except Exception: + return paths + return paths + + +def _attach_file_content(target, file_path, cwd, inline_content=None): + """Attach target['file_content'] (list of {path, content, truncated}) for a file tool.""" + try: + entries = target.get('file_content') or [] + _append_file_entry(entries, file_path, cwd, inline_content) + if entries: + target['file_content'] = entries + except Exception: + return + + +def _attach_command_file_content(target, command, cwd): + """Attach file_path + file_content for the existing files referenced in a shell command.""" + try: + entries = target.get('file_content') or [] + for abspath in _extract_command_file_paths(command, cwd): + _append_file_entry(entries, abspath, cwd) + if entries: + target['file_content'] = entries + if not target.get('file_path'): + target['file_path'] = entries[0]['path'] + except Exception: + return + + def _read_mcp_server_config(server_name: str, config_path: Path, cwd: Optional[str] = None) -> Optional[Dict]: try: if not config_path.exists(): @@ -1340,6 +1497,12 @@ def process_pre_tool_use(event: Dict, api_key: str) -> Dict: tool_input = event.get('tool_input') or {} if 'file_path' in tool_input: metadata['file_path'] = tool_input['file_path'] + _attach_file_content( + metadata, tool_input.get('file_path'), event.get('cwd'), + tool_input.get('content'), + ) + elif tool_name == 'Bash' and tool_input.get('command'): + _attach_command_file_content(metadata, tool_input['command'], event.get('cwd')) if is_mcp: # Parse mcp____ to extract server and tool for gateway matching @@ -1367,7 +1530,7 @@ def process_pre_tool_use(event: Dict, api_key: str) -> Dict: if plugin_cfg: metadata['mcp_server_config'] = plugin_cfg else: - session_connector = _resolve_claude_code_session_connector(mcp_server_name) + session_connector = _resolve_claude_code_session_connector(mcp_server_name, cwd) if session_connector: display_name, connector_cfg = session_connector metadata['mcp_server'] = display_name @@ -1518,13 +1681,28 @@ def build_llm_exchange(events: List[Dict], stop_assistant_message: Optional[str] if tool_response['content'] == tool_input['content']: tool_response = {k: v for k, v in tool_response.items() if k != 'content'} - assistant_tool_uses.append({ + tool_use_obj = { 'type': 'PostToolUse', 'tool_name': tool_name, 'tool_input': tool_input, 'tool_response': tool_response, 'tool_use_id': event.get('tool_use_id') - }) + } + if isinstance(tool_input, dict) and 'file_path' in tool_input: + _inline = tool_input.get('content') or None + if not isinstance(_inline, str) and isinstance(tool_response, dict): + _resp_content = tool_response.get('content') + if isinstance(_resp_content, str): + _inline = _resp_content + _attach_file_content( + tool_use_obj, tool_input.get('file_path'), + event.get('cwd'), _inline, + ) + elif tool_name == 'Bash' and isinstance(tool_input, dict) and tool_input.get('command'): + _attach_command_file_content( + tool_use_obj, tool_input['command'], event.get('cwd'), + ) + assistant_tool_uses.append(tool_use_obj) if user_prompt: messages.append({'role': 'user', 'content': user_prompt}) diff --git a/codex/hooks/unbound.py b/codex/hooks/unbound.py index e618b9c1..a5d6df1b 100644 --- a/codex/hooks/unbound.py +++ b/codex/hooks/unbound.py @@ -13,6 +13,11 @@ import tempfile import base64 +# file-content telemetry caps +_MAX_FILE_CONTENT_BYTES = 64 * 1024 # per-file cap +_MAX_FILE_CONTENT_TOTAL_BYTES = 128 * 1024 # total across all files in one tool call +_MAX_FILE_CONTENT_FILES = 5 # max files per tool call + UNBOUND_GATEWAY_URL = os.environ.get( "UNBOUND_GATEWAY_URL", "https://api.getunbound.ai" @@ -674,6 +679,171 @@ def _compute_script_hash(command, args, cwd): return None +def _cap_file_text(text): + """Return (text, truncated) with text capped to _MAX_FILE_CONTENT_BYTES of UTF-8.""" + encoded = text.encode('utf-8') + if len(encoded) <= _MAX_FILE_CONTENT_BYTES: + return text, False + return encoded[:_MAX_FILE_CONTENT_BYTES].decode('utf-8', errors='ignore'), True + + +def _abspath(path, cwd): + """Resolve path to a normalized absolute path (cwd-joined if relative), or None.""" + try: + if not path or not isinstance(path, str): + return None + p = os.path.expanduser(path) + if not os.path.isabs(p): + if not cwd: + return None + p = os.path.join(cwd, p) + return os.path.normpath(p) + except Exception: + return None + + +def _is_excluded_path(abspath): + """Exclude only the /proc and /sys pseudo-filesystems (kernel/process state, not real files).""" + if not isinstance(abspath, str): + return False + return abspath.startswith(('/proc/', '/sys/')) or abspath in ('/proc', '/sys') + + +def _resolve_existing_file(path, cwd): + """Absolute realpath of an existing file. Absolute paths are used directly; a relative + path resolves against the tool turn's cwd only (never the hook's own process cwd).""" + try: + if not path or not isinstance(path, str): + return None + expanded = os.path.expanduser(path) + if os.path.isabs(expanded): + cand = expanded + elif cwd: + cand = os.path.join(cwd, expanded) + else: + return None + real = os.path.realpath(cand) + if os.path.isfile(real) and not _is_excluded_path(real): + return real + return None + except Exception: + return None + + +def _read_file_text(abspath): + """Read a file as capped UTF-8 text -> (text, truncated), or None if binary/unreadable.""" + try: + # Read content of all files including sensitive files to identify and prevent data leaks. + with open(abspath, 'rb') as f: + raw = f.read(_MAX_FILE_CONTENT_BYTES + 1) + truncated = len(raw) > _MAX_FILE_CONTENT_BYTES + raw = raw[:_MAX_FILE_CONTENT_BYTES] + if b'\x00' in raw: + return None + if not truncated: + try: + return raw.decode('utf-8'), False + except UnicodeDecodeError: + return None + for _ in range(4): # tail may split a multibyte char; trim up to 3 bytes + try: + return raw.decode('utf-8'), True + except UnicodeDecodeError: + raw = raw[:-1] + return None + except Exception: + return None + + +def _make_file_entry(path, cwd, inline_content=None): + """Build one {path, content, truncated} entry (absolute path, readable text), or None. + Write-style tools pass new text as inline_content; otherwise the file must exist and be text.""" + try: + if isinstance(inline_content, str): + abspath = _abspath(path, cwd) + if abspath is None or _is_excluded_path(abspath) or _is_excluded_path(os.path.realpath(abspath)): + return None + content, truncated = _cap_file_text(inline_content) + return {'path': abspath, 'content': content, 'truncated': truncated} + abspath = _resolve_existing_file(path, cwd) + if abspath is None: + return None + res = _read_file_text(abspath) + if res is None: + return None + content, truncated = res + return {'path': abspath, 'content': content, 'truncated': truncated} + except Exception: + return None + + +def _append_file_entry(entries, path, cwd, inline_content=None): + """Append one entry to entries, honoring the file-count/total-byte caps and skipping dups.""" + try: + if len(entries) >= _MAX_FILE_CONTENT_FILES: + return + entry = _make_file_entry(path, cwd, inline_content) + if entry is None or any(e.get('path') == entry['path'] for e in entries): + return + total = sum(len((e.get('content') or '').encode('utf-8')) for e in entries) + if total + len((entry.get('content') or '').encode('utf-8')) > _MAX_FILE_CONTENT_TOTAL_BYTES: + return + entries.append(entry) + except Exception: + return + + +def _extract_command_file_paths(command, cwd): + """Absolute paths of existing files referenced as tokens in a shell command (str or argv list). + Only tokens that resolve to a real file are kept; flags/dirs/devices are skipped.""" + paths = [] + try: + if isinstance(command, list): + command = ' '.join(t for t in command if isinstance(t, str)) + if not command or not isinstance(command, str): + return paths + seen = set() + for raw_tok in command.split()[:256]: + tok = raw_tok.strip().strip('"\'').lstrip('<>') + if not tok or tok.startswith('-'): + continue + abspath = _resolve_existing_file(tok, cwd) + if not abspath or abspath in seen: + continue + seen.add(abspath) + paths.append(abspath) + if len(paths) >= _MAX_FILE_CONTENT_FILES: + break + except Exception: + return paths + return paths + + +def _attach_file_content(target, file_path, cwd, inline_content=None): + """Attach target['file_content'] (list of {path, content, truncated}) for a file tool.""" + try: + entries = target.get('file_content') or [] + _append_file_entry(entries, file_path, cwd, inline_content) + if entries: + target['file_content'] = entries + except Exception: + return + + +def _attach_command_file_content(target, command, cwd): + """Attach file_path + file_content for the existing files referenced in a shell command.""" + try: + entries = target.get('file_content') or [] + for abspath in _extract_command_file_paths(command, cwd): + _append_file_entry(entries, abspath, cwd) + if entries: + target['file_content'] = entries + if not target.get('file_path'): + target['file_path'] = entries[0]['path'] + except Exception: + return + + def _augment_script_hash(result, cwd): """Add scriptHash to an MCP server config when it runs a local script, so the gateway can fingerprint it as `script:`.""" @@ -855,6 +1025,13 @@ def process_pre_tool_use(event: Dict, api_key: str) -> Dict: # Build metadata with the raw event metadata = dict(event) + tool_input = event.get('tool_input') or {} + if tool_input.get('file_path'): + metadata['file_path'] = tool_input.get('file_path') + _attach_file_content(metadata, tool_input.get('file_path'), event.get('cwd'), tool_input.get('content')) + elif tool_name == 'Bash' and tool_input.get('command'): + _attach_command_file_content(metadata, tool_input.get('command'), event.get('cwd')) + if is_mcp: # Parse mcp____ to extract server and tool for gateway matching parts = tool_name[len(MCP_TOOL_PREFIX):].split('__', 1) @@ -1224,6 +1401,14 @@ def process_stop_event(event: Dict, api_key: str): # Parse tool uses from Codex transcript (function_call/function_call_output pairs) assistant_tool_uses = parse_codex_transcript_for_tools(transcript_path, user_prompt_timestamp) + cwd = event.get('cwd') + for tool_use in assistant_tool_uses: + tu_input = tool_use.get('tool_input') or {} + if tu_input.get('file_path'): + _attach_file_content(tool_use, tu_input.get('file_path'), cwd, tu_input.get('content')) + elif tool_use.get('tool_name') == 'Bash' and tu_input.get('command'): + _attach_command_file_content(tool_use, tu_input.get('command'), cwd) + assistant_msg = { 'role': 'assistant', 'content': last_assistant_message or '' diff --git a/copilot/hooks/unbound.py b/copilot/hooks/unbound.py index 781f4b19..239c543a 100644 --- a/copilot/hooks/unbound.py +++ b/copilot/hooks/unbound.py @@ -17,6 +17,11 @@ import re from urllib.parse import urlsplit, urlunsplit +# file-content telemetry caps +_MAX_FILE_CONTENT_BYTES = 64 * 1024 # per-file cap +_MAX_FILE_CONTENT_TOTAL_BYTES = 128 * 1024 # total across all files in one tool call +_MAX_FILE_CONTENT_FILES = 5 # max files per tool call + UNBOUND_GATEWAY_URL = os.environ.get( "UNBOUND_GATEWAY_URL", "https://api.getunbound.ai" ).rstrip("/") @@ -652,6 +657,171 @@ def _compute_script_hash(command, args, cwd): return None +def _cap_file_text(text): + """Return (text, truncated) with text capped to _MAX_FILE_CONTENT_BYTES of UTF-8.""" + encoded = text.encode('utf-8') + if len(encoded) <= _MAX_FILE_CONTENT_BYTES: + return text, False + return encoded[:_MAX_FILE_CONTENT_BYTES].decode('utf-8', errors='ignore'), True + + +def _abspath(path, cwd): + """Resolve path to a normalized absolute path (cwd-joined if relative), or None.""" + try: + if not path or not isinstance(path, str): + return None + p = os.path.expanduser(path) + if not os.path.isabs(p): + if not cwd: + return None + p = os.path.join(cwd, p) + return os.path.normpath(p) + except Exception: + return None + + +def _is_excluded_path(abspath): + """Exclude only the /proc and /sys pseudo-filesystems (kernel/process state, not real files).""" + if not isinstance(abspath, str): + return False + return abspath.startswith(('/proc/', '/sys/')) or abspath in ('/proc', '/sys') + + +def _resolve_existing_file(path, cwd): + """Absolute realpath of an existing file. Absolute paths are used directly; a relative + path resolves against the tool turn's cwd only (never the hook's own process cwd).""" + try: + if not path or not isinstance(path, str): + return None + expanded = os.path.expanduser(path) + if os.path.isabs(expanded): + cand = expanded + elif cwd: + cand = os.path.join(cwd, expanded) + else: + return None + real = os.path.realpath(cand) + if os.path.isfile(real) and not _is_excluded_path(real): + return real + return None + except Exception: + return None + + +def _read_file_text(abspath): + """Read a file as capped UTF-8 text -> (text, truncated), or None if binary/unreadable.""" + try: + # Read content of all files including sensitive files to identify and prevent data leaks. + with open(abspath, 'rb') as f: + raw = f.read(_MAX_FILE_CONTENT_BYTES + 1) + truncated = len(raw) > _MAX_FILE_CONTENT_BYTES + raw = raw[:_MAX_FILE_CONTENT_BYTES] + if b'\x00' in raw: + return None + if not truncated: + try: + return raw.decode('utf-8'), False + except UnicodeDecodeError: + return None + for _ in range(4): # tail may split a multibyte char; trim up to 3 bytes + try: + return raw.decode('utf-8'), True + except UnicodeDecodeError: + raw = raw[:-1] + return None + except Exception: + return None + + +def _make_file_entry(path, cwd, inline_content=None): + """Build one {path, content, truncated} entry (absolute path, readable text), or None. + Write-style tools pass new text as inline_content; otherwise the file must exist and be text.""" + try: + if isinstance(inline_content, str): + abspath = _abspath(path, cwd) + if abspath is None or _is_excluded_path(abspath) or _is_excluded_path(os.path.realpath(abspath)): + return None + content, truncated = _cap_file_text(inline_content) + return {'path': abspath, 'content': content, 'truncated': truncated} + abspath = _resolve_existing_file(path, cwd) + if abspath is None: + return None + res = _read_file_text(abspath) + if res is None: + return None + content, truncated = res + return {'path': abspath, 'content': content, 'truncated': truncated} + except Exception: + return None + + +def _append_file_entry(entries, path, cwd, inline_content=None): + """Append one entry to entries, honoring the file-count/total-byte caps and skipping dups.""" + try: + if len(entries) >= _MAX_FILE_CONTENT_FILES: + return + entry = _make_file_entry(path, cwd, inline_content) + if entry is None or any(e.get('path') == entry['path'] for e in entries): + return + total = sum(len((e.get('content') or '').encode('utf-8')) for e in entries) + if total + len((entry.get('content') or '').encode('utf-8')) > _MAX_FILE_CONTENT_TOTAL_BYTES: + return + entries.append(entry) + except Exception: + return + + +def _extract_command_file_paths(command, cwd): + """Absolute paths of existing files referenced as tokens in a shell command (str or argv list). + Only tokens that resolve to a real file are kept; flags/dirs/devices are skipped.""" + paths = [] + try: + if isinstance(command, list): + command = ' '.join(t for t in command if isinstance(t, str)) + if not command or not isinstance(command, str): + return paths + seen = set() + for raw_tok in command.split()[:256]: + tok = raw_tok.strip().strip('"\'').lstrip('<>') + if not tok or tok.startswith('-'): + continue + abspath = _resolve_existing_file(tok, cwd) + if not abspath or abspath in seen: + continue + seen.add(abspath) + paths.append(abspath) + if len(paths) >= _MAX_FILE_CONTENT_FILES: + break + except Exception: + return paths + return paths + + +def _attach_file_content(target, file_path, cwd, inline_content=None): + """Attach target['file_content'] (list of {path, content, truncated}) for a file tool.""" + try: + entries = target.get('file_content') or [] + _append_file_entry(entries, file_path, cwd, inline_content) + if entries: + target['file_content'] = entries + except Exception: + return + + +def _attach_command_file_content(target, command, cwd): + """Attach file_path + file_content for the existing files referenced in a shell command.""" + try: + entries = target.get('file_content') or [] + for abspath in _extract_command_file_paths(command, cwd): + _append_file_entry(entries, abspath, cwd) + if entries: + target['file_content'] = entries + if not target.get('file_path'): + target['file_path'] = entries[0]['path'] + except Exception: + return + + def _augment_script_hash(result, cwd): """Add scriptHash to an MCP server config when it runs a local script, so the gateway can fingerprint it as `script:`.""" @@ -1093,6 +1263,10 @@ def process_pre_tool_use(event, api_key): file_path = tool_input.get('filePath') or tool_input.get('path') or tool_input.get('file_path') if file_path: metadata['file_path'] = file_path + _attach_file_content(metadata, file_path, event.get('cwd'), + tool_input.get('content') or tool_input.get('file_text')) + elif canonical == 'Bash' and tool_input.get('command'): + _attach_command_file_content(metadata, tool_input.get('command'), event.get('cwd')) if mcp_server is not None: metadata['mcp_server'] = mcp_server @@ -1233,7 +1407,7 @@ def _extract_patch_target_path(args): return m.group(1).strip() if m else '' -def map_copilot_tool(name, args, result_content): +def map_copilot_tool(name, args, result_content, cwd=None): """Map a Copilot tool call to a cursor-style tool_use entry. Returns None for internal orchestration tools (intentionally not emitted). @@ -1272,11 +1446,18 @@ def map_copilot_tool(name, args, result_content): 'tool_input': args, 'result_json': result_content or '', } + # Uniform file_content for file-oriented tools; reuse the content already on + # the entry, falling back to a disk read (relative paths resolved via cwd). + file_path = entry.get('file_path') + if file_path: + _attach_file_content(entry, file_path, cwd, entry.get('content') or None) + elif entry.get('command'): + _attach_command_file_content(entry, entry.get('command'), cwd) # Drop empty-string values. return {k: v for k, v in entry.items() if v != ''} -def build_exchange_from_transcript(transcript_path, fallback_session_id, session_start_model=None): +def build_exchange_from_transcript(transcript_path, fallback_session_id, session_start_model=None, cwd=None): """Parse a Copilot JSONL transcript into a cursor-style LLM exchange. Reads defensively — blank or unparseable lines are skipped, never raised.""" @@ -1381,7 +1562,7 @@ def _register(call_id): tool_use = [] for call_id in tool_calls: call = tool_data[call_id] - mapped = map_copilot_tool(call['name'], call['arguments'], call['result']) + mapped = map_copilot_tool(call['name'], call['arguments'], call['result'], cwd) # `is not None` (not truthiness): None means a consciously-dropped internal # tool; an empty-but-valid dict should still be appended. if mapped is not None: @@ -1833,6 +2014,7 @@ def main(): exchange = build_exchange_from_transcript( event.get('transcript_path'), session_id, session_start_model=get_session_start_model(session_id), + cwd=event.get('cwd'), ) if exchange: # Turn boundaries from event-fire times diff --git a/cursor/unbound.py b/cursor/unbound.py index a266fd9c..e93b7a84 100644 --- a/cursor/unbound.py +++ b/cursor/unbound.py @@ -19,6 +19,11 @@ import platform from urllib.parse import quote +# file-content telemetry caps +_MAX_FILE_CONTENT_BYTES = 64 * 1024 # per-file cap +_MAX_FILE_CONTENT_TOTAL_BYTES = 128 * 1024 # total across all files in one tool call +_MAX_FILE_CONTENT_FILES = 5 # max files per tool call + UNBOUND_GATEWAY_URL = os.environ.get( "UNBOUND_GATEWAY_URL", "https://api.getunbound.ai" ).rstrip("/") @@ -688,6 +693,7 @@ def process_pre_tool_use(event, api_key): file_path = tool_input.get('file_path', '') if file_path: metadata['file_path'] = file_path + _attach_file_content(metadata, file_path, event.get('cwd'), tool_input.get('content')) approval_key = f"{tool_name}:{file_path}" if file_path else tool_name is_retry = _is_approval_retry(approval_key) @@ -879,6 +885,171 @@ def _augment_script_hash(result, cwd): return result +def _cap_file_text(text): + """Return (text, truncated) with text capped to _MAX_FILE_CONTENT_BYTES of UTF-8.""" + encoded = text.encode('utf-8') + if len(encoded) <= _MAX_FILE_CONTENT_BYTES: + return text, False + return encoded[:_MAX_FILE_CONTENT_BYTES].decode('utf-8', errors='ignore'), True + + +def _abspath(path, cwd): + """Resolve path to a normalized absolute path (cwd-joined if relative), or None.""" + try: + if not path or not isinstance(path, str): + return None + p = os.path.expanduser(path) + if not os.path.isabs(p): + if not cwd: + return None + p = os.path.join(cwd, p) + return os.path.normpath(p) + except Exception: + return None + + +def _is_excluded_path(abspath): + """Exclude only the /proc and /sys pseudo-filesystems (kernel/process state, not real files).""" + if not isinstance(abspath, str): + return False + return abspath.startswith(('/proc/', '/sys/')) or abspath in ('/proc', '/sys') + + +def _resolve_existing_file(path, cwd): + """Absolute realpath of an existing file. Absolute paths are used directly; a relative + path resolves against the tool turn's cwd only (never the hook's own process cwd).""" + try: + if not path or not isinstance(path, str): + return None + expanded = os.path.expanduser(path) + if os.path.isabs(expanded): + cand = expanded + elif cwd: + cand = os.path.join(cwd, expanded) + else: + return None + real = os.path.realpath(cand) + if os.path.isfile(real) and not _is_excluded_path(real): + return real + return None + except Exception: + return None + + +def _read_file_text(abspath): + """Read a file as capped UTF-8 text -> (text, truncated), or None if binary/unreadable.""" + try: + # Read content of all files including sensitive files to identify and prevent data leaks. + with open(abspath, 'rb') as f: + raw = f.read(_MAX_FILE_CONTENT_BYTES + 1) + truncated = len(raw) > _MAX_FILE_CONTENT_BYTES + raw = raw[:_MAX_FILE_CONTENT_BYTES] + if b'\x00' in raw: + return None + if not truncated: + try: + return raw.decode('utf-8'), False + except UnicodeDecodeError: + return None + for _ in range(4): # tail may split a multibyte char; trim up to 3 bytes + try: + return raw.decode('utf-8'), True + except UnicodeDecodeError: + raw = raw[:-1] + return None + except Exception: + return None + + +def _make_file_entry(path, cwd, inline_content=None): + """Build one {path, content, truncated} entry (absolute path, readable text), or None. + Write-style tools pass new text as inline_content; otherwise the file must exist and be text.""" + try: + if isinstance(inline_content, str): + abspath = _abspath(path, cwd) + if abspath is None or _is_excluded_path(abspath) or _is_excluded_path(os.path.realpath(abspath)): + return None + content, truncated = _cap_file_text(inline_content) + return {'path': abspath, 'content': content, 'truncated': truncated} + abspath = _resolve_existing_file(path, cwd) + if abspath is None: + return None + res = _read_file_text(abspath) + if res is None: + return None + content, truncated = res + return {'path': abspath, 'content': content, 'truncated': truncated} + except Exception: + return None + + +def _append_file_entry(entries, path, cwd, inline_content=None): + """Append one entry to entries, honoring the file-count/total-byte caps and skipping dups.""" + try: + if len(entries) >= _MAX_FILE_CONTENT_FILES: + return + entry = _make_file_entry(path, cwd, inline_content) + if entry is None or any(e.get('path') == entry['path'] for e in entries): + return + total = sum(len((e.get('content') or '').encode('utf-8')) for e in entries) + if total + len((entry.get('content') or '').encode('utf-8')) > _MAX_FILE_CONTENT_TOTAL_BYTES: + return + entries.append(entry) + except Exception: + return + + +def _extract_command_file_paths(command, cwd): + """Absolute paths of existing files referenced as tokens in a shell command (str or argv list). + Only tokens that resolve to a real file are kept; flags/dirs/devices are skipped.""" + paths = [] + try: + if isinstance(command, list): + command = ' '.join(t for t in command if isinstance(t, str)) + if not command or not isinstance(command, str): + return paths + seen = set() + for raw_tok in command.split()[:256]: + tok = raw_tok.strip().strip('"\'').lstrip('<>') + if not tok or tok.startswith('-'): + continue + abspath = _resolve_existing_file(tok, cwd) + if not abspath or abspath in seen: + continue + seen.add(abspath) + paths.append(abspath) + if len(paths) >= _MAX_FILE_CONTENT_FILES: + break + except Exception: + return paths + return paths + + +def _attach_file_content(target, file_path, cwd, inline_content=None): + """Attach target['file_content'] (list of {path, content, truncated}) for a file tool.""" + try: + entries = target.get('file_content') or [] + _append_file_entry(entries, file_path, cwd, inline_content) + if entries: + target['file_content'] = entries + except Exception: + return + + +def _attach_command_file_content(target, command, cwd): + """Attach file_path + file_content for the existing files referenced in a shell command.""" + try: + entries = target.get('file_content') or [] + for abspath in _extract_command_file_paths(command, cwd): + _append_file_entry(entries, abspath, cwd) + if entries: + target['file_content'] = entries + if not target.get('file_path'): + target['file_path'] = entries[0]['path'] + except Exception: + return + + def _read_mcp_server_config(server_name, config_path): """ Read an MCP server's config (url, command, args) from a config file. @@ -935,6 +1106,9 @@ def process_pre_tool_use_execution(event, api_key, tool_name, command, mcp_serve if mcp_tool is not None: metadata['mcp_tool'] = mcp_tool + if mcp_server is None and event.get('command'): + _attach_command_file_content(metadata, event.get('command'), event.get('cwd')) + approval_key = f"{tool_name}:{command}" is_retry = _is_approval_retry(approval_key) @@ -1133,12 +1307,14 @@ def build_llm_exchange(events, api_key=None): usage = _cursor_usage_from_event(event) or usage elif hook_event_name == 'beforeReadFile': - assistant_tool_uses.append({ + tool_use = { 'type': hook_event_name, 'file_path': event.get('file_path'), 'content': event.get('content', ''), 'attachments': event.get('attachments', []) - }) + } + _attach_file_content(tool_use, event.get('file_path'), event.get('cwd'), event.get('content') or None) + assistant_tool_uses.append(tool_use) elif hook_event_name == 'postToolUse': tool_name = event.get('tool_name', '') @@ -1147,29 +1323,36 @@ def build_llm_exchange(events, api_key=None): continue tool_output = event.get('tool_output', '') - - assistant_tool_uses.append({ + tool_use = { 'type': hook_event_name, 'tool_name': tool_name, 'tool_input': event.get('tool_input'), 'tool_output': tool_output, 'duration': event.get('duration'), 'tool_use_id': event.get('tool_use_id') - }) + } + _ti = event.get('tool_input') + if isinstance(_ti, dict) and _ti.get('file_path'): + _attach_file_content(tool_use, _ti.get('file_path'), event.get('cwd'), _ti.get('content') or None) + assistant_tool_uses.append(tool_use) elif hook_event_name == 'afterFileEdit': - assistant_tool_uses.append({ + tool_use = { 'type': hook_event_name, 'file_path': event.get('file_path'), 'edits': event.get('edits', []) - }) + } + _attach_file_content(tool_use, event.get('file_path'), event.get('cwd'), None) + assistant_tool_uses.append(tool_use) elif hook_event_name == 'afterShellExecution': - assistant_tool_uses.append({ + tool_use = { 'type': hook_event_name, 'command': event.get('command'), 'output': event.get('output', '') - }) + } + _attach_command_file_content(tool_use, event.get('command'), event.get('cwd')) + assistant_tool_uses.append(tool_use) elif hook_event_name == 'afterMCPExecution': assistant_tool_uses.append({