kczx-user-management 1.0.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.
@@ -0,0 +1,184 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Standalone login/register page served at `/login`.
5
+ *
6
+ * Deliberately independent of the host SPA (which the gate blocks until
7
+ * authenticated): one self-contained HTML string, no external assets, dark/
8
+ * light adaptive. Talks to /user-management/api like everything else.
9
+ */
10
+
11
+ function esc(value) {
12
+ return String(value).replace(/[&<>"']/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch]))
13
+ }
14
+
15
+ const PAGE_STYLES = `
16
+ :root { color-scheme: light dark; }
17
+ * { box-sizing: border-box; }
18
+ body {
19
+ margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
20
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
21
+ background: #f5f6f8; color: #1f2329;
22
+ }
23
+ @media (prefers-color-scheme: dark) {
24
+ body { background: #16181c; color: #e8eaed; }
25
+ }
26
+ .card {
27
+ width: 360px; max-width: calc(100vw - 32px); padding: 32px 28px 24px;
28
+ border-radius: 14px; background: #ffffff; border: 1px solid rgba(0,0,0,.08);
29
+ box-shadow: 0 8px 30px rgba(0,0,0,.08);
30
+ }
31
+ @media (prefers-color-scheme: dark) {
32
+ .card { background: #22252b; border-color: rgba(255,255,255,.08); box-shadow: 0 8px 30px rgba(0,0,0,.4); }
33
+ }
34
+ h1 { font-size: 18px; margin: 0 0 4px; text-align: center; }
35
+ .sub { font-size: 12px; opacity: .6; text-align: center; margin: 0 0 20px; }
36
+ .tabs { display: flex; gap: 4px; background: rgba(127,127,127,.12); border-radius: 8px; padding: 3px; margin-bottom: 18px; }
37
+ .tabs button {
38
+ flex: 1; border: 0; background: transparent; color: inherit; font-size: 13px;
39
+ padding: 7px 0; border-radius: 6px; cursor: pointer;
40
+ }
41
+ .tabs button.active { background: #ffffff; box-shadow: 0 1px 3px rgba(0,0,0,.15); }
42
+ @media (prefers-color-scheme: dark) { .tabs button.active { background: #3a3f47; } }
43
+ label { display: block; font-size: 12px; opacity: .75; margin: 12px 0 4px; }
44
+ label .opt { opacity: .8; font-weight: 400; }
45
+ input {
46
+ width: 100%; padding: 9px 11px; font-size: 14px; border-radius: 8px;
47
+ border: 1px solid rgba(127,127,127,.35); background: transparent; color: inherit; outline: none;
48
+ }
49
+ input:focus { border-color: #4c6ef5; }
50
+ .remember { display: flex; align-items: center; gap: 6px; font-size: 12px; opacity: .85; margin: 12px 0 0; cursor: pointer; user-select: none; }
51
+ .remember input { width: auto; margin: 0; cursor: pointer; }
52
+ .err { min-height: 18px; font-size: 12px; color: #e03131; margin: 10px 0 2px; white-space: pre-wrap; }
53
+ .reg-note { font-size: 12px; color: #2b8a3e; margin: 10px 0 2px; white-space: pre-wrap; }
54
+ .submit {
55
+ width: 100%; margin-top: 10px; padding: 10px 0; font-size: 14px; border: 0; border-radius: 8px;
56
+ background: #4c6ef5; color: #fff; cursor: pointer;
57
+ }
58
+ .submit:disabled { opacity: .55; cursor: default; }
59
+ .hint { font-size: 12px; color: #f08c00; margin-top: 12px; text-align: center; }
60
+ .foot { margin-top: 16px; font-size: 11px; opacity: .45; text-align: center; }
61
+ `
62
+
63
+ const PAGE_SCRIPT = `
64
+ (function () {
65
+ var hasUsers = __HAS_USERS__;
66
+ var loginForm = document.getElementById('login-form');
67
+ var registerForm = document.getElementById('register-form');
68
+ var otpInput = document.getElementById('login-otp');
69
+ var remember = document.getElementById('login-remember');
70
+ function show(tab) {
71
+ loginForm.style.display = tab === 'login' ? '' : 'none';
72
+ registerForm.style.display = tab === 'register' ? '' : 'none';
73
+ document.getElementById('tab-login').classList.toggle('active', tab === 'login');
74
+ document.getElementById('tab-register').classList.toggle('active', tab === 'register');
75
+ loginForm.querySelector('.err').textContent = '';
76
+ registerForm.querySelector('.err').textContent = '';
77
+ document.getElementById('reg-note').textContent = '';
78
+ }
79
+ document.getElementById('tab-login').addEventListener('click', function () { show('login') });
80
+ document.getElementById('tab-register').addEventListener('click', function () { show('register') });
81
+ function submit(form, path, body) {
82
+ var errEl = form.querySelector('.err');
83
+ var button = form.querySelector('.submit');
84
+ button.disabled = true;
85
+ errEl.textContent = '';
86
+ fetch(path, {
87
+ method: 'POST',
88
+ headers: { 'content-type': 'application/json' },
89
+ body: JSON.stringify(body),
90
+ }).then(function (res) {
91
+ return res.json().catch(function () { return {} }).then(function (data) { return { ok: res.ok, data: data } });
92
+ }).then(function (result) {
93
+ if (result.ok) {
94
+ if (form === registerForm && result.data && result.data.pending) {
95
+ // approval mode: registered as disabled — no session, stay and inform
96
+ document.getElementById('reg-note').textContent = '注册成功!账号需管理员审核启用后方可登录,请稍后再来。';
97
+ button.disabled = false;
98
+ return;
99
+ }
100
+ if (form === loginForm) {
101
+ try {
102
+ if (remember.checked) localStorage.setItem('um-last-username', body.username || '');
103
+ else localStorage.removeItem('um-last-username');
104
+ } catch (e) {}
105
+ }
106
+ location.href = '/';
107
+ return;
108
+ }
109
+ errEl.textContent = (result.data && result.data.error) || '请求失败';
110
+ button.disabled = false;
111
+ }).catch(function () {
112
+ errEl.textContent = '网络错误';
113
+ button.disabled = false;
114
+ });
115
+ }
116
+ loginForm.addEventListener('submit', function (e) {
117
+ e.preventDefault();
118
+ submit(loginForm, '/user-management/api/login', {
119
+ username: document.getElementById('login-username').value.trim(),
120
+ password: document.getElementById('login-password').value,
121
+ otp: otpInput.value.replace(/\\s+/g, '') || undefined,
122
+ });
123
+ });
124
+ registerForm.addEventListener('submit', function (e) {
125
+ e.preventDefault();
126
+ var errEl = registerForm.querySelector('.err');
127
+ var password = document.getElementById('reg-password').value;
128
+ if (password !== document.getElementById('reg-password2').value) {
129
+ errEl.textContent = '两次输入的密码不一致';
130
+ return;
131
+ }
132
+ submit(registerForm, '/user-management/api/register', {
133
+ username: document.getElementById('reg-username').value.trim(),
134
+ password: password,
135
+ });
136
+ });
137
+ try {
138
+ var remembered = localStorage.getItem('um-last-username');
139
+ if (remembered) document.getElementById('login-username').value = remembered;
140
+ } catch (e) {}
141
+ if (!hasUsers) {
142
+ document.getElementById('first-hint').style.display = '';
143
+ show('register');
144
+ }
145
+ })();
146
+ `
147
+
148
+ /**
149
+ * Render the login page. `hasUsers=false` (fresh system) flips the default
150
+ * tab to register and reveals the first-user-becomes-admin hint.
151
+ */
152
+ function renderLoginPage({ hasUsers, title = 'DSH 控制台' } = {}) {
153
+ return '<!doctype html>\n<html lang="zh-CN">\n<head>\n<meta charset="utf-8">\n' +
154
+ '<meta name="viewport" content="width=device-width, initial-scale=1">\n' +
155
+ `<title>${esc(title)} · 登录</title>\n` +
156
+ `<style>${PAGE_STYLES}</style>\n</head>\n<body>\n` +
157
+ '<div class="card">\n' +
158
+ `<h1>${esc(title)}</h1>\n<p class="sub">登录后继续访问</p>\n` +
159
+ '<div class="tabs">' +
160
+ '<button type="button" id="tab-login" class="active">登录</button>' +
161
+ '<button type="button" id="tab-register">注册</button></div>\n' +
162
+ '<form id="login-form">\n' +
163
+ '<label for="login-username">用户名</label>\n<input id="login-username" autocomplete="username" required>\n' +
164
+ '<label for="login-password">密码</label>\n<input id="login-password" type="password" autocomplete="current-password" required>\n' +
165
+ '<label for="login-otp">两步验证码 <span class="opt">(未开启请留空)</span></label>\n' +
166
+ '<input id="login-otp" inputmode="numeric" autocomplete="one-time-code" maxlength="6" placeholder="已开启两步验证?输入 6 位动态码">\n' +
167
+ '<label class="remember"><input type="checkbox" id="login-remember" checked> 记住用户名</label>\n' +
168
+ '<div class="err"></div>\n' +
169
+ '<button class="submit" type="submit">登录</button>\n</form>\n' +
170
+ '<form id="register-form" style="display:none">\n' +
171
+ '<label for="reg-username">用户名</label>\n<input id="reg-username" autocomplete="username" required>\n' +
172
+ '<label for="reg-password">密码(至少 6 位)</label>\n<input id="reg-password" type="password" autocomplete="new-password" minlength="6" required>\n' +
173
+ '<label for="reg-password2">确认密码</label>\n<input id="reg-password2" type="password" autocomplete="new-password" required>\n' +
174
+ '<div class="err"></div>\n' +
175
+ '<div class="reg-note" id="reg-note"></div>\n' +
176
+ '<button class="submit" type="submit">注册并登录</button>\n' +
177
+ `<div class="hint" id="first-hint" style="display:none">当前还没有任何账号,首个注册的账号将成为管理员</div>\n</form>\n` +
178
+ '<div class="foot">dsh 插件 · user-management</div>\n' +
179
+ '</div>\n' +
180
+ `<script>${PAGE_SCRIPT.replace('__HAS_USERS__', hasUsers ? 'true' : 'false')}</script>\n` +
181
+ '</body>\n</html>\n'
182
+ }
183
+
184
+ export { renderLoginPage }
@@ -0,0 +1,477 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Workspace-scope and sandbox policy — the pure half of the account model
5
+ * ported from dsh-passwords.
6
+ *
7
+ * Everything here is a pure function over values: no store, no request, no
8
+ * cordis. The enforcement layer (gateway response filtering, request clamping)
9
+ * calls these; keeping them separate is what makes the policy verifiable
10
+ * without a live gateway.
11
+ *
12
+ * Semantics are carried over verbatim, because the store and the settings UI
13
+ * already speak them:
14
+ * - an EMPTY allow-list means \"every workspace\" (the unrestricted default);
15
+ * - DENY_ALL_WORKSPACES is how an admin says \"no workspace at all\", which an
16
+ * empty list cannot express;
17
+ * - a path is allowed when it equals an entry or sits underneath it.
18
+ */
19
+
20
+ import path from 'node:path'
21
+
22
+ /** Sentinel entry meaning \"deny every workspace\" (distinct from \"[] = all\"). */
23
+ const DENY_ALL_WORKSPACES = '__deny__'
24
+
25
+ /** Sandbox modes from narrowest to widest; the rank decides what is a
26
+ * downgrade and what would be an escalation. */
27
+ const SANDBOX_RANK = { 'read-only': 0, 'workspace-write': 1, 'danger-full-access': 2 }
28
+
29
+ /** Recursion cap for the JSON walkers: a deeply nested upstream payload must
30
+ * not turn into a stack-overflow DoS. */
31
+ const MAX_JSON_DEPTH = 8
32
+
33
+ /**
34
+ * Canonical path form for comparisons: backslashes to slashes, posix
35
+ * normalization (so `..` and trailing slashes cannot smuggle a path past the
36
+ * allow-list), and case folding for Windows-style paths.
37
+ */
38
+ function normalizePath(p) {
39
+ let n = String(p === undefined || p === null ? '' : p).replace(/\\/g, '/')
40
+ n = path.posix.normalize(n)
41
+ // Windows-style paths fold to lower case in FULL, not just the drive letter:
42
+ // the filesystem is case-insensitive, so a case-sensitive compare would
43
+ // reject a legitimate 'D:\\Work' allow-list entry for a workspace reported
44
+ // as 'd:/work'. POSIX paths keep their case sensitivity.
45
+ // (dsh-passwords lower-cased only the drive letter — it targets Linux hosts;
46
+ // this is a deliberate divergence for Windows deployments.)
47
+ if (/^[a-zA-Z]:/.test(n)) n = n.toLowerCase()
48
+ // posix.normalize KEEPS one trailing separator ('/srv/a/' survives as
49
+ // written), which would break the separator-boundary prefix test below: an
50
+ // admin pasting 'D:\\work\\' would then silently match nothing. Fold it
51
+ // away — the root ('/') is kept, and a bare 'c:' still matches the drive.
52
+ if (n.length > 1 && n.endsWith('/')) n = n.replace(/\/+$/, '')
53
+ return n
54
+ }
55
+
56
+ /** Whether a list actually restricts anything (empty = unrestricted). */
57
+ function isWorkspaceRestricted(allowedFolders) {
58
+ return Array.isArray(allowedFolders) && allowedFolders.length > 0
59
+ }
60
+
61
+ /** Whether `target` is inside the allow-list (or the list is unrestricted). */
62
+ function folderAllowed(target, allowedFolders) {
63
+ const list = Array.isArray(allowedFolders) ? allowedFolders : []
64
+ if (list.length === 0) return true
65
+ if (list.includes(DENY_ALL_WORKSPACES)) return false
66
+ const p = normalizePath(target)
67
+ return list.some((entry) => {
68
+ const base = normalizePath(entry)
69
+ // normalize('') is '.': an empty entry and '/' both mean \"the whole disk\"
70
+ if (base === '.' || base === '/') return true
71
+ // Prefix match must respect a separator boundary, or '/srv/ab' would pass
72
+ // an allow-list entry of '/srv/a'.
73
+ return p === base || p.startsWith(base + '/')
74
+ })
75
+ }
76
+
77
+ /**
78
+ * Drop JSON entries whose path field falls outside the allow-list.
79
+ * Only array items carrying that field are judged; everything else is copied
80
+ * through recursively, so unrelated payload shapes survive untouched.
81
+ */
82
+ function filterByPathFieldWithPredicate(value, field, allowed, depth = 0) {
83
+ // Past the cap the path field can no longer be checked reliably: drop the
84
+ // subtree instead of passing it through (fail closed).
85
+ if (depth > MAX_JSON_DEPTH) return null
86
+ if (value === null || value === undefined) return value === undefined ? undefined : value
87
+ if (Array.isArray(value)) {
88
+ const out = []
89
+ for (const item of value) {
90
+ if (item !== null && typeof item === 'object' && typeof item[field] === 'string' && item[field] !== '' && !allowed(item[field])) continue
91
+ out.push(filterByPathFieldWithPredicate(item, field, allowed, depth + 1))
92
+ }
93
+ return out
94
+ }
95
+ if (typeof value === 'object') {
96
+ const out = {}
97
+ for (const [key, nested] of Object.entries(value)) out[key] = filterByPathFieldWithPredicate(nested, field, allowed, depth + 1)
98
+ return out
99
+ }
100
+ return value
101
+ }
102
+
103
+ /** filterByPathField with the allow-list as the predicate. */
104
+ function filterByPathField(value, allowedFolders, field, depth = 0) {
105
+ return filterByPathFieldWithPredicate(value, field, (candidate) => folderAllowed(candidate, allowedFolders), depth)
106
+ }
107
+
108
+ /**
109
+ * Rank a sandbox preset. An unknown value ranks as the MOST permissive, so an
110
+ * unrecognized preset can never be mistaken for a downgrade and silently let
111
+ * through (dsh-passwords' rule, kept because the reasoning holds).
112
+ * Own-property check, not a bare lookup: 'constructor' and friends must not
113
+ * resolve to anything from the prototype chain.
114
+ */
115
+ function sandboxPresetRank(preset) {
116
+ return Object.prototype.hasOwnProperty.call(SANDBOX_RANK, preset) ? SANDBOX_RANK[preset] : 2
117
+ }
118
+
119
+ /** Request-body fields that may carry the target path, in priority order. */
120
+ const PATH_FIELDS = [
121
+ 'cwd',
122
+ 'path',
123
+ 'directory',
124
+ 'dir',
125
+ 'folder',
126
+ 'workspace',
127
+ 'root',
128
+ 'workspacePath',
129
+ 'absolutePath',
130
+ 'target',
131
+ 'targetPath',
132
+ ]
133
+
134
+ /** Depth cap for the body walkers (same reasoning as the JSON filter). */
135
+ const MAX_BODY_DEPTH = 6
136
+
137
+ /**
138
+ * First path-looking string in a request body, walking the typert envelope
139
+ * ({ type, rpcId, method, payload }).
140
+ *
141
+ * `args` is skipped on purpose: it is a pseudo-field dsh does not consume, so a
142
+ * body of { payload: { args: { cwd: '/root/11' } } } actually creates a session
143
+ * in the DEFAULT workspace. Trusting args.cwd would authorize a path the host
144
+ * never uses — a fail-open hole rather than a strictness bug.
145
+ *
146
+ * Ported verbatim from dsh-passwords; the reasoning above is why.
147
+ */
148
+ function extractPathFromBody(value, depth = 0) {
149
+ if (depth > MAX_BODY_DEPTH || value === null || typeof value !== 'object') return null
150
+ for (const field of PATH_FIELDS) {
151
+ const candidate = value[field]
152
+ if (typeof candidate === 'string' && candidate.length > 0) return candidate
153
+ }
154
+ for (const key of Object.keys(value)) {
155
+ if (key === 'args') continue
156
+ const nested = extractPathFromBody(value[key], depth + 1)
157
+ if (nested !== null) return nested
158
+ }
159
+ return null
160
+ }
161
+
162
+ /** Same walk, looking for a workspaceId (session.create may send one instead of cwd). */
163
+ function extractWorkspaceId(value, depth = 0) {
164
+ if (depth > MAX_BODY_DEPTH || value === null || typeof value !== 'object') return null
165
+ if (typeof value.workspaceId === 'string' && value.workspaceId.length > 0) return value.workspaceId
166
+ for (const key of Object.keys(value)) {
167
+ if (key === 'args') continue
168
+ const nested = extractWorkspaceId(value[key], depth + 1)
169
+ if (nested !== null) return nested
170
+ }
171
+ return null
172
+ }
173
+
174
+ /**
175
+ * Harvest workspaceId → path pairs out of a workspace-list response, so a later
176
+ * session.create that carries only a workspaceId can still be checked against
177
+ * the allow-list. Both `workspaceId` and a top-level `id` are accepted: dsh
178
+ * reports the former, and a miss here means a fail-closed 403 on a legitimate
179
+ * request.
180
+ */
181
+ function collectIdPathPairs(value, out = new Map(), depth = 0) {
182
+ if (depth > MAX_JSON_DEPTH || value === null) return out
183
+ if (Array.isArray(value)) {
184
+ for (const item of value) collectIdPathPairs(item, out, depth + 1)
185
+ } else if (typeof value === 'object') {
186
+ if (typeof value.path === 'string') {
187
+ const id = typeof value.workspaceId === 'string' ? value.workspaceId : typeof value.id === 'string' ? value.id : null
188
+ if (id !== null) out.set(id, value.path)
189
+ }
190
+ for (const nested of Object.values(value)) collectIdPathPairs(nested, out, depth + 1)
191
+ }
192
+ return out
193
+ }
194
+
195
+ /** First string value stored under `field` anywhere in the body (depth-capped). */
196
+ function findStringField(value, field, depth = 0) {
197
+ if (depth > MAX_BODY_DEPTH || value === null || typeof value !== 'object') return null
198
+ const candidate = value[field]
199
+ if (typeof candidate === 'string' && candidate.length > 0) return candidate
200
+ for (const key of Object.keys(value)) {
201
+ const nested = findStringField(value[key], field, depth + 1)
202
+ if (nested !== null) return nested
203
+ }
204
+ return null
205
+ }
206
+
207
+ /**
208
+ * The preset named by a `/permission` slash command, or null.
209
+ * e.g. "/permission workspace-write" → "workspace-write".
210
+ */
211
+ function permissionPresetFromCommand(line) {
212
+ const match = /^\/permission\s+([A-Za-z0-9_-]+)/.exec(String(line || '').trim())
213
+ return match ? match[1] : null
214
+ }
215
+
216
+ /**
217
+ * The preset written by a settings.mutate body.
218
+ *
219
+ * The field lives inside an `ops[].path` ARRAY (path: [..., 'defaultPreset'])
220
+ * rather than under an object key, so the generic field walker cannot find it —
221
+ * hence this dedicated shape match.
222
+ */
223
+ function presetFromSettingsMutate(value, depth = 0) {
224
+ if (depth > MAX_BODY_DEPTH || value === null || typeof value !== 'object') return null
225
+ if (Array.isArray(value.path) && value.path.includes('defaultPreset')) {
226
+ if (typeof value.value === 'string' && value.value.length > 0) return value.value
227
+ }
228
+ for (const key of Object.keys(value)) {
229
+ const nested = presetFromSettingsMutate(value[key], depth + 1)
230
+ if (nested !== null) return nested
231
+ }
232
+ return null
233
+ }
234
+
235
+ /**
236
+ * Force every approval outcome in a response body to 'rejected'.
237
+ *
238
+ * A restricted account's agent must not be able to escalate its own sandbox by
239
+ * asking for permission: the user may click "allow", but the answer forwarded
240
+ * upstream is a denial. Only objects carrying BOTH a string approvalId and a
241
+ * string outcome are touched, so ask_user_question answers (which use `answer`)
242
+ * pass through untouched.
243
+ *
244
+ * @returns whether anything actually changed (the caller only rewrites the body
245
+ * when it did).
246
+ */
247
+ function forceRejectApproval(value, depth = 0) {
248
+ if (depth > MAX_BODY_DEPTH || value === null || typeof value !== 'object') return false
249
+ let changed = false
250
+ if (typeof value.approvalId === 'string' && typeof value.outcome === 'string' && value.outcome !== 'rejected') {
251
+ value.outcome = 'rejected'
252
+ changed = true
253
+ }
254
+ for (const key of Object.keys(value)) {
255
+ const nested = value[key]
256
+ if (nested !== null && typeof nested === 'object' && forceRejectApproval(nested, depth + 1)) changed = true
257
+ }
258
+ return changed
259
+ }
260
+
261
+ /**
262
+ * Invisible Unicode: zero-width joiners/spaces, bidi controls, word joiners,
263
+ * BOM, soft hyphen, Mongolian vowel separator, combining grapheme joiner,
264
+ * Arabic letter mark, Hangul fillers.
265
+ *
266
+ * These characters mean nothing to a human reader but are perfectly visible
267
+ * instructions to a model, which makes them a prompt-injection carrier: a file
268
+ * the agent reads can hide text that only comes back to the model when the
269
+ * session history is replayed. Stripping them loses no meaning.
270
+ */
271
+ const HIDDEN_UNICODE_RE = /[\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff\u00ad\u180e\u034f\u061c\u115f\u1160]/g
272
+
273
+ /** Strip invisible Unicode from one string. */
274
+ function sanitizeHiddenUnicode(content) {
275
+ return String(content).replace(HIDDEN_UNICODE_RE, '')
276
+ }
277
+
278
+ /**
279
+ * The same, over a whole JSON payload: every string is cleaned, structure and
280
+ * other types are preserved. Past the depth cap the subtree is returned as-is
281
+ * (this is a defense in depth, not an authorization check — refusing the
282
+ * response to the user would be worse than passing an unsanitized deep node).
283
+ */
284
+ function sanitizeHiddenUnicodeJson(value, depth = 0) {
285
+ if (depth > MAX_JSON_DEPTH) return value
286
+ if (typeof value === 'string') return sanitizeHiddenUnicode(value)
287
+ if (value === null || typeof value !== 'object') return value
288
+ if (Array.isArray(value)) return value.map((item) => sanitizeHiddenUnicodeJson(item, depth + 1))
289
+ const out = {}
290
+ for (const [key, nested] of Object.entries(value)) out[key] = sanitizeHiddenUnicodeJson(nested, depth + 1)
291
+ return out
292
+ }
293
+
294
+ /**
295
+ * Clamp a sandbox mode wider than the account's allowance back down to it.
296
+ *
297
+ * Why this exists: a session shared by the owning account can carry
298
+ * permission/preset or sandbox/mode events recorded while it ran with a wider
299
+ * sandbox. Replaying that history into a restricted account would hand it the
300
+ * wider mode without a single action from anyone — an escalation with no
301
+ * approval step to intercept.
302
+ *
303
+ * Rewrites in place: `permission/preset` events, `sandbox/mode` events, and
304
+ * the projected `currentValue` the client displays as the active preset.
305
+ *
306
+ * @returns whether anything changed.
307
+ */
308
+ function clampSessionHistorySandbox(value, allowedMode, depth = 0) {
309
+ if (depth > MAX_JSON_DEPTH || value === null || typeof value !== 'object') return false
310
+ if (typeof allowedMode !== 'string' || !Object.prototype.hasOwnProperty.call(SANDBOX_RANK, allowedMode)) return false
311
+ const allowedRank = SANDBOX_RANK[allowedMode]
312
+ let changed = false
313
+
314
+ const widerThan = (mode) => Object.prototype.hasOwnProperty.call(SANDBOX_RANK, mode) && SANDBOX_RANK[mode] > allowedRank
315
+
316
+ if (value.type === 'permission/preset' && value.data && typeof value.data === 'object') {
317
+ if (typeof value.data.preset === 'string' && widerThan(value.data.preset)) {
318
+ value.data.preset = allowedMode
319
+ changed = true
320
+ }
321
+ }
322
+ if (value.type === 'sandbox/mode' && value.data && typeof value.data === 'object') {
323
+ if (typeof value.data.mode === 'string' && widerThan(value.data.mode)) {
324
+ value.data.mode = allowedMode
325
+ changed = true
326
+ }
327
+ }
328
+ if (typeof value.currentValue === 'string' && widerThan(value.currentValue)) {
329
+ value.currentValue = allowedMode
330
+ changed = true
331
+ }
332
+
333
+ for (const key of Object.keys(value)) {
334
+ const nested = value[key]
335
+ if (nested !== null && typeof nested === 'object' && clampSessionHistorySandbox(nested, allowedMode, depth + 1)) {
336
+ changed = true
337
+ }
338
+ }
339
+ return changed
340
+ }
341
+
342
+ /** Workspace creation endpoints (gated by allowWorkspaceCreate). */
343
+ function isWorkspaceCreate(pathname) {
344
+ return /^\/api\/workspace[.\/](add|create)([.\/]|$)/.test(String(pathname))
345
+ }
346
+
347
+ /** Workspace delete/rename endpoints (ownership applies as well as the switch). */
348
+ function isWorkspaceDeleteOrRename(pathname) {
349
+ return /^\/api\/workspace[.\/](remove|delete|rename|update)([.\/]|$)/.test(String(pathname))
350
+ }
351
+
352
+ /**
353
+ * Old and new path of a rename/delete request, or null when either is missing.
354
+ *
355
+ * Callers must stay fail-closed on null: a rename whose target cannot be read is
356
+ * a rename that cannot be authorized.
357
+ */
358
+ function extractWorkspaceRenamePaths(value, depth = 0) {
359
+ const oldKeys = new Set(['oldPath', 'previousPath', 'sourcePath', 'fromPath'])
360
+ const newKeys = new Set(['newPath', 'targetPath', 'destinationPath', 'toPath'])
361
+ let oldPath = null
362
+ let newPath = null
363
+ const visit = (current, level) => {
364
+ if (level > MAX_BODY_DEPTH || current === null || typeof current !== 'object') return
365
+ if (oldPath !== null && newPath !== null) return
366
+ if (Array.isArray(current)) {
367
+ for (const item of current) visit(item, level + 1)
368
+ return
369
+ }
370
+ for (const [key, nested] of Object.entries(current)) {
371
+ if (oldPath === null && oldKeys.has(key) && typeof nested === 'string' && nested !== '') oldPath = nested
372
+ else if (newPath === null && newKeys.has(key) && typeof nested === 'string' && nested !== '') newPath = nested
373
+ else visit(nested, level + 1)
374
+ }
375
+ }
376
+ visit(value, depth)
377
+ return oldPath !== null && newPath !== null ? { oldPath, newPath } : null
378
+ }
379
+
380
+ /** aionui-panel file-tree endpoints that WRITE (they mirror the upload switch). */
381
+ const AIONUI_WRITE_PATHS = new Set([
382
+ '/aionui-panel/write',
383
+ '/aionui-panel/delete',
384
+ '/aionui-panel/git-stage',
385
+ '/aionui-panel/git-unstage',
386
+ '/aionui-panel/git-discard',
387
+ ])
388
+
389
+ /**
390
+ * Endpoints that put a file INTO the host: the uploads plugin, the file-path
391
+ * "import into workspace" bridge, the SSH upload, and the aionui-panel writers.
392
+ *
393
+ * Ported from dsh-passwords (its isUploadRequest plus the aionui writers, which
394
+ * it gates through the same switch).
395
+ */
396
+ function isUploadRequest(method, pathname) {
397
+ const m = String(method || '').toUpperCase()
398
+ if (m !== 'POST' && m !== 'PUT' && m !== 'DELETE') return false
399
+ if (AIONUI_WRITE_PATHS.has(pathname)) return true
400
+ if (m !== 'POST' && m !== 'PUT') return false
401
+ return (
402
+ pathname === '/api/dsh-uploads' ||
403
+ pathname.startsWith('/api/dsh-uploads/') ||
404
+ pathname === '/api/filePathBridge/importFile' ||
405
+ pathname === '/api/dsh-ssh/upload'
406
+ )
407
+ }
408
+
409
+ /**
410
+ * Endpoints that take data OUT of the host: the git tool RPCs, the git-graph
411
+ * and aionui git panels, plus the other "hand me the server's contents" doors
412
+ * (session export zip, SSH download/list, uploads download).
413
+ *
414
+ * Deliberately a prefix match on git RPCs only — /api/session.fetch and friends
415
+ * are ordinary endpoints and must not be caught here.
416
+ */
417
+ function isGitRequest(pathname) {
418
+ return (
419
+ /^\/api\/git[-.\/]/i.test(pathname) ||
420
+ /^\/aionui-panel\/git[-.]/.test(pathname) ||
421
+ /^\/api\/session[.\/]export/.test(pathname) ||
422
+ /^\/api\/dsh-ssh[.\/](download|ls)/.test(pathname) ||
423
+ /^\/api\/dsh-uploads[.\/]download/.test(pathname)
424
+ )
425
+ }
426
+
427
+ /**
428
+ * Third-party "ops surfaces" that only the owning account may see: the SSH host
429
+ * inventory/tunnels (they carry connection details and key material), the skin
430
+ * centre and modlens panels (outside the permission model entirely), and the
431
+ * shared upload store's LIST/DELETE routes (enumerating or deleting other
432
+ * people's files). The uploads POST path stays governed by allowUpload and its
433
+ * download by allowGit, as in dsh-passwords.
434
+ */
435
+ function isAdminOnlyPluginEndpoint(method, pathname) {
436
+ const m = String(method || 'GET').toUpperCase()
437
+ return (
438
+ pathname === '/api/dsh-ssh' ||
439
+ pathname.startsWith('/api/dsh-ssh/') ||
440
+ pathname === '/api/skin-center' ||
441
+ pathname.startsWith('/api/skin-center/') ||
442
+ pathname === '/modlens' ||
443
+ pathname.startsWith('/modlens/') ||
444
+ (pathname === '/api/dsh-uploads' && (m === 'GET' || m === 'DELETE'))
445
+ )
446
+ }
447
+
448
+ export {
449
+ isWorkspaceCreate,
450
+ isWorkspaceDeleteOrRename,
451
+ extractWorkspaceRenamePaths,
452
+ HIDDEN_UNICODE_RE,
453
+ sanitizeHiddenUnicode,
454
+ sanitizeHiddenUnicodeJson,
455
+ clampSessionHistorySandbox,
456
+ AIONUI_WRITE_PATHS,
457
+ isUploadRequest,
458
+ isGitRequest,
459
+ isAdminOnlyPluginEndpoint,
460
+ findStringField,
461
+ permissionPresetFromCommand,
462
+ presetFromSettingsMutate,
463
+ forceRejectApproval,
464
+ PATH_FIELDS,
465
+ extractPathFromBody,
466
+ extractWorkspaceId,
467
+ collectIdPathPairs,
468
+ DENY_ALL_WORKSPACES,
469
+ SANDBOX_RANK,
470
+ MAX_JSON_DEPTH,
471
+ normalizePath,
472
+ isWorkspaceRestricted,
473
+ folderAllowed,
474
+ filterByPathField,
475
+ filterByPathFieldWithPredicate,
476
+ sandboxPresetRank,
477
+ }