@sema-agent/server 7.88.2 → 7.90.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 (44) hide show
  1. package/dist/boot/fatal-exit.d.ts +3 -0
  2. package/dist/boot/fatal-exit.js +5 -0
  3. package/dist/boot/resolve-spec.js +2 -0
  4. package/dist/boot/stage-06-runners.js +7 -3
  5. package/dist/brain.js +32 -19
  6. package/dist/capabilities/center-plugins.d.ts +10 -0
  7. package/dist/capabilities/center-plugins.js +21 -5
  8. package/dist/capabilities/memory-notice.d.ts +7 -1
  9. package/dist/capabilities/memory-notice.js +6 -6
  10. package/dist/capabilities/team.d.ts +37 -0
  11. package/dist/config-catalog.js +7 -19
  12. package/dist/config-center/apply-effective.js +1 -1
  13. package/dist/config.d.ts +1 -1
  14. package/dist/config.js +11 -5
  15. package/dist/hooks/hook-llm.d.ts +25 -0
  16. package/dist/hooks/hook-llm.js +27 -9
  17. package/dist/hooks/hook-runner.js +21 -4
  18. package/dist/http/admission.js +9 -5
  19. package/dist/http/routes/capabilities.js +2 -2
  20. package/dist/http/routes/session-sync.js +10 -3
  21. package/dist/http/routes/sessions.js +16 -14
  22. package/dist/http/wire-types.d.ts +12 -0
  23. package/dist/main.js +2 -1
  24. package/dist/model-route-url.d.ts +27 -3
  25. package/dist/model-route-url.js +28 -14
  26. package/dist/plugins/memory-embedder.d.ts +0 -6
  27. package/dist/plugins/memory-embedder.js +10 -16
  28. package/dist/plugins/remote-env-adb.d.ts +13 -0
  29. package/dist/plugins/remote-env-adb.js +42 -9
  30. package/dist/plugins/remote-env-device.d.ts +39 -3
  31. package/dist/plugins/remote-env-device.js +107 -31
  32. package/dist/request-key-closure.js +2 -1
  33. package/dist/runtime-governance.d.ts +16 -7
  34. package/dist/runtime-governance.js +27 -7
  35. package/dist/session-policy-wire.d.ts +103 -0
  36. package/dist/session-policy-wire.js +66 -0
  37. package/dist/task-settings.d.ts +12 -2
  38. package/dist/task-settings.js +7 -5
  39. package/dist/trace/core-keyset-guard.d.ts +2 -2
  40. package/dist/trace/project.d.ts +7 -0
  41. package/dist/trace/project.js +1 -0
  42. package/dist/trace/redact.d.ts +110 -5
  43. package/dist/trace/redact.js +71 -12
  44. package/package.json +2 -2
@@ -99,6 +99,17 @@ const DEVICE_REJECT_TO_REMOTE_ERROR = {
99
99
  function rejectText(reject) {
100
100
  return `[${reject.code}] ${reject.message}`;
101
101
  }
102
+ const ENV_TERMINAL = { kind: "env_terminal" };
103
+ const ENV_TERMINAL_MESSAGE = "execution env already destroyed";
104
+ function failureFor(code, message) {
105
+ return { remote: DEVICE_REJECT_TO_REMOTE_ERROR[code], exec: DEVICE_REJECT_TO_EXEC_ERROR[code], file: DEVICE_REJECT_TO_FILE_ERROR[code], message };
106
+ }
107
+ const ADMISSION_DESTROYED = {
108
+ remote: "connect_failed",
109
+ exec: "shell_unavailable",
110
+ file: "unknown",
111
+ message: "execution env already destroyed",
112
+ };
102
113
  function assertPosixDevicePlatform(os, deviceId) {
103
114
  switch (os) {
104
115
  case "darwin":
@@ -118,6 +129,7 @@ export class RemoteDeviceExecutionEnv {
118
129
  writeFileExclusive;
119
130
  writeFileGuarded;
120
131
  connected = false;
132
+ connectPromise;
121
133
  destroyed = false;
122
134
  constructor(cfg) {
123
135
  this.cfg = cfg;
@@ -172,18 +184,61 @@ export class RemoteDeviceExecutionEnv {
172
184
  };
173
185
  }
174
186
  async connect(config) {
175
- if (this.destroyed)
176
- return { ok: false, error: new RemoteExecutionError("connect_failed", "device env destroyed — create a fresh env instead of reviving") };
177
187
  if (config?.abortSignal?.aborted === true)
178
188
  return { ok: false, error: new RemoteExecutionError("aborted", "connect aborted") };
179
- const admitted = await this.cfg.enrollment.admitSession({ rootSessionId: this.cfg.rootSessionId, requester: this.cfg.owner });
189
+ const admitted = await this.admitShared();
190
+ if (admitted.ok)
191
+ return ok(admitted.value);
192
+ return { ok: false, error: new RemoteExecutionError(admitted.failure.remote, admitted.failure.message) };
193
+ }
194
+ async admitShared() {
195
+ const inflight = this.connectPromise;
196
+ if (inflight !== undefined)
197
+ return inflight;
198
+ const started = this.admit();
199
+ this.connectPromise = started;
200
+ try {
201
+ return await started;
202
+ }
203
+ finally {
204
+ if (this.connectPromise === started)
205
+ this.connectPromise = undefined;
206
+ }
207
+ }
208
+ async ensureConnected() {
209
+ if (this.destroyed)
210
+ return ADMISSION_DESTROYED;
211
+ if (this.connected)
212
+ return undefined;
213
+ const admitted = await this.admitShared();
214
+ return admitted.ok ? undefined : admitted.failure;
215
+ }
216
+ async admit() {
217
+ if (this.destroyed) {
218
+ return { ok: false, failure: { ...ADMISSION_DESTROYED, message: "device env destroyed — create a fresh env instead of reviving" } };
219
+ }
220
+ let admitted;
221
+ try {
222
+ admitted = await this.cfg.enrollment.admitSession({ rootSessionId: this.cfg.rootSessionId, requester: this.cfg.owner });
223
+ }
224
+ catch (err) {
225
+ return {
226
+ ok: false,
227
+ failure: {
228
+ remote: "connect_failed",
229
+ exec: "target_unavailable",
230
+ file: "transport_lost",
231
+ message: `[device.admission_unavailable] the device admission read failed, so this session's device could not be confirmed (fail-closed): ${err instanceof Error ? err.message : String(err)}`,
232
+ },
233
+ };
234
+ }
180
235
  if (!admitted.ok) {
181
- return { ok: false, error: new RemoteExecutionError(DEVICE_REJECT_TO_REMOTE_ERROR[admitted.reject.code], rejectText(admitted.reject)) };
236
+ return { ok: false, failure: failureFor(admitted.reject.code, rejectText(admitted.reject)) };
182
237
  }
183
238
  if (admitted.value.deviceId !== this.cfg.deviceId) {
184
239
  return {
185
240
  ok: false,
186
- error: new RemoteExecutionError("auth_failed", `[${DEVICE_ERROR_CODES.IDENTITY_MISMATCH}] this session is now bound to a different device (${admitted.value.deviceId}) than the one this environment was minted for — refusing to silently re-target; re-initiate the work on the currently bound device (device lane identity, v2 §4.5/§3)`),
241
+ failure: failureFor("device.identity_mismatch", `[${DEVICE_ERROR_CODES.IDENTITY_MISMATCH}] this session is now bound to a different device (${admitted.value.deviceId}) than the one this environment was minted for — refusing to silently re-target; re-initiate the work on the currently bound device (device lane identity, v2 §4.5/§3)`),
187
242
  };
188
243
  }
189
244
  {
@@ -191,12 +246,20 @@ export class RemoteDeviceExecutionEnv {
191
246
  if (gained.length > 0) {
192
247
  return {
193
248
  ok: false,
194
- error: new RemoteExecutionError("connect_failed", `[device.capability_drift] this environment was minted while the device's protective write primitives (${gained.join(", ")}) were not observable, ` +
195
- "so they are absent from its filesystem face — core would read that absence as \"the backend has no such primitive\" and fall back to a NON-atomic write, " +
196
- "silently re-opening the TOCTOU window guarded writes exist to close (#157). Refusing this connect; the per-task factory will mint a correctly-shaped env on retry."),
249
+ failure: {
250
+ remote: "connect_failed",
251
+ exec: "shell_unavailable",
252
+ file: "not_supported",
253
+ message: `[device.capability_drift] this environment was minted while the device's protective write primitives (${gained.join(", ")}) were not observable, ` +
254
+ "so they are absent from its filesystem face — core would read that absence as \"the backend has no such primitive\" and fall back to a NON-atomic write, " +
255
+ "silently re-opening the TOCTOU window guarded writes exist to close (#157). Refusing this connect; the per-task factory will mint a correctly-shaped env on retry.",
256
+ },
197
257
  };
198
258
  }
199
259
  }
260
+ if (this.destroyed) {
261
+ return { ok: false, failure: { ...ADMISSION_DESTROYED, message: "device env destroyed while admitting — create a fresh env instead of reviving" } };
262
+ }
200
263
  this.connected = true;
201
264
  return ok(this.workspaceHandle());
202
265
  }
@@ -226,24 +289,28 @@ export class RemoteDeviceExecutionEnv {
226
289
  async cleanup() {
227
290
  return this.destroy();
228
291
  }
229
- shellGate() {
292
+ async shellGate() {
230
293
  if (this.destroyed)
231
294
  return { ok: false, error: new ExecutionError("shell_unavailable", "execution env already destroyed") };
232
- if (!this.connected)
233
- return { ok: false, error: new ExecutionError("shell_unavailable", "device env not connected — call connect() first") };
295
+ const failure = await this.ensureConnected();
296
+ if (failure)
297
+ return { ok: false, error: new ExecutionError(failure.exec, failure.message) };
234
298
  return undefined;
235
299
  }
236
- fsGate(p) {
300
+ async fsGate(p) {
237
301
  if (this.destroyed)
238
302
  return { ok: false, error: new FileError("unknown", "execution env already destroyed", this.resolve(p)) };
239
- if (!this.connected)
240
- return { ok: false, error: new FileError("unknown", "device env not connected — call connect() first", this.resolve(p)) };
303
+ const failure = await this.ensureConnected();
304
+ if (failure)
305
+ return { ok: false, error: new FileError(failure.file, failure.message, this.resolve(p)) };
241
306
  return undefined;
242
307
  }
243
308
  timeoutMsFor(seconds) {
244
309
  return seconds != null && Number.isFinite(seconds) && seconds > 0 ? Math.ceil(seconds * 1000) : this.cfg.execTimeoutMs;
245
310
  }
246
311
  dispatch(kind, args, opts) {
312
+ if (this.destroyed)
313
+ return Promise.resolve(ENV_TERMINAL);
247
314
  const input = {
248
315
  rootSessionId: this.cfg.rootSessionId,
249
316
  requester: this.cfg.owner,
@@ -262,6 +329,8 @@ export class RemoteDeviceExecutionEnv {
262
329
  }
263
330
  fileOutcome(res, absPath, take) {
264
331
  switch (res.kind) {
332
+ case "env_terminal":
333
+ return { ok: false, error: new FileError("unknown", ENV_TERMINAL_MESSAGE, absPath) };
265
334
  case "rejected":
266
335
  return { ok: false, error: new FileError(DEVICE_REJECT_TO_FILE_ERROR[res.reject.code], rejectText(res.reject), absPath) };
267
336
  case "never_started":
@@ -277,7 +346,7 @@ export class RemoteDeviceExecutionEnv {
277
346
  return { ok: false, error: new FileError("unknown", `device returned a "${got}" outcome for an operation that requires "${want}" — protocol violation`, absPath) };
278
347
  }
279
348
  async exec(command, options) {
280
- const gated = this.shellGate();
349
+ const gated = await this.shellGate();
281
350
  if (gated)
282
351
  return gated;
283
352
  let stdout = "";
@@ -301,6 +370,8 @@ export class RemoteDeviceExecutionEnv {
301
370
  },
302
371
  });
303
372
  switch (res.kind) {
373
+ case "env_terminal":
374
+ return { ok: false, error: new ExecutionError("shell_unavailable", ENV_TERMINAL_MESSAGE) };
304
375
  case "rejected":
305
376
  return { ok: false, error: new ExecutionError(DEVICE_REJECT_TO_EXEC_ERROR[res.reject.code], rejectText(res.reject)) };
306
377
  case "never_started":
@@ -321,8 +392,11 @@ export class RemoteDeviceExecutionEnv {
321
392
  async *execStream(command, options) {
322
393
  if (this.destroyed)
323
394
  throw new RemoteExecutionError("connect_failed", "execution env already destroyed");
324
- if (!this.connected)
325
- throw new RemoteExecutionError("connect_failed", "device env not connected — call connect() first");
395
+ {
396
+ const failure = await this.ensureConnected();
397
+ if (failure)
398
+ throw new RemoteExecutionError(failure.remote, failure.message);
399
+ }
326
400
  const queue = [];
327
401
  let queuedBytes = 0;
328
402
  let done = false;
@@ -434,7 +508,7 @@ export class RemoteDeviceExecutionEnv {
434
508
  }
435
509
  }
436
510
  async exists(p, abortSignal) {
437
- const gated = this.fsGate(p);
511
+ const gated = await this.fsGate(p);
438
512
  if (gated)
439
513
  return gated;
440
514
  const abs = this.resolve(p);
@@ -448,7 +522,7 @@ export class RemoteDeviceExecutionEnv {
448
522
  });
449
523
  }
450
524
  async canonicalPath(p, abortSignal) {
451
- const gated = this.fsGate(p);
525
+ const gated = await this.fsGate(p);
452
526
  if (gated)
453
527
  return gated;
454
528
  const abs = this.resolve(p);
@@ -462,7 +536,7 @@ export class RemoteDeviceExecutionEnv {
462
536
  });
463
537
  }
464
538
  async fileInfo(p, abortSignal) {
465
- const gated = this.fsGate(p);
539
+ const gated = await this.fsGate(p);
466
540
  if (gated)
467
541
  return gated;
468
542
  const abs = this.resolve(p);
@@ -476,7 +550,7 @@ export class RemoteDeviceExecutionEnv {
476
550
  });
477
551
  }
478
552
  async doReadLink(p, abortSignal) {
479
- const gated = this.fsGate(p);
553
+ const gated = await this.fsGate(p);
480
554
  if (gated)
481
555
  return gated;
482
556
  const abs = this.resolve(p);
@@ -492,7 +566,7 @@ export class RemoteDeviceExecutionEnv {
492
566
  });
493
567
  }
494
568
  async readBinaryFile(p, abortSignal) {
495
- const gated = this.fsGate(p);
569
+ const gated = await this.fsGate(p);
496
570
  if (gated)
497
571
  return gated;
498
572
  const abs = this.resolve(p);
@@ -508,7 +582,7 @@ export class RemoteDeviceExecutionEnv {
508
582
  return this.fileOutcome(res, abs, (o) => (o.kind === "read" ? ok(new Uint8Array(Buffer.concat(parts))) : this.wrongOutcome("read", o.kind, abs)));
509
583
  }
510
584
  async readTextFile(p, abortSignal) {
511
- const gated = this.fsGate(p);
585
+ const gated = await this.fsGate(p);
512
586
  if (gated)
513
587
  return gated;
514
588
  const abs = this.resolve(p);
@@ -524,7 +598,7 @@ export class RemoteDeviceExecutionEnv {
524
598
  return this.fileOutcome(res, abs, (o) => (o.kind === "read" ? ok(Buffer.concat(parts).toString("utf8")) : this.wrongOutcome("read", o.kind, abs)));
525
599
  }
526
600
  async readTextLines(p, options) {
527
- const gated = this.fsGate(p);
601
+ const gated = await this.fsGate(p);
528
602
  if (gated)
529
603
  return gated;
530
604
  const abs = this.resolve(p);
@@ -547,7 +621,7 @@ export class RemoteDeviceExecutionEnv {
547
621
  });
548
622
  }
549
623
  async listDir(p, abortSignal) {
550
- const gated = this.fsGate(p);
624
+ const gated = await this.fsGate(p);
551
625
  if (gated)
552
626
  return gated;
553
627
  const abs = this.resolve(p);
@@ -567,7 +641,7 @@ export class RemoteDeviceExecutionEnv {
567
641
  return this.doWrite("appendFile", p, content, undefined, abortSignal);
568
642
  }
569
643
  async doWrite(kind, p, content, _unused, abortSignal) {
570
- const gated = this.fsGate(p);
644
+ const gated = await this.fsGate(p);
571
645
  if (gated)
572
646
  return gated;
573
647
  const abs = this.resolve(p);
@@ -590,7 +664,7 @@ export class RemoteDeviceExecutionEnv {
590
664
  });
591
665
  }
592
666
  async doGuardedWrite(p, content, expect, abortSignal) {
593
- const gated = this.fsGate(p);
667
+ const gated = await this.fsGate(p);
594
668
  if (gated)
595
669
  return gated;
596
670
  const abs = this.resolve(p);
@@ -615,7 +689,7 @@ export class RemoteDeviceExecutionEnv {
615
689
  return false;
616
690
  }
617
691
  async createDir(p, options) {
618
- const gated = this.fsGate(p);
692
+ const gated = await this.fsGate(p);
619
693
  if (gated)
620
694
  return gated;
621
695
  const abs = this.resolve(p);
@@ -623,7 +697,7 @@ export class RemoteDeviceExecutionEnv {
623
697
  return this.fileOutcome(res, abs, (o) => (o.kind === "void" ? ok(undefined) : this.wrongOutcome("void", o.kind, abs)));
624
698
  }
625
699
  async remove(p, options) {
626
- const gated = this.fsGate(p);
700
+ const gated = await this.fsGate(p);
627
701
  if (gated)
628
702
  return gated;
629
703
  const abs = this.resolve(p);
@@ -631,14 +705,14 @@ export class RemoteDeviceExecutionEnv {
631
705
  return this.fileOutcome(res, abs, (o) => (o.kind === "void" ? ok(undefined) : this.wrongOutcome("void", o.kind, abs)));
632
706
  }
633
707
  async createTempDir(prefix = "tmp-", abortSignal) {
634
- const gated = this.fsGate(this.cwd);
708
+ const gated = await this.fsGate(this.cwd);
635
709
  if (gated)
636
710
  return gated;
637
711
  const res = await this.dispatch("createTempDir", { prefix }, { timeoutMs: this.cfg.execTimeoutMs, ...(abortSignal === undefined ? {} : { signal: abortSignal }) });
638
712
  return this.fileOutcome(res, this.cwd, (o) => (o.kind === "write" ? ok(o.canonicalPath) : this.wrongOutcome("write", o.kind, this.cwd)));
639
713
  }
640
714
  async createTempFile(options) {
641
- const gated = this.fsGate(this.cwd);
715
+ const gated = await this.fsGate(this.cwd);
642
716
  if (gated)
643
717
  return gated;
644
718
  const res = await this.dispatch("createTempFile", { ...(options?.prefix === undefined ? {} : { prefix: options.prefix }), ...(options?.suffix === undefined ? {} : { suffix: options.suffix }) }, { timeoutMs: this.cfg.execTimeoutMs, ...(options?.abortSignal === undefined ? {} : { signal: options.abortSignal }) });
@@ -666,6 +740,8 @@ function toWireExpectation(e) {
666
740
  }
667
741
  function streamFailure(res) {
668
742
  switch (res.kind) {
743
+ case "env_terminal":
744
+ return new RemoteExecutionError("connect_failed", ENV_TERMINAL_MESSAGE);
669
745
  case "rejected":
670
746
  return new RemoteExecutionError(DEVICE_REJECT_TO_REMOTE_ERROR[res.reject.code], rejectText(res.reject));
671
747
  case "never_started":
@@ -20,7 +20,8 @@ export function keyClosureClause(names, label) {
20
20
  const TASK_BODY_DECLARED_KEYS = [
21
21
  "a2aPeers", "acceptPartialRestore", "additionalDirectories", "additionalReadDirectories", "agents",
22
22
  "appendSystemPrompt", "attachmentIds", "attachments", "capabilitiesNeeded", "cascade", "clientContext",
23
- "compaction", "compactionModel", "council", "cwd", "debate", "deferTools", "deviceId", "enableFork", "excludeTools",
23
+ "compaction", "compactionModel", "council", "cwd", "debate", "deferTools", "deviceId", "enableFork",
24
+ "excludeAllTools", "excludeTools",
24
25
  "finalVerification", "forwardSubagentEvents", "images", "interactiveTools", "jobId", "limits", "maxCostUsd",
25
26
  "maxTokens", "mcpServers", "memory", "memoryCapture", "memoryWrite", "model", "objective", "oneShot", "outputRetries",
26
27
  "outputSchema", "permissionMode", "projectId", "promptProfile", "reasoningEffort", "requireExistingSession",
@@ -24,12 +24,14 @@
24
24
  * name). Real isolation is the `executionEnv` sandbox. Same caveat core documents on `createCoarseCommandNamePolicy`.
25
25
  */
26
26
  import { type TaskSpec, type ToolPolicy } from "@sema-agent/core";
27
+ import { GovernanceConfig } from "@sema-agent/settings-schema";
27
28
  import { type GovernanceAskMarks } from "./governance-ask-marks.js";
28
- /** A per-command governance rule (registry `runtime.commandPolicy[]`). */
29
- export type CommandRule = {
30
- command: string;
31
- decision: "allow" | "ask" | "deny";
32
- };
29
+ /** 运维 autonomy 四档的**闭集**(同上)。`config.ts` 的 `parseAutonomy` 也从这里读 —— 四档词表全仓一份。 */
30
+ export declare const AUTONOMY_MODES: ["read-only", "ask", "plan", "auto"];
31
+ /** A per-command governance rule (registry `runtime.commandPolicy[]`) —— **型也从契约派生**:
32
+ * 契约给 `command` 加一条 refine / 给 `decision` 加一个判词,本仓的型当场跟上(或在下面的
33
+ * `RANK` / 穷尽 switch 上编译红),而不是靠人记得把手写联合改一遍。 */
34
+ export type CommandRule = NonNullable<GovernanceConfig["commandPolicy"]>[number];
33
35
  /** True when `name` names a DELEGATION tool — the subagent tool(CC-187 canonical `"Agent"`,core
34
36
  * `DEFAULT_SUBAGENT_TOOL_NAME`)。5.0.0 [ref]:折叠面退役,RAW 比对 core 单源常量——旧名("Task")
35
37
  * 在 core roster 层响亮 miss,不再需要归一;单源常量本身就防了当年 1.202 改名时裸字符串失配的
@@ -50,8 +52,8 @@ export declare function isDelegationToolName(name: string): boolean;
50
52
  export declare function stripDelegationTools<T extends {
51
53
  name: string;
52
54
  }>(tools: readonly T[]): T[];
53
- /** The operator autonomy mode (registry `runtime.autonomy`). */
54
- export type Autonomy = "read-only" | "ask" | "plan" | "auto";
55
+ /** The operator autonomy mode (registry `governance.autonomy`) —— 契约派生(见 {@link AUTONOMY_MODES})。 */
56
+ export type Autonomy = NonNullable<GovernanceConfig["autonomy"]>;
55
57
  /** 导出给 `test/coarse-shell-tools-mirror.test.ts` 的同源钉用(见那里的顶注)。 */
56
58
  export declare const COARSE_SHELL_TOOLS_MIRROR: readonly string[];
57
59
  /** Validate `commandPolicy` rules — the shape (array of {command, decision}), the `command` (a bare argv[0] name,
@@ -81,6 +83,13 @@ export declare function validateCommandRules(rules: CommandRule[]): string[];
81
83
  * Either way the result only ever ADDS asks/denies — composed onto the baseline via `combinePolicies`
82
84
  * (deny-wins) by {@link applyRuntimeGovernance}, so it is tighten-only. (Assumes commands are pre-validated by
83
85
  * {@link validateCommandRules}; an un-matchable command here is simply inert, never a parse error.)
86
+ *
87
+ * 🔴 **S-496:词表外的 `decision` 折 `deny`,不折 `ask`。** {@link validateCommandRules} 的头注写下的那条
88
+ * 后果(「域外的 `decision` 穿过本函数的三元链落到 ASK,静默把一条 intended deny 弱化掉」)此前只靠
89
+ * **调用方记得先校验**挡着 —— 而本函数在 `resolve-spec` 里是逐请求现调的,`config.commandPolicy` 的来路
90
+ * (center hot apply / 本地 `config.d/governance.json` / LKG / 测试直塞)不止一条。现在闭集在本函数**内部**
91
+ * 收口:`VALID_DECISIONS` 之外的值一律折最严的 `deny`(fail-closed,[ref] 的运行期 miss 臂,下方自陈),
92
+ * 闭集本身则由契约 zod 派生 ⇒ 契约加一个判词,`RANK` 与桶分配的穷尽 switch 双双**编译红**。
84
93
  */
85
94
  export declare function compileCommandPolicy(rules: CommandRule[] | undefined): ToolPolicy | undefined;
86
95
  /**
@@ -1,7 +1,10 @@
1
1
  import { combinePolicies, COARSE_SHELL_TOOLS as CORE_COARSE_SHELL_TOOLS, createCoarseCommandNamePolicy, DEFAULT_SUBAGENT_TOOL_NAME, parseLeadingCommandName, tightenTaskSpec, } from "@sema-agent/core";
2
- import { SAFETY_MERGE_SPEC } from "@sema-agent/settings-schema";
2
+ import { GovernanceConfig, SAFETY_MERGE_SPEC } from "@sema-agent/settings-schema";
3
3
  import { currentGovernanceAskMarks } from "./governance-ask-marks.js";
4
4
  import { operatorAsk } from "./operator-ask.js";
5
+ const COMMAND_RULE_SHAPE = GovernanceConfig.shape.commandPolicy.unwrap().element.shape;
6
+ const COMMAND_DECISIONS = COMMAND_RULE_SHAPE.decision.options;
7
+ export const AUTONOMY_MODES = GovernanceConfig.shape.autonomy.unwrap().options;
5
8
  export function isDelegationToolName(name) {
6
9
  return name === DEFAULT_SUBAGENT_TOOL_NAME;
7
10
  }
@@ -30,7 +33,9 @@ function createCommandAskListPolicy(askCommands, tools = COARSE_SHELL_TOOLS) {
30
33
  };
31
34
  }
32
35
  const VALID_COMMAND_NAME = /^[A-Za-z0-9._+-]+$/;
33
- const VALID_DECISIONS = new Set(["allow", "ask", "deny"]);
36
+ function isCommandDecision(raw) {
37
+ return typeof raw === "string" && COMMAND_DECISIONS.includes(raw);
38
+ }
34
39
  export function validateCommandRules(rules) {
35
40
  const errors = [];
36
41
  if (!Array.isArray(rules))
@@ -45,8 +50,8 @@ export function validateCommandRules(rules) {
45
50
  errors.push(`commandPolicy command "${String(command)}" is not a bare argv[0] name — core matches the EXACT command name (no glob/path/operators). ` +
46
51
  `Use e.g. "git" not "git*"; to gate sub-commands or pipes, deny the interpreter ("sh"/"bash") + rely on the sandbox.`);
47
52
  }
48
- if (typeof decision !== "string" || !VALID_DECISIONS.has(decision)) {
49
- errors.push(`commandPolicy command "${String(command)}" has invalid decision "${String(decision)}" — must be one of allow | ask | deny`);
53
+ if (!isCommandDecision(decision)) {
54
+ errors.push(`commandPolicy command "${String(command)}" has invalid decision "${String(decision)}" — must be one of ${COMMAND_DECISIONS.join(" | ")}`);
50
55
  }
51
56
  }
52
57
  return errors;
@@ -57,15 +62,30 @@ export function compileCommandPolicy(rules) {
57
62
  const RANK = { deny: 3, ask: 2, allow: 1 };
58
63
  const strictest = new Map();
59
64
  for (const r of rules) {
65
+ const decision = isCommandDecision(r.decision) ? r.decision : "deny";
60
66
  const prev = strictest.get(r.command);
61
- if (prev === undefined || RANK[r.decision] > RANK[prev])
62
- strictest.set(r.command, r.decision);
67
+ if (RANK[decision] > (prev === undefined ? 0 : RANK[prev]))
68
+ strictest.set(r.command, decision);
63
69
  }
64
70
  const deny = [];
65
71
  const allow = [];
66
72
  const ask = [];
67
73
  for (const [command, decision] of strictest) {
68
- (decision === "deny" ? deny : decision === "allow" ? allow : ask).push(command);
74
+ switch (decision) {
75
+ case "deny":
76
+ deny.push(command);
77
+ break;
78
+ case "allow":
79
+ allow.push(command);
80
+ break;
81
+ case "ask":
82
+ ask.push(command);
83
+ break;
84
+ default: {
85
+ const unhandled = decision;
86
+ throw new Error(`commandPolicy decision "${String(unhandled)}" has no compilation arm`);
87
+ }
88
+ }
69
89
  }
70
90
  const policies = [];
71
91
  if (allow.length > 0) {
@@ -0,0 +1,103 @@
1
+ /**
2
+ * `SessionPermissionRules` 的 **wire 形读者** —— 两条写入腿(`PUT /v1/sessions/:id/policy` 与
3
+ * `POST /v1/sessions/:id/sync/import` 的 `policy` 面)**唯一**的形判据。
4
+ *
5
+ * ── 为什么是一只共享读者(S-496 / [ref],取证 P4 + S-498)───────────────────────────────────────────
6
+ * 改前两条腿各自为政:
7
+ * · PUT 腿在 `http/routes/sessions.ts` 里**手抄**五桶键名(`["toolAllow","toolDeny","allowDirs",
8
+ * "commandAllow","commandDeny"] as const`),core 的 `SessionPermissionRules` 加第六桶 ⇒ 那一桶在
9
+ * PUT 上被**静默丢**、200 回执却说「已生效」;
10
+ * · sync/import 腿(S-498)只判 `Array.isArray(body.policy)` **外层**,逐条记录的 `principal` / `rules`
11
+ * 一个字节都不校验就裸交 `replayPolicyRecords` → `putRules`。坏形走的是**放宽**方向,亲读 core
12
+ * `session-policy-store.js` 两处实现:
13
+ * · `normalizeRules` 对每桶做 `[...rules.toolDeny]` ⇒ 一个**字符串** `"rm"` 被展开成 `["r","m"]`,
14
+ * 本来该拒 `rm` 的 deny 规则变成拒两个不存在的命令名 = 门整条失效;
15
+ * · `allowDirs` 走 `.map(...)` ⇒ 字符串上没有 `.map` ⇒ **TypeError → 500**(路由头注 `:300-302`
16
+ * 自陈「Malformed shape → 400(never a downstream TypeError)」,这一半没守);
17
+ * · `loosenReasons` 的每一臂都从 `prior` 出发,**prior 为空时恒回空集** ⇒ tighten-only 门对一条
18
+ * 全新的会话放行任何内容,坏形因此连「收紧门」这一层都碰不到。
19
+ * ⇒ 类修:一只读者,键集由 `keyof SessionPermissionRules` **派生**(下方 `RULE_FIELD_PRESENCE` 的
20
+ * `satisfies Record<keyof SessionPermissionRules, true>`:core 加桶 ⇒ 缺键**编译红**;core 删桶 ⇒
21
+ * 多键**编译红**),两条腿同调。
22
+ *
23
+ * ── 文案也在这里(codex 一轮追加:两条腿各铸一份句子 = 第二份手抄)───────────────────────────────
24
+ * 初版把 wire 文案留在两个路由各自铸,理由是错误文案冻结门(`test/api-error-text-freeze.test.ts`)锚的是
25
+ * `src/http/` 桶里的 `sendError` 站点。codex 一轮指出那等于**把同一句话写了两遍**(`sessions.ts` 与
26
+ * `session-sync.ts` 各一份),这正是本批要消灭的形。改法照本仓既有成案(S-405 / [ref] / [ref] 三处
27
+ * 「常量锚」):句子收进本文件**单一属主**,冻结职责搬到冻结门的**常量锚格** —— 那一格直接断言这两只
28
+ * 渲染器对每一种判词的输出字节,且对判词闭集穷尽(加一种判词 ⇒ 那一格编译红)。文案一个字节未改。
29
+ */
30
+ import type { SessionPermissionRules, SessionRulesRecord } from "@sema-agent/core";
31
+ /** core `SessionPermissionRules` 的桶名全集(派生,不是手抄)。 */
32
+ export declare const SESSION_PERMISSION_RULE_FIELDS: readonly (keyof SessionPermissionRules)[];
33
+ /** 一条规则体的形问题。判词而非文案(见文件头注的「文案属主」一段)。 */
34
+ export type SessionPermissionRulesProblem =
35
+ /** 整体不是一只普通对象(`null` / 数组 / 标量 / 缺席)。 */
36
+ {
37
+ kind: "not-an-object";
38
+ }
39
+ /** 某一桶在场但不是 `string[]`(字符串 / 数字 / 混杂数组都在此)。 */
40
+ | {
41
+ kind: "field-not-string-array";
42
+ field: keyof SessionPermissionRules;
43
+ };
44
+ /** {@link readSessionPermissionRules} 的读数:坏形给判词,好形给**已收窄**的规则体(调用方零 cast)。 */
45
+ export type SessionPermissionRulesRead = {
46
+ problem: SessionPermissionRulesProblem;
47
+ rules?: undefined;
48
+ } | {
49
+ problem?: undefined;
50
+ rules: SessionPermissionRules;
51
+ };
52
+ /**
53
+ * 读一只 wire 上的 `SessionPermissionRules`:形不对给判词,形对给**只含五桶**的规则体。
54
+ *
55
+ * 认识面窄得刻意:**只判形**,不判内容(桶内字符串的 pattern 文法、路径是否可达、tighten-only 是否成立
56
+ * 都不在这里 —— 前两者归 core 的 rule 文法与 `normalizeRules`,后者归 `putRules` 的收紧门)。
57
+ * 未知键**照旧忽略**(不是新的拒因):core 的 `normalizeRules` 本来就只搬这五桶,本读者与它同口径。
58
+ */
59
+ export declare function readSessionPermissionRules(raw: unknown): SessionPermissionRulesRead;
60
+ /** 一条 `SessionRulesRecord` 的形问题(`policy[]` 的元素级;桶级问题原样转载在 `rules` 位)。 */
61
+ export type SessionRulesRecordProblem =
62
+ /** `policy` 本身不是数组。 */
63
+ {
64
+ kind: "not-an-array";
65
+ }
66
+ /** 第 `index` 条记录不是一只普通对象。 */
67
+ | {
68
+ kind: "record-not-an-object";
69
+ index: number;
70
+ }
71
+ /** 第 `index` 条的 `principal` 在场但不是字符串(**缺席**才是「会话级默认行」的合法写法 —— 两个店的
72
+ * `listBySession` 对无 principal 的行都产 `undefined`,JSON 序列化后即缺席,所以 `null` 不在受理面)。 */
73
+ | {
74
+ kind: "principal-not-a-string";
75
+ index: number;
76
+ }
77
+ /** 第 `index` 条的 `rules` 形不对(转载桶级判词)。 */
78
+ | {
79
+ kind: "rules";
80
+ index: number;
81
+ problem: SessionPermissionRulesProblem;
82
+ };
83
+ /** {@link readSessionRulesRecords} 的读数。 */
84
+ export type SessionRulesRecordsRead = {
85
+ problem: SessionRulesRecordProblem;
86
+ records?: undefined;
87
+ } | {
88
+ problem?: undefined;
89
+ records: SessionRulesRecord[];
90
+ };
91
+ /**
92
+ * 读 sync/import 的 `policy` 面(`SessionRulesRecord[]`)。好形返回**已收窄**的记录数组,坏形给判词。
93
+ *
94
+ * `rev`:记录里带的 `rev` 是**导出端的**乐观锁读数,`putRules` 侧一进门就 `stripRev` 掉(`sameRulesContent`
95
+ * 也比的是去 rev 的字节形),所以它在本读者里只是过境位 —— 非数字即当缺席(不是拒因:一个坏 rev 改变不了
96
+ * 任何一次写入的结果,拒它只会把合法同步砖死)。
97
+ */
98
+ export declare function readSessionRulesRecords(raw: unknown): SessionRulesRecordsRead;
99
+ /** 桶级判词 → 人话。闭集穷尽:{@link SessionPermissionRulesProblem} 加一种判词 ⇒ 本 switch 编译红。 */
100
+ export declare function describeSessionPermissionRulesProblem(p: SessionPermissionRulesProblem): string;
101
+ /** `policy[]` 判词 → 人话(sync/import 腿在它前面拼 `policy: `)。闭集穷尽同上。 */
102
+ export declare function describeSessionRulesRecordProblem(p: SessionRulesRecordProblem): string;
103
+ //# sourceMappingURL=session-policy-wire.d.ts.map
@@ -0,0 +1,66 @@
1
+ const RULE_FIELD_PRESENCE = {
2
+ toolAllow: true,
3
+ toolDeny: true,
4
+ allowDirs: true,
5
+ commandAllow: true,
6
+ commandDeny: true,
7
+ };
8
+ export const SESSION_PERMISSION_RULE_FIELDS = Object.keys(RULE_FIELD_PRESENCE);
9
+ export function readSessionPermissionRules(raw) {
10
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
11
+ return { problem: { kind: "not-an-object" } };
12
+ const src = raw;
13
+ const rules = {};
14
+ for (const field of SESSION_PERMISSION_RULE_FIELDS) {
15
+ const v = src[field];
16
+ if (v === undefined)
17
+ continue;
18
+ if (!Array.isArray(v) || !v.every((x) => typeof x === "string"))
19
+ return { problem: { kind: "field-not-string-array", field } };
20
+ rules[field] = v;
21
+ }
22
+ return { rules };
23
+ }
24
+ export function readSessionRulesRecords(raw) {
25
+ if (!Array.isArray(raw))
26
+ return { problem: { kind: "not-an-array" } };
27
+ const records = [];
28
+ for (let index = 0; index < raw.length; index++) {
29
+ const e = raw[index];
30
+ if (e === null || typeof e !== "object" || Array.isArray(e))
31
+ return { problem: { kind: "record-not-an-object", index } };
32
+ const { principal, rules, rev } = e;
33
+ if (principal !== undefined && typeof principal !== "string")
34
+ return { problem: { kind: "principal-not-a-string", index } };
35
+ const read = readSessionPermissionRules(rules);
36
+ if (read.problem !== undefined)
37
+ return { problem: { kind: "rules", index, problem: read.problem } };
38
+ const innerRev = rules?.rev;
39
+ const carried = typeof innerRev === "number" ? innerRev : typeof rev === "number" ? rev : 0;
40
+ records.push({ principal, rules: { ...read.rules, rev: carried } });
41
+ }
42
+ return { records };
43
+ }
44
+ export function describeSessionPermissionRulesProblem(p) {
45
+ switch (p.kind) {
46
+ case "not-an-object": return "rules must be a SessionPermissionRules object";
47
+ case "field-not-string-array": return `${p.field} must be an array of strings`;
48
+ default: {
49
+ const unhandled = p;
50
+ throw new Error(`unhandled SessionPermissionRules problem: ${JSON.stringify(unhandled)}`);
51
+ }
52
+ }
53
+ }
54
+ export function describeSessionRulesRecordProblem(p) {
55
+ switch (p.kind) {
56
+ case "not-an-array": return "must be an array of {principal, rules} records";
57
+ case "record-not-an-object": return `[${p.index}] must be a {principal, rules} record`;
58
+ case "principal-not-a-string": return `[${p.index}].principal must be a string when present (omit it for the session-wide row)`;
59
+ case "rules": return `[${p.index}].rules: ${describeSessionPermissionRulesProblem(p.problem)}`;
60
+ default: {
61
+ const unhandled = p;
62
+ throw new Error(`unhandled SessionRulesRecord problem: ${JSON.stringify(unhandled)}`);
63
+ }
64
+ }
65
+ }
66
+ //# sourceMappingURL=session-policy-wire.js.map
@@ -48,7 +48,16 @@ import { type KeyClosureIssue } from "./request-key-closure.js";
48
48
  * deployment approval/governance baseline is composed OUTSIDE this derive and is untouchable from a settings stamp),
49
49
  * and `auto` adds the same ask gate as `default` with core's auto-mode classifier screening the asks upstream
50
50
  * (entitlement-gated in core, [ref]②). */
51
- export type SettingsPermissionMode = "default" | "acceptEdits" | "plan" | "bypassPermissions" | "auto";
51
+ export declare const SETTINGS_PERMISSION_MODES: readonly ["default", "acceptEdits", "plan", "bypassPermissions", "auto"];
52
+ /** 五模式闭集的型形 —— **从值表派生**(S-496 / [ref]):改前本仓有 **4 份**同一张五词表(本处的手写联合 /
53
+ * 下面 `coercePermissionModeStrict` 的运行期字面量链 / `http/admission.ts` 顶层 `permissionMode` 的比较链 /
54
+ * 同文件 `settings.permissions.defaultMode` 的比较链),外加两句错误文案里各抄一遍词。现在值表一份、
55
+ * 型与文案都从它派生,比较链改调 {@link isSettingsPermissionMode}。 */
56
+ export type SettingsPermissionMode = (typeof SETTINGS_PERMISSION_MODES)[number];
57
+ /** 五词表的 wire 文案形(`"a" | "b" | …`)—— 两条 400 的句子从这里取词,不再各抄一份。 */
58
+ export declare const SETTINGS_PERMISSION_MODE_WORDS: string;
59
+ /** 闭集成员判定(`unknown` 入口用)—— 词表的**唯一**运行期读者:wire 门与 coerce 两族同调。 */
60
+ export declare function isSettingsPermissionMode(raw: unknown): raw is SettingsPermissionMode;
52
61
  /**
53
62
  * R4: coerce a raw top-level `body.permissionMode` (the LIGHT per-turn wire — the shell sends the
54
63
  * CC permission mode RAW, axis-agnostic; the service interprets) to a service-honored mode, or undefined.
@@ -68,7 +77,8 @@ export type SettingsPermissionMode = "default" | "acceptEdits" | "plan" | "bypas
68
77
  */
69
78
  export declare function coercePermissionMode(raw: unknown): SettingsPermissionMode | undefined;
70
79
  /** 五模式闭集的**严格**读法([ref] ④:`/v1/capabilities?permissionMode=` 查询串用它 —— 读面对词表外的值 400,
71
- * 不走上面那条给 resume 重放留的 `default` 宽臂)。上面的宽读只是本函数外再包一层折叠,词表单点在此。 */
80
+ * 不走上面那条给 resume 重放留的 `default` 宽臂)。上面的宽读只是本函数外再包一层折叠。
81
+ * S-496:词表单点已上移到 {@link SETTINGS_PERMISSION_MODES},本函数只是它的 `undefined` 形读者。 */
72
82
  export declare function coercePermissionModeStrict(raw: unknown): SettingsPermissionMode | undefined;
73
83
  /**
74
84
  * S-433 —— 一份**被批准的计划**之后,这条会话继续用的 permission mode(`plan_review` 决裁体的