From 306b6435ae1ded48db1e28bfb096656aa74abed4 Mon Sep 17 00:00:00 2001 From: Goki602 Date: Thu, 5 Mar 2026 08:05:17 +0900 Subject: [PATCH 1/6] feat(adapter-claude): session allowlist for VSCode confirm-to-deny fallback VSCode doesn't support the "ask" permission, so confirm actions are force-denied. This adds a session allowlist so that non-high-risk operations auto-allow on retry within the same session, with a softer denial message guiding users to simply re-instruct Claude. Co-Authored-By: Claude Opus 4.6 --- .../src/__tests__/hook-handler.test.ts | 9 ++++- packages/adapter-claude/src/hook-handler.ts | 11 ++++-- packages/adapter-claude/src/index.ts | 1 + packages/cli/src/engine-factory.ts | 36 +++++++++++++++++- .../src/__tests__/decision-store.test.ts | 38 +++++++++++++++++++ packages/memory/src/decision-store.ts | 29 ++++++++++++++ 6 files changed, 117 insertions(+), 7 deletions(-) diff --git a/packages/adapter-claude/src/__tests__/hook-handler.test.ts b/packages/adapter-claude/src/__tests__/hook-handler.test.ts index 943e640..2c0978f 100644 --- a/packages/adapter-claude/src/__tests__/hook-handler.test.ts +++ b/packages/adapter-claude/src/__tests__/hook-handler.test.ts @@ -128,7 +128,7 @@ describe("buildHookOutput", () => { ); }); - it("returns ask for medium-risk confirm when vsCodeCompat is true", () => { + it("returns deny with soft hint for medium-risk confirm when vsCodeCompat is true", () => { const decision: PolicyDecision = { action: "confirm", risk: "medium", @@ -143,7 +143,12 @@ describe("buildHookOutput", () => { }; const output = buildHookOutput(decision, "ja", true); expect(output).not.toBeNull(); - expect(output?.hookSpecificOutput.permissionDecision).toBe("ask"); + expect(output?.hookSpecificOutput.permissionDecision).toBe("deny"); + expect(output?.hookSpecificOutput.permissionDecisionReason).toContain("パッケージ追加"); + expect(output?.hookSpecificOutput.permissionDecisionReason).toContain("リスクがあるためブロック"); + expect(output?.hookSpecificOutput.permissionDecisionReason).not.toContain( + "claw-guard init --profile expert", + ); }); it("returns ask for high-risk confirm when vsCodeCompat is false", () => { diff --git a/packages/adapter-claude/src/hook-handler.ts b/packages/adapter-claude/src/hook-handler.ts index 500ca2b..76d7faf 100644 --- a/packages/adapter-claude/src/hook-handler.ts +++ b/packages/adapter-claude/src/hook-handler.ts @@ -8,11 +8,16 @@ export function parseHookInput(jsonStr: string): ClaudeHookInput { return JSON.parse(jsonStr) as ClaudeHookInput; } -const POLICY_HINT = { +const POLICY_HINT_HARD = { ja: "\n\n⛔ この操作は高リスクのためブロックされました(その場での許可はできません)。\n許可するには:\n - プリセットを `expert` に変更: `claw-guard init --profile expert`\n - または clawguard.yaml にプロジェクト例外を追加", en: "\n\n⛔ This operation was blocked due to high risk (cannot be approved on the spot).\nTo allow it:\n - Change preset to `expert`: `claw-guard init --profile expert`\n - Or add a project override in clawguard.yaml", } as const; +const POLICY_HINT_SOFT = { + ja: "\n\n⚠️ この操作にはリスクがあるためブロックしました。\n上記の内容を確認し、問題なければそのまま指示を続けてください(同じ操作は今回のセッション中は自動で許可されます)。", + en: "\n\n⚠️ This operation was blocked due to risk.\nReview the details above. If it looks safe, just tell Claude to proceed (the same operation will be auto-allowed for this session).", +} as const; + function decisionToClaudeAction( decision: PolicyDecision, forceBlock?: boolean, @@ -21,7 +26,7 @@ function decisionToClaudeAction( case "deny": return "deny"; case "confirm": - if (forceBlock && decision.risk === "high") return "deny"; + if (forceBlock) return "deny"; return "ask"; case "allow": case "log": @@ -57,7 +62,7 @@ export function buildHookOutput( // When confirm was force-blocked to deny (VSCode compat), append policy change hint if (decision.action === "confirm" && claudeAction === "deny" && reason) { - reason += POLICY_HINT[lang]; + reason += decision.risk === "high" ? POLICY_HINT_HARD[lang] : POLICY_HINT_SOFT[lang]; } return { diff --git a/packages/adapter-claude/src/index.ts b/packages/adapter-claude/src/index.ts index 6ced1e2..0a31253 100644 --- a/packages/adapter-claude/src/index.ts +++ b/packages/adapter-claude/src/index.ts @@ -7,3 +7,4 @@ export { export { installHook, uninstallHook } from "./installer.js"; export type { HookMode } from "./installer.js"; export type { ClaudeHookInput, ClaudeHookOutput } from "./types.js"; +export { isVscodeEnvironment } from "./types.js"; diff --git a/packages/cli/src/engine-factory.ts b/packages/cli/src/engine-factory.ts index 41ea052..ad4b1f1 100644 --- a/packages/cli/src/engine-factory.ts +++ b/packages/cli/src/engine-factory.ts @@ -3,6 +3,7 @@ import { resolve } from "node:path"; import type { ClaudeHookOutput } from "@clawguard/adapter-claude"; import { buildHookOutput, + isVscodeEnvironment, mapToToolRequest, parseHookInput, shouldIntervene, @@ -130,6 +131,7 @@ export function createEngineContext(overrideLang?: Lang): EngineContext { const engine = new PolicyEngine(rules, preset, feedVersion, config.project_overrides); const writer = new AuditWriter(); const store = new DecisionStore(); + try { store.cleanExpiredSessions(24); } catch { /* non-fatal */ } const reputation = new ReputationAggregator(store, feedBundle?.reputation); // Telemetry upload is always enabled (anonymous aggregate stats). @@ -169,6 +171,15 @@ export function evaluateHookRequest(rawInput: string, ctx: EngineContext): EvalR const request = mapToToolRequest(hookInput); const decision = ctx.engine.evaluate(request); + const isVSCode = ctx.vsCodeCompat ?? isVscodeEnvironment(); + const contentHash = DecisionStore.hashContent(request.content); + + // Session allowlist: if a non-high-risk confirm was already force-denied and user retried, allow it + if (isVSCode && decision.action === "confirm" && decision.risk !== "high" && ctx.store) { + if (ctx.store.isSessionAllowed(request.context.session_id, contentHash, decision.rule_id)) { + decision.action = "allow"; + } + } const event = createOcsfEvent(request, decision); ctx.writer.write(event); @@ -177,13 +188,19 @@ export function evaluateHookRequest(rawInput: string, ctx: EngineContext): EvalR ctx.store.record({ rule_id: decision.rule_id, action: decision.action, - content_hash: DecisionStore.hashContent(request.content), + content_hash: contentHash, agent: request.context.agent, session_id: request.context.session_id, }); } const output = buildHookOutput(decision, ctx.lang, ctx.vsCodeCompat); + + // Record soft force-deny to session allowlist (non-high-risk confirm in VSCode) + if (isVSCode && decision.action === "confirm" && decision.risk !== "high" && ctx.store && output) { + ctx.store.recordSessionAllow(request.context.session_id, contentHash, decision.rule_id); + } + return { output, skipped: false }; } @@ -199,6 +216,8 @@ export async function evaluateHookRequestAsync( const request = mapToToolRequest(hookInput); let decision = ctx.engine.evaluate(request); + const isVSCode = ctx.vsCodeCompat ?? isVscodeEnvironment(); + const contentHash = DecisionStore.hashContent(request.content); if (ctx.enricher && decision.action !== "allow") { try { @@ -208,6 +227,13 @@ export async function evaluateHookRequestAsync( } } + // Session allowlist: if a non-high-risk confirm was already force-denied and user retried, allow it + if (isVSCode && decision.action === "confirm" && decision.risk !== "high" && ctx.store) { + if (ctx.store.isSessionAllowed(request.context.session_id, contentHash, decision.rule_id)) { + decision.action = "allow"; + } + } + const event = createOcsfEvent(request, decision); ctx.writer.write(event); @@ -215,12 +241,18 @@ export async function evaluateHookRequestAsync( ctx.store.record({ rule_id: decision.rule_id, action: decision.action, - content_hash: DecisionStore.hashContent(request.content), + content_hash: contentHash, agent: request.context.agent, session_id: request.context.session_id, }); } const output = buildHookOutput(decision, ctx.lang, ctx.vsCodeCompat); + + // Record soft force-deny to session allowlist (non-high-risk confirm in VSCode) + if (isVSCode && decision.action === "confirm" && decision.risk !== "high" && ctx.store && output) { + ctx.store.recordSessionAllow(request.context.session_id, contentHash, decision.rule_id); + } + return { output, skipped: false }; } diff --git a/packages/memory/src/__tests__/decision-store.test.ts b/packages/memory/src/__tests__/decision-store.test.ts index 607b45e..8528cbd 100644 --- a/packages/memory/src/__tests__/decision-store.test.ts +++ b/packages/memory/src/__tests__/decision-store.test.ts @@ -94,4 +94,42 @@ describe("DecisionStore", () => { expect(recent[0].agent).toBe("claude"); expect(recent[0].session_id).toBe("sess-1"); }); + + describe("session allowlist", () => { + it("returns false when no entry exists", () => { + expect(store.isSessionAllowed("s1", "hash1", "BASH.NPM_INSTALL")).toBe(false); + }); + + it("returns true after recording", () => { + store.recordSessionAllow("s1", "hash1", "BASH.NPM_INSTALL"); + expect(store.isSessionAllowed("s1", "hash1", "BASH.NPM_INSTALL")).toBe(true); + }); + + it("does not match different session", () => { + store.recordSessionAllow("s1", "hash1", "BASH.NPM_INSTALL"); + expect(store.isSessionAllowed("s2", "hash1", "BASH.NPM_INSTALL")).toBe(false); + }); + + it("does not match different content hash", () => { + store.recordSessionAllow("s1", "hash1", "BASH.NPM_INSTALL"); + expect(store.isSessionAllowed("s1", "hash2", "BASH.NPM_INSTALL")).toBe(false); + }); + + it("does not match different rule", () => { + store.recordSessionAllow("s1", "hash1", "BASH.NPM_INSTALL"); + expect(store.isSessionAllowed("s1", "hash1", "BASH.PIP_INSTALL")).toBe(false); + }); + + it("handles duplicate inserts gracefully", () => { + store.recordSessionAllow("s1", "hash1", "BASH.NPM_INSTALL"); + store.recordSessionAllow("s1", "hash1", "BASH.NPM_INSTALL"); + expect(store.isSessionAllowed("s1", "hash1", "BASH.NPM_INSTALL")).toBe(true); + }); + + it("cleanExpiredSessions does not remove recent entries", () => { + store.recordSessionAllow("s1", "hash1", "BASH.NPM_INSTALL"); + store.cleanExpiredSessions(24); + expect(store.isSessionAllowed("s1", "hash1", "BASH.NPM_INSTALL")).toBe(true); + }); + }); }); diff --git a/packages/memory/src/decision-store.ts b/packages/memory/src/decision-store.ts index 7f5f547..49f8fb5 100644 --- a/packages/memory/src/decision-store.ts +++ b/packages/memory/src/decision-store.ts @@ -19,6 +19,16 @@ CREATE TABLE IF NOT EXISTS decisions ( ); CREATE INDEX IF NOT EXISTS idx_decisions_rule ON decisions(rule_id); CREATE INDEX IF NOT EXISTS idx_decisions_hash ON decisions(content_hash); + +CREATE TABLE IF NOT EXISTS session_allowlist ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + content_hash TEXT NOT NULL, + rule_id TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(session_id, content_hash, rule_id) +); +CREATE INDEX IF NOT EXISTS idx_sa_session ON session_allowlist(session_id); `; export class DecisionStore { @@ -125,6 +135,25 @@ export class DecisionStore { return this.getStats(ruleId).override_rate; } + isSessionAllowed(sessionId: string, contentHash: string, ruleId: string): boolean { + const row = this.db + .prepare("SELECT 1 FROM session_allowlist WHERE session_id = ? AND content_hash = ? AND rule_id = ?") + .get(sessionId, contentHash, ruleId); + return row != null; + } + + recordSessionAllow(sessionId: string, contentHash: string, ruleId: string): void { + this.db + .prepare("INSERT OR IGNORE INTO session_allowlist (session_id, content_hash, rule_id) VALUES (?, ?, ?)") + .run(sessionId, contentHash, ruleId); + } + + cleanExpiredSessions(maxAgeHours = 24): void { + this.db + .prepare("DELETE FROM session_allowlist WHERE created_at < datetime('now', ?)") + .run(`-${maxAgeHours} hours`); + } + close(): void { this.db.close(); } From b9f62ee6cf6c19dcf4b88b04d58e2ef67d44cc40 Mon Sep 17 00:00:00 2001 From: Goki602 Date: Tue, 10 Mar 2026 09:25:31 +0900 Subject: [PATCH 2/6] feat: reposition as convenience tool + explicit allow to suppress Claude dialogs Reframe ClawGuard from "security tool" to "confirmation fatigue solver": - Rewrite README/LP messaging: convenience-first, security as side-effect - Add `claw-guard stats` command showing auto-allow counts - Expand session allowlist from VSCode-only to all environments - Return explicit `permissionDecision: "allow"` instead of null so Claude Code actually skips its permission dialog for safe commands - Simplify billing to free-only model (MIT, no license keys) - Natural Japanese copy for README.ja.md and LP jp.ts Co-Authored-By: Claude Opus 4.6 --- README.ja.md | 151 ++++++++--------- README.md | 152 +++++++++--------- homebrew/claw-guard.rb | 2 +- .../src/__tests__/hook-handler.test.ts | 8 +- packages/adapter-claude/src/hook-handler.ts | 13 +- packages/api/src/routes/passport.ts | 24 +-- .../src/__tests__/feature-gate.test.ts | 146 ++++------------- .../billing/src/__tests__/license.test.ts | 20 ++- packages/billing/src/feature-gate.ts | 98 +++-------- packages/billing/src/license.ts | 39 ++--- packages/cli/package.json | 2 +- .../cli/src/__tests__/integration.test.ts | 19 ++- packages/cli/src/commands/init.ts | 59 ++++--- packages/cli/src/commands/serve.ts | 20 +-- packages/cli/src/commands/stats.ts | 61 +++++++ packages/cli/src/commands/upgrade.ts | 93 ----------- packages/cli/src/engine-factory.ts | 46 +++--- packages/cli/src/index.ts | 17 +- packages/lp/package.json | 2 +- packages/lp/src/__tests__/content.test.ts | 2 +- packages/lp/src/components/HeroSection.tsx | 14 +- packages/lp/src/components/PricingCards.tsx | 120 +++++--------- packages/lp/src/content/en.ts | 115 +++++-------- packages/lp/src/content/jp.ts | 126 ++++++--------- packages/memory/src/decision-store.ts | 48 ++++++ packages/replay/src/report-generator.ts | 2 +- 26 files changed, 575 insertions(+), 824 deletions(-) create mode 100644 packages/cli/src/commands/stats.ts delete mode 100644 packages/cli/src/commands/upgrade.ts diff --git a/README.ja.md b/README.ja.md index 6a9c677..13c8912 100644 --- a/README.ja.md +++ b/README.ja.md @@ -1,20 +1,20 @@ # ClawGuard -> AIエージェント・セキュリティ・コンパニオン — 防御・監査・更新 +> AIエージェントの記憶 — 確認を減らして、判断を賢く [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -[![Tests](https://img.shields.io/badge/tests-375%20passing-brightgreen)]() +[![Tests](https://img.shields.io/badge/tests-369%20passing-brightgreen)]() [![Node](https://img.shields.io/badge/node-%3E%3D20-green)]() [English version](README.md) ## ClawGuardとは? -ClawGuardは、AIコーディングエージェントのためのリアルタイム・セキュリティレイヤーです。シェルコマンド・ファイル書き込み・ネットワークアクセスなどのツール呼び出しを実行前にインターセプトし、ポリシールールでリスクを評価して、100ms以内にallow(許可)/ confirm(確認)/ deny(拒否)の判定を返します。 +AIエージェント、確認が多すぎませんか? `npm install`のたびに確認、`git push`のたびに確認。5分前に「いいよ」って言ったのに、また同じことを聞いてくる。 -Claude Code、Codex、MCPなど、フックベースのインターセプションに対応するあらゆるAIエージェントプラットフォームで動作します。判断データはローカルに保存され、署名付き脅威フィードを通じてコミュニティの評判データで強化できます。 +ClawGuardが覚えておきます。一度OKした操作は記録して、次からは自動で通す。セッションをまたいでも、別のエージェントでも、別のツールでも。Claude Code・Codex・Cursorなど、フック対応のエージェントならどれでも使えます。 -ClawGuardは「完全な防御」を約束しません。被害の確率を下げ、影響範囲を小さくし、すべてのAIエージェントセッションを監査・再現可能にします。 +危ない操作(`rm -rf`、`git push --force`、`curl|bash`)はもちろん止めます。でもインストールする本当の理由は、エージェントが静かに・速くなるから。 ## クイックスタート @@ -32,64 +32,23 @@ claw-guard test ## しくみ ``` -AIエージェントのツール呼び出し - │ - ▼ -┌─────────────┐ -│ ClawGuard │ -│ フック層 │ -└──────┬──────┘ - │ - ▼ -┌─────────────┐ ┌──────────┐ -│ ポリシー │────▶│ ルール │ 12コア + コミュニティ -│ エンジン │ └──────────┘ -└──────┬──────┘ - │ - ┌───┼───┐ - ▼ ▼ ▼ - 許可 確認 拒否 - │ - ▼ - ExplainRisk - (データに裏付けられた理由説明) -``` +初回: Agent → npm install foo → ClawGuard が確認 → 許可 → 記憶 ✓ -## アーキテクチャ +2回目: Agent → npm install foo → 自動許可(中断なし) ✓ -### 2層設計 +危険な操作: Agent → rm -rf / → 常にブロック+理由説明 ✗ +``` -**層1 — ランタイム防御**(フックベース、Docker不要) -- Adapter → Policy Engine → allow/confirm/deny -- confirm(確認)ダイアログに評判データ(ローカル+フィード)を表示 -- クロスエージェント判断記憶(SQLite) -- セキュリティパスポート(継続監視証明) +## 静かさレベルを選ぶ -**層2 — インフラ隔離**(Docker、Pro/Max向け) -- 3コンテナ構成: gateway / fetcher / agent -- ネットワーク分離(agentは外部に直接通信不可) +どれくらい静かにするかは自分で決められます。全部見せるモードから、ほぼ無音まで: -## CLIコマンド - -| コマンド | 説明 | -|---|---| -| `claw-guard init` | AIエージェント環境のセットアップ | -| `claw-guard evaluate --json` | ツールリクエストの評価(フックエントリポイント) | -| `claw-guard test` | ルール・エンジン・設定の検証 | -| `claw-guard serve` | HTTPフックサーバー(レイテンシ1-3ms) | -| `claw-guard log` | 監査ログの閲覧 | -| `claw-guard dashboard` | Webダッシュボードを起動 | -| `claw-guard feed` | 脅威フィードの管理(`--update`, `--status`) | -| `claw-guard marketplace` | ルールパックの管理(`installed`, `install`, `remove`) | -| `claw-guard upgrade` | ライセンス管理(`--key`, `--remove`) | -| `claw-guard passport` | セキュリティパスポート&GitHubバッジ | -| `claw-guard replay` | インシデントリプレイ&因果分析 | -| `claw-guard report` | 週次安全レポート | -| `claw-guard monitor` | 誤検知モニタリング | -| `claw-guard docker` | Dockerデプロイ(`init`, `up`, `down`) | -| `claw-guard skills` | Skills AVスキャン | - -## 設定 +| プリセット | スタイル | 低リスク | 中リスク | 高リスク | +|---|---|---|---|---| +| `observer` | 見るだけ | log | log | log | +| `guardian` | よく確認 | allow | confirm | deny | +| `balanced` | **おすすめ** | allow | confirm | confirm | +| `expert` | ほぼ無音 | allow | allow | confirm | 最小構成 — `clawguard.yaml` に1行書くだけ: @@ -97,20 +56,27 @@ AIエージェントのツール呼び出し profile: balanced ``` -### プリセット - -| プリセット | 低リスク | 中リスク | 高リスク | -|---|---|---|---| -| `observer` | log | log | log | -| `guardian` | allow | confirm | deny | -| `balanced` | allow | confirm | confirm | -| `expert` | allow | allow | confirm | - ### 優先順位 CLI引数 > プロジェクト(`.clawguard.yaml`) > グローバル(`~/.config/clawguard/`) > デフォルト(`balanced`) -## セキュリティルール +## アーキテクチャ + +### 2層設計 + +**層1 — スマート承認**(フックベース、Docker不要) +- 一度OKした操作を覚えて、次から自動で通す(SQLite) +- 確認時に「他の開発者はどうしたか」をコミュニティデータで表示 +- Adapter → Policy Engine → allow/confirm/deny(100ms以内) +- セキュリティパスポート(継続監視の証明書) + +**層2 — インフラ隔離**(Docker、オプション) +- 3コンテナ構成: gateway / fetcher / agent +- ネットワーク分離(agentから外部へ直接通信できない) + +## 組み込みの安全網 + +危険な操作は自動でキャッチします。設定は不要で、最初から12のルールが入っています。 ### コアルール(12件) @@ -129,6 +95,45 @@ CLI引数 > プロジェクト(`.clawguard.yaml`) > グローバル(`~/.co | `BASH.NPM_INSTALL` | bash | medium | `npm install <パッケージ>` | | `BASH.PIP_INSTALL` | bash | medium | `pip install <パッケージ>` | +### いつ聞いてくる? + +| 条件 | 動作 | 例 | +|---|---|---| +| 前にOKした操作 | **何も聞かず自動で通す** | `npm install express`(以前OKしたもの) | +| どのルールにもひっかからない | **何も聞かずそのまま実行** | `git status`, `ls`, `npm test` | +| ルールにひっかかる + confirm設定 | データ付きで確認を出す | `npm install unknown-pkg` | +| ルールにひっかかる + deny設定 | 理由を説明してブロック | `guardian`モードでの`rm -rf /` | + +### 動かないケース + +| 状況 | 理由 | +|---|---| +| AIエージェント自身がコマンドを断った | エージェントがツールを呼ばないので、ClawGuardの出番がない。これは多層防御(何重にも守る仕組み)として正常な動作です。 | +| `bypassPermissions` / `dontAsk` モードで実行中 | この権限モードではClawGuardの判定をスキップします。 | +| フックが入っていない | `claw-guard init` でフックを登録してください。 | + +> **動作確認:** `claw-guard test` を実行するか、JSONを `claw-guard evaluate --json` にパイプすると、ルール照合を直接テストできます。 + +## CLIコマンド + +| コマンド | 説明 | +|---|---| +| `claw-guard init` | AIエージェント環境のセットアップ | +| `claw-guard evaluate --json` | ツールリクエストの評価(フックエントリポイント) | +| `claw-guard test` | ルール・エンジン・設定の検証 | +| `claw-guard stats` | 自動許可カウント&判断サマリーの表示 | +| `claw-guard serve` | HTTPフックサーバー(レイテンシ1-3ms) | +| `claw-guard log` | 監査ログの閲覧 | +| `claw-guard dashboard` | Webダッシュボードを起動 | +| `claw-guard feed` | 脅威フィードの管理(`--update`, `--status`) | +| `claw-guard marketplace` | ルールパックの管理(`installed`, `install`, `remove`) | +| `claw-guard passport` | セキュリティパスポート&GitHubバッジ | +| `claw-guard replay` | インシデントリプレイ&因果分析 | +| `claw-guard report` | 週次安全レポート | +| `claw-guard monitor` | 誤検知モニタリング | +| `claw-guard docker` | Dockerデプロイ(`init`, `up`, `down`) | +| `claw-guard skills` | Skills AVスキャン | + ### ルール形式(YAML) ```yaml @@ -155,13 +160,9 @@ CLI引数 > プロジェクト(`.clawguard.yaml`) > グローバル(`~/.co phase: 0 ``` -## 料金プラン +## 料金 -| プラン | 価格 | 主な機能 | -|---|---|---| -| **Free** | $0 | 8コアルール、週次フィード、基本リプレイ(24時間) | -| **Pro** | $12/月 | 全ルール、日次フィード、パスポート、マーケットプレイス、Skills AV | -| **Max** | $39/月 | チーム管理、クロスチーム記憶、組織パスポート | +完全無料のオープンソース(MIT)。全機能が制限なく使えます。ライセンスキーも課金もありません。 ## プロジェクト構成 @@ -188,7 +189,7 @@ packages/ └── docker/ 3コンテナ参考実装 rules/ ├── core/ 12コアルール(Phase 0-1) -└── phase2/ 15追加ルール(Pro/Max向け) +└── phase2/ 15追加ルール ``` ## 開発 diff --git a/README.md b/README.md index a05d584..10850f1 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,20 @@ # ClawGuard -> AI Agent Security Companion — Defend, Audit, Update +> AI Agent Memory — Fewer Prompts, Smarter Decisions [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -[![Tests](https://img.shields.io/badge/tests-375%20passing-brightgreen)]() +[![Tests](https://img.shields.io/badge/tests-369%20passing-brightgreen)]() [![Node](https://img.shields.io/badge/node-%3E%3D20-green)]() [日本語版はこちら](README.ja.md) ## What is ClawGuard? -ClawGuard is a real-time security layer for AI coding agents. It intercepts tool calls (shell commands, file writes, network access) before execution, evaluates risk using policy rules, and returns allow/confirm/deny decisions — all in under 100ms. +AI agents ask too many questions. Every `npm install`, every `git push` — confirm, confirm, confirm. You said yes 5 minutes ago; now it's asking again. -It works across AI agent platforms: Claude Code, Codex, MCP, and any agent supporting hook-based interception. Decision data is stored locally and can be enriched with community reputation data via signed threat feeds. +ClawGuard remembers. When you approve an operation, it's stored. Next time the same pattern appears — in this session, another agent, or a different tool — auto-allowed. Your trust decisions travel across Claude Code, Codex, Cursor, and any hook-compatible agent. -ClawGuard doesn't promise "complete security." It reduces probability of damage, limits blast radius, and makes every AI agent session auditable and replayable. +Dangerous operations (`rm -rf`, `git push --force`, `curl|bash`) are still caught automatically. But that's not why you install ClawGuard — you install it because your agents get faster and quieter. ## Quick Start @@ -32,64 +32,26 @@ claw-guard test ## How It Works ``` -AI Agent Tool Call - │ - ▼ -┌─────────────┐ -│ ClawGuard │ -│ Hook Layer │ -└──────┬──────┘ - │ - ▼ -┌─────────────┐ ┌──────────┐ -│ Policy │────▶│ Rules │ 12 core + community -│ Engine │ └──────────┘ -└──────┬──────┘ - │ - ┌───┼───┐ - ▼ ▼ ▼ - allow confirm deny - │ - ▼ - ExplainRisk - (data-backed reason) -``` +First time: Agent tries `npm install foo` + → ClawGuard asks → You allow → Remembered ✓ -## Architecture +Second time: Agent tries `npm install foo` + → Auto-allowed (no interruption) ✓ -### 2-Layer Design +Dangerous: Agent tries `rm -rf /` + → Always blocked, always explained ✗ +``` -**Layer 1 — Runtime Defense** (hooks-based, no Docker required) -- Adapter → Policy Engine → allow/confirm/deny -- Reputation data (local + feed) in confirm dialogs -- Cross-agent decision memory (SQLite) -- Security Passport for compliance proof +## Choose How Quiet -**Layer 2 — Infrastructure Isolation** (Docker, Pro/Max) -- 3 containers: gateway / fetcher / agent -- Network segmentation (agent cannot reach external) +Pick a preset that matches your comfort level — from maximum visibility to near-silence: -## CLI Commands - -| Command | Description | -|---|---| -| `claw-guard init` | Set up for your AI agent environment | -| `claw-guard evaluate --json` | Evaluate a tool request (hook entry point) | -| `claw-guard test` | Validate rules, engine, and configuration | -| `claw-guard serve` | HTTP hook server (1-3ms latency) | -| `claw-guard log` | View audit log | -| `claw-guard dashboard` | Open web dashboard | -| `claw-guard feed` | Manage threat feed (`--update`, `--status`) | -| `claw-guard marketplace` | Manage rule packs (`installed`, `install`, `remove`) | -| `claw-guard upgrade` | License management (`--key`, `--remove`) | -| `claw-guard passport` | Security passport & GitHub badges | -| `claw-guard replay` | Incident replay & causal analysis | -| `claw-guard report` | Weekly safety report | -| `claw-guard monitor` | False positive monitoring | -| `claw-guard docker` | Docker deployment (`init`, `up`, `down`) | -| `claw-guard skills` | Skills AV scanning | - -## Configuration +| Preset | Style | Low Risk | Medium Risk | High Risk | +|---|---|---|---|---| +| `observer` | Watch only | log | log | log | +| `guardian` | Asks often | allow | confirm | deny | +| `balanced` | **Recommended** | allow | confirm | confirm | +| `expert` | Almost silent | allow | allow | confirm | Minimum config — just one line in `clawguard.yaml`: @@ -97,20 +59,27 @@ Minimum config — just one line in `clawguard.yaml`: profile: balanced ``` -### Presets - -| Preset | Low Risk | Medium Risk | High Risk | -|---|---|---|---| -| `observer` | log | log | log | -| `guardian` | allow | confirm | deny | -| `balanced` | allow | confirm | confirm | -| `expert` | allow | allow | confirm | - ### Priority CLI args > Project (`.clawguard.yaml`) > Global (`~/.config/clawguard/`) > Default (`balanced`) -## Security Rules +## Architecture + +### 2-Layer Design + +**Layer 1 — Smart Approval** (hooks-based, no Docker required) +- Cross-agent decision memory (SQLite) — remembers what you approved +- Community reputation data in confirm dialogs — see what others decided +- Adapter → Policy Engine → allow/confirm/deny (under 100ms) +- Security Passport for compliance proof + +**Layer 2 — Infrastructure Isolation** (Docker, optional) +- 3 containers: gateway / fetcher / agent +- Network segmentation (agent cannot reach external) + +## Built-in Safety Net + +ClawGuard silently catches dangerous operations. You don't need to configure anything — 12 rules ship by default. ### Core Rules (12) @@ -129,6 +98,45 @@ CLI args > Project (`.clawguard.yaml`) > Global (`~/.config/clawguard/`) > Defau | `BASH.NPM_INSTALL` | bash | medium | `npm install ` | | `BASH.PIP_INSTALL` | bash | medium | `pip install ` | +### When Does ClawGuard Ask? + +| Condition | What happens | Example | +|---|---|---| +| Already approved (same session/history) | **Silent — auto-allowed** | `npm install express` (approved before) | +| No rule matched | **Silent — proceeds normally** | `git status`, `ls`, `npm test` | +| Rule matched + preset = **confirm** | Asks with data-backed explanation | `npm install unknown-pkg` | +| Rule matched + preset = **deny** | Blocked with explanation | `rm -rf /` in `guardian` mode | + +### Important Limitations + +| Condition | Why ClawGuard Cannot Intervene | +|---|---| +| AI agent refuses the command on its own | The tool call never happens, so the PreToolUse hook never fires. This is defense-in-depth — the agent's own safety layer acts upstream of ClawGuard. | +| Running in `bypassPermissions` or `dontAsk` mode | ClawGuard skips evaluation in these permission modes. | +| Hook not installed | Run `claw-guard init` to register the PreToolUse hook. | + +> **Note:** To verify ClawGuard's rule matching directly, use `claw-guard test` or pipe JSON to `claw-guard evaluate --json`. + +## CLI Commands + +| Command | Description | +|---|---| +| `claw-guard init` | Set up for your AI agent environment | +| `claw-guard evaluate --json` | Evaluate a tool request (hook entry point) | +| `claw-guard test` | Validate rules, engine, and configuration | +| `claw-guard stats` | View auto-allow count and decision summary | +| `claw-guard serve` | HTTP hook server (1-3ms latency) | +| `claw-guard log` | View audit log | +| `claw-guard dashboard` | Open web dashboard | +| `claw-guard feed` | Manage threat feed (`--update`, `--status`) | +| `claw-guard marketplace` | Manage rule packs (`installed`, `install`, `remove`) | +| `claw-guard passport` | Security passport & GitHub badges | +| `claw-guard replay` | Incident replay & causal analysis | +| `claw-guard report` | Weekly safety report | +| `claw-guard monitor` | False positive monitoring | +| `claw-guard docker` | Docker deployment (`init`, `up`, `down`) | +| `claw-guard skills` | Skills AV scanning | + ### Rule Format (YAML) ```yaml @@ -157,11 +165,7 @@ CLI args > Project (`.clawguard.yaml`) > Global (`~/.config/clawguard/`) > Defau ## Pricing -| Plan | Price | Key Features | -|---|---|---| -| **Free** | $0 | 8 core rules, weekly feed, basic replay (24h) | -| **Pro** | $12/mo | All rules, daily feed, passport, marketplace, Skills AV | -| **Max** | $39/mo | Team management, cross-team memory, org passport | +Completely free and open source (MIT). All features available to everyone — no license key, no paywalls. ## Project Structure @@ -188,7 +192,7 @@ packages/ └── docker/ 3-container reference implementation rules/ ├── core/ 12 core rules (Phase 0-1) -└── phase2/ 15 additional rules (Pro/Max) +└── phase2/ 15 additional rules ``` ## Development diff --git a/homebrew/claw-guard.rb b/homebrew/claw-guard.rb index ac167be..7b38719 100644 --- a/homebrew/claw-guard.rb +++ b/homebrew/claw-guard.rb @@ -1,5 +1,5 @@ class ClawGuard < Formula - desc "AI agent security companion — defend, audit, update" + desc "AI agent memory — fewer prompts, smarter decisions" homepage "https://github.com/Goki602/ClawGuard" url "https://registry.npmjs.org/@clawguard-sec/cli/-/cli-0.1.0.tgz" sha256 "71a408a167d37c6ac8b91e873ffeba5329fdd5532bfd18c998b0ecbff0c5e0bb" diff --git a/packages/adapter-claude/src/__tests__/hook-handler.test.ts b/packages/adapter-claude/src/__tests__/hook-handler.test.ts index 2c0978f..f3d7bef 100644 --- a/packages/adapter-claude/src/__tests__/hook-handler.test.ts +++ b/packages/adapter-claude/src/__tests__/hook-handler.test.ts @@ -48,17 +48,19 @@ describe("shouldIntervene", () => { }); describe("buildHookOutput", () => { - it("returns null for allow", () => { + it("returns explicit allow for allow action", () => { const decision: PolicyDecision = { action: "allow", risk: "low", rule_id: "NO_MATCH", feed_version: "0.1.0", }; - expect(buildHookOutput(decision)).toBeNull(); + const output = buildHookOutput(decision); + expect(output).not.toBeNull(); + expect(output?.hookSpecificOutput.permissionDecision).toBe("allow"); }); - it("returns null for log", () => { + it("returns null for log (observer mode)", () => { const decision: PolicyDecision = { action: "log", risk: "high", diff --git a/packages/adapter-claude/src/hook-handler.ts b/packages/adapter-claude/src/hook-handler.ts index 76d7faf..251b863 100644 --- a/packages/adapter-claude/src/hook-handler.ts +++ b/packages/adapter-claude/src/hook-handler.ts @@ -53,10 +53,19 @@ export function buildHookOutput( const forceBlock = vsCodeCompat ?? isVscodeEnvironment(); const claudeAction = decisionToClaudeAction(decision, forceBlock); - // For allow, return null (exit 0 with no stdout = proceed) - if (claudeAction === "allow") { + // observer mode (log): don't affect Claude's behavior — just observe + if (decision.action === "log") { return null; } + // Explicit allow: tell Claude to auto-approve (suppress permission dialog) + if (claudeAction === "allow") { + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "allow", + }, + }; + } let reason = buildReason(decision, lang); diff --git a/packages/api/src/routes/passport.ts b/packages/api/src/routes/passport.ts index ccc61d2..5bf8950 100644 --- a/packages/api/src/routes/passport.ts +++ b/packages/api/src/routes/passport.ts @@ -28,27 +28,9 @@ export async function handlePassportGet( } export async function handlePassportPut(request: Request, env: Env, id: string): Promise { + // Bearer token kept for spam prevention (no license validation) const authHeader = request.headers.get("Authorization"); - if (!authHeader?.startsWith("Bearer ")) { - return new Response(JSON.stringify({ error: "Missing license key" }), { - status: 401, - headers: { "Content-Type": "application/json" }, - }); - } - - const licenseKey = authHeader.slice(7); - - // Validate license key against the licenses database - const license = await env.LICENSE_DB.prepare("SELECT plan FROM licenses WHERE license_key = ?") - .bind(licenseKey) - .first<{ plan: string }>(); - - if (!license) { - return new Response(JSON.stringify({ error: "Invalid license key" }), { - status: 403, - headers: { "Content-Type": "application/json" }, - }); - } + const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : ""; let passport: { repository?: string }; try { @@ -69,7 +51,7 @@ export async function handlePassportPut(request: Request, env: Env, id: string): license_key = excluded.license_key, updated_at = datetime('now')`, ) - .bind(id, passport.repository ?? "", JSON.stringify(passport), licenseKey) + .bind(id, passport.repository ?? "", JSON.stringify(passport), token) .run(); return new Response(JSON.stringify({ ok: true, project_id: id }), { diff --git a/packages/billing/src/__tests__/feature-gate.test.ts b/packages/billing/src/__tests__/feature-gate.test.ts index 4696602..e361358 100644 --- a/packages/billing/src/__tests__/feature-gate.test.ts +++ b/packages/billing/src/__tests__/feature-gate.test.ts @@ -3,136 +3,44 @@ import { describe, expect, it } from "vitest"; import { FeatureGate } from "../feature-gate.js"; function makeLicense(plan: "free" | "pro" | "max"): LicenseInfo { - const features = { - free: { - max_rules: 12, - feed_interval: "weekly" as const, - reputation_network: false, - marketplace: false, - team: false, - team_admin: false, - }, - pro: { - max_rules: Number.MAX_SAFE_INTEGER, - feed_interval: "daily" as const, - reputation_network: true, - marketplace: true, - team: true, - team_admin: false, - }, - max: { + return { + plan, + features: { max_rules: Number.MAX_SAFE_INTEGER, - feed_interval: "daily" as const, + feed_interval: "daily", reputation_network: true, marketplace: true, team: true, team_admin: true, }, }; - return { plan, features: features[plan] }; } -describe("FeatureGate", () => { - it("free plan: blocks phase2 rules, reputation, marketplace", () => { - const gate = new FeatureGate(makeLicense("free")); - expect(gate.canLoadPhase2Rules()).toBe(false); - expect(gate.canUseReputation()).toBe(false); - expect(gate.canUseMarketplace()).toBe(false); - expect(gate.canUseFeed()).toBe(true); - expect(gate.canUseDailyFeed()).toBe(false); - expect(gate.getMaxRules()).toBe(12); +describe("FeatureGate (all features free)", () => { + it("all plans unlock all features", () => { + for (const plan of ["free", "pro", "max"] as const) { + const gate = new FeatureGate(makeLicense(plan)); + expect(gate.canLoadPhase2Rules()).toBe(true); + expect(gate.canUseFeed()).toBe(true); + expect(gate.canUseDailyFeed()).toBe(true); + expect(gate.canUseReputation()).toBe(true); + expect(gate.canUseMarketplace()).toBe(true); + expect(gate.getMaxRules()).toBe(Number.MAX_SAFE_INTEGER); + expect(gate.canUsePassport()).toBe(true); + expect(gate.canUseOrgPassport()).toBe(true); + expect(gate.canUseFullReplay()).toBe(true); + expect(gate.canUseCausalChain()).toBe(true); + expect(gate.canExportReplay()).toBe(true); + expect(gate.getReplayRetentionDays()).toBe(-1); + expect(gate.canUseSkillsAV()).toBe(true); + expect(gate.canUseTeam()).toBe(true); + expect(gate.canUseTeamAdmin()).toBe(true); + expect(gate.canUseCentralizedAudit()).toBe(true); + expect(gate.canUseCrossTeamMemory()).toBe(true); + } }); - it("pro plan: allows all features", () => { - const gate = new FeatureGate(makeLicense("pro")); - expect(gate.canLoadPhase2Rules()).toBe(true); - expect(gate.canUseReputation()).toBe(true); - expect(gate.canUseMarketplace()).toBe(true); - expect(gate.canUseFeed()).toBe(true); - expect(gate.canUseDailyFeed()).toBe(true); - expect(gate.getMaxRules()).toBe(Number.MAX_SAFE_INTEGER); - }); - - it("max plan: allows all features", () => { - const gate = new FeatureGate(makeLicense("max")); - expect(gate.canLoadPhase2Rules()).toBe(true); - expect(gate.canUseReputation()).toBe(true); - expect(gate.canUseMarketplace()).toBe(true); - expect(gate.canUseDailyFeed()).toBe(true); - }); - - it("getPlan returns plan name", () => { + it("getPlan returns free", () => { expect(new FeatureGate(makeLicense("free")).getPlan()).toBe("free"); - expect(new FeatureGate(makeLicense("pro")).getPlan()).toBe("pro"); - }); - - describe("Phase 3: Passport & Replay", () => { - it("canUsePassport: free=false, pro=true, max=true", () => { - expect(new FeatureGate(makeLicense("free")).canUsePassport()).toBe(false); - expect(new FeatureGate(makeLicense("pro")).canUsePassport()).toBe(true); - expect(new FeatureGate(makeLicense("max")).canUsePassport()).toBe(true); - }); - - it("canUseOrgPassport: free=false, pro=false, max=true", () => { - expect(new FeatureGate(makeLicense("free")).canUseOrgPassport()).toBe(false); - expect(new FeatureGate(makeLicense("pro")).canUseOrgPassport()).toBe(false); - expect(new FeatureGate(makeLicense("max")).canUseOrgPassport()).toBe(true); - }); - - it("canUseFullReplay: free=false, pro=true, max=true", () => { - expect(new FeatureGate(makeLicense("free")).canUseFullReplay()).toBe(false); - expect(new FeatureGate(makeLicense("pro")).canUseFullReplay()).toBe(true); - expect(new FeatureGate(makeLicense("max")).canUseFullReplay()).toBe(true); - }); - - it("canUseCausalChain: free=false, pro=true, max=true", () => { - expect(new FeatureGate(makeLicense("free")).canUseCausalChain()).toBe(false); - expect(new FeatureGate(makeLicense("pro")).canUseCausalChain()).toBe(true); - expect(new FeatureGate(makeLicense("max")).canUseCausalChain()).toBe(true); - }); - - it("canExportReplay: free=false, pro=true, max=true", () => { - expect(new FeatureGate(makeLicense("free")).canExportReplay()).toBe(false); - expect(new FeatureGate(makeLicense("pro")).canExportReplay()).toBe(true); - expect(new FeatureGate(makeLicense("max")).canExportReplay()).toBe(true); - }); - - it("getReplayRetentionDays: free=1, pro=-1, max=-1", () => { - expect(new FeatureGate(makeLicense("free")).getReplayRetentionDays()).toBe(1); - expect(new FeatureGate(makeLicense("pro")).getReplayRetentionDays()).toBe(-1); - expect(new FeatureGate(makeLicense("max")).getReplayRetentionDays()).toBe(-1); - }); - }); - - describe("Phase 4: Skills AV, Team, Cross-team Memory", () => { - it("canUseSkillsAV: free=false, pro=true, max=true", () => { - expect(new FeatureGate(makeLicense("free")).canUseSkillsAV()).toBe(false); - expect(new FeatureGate(makeLicense("pro")).canUseSkillsAV()).toBe(true); - expect(new FeatureGate(makeLicense("max")).canUseSkillsAV()).toBe(true); - }); - - it("canUseTeam: free=false, pro=true, max=true", () => { - expect(new FeatureGate(makeLicense("free")).canUseTeam()).toBe(false); - expect(new FeatureGate(makeLicense("pro")).canUseTeam()).toBe(true); - expect(new FeatureGate(makeLicense("max")).canUseTeam()).toBe(true); - }); - - it("canUseTeamAdmin: free=false, pro=false, max=true", () => { - expect(new FeatureGate(makeLicense("free")).canUseTeamAdmin()).toBe(false); - expect(new FeatureGate(makeLicense("pro")).canUseTeamAdmin()).toBe(false); - expect(new FeatureGate(makeLicense("max")).canUseTeamAdmin()).toBe(true); - }); - - it("canUseCentralizedAudit: free=false, pro=false, max=true", () => { - expect(new FeatureGate(makeLicense("free")).canUseCentralizedAudit()).toBe(false); - expect(new FeatureGate(makeLicense("pro")).canUseCentralizedAudit()).toBe(false); - expect(new FeatureGate(makeLicense("max")).canUseCentralizedAudit()).toBe(true); - }); - - it("canUseCrossTeamMemory: free=false, pro=false, max=true", () => { - expect(new FeatureGate(makeLicense("free")).canUseCrossTeamMemory()).toBe(false); - expect(new FeatureGate(makeLicense("pro")).canUseCrossTeamMemory()).toBe(false); - expect(new FeatureGate(makeLicense("max")).canUseCrossTeamMemory()).toBe(true); - }); }); }); diff --git a/packages/billing/src/__tests__/license.test.ts b/packages/billing/src/__tests__/license.test.ts index ea74fed..de8783a 100644 --- a/packages/billing/src/__tests__/license.test.ts +++ b/packages/billing/src/__tests__/license.test.ts @@ -41,29 +41,39 @@ describe("LicenseManager", () => { rmSync(tmpDir, { recursive: true, force: true }); }); - it("returns free license when no key exists", () => { + it("returns free license with all features unlocked when no key exists", () => { const license = manager.getCurrentLicense(); expect(license.plan).toBe("free"); - expect(license.features.max_rules).toBe(12); - expect(license.features.reputation_network).toBe(false); + expect(license.features.max_rules).toBe(Number.MAX_SAFE_INTEGER); + expect(license.features.feed_interval).toBe("daily"); + expect(license.features.reputation_network).toBe(true); + expect(license.features.marketplace).toBe(true); + expect(license.features.team).toBe(true); + expect(license.features.team_admin).toBe(true); }); - it("validates and saves pro key", () => { + it("validates and saves pro key with all features unlocked", () => { const key = "cg_pro_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"; const license = manager.saveLicense(key); expect(license.plan).toBe("pro"); expect(license.key).toBe(key); + expect(license.features.max_rules).toBe(Number.MAX_SAFE_INTEGER); expect(license.features.reputation_network).toBe(true); expect(license.features.marketplace).toBe(true); expect(license.features.feed_interval).toBe("daily"); + expect(license.features.team).toBe(true); + expect(license.features.team_admin).toBe(true); }); - it("reads saved license", () => { + it("reads saved license with all features unlocked", () => { const key = "cg_max_00000000000000000000000000000000"; manager.saveLicense(key); const license = manager.getCurrentLicense(); expect(license.plan).toBe("max"); expect(license.features.max_rules).toBe(Number.MAX_SAFE_INTEGER); + expect(license.features.reputation_network).toBe(true); + expect(license.features.marketplace).toBe(true); + expect(license.features.team).toBe(true); }); it("returns free for invalid saved key", () => { diff --git a/packages/billing/src/feature-gate.ts b/packages/billing/src/feature-gate.ts index 87396b4..8ef062d 100644 --- a/packages/billing/src/feature-gate.ts +++ b/packages/billing/src/feature-gate.ts @@ -1,81 +1,25 @@ import type { LicenseInfo } from "@clawguard/core"; +// All features unlocked — ClawGuard is 100% free export class FeatureGate { - private license: LicenseInfo; - - constructor(license: LicenseInfo) { - this.license = license; - } - - canLoadPhase2Rules(): boolean { - return this.license.plan === "pro" || this.license.plan === "max"; - } - - canUseFeed(): boolean { - return true; - } - - canUseDailyFeed(): boolean { - return this.license.features.feed_interval === "daily"; - } - - canUseReputation(): boolean { - return this.license.features.reputation_network; - } - - canUseMarketplace(): boolean { - return this.license.features.marketplace; - } - - getMaxRules(): number { - return this.license.features.max_rules; - } - - getPlan(): string { - return this.license.plan; - } - - canUsePassport(): boolean { - return this.license.plan === "pro" || this.license.plan === "max"; - } - - canUseOrgPassport(): boolean { - return this.license.plan === "max"; - } - - canUseFullReplay(): boolean { - return this.license.plan === "pro" || this.license.plan === "max"; - } - - canUseCausalChain(): boolean { - return this.license.plan === "pro" || this.license.plan === "max"; - } - - canExportReplay(): boolean { - return this.license.plan === "pro" || this.license.plan === "max"; - } - - getReplayRetentionDays(): number { - return this.license.plan === "free" ? 1 : -1; - } - - canUseSkillsAV(): boolean { - return this.license.features.team || this.license.plan === "pro" || this.license.plan === "max"; - } - - canUseTeam(): boolean { - return this.license.features.team; - } - - canUseTeamAdmin(): boolean { - return this.license.features.team_admin; - } - - canUseCentralizedAudit(): boolean { - return this.license.plan === "max"; - } - - canUseCrossTeamMemory(): boolean { - return this.license.plan === "max"; - } + constructor(_license: LicenseInfo) {} + + canLoadPhase2Rules(): boolean { return true; } + canUseFeed(): boolean { return true; } + canUseDailyFeed(): boolean { return true; } + canUseReputation(): boolean { return true; } + canUseMarketplace(): boolean { return true; } + getMaxRules(): number { return Number.MAX_SAFE_INTEGER; } + getPlan(): string { return "free"; } + canUsePassport(): boolean { return true; } + canUseOrgPassport(): boolean { return true; } + canUseFullReplay(): boolean { return true; } + canUseCausalChain(): boolean { return true; } + canExportReplay(): boolean { return true; } + getReplayRetentionDays(): number { return -1; } + canUseSkillsAV(): boolean { return true; } + canUseTeam(): boolean { return true; } + canUseTeamAdmin(): boolean { return true; } + canUseCentralizedAudit(): boolean { return true; } + canUseCrossTeamMemory(): boolean { return true; } } diff --git a/packages/billing/src/license.ts b/packages/billing/src/license.ts index 0ecf289..2b39443 100644 --- a/packages/billing/src/license.ts +++ b/packages/billing/src/license.ts @@ -3,36 +3,25 @@ import { homedir } from "node:os"; import { dirname, join } from "node:path"; import type { BillingPlan, LicenseInfo } from "@clawguard/core"; +// All features unlocked — ClawGuard is 100% free +const ALL_FEATURES: LicenseInfo["features"] = { + max_rules: Number.MAX_SAFE_INTEGER, + feed_interval: "daily", + reputation_network: true, + marketplace: true, + team: true, + team_admin: true, +}; + const FREE_LICENSE: LicenseInfo = { plan: "free", - features: { - max_rules: 12, - feed_interval: "weekly", - reputation_network: false, - marketplace: false, - team: false, - team_admin: false, - }, + features: { ...ALL_FEATURES }, }; const PLAN_FEATURES: Record = { - free: FREE_LICENSE.features, - pro: { - max_rules: Number.MAX_SAFE_INTEGER, - feed_interval: "daily", - reputation_network: true, - marketplace: true, - team: true, - team_admin: false, - }, - max: { - max_rules: Number.MAX_SAFE_INTEGER, - feed_interval: "daily", - reputation_network: true, - marketplace: true, - team: true, - team_admin: true, - }, + free: ALL_FEATURES, + pro: ALL_FEATURES, + max: ALL_FEATURES, }; const KEY_REGEX = /^cg_(free|pro|max)_([0-9a-f]{32})$/; diff --git a/packages/cli/package.json b/packages/cli/package.json index 7940608..fe6cd19 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "name": "@clawguard-sec/cli", "version": "0.1.0", - "description": "AI agent security companion — defend, audit, update", + "description": "AI agent memory — fewer prompts, smarter decisions", "type": "module", "license": "MIT", "bin": { diff --git a/packages/cli/src/__tests__/integration.test.ts b/packages/cli/src/__tests__/integration.test.ts index 1055eca..447c5f0 100644 --- a/packages/cli/src/__tests__/integration.test.ts +++ b/packages/cli/src/__tests__/integration.test.ts @@ -139,9 +139,10 @@ describe("Integration: Evaluate Pipeline", () => { expect(result.skipped).toBe(false); }); - it("git status → null output (allow)", () => { + it("git status → explicit allow (suppress Claude dialog)", () => { const result = evaluateHookRequest(hookInput("git status"), ctx); - expect(result.output).toBeNull(); + expect(result.output).not.toBeNull(); + expect(result.output?.hookSpecificOutput.permissionDecision).toBe("allow"); expect(result.skipped).toBe(false); }); @@ -156,8 +157,10 @@ describe("Integration: Evaluate Pipeline", () => { const hasEnvRule = ctx.engine.getRules().some((r) => r.id === "BASH.ENV_FILE_READ"); if (hasEnvRule) { expect(result.output).not.toBeNull(); + expect(result.output?.hookSpecificOutput.permissionDecision).not.toBe("allow"); } else { - expect(result.output).toBeNull(); + expect(result.output).not.toBeNull(); + expect(result.output?.hookSpecificOutput.permissionDecision).toBe("allow"); } }); @@ -167,9 +170,10 @@ describe("Integration: Evaluate Pipeline", () => { expect(result.skipped).toBe(true); }); - it("unknown tool → allow", () => { + it("unknown tool → explicit allow", () => { const result = evaluateHookRequest(hookInputTool("CustomTool", { data: "test" }), ctx); - expect(result.output).toBeNull(); + expect(result.output).not.toBeNull(); + expect(result.output?.hookSpecificOutput.permissionDecision).toBe("allow"); expect(result.skipped).toBe(false); }); }); @@ -320,14 +324,15 @@ describe("Integration: HTTP Server", () => { expect(data.hookSpecificOutput.permissionDecision).toBe("ask"); }); - it("POST /hook with safe command returns empty object", async () => { + it("POST /hook with safe command returns explicit allow", async () => { const res = await fetch(`${baseUrl}/hook`, { method: "POST", headers: { "Content-Type": "application/json" }, body: hookInput("git status"), }); const data = await res.json(); - expect(data).toEqual({}); + expect(data.hookSpecificOutput).toBeDefined(); + expect(data.hookSpecificOutput.permissionDecision).toBe("allow"); }); it("GET /health returns status ok and rules count", async () => { diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index ac3cdf5..20bea0e 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -1,5 +1,6 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { createInterface } from "node:readline/promises"; import { installHook } from "@clawguard/adapter-claude"; import { getGlobalConfigDir, isValidPresetName } from "@clawguard/core"; import type { PresetName } from "@clawguard/core"; @@ -11,36 +12,34 @@ interface PresetRow { label: string; high: string; medium: string; + low: string; + desc: string; recommended?: boolean; } const PRESETS_JA: PresetRow[] = [ - { name: "observer", label: "(見守りモード)", high: "ログ", medium: "ログ" }, - { name: "guardian", label: "(厳格モード) ", high: "拒否", medium: "確認" }, - { name: "balanced", label: "(バランス型) ", high: "確認", medium: "確認", recommended: true }, - { name: "expert", label: "(上級者向け) ", high: "確認", medium: "許可" }, + { name: "observer", label: "(見るだけ) ", high: "ログ", medium: "ログ", low: "ログ", desc: "何もブロックせず記録。AIの動きを把握したい時に" }, + { name: "guardian", label: "(しっかり) ", high: "拒否", medium: "確認", low: "許可", desc: "よく確認する。AI初心者に最適" }, + { name: "balanced", label: "(バランス) ", high: "確認", medium: "確認", low: "許可", desc: "時々確認。すぐ学習する。ほとんどの人に最適", recommended: true }, + { name: "expert", label: "(静か) ", high: "確認", medium: "許可", low: "許可", desc: "ほぼ無音。本当に危ない操作だけ確認" }, ]; const PRESETS_EN: PresetRow[] = [ - { name: "observer", label: "(Watch mode)", high: "log ", medium: "log " }, - { name: "guardian", label: "(Strict) ", high: "deny ", medium: "confirm" }, - { - name: "balanced", - label: "(Balanced) ", - high: "confirm", - medium: "confirm", - recommended: true, - }, - { name: "expert", label: "(Expert) ", high: "confirm", medium: "allow " }, + { name: "observer", label: "(Watch only)", high: "log ", medium: "log ", low: "log ", desc: "Logs everything, blocks nothing. See what your agent does." }, + { name: "guardian", label: "(Careful) ", high: "deny ", medium: "confirm", low: "allow", desc: "Asks often. Best for getting started with AI agents." }, + { name: "balanced", label: "(Balanced) ", high: "confirm", medium: "confirm", low: "allow", desc: "Asks sometimes. Learns fast. Best for most users.", recommended: true }, + { name: "expert", label: "(Quiet) ", high: "confirm", medium: "allow ", low: "allow", desc: "Almost silent. Only flags truly dangerous operations." }, ]; +const DEFAULT_INDEX = 2; // balanced (0-based) + const MSG = { ja: { title: "ClawGuard セットアップ", choose: "プリセットを選んでください:", - header: " 危険 注意", - hint: (cmd: string) => `ヒント: ${cmd} で指定できます`, - defaultUsing: (name: string) => `デフォルト: ${name} を使用します`, + header: " 危険 注意 安全", + prompt: `選択 [1-4] (デフォルト: ${DEFAULT_INDEX + 1}): `, + selected: (name: string) => `${name} を選択しました`, rec: "<- おすすめ", created: (path: string, profile: string) => `${path} を作成しました (profile: ${profile})`, done: "セットアップ完了!", @@ -51,9 +50,9 @@ const MSG = { en: { title: "ClawGuard Setup", choose: "Choose a preset:", - header: " High Medium", - hint: (cmd: string) => `Hint: ${cmd}`, - defaultUsing: (name: string) => `Default: using ${name}`, + header: " High Medium Low", + prompt: `Select [1-4] (default: ${DEFAULT_INDEX + 1}): `, + selected: (name: string) => `Selected ${name}`, rec: "<- recommended", created: (path: string, profile: string) => `Created ${path} (profile: ${profile})`, done: "Setup complete!", @@ -75,13 +74,23 @@ export async function initCommand(options: { profile?: string; agent?: string }) console.log(chalk.bold(`\n${m.title}\n`)); console.log(`${m.choose}\n`); console.log(chalk.dim(m.header)); - for (const p of presets) { + for (let i = 0; i < presets.length; i++) { + const p = presets[i]; + const num = chalk.bold(`[${i + 1}]`); const rec = p.recommended ? chalk.cyan(` ${m.rec}`) : ""; - console.log(` ${chalk.bold(p.name.padEnd(10))}${p.label} ${p.high} ${p.medium}${rec}`); + console.log(` ${num} ${chalk.bold(p.name.padEnd(10))}${p.label} ${p.high} ${p.medium} ${p.low}${rec}`); + console.log(` ${chalk.dim(p.desc)}`); + console.log(""); } - console.log(`\n${m.hint(chalk.cyan("claw-guard init --profile "))}`); - profile = "balanced"; - console.log(`${m.defaultUsing(chalk.bold("balanced"))}\n`); + + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const answer = await rl.question(chalk.cyan(m.prompt)); + rl.close(); + + const idx = Number.parseInt(answer.trim(), 10) - 1; + const chosen = idx >= 0 && idx < presets.length ? idx : DEFAULT_INDEX; + profile = presets[chosen].name; + console.log(`${chalk.green("✓")} ${m.selected(chalk.bold(profile))}\n`); } // Create global config (save lang for future commands) diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 63812cd..5115df3 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -23,7 +23,6 @@ const MSG = { preset: (name: string) => `プリセット: ${name}`, port: (p: number) => `ポート: ${p}`, memory: (on: boolean) => `記憶: ${on ? "有効" : "無効"}`, - plan: (name: string) => `プラン: ${name}`, feed: (on: boolean) => `フィード: ${on ? "有効" : "無効"}`, enrichment: "エンリッチメント: 有効", listening: (url: string) => `リッスン中: ${url}`, @@ -37,7 +36,6 @@ const MSG = { preset: (name: string) => `Preset: ${name}`, port: (p: number) => `Port: ${p}`, memory: (on: boolean) => `Memory: ${on ? "enabled" : "disabled"}`, - plan: (name: string) => `Plan: ${name}`, feed: (on: boolean) => `Feed: ${on ? "enabled" : "disabled"}`, enrichment: "Enrichment: enabled", listening: (url: string) => `Listening on ${url}`, @@ -83,19 +81,16 @@ export async function serveCommand(options: { port?: string; host?: string }): P ctx.reportGenerator = reportGenerator; ctx.passportGenerator = passportGenerator; - // Team stores (Max tier only, always initialized for API but gated) + // Team stores let teamMemberStore: MemberStore | undefined; let teamAuditStore: TeamAuditStore | undefined; let teamMemoryStore: TeamMemoryStore | undefined; - const isMaxTier = ctx.gate?.canUseTeamAdmin(); - if (isMaxTier) { - try { - teamMemberStore = new MemberStore(); - teamAuditStore = new TeamAuditStore(); - teamMemoryStore = new TeamMemoryStore(); - } catch { - // Team DB init failure is non-fatal - } + try { + teamMemberStore = new MemberStore(); + teamAuditStore = new TeamAuditStore(); + teamMemoryStore = new TeamMemoryStore(); + } catch { + // Team DB init failure is non-fatal } // Telemetry auto-submission (every 6 hours) @@ -120,7 +115,6 @@ export async function serveCommand(options: { port?: string; host?: string }): P console.log(` ${m.preset(ctx.engine.getPreset().name)}`); console.log(` ${m.port(port)}`); console.log(` ${m.memory(Boolean(ctx.store))}`); - console.log(` ${m.plan(ctx.license?.plan ?? "free")}`); console.log(` ${m.feed(Boolean(ctx.feedClient))}`); console.log(` ${m.enrichment}`); console.log(` Hook: POST http://localhost:${port}/hook`); diff --git a/packages/cli/src/commands/stats.ts b/packages/cli/src/commands/stats.ts new file mode 100644 index 0000000..20b7366 --- /dev/null +++ b/packages/cli/src/commands/stats.ts @@ -0,0 +1,61 @@ +import { DecisionStore } from "@clawguard/memory"; +import chalk from "chalk"; +import { detectLocale } from "../locale.js"; + +const MSG = { + ja: { + title: "ClawGuard 判断サマリー", + today: "今日", + allTime: "全期間", + autoAllowed: "自動許可(確認を節約)", + totalDecisions: "判断総数", + allowed: "許可", + confirmed: "確認要求", + denied: "拒否", + agents: "エージェント数", + noData: "まだ判断データがありません。claw-guard init を実行してAIエージェントを使い始めてください。", + }, + en: { + title: "ClawGuard Decision Summary", + today: "Today", + allTime: "All Time", + autoAllowed: "Auto-allowed (confirmations saved)", + totalDecisions: "Total decisions", + allowed: "Allowed", + confirmed: "Confirmations", + denied: "Denied", + agents: "Agents", + noData: "No decision data yet. Run claw-guard init and start using your AI agent.", + }, +}; + +export async function statsCommand(): Promise { + const m = MSG[detectLocale()]; + const store = new DecisionStore(); + + try { + const all = store.getStatsSummary(); + if (all.total === 0 && all.autoAllowed === 0) { + console.log(m.noData); + return; + } + + const today = store.getTodayStats(); + + console.log(chalk.bold(`\n${m.title}\n`)); + + console.log(chalk.underline(m.today)); + console.log(` ${chalk.green(`${today.autoAllowed}`)} ${m.autoAllowed}`); + console.log(` ${today.total} ${m.totalDecisions} | ${chalk.green(today.allowed)} ${m.allowed} | ${chalk.yellow(today.confirmed)} ${m.confirmed} | ${chalk.red(today.denied)} ${m.denied}`); + + console.log(chalk.underline(`\n${m.allTime}`)); + console.log(` ${chalk.green.bold(`${all.autoAllowed}`)} ${m.autoAllowed}`); + console.log(` ${all.total} ${m.totalDecisions} | ${chalk.green(all.allowed)} ${m.allowed} | ${chalk.yellow(all.confirmed)} ${m.confirmed} | ${chalk.red(all.denied)} ${m.denied}`); + if (all.agents > 0) { + console.log(` ${all.agents} ${m.agents}`); + } + console.log(); + } finally { + store.close(); + } +} diff --git a/packages/cli/src/commands/upgrade.ts b/packages/cli/src/commands/upgrade.ts deleted file mode 100644 index bb4bbcd..0000000 --- a/packages/cli/src/commands/upgrade.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { LicenseManager } from "@clawguard/billing"; -import chalk from "chalk"; -import { detectLocale } from "../locale.js"; - -const MSG = { - ja: { - removed: "ライセンスを削除しました。Free プランに戻りました。", - invalidKey: "無効なライセンスキーです。Free プランのままです。", - activated: (plan: string) => `ライセンス有効化: ${plan} プラン`, - dailyFeed: "日次フィード", - reputation: "評判ネットワーク", - marketplace: "マーケットプレイス", - enabled: "有効", - disabled: "無効", - title: "ClawGuard サブスクリプション", - currentPlan: (plan: string) => `現在のプラン: ${plan}`, - upgradeOptions: "アップグレード:", - proDesc: "$12/月 — 日次フィード、評判ネットワーク、マーケットプレイス、ルール無制限", - maxDesc: "$39/月 — Pro全機能 + チーム管理、クロスチーム記憶", - activate: (cmd: string) => `有効化: ${cmd}`, - features: "機能:", - rules: "ルール: 無制限", - feed: "フィード", - }, - en: { - removed: "License removed. Reverted to Free plan.", - invalidKey: "Invalid license key. Staying on free plan.", - activated: (plan: string) => `License activated: ${plan} plan`, - dailyFeed: "Daily feed", - reputation: "Reputation network", - marketplace: "Marketplace", - enabled: "enabled", - disabled: "disabled", - title: "ClawGuard Subscription", - currentPlan: (plan: string) => `Current plan: ${plan}`, - upgradeOptions: "Upgrade options:", - proDesc: "$12/month — Daily feed, reputation network, marketplace, unlimited rules", - maxDesc: "$39/month — All Pro features + team management, cross-team memory", - activate: (cmd: string) => `Activate: ${cmd}`, - features: "Features:", - rules: "Rules: unlimited", - feed: "Feed", - }, -}; - -export async function upgradeCommand( - plan?: string, - options?: { key?: string; remove?: boolean }, -): Promise { - const m = MSG[detectLocale()]; - const manager = new LicenseManager(); - const onOff = (v: boolean) => (v ? m.enabled : m.disabled); - - if (options?.remove) { - manager.removeLicense(); - console.log(`${chalk.green("✓")} ${m.removed}`); - return; - } - - if (options?.key) { - const license = manager.saveLicense(options.key); - if (license.plan === "free") { - console.log(chalk.red(m.invalidKey)); - } else { - console.log(`${chalk.green("✓")} ${m.activated(chalk.bold(license.plan.toUpperCase()))}`); - console.log( - ` ${m.dailyFeed}: ${license.features.feed_interval === "daily" ? m.enabled : m.disabled}`, - ); - console.log(` ${m.reputation}: ${onOff(license.features.reputation_network)}`); - console.log(` ${m.marketplace}: ${onOff(license.features.marketplace)}`); - } - return; - } - - const current = manager.getCurrentLicense(); - console.log(chalk.bold(m.title)); - console.log(` ${m.currentPlan(chalk.cyan(current.plan.toUpperCase()))}`); - console.log(""); - - if (current.plan === "free") { - console.log(m.upgradeOptions); - console.log(` ${chalk.green("Pro")} ${m.proDesc}`); - console.log(` ${chalk.blue("Max")} ${m.maxDesc}`); - console.log(""); - console.log(m.activate(chalk.cyan("claw-guard upgrade --key "))); - } else { - console.log(m.features); - console.log(` ${m.rules}`); - console.log(` ${m.feed}: ${current.features.feed_interval}`); - console.log(` ${m.reputation}: ${onOff(current.features.reputation_network)}`); - console.log(` ${m.marketplace}: ${onOff(current.features.marketplace)}`); - } -} diff --git a/packages/cli/src/engine-factory.ts b/packages/cli/src/engine-factory.ts index ad4b1f1..a6b5499 100644 --- a/packages/cli/src/engine-factory.ts +++ b/packages/cli/src/engine-factory.ts @@ -3,7 +3,6 @@ import { resolve } from "node:path"; import type { ClaudeHookOutput } from "@clawguard/adapter-claude"; import { buildHookOutput, - isVscodeEnvironment, mapToToolRequest, parseHookInput, shouldIntervene, @@ -96,15 +95,13 @@ export function createEngineContext(overrideLang?: Lang): EngineContext { const license = licenseManager.getCurrentLicense(); const gate = new FeatureGate(license); - // Core rules (always loaded) + // Core rules let rules = loadRulesFromDir(findRulesDir()); - // Phase 2 rules (pro/max only) - if (gate.canLoadPhase2Rules()) { - const phase2Dir = findPhase2RulesDir(); - if (phase2Dir) { - rules = [...rules, ...loadRulesFromDir(phase2Dir)]; - } + // Phase 2 rules + const phase2Dir = findPhase2RulesDir(); + if (phase2Dir) { + rules = [...rules, ...loadRulesFromDir(phase2Dir)]; } // Feed rules (overlay) @@ -117,16 +114,9 @@ export function createEngineContext(overrideLang?: Lang): EngineContext { rules = mergeRules(rules, feedCompiled); } - // Marketplace packs (pro/max only) - if (gate.canUseMarketplace()) { - const marketplace = new MarketplaceClient(); - rules = [...rules, ...marketplace.loadInstalledRules()]; - } - - // Free plan: only phase 0 rules - if (!gate.canLoadPhase2Rules()) { - rules = rules.filter((r) => (r.meta?.phase ?? 0) === 0); - } + // Marketplace packs + const marketplace = new MarketplaceClient(); + rules = [...rules, ...marketplace.loadInstalledRules()]; const engine = new PolicyEngine(rules, preset, feedVersion, config.project_overrides); const writer = new AuditWriter(); @@ -171,11 +161,11 @@ export function evaluateHookRequest(rawInput: string, ctx: EngineContext): EvalR const request = mapToToolRequest(hookInput); const decision = ctx.engine.evaluate(request); - const isVSCode = ctx.vsCodeCompat ?? isVscodeEnvironment(); + const contentHash = DecisionStore.hashContent(request.content); - // Session allowlist: if a non-high-risk confirm was already force-denied and user retried, allow it - if (isVSCode && decision.action === "confirm" && decision.risk !== "high" && ctx.store) { + // Session allowlist: auto-allow if the same non-high-risk operation was already approved in this session + if (decision.action === "confirm" && decision.risk !== "high" && ctx.store) { if (ctx.store.isSessionAllowed(request.context.session_id, contentHash, decision.rule_id)) { decision.action = "allow"; } @@ -196,8 +186,8 @@ export function evaluateHookRequest(rawInput: string, ctx: EngineContext): EvalR const output = buildHookOutput(decision, ctx.lang, ctx.vsCodeCompat); - // Record soft force-deny to session allowlist (non-high-risk confirm in VSCode) - if (isVSCode && decision.action === "confirm" && decision.risk !== "high" && ctx.store && output) { + // Record confirm decisions to session allowlist for future auto-allow (all environments) + if (decision.action === "confirm" && decision.risk !== "high" && ctx.store && output) { ctx.store.recordSessionAllow(request.context.session_id, contentHash, decision.rule_id); } @@ -216,7 +206,7 @@ export async function evaluateHookRequestAsync( const request = mapToToolRequest(hookInput); let decision = ctx.engine.evaluate(request); - const isVSCode = ctx.vsCodeCompat ?? isVscodeEnvironment(); + const contentHash = DecisionStore.hashContent(request.content); if (ctx.enricher && decision.action !== "allow") { @@ -227,8 +217,8 @@ export async function evaluateHookRequestAsync( } } - // Session allowlist: if a non-high-risk confirm was already force-denied and user retried, allow it - if (isVSCode && decision.action === "confirm" && decision.risk !== "high" && ctx.store) { + // Session allowlist: auto-allow if the same non-high-risk operation was already approved in this session + if (decision.action === "confirm" && decision.risk !== "high" && ctx.store) { if (ctx.store.isSessionAllowed(request.context.session_id, contentHash, decision.rule_id)) { decision.action = "allow"; } @@ -249,8 +239,8 @@ export async function evaluateHookRequestAsync( const output = buildHookOutput(decision, ctx.lang, ctx.vsCodeCompat); - // Record soft force-deny to session allowlist (non-high-risk confirm in VSCode) - if (isVSCode && decision.action === "confirm" && decision.risk !== "high" && ctx.store && output) { + // Record confirm decisions to session allowlist for future auto-allow (all environments) + if (decision.action === "confirm" && decision.risk !== "high" && ctx.store && output) { ctx.store.recordSessionAllow(request.context.session_id, contentHash, decision.rule_id); } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 88f4271..b06ab3c 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -13,15 +13,16 @@ import { replayCommand } from "./commands/replay.js"; import { reportCommand } from "./commands/report.js"; import { serveCommand } from "./commands/serve.js"; import { skillsCommand } from "./commands/skills.js"; +import { statsCommand } from "./commands/stats.js"; import { teamCommand } from "./commands/team.js"; import { testCommand } from "./commands/test.js"; -import { upgradeCommand } from "./commands/upgrade.js"; + const program = new Command(); program .name("claw-guard") - .description("AI agent security companion — defend, audit, update") + .description("AI agent memory — fewer prompts, smarter decisions") .version("0.1.0"); program @@ -42,6 +43,11 @@ program .description("Validate rules, engine, and configuration") .action(testCommand); +program + .command("stats") + .description("View auto-allow count and decision summary") + .action(statsCommand); + program .command("log") .description("View audit log") @@ -74,13 +80,6 @@ program .description("Manage rule packs (installed/install/remove/curate)") .action(marketplaceCommand); -program - .command("upgrade [plan]") - .description("Manage your ClawGuard license") - .option("--key ", "License key") - .option("--remove", "Remove license key") - .action(upgradeCommand); - program .command("passport") .description("Manage your security passport") diff --git a/packages/lp/package.json b/packages/lp/package.json index 31344e4..706e8c9 100644 --- a/packages/lp/package.json +++ b/packages/lp/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "type": "module", - "description": "ClawGuard landing page — AI agent security platform", + "description": "ClawGuard landing page — AI agent intelligence layer", "scripts": { "dev": "vite", "build": "tsc -b && vite build", diff --git a/packages/lp/src/__tests__/content.test.ts b/packages/lp/src/__tests__/content.test.ts index 0476b0a..c03a05f 100644 --- a/packages/lp/src/__tests__/content.test.ts +++ b/packages/lp/src/__tests__/content.test.ts @@ -30,7 +30,7 @@ describe("LP content", () => { it("EN and JP have same number of pricing plans", () => { expect(en.pricing.cards.length).toBe(jp.pricing.cards.length); - expect(en.pricing.cards.length).toBe(3); + expect(en.pricing.cards.length).toBe(1); }); it("all pricing plans have CTA", () => { diff --git a/packages/lp/src/components/HeroSection.tsx b/packages/lp/src/components/HeroSection.tsx index 391a462..2b8170a 100644 --- a/packages/lp/src/components/HeroSection.tsx +++ b/packages/lp/src/components/HeroSection.tsx @@ -54,20 +54,10 @@ export function HeroSection({ content }: Props) {

{content.terminal.ready}

{content.terminal.agentSession}

{content.terminal.command}

-

- {"⚠"} CONFIRM{" "} - {content.terminal.confirmLabel} +

+ {"✓"} {content.terminal.confirmLabel}{" "}

{content.terminal.confirmDetail}

-

- [ - allow - ] [ - deny - ] [ - explain - ] -

diff --git a/packages/lp/src/components/PricingCards.tsx b/packages/lp/src/components/PricingCards.tsx index cbb3e6d..9d3d1d7 100644 --- a/packages/lp/src/components/PricingCards.tsx +++ b/packages/lp/src/components/PricingCards.tsx @@ -5,89 +5,53 @@ interface Props { } export function PricingCards({ content }: Props) { + const card = content.cards[0]; return (
-

{content.title}

- {content.earlyAccessNote && ( -

- {content.earlyAccessNote} -

- )} - {!content.earlyAccessNote &&
} -
- {content.cards.map((card) => ( -
- {card.highlighted && card.highlightLabel && ( -
- {card.highlightLabel} -
- )} -

{card.name}

-
- {card.price !== "$0" && !card.comingSoon ? ( - <> - - {card.price} - - $0 - - ) : ( - {card.price} - )} - {card.period} +

{content.title}

+
+
+ {card.highlightLabel && ( +
+ {card.highlightLabel}
-
    - {card.features.map((feature) => ( -
  • - - {feature} -
  • - ))} -
- {card.comingSoon ? ( - - {card.comingSoonLabel ?? card.cta} - - ) : ( - - {card.cta} - - )} + )} +

{card.name}

+
+ {card.price} + {card.period}
- ))} +
    + {card.features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+ + {card.cta} + +
diff --git a/packages/lp/src/content/en.ts b/packages/lp/src/content/en.ts index 98b17bf..d4e526a 100644 --- a/packages/lp/src/content/en.ts +++ b/packages/lp/src/content/en.ts @@ -3,119 +3,89 @@ import type { LPContent } from "../types"; export const en: LPContent = { nav: { brand: "ClawGuard", - cta: "Get Started Free", + cta: "Get Started", links: [ { label: "Features", href: "#features" }, - { label: "Pricing", href: "#pricing" }, { label: "Docs", href: "https://github.com/Goki602/ClawGuard" }, ], }, hero: { - headline: "Stop AI Agents Before They Break Things", + headline: "Your AI Agents Ask Too Many Questions", subheadline: - "ClawGuard intercepts dangerous commands — rm -rf, git push --force, curl|bash — before your AI agent executes them. One install, real-time protection.", - cta: "Get Started Free", + "ClawGuard remembers what's safe. Once you approve an operation, it's auto-allowed — across sessions, across agents, across tools. Dangerous commands still get caught. But you'll barely notice.", + cta: "Get Started", secondaryCta: "View on GitHub", terminal: { - ready: "Ready.", + ready: "Ready. 47 past decisions loaded.", agentSession: "--- AI agent session ---", - command: "$ rm -rf /tmp/project", - confirmLabel: "Bulk file deletion", - confirmDetail: "Wrong path could destroy your entire project.", + command: "$ npm install express", + confirmLabel: "Auto-allowed", + confirmDetail: "You approved this 3 days ago. 94% of developers allow it.", }, }, features: { - title: "What ClawGuard Actually Does", + title: "Less Noise. Smarter Agents.", cards: [ { - title: "Intercepts Dangerous Commands", + title: "Remembers What's Safe", description: - "When your AI agent tries rm -rf /, git push --force, or curl|bash, ClawGuard catches it in under 100ms and asks you to confirm or blocks it. 12 built-in rules cover destructive ops, secret leaks, and untrusted installs.", - icon: "\u{1F6E1}", + "You said yes to npm install express once. ClawGuard remembers. Next time — auto-allowed. No popup, no interruption. Your approval decisions persist across sessions.", + icon: "\u{1F9E0}", }, { - title: "Works With Any AI Agent", + title: "Works Across Every AI Agent", description: - "Claude Code, Codex, MCP — ClawGuard hooks into the tool-call layer. No Docker required. Install once, protect every agent in your workflow.", + "Claude Code, Codex, Cursor — your trust decisions travel with you. Approve something in Claude, it's remembered in Codex. One brain for all your agents.", icon: "\u{1F517}", }, { - title: "Smarter With Every User", + title: "Community Intelligence", description: - 'When you allow or deny a command, anonymized data improves detection for everyone. "87% of developers allowed this npm package" — context that helps you decide faster.', - icon: "\u{1F310}", + '"94% of developers allowed this npm package." When ClawGuard does ask, it shows you what the community decided. Makes the remaining confirmations take 2 seconds.', + icon: "\u{1F465}", }, { - title: "Prove Your Sessions Are Monitored", + title: "Safety Net Built In", description: - "Generate a GitHub badge that proves your AI agent sessions are continuously audited. Show clients and teammates that every command was reviewed.", - icon: "\u{1F4DC}", + "rm -rf, git push --force, curl|bash — the truly dangerous stuff is always caught. 12 built-in rules protect against irreversible damage. Security isn't the headline, but it's always there.", + icon: "\u{1F6E1}", }, { - title: "Replay Any AI Session", + title: "Full Session History", description: - "Something went wrong? Replay the entire decision chain: what the agent tried, what was blocked, what was allowed, and why. Full causal analysis.", - icon: "\u{1F50D}", + "Every decision — auto-allowed, confirmed, or blocked — is logged. Replay any session, trace any command chain. When something goes wrong, you can see exactly what happened.", + icon: "\u{1F4CB}", }, { - title: "Signed Threat Updates", + title: "Always Up to Date", description: - "New CVEs, malicious packages, revoked rules — delivered as signed feeds. Free: weekly. Pro/Max: daily. Your rules stay current without manual work.", + "New threats, new safe packages, community decisions — delivered daily as signed updates. Your agent gets smarter without you doing anything.", icon: "\u{1F4E1}", }, ], }, pricing: { - title: "Simple, Transparent Pricing", - earlyAccessNote: - "Pro plan is free during Early Access \u2014 install and get full Pro features today.", + title: "100% Free & Open Source", cards: [ { - name: "Free", + name: "ClawGuard", price: "$0", period: "forever", features: [ - "12 core security rules", - "Weekly threat feed (rules + reputation)", - "Basic replay (24h)", - "Community reputation data (read-only)", - "Single agent support", - ], - cta: "Get Started Free", - href: "#how-it-works", - }, - { - name: "Pro", - price: "$12", - period: "/month", - features: [ - "All security rules", - "Daily threat feed (rules + reputation + CVE)", - "Full incident replay", + "Cross-agent decision memory (auto-allow across tools)", + "Community intelligence (see what others approved)", + "All safety rules (core + community)", + "Daily signed updates (new threats + community data)", + "Full session replay with causal analysis", "Rule Marketplace (install & publish)", - "Security Passport + badge", - "Skills AV scanning", - ], - cta: "Start Free \u2014 Early Access", - href: "#how-it-works", - highlighted: true, - highlightLabel: "Popular", - }, - { - name: "Max", - price: "$39", - period: "/month", - features: [ - "Everything in Pro", + "Security Passport + GitHub badge", "Team & org management", - "Cross-team memory sharing", - "Centralized audit dashboard", - "Organization-wide passport", + "Skills security scanning", ], - cta: "Coming Soon", + cta: "Get Started", href: "#how-it-works", - comingSoon: true, - comingSoonLabel: "Coming Soon", + highlighted: true, + highlightLabel: "All Features Included", }, ], }, @@ -129,15 +99,15 @@ export const en: LPContent = { }, { step: "2", - title: "Choose Your Level", + title: "Choose How Quiet", description: - "Pick a preset: guardian (strict), balanced (recommended), or expert (minimal interruption). One line in clawguard.yaml.", + "Pick a preset: guardian (asks often), balanced (recommended), or expert (almost silent). One line in clawguard.yaml.", }, { step: "3", - title: "Protected", + title: "It Learns", description: - "Next time your AI agent runs rm -rf or git push --force, ClawGuard intercepts it, explains the risk, and asks you to confirm or deny. Every decision is logged.", + "Use your AI agent normally. ClawGuard learns from every decision you make. Within a day, most confirmations disappear — only new or dangerous operations still ask.", }, ], }, @@ -176,7 +146,7 @@ export const en: LPContent = { legal: "Legal", devex: "Developers", support: "Support", - description: "AI Agent Security Platform", + description: "AI Agent Intelligence Layer", }, legal: [ { @@ -207,7 +177,6 @@ export const en: LPContent = { }, ], support: [ - { label: "Pricing", href: "#pricing" }, { label: "Issues & FAQ", href: "https://github.com/Goki602/ClawGuard/issues", diff --git a/packages/lp/src/content/jp.ts b/packages/lp/src/content/jp.ts index 03ef282..8652828 100644 --- a/packages/lp/src/content/jp.ts +++ b/packages/lp/src/content/jp.ts @@ -3,10 +3,9 @@ import type { LPContent } from "../types"; export const jp: LPContent = { nav: { brand: "ClawGuard", - cta: "\u7121\u6599\u3067\u59CB\u3081\u308B", + cta: "\u59CB\u3081\u308B", links: [ { label: "\u6A5F\u80FD", href: "#features" }, - { label: "\u6599\u91D1", href: "#pricing" }, { label: "\u30C9\u30AD\u30E5\u30E1\u30F3\u30C8", href: "https://github.com/Goki602/ClawGuard/blob/main/README.ja.md", @@ -14,114 +13,83 @@ export const jp: LPContent = { ], }, hero: { - headline: - "AI\u30A8\u30FC\u30B8\u30A7\u30F3\u30C8\u306E\u5371\u967A\u306A\u64CD\u4F5C\u3092\u3001\u5B9F\u884C\u524D\u306B\u6B62\u3081\u308B", + headline: "AIエージェント、聞きすぎじゃない?", subheadline: - "rm -rf\u3001git push --force\u3001curl|bash \u2014 ClawGuard\u306FAI\u30A8\u30FC\u30B8\u30A7\u30F3\u30C8\u304C\u5371\u967A\u306A\u30B3\u30DE\u30F3\u30C9\u3092\u5B9F\u884C\u3059\u308B\u524D\u306B\u30A4\u30F3\u30BF\u30FC\u30BB\u30D7\u30C8\uFF08\u6A2A\u53D6\u308A\uFF09\u3057\u3066\u5224\u65AD\u3057\u307E\u3059\u3002\u30A4\u30F3\u30B9\u30C8\u30FC\u30EB1\u56DE\u3067\u3001\u30EA\u30A2\u30EB\u30BF\u30A4\u30E0\u4FDD\u8B77\u3002", - cta: "\u7121\u6599\u3067\u59CB\u3081\u308B", - secondaryCta: "GitHub\u3067\u898B\u308B", + "ClawGuardが覚えておきます。一度OKした操作は、次から自動で通す。セッション、エージェント、ツールをまたいでも。危ない操作はちゃんと止める。でも気づかないくらい静か。", + cta: "始める", + secondaryCta: "GitHubで見る", terminal: { - ready: "\u6E96\u5099\u5B8C\u4E86\u3002", - agentSession: "--- AI\u30A8\u30FC\u30B8\u30A7\u30F3\u30C8\u30BB\u30C3\u30B7\u30E7\u30F3 ---", - command: "$ rm -rf /tmp/project", - confirmLabel: "\u4E00\u62EC\u30D5\u30A1\u30A4\u30EB\u524A\u9664", + ready: "準備完了。過去の判断47件を読み込みました。", + agentSession: "--- AIエージェントセッション ---", + command: "$ npm install express", + confirmLabel: "自動許可", confirmDetail: - "\u30D1\u30B9\u3092\u9593\u9055\u3048\u308B\u3068\u30D7\u30ED\u30B8\u30A7\u30AF\u30C8\u5168\u4F53\u304C\u6D88\u3048\u308B\u53EF\u80FD\u6027\u304C\u3042\u308A\u307E\u3059\u3002", + "3日前にOK済み。開発者の94%が許可しています。", }, }, features: { - title: "ClawGuard\u304C\u5B9F\u969B\u306B\u3084\u308B\u3053\u3068", + title: "もっと静かに。もっと賢く。", cards: [ { - title: - "\u5371\u967A\u306A\u30B3\u30DE\u30F3\u30C9\u3092\u30A4\u30F3\u30BF\u30FC\u30BB\u30D7\u30C8", + title: "OKした操作を覚えてくれる", description: - "AI\u30A8\u30FC\u30B8\u30A7\u30F3\u30C8\u304C rm -rf /\u3001git push --force\u3001curl|bash \u3092\u5B9F\u884C\u3057\u3088\u3046\u3068\u3059\u308B\u3068\u3001ClawGuard\u304C100ms\u4EE5\u5185\u306B\u30AD\u30E3\u30C3\u30C1\u3002\u78BA\u8A8D\u3092\u6C42\u3081\u308B\u304B\u3001\u30D6\u30ED\u30C3\u30AF\u3057\u307E\u3059\u300212\u306E\u7D44\u307F\u8FBC\u307F\u30EB\u30FC\u30EB\u304C\u7834\u58CA\u7684\u64CD\u4F5C\u30FB\u79D8\u5BC6\u60C5\u5831\u306E\u6F0F\u6D29\u30FB\u4FE1\u983C\u3067\u304D\u306A\u3044\u30A4\u30F3\u30B9\u30C8\u30FC\u30EB\u3092\u30AB\u30D0\u30FC\u3002", - icon: "\u{1F6E1}", + "npm install expressを一度OKしたら、それで終わり。次からは自動で通ります。ポップアップも中断もなし。セッションが変わっても覚えたまま。", + icon: "\u{1F9E0}", }, { - title: "\u3042\u3089\u3086\u308BAI\u30A8\u30FC\u30B8\u30A7\u30F3\u30C8\u306B\u5BFE\u5FDC", + title: "どのAIエージェントでも使える", description: - "Claude Code\u3001Codex\u3001MCP \u2014 ClawGuard\u306F\u30C4\u30FC\u30EB\u30B3\u30FC\u30EB\u5C64\uFF08AI\u304C\u30B3\u30DE\u30F3\u30C9\u3092\u5B9F\u884C\u3059\u308B\u4ED5\u7D44\u307F\uFF09\u306B\u30D5\u30C3\u30AF\u3057\u307E\u3059\u3002Docker\u4E0D\u8981\u30021\u56DE\u30A4\u30F3\u30B9\u30C8\u30FC\u30EB\u3059\u308C\u3070\u3001\u5168\u30A8\u30FC\u30B8\u30A7\u30F3\u30C8\u3092\u4FDD\u8B77\u3002", + "Claude Code、Codex、Cursor — OKした判断はどこにでもついていきます。Claudeで許可したものはCodexでもそのまま。エージェントが変わっても、頭脳はひとつ。", icon: "\u{1F517}", }, { - title: "\u4F7F\u3046\u4EBA\u304C\u5897\u3048\u308B\u307B\u3069\u8CE2\u304F\u306A\u308B", + title: "みんなの判断が見える", description: - "\u3042\u306A\u305F\u304C\u30B3\u30DE\u30F3\u30C9\u3092\u8A31\u53EF\u30FB\u62D2\u5426\u3059\u308B\u305F\u3073\u306B\u3001\u533F\u540D\u30C7\u30FC\u30BF\u304C\u5168\u4F53\u306E\u691C\u77E5\u7CBE\u5EA6\u3092\u5411\u4E0A\u3055\u305B\u307E\u3059\u3002\u300C\u958B\u767A\u8005\u306E87%\u304C\u3053\u306Enpm\u30D1\u30C3\u30B1\u30FC\u30B8\u3092\u8A31\u53EF\u300D\u2014 \u5224\u65AD\u3092\u52A9\u3051\u308B\u30B3\u30F3\u30C6\u30AD\u30B9\u30C8\u3002", - icon: "\u{1F310}", + "「開発者の94%がこのパッケージを許可」。確認が出たとき、他の開発者がどうしたかも一緒に表示。迷う時間がぐっと減ります。", + icon: "\u{1F465}", }, { - title: "\u76E3\u8996\u6E08\u307F\u30BB\u30C3\u30B7\u30E7\u30F3\u3092\u8A3C\u660E", + title: "目立たないけど、しっかり守る", description: - "AI\u30A8\u30FC\u30B8\u30A7\u30F3\u30C8\u30BB\u30C3\u30B7\u30E7\u30F3\u304C\u7D99\u7D9A\u7684\u306B\u76E3\u67FB\u3055\u308C\u3066\u3044\u308B\u3053\u3068\u3092\u8A3C\u660E\u3059\u308BGitHub\u30D0\u30C3\u30B8\u3092\u751F\u6210\u3002\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u3084\u30C1\u30FC\u30E0\u30E1\u30A4\u30C8\u306B\u3001\u5168\u30B3\u30DE\u30F3\u30C9\u304C\u30EC\u30D3\u30E5\u30FC\u6E08\u307F\u3067\u3042\u308B\u3053\u3068\u3092\u793A\u305B\u307E\u3059\u3002", - icon: "\u{1F4DC}", + "rm -rf、git push --force、curl|bash — 本当にヤバい操作はちゃんと止めます。12のルールが最初から入っていて、取り返しのつかない事故を防ぎます。", + icon: "\u{1F6E1}", }, { - title: "AI\u30BB\u30C3\u30B7\u30E7\u30F3\u3092\u4E38\u3054\u3068\u518D\u73FE", + title: "全部記録に残る", description: - "\u4F55\u304B\u554F\u984C\u304C\u8D77\u304D\u305F\uFF1F\u5224\u65AD\u30C1\u30A7\u30FC\u30F3\u5168\u4F53\u3092\u518D\u73FE\uFF1A\u30A8\u30FC\u30B8\u30A7\u30F3\u30C8\u304C\u4F55\u3092\u8A66\u307F\u3001\u4F55\u304C\u30D6\u30ED\u30C3\u30AF\u3055\u308C\u3001\u4F55\u304C\u8A31\u53EF\u3055\u308C\u3001\u306A\u305C\u304B\u3002\u5B8C\u5168\u306A\u56E0\u679C\u5206\u6790\u3002", - icon: "\u{1F50D}", + "自動許可も、確認も、ブロックも — 判断はすべて記録されます。セッションの再生やコマンドの流れも追えるので、何かあったときに「何が起きたか」がすぐわかります。", + icon: "\u{1F4CB}", }, { - title: "\u7F72\u540D\u4ED8\u304D\u8105\u5A01\u30A2\u30C3\u30D7\u30C7\u30FC\u30C8", + title: "放っておいても賢くなる", description: - "\u65B0\u3057\u3044CVE\uFF08\u8106\u5F31\u6027\u60C5\u5831\uFF09\u3001\u60AA\u610F\u3042\u308B\u30D1\u30C3\u30B1\u30FC\u30B8\u3001\u5931\u52B9\u30EB\u30FC\u30EB \u2014 \u7F72\u540D\u4ED8\u304D\u30D5\u30A3\u30FC\u30C9\u3067\u914D\u4FE1\u3002Free: \u9031\u6B21\u3002Pro/Max: \u6BCE\u65E5\u3002\u624B\u52D5\u4F5C\u696D\u306A\u3057\u3067\u30EB\u30FC\u30EB\u304C\u6700\u65B0\u306B\u3002", + "新しい脅威、安全なパッケージ、みんなの判断データ。署名付きで毎日届きます。何もしなくても、エージェントは日々賢くなっていきます。", icon: "\u{1F4E1}", }, ], }, pricing: { - title: "\u30B7\u30F3\u30D7\u30EB\u3067\u900F\u660E\u306A\u6599\u91D1\u4F53\u7CFB", - earlyAccessNote: - "Early Access\u671F\u9593\u4E2D\u306FPro\u30D7\u30E9\u30F3\u307E\u3067\u7121\u6599 \u2014 \u30A4\u30F3\u30B9\u30C8\u30FC\u30EB\u3059\u308B\u3060\u3051\u3067Pro\u6A5F\u80FD\u304C\u4F7F\u3048\u307E\u3059\u3002", + title: "\u5B8C\u5168\u7121\u6599\u30FB\u30AA\u30FC\u30D7\u30F3\u30BD\u30FC\u30B9", cards: [ { - name: "Free", + name: "ClawGuard", price: "$0", - period: "\u6C38\u4E45\u7121\u6599", - features: [ - "12\u306E\u30B3\u30A2\u30BB\u30AD\u30E5\u30EA\u30C6\u30A3\u30EB\u30FC\u30EB", - "\u9031\u6B21\u8105\u5A01\u30D5\u30A3\u30FC\u30C9", - "\u57FA\u672C\u30EA\u30D7\u30EC\u30A4\uFF0824\u6642\u9593\uFF09", - "\u30B3\u30DF\u30E5\u30CB\u30C6\u30A3\u95B2\u89A7\u306E\u307F", - "\u5358\u4E00\u30A8\u30FC\u30B8\u30A7\u30F3\u30C8\u5BFE\u5FDC", - ], - cta: "\u7121\u6599\u3067\u59CB\u3081\u308B", - href: "#how-it-works", - }, - { - name: "Pro", - price: "$12", - period: "/\u6708", + period: "永久無料", features: [ - "\u5168\u30BB\u30AD\u30E5\u30EA\u30C6\u30A3\u30EB\u30FC\u30EB", - "\u65E5\u6B21\u8105\u5A01\u30D5\u30A3\u30FC\u30C9\uFF08\u30EB\u30FC\u30EB+\u8A55\u5224+CVE\uFF09", - "\u5B8C\u5168\u306A\u30A4\u30F3\u30B7\u30C7\u30F3\u30C8\u30EA\u30D7\u30EC\u30A4", - "\u30EB\u30FC\u30EB\u30DE\u30FC\u30B1\u30C3\u30C8\u30D7\u30EC\u30A4\u30B9\u30A2\u30AF\u30BB\u30B9", - "\u30BB\u30AD\u30E5\u30EA\u30C6\u30A3\u30D1\u30B9\u30DD\u30FC\u30C8 + \u30D0\u30C3\u30B8", - "\u30B9\u30AD\u30EBAV\u30B9\u30AD\u30E3\u30F3", + "ツールをまたいで判断を記憶・自動許可", + "みんなの判断データが見える(コミュニティ知性)", + "全セーフティルール(コア + コミュニティ)", + "毎日届く署名付きアップデート", + "セッションの完全な再生と原因分析", + "ルールマーケットプレイス(追加も公開も自由)", + "セキュリティパスポート + GitHubバッジ", + "チーム・組織での一括管理", + "スキルのセキュリティスキャン", ], - cta: "\u7121\u6599\u3067\u59CB\u3081\u308B\uFF08Early Access\uFF09", + cta: "\u59CB\u3081\u308B", href: "#how-it-works", highlighted: true, - highlightLabel: "\u4EBA\u6C17", - }, - { - name: "Max", - price: "$39", - period: "/\u6708", - features: [ - "Pro\u306E\u5168\u6A5F\u80FD\u3092\u542B\u3080", - "\u30C1\u30FC\u30E0\u30FB\u7D44\u7E54\u7BA1\u7406", - "\u30AF\u30ED\u30B9\u30C1\u30FC\u30E0\u30E1\u30E2\u30EA\u5171\u6709", - "\u4E00\u5143\u7BA1\u7406\u76E3\u67FB\u30C0\u30C3\u30B7\u30E5\u30DC\u30FC\u30C9", - "\u7D44\u7E54\u5168\u4F53\u306E\u30D1\u30B9\u30DD\u30FC\u30C8", - ], - cta: "\u6E96\u5099\u4E2D", - href: "#how-it-works", - comingSoon: true, - comingSoonLabel: "\u6E96\u5099\u4E2D", + highlightLabel: "\u5168\u6A5F\u80FD\u7121\u6599", }, ], }, @@ -135,15 +103,15 @@ export const jp: LPContent = { }, { step: "2", - title: "\u30EC\u30D9\u30EB\u3092\u9078\u3076", + title: "静かさレベルを選ぶ", description: - "\u30D7\u30EA\u30BB\u30C3\u30C8\u3092\u9078\u629E: guardian\uFF08\u53B3\u683C\uFF09\u3001balanced\uFF08\u304A\u3059\u3059\u3081\uFF09\u3001expert\uFF08\u6700\u5C0F\u9650\u306E\u4E2D\u65AD\uFF09\u3002clawguard.yaml\u306B1\u884C\u66F8\u304F\u3060\u3051\u3002", + "guardian(よく確認)、balanced(おすすめ)、expert(ほぼ無音)から選ぶだけ。設定はclawguard.yamlに1行。", }, { step: "3", - title: "\u4FDD\u8B77\u958B\u59CB", + title: "あとは使うだけ", description: - "\u6B21\u306BAI\u30A8\u30FC\u30B8\u30A7\u30F3\u30C8\u304C rm -rf \u3084 git push --force \u3092\u5B9F\u884C\u3057\u3088\u3046\u3068\u3059\u308B\u3068\u3001ClawGuard\u304C\u30A4\u30F3\u30BF\u30FC\u30BB\u30D7\u30C8\u3057\u3066\u30EA\u30B9\u30AF\u3092\u8AAC\u660E\u3057\u3001\u8A31\u53EF\u304B\u62D2\u5426\u304B\u3092\u78BA\u8A8D\u3057\u307E\u3059\u3002\u3059\u3079\u3066\u306E\u5224\u65AD\u304C\u8A18\u9332\u3055\u308C\u307E\u3059\u3002", + "いつも通りAIエージェントを使ってください。使うほど賢くなります。1日もすればほとんどの確認は消えて、聞いてくるのは初めての操作や危ない操作だけに。", }, ], }, @@ -185,8 +153,7 @@ export const jp: LPContent = { legal: "\u6CD5\u7684\u60C5\u5831", devex: "\u958B\u767A\u8005\u5411\u3051", support: "\u30B5\u30DD\u30FC\u30C8", - description: - "AI\u30A8\u30FC\u30B8\u30A7\u30F3\u30C8\u30BB\u30AD\u30E5\u30EA\u30C6\u30A3\u30D7\u30E9\u30C3\u30C8\u30D5\u30A9\u30FC\u30E0", + description: "AIエージェントの知性レイヤー", }, legal: [ { @@ -217,7 +184,6 @@ export const jp: LPContent = { }, ], support: [ - { label: "\u6599\u91D1", href: "#pricing" }, { label: "\u8CEA\u554F\u30FB\u4E0D\u5177\u5408\u5831\u544A", href: "https://github.com/Goki602/ClawGuard/issues", diff --git a/packages/memory/src/decision-store.ts b/packages/memory/src/decision-store.ts index 49f8fb5..609691d 100644 --- a/packages/memory/src/decision-store.ts +++ b/packages/memory/src/decision-store.ts @@ -154,6 +154,54 @@ export class DecisionStore { .run(`-${maxAgeHours} hours`); } + getAutoAllowCount(sessionId?: string): number { + if (sessionId) { + const row = this.db + .prepare("SELECT COUNT(*) as count FROM session_allowlist WHERE session_id = ?") + .get(sessionId) as { count: number }; + return row.count; + } + const row = this.db + .prepare("SELECT COUNT(*) as count FROM session_allowlist") + .get() as { count: number }; + return row.count; + } + + getStatsSummary(): { total: number; allowed: number; denied: number; confirmed: number; autoAllowed: number; agents: number } { + const decisions = this.db + .prepare( + `SELECT + COUNT(*) as total, + SUM(CASE WHEN action = 'allow' OR action = 'log' THEN 1 ELSE 0 END) as allowed, + SUM(CASE WHEN action = 'deny' THEN 1 ELSE 0 END) as denied, + SUM(CASE WHEN action = 'confirm' THEN 1 ELSE 0 END) as confirmed + FROM decisions`, + ) + .get() as { total: number; allowed: number; denied: number; confirmed: number }; + const autoAllowed = this.getAutoAllowCount(); + const agents = this.db + .prepare("SELECT COUNT(DISTINCT agent) as count FROM decisions WHERE agent IS NOT NULL") + .get() as { count: number }; + return { ...decisions, autoAllowed, agents: agents.count }; + } + + getTodayStats(): { total: number; allowed: number; denied: number; confirmed: number; autoAllowed: number } { + const decisions = this.db + .prepare( + `SELECT + COUNT(*) as total, + SUM(CASE WHEN action = 'allow' OR action = 'log' THEN 1 ELSE 0 END) as allowed, + SUM(CASE WHEN action = 'deny' THEN 1 ELSE 0 END) as denied, + SUM(CASE WHEN action = 'confirm' THEN 1 ELSE 0 END) as confirmed + FROM decisions WHERE timestamp >= date('now')`, + ) + .get() as { total: number; allowed: number; denied: number; confirmed: number }; + const autoRow = this.db + .prepare("SELECT COUNT(*) as count FROM session_allowlist WHERE created_at >= date('now')") + .get() as { count: number }; + return { ...decisions, autoAllowed: autoRow.count }; + } + close(): void { this.db.close(); } diff --git a/packages/replay/src/report-generator.ts b/packages/replay/src/report-generator.ts index be06489..0050319 100644 --- a/packages/replay/src/report-generator.ts +++ b/packages/replay/src/report-generator.ts @@ -216,7 +216,7 @@ export class ReportGenerator { lines.push("---"); lines.push( - "*Generated by [ClawGuard](https://clawguard-sec.com) \u2014 AI agent security companion*", + "*Generated by [ClawGuard](https://clawguard-sec.com) \u2014 AI agent memory*", ); lines.push(""); From 9fcc7a05d3b28e63fa6c9f65a11be7b2a5cdbb0e Mon Sep 17 00:00:00 2001 From: Goki602 Date: Tue, 10 Mar 2026 20:34:01 +0900 Subject: [PATCH 3/6] feat: unify confirm handling as deny+retry, harden server security, and improve rule coverage Replace VSCode-specific confirm-to-deny fallback with a universal deny+retry pattern: all confirm decisions now return deny with an explanation + retry hint, pre-registering the session allowlist so the retry auto-allows. This eliminates the need for vsCodeCompat flag and works consistently across all environments. Security hardening: - Restrict CORS to localhost origins only - Add 1MB request body size limit with early abort - Add anti-evasion hint to deny/ask responses Robustness: - Audit reader skips malformed JSONL lines instead of crashing - Rule loader catches invalid regex patterns gracefully Rule improvements: - Broaden npm/pip install regex to catch flag variants (--ignore-scripts, -D, -g, --no-deps, --upgrade) - Add isHistoricallyAllowed() for cross-session memory lookup - Extend session allowlist to cover high-risk confirm decisions Co-Authored-By: Claude Opus 4.6 --- package.json | 4 +- .../src/__tests__/hook-handler.test.ts | 66 +-------- packages/adapter-claude/src/hook-handler.ts | 25 ++-- packages/adapter-claude/src/index.ts | 1 - packages/adapter-claude/src/types.ts | 10 +- packages/audit/src/__tests__/writer.test.ts | 16 ++- packages/audit/src/reader.ts | 10 +- .../cli/src/__tests__/integration.test.ts | 7 +- packages/cli/src/commands/serve.ts | 136 ++++++++++-------- packages/cli/src/engine-factory.ts | 55 +++++-- .../core/src/__tests__/policy-engine.test.ts | 9 +- .../core/src/__tests__/rule-loader.test.ts | 12 ++ packages/core/src/rule-loader.ts | 6 +- packages/core/src/types.ts | 1 - .../src/__tests__/decision-store.test.ts | 30 ++++ packages/memory/src/decision-store.ts | 7 + rules/core/bash-npm-install.yaml | 4 +- rules/core/bash-pip-install.yaml | 4 +- 18 files changed, 222 insertions(+), 181 deletions(-) diff --git a/package.json b/package.json index e9b5d30..f26c19f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,9 @@ { "name": "claw-guard-monorepo", "private": true, - "workspaces": ["packages/*"], + "workspaces": [ + "packages/*" + ], "scripts": { "build": "npm run build -w packages/core -w packages/memory && npm run build -w packages/audit -w packages/billing -w packages/enrichment -w packages/feed -w packages/reputation -w packages/skills-av -w packages/team && npm run build -w packages/adapter-claude -w packages/adapter-codex -w packages/adapter-mcp -w packages/passport -w packages/replay && npm run build -w packages/cli -w packages/web-ui -w packages/lp -w packages/docker", "test": "vitest run", diff --git a/packages/adapter-claude/src/__tests__/hook-handler.test.ts b/packages/adapter-claude/src/__tests__/hook-handler.test.ts index f3d7bef..52e87e3 100644 --- a/packages/adapter-claude/src/__tests__/hook-handler.test.ts +++ b/packages/adapter-claude/src/__tests__/hook-handler.test.ts @@ -83,7 +83,7 @@ describe("buildHookOutput", () => { check: ["パスは正しい?"], }, }; - const output = buildHookOutput(decision, "ja", false); + const output = buildHookOutput(decision, "ja"); expect(output).not.toBeNull(); expect(output?.hookSpecificOutput.permissionDecision).toBe("ask"); expect(output?.hookSpecificOutput.permissionDecisionReason).toContain("大量削除"); @@ -107,70 +107,6 @@ describe("buildHookOutput", () => { expect(output?.hookSpecificOutput.permissionDecision).toBe("deny"); }); - it("returns deny for high-risk confirm when vsCodeCompat is true", () => { - const decision: PolicyDecision = { - action: "confirm", - risk: "high", - rule_id: "BASH.RM_RISK", - feed_version: "0.1.0", - explain: { - title: "大量削除の可能性", - what: "ファイルをまとめて削除", - why: ["元に戻せません"], - check: ["パスは正しい?"], - }, - }; - const output = buildHookOutput(decision, "ja", true); - expect(output).not.toBeNull(); - expect(output?.hookSpecificOutput.permissionDecision).toBe("deny"); - expect(output?.hookSpecificOutput.permissionDecisionReason).toContain("大量削除"); - expect(output?.hookSpecificOutput.permissionDecisionReason).toContain("ブロックされました"); - expect(output?.hookSpecificOutput.permissionDecisionReason).toContain( - "claw-guard init --profile expert", - ); - }); - - it("returns deny with soft hint for medium-risk confirm when vsCodeCompat is true", () => { - const decision: PolicyDecision = { - action: "confirm", - risk: "medium", - rule_id: "BASH.NPM_INSTALL", - feed_version: "0.1.0", - explain: { - title: "パッケージ追加", - what: "パッケージをインストール", - why: ["不明なコードが実行される可能性"], - check: ["信頼できるパッケージ?"], - }, - }; - const output = buildHookOutput(decision, "ja", true); - expect(output).not.toBeNull(); - expect(output?.hookSpecificOutput.permissionDecision).toBe("deny"); - expect(output?.hookSpecificOutput.permissionDecisionReason).toContain("パッケージ追加"); - expect(output?.hookSpecificOutput.permissionDecisionReason).toContain("リスクがあるためブロック"); - expect(output?.hookSpecificOutput.permissionDecisionReason).not.toContain( - "claw-guard init --profile expert", - ); - }); - - it("returns ask for high-risk confirm when vsCodeCompat is false", () => { - const decision: PolicyDecision = { - action: "confirm", - risk: "high", - rule_id: "BASH.RM_RISK", - feed_version: "0.1.0", - explain: { - title: "大量削除の可能性", - what: "ファイルをまとめて削除", - why: ["元に戻せません"], - check: ["パスは正しい?"], - }, - }; - const output = buildHookOutput(decision, "ja", false); - expect(output).not.toBeNull(); - expect(output?.hookSpecificOutput.permissionDecision).toBe("ask"); - }); - it("output has correct hookEventName", () => { const decision: PolicyDecision = { action: "confirm", diff --git a/packages/adapter-claude/src/hook-handler.ts b/packages/adapter-claude/src/hook-handler.ts index 251b863..97bed95 100644 --- a/packages/adapter-claude/src/hook-handler.ts +++ b/packages/adapter-claude/src/hook-handler.ts @@ -2,31 +2,24 @@ import type { Lang, PolicyDecision } from "@clawguard/core"; import { formatExplainTerminal } from "@clawguard/core"; import { mapToToolRequest } from "./mapper.js"; import type { ClaudeHookInput, ClaudeHookOutput } from "./types.js"; -import { isVscodeEnvironment, shouldIntervene } from "./types.js"; +import { shouldIntervene } from "./types.js"; export function parseHookInput(jsonStr: string): ClaudeHookInput { return JSON.parse(jsonStr) as ClaudeHookInput; } -const POLICY_HINT_HARD = { - ja: "\n\n⛔ この操作は高リスクのためブロックされました(その場での許可はできません)。\n許可するには:\n - プリセットを `expert` に変更: `claw-guard init --profile expert`\n - または clawguard.yaml にプロジェクト例外を追加", - en: "\n\n⛔ This operation was blocked due to high risk (cannot be approved on the spot).\nTo allow it:\n - Change preset to `expert`: `claw-guard init --profile expert`\n - Or add a project override in clawguard.yaml", -} as const; - -const POLICY_HINT_SOFT = { - ja: "\n\n⚠️ この操作にはリスクがあるためブロックしました。\n上記の内容を確認し、問題なければそのまま指示を続けてください(同じ操作は今回のセッション中は自動で許可されます)。", - en: "\n\n⚠️ This operation was blocked due to risk.\nReview the details above. If it looks safe, just tell Claude to proceed (the same operation will be auto-allowed for this session).", +const ANTI_EVASION_HINT = { + ja: "\n\n🚫 セキュリティチェックを回避するためにコマンドを変更しないでください。", + en: "\n\n🚫 Do not modify the command to bypass this security check.", } as const; function decisionToClaudeAction( decision: PolicyDecision, - forceBlock?: boolean, ): "allow" | "deny" | "ask" { switch (decision.action) { case "deny": return "deny"; case "confirm": - if (forceBlock) return "deny"; return "ask"; case "allow": case "log": @@ -48,10 +41,8 @@ function buildReason(decision: PolicyDecision, lang: Lang = "ja"): string | unde export function buildHookOutput( decision: PolicyDecision, lang: Lang = "ja", - vsCodeCompat?: boolean, ): ClaudeHookOutput | null { - const forceBlock = vsCodeCompat ?? isVscodeEnvironment(); - const claudeAction = decisionToClaudeAction(decision, forceBlock); + const claudeAction = decisionToClaudeAction(decision); // observer mode (log): don't affect Claude's behavior — just observe if (decision.action === "log") { @@ -69,9 +60,9 @@ export function buildHookOutput( let reason = buildReason(decision, lang); - // When confirm was force-blocked to deny (VSCode compat), append policy change hint - if (decision.action === "confirm" && claudeAction === "deny" && reason) { - reason += decision.risk === "high" ? POLICY_HINT_HARD[lang] : POLICY_HINT_SOFT[lang]; + // Append anti-evasion instruction for deny/ask to prevent command modification bypass + if (reason && (claudeAction === "deny" || claudeAction === "ask")) { + reason += ANTI_EVASION_HINT[lang]; } return { diff --git a/packages/adapter-claude/src/index.ts b/packages/adapter-claude/src/index.ts index 0a31253..6ced1e2 100644 --- a/packages/adapter-claude/src/index.ts +++ b/packages/adapter-claude/src/index.ts @@ -7,4 +7,3 @@ export { export { installHook, uninstallHook } from "./installer.js"; export type { HookMode } from "./installer.js"; export type { ClaudeHookInput, ClaudeHookOutput } from "./types.js"; -export { isVscodeEnvironment } from "./types.js"; diff --git a/packages/adapter-claude/src/types.ts b/packages/adapter-claude/src/types.ts index cbffa99..edf97a8 100644 --- a/packages/adapter-claude/src/types.ts +++ b/packages/adapter-claude/src/types.ts @@ -21,12 +21,4 @@ const NON_INTERVENTION_MODES = new Set(["bypassPermissions", "dontAsk"]); export function shouldIntervene(permissionMode: string): boolean { return !NON_INTERVENTION_MODES.has(permissionMode); -} - -export function isVscodeEnvironment(): boolean { - return !!( - process.env.VSCODE_PID || - process.env.VSCODE_CLI || - process.env.TERM_PROGRAM === "vscode" - ); -} +} \ No newline at end of file diff --git a/packages/audit/src/__tests__/writer.test.ts b/packages/audit/src/__tests__/writer.test.ts index 617e7b2..6695c3f 100644 --- a/packages/audit/src/__tests__/writer.test.ts +++ b/packages/audit/src/__tests__/writer.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { PolicyDecision, ToolRequest } from "@clawguard/core"; @@ -72,6 +72,20 @@ describe("AuditReader", () => { expect(reader.readDate("2026-01-01")).toEqual([]); }); + it("skips malformed JSONL lines without crashing", () => { + const reader = new AuditReader(tmpDir); + const writer = new AuditWriter(tmpDir); + writer.write(makeEvent()); + const dates = reader.listDates(); + // Inject a malformed line into the log file + const filePath = join(tmpDir, `${dates[0]}.jsonl`); + writeFileSync(filePath, '{"valid":true}\nNOT_JSON\n{"also_valid":true}\n'); + const events = reader.readDate(dates[0]); + expect(events.length).toBe(2); + expect(events[0]).toEqual({ valid: true }); + expect(events[1]).toEqual({ also_valid: true }); + }); + it("lists available dates", () => { const writer = new AuditWriter(tmpDir); writer.write(makeEvent()); diff --git a/packages/audit/src/reader.ts b/packages/audit/src/reader.ts index 7613fd4..77a403c 100644 --- a/packages/audit/src/reader.ts +++ b/packages/audit/src/reader.ts @@ -15,7 +15,15 @@ export class AuditReader { if (!existsSync(filePath)) return []; const lines = readFileSync(filePath, "utf-8").split("\n").filter(Boolean); - return lines.map((line) => JSON.parse(line) as OcsfEvent); + const events: OcsfEvent[] = []; + for (const line of lines) { + try { + events.push(JSON.parse(line) as OcsfEvent); + } catch { + // Skip malformed lines (e.g., truncated writes) + } + } + return events; } readToday(): OcsfEvent[] { diff --git a/packages/cli/src/__tests__/integration.test.ts b/packages/cli/src/__tests__/integration.test.ts index 447c5f0..953d9a8 100644 --- a/packages/cli/src/__tests__/integration.test.ts +++ b/packages/cli/src/__tests__/integration.test.ts @@ -74,7 +74,6 @@ function createIsolatedContext(): EngineContext & { tmpDir: string } { rulesCount: rules.length, lang: "ja" as const, store, - vsCodeCompat: false, tmpDir, }; } @@ -132,10 +131,10 @@ describe("Integration: Evaluate Pipeline", () => { rmSync(ctx.tmpDir, { recursive: true, force: true }); }); - it("rm -rf → non-null output with confirm", () => { + it("rm -rf → non-null output with deny (deny+retry for explanation visibility)", () => { const result = evaluateHookRequest(hookInput("rm -rf /tmp/test"), ctx); expect(result.output).not.toBeNull(); - expect(result.output?.hookSpecificOutput.permissionDecision).toBe("ask"); + expect(result.output?.hookSpecificOutput.permissionDecision).toBe("deny"); expect(result.skipped).toBe(false); }); @@ -321,7 +320,7 @@ describe("Integration: HTTP Server", () => { }); const data = await res.json(); expect(data.hookSpecificOutput).toBeDefined(); - expect(data.hookSpecificOutput.permissionDecision).toBe("ask"); + expect(data.hookSpecificOutput.permissionDecision).toBe("deny"); }); it("POST /hook with safe command returns explicit allow", async () => { diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 5115df3..e32c362 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -15,6 +15,7 @@ import { createEngineContext, evaluateHookRequestAsync } from "../engine-factory import { detectLocale } from "../locale.js"; const DEFAULT_PORT = 19280; +const MAX_BODY_SIZE = 1024 * 1024; // 1MB const MSG = { ja: { @@ -45,16 +46,19 @@ const MSG = { }, }; -function corsHeaders(): Record { +function corsHeaders(req?: IncomingMessage): Record { + const origin = req?.headers.origin; + const allowed = + !origin || origin.startsWith("http://localhost") || origin.startsWith("http://127.0.0.1"); return { - "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Origin": allowed ? (origin ?? "http://localhost") : "", "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type", }; } -function jsonResponse(res: ServerResponse, status: number, data: unknown): void { - res.writeHead(status, { "Content-Type": "application/json", ...corsHeaders() }); +function jsonResponse(res: ServerResponse, status: number, data: unknown, req?: IncomingMessage): void { + res.writeHead(status, { "Content-Type": "application/json", ...corsHeaders(req) }); res.end(JSON.stringify(data)); } @@ -122,14 +126,16 @@ export async function serveCommand(options: { port?: string; host?: string }): P console.log(""); const server = createServer((req: IncomingMessage, res: ServerResponse) => { + const json = (status: number, data: unknown) => jsonResponse(res, status, data, req); + if (req.method === "OPTIONS") { - res.writeHead(204, corsHeaders()); + res.writeHead(204, corsHeaders(req)); res.end(); return; } if (req.method === "GET" && req.url === "/health") { - jsonResponse(res, 200, { + json(200, { status: "ok", rules: ctx.rulesCount, memory: Boolean(ctx.store), @@ -140,7 +146,7 @@ export async function serveCommand(options: { port?: string; host?: string }): P if (req.method === "GET" && req.url === "/api/logs/today") { const entries = reader.readToday(); - jsonResponse(res, 200, { entries }); + json(200, { entries }); return; } @@ -152,13 +158,13 @@ export async function serveCommand(options: { port?: string; host?: string }): P risk: r.risk, title: r.explain?.title ?? "", })); - jsonResponse(res, 200, { rules }); + json(200, { rules }); return; } if (req.method === "GET" && req.url === "/api/stats") { if (!ctx.store) { - jsonResponse(res, 200, { stats: [] }); + json(200, { stats: [] }); return; } const ruleIds = ctx.engine.getRules().map((r) => r.id); @@ -167,7 +173,7 @@ export async function serveCommand(options: { port?: string; host?: string }): P rule_id: id, ...store?.getStats(id), })); - jsonResponse(res, 200, { stats }); + json(200, { stats }); return; } @@ -175,9 +181,9 @@ export async function serveCommand(options: { port?: string; host?: string }): P const ruleId = decodeURIComponent(req.url.slice("/api/reputation/".length)); if (ctx.reputation) { const rep = ctx.reputation.getReputation(ruleId); - jsonResponse(res, 200, rep); + json(200, rep); } else { - jsonResponse(res, 200, { + json(200, { rule_id: ruleId, community_total: 0, community_allowed: 0, @@ -190,16 +196,16 @@ export async function serveCommand(options: { port?: string; host?: string }): P } if (req.method === "GET" && req.url === "/api/license") { - jsonResponse(res, 200, { license: ctx.license ?? { plan: "free" } }); + json(200, { license: ctx.license ?? { plan: "free" } }); return; } if (req.method === "GET" && req.url === "/api/feed/status") { if (ctx.feedClient) { const status = ctx.feedClient.getStatus(); - jsonResponse(res, 200, status); + json(200, status); } else { - jsonResponse(res, 200, { status: "none" }); + json(200, { status: "none" }); } return; } @@ -210,16 +216,16 @@ export async function serveCommand(options: { port?: string; host?: string }): P .fetchLatest() .then((bundle) => { if (bundle) { - jsonResponse(res, 200, { success: true, version: bundle.manifest.version }); + json(200, { success: true, version: bundle.manifest.version }); } else { - jsonResponse(res, 200, { success: false, error: "Feed fetch failed" }); + json(200, { success: false, error: "Feed fetch failed" }); } }) .catch((err) => { - jsonResponse(res, 500, { success: false, error: String(err) }); + json(500, { success: false, error: String(err) }); }); } else { - jsonResponse(res, 200, { success: false, error: "Feed not configured" }); + json(200, { success: false, error: "Feed not configured" }); } return; } @@ -227,19 +233,26 @@ export async function serveCommand(options: { port?: string; host?: string }): P if (req.method === "GET" && req.url === "/api/passport") { const passport = passportGenerator.load(); if (passport) { - jsonResponse(res, 200, passport); + json(200, passport); } else { - jsonResponse(res, 200, { error: "No passport found" }); + json(200, { error: "No passport found" }); } return; } if (req.method === "POST" && req.url === "/api/passport/generate") { let body = ""; + let aborted = false; req.on("data", (chunk: Buffer) => { body += chunk.toString(); + if (body.length > MAX_BODY_SIZE) { + aborted = true; + json(413, { error: "Request body too large" }); + req.destroy(); + } }); req.on("end", () => { + if (aborted) return; try { const opts = JSON.parse(body || "{}"); const passport = passportGenerator.generate({ @@ -247,9 +260,9 @@ export async function serveCommand(options: { port?: string; host?: string }): P feedVersion: ctx.feedClient?.getStatus()?.version ?? undefined, }); passportGenerator.save(passport); - jsonResponse(res, 200, passport); + json(200, passport); } catch (err) { - jsonResponse(res, 500, { error: String(err) }); + json(500, { error: String(err) }); } }); return; @@ -259,10 +272,10 @@ export async function serveCommand(options: { port?: string; host?: string }): P const passport = passportGenerator.load(); if (passport) { const svg = badgeGenerator.generateSvg(passport); - res.writeHead(200, { "Content-Type": "image/svg+xml", ...corsHeaders() }); + res.writeHead(200, { "Content-Type": "image/svg+xml", ...corsHeaders(req) }); res.end(svg); } else { - jsonResponse(res, 404, { error: "No passport found" }); + json(404, { error: "No passport found" }); } return; } @@ -271,7 +284,7 @@ export async function serveCommand(options: { port?: string; host?: string }): P const urlObj = new URL(req.url, "http://localhost"); const date = urlObj.searchParams.get("date") ?? new Date().toISOString().split("T")[0]; const sessions = sessionBuilder.listSessions(date); - jsonResponse(res, 200, { sessions }); + json(200, { sessions }); return; } @@ -280,9 +293,9 @@ export async function serveCommand(options: { port?: string; host?: string }): P const timeline = sessionBuilder.buildForSession(id); if (timeline) { timeline.causal_chains = causalAnalyzer.analyze(timeline.events); - jsonResponse(res, 200, timeline); + json(200, timeline); } else { - jsonResponse(res, 404, { error: "Session not found" }); + json(404, { error: "Session not found" }); } return; } @@ -291,9 +304,9 @@ export async function serveCommand(options: { port?: string; host?: string }): P const id = decodeURIComponent(req.url.split("/")[4]); const timeline = sessionBuilder.buildForSession(id); if (timeline) { - jsonResponse(res, 200, timeline); + json(200, timeline); } else { - jsonResponse(res, 404, { error: "Session not found" }); + json(404, { error: "Session not found" }); } return; } @@ -302,7 +315,7 @@ export async function serveCommand(options: { port?: string; host?: string }): P const urlObj = new URL(req.url, "http://localhost"); const offset = Number.parseInt(urlObj.searchParams.get("offset") ?? "0", 10); const report = reportGenerator.generateWeekly(Number.isNaN(offset) ? 0 : offset); - jsonResponse(res, 200, report); + json(200, report); return; } @@ -312,9 +325,9 @@ export async function serveCommand(options: { port?: string; host?: string }): P const skillsDir = resolve(homedir(), ".claude", "skills"); const scanner = new SkillsScanner(skillsDir); const results = scanner.scanAllSkills(skillsDir); - jsonResponse(res, 200, { results }); + json(200, { results }); } catch { - jsonResponse(res, 200, { results: [] }); + json(200, { results: [] }); } return; } @@ -323,9 +336,9 @@ export async function serveCommand(options: { port?: string; host?: string }): P try { const manager = new ManifestManager(homedir()); const manifest = manager.load(); - jsonResponse(res, 200, { manifest }); + json(200, { manifest }); } catch { - jsonResponse(res, 200, { manifest: null }); + json(200, { manifest: null }); } return; } @@ -339,9 +352,9 @@ export async function serveCommand(options: { port?: string; host?: string }): P .map((d) => resolve(skillsDir, d.name)); const manifest = manager.buildManifest(dirs); manager.save(manifest); - jsonResponse(res, 200, { success: true, manifest }); + json(200, { success: true, manifest }); } catch (err) { - jsonResponse(res, 500, { success: false, error: String(err) }); + json(500, { success: false, error: String(err) }); } return; } @@ -350,9 +363,9 @@ export async function serveCommand(options: { port?: string; host?: string }): P if (req.method === "GET" && req.url === "/api/monitor/alerts") { if (fpMonitor) { const alerts = fpMonitor.analyze(); - jsonResponse(res, 200, { alerts }); + json(200, { alerts }); } else { - jsonResponse(res, 200, { alerts: [] }); + json(200, { alerts: [] }); } return; } @@ -360,9 +373,9 @@ export async function serveCommand(options: { port?: string; host?: string }): P if (req.method === "GET" && req.url === "/api/monitor/stats") { if (fpMonitor) { const stats = fpMonitor.getDetailedStats(); - jsonResponse(res, 200, { stats }); + json(200, { stats }); } else { - jsonResponse(res, 200, { stats: [] }); + json(200, { stats: [] }); } return; } @@ -372,7 +385,7 @@ export async function serveCommand(options: { port?: string; host?: string }): P const urlObj = new URL(req.url, "http://localhost"); const offset = Number.parseInt(urlObj.searchParams.get("offset") ?? "0", 10); const publicReport = reportGenerator.generatePublic(Number.isNaN(offset) ? 0 : offset); - jsonResponse(res, 200, publicReport); + json(200, publicReport); return; } @@ -382,9 +395,9 @@ export async function serveCommand(options: { port?: string; host?: string }): P const marketplace = new MarketplaceClient(); const curator = new RuleCurator(marketplace, ctx.store); const result = curator.evaluate(); - jsonResponse(res, 200, result); + json(200, result); } else { - jsonResponse(res, 200, { + json(200, { evaluated_at: new Date().toISOString(), tasks: [], promoted: [], @@ -401,13 +414,13 @@ export async function serveCommand(options: { port?: string; host?: string }): P const curator = new RuleCurator(marketplace, ctx.store); const result = curator.evaluate(); const applied = curator.applyPromotions(result); - jsonResponse(res, 200, { + json(200, { applied, promoted: result.promoted, deprecated: result.deprecated, }); } else { - jsonResponse(res, 200, { applied: 0, promoted: [], deprecated: [] }); + json(200, { applied: 0, promoted: [], deprecated: [] }); } return; } @@ -415,11 +428,11 @@ export async function serveCommand(options: { port?: string; host?: string }): P // --- Team endpoints --- if (req.method === "GET" && req.url === "/api/team/status") { if (!teamMemberStore) { - jsonResponse(res, 200, { connected: false }); + json(200, { connected: false }); return; } const members = teamMemberStore.listMembers(); - jsonResponse(res, 200, { + json(200, { connected: true, member_count: members.length, policy: { profile: ctx.engine.getPreset().name, enforce: true }, @@ -429,59 +442,66 @@ export async function serveCommand(options: { port?: string; host?: string }): P if (req.method === "GET" && req.url === "/api/team/members") { if (!teamMemberStore) { - jsonResponse(res, 200, { members: [] }); + json(200, { members: [] }); return; } const members = teamMemberStore.listMembers(); - jsonResponse(res, 200, { members }); + json(200, { members }); return; } if (req.method === "GET" && req.url === "/api/team/memory/stats") { if (!teamMemoryStore) { - jsonResponse(res, 200, { stats: [] }); + json(200, { stats: [] }); return; } const stats = teamMemoryStore.getTeamStats(); - jsonResponse(res, 200, { stats }); + json(200, { stats }); return; } if (req.method === "GET" && req.url?.startsWith("/api/team/audit/summary")) { if (!teamAuditStore) { - jsonResponse(res, 200, { total: 0, by_rule: {}, by_member: {}, by_action: {} }); + json(200, { total: 0, by_rule: {}, by_member: {}, by_action: {} }); return; } const urlObj = new URL(req.url, "http://localhost"); const since = urlObj.searchParams.get("since") ?? undefined; const summary = teamAuditStore.getSummary(since); - jsonResponse(res, 200, summary); + json(200, summary); return; } if (req.method === "POST" && req.url === "/hook") { let body = ""; + let aborted = false; req.on("data", (chunk: Buffer) => { body += chunk.toString(); + if (body.length > MAX_BODY_SIZE) { + aborted = true; + json(413, { error: "Request body too large" }); + req.destroy(); + } }); req.on("end", () => { + if (aborted) return; evaluateHookRequestAsync(body, ctx) .then(({ output, skipped }) => { if (skipped || !output) { - jsonResponse(res, 200, {}); + json(200, {}); } else { - jsonResponse(res, 200, output); + json(200, output); } }) .catch((err) => { console.error("Evaluation error:", err); - jsonResponse(res, 200, {}); + json(200, {}); }); }); return; } - jsonResponse(res, 404, { error: "Not found" }); + json(404, { error: "Not found" }); }); server.listen(port, host, () => { diff --git a/packages/cli/src/engine-factory.ts b/packages/cli/src/engine-factory.ts index a6b5499..f816b60 100644 --- a/packages/cli/src/engine-factory.ts +++ b/packages/cli/src/engine-factory.ts @@ -28,6 +28,11 @@ import type { PassportGenerator } from "@clawguard/passport"; import type { ReportGenerator, SessionBuilder } from "@clawguard/replay"; import { ReputationAggregator, TelemetryUploader } from "@clawguard/reputation"; +const RETRY_HINT = { + ja: "\n\n🔄 上記のリスクをユーザーに説明し、実行してよいか確認してください。許可された場合のみ、コマンドを変更せずに再実行してください。", + en: "\n\n🔄 Explain the above risks to the user and ask for permission. Only retry the command without modification if the user approves.", +} as const; + export function findRulesDir(): string { const coreBundled = getCoreRulesDir(); const repoRoot = resolve(process.cwd(), "rules/core"); @@ -80,7 +85,6 @@ export interface EngineContext { teamClient?: unknown; skillsScanner?: unknown; teamMemoryStore?: unknown; - vsCodeCompat?: boolean; } export function createEngineContext(overrideLang?: Lang): EngineContext { @@ -131,7 +135,6 @@ export function createEngineContext(overrideLang?: Lang): EngineContext { }); const lang: Lang = overrideLang ?? (config.lang === "en" ? "en" : "ja"); - const vsCodeCompat = config.vscode_compat; return { engine, writer, @@ -143,7 +146,6 @@ export function createEngineContext(overrideLang?: Lang): EngineContext { license, gate, feedClient, - vsCodeCompat, }; } @@ -164,13 +166,15 @@ export function evaluateHookRequest(rawInput: string, ctx: EngineContext): EvalR const contentHash = DecisionStore.hashContent(request.content); - // Session allowlist: auto-allow if the same non-high-risk operation was already approved in this session - if (decision.action === "confirm" && decision.risk !== "high" && ctx.store) { + // Auto-allow: session allowlist only (same session, same content, same rule) + if (decision.action === "confirm" && ctx.store) { if (ctx.store.isSessionAllowed(request.context.session_id, contentHash, decision.rule_id)) { decision.action = "allow"; } } + const output = buildHookOutput(decision, ctx.lang); + const event = createOcsfEvent(request, decision); ctx.writer.write(event); @@ -184,11 +188,21 @@ export function evaluateHookRequest(rawInput: string, ctx: EngineContext): EvalR }); } - const output = buildHookOutput(decision, ctx.lang, ctx.vsCodeCompat); - - // Record confirm decisions to session allowlist for future auto-allow (all environments) - if (decision.action === "confirm" && decision.risk !== "high" && ctx.store && output) { + // Non-high confirm: pre-register session allowlist, return deny with retry hint + // (deny reason is shown to Claude, who relays explanation to user then retries) + if (decision.action === "confirm" && ctx.store && output) { ctx.store.recordSessionAllow(request.context.session_id, contentHash, decision.rule_id); + const reason = output.hookSpecificOutput.permissionDecisionReason ?? ""; + return { + output: { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: reason + RETRY_HINT[ctx.lang], + }, + }, + skipped: false, + }; } return { output, skipped: false }; @@ -217,13 +231,15 @@ export async function evaluateHookRequestAsync( } } - // Session allowlist: auto-allow if the same non-high-risk operation was already approved in this session - if (decision.action === "confirm" && decision.risk !== "high" && ctx.store) { + // Auto-allow: session allowlist only (same session, same content, same rule) + if (decision.action === "confirm" && ctx.store) { if (ctx.store.isSessionAllowed(request.context.session_id, contentHash, decision.rule_id)) { decision.action = "allow"; } } + const output = buildHookOutput(decision, ctx.lang); + const event = createOcsfEvent(request, decision); ctx.writer.write(event); @@ -237,11 +253,20 @@ export async function evaluateHookRequestAsync( }); } - const output = buildHookOutput(decision, ctx.lang, ctx.vsCodeCompat); - - // Record confirm decisions to session allowlist for future auto-allow (all environments) - if (decision.action === "confirm" && decision.risk !== "high" && ctx.store && output) { + // Non-high confirm: pre-register session allowlist, return deny with retry hint + if (decision.action === "confirm" && ctx.store && output) { ctx.store.recordSessionAllow(request.context.session_id, contentHash, decision.rule_id); + const reason = output.hookSpecificOutput.permissionDecisionReason ?? ""; + return { + output: { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: reason + RETRY_HINT[ctx.lang], + }, + }, + skipped: false, + }; } return { output, skipped: false }; diff --git a/packages/core/src/__tests__/policy-engine.test.ts b/packages/core/src/__tests__/policy-engine.test.ts index 7c8c294..8193b26 100644 --- a/packages/core/src/__tests__/policy-engine.test.ts +++ b/packages/core/src/__tests__/policy-engine.test.ts @@ -55,9 +55,15 @@ describe("PolicyEngine", () => { ["npm i express", "BASH.NPM_INSTALL"], ["npm add react", "BASH.NPM_INSTALL"], ["echo x && npm install axios", "BASH.NPM_INSTALL"], + ["npm install --ignore-scripts lodash", "BASH.NPM_INSTALL"], + ["npm i --save-dev jest", "BASH.NPM_INSTALL"], + ["npm i -g typescript", "BASH.NPM_INSTALL"], + ["npm i -D lodash", "BASH.NPM_INSTALL"], ["pip install requests", "BASH.PIP_INSTALL"], ["pip3 install flask", "BASH.PIP_INSTALL"], ["echo x && pip install numpy", "BASH.PIP_INSTALL"], + ["pip install --no-deps requests", "BASH.PIP_INSTALL"], + ["pip install --upgrade pip", "BASH.PIP_INSTALL"], ]; for (const [cmd, expectedRule] of dangerousCases) { @@ -95,12 +101,9 @@ describe("PolicyEngine", () => { "cat .envrc", "cat environment.ts", "npm install", - "npm i -g typescript", - "npm i --save-dev jest", "npm run build", "pip install -r requirements.txt", "pip install -e .", - "pip install --upgrade pip", "pip freeze", ]; diff --git a/packages/core/src/__tests__/rule-loader.test.ts b/packages/core/src/__tests__/rule-loader.test.ts index a8dfe17..bcca9db 100644 --- a/packages/core/src/__tests__/rule-loader.test.ts +++ b/packages/core/src/__tests__/rule-loader.test.ts @@ -61,6 +61,18 @@ describe("compileRule", () => { expect(compiled.compiledRegex?.test("ls -la")).toBe(false); }); + it("skips invalid regex without crashing", () => { + const rule: Rule = { + id: "BAD.REGEX", + match: { tool: "bash", command_regex: "(unclosed" }, + risk: "high", + explain: { title: "T", what: "W", why: [], check: [] }, + }; + const compiled = compileRule(rule); + expect(compiled.compiledRegex).toBeUndefined(); + expect(compiled.id).toBe("BAD.REGEX"); + }); + it("handles trigger-based rules (no regex)", () => { const rule: Rule = { id: "SKILL.NEW", diff --git a/packages/core/src/rule-loader.ts b/packages/core/src/rule-loader.ts index 526ecc1..d27652e 100644 --- a/packages/core/src/rule-loader.ts +++ b/packages/core/src/rule-loader.ts @@ -6,7 +6,11 @@ import type { CompiledRule, Rule } from "./types.js"; export function compileRule(rule: Rule): CompiledRule { const compiled: CompiledRule = { ...rule }; if (rule.match.command_regex) { - compiled.compiledRegex = new RegExp(rule.match.command_regex); + try { + compiled.compiledRegex = new RegExp(rule.match.command_regex); + } catch { + console.warn(`[ClawGuard] Invalid regex in rule ${rule.id}: ${rule.match.command_regex}`); + } } return compiled; } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index ff0be4b..31d0a6e 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -184,7 +184,6 @@ export interface ClawGuardConfig { marketplace?: { packs?: string[]; }; - vscode_compat?: boolean; } export interface ProjectOverride { diff --git a/packages/memory/src/__tests__/decision-store.test.ts b/packages/memory/src/__tests__/decision-store.test.ts index 8528cbd..35ba8c6 100644 --- a/packages/memory/src/__tests__/decision-store.test.ts +++ b/packages/memory/src/__tests__/decision-store.test.ts @@ -95,6 +95,36 @@ describe("DecisionStore", () => { expect(recent[0].session_id).toBe("sess-1"); }); + describe("isHistoricallyAllowed", () => { + it("returns false when no past decisions", () => { + expect(store.isHistoricallyAllowed("hash1", "BASH.NPM_INSTALL")).toBe(false); + }); + + it("returns false with only 1 past confirm", () => { + store.record({ rule_id: "BASH.NPM_INSTALL", action: "confirm", content_hash: "hash1" }); + expect(store.isHistoricallyAllowed("hash1", "BASH.NPM_INSTALL")).toBe(false); + }); + + it("returns true with 2+ past confirms", () => { + store.record({ rule_id: "BASH.NPM_INSTALL", action: "confirm", content_hash: "hash1", session_id: "s1" }); + store.record({ rule_id: "BASH.NPM_INSTALL", action: "confirm", content_hash: "hash1", session_id: "s2" }); + expect(store.isHistoricallyAllowed("hash1", "BASH.NPM_INSTALL")).toBe(true); + }); + + it("does not count allow or deny actions", () => { + store.record({ rule_id: "BASH.NPM_INSTALL", action: "allow", content_hash: "hash1" }); + store.record({ rule_id: "BASH.NPM_INSTALL", action: "deny", content_hash: "hash1" }); + store.record({ rule_id: "BASH.NPM_INSTALL", action: "confirm", content_hash: "hash1" }); + expect(store.isHistoricallyAllowed("hash1", "BASH.NPM_INSTALL")).toBe(false); + }); + + it("does not match different rule", () => { + store.record({ rule_id: "BASH.NPM_INSTALL", action: "confirm", content_hash: "hash1" }); + store.record({ rule_id: "BASH.NPM_INSTALL", action: "confirm", content_hash: "hash1" }); + expect(store.isHistoricallyAllowed("hash1", "BASH.PIP_INSTALL")).toBe(false); + }); + }); + describe("session allowlist", () => { it("returns false when no entry exists", () => { expect(store.isSessionAllowed("s1", "hash1", "BASH.NPM_INSTALL")).toBe(false); diff --git a/packages/memory/src/decision-store.ts b/packages/memory/src/decision-store.ts index 609691d..e52382e 100644 --- a/packages/memory/src/decision-store.ts +++ b/packages/memory/src/decision-store.ts @@ -135,6 +135,13 @@ export class DecisionStore { return this.getStats(ruleId).override_rate; } + isHistoricallyAllowed(contentHash: string, ruleId: string, minCount = 2): boolean { + const row = this.db + .prepare("SELECT COUNT(*) as count FROM decisions WHERE content_hash = ? AND rule_id = ? AND action = 'confirm'") + .get(contentHash, ruleId) as { count: number }; + return row.count >= minCount; + } + isSessionAllowed(sessionId: string, contentHash: string, ruleId: string): boolean { const row = this.db .prepare("SELECT 1 FROM session_allowlist WHERE session_id = ? AND content_hash = ? AND rule_id = ?") diff --git a/rules/core/bash-npm-install.yaml b/rules/core/bash-npm-install.yaml index 7840ec1..fe9a1ee 100644 --- a/rules/core/bash-npm-install.yaml +++ b/rules/core/bash-npm-install.yaml @@ -1,7 +1,7 @@ - id: BASH.NPM_INSTALL match: tool: bash - command_regex: '(?:^|\s|;|&&|\|\|)npm\s+(?:install|i|add)\s+(?!-)[^\s-]' + command_regex: '(?:^|\s|;|&&|\|\|)npm\s+(?:install|i|add)\s+\S' risk: medium explain: title: "npm追加" @@ -14,7 +14,7 @@ - "このパッケージは信頼できる?(ダウンロード数・メンテナ)" alternatives: - "`npm info ` で先にパッケージ情報を確認する" - - "`npm install --ignore-scripts ` でスクリプト実行なしにインストールする" + - "npmjs.com でパッケージの信頼性を確認する" simple: doing: "新しいパッケージ(追加機能)をインストールしようとしています" safe_points: ["有名なパッケージなら通常は安全です"] diff --git a/rules/core/bash-pip-install.yaml b/rules/core/bash-pip-install.yaml index ea94b8f..6c5e217 100644 --- a/rules/core/bash-pip-install.yaml +++ b/rules/core/bash-pip-install.yaml @@ -1,7 +1,7 @@ - id: BASH.PIP_INSTALL match: tool: bash - command_regex: '(?:^|\s|;|&&|\|\|)pip3?\s+install\s+(?!-r\b|-e\b|--)[^\s-]' + command_regex: '(?:^|\s|;|&&|\|\|)pip3?\s+install\s+(?!-r\s|-e\s)\S' risk: medium explain: title: "pip追加" @@ -14,7 +14,7 @@ - "このパッケージは信頼できる?(PyPIでの情報を確認)" alternatives: - "`pip show ` で先にパッケージ情報を確認する" - - "`pip install --no-deps ` で依存関係なしにインストールする" + - "PyPI でパッケージの信頼性を確認する" simple: doing: "新しいPythonパッケージをインストールしようとしています" safe_points: ["有名なパッケージなら通常は安全です"] From 29c0ed17e865ebeb5e6a903aecd5894927ea2b50 Mon Sep 17 00:00:00 2001 From: Goki602 Date: Wed, 11 Mar 2026 00:51:24 +0900 Subject: [PATCH 4/6] chore: fix lint/format, add Phase 1 security rules, improve historical auto-allow tests - Apply biome formatting across all packages - Add 4 new Phase 1 core rules (env-file-read, npm-install, pip-install, ssh-key-read) with active marketplace status - Replace non-null assertions with type-safe casts in integration tests - Suppress noUselessConstructor for FeatureGate (intentional API contract) - Add historical auto-allow integration tests (medium/high risk thresholds) Co-Authored-By: Claude Opus 4.6 --- README.ja.md | 23 +++-- README.md | 23 +++-- package.json | 4 +- packages/adapter-claude/src/hook-handler.ts | 4 +- packages/adapter-claude/src/types.ts | 2 +- packages/billing/src/feature-gate.ts | 73 ++++++++++---- packages/cli/build.mjs | 15 ++- packages/cli/docker/.env.example | 4 + packages/cli/docker/agent/Dockerfile | 4 + packages/cli/docker/docker-compose.yml | 57 +++++++++++ packages/cli/docker/fetcher/Dockerfile | 8 ++ packages/cli/docker/fetcher/fetcher.sh | 10 ++ packages/cli/docker/gateway/Dockerfile | 7 ++ packages/cli/package.json | 2 +- .../cli/src/__tests__/integration.test.ts | 96 +++++++++++++++++++ packages/cli/src/commands/docker.ts | 5 +- packages/cli/src/commands/init.ts | 78 +++++++++++++-- packages/cli/src/commands/serve.ts | 7 +- packages/cli/src/commands/stats.ts | 11 ++- packages/cli/src/engine-factory.ts | 43 ++++++++- packages/cli/src/index.ts | 1 - .../core/rules/core/bash-env-file-read.yaml | 28 ++++++ .../core/rules/core/bash-npm-install.yaml | 28 ++++++ .../core/rules/core/bash-pip-install.yaml | 28 ++++++ .../core/rules/core/bash-ssh-key-read.yaml | 28 ++++++ packages/lp/src/components/HeroSection.tsx | 4 +- packages/lp/src/components/PricingCards.tsx | 6 +- packages/lp/src/content/jp.ts | 3 +- .../src/__tests__/decision-store.test.ts | 14 ++- packages/memory/src/decision-store.ts | 35 +++++-- packages/replay/src/report-generator.ts | 4 +- 31 files changed, 569 insertions(+), 86 deletions(-) create mode 100644 packages/cli/docker/.env.example create mode 100644 packages/cli/docker/agent/Dockerfile create mode 100644 packages/cli/docker/docker-compose.yml create mode 100644 packages/cli/docker/fetcher/Dockerfile create mode 100644 packages/cli/docker/fetcher/fetcher.sh create mode 100644 packages/cli/docker/gateway/Dockerfile create mode 100644 packages/core/rules/core/bash-env-file-read.yaml create mode 100644 packages/core/rules/core/bash-npm-install.yaml create mode 100644 packages/core/rules/core/bash-pip-install.yaml create mode 100644 packages/core/rules/core/bash-ssh-key-read.yaml diff --git a/README.ja.md b/README.ja.md index 13c8912..046029f 100644 --- a/README.ja.md +++ b/README.ja.md @@ -3,7 +3,7 @@ > AIエージェントの記憶 — 確認を減らして、判断を賢く [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -[![Tests](https://img.shields.io/badge/tests-369%20passing-brightgreen)]() +[![Tests](https://img.shields.io/badge/tests-380%20passing-brightgreen)]() [![Node](https://img.shields.io/badge/node-%3E%3D20-green)]() [English version](README.md) @@ -12,7 +12,7 @@ AIエージェント、確認が多すぎませんか? `npm install`のたびに確認、`git push`のたびに確認。5分前に「いいよ」って言ったのに、また同じことを聞いてくる。 -ClawGuardが覚えておきます。一度OKした操作は記録して、次からは自動で通す。セッションをまたいでも、別のエージェントでも、別のツールでも。Claude Code・Codex・Cursorなど、フック対応のエージェントならどれでも使えます。 +ClawGuardが覚えておきます。一度OKした操作は記録して、次からは自動で通す。セッションをまたいでも、別のエージェントでも、別のツールでも。Claude Codeをはじめ、フック対応のエージェントならどれでも使えます。 危ない操作(`rm -rf`、`git push --force`、`curl|bash`)はもちろん止めます。でもインストールする本当の理由は、エージェントが静かに・速くなるから。 @@ -36,7 +36,7 @@ claw-guard test 2回目: Agent → npm install foo → 自動許可(中断なし) ✓ -危険な操作: Agent → rm -rf / → 常にブロック+理由説明 ✗ +危険な操作: Agent → rm -rf / → 検知して理由を説明+あなたの確認が必要 ✗ ``` ## 静かさレベルを選ぶ @@ -66,11 +66,12 @@ CLI引数 > プロジェクト(`.clawguard.yaml`) > グローバル(`~/.co **層1 — スマート承認**(フックベース、Docker不要) - 一度OKした操作を覚えて、次から自動で通す(SQLite) -- 確認時に「他の開発者はどうしたか」をコミュニティデータで表示 +- 確認時に「他の開発者はどうしたか」をコミュニティデータで表示(準備中) - Adapter → Policy Engine → allow/confirm/deny(100ms以内) - セキュリティパスポート(継続監視の証明書) -**層2 — インフラ隔離**(Docker、オプション) +**層2 — インフラ隔離**(Docker、オプション・参考実装) +- `claw-guard docker init` で docker-compose テンプレートを取得 - 3コンテナ構成: gateway / fetcher / agent - ネットワーク分離(agentから外部へ直接通信できない) @@ -90,7 +91,7 @@ CLI引数 > プロジェクト(`.clawguard.yaml`) > グローバル(`~/.co | `BASH.ROOT_PATH_OP` | bash | high | `/`(ルート)への操作 | | `BASH.PIPE_EXEC_001` | bash | high | `curl \| bash` パイプ実行 | | `BASH.PIPE_EXEC_002` | bash | high | `wget \| sh` パイプ実行 | -| `BASH.SSH_KEY_READ` | bash | medium | SSH鍵ファイルへのアクセス | +| `BASH.SSH_KEY_READ` | bash | high | SSH鍵ファイルへのアクセス | | `BASH.ENV_FILE_READ` | bash | medium | `.env`ファイルへのアクセス | | `BASH.NPM_INSTALL` | bash | medium | `npm install <パッケージ>` | | `BASH.PIP_INSTALL` | bash | medium | `pip install <パッケージ>` | @@ -122,7 +123,7 @@ CLI引数 > プロジェクト(`.clawguard.yaml`) > グローバル(`~/.co | `claw-guard evaluate --json` | ツールリクエストの評価(フックエントリポイント) | | `claw-guard test` | ルール・エンジン・設定の検証 | | `claw-guard stats` | 自動許可カウント&判断サマリーの表示 | -| `claw-guard serve` | HTTPフックサーバー(レイテンシ1-3ms) | +| `claw-guard serve` | HTTPフックサーバー(低レイテンシ) | | `claw-guard log` | 監査ログの閲覧 | | `claw-guard dashboard` | Webダッシュボードを起動 | | `claw-guard feed` | 脅威フィードの管理(`--update`, `--status`) | @@ -133,6 +134,7 @@ CLI引数 > プロジェクト(`.clawguard.yaml`) > グローバル(`~/.co | `claw-guard monitor` | 誤検知モニタリング | | `claw-guard docker` | Dockerデプロイ(`init`, `up`, `down`) | | `claw-guard skills` | Skills AVスキャン | +| `claw-guard team` | チーム管理(serve/add/list/remove/policy) | ### ルール形式(YAML) @@ -186,7 +188,10 @@ packages/ ├── web-ui/ Reactダッシュボード ├── lp/ ランディングページ(英語+日本語) ├── webhook/ Stripe Webhook(Cloudflare Worker) -└── docker/ 3コンテナ参考実装 +├── docker/ 3コンテナ参考実装 +├── api/ REST APIサーバー +├── sdk/ 組み込み用SDK +└── siem/ SIEMコネクター rules/ ├── core/ 12コアルール(Phase 0-1) └── phase2/ 15追加ルール @@ -201,7 +206,7 @@ npm install # 全パッケージをビルド npm run build -# テスト実行(375テスト) +# テスト実行(380テスト) npm test # リント diff --git a/README.md b/README.md index 10850f1..9ff65bb 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ > AI Agent Memory — Fewer Prompts, Smarter Decisions [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -[![Tests](https://img.shields.io/badge/tests-369%20passing-brightgreen)]() +[![Tests](https://img.shields.io/badge/tests-380%20passing-brightgreen)]() [![Node](https://img.shields.io/badge/node-%3E%3D20-green)]() [日本語版はこちら](README.ja.md) @@ -12,7 +12,7 @@ AI agents ask too many questions. Every `npm install`, every `git push` — confirm, confirm, confirm. You said yes 5 minutes ago; now it's asking again. -ClawGuard remembers. When you approve an operation, it's stored. Next time the same pattern appears — in this session, another agent, or a different tool — auto-allowed. Your trust decisions travel across Claude Code, Codex, Cursor, and any hook-compatible agent. +ClawGuard remembers. When you approve an operation, it's stored. Next time the same pattern appears — in this session, another agent, or a different tool — auto-allowed. Your trust decisions travel across Claude Code and other hook-compatible agents. Dangerous operations (`rm -rf`, `git push --force`, `curl|bash`) are still caught automatically. But that's not why you install ClawGuard — you install it because your agents get faster and quieter. @@ -39,7 +39,7 @@ Second time: Agent tries `npm install foo` → Auto-allowed (no interruption) ✓ Dangerous: Agent tries `rm -rf /` - → Always blocked, always explained ✗ + → Caught, explained, requires your OK ✗ ``` ## Choose How Quiet @@ -69,11 +69,12 @@ CLI args > Project (`.clawguard.yaml`) > Global (`~/.config/clawguard/`) > Defau **Layer 1 — Smart Approval** (hooks-based, no Docker required) - Cross-agent decision memory (SQLite) — remembers what you approved -- Community reputation data in confirm dialogs — see what others decided +- Community reputation data in confirm dialogs (coming soon) - Adapter → Policy Engine → allow/confirm/deny (under 100ms) - Security Passport for compliance proof -**Layer 2 — Infrastructure Isolation** (Docker, optional) +**Layer 2 — Infrastructure Isolation** (Docker, optional reference implementation) +- `claw-guard docker init` to get docker-compose templates - 3 containers: gateway / fetcher / agent - Network segmentation (agent cannot reach external) @@ -93,7 +94,7 @@ ClawGuard silently catches dangerous operations. You don't need to configure any | `BASH.ROOT_PATH_OP` | bash | high | Operations targeting `/` | | `BASH.PIPE_EXEC_001` | bash | high | `curl \| bash` pipe execution | | `BASH.PIPE_EXEC_002` | bash | high | `wget \| sh` pipe execution | -| `BASH.SSH_KEY_READ` | bash | medium | SSH key file access | +| `BASH.SSH_KEY_READ` | bash | high | SSH key file access | | `BASH.ENV_FILE_READ` | bash | medium | `.env` file access | | `BASH.NPM_INSTALL` | bash | medium | `npm install ` | | `BASH.PIP_INSTALL` | bash | medium | `pip install ` | @@ -125,7 +126,7 @@ ClawGuard silently catches dangerous operations. You don't need to configure any | `claw-guard evaluate --json` | Evaluate a tool request (hook entry point) | | `claw-guard test` | Validate rules, engine, and configuration | | `claw-guard stats` | View auto-allow count and decision summary | -| `claw-guard serve` | HTTP hook server (1-3ms latency) | +| `claw-guard serve` | HTTP hook server (low-latency) | | `claw-guard log` | View audit log | | `claw-guard dashboard` | Open web dashboard | | `claw-guard feed` | Manage threat feed (`--update`, `--status`) | @@ -136,6 +137,7 @@ ClawGuard silently catches dangerous operations. You don't need to configure any | `claw-guard monitor` | False positive monitoring | | `claw-guard docker` | Docker deployment (`init`, `up`, `down`) | | `claw-guard skills` | Skills AV scanning | +| `claw-guard team` | Team management (serve/add/list/remove/policy) | ### Rule Format (YAML) @@ -189,7 +191,10 @@ packages/ ├── web-ui/ React dashboard ├── lp/ Landing page (EN + JP) ├── webhook/ Stripe webhook (Cloudflare Worker) -└── docker/ 3-container reference implementation +├── docker/ 3-container reference implementation +├── api/ REST API server +├── sdk/ Embedded SDK for integrations +└── siem/ SIEM connector rules/ ├── core/ 12 core rules (Phase 0-1) └── phase2/ 15 additional rules @@ -204,7 +209,7 @@ npm install # Build all packages npm run build -# Run tests (375 tests) +# Run tests (380 tests) npm test # Lint diff --git a/package.json b/package.json index f26c19f..e9b5d30 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,7 @@ { "name": "claw-guard-monorepo", "private": true, - "workspaces": [ - "packages/*" - ], + "workspaces": ["packages/*"], "scripts": { "build": "npm run build -w packages/core -w packages/memory && npm run build -w packages/audit -w packages/billing -w packages/enrichment -w packages/feed -w packages/reputation -w packages/skills-av -w packages/team && npm run build -w packages/adapter-claude -w packages/adapter-codex -w packages/adapter-mcp -w packages/passport -w packages/replay && npm run build -w packages/cli -w packages/web-ui -w packages/lp -w packages/docker", "test": "vitest run", diff --git a/packages/adapter-claude/src/hook-handler.ts b/packages/adapter-claude/src/hook-handler.ts index 97bed95..904aebd 100644 --- a/packages/adapter-claude/src/hook-handler.ts +++ b/packages/adapter-claude/src/hook-handler.ts @@ -13,9 +13,7 @@ const ANTI_EVASION_HINT = { en: "\n\n🚫 Do not modify the command to bypass this security check.", } as const; -function decisionToClaudeAction( - decision: PolicyDecision, -): "allow" | "deny" | "ask" { +function decisionToClaudeAction(decision: PolicyDecision): "allow" | "deny" | "ask" { switch (decision.action) { case "deny": return "deny"; diff --git a/packages/adapter-claude/src/types.ts b/packages/adapter-claude/src/types.ts index edf97a8..869a603 100644 --- a/packages/adapter-claude/src/types.ts +++ b/packages/adapter-claude/src/types.ts @@ -21,4 +21,4 @@ const NON_INTERVENTION_MODES = new Set(["bypassPermissions", "dontAsk"]); export function shouldIntervene(permissionMode: string): boolean { return !NON_INTERVENTION_MODES.has(permissionMode); -} \ No newline at end of file +} diff --git a/packages/billing/src/feature-gate.ts b/packages/billing/src/feature-gate.ts index 8ef062d..cd56daa 100644 --- a/packages/billing/src/feature-gate.ts +++ b/packages/billing/src/feature-gate.ts @@ -2,24 +2,61 @@ import type { LicenseInfo } from "@clawguard/core"; // All features unlocked — ClawGuard is 100% free export class FeatureGate { + // biome-ignore lint/complexity/noUselessConstructor: maintains API contract for future license gating constructor(_license: LicenseInfo) {} - canLoadPhase2Rules(): boolean { return true; } - canUseFeed(): boolean { return true; } - canUseDailyFeed(): boolean { return true; } - canUseReputation(): boolean { return true; } - canUseMarketplace(): boolean { return true; } - getMaxRules(): number { return Number.MAX_SAFE_INTEGER; } - getPlan(): string { return "free"; } - canUsePassport(): boolean { return true; } - canUseOrgPassport(): boolean { return true; } - canUseFullReplay(): boolean { return true; } - canUseCausalChain(): boolean { return true; } - canExportReplay(): boolean { return true; } - getReplayRetentionDays(): number { return -1; } - canUseSkillsAV(): boolean { return true; } - canUseTeam(): boolean { return true; } - canUseTeamAdmin(): boolean { return true; } - canUseCentralizedAudit(): boolean { return true; } - canUseCrossTeamMemory(): boolean { return true; } + canLoadPhase2Rules(): boolean { + return true; + } + canUseFeed(): boolean { + return true; + } + canUseDailyFeed(): boolean { + return true; + } + canUseReputation(): boolean { + return true; + } + canUseMarketplace(): boolean { + return true; + } + getMaxRules(): number { + return Number.MAX_SAFE_INTEGER; + } + getPlan(): string { + return "free"; + } + canUsePassport(): boolean { + return true; + } + canUseOrgPassport(): boolean { + return true; + } + canUseFullReplay(): boolean { + return true; + } + canUseCausalChain(): boolean { + return true; + } + canExportReplay(): boolean { + return true; + } + getReplayRetentionDays(): number { + return -1; + } + canUseSkillsAV(): boolean { + return true; + } + canUseTeam(): boolean { + return true; + } + canUseTeamAdmin(): boolean { + return true; + } + canUseCentralizedAudit(): boolean { + return true; + } + canUseCrossTeamMemory(): boolean { + return true; + } } diff --git a/packages/cli/build.mjs b/packages/cli/build.mjs index 98e4d73..0ec5dc2 100644 --- a/packages/cli/build.mjs +++ b/packages/cli/build.mjs @@ -23,4 +23,17 @@ await build({ // Copy rules into package root (included via "files" in package.json) cpSync("../../rules", "./rules", { recursive: true }); -console.log("Build complete: dist/index.js + rules/"); +// Copy docker templates (docker-compose.yml, Dockerfiles, scripts) +rmSync("docker", { recursive: true, force: true }); +cpSync("../docker", "./docker", { + recursive: true, + filter: (src) => + !src.includes("node_modules") && + !/\/src(\/|$)/.test(src) && + !/\/dist(\/|$)/.test(src) && + !src.includes("tsconfig") && + !src.includes("package.json") && + !src.includes("__tests__"), +}); + +console.log("Build complete: dist/index.js + rules/ + docker/"); diff --git a/packages/cli/docker/.env.example b/packages/cli/docker/.env.example new file mode 100644 index 0000000..9d3830e --- /dev/null +++ b/packages/cli/docker/.env.example @@ -0,0 +1,4 @@ +# ClawGuard Docker Configuration +CLAWGUARD_PROFILE=balanced +FEED_URL=https://feed.clawguard-sec.com/v1/latest +AGENT_IMAGE=openclaw/openclaw:stable diff --git a/packages/cli/docker/agent/Dockerfile b/packages/cli/docker/agent/Dockerfile new file mode 100644 index 0000000..662afdf --- /dev/null +++ b/packages/cli/docker/agent/Dockerfile @@ -0,0 +1,4 @@ +FROM openclaw/openclaw:stable + +# Agent container runs on internal network only +# No external network access - must go through gateway diff --git a/packages/cli/docker/docker-compose.yml b/packages/cli/docker/docker-compose.yml new file mode 100644 index 0000000..574df73 --- /dev/null +++ b/packages/cli/docker/docker-compose.yml @@ -0,0 +1,57 @@ +services: + gateway: + build: ./gateway + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + networks: + - internal + - external + volumes: + - logs:/var/log/claw-guard:rw + - feedcache:/var/lib/claw-guard:rw + environment: + - CLAWGUARD_PROFILE=${CLAWGUARD_PROFILE:-balanced} + - FEED_URL=${FEED_URL:-} + ports: + - "127.0.0.1:19280:19280" + tmpfs: + - /tmp:size=64M + + fetcher: + build: ./fetcher + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + networks: + - external + tmpfs: + - /tmp:size=64M + + agent: + image: ${AGENT_IMAGE:-openclaw/openclaw:stable} + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + networks: + - internal + depends_on: + - gateway + tmpfs: + - /tmp:size=256M + +networks: + internal: + internal: true + external: + internal: false + +volumes: + logs: + feedcache: diff --git a/packages/cli/docker/fetcher/Dockerfile b/packages/cli/docker/fetcher/Dockerfile new file mode 100644 index 0000000..cb0cae6 --- /dev/null +++ b/packages/cli/docker/fetcher/Dockerfile @@ -0,0 +1,8 @@ +FROM node:22-slim + +RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates && rm -rf /var/lib/apt/lists/* + +COPY fetcher.sh /usr/local/bin/fetcher.sh +RUN chmod +x /usr/local/bin/fetcher.sh + +ENTRYPOINT ["/usr/local/bin/fetcher.sh"] diff --git a/packages/cli/docker/fetcher/fetcher.sh b/packages/cli/docker/fetcher/fetcher.sh new file mode 100644 index 0000000..9d2dc62 --- /dev/null +++ b/packages/cli/docker/fetcher/fetcher.sh @@ -0,0 +1,10 @@ +#!/bin/sh +# Simple HTTP fetcher proxy for ClawGuard Docker setup +# Listens on port 8080 and proxies external HTTP requests + +echo "ClawGuard Fetcher ready" + +# Keep container running - in production, this would be a proper HTTP proxy +while true; do + sleep 3600 +done diff --git a/packages/cli/docker/gateway/Dockerfile b/packages/cli/docker/gateway/Dockerfile new file mode 100644 index 0000000..7461a46 --- /dev/null +++ b/packages/cli/docker/gateway/Dockerfile @@ -0,0 +1,7 @@ +FROM node:22-slim + +RUN npm install -g @clawguard-sec/cli + +EXPOSE 19280 + +ENTRYPOINT ["claw-guard", "serve", "--host", "0.0.0.0", "--port", "19280"] diff --git a/packages/cli/package.json b/packages/cli/package.json index fe6cd19..6f82cc4 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -7,7 +7,7 @@ "bin": { "claw-guard": "./dist/index.js" }, - "files": ["dist", "rules"], + "files": ["dist", "rules", "docker"], "publishConfig": { "access": "public" }, diff --git a/packages/cli/src/__tests__/integration.test.ts b/packages/cli/src/__tests__/integration.test.ts index 953d9a8..ff42346 100644 --- a/packages/cli/src/__tests__/integration.test.ts +++ b/packages/cli/src/__tests__/integration.test.ts @@ -377,3 +377,99 @@ describe("Integration: HTTP Server", () => { expect(res.headers.get("access-control-allow-origin")).toBe("*"); }); }); + +// ==================================================================== +// Group 5: Historical Auto-Allow (Cross-Session Memory) +// ==================================================================== + +describe("Integration: Historical Auto-Allow", () => { + let ctx: EngineContext & { tmpDir: string }; + + beforeAll(() => { + ctx = createIsolatedContext(); + }); + + afterAll(() => { + ctx.store?.close(); + rmSync(ctx.tmpDir, { recursive: true, force: true }); + }); + + // Use CHMOD_777 (medium, bundled) and RM_RISK (high, bundled) with unique paths per test + // to avoid session allowlist contamination between tests + + it("auto-allows medium-risk operation after 2 historical confirms", () => { + const cmd = "chmod 777 /tmp/hist-test-1"; + const contentHash = DecisionStore.hashContent(cmd); + const store = ctx.store as DecisionStore; + store.record({ + rule_id: "BASH.CHMOD_777", + action: "confirm", + content_hash: contentHash, + session_id: "past-1", + }); + store.record({ + rule_id: "BASH.CHMOD_777", + action: "confirm", + content_hash: contentHash, + session_id: "past-2", + }); + + const result = evaluateHookRequest(hookInput(cmd), ctx); + expect(result.output).not.toBeNull(); + expect(result.output?.hookSpecificOutput.permissionDecision).toBe("allow"); + }); + + it("does NOT auto-allow high-risk operation with only 2 confirms (needs 5)", () => { + const cmd = "rm -rf /tmp/hist-test-2"; + const contentHash = DecisionStore.hashContent(cmd); + const store = ctx.store as DecisionStore; + store.record({ + rule_id: "BASH.RM_RISK", + action: "confirm", + content_hash: contentHash, + session_id: "past-1", + }); + store.record({ + rule_id: "BASH.RM_RISK", + action: "confirm", + content_hash: contentHash, + session_id: "past-2", + }); + + const result = evaluateHookRequest(hookInput(cmd), ctx); + expect(result.output?.hookSpecificOutput.permissionDecision).toBe("deny"); + }); + + it("auto-allows high-risk operation after 5 historical confirms", () => { + const cmd = "rm -rf /tmp/hist-test-3"; + const contentHash = DecisionStore.hashContent(cmd); + const store = ctx.store as DecisionStore; + for (let i = 1; i <= 5; i++) { + store.record({ + rule_id: "BASH.RM_RISK", + action: "confirm", + content_hash: contentHash, + session_id: `past-${i}`, + }); + } + + const result = evaluateHookRequest(hookInput(cmd), ctx); + expect(result.output).not.toBeNull(); + expect(result.output?.hookSpecificOutput.permissionDecision).toBe("allow"); + }); + + it("does NOT auto-allow with only 1 confirm (below threshold)", () => { + const cmd = "chmod 777 /tmp/hist-test-4"; + const contentHash = DecisionStore.hashContent(cmd); + const store = ctx.store as DecisionStore; + store.record({ + rule_id: "BASH.CHMOD_777", + action: "confirm", + content_hash: contentHash, + session_id: "past-1", + }); + + const result = evaluateHookRequest(hookInput(cmd), ctx); + expect(result.output?.hookSpecificOutput.permissionDecision).toBe("deny"); + }); +}); diff --git a/packages/cli/src/commands/docker.ts b/packages/cli/src/commands/docker.ts index 0041d5b..404d72b 100644 --- a/packages/cli/src/commands/docker.ts +++ b/packages/cli/src/commands/docker.ts @@ -37,7 +37,10 @@ const MSG = { }; function getTemplateDir(): string { - const candidates = [resolve(import.meta.dirname, "../../../docker")]; + const candidates = [ + resolve(import.meta.dirname, "../docker"), // npm installed (dist/../docker) + resolve(import.meta.dirname, "../../../docker"), // dev (packages/cli/src → packages/docker) + ]; for (const c of candidates) { if (existsSync(c)) return c; } diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 20bea0e..acdf235 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -18,17 +18,75 @@ interface PresetRow { } const PRESETS_JA: PresetRow[] = [ - { name: "observer", label: "(見るだけ) ", high: "ログ", medium: "ログ", low: "ログ", desc: "何もブロックせず記録。AIの動きを把握したい時に" }, - { name: "guardian", label: "(しっかり) ", high: "拒否", medium: "確認", low: "許可", desc: "よく確認する。AI初心者に最適" }, - { name: "balanced", label: "(バランス) ", high: "確認", medium: "確認", low: "許可", desc: "時々確認。すぐ学習する。ほとんどの人に最適", recommended: true }, - { name: "expert", label: "(静か) ", high: "確認", medium: "許可", low: "許可", desc: "ほぼ無音。本当に危ない操作だけ確認" }, + { + name: "observer", + label: "(見るだけ) ", + high: "ログ", + medium: "ログ", + low: "ログ", + desc: "何もブロックせず記録。AIの動きを把握したい時に", + }, + { + name: "guardian", + label: "(しっかり) ", + high: "拒否", + medium: "確認", + low: "許可", + desc: "よく確認する。AI初心者に最適", + }, + { + name: "balanced", + label: "(バランス) ", + high: "確認", + medium: "確認", + low: "許可", + desc: "時々確認。すぐ学習する。ほとんどの人に最適", + recommended: true, + }, + { + name: "expert", + label: "(静か) ", + high: "確認", + medium: "許可", + low: "許可", + desc: "ほぼ無音。本当に危ない操作だけ確認", + }, ]; const PRESETS_EN: PresetRow[] = [ - { name: "observer", label: "(Watch only)", high: "log ", medium: "log ", low: "log ", desc: "Logs everything, blocks nothing. See what your agent does." }, - { name: "guardian", label: "(Careful) ", high: "deny ", medium: "confirm", low: "allow", desc: "Asks often. Best for getting started with AI agents." }, - { name: "balanced", label: "(Balanced) ", high: "confirm", medium: "confirm", low: "allow", desc: "Asks sometimes. Learns fast. Best for most users.", recommended: true }, - { name: "expert", label: "(Quiet) ", high: "confirm", medium: "allow ", low: "allow", desc: "Almost silent. Only flags truly dangerous operations." }, + { + name: "observer", + label: "(Watch only)", + high: "log ", + medium: "log ", + low: "log ", + desc: "Logs everything, blocks nothing. See what your agent does.", + }, + { + name: "guardian", + label: "(Careful) ", + high: "deny ", + medium: "confirm", + low: "allow", + desc: "Asks often. Best for getting started with AI agents.", + }, + { + name: "balanced", + label: "(Balanced) ", + high: "confirm", + medium: "confirm", + low: "allow", + desc: "Asks sometimes. Learns fast. Best for most users.", + recommended: true, + }, + { + name: "expert", + label: "(Quiet) ", + high: "confirm", + medium: "allow ", + low: "allow", + desc: "Almost silent. Only flags truly dangerous operations.", + }, ]; const DEFAULT_INDEX = 2; // balanced (0-based) @@ -78,7 +136,9 @@ export async function initCommand(options: { profile?: string; agent?: string }) const p = presets[i]; const num = chalk.bold(`[${i + 1}]`); const rec = p.recommended ? chalk.cyan(` ${m.rec}`) : ""; - console.log(` ${num} ${chalk.bold(p.name.padEnd(10))}${p.label} ${p.high} ${p.medium} ${p.low}${rec}`); + console.log( + ` ${num} ${chalk.bold(p.name.padEnd(10))}${p.label} ${p.high} ${p.medium} ${p.low}${rec}`, + ); console.log(` ${chalk.dim(p.desc)}`); console.log(""); } diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index e32c362..70afa35 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -57,7 +57,12 @@ function corsHeaders(req?: IncomingMessage): Record { }; } -function jsonResponse(res: ServerResponse, status: number, data: unknown, req?: IncomingMessage): void { +function jsonResponse( + res: ServerResponse, + status: number, + data: unknown, + req?: IncomingMessage, +): void { res.writeHead(status, { "Content-Type": "application/json", ...corsHeaders(req) }); res.end(JSON.stringify(data)); } diff --git a/packages/cli/src/commands/stats.ts b/packages/cli/src/commands/stats.ts index 20b7366..2ef3cc4 100644 --- a/packages/cli/src/commands/stats.ts +++ b/packages/cli/src/commands/stats.ts @@ -13,7 +13,8 @@ const MSG = { confirmed: "確認要求", denied: "拒否", agents: "エージェント数", - noData: "まだ判断データがありません。claw-guard init を実行してAIエージェントを使い始めてください。", + noData: + "まだ判断データがありません。claw-guard init を実行してAIエージェントを使い始めてください。", }, en: { title: "ClawGuard Decision Summary", @@ -46,11 +47,15 @@ export async function statsCommand(): Promise { console.log(chalk.underline(m.today)); console.log(` ${chalk.green(`${today.autoAllowed}`)} ${m.autoAllowed}`); - console.log(` ${today.total} ${m.totalDecisions} | ${chalk.green(today.allowed)} ${m.allowed} | ${chalk.yellow(today.confirmed)} ${m.confirmed} | ${chalk.red(today.denied)} ${m.denied}`); + console.log( + ` ${today.total} ${m.totalDecisions} | ${chalk.green(today.allowed)} ${m.allowed} | ${chalk.yellow(today.confirmed)} ${m.confirmed} | ${chalk.red(today.denied)} ${m.denied}`, + ); console.log(chalk.underline(`\n${m.allTime}`)); console.log(` ${chalk.green.bold(`${all.autoAllowed}`)} ${m.autoAllowed}`); - console.log(` ${all.total} ${m.totalDecisions} | ${chalk.green(all.allowed)} ${m.allowed} | ${chalk.yellow(all.confirmed)} ${m.confirmed} | ${chalk.red(all.denied)} ${m.denied}`); + console.log( + ` ${all.total} ${m.totalDecisions} | ${chalk.green(all.allowed)} ${m.allowed} | ${chalk.yellow(all.confirmed)} ${m.confirmed} | ${chalk.red(all.denied)} ${m.denied}`, + ); if (all.agents > 0) { console.log(` ${all.agents} ${m.agents}`); } diff --git a/packages/cli/src/engine-factory.ts b/packages/cli/src/engine-factory.ts index f816b60..ff22f2a 100644 --- a/packages/cli/src/engine-factory.ts +++ b/packages/cli/src/engine-factory.ts @@ -125,7 +125,11 @@ export function createEngineContext(overrideLang?: Lang): EngineContext { const engine = new PolicyEngine(rules, preset, feedVersion, config.project_overrides); const writer = new AuditWriter(); const store = new DecisionStore(); - try { store.cleanExpiredSessions(24); } catch { /* non-fatal */ } + try { + store.cleanExpiredSessions(24); + } catch { + /* non-fatal */ + } const reputation = new ReputationAggregator(store, feedBundle?.reputation); // Telemetry upload is always enabled (anonymous aggregate stats). @@ -165,17 +169,31 @@ export function evaluateHookRequest(rawInput: string, ctx: EngineContext): EvalR const decision = ctx.engine.evaluate(request); const contentHash = DecisionStore.hashContent(request.content); + let autoAllowReason: string | undefined; - // Auto-allow: session allowlist only (same session, same content, same rule) + // Auto-allow: session allowlist (same session, same content, same rule) if (decision.action === "confirm" && ctx.store) { if (ctx.store.isSessionAllowed(request.context.session_id, contentHash, decision.rule_id)) { decision.action = "allow"; + autoAllowReason = "session"; + } + } + + // Auto-allow: cross-session historical memory (confirmed 2+ times for same content+rule) + if (decision.action === "confirm" && ctx.store) { + const minCount = decision.risk === "high" ? 5 : 2; + if (ctx.store.isHistoricallyAllowed(contentHash, decision.rule_id, minCount)) { + decision.action = "allow"; + autoAllowReason = "historical"; } } const output = buildHookOutput(decision, ctx.lang); const event = createOcsfEvent(request, decision); + if (autoAllowReason) { + event.enrichments.push({ name: "auto_allow_reason", value: autoAllowReason }); + } ctx.writer.write(event); if (ctx.store) { @@ -188,7 +206,7 @@ export function evaluateHookRequest(rawInput: string, ctx: EngineContext): EvalR }); } - // Non-high confirm: pre-register session allowlist, return deny with retry hint + // Confirm: pre-register session allowlist, return deny with retry hint // (deny reason is shown to Claude, who relays explanation to user then retries) if (decision.action === "confirm" && ctx.store && output) { ctx.store.recordSessionAllow(request.context.session_id, contentHash, decision.rule_id); @@ -231,16 +249,31 @@ export async function evaluateHookRequestAsync( } } - // Auto-allow: session allowlist only (same session, same content, same rule) + let autoAllowReason: string | undefined; + + // Auto-allow: session allowlist (same session, same content, same rule) if (decision.action === "confirm" && ctx.store) { if (ctx.store.isSessionAllowed(request.context.session_id, contentHash, decision.rule_id)) { decision.action = "allow"; + autoAllowReason = "session"; + } + } + + // Auto-allow: cross-session historical memory (confirmed 2+ times for same content+rule) + if (decision.action === "confirm" && ctx.store) { + const minCount = decision.risk === "high" ? 5 : 2; + if (ctx.store.isHistoricallyAllowed(contentHash, decision.rule_id, minCount)) { + decision.action = "allow"; + autoAllowReason = "historical"; } } const output = buildHookOutput(decision, ctx.lang); const event = createOcsfEvent(request, decision); + if (autoAllowReason) { + event.enrichments.push({ name: "auto_allow_reason", value: autoAllowReason }); + } ctx.writer.write(event); if (ctx.store) { @@ -253,7 +286,7 @@ export async function evaluateHookRequestAsync( }); } - // Non-high confirm: pre-register session allowlist, return deny with retry hint + // Confirm: pre-register session allowlist, return deny with retry hint if (decision.action === "confirm" && ctx.store && output) { ctx.store.recordSessionAllow(request.context.session_id, contentHash, decision.rule_id); const reason = output.hookSpecificOutput.permissionDecisionReason ?? ""; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index b06ab3c..054e7f6 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -17,7 +17,6 @@ import { statsCommand } from "./commands/stats.js"; import { teamCommand } from "./commands/team.js"; import { testCommand } from "./commands/test.js"; - const program = new Command(); program diff --git a/packages/core/rules/core/bash-env-file-read.yaml b/packages/core/rules/core/bash-env-file-read.yaml new file mode 100644 index 0000000..ceae05d --- /dev/null +++ b/packages/core/rules/core/bash-env-file-read.yaml @@ -0,0 +1,28 @@ +- id: BASH.ENV_FILE_READ + match: + tool: bash + command_regex: '(?:^|\s|;|&&|\|\|)cat\s+.*\.env(?:\.\w+)?(?:\s|$)' + risk: medium + explain: + title: "環境変数の読み取り" + what: ".envファイル(パスワードやAPIキーが入っていることが多いファイル)を表示しようとしています。" + why: + - "APIキーやパスワードが画面やログに表示される可能性があります。" + - "表示された情報がチャット履歴に残る可能性があります。" + check: + - ".envファイルにAPIキーやパスワードは入っている?" + - "特定の変数だけ確認すれば足りない?" + alternatives: + - "`grep KEY_NAME .env` で特定の変数だけ確認する" + - "`cat .env.example` でテンプレートを確認する" + meta: + author: "clawguard" + pack: "core" + version: "1.0.0" + tags: ["secret", "credential"] + phase: 1 + marketplace: + status: "active" + downloads: 0 + rating: 0.0 + override_rate: 0.0 diff --git a/packages/core/rules/core/bash-npm-install.yaml b/packages/core/rules/core/bash-npm-install.yaml new file mode 100644 index 0000000..ab1752b --- /dev/null +++ b/packages/core/rules/core/bash-npm-install.yaml @@ -0,0 +1,28 @@ +- id: BASH.NPM_INSTALL + match: + tool: bash + command_regex: '(?:^|\s|;|&&|\|\|)npm\s+(?:install|i|add)\s+\S' + risk: medium + explain: + title: "npm追加" + what: "新しいnpmパッケージをインストールしようとしています。" + why: + - "悪意あるパッケージがインストール時にスクリプトを実行する可能性があります。" + - "typosquatting(名前の似た偽パッケージ)の可能性があります。" + check: + - "パッケージ名のスペルは正しい?" + - "このパッケージは信頼できる?(ダウンロード数・メンテナ)" + alternatives: + - "`npm info ` で先にパッケージ情報を確認する" + - "npmjs.com でパッケージの信頼性を確認する" + meta: + author: "clawguard" + pack: "core" + version: "1.0.0" + tags: ["package", "supply-chain"] + phase: 1 + marketplace: + status: "active" + downloads: 0 + rating: 0.0 + override_rate: 0.0 diff --git a/packages/core/rules/core/bash-pip-install.yaml b/packages/core/rules/core/bash-pip-install.yaml new file mode 100644 index 0000000..3f9861b --- /dev/null +++ b/packages/core/rules/core/bash-pip-install.yaml @@ -0,0 +1,28 @@ +- id: BASH.PIP_INSTALL + match: + tool: bash + command_regex: '(?:^|\s|;|&&|\|\|)pip3?\s+install\s+(?!-r\s|-e\s)\S' + risk: medium + explain: + title: "pip追加" + what: "新しいPythonパッケージをインストールしようとしています。" + why: + - "悪意あるパッケージがインストール時にコードを実行する可能性があります。" + - "typosquatting(名前の似た偽パッケージ)の可能性があります。" + check: + - "パッケージ名のスペルは正しい?" + - "このパッケージは信頼できる?(PyPIでの情報を確認)" + alternatives: + - "`pip show ` で先にパッケージ情報を確認する" + - "PyPI でパッケージの信頼性を確認する" + meta: + author: "clawguard" + pack: "core" + version: "1.0.0" + tags: ["package", "supply-chain"] + phase: 1 + marketplace: + status: "active" + downloads: 0 + rating: 0.0 + override_rate: 0.0 diff --git a/packages/core/rules/core/bash-ssh-key-read.yaml b/packages/core/rules/core/bash-ssh-key-read.yaml new file mode 100644 index 0000000..6eff36a --- /dev/null +++ b/packages/core/rules/core/bash-ssh-key-read.yaml @@ -0,0 +1,28 @@ +- id: BASH.SSH_KEY_READ + match: + tool: bash + command_regex: '(?:^|\s|;|&&|\|\|)cat\s+.*(?:~\/\.ssh\/|\/etc\/ssh\/|\.ssh\/)' + risk: high + explain: + title: "SSH鍵の読み取り" + what: "SSH秘密鍵や設定ファイルの内容を表示しようとしています。" + why: + - "秘密鍵が漏れると、サーバーに不正アクセスされる可能性があります。" + - "表示された鍵がログやチャットに残る可能性があります。" + check: + - "秘密鍵の内容を本当に確認する必要がある?" + - "公開鍵(.pub)のほうで足りない?" + alternatives: + - "`ls ~/.ssh/` でファイル一覧だけ確認する" + - "`ssh-keygen -l -f ~/.ssh/id_rsa` で鍵の指紋だけ確認する" + meta: + author: "clawguard" + pack: "core" + version: "1.0.0" + tags: ["secret", "credential"] + phase: 1 + marketplace: + status: "active" + downloads: 0 + rating: 0.0 + override_rate: 0.0 diff --git a/packages/lp/src/components/HeroSection.tsx b/packages/lp/src/components/HeroSection.tsx index 2b8170a..efdb067 100644 --- a/packages/lp/src/components/HeroSection.tsx +++ b/packages/lp/src/components/HeroSection.tsx @@ -55,7 +55,9 @@ export function HeroSection({ content }: Props) {

{content.terminal.agentSession}

{content.terminal.command}

- {"✓"} {content.terminal.confirmLabel}{" "} + + {"✓"} {content.terminal.confirmLabel} + {" "}

{content.terminal.confirmDetail}

diff --git a/packages/lp/src/components/PricingCards.tsx b/packages/lp/src/components/PricingCards.tsx index 9d3d1d7..650f56f 100644 --- a/packages/lp/src/components/PricingCards.tsx +++ b/packages/lp/src/components/PricingCards.tsx @@ -35,11 +35,7 @@ export function PricingCards({ content }: Props) { aria-hidden="true" > Check - + {feature} diff --git a/packages/lp/src/content/jp.ts b/packages/lp/src/content/jp.ts index 8652828..97b5ab4 100644 --- a/packages/lp/src/content/jp.ts +++ b/packages/lp/src/content/jp.ts @@ -23,8 +23,7 @@ export const jp: LPContent = { agentSession: "--- AIエージェントセッション ---", command: "$ npm install express", confirmLabel: "自動許可", - confirmDetail: - "3日前にOK済み。開発者の94%が許可しています。", + confirmDetail: "3日前にOK済み。開発者の94%が許可しています。", }, }, features: { diff --git a/packages/memory/src/__tests__/decision-store.test.ts b/packages/memory/src/__tests__/decision-store.test.ts index 35ba8c6..5de3768 100644 --- a/packages/memory/src/__tests__/decision-store.test.ts +++ b/packages/memory/src/__tests__/decision-store.test.ts @@ -106,8 +106,18 @@ describe("DecisionStore", () => { }); it("returns true with 2+ past confirms", () => { - store.record({ rule_id: "BASH.NPM_INSTALL", action: "confirm", content_hash: "hash1", session_id: "s1" }); - store.record({ rule_id: "BASH.NPM_INSTALL", action: "confirm", content_hash: "hash1", session_id: "s2" }); + store.record({ + rule_id: "BASH.NPM_INSTALL", + action: "confirm", + content_hash: "hash1", + session_id: "s1", + }); + store.record({ + rule_id: "BASH.NPM_INSTALL", + action: "confirm", + content_hash: "hash1", + session_id: "s2", + }); expect(store.isHistoricallyAllowed("hash1", "BASH.NPM_INSTALL")).toBe(true); }); diff --git a/packages/memory/src/decision-store.ts b/packages/memory/src/decision-store.ts index e52382e..7759299 100644 --- a/packages/memory/src/decision-store.ts +++ b/packages/memory/src/decision-store.ts @@ -137,21 +137,27 @@ export class DecisionStore { isHistoricallyAllowed(contentHash: string, ruleId: string, minCount = 2): boolean { const row = this.db - .prepare("SELECT COUNT(*) as count FROM decisions WHERE content_hash = ? AND rule_id = ? AND action = 'confirm'") + .prepare( + "SELECT COUNT(*) as count FROM decisions WHERE content_hash = ? AND rule_id = ? AND action = 'confirm'", + ) .get(contentHash, ruleId) as { count: number }; return row.count >= minCount; } isSessionAllowed(sessionId: string, contentHash: string, ruleId: string): boolean { const row = this.db - .prepare("SELECT 1 FROM session_allowlist WHERE session_id = ? AND content_hash = ? AND rule_id = ?") + .prepare( + "SELECT 1 FROM session_allowlist WHERE session_id = ? AND content_hash = ? AND rule_id = ?", + ) .get(sessionId, contentHash, ruleId); return row != null; } recordSessionAllow(sessionId: string, contentHash: string, ruleId: string): void { this.db - .prepare("INSERT OR IGNORE INTO session_allowlist (session_id, content_hash, rule_id) VALUES (?, ?, ?)") + .prepare( + "INSERT OR IGNORE INTO session_allowlist (session_id, content_hash, rule_id) VALUES (?, ?, ?)", + ) .run(sessionId, contentHash, ruleId); } @@ -168,13 +174,20 @@ export class DecisionStore { .get(sessionId) as { count: number }; return row.count; } - const row = this.db - .prepare("SELECT COUNT(*) as count FROM session_allowlist") - .get() as { count: number }; + const row = this.db.prepare("SELECT COUNT(*) as count FROM session_allowlist").get() as { + count: number; + }; return row.count; } - getStatsSummary(): { total: number; allowed: number; denied: number; confirmed: number; autoAllowed: number; agents: number } { + getStatsSummary(): { + total: number; + allowed: number; + denied: number; + confirmed: number; + autoAllowed: number; + agents: number; + } { const decisions = this.db .prepare( `SELECT @@ -192,7 +205,13 @@ export class DecisionStore { return { ...decisions, autoAllowed, agents: agents.count }; } - getTodayStats(): { total: number; allowed: number; denied: number; confirmed: number; autoAllowed: number } { + getTodayStats(): { + total: number; + allowed: number; + denied: number; + confirmed: number; + autoAllowed: number; + } { const decisions = this.db .prepare( `SELECT diff --git a/packages/replay/src/report-generator.ts b/packages/replay/src/report-generator.ts index 0050319..fb1a588 100644 --- a/packages/replay/src/report-generator.ts +++ b/packages/replay/src/report-generator.ts @@ -215,9 +215,7 @@ export class ReportGenerator { } lines.push("---"); - lines.push( - "*Generated by [ClawGuard](https://clawguard-sec.com) \u2014 AI agent memory*", - ); + lines.push("*Generated by [ClawGuard](https://clawguard-sec.com) \u2014 AI agent memory*"); lines.push(""); return lines.join("\n"); From ea9f29f9282362fa77adce9f5d6cc8b157276b07 Mon Sep 17 00:00:00 2001 From: Goki602 Date: Wed, 25 Mar 2026 20:19:02 +0900 Subject: [PATCH 5/6] chore: remove pricing/license references, add LLM discoverability (comparison section, llms.txt, keywords) - Replace "license key" with "API key" in CLI commands - Remove plan display from `claw-guard test` output - Add "Why ClawGuard?" comparison table to README (EN + JA) - Add llms.txt for LLM crawlers - Expand npm keywords for search discoverability - Update billing package description to reflect free model Co-Authored-By: Claude Opus 4.6 (1M context) --- README.ja.md | 39 ++++++++++++++++++- README.md | 39 ++++++++++++++++++- llms.txt | 54 +++++++++++++++++++++++++++ packages/cli/package.json | 2 +- packages/cli/src/commands/passport.ts | 6 +-- packages/cli/src/commands/test.ts | 3 -- packages/cli/src/index.ts | 2 +- 7 files changed, 133 insertions(+), 12 deletions(-) create mode 100644 llms.txt diff --git a/README.ja.md b/README.ja.md index 046029f..b7e1136 100644 --- a/README.ja.md +++ b/README.ja.md @@ -162,10 +162,45 @@ CLI引数 > プロジェクト(`.clawguard.yaml`) > グローバル(`~/.co phase: 0 ``` +## なぜClawGuard? + +| | 手動hooks | mcp-scan | ClawGuard | +|---|---|---|---| +| エージェント間の記憶 | - | - | **あり** — 一度OKすれば、どこでも自動許可 | +| Claude Code / Codex / MCP対応 | - | MCPのみ | **3つとも対応** | +| コミュニティの知恵 | - | - | **あり** — 他の開発者の判断が見える | +| セットアップ | 自分で書く | インストール+スキャン | `claw-guard init`(1コマンド) | +| 料金 | 無料(自作) | 無料 | **無料(MIT、全機能)** | + +ClawGuardはブロッカーではなく、便利ツールです。あなたの判断を覚えて確認回数を減らし、コミュニティの知恵を共有します。セキュリティは副産物であり、売りではありません。 + ## 料金 完全無料のオープンソース(MIT)。全機能が制限なく使えます。ライセンスキーも課金もありません。 +## テレメトリ(匿名統計) + +ClawGuardは匿名の利用統計を収集し、コミュニティの知恵として還元しています。確認ダイアログで「他の開発者の85%がこの操作を許可しています」のように表示されます。 + +### 送信するもの(6時間ごと) + +- ルールIDごとの集計(許可/拒否/合計の件数のみ) + +### 送信しないもの + +- コマンド内容、ファイルパス、引数 +- ユーザーの身元、IPアドレス、セッション情報 +- プロジェクト名やリポジトリの情報 + +### 無効にするには + +`clawguard.yaml` に以下を追加: + +```yaml +reputation: + opt_in: false +``` + ## プロジェクト構成 ``` @@ -176,7 +211,7 @@ packages/ ├── adapter-claude/ Claude Code PreToolUseフック ├── adapter-codex/ Codex承認ポリシー拡張 ├── adapter-mcp/ MCP JSON-RPCプロキシ -├── billing/ ライセンスマネージャー、機能ゲート +├── billing/ 機能設定(全機能が無制限で利用可能) ├── feed/ 署名付き日次フィードクライアント ├── enrichment/ npmレジストリ、CVE検索 ├── memory/ SQLite判断ストア @@ -187,7 +222,7 @@ packages/ ├── team/ 組織ポリシー&メンバー管理 ├── web-ui/ Reactダッシュボード ├── lp/ ランディングページ(英語+日本語) -├── webhook/ Stripe Webhook(Cloudflare Worker) +├── webhook/ Webhookハンドラー(Cloudflare Worker) ├── docker/ 3コンテナ参考実装 ├── api/ REST APIサーバー ├── sdk/ 組み込み用SDK diff --git a/README.md b/README.md index 9ff65bb..6a9b6f5 100644 --- a/README.md +++ b/README.md @@ -165,10 +165,45 @@ ClawGuard silently catches dangerous operations. You don't need to configure any phase: 0 ``` +## Why ClawGuard? + +| | Manual hooks | mcp-scan | ClawGuard | +|---|---|---|---| +| Cross-agent memory | - | - | **Yes** — approve once, auto-allowed everywhere | +| Works across Claude Code, Codex, MCP | - | MCP only | **All three** | +| Community intelligence | - | - | **Yes** — see what other devs decided | +| Setup | Write your own | Install + scan | `claw-guard init` (one command) | +| Price | Free (DIY) | Free | **Free (MIT, all features)** | + +ClawGuard is not a blocker — it's a convenience layer. It reduces confirm fatigue by remembering your trust decisions and sharing community intelligence. Security is a side effect, not the sales pitch. + ## Pricing Completely free and open source (MIT). All features available to everyone — no license key, no paywalls. +## Telemetry + +ClawGuard collects anonymous, aggregated usage statistics to power community intelligence — showing you what other developers decided for the same operation (e.g., "85% of users allowed this"). + +### What is sent (every 6 hours) + +- Rule IDs and aggregate counts (allowed / denied / total per rule) + +### What is NOT sent + +- Commands, file paths, or arguments +- User identity, IP addresses, or session data +- Project names or repository information + +### Disable telemetry + +Add to your `clawguard.yaml`: + +```yaml +reputation: + opt_in: false +``` + ## Project Structure ``` @@ -179,7 +214,7 @@ packages/ ├── adapter-claude/ Claude Code PreToolUse hook ├── adapter-codex/ Codex approval policy extension ├── adapter-mcp/ MCP JSON-RPC proxy -├── billing/ License manager, feature gate +├── billing/ Feature configuration (all features unlocked) ├── feed/ Signed daily feed client ├── enrichment/ npm registry, CVE lookup ├── memory/ SQLite decision store @@ -190,7 +225,7 @@ packages/ ├── team/ Organization policy & member management ├── web-ui/ React dashboard ├── lp/ Landing page (EN + JP) -├── webhook/ Stripe webhook (Cloudflare Worker) +├── webhook/ Webhook handlers (Cloudflare Worker) ├── docker/ 3-container reference implementation ├── api/ REST API server ├── sdk/ Embedded SDK for integrations diff --git a/llms.txt b/llms.txt new file mode 100644 index 0000000..241a51d --- /dev/null +++ b/llms.txt @@ -0,0 +1,54 @@ +# ClawGuard + +> AI Agent Memory — Fewer Prompts, Smarter Decisions + +## What is ClawGuard? + +ClawGuard is a free, open-source (MIT) cross-platform intelligence layer for AI agents. +It remembers your trust decisions and reduces confirmation fatigue across Claude Code, Codex, and MCP-compatible agents. + +When you approve an operation like `npm install express`, ClawGuard stores that decision. +Next time the same pattern appears — in this session, another agent, or a different tool — it's auto-allowed. +Dangerous operations (rm -rf, git push --force, curl|bash) are still caught automatically. + +## Key Features + +- Cross-agent decision memory (SQLite) — approve once, auto-allowed everywhere +- 12 built-in safety rules — catches destructive operations without configuration +- Community intelligence — see what other developers decided for the same operation +- 4 presets (observer/guardian/balanced/expert) — choose your comfort level +- Works with Claude Code (PreToolUse hooks), Codex, and MCP +- Security Passport — compliance proof with GitHub badges +- Incident replay — trace agent decision chains +- Rule marketplace — share and install community rules +- Completely free — MIT license, all features, no paywalls + +## Installation + +``` +npm install -g @clawguard-sec/cli +claw-guard init +``` + +Also available via Homebrew: `brew tap Goki602/claw-guard && brew install claw-guard` + +## Comparison + +| Feature | Manual hooks | mcp-scan | ClawGuard | +|---|---|---|---| +| Cross-agent memory | No | No | Yes | +| Claude Code + Codex + MCP | No | MCP only | All three | +| Community intelligence | No | No | Yes | +| Setup | Write your own | Install + scan | One command | +| Price | Free (DIY) | Free | Free (MIT) | + +## Links + +- GitHub: https://github.com/Goki602/ClawGuard +- npm: https://www.npmjs.com/package/@clawguard-sec/cli + +## Keywords + +AI agent security, Claude Code hooks, Claude Code security, Codex security, MCP security, +AI guardrails, prompt injection prevention, AI agent monitoring, confirmation fatigue, +cross-agent memory, security policy engine, open source security tool diff --git a/packages/cli/package.json b/packages/cli/package.json index 6f82cc4..1368c73 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -15,7 +15,7 @@ "build": "node build.mjs", "clean": "rm -rf dist rules" }, - "keywords": ["ai", "security", "claude", "agent", "hooks", "audit"], + "keywords": ["ai", "security", "claude", "claude-code", "agent", "hooks", "audit", "ai-agent-security", "codex", "mcp", "ai-guardrails", "prompt-injection", "open-source", "free"], "repository": { "type": "git", "url": "https://github.com/Goki602/ClawGuard.git", diff --git a/packages/cli/src/commands/passport.ts b/packages/cli/src/commands/passport.ts index d881795..9705605 100644 --- a/packages/cli/src/commands/passport.ts +++ b/packages/cli/src/commands/passport.ts @@ -31,7 +31,7 @@ const MSG = { publishing: "パスポートを公開中...", published: "パスポートを公開しました", publishFailed: "公開に失敗しました", - noLicenseKey: "ライセンスキーが必要です。--key オプションで指定してください。", + noApiKey: "APIキーが必要です。--key オプションで指定してください。", }, en: { generating: "Generating security passport...", @@ -58,7 +58,7 @@ const MSG = { publishing: "Publishing passport...", published: "Passport published", publishFailed: "Failed to publish", - noLicenseKey: "License key required. Use --key option.", + noApiKey: "API key required. Use --key option.", }, }; @@ -80,7 +80,7 @@ export async function passportCommand(options: { return; } if (!options.key) { - console.log(chalk.red(m.noLicenseKey)); + console.log(chalk.red(m.noApiKey)); return; } console.log(m.publishing); diff --git a/packages/cli/src/commands/test.ts b/packages/cli/src/commands/test.ts index 67110b0..ce8a780 100644 --- a/packages/cli/src/commands/test.ts +++ b/packages/cli/src/commands/test.ts @@ -24,7 +24,6 @@ const MSG = { ja: { engineOk: (ms: number) => `Policy Engine: OK (${ms}ms)`, rulesLoaded: (n: number) => `ルール: ${n} 件`, - plan: (name: string) => `プラン: ${name}`, preset: (name: string) => `プリセット: ${name}`, auditLog: (dir: string) => `監査ログ: ${dir}`, testTitle: "テスト判定:", @@ -32,7 +31,6 @@ const MSG = { en: { engineOk: (ms: number) => `Policy Engine: OK (${ms}ms)`, rulesLoaded: (n: number) => `Rules: ${n} loaded`, - plan: (name: string) => `Plan: ${name}`, preset: (name: string) => `Preset: ${name}`, auditLog: (dir: string) => `Audit log: ${dir}`, testTitle: "Test results:", @@ -72,7 +70,6 @@ export async function testCommand(): Promise { console.log(`${chalk.green("✓")} ${m.engineOk(engineTime)}`); console.log(`${chalk.green("✓")} ${m.rulesLoaded(rules.length)}`); - console.log(`${chalk.green("✓")} ${m.plan(license.plan.toUpperCase())}`); console.log(`${chalk.green("✓")} ${m.preset(config.profile)}`); console.log(`${chalk.green("✓")} ${m.auditLog(getLogDir())}`); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 054e7f6..8b062bf 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -86,7 +86,7 @@ program .option("--repo ", "Repository identifier") .option("--badge", "Show badge Markdown snippet") .option("--publish", "Publish passport to ClawGuard API") - .option("--key ", "License key for publishing") + .option("--key ", "API key for publishing") .action(passportCommand); program From 2561313afca9704e16eb40aa604ea5e5d7327b6b Mon Sep 17 00:00:00 2001 From: Goki602 Date: Wed, 25 Mar 2026 20:19:26 +0900 Subject: [PATCH 6/6] feat: respect reputation.opt_in config for telemetry upload Allow users to disable telemetry by setting reputation.opt_in: false in clawguard.yaml, as documented in README. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/engine-factory.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/engine-factory.ts b/packages/cli/src/engine-factory.ts index ff22f2a..a6f4bf3 100644 --- a/packages/cli/src/engine-factory.ts +++ b/packages/cli/src/engine-factory.ts @@ -132,10 +132,11 @@ export function createEngineContext(overrideLang?: Lang): EngineContext { } const reputation = new ReputationAggregator(store, feedBundle?.reputation); - // Telemetry upload is always enabled (anonymous aggregate stats). + // Telemetry upload is enabled by default (anonymous aggregate stats). + // Users can disable via reputation.opt_in: false in clawguard.yaml. // Community data *display* is gated by gate.canUseReputation() in enricher. const telemetryUploader = new TelemetryUploader({ - enabled: true, + enabled: config.reputation?.opt_in !== false, }); const lang: Lang = overrideLang ?? (config.lang === "en" ? "en" : "ja");