SLOPSHOPPER

nas-rails

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

newbandguardcommandtoastprompt
A shopper browsing a rack in a slop shop
README

centralized-nas-container-management

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

Homepage Dashboard

Stacks

DirectoryPurposeLocal PortExternal (Synology Reverse Proxy)
homepage/Dashboard UI (gethomepage/homepage)3000 (Nginx + HTTPS inside)https://…:443
jellyfin/Media server with NVIDIA GPU transcoding8096https://…:18096
maid-tracker/Household worker attendance & salary tracker5055https://…:15055
portainer/Docker management UI9000https://…:19000
uptime-kuma/Service health monitor3001https://…:13001
watchtower/Auto-update containers + LINE notification sidecar——
secretary/Personal knowledge base — Notion→Qdrant RAG5065 (query)https://…:15065
n8n/Shared workflow automation5678https://…: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 alerts5059https://…:15059
news-feed/AI & IT news feed bot — Thai summaries via Claude/DeepSeek, digest to LINE + Telegram, dashboard5064https://…:15064
ink-reader/Doujin library + OPDS catalog for Meebook M8 — scrapes doujin-th.com, converts to CBZ, curation dashboard5068https://…:15068
dupe-sweeper/Duplicate video finder/deleter dashboard by media code5069 (Nginx basic-auth dashboard)— (LAN only)
friendly-reminder/Monthly installment payment tracker — LINE reminders + webhook slip auto-match5066https://…:15066
ops-bot/AI incident-response bot — Uptime Kuma webhook, SSH diagnostics, Telegram fixes, fix-as-PR5070https://…:15070
shorts-factory/Telegram bot that renders Thai YouTube Shorts — read-only dashboard over the clip record5071 (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

Reverse Proxy Summary

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.

StackSynology RP Source→ Destination
homepagehttps://…:443http://localhost:3000 → internal Nginx → Homepage
jellyfinhttps://…:18096http://localhost:8096
maid-trackerhttps://…:15055http://localhost:5055 → internal Nginx → Maid Tracker
portainerhttps://…:19000http://localhost:9000
uptime-kumahttps://…:13001http://localhost:3001
secretary-queryhttps://…:15065http://localhost:5065
n8nhttps://…:15678http://localhost:5678 (moved 2026-09-08 from an n8n.<NAS_HOST> subdomain on :443)
torrentwatchhttps://…:15059http://localhost:5059
Hermeshttps://…:15063http://localhost:5063
news-feedhttps://…:15064http://localhost:5064
ink-readerhttps://…:15068http://localhost:5068
friendly-reminderhttps://…:15066http://localhost:5066
ops-bothttps://…:15070http://localhost:5070
shorts-factoryhttps://…:15071http://localhost:5071
lotto-pickerhttps://…:15073http://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 :443 to port 3000 unencrypted and lets Nginx take over from there.

Environment Variables

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 secrets and deploy.sh.

Setup

# 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

Daily workflow

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 check validates manifests against both vault.sops.yaml and test-vault.sops.yaml. A key that exists only in the real vault fails check with manifest references missing vault path .... After make edit-vault, run make sync-test-vault — it regenerates test-vault.sops.yaml from the real vault's key tree with dummy test-<path> values (its own age recipient), keeping structure in sync.

File map

.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.

Uploading to NAS

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)

  1. Generate an SSH key and copy it to the NAS: ``bash ssh-keygen -t ed25519 -C "nas-key" ssh-copy-id -i ~/.ssh/id_ed25519.pub -p 2222 <NAS_USER>@<NAS_HOST> ``
  2. In DSM → Control Panel → Terminal & SNMP, set SSH port to 2222 and disable password auth in /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_PASSWORD in the root .env is only used to run sudo docker compose during stack restarts. SSH itself uses key auth exclusively. Compose finds each stack's .env automatically via --project-directory.

Adding a Stack to Container Manager (DSM 7.4)

After uploading files to the NAS via deploy.sh, register each stack in Synology Container Manager:

  1. Open DSM → Container Manager → Project tab
  2. Click Create
  3. Fill in:
  4. Project Name — e.g. homepage
  5. Path — point to the stack directory on NAS, e.g. /volume2/docker/homepage Container Manager will automatically detect docker-compose.yml in that path
  6. Click Next → review the compose config → click Build
  7. Container Manager pulls images and starts the containers

For stacks with a local build (e.g. watchtower, maid-tracker), Container Manager will build the image on first run. On subsequent updates, use deploy.sh with the restart option instead of re-registering the project.

Architecture Notes

  • Homepage — sits behind an Nginx reverse proxy that handles HTTPS (port 3000 on host → 443 inside container) with Authelia SSO forward-auth. TLS uses the Synology system certificate mounted from /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.
  • External HTTPS — all stacks except homepage use Synology Reverse Proxy (DSM → Control Panel → Login Portal → Advanced) for HTTPS termination. Synology handles the SSL cert and auto-renewal; containers run plain HTTP internally.
  • DSM / Download Station widgets — Homepage connects to the Synology API over HTTP (HOMEPAGE_VAR_NAS_URL=http://192.168.x.x:5000) to avoid SSL certificate mismatch when using an IP address.
  • Maid Tracker — FastAPI + SQLite single-container app. Database persisted in a named volume 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.
  • Watchtower — runs two services: the updater and a Python sidecar that tails Watchtower logs via raw Docker socket HTTP and pushes LINE notifications. The sidecar is excluded from auto-updates via com.centurylinklabs.watchtower.enable=false.
  • Portainer — standard CE deployment on port 9000. HTTPS is handled upstream by Synology Reverse Proxy. Data persisted in portainer_data named volume.
  • TorrentWatch — FastAPI + Python 3.12 daily torrent monitor that scrapes bearbit.org on a schedule, filters today's uploads by seed/leech threshold and per-source keywords, and surfaces results via a mobile-first dark web UI. Supports multiple listing URLs (each with its own keyword list), cover images, file size/count display, and two download modes: proxy .torrent to browser or save directly to a NAS watch folder. LINE push notifications on keyword matches. Auto scrape schedule configurable (30 min / 1 hour, night window or all day). Data older than 7 days is cleaned up weekly. Port 5059 internally; external HTTPS via Synology Reverse Proxy on port 15059.
  • secretary — Personal knowledge base stack: Notion pages ingested into Qdrant via BGE-M3 hybrid embeddings (dense 1024d + sparse), served as a RAG API (FastAPI :5065), orchestrated by n8n Telegram bot workflows (see the 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.
  • n8n — Shared workflow automation (:5678), split out of 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 — Single-container FastAPI + APScheduler + SQLite stack. Fetches RSS from 7 sources (TechCrunch AI, VentureBeat, The Verge, Ars Technica, GSMArena, 9to5Mac, Android Authority), summarises articles in Thai via Anthropic Claude (default) or OpenRouter/DeepSeek (switchable at runtime via dashboard). Sends digest to LINE + Telegram at configurable times (default 07:00/12:00/18:00 Bangkok). Dashboard at port 5064 provides Source Health, News Timeline, AI Price Tracker, Leaderboard, Digest History, and Schedule Config. Data persisted in news_feed_data named volume. No external reverse proxy — LAN access only.

AI Deck coding workspaces

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.

Product video workspace

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.

Source 3 files
hooks/index.tsx 445 lines
1import { 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}
445
hooks/rules.ts 460 lines
1// 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}
460
types.d.ts 17 lines
1export 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