@sema-agent/server 7.116.0 → 7.117.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/MIGRATION.md +98 -0
  2. package/USAGE.md +32 -27
  3. package/dist/approval-card.d.ts +43 -9
  4. package/dist/approval-card.js +36 -4
  5. package/dist/boot/budget-tracing.js +2 -3
  6. package/dist/boot/config-center.d.ts +2 -2
  7. package/dist/boot/config-center.js +5 -7
  8. package/dist/boot/resolve-spec.js +10 -3
  9. package/dist/boot/runner-deps.d.ts +5 -3
  10. package/dist/boot/runner-deps.js +4 -2
  11. package/dist/brain.d.ts +17 -8
  12. package/dist/brain.js +10 -7
  13. package/dist/config-catalog.js +3 -2
  14. package/dist/config-center/apply-effective.d.ts +6 -16
  15. package/dist/config-center/apply-effective.js +0 -6
  16. package/dist/config-center/facade.d.ts +1 -1
  17. package/dist/config-center/facade.js +1 -1
  18. package/dist/config-center/restart-signal.d.ts +1 -1
  19. package/dist/config-types.d.ts +12 -16
  20. package/dist/config.d.ts +34 -27
  21. package/dist/config.js +23 -19
  22. package/dist/deployment-governance.d.ts +11 -12
  23. package/dist/deployment-governance.js +4 -9
  24. package/dist/governance-overrides.d.ts +20 -0
  25. package/dist/governance-overrides.js +8 -0
  26. package/dist/http/admission.js +7 -7
  27. package/dist/http/approval-sweeps.js +15 -12
  28. package/dist/http/checkpoint-ctx.d.ts +83 -24
  29. package/dist/http/checkpoint-ctx.js +68 -8
  30. package/dist/http/resume-legs.js +11 -15
  31. package/dist/http/retired-skill-manifest.d.ts +1 -1
  32. package/dist/http/route-ctx.d.ts +3 -3
  33. package/dist/http/routes/capabilities.js +3 -3
  34. package/dist/http/routes/runs.js +12 -0
  35. package/dist/model-select.d.ts +16 -5
  36. package/dist/model-select.js +7 -3
  37. package/dist/namespaced-rule-name.d.ts +7 -5
  38. package/dist/observability/fail-open.d.ts +1 -1
  39. package/dist/observability/fail-open.js +1 -1
  40. package/dist/observability/permission-rule-events.d.ts +3 -0
  41. package/dist/observability/permission-rule-events.js +4 -0
  42. package/dist/observability/run-terminal.js +21 -1
  43. package/dist/outbound-dispatcher.d.ts +18 -0
  44. package/dist/outbound-dispatcher.js +96 -10
  45. package/dist/permission-rule-list.d.ts +20 -31
  46. package/dist/plugins/one-time-migrations.d.ts +20 -0
  47. package/dist/plugins/one-time-migrations.js +84 -4
  48. package/dist/router/route-orchestration.d.ts +2 -23
  49. package/dist/rule-entry-list.d.ts +99 -0
  50. package/dist/rule-entry-list.js +110 -0
  51. package/dist/run-local.d.ts +4 -2
  52. package/dist/run-local.js +3 -3
  53. package/dist/runs.js +1 -0
  54. package/dist/runtime-governance.d.ts +5 -21
  55. package/dist/runtime-governance.js +4 -8
  56. package/dist/spec-fields.d.ts +15 -24
  57. package/dist/spec-fields.js +16 -17
  58. package/dist/task-settings.d.ts +18 -18
  59. package/dist/task-settings.js +57 -30
  60. package/dist/tool-approval.d.ts +24 -33
  61. package/dist/tool-approval.js +5 -7
  62. package/dist/trace/core-keyset-guard.d.ts +3 -3
  63. package/dist/trace/engine-notice-wire.d.ts +4 -6
  64. package/dist/trace/engine-notice-wire.js +14 -1
  65. package/package.json +3 -3
  66. package/scripts/drain-check-local.mjs +31 -6
  67. package/scripts/drain-check.mysql.sql +31 -6
  68. package/scripts/drain-check.pg.sql +38 -8
@@ -11,6 +11,9 @@ export declare const PERMISSION_RULE_EVENT_TABLE: readonly [{
11
11
  }, {
12
12
  readonly event: "skill_allowed";
13
13
  readonly meaning: "an activated skill's allowed-tools list cleared the ask for the rest of that run (core decisionReason \"skill\"; today only a skill the request itself carried can do this — this deployment's own skills carry no lists; it clears only the unstated / ordinary questions a persisted allow rule may clear, never a deny / ask rule, an operator list, a governance or shell-floor question, or a mandated one)";
14
+ }, {
15
+ readonly event: "allowed_tools_allowed";
16
+ readonly meaning: "the request's allow list (settings.permissions.allow, sent whole to the engine's allow layer) or the deployment's (APPROVAL_GRANT) cleared the ask (core decisionReason \"request_rule\" / \"deployment_rule\"; the log line carries source and the matched entries, the label carries the event word only)";
14
17
  }];
15
18
  /** 词表里的一个词(闭集)。 */
16
19
  export type PermissionRuleEventWord = (typeof PERMISSION_RULE_EVENT_TABLE)[number]["event"];
@@ -16,6 +16,10 @@ export const PERMISSION_RULE_EVENT_TABLE = [
16
16
  event: "skill_allowed",
17
17
  meaning: "an activated skill's allowed-tools list cleared the ask for the rest of that run (core decisionReason \"skill\"; today only a skill the request itself carried can do this — this deployment's own skills carry no lists; it clears only the unstated / ordinary questions a persisted allow rule may clear, never a deny / ask rule, an operator list, a governance or shell-floor question, or a mandated one)",
18
18
  },
19
+ {
20
+ event: "allowed_tools_allowed",
21
+ meaning: "the request's allow list (settings.permissions.allow, sent whole to the engine's allow layer) or the deployment's (APPROVAL_GRANT) cleared the ask (core decisionReason \"request_rule\" / \"deployment_rule\"; the log line carries source and the matched entries, the label carries the event word only)",
22
+ },
19
23
  ];
20
24
  const PERMISSION_RULE_EVENT_KINDS = new Set(PERMISSION_RULE_EVENT_TABLE.map((r) => `${PERMISSION_TRACE_PREFIX}${r.event}`));
21
25
  export function isPermissionRuleEventKind(kind) {
@@ -416,7 +416,7 @@ const RISK_DESCRIPTOR_TREATMENT = {
416
416
  shellGateDoctrine: "verbatim",
417
417
  summary: "text",
418
418
  touchedPaths: "text[]",
419
- shadowedRule: "text",
419
+ allowRuleShadowed: "allow-rule-shadows",
420
420
  probeReason: "text",
421
421
  probeCause: "probe-cause",
422
422
  };
@@ -473,12 +473,32 @@ export function redactRiskDescriptor(rd) {
473
473
  out[key] = np;
474
474
  break;
475
475
  }
476
+ case "allow-rule-shadows": {
477
+ const recs = r[key];
478
+ if (!Array.isArray(recs))
479
+ break;
480
+ out[key] = recs.map((rec) => {
481
+ if (rec === null || typeof rec !== "object" || Array.isArray(rec))
482
+ return rec;
483
+ const o = rec;
484
+ return Array.isArray(o.entries) ? { ...o, entries: strs(o.entries) } : rec;
485
+ });
486
+ break;
487
+ }
476
488
  default: {
477
489
  const unhandled = treatment;
478
490
  throw new Error(`unhandled leaf treatment: ${String(unhandled)}`);
479
491
  }
480
492
  }
481
493
  }
494
+ for (const [key, v] of Object.entries(r)) {
495
+ if (Object.hasOwn(RISK_DESCRIPTOR_TREATMENT, key))
496
+ continue;
497
+ if (typeof v === "string")
498
+ out[key] = str(v);
499
+ else if (Array.isArray(v))
500
+ out[key] = strs(v);
501
+ }
482
502
  return changed ? out : rd;
483
503
  }
484
504
  const STATS_KEY_TREATMENT = {
@@ -55,6 +55,24 @@
55
55
  * connect ECONNREFUSED 127.0.0.1:1)`(message 不带代理地址 —— 主会话 F3 裁定,见 {@link OutboundProxyError})。core 的句子一字不改(它逐层展开 cause 链,包装层就是链上多一节)。隧道建好之后的失败(目标的 TLS、
56
56
  * 目标的响应)不经这个接缝,不冠代理 —— 那是目标的事。
57
57
  *
58
+ * ── http 目标:绝对 URL 形,不建隧道(S-804;test 黑盒验收 A1)──────────────────────────────────────────────────
59
+ * 病:undici `ProxyAgent` 的 `proxyTunnel` **代码缺省是 true**(7.30.0 `lib/dispatcher/proxy-agent.js` 构造器 `const { proxyTunnel = true } = opts`;它的文档写缺省 false,
60
+ * 两处相反,以代码为准)⇒ 修前连 **http 网关**也被对代理发 `CONNECT host:80`;只放行 `:443` CONNECT 的代理(squid 缺省)拒掉它,run 约 175 s 后失败。
61
+ * 现在构造 `EnvHttpProxyAgent` 时显式 `proxyTunnel:false`:**http 目标 ⇒ 绝对 URL 形请求**(`GET http://host/path`,undici 的 `Http1ProxyWrapper`),**https 目标 ⇒ 照旧 CONNECT**。
62
+ * 范围是 undici 划的:目标 `http:` **且**代理 URL 也是 `http:`;`https://` 代理 + http 目标 undici 不走绝对 URL 形,仍 CONNECT。
63
+ * 三件事照旧成立(逐项读实现得出):
64
+ * · 代理认证头:`Proxy-Authorization` 由 `ProxyAgent` 构造器从 userinfo 译一次、两条腿共用(隧道腿放进 CONNECT 请求头,绝对 URL 形腿放进每条请求头);
65
+ * · `NO_PROXY` 与回环恒直连:路由在 `EnvHttpProxyAgent` / 本文件外壳里判,先于选哪条腿,不受影响;
66
+ * · 代理**拨号**失败(拒连 / 解析不了 / 超时 / 代理 TLS)⇒ 仍是 {@link OutboundProxyError}(`factory` 接缝,见 {@link createProxyAgentFactory}),
67
+ * 建连失败的观察照报 `proxyInUse`;`targetHost` 读对目标(绝对 URL 形请求的 `origin` 是代理、目标在 `path`,见 {@link targetHostOf})。
68
+ * 🔴 **与 CONNECT 腿不同、读者要知道的三件**(绝对 URL 形里代理的回话是普通 HTTP 响应,不是 CONNECT 的回包):
69
+ * ① 代理的 **407** ⇒ {@link OutboundProxyError},与 CONNECT 腿的 407 **同类、同一只铸点**({@link proxyRefusal})。407 按协议只可能来自代理;undici 的 `Http1ProxyWrapper`
70
+ * 自己把它转成 `InvalidArgumentError`(只留文案、没有状态码位,按结构认不出),所以改在代理客户端那一层({@link ProxyHopClient})**按响应状态码**先认、先于 undici 的转换,
71
+ * 不看任何文案;
72
+ * ② 代理的 **403** 等其余拒绝状态是**普通响应**原样交给调用方 —— 在 HTTP 层与网关自己回的 403 **原理上分不开**(无通用判别式),调用方读到的是「网关回了 403」(接受并披露);
73
+ * ③ 请求发出之后的失败(代理中途断流 / 收下连接就关)与目标自己的失败同形、不冠代理(与隧道建好之后的失败同理)。
74
+ * 保密面:http 目标的请求行与头(含发给网关的凭据头)以明文过代理,代理看得到完整 URL;要对代理保密就用 https 网关(隧道里是密文)。
75
+ *
58
76
  * ── 安全轴 ───────────────────────────────────────────────────────────────────────────────────────
59
77
  * 代理 URL 的 userinfo 是凭据(undici 把它译成 `Proxy-Authorization: Basic …`)。它只进 dispatcher,不进任何观测面:姿态
60
78
  * ({@link buildOutboundProxyPosture})、拒启句与归因句同一把尺 {@link displayProxyUrl}(形干净只出 `scheme//host[:port]`、过全仓那一只
@@ -1,4 +1,4 @@
1
- import { Agent, Dispatcher, EnvHttpProxyAgent, getGlobalDispatcher, Pool, setGlobalDispatcher } from "undici";
1
+ import { Agent, Client, Dispatcher, EnvHttpProxyAgent, getGlobalDispatcher, Pool, setGlobalDispatcher } from "undici";
2
2
  import { isIP } from "node:net";
3
3
  import diagnosticsChannel from "node:diagnostics_channel";
4
4
  import { buildDisplaySafeUrl, DISPLAY_SAFE_URL_TOKEN } from "./trace/redact.js";
@@ -128,9 +128,12 @@ class ProxyDialPool extends Pool {
128
128
  if (data.statusCode === 200)
129
129
  return undefined;
130
130
  data.socket.on("error", () => undefined).destroy();
131
- return new OutboundProxyError(this.shown, this.from, new Error(`proxy answered CONNECT with HTTP ${String(data.statusCode)}`));
131
+ return proxyRefusal(this.shown, this.from, data.statusCode, "CONNECT");
132
132
  }
133
133
  }
134
+ function proxyRefusal(shown, from, statusCode, leg) {
135
+ return new OutboundProxyError(shown, from, new Error(`proxy answered ${leg} with HTTP ${String(statusCode)}`));
136
+ }
134
137
  function withDialDeadline(options) {
135
138
  const inner = Reflect.get(options, "connect");
136
139
  if (typeof inner !== "function")
@@ -153,12 +156,93 @@ function withDialDeadline(options) {
153
156
  };
154
157
  return { ...options, connect };
155
158
  }
156
- function createProxyClientFactory(v) {
159
+ function createProxyIdentity(v) {
157
160
  const vars = [["HTTP_PROXY", v.httpProxy], ["HTTPS_PROXY", v.httpsProxy]].filter(([, raw]) => raw !== "" && URL.canParse(raw));
161
+ return (url) => {
162
+ const same = vars.filter(([, raw]) => new URL(raw).href === url.href);
163
+ const first = same[0];
164
+ return first === undefined ? undefined : { shown: displayProxyUrl(first[1]), from: same.map(([name]) => name).join("/") };
165
+ };
166
+ }
167
+ function createProxyClientFactory(v) {
168
+ const identify = createProxyIdentity(v);
169
+ return (origin, options) => {
170
+ const { shown, from } = identify(origin) ?? { shown: displayProxyUrl(origin.href), from: "proxy variable" };
171
+ return new ProxyDialPool(origin, withDialDeadline(options), shown, from);
172
+ };
173
+ }
174
+ const PROXY_AUTH_REQUIRED = 407;
175
+ function interceptProxyAuthRequired(handler, refuse) {
176
+ let abort;
177
+ return new Proxy(handler, {
178
+ get(target, prop) {
179
+ const member = Reflect.get(target, prop, target);
180
+ if (typeof member !== "function")
181
+ return member;
182
+ if (prop === "onConnect") {
183
+ return (...args) => {
184
+ const candidate = args[0];
185
+ if (typeof candidate === "function")
186
+ abort = (err) => void Reflect.apply(candidate, undefined, [err]);
187
+ return Reflect.apply(member, target, args);
188
+ };
189
+ }
190
+ if (prop === "onHeaders") {
191
+ return (...args) => {
192
+ if (args[0] !== PROXY_AUTH_REQUIRED)
193
+ return Reflect.apply(member, target, args);
194
+ const refusal = refuse();
195
+ if (abort !== undefined)
196
+ abort(refusal);
197
+ else {
198
+ const deliver = Reflect.get(target, "onError", target);
199
+ if (typeof deliver === "function")
200
+ Reflect.apply(deliver, target, [refusal]);
201
+ }
202
+ return undefined;
203
+ };
204
+ }
205
+ return member.bind(target);
206
+ },
207
+ });
208
+ }
209
+ class ProxyHopClient extends Client {
210
+ shown;
211
+ from;
212
+ constructor(origin, options, shown, from) {
213
+ super(origin, options);
214
+ this.shown = shown;
215
+ this.from = from;
216
+ }
217
+ dispatch(options, handler) {
218
+ return super.dispatch(options, interceptProxyAuthRequired(handler, () => proxyRefusal(this.shown, this.from, PROXY_AUTH_REQUIRED, "the request")));
219
+ }
220
+ }
221
+ function withProxyAttribution(options, shown, from) {
222
+ const inner = Reflect.get(options, "connect");
223
+ const connect = typeof inner !== "function"
224
+ ? undefined
225
+ : (opts, callback) => {
226
+ Reflect.apply(inner, undefined, [
227
+ opts,
228
+ (err, socket) => {
229
+ if (err !== null)
230
+ callback(new OutboundProxyError(shown, from, err), null);
231
+ else if (socket !== null)
232
+ callback(null, socket);
233
+ else
234
+ callback(new OutboundProxyError(shown, from, new Error("proxy connector returned neither a socket nor an error")), null);
235
+ },
236
+ ]);
237
+ };
238
+ return { ...options, ...(connect !== undefined ? { connect } : {}), factory: (origin, clientOptions) => new ProxyHopClient(origin, { ...clientOptions }, shown, from) };
239
+ }
240
+ function createProxyAgentFactory(v) {
241
+ const identify = createProxyIdentity(v);
158
242
  return (origin, options) => {
159
- const same = vars.filter(([, raw]) => new URL(raw).href === origin.href);
160
- const raw = same[0]?.[1] ?? origin.href;
161
- return new ProxyDialPool(origin, withDialDeadline(options), displayProxyUrl(raw), same.length > 0 ? same.map(([name]) => name).join("/") : "proxy variable");
243
+ const text = String(origin);
244
+ const proxy = URL.canParse(text) ? identify(new URL(text)) : undefined;
245
+ return new Pool(origin, proxy === undefined ? { ...options } : withProxyAttribution(options, proxy.shown, proxy.from));
162
246
  };
163
247
  }
164
248
  export const OUTBOUND_PROXY_INVALID_CODE = "config.outbound_proxy_invalid";
@@ -171,7 +255,7 @@ export function createOutboundDispatcher(env = process.env) {
171
255
  throw refuse("ambiguous-url");
172
256
  let proxied;
173
257
  try {
174
- proxied = new EnvHttpProxyAgent({ httpProxy: v.httpProxy, httpsProxy: v.httpsProxy, noProxy: v.noProxy, clientFactory: createProxyClientFactory(v) });
258
+ proxied = new EnvHttpProxyAgent({ httpProxy: v.httpProxy, httpsProxy: v.httpsProxy, noProxy: v.noProxy, proxyTunnel: false, clientFactory: createProxyClientFactory(v), factory: createProxyAgentFactory(v) });
175
259
  }
176
260
  catch (err) {
177
261
  throw refuse(err instanceof Error && "code" in err && typeof err.code === "string" ? err.code : "invalid");
@@ -209,8 +293,10 @@ export function classifyOutboundConnectFailure(error) {
209
293
  }
210
294
  return undefined;
211
295
  }
212
- function targetHostOf(origin) {
213
- const text = typeof origin === "string" ? origin : origin instanceof URL ? origin.href : "";
296
+ function targetHostOf(request) {
297
+ const path = Reflect.get(request, "path");
298
+ const origin = Reflect.get(request, "origin");
299
+ const text = typeof path === "string" && /^https?:\/\//i.test(path) ? path : typeof origin === "string" ? origin : origin instanceof URL ? origin.href : "";
214
300
  return URL.canParse(text) ? new URL(text).host : "(unreadable origin)";
215
301
  }
216
302
  let observer;
@@ -263,7 +349,7 @@ function startConnectObserver(logger, nowMs) {
263
349
  call.last = { kind: "other" };
264
350
  return;
265
351
  }
266
- const failure = { targetHost: targetHostOf(Reflect.get(request, "origin")), ...verdict };
352
+ const failure = { targetHost: targetHostOf(request), ...verdict };
267
353
  current.last = { ...failure, atMs: Date.now() };
268
354
  logger.warn("outbound_connect_failed", {
269
355
  ...failure,
@@ -1,25 +1,23 @@
1
1
  /**
2
- * **规则表的唯一编译口** —— 一张工具名规则表(`allow` / `ask` / `deny` 之一)编成一只 `ToolPolicy`。
2
+ * **规则表的唯一编译口** —— 一张工具名规则表(`ask` / `deny` 之一)编成一只 `ToolPolicy`。读者:请求腿的 `settings.permissions.{ask,deny}`
3
+ * 两桶(`task-settings.ts` 的 `deriveSettingsPolicy`)。
3
4
  *
4
- * 两类读者,同一只编译口(编译与校验各只有一份,不在别处另写第二份文法):
5
- * · 请求腿的 `settings.permissions.{allow,ask,deny}` 三桶(`task-settings.ts` 的 `deriveSettingsPolicy`);
6
- * · 运维的 allow 规则 `APPROVAL_GRANT`(`runtime-governance.ts` 的 `applyRuntimeGovernance`,经部署治理链的单一构造口
7
- * `deployment-governance.ts` 交进来 —— HTTP 提交 / durable 续跑 / 停驻后台 agent 的跨副本赎回 / run-local 四条腿同装)。
5
+ * 🔴 core 7.42.0 起策略只说 deny / ask:allow 规则有且只有引擎 allow 层的两个席位 —— 请求的 `settings.permissions.allow` 整份投
6
+ * `TaskSpec.allowedTools`(`task-settings.ts` 的 `applyTaskSettings`),运维的 `APPROVAL_GRANT` 投 `RunnerDeps.allowedTools`
7
+ * (`boot/runner-deps.ts` 的部署姿态座席族)。本口的 `behavior` 型直接是 core 的 `PermissionRule["behavior"]`(不手抄:core 收窄,这里跟着收窄)。
8
8
  *
9
- * 为什么住在一只叶模块里(不留在 `task-settings.ts`):运维那一侧的启动校验在 `config.ts` 里跑(坏值 = 拒启),而
10
- * `task-settings.ts` 值引 `config.ts` —— 编译口留在那里,`config.ts` 要调它就闭合一只 ESM 装载环。本模块只 import core 与
11
- * `text-bidi.ts`(纯叶),三处读者引它都不成环。
9
+ * 为什么住在一只叶模块里(不留在 `task-settings.ts`):本模块只 import core 与 `text-bidi.ts`(纯叶),读者引它不成环。
12
10
  */
13
- import { type PathRuleBases, type RuleBehavior, type ToolPolicy } from "@sema-agent/core";
14
- /** 一张表里规则条数的上限 —— 请求腿 wire 门(`task-settings.ts` 的三桶判形)读的也是这一个名字:core 的缺省帽是 256,
11
+ import { type PathRuleBases, type PermissionRule, type ToolPolicy } from "@sema-agent/core";
12
+ /** 一张表里规则条数的上限 —— 请求腿 wire 门(`task-settings.ts` 的 deny / ask 两桶判形)读的也是这一个名字:core 的缺省帽是 256,
15
13
  * 不对齐的话第 257 条起编译当场抛,等于服务端自己收下的东西自己反悔。 */
16
14
  export declare const MAX_PERMISSION_RULES_PER_LIST = 1000;
17
- /** A name-keyed rule-list ToolPolicy: a tool named by `names` gets `behavior` (an `allow` hit states the allow with
18
- * `decisionReason: "rule"`; `ask` tightens to an approval gate; `deny` hard-blocks); every other tool is out of
19
- * scope (`defaultAction: "allow"` = this policy has no opinion about anything it does not name). Composed deny-wins
20
- * with the rest of the fold, so `ask` / `deny` only ever tighten and an `allow` never overrides a deny or an ask.
15
+ /** A name-keyed rule-list ToolPolicy: a tool named by `names` gets `behavior` (`ask` tightens to an approval gate;
16
+ * `deny` hard-blocks); every other tool is out of scope (`defaultAction: "allow"` = this policy has no opinion about
17
+ * anything it does not name — core 7.42.0: it states nothing and clears nothing). Composed deny-wins with the rest of
18
+ * the fold, so both only ever tighten.
21
19
  *
22
- * `source` = 这张表**写在哪**(请求腿 `settings.permissions.<桶>`;运维腿 `APPROVAL_GRANT`)。它盖进每条规则(core 的 deny / ask
20
+ * `source` = 这张表**写在哪**(请求腿 `settings.permissions.<桶>`)。它盖进每条规则(core 的 deny / ask
23
21
  * 句里点名它:`flagged by permission rule "mcp__figma__*" (settings.permissions.ask)`),编不出来时的错误句也点名它;只是出处,
24
22
  * 不带任何优先级(层序在各自的折叠里)。
25
23
  *
@@ -32,7 +30,7 @@ export declare const MAX_PERMISSION_RULES_PER_LIST = 1000;
32
30
  * `deny:["mcp__figma__*"]` 却**真拦** —— deny 走 `createAllowDenyPolicy`,它用的是 core 的
33
31
  * `namespacedCoveringEntries/Hit`。同族分家,差的只是解析器。
34
32
  *
35
- * ⇒ **规则表的 policy 面现在同源**:三种态度都由 `createPermissionRulePolicy` 的 covering 车道解,底下是
33
+ * ⇒ **规则表的 policy 面现在同源**:两种态度都由 `createPermissionRulePolicy` 的 covering 车道解,底下是
36
34
  * core 的**同一只** `namespacedRuleNameCovers`(协议无关:`mcp__`/`a2a__` 同解;peer 段精确比;工具段 `*`/glob 覆盖)。
37
35
  * 本仓**不自写第二套 glob** —— 那正是当年分家的成因。
38
36
  *
@@ -56,26 +54,17 @@ export declare const MAX_PERMISSION_RULES_PER_LIST = 1000;
56
54
  * 这里;真正新增的是「名字里带反斜杠」「单条 > 512 字符」这两族永不匹配的拼法。
57
55
  * ✅ **不对称已收口([ref]①,[ref] 销)**:codex finding-2 当年如实登记的「同样两族拼法在 `deny`
58
56
  * 上仍是静默 no-op」已修 —— 判据搬进单点成文的 `unmatchableToolNamesRefusal`,它拿 core 自己的 `validatePermissionRules`
59
- * 对同一串跑一遍文法(不自铸第二套判词),于是三张表对这两族说同一句话。S-654 (c) 起 `allow` 也收。
57
+ * 对同一串跑一遍文法(不自铸第二套判词),于是各表对这两族说同一句话。
60
58
  * ⚠️ 存量 resume:带这两族拼法的**已存 body** 在 resume 腿会 422(resolve-spec 翻)。这**不是**新造的姿态——A3
61
59
  * (`rejectUnmatchableSettingsNames`)早就对存量 body 里的退役名/短名同样抛,且 `applyTaskSettings` 腿有专门的回归钉。
62
60
  * 判据同一条:一个**静默不存在的审批门**比一次响亮失败更贵。codex 建议的「给非法存量条目保留旧的逐字比对」被**驳回**:
63
61
  * 那正是把 fail-open 请回来。
64
- * · `caps.maxRules` 对齐 {@link MAX_PERMISSION_RULES_PER_LIST}(理由见那只常量)。core 的 set-level cap 连 `skip` 都照抛,
65
- * 所以运维腿的启动校验还要**试编一次**(`approvalGrantRefusal`)才拦得住超帽 —— 判据仍是本函数这一只。
66
- * · 不再自铸 `decisionReason:"rule"` —— core 的规则策略本就不铸这个键(allow 命中由 core 自己铸 `"rule"`),且 core 侧唯一区分它的
62
+ * · `caps.maxRules` 对齐 {@link MAX_PERMISSION_RULES_PER_LIST}(理由见那只常量);请求腿的受理门先按同一个数拒超帽(400)。
63
+ * · 不自铸 `decisionReason` —— core 的规则策略本就不铸这个键,且 core 侧唯一区分它的
67
64
  * 两处(prepare-task 的继承 grant 复用判据、park 的 rule-suggestion 门)对 `"rule"` 与 `undefined` **同待**(只把 `"hook"`
68
65
  * 排除在外),dist 亲读在案。审计归因改由 `matchedAskRule` + `source` 承担,后者比前者强:它还让 core 的 auto-mode 分类器
69
66
  * **拒绝**替人清掉这条 ask(「运维写下的『每次问我』不是分类器的犹豫」),方向是收紧。
70
- *
71
- * 🔴 **S-654 (c)(7.115.0,core 7.40.0 #1065):三种态度同一个编译口,`allow` 也走这里** —— `behavior: "allow"` 的规则命中 = core 带
72
- * `decisionReason: "rule"` 的放行 —— allow 规则给出「有人表态」—— 它回答的是没人表态的服务器工具 / A2A 技能调用;内置工具上引擎自己的写门、shell 检查照问,deny / ask 照赢(与参照实现的 `permissions.allow` 在内置工具上**不同**:那边 `allow: ["Edit"]` 跳过编辑确认,这里不放掉引擎的写门);未命中 = `defaultAction: "allow"` 的裸 allow
73
- * = 不表态、照常判。修前请求腿的 allow 编成 `createAllowDenyPolicy({ allow })` 的**名单**(名单在场 ⇒ 名单外的工具一律拒),与写它的人要的
74
- * 「替这些工具表态」方向相反;终端因此从不投这只桶。core 7.40.0 起没人表态的服务器工具调用要问,allow 规则就是那张卡的常设答案
75
- * (`mcp__<server>__*` 一条覆盖整台服务器)。allow 规则**不越过** deny / ask:折叠 deny > ask > allow,任何一层的 deny / ask 照赢;
76
- * 它只在没人反对时给出那句表态。core 的 allow 文法只收工具名形(精确名 / `mcp__`·`a2a__` covering 形),内容形放行(`Bash(npm:*)` /
77
- * `Edit(src/**)` / 参数级)core 拒编,两条腿与 deny / ask 同一个出口(判据 `unmatchableToolNamesRefusal`)。
78
- * 7.115.0 起运维的 `APPROVAL_GRANT` 也编在这里(无人值守腿没有请求体,那是运维为自己接的服务器表态的唯一位置)。 */
79
- /** `behavior` = 这张表的态度 —— 型直接是 core 的 `RuleBehavior`(不手抄:core 加一种态度,这里与它同步、读者 tsc 上见到)。 */
80
- export declare function createRuleListPolicy(names: readonly string[], behavior: RuleBehavior, source: string, pathBases: PathRuleBases | undefined): ToolPolicy;
67
+ */
68
+ /** `behavior` = 这张表的态度 —— 型直接是 core 的 `PermissionRule["behavior"]`(不手抄:core 加 / 减一种态度,这里与它同步、读者 tsc 上见到)。 */
69
+ export declare function createRuleListPolicy(names: readonly string[], behavior: PermissionRule["behavior"], source: string, pathBases: PathRuleBases | undefined): ToolPolicy;
81
70
  //# sourceMappingURL=permission-rule-list.d.ts.map
@@ -24,6 +24,10 @@ export interface OneTimeMigrationReport {
24
24
  readonly sessionIncarnationsBackfilled: number;
25
25
  /** M6:本次给「只有句子」的死亡判决行补上机读码的 run 行数(SQL `task_run` / local `run.json`)。非零 = 这次迁移真写了存量行(与 M4 同一条纪律)。 */
26
26
  readonly holderDeathCodeRowsBackfilled: number;
27
+ /** M8:本次把 `checkpoint.risk_descriptor` 的旧披露键 `shadowedRule` 改写成 `allowRuleShadowed` 的行数(SQL 独有;local 恒 0)。非零 = 真写了存量行。 */
28
+ readonly allowRuleShadowRowsMigrated: number;
29
+ /** M8:带旧键字样、但这一格的 JSON 读不懂 ⇒ 没改写的行数(留给读口自己的响亮臂;非零 = 运维该看一眼这些行)。SQL 独有;local 恒 0。 */
30
+ readonly allowRuleShadowRowsUnreadable: number;
27
31
  }
28
32
  /**
29
33
  * S-590 —— 一条一次性迁移的窗(登记表的一行;纯数据)。字段注写着每个值的取证方式,改一行之前照着重核。
@@ -95,6 +99,22 @@ export declare class RetiredSteerSeatError extends Error {
95
99
  * @throws {RetiredSteerSeatError} M2 探到非空的退役席位(唯一的拒启臂)。
96
100
  */
97
101
  export declare function runSqlOneTimeMigrations(dialect: "tidb" | "pg", pool: MysqlPool | PgPool): Promise<OneTimeMigrationReport>;
102
+ /** M8 一行改写的结论:改写后的文本 / 这一行不用动 / 读不懂(解析错误原样交出,调用方计数进读数)。 */
103
+ export type ShadowedRuleRewrite = {
104
+ readonly kind: "rewrite";
105
+ readonly text: string;
106
+ } | {
107
+ readonly kind: "untouched";
108
+ } | {
109
+ readonly kind: "unreadable";
110
+ readonly error: unknown;
111
+ };
112
+ /**
113
+ * M8 的**一行**改写(纯函数;导出给钉用):`risk_descriptor` 的 JSON 文本里顶层带字符串 `shadowedRule` ⇒ 改写(旧键删、`allowRuleShadowed`
114
+ * 由旧值铸一条 `persisted` 记录;行上已经有 `allowRuleShadowed` ⇒ 只删旧键,不并);不是对象 / 没有旧键 / 旧键不是字符串 ⇒ `untouched`;
115
+ * JSON 读不懂 ⇒ `unreadable`(这一行不动,交给读口自己的响亮臂;迁移不替它猜,但把它数进读数,不静默)。
116
+ */
117
+ export declare function migrateShadowedRuleDescriptor(text: string): ShadowedRuleRewrite;
98
118
  /**
99
119
  * M3 的**纯**改写(local 车道的账本行逐条走它;SQL 两腿在服务端用等价的 JSON 路径表达同一件事)。
100
120
  * 不改原对象(调用方与账本共享引用):有变更时换一只信封,原记录一个字节不动;无变更 ⇒ 返回
@@ -1,6 +1,6 @@
1
1
  import { acquireEnsureSchemaLock, releaseEnsureSchemaLock } from "./tidb-pool.js";
2
2
  import { acquirePgEnsureSchemaLock, releasePgEnsureSchemaLock } from "./pg-pool.js";
3
- import { jsonColumnTextExpr } from "./sql-json-column.js";
3
+ import { decodeJsonColumn, isJsonColumnBodyProblem, jsonColumnText, jsonColumnTextExpr, readNonNullJsonValueColumn } from "./sql-json-column.js";
4
4
  import { SESSION_META_INCARNATION_COLUMN, mintSessionIncarnation } from "../session-incarnation.js";
5
5
  import { RUN_HOLDER_DEATH_CODES, RUN_ORPHANED_ERROR, RUN_STALLED_ERROR } from "./store-contracts.js";
6
6
  export const ONE_TIME_MIGRATIONS = [
@@ -76,6 +76,29 @@ export const ONE_TIME_MIGRATIONS = [
76
76
  "丢掉的 steer 不再告诉用户;`GET /v1/runs/:id` 上 `errorCode` 缺席、只剩 `error` 句子",
77
77
  reportKeys: ["holderDeathCodeRowsBackfilled"],
78
78
  },
79
+ {
80
+ id: "M7",
81
+ introducedIn: "7.117.0",
82
+ releasedOn: "2026-10-06",
83
+ removableAfter: "2026-11-05",
84
+ removal: "删 `http/checkpoint-ctx.ts` 的读法记号判据与拒臂(`allowListReadingUnstated` / `allowListReadingEraOf` / `allowListReadingRefusal` / " +
85
+ "`PERMISSIONS_ALLOW_READINGS` / `ALLOW_LIST_READING_ERAS`、`buildCheckpointCtx` 里的记号写点)、`CheckpointCtx.permissionsAllowReading`、" +
86
+ "`resume-legs.ts` 两个判点与 `approval-sweeps.ts` 的跳过臂、随包排空判据 `permissions-allow-list`(两份 SQL + 本地脚本那一条)",
87
+ afterRemoval: "没有响亮臂:删码之后仍停驻、体里带非空 allow 的旧读法行(把 `APPROVAL_TERMINAL_BACKSTOP_MS` 调长的部署,或跳过了带本条的版本直接升的库)会被按" +
88
+ "当前读法静默重放(名单时代的行:名单外的工具放开;表态时代的行:普通问不再问)—— 升级前按 MIGRATION 7.117.0 节跑排空判据清零",
89
+ reportKeys: [],
90
+ },
91
+ {
92
+ id: "M8",
93
+ introducedIn: "7.117.0",
94
+ releasedOn: "2026-10-06",
95
+ removableAfter: "2026-11-05",
96
+ removal: "删 migrateOn 的 M8 步(`checkpoint.risk_descriptor` 的旧键扫描 + CAS 回写)与 `migrateShadowedRuleKey` / `migrateShadowedRuleDescriptor`;" +
97
+ "报告、日志行与 `NO_SQL_MIGRATIONS` 的 `allowRuleShadowRowsMigrated` / `allowRuleShadowRowsUnreadable`",
98
+ afterRemoval: "没有响亮臂:删码之后仍带旧键的停驻行(跳过了带本条的版本直接升的库)在运维待批队列上只缺结构化披露 `allowRuleShadowed`;旧键的文本仍经脱敏叶表" +
99
+ "「认不得的键按内容脱敏」一条脱敏后原样显示,卡上 `message` 里引擎写好的那句话不受影响",
100
+ reportKeys: ["allowRuleShadowRowsMigrated", "allowRuleShadowRowsUnreadable"],
101
+ },
79
102
  ];
80
103
  export const NO_SQL_MIGRATIONS = {
81
104
  retiredSnapshotTable: "absent",
@@ -85,6 +108,8 @@ export const NO_SQL_MIGRATIONS = {
85
108
  auditDetailJsonNullRows: 0,
86
109
  sessionIncarnationColumn: "lazy-on-read",
87
110
  sessionIncarnationsBackfilled: 0,
111
+ allowRuleShadowRowsMigrated: 0,
112
+ allowRuleShadowRowsUnreadable: 0,
88
113
  };
89
114
  const RETIRED_SNAPSHOT_TABLE = "snapshot_manifest";
90
115
  const RETIRED_STEER_SEAT_COLUMN = "pending_steer";
@@ -224,6 +249,7 @@ async function migrateOn(db, dialect) {
224
249
  (await db.exec(dialect === "tidb"
225
250
  ? "UPDATE task_run SET error_code = ? WHERE status = 'failed' AND error_code IS NULL AND (error = ? OR error LIKE ?)"
226
251
  : "UPDATE task_run SET error_code = $1 WHERE status = 'failed' AND error_code IS NULL AND (error = $2 OR error LIKE $3)", [RUN_HOLDER_DEATH_CODES.orphaned, RUN_ORPHANED_ERROR, `${RUN_ORPHANED_ERROR} (%`]));
252
+ const { migrated: allowRuleShadowRowsMigrated, unreadable: allowRuleShadowRowsUnreadable } = await migrateShadowedRuleKey(db, dialect);
227
253
  let retiredSnapshotTable = "absent";
228
254
  let retiredSnapshotRows = 0;
229
255
  let retiredSteerSeatColumn = "absent";
@@ -256,9 +282,59 @@ async function migrateOn(db, dialect) {
256
282
  }
257
283
  return {
258
284
  retiredSnapshotTable, retiredSnapshotRows, retiredSteerSeatColumn, retiredSteerSeatStaleRows, askOriginRowsMigrated, auditDetailJsonNullRows,
259
- sessionIncarnationColumn, sessionIncarnationsBackfilled, holderDeathCodeRowsBackfilled,
285
+ sessionIncarnationColumn, sessionIncarnationsBackfilled, holderDeathCodeRowsBackfilled, allowRuleShadowRowsMigrated, allowRuleShadowRowsUnreadable,
260
286
  };
261
287
  }
288
+ const RETIRED_SHADOWED_RULE_KEY = "shadowedRule";
289
+ export function migrateShadowedRuleDescriptor(text) {
290
+ let parsed;
291
+ try {
292
+ parsed = decodeJsonColumn(text, readNonNullJsonValueColumn, "checkpoint.risk_descriptor (M8 rewrite)");
293
+ }
294
+ catch (error) {
295
+ if (isJsonColumnBodyProblem(error))
296
+ return { kind: "unreadable", error };
297
+ throw error;
298
+ }
299
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
300
+ return { kind: "untouched" };
301
+ const { [RETIRED_SHADOWED_RULE_KEY]: old, ...rest } = parsed;
302
+ if (typeof old !== "string")
303
+ return { kind: "untouched" };
304
+ const next = Object.hasOwn(rest, "allowRuleShadowed") ? rest : { ...rest, allowRuleShadowed: [{ member: "persisted", entries: [old] }] };
305
+ return { kind: "rewrite", text: JSON.stringify(next) };
306
+ }
307
+ const SHADOWED_RULE_BATCH = 500;
308
+ async function migrateShadowedRuleKey(db, dialect) {
309
+ const like = `%"${RETIRED_SHADOWED_RULE_KEY}"%`;
310
+ let migrated = 0;
311
+ let unreadable = 0;
312
+ let after = "";
313
+ for (;;) {
314
+ const rows = await db.rows(dialect === "tidb"
315
+ ? `SELECT token, ${jsonColumnText(dialect, "risk_descriptor")} FROM checkpoint WHERE ${jsonColumnTextExpr(dialect, "risk_descriptor")} LIKE ? AND token > ? ORDER BY token LIMIT ${SHADOWED_RULE_BATCH}`
316
+ : `SELECT token, ${jsonColumnText(dialect, "risk_descriptor")} FROM checkpoint WHERE ${jsonColumnTextExpr(dialect, "risk_descriptor")} LIKE $1 AND token > $2 ORDER BY token LIMIT ${SHADOWED_RULE_BATCH}`, [like, after]);
317
+ for (const r of rows) {
318
+ const token = String(r.token);
319
+ after = token;
320
+ const text = r.risk_descriptor;
321
+ if (typeof text !== "string")
322
+ continue;
323
+ const next = migrateShadowedRuleDescriptor(text);
324
+ if (next.kind === "unreadable") {
325
+ unreadable += 1;
326
+ continue;
327
+ }
328
+ if (next.kind === "untouched")
329
+ continue;
330
+ migrated += await db.exec(dialect === "tidb"
331
+ ? `UPDATE checkpoint SET risk_descriptor = ? WHERE token = ? AND ${jsonColumnTextExpr(dialect, "risk_descriptor")} = ?`
332
+ : `UPDATE checkpoint SET risk_descriptor = $1 WHERE token = $2 AND ${jsonColumnTextExpr(dialect, "risk_descriptor")} = $3`, [next.text, token, text]);
333
+ }
334
+ if (rows.length < SHADOWED_RULE_BATCH)
335
+ return { migrated, unreadable };
336
+ }
337
+ }
262
338
  const INCARNATION_BACKFILL_BATCH = 500;
263
339
  async function migrateSessionIncarnations(db, dialect) {
264
340
  if (!(await tableExists(db, dialect, "session_meta")))
@@ -329,7 +405,7 @@ export function migrateRetiredAskOrigin(type, data) {
329
405
  }
330
406
  export function logOneTimeMigrations(log, backend, report) {
331
407
  const changed = report.retiredSnapshotTable === "dropped" || report.retiredSteerSeatColumn === "dropped" || report.askOriginRowsMigrated > 0 || report.auditDetailJsonNullRows > 0 ||
332
- report.sessionIncarnationColumn === "added" || report.sessionIncarnationsBackfilled > 0 || report.holderDeathCodeRowsBackfilled > 0;
408
+ report.sessionIncarnationColumn === "added" || report.sessionIncarnationsBackfilled > 0 || report.holderDeathCodeRowsBackfilled > 0 || report.allowRuleShadowRowsMigrated > 0 || report.allowRuleShadowRowsUnreadable > 0;
333
409
  (changed ? log.warn : log.info).call(log, "one_time_migrations_applied", {
334
410
  backend,
335
411
  retiredSnapshotTable: report.retiredSnapshotTable,
@@ -341,13 +417,17 @@ export function logOneTimeMigrations(log, backend, report) {
341
417
  sessionIncarnationColumn: report.sessionIncarnationColumn,
342
418
  sessionIncarnationsBackfilled: report.sessionIncarnationsBackfilled,
343
419
  holderDeathCodeRowsBackfilled: report.holderDeathCodeRowsBackfilled,
420
+ allowRuleShadowRowsMigrated: report.allowRuleShadowRowsMigrated,
421
+ allowRuleShadowRowsUnreadable: report.allowRuleShadowRowsUnreadable,
344
422
  removableAfter: Object.fromEntries(ONE_TIME_MIGRATIONS.map((w) => [w.id, w.removableAfter])),
345
423
  note: "one-time persisted-data migrations (M1: retired whole-tree snapshot index table dropped; M2: retired single-seat " +
346
424
  "steering column dropped; M3: retired ask-origin spelling rewritten in place; M4: audit details written as the JSON " +
347
425
  "text `null` by an earlier version folded to a SQL NULL, so \"no detail\" has exactly one on-disk spelling; M5: the " +
348
426
  "session incarnation column added and every session row without one minted its own token; M6: run rows failed by a " +
349
427
  "holder-death verdict written by an earlier version with only the sentence got the machine-readable code run.stalled / " +
350
- "run.orphaned). Each step " +
428
+ "run.orphaned; M7 writes nothing — it is the window of the refusal that keeps a suspended task whose stored allow list was written " +
429
+ "under an earlier reading from resuming under the current one; M8: parked rows' allow-layer disclosure written by an earlier engine " +
430
+ "under its old key shadowedRule rewritten to allowRuleShadowed). Each step " +
351
431
  "runs on every boot until its window closes (the release that introduced it + 30 days; the per-step date is in " +
352
432
  "removableAfter, null while that release is not published yet) and is deleted in the first minor after that — start a " +
353
433
  "version that still carries a step at least once before upgrading past its deletion.",
@@ -1,26 +1,5 @@
1
- /**
2
- * The A value router — cash out the S1 supervisor value verdict (sema-internal server/docs/S1-VALUE-VERDICT.md §6.3) into a per-task
3
- * orchestration decision: run a task as `single` / `supervisor` / `team`.
4
- *
5
- * The verdict, in one line: **competent tasks have SUP/TEAM dominated (pure overhead) ⇒ default single; the real
6
- * supervisor value is (a) DANGER backstop (SUP prevention gate-deny > TEAM fan-out which AMPLIFIES danger ×K) and
7
- * (b) STRUCTURAL view-gap (goal-keeping / ask-aggregation, universally useful).** So this router is CONSERVATIVE
8
- * by construction:
9
- * - default **single** (escalation is never free — the verdict measured it as pure overhead on competent work);
10
- * - escalate to **supervisor** ONLY on a positive (a)-danger or (b)-structural signal;
11
- * - NEVER auto-select **team** (fan-out is dominated everywhere tested AND amplifies danger; team stays
12
- * caller-explicit — a deliberate `council`/`debate`/`team` request, or the `/v1/leader` endpoint).
13
- *
14
- * 🔴 ALL signals are DETERMINISTIC (config / mounted tools / exec-env isolation / autonomy / delegation). There is
15
- * NO LLM "difficulty" judge in the routing decision — the verdict's measurement-integrity rule ([ref] §2.2.1):
16
- * an LLM judge is a GATE inside an arm, NEVER the routing metric (it would make the router un-auditable + gameable).
17
- *
18
- * Pure + unit-testable. The decision is VALIDATED empirically against the S1 harness (a ROUTER arm that runs the
19
- * router's chosen arm per trap; the objective hidden-test oracle confirms danger→SUP prevents + competent→single
20
- * is not-worse) BEFORE it is wired into the live task path — never ship an unvalidated router.
21
- */
22
- import { type TaskSpec } from "@sema-agent/core";
23
1
  import type { Autonomy } from "../runtime-governance.js";
2
+ import type { GovernanceOverrides } from "../governance-overrides.js";
24
3
  export type OrchestrationMode = "single" | "supervisor" | "team";
25
4
  /** The DETERMINISTIC signals the routing decision reads (extracted by {@link deriveRoutingSignals} from the
26
5
  * task's resolved config — no task-content LLM judgement). */
@@ -116,5 +95,5 @@ export declare function isIsolatedExecEnv(provider?: string, runtimeClass?: stri
116
95
  * ⚠️ 连带(产品面,如实):写工具的 ask 从此也是强制卡 ⇒ 用户的「本会话放行」不再退役 SUP 路由的写门(shell 那一问修前就退不掉)。
117
96
  * 不改路由判据本身(`routeServiceTask` 一字不动)。
118
97
  */
119
- export declare function supPostureOverrides(): Partial<TaskSpec>;
98
+ export declare function supPostureOverrides(): GovernanceOverrides;
120
99
  //# sourceMappingURL=route-orchestration.d.ts.map