From bdb32b189393db1ef93c3c3980eb5c3a90fbf3c6 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Wed, 6 May 2026 13:53:21 -0700 Subject: [PATCH] import from local vendored copy (2026-05-06) --- .github/workflows/ci.yml | 5 ++ .gitignore | 21 ++++++ .molecule-ci/scripts/requirements.txt | 1 + .molecule-ci/scripts/validate-plugin.py | 52 +++++++++++++ CLAUDE.md | 98 +++++++++++++++++++++++++ README.md | 19 +++++ adapters/__init__.py | 0 adapters/claude_code.py | 2 + hooks/_lib.py | 46 ++++++++++++ hooks/post-edit-audit.py | 38 ++++++++++ hooks/post-edit-audit.sh | 2 + known-issues.md | 57 ++++++++++++++ plugin.yaml | 11 +++ settings-fragment.json | 1 + 14 files changed, 353 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .molecule-ci/scripts/requirements.txt create mode 100644 .molecule-ci/scripts/validate-plugin.py create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 adapters/__init__.py create mode 100644 adapters/claude_code.py create mode 100755 hooks/_lib.py create mode 100755 hooks/post-edit-audit.py create mode 100755 hooks/post-edit-audit.sh create mode 100644 known-issues.md create mode 100644 plugin.yaml create mode 100644 settings-fragment.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c8fb9d3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,5 @@ +name: CI +on: [push, pull_request] +jobs: + validate: + uses: Molecule-AI/molecule-ci/.github/workflows/validate-plugin.yml@main diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2af45b5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Credentials — never commit. Use .env.example as the template. +.env +.env.local +.env.*.local +.env.* +!.env.example +!.env.sample + +# Private keys + certs +*.pem +*.key +*.crt +*.p12 +*.pfx + +# Secret directories +.secrets/ + +# Workspace auth tokens +.auth-token +.auth_token diff --git a/.molecule-ci/scripts/requirements.txt b/.molecule-ci/scripts/requirements.txt new file mode 100644 index 0000000..c3726e8 --- /dev/null +++ b/.molecule-ci/scripts/requirements.txt @@ -0,0 +1 @@ +pyyaml diff --git a/.molecule-ci/scripts/validate-plugin.py b/.molecule-ci/scripts/validate-plugin.py new file mode 100644 index 0000000..c42e916 --- /dev/null +++ b/.molecule-ci/scripts/validate-plugin.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Validate a Molecule AI plugin repo.""" +import os, sys, yaml + +errors = [] + +# 1. plugin.yaml exists +if not os.path.isfile("plugin.yaml"): + print("::error::plugin.yaml not found at repo root") + sys.exit(1) + +with open("plugin.yaml") as f: + plugin = yaml.safe_load(f) + +# 2. Required fields +for field in ["name", "version", "description"]: + if not plugin.get(field): + errors.append(f"Missing required field: {field}") + +# 3. Version format +v = str(plugin.get("version", "")) +if v and not all(c in "0123456789." for c in v): + errors.append(f"Invalid version format: {v}") + +# 4. Runtimes type +runtimes = plugin.get("runtimes") +if runtimes is not None and not isinstance(runtimes, list): + errors.append(f"runtimes must be a list, got {type(runtimes).__name__}") + +# 5. Has content +content_paths = ["SKILL.md", "hooks", "skills", "rules"] +found = [p for p in content_paths if os.path.exists(p)] +if not found: + errors.append("Plugin must contain at least one of: SKILL.md, hooks/, skills/, rules/") + +# 6. SKILL.md formatting check +if os.path.isfile("SKILL.md"): + with open("SKILL.md") as f: + first_line = f.readline().strip() + if first_line and not first_line.startswith("#"): + print("::warning::SKILL.md should start with a markdown heading (e.g., # Plugin Name)") + +if errors: + for e in errors: + print(f"::error::{e}") + sys.exit(1) + +print(f"✓ plugin.yaml valid: {plugin['name']} v{plugin['version']}") +if found: + print(f" Content: {', '.join(found)}") +if runtimes: + print(f" Runtimes: {', '.join(runtimes)}") diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8c7047c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,98 @@ +# molecule-ai-plugin-molecule-audit-trail + +Appends a one-line JSONL audit record for every Edit and Write tool call to +`.claude/audit.jsonl`. Provides an append-only, chronologically ordered history +of all file modifications across a workspace session. + +**Version:** 1.0.0 +**Runtime:** `claude_code` +**Hook:** `post-edit-audit` (PostToolUse) +**Output:** `.claude/audit.jsonl` + +--- + +## What It Does + +Every time an Edit or Write tool succeeds, the `post-edit-audit` hook appends a +single JSON line to `.claude/audit.jsonl` in the workspace root: + +```jsonl +{"ts": "2026-04-21T14:00:00Z", "tool": "Edit", "file": "src/main.go", "ok": true} +{"ts": "2026-04-21T14:00:01Z", "tool": "Write", "file": "README.md", "ok": true} +``` + +- Timestamps are UTC (`YYYY-MM-DDTHH:MM:SSZ`). +- `file` is relative to the repo root. +- Only Edit and Write are audited — Read/List/Bash/agent calls are not logged. +- Failures are silently swallowed; the tool always completes successfully. + +--- + +## Repository Layout + +``` +molecule-audit-trail/ +├── hooks/ +│ ├── post-edit-audit.py # Python hook implementation +│ ├── post-edit-audit.sh # Thin shim invoking the Python module +│ └── _lib.py # Shared helpers: input reading, stderr warnings +├── adapters/ +│ ├── __init__.py +│ └── claude_code.py # Claude Code harness adapter +├── plugin.yaml # Plugin manifest +├── settings-fragment.json # Workspace config snippet +└── README.md +``` + +--- + +## Hook Details + +### `post-edit-audit` (PostToolUse) + +Triggered **after** any Edit or Write tool call returns. The Python hook: + +1. Reads the tool call payload from stdin (`tool_name`, `tool_input`, `tool_response`). +2. Extracts `file_path` from `tool_input` (handles both `file_path` and `notebook_path`). +3. Records `{ts, tool, file, ok}` and appends to `.claude/audit.jsonl`. +4. Exits `0` — never blocks tool execution. + +The `.claude/` directory must exist; if it does not, the hook silently succeeds +with no output written. + +--- + +## Development + +```bash +# Simulate a tool call and run the hook locally +echo '{"tool_name":"Edit","tool_input":{"file_path":"src/foo.go"},"tool_response":{"success":true}}' \ + | python hooks/post-edit-audit.py + +# Check the audit log +cat .claude/audit.jsonl | jq + +# Lint Python +python -m py_compile hooks/post-edit-audit.py hooks/_lib.py +``` + +--- + +## Key Conventions + +| Topic | Convention | +|---|---| +| **Output format** | JSONL, one record per line, append-only | +| **Timestamp** | UTC, ISO 8601 (`YYYY-MM-DDTHH:MM:SSZ`) | +| **File path** | Relative to repo root (never absolute) | +| **Failure behavior** | Silent — never blocks tool execution | +| **No auth required** | Hook has no external dependencies | +| **Python version** | Compatible with Python 3.x (no external deps) | + +--- + +## Relationship to Other Plugins + +- Pairs with `molecule-session-context` to replay audit context at session start. +- Complements `molecule-workflow-retro` which records retrospective notes rather + than raw file edits. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3add0d1 --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# molecule-audit-trail + +Molecule AI plugin. Install via the Molecule AI platform plugin system. + +## Usage + +### In org template (org.yaml) +```yaml +plugins: + - molecule-audit-trail +``` + +### From URL (community install) +``` +github://Molecule-AI/molecule-ai-plugin-molecule-audit-trail +``` + +## License +Business Source License 1.1 — © Molecule AI. diff --git a/adapters/__init__.py b/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/adapters/claude_code.py b/adapters/claude_code.py new file mode 100644 index 0000000..cc58993 --- /dev/null +++ b/adapters/claude_code.py @@ -0,0 +1,2 @@ +"""Claude Code adaptor — uses the generic rule+skill+hooks installer.""" +from plugins_registry.builtins import AgentskillsAdaptor as Adaptor # noqa: F401 diff --git a/hooks/_lib.py b/hooks/_lib.py new file mode 100755 index 0000000..1d0555a --- /dev/null +++ b/hooks/_lib.py @@ -0,0 +1,46 @@ +"""Common helpers for Claude Code hooks. Imported by the .py hook scripts. + +Hooks receive JSON on stdin per the Claude Code hook spec, and may emit +JSON on stdout or exit with code 2 to block. This module wraps both. +""" +import json +import sys + + +def read_input() -> dict: + """Parse stdin JSON. Empty input → empty dict.""" + raw = sys.stdin.read().strip() + if not raw: + return {} + try: + return json.loads(raw) + except json.JSONDecodeError: + return {} + + +def emit(payload: dict) -> None: + """Print JSON payload to stdout for the harness to interpret.""" + print(json.dumps(payload)) + + +def deny_pretooluse(reason: str) -> None: + """Emit a PreToolUse denial with reason and exit 0.""" + emit({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason, + } + }) + sys.exit(0) + + +def add_context(text: str) -> None: + """Emit additionalContext for SessionStart / UserPromptSubmit hooks.""" + if text and text.strip(): + emit({"additionalContext": text}) + + +def warn_to_stderr(msg: str) -> None: + """Non-blocking warning visible to the next agent turn via stderr.""" + print(msg, file=sys.stderr) diff --git a/hooks/post-edit-audit.py b/hooks/post-edit-audit.py new file mode 100755 index 0000000..98a6a37 --- /dev/null +++ b/hooks/post-edit-audit.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""PostToolUse:Edit/Write — append one-line audit record to .claude/audit.jsonl.""" +import datetime as dt +import json +import os +import sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _lib import read_input, warn_to_stderr # noqa + +REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +AUDIT = os.path.join(REPO, ".claude", "audit.jsonl") + + +def main() -> None: + data = read_input() + target = data.get("tool_input", {}).get("file_path") or data.get("tool_input", {}).get("notebook_path") or "" + if target.startswith(REPO + "/"): + target = target[len(REPO) + 1:] + + record = { + "ts": dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "tool": data.get("tool_name", "unknown"), + "file": target, + "ok": data.get("tool_response", {}).get("success", True), + } + try: + with open(AUDIT, "a") as f: + f.write(json.dumps(record) + "\n") + except Exception: + pass # never block tool execution on audit-write failure + + +if __name__ == "__main__": + try: + main() + except Exception as e: + warn_to_stderr(f"[audit hook error] {e}") + sys.exit(0) diff --git a/hooks/post-edit-audit.sh b/hooks/post-edit-audit.sh new file mode 100755 index 0000000..141ca41 --- /dev/null +++ b/hooks/post-edit-audit.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +exec python3 "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/post-edit-audit.py" diff --git a/known-issues.md b/known-issues.md new file mode 100644 index 0000000..abfd5c3 --- /dev/null +++ b/known-issues.md @@ -0,0 +1,57 @@ +# Known Issues + +Active and recently resolved issues for `molecule-audit-trail`. + +--- + +## Active Issues + +*(None currently open. File an issue if you encounter a problem.)* + +--- + +## Known Gotchas + +These are not bugs but behaviors that may surprise contributors or operators. + +### `.claude/` directory must exist before first audit write + +**Severity:** Low +**Workaround:** Ensure `.claude/` is created (e.g., by another plugin or a session-start hook) before the first Edit/Write call. The hook silently succeeds with no output if the directory is missing. + +**Prevention:** The hook could `os.makedirs(AUDIT, exist_ok=True)` but intentionally does not, to avoid creating directories that were not expected to exist. + +--- + +### Audit failures are silent — no observability signal + +**Severity:** Low +**Impact:** If the `.claude/` directory is missing or not writable, the hook silently completes with no error. The workspace operator has no way to detect that auditing is not working. + +**Workaround:** Manually verify `.claude/audit.jsonl` exists and is growing after the first Edit/Write call. For critical audit requirements, add a pre-session check that verifies writability: + +```bash +touch .claude/audit.jsonl && echo "audit write OK" || echo "audit write FAILED" +``` + +--- + +### `ok` field reflects `tool_response.success`, not HTTP/exit-code status + +**Severity:** Informational +**Detail:** The `ok` field in each JSONL record is `tool_response.success`, which reports whether the tool declared success — not whether the underlying write was durable. A tool that reports success but fails at the filesystem level (e.g., ENOSPC) may still record `"ok": true`. + +--- + +### No size management — audit log grows indefinitely + +**Severity:** Low (operational) +**Impact:** In long-running sessions with many file edits, `.claude/audit.jsonl` can grow large. + +**Workaround:** Periodically rotate the log or rely on the session-bound lifecycle: `.claude/` is typically ephemeral per workspace and is cleaned up on workspace teardown. + +--- + +## Recently Resolved + +*(None yet.)* diff --git a/plugin.yaml b/plugin.yaml new file mode 100644 index 0000000..814c7b0 --- /dev/null +++ b/plugin.yaml @@ -0,0 +1,11 @@ +name: molecule-audit-trail +version: 1.0.0 +description: Append every Edit/Write to .claude/audit.jsonl. PostToolUse hook for accountability. +author: Molecule AI +tags: [molecule, guardrails] + +runtimes: + - claude_code + +hooks: + - post-edit-audit diff --git a/settings-fragment.json b/settings-fragment.json new file mode 100644 index 0000000..9efdcf9 --- /dev/null +++ b/settings-fragment.json @@ -0,0 +1 @@ +{"hooks":{"PostToolUse":[{"matcher":"Edit|Write|NotebookEdit","hooks":[{"type":"command","command":"bash ${CLAUDE_DIR}/hooks/post-edit-audit.sh"}]}]}}