Snow Gloves guard: keeps the ERP connector read-only and ids-only, redacts personal data from MCP results, and refuses user-installed mods that spawn processes…

<img src="docs/assets/snowgloves-hero.png" width="100%" alt="Snow Gloves OS — Hand-In-Glove Business Operations Platform" />
Hand-In-Glove Business Operations Platform
<!-- og:image: docs/assets/snowgloves-og.png (upload via repo Settings → Social preview) -->
<!-- readme-gen:start:badges -->
<!-- readme-gen:end:badges -->
<!-- readme-gen:start:tech-stack --> <img src="https://skillicons.dev/icons?i=py,bash,yaml,rust,tauri,github,githubactions&theme=dark" alt="Tech Stack" /> <!-- readme-gen:end:tech-stack -->
<!-- readme-gen:start:social -->
<!-- readme-gen:end:social -->
Run a business as if a senior team were watching it 24/7. Snow Gloves OS is a reusable, tenant-scoped operations platform. It wraps your tools (G-Stack connectors), your knowledge (NVIDIA embeddings), your judgement (an interpretation layer), and your actions (Hermes + Paperclip orchestration), so events don't get dropped, decisions are auditable, and risky actions are gated. It is agent-agnostic: you pick the runtime (Claude Code, Codex, Cursor, OpenCode, Grok, Hermes, OpenClaw, or any other agent), and Snow Gloves renders its skills, MCP servers, and rules into that runtime's own format.
Docs for new users: snow-gloves-wiki — how to use the app and the ecosystem (install, pick a runtime, onboard, modules, a quiet week). Slides, audio, and video live there. The modules dashboard is the catalog, not the human guide.
The Infrastructure cockpit connects the playable Infra Block city to catalog, agent routing, runtime adapters, connector gates, scoped tenant metadata, wing profiles, activity records and acceptance evidence. Run python3 scripts/infra_cockpit.py and npm --prefix apps/infra-block run dev, then open http://127.0.0.1:18760/. The workspace supports source inspection and gate-checked proposal exports. Private instance metadata requires an explicit --data-root; execution and enablement remain outside this read-only interface.
<img src="https://capsule-render.vercel.app/api?type=rect&color=gradient&customColorList=12,13,14&height=2" width="100%" />
<table> <tr> <td width="50%" valign="top">
Seven specialized agents (CEO, CTO, Chief of Staff, Librarian, Interpreter, Dispatcher, Sentinel) work as one operations team, each with IDENTITY · SOUL · TOOLS · SKILLS · HEARTBEAT · MEMORY · EVOLUTION.
</td> <td width="50%" valign="top">
A dedicated Chief of Staff routes four native snowgloves: skills via glob-matched hooks so CEO/CTO stay strategic. inference-sh and Explee are catalog packs, not core. Enable a module and it is callable across agents.
</td> </tr> <tr> <td width="50%" valign="top">
132 catalog cards (skills, MCP servers, plugins, playbooks, connectors) harvested from X, Instagram, Field Theory, founders-kit, and marketingskills — including optional inference-sh and Explee packs. Every card is a pointer with a disposition: add, pointer, hold, or refuse.
</td> <td width="50%" valign="top">
One adapter.yaml per runtime says where skills, MCP config, and rules live and which question tool drives plan mode. The founder picks the runtime; nothing is hard-wired to one agent.
</td> </tr> <tr> <td width="50%" valign="top">
Composio-like scoped connectors (Gmail, Calendar, Drive, Slack, PMS, Accounting, HRIS). Per-capability risk + approval flags. Webhooks signed and verified.
</td> <td width="50%" valign="top">
Tenant-isolated vector index (nv-embedqa-e5-v5, 1024-dim). Plug into NVIDIA NIM with NVIDIA_API_KEY, or run offline with the deterministic stub backend.
</td> </tr> <tr> <td width="50%" valign="top">
Minimal HTTP listener on port 4100 (scripts/hermes.py). POST /publish, GET /events, POST /test/e2e for end-to-end smoke. Every event is append-logged to _audit/hermes-events.jsonl.
</td> <td width="50%" valign="top">
Daily aggregation of hook usage, fallbacks, escalations, and top skills per agent. Auto-appended to each agent's EVOLUTION.md for self-improving loops.
</td> </tr> </table>
<img src="https://capsule-render.vercel.app/api?type=rect&color=gradient&customColorList=12,13,14&height=2" width="100%" />
git clone https://github.com/Sheshiyer/snow-gloves-os.git
cd snow-gloves-os
./scripts/install.sh # deps + print next steps (does not go live)
make doctor # pre-flight
make smoke # bus up, test event, stub embed, Sentinel
make walk # machine-check CoS graph + native inner loops
make graph-upgrade # learning-edge dry-run (constraints; hooks gated)
make onboard-prompt R=claude # print the plan-mode interview for your runtime
make test # pytest
Snow Gloves runs on three office Mac minis by function (marketing, design, coding) over Tailscale. The Coding Mac hosts OmniRoute :20128 and Hermes :4100 for the whole fleet. A brand (tenant) is enabled on a wing (node), and make fleet-render W=<wing> T=<tenant> R=<runtime> writes that brand's modules and the wing's MCPs into the runtime's own files. Start with docs/fleet/README.md; make fleet-doctor tells a mini whether it is wired as its wing says.
New Mac Mini (use the product, do not compile Tauri): docs/MAC-MINI-SETUP.md. Emulate the desktop wizard on a dev Mac with make app-install then make app-dev — apps/onboarding/README.md. The fleet inventory is fleet.yaml in your private data checkout (template: fleet.example.yaml), and each wing's machine profile lives in nodes/ (nodes/<wing>/node.yaml; templates here, the instance copy in the data checkout wins).
This repository is the platform. Instance data (brand tenants, the fleet inventory and wing profiles, receipts, decisions, planning state) lives in a separate private checkout, snow-gloves-ops. Point the scripts at it:
export SNOWGLOVES_DATA=/path/to/snow-gloves-ops # tenants/, fleet.yaml, nodes/, _audit/
Unset, everything resolves to this checkout and its public fixtures (_demo, acme, tryambakam-noesis), which the local validation commands use; make smoke and make walk always use the fixtures. The resolver is scripts/lib/paths.py; scripts/onboard.py, node_profile.py, graph_upgrade.py and upgrade.py also take --data DIR. Never commit tenants, fleet.yaml, receipts or planning state here.
Planned fresh-node CLI onboarding: layered setup plan and Claude start prompt. This covers an always-on Mac mini workspace, developer tooling, Temperance/OmniRoute, secrets, and selected brand capabilities. It is a planning candidate; the node bootstrap CLI is not implemented yet.
| Target | Purpose |
|---|---|
make install | Bootstrap (paperclipai + python deps) |
make doctor | Pre-flight diagnostic |
make onboard | Interactive tenant + sources prompt (legacy flow) |
make onboard-prompt R=<runtime> | Print the plan-mode interview prompt for a runtime |
make catalog / make catalog-check | Rebuild catalog/modules.json + registry.yaml / fail if stale |
make legacy-check | Fail if pre-Hermes bus names remain (skips .bak-*) |
make site | Build the static dashboard site (npm run build:site) |
make hermes | Foreground Hermes listener on :4100 |
make smoke | Hermes → e2e → bridge dry-run → embed stub → sentinel |
make walk | Route fixtures through CoS; native produce/check/correct; receipt JSON |
make tui | Onboarding TUI (agent vs manual; --headless for agents) |
make graph-upgrade [T=<slug>] [WRITE=1] | Learning edge dry-run (or apply constraints; hook diffs need approval) |
make embed T=<tenant> | Run NVIDIA embed worker (QUIET=1 for cron) |
make sentinel | Daily drift sweep |
make release-dry V=x.y.z | Preview a platform version bump |
make release V=x.y.z | Bump every version file, rebuild catalog, commit, tag (no push) |
make release-push V=x.y.z | Push HEAD + tag; signing and publication require explicit manual steps |
make release-check | Verify all version files agree with VERSION |
make upgrade [T=<slug>] [WRITE=1] | Dry-run (or apply) tenant platform migrations |
make kill-hermes | Free port 4100 |
make fleet-doctor [W=<wing>] / fleet-render W= T= R= / fleet-enable W= T= / fleet-connect W= / fleet-kit-export / fleet-remote-access W= | Three-mini fleet: wing diagnostics, brand x wing x runtime render, enable, Tailscale shell, gateway kit, remote-access plan (docs/fleet/README.md) |
make app-install / app-dev / app-build | Tauri onboarding app: npm install, tauri dev (GUI), release bundle |
<img src="https://capsule-render.vercel.app/api?type=rect&color=gradient&customColorList=12,13,14&height=2" width="100%" />
<!-- readme-gen:start:architecture -->
graph TD
subgraph Runtime layer
Ext[3rd-party app] -- signed webhook --> GS[G-Stack /webhook]
GS -- normalized envelope --> Her[Hermes :4100]
Her --> CoS[Chief of Staff<br/>skill-hooks.yaml]
CoS -->|strategy| CEO
CoS -->|technical| CTO
CoS --> Lib[Librarian]
CoS --> Int[Interpreter]
CoS --> Dis[Dispatcher]
CoS --> Sen[Sentinel]
Dis --> PB[Paperclip Bridge<br/>:3100/api/tasks]
Lib --> EW[NVIDIA Embed Worker]
EW --> VI[(tenant vector-index.jsonl)]
Her -- audit --> Log[(_audit/hermes-events.jsonl)]
Log --> SS[Sentinel Sweep<br/>daily]
SS --> Evo[(agents/*/EVOLUTION.md)]
end
subgraph Catalog and adapter layer
Cards[catalog/cards/*.md] --> BC[build_catalog.py]
Agents[agents/*/MANIFEST.yaml] --> BC
Ad[adapters/*/adapter.yaml] --> BC
BC --> MJ[(catalog/modules.json)]
MJ --> Prompt[onboard.py --prompt runtime]
Prompt --> Harvest[snowgloves-harvest.md]
Harvest --> Apply[onboard.py --apply-harvest]
Apply --> En[(tenants/slug/enabled.yaml<br/>runtime.yaml)]
En --> Render[onboard.py --render-adapter]
Ad --> Render
Render --> RT[Runtime files:<br/>skills, MCP config, rules block]
MJ --> Dash[Tauri dashboard + Pages site]
En --> Gate[connector-gate]
end
| Engine | Owns | Files |
|---|---|---|
| Connector | G-Stack fabric, scopes, webhooks | connectors/g-stack/, skills/connector-gate/ |
| Knowledge | Ingest, chunk, embed, retrieve | scripts/ingest.py, scripts/embed_worker.py |
| Interpretation | Skill routing, escalation | workflows/skill-hooks.yaml, agents/chief-of-staff/ |
| Orchestration | Event bus + Paperclip bridge | scripts/hermes.py, scripts/paperclip_bridge.py |
More detail: docs/architecture-overview.md.
<!-- readme-gen:end:architecture -->
catalog/cards/<id>.md holds one card per third-party skill, MCP server, plugin, connector, or playbook. A card is a pointer, never a copy of upstream code. scripts/build_catalog.py compiles the cards, the agent manifests, the adapters, and the G-Stack connectors into catalog/registry.yaml and catalog/modules.json (schema snowgloves.modules.v1). Run make catalog-check locally to reject stale generated output.
| Disposition | Count | Meaning |
|---|---|---|
add | 61 | Tenant can enable it |
pointer | 45 | Tenant can enable it as a reference; the host already provides it |
hold | 14 | Visible, not enableable until the founder picks it and it passes review |
refuse | 12 | Visible, never enableable |
By category: skills 79, playbook 36, plugin 13, mcp 3, connector 1. Optional packs inference-sh-agent-skills and explee-skills are add cards, not core hooks. Card schema: docs/catalog.md. Harvest notes: docs/research/2026-09-29-ecosystem-review.md.
Snow Gloves does not assume an agent. Each runtime has adapters/<runtime>/adapter.yaml (schema snowgloves.adapter.v1): skill, MCP, and rules paths; file formats; the runtime's question tool; and how to enter plan mode. Shipped adapters: hermes, claude, codex, cursor, opencode, grok, openclaw, muse, generic. Fields nobody has confirmed against the real runtime are marked verify: true and reported on every render. The Muse adapter is a guess throughout.
Onboarding is a plan-mode interview run inside the project the founder has been building:
make onboard-prompt R=cursor # 1. interview → snowgloves-harvest.md
python3 scripts/onboard.py --apply-harvest snowgloves-harvest.md --tenant acme # 2. apply
python3 scripts/onboard.py --list --category skills # 3. adjust
python3 scripts/onboard.py --enable ms-copywriting --tenant acme
python3 scripts/onboard.py --render-adapter cursor --tenant acme # 4. render (dry run)
python3 scripts/onboard.py --render-adapter cursor --tenant acme --write
The agent asks one decision at a time, takes options only from modules.json, and writes FILL: instead of guessing. See docs/onboarding.md and docs/adapters.md.
The native Tauri v2 app in apps/onboarding/ gains a Modules dashboard and a guided onboarding flow. Both read the same catalog/modules.json plus each tenant's enabled.yaml. The same data is published as a static site, built with make site (npm run build:site) previously published by GitHub Pages. Automatic publishing is retired; this URL retains its last published content until a separate local publisher is selected:
https://sheshiyer.github.io/snow-gloves-os/
make app-install # one-time: npm install
make app-dev # launch in dev mode (hot-reload frontend)
make app-build # produce a signable release bundle
make site # build the static Pages site locally
See its README for the IPC surface and capability list.
The root VERSION file (currently 0.2.1) is the platform version. The app's package.json, tauri.conf.json, Cargo.toml, both lockfiles, and distribution.yaml are kept in lockstep with it, and catalog/modules.json embeds it.
make release-dry V=0.2.1 # show every file that would change
make release V=0.2.1 # bump, rebuild catalog, finalize CHANGELOG, commit, tag v0.2.1
make release-push V=0.2.1 # push only; no automatic signing or publication
make upgrade # dry-run tenant migrations to VERSION
make upgrade T=acme WRITE=1 # apply, rebuild catalog, re-render acme's adapters
The retired hosted release built signed Tauri installers and attached source, catalog, adapter, and checksum assets. Those publication jobs are no longer active. make app-build remains the local app build entry point; signing, cross-platform builds, updater latest.json, and publication require a separately reviewed local procedure. v0.2.0 is a reinstall for 0.1.x (new bundle id). See docs/RELEASING.md and docs/UPGRADING.md.
<!-- readme-gen:start:tree -->
📦 snow-gloves-os
├── 📄 VERSION # platform version (0.2.1)
├── 📄 distribution.yaml # what the platform owns vs what tenants own
├── 📂 .local-jobs/ # explicit local checks; schedules disabled
├── 📂 .specify/ # Spec-Kit templates + workflows
├── 📂 .planning/ # GSD state: local symlink into the private data checkout (gitignored)
├── 📂 agents/ # 7 agents, each with 8+ md files + MANIFEST
├── 📂 adapters/ # one adapter.yaml per runtime (9)
├── 📂 catalog/ # SCHEMA.md · cards/ (132) · registry.yaml · modules.json (generated)
├── 📂 prompts/ # onboard-interview.md (plan-mode interview template)
├── 📂 migrations/ # v0_1_to_v0_2.py (bus rename to Hermes)
├── 📂 apps/onboarding/ # Tauri v2 app: onboarding + Modules dashboard, Pages site
├── 📂 connectors/g-stack/ # Capability registry + auth + webhooks
├── 📂 scripts/ # hermes · ingest · embed · bridge · sentinel · build_catalog · onboard · release · upgrade
├── 📂 skills/ # registry.yaml (4 native skills) · connector-gate · sg-onboard · tn-seed · gtm-brief-synthesis
├── 📂 workflows/ # skill-hooks.yaml — Chief of Staff graph
├── 📂 specs/ # Spec-Kit features
├── 📂 docs/ # architecture, catalog, adapters, onboarding, releasing, upgrading, research, runbooks
├── 📂 tenants/ # Fixture tenants only; real tenants live in $SNOWGLOVES_DATA/tenants
├── 📂 nodes/ # Wing profile templates (marketing, design, coding)
├── 📄 fleet.example.yaml # Fleet inventory template (the real fleet.yaml is private)
├── 📂 tests/ # pytest suite
├── 📂 _audit/ # Append-only event log
├── 📄 config/snowgloves.yaml # Runtime config (ports, embeddings, scopes)
└── 📄 Makefile # make smoke runs the whole loop
<!-- readme-gen:end:tree -->
<img src="https://capsule-render.vercel.app/api?type=rect&color=gradient&customColorList=12,13,14&height=2" width="100%" />
<!-- readme-gen:start:health -->
| Category | Status | Score |
|---|---|---|
| Spec coverage | ████████████████████ | 100% |
| Agents wired | ████████████████████ | 100% |
| End-to-end smoke | ████████████████████ | 100% |
Local validation (pytest, version + catalog, make walk, smoke; execution is opt-in) | ████████████████████ | 100% |
Catalog (132 cards, --check in CI) | ████████████████████ | 100% |
Runtime adapters (9 shipped; several fields still verify: true) | ██████████░░░░░░░░░░ | 50% |
| Release + upgrade tooling (v0.2.1 stable) | ████████████████░░░░ | 80% |
| Real NVIDIA NIM integration | ████░░░░░░░░░░░░░░░░ | 20% |
| Live Paperclip wiring | ████░░░░░░░░░░░░░░░░ | 20% |
| Production hardening | ████░░░░░░░░░░░░░░░░ | 20% |
Overall: 70% — Stable v0.2.1 platform. Before a live pilot: confirm unverified adapter fields, and wire real connectors, NIM, and Paperclip.
<!-- readme-gen:end:health -->
com.tryambakam.snowgloves.onboarding (was com.thoughtseed.snowgloves.onboarding). v0.2.0 is a new app: existing 0.1.x users must reinstall from the DMG/MSI; OTA will not find 0.2.0.distribution.yaml lacks hermes_requires. Nothing declares which Hermes version or port a distribution needs yet. Do not set it in this release./plan, are unconfirmed. See docs/adapters.md.This repo uses GitHub Spec-Kit. Available slash commands (in Copilot/Codex chat):
/speckit.constitution /speckit.specify /speckit.clarify
/speckit.plan /speckit.tasks /speckit.analyze
/speckit.checklist /speckit.implement /speckit.taskstoissues
Active feature: specs/001-hand-in-glove-platform.
7 principles govern every change:
See .specify/memory/constitution.md.
<!-- readme-gen:start:footer -->
<img src="https://capsule-render.vercel.app/api?type=waving&color=gradient&customColorList=12,13,14&height=110§ion=footer" width="100%" />
Built with ❤️ by Mage Narayan · Tryambakam Noesis
<!-- readme-gen:end:footer -->
hooks/register.ts 98 lines1// sg-guard: three guards that stay in force whatever the model or another mod does.
2// 1. The ERP connector may only run execute_read_only_query, one bounded ids-only SELECT. A call is
3// refused with { deny } or passed on with next(e); this mod never answers one, so the settings
4// PreToolUse hook (erp-read-only.py) still runs after it.
5// 2. Results of MCP tools lose emails, phone numbers, card numbers and secrets before the model reads them.
6// 3. plugin.register refuses user-tier mods that spawn or fetch without a catalog card. Claude Code only
7// asks this hook about mods that load after it, so it is effective when sg-guard is in prependPlugins.
8import type { EngineInterface, PluginOptions, Register } from 'claude-code'
9
10import { READ_TOOL, erpProblem, redact, supplyProblem } from './lib'
11
12const CARDS_TIMEOUT_MS = 15_000
13
14let addCards: ReadonlySet<string> = new Set()
15let blocked = 0
16let redacted = 0
17const refused: string[] = []
18
19async function loadCards($: EngineInterface, options: PluginOptions) {
20 try {
21 const root = String(options.platformRoot || '') || $.plugin.root.split('/').slice(0, -2).join('/')
22 const venv = root + '/.venv/bin/python'
23 const python = (await $.fs.exists(venv)) ? venv : 'python3'
24 const run = await $.process.run([python, root + '/scripts/sg_mods.py', 'cards-add'], { cwd: root, timeoutMs: CARDS_TIMEOUT_MS })
25 const names = JSON.parse(run.stdout)
26 if (Array.isArray(names)) addCards = new Set(names.map(String))
27 } catch {
28 // keep the last list; with none, every risky user mod is refused
29 }
30}
31
32// register sets these from the plugin's options
33let serverId = ''
34let redactResults = true
35let supplyChain = true
36let pluginOptions: PluginOptions = {}
37
38async function erpGuard($: EngineInterface, e, next) {
39 const problem = erpProblem(e.tool, serverId, e.query)
40 if (problem) {
41 blocked += 1
42 return { deny: 'ERP read-only guard: ' + problem }
43 }
44 return next(e)
45}
46
47async function redactor($: EngineInterface, e, next) {
48 const res = await next(e)
49 if (!redactResults || !res || res.deny) return res
50 const tally = { n: 0 }
51 const clean = { ...res, result: redact(res.result, tally), text: redact(res.text, tally) }
52 redacted += tally.n
53 return tally.n ? clean : res
54}
55
56function statusText() {
57 return [
58 'sg-guard',
59 ' ERP server ' + (serverId || '(unset)') + ': only ' + READ_TOOL + ' passes; ' + blocked + ' call(s) refused this session',
60 ' redaction ' + (redactResults ? 'on' : 'off') + ': ' + redacted + ' value(s) replaced',
61 ' supply chain ' + (supplyChain ? 'on' : 'off') + ' (' + addCards.size + ' add cards): refused ' + (refused.join(', ') || 'none'),
62 ' Mods only reach plugin.register when sg-guard is listed in prependPlugins (managed settings, or user settings on a machine with none).',
63 ].join('\n')
64}
65
66export const register: Register = (on, options) => {
67 pluginOptions = options
68 serverId = String(options.erpServerId ?? '')
69 redactResults = options.redactResults !== false
70 supplyChain = options.supplyChain !== false
71
72 on('session.start', async ($, e, next) => {
73 await $.command.register({ name: 'sg-guard', description: 'Show what the Snow Gloves guard has refused this session', immediate: true })
74 if (supplyChain) await loadCards($, pluginOptions)
75 return next(e)
76 })
77
78 // Fails closed for the ERP server only: a failure on someone else's server lets it go on
79 on('tool.call', { tool: /^mcp__/ }, erpGuard).catch(($, e, next) => {
80 if (next.called) return next(e)
81 return serverId && e.tool.startsWith('mcp__' + serverId + '__') ? { deny: 'ERP read-only guard could not check this call, refusing' } : next(e)
82 })
83
84 on('tool.call', { tool: /^mcp__/ }, redactor).catch(($, e, next) => (next.called ? next(e) : { deny: 'sg-guard redaction could not run, refusing' }))
85
86 on('plugin.register', ($, e, next) => {
87 if (!supplyChain || e.name === 'sg-guard') return next(e)
88 const why = supplyProblem(e.name, e.provenance, e.uses, addCards)
89 if (why) {
90 refused.push(e.name)
91 return { refuse: 'sg-guard: ' + why }
92 }
93 return next(e)
94 })
95
96 on('command.run', { command: 'sg-guard' }, () => ({ text: statusText() }))
97}
98hooks/lib.ts 93 lines1// Pure rules for sg-guard. No $ in this file, so the tests and the Python parity check call it directly.
2// The ERP rules port scripts/lib/erp_reader.py validate_query and scripts/hooks/erp-read-only.py;
3// the redaction ports scripts/lib/redact.py. tests/redact-fixtures.json holds the cases both languages must agree on.
4
5export const READ_TOOL = 'execute_read_only_query'
6export const HARD_CAP = 100
7export const NEVER_TABLES = ['TM_MAK_Mcp_Api_Key', 'TR_BAC_Bank_Account']
8export const NEVER_COLUMNS = [
9 'cli_bank_iban', 'cli_bank_bic', 'cli_bank_name', 'cli_bank_account_holder', 'cli_bank_address', 'cli_email',
10 'cli_accounting_email', 'cli_newsletter_email', 'cli_tel1', 'cli_tel2', 'cli_cellphone', 'cli_fax', 'cli_address1',
11 'cli_address2', 'cli_first_name', 'cli_last_name', 'cli_notes', 'cli_comment_for_client', 'cli_comment_for_interne',
12 'cli_credit_limit', 'cli_discount',
13]
14const FORBIDDEN = [
15 'insert', 'update', 'delete', 'drop', 'alter', 'create', 'truncate', 'grant', 'revoke', 'replace', 'merge', 'call', 'exec',
16 'execute', 'pragma', 'attach', 'detach', 'set', 'lock', 'into', 'outfile', 'dumpfile', 'load', 'handler', 'show', 'use',
17]
18
19const mask = (sql: string) => sql.replace(/'(?:[^']|'')*'/g, "''")
20const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
21
22// Why a query may not run, or null. Mirrors validate_query, then the hook's table, column and SELECT * rules.
23export function queryProblem(sql: unknown, tables = NEVER_TABLES, columns = NEVER_COLUMNS): string | null {
24 if (typeof sql !== 'string') return 'execute_read_only_query needs a query string'
25 let masked = mask(sql.trim())
26 if (!masked) return 'Empty query'
27 if (masked.endsWith(';')) masked = masked.slice(0, -1).trimEnd()
28 if (masked.includes(';')) return 'Only a single statement is allowed'
29 if (masked.includes('--') || masked.includes('/*') || masked.includes('*/')) return 'Comments are not allowed'
30 if (!/^\s*(select|with)\b/i.test(masked)) return 'Only SELECT queries are allowed'
31 const lowered = masked.toLowerCase()
32 for (const word of FORBIDDEN) if (new RegExp('\\b' + word + '\\b').test(lowered)) return 'Keyword not allowed: ' + word
33 if (/\bfor\s+(update|share)\b/.test(lowered)) return 'Locking reads are not allowed'
34 for (const m of lowered.matchAll(/\blimit\s+(\d+)(?:\s*,\s*(\d+))?/g)) {
35 if (Number(m[2] ?? m[1]) > HARD_CAP) return 'LIMIT exceeds the ' + HARD_CAP + '-row cap'
36 }
37 for (const t of tables) if (new RegExp('\\b' + escape(t.toLowerCase()) + '\\b').test(lowered)) return 'table ' + t + ' must never be touched'
38 for (const c of columns) {
39 if (new RegExp('\\b' + escape(c.toLowerCase()) + '\\b').test(lowered)) return 'column ' + c + ' is personal or bank data and is not approved'
40 }
41 if (/select\s+(distinct\s+)?\*/.test(lowered) || /,\s*\*\s*(from|,)/.test(lowered) || /\.\*/.test(lowered)) {
42 return 'SELECT * is not allowed; name the columns (ids only)'
43 }
44 return null
45}
46
47// The guard's whole decision for one tool name: null passes the call on, a string refuses it.
48export function erpProblem(tool: string, serverId: string, query: unknown): string | null {
49 const prefix = 'mcp__' + serverId + '__'
50 if (!serverId || !tool.startsWith(prefix)) return null
51 const name = tool.slice(prefix.length)
52 if (name !== READ_TOOL) return name + ' is not permitted. This ERP is read-only; only ' + READ_TOOL + ' may be used.'
53 return queryProblem(query)
54}
55
56// Python's \w, \d and \b are Unicode-aware; the classes below match them.
57const W = '[\\p{L}\\p{N}_]'
58const D = '\\p{Nd}'
59const EMAIL = new RegExp(`[${W.slice(1, -1)}.\\-+]+@[${W.slice(1, -1)}.\\-]+\\.[A-Za-z]{2,}`, 'gu')
60const CARD = new RegExp(`(?<![\\p{L}\\p{N}_])(?:${D}[ -]*?){13,19}(?![\\p{L}\\p{N}_])`, 'gu')
61const PHONE = new RegExp(`(?:\\+?${D}[${D}\\s\\-().]{7,}${D})`, 'gu')
62const TOKEN = new RegExp(`(?:bearer|token|secret|api[_-]?key)["':=\\s]+([${W.slice(1, -1)}\\-.]{16,})`, 'giu')
63
64export function redactText(s: string): string {
65 let out = s.replace(EMAIL, '[email]').replace(CARD, '[card]').replace(PHONE, '[phone]')
66 out = out.replace(TOKEN, (whole, secret: string) => whole.split(secret).join('[secret]'))
67 return out
68}
69
70// A fresh copy with every string redacted; counts what changed.
71export function redact<T>(value: T, tally: { n: number } = { n: 0 }): T {
72 if (typeof value === 'string') {
73 const next = redactText(value)
74 if (next !== value) tally.n += 1
75 return next as unknown as T
76 }
77 if (Array.isArray(value)) return value.map((x) => redact(x, tally)) as unknown as T
78 if (value && typeof value === 'object') {
79 return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redact(v, tally)])) as unknown as T
80 }
81 return value
82}
83
84export type Uses = { events: readonly string[]; calls: readonly string[] }
85// Why a user-tier mod may not load, or null. A mod that spawns processes or fetches needs a catalog card with disposition add.
86export function supplyProblem(name: string, provenance: string, uses: Uses, cards: ReadonlySet<string>): string | null {
87 if (provenance.endsWith('@snowgloves-mods')) return null
88 const risky = uses.calls.filter((c) => c === 'process.spawn' || c === 'http.fetch' || c === 'fs.write')
89 if (!risky.length) return null
90 if (cards.has(name)) return null
91 return name + ' calls ' + risky.map((c) => '$.' + c).join(', ') + ' and has no add card in the Snow Gloves catalog'
92}
93types/index.d.ts 10 lines1declare module 'claude-code' {
2 interface PluginState {
3 'sg-guard': {
4 blocked: number
5 redacted: number
6 refused: string[]
7 }
8 }
9}
10