@gotcos/glasses-server 6.12.2 → 6.12.4

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/.env.example CHANGED
@@ -55,6 +55,12 @@ BIND_HOST=0.0.0.0
55
55
  #
56
56
  # Codex remains read-only by default. This is the only broader trust opt-in:
57
57
  # COS_CODEX_SANDBOX=workspace-write
58
+ #
59
+ # Claude preserves COS's established trusted-machine behavior by default.
60
+ # Security-conscious installs can remove the permission bypass and restrict
61
+ # Claude to COS's explicit per-query tool allowlist. Undeclared tools fail
62
+ # closed without an interactive prompt:
63
+ # COS_CLAUDE_TRUST_MODE=allowlist
58
64
 
59
65
  # ── VOICE (optional) ────────────────────────────────────────────────────
60
66
  # Local transcription is FREE via whisper.cpp (brew install whisper-cpp; the
@@ -68,3 +74,7 @@ BIND_HOST=0.0.0.0
68
74
  # Power users running the COS Starter Kit can point the glasses at their
69
75
  # pipeline to inherit live tasks/calendar/people context. Omit for standalone.
70
76
  # COS_SCRIPTS_DIR=/path/to/your/cos/operations/scripts
77
+
78
+ # Telegram session/activity notifications remain OFF even if the COS scripts
79
+ # directory contains .telegram_config.json. Enable export explicitly:
80
+ # COS_TELEGRAM_NOTIFICATIONS=1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,46 @@
1
1
  # Changelog
2
2
 
3
+ ## 6.12.4
4
+
5
+ Completes the public launch security review with a constant-time token check.
6
+
7
+ - **API tokens compare in constant time.** The `/api` middleware and the
8
+ OpenAI-compatible `/v1/chat/completions` Bearer check no longer use a plain
9
+ `!==` string compare, which short-circuits on the first differing byte and
10
+ leaks token bytes through response timing. Both now hash each side to a fixed
11
+ SHA-256 digest and compare with `crypto.timingSafeEqual`. Missing headers,
12
+ duplicated headers, and length-mismatched tokens fail closed without throwing.
13
+ A new `token-auth` test pins the behavior. 401 responses are otherwise
14
+ unchanged.
15
+
16
+ ## 6.12.3
17
+
18
+ Security hardening from the public launch review, without changing app/server
19
+ wire contracts.
20
+
21
+ - **Stored prompts never enter a shell command.** Archive title generation now
22
+ launches Claude with an argument array and sends user content over stdin. A
23
+ regression test proves command substitutions and backticks remain inert.
24
+ - **Claude can run in a real allowlist mode.** Existing installs retain trusted
25
+ mode for backward compatibility. Setting `COS_CLAUDE_TRUST_MODE=allowlist`
26
+ removes the permission bypass, restricts Claude to COS's explicit per-query
27
+ tools, and denies undeclared tools without an interactive prompt.
28
+ - **Tailscale matching is exact.** Network and CORS policy now accept only the
29
+ assigned `100.64.0.0/10` CGNAT range rather than every `100.x` address.
30
+ Localhost and RFC1918 LAN access remain unchanged.
31
+ - **Durable local state is private.** Runtime data and archive directories are
32
+ repaired to `0700`; atomic state, conversation archives, session logs, and
33
+ the saved OpenAI key are created or repaired to `0600`. Credential writes use
34
+ private, exclusive, fsync-backed atomic publication.
35
+ - **Telegram export requires consent.** Merely finding a private
36
+ `.telegram_config.json` no longer enables activity export. Operators must set
37
+ the exact `COS_TELEGRAM_NOTIFICATIONS=1` opt-in.
38
+ - **Canonical history remains exact.** Operational previews and provider
39
+ ledgers keep their existing redaction, while durable prompts/answers are not
40
+ silently mutated; recovery, retries, and `reference message N` remain intact.
41
+ - **Backward compatible.** Query, prompt recovery, meetings, media, display,
42
+ diagnostics, transcription, and protocol response shapes are unchanged.
43
+
3
44
  ## 6.12.2
4
45
 
5
46
  First-install hardening for public `npx` users.
package/README.md CHANGED
@@ -43,6 +43,10 @@ without silently losing completed replies.
43
43
  > Existing `COS_CODEX_MODEL` / `COS_CODEX_REASONING_EFFORT` settings remain
44
44
  > supported on the migrated Frontier slot; leave them blank for auto-latest.
45
45
  > Codex runs **sandboxed read-only** by default (`COS_CODEX_SANDBOX` to adjust).
46
+ > Claude preserves the established trusted-machine mode for compatibility.
47
+ > Set `COS_CLAUDE_TRUST_MODE=allowlist` to remove Claude's permission bypass
48
+ > and restrict it to COS's explicit per-query tool allowlist; undeclared tools
49
+ > then fail closed without prompting.
46
50
 
47
51
  ## Connect your phone (the one gotcha)
48
52
 
@@ -54,7 +58,9 @@ The glasses app runs on your iPhone and must reach this server on your Mac.
54
58
  4. Either way, paste the **API token** the server printed at boot.
55
59
 
56
60
  To restrict the server to localhost only, set `BIND_HOST=127.0.0.1` in `~/.cos-glasses/.env`.
57
- The built-in IP allowlist blocks public-internet traffic regardless.
61
+ The built-in IP allowlist blocks public-internet traffic regardless. Its mesh
62
+ range is the exact Tailscale/CGNAT allocation (`100.64.0.0/10`), not all of
63
+ `100.0.0.0/8`; RFC1918 LAN ranges remain supported.
58
64
 
59
65
  ## What it does
60
66
 
@@ -97,6 +103,9 @@ optional except an installed CLI. Highlights: `BIND_HOST`, `PORT`,
97
103
  server-owned query recovery), and `COS_MEDIA_ROOT` (optional image-store
98
104
  location; default `~/.cos-glasses/data/media`). Your name + transcription vocabulary live in
99
105
  `~/.cos-glasses/.cos-profile.json` (see `.cos-profile.example.json`).
106
+ Telegram activity export is disabled by default even when a private COS
107
+ pipeline contains `.telegram_config.json`; enable it only with the explicit
108
+ `COS_TELEGRAM_NOTIFICATIONS=1` opt-in.
100
109
 
101
110
  ## Run from source
102
111
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.12.2",
3
+ "version": "6.12.4",
4
4
  "description": "COS Glasses — self-hosted AI heads-up-display server for Even G2 smart glasses, powered by your local Claude Code or Codex CLI",
5
5
  "type": "module",
6
6
  "bin": {
package/server/index.ts CHANGED
@@ -52,6 +52,12 @@ import {
52
52
  queryJobCoordinator,
53
53
  shutdownQueryJobRuntime,
54
54
  } from './lib/query-job-runtime.js'
55
+ import {
56
+ isAllowedNetworkIp,
57
+ isAllowedNetworkOrigin,
58
+ isTailscaleIpv4,
59
+ } from './lib/network-policy.js'
60
+ import { timingSafeTokenEqual } from './lib/token-auth.js'
55
61
 
56
62
  const app = express()
57
63
  const PORT = parseInt(process.env.PORT ?? '3141', 10)
@@ -94,15 +100,7 @@ if (API_TOKEN_AUTO) {
94
100
  // local + meshnet (Tailscale/CGNAT) + LAN consumers working.
95
101
  app.use((req, res, next) => {
96
102
  const ip = req.ip || req.socket.remoteAddress || ''
97
- // Normalize IPv6-mapped IPv4 (::ffff:127.0.0.1 → 127.0.0.1)
98
- const cleanIp = ip.replace(/^::ffff:/, '')
99
- const allowed =
100
- cleanIp === '127.0.0.1' || cleanIp === '::1' || // localhost
101
- /^100\./.test(cleanIp) || // meshnet (CGNAT)
102
- /^10\./.test(cleanIp) || // private 10.x
103
- /^172\.(1[6-9]|2\d|3[01])\./.test(cleanIp) || // private 172.16-31.x
104
- /^192\.168\./.test(cleanIp) // private 192.168.x
105
- if (!allowed) {
103
+ if (!isAllowedNetworkIp(ip)) {
106
104
  res.status(403).json({ error: 'forbidden — not on allowed network' })
107
105
  return
108
106
  }
@@ -114,10 +112,7 @@ app.use(cors({
114
112
  origin: (origin, cb) => {
115
113
  // Allow requests with no origin (same-origin, curl, SSE) or "null" origin (file:// WebViews like Even Hub)
116
114
  if (!origin || origin === 'null') return cb(null, true)
117
- // Allow localhost variants
118
- if (/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) return cb(null, true)
119
- // Allow private network IPs (10.x.x.x, 172.16-31.x.x, 192.168.x.x, 100.x.x.x for Meshnet/CGNAT)
120
- if (/^https?:\/\/(10|172\.(1[6-9]|2\d|3[01])|192\.168|100)(\.\d+){2,3}(:\d+)?$/.test(origin)) return cb(null, true)
115
+ if (isAllowedNetworkOrigin(origin)) return cb(null, true)
121
116
  cb(new Error('CORS blocked'))
122
117
  },
123
118
  }))
@@ -134,7 +129,7 @@ app.use('/api', (req, res, next) => {
134
129
  req.path === '/diag/client' ||
135
130
  req.path === '/diag/health'
136
131
  ) return next()
137
- if (req.headers['x-cos-token'] !== API_TOKEN) {
132
+ if (!timingSafeTokenEqual(req.headers['x-cos-token'], API_TOKEN)) {
138
133
  return res.status(401).json({ error: 'unauthorized' })
139
134
  }
140
135
  next()
@@ -274,7 +269,7 @@ listenRequiredServers(listeners).then(() => {
274
269
  for (const [name, infos] of Object.entries(nets)) {
275
270
  for (const info of infos ?? []) {
276
271
  if (info.family !== 'IPv4' || info.internal) continue
277
- const isTailscale = info.address.startsWith('100.') || name.startsWith('utun') || name.startsWith('tailscale')
272
+ const isTailscale = isTailscaleIpv4(info.address) || name.startsWith('tailscale')
278
273
  addrs.push({ ip: info.address, label: isTailscale ? 'Tailscale — works from anywhere' : `${name} — same Wi-Fi only` })
279
274
  }
280
275
  }
@@ -3,17 +3,16 @@
3
3
  // Each day's archive contains one or more "chats" (split by context breaks)
4
4
  // Summaries are generated via `claude -p --model sonnet`, budget-capped per day.
5
5
 
6
- import { mkdirSync, readdirSync } from 'node:fs'
6
+ import { chmodSync, mkdirSync, readdirSync } from 'node:fs'
7
7
  import { resolve, dirname } from 'node:path'
8
8
  import { fileURLToPath } from 'node:url'
9
- import { exec } from 'node:child_process'
10
- import { promisify } from 'node:util'
9
+ import { spawn } from 'node:child_process'
11
10
  import { logTokenAudit } from './token-audit.js'
12
11
  import { atomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
13
12
  import { consumeArchiveLLMBudget } from './archive-budget.js'
14
13
  import { mergeMediaAttachmentRefs, type MediaAttachmentRef } from '../../shared/media-attachment.js'
14
+ import { secureExistingPrivateFile } from './secure-user-config.js'
15
15
 
16
- const execAsync = promisify(exec)
17
16
  import type { Exchange } from './conversation.js'
18
17
 
19
18
  import { dataPath } from './data-dir.js'
@@ -65,7 +64,8 @@ export interface SessionToArchive {
65
64
  // ── Directory management ────────────────────────────────────
66
65
 
67
66
  function ensureArchiveDir(): string {
68
- mkdirSync(ARCHIVE_DIR, { recursive: true })
67
+ mkdirSync(ARCHIVE_DIR, { recursive: true, mode: 0o700 })
68
+ chmodSync(ARCHIVE_DIR, 0o700)
69
69
  return ARCHIVE_DIR
70
70
  }
71
71
 
@@ -76,7 +76,9 @@ function archivePath(date: string): string {
76
76
  // ── Read/Write ──────────────────────────────────────────────
77
77
 
78
78
  export function loadArchive(date: string): DailyArchive | null {
79
- const result = loadJsonOrQuarantine<DailyArchive>(archivePath(date))
79
+ const path = archivePath(date)
80
+ secureExistingPrivateFile(path)
81
+ const result = loadJsonOrQuarantine<DailyArchive>(path)
80
82
  if (result.status === 'corrupt') {
81
83
  // Loud — a silent return masked archive corruption as "day unavailable"
82
84
  console.error(
@@ -178,6 +180,54 @@ function fallbackDaySummary(chats: ArchivedChat[]): string {
178
180
  return summaries.slice(0, 2).join(', ').slice(0, 60) || 'Day activity'
179
181
  }
180
182
 
183
+ /**
184
+ * Run the archive summarizer without invoking a shell.
185
+ *
186
+ * The archived query text is user-controlled. It must travel over stdin, never
187
+ * through a command string, so shell metacharacters remain inert data.
188
+ */
189
+ function runClaudeArchiveSummary(input: string, instruction: string): Promise<string> {
190
+ return new Promise((resolve, reject) => {
191
+ const proc = spawn('claude', ['-p', '--model', 'sonnet', instruction], {
192
+ stdio: ['pipe', 'pipe', 'pipe'],
193
+ })
194
+
195
+ let stdout = ''
196
+ let stderr = ''
197
+ let settled = false
198
+
199
+ const finish = (error?: Error) => {
200
+ if (settled) return
201
+ settled = true
202
+ clearTimeout(timer)
203
+ if (error) reject(error)
204
+ else resolve(stdout)
205
+ }
206
+
207
+ const timer = setTimeout(() => {
208
+ proc.kill('SIGTERM')
209
+ finish(new Error('Archive summary timed out'))
210
+ }, 15_000)
211
+ timer.unref?.()
212
+
213
+ proc.stdout.on('data', (chunk: Buffer) => {
214
+ if (stdout.length < 16_384) stdout += chunk.toString().slice(0, 16_384 - stdout.length)
215
+ })
216
+ proc.stderr.on('data', (chunk: Buffer) => {
217
+ if (stderr.length < 4_096) stderr += chunk.toString().slice(0, 4_096 - stderr.length)
218
+ })
219
+ proc.on('error', error => finish(error))
220
+ proc.on('close', code => {
221
+ if (code === 0) finish()
222
+ else finish(new Error(`Archive summary exited ${code}: ${stderr.trim()}`))
223
+ })
224
+
225
+ // Ignore EPIPE here; the close/error handlers above own the final outcome.
226
+ proc.stdin.on('error', () => {})
227
+ proc.stdin.end(input)
228
+ })
229
+ }
230
+
181
231
  /** Generate a <60 char summary for a single chat via claude -p.
182
232
  * Budget-capped: if MAX_DAILY_ARCHIVE_LLM_CALLS is exhausted or `skipLLM` is
183
233
  * passed, returns a deterministic string fallback. */
@@ -196,9 +246,9 @@ export async function generateChatSummary(exchanges: Exchange[], skipLLM = false
196
246
 
197
247
  const startMs = Date.now()
198
248
  try {
199
- const { stdout } = await execAsync(
200
- `echo ${JSON.stringify(userQueries)} | claude -p --model sonnet "Summarize these COS Glasses queries into a single title under 60 characters. Just the title, no quotes, no explanation."`,
201
- { timeout: 15_000 }
249
+ const stdout = await runClaudeArchiveSummary(
250
+ userQueries,
251
+ 'Summarize these COS Glasses queries into a single title under 60 characters. Just the title, no quotes, no explanation.',
202
252
  )
203
253
  const result = stdout.trim()
204
254
 
@@ -237,9 +287,9 @@ export async function generateDaySummary(chats: ArchivedChat[], skipLLM = false)
237
287
 
238
288
  const startMs = Date.now()
239
289
  try {
240
- const { stdout } = await execAsync(
241
- `echo ${JSON.stringify(allQueries)} | claude -p --model sonnet "Summarize these COS Glasses queries from one day into a daily title under 60 characters. Just the title, no quotes."`,
242
- { timeout: 15_000 }
290
+ const stdout = await runClaudeArchiveSummary(
291
+ allQueries,
292
+ 'Summarize these COS Glasses queries from one day into a daily title under 60 characters. Just the title, no quotes.',
243
293
  )
244
294
  const result = stdout.trim()
245
295
 
@@ -9,6 +9,7 @@
9
9
 
10
10
  import {
11
11
  closeSync,
12
+ chmodSync,
12
13
  constants,
13
14
  existsSync,
14
15
  fchmodSync,
@@ -23,9 +24,12 @@ import { randomBytes } from 'node:crypto'
23
24
  import { basename, dirname, join } from 'node:path'
24
25
 
25
26
  export function atomicWriteFileSync(path: string, data: string | Buffer, options: { mode?: number } = {}): void {
27
+ const mode = options.mode ?? 0o600
26
28
  const tmp = `${path}.tmp`
27
- writeFileSync(tmp, data, options.mode === undefined ? undefined : { mode: options.mode })
29
+ writeFileSync(tmp, data, { mode })
30
+ chmodSync(tmp, mode)
28
31
  renameSync(tmp, path)
32
+ chmodSync(path, mode)
29
33
  }
30
34
 
31
35
  /**
@@ -48,6 +48,7 @@ import {
48
48
  type MediaAttachmentRef,
49
49
  } from '../../shared/media-attachment.js'
50
50
  import { terminalProviderAuthFailure } from './provider-terminal-error.js'
51
+ import { claudePermissionArgs, getClaudeTrustMode } from './claude-permissions.js'
51
52
 
52
53
  // Inactivity = no stdout data for this long → kill (catches stalls)
53
54
  const INACTIVITY_BY_MODEL: Record<ClaudeModelPreference, number> = {
@@ -214,7 +215,7 @@ export async function preWarmCLI(): Promise<void> {
214
215
  '--effort', getClaudeEffortLevel(),
215
216
  '--output-format', 'stream-json',
216
217
  '--verbose',
217
- '--dangerously-skip-permissions',
218
+ ...claudePermissionArgs(getClaudeTrustMode(), null),
218
219
  '--system-prompt', buildPrewarmSystemPrompt(),
219
220
  ], {
220
221
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -493,20 +494,19 @@ export async function callClaudeStreaming(
493
494
  '--effort', cliEffortFlag,
494
495
  '--output-format', 'stream-json',
495
496
  '--verbose', // Required: stream-json requires --verbose
496
- '--dangerously-skip-permissions', // Required: headless CLI mode with no TTY for user prompts
497
497
  '--system-prompt', systemPrompt,
498
498
  ]
499
499
 
500
500
  // Full COS path gets tools + partial messages; lightweight gets web search only
501
501
  if (options?.lightweight) {
502
502
  if (imagePaths.length > 0) {
503
- args.push('--allowedTools', tools)
503
+ args.push(...claudePermissionArgs(getClaudeTrustMode(), tools))
504
504
  } else {
505
505
  // Lightweight: web search for general questions, no Bash/Read/Write (saves 5-10s)
506
- args.push('--allowedTools', 'WebSearch,WebFetch')
506
+ args.push(...claudePermissionArgs(getClaudeTrustMode(), 'WebSearch,WebFetch'))
507
507
  }
508
508
  } else {
509
- args.push('--allowedTools', tools, '--include-partial-messages')
509
+ args.push(...claudePermissionArgs(getClaudeTrustMode(), tools), '--include-partial-messages')
510
510
  }
511
511
 
512
512
  if (existingCliSession) {
@@ -0,0 +1,40 @@
1
+ export type ClaudeTrustMode = 'trusted' | 'allowlist'
2
+
3
+ /**
4
+ * Claude Code historically ran COS in trusted mode so headless sessions could
5
+ * use the operator's existing tools without stopping for an interactive
6
+ * permission prompt. Keep that behavior for compatibility, while allowing
7
+ * security-conscious installs to opt into a strict, non-interactive allowlist.
8
+ */
9
+ export function getClaudeTrustMode(
10
+ env: NodeJS.ProcessEnv = process.env,
11
+ ): ClaudeTrustMode {
12
+ return env.COS_CLAUDE_TRUST_MODE?.trim().toLowerCase() === 'allowlist'
13
+ ? 'allowlist'
14
+ : 'trusted'
15
+ }
16
+
17
+ /**
18
+ * Build only Claude's permission-related CLI arguments.
19
+ *
20
+ * - trusted: preserves the established COS behavior.
21
+ * - allowlist: denies undeclared tools without prompting and restricts the
22
+ * available built-ins to the explicit per-query list.
23
+ */
24
+ export function claudePermissionArgs(
25
+ mode: ClaudeTrustMode,
26
+ allowedTools: string | null,
27
+ ): string[] {
28
+ if (mode === 'trusted') {
29
+ return allowedTools === null
30
+ ? ['--dangerously-skip-permissions']
31
+ : ['--dangerously-skip-permissions', '--allowedTools', allowedTools]
32
+ }
33
+
34
+ const tools = allowedTools ?? ''
35
+ return [
36
+ '--permission-mode', 'dontAsk',
37
+ '--tools', tools,
38
+ '--allowedTools', tools,
39
+ ]
40
+ }
@@ -14,6 +14,7 @@ import { atomicWriteFileSync, durableAtomicWriteFileSync, loadJsonOrQuarantine }
14
14
  import { localDay } from './local-day.js'
15
15
  import { normalizeModelPreference, type ModelPreference } from '../../shared/model-preference.js'
16
16
  import { parseMediaAttachmentRefs, type MediaAttachmentRef } from '../../shared/media-attachment.js'
17
+ import { secureExistingPrivateFile } from './secure-user-config.js'
17
18
 
18
19
  export type { ModelPreference }
19
20
 
@@ -119,6 +120,7 @@ function matchesJobIdentity(exchange: Exchange, identity: ExchangeJobIdentity):
119
120
  }
120
121
 
121
122
  function loadFromDisk(): void {
123
+ secureExistingPrivateFile(SESSION_FILE)
122
124
  const result = loadJsonOrQuarantine<SessionsFile>(SESSION_FILE)
123
125
  if (result.status === 'missing') return // fresh start
124
126
 
@@ -1,6 +1,6 @@
1
1
  import { homedir } from 'node:os'
2
2
  import { join, resolve } from 'node:path'
3
- import { mkdirSync } from 'node:fs'
3
+ import { chmodSync, mkdirSync } from 'node:fs'
4
4
 
5
5
  // Runtime state directory. Defaults to ~/.cos-glasses/data — a writable location
6
6
  // that survives `npx` cache churn and works on global/Docker installs. (Writing
@@ -11,7 +11,8 @@ export const DATA_DIR = process.env.COS_DATA_DIR
11
11
  : join(homedir(), '.cos-glasses', 'data')
12
12
 
13
13
  try {
14
- mkdirSync(DATA_DIR, { recursive: true })
14
+ mkdirSync(DATA_DIR, { recursive: true, mode: 0o700 })
15
+ chmodSync(DATA_DIR, 0o700)
15
16
  } catch { /* best effort — individual writers also tolerate a missing dir */ }
16
17
 
17
18
  /** Build a path under the runtime data directory. */
@@ -0,0 +1,42 @@
1
+ /** Network ranges accepted by the public server before API-token auth runs. */
2
+
3
+ function parseIpv4(value: string): number[] | null {
4
+ const parts = value.split('.')
5
+ if (parts.length !== 4) return null
6
+ const octets = parts.map(part => Number(part))
7
+ if (octets.some((octet, index) => !/^\d{1,3}$/.test(parts[index]) || octet < 0 || octet > 255)) {
8
+ return null
9
+ }
10
+ return octets
11
+ }
12
+
13
+ export function normalizeRemoteIp(value: string): string {
14
+ return value.replace(/^::ffff:/, '')
15
+ }
16
+
17
+ export function isTailscaleIpv4(value: string): boolean {
18
+ const octets = parseIpv4(normalizeRemoteIp(value))
19
+ return !!octets && octets[0] === 100 && octets[1] >= 64 && octets[1] <= 127
20
+ }
21
+
22
+ export function isAllowedNetworkIp(value: string): boolean {
23
+ const clean = normalizeRemoteIp(value)
24
+ if (clean === '127.0.0.1' || clean === '::1') return true
25
+ if (isTailscaleIpv4(clean)) return true
26
+
27
+ const octets = parseIpv4(clean)
28
+ if (!octets) return false
29
+ return octets[0] === 10 ||
30
+ (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) ||
31
+ (octets[0] === 192 && octets[1] === 168)
32
+ }
33
+
34
+ export function isAllowedNetworkOrigin(origin: string): boolean {
35
+ try {
36
+ const url = new URL(origin)
37
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return false
38
+ return url.hostname === 'localhost' || isAllowedNetworkIp(url.hostname)
39
+ } catch {
40
+ return false
41
+ }
42
+ }
@@ -20,6 +20,7 @@ import { existsSync, readFileSync } from 'node:fs'
20
20
  import { dirname, resolve } from 'node:path'
21
21
  import { fileURLToPath } from 'node:url'
22
22
  import { COS_SCRIPTS_DIR } from './python-bridge.js'
23
+ import { secureExistingPrivateFile } from './secure-user-config.js'
23
24
 
24
25
  const __dirname = dirname(fileURLToPath(import.meta.url))
25
26
 
@@ -61,6 +62,7 @@ export function clearCachedKey(): void {
61
62
  function readConfigFile(): KeyConfigFile | null {
62
63
  if (!existsSync(KEY_FILE_PATH)) return null
63
64
  try {
65
+ secureExistingPrivateFile(KEY_FILE_PATH)
64
66
  const raw = readFileSync(KEY_FILE_PATH, 'utf-8')
65
67
  const parsed = JSON.parse(raw) as Partial<KeyConfigFile>
66
68
  if (!parsed || typeof parsed.key !== 'string' || !parsed.key.trim()) return null
@@ -2,7 +2,7 @@
2
2
  // so COS can query Glasses sessions by original UUID, date, domain, or content.
3
3
  // Fires on: TTL expiry, explicit /api/sessions/:id/end, server shutdown.
4
4
 
5
- import { appendFileSync, mkdirSync } from 'node:fs'
5
+ import { appendFileSync, chmodSync, mkdirSync } from 'node:fs'
6
6
  import { resolve, dirname } from 'node:path'
7
7
  import type { Exchange } from './conversation.js'
8
8
 
@@ -143,7 +143,8 @@ export function writeSessionLog(entry: SessionLogEntry): boolean {
143
143
 
144
144
  try {
145
145
  mkdirSync(dirname(logPath), { recursive: true })
146
- appendFileSync(logPath, JSON.stringify(entry) + '\n')
146
+ appendFileSync(logPath, JSON.stringify(entry) + '\n', { encoding: 'utf8', mode: 0o600 })
147
+ chmodSync(logPath, 0o600)
147
148
  console.log(`[session-log] Logged session ${entry.session_id} (${entry.end_reason}, ${entry.duration_minutes}m, ${entry.total_message_count} msgs)`)
148
149
  return true
149
150
  } catch (err) {
@@ -13,7 +13,14 @@ interface TelegramConfig {
13
13
 
14
14
  let config: TelegramConfig | null = null
15
15
 
16
+ export function telegramNotificationsEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
17
+ return env.COS_TELEGRAM_NOTIFICATIONS === '1'
18
+ }
19
+
16
20
  function loadConfig(): TelegramConfig | null {
21
+ // A credential file is not consent to export conversation activity. Require
22
+ // the same kind of explicit opt-in used by cloud transcription fallback.
23
+ if (!telegramNotificationsEnabled()) return null
17
24
  if (config) return config
18
25
  if (!COS_SCRIPTS_DIR) return null
19
26
 
@@ -0,0 +1,26 @@
1
+ // Constant-time API-token comparison for the public server.
2
+
3
+ import { createHash, timingSafeEqual } from 'node:crypto'
4
+
5
+ /**
6
+ * Compare a client-supplied API token against the expected token without
7
+ * leaking token bytes through response timing.
8
+ *
9
+ * A plain `provided !== expected` string compare short-circuits at the first
10
+ * differing byte, so an attacker measuring response latency can recover the
11
+ * token one byte at a time. Hashing both sides to a fixed 32-byte SHA-256
12
+ * digest before `timingSafeEqual` keeps the comparison constant time
13
+ * regardless of input length, and avoids `timingSafeEqual` throwing on
14
+ * length-mismatched buffers.
15
+ *
16
+ * Non-string input (missing header, duplicated header parsed as an array) and
17
+ * an empty expected token never match.
18
+ */
19
+ export function timingSafeTokenEqual(provided: unknown, expected: string): boolean {
20
+ if (typeof provided !== 'string' || typeof expected !== 'string' || expected.length === 0) {
21
+ return false
22
+ }
23
+ const a = createHash('sha256').update(provided).digest()
24
+ const b = createHash('sha256').update(expected).digest()
25
+ return timingSafeEqual(a, b)
26
+ }
@@ -13,6 +13,7 @@ import {
13
13
  } from '../lib/codex-model-catalog.js'
14
14
  import { tryInstantResponse } from '../lib/response-cache.js'
15
15
  import crypto from 'node:crypto'
16
+ import { timingSafeTokenEqual } from '../lib/token-auth.js'
16
17
 
17
18
  export const openaiCompatRouter = Router()
18
19
 
@@ -66,7 +67,7 @@ function validateAuth(req: any, res: any): boolean {
66
67
  }
67
68
 
68
69
  const token = auth.slice(7)
69
- if (token !== cosToken) {
70
+ if (!timingSafeTokenEqual(token, cosToken)) {
70
71
  res.status(401).json({ error: { message: 'Invalid token', type: 'invalid_request_error' } })
71
72
  return false
72
73
  }
@@ -15,11 +15,12 @@
15
15
  // of pretending the saved value is in use.
16
16
 
17
17
  import { Router } from 'express'
18
- import { existsSync, mkdirSync, unlinkSync } from 'node:fs'
18
+ import { existsSync, unlinkSync } from 'node:fs'
19
19
  import { dirname } from 'node:path'
20
20
  import { errMsg } from '../lib/utils.js'
21
- import { atomicWriteFileSync } from '../lib/atomic-fs.js'
21
+ import { durableAtomicWriteFileSync } from '../lib/atomic-fs.js'
22
22
  import { KEY_FILE_PATH, clearCachedKey, getKeyStatus } from '../lib/openai-key.js'
23
+ import { securePrivateDirectory } from '../lib/secure-user-config.js'
23
24
 
24
25
  export const openaiKeyRouter = Router()
25
26
 
@@ -79,9 +80,9 @@ openaiKeyRouter.post('/openai-key/set', async (req, res) => {
79
80
  // Ensure parent dir exists (server/data/ is gitignored but may not exist
80
81
  // on a fresh checkout that's never run a budget write).
81
82
  const parent = dirname(KEY_FILE_PATH)
82
- if (!existsSync(parent)) mkdirSync(parent, { recursive: true })
83
+ securePrivateDirectory(parent)
83
84
 
84
- atomicWriteFileSync(KEY_FILE_PATH, payload)
85
+ durableAtomicWriteFileSync(KEY_FILE_PATH, payload, { mode: 0o600 })
85
86
  clearCachedKey()
86
87
 
87
88
  const status = getKeyStatus()