From 2762f967e6a7a2e04975aad295cc1eb4a1a4fde0 Mon Sep 17 00:00:00 2001 From: Liu-RK Date: Wed, 16 Sep 2026 15:29:00 +0800 Subject: [PATCH 1/4] fix(safety): make secret redaction source aware --- src/opensquilla/safety/secret_redaction.py | 94 +++++++++-- src/opensquilla/tools/policy/finalize.py | 176 ++++++++++++++++++++- tests/test_safety/test_secret_redaction.py | 16 ++ tests/test_tools/test_dispatch_envelope.py | 140 ++++++++++++++++ 4 files changed, 413 insertions(+), 13 deletions(-) diff --git a/src/opensquilla/safety/secret_redaction.py b/src/opensquilla/safety/secret_redaction.py index 4d4ce63e96..672af4d9ee 100644 --- a/src/opensquilla/safety/secret_redaction.py +++ b/src/opensquilla/safety/secret_redaction.py @@ -71,39 +71,109 @@ def is_secret_key(key: str) -> bool: return lowered in _SECRET_KEY_EXACT or any(part in lowered for part in _SECRET_KEY_PARTS) -def _is_secret_assignment_key(key: str) -> bool: +def _looks_like_opaque_credential(value: str) -> bool: + """Distinguish credentials from source expressions for ambiguous token names.""" + candidate = value.strip().strip("\"'") + if candidate == _REDACTED: + return True + if candidate.startswith("$"): + return False + if len(candidate) >= 16 and re.fullmatch(r"[A-Fa-f0-9]+", candidate): + return True + if len(candidate) >= 20 and re.fullmatch(r"[A-Za-z0-9_./+=-]+", candidate): + return True + if len(candidate) < 12: + return False + return sum( + bool(re.search(pattern, candidate)) + for pattern in (r"[a-z]", r"[A-Z]", r"[0-9]") + ) >= 2 + + +def _is_secret_assignment_key(key: str, value: str) -> bool: lowered = key.lower() if lowered == "authorization": return False - return is_secret_key(lowered) or lowered.endswith(("token", ".token", "_token", "-token")) + if is_secret_key(lowered) or lowered.endswith((".token", "_token", "-token")): + return True + if lowered.endswith("token"): + return _looks_like_opaque_credential(value) + return False def _redact_assignment(match: re.Match[str]) -> str: - key, separator = match.group(1), match.group(2) - if not _is_secret_assignment_key(key): + key, separator, value = match.group(1), match.group(2), match.group(3) + if not _is_secret_assignment_key(key, value): return match.group(0) return f"{key}{separator}{_REDACTED}" -def redact_secret_text(text: str) -> str: +def redact_secret_text( + text: str, + *, + code_file: bool = False, + secret_file: bool = False, +) -> str: + """Redact secrets while preserving ordinary source-code assignments. + + ``code_file`` skips generic assignment matching, which otherwise corrupts + source variables and fixtures. ``secret_file`` is authoritative and + re-enables assignment matching for credential-bearing files such as + ``.env`` and shell profiles. Known provider-key prefixes and authorization + headers are always redacted. + """ + code_file = code_file and not secret_file redacted = text redacted = _AUTH_HEADER_RE.sub(_redact_auth_header, redacted) - redacted = _SECRET_ASSIGNMENT_RE.sub(_redact_assignment, redacted) - redacted = _SECRET_QUOTED_ASSIGNMENT_RE.sub(_redact_assignment, redacted) + if not code_file: + redacted = _SECRET_ASSIGNMENT_RE.sub(_redact_assignment, redacted) + redacted = _SECRET_QUOTED_ASSIGNMENT_RE.sub(_redact_assignment, redacted) for pattern in _SECRET_TOKEN_PATTERNS: redacted = pattern.sub(_REDACTED, redacted) return redacted -def redact_secret_value(value: Any, *, key: str | None = None) -> Any: +def redact_secret_value( + value: Any, + *, + key: str | None = None, + code_file: bool = False, + secret_file: bool = False, +) -> Any: if key and is_secret_key(key): return _REDACTED if isinstance(value, str): - return redact_secret_text(value) + return redact_secret_text( + value, + code_file=code_file, + secret_file=secret_file, + ) if isinstance(value, dict): - return {str(k): redact_secret_value(v, key=str(k)) for k, v in value.items()} + return { + str(k): redact_secret_value( + v, + key=str(k), + code_file=code_file, + secret_file=secret_file, + ) + for k, v in value.items() + } if isinstance(value, list): - return [redact_secret_value(item) for item in value] + return [ + redact_secret_value( + item, + code_file=code_file, + secret_file=secret_file, + ) + for item in value + ] if isinstance(value, tuple): - return tuple(redact_secret_value(item) for item in value) + return tuple( + redact_secret_value( + item, + code_file=code_file, + secret_file=secret_file, + ) + for item in value + ) return value diff --git a/src/opensquilla/tools/policy/finalize.py b/src/opensquilla/tools/policy/finalize.py index d6d1a994f9..c8b21c1b52 100644 --- a/src/opensquilla/tools/policy/finalize.py +++ b/src/opensquilla/tools/policy/finalize.py @@ -14,6 +14,8 @@ from __future__ import annotations import json +import shlex +from pathlib import Path from typing import Any import structlog @@ -25,6 +27,7 @@ mark_execution_status_truncated, normalize_execution_status, ) +from opensquilla.paths import default_opensquilla_home from opensquilla.result_budget import ( ToolResultBudgetTracker, ToolRunBudgetExceededError, @@ -43,6 +46,61 @@ ) _MAX_TERMINAL_RESPONSE_CHARS = 2_000 +_SOURCE_RESULT_TOOLS = frozenset( + {"grep_search", "read_file", "read_source", "source_symbols"} +) +_ENV_FILE_BASENAMES = frozenset( + { + ".env", + ".env.development", + ".env.local", + ".env.production", + ".env.staging", + ".env.test", + ".envrc", + } +) +_SHELL_RC_BASENAMES = frozenset( + { + ".bash_login", + ".bash_profile", + ".bashrc", + ".profile", + ".zlogin", + ".zprofile", + ".zshenv", + ".zshrc", + } +) +_ENV_DUMP_COMMANDS = frozenset({"declare", "env", "export", "printenv", "set"}) +_FILE_READ_COMMANDS = frozenset( + { + "awk", + "bat", + "batcat", + "cat", + "grep", + "head", + "less", + "more", + "nl", + "sed", + "tac", + "tail", + "type", + "view", + "zcat", + } +) +_PATTERN_FIRST_COMMANDS = frozenset({"awk", "grep", "sed"}) +_OPENSQUILLA_HOME_PREFIXES = ( + "$OPENSQUILLA_HOME/", + "${OPENSQUILLA_HOME}/", + "$OPENSQUILLA_STATE_DIR/", + "${OPENSQUILLA_STATE_DIR}/", +) +_HOME_PREFIXES = ("$HOME/", "${HOME}/") + _DISPATCH_TRUNCATION_RETRIEVE_HINT = ( "This tool result was truncated before entering model context. " @@ -50,6 +108,122 @@ ) +def _command_segments(command: str) -> list[str]: + """Split shell pipelines and sequences without splitting quoted text.""" + segments: list[str] = [] + buffer: list[str] = [] + quote: str | None = None + for character in command: + if quote: + buffer.append(character) + if character == quote: + quote = None + continue + if character in "'\"": + quote = character + buffer.append(character) + continue + if character in "|;&": + segment = "".join(buffer).strip() + if segment: + segments.append(segment) + buffer = [] + continue + buffer.append(character) + segment = "".join(buffer).strip() + if segment: + segments.append(segment) + return segments + + +def _is_opensquilla_config( + path: str, + parts: list[str], + *, + opensquilla_home: bool = False, +) -> bool: + if not parts or parts[-1] != "config.toml": + return False + if opensquilla_home or ".opensquilla" in parts[:-1]: + return True + try: + candidate = Path(path.strip("\"'")).expanduser().resolve(strict=False) + home = default_opensquilla_home().expanduser().resolve(strict=False) + candidate.relative_to(home) + except (OSError, RuntimeError, ValueError): + return False + return True + + +def _is_secret_file_arg(argument: object) -> bool: + if not isinstance(argument, str) or not argument.strip(): + return False + path = argument.strip("\"'").replace("\\", "/") + opensquilla_home = False + for prefix in _OPENSQUILLA_HOME_PREFIXES: + if path.startswith(prefix): + path = path[len(prefix) :] + opensquilla_home = True + break + for prefix in _HOME_PREFIXES: + if path.startswith(prefix): + path = path[len(prefix) :] + break + if "$" in path: + return False + parts = [part.lower() for part in path.split("/") if part] + if not parts: + return False + basename = parts[-1] + if basename in _ENV_FILE_BASENAMES or basename in _SHELL_RC_BASENAMES: + return True + return _is_opensquilla_config( + path, + parts, + opensquilla_home=opensquilla_home, + ) + + +def _is_env_dump_command(command: str) -> bool: + for segment in _command_segments(command): + try: + tokens = shlex.split(segment) + except ValueError: + tokens = segment.split() + if tokens and tokens[0].rsplit("/", 1)[-1].lower() in _ENV_DUMP_COMMANDS: + return True + return False + + +def _command_reads_secret_file(command: str) -> bool: + for segment in _command_segments(command): + tokens = segment.split() + if not tokens: + continue + reader = tokens[0].rsplit("/", 1)[-1].lower() + if reader not in _FILE_READ_COMMANDS: + continue + positional = [argument for argument in tokens[1:] if not argument.startswith("-")] + if reader in _PATTERN_FIRST_COMMANDS: + positional = positional[1:] + if any(_is_secret_file_arg(argument) for argument in positional): + return True + return False + + +def _tool_result_redaction_options(call: ToolCall) -> dict[str, bool]: + """Choose assignment redaction from the source that produced the result.""" + if call.tool_name in _SOURCE_RESULT_TOOLS: + secret_file = _is_secret_file_arg(call.arguments.get("path")) + return {"code_file": not secret_file, "secret_file": secret_file} + if call.tool_name == "exec_command": + command = call.arguments.get("command", "") + command = command if isinstance(command, str) else "" + secret_output = _is_env_dump_command(command) or _command_reads_secret_file(command) + return {"code_file": not secret_output, "secret_file": secret_output} + return {} + + def _registered_terminates_turn(registered: Any) -> bool: return bool(getattr(getattr(registered, "spec", None), "terminates_turn", False)) @@ -330,7 +504,7 @@ async def finalize( terminates_turn=False, ) - result = redact_secret_value(raw_result) + result = redact_secret_value(raw_result, **_tool_result_redaction_options(call)) # ---------------- Approval-on-unsupported-surface branch ---------------- if not _has_live_approval_surface(ctx): diff --git a/tests/test_safety/test_secret_redaction.py b/tests/test_safety/test_secret_redaction.py index bae9bbb722..a3760c6347 100644 --- a/tests/test_safety/test_secret_redaction.py +++ b/tests/test_safety/test_secret_redaction.py @@ -72,6 +72,22 @@ def test_redact_secret_text_does_not_mask_token_counters() -> None: ) +def test_redact_secret_text_does_not_mask_source_token_variables() -> None: + source = ( + "$nextToken = $tokens[$index + 1];\n" + "$searchToken = $tokens[$searchIndex];\n" + "$searchEndToken = $tokens[$searchEndIndex];" + ) + + assert redact_secret_text(source) == source + + +def test_redact_secret_text_still_masks_opaque_camel_case_tokens() -> None: + assert redact_secret_text("nextToken=abcdefghijklmnopqrstuvwx") == ( + "nextToken=[REDACTED]" + ) + + def test_redact_secret_text_masks_non_bearer_authorization_credentials() -> None: basic = redact_secret_text("Authorization: Basic dXNlcjpwYXNz") assert "dXNlcjpwYXNz" not in basic diff --git a/tests/test_tools/test_dispatch_envelope.py b/tests/test_tools/test_dispatch_envelope.py index 8d3f93e8d6..c95a4eaf50 100644 --- a/tests/test_tools/test_dispatch_envelope.py +++ b/tests/test_tools/test_dispatch_envelope.py @@ -496,6 +496,146 @@ async def test_dispatch_redacts_secret_like_tool_result_content() -> None: assert result.content == "env.OPENROUTER_API_KEY=[REDACTED]" +@pytest.mark.asyncio +async def test_dispatch_preserves_assignments_in_source_file_results() -> None: + registry = ToolRegistry() + provider_key = "sk-or-v1-abcdefghijklmnopqrstuvwxyz" + + async def read_file(path: str) -> str: + del path + return ( + '1\tTOKEN = "fixture-value"\n' + "2\t$nextToken = $tokens[$index + 1];\n" + f"3\tprovider_key = '{provider_key}'\n" + ) + + registry.register( + ToolSpec( + name="read_file", + description="read", + parameters={"path": {"type": "string"}}, + required=["path"], + ), + read_file, + ) + handler = build_tool_handler(registry) + + result = await handler( + ToolCall( + tool_use_id="tc-source-read", + tool_name="read_file", + arguments={"path": "src/Fixer.php"}, + ) + ) + + assert result.is_error is False + assert 'TOKEN = "fixture-value"' in result.content + assert "$nextToken = $tokens[$index + 1];" in result.content + assert provider_key not in result.content + assert "provider_key = '[REDACTED]'" in result.content + + +@pytest.mark.asyncio +async def test_dispatch_redacts_assignments_in_secret_file_results() -> None: + registry = ToolRegistry() + + async def read_file(path: str) -> str: + del path + return "1\tAPI_TOKEN=abcdefghijklmnopqrstuvwx\n" + + registry.register( + ToolSpec( + name="read_file", + description="read", + parameters={"path": {"type": "string"}}, + required=["path"], + ), + read_file, + ) + handler = build_tool_handler(registry) + + result = await handler( + ToolCall( + tool_use_id="tc-secret-file-read", + tool_name="read_file", + arguments={"path": ".env.production"}, + ) + ) + + assert result.is_error is False + assert result.content == "1\tAPI_TOKEN=[REDACTED]\n" + + +@pytest.mark.asyncio +async def test_dispatch_distinguishes_source_commands_from_environment_dumps() -> None: + registry = ToolRegistry() + + async def exec_command(command: str) -> str: + del command + return 'TOKEN="fixture-value"\n' + + registry.register( + ToolSpec( + name="exec_command", + description="exec", + parameters={"command": {"type": "string"}}, + required=["command"], + ), + exec_command, + ) + handler = build_tool_handler(registry) + + source_result = await handler( + ToolCall( + tool_use_id="tc-source-command", + tool_name="exec_command", + arguments={"command": "sed -n '1,20p' src/settings.py"}, + ) + ) + env_result = await handler( + ToolCall( + tool_use_id="tc-env-command", + tool_name="exec_command", + arguments={"command": "printenv"}, + ) + ) + secret_file_result = await handler( + ToolCall( + tool_use_id="tc-secret-file-command", + tool_name="exec_command", + arguments={"command": "cat .env.local"}, + ) + ) + project_config_result = await handler( + ToolCall( + tool_use_id="tc-project-config-command", + tool_name="exec_command", + arguments={"command": "cat config.toml"}, + ) + ) + opensquilla_config_result = await handler( + ToolCall( + tool_use_id="tc-opensquilla-config-command", + tool_name="exec_command", + arguments={"command": "cat ~/.opensquilla/config.toml"}, + ) + ) + opensquilla_home_config_result = await handler( + ToolCall( + tool_use_id="tc-opensquilla-home-config-command", + tool_name="exec_command", + arguments={"command": "cat $HOME/.opensquilla/config.toml"}, + ) + ) + + assert source_result.content == 'TOKEN="fixture-value"\n' + assert env_result.content == "TOKEN=[REDACTED]\n" + assert secret_file_result.content == "TOKEN=[REDACTED]\n" + assert project_config_result.content == 'TOKEN="fixture-value"\n' + assert opensquilla_config_result.content == "TOKEN=[REDACTED]\n" + assert opensquilla_home_config_result.content == "TOKEN=[REDACTED]\n" + + @pytest.mark.asyncio async def test_dispatch_rejects_unparsed_raw_tool_arguments_before_handler() -> None: handler = build_tool_handler(_build_registry()) From e28bcbb2e71c09835126cb7823d774eddb35541f Mon Sep 17 00:00:00 2001 From: Liu-RK Date: Wed, 16 Sep 2026 15:47:47 +0800 Subject: [PATCH 2/4] fix(safety): type redaction options explicitly --- src/opensquilla/tools/policy/finalize.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/opensquilla/tools/policy/finalize.py b/src/opensquilla/tools/policy/finalize.py index c8b21c1b52..2b2b17ddb1 100644 --- a/src/opensquilla/tools/policy/finalize.py +++ b/src/opensquilla/tools/policy/finalize.py @@ -504,7 +504,12 @@ async def finalize( terminates_turn=False, ) - result = redact_secret_value(raw_result, **_tool_result_redaction_options(call)) + redaction_options = _tool_result_redaction_options(call) + result = redact_secret_value( + raw_result, + code_file=redaction_options.get("code_file", False), + secret_file=redaction_options.get("secret_file", False), + ) # ---------------- Approval-on-unsupported-surface branch ---------------- if not _has_live_approval_surface(ctx): From 8b1e2f409a97069ff3d117e48b6e386e78687297 Mon Sep 17 00:00:00 2001 From: Liu-RK Date: Wed, 16 Sep 2026 17:42:14 +0800 Subject: [PATCH 3/4] fix(safety): fail closed for ambiguous redaction contexts Classify shell and grep output using explicit source provenance, recognize all .env variants, and keep credential assignment text strict outside source contexts. --- src/opensquilla/safety/secret_redaction.py | 33 ++----- src/opensquilla/tools/policy/finalize.py | 103 +++++++++++++++------ tests/test_safety/test_secret_redaction.py | 18 +++- tests/test_tools/test_dispatch_envelope.py | 99 +++++++++++++++++++- 4 files changed, 193 insertions(+), 60 deletions(-) diff --git a/src/opensquilla/safety/secret_redaction.py b/src/opensquilla/safety/secret_redaction.py index 672af4d9ee..1da968b933 100644 --- a/src/opensquilla/safety/secret_redaction.py +++ b/src/opensquilla/safety/secret_redaction.py @@ -71,39 +71,18 @@ def is_secret_key(key: str) -> bool: return lowered in _SECRET_KEY_EXACT or any(part in lowered for part in _SECRET_KEY_PARTS) -def _looks_like_opaque_credential(value: str) -> bool: - """Distinguish credentials from source expressions for ambiguous token names.""" - candidate = value.strip().strip("\"'") - if candidate == _REDACTED: - return True - if candidate.startswith("$"): - return False - if len(candidate) >= 16 and re.fullmatch(r"[A-Fa-f0-9]+", candidate): - return True - if len(candidate) >= 20 and re.fullmatch(r"[A-Za-z0-9_./+=-]+", candidate): - return True - if len(candidate) < 12: - return False - return sum( - bool(re.search(pattern, candidate)) - for pattern in (r"[a-z]", r"[A-Z]", r"[0-9]") - ) >= 2 - - -def _is_secret_assignment_key(key: str, value: str) -> bool: +def _is_secret_assignment_key(key: str) -> bool: lowered = key.lower() if lowered == "authorization": return False - if is_secret_key(lowered) or lowered.endswith((".token", "_token", "-token")): - return True - if lowered.endswith("token"): - return _looks_like_opaque_credential(value) - return False + return is_secret_key(lowered) or lowered.endswith( + ("token", ".token", "_token", "-token") + ) def _redact_assignment(match: re.Match[str]) -> str: - key, separator, value = match.group(1), match.group(2), match.group(3) - if not _is_secret_assignment_key(key, value): + key, separator = match.group(1), match.group(2) + if not _is_secret_assignment_key(key): return match.group(0) return f"{key}{separator}{_REDACTED}" diff --git a/src/opensquilla/tools/policy/finalize.py b/src/opensquilla/tools/policy/finalize.py index 2b2b17ddb1..47b93605e1 100644 --- a/src/opensquilla/tools/policy/finalize.py +++ b/src/opensquilla/tools/policy/finalize.py @@ -14,6 +14,7 @@ from __future__ import annotations import json +import re import shlex from pathlib import Path from typing import Any @@ -34,7 +35,7 @@ resolve_budget_class, ) from opensquilla.router_control import router_control_payload_terminates_turn -from opensquilla.safety.secret_redaction import redact_secret_value +from opensquilla.safety.secret_redaction import redact_secret_text, redact_secret_value from opensquilla.tool_boundary import ToolCall, ToolResult from opensquilla.tools.envelope import build_tool_failure_envelope, is_denial_payload from opensquilla.tools.types import CallerKind, InteractionMode, ToolContext @@ -46,20 +47,7 @@ ) _MAX_TERMINAL_RESPONSE_CHARS = 2_000 -_SOURCE_RESULT_TOOLS = frozenset( - {"grep_search", "read_file", "read_source", "source_symbols"} -) -_ENV_FILE_BASENAMES = frozenset( - { - ".env", - ".env.development", - ".env.local", - ".env.production", - ".env.staging", - ".env.test", - ".envrc", - } -) +_DIRECT_FILE_RESULT_TOOLS = frozenset({"read_file", "read_source"}) _SHELL_RC_BASENAMES = frozenset( { ".bash_login", @@ -80,6 +68,7 @@ "batcat", "cat", "grep", + "get-content", "head", "less", "more", @@ -100,6 +89,10 @@ "${OPENSQUILLA_STATE_DIR}/", ) _HOME_PREFIXES = ("$HOME/", "${HOME}/") +_GREP_MATCH_RE = re.compile( + r"^(?P(?P.+?)(?::\d+)?: )" + r"(?P[^\r\n]*)(?P\r?\n)?$" +) _DISPATCH_TRUNCATION_RETRIEVE_HINT = ( @@ -123,7 +116,7 @@ def _command_segments(command: str) -> list[str]: quote = character buffer.append(character) continue - if character in "|;&": + if character in "|;&\r\n": segment = "".join(buffer).strip() if segment: segments.append(segment) @@ -175,7 +168,12 @@ def _is_secret_file_arg(argument: object) -> bool: if not parts: return False basename = parts[-1] - if basename in _ENV_FILE_BASENAMES or basename in _SHELL_RC_BASENAMES: + if ( + basename == ".env" + or basename.startswith(".env.") + or basename == ".envrc" + or basename in _SHELL_RC_BASENAMES + ): return True return _is_opensquilla_config( path, @@ -195,32 +193,74 @@ def _is_env_dump_command(command: str) -> bool: return False -def _command_reads_secret_file(command: str) -> bool: +def _command_file_read_classification(command: str) -> tuple[bool, bool, bool]: + """Return ordinary-read, secret-read, and unknown-segment flags.""" + reads_file = False + reads_secret_file = False + has_unknown_segment = False for segment in _command_segments(command): - tokens = segment.split() + try: + tokens = shlex.split(segment) + except ValueError: + tokens = segment.split() if not tokens: continue reader = tokens[0].rsplit("/", 1)[-1].lower() if reader not in _FILE_READ_COMMANDS: + has_unknown_segment = True continue positional = [argument for argument in tokens[1:] if not argument.startswith("-")] if reader in _PATTERN_FIRST_COMMANDS: positional = positional[1:] + if not positional: + continue if any(_is_secret_file_arg(argument) for argument in positional): - return True - return False + reads_secret_file = True + else: + reads_file = True + return reads_file, reads_secret_file, has_unknown_segment + + +def _redact_grep_search_result(value: Any) -> Any: + """Redact each grep match using the path emitted with that match.""" + if not isinstance(value, str): + return redact_secret_value(value) + redacted_lines: list[str] = [] + for line in value.splitlines(keepends=True): + match = _GREP_MATCH_RE.match(line) + if match is None: + redacted_lines.append(redact_secret_text(line)) + continue + secret_file = _is_secret_file_arg(match.group("path")) + redacted_content = redact_secret_text( + match.group("content"), + code_file=not secret_file, + secret_file=secret_file, + ) + redacted_lines.append( + match.group("prefix") + redacted_content + (match.group("ending") or "") + ) + return "".join(redacted_lines) def _tool_result_redaction_options(call: ToolCall) -> dict[str, bool]: """Choose assignment redaction from the source that produced the result.""" - if call.tool_name in _SOURCE_RESULT_TOOLS: + if call.tool_name in _DIRECT_FILE_RESULT_TOOLS: secret_file = _is_secret_file_arg(call.arguments.get("path")) return {"code_file": not secret_file, "secret_file": secret_file} + if call.tool_name == "source_symbols": + return {"code_file": True, "secret_file": False} if call.tool_name == "exec_command": command = call.arguments.get("command", "") command = command if isinstance(command, str) else "" - secret_output = _is_env_dump_command(command) or _command_reads_secret_file(command) - return {"code_file": not secret_output, "secret_file": secret_output} + reads_file, reads_secret_file, has_unknown_segment = ( + _command_file_read_classification(command) + ) + if _is_env_dump_command(command) or reads_secret_file: + return {"code_file": False, "secret_file": True} + if reads_file and not has_unknown_segment: + return {"code_file": True, "secret_file": False} + return {} return {} @@ -504,12 +544,15 @@ async def finalize( terminates_turn=False, ) - redaction_options = _tool_result_redaction_options(call) - result = redact_secret_value( - raw_result, - code_file=redaction_options.get("code_file", False), - secret_file=redaction_options.get("secret_file", False), - ) + if call.tool_name == "grep_search": + result = _redact_grep_search_result(raw_result) + else: + redaction_options = _tool_result_redaction_options(call) + result = redact_secret_value( + raw_result, + code_file=redaction_options.get("code_file", False), + secret_file=redaction_options.get("secret_file", False), + ) # ---------------- Approval-on-unsupported-surface branch ---------------- if not _has_live_approval_surface(ctx): diff --git a/tests/test_safety/test_secret_redaction.py b/tests/test_safety/test_secret_redaction.py index a3760c6347..f75ab42d75 100644 --- a/tests/test_safety/test_secret_redaction.py +++ b/tests/test_safety/test_secret_redaction.py @@ -79,7 +79,23 @@ def test_redact_secret_text_does_not_mask_source_token_variables() -> None: "$searchEndToken = $tokens[$searchEndIndex];" ) - assert redact_secret_text(source) == source + assert redact_secret_text(source, code_file=True) == source + + +def test_redact_secret_text_strictly_masks_explicit_credential_assignments() -> None: + text = 'accessToken=abc123def45 refreshToken="abcdefghijklmnop"' + + for options in ({}, {"secret_file": True}): + redacted = redact_secret_text(text, **options) + assert "abc123def45" not in redacted + assert "abcdefghijklmnop" not in redacted + assert redacted == "accessToken=[REDACTED] refreshToken=[REDACTED]" + + +def test_redact_secret_value_does_not_expand_camel_case_structured_key_policy() -> None: + payload = {"accessToken": "abc123def45"} + + assert redact_secret_value(payload) == payload def test_redact_secret_text_still_masks_opaque_camel_case_tokens() -> None: diff --git a/tests/test_tools/test_dispatch_envelope.py b/tests/test_tools/test_dispatch_envelope.py index c95a4eaf50..c04ccf8907 100644 --- a/tests/test_tools/test_dispatch_envelope.py +++ b/tests/test_tools/test_dispatch_envelope.py @@ -536,7 +536,8 @@ async def read_file(path: str) -> str: @pytest.mark.asyncio -async def test_dispatch_redacts_assignments_in_secret_file_results() -> None: +@pytest.mark.parametrize("path", [".env.production", ".env.production.local"]) +async def test_dispatch_redacts_assignments_in_secret_file_results(path: str) -> None: registry = ToolRegistry() async def read_file(path: str) -> str: @@ -558,7 +559,7 @@ async def read_file(path: str) -> str: ToolCall( tool_use_id="tc-secret-file-read", tool_name="read_file", - arguments={"path": ".env.production"}, + arguments={"path": path}, ) ) @@ -566,6 +567,100 @@ async def read_file(path: str) -> str: assert result.content == "1\tAPI_TOKEN=[REDACTED]\n" +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("source_match", "secret_match"), + [ + ( + "src/Fixer.php:2: $nextToken = $tokens[$index + 1];", + ".bashrc:1: export API_TOKEN=FAKE_REVIEW_PASSWORD_12345", + ), + ( + "src/Fixer.php: $nextToken = $tokens[$index + 1];", + ".bashrc: API_TOKEN=FAKE_REVIEW_PASSWORD_12345", + ), + ], +) +async def test_dispatch_redacts_directory_grep_matches_from_secret_files( + source_match: str, + secret_match: str, +) -> None: + registry = ToolRegistry() + + async def grep_search(pattern: str, path: str, include: str) -> str: + del pattern, path, include + return f"{source_match}\n{secret_match}\n" + + registry.register( + ToolSpec( + name="grep_search", + description="grep", + parameters={ + "pattern": {"type": "string"}, + "path": {"type": "string"}, + "include": {"type": "string"}, + }, + required=["pattern", "path", "include"], + ), + grep_search, + ) + handler = build_tool_handler(registry) + + result = await handler( + ToolCall( + tool_use_id="tc-secret-directory-grep", + tool_name="grep_search", + arguments={"pattern": "API_TOKEN", "path": ".", "include": ".bashrc"}, + ) + ) + + assert result.is_error is False + assert "$nextToken = $tokens[$index + 1];" in result.content + assert "FAKE_REVIEW_PASSWORD_12345" not in result.content + assert "API_TOKEN=[REDACTED]" in result.content + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "command", + [ + "Get-Content .env", + "pwd\ncat .env", + "custom-credential-dump", + "cat src/settings.py\ncustom-credential-dump", + ], +) +async def test_dispatch_redacts_assignments_from_non_source_commands(command: str) -> None: + registry = ToolRegistry() + + async def exec_command(command: str) -> str: + del command + return "PASSWORD=FAKE_REVIEW_PASSWORD_12345\n" + + registry.register( + ToolSpec( + name="exec_command", + description="exec", + parameters={"command": {"type": "string"}}, + required=["command"], + ), + exec_command, + ) + handler = build_tool_handler(registry) + + result = await handler( + ToolCall( + tool_use_id="tc-non-source-command", + tool_name="exec_command", + arguments={"command": command}, + ) + ) + + assert result.is_error is False + assert "FAKE_REVIEW_PASSWORD_12345" not in result.content + assert result.content == "PASSWORD=[REDACTED]\n" + + @pytest.mark.asyncio async def test_dispatch_distinguishes_source_commands_from_environment_dumps() -> None: registry = ToolRegistry() From fcfbcd517bd6a4f4bcdb3f980b4926fe60890d83 Mon Sep 17 00:00:00 2001 From: Liu-RK Date: Wed, 16 Sep 2026 19:08:15 +0800 Subject: [PATCH 4/4] test(ci): serialize attachment capacity fault harness Keep the gateway subprocess timeout contract out of the saturated xdist phase so its one-second provider deadline measures the synthetic stream rather than worker contention. --- tests/test_live_provider_profile_gateway_e2e.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_live_provider_profile_gateway_e2e.py b/tests/test_live_provider_profile_gateway_e2e.py index 8b67347a2a..d745a9ad0d 100644 --- a/tests/test_live_provider_profile_gateway_e2e.py +++ b/tests/test_live_provider_profile_gateway_e2e.py @@ -957,6 +957,7 @@ def do_POST(self) -> None: # noqa: N802 - stdlib handler contract ("timeout", "transport", 0), ], ) +@pytest.mark.ci_serial def test_attachment_capacity_runner_fails_closed_for_stream_faults( monkeypatch: pytest.MonkeyPatch, tmp_path: Path,