deadlatch 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- deadlatch/__init__.py +32 -0
- deadlatch/_decimal.py +37 -0
- deadlatch/_resources.py +33 -0
- deadlatch/_timeutil.py +25 -0
- deadlatch/_validation.py +176 -0
- deadlatch/audit.py +325 -0
- deadlatch/cli.py +201 -0
- deadlatch/direction.py +115 -0
- deadlatch/engine.py +256 -0
- deadlatch/exposure.py +161 -0
- deadlatch/guard.py +151 -0
- deadlatch/mcp_server.py +582 -0
- deadlatch/migrations.py +196 -0
- deadlatch/model.py +172 -0
- deadlatch/report.py +135 -0
- deadlatch/rules/__init__.py +6 -0
- deadlatch/rules/base.py +40 -0
- deadlatch/rules/cash_margin_check.py +169 -0
- deadlatch/rules/data_freshness.py +48 -0
- deadlatch/rules/input_validity.py +57 -0
- deadlatch/rules/kill_switch.py +51 -0
- deadlatch/rules/max_daily_loss.py +47 -0
- deadlatch/rules/max_drawdown.py +49 -0
- deadlatch/rules/max_order_quantity.py +37 -0
- deadlatch/rules/max_order_value.py +44 -0
- deadlatch/rules/max_symbol_exposure.py +95 -0
- deadlatch/rules/max_total_exposure.py +82 -0
- deadlatch/rules/missing_data_fail_closed.py +132 -0
- deadlatch/rules/order_time_validity.py +62 -0
- deadlatch/rules/registry.py +111 -0
- deadlatch/rules/stubs.py +47 -0
- deadlatch/schemas/audit-record.schema.json +86 -0
- deadlatch/schemas/order.schema.json +143 -0
- deadlatch/schemas/policy.schema.json +138 -0
- deadlatch/schemas/portfolio.schema.json +143 -0
- deadlatch/schemas/result.schema.json +148 -0
- deadlatch/schemas/shadow-report.schema.json +110 -0
- deadlatch-0.1.0.dist-info/METADATA +305 -0
- deadlatch-0.1.0.dist-info/RECORD +43 -0
- deadlatch-0.1.0.dist-info/WHEEL +5 -0
- deadlatch-0.1.0.dist-info/entry_points.txt +3 -0
- deadlatch-0.1.0.dist-info/licenses/LICENSE +21 -0
- deadlatch-0.1.0.dist-info/top_level.txt +1 -0
deadlatch/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""deadlatch 核心包。
|
|
2
|
+
|
|
3
|
+
-A:S-1 决策合成、S-2 异常捕获、S-7 数值摄取、规则接口、R1。
|
|
4
|
+
-B:R2–R12 全部真实实现、Guard.from_policy、CLI、标准规则注册表。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from ._decimal import DecimalInputError, as_decimal
|
|
8
|
+
from .engine import GuardEngine
|
|
9
|
+
from .guard import Guard, load_policy_file
|
|
10
|
+
from .model import Order, Policy, Portfolio, Result
|
|
11
|
+
from .rules.base import Rule, RuleContext, RuleOutcome
|
|
12
|
+
from .rules.kill_switch import KillSwitchRule
|
|
13
|
+
from .rules.registry import RULE_IDS, MANDATORY_RULE_IDS, standard_rule_registry
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"DecimalInputError",
|
|
17
|
+
"as_decimal",
|
|
18
|
+
"GuardEngine",
|
|
19
|
+
"Guard",
|
|
20
|
+
"load_policy_file",
|
|
21
|
+
"Order",
|
|
22
|
+
"Policy",
|
|
23
|
+
"Portfolio",
|
|
24
|
+
"Result",
|
|
25
|
+
"Rule",
|
|
26
|
+
"RuleContext",
|
|
27
|
+
"RuleOutcome",
|
|
28
|
+
"KillSwitchRule",
|
|
29
|
+
"RULE_IDS",
|
|
30
|
+
"MANDATORY_RULE_IDS",
|
|
31
|
+
"standard_rule_registry",
|
|
32
|
+
]
|
deadlatch/_decimal.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""S-7 数值摄取:金额与比例的唯一入口。
|
|
2
|
+
|
|
3
|
+
硬性要求(-A §四):
|
|
4
|
+
- Decimal(str(value)),禁止 Decimal(float);
|
|
5
|
+
- float / int / str 输入均须正确处理(str(float) 对 0.1 → "0.1",精确);
|
|
6
|
+
- NaN / Infinity / -Infinity → DecimalInputError(引擎映射 exit 4);
|
|
7
|
+
- 金额与比例不得参与任何 float 运算。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from decimal import Decimal, InvalidOperation
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DecimalInputError(ValueError):
|
|
14
|
+
"""非有限或不可解析的数值输入(映射 exit 4,输入错误语义)。"""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def as_decimal(value) -> Decimal:
|
|
18
|
+
"""单一入口。接受 int / float / str / Decimal。"""
|
|
19
|
+
if isinstance(value, Decimal):
|
|
20
|
+
d = value
|
|
21
|
+
else:
|
|
22
|
+
try:
|
|
23
|
+
d = Decimal(str(value))
|
|
24
|
+
except (InvalidOperation, ValueError, TypeError) as exc:
|
|
25
|
+
# FIX-005-5:消息不回显输入值(details/evidence 共用,防回显注入)
|
|
26
|
+
raise DecimalInputError("无法解析为 Decimal(输入必须是有穷数字)") from exc
|
|
27
|
+
if not d.is_finite():
|
|
28
|
+
raise DecimalInputError("非有限数值(NaN/Infinity 不接受)")
|
|
29
|
+
return d
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def check_finite(value, name: str) -> None:
|
|
33
|
+
"""校验有限性(不返回 Decimal,仅检查;用于输入门)。"""
|
|
34
|
+
try:
|
|
35
|
+
as_decimal(value)
|
|
36
|
+
except DecimalInputError as exc:
|
|
37
|
+
raise DecimalInputError(f"{name}: {exc}") from exc
|
deadlatch/_resources.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""包内资源统一读取( §二)。
|
|
2
|
+
|
|
3
|
+
wheel 安装后运行期 Schema 的唯一入口:使用 importlib.resources 读取包数据
|
|
4
|
+
(支持普通目录安装与 zip 安装语义),禁止各模块自行拼接
|
|
5
|
+
``Path(__file__).resolve().parents[...] / "schemas"``。
|
|
6
|
+
|
|
7
|
+
仓库根 ``schemas/`` 仅作可读设计/分发副本(tools/validate_schemas.py 校验用);
|
|
8
|
+
两份内容一致性由 tests/test_resources.py 逐文件字节断言保证。
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from importlib import resources
|
|
13
|
+
|
|
14
|
+
_PACKAGE = "deadlatch" # 包名固定(importlib.resources 要求非 None)
|
|
15
|
+
_SCHEMA_DIR = "schemas"
|
|
16
|
+
|
|
17
|
+
SCHEMA_NAMES = ("order", "portfolio", "policy", "result", "audit-record", "shadow-report")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _schema_ref(name: str):
|
|
21
|
+
if name not in SCHEMA_NAMES:
|
|
22
|
+
raise ValueError(f"未知 Schema: {name!r}(可用: {SCHEMA_NAMES})")
|
|
23
|
+
return resources.files(_PACKAGE).joinpath(_SCHEMA_DIR, f"{name}.schema.json")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def schema_text(name: str) -> str:
|
|
27
|
+
"""返回 {name}.schema.json 的 UTF-8 文本(目录/zip 安装均可用)。"""
|
|
28
|
+
return _schema_ref(name).read_text(encoding="utf-8")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def schema_dict(name: str) -> dict:
|
|
32
|
+
"""解析为 dict(运行时契约对象)。"""
|
|
33
|
+
return json.loads(schema_text(name))
|
deadlatch/_timeutil.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""RFC3339 时间解析工具(R10/R11/R12 共用)。
|
|
2
|
+
|
|
3
|
+
Schema 强制 RFC3339 且带显式时区(pattern 约束);本模块只负责解析成
|
|
4
|
+
aware datetime,失败返回 None(由 R12 判为业务数据缺失,R2 已先拦截
|
|
5
|
+
订单侧格式错误)。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def parse_rfc3339(value) -> datetime | None:
|
|
12
|
+
"""解析 RFC3339 字符串为 aware datetime。非法输入 → None。"""
|
|
13
|
+
if not isinstance(value, str):
|
|
14
|
+
return None
|
|
15
|
+
try:
|
|
16
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
17
|
+
except ValueError:
|
|
18
|
+
return None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def to_epoch_seconds(dt: datetime) -> int:
|
|
22
|
+
"""aware datetime → UTC 纪元秒(整数)。naive 视为 UTC。"""
|
|
23
|
+
if dt.tzinfo is None:
|
|
24
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
25
|
+
return int(dt.timestamp())
|
deadlatch/_validation.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""输入/配置校验(优先级 2 输入门 + 分诊)。
|
|
2
|
+
|
|
3
|
+
分诊(-B §2.3,四类不串码):
|
|
4
|
+
- order / policy:全部 Schema 错误(含版本门、币种、订单金额有限性)→ exit 4;
|
|
5
|
+
- portfolio:结构类错误(额外字段 / 枚举 / 格式 pattern / const)→ exit 4;
|
|
6
|
+
portfolio 业务数据缺失 / null / 类型非法 / 数值范围 / 非有限 → **不在此抛错**,
|
|
7
|
+
由 R12 missing_data_fail_closed 判为 exit 3(账户数据不可用,风控语义);
|
|
8
|
+
- 引擎/规则异常 → exit 5(DEF-A3 兜底)。
|
|
9
|
+
|
|
10
|
+
任何 exit-4 失败抛 InputValidationError。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from jsonschema import Draft202012Validator
|
|
15
|
+
|
|
16
|
+
from ._decimal import DecimalInputError, as_decimal
|
|
17
|
+
from ._resources import schema_dict
|
|
18
|
+
|
|
19
|
+
_validators: dict[str, Draft202012Validator] = {}
|
|
20
|
+
|
|
21
|
+
# 各 Schema 当前版本(NEW-12f 独立版本号;版本门 exit 4,NEW-13)
|
|
22
|
+
_EXPECTED_SCHEMA_VERSION = {"order": 2, "portfolio": 3, "policy": 2}
|
|
23
|
+
|
|
24
|
+
# portfolio 结构类错误关键词(其余 → R12 exit 3)
|
|
25
|
+
_PORTFOLIO_EXIT4_KEYWORDS = ("additionalProperties", "enum", "pattern", "const")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _validator(name: str) -> Draft202012Validator:
|
|
29
|
+
if name not in _validators:
|
|
30
|
+
# :包内 Schema 是运行时唯一来源(wheel 安装后无源码仓)
|
|
31
|
+
_validators[name] = Draft202012Validator(schema_dict(name))
|
|
32
|
+
return _validators[name]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class InputValidationError(Exception):
|
|
36
|
+
def __init__(self, details: list[str]):
|
|
37
|
+
super().__init__("; ".join(details))
|
|
38
|
+
self.details = details
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _schema_errors(name: str, instance: dict) -> list:
|
|
42
|
+
return sorted(_validator(name).iter_errors(instance), key=lambda e: list(e.path))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _path_of(err) -> str:
|
|
46
|
+
return "/".join(str(p) for p in err.path) or "$"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# FIX-005-5:jsonschema 的 err.message 内嵌实例值(如 "'<注入值>' is not one of [...]"),
|
|
50
|
+
# 直接进入 details/evidence 会被 CLI explain()/审计记录回显。统一改为
|
|
51
|
+
# "字段路径 + 通用类别";enum/required 附 Schema 已知静态内容(合法值/必填字段名),
|
|
52
|
+
# 绝不回显用户输入本身。
|
|
53
|
+
_SCHEMA_ERROR_CATEGORY = {
|
|
54
|
+
"required": "缺少必填字段",
|
|
55
|
+
"type": "类型非法",
|
|
56
|
+
"enum": "枚举值非法",
|
|
57
|
+
"const": "值非法",
|
|
58
|
+
"pattern": "格式非法",
|
|
59
|
+
"minimum": "数值低于下限",
|
|
60
|
+
"maximum": "数值高于上限",
|
|
61
|
+
"exclusiveMinimum": "数值不满足下限",
|
|
62
|
+
"exclusiveMaximum": "数值不满足上限",
|
|
63
|
+
"minLength": "长度不足",
|
|
64
|
+
"maxLength": "长度超限",
|
|
65
|
+
"additionalProperties": "未知字段",
|
|
66
|
+
"items": "数组元素非法",
|
|
67
|
+
"format": "格式非法",
|
|
68
|
+
"anyOf": "条件约束非法",
|
|
69
|
+
"oneOf": "条件约束非法",
|
|
70
|
+
"if": "条件约束非法",
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def schema_error_text(name: str, err) -> str:
|
|
75
|
+
"""jsonschema 错误的脱敏文本:{文档}.{字段路径}: {通用类别},不回显实例值。"""
|
|
76
|
+
path = _path_of(err)
|
|
77
|
+
category = _SCHEMA_ERROR_CATEGORY.get(err.validator, "校验失败")
|
|
78
|
+
text = f"{name}.{path}: {category}"
|
|
79
|
+
if err.validator == "enum":
|
|
80
|
+
allowed = err.validator_value
|
|
81
|
+
if isinstance(allowed, list):
|
|
82
|
+
text += f"(允许 {allowed})"
|
|
83
|
+
elif err.validator == "required":
|
|
84
|
+
missing = err.validator_value
|
|
85
|
+
if isinstance(missing, list):
|
|
86
|
+
text += f"({missing})"
|
|
87
|
+
return text
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def safe_field_error(doc: str, path: str, category: str) -> str:
|
|
91
|
+
"""共享安全错误文本(FIX-005-6/7):{文档}.{字段路径}: {固定类别}。
|
|
92
|
+
|
|
93
|
+
只接受文档名、字段路径与固定类别字符串;调用方原始值(版本号/币种/任意
|
|
94
|
+
文本)不得作为参数传入,本函数也不格式化任何值——错误文本绝不回显
|
|
95
|
+
调用方输入。CLI/MCP/审计层不得再替换或追加原始值。
|
|
96
|
+
"""
|
|
97
|
+
return f"{doc}.{path}: {category}"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def validate_inputs(order, portfolio, policy) -> None:
|
|
101
|
+
"""校验 order / portfolio / policy;exit-4 类失败抛 InputValidationError。"""
|
|
102
|
+
details_exit4: list[str] = []
|
|
103
|
+
|
|
104
|
+
# 版本门(NEW-13):缺 / 高 / 低于当前版本 → exit 4(先于分类,避免被 R12 吞掉)
|
|
105
|
+
# FIX-005-6:不回显实际值/类型 repr/容器内容(恶意版本值不得进入任何输出)
|
|
106
|
+
for name, inst in (
|
|
107
|
+
("order", order.to_dict()),
|
|
108
|
+
("portfolio", portfolio.to_dict()),
|
|
109
|
+
("policy", policy.to_dict()),
|
|
110
|
+
):
|
|
111
|
+
if inst.get("schema_version") != _EXPECTED_SCHEMA_VERSION[name]:
|
|
112
|
+
details_exit4.append(
|
|
113
|
+
safe_field_error(
|
|
114
|
+
name, "schema_version",
|
|
115
|
+
f"版本缺失或不符(期望 {_EXPECTED_SCHEMA_VERSION[name]})",
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# order:全量 Schema → exit 4
|
|
120
|
+
for err in _schema_errors("order", order.to_dict()):
|
|
121
|
+
if err.path and err.path[0] == "schema_version":
|
|
122
|
+
continue # 版本门已单独处理
|
|
123
|
+
details_exit4.append(schema_error_text("order", err))
|
|
124
|
+
|
|
125
|
+
# policy:全量 Schema → exit 4
|
|
126
|
+
for err in _schema_errors("policy", policy.to_dict()):
|
|
127
|
+
if err.path and err.path[0] == "schema_version":
|
|
128
|
+
continue
|
|
129
|
+
details_exit4.append(schema_error_text("policy", err))
|
|
130
|
+
|
|
131
|
+
# portfolio:结构类 → exit 4;业务数据缺失/null/类型/范围 → 留给 R12(exit 3)
|
|
132
|
+
for err in _schema_errors("portfolio", portfolio.to_dict()):
|
|
133
|
+
if err.path and err.path[0] == "schema_version":
|
|
134
|
+
continue
|
|
135
|
+
if err.validator in _PORTFOLIO_EXIT4_KEYWORDS:
|
|
136
|
+
details_exit4.append(schema_error_text("portfolio", err))
|
|
137
|
+
|
|
138
|
+
# 币种一致性(运行时规则,exit 4 输入错误语义)
|
|
139
|
+
# FIX-005-7:不回显任一实际币种值(固定类别 + 安全字段路径)
|
|
140
|
+
if order.currency and order.currency != policy.base_currency:
|
|
141
|
+
details_exit4.append(
|
|
142
|
+
safe_field_error("order", "currency",
|
|
143
|
+
"currency mismatch(与 policy.base_currency 不一致)")
|
|
144
|
+
)
|
|
145
|
+
pf_currency = portfolio.data.get("base_currency")
|
|
146
|
+
if pf_currency and pf_currency != policy.base_currency:
|
|
147
|
+
details_exit4.append(
|
|
148
|
+
safe_field_error("portfolio", "base_currency",
|
|
149
|
+
"currency mismatch(与 policy.base_currency 不一致)")
|
|
150
|
+
)
|
|
151
|
+
positions_raw = portfolio.data.get("positions")
|
|
152
|
+
if isinstance(positions_raw, list):
|
|
153
|
+
for i, pos in enumerate(positions_raw):
|
|
154
|
+
if isinstance(pos, dict) and pos.get("currency") and pos["currency"] != policy.base_currency:
|
|
155
|
+
details_exit4.append(
|
|
156
|
+
safe_field_error("portfolio", f"positions[{i}].currency",
|
|
157
|
+
"currency mismatch(与 policy.base_currency 不一致)")
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# 订单金额有限性(order 侧 → exit 4;portfolio 侧非有限由 R12/business_number 处理)
|
|
161
|
+
_check_finite(order.price, "order.price", details_exit4)
|
|
162
|
+
opt = order.option or {}
|
|
163
|
+
_check_finite(opt.get("strike"), "order.option.strike", details_exit4)
|
|
164
|
+
|
|
165
|
+
if details_exit4:
|
|
166
|
+
raise InputValidationError(details_exit4)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _check_finite(value, name: str, details: list[str]) -> None:
|
|
170
|
+
if value is None:
|
|
171
|
+
return
|
|
172
|
+
try:
|
|
173
|
+
as_decimal(value)
|
|
174
|
+
except DecimalInputError:
|
|
175
|
+
# FIX-005-5:不回显输入值(只给类别;schema 已保证为 number,此处仅有限性)
|
|
176
|
+
details.append(f"{name}: 非有限数值(NaN/Infinity)")
|
deadlatch/audit.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""本地审计 JSONL( §三/§四/§五)。
|
|
2
|
+
|
|
3
|
+
- 每次 Guard.check() 尝试原子追加一条 AuditRecord(单行 UTF-8 JSON,
|
|
4
|
+
排序与序列化确定:sort_keys + 紧凑分隔符);
|
|
5
|
+
- 跨进程文件锁(fcntl.flock 独立锁文件)+ flush/fsync;清理时锁内写临时
|
|
6
|
+
文件、fsync 后 os.replace 原子替换,异常不破坏原文件;
|
|
7
|
+
- 30 天保留(免费版固定,不读 license):每次 append 与 report 入口触发,
|
|
8
|
+
恰好 30 天保留、早于 30 天删除、未来记录不误删(计数返回供报告附注);
|
|
9
|
+
- 脱敏:rule_hits.detail 用本次 order/portfolio 的 symbol/underlying 等
|
|
10
|
+
已知敏感值精确替换,并过滤绝对路径与常见凭据形态;input_hash 保留;
|
|
11
|
+
- malformed / Schema 非法行:读取/清理/追加一律 fail-closed(抛 AuditError),
|
|
12
|
+
事务不写盘、原文件保持可恢复、不静默跳过;append 传入的新记录同样先过
|
|
13
|
+
audit-record.schema 校验。
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import uuid
|
|
20
|
+
from datetime import datetime, timedelta, timezone
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from jsonschema import Draft202012Validator
|
|
24
|
+
|
|
25
|
+
from ._resources import schema_dict
|
|
26
|
+
from ._timeutil import parse_rfc3339
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
import fcntl
|
|
30
|
+
except ImportError: # 非 POSIX 平台退化( 验收环境为 macOS/POSIX)
|
|
31
|
+
fcntl = None # type: ignore[assignment]
|
|
32
|
+
|
|
33
|
+
_AUDIT_VALIDATOR = Draft202012Validator(schema_dict("audit-record")) # :包内 Schema
|
|
34
|
+
|
|
35
|
+
# 默认本地用户状态路径(文档化;DEADLATCH_AUDIT_PATH 环境变量可覆盖)
|
|
36
|
+
DEFAULT_AUDIT_PATH = Path.home() / ".deadlatch" / "audit.jsonl"
|
|
37
|
+
|
|
38
|
+
RETENTION_DAYS = 30
|
|
39
|
+
# T10:审计文件大小上限(30 天窗口量级:约 9MiB/天千条,64MiB 足够)
|
|
40
|
+
MAX_AUDIT_FILE_BYTES = 64 * 1024 * 1024
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _check_audit_size(path: Path) -> None:
|
|
44
|
+
"""审计文件载入前大小检查:超限 → AuditError(fail-closed,拒绝完整读取)。"""
|
|
45
|
+
try:
|
|
46
|
+
size = path.stat().st_size
|
|
47
|
+
except OSError:
|
|
48
|
+
return
|
|
49
|
+
if size > MAX_AUDIT_FILE_BYTES:
|
|
50
|
+
raise AuditError(f"审计文件超过大小上限 {MAX_AUDIT_FILE_BYTES} 字节(实际 {size} 字节),拒绝读取")
|
|
51
|
+
|
|
52
|
+
# 脱敏:常见凭据形态与任意 POSIX/macOS 绝对路径(不依赖调用方,不列举少数前缀)
|
|
53
|
+
_SENSITIVE_PATTERN_RE = re.compile(
|
|
54
|
+
r"(?i)"
|
|
55
|
+
r"(authorization|proxy-authorization)\s*:\s*bearer\s+\S+"
|
|
56
|
+
r"|\bbearer\s+[a-zA-Z0-9._~+/=-]{8,}"
|
|
57
|
+
r"|\b(api[_-]?key|secret|passwd|password|access[_-]?token|auth[_-]?token|token)\b\s*[=:]\s*\S+"
|
|
58
|
+
r"|\b(sk-|pk-|rk-)[a-zA-Z0-9_-]{12,}"
|
|
59
|
+
r"|\b(?:cookie|set-cookie)\s*[:=][^\r\n]*" # Cookie / Set-Cookie 头整段
|
|
60
|
+
r"|\b(?:sessionid|session|auth)\b\s*=\s*[^\s;]+" # session credential 形态
|
|
61
|
+
r"|(?<![A-Za-z0-9_.~])(?:/[\w .\-]+){2,}" # 任意绝对路径(≥2 段,段内可含空格,如 "/Applications/Secret App/data.json")
|
|
62
|
+
)
|
|
63
|
+
_SENSITIVE_KEY_RE = re.compile(r"(?i)symbol|underlying|account|token|api[_-]?key|secret|password|credential|bearer")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class AuditError(Exception):
|
|
67
|
+
"""审计读写/校验失败(fail-closed;调用方转可见降级或 exit 5)。"""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------- 脱敏
|
|
71
|
+
|
|
72
|
+
def collect_sensitive_values(order, portfolio) -> list[str]:
|
|
73
|
+
"""收集本次输入中的已知敏感字符串值(symbol/underlying/账户类字段)。"""
|
|
74
|
+
out: list[str] = []
|
|
75
|
+
|
|
76
|
+
def _walk(obj, key: str | None) -> None:
|
|
77
|
+
if isinstance(obj, dict):
|
|
78
|
+
for k, v in obj.items():
|
|
79
|
+
if isinstance(v, str):
|
|
80
|
+
if k in ("symbol", "underlying") or _SENSITIVE_KEY_RE.search(k):
|
|
81
|
+
out.append(v)
|
|
82
|
+
else:
|
|
83
|
+
_walk(v, k)
|
|
84
|
+
elif isinstance(obj, list):
|
|
85
|
+
for item in obj:
|
|
86
|
+
_walk(item, key)
|
|
87
|
+
|
|
88
|
+
_walk(order.to_dict(), None)
|
|
89
|
+
_walk(portfolio.to_dict(), None)
|
|
90
|
+
# 精确替换需要非空、非纯数字、长度 >= 2 的值(避免误伤数量/比例)
|
|
91
|
+
return sorted({v for v in out if isinstance(v, str) and len(v) >= 2 and not v.isdigit()},
|
|
92
|
+
key=len, reverse=True)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def sanitize_text(text: str, sensitive: list[str]) -> str:
|
|
96
|
+
"""脱敏:先精确替换已知敏感值,再过滤路径/凭据形态。"""
|
|
97
|
+
if not isinstance(text, str):
|
|
98
|
+
return text
|
|
99
|
+
for value in sensitive:
|
|
100
|
+
text = text.replace(value, "<redacted>")
|
|
101
|
+
return _SENSITIVE_PATTERN_RE.sub("<redacted>", text)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def sanitize_hits(hits: list[dict], sensitive: list[str]) -> list[dict]:
|
|
105
|
+
"""rule_hits 脱敏:只保留 rule_id/severity/detail(detail 脱敏)。"""
|
|
106
|
+
out = []
|
|
107
|
+
for h in hits:
|
|
108
|
+
rule_id = h.get("rule_id", "")
|
|
109
|
+
severity = h.get("severity", "")
|
|
110
|
+
if not re.fullmatch(r"[a-z_][a-z0-9_]*", str(rule_id)):
|
|
111
|
+
continue
|
|
112
|
+
if severity not in ("BLOCK", "WARN"):
|
|
113
|
+
continue
|
|
114
|
+
out.append(
|
|
115
|
+
{
|
|
116
|
+
"rule_id": str(rule_id),
|
|
117
|
+
"severity": severity,
|
|
118
|
+
"detail": sanitize_text(str(h.get("detail", "")), sensitive),
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------- 记录构造
|
|
125
|
+
|
|
126
|
+
def build_audit_record(order, portfolio, policy, result) -> dict:
|
|
127
|
+
"""构造 AuditRecord(脱敏后;schema 校验失败抛 AuditError)。"""
|
|
128
|
+
sensitive = collect_sensitive_values(order, portfolio)
|
|
129
|
+
hits = []
|
|
130
|
+
for v in result.violations:
|
|
131
|
+
hits.append({"rule_id": v.get("rule_id", ""), "severity": "BLOCK", "detail": v.get("detail", "")})
|
|
132
|
+
for w in result.warnings:
|
|
133
|
+
hits.append({"rule_id": w.get("rule_id", ""), "severity": "WARN", "detail": w.get("detail", "")})
|
|
134
|
+
record = {
|
|
135
|
+
"schema_version": 1,
|
|
136
|
+
"record_id": uuid.uuid4().hex,
|
|
137
|
+
"evaluated_at": result.evaluated_at,
|
|
138
|
+
"input_hash": (result.evidence or {}).get("input_hash", ""),
|
|
139
|
+
"decision": result.decision,
|
|
140
|
+
"shadow_mode": bool(result.shadow_mode),
|
|
141
|
+
"shadow_verdict": result.shadow_verdict,
|
|
142
|
+
"exit_code": result.exit_code,
|
|
143
|
+
# FIX-003-2:policy_version 同样过安全处理,Token/路径/凭据形态不得原样落盘
|
|
144
|
+
"policy_version": sanitize_text(str(policy.version), sensitive),
|
|
145
|
+
"rule_hits": sanitize_hits(hits, sensitive),
|
|
146
|
+
}
|
|
147
|
+
errors = sorted(_AUDIT_VALIDATOR.iter_errors(record), key=lambda e: list(e.path))
|
|
148
|
+
if errors:
|
|
149
|
+
raise AuditError(f"审计记录未通过 audit-record.schema:{errors[0].message}")
|
|
150
|
+
return record
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ---------------------------------------------------------------- 原子追加 / 清理
|
|
154
|
+
|
|
155
|
+
def _lock_path(path: Path) -> Path:
|
|
156
|
+
return path.with_name(path.name + ".lock")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _locked(path: Path, fn):
|
|
160
|
+
"""跨进程文件锁内执行 fn(独立锁文件,避免 replace 换 inode 的竞态)。"""
|
|
161
|
+
lock_file = _lock_path(path)
|
|
162
|
+
lock_file.parent.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
if fcntl is not None:
|
|
164
|
+
with open(lock_file, "a+", encoding="utf-8") as lf:
|
|
165
|
+
fcntl.flock(lf.fileno(), fcntl.LOCK_EX)
|
|
166
|
+
try:
|
|
167
|
+
return fn()
|
|
168
|
+
finally:
|
|
169
|
+
fcntl.flock(lf.fileno(), fcntl.LOCK_UN)
|
|
170
|
+
# 非 POSIX:进程内锁退化为无跨进程保证(验收环境为 POSIX)
|
|
171
|
+
return fn()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _filter_kept_lines(lines: list[str], now: datetime, cutoff: datetime) -> tuple[list[str], int, int]:
|
|
175
|
+
"""按 30 天保留过滤:保留 ≥ cutoff 的行与未来记录。
|
|
176
|
+
|
|
177
|
+
每条既有记录除解析 JSON / evaluated_at 外,还必须通过
|
|
178
|
+
audit-record.schema.json——malformed 或 Schema 非法一律 AuditError
|
|
179
|
+
(fail-closed,事务不写盘、原文件不动)。返回 (保留行, removed, future)。
|
|
180
|
+
"""
|
|
181
|
+
kept: list[str] = []
|
|
182
|
+
removed = 0
|
|
183
|
+
future = 0
|
|
184
|
+
for lineno, line in enumerate(lines, 1):
|
|
185
|
+
if not line.strip():
|
|
186
|
+
continue
|
|
187
|
+
try:
|
|
188
|
+
rec = json.loads(line)
|
|
189
|
+
except Exception as exc:
|
|
190
|
+
raise AuditError(
|
|
191
|
+
f"审计文件第 {lineno} 行 JSON 损坏,操作已中止(原文件未改动): {type(exc).__name__}"
|
|
192
|
+
) from exc
|
|
193
|
+
errors = sorted(_AUDIT_VALIDATOR.iter_errors(rec), key=lambda e: list(e.path))
|
|
194
|
+
if errors:
|
|
195
|
+
raise AuditError(
|
|
196
|
+
f"审计文件第 {lineno} 行未通过 audit-record.schema,操作已中止(原文件未改动): {errors[0].message}"
|
|
197
|
+
)
|
|
198
|
+
dt = parse_rfc3339(rec.get("evaluated_at", ""))
|
|
199
|
+
if dt is None:
|
|
200
|
+
raise AuditError(
|
|
201
|
+
f"审计文件第 {lineno} 行 evaluated_at 缺失/不可解析,操作已中止(原文件未改动)"
|
|
202
|
+
)
|
|
203
|
+
if dt > now:
|
|
204
|
+
future += 1
|
|
205
|
+
kept.append(line)
|
|
206
|
+
elif dt >= cutoff:
|
|
207
|
+
kept.append(line)
|
|
208
|
+
else:
|
|
209
|
+
removed += 1
|
|
210
|
+
return kept, removed, future
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _write_atomic(path: Path, lines: list[str]) -> None:
|
|
214
|
+
"""同目录临时文件 → flush/fsync → os.replace 原子替换(异常时原文件保持完整)。"""
|
|
215
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
216
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
217
|
+
f.writelines(lines)
|
|
218
|
+
f.flush()
|
|
219
|
+
os.fsync(f.fileno())
|
|
220
|
+
os.replace(tmp, path)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def append_audit(path, record: dict, now: datetime | None = None) -> dict:
|
|
224
|
+
"""原子追加一条记录,同一锁事务内执行 30 天保留(FIX-003-1)。
|
|
225
|
+
|
|
226
|
+
- 每次成功 append 都保证早于 (now − 30d) 的记录被清理(不按文件大小决定);
|
|
227
|
+
- 恰好 30 天保留、早于 30 天删除、未来记录保留;
|
|
228
|
+
- 校验/清理/追加在同一跨进程锁内完成:要么全部成功(单条记录不拆裂),
|
|
229
|
+
要么不写任何东西(malformed/读写/replace 失败 → 抛异常,调用方转
|
|
230
|
+
audit_write_failed 降级;原文件保持可恢复,不会出现"磁盘已写 PASS/0
|
|
231
|
+
但调用方拿到 WARN/2"的矛盾)。
|
|
232
|
+
返回 {"removed", "future"}。
|
|
233
|
+
"""
|
|
234
|
+
path = Path(path)
|
|
235
|
+
now = now or datetime.now(timezone.utc)
|
|
236
|
+
cutoff = now - timedelta(days=RETENTION_DAYS)
|
|
237
|
+
|
|
238
|
+
def _tx() -> dict:
|
|
239
|
+
# 新记录同样必须通过 audit-record.schema(非法 → 事务不写盘)
|
|
240
|
+
errors = sorted(_AUDIT_VALIDATOR.iter_errors(record), key=lambda e: list(e.path))
|
|
241
|
+
if errors:
|
|
242
|
+
raise AuditError(
|
|
243
|
+
f"审计记录未通过 audit-record.schema,事务未写盘: {errors[0].message}"
|
|
244
|
+
)
|
|
245
|
+
line = json.dumps(record, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
246
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
247
|
+
if path.exists():
|
|
248
|
+
_check_audit_size(path) # T10
|
|
249
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
250
|
+
lines = f.readlines()
|
|
251
|
+
kept, removed, future = _filter_kept_lines(lines, now, cutoff)
|
|
252
|
+
else:
|
|
253
|
+
kept, removed, future = [], 0, 0
|
|
254
|
+
kept.append(line + "\n")
|
|
255
|
+
# FIX-005-3:最终 UTF-8 字节数(含换行)越界 → 在创建/替换目标文件前拒绝,
|
|
256
|
+
# 原审计文件字节级不变、不留临时文件;== MAX 成功,+1 拒绝
|
|
257
|
+
final_size = sum(len(l.encode("utf-8")) for l in kept)
|
|
258
|
+
if final_size > MAX_AUDIT_FILE_BYTES:
|
|
259
|
+
raise AuditError(
|
|
260
|
+
f"审计文件追加后大小 {final_size} 字节超过上限 {MAX_AUDIT_FILE_BYTES},"
|
|
261
|
+
f"拒绝写入(原文件未改动)"
|
|
262
|
+
)
|
|
263
|
+
_write_atomic(path, kept)
|
|
264
|
+
return {"removed": removed, "future": future}
|
|
265
|
+
|
|
266
|
+
return _locked(path, _tx)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def prune_audit(path, now: datetime | None = None, retention_days: int = RETENTION_DAYS) -> dict:
|
|
270
|
+
"""30 天保留:早于 (now − retention_days) 的记录删除;未来记录保留。
|
|
271
|
+
|
|
272
|
+
锁内读全文件 → 写同目录临时文件 → fsync → os.replace 原子替换。
|
|
273
|
+
malformed 行 → fail-closed 抛 AuditError,原文件不动(可恢复)。
|
|
274
|
+
返回 {"removed", "kept", "future"}。
|
|
275
|
+
"""
|
|
276
|
+
path = Path(path)
|
|
277
|
+
now = now or datetime.now(timezone.utc)
|
|
278
|
+
if not path.exists():
|
|
279
|
+
return {"removed": 0, "kept": 0, "future": 0}
|
|
280
|
+
_check_audit_size(path) # T10
|
|
281
|
+
cutoff = now - timedelta(days=retention_days)
|
|
282
|
+
|
|
283
|
+
def _prune() -> dict:
|
|
284
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
285
|
+
lines = f.readlines()
|
|
286
|
+
kept, removed, future = _filter_kept_lines(lines, now, cutoff)
|
|
287
|
+
if removed == 0:
|
|
288
|
+
return {"removed": 0, "kept": len(kept), "future": future}
|
|
289
|
+
_write_atomic(path, kept)
|
|
290
|
+
return {"removed": removed, "kept": len(kept), "future": future}
|
|
291
|
+
|
|
292
|
+
return _locked(path, _prune)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# ---------------------------------------------------------------- 读取(供报告)
|
|
296
|
+
|
|
297
|
+
def read_audit_records(path) -> list[dict]:
|
|
298
|
+
"""逐行读取并校验(audit-record.schema)。malformed / schema 非法 → AuditError。
|
|
299
|
+
|
|
300
|
+
只读操作;失败时原文件不动(调用方 fail-closed,CLI exit 5)。
|
|
301
|
+
"""
|
|
302
|
+
path = Path(path)
|
|
303
|
+
try:
|
|
304
|
+
_check_audit_size(path) # T10
|
|
305
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
306
|
+
lines = f.readlines()
|
|
307
|
+
except OSError as exc:
|
|
308
|
+
raise AuditError(f"审计文件不可读:{type(exc).__name__}") from exc
|
|
309
|
+
records: list[dict] = []
|
|
310
|
+
for lineno, line in enumerate(lines, 1):
|
|
311
|
+
if not line.strip():
|
|
312
|
+
continue
|
|
313
|
+
try:
|
|
314
|
+
rec = json.loads(line)
|
|
315
|
+
except Exception as exc:
|
|
316
|
+
raise AuditError(
|
|
317
|
+
f"审计文件第 {lineno} 行 JSON 损坏(fail-closed,不静默跳过): {type(exc).__name__}"
|
|
318
|
+
) from exc
|
|
319
|
+
errors = sorted(_AUDIT_VALIDATOR.iter_errors(rec), key=lambda e: list(e.path))
|
|
320
|
+
if errors:
|
|
321
|
+
raise AuditError(
|
|
322
|
+
f"审计文件第 {lineno} 行未通过 audit-record.schema(fail-closed): {errors[0].message}"
|
|
323
|
+
)
|
|
324
|
+
records.append(rec)
|
|
325
|
+
return records
|