@sema-agent/server 7.3.0 → 7.5.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 (78) hide show
  1. package/USAGE.md +63 -0
  2. package/dist/approval-card.d.ts +15 -3
  3. package/dist/approval-card.js +41 -7
  4. package/dist/approval-reconciler.d.ts +120 -16
  5. package/dist/approval-reconciler.js +146 -19
  6. package/dist/boot/coordinators.js +13 -3
  7. package/dist/boot/deferred-sandbox-path-env.d.ts +99 -0
  8. package/dist/boot/deferred-sandbox-path-env.js +279 -0
  9. package/dist/boot/execution-env.js +11 -1
  10. package/dist/boot/org-memory.d.ts +6 -0
  11. package/dist/boot/org-memory.js +1 -1
  12. package/dist/boot/reapers.d.ts +2 -0
  13. package/dist/boot/reapers.js +11 -4
  14. package/dist/boot/resolve-spec.d.ts +3 -2
  15. package/dist/boot/resolve-spec.js +175 -63
  16. package/dist/boot/runner-deps.d.ts +23 -1
  17. package/dist/boot/runner-deps.js +8 -11
  18. package/dist/boot/workflow-orchestration.d.ts +8 -3
  19. package/dist/boot/workflow-orchestration.js +23 -1
  20. package/dist/capabilities/center-prompts.js +4 -1
  21. package/dist/config-center/apply-effective.js +33 -10
  22. package/dist/config-types.d.ts +32 -9
  23. package/dist/config.d.ts +6 -1
  24. package/dist/config.js +65 -12
  25. package/dist/elicitation.js +5 -1
  26. package/dist/env-facts.d.ts +3 -1
  27. package/dist/env-facts.js +3 -1
  28. package/dist/fleet/fleet-bus.d.ts +6 -1
  29. package/dist/fleet/fleet-bus.js +25 -3
  30. package/dist/governance-ask-marks.d.ts +31 -0
  31. package/dist/governance-ask-marks.js +122 -0
  32. package/dist/hooks/hook-runner.d.ts +28 -0
  33. package/dist/hooks/hook-runner.js +180 -24
  34. package/dist/http/routes/diagnostics.d.ts +84 -0
  35. package/dist/http/routes/diagnostics.js +145 -0
  36. package/dist/http/routes/memory-policy.d.ts +2 -1
  37. package/dist/http/routes/memory-policy.js +77 -13
  38. package/dist/http/routes/runs.js +1 -1
  39. package/dist/http/routes/tasks.js +87 -29
  40. package/dist/http/server.d.ts +10 -0
  41. package/dist/http/server.js +29 -12
  42. package/dist/http/wire-types.d.ts +7 -2
  43. package/dist/main.js +51 -8
  44. package/dist/observability/fail-open.d.ts +109 -0
  45. package/dist/observability/fail-open.js +227 -0
  46. package/dist/observability/prompt-manifest.d.ts +17 -0
  47. package/dist/observability/prompt-manifest.js +8 -0
  48. package/dist/orchestration/workflow-notify-journal.d.ts +57 -1
  49. package/dist/orchestration/workflow-notify-journal.js +137 -32
  50. package/dist/parked-decide.js +9 -4
  51. package/dist/plugins/approval-ask-store-memory.d.ts +2 -2
  52. package/dist/plugins/approval-ask-store-memory.js +3 -2
  53. package/dist/plugins/approval-ask-store-sql.d.ts +27 -5
  54. package/dist/plugins/approval-ask-store-sql.js +9 -2
  55. package/dist/plugins/background-shell-support.d.ts +1 -1
  56. package/dist/plugins/background-shell-support.js +2 -2
  57. package/dist/plugins/checkpoint-store-sql.d.ts +62 -6
  58. package/dist/plugins/checkpoint-store-sql.js +71 -11
  59. package/dist/plugins/local-checkpoint-store.d.ts +20 -1
  60. package/dist/plugins/local-checkpoint-store.js +19 -0
  61. package/dist/plugins/mailbox-store-sql.d.ts +4 -10
  62. package/dist/plugins/mailbox-store-sql.js +57 -4
  63. package/dist/question.d.ts +18 -14
  64. package/dist/question.js +83 -34
  65. package/dist/runs.d.ts +8 -0
  66. package/dist/runs.js +15 -2
  67. package/dist/runtime-governance.d.ts +18 -0
  68. package/dist/runtime-governance.js +90 -3
  69. package/dist/task-settings.d.ts +16 -21
  70. package/dist/task-settings.js +22 -19
  71. package/dist/tool-approval.d.ts +33 -6
  72. package/dist/tool-approval.js +95 -30
  73. package/dist/trace/core-keyset-guard.d.ts +17 -3
  74. package/dist/trace/project.d.ts +36 -1
  75. package/dist/trace/project.js +55 -2
  76. package/package.json +3 -3
  77. package/dist/boot/lexical-path-env.d.ts +0 -14
  78. package/dist/boot/lexical-path-env.js +0 -116
package/dist/question.js CHANGED
@@ -15,29 +15,39 @@
15
15
  * (the HTTP boundary) and REDACTS secrets from the model-authored QUESTION text before it is persisted/streamed (the
16
16
  * same redact-at-write contract as elicitation's `message`/tool outputs — the question is shown to a human, never
17
17
  * re-fed to a model, so no `delimitUntrusted` is needed on it). Deployment responsibilities core delegates: THROTTLE
18
- * (bound concurrency/total/ttl per run — an over-eager agent could flood the human) and no-live-human FALLBACK (a leg
19
- * with no stream returns the headless "no answer" default so the model proceeds instead of hanging).
18
+ * (bound concurrency/total/ttl per run — an over-eager agent could flood the human).
19
+ *
20
+ * 🔴 VERDICT vs LANDING are separate jobs (#166 裁1). This coordinator is the deployment's HONEST reporter of one fact:
21
+ * "no human answered this". Every arm with no real human answer — no run context, the run aborted, the throttle window
22
+ * is spent, the question cannot be presented, the TTL expired (incl. the run-exit sweep) — returns core's
23
+ * `{kind:"unavailable"}`. It NEVER synthesizes an empty answer set: an empty `{answers:[]}` reads to core as "a human
24
+ * was here and picked nothing", which on a durable deployment consumes the pending approval and hands the model a
25
+ * fabricated human verdict instead of parking the question where an operator can still answer it. Choosing the landing
26
+ * is CORE's job: a durable leg parks a checkpoint (operator answers later via decide), a non-durable leg continues with
27
+ * the `declined_unavailable` synthetic-continuation card (the model is told nobody is reachable and proceeds on its own
28
+ * judgment — a run with no human still never hangs). Only a REAL human answer returns a {@link QuestionAnswer}.
20
29
  *
21
30
  * ROUTING mirrors elicitation: `onQuestion` lives on the SINGLETON runner's `RunnerDeps` (the request carries no taskId),
22
31
  * so the run leg establishes a per-run AsyncLocalStorage context (taskId throttle key, owner respond-gate, `emit`
23
- * closure). `node:async_hooks` propagates it through every await; no context ⇒ headless default (never a hang).
32
+ * closure). `node:async_hooks` propagates it through every await; no context ⇒ unavailable (never a hang).
24
33
  */
25
34
  import { AsyncLocalStorage } from "node:async_hooks";
26
35
  import { uuidv7 } from "@sema-agent/core";
27
36
  import { redactDeep } from "./trace/redact.js";
37
+ import { recordFailOpen } from "./observability/fail-open.js";
28
38
  /** Size bound on the model-authored questions payload before it is persisted to the durable log + streamed (parity
29
39
  * with elicitation's MAX_ELICIT_* — an over-large questions blob amplifies into the socket/log). Over the cap ⇒ the
30
- * questions are dropped and the ask headless-defaults (a question the human can't see can't be answered). */
40
+ * questions are dropped and the ask reports unavailable (a question the human can't see can't be answered). */
31
41
  const MAX_QUESTIONS_BYTES = 16384;
32
42
  export const DEFAULT_QUESTION_THROTTLE = {
33
43
  maxConcurrentPerRun: 2,
34
44
  maxTotalPerRun: 20,
35
45
  ttlMs: 5 * 60_000,
36
46
  };
37
- /** The headless "no live human answered" result an EMPTY answer set (no question got a selection). core's
38
- * AskUserQuestion tool renders this to the model as "no option selected" so it proceeds with its own judgment instead
39
- * of hanging (parity with the `onQuestion===undefined` headless default a run with no human never blocks). */
40
- const NO_HUMAN = { answers: [] };
47
+ /** The honest "nobody answered this" outcome. Frozen + shared: it is pure data core reads through
48
+ * `classifyQuestionOutcome`, and it must never be confused with an empty ANSWER (which claims a human chose nothing
49
+ * and, on a durable leg, spends the approval that park would have preserved). */
50
+ const UNAVAILABLE = Object.freeze({ kind: "unavailable" });
41
51
  /** Validate the respond body = core's {@link QuestionAnswer}. `selected`/`note` are OPAQUE passthrough (core fences
42
52
  * them: `selected ⊆ options`, `note` untrusted-fenced) — we validate only the outer SHAPE (answers[] of {header,
43
53
  * selected:string[], note?}), never the option semantics. */
@@ -65,7 +75,7 @@ export function parseQuestionResponse(body) {
65
75
  }
66
76
  /** redactDeep + size-bound the UNTRUSTED model-authored questions before they are persisted/streamed (a question's
67
77
  * free-text — question/header/option label+description — can echo a secret from the model's context). Over the byte
68
- * cap OR unserializable ⇒ undefined (drop; the ask headless-defaults). */
78
+ * cap OR unserializable ⇒ undefined (drop; the ask reports unavailable). */
69
79
  function boundQuestions(questions) {
70
80
  const redacted = redactDeep(questions);
71
81
  try {
@@ -81,7 +91,7 @@ function boundQuestions(questions) {
81
91
  * Coordinates AskUserQuestion HITL for the singleton runner. Process-local + same-replica (the pending map is in memory,
82
92
  * like ElicitationCoordinator / steerableRuns): a respond that lands on another replica finds nothing → 404. Present
83
93
  * (passed into `RunnerDeps.onQuestion` + the respond route) ONLY when `ASK_QUESTION_ENABLED` — absent ⇒ core mounts the
84
- * AskUserQuestion tool with the headless default (a run never hangs; the model just can't get a live answer).
94
+ * AskUserQuestion tool with no seam at all (`seam_absent` continuation; the model just can't get a live answer).
85
95
  */
86
96
  export class QuestionCoordinator {
87
97
  als = new AsyncLocalStorage();
@@ -92,7 +102,7 @@ export class QuestionCoordinator {
92
102
  this.throttle = throttle;
93
103
  }
94
104
  /** Run `fn` with the per-run question context ambient. On exit, release any still-pending question for this run (a
95
- * live-only question cannot outlive its leg → headless default) and drop the run's counters (no leak). */
105
+ * live-only question cannot outlive its leg → unavailable) and drop the run's counters (no leak). */
96
106
  runWithContext(ctx, fn) {
97
107
  return this.als.run(ctx, async () => {
98
108
  try {
@@ -101,31 +111,34 @@ export class QuestionCoordinator {
101
111
  finally {
102
112
  for (const [, p] of [...this.pending]) {
103
113
  if (p.taskId === ctx.taskId)
104
- p.settle(NO_HUMAN, "unanswered");
114
+ p.settle(UNAVAILABLE, "unanswered");
105
115
  }
106
116
  this.counters.delete(ctx.taskId);
107
117
  }
108
118
  });
109
119
  }
110
- /** `RunnerDeps.onQuestion`. core calls this when the agent's AskUserQuestion tool fires; the resolved answer is what
111
- * the model sees. Headless default (`NO_HUMAN`) in every case with no live human, so the run never hangs. Arrow
112
- * property so it can be passed as `onQuestion: coordinator.question` with `this` bound. */
120
+ /** `RunnerDeps.onQuestion`. core calls this when the agent's AskUserQuestion tool fires; the resolved outcome is what
121
+ * core acts on a {@link QuestionAnswer} ONLY when a human really answered, otherwise `{kind:"unavailable"}` (core
122
+ * then parks on a durable leg / continues with the `declined_unavailable` card on a non-durable one; either way the
123
+ * run never hangs). Arrow property so it can be passed as `onQuestion: coordinator.question` with `this` bound. */
113
124
  question = async (req, signal) => {
114
125
  const ctx = this.als.getStore();
115
126
  // No run context (a leg not wrapped by runWithContext — synchronous /v1/tasks, verify/cascade, or a leader sub-task
116
- // with no live stream) ⇒ headless default. Sub-tasks run in the parent's ALS frame → a child's question inherits the
127
+ // with no live stream) ⇒ nobody to ask. Sub-tasks run in the parent's ALS frame → a child's question inherits the
117
128
  // top-level run's ctx (one human, one stream, one throttle budget), same as elicitation.
118
129
  if (!ctx)
119
- return NO_HUMAN;
130
+ return UNAVAILABLE;
131
+ // Aborted (call signal or the run's own): whatever comes back has no consumer, so claiming a human answered is a lie.
120
132
  if (signal?.aborted || ctx.abortSignal?.aborted)
121
- return NO_HUMAN;
122
- // THROTTLE (service's job — core imposes none): bound concurrency + total. Breach ⇒ headless default.
133
+ return UNAVAILABLE;
134
+ // THROTTLE (service's job — core imposes none): bound concurrency + total. Breach ⇒ unavailable, never a denial and
135
+ // never a fabricated answer — a spent window is a "not now", which is exactly what park/continue is for.
123
136
  const rc = this.countersFor(ctx.taskId);
124
137
  if (rc.inflight >= this.throttle.maxConcurrentPerRun || rc.total >= this.throttle.maxTotalPerRun)
125
- return NO_HUMAN;
138
+ return UNAVAILABLE;
126
139
  const questions = boundQuestions(req.questions);
127
140
  if (!questions)
128
- return NO_HUMAN; // over-cap / unserializable → can't show it → headless
141
+ return UNAVAILABLE; // over-cap / unserializable → can't show it → nobody can answer it
129
142
  const id = uuidv7();
130
143
  rc.inflight += 1; // reserve the concurrency slot; total charged only on a successful emit
131
144
  let done = false;
@@ -147,8 +160,10 @@ export class QuestionCoordinator {
147
160
  resolveAnswer(a);
148
161
  };
149
162
  let lastOutcome = "unanswered";
150
- const onAbort = () => settle(NO_HUMAN, "unanswered");
151
- timer = setTimeout(() => settle(NO_HUMAN, "unanswered"), this.throttle.ttlMs);
163
+ const onAbort = () => settle(UNAVAILABLE, "unanswered");
164
+ // TTL: the human walked away. Release as unavailable — core decides whether that parks (durable, operator can still
165
+ // answer) or continues (non-durable); holding core past the window is not one of the choices.
166
+ timer = setTimeout(() => settle(UNAVAILABLE, "unanswered"), this.throttle.ttlMs);
152
167
  timer.unref?.();
153
168
  signal?.addEventListener("abort", onAbort, { once: true });
154
169
  ctx.abortSignal?.addEventListener("abort", onAbort, { once: true });
@@ -156,20 +171,53 @@ export class QuestionCoordinator {
156
171
  this.pending.set(id, { settle, owner: ctx.owner, taskId: ctx.taskId });
157
172
  // Emit the OPEN frame AWAITED so its durable append is ORDERED ahead of the answer/completion/terminal appends (a
158
173
  // fire-and-forget append can commit out of seq order and be skipped by the monotonic events-tail cursor). A FAILED
159
- // emit headless-defaults (nobody can answer) and must NOT burn the per-run total.
160
- try {
161
- await ctx.emit({ type: "question", questionId: id, questions, serverNowMs: Date.now() });
174
+ // emit settles unavailable (nobody can answer a frame that never arrived) and must NOT burn the per-run total.
175
+ // 🔴 RACED against the settlement (codex 复审 R2-high): a STALLED delivery (a durable append that never settles, a
176
+ // half-open socket) is a third shape next to ok/threw, and a bare `await ctx.emit` on it outlives TTL *and* abort
177
+ // settle would resolve `answerP` while this frame is still parked on the emit, so `question()` never returns, core's
178
+ // callTool never unblocks and the leg cannot even reach its own cleanup. "The window always releases" has to hold
179
+ // for the stalled shape too, so the await ends at whichever comes first. Ordering is unchanged on the normal path
180
+ // (the settlement is still pending there, so the emit is still fully awaited).
181
+ // The loser of the race keeps its own handlers — an emit that rejects AFTER the race must not surface as an
182
+ // unhandled rejection.
183
+ const emitted = (async () => ctx.emit({ type: "question", questionId: id, questions, serverNowMs: Date.now() }))()
184
+ .then(() => "ok", () => "failed");
185
+ const delivery = await Promise.race([emitted, answerP.then(() => "stalled")]);
186
+ if (delivery === "ok") {
162
187
  rc.total += 1;
163
188
  }
164
- catch {
165
- settle(NO_HUMAN, "unanswered");
189
+ else if (delivery === "failed") {
190
+ // #157(codex 复审 MEDIUM,2026-08-07):投递失败 ⇒ 人在环这道门被**一次故障**跳过,模型自己往下判。
191
+ // 「绝不把 run 挂死」是刻意的产品姿态,所以方向不改;但它是 P-DEBT 不是合法兜底——债要在遥测里显形。
192
+ // #166 后本臂结算成 unavailable(而非空答):留痕语义不变(一次投递故障仍可能让人在环被跳过——非
193
+ // durable 形 core 合成续跑),durable 形则改由 core park,债的方向与本记账口径一致。
194
+ recordFailOpen("server.question.open-frame-undelivered", `question=${id}`);
195
+ settle(UNAVAILABLE, "unanswered");
166
196
  }
197
+ // delivery==="stalled":窗已由 TTL/abort/清扫结算,这一问就此让位;账在下面那个迟到臂里补(投递还没
198
+ // 判出成败,现在既不能计费也不能记债)。
167
199
  const answer = await answerP;
168
200
  // Completion breadcrumb (dialog dismiss) — FIRE-AND-FORGET so a slow durable append can never delay returning the
169
- // answer to core. Best-effort + ordering-uncritical (the shell already has the answer via respond).
170
- void Promise.resolve()
171
- .then(() => ctx.emit({ type: "question_complete", questionId: id, outcome: lastOutcome, serverNowMs: Date.now() }))
172
- .catch(() => undefined);
201
+ // answer to core. Best-effort (the shell already has the answer via respond), but CHAINED BEHIND the open frame and
202
+ // BRANCHED on how that frame ended (codex 复审 R3):
203
+ // · 送达(含**迟到**送达) 人真看见了这张卡:补烧总量额度(否则「反复悬挂再落地」是一条绕过刷屏帽的
204
+ // 路子),再发消卡帧把它消解掉;
205
+ // · 投递失败(含**迟到**失败)⇒ 没有卡可消解,发消卡帧只会在流里留一条无主帧;此时补记那条 P-DEBT
206
+ // 留痕(同步臂已经记过的不会重复:那条腿的 delivery 不是 stalled)。
207
+ void emitted
208
+ .then((result) => {
209
+ if (result === "failed") {
210
+ if (delivery === "stalled")
211
+ recordFailOpen("server.question.open-frame-undelivered", `question=${id}`);
212
+ return undefined;
213
+ }
214
+ if (delivery === "stalled")
215
+ rc.total += 1;
216
+ return ctx.emit({ type: "question_complete", questionId: id, outcome: lastOutcome, serverNowMs: Date.now() });
217
+ })
218
+ // #157:F 类兜底(丢的是收尾渲染,不是答案)——合法,但必须留痕,否则"面包屑成片消失"只能靠
219
+ // 用户报"对话框不消解"发现。
220
+ .catch(() => recordFailOpen("server.question.complete-breadcrumb-dropped", `question=${id}`));
173
221
  return answer;
174
222
  };
175
223
  /** `POST /v1/questions/:id/respond` — resolve a parked question with the shell's answer. Owner-gated with a 404 (no
@@ -197,9 +245,10 @@ export class QuestionCoordinator {
197
245
  * resolve-spec 的 durable question policy 用它在**判决时**分腿:有活流 ⇒ allow(问活人),无 ⇒
198
246
  * ask(durable park)。ALS 让这个判断天然 per-leg,policy 组装期不必预知腿别。
199
247
  * 🔴 判据是「投递面此刻可达」而不只是「ALS 在场」(复审 A1):detach 腿断连后 run 仍在本作用域里跑,
200
- * 只判 ALS 会把一条谁也收不到的问题判成 allow(挂满 ttl 后以空答喂模型),而 park 才是它该走的腿。
248
+ * 只判 ALS 会把一条谁也收不到的问题判成 allow——挂满 ttl 后以 unavailable 结算,非 durable 形 core
249
+ * 就此合成续跑,活人再没机会答;而 park 才是它该走的腿。
201
250
  * 谓词缺席 ⇒ 恒可达(后台腿的 durable events tail 语义)。谓词本身抛错按不可达处理:判决面
202
- * fail-closed 到 park(park 可由运维补答,空答不可回收)。 */
251
+ * fail-closed 到 park(park 可由运维补答,续跑掉的问题不可回收)。 */
203
252
  hasLiveContext() {
204
253
  const ctx = this.als.getStore();
205
254
  if (ctx === undefined)
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
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
@@ -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
  }
@@ -149,7 +149,7 @@ export declare function providerDropsAppend(provider: PromptProvider | undefined
149
149
  export declare function acceptAppendSystemPrompt(v: unknown, warn?: (detail: string) => void, packDropsAppend?: boolean): string | undefined;
150
150
  /** [1248]②/codex F2 — what the workflow ask leg needs from the deployment, on EVERY lane. It is name-keyed and
151
151
  * fs-independent, so it never had the lane split {@link FsWriteGateWiring} carried (that split is itself gone
152
- * since #156 — the sandbox lanes now wire a lexical-path gate instead of no gate). */
152
+ * since #156 — the sandbox lanes wire a gate of their own shape instead of no gate). */
153
153
  export interface WorkflowGateWiring {
154
154
  /** The session "don't ask again" probe (approvalExemptionStore.has, canonical toolName key) — same store and
155
155
  * key space as the fs-write gate's probe; one remember="session" grant serves both. */
@@ -162,22 +162,23 @@ export interface WorkflowGateWiring {
162
162
  * (real `exists`/`canonicalPath`/`readLink` fs access — read in dist, fs-write-gate-policy.js), so on a
163
163
  * HOST-SEMANTICS lane (`REMOTE_EXEC` unset or "host") the env MUST be the fs the hand tools write.
164
164
  *
165
- * #156 — the sandbox lanes (e2b/k8s/ssh/adb/local-docker) now wire a gate TOO, but a different-shaped one: the
165
+ * #156/#165 — the sandbox lanes (e2b/k8s/ssh/adb/local-docker) wire a gate TOO, but a different-shaped one: the
166
166
  * tools run OFF this box and the per-task sandbox env is minted inside core (executionEnvFactory) AFTER spec
167
167
  * build, so a worker-local env would adjudicate against the WRONG fs (a wrong ALLOW is worse than no gate — that
168
- * is why [816] left those lanes gate-less). The transitional shape instead supplies a `LexicalPathExecutionEnv`
169
- * (`src/boot/lexical-path-env.ts`): NO fs is consulted at all, absolute paths are judged by lexical
170
- * normalization and everything else fails closed to `ask`. Its residual surface (symlink form) and the endgame
171
- * seam (core [2751] read-only env face on HookToolContext) are documented on that module. */
168
+ * is why [816] left those lanes gate-less). They supply a `DeferredSandboxPathEnv`
169
+ * (`src/boot/deferred-sandbox-path-env.ts`) instead: a proxy that forwards the fs READ primitives to the task's
170
+ * REAL sandbox env at adjudication time (a per-session slot the factory decorator fills), keeping only
171
+ * `absolutePath` lexical so no relative path is resolved against the wrong base. Empty slot ⇒ fail-closed. */
172
172
  export interface FsWriteGateWiring {
173
- /** The env the gate canonicalizes against — the worker host env on the host lane, the fs-less
174
- * `LexicalPathExecutionEnv` on the sandbox lanes (#156). */
173
+ /** The env the gate canonicalizes against — the worker host env on the host lane, the
174
+ * `DeferredSandboxPathEnv` proxy over the task's real sandbox env on the sandbox lanes (#165). */
175
175
  env: ExecutionEnv;
176
176
  /** The task's working directory: the factory's `rootPath` (relative-path base) AND the acceptEdits accept domain.
177
- * ABSENT on the sandbox lanes (#156): the sandbox cwd is not knowable at spec time and inventing one would mint
178
- * a bogus auto-allow domain. With it absent the gate gets no `rootPath` and no `acceptDirs`, so relative paths
179
- * fail to resolve (⇒ ask) and `acceptEdits` degrades to the `default` arm the same fail-safe direction as the
180
- * host lane's never-created sentinel dir (resolve-spec 修5). */
177
+ * ABSENT on the sandbox lanes (#156/#165): the sandbox cwd is the ENGINE's tracked cwd, not the env's own, so
178
+ * neither spec time nor the env proxy can supply the right base inventing one would mint a bogus auto-allow
179
+ * domain. With it absent the gate gets no `rootPath` and no `acceptDirs`, so relative paths fail to resolve
180
+ * (⇒ ask) and `acceptEdits` degrades to the `default` arm — the same fail-safe direction as the host lane's
181
+ * never-created sentinel dir (resolve-spec 修5). */
181
182
  cwd?: string;
182
183
  /** The session scratchpad dir (envFacts.scratchpadDir, [820]③) — writes there are always auto-allowed. */
183
184
  scratchpadDir?: string;
@@ -191,16 +192,10 @@ export interface FsWriteGateWiring {
191
192
  * allow with decisionReason "rule", a THROWN probe ⇒ not exempt = fail-closed to ask). Same canonical
192
193
  * toolName key space as the ask-policy layer's exemption probe — ONE grant serves both layers; the gate-level
193
194
  * check is what makes "allow all session" bite SAME-TURN for the parent and inherited child tasks (the ask
194
- * path is never re-entered). (codex M1 的边界注记已闭合:core 1.295 起本仓在 gate 腿上组合
195
- * createSensitivePathPolicy —— `sensitivePatterns`,deny combinePolicies 折叠里恒胜,豁免/acceptDirs
196
- * 越不过它。) */
195
+ * path is never re-entered). (codex M1 的边界注记:sensitive-path DENY 腿与本探针的「豁免越不过 deny」
196
+ * 保证仍成立——但那条腿已随 #177 搬到 governance 层,deny-wins tightenTaskSpec 折叠给的,不再依赖
197
+ * 本 wiring 的组合点。) */
197
198
  isExempt?: (toolName: string, canonicalPath: string) => boolean | Promise<boolean>;
198
- /** ③ (core 1.295): the sensitive-path write DENY set composed onto the fs-write gate leg —
199
- * `createSensitivePathPolicy({env, patterns, rootPath: cwd})`, deny-wins in the SAME combinePolicies fold, so a
200
- * session exemption (isExempt) or an acceptEdits auto-allow can never cross it (the 1.199-batch documented gap,
201
- * now closed). Source = config `SENSITIVE_WRITE_PATTERNS` (unset ⇒ core RECOMMENDED_SENSITIVE_PATTERNS; the
202
- * set curation is core's — the server passes through). Absent/empty ⇒ no sensitive policy (pre-1.295 shape). */
203
- sensitivePatterns?: string[];
204
199
  }
205
200
  export declare function deriveSettingsPolicy(settings: ParsedTaskSettings, gate?: FsWriteGateWiring, workflowGate?: WorkflowGateWiring): {
206
201
  toolPolicy?: ToolPolicy;
@@ -35,8 +35,8 @@
35
35
  * - (`hooks` NO LONGER deferred either — 轴B #2 注释保鲜:applied 形已接线(本文件 hooks 分片 +
36
36
  * main.ts 单用户闸),仅 malformed 才报 deferred;旧「需要 remote hook-runner」是接线前拍照。)
37
37
  */
38
- import { combinePolicies, createAllowDenyPolicy, createFsWriteGatePolicy, createSensitivePathPolicy, tightenTaskSpec, resolveReasoningProfile, rankOf, isThinkingLevel, CYBER_RISK, URL_SAFETY, HARNESS_SECTION_ANCHOR, } from "@sema-agent/core";
39
- import { findUnmatchableToolNames, formatUnmatchableToolNames } from "./config.js";
38
+ import { combinePolicies, createAllowDenyPolicy, createFsWriteGatePolicy, tightenTaskSpec, resolveReasoningProfile, rankOf, isThinkingLevel, CYBER_RISK, URL_SAFETY, HARNESS_SECTION_ANCHOR, } from "@sema-agent/core";
39
+ import { findCcRuleFormNames, findUnmatchableToolNames, formatUnmatchableToolNames } from "./config.js";
40
40
  import { parseHooksConfig } from "./hooks/hook-runner.js";
41
41
  /**
42
42
  * R4: coerce a raw top-level `body.permissionMode` (the LIGHT per-turn wire — the shell sends the
@@ -367,9 +367,12 @@ function createWorkflowPlanDenyPolicy() {
367
367
  * silent on this leg. Throwing here surfaces as a 422 at the HTTP boundary (resolve-spec already wraps every
368
368
  * throw out of `applyTaskSettings`), i.e. the same fail-loud posture the other two legs have.
369
369
  *
370
- * `allow` is deliberately NOT judged: it is a WHITELIST — a name that matches nothing leaves the allowlist
371
- * effectively empty, so every tool falls to "not in the allowlist" and is DENIED. That fails closed and is
372
- * immediately visible to the caller, which is a different (and acceptable) failure mode from a silent hole.
370
+ * `allow` is deliberately NOT judged by the GENERAL arm: it is a WHITELIST — a name that matches nothing leaves
371
+ * the allowlist effectively empty, so every tool falls to "not in the allowlist" and is DENIED. That fails closed
372
+ * and is immediately visible to the caller, which is a different (and acceptable) failure mode from a silent hole.
373
+ * The ONE exception is the CC-rule-form arm ({@link findCcRuleFormNames}, #186): `allow: ["Bash(ps:*)"]` is the
374
+ * shape a CC user writes to get FEWER prompts and it silently costs them Bash entirely — a spelling-family error,
375
+ * not a typo, so it is refused on all three lists. See the config.ts docblock for the two knobs it points at.
373
376
  */
374
377
  function rejectUnmatchableSettingsNames(perms) {
375
378
  for (const [field, names] of [["permissions.ask", perms.ask], ["permissions.deny", perms.deny]]) {
@@ -379,6 +382,11 @@ function rejectUnmatchableSettingsNames(perms) {
379
382
  if (bad.length > 0)
380
383
  throw new Error(formatUnmatchableToolNames(field, bad));
381
384
  }
385
+ if (perms.allow) {
386
+ const bad = findCcRuleFormNames(perms.allow);
387
+ if (bad.length > 0)
388
+ throw new Error(formatUnmatchableToolNames("permissions.allow", bad));
389
+ }
382
390
  }
383
391
  export function deriveSettingsPolicy(settings, gate, workflowGate) {
384
392
  const perms = settings.permissions;
@@ -395,20 +403,17 @@ export function deriveSettingsPolicy(settings, gate, workflowGate) {
395
403
  policies.push(createAskListPolicy(ask));
396
404
  // The mode-derived fs-write ask gate (core 1.290 factory — real shape read in dist: {env, rootPath?, acceptDirs?,
397
405
  // exemptDirs?, defaultWrite}, gates Write/Edit/NotebookEdit, exempt→accept→default layering, canonicalize inside
398
- // the given env, only ever allow/ask). undefined without wiring (see FsWriteGateWiring). #156: the sandbox lanes
399
- // supply wiring too — same factory, a lexical (fs-less) env and no cwd; the shape difference lives entirely in
400
- // what `resolve-spec.ts` puts in the wiring, not in a second code path here.
401
- // ③ (core 1.295): the sensitive-path DENY policy (patterns from the wiring config SENSITIVE_WRITE_PATTERNS,
402
- // default = core's RECOMMENDED set) composes into the SAME fold, deny-wins — core semantics deny/"safety", so
403
- // neither the gate's own allow legs (exemptDirs/acceptDirs/isExempt session exemption) nor a settings allow can
404
- // cross it (the 1.199-batch gap: an exempted tool could write .git/hooks — closed here). Same env/rootPath as
405
- // the gate (the fs the hands really write; symlink canonicalization inside the policy, dist-read).
406
+ // the given env, only ever allow/ask). undefined without wiring (see FsWriteGateWiring). #156/#165: the sandbox
407
+ // lanes supply wiring too — same factory, a deferred real-env proxy and no cwd; the shape difference lives
408
+ // entirely in what `resolve-spec.ts` puts in the wiring, not in a second code path here.
409
+ // (③ sensitive-path DENY 已随 #177 governance 层——见 FsWriteGateWiring 尾注。deny-wins
410
+ // tightenTaskSpec 折叠保证:豁免/acceptDirs/settings allow 仍然越不过部署 DENY 基线。)
406
411
  const fsWriteGate = (acceptCwd) => {
407
412
  if (!gate)
408
413
  return undefined;
409
- // #156: `cwd` is optional (absent on the sandbox lanes). Omitting `rootPath` makes core leave a relative
410
- // target relative → its lexical env cannot resolve it → ask; omitting `acceptDirs` means acceptEdits mints
411
- // no auto-allow domain at all. Both are the intended fail-safe, NOT a degraded copy of the host arm.
414
+ // #156/#165: `cwd` is optional (absent on the sandbox lanes). Omitting `rootPath` makes core leave a relative
415
+ // target relative → the sandbox proxy's lexical `absolutePath` refuses it → ask; omitting `acceptDirs` means
416
+ // acceptEdits mints no auto-allow domain at all. Both are the intended fail-safe, NOT a degraded host arm.
412
417
  const gatePolicy = createFsWriteGatePolicy({
413
418
  env: gate.env,
414
419
  ...(gate.cwd !== undefined ? { rootPath: gate.cwd } : {}),
@@ -422,9 +427,7 @@ export function deriveSettingsPolicy(settings, gate, workflowGate) {
422
427
  // NotebookEdit 时 notebook_path 恒先(诱饵 file_path 失效),其余工具 file_path 先(反向诱饵也封);均 dist
423
428
  // 亲读。回归锚保留在 test/task-settings.test.ts(NotebookEdit deny + 双向诱饵),现在锁的是 core 行为经
424
429
  // 我方折叠——core 若回退,锚变红。
425
- return gate.sensitivePatterns && gate.sensitivePatterns.length > 0
426
- ? combinePolicies(createSensitivePathPolicy({ env: gate.env, patterns: gate.sensitivePatterns, ...(gate.cwd !== undefined ? { rootPath: gate.cwd } : {}) }), gatePolicy)
427
- : gatePolicy;
430
+ return gatePolicy;
428
431
  };
429
432
  const compose = (...extra) => {
430
433
  const all = [...policies, ...extra.filter((p) => p !== undefined)];