@sema-agent/server 7.22.0-rc.1 → 7.23.0-rc.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 (53) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/USAGE.md +6 -4
  4. package/deploy/sema-up/chart/values.yaml +1 -1
  5. package/dist/approval-card.d.ts +53 -0
  6. package/dist/approval-card.js +104 -1
  7. package/dist/bench/s1/run-firm.js +1 -1
  8. package/dist/boot/budget-tracing.d.ts +0 -1
  9. package/dist/boot/resolve-spec.js +3 -1
  10. package/dist/budget.d.ts +0 -11
  11. package/dist/budget.js +10 -22
  12. package/dist/capabilities/scenarios.js +14 -8
  13. package/dist/config.d.ts +1 -1
  14. package/dist/config.js +12 -1
  15. package/dist/fleet/fleet-bus.d.ts +8 -3
  16. package/dist/fleet/fleet-bus.js +10 -5
  17. package/dist/http/route-ctx.d.ts +11 -0
  18. package/dist/http/route-ctx.js +16 -1
  19. package/dist/http/routes/runs.d.ts +3 -0
  20. package/dist/http/routes/runs.js +28 -24
  21. package/dist/http/routes/side-query.js +4 -13
  22. package/dist/http/routes/tasks.js +15 -3
  23. package/dist/http/server.d.ts +4 -3
  24. package/dist/http/server.js +8 -4
  25. package/dist/http/wire-types.d.ts +3 -1
  26. package/dist/lsp/manager.d.ts +9 -0
  27. package/dist/lsp/manager.js +8 -26
  28. package/dist/main.js +4 -0
  29. package/dist/observability/fail-open.d.ts +4 -0
  30. package/dist/observability/fail-open.js +4 -0
  31. package/dist/observability/otel-exporter.js +7 -1
  32. package/dist/observability/secret-env-scrub.d.ts +53 -0
  33. package/dist/observability/secret-env-scrub.js +135 -0
  34. package/dist/plugins/image-bake-store-sql.d.ts +8 -1
  35. package/dist/plugins/image-bake-store-sql.js +8 -1
  36. package/dist/plugins/image-index-sql.d.ts +13 -3
  37. package/dist/plugins/image-index-sql.js +13 -3
  38. package/dist/plugins/permission-rule-store-sql.d.ts +9 -72
  39. package/dist/plugins/permission-rule-store-sql.js +36 -64
  40. package/dist/plugins/remote-env-host.js +10 -2
  41. package/dist/project-memory.js +7 -1
  42. package/dist/run-local.js +7 -0
  43. package/dist/runtime-governance.d.ts +1 -1
  44. package/dist/runtime-governance.js +12 -15
  45. package/dist/security.d.ts +3 -1
  46. package/dist/security.js +15 -7
  47. package/dist/task-settings.js +10 -5
  48. package/dist/tool-approval.d.ts +20 -1
  49. package/dist/tool-approval.js +4 -1
  50. package/dist/trace/core-keyset-guard.d.ts +1 -1
  51. package/dist/trace/project.d.ts +2 -0
  52. package/dist/trace/project.js +11 -0
  53. package/package.json +3 -3
@@ -0,0 +1,135 @@
1
+ /**
2
+ * A-033.3(#256)—— **密钥形 env 剥除的留痕面**(core D1 `scrubSecretEnv` 的可观测半场)。
3
+ *
4
+ * ## 它解决的问题
5
+ * `scrubSecretEnv` 是本仓两条 shell/子进程腿的**安全默认**:模型可驱动的 shell(`inheritEnv:"scrub"`)与
6
+ * git 子进程(钩子/凭证助手)拿到的 env 里,`*_KEY`/`*_TOKEN`/`*_SECRET`/`*_PASSWORD`… 一律先丢掉。
7
+ * 两处都以**单参形**调用,于是「这一次到底丢了哪些 key、按哪条规则(suffix-rule / exact-name)、置信度多少」
8
+ * **零留痕** —— 部署方既证明不了保护生效过,也发现不了它悄悄不生效了(词表漂移 / 一个新的密钥命名法从
9
+ * 规则的缝里穿过去 / 某条腿被改成 `inheritEnv:"all"`)。core 早就把收集口开好了(`findings?:
10
+ * SecretEnvFinding[]`,d.ts 原话:the VALUE is never carried — this record is meant to be loggable),
11
+ * 缺的只是本仓这半场。本模块就是那半场,**不是**一个新的执法面:它一个字节也不改变剥除行为。
12
+ *
13
+ * ## 形(照 `fail-open.ts` 的仓内先例)
14
+ * 1. **计数(逐次)** —— `secret_env_scrubbed_total{site,kind,confidence}`;
15
+ * 2. **结构化披露(每 site×key 一次)** —— 首次在某条腿上丢掉某个 key 时一条 `info`,之后同 key 只走计数。
16
+ * 不是「每 site 一次」:一个**新**的密钥形 key 后来才出现(部署加了一个凭证)时,那才是最该看见的一行。
17
+ * 值永不进任何一件(core 的 finding 本身就不携带值)。
18
+ *
19
+ * ## 为什么是进程级自由函数而不是注入
20
+ * 与 `recordFailOpen` 同因:两个调用点都在**没有 logger/metrics 句柄**的深处
21
+ * (`plugins/remote-env-host.ts` 的 `mergeEnv` 全文零观测通道;`project-memory.ts` 的 `git()` 是个自由函数)。
22
+ * 装配层用 {@link installSecretEnvScrubRecorder} 把句柄接上,装配之前命中的次数不会丢(计数照常累加,
23
+ * install 时一次性补进 metrics),披露行则在下一次命中时补发(未成功披露的 key 不进 `disclosed` 集)。
24
+ */
25
+ import { summarizeRedactions } from "@sema-agent/core";
26
+ /** 登记过的剥除现场。闭集:未登记的 site 传不进 {@link recordSecretEnvScrub}(编译期拒,同 FAIL_OPEN_TAGS 纪律)。 */
27
+ export const SECRET_ENV_SCRUB_SITES = {
28
+ "server.remote-env-host.inherit-scrub": "模型可驱动的 host shell 继承 env(`inheritEnv:\"scrub\"` 默认腿)——这条腿上的一次泄漏等于把编排器的脑钥/仓库令牌交给模型选的命令",
29
+ "server.project-memory.git-subprocess": "project-memory 读仓叙事时的 git 子进程 env —— 仓内的钩子/凭证助手会以本进程身份运行",
30
+ };
31
+ /** 计数器名。 */
32
+ const SCRUB_COUNTER = "secret_env_scrubbed_total";
33
+ /** 已披露集的上限。key 集在一个进程里实际上是常量(恒扫同一份 `process.env`),这个帽只是防病态输入
34
+ * 把集合撑爆;越帽之后只停止**新增披露**,计数照常(缺席的是行,不是账)。 */
35
+ const DISCLOSED_CAP = 512;
36
+ /** 桶键与披露键的分隔符。用 `=` 是有据的:POSIX 环境变量名里唯二不可能出现的字节就是 `=` 与 NUL
37
+ * (NUL 不写进源码 —— 裸控制字节会让 grep 对整个文件失明,本仓有机械门盯它)。用空格/冒号分隔则会让
38
+ * `A B`+`C` 与 `A`+`B C` 撞成同一个键 —— 标签串台 = 遥测说谎。 */
39
+ const SEP = "=";
40
+ let sink = {};
41
+ /** `site=kind=confidence` → 累计次数(逐次,进程级)。 */
42
+ let counts = new Map();
43
+ /** 已经推进 metrics 的部分 —— 差额即欠账,install 时一次性补。 */
44
+ let pushed = new Map();
45
+ /** `site=key` —— 已经出过披露行的现场×键。 */
46
+ let disclosed = new Set();
47
+ function bucketKey(site, f) {
48
+ return `${site}${SEP}${f.kind}${SEP}${f.confidence}`;
49
+ }
50
+ /** 把 counts 与 pushed 的差额推进 metrics。metrics 缺席时什么也不做(差额留着,下次补)。 */
51
+ function drainToMetrics() {
52
+ const m = sink.metrics;
53
+ if (m === undefined)
54
+ return;
55
+ for (const [k, n] of counts) {
56
+ const already = pushed.get(k) ?? 0;
57
+ if (n <= already)
58
+ continue;
59
+ const [site, kind, confidence] = k.split(SEP);
60
+ try {
61
+ m.inc(SCRUB_COUNTER, { site: site, kind: kind, confidence: confidence }, n - already);
62
+ pushed.set(k, n); // 真进了汇才销账(投递失败 ⇒ 差额留着重放)
63
+ }
64
+ catch {
65
+ /* 下一次命中/下一次装配再补 */
66
+ }
67
+ }
68
+ }
69
+ /**
70
+ * 记一次剥除。**总不抛** —— 观测本身绝不能变成故障(同 `recordFailOpen` / reapers 的判据)。
71
+ * `findings` 为空(这一次没丢任何 key)时什么也不做:那不是事件,是常态。
72
+ */
73
+ export function recordSecretEnvScrub(site, findings) {
74
+ if (findings.length === 0)
75
+ return;
76
+ try {
77
+ for (const f of findings) {
78
+ const k = bucketKey(site, f);
79
+ counts.set(k, (counts.get(k) ?? 0) + 1);
80
+ }
81
+ drainToMetrics();
82
+ const logger = sink.logger;
83
+ if (logger === undefined)
84
+ return; // 披露行留到有 logger 的下一次命中(未进 disclosed ⇒ 会补发)
85
+ // 🔴 帽是**对这一次的批也生效**的(codex 复审 round2 medium 真修):先按剩余容量截断,再用截断后的
86
+ // 那一批去发行与登记。两条都靠它:
87
+ // ① 帽满 ⇒ 停止披露,而不是「喊了但记不下」—— 后者会把「每 site×key 一次」在 key 最多的时候
88
+ // 反转成「每次调用一行」(mergeEnv 是逐命令调用的,那是刷屏);
89
+ // ② 单批越界 ⇒ 也得截断 —— 只在批**之前**判帽,一次 513 条的病态 env 会一次性把 513 条全发出去
90
+ // (一行超大日志)并全部留驻,帽等于没有。计数不受截断影响:账永远是全的,缺席的只是行。
91
+ const room = DISCLOSED_CAP - disclosed.size;
92
+ if (room <= 0)
93
+ return;
94
+ const fresh = findings.filter((f) => !disclosed.has(`${site}${SEP}${f.key}`)).slice(0, room);
95
+ if (fresh.length === 0)
96
+ return;
97
+ logger.info("secret_env_scrubbed", {
98
+ site,
99
+ note: SECRET_ENV_SCRUB_SITES[site],
100
+ // 只报**本次新见**的 key(值永不携带 —— core 的 finding 按契约只带键名/规则/置信度/来源)。
101
+ keys: fresh.map((f) => f.key),
102
+ // 一行人读摘要走 core 的 `summarizeRedactions`(SecretEnvFinding 满足 SummarizableFinding),
103
+ // 不在本仓另写一份分组渲染 —— 那正是本票要消灭的那种孪生。
104
+ summary: summarizeRedactions(fresh),
105
+ });
106
+ for (const f of fresh)
107
+ disclosed.add(`${site}${SEP}${f.key}`);
108
+ }
109
+ catch {
110
+ /* 观测绝不抛 */
111
+ }
112
+ }
113
+ /** 装配层把 logger/metrics 接上(main.ts,与 `installFailOpenRecorder` 同一处)。装配前累计的次数在此补进 metrics。 */
114
+ export function installSecretEnvScrubRecorder(deps) {
115
+ sink = { ...deps };
116
+ deps.metrics?.counter(SCRUB_COUNTER, "secret-pattern env entries dropped before spawning a child process, by site/rule/confidence (core scrubSecretEnv findings)");
117
+ drainToMetrics();
118
+ }
119
+ /** 已留驻的「现场×键」条数(测试/诊断读面)。恒 ≤ {@link DISCLOSED_CAP}。 */
120
+ export function secretEnvScrubDisclosedSize() {
121
+ return disclosed.size;
122
+ }
123
+ /** 本进程各桶的累计次数(测试/诊断读面)。 */
124
+ export function secretEnvScrubCounts() {
125
+ return new Map(counts);
126
+ }
127
+ /** 测试用重置(进程级状态在同一个 vitest worker 里跨用例存活)。 */
128
+ export function resetSecretEnvScrubRecorderForTest(deps = {}) {
129
+ counts = new Map();
130
+ pushed = new Map();
131
+ disclosed = new Set();
132
+ sink = { ...deps };
133
+ deps.metrics?.counter(SCRUB_COUNTER, "secret-pattern env entries dropped before spawning a child process, by site/rule/confidence (core scrubSecretEnv findings)");
134
+ }
135
+ //# sourceMappingURL=secret-env-scrub.js.map
@@ -123,7 +123,14 @@ export declare class SqlImageBake {
123
123
  retainedFrom(bakeId: string): Promise<number>;
124
124
  /** Events with seq strictly greater than `afterSeq` (0 for the whole stream), ascending. */
125
125
  getEvents(bakeId: string, afterSeq: number): Promise<BakeEvent[]>;
126
- /** Coarse 4-value lifecycle transition (the column the SSE reader polls for terminality). */
126
+ /**
127
+ * Coarse 4-value lifecycle transition (the column the SSE reader polls for terminality).
128
+ *
129
+ * @deprecated #193 车7(staleness-sweep P2-12):**test-only**——生产零调用。生产唯一写入路径是
130
+ * `ingestBakeLine`(routes/images.ts),它只经 `setState`(denormalize)与 `setTerminal` 驱动状态;
131
+ * 本方法是绕开「status 只由 state/done 驱动」不变量的唯一直接写口,**禁止新增生产调用**。留而不删
132
+ * 的唯一理由=三个集成测试用它铸中间态,整删随该套件下次翻新一并做。
133
+ */
127
134
  setStatus(bakeId: string, status: BakeStatus): Promise<void>;
128
135
  /** Denormalize build.sh's 8-value `state` for the UI progress bar (NEVER drives terminality, P2.3). */
129
136
  setState(bakeId: string, state: BakeState): Promise<void>;
@@ -494,7 +494,14 @@ export class SqlImageBake {
494
494
  ts: iso(r.ts),
495
495
  }));
496
496
  }
497
- /** Coarse 4-value lifecycle transition (the column the SSE reader polls for terminality). */
497
+ /**
498
+ * Coarse 4-value lifecycle transition (the column the SSE reader polls for terminality).
499
+ *
500
+ * @deprecated #193 车7(staleness-sweep P2-12):**test-only**——生产零调用。生产唯一写入路径是
501
+ * `ingestBakeLine`(routes/images.ts),它只经 `setState`(denormalize)与 `setTerminal` 驱动状态;
502
+ * 本方法是绕开「status 只由 state/done 驱动」不变量的唯一直接写口,**禁止新增生产调用**。留而不删
503
+ * 的唯一理由=三个集成测试用它铸中间态,整删随该套件下次翻新一并做。
504
+ */
498
505
  async setStatus(bakeId, status) {
499
506
  await this.db.query(this.q("UPDATE image_bake SET status = ?, updated_at = ? WHERE bake_id = ?", "UPDATE image_bake SET status = $1, updated_at = $2 WHERE bake_id = $3"), [status, new Date(), bakeId]);
500
507
  }
@@ -17,7 +17,16 @@ export declare class SqlImageIndex {
17
17
  private q;
18
18
  /** Stable index id = sha256(repo@digest) — identity is the digest, not the mutable tag. */
19
19
  static idFor(repo: string, digest: string): string;
20
- /** Register or update an index row (idempotent on repo+digest). Returns the row id. */
20
+ /**
21
+ * Register or update an index row (idempotent on repo+digest). Returns the row id.
22
+ *
23
+ * @deprecated #193 车7(staleness-sweep P2-11):**生产零调用**——曾声称的 operator
24
+ * `/v1/images/register` 路径实际走 `registerBuilding`(routes/images.ts),本方法只剩集成测试当
25
+ * seed 口在用。危险语义原样保留着:UNCONDITIONAL upsert 会把 `published` 行打回 `building`
26
+ * (正是 §P2.10 静默 de-publish 病的成因,`registerBuilding` 因此才带 STATUS-MONOTONIC 守卫)。
27
+ * **禁止新增生产调用**;测试 seed 请优先走 `registerBuilding`(不需要覆盖语义时)。留而不删的
28
+ * 唯一理由=16 处 pg 集成测试 seed 依赖,整删随该套件下次翻新一并做。
29
+ */
21
30
  upsert(e: ImageIndexUpsert): Promise<string>;
22
31
  /**
23
32
  * STATUS-MONOTONIC register (IMAGE-API-DESIGN.md §P2.10 MUST-FIX) — the auto-register path the bake runner uses.
@@ -30,8 +39,9 @@ export declare class SqlImageIndex {
30
39
  * isn't already `published` — a late/duplicate `done` for an already-promoted digest is a no-op on the
31
40
  * lifecycle, never a regression. Returns the row id.
32
41
  *
33
- * Distinct from the unconditional `upsert` (kept for the seed + the operator `/v1/images/register` path); this
34
- * is the bake auto-register's ONLY write into the index. Default status is `building` (P2 registers
42
+ * Distinct from the unconditional `upsert` (#193 车7 更正:那条 operator `/v1/images/register` 路径
43
+ * **也走本方法**——routes/images.ts:446 `idx.registerBuilding(...)`;`upsert` @deprecated,只剩
44
+ * 测试 seed 在用)。This is the bake auto-register's ONLY write into the index. Default status is `building` (P2 registers
35
45
  * node-local/quarantine `building` artifacts; P3's verify gate is what flips `building → published`).
36
46
  *
37
47
  * The `IF(...published...)` (TiDB) / `CASE WHEN...published...` (PG) guards are the monotonic de-publish
@@ -180,7 +180,16 @@ export class SqlImageIndex {
180
180
  static idFor(repo, digest) {
181
181
  return createHash("sha256").update(`${repo}@${digest}`).digest("hex").slice(0, 64);
182
182
  }
183
- /** Register or update an index row (idempotent on repo+digest). Returns the row id. */
183
+ /**
184
+ * Register or update an index row (idempotent on repo+digest). Returns the row id.
185
+ *
186
+ * @deprecated #193 车7(staleness-sweep P2-11):**生产零调用**——曾声称的 operator
187
+ * `/v1/images/register` 路径实际走 `registerBuilding`(routes/images.ts),本方法只剩集成测试当
188
+ * seed 口在用。危险语义原样保留着:UNCONDITIONAL upsert 会把 `published` 行打回 `building`
189
+ * (正是 §P2.10 静默 de-publish 病的成因,`registerBuilding` 因此才带 STATUS-MONOTONIC 守卫)。
190
+ * **禁止新增生产调用**;测试 seed 请优先走 `registerBuilding`(不需要覆盖语义时)。留而不删的
191
+ * 唯一理由=16 处 pg 集成测试 seed 依赖,整删随该套件下次翻新一并做。
192
+ */
184
193
  async upsert(e) {
185
194
  // 🔴 PG-only identity-axis validation (codex R17-H1/R19) — TiDB has no NUL/lone-surrogate column
186
195
  // constraint, so it stores repo/tenantId verbatim; PG rejects up front (see file-header dialect delta).
@@ -226,8 +235,9 @@ export class SqlImageIndex {
226
235
  * isn't already `published` — a late/duplicate `done` for an already-promoted digest is a no-op on the
227
236
  * lifecycle, never a regression. Returns the row id.
228
237
  *
229
- * Distinct from the unconditional `upsert` (kept for the seed + the operator `/v1/images/register` path); this
230
- * is the bake auto-register's ONLY write into the index. Default status is `building` (P2 registers
238
+ * Distinct from the unconditional `upsert` (#193 车7 更正:那条 operator `/v1/images/register` 路径
239
+ * **也走本方法**——routes/images.ts:446 `idx.registerBuilding(...)`;`upsert` @deprecated,只剩
240
+ * 测试 seed 在用)。This is the bake auto-register's ONLY write into the index. Default status is `building` (P2 registers
231
241
  * node-local/quarantine `building` artifacts; P3's verify gate is what flips `building → published`).
232
242
  *
233
243
  * The `IF(...published...)` (TiDB) / `CASE WHEN...published...` (PG) guards are the monotonic de-publish
@@ -1,81 +1,18 @@
1
- import { type PersistedAllowRule, type PermissionRuleStore, type PermissionRuleStoreProvider, type PutResult, type QuarantinedRuleAdd, type RuleAdd, type RuleDot, type RuleScope, type RuleSyncFrontier, type RuleTombstone, type RuleApprovalRecord, type RuleApprovalRecordStore, type RuleOwner } from "@sema-agent/core";
1
+ import { type PermissionRuleWriter, type PermissionRuleStore, type PermissionRuleStoreProvider, type RuleScope, type RuleSyncFrontier, type RuleApprovalRecord, type RuleApprovalRecordStore, type RuleOwner } from "@sema-agent/core";
2
2
  import type { Pool as MySqlPool } from "mysql2/promise";
3
3
  import type { PgQueryFn } from "./pg-query.js";
4
4
  import { type SqlDriver } from "./sql-driver.js";
5
- /** core `permission-rule-store.ts` 的 `PERMISSION_RULE_WRITER` 字面值(逐字)。`writerOf(store)` 读的
6
- * 就是这把键——它是 core 与 backend 之间**事实上的**协议名,只是没被导出。 */
7
- export declare const PERMISSION_RULE_WRITER_KEY = "__semaPermissionRuleWriter";
8
- /** core `RedemptionAuthorization` 的镜像。 */
9
- export interface RedemptionAuthorizationMirror {
10
- recordId: string;
11
- principal?: string;
12
- owner?: RuleOwner;
13
- }
14
- /** core `RuleAddDelta` 的镜像。 */
15
- export interface RuleAddDeltaMirror {
16
- kind: "redemption-add";
17
- rule: string;
18
- scope: RuleScope;
19
- tool: PersistedAllowRule["tool"];
20
- match: PersistedAllowRule["match"];
21
- command: string;
22
- add: RuleAdd;
23
- redemption: RedemptionAuthorizationMirror;
24
- }
25
- /** core `RuleDeleteDelta` 的镜像。 */
26
- export interface RuleDeleteDeltaMirror {
27
- kind: "tighten-delete";
28
- tombstone: RuleTombstone;
29
- }
30
- /** core `RuleSyncJoinDelta` 的镜像(**本 backend 不实现**,只为判别式完整——见顶注 ②)。 */
31
- export interface RuleSyncJoinDeltaMirror {
32
- kind: "sync-join";
33
- inbound: {
34
- rules: PersistedAllowRule[];
35
- tombstones: RuleTombstone[];
36
- };
37
- gcFrontier?: RuleSyncFrontier;
38
- observedVector?: RuleSyncFrontier;
39
- quarantine?: Array<{
40
- rule: string;
41
- scope: RuleScope;
42
- dots: RuleDot[];
43
- reason: QuarantinedRuleAdd["reason"];
44
- }>;
45
- }
46
- export type RuleWriteDeltaMirror = RuleAddDeltaMirror | RuleDeleteDeltaMirror | RuleSyncJoinDeltaMirror;
47
- /** core `RawRuleSyncState` 的镜像。 */
48
- export interface RawRuleSyncStateMirror {
49
- actor: string;
50
- counter: number;
51
- rev: number;
52
- rules: PersistedAllowRule[];
53
- tombstones: RuleTombstone[];
54
- observedVector?: RuleSyncFrontier;
55
- quarantined?: QuarantinedRuleAdd[];
56
- }
57
- /** core `PermissionRuleWriter` 的镜像。 */
58
- export interface PermissionRuleWriterMirror {
59
- nextDot(): Promise<RuleDot>;
60
- apply(delta: RuleWriteDeltaMirror, opts: {
61
- expectedRev: number;
62
- }): Promise<PutResult | {
63
- conflict: true;
64
- rev: number;
65
- }>;
66
- readRaw(): Promise<RawRuleSyncStateMirror>;
67
- }
68
- /** core `WritablePermissionRuleStore` 的镜像(属性名 = {@link PERMISSION_RULE_WRITER_KEY})。 */
69
- export interface WritablePermissionRuleStoreMirror extends PermissionRuleStore {
70
- readonly __semaPermissionRuleWriter: PermissionRuleWriterMirror;
71
- }
72
5
  /**
73
- * core 私有 `writerOf` 的**本地对偶**:一只店的写面,或 `undefined`(该 backend 从引擎侧看是只读的)。
6
+ * 一只店的写面,或 `undefined`(该 backend 从引擎侧看是只读的)。
7
+ *
8
+ * 与 core 的 `writerOf` 的**唯一**区别是判据更严:core 只看 `apply`/`nextDot` 两个方法在不在(它服务的是
9
+ * 引擎自己的兑付腿),本函数还要求 `readRaw` —— 本仓的消费点(测试/取证)读的是**完整**写面,少一个方法
10
+ * 就该当场判 `undefined`,而不是在调用 `readRaw()` 时炸一个 `not a function`。
74
11
  *
75
- * 结构检查而不是断言 —— 它同时是本仓消费点(测试/诊断)读写面的**唯一**入口:没有这个函数,每个调用点
76
- * 都会各写一次 `as unknown as {…}`,那既是三处宽松断言,也是三份会各自漂的形状假设。
12
+ * 结构检查而不是断言:没有这个函数,每个调用点都会各写一次 `as unknown as {…}`,那既是三处宽松断言,
13
+ * 也是三份会各自漂的形状假设。
77
14
  */
78
- export declare function writerOfSqlRuleStore(store: PermissionRuleStore): PermissionRuleWriterMirror | undefined;
15
+ export declare function writerOfSqlRuleStore(store: PermissionRuleStore): PermissionRuleWriter | undefined;
79
16
  export declare const PERMISSION_RULE_TABLE = "permission_rule";
80
17
  export declare const PERMISSION_RULE_APPROVAL_TABLE = "permission_rule_approval";
81
18
  export declare const PERMISSION_RULE_TICKET_TABLE = "permission_rule_ticket";
@@ -23,23 +23,28 @@
23
23
  * 天然原子,不需要事务、不需要 `FOR UPDATE`、不进本仓在案的悲观锁死锁族。
24
24
  *
25
25
  * ─────────────────────────────────────────────────────────────────────────────────────────────────
26
- * 🔴 已登记的**上游契约缺口**(汇报件,不是本车的自由发挥)
26
+ * 🔴 写面 = **core 的本尊**(A-033.2 / #256 销账,交接件③已兑现)
27
27
  * ─────────────────────────────────────────────────────────────────────────────────────────────────
28
- * core 的**写面**(`PERMISSION_RULE_WRITER` 常量、`PermissionRuleWriter`/`WritablePermissionRuleStore`/
29
- * `RuleWriteDelta`/`RawRuleSyncState` 类型、`foldDelta`/`applySyncJoin`/`writerOf`/
30
- * `assertRedemptionNotQuarantined`/`assertDeleteDeltaCarriesNoAdd` 函数)**全部不在 `@sema-agent/core`
31
- * 的公开导出面上**(亲读 `dist/index.d.ts`;`exports` map 只开 `.`/`./bench`/`./fixtures`,深路径
32
- * import 被封)。core 的原话是「a host cannot hold one, so no API-level path to the store bypasses the
33
- * consent protocol」——那条纪律针对的是「宿主自己往店里写」,而**一个宿主提供的 backend** 恰恰必须持有
34
- * 写面才能被 `redeemRuleTicket` 使用(core 的 `writerOf(store)` 读的就是这个属性)。
28
+ * 本文件曾整段登记一个上游契约缺口:core 的写面(`PERMISSION_RULE_WRITER` 常量、`PermissionRuleWriter`/
29
+ * `WritablePermissionRuleStore`/`RuleWriteDelta`/`RawRuleSyncState` 类型、`foldDelta`/`writerOf`/
30
+ * `assertRedemptionNotQuarantined`/`assertDeleteDeltaCarriesNoAdd` 函数)当时**不在包的公开导出面上**,
31
+ * 于是本文件按结构满足它:属性名用字面常量、类型在下方一份份**本地镜像**(`RuleWriteDeltaMirror` 等),
32
+ * 并把「求 core 导出写面」列为交接件③。
35
33
  *
36
- * 本文件按**结构**满足写面:属性名用 core 的字面常量 `"__semaPermissionRuleWriter"`,类型在下方
37
- * 本地镜像(`RuleWriteDeltaMirror` 等)。这**是**一次形状复制,违反「schema 单一属主」的字面——
38
- * 处置不是隐瞒而是登记:①镜像类型逐字对着 core d.ts 写,并在 `test/permission-rule-store-*.test.ts`
39
- * 里用**结构可赋值性**钉住(core 改形 编译红);②`sync-join` 那一臂**不实现**——它的落地管线
40
- * (`applySyncJoin`)是 core 私有的一整条流水线,复制它才是真的会漂;本 backend 对该 delta **响亮拒绝**
41
- * (fail-closed,不是静默 no-op),同步客户端本仓尚未接线,所以这条臂今天零调用点。
42
- * core 导出写面(或提供一个 `createSqlBackedRuleStore` 适配器)已列入交接件。
34
+ * core 2026-08-10 裁定**导出后端契约**(d.ts 原话:「Exported as part of the BACKEND CONTRACT … so an
35
+ * out-of-repo store implementation hangs the same face the file backend does, instead of mirroring the
36
+ * types」),5.33.0 起全部根导出 镜像与手写孪生一并摘除,本文件直接消费 core 的类型与三个共享判据。
37
+ * 这不是洁癖:`foldDelta` d.ts 自陈「Pure — the backends share it so the file and in-memory forms
38
+ * cannot drift in what a delta MEANS」,而本 backend 此前**没有共享它**——一份 delta 在 file 后端与
39
+ * SQL 后端上「意味着什么」可以各自漂,漂的落点是放行面。换装同时把两条判据换成 core 的严格版:
40
+ * · `assertDeleteDeltaCarriesNoAdd`:core **6 个** add-only 键(add/redemption/rule/command/match/tool)
41
+ * 并要求 `removedDots` 是**非空数组**;本地孪生只查过一个 `rules` 键(那还是 sync-join 的字段名,
42
+ * 打偏了)且对空 `removedDots` 放行 —— 一条删不掉任何 add 的墓碑会让本该已删的规则继续放行。
43
+ * · `assertRedemptionNotQuarantined`:语义等价(同 dot 即拒),换本尊后报文也与 file 后端一致。
44
+ *
45
+ * 仍然**不实现**的只剩 `sync-join` 那一臂:它的落地管线(`applySyncJoin`)至今是 core 私有的一整条
46
+ * 流水线,复制它才是真的会漂;本 backend 对该 delta **响亮拒绝**(fail-closed,不是静默 no-op),
47
+ * 同步客户端本仓尚未接线,所以这条臂今天零调用点。
43
48
  *
44
49
  * ─────────────────────────────────────────────────────────────────────────────────────────────────
45
50
  * 边界纪律:列回读一律 zod `safeParse`(宪法 [2704] 禁裸 as-cast)。JSON 列是驱动交回的 `unknown`,
@@ -49,22 +54,23 @@
49
54
  */
50
55
  import { createHash, randomBytes } from "node:crypto";
51
56
  import { z } from "zod";
52
- import { applyTombstones, parseAllowRuleText, sameScope, screenRuleSyncState, RULE_SYNC_DROP_CODES, } from "@sema-agent/core";
57
+ import { applyTombstones, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuarantined, foldDelta, parseAllowRuleText, screenRuleSyncState, PERMISSION_RULE_WRITER, RULE_SYNC_DROP_CODES, } from "@sema-agent/core";
53
58
  import { dialectProtocolJsonEncoder } from "./sql-driver.js";
54
59
  // ─────────────────────────────────────────────────────────────────────────────────────────────────
55
- // core 私有写面的**本地镜像**(顶注 §「上游契约缺口」逐条理由)
60
+ // 写面(core 的后端契约,2026-08-10 裁定导出;本仓 A-033.2 已摘除全部本地镜像)
56
61
  // ─────────────────────────────────────────────────────────────────────────────────────────────────
57
- /** core `permission-rule-store.ts` 的 `PERMISSION_RULE_WRITER` 字面值(逐字)。`writerOf(store)` 读的
58
- * 就是这把键——它是 core 与 backend 之间**事实上的**协议名,只是没被导出。 */
59
- export const PERMISSION_RULE_WRITER_KEY = "__semaPermissionRuleWriter";
60
62
  /**
61
- * core 私有 `writerOf` 的**本地对偶**:一只店的写面,或 `undefined`(该 backend 从引擎侧看是只读的)。
63
+ * 一只店的写面,或 `undefined`(该 backend 从引擎侧看是只读的)。
64
+ *
65
+ * 与 core 的 `writerOf` 的**唯一**区别是判据更严:core 只看 `apply`/`nextDot` 两个方法在不在(它服务的是
66
+ * 引擎自己的兑付腿),本函数还要求 `readRaw` —— 本仓的消费点(测试/取证)读的是**完整**写面,少一个方法
67
+ * 就该当场判 `undefined`,而不是在调用 `readRaw()` 时炸一个 `not a function`。
62
68
  *
63
- * 结构检查而不是断言 —— 它同时是本仓消费点(测试/诊断)读写面的**唯一**入口:没有这个函数,每个调用点
64
- * 都会各写一次 `as unknown as {…}`,那既是三处宽松断言,也是三份会各自漂的形状假设。
69
+ * 结构检查而不是断言:没有这个函数,每个调用点都会各写一次 `as unknown as {…}`,那既是三处宽松断言,
70
+ * 也是三份会各自漂的形状假设。
65
71
  */
66
72
  export function writerOfSqlRuleStore(store) {
67
- const w = Reflect.get(store, PERMISSION_RULE_WRITER_KEY);
73
+ const w = Reflect.get(store, PERMISSION_RULE_WRITER);
68
74
  if (w === null || typeof w !== "object")
69
75
  return undefined;
70
76
  const nextDot = Reflect.get(w, "nextDot");
@@ -315,42 +321,6 @@ function canonicalJson(v) {
315
321
  .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
316
322
  return `{${entries.map(([k, val]) => `${JSON.stringify(k)}:${canonicalJson(val)}`).join(",")}}`;
317
323
  }
318
- /**
319
- * core `foldDelta` 的**语义镜像**:把一次 redemption-add 折进原始规则集。
320
- *
321
- * 逐字对着 core 的 JSDoc 与 in-memory 参照物写:同 (rule, scope) 归一组,组内 adds 按 **dot 身份**并集
322
- * (同 dot 重放 ⇒ 恒等,这正是 core「一次兑付恒铸一个 dot,重放多少次都只有一个 add」的落点)。
323
- */
324
- function foldAdd(rules, delta) {
325
- const next = rules.map((r) => ({ ...r, adds: [...r.adds] }));
326
- const hit = next.find((r) => r.rule === delta.rule && sameScope(r.scope, delta.scope));
327
- if (hit === undefined) {
328
- next.push({ rule: delta.rule, tool: delta.tool, match: delta.match, command: delta.command, scope: delta.scope, adds: [delta.add] });
329
- return next;
330
- }
331
- if (!hit.adds.some((a) => a.dot.actor === delta.add.dot.actor && a.dot.counter === delta.add.dot.counter))
332
- hit.adds.push(delta.add);
333
- return next;
334
- }
335
- /** core `assertRedemptionNotQuarantined` 的语义镜像:**任何** (rule, scope) 下与本 delta 同 dot 的
336
- * 隔离行都让这次写响亮失败——dot 只铸一次,被隔离钉住的 dot 再出现就是「复活」形。 */
337
- function assertNotQuarantined(quarantined, delta) {
338
- const hit = quarantined.find((q) => q.add.dot.actor === delta.add.dot.actor && q.add.dot.counter === delta.add.dot.counter);
339
- if (hit !== undefined) {
340
- throw new Error(`refusing to fold a redemption whose dot (${hit.add.dot.actor}#${hit.add.dot.counter}) is quarantined (${hit.reason}) — ` +
341
- `re-entry requires a NEW consent (a new ask, a new record, a new dot)`);
342
- }
343
- }
344
- /** core `assertDeleteDeltaCarriesNoAdd` 的语义镜像(联合已让它不可拼写,但 JS 调用方不受联合约束 ⇒ 执行它)。 */
345
- function assertDeleteCarriesNoAdd(delta) {
346
- // `Reflect.get` 而不是 `as unknown as {…}`:这一行的**全部目的**就是看一个联合类型里不存在的键在不在
347
- // (JS 调用方不受联合约束),用断言去表达「我知道它可能有个类型上没有的键」既绕过类型面又触类型卫生门。
348
- const smuggled = Reflect.get(delta, "rules");
349
- if (smuggled !== undefined)
350
- throw new Error("a tighten-delete delta must carry a tombstone and nothing else");
351
- if (delta.tombstone === undefined || delta.tombstone.removedDots === undefined)
352
- throw new Error("a tighten-delete delta must carry a tombstone");
353
- }
354
324
  /** {@link SqlPermissionRuleStore} 的 dot 铸造 CAS 重试上限。有界:一个无界循环只会把「争不过」变成
355
325
  * 挂死;用尽 ⇒ 响亮抛错,由 core 的兑付腿(它自己也有有界重试)如实回 refused。 */
356
326
  const NEXT_DOT_MAX_ATTEMPTS = 8;
@@ -475,7 +445,7 @@ class SqlPermissionRuleStore {
475
445
  async quarantined() {
476
446
  return (await this.read()).quarantined;
477
447
  }
478
- get [PERMISSION_RULE_WRITER_KEY]() {
448
+ get [PERMISSION_RULE_WRITER]() {
479
449
  return this.writer;
480
450
  }
481
451
  writer = {
@@ -535,12 +505,14 @@ class SqlPermissionRuleStore {
535
505
  return { conflict: true, rev: cur.rev };
536
506
  let nextRules = cur.rules;
537
507
  let nextTombstones = cur.tombstones;
508
+ // A-033.2(#256):两条判据 + 折叠本体全部走 core 的共享件(`foldDelta` 的 d.ts:「the backends share
509
+ // it so the file and in-memory forms cannot drift in what a delta MEANS」)。此前这三处是本地孪生。
538
510
  if (delta.kind === "redemption-add") {
539
- assertNotQuarantined(cur.quarantined, delta);
540
- nextRules = foldAdd(cur.rules, delta);
511
+ assertRedemptionNotQuarantined(cur.quarantined, delta);
512
+ nextRules = foldDelta(cur.rules, delta);
541
513
  }
542
514
  else {
543
- assertDeleteCarriesNoAdd(delta);
515
+ assertDeleteDeltaCarriesNoAdd(delta);
544
516
  nextTombstones = [...cur.tombstones, delta.tombstone];
545
517
  }
546
518
  // 🔴 codex 交叉复审 round5 [high] 一(验真后修)——**只写读得回来的东西**。
@@ -48,6 +48,7 @@ import { hostBackgroundShellEnabled, hostExecSpoolEnabled } from "../config.js";
48
48
  import { resolveHostShell, hostShell, spawnGroupOptions, killTreeHard, killTreeSoft, collapseWin32EnvKeys } from "./host-platform.js";
49
49
  import { BackgroundShellManager, seedMemStream, feedMemStream, drainMemStream } from "./background-shell-support.js";
50
50
  import { FileError, ExecutionError, RemoteExecutionError, scrubSecretEnv, RollingTailBuffer, markTruncated, SchedulerError, BackgroundShellError, } from "@sema-agent/core";
51
+ import { recordSecretEnvScrub } from "../observability/secret-env-scrub.js";
51
52
  const PROVIDER = "host";
52
53
  /** Default per-command wall-clock when the caller passes none (NOT a hang detector — host commands rarely hang).
53
54
  * ⚠️ 同判据三腿之一(另两条:`remote-env-local-docker.ts` 同值 30min、`remote-env-ssh.ts` **5min**)——
@@ -1526,8 +1527,15 @@ export class RemoteHostExecutionEnv {
1526
1527
  if (process.env[k] !== undefined)
1527
1528
  base[k] = process.env[k];
1528
1529
  }
1529
- else
1530
- base = scrubSecretEnv(process.env); // "scrub" — core's first-party D1 secret-env filter
1530
+ else {
1531
+ // "scrub" — core's first-party D1 secret-env filter. A-033.3(#256):带 `findings` 收集器调用,
1532
+ // 把「这一次丢了哪些密钥形 key、按哪条规则、置信度多少」交给既有观测面留痕。零留痕的保护证明不了
1533
+ // 自己生效过,也发现不了自己不再生效(词表漂/新命名法穿缝/这条腿被改成 inheritEnv:"all")。
1534
+ // 收集器**不改变**剥除行为一个字节(core: 省略它 ⇒ byte-for-byte 与之前相同)。
1535
+ const findings = [];
1536
+ base = scrubSecretEnv(process.env, findings);
1537
+ recordSecretEnvScrub("server.remote-env-host.inherit-scrub", findings);
1538
+ }
1531
1539
  if (this.cfg.env)
1532
1540
  Object.assign(base, this.cfg.env); // caller-supplied (control-plane secrets) verbatim
1533
1541
  if (perCommand)
@@ -17,6 +17,7 @@ import { execFile } from "node:child_process";
17
17
  import { createHmac, randomBytes } from "node:crypto";
18
18
  import { isAbsolute, join, sep } from "node:path";
19
19
  import { scrubSecretEnv } from "@sema-agent/core";
20
+ import { recordSecretEnvScrub } from "./observability/secret-env-scrub.js";
20
21
  import { redactSecrets } from "./trace/redact.js";
21
22
  /** The instruction-file name chain (core 1.302 S-1): AGENTS.md wins, CLAUDE.md is the fallback, then the
22
23
  * `.claude/` nested form. The FIRST hit is the single declared instruction source. */
@@ -122,11 +123,16 @@ async function hasSymlinkedParent(root, rel) {
122
123
  * and GIT_CONFIG_GLOBAL/SYSTEM=/dev/null drop the global/system config so a planted one can't inject either. */
123
124
  function git(root, args, maxBytes) {
124
125
  const safeArgs = ["-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", ...args];
126
+ // A-033.3(#256):带 `findings` 收集器 —— 见下方 env 行的注释,这条腿丢掉的是「仓内的钩子/凭证助手
127
+ // 本来会看到的」那批 key,零留痕的话保护生效与否在遥测里读不出来。收集器不改变剥除行为。
128
+ const scrubFindings = [];
129
+ const scrubbedEnv = scrubSecretEnv(process.env, scrubFindings);
130
+ recordSecretEnvScrub("server.project-memory.git-subprocess", scrubFindings);
125
131
  return new Promise((res) => {
126
132
  execFile("git", safeArgs,
127
133
  // scrubSecretEnv: a git hook / credential helper must not see the orchestrator's *_KEY/*_TOKEN/*_SECRET env.
128
134
  // GIT_CONFIG_GLOBAL/SYSTEM=/dev/null: ignore ~/.gitconfig + /etc/gitconfig so a planted exec-config can't steer us.
129
- { cwd: root, timeout: 5000, maxBuffer: maxBytes, env: { ...scrubSecretEnv(process.env), GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null" } }, (err, stdout, stderr) => {
135
+ { cwd: root, timeout: 5000, maxBuffer: maxBytes, env: { ...scrubbedEnv, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null" } }, (err, stdout, stderr) => {
130
136
  if (!err)
131
137
  return res({ out: stdout.trim() });
132
138
  // S23: the git narrative used to vanish with zero fingerprint — classify why (→ gitDropReason).
package/dist/run-local.js CHANGED
@@ -78,6 +78,7 @@ import { createPermissionDeniedMeter } from "./observability/tool-trace.js";
78
78
  import { createKeyResolver } from "./key-resolver.js";
79
79
  import { createLogger } from "./observability/logger.js";
80
80
  import { createMetrics } from "./observability/metrics.js";
81
+ import { installSecretEnvScrubRecorder } from "./observability/secret-env-scrub.js";
81
82
  /** Parse argv (everything AFTER `node run-local.js`). Throws a clear Error on a missing objective. */
82
83
  export function parseArgs(argv) {
83
84
  let objective;
@@ -308,6 +309,12 @@ export async function runLocal(argv, deps = {}) {
308
309
  const config = loadConfig();
309
310
  const logger = deps.logger ?? createLogger(config.logLevel);
310
311
  const metrics = createMetrics();
312
+ // A-033.3(#256,codex 复审 medium 真修):run-local **也**要装密钥剥除的留痕面。这不是 main.ts 的
313
+ // 复制粘贴 —— 它恰恰是最该有的那条腿:run-local 是 TOC 本地形,模型可驱动的 shell 跑在**用户自己的
314
+ // 机器**上、继承的是用户**真实的** env(脑钥/仓库令牌/DB 凭据),而这两条剥除腿(hostExecutionEnvFactory
315
+ // 的 mergeEnv 与 makeLoadProjectMemory 的 git 子进程)在本入口全都接线了。不装 ⇒ 收集器有了、sink 是空的,
316
+ // 这条腿保留 A-033.3 要消灭的那个「零披露」原状(而且进程一退,连计数都没了)。
317
+ installSecretEnvScrubRecorder({ logger, metrics });
311
318
  // F10 (codex audit): run-local used to drop the pre-logger config diagnostics on the floor (S20 soft-knob
312
319
  // warnings + [792]④ boot notices) — main.ts drained them, this entrypoint didn't. Shared helper, no drift.
313
320
  logConfigDiagnostics(logger);
@@ -52,7 +52,7 @@ export declare function stripDelegationTools<T extends {
52
52
  }>(tools: readonly T[]): T[];
53
53
  /** The operator autonomy mode (registry `runtime.autonomy`). */
54
54
  export type Autonomy = "read-only" | "ask" | "plan" | "auto";
55
- /** 导出给 的枚举式行为钉用(见那里的顶注:从 core 行为反推再比对)。 */
55
+ /** 导出给 `test/coarse-shell-tools-mirror.test.ts` 的同源钉用(见那里的顶注)。 */
56
56
  export declare const COARSE_SHELL_TOOLS_MIRROR: readonly string[];
57
57
  /** Validate `commandPolicy` rules — the shape (array of {command, decision}), the `command` (a bare argv[0] name,
58
58
  * see {@link VALID_COMMAND_NAME}), AND the `decision` enum. Returns the list of human-readable errors (empty =
@@ -23,7 +23,7 @@
23
23
  * path-prefixed commands all bypass the argv[0] match (they route to `ask`, fail-closed, but are not blocked by
24
24
  * name). Real isolation is the `executionEnv` sandbox. Same caveat core documents on `createCoarseCommandNamePolicy`.
25
25
  */
26
- import { combinePolicies, createCoarseCommandNamePolicy, DEFAULT_SUBAGENT_TOOL_NAME, parseLeadingCommandName, tightenTaskSpec, } from "@sema-agent/core";
26
+ import { combinePolicies, COARSE_SHELL_TOOLS as CORE_COARSE_SHELL_TOOLS, createCoarseCommandNamePolicy, DEFAULT_SUBAGENT_TOOL_NAME, parseLeadingCommandName, tightenTaskSpec, } from "@sema-agent/core";
27
27
  import { currentGovernanceAskMarks } from "./governance-ask-marks.js";
28
28
  /** True when `name` names a DELEGATION tool — the subagent tool(CC-187 canonical `"Agent"`,core
29
29
  * `DEFAULT_SUBAGENT_TOOL_NAME`)。5.0.0 RB-476:折叠面退役,RAW 比对 core 单源常量——旧名("Task")
@@ -48,23 +48,20 @@ export function stripDelegationTools(tools) {
48
48
  const kept = tools.filter((t) => !isDelegationToolName(t.name));
49
49
  return kept.length === tools.length ? tools : kept;
50
50
  }
51
- /** The shell tool names a command-name gate parses. Mirrors core's (un-exported) `COARSE_SHELL_TOOLS` default so
52
- * the ask-list policy below gates the SAME tools `createCoarseCommandNamePolicy` does. A unit test pins behavior
53
- * (a `Bash` ask-command asks) so a core rename surfaces as a red test, not silent drift.
51
+ /** The shell tool names a command-name gate parses **core {@link COARSE_SHELL_TOOLS} 本尊**,不再是镜像。
52
+ * 本仓的 ask-list 策略因此恒门住与 `createCoarseCommandNamePolicy` **同一批**工具。
54
53
  * Q6 (core 1.161): tool names align to CC — `bash`→`Bash`; the old `bash_readonly` MERGED into `Bash` (read-only is
55
54
  * now a toolEffect, not a separate tool).
56
55
  *
57
- * 🔴 2026-07-26:core 1.414 把默认从 `["Bash"]` 改成 `["Bash","Monitor"]`(Monitor 走同一条执行 seam,此前是
58
- * 两条 always-on 门的旁门;core 的端到端实证:同一条 `rm -rf $(cat targets)`,Bash 被拒、**Monitor 零门执行**)
59
- * 我这份镜像因此漂了,后果是 **blocklist 模式下我的 ask-list 不覆盖 Monitor core deny 覆盖**。
60
- * ⚠️ 更要紧的是**旧的钉抓不到这次加名** —— 它只钉了「一条 Bash ask-command 会 ask」,加一个新名字它照样绿
61
- * (core [1707]③ 明确点了这条)。现在的钉改成**枚举式且从 core 的行为反推**:
62
- * `test/coarse-shell-tools-mirror.test.ts` core 导出的 `createCoarseCommandNamePolicy()`(默认参)去逐个探
63
- * 候选工具名,把「core 真的门了哪些」测出来,再断言本常量**等于**那个集合。
64
- * 这样做的理由:core 的 `COARSE_SHELL_TOOLS` **未导出**,而扫它的 dist 源码只能挡写法不挡行为
65
- * (core RB-149 的教训:一个变异靠换写法就穿过了源码扫描)。 */
66
- const COARSE_SHELL_TOOLS = ["Bash", "Monitor"];
67
- /** 导出给 的枚举式行为钉用(见那里的顶注:从 core 行为反推再比对)。 */
56
+ * 🔴 A-033.1(#256)换装记:本常量此前是**手抄镜像** `["Bash","Monitor"] as const`,注释自称「core 未导出」。
57
+ * 那句话在 core RB-153(server [1708] 请托)之后就过期了 —— core 现在**公开导出**这份名单(index.d.ts:134),
58
+ * 它自己的原话是「The list itself is the fact; publishing it removes the guess」。镜像的真实代价有前科:
59
+ * core 1.414 把默认从 `["Bash"]` 扩到 `["Bash","Monitor"]`(Monitor 走同一条执行 seam),镜像漏跟一轮
60
+ * **blocklist 模式下本仓的 ask-list 不覆盖 Monitor 而 core deny 覆盖**(core 端到端实证:同一条
61
+ * `rm -rf $(cat targets)`,Bash 被拒、Monitor 零门执行)。换真 import 之后这一整类漏跟在结构上消失。
62
+ * `test/coarse-shell-tools-mirror.test.ts` 的钉相应从「行为反推 + 值比对」改成**同源断言**(见那里顶注)。 */
63
+ const COARSE_SHELL_TOOLS = CORE_COARSE_SHELL_TOOLS;
64
+ /** 导出给 `test/coarse-shell-tools-mirror.test.ts` 的同源钉用(见那里的顶注)。 */
68
65
  export const COARSE_SHELL_TOOLS_MIRROR = COARSE_SHELL_TOOLS;
69
66
  /**
70
67
  * A per-command ask-list ToolPolicy: a parsed shell command whose `argv[0]` is in `askCommands` tightens to
@@ -138,7 +138,9 @@ export interface AuthContext {
138
138
  sessionId: string;
139
139
  /** Long-term memory scope, derived from the principal (not the body). Undefined disables memory. */
140
140
  memoryScope?: string;
141
- /** 142-S4:the request's SHAPE-VALIDATED projectId (body.projectId 过 PROJECT_ID_REGEX 门后回传)。
141
+ /** 142-S4:the request's SHAPE-VALIDATED **且已归一(小写)** 的 projectId(body.projectId 过 core
142
+ * `PROJECT_ID_REGEX` 门后折小写回传;A-033.1/#256 宽读严写 —— 词法带 /i 收下大写形,但派生 scope 键
143
+ * 与 config.projects 查表都只认这一个归一形,否则同一个项目会按大小写分成两只记忆盘)。
142
144
  * 身份半场(scope 的 tenant 段)永远来自 verified principal — projectId 只选「哪个项目盘/哪条登记」
143
145
  * (config.projects 查表键 + proj: 键的 projectId 段),不是 capability。main.ts 拿它查 defaultScopes。 */
144
146
  resolvedProjectId?: string;