free-coding-models 0.5.87 → 0.5.88

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/README.md CHANGED
@@ -27,10 +27,6 @@ free-coding-models
27
27
  <sub>Then create a free account on one of the <a href="#-providers">24 providers</a> to grab an API key.</sub>
28
28
  </p>
29
29
 
30
- <p align="center">
31
- <img src="website/public/demo.gif" alt="free-coding-models demo" width="100%">
32
- </p>
33
-
34
30
  <p align="center">
35
31
  <sub>Made with ❤️ and ☕ by <a href="https://vanessadepraute.dev">Vanessa Depraute</a> (aka <a href="https://vavanessa.dev">Vava-Nessa</a>)</sub>
36
32
  </p>
@@ -87,10 +83,6 @@ free-coding-models --fiable # print the single most reliable model
87
83
  - 🔀 Want one endpoint that never dies? `free-coding-models --daemon-bg` starts the [Smart Model Router](./docs/router.md).
88
84
  - 🤖 Live inside an agent? Install the [OpenCode plugin](./packages/fcm-opencode/README.md) or the [Pi extension](./packages/fcm-pi/README.md).
89
85
 
90
- <p align="center">
91
- <img src="demo2.gif" alt="free-coding-models TUI demo" width="100%">
92
- </p>
93
-
94
86
  ---
95
87
 
96
88
  ## 🟢 Providers
@@ -179,7 +171,7 @@ Flags combine freely in any order. The most common:
179
171
  | `--reprobe` / `--probe-ttl <ms>` / `--show-broken` | Probe cache control |
180
172
  | `--check-drift` | Diff `sources.js` vs models.dev, exit 1 on mismatch |
181
173
  | `--config-dir <dir>` | Custom config location |
182
- | `--fix-permissions` / `--yes` | Auto-fix config file permissions (chmod 600) |
174
+ | `--fix-permissions` / `--yes` | Auto-fix config file permissions (chmod 600, NTFS ACLs via icacls on Windows) |
183
175
  | `--web` | Open the Web Dashboard instead of the TUI |
184
176
  | `--daemon` / `--daemon-bg` / `--daemon-status` / `--daemon-stop` | Smart Model Router lifecycle |
185
177
  | `--sync-set [name]` | Auto-populate a router set with the currently best models |
@@ -0,0 +1,16 @@
1
+ # Changelog v0.5.88 - 2026-09-06
2
+
3
+ ### Fixed
4
+ - **Windows: the config permissions warning no longer re-fires on every launch** (issue #173 follow-up reported by @rutexd). On Windows, Node can only report file modes 0666 (writable) or 0444 (read-only), so the old "is it 600?" check could never pass: the app warned, you answered yes, it said "fixed!", and the next launch warned again in an endless loop.
5
+ - The Windows security verdict now comes from the real NTFS ACL, read with `icacls <file>` and parsed by a new pure helper (`parseIcaclsOutput`). The file counts as protected when NTFS inheritance is disabled and only your user (plus trusted SYSTEM/Administrators entries, matched by name or well-known SID, locale-proof) has access.
6
+ - Fixing on Windows now runs `icacls "<file>" /inheritance:r /grant:r "<user>:F"` (no shell, injection-safe) instead of the no-op chmod, and the fix is **verified by re-reading the ACL** before the app claims success. No more fake "✅ Permissions fixed!".
7
+ - The warning now explains *what* is wrong on NTFS (inherited access, or which other principals have a grant) instead of showing a meaningless octal mode.
8
+ - **Anti-nag guard (all platforms)**: when the fix genuinely cannot be applied or verified (rare), the app now writes a small ack marker next to the config file and stays quiet about the same warning for 30 days, instead of nagging on every single launch. Passing `--fix-permissions` / `--yes` / `-y` always bypasses the marker and retries. The marker is cleaned up as soon as a fix succeeds.
9
+ - The manual fix hint on Windows now shows the exact `icacls` command with your real username, and the POSIX path (chmod 600) additionally re-checks the mode after chmod so success messages are always truthful.
10
+
11
+ ### Changed
12
+ - `--fix-permissions` documentation now mentions it applies NTFS ACLs on Windows (README flags table).
13
+
14
+ ### For contributors
15
+ - New pure helpers in `src/core/utils.js`, fully unit-tested: `parseIcaclsOutput` (icacls output parser, handles the echoed path glued to the first ACE, `(I)` inheritance markers, SIDs and localized group names) and `shouldSkipSecurityWarn` (30-day anti-nag gate, ISO string or epoch input, clock-skew safe).
16
+ - `checkConfigSecurity()` gained an integration test covering the full lifecycle: warn once, ack, stay silent on relaunch, auto-fix bypasses the ack, marker cleanup after a successful fix.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.5.87",
3
+ "version": "0.5.88",
4
4
  "description": "Find the fastest coding LLM models in seconds — ping free models from multiple providers, pick the best one for OpenCode, Cursor, or any AI coding assistant.",
5
5
  "keywords": [
6
6
  "nvidia",
@@ -94,5 +94,10 @@
94
94
  "react": "^19.2.7",
95
95
  "react-dom": "^19.2.7",
96
96
  "vite": "^8.0.16"
97
+ },
98
+ "pnpm": {
99
+ "onlyBuiltDependencies": [
100
+ "esbuild"
101
+ ]
97
102
  }
98
103
  }
@@ -17,21 +17,34 @@
17
17
  * Now checkConfigSecurity() is async, awaited by the bin entry BEFORE the TUI
18
18
  * starts, and it never prompts on non-TTY stdin or daemon/web/JSON surfaces.
19
19
  *
20
- * 📖 Secure permissions:
21
- * - 0o600 (octal 600) = user:rw, group:---, world:---
22
- * - Only the file owner can read or write
23
- * - This is the standard for files containing secrets (SSH keys, API keys, etc.)
20
+ * 📖 Issue #173 follow-up (rutexd): on Windows the "fix" never persisted and the
21
+ * warning re-fired on every launch. Root cause: Node maps win32 modes to only
22
+ * 0666 (writable) or 0444 (read-only), so 0600 is unreachable and
23
+ * `(mode & 0o777) === 0o600` was always false; chmod 600 on win32 just clears
24
+ * the read-only bit. The real fix:
25
+ * - the Windows verdict now comes from the NTFS ACL via `icacls <file>`
26
+ * - fixing on Windows runs `icacls /inheritance:r /grant:r <user>:F` and is
27
+ * verified by re-reading the ACL before claiming success
28
+ * - when the fix cannot be applied or verified, an ack marker file
29
+ * (<config>.securityack) keeps the warning quiet for 30 days instead of
30
+ * nagging on every launch (--fix-permissions bypasses the marker)
31
+ * POSIX behaviour (chmod 600) is unchanged on macOS/Linux.
24
32
  *
25
- * 📖 Windows note: Node's chmod on win32 is best-effort (it can only toggle the
26
- * read-only attribute, NTFS ACLs still govern real access). We still try,
27
- * and the manual hint points at icacls for a real fix.
33
+ * 📖 Secure permissions:
34
+ * - POSIX: 0o600 (octal 600) = user:rw, group:---, world:---
35
+ * - Windows: NTFS inheritance disabled, grants only for the current user
36
+ * (SYSTEM/Administrators whitelisted) - see parseIcaclsOutput in utils.js
28
37
  *
29
38
  * @functions
30
39
  * → checkConfigSecurity() - Async main security check; awaited before the TUI starts
31
40
  * → resolveSecurityAction() - Pure gate deciding auto-fix / prompt / warn-only (in utils.js)
41
+ * → parseIcaclsOutput() - Pure parser for icacls output (in utils.js)
42
+ * → shouldSkipSecurityWarn() - Pure anti-nag gate for repeat warnings (in utils.js)
32
43
  * → getConfigPermissions() - Returns file mode object for config
33
- * → isConfigSecure() - Boolean check if permissions are correct
34
- * → fixConfigPermissions() - Applies chmod 600 to config file (best-effort on Windows)
44
+ * → isConfigSecure() - Boolean check if permissions are correct (POSIX modes)
45
+ * → fixConfigPermissions() - Applies chmod 600 to config file (POSIX)
46
+ * → getWindowsAclStatus() - Async icacls read + parse → is the ACL secure?
47
+ * → fixWindowsAcl() - Async icacls fix + ACL re-verify
35
48
  * → promptSecurityFix() - Interactive prompt asking user to fix permissions
36
49
  *
37
50
  * @exports checkConfigSecurity, isConfigSecure, fixConfigPermissions, formatMode, formatModeRwx
@@ -41,8 +54,12 @@ import fs from 'node:fs'
41
54
  import path from 'node:path'
42
55
  import os from 'node:os'
43
56
  import readline from 'node:readline'
57
+ import { execFile } from 'node:child_process'
58
+ import { promisify } from 'node:util'
44
59
  import { CONFIG_PATH } from './config.js'
45
- import { resolveSecurityAction } from './utils.js'
60
+ import { resolveSecurityAction, parseIcaclsOutput, shouldSkipSecurityWarn } from './utils.js'
61
+
62
+ const execFileAsync = promisify(execFile)
46
63
 
47
64
  // 📖 Config file path - matches the path used in config.js (honours the
48
65
  // 📖 --config-dir / FCM_CONFIG_DIR override when set).
@@ -91,7 +108,8 @@ export function isConfigSecure() {
91
108
  }
92
109
 
93
110
  // 📖 Fix config file permissions to secure mode (chmod 600)
94
- // 📖 Best-effort on Windows (read-only bit); returns true if successful, false otherwise
111
+ // 📖 POSIX path; on Windows this is NOT the real fix (see fixWindowsAcl) and is
112
+ // 📖 kept only as a harmless last-ditch fallback. Returns true if successful.
95
113
  export function fixConfigPermissions() {
96
114
  const configPath = getConfigPath()
97
115
 
@@ -107,6 +125,88 @@ export function fixConfigPermissions() {
107
125
  }
108
126
  }
109
127
 
128
+ // 📖 Current Windows user name, used both for icacls grants and for matching
129
+ // 📖 ACE lines when parsing the ACL (os.userInfo works on win32 too).
130
+ function getWindowsUserName() {
131
+ try {
132
+ return os.userInfo().username
133
+ } catch {
134
+ return process.env.USERNAME || ''
135
+ }
136
+ }
137
+
138
+ // 📖 Read the real NTFS ACL via icacls and decide if it is secure:
139
+ // 📖 inheritance disabled + grants only for the current user (and trusted
140
+ // 📖 SYSTEM/Administrators entries). Returns { checked, secure, ...parsed };
141
+ // 📖 checked=false when icacls is missing or failed (caller falls back to the
142
+ // 📖 ack-gated warning path instead of trusting useless mode bits).
143
+ async function getWindowsAclStatus(configPath) {
144
+ try {
145
+ const { stdout } = await execFileAsync('icacls', [configPath], {
146
+ timeout: 5000,
147
+ windowsHide: true,
148
+ })
149
+ const parsed = parseIcaclsOutput({ output: stdout, userName: getWindowsUserName(), filePath: configPath })
150
+ const secure = !parsed.inheritanceEnabled && !parsed.othersHaveAccess && parsed.ownerHasAccess
151
+ return { checked: true, secure, ...parsed }
152
+ } catch (err) {
153
+ return { checked: false, secure: false, grants: [], otherNames: [] }
154
+ }
155
+ }
156
+
157
+ // 📖 Fix the NTFS ACL for real: drop inherited ACEs, grant only the current
158
+ // 📖 user full control, then VERIFY by re-reading the ACL (never claim success
159
+ // 📖 without proof - that lie was the core of the issue #173 follow-up).
160
+ // 📖 execFile (no shell) keeps the path injection-safe even with spaces.
161
+ async function fixWindowsAcl(configPath) {
162
+ const userName = getWindowsUserName()
163
+ if (!userName) return false
164
+
165
+ try {
166
+ await execFileAsync(
167
+ 'icacls',
168
+ [configPath, '/inheritance:r', '/grant:r', `${userName}:F`],
169
+ { timeout: 5000, windowsHide: true }
170
+ )
171
+ const status = await getWindowsAclStatus(configPath)
172
+ return status.checked && status.secure
173
+ } catch (err) {
174
+ return false
175
+ }
176
+ }
177
+
178
+ // 📖 Anti-nag marker: written when we warned but could not fix/verify, so the
179
+ // 📖 same warning does not re-fire on every launch (see shouldSkipSecurityWarn).
180
+ // 📖 Lives next to the config so --config-dir / FCM_CONFIG_DIR stays coherent.
181
+ function getAckPath() {
182
+ return `${getConfigPath()}.securityack`
183
+ }
184
+
185
+ function readSecurityAck() {
186
+ try {
187
+ return fs.readFileSync(getAckPath(), 'utf8').trim()
188
+ } catch {
189
+ return null
190
+ }
191
+ }
192
+
193
+ function writeSecurityAck() {
194
+ try {
195
+ fs.writeFileSync(getAckPath(), new Date().toISOString(), { mode: 0o600 })
196
+ return true
197
+ } catch {
198
+ return false
199
+ }
200
+ }
201
+
202
+ function clearSecurityAck() {
203
+ try {
204
+ fs.unlinkSync(getAckPath())
205
+ } catch {
206
+ // 📖 Nothing to clean up is fine.
207
+ }
208
+ }
209
+
110
210
  // 📖 Format permission mode in octal (e.g., 0o644 → "644")
111
211
  // 📖 Exported for unit tests
112
212
  export function formatMode(mode) {
@@ -133,7 +233,9 @@ export function formatModeRwx(mode) {
133
233
  }
134
234
 
135
235
  // 📖 Print the insecure-permissions warning (stderr, so --json stdout stays clean)
136
- function printSecurityWarning(perms) {
236
+ // 📖 On Windows, mode bits are meaningless (always 0666/0444), so when the ACL
237
+ // 📖 was read we show WHAT actually grants access instead of a fake octal story.
238
+ function printSecurityWarning(perms, aclStatus = { checked: false }) {
137
239
  const currentMode = formatMode(perms.mode)
138
240
  const currentRwx = formatModeRwx(perms.mode)
139
241
 
@@ -145,16 +247,29 @@ function printSecurityWarning(perms) {
145
247
  console.error('')
146
248
  console.error('This means other users on this system may be able to read your API keys.')
147
249
  console.error('')
148
- console.error('Recommended: Fix permissions to 600 (rw-------) - owner read/write only')
250
+
251
+ if (aclStatus.checked) {
252
+ if (aclStatus.inheritanceEnabled) {
253
+ console.error('NTFS: this file inherits access permissions from your user folder.')
254
+ }
255
+ if (aclStatus.othersHaveAccess) {
256
+ const names = (aclStatus.otherNames || []).slice(0, 3).join(', ')
257
+ console.error(`NTFS: access is also granted to: ${names}`)
258
+ }
259
+ console.error('')
260
+ }
261
+
262
+ console.error('Recommended: restrict access to your user account only')
149
263
  }
150
264
 
151
- // 📖 Print the manual fix hint. On Windows, point at icacls since Node's chmod
152
- // 📖 only toggles the read-only attribute there.
265
+ // 📖 Print the manual fix hint. Platform-matched command: icacls on Windows
266
+ // 📖 (NTFS ACLs), chmod elsewhere.
153
267
  function printManualFixHint() {
154
268
  console.error('')
155
269
  if (IS_WINDOWS) {
270
+ const user = getWindowsUserName() || '$env:USERNAME'
156
271
  console.error('To fix manually (PowerShell), run:')
157
- console.error(` icacls "${getConfigPath()}" /inheritance:r /grant:r "$env:USERNAME:R,W"`)
272
+ console.error(` icacls "${getConfigPath()}" /inheritance:r /grant:r "${user}:F"`)
158
273
  } else {
159
274
  console.error('To fix manually, run:')
160
275
  console.error(` chmod 600 ${getConfigPath()}`)
@@ -164,24 +279,37 @@ function printManualFixHint() {
164
279
 
165
280
  // 📖 Apply the fix and report the outcome. Shared by the prompt path (user said
166
281
  // 📖 yes) and the auto-fix path (--fix-permissions / --yes / -y).
167
- function applyFixAndReport() {
168
- const success = fixConfigPermissions()
282
+ // 📖 Windows: icacls ACL fix, verified by re-reading the ACL.
283
+ // 📖 POSIX: chmod 600, verified by re-statting the file.
284
+ // 📖 If the fix cannot be verified, write the anti-nag ack so the warning does
285
+ // 📖 not re-fire on every launch, and point at the manual command.
286
+ async function applyFixAndReport() {
287
+ const configPath = getConfigPath()
288
+ let success = false
289
+
290
+ if (IS_WINDOWS) {
291
+ success = await fixWindowsAcl(configPath)
292
+ } else {
293
+ success = fixConfigPermissions() && getConfigPermissions()?.isSecure === true
294
+ }
169
295
 
170
296
  if (success) {
297
+ clearSecurityAck()
171
298
  console.error('')
172
299
  console.error('✅ Permissions fixed! Your API keys are now secure.')
173
300
  console.error('')
174
301
  if (IS_WINDOWS) {
175
- console.error('Note: on Windows this is best-effort (read-only bit). See docs for NTFS ACLs.')
302
+ console.error(`NTFS access is now restricted to "${getWindowsUserName()}".`)
176
303
  console.error('')
177
304
  }
178
305
  return { wasSecure: false, wasFixed: true }
179
306
  }
180
307
 
308
+ writeSecurityAck()
181
309
  console.error('')
182
310
  console.error('❌ Failed to fix permissions automatically.')
183
311
  printManualFixHint()
184
- return { wasSecure: false, wasFixed: false, error: 'chmod_failed' }
312
+ return { wasSecure: false, wasFixed: false, error: 'fix_failed' }
185
313
  }
186
314
 
187
315
  // 📖 Check security and handle the fix flow if needed
@@ -189,9 +317,14 @@ function applyFixAndReport() {
189
317
  // 📖 the confirmation prompt are visible and fully resolved before raw mode /
190
318
  // 📖 the alternate screen take over.
191
319
  //
320
+ // 📖 Windows verdict (issue #173 follow-up): mode bits on win32 are only ever
321
+ // 📖 0666 or 0444, so the POSIX 0600 check would nag forever. The real verdict
322
+ // 📖 comes from the NTFS ACL (icacls). If icacls is unavailable, fall through to
323
+ // 📖 the warning path but let the anti-nag ack keep it to once per 30 days.
324
+ //
192
325
  // 📖 Options:
193
326
  // autoFix - true when --fix-permissions / --yes / -y was passed: apply the
194
- // fix without asking
327
+ // fix without asking (also bypasses the anti-nag ack)
195
328
  // promptAllowed - false on daemon/web/JSON surfaces: never prompt there, at most
196
329
  // warn on stderr
197
330
  // stdinIsTTY - override the stdin TTY detection (tests); defaults to real detection
@@ -205,11 +338,23 @@ export async function checkConfigSecurity(options = {}) {
205
338
  return { wasSecure: true, wasFixed: false }
206
339
  }
207
340
 
208
- // 📖 Permissions are already secure
209
- if (perms.isSecure) {
341
+ // 📖 Security verdict, platform-aware.
342
+ let aclStatus = { checked: false, secure: false, grants: [], otherNames: [] }
343
+ if (IS_WINDOWS) {
344
+ aclStatus = await getWindowsAclStatus(perms.path)
345
+ if (aclStatus.checked && aclStatus.secure) {
346
+ return { wasSecure: true, wasFixed: false }
347
+ }
348
+ } else if (perms.isSecure) {
210
349
  return { wasSecure: true, wasFixed: false }
211
350
  }
212
351
 
352
+ // 📖 Anti-nag gate: if we already warned recently and the fix did not stick,
353
+ // 📖 stay quiet instead of nagging on every launch. --fix-permissions bypasses.
354
+ if (options.autoFix !== true && shouldSkipSecurityWarn({ ackedAt: readSecurityAck() })) {
355
+ return { wasSecure: false, wasFixed: false, error: 'warned_recently' }
356
+ }
357
+
213
358
  // 📖 Pure gate (see utils.js): decides auto-fix vs prompt vs warn-only.
214
359
  const action = resolveSecurityAction({
215
360
  configExists: true,
@@ -225,7 +370,7 @@ export async function checkConfigSecurity(options = {}) {
225
370
 
226
371
  // 📖 Security issue detected! Print the warning first so it is on screen
227
372
  // 📖 no matter which path follows.
228
- printSecurityWarning(perms)
373
+ printSecurityWarning(perms, aclStatus)
229
374
 
230
375
  if (action === 'auto-fix') {
231
376
  return applyFixAndReport()
@@ -234,6 +379,7 @@ export async function checkConfigSecurity(options = {}) {
234
379
  if (action === 'warn-only') {
235
380
  console.error('Running non-interactively (piped stdin or daemon/web mode), so skipping the prompt.')
236
381
  printManualFixHint()
382
+ writeSecurityAck()
237
383
  return { wasSecure: false, wasFixed: false, error: 'non_interactive' }
238
384
  }
239
385
 
package/src/core/utils.js CHANGED
@@ -750,6 +750,103 @@ export function resolveSecurityAction({ configExists, isSecure, autoFixRequested
750
750
  return 'prompt'
751
751
  }
752
752
 
753
+ // 📖 parseIcaclsOutput: PURE parser for Windows `icacls <file>` output, used by
754
+ // 📖 the config security check to get a REAL answer about NTFS access.
755
+ // 📖 WHY: on win32 Node reports POSIX-style modes as 0666 (writable) or 0444
756
+ // 📖 (read-only) only - 0600 is unreachable - so the old `(mode & 0o777) === 0o600`
757
+ // 📖 test was always false and the warning re-fired on every launch (issue #173
758
+ // 📖 follow-up from rutexd). Only the ACL tells the truth on NTFS.
759
+ // 📖
760
+ // 📖 Sample output we must handle (ACEs, one per line, path on the first line):
761
+ // C:\Users\rutex\.free-coding-models.json NT AUTHORITY\SYSTEM:(I)(F)
762
+ // BUILTIN\Administrators:(I)(F)
763
+ // DESKTOP-XYZ\rutex:(F)
764
+ // Successfully processed 1 files; Failed processing 0 files
765
+ // 📖 `(I)` marks an inherited ACE, so "inheritance disabled" = no `(I)` anywhere.
766
+ // 📖 Locale-proof whitelists: names are machine-localized, so we whitelist by
767
+ // 📖 substring (SYSTEM, Administra* covers Administrators/Administrateurs) and
768
+ // 📖 by well-known SID (S-1-5-18 = SYSTEM, S-1-5-32-544 = Administrators), plus
769
+ // 📖 the current user (case-insensitive, domain-qualified or bare).
770
+ // 📖
771
+ // 📖 Params:
772
+ // output - raw stdout of `icacls <path>` (string)
773
+ // userName - current user name (string), e.g. "rutex"
774
+ // filePath - the exact path passed to icacls (optional but STRONGLY
775
+ // recommended: icacls echoes it on the first line, glued to the
776
+ // first ACE, and paths/account names can both contain spaces)
777
+ // 📖 Returns { inheritanceEnabled, othersHaveAccess, ownerHasAccess, grants }
778
+ // grants = [{ name, flags }] for every ACE line; empty when output unparseable.
779
+ export function parseIcaclsOutput({ output, userName, filePath = '' }) {
780
+ const text = String(output ?? '')
781
+ const user = String(userName ?? '').trim().toLowerCase()
782
+ const knownPath = String(filePath ?? '')
783
+ const lines = text.split(/\r?\n/)
784
+
785
+ // 📖 The rights/flags groups always end the line: "(I)(F)", "(F)", "(R,W)"...
786
+ const aceTail = /((?:\([^)]*\)\s*)+)$/
787
+ const grants = []
788
+ for (const rawLine of lines) {
789
+ const tail = aceTail.exec(rawLine)
790
+ if (!tail) continue
791
+
792
+ let before = rawLine.slice(0, tail.index).trim()
793
+ if (before === '') continue
794
+ // 📖 Drop the colon that separates the account name from its flags.
795
+ before = before.replace(/:$/, '').trim()
796
+ // 📖 First ACE shares its line with the echoed path: strip the path we
797
+ // 📖 passed to icacls. Without a known path, keep only the segment after
798
+ // 📖 the last whitespace (exact for simple names; callers should pass
799
+ // 📖 filePath when the path or account name contains spaces).
800
+ if (knownPath && before.startsWith(knownPath)) {
801
+ before = before.slice(knownPath.length).trim()
802
+ } else if (knownPath === '' && /\s/.test(before)) {
803
+ before = before.slice(before.lastIndexOf(' ') + 1).trim()
804
+ }
805
+ if (before === '') continue
806
+ grants.push({ name: before, flags: tail[1].replace(/\s+/g, '') })
807
+ }
808
+
809
+ const isTrusted = (name) => {
810
+ const n = name.toLowerCase()
811
+ if (user !== '' && (n === user || n.endsWith('\\' + user))) return true
812
+ if (n.includes('system') || n.includes('s-1-5-18')) return true
813
+ if (n.includes('administra') || n.includes('s-1-5-32-544')) return true
814
+ return false
815
+ }
816
+
817
+ const inheritanceEnabled = grants.some((g) => g.flags.includes('I'))
818
+ const others = grants.filter((g) => !isTrusted(g.name)).map((g) => g.name)
819
+ const ownerHasAccess = grants.some((g) => {
820
+ const n = g.name.toLowerCase()
821
+ return user !== '' && (n === user || n.endsWith('\\' + user))
822
+ })
823
+
824
+ return {
825
+ inheritanceEnabled,
826
+ othersHaveAccess: others.length > 0,
827
+ ownerHasAccess,
828
+ grants,
829
+ otherNames: others,
830
+ }
831
+ }
832
+
833
+ // 📖 shouldSkipSecurityWarn: PURE anti-nag gate for the config security warning.
834
+ // 📖 WHY: when the permission fix cannot be applied or verified (rare: icacls
835
+ // 📖 missing on Windows, chmod failing on POSIX), the insecure state persists and
836
+ // 📖 the warning used to re-fire on every single launch. Once we warned and the
837
+ // 📖 fix did not stick, stay quiet for `intervalDays` (default 30) instead of
838
+ // 📖 nagging daily. `--fix-permissions` bypasses the gate in the caller.
839
+ // 📖 Params: ackedAt - last warning timestamp (ms epoch or ISO string or null)
840
+ // now - current time in ms epoch; intervalDays - quiet window length
841
+ // 📖 Returns true only when ackedAt parses AND is less than intervalDays old.
842
+ export function shouldSkipSecurityWarn({ ackedAt, now = Date.now(), intervalDays = 30 }) {
843
+ if (ackedAt === null || ackedAt === undefined || ackedAt === '') return false
844
+ const then = typeof ackedAt === 'number' ? ackedAt : Date.parse(String(ackedAt))
845
+ if (!Number.isFinite(then)) return false
846
+ const intervalMs = Math.max(0, intervalDays) * 24 * 60 * 60 * 1000
847
+ return now - then < intervalMs && now >= then
848
+ }
849
+
753
850
  // 📖 detectTerminalCapabilities: PURE terminal capability probe for TUI overlays.
754
851
  // 📖 WHY: basic server consoles (IPMI/KVM viewers, ASPEED framebuffer, serial
755
852
  // 📖 terminals) often run 80x24 or smaller with no or limited color support, and