# 策略语法

策略是一份 JSON 文档，四类控制相互独立：

1. `rules`：按顺序匹配工具调用，决定放行、拒绝或转人工审批；
2. `egress` + `sensitive`：跨调用的数据血缘控制；
3. `rate`：会话级调用频率；
4. `hide_denied`：是否把被拒绝的工具从 `tools/list` 中摘掉。

最小可用策略：

```json
{
  "version": 1,
  "name": "read-only",
  "default_decision": "deny",
  "rules": [
    { "id": "read", "tool": "fs.read", "decision": "allow" }
  ]
}
```

## 顶层字段

| 字段 | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| `version` | 整数 | 1 | 目前只接受 1 |
| `name` | 字符串 | `unnamed` | 报告与账本中显示 |
| `default_decision` | `allow` \| `deny` | `deny` | 所有规则都不匹配时的判定 |
| `rules` | 数组 | `[]` | 最多 128 条，`id` 不可重复 |
| `sensitive` | 数组 | `[]` | 声明哪些工具结果需要打血缘标记 |
| `egress` | 对象 | 无 | `{ "tools": [...], "on_taint": "deny" \| "approval" }` |
| `rate` | 对象 | `{window_ms: 60000, max_calls: 60}` | 每条会话独立计数 |
| `budget` | 对象 | 全部为 0（不限制） | 整场会话的累计配额：调用数、参数字节、密钥替换次数 |
| `approval_ttl_ms` | 整数 | 45000 | 审批请求的默认有效期 |
| `min_secret_len` | 整数 | 12 | 血缘提取的最小值长度 |
| `mask_secrets` | 布尔 | true | 把敏感结果中的值替换为 `«mg:<指纹>»`，明文只留在网关内存 |
| `reveal` | 数组 | `[]` | 允许接收真值的目的地；未命中的占位符调用一律拒绝 |
| `hide_denied` | 布尔 | true | 被无条件拒绝的工具不进入 `tools/list` |

字段越界或类型错误会直接拒绝加载，错误信息带 JSON 路径，不回显字段值。

## 规则

```json
{
  "id": "write-report",
  "tool": "fs.write",
  "decision": "approval",
  "reason": "写报告需要人工确认",
  "approval_ttl_ms": 45000,
  "when": [
    { "arg": "path", "glob": ["reports/*", "reports/**"] }
  ]
}
```

| 字段 | 说明 |
| --- | --- |
| `id` | 唯一，1–64 字符；出现在账本、错误响应和报告里 |
| `tool` | 工具名的 glob。`*` 匹配任意字符（含 `.` 和 `/`），`?` 匹配一个字符 |
| `decision` | `allow` / `deny` / `approval` |
| `reason` | 可选的说明，进入拒绝消息与审批台 |
| `when` | 可选，全部条件满足才算命中（AND） |
| `approval_ttl_ms` | 仅对 `approval` 生效，覆盖顶层默认值 |

### `when` 条件

```json
{ "arg": "path", "prefix": ["..", "/", "C:\\"], "negate": true }
```

- `arg`：参数名。取 `params.arguments[arg]`；非字符串值先按 JSON 文本处理；缺失视为空串。
- `glob`：任一匹配即可（OR）。
- `prefix`：任一前缀命中即可（OR）。
- `negate`：把结果取反。常用于“路径不以这些前缀开头”。
- `normalize: "path"`：匹配前先做词法路径规范化（`\` 转 `/`，解析 `.` 与 `..`）。没有它，`reports/../../etc/passwd` 会通过 `reports/*` 这类前缀检查。
- 多个 `when` 之间是 AND。

`glob` 是整串匹配，不做路径规范化。`reports/*` 与 `reports/a/b.md` 不匹配，需要显式写 `reports/**`。

### 求值顺序

```
0. 替身检查      参数含 «mg:…» 时必须有 reveal 规则，否则 secret-reveal 拒绝
1. 频率检查      超出 rate 直接 deny，规则 "rate-limit"
2. 配额检查      调用数 / 参数字节超限直接 deny（budget-calls / budget-bytes）
3. 血缘检查      外发工具的参数含敏感值时，按 egress.on_taint 判定，规则 "egress-taint"
4. 规则列表      第一条命中的规则生效
5. 默认判定      default_decision
```

替身检查排在最前，是因为未授权的占位符一旦放行就会原样到达上游；频率与配额排在血缘之前，是因为它们最便宜且能保护上游。血缘排在规则之前，是因为它表达的是“这个值不能出去”，比“这个工具通常允许”更强；放在规则之后，一条宽泛的 allow 就能绕过它。

规则按顺序匹配而不是“最具体优先”，是为了让策略可读、可预测。代价是顺序写错会遮蔽后面的规则，`moongate check` 会对同工具模式的无条件规则给出遮蔽警告：

```sh
node bin/moongate.mjs check --policy examples/policy.workspace.json
```

建议的顺序：**窄范围 deny（逃逸、危险参数）→ 常规 allow → 需要审批的动作 → 兜底 deny**。

## 会话配额

```jsonc
{ "budget": { "max_calls": 80, "max_arg_bytes": 262144, "max_reveals": 5 } }
```

- `max_calls`：整场会话允许的 `tools/call` 次数；`0` 表示不限制。
- `max_arg_bytes`：累计参数 UTF-8 字节数；`0` 表示不限制。
- `max_reveals`：密钥被换回真值的次数；`0` 表示不限制。审批批准时还会再检查一次，避免多个待批请求合计超限。
- 配额与 `rate` 独立：`rate` 管窗口内频率，`budget` 管整场总量。两者都不通过时分别返回 `rate-limit` / `budget-*`。

## 密钥替身

```jsonc
{
  "mask_secrets": true,
  "sensitive": [
    { "tool": "fs.read", "when": [{ "arg": "path", "glob": ["**/*.env"] }] }
  ],
  "reveal": [
    { "id": "internal-collector", "tool": "net.post",
      "when": [{ "arg": "url", "glob": ["**/internal/**"] }],
      "reason": "the internal collector is allowed to receive the value" }
  ]
}
```

- `sensitive` 命中的结果会在转发给客户端前脱敏：密钥形状的值变成 `«mg:<16 位指纹>»`，明文写入会话内存 vault。
- 客户端参数里出现占位符时，引擎先找 `reveal`：命中则替换回真值再转发；未命中返回 `-32001`，规则 `secret-reveal`，不转发。
- 替换规则与工具规则分开：`rules` 决定“能不能调用”，`reveal` 决定“能不能看见值”。
- 审批摘要基于替换前的参数（只含占位符），审批人看不到明文；账本只记录替换次数与 `reveal_rule`。
- 服务端 guard 没有 vault：遇到占位符直接 `unresolved-secret` 拒绝。

注意：`reveal` 是按目的地授权的。把 `**/internal/**` 写成 `**` 等于对所有外发工具开放真值；`moongate check` 不会阻止这种写法，因为它是策略意图，不是语法错误。

## 能力裁剪

`hide_denied: true` 时，`tools/list` 会过滤掉同时满足以下条件的工具：

- 命中了**无条件**的 `deny` 规则；或
- 没有任何规则命中，且 `default_decision` 是 `deny`。

带 `when` 的 deny 规则不会导致工具被隐藏（它只封掉部分参数），带 `when` 的 allow/approval 会让工具保持可见。

## 敏感来源与血缘

```json
{
  "sensitive": [
    { "tool": "fs.read", "when": [{ "arg": "path", "glob": ["**/*.env", "**/secrets/**"] }] },
    { "tool": "vault.read" }
  ],
  "egress": { "tools": ["net.*", "mail.*"], "on_taint": "deny" }
}
```

被 `sensitive` 命中的工具调用，其结果会被扫描：

1. 按 `[A-Za-z0-9_.-]` 切分，长度 ≥ `min_secret_len`；
2. 至少含一个数字或大写字母；
3. 排除字段名：后面紧跟 `=` 的，或“引号 + 冒号”结构中的键；
4. 每个值取 SHA-256 前 16 位作为指纹，记录来源调用编号；
5. 账本与控制台只出现指纹和 4 字符前缀，不出现原值。

外发工具的参数（递归遍历所有字符串叶子）会做同样的提取。指纹交集非空即判定为携带敏感数据。

阈值越低，拦截越严，误报越多。默认 12 是“多数 API Key / Token”与“普通英文单词”之间的折中；`min_secret_len` 可调，但不建议低于 8。

## 频率

```json
{ "rate": { "window_ms": 60000, "max_calls": 30 } }
```

滑动窗口内统计**所有**工具调用尝试（包括被拒绝的），超过即拒绝。被拒绝的调用也计数，是为了避免 Agent 在拒绝后高频重试形成忙等。

## 常见写法

**只读工作区**

```json
{
  "default_decision": "deny",
  "rules": [
    { "id": "escape", "tool": "*", "decision": "deny",
      "when": [{ "arg": "path", "prefix": ["..", "/", "~"] }] },
    { "id": "read", "tool": "fs.read", "decision": "allow" },
    { "id": "list", "tool": "fs.list", "decision": "allow" }
  ]
}
```

**写操作一律人工确认**

```json
{
  "default_decision": "deny",
  "rules": [
    { "id": "write", "tool": "fs.write", "decision": "approval", "approval_ttl_ms": 60000 }
  ]
}
```

**阻止路径穿越**

```json
{
  "rules": [
    { "id": "deny-escape", "tool": "fs.*", "decision": "deny",
      "reason": "path escapes the workspace",
      "when": [{ "arg": "path", "normalize": "path", "prefix": ["..", "/", "C:/", "~"] }] }
  ]
}
```

`normalize` 是词法的：它不访问文件系统、不解析符号链接、不做 URL 解码。

**允许外发，但禁止携带刚读到的凭据**

```json
{
  "rules": [
    { "id": "read", "tool": "fs.read", "decision": "allow" },
    { "id": "post", "tool": "net.post", "decision": "allow" }
  ],
  "sensitive": [{ "tool": "fs.read", "when": [{ "arg": "path", "glob": ["**/*.env"] }] }],
  "egress": { "tools": ["net.*"], "on_taint": "deny" }
}
```

## 已知限制

- 血缘只匹配**原样出现**的值。Base64、分片、改写会绕过；这是精确匹配的代价。
- 不解析工具语义，`tool` 名称由服务端决定，恶意服务端可以改名规避规则；因此策略应配合“默认拒绝 + 显式允许”。
- `when` 只支持单层参数名，不支持 `a.b.c` 路径。
- 不支持正则，只有 glob 与前缀；这是为了保持求值可预测、无回溯风险。
- 服务端 guard 只执行 `deny`；`approval` 在 guard 里返回 `allow`，审批由网关负责。
- 占位符替换只发生在网关；guard 看到占位符会拒绝，不做替换。
- `min_secret_len` 同时影响血缘与脱敏；调低会增加误伤，调高会漏掉短密钥。
