Files
tracer-cloud--opensre/surfaces/interactive_shell/prompt_history/policy.py
T
wehub-resource-sync 4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

220 lines
7.6 KiB
Python

"""Privacy controls for the interactive-shell command history.
Built-in redaction patterns + a ``RedactingFileHistory`` that subclasses
``prompt_toolkit.FileHistory`` to apply redaction before each entry is
persisted. Settings resolve from env vars and the ``interactive.history``
section of ``~/.opensre/config.yml``; built-in defaults keep
redaction on, persistence on, and entries capped at 5000.
"""
from __future__ import annotations
import os
import re
from collections.abc import Iterator
from pathlib import Path
from typing import Any, ClassVar
from prompt_toolkit.history import FileHistory
from pydantic import ConfigDict
from config.strict_config import StrictConfigModel
DEFAULT_MAX_ENTRIES = 5000
class RedactionRule(StrictConfigModel):
"""One named regex with its replacement."""
model_config = ConfigDict(extra="forbid", frozen=True, arbitrary_types_allowed=True)
name: str
pattern: re.Pattern[str]
replacement: str
def _build_default_rules() -> tuple[RedactionRule, ...]:
raw: list[tuple[str, str, str]] = [
("aws_key", r"(?:AKIA|ASIA)[A-Z0-9]{16}", "[REDACTED:aws_key]"),
(
"aws_secret",
r"(?i)aws_secret_access_key[\s=:]+[A-Za-z0-9/+=]{40}",
"aws_secret_access_key=[REDACTED:aws_secret]",
),
("github_pat_classic", r"ghp_[A-Za-z0-9]{36}", "[REDACTED:github_pat]"),
("github_pat_fine", r"github_pat_[A-Za-z0-9_]{82}", "[REDACTED:github_pat]"),
("anthropic_key", r"sk-ant-[A-Za-z0-9_\-]{40,}", "[REDACTED:anthropic_key]"),
("openai_key", r"sk-(?!ant-)[A-Za-z0-9_\-]{20,}", "[REDACTED:openai_key]"),
("slack_token", r"xox[bopas]-[A-Za-z0-9-]{10,}", "[REDACTED:slack_token]"),
("stripe_key", r"sk_(?:live|test)_[A-Za-z0-9]{24,}", "[REDACTED:stripe_key]"),
("bearer", r"(?i)bearer\s+[A-Za-z0-9_\-\.]{20,}", "Bearer [REDACTED]"),
(
"jwt",
r"eyJ[A-Za-z0-9_\-]{8,}\.eyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}",
"[REDACTED:jwt]",
),
("password_arg", r"(?i)(--password=|password=)\S+", "[REDACTED:password]"),
(
"private_key",
r"-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----",
"[REDACTED:private_key]",
),
]
return tuple(
RedactionRule(name=name, pattern=re.compile(p), replacement=repl) for (name, p, repl) in raw
)
DEFAULT_REDACTION_RULES: tuple[RedactionRule, ...] = _build_default_rules()
def redact_text(text: str, rules: tuple[RedactionRule, ...] = DEFAULT_REDACTION_RULES) -> str:
"""Apply each rule's pattern in declared order, replacing every match."""
for rule in rules:
text = rule.pattern.sub(rule.replacement, text)
return text
class HistoryPolicy(StrictConfigModel):
"""Resolved privacy policy for the prompt-history persistence layer."""
model_config = ConfigDict(extra="forbid", frozen=True)
enabled: bool = True
redact: bool = True
max_entries: int = DEFAULT_MAX_ENTRIES
_ENV_ENABLED: ClassVar[str] = "OPENSRE_HISTORY_ENABLED"
_ENV_REDACT: ClassVar[str] = "OPENSRE_HISTORY_REDACT"
_ENV_MAX_ENTRIES: ClassVar[str] = "OPENSRE_HISTORY_MAX_ENTRIES"
@classmethod
def load(cls, file_settings: dict[str, Any] | None = None) -> HistoryPolicy:
"""Resolve from env -> file -> defaults (env wins)."""
file_settings = file_settings or {}
return cls(
enabled=_parse_bool(os.getenv(cls._ENV_ENABLED), file_settings.get("enabled"), True),
redact=_parse_bool(os.getenv(cls._ENV_REDACT), file_settings.get("redact"), True),
max_entries=_parse_int(
os.getenv(cls._ENV_MAX_ENTRIES),
file_settings.get("max_entries"),
DEFAULT_MAX_ENTRIES,
),
)
def _parse_bool(env_val: str | None, file_val: Any, default: bool) -> bool:
if env_val is not None and env_val != "":
return env_val.strip().lower() not in {"0", "false", "off", "no"}
if isinstance(file_val, bool):
return file_val
if isinstance(file_val, str) and file_val.strip() != "":
return file_val.strip().lower() not in {"0", "false", "off", "no"}
if file_val is not None:
return bool(file_val)
return default
def _parse_int(env_val: str | None, file_val: Any, default: int) -> int:
if env_val is not None and env_val.strip():
try:
return max(0, int(env_val.strip()))
except ValueError:
return default
if isinstance(file_val, int) and file_val >= 0:
return file_val
return default
class RedactingFileHistory(FileHistory):
"""``FileHistory`` that redacts known token shapes before persisting.
Also enforces a max-entry retention cap by rewriting the file when
the new total exceeds the cap. ``paused`` lets ``/history off`` stop
persistence at runtime without swapping the History instance.
"""
def __init__(
self,
filename: str,
*,
rules: tuple[RedactionRule, ...] = DEFAULT_REDACTION_RULES,
max_entries: int = DEFAULT_MAX_ENTRIES,
) -> None:
super().__init__(filename)
self.paused: bool = False
self._rules = rules
self._max_entries = max_entries
self._entry_count: int | None = None
def load_history_strings(self) -> Iterator[str]:
"""Yield entries with CRLF artifacts from the on-disk format normalized away."""
for item in super().load_history_strings():
yield item.rstrip("\r\n")
@property
def max_entries(self) -> int:
return self._max_entries
def set_max_entries(self, max_entries: int, *, prune: bool = True) -> None:
self._max_entries = max(0, max_entries)
if prune:
self._prune_to_cap()
def store_string(self, string: str) -> None:
if self.paused:
return
cleaned = redact_text(string, self._rules) if self._rules else string
super().store_string(cleaned)
if self._max_entries <= 0:
return
if self._entry_count is None:
self._entry_count = self._count_entries()
else:
self._entry_count += 1
if self._entry_count > self._max_entries:
self._prune_to_cap()
def _prune_to_cap(self) -> None:
if self._max_entries <= 0:
return
path = Path(os.fsdecode(self.filename))
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return
lines = text.splitlines(keepends=True)
# Each persisted entry begins with a "# <timestamp>" comment line.
boundaries = [i for i, ln in enumerate(lines) if ln.startswith("# ")]
if len(boundaries) <= self._max_entries:
self._entry_count = len(boundaries)
return
keep_from = boundaries[len(boundaries) - self._max_entries]
if keep_from > 0 and lines[keep_from - 1].strip() == "":
keep_from -= 1
try:
path.write_text("".join(lines[keep_from:]), encoding="utf-8")
self._entry_count = self._max_entries
except OSError:
return
def _count_entries(self) -> int:
path = Path(os.fsdecode(self.filename))
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return 0
return sum(1 for line in text.splitlines() if line.startswith("# "))
__all__ = [
"DEFAULT_MAX_ENTRIES",
"DEFAULT_REDACTION_RULES",
"HistoryPolicy",
"RedactingFileHistory",
"RedactionRule",
"redact_text",
]