@sema-agent/server 7.4.0 → 7.6.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 (201) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +18 -3
  3. package/README.zh-CN.md +14 -3
  4. package/USAGE.md +80 -1
  5. package/dist/approval-card.d.ts +15 -3
  6. package/dist/approval-card.js +41 -7
  7. package/dist/approval-reconciler.d.ts +109 -12
  8. package/dist/approval-reconciler.js +152 -24
  9. package/dist/boot/config-center.js +15 -2
  10. package/dist/boot/coordinators.js +10 -2
  11. package/dist/boot/execution-env.js +1 -1
  12. package/dist/boot/org-memory.d.ts +6 -0
  13. package/dist/boot/org-memory.js +1 -1
  14. package/dist/boot/parked-revive-gate.d.ts +78 -0
  15. package/dist/boot/parked-revive-gate.js +114 -0
  16. package/dist/boot/reapers.d.ts +2 -0
  17. package/dist/boot/reapers.js +11 -4
  18. package/dist/boot/resolve-spec.d.ts +3 -19
  19. package/dist/boot/resolve-spec.js +73 -67
  20. package/dist/boot/runner-deps.d.ts +23 -1
  21. package/dist/boot/runner-deps.js +8 -11
  22. package/dist/boot/workflow-orchestration.d.ts +8 -3
  23. package/dist/boot/workflow-orchestration.js +23 -1
  24. package/dist/budget.d.ts +1 -1
  25. package/dist/budget.js +1 -1
  26. package/dist/capabilities/repo-tools.d.ts +1 -1
  27. package/dist/capabilities/repo-tools.js +8 -2
  28. package/dist/config-center/apply-effective.js +33 -10
  29. package/dist/config-provider.d.ts +1 -0
  30. package/dist/config-provider.js +23 -3
  31. package/dist/config-types.d.ts +27 -9
  32. package/dist/config.d.ts +6 -1
  33. package/dist/config.js +61 -15
  34. package/dist/deployment-governance.d.ts +168 -0
  35. package/dist/deployment-governance.js +206 -0
  36. package/dist/env-facts.d.ts +3 -1
  37. package/dist/env-facts.js +3 -1
  38. package/dist/fleet/fleet-bus.d.ts +17 -2
  39. package/dist/fleet/fleet-bus.js +68 -3
  40. package/dist/fleet/fleet-terminal-window.d.ts +98 -0
  41. package/dist/fleet/fleet-terminal-window.js +316 -0
  42. package/dist/governance-ask-marks.d.ts +31 -0
  43. package/dist/governance-ask-marks.js +122 -0
  44. package/dist/hooks/hook-runner.d.ts +28 -0
  45. package/dist/hooks/hook-runner.js +149 -25
  46. package/dist/http/routes/approvals-assistant.js +6 -7
  47. package/dist/http/routes/diagnostics.js +10 -5
  48. package/dist/http/routes/fleet.js +160 -14
  49. package/dist/http/routes/memory-policy.d.ts +2 -1
  50. package/dist/http/routes/memory-policy.js +77 -13
  51. package/dist/http/routes/runs.js +6 -2
  52. package/dist/http/routes/tasks.js +59 -22
  53. package/dist/http/routes/trace-usage.js +3 -4
  54. package/dist/http/send.d.ts +23 -0
  55. package/dist/http/send.js +23 -0
  56. package/dist/http/server.d.ts +9 -0
  57. package/dist/http/server.js +28 -14
  58. package/dist/http/sse-log.js +3 -4
  59. package/dist/http/wire-types.d.ts +7 -2
  60. package/dist/leader/diffout.d.ts +10 -0
  61. package/dist/leader/diffout.js +14 -2
  62. package/dist/leader/diffup.js +3 -2
  63. package/dist/leader/planner.js +7 -0
  64. package/dist/main.js +39 -31
  65. package/dist/observability/fail-open.d.ts +17 -2
  66. package/dist/observability/fail-open.js +19 -4
  67. package/dist/observability/prompt-manifest.d.ts +5 -1
  68. package/dist/orchestration/workflow-notify-journal.d.ts +58 -2
  69. package/dist/orchestration/workflow-notify-journal.js +130 -45
  70. package/dist/parked-decide.js +9 -4
  71. package/dist/plugins/approval-ask-store-memory.d.ts +2 -2
  72. package/dist/plugins/approval-ask-store-memory.js +3 -2
  73. package/dist/plugins/approval-ask-store-sql.d.ts +60 -5
  74. package/dist/plugins/approval-ask-store-sql.js +75 -35
  75. package/dist/plugins/background-agent-store-sql.js +16 -16
  76. package/dist/plugins/background-shell-support.d.ts +1 -1
  77. package/dist/plugins/background-shell-support.js +2 -2
  78. package/dist/plugins/breaker-state-sql.js +2 -2
  79. package/dist/plugins/checkpoint-store-sql.d.ts +67 -8
  80. package/dist/plugins/checkpoint-store-sql.js +76 -13
  81. package/dist/plugins/image-bake-store-sql.d.ts +1 -1
  82. package/dist/plugins/image-bake-store-sql.js +27 -27
  83. package/dist/plugins/image-index-sql.js +15 -15
  84. package/dist/plugins/local-checkpoint-store.d.ts +20 -1
  85. package/dist/plugins/local-checkpoint-store.js +19 -0
  86. package/dist/plugins/mailbox-store-sql.d.ts +4 -10
  87. package/dist/plugins/mailbox-store-sql.js +59 -6
  88. package/dist/plugins/memory-engine-pg.js +9 -9
  89. package/dist/plugins/memory-engine-tidb.js +7 -7
  90. package/dist/plugins/memory-sync-store-pg.js +13 -13
  91. package/dist/plugins/memory-sync-store-tidb.js +5 -5
  92. package/dist/plugins/outcome-ledger-sql.js +7 -7
  93. package/dist/plugins/pg-cost-quota.js +3 -3
  94. package/dist/plugins/pg-pool.js +84 -75
  95. package/dist/plugins/pg-rate-limiter.js +3 -3
  96. package/dist/plugins/pg-session-storage.d.ts +1 -1
  97. package/dist/plugins/pg-session-storage.js +12 -13
  98. package/dist/plugins/remote-env-host.js +3 -1
  99. package/dist/plugins/remote-env-local-docker.js +6 -3
  100. package/dist/plugins/remote-env-ssh.d.ts +13 -1
  101. package/dist/plugins/roster-store-sql.js +8 -8
  102. package/dist/plugins/store-contracts.d.ts +19 -0
  103. package/dist/plugins/store-contracts.js +42 -0
  104. package/dist/plugins/task-attachment-store.js +5 -5
  105. package/dist/plugins/task-list-store-sql.js +1 -1
  106. package/dist/plugins/tidb-cost-quota.js +1 -1
  107. package/dist/plugins/tidb-pool.js +83 -60
  108. package/dist/plugins/tidb-rate-limiter.js +1 -1
  109. package/dist/plugins/tidb-session-store.js +2 -5
  110. package/dist/plugins/tool-result-store-sql.js +2 -2
  111. package/dist/plugins/usage-window-store-sql.js +13 -13
  112. package/dist/plugins/write-behind-counter.d.ts +10 -2
  113. package/dist/plugins/write-behind-counter.js +13 -3
  114. package/dist/resource-suspend.d.ts +3 -1
  115. package/dist/resource-suspend.js +3 -1
  116. package/dist/run-local.d.ts +73 -1
  117. package/dist/run-local.js +146 -5
  118. package/dist/runs.d.ts +11 -1
  119. package/dist/runs.js +18 -3
  120. package/dist/runtime-governance.d.ts +18 -0
  121. package/dist/runtime-governance.js +90 -3
  122. package/dist/security.d.ts +12 -0
  123. package/dist/security.js +12 -0
  124. package/dist/session-sync-kernel.d.ts +13 -0
  125. package/dist/session-sync-kernel.js +13 -0
  126. package/dist/task-settings.d.ts +3 -9
  127. package/dist/task-settings.js +16 -13
  128. package/dist/tool-approval.d.ts +33 -6
  129. package/dist/tool-approval.js +80 -23
  130. package/dist/trace/core-keyset-guard.d.ts +18 -4
  131. package/dist/trace/project.d.ts +10 -1
  132. package/dist/trace/project.js +31 -0
  133. package/package.json +3 -3
  134. package/dist/boot/lexical-path-env.d.ts +0 -10
  135. package/dist/boot/lexical-path-env.js +0 -88
  136. package/dist/capabilities/oa-tools.d.ts +0 -15
  137. package/dist/capabilities/oa-tools.js +0 -54
  138. package/dist/finance/cost-taxonomy.d.ts +0 -34
  139. package/dist/finance/cost-taxonomy.js +0 -26
  140. package/dist/plugins/approval-store-sql.d.ts +0 -116
  141. package/dist/plugins/approval-store-sql.js +0 -151
  142. package/dist/plugins/file-workflow-journal-store.d.ts +0 -12
  143. package/dist/plugins/file-workflow-journal-store.js +0 -12
  144. package/dist/plugins/pg-approval-store.d.ts +0 -9
  145. package/dist/plugins/pg-approval-store.js +0 -9
  146. package/dist/plugins/pg-breaker-state.d.ts +0 -8
  147. package/dist/plugins/pg-breaker-state.js +0 -8
  148. package/dist/plugins/pg-checkpoint-store.d.ts +0 -10
  149. package/dist/plugins/pg-checkpoint-store.js +0 -10
  150. package/dist/plugins/pg-file-snapshot-store.d.ts +0 -8
  151. package/dist/plugins/pg-file-snapshot-store.js +0 -8
  152. package/dist/plugins/pg-image-bake.d.ts +0 -12
  153. package/dist/plugins/pg-image-bake.js +0 -11
  154. package/dist/plugins/pg-image-index.d.ts +0 -12
  155. package/dist/plugins/pg-image-index.js +0 -11
  156. package/dist/plugins/pg-outcome-ledger.d.ts +0 -12
  157. package/dist/plugins/pg-outcome-ledger.js +0 -11
  158. package/dist/plugins/pg-resume-anchor-store.d.ts +0 -7
  159. package/dist/plugins/pg-resume-anchor-store.js +0 -7
  160. package/dist/plugins/pg-run-store.d.ts +0 -9
  161. package/dist/plugins/pg-run-store.js +0 -9
  162. package/dist/plugins/pg-session-policy-store.d.ts +0 -7
  163. package/dist/plugins/pg-session-policy-store.js +0 -7
  164. package/dist/plugins/pg-session-store.d.ts +0 -12
  165. package/dist/plugins/pg-session-store.js +0 -12
  166. package/dist/plugins/pg-tool-result-store.d.ts +0 -9
  167. package/dist/plugins/pg-tool-result-store.js +0 -9
  168. package/dist/plugins/pg-workflow-journal-store.d.ts +0 -9
  169. package/dist/plugins/pg-workflow-journal-store.js +0 -9
  170. package/dist/plugins/pg-workflow-run-store.d.ts +0 -9
  171. package/dist/plugins/pg-workflow-run-store.js +0 -9
  172. package/dist/plugins/tidb-approval-store.d.ts +0 -8
  173. package/dist/plugins/tidb-approval-store.js +0 -8
  174. package/dist/plugins/tidb-breaker-state.d.ts +0 -7
  175. package/dist/plugins/tidb-breaker-state.js +0 -7
  176. package/dist/plugins/tidb-checkpoint-store.d.ts +0 -9
  177. package/dist/plugins/tidb-checkpoint-store.js +0 -9
  178. package/dist/plugins/tidb-file-snapshot-store.d.ts +0 -8
  179. package/dist/plugins/tidb-file-snapshot-store.js +0 -8
  180. package/dist/plugins/tidb-image-bake.d.ts +0 -12
  181. package/dist/plugins/tidb-image-bake.js +0 -11
  182. package/dist/plugins/tidb-image-index.d.ts +0 -12
  183. package/dist/plugins/tidb-image-index.js +0 -11
  184. package/dist/plugins/tidb-outcome-ledger.d.ts +0 -12
  185. package/dist/plugins/tidb-outcome-ledger.js +0 -12
  186. package/dist/plugins/tidb-resume-anchor-store.d.ts +0 -7
  187. package/dist/plugins/tidb-resume-anchor-store.js +0 -7
  188. package/dist/plugins/tidb-run-store.d.ts +0 -10
  189. package/dist/plugins/tidb-run-store.js +0 -9
  190. package/dist/plugins/tidb-session-policy-store.d.ts +0 -7
  191. package/dist/plugins/tidb-session-policy-store.js +0 -7
  192. package/dist/plugins/tidb-tool-result-store.d.ts +0 -8
  193. package/dist/plugins/tidb-tool-result-store.js +0 -10
  194. package/dist/plugins/tidb-workflow-journal-store.d.ts +0 -9
  195. package/dist/plugins/tidb-workflow-journal-store.js +0 -9
  196. package/dist/plugins/tidb-workflow-run-store.d.ts +0 -10
  197. package/dist/plugins/tidb-workflow-run-store.js +0 -10
  198. package/dist/plugins/workflow-journal-limits.d.ts +0 -12
  199. package/dist/plugins/workflow-journal-limits.js +0 -12
  200. package/dist/sema-registry.d.ts +0 -41
  201. package/dist/sema-registry.js +0 -40
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { type Brain } from "@sema-agent/core";
2
+ import { type Brain, type OnAsk, type TaskSpec } from "@sema-agent/core";
3
+ import { type ApprovalBaselineConfigView, type DeploymentGovernanceConfigView } from "./deployment-governance.js";
3
4
  import { type ServiceConfig } from "./config.js";
4
5
  import { type Logger } from "./observability/logger.js";
5
6
  /** Parsed CLI invocation. */
@@ -33,6 +34,12 @@ export interface RunLocalDeps {
33
34
  print?: (line: string) => void;
34
35
  /** stderr sink for the "you still need to set …" guidance (defaults to console.error). */
35
36
  printErr?: (line: string) => void;
37
+ /** design/181 件三: override the TTY detection behind the {@link createLocalApprover} seat
38
+ * (defaults to `process.stdin.isTTY === true`) — lets a test drive BOTH arms deterministically. */
39
+ isTty?: boolean;
40
+ /** design/181 件三: the TTY arm's prompt (defaults to a readline `y/N` on stdin/stderr);
41
+ * `signal` = core `OnAsk` 的取消(EOF/abort 决断臂见 {@link promptYesNoOnTty})。 */
42
+ askYesNo?: (prompt: string, signal?: AbortSignal) => Promise<boolean>;
36
43
  }
37
44
  /** Parse argv (everything AFTER `node run-local.js`). Throws a clear Error on a missing objective. */
38
45
  export declare function parseArgs(argv: string[]): RunLocalArgs;
@@ -49,6 +56,71 @@ export declare class UsageError extends Error {
49
56
  * this check in that case — the check is about a REAL run, not the assembly.
50
57
  */
51
58
  export declare function missingGatewayRequirements(config: ServiceConfig): string[];
59
+ /** 本腿读的部署配置切面(`ServiceConfig` 结构满足;两个构造口各自的切面之和)。 */
60
+ export interface LocalGovernanceConfigView extends DeploymentGovernanceConfigView, ApprovalBaselineConfigView {
61
+ }
62
+ /**
63
+ * design/181 件三:run-local 腿的**部署治理链装配**(#180)。
64
+ *
65
+ * 与另外两条腿(HTTP 的 `resolveSpec`、durable park 的赎回腿)装的是**同一条**链,经**同一个**构造口:
66
+ * 审批基线作 base 的 `toolPolicy` 座,再经**折叠属主** `applyRuntimeGovernance`(→ core `tightenTaskSpec`)
67
+ * 叠上部署治理段(autonomy / commandPolicy / MANUAL_MODE_SHELL_GATE / 守卫集)。本函数一条 policy 都不
68
+ * 自己合成。
69
+ *
70
+ * · **审批基线 = adjudicated allow-all**:一次性 CLI 没有 durable 审批设施(没有 `/decide` 腿可赎回),
71
+ * 所以 `createApprovalBaselinePolicy` 走 durable 缺席那一臂。它是一条**在场的** effect-aware 策略
72
+ * (满足 core 的 `hasEffectAwareGate`),运维照旧用 `AUTONOMY` / `commandPolicy` / 守卫集在其上收紧。
73
+ * · **裁决 env 恒 host 形,锚 `workspaceDir`**:run-local 的手就跑在这台机器的这个目录上
74
+ * (`--workspace` / config.d workdir / cwd 三选一,调用方已解析好),所以 `cwd` 恒在场 —— 相对形写目标
75
+ * 由 core 按 `rootPath` 解析进真身裁决,构造口的词法补层(沙箱腿那条)在这里自动不铺。
76
+ * ✅ **旧残余面已收口(core 5.19.0 #108,2026-08-08 校正)**:`rootPath` 是装配期的**静态**值,而 core
77
+ * 的写工具解析相对形用的是被 Bash `cd` 就地改写的活 cwd —— 于是 `cd .git/hooks` 之后
78
+ * `Write("pre-commit")` 落进守卫段却判不出来(两条 host 腿同形,本腿与 resolve-spec)。5.19.0 起守卫
79
+ * 按 `ToolCallRequest.cwd`(引擎每次调用从同一只 tracked cwd 盖戳)解析写目标,本腿零改动即得保护;
80
+ * **不对称是 core 故意的**:目标跟活 cwd 走,守卫自己配置的目录仍锚静态根,`cd` 搬不动栅栏。全文与
81
+ * 证据见 `deployment-governance.ts` 的 `RelativeTargetLexicalEnv` 顶注,端到端正控钉见
82
+ * test/run-local.test.ts(该钉已由 🟡 特征化翻成 🔴 正控)。
83
+ * · **禁 memoize**:四个旋钮都是热改字段,每次装配现读活 config(与另两条腿同一姿势)。
84
+ */
85
+ export declare function applyLocalGovernance(base: TaskSpec, config: LocalGovernanceConfigView, workspaceDir: string): TaskSpec;
86
+ /** {@link createLocalApprover} 的 io 面(全部可注入,所以两条臂都测得动)。 */
87
+ export interface LocalApproverIo {
88
+ /** 这次调用是不是接在一个人面前(生产里 = `process.stdin.isTTY`)。 */
89
+ readonly isTty: boolean;
90
+ /** TTY 臂的问人器(默认 {@link promptYesNoOnTty} 的 readline y/N;`signal`=core 取消透传)。 */
91
+ readonly askYesNo: (prompt: string, signal?: AbortSignal) => Promise<boolean>;
92
+ /** 非 TTY 臂的告知面(stderr)。 */
93
+ readonly printErr: (line: string) => void;
94
+ }
95
+ /**
96
+ * design/181 件三:**onAsk 座**(§0 潜雷 / §5 锁 6)。
97
+ *
98
+ * 🔴 为什么这个座位是接治理链的**同窗必做项**:run-local 有 checkpointStore(file backend),所以
99
+ * `AUTONOMY=ask` ⇒ `shellGate:"always"` ⇒ Bash 进 `irreversibleTools` ⇒ park lane effective ⇒ **每一次
100
+ * shell 调用都 durable park 成一张永远没人 resume 的 checkpoint**,任务挂起、CLI 退 1。一次性 CLI 里
101
+ * 没有任何赎回腿,那不是「等人来批」,是卡死。core 的 `isLiveApproverSeat(onAsk)` 为真时 `suspendAsk`
102
+ * 早退(prepare-task dist 亲读),ask 于是落回本回调 —— 把 park 换成「问人 / 当场拒」的唯一正当接法。
103
+ *
104
+ * ⛔ **不在 toolPolicy 层做 ask→deny 映射**:core 自己就有 headless auto-deny 的语义与文案,在策略层
105
+ * 复刻一份 = 同源谎(两处判据日后各改各的)。裁决归 core,座位归这里。
106
+ *
107
+ * 🔴 非 TTY 臂答 `false`(真 deny),**绝不答 `"unavailable"`**:后者被 core 读成「席位够不到」并**改道
108
+ * `suspendAsk`**(dist `core/hooks.js:296` 的 `approverUnavailable` 重投),上面那颗雷会原样回来。
109
+ */
110
+ export declare function createLocalApprover(io: LocalApproverIo): OnAsk;
111
+ /** 默认的 TTY 问人器:一问一答,回答写 **stderr**(stdout 是任务结果的专用管道,别污染它)。
112
+ * streams 可注入=EOF/abort 两臂测得动(默认 process.stdin/stderr)。
113
+ *
114
+ * 🔴 决断三臂(codex 验收轮 high,红先修):`readline/promises` 的 `question` 在输入流 EOF/close 时
115
+ * **不决**(promise 悬挂)——Ctrl-D、关闭的 stdin、任务取消都会把 runLocal 永久挂住,文件后端的锁
116
+ * 跟着被拖住,下一次同 root 调用直接卡死。三臂全部 fail-closed 决 false:
117
+ * · `signal` abort(core `OnAsk` 递进来的取消,含预先 aborted);
118
+ * · readline `close`(输入流 end/close 时 readline 自关);
119
+ * · `question` 自身 reject(abort throw / 流错误)。 */
120
+ export declare function promptYesNoOnTty(prompt: string, signal?: AbortSignal, streams?: {
121
+ input: NodeJS.ReadableStream;
122
+ output: NodeJS.WritableStream;
123
+ }): Promise<boolean>;
52
124
  /**
53
125
  * Boot the engine in LOCAL mode, run ONE task to completion, print the result. Returns the process exit
54
126
  * code (0 = completed, 1 = the task did not complete / a fatal error, 2 = bad usage / unmet env). Pure of
package/dist/run-local.js CHANGED
@@ -13,7 +13,18 @@
13
13
  * applyEffective (config.d/.env drives models/roles/mcp), and the `host` exec lane (run on THIS machine,
14
14
  * no container) — identical building blocks to the server path so behavior matches (no drift). It reuses
15
15
  * main.ts's helpers verbatim; the only thing it skips is the server-only surface (createHttpServer, listen,
16
- * auth/rateLimit/quota/run-store/leader/durable-checkpoint/reapers).
16
+ * auth/rateLimit/quota/run-store/leader/durable-approval-decide/reapers).
17
+ *
18
+ * 🔴 **DEPLOYMENT GOVERNANCE IS NOT IN THAT SKIP LIST** (design/181 件三 / #180). Until 7.4.x this file
19
+ * assembled its `TaskSpec` with the governance chain entirely ABSENT — `loadConfig` + `applyEffective` had
20
+ * already parsed `autonomy` / `commandPolicy` / `sensitiveWritePatterns` into the config, and the spec
21
+ * literal simply never read them (compute-then-discard), so the same operator knob was live on the HTTP leg
22
+ * and structurally non-existent here. It now goes through the SAME single construction port as the other two
23
+ * legs ({@link applyLocalGovernance} → `src/deployment-governance.ts` → `applyRuntimeGovernance` → core's
24
+ * `tightenTaskSpec`). What this leg genuinely does NOT have is the **durable approval** face (a one-shot CLI
25
+ * has no `/v1/approvals/:id/decide` leg to resume from), so the approval baseline is the adjudicated
26
+ * allow-all arm and a gated `ask` is answered by {@link createLocalApprover} — the TTY human, or a
27
+ * fail-closed deny — instead of parking a checkpoint nobody will ever redeem.
17
28
  *
18
29
  * Usage: run-local "<objective>" [--root <dir>] [--json] [--scenario <name>]
19
30
  *
@@ -24,10 +35,14 @@
24
35
  import { homedir } from "node:os";
25
36
  import { join } from "node:path";
26
37
  import { realpathSync } from "node:fs";
38
+ import { createInterface } from "node:readline/promises";
27
39
  import { fileURLToPath } from "node:url";
28
40
  import { loadRemoteExec } from "@sema-agent/registry-core/node";
29
- import { Runner, uuidv7, parseModelMention, FileStorageBackend, NodeLspManager, TtlSessionStore, CenterPromptSource, FilePromptArtifactStore, FilePromptSourceStateStore, MemoryPromptArtifactStore, MemoryPromptSourceStateStore } from "@sema-agent/core";
41
+ import { Runner, uuidv7, parseModelMention, FileStorageBackend, NodeExecutionEnv, NodeLspManager, TtlSessionStore, CenterPromptSource, FilePromptArtifactStore, FilePromptSourceStateStore, MemoryPromptArtifactStore, MemoryPromptSourceStateStore } from "@sema-agent/core";
42
+ import { hasOperatorGateIntent } from "./approval.js";
30
43
  import { createBrain } from "./brain.js";
44
+ import { assertGuardPatternsUsable, buildOnlySensitiveBaselineWarning, createApprovalBaselinePolicy, createDeploymentGovernanceInputs, } from "./deployment-governance.js";
45
+ import { applyRuntimeGovernance } from "./runtime-governance.js";
31
46
  import { ForkRoutingSessionStore } from "./plugins/fork-routing-session-store.js";
32
47
  import { taskWallClockSec } from "./task-workflow.js";
33
48
  import { applyCatalogToSource } from "./capabilities/center-prompts.js";
@@ -147,6 +162,89 @@ export function missingGatewayRequirements(config) {
147
162
  }
148
163
  return missing;
149
164
  }
165
+ /**
166
+ * design/181 件三:run-local 腿的**部署治理链装配**(#180)。
167
+ *
168
+ * 与另外两条腿(HTTP 的 `resolveSpec`、durable park 的赎回腿)装的是**同一条**链,经**同一个**构造口:
169
+ * 审批基线作 base 的 `toolPolicy` 座,再经**折叠属主** `applyRuntimeGovernance`(→ core `tightenTaskSpec`)
170
+ * 叠上部署治理段(autonomy / commandPolicy / MANUAL_MODE_SHELL_GATE / 守卫集)。本函数一条 policy 都不
171
+ * 自己合成。
172
+ *
173
+ * · **审批基线 = adjudicated allow-all**:一次性 CLI 没有 durable 审批设施(没有 `/decide` 腿可赎回),
174
+ * 所以 `createApprovalBaselinePolicy` 走 durable 缺席那一臂。它是一条**在场的** effect-aware 策略
175
+ * (满足 core 的 `hasEffectAwareGate`),运维照旧用 `AUTONOMY` / `commandPolicy` / 守卫集在其上收紧。
176
+ * · **裁决 env 恒 host 形,锚 `workspaceDir`**:run-local 的手就跑在这台机器的这个目录上
177
+ * (`--workspace` / config.d workdir / cwd 三选一,调用方已解析好),所以 `cwd` 恒在场 —— 相对形写目标
178
+ * 由 core 按 `rootPath` 解析进真身裁决,构造口的词法补层(沙箱腿那条)在这里自动不铺。
179
+ * ✅ **旧残余面已收口(core 5.19.0 #108,2026-08-08 校正)**:`rootPath` 是装配期的**静态**值,而 core
180
+ * 的写工具解析相对形用的是被 Bash `cd` 就地改写的活 cwd —— 于是 `cd .git/hooks` 之后
181
+ * `Write("pre-commit")` 落进守卫段却判不出来(两条 host 腿同形,本腿与 resolve-spec)。5.19.0 起守卫
182
+ * 按 `ToolCallRequest.cwd`(引擎每次调用从同一只 tracked cwd 盖戳)解析写目标,本腿零改动即得保护;
183
+ * **不对称是 core 故意的**:目标跟活 cwd 走,守卫自己配置的目录仍锚静态根,`cd` 搬不动栅栏。全文与
184
+ * 证据见 `deployment-governance.ts` 的 `RelativeTargetLexicalEnv` 顶注,端到端正控钉见
185
+ * test/run-local.test.ts(该钉已由 🟡 特征化翻成 🔴 正控)。
186
+ * · **禁 memoize**:四个旋钮都是热改字段,每次装配现读活 config(与另两条腿同一姿势)。
187
+ */
188
+ export function applyLocalGovernance(base, config, workspaceDir) {
189
+ return applyRuntimeGovernance({ ...base, toolPolicy: createApprovalBaselinePolicy(config) }, createDeploymentGovernanceInputs(config, { env: new NodeExecutionEnv({ cwd: workspaceDir }), cwd: workspaceDir }));
190
+ }
191
+ /**
192
+ * design/181 件三:**onAsk 座**(§0 潜雷 / §5 锁 6)。
193
+ *
194
+ * 🔴 为什么这个座位是接治理链的**同窗必做项**:run-local 有 checkpointStore(file backend),所以
195
+ * `AUTONOMY=ask` ⇒ `shellGate:"always"` ⇒ Bash 进 `irreversibleTools` ⇒ park lane effective ⇒ **每一次
196
+ * shell 调用都 durable park 成一张永远没人 resume 的 checkpoint**,任务挂起、CLI 退 1。一次性 CLI 里
197
+ * 没有任何赎回腿,那不是「等人来批」,是卡死。core 的 `isLiveApproverSeat(onAsk)` 为真时 `suspendAsk`
198
+ * 早退(prepare-task dist 亲读),ask 于是落回本回调 —— 把 park 换成「问人 / 当场拒」的唯一正当接法。
199
+ *
200
+ * ⛔ **不在 toolPolicy 层做 ask→deny 映射**:core 自己就有 headless auto-deny 的语义与文案,在策略层
201
+ * 复刻一份 = 同源谎(两处判据日后各改各的)。裁决归 core,座位归这里。
202
+ *
203
+ * 🔴 非 TTY 臂答 `false`(真 deny),**绝不答 `"unavailable"`**:后者被 core 读成「席位够不到」并**改道
204
+ * `suspendAsk`**(dist `core/hooks.js:296` 的 `approverUnavailable` 重投),上面那颗雷会原样回来。
205
+ */
206
+ export function createLocalApprover(io) {
207
+ return async (req, signal) => {
208
+ if (!io.isTty) {
209
+ io.printErr(`denied "${req.toolName}" (${req.message}) — this run-local invocation has no TTY, so there is nobody to ask. ` +
210
+ `The gate comes from this deployment's governance knobs: AUTONOMY, MANUAL_MODE_SHELL_GATE, ` +
211
+ `runtime.commandPolicy (config.d/governance.json). Run it in a terminal to answer interactively, or relax the knob.`);
212
+ return false;
213
+ }
214
+ return io.askYesNo(`approve "${req.toolName}"? ${req.message} [y/N] `, signal);
215
+ };
216
+ }
217
+ /** 默认的 TTY 问人器:一问一答,回答写 **stderr**(stdout 是任务结果的专用管道,别污染它)。
218
+ * streams 可注入=EOF/abort 两臂测得动(默认 process.stdin/stderr)。
219
+ *
220
+ * 🔴 决断三臂(codex 验收轮 high,红先修):`readline/promises` 的 `question` 在输入流 EOF/close 时
221
+ * **不决**(promise 悬挂)——Ctrl-D、关闭的 stdin、任务取消都会把 runLocal 永久挂住,文件后端的锁
222
+ * 跟着被拖住,下一次同 root 调用直接卡死。三臂全部 fail-closed 决 false:
223
+ * · `signal` abort(core `OnAsk` 递进来的取消,含预先 aborted);
224
+ * · readline `close`(输入流 end/close 时 readline 自关);
225
+ * · `question` 自身 reject(abort throw / 流错误)。 */
226
+ export async function promptYesNoOnTty(prompt, signal, streams = { input: process.stdin, output: process.stderr }) {
227
+ if (signal?.aborted)
228
+ return false;
229
+ const rl = createInterface({ input: streams.input, output: streams.output });
230
+ try {
231
+ return await new Promise((resolve) => {
232
+ const settle = (v) => {
233
+ rl.off("close", onClosed);
234
+ signal?.removeEventListener("abort", onAborted);
235
+ resolve(v);
236
+ };
237
+ const onClosed = () => settle(false);
238
+ const onAborted = () => settle(false);
239
+ rl.on("close", onClosed);
240
+ signal?.addEventListener("abort", onAborted, { once: true });
241
+ rl.question(prompt).then((answer) => settle(/^y(es)?$/i.test(answer.trim())), () => settle(false));
242
+ });
243
+ }
244
+ finally {
245
+ rl.close();
246
+ }
247
+ }
150
248
  /**
151
249
  * Boot the engine in LOCAL mode, run ONE task to completion, print the result. Returns the process exit
152
250
  * code (0 = completed, 1 = the task did not complete / a fatal error, 2 = bad usage / unmet env). Pure of
@@ -248,6 +346,28 @@ export async function runLocal(argv, deps = {}) {
248
346
  printErr(`config.d/remote-exec.json is invalid: ${String(err)}`);
249
347
  return 2;
250
348
  }
349
+ // ── design/181 件三 boot 期两件(与 HTTP 腿同一只判据)────────────────────────────────────────
350
+ // ① 守卫集的**可编译性**门:编译发生在每一次装配上,一条「一个路径段都没有」的模式(`"/"`)会 THROW,
351
+ // 运维从那条错误里看不出是自己的 env 写错了。共享 .env 的坏值必须炸在启动上——这条腿按 run-local 的
352
+ // 既定 UX 落地(doctor 文案 + 退 2),而不是把栈抛给用户。
353
+ try {
354
+ assertGuardPatternsUsable(config);
355
+ }
356
+ catch (e) {
357
+ printErr(e instanceof Error ? e.message : String(e));
358
+ printErr(`(local config root: ${root} — fix it in ${join(root, ".env")} or your shell env; \`SENSITIVE_WRITE_PATTERNS=off\` disables the guard set)`);
359
+ return 2;
360
+ }
361
+ // ② UNGATED 补偿:接上审批基线后 core 的 `hasEffectAwareGate` 恒真 ⇒ 它那条 "write-capable hand tools
362
+ // are present but UNGATED" 的信号在这条腿上结构性消失。判据与文案是**同一只**构造口的
363
+ // (buildOnlySensitiveBaselineWarning),两个座位量按本腿的真形取:durable 门恒不在场(一次性 CLI
364
+ // 没有 /decide 赎回腿),单用户 auto-accept 基线的适用性判据与 main.ts 逐字同源。
365
+ const ungatedCompensation = buildOnlySensitiveBaselineWarning(config, {
366
+ durableEnabled: false,
367
+ singleUserAutoAcceptBaseline: config.requirePrincipal !== true && !hasOperatorGateIntent(config),
368
+ });
369
+ if (ungatedCompensation)
370
+ logger.warn(ungatedCompensation.event, ungatedCompensation.fields);
251
371
  // ── Pre-flight: with a REAL brain, validate the gateway + key and print the doctor-style guidance instead
252
372
  // of crashing on the first brain call. With an injected mock brain, no real call happens → skip. ──
253
373
  if (!deps.brain) {
@@ -361,6 +481,20 @@ export async function runLocal(argv, deps = {}) {
361
481
  catch (e) {
362
482
  logger.warn("center_prompts_catalog_restore_failed", { err: String(e), note: "catalog candidate absent — built-ins serve" });
363
483
  }
484
+ // design/181 件三:**onAsk 座**(全文理由见 {@link createLocalApprover})。两只 Runner 同装 —— 委派出去
485
+ // 的子任务也在同一台机器上动手,少装一只就是「主任务问人、子任务 park 卡死」。
486
+ const onAsk = createLocalApprover({
487
+ isTty: deps.isTty ?? process.stdin.isTTY === true,
488
+ askYesNo: deps.askYesNo ?? promptYesNoOnTty,
489
+ printErr,
490
+ });
491
+ /** core 的 `RunnerDeps.onError` 座(两只 Runner 共用一只闭包 —— 两处各写一份正是漂移的成因)。 */
492
+ const engineOnError = (err, ctx) => logger.warn("engine_error", {
493
+ phase: ctx.phase,
494
+ sessionId: ctx.sessionId,
495
+ ...(ctx.classification !== undefined ? { classification: ctx.classification } : {}),
496
+ err: String(err),
497
+ });
364
498
  const runner = new Runner({
365
499
  brain,
366
500
  models: config.models,
@@ -372,6 +506,11 @@ export async function runLocal(argv, deps = {}) {
372
506
  sessionStore,
373
507
  ...(memoryEngine ? { memoryBackend: memoryEngine.backend, memoryEngineDir: memoryEngine.root } : {}),
374
508
  checkpointStore: fileBackend.checkpointStore,
509
+ // design/181 件三:core 的降级/配置类告警此前被 `deps.onError?.()` 的可选链整条吞掉(这条腿从不接这
510
+ // 个座),包括那条 "write-capable hand tools are present but UNGATED"。接上 stderr(logger.warn 走
511
+ // stderr)后其余告警——compaction / prompt-cache / mcp / memory / hook 的降级——也不再无声。
512
+ onError: engineOnError,
513
+ onAsk,
375
514
  // core 1.219 (dogfood: "ReadToolResult(ref) 恒空"): durable tool-result refs on the TOC CLI
376
515
  // lane too — offloaded full text survives a process restart (one file per ref under the data root).
377
516
  toolResultStore: fileBackend.toolResultStore,
@@ -389,7 +528,7 @@ export async function runLocal(argv, deps = {}) {
389
528
  // [1367]① fork server half(main.ts subRunner 同款):ForkRoutingSessionStore——fork/resume 形
390
529
  // (requireExisting)→ host 文件店优先(core 1.350 hostSessionFork 在宿主店 fork,拆店=响亮
391
530
  // resume.session_not_found),普通子任务 → 私有 TTL 店(throwaway 姿势保留)。
392
- const subRunner = new Runner({ brain, models: config.models, roles: config.roles, pricing, promptSource, sessionStore: new ForkRoutingSessionStore(sessionStore, new TtlSessionStore({ defaultTtlDays: 1 / 24 })), ...(executionEnvFactory ? { executionEnvFactory } : {}), ...(lspManager ? { lspManager } : {}) });
531
+ const subRunner = new Runner({ brain, models: config.models, roles: config.roles, pricing, promptSource, sessionStore: new ForkRoutingSessionStore(sessionStore, new TtlSessionStore({ defaultTtlDays: 1 / 24 })), onError: engineOnError, onAsk, ...(executionEnvFactory ? { executionEnvFactory } : {}), ...(lspManager ? { lspManager } : {}) });
393
532
  // Capability layer (loadSkills + buildScenarios + selectScenario) — assembled exactly like main.ts.
394
533
  const skills = loadSkills(config.skillsDir);
395
534
  const repoClient = config.gitApiBaseUrl ? new GiteaClient(config.gitApiBaseUrl, config.gitApiToken) : undefined;
@@ -451,7 +590,9 @@ export async function runLocal(argv, deps = {}) {
451
590
  const taskTimeoutSec = Math.max(0, Math.floor(numEnv("TASK_TIMEOUT_SEC", "0")));
452
591
  const timeoutSec = taskWallClockSec(taskTimeoutSec, false, scenarioName === "team");
453
592
  const mcp = mcpForScenario(config.mcpServers, scenarioName);
454
- const spec = {
593
+ // design/181 件三:自建的 spec 字面量经**同一条**部署治理链(见 {@link applyLocalGovernance})——
594
+ // 审批基线 + autonomy/commandPolicy/MANUAL_MODE_SHELL_GATE/守卫集,tighten-only,折叠属主仍是 core。
595
+ const spec = applyLocalGovernance({
455
596
  objective: mention.cleanedText || args.objective,
456
597
  sessionId,
457
598
  // design/129: run-local IS the pure TOC lane → session-scoped background children
@@ -471,7 +612,7 @@ export async function runLocal(argv, deps = {}) {
471
612
  ...(mcp ? { mcp } : {}),
472
613
  // core 5.8.0:timeoutSec 键退役 → maxWalltimeMs(毫秒);taskWallClockSec 仍产秒,此处换算一次。
473
614
  ...(timeoutSec !== undefined ? { limits: { maxWalltimeMs: timeoutSec * 1000 } } : {}),
474
- };
615
+ }, config, workspaceDir);
475
616
  logger.info("run_local_start", { scenario: scenarioName, model: spec.model, sessionId, exec: config.remoteExec?.provider ?? "in-process" });
476
617
  // ── Run ONE task to completion (the sync /v1/tasks path: plain runTask, no verify/cascade). ──
477
618
  let result;
package/dist/runs.d.ts CHANGED
@@ -80,6 +80,14 @@ export declare function taskHandleStop(registry: typeof defaultTaskRegistry, han
80
80
  content: string;
81
81
  details: unknown;
82
82
  }>;
83
+ /**
84
+ * Attribute this signal's abort as a USER stop for the owner's task-scoped children.
85
+ *
86
+ * 🔴 已经 abort 的信号在这里**立刻**记账,不是静默无操作(#168 件3 的配套)。调用点在 #168 件3 之后
87
+ * 一律挪到了「run 认领成功之后」—— 认领是一次 await,断连完全可能落在它之前。`addEventListener` 对一个
88
+ * 已 fire 的信号永不回调,那样一次真实的人为断连会被吞成「无归因」(子代 settle 时记成 system)。
89
+ * 语义上这两种时序是同一件事:本请求赢下了这条 session,而这条 session 的连接是被人断掉的。
90
+ */
83
91
  export declare function markChildrenStoppedByUserOnAbort(signal: AbortSignal, taskId: string, principal: string | null | undefined): void;
84
92
  /**
85
93
  * A session CAS conflict is swallowed by core into a failed `TaskResult` (it is not thrown from
@@ -176,7 +184,9 @@ export declare class TurnAnchorCapture {
176
184
  * Strip the durable-resume capability token from a result before it is persisted to the replayable event
177
185
  * log / run row or returned to the submitter. The token IS the resume credential (token-as-auth, search
178
186
  * [18] Q1b) and must never leave the service. Safe on any result (no-op when absent) — notably core maps a
179
- * NESTED suspend under verify/cascade to status:"failed" + errorCode "suspended.awaiting_approval"(core 5.9.0 W1 改名)while still
187
+ * NESTED suspend under verify/cascade to status:"failed" + errorCode "unexpected.suspended"(inner status
188
+ * "needs_review" ⇒ "unexpected.needs_review";core `agents/suspend-guard.js` `mapNestedSuspend`,由 verify.js
189
+ * 191/201/208 与 cascade.js:125 调用)while still
180
190
  * spreading `checkpointToken`, so that "failed" path (unlike the stream path's explicit suspend handling)
181
191
  * would otherwise leak it into task_event / task_run.result / the sync 200 response. `checkpointGate` is
182
192
  * non-secret and intentionally kept.
package/dist/runs.js CHANGED
@@ -111,15 +111,28 @@ export async function taskHandleStop(registry, handle, access, agentStore) {
111
111
  registry.markStopSource(handle, "user");
112
112
  return registry.stopTask(handle, access);
113
113
  }
114
+ /**
115
+ * Attribute this signal's abort as a USER stop for the owner's task-scoped children.
116
+ *
117
+ * 🔴 已经 abort 的信号在这里**立刻**记账,不是静默无操作(#168 件3 的配套)。调用点在 #168 件3 之后
118
+ * 一律挪到了「run 认领成功之后」—— 认领是一次 await,断连完全可能落在它之前。`addEventListener` 对一个
119
+ * 已 fire 的信号永不回调,那样一次真实的人为断连会被吞成「无归因」(子代 settle 时记成 system)。
120
+ * 语义上这两种时序是同一件事:本请求赢下了这条 session,而这条 session 的连接是被人断掉的。
121
+ */
114
122
  export function markChildrenStoppedByUserOnAbort(signal, taskId, principal) {
115
- signal.addEventListener("abort", () => {
123
+ const mark = () => {
116
124
  try {
117
125
  defaultTaskRegistry.markStopSourceForOwner({ owner: taskId, scope: principal ?? "default" }, "user", { skipSessionScoped: true });
118
126
  }
119
127
  catch {
120
128
  /* attribution is advisory — the cancel itself must never fail on it */
121
129
  }
122
- }, { once: true });
130
+ };
131
+ if (signal.aborted) {
132
+ mark();
133
+ return;
134
+ }
135
+ signal.addEventListener("abort", mark, { once: true });
123
136
  }
124
137
  /**
125
138
  * A session CAS conflict is swallowed by core into a failed `TaskResult` (it is not thrown from
@@ -249,7 +262,9 @@ export class TurnAnchorCapture {
249
262
  * Strip the durable-resume capability token from a result before it is persisted to the replayable event
250
263
  * log / run row or returned to the submitter. The token IS the resume credential (token-as-auth, search
251
264
  * [18] Q1b) and must never leave the service. Safe on any result (no-op when absent) — notably core maps a
252
- * NESTED suspend under verify/cascade to status:"failed" + errorCode "suspended.awaiting_approval"(core 5.9.0 W1 改名)while still
265
+ * NESTED suspend under verify/cascade to status:"failed" + errorCode "unexpected.suspended"(inner status
266
+ * "needs_review" ⇒ "unexpected.needs_review";core `agents/suspend-guard.js` `mapNestedSuspend`,由 verify.js
267
+ * 191/201/208 与 cascade.js:125 调用)while still
253
268
  * spreading `checkpointToken`, so that "failed" path (unlike the stream path's explicit suspend handling)
254
269
  * would otherwise leak it into task_event / task_run.result / the sync 200 response. `checkpointGate` is
255
270
  * non-secret and intentionally kept.
@@ -24,6 +24,7 @@
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 { type GovernanceAskMarks } from "./governance-ask-marks.js";
27
28
  /** A per-command governance rule (registry `runtime.commandPolicy[]`). */
28
29
  export type CommandRule = {
29
30
  command: string;
@@ -106,10 +107,27 @@ export declare function autonomyOverrides(autonomy: Autonomy | undefined): Parti
106
107
  * ⇒ 旋钮静默失效(clay 实机 Monitor 案「门根本没铸」);bypassPermissions 一句话掀掉部署闸;非 host lane
107
108
  * fsWriteGate wiring 不建 ⇒ 沙箱 lane 全失效。governance 层与 autonomy 同拍施加后三者全闭。rank 合成:
108
109
  * autonomy 派生值与旋钮取大(ask→"always" 压过 "classify"),再对 base 只升不降(防 tightenTaskSpec throw)。
110
+ *
111
+ * #177([2951] issue #29,**邻仓 test AI 的 46 格真机矩阵**实证——不是本仓的钉,别到本仓文件里数 46;
112
+ * 本仓的类级门是 test/operator-knob-client-posture-matrix.test.ts 的 旋钮×表态×lane 矩阵):
113
+ * `SENSITIVE_WRITE_PATTERNS` 是 #153 那次搬家漏下的兄弟旋钮——同一个病灶原样复发在 sensitive-path
114
+ * DENY 腿上(唯一合成点在 deriveSettingsPolicy 的 fsWriteGate 闭包,bypassPermissions/键缺席/settings
115
+ * 整缺席三形整条腿不建)。同方修:调用方(resolve-spec)把 operator 守卫集预铸成 `sensitivePathPolicy`
116
+ * 从这里**无条件施加**——它只在守卫段上产 deny(core 语义 deny/"safety"),对其余目标返回的是
117
+ * `action:"allow"`(**不是**「无意见」这种第三态:core 的 ToolPolicy 没有弃权值。它在
118
+ * `combinePolicies` 的 deny/ask 优先折叠里不构成一票,所以在**折叠语境**下等价于弃权;若日后有人把
119
+ * 这条策略单独当成唯一 policy 用,那个 allow 就是真放行——别照抄这句话去别的语境)。于是对 bypass
120
+ * 只挡守卫段、不给普通目标加 ask 门([820] 表第四行「bypass=不加门非开门」保持逐字)。策略在这里
121
+ * 只组合不构造:裁决 env 的 lane 分形(host 真 fs / 沙箱 deferred 代理)是 resolve-spec 的属主知识,
122
+ * governance 层不重复它。
109
123
  */
110
124
  export declare function applyRuntimeGovernance(base: TaskSpec, governance: {
111
125
  autonomy?: Autonomy;
112
126
  commandPolicy?: CommandRule[];
113
127
  manualModeShellGate?: "always" | "classify";
128
+ sensitivePathPolicy?: ToolPolicy;
129
+ /** [2942]/[2943] `governanceForced` 的标记表(缺省 = **当前 run 腿的 ALS 表**,见
130
+ * `governance-ask-marks.ts` 顶注「作用域」段)。注入口只为测试与将来的多实例形。 */
131
+ askMarks?: GovernanceAskMarks;
114
132
  }): TaskSpec;
115
133
  //# sourceMappingURL=runtime-governance.d.ts.map
@@ -24,6 +24,7 @@
24
24
  * name). Real isolation is the `executionEnv` sandbox. Same caveat core documents on `createCoarseCommandNamePolicy`.
25
25
  */
26
26
  import { combinePolicies, createCoarseCommandNamePolicy, DEFAULT_SUBAGENT_TOOL_NAME, parseLeadingCommandName, tightenTaskSpec, } from "@sema-agent/core";
27
+ import { currentGovernanceAskMarks } from "./governance-ask-marks.js";
27
28
  /** True when `name` names a DELEGATION tool — the subagent tool(CC-187 canonical `"Agent"`,core
28
29
  * `DEFAULT_SUBAGENT_TOOL_NAME`)。5.0.0 RB-476:折叠面退役,RAW 比对 core 单源常量——旧名("Task")
29
30
  * 在 core roster 层响亮 miss,不再需要归一;单源常量本身就防了当年 1.202 改名时裸字符串失配的
@@ -178,7 +179,14 @@ export function compileCommandPolicy(rules) {
178
179
  policies.push(createCoarseCommandNamePolicy({ allow, deny, defaultAction: "ask" }));
179
180
  }
180
181
  else {
181
- // Blocklist mode: only `deny` blocks; per-command `ask` via the ask-list; unlisted pass through.
182
+ // Blocklist mode: only `deny` blocks; per-command `ask` via the ask-list; an unlisted **simple** command
183
+ // passes through.
184
+ // ⚠️ #184([3038]/[3047] 双探针实证):「unlisted pass through」只对**单纯命令**成立。带 shell 运算符的
185
+ // 复合命令(`a && b`、管道、`;`)`parseLeadingCommandName` 判 reject,而 `createCoarseCommandNamePolicy`
186
+ // 的 `defaultAction` **缺省就是 `"ask"`**(core tool-policy.js:`opts.defaultAction ?? "ask"`)—— 于是它们
187
+ // 在 deny 门上折 **ask**,不是放行(无 command 串的调用同理)。这与 allowlist 模式那句「fall to
188
+ // defaultAction」是同一个兜底,只是这里没显式传参所以容易读漏。上方 docblock 的「Un-parseable bypass
189
+ // commands fail toward `ask` in both the deny gate and the ask-list」说的就是这一条。零行为码改动。
182
190
  // (NOTE: never pass `allow: []` to the coarse helper — an empty array is truthy there and would turn it
183
191
  // into an allowlist that asks on EVERYTHING. Omit allow entirely instead.)
184
192
  if (deny.length > 0)
@@ -211,6 +219,56 @@ export function autonomyOverrides(autonomy) {
211
219
  return {};
212
220
  }
213
221
  }
222
+ /**
223
+ * [2942]/[2943] `governanceForced` 的**写侧** —— 把内层策略(治理层自己合成的那些)产的 `ask` 记进标记表。
224
+ *
225
+ * 决议本身**逐字透传**:本包装器只观察,不改 action / message / updatedInput / decisionReason,也不吞异常
226
+ * (内层抛就照抛,fail-closed 的属主仍是内层)。同步内层保持同步返回(不无谓地把一条同步策略异步化)。
227
+ *
228
+ * 为什么标在这一层而不是让 `decisionReason` 上 wire:core 的 ask 铸造点不透传 `PermissionResult`
229
+ * (侦察结论逐字见 `governance-ask-marks.ts` 顶注),这条缝在当前 core 上根本不存在。
230
+ */
231
+ function createGovernanceAskMarkingPolicy(inner, marksOf) {
232
+ // 🔴 codex 交叉复审 round2 [medium](验真):**如实覆写**,不是只加不减 —— core 的
233
+ // `recheckApprovedEdit` 拿同一个 `toolCallId` 重跑策略链(人编辑 args 之后),「原始 args 治理层要问 /
234
+ // 编辑后治理层放行、由别的策略提第二问」会让第二张卡挂陈旧治理标。理由全文见 GovernanceAskMarks.setMarked。
235
+ const note = (d, req) => {
236
+ // 表在**裁决那一刻**解析(不是构造时):策略闭包铸于 resolveSpec,真正的作用域要到 run 腿跑起来才在。
237
+ marksOf()?.setMarked(req.toolCallId, d.action === "ask");
238
+ return d;
239
+ };
240
+ return {
241
+ check(req, signal) {
242
+ const decision = inner.check(req, signal);
243
+ return decision instanceof Promise ? decision.then((d) => note(d, req)) : note(decision, req);
244
+ },
245
+ };
246
+ }
247
+ /**
248
+ * [2942]/[2943] shellGate 腿的写侧 —— **纯观察器**(恒 `allow` = `combinePolicies` 折叠里的弃权,见
249
+ * `applyRuntimeGovernance` 顶注对 sensitivePathPolicy 那段 allow 语义的同款说明)。
250
+ *
251
+ * 只在治理层把 shellGate 抬到 **`"always"`** 时装:该档下 core 对**每一次** shell 调用都要人批
252
+ * (`prepare-task.js` 把 Bash/Monitor 的 irreversibility tier 置 `"always"`),所以「这是一次 shell 调用」
253
+ * 与「这只 ask 是治理层门出来的」等价——推断是紧的,零错标。
254
+ *
255
+ * 🔴 `"classify"` 档**故意不装**:那一档由 core 的分类器逐调用裁决,一次 shell ask 可能来自分类器
256
+ * (治理),也可能来自 `APPROVAL_REQUIRE` 这类别的门(非治理),在 `AskRequest` 上无从分辨。按本信号
257
+ * 「只做正向断言」的语义,分不清就**让键缺席**,不去猜(`governance-ask-marks.ts` 语义 1)。
258
+ */
259
+ function createGovernanceShellGateMarkPolicy(marksOf, tools = COARSE_SHELL_TOOLS) {
260
+ const shellTools = new Set(tools);
261
+ return {
262
+ check(req) {
263
+ // 只对 shell 工具**置位**,非 shell 一格不碰(那格的属主是上面的 ask 观察器)。本策略在合成里
264
+ // 恒排在 ask 观察器**之后**(见 applyRuntimeGovernance 的 combinePolicies 顺序):`always` 档下
265
+ // 每一次 shell 调用都被治理层门住,所以它对 shell 那一格的裁定压过「治理策略这次没产 ask」。
266
+ if (shellTools.has(req.toolName))
267
+ marksOf()?.setMarked(req.toolCallId, true);
268
+ return { action: "allow" };
269
+ },
270
+ };
271
+ }
214
272
  /** core `tighten-task-spec` 的 SHELL_GATE_RANK 镜像(单一属主:#153 搬家后本文件是 server 侧唯一副本;
215
273
  * task-settings 的旧副本随 settings 折叠臂一并拆除)。tightenTaskSpec 的契约是「override 自身必须收紧」
216
274
  * 而非「取两者较严」——低于 base 的 override 直接 throw,所以施加前要按 rank 只升不降。 */
@@ -229,12 +287,34 @@ const SHELL_GATE_RANK = { off: 0, classify: 1, always: 2 };
229
287
  * ⇒ 旋钮静默失效(clay 实机 Monitor 案「门根本没铸」);bypassPermissions 一句话掀掉部署闸;非 host lane
230
288
  * fsWriteGate wiring 不建 ⇒ 沙箱 lane 全失效。governance 层与 autonomy 同拍施加后三者全闭。rank 合成:
231
289
  * autonomy 派生值与旋钮取大(ask→"always" 压过 "classify"),再对 base 只升不降(防 tightenTaskSpec throw)。
290
+ *
291
+ * #177([2951] issue #29,**邻仓 test AI 的 46 格真机矩阵**实证——不是本仓的钉,别到本仓文件里数 46;
292
+ * 本仓的类级门是 test/operator-knob-client-posture-matrix.test.ts 的 旋钮×表态×lane 矩阵):
293
+ * `SENSITIVE_WRITE_PATTERNS` 是 #153 那次搬家漏下的兄弟旋钮——同一个病灶原样复发在 sensitive-path
294
+ * DENY 腿上(唯一合成点在 deriveSettingsPolicy 的 fsWriteGate 闭包,bypassPermissions/键缺席/settings
295
+ * 整缺席三形整条腿不建)。同方修:调用方(resolve-spec)把 operator 守卫集预铸成 `sensitivePathPolicy`
296
+ * 从这里**无条件施加**——它只在守卫段上产 deny(core 语义 deny/"safety"),对其余目标返回的是
297
+ * `action:"allow"`(**不是**「无意见」这种第三态:core 的 ToolPolicy 没有弃权值。它在
298
+ * `combinePolicies` 的 deny/ask 优先折叠里不构成一票,所以在**折叠语境**下等价于弃权;若日后有人把
299
+ * 这条策略单独当成唯一 policy 用,那个 allow 就是真放行——别照抄这句话去别的语境)。于是对 bypass
300
+ * 只挡守卫段、不给普通目标加 ask 门([820] 表第四行「bypass=不加门非开门」保持逐字)。策略在这里
301
+ * 只组合不构造:裁决 env 的 lane 分形(host 真 fs / 沙箱 deferred 代理)是 resolve-spec 的属主知识,
302
+ * governance 层不重复它。
232
303
  */
233
304
  export function applyRuntimeGovernance(base, governance) {
305
+ // 显式注入(测试/将来的多实例形)优先;否则**每次裁决时**取当前 run 腿的 ALS 表(顶注「作用域」段)。
306
+ const marksOf = () => governance.askMarks ?? currentGovernanceAskMarks();
234
307
  const overrides = { ...autonomyOverrides(governance.autonomy) };
235
308
  const coarse = compileCommandPolicy(governance.commandPolicy);
236
- if (coarse)
237
- overrides.toolPolicy = coarse;
309
+ const governancePolicies = [coarse, governance.sensitivePathPolicy].filter((p) => p !== undefined);
310
+ // [2942]/[2943]:治理层自己产的 `ask` 打标 —— 包在**合成之后**,于是 commandPolicy 的两模式
311
+ // (allowlist 的 defaultAction:"ask" / blocklist 的 ask-list)与 sensitivePathPolicy 走同一个铸点,
312
+ // 不会漏掉将来加进这个数组的第四条腿。
313
+ const governancePolicy = governancePolicies.length > 0
314
+ ? createGovernanceAskMarkingPolicy(governancePolicies.length === 1 ? governancePolicies[0] : combinePolicies(...governancePolicies), marksOf)
315
+ : undefined;
316
+ if (governancePolicy !== undefined)
317
+ overrides.toolPolicy = governancePolicy;
238
318
  if (governance.manualModeShellGate !== undefined) {
239
319
  const candidate = overrides.shellGate !== undefined && SHELL_GATE_RANK[overrides.shellGate] >= SHELL_GATE_RANK[governance.manualModeShellGate]
240
320
  ? overrides.shellGate
@@ -249,6 +329,13 @@ export function applyRuntimeGovernance(base, governance) {
249
329
  else
250
330
  delete overrides.shellGate;
251
331
  }
332
+ // [2942]/[2943] shellGate 腿:治理层**自己**把门抬到 `"always"` 时,挂上纯观察器给 shell 调用打标
333
+ // (理由与 `"classify"` 为何不装,见 createGovernanceShellGateMarkPolicy 顶注)。判据取
334
+ // `overrides.shellGate` 而不是有效档 —— base 已经更严时上面那支会 `delete`,那时门不是治理层给的。
335
+ if (overrides.shellGate === "always") {
336
+ const shellObserver = createGovernanceShellGateMarkPolicy(marksOf);
337
+ overrides.toolPolicy = governancePolicy === undefined ? shellObserver : combinePolicies(governancePolicy, shellObserver);
338
+ }
252
339
  if (overrides.toolPolicy === undefined && overrides.handsReadOnly === undefined && overrides.shellGate === undefined) {
253
340
  return base; // nothing to govern → avoid a needless tightenTaskSpec pass
254
341
  }
@@ -208,6 +208,18 @@ export declare function principalFrom(req: IncomingMessage, config: ServiceConfi
208
208
  /** Header carrying the crypto-verified principal JWT on a direct-door worker (the issuer signs it; the worker only
209
209
  * verifies). The trusted twin of `config.principalHeader` (the latter is client-spoofable on a direct door). */
210
210
  export declare const PRINCIPAL_TOKEN_HEADER = "x-approval-principal-token";
211
+ /**
212
+ * direct-door 决策证明三件套的另外两个头名(JWT 在上面那个常量里)。
213
+ *
214
+ * 为什么也具名(2026-08-08,#176 二轮扫描 finding):这三个头名此前在
215
+ * `http/routes/approvals-assistant.ts` 的两个 `verifyDirectDoorProof` 调用点、以及 `http/server.ts` 的
216
+ * CORS `access-control-allow-headers` 串里**各手抄一份**——而 CORS 那一行同一句里已经在用
217
+ * `${principalHeader}` 插值另一个头名,证明具名写法在该处本就可用。改任一头名时漏改 CORS 串的后果
218
+ * 是**静默的**:浏览器预检把该头挡下,请求到不了 handler ⇒ 表现成「direct-door 上审批面莫名失效」,
219
+ * 而不是任何一处报错。低危(改头名本身即 wire BREAKING、不太可能单边发生),但零成本可消。
220
+ */
221
+ export declare const APPROVAL_MAC_HEADER = "x-approval-mac";
222
+ export declare const APPROVAL_MAC_KID_HEADER = "x-approval-mac-kid";
211
223
  export declare function setSsoPrincipal(req: IncomingMessage, principal: string): void;
212
224
  export declare function ssoVerifiedPrincipal(req: IncomingMessage): string | undefined;
213
225
  export declare function setSsoScope(req: IncomingMessage, scope: string): void;
package/dist/security.js CHANGED
@@ -279,6 +279,18 @@ export function principalFrom(req, config) {
279
279
  /** Header carrying the crypto-verified principal JWT on a direct-door worker (the issuer signs it; the worker only
280
280
  * verifies). The trusted twin of `config.principalHeader` (the latter is client-spoofable on a direct door). */
281
281
  export const PRINCIPAL_TOKEN_HEADER = "x-approval-principal-token";
282
+ /**
283
+ * direct-door 决策证明三件套的另外两个头名(JWT 在上面那个常量里)。
284
+ *
285
+ * 为什么也具名(2026-08-08,#176 二轮扫描 finding):这三个头名此前在
286
+ * `http/routes/approvals-assistant.ts` 的两个 `verifyDirectDoorProof` 调用点、以及 `http/server.ts` 的
287
+ * CORS `access-control-allow-headers` 串里**各手抄一份**——而 CORS 那一行同一句里已经在用
288
+ * `${principalHeader}` 插值另一个头名,证明具名写法在该处本就可用。改任一头名时漏改 CORS 串的后果
289
+ * 是**静默的**:浏览器预检把该头挡下,请求到不了 handler ⇒ 表现成「direct-door 上审批面莫名失效」,
290
+ * 而不是任何一处报错。低危(改头名本身即 wire BREAKING、不太可能单边发生),但零成本可消。
291
+ */
292
+ export const APPROVAL_MAC_HEADER = "x-approval-mac";
293
+ export const APPROVAL_MAC_KID_HEADER = "x-approval-mac-kid";
282
294
  /** The DIRECT-DOOR-SAFE principal — the VERIFIED identity for governance + cost + isolation. On a BFF/gated deployment
283
295
  * the header is already verified upstream (return it); on a direct-door worker the `principalHeader` is client-
284
296
  * spoofable, so identity comes from the crypto-verified `x-approval-principal-token` JWT (the issuer's private key
@@ -66,6 +66,19 @@ export declare function stagingIdFor(realSessionId: string, token: string): stri
66
66
  export declare const STAGING_ID_MARKER = "#stg-";
67
67
  /** Recover the real session id from a staging id (everything before the `#stg-` marker), or null if not a staging id. */
68
68
  export declare function realIdOfStaging(stagingId: string): string | null;
69
+ /**
70
+ * Staging-row reaper grace window — a staging session's rows are GC'able only if it has NO `session_meta` (never
71
+ * committed) AND its OLDEST row is older than this (an in-flight stream keeps appending fresh rows → safe).
72
+ * Mirrors the file-snapshot store's `BLOB_GC_GRACE_MS` discipline (an in-flight import must never be reaped
73
+ * mid-stream).
74
+ *
75
+ * 🔴 住在 kernel 里而不是任一个店里(2026-08-08,#176 二轮扫描 finding):TiDB 与 PG 两个孪生店原先**各
76
+ * 抄一份**同值常量,PG 那份的注释甚至自称「twin of the TiDB store's STAGING_GC_GRACE_MS」——明知孪生却
77
+ * 不共享。单边改值的后果是两个后端对「同一个中断的导入还能不能收」判定分家,而两边都按自己的常量静默
78
+ * 正确工作。这里是两个店**本就都 import** 的那个叶子模块(店契约纪律:适配器不许 import 自己的孪生,
79
+ * 共享常量必须落在中立处),同族先例见 `checkpoint-store-sql.ts` 的 `export const TERMINAL_GRACE_MS`。
80
+ */
81
+ export declare const STAGING_GC_GRACE_MS = 3600000;
69
82
  /**
70
83
  * §7 — the relationship between a SOURCE bundle's entry log and the DESTINATION's CURRENT entry log, the gate that
71
84
  * decides whether an import is a safe sync or a data-losing overwrite. Five exhaustive cases:
@@ -10,6 +10,19 @@ export function realIdOfStaging(stagingId) {
10
10
  const i = stagingId.indexOf(STAGING_ID_MARKER);
11
11
  return i < 0 ? null : stagingId.slice(0, i);
12
12
  }
13
+ /**
14
+ * Staging-row reaper grace window — a staging session's rows are GC'able only if it has NO `session_meta` (never
15
+ * committed) AND its OLDEST row is older than this (an in-flight stream keeps appending fresh rows → safe).
16
+ * Mirrors the file-snapshot store's `BLOB_GC_GRACE_MS` discipline (an in-flight import must never be reaped
17
+ * mid-stream).
18
+ *
19
+ * 🔴 住在 kernel 里而不是任一个店里(2026-08-08,#176 二轮扫描 finding):TiDB 与 PG 两个孪生店原先**各
20
+ * 抄一份**同值常量,PG 那份的注释甚至自称「twin of the TiDB store's STAGING_GC_GRACE_MS」——明知孪生却
21
+ * 不共享。单边改值的后果是两个后端对「同一个中断的导入还能不能收」判定分家,而两边都按自己的常量静默
22
+ * 正确工作。这里是两个店**本就都 import** 的那个叶子模块(店契约纪律:适配器不许 import 自己的孪生,
23
+ * 共享常量必须落在中立处),同族先例见 `checkpoint-store-sql.ts` 的 `export const TERMINAL_GRACE_MS`。
24
+ */
25
+ export const STAGING_GC_GRACE_MS = 3_600_000;
13
26
  /**
14
27
  * §7 — classify how importing `srcEntries` into a destination holding `dstEntries` would relate the two logs. PURE
15
28
  * (no I/O); the §7 fork detector. The comparison is over the ENTRY-ID SETS, not leaf_id: