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
220 lines
7.6 KiB
Python
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",
|
|
]
|