@sema-agent/server 7.104.0 → 7.105.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 (52) hide show
  1. package/MIGRATION.md +32 -0
  2. package/USAGE.md +59 -10
  3. package/dist/boot/runner-deps.js +2 -1
  4. package/dist/config.d.ts +15 -2
  5. package/dist/config.js +42 -7
  6. package/dist/file-history-disclosure.d.ts +7 -31
  7. package/dist/file-history-disclosure.js +12 -0
  8. package/dist/fleet/fleet-bus.d.ts +5 -1
  9. package/dist/http/admission.d.ts +1 -1
  10. package/dist/http/dispatch.d.ts +18 -0
  11. package/dist/http/dispatch.js +17 -4
  12. package/dist/http/resume-legs.d.ts +1 -1
  13. package/dist/http/route-table.d.ts +15 -4
  14. package/dist/http/route-table.js +6 -5
  15. package/dist/http/routes/fleet.d.ts +3 -2
  16. package/dist/http/routes/images.js +0 -1
  17. package/dist/http/routes/runs.js +2 -8
  18. package/dist/http/routes/session-sync.js +6 -1
  19. package/dist/http/routes/sessions.js +5 -2
  20. package/dist/http/routes/side-query.js +8 -1
  21. package/dist/http/routes/trace-usage.d.ts +2 -0
  22. package/dist/http/routes/trace-usage.js +4 -9
  23. package/dist/http/server.d.ts +9 -4
  24. package/dist/http/server.js +4 -1
  25. package/dist/http/sse-lifecycle.d.ts +1 -1
  26. package/dist/http/sse-log.d.ts +12 -9
  27. package/dist/http/sse-log.js +1 -8
  28. package/dist/observability/fail-open.d.ts +5 -1
  29. package/dist/observability/fail-open.js +5 -1
  30. package/dist/observability/metrics.js +1 -1
  31. package/dist/observability/run-terminal.d.ts +45 -9
  32. package/dist/observability/run-terminal.js +115 -9
  33. package/dist/plugins/image-bake-store-sql.d.ts +0 -2
  34. package/dist/plugins/image-bake-store-sql.js +0 -5
  35. package/dist/plugins/retention-store-sql.js +1 -0
  36. package/dist/plugins/run-store-sql.d.ts +3 -2
  37. package/dist/plugins/schema-indexes.js +1 -0
  38. package/dist/plugins/session-policy-store-sql.js +18 -5
  39. package/dist/plugins/store-backend.d.ts +6 -4
  40. package/dist/plugins/tidb-pool.js +4 -3
  41. package/dist/read-face-posture.d.ts +21 -0
  42. package/dist/read-face-posture.js +9 -0
  43. package/dist/rules-consent.js +0 -11
  44. package/dist/session-policy-wire.d.ts +98 -12
  45. package/dist/session-policy-wire.js +71 -8
  46. package/dist/trace/project.d.ts +10 -5
  47. package/dist/trace/project.js +3 -3
  48. package/dist/trace/redact.d.ts +7 -0
  49. package/dist/trace/redact.js +153 -61
  50. package/package.json +1 -1
  51. package/dist/plugins/memory-session-policy-store.d.ts +0 -46
  52. package/dist/plugins/memory-session-policy-store.js +0 -52
@@ -1,11 +1,24 @@
1
- const RULE_FIELD_PRESENCE = {
2
- toolAllow: true,
3
- toolDeny: true,
4
- allowDirs: true,
5
- commandAllow: true,
6
- commandDeny: true,
1
+ import { PROTOCOL_TABLE, RETIRED_TOOL_NAMES, protocolOf } from "@sema-agent/core";
2
+ const RULE_FIELD_ROLE = {
3
+ toolAllow: "tool-name",
4
+ toolDeny: "tool-name",
5
+ allowDirs: "path",
6
+ commandAllow: "command",
7
+ commandDeny: "command",
7
8
  };
8
- export const SESSION_PERMISSION_RULE_FIELDS = Object.keys(RULE_FIELD_PRESENCE);
9
+ export const SESSION_PERMISSION_RULE_FIELDS = Object.keys(RULE_FIELD_ROLE);
10
+ const TOOL_NAME_RULE_FIELDS = SESSION_PERMISSION_RULE_FIELDS.filter((f) => RULE_FIELD_ROLE[f] === "tool-name");
11
+ export function legacyToolNameOf(name) {
12
+ const guidance = RETIRED_TOOL_NAMES.get(name);
13
+ if (guidance !== undefined)
14
+ return { why: "retired", guidance };
15
+ if (name.includes("__") && protocolOf(name) === undefined)
16
+ return { why: "unprefixed-namespace" };
17
+ return undefined;
18
+ }
19
+ function protocolToolNameShapes() {
20
+ return PROTOCOL_TABLE.map((ns) => `${ns.prefix}<peer>__<tool>`).join(" or ");
21
+ }
9
22
  export function readSessionPermissionRules(raw) {
10
23
  if (raw === null || typeof raw !== "object" || Array.isArray(raw))
11
24
  return { problem: { kind: "not-an-object" } };
@@ -21,6 +34,19 @@ export function readSessionPermissionRules(raw) {
21
34
  }
22
35
  return { rules };
23
36
  }
37
+ export function readWritableSessionPermissionRules(raw) {
38
+ const read = readSessionPermissionRules(raw);
39
+ if (read.problem !== undefined)
40
+ return read;
41
+ for (const field of TOOL_NAME_RULE_FIELDS) {
42
+ for (const name of read.rules[field] ?? []) {
43
+ const legacy = legacyToolNameOf(name);
44
+ if (legacy !== undefined)
45
+ return { problem: { kind: "legacy-tool-name", field, name, legacy } };
46
+ }
47
+ }
48
+ return read;
49
+ }
24
50
  export function readSessionRulesRecords(raw) {
25
51
  if (!Array.isArray(raw))
26
52
  return { problem: { kind: "not-an-array" } };
@@ -32,7 +58,7 @@ export function readSessionRulesRecords(raw) {
32
58
  const { principal, rules, rev } = e;
33
59
  if (principal !== undefined && typeof principal !== "string")
34
60
  return { problem: { kind: "principal-not-a-string", index } };
35
- const read = readSessionPermissionRules(rules);
61
+ const read = readWritableSessionPermissionRules(rules);
36
62
  if (read.problem !== undefined)
37
63
  return { problem: { kind: "rules", index, problem: read.problem } };
38
64
  const innerRev = rules?.rev;
@@ -45,12 +71,49 @@ export function describeSessionPermissionRulesProblem(p) {
45
71
  switch (p.kind) {
46
72
  case "not-an-object": return "rules must be a SessionPermissionRules object";
47
73
  case "field-not-string-array": return `${p.field} must be an array of strings`;
74
+ case "legacy-tool-name": return describeLegacyToolName(p.field, p.name, p.legacy);
48
75
  default: {
49
76
  const unhandled = p;
50
77
  throw new Error(`unhandled SessionPermissionRules problem: ${JSON.stringify(unhandled)}`);
51
78
  }
52
79
  }
53
80
  }
81
+ function describeLegacyToolName(field, name, legacy) {
82
+ switch (legacy.why) {
83
+ case "retired":
84
+ return `${field} names "${name}", a retired tool name (${legacy.guidance}); with that rule in place this session could not start another run, so nothing was written — use the current tool name`;
85
+ case "unprefixed-namespace":
86
+ return `${field} names "${name}", which contains "__" but no protocol prefix; with that rule in place this session could not start another run, so nothing was written — a protocol tool is named ${protocolToolNameShapes()}`;
87
+ default: {
88
+ const unhandled = legacy;
89
+ throw new Error(`unhandled legacy tool name arm: ${JSON.stringify(unhandled)}`);
90
+ }
91
+ }
92
+ }
93
+ export function legacyToolNameProblemOf(p) {
94
+ switch (p.kind) {
95
+ case "not-an-array":
96
+ case "record-not-an-object":
97
+ case "principal-not-a-string":
98
+ return undefined;
99
+ case "rules":
100
+ switch (p.problem.kind) {
101
+ case "not-an-object":
102
+ case "field-not-string-array":
103
+ return undefined;
104
+ case "legacy-tool-name":
105
+ return { field: p.problem.field, name: p.problem.name };
106
+ default: {
107
+ const unhandled = p.problem;
108
+ throw new Error(`unhandled SessionPermissionRules problem: ${JSON.stringify(unhandled)}`);
109
+ }
110
+ }
111
+ default: {
112
+ const unhandled = p;
113
+ throw new Error(`unhandled SessionRulesRecord problem: ${JSON.stringify(unhandled)}`);
114
+ }
115
+ }
116
+ }
54
117
  export function describeSessionRulesRecordProblem(p) {
55
118
  switch (p.kind) {
56
119
  case "not-an-array": return "must be an array of {principal, rules} records";
@@ -156,8 +156,9 @@ export declare function toolStartEventData(ev: {
156
156
  * 🔴 **筛子是一只总函数**(S-239,codex 对抗复审 r1 [medium] 亲核复现后修)—— 上面纪律②许诺的
157
157
  * 「坏形 ⇒ report + withhold,**不抛**」此前是靠**逐条枚举**坏形兑现的,枚举不到的那些照样抛:
158
158
  * `projectSettlement` 对 `note` / `who.approver` 先 `redactSecrets` 再截断,而 `redactSecrets` 会在
159
- * 病态输入上爆回溯栈(`RangeError`:≥2^23 单段那一形已由 S-219 的 `atLeast` 铸点闭掉,仍能抛的是 JSON 秘密字段臂的
160
- * 转义对循环,S-102/S-106 登记的既有上界,见 `redact.ts` 该行顶注),两条叶各能抛一次。
159
+ * 病态输入上爆回溯栈(`RangeError`:≥2^23 单段那一形已由 S-219 的 `atLeast` 铸点闭掉,JSON 秘密字段臂的转义对循环与
160
+ * PEM 栅栏臂的标签交替循环由 S-265 换成手写扫描器闭掉 —— 已知形全部闭掉,本段写的是「当时为什么要总函数化」),
161
+ * 两条叶各能抛一次。总函数化照留:它兑现的是「坏字节一律 withhold」这条**结构**,不依赖脱敏器今天抛不抛。
161
162
  *
162
163
  * 抛出去的代价按腿算,而且一条比一条重:写腿落账那处的 `.catch` 看不到**同步**投影期的异常;读腿上它
163
164
  * 直接逃出 `formatEvent` / 投影函数 —— 账本裸回放腿的那一次会当场断流(那一行不上帧、游标不前进、后面的
@@ -295,7 +296,9 @@ export declare function toolEndEventData(ev: {
295
296
  * **不等于** `tool_end` 的 duration)。
296
297
  *
297
298
  * ## 信任划线
298
- * · `toolCallId` / `toolName` —— 引擎铸的 id 与名册里的工具名(与 `tool_start` 逐字同源)⇒ verbatim。
299
+ * · `toolCallId` / `toolName` —— 调用 id(🔴 S-283 订正:**服务商**在 tool_use / tool_calls 里给的,引擎只在缺席时自铸兜底 ——
300
+ * 此前写「引擎铸」不实)与名册里的工具名(与 `tool_start` 逐字同源)⇒ verbatim。调用 id 这一族在**逐帧面**上的形状闸
301
+ * 不在 S-283 射程(它是全 wire 的 join 键,要闸得所有面一起闸,见 run-terminal `admitProviderEcho` 顶注与车QO 收车档)。
299
302
  * · `elapsedTimeSeconds` —— 引擎铸的整秒,单调、一 call 一个原点 ⇒ 有限数才铸键。
300
303
  * · `output` —— 🔴 **UNTRUSTED RAW**,core 逐字「exactly as `tool_end.output`: process output that may
301
304
  * carry secrets, paths or PII; core bounds only its SIZE, and a consumer MUST redact before persisting
@@ -925,8 +928,10 @@ export declare function contextUsageEventData(ev: {
925
928
  *
926
929
  * `responseModel`(core 7.29.0 #1029):**供应商自己**报的、回答这一 turn 的模型名,**只在**它 ≠ 请求所带 id
927
930
  * 时在场。缺席 = 「报了同名」或「没报」,**一个读法**(请求 id 读 `TaskResult.model`)。值是供应商铸的名字
928
- * (与 `apiFailure.requestId` 同类:不是模型写的自由文本)⇒ 逐字;非串 / 空串 ⇒ 缺席(core 的读律
929
- * `providerReportedModel` 对空串答「没报」,本仓不铸一个看起来合法的空名)。
931
+ * (与 `apiFailure.requestId` 同类:不是模型写的自由文本)⇒ 🔴 **S-283 起过 provider 回声的形状闸**
932
+ * ({@link admitProviderEcho}:长度 1–128 的可见 ASCII 原样,越界 ⇒ 不铸键 + 计数,与 core #1204 源头闸同一条;不过脱敏器),
933
+ * 与结果面的孪生 `TaskResult.responseModel` 同一只闸 —— 同一个名字在两张脸上不许两种待遇。非串 / 空串同样不铸键(空串在
934
+ * 那条谓词的长度下界之外;core 的读律 `providerReportedModel` 对空串也答「没报」)。
930
935
  */
931
936
  export declare function turnEndEventData(ev: {
932
937
  usage?: unknown;
@@ -1,4 +1,4 @@
1
- import { redactErrorMessage, redactModelText } from "../observability/run-terminal.js";
1
+ import { admitProviderEcho, redactErrorMessage, redactModelText } from "../observability/run-terminal.js";
2
2
  import { MCP_DELIVERY_VERDICTS, MCP_LIVENESS_STATES, screenGateOutcome, TEAM_ROLES, ToolRoster, ToolRosterDelta, WRITE_PROTECTION_TARGET_VIEWS } from "@sema-agent/core";
3
3
  import { Value } from "typebox/value";
4
4
  import { recordFailOpen } from "../observability/fail-open.js";
@@ -300,7 +300,7 @@ export function taskNotificationEventData(ev) {
300
300
  ? {
301
301
  apiFailure: {
302
302
  ...(typeof n.apiFailure.status === "number" ? { status: n.apiFailure.status } : {}),
303
- ...(typeof n.apiFailure.requestId === "string" ? { requestId: n.apiFailure.requestId } : {}),
303
+ ...(admitProviderEcho(n.apiFailure.requestId, "task_notification.apiFailure.requestId") ? { requestId: n.apiFailure.requestId } : {}),
304
304
  },
305
305
  }
306
306
  : {}),
@@ -756,7 +756,7 @@ export function turnEndEventData(ev) {
756
756
  ...(ev.usage ? { usage: ev.usage } : {}),
757
757
  ...(ev.usageMissing === true ? { usageMissing: true } : {}),
758
758
  ...(typeof ev.stopReason === "string" ? { stopReason: ev.stopReason } : {}),
759
- ...(typeof ev.responseModel === "string" && ev.responseModel !== "" ? { responseModel: ev.responseModel } : {}),
759
+ ...(admitProviderEcho(ev.responseModel, "turn_end.responseModel") ? { responseModel: ev.responseModel } : {}),
760
760
  ...identityFields(ev),
761
761
  };
762
762
  }
@@ -72,6 +72,13 @@ declare const DISPLAY_SAFE_URL_TOKEN = "\u00ABredacted:url\u00BB";
72
72
  /** S-776:出网代理 URL 的回显尺(`outbound-dispatcher.ts` `displayProxyUrl`)形不干净时整只用**同一枚**占位 —— 单独一行导出,
73
73
  * 上面那行字面量保持 `const NAME = "…";` 形(`test/api-notice-text-freeze.test.ts` §6 的冻结扫描按这一形认替换 token)。 */
74
74
  export { DISPLAY_SAFE_URL_TOKEN };
75
+ /** 测试可见(S-265 真值表的两半):结构段里**表行只作声明、实现是手写扫描器**的臂 —— 声明正则的**新拷贝**
76
+ * (`lastIndex` 不与模块内那一枚共享)+ 扫描器在一份原文上产的候选跨度 `[起, 止)`。臂名是闭集 ⇒ 穷尽 `switch`
77
+ * (少一个 `case` = 缺返回 = 编译红)。 */
78
+ export declare function structuralScannerForTest(arm: "json-secret-field" | "pem-fence"): {
79
+ declared: RegExp;
80
+ spans: (text: string) => Array<[number, number]>;
81
+ };
75
82
  /** 测试可见:扫描器 indexOf 实际扫过的字节数(摊还线性 ⇒ ≤ 常数 × 输入长)。 */
76
83
  export declare function ppkScanBytesForTest(): number;
77
84
  export declare function resetPpkScanBytesForTest(): void;
@@ -486,13 +486,12 @@ function deriveLiteralHead(re) {
486
486
  function scanResumeAfterHit(start, end, back) {
487
487
  return Math.max(start + 1, end - back);
488
488
  }
489
- const spanOffset = (a, m) => (a.outside === 1 ? m[1].length : 0);
490
489
  function collectRegexSpans(a, text, out) {
491
490
  const g = a.g;
492
491
  g.lastIndex = 0;
493
492
  for (let m = g.exec(text); m !== null; m = g.exec(text)) {
494
493
  const end = m.index + m[0].length;
495
- out.push({ start: m.index + spanOffset(a, m), end, repl: a.repl, rank: a.rank, order: a.order });
494
+ out.push({ start: m.index, end, repl: a.repl, rank: a.rank, order: a.order });
496
495
  g.lastIndex = scanResumeAfterHit(m.index, end, a.back);
497
496
  }
498
497
  }
@@ -585,7 +584,7 @@ function collectStickySpans(a, text, at, out) {
585
584
  if (m === null)
586
585
  continue;
587
586
  const end = p + m[0].length;
588
- out.push({ start: p + spanOffset(a, m), end, repl, rank, order });
587
+ out.push({ start: p, end, repl, rank, order });
589
588
  pos = scanResumeAfterHit(p, end, back);
590
589
  }
591
590
  }
@@ -935,6 +934,47 @@ function collectSsh2FenceSpans(text, span) {
935
934
  from = scanResumeAfterHit(fence, end, SSH2_FENCE_OPEN.length);
936
935
  }
937
936
  }
937
+ const PEM_END_OPEN = "-----END";
938
+ const PEM_FENCE_BODY_MAX = 8192;
939
+ function pemFenceLineEnd(text, labelFrom) {
940
+ const close = text.indexOf(PEM_FENCE_CLOSE, labelFrom);
941
+ return close >= 0 && isPemFenceLabel(text, labelFrom, close) ? close + PEM_FENCE_CLOSE.length : -1;
942
+ }
943
+ function collectPemFenceSpans(text, span) {
944
+ let endFrom = -1;
945
+ let endAt = -1;
946
+ let endAfter = -1;
947
+ const earliestEndFence = (from) => {
948
+ if (endFrom >= 0 && from >= endFrom && (endAt < 0 || endAt >= from))
949
+ return;
950
+ let b = text.indexOf(PEM_END_OPEN, from);
951
+ let after = -1;
952
+ while (b >= 0 && (after = pemFenceLineEnd(text, b + PEM_END_OPEN.length)) < 0)
953
+ b = text.indexOf(PEM_END_OPEN, b + 1);
954
+ endFrom = from;
955
+ endAt = b;
956
+ endAfter = after;
957
+ };
958
+ for (let from = 0;;) {
959
+ const fence = text.indexOf(PEM_FENCE_OPEN, from);
960
+ if (fence < 0)
961
+ return;
962
+ const bodyFrom = pemFenceLineEnd(text, fence + PEM_FENCE_OPEN.length);
963
+ if (bodyFrom < 0) {
964
+ from = fence + 1;
965
+ continue;
966
+ }
967
+ earliestEndFence(bodyFrom);
968
+ if (endAt < 0)
969
+ return;
970
+ if (endAt - bodyFrom > PEM_FENCE_BODY_MAX) {
971
+ from = fence + 1;
972
+ continue;
973
+ }
974
+ span(fence, endAfter);
975
+ from = scanResumeAfterHit(fence, endAfter, PEM_FENCE_OPEN.length);
976
+ }
977
+ }
938
978
  function collectPemFragmentSpans(text, json, span) {
939
979
  let cur = findPemFence(text, 0);
940
980
  let inherited = false;
@@ -1145,6 +1185,18 @@ const SSH2_FENCE_PATTERN = (() => {
1145
1185
  throw new Error("redact: SSH2 fence rule missing from FULL_ARM_PATTERNS");
1146
1186
  return row;
1147
1187
  })();
1188
+ const PEM_FENCE_PATTERN = (() => {
1189
+ const row = FULL_ARM_PATTERNS.find(([re]) => re.source.startsWith(PEM_FENCE_OPEN));
1190
+ if (row === undefined)
1191
+ throw new Error("redact: PEM fence rule missing from FULL_ARM_PATTERNS");
1192
+ return row;
1193
+ })();
1194
+ const JSON_SECRET_FIELD_PATTERN = (() => {
1195
+ const rows = FULL_ARM_PATTERNS.filter(([, repl]) => repl === '$1"«redacted»"');
1196
+ if (rows.length !== 1)
1197
+ throw new Error(`redact: expected exactly one JSON secret-field rule (replacement '$1"«redacted»"') in FULL_ARM_PATTERNS, found ${rows.length}`);
1198
+ return rows[0];
1199
+ })();
1148
1200
  const isRegexSpace = (c) => c === 32 || (c >= 9 && c <= 13) || c === 0xa0 || c === 0x1680 || (c >= 0x2000 && c <= 0x200a) || c === 0x2028 || c === 0x2029 || c === 0x202f || c === 0x205f || c === 0x3000 || c === 0xfeff;
1149
1201
  const isLineTerminator = (c) => c === 10 || c === 13 || c === 0x2028 || c === 0x2029;
1150
1202
  const isControlCode = (c) => c < 0x20 || c === 0x7f;
@@ -1360,6 +1412,78 @@ function collectUrlCredentialSpans(text, json, span) {
1360
1412
  i = valueEnd - 1;
1361
1413
  }
1362
1414
  }
1415
+ const JSON_FIELD_ANCHOR = '"';
1416
+ const CREDENTIAL_NAME_MAX = Math.max(...CREDENTIAL_NAME_WORDS.map((w) => w.length));
1417
+ function jsonSecretFieldValueQuoteAt(text, p) {
1418
+ const n = text.length;
1419
+ let name = "";
1420
+ let i = p + 1;
1421
+ for (; i < n; i++) {
1422
+ let c = text.charCodeAt(i);
1423
+ if (c === CH_QUOTE)
1424
+ break;
1425
+ if (i - p > CREDENTIAL_NAME_MAX)
1426
+ return -1;
1427
+ if (c >= CH_A && c <= CH_Z)
1428
+ c += 32;
1429
+ if (!((c >= CH_a && c <= CH_z) || (c >= CH_0 && c <= CH_9) || c === CH_UNDERSCORE || c === CH_MINUS))
1430
+ return -1;
1431
+ name += String.fromCharCode(c);
1432
+ }
1433
+ if (i >= n || !CREDENTIAL_NAME_SET.has(name))
1434
+ return -1;
1435
+ i += 1;
1436
+ while (i < n && isRegexSpace(text.charCodeAt(i)))
1437
+ i += 1;
1438
+ if (text.charCodeAt(i) !== CH_COLON)
1439
+ return -1;
1440
+ i += 1;
1441
+ while (i < n && isRegexSpace(text.charCodeAt(i)))
1442
+ i += 1;
1443
+ return text.charCodeAt(i) === CH_QUOTE ? i : -1;
1444
+ }
1445
+ function jsonStringEndAt(text, q) {
1446
+ const n = text.length;
1447
+ for (let i = q + 1; i < n;) {
1448
+ const c = text.charCodeAt(i);
1449
+ if (c === CH_QUOTE)
1450
+ return i + 1;
1451
+ if (c === CH_BACKSLASH) {
1452
+ if (i + 1 < n && !isLineTerminator(text.charCodeAt(i + 1))) {
1453
+ i += 2;
1454
+ continue;
1455
+ }
1456
+ return -1;
1457
+ }
1458
+ i += 1;
1459
+ }
1460
+ return -1;
1461
+ }
1462
+ function collectJsonSecretFieldSpans(text, span) {
1463
+ for (let p = text.indexOf(JSON_FIELD_ANCHOR); p >= 0;) {
1464
+ const q = jsonSecretFieldValueQuoteAt(text, p);
1465
+ const end = q < 0 ? -1 : jsonStringEndAt(text, q);
1466
+ if (end < 0) {
1467
+ p = text.indexOf(JSON_FIELD_ANCHOR, p + 1);
1468
+ continue;
1469
+ }
1470
+ span(q, end);
1471
+ p = text.indexOf(JSON_FIELD_ANCHOR, scanResumeAfterHit(p, end, JSON_FIELD_ANCHOR.length));
1472
+ }
1473
+ }
1474
+ export function structuralScannerForTest(arm) {
1475
+ const collect = (scan) => (text) => {
1476
+ const out = [];
1477
+ scan(text, (start, end) => out.push([start, end]));
1478
+ return out;
1479
+ };
1480
+ switch (arm) {
1481
+ case "json-secret-field":
1482
+ return { declared: new RegExp(JSON_SECRET_FIELD_PATTERN[0].source, JSON_SECRET_FIELD_PATTERN[0].flags), spans: collect(collectJsonSecretFieldSpans) };
1483
+ case "pem-fence":
1484
+ return { declared: new RegExp(PEM_FENCE_PATTERN[0].source, PEM_FENCE_PATTERN[0].flags), spans: collect(collectPemFenceSpans) };
1485
+ }
1486
+ }
1363
1487
  let ppkScanBytes = 0;
1364
1488
  export function ppkScanBytesForTest() { return ppkScanBytes; }
1365
1489
  export function resetPpkScanBytesForTest() { ppkScanBytes = 0; }
@@ -1462,56 +1586,19 @@ const armPool = (() => {
1462
1586
  throw new Error(`redact: arm-pool replacement ${JSON.stringify(token)} may only use a leading $1 (the run kept outside the span)`);
1463
1587
  return body;
1464
1588
  };
1465
- const literalAnchorHead = (re) => {
1466
- const src = re.source;
1467
- let i = src.startsWith("(") && !src.startsWith("(?") ? 1 : 0;
1468
- let out = "";
1469
- for (; i < src.length; i++) {
1470
- const c = src[i];
1471
- if (c === "\\") {
1472
- const e = src[i + 1];
1473
- if (e === undefined || !HEAD_LITERAL_ESCAPES.has(e))
1474
- break;
1475
- if ("*+?{".includes(src[i + 2] ?? ""))
1476
- break;
1477
- out += src.slice(i, i + 2);
1478
- i += 1;
1479
- continue;
1480
- }
1481
- if ("([{|).^$*+?".includes(c))
1482
- break;
1483
- if ("*+?{".includes(src[i + 1] ?? ""))
1484
- break;
1485
- out += c;
1486
- }
1487
- if (out.length === 0)
1488
- throw new Error(`redact: structural arm /${src}/ has no literal anchor head — its same-arm rewind amount cannot be derived`);
1489
- return out;
1490
- };
1491
- const regexMember = ([re, token], rank, order, structural) => {
1589
+ const regexMember = ([re, token], rank, order) => {
1590
+ if (token.startsWith("$1"))
1591
+ throw new Error(`redact: regex arm /${re.source}/ cannot carry an outside-span $1 prefix — that form belongs to the structural scanners`);
1492
1592
  const repl = spanRepl(token);
1493
- const outside = token.startsWith("$1") ? 1 : 0;
1494
1593
  const y = VALUE_ARM_STICKY.get(re);
1495
- let back;
1496
- let headSrc;
1497
- let headFirst;
1498
- if (structural) {
1499
- back = literalAnchorHead(re).length;
1500
- headSrc = undefined;
1501
- headFirst = undefined;
1502
- }
1503
- else {
1504
- const d = deriveLiteralHead(re);
1505
- back = Math.max(d.len - 1, 0);
1506
- headSrc = d.src;
1507
- headFirst = d.first;
1508
- if (y !== undefined && (d.len === 0 || headSrc === undefined || headFirst === undefined)) {
1509
- throw new Error(`redact: boundary arm /${re.source}/ has a zero-length structural head — the bounded head screen would admit every candidate position`);
1510
- }
1594
+ const d = deriveLiteralHead(re);
1595
+ const back = Math.max(d.len - 1, 0);
1596
+ const headSrc = d.src;
1597
+ const headFirst = d.first;
1598
+ if (y !== undefined && (d.len === 0 || headSrc === undefined || headFirst === undefined)) {
1599
+ throw new Error(`redact: boundary arm /${re.source}/ has a zero-length structural head — the bounded head screen would admit every candidate position`);
1511
1600
  }
1512
- if (y !== undefined && outside === 1)
1513
- throw new Error(`redact: value arm /${re.source}/ cannot carry an outside-span $1 prefix`);
1514
- const arm = { g: re, y, back, headSrc, headFirst, repl, rank, order, outside };
1601
+ const arm = { g: re, y, back, headSrc, headFirst, repl, rank, order };
1515
1602
  const boundary = y === undefined || headSrc === undefined || headFirst === undefined ? undefined : { ...arm, y, headSrc, headFirst };
1516
1603
  return {
1517
1604
  token,
@@ -1541,24 +1628,29 @@ const armPool = (() => {
1541
1628
  headFirst: undefined,
1542
1629
  };
1543
1630
  };
1631
+ const structuralScannerMember = (row, order) => {
1632
+ if (row === PEM_FENCE_PATTERN)
1633
+ return scannerMember(row[1], order, 1, (text, _json, _observe, span) => collectPemFenceSpans(text, span));
1634
+ if (row === SSH2_FENCE_PATTERN)
1635
+ return scannerMember(row[1], order, 2, (text, _json, _observe, span) => collectSsh2FenceSpans(text, span));
1636
+ if (row === URL_CREDENTIAL_PATTERN)
1637
+ return scannerMember(row[1], order, 2, (text, json, _observe, span) => collectUrlCredentialSpans(text, json, span));
1638
+ if (row === JSON_SECRET_FIELD_PATTERN)
1639
+ return scannerMember(row[1], order, 1, (text, _json, _observe, span) => collectJsonSecretFieldSpans(text, span));
1640
+ throw new Error(`redact: structural row /${row[0].source}/ has no hand-written scanner — structural grammars are declared as regexes but never executed as one (S-102 / S-265)`);
1641
+ };
1544
1642
  const members = [];
1545
- for (const row of STRUCTURAL_SECRET_PATTERNS) {
1546
- const order = members.length;
1547
- members.push(row === URL_CREDENTIAL_PATTERN
1548
- ? scannerMember(row[1], order, 2, (text, json, _observe, span) => collectUrlCredentialSpans(text, json, span))
1549
- : row === SSH2_FENCE_PATTERN
1550
- ? scannerMember(row[1], order, 2, (text, _json, _observe, span) => collectSsh2FenceSpans(text, span))
1551
- : regexMember(row, ARM_RANK.structural, order, true));
1552
- }
1643
+ for (const row of STRUCTURAL_SECRET_PATTERNS)
1644
+ members.push(structuralScannerMember(row, members.length));
1553
1645
  members.push(scannerMember(PPK_CONTAINER_TOKEN, members.length, 2, (text, _json, observe, span) => collectPpkContainerSpans(text, observe, span)));
1554
1646
  members.push(scannerMember(PEM_FRAGMENT_TOKEN, members.length, 2, (text, json, _observe, span) => collectPemFragmentSpans(text, json, span)));
1555
1647
  members.push(scannerMember(HEADLESS_PPK_TOKEN, members.length, 2, (text, _json, _observe, span) => collectHeadlessPpkSpans(text, span)));
1556
1648
  for (const row of KEYWORD_SECRET_PATTERNS)
1557
- members.push(regexMember(row, ARM_RANK.keyword, members.length, false));
1649
+ members.push(regexMember(row, ARM_RANK.keyword, members.length));
1558
1650
  for (const row of SHAPE_SECRET_PATTERNS)
1559
- members.push(regexMember(row, ARM_RANK.shape, members.length, false));
1651
+ members.push(regexMember(row, ARM_RANK.shape, members.length));
1560
1652
  for (const row of FRAGMENT_SECRET_PATTERNS)
1561
- members.push(regexMember(row, ARM_RANK.fragment, members.length, false));
1653
+ members.push(regexMember(row, ARM_RANK.fragment, members.length));
1562
1654
  for (const [re] of KEYWORD_SECRET_PATTERNS) {
1563
1655
  if (!LEXICAL_KEYWORD_ARMS.has(re))
1564
1656
  throw new Error(`redact: keyword arm /${re.source}/ is not registered as lexical — build it with keywordArm() so the bounded-unit scope can exclude it`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "7.104.0",
3
+ "version": "7.105.0",
4
4
  "description": "Sema Server — the server/API implementation layer for Sema, wiring core, registry, model providers, and cloud agent execution. Built on @sema-agent/core.",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -1,46 +0,0 @@
1
- /**
2
- * In-memory SessionPolicyStore + the service's `deleteBySession` extension (E21 purge / 2c overwrite-dst wipe).
3
- *
4
- * WHY THIS EXISTS (and not core's `InMemorySessionPolicyStore`): core's in-memory/file policy stores implement only
5
- * the core interface (getRules/putRules/listBySession) — they have NO deletion seam, so neither the E21 session purge
6
- * nor the 2c `overwrite-dst` subsystem wipe can remove policy rows on a backend that uses them (the SQL twins carry
7
- * `deleteBySession` as a service extension; see session-policy-store-sql.ts). This store is the delete-capable
8
- * in-memory twin. The seam itself is also requested upstream (core: optional `deleteBySession?` on the interface +
9
- * the File/InMemory impls) — if core lands it, this class can retire in favor of core's.
10
- *
11
- * BYTE-MATCH DISCIPLINE: getRules/putRules/listBySession replicate core's `InMemorySessionPolicyStore` semantics
12
- * exactly (same key shape, same CAS `conflict` on expectedRev mismatch, same tighten-only `loosen_forbidden` gate via
13
- * core's exported `normalizeRules`/`loosenReasons`/`stripRev`, same rev start/bump). Any divergence is a bug here.
14
- * S-529 起同样钉住 `@contract session_policy.rev_survives_delete`:`deleteBySession` 保留每把键的 rev 高水位
15
- * **并给那把键的世代(`gen`)+1**(见 {@link MemorySessionPolicyStore.deleteBySession});共用试剂盒
16
- * `test/helpers/session-policy-rev-line-contract.ts` 与 SQL 双生、以及 core 自己的 `InMemorySessionPolicyStore`
17
- * (7.25.0 起带这条契约,已作为第三只引擎接进同一只试剂盒当 oracle)跑同一段剧本。
18
- */
19
- import { type PutRulesOptions, type SessionPermissionRules, type SessionPolicyStore, type SessionRulesRecord, type StoredSessionRules } from "@sema-agent/core";
20
- export declare class MemorySessionPolicyStore implements SessionPolicyStore {
21
- private readonly map;
22
- /** S-529 —— 每把键的 rev **高水位 + 世代**(`@contract session_policy.rev_survives_delete`):`deleteBySession`
23
- * 抹掉规则行时把它那条 rev 线的终点留在这里,于是删后首写 stamp 的是 `max(当前行 rev, 记号.rev) + 1`,
24
- * 而不是从 1 重来;同时把 `gen` +1 —— **抹除终结的是那一行的血统**,下一次写开的是新世代,读侧因此能把
25
- * 「重建的行」与「它正在执行的那一行的延续」分开,无论新行的 rev 有多大(core 7.25.0 [ref] 判词:
26
- * 没有世代时读侧只能靠「亲眼看见行消失」那条弱守卫,于是两道门之间未被观察到的 erase+重建抓不住,
27
- * 而一次瞬时读 null 会把该腿永久闩在旧行上)。
28
- * 与 SQL 双生的 `session_policy_rev_mark` 表、core File 店的 `<stem>.hwm.json` 侧车是同一条律的三个实现体
29
- * (SQL 那边的表注写了为什么不留墓碑行)。本表只增不减、条目是两个整数 —— 与 `map` 同为进程内结构,
30
- * 随进程亡;跨副本的持久面在 SQL 双生那半。 */
31
- private readonly marks;
32
- private key;
33
- getRules(sessionId: string, principal?: string): Promise<StoredSessionRules | null>;
34
- putRules(sessionId: string, principal: string | undefined, rules: SessionPermissionRules, opts?: PutRulesOptions): Promise<StoredSessionRules>;
35
- listBySession(sessionId: string): Promise<SessionRulesRecord[]>;
36
- /** Purge ALL policy rows for a session, across every principal — KEEPING each key's rev high-water mark.
37
- * Scoped by session_id alone — the caller (E21 route / importSession overwrite-dst) already proved ownership.
38
- * Shape = core 1.423's optional `SessionPolicyStore.deleteBySession?` seam (`Promise<void>` — the count was
39
- * incidental; nothing consumed it).
40
- *
41
- * S-529:规则本身死,rev 线不死(`@contract session_policy.rev_survives_delete`)。记号只升不降(`Math.max`),
42
- * 所以反复抹除不会把线走回头;从没写过的键不留记号(没有线就不凭空抬)。**抹除同时终结那一行的血统**:
43
- * `gen` +1,于是下一次写开的是新世代 —— 数字继续爬,但读侧按世代判「这不是我在执行的那一行的延续」。 */
44
- deleteBySession(sessionId: string): Promise<void>;
45
- }
46
- //# sourceMappingURL=memory-session-policy-store.d.ts.map
@@ -1,52 +0,0 @@
1
- import { normalizeRules, loosenReasons, stripRev, SessionPolicyError, } from "@sema-agent/core";
2
- export class MemorySessionPolicyStore {
3
- map = new Map();
4
- marks = new Map();
5
- key(sessionId, principal) {
6
- return JSON.stringify([sessionId, principal ?? null]);
7
- }
8
- async getRules(sessionId, principal) {
9
- const r = this.map.get(this.key(sessionId, principal));
10
- return r ? structuredClone(r) : null;
11
- }
12
- async putRules(sessionId, principal, rules, opts) {
13
- const key = this.key(sessionId, principal);
14
- const prior = this.map.get(key);
15
- if (opts?.expectedRev !== undefined && (prior?.rev ?? 0) !== opts.expectedRev) {
16
- throw new SessionPolicyError("conflict", `session rules rev mismatch (expected ${opts.expectedRev}, have ${prior?.rev ?? 0})`);
17
- }
18
- const clean = normalizeRules(stripRev(rules));
19
- if (prior && opts?.operator !== true) {
20
- const loosens = loosenReasons(prior, clean);
21
- if (loosens.length > 0) {
22
- throw new SessionPolicyError("loosen_forbidden", `a non-operator session-rule write may only tighten; refused: ${loosens.join("; ")}`);
23
- }
24
- }
25
- const mark = this.marks.get(key) ?? { rev: 0, gen: 0 };
26
- const gen = prior?.gen ?? mark.gen;
27
- const next = { ...structuredClone(clean), rev: Math.max(prior?.rev ?? 0, mark.rev) + 1, gen };
28
- this.map.set(key, next);
29
- this.marks.set(key, { rev: next.rev, gen });
30
- return structuredClone(next);
31
- }
32
- async listBySession(sessionId) {
33
- const out = [];
34
- for (const [k, rules] of this.map) {
35
- const [sid, principal] = JSON.parse(k);
36
- if (sid === sessionId)
37
- out.push({ principal: principal ?? undefined, rules: structuredClone(rules) });
38
- }
39
- return out;
40
- }
41
- async deleteBySession(sessionId) {
42
- for (const [k, rules] of [...this.map]) {
43
- const [sid] = JSON.parse(k);
44
- if (sid !== sessionId)
45
- continue;
46
- const mark = this.marks.get(k) ?? { rev: 0, gen: 0 };
47
- this.marks.set(k, { rev: Math.max(mark.rev, rules.rev), gen: mark.gen + 1 });
48
- this.map.delete(k);
49
- }
50
- }
51
- }
52
- //# sourceMappingURL=memory-session-policy-store.js.map