Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions augment/hooks/test_augment_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
206 changes: 204 additions & 2 deletions augment/hooks/unbound.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Comment thread
cursor[bot] marked this conversation as resolved.

if canonical == 'Bash':
canon_input = {'command': tool_input.get('command', '')}
Expand All @@ -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
Comment thread
pugazhendhi-m marked this conversation as resolved.
if canonical == 'Read':
tool_response = {'content': tool_output} if tool_output else {}
else:
Expand All @@ -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'))
Comment thread
pugazhendhi-m marked this conversation as resolved.
return result


def build_llm_exchange(event: Dict, post_tool_events: List[Dict], model: Optional[str] = None) -> Optional[Dict]:
Expand Down Expand Up @@ -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


Comment thread
pugazhendhi-m marked this conversation as resolved.
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)
Comment thread
pugazhendhi-m marked this conversation as resolved.
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
Comment thread
pugazhendhi-m marked this conversation as resolved.
api_key = get_api_key()
Expand Down
Loading