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

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.

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.
Full feature walkthrough:
docs/user-guide.md.
| Workflow Studio | Local knowledge base |
|---|---|
![]() | ![]() |
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.
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).
| Layer | Choice |
|---|---|
| Shell | Tauri 2 (Rust) — process management + webview only |
| UI | Next.js 14 (static export) + React 18 + Tailwind |
| Runtime | Python + FastAPI + LangGraph (sidecar, bundled with PyInstaller) |
| Storage | plain files (no DB); optional LanceDB for semantic-search vectors |
Architecture and subsystem designs:
docs/architecture.md.
MIT © 2026 ChuanchuanSong
hooks/blast-radius.mjs 104 lines1// 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