SLOPSHOPPER

blast-radius

Guards dangerous shell commands: deny list + confirm-via-ask + a band switch. Ginno P1 acceptance sample (mirrors the official Blast Radius scenario).

newbandguardtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · blast-radius
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(rm -rf build && git push --force origin main) ⎿ Denied by blast-radius: Blast Radius: denied by user ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ◍ Blast Radius[normal] denied 1 · allowed 0 [ tighten ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◍ Blast Radius[normal] denied 1 · allowed 0 [ tighten ]
README

Ginno

A local-first personal AI agent that runs on your own machine.

The shape of Claude Code, all-file storage (no DB · no account · no cloud), and a visual Workflow Studio.

License: MIT Platform-black.svg) No cloud

English · 简体中文

Ginno chat workspace


What it is

Ginno turns the capabilities an AI agent should have into a complete desktop app, instead of scattering them across a terminal and a dozen browser tabs.

  • 🧠 Multi-agent chat — switch agents mid-conversation, shared history, per-agent memory
  • 🛠️ Tools · permissions · skills · MCP · hooks — Claude Code's shape, all built in
  • 🔀 Visual Workflow Studio — a versioned DSL you compile into a LangGraph state graph, with a live run observer
  • 📚 Local knowledge base — index your Obsidian vault; answers come back with citations
  • 🎯 Long-horizon goals + TODO sync — let the agent keep working toward a standing objective
  • 🌐 Embedded browser + web search — retrieval with source attribution, not vibes

Full feature walkthrough: docs/user-guide.md.

Workflow StudioLocal knowledge base
Workflow StudioKnowledge base

Why "local-first"

One design rule: your data should not leave your machine.

Ginno
Database❌ none — all state is JSON / JSONL / Markdown under ~/.ginno/
Account❌ none — open it and go
Cloud sync❌ none — everything stays local
Portable✅ copy the folder to move it; readable, hand-editable, git-able

You bring your own model key (OpenAI / Anthropic / DeepSeek, or any compatible endpoint). Ginno is just a client — it never takes custody of your key or your data.


Install

Option A — download (macOS, Apple Silicon): grab the latest .dmg from Releases. It is not code-signed yet, so on first launch use right-click → Open to get past Gatekeeper.

Option B — build from source:

git clone https://github.com/robscc/ginno
cd ginno

pnpm install                       # web + desktop shell
cd packages/runtime && uv sync     # Python runtime
cd ../..

pnpm dev                           # web + runtime + desktop shell, all at once

Requires: Node ≥ 20 · pnpm ≥ 9 · Python ≥ 3.11 (via uv) · Rust (Tauri).

Package a .app: make app → apps/desktop/target/release/bundle/dmg/Ginno_*.dmg (see docs/p3-packaging-notes.md).


Stack

LayerChoice
ShellTauri 2 (Rust) — process management + webview only
UINext.js 14 (static export) + React 18 + Tailwind
RuntimePython + FastAPI + LangGraph (sidecar, bundled with PyInstaller)
Storageplain files (no DB); optional LanceDB for semantic-search vectors

Architecture and subsystem designs: docs/architecture.md.


Status

  • A personal project, used daily (dogfooding) and iterated fast — ~2.5 months, 220+ commits.
  • Early and unpolished; APIs and UI still move.
  • Issues, PRs, and ideas are welcome — in English or Chinese.

License

MIT © 2026 ChuanchuanSong

Source 1 files
hooks/blast-radius.mjs 104 lines
1// Blast Radius — Ginno P1 acceptance mod.
2// Guards dangerous shell commands with three layers, mirroring the official
3// Blast Radius scenario (tool.call interception + band button + $.ui.ask):
4//   1. HIGH-RISK commands (rm -rf /, fork bombs, curl|sh, raw disk writes) → denied outright.
5//   2. MEDIUM-RISK commands (package installs, force pushes, recursive rm) →
6//      strict mode: denied; normal mode: $.ui.ask the user.
7//   3. Band: live counters + a Button toggling strict mode (press → state → redraw).
8//
9// Event shape: the runner hands hooks the payload itself as `e` — for
10// tool.call that is { tool, args } (NOT { payload: { tool, args } }).
11
12const STATE = { plugin: "blast-radius", key: "core" };
13
14const HIGH = [
15  /rm\s+(-[a-zA-Z]*[rf][a-zA-Z]*\s+)*\/(\s|$)/,   // rm -rf /
16  /rm\s+(-[a-zA-Z]*[rf][a-zA-Z]*\s+)*~/,          // rm -rf ~
17  /:\(\)\s*\{.*\};\s*:/,                          // fork bomb
18  /curl[^|]*\|\s*(ba)?sh/,                        // curl | sh
19  /wget[^|]*\|\s*(ba)?sh/,
20  /mkfs\./,                                       // filesystem format
21  /dd\s+if=.*of=\/dev\//,                         // raw disk write
22];
23
24const MEDIUM = [
25  /\b(brew|apt|apt-get|npm|pnpm|yarn|pip3?|uv|cargo|gem)\s+(install|add|addGroup)\b/,
26  /git\s+push\s+(--force|-f)/,
27  /rm\s+-[a-zA-Z]*r[a-zA-Z]*/,                    // recursive rm on some path
28  /chmod\s+-R\s+777/,
29  />\s*\/etc\//,                                  // writing into /etc
30];
31
32const DEFAULTS = { strict: false, denied: 0, allowed: 0 };
33
34async function loadCore($) {
35  const { value } = await $.state.get(STATE);
36  return { ...DEFAULTS, ...(value ?? {}) };
37}
38
39function classify(command) {
40  const s = String(command ?? "");
41  if (HIGH.some((re) => re.test(s))) return "high";
42  if (MEDIUM.some((re) => re.test(s))) return "medium";
43  return "ok";
44}
45
46export function register(on) {
47  // Guard Bash tool calls (provider casing varies: "Bash"/"bash").
48  // Pass-through is `next(e)`; deny/ask per the P1 contract.
49  on("tool.call", { tool: ["Bash", "bash"] }, async ($, e, next) => {
50    const command = e?.args?.command ?? e?.input?.command ?? e?.command ?? "";
51    const level = classify(command);
52    if (level === "ok") return next(e);
53    const core = await loadCore($);
54
55    if (level === "high") {
56      await $.state.set(STATE, { ...core, denied: core.denied + 1 });
57      await $.ui.toast(`Blast Radius: 高危命令已拦截 — ${String(command).slice(0, 80)}`);
58      return { deny: "Blast Radius: high-risk command denied outright" };
59    }
60
61    // medium risk
62    if (core.strict) {
63      await $.state.set(STATE, { ...core, denied: core.denied + 1 });
64      await $.ui.toast(`Blast Radius: strict 模式拦截 — ${String(command).slice(0, 80)}`);
65      return { deny: "Blast Radius: denied by strict mode" };
66    }
67
68    const answer = await $.ui.ask(`Blast Radius:允许执行?\n${String(command).slice(0, 120)}`, {
69      choices: ["allow", "deny"],
70    });
71    if (String(answer) === "allow") {
72      await $.state.set(STATE, { ...core, allowed: core.allowed + 1 });
73      return next(e);
74    }
75    await $.state.set(STATE, { ...core, denied: core.denied + 1 });
76    return { deny: "Blast Radius: denied by user" };
77  });
78
79  // Band: mode + counters + the strict toggle.
80  on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
81    if (e.props?.hasSurvey) return next(e);
82    const core = await loadCore($);
83    const { Box, Text, Button } = $.ui.resolve(e);
84    const mode = core.strict ? "STRICT" : "normal";
85    return Box({
86      flexDirection: "row",
87      paddingX: 1,
88      gap: 1,
89      children: [
90        Text({ color: core.strict ? "red" : "green", bold: true, children: `◍ Blast Radius[${mode}]` }),
91        Text({ dimColor: true, children: `denied ${core.denied} · allowed ${core.allowed}` }),
92        Button({
93          label: core.strict ? "loosen" : "tighten",
94          onPress: async () => {
95            const cur = await loadCore($);
96            await $.state.set(STATE, { ...cur, strict: !cur.strict });
97            await $.ui.toast(`Blast Radius: strict = ${!cur.strict}`);
98          },
99        }),
100      ],
101    });
102  });
103}
104