@sema-agent/settings-schema 12.3.0 → 13.0.1
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 +188 -0
- package/README.md +33 -4
- package/conformance/VERSION +1 -1
- package/conformance/cases/c01-entry-salvage-then-typo/expected.json +1 -3
- package/conformance/cases/c03-literal-dotted-top-key/expected.json +4 -12
- package/conformance/cases/c10-record-key-secret-redacted-twice/expected.json +1 -3
- package/conformance/cases/c23-mixed-validity-models/expected.json +2 -6
- package/conformance/cases/c24-valid-entries-with-unknown-keys/expected.json +2 -6
- package/conformance/cases/c25-published-roundtrip-zero-warnings/expected.json +2 -6
- package/conformance/cases/c30-version-triple-a-first/expected.json +2 -6
- package/conformance/cases/c31-version-triple-b-changed/expected.json +2 -6
- package/conformance/cases/c32-version-triple-a-restored/expected.json +2 -6
- package/conformance/cases/c40-s712-identity-only-what-reads-out/expected.json +1 -3
- package/conformance/cases/c41-s792-encoded-pem-identity/expected.json +1 -3
- package/conformance/cases/c42-s792-unknown-key-secret-redacted/expected.json +2 -6
- package/conformance/cases/c43-s792-form-and-deep-encoded-pem/expected.json +1 -3
- package/conformance/cases/c44-s792-utf8-escape-glued-token/expected.json +1 -3
- package/conformance/cases/c45-s792-identity-bounds-and-decode-depth/expected.json +1 -3
- package/conformance/cases/c47-s811-roles-key-url-userinfo-withheld/expected.json +1 -3
- package/conformance/cases/c48-s935-model-headers-transported/expected.json +6 -18
- package/conformance/cases/c49-s935-model-headers-secret-entry-dropped/expected.json +1 -3
- package/conformance/cases/c69-deep-encoded-token-past-every-reading/expected.json +1 -3
- package/conformance/cases/c70-rewritten-token-in-front-of-encoded-pem-header/expected.json +1 -3
- package/conformance/cases/c72-dangling-reference-write-seat-refuses/expected.json +1 -3
- package/conformance/cases/cc03-model-compat-unknown-subkey/expected.json +2 -6
- package/conformance/cases/cli01-model-name-nfc-vs-nfd/expected.json +1 -3
- package/conformance/cases/cli02-control-and-zero-width-chars/expected.json +1 -3
- package/conformance/cases/cli05-long-and-prefix-colliding-names/expected.json +6 -18
- package/conformance/cases/t01-absent-domain-key-not-null/expected.json +1 -3
- package/conformance/cases/t03-prototype-name-key-not-swallowed/expected.json +2 -6
- package/conformance/cases/t04-secret-literal-never-in-warning-value/expected.json +1 -3
- package/dist/hooks-config.d.ts +243 -0
- package/dist/hooks-config.js +466 -0
- package/dist/hooks.d.ts +16 -154
- package/dist/hooks.js +17 -111
- package/dist/http-hook-allowlists.d.ts +63 -0
- package/dist/http-hook-allowlists.js +73 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +6 -1
- package/dist/mcp-server-policy.d.ts +75 -0
- package/dist/mcp-server-policy.js +84 -0
- package/dist/safety-merge-spec.d.ts +13 -3
- package/dist/safety-merge-spec.js +10 -3
- package/dist/settings-sources.d.ts +91 -0
- package/dist/settings-sources.js +107 -0
- package/dist/types.d.ts +6 -6
- package/dist/types.js +9 -5
- package/dist/ui-language.d.ts +40 -0
- package/dist/ui-language.js +37 -0
- package/dist/url-pattern.d.ts +33 -0
- package/dist/url-pattern.js +239 -0
- package/docs/API-SURFACE.md +1395 -0
- package/docs/CC-DIVERGENCE.md +42 -0
- package/docs/CONFORMANCE.md +344 -0
- package/docs/CONSUMER-INTEGRATION.md +282 -0
- package/docs/openapi-auth.yaml +357 -0
- package/docs/openapi-scopes.yaml +281 -0
- package/llms-full.txt +656 -0
- package/llms.txt +174 -0
- package/package.json +4 -1
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL 名单里一条模式对一个 URL 的**唯一判法**(13.0.0,U3 [10538] / S-1597)。管理员写的 URL 名单 —— http hook `allowedHttpHookUrls`
|
|
3
|
+
* ({@link ./http-hook-allowlists})、MCP `allowedMcpServers` / `deniedMcpServers` 的 `serverUrl` 条目({@link ./mcp-server-policy})——
|
|
4
|
+
* 都读这一只。纯函数,全局只用 `URL`,浏览器安全。
|
|
5
|
+
*
|
|
6
|
+
* 一条律,两只出口:**名单读不准的地方一律判向「拦」**。放行出口在含糊处不命中(命中面 ⊆ 上游),拒绝出口在含糊处命中(命中面 ⊇ 上游)。
|
|
7
|
+
*
|
|
8
|
+
* 组件判法(上游同形:CC 2.1.296 `g9`,cli296.js:249195;上游两张名单都调它):
|
|
9
|
+
* - 模式恰为 `*` ⇒ 一切命中;URL 自身解析不了 ⇒ 不命中。
|
|
10
|
+
* - 模式里的 `*` 换成占位记号后按 URL 解析;解析不了且端口位是 `*` ⇒ 端口位换 0 再解析,并记「端口不判」。
|
|
11
|
+
* - 协议:相等,或模式协议整段是 `*`。主机名:两边去尾点、小写后按 glob 比。
|
|
12
|
+
* - 端口:模式没写端口且主机名带 `*` ⇒ 不判;否则逐字相等(协议缺省端口两边都读成空)。
|
|
13
|
+
* - 路径:模式路径是 `/` 或空、没有查询串、且模式原文不以 `/` 结尾 ⇒ 任意路径命中;否则「路径 + 查询串」按 glob 比。片段、用户信息不比。
|
|
14
|
+
* - 模式解析不出 URL(或特殊协议的主机名里出现带通配的 punycode 标签)⇒ 回落成按 `/` 分段的 glob(段数相等、逐段比,`*` 跨不过 `/`)。
|
|
15
|
+
* glob 只认 `*`(任意长度任意字符,含空),其余逐字、区分大小写。
|
|
16
|
+
*
|
|
17
|
+
* 放行出口比上游严(上游四洞;登本包 docs/CC-DIVERGENCE.md,与 cli 偏离登记 D-025 / D-026 / D-028 / D-029 同形):
|
|
18
|
+
* - D-025 模式写了端口(哪怕是协议缺省端口,解析后读成空)⇒ 主机名带 `*` 也照判端口(上游把写明的 `:443` 当没写、放行任意端口)。
|
|
19
|
+
* 「写没写」由解析器自己答:同一串换成缺省端口不同的同族协议再解析,端口不再读成空 ⇒ 写了。
|
|
20
|
+
* - D-026 目标协议不在「主机即连接目标」的四种(http / https / ws / wss)⇒ 模式必须写出那个协议,`*` 协议不通配它;`ws+unix:` 的连接目标
|
|
21
|
+
* 是路径里第一个 `:` 之前的套接字路径 ⇒ 套接字段只认逐字相等(带 `*` 即不放行任何套接字 —— 套接字路径到连接层还会被点段 / 反斜杠 /
|
|
22
|
+
* 长度截断改写,glob 判不住真实目标);`:` 之后的请求路径照 glob;没写路径的模式、套接字段为空或含 `..` 段的目标一律不放行。
|
|
23
|
+
* - D-028 模式解析不出 URL 形(走分段回落)⇒ 不放行:回落把主机与端口拼成一段比,`*` 能跨过两者的边界。
|
|
24
|
+
* - D-029 解析后主机名里出现不是占位来的 `*`(`%2a` / 全角 `*` 被解析器解码、归一成的字面 `*`)⇒ 不放行:那是字面字符,不是通配。
|
|
25
|
+
* 拒绝出口比上游宽一处(D-027):另按整串 glob 再判一次,既有的无协议 / 跨组件写法不因按组件判而失效。
|
|
26
|
+
*
|
|
27
|
+
* 占位记号(与上游 / cli 不同的一处实现):上游用每进程随机串;本包要纯、要确定 ⇒ 按输入选一个记号 —— 它在模式与目标的任何解析读法里
|
|
28
|
+
* (原文 / 百分号解码 / IDNA 近似映射)都不出现,放进模式后也只在放进去的那几处出现(不与相邻字面拼出第二份)。
|
|
29
|
+
*/
|
|
30
|
+
/** 解析规则特殊、可能带缺省端口的协议(上游 `ojo`)。 */
|
|
31
|
+
const SPECIAL_SCHEMES = new Set(["http:", "https:", "ws:", "wss:", "ftp:", "file:"]);
|
|
32
|
+
/** 放行出口里 `*` 协议能通配的目标协议:这四种的连接目标就是 URL 的主机与端口。 */
|
|
33
|
+
const HOST_ROUTED_SCHEMES = new Set(["http:", "https:", "ws:", "wss:"]);
|
|
34
|
+
/** 有缺省端口的特殊协议 → 解析规则相同、缺省端口不同的同族协议(判「缺省端口是不是写出来的」用)。 */
|
|
35
|
+
const OTHER_DEFAULT_PORT_SCHEME = { "http:": "https", "https:": "http", "ws:": "wss", "wss:": "ws", "ftp:": "http" };
|
|
36
|
+
/** 连接目标是路径里第一个 `:` 之前那一段(套接字路径)的协议。 */
|
|
37
|
+
const SOCKET_PATH_SCHEME = "ws+unix:";
|
|
38
|
+
function parseUrl(s) {
|
|
39
|
+
try {
|
|
40
|
+
return new URL(s);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function occurrences(haystack, needle) {
|
|
47
|
+
return haystack.split(needle).length - 1;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* 一段文本在 URL 解析器眼里可能成为的样子:先过解析器自己的输入预处理({@link asParserSees}:去首尾 C0 与空格、删掉所有 TAB / LF / CR ——
|
|
51
|
+
* 不先做这一步,`zzwild<TAB>card0qq` 会在解析后拼回记号,codex r1 [high]),再取原文与百分号解码两读,各按主机名的 IDNA 映射近似
|
|
52
|
+
* (去默认可忽略码位、NFKC、小写)。
|
|
53
|
+
*/
|
|
54
|
+
function parserReadings(s) {
|
|
55
|
+
const fold = (x) => x.replace(/\p{Default_Ignorable_Code_Point}/gu, "").normalize("NFKC").toLowerCase().normalize("NFKC");
|
|
56
|
+
const seen = asParserSees(s);
|
|
57
|
+
const decoded = seen.replace(/(?:%[0-9a-f]{2})+/gi, (run) => {
|
|
58
|
+
try {
|
|
59
|
+
return decodeURIComponent(run);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return run;
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
return [fold(seen), fold(decoded)];
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* 选一个占位记号 `zz<stem><i>qq`:`avoid`(模式原文与目标 URL)的任何读法里都不出现,`build(token)` 造出的串在每一种读法里恰好出现
|
|
69
|
+
* `inserted` 次(只有自己放进去的那几处)。`avoid` 的读法只算一次、一遍扫出所有已被占用的序号,再取最小的空号 —— 工作量与输入长度成
|
|
70
|
+
* 线性(逐个候选重扫整段 `avoid` 会让塞满记号原文的目标 URL 逼出平方级工作,codex r1 [medium])。记号首尾不同(`zz…qq`)⇒ 不与自己重叠,
|
|
71
|
+
* 按切分数出现次数是准的,两处出现也不可能互相交叠。纯、确定。
|
|
72
|
+
*/
|
|
73
|
+
function freeToken(stem, avoid, build) {
|
|
74
|
+
const taken = new Set();
|
|
75
|
+
const shape = new RegExp(`zz${stem}(\\d+)qq`, "g");
|
|
76
|
+
for (const reading of parserReadings(avoid))
|
|
77
|
+
for (const m of reading.matchAll(shape))
|
|
78
|
+
taken.add(m[1]);
|
|
79
|
+
for (let i = 0;; i++) {
|
|
80
|
+
if (taken.has(String(i)))
|
|
81
|
+
continue;
|
|
82
|
+
const token = `zz${stem}${i}qq`;
|
|
83
|
+
const { built, inserted } = build(token);
|
|
84
|
+
if (parserReadings(built).every((r) => occurrences(r, token) === inserted))
|
|
85
|
+
return { token, built };
|
|
86
|
+
taken.add(String(i));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** URL 解析器的输入预处理:去首尾 C0 控制符与空格(码位 ≤ U+0020)、删掉串里所有 TAB / LF / CR。去首尾按码位从两端扫(线性);
|
|
90
|
+
* 不用 `[\0-\x20]+$` 这类锚尾正则 —— 它会从串中间每一段空白的每个位置重试,长段空白上是平方级(13.0.0 codex 轮 [medium])。 */
|
|
91
|
+
function asParserSees(s) {
|
|
92
|
+
let start = 0;
|
|
93
|
+
let end = s.length;
|
|
94
|
+
while (start < end && s.charCodeAt(start) <= 0x20)
|
|
95
|
+
start++;
|
|
96
|
+
while (end > start && s.charCodeAt(end - 1) <= 0x20)
|
|
97
|
+
end--;
|
|
98
|
+
return s.slice(start, end).replace(/[\t\n\r]/g, "");
|
|
99
|
+
}
|
|
100
|
+
/** 解析后端口读成空的模式,端口是不是写出来的缺省端口:换成缺省端口不同的同族协议再解析一次,端口不再为空 ⇒ 写了。 */
|
|
101
|
+
function defaultPortWritten(marked, pat) {
|
|
102
|
+
if (pat.port !== "")
|
|
103
|
+
return false;
|
|
104
|
+
const other = OTHER_DEFAULT_PORT_SCHEME[pat.protocol];
|
|
105
|
+
if (other === undefined)
|
|
106
|
+
return false;
|
|
107
|
+
const seen = asParserSees(marked);
|
|
108
|
+
const colon = seen.indexOf(":");
|
|
109
|
+
if (colon === -1)
|
|
110
|
+
return false;
|
|
111
|
+
const alt = parseUrl(other + seen.slice(colon));
|
|
112
|
+
return alt !== undefined && alt.port !== "";
|
|
113
|
+
}
|
|
114
|
+
/** 模式写出了目标协议(按解析器看到的串,大小写不计)。 */
|
|
115
|
+
function namesScheme(pattern, protocol) {
|
|
116
|
+
return asParserSees(pattern).toLowerCase().startsWith(protocol);
|
|
117
|
+
}
|
|
118
|
+
/** 路径里第一个 `:` 之前那一段(套接字协议的连接目标)。 */
|
|
119
|
+
function socketPart(path) {
|
|
120
|
+
const i = path.indexOf(":");
|
|
121
|
+
return i === -1 ? path : path.slice(0, i);
|
|
122
|
+
}
|
|
123
|
+
/** 整串 glob:`*` = 任意长度任意字符;没有 `*` ⇒ 逐字相等。 */
|
|
124
|
+
function globMatches(pattern, value) {
|
|
125
|
+
const parts = pattern.split("*");
|
|
126
|
+
const head = parts[0] ?? "";
|
|
127
|
+
if (parts.length === 1)
|
|
128
|
+
return head === value;
|
|
129
|
+
const tail = parts[parts.length - 1] ?? "";
|
|
130
|
+
const end = value.length - tail.length;
|
|
131
|
+
if (end < head.length || !value.startsWith(head) || !value.endsWith(tail))
|
|
132
|
+
return false;
|
|
133
|
+
let at = head.length;
|
|
134
|
+
for (const mid of parts.slice(1, -1)) {
|
|
135
|
+
if (mid === "")
|
|
136
|
+
continue;
|
|
137
|
+
const found = value.indexOf(mid, at);
|
|
138
|
+
if (found === -1 || found + mid.length > end)
|
|
139
|
+
return false;
|
|
140
|
+
at = found + mid.length;
|
|
141
|
+
}
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
function nextSeparator(s, separators, from) {
|
|
145
|
+
let i = from;
|
|
146
|
+
while (i < s.length && !separators.includes(s.charAt(i)))
|
|
147
|
+
i++;
|
|
148
|
+
return i;
|
|
149
|
+
}
|
|
150
|
+
/** 按分隔符逐段 glob:分隔符逐位相同、段数相等、每段 glob 命中(`*` 跨不过分隔符)。 */
|
|
151
|
+
function segmentedGlobMatches(pattern, value, separators) {
|
|
152
|
+
let p = 0;
|
|
153
|
+
let v = 0;
|
|
154
|
+
for (;;) {
|
|
155
|
+
const pe = nextSeparator(pattern, separators, p);
|
|
156
|
+
const ve = nextSeparator(value, separators, v);
|
|
157
|
+
if (pattern.charAt(pe) !== value.charAt(ve) || !globMatches(pattern.slice(p, pe), value.slice(v, ve)))
|
|
158
|
+
return false;
|
|
159
|
+
if (pe === pattern.length)
|
|
160
|
+
return true;
|
|
161
|
+
p = pe + 1;
|
|
162
|
+
v = ve + 1;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/** 组件判法(见文件头);`allow` 多四处收紧(D-025 / D-026 / D-028 / D-029),`upstream` 与上游逐支同形。 */
|
|
166
|
+
function componentMatches(url, pattern, reading) {
|
|
167
|
+
if (pattern === "*")
|
|
168
|
+
return true;
|
|
169
|
+
const target = parseUrl(url);
|
|
170
|
+
if (target === undefined)
|
|
171
|
+
return false;
|
|
172
|
+
const strict = reading === "allow";
|
|
173
|
+
if (strict && !HOST_ROUTED_SCHEMES.has(target.protocol) && !namesScheme(pattern, target.protocol))
|
|
174
|
+
return false;
|
|
175
|
+
const socketTarget = strict && target.protocol === SOCKET_PATH_SCHEME;
|
|
176
|
+
if (socketTarget) {
|
|
177
|
+
const socket = socketPart(target.pathname + target.search);
|
|
178
|
+
if (socket === "" || socket.split("/").includes(".."))
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
const { token: WILDCARD, built: starred } = freeToken("wildcard", `${pattern}\n${url}`, (t) => ({ built: pattern.replaceAll("*", t), inserted: occurrences(pattern, "*") }));
|
|
182
|
+
// 以 `xn--` 开头且带通配的标签前缀一个护符,免得解析器把它当 punycode 解码失败;比之前剥掉(上游 `j9t` / `BKe`)。
|
|
183
|
+
const { token: SHIELD, built: marked } = freeToken("punycode", `${starred}\n${url}`, (t) => {
|
|
184
|
+
let inserted = 0;
|
|
185
|
+
const built = starred.replace(/[a-z0-9-]+/gi, (run) => {
|
|
186
|
+
if (!/^xn--/i.test(run) || !run.includes(WILDCARD))
|
|
187
|
+
return run;
|
|
188
|
+
inserted++;
|
|
189
|
+
return t + run;
|
|
190
|
+
});
|
|
191
|
+
return { built, inserted };
|
|
192
|
+
});
|
|
193
|
+
const unshield = (s) => s.replaceAll(SHIELD, "");
|
|
194
|
+
let portAny = false;
|
|
195
|
+
let pat = parseUrl(marked);
|
|
196
|
+
if (pat === undefined) {
|
|
197
|
+
const zeroPort = marked.replace(new RegExp(`:${WILDCARD}(?=[/?#]|$)`), ":0");
|
|
198
|
+
if (zeroPort !== marked) {
|
|
199
|
+
pat = parseUrl(zeroPort);
|
|
200
|
+
portAny = pat !== undefined;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// 兜底:解析结果里记号出现得比放进去的还多 ⇒ 有一处是解析器从字面拼出来的,不是通配 —— 按「解析不出」处理(放行出口不放,拒绝出口回落)。
|
|
204
|
+
if (pat !== undefined && (occurrences(pat.href, WILDCARD) > occurrences(pattern, "*") || occurrences(pat.href, SHIELD) > occurrences(marked, SHIELD)))
|
|
205
|
+
pat = undefined;
|
|
206
|
+
if (pat === undefined || (SPECIAL_SCHEMES.has(pat.protocol) && pat.hostname.split(".").some((l) => l.startsWith("xn--") && l.includes(WILDCARD)))) {
|
|
207
|
+
return !strict && segmentedGlobMatches(pattern, `${target.protocol}//${target.host}${target.pathname}${target.search}`, "/");
|
|
208
|
+
}
|
|
209
|
+
if (pat.protocol !== `${WILDCARD}:` && pat.protocol !== target.protocol)
|
|
210
|
+
return false;
|
|
211
|
+
if (strict && unshield(pat.hostname).includes("*"))
|
|
212
|
+
return false;
|
|
213
|
+
const host = target.hostname.replace(/\.$/, "").toLowerCase();
|
|
214
|
+
const hostPattern = unshield(pat.hostname).replace(/\.$/, "").toLowerCase().replaceAll(WILDCARD, "*");
|
|
215
|
+
if (!globMatches(hostPattern, host))
|
|
216
|
+
return false;
|
|
217
|
+
if (pat.port === "" && pat.hostname.includes(WILDCARD) && !(strict && defaultPortWritten(marked, pat)))
|
|
218
|
+
portAny = true;
|
|
219
|
+
if (!portAny && pat.port !== target.port)
|
|
220
|
+
return false;
|
|
221
|
+
if ((pat.pathname === "/" || pat.pathname === "") && pat.search === "" && !marked.endsWith("/"))
|
|
222
|
+
return !socketTarget;
|
|
223
|
+
const pathPattern = unshield(pat.pathname + pat.search).replaceAll(WILDCARD, "*");
|
|
224
|
+
const targetPath = target.pathname + target.search;
|
|
225
|
+
if (!globMatches(pathPattern, targetPath))
|
|
226
|
+
return false;
|
|
227
|
+
if (!socketTarget)
|
|
228
|
+
return true;
|
|
229
|
+
const socketPattern = socketPart(pathPattern);
|
|
230
|
+
return !socketPattern.includes("*") && socketPattern === socketPart(targetPath);
|
|
231
|
+
}
|
|
232
|
+
/** 放行名单:`url` 是否被模式 `pattern` 放行(判法见文件头;命中面 ⊆ 上游)。 */
|
|
233
|
+
export function urlAllowPatternMatches(url, pattern) {
|
|
234
|
+
return componentMatches(url, pattern, "allow");
|
|
235
|
+
}
|
|
236
|
+
/** 拒绝名单:`url` 是否被模式 `pattern` 拦下(上游组件判法,或整串 glob;命中面 ⊇ 上游)。 */
|
|
237
|
+
export function urlDenyPatternMatches(url, pattern) {
|
|
238
|
+
return componentMatches(url, pattern, "upstream") || globMatches(pattern, url);
|
|
239
|
+
}
|