@sema-agent/server 7.89.0 → 7.91.1

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 (72) hide show
  1. package/dist/adoption/plan.js +11 -0
  2. package/dist/boot/fatal-exit.d.ts +3 -0
  3. package/dist/boot/fatal-exit.js +5 -0
  4. package/dist/boot/resolve-spec.js +2 -0
  5. package/dist/boot/runner-deps.d.ts +6 -0
  6. package/dist/boot/runner-deps.js +11 -0
  7. package/dist/boot/session-faces.d.ts +6 -0
  8. package/dist/boot/session-faces.js +10 -2
  9. package/dist/boot/stage-03-store-backend.d.ts +1 -0
  10. package/dist/boot/stage-03-store-backend.js +2 -2
  11. package/dist/boot/stage-04-stores.d.ts +1 -0
  12. package/dist/boot/stage-05-execution-env.d.ts +1 -0
  13. package/dist/boot/stage-06-runners.d.ts +1 -0
  14. package/dist/boot/stage-06-runners.js +2 -2
  15. package/dist/boot/stage-07-capability-layer.d.ts +1 -0
  16. package/dist/boot/stage-08-reapers.d.ts +1 -0
  17. package/dist/boot/stage-09-leader.d.ts +1 -0
  18. package/dist/boot/stage-09-leader.js +2 -2
  19. package/dist/boot/stage-10-http-server.d.ts +1 -0
  20. package/dist/boot/stage-10-http-server.js +1 -0
  21. package/dist/boot/stores.d.ts +2 -0
  22. package/dist/boot/stores.js +11 -3
  23. package/dist/brain.js +32 -19
  24. package/dist/capabilities/memory-notice.d.ts +7 -1
  25. package/dist/capabilities/memory-notice.js +6 -6
  26. package/dist/capabilities/team.d.ts +37 -0
  27. package/dist/config-catalog.js +6 -18
  28. package/dist/config.d.ts +1 -1
  29. package/dist/config.js +5 -5
  30. package/dist/cross-session-settings.d.ts +6 -2
  31. package/dist/hooks/hook-llm.js +10 -6
  32. package/dist/hooks/hook-runner.js +19 -2
  33. package/dist/http/admission.js +9 -5
  34. package/dist/http/routes/capabilities.js +6 -3
  35. package/dist/http/routes/rules.d.ts +11 -0
  36. package/dist/http/routes/rules.js +54 -5
  37. package/dist/http/routes/session-sync.js +8 -2
  38. package/dist/http/routes/sessions-list.js +6 -1
  39. package/dist/http/routes/sessions.js +16 -14
  40. package/dist/http/server.d.ts +12 -0
  41. package/dist/http/wire-types.d.ts +12 -0
  42. package/dist/main.js +2 -1
  43. package/dist/model-route-url.d.ts +8 -0
  44. package/dist/model-route-url.js +17 -8
  45. package/dist/peer-directory.d.ts +114 -0
  46. package/dist/peer-directory.js +136 -0
  47. package/dist/permission-rule-vocab.d.ts +27 -1
  48. package/dist/permission-rule-vocab.js +8 -0
  49. package/dist/plugins/mailbox-recipient-lifecycle.d.ts +86 -0
  50. package/dist/plugins/mailbox-recipient-lifecycle.js +10 -0
  51. package/dist/plugins/mailbox-store-sql.d.ts +18 -2
  52. package/dist/plugins/mailbox-store-sql.js +77 -1
  53. package/dist/plugins/memory-embedder.d.ts +0 -6
  54. package/dist/plugins/memory-embedder.js +10 -16
  55. package/dist/plugins/store-contracts.d.ts +36 -1
  56. package/dist/plugins/store-contracts.js +10 -0
  57. package/dist/request-key-closure.js +2 -1
  58. package/dist/rules-consent.d.ts +74 -1
  59. package/dist/rules-consent.js +81 -1
  60. package/dist/runtime-governance.d.ts +16 -7
  61. package/dist/runtime-governance.js +27 -7
  62. package/dist/session-policy-wire.d.ts +103 -0
  63. package/dist/session-policy-wire.js +66 -0
  64. package/dist/task-settings.d.ts +12 -2
  65. package/dist/task-settings.js +7 -5
  66. package/dist/trace/core-keyset-guard.d.ts +2 -2
  67. package/dist/trace/project.d.ts +7 -0
  68. package/dist/trace/project.js +1 -0
  69. package/dist/trace/redact.d.ts +96 -11
  70. package/dist/trace/redact.js +155 -11
  71. package/dist/trace/sema-provenance.d.ts +7 -6
  72. package/package.json +3 -3
@@ -26,12 +26,6 @@ export interface OpenAiCompatEmbedderOptions extends MemoryEmbedderConfig {
26
26
  * URL 合法性门,只在运行期炸(而运行期的炸法就是上面那条「写入失败/检索退档」的静默病)。
27
27
  */
28
28
  export declare function embeddingsUrl(endpoint: string): string;
29
- /**
30
- * 诊断面用的**脱敏** URL(codex 复审 F4,已核真):端点里可能带 userinfo(`https://user:pass@h/v1`)
31
- * 或把凭据放 query(Azure 的 `api-key=`),而错误文本会进日志、进 PatchReport 的 conflict reason。
32
- * 主机与路径保留(诊断价值全在这里),userinfo 抹掉、query 值一律换成 `***`(键名保留,便于认形)。
33
- */
34
- export declare function redactEndpointForLog(raw: string): string;
35
29
  /** 造一个 OpenAI 兼容的 `PgEmbedder`(工厂命名律:返回带行为的对象 ⇒ `create*`)。 */
36
30
  export declare function createOpenAiCompatEmbedder(opts: OpenAiCompatEmbedderOptions): PgEmbedder;
37
31
  /**
@@ -1,13 +1,17 @@
1
+ import { joinRequestUrl } from "@sema-agent/core";
1
2
  import { z } from "zod";
3
+ import { buildDisplaySafeRefusal, buildDisplaySafeUrl } from "../trace/redact.js";
2
4
  const EmbeddingsResponse = z.object({
3
5
  data: z.array(z.object({ embedding: z.array(z.number()) })).min(1),
4
6
  });
5
7
  export function embeddingsUrl(endpoint) {
6
- const u = new URL(endpoint);
7
- u.hash = "";
8
- const path = u.pathname.replace(/\/+$/, "");
9
- u.pathname = decodeUnreservedEscapes(path).endsWith("/embeddings") ? path : `${path}/embeddings`;
10
- return u.toString();
8
+ const path = URL.canParse(endpoint) ? new URL(endpoint).pathname.replace(/\/+$/, "") : "";
9
+ try {
10
+ return joinRequestUrl(endpoint, decodeUnreservedEscapes(path).endsWith("/embeddings") ? "" : "/embeddings");
11
+ }
12
+ catch (e) {
13
+ throw new Error(buildDisplaySafeRefusal(e instanceof Error ? e.message : String(e), [endpoint]), { cause: undefined });
14
+ }
11
15
  }
12
16
  function decodeUnreservedEscapes(s) {
13
17
  return s.replace(/%([0-9A-Fa-f]{2})/g, (whole, hex) => {
@@ -15,16 +19,6 @@ function decodeUnreservedEscapes(s) {
15
19
  return /[A-Za-z0-9\-._~]/.test(c) ? c : whole;
16
20
  });
17
21
  }
18
- export function redactEndpointForLog(raw) {
19
- if (!URL.canParse(raw))
20
- return "<invalid-url>";
21
- const u = new URL(raw);
22
- u.username = "";
23
- u.password = "";
24
- for (const k of [...u.searchParams.keys()])
25
- u.searchParams.set(k, "***");
26
- return u.toString();
27
- }
28
22
  function credentialsInQuery(endpoint) {
29
23
  if (!URL.canParse(endpoint))
30
24
  return [];
@@ -32,7 +26,7 @@ function credentialsInQuery(endpoint) {
32
26
  }
33
27
  export function createOpenAiCompatEmbedder(opts) {
34
28
  const url = embeddingsUrl(opts.endpoint);
35
- const shownUrl = redactEndpointForLog(url);
29
+ const shownUrl = buildDisplaySafeUrl(url);
36
30
  const fetchImpl = opts.fetchImpl ?? fetch;
37
31
  const { model, dimensions, timeoutMs, apiKey, onFailure } = opts;
38
32
  const secrets = [...(apiKey !== undefined ? [apiKey] : []), ...credentialsInQuery(opts.endpoint)];
@@ -14,7 +14,7 @@
14
14
  * - The TiDB adapters re-export these names for back-compat (the historical import surface), but new
15
15
  * code should import from this file.
16
16
  */
17
- import type { TaskResult, TaskStatus } from "@sema-agent/core";
17
+ import type { PeerSessionTempo, TaskResult, TaskStatus } from "@sema-agent/core";
18
18
  /**
19
19
  * **File 店族的 boot 期拒启基类**(S-384;把 `RunStoreStrictHydrateError` 的那条论证升成一条族规)。
20
20
  *
@@ -240,6 +240,41 @@ export declare function isUnsuccessfulRunStatus(status: RunRecord["status"]): bo
240
240
  * 动作(cancel 的恢复把手会 CAS-expire 一张 checkpoint);对一个本进程读不懂的状态,少动一次远好过错杀。
241
241
  */
242
242
  export declare function isParkedRunStatus(status: RunRecord["status"]): boolean;
243
+ /**
244
+ * 会话的**活动词**(core `PeerSessionTempo`)——「这条会话现在在忙还是闲」的**唯一**一张表([ref])。
245
+ *
246
+ * 🔴 为什么键是 {@link RunStatusClass} 而不是状态词本身:那张状态词表(`RUN_STATUS_CLASS`)已经是这条
247
+ * 车道上唯一的词表属主,它的双向编译钉(core 加词 / 删词当场红)是本表白拿的。按状态词再写一张表
248
+ * 等于给同一族词开第二个写者 —— 正是那张表顶注里 `blocked` 长出两个互斥读法的成因。
249
+ *
250
+ * 🔴 两处读、一处定义:core 的 peer 目录席(`src/peer-directory.ts` 的 `PeerSessionRecord.tempo`)与
251
+ * `GET /v1/sessions` 列表行的 `tempo` 列读的是**同一次调用**的结果 —— 同一条会话在两个面上不可能给出
252
+ * 两个不同的忙闲。
253
+ *
254
+ * 🔴 **未知词 / 无读数 ⇒ 词缺席(`undefined`),不是 `idle`**(诚实缺席,不是静默兜底):
255
+ * · `lastStatus === ""` 有两个真来源 ——(a)SQL 双生:这条会话**还没有任何 run**;(b)local 文件车道:
256
+ * 该 lister **结构上说不出** run 状态(`local-session-store.ts:465` 自陈的诚实降级 `lastStatus: ""`)。
257
+ * 两者都不是「我知道它闲着」,所以都不许铸出 `idle` 这个断言;
258
+ * · 读不懂的词(滚动升级里新副本写入的新状态词经裸 cast 读到本进程)同判 —— 与
259
+ * {@link isTerminalRunStatus} 族的保守方向一致。
260
+ * · **缺席怎么渲染是 core 的辖域,本仓一个字都不复述**(S-514-JU:此处原写着「core 对缺席渲染成 `idle`」
261
+ * 并抄了当时 `list-agents-tool.js:58` 的旧三元 —— core 7.24.0 [ref] 把那一臂改成了一张 own-key 表 +
262
+ * `unknown` 兜底,于是那句复述当天就成了假话,而它在本仓被抄了六处)。本表只负责**铸不铸键**;
263
+ * 渲染词由**跑真 core 的黑盒**钉住(`test/peer-lane-e2e.test.ts` 的 G1 格),core 哪天再改那张表,
264
+ * 红的是那一格而不是一句注释。承重的不变量只有一条,且它与渲染词无关:**缺席绝不铸 `dead`** ——
265
+ * `liveness` 才是 core 会拿来**整行隐藏**的那一位(7.24.2 dist 的 `list-agents-tool.js:60/62`),而任何 tempo 词
266
+ * (含缺席)都照样列出。
267
+ */
268
+ export declare const SESSION_TEMPO_BY_RUN_CLASS: {
269
+ readonly live: "active";
270
+ readonly parked: "blocked";
271
+ readonly succeeded: "idle";
272
+ readonly unsuccessful: "idle";
273
+ };
274
+ /** {@link SESSION_TEMPO_BY_RUN_CLASS} 的查表面 —— 入参是**最近一条 run 的状态词**({@link SessionSummary.lastStatus},
275
+ * 开集 `string`:它来自无约束的 SQL 文本列 / 诚实降级的空串,收成闭集等于在签名上撒谎)。
276
+ * 返回词缺席 ⇒ 调用方**不铸键**(不是铸 `idle`),理由逐字见上表头注。 */
277
+ export declare function sessionTempoOfRunStatus(lastStatus: string): PeerSessionTempo | undefined;
243
278
  /**
244
279
  * 「**证得出**这条 run 活在别的副本上」——「他副本」那一句话的**唯一判据**(S-381 收编;S-430 迁居)。
245
280
  *
@@ -23,6 +23,16 @@ export function isUnsuccessfulRunStatus(status) {
23
23
  export function isParkedRunStatus(status) {
24
24
  return runStatusClassOf(status) === "parked";
25
25
  }
26
+ export const SESSION_TEMPO_BY_RUN_CLASS = {
27
+ live: "active",
28
+ parked: "blocked",
29
+ succeeded: "idle",
30
+ unsuccessful: "idle",
31
+ };
32
+ export function sessionTempoOfRunStatus(lastStatus) {
33
+ const cls = runStatusClassOf(lastStatus);
34
+ return cls === undefined ? undefined : SESSION_TEMPO_BY_RUN_CLASS[cls];
35
+ }
26
36
  export function runProvenElsewhere(run, thisInstanceId) {
27
37
  return typeof run.instanceId === "string" && run.instanceId.length > 0 && thisInstanceId !== undefined && run.instanceId !== thisInstanceId;
28
38
  }
@@ -20,7 +20,8 @@ export function keyClosureClause(names, label) {
20
20
  const TASK_BODY_DECLARED_KEYS = [
21
21
  "a2aPeers", "acceptPartialRestore", "additionalDirectories", "additionalReadDirectories", "agents",
22
22
  "appendSystemPrompt", "attachmentIds", "attachments", "capabilitiesNeeded", "cascade", "clientContext",
23
- "compaction", "compactionModel", "council", "cwd", "debate", "deferTools", "deviceId", "enableFork", "excludeTools",
23
+ "compaction", "compactionModel", "council", "cwd", "debate", "deferTools", "deviceId", "enableFork",
24
+ "excludeAllTools", "excludeTools",
24
25
  "finalVerification", "forwardSubagentEvents", "images", "interactiveTools", "jobId", "limits", "maxCostUsd",
25
26
  "maxTokens", "mcpServers", "memory", "memoryCapture", "memoryWrite", "model", "objective", "oneShot", "outputRetries",
26
27
  "outputSchema", "permissionMode", "projectId", "promptProfile", "reasoningEffort", "requireExistingSession",
@@ -1,3 +1,4 @@
1
+ import { type DirectWritableRuleBehavior } from "./permission-rule-vocab.js";
1
2
  import { type RuleOwner, type ImportPreview, type RedeemedBatchMember, type ImportedSettingsLayer, type PersistedRule, type RuleBehavior, type RuleCandidate, type EffectivePermissionRule, type RemoveResult, type EditedRuleTextPrecheck, type RuleScope, type RuleOffer } from "@sema-agent/core";
2
3
  import type { DurableRulePartitionProvider, PermissionRuleStoreProvider, RuleApprovalRecordStore } from "@sema-agent/core";
3
4
  import { type RuleImportTicket, type RuleTicketPurpose, type RuleTicketReclaimResult, type RuleTicketRedeemResult, type RuleTicketSnapshot } from "./plugins/permission-rule-store-sql.js";
@@ -155,7 +156,12 @@ export interface LocalImportRuleKey {
155
156
  *
156
157
  * 🔴 闭集而不是自由文本:客户端要按拒因分流(「这条是你本机的数据坏了」vs「这条规则本身不合法」是
157
158
  * 两句不同的话),而自由文本只能被展示、不能被分支。`detail` 才是给人看的那一半。
158
- */
159
+ *
160
+ * 🔴 S-511 起**两条腿共用这一张表**(导入面的逐行预览 + 单步写口的拒):它们判的是同一件事,而判据
161
+ * 本身就是同一个函数({@link screenLocalImportRow})。给写口另起一张同形的表,就是给同一个问题立第二个
162
+ * 说话人 —— 漂的表现会是「同一条规则在一条腿上收、在另一条腿上拒」。写口的**方向**判据不在这张表里:
163
+ * 它在端点上先判、有自己的整句文案,而且判完就把类型收窄掉(`permission-rule-vocab.ts` 的
164
+ * `DIRECT_RULE_WRITE_ADMITS`)⇒ 放宽态根本到不了本表所服务的那段代码。 */
159
165
  export type LocalImportRefusalCode =
160
166
  /** scope 串读不出形(既不是 `global` 也不是 `project:<非空 root>`)。 */
161
167
  "scope_unreadable"
@@ -501,6 +507,22 @@ export declare function parseRuleScope(text: string): DurableRuleScope | undefin
501
507
  /** 一条规则在 wire 上的行 = 一个逻辑 (rule, scope) 对([ref] v2 §6 F5)。
502
508
  * `adds` **如实上 wire**、不折叠成单值:同一条规则可以被批准过多次(不同 dot、不同来源、不同时刻),
503
509
  * 折成「一个 addedAt」会让「这条规则是我导入的还是我点过卡的」在治理面上不可分。 */
510
+ /**
511
+ * 一条 wire 行与一份身份三元组是不是**同一条规则**。
512
+ *
513
+ * 🔴 **三元组,不是 `(rule, scope)` 二元组**(异源复核 [medium],验真后修):core 的 `sameRuleIdentity`
514
+ * 头注逐字 ——「a comparison that forgot the behavior member would let a delete aimed at an allow take the
515
+ * same-text deny」。漏掉 behavior 的比较在 wire 上的表现不是「删错行」,是**报错行的死活**:撤一条从来
516
+ * 没有过的 `allow` 会因为看见同文本的兄弟 `deny` 而答 `stillLive: true`,那句话说的是另一条规则。
517
+ *
518
+ * 凡「这份清单里有没有这条规则」一律走这一只(撤销口的存活回读 / 单步写口的写前问与写后读回)——
519
+ * 同一个问题有两个说话人,迟早会给两个答案。
520
+ */
521
+ export declare function sameWireRuleIdentity(row: PersistedRuleWireRow, identity: {
522
+ behavior: RuleBehavior;
523
+ rule: string;
524
+ scope: string;
525
+ }): boolean;
504
526
  export interface PersistedRuleWireRow {
505
527
  /** core 7.9.0([ref])—— 这一行是哪一态的规则(`deny` / `ask` / `allow`)。**身份的一格**:
506
528
  * 规则的身份是 (behavior, text, scope) 三元组,同文本不同态是两条不同的行(core `sameRuleIdentity`),
@@ -557,6 +579,37 @@ export interface PersistedRuleListing {
557
579
  * 不可分。判别式联合是唯一诚实的形。
558
580
  */
559
581
  export type RuleBucketRef = string | RuleOwner;
582
+ /**
583
+ * S-511 —— 单步写的结果(闭集三臂)。
584
+ *
585
+ * 🔴 三臂**刻意不塌成布尔**:「落了」「早就有了」「不知道落没落」对调用方是三种不同的处置,而把第三种
586
+ * 折进前两种里的任一边都是编一个结论(猜"成了"会谎报一次没发生的写,猜"没成"会让人以为可以重来)。
587
+ * 与导入面的成员级四态({@link LocalImportMemberOutcome})同一条纪律,词更少是因为本口只有一行。
588
+ */
589
+ export type RuleWritten =
590
+ /** `persisted` = 这一次**真的往店里写了**;`no-op` = 这条身份**此前就站在店里**,本次调用**一个字都没写**
591
+ * (幂等重写)。两支都带 `row` —— 它是**从店里读出来的**那一行,不是我方按入参拼出来的回声。
592
+ *
593
+ * 🔴 两个词的判别位是「**这次调用有没有写**」,不是 core 的 `deduped`(codex 对抗复审 F1,验真后修)。
594
+ * core 的 `deduped` 只说「等价规则此前已站」,它**仍然落了一枚新的 dot**(契约逐字:`deduped` counts
595
+ * as LANDED;亲读 dist 的成员循环:`res.dot` 无条件进 members)。把它当 `no-op` 上 wire 是一句假话:
596
+ * 一个照常重试的客户端会让 `adds` 台账无界增长、桶的 rev 逐次推进(在翻页的治理面全体重列)。 */
597
+ {
598
+ status: "persisted" | "no-op";
599
+ rev: number;
600
+ row: PersistedRuleWireRow;
601
+ }
602
+ /** 裁定性不收(内容判据 / 方向判据)。`detail` 是给人看的那一半,`reason` 是给客户端分流的那一半。 */
603
+ | {
604
+ status: "rejected";
605
+ reason: LocalImportRefusalCode;
606
+ detail: string;
607
+ }
608
+ /** **不知道落没落**:店在这一条上出了状况。如实编,禁猜(端点侧 503 + 指路 `GET /v1/rules` 对账)。 */
609
+ | {
610
+ status: "indeterminate";
611
+ detail: string;
612
+ };
560
613
  export interface RuleConsentLane {
561
614
  /** 卡道兑付口:一次 ask 决议携规则确认 ⇒ prepare→confirm→redeem 一气呵成。 */
562
615
  persistCardRule(input: {
@@ -608,6 +661,26 @@ export interface RuleConsentLane {
608
661
  rule: string;
609
662
  scope: RuleScope;
610
663
  }): Promise<RemoveResult>;
664
+ /**
665
+ * S-511 撤销面写半场的**对偶** —— 一条**收紧**规则(deny/ask)的单步写入,无同意仪式往返。
666
+ *
667
+ * 它为什么可以没有仪式,与 `removeRule` 为什么可以没有仪式是**同一条理由**(core
668
+ * `removePersistedRule` 头注逐字:「a host may narrow on a user's behalf, it may not widen」)。放宽
669
+ * 方向在类型上就到不了这里:`behavior` 收窄成 {@link DirectWritableRuleBehavior}。
670
+ *
671
+ * 🔴 **一行都不自造**:行的 tool/match/command 三格是规则文本的**派生事实**,由 core 的
672
+ * `parseRuleText` 铸;落店走的是导入面那条**同一条**兑付腿(逐行判据 → core 的记录铸造口 → confirm
673
+ * → 批兑付),于是本口与导入口对「一条规则能不能进店」永远给同一个答案。
674
+ *
675
+ * `principal` 是裸串(与 prepare/redeem 两腿同形);local-owner 桶**不在本口**上 —— 那只桶没有
676
+ * 「谁在写」这个主体,而本口的授权基础就是已验明的 principal。
677
+ */
678
+ writeRule(input: {
679
+ principal: string;
680
+ behavior: DirectWritableRuleBehavior;
681
+ rule: string;
682
+ scope: DurableRuleScope;
683
+ }): Promise<RuleWritten>;
611
684
  }
612
685
  export declare function createRuleConsentLane(stores: RuleConsentStores, opts?: {
613
686
  ticketTtlMs?: number;
@@ -3,6 +3,7 @@ import { z } from "zod";
3
3
  import { recordFailOpen } from "./observability/fail-open.js";
4
4
  import { createLogger } from "./observability/logger.js";
5
5
  import { MAX_CWD_CHARS } from "./task-cwd.js";
6
+ import { isDirectWritableRuleBehavior } from "./permission-rule-vocab.js";
6
7
  import { CONTROL_AND_BIDI_CHARS, hasBidiControls } from "./text-bidi.js";
7
8
  import { confirmRuleApproval, RULE_BEHAVIORS, parseRuleText, ruleReachesProgramRun, ruleToolGrammarOf, precheckEditedRuleText, prepareCardApproval, prepareCcImport, redeemRuleBatch, redeemRuleTicket, removePersistedRule, effectiveOrThrow, readRowBehavior, } from "@sema-agent/core";
8
9
  import { buildRulePayloadHash } from "./plugins/permission-rule-store-sql.js";
@@ -59,6 +60,9 @@ export function parseRuleScope(text) {
59
60
  const root = text.slice("project:".length);
60
61
  return root === "" ? undefined : { kind: "project", root };
61
62
  }
63
+ export function sameWireRuleIdentity(row, identity) {
64
+ return row.behavior === identity.behavior && row.rule === identity.rule && row.scope === identity.scope;
65
+ }
62
66
  function storeForBucket(provider, ref) {
63
67
  if (typeof ref === "string")
64
68
  return provider.forPrincipal(ref);
@@ -256,7 +260,7 @@ export function createRuleConsentLane(stores, opts) {
256
260
  const monotonic = opts?.monotonic ?? (() => performance.now());
257
261
  const sleep = opts?.sleep ?? (async (ms) => new Promise((resolve) => setTimeout(resolve, ms)));
258
262
  const localImportLogger = createLogger();
259
- return {
263
+ const lane = {
260
264
  async persistCardRule(input) {
261
265
  let prepared;
262
266
  try {
@@ -710,6 +714,82 @@ export function createRuleConsentLane(stores, opts) {
710
714
  async removeRule(input) {
711
715
  return await removePersistedRule({ behavior: input.behavior, rule: input.rule, scope: input.scope, principal: input.principal, provider: stores.provider });
712
716
  },
717
+ async writeRule(input) {
718
+ const written = parseRuleText(input.rule, input.behavior);
719
+ if ("reject" in written)
720
+ return { status: "rejected", reason: "rule_rejected", detail: `${written.reject.code}: ${written.reject.message}` };
721
+ if (input.rule !== written.rule.rule) {
722
+ return {
723
+ status: "rejected",
724
+ reason: "rule_rejected",
725
+ detail: `this rule text is not the spelling the store would hold: send ${JSON.stringify(written.rule.rule)} instead ` +
726
+ `— a rule written under one spelling and held under another cannot be listed or revoked by the bytes you sent`,
727
+ };
728
+ }
729
+ const row = {
730
+ behavior: input.behavior,
731
+ rule: input.rule,
732
+ scope: serializeRuleScope(input.scope),
733
+ tool: written.rule.tool,
734
+ match: written.rule.match,
735
+ command: written.rule.command,
736
+ adds: [],
737
+ };
738
+ const rowOf = (rows) => rows.find((r) => sameWireRuleIdentity(r, { behavior: input.behavior, rule: written.rule.rule, scope: row.scope }));
739
+ const before = await lane.listRules(input.principal);
740
+ const standing = rowOf(before.rows);
741
+ if (standing !== undefined)
742
+ return { status: "no-op", rev: before.rev, row: standing };
743
+ const prepared = await lane.prepareLocalImport(input.principal, [row]);
744
+ if (!prepared.ok) {
745
+ throw new Error(`a single-rule write hit the batch ceiling (${prepared.reason}) — the write door and the batch caps are out of step`);
746
+ }
747
+ if (prepared.ticket === undefined) {
748
+ const member = prepared.preview.members[0];
749
+ if (member === undefined || member.admitted) {
750
+ throw new Error("the rule screening reported neither an admitted row nor a refusal for this write — refusing to guess which of the two happened");
751
+ }
752
+ return { status: "rejected", reason: member.reason, detail: member.detail };
753
+ }
754
+ const redeemed = await lane.redeemLocalImport(input.principal, prepared.ticket);
755
+ if (!redeemed.ok) {
756
+ localImportLogger.warn("direct_rule_write_ticket_refused", {
757
+ reason: redeemed.reason,
758
+ detail: redeemed.detail,
759
+ note: "a self-minted, immediately redeemed ticket was refused — the outcome of this write is not known",
760
+ });
761
+ return { status: "indeterminate", detail: `${redeemed.reason}: ${redeemed.detail}` };
762
+ }
763
+ const member = redeemed.outcome.members[0];
764
+ if (member === undefined) {
765
+ throw new Error("the redemption of a single-rule write came back with no member — refusing to report an outcome nobody computed");
766
+ }
767
+ switch (member.outcome) {
768
+ case "persisted":
769
+ break;
770
+ case "no-op":
771
+ break;
772
+ case "indeterminate":
773
+ return { status: "indeterminate", detail: member.detail ?? member.reason ?? "the store did not say whether this rule landed" };
774
+ case "refused":
775
+ return { status: "indeterminate", detail: member.detail ?? "the store refused this rule without a machine-readable reason" };
776
+ default: {
777
+ const unknown = member.outcome;
778
+ throw new Error(`unknown single-rule write outcome: ${JSON.stringify(unknown)}`);
779
+ }
780
+ }
781
+ const landed = rowOf((await lane.listRules(input.principal)).rows);
782
+ if (landed === undefined) {
783
+ localImportLogger.warn("direct_rule_write_vanished_before_read_back", {
784
+ behavior: input.behavior,
785
+ scope: row.scope,
786
+ note: "the store reported this rule as landed, and it was already gone when the row was read back — a revocation landed in between",
787
+ });
788
+ return { status: "indeterminate", detail: "this rule landed and was already gone when it was read back — a revocation landed in between" };
789
+ }
790
+ return { status: "persisted", rev: redeemed.outcome.rev, row: landed };
791
+ },
713
792
  };
793
+ return lane;
714
794
  }
715
795
  //# sourceMappingURL=rules-consent.js.map
@@ -24,12 +24,14 @@
24
24
  * name). Real isolation is the `executionEnv` sandbox. Same caveat core documents on `createCoarseCommandNamePolicy`.
25
25
  */
26
26
  import { type TaskSpec, type ToolPolicy } from "@sema-agent/core";
27
+ import { GovernanceConfig } from "@sema-agent/settings-schema";
27
28
  import { type GovernanceAskMarks } from "./governance-ask-marks.js";
28
- /** A per-command governance rule (registry `runtime.commandPolicy[]`). */
29
- export type CommandRule = {
30
- command: string;
31
- decision: "allow" | "ask" | "deny";
32
- };
29
+ /** 运维 autonomy 四档的**闭集**(同上)。`config.ts` 的 `parseAutonomy` 也从这里读 —— 四档词表全仓一份。 */
30
+ export declare const AUTONOMY_MODES: ["read-only", "ask", "plan", "auto"];
31
+ /** A per-command governance rule (registry `runtime.commandPolicy[]`) —— **型也从契约派生**:
32
+ * 契约给 `command` 加一条 refine / 给 `decision` 加一个判词,本仓的型当场跟上(或在下面的
33
+ * `RANK` / 穷尽 switch 上编译红),而不是靠人记得把手写联合改一遍。 */
34
+ export type CommandRule = NonNullable<GovernanceConfig["commandPolicy"]>[number];
33
35
  /** True when `name` names a DELEGATION tool — the subagent tool(CC-187 canonical `"Agent"`,core
34
36
  * `DEFAULT_SUBAGENT_TOOL_NAME`)。5.0.0 [ref]:折叠面退役,RAW 比对 core 单源常量——旧名("Task")
35
37
  * 在 core roster 层响亮 miss,不再需要归一;单源常量本身就防了当年 1.202 改名时裸字符串失配的
@@ -50,8 +52,8 @@ export declare function isDelegationToolName(name: string): boolean;
50
52
  export declare function stripDelegationTools<T extends {
51
53
  name: string;
52
54
  }>(tools: readonly T[]): T[];
53
- /** The operator autonomy mode (registry `runtime.autonomy`). */
54
- export type Autonomy = "read-only" | "ask" | "plan" | "auto";
55
+ /** The operator autonomy mode (registry `governance.autonomy`) —— 契约派生(见 {@link AUTONOMY_MODES})。 */
56
+ export type Autonomy = NonNullable<GovernanceConfig["autonomy"]>;
55
57
  /** 导出给 `test/coarse-shell-tools-mirror.test.ts` 的同源钉用(见那里的顶注)。 */
56
58
  export declare const COARSE_SHELL_TOOLS_MIRROR: readonly string[];
57
59
  /** Validate `commandPolicy` rules — the shape (array of {command, decision}), the `command` (a bare argv[0] name,
@@ -81,6 +83,13 @@ export declare function validateCommandRules(rules: CommandRule[]): string[];
81
83
  * Either way the result only ever ADDS asks/denies — composed onto the baseline via `combinePolicies`
82
84
  * (deny-wins) by {@link applyRuntimeGovernance}, so it is tighten-only. (Assumes commands are pre-validated by
83
85
  * {@link validateCommandRules}; an un-matchable command here is simply inert, never a parse error.)
86
+ *
87
+ * 🔴 **S-496:词表外的 `decision` 折 `deny`,不折 `ask`。** {@link validateCommandRules} 的头注写下的那条
88
+ * 后果(「域外的 `decision` 穿过本函数的三元链落到 ASK,静默把一条 intended deny 弱化掉」)此前只靠
89
+ * **调用方记得先校验**挡着 —— 而本函数在 `resolve-spec` 里是逐请求现调的,`config.commandPolicy` 的来路
90
+ * (center hot apply / 本地 `config.d/governance.json` / LKG / 测试直塞)不止一条。现在闭集在本函数**内部**
91
+ * 收口:`VALID_DECISIONS` 之外的值一律折最严的 `deny`(fail-closed,[ref] 的运行期 miss 臂,下方自陈),
92
+ * 闭集本身则由契约 zod 派生 ⇒ 契约加一个判词,`RANK` 与桶分配的穷尽 switch 双双**编译红**。
84
93
  */
85
94
  export declare function compileCommandPolicy(rules: CommandRule[] | undefined): ToolPolicy | undefined;
86
95
  /**
@@ -1,7 +1,10 @@
1
1
  import { combinePolicies, COARSE_SHELL_TOOLS as CORE_COARSE_SHELL_TOOLS, createCoarseCommandNamePolicy, DEFAULT_SUBAGENT_TOOL_NAME, parseLeadingCommandName, tightenTaskSpec, } from "@sema-agent/core";
2
- import { SAFETY_MERGE_SPEC } from "@sema-agent/settings-schema";
2
+ import { GovernanceConfig, SAFETY_MERGE_SPEC } from "@sema-agent/settings-schema";
3
3
  import { currentGovernanceAskMarks } from "./governance-ask-marks.js";
4
4
  import { operatorAsk } from "./operator-ask.js";
5
+ const COMMAND_RULE_SHAPE = GovernanceConfig.shape.commandPolicy.unwrap().element.shape;
6
+ const COMMAND_DECISIONS = COMMAND_RULE_SHAPE.decision.options;
7
+ export const AUTONOMY_MODES = GovernanceConfig.shape.autonomy.unwrap().options;
5
8
  export function isDelegationToolName(name) {
6
9
  return name === DEFAULT_SUBAGENT_TOOL_NAME;
7
10
  }
@@ -30,7 +33,9 @@ function createCommandAskListPolicy(askCommands, tools = COARSE_SHELL_TOOLS) {
30
33
  };
31
34
  }
32
35
  const VALID_COMMAND_NAME = /^[A-Za-z0-9._+-]+$/;
33
- const VALID_DECISIONS = new Set(["allow", "ask", "deny"]);
36
+ function isCommandDecision(raw) {
37
+ return typeof raw === "string" && COMMAND_DECISIONS.includes(raw);
38
+ }
34
39
  export function validateCommandRules(rules) {
35
40
  const errors = [];
36
41
  if (!Array.isArray(rules))
@@ -45,8 +50,8 @@ export function validateCommandRules(rules) {
45
50
  errors.push(`commandPolicy command "${String(command)}" is not a bare argv[0] name — core matches the EXACT command name (no glob/path/operators). ` +
46
51
  `Use e.g. "git" not "git*"; to gate sub-commands or pipes, deny the interpreter ("sh"/"bash") + rely on the sandbox.`);
47
52
  }
48
- if (typeof decision !== "string" || !VALID_DECISIONS.has(decision)) {
49
- errors.push(`commandPolicy command "${String(command)}" has invalid decision "${String(decision)}" — must be one of allow | ask | deny`);
53
+ if (!isCommandDecision(decision)) {
54
+ errors.push(`commandPolicy command "${String(command)}" has invalid decision "${String(decision)}" — must be one of ${COMMAND_DECISIONS.join(" | ")}`);
50
55
  }
51
56
  }
52
57
  return errors;
@@ -57,15 +62,30 @@ export function compileCommandPolicy(rules) {
57
62
  const RANK = { deny: 3, ask: 2, allow: 1 };
58
63
  const strictest = new Map();
59
64
  for (const r of rules) {
65
+ const decision = isCommandDecision(r.decision) ? r.decision : "deny";
60
66
  const prev = strictest.get(r.command);
61
- if (prev === undefined || RANK[r.decision] > RANK[prev])
62
- strictest.set(r.command, r.decision);
67
+ if (RANK[decision] > (prev === undefined ? 0 : RANK[prev]))
68
+ strictest.set(r.command, decision);
63
69
  }
64
70
  const deny = [];
65
71
  const allow = [];
66
72
  const ask = [];
67
73
  for (const [command, decision] of strictest) {
68
- (decision === "deny" ? deny : decision === "allow" ? allow : ask).push(command);
74
+ switch (decision) {
75
+ case "deny":
76
+ deny.push(command);
77
+ break;
78
+ case "allow":
79
+ allow.push(command);
80
+ break;
81
+ case "ask":
82
+ ask.push(command);
83
+ break;
84
+ default: {
85
+ const unhandled = decision;
86
+ throw new Error(`commandPolicy decision "${String(unhandled)}" has no compilation arm`);
87
+ }
88
+ }
69
89
  }
70
90
  const policies = [];
71
91
  if (allow.length > 0) {
@@ -0,0 +1,103 @@
1
+ /**
2
+ * `SessionPermissionRules` 的 **wire 形读者** —— 两条写入腿(`PUT /v1/sessions/:id/policy` 与
3
+ * `POST /v1/sessions/:id/sync/import` 的 `policy` 面)**唯一**的形判据。
4
+ *
5
+ * ── 为什么是一只共享读者(S-496 / [ref],取证 P4 + S-498)───────────────────────────────────────────
6
+ * 改前两条腿各自为政:
7
+ * · PUT 腿在 `http/routes/sessions.ts` 里**手抄**五桶键名(`["toolAllow","toolDeny","allowDirs",
8
+ * "commandAllow","commandDeny"] as const`),core 的 `SessionPermissionRules` 加第六桶 ⇒ 那一桶在
9
+ * PUT 上被**静默丢**、200 回执却说「已生效」;
10
+ * · sync/import 腿(S-498)只判 `Array.isArray(body.policy)` **外层**,逐条记录的 `principal` / `rules`
11
+ * 一个字节都不校验就裸交 `replayPolicyRecords` → `putRules`。坏形走的是**放宽**方向,亲读 core
12
+ * `session-policy-store.js` 两处实现:
13
+ * · `normalizeRules` 对每桶做 `[...rules.toolDeny]` ⇒ 一个**字符串** `"rm"` 被展开成 `["r","m"]`,
14
+ * 本来该拒 `rm` 的 deny 规则变成拒两个不存在的命令名 = 门整条失效;
15
+ * · `allowDirs` 走 `.map(...)` ⇒ 字符串上没有 `.map` ⇒ **TypeError → 500**(路由头注 `:300-302`
16
+ * 自陈「Malformed shape → 400(never a downstream TypeError)」,这一半没守);
17
+ * · `loosenReasons` 的每一臂都从 `prior` 出发,**prior 为空时恒回空集** ⇒ tighten-only 门对一条
18
+ * 全新的会话放行任何内容,坏形因此连「收紧门」这一层都碰不到。
19
+ * ⇒ 类修:一只读者,键集由 `keyof SessionPermissionRules` **派生**(下方 `RULE_FIELD_PRESENCE` 的
20
+ * `satisfies Record<keyof SessionPermissionRules, true>`:core 加桶 ⇒ 缺键**编译红**;core 删桶 ⇒
21
+ * 多键**编译红**),两条腿同调。
22
+ *
23
+ * ── 文案也在这里(codex 一轮追加:两条腿各铸一份句子 = 第二份手抄)───────────────────────────────
24
+ * 初版把 wire 文案留在两个路由各自铸,理由是错误文案冻结门(`test/api-error-text-freeze.test.ts`)锚的是
25
+ * `src/http/` 桶里的 `sendError` 站点。codex 一轮指出那等于**把同一句话写了两遍**(`sessions.ts` 与
26
+ * `session-sync.ts` 各一份),这正是本批要消灭的形。改法照本仓既有成案(S-405 / [ref] / [ref] 三处
27
+ * 「常量锚」):句子收进本文件**单一属主**,冻结职责搬到冻结门的**常量锚格** —— 那一格直接断言这两只
28
+ * 渲染器对每一种判词的输出字节,且对判词闭集穷尽(加一种判词 ⇒ 那一格编译红)。文案一个字节未改。
29
+ */
30
+ import type { SessionPermissionRules, SessionRulesRecord } from "@sema-agent/core";
31
+ /** core `SessionPermissionRules` 的桶名全集(派生,不是手抄)。 */
32
+ export declare const SESSION_PERMISSION_RULE_FIELDS: readonly (keyof SessionPermissionRules)[];
33
+ /** 一条规则体的形问题。判词而非文案(见文件头注的「文案属主」一段)。 */
34
+ export type SessionPermissionRulesProblem =
35
+ /** 整体不是一只普通对象(`null` / 数组 / 标量 / 缺席)。 */
36
+ {
37
+ kind: "not-an-object";
38
+ }
39
+ /** 某一桶在场但不是 `string[]`(字符串 / 数字 / 混杂数组都在此)。 */
40
+ | {
41
+ kind: "field-not-string-array";
42
+ field: keyof SessionPermissionRules;
43
+ };
44
+ /** {@link readSessionPermissionRules} 的读数:坏形给判词,好形给**已收窄**的规则体(调用方零 cast)。 */
45
+ export type SessionPermissionRulesRead = {
46
+ problem: SessionPermissionRulesProblem;
47
+ rules?: undefined;
48
+ } | {
49
+ problem?: undefined;
50
+ rules: SessionPermissionRules;
51
+ };
52
+ /**
53
+ * 读一只 wire 上的 `SessionPermissionRules`:形不对给判词,形对给**只含五桶**的规则体。
54
+ *
55
+ * 认识面窄得刻意:**只判形**,不判内容(桶内字符串的 pattern 文法、路径是否可达、tighten-only 是否成立
56
+ * 都不在这里 —— 前两者归 core 的 rule 文法与 `normalizeRules`,后者归 `putRules` 的收紧门)。
57
+ * 未知键**照旧忽略**(不是新的拒因):core 的 `normalizeRules` 本来就只搬这五桶,本读者与它同口径。
58
+ */
59
+ export declare function readSessionPermissionRules(raw: unknown): SessionPermissionRulesRead;
60
+ /** 一条 `SessionRulesRecord` 的形问题(`policy[]` 的元素级;桶级问题原样转载在 `rules` 位)。 */
61
+ export type SessionRulesRecordProblem =
62
+ /** `policy` 本身不是数组。 */
63
+ {
64
+ kind: "not-an-array";
65
+ }
66
+ /** 第 `index` 条记录不是一只普通对象。 */
67
+ | {
68
+ kind: "record-not-an-object";
69
+ index: number;
70
+ }
71
+ /** 第 `index` 条的 `principal` 在场但不是字符串(**缺席**才是「会话级默认行」的合法写法 —— 两个店的
72
+ * `listBySession` 对无 principal 的行都产 `undefined`,JSON 序列化后即缺席,所以 `null` 不在受理面)。 */
73
+ | {
74
+ kind: "principal-not-a-string";
75
+ index: number;
76
+ }
77
+ /** 第 `index` 条的 `rules` 形不对(转载桶级判词)。 */
78
+ | {
79
+ kind: "rules";
80
+ index: number;
81
+ problem: SessionPermissionRulesProblem;
82
+ };
83
+ /** {@link readSessionRulesRecords} 的读数。 */
84
+ export type SessionRulesRecordsRead = {
85
+ problem: SessionRulesRecordProblem;
86
+ records?: undefined;
87
+ } | {
88
+ problem?: undefined;
89
+ records: SessionRulesRecord[];
90
+ };
91
+ /**
92
+ * 读 sync/import 的 `policy` 面(`SessionRulesRecord[]`)。好形返回**已收窄**的记录数组,坏形给判词。
93
+ *
94
+ * `rev`:记录里带的 `rev` 是**导出端的**乐观锁读数,`putRules` 侧一进门就 `stripRev` 掉(`sameRulesContent`
95
+ * 也比的是去 rev 的字节形),所以它在本读者里只是过境位 —— 非数字即当缺席(不是拒因:一个坏 rev 改变不了
96
+ * 任何一次写入的结果,拒它只会把合法同步砖死)。
97
+ */
98
+ export declare function readSessionRulesRecords(raw: unknown): SessionRulesRecordsRead;
99
+ /** 桶级判词 → 人话。闭集穷尽:{@link SessionPermissionRulesProblem} 加一种判词 ⇒ 本 switch 编译红。 */
100
+ export declare function describeSessionPermissionRulesProblem(p: SessionPermissionRulesProblem): string;
101
+ /** `policy[]` 判词 → 人话(sync/import 腿在它前面拼 `policy: `)。闭集穷尽同上。 */
102
+ export declare function describeSessionRulesRecordProblem(p: SessionRulesRecordProblem): string;
103
+ //# sourceMappingURL=session-policy-wire.d.ts.map
@@ -0,0 +1,66 @@
1
+ const RULE_FIELD_PRESENCE = {
2
+ toolAllow: true,
3
+ toolDeny: true,
4
+ allowDirs: true,
5
+ commandAllow: true,
6
+ commandDeny: true,
7
+ };
8
+ export const SESSION_PERMISSION_RULE_FIELDS = Object.keys(RULE_FIELD_PRESENCE);
9
+ export function readSessionPermissionRules(raw) {
10
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
11
+ return { problem: { kind: "not-an-object" } };
12
+ const src = raw;
13
+ const rules = {};
14
+ for (const field of SESSION_PERMISSION_RULE_FIELDS) {
15
+ const v = src[field];
16
+ if (v === undefined)
17
+ continue;
18
+ if (!Array.isArray(v) || !v.every((x) => typeof x === "string"))
19
+ return { problem: { kind: "field-not-string-array", field } };
20
+ rules[field] = v;
21
+ }
22
+ return { rules };
23
+ }
24
+ export function readSessionRulesRecords(raw) {
25
+ if (!Array.isArray(raw))
26
+ return { problem: { kind: "not-an-array" } };
27
+ const records = [];
28
+ for (let index = 0; index < raw.length; index++) {
29
+ const e = raw[index];
30
+ if (e === null || typeof e !== "object" || Array.isArray(e))
31
+ return { problem: { kind: "record-not-an-object", index } };
32
+ const { principal, rules, rev } = e;
33
+ if (principal !== undefined && typeof principal !== "string")
34
+ return { problem: { kind: "principal-not-a-string", index } };
35
+ const read = readSessionPermissionRules(rules);
36
+ if (read.problem !== undefined)
37
+ return { problem: { kind: "rules", index, problem: read.problem } };
38
+ const innerRev = rules?.rev;
39
+ const carried = typeof innerRev === "number" ? innerRev : typeof rev === "number" ? rev : 0;
40
+ records.push({ principal, rules: { ...read.rules, rev: carried } });
41
+ }
42
+ return { records };
43
+ }
44
+ export function describeSessionPermissionRulesProblem(p) {
45
+ switch (p.kind) {
46
+ case "not-an-object": return "rules must be a SessionPermissionRules object";
47
+ case "field-not-string-array": return `${p.field} must be an array of strings`;
48
+ default: {
49
+ const unhandled = p;
50
+ throw new Error(`unhandled SessionPermissionRules problem: ${JSON.stringify(unhandled)}`);
51
+ }
52
+ }
53
+ }
54
+ export function describeSessionRulesRecordProblem(p) {
55
+ switch (p.kind) {
56
+ case "not-an-array": return "must be an array of {principal, rules} records";
57
+ case "record-not-an-object": return `[${p.index}] must be a {principal, rules} record`;
58
+ case "principal-not-a-string": return `[${p.index}].principal must be a string when present (omit it for the session-wide row)`;
59
+ case "rules": return `[${p.index}].rules: ${describeSessionPermissionRulesProblem(p.problem)}`;
60
+ default: {
61
+ const unhandled = p;
62
+ throw new Error(`unhandled SessionRulesRecord problem: ${JSON.stringify(unhandled)}`);
63
+ }
64
+ }
65
+ }
66
+ //# sourceMappingURL=session-policy-wire.js.map