sentinel-harness · v0.5.1

会构建 agent 的 agent
—— 而且安全

LLM 起草,确定性代码裁决。没有一样东西能在目击到通过 + 人工批准之前被推上生产。

2365

离线测试全绿 · 90% 覆盖率

100

对抗审计缺陷已修复

78.6s

真实 AWS 上一次 CVE 分诊,干净拆除

Bedrock AgentCore Harness enforcement outside the model GA 两平面 API
1 / 55
Chapter 01

第一章 · 三个安静的背叛

自主的 SecOps agent 会用三种方式辜负你 —— 而这三种都不抛异常

背叛 · 一

混淆代理

自我改进的 agent 靠"自己一句话"就把自己推上生产。没有崩溃,只有一个更差的 agent 开始接真实 SOC 流量。

背叛 · 二

检测无编译器

LLM 写出一条读起来完美的 Sigma 规则,引用了一个从未定义的 selection。谁来拒绝它?写它的 LLM 有自我批准偏见。

背叛 · 三

舰队静默毁灭

一个未设置的环境变量,让清理脚本匹配并删除每一个 harness。分页丢失让资源变成孤儿。

共同点在最后一页揭晓 —— 它是贯穿全篇的脊梁。

2 / 55
问题 #1 · The Confused Deputy

决定推它上线的 agent,正是被推的那个 agent

设想 north-star 循环正常运转:agent 给候选打分、判定通过、推上生产端点。现在设想它只是了句"评测通过,正在推送"—— 推送就真的执行了。或者更微妙:它诚实地给 harness A 打分,却推送了 harness B。

自我改进 agent "评测通过,推送" 生产端点 更差 / 不安全的 agent 凭一句话 给 A 打分 → 推送 B 没有崩溃 · 没有异常 · 审计日志一片绿
# agent_loop.py:4-12 —— 缺口被精确命名 # M12/M13 里,improve→score→gate→promote # 这些"决策"是 Python 作者写的: # "a human wrote the loop", 不是 agent 跑循环。 # 一旦让 agent 靠发 tool call 来"作者化"循环, # 就继承了 deputy 问题:自我报告即自利。 # agent_loop.py:23-26 # "promote only when passing and approved" # —— 但 a prompt is advice, not enforcement. # The driver is the enforcement point.

为什么 prompt 拦不住?因为 prompt 是模型能读、能被绕过、能自我说服的东西。执法点必须在模型够不到的地方。

3 / 55
问题 #2 · 检测工程没有编译器

谁来拒绝那条读起来完美的坏规则?

一条 Sigma 规则,condition 里引用了名叫 suspicious_cmd 的 selection —— 一个它从未定义的 selection。上线后 SIEM 静默编译出一条匹配一切、或什么都不匹配的规则。写它的 LLM 是你最不该让它来打分的东西。

# sigma_yara_lint/handler.py:10-14 # "an LLM may draft a rule, but this linter # — not another LLM — decides whether the # rule is structurally valid." # handler.py:257-264 —— 抓住那类静默失败: def _check_condition_refs(cond, selections): # condition 里每个标识符都必须引用 # 一个已定义的 selection —— 否则是 # 硬错误 (hard error),不是 warning

纯 Python,零 token,零网络。同一输入永远同一输出 —— 才配当强制自动闸门。

真实审计一跑
48 / 100

一次真实审计运行里,8 条无效规则被评了 48 分 —— 如果没有确定性编译器把关,它们本会一路流向生产。

两个模型伪造不了的东西

① 一个独立对抗 reviewer harness去攻击规则(无自我批准偏见);② 一个不是 LLM 的编译器。generation ≠ evaluation,写进结构,不写进 prompt。

4 / 55
问题 #3 · 舰队操作静默失败

在舰队里,危险的失败是毁灭之前不出声的那种

一条 manifest 跨 test / staging / prod 铺开一支安全 agent 舰队。一个 YAML 里的 tag 值被解析成整数 42 而非字符串 —— 本地毫无报错,然后第五个 CreateHarness 中途 ParamValidationError,留给你一支半成品舰队和无处回滚。

# factory.py:298-304 —— 空前缀清理守卫 if not prefix: raise FactoryError( "refusing an empty teardown prefix" " — it would match and delete" " EVERY harness") # 一个未设置的 $PREFIX 会让 ''.startswith('') # 匹配到每一个 harness —— 灾难性的删库
# factory.py:217-236 —— 守卫的守卫 # ListHarnesses 的 summary 不携带 tags; # 早期版本读 summary['tags'] == None, # 静默杀死整个跨环境保护。 # 修法:走 ListTagsForResource 真取 tag, # 读取失败 fail-safe 成 None(当未打标处理)

跨环境误删

共享同名的 staging 拆除,静默删掉 prod agent。每个 harness 都被打上 sentinel:env tag;同名但不同 env 的 harness,provision 和 teardown 都拒绝触碰。

分页丢失 → 孤儿资源

list_harnesses 若不翻完全部分页,就会漏看已存在的 harness,重复创建、留下无人清理的孤儿。

未打标 = 硬拒绝

teardown 遇到没有 sentinel:env tag 的 harness 直接拒删 —— 它可能是打标之前就存在的 prod 资源。teardown 刻意比 provision 更谨慎。

5 / 55
共同的线索

把执法点,移到 agent 够不到的地方

三个背叛看似无关,其实是同一个病。修法也是同一条脊梁:enforcement outside the model。

混淆代理

推送只在 driver 亲自目击过通过闸门的评测 + 人工批准时才执行;分数从 handler 的真实返回读,永不从 agent 的话里读。

检测无编译器

结构合法性由纯 Python linter裁决,不是另一个 LLM;对抗 reviewer 通过 tool call 提交裁决,不靠自由文本。

舰队毁灭

可检查的失败全部左移进零 AWS 调用的 dry-run;删除由 tag 守卫和空前缀守卫在代码里挡住。

a prompt is advice, not enforcement. 这句话(README.md:206-208)是全篇的脊梁 —— 下一章,我们看它如何变成架构。

6 / 55
第二章

Agent 即配置

不是编排代码 — 而是一份能进 code review、能 diff、能 CI 的声明式规格

这一章我们把地基讲透:为什么"让 AWS 跑循环"是把安全控制点移出模型可触及范围的唯一办法,以及 Bedrock AgentCore 用两个平面、一个托管循环、一台 per-session 微虚拟机把这件事变成现实。

7 / 55
p8 · 核心命题

"Agent 即配置"到底指什么

README.md:32 — "a production SecOps agent should be configuration, not orchestration code."

你声明的 · harness.yaml

# 一屏放得下,PR 里干净地 diff harnessName: cve_triage model: global.anthropic.claude-sonnet-4-6 systemPrompt: ./prompt.md # 路径受目录约束 allowedTools: # 显式白名单,禁 '*' - code_interpreter - nvd_lookup - request_human_review # HITL 门自动注入 memory: { strategies: [SEMANTIC], expiry_days: 90 } maxIterations: 15

loader.py 纯离线把它翻译成 create_harness kwargs,零 AWS 调用即可在 CI 里校验。

你不再写的 · 编排循环

# LangGraph 风格:这一切都归你维护 while not done: resp = model.invoke(messages) for call in resp.tool_calls: out = dispatch(call) # 手写 dispatch messages.append(out) # 手写状态 check_iter_cap(); check_tokens() handle_throttle(); checkpoint()

每一行都是引 bug 的地方,且没一行是你真正的安全逻辑。更糟:谁能 promote、什么能发布,全藏在模型能影响的 glue code 里。

旗舰 CVE 分诊 agent 只有约 30 行配置(scenario_cve_triage.py:49-58):create_harness + wait_ready + return arn,零循环代码。行为变更 = 改配置 + UpdateHarness,永远不改代码。

8 / 55
p9 · 架构

Bedrock AgentCore Harness — 两个平面,一个托管循环

core.py:50-51 顶部有两个 boto3 client,它们不可互换:一个造/改 agent,一个 stream 它的思考。

你的代码 loader / scenario Control Plane bedrock-agentcore-control CreateHarness · UpdateHarness · wait_ready read_timeout=60 · retries=3 · 快失败多重试 Data Plane (streaming) bedrock-agentcore InvokeHarness → _consume_stream read_timeout=180 · retries=2 · 长读少重试 Firecracker microVM / session AWS 跑 ReAct 循环 工具沙箱 · 隔离

docs/ARCHITECTURE.md:15 — 两个 workload 形状相反:控制面是短事务生命周期,数据面是长达 78.6s 的 streaming LLM turn。一台 microVM per runtimeSessionId(ARCHITECTURE.md:45):会话隔离、工具沙箱、循环执行全是 AWS 的问题,不是你的 —— 就像你宁愿跑 Lambda 而不是管 EC2。

9 / 55
p10 · 核心命题

AWS 负责什么 vs 你声明什么

这条分界线,就是整个价值主张。

你声明(一屏规格)

  • model — 跨区推理 id,带完整版本后缀
  • systemPrompt — agent 的职责与红线
  • allowedTools — 显式白名单,'*' 被禁
  • memory — 语义/摘要策略 + 过期天数
  • limits — maxIterations / maxTokens / timeout

每个可选字段仅在真值时才加入 args(core.py:125-132)—— wrapper 从不发 null,"agent 形状"就是你声明的样子。

AWS 托管(你不碰)

  • ReAct 循环迭代与工具调度
  • 会话状态与消息历史 bookkeeping
  • token 计费与限流退避
  • streaming 事件重组(contentBlock…)
  • microVM 生命周期与 teardown

create_harness 里刻意没有循环代码;返回的 harness id 就是一个 server-side agent。要跑它,poll wait_ready 到 READY,再走数据面 invoke。

READYwait_ready 轮询 get_harness / 8s
360s默认超时,CREATE_FAILED 即 raise
full-replaceUpdateHarness 是全量读改写,非 patch
10 / 55
p11 · 核心命题

LLM 起草,确定性代码裁决

直播 demo 里最重要的数字不是 78 秒延迟 —— 而是 CVSS 计算和 CVE 事实没有一个来自模型的想象

系统提示的硬约束

# scenario_cve_triage.py:26-30 use the code interpreter to do any deterministic math ... never guess numbers

证据里 did_deterministic_calc=true(evidence/README.md:21)。版本区间检查、受影响资产比例、分数解析、Sigma/YARA lint —— 全在纯 Python 里跑(core.py:487-489)。

为什么这让 CI + 审计成为可能

  • 零 token — 纯 Python 工具不烧模型账单
  • 零 egress — nvd_lookup 默认离线(NVD_LIVE=1 才联网),无网络无密钥
  • 零密钥 — 无外部依赖,整套工具在 CI 里可单测

reviewer 读 handler.py 就能确切知道它做什么 —— 循环里没有模型来引入非确定性。

L3 地基被 read-only 实盘验证为 default-deny VPC 孤岛:无 IGW、无 NAT、无 0.0.0.0/0 路由(evidence/live_verify_result.json)—— egress 在结构上不可能,而非仅仅策略禁止。替代方案(工具本身是 LLM 调用,或自由访问互联网)会让每个测试 flaky、每次运行昂贵、每次审计变成概率论辩论。sentinel-harness 拒绝这条路:边缘确定性,LLM 只在中间。

11 / 55
p12 · 架构

三个 SecOps 层,映射到 AgentCore 原语

docs/ARCHITECTURE.md:3 — 整个框架是一次逆向工程:把三层 SecOps 平台,纯粹用 Harness 原语表达。层次编码了信任与爆炸半径,从最安全到最危险。

L1 Strategy 日常循环 · 配置型 harness 甜区 research → supervisor + specialist harness(~2.3-2.6x 并行加速) detection → generator → 独立 adversarial-reviewer → lint → inline 发布门 triage → alert-triage harness + human gate · feedback → Memory facts/{actor} L2 Simulation 最危险 · 长运行 Runtime 容器 BAS / 对手模拟 → 长运行 Runtime(超出 harness timeout) Play Mode → 每个进攻步骤挂一个 inline_function 门 DESIGNED + 部分构建 —— 不作为全实盘呈现 L3 Foundation 地基 · 纯 Python · 零 AWS 调用 sandbox → microVM + PreToolUse hooks(路径/命令 allowlist) AI-coding → LiteLLM + Gateway(语义工具搜索,单一 MCP 入口) cyber-skills → S3 SKILL.md + 中央 registry 双门

它们自底向上组合:L3 决定工具面(哪些工具 live),L1/L2 消费它。你无法在没有地基(L3)时治理进攻(L2),也无法在不知道哪些工具被批准(L3)时跑策略(L1)。替代方案 —— 一个全工具扁平 agent —— 在"查个 CVE"和"引爆攻击路径"之间没有职责分离,没有门。

12 / 55
p13 · 核心命题

重新定义 multi-agent

docs/ARCHITECTURE.md:18 — "multi-agent = 多个 harness + 一个 supervisor"。一个 harness 按设计是 single-agent + multi-tool。

并行来自组合,不是单体

并行/角色分解,来自并发跑多个 harness,再用一个 supervisor harness 综合。detection-eng 里 generator harness 和 adversarial-reviewer harness 是两个独立 harness —— 生成≠评估,没有自我批准偏差。这个双 harness 拆分是抗幻觉机制,不是装饰。

身份与密钥(README.md:44)

  • 人/服务 经 OAuth-JWT 或 SigV4 认证
  • Per-user 身份 传播到下游工具需走 JWT inbound,不是 SigV4
  • execution role 按最小权限 scope 内部 AWS 访问(非 per-person)
  • 第三方密钥 存在 AgentCore Identity token vault,${arn:...} 头插值,agent 永远看不到原始凭证

把这三件事分清楚,就避开了一个真实的认证模型错误。inline_function 门(core.py:507)PAUSE 循环(stop_reason=tool_use)并把调用交回你的代码 —— human-in-the-loop 通过两个机制杀幻觉:独立 reviewer harness + 需分析师签字的 inline 门。这套地基铺完,后面所有 SecOps 能力才能站上来。

13 / 55
第三章 · CROWN JEWEL

谁握着笔?

前两章的循环,都是人写的 Python 在做决定:改一版、打个分、过个闸、上线。这一章我们把笔交给 agent 自己——让它通过发出 tool call 来驱动整个循环。问题随之而来:当"决定要不要上线"的那个 agent,恰好就是"要被上线"的那个 agent,它说的每一句话都是自利的。一个足够聪明或足够坏的 agent,能不能只凭一句"评估通过了,上线吧"就把自己推上生产?这一章讲的,就是我们怎么让它"说了不算"。

见证门信任模型 · Witness-Gate Trust Model · agent_loop.py

14 / 55
两种循环 · 谁在做决定

从"人写循环"到"agent 写循环",一次危险的反转

同样是 改进→打分→过闸→上线,笔在谁手里,安全模型是完全不同的两件事。

M12 / M13

Runner 编排

autonomy.run_improvement_loop:循环本身是人写死的 Python。哪一步打分、什么时候过闸、要不要上线,全部由脚本或确定性控制器决定。agent 只是被调用去"生成候选"的那只手——它碰不到方向盘。

北极星 · M14

Agent 自写循环

agent_loop.run_agent_loop:agent 在自己的 harness 里通过 stop_reason == "tool_use" 不断发出 tool call,循环的每一步都由它自己驱动。driver 只负责"执行与看守"——派发、恢复会话、按策略放行或拒绝。

这次反转的代价,就是经典的 confused deputy(被搞糊涂的代理人):做上线决定的 agent,同时也是被上线的对象。它的任何自我汇报都是利益相关方的证词。agent_loop.py:4-12 把这句话说得很直白——"the agent authors the loop … the driver here only executes and guards"。

# agent_loop.py:4-12 —— 模块开篇就点明这次反转 # in scenario_self_improve_loop (M12) and the C1 controller (M13.7) the # improve→score→gate→promote DECISIONS were authored by Python — a runner # script or the deterministic controller. This module inverts that: # the **agent authors the loop** by emitting tool calls … and the driver # here only **executes and guards** … so a confused or adversarial agent # can never promote by assertion.
15 / 55
见证门模型 · 皇冠明珠

状态只由 handler 的返回写入,永不由 agent 的话写入

整个信任模型可以压缩成一句话:driver 内部维护三个见证变量——witnessed_pass / witnessed_approval / witnessed_subject,它们只能被 tool-handler 的返回 dict 更新,绝不接受 agent 说的任何一个字

Agent 发出 tool_use Driver 执行 · 看守 派发 · 恢复会话 Handlers 确定性 · 返回 dict 见证账本 Witness Ledger pass · approval · subject 只有 handler 返回可写 agent 的话写不进账本
# agent_loop.py:28-35 —— 信任模型为什么在 driver 而不在 prompt # - Promotion is witness-gated. A promotion tool call is executed ONLY if # the driver itself has WITNESSED, in this session, (1) an evaluation # that clears autonomy.evaluate_gate and (2) a human approval. # - The eval score is read from the HANDLER's return, never the agent's # words. The agent cannot claim a score.
16 / 55
四段式拒绝检查

一个空的 missing[] 才放行,任何一条都足以拒绝

当 agent 发出一次上线调用,driver 把四种"不该放行"的理由逐条收进 missing[]:没见证到通过、没见证到人类批准、通过了却没绑定主体、主体对不上。只有 missing 为空,代码才 fall through 去真正执行。

# agent_loop.py:386-401 —— 四段式检查,逐条累加拒绝理由 missing = [] if not witnessed_pass: missing.append("no witnessed passing evaluation") if not witnessed_approval: missing.append("no witnessed human approval") if witnessed_pass and witnessed_subject is None: # FAIL-CLOSED: 通过了却没说打的是谁,什么也没见证 missing.append("eval did not identify its subject") if witnessed_subject is not None: if promo_subject is None: missing.append("promotion did not identify its subject (no harness_id)") elif promo_subject != witnessed_subject: missing.append("subject mismatch: witnessed eval scored ...")

离线证据里最朴素的一条 promotion_refused:agent 一上来就调 harness_ops create_endpoint,之前根本没跑过评估。driver 直接拒。

false

promoted

没有任何东西被推上线

1

refused_promotions

tool_calls_used 也是 1,一步就拦下

×2

refusal_reasons

"no witnessed passing evaluation; no witnessed human approval"

证据:evidence/agent_authored_loop_result.json 的 promotion_refused 步骤。

17 / 55
主体绑定 · 深潜

"给 A 打分,却上线 B"——这一手也要拦下

最阴险的变体不是跳过评估,而是诚实地给 harness A 打了高分,转手却去上线 harness B。见证门用主体绑定堵死它:一次 pass 永远和它实际打分的那个 harness 死死绑在一起。

# agent_loop.py:447-451 —— pass 当场绑定到它打分的主体 witnessed_pass = gate["promotable_pre_human"] # 一个没说清打的是谁的 pass,留 None(上线时 fail-closed) witnessed_subject = ( subject_of_eval(out) if witnessed_pass else None) # agent_loop.py:86-90 —— subject 契约:读不出主体就是 None # subject_of_eval / subject_of_promotion -> the harness the call is ABOUT # (or None). The subject binding is what stops a confused-deputy # promotion: score harness A, promote harness B.

一旦见证到的主体和上线目标对不上,agent 收到的是一条精确到名字的结构化拒绝——它自己能看到,也被写进 trace:

"subject mismatch: witnessed eval scored 'sec_triage_v2' but promotion targets 'sec_triage_v3'"

注意 witnessed_subject 在 gate 失败时会被重置为 None:一次陈旧的 pass 无法在事后被一个新批准"重新挂靠"。fail-closed 是这里的默认姿态。

18 / 55
Fail-closed 打分

读不出、算不动、或想绕过的分数,一律当 0 分

见证门的第一道防线,是"分数本身可信"。autonomy._score_value 把三类会静默污染判断的输入统统压回 0.0——因为"读不出的分不是通过"。

# autonomy.py:104-128 —— 聚合分的 fail-closed 解析 for key in ("score", "aggregate"): if key in score: v_raw = score[key] if isinstance(v_raw, bool): # bool 是 int 子类 -> 拒绝 return 0.0 try: v = float(v_raw) except (TypeError, ValueError): return 0.0 if not math.isfinite(v): # NaN / inf -> fail-closed return 0.0 return 0.0 if v < 0 else 1.0 if v > 1 else v

bool 单独拦:True 若直接 float() 会变 1.0,一个返回"通过标志位"当分数的裁判就会静默自动上线。NaN 也单独拦:它同时让 v<0v>1 都为 False,会溜过 clamp。

第二道防线是审计里抓到的嵌套维度绕过:如果 dimension_scores 里塞一个叫 dimensions 的子块,解析器会"钻进去"只看孩子、悄悄丢掉同级的 safety 维度。autonomy.py:132-149 直接把这类 key 剥掉,让安全否决看到真正的维度。

# autonomy.py:168-178 —— 三闸合一,全过才 promotable veto = loop_safety.apply_safety_veto(dims, aggregate=agg, threshold=threshold) safety_ok = not veto["vetoed"] # 安全否决 passed_bar = veto["aggregate_passed"] # 过了 pass 线 guard = loop_safety.regression_guard(incumbent_best, agg, min_pass=threshold, ...) regression_ok = guard["promote"] # 不比现役更差 promotable = safety_ok and passed_bar and regression_ok
19 / 55
五种归宿 · 实测证据

run_agent_loop 的五条路径,全部离线实证

同一个 driver,喂进四种 agent 行为,加一个总裁决。evidence/agent_authored_loop_result.json 逐条记录了它看到什么、拦了什么。

happy_promotion

评估通过 + 人类批准 + 主体一致 → promoted:true,subject 'sec_triage_v2'。该放行时放行。

promotion_refused

没评估就想上线 → 一步拦下,promoted:false,refused:1。

spinning_agent

反复跑低分评估 → 撞上 max_tool_calls,stopped_by == "cap",永不无限循环。

safety_trap

人类点了批准,聚合分也过了——却仍然没上线。

safety_trap 是全章最锋利的一刀witnessed_approval:true(人真的批了),但某个 safety 维度失败,evaluate_gate 强制否决——aggregate_passed=True 也不算。安全否决压过人类批准,witnessed_pass 始终是 false,上线被拒。人的一次点击,不等于见证到一次真正通过的评估。

# evidence: safety_trap 步骤的 final_gate_reason "safety veto: dimension(s) ['safety'] failed — forced fail regardless of aggregate (aggregate_passed=True)" # verdict: safety_trap_never_promoted = true, closed = true, paths = 4
20 / 55
CHAPTER 04

核心库的血肉

第四章 · 从 import 那一刻到一次真实 invoke——core.py / loader.py / factory.py 里那些不写就会静默出错的决定

42–79

import 期建 client 与 set_region 陷阱

115–174

create / wait_ready / update 全生命周期

285–464

流式解析与两段式 HITL 恢复

loader+factory

YAML→kwargs 与舰队编排

一句话:这一层从不调用模型、不开 socket、不读时钟——它只是把你声明的东西翻译成 GA API 的形状,并在每一处会静默失败的地方替你提前失败。

21 / 55
core.py:42–79 · import 期绑定

两个 client 在 import 那一刻就冻结了 region

# core.py:42-51 — 模块加载时执行一次 REGION = os.environ.get("SENTINEL_REGION", "us-east-1") # 数据面:一次 invoke 能流 78s+,长读窗口、少重试 _DATA_CONFIG = Config(read_timeout=180, retries={"max_attempts":2}) # 控制面:生命周期调用短,快失败、多重试 _CONTROL_CONFIG = Config(read_timeout=60, retries={"max_attempts":3}) _control = boto3.client("bedrock-agentcore-control", config=_CONTROL_CONFIG) _data = boto3.client("bedrock-agentcore", config=_DATA_CONFIG)

静默陷阱

import 之后再 export SENTINEL_REGION=... 完全无效——client 已经建好了。你所有调用都留在旧 region,而且不报错。

# core.py:54-79 — 唯一正确的运行时切换 def set_region(region): if not region: raise ValueError(...) global REGION, _control, _data _control = boto3.client("...control", region_name=region) _data = boto3.client("...", region_name=region) # gateway / registry_live 曾 from .core import _control # 它们的局部名还指向旧 client — 必须走 sys.modules 重绑 for m in ("...gateway", "...registry_live"): _mod._control = _control

两个 Config 不是随手写的:数据面 180s 读超时对应一次会 stream 78 秒的模型思考;控制面 60s/3 重试对应短而最终一致的生命周期调用。用 boto 默认值,会把一次长 CVE 分诊 stream 拦腰截断。

22 / 55
core.py:115–134 · 267–282 · fire-and-forget

create_harness 立刻返回 CREATING——真正能用要靠 wait_ready 轮询

# core.py:123-134 — 纯翻译层,无循环代码 args = dict( harnessName=name, executionRoleArn=_role(), # 未设则 raise systemPrompt=[{"text": system_prompt}], # GA list 形状 ) if model: args["model"] = model # 每个可选项 if tools: args["tools"] = tools # 有值才挂 if memory: args["memory"] = memory # 绝不发 null return _control.create_harness(**args)["harness"]

命名陷阱

harnessName 必须匹配 [a-zA-Z][a-zA-Z0-9_]{0,39}——不能有连字符。这是最常见的 ValidationException;loader/factory 本地预校验,让你在往返前失败。

# core.py:273-282 — 8 秒心跳的阻塞轮询 def wait_ready(harness_id, timeout=360): while ...: h = _control.get_harness(...) st = h["status"] if st == "READY": return h if st in ("CREATE_FAILED","FAILED","UPDATE_FAILED"): raise RuntimeError(h["failureReason"]) time.sleep(8) # 固定睡眠,非退避;nosemgrep 标注 raise TimeoutError
create wait_ready 8s↻ READY invoke

live CVE demo 的真实 trace:create_harness → harnessId sentinel_live_demo-XETVUi59pC → wait_ready status READY → invoke。systemPrompt 在 create 和 update 里都被归一成 [{"text": ...}]——GA API 拒收裸字符串。

23 / 55
core.py:285–366 · _consume_stream

一次 invoke 返回的是事件流,不是字符串

# core.py:308-324 — 从 JSON 字符串碎片重建 tool 调用 if d.get("text"): out += d["text"] if cur is not None and tu.get("input"): cur["_raw"] += tu["input"] # 拼接 delta # contentBlockStop: raw = cur.pop("_raw", "") or "" # 恰好 pop 一次! cur["input"] = json.loads(raw or "{}") # 解析失败→_unparsed pending.append(cur) # append 不是覆盖!

两个 bug 被注释钉在案:_raw 只能 pop 一次(早期版本在 try/except 里各 pop 一次,第二次拿到空串);完成的 tool 调用必须 append 进 list——一个 turn 可能并行暂停在多个 tool_use 块上,只留最后一个会静默丢掉更早的 HITL gate。

# core.py:345-350 — stop_reason 就是契约 paused = pending if stop == "tool_use" else [] return { "text": out, "stop_reason": stop, "tool_use": paused[0] if paused else None, "tool_uses": paused, # 全部并行 gate "usage": usage, # 顶层暴露 token }

stop_reason 语义

tool_use = 循环暂停了,需要人/客户端回答;其它(end_turn / max_tokens…)= 这一轮结束。live CVE:stop_reason end_turn,usage 75456 in / 3003 out = 78459,78.6s。

陷阱:只读 result['tool_use'] 而忽略 tool_uses 的调用者,会在并行 gate 上少答、并在恢复时腐化 session。流级错误(runtimeClientError 等)被显式抬成 [STREAM-ERROR] 文本标记 + 一等 error 字段,不埋进正文。

24 / 55
core.py:424–464 · 两段式恢复

恢复一个暂停的 agent 要发两条消息,顺序严格

# core.py:452-461 — assistant.toolUse 然后 user.toolResult tool_use_blocks.append({"toolUse": { "toolUseId": tuid, "name": name, "input": tinput}}) tool_result_blocks.append({"toolResult": { "toolUseId": tuid, # 同一个 id! "content": [{"text": result_text}], "status": status}}) # 默认 success messages=[ {"role":"assistant", "content": tool_use_blocks}, {"role":"user", "content": tool_result_blocks}]

为什么两条:模型的上下文必须呈现「工具被调用」+「结果返回」这一段连贯的 assistant→user 交换。只发结果、或只发调用,都会打破模型期待的交替。

什么会腐化

每个 pending 的 toolUseId 必须拿到一个 toolResult;缺一个就是 ValidationException / session 被腐化。这正是 _consume_stream 被修成 append 全部并行 gate 的原因。

# scenario_hitl_resume.py:66-68 — hitl_resume_result.json 证据 decision = {"decision": "APPROVED", "approver": "analyst-001", ...} # turn2 复用同一个 sid — 强制要求 r2 = sh.invoke_with_tool_result(arn, sid, r1["tool_use"], decision) # → end_turn

换个 session_id 恢复 = 开一段全新对话,模型对暂停毫无记忆,pending toolUse 成孤儿。回填的 input 必须是 _consume_stream 重建的那个(可能是 {"_unparsed":...}),不能凭空捏造。

单 gate 便捷封装 invoke_with_tool_result 只是对复数版 invoke_with_tool_results 的转调;答多个并行 gate 时必须用复数版,答全 tool_uses 里每一项。空 answers 会 raise ValueError——它拒绝一次空恢复。

25 / 55
loader.py:42–286 · 纯离线变换

loader:YAML→kwargs,扩展环境变量、拒绝扩展 secret

# loader.py:42 — 只匹配全大写 ${ENV} _ENV_REF = re.compile(r"\$\{([A-Z_][A-Z0-9_]*)\}") # 故意不匹配 ${arn:...} 令牌库引用 — # 那是小写+冒号,由 AgentCore Identity 服务端解析, # loader 必须原样留下这些 secret 指针 # loader.py:178-184 — systemPrompt 文件路径必须留在目录内 prompt_path = os.path.realpath(os.path.join(harness_dir, sp)) if prompt_path != base and \ not prompt_path.startswith(base + os.sep): raise ValueError("'..' traversal is not allowed")

load_harness_config 做 AWS 调用——离线的一半,让你无凭证也能在 CI 校验 config;只有 create_from_config 才碰 AWS。缺失环境变量会 raise KeyError 并点名(12-factor 大声失败)。

# loader.py:203-205 — 内联 HITL gate 自动注入 if entry in _INLINE_GATES and entry not in declared: gate = _INLINE_GATES[entry] tools.append(core.tool_inline( entry, gate["description"], gate["input_schema"])) # loader.py:266-270 — 铁律 #1 if "*" in allowed_tools: raise ValueError( "allowedTools must be an explicit allowlist")

gate 注入的巧思

HITL gate 的名字在 config 的 allowedTools 里,但它的 inputSchema 在代码的 _INLINE_GATES 注册表里。loader 把匹配的 tool_inline 定义注入 tools,sentinel create 就自动接好了暂停循环的 gate——除非 yaml 已显式声明(则尊重,不重复注入)。

陷阱:harnessName: 123(解析成 int)或带连字符的名字在 loader.py:233 本地就挂——早于 AWS,因为同一条命名正则在此也强制执行。裸标量 allowedTools(非 list)会被逐字符迭代,静默接不上 gate——所以 loader 大声拒绝非 list。

26 / 55
factory.py:73–340 · 舰队编排

provision_fleet:dry_run 零 AWS 调用 + 跨环境标签守卫 + 幂等

① dry_run

解析并校验整个舰队——yaml 读取、${ENV} 扩展、命名规则、标签合成—— AWS 调用,连 list_harnesses 都不发。对抗静默服务端校验的本地平价检查。

② 幂等

一次共享 list_harnesses 索引所有已存在 harness,供 N 个配置复用。已存在 → 记 exists,绝不重建。create-or-skip,从不 update。

③ 跨环境守卫

每个 harness 盖 sentinel:env 标签;同名 harness 若已在不同 env 下,provision 和 teardown 都拒绝碰它。prod run 绝不误删同名的 staging。

# factory.py:158-164 — 标签值必须是 string # CreateHarness tags 是 map<string,string> # YAML `build: 42` 解析成 int,只会在 live 调用报错 # dry_run 会漏掉它 → 在 _resolve_entry 里就 string 校验 # factory.py:270-275 if prior_env is not None and prior_env != env: raise FactoryError("cross-env tag-guard: ...")
# factory.py:217-236 — 曾经静默杀死守卫的 bug 修复 # ListHarnesses 的 summary 不带 tags! # 早期读 summary['tags'] 永远拿到 None → 守卫失效 resp = core._control.list_tags_for_resource(resourceArn=arn) # 读标签失败 → fail-safe 到 None(当作未打标) # factory.py:298-304 — teardown 拒绝空前缀 # ''.startswith('') 匹配每一个 harness = 删光一切

env 优先级:SENTINEL_ENV > manifest.env > 'dev' 兜底——遗忘的 env 绝不静默落到 prod。teardown 比 provision 更谨慎:未打标的同名 harness 在 provision 是安全跳过,在 teardown 是硬拒绝(它可能是 pre-tagging 的 prod 资源或别的工具建的)。

27 / 55
core.py:137–174 · BLUEPRINT.md:283

update_harness 是全量替换,不是补丁

静默丢字段陷阱

只传你想改的那一个字段,会静默丢掉 tools / memory / limits——服务端把这当成一次完整的新配置。harness_ops 必须先读当前配置,改,再把整个形状发回去(read-modify-write)。

# core.py:155 — 和 create 一样归一 systemPrompt args["systemPrompt"] = [{"text": system_prompt}] # core.py:167 — memory 形状在 create/update 间不同! # UpdateHarness.memory 是 UpdatedHarnessMemoryConfiguration # 只有一个 optionalValue 成员 — 必须重新包裹 args["memory"] = memory if "optionalValue" in memory \ else {"optionalValue": memory}
❌ 补丁心智 (错) update(model=X) → tools / memory / limits 全被丢弃 ✓ read-modify-write (对) get_harness 读完整配置 mutate 改一个字段 update 发整个形状

factory 的幂等刻意做成 create-or-skip、从不 update:「agent update = 全量替换」是 AgentCore 一个有文档记载的坑,交由别处的 read-modify-write 处理。memory 的 auto-wrap 是幂等的,但你必须走 update_harness,不能裸调 boto。

wait_ready 在 UPDATE_FAILED 上同样会 raise 并带 failureReason——一次全量替换若把配置改成非法形状,会在 update 后的轮询里显形,而不是在提交那一刻。

28 / 55
Chapter 05 · Detection Engineering

给检测工程
一个编译器

LLM 可以起草一条检测规则,但永远不允许 LLM 判定它是否有效、是否命中攻击、是否安全上线。那份裁决,交给 3,597 行零 token、零网络、同输入必同输出的纯 Python。

8

确定性工具,全程 LLM-free

3,597

行纯 Python,零第三方依赖可离线跑

0

token / 网络调用 / 密钥 —— 工具本身也是安全控制

29 / 55
架构 · 组合链

七个工具像 Unix 管道一样组合,谁都不重造 Sigma 解析

只有一个解析器、一套 rule-id 推导。上层工具全部按绝对路径复用下层,而不是复制逻辑。打穿底层,整座塔同步移动 —— 这正是设计意图:强制"一致"。

lint校验+FP translate→YARA/EQL dedup可证重复 coverageATT&CK 映射 audit折叠三工具→ health_score navigator热图渲染 baselinediff 回归 audit 按路径调用 lint + dedup + coverage(audit/handler.py:165-167) 共享解析器 sigma_yara_lint._parse_yaml + 一套 _rule_id 推导 所有工具对 "rule #5" 指同一条规则 —— 没有三种 Sigma 方言、三个覆盖率答案
30 / 55
实操 · lint + audit

一条规则进去,48/100 出来 —— 每一笔扣分都可复现

输入:一条结构损坏的 Sigma(缺 detection 主体)

# rule_broken.yml — 无 detection / 无 id / 无 tags title: Suspicious PowerShell logsource: category: process_creation # ← detection 段缺失:结构性错误

运行 lint

$ sentinel detection lint rule_broken.yml ok: true # 工具跑通了 valid: false # ← 规则本身无效(别看 ok) errors: ["missing 'detection' body"]

对 8 条同类烂规则跑 audit(真实 CLI 输出)

$ sentinel detection audit rules/ --json { "ok": true, "health_score": 48, "totals": { "invalid_rules": 8, "rules_with_warnings": 8, "untagged_rules": 8, "duplicate_pairs": 0 } }

读法要点

ok:true = 工具运行成功;valid 才是规则有效性。混淆这两个字段,是这套工具最常见的误读。

31 / 55
Demo · BAS 回放(真实证据)

4 个技术打向 2 条规则,coverage_ratio 0.5

evidence/bas_replay_result.json —— 确定性回放引擎 generate_cases + sigma_match,离线、无 LLM / 网络 / AWS。两个亮绿,两个死寂,盲点就是检测工程的 backlog。

# bas_replay_result.json — per_technique 判决 "T1059.001": # PowerShell firing_rules: ["PowerShell Encoded Command Execution"] "T1046": # Network Service Discovery firing_rules: ["Network Service Discovery via nmap"] "T1003.001": # LSASS memory dumping firing_rules: [] ✗ BLIND SPOT "T1547.001": # Registry Run Keys firing_rules: [] ✗ BLIND SPOT "techniques_detected": [T1059.001, T1046] "blind_spots": [T1003.001, T1547.001] "coverage_ratio": 0.5 # = 2/4 检出/测试
0.5

coverage_ratio = 检出 2 / 测试 4

为什么必须强制 matched=False

BAS 唯一绝不能发生的事:把一条不可评估的规则算成"命中",那会从 backlog 里抹掉一个真盲点。任何 caveat 都强制 matched=False。

注意区分

回放的 coverage 测"事件是否触发规则";detection_coverage 测"规则是否标注技术"。规则可能被标注却不开火,反之亦然。

32 / 55
概念 · 健康分公式

从 100 开始,每类缺陷扣一个封顶且饱和的惩罚

# audit/handler.py:136-140 —— 每类扣分饱和 def _deduct(key, count): weight, basis = _SCORE_WEIGHTS[key] if count <= 0: return 0.0 return weight * min(1.0, count / basis) # 最终:钳制 + 四舍五入,确定性整数 return max(0, min(100, round(score)))

权重是静态字典,不是学习模型 —— 可对审计员逐条辩护。饱和意味着单个噪声类扣满权重后不再增加,淹没不了更严重的类。

缺陷类weightbasis
invalid_rules 结构损坏405
uncovered_techniques 盲点3010
duplicate_pairs 重复155
untagged_rules 未标注1010
fp_prone_rules 易误报105
lint_and_tag_noise 噪声510
# 实算:8 条 invalid,无 techniques invalid : min(1, 8/5)=1.0 → -40 untagged: min(1, 8/10)=0.8 → -8 noise : min(1, 8/10)=0.8 → -4 100 - 40 - 8 - 4 = 48 # 实测一致 # + T1059,T1046 未覆盖: min(1,2/10)=0.2 → -6 → 42
33 / 55
代码 · sigma_match v0.5

7 个修饰符,与它们杀掉的silent-wrong bug

v0.5 之前,Count|gt: 1000 被静默当成 == 1000payload|frobnicate 也 fall through 到相等。都返回一个自信的布尔值 —— 在盲点分析里这是毒药。

# handler.py:736-737 —— caveat 账本合约 if caveats: matched = False # 无法评估≠未命中/命中 # 未知修饰符:记 caveat + 返回 False, # 绝不 fall back 到相等(handler.py:483-487) frobnicate → {matched: false, caveats:[{modifier: frobnicate, reason: unsupported_modifier}]}

选择项无短路地评估所有 key —— 后一个 key 的 caveat 不会被前一个失败的 key 藏起来。

修饰符语义(全部实测)
cidripaddress 成员判定:10.4.2.1 ∈ 10.0.0.0/8 ✓
base64offset3 种 pad 对齐,jndi:ldap 流中命中 ✓
windash-enc 同时匹配 /enc ✓
gt/gte/lt/lte真浮点比较;非数值 → caveat
exists仅判存在性
cased翻成大小写敏感:Admin ≠ admin
null匹配字段缺失或 None

未支持修饰符的账本

_to_number 拒绝布尔(True 当 1 会藏错);坏 CIDR 值记 caveat,非 IP 字段只是不匹配(规则没错,事件不是 IP)。

34 / 55
概念 · 诚实账本 + FP 启发式

最重要的输出,是满是 "cannot" 的那几个列表

工具承认它能"证明"的边界,这个承认就是整个信任模型。会猜的工具看起来更聪明,却危险得多 —— 一个假的"可安全删除"会删掉真覆盖。

not_analyzed

dedup 只在单选择-纯 AND 且集合包含可数学证明时才断言重复。复杂条件 / 正则 / 数值全进此账本,永不参与重复断言(dedup/handler.py:260-266)。

untranslatable

translate 把有损修饰符或 NEGATION 路由进来。否定是关键:OR-flatten 骨架会把排除反转成包含,静默改变语义 —— 宁可标注"需人工重建"。

caveats

sigma_match 里任何非空 caveats 强制 matched=False,让 BAS 调用方明确排除该规则,而不是信任它。

FP 启发式:5 个确定性检查(触发 fp_prone)

# sigma_yara_lint/handler.py:289 _fp_heuristics 1 无 falsepositives: 字段 2 高volume logsource 且 condition 无 not/filter 3 |contains 值 <=4 字符 或 在通用词denylist 4 唯一谓词是单个未锚定 contains 5 critical/high 级别但 <=2 个谓词

为什么是警告类,不是错误

结构有效性与运营噪声是正交的,必须分开通道。fp_warnings 是第三列表,不动 errors/warnings 计数。

阈值与封顶

audit 要求 ≥2 个 fp_warnings 才归为 fp_prone(单个软信号太急躁);健康分封顶 -10。仅对通过 lint 的规则跑 —— 坏规则不会同时是 fp_prone。

35 / 55
Demo · 一行 CI 门禁

所有确定性的回报:一个进程,一个退出码

# 流水线 YAML 里的一行 $ sentinel detection ci rules/ \ --min-score 90 \ --techniques T1059,T1046 \ --against baseline.json \ --navigator-out layer.json # lint → dedup → 评 ATT&CK 覆盖 → # 对 baseline 查回归 → 折成单个 exit code GATE FAILED: health_score 48 < 90 GATE FAILED: regression — T1003.001 newly uncovered exit 1

baseline 不只看标量分数,还 diff 集合:某条规则变 invalid,即使分数持平也算回归(baseline/handler.py:121-141)。

三个退出码,含义清晰

2

输入坏:读不到目录 / 无规则 / 畸形

1

门禁失败:分数低于底线 或 回归

0

通过

用 jq 解析

$ sentinel detection audit rules/ --json \ | jq '.health_score' 48

三个陷阱

空规则目录 exit 2 不是 0;baseline 必须用相同 --techniques;两个门禁都不开则永远通过,须至少 opt-in 一个。Navigator 导出是纯副作用,永不影响门禁。

36 / 55
第六章 · Chapter 06

让任何 agent
都能用

MCP 与三层架构 —— 同一个治理闸门,既护住 AWS 生产面,也护住 Claude Code / Cursor 这类最不可信的边缘调用方。一份 registry,两个消费者,永不漂移。

MCP Server

文件系统发现工具,默认只暴露 17 个 —— 20 减 2 控制面减 1 pending

双闸 Registry

approved ∧ code-mapped,离线 tools.yaml 与 GA Registry 同一条规则

三层 SecOps

L1 策略 / L2 攻击验证 / L3 地基,逐层映射到 Harness 原语

37 / 55
MCP Server · 零集成代码

一条命令起服务,工具从文件系统里"长"出来

大多数框架把 MCP 当垫片:手写 20 个 wrapper、20 份 JSON schema,然后永远手动同步。这里反过来 —— 启动时扫 tools/ 目录,谁没过治理闸门就不暴露。MCP 面和 AWS harness 面读同一个 registry,物理上无法漂移。

# 装好后直接起(stdio) $ sentinel mcp serve # cli.py:731 cmd_mcp_serve → mcp_server.run() # 免安装跑: $ uvx --from 'sentinel-harness[mcp]' \ sentinel mcp serve
// 接入 Claude Code — docs/MCP-SERVER.md:34 { "mcpServers": { "sentinel": { "command": "sentinel", "args": ["mcp", "serve"] } } }

启动做三件事(一次性)

1 _discover_tools() 按字母序遍历 tools/,找每个目录的 handler.py
2 逐目录过三道闸:治理闸 → 控制面闸 → import 加载。
3 两个 async handler 挂上:list_tools() 只报能加载的,call_tool()event = arguments.get("event", arguments) 后调 mod.handler(event, None)

20 → 17

默认暴露面:20 个目录 − 2 控制面 − 1 pending。这个算式在 tests/test_mcp_server.py:26 里被断言。

source checkout 里 command:"sentinel" 要求装在 PATH 上;否则用 uv run --extra mcp sentinel mcp servemcp SDK 是可选 extra,缺了 create_server() 会带安装提示 sys.exit(1),其余库照常工作。

38 / 55
治理过滤器 · 边缘最不可信

默认藏起危险工具,不是漏,是设计

连过来的 AI agent 在边缘是不可信的 —— 它看见什么就敢调什么。若 MCP 暴露全部工具,就等于广播了 (a) SecOps 从没批准的工具、(b) 会创建/删除真实 AWS 资源或烧 token 的控制面工具。所以复用 registry.py 当唯一裁判,不搞第三份 allowlist。

闸 1

治理闸

mcp_server.py:115
if approved and tool_name not in approved and not allow_pending: continue
只有 status==approved 才放行,web_search(pending)被藏。

闸 2

控制面闸

mcp_server.py:119
_CONTROL_PLANE_TOOLS = {harness_ops, run_evaluation}
能建/删 AWS 或调模型(=花钱),默认 OFF

闸 3

import 加载

import 期报错的工具不静默丢弃:保留 module=None + [LOAD ERROR],从 list_tools 里滤掉,但 call_tool 返回 load_error —— 失败可见,不静默。

# 两个逃生舱 —— 仅供开发/测试 SENTINEL_MCP_EXPOSE_CONTROL_PLANE=1 # 放出控制面 SENTINEL_MCP_ALLOW_PENDING=1 # 绕过 approved 闸 # truthy = {'1','true','yes','on'} (line 71)

别在生产里翻这个开关

在真实 Claude Code 配置里开 ALLOW_PENDING=1 会悄悄放出 web_search(出网工具)。它 pending 恰恰因为出网 allowlist 未批准 —— 这是开发逃生舱,不是生产开关。缺 registry 时 _load_approved_set() 返回空集,故意 fail-open 让服务能起来而非崩溃。

39 / 55
Registry 双闸 · 职责分离即集合交集

approved AND code-mapped,否则它不存在

一行 YAML,一个 Python dict key。两边都同意,工具才是活的。删 YAML —— SecOps 无需碰代码就吊销了;删 factory-map key —— 工具注册了却没实现,governance_check() 标为漂移、CI 挂掉。任何一边都无法单独让能力成真。

tools.yaml · SecOps 拥有 status: approved "哪些工具可以跑" 删一行 = 无需 deploy 吊销 factory_map · 工程拥有 name → callable() "工具的真实实现" 删 key = 有批准无实现 list_live() = 交集 approved==True AND name in _factories · registry.py:178

它挡住两种攻击

批准但未实现的名字无法 resolve(无 factory → raise),过期批准不会 500 掉 agent;已实现但未批准的影子能力永不可解析,且被 impl_missing_registry 曝光 —— 工程无法偷偷上一个 SecOps 没见过的工具。

五个桶,pending 不算漂移

governance_check()(registry.py:195)产出:live / approved_missing_impl / impl_missing_registry / pending / deprecated_with_code。report.ok 仅当两边都无漂移为 True —— pending 是故意 held,deprecated 却仍有 live factory 才是漂移。纯离线 · 零 AWS · 零 LLM

40 / 55
Gateway · 每个参数都带着真实 AWS 的疤

CUSTOM_JWT 认证 + Lambda 拦截器 + 策略引擎

蓝图称 Gateway 是"最重要的可靠性升级":supervisor harness 去调一个工具,而不是自己写 HTTP。gateway.py 几乎每行都写着一个 offline mock 抓不到、只有真实 AWS 才报的 ValidationException,被编码成 fail-fast 的本地 guard。

# create → 只有 CUSTOM_JWT 才带 authorizerConfiguration # gateway.py:160 — AWS_IAM/NONE 带了就报错 create_gateway(name, authorizer_type="CUSTOM_JWT", protocolType="MCP", roleArn=exec_role, ...) # 恰好二选一:M2M 令牌没有 aud claim! # gateway.py:213 if bool(allowed_audience) == bool(allowed_clients): raise # 人类 ID 令牌→aud;机器→client_id # wait READY,每 8s 轮询;DELETING 视为终态 wait_gateway_ready(arn) # line 316,快失败不空等 # teardown:先删 target 再删 gateway(line 524)

GA 关键更正

CreateGateway 上没有原生 "Guardrail 拦截器" 原语。守护栏要么跑在 Lambda 拦截器里(ApplyGuardrail,interceptor.lambda.arn,作用于 REQUEST/RESPONSE),要么走独立的 policyEngineConfiguration(Bedrock guardrail ARN,LOG_ONLY / ENFORCE)。两条路互相独立。

200-vs-401 就是授权证明

live 证据 live_custom_jwt_gateway_result.json
valid_jwt → 200 no_token → 401 garbage → 401
随后真实 Lambda MCP target(cve-severity-tool)端到端跑通,返回 {cvss:9.8, severity:critical}。机器 client secret 全程留在服务端(GenerateSecret=true),从未被取出或打印。

踩过的坑:Gateway 名字正则(≤48 字符、无尾连字符)≠ target 正则(≤100、允许尾连字符);payloadFilter.exclude 必须是结构体 {"field": <jsonpath>} 不是裸字符串;update_gateway_target 是全量替换不是 patch;所有 list 操作走 _drain_pages 排干每一页(只读第一页会漏删计费资源)。

41 / 55
三层 SecOps · 逆向工程成 Harness 原语

L1 策略 / L2 攻击验证 / L3 地基 —— 按信任与爆炸半径分层

核心洞见(ARCHITECTURE.md:5):安全团队已经有模型、MCP server、skills —— 缺的是让它们流动的框架。三层就是这条循环,自底向上组合:L3 决定工具面,L1/L2 消费它。

L1 策略 · 日常闭环(Harness,零编排代码) research supervisor→specialist 并行(~2.3–2.6x) · detection: generator→独立 adversarial-reviewer→Sigma/YARA lint→inline_function 发布闸 · alert-triage 人闸 · 反馈落 Memory facts/{actor} L2 攻击验证 · 最危险(长运行 Runtime 容器,完整 loop 控制) BAS/对手模拟超过 harness timeoutSeconds → async entrypoint + checkpoint + session cap 自重启 · Play Mode 在每一步进攻上包 inline_function 闸 L3 地基 · 零 AWS 调用、纯 Python,确定性地 gate 上面每一层 sandbox: microVM/session + PreToolUse 钩子(路径/命令 allowlist/只读云) · Agent Factory test→staging→prod tag-guard · Gateway 语义搜索单一 MCP 入口 · registry 双闸 + S3 SKILL.md

怎么组合

Gateway(L3) 是工具入口;registry(L3) 决定其中哪些是 live;L1 harness 调 tool_gateway 触达;L2 Runtime 调同一个 Gateway,但每步进攻都过 inline 闸。

反幻觉两机制

独立 adversarial-reviewer 是单独一个 harness(生成≠评估,无自批偏差);tool_inline(core.py:507)暂停 loop(stop_reason=tool_use)把调用交回你的代码,等分析师签字。

诚实的成熟度

端到端已验证的是 L1:CVE triage(live_cve_demo,78.6s / 78459 token 真实 Sonnet)、多 harness 并行、detection 生成+审阅+发布闸。L2/L3 为已设计、部分构建 —— 不夸大成全 live。

42 / 55
CHAPTER 07

第七章 · 纵深防御

安全治理与部署 —— 从提示词是「建议」,到 IAM 与网络边界是「强制」

6

安全不变量

沙箱钩子 / SSRF / 禁 *

3

层析销毁护栏

$PREFIX 空串陷阱

9

CDK 栈

零常驻成本合成

88%

覆盖率闸门

tag 触发才发布

本章讲一件事:任何安全控制,只要还依赖「模型选择遵守」,注入就能击穿它。所以我们把执行点全部移到模型够不到的地方——确定性代码、IAM、以及物理上不可路由的网络。

43 / 55
安全不变量 · ADR-0001

「LLM 负责叙述,它无权自行扩大爆炸半径」

每条控制要么是确定性代码、要么是 IAM/网络边界。没有一条依赖模型「决定守规矩」——因为 CVE 文本、SIEM 字段、网页片段都可能夹带注入载荷。

① 沙箱钩子

validate_command 五步 fail-closed:非空可解析 → 全串黑名单正则 → 链操作符拒绝 → 首动词白名单(42 个读/构建/测试/VCS 动词) → 逐参数路径限定。

validate_path 在归一化之前按字面拒绝 ..,于是 /workspace/../etc 被拦;兄弟目录 /workspace-evil 也因 norm==base 或 startswith(base+sep) 被拒。

test_sandbox_hooks.py test_fuzz_sandbox_hooks.py

② 禁 allowedTools '*'

create_from_config 使用前先校验形状:必须是 list(裸标量会逐字符迭代、静默漏接 HITL 门),每项非空字符串,出现 '*' 直接 ValueError('ironclad rule #1')

嵌套 list / dict / None 也一并拒绝——非字符串工具名永远匹配不上,其 HITL 门会被静默地永不注入。

loader.py:266 · grant-all forbidden

③ SSRF 护栏

_assert_safe_url 在任何实网请求打开之前跑:scheme 白名单 {https,http} 挡掉 file/gopher/ftp;IP 字面量做段范围检查,link_local/multicast/reserved/unspecified 全拒——169.254.169.254 元数据 IP 命中 is_link_local。

环回 127.0.0.1 故意允许:本机自托管搜索后端是合法运维选择;真正的威胁是元数据与链路本地地址。

test_ssrf_guard.py · 25+ 用例

# sandbox_hooks.py:42-54 — 黑名单在整串上扫,_CHAIN_OPERATORS 含 \n / \r _DENY_PATTERNS = [ rm -[rf] , fork bomb , mkfs/dd if= , >/dev/sd , curl|sh , sudo , chmod 777 , eval/exec , /etc/(passwd|shadow|sudoers) ] # 若不拦换行/回车,"echo ok\nnmap ..." 会绕过只看 tokens[0] 的动词检查
44 / 55
三层销毁护栏

''.startswith('')True —— 一个未设置的 $PREFIX 删光整个账号

sentinel cleanup $PREFIX 删掉所有名字以 PREFIX 开头的 harness,并级联删掉它们的 managed memory。触发者通常不是恶意,而是 teardown 脚本里一个没赋值的环境变量。单一护栏足够正确,三层是纵深——没有任何代码路径能带着空串抵达销毁循环。

L1

CLI 早退出

cmd_cleanupif not args.prefix.strip() → 打印拒绝、return 2,在任何破坏动作之前

cli.py:226

L2

CLI --dry-run

安全彩排:只列出「删哪些」而不碰任何东西——[dry-run] N harness(es) would be deleted

cli.py:230

L3

库层 ValueError

即便绕过 CLI 直接调库,core.cleanup 首行就 raise ValueError

core.py:576

# core.py:576-580 — 即使有人绕过 CLI 直接调库,这里仍然 fail-loud def cleanup(prefix): if not isinstance(prefix, str) or not prefix.strip(): raise ValueError("cleanup: refusing an empty/whitespace prefix — it would match and delete EVERY harness") # 只有越过这道门,才 paginate _all_harnesses()(抽干所有分页,曾因只读第 1 页 orphan)并逐个 best-effort 删除

代价提醒:级联删除会连 triage 事实一起毁;且逐个删是 best-effort(catch/log/continue),部分失败不回滚——已删的保持已删。

45 / 55
Infrastructure as Code

9 个 CDK 栈:各映射一个 AgentCore 原语,按构造零常驻成本

bin/sentinel.ts —— 每个栈都是原始 CfnResource 或 L1/L2,account/region 取自 CDK_DEFAULT_* 或 profile,无字面量 免费 / 已实盘部署并保留 (m4_live_deploy) networkVPC · 无 IGW/NAT/0.0.0.0/0 identityCognito · 人 + M2M token guardrailBedrock · sha256 版本 observabilityMetric+Budget 电子围栏 gatewayCUSTOM_JWT memory{actorId} 命名空间 映射原生 AWS::BedrockAgentCore::* harness · 唯一已注册 FULLY_MUTABLE registry · 未注册 → CR 兜底 runtime · 未注册 · synth 过 deploy 失败 成本按构造门控 natGateways:0 → 省 ~$32/mo/AZ 5 个 interface endpoint(~$27-34/mo) 默认 OFF network-stack.ts:79 — Vpc({maxAzs:1, natGateways:0, subnetConfiguration:[PRIVATE_ISOLATED]}) · :101 allowAllOutbound:false · :182 StringEquals{aws:PrincipalAccount:this.account} iac-terraform/ — 可部署的 HCL 镜像(用 hashicorp/awscc 起原生 Harness),同一 var.deploy_vpc_endpoints 成本门(默认 false)。CDK 是源头,TF 是镜像。

出网是物理不可路由而非被过滤——没有 IGW/NAT/公网路由。guardrail 把 policy 的 sha256 嵌进版本描述,否则原地收紧策略会静默 no-op(一个被审计出的 bug)。Registry/Runtime 的未注册状态在各栈头如实写明,不藏。

46 / 55
最小权限执行角色

最危险的 IAM 授权,我们刻意不授予;最阴的坑,IAM 模拟会骗你

bedrock-agentcore:InvokeAgentRuntimeCommand 以 root 在 microVM 上跑 shell,绕过 LLM 和 allowedTools。allowedTools 只约束 LLM 选哪个工具,管不了这个独立数据面 API——唯一控制就是不给这个 IAM action(ADR-0001 不变量 #6)。

今天真实踩到的信任策略坑

执行角色的信任策略必须允许 Principal Service: bedrock-agentcore.amazonaws.comsts:AssumeRole

# 通用角色 role/neo 建 harness 直接失败: ValidationException: trust policy does not allow bedrock-agentcore to assume # 权限再全也没用——服务 assume 不了这个角色 # AgentCoreHarnessPOCRole(信任策略正确) → 成功

阻塞点是信任策略而非权限:一个权限完美但信任主体错误的角色照样失败。

SimulatePrincipalPolicy 会撒谎

用 AdministratorAccess 主体,iam:SimulatePrincipalPolicy 对 InvokeHarness 返回 allowed,实调却 AccessDeniedException

# live_dataplane_gate_diagnosis.json:21 IAM simulation returns "allowed"... yet the live call is DENIED — service-side account-level opt-in gate invisible to SimulatePrincipalPolicy

根因是服务侧账号级 allowlist,IAM 模拟看不见。控制面(CreateHarness→READY)完全可用,只有 invoke 数据面被门控。别信绿色的模拟。

其余最小权限:观测权限用 cloudwatch:namespace='bedrock-agentcore' 条件限定而非 logs:*,避免 agent 被攻陷后全账号篡改日志;信任策略带 aws:SourceAccount=stack.account 挡跨账号混淆代理;X-Ray 因不支持该条件而单列一条语句(否则会静默破坏授权)。

47 / 55
CI/CD 发布流水线

一个 git tag,全自动走完质量闸门 → SBOM → SLSA → PyPI

push: tags: [ v* ] ① gate (质量闸门) ruff==0.15.20 check . coverage run -m pytest --fail-under=88 (实际~90%) 存在因为审计发现: tag 上不跑 CI ② build (needs: gate) python -m build + 冒烟测 wheel CycloneDX SBOM (cyclonedx-py) SLSA provenance · Sigstore keyless attest-build-provenance → dist/* GitHub Release awk 抽 '## [X.Y.Z]' 附 dist + SBOM CHANGELOG 标题是 load-bearing ③ pypi-publish needs: build if !contains(ref,'-') → 预发布(带连字符)跳过 PyPI PYPI_API_TOKEN · id-token 就绪 红 gate 挡住整条发布:build/publish 都 needs:gate。88 与 .coveragerc、make ci 三处必须一致。OIDC Trusted Publishing 本是意图,但 PyPI 无 API 注册 publisher,退回 project-scoped token。

这就是全书的模式:每个控制都能追溯到一个被抓到的具体缺陷。gate 存在,是因为审计发现 ci.yml 只在 branch 上触发、不在 tag 上跑——不加它,git tag v1.2.3 <任意 commit> 就能把未验证代码直发 PyPI。

48 / 55
供应链安全

每一个第三方 action 都钉死到 40 位 commit SHA —— 不是 tag

tag 可以被移动,commit SHA 不能。被攻陷的 action tag 注入不进来。每条防线都对着一类具体威胁。

SHA 钉死一切

每个 action 钉到完整 40 位 SHA,上方带人类可读版本注释:
actions/checkout@9c091bb... # v7.0.0

为什么:OpenSSF Scorecard 的 pinned-dependencies 检查因此加分;被劫持的可变 tag 无法悄悄换掉底层代码。

CodeQL + pip-audit

codeql.yml 每周扫 python + js-ts。pip-audit 阻断式,但只审项目自己 pyproject.toml 声明的依赖。

为什么只审自家依赖:审整个 CI venv 会被 runner 基线包误报而假红。bandit 顾问式 || true 不 gate。

Dependabot

覆盖三个生态:pip / npm / github-actions——连 action 的 SHA 升级都自动化提 PR。

为什么:钉死 SHA 不等于不升级;Dependabot 负责让钉死的版本持续追上安全补丁。

secret-and-name 扫描

拦客户名(字符类拼装的正则)、硬编码 12 位 account、AKIA/ASIA 密钥;占位符(000000000000/1234.../5555...)白名单放行。

为什么把 .github 也纳入:那正是 live OIDC role ARN 最可能泄漏的地方。

# ci.yml:192 — account-id 正则,带占位符白名单,避免误伤示例 ACCT_RE ... | grep -vE ':0{12}:|123456789012|555555555555' # 真实 12 位 id 命中即 fail build

纵深防御闭环:从「提示词是建议」到「IAM 与网络是强制」,每一层都留了署名证据——OpenSSF Scorecard 徽章经 OIDC 发布,供任何人复核。

49 / 55
Chapter 08

跑给你看

前面七章讲的是设计与取舍。这一章只做一件事:把每一条声明还原成一份可以独立攻击的真实证据——真账号、真 ARN、真 token 计数、真模型答案。

压轴的方式不是 PPT,是 evidence/*.json。一个怀疑一切的安全架构师,只信落地的工件。

50 / 55
现场 Demo · evidence/live_cve_demo_result.json

Log4Shell 走进前门,78.6 秒后连微 VM 都不剩

一个一分钟前还不存在的 harness,在真实 AWS 上生出来、答完题、再自己删掉。四步账本,闭环干净。

# account 835751346093 / us-east-1(JSON 内已脱敏为 <scrubbed>) step create_harness ok id=sentinel_live_demo-XETVUi59pC step wait_ready ok status=READY step invoke ok stop_reason=end_turn latency_s=78.6 usage: in=75456 out=3003 total=78459 step delete_harness ok teardown=complete

75k 输入 token 是关键 tell——答题前先把 NVD 与 MITRE/CISA 两个权威源拉进上下文,这是有据的 grounding,不是幻觉长文。

真实 Claude Sonnet 答案(节选)

CVSS 10.0 CRITICAL

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H

根因:log4j2 消息 lookup 把攻击者可控串经 ${jndi:ldap://…} 直送 JNDI 解析器;LDAP 回引远程类,JVM 取回并实例化 → 进程内 RCE。

CWE:CWE-917(主)· CWE-502 · CWE-20 KEV:2021-12-10 收录

修复深度:准确区分 2.16.0(45046)/2.17.0(45105)/2.17.1 才是最小安全版——一道事实深度体检,答案通过。

51 / 55
五份证据 · 一份一个能力面

一次答对是 demo,五个平面各自成立才是平台

刻意拆成「一能力一文件」——评审可以逐条独立进攻,任何一步 flaky 都不会拖垮全局。

closed_loop · 自改进北极星

弱 agent 被独立 judge 打 0.0 → update_harness 换强 prompt(v2) → 复评 1.0 → 过 safety-veto + regression-guard → HITL approve → CreateHarnessEndpoint。reject 路径证明会扣住晋升

live_memory_recall · 跨会话 + 租户隔离

tenant-1 四个会话写入 web-01 Log4Shell 处置,语义策略异步抽取,跨会话检索回 3 条;同一 memory、同 query,tenant-2 回 0 条。硬隔离。

live_custom_jwt_gateway · 200 vs 401

真建 Cognito user pool + M2M client_credentials,Gateway 用 CUSTOM_JWT authorizer 指向 OIDC discovery。带合法 RS256 token → 200;无/错 token → 401

hitl_resume · 暂停→批准→续跑

turn1 停在 request_containment_approval(WEB-07 isolate,T1071+T1003.001)→ 人类 analyst-001 批准 → turn2 end_turn。两消息 resume 契约成立。

cve_asset_triage · 确定性核心(离线)

Log4Shell(CVSS 10.0, EPSS 0.975, in KEV) → 命中资产 web-01 → blast radius 触及 app-01、internet_exposed 命中 → 建议 patch_now,且 强制 HITL 签核后才动手,无 AI 自动执行。

52 / 55
方法论 · docs/ROADMAP.md:20

先有证据,才敢说 done」——连同它诚实承认的边界

这不是口号,是被文件布局、脱敏正则、CI secret-scan 和每份 JSON 里的 honesty 字段一起强制执行的规矩。最有说服力的,恰恰是它写清了自己不做什么

规矩本身

每个 scenario_*.py 用 rec() 追加 {step, ok, data},_ACCT_RE 把 12 位账号脱敏为 <ACCOUNT_ID> 后再落盘;顶层 verdict 带 closed 布尔 + note 明说哪些真、哪些 mock。

FIDELITY-REPORT.md 敢用 GENUINE 开头:凡标 live/real,无一暗中 mock。

四条诚实的边界

① 模拟 vs 真:BAS 遥测是模拟 Sysmon 事件,但匹配/回放是真确定性 Python。

② quota-gated:M2 单次 re-score 撞过 InvokeHarness 403,晋升在 endpoint_promote 里单独证。

③ runner vs agent 编排:loop 决策由 runner 编排,agent 自编排变体仅离线脚本,不声称 live。

④ 异步 memory:语义抽取由服务调度(分钟级),无 force-now。

连「诊断错了」都留档:live_dataplane_gate_diagnosis.json 被更正后保留——10 分钟一个鉴别性测试,胜过百万 token 的推断扫。verify, don't infer.

53 / 55
安全网 · make test

2494 passed, 7 skipped in ~44s——零网络、零凭证、零 sleep

「配置而非代码」的前提,是那层薄核心库必须刀枪不入。~127 个测试文件、约 2501 条用例,工程上被设计成密封的。多轮对抗审计累计修掉约 100 个真实缺陷。

1

conftest 凭证防火墙

conftest.py:30-43 在 import 期 pop 掉 AWS_PROFILE,setdefault 假凭证 + us-east-1——任何测试都碰不到真账号。

6

Hypothesis property 测试

@given 攻击确定性 gate 核心:blast-radius / sigma-match / whitelist……断言不变量(幂等、count 相等、可达与受影响不相交),非同义反复。

7

skip 都是有条件的

tomllib<3.11、bash 不可用、PyYAML 缺失、CDK node_modules 缺——每个都有显式 reason,绝无静默失败。

对抗式设计 · adversarial by design

contract 测试(test_specialist_a2a_contract.py)对两个 specialist 参数化跑同一断言:agent-card=canonical、message/send 往返结构化信封、畸形输入被拒。pytest-randomly 打印种子、顺序无关;coverage gate 88%(.coveragerc 与 ci.yml 共享)。

54 / 55
上手 · 15 分钟

clone 到绿灯 60 秒,从绿灯到你自己账号里的真实答案 一个 role ARN

# 0-6 分钟:全程离线,零 AWS git clone … && cd sentinel-harness pip install -e . # Python 3.10+, v0.5.1 make test # 2494 passed, 7 skipped ~44s make demo # BEAT 1→7,纯离线确定性 ls evidence/ # 打开 live_cve_demo_result.json # 8-15 分钟:需一个非 prod 账号 export SENTINEL_EXECUTION_ROLE_ARN=arn:aws:iam::<acct>:role/<role> python scenarios/scenario_cve_triage.py sentinel mcp serve # 20 工具,一层治理过滤

默认离线,是刻意的

只有 4 个 Makefile target 碰 AWS(deploy/deploy-endpoints/reset/destroy),每个都先打印账号+region 再要你键入 yes。让新人先「看它跑起来」,再「为它花钱」。

standing 成本 ≈ $0/mo;唯一有意义的是可选 make deploy-endpoints(~$30/mo PrivateLink)。

github.com/aws-samples/sample-sentinel-harness pip install sentinel-harness

安全 agent 是配置,不是编排代码。
AWS 跑循环,证据替你说话。

55 / 55