Files
HomeAgent/internal/agent/core/resident_tools.go
JianFeeeee e78a38b3d8 fix(security): 父为子声明工具策略 —— 修正上一版「子继承父权限」的错误模型
## 上一版错在哪

上一版(1d007e1)给子 agent 加了 `Security: a.security`,判据也钉的是
「子持有**同一个** Runtime 实例」。

那只保证了**机制**到位,却把权限模型写错成了「子 = 父的完整权限」。
用户裁定:**应当是父 agent 为子 agent 声明策略**。

区别是实质的:

	错的模型:父能调 cmd_* ⇒ 子也能调 cmd_*(子只受全局策略约束)
	对的模型:父在创建子时决定这个子能调什么,内核持有并执行,子不能自己扩大

驻留子长期无人盯、rtchild 通话中一边听一边执行工具 —— 它们恰恰最需要
**比父更窄**的权限,而不是与父等权。

## 这不是新机制,是没接线的旧机制

`security.Runtime` 里早就有:

	CheckToolFor(p Principal, tool)              // 按主体判定
	PrincipalPolicy{AllowTools, DenyTools,       // 主体策略
	                HardPrivateTools}
	DeclarePrincipalPolicy(scope, key, policy)   // 声明入口

而 `codegraph_callers("CheckToolFor")` = **0 个生产调用方**
(与工具策略闸、ScanContent 同型:机制完整、接线缺失)。
公开 SDK 也没暴露 `DeclarePrincipalPolicy`(插件暂时无法声明主体策略)。

## 改动

① `security`:新增 `RevokePrincipalPolicy(scope,key)` / `HasPrincipalPolicy(id)`。
   **撤销是必须的**:主体 ID 是调用方给的名字(`create id="bot1"`),
   子回收后同名重建是常规操作,旧策略留着 ⇒ 新一代用上一代父留的约束运行。
   这与今天修的 rtchild 留档串线(`rtClosedTranscripts` 以模型给的名字为键、
   全仓 0 处删除)是**同一种形态**。撤销只支持 scope="id" —— 允许撤销 tag
   等于任何执行体销毁时都能抹掉全站策略。

② `agent/core`:`securityDecisionFor` 分两条路径

	父(parentID == "")    → CheckTool(tool)                          // 内核自身
	子(parentID != "")    → CheckToolFor(Principal{self, "resident"},
	                                        tool)                      // 叠加父的声明

   CheckToolFor 内部**先**调 CheckTool,所以主体策略无法绕过全局黑名单。
   主体 Kind 用 "resident"(IsInternal 为真):HardPrivateTools 不拦内部委派
   (那条防的是用户主体偷看私人数据),但父声明的 Deny/Allow 仍然生效。

③ 创建点声明、销毁点撤销(生命周期对称)

	SpawnResident      → declareChildPolicy(opts.AllowedTools, opts.DenyTools)
	SpawnRTChild       → 同上
	teardownResidentWith / StopRTChild / StopRTChildren → revokeChildPolicy

   声明**必须在子开始执行之前**(rtLoop / child.Start 之前),否则存在一个
   「子已跑工具、策略还没进内核」的窗口。
   声明失败 ⇒ 回滚整个创建:一个「本该受限却无限」的执行体比创建失败危险得多。

④ 工具面:`resident_agents create` 与 `rt_children create` 新增
   `allowed_tools` / `deny_tools`。描述里写明「**你为它声明**」而非「授权」,
   因为模型很容易把白名单误读成额外授予。

## 判据(4 个变异全部实测咬住)

| 判据 | 变异 |
|---|---|
| TestParentDeclaresChildPolicy | 子退化为 CheckTool(不分主体)⇒ 红 |
| TestChildTeardownRevokesPolicy | teardown 不撤销 ⇒ 红;StopRTChildren 漏撤销 ⇒ 红 |
| TestPolicyDeclaredBeforeChildStarts | 声明挪到 rtLoop 之后 ⇒ 红 |
| TestPrincipalPolicyCanBeRevoked / TestRevokeRejectsTagScope | security 侧撤销语义 |

`TestParentDeclaresChildPolicy` 同时钉三件事:① 父声明的 deny 拦住子
② 同一条策略**不影响父**(主体隔离,不是全局策略)③ 撤销后子恢复。

`TestChildTeardownRevokesPolicy` 用源码断言单列,因为本仓反复吃亏在
「单数路径改了、复数路径漏了」—— `StopRTChild` 注销入站登记而
`StopRTChildren` 漏了,就是这么来的。真销毁需要完整记忆栈,而
「前置不满足就 skip」的判据等于没有。

## 实测(真实整机装配 + 真执行)

	① 父 ExecuteTool(cmd_run)            → 走到分派(未被拦)
	② 父声明 deny=[cmd_run] 后,子的判定  → Allowed=false
	   reason="tool \"cmd_run\" is not permitted for this principal (deny \"cmd_run\")"
	③ 父自己的判定                        → 仍 Allowed(主体隔离)
	④ 撤销后子的判定                      → 恢复 Allowed

build / vet / go test ./...(62 ok / 0 fail)/ -race(kernelmod/all +
agent/core + security)全绿;gofmt 漂移清零。

## 仍未接线

- `ScanContent`(敏感词/注入预警)仍 0 生产调用方
- 公开 SDK 未暴露 `DeclarePrincipalPolicy`(插件无法声明主体策略)
- `OversizeToolReports` / `oversizeHintFor` 仍 0 消费者
2026-10-31 06:35:32 +08:00

237 lines
8.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package core
// 驻留子的**工具面**(对照设计 §7 控制面与 §8 处理表)。
//
// 单工具多动作:父侧一个 `resident_agents`(list/create/send/inspect/compress/reclaim/destroy),
// 子侧两个小工具:`notify_parent`(L3 主动汇报)与 `inputch_note`(主动写处理表)。
import (
"fmt"
"log"
"path/filepath"
"strings"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
"github.com/JianFeeeee/HologramAgent/internal/memory"
)
func strArg(tc agentAPI.ToolCall, key string) string {
s, _ := tc.Arguments[key].(string)
return strings.TrimSpace(s)
}
func splitArg(s string) []string {
if strings.TrimSpace(s) == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// executeResidentAgents 是父的驻留子控制面(单工具多动作)。
func (a *Agent) executeResidentAgents(tc agentAPI.ToolCall) string {
switch action := strArg(tc, "action"); action {
case "", "list":
list := a.Residents()
if len(list) == 0 {
return "当前没有驻留子 agent。"
}
var b strings.Builder
fmt.Fprintf(&b, "驻留子 agent(%d 个):", len(list))
for _, r := range list {
fmt.Fprintf(&b, "\n - %s [%s] inputch=%v 轮次=%d 处理表=%d",
r.ID, r.State, r.InputChs, r.Rounds, r.TableSize)
if r.ContextFull {
b.WriteString(" ⚠️ contextfull")
}
}
return b.String()
case "create":
id := strArg(tc, "id")
tempPath := strArg(tc, "temp_path")
if tempPath == "" {
anchor := a.dataDir
if anchor == "" {
// 兜底:从**主图库路径**推导(<data>/memory/graph.db ⇒ <data>)。
// 为什么不静默失败:这条路径只在"配置漏接线"时走到,
// 静默报错会让线上表现为"工具能调但永远建不出来"(实测就是这样)。
if a.memory != nil && a.memory.Path() != "" {
anchor = filepath.Dir(filepath.Dir(a.memory.Path()))
log.Printf("[resident] data_dir not wired, falling back to main graph dir: %s", anchor)
}
}
if anchor == "" {
return "创建驻留子需要 data_dir 或显式 temp_path(内核未接线 DataDir)"
}
tempPath = filepath.Join(anchor, "residents", id, "graph.db")
}
info, err := a.SpawnResident(ResidentOptions{
ID: id,
TaskPrompt: strArg(tc, "task_prompt"),
InputChs: splitArg(strArg(tc, "input_chs")),
AllowedOutputs: splitArg(strArg(tc, "allowed_outputs")),
Capacity: intArg(tc, "capacity"),
TempPath: tempPath,
// ★ 父为子声明工具策略(用户裁定 2026-10-30)。
AllowedTools: splitArg(strArg(tc, "allowed_tools")),
DenyTools: splitArg(strArg(tc, "deny_tools")),
})
if err != nil {
return fmt.Sprintf("创建驻留子失败: %v", err)
}
return "已创建驻留子: " + MarshalResidentInfo(info)
case "send":
if err := a.SendToResident(strArg(tc, "id"), strArg(tc, "text")); err != nil {
return fmt.Sprintf("发送失败: %v", err)
}
return "已发送(对子而言是 L4 中断)"
case "inspect":
id := strArg(tc, "id")
if id == "" {
return "inspect 需要 id(或先用 action=list)"
}
table, err := a.ResidentTable(id)
if err != nil {
return fmt.Sprintf("查看失败: %v", err)
}
var b strings.Builder
fmt.Fprintf(&b, "驻留子 %s 的 inputch 处理表(%d 条):", id, len(table))
for _, r := range table {
kind := "系统写"
if r.Proactive {
kind = "主动写"
}
fmt.Fprintf(&b, "\n - [%s][%s] %s", r.InputCh, kind, r.Text)
}
if len(table) == 0 {
b.WriteString("\n (尚无记录)")
}
return b.String()
case "compress":
n, err := a.CompressResident(strArg(tc, "id"))
if err != nil {
return fmt.Sprintf("压缩失败: %v", err)
}
return fmt.Sprintf("已压缩子 agent 上下文(丢弃 %d 条旧事件,并发清理其 inputch 处理表);子继续存在", n)
case "reclaim":
id := strArg(tc, "id")
// 残余任务先由父**显式**决定保留还是丢弃(用户 2026-09-19 要求)。
//
// ★ 用 FinishResident 一把做完,不要拆成 ApplyResidual + ReclaimResident:
// 拆开时"解冻到销毁"之间子会继续收新任务,那些任务的结果随子一起丢失
// 且不留痕迹(详见 FinishResident 的说明)。
// onDestroy 里做记忆收割:读子 temp 的活跃三元组并合入主记忆。
// 必须在 Destroy 之前 —— Destroy 会关掉 temp(这正是原先
// ReclaimResident 内部做的顺序)。
var promoted int
residual, rmsg, ferr := a.FinishResidentWith(id, residualPolicyOf(tc), channelDispositionOf(tc),
func(rc *residentChild) error {
p, herr := a.harvestResidentMemory(rc)
if herr != nil {
return fmt.Errorf("合入主记忆失败: %w", herr)
}
promoted = p
return nil
})
if ferr != nil {
return fmt.Sprintf("回收失败: %v", ferr)
}
return fmt.Sprintf("残余任务(%d 条):%s\n已回收(temp 中选中的记录已合入主记忆,promoted=%d,该驻留子已取消)",
residual, rmsg, promoted)
case "destroy":
id := strArg(tc, "id")
// 同上:销毁前先把残余任务交出去,否则它们会随子一起无声消失。
residual, rmsg, ferr := a.FinishResidentWith(id, residualPolicyOf(tc), channelDispositionOf(tc), nil)
if ferr != nil {
return fmt.Sprintf("销毁失败: %v", ferr)
}
return fmt.Sprintf("残余任务(%d 条):%s\n已销毁并移除该驻留子", residual, rmsg)
default:
return fmt.Sprintf("未知 action=%q;可用:list | create | send | inspect | compress | reclaim | destroy", action)
}
}
// reclaimKeepAll 是回收时的默认策略:把子 temp 的活跃记录全部纳入主记忆
// ("哪些纳入"由父的模型决定——这里给的是"全要"这一档)。
func reclaimKeepAll(_ []InputchRecord, triples []memory.Triple) []memory.Triple { return triples }
// residualPolicyOf 从工具参数读残余任务的处置策略(keep/drop)。
//
// 默认 keep:父没明确说丢时,一律转回自己而不是丢弃。
// "宁可多做一件,不可默默丢一条"——吞掉一条输入比多处理一条更糟。
func residualPolicyOf(tc agentAPI.ToolCall) ResidualPolicy {
switch strings.ToLower(strArg(tc, "residual")) {
case "drop", "discard":
return ResidualDrop
case "stash", "park":
// 暂存到原通道,等通道被接回时放出。
return ResidualStash
case "takeover", "take_over", "take":
return ResidualTakeover
default:
return ResidualKeep
}
}
// channelDispositionOf 从工具参数读**通道**的处置方式(release/suspend/takeover)。
//
// 默认 release = 原有行为(归还未分配),保持向后兼容。
func channelDispositionOf(tc agentAPI.ToolCall) ChannelDisposition {
switch strings.ToLower(strArg(tc, "channels")) {
case "suspend", "park":
return ChannelSuspend
case "takeover", "take_over", "take":
return ChannelTakeover
default:
return ChannelRelease
}
}
func intArg(tc agentAPI.ToolCall, key string) int {
switch v := tc.Arguments[key].(type) {
case float64:
return int(v)
case int:
return v
}
return 0
}
// executeNotifyParent 是子的"主动向父发消息"(父侧阶梯 = **L3 中断**)。
func (a *Agent) executeNotifyParent(tc agentAPI.ToolCall) string {
return a.notifyParentFrom(strArg(tc, "text"))
}
// executeInputchNote 是子"主动写入本轮 inputch 的处理信息"。
// 主动写过 ⇒ 本轮系统不再自动写(见 autoRecordInputch)。
func (a *Agent) executeInputchNote(tc agentAPI.ToolCall) string {
text := strArg(tc, "text")
if text == "" {
return "text 不能为空"
}
a.recordInputchNote(text)
return "已记录本轮 inputch 处理信息(本轮系统不会再自动写)"
}
// childInboundChannelHint 是给子看的"父会怎么把消息投给你"的提示(不参与调度)。
func childInboundChannelHint(a *Agent) string { return "sub/" + string(a.id) }
// residentTempDir 返回某个驻留子 temp 存储所在目录(销毁时连同目录丢弃)。
func residentTempDir(tempPath string) string { return filepath.Dir(tempPath) }
var _ = agentIO.InputChannel{} // 保持 agentIO 依赖(工具面未来会用通道登记)