diff --git a/.aiguard.yml b/.aiguard.yml new file mode 100644 index 0000000..f565384 --- /dev/null +++ b/.aiguard.yml @@ -0,0 +1,167 @@ +version: 1 + +context: + small: + include: + - ARCHITECTURE.md + - SNAPSHOT.md + - README.md + - pyproject.toml + - package.json + - frontend/package.json + - contracts/** + large: + roots: [backend, app, src, frontend] + exclude_dirs: + - .git + - .venv + - venv + - node_modules + - dist + - build + - .next + - __pycache__ + + + - __pypackages__ + + - .pytest_cache + - .mypy_cache + - .ruff_cache + exclude_globs: ["**/*.min.js", "**/*.map"] + max_files: 220 + max_kb_each: 64 + + evidence: + commands: + - git status --porcelain=v1 || true + - git diff || true + - python --version || true + - node --version || true + - npm --version || true + + +evidence: + commands: + - git status --porcelain=v1 || true + - git diff || true + - python --version || true + - node --version || true + - npm --version || true + + +dlp: + enable: true + block_on_detect: true + mask: true + allowlist_files: + - ".env.example" + - "frontend/.env.example" + +guard: + + max_files: 10 + max_lines: 400 + forbid_full_rewrite: true + + forbid_full_rewrite: true + allow_full_rewrite_globs: + - "core/aiguard.py" + - ".github/workflows/oceansguard.yml" + + + +checks: + commands: + # ==== + + # Backend (FastAPI / Python) + # ==== + + # compileall: backend/app/src が存在する場合のみ実行(無ければスキップ) + + # Backend (Python) + # ==== + + - > + python -c "import os,sys,subprocess; + targets=[d for d in ('backend','app','src') if os.path.isdir(d)]; + sys.exit(subprocess.call([sys.executable,'-m','compileall',*targets]) if targets else 0)" + + + # ruff: インストール済み かつ 対象dir存在時のみ実行(無ければスキップ) + + + - > + python -c "import os,sys,subprocess,importlib.util; + targets=[d for d in ('backend','app','src') if os.path.isdir(d)]; + has=importlib.util.find_spec('ruff') is not None; + sys.exit(subprocess.call([sys.executable,'-m','ruff','check',*targets]) if (has and targets) else 0)" + + + # mypy: インストール済み かつ 対象dir存在時のみ実行(無ければスキップ) + + + - > + python -c "import os,sys,subprocess,importlib.util; + targets=[d for d in ('backend','app','src') if os.path.isdir(d)]; + has=importlib.util.find_spec('mypy') is not None; + sys.exit(subprocess.call([sys.executable,'-m','mypy',*targets]) if (has and targets) else 0)" + + + # pytest: tests が存在し、pytest導入済みの場合のみ実行(無ければスキップ) + + + - > + python -c "import os,sys,subprocess,importlib.util; + has=importlib.util.find_spec('pytest') is not None; + has_tests=any(os.path.isdir(p) for p in ('tests','backend/tests','app/tests','src/tests')); + sys.exit(subprocess.call([sys.executable,'-m','pytest','-q']) if (has and has_tests) else 0)" + + # ==== + + # Frontend (React / Node) + # ==== + + # npm ci (or npm install): frontend が存在する場合のみ実行 + + # Frontend (React) + # ==== + + - > + python -c "import os,sys,subprocess; + d='frontend'; + sys.exit(0 if not os.path.isdir(d) else (subprocess.call('npm ci --silent', cwd=d, shell=True) if os.path.exists(os.path.join(d,'package-lock.json')) else subprocess.call('npm install --silent', cwd=d, shell=True)))" + + + # npm run build: frontend が存在する場合のみ実行 + + + - > + python -c "import os,sys,subprocess; + d='frontend'; + sys.exit(0 if not os.path.isdir(d) else subprocess.call('npm run build --silent', cwd=d, shell=True))" + + + # (既存の Python / React checks の下に追加) + - python core/openapi_contract.py + + - > + python -c "import os,sys; + sys.exit(0 if not os.path.exists('contracts/openapi.json') else + __import__('subprocess').call([sys.executable,'core/openapi_contract.py']))" + + # 失敗時ログ要約(check が失敗しても要約は残す) + - python core/summarize_logs.py || true + + + + +output: + pack: ai_context_pack.md + audit: CHANGELOG_AI.md + testlog: ai_test_last.log + + + report_json: ai_check_report.json + diff --git a/.github/workflows/oceansguard.yml b/.github/workflows/oceansguard.yml index e26914d..2969148 100644 --- a/.github/workflows/oceansguard.yml +++ b/.github/workflows/oceansguard.yml @@ -31,21 +31,36 @@ jobs: - name: Upgrade pip run: python -m pip install --upgrade pip - # FastAPI / OpenAPI契約チェック用(未使用PJでも害なし) - - name: Install Python tooling (best-effort) + - name: Install tooling (best-effort) run: | - pip install uvicorn fastapi || true + pip install pyyaml || true pip install ruff mypy pytest || true + pip install uvicorn fastapi || true + + - name: Resolve OceansGuard entry + id: og + run: | + if [ -f "tools/OceansGuard/core/aiguard.py" ]; then + echo "ENTRY=tools/OceansGuard/core/aiguard.py" >> $GITHUB_OUTPUT + elif [ -f "core/aiguard.py" ]; then + echo "ENTRY=core/aiguard.py" >> $GITHUB_OUTPUT + else + echo "OceansGuard entry not found" >&2 + exit 1 + fi - # React がある場合に備えて:node_modules はnpm ci/build側で吸収 - name: OceansGuard init (idempotent) run: | - python tools/OceansGuard/core/aiguard.py init + python "${{ steps.og.outputs.ENTRY }}" init --repo . - - name: OceansGuard pack + - name: OceansGuard run (pack + check) run: | - python tools/OceansGuard/core/aiguard.py pack + + python "${{ steps.og.outputs.ENTRY }}" pack --repo . - name: OceansGuard check run: | - python tools/OceansGuard/core/aiguard.py check + python "${{ steps.og.outputs.ENTRY }}" check --repo . + + python "${{ steps.og.outputs.ENTRY }}" run --repo . --task "CI guard" --strict + diff --git a/.gitignore b/.gitignore index dd15ec4..34c46b0 100644 --- a/.gitignore +++ b/.gitignore @@ -178,4 +178,6 @@ cython_debug/ # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data # refer to https://docs.cursor.com/context/ignore-files .cursorignore -.cursorindexingignore \ No newline at end of file +.cursorindexingignoreai_context_pack.md +ai_check_report.json +ai_test_last.log diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..3b0a4bb --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "common"] + path = common + url = https://github.com/OceansCreative/OceansCommon.git diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..2936dd3 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,11 @@ +# ARCHITECTURE + +- レイヤ構成 +- 依存方向 +- 外部I/O(API/DB) + + +_generated by OceansGuard init @ 2025-12-31T00:29:25_ + +_generated by OceansGuard init @ 2025-12-31T10:35:26_ + diff --git a/README.md b/README.md index fd40283..50e0786 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,61 @@ +# README.md # OceansGuard -OceansGuard は、生成AIによるコード変更を -**CI・契約・セキュリティで機械的に裁くためのガードレール**です。 - -## 目的 -- AIにコードを書かせても事故らせない -- 人が説明・確認・判断しなくてよい開発 -- どの言語・フレームワークでも共通運用 - -## 基本思想 -- AIは「提案者」 -- 正しさは「テスト・契約・ポリシー」が決める -- 通らない変更は採用されない - -## 使い方(各プロジェクト側) -```bash -python path/to/aiguard.py init -python path/to/aiguard.py pack -python path/to/aiguard.py check - -対応フェーズ - -開発前 / 開発途中 / 開発後 すべて対応 - - ---- - -## ③ あなたの「不可がほぼ無い」運用フロー(確定) -**どの案件でもこれだけ** - - - -AIに投げる前 → ai:pack -AI差分適用後 → ai:check -通ったら → 採用 - - -- 考えない -- 説明しない -- レビューしない - ---- - -## ④ 最初のGit操作(推奨) -```bash -git add . -git commit -m "feat: initial OceansGuard core structure" -git tag v0.1.0 -git push origin main --tags \ No newline at end of file +AI-assisted development guardrails for any repository. + +## What it solves +- AI-generated changes that accidentally drop existing code +- Lack of global context (only partial files shown) +- Forgetfulness / inconsistent constraints across sessions +- No test / lint guarantees +- Secret leakage (keys/tokens) into commits +- Risky full-rewrite changes + +## Core commands + +### init +Create minimal guard files in target repo (idempotent; no overwrite). + +python core/aiguard.py init --repo . + +### pack +Generate AI context pack (diff-first). +``` +python core/aiguard.py pack --repo . +``` +### check +Run guard checks + configured project checks and write reports. +``` +python core/aiguard.py check --repo . +``` +### run +Shortcut = pack + check. +``` +python core/aiguard.py run --repo . --task "your task" +``` +## Strict mode +--strict makes guardrails non-negotiable: +- requires PyYAML +- fails if checks.commands is empty +- fails if contracts/openapi.json is missing/empty +``` +python core/aiguard.py run --repo . --task "CI guard" --strict +``` + +## Submodule usage (recommended) +In your target repository: +``` +git submodule add https://github.com/OceansCreative/OceansGuard.git tools/OceansGuard +python tools/OceansGuard/core/aiguard.py init --repo . +python tools/OceansGuard/core/aiguard.py run --repo . --task "初回ガード適用" +``` +## Outputs +- ai_context_pack.md: single file to paste into AI chat +- ai_test_last.log: raw execution logs +- ai_check_report.json: structured result for CI/PR gating + +## Git hooks (prevent committing to main) +Install with: +``` +python core/install_hooks.py --repo . +``` \ No newline at end of file diff --git a/SNAPSHOT.md b/SNAPSHOT.md new file mode 100644 index 0000000..7220178 --- /dev/null +++ b/SNAPSHOT.md @@ -0,0 +1,11 @@ +# SNAPSHOT + +- 現在の仕様 +- 既知の制約 +- 触ってはいけない領域 + + +_generated by OceansGuard init @ 2025-12-31T00:29:25_ + +_generated by OceansGuard init @ 2025-12-31T10:35:26_ + diff --git a/common b/common new file mode 160000 index 0000000..69429d3 --- /dev/null +++ b/common @@ -0,0 +1 @@ +Subproject commit 69429d3e086fb800fb6f326d6fb48fbbad93c1fd diff --git a/contracts/README.md b/contracts/README.md new file mode 100644 index 0000000..ccf7f5a --- /dev/null +++ b/contracts/README.md @@ -0,0 +1,7 @@ +# Contracts + +このディレクトリには、守るべき契約(スキーマ/仕様)を置きます。 + +- OpenAPI: contracts/openapi.json(または openapi.yaml) +- DB schema snapshot +- DTO/型 diff --git a/contracts/openapi.json b/contracts/openapi.json index e69de29..94c5cde 100644 --- a/contracts/openapi.json +++ b/contracts/openapi.json @@ -0,0 +1,8 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "OceansGuard Placeholder API", + "version": "0.0.0" + }, + "paths": {} +} diff --git a/core/aiguard.py b/core/aiguard.py index c977363..651cdbd 100644 --- a/core/aiguard.py +++ b/core/aiguard.py @@ -2,56 +2,314 @@ from __future__ import annotations import argparse -import shutil -from pathlib import Path +import fnmatch +import hashlib +import json +import os +import re +import subprocess +from dataclasses import dataclass from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +# ============================================================ +# Basic utilities +# ============================================================ def now_iso() -> str: return datetime.now().isoformat(timespec="seconds") -def die(msg: str) -> None: +def info(msg: str) -> None: + print(f"[OceansGuard] {msg}") + + +def warn(msg: str) -> None: + print(f"[OceansGuard][WARN] {msg}") + + +def die(msg: str, code: int = 1) -> None: raise SystemExit(f"[OceansGuard] {msg}") +def guard_root() -> Path: + # core/aiguard.py -> OceansGuard/ + return Path(__file__).resolve().parent.parent + + +def run_argv(argv: List[str], cwd: Path, check: bool = False) -> subprocess.CompletedProcess: + """ + subprocess.run wrapper (argv form). + stdout/stderr are decoded as UTF-8 best-effort. + """ + cp = subprocess.run( + argv, + cwd=str(cwd), + capture_output=True, + check=check, + env=os.environ.copy(), + ) + + def _decode(b: bytes) -> str: + if not b: + return "" + try: + return b.decode("utf-8") + except UnicodeDecodeError: + return b.decode("utf-8", errors="replace") + + # monkeypatch for convenience (keeps compatibility with old code style) + cp.stdout = _decode(cp.stdout) # type: ignore[attr-defined] + cp.stderr = _decode(cp.stderr) # type: ignore[attr-defined] + return cp + + +def run_shell(cmd: str, cwd: Path, check: bool = False) -> subprocess.CompletedProcess: + """ + subprocess.run wrapper (shell form). + """ + cp = subprocess.run( + cmd, + cwd=str(cwd), + shell=True, + capture_output=True, + check=check, + env=os.environ.copy(), + ) + + def _decode(b: bytes) -> str: + if not b: + return "" + try: + return b.decode("utf-8") + except UnicodeDecodeError: + return b.decode("utf-8", errors="replace") + + cp.stdout = _decode(cp.stdout) # type: ignore[attr-defined] + cp.stderr = _decode(cp.stderr) # type: ignore[attr-defined] + return cp + + +def sha256_text(s: str) -> str: + return hashlib.sha256(s.encode("utf-8", errors="ignore")).hexdigest() + + +def safe_read_text(p: Path, max_kb: int) -> str: + try: + b = p.read_bytes() + except Exception as e: + return f"(failed to read: {e})\n" + + if len(b) > max_kb * 1024: + return f"(skipped: too large {len(b)} bytes > {max_kb}KB)\n" + + try: + return b.decode("utf-8", errors="replace") + except Exception as e: + return f"(failed to decode: {e})\n" + + +# ============================================================ +# Config / YAML +# ============================================================ + +def ensure_pyyaml(strict: bool) -> bool: + """ + Returns True if PyYAML is available. + In strict mode, missing PyYAML is fatal. + """ + try: + import yaml # noqa: F401 + return True + except Exception: + if strict: + die("PyYAML is required in --strict mode. Install: pip install pyyaml") + warn("PyYAML not found. Some config-driven features may be skipped.") + return False + + +@dataclass +class ContextSmall: + include: List[str] + + +@dataclass +class ContextLarge: + roots: List[str] + exclude_dirs: List[str] + exclude_globs: List[str] + max_files: int + max_kb_each: int + + +@dataclass +class Evidence: + commands: List[str] + + +@dataclass +class Dlp: + enable: bool + block_on_detect: bool + mask: bool + allowlist_files: List[str] + + +@dataclass +class Guard: + max_files: int + max_lines: int + forbid_full_rewrite: bool + allow_full_rewrite_globs: List[str] + + +@dataclass +class Checks: + commands: List[str] + + +@dataclass +class Output: + pack: str + audit: str + testlog: str + report_json: str + + +@dataclass +class Config: + version: int + context_small: ContextSmall + context_large: ContextLarge + evidence: Evidence + dlp: Dlp + guard: Guard + checks: Checks + output: Output + + +def _dict_get(d: Dict[str, Any], key: str, default: Any) -> Any: + v = d.get(key, default) + return default if v is None else v + + +def load_config(repo: Path, strict: bool) -> Config: + has_yaml = ensure_pyyaml(strict=strict) + cfg_path = repo / ".aiguard.yml" + if not cfg_path.exists(): + die(".aiguard.yml not found. Run `python core/aiguard.py init --repo ` first.") + + raw: Dict[str, Any] = {} + if has_yaml: + try: + import yaml # type: ignore + raw = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {} + except Exception as e: + if strict: + die(f"Failed to parse .aiguard.yml: {e}") + warn(f"Failed to parse .aiguard.yml; using minimal defaults. ({e})") + raw = {} + else: + if strict: + die("Cannot read .aiguard.yml without PyYAML in strict mode.") + + version = int(_dict_get(raw, "version", 1)) + + ctx = _dict_get(raw, "context", {}) + small = _dict_get(ctx, "small", {}) + large = _dict_get(ctx, "large", {}) + + context_small = ContextSmall(include=[str(x) for x in _dict_get(small, "include", [])]) + + # sane defaults + context_large = ContextLarge( + roots=[str(x) for x in _dict_get(large, "roots", ["backend", "app", "src", "frontend"])], + exclude_dirs=[str(x) for x in _dict_get( + large, + "exclude_dirs", + [ + ".git", ".venv", "venv", "node_modules", "dist", "build", ".next", + "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", + ], + )], + exclude_globs=[str(x) for x in _dict_get(large, "exclude_globs", ["**/*.min.js", "**/*.map"])], + max_files=int(_dict_get(large, "max_files", 220)), + max_kb_each=int(_dict_get(large, "max_kb_each", 64)), + ) + + ev = _dict_get(raw, "evidence", {}) + evidence = Evidence(commands=[str(x) for x in _dict_get(ev, "commands", [])]) + + dlp_raw = _dict_get(raw, "dlp", {}) + dlp = Dlp( + enable=bool(_dict_get(dlp_raw, "enable", True)), + block_on_detect=bool(_dict_get(dlp_raw, "block_on_detect", True)), + mask=bool(_dict_get(dlp_raw, "mask", True)), + allowlist_files=[str(x) for x in _dict_get(dlp_raw, "allowlist_files", [])], + ) + + guard_raw = _dict_get(raw, "guard", {}) + guard = Guard( + max_files=int(_dict_get(guard_raw, "max_files", 10)), + max_lines=int(_dict_get(guard_raw, "max_lines", 400)), + forbid_full_rewrite=bool(_dict_get(guard_raw, "forbid_full_rewrite", True)), + allow_full_rewrite_globs=[str(x) for x in _dict_get(guard_raw, "allow_full_rewrite_globs", [])], + ) + + checks_raw = _dict_get(raw, "checks", {}) + checks = Checks(commands=[str(x) for x in _dict_get(checks_raw, "commands", [])]) + + out_raw = _dict_get(raw, "output", {}) + output = Output( + pack=str(_dict_get(out_raw, "pack", "ai_context_pack.md")), + audit=str(_dict_get(out_raw, "audit", "CHANGELOG_AI.md")), + testlog=str(_dict_get(out_raw, "testlog", "ai_test_last.log")), + report_json=str(_dict_get(out_raw, "report_json", "ai_check_report.json")), + ) + + return Config( + version=version, + context_small=context_small, + context_large=context_large, + evidence=evidence, + dlp=dlp, + guard=guard, + checks=checks, + output=output, + ) + + +# ============================================================ +# init +# ============================================================ + def copy_if_missing(src: Path, dst: Path) -> None: if dst.exists(): - print(f"[skip] exists: {dst}") + info(f"skip exists: {dst}") return dst.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(src, dst) - print(f"[create] {dst}") + dst.write_bytes(src.read_bytes()) + info(f"create: {dst}") def write_if_missing(dst: Path, content: str) -> None: if dst.exists(): - print(f"[skip] exists: {dst}") + info(f"skip exists: {dst}") return dst.parent.mkdir(parents=True, exist_ok=True) dst.write_text(content, encoding="utf-8") - print(f"[create] {dst}") + info(f"create: {dst}") def cmd_init(repo: Path) -> None: - """ - init は「コピー専用」。 - - templates/.aiguard.yml を .aiguard.yml にコピー(上書きしない) - - ARCHITECTURE.md / SNAPSHOT.md を無ければ作成 - - contracts/ を無ければ作成 - """ - here = Path(__file__).resolve() - guard_root = here.parent.parent # OceansGuard/ - templates = guard_root / "templates" - - tpl_aiguard = templates / ".aiguard.yml" - if not tpl_aiguard.exists(): - die("templates/.aiguard.yml not found") + tpl = guard_root() / "templates" / ".aiguard.yml" + if not tpl.exists(): + die("templates/.aiguard.yml not found in OceansGuard") - # 1) .aiguard.yml - copy_if_missing(tpl_aiguard, repo / ".aiguard.yml") + copy_if_missing(tpl, repo / ".aiguard.yml") - # 2) ARCHITECTURE.md / SNAPSHOT.md write_if_missing( repo / "ARCHITECTURE.md", "# ARCHITECTURE\n\n" @@ -69,30 +327,422 @@ def cmd_init(repo: Path) -> None: f"_generated by OceansGuard init @ {now_iso()}_\n", ) - # 3) contracts/ contracts = repo / "contracts" - if not contracts.exists(): - contracts.mkdir(parents=True, exist_ok=True) - (contracts / "README.md").write_text( - "# Contracts\n\n" - "このディレクトリには、守るべき契約を置きます。\n\n" - "- OpenAPI(FastAPI の openapi.yaml)\n" - "- DB schema snapshot\n" - "- UI DTO / 型\n\n", - encoding="utf-8", - ) - print(f"[create] {contracts}/README.md") + contracts.mkdir(parents=True, exist_ok=True) + write_if_missing( + contracts / "README.md", + "# Contracts\n\n" + "このディレクトリには、守るべき契約(スキーマ/仕様)を置きます。\n\n" + "- OpenAPI: contracts/openapi.json(または openapi.yaml)\n" + "- DB schema snapshot\n" + "- DTO/型\n", + ) + + # openapi.json は「空でもOK」運用 + if not (contracts / "openapi.json").exists(): + (contracts / "openapi.json").write_text("", encoding="utf-8") + info("create: contracts/openapi.json (empty)") + + info("init completed") + + +# ============================================================ +# pack +# ============================================================ + +def glob_files(root: Path, pattern: str) -> List[Path]: + # Path.glob supports ** when pattern contains it + return [p for p in root.glob(pattern) if p.is_file()] + + +def should_exclude(path: Path, repo: Path, exclude_dirs: List[str], exclude_globs: List[str]) -> bool: + rel = path.relative_to(repo).as_posix() + parts = set(Path(rel).parts) + if any(d in parts for d in exclude_dirs): + return True + for g in exclude_globs: + if fnmatch.fnmatch(rel, g): + return True + return False + + +def collect_small(repo: Path, cfg: Config) -> List[Path]: + out: List[Path] = [] + for pat in cfg.context_small.include: + out.extend(glob_files(repo, pat)) + + seen = set() + uniq: List[Path] = [] + for p in sorted(out, key=lambda x: x.relative_to(repo).as_posix()): + rel = p.relative_to(repo).as_posix() + if rel in seen: + continue + seen.add(rel) + uniq.append(p) + return uniq + + +def collect_changed_files(repo: Path) -> List[Path]: + cp = run_argv(["git", "diff", "--name-only"], cwd=repo) + files: List[Path] = [] + for ln in (cp.stdout or "").splitlines(): # type: ignore[union-attr] + ln = ln.strip() + if not ln: + continue + p = repo / ln + if p.exists() and p.is_file(): + files.append(p) + return files + + +def collect_large(repo: Path, cfg: Config) -> List[Path]: + files: List[Path] = [] + roots = cfg.context_large.roots[:] if cfg.context_large.roots else ["."] + for r in roots: + base = (repo / r).resolve() + if not base.exists(): + continue + for p in base.rglob("*"): + if not p.is_file(): + continue + if should_exclude(p, repo, cfg.context_large.exclude_dirs, cfg.context_large.exclude_globs): + continue + files.append(p) + + files = sorted(files, key=lambda x: x.relative_to(repo).as_posix()) + if len(files) > cfg.context_large.max_files: + files = files[: cfg.context_large.max_files] + return files + + +def pack_git_section(repo: Path) -> str: + buf: List[str] = [] + buf.append("## Git\n") + + st = run_argv(["git", "status", "--porcelain=v1"], cwd=repo) + buf.append("\n### git status --porcelain=v1\n```text\n") + buf.append(st.stdout or "") # type: ignore[arg-type] + buf.append("\n```\n") + + df = run_argv(["git", "diff"], cwd=repo) + diff_text = df.stdout or "" # type: ignore[assignment] + buf.append("\n### git diff\n```diff\n") + if len(diff_text) > 200_000: + buf.append(diff_text[:200_000]) + buf.append("\n... (truncated)\n") else: - print(f"[skip] exists: {contracts}/") + buf.append(diff_text) + buf.append("\n```\n") + + return "".join(buf) + + +def evidence_section(repo: Path, cfg: Config) -> str: + buf: List[str] = [] + if not cfg.evidence.commands: + return "" + buf.append("## Evidence\n") + for cmd in cfg.evidence.commands: + buf.append(f"\n### $ {cmd}\n") + cp = run_shell(cmd, cwd=repo) + buf.append("```text\n") + buf.append(cp.stdout or "") # type: ignore[arg-type] + buf.append("\n```\n") + return "".join(buf) + + +def pack_files(repo: Path, files: List[Path], max_kb_each: int, title: str) -> str: + buf: List[str] = [] + buf.append(f"## {title}\n") + buf.append("\n### File list\n```text\n") + for p in files: + buf.append(p.relative_to(repo).as_posix() + "\n") + buf.append("```\n\n") + + for p in files: + rel = p.relative_to(repo).as_posix() + buf.append(f"### {rel}\n") + buf.append("```text\n") + buf.append(safe_read_text(p, max_kb=max_kb_each)) + buf.append("\n```\n\n") + return "".join(buf) + + +def cmd_pack(repo: Path, strict: bool) -> None: + cfg = load_config(repo, strict=strict) + out_path = repo / cfg.output.pack + + # diff-first + changed = [ + p for p in collect_changed_files(repo) + if not should_exclude(p, repo, cfg.context_large.exclude_dirs, cfg.context_large.exclude_globs) + ] + small_files = collect_small(repo, cfg) + large_files = collect_large(repo, cfg) + + buf: List[str] = [] + buf.append("# OceansGuard Context Pack\n\n") + buf.append(f"- generated_at: {now_iso()}\n") + buf.append(f"- repo: {repo}\n") + buf.append(f"- config_version: {cfg.version}\n") + buf.append(f"- mode: {'strict' if strict else 'normal'}\n\n") + + buf.append(pack_git_section(repo)) + buf.append(evidence_section(repo, cfg)) + + if changed: + buf.append(pack_files(repo, changed, max_kb_each=cfg.context_large.max_kb_each, title="Changed files (diff-first)")) + if small_files: + buf.append(pack_files(repo, small_files, max_kb_each=cfg.context_large.max_kb_each, title="Context (small)")) + if large_files: + buf.append(pack_files(repo, large_files, max_kb_each=cfg.context_large.max_kb_each, title="Context (large, capped)")) + + out_path.write_text("".join(buf), encoding="utf-8") + info(f"pack written: {out_path}") + + +# ============================================================ +# Guard (full rewrite) +# ============================================================ + +def detect_full_rewrite(repo: Path, forbid: bool, allow_globs: List[str]) -> Optional[str]: + if not forbid: + return None + + cp = run_argv(["git", "diff", "--numstat"], cwd=repo) + suspicious: List[str] = [] + + def _allowed(path_str: str) -> bool: + s = path_str.replace("\\", "/") + for g in allow_globs or []: + if fnmatch.fnmatch(s, g): + return True + return False + + for ln in (cp.stdout or "").splitlines(): # type: ignore[union-attr] + parts = ln.split("\t") + if len(parts) != 3: + continue + add_s, del_s, file_s = parts + if add_s == "-" or del_s == "-": + continue + if _allowed(file_s): + continue + + try: + add_n = int(add_s) + del_n = int(del_s) + except ValueError: + continue + + # heuristic: large add+del with both high -> possible rewrite + if (add_n + del_n) >= 800 and add_n >= 300 and del_n >= 300: + suspicious.append(f"{file_s} (add={add_n}, del={del_n})") + + if suspicious: + return "Possible full rewrite detected:\n" + "\n".join(f"- {s}" for s in suspicious) + return None + + +# ============================================================ +# DLP +# ============================================================ - print("[OK] init completed") +SECRET_PATTERNS: List[Tuple[str, re.Pattern[str]]] = [ + ("PRIVATE_KEY", re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----")), + ("AWS_ACCESS_KEY", re.compile(r"\bAKIA[0-9A-Z]{16}\b")), + ("GITHUB_TOKEN", re.compile(r"\bghp_[A-Za-z0-9]{36}\b")), + ("SLACK_TOKEN", re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b")), + ("JWT_LIKE", re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b")), + ("PASSWORD_ASSIGN", re.compile(r"(?i)\b(password|passwd|pwd|secret|api[_-]?key|token)\b\s*[:=]\s*['\"][^'\"]{6,}['\"]")), +] +def is_allowlisted(rel_posix: str, allowlist: List[str]) -> bool: + for pat in allowlist: + if fnmatch.fnmatch(rel_posix, pat): + return True + return False + + +def mask_hit(text: str) -> str: + return f"" + + +def scan_dlp(repo: Path, enable: bool, allowlist: List[str], mask: bool) -> List[str]: + if not enable: + return [] + + max_bytes = 256 * 1024 # 256KB + hits: List[str] = [] + + cp = run_argv(["git", "ls-files"], cwd=repo) + for rel in (cp.stdout or "").splitlines(): # type: ignore[union-attr] + rel = rel.strip() + if not rel: + continue + if is_allowlisted(rel, allowlist): + continue + + p = repo / rel + if not p.exists() or not p.is_file(): + continue + + try: + b = p.read_bytes() + except Exception: + continue + if len(b) > max_bytes: + continue + + s = b.decode("utf-8", errors="ignore") + for name, pat in SECRET_PATTERNS: + m = pat.search(s) + if not m: + continue + sample = m.group(0) + if mask: + sample = mask_hit(sample) + hits.append(f"{name}: {rel}: {sample}") + + return hits + + +# ============================================================ +# OpenAPI contract +# ============================================================ + +def openapi_contract_check(repo: Path, strict: bool) -> Optional[str]: + p = repo / "contracts" / "openapi.json" + if not p.exists(): + if strict: + return "contracts/openapi.json is required in strict mode (can be empty only in normal mode)" + return None + + txt = p.read_text(encoding="utf-8", errors="ignore").strip() + if not txt: + if strict: + return "contracts/openapi.json is empty in strict mode" + return None + + try: + obj = json.loads(txt) + except Exception as e: + return f"contracts/openapi.json is not valid JSON: {e}" + + if not isinstance(obj, dict): + return "contracts/openapi.json must be a JSON object" + if "openapi" not in obj and "swagger" not in obj: + return "contracts/openapi.json missing 'openapi' (or 'swagger') field" + return None + + +# ============================================================ +# check +# ============================================================ + +def cmd_check(repo: Path, strict: bool) -> None: + cfg = load_config(repo, strict=strict) + + report: Dict[str, Any] = { + "tool": "OceansGuard", + "generated_at": now_iso(), + "repo": str(repo), + "mode": "strict" if strict else "normal", + "checks": [], + "dlp_hits": [], + "guard": {"full_rewrite": None}, + "openapi_contract": None, + "status": "pass", + } + + # 1) full rewrite guard + fr = detect_full_rewrite(repo, forbid=cfg.guard.forbid_full_rewrite, allow_globs=cfg.guard.allow_full_rewrite_globs) + if fr: + report["guard"]["full_rewrite"] = fr + report["status"] = "fail" + (repo / cfg.output.report_json).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + die(fr) + + # 2) DLP + dlp_hits = scan_dlp(repo, enable=cfg.dlp.enable, allowlist=cfg.dlp.allowlist_files, mask=cfg.dlp.mask) + report["dlp_hits"] = dlp_hits + if dlp_hits and cfg.dlp.block_on_detect: + report["status"] = "fail" + (repo / cfg.output.report_json).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + die("DLP detected potential secrets:\n" + "\n".join(f"- {h}" for h in dlp_hits)) + + # 3) OpenAPI contract + oc = openapi_contract_check(repo, strict=strict) + report["openapi_contract"] = oc + if oc: + report["status"] = "fail" + (repo / cfg.output.report_json).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + die(oc) + + # 4) checks.commands + log_path = repo / cfg.output.testlog + logs: List[str] = [] + logs.append(f"[OceansGuard] check started @ {now_iso()}\n") + + failed: List[Tuple[str, int]] = [] + for i, cmd in enumerate(cfg.checks.commands, start=1): + cmd = (cmd or "").strip() + if not cmd: + continue + info(f"check[{i}] {cmd}") + cp = run_shell(cmd, cwd=repo) + logs.append(f"\n=== check[{i}] {cmd} ===\n") + logs.append(cp.stdout or "") # type: ignore[arg-type] + logs.append(f"\n[exit_code] {cp.returncode}\n") + report["checks"].append({"index": i, "command": cmd, "exit_code": cp.returncode}) + if cp.returncode != 0: + failed.append((cmd, cp.returncode)) + + # always write logs + log_path.write_text("".join(logs), encoding="utf-8") + info(f"testlog written: {log_path}") + + # strict: checks.commands must exist + if strict and not [c for c in cfg.checks.commands if (c or "").strip()]: + report["status"] = "fail" + (repo / cfg.output.report_json).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + die("checks.commands is empty in strict mode") + + if failed: + report["status"] = "fail" + (repo / cfg.output.report_json).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + summary = "\n".join([f"- ({rc}) {cmd}" for cmd, rc in failed]) + die("check failed:\n" + summary) + + (repo / cfg.output.report_json).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + info(f"report written: {repo / cfg.output.report_json}") + info("check passed") + + +# ============================================================ +# run +# ============================================================ + +def cmd_run(repo: Path, task: str, strict: bool) -> None: + if task.strip(): + info(f"run task: {task}") + else: + warn("run: --task is empty (still runs pack/check)") + cmd_pack(repo, strict=strict) + cmd_check(repo, strict=strict) + + +# ============================================================ +# CLI +# ============================================================ + def main() -> None: ap = argparse.ArgumentParser(prog="aiguard") ap.add_argument("command", choices=["init", "pack", "check", "run"]) ap.add_argument("--repo", default=".") ap.add_argument("--task", default="") + ap.add_argument("--strict", action="store_true", help="Do not allow skipping; fail if prerequisites missing.") args = ap.parse_args() repo = Path(args.repo).resolve() @@ -100,9 +750,17 @@ def main() -> None: if args.command == "init": cmd_init(repo) return + if args.command == "pack": + cmd_pack(repo, strict=args.strict) + return + if args.command == "check": + cmd_check(repo, strict=args.strict) + return + if args.command == "run": + cmd_run(repo, task=args.task, strict=args.strict) + return - # pack / check / run は既存実装を使用 - die("This build only finalizes init. Use previous implementation for pack/check/run.") + die("unknown command") if __name__ == "__main__": diff --git a/core/install_hooks.py b/core/install_hooks.py new file mode 100644 index 0000000..c6bebf0 --- /dev/null +++ b/core/install_hooks.py @@ -0,0 +1,41 @@ +# core/install_hooks.py +from __future__ import annotations + +import argparse +from pathlib import Path + + +PRE_COMMIT = """#!/bin/sh +branch="$(git rev-parse --abbrev-ref HEAD)" +if [ "$branch" = "main" ]; then + echo "ERROR: main への直接コミットは禁止です。ブランチを作ってください。" + exit 1 +fi +exit 0 +""" + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--repo", default=".") + args = ap.parse_args() + + repo = Path(args.repo).resolve() + hooks = repo / ".git" / "hooks" + if not hooks.exists(): + raise SystemExit("ERROR: .git/hooks not found. Run inside a git repository.") + + target = hooks / "pre-commit" + target.write_text(PRE_COMMIT, encoding="utf-8") + + # Try to set executable bit (harmless on Windows) + try: + target.chmod(0o755) + except Exception: + pass + + print(f"[OceansGuard] installed: {target}") + + +if __name__ == "__main__": + main() diff --git a/core/openapi_contract.py b/core/openapi_contract.py index 1d2f17d..a7c985f 100644 --- a/core/openapi_contract.py +++ b/core/openapi_contract.py @@ -2,81 +2,34 @@ from __future__ import annotations import json -import sys -import time -import urllib.request -import subprocess from pathlib import Path -OPENAPI_URL = "http://127.0.0.1:8000/openapi.json" -TIMEOUT_SEC = 15 +def main() -> int: + p = Path("contracts/openapi.json") + if not p.exists(): + print("[openapi_contract] contracts/openapi.json not found (skip).") + return 0 + text = p.read_text(encoding="utf-8", errors="ignore").strip() + if text == "": + print("[openapi_contract] contracts/openapi.json is empty (skip as placeholder).") + return 0 -def die(msg: str) -> None: - raise SystemExit(f"[OpenAPI] {msg}") - - -def run(cmd: list[str]) -> subprocess.Popen: - return subprocess.Popen( - cmd, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - text=True, - ) - - -def fetch_openapi(url: str) -> dict: - with urllib.request.urlopen(url, timeout=5) as r: - return json.loads(r.read().decode("utf-8")) - - -def normalize(spec: dict) -> dict: - """ - FastAPI の自動生成で揺れる部分を正規化 - """ - spec = dict(spec) - spec.pop("servers", None) - info = spec.get("info", {}) - info.pop("version", None) - info.pop("title", None) - return spec - - -def main() -> None: - repo = Path(".").resolve() - contract = repo / "contracts" / "openapi.json" - - if not contract.exists(): - die("contracts/openapi.json not found") - - # 起動(app.main:app を標準とする) - proc = run([sys.executable, "-m", "uvicorn", "app.main:app", "--port", "8000"]) try: - # 起動待ち - for _ in range(TIMEOUT_SEC): - try: - live = fetch_openapi(OPENAPI_URL) - break - except Exception: - time.sleep(1) - else: - die("FastAPI did not start") - - expected = json.loads(contract.read_text(encoding="utf-8")) - - live_n = normalize(live) - expected_n = normalize(expected) - - if live_n != expected_n: - die("OpenAPI contract changed") + obj = json.loads(text) + except json.JSONDecodeError as e: + print(f"[openapi_contract] invalid json: {e.msg} (line {e.lineno}, col {e.colno})") + return 1 - print("[OK] OpenAPI contract unchanged") + # Minimal sanity: must have openapi or swagger + if not (isinstance(obj, dict) and ("openapi" in obj or "swagger" in obj)): + print("[openapi_contract] json loaded but missing 'openapi'/'swagger' key.") + return 1 - finally: - proc.terminate() - proc.wait(timeout=5) + print("[openapi_contract] OK") + return 0 if __name__ == "__main__": - main() + raise SystemExit(main()) diff --git a/core/summarize_logs.py b/core/summarize_logs.py index 06783a3..e10b66e 100644 --- a/core/summarize_logs.py +++ b/core/summarize_logs.py @@ -1,55 +1,23 @@ # core/summarize_logs.py from __future__ import annotations -import re -import sys from pathlib import Path -MAX_LINES = 200 -KEYWORDS = ( - "ERROR", "Error", "FAILED", "FAIL", "Traceback", - "AssertionError", "TypeError", "ValueError", - "ModuleNotFoundError", "ImportError", - "mypy:", "ruff:", "pytest", "npm ERR", "openapi" -) -def die(msg: str) -> None: - raise SystemExit(f"[Summarize] {msg}") +def main() -> int: + p = Path("ai_test_last.log") + if not p.exists(): + print("[summarize_logs] ai_test_last.log not found.") + return 0 -def main() -> None: - log = Path("ai_test_last.log") - if not log.exists(): - die("ai_test_last.log not found") + lines = p.read_text(encoding="utf-8", errors="ignore").splitlines() + tail = lines[-80:] if len(lines) > 80 else lines - lines = log.read_text(encoding="utf-8", errors="ignore").splitlines() + print("[summarize_logs] tail:") + for ln in tail: + print(ln) + return 0 - picked: list[str] = [] - for i, line in enumerate(lines): - if any(k in line for k in KEYWORDS): - start = max(0, i - 3) - end = min(len(lines), i + 5) - picked.extend(lines[start:end]) - - # 重複除去・整形 - uniq = [] - seen = set() - for l in picked: - if l not in seen: - seen.add(l) - uniq.append(l) - - summary = uniq[-MAX_LINES:] - - out = Path("ai_failure_summary.md") - out.write_text( - "# AI Failure Summary\n\n" - "以下は、CI失敗時の要点抽出です。\n" - "修正はこの内容のみを前提に行ってください。\n\n" - "```text\n" + "\n".join(summary) + "\n```\n", - encoding="utf-8", - ) - - print(f"[OK] summarized -> {out}") if __name__ == "__main__": - main() + raise SystemExit(main()) diff --git a/templates/.aiguard.yml b/templates/.aiguard.yml index 6b1bdcc..8f17724 100644 --- a/templates/.aiguard.yml +++ b/templates/.aiguard.yml @@ -21,19 +21,21 @@ context: - build - .next - __pycache__ + - __pypackages__ - .pytest_cache - .mypy_cache - .ruff_cache exclude_globs: ["**/*.min.js", "**/*.map"] max_files: 220 max_kb_each: 64 - evidence: - commands: - - git status --porcelain=v1 || true - - git diff || true - - python --version || true - - node --version || true - - npm --version || true + +evidence: + commands: + - git status --porcelain=v1 || true + - git diff || true + - python --version || true + - node --version || true + - npm --version || true dlp: enable: true @@ -44,72 +46,54 @@ dlp: - "frontend/.env.example" guard: - max_files: 10 - max_lines: 400 forbid_full_rewrite: true + allow_full_rewrite_globs: + - "core/aiguard.py" + - ".github/workflows/oceansguard.yml" checks: commands: - # ========================= - # Backend (FastAPI / Python) - # ========================= - - # compileall: backend/app/src が存在する場合のみ実行(無ければスキップ) + # ==== + # Backend (Python) + # ==== - > python -c "import os,sys,subprocess; targets=[d for d in ('backend','app','src') if os.path.isdir(d)]; sys.exit(subprocess.call([sys.executable,'-m','compileall',*targets]) if targets else 0)" - # ruff: インストール済み かつ 対象dir存在時のみ実行(無ければスキップ) - > python -c "import os,sys,subprocess,importlib.util; targets=[d for d in ('backend','app','src') if os.path.isdir(d)]; has=importlib.util.find_spec('ruff') is not None; sys.exit(subprocess.call([sys.executable,'-m','ruff','check',*targets]) if (has and targets) else 0)" - # mypy: インストール済み かつ 対象dir存在時のみ実行(無ければスキップ) - > python -c "import os,sys,subprocess,importlib.util; targets=[d for d in ('backend','app','src') if os.path.isdir(d)]; has=importlib.util.find_spec('mypy') is not None; sys.exit(subprocess.call([sys.executable,'-m','mypy',*targets]) if (has and targets) else 0)" - # pytest: tests が存在し、pytest導入済みの場合のみ実行(無ければスキップ) - > python -c "import os,sys,subprocess,importlib.util; has=importlib.util.find_spec('pytest') is not None; has_tests=any(os.path.isdir(p) for p in ('tests','backend/tests','app/tests','src/tests')); sys.exit(subprocess.call([sys.executable,'-m','pytest','-q']) if (has and has_tests) else 0)" - # ========================= - # Frontend (React / Node) - # ========================= - - # npm ci (or npm install): frontend が存在する場合のみ実行 + # ==== + # Frontend (React) + # ==== - > python -c "import os,sys,subprocess; d='frontend'; sys.exit(0 if not os.path.isdir(d) else (subprocess.call('npm ci --silent', cwd=d, shell=True) if os.path.exists(os.path.join(d,'package-lock.json')) else subprocess.call('npm install --silent', cwd=d, shell=True)))" - # npm run build: frontend が存在する場合のみ実行 - > python -c "import os,sys,subprocess; d='frontend'; sys.exit(0 if not os.path.isdir(d) else subprocess.call('npm run build --silent', cwd=d, shell=True))" - # (既存の Python / React checks の下に追加) - - python core/openapi_contract.py - - - > - python -c "import os,sys; - sys.exit(0 if not os.path.exists('contracts/openapi.json') else - __import__('subprocess').call([sys.executable,'core/openapi_contract.py']))" - - # 失敗時ログ要約(check が失敗しても要約は残す) - - python core/summarize_logs.py || true - - output: pack: ai_context_pack.md audit: CHANGELOG_AI.md testlog: ai_test_last.log + report_json: ai_check_report.json