@sema-agent/server 7.119.0 → 7.120.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 (61) hide show
  1. package/MIGRATION.md +54 -0
  2. package/USAGE.md +5 -3
  3. package/dist/boot/config-center.js +5 -5
  4. package/dist/boot/execution-env.js +3 -2
  5. package/dist/boot/fatal-exit.d.ts +7 -1
  6. package/dist/boot/fatal-exit.js +2 -1
  7. package/dist/boot/runner-deps.d.ts +4 -3
  8. package/dist/boot/runner-deps.js +7 -3
  9. package/dist/boot/stage-01-config.js +2 -0
  10. package/dist/capabilities/builtin-tools.d.ts +31 -8
  11. package/dist/capabilities/builtin-tools.js +22 -1
  12. package/dist/capabilities/scenarios.d.ts +8 -6
  13. package/dist/capabilities/scenarios.js +3 -3
  14. package/dist/capabilities/send-user-file-tool.d.ts +6 -3
  15. package/dist/capabilities/send-user-file-tool.js +1 -1
  16. package/dist/config-catalog.js +2 -2
  17. package/dist/config-lkg.d.ts +33 -5
  18. package/dist/config-lkg.js +55 -11
  19. package/dist/config-types.d.ts +8 -1
  20. package/dist/config.js +9 -9
  21. package/dist/deployment-governance.d.ts +3 -1
  22. package/dist/deployment-governance.js +3 -1
  23. package/dist/device-store.d.ts +7 -0
  24. package/dist/device-store.js +4 -0
  25. package/dist/device-ws-hub.js +17 -9
  26. package/dist/device-ws-protocol.d.ts +15 -0
  27. package/dist/http/resume-legs.js +4 -0
  28. package/dist/http/routes/capabilities.js +1 -0
  29. package/dist/http/routes/devices.js +3 -3
  30. package/dist/http/routes/rules.js +13 -6
  31. package/dist/list-entry-display.d.ts +14 -0
  32. package/dist/list-entry-display.js +14 -0
  33. package/dist/namespaced-rule-name.js +2 -6
  34. package/dist/observability/fail-open.d.ts +4 -0
  35. package/dist/observability/fail-open.js +4 -0
  36. package/dist/operator-ask.d.ts +1 -1
  37. package/dist/plugins/one-time-migrations.d.ts +8 -0
  38. package/dist/plugins/one-time-migrations.js +72 -3
  39. package/dist/plugins/permission-rule-store-file.js +2 -6
  40. package/dist/plugins/permission-rule-store-sql.d.ts +64 -6
  41. package/dist/plugins/permission-rule-store-sql.js +166 -59
  42. package/dist/plugins/remote-env-device.d.ts +12 -1
  43. package/dist/plugins/remote-env-device.js +13 -5
  44. package/dist/plugins/scheduler-support.d.ts +8 -4
  45. package/dist/plugins/scheduler-support.js +5 -5
  46. package/dist/rule-entry-list.d.ts +0 -1
  47. package/dist/rule-entry-list.js +4 -9
  48. package/dist/rules-consent.d.ts +12 -3
  49. package/dist/rules-consent.js +5 -4
  50. package/dist/run-local.js +2 -0
  51. package/dist/sema-config-dir.d.ts +19 -0
  52. package/dist/sema-config-dir.js +33 -0
  53. package/dist/session-policy-wire.js +5 -4
  54. package/dist/spec-fields.d.ts +5 -4
  55. package/dist/task-settings.d.ts +3 -3
  56. package/dist/trace/engine-notice-wire.d.ts +6 -4
  57. package/dist/trace/engine-notice-wire.js +16 -9
  58. package/dist/trace/self-minted-wire.d.ts +14 -0
  59. package/dist/trace/self-minted-wire.js +2 -0
  60. package/docs/DEVICE-EXECUTOR-PROTOCOL.md +56 -14
  61. package/package.json +2 -2
@@ -4,16 +4,12 @@ import { readdirSync } from "node:fs";
4
4
  import { performance } from "node:perf_hooks";
5
5
  import { FileDurableRulePartitionProvider, AppendLog, ensureDir, } from "@sema-agent/core";
6
6
  import { z } from "zod";
7
- import { buildRulePayloadHash } from "./permission-rule-store-sql.js";
7
+ import { DurableRuleScopeSchema, buildRulePayloadHash } from "./permission-rule-store-sql.js";
8
8
  import { replayJsonlGuarded } from "../observability/corrupt-read-seat.js";
9
9
  import { EDITED_RULE_BREADTH_WARNING_CODES, RULE_APPROVAL_RECORD_SCHEMA, UNCOVERED_SEGMENT_REASONS } from "../permission-rule-vocab.js";
10
10
  import { RULE_BEHAVIORS } from "@sema-agent/core";
11
- const RuleScopeSchema = z.union([
12
- z.object({ kind: z.literal("global") }).strict(),
13
- z.object({ kind: z.literal("project"), root: z.string().min(1) }).strict(),
14
- ]);
15
11
  const RuleDotSchema = z.object({ actor: z.string().min(1), counter: z.number().int().nonnegative().safe() }).strict();
16
- const RuleCandidateSchema = z.object({ behavior: z.enum(RULE_BEHAVIORS), rule: z.string(), scope: RuleScopeSchema }).strict();
12
+ const RuleCandidateSchema = z.object({ behavior: z.enum(RULE_BEHAVIORS), rule: z.string(), scope: DurableRuleScopeSchema }).strict();
17
13
  const RuleOffer2Schema = z.union([
18
14
  z.object({ kind: z.literal("single"), candidate: z.number().int().nonnegative().safe() }).strict(),
19
15
  z
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { type PermissionRuleWriter, type DurableRulePartition, type DurableRulePartitionProvider, type RuleScope, type RuleSyncFrontier, type RuleCandidate, type RuleApprovalRecord, type StaleRuleApprovalRecord, type RuleApprovalRecordStore, type RuleOwner } from "@sema-agent/core";
2
+ import { type PermissionRuleWriter, type DurableRulePartition, type DurableRulePartitionProvider, type RuleScope, type RuleSyncFrontier, type RuleBehavior, type RuleCandidate, type RuleApprovalRecord, type StaleRuleApprovalRecord, type RuleApprovalRecordStore, type RuleOwner } from "@sema-agent/core";
3
3
  import type { Pool as MySqlPool } from "mysql2/promise";
4
4
  import type { PgQueryFn } from "./pg-query.js";
5
5
  import { type SqlDriver } from "./sql-driver.js";
@@ -16,13 +16,19 @@ import { type IndexSpec } from "./ensure-index.js";
16
16
  */
17
17
  export declare function writerOfSqlRuleStore(store: DurableRulePartition): PermissionRuleWriter | undefined;
18
18
  /**
19
- * 382P1(core 7.0.0 [ref] §4.3)—— 这是 **DURABLE 二员面**的 at-rest 形:`{global, project-with-root}`,
20
- * 与 core `isValidDurableScope` 逐格等价(`test/permission-rule-durable-face-pin.test.ts` 探针表钉住)。
19
+ * 382P1(core 7.0.0 [ref] §4.3)—— DURABLE 二员面的 at-rest **形**:`{kind:"global"}` | `{kind:"project", root:string}`。
20
+ *
21
+ * 🔴 **只判形,不判 root 收不收**。「这个 root 能不能当持久项目 scope」只有一只判据 —— core 的 `isValidDurableScope`
22
+ * (7.46.0 起 root 必须是完整绝对路径):写臂在 {@link SqlDurableRulePartition.apply} 里经 core 的
23
+ * `assertWriteDeltaScopeDurable` 先拒,读侧由 core 的统一店在有效视图里丢掉那一行并发 `permission.rule_row_dropped`
24
+ * (一行的坏只丢那一行)。本 schema 若再判一次 root,就是第二份判据:它与 core 答案不同的那一天(`proj` / `C:`
25
+ * 本仓收、引擎丢;空 root 本仓整桶拒、引擎只丢一行),同一行在两侧是两种命运。core 自己的 File 参照后端在 at-rest
26
+ * 上同样只判形。入口(wire)的判据见 `rules-consent.ts` 的 `parseRuleScope`(调同一只谓词)。
27
+ *
21
28
  * `RuleScope` 联合自 v7 起有第三员 `session`,但它**结构上**不许进持久店(家在会话 overlay,随会话死):
22
29
  * at-rest 解码不认它 ⇒ 一条被外部写进来的 session 行整桶拒读(fail-closed),写臂另由 core 的
23
- * `assertWriteDeltaScopeDurable` 在任何 DB 往返之前以典型码拒(见 `apply`)。consent 面(三员)归
24
- * engine/cli 半场;本仓 wire 入口 `parseRuleScope` 只拼二员,所以审批记录的候选也用这张表(下方
25
- * `RuleCandidateSchema`)——那是本仓自己的窄型收口,不是把两张表合成一张。
30
+ * `assertWriteDeltaScopeDurable` 在任何 DB 往返之前以典型码拒(见 `apply`)。审批记录的候选也用这张表
31
+ * (下方 `RuleCandidateSchema`;File 店的审批记录读同一只)。
26
32
  */
27
33
  export declare const DurableRuleScopeSchema: z.ZodType<RuleScope>;
28
34
  export declare const PERMISSION_RULE_TABLE = "permission_rule";
@@ -406,6 +412,58 @@ export declare const RULE_PENDING_APPROVAL_RETENTION_MS: number;
406
412
  * 同口径)。在那之前,MySQL 存量库上这条腿仍是全表扫 —— 但每轮 500 行的上界让它的**单次**代价仍然有界。
407
413
  */
408
414
  export declare const RULE_REAP_BATCH = 500;
415
+ /**
416
+ * M11 删掉的一行。只带桶键、态、工具名与 scope 的 root —— 规则正文不出本函数(命令行里可以有任何东西)。
417
+ *
418
+ * `kind` 是闭集(启动日志按它穷尽分句,`one-time-migrations.ts` `logOneTimeMigrations`):
419
+ * · `rule` —— 仍有活 add 的规则行(引擎读侧每跑一次丢它并告知;它从未生效,要的话按项目的完整路径重写);
420
+ * · `revoked-rule` —— 每个 add 都已被同桶墓碑撤掉的规则行(判活用 core 的 `applyTombstones`):它什么都不授予,人的撤销照旧成立,无需处置;
421
+ * · `tombstone` —— 一条撤销记录。它只盖得住同 (behavior, 文本, scope) 的规则 ⇒ 那些规则的 scope 与它同样不被收,随它一起删了或本来就不在,
422
+ * 删它不会让任何被撤销的东西回来。
423
+ */
424
+ export interface UndurableScopeRuleRow {
425
+ /** 桶键(sha256,不是 principal 原文)。 */
426
+ readonly ownerKey: string;
427
+ readonly kind: "rule" | "revoked-rule" | "tombstone";
428
+ readonly behavior: RuleBehavior;
429
+ readonly tool: string;
430
+ /** scope 的 root,**过 `redactSecrets`**(本仓唯一脱敏器):坏 root 是最不可信的自由文本,可能夹着凭据形,而本读数进启动日志与拒因。 */
431
+ readonly scopeRoot: string;
432
+ }
433
+ /** M11 判成读不出、一个字节没动的桶。`why` = 唯一判官那次整桶拒**不带任何行内容**的拒因(读腿对这只桶照旧整桶拒,它仍是这只桶的属主)。 */
434
+ export interface UnreadableRuleBucket {
435
+ /** 桶键(sha256,不是 principal 原文)。 */
436
+ readonly ownerKey: string;
437
+ readonly why: string;
438
+ }
439
+ /** M11 一次跑完的读数。 */
440
+ export interface UndurableScopePurge {
441
+ readonly removed: readonly UndurableScopeRuleRow[];
442
+ /** 连续争不过在线写者(OCC 每次都输)而留到下一次起服的桶数。那些桶里的坏行这期间照旧由引擎读侧丢掉并告知。 */
443
+ readonly bucketsDeferred: number;
444
+ /** 读腿整桶拒的桶(形 / 指纹 / behavior 闭集 / 墓碑规范文本 / 单一验证器):一个字节不动,逐桶交出拒因,等人处置。 */
445
+ readonly bucketsUnreadable: readonly UnreadableRuleBucket[];
446
+ }
447
+ /** {@link purgeUndurableScopeRows} 要的最小查询口(一次性迁移段的 `MigrationDb` 同形;占位符按方言由本函数写)。 */
448
+ export interface UndurableScopePurgeDb {
449
+ rows(sql: string, params?: readonly unknown[]): Promise<Record<string, unknown>[]>;
450
+ exec(sql: string, params?: readonly unknown[]): Promise<number>;
451
+ }
452
+ /**
453
+ * **M11** —— 把持久桶里 scope 不被 core `isValidDurableScope` 收的行(规则行与墓碑)删掉,一行一条读数。
454
+ *
455
+ * 这些行从哪来:7.119.0 及更早的本仓入口按「root 非空」收 project scope(`/v1/rules` 写口、两条导入面),引擎 7.45 也收;
456
+ * 引擎 7.46.0 把 project root 收窄成完整绝对路径 ⇒ `project:myproj` 一类存量行在读侧被丢(`permission.rule_row_dropped`,每跑一条),
457
+ * 而撤销口删不动它(引擎的写臂对这种 scope 的墓碑同样拒)。⇒ 一次性迁移,不留「撤销口对坏 scope 另开一条读法」的长期双读。
458
+ *
459
+ * 读法与写法都是**店的那一只**:每只桶先过唯一判官 {@link decodeBucketRow}(读腿用的同一只),判官整桶拒 ⇒ 这只桶**一个字节不动**,
460
+ * 进 `bucketsUnreadable`(桶键 + 不带行内容的拒因;读腿对它的整桶拒仍是它的属主,出路是人);判官放行 ⇒ 在那份合法状态上筛 scope,
461
+ * 经写臂的同一只 {@link writeBucket} 整桶 OCC 回写(`WHERE owner_key = ? AND rev = ?`,rev+1,落盘字节与指纹同写臂)—— 不碰别的桶。
462
+ * **硬删、不铸墓碑**:墓碑的作用是挡同步回灌,而引擎的 sync-join 对 scope 不合法的入站行本来就丢(`applySyncJoin`),
463
+ * 本店也不实现同步(见 `apply` 的 sync-join 臂)。
464
+ * 幂等:第二跑零命中零写(读不出的桶每一跑照报,直到有人处置)。
465
+ */
466
+ export declare function purgeUndurableScopeRows(db: UndurableScopePurgeDb, dialect: "tidb" | "pg", now?: () => number): Promise<UndurableScopePurge>;
409
467
  /** 三个 SQL 面的一次性装配束(one driver, three faces)。`durable` = [ref] 的 durable 分区后端;
410
468
  * 引擎接缝上的统一店由 `main.ts` 用 `createPermissionRuleStoreProvider({ durable })` 合成一次。 */
411
469
  export interface PermissionRuleStores {
@@ -1,12 +1,13 @@
1
1
  import { createHash, randomBytes, randomUUID } from "node:crypto";
2
2
  import { z } from "zod";
3
- import { applyTombstones, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuarantined, assertWriteDeltaScopeDurable, foldDelta, parseRuleText, readRowBehavior, screenRuleSyncState, PERMISSION_RULE_WRITER, RULE_BEHAVIORS, RULE_SYNC_DROP_CODES, EMPTY_DURABLE_RULE_PARTITION, } from "@sema-agent/core";
3
+ import { applyTombstones, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuarantined, assertWriteDeltaScopeDurable, foldDelta, isValidDurableScope, parseRuleText, readRowBehavior, screenRuleSyncState, PERMISSION_RULE_WRITER, RULE_BEHAVIORS, RULE_SYNC_DROP_CODES, EMPTY_DURABLE_RULE_PARTITION, } from "@sema-agent/core";
4
4
  import { dialectProtocolJsonEncoder, sqlDbNowMs, SQL_DB_NOW_MS } from "./sql-driver.js";
5
5
  import { isMissingColumnError } from "./sql-errors.js";
6
6
  import { canonicalJson } from "../canonical-json.js";
7
7
  import { EDITED_RULE_BREADTH_WARNING_CODES, PERSISTED_RULE_MATCHES, isPersistedRuleTool, RULE_APPROVAL_RECORD_SCHEMA, UNCOVERED_SEGMENT_REASONS } from "../permission-rule-vocab.js";
8
8
  import { ensureIndexes } from "./ensure-index.js";
9
- import { createZodColumnReader, decodeJsonColumn, foldJsonColumn, jsonColumnText, readJsonObjectColumn } from "./sql-json-column.js";
9
+ import { createZodColumnReader, decodeJsonColumn, foldJsonColumn, jsonColumnText, readJsonObjectColumn, SqlJsonColumnError } from "./sql-json-column.js";
10
+ import { redactSecrets } from "../trace/redact.js";
10
11
  export function writerOfSqlRuleStore(store) {
11
12
  const w = Reflect.get(store, PERMISSION_RULE_WRITER);
12
13
  if (w === null || typeof w !== "object")
@@ -20,7 +21,7 @@ export function writerOfSqlRuleStore(store) {
20
21
  }
21
22
  export const DurableRuleScopeSchema = z.union([
22
23
  z.object({ kind: z.literal("global") }).strict(),
23
- z.object({ kind: z.literal("project"), root: z.string().min(1) }).strict(),
24
+ z.object({ kind: z.literal("project"), root: z.string() }).strict(),
24
25
  ]);
25
26
  const RuleDotSchema = z.object({ actor: z.string().min(1), counter: z.number().int().nonnegative().safe() }).strict();
26
27
  const RuleAddSchema = z
@@ -57,11 +58,17 @@ const QuarantinedRuleAddRowSchema = z
57
58
  function readBucketRowBehavior(raw, what, ownerKey) {
58
59
  const behavior = readRowBehavior(raw);
59
60
  if (behavior === undefined) {
60
- throw new Error(`permission_rule bucket ${ownerKey} carries a ${what} whose behavior word is outside the closed set (${JSON.stringify(raw)}) — ` +
61
- `refusing the whole bucket rather than reading a standing deny as "no rule"`);
61
+ throw new BucketRefusedError(`permission_rule bucket ${ownerKey} carries a ${what} whose behavior word is outside the closed set`, `(${JSON.stringify(raw)}) — refusing the whole bucket rather than reading a standing deny as "no rule"`);
62
62
  }
63
63
  return behavior;
64
64
  }
65
+ class BucketRefusedError extends Error {
66
+ verdict;
67
+ constructor(verdict, evidence) {
68
+ super(`${verdict} ${evidence}`);
69
+ this.verdict = verdict;
70
+ }
71
+ }
65
72
  const WritablePersistedRuleSchema = PersistedRuleRowSchema.extend({ behavior: z.enum(RULE_BEHAVIORS) });
66
73
  const WritableRuleTombstoneSchema = RuleTombstoneSchema.extend({ behavior: z.enum(RULE_BEHAVIORS) });
67
74
  const FrontierSchema = z.record(z.string(), z.number().int().nonnegative().safe());
@@ -349,6 +356,64 @@ async function refusingUnreadableColumns(what, fn) {
349
356
  throw foldJsonColumn(e, (m) => `${m} — the ${what} does not carry a readable shape; refusing the whole of it rather than report a partial one`);
350
357
  }
351
358
  }
359
+ function bucketRowColumns(dialect) {
360
+ return `actor, counter, rev, ${jsonColumnText(dialect, "rules_json")}, ${jsonColumnText(dialect, "tombstones_json")}, ${jsonColumnText(dialect, "quarantined_json")}, ${jsonColumnText(dialect, "observed_vector_json")}, checksum`;
361
+ }
362
+ function decodeBucketRow(ownerKey, row) {
363
+ const at = `${PERMISSION_RULE_TABLE} bucket ${ownerKey}`;
364
+ const rulesRaw = decodeJsonColumn(row.rules_json, readRulesJson, `${at}.rules_json`);
365
+ const tombstonesRaw = decodeJsonColumn(row.tombstones_json, readTombstonesJson, `${at}.tombstones_json`);
366
+ const quarantinedRaw = decodeJsonColumn(row.quarantined_json, readQuarantinedJson, `${at}.quarantined_json`);
367
+ const observedVector = row.observed_vector_json === null || row.observed_vector_json === undefined ? undefined : decodeJsonColumn(row.observed_vector_json, readObservedVectorJson, `${at}.observed_vector_json`);
368
+ const expected = buildRuleBucketChecksum({ rules: rulesRaw, tombstones: tombstonesRaw, quarantined: quarantinedRaw, ...(observedVector !== undefined ? { observedVector } : {}) });
369
+ if (String(row.checksum ?? "") !== expected) {
370
+ throw new BucketRefusedError(`permission_rule bucket ${ownerKey} failed its integrity check`, `(stored ${String(row.checksum)}, computed ${expected}) — refusing the whole bucket rather than reporting a partial rule set`);
371
+ }
372
+ const rules = rulesRaw.map((r) => ({ ...r, behavior: readBucketRowBehavior(r.behavior, "rule row", ownerKey) }));
373
+ const tombstones = tombstonesRaw.map((t) => ({ ...t, behavior: readBucketRowBehavior(t.behavior, "tombstone", ownerKey) }));
374
+ const quarantined = quarantinedRaw.map((q) => ({ ...q, behavior: readBucketRowBehavior(q.behavior, "quarantined row", ownerKey) }));
375
+ const badTombstone = tombstones.find((t) => {
376
+ const parsed = parseRuleText(t.rule, t.behavior);
377
+ return "reject" in parsed || parsed.rule.rule !== t.rule;
378
+ });
379
+ if (badTombstone !== undefined) {
380
+ throw new BucketRefusedError(`permission_rule bucket ${ownerKey} carries a tombstone whose rule text the single validator refuses or does not spell canonically`, `(${badTombstone.rule}) — refusing the whole bucket rather than keeping a rule alive under a tombstone that can never match it`);
381
+ }
382
+ const screened = screenRuleSyncState({ rules, tombstones });
383
+ if (screened.rejected.length > 0) {
384
+ const names = screened.rejected.map((r) => `${r.rule}(${r.reason})`).join(", ");
385
+ throw new BucketRefusedError(`permission_rule bucket ${ownerKey} carries rows the single validator refuses`, `[${names}] — refusing the whole bucket rather than authorizing a rule whose text and matcher metadata disagree`);
386
+ }
387
+ return {
388
+ actor: String(row.actor ?? ""),
389
+ counter: Number(row.counter ?? 0),
390
+ rev: Number(row.rev ?? 0),
391
+ rules,
392
+ tombstones,
393
+ quarantined,
394
+ ...(observedVector !== undefined ? { observedVector } : {}),
395
+ };
396
+ }
397
+ async function writeBucket(exec, dialect, ownerKey, cur, next, nowMs) {
398
+ const writable = z.object({ rules: z.array(WritablePersistedRuleSchema), tombstones: z.array(WritableRuleTombstoneSchema) }).safeParse(next);
399
+ if (!writable.success) {
400
+ throw new Error(`refusing to write a permission_rule bucket this backend could not read back: ${writable.error.message}`);
401
+ }
402
+ const body = { rules: next.rules, tombstones: next.tombstones, quarantined: cur.quarantined, ...(cur.observedVector !== undefined ? { observedVector: cur.observedVector } : {}) };
403
+ const enc = dialectProtocolJsonEncoder(dialect);
404
+ const affected = await exec(dialect === "tidb"
405
+ ? `UPDATE ${PERMISSION_RULE_TABLE} SET rules_json = ?, tombstones_json = ?, quarantined_json = ?, checksum = ?, rev = rev + 1, updated_at_ms = ? WHERE owner_key = ? AND rev = ?`
406
+ : `UPDATE ${PERMISSION_RULE_TABLE} SET rules_json = $1, tombstones_json = $2, quarantined_json = $3, checksum = $4, rev = rev + 1, updated_at_ms = $5 WHERE owner_key = $6 AND rev = $7`, [
407
+ enc(next.rules, "permission_rule.rules_json"),
408
+ enc(next.tombstones, "permission_rule.tombstones_json"),
409
+ enc(cur.quarantined, "permission_rule.quarantined_json"),
410
+ buildRuleBucketChecksum(body),
411
+ nowMs,
412
+ ownerKey,
413
+ cur.rev,
414
+ ]);
415
+ return affected === 1;
416
+ }
352
417
  class SqlDurableRulePartition {
353
418
  db;
354
419
  owner;
@@ -372,47 +437,11 @@ class SqlDurableRulePartition {
372
437
  return refusingUnreadableColumns(`bucket ${this.ownerKey}`, () => this.readBucketRow());
373
438
  }
374
439
  async readBucketRow() {
375
- const { rows } = await this.db.query(this.q(`SELECT actor, counter, rev, ${jsonColumnText("tidb", "rules_json")}, ${jsonColumnText("tidb", "tombstones_json")}, ${jsonColumnText("tidb", "quarantined_json")}, ${jsonColumnText("tidb", "observed_vector_json")}, checksum FROM ${PERMISSION_RULE_TABLE} WHERE owner_key = ?`, `SELECT actor, counter, rev, ${jsonColumnText("pg", "rules_json")}, ${jsonColumnText("pg", "tombstones_json")}, ${jsonColumnText("pg", "quarantined_json")}, ${jsonColumnText("pg", "observed_vector_json")}, checksum FROM ${PERMISSION_RULE_TABLE} WHERE owner_key = $1`), [this.ownerKey]);
440
+ const { rows } = await this.db.query(this.q(`SELECT ${bucketRowColumns("tidb")} FROM ${PERMISSION_RULE_TABLE} WHERE owner_key = ?`, `SELECT ${bucketRowColumns("pg")} FROM ${PERMISSION_RULE_TABLE} WHERE owner_key = $1`), [this.ownerKey]);
376
441
  const row = rows[0];
377
442
  if (row === undefined)
378
443
  return { ...EMPTY_BUCKET, rules: [], tombstones: [], quarantined: [] };
379
- const at = `${PERMISSION_RULE_TABLE} bucket ${this.ownerKey}`;
380
- const rulesRaw = decodeJsonColumn(row.rules_json, readRulesJson, `${at}.rules_json`);
381
- const tombstonesRaw = decodeJsonColumn(row.tombstones_json, readTombstonesJson, `${at}.tombstones_json`);
382
- const quarantinedRaw = decodeJsonColumn(row.quarantined_json, readQuarantinedJson, `${at}.quarantined_json`);
383
- const observedVector = row.observed_vector_json === null || row.observed_vector_json === undefined ? undefined : decodeJsonColumn(row.observed_vector_json, readObservedVectorJson, `${at}.observed_vector_json`);
384
- const expected = buildRuleBucketChecksum({ rules: rulesRaw, tombstones: tombstonesRaw, quarantined: quarantinedRaw, ...(observedVector !== undefined ? { observedVector } : {}) });
385
- if (String(row.checksum ?? "") !== expected) {
386
- throw new Error(`permission_rule bucket ${this.ownerKey} failed its integrity check (stored ${String(row.checksum)}, computed ${expected}) — ` +
387
- `refusing the whole bucket rather than reporting a partial rule set`);
388
- }
389
- const rules = rulesRaw.map((r) => ({ ...r, behavior: readBucketRowBehavior(r.behavior, "rule row", this.ownerKey) }));
390
- const tombstones = tombstonesRaw.map((t) => ({ ...t, behavior: readBucketRowBehavior(t.behavior, "tombstone", this.ownerKey) }));
391
- const quarantined = quarantinedRaw.map((q) => ({ ...q, behavior: readBucketRowBehavior(q.behavior, "quarantined row", this.ownerKey) }));
392
- const state = {
393
- actor: String(row.actor ?? ""),
394
- counter: Number(row.counter ?? 0),
395
- rev: Number(row.rev ?? 0),
396
- rules,
397
- tombstones,
398
- quarantined,
399
- ...(observedVector !== undefined ? { observedVector } : {}),
400
- };
401
- const badTombstone = tombstones.find((t) => {
402
- const parsed = parseRuleText(t.rule, t.behavior);
403
- return "reject" in parsed || parsed.rule.rule !== t.rule;
404
- });
405
- if (badTombstone !== undefined) {
406
- throw new Error(`permission_rule bucket ${this.ownerKey} carries a tombstone whose rule text the single validator refuses or does not spell canonically (${badTombstone.rule}) — ` +
407
- `refusing the whole bucket rather than keeping a rule alive under a tombstone that can never match it`);
408
- }
409
- const screened = screenRuleSyncState({ rules, tombstones });
410
- if (screened.rejected.length > 0) {
411
- const names = screened.rejected.map((r) => `${r.rule}(${r.reason})`).join(", ");
412
- throw new Error(`permission_rule bucket ${this.ownerKey} carries rows the single validator refuses [${names}] — ` +
413
- `refusing the whole bucket rather than authorizing a rule whose text and matcher metadata disagree`);
414
- }
415
- return state;
444
+ return decodeBucketRow(this.ownerKey, row);
416
445
  }
417
446
  async ensureBucket() {
418
447
  const nowMs = this.now();
@@ -500,22 +529,8 @@ class SqlDurableRulePartition {
500
529
  throw new Error(`the SQL permission-rule backend was handed a write delta it does not know: ${JSON.stringify(unknown)}`);
501
530
  }
502
531
  }
503
- const writable = z.object({ rules: z.array(WritablePersistedRuleSchema), tombstones: z.array(WritableRuleTombstoneSchema) }).safeParse({ rules: nextRules, tombstones: nextTombstones });
504
- if (!writable.success) {
505
- throw new Error(`refusing to write a permission_rule bucket this backend could not read back: ${writable.error.message}`);
506
- }
507
- const body = { rules: nextRules, tombstones: nextTombstones, quarantined: cur.quarantined, ...(cur.observedVector !== undefined ? { observedVector: cur.observedVector } : {}) };
508
- const nowMs = this.now();
509
- const { affected } = await this.db.query(this.q(`UPDATE ${PERMISSION_RULE_TABLE} SET rules_json = ?, tombstones_json = ?, quarantined_json = ?, checksum = ?, rev = rev + 1, updated_at_ms = ? WHERE owner_key = ? AND rev = ?`, `UPDATE ${PERMISSION_RULE_TABLE} SET rules_json = $1, tombstones_json = $2, quarantined_json = $3, checksum = $4, rev = rev + 1, updated_at_ms = $5 WHERE owner_key = $6 AND rev = $7`), [
510
- this.enc(nextRules, "permission_rule.rules_json"),
511
- this.enc(nextTombstones, "permission_rule.tombstones_json"),
512
- this.enc(cur.quarantined, "permission_rule.quarantined_json"),
513
- buildRuleBucketChecksum(body),
514
- nowMs,
515
- this.ownerKey,
516
- opts.expectedRev,
517
- ]);
518
- if (affected === 0) {
532
+ const landed = await writeBucket(async (sql, params) => (await this.db.query(sql, params)).affected, this.db.dialect, this.ownerKey, cur, { rules: nextRules, tombstones: nextTombstones }, this.now());
533
+ if (!landed) {
519
534
  const again = await this.read();
520
535
  return { conflict: true, rev: again.rev };
521
536
  }
@@ -766,6 +781,98 @@ export class SqlRuleImportTicketStore {
766
781
  }
767
782
  export const RULE_PENDING_APPROVAL_RETENTION_MS = 24 * 60 * 60_000;
768
783
  export const RULE_REAP_BATCH = 500;
784
+ const UNDURABLE_PURGE_PAGE = 200;
785
+ const UNDURABLE_PURGE_OCC_ATTEMPTS = 5;
786
+ export async function purgeUndurableScopeRows(db, dialect, now = Date.now) {
787
+ const acc = { removed: [], bucketsDeferred: 0, bucketsUnreadable: [] };
788
+ try {
789
+ await purgeAllBuckets(db, dialect, now, acc);
790
+ }
791
+ catch (e) {
792
+ if (acc.removed.length === 0)
793
+ throw e;
794
+ throw new Error(`the one-time removal of persisted permission rules whose scope the engine refuses failed AFTER removing ${acc.removed.length} row(s) — record them before retrying: ` +
795
+ acc.removed.map((r) => `${r.kind} in bucket ${r.ownerKey} (${r.behavior} ${r.tool}, scope root ${JSON.stringify(r.scopeRoot)})`).join("; ") +
796
+ `. Underlying failure: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
797
+ }
798
+ return acc;
799
+ }
800
+ async function purgeAllBuckets(db, dialect, now, acc) {
801
+ const cols = `owner_key, ${bucketRowColumns(dialect)}`;
802
+ let after = "";
803
+ for (;;) {
804
+ const page = await db.rows(dialect === "tidb"
805
+ ? `SELECT ${cols} FROM ${PERMISSION_RULE_TABLE} WHERE owner_key > ? ORDER BY owner_key LIMIT ${UNDURABLE_PURGE_PAGE}`
806
+ : `SELECT ${cols} FROM ${PERMISSION_RULE_TABLE} WHERE owner_key > $1 ORDER BY owner_key LIMIT ${UNDURABLE_PURGE_PAGE}`, [after]);
807
+ for (const first of page) {
808
+ const ownerKey = String(first.owner_key);
809
+ after = ownerKey;
810
+ let row = first;
811
+ let settled = false;
812
+ for (let attempt = 0; attempt < UNDURABLE_PURGE_OCC_ATTEMPTS && row !== undefined && !settled; attempt++) {
813
+ const outcome = await purgeOneBucket(db, dialect, ownerKey, row, now);
814
+ switch (outcome.kind) {
815
+ case "purged":
816
+ acc.removed.push(...outcome.rows);
817
+ settled = true;
818
+ break;
819
+ case "unreadable":
820
+ acc.bucketsUnreadable.push({ ownerKey, why: outcome.why });
821
+ settled = true;
822
+ break;
823
+ case "clean":
824
+ settled = true;
825
+ break;
826
+ case "conflict":
827
+ row = (await db.rows(dialect === "tidb" ? `SELECT ${cols} FROM ${PERMISSION_RULE_TABLE} WHERE owner_key = ?` : `SELECT ${cols} FROM ${PERMISSION_RULE_TABLE} WHERE owner_key = $1`, [ownerKey]))[0];
828
+ if (row === undefined)
829
+ settled = true;
830
+ break;
831
+ default: {
832
+ const unknown = outcome;
833
+ throw new Error(`M11: unhandled bucket outcome ${JSON.stringify(unknown)}`);
834
+ }
835
+ }
836
+ }
837
+ if (!settled)
838
+ acc.bucketsDeferred += 1;
839
+ }
840
+ if (page.length < UNDURABLE_PURGE_PAGE)
841
+ break;
842
+ }
843
+ }
844
+ async function purgeOneBucket(db, dialect, ownerKey, row, now) {
845
+ let cur;
846
+ try {
847
+ cur = decodeBucketRow(ownerKey, row);
848
+ }
849
+ catch (e) {
850
+ return { kind: "unreadable", why: contentFreeRefusal(ownerKey, e) };
851
+ }
852
+ const badRules = cur.rules.filter((r) => !isValidDurableScope(r.scope));
853
+ const badTombstones = cur.tombstones.filter((t) => !isValidDurableScope(t.scope));
854
+ if (badRules.length === 0 && badTombstones.length === 0)
855
+ return { kind: "clean" };
856
+ const rootOf = (scope) => redactSecrets(scope.kind === "project" ? scope.root : scope.kind);
857
+ const rows = [
858
+ ...badRules.map((r) => ({ ownerKey, kind: applyTombstones([r], cur.tombstones).length > 0 ? "rule" : "revoked-rule", behavior: r.behavior, tool: r.tool, scopeRoot: rootOf(r.scope) })),
859
+ ...badTombstones.map((t) => {
860
+ const parsed = parseRuleText(t.rule, t.behavior);
861
+ if ("reject" in parsed)
862
+ throw new Error(`permission_rule bucket ${ownerKey}: a tombstone the bucket judge admitted does not parse here — refusing to touch the bucket`);
863
+ return { ownerKey, kind: "tombstone", behavior: t.behavior, tool: parsed.rule.tool, scopeRoot: rootOf(t.scope) };
864
+ }),
865
+ ];
866
+ const landed = await writeBucket((sql, params) => db.exec(sql, params), dialect, ownerKey, cur, { rules: cur.rules.filter((r) => !badRules.includes(r)), tombstones: cur.tombstones.filter((t) => !badTombstones.includes(t)) }, now());
867
+ return landed ? { kind: "purged", rows } : { kind: "conflict" };
868
+ }
869
+ function contentFreeRefusal(ownerKey, e) {
870
+ if (e instanceof BucketRefusedError)
871
+ return e.verdict;
872
+ if (e instanceof SqlJsonColumnError)
873
+ return `${e.where}: ${e.problem}`;
874
+ return `permission_rule bucket ${ownerKey}: the bucket judge threw ${e instanceof Error ? e.name : typeof e} (its text is withheld here: it may quote the row)`;
875
+ }
769
876
  export function createSqlPermissionRuleStores(db, now) {
770
877
  const q = (tidb, pg) => (db.dialect === "tidb" ? tidb : pg);
771
878
  return {
@@ -152,6 +152,12 @@ export declare class RemoteDeviceExecutionEnv implements RemoteExecutionEnv {
152
152
  private absentGuardFaces;
153
153
  private driftError;
154
154
  private resolve;
155
+ /**
156
+ * exec / execStream 指令帧的 `cwd` —— **恒填、恒绝对**的唯一铸点(协议文档 §3.1:executor 不再有「缺席时按
157
+ * 隐含工作目录」的第二种读法)。调用方没给 ⇒ env 的 cwd(铸造时已由 {@link effectiveDeviceCwd} 保证绝对);
158
+ * 给了 ⇒ 按同一只 {@link resolve} 解成绝对。其余指令 kind 的路径参数在下发前就已是绝对路径,不带 `cwd`。
159
+ */
160
+ private instructionCwd;
155
161
  absolutePath(p: string): Promise<Result<string, FileError>>;
156
162
  joinPath(parts: string[]): Promise<Result<string, FileError>>;
157
163
  workspaceHandle(): WorkspaceHandle;
@@ -321,8 +327,13 @@ export declare function deviceExecutionEnvFactory(deps: DeviceEnvFactoryDeps): E
321
327
  * §4.3.3 的 cwd 优先序 —— 与 host 腿的 `effectiveHostWorkspace` **同源**:
322
328
  * ① 本 session 显式注册的 cwd(`deviceCwdHonored` 闸下由 resolve-spec 写);
323
329
  * ② `ctx.parentCwd`(委派子代:自己没注册过 cwd,继承父的工作目录 —— 少了它子代会丢工作目录);
324
- * ③ 设备 hello 声明并落库的 `workspaceRoot`(缺 cwd 时的解析基准,有明确 wire 契约,不是「executor
330
+ * ③ 设备入籍时登记并落库的 `workspaceRoot`(缺 cwd 时的解析基准,有明确 wire 契约,不是「executor
325
331
  * 默认根」这种空话)。
332
+ *
333
+ * 🔴 **这一只解析源同时守它的后置条件:结果必是 POSIX 绝对路径,否则响亮抛。** 协议文档对 executor 的承诺是
334
+ * 「exec / execStream 的指令帧恒带绝对 `cwd`」—— 不带或带相对的,都会把「用哪个目录」这件事留给 executor
335
+ * 自己猜(同一个事实两种读法)。判据是入籍口那**同一只**谓词 `isDeviceAbsolutePath`(`device-store.ts`):入籍口用它拒相对的
336
+ * `workspaceRoot`;这里的判留作存量行守卫(入籍口收紧之前落库的相对根)并守另两个来源 —— 委派链的父 cwd 来自引擎,不经入籍口。
326
337
  */
327
338
  export declare function effectiveDeviceCwd(sessionCwd: string | undefined, parentCwd: string | undefined, row: Pick<DeviceRow, "workspaceRoot">): string;
328
339
  export {};
@@ -1,6 +1,6 @@
1
1
  import { posix } from "node:path";
2
2
  import { ExecutionError, FileError, RemoteExecutionError, } from "@sema-agent/core";
3
- import { DEVICE_ERROR_CODES, } from "../device-store.js";
3
+ import { DEVICE_ERROR_CODES, isDeviceAbsolutePath, } from "../device-store.js";
4
4
  export const DEVICE_PROVIDER = "device";
5
5
  const ok = (value) => ({ ok: true, value });
6
6
  const STREAM_PENDING_MAX_BYTES = 2 * 1024 * 1024;
@@ -166,7 +166,10 @@ export class RemoteDeviceExecutionEnv {
166
166
  "performing a NON-atomic write here is exactly the silent downgrade guarded writes exist to prevent (#157). Refusing; the per-task factory mints a correctly-shaped env on the next task.", absPath);
167
167
  }
168
168
  resolve(p) {
169
- return posix.isAbsolute(p) ? posix.normalize(p) : posix.normalize(posix.join(this.cwd, p));
169
+ return isDeviceAbsolutePath(p) ? posix.normalize(p) : posix.normalize(posix.join(this.cwd, p));
170
+ }
171
+ instructionCwd(requested) {
172
+ return requested === undefined ? this.cwd : this.resolve(requested);
170
173
  }
171
174
  async absolutePath(p) {
172
175
  return ok(this.resolve(p));
@@ -354,7 +357,7 @@ export class RemoteDeviceExecutionEnv {
354
357
  const res = await this.dispatch("exec", { command }, {
355
358
  timeoutMs: this.timeoutMsFor(options?.timeout),
356
359
  ...(options?.abortSignal === undefined ? {} : { signal: options.abortSignal }),
357
- cwd: options?.cwd === undefined ? this.cwd : this.resolve(options.cwd),
360
+ cwd: this.instructionCwd(options?.cwd),
358
361
  ...(mergedEnv(this.cfg.shellEnv, options?.env) === undefined ? {} : { shellEnv: mergedEnv(this.cfg.shellEnv, options?.env) }),
359
362
  onChunk: (c) => {
360
363
  if (c.kind === "stdout") {
@@ -430,7 +433,7 @@ export class RemoteDeviceExecutionEnv {
430
433
  const settled = this.dispatch("execStream", { command }, {
431
434
  timeoutMs: this.timeoutMsFor(options?.timeout),
432
435
  signal: inner.signal,
433
- cwd: options?.cwd === undefined ? this.cwd : this.resolve(options.cwd),
436
+ cwd: this.instructionCwd(options?.cwd),
434
437
  ...(mergedEnv(this.cfg.shellEnv, options?.env) === undefined ? {} : { shellEnv: mergedEnv(this.cfg.shellEnv, options?.env) }),
435
438
  onChunk: (c) => {
436
439
  if (!accepting) {
@@ -811,6 +814,11 @@ export function deviceExecutionEnvFactory(deps) {
811
814
  };
812
815
  }
813
816
  export function effectiveDeviceCwd(sessionCwd, parentCwd, row) {
814
- return sessionCwd ?? parentCwd ?? row.workspaceRoot;
817
+ const [source, cwd] = sessionCwd !== undefined ? ["session cwd", sessionCwd] : parentCwd !== undefined ? ["parent cwd", parentCwd] : ["device workspaceRoot", row.workspaceRoot];
818
+ if (!isDeviceAbsolutePath(cwd)) {
819
+ throw new Error(`device lane: the effective working directory (${source}) ${JSON.stringify(cwd)} is not an absolute path — every exec / execStream instruction carries an absolute \`cwd\`, ` +
820
+ "and the executor must never be left to guess a base directory. Fix the source named above (a device's workspaceRoot is fixed at enrollment: enroll again with an absolute path)");
821
+ }
822
+ return cwd;
815
823
  }
816
824
  //# sourceMappingURL=remote-env-device.js.map
@@ -26,8 +26,11 @@ type Caps = SchedulerCapability["schedulerCapabilities"];
26
26
  * cron via core `isValidCronExpr` (RCE-safe, no shell-exec); delay/at bounded by minDelaySec + horizon.
27
27
  */
28
28
  export declare function validateIntent(intent: ScheduledIntent, caps: Caps, nowMs: number): SchedulerError | null;
29
- /** The default shared store path: `SEMA_CONFIG_DIR` → `~/.sema`, file `scheduled_tasks.json` (server + shell agree). */
30
- export declare function defaultSchedulerStorePath(): string;
29
+ /** The default shared store path: the configuration root (the `agentConfigDir` seat `loadConfig` minted from `SEMA_CONFIG_DIR`, else `~/.sema`),
30
+ * file `scheduled_tasks.json` (server + shell agree). Reads the seat, never `process.env` at call time. */
31
+ export declare function defaultSchedulerStorePath(config: {
32
+ readonly agentConfigDir?: string;
33
+ }): string;
31
34
  /**
32
35
  * File-backed SchedulerCapability (durable across daemon restarts). Store = a single JSON file (atomic write via
33
36
  * the registry-core /node binding). The resident shell daemon reads the same store to fire intents. `now`
@@ -37,8 +40,9 @@ export declare class FileSchedulerBackend implements SchedulerCapability {
37
40
  readonly schedulerCapabilities: Caps;
38
41
  private readonly storePath;
39
42
  private readonly now;
40
- constructor(opts?: {
41
- storePath?: string;
43
+ /** `storePath` 必填:路径是装配层从配置定的事实(`SCHEDULER_STORE_PATH` ?? {@link defaultSchedulerStorePath}),本类不自己找缺省。 */
44
+ constructor(opts: {
45
+ storePath: string;
42
46
  caps?: Partial<Caps>;
43
47
  now?: () => number;
44
48
  });
@@ -1,8 +1,8 @@
1
1
  import { join } from "node:path";
2
- import { homedir } from "node:os";
3
2
  import { randomBytes, createHash } from "node:crypto";
4
3
  import { SchedulerError, isValidCronExpr } from "@sema-agent/core";
5
4
  import { loadSchedulerStore, saveSchedulerStore } from "@sema-agent/settings-schema/node";
5
+ import { configRootOf } from "../sema-config-dir.js";
6
6
  const ok = (value) => ({ ok: true, value });
7
7
  const err = (error) => ({ ok: false, error });
8
8
  export const DEFAULT_SCHEDULER_CAPS = {
@@ -59,16 +59,16 @@ function computeNextRunMs(when, nowMs) {
59
59
  return nowMs + when.delaySec * 1000;
60
60
  return undefined;
61
61
  }
62
- export function defaultSchedulerStorePath() {
63
- return join(process.env.SEMA_CONFIG_DIR ?? join(homedir(), ".sema"), "scheduled_tasks.json");
62
+ export function defaultSchedulerStorePath(config) {
63
+ return join(configRootOf(config), "scheduled_tasks.json");
64
64
  }
65
65
  export class FileSchedulerBackend {
66
66
  schedulerCapabilities;
67
67
  storePath;
68
68
  now;
69
- constructor(opts = {}) {
69
+ constructor(opts) {
70
70
  this.schedulerCapabilities = { ...DEFAULT_SCHEDULER_CAPS, ...(opts.caps ?? {}) };
71
- this.storePath = opts.storePath ?? defaultSchedulerStorePath();
71
+ this.storePath = opts.storePath;
72
72
  this.now = opts.now ?? (() => Date.now());
73
73
  }
74
74
  load() {
@@ -21,7 +21,6 @@
21
21
  */
22
22
  import { compileAllowList } from "@sema-agent/core";
23
23
  import { emptyToolSegmentOf } from "./namespaced-rule-name.js";
24
- export declare function presentRuleEntry(entry: string): string;
25
24
  /** ① 形:字符串数组(空串也是字符串 —— 引擎把它读成 `grammar` 并通告 / 拒,见 ③)。 */
26
25
  export declare const ruleEntryListShapeOk: (v: unknown) => v is readonly string[];
27
26
  /**
@@ -1,11 +1,6 @@
1
1
  import { compileAllowList } from "@sema-agent/core";
2
+ import { displayListEntry } from "./list-entry-display.js";
2
3
  import { describeEmptyToolSegment, emptyToolSegmentOf, NO_DECLARED_TOOL_NAMES, ruleEntryToolName } from "./namespaced-rule-name.js";
3
- import { CONTROL_AND_BIDI_STRIP_RE } from "./text-bidi.js";
4
- const PRESENTED_ENTRY_MAX = 160;
5
- export function presentRuleEntry(entry) {
6
- const clean = entry.replace(CONTROL_AND_BIDI_STRIP_RE, "");
7
- return clean.length <= PRESENTED_ENTRY_MAX ? clean : `${clean.slice(0, PRESENTED_ENTRY_MAX)}…(${entry.length} chars)`;
8
- }
9
4
  export const ruleEntryListShapeOk = (v) => Array.isArray(v) && v.every((x) => typeof x === "string");
10
5
  export const MAX_ALLOW_LAYER_ENTRIES = 1_000;
11
6
  export function describeAllowLayerOverCap(field, count) {
@@ -26,14 +21,14 @@ export function emptyToolSegmentEntryOf(list) {
26
21
  return undefined;
27
22
  }
28
23
  export function describeEmptyToolSegmentEntry(field, u) {
29
- return `${field} names "${presentRuleEntry(u.entry)}", which can never match a tool (${describeEmptyToolSegment(u.ns)})`;
24
+ return `${field} names ${displayListEntry(u.entry)}, which can never match a tool (${describeEmptyToolSegment(u.ns)})`;
30
25
  }
31
26
  export function inertRuleEntriesOf(list, behavior) {
32
27
  return compileAllowList(list, behavior, NO_DECLARED_TOOL_NAMES).inert;
33
28
  }
34
29
  export function inertRuleEntryGuidance(i) {
35
30
  const reason = i.reason;
36
- const write = i.currentName !== undefined ? ` — write "${presentRuleEntry(i.currentName)}"` : "";
31
+ const write = i.currentName !== undefined ? ` — write ${displayListEntry(i.currentName)}` : "";
37
32
  switch (reason) {
38
33
  case "tool_name":
39
34
  return `the tool name can never match a mounted tool (${i.verdict ?? "stale"})${write}`;
@@ -58,7 +53,7 @@ export function inertRuleEntryGuidance(i) {
58
53
  }
59
54
  }
60
55
  export function describeInertRuleEntries(field, inert) {
61
- return inert.map((i) => `${field} entry "${presentRuleEntry(i.entry)}" reads as nothing (${i.reason}): ${inertRuleEntryGuidance(i)}`).join("; ");
56
+ return inert.map((i) => `${field} entry ${displayListEntry(i.entry)} reads as nothing (${i.reason}): ${inertRuleEntryGuidance(i)}`).join("; ");
62
57
  }
63
58
  const isPlainRecord = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
64
59
  function skillDenyInertSentence(skill) {
@@ -163,7 +163,7 @@ export interface LocalImportRuleKey {
163
163
  * 它在端点上先判、有自己的整句文案,而且判完就把类型收窄掉(`permission-rule-vocab.ts` 的
164
164
  * `DIRECT_RULE_WRITE_ADMITS`)⇒ 放宽态根本到不了本表所服务的那段代码。 */
165
165
  export type LocalImportRefusalCode =
166
- /** scope 串读不出形(既不是 `global` 也不是 `project:<非空 root>`)。 */
166
+ /** scope 串读不出形,或读出来的 scope 引擎的 durable 谓词不收(既不是 `global` 也不是 `project:<绝对 root>`;判据 = {@link parseRuleScope})。 */
167
167
  "scope_unreadable"
168
168
  /** scope 的 project root 里有 bidi 控制符([ref]② Trojan Source 族):人看到的射程与机器判据用的
169
169
  * 射程不是同一串 —— 一条规则的**作用范围**因此可以被伪装。 */
@@ -480,7 +480,7 @@ export declare function serializeRuleScope(scope: RuleScope): string;
480
480
  * 各挑各的」这件事本身,不是把某个数字调大一点)。
481
481
  *
482
482
  * 病:铸侧 `cardRuleScopeRoot` 对 root 长度零把关(入口尺 = `isValidCwd` 的 {@link MAX_CWD_CHARS}),
483
- * core 与两个店都只校验 `root.min(1)` ⇒ 写面全程无阻;而 `DELETE /v1/rules` 的体 schema 此前独立写死
483
+ * core 与两个店都不设 root 长度上限 ⇒ 写面全程无阻;而 `DELETE /v1/rules` 的体 schema 此前独立写死
484
484
  * `scope: max(1088)`,且 safeParse 排在 `parseRuleScope`/权限判据**之前** ⇒ 超限 root 的规则
485
485
  * `GET /v1/rules` **列得出**、按同一串 DELETE **恒 400**。全仓只有一个 `lane.removeRule` 调用点(直删
486
486
  * 店行是无墓碑硬删、会被 sync 的 join 复活),所以那是**一条已列出的常驻放行规则在受支持接口上不可撤**;
@@ -502,8 +502,17 @@ export type DurableRuleScope = Exclude<RuleScope, {
502
502
  kind: "session";
503
503
  }>;
504
504
  /** {@link serializeRuleScope} 的逆。读不出形 ⇒ `undefined`(调用方 400,绝不猜一个 global 出来 ——
505
- * 猜 global 会把一次「删项目内规则」的请求变成一次删不掉任何东西的 no-op,或者更糟)。 */
505
+ * 猜 global 会把一次「删项目内规则」的请求变成一次删不掉任何东西的 no-op,或者更糟)。
506
+ *
507
+ * 🔴 **收不收只有一只判据:core 的 `isValidDurableScope`**(引擎 7.46.0 起 project root 必须是完整绝对路径:`/…`、`X:\…` /
508
+ * `X:/…`、`\\host\…`)。四个 wire 入口(`/v1/rules` 写 / 撤 / 列举过滤、local-import 的行)都经这里;cc-import 的层 root 调同一只
509
+ * 谓词(`http/routes/rules.ts`)。修前这里手抄了一份「root 非空」:`project:myproj` 本仓收、引擎写臂拒(写口答 503「再发一次」——
510
+ * 客户端会无限重试)、引擎读侧丢(撤销口删不动),一条规则在两侧是两种命运。存量的那种行由一次性迁移 M11 删
511
+ * (`plugins/one-time-migrations.ts` 登记表)。 */
506
512
  export declare function parseRuleScope(text: string): DurableRuleScope | undefined;
513
+ /** 引擎不收的 scope(或 cc-import 层 root)的**唯一一句**拒句:写 / 撤 / 列举过滤三个路由口、cc-import 预览的层 root、local-import 的成员拒因
514
+ * `scope_unreadable` 都出站这一只(各口的码照旧各自的),不在读点各抄一份。 */
515
+ export declare const RULE_SCOPE_REFUSAL_SENTENCE = "scope must be 'global' or 'project:<root>' with an absolute root";
507
516
  /** 一条规则在 wire 上的行 = 一个逻辑 (rule, scope) 对([ref] v2 §6 F5)。
508
517
  * `adds` **如实上 wire**、不折叠成单值:同一条规则可以被批准过多次(不同 dot、不同来源、不同时刻),
509
518
  * 折成「一个 addedAt」会让「这条规则是我导入的还是我点过卡的」在治理面上不可分。 */