Repo rails for centralized-nas-container-management: guards, notes/deploy/shared-drift band, commit secret scan

Docker stacks for Synology DS925+ NAS, managed via Synology Container Manager.

| Directory | Purpose | Local Port | External (Synology Reverse Proxy) |
|---|---|---|---|
homepage/ | Dashboard UI (gethomepage/homepage) | 3000 (Nginx + HTTPS inside) | https://…:443 |
jellyfin/ | Media server with NVIDIA GPU transcoding | 8096 | https://…:18096 |
maid-tracker/ | Household worker attendance & salary tracker | 5055 | https://…:15055 |
portainer/ | Docker management UI | 9000 | https://…:19000 |
uptime-kuma/ | Service health monitor | 3001 | https://…:13001 |
watchtower/ | Auto-update containers + LINE notification sidecar | — | — |
secretary/ | Personal knowledge base — Notion→Qdrant RAG | 5065 (query) | https://…:15065 |
n8n/ | Shared workflow automation | 5678 | https://…:15678 |
hermes-agent/ | Autonomous AI agent — Telegram + Discord (NousResearch/hermes-agent) | 5063 (Nginx basic-auth dashboard) | https://…:15063 |
torrentwatch/ | Daily torrent monitor for bearbit.org — scrapes, filters, LINE alerts | 5059 | https://…:15059 |
news-feed/ | AI & IT news feed bot — Thai summaries via Claude/DeepSeek, digest to LINE + Telegram, dashboard | 5064 | https://…:15064 |
ink-reader/ | Doujin library + OPDS catalog for Meebook M8 — scrapes doujin-th.com, converts to CBZ, curation dashboard | 5068 | https://…:15068 |
dupe-sweeper/ | Duplicate video finder/deleter dashboard by media code | 5069 (Nginx basic-auth dashboard) | — (LAN only) |
friendly-reminder/ | Monthly installment payment tracker — LINE reminders + webhook slip auto-match | 5066 | https://…:15066 |
ops-bot/ | AI incident-response bot — Uptime Kuma webhook, SSH diagnostics, Telegram fixes, fix-as-PR | 5070 | https://…:15070 |
shorts-factory/ | Telegram bot that renders Thai YouTube Shorts — read-only dashboard over the clip record | 5071 (Nginx basic-auth dashboard) | https://…:15071 |
ai-deck/ | Claude Code as a document desk from the phone — web terminal + key bar, pptx/docx/xlsx skills, out/ download drawer, chat view (a second Claude Code over stream-json), sessions sheet (tap to resume), 5h/7d rate-limit chip with reset countdowns, finish notification, workstation status line (STATUSLINE_BAR_W picks the phone layout) | 5072 (Nginx basic-auth) | https://…:15072 |
lotto-picker/ | สุ่มเลข 2 ตัว/3 ตัวแยกกัน ไม่ซ้ำ N งวดล่าสุด (ผลจาก API กองสลาก) — dashboard, เลขอัตโนมัติ + ผลรางวัลเข้า Telegram, ตรวจผลเลขที่บันทึกไว้ | 5073 (Nginx basic-auth) | https://…:15073 |
All stacks except watchtower are exposed externally via Synology Reverse Proxy (DSM → Control Panel → Login Portal → Advanced). Synology handles TLS termination; containers run plain HTTP internally.
| Stack | Synology RP Source | → Destination |
|---|---|---|
| homepage | https://…:443 | http://localhost:3000 → internal Nginx → Homepage |
| jellyfin | https://…:18096 | http://localhost:8096 |
| maid-tracker | https://…:15055 | http://localhost:5055 → internal Nginx → Maid Tracker |
| portainer | https://…:19000 | http://localhost:9000 |
| uptime-kuma | https://…:13001 | http://localhost:3001 |
| secretary-query | https://…:15065 | http://localhost:5065 |
| n8n | https://…:15678 | http://localhost:5678 (moved 2026-09-08 from an n8n.<NAS_HOST> subdomain on :443) |
| torrentwatch | https://…:15059 | http://localhost:5059 |
| Hermes | https://…:15063 | http://localhost:5063 |
| news-feed | https://…:15064 | http://localhost:5064 |
| ink-reader | https://…:15068 | http://localhost:5068 |
| friendly-reminder | https://…:15066 | http://localhost:5066 |
| ops-bot | https://…:15070 | http://localhost:5070 |
| shorts-factory | https://…:15071 | http://localhost:5071 |
| lotto-picker | https://…:15073 | http://localhost:5073 |
Your router must forward each external port → NAS so traffic reaches Synology Reverse Proxy. Homepage is the only exception — it has its own Nginx inside the container that handles TLS, so Synology RP simply forwards
:443to port3000unencrypted and lets Nginx take over from there.
Secrets live in secrets/vault.sops.yaml — one encrypted YAML (sops+age) committed to git as the single source of truth. Each stack has a secrets.manifest.yaml declaring which vault paths it consumes and the ENV name to project them as. A generator (make secrets) decrypts the vault, applies each manifest, and writes per-stack .env files (gitignored). The NAS never sees sops or age.
Full docs: secrets/README.md — architecture diagrams, step-by-step guides for adding/removing/rotating secrets, new machine setup, and troubleshooting.
Moving to a new workstation? Follow secrets/NEW_MACHINE.md — checklist for git push,
make secretsanddeploy.sh.
# 1. Install tooling
brew install sops age
# 2. Import your age private key (one-time per machine)
mkdir -p ~/.config/sops/age
# Generate new and ask vault maintainer to add public key + sops updatekeys:
age-keygen -o ~/.config/sops/age/keys.txt
# OR paste an existing key (e.g. from Bitwarden / 1Password):
$EDITOR ~/.config/sops/age/keys.txt
chmod 600 ~/.config/sops/age/keys.txt
# 3. Generate .env files
make secrets # decrypts vault, writes <stack>/.env and .env.deploy
# 4. Deploy
./scripts/deploy.sh
make edit-vault # open vault in $EDITOR (sops decrypts on read, re-encrypts on save)
make sync-test-vault # after adding/removing a key: mirror structure into test-vault
make secrets # regenerate all <stack>/.env + .env.deploy
./scripts/deploy.sh # tar + ssh + restart
Keeping the desk's agents current is its own one-liner, because nothing inside that container can update itself (npm globals owned by root, container runs as uid 1000, and a self-update would be discarded by the next deploy):
make desk-latest ARGS=-n # what is behind: Claude Code, MiMoCode, office skills
make desk-latest # rewrite the pins, then deploy ai-deck
Adding a new secret?
make checkvalidates manifests against bothvault.sops.yamlandtest-vault.sops.yaml. A key that exists only in the real vault fails check withmanifest references missing vault path .... Aftermake edit-vault, runmake sync-test-vault— it regeneratestest-vault.sops.yamlfrom the real vault's key tree with dummytest-<path>values (its own age recipient), keeping structure in sync.
.sops.yaml # public age recipients (commit)
secrets/vault.sops.yaml # encrypted vault (commit)
secrets/test-vault.sops.yaml # CI dummy vault, mirrors real vault structure (commit; regen: make sync-test-vault)
secrets/manifest.schema.json # manifest JSON schema
deploy.manifest.yaml # produces .env.deploy for deploy.sh
<stack>/secrets.manifest.yaml # per-stack vault → ENV mapping
.env.deploy # generated, gitignored
<stack>/.env # generated, gitignored
All .env files and secrets/vault.yaml (transient plaintext during edits) are gitignored. Only vault.sops.yaml and manifests are tracked.
scripts/deploy.sh syncs the project from your local machine to $NAS_TARGET_PATH (default /volume2/docker) over SSH (key-based auth). Per-stack .env files are uploaded inside the project tarball; the root .env is intentionally excluded (it holds local-only deploy/script secrets that containers never need).
Prerequisites (one-time setup)
bash ssh-keygen -t ed25519 -C "nas-key" ssh-copy-id -i ~/.ssh/id_ed25519.pub -p 2222 <NAS_USER>@<NAS_HOST> ``/etc/ssh/sshd_config (PasswordAuthentication no).Deploy
# 1. Make sure all .env files are filled in (root + each stack)
# 2. Run
scripts/deploy.sh
NAS_SUDO_PASSWORDin the root.envis only used to runsudo docker composeduring stack restarts. SSH itself uses key auth exclusively. Compose finds each stack's.envautomatically via--project-directory.
After uploading files to the NAS via deploy.sh, register each stack in Synology Container Manager:
homepage/volume2/docker/homepage Container Manager will automatically detect docker-compose.yml in that pathFor stacks with a local build (e.g.
watchtower,maid-tracker), Container Manager will build the image on first run. On subsequent updates, usedeploy.shwith the restart option instead of re-registering the project.
/usr/syno/etc/certificate/system/default/. Config files in homepage/config/ are hot-reloaded. Secrets are injected via HOMEPAGE_VAR_* env vars from homepage/.env and referenced in services.yaml as {{HOMEPAGE_VAR_*}}. DSM / Download Station widgets authenticate with HOMEPAGE_VAR_NAS_USERNAME / HOMEPAGE_VAR_NAS_PASSWORD — use a dedicated DSM user (no 2FA) and whitelist private subnets in DSM → Security → Protection to avoid auto-blocking the Docker container IP.HOMEPAGE_VAR_NAS_URL=http://192.168.x.x:5000) to avoid SSL certificate mismatch when using an IP address.maid_tracker_data. Local build. The container runs on port 5055 internally; external access is via Synology Reverse Proxy on port 15055 (https://<NAS_HOST>:15055), which handles TLS termination — the container itself serves plain HTTP.com.centurylinklabs.watchtower.enable=false.portainer_data named volume.n8n stack). Ingest is a one-shot container (restart: "no") run manually or triggered via POST /ingest-trigger. Hybrid search uses RRF fusion; optional Cohere rerank-multilingual-v3.0 improves cross-lingual (Thai query / English content) accuracy. LLM provider switchable via LLM_PROVIDER: anthropic (default), openrouter, or nous (OAuth Device Code). BGE-M3 model (~2GB) cached in hf_cache volume shared between ingest and query containers.secretary/ so any stack can use it. Workflows reach other stacks over the host's published ports (http://host.docker.internal:<port>) via extra_hosts: host-gateway, not a shared docker network. Data volume binds /volume2/docker/n8n/n8n_data, moved out of secretary/ during the split (a rename within volume2 — atomic, no copy). It holds the credential encryption key, so the directory must be verified non-empty before the container is started. Workflow JSON is git-tracked in n8n/workflows/ (scripts/n8n_export.sh / n8n_import.sh). N8N_WEBHOOK_URL must match the Synology Reverse Proxy entry — n8n registers its webhook with Telegram only when a workflow is activated, so after that URL changes, every workflow with a webhook trigger has to be deactivated and reactivated. Skip it and the provider keeps POSTing to the old address with no error surfacing on either side.news_feed_data named volume. No external reverse proxy — LAN access only.AI Deck can clone GitHub repositories into persistent task branches via its optional coding Compose profile. Code runs in a separate worker; deployment runs on a trusted Mac/VM with its own credentials. See coding workflow and runner setup. New private runner settings are vault-managed under stacks.ai_desk.deploy_runner.*; no new NAS public port is required. The Mac runner uses private HTTPS port 8792.
shorts-factory Creator Studio includes a Shop Flow Studio workspace for manual Google Flow scene imports and mixed-video product clips. It uses dedicated private API/worker services on the existing authenticated port5071. Configure vault-managed SHOP_AUTOMATION_TOKEN and optional external-origin SHOP_PUBLIC_URL, then bind the inactive n8n workflow. See Shop usage.
hooks/index.tsx 445 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Rails } from '../types'
5import {
6 afterWrite,
7 ALLOW_MARK,
8 bashDeny,
9 commitScan,
10 notesRows,
11 pathDeny,
12 stackOf,
13 scanDiff,
14 statusPaths,
15 deployRefs,
16 undeployed,
17 stacksMentioned,
18 notesContext,
19 secretsFromEnv,
20 redact,
21 nasRisk,
22} from './rules'
23import type { Secret } from './rules'
24
25type $ = EngineInterface
26
27const rails = atom({ plugin: 'nas-rails', key: 'rails' } as const, null)
28const closing = atom({ plugin: 'nas-rails', key: 'closing' } as const, false)
29const isHidden = atom({ plugin: 'nas-rails', key: 'isHidden' } as const, false)
30const loadedNotes = atom({ plugin: 'nas-rails', key: 'loadedNotes' } as const, [])
31
32const REFRESH_MS = 20_000
33const base = (p: string) => p.slice(p.lastIndexOf('/') + 1)
34
35// Caches of this environment; a reload starts them over.
36let root: string | null | undefined
37let started = false
38let vendored: Set<string> | null = null
39const touched = new Set<string>()
40let lastDrift = ''
41let lastRails = ''
42let stackSet = new Set<string>()
43let secrets: Secret[] = []
44
45async function repoRoot($: $): Promise<string | null> {
46 if (root !== undefined) return root
47 const r = await $.session.root()
48 const isRepo = (await $.fs.exists(`${r}/scripts/deploy.sh`)) && (await $.fs.exists(`${r}/secrets/vault.sops.yaml`))
49 root = isRepo ? r : null
50 return root
51}
52
53const relOf = (r: string, path: string) => (path.startsWith(`${r}/`) ? path.slice(r.length + 1) : null)
54
55const git = ($: $, r: string, args: string[]) => $.process.run(['git', ...args], { cwd: r, timeoutMs: 10_000 })
56
57async function vendoredCopies($: $, r: string): Promise<Set<string>> {
58 if (vendored) return vendored
59 const names = new Set(
60 (await $.fs.list(`${r}/shared`))
61 .filter(f => f.kind === 'file' && f.name.endsWith('.py') && !f.name.startsWith('test_'))
62 .map(f => f.name),
63 )
64 const files = (await git($, r, ['ls-files'])).stdout.split('\n')
65 vendored = new Set(files.filter(f => f && !f.startsWith('shared/') && names.has(base(f))))
66 return vendored
67}
68
69async function stacksOf($: $, r: string): Promise<Set<string>> {
70 const out = new Set<string>()
71 for (const d of await $.fs.list(r)) {
72 if (d.kind === 'dir' && !d.name.startsWith('.') && (await $.fs.exists(`${r}/${d.name}/docker-compose.yml`))) out.add(d.name)
73 }
74 return out
75}
76
77async function deployRecords($: $, r: string): Promise<Map<string, string>> {
78 return deployRefs((await git($, r, ['for-each-ref', 'refs/deploy', '--format=%(refname:lstrip=2) %(objectname)'])).stdout)
79}
80
81/** What deploy.sh uploads: the worktree (`git stash create`), or HEAD when clean. */
82async function worktreeSha($: $, r: string): Promise<string> {
83 const stash = (await git($, r, ['stash', 'create'])).stdout.trim()
84 return stash || (await git($, r, ['rev-parse', 'HEAD'])).stdout.trim()
85}
86
87/** Secret-named values of every rendered .env (gitignored, so `ls-files -oi`). */
88async function loadSecrets($: $, r: string) {
89 const list = await git($, r, ['ls-files', '-oi', '--exclude-standard', '--', ':(glob)**/.env', ':(glob)**/.env.deploy'])
90 const found: Secret[] = []
91 for (const f of list.stdout.split('\n').filter(Boolean)) {
92 try {
93 found.push(...secretsFromEnv(await $.fs.read(`${r}/${f}`)))
94 } catch {
95 // vanished between list and read
96 }
97 }
98 // Longest first, so a value containing another is replaced whole.
99 secrets = [...new Map(found.map(x => [x.value, x])).values()].sort((a, b) => b.value.length - a.value.length)
100}
101
102/** Adds each stack's 00_INDEX.md not yet in this conversation; returns the context blocks. */
103async function notesFor($: $, r: string, wanted: string[]): Promise<string[]> {
104 const have = await read($, loadedNotes)
105 const fresh = wanted.filter(s => !have.includes(s))
106 const out: string[] = []
107 for (const s of fresh) {
108 try {
109 out.push(notesContext(s, await $.fs.read(`${r}/${s}/.notes/00_INDEX.md`)))
110 } catch {
111 // stack without notes
112 }
113 }
114 if (fresh.length) await update($, loadedNotes, list => [...list, ...fresh])
115 return out
116}
117
118/** Asks the person; a dismissed dialog or a session with nobody to ask is a no. */
119async function confirm($: $, question: string): Promise<boolean> {
120 try {
121 return (await $.ui.ask(question, { options: ['Run it', 'Cancel'], header: 'NAS' })) === 'Run it'
122 } catch {
123 return false
124 }
125}
126
127async function n8nKeyState($: $): Promise<'present' | 'missing' | 'unknown'> {
128 try {
129 const ran = await $.process.run(
130 ['ssh', '-o', 'ConnectTimeout=8', '-o', 'BatchMode=yes', 'nas', 'sudo -n /usr/local/bin/docker exec n8n sh -c "test -s /home/node/.n8n/config && echo present || echo missing"'],
131 { timeoutMs: 20_000 },
132 )
133 const word = ran.stdout.trim()
134 return word === 'present' || word === 'missing' ? word : 'unknown'
135 } catch {
136 return 'unknown'
137 }
138}
139
140async function refresh($: $): Promise<Rails | null> {
141 const r = await repoRoot($)
142 if (!r) return null
143 vendored = null
144 const stacks = await stacksOf($, r)
145 stackSet = stacks
146 await loadSecrets($, r)
147 const status = await git($, r, ['status', '--porcelain', '-z', '--untracked-files=all'])
148 const notes = notesRows([...statusPaths(status.stdout), ...touched], stacks)
149
150 const records = await deployRecords($, r)
151 const changed = new Map<string, string[] | null>()
152 for (const sha of new Set(records.values())) {
153 const d = await git($, r, ['diff', '--name-only', sha, 'HEAD'])
154 changed.set(sha, d.exitCode === 0 ? d.stdout.split('\n').filter(Boolean) : null)
155 }
156 const und = undeployed(records, changed).filter(([s]) => stacks.has(s))
157
158 const drift: Rails['drift'] = []
159 for (const dst of await vendoredCopies($, r)) {
160 const src = `shared/${base(dst)}`
161 try {
162 if ((await $.fs.read(`${r}/${src}`)) !== (await $.fs.read(`${r}/${dst}`))) drift.push({ src, dst })
163 } catch {
164 // a copy deleted in the worktree is git's business, not drift
165 }
166 }
167 const driftKey = drift.map(d => d.dst).join(',')
168 if (driftKey && driftKey !== lastDrift) {
169 $.ui.toast(`shared drift: ${drift.map(d => d.dst).join(', ')} — run make sync-shared`, { timeoutMs: 8000 })
170 }
171 lastDrift = driftKey
172
173 const next: Rails = { notes, undeployed: und, unrecorded: [...stacks].filter(s => !records.has(s)).length, drift }
174 // The timer ticks every 20s; writing an unchanged value would redraw the band each time.
175 const key = JSON.stringify(next)
176 if (key !== lastRails) {
177 lastRails = key
178 await update($, rails, () => next)
179 }
180 return next
181}
182
183function ensureStarted($: $) {
184 if (started) return
185 started = true
186 void $.command.register({ name: 'deployed', description: 'nas-rails: mark stacks as deployed at HEAD (args: stack,stack | all)' })
187 void $.command.register({ name: 'rails', description: 'nas-rails: refresh and show the notes / deploy / shared-drift band' })
188 $.clock.every(REFRESH_MS, () => void refresh($).catch(() => {}))
189 void refresh($).catch(() => {})
190}
191
192async function readHook($: $, e: any, next: (e: any) => Promise<any>) {
193 const r = await repoRoot($)
194 const rel = r ? relOf(r, String(e.file_path ?? '')) : null
195 const ran = await next(e)
196 if (!r || rel === null || ran.deny !== undefined || ran.isError) return ran
197 const stack = stackOf(rel, stackSet)
198 if (!stack) return ran
199 if (rel === `${stack}/.notes/00_INDEX.md`) {
200 await update($, loadedNotes, list => (list.includes(stack) ? list : [...list, stack]))
201 return ran
202 }
203 const ctx = await notesFor($, r, [stack])
204 return ctx.length ? { ...ran, context: [...(ran.context ?? []), ...ctx] } : ran
205}
206
207const pendingNotes = (s: Rails) => s.notes.filter(n => !n.log || !n.index)
208const noteLine = (n: Rails['notes'][number]) => `${n.stack} ${n.log ? '✓' : '✗'}log ${n.index ? '✓' : '✗'}index`
209
210// A guard that throws must not lock the session out of Bash or Edit: these rails fail open.
211const failOpen = <E,>(_: $, e: E, next: (e: E) => any) => next(e)
212
213export const register: Register = on => {
214 on('session.start', async ($, e, next) => {
215 const r = await repoRoot($)
216 if (r) ensureStarted($)
217 return next(e)
218 })
219
220 // ── guards ──────────────────────────────────────────────────────────────────
221
222 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
223 if (!(await repoRoot($))) return next(e)
224 const risk = nasRisk(e.command)
225 if (risk.danger.length) {
226 const yes = await confirm($, `Run a destructive NAS command (${risk.danger.join(', ')})?\n\n${e.command.slice(0, 600)}`)
227 if (!yes) return { deny: `nas-rails: the person declined (or could not be asked about) a destructive NAS command: ${risk.danger.join(', ')}.` }
228 }
229 if (risk.startsN8n) {
230 const key = await n8nKeyState($)
231 if (key === 'missing') {
232 return {
233 deny:
234 'nas-rails: n8n has no encryption key in /home/node/.n8n/config. Starting it now generates a new key and every stored credential becomes undecryptable for good. Restore n8n_data first (see n8n/.notes/00_INDEX.md).',
235 }
236 }
237 if (key === 'unknown' && !(await confirm($, 'Could not verify n8n\'s encryption key (container down or SSH failed). Start n8n anyway?'))) {
238 return { deny: 'nas-rails: n8n start refused: its encryption key could not be verified and the person did not confirm.' }
239 }
240 }
241 const ran = await next(e)
242 if (!risk.composeDirect || ran.deny !== undefined) return ran
243 return {
244 ...ran,
245 context: [
246 ...(ran.context ?? []),
247 'nas-rails: compose ran straight on the NAS, so no ops-bot maintenance window was written: expect Uptime Kuma DOWN alerts for these containers. scripts/deploy.sh -s <stack> -y writes the window.',
248 ],
249 }
250 }).catch(($, e, next) => {
251 // Fail closed only for the commands this guard exists for; every other Bash call goes on.
252 if (next.called) return next(e)
253 let risky = true
254 try {
255 const r = nasRisk(e.command)
256 risky = r.danger.length > 0 || r.startsN8n
257 } catch {}
258 return risky ? { deny: 'nas-rails: the NAS command guard failed, so this destructive/n8n command is refused. Check `claude --debug`.' } : next(e)
259 })
260
261 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
262 if (!(await repoRoot($))) return next(e)
263 const why = bashDeny(e.command)
264 return why ? { deny: `nas-rails: ${why}` } : next(e)
265 }).catch(failOpen)
266
267 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
268 const scan = commitScan(e.command)
269 const r = await repoRoot($)
270 if (!scan || !r) return next(e)
271 let diff = (await git($, r, ['diff', '--cached', '-U0', '--no-color'])).stdout
272 if (scan.includeWorktree) diff += '\n' + (await git($, r, ['diff', '-U0', '--no-color'])).stdout
273 const found = scanDiff(diff)
274 if (!found.length) return next(e)
275 const list = found.slice(0, 15).map(f => ` ${f.file}:${f.line} ${f.rule} ${f.hint}`).join('\n')
276 const more = found.length > 15 ? `\n …and ${found.length - 15} more` : ''
277 return {
278 deny:
279 `nas-rails: commit refused, ${found.length} possible secret(s) in the added lines:\n${list}${more}\n` +
280 'Move real values to the vault (`make edit-vault` + secrets.manifest.yaml) or use a placeholder like <NAS_HOST>. ' +
281 `If a line is a deliberate false positive, add a \`${ALLOW_MARK}\` comment on it.`,
282 }
283 }).catch(($, e, next) =>
284 next.called || !commitScan(e.command) ? next(e) : { deny: 'nas-rails: the commit secret scan failed to run, so the commit is refused. Run /rails or check `claude --debug`.' },
285 )
286
287 on('tool.call', async ($, e, next) => {
288 const tool = String(e.tool)
289 if (tool === 'Read') return readHook($, e, next)
290 if (tool !== 'Edit' && tool !== 'Write' && tool !== 'NotebookEdit') return next(e)
291 const r = await repoRoot($)
292 if (!r) return next(e)
293 const input = e as unknown as { file_path?: string; notebook_path?: string; content?: string; new_string?: string }
294 const rel = relOf(r, input.file_path ?? input.notebook_path ?? '')
295 const why = pathDeny({ rel, vendored: await vendoredCopies($, r) })
296 if (why) return { deny: `nas-rails: ${why}` }
297
298 const ran = await next(e)
299 if (ran.deny !== undefined || ran.isError || rel === null) return ran
300 touched.add(rel)
301 const notes = afterWrite(rel, input.content ?? input.new_string ?? '')
302 if (notes.length) $.ui.toast(notes[0]!.replace(/^nas-rails: /, ''), { timeoutMs: 8000 })
303 const stack = stackOf(rel, stackSet)
304 const ctx = [...notes, ...(stack ? await notesFor($, r, [stack]) : [])]
305 return ctx.length ? { ...ran, context: [...(ran.context ?? []), ...ctx] } : ran
306 }).catch(failOpen)
307
308 // ── "จบงาน" ────────────────────────────────────────────────────────────────
309
310 on('prompt.submit', async ($, e, next) => {
311 const r = await repoRoot($)
312 if (!r) return next(e)
313 ensureStarted($)
314 if (!stackSet.size) stackSet = await stacksOf($, r)
315 const named = await notesFor($, r, stacksMentioned(e.text, stackSet))
316 if (named.length) e = { ...e, context: [...(e.context ?? []), ...named] }
317 if (!e.text.includes('จบงาน')) return next(e)
318 await update($, closing, () => true)
319 const s = await refresh($)
320 if (!s?.notes.length) return next(e)
321 const ctx =
322 'nas-rails: stacks with work in this tree and the state of their notes ' +
323 `(update both <stack>/.notes/daily_log.md and <stack>/.notes/00_INDEX.md before closing): ${s.notes.map(noteLine).join('; ')}`
324 return next({ ...e, context: [...(e.context ?? []), ctx] })
325 }).catch(failOpen)
326
327 on('turn.complete', async ($, e, next) => {
328 const result = await next(e)
329 if (!(await repoRoot($))) return result
330 ensureStarted($)
331 const s = await refresh($)
332 if (await read($, closing)) {
333 await update($, closing, () => false)
334 const left = s ? pendingNotes(s) : []
335 if (left.length) $.ui.toast(`จบงาน but notes not updated: ${left.map(noteLine).join(' · ')}`, { timeoutMs: 15_000 })
336 }
337 return result
338 })
339
340 // ── secrets out of the conversation ─────────────────────────────────────────
341
342 on('session.append', async ($, e, next) => {
343 if (e.message.type !== 'user' || !(await repoRoot($))) return next(e)
344 let hidden = 0
345 const scrub = (text: string) => {
346 const r = redact(text, secrets)
347 hidden += r.hidden
348 return r.text
349 }
350 const content = e.message.content.map(block => {
351 if (block.type === 'text' && typeof block.text === 'string' && e.door !== 'prompt') return { ...block, text: scrub(block.text) }
352 if (block.type !== 'tool_result') return block
353 const c = block.content
354 if (typeof c === 'string') return { ...block, content: scrub(c) }
355 if (Array.isArray(c)) {
356 return { ...block, content: c.map((x: any) => (x?.type === 'text' && typeof x.text === 'string' ? { ...x, text: scrub(x.text) } : x)) }
357 }
358 return block
359 })
360 if (!hidden) return next(e)
361 $.ui.toast(`nas-rails: hid ${hidden} secret value(s) from a tool result`, { timeoutMs: 5000 })
362 return next({ ...e, message: { ...e.message, content } })
363 }).catch(failOpen)
364
365 on('session.compact', async ($, e, next) => {
366 const result = await next(e)
367 // The summary replaces the notes that were in context; let them load again.
368 await update($, loadedNotes, () => [])
369 return result
370 })
371
372 // ── commands ────────────────────────────────────────────────────────────────
373
374 on('command.run', { command: 'deployed' }, async ($, e) => {
375 const r = await repoRoot($)
376 if (!r) return { text: 'nas-rails: not in the NAS repo.' }
377 const stacks = await stacksOf($, r)
378 const want = e.args.trim() === 'all' ? [...stacks] : e.args.split(/[\s,]+/).filter(Boolean)
379 const unknown = want.filter(s => !stacks.has(s))
380 if (!want.length || unknown.length) {
381 return { text: `usage: /deployed <stack,stack|all>${unknown.length ? ` (unknown: ${unknown.join(', ')})` : ''}` }
382 }
383 const sha = await worktreeSha($, r)
384 for (const s of want) await git($, r, ['update-ref', `refs/deploy/${s}`, sha])
385 await refresh($)
386 return { text: `marked ${want.length} stack(s) as deployed from the current worktree (${sha.slice(0, 7)})` }
387 })
388
389 on('command.run', { command: 'rails' }, async ($, e) => {
390 await update($, isHidden, () => false)
391 const s = await refresh($)
392 if (!s) return { text: 'nas-rails: not in the NAS repo.' }
393 const lines = [
394 `notes: ${s.notes.length ? s.notes.map(noteLine).join(' · ') : 'nothing pending'}`,
395 `undeployed: ${s.undeployed.length ? s.undeployed.map(([k, n]) => (n === null ? `${k} ?` : k)).join(' · ') : 'none'} (${s.unrecorded} stack(s) without a deploy record — /deployed after a deploy)`,
396 `shared drift: ${s.drift.length ? s.drift.map(d => d.dst).join(', ') : 'none'}`,
397 ]
398 return { text: lines.join('\n') }
399 })
400
401 // ── band ────────────────────────────────────────────────────────────────────
402
403 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
404 const s = await read($, rails)
405 if (e.props.hasSurvey || !s || (await read($, isHidden))) return next(e)
406 const und = s.undeployed
407 const notes = pendingNotes(s)
408 if (!notes.length && !und.length && !s.drift.length) return next(e)
409 const { Box, Button, Text } = $.ui.resolve(e)
410 return (
411 <Box flexDirection="column">
412 {notes.length > 0 && (
413 <Text wrap="truncate">
414 <Text dimColor>notes </Text>
415 {notes.map(n => (
416 <Text key={n.stack}>
417 {n.stack} <Text color={n.log ? 'success' : 'warning'}>{n.log ? '✓' : '✗'}log</Text>{' '}
418 <Text color={n.index ? 'success' : 'warning'}>{n.index ? '✓' : '✗'}index</Text>
419 {' '}
420 </Text>
421 ))}
422 </Text>
423 )}
424 {und.length > 0 && (
425 <Text wrap="truncate">
426 <Text dimColor>undeployed </Text>
427 <Text color="warning">{und.map(([k, n]) => (n === null ? `${k} ?` : k)).join(' · ')}</Text>
428 <Text dimColor> changed since last deploy</Text>
429 </Text>
430 )}
431 {s.drift.length > 0 && (
432 <Text wrap="truncate">
433 <Text color="error">shared drift </Text>
434 {s.drift.map(d => d.dst).join(', ')}
435 <Text dimColor> → make sync-shared</Text>
436 </Text>
437 )}
438 <Box>
439 <Button key="hide" plain label="hide (/rails to show)" onPress={() => update($, isHidden, () => true)} />
440 </Box>
441 </Box>
442 )
443 })
444}
445hooks/rules.ts 460 lines1// Pure rules: no `$`, so `claude plugin test` covers them without an engine.
2
3// ── shell parsing ─────────────────────────────────────────────────────────────
4
5// Heredoc bodies are data (commit messages, files being written), never commands.
6const HEREDOC = /<<-?\s*(['"]?)(\w+)\1[^\n]*\n[\s\S]*?\n[ \t]*\2[ \t]*(?=\n|$)/g
7
8/** Splits a shell command into simple commands, each a list of words, quotes honoured. */
9export function segments(command: string): string[][] {
10 const text = command.replace(HEREDOC, m => m.slice(0, m.indexOf('\n')))
11 const out: string[][] = []
12 let words: string[] = []
13 let word = ''
14 let hasWord = false
15 let quote: '"' | "'" | null = null
16 const endWord = () => {
17 if (hasWord) words.push(word)
18 word = ''
19 hasWord = false
20 }
21 const endSegment = () => {
22 endWord()
23 if (words.length) out.push(words)
24 words = []
25 }
26 for (let i = 0; i < text.length; i++) {
27 const c = text[i]
28 if (quote) {
29 if (c === quote) quote = null
30 else if (c === '\\' && quote === '"' && i + 1 < text.length) word += text[++i]
31 else word += c
32 continue
33 }
34 if (c === '"' || c === "'") {
35 quote = c
36 hasWord = true
37 } else if (c === '\\' && i + 1 < text.length) {
38 word += text[++i]
39 hasWord = true
40 } else if (c === ' ' || c === '\t') {
41 endWord()
42 } else if (c === ';' || c === '|' || c === '&' || c === '\n' || c === '(' || c === ')' || c === '`') {
43 endSegment()
44 } else if (c === '$' && text[i + 1] === '(') {
45 endSegment()
46 i++
47 } else {
48 word += c
49 hasWord = true
50 }
51 }
52 endSegment()
53 return out
54}
55
56const WRAPPERS = new Set(['env', 'sudo', 'command', 'time', 'nohup', 'exec', 'xargs'])
57
58/** The program a simple command runs and its arguments, wrappers stripped; `bash -c` recursed. */
59export function programs(command: string, depth = 0): { prog: string; args: string[] }[] {
60 const out: { prog: string; args: string[] }[] = []
61 for (const seg of segments(command)) {
62 let i = 0
63 while (i < seg.length) {
64 const w = seg[i]!
65 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) i++
66 else if (WRAPPERS.has(w)) i++
67 else if (w === 'rtk' && seg[i + 1] === 'proxy') i += 2
68 else if (w === 'rtk') i++
69 else break
70 }
71 if (i >= seg.length) continue
72 let prog = seg[i]!
73 let args = seg.slice(i + 1)
74 if ((prog === 'bash' || prog === 'sh' || prog === 'zsh') && args.length) {
75 const c = args.findIndex(a => /^-[a-z]*c$/.test(a))
76 const inner = c >= 0 ? args[c + 1] : undefined
77 if (inner !== undefined && depth < 3) {
78 out.push(...programs(inner, depth + 1))
79 continue
80 }
81 const script = args.findIndex(a => !a.startsWith('-'))
82 if (script < 0) continue
83 prog = args[script]!
84 args = args.slice(script + 1)
85 }
86 out.push({ prog, args })
87 }
88 return out
89}
90
91const base = (p: string) => p.slice(p.lastIndexOf('/') + 1)
92
93/** `git -C x -c k=v <sub> ...` → `{ sub, args }`. */
94function gitSub(args: string[]): { sub: string; args: string[] } | null {
95 let i = 0
96 while (i < args.length) {
97 const a = args[i]!
98 if (a === '-C' || a === '-c') i += 2
99 else if (a.startsWith('-')) i++
100 else return { sub: a, args: args.slice(i + 1) }
101 }
102 return null
103}
104
105// ── Bash guard ────────────────────────────────────────────────────────────────
106
107const SECRET_FILE = /(^|\/)(\.env|\.env\.deploy|secrets\/vault\.yaml)$/
108const CLAUDE_COAUTHOR = /co-authored-by:[^\n]*\b(claude|anthropic)\b/i
109
110export type CommitScan = { includeWorktree: boolean }
111
112/** A reason to refuse a Bash command, or null. */
113export function bashDeny(command: string): string | null {
114 for (const { prog, args } of programs(command)) {
115 const name = base(prog)
116 if (name === 'rsync') {
117 return 'rsync is banned in this repo: macOS openrsync (protocol 29) and Synology GNU rsync (protocol 31) do not talk. Use `tar | ssh` (see CLAUDE.md).'
118 }
119 if (name === 'deploy.sh') {
120 const ok = args.some(a => a === '-y' || a === '--yes' || a === '-h' || a === '--help' || a === '--restart-only')
121 if (!ok) {
122 return 'deploy.sh without -y: with no TTY the upload prompt is skipped silently and the script still exits 0. Re-run with `-y`.'
123 }
124 }
125 if (name === 'git') {
126 const g = gitSub(args)
127 if (g?.sub === 'add') {
128 const bad = g.args.filter(a => !a.startsWith('-') && SECRET_FILE.test(a))
129 if (bad.length) {
130 return `refusing to stage ${bad.join(', ')}: plaintext secrets stay out of git (only secrets/vault.sops.yaml is committed).`
131 }
132 }
133 if (g?.sub === 'commit' && CLAUDE_COAUTHOR.test(command)) {
134 return 'commit message carries a Claude/Anthropic Co-Authored-By trailer; the user forbids it. Drop the trailer.'
135 }
136 }
137 }
138 return null
139}
140
141/** Whether the command commits, and whether the commit takes worktree changes past the index. */
142export function commitScan(command: string): CommitScan | null {
143 for (const { prog, args } of programs(command)) {
144 if (base(prog) !== 'git') continue
145 const g = gitSub(args)
146 if (g?.sub !== 'commit') continue
147 const VALUED = new Set(['-m', '-F', '-C', '-c', '-t', '--author', '--date', '--fixup', '--squash', '--message', '--file', '--template'])
148 let includeWorktree = false
149 for (let i = 0; i < g.args.length; i++) {
150 const a = g.args[i]!
151 if (VALUED.has(a)) i++
152 else if (a === '--all' || a === '--include' || a === '--only') includeWorktree = true
153 else if (/^-[A-Za-z]+$/.test(a) && a.includes('a')) includeWorktree = true
154 else if (!a.startsWith('-')) includeWorktree = true
155 }
156 return { includeWorktree }
157 }
158 return null
159}
160
161// ── Edit/Write guard ──────────────────────────────────────────────────────────
162
163export type PathFacts = {
164 /** Path relative to the repo root, `/`-separated; null when outside the repo. */
165 rel: string | null
166 /** Tracked vendored copies of shared/*.py, repo-relative. */
167 vendored: ReadonlySet<string>
168}
169
170export const MOD_DIR = '.claude/skills/nas-rails/'
171
172/** A reason to refuse writing this file, or null. */
173export function pathDeny({ rel, vendored }: PathFacts): string | null {
174 if (rel === null || rel.startsWith(MOD_DIR)) return null
175 if (rel.startsWith('.notes/')) {
176 return 'root .notes/ is off limits: write to <stack>/.notes/daily_log.md and <stack>/.notes/00_INDEX.md of the stack you worked in.'
177 }
178 if (/^secrets\/.*\.sops\.yaml$/.test(rel)) {
179 return `${rel} is sops-encrypted: edit it with \`make edit-vault\` (or \`sops set\`), never directly.`
180 }
181 if (vendored.has(rel)) {
182 return `${rel} is a vendored copy of shared/${base(rel)}: edit shared/${base(rel)}, then \`make sync-shared\`.`
183 }
184 return null
185}
186
187/** Reminders the model should read after a write lands, by path and new text. */
188export function afterWrite(rel: string | null, text: string): string[] {
189 if (rel === null) return []
190 const out: string[] = []
191 if (/^shared\/(?!test_)[^/]+\.py$/.test(rel)) {
192 out.push(`nas-rails: ${rel} changed. Run \`make sync-shared\` before testing or committing, or tests/test_shared_sync.py goes red.`)
193 }
194 if (/(^|\/)secrets\.manifest\.yaml$/.test(rel)) {
195 out.push('nas-rails: secrets.manifest.yaml changed. Run `make check`: an `env:` key whose vault path does not exist yet breaks `make secrets` for every stack.')
196 }
197 if (/(^|\/)docker-compose\.ya?ml$/.test(rel) && /^\s*cpus\s*:/m.test(text)) {
198 out.push('nas-rails: `cpus:` is a no-op on DSM (no CFS bandwidth control). Cap CPU inside the workload (e.g. ffmpeg -threads); `mem_limit` works.')
199 }
200 return out
201}
202
203/** The top-level directory a repo path belongs to, if it is a stack. */
204export function stackOf(rel: string, stacks: ReadonlySet<string>): string | null {
205 const top = rel.split('/')[0]!
206 return stacks.has(top) ? top : null
207}
208
209// ── git status → notes ────────────────────────────────────────────────────────
210
211/** Paths `git status --porcelain -z` names (renames give the new path). */
212export function statusPaths(porcelainZ: string): string[] {
213 const parts = porcelainZ.split('\0')
214 const out: string[] = []
215 for (let i = 0; i < parts.length; i++) {
216 const p = parts[i]!
217 if (p.length < 4) continue
218 const xy = p.slice(0, 2)
219 out.push(p.slice(3))
220 if (xy.includes('R') || xy.includes('C')) i++
221 }
222 return out
223}
224
225export type NotesRow = { stack: string; log: boolean; index: boolean }
226
227/** Stacks with work and whether their two notes files have been touched too. */
228export function notesRows(changed: Iterable<string>, stacks: ReadonlySet<string>): NotesRow[] {
229 const work = new Set<string>()
230 const log = new Set<string>()
231 const index = new Set<string>()
232 for (const rel of changed) {
233 const s = stackOf(rel, stacks)
234 if (!s) continue
235 if (rel === `${s}/.notes/daily_log.md`) log.add(s)
236 else if (rel === `${s}/.notes/00_INDEX.md`) index.add(s)
237 else if (!rel.startsWith(`${s}/.notes/`)) work.add(s)
238 }
239 return [...work].sort().map(stack => ({ stack, log: log.has(stack), index: index.has(stack) }))
240}
241
242// ── deploy records ────────────────────────────────────────────────────────────
243
244/** `git for-each-ref refs/deploy --format='%(refname:lstrip=2) %(objectname)'` → stack → sha. */
245export function deployRefs(out: string): Map<string, string> {
246 const m = new Map<string, string>()
247 for (const line of out.split('\n')) {
248 const [stack, sha] = line.trim().split(' ')
249 if (stack && sha) m.set(stack, sha)
250 }
251 return m
252}
253
254/**
255 * Stacks whose files differ between what was deployed and HEAD. `changed` maps
256 * each recorded sha to `git diff --name-only <sha> HEAD`, or null when git
257 * could not read it.
258 */
259export function undeployed(records: ReadonlyMap<string, string>, changed: ReadonlyMap<string, string[] | null>): [string, boolean | null][] {
260 const out: [string, boolean | null][] = []
261 for (const [stack, sha] of [...records].sort()) {
262 const files = changed.get(sha)
263 if (files === null || files === undefined) out.push([stack, null])
264 else if (files.some(f => f.startsWith(`${stack}/`))) out.push([stack, true])
265 }
266 return out
267}
268
269// ── commit secret scan ────────────────────────────────────────────────────────
270
271export type Finding = { file: string; line: number; rule: string; hint: string }
272
273const TOKENS: [string, RegExp][] = [
274 ['anthropic-key', /sk-ant-[A-Za-z0-9_-]{20,}/],
275 ['openrouter-key', /sk-or-v1-[A-Fa-f0-9]{40,}/],
276 ['api-key', /\bsk-[A-Za-z0-9]{32,}\b/],
277 ['github-token', /\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{40,})/],
278 ['telegram-bot-token', /\b\d{8,10}:[A-Za-z0-9_-]{35}\b/],
279 ['aws-key', /\bAKIA[0-9A-Z]{16}\b/],
280 ['slack-token', /\bxox[baprs]-[A-Za-z0-9-]{10,}/],
281 ['google-api-key', /\bAIza[0-9A-Za-z_-]{35}\b/],
282 ['private-key', /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
283 ['age-secret-key', /AGE-SECRET-KEY-1[0-9A-Z]{50,}/],
284]
285
286// A LAN host address; network/broadcast addresses (subnets in docs) and CIDR are fine.
287const LAN_IP = /(?<![\d.])(192\.168\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3})(?![\d.]|\/\d)/g
288
289const ASSIGN = /\b([A-Z][A-Z0-9_]*(?:PASSWORD|PASSWD|SECRET|TOKEN|API_KEY|ACCESS_KEY))(?:=|\s*:\s*)["']?([^\s"'#,;`)\]]+)/
290const PLACEHOLDER = /^(\$|<|\{\{|%|\*|abc)|\.\.\.|…|example|change.?me|placeholder|your[_-]|xxx|dummy|fake|redacted|replace|random|secret_here|^(none|null|true|false)$/i
291// secrets.manifest.yaml maps env keys to vault paths (`stacks.x.y`), not values.
292// Lower-case dotted names are vault paths or code (`settings.token`), not values.
293const VAULT_PATH = /^[a-z_][a-z0-9_-]*(\.[a-z0-9_-]+)+$/
294const SKIP_FILE = /(\.sops\.ya?ml|\.lock|package-lock\.json|\.min\.js|\.svg|\.png|\.jpe?g|\.woff2?)$/
295const TEST_FILE = /(^|\/)(tests?\/|test_[^/]*$|[^/]*_test\.[a-z]+$|[^/]*\.test\.[a-z]+$)/
296
297export const ALLOW_MARK = 'secrets-ok'
298
299const mask = (s: string) => `${s.slice(0, 4)}…(${s.length} chars)`
300
301/** Secrets in the added lines of a unified diff. Values are masked in the result. */
302export function scanDiff(diff: string): Finding[] {
303 const out: Finding[] = []
304 let file = ''
305 let line = 0
306 for (const raw of diff.split('\n')) {
307 if (raw.startsWith('+++ ')) {
308 file = raw.startsWith('+++ b/') ? raw.slice(6) : ''
309 continue
310 }
311 const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)/.exec(raw)
312 if (hunk) {
313 line = Number(hunk[1])
314 continue
315 }
316 if (!file || SKIP_FILE.test(file)) continue
317 if (raw.startsWith('+')) {
318 const text = raw.slice(1)
319 if (!text.includes(ALLOW_MARK)) {
320 for (const [rule, re] of TOKENS) {
321 const m = re.exec(text)
322 if (m) out.push({ file, line, rule, hint: mask(m[0]) })
323 }
324 for (const m of TEST_FILE.test(file) ? [] : text.matchAll(LAN_IP)) {
325 const ip = m[1]!
326 const last = Number(ip.split('.')[3])
327 if (last !== 0 && last !== 255) out.push({ file, line, rule: 'lan-ip', hint: ip.replace(/\.\d+$/, '.x') })
328 }
329 const a = ASSIGN.exec(text)
330 const [, key = '', value = ''] = a ?? []
331 if (a && key !== 'NOPASSWD' && !TEST_FILE.test(file) && value.length >= 8 && !PLACEHOLDER.test(value) && !VAULT_PATH.test(value) && !/[()[\]{}]/.test(value)) {
332 out.push({ file, line, rule: `hardcoded ${key}`, hint: mask(value) })
333 }
334 }
335 line++
336 } else if (!raw.startsWith('-') && !raw.startsWith('\\')) {
337 line++
338 }
339 }
340 return out
341}
342
343// ── stack notes in context ────────────────────────────────────────────────────
344
345/** Stacks named as whole words in a prompt, in the order first named. */
346export function stacksMentioned(text: string, stacks: ReadonlySet<string>): string[] {
347 const out: string[] = []
348 for (const m of text.toLowerCase().matchAll(/[a-z0-9][a-z0-9-]*[a-z0-9]/g)) {
349 if (stacks.has(m[0]) && !out.includes(m[0])) out.push(m[0])
350 }
351 return out
352}
353
354export const NOTES_CAP = 16_000
355
356/** The context block carrying a stack's 00_INDEX.md, its head when long. */
357export function notesContext(stack: string, index: string): string {
358 const path = `${stack}/.notes/00_INDEX.md`
359 const head = index.length > NOTES_CAP ? `${index.slice(0, NOTES_CAP)}\n\n[… ${index.length - NOTES_CAP} more characters: Read ${path} for the rest]` : index
360 return `nas-rails: ${path} (the stack's memory, loaded because this work touches ${stack}):\n\n${head}`
361}
362
363// ── secret redaction ──────────────────────────────────────────────────────────
364
365const SECRET_KEY = /(KEY|TOKEN|SECRET|PASSWORD|PASSWD|PASS|CREDENTIALS?|_B64|COOKIE|HTPASSWD|WEBHOOK_URL)$/
366
367export type Secret = { key: string; value: string }
368
369/** Secret-named values of a dotenv file, long enough to be worth hiding. */
370export function secretsFromEnv(text: string): Secret[] {
371 const out: Secret[] = []
372 for (const line of text.split('\n')) {
373 const m = /^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/.exec(line)
374 if (!m) continue
375 const key = m[1]!
376 let value = m[2]!.trim()
377 if (/^(['"]).*\1$/.test(value)) value = value.slice(1, -1)
378 // A path (NAS_SSH_KEY=~/.ssh/id_ed25519) names a secret, it is not one.
379 if (SECRET_KEY.test(key) && value.length >= 8 && !/^[~/]/.test(value)) out.push({ key, value })
380 }
381 return out
382}
383
384/** Replaces known secret values and token-shaped strings; counts what it hid. */
385export function redact(text: string, secrets: readonly Secret[]): { text: string; hidden: number } {
386 let hidden = 0
387 let out = text
388 for (const { key, value } of secrets) {
389 if (!out.includes(value)) continue
390 const parts = out.split(value)
391 hidden += parts.length - 1
392 out = parts.join(`«SECRET:${key}»`)
393 }
394 for (const [rule, re] of TOKENS) {
395 out = out.replace(new RegExp(re.source, 'g'), () => {
396 hidden++
397 return `«REDACTED:${rule}»`
398 })
399 }
400 return { text: out, hidden }
401}
402
403// ── NAS commands ──────────────────────────────────────────────────────────────
404
405const SSH_VALUED = new Set(['-p', '-i', '-o', '-l', '-F', '-J', '-L', '-R', '-D', '-c', '-m', '-E', '-b', '-e', '-S', '-W', '-O', '-Q', '-B', '-I', '-w'])
406
407/** The remote command of an `ssh` invocation, or null when it opens a shell. */
408function sshRemote(args: string[]): string | null {
409 let i = 0
410 while (i < args.length && args[i]!.startsWith('-')) i += SSH_VALUED.has(args[i]!) ? 2 : 1
411 const rest = args.slice(i + 1)
412 return rest.length ? rest.join(' ') : null
413}
414
415const POWER = new Set(['reboot', 'poweroff', 'shutdown', 'halt'])
416
417const DANGER: [string, RegExp][] = [
418 ['synopkg stop/restart', /\bsynopkg\s+(restart|stop|uninstall)\b/],
419 ['docker prune', /\bdocker\s+(system|volume|image|container|network|builder)\s+prune\b/],
420 ['docker volume rm', /\bdocker\s+volume\s+rm\b/],
421 ['rm -r under /volume', /\brm\s+(-[a-zA-Z]*\s+)*-[a-zA-Z]*r[a-zA-Z]*\s+(\S+\s+)*['"]?\/volume\d/],
422 ['compose down -v', /\bcompose\b[^;&|]*\bdown\b[^;&|]*(\s-v\b|--volumes)/],
423 ['synoshare --del', /\bsynoshare\s+--del\b/],
424 ['disk tools', /\b(mkfs(\.\w+)?|fdisk|parted)\b|\bdd\b[^;|]*\bof=/],
425 ['router network restart', /\b(restart_net_and_phy|restart_wireless|service\s+reboot)\b/],
426]
427
428export type NasRisk = {
429 /** Destructive remote operations: each needs the person's yes. */
430 danger: string[]
431 /** compose up/down/restart run straight on the NAS, past deploy.sh's ops-bot window. */
432 composeDirect: boolean
433 /** The command (re)starts n8n, whose data volume holds the credentials key. */
434 startsN8n: boolean
435}
436
437export function nasRisk(command: string): NasRisk {
438 const risk: NasRisk = { danger: [], composeDirect: false, startsN8n: false }
439 for (const { prog, args } of programs(command)) {
440 const name = base(prog)
441 if (name === 'ssh') {
442 const remote = sshRemote(args)
443 if (!remote) continue
444 // Power commands count only as the program run (not `grep reboot-notifier`).
445 if (programs(remote).some(p => POWER.has(base(p.prog))) && !risk.danger.includes('reboot/shutdown')) risk.danger.push('reboot/shutdown')
446 for (const [label, re] of DANGER) if (re.test(remote) && !risk.danger.includes(label)) risk.danger.push(label)
447 if (/\bcompose\b[^;&|]*\b(up|down|restart|stop)\b/.test(remote)) risk.composeDirect = true
448 if (/\bcompose\b[^;&|]*\bn8n\b[^;&|]*\b(up|restart|start)\b/.test(remote) || /\bdocker\s+(start|restart)\s+n8n\b/.test(remote)) {
449 risk.startsN8n = true
450 }
451 }
452 if (name === 'deploy.sh' && !args.includes('--upload-only')) {
453 const at = args.findIndex(a => a === '-s' || a === '--stacks')
454 const value = at >= 0 ? (args[at + 1] ?? '') : (args.find(a => a.startsWith('--stacks='))?.slice(9) ?? '')
455 if (value.split(',').some(s => s.trim() === 'n8n' || s.trim() === 'all')) risk.startsN8n = true
456 }
457 }
458 return risk
459}
460types.d.ts 17 lines1export type RailsNotes = { stack: string; log: boolean; index: boolean }
2
3export type Rails = {
4 notes: RailsNotes[]
5 /** Stacks whose files changed in HEAD since refs/deploy/<stack>; null = record unreadable. */
6 undeployed: [string, boolean | null][]
7 /** Stacks with no deploy record yet. */
8 unrecorded: number
9 drift: { src: string; dst: string }[]
10}
11
12declare module 'claude-code' {
13 interface PluginState {
14 'nas-rails': { rails: Rails | null; closing: boolean; isHidden: boolean; loadedNotes: string[] }
15 }
16}
17