@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.
- package/CHANGELOG.md +95 -0
- package/README.md +24 -4
- package/conformance/VERSION +1 -1
- package/dist/caller-text-echo.d.ts +4 -0
- package/dist/caller-text-echo.js +13 -0
- package/dist/control-and-bidi.d.ts +21 -0
- package/dist/control-and-bidi.js +29 -0
- package/dist/hooks-config.d.ts +6 -2
- package/dist/hooks-config.js +11 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mcp-request-entry.d.ts +29 -30
- package/dist/mcp-request-entry.js +116 -19
- package/dist/mcp-server-policy.d.ts +2 -1
- package/dist/mcp-server-policy.js +5 -3
- package/dist/safety-merge-spec.d.ts +30 -1
- package/dist/safety-merge-spec.js +19 -1
- package/dist/settings-sources.d.ts +49 -33
- package/dist/settings-sources.js +77 -15
- package/docs/API-SURFACE.md +9 -2
- package/docs/CC-DIVERGENCE.md +6 -0
- package/llms-full.txt +23 -3
- package/llms.txt +15 -7
- package/package.json +1 -1
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
* 或者自己抄一份 —— 两份判据就是将来漂开的那一份。现在判官在契约包,服务端与壳读同一份,同一条条目在两处同受理 / 同拒、同一个键路径、同一句原因。
|
|
8
8
|
*
|
|
9
9
|
* ## 语义是搬来的,不是重写的
|
|
10
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
65
|
-
*
|
|
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
|
-
/**
|
|
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 =
|
|
77
|
-
|
|
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
|
-
/** 坏内容出处值的**可打印**摘要:串
|
|
113
|
+
/** 坏内容出处值的**可打印**摘要:串 ⇒ 引号形的回显({@link quotedCallerText}:剥控制与双向字符、截 64 字);非串只说种类。 */
|
|
89
114
|
export function describeBadContentOrigin(v) {
|
|
90
|
-
|
|
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`
|
|
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
|
-
|
|
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
|
-
...(
|
|
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
|
-
|
|
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
|
-
...(
|
|
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」)
|
|
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」)
|
|
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
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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;
|
package/dist/settings-sources.js
CHANGED
|
@@ -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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
91
|
-
return "
|
|
92
|
-
return "
|
|
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) {
|