@sema-agent/settings-schema 14.0.0 → 14.2.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.
@@ -7,8 +7,10 @@
7
7
  * 或者自己抄一份 —— 两份判据就是将来漂开的那一份。现在判官在契约包,服务端与壳读同一份,同一条条目在两处同受理 / 同拒、同一个键路径、同一句原因。
8
8
  *
9
9
  * ## 语义是搬来的,不是重写的
10
- * 判据与原因句逐字等于消费方 7.131.x 的那一份(钉:`test/mcp-request-entry-14.0.0.test.ts` 逐例搬了消费方既有钉的夹具与期望)。
10
+ * 判据与原因句逐字等于消费方请求腿的那一份(钉:`test/mcp-request-entry-14.0.0.test.ts` 逐例搬了消费方既有钉的夹具与期望)。
11
11
  * 本模块只答「这一格的形」;拒绝的整句(条目下标、条目名的回显清洗、收尾句)与「拒还是降级」的后果仍是消费方按自己的腿定的 —— 判据一份,后果按腿分。
12
+ * 与最初搬来时相比有三处收严,都是收到与消费方请求腿同严、不是新语义,消费方改 import 这一份之后不再各留一份旧判据:申报名判官另判**首尾空白**;
13
+ * `env` / `headers` 只收**纯数据**的「名 → 串」记录(Map / 类实例 / 自有 `__proto__` 成员不收,交出的是副本);两只回显描述器用消费方的回显口径(剥控制与双向字符、截 64 字、引号形)。
12
14
  *
13
15
  * ## 依赖的引擎事实(镜像 + 漂移门)
14
16
  * · 申报名的线长界 {@link TOOL_WIRE_NAME_MAX_CHARS} = 引擎同名常量(值级对拍:上游漂移门 ⑧);
@@ -21,6 +23,7 @@
21
23
  * 两份契约对「名」与「url」今天给的判据不同(配置面:名按字符集、url 按 `NON_SECRET_URL`;请求面:名按长度界、url 按 http(s) 前缀),本版刻意不动
22
24
  * (语义逐字搬),是一项已知待收的分歧。
23
25
  */
26
+ import { echoCallerText } from "./caller-text-echo.js";
24
27
  import { MCP_CONTENT_ORIGINS } from "./types.js";
25
28
  import { startsWithHttpScheme } from "./url-shape.js";
26
29
  // ── 值的种类描述(拒句只说「你写的是什么形」,不回显值:值可能带凭据、可能任意长)──────────────────────────────────────
@@ -50,31 +53,53 @@ export function describeNotStringRecord(v) {
50
53
  export function isStringArray(v) {
51
54
  return Array.isArray(v) && v.every((a) => typeof a === "string");
52
55
  }
53
- /** 字符串值的对象(空对象合法;数组不是)。 */
56
+ /**
57
+ * 纯数据的「名 → 串」记录(空对象合法;数组、`Map`、类实例、自有 `__proto__` 成员、有非串成员的对象都不是)。与传输读器读 `env` / `headers` 是**同一个**定义
58
+ * ({@link readPlainStringRecord}):包内没有第二把更宽的尺。
59
+ */
54
60
  export function isStringRecord(v) {
55
- return !!v && typeof v === "object" && !Array.isArray(v) && Object.values(v).every((x) => typeof x === "string");
61
+ const read = readPlainStringRecord(v);
62
+ return read.ok && !read.reservedMember;
56
63
  }
57
64
  function isRecord(v) {
58
65
  return v !== null && typeof v === "object" && !Array.isArray(v);
59
66
  }
67
+ // ── 调用方写的串的回显口径 ──────────────────────────────────────────────────────────────────────────
68
+ /** 句子里点名一个值的写法:{@link echoCallerText} 外加一对双引号;内部的引号与反斜杠逐字(不转义)。 */
69
+ function quotedCallerText(text) {
70
+ return `"${echoCallerText(text)}"`;
71
+ }
60
72
  // ── 申报名的线长界 ──────────────────────────────────────────────────────────────────────────────────
61
73
  /** 引擎 `TOOL_WIRE_NAME_MAX_CHARS` 的镜像:一台 MCP server(或 A2A 对端)的申报名会成为线上工具名的命名空间段,引擎对超界名是**拒**(不截断)。 */
62
74
  export const TOOL_WIRE_NAME_MAX_CHARS = 128;
63
75
  /**
64
- * 这个申报名能用吗。严判:非串、空串、超 {@link TOOL_WIRE_NAME_MAX_CHARS} 一律不可用。不截断、不规范化 —— 截短一个名字就是**换一个身份**,
65
- * 而被换掉身份的那台 server 上挂的 allowTools / toolFaces 收窄声明会一起对不上号。
76
+ * 首尾带空白吗 —— 「空白」就是 `String.prototype.trim` 剥的那一组(ECMAScript 的 WhiteSpace + LineTerminator:空格 / 制表 / 换行 / NBSP / BOM / 全角空格 / 行段分隔符等)。
77
+ * {@link isUsableWireName} 与 {@link describeBadWireName} 共读这一个定义,判与说不会各按各的口径。
78
+ */
79
+ const hasEdgeWhitespace = (v) => v.trim() !== v;
80
+ /**
81
+ * 这个申报名能用吗。严判:非串、空串、超 {@link TOOL_WIRE_NAME_MAX_CHARS}、**首尾带空白**一律不可用。不截断、不 trim、不规范化 —— 截短或剥掉一个名字就是**换一个身份**,
82
+ * 而被换掉身份的那台 server 上挂的 allowTools / toolFaces 收窄声明会一起对不上号。首尾空白 = 坏名(它不是另一个名字:名字是 `mcp__<server>__<tool>` 的身份片,
83
+ * 带着空白的一端无从与不带的那个对上号);内部空白(`a b`)照旧是合法名,本判官不扩判。
66
84
  */
67
85
  export function isUsableWireName(v) {
68
- return typeof v === "string" && v.length > 0 && v.length <= TOOL_WIRE_NAME_MAX_CHARS;
86
+ return typeof v === "string" && v.length > 0 && v.length <= TOOL_WIRE_NAME_MAX_CHARS && !hasEdgeWhitespace(v);
69
87
  }
70
- /** 坏名的**可打印**摘要(进拒句 / 告警元数据):种类、`empty`,或截到 32 字加长度事实 —— 绝不把任意长度的申报串原样回显。 */
88
+ /**
89
+ * 坏名的**可打印**摘要(进拒句 / 告警元数据):种类、`empty`,或引号形的回显加原因 —— 绝不把任意长度的申报串原样回显。回显口径见 {@link quotedCallerText}
90
+ * (剥控制与双向字符、截 64 字)。串的原因是:超界 ⇒ `N chars > 128`(N 是送来的原长度,不是剥完的);首尾带空白 ⇒ `leading or trailing whitespace`
91
+ * (制表 / 换行这类控制空白在回显里被剥掉,所以看得出是空白靠这个种类词,不靠引号里的样子);两件事并存 ⇒ 一句说全(`N chars > 128, leading or trailing whitespace`)。
92
+ */
71
93
  export function describeBadWireName(v) {
72
94
  if (typeof v !== "string")
73
95
  return v === null ? "null" : Array.isArray(v) ? "array" : typeof v;
74
96
  if (v.length === 0)
75
97
  return "empty";
76
- const head = v.length > 32 ? `${JSON.stringify(v.slice(0, 32))}…` : JSON.stringify(v);
77
- return `${head} (${v.length} chars > ${TOOL_WIRE_NAME_MAX_CHARS})`;
98
+ const head = quotedCallerText(v);
99
+ const tooLong = `${v.length} chars > ${TOOL_WIRE_NAME_MAX_CHARS}`;
100
+ if (!hasEdgeWhitespace(v))
101
+ return `${head} (${tooLong})`;
102
+ return `${head} (${v.length > TOOL_WIRE_NAME_MAX_CHARS ? `${tooLong}, ` : ""}leading or trailing whitespace)`;
78
103
  }
79
104
  // ── 内容出处词 ──────────────────────────────────────────────────────────────────────────────────────
80
105
  const CONTENT_ORIGIN_WORDS = new Set(MCP_CONTENT_ORIGINS);
@@ -85,10 +110,9 @@ const CONTENT_ORIGIN_WORDS = new Set(MCP_CONTENT_ORIGINS);
85
110
  export function isContentOriginWord(v) {
86
111
  return typeof v === "string" && CONTENT_ORIGIN_WORDS.has(v);
87
112
  }
88
- /** 坏内容出处值的**可打印**摘要:串 JSON 化、64 字截断加 `…`;非串只说种类。 */
113
+ /** 坏内容出处值的**可打印**摘要:串 ⇒ 引号形的回显({@link quotedCallerText}:剥控制与双向字符、截 64 字);非串只说种类。 */
89
114
  export function describeBadContentOrigin(v) {
90
- const raw = typeof v === "string" ? JSON.stringify(v) : v === null ? "null" : Array.isArray(v) ? "array" : typeof v;
91
- return raw.length > 64 ? `${raw.slice(0, 64)}…` : raw;
115
+ return typeof v === "string" ? quotedCallerText(v) : v === null ? "null" : Array.isArray(v) ? "array" : typeof v;
92
116
  }
93
117
  // ── 两个身份头 ──────────────────────────────────────────────────────────────────────────────────────
94
118
  /**
@@ -105,8 +129,11 @@ const transportBad = (path, reason) => ({ ok: false, issue: { path, reason } });
105
129
  /**
106
130
  * 请求面 `transport` 的**唯一读器**:读器投影 transport 用它,发送前 / 受理时的判形读它报的 `lost` —— 同一只函数两个消费点,「读器丢了什么」与「门拒什么」不可能分岔。
107
131
  * · 硬坏形(`transport` 不是对象 / `kind` 出闭集 / stdio 缺 `command` / http 的 `url` 不是 http(s))⇒ `ok:false`,`path` 是读器点名的键路径;
108
- * · 软坏形(`args` 不是串数组 / `env` `headers` 含非串值 / `principalHeader` 非串)⇒ 读器**丢这个键**(重放存量提交体时的升级前读法),并把它记进 `lost`
132
+ * · 软坏形(`args` 不是串数组 / `env` `headers` 不是纯数据的串记录 / `principalHeader` 非串)⇒ 读器**丢这个键**(重放存量提交体时的升级前读法),并把它记进 `lost`
109
133
  * (受理新提交的门据它响亮拒)。`assertionHeader` 不在 `lost` 里:两个身份头的键组合与 `assertionHeader` 的值形由调用方在读之前对原始传输判。
134
+ * · `env` / `headers` 只收**纯数据**的记录({@link readPlainStringRecord}):原型必须是 `Object.prototype` 或 `null`(`Map`、`Set`、类实例整张不收),每个自有成员的值都是串;
135
+ * 交出的是**副本**(不是调用方的对象,改原对象不影响读后的形)。名叫 `__proto__` 的成员(JSON 文本铸得出这个自有键,对象字面量写不出)不进副本、单独记进 `lost`。
136
+ * 坏形的 `lost` 条目带机读位(见 {@link McpEntryKeyIssue})。
110
137
  * url 的拒句不回显值:它常规地带凭据(userinfo)。
111
138
  */
112
139
  export function readRequestMcpTransport(t) {
@@ -119,15 +146,14 @@ export function readRequestMcpTransport(t) {
119
146
  return transportBad("transport.command", `must be a non-empty string (the command to run) — got ${describeValueShape(tr.command)}`);
120
147
  if (tr.args !== undefined && !isStringArray(tr.args))
121
148
  lost.push({ path: "transport.args", reason: `must be an array of strings — got ${describeNotStringArray(tr.args)}` });
122
- if (tr.env !== undefined && !isStringRecord(tr.env))
123
- lost.push({ path: "transport.env", reason: `must be an object of string values — got ${describeNotStringRecord(tr.env)}` });
149
+ const env = readTransportRecord("transport.env", tr.env, lost);
124
150
  return {
125
151
  ok: true,
126
152
  transport: {
127
153
  kind: "stdio",
128
154
  command: tr.command,
129
155
  ...(isStringArray(tr.args) ? { args: tr.args } : {}),
130
- ...(isStringRecord(tr.env) ? { env: tr.env } : {}),
156
+ ...(env !== undefined ? { env } : {}),
131
157
  },
132
158
  lost,
133
159
  };
@@ -137,8 +163,7 @@ export function readRequestMcpTransport(t) {
137
163
  if (typeof tr.url !== "string" || !startsWithHttpScheme(tr.url))
138
164
  return transportBad("transport.url", "must be an absolute http(s) URL (put credentials in transport.headers, never in the URL)");
139
165
  const ids = readMcpIdentityHeaders(tr);
140
- if (tr.headers !== undefined && !isStringRecord(tr.headers))
141
- lost.push({ path: "transport.headers", reason: `must be an object of string values — got ${describeNotStringRecord(tr.headers)}` });
166
+ const headers = readTransportRecord("transport.headers", tr.headers, lost);
142
167
  if (tr.principalHeader !== undefined && ids.principalHeader === undefined) {
143
168
  lost.push({ path: "transport.principalHeader", reason: `must be a string (the HTTP header NAME the end-user principal is injected into) — got ${describeValueShape(tr.principalHeader)}` });
144
169
  }
@@ -147,7 +172,7 @@ export function readRequestMcpTransport(t) {
147
172
  transport: {
148
173
  kind: "http",
149
174
  url: tr.url,
150
- ...(isStringRecord(tr.headers) ? { headers: tr.headers } : {}),
175
+ ...(headers !== undefined ? { headers } : {}),
151
176
  ...ids,
152
177
  },
153
178
  lost,
@@ -155,3 +180,75 @@ export function readRequestMcpTransport(t) {
155
180
  }
156
181
  return transportBad("transport.kind", `must be "stdio" or "http" — got ${describeValueShape(tr.kind)}`);
157
182
  }
183
+ // ── 「名 → 串」记录的读法(`env` / `headers`)────────────────────────────────────────────────────────
184
+ /** 引擎的声明收据不收的成员名(core `mcp-server-spec`:按赋值拷贝它会变成原型):JSON 文本铸得出这个**自有**键,对象字面量写不出。 */
185
+ const RESERVED_MEMBER_NAME = "__proto__";
186
+ const RESERVED_MEMBER_FAULT = `no member of a declaration may be named "${RESERVED_MEMBER_NAME}"`;
187
+ /**
188
+ * 读一份「名 → 串」记录。记录交不交:是对象(不是数组 / `null`),原型是 `Object.prototype` 或 `null`(纯数据:`Map`、`Set`、类实例、建在原型上的记录都不是),
189
+ * 且每个成员的值都是串。成员 = `Object.getOwnPropertyNames` 的每一个名字(不可枚举的也算 —— 引擎的收据按全部自有属性名读),值按属性描述读:数据属性取值,访问器调一次(访问器或描述 trap 抛了同判为坏)。
190
+ * 读原型与成员名时代理 trap 抛了 ⇒ 整张不交(读器对调用方递来的任何对象都不抛;`JSON` 存体里不会有代理,这条只落在直接调读器的嵌入方身上)。
191
+ * 交出去的是副本(原型 `Object.prototype`、成员全可枚举),调用方的对象不被交出去、也不被改;名叫 `__proto__` 的成员不进副本,`reservedMember` 记下它。
192
+ * 名叫 `__proto__` 而值也坏的成员,句子一次说全两件事(名坏在前、值坏并列):调用方照值改完再撞第二条是两步才改对。
193
+ */
194
+ function readPlainStringRecord(v) {
195
+ if (v === null || typeof v !== "object" || Array.isArray(v))
196
+ return { ok: false, fault: "not_plain_record", detail: v === null ? "got null" : Array.isArray(v) ? "got an array" : `got a ${typeof v}` };
197
+ let proto;
198
+ let names;
199
+ try {
200
+ proto = Object.getPrototypeOf(v);
201
+ names = Object.getOwnPropertyNames(v);
202
+ }
203
+ catch {
204
+ // 代理的 trap 抛了:读不出原型与成员名的对象不是纯数据。读器不抛出去 —— 重放存量提交体的腿被一次抛错砖死比整键不交糟,而「整键不交」是响亮的(进 `lost`)。
205
+ return { ok: false, fault: "not_plain_record", detail: "got an object that could not be inspected (a proxy trap threw)" };
206
+ }
207
+ if (proto !== Object.prototype && proto !== null) {
208
+ return { ok: false, fault: "not_plain_record", detail: "got an object that is not plain data (a class instance, a Map, process.env, or a record built on a prototype; copy it into a plain object)" };
209
+ }
210
+ const record = {};
211
+ let reservedMember = false;
212
+ for (const name of names) {
213
+ let value;
214
+ let unreadable = false;
215
+ try {
216
+ const d = Object.getOwnPropertyDescriptor(v, name);
217
+ value = d === undefined ? undefined : Object.hasOwn(d, "value") ? d.value : d.get === undefined ? undefined : Reflect.apply(d.get, v, []);
218
+ }
219
+ catch {
220
+ unreadable = true;
221
+ }
222
+ if (unreadable || typeof value !== "string") {
223
+ const valueFault = unreadable ? "could not be read (its getter threw)" : `must be a string — got ${describeValueShape(value)}`;
224
+ const reserved = name === RESERVED_MEMBER_NAME;
225
+ return {
226
+ ok: false,
227
+ fault: reserved ? "member_name_reserved" : "member_not_string",
228
+ member: echoCallerText(name),
229
+ detail: `its member "${echoCallerText(name)}" ${reserved ? `is not allowed (${RESERVED_MEMBER_FAULT}) and its value ${valueFault}` : valueFault}`,
230
+ };
231
+ }
232
+ if (name === RESERVED_MEMBER_NAME)
233
+ reservedMember = true;
234
+ else
235
+ record[name] = value;
236
+ }
237
+ return { ok: true, record, reservedMember };
238
+ }
239
+ /**
240
+ * `transport.env` / `transport.headers` 的读法(两键同一只):缺席 ⇒ 缺席;记录不交 ⇒ `lost` 一处点名**这个键**(不交的是整张记录;坏在某个成员时句子与 `member` 点名它);
241
+ * 交 ⇒ 纯数据副本,名叫 `__proto__` 的成员在 `lost` 里单记一处、路径 `<键>.__proto__`。
242
+ */
243
+ function readTransportRecord(path, raw, lost) {
244
+ if (raw === undefined)
245
+ return undefined;
246
+ const read = readPlainStringRecord(raw);
247
+ if (!read.ok) {
248
+ lost.push({ path, reason: `must be a plain object of string values — ${read.detail}`, ...(read.member !== undefined ? { member: read.member } : {}), fault: read.fault });
249
+ return undefined;
250
+ }
251
+ if (read.reservedMember)
252
+ lost.push({ path: `${path}.${RESERVED_MEMBER_NAME}`, reason: RESERVED_MEMBER_FAULT, member: RESERVED_MEMBER_NAME, fault: "member_name_reserved" });
253
+ return read.record;
254
+ }
@@ -5,7 +5,8 @@
5
5
  *
6
6
  * 判法(锚 = cli main ceff8bc6 `mcpServerPolicyBlock`,它与 CC 2.1.296 名单判法逐支一致;URL 条目按本包 `url-pattern` 两只出口判):
7
7
  * - 拒绝名单**绝对优先**,各层都算(用户可以替自己拒):名字条目 → 命令条目(只对 stdio,整数组逐项相等)→ URL 条目(只对远端,拒绝出口)。
8
- * - 允许名单:各层写的拼成一张(CC「Arrays merge across settings sources」);托管层(源表 `SETTINGS_SOURCE_CONTRACT` 档 = `managed` 的源,
8
+ * - 允许名单:各层写的拼成一张(CC「Arrays merge across settings sources」);拼接会**放宽**别层的名单,所以只收紧的源(源表 `tightenOnly`,
9
+ * 仓库里写给另一个工具的设置文件)的允许名单不读 —— 它只贡献拒绝名单;托管层(源表 `SETTINGS_SOURCE_CONTRACT` 档 = `managed` 的源,
9
10
  * 经 `settingsSourceContract` 查 —— 调用方不给档)写了 `allowManagedMcpServersOnly: true` ⇒ 只读托管层那一张(别层写的这个开关不生效)。没有一层写 ⇒ 不限;空 ⇒ 一台都不许;
10
11
  * stdio 且名单里有命令条目 ⇒ 必须命中其一;远端且名单里有 URL 条目 ⇒ 必须命中其一(放行出口);否则按名字。
11
12
  * - SDK 型等「不由宿主连接」的条目是否豁免,归调用方(cli 的过滤口豁免、盘上加载器不豁免 —— 那是接线,不是判法)。
@@ -5,7 +5,8 @@
5
5
  *
6
6
  * 判法(锚 = cli main ceff8bc6 `mcpServerPolicyBlock`,它与 CC 2.1.296 名单判法逐支一致;URL 条目按本包 `url-pattern` 两只出口判):
7
7
  * - 拒绝名单**绝对优先**,各层都算(用户可以替自己拒):名字条目 → 命令条目(只对 stdio,整数组逐项相等)→ URL 条目(只对远端,拒绝出口)。
8
- * - 允许名单:各层写的拼成一张(CC「Arrays merge across settings sources」);托管层(源表 `SETTINGS_SOURCE_CONTRACT` 档 = `managed` 的源,
8
+ * - 允许名单:各层写的拼成一张(CC「Arrays merge across settings sources」);拼接会**放宽**别层的名单,所以只收紧的源(源表 `tightenOnly`,
9
+ * 仓库里写给另一个工具的设置文件)的允许名单不读 —— 它只贡献拒绝名单;托管层(源表 `SETTINGS_SOURCE_CONTRACT` 档 = `managed` 的源,
9
10
  * 经 `settingsSourceContract` 查 —— 调用方不给档)写了 `allowManagedMcpServersOnly: true` ⇒ 只读托管层那一张(别层写的这个开关不生效)。没有一层写 ⇒ 不限;空 ⇒ 一台都不许;
10
11
  * stdio 且名单里有命令条目 ⇒ 必须命中其一;远端且名单里有 URL 条目 ⇒ 必须命中其一(放行出口);否则按名字。
11
12
  * - SDK 型等「不由宿主连接」的条目是否豁免,归调用方(cli 的过滤口豁免、盘上加载器不豁免 —— 那是接线,不是判法)。
@@ -60,8 +61,9 @@ function writtenOf(server) {
60
61
  }
61
62
  /** 名单对这一台的判决:放行 ⇒ `undefined`;拦下 ⇒ 原因(判法见文件头)。 */
62
63
  export function mcpServerPolicyVerdict(server, layers) {
63
- // 每一层先过源表(闭集外的源在这里响亮抛,哪怕那一层什么都没写);托管 ⇔ 源表档 = managed。
64
+ // 每一层先过源表(闭集外的源在这里响亮抛,哪怕那一层什么都没写);托管 ⇔ 源表档 = managed;只收紧的源不进允许名单(拼接是放宽)。
64
65
  const managed = new Set(layers.filter((l) => settingsSourceContract(l.source).trust === "managed"));
66
+ const tightenOnly = new Set(layers.filter((l) => settingsSourceContract(l.source).tightenOnly === true));
65
67
  const block = (list, rule, sources) => ({
66
68
  name: server.name,
67
69
  list,
@@ -76,7 +78,7 @@ export function mcpServerPolicyVerdict(server, layers) {
76
78
  return block("deniedMcpServers", rule, sources);
77
79
  }
78
80
  const managedOnly = layers.some((l) => managed.has(l) && l.allowManagedMcpServersOnly === true);
79
- const allowLayers = layers.filter((l) => l.allowedMcpServers !== undefined && (!managedOnly || managed.has(l)));
81
+ const allowLayers = layers.filter((l) => l.allowedMcpServers !== undefined && !tightenOnly.has(l) && (!managedOnly || managed.has(l)));
80
82
  if (allowLayers.length === 0)
81
83
  return undefined;
82
84
  const miss = allowMiss(allowLayers.flatMap((l) => l.allowedMcpServers), server);
@@ -296,7 +296,7 @@ export declare const SAFETY_MERGE_SPEC: {
296
296
  };
297
297
  readonly enforcement: "gate";
298
298
  readonly fail: "closed";
299
- readonly seam: "derive → toolPolicy / onAsk / handsReadOnly (§6.4); bypass = launch-flag only + MANAGED kill-switch";
299
+ readonly seam: "derive → toolPolicy / onAsk / handsReadOnly (§6.4); bypass = launch-flag only; the kill-switches are the killSwitches row";
300
300
  };
301
301
  readonly handsReadOnly: {
302
302
  readonly mergeShape: "replace";
@@ -534,6 +534,35 @@ export declare const SAFETY_MERGE_SPEC: {
534
534
  readonly fail: "closed";
535
535
  readonly seam: "http hook URL / header-env allowlists (httpHookUrlVerdict / httpHookEnvVarsAllowed)";
536
536
  };
537
+ readonly killSwitches: {
538
+ readonly mergeShape: "replace";
539
+ readonly minTrust: "global";
540
+ readonly tighten: {
541
+ readonly rule: "boolStricter";
542
+ readonly strict: true;
543
+ };
544
+ readonly enforcement: "gate";
545
+ readonly fail: "closed";
546
+ readonly seam: "permission-mode kill-switches (a switch ON removes bypassPermissions / auto from every layer's mode choice)";
547
+ readonly members: {
548
+ readonly disableBypassPermissionsMode: {
549
+ readonly direction: "tighten";
550
+ readonly minTrust: "global";
551
+ readonly boolPole: {
552
+ readonly strict: true;
553
+ readonly minTrust: "local";
554
+ };
555
+ };
556
+ readonly disableAutoMode: {
557
+ readonly direction: "tighten";
558
+ readonly minTrust: "global";
559
+ readonly boolPole: {
560
+ readonly strict: true;
561
+ readonly minTrust: "local";
562
+ };
563
+ };
564
+ };
565
+ };
537
566
  };
538
567
  export type SafetyMergeCategory = keyof typeof SAFETY_MERGE_SPEC;
539
568
  /** The members a category declares, as a CLOSED SET TYPE: `"allow" | "deny" | "ask"` for `permissions`, `never` for
@@ -166,7 +166,7 @@ export const SAFETY_MERGE_SPEC = {
166
166
  // releases every releasable ask; auto hands them to the classifier, which may auto-release ANY benign ask (⊇
167
167
  // acceptEdits' edits-only auto-allow); dontAsk = every would-ask → deny (allow rules still pass, tighter than default,
168
168
  // looser than plan's handsReadOnly).
169
- permissionMode: { mergeShape: "replace", minTrust: "global", tighten: { rule: "enumRank", order: ["bypassPermissions", "auto", "acceptEdits", "default", "dontAsk", "plan"] }, enforcement: "gate", fail: "closed", seam: "derive → toolPolicy / onAsk / handsReadOnly (§6.4); bypass = launch-flag only + MANAGED kill-switch" },
169
+ permissionMode: { mergeShape: "replace", minTrust: "global", tighten: { rule: "enumRank", order: ["bypassPermissions", "auto", "acceptEdits", "default", "dontAsk", "plan"] }, enforcement: "gate", fail: "closed", seam: "derive → toolPolicy / onAsk / handsReadOnly (§6.4); bypass = launch-flag only; the kill-switches are the killSwitches row" },
170
170
  handsReadOnly: { mergeShape: "replace", minTrust: "global", tighten: { rule: "boolStricter", strict: true }, enforcement: "gate", fail: "closed", seam: "handsReadOnly" },
171
171
  // The engine's shell floor has ONE word, `"always"` (the operator mandates a per-call confirmation for every shell command);
172
172
  // the earlier `off` / `classify` are refused at the engine's door, so a layer carrying either is out of vocabulary here too.
@@ -239,6 +239,24 @@ export const SAFETY_MERGE_SPEC = {
239
239
  // tightening word with a loosening consequence, the same boundary as sandboxNetwork.failIfUnavailable. Read and judged by
240
240
  // ./http-hook-allowlists (`httpHookUrlVerdict` / `httpHookEnvVarsAllowed`); not `sandboxNetwork` (that row is enforcement:"gate").
241
241
  httpHookAllowlists: { mergeShape: "denyFirst", minTrust: "global", tighten: { rule: "setNarrower" }, enforcement: "resolver", fail: "closed", seam: "http hook URL / header-env allowlists (httpHookUrlVerdict / httpHookEnvVarsAllowed)" },
242
+ // The permission-mode KILL-SWITCHES — `disableBypassPermissionsMode` / `disableAutoMode` (the switch ON removes that mode from every layer's
243
+ // choice). TIGHTEN-ONLY FROM EVERY LAYER: turning a switch on only narrows, so any settings-file layer may do it (the strict pole is
244
+ // tighten@"local", the lowest settings-FILE tier — the permissions.deny / ask law); nothing can turn it back off, because the merge is
245
+ // boolStricter(strict: true) — one layer's ON beats every layer's OFF. CC 2.1.296's restrictive-key table has the same shape
246
+ // ({path: ["permissions", "disableBypassPermissionsMode"], restrictive: "disable"}; CC spells ON "disable", consumers read it as `true`).
247
+ // There is no managed-only floor for them: before this row the table had none, a consumer stamped "managed" on them locally, and its judge
248
+ // (every source counts) and its resolver ("may only be set by managed-level configuration") said opposite things.
249
+ // VALUE-directional rows (the sandboxNetwork boolean law): the ON pole at "local"; OFF, a non-boolean or a valueless query at the row floor
250
+ // "global" = the category floor (a pre-members / pre-boolPole consumer withholds BOTH poles from the repository layers — fail-closed).
251
+ // A switch this row does not declare stays under the category floor "global", whole-key (the 1.1.0 header law).
252
+ killSwitches: {
253
+ mergeShape: "replace", minTrust: "global", tighten: { rule: "boolStricter", strict: true }, enforcement: "gate", fail: "closed",
254
+ seam: "permission-mode kill-switches (a switch ON removes bypassPermissions / auto from every layer's mode choice)",
255
+ members: {
256
+ disableBypassPermissionsMode: { direction: "tighten", minTrust: "global", boolPole: { strict: true, minTrust: "local" } },
257
+ disableAutoMode: { direction: "tighten", minTrust: "global", boolPole: { strict: true, minTrust: "local" } },
258
+ },
259
+ },
242
260
  };
243
261
  /** Own-property table lookup. The category union is compile-time only; a JavaScript or deserialized caller can
244
262
  * hand in `"__proto__"` / `"constructor"` / `"toString"`, which a bare index resolves through Object.prototype
@@ -1,41 +1,18 @@
1
- /**
2
- * Settings SOURCES and the ONE layer-admission function.
3
- *
4
- * `SAFETY_MERGE_SPEC` answers "how trusted must a layer be to set this category / member, and which way does it move the unattended
5
- * surface" (floor + direction); `trustRank` / `isAtLeast` are the ladder. What was missing is the LAYER side: which settings sources
6
- * there are, which trust tier each one carries, which of them a repository controls, and what an untrusted workspace does to them —
7
- * every host kept its own table for that. This file is that table, plus the one function a resolver asks per layer × key.
8
- *
9
- * THE RULE (one, no per-key special case): a repository-controllable source (`projectSettings` / `localSettings` — a clone brings or the
10
- * repository's tooling writes them) in a workspace whose trust reading is not `accepted` admits only the members whose published
11
- * direction is `"tighten"`; everything it may otherwise set is `trust_withheld`. In every case the source's tier must reach the floor
12
- * (`isAtLeast(tier, floor)`), checked first — a write below its floor is `below_trust_floor` whether or not the workspace is trusted, which
13
- * also keeps the loose pole of a value-directional (`boolPole`) member out of an untrusted layer. The `direction` data already says which
14
- * keys an untrusted repository may still set (restricting needs no trust); there is no per-row workspace column (that would copy a
15
- * layer fact into every category row).
16
- *
17
- * CC 2.1.296 (the reference): trust is one per-session boolean (a non-interactive session counts as trusted, `cli296.js:238351`); keys split
18
- * into the operator tier `policy / flag / user` and the repository-controllable tier `project / local` ("projectSettings and
19
- * localSettings are repo-controllable", `:237757`); merge order user < project < local < flag < policy (`:237339`).
20
- * The local layer sits in the project tier here too: a `.local` settings file is gitignored, so a clone cannot bring one, and whoever can
21
- * write it can write the project file next to it — "local below project" was never a written rule (it arrived with the first import of the
22
- * table) and holds no threat model. The category rows (`hooks` / `env` / `mcpServers` / `rulesInstructions` at `minTrust: "project"`) and
23
- * the ladder numbers are untouched: the change is on the source's tier, not on any row.
24
- *
25
- * NOT here (each has its own place): the hooks EXECUTION gate (`declined` ⇒ no hook from any layer runs) is contract text in `./hooks`'
26
- * header and executes in the client core; the workspace trust RECORD (where "this folder was accepted" is stored) is host state.
27
- */
28
1
  import { type MinTrust, type SafetyMergeCategory, type SafetyMergeMember } from "./safety-merge-spec.js";
29
2
  /** The settings sources, in MERGE order (a later source overrides an earlier one where a category's merge shape lets it):
30
3
  * user < project < local < flag < policy. `flagSettings` = settings handed in at launch (`--settings <file|json>`, an SDK embedder's inline
31
- * settings); `policySettings` = managed. */
32
- export declare const SETTINGS_SOURCES: readonly ["userSettings", "projectSettings", "localSettings", "flagSettings", "policySettings"];
4
+ * settings); `policySettings` = managed. `foreignProjectSettings` (appended) = the repository's settings files written for another tool; it
5
+ * contributes tightening writes only, and every tighten rule merges by "the stricter wins" (order-independent), so its place in the order
6
+ * changes no merged value. */
7
+ export declare const SETTINGS_SOURCES: readonly ["userSettings", "projectSettings", "localSettings", "flagSettings", "policySettings", "foreignProjectSettings"];
33
8
  export type SettingsSource = (typeof SETTINGS_SOURCES)[number];
34
- /** One source's admission facts: its trust tier (a {@link MinTrust} word, compared with `isAtLeast` against a floor) and whether a
35
- * repository controls it (⇒ subject to the workspace trust reading). */
9
+ /** One source's admission facts: its trust tier (a {@link MinTrust} word, compared with `isAtLeast` against a floor), whether a
10
+ * repository controls it (⇒ subject to the workspace trust reading), and whether it is read in the tighten direction only whatever that
11
+ * reading is (`tightenOnly`; absent = false). */
36
12
  export interface SettingsSourceContract {
37
13
  readonly trust: MinTrust;
38
14
  readonly repoControllable: boolean;
15
+ readonly tightenOnly?: boolean;
39
16
  }
40
17
  /**
41
18
  * The source table. `flagSettings` is in the operator tier (`global`, not repository-controllable) like CC's: settings handed in at launch are
@@ -63,6 +40,11 @@ export declare const SETTINGS_SOURCE_CONTRACT: {
63
40
  readonly trust: "managed";
64
41
  readonly repoControllable: false;
65
42
  };
43
+ readonly foreignProjectSettings: {
44
+ readonly trust: "project";
45
+ readonly repoControllable: true;
46
+ readonly tightenOnly: true;
47
+ };
66
48
  };
67
49
  /**
68
50
  * The ONE lookup of {@link SETTINGS_SOURCE_CONTRACT}: a source's tier and whether a repository controls it. Every function in this package
@@ -78,8 +60,9 @@ export declare function settingsSourceContract(source: SettingsSource): Settings
78
60
  export declare const WORKSPACE_TRUST_STATES: readonly ["accepted", "declined", "no_trust_step"];
79
61
  export type WorkspaceTrustState = (typeof WORKSPACE_TRUST_STATES)[number];
80
62
  /** What {@link layerAdmission} answers (closed): `admitted`; `trust_withheld` = would be admitted once the workspace is `accepted`;
81
- * `below_trust_floor` = this source can never set it (its tier is below the floor). */
82
- export declare const LAYER_ADMISSION_VERDICTS: readonly ["admitted", "trust_withheld", "below_trust_floor"];
63
+ * `below_trust_floor` = this source can never set it (its tier is below the floor); `tighten_only` = this source is read in the tighten direction
64
+ * only, so nothing — no workspace decision — admits this write from it. */
65
+ export declare const LAYER_ADMISSION_VERDICTS: readonly ["admitted", "trust_withheld", "below_trust_floor", "tighten_only"];
83
66
  export type LayerAdmissionVerdict = (typeof LAYER_ADMISSION_VERDICTS)[number];
84
67
  /**
85
68
  * Does a value from `source` for `category` (or one of its declared `member`s; `value` = the candidate value, for a value-directional member)
@@ -89,3 +72,36 @@ export type LayerAdmissionVerdict = (typeof LAYER_ADMISSION_VERDICTS)[number];
89
72
  * Unknown source / trust reading / category, a member named on a whole-key category, or an undeclared member ⇒ RangeError (never a fallback).
90
73
  */
91
74
  export declare function layerAdmission<C extends SafetyMergeCategory>(source: SettingsSource, workspace: WorkspaceTrustState, category: C, member?: SafetyMergeMember<C>, value?: unknown): LayerAdmissionVerdict;
75
+ /**
76
+ * Is a layer from `source` read in the tighten direction only, given the workspace trust reading? — the ONE answer {@link layerAdmission} and the
77
+ * disclosure ({@link buildLayerPartsUnreadNotice}) read: a tighten-only source always; a repository-controllable source while the reading is not
78
+ * `accepted`; an operator source never. Unknown source / reading ⇒ RangeError.
79
+ */
80
+ export declare function layerReadsTightenOnly(source: SettingsSource, workspace: WorkspaceTrustState): boolean;
81
+ /** The event code of the one-line disclosure of what a tighten-direction-only layer did not read ({@link buildLayerPartsUnreadNotice}). */
82
+ export declare const LAYER_PARTS_UNREAD_CODE = "settings.layer_parts_unread";
83
+ /** The disclosure: plain data — the code, the source, the parts as handed in (machine face, unaltered) and the one line (display face). */
84
+ export interface LayerPartsUnreadNotice {
85
+ readonly code: typeof LAYER_PARTS_UNREAD_CODE;
86
+ readonly source: SettingsSource;
87
+ readonly parts: readonly string[];
88
+ readonly line: string;
89
+ }
90
+ /**
91
+ * The ONE line that names what a layer read in the tighten direction only did not read — the consumer emits it under {@link LAYER_PARTS_UNREAD_CODE}.
92
+ * `file` = how the consumer shows the path of the layer's file it opened (its own display string, bounded by the platform's path limit — stripped of
93
+ * control and bidi characters but not cut, so the file name at its end survives); `parts` = the setting paths it did not read, as written in the file
94
+ * (repository-written text: echoed in the bounded caller-text form; dot-joined, document order):
95
+ * every write `layerAdmission` did not admit, AND every key the consumer does not map to a category at all (not read either). Line form —
96
+ * `<file>: read for restrictions only (<cause>) — not read: <parts>`, the cause `it is written for another tool` for a tighten-only source and
97
+ * `this workspace is not trusted` for a repository-controllable one; each part cut at 64 characters after stripping, at most 16 parts named and the rest
98
+ * counted (`+N more`). No unread part ⇒ `undefined` (nothing to disclose).
99
+ * A layer that is NOT read in the tighten direction only (an operator source; this product's repository files in an accepted workspace) ⇒ RangeError:
100
+ * this sentence would be false for it — a caller bug, loud. Unknown source / reading ⇒ RangeError.
101
+ */
102
+ export declare function buildLayerPartsUnreadNotice(input: {
103
+ readonly source: SettingsSource;
104
+ readonly workspace: WorkspaceTrustState;
105
+ readonly file: string;
106
+ readonly parts: readonly string[];
107
+ }): LayerPartsUnreadNotice | undefined;
@@ -6,13 +6,20 @@
6
6
  * there are, which trust tier each one carries, which of them a repository controls, and what an untrusted workspace does to them —
7
7
  * every host kept its own table for that. This file is that table, plus the one function a resolver asks per layer × key.
8
8
  *
9
- * THE RULE (one, no per-key special case): a repository-controllable source (`projectSettings` / `localSettings` — a clone brings or the
10
- * repository's tooling writes them) in a workspace whose trust reading is not `accepted` admits only the members whose published
11
- * direction is `"tighten"`; everything it may otherwise set is `trust_withheld`. In every case the source's tier must reach the floor
12
- * (`isAtLeast(tier, floor)`), checked first — a write below its floor is `below_trust_floor` whether or not the workspace is trusted, which
13
- * also keeps the loose pole of a value-directional (`boolPole`) member out of an untrusted layer. The `direction` data already says which
14
- * keys an untrusted repository may still set (restricting needs no trust); there is no per-row workspace column (that would copy a
15
- * layer fact into every category row).
9
+ * THE RULE (one, no per-key special case): a layer that is READ IN THE TIGHTEN DIRECTION ONLY ({@link layerReadsTightenOnly}) admits only the
10
+ * members whose published direction is `"tighten"`. Which layers those are is one answer, from the source table and the workspace reading:
11
+ * · a repository-controllable source of this product (`projectSettings` / `localSettings` — a clone brings or the repository's tooling writes
12
+ * them) while the workspace trust reading is not `accepted` — everything else it may set is `trust_withheld` (accepting the workspace admits it);
13
+ * · a tighten-only source (`foreignProjectSettings` — the repository's settings files written for ANOTHER tool, today the `.claude/settings.json` /
14
+ * `.claude/settings.local.json` a cloned repository carries) whatever the reading — everything else is `tighten_only` (nothing admits it:
15
+ * accepting a workspace is consent to this product's files, not to another tool's hooks, env and allow rules).
16
+ * In every case the source's tier must reach the floor (`isAtLeast(tier, floor)`), checked first — a write below its floor is `below_trust_floor`
17
+ * whatever the reading, which also keeps the loose pole of a value-directional (`boolPole`) member out of such a layer. The `direction` data
18
+ * already says which keys such a layer may still set (restricting needs no trust); there is no per-row workspace or source column (that would
19
+ * copy a layer fact into every category row).
20
+ *
21
+ * DISCLOSURE (one event code, {@link LAYER_PARTS_UNREAD_CODE}): a layer read in the tighten direction only names what it did not read in ONE
22
+ * line ({@link buildLayerPartsUnreadNotice}) — the same line form for both causes above, the cause in parentheses.
16
23
  *
17
24
  * CC 2.1.296 (the reference): trust is one per-session boolean (a non-interactive session counts as trusted, `cli296.js:238351`); keys split
18
25
  * into the operator tier `policy / flag / user` and the repository-controllable tier `project / local` ("projectSettings and
@@ -25,11 +32,15 @@
25
32
  * NOT here (each has its own place): the hooks EXECUTION gate (`declined` ⇒ no hook from any layer runs) is contract text in `./hooks`'
26
33
  * header and executes in the client core; the workspace trust RECORD (where "this folder was accepted" is stored) is host state.
27
34
  */
35
+ import { echoCallerText } from "./caller-text-echo.js";
36
+ import { stripControlAndBidi } from "./control-and-bidi.js";
28
37
  import { SAFETY_MERGE_SPEC, isAtLeast, memberAdmission } from "./safety-merge-spec.js";
29
38
  /** The settings sources, in MERGE order (a later source overrides an earlier one where a category's merge shape lets it):
30
39
  * user < project < local < flag < policy. `flagSettings` = settings handed in at launch (`--settings <file|json>`, an SDK embedder's inline
31
- * settings); `policySettings` = managed. */
32
- export const SETTINGS_SOURCES = ["userSettings", "projectSettings", "localSettings", "flagSettings", "policySettings"];
40
+ * settings); `policySettings` = managed. `foreignProjectSettings` (appended) = the repository's settings files written for another tool; it
41
+ * contributes tightening writes only, and every tighten rule merges by "the stricter wins" (order-independent), so its place in the order
42
+ * changes no merged value. */
43
+ export const SETTINGS_SOURCES = ["userSettings", "projectSettings", "localSettings", "flagSettings", "policySettings", "foreignProjectSettings"];
33
44
  /**
34
45
  * The source table. `flagSettings` is in the operator tier (`global`, not repository-controllable) like CC's: settings handed in at launch are
35
46
  * the launcher's own act — an SDK embedder or a wrapper script — not something a repository brings (a wrapper that turns repository content
@@ -41,6 +52,10 @@ export const SETTINGS_SOURCE_CONTRACT = {
41
52
  localSettings: { trust: "project", repoControllable: true },
42
53
  flagSettings: { trust: "global", repoControllable: false },
43
54
  policySettings: { trust: "managed", repoControllable: false },
55
+ // The repository's settings files written for another tool (a cloned repository's `.claude/settings.json` / `.claude/settings.local.json`):
56
+ // project tier and repository-controlled like this product's own project files, and tighten-only — its deny / ask rules, kill-switches ON and
57
+ // sandbox restrictions are read, its allow rules, hooks, env, MCP servers and the rest never are (see the file header).
58
+ foreignProjectSettings: { trust: "project", repoControllable: true, tightenOnly: true },
44
59
  };
45
60
  /**
46
61
  * The ONE lookup of {@link SETTINGS_SOURCE_CONTRACT}: a source's tier and whether a repository controls it. Every function in this package
@@ -60,8 +75,9 @@ export function settingsSourceContract(source) {
60
75
  */
61
76
  export const WORKSPACE_TRUST_STATES = ["accepted", "declined", "no_trust_step"];
62
77
  /** What {@link layerAdmission} answers (closed): `admitted`; `trust_withheld` = would be admitted once the workspace is `accepted`;
63
- * `below_trust_floor` = this source can never set it (its tier is below the floor). */
64
- export const LAYER_ADMISSION_VERDICTS = ["admitted", "trust_withheld", "below_trust_floor"];
78
+ * `below_trust_floor` = this source can never set it (its tier is below the floor); `tighten_only` = this source is read in the tighten direction
79
+ * only, so nothing — no workspace decision — admits this write from it. */
80
+ export const LAYER_ADMISSION_VERDICTS = ["admitted", "trust_withheld", "below_trust_floor", "tighten_only"];
65
81
  /**
66
82
  * Does a value from `source` for `category` (or one of its declared `member`s; `value` = the candidate value, for a value-directional member)
67
83
  * enter the effective settings, given the workspace trust reading? The rule is in the file header; the data is {@link SETTINGS_SOURCE_CONTRACT}
@@ -71,7 +87,7 @@ export const LAYER_ADMISSION_VERDICTS = ["admitted", "trust_withheld", "below_tr
71
87
  */
72
88
  export function layerAdmission(source, workspace, category, member, value) {
73
89
  const layer = settingsSourceContract(source);
74
- const trusted = workspaceAccepted(workspace);
90
+ const tightenOnlyLayer = readsTightenOnly(layer, workspace); // an unknown reading throws here, before any category lookup
75
91
  let floor;
76
92
  let direction;
77
93
  if (member === undefined) {
@@ -87,9 +103,55 @@ export function layerAdmission(source, workspace, category, member, value) {
87
103
  }
88
104
  if (!isAtLeast(layer.trust, floor))
89
105
  return "below_trust_floor";
90
- if (layer.repoControllable && !trusted && direction !== "tighten")
91
- return "trust_withheld";
92
- return "admitted";
106
+ if (direction === "tighten" || !tightenOnlyLayer)
107
+ return "admitted";
108
+ return layer.tightenOnly === true ? "tighten_only" : "trust_withheld";
109
+ }
110
+ /**
111
+ * Is a layer from `source` read in the tighten direction only, given the workspace trust reading? — the ONE answer {@link layerAdmission} and the
112
+ * disclosure ({@link buildLayerPartsUnreadNotice}) read: a tighten-only source always; a repository-controllable source while the reading is not
113
+ * `accepted`; an operator source never. Unknown source / reading ⇒ RangeError.
114
+ */
115
+ export function layerReadsTightenOnly(source, workspace) {
116
+ return readsTightenOnly(settingsSourceContract(source), workspace);
117
+ }
118
+ function readsTightenOnly(layer, workspace) {
119
+ const accepted = workspaceAccepted(workspace); // judged first: an unknown reading throws whatever the source
120
+ return layer.tightenOnly === true || (layer.repoControllable && !accepted);
121
+ }
122
+ /** The event code of the one-line disclosure of what a tighten-direction-only layer did not read ({@link buildLayerPartsUnreadNotice}). */
123
+ export const LAYER_PARTS_UNREAD_CODE = "settings.layer_parts_unread";
124
+ /** How many unread parts the line names before it counts the rest (`+N more`): a repository's file can carry any number of keys. */
125
+ const NOTICE_PARTS_SHOWN = 16;
126
+ /**
127
+ * The ONE line that names what a layer read in the tighten direction only did not read — the consumer emits it under {@link LAYER_PARTS_UNREAD_CODE}.
128
+ * `file` = how the consumer shows the path of the layer's file it opened (its own display string, bounded by the platform's path limit — stripped of
129
+ * control and bidi characters but not cut, so the file name at its end survives); `parts` = the setting paths it did not read, as written in the file
130
+ * (repository-written text: echoed in the bounded caller-text form; dot-joined, document order):
131
+ * every write `layerAdmission` did not admit, AND every key the consumer does not map to a category at all (not read either). Line form —
132
+ * `<file>: read for restrictions only (<cause>) — not read: <parts>`, the cause `it is written for another tool` for a tighten-only source and
133
+ * `this workspace is not trusted` for a repository-controllable one; each part cut at 64 characters after stripping, at most 16 parts named and the rest
134
+ * counted (`+N more`). No unread part ⇒ `undefined` (nothing to disclose).
135
+ * A layer that is NOT read in the tighten direction only (an operator source; this product's repository files in an accepted workspace) ⇒ RangeError:
136
+ * this sentence would be false for it — a caller bug, loud. Unknown source / reading ⇒ RangeError.
137
+ */
138
+ export function buildLayerPartsUnreadNotice(input) {
139
+ const layer = settingsSourceContract(input.source);
140
+ if (!readsTightenOnly(layer, input.workspace)) {
141
+ throw new RangeError(`buildLayerPartsUnreadNotice: source '${input.source}' is not read in the tighten direction only under workspace reading '${input.workspace}' — there is nothing "read for restrictions only" to disclose`);
142
+ }
143
+ if (input.parts.length === 0)
144
+ return undefined;
145
+ const cause = layer.tightenOnly === true ? "it is written for another tool" : "this workspace is not trusted";
146
+ const shown = input.parts.slice(0, NOTICE_PARTS_SHOWN).map(echoCallerText);
147
+ const rest = input.parts.length - shown.length;
148
+ const named = rest > 0 ? `${shown.join(", ")}, +${rest} more` : shown.join(", ");
149
+ return {
150
+ code: LAYER_PARTS_UNREAD_CODE,
151
+ source: input.source,
152
+ parts: [...input.parts],
153
+ line: `${stripControlAndBidi(input.file)}: read for restrictions only (${cause}) — not read: ${named}`,
154
+ };
93
155
  }
94
156
  /** `accepted` ⇒ true; `declined` / `no_trust_step` ⇒ false (they differ only at the hooks execution gate, not here); anything else ⇒ RangeError. */
95
157
  function workspaceAccepted(workspace) {