Skip to content

Commit 14c81d0

Browse files
committed
Redact sensitive tool result values (stdout, stderr, content, text) in audit logs; Restrict audit log and run store file permissions to owner-only
- Introduce SENSITIVE_RESULT_KEYS frozenset with content, stderr, stdout, text - Route result dict through redact_tool_result to strip file contents, shell output, and match text while preserving metadata like path, truncated, line, and exit_code - Set 0700 on audit and run store directories and 0600 on individual log files via chmod to prevent unauthorized reads - Apply owner-only permissions on creation of .teaagent/ dirs and when writing new audit/run store files
1 parent 3c04533 commit 14c81d0

4 files changed

Lines changed: 106 additions & 2 deletions

File tree

teaagent/audit.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
AUDIT_REDACTED = '[redacted]'
1313
AUDIT_TRUNCATED = '[truncated]'
1414
MAX_AUDIT_STRING_LENGTH = 20_000
15+
AUDIT_DIR_MODE = 0o700
16+
AUDIT_FILE_MODE = 0o600
1517
SENSITIVE_KEY_PARTS = (
1618
'api_key',
1719
'authorization',
@@ -21,6 +23,7 @@
2123
'token',
2224
)
2325
SENSITIVE_ARGUMENT_KEYS = frozenset({'command', 'content', 'new', 'old'})
26+
SENSITIVE_RESULT_KEYS = frozenset({'content', 'stderr', 'stdout', 'text'})
2427

2528

2629
def utc_now() -> str:
@@ -58,6 +61,7 @@ def __init__(self, path: Optional[Path] = None) -> None:
5861
self._lock = threading.Lock()
5962
if self.path is not None:
6063
self.path.parent.mkdir(parents=True, exist_ok=True)
64+
secure_audit_dir(self.path.parent)
6165

6266
def add_sink(self, sink: Callable[[AuditEvent], None]) -> None:
6367
self._sinks.append(sink)
@@ -71,6 +75,7 @@ def record(self, event_type: str, run_id: str, **payload: Any) -> AuditEvent:
7175
if self.path is not None:
7276
with self.path.open('a', encoding='utf-8') as handle:
7377
handle.write(event.to_json() + '\n')
78+
secure_audit_file(self.path)
7479
sinks = list(self._sinks)
7580
for sink in sinks:
7681
sink(event)
@@ -82,11 +87,21 @@ def redact_audit_payload(payload: dict[str, Any]) -> dict[str, Any]:
8287
for key, value in payload.items():
8388
if key == 'arguments' and isinstance(value, dict):
8489
redacted[key] = redact_tool_arguments(value)
90+
elif key == 'result' and isinstance(value, dict):
91+
redacted[key] = redact_tool_result(value)
8592
else:
8693
redacted[key] = redact_audit_value(key, value)
8794
return redacted
8895

8996

97+
def secure_audit_dir(path: Path) -> None:
98+
path.chmod(AUDIT_DIR_MODE)
99+
100+
101+
def secure_audit_file(path: Path) -> None:
102+
path.chmod(AUDIT_FILE_MODE)
103+
104+
90105
def redact_tool_arguments(arguments: dict[str, Any]) -> dict[str, Any]:
91106
return {
92107
str(key): redact_tool_argument_value(str(key), value)
@@ -109,6 +124,28 @@ def redact_tool_argument_value(key: str, value: Any) -> Any:
109124
return redact_audit_value(key, value)
110125

111126

127+
def redact_tool_result(result: dict[str, Any]) -> dict[str, Any]:
128+
return {
129+
str(key): redact_tool_result_value(str(key), value)
130+
for key, value in result.items()
131+
}
132+
133+
134+
def redact_tool_result_value(key: str, value: Any) -> Any:
135+
if is_sensitive_key(key) or key in SENSITIVE_RESULT_KEYS:
136+
return AUDIT_REDACTED
137+
if isinstance(value, dict):
138+
return {
139+
str(child_key): redact_tool_result_value(str(child_key), child_value)
140+
for child_key, child_value in value.items()
141+
}
142+
if isinstance(value, list):
143+
return [redact_tool_result_value('', item) for item in value]
144+
if isinstance(value, tuple):
145+
return [redact_tool_result_value('', item) for item in value]
146+
return redact_audit_value(key, value)
147+
148+
112149
def redact_audit_value(key: str, value: Any) -> Any:
113150
if is_sensitive_key(key):
114151
return AUDIT_REDACTED

teaagent/run_store.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from typing import Any, Optional
77
from uuid import uuid4
88

9-
from teaagent.audit import AuditLogger, utc_now
9+
from teaagent.audit import AuditLogger, secure_audit_dir, secure_audit_file, utc_now
1010
from teaagent.runner import RunResult
1111

1212

@@ -37,6 +37,8 @@ def __init__(self, root: str | Path = '.') -> None:
3737
self.root = Path(root).resolve()
3838
self.store_dir = self.root / '.teaagent' / 'runs'
3939
self.store_dir.mkdir(parents=True, exist_ok=True)
40+
secure_audit_dir(self.root / '.teaagent')
41+
secure_audit_dir(self.store_dir)
4042

4143
def audit_logger(self, run_id: Optional[str] = None) -> AuditLogger:
4244
if run_id is None:
@@ -50,6 +52,7 @@ def logger_for_result(self, result: RunResult, audit: AuditLogger) -> None:
5052
return
5153
target = self.run_path(result.run_id)
5254
target.write_text(audit.path.read_text(encoding='utf-8'), encoding='utf-8')
55+
secure_audit_file(target)
5356
audit.path.unlink(missing_ok=True)
5457

5558
def run_path(self, run_id: str) -> Path:

tests/test_audit.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
from __future__ import annotations
22

33
import json
4+
import stat
45
import tempfile
56
import threading
67
import unittest
78
from dataclasses import FrozenInstanceError
89
from pathlib import Path
910

1011
from teaagent.audit import (
12+
AUDIT_DIR_MODE,
13+
AUDIT_FILE_MODE,
1114
AUDIT_REDACTED,
1215
AUDIT_TRUNCATED,
1316
MAX_AUDIT_STRING_LENGTH,
@@ -17,6 +20,10 @@
1720
)
1821

1922

23+
def file_mode(path: Path) -> int:
24+
return stat.S_IMODE(path.stat().st_mode)
25+
26+
2027
class AuditEventTests(unittest.TestCase):
2128
def test_event_has_default_fields(self) -> None:
2229
event = AuditEvent(
@@ -159,6 +166,32 @@ def test_record_preserves_non_argument_content(self) -> None:
159166

160167
self.assertEqual(event.payload['content'], 'read result')
161168

169+
def test_record_redacts_sensitive_tool_result_values(self) -> None:
170+
logger = AuditLogger()
171+
172+
event = logger.record(
173+
'tool_call_completed',
174+
'run-1',
175+
tool_name='workspace_read_file',
176+
result={
177+
'path': 'file.txt',
178+
'content': 'secret file body',
179+
'truncated': False,
180+
'matches': [{'line': 1, 'text': 'secret match'}],
181+
'stdout': 'secret stdout',
182+
'stderr': 'secret stderr',
183+
},
184+
)
185+
186+
result = event.payload['result']
187+
self.assertEqual(result['path'], 'file.txt')
188+
self.assertFalse(result['truncated'])
189+
self.assertEqual(result['content'], AUDIT_REDACTED)
190+
self.assertEqual(result['matches'][0]['line'], 1)
191+
self.assertEqual(result['matches'][0]['text'], AUDIT_REDACTED)
192+
self.assertEqual(result['stdout'], AUDIT_REDACTED)
193+
self.assertEqual(result['stderr'], AUDIT_REDACTED)
194+
162195
def test_record_truncates_large_strings(self) -> None:
163196
logger = AuditLogger()
164197

@@ -178,6 +211,8 @@ def test_path_parent_dirs_are_created(self) -> None:
178211
logger.record('e', 'r')
179212

180213
self.assertTrue(path.exists())
214+
self.assertEqual(file_mode(path.parent), AUDIT_DIR_MODE)
215+
self.assertEqual(file_mode(path), AUDIT_FILE_MODE)
181216

182217
def test_in_memory_only_when_no_path(self) -> None:
183218
logger = AuditLogger()

tests/test_run_store.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import io
44
import json
5+
import stat
56
import tempfile
67
import unittest
78
from contextlib import redirect_stdout
@@ -13,6 +14,10 @@
1314
from teaagent.runner import RunResult
1415

1516

17+
def file_mode(path: Path) -> int:
18+
return stat.S_IMODE(path.stat().st_mode)
19+
20+
1621
class RunStoreTests(unittest.TestCase):
1722
def test_run_store_persists_and_summarizes_audit_events(self) -> None:
1823
with tempfile.TemporaryDirectory() as tmp:
@@ -38,6 +43,29 @@ def test_run_store_persists_and_summarizes_audit_events(self) -> None:
3843
self.assertEqual(events[0]['event_type'], 'run_started')
3944
self.assertTrue((Path(tmp) / '.teaagent' / 'runs' / 'run-1.jsonl').exists())
4045

46+
def test_run_store_uses_owner_only_permissions(self) -> None:
47+
with tempfile.TemporaryDirectory() as tmp:
48+
store = RunStore(tmp)
49+
audit = store.audit_logger()
50+
audit.record('run_started', 'secure-run', task='demo')
51+
store.logger_for_result(
52+
RunResult(
53+
run_id='secure-run',
54+
final_answer=None,
55+
iterations=1,
56+
tool_calls=0,
57+
status='completed',
58+
),
59+
audit,
60+
)
61+
62+
root = Path(tmp)
63+
self.assertEqual(file_mode(root / '.teaagent'), 0o700)
64+
self.assertEqual(file_mode(root / '.teaagent' / 'runs'), 0o700)
65+
self.assertEqual(
66+
file_mode(root / '.teaagent' / 'runs' / 'secure-run.jsonl'), 0o600
67+
)
68+
4169
def test_task_for_run_extracts_original_task(self) -> None:
4270
with tempfile.TemporaryDirectory() as tmp:
4371
store = RunStore(tmp)
@@ -183,7 +211,8 @@ def test_observations_for_run_returns_completed_tool_calls(self) -> None:
183211
observations = store.observations_for_run('run-obs')
184212

185213
self.assertEqual([obs['call_id'] for obs in observations], ['r1', 'r2'])
186-
self.assertEqual(observations[0]['result']['content'], 'hi')
214+
self.assertEqual(observations[0]['result']['path'], 'a.txt')
215+
self.assertEqual(observations[0]['result']['content'], AUDIT_REDACTED)
187216

188217
def test_pending_approval_for_run_returns_last_unresolved(self) -> None:
189218
with tempfile.TemporaryDirectory() as tmp:

0 commit comments

Comments
 (0)