diff --git "a/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/README.md" "b/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/README.md" new file mode 100644 index 0000000000000000000000000000000000000000..d6c866fd9adbe1bbf4490829ed768d14c96ebf80 --- /dev/null +++ "b/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/README.md" @@ -0,0 +1,91 @@ +# 基于 LLM 生成自适应 LSM 安全策略:逻辑框架 + +本项目实现 Issue `IJV3B7` 要求的三层闭环逻辑框架:将 eBPF 采集到的行为归一化,交给语义分析器判断,再生成最小权限的后端无关策略意图,经安全校验后下发至 SELinux 或 BPF LSM 逻辑适配器,并把新行为回采到漂移基线。 + +> 当前交付物是安全的参考原型。适配器只激活 `logical-dry-run` 策略包,不编译、加载或修改宿主机真实内核策略;这与 Issue“实现逻辑框架即可,不要求策略”的验收范围一致。 + +## 架构 + +```text +感知层 智能决策层 执行与反馈层 +┌──────────────┐ ┌──────────────────┐ ┌─────────────────────┐ +│ eBPF/JSONL │ 事件 │ 特征归一化 │ 意图 │ 最小权限生成 │ +│ 行为采集器 ├─────────►│ LLM/离线分析器 ├─────────►│ 安全校验/原子版本 │ +└──────────────┘ └──────────────────┘ │ SELinux/BPF LSM适配 │ + ▲ └──────────┬──────────┘ + │ 行为漂移回采 │ + └──────────────────────────────────────────────────────────────┘ +``` + +对应数据流: + +1. `EBPFBehaviorCollector` 隔离 BCC/libbpf/ring-buffer 等具体实现; +2. `EventNormalizer` 生成稳定、可比较的行为特征; +3. `LLMSemanticAnalyzer` 只允许模型接受或拒绝已观测的事件 ID; +4. `LeastPrivilegePolicyGenerator` 从可信事件重建精确资源和动作,模型无法凭空扩权; +5. `PolicySafetyValidator` 拒绝通配符、根目录授权、低置信度和超大策略; +6. `SELinuxAdapter` 与 `BPFLSMAdapter` 分别完成逻辑分阶段下发和原子激活; +7. `DriftDetector` 识别新特征与拦截事件,触发下一轮分析和策略版本更新。 + +## 快速运行 + +项目仅使用 Python 3.10+ 标准库,无运行时第三方依赖。 + +```bash +cd adaptive-lsm +python -m pip install -e . +python -m adaptive_lsm.cli --demo +``` + +内置演示包含两轮:首轮同时激活 SELinux 和 BPF LSM 的第 1 代逻辑策略;第二轮模拟新的文件写入被拒绝,漂移检测触发 SELinux 第 2 代策略。 + +也可以读取 eBPF 事件形状的 JSONL 文件: + +```bash +python -m adaptive_lsm.cli --input examples/events.jsonl +``` + +### 接入 OpenAI 兼容模型 + +```bash +export ADAPTIVE_LSM_LLM_ENDPOINT=https://example/v1/chat/completions +export ADAPTIVE_LSM_LLM_API_KEY=your-key +export ADAPTIVE_LSM_LLM_MODEL=your-model +python -m adaptive_lsm.cli --demo --analyzer llm +``` + +LLM 输出不会直接成为策略。框架验证模型返回的事件 ID、分组边界、布尔决策和置信度,并且只从原始已归一化事件重建权限元组。 + +## 测试 + +```bash +python -m unittest discover -s tests -v +``` + +测试覆盖路径归一化、LLM 防越权校验、SELinux/BPF LSM 双适配、行为漂移触发版本升级,以及通配符策略失败关闭。 + +Linux 实机验证环境与结果见 [`docs/validation.md`](docs/validation.md)。 + +## 接入真实环境的扩展点 + +- eBPF:向 `EBPFBehaviorCollector` 注入 ring-buffer reader,将内核事件映射为 `BehaviorEvent`; +- LLM:实现 `LLMClient.complete()`,或使用内置 OpenAI 兼容客户端; +- 策略后端:在独立的特权代理中实现 `LSMAdapter`,完成编译、签名、灰度、回滚与加载; +- 状态:把 `InMemoryPolicyStore` 替换为带乐观锁和审计留痕的持久化存储; +- 观测:将 `AuditTrail` 对接日志、指标和告警平台。 + +生产接入必须继续保持控制面与特权执行面隔离,并增加人工审批开关、沙箱回放、签名验证、灰度发布、健康探测和自动回滚。LLM 不应直接获得内核策略加载权限。 + +## 目录 + +```text +src/adaptive_lsm/ +├── collection.py # eBPF/JSONL/测试采集边界 +├── normalization.py # 特征归一化 +├── intelligence.py # LLM 与离线语义分析 +├── policy.py # 最小权限策略意图 +├── enforcement.py # 双 LSM、校验、原子版本下发 +├── drift.py # 行为漂移检测 +├── controller.py # 三层闭环编排 +└── cli.py # 可运行演示 +``` diff --git "a/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/docs/validation.md" "b/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/docs/validation.md" new file mode 100644 index 0000000000000000000000000000000000000000..b5d40ad13af405dd28cfdf46ce747692dc555ef0 --- /dev/null +++ "b/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/docs/validation.md" @@ -0,0 +1,35 @@ +# 验证报告 + +验证日期:2026-07-11 + +## 验证范围 + +- Python 单元测试; +- 内置双 LSM 两轮闭环演示; +- JSONL 采集入口; +- OpenCloudOS 9 内核上的运行兼容性与 eBPF 前置能力探测; +- 不加载、不修改真实 SELinux/BPF LSM 策略。 + +## 环境 + +| 环境 | 系统与运行时 | 结果 | +| --- | --- | --- | +| 本地开发 | Windows,Python 3.10.11 | 通过 | +| Linux 实机 | Ubuntu 24.04.4 LTS,Python 3.12.3 | 通过 | +| 宿主内核 | `6.6.69-opencloudos9.cubesandbox.pvm.host-gb85200d80fa2` | 通过 | + +Linux 实机的 `/sys/kernel/security/lsm` 包含 `bpf`,存在 `/sys/kernel/btf/vmlinux`,并已提供 `bpftool`,满足后续接入 CO-RE eBPF 采集器的基本条件。该实例未启用 SELinux,因此 SELinux 部分通过逻辑适配器和单元测试验证。 + +## 执行结果 + +```text +Ran 5 tests in 0.002s +OK +``` + +内置演示结果: + +- 第 1 轮采集 2 个特征,分别激活 BPF LSM 与 SELinux 第 1 代逻辑策略; +- 第 2 轮检测到 1 个新的写入特征,激活 SELinux 第 2 代逻辑策略; +- JSONL 示例采集 2 个事件并成功完成双后端逻辑下发; +- 通配符候选被安全校验器拒绝,策略存储保持为空。 diff --git "a/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/examples/events.jsonl" "b/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/examples/events.jsonl" new file mode 100644 index 0000000000000000000000000000000000000000..d1e48fce850eed8b8880932c435c6bfc20a566a8 --- /dev/null +++ "b/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/examples/events.jsonl" @@ -0,0 +1,2 @@ +{"timestamp": 1783699200.0, "host": "node-a", "workload": "inference-api", "subject": "api_t", "resource_kind": "file", "resource": "/etc/inference/model.yaml", "action": "read", "lsm": "selinux", "outcome": "allowed", "attributes": {"pod": "api-0"}} +{"timestamp": 1783699200.1, "host": "node-a", "workload": "inference-api", "subject": "api_t", "resource_kind": "tcp", "resource": "model-service:9000", "action": "connect", "lsm": "bpf_lsm", "outcome": "allowed", "attributes": {"pod": "api-0"}} diff --git "a/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/pyproject.toml" "b/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/pyproject.toml" new file mode 100644 index 0000000000000000000000000000000000000000..9c60b43b722af68aaed0e580a74b3e395376915c --- /dev/null +++ "b/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/pyproject.toml" @@ -0,0 +1,19 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "adaptive-lsm" +version = "0.1.0" +description = "LLM-driven adaptive LSM policy closed-loop framework" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "MulanPSL-2.0" } +authors = [{ name = "ruirui6946" }] +dependencies = [] + +[project.scripts] +adaptive-lsm = "adaptive_lsm.cli:main" + +[tool.setuptools.packages.find] +where = ["src"] diff --git "a/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/src/adaptive_lsm/__init__.py" "b/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/src/adaptive_lsm/__init__.py" new file mode 100644 index 0000000000000000000000000000000000000000..432e63d956b7f0c8dc3638f77be4b7138bfc6d9f --- /dev/null +++ "b/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/src/adaptive_lsm/__init__.py" @@ -0,0 +1,12 @@ +"""Adaptive LSM closed-loop framework.""" + +from .controller import AdaptiveLSMController, ControllerRun +from .models import BehaviorEvent, LSMKind, NormalizedEvent + +__all__ = [ + "AdaptiveLSMController", + "BehaviorEvent", + "ControllerRun", + "LSMKind", + "NormalizedEvent", +] diff --git "a/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/src/adaptive_lsm/cli.py" "b/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/src/adaptive_lsm/cli.py" new file mode 100644 index 0000000000000000000000000000000000000000..b62046ce332bf3c9df23c3c39ec1cb7c4801328b --- /dev/null +++ "b/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/src/adaptive_lsm/cli.py" @@ -0,0 +1,142 @@ +"""Command-line demonstration for the adaptive loop.""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import time + +from .collection import JsonlCollector, QueueCollector +from .controller import AdaptiveLSMController, ControllerRun +from .intelligence import ( + HeuristicAnalyzer, + LLMSemanticAnalyzer, + OpenAICompatibleClient, + SemanticAnalyzer, +) +from .models import BehaviorEvent, LSMKind + + +def _demo_batches() -> list[list[BehaviorEvent]]: + now = time.time() + return [ + [ + BehaviorEvent( + now, + "node-a", + "inference-api", + "api_t", + "file", + "/etc/inference/model.yaml", + "read", + LSMKind.SELINUX, + "allowed", + {"pod": "api-0"}, + ), + BehaviorEvent( + now + 0.1, + "node-a", + "inference-api", + "api_t", + "tcp", + "model-service:9000", + "connect", + LSMKind.BPF, + "allowed", + {"pod": "api-0"}, + ), + ], + [ + BehaviorEvent( + now + 1, + "node-a", + "inference-api", + "api_t", + "file", + "/var/cache/inference/index.db", + "write", + LSMKind.SELINUX, + "denied", + {"pod": "api-0", "phase": "drift"}, + ) + ], + ] + + +def _analyzer(name: str) -> SemanticAnalyzer: + if name == "heuristic": + return HeuristicAnalyzer() + endpoint = os.environ.get("ADAPTIVE_LSM_LLM_ENDPOINT", "") + api_key = os.environ.get("ADAPTIVE_LSM_LLM_API_KEY", "") + model = os.environ.get("ADAPTIVE_LSM_LLM_MODEL", "") + if not all((endpoint, api_key, model)): + raise ValueError( + "LLM mode requires ADAPTIVE_LSM_LLM_ENDPOINT, " + "ADAPTIVE_LSM_LLM_API_KEY and ADAPTIVE_LSM_LLM_MODEL" + ) + return LLMSemanticAnalyzer(OpenAICompatibleClient(endpoint, api_key, model)) + + +def _as_dict(index: int, run: ControllerRun) -> dict[str, object]: + return { + "cycle": index, + "collected": run.collected, + "normalized": run.normalized, + "analyzed": run.analyzed, + "drift_detected": run.drift_detected, + "new_features": run.new_features, + "activated": run.activated, + "deployments": [ + { + "candidate_id": item.candidate_id, + "backend": item.backend.value, + "generation": item.generation, + "activated": item.activated, + "message": item.message, + } + for item in run.deployments + ], + } + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + source = parser.add_mutually_exclusive_group() + source.add_argument("--input", help="read one batch from a JSONL file") + source.add_argument("--demo", action="store_true", help="run the built-in two-cycle demo") + parser.add_argument( + "--analyzer", + choices=("heuristic", "llm"), + default="heuristic", + help="semantic analyzer implementation", + ) + parser.add_argument("--limit", type=int, default=256, help="maximum events per cycle") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + if args.limit <= 0: + print("--limit must be positive", file=sys.stderr) + return 2 + try: + analyzer = _analyzer(args.analyzer) + if args.input: + collector = JsonlCollector(args.input) + cycles = 1 + else: + collector = QueueCollector(_demo_batches()) + cycles = 2 + controller = AdaptiveLSMController(collector, analyzer) + output = [_as_dict(index, controller.run_once(args.limit)) for index in range(1, cycles + 1)] + except (OSError, ValueError) as exc: + print(f"adaptive-lsm: {exc}", file=sys.stderr) + return 1 + print(json.dumps(output, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git "a/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/src/adaptive_lsm/collection.py" "b/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/src/adaptive_lsm/collection.py" new file mode 100644 index 0000000000000000000000000000000000000000..78b4634989e5e3c589573ac63a0b9a4847fdde8c --- /dev/null +++ "b/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/src/adaptive_lsm/collection.py" @@ -0,0 +1,72 @@ +"""Behavior collection interfaces and eBPF integration boundary.""" + +from __future__ import annotations + +import json +from collections import deque +from pathlib import Path +from typing import Callable, Iterable, Mapping, Protocol + +from .models import BehaviorEvent + + +class BehaviorCollector(Protocol): + def collect(self, limit: int = 256) -> list[BehaviorEvent]: + """Return up to ``limit`` newly observed events.""" + + +class QueueCollector: + """Deterministic collector for demonstrations and tests. + + Each queue item represents one collection interval. + """ + + def __init__(self, batches: Iterable[Iterable[BehaviorEvent]]) -> None: + self._batches = deque(list(batch) for batch in batches) + + def collect(self, limit: int = 256) -> list[BehaviorEvent]: + if not self._batches: + return [] + return self._batches.popleft()[:limit] + + +class JsonlCollector: + """Read eBPF-shaped events from JSON Lines once.""" + + def __init__(self, path: str | Path) -> None: + self._path = Path(path) + self._consumed = False + + def collect(self, limit: int = 256) -> list[BehaviorEvent]: + if self._consumed: + return [] + self._consumed = True + events: list[BehaviorEvent] = [] + with self._path.open("r", encoding="utf-8") as source: + for line_number, line in enumerate(source, start=1): + if not line.strip(): + continue + try: + value = json.loads(line) + events.append(BehaviorEvent.from_mapping(value)) + except (ValueError, TypeError, json.JSONDecodeError) as exc: + raise ValueError( + f"invalid event at {self._path}:{line_number}: {exc}" + ) from exc + if len(events) >= limit: + break + return events + + +class EBPFBehaviorCollector: + """Adapter for a real eBPF ring-buffer reader. + + The injected reader keeps kernel/BCC/libbpf details outside the orchestration + layer. It should return mappings with the same schema as ``BehaviorEvent``. + """ + + def __init__(self, reader: Callable[[int], Iterable[Mapping[str, object]]]) -> None: + self._reader = reader + + def collect(self, limit: int = 256) -> list[BehaviorEvent]: + return [BehaviorEvent.from_mapping(item) for item in self._reader(limit)] diff --git "a/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/src/adaptive_lsm/controller.py" "b/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/src/adaptive_lsm/controller.py" new file mode 100644 index 0000000000000000000000000000000000000000..26f10142a897fd3114c26c97c665cc666a1058c2 --- /dev/null +++ "b/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/src/adaptive_lsm/controller.py" @@ -0,0 +1,98 @@ +"""Three-layer, closed-loop orchestration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from time import time +from typing import Any + +from .collection import BehaviorCollector +from .drift import DriftDetector +from .enforcement import ( + InMemoryPolicyStore, + PolicyDispatcher, + default_adapters, +) +from .intelligence import SemanticAnalyzer +from .models import DeploymentResult +from .normalization import EventNormalizer +from .policy import LeastPrivilegePolicyGenerator + + +@dataclass(frozen=True) +class ControllerRun: + collected: int + normalized: int + analyzed: int + drift_detected: bool + new_features: int + deployments: tuple[DeploymentResult, ...] + + @property + def activated(self) -> int: + return sum(result.activated for result in self.deployments) + + +class AuditTrail: + """Append-only in-process audit records for framework decisions.""" + + def __init__(self) -> None: + self.records: list[dict[str, Any]] = [] + + def append(self, event: str, **fields: Any) -> None: + self.records.append({"timestamp": time(), "event": event, **fields}) + + +class AdaptiveLSMController: + """Execute perception -> intelligence -> enforcement -> feedback once.""" + + def __init__( + self, + collector: BehaviorCollector, + analyzer: SemanticAnalyzer, + *, + normalizer: EventNormalizer | None = None, + generator: LeastPrivilegePolicyGenerator | None = None, + detector: DriftDetector | None = None, + store: InMemoryPolicyStore | None = None, + dispatcher: PolicyDispatcher | None = None, + audit: AuditTrail | None = None, + ) -> None: + self.collector = collector + self.analyzer = analyzer + self.normalizer = normalizer or EventNormalizer() + self.generator = generator or LeastPrivilegePolicyGenerator() + self.detector = detector or DriftDetector() + self.store = store or InMemoryPolicyStore() + self.dispatcher = dispatcher or PolicyDispatcher(default_adapters(), self.store) + self.audit = audit or AuditTrail() + + def run_once(self, limit: int = 256) -> ControllerRun: + raw_events = self.collector.collect(limit) + normalized = self.normalizer.normalize(raw_events) + drift = self.detector.inspect(normalized) + self.audit.append( + "behavior_collected", + count=len(normalized), + drift=drift.detected, + new_features=len(drift.new_feature_keys), + ) + + findings = self.analyzer.analyze(drift.candidate_events) + candidates = self.generator.generate(findings, self.store.snapshot()) + deployments = tuple(self.dispatcher.deploy(candidate) for candidate in candidates) + self.detector.update(normalized) + self.audit.append( + "cycle_completed", + findings=len(findings), + candidates=len(candidates), + activated=sum(item.activated for item in deployments), + ) + return ControllerRun( + collected=len(raw_events), + normalized=len(normalized), + analyzed=len(drift.candidate_events), + drift_detected=drift.detected, + new_features=len(drift.new_feature_keys), + deployments=deployments, + ) diff --git "a/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/src/adaptive_lsm/drift.py" "b/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/src/adaptive_lsm/drift.py" new file mode 100644 index 0000000000000000000000000000000000000000..05f249ddbf4cd8a71ba8f232f737c9a6fc8c1838 --- /dev/null +++ "b/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/src/adaptive_lsm/drift.py" @@ -0,0 +1,54 @@ +"""Behavior drift feedback for the adaptive loop.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable + +from .models import NormalizedEvent + + +@dataclass(frozen=True) +class DriftReport: + baseline_initialized: bool + new_feature_keys: tuple[str, ...] + denied_event_ids: tuple[str, ...] + candidate_events: tuple[NormalizedEvent, ...] + + @property + def detected(self) -> bool: + return bool(self.candidate_events) + + +class DriftDetector: + """Detect unseen behavior and recurring enforcement denials.""" + + def __init__(self) -> None: + self._baseline: set[str] = set() + self._initialized = False + + def inspect(self, events: Iterable[NormalizedEvent]) -> DriftReport: + event_list = list(events) + new_keys = {event.feature_key for event in event_list if event.feature_key not in self._baseline} + denied = { + event.event_id + for event in event_list + if event.outcome in {"denied", "blocked", "reject"} + } + candidates = tuple( + event + for event in event_list + if not self._initialized + or event.feature_key in new_keys + or event.event_id in denied + ) + return DriftReport( + baseline_initialized=self._initialized, + new_feature_keys=tuple(sorted(new_keys)), + denied_event_ids=tuple(sorted(denied)), + candidate_events=candidates, + ) + + def update(self, events: Iterable[NormalizedEvent]) -> None: + self._baseline.update(event.feature_key for event in events) + self._initialized = True diff --git "a/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/src/adaptive_lsm/enforcement.py" "b/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/src/adaptive_lsm/enforcement.py" new file mode 100644 index 0000000000000000000000000000000000000000..7711df3e7b6c0ef73a9a300834694039bb352884 --- /dev/null +++ "b/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/src/adaptive_lsm/enforcement.py" @@ -0,0 +1,165 @@ +"""Validated staging and atomic activation for dual LSM backends.""" + +from __future__ import annotations + +from typing import Any, Mapping, Protocol + +from .models import ( + DeploymentResult, + LSMKind, + PolicyBundle, + PolicyCandidate, +) + + +class LSMAdapter(Protocol): + kind: LSMKind + + def stage(self, candidate: PolicyCandidate) -> PolicyBundle: + """Compile an intent into a backend-specific, non-active bundle.""" + + def activate(self, bundle: PolicyBundle) -> None: + """Atomically activate a previously staged bundle.""" + + +class _LogicalAdapter: + """Safe reference adapter: records activation but never mutates the kernel.""" + + kind: LSMKind + + def __init__(self) -> None: + self.active_bundles: dict[tuple[str, str], PolicyBundle] = {} + + def stage(self, candidate: PolicyCandidate) -> PolicyBundle: + rules = [ + { + "resource_kind": access.resource_kind, + "resource": access.resource, + "action": access.action, + } + for access in candidate.accesses + ] + payload = { + "mode": "logical-dry-run", + "workload": candidate.workload, + "subject": candidate.subject, + "rules": rules, + } + return PolicyBundle( + candidate_id=candidate.candidate_id, + generation=candidate.generation, + backend=self.kind, + payload=payload, + ) + + def activate(self, bundle: PolicyBundle) -> None: + key = (str(bundle.payload["workload"]), str(bundle.payload["subject"])) + self.active_bundles[key] = bundle + + +class SELinuxAdapter(_LogicalAdapter): + kind = LSMKind.SELINUX + + +class BPFLSMAdapter(_LogicalAdapter): + kind = LSMKind.BPF + + +class PolicySafetyValidator: + """Fail closed before any backend activation.""" + + def __init__(self, minimum_confidence: float = 0.6, max_accesses: int = 256) -> None: + self._minimum_confidence = minimum_confidence + self._max_accesses = max_accesses + + def validate(self, candidate: PolicyCandidate, bundle: PolicyBundle) -> None: + if bundle.backend is not candidate.lsm: + raise ValueError("policy bundle backend does not match candidate LSM") + if candidate.confidence < self._minimum_confidence: + raise ValueError("candidate confidence is below the activation threshold") + if not candidate.accesses: + raise ValueError("empty policy candidates cannot be activated") + if len(candidate.accesses) > self._max_accesses: + raise ValueError("candidate exceeds the per-generation access limit") + for access in candidate.accesses: + if access.resource in {"*", "/", "/**"}: + raise ValueError("wildcard or root-wide resource access is forbidden") + if "*" in access.action or "*" in access.resource: + raise ValueError("wildcard access is forbidden") + if bundle.payload.get("mode") != "logical-dry-run": + raise ValueError("reference framework accepts only dry-run bundles") + + +class InMemoryPolicyStore: + """Versioned active-policy view used by generation and drift feedback.""" + + def __init__(self) -> None: + self._active: dict[tuple[str, str, LSMKind], PolicyCandidate] = {} + + def snapshot(self) -> Mapping[tuple[str, str, LSMKind], PolicyCandidate]: + return dict(self._active) + + def validate_next(self, candidate: PolicyCandidate) -> None: + current = self._active.get(candidate.key) + expected = 1 if current is None else current.generation + 1 + if candidate.generation != expected: + raise ValueError( + f"non-monotonic generation {candidate.generation}; expected {expected}" + ) + + def activate(self, candidate: PolicyCandidate) -> None: + self.validate_next(candidate) + self._active[candidate.key] = candidate + + +class PolicyDispatcher: + """Stage, validate, activate, then publish the new generation.""" + + def __init__( + self, + adapters: Mapping[LSMKind, LSMAdapter], + store: InMemoryPolicyStore, + validator: PolicySafetyValidator | None = None, + ) -> None: + self._adapters = dict(adapters) + self._store = store + self._validator = validator or PolicySafetyValidator() + + def deploy(self, candidate: PolicyCandidate) -> DeploymentResult: + adapter = self._adapters.get(candidate.lsm) + if adapter is None: + return DeploymentResult( + candidate.candidate_id, + candidate.lsm, + candidate.generation, + False, + "no adapter registered for backend", + ) + try: + bundle = adapter.stage(candidate) + self._validator.validate(candidate, bundle) + self._store.validate_next(candidate) + adapter.activate(bundle) + self._store.activate(candidate) + except (KeyError, TypeError, ValueError) as exc: + return DeploymentResult( + candidate.candidate_id, + candidate.lsm, + candidate.generation, + False, + str(exc), + ) + return DeploymentResult( + candidate.candidate_id, + candidate.lsm, + candidate.generation, + True, + "logical bundle activated atomically", + ) + + +def default_adapters() -> Mapping[LSMKind, LSMAdapter]: + return { + LSMKind.SELINUX: SELinuxAdapter(), + LSMKind.BPF: BPFLSMAdapter(), + } diff --git "a/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/src/adaptive_lsm/intelligence.py" "b/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/src/adaptive_lsm/intelligence.py" new file mode 100644 index 0000000000000000000000000000000000000000..052ac709d6600d0e03b6888a234accf213224988 --- /dev/null +++ "b/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/src/adaptive_lsm/intelligence.py" @@ -0,0 +1,204 @@ +"""Semantic-analysis layer with deterministic and LLM-backed implementations.""" + +from __future__ import annotations + +import json +import urllib.request +from collections import defaultdict +from typing import Iterable, Mapping, Protocol + +from .models import Access, AnalysisFinding, LSMKind, NormalizedEvent + + +class SemanticAnalyzer(Protocol): + def analyze(self, events: Iterable[NormalizedEvent]) -> list[AnalysisFinding]: + """Classify observed behavior and explain the decision.""" + + +class HeuristicAnalyzer: + """Offline analyzer used by the runnable demo. + + It demonstrates the control flow, not a production authorization decision. + """ + + def analyze(self, events: Iterable[NormalizedEvent]) -> list[AnalysisFinding]: + groups: dict[tuple[str, str, LSMKind], list[NormalizedEvent]] = defaultdict(list) + for event in events: + groups[(event.workload, event.subject, event.lsm)].append(event) + + findings: list[AnalysisFinding] = [] + for (workload, subject, lsm), grouped in sorted( + groups.items(), key=lambda item: tuple(str(part) for part in item[0]) + ): + accesses = tuple( + sorted( + { + Access(item.resource_kind, item.resource, item.action) + for item in grouped + } + ) + ) + findings.append( + AnalysisFinding( + workload=workload, + subject=subject, + lsm=lsm, + event_ids=tuple(sorted(item.event_id for item in grouped)), + accesses=accesses, + legitimate=True, + confidence=0.75, + reason="offline demo: behavior was observed and retained at exact-resource granularity", + ) + ) + return findings + + +class LLMClient(Protocol): + def complete(self, system_prompt: str, user_prompt: str) -> str: + """Return a JSON-only model response.""" + + +class OpenAICompatibleClient: + """Small dependency-free client for an OpenAI-compatible chat endpoint.""" + + def __init__( + self, + endpoint: str, + api_key: str, + model: str, + timeout_seconds: float = 30.0, + ) -> None: + self._endpoint = endpoint + self._api_key = api_key + self._model = model + self._timeout_seconds = timeout_seconds + + def complete(self, system_prompt: str, user_prompt: str) -> str: + body = json.dumps( + { + "model": self._model, + "temperature": 0, + "response_format": {"type": "json_object"}, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + } + ).encode("utf-8") + request = urllib.request.Request( + self._endpoint, + data=body, + method="POST", + headers={ + "Authorization": f"Bearer {self._api_key}", + "Content-Type": "application/json", + }, + ) + with urllib.request.urlopen(request, timeout=self._timeout_seconds) as response: + payload = json.loads(response.read().decode("utf-8")) + return str(payload["choices"][0]["message"]["content"]) + + +class LLMSemanticAnalyzer: + """Use an LLM for intent classification without letting it invent access. + + The model may only accept or reject event IDs. Access tuples are reconstructed + from trusted normalized events after the response is validated. + """ + + _SYSTEM_PROMPT = """You are a Linux security behavior classifier. +Return a JSON object with a 'decisions' array. Each item must contain: +event_ids (array of supplied IDs), legitimate (boolean), confidence (0..1), +and reason (short string). Never create IDs or permissions. Group only events +that have the same workload, subject and LSM backend.""" + + def __init__(self, client: LLMClient, minimum_confidence: float = 0.6) -> None: + self._client = client + self._minimum_confidence = minimum_confidence + + def analyze(self, events: Iterable[NormalizedEvent]) -> list[AnalysisFinding]: + event_list = list(events) + if not event_list: + return [] + trusted = {event.event_id: event for event in event_list} + prompt_events = [ + { + "event_id": event.event_id, + "workload": event.workload, + "subject": event.subject, + "lsm": event.lsm.value, + "resource_kind": event.resource_kind, + "resource": event.resource, + "action": event.action, + "outcome": event.outcome, + "attributes": dict(event.attributes), + } + for event in event_list + ] + raw = self._client.complete( + self._SYSTEM_PROMPT, + json.dumps({"events": prompt_events}, ensure_ascii=False), + ) + try: + response = json.loads(raw) + decisions = response["decisions"] + except (json.JSONDecodeError, KeyError, TypeError) as exc: + raise ValueError("LLM returned an invalid decision document") from exc + if not isinstance(decisions, list): + raise ValueError("LLM decisions must be an array") + + findings: list[AnalysisFinding] = [] + seen: set[str] = set() + for decision in decisions: + if not isinstance(decision, Mapping): + raise ValueError("each LLM decision must be an object") + findings.append(self._validated_finding(decision, trusted, seen)) + return findings + + def _validated_finding( + self, + decision: Mapping[str, object], + trusted: Mapping[str, NormalizedEvent], + seen: set[str], + ) -> AnalysisFinding: + raw_ids = decision.get("event_ids") + if not isinstance(raw_ids, list) or not raw_ids: + raise ValueError("each LLM decision must reference at least one event") + event_ids = tuple(str(value) for value in raw_ids) + if len(set(event_ids)) != len(event_ids): + raise ValueError("LLM decision contains duplicate event IDs") + unknown = set(event_ids).difference(trusted) + if unknown: + raise ValueError(f"LLM referenced unknown event IDs: {sorted(unknown)}") + duplicate = set(event_ids).intersection(seen) + if duplicate: + raise ValueError(f"LLM classified event IDs more than once: {sorted(duplicate)}") + seen.update(event_ids) + + selected = [trusted[event_id] for event_id in event_ids] + keys = {(item.workload, item.subject, item.lsm) for item in selected} + if len(keys) != 1: + raise ValueError("LLM grouped events from different security subjects") + workload, subject, lsm = keys.pop() + confidence = float(decision.get("confidence", 0.0)) + if not 0.0 <= confidence <= 1.0: + raise ValueError("LLM confidence must be between 0 and 1") + legitimate = decision.get("legitimate") is True and confidence >= self._minimum_confidence + accesses = tuple( + sorted( + { + Access(item.resource_kind, item.resource, item.action) + for item in selected + } + ) + ) + return AnalysisFinding( + workload=workload, + subject=subject, + lsm=lsm, + event_ids=tuple(sorted(event_ids)), + accesses=accesses, + legitimate=legitimate, + confidence=confidence, + reason=str(decision.get("reason", ""))[:500], + ) diff --git "a/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/src/adaptive_lsm/models.py" "b/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/src/adaptive_lsm/models.py" new file mode 100644 index 0000000000000000000000000000000000000000..8d29323a91676eaba11f6c8c02674ba8687b2a06 --- /dev/null +++ "b/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/src/adaptive_lsm/models.py" @@ -0,0 +1,154 @@ +"""Shared, backend-neutral domain models.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from hashlib import sha256 +from typing import Any, Mapping + + +class LSMKind(str, Enum): + """LSM backends supported by the logical framework.""" + + SELINUX = "selinux" + BPF = "bpf_lsm" + + +@dataclass(frozen=True) +class BehaviorEvent: + """Raw event emitted by an eBPF-facing collector.""" + + timestamp: float + host: str + workload: str + subject: str + resource_kind: str + resource: str + action: str + lsm: LSMKind + outcome: str = "observed" + attributes: Mapping[str, Any] = field(default_factory=dict) + + @classmethod + def from_mapping(cls, data: Mapping[str, Any]) -> "BehaviorEvent": + required = ( + "timestamp", + "host", + "workload", + "subject", + "resource_kind", + "resource", + "action", + "lsm", + ) + missing = [name for name in required if name not in data] + if missing: + raise ValueError(f"behavior event is missing fields: {', '.join(missing)}") + + try: + lsm = LSMKind(str(data["lsm"]).lower()) + except ValueError as exc: + supported = ", ".join(item.value for item in LSMKind) + raise ValueError(f"unsupported LSM {data['lsm']!r}; expected {supported}") from exc + + attributes = data.get("attributes", {}) + if not isinstance(attributes, Mapping): + raise ValueError("event attributes must be an object") + + return cls( + timestamp=float(data["timestamp"]), + host=str(data["host"]), + workload=str(data["workload"]), + subject=str(data["subject"]), + resource_kind=str(data["resource_kind"]), + resource=str(data["resource"]), + action=str(data["action"]), + lsm=lsm, + outcome=str(data.get("outcome", "observed")), + attributes=dict(attributes), + ) + + +@dataclass(frozen=True) +class NormalizedEvent: + """Canonical behavior feature used by analysis and drift detection.""" + + event_id: str + timestamp: float + host: str + workload: str + subject: str + resource_kind: str + resource: str + action: str + lsm: LSMKind + outcome: str + attributes: Mapping[str, str] + feature_key: str + + @staticmethod + def make_id(*parts: object) -> str: + encoded = "\x1f".join(str(part) for part in parts).encode("utf-8") + return sha256(encoded).hexdigest()[:20] + + +@dataclass(frozen=True, order=True) +class Access: + """One exact backend-neutral access tuple.""" + + resource_kind: str + resource: str + action: str + + +@dataclass(frozen=True) +class AnalysisFinding: + """A semantic decision tied only to observed source events.""" + + workload: str + subject: str + lsm: LSMKind + event_ids: tuple[str, ...] + accesses: tuple[Access, ...] + legitimate: bool + confidence: float + reason: str + + +@dataclass(frozen=True) +class PolicyCandidate: + """Versioned policy intent; it is deliberately not kernel policy syntax.""" + + candidate_id: str + generation: int + workload: str + subject: str + lsm: LSMKind + accesses: tuple[Access, ...] + source_event_ids: tuple[str, ...] + confidence: float + rationale: str + + @property + def key(self) -> tuple[str, str, LSMKind]: + return self.workload, self.subject, self.lsm + + +@dataclass(frozen=True) +class PolicyBundle: + """A staged payload understood by one logical LSM adapter.""" + + candidate_id: str + generation: int + backend: LSMKind + payload: Mapping[str, Any] + + +@dataclass(frozen=True) +class DeploymentResult: + candidate_id: str + backend: LSMKind + generation: int + activated: bool + message: str diff --git "a/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/src/adaptive_lsm/normalization.py" "b/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/src/adaptive_lsm/normalization.py" new file mode 100644 index 0000000000000000000000000000000000000000..0aa3013f595c7e1a0efe6624346d10f92a0a5f4d --- /dev/null +++ "b/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/src/adaptive_lsm/normalization.py" @@ -0,0 +1,82 @@ +"""Feature normalization for events coming from heterogeneous probes.""" + +from __future__ import annotations + +import posixpath +import re +from typing import Iterable + +from .models import BehaviorEvent, NormalizedEvent + + +_SAFE_TOKEN = re.compile(r"[^a-z0-9_.:@/-]+") + + +class EventNormalizer: + """Create stable features without discarding the original security scope.""" + + def normalize(self, events: Iterable[BehaviorEvent]) -> list[NormalizedEvent]: + return [self._normalize_one(event) for event in events] + + def _normalize_one(self, event: BehaviorEvent) -> NormalizedEvent: + if event.timestamp < 0: + raise ValueError("event timestamp must be non-negative") + + host = self._token(event.host) + workload = self._token(event.workload) + subject = self._token(event.subject) + resource_kind = self._token(event.resource_kind) + action = self._token(event.action) + outcome = self._token(event.outcome) + resource = self._resource(event.resource, resource_kind) + if not all((host, workload, subject, resource_kind, resource, action)): + raise ValueError("normalized event fields must not be empty") + + attributes = { + self._token(str(key)): self._token(str(value)) + for key, value in sorted(event.attributes.items(), key=lambda item: str(item[0])) + } + feature_key = NormalizedEvent.make_id( + workload, + subject, + resource_kind, + resource, + action, + event.lsm.value, + ) + event_id = NormalizedEvent.make_id( + event.timestamp, + host, + feature_key, + outcome, + tuple(attributes.items()), + ) + return NormalizedEvent( + event_id=event_id, + timestamp=event.timestamp, + host=host, + workload=workload, + subject=subject, + resource_kind=resource_kind, + resource=resource, + action=action, + lsm=event.lsm, + outcome=outcome, + attributes=attributes, + feature_key=feature_key, + ) + + @staticmethod + def _token(value: str) -> str: + return _SAFE_TOKEN.sub("_", value.strip().lower()) + + @staticmethod + def _resource(value: str, resource_kind: str) -> str: + value = value.strip() + # Keep policy metacharacters visible so the downstream safety validator + # can fail closed instead of accidentally turning them into benign text. + if "*" in value: + return value.lower() + if resource_kind in {"file", "dir", "socket"} and value.startswith("/"): + return posixpath.normpath(value) + return _SAFE_TOKEN.sub("_", value.lower()) diff --git "a/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/src/adaptive_lsm/policy.py" "b/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/src/adaptive_lsm/policy.py" new file mode 100644 index 0000000000000000000000000000000000000000..2fae7373097bcace75093a16b74e672550c266b9 --- /dev/null +++ "b/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/src/adaptive_lsm/policy.py" @@ -0,0 +1,74 @@ +"""Least-privilege policy-intent generation.""" + +from __future__ import annotations + +from collections import defaultdict +from typing import Iterable, Mapping + +from .models import Access, AnalysisFinding, LSMKind, PolicyCandidate + + +class LeastPrivilegePolicyGenerator: + """Merge only exact, model-approved observed accesses into a new generation.""" + + def generate( + self, + findings: Iterable[AnalysisFinding], + active: Mapping[tuple[str, str, LSMKind], PolicyCandidate], + ) -> list[PolicyCandidate]: + grouped: dict[tuple[str, str, LSMKind], list[AnalysisFinding]] = defaultdict(list) + for finding in findings: + if finding.legitimate: + grouped[(finding.workload, finding.subject, finding.lsm)].append(finding) + + candidates: list[PolicyCandidate] = [] + for key, group in sorted( + grouped.items(), key=lambda item: tuple(str(part) for part in item[0]) + ): + current = active.get(key) + observed: set[Access] = set() + source_ids: set[str] = set() + for finding in group: + observed.update(finding.accesses) + source_ids.update(finding.event_ids) + if current: + observed.update(current.accesses) + accesses = tuple(sorted(observed)) + if current and accesses == current.accesses: + continue + + generation = 1 if current is None else current.generation + 1 + workload, subject, lsm = key + candidate_id = PolicyCandidateId.create(key, generation, accesses) + candidates.append( + PolicyCandidate( + candidate_id=candidate_id, + generation=generation, + workload=workload, + subject=subject, + lsm=lsm, + accesses=accesses, + source_event_ids=tuple(sorted(source_ids)), + confidence=min(item.confidence for item in group), + rationale="; ".join(item.reason for item in group if item.reason)[:1000], + ) + ) + return candidates + + +class PolicyCandidateId: + @staticmethod + def create( + key: tuple[str, str, LSMKind], + generation: int, + accesses: tuple[Access, ...], + ) -> str: + from .models import NormalizedEvent + + return NormalizedEvent.make_id( + key[0], + key[1], + key[2].value, + generation, + tuple((item.resource_kind, item.resource, item.action) for item in accesses), + ) diff --git "a/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/tests/test_framework.py" "b/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/tests/test_framework.py" new file mode 100644 index 0000000000000000000000000000000000000000..d78a56d03bc79a89d4f3c77c9315b8bd6043b71a --- /dev/null +++ "b/2026\345\256\236\346\210\230\344\273\273\345\212\241_\344\275\234\345\223\201\346\226\207\344\273\266\345\244\271/\345\237\272\344\272\216LLM\347\224\237\346\210\220\350\207\252\351\200\202\345\272\224LSM\345\256\211\345\205\250\347\255\226\347\225\245/ruirui6946_\344\275\234\345\223\201/adaptive-lsm/tests/test_framework.py" @@ -0,0 +1,157 @@ +from __future__ import annotations + +import json +import unittest + +from adaptive_lsm.collection import QueueCollector +from adaptive_lsm.controller import AdaptiveLSMController +from adaptive_lsm.enforcement import ( + BPFLSMAdapter, + InMemoryPolicyStore, + PolicyDispatcher, + SELinuxAdapter, +) +from adaptive_lsm.intelligence import HeuristicAnalyzer, LLMSemanticAnalyzer +from adaptive_lsm.models import BehaviorEvent, LSMKind +from adaptive_lsm.normalization import EventNormalizer + + +def event( + *, + resource: str, + action: str = "read", + lsm: LSMKind = LSMKind.SELINUX, + timestamp: float = 1.0, + outcome: str = "allowed", +) -> BehaviorEvent: + return BehaviorEvent( + timestamp=timestamp, + host="Node-A", + workload="Inference API", + subject="api_t", + resource_kind="file" if resource.startswith("/") else "tcp", + resource=resource, + action=action, + lsm=lsm, + outcome=outcome, + ) + + +class FakeLLMClient: + def __init__(self, response_factory): + self.response_factory = response_factory + + def complete(self, system_prompt: str, user_prompt: str) -> str: + return self.response_factory(json.loads(user_prompt)) + + +class NormalizationTests(unittest.TestCase): + def test_paths_and_tokens_are_canonical(self) -> None: + normalized = EventNormalizer().normalize( + [event(resource="/etc/inference/../inference/model.yaml")] + )[0] + self.assertEqual(normalized.workload, "inference_api") + self.assertEqual(normalized.resource, "/etc/inference/model.yaml") + self.assertEqual(len(normalized.feature_key), 20) + + +class LLMValidationTests(unittest.TestCase): + def test_llm_cannot_invent_event_ids(self) -> None: + client = FakeLLMClient( + lambda _: json.dumps( + { + "decisions": [ + { + "event_ids": ["invented"], + "legitimate": True, + "confidence": 0.99, + "reason": "trust me", + } + ] + } + ) + ) + analyzer = LLMSemanticAnalyzer(client) + normalized = EventNormalizer().normalize([event(resource="/etc/model.yaml")]) + with self.assertRaisesRegex(ValueError, "unknown event IDs"): + analyzer.analyze(normalized) + + def test_llm_decision_is_rebuilt_from_observed_access(self) -> None: + def response(payload): + event_id = payload["events"][0]["event_id"] + return json.dumps( + { + "decisions": [ + { + "event_ids": [event_id], + "legitimate": True, + "confidence": 0.9, + "reason": "expected model configuration read", + } + ] + } + ) + + normalized = EventNormalizer().normalize([event(resource="/etc/model.yaml")]) + finding = LLMSemanticAnalyzer(FakeLLMClient(response)).analyze(normalized)[0] + self.assertTrue(finding.legitimate) + self.assertEqual(finding.accesses[0].resource, "/etc/model.yaml") + + +class ClosedLoopTests(unittest.TestCase): + def test_dual_lsm_activation_and_drift_feedback(self) -> None: + initial = [ + event(resource="/etc/model.yaml", lsm=LSMKind.SELINUX), + event(resource="model-service:9000", lsm=LSMKind.BPF), + ] + repeated = [ + event(resource="/etc/model.yaml", lsm=LSMKind.SELINUX, timestamp=2), + event(resource="model-service:9000", lsm=LSMKind.BPF, timestamp=2), + ] + drift = [ + event( + resource="/var/cache/index.db", + action="write", + lsm=LSMKind.SELINUX, + timestamp=3, + outcome="denied", + ) + ] + controller = AdaptiveLSMController( + QueueCollector([initial, repeated, drift]), HeuristicAnalyzer() + ) + + first = controller.run_once() + second = controller.run_once() + third = controller.run_once() + + self.assertEqual(first.activated, 2) + self.assertFalse(second.drift_detected) + self.assertEqual(second.analyzed, 0) + self.assertTrue(third.drift_detected) + self.assertEqual(third.activated, 1) + active = controller.store.snapshot() + selinux = active[("inference_api", "api_t", LSMKind.SELINUX)] + self.assertEqual(selinux.generation, 2) + self.assertEqual(len(selinux.accesses), 2) + + def test_wildcard_candidate_fails_closed(self) -> None: + store = InMemoryPolicyStore() + adapters = { + LSMKind.SELINUX: SELinuxAdapter(), + LSMKind.BPF: BPFLSMAdapter(), + } + controller = AdaptiveLSMController( + QueueCollector([[event(resource="*")]]), + HeuristicAnalyzer(), + store=store, + dispatcher=PolicyDispatcher(adapters, store), + ) + run = controller.run_once() + self.assertEqual(run.activated, 0) + self.assertIn("wildcard", run.deployments[0].message) + self.assertEqual(store.snapshot(), {}) + + +if __name__ == "__main__": + unittest.main()