@sema-agent/server 7.66.0 → 7.68.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 (48) hide show
  1. package/USAGE.md +6 -3
  2. package/dist/approval-card.d.ts +8 -0
  3. package/dist/approval.d.ts +3 -0
  4. package/dist/approval.js +2 -1
  5. package/dist/boot/resolve-spec.js +13 -6
  6. package/dist/config-catalog.js +2 -2
  7. package/dist/config-center/apply-effective.js +1 -1
  8. package/dist/config-types.d.ts +15 -2
  9. package/dist/config.d.ts +25 -3
  10. package/dist/config.js +58 -30
  11. package/dist/deployment-governance.d.ts +8 -0
  12. package/dist/http/route-ctx.d.ts +5 -1
  13. package/dist/http/routes/diagnostics.d.ts +27 -2
  14. package/dist/http/routes/diagnostics.js +21 -1
  15. package/dist/http/routes/rules.js +15 -10
  16. package/dist/http/routes/runs.js +1 -1
  17. package/dist/http/routes/tasks.js +4 -8
  18. package/dist/http/server.d.ts +6 -1
  19. package/dist/http/server.js +7 -21
  20. package/dist/mcp-tool-faces.js +11 -2
  21. package/dist/observability/fail-open.d.ts +4 -0
  22. package/dist/observability/fail-open.js +4 -0
  23. package/dist/observability/metrics.js +1 -1
  24. package/dist/permission-rule-vocab.d.ts +13 -21
  25. package/dist/permission-rule-vocab.js +6 -5
  26. package/dist/plugins/permission-rule-store-file.d.ts +3 -5
  27. package/dist/plugins/permission-rule-store-file.js +2 -1
  28. package/dist/plugins/permission-rule-store-sql.d.ts +10 -10
  29. package/dist/plugins/permission-rule-store-sql.js +35 -20
  30. package/dist/posture-source.d.ts +57 -0
  31. package/dist/posture-source.js +27 -0
  32. package/dist/read-face-posture.d.ts +9 -4
  33. package/dist/read-face-posture.js +7 -11
  34. package/dist/rules-consent.d.ts +15 -10
  35. package/dist/rules-consent.js +41 -21
  36. package/dist/runs.d.ts +10 -1
  37. package/dist/runs.js +2 -1
  38. package/dist/session-titler.js +3 -12
  39. package/dist/task-mcp.d.ts +50 -6
  40. package/dist/task-mcp.js +71 -35
  41. package/dist/task-settings.d.ts +30 -0
  42. package/dist/task-settings.js +39 -18
  43. package/dist/tool-approval.d.ts +5 -4
  44. package/dist/tool-approval.js +2 -2
  45. package/dist/trace/engine-notice-wire.d.ts +17 -1
  46. package/dist/trace/engine-notice-wire.js +6 -1
  47. package/dist/trace/project.d.ts +4 -3
  48. package/package.json +3 -3
@@ -22,9 +22,25 @@
22
22
  * `runtimeCapsResolver`) is a DEFERRED v2.
23
23
  * When the gate is closed, a sent `mcpServers` is IGNORED (advertised via `capabilities.mcpInjection`).
24
24
  */
25
- import { type CompliancePosture, type LockedKey, type McpServerSpec } from "@sema-agent/core";
25
+ import { type CompliancePosture, type EngineNotice, type LockedKey, type McpInjectionDropReason, type McpServerSpec } from "@sema-agent/core";
26
26
  /** Bound on caller-supplied servers (anti-DoS; a real local `.mcp.json` has a handful). */
27
27
  export declare const MAX_REQUEST_MCP_SERVERS = 32;
28
+ /**
29
+ * 🔴 **一类丢弃至多点名多少条**(codex 对抗复审 r1 [high],验真后采纳)—— 与挂载上限**同一个数**,
30
+ * 刻意不新造一个可调旋钮:点名的量级本就该与挂载的量级同阶。
31
+ *
32
+ * 病(本批引入,不是既有形):`MAX_REQUEST_MCP_SERVERS` 管的是「挂几台」,管不住「你发了几台」——
33
+ * HTTP 体上限 8 MiB 装得下十万个名字,而 `gate_closed`(整只列表被忽略)与 `over_cap`(上限之后的尾项)
34
+ * 两臂原本逐条点名,于是一条已认证请求就能造出十万条事实、十万条通告,逐条排进本腿的**串行账本写链**
35
+ * ——同一条链上后续的正常事件全部排在它们后面。修前的名字数组把超上限折成**一条**聚合串,所以无界的
36
+ * 是本批新写的那两个循环。
37
+ *
38
+ * 为什么不改成「数组太长就 400」(codex 的另一条建议,**未采纳**):今天 33 台的请求是**照常受理、挂前
39
+ * 32 台**的,加一条新的拒绝面为了一个资源边界去改受理语义,爆炸半径比病本身大(而且 resume 腿重放存量
40
+ * body 会当场砖死一批已 park 的任务——本仓 `opts.leg` 三处先例逐字写着「砖死 resume 比降级糟」)。
41
+ * 有界点名两头都占:该说的仍然说得出,量级钉死在与挂载同阶。
42
+ */
43
+ export declare const MAX_REQUEST_MCP_DROP_NAMES = 32;
28
44
  /**
29
45
  * Does THIS deployment honor caller-supplied per-request MCP servers? TRUE for a SINGLE-USER deployment
30
46
  * (`requirePrincipal !== true`) — the requester is the super-admin of their own worker, so their `.mcp.json` is
@@ -37,19 +53,42 @@ export declare function mcpInjectionHonored(config: {
37
53
  lockedConfigKeys?: readonly LockedKey[];
38
54
  compliancePosture?: CompliancePosture;
39
55
  }): boolean;
56
+ /**
57
+ * L-167(core 7.9.1)—— 一条**没有被挂上**的请求腿 MCP 条目的**事实**。宿主报事实,core 铸词:
58
+ * `reason` 的词表属主是 core(`McpInjectionDropReason` = malformed_entry / name_reserved_by_deployment /
59
+ * gate_closed / over_cap),本仓一个词都不枚举,通告的码/受众/句子一个字都不拼。
60
+ *
61
+ * `sessionId` **不在**这个形上:它是通告的**路由半场**,在铸点(`mintInjectionDropNotices`)才补 ——
62
+ * 一条丢弃事实在没有会话语境的调用方(桩、单测、未来的非 HTTP 腿)那里同样成立,而通告不成立。
63
+ */
64
+ export interface McpInjectionDrop {
65
+ readonly server: string;
66
+ readonly reason: McpInjectionDropReason;
67
+ /** `malformed_entry` 专用:**哪个键**判不出形。结构性给出(读器在拒的那一行就知道),不从文案里 parse。 */
68
+ readonly field?: string;
69
+ }
70
+ /** L-167:本条腿的用户面通告席。`sessionId` = 路由半场(`auth.sessionId`,与 `spec.sessionId` 同源);
71
+ * `emit` = 收下 core 铸好的 `EngineNotice`。**本模块不知道**它最后落在 SSE、账本还是日志上 —— 那是
72
+ * 投递腿的事,本模块只负责「事实 → core 工厂 → 一条通告」。 */
73
+ export interface McpInjectionDropSeat {
74
+ readonly sessionId: string | undefined;
75
+ readonly emit: (notice: EngineNotice) => void;
76
+ }
40
77
  /**
41
78
  * Shape-check the raw `body.mcpServers`. `null` → a non-array (caller error; the route may 400). Otherwise the
42
79
  * valid subset + the names of dropped (malformed/over-cap) entries + [ref]'s `badContentOrigin`
43
80
  * (**降级**掉的内容出处声明,与 `dropped` 是两回事:那条 server **留着**,丢的只是它的声明)。
44
81
  */
45
- export declare function validateRequestMcp(raw: unknown): {
82
+ export declare function validateRequestMcp(raw: unknown): ValidatedRequestMcp | null;
83
+ type ValidatedRequestMcp = {
46
84
  ok: McpServerSpec[];
47
- dropped: string[];
85
+ dropped: McpInjectionDrop[];
86
+ droppedTotal: number;
48
87
  badContentOrigin: Array<{
49
88
  server: string;
50
89
  value: string;
51
90
  }>;
52
- } | null;
91
+ };
53
92
  /**
54
93
  * [ref] 件C §4.3 —— **同步**拒面:部署锁了 `mcp` 而请求仍带 `mcpServers` ⇒ 400 `config.locked_key`。
55
94
  *
@@ -128,7 +167,7 @@ export declare function mergeRequestMcp(baseline: McpServerSpec[] | undefined, g
128
167
  /**
129
168
  * The single entry point the spec-builder calls: compute the effective `TaskSpec.mcp` = deployment baseline + the
130
169
  * caller's gated per-request servers. Off the honored lane (or with no caller servers) returns the baseline
131
- * unchanged. Logs an `mcp_injection_ignored` when a caller SENT servers a closed gate dropped (so the shell never
170
+ * unchanged. Logs an `mcp_injection_dropped` (reason `gate_closed`) when a caller SENT servers a closed gate dropped (so the shell never
132
171
  * silently believes its `.mcp.json` took effect — it also reads `capabilities.mcpInjection`).
133
172
  */
134
173
  export declare function resolveRequestMcp(baseline: McpServerSpec[] | undefined, bodyMcp: unknown, config: {
@@ -144,5 +183,10 @@ export declare function resolveRequestMcp(baseline: McpServerSpec[] | undefined,
144
183
  }, logger?: {
145
184
  info?(m: string, meta?: unknown): void;
146
185
  warn(m: string, meta?: unknown): void;
147
- }): McpServerSpec[] | undefined;
186
+ },
187
+ /** L-167:本条腿的**用户面**投递口。`sessionId` = 通告的路由半场(缺席 ⇒ 一条都不铸,见
188
+ * {@link mintInjectionDropNotices});`emit` 收下 core 铸好的通告,由调用腿决定投到哪(见 `boot/resolve-spec.ts`)。
189
+ * 整段缺席(桩、单测、非 HTTP 腿)⇒ 只走日志终点,行为逐字与本件之前相同。 */
190
+ notice?: McpInjectionDropSeat): McpServerSpec[] | undefined;
191
+ export {};
148
192
  //# sourceMappingURL=task-mcp.d.ts.map
package/dist/task-mcp.js CHANGED
@@ -1,9 +1,10 @@
1
- import { resolveComplianceDenies } from "@sema-agent/core";
1
+ import { mcpInjectionDroppedNotice, resolveComplianceDenies } from "@sema-agent/core";
2
2
  import { HttpError } from "./security.js";
3
3
  import { CONTENT_ORIGIN_WORDS, describeBadContentOrigin, isContentOriginWord } from "./mcp-content-origin.js";
4
4
  import { readMcpSourceLabel } from "./mcp-source-label.js";
5
5
  import { readMcpToolFaces } from "./mcp-tool-faces.js";
6
6
  export const MAX_REQUEST_MCP_SERVERS = 32;
7
+ export const MAX_REQUEST_MCP_DROP_NAMES = MAX_REQUEST_MCP_SERVERS;
7
8
  export function mcpInjectionHonored(config) {
8
9
  if (config.lockedConfigKeys?.includes("mcp") === true)
9
10
  return false;
@@ -14,20 +15,27 @@ export function mcpInjectionHonored(config) {
14
15
  function isStringRecord(v) {
15
16
  return !!v && typeof v === "object" && !Array.isArray(v) && Object.values(v).every((x) => typeof x === "string");
16
17
  }
18
+ const bad = (field) => ({ ok: false, field });
19
+ const MALFORMED_NAME = "<malformed>";
20
+ function declaredName(e) {
21
+ return e && typeof e === "object" && typeof e.name === "string" && e.name !== ""
22
+ ? String(e.name)
23
+ : MALFORMED_NAME;
24
+ }
17
25
  function normalizeServer(e, noteBadOrigin) {
18
26
  if (!e || typeof e !== "object")
19
- return null;
27
+ return bad("entry");
20
28
  const o = e;
21
29
  if (typeof o.name !== "string" || o.name.length === 0 || o.name.length > 128)
22
- return null;
30
+ return bad("name");
23
31
  const t = o.transport;
24
32
  if (!t || typeof t !== "object")
25
- return null;
33
+ return bad("transport");
26
34
  const tr = t;
27
35
  let transport;
28
36
  if (tr.kind === "stdio") {
29
37
  if (typeof tr.command !== "string" || tr.command.length === 0)
30
- return null;
38
+ return bad("transport.command");
31
39
  transport = {
32
40
  kind: "stdio",
33
41
  command: tr.command,
@@ -37,7 +45,7 @@ function normalizeServer(e, noteBadOrigin) {
37
45
  }
38
46
  else if (tr.kind === "http") {
39
47
  if (typeof tr.url !== "string" || !/^https?:\/\//i.test(tr.url))
40
- return null;
48
+ return bad("transport.url");
41
49
  transport = {
42
50
  kind: "http",
43
51
  url: tr.url,
@@ -46,12 +54,12 @@ function normalizeServer(e, noteBadOrigin) {
46
54
  };
47
55
  }
48
56
  else {
49
- return null;
57
+ return bad("transport.kind");
50
58
  }
51
59
  const spec = { name: o.name, transport };
52
60
  if (o.allowTools !== undefined) {
53
61
  if (!Array.isArray(o.allowTools) || !o.allowTools.every((a) => typeof a === "string"))
54
- return null;
62
+ return bad("allowTools");
55
63
  spec.allowTools = o.allowTools;
56
64
  }
57
65
  if (typeof o.elicitation === "boolean")
@@ -82,29 +90,32 @@ function normalizeServer(e, noteBadOrigin) {
82
90
  Object.assign(spec, readMcpSourceLabel(spec.name, o.source));
83
91
  const faces = readMcpToolFaces(o.toolFaces);
84
92
  if (!faces.ok)
85
- return null;
93
+ return bad("toolFaces");
86
94
  if (faces.faces !== undefined)
87
95
  spec.toolFaces = faces.faces;
88
- return spec;
96
+ return { ok: true, spec };
89
97
  }
90
98
  export function validateRequestMcp(raw) {
91
99
  if (raw === undefined || raw === null)
92
- return { ok: [], dropped: [], badContentOrigin: [] };
100
+ return validateRequestMcpEntries([]);
93
101
  if (!Array.isArray(raw))
94
102
  return null;
103
+ return validateRequestMcpEntries(raw);
104
+ }
105
+ function validateRequestMcpEntries(raw) {
95
106
  const ok = [];
96
107
  const dropped = [];
97
108
  const badContentOrigin = [];
98
109
  for (const e of raw.slice(0, MAX_REQUEST_MCP_SERVERS)) {
99
110
  const v = normalizeServer(e, (server, value) => badContentOrigin.push({ server, value: describeBadContentOrigin(value) }));
100
- if (v)
101
- ok.push(v);
111
+ if (v.ok)
112
+ ok.push(v.spec);
102
113
  else
103
- dropped.push(e && typeof e === "object" && typeof e.name === "string" ? String(e.name) : "<malformed>");
114
+ dropped.push({ server: declaredName(e), reason: "malformed_entry", field: v.field });
104
115
  }
105
- if (raw.length > MAX_REQUEST_MCP_SERVERS)
106
- dropped.push(`(+${raw.length - MAX_REQUEST_MCP_SERVERS} over cap of ${MAX_REQUEST_MCP_SERVERS})`);
107
- return { ok, dropped, badContentOrigin };
116
+ for (const e of raw.slice(MAX_REQUEST_MCP_SERVERS, MAX_REQUEST_MCP_SERVERS + MAX_REQUEST_MCP_DROP_NAMES))
117
+ dropped.push({ server: declaredName(e), reason: "over_cap" });
118
+ return { ok, dropped, droppedTotal: Math.max(0, raw.length - ok.length), badContentOrigin };
108
119
  }
109
120
  export function assertRequestMcpUnlocked(bodyMcp, lockedKeys) {
110
121
  if (!lockedKeys.has("mcp"))
@@ -140,21 +151,33 @@ export function mergeRequestMcp(baseline, gated, reservedNames) {
140
151
  const add = gated.filter((s) => !closed.has(s.name));
141
152
  return add.length === 0 ? baseline : [...(baseline ?? []), ...add];
142
153
  }
143
- export function resolveRequestMcp(baseline, bodyMcp, config, logger) {
154
+ export function resolveRequestMcp(baseline, bodyMcp, config, logger, notice) {
144
155
  if (bodyMcp === undefined || bodyMcp === null)
145
156
  return baseline;
146
- const honored = mcpInjectionHonored(config);
147
- if (!honored) {
148
- logger?.warn("mcp_injection_ignored", { reason: "gate_closed (per-request MCP honored only on single-user host lane)" });
149
- return baseline;
150
- }
151
- const v = validateRequestMcp(bodyMcp);
152
- if (v === null) {
157
+ const { mcp, drops } = classifyRequestMcp(baseline, bodyMcp, config, logger);
158
+ mintInjectionDropNotices(drops, notice);
159
+ return mcp;
160
+ }
161
+ function classifyRequestMcp(baseline, bodyMcp, config, logger) {
162
+ if (bodyMcp !== undefined && bodyMcp !== null && !Array.isArray(bodyMcp)) {
153
163
  logger?.warn("mcp_injection_ignored", { reason: "mcpServers not an array" });
154
- return baseline;
164
+ return { mcp: baseline, drops: [] };
155
165
  }
156
- if (v.dropped.length > 0)
157
- logger?.warn("mcp_injection_dropped", { dropped: v.dropped });
166
+ const entries = Array.isArray(bodyMcp) ? bodyMcp : [];
167
+ const outcome = mcpInjectionHonored(config) ? classifyHonored(baseline, entries, config, logger) : classifyGateClosed(baseline, entries);
168
+ if (outcome.drops.length > 0)
169
+ logger?.warn("mcp_injection_dropped", { dropped: outcome.drops, droppedTotal: outcome.droppedTotal });
170
+ return { mcp: outcome.mcp, drops: outcome.drops };
171
+ }
172
+ function classifyGateClosed(baseline, entries) {
173
+ return {
174
+ mcp: baseline,
175
+ drops: entries.slice(0, MAX_REQUEST_MCP_DROP_NAMES).map((e) => ({ server: declaredName(e), reason: "gate_closed" })),
176
+ droppedTotal: entries.length,
177
+ };
178
+ }
179
+ function classifyHonored(baseline, entries, config, logger) {
180
+ const v = validateRequestMcpEntries(entries);
158
181
  if (v.badContentOrigin.length > 0) {
159
182
  logger?.warn("mcp_content_class_dropped", {
160
183
  entries: v.badContentOrigin,
@@ -165,13 +188,26 @@ export function resolveRequestMcp(baseline, bodyMcp, config, logger) {
165
188
  const reservedNames = config.mcpServers?.reservedNames ?? [];
166
189
  const closed = deploymentClosedMcpNames(baseline, reservedNames);
167
190
  const blocked = v.ok.filter((s) => closed.has(s.name)).map((s) => s.name);
168
- if (blocked.length > 0) {
169
- logger?.warn("mcp_injection_dropped", {
170
- reason: "name_reserved_by_deployment",
171
- dropped: blocked,
172
- note: "these names are declared by the deployment (center/config) — mounted or skipped, a caller can neither shadow nor refill them; rename the request-side server",
173
- });
191
+ return {
192
+ mcp: mergeRequestMcp(baseline, v.ok, reservedNames),
193
+ drops: [...v.dropped, ...blocked.map((server) => ({ server, reason: "name_reserved_by_deployment" }))],
194
+ droppedTotal: v.droppedTotal + blocked.length,
195
+ };
196
+ }
197
+ function mintInjectionDropNotices(drops, notice) {
198
+ if (notice === undefined || drops.length === 0)
199
+ return;
200
+ const sessionId = notice.sessionId;
201
+ if (typeof sessionId !== "string" || sessionId.length === 0)
202
+ return;
203
+ const said = new Set();
204
+ for (const d of drops) {
205
+ if (d.server !== MALFORMED_NAME) {
206
+ if (said.has(d.server))
207
+ continue;
208
+ said.add(d.server);
209
+ }
210
+ notice.emit(mcpInjectionDroppedNotice({ sessionId, server: d.server, reason: d.reason, ...(d.field !== undefined ? { field: d.field } : {}) }));
174
211
  }
175
- return mergeRequestMcp(baseline, v.ok, reservedNames);
176
212
  }
177
213
  //# sourceMappingURL=task-mcp.js.map
@@ -293,6 +293,36 @@ export interface FsWriteGateWiring {
293
293
  * (⇒ ask) and `acceptEdits` degrades to the `default` arm — the same fail-safe direction as the host lane's
294
294
  * never-created sentinel dir (resolve-spec 修5). */
295
295
  cwd?: string;
296
+ /**
297
+ * 🔴 S-177 / codex r1 [high](验真后修)—— **权限规则**的路径基,与上面那个 `cwd` **刻意分成两个字段**。
298
+ *
299
+ * 两者在 host 腿上常常同值,但它们回答的不是同一个问题,而混用一个字段会让其中一个静默失效:
300
+ * · `cwd` 是**写门**的基。它在「有 remoteExec 但这条 session 还没登记工作目录」时取的是一个
301
+ * **永不创建的哨兵目录**(`resolve-spec` 的 `fs-write-gate-unrooted`)。对写门那是 fail-SAFE 的:
302
+ * 相对目标在哨兵下解析不出真身 ⇒ 落 ask。
303
+ * · `taskRoot` 是**规则模式**的基(core `PathRuleBases.root`)。规则编译器按**词法**匹配,不问目录
304
+ * 存不存在 ⇒ 把哨兵递给它,`deny Edit(src/**)` 会编译成功、指向一个谁也不会写的目录,对真实工作区
305
+ * 的 `<工作区>/src/secret.ts` 这类目标判 allow —— 一条运维亲手写下的收紧规则**静默失效**,方向与写门那条正相反。
306
+ *
307
+ * ⇒ 本字段**只在真任务根已知时**在场。缺席 ⇒ 规则编译器拿不到 `pathBases.root` ⇒ 需要该基的拼法
308
+ * (`Edit(src/**)` / `Edit(/etc/**)`)被 core 以 `unsupported.path_base` **整表拒**(→ 422,fail-loud),
309
+ * 而不是编成一条够不着任何东西的规则。绝对形(`//abs`)与 `~/` 形不需要基,两种部署形都照常编得出。
310
+ */
311
+ taskRoot?: string;
312
+ /**
313
+ * 🔴 S-177 / codex r2 [high](验真后修)—— **执行环境的 home**(core `PathRuleBases.home`,`~/…` 形的基)。
314
+ *
315
+ * core 对缺席的 `home` 回落 `os.homedir()` —— 那是**这个 server 进程**的 home。引擎与执行同机时它就是
316
+ * 对的;**远端车道**(SSH / 容器 / 沙箱)上执行用户的 home 与 server 的 home 是两个目录,于是
317
+ * `deny Edit(~/.sema/**)` 编译成功、却拿 server 的 home 去匹配远端的目标 ⇒ 一条收紧规则**静默失效**
318
+ * (与 `taskRoot` 那条哨兵病同族、同方向)。
319
+ *
320
+ * ⇒ 本字段只在 home **真知道**时在场(引擎与执行同机)。缺席时,`~/` 形的规则由
321
+ * {@link rejectUnmatchableSettingsNames} **响亮拒**(422),绝不回落到 server 的 home。
322
+ * ⚠️ 上游缺口如实登记:core 今天没有「这条规则需要哪些 base」的查询口,也不把 `home` 当必答项 ——
323
+ * 所以「这条规则用了 `~/` 吗」由本仓读**core 自己解析出的规范 command**(`~/` 前缀)来判,不自铸文法。
324
+ */
325
+ taskHome?: string;
296
326
  /** The session scratchpad dir (envFacts.scratchpadDir, [ref]③) — writes there are always auto-allowed. */
297
327
  scratchpadDir?: string;
298
328
  /** [ref]① / core 1.294 exemption seam: the session "don't ask again" probe (approvalExemptionStore.has),
@@ -1,5 +1,5 @@
1
- import { combinePolicies, createAllowDenyPolicy, createFsWriteGatePolicy, createPermissionRulePolicy, tightenTaskSpec, resolveReasoningProfile, rankOf, isThinkingLevel, CYBER_RISK, URL_SAFETY, HARNESS_SECTION_ANCHOR, RUN_WORKFLOW_TOOL_NAME, } from "@sema-agent/core";
2
- import { findCcRuleFormNames, findUnmatchableToolNames, formatUnmatchableToolNames } from "./config.js";
1
+ import { combinePolicies, createAllowDenyPolicy, createFsWriteGatePolicy, createPermissionRulePolicy, parseRuleText, tightenTaskSpec, resolveReasoningProfile, rankOf, isThinkingLevel, CYBER_RISK, URL_SAFETY, HARNESS_SECTION_ANCHOR, RUN_WORKFLOW_TOOL_NAME, ruleToolGrammarOf, } from "@sema-agent/core";
2
+ import { findUnmatchableToolNames, formatUnmatchableToolNames } from "./config.js";
3
3
  import { CONTROL_AND_BIDI_STRIP_RE } from "./text-bidi.js";
4
4
  import { parseHooksConfig } from "./hooks/hook-runner.js";
5
5
  export function coercePermissionMode(raw) {
@@ -225,17 +225,18 @@ export function acceptAppendSystemPrompt(v, warn, packDropsAppend) {
225
225
  }
226
226
  return v;
227
227
  }
228
- const ASK_RULE_SOURCE = "settings.permissions.ask";
229
- function createAskListPolicy(askNames) {
228
+ const RULE_SOURCE_BY_BEHAVIOR = { ask: "settings.permissions.ask", deny: "settings.permissions.deny" };
229
+ function createRuleListPolicy(names, behavior, pathBases) {
230
230
  try {
231
- return createPermissionRulePolicy(askNames.map((name) => ({ rule: name, behavior: "ask", source: ASK_RULE_SOURCE })), {
231
+ return createPermissionRulePolicy(names.map((name) => ({ rule: name, behavior, source: RULE_SOURCE_BY_BEHAVIOR[behavior] })), {
232
232
  defaultAction: "allow",
233
233
  caps: { maxRules: MAX_SETTINGS_PERMISSION_RULES },
234
+ ...(pathBases !== undefined ? { pathBases } : {}),
234
235
  });
235
236
  }
236
237
  catch (e) {
237
238
  const coreMsg = (e instanceof Error ? e.message : String(e)).replace(CONTROL_AND_BIDI_STRIP_RE, "");
238
- throw new Error(`permissions.ask has rule(s) core cannot compile — ${coreMsg}`, { cause: e });
239
+ throw new Error(`${RULE_SOURCE_BY_BEHAVIOR[behavior].replace("settings.", "")} has rule(s) core cannot compile — ${coreMsg}`, { cause: e });
239
240
  }
240
241
  }
241
242
  const WORKFLOW_ASK_NAMES = [RUN_WORKFLOW_TOOL_NAME];
@@ -267,31 +268,51 @@ function createWorkflowPlanDenyPolicy() {
267
268
  nameSets: [{ deny: WORKFLOW_ASK_NAMES }],
268
269
  };
269
270
  }
270
- function rejectUnmatchableSettingsNames(perms) {
271
- for (const [field, names] of [["permissions.ask", perms.ask], ["permissions.deny", perms.deny]]) {
271
+ function rejectUnmatchableSettingsNames(perms, pathBases) {
272
+ if (pathBases?.home === undefined) {
273
+ for (const [field, names, behavior] of [
274
+ ["permissions.ask", perms.ask, "ask"],
275
+ ["permissions.deny", perms.deny, "deny"],
276
+ ["permissions.allow", perms.allow, "allow"],
277
+ ]) {
278
+ for (const name of names ?? []) {
279
+ const parsed = parseRuleText(name, behavior);
280
+ if ("rule" in parsed && ruleToolGrammarOf(parsed.rule.tool) === "path" && parsed.rule.command.startsWith("~/")) {
281
+ throw new Error(`${field} has a home-relative path rule (${name}) but this deployment cannot resolve the EXECUTION environment's home directory — ` +
282
+ `on a remote lane the engine's own home is a different user's directory, so the rule would compile and then match nothing that runs. ` +
283
+ `Spell the path absolutely (${"Edit(//abs/path/**)"} form), or run the task on a lane whose home this worker knows.`);
284
+ }
285
+ }
286
+ }
287
+ }
288
+ for (const [field, names, behavior, consumer] of [
289
+ ["permissions.ask", perms.ask, "ask", "rule-policy"],
290
+ ["permissions.deny", perms.deny, "deny", "rule-policy"],
291
+ ["permissions.allow", perms.allow, "allow", "tool-name-table"],
292
+ ]) {
272
293
  if (!names)
273
294
  continue;
274
- const bad = findUnmatchableToolNames(names);
295
+ const bad = findUnmatchableToolNames(names, { consumer, behavior, ...(pathBases !== undefined ? { pathBases } : {}) });
275
296
  if (bad.length > 0)
276
297
  throw new Error(formatUnmatchableToolNames(field, bad));
277
298
  }
278
- if (perms.allow) {
279
- const bad = findCcRuleFormNames(perms.allow);
280
- if (bad.length > 0)
281
- throw new Error(formatUnmatchableToolNames("permissions.allow", bad));
282
- }
283
299
  }
284
300
  export function deriveSettingsPolicy(settings, gate, workflowGate) {
285
301
  const perms = settings.permissions;
286
302
  if (!perms)
287
303
  return {};
288
304
  const { allow, deny, ask, defaultMode } = perms;
289
- rejectUnmatchableSettingsNames(perms);
305
+ const pathBases = gate?.taskRoot !== undefined || gate?.taskHome !== undefined
306
+ ? { ...(gate.taskRoot !== undefined ? { root: gate.taskRoot } : {}), ...(gate.taskHome !== undefined ? { home: gate.taskHome } : {}) }
307
+ : undefined;
308
+ rejectUnmatchableSettingsNames(perms, pathBases);
290
309
  const policies = [];
291
- if (allow || deny)
292
- policies.push(createAllowDenyPolicy({ ...(allow ? { allow } : {}), ...(deny ? { deny } : {}) }));
310
+ if (allow)
311
+ policies.push(createAllowDenyPolicy({ allow }));
312
+ if (deny)
313
+ policies.push(createRuleListPolicy(deny, "deny", pathBases));
293
314
  if (ask)
294
- policies.push(createAskListPolicy(ask));
315
+ policies.push(createRuleListPolicy(ask, "ask", pathBases));
295
316
  const fsWriteGate = (acceptCwd) => {
296
317
  if (!gate)
297
318
  return undefined;
@@ -126,8 +126,9 @@ export interface ToolApprovalFrame {
126
126
  requiresRealApproval?: true;
127
127
  /**
128
128
  * **S-125③/[ref]**(core 7.5.0;**ADDITIVE**,`"tool_approval"` only)—— 这只 ask 的**出身**:
129
- * 谁提的这一问,一个 core 闭集词(`ASK_ORIGINS` 八员 content_question / unresolvable / org_unavailable /
130
- * org_rule / hook / ask_rule / denial_limit_fallback / policy)。引擎在门上盖章(core d.ts:
129
+ * 谁提的这一问,一个 core 闭集词(`ASK_ORIGINS` **11 员**,core 7.9.0 现值:content_question /
130
+ * unresolvable / org_unavailable / org_rule / rule_store_unavailable / hook / ask_rule /
131
+ * denial_limit_fallback / shell_gate_tighten / safety_tighten / policy)。引擎在门上盖章(core d.ts:
131
132
  * 「engine-stamped at the gate, never a policy's claim」),`AskRequest.origin` 逐字透传。
132
133
  *
133
134
  * 🔴 **echo-only,零消费**:自动车道的资格判据全在 core(`classifierMayAnswer` 的闭表),server
@@ -864,7 +865,7 @@ export declare class ToolApprovalCoordinator {
864
865
  * [ref] / 5.58 复审 F-1 —— OFFER 的**文本座尺**(红先修)。
865
866
  *
866
867
  * 🔴 病灶(真复现,`npm run build --flag=<700 字> && git status`):`batch` 成员的 `segment` 是复合命令
867
- * 那一段的**原字节**,它**不经** `parseAllowRuleText`,所以 core 的 `MAX_RULE_TEXT_CHARS` 对它一个字
868
+ * 那一段的**原字节**,它**不经** `parseRuleText`,所以 core 的 `MAX_RULE_TEXT_CHARS` 对它一个字
868
869
  * 都不管;而卡的 `ApprovalCardSchema` 对四个文本座一律 `.max(MAX_RULE_TEXT_CHARS)`。逐字拷进卡 ⇒
869
870
  * `card_json` 落库 ⇒ `ensureAsk` 当场回读 `safeParse` 判假 ⇒ `persisted-row-unusable` ⇒ **整只 ask 走
870
871
  * park**,重放腿一并跳过该行。与「重扫二轮 §基数帽」是**同一条悬崖的另一条边**(那次是 offer 条数
@@ -885,7 +886,7 @@ export declare class ToolApprovalCoordinator {
885
886
  /** 兑付侧座(single 的 rule/command、command 成员的 rule/command、directoryRead 成员的 rule/directory)任一超尺?
886
887
  * `segment` 与 `uncoveredDetail[].segment` 是纯渲染座,不在此列(截长是诚实省略)。 */
887
888
  private static ruleOfferTextsOverSize;
888
- /** codex r1 [high]([ref]):offer 的自称座与 `parseAllowRuleText(rule)` 派生的三元组逐字一致?(理由见调用点) */
889
+ /** codex r1 [high]([ref]):offer 的自称座与 `parseRuleText(rule, "allow")` 派生的三元组逐字一致?(理由见调用点) */
889
890
  private static ruleOfferDerivedFactsAgree;
890
891
  private static boundRuleOfferTexts;
891
892
  private buildRuleLaneMaterial;
@@ -1,6 +1,6 @@
1
1
  import { contentKindOf } from "./approval-content-kind.js";
2
2
  import { AsyncLocalStorage } from "node:async_hooks";
3
- import { uuidv7, MAX_RULE_TEXT_CHARS, parseAllowRuleText, pathConfinableWriteToolNames } from "@sema-agent/core";
3
+ import { uuidv7, MAX_RULE_TEXT_CHARS, parseRuleText, pathConfinableWriteToolNames } from "@sema-agent/core";
4
4
  import { precheckCardRuleText } from "./rules-consent.js";
5
5
  import { redactDeep, redactSecrets } from "./trace/redact.js";
6
6
  import { hasBidiControls } from "./text-bidi.js";
@@ -381,7 +381,7 @@ export class ToolApprovalCoordinator {
381
381
  }
382
382
  static ruleOfferDerivedFactsAgree(offer) {
383
383
  const derive = (rule) => {
384
- const p = parseAllowRuleText(rule);
384
+ const p = parseRuleText(rule, "allow");
385
385
  return "rule" in p ? p.rule : undefined;
386
386
  };
387
387
  if (offer.kind === "single") {
@@ -16,7 +16,7 @@
16
16
  * 同文件 G1 e2e 的全码扇出夹具、以及契约文档附录 D.3 那张表(含**小节标题里的码数** —— 那道门本批
17
17
  * 刚立,立完就在下一次加码时自己咬住了)。
18
18
  */
19
- export declare const ENGINE_NOTICE_WIRE_CODES: readonly ["memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "task.turn_interrupted", "steering.parked_input_blocked", "task.user_steer_undrained", "task.user_followup_undrained", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "task.halt_unconsumed", "task.late_approval", "config.durable_gate_unavailable", "peer.inbound_disposition", "peer.held_settled", "peer.idle_subscription", "classifier.denial_limit"];
19
+ export declare const ENGINE_NOTICE_WIRE_CODES: readonly ["memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "task.turn_interrupted", "steering.parked_input_blocked", "task.user_steer_undrained", "task.user_followup_undrained", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "task.halt_unconsumed", "task.late_approval", "config.durable_gate_unavailable", "peer.inbound_disposition", "peer.held_settled", "peer.idle_subscription", "classifier.denial_limit", "mcp.injection_dropped"];
20
20
  export type EngineNoticeWireCode = (typeof ENGINE_NOTICE_WIRE_CODES)[number];
21
21
  /** 白名单谓词(单点):路由与门都读这一个,不许第二处手抄码串。 */
22
22
  export declare function isEngineNoticeWireCode(code: string): code is EngineNoticeWireCode;
@@ -136,5 +136,21 @@ export declare function registerEngineNoticeLeg(opts: {
136
136
  live?: EngineNoticeWireSink;
137
137
  durable?: EngineNoticeWireSink;
138
138
  router?: EngineNoticeRouter;
139
+ /**
140
+ * L-167 —— **腿开张之前**就已经铸好的通告(装配期 `resolveSpec` 的产物,经
141
+ * `PreparedTaskSubmission.engineNotices` 递过来)。注册完口**立刻**经同一只 {@link EngineNoticeRouter.route}
142
+ * 投出:白名单、归属键、消毒、隔离四件判据一个都不绕,**不是**第二条投递机制 —— 只是把 `route()` 挪到
143
+ * 「这条会话已经有口在听」的那一拍。
144
+ *
145
+ * 🔴 为什么不让铸点自己 `route()`:装配(`prepareSpec`)跑在本函数**之前**,那一刻这条会话一只口都没有,
146
+ * `route()` 恒返 0 —— 一条结构上永远投不出去的用户面披露,就是本仓 `task.user_steer_undrained` 陈腐明拒
147
+ * 那一族的病。缺席/空数组 ⇒ 一个字节不投(既有腿逐字不变)。
148
+ */
149
+ pending?: readonly {
150
+ code: string;
151
+ message: string;
152
+ detail?: Record<string, unknown>;
153
+ sessionId?: unknown;
154
+ }[];
139
155
  }): () => void;
140
156
  //# sourceMappingURL=engine-notice-wire.d.ts.map
@@ -20,6 +20,7 @@ export const ENGINE_NOTICE_WIRE_CODES = [
20
20
  "peer.held_settled",
21
21
  "peer.idle_subscription",
22
22
  "classifier.denial_limit",
23
+ "mcp.injection_dropped",
23
24
  ];
24
25
  const WIRE_CODES = new Set(ENGINE_NOTICE_WIRE_CODES);
25
26
  export function isEngineNoticeWireCode(code) {
@@ -150,6 +151,10 @@ export function registerEngineNoticeLeg(opts) {
150
151
  const sinks = [...(live ? [live] : []), ...(durable ? [durable] : [])];
151
152
  if (sinks.length === 0)
152
153
  return () => undefined;
153
- return (opts.router ?? defaultEngineNoticeRouter).registerLeg(sessionId, sinks);
154
+ const router = opts.router ?? defaultEngineNoticeRouter;
155
+ const unregister = router.registerLeg(sessionId, sinks);
156
+ for (const notice of opts.pending ?? [])
157
+ router.route(notice);
158
+ return unregister;
154
159
  }
155
160
  //# sourceMappingURL=engine-notice-wire.js.map
@@ -419,9 +419,10 @@ export declare function workspaceChangedEventData(ev: {
419
419
  * typebox schema `ToolRosterDelta`,过了整只透传,没过 ⇒ **整帧不发** + F 类留痕。半张名册变更帧比
420
420
  * 不发更坏:消费端会据它把没读到的行当成「这只工具被卸了」。
421
421
  *
422
- * ⚠️ **如实披露(core 7.8.0 自述)**:7.8.0 的 runtask **还没有**推这条帧([ref] S3 才接线),座位与
423
- * 类型先到货。本臂因此是**前瞻接线**:core 开始推的那一版,server 这一侧零改动即到达消费端;在那之前
424
- * 它永不触发。消费端在那一版之前**不得**依赖本帧(core 契约原文)。
422
+ * ✅ **已转正(core 7.9.0 / [ref] S3)**:引擎自 7.9.0 起**真推**这条帧 —— 驱动在推 `wiring_manifest`
423
+ * 的那一点订阅腿的名册增量座,一次改变名册的 refresh ⇒ **恰一帧**(序在 manifest 之后、那次 refresh 自己的
424
+ * `tool_end` 之前),`delta.fromDigest` = 消费端手上那份名册的 digest。7.66.0 写下的「前瞻接线」披露随本版
425
+ * 退役:本臂现在会真的触发,消费端可以依赖它了。
425
426
  */
426
427
  export declare function toolRosterDeltaEventData(ev: {
427
428
  delta?: unknown;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "7.66.0",
3
+ "version": "7.68.0",
4
4
  "description": "Sema Server — the server/API implementation layer for Sema, wiring core, registry, model providers, and cloud agent execution. Built on @sema-agent/core.",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -54,7 +54,7 @@
54
54
  "build:binary:run-local:darwin-arm64": "bun build --compile --target=bun-darwin-arm64 src/run-local.ts --outfile dist/run-local-darwin-arm64"
55
55
  },
56
56
  "dependencies": {
57
- "@sema-agent/core": "7.8.0",
57
+ "@sema-agent/core": "7.9.2",
58
58
  "@sema-agent/settings-schema": "1.9.0",
59
59
  "e2b": "^2.28.0",
60
60
  "libsodium-wrappers": "^0.8.4",
@@ -69,7 +69,7 @@
69
69
  "sharp": "^0.35.3"
70
70
  },
71
71
  "devDependencies": {
72
- "@sema-agent/sdk": "8.5.0",
72
+ "@sema-agent/sdk": "8.8.0",
73
73
  "@types/libsodium-wrappers": "^0.7.14",
74
74
  "@types/node": "22.10.2",
75
75
  "@types/pg": "^8.20.0",