@sema-agent/server 7.44.0 → 7.45.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 (48) hide show
  1. package/README.md +2 -0
  2. package/README.zh-CN.md +2 -0
  3. package/USAGE.md +10 -1
  4. package/dist/approval-card.d.ts +5 -0
  5. package/dist/approval-card.js +2 -0
  6. package/dist/boot/config-center.js +22 -11
  7. package/dist/boot/execution-env.js +1 -0
  8. package/dist/boot/shutdown.d.ts +18 -2
  9. package/dist/boot/shutdown.js +54 -12
  10. package/dist/boot/side-query-lane.d.ts +31 -46
  11. package/dist/boot/side-query-lane.js +3 -15
  12. package/dist/boot/webfetch-summarize-lane.d.ts +3 -2
  13. package/dist/boot/webfetch-summarize-lane.js +1 -1
  14. package/dist/brain.js +32 -1
  15. package/dist/config-center/apply-effective.js +3 -2
  16. package/dist/config-types.d.ts +16 -1
  17. package/dist/config.js +68 -3
  18. package/dist/hooks/hook-llm.js +29 -20
  19. package/dist/hooks/hook-runner.js +3 -0
  20. package/dist/http/routes/capabilities.js +10 -1
  21. package/dist/http/routes/runs.js +4 -0
  22. package/dist/http/server.d.ts +12 -1
  23. package/dist/http/server.js +6 -3
  24. package/dist/leader/merge.js +4 -2
  25. package/dist/main.js +6 -2
  26. package/dist/memory-sync.js +15 -2
  27. package/dist/observability/fail-open.d.ts +10 -2
  28. package/dist/observability/fail-open.js +10 -2
  29. package/dist/orchestration/hardened-vm-runner.d.ts +29 -0
  30. package/dist/orchestration/hardened-vm-runner.js +52 -4
  31. package/dist/plugins/remote-env-ssh.d.ts +3 -0
  32. package/dist/plugins/remote-env-ssh.js +20 -1
  33. package/dist/plugins/send-user-file.js +2 -1
  34. package/dist/plugins/tidb-session-storage.d.ts +8 -6
  35. package/dist/plugins/tidb-session-storage.js +2 -5
  36. package/dist/rules-consent.d.ts +19 -16
  37. package/dist/rules-consent.js +18 -1
  38. package/dist/run-local.js +6 -1
  39. package/dist/session-titler.js +2 -1
  40. package/dist/sighup-idle.d.ts +16 -2
  41. package/dist/sighup-idle.js +10 -1
  42. package/dist/ssh-host-key.d.ts +65 -0
  43. package/dist/ssh-host-key.js +117 -0
  44. package/dist/text-bidi.d.ts +50 -0
  45. package/dist/text-bidi.js +8 -0
  46. package/dist/tool-approval.d.ts +56 -2
  47. package/dist/tool-approval.js +95 -14
  48. package/package.json +2 -2
@@ -24,14 +24,34 @@ function scrubProtoDeep(value) {
24
24
  scrubProtoDeep(value[key]);
25
25
  }
26
26
  }
27
+ export function isScriptRealmRejection(promise) {
28
+ return !(promise instanceof Promise);
29
+ }
30
+ export function describeRejectionReason(reason) {
31
+ if (typeof reason === "object")
32
+ return reason === null ? "null" : "<object reason withheld: coercing it would run script-controlled code>";
33
+ if (typeof reason === "string")
34
+ return `string: ${reason.length > 200 ? `${reason.slice(0, 200)}…` : reason}`;
35
+ if (typeof reason === "number" || typeof reason === "boolean")
36
+ return `${typeof reason}: ${reason}`;
37
+ if (typeof reason === "bigint")
38
+ return `bigint: ${reason.toString()}`;
39
+ if (typeof reason === "undefined")
40
+ return "undefined";
41
+ if (typeof reason === "symbol")
42
+ return String(reason);
43
+ if (typeof reason === "function")
44
+ return "<function reason withheld>";
45
+ return `<unknown typeof ${typeof reason}>`;
46
+ }
27
47
  let rejectionGuardInstalled = false;
28
48
  export function installScriptRealmRejectionGuard() {
29
49
  if (rejectionGuardInstalled)
30
50
  return;
31
51
  rejectionGuardInstalled = true;
32
52
  process.on("unhandledRejection", (reason, promise) => {
33
- if (!(promise instanceof Promise)) {
34
- const summary = reason instanceof Error ? `${reason.name}: ${reason.message}` : String(reason);
53
+ if (isScriptRealmRejection(promise)) {
54
+ const summary = describeRejectionReason(reason);
35
55
  try {
36
56
  console.warn(`hardened-vm: un-awaited script rejection contained: ${summary}`);
37
57
  }
@@ -125,6 +145,25 @@ async function runHardened(opts) {
125
145
  }
126
146
  });
127
147
  const membrane = { bridge, dataIn, ctxEval: (src) => vm.runInContext(src, ctx) };
148
+ const describeScriptThrow = (thrown) => {
149
+ try {
150
+ const summaryCtx = vm.createContext({ __proto__: null, e: thrown }, { codeGeneration: { strings: false, wasm: false } });
151
+ const summarized = vm.runInContext(`(() => {
152
+ try {
153
+ if (e !== null && (typeof e === "object" || typeof e === "function") && typeof e.message === "string") {
154
+ return (typeof e.name === "string" ? e.name + ": " : "") + e.message;
155
+ }
156
+ return String(e);
157
+ } catch (inner) {
158
+ return "<script error message unavailable: summarizing it threw in-context>";
159
+ }
160
+ })()`, summaryCtx, { timeout: cfg.syncTimeoutMs });
161
+ return typeof summarized === "string" ? summarized : "<script error message unavailable: not a string>";
162
+ }
163
+ catch {
164
+ return "<script error message unavailable: summarizing it threw or timed out in-context>";
165
+ }
166
+ };
128
167
  for (const [name, value] of Object.entries(opts.buildGlobals(membrane)))
129
168
  sandbox[name] = value;
130
169
  let invocation;
@@ -135,7 +174,13 @@ async function runHardened(opts) {
135
174
  throw new WorkflowScriptError(`script failed to compile: ${err instanceof Error ? err.message : String(err)}`);
136
175
  }
137
176
  const scriptPromise = invocation.runInContext(ctx, { timeout: cfg.syncTimeoutMs });
138
- scriptPromise.then(() => (scriptSettled = true), () => (scriptSettled = true));
177
+ let scriptThrew = false;
178
+ let scriptThrownValue;
179
+ scriptPromise.then(() => (scriptSettled = true), (thrown) => {
180
+ scriptSettled = true;
181
+ scriptThrew = true;
182
+ scriptThrownValue = thrown;
183
+ });
139
184
  probeQuiescence();
140
185
  let timer;
141
186
  let onAbort;
@@ -155,9 +200,12 @@ async function runHardened(opts) {
155
200
  return marshalOut(result);
156
201
  }
157
202
  catch (err) {
203
+ if (scriptThrew && err === scriptThrownValue) {
204
+ throw new WorkflowScriptError(`script failed at runtime: ${describeScriptThrow(err)}`);
205
+ }
158
206
  if (err instanceof WorkflowScriptError)
159
207
  throw err;
160
- throw new WorkflowScriptError(`script failed at runtime: ${err instanceof Error ? err.message : String(err)}`);
208
+ throw new WorkflowScriptError(`script failed at runtime: ${err instanceof Error ? err.message : describeScriptThrow(err)}`);
161
209
  }
162
210
  finally {
163
211
  if (timer)
@@ -1,3 +1,4 @@
1
+ import { type SshHostKeyPolicy } from "../ssh-host-key.js";
1
2
  import { FileError, ExecutionError, RemoteExecutionError, type ExecutionEnv, type RemoteExecutionEnv, type WorkspaceHandle, type FileInfo, type Result, type OutputChunk, type ExecStreamOptions, type RemoteConnectConfig, type SnapshotId, type SessionToken, type VmLifecycleOptions, type ExecutionEnvFactory } from "@sema-agent/core";
2
3
  type ExecOpts = Parameters<ExecutionEnv["exec"]>[1];
3
4
  export interface SshEnvConfig {
@@ -32,6 +33,8 @@ export interface SshEnvConfig {
32
33
  maxAttempts?: number;
33
34
  backoffMs?: number;
34
35
  };
36
+ /** #296 / C-R21:主机密钥校验策略(两旋钮,语义见 {@link SshHostKeyPolicy});缺席 = 两旋钮都没配。 */
37
+ hostKey?: SshHostKeyPolicy;
35
38
  }
36
39
  type ConnectResult = {
37
40
  ok: true;
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { Client } from "ssh2";
3
+ import { createSshHostKeyVerifier } from "../ssh-host-key.js";
3
4
  import { shellQuote, kindFromMode } from "./remote-shell.js";
4
5
  import { signalNumber } from "./host-platform.js";
5
6
  import { createPosixShellFs } from "./posix-shell-fs.js";
@@ -37,6 +38,7 @@ export class RemoteSshExecutionEnv {
37
38
  commandTimeoutMs: config.commandTimeoutMs ?? 5 * 60_000,
38
39
  dataTimeoutMs: config.dataTimeoutMs ?? 5 * 60_000,
39
40
  retry: { maxAttempts: config.retry?.maxAttempts ?? 3, backoffMs: config.retry?.backoffMs ?? 500 },
41
+ hostKey: config.hostKey,
40
42
  };
41
43
  this.cwd = this.cfg.mountPath;
42
44
  }
@@ -136,6 +138,12 @@ export class RemoteSshExecutionEnv {
136
138
  if (signal?.aborted)
137
139
  return resolve({ ok: false, error: new RemoteExecutionError("aborted", "connect aborted") });
138
140
  const c = new Client();
141
+ let hostKeyRejection;
142
+ const verifyHostKey = createSshHostKeyVerifier({
143
+ host: this.cfg.host,
144
+ port: this.cfg.port,
145
+ ...(this.cfg.hostKey !== undefined ? { policy: this.cfg.hostKey } : {}),
146
+ });
139
147
  const opts = {
140
148
  host: this.cfg.host,
141
149
  port: this.cfg.port,
@@ -143,6 +151,14 @@ export class RemoteSshExecutionEnv {
143
151
  privateKey: this.cfg.privateKey,
144
152
  ...(this.cfg.passphrase ? { passphrase: this.cfg.passphrase } : {}),
145
153
  readyTimeout: this.cfg.readyTimeoutMs,
154
+ hostVerifier: (key) => {
155
+ const verdict = verifyHostKey(key);
156
+ if (!verdict.ok) {
157
+ hostKeyRejection = verdict.reason;
158
+ return false;
159
+ }
160
+ return true;
161
+ },
146
162
  };
147
163
  c.on("ready", () => {
148
164
  if (this.destroyed || epoch !== this.connEpoch) {
@@ -168,7 +184,10 @@ export class RemoteSshExecutionEnv {
168
184
  }
169
185
  catch {
170
186
  }
171
- resolve({ ok: false, error: this.connErr(e) });
187
+ resolve({
188
+ ok: false,
189
+ error: hostKeyRejection !== undefined ? new RemoteExecutionError("connect_failed", hostKeyRejection, e) : this.connErr(e),
190
+ });
172
191
  });
173
192
  try {
174
193
  c.connect(opts);
@@ -1,5 +1,6 @@
1
1
  import { createHmac } from "node:crypto";
2
2
  import { uuidv7 } from "@sema-agent/core";
3
+ import { CONTROL_AND_BIDI_STRIP_RE } from "../text-bidi.js";
3
4
  import { presignS3CopyUrl, presignS3Url } from "./s3-presign.js";
4
5
  import { OBJECT_STORE_IO_TIMEOUT_MS } from "./blob-backend.js";
5
6
  export const PRESIGN_MAX_TTL_SEC = 604_800;
@@ -10,7 +11,7 @@ export function scopeSegment(secretKey, scope) {
10
11
  }
11
12
  export function sanitizeSendFileName(name) {
12
13
  const base = (name ?? "").split(/[/\\]/).pop() ?? "";
13
- let clean = base.replace(/[\u0000-\u001f\u007f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "").replace(/^\.+/, "").trim();
14
+ let clean = base.replace(CONTROL_AND_BIDI_STRIP_RE, "").replace(/^\.+/, "").trim();
14
15
  if (clean.length > 128) {
15
16
  const dot = clean.lastIndexOf(".");
16
17
  const ext = dot > 0 && clean.length - dot <= 16 ? clean.slice(dot) : "";
@@ -3,10 +3,13 @@ import type { Pool } from "mysql2/promise";
3
3
  /**
4
4
  * TiDB-backed {@link BaseSessionStorage} — the durable L1 event log for one session.
5
5
  *
6
- * - `appendEntry` / `setLeafId` persist with an **atomic compare-and-set** on `leaf_seq` + `leaf_id`
7
- * (core's F2 optimistic-lock seam): a write only lands if the branch leaf is still what the entry
8
- * was built against. Two stateless runners that both woke the same session can't silently fork —
9
- * the loser gets `SessionError("conflict")` (catch with `isSessionConflict`).
6
+ * - `appendEntry` / `setLeafId` persist under an **atomic compare-and-set on `leaf_id`** (core's F2
7
+ * optimistic-lock seam): the conditional UPDATE's only predicate is `leaf_id <=> ?`, so a write lands
8
+ * only if the branch leaf is still what the entry was built against. Two stateless runners that both
9
+ * woke the same session can't silently fork — the loser gets `SessionError("conflict")` (catch with
10
+ * `isSessionConflict`), with the `(session_id, seq)` primary key as the backstop for a racing writer
11
+ * that grabbed the same seq. (A-071 件2 更正:`leaf_seq` **不是**判据 —— 它是无条件的 `SET leaf_seq =
12
+ * leaf_seq + 1` 计数器,在同一事务内重读出来给事件铸 `seq`,从不进 WHERE。)
10
13
  * - The in-memory base tree is the wake-time snapshot; every write is also mirrored into it so the
11
14
  * live harness keeps reading a consistent view. Wake is **bounded** to `[floor .. leaf]` (F3,
12
15
  * core 1.6.0 branch-floor seam); older history is covered by the last compaction's summary.
@@ -14,9 +17,8 @@ import type { Pool } from "mysql2/promise";
14
17
  export declare class TiDBSessionStorage extends BaseSessionStorage {
15
18
  private readonly pool;
16
19
  private readonly sessionId;
17
- private leafSeq;
18
20
  private readonly floorSeq;
19
- constructor(pool: Pool, sessionId: string, metadata: SessionMetadata, entries: SessionTreeEntry[], leafId: string | null, leafSeq: number, floorEntryId?: string | null, floorSeq?: number);
21
+ constructor(pool: Pool, sessionId: string, metadata: SessionMetadata, entries: SessionTreeEntry[], leafId: string | null, floorEntryId?: string | null, floorSeq?: number);
20
22
  /**
21
23
  * [976] S3 RB-14 pull seam — `SessionStorage.getEpochAnchor?()`: the nearest epoch carrier BELOW the
22
24
  * bounded-wake floor. core's `Session.getPromptEpoch()` (≥1.305) calls this only when the in-window
@@ -7,13 +7,11 @@ const isDupKey = isMysqlDupKeyError;
7
7
  export class TiDBSessionStorage extends BaseSessionStorage {
8
8
  pool;
9
9
  sessionId;
10
- leafSeq;
11
10
  floorSeq;
12
- constructor(pool, sessionId, metadata, entries, leafId, leafSeq, floorEntryId = null, floorSeq = 0) {
11
+ constructor(pool, sessionId, metadata, entries, leafId, floorEntryId = null, floorSeq = 0) {
13
12
  super(metadata, entries, leafId, { floorEntryId });
14
13
  this.pool = pool;
15
14
  this.sessionId = sessionId;
16
- this.leafSeq = leafSeq;
17
15
  this.floorSeq = floorSeq;
18
16
  }
19
17
  async getEpochAnchor() {
@@ -43,7 +41,7 @@ export class TiDBSessionStorage extends BaseSessionStorage {
43
41
  const entries = eventRows.map((r) => typeof r.payload === "string" ? JSON.parse(r.payload) : r.payload);
44
42
  const placement = placementFromRow(meta);
45
43
  const metadata = { id: sessionId, createdAt: toIso(meta.created_at), ...(placement !== undefined ? { placement } : {}) };
46
- return new TiDBSessionStorage(pool, sessionId, metadata, entries, meta.leaf_id ?? null, Number(meta.leaf_seq), floorEntryId, floorSeq);
44
+ return new TiDBSessionStorage(pool, sessionId, metadata, entries, meta.leaf_id ?? null, floorEntryId, floorSeq);
47
45
  }
48
46
  static async computeFloor(pool, sessionId) {
49
47
  const [compRows] = await pool.query("SELECT payload FROM session_event WHERE session_id = ? AND type = 'compaction' ORDER BY seq DESC LIMIT 1", [sessionId]);
@@ -96,7 +94,6 @@ export class TiDBSessionStorage extends BaseSessionStorage {
96
94
  const commitOwner = seqRows[0].owner ?? null;
97
95
  await conn.query("INSERT INTO session_event (session_id, seq, entry_id, parent_id, type, payload, ts) VALUES (?,?,?,?,?,?,?)", [this.sessionId, seq, entry.id, entry.parentId ?? null, entry.type, JSON.stringify(entry), new Date(entry.timestamp)]);
98
96
  await conn.commit();
99
- this.leafSeq = seq;
100
97
  emitLeafAdvance(this.sessionId, newLeaf, commitOwner, seq);
101
98
  }
102
99
  catch (err) {
@@ -1,4 +1,4 @@
1
- import { type RuleOwner, type ImportPreview, type ImportResult, type ImportedSettingsLayer, type PersistedAllowRule, type RemoveResult, type RuleScope } from "@sema-agent/core";
1
+ import { type RuleOwner, type ImportPreview, type ImportResult, type ImportedSettingsLayer, type PersistedAllowRule, type RemoveResult, type EditedRuleTextPrecheck, type RuleScope } from "@sema-agent/core";
2
2
  import type { PermissionRuleStoreProvider, RuleApprovalRecordStore } from "@sema-agent/core";
3
3
  import { type RuleImportTicket, type RuleTicketDecisionClock, type RuleTicketRedeemResult } from "./plugins/permission-rule-store-sql.js";
4
4
  /**
@@ -107,13 +107,7 @@ export type RuleImportRedeemed = {
107
107
  detail: string;
108
108
  retryable?: true;
109
109
  };
110
- /**
111
- * #340([4763])—— 一次卡道兑付**要兑的是什么**:两个显式臂,永不折叠。
112
- *
113
- * 🔴 判别式联合而不是「一个文本 + 一个布尔」:两个臂的文本**含义不同**——候选臂的串是「卡上第几条」的
114
- * 定位键(对不上 ⇒ `rule_not_offered`,语义一个字不变),编辑臂的串是**人自己写的规则**(由 core 的
115
- * 卡编辑面校验 + 覆盖门判)。同一个字段两种含义会让下一个读者(和下一次改动)分不清哪条路上该做哪道门。
116
- */
110
+ export declare function precheckCardRuleText(text: string, command: string): EditedRuleTextPrecheck | undefined;
117
111
  export type CardRuleRedemption =
118
112
  /** 人**点了卡上的某条候选**:文本用来在引擎铸的候选表里定位下标。 */
119
113
  {
@@ -143,17 +137,26 @@ export type CardRulePersisted = {
143
137
  * #340:人写的文本**被引擎的门拒了**——共享校验器不认这个拼写,或它不覆盖本次被裁决的命令
144
138
  * (「编辑可以更宽,但不许换成另一条授权」)。
145
139
  *
146
- * 🔴 **这一格不是「重试就好」**(codex 对抗复审 [medium],验真后按实况改词):兑付发生在裁决
147
- * **落定之后**(那条顺序是硬的 —— 一次没有生效的裁决不该留下一条永久规则),所以拿到这个词时
148
- * 这张卡已经消费掉了,同一个 `approvalId` 再回决是 404。人的「允许这次」照常生效,只是这一次
149
- * 没能顺手存下规则;改好的文本要落,得等**下一次同命令的 ask**( `rule_input_edited` 逐字
150
- * 同一条处置)。壳的措辞据此写,别渲成「点这里重试」。
151
- * 想做到「同一张卡上改了再来」需要一次**裁决之前**的预检,而那要求 core 把编辑面的校验(含它自己
152
- * 的拼写规范化)导出成一只纯函数 —— 在本层照抄一个更严的校验器会把 `Bash(adb *)` 这条**成因形**
153
- * 当场误拒。已登记为上游请托,不在本层用第二个解析器凑。
140
+ * 🔴 **射程自 #345 起收窄**(core 5.57.0 `precheckEditedRuleText` 到货 —— 下面那条上游请托兑现了)
141
+ * 原话保留作史:「兑付发生在裁决**落定之后**,所以拿到这个词时这张卡已经消费掉了;想做到『同一张
142
+ * 卡上改了再来』需要一次**裁决之前**的预检,而那要求 core 把编辑面的校验(含它自己的拼写规范化)
143
+ * 导出成一只纯函数 —— 在本层照抄一个更严的校验器会把 `Bash(adb *)` 这条**成因形**当场误拒。」
144
+ *
145
+ * 现况:那只纯函数到货了,于是**文本能独立判定的那一半**({@link precheckEditedRuleText} 的语法门
146
+ * + 覆盖门)搬到了**裁决之前**——`POST /v1/tool-approvals/:id/respond` 在结算之前就 400,卡**还在**
147
+ * (与 `persistRule.rule` 的形/上限 400 同一姿势,那条 400 早就是这个形)。所以壳今天可以渲
148
+ * 「改一下再来」,而且真能来。
149
+ *
150
+ * 这个词因此只剩**纵深**的那一半:预检被跳过的路径(治理档 / 无车道素材 / 无属主 / ctrl+g 编辑放行
151
+ * 各自有自己的词,不到这里)与「预检过了但记录级仍拒」的残余竞态。到这个词时那句老话仍然成立
152
+ * ——卡已消费、同一把 approvalId 再回决是 404。
154
153
  */
155
154
  | "edit-rejected";
156
155
  detail: string;
156
+ /** #345:core 的**共享校验器**refusal code(`RuleRejectCode`,开集透传)。覆盖门拒时**缺席** ——
157
+ * 这条在场规则与 core `confirmRuleApproval` 的 `edit_rejected` detail 逐字同源(同一个函数体)。
158
+ * 🔴 不 switch、不翻译、不折叠:词表属主是 core,本仓照转。 */
159
+ code?: string;
157
160
  };
158
161
  /** 一层用户交上来的 settings(HTTP 载荷已 zod 校验过的形)。 */
159
162
  export interface RuleImportLayerInput {
@@ -2,11 +2,24 @@ import { randomUUID } from "node:crypto";
2
2
  import { recordFailOpen } from "./observability/fail-open.js";
3
3
  import { createLogger } from "./observability/logger.js";
4
4
  import { MAX_CWD_CHARS } from "./task-cwd.js";
5
- import { confirmRuleApproval, parseAllowRuleText, prepareCardApproval, prepareCcImport, redeemRuleBatch, redeemRuleTicket, removePersistedRule, } from "@sema-agent/core";
5
+ import { confirmRuleApproval, parseAllowRuleText, precheckEditedRuleText, prepareCardApproval, prepareCcImport, redeemRuleBatch, redeemRuleTicket, removePersistedRule, } from "@sema-agent/core";
6
6
  import { buildRulePayloadHash } from "./plugins/permission-rule-store-sql.js";
7
7
  export const RULE_IMPORT_TICKET_TTL_MS = 10 * 60_000;
8
8
  export const RULE_IMPORT_RETRY_AFTER_SEC = 2;
9
9
  export const MAX_IMPORT_CANDIDATES = 200;
10
+ const precheckLogger = createLogger();
11
+ export function precheckCardRuleText(text, command) {
12
+ try {
13
+ return precheckEditedRuleText(text, command);
14
+ }
15
+ catch (e) {
16
+ precheckLogger.warn("card_rule_precheck_unanswerable", {
17
+ err: String(e),
18
+ note: "the edit-face precheck could not be asked about these inputs — falling through to the authoritative gate inside confirmRuleApproval (same body, later)",
19
+ });
20
+ return undefined;
21
+ }
22
+ }
10
23
  export function serializeRuleScope(scope) {
11
24
  return scope.kind === "global" ? "global" : `project:${scope.root}`;
12
25
  }
@@ -128,6 +141,10 @@ export function createRuleConsentLane(stores, opts) {
128
141
  if (input.boundInputHash === undefined) {
129
142
  return { ok: false, reason: "edit-unsupported", detail: "this approval card carries no bound-input digest — an edited rule has nothing to bind to" };
130
143
  }
144
+ const pre = precheckCardRuleText(redemption.text, input.command);
145
+ if (pre !== undefined && !pre.ok) {
146
+ return { ok: false, reason: "edit-rejected", detail: pre.message, ...(pre.code !== undefined ? { code: pre.code } : {}) };
147
+ }
131
148
  const confirmed = await confirmRuleApproval({
132
149
  approvalId: prepared.approvalId,
133
150
  principal: input.principal,
package/dist/run-local.js CHANGED
@@ -285,6 +285,8 @@ export async function runLocal(argv, deps = {}) {
285
285
  memoryRoot: memoryEngine.root,
286
286
  transport: createMemorySyncTransport({ url: config.memorySync.url, token: config.memorySync.token }),
287
287
  log: logger,
288
+ ...(config.memorySync.maxPushEntries !== undefined ? { maxPushEntries: config.memorySync.maxPushEntries } : {}),
289
+ ...(config.memorySync.maxPullEntries !== undefined ? { maxPullEntries: config.memorySync.maxPullEntries } : {}),
288
290
  });
289
291
  logger.info("memory_sync_enabled", { url: config.memorySync.url, scope: config.memorySync.scope, cursorPath: memorySync.cursorPath });
290
292
  memorySync.trigger("boot");
@@ -378,7 +380,10 @@ export async function runLocal(argv, deps = {}) {
378
380
  backgroundAgentStore: undefined,
379
381
  mailboxStore: undefined,
380
382
  rosterStore: undefined,
381
- deploymentHooks: createPermissionDeniedMeter(metrics),
383
+ deploymentHooks: {
384
+ ...createPermissionDeniedMeter(metrics),
385
+ ...(config.hooksTimeoutMs !== undefined ? { timeoutMs: config.hooksTimeoutMs } : {}),
386
+ },
382
387
  toolResultStore: fileBackend.toolResultStore,
383
388
  sessionPolicyStore: fileBackend.sessionPolicyStore,
384
389
  usageWindowStore: config.usageWindows ? fileBackend.usageWindowStore : undefined,
@@ -1,3 +1,4 @@
1
+ import { CONTROL_AND_BIDI_STRIP_RE } from "./text-bidi.js";
1
2
  export const TITLE_MAX_CHARS = 80;
2
3
  const OBJECTIVE_SNIPPET_CHARS = 600;
3
4
  const LLM_TIMEOUT_MS = 15_000;
@@ -6,7 +7,7 @@ const FAIL_CAUSE_KEY_CHARS = 80;
6
7
  const FAIL_CAUSE_CAP = 100;
7
8
  const SEEN_CAP = 10_000;
8
9
  export function sanitizeTitle(raw) {
9
- const oneLine = raw.replace(/[\r\n]+/g, " ").replace(/[\u0000-\u001f\u007f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "").trim();
10
+ const oneLine = raw.replace(/[\r\n]+/g, " ").replace(CONTROL_AND_BIDI_STRIP_RE, "").trim();
10
11
  const unquoted = oneLine.replace(/^["'「『]+/, "").replace(/["'」』]+$/, "").trim();
11
12
  return unquoted.length > TITLE_MAX_CHARS ? `${unquoted.slice(0, TITLE_MAX_CHARS - 1)}…` : unquoted;
12
13
  }
@@ -36,8 +36,22 @@ export interface SighupIdleDeps {
36
36
  * 本文件不对外报告 epoch 时间戳,故 Date.now() 一律不再出现。 */
37
37
  now?: () => number;
38
38
  }
39
+ /**
40
+ * SIGHUP 处理器 + 它自己那只空闲窗的**停表口**。
41
+ *
42
+ * 🔴 `stop()` 不是可选装饰(codex 对抗复审 F2,已复现):armed 之后这只 interval **刻意不 unref**
43
+ * (孤儿形下它就是关停驱动),而它握着的 `shutdown`/`escalate` 是**装配那一刻**的闭包。只摘 SIGHUP 监听、
44
+ * 不停表 = 一个已经退役的装配仍会在 grace 到期时按旧 ctx 关停进程(实测:arm 后立刻摘监听,1.1s 后旧
45
+ * ctx 的 `server.close` 照样被调)。注册/摘除配对必须覆盖它。
46
+ */
47
+ export interface SighupIdleHandler {
48
+ /** 信号处理函数本体:每次 SIGHUP 调一次(首次 arm 空闲窗,第二次 escalate)。 */
49
+ (): void;
50
+ /** 停掉已 armed 的空闲窗(幂等;没 arm 过时无操作)。 */
51
+ stop(): void;
52
+ }
39
53
  /** Returns the SIGHUP handler. Call it on every SIGHUP; it arms the idle watch on the first signal and
40
54
  * escalates on the second. The interval is deliberately NOT unref'd — in the orphan case it IS the
41
- * shutdown driver (same posture as the drain tick). */
42
- export declare function createSighupIdleHandler(deps: SighupIdleDeps): () => void;
55
+ * shutdown driver (same posture as the drain tick); {@link SighupIdleHandler.stop} is the退役口. */
56
+ export declare function createSighupIdleHandler(deps: SighupIdleDeps): SighupIdleHandler;
43
57
  //# sourceMappingURL=sighup-idle.d.ts.map
@@ -4,7 +4,7 @@ export function createSighupIdleHandler(deps) {
4
4
  let watch;
5
5
  let idleSince = null;
6
6
  let armedAt = 0;
7
- return () => {
7
+ const onSignal = () => {
8
8
  if (deps.isStopped()) {
9
9
  deps.log("sighup_ignored_shutdown_in_progress", {});
10
10
  return;
@@ -50,5 +50,14 @@ export function createSighupIdleHandler(deps) {
50
50
  }
51
51
  }, tickMs);
52
52
  };
53
+ return Object.assign(onSignal, {
54
+ stop: () => {
55
+ if (watch === undefined)
56
+ return;
57
+ clearInterval(watch);
58
+ watch = undefined;
59
+ idleSince = null;
60
+ },
61
+ });
53
62
  }
54
63
  //# sourceMappingURL=sighup-idle.js.map
@@ -0,0 +1,65 @@
1
+ /**
2
+ * #296(A-052.2;clay 裁定 **C-R21** 形:「响亮警告继续连」)—— SSH lane 的**主机密钥校验**两旋钮。
3
+ *
4
+ * ## 病灶(修前逐字形)
5
+ * `connectOnce` 的 `ConnectConfig` 从来不设 `hostVerifier`,而 ssh2 在缺席时**不做任何主机密钥校验** ——
6
+ * 这条 lane 于是恒等于 `ssh -o StrictHostKeyChecking=no`:握手对面是谁都照连、照把 agent 的命令与
7
+ * sftp 写入交出去。适配器持有的是控制面注入的私钥(design/61 §5 #2),中间人拿不到私钥,但**拿得到
8
+ * 这次会话的全部命令、全部产物、以及 agent 在那台机器上的全部动作**。
9
+ *
10
+ * ## 为什么不是「一律拒连」(裁定形)
11
+ * 本 lane 的既有部署形是「AI 编排批量部署到一批 SSH 可达的机器」,那些机器的密钥指纹通常不在任何
12
+ * known_hosts 里。默认拒连 = 一次静默的**可用性**断裂,而 C-R21 裁的是:**在场即严格,缺席即响亮**。
13
+ * 缺席那一格是登记在案的 fail-open(`server.exec-lane.ssh-host-key-unverified`,census §1),不是遗漏。
14
+ *
15
+ * ## 两旋钮(config 面 `SSH_HOST_FINGERPRINT` / `SSH_KNOWN_HOSTS`,坏值拒启 —— #210 律)
16
+ * · `fingerprintSha256` —— 主机公钥的 **SHA256 base64** 指纹(`ssh-keyscan` + `ssh-keygen -lf` 打印的
17
+ * 那一串)。归一后存**裸 base64**(无 `SHA256:` 前缀、无 `=` 填充);
18
+ * · `knownHostsPath` —— known_hosts 文件路径,按 `host` / `[host]:port` **逐字**匹配行。
19
+ *
20
+ * **两只同时在场 = 合取**(都必须过)。任一在场 ⇒ 严格校验,不符**拒连**;两只都缺席 ⇒ 每连接一条
21
+ * 响亮 warn + 计数,照常连。
22
+ *
23
+ * ## 明确**不支持**的 known_hosts 语法(成文,不是遗漏 —— 遇到即拒连并在拒因里说清)
24
+ * · **hashed 行**(`|1|<salt>|<hash>`,`ssh-keyscan -H` / `HashKnownHosts yes` 的产物):要按 HMAC-SHA1
25
+ * 反查主机名,而本适配器刻意不实现那一步(它把「读一个文件、比一串字节」变成一个需要自己实现
26
+ * OpenSSH 私有派生的解析器)。⇒ hashed 行一律**跳过**;若某台机器只有 hashed 行,结果是**拒连**
27
+ * (fail-closed,不是悄悄放行),拒因里逐字点名这一条并给出 `ssh-keyscan` 指路;
28
+ * · **通配符 pattern**(`*.example.com`、`!host` 取反):同上跳过 ⇒ 只有通配符行的主机会被拒连;
29
+ * · **`@cert-authority` 行**:证书信任链本适配器不实现 ⇒ 跳过(同样是拒连方向)。
30
+ * · **`@revoked` 行**:不跳过 —— 匹配上就是**硬拒**(吊销是收紧方向,跳过它才是 fail-open)。
31
+ */
32
+ export interface SshHostKeyPolicy {
33
+ /** 归一后的**裸 base64** SHA256 指纹(无 `SHA256:` 前缀、无 `=` 填充)。 */
34
+ fingerprintSha256?: string;
35
+ /** known_hosts 文件路径。 */
36
+ knownHostsPath?: string;
37
+ }
38
+ /** {@link createSshHostKeyVerifier} 的判词。`verified:false` = 两旋钮都缺席那一格(照连,但响亮)。 */
39
+ export type SshHostKeyVerdict = {
40
+ ok: true;
41
+ verified: boolean;
42
+ } | {
43
+ ok: false;
44
+ reason: string;
45
+ };
46
+ /** SHA256 base64 指纹的**归一**:`SHA256:` 前缀可选、`=` 填充可选;形不合 ⇒ **抛**(调用方在 boot 期拒启)。 */
47
+ export declare function normalizeSshFingerprint(raw: string): string;
48
+ /** ssh2 交来的**主机公钥 wire blob** → `SHA256:<base64>`(OpenSSH 打印的同一形,无 `=` 填充)。 */
49
+ export declare function sshHostKeyFingerprint(key: Buffer): string;
50
+ /**
51
+ * 主机密钥判官(#296 / C-R21)。返回一个**收 ssh2 的公钥 blob、给判词**的闭包(有捕获行为 ⇒ `create*`)。
52
+ *
53
+ * 缺席那一格调用 `onUnverified` —— **每连接一次**,不是每进程一次:一台长期未配旋钮的机器上,
54
+ * 「这条 lane 一直在裸连」必须在每一条连接上都看得见,否则它会随第一条日志滚出视野。
55
+ */
56
+ export declare function createSshHostKeyVerifier(opts: {
57
+ host: string;
58
+ port: number;
59
+ policy?: SshHostKeyPolicy;
60
+ /** 测试缝:替掉 known_hosts 的读盘(缺省 `readFileSync`)。 */
61
+ readKnownHosts?: (p: string) => string;
62
+ /** 两旋钮全缺席时的响亮口(缺省 = 本模块 logger 的一条 warn + fail-open 计数)。 */
63
+ onUnverified?: (message: string) => void;
64
+ }): (key: Buffer) => SshHostKeyVerdict;
65
+ //# sourceMappingURL=ssh-host-key.d.ts.map
@@ -0,0 +1,117 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { createLogger } from "./observability/logger.js";
4
+ import { recordFailOpen } from "./observability/fail-open.js";
5
+ const sshLogger = createLogger();
6
+ export function normalizeSshFingerprint(raw) {
7
+ const bare = raw.trim().replace(/^SHA256:/i, "").replace(/=+$/, "");
8
+ if (!/^[A-Za-z0-9+/]{43}$/.test(bare)) {
9
+ throw new Error(`SSH host fingerprint must be a SHA256 base64 digest (43 base64 chars, optional "SHA256:" prefix / "=" padding) — got ${JSON.stringify(raw)}. ` +
10
+ `Read the real one with: ssh-keyscan -p <port> <host> | ssh-keygen -lf -`);
11
+ }
12
+ return bare;
13
+ }
14
+ export function sshHostKeyFingerprint(key) {
15
+ return `SHA256:${createHash("sha256").update(key).digest("base64").replace(/=+$/, "")}`;
16
+ }
17
+ function knownHostsPatternsFor(host, port) {
18
+ return port === 22 ? [host, `[${host}]:22`] : [`[${host}]:${port}`];
19
+ }
20
+ function matchKnownHosts(text, host, port, keyB64) {
21
+ const wanted = new Set(knownHostsPatternsFor(host, port));
22
+ let sawEntry = false;
23
+ let plainMatch = false;
24
+ for (const rawLine of text.split(/\r?\n/)) {
25
+ const line = rawLine.trim();
26
+ if (line === "" || line.startsWith("#"))
27
+ continue;
28
+ let fields = line.split(/\s+/);
29
+ let revoked = false;
30
+ if (fields[0]?.startsWith("@")) {
31
+ const marker = fields[0];
32
+ if (marker !== "@revoked")
33
+ continue;
34
+ revoked = true;
35
+ fields = fields.slice(1);
36
+ }
37
+ const [patterns, , blob] = fields;
38
+ if (patterns === undefined || blob === undefined)
39
+ continue;
40
+ if (revoked && blob === keyB64 && (patterns.startsWith("|") || /[*?!]/.test(patterns)))
41
+ return "revoked";
42
+ if (patterns.startsWith("|"))
43
+ continue;
44
+ const names = patterns.split(",");
45
+ if (!names.some((n) => wanted.has(n)))
46
+ continue;
47
+ sawEntry = true;
48
+ if (blob !== keyB64)
49
+ continue;
50
+ if (revoked)
51
+ return "revoked";
52
+ plainMatch = true;
53
+ }
54
+ if (plainMatch)
55
+ return "matched";
56
+ return sawEntry ? "mismatch" : "no-entry";
57
+ }
58
+ function keyscanHint(host, port) {
59
+ return `Read the host's real fingerprint with: ssh-keyscan -p ${port} ${host} | ssh-keygen -lf -`;
60
+ }
61
+ export function createSshHostKeyVerifier(opts) {
62
+ const { host, port, policy } = opts;
63
+ const readKnownHosts = opts.readKnownHosts ?? ((p) => readFileSync(p, "utf8"));
64
+ const fingerprint = policy?.fingerprintSha256;
65
+ const knownHostsPath = policy?.knownHostsPath;
66
+ const unverifiedMessage = `SSH host key NOT verified — man-in-the-middle risk: this connection to ${host}:${port} accepts ANY host key. ` +
67
+ `Set SSH_HOST_FINGERPRINT (SHA256 base64) or SSH_KNOWN_HOSTS (a known_hosts path) to verify it. ` +
68
+ keyscanHint(host, port);
69
+ const onUnverified = opts.onUnverified ??
70
+ ((message) => {
71
+ sshLogger.warn("ssh_host_key_unverified", { host, port, detail: message });
72
+ recordFailOpen("server.exec-lane.ssh-host-key-unverified", `${host}:${port}`);
73
+ });
74
+ return (key) => {
75
+ if (fingerprint === undefined && knownHostsPath === undefined) {
76
+ onUnverified(unverifiedMessage);
77
+ return { ok: true, verified: false };
78
+ }
79
+ const presented = sshHostKeyFingerprint(key);
80
+ if (fingerprint !== undefined && presented !== `SHA256:${fingerprint}`) {
81
+ return {
82
+ ok: false,
83
+ reason: `SSH host key rejected for ${host}:${port} — fingerprint mismatch. presented=${presented} expected=SHA256:${fingerprint} (SSH_HOST_FINGERPRINT). ` +
84
+ keyscanHint(host, port),
85
+ };
86
+ }
87
+ if (knownHostsPath !== undefined) {
88
+ let text;
89
+ try {
90
+ text = readKnownHosts(knownHostsPath);
91
+ }
92
+ catch (e) {
93
+ return {
94
+ ok: false,
95
+ reason: `SSH host key rejected for ${host}:${port} — SSH_KNOWN_HOSTS (${knownHostsPath}) could not be read at connect time: ` +
96
+ `${e instanceof Error ? e.message : String(e)}. presented=${presented}. ` +
97
+ keyscanHint(host, port),
98
+ };
99
+ }
100
+ const verdict = matchKnownHosts(text, host, port, key.toString("base64"));
101
+ if (verdict !== "matched") {
102
+ const why = verdict === "revoked"
103
+ ? "the presented key is listed @revoked"
104
+ : verdict === "mismatch"
105
+ ? "an entry for this host exists but carries a DIFFERENT key"
106
+ : `no usable entry for ${knownHostsPatternsFor(host, port).join(" / ")} (hashed "|1|" lines, wildcard patterns and @cert-authority entries are deliberately NOT supported — see SshHostKeyPolicy)`;
107
+ return {
108
+ ok: false,
109
+ reason: `SSH host key rejected for ${host}:${port} — ${why}. presented=${presented} source=SSH_KNOWN_HOSTS:${knownHostsPath}. ` +
110
+ keyscanHint(host, port),
111
+ };
112
+ }
113
+ }
114
+ return { ok: true, verified: true };
115
+ };
116
+ }
117
+ //# sourceMappingURL=ssh-host-key.js.map