跳转至

SECURITY PATTERN

数据保留与隐私

Maturity
L1
Complexity
medium
Reliability
high
Hermes
memory
Version
v1.0.0

When to Use

  • Agent 长期运行积累日志/STATE.md
  • 涉及用户数据的 pipeline
  • 合规审计要求的场景

When NOT to Use

  • 纯离线一次性 demo
  • 无敏感数据的公开信息聚合
Skill Contract(本模式的模块声明)

版本 v1.0.0 | 分类 conventions | 相关 secret-management · state-file-pattern · error-compact-pattern

对应 12-Factor Agents 的隐含原则:State is a liability, not an asset. 与 secret-management.md 互补:secret-management 管密钥存放,本篇管数据生命周期。

核心原则

数据只在需要时保留,超过期限自动清理,写入前先脱敏。

问题

STATE.md、运行日志、错误日志在长期运行中会不知不觉积累敏感信息:

  • 用户原始输入中的 PII(邮箱、手机号、身份证号)
  • 密钥碎片(调试时不小心写入的 API key 片段)
  • 完整 API 响应体(含 token、用户 ID、内部 URL)
  • 文件路径泄露(暴露用户名、目录结构)

这些数据一旦进入 Git 历史或共享日志,就很难彻底清除。

不该出现的内容清单

类别 示例 风险等级
PII — 邮箱 user@example.com 🔴 高
PII — 手机号 13812345678, +1-555-0123 🔴 高
PII — 身份证号 110101199001011234 🔴 高
PII — 姓名+地址组合 张三,北京市朝阳区... 🟡 中
密钥碎片 sk-proj-abc...xyz 🔴 高
完整 API 响应体 access_token, refresh_token 的 JSON 🔴 高
内部 URL https://internal.corp.local/api/... 🟡 中
文件路径(含用户名) /home/john/.hermes/.env 🟢 低

保留期限建议

数据类型 保留期限 清理方式
运行日志(logs/ 30 天 按文件修改时间删除
STATE.md 历史版本 90 天 只保留最近 N 个版本
错误日志(error_logs/ 7 天 按文件修改时间删除
临时文件(/tmp/hermes-* 1 天 cron 定时清理
Git 中的敏感提交 0(立即清除) git filter-branch 或 BFG

原则:宁短勿长。 不确定时选更短的保留期。

自动清理脚本模式

#!/usr/bin/env python3
"""data_retention_cleanup.py — 按保留期限清理过期数据"""

import os
import time
import re
import glob
from pathlib import Path
from datetime import datetime, timedelta

# 保留期限配置(天)
RETENTION = {
    "logs": 30,
    "state_history": 90,
    "error_logs": 7,
    "tmp": 1,
}

def cleanup_by_age(directory: str, max_days: int, dry_run: bool = True) -> int:
    """删除超过 max_days 天的文件,返回删除数量"""
    if not os.path.isdir(directory):
        return 0
    cutoff = time.time() - (max_days * 86400)
    removed = 0
    for f in Path(directory).rglob("*"):
        if f.is_file() and f.stat().st_mtime < cutoff:
            if dry_run:
                print(f"[DRY] would remove: {f}")
            else:
                f.unlink()
                print(f"removed: {f}")
            removed += 1
    return removed

def scan_for_pii(filepath: str) -> list[dict]:
    """扫描文件中的 PII,返回匹配列表"""
    patterns = {
        "email": r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}",
        "phone_cn": r"(?<!\d)1[3-9]\d{9}(?!\d)",
        "phone_intl": r"\+\d{1,3}[-.\s]?\d{4,14}",
        "id_card_cn": r"(?<!\d)[1-9]\d{5}(?:19|20)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])\d{3}[\dXx](?!\d)",
        "api_key_generic": r"(?:sk|key|token|secret)[_-][a-zA-Z0-9]{20,}",
        "bearer_token": r"Bearer\s+[a-zA-Z0-9._-]{20,}",
    }
    findings = []
    try:
        text = Path(filepath).read_text(errors="ignore")
    except Exception:
        return findings
    for ptype, pattern in patterns.items():
        for match in re.finditer(pattern, text):
            findings.append({
                "type": ptype,
                "line_preview": match.group()[:8] + "***",
                "position": match.start(),
            })
    return findings

# 敏感信息正则模式列表(导出供其他脚本使用)
SENSITIVE_PATTERNS = {
    "email": r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}",
    "phone_cn": r"(?<!\d)1[3-9]\d{9}(?!\d)",
    "phone_intl": r"\+\d{1,3}[-.\s]?\d{4,14}",
    "id_card_cn": r"(?<!\d)[1-9]\d{5}(?:19|20)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])\d{3}[\dXx](?!\d)",
    "api_key_generic": r"(?:sk|key|token|secret)[_-][a-zA-Z0-9]{20,}",
    "bearer_token": r"Bearer\s+[a-zA-Z0-9._-]{20,}",
    "ipv4_private": r"(?:10|172\.(?:1[6-9]|2\d|3[01])|192\.168)\.\d{1,3}\.\d{1,3}",
    "jwt": r"eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}",
}

def main():
    import argparse
    parser = argparse.ArgumentParser(description="Hermes data retention cleanup")
    parser.add_argument("--dry-run", action="store_true", default=True)
    parser.add_argument("--execute", action="store_true", help="Actually delete files")
    parser.add_argument("--scan-pii", type=str, help="Scan a file/directory for PII")
    args = parser.parse_args()

    dry_run = not args.execute

    if args.scan_pii:
        target = Path(args.scan_pii)
        files = [target] if target.is_file() else target.rglob("*")
        for f in files:
            if f.is_file():
                findings = scan_for_pii(str(f))
                if findings:
                    print(f"\n⚠️  {f}:")
                    for f2 in findings:
                        print(f"  [{f2['type']}] {f2['line_preview']}")
        return

    hermes_dir = os.path.expanduser("~/.hermes")
    cleanup_targets = {
        os.path.join(hermes_dir, "logs"): RETENTION["logs"],
        os.path.join(hermes_dir, "error_logs"): RETENTION["error_logs"],
        "/tmp": RETENTION["tmp"],  # 仅清理 hermes-* 前缀
    }

    print(f"{'[DRY RUN] ' if dry_run else ''}Starting cleanup at {datetime.now()}")
    for directory, max_days in cleanup_targets.items():
        count = cleanup_by_age(directory, max_days, dry_run=dry_run)
        print(f"  {directory}: {count} files {'would be' if dry_run else ''} removed (>{max_days}d)")

if __name__ == "__main__":
    main()

定时运行

# crontab -e
# 每天凌晨 3 点清理过期数据
0 3 * * * ~/.hermes/scripts/data_retention_cleanup.py --execute >> ~/.hermes/logs/cleanup.log 2>&1

正则模式列表(敏感信息检测)

以下是可直接用于 grepripgrep 或 Python re 的模式:

名称 正则 说明
邮箱 [a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,} 通用邮箱格式
中国手机号 (?<!\d)1[3-9]\d{9}(?!\d) 11 位,1 开头
国际手机号 \+\d{1,3}[-.\s]?\d{4,14} 含国家码
中国身份证号 (?<!\d)[1-9]\d{5}(?:19\|20)\d{2}(?:0[1-9]\|1[0-2])(?:0[1-9]\|[12]\d\|3[01])\d{3}[\dXx](?!\d) 18 位
API Key (?:sk\|key\|token\|secret)[_-][a-zA-Z0-9]{20,} 常见 key 前缀
Bearer Token Bearer\s+[a-zA-Z0-9._-]{20,} HTTP Authorization
JWT eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,} 三段式 JWT
内网 IP (?:10\|172\.(?:1[6-9]\|2\d\|3[01])\|192\.168)\.\d{1,3}\.\d{1,3} RFC1918 地址

快速扫描命令

# 扫描 STATE.md 中的敏感信息
grep -Pn '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}' reports/*/STATE.md

# 扫描日志中的 API key
grep -Prn '(?:sk|key|token|secret)[_-][a-zA-Z0-9]{20,}' ~/.hermes/logs/

# 使用 Python 脚本批量扫描
python data_retention_cleanup.py --scan-pii ~/.hermes/

脱敏写入策略

在写入 STATE.md 或日志前,对敏感字段做替换:

import re

def redact(text: str) -> str:
    """写入前脱敏:替换敏感信息为占位符"""
    text = re.sub(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", "[REDACTED_EMAIL]", text)
    text = re.sub(r"(?<!\d)1[3-9]\d{9}(?!\d)", "[REDACTED_PHONE]", text)
    text = re.sub(r"(?:sk|key|token|secret)[_-][a-zA-Z0-9]{20,}", "[REDACTED_KEY]", text)
    text = re.sub(r"Bearer\s+[a-zA-Z0-9._-]{20,}", "Bearer [REDACTED]", text)
    return text

# 写入 STATE.md 时使用
state_content = redact(raw_content)
Path("STATE.md").write_text(state_content)

和 secret-management.md 的分工

维度 secret-management data-retention-privacy(本篇)
管什么 密钥(API Key、Token、密码) 数据(PII、日志、状态文件)
核心问题 放在哪?怎么轮换? 保留多久?何时清理?怎么脱敏?
生命周期 创建 → 存储 → 轮换 → 销毁 生成 → 脱敏 → 保留 → 过期清理
关注点 不进 Git、不进上下文 不超期、不裸写、可审计

简单记:secret-management 管「钥匙不丢」,data-retention-privacy 管「垃圾及时倒」。

检查清单

  • [ ] STATE.md 写入前经过 redact() 脱敏
  • [ ] 日志不包含完整 API 响应体(只记 status code 和 request ID)
  • [ ] 错误日志 7 天自动清理
  • [ ] 运行日志 30 天自动清理
  • [ ] 定期扫描 ~/.hermes/ 目录确认无 PII 残留
  • [ ] Git 历史中无密钥或 PII(用 git log -p | grep 检查)