import from local vendored copy (2026-05-06)
Some checks failed
CI / validate (push) Failing after 0s

This commit is contained in:
Hongming Wang 2026-05-06 13:53:21 -07:00
commit bdb32b1893
14 changed files with 353 additions and 0 deletions

5
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,5 @@
name: CI
on: [push, pull_request]
jobs:
validate:
uses: Molecule-AI/molecule-ci/.github/workflows/validate-plugin.yml@main

21
.gitignore vendored Normal file
View File

@ -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

View File

@ -0,0 +1 @@
pyyaml

View File

@ -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)}")

98
CLAUDE.md Normal file
View File

@ -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.

19
README.md Normal file
View File

@ -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.

0
adapters/__init__.py Normal file
View File

2
adapters/claude_code.py Normal file
View File

@ -0,0 +1,2 @@
"""Claude Code adaptor — uses the generic rule+skill+hooks installer."""
from plugins_registry.builtins import AgentskillsAdaptor as Adaptor # noqa: F401

46
hooks/_lib.py Executable file
View File

@ -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)

38
hooks/post-edit-audit.py Executable file
View File

@ -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)

2
hooks/post-edit-audit.sh Executable file
View File

@ -0,0 +1,2 @@
#!/usr/bin/env bash
exec python3 "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/post-edit-audit.py"

57
known-issues.md Normal file
View File

@ -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.)*

11
plugin.yaml Normal file
View File

@ -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

1
settings-fragment.json Normal file
View File

@ -0,0 +1 @@
{"hooks":{"PostToolUse":[{"matcher":"Edit|Write|NotebookEdit","hooks":[{"type":"command","command":"bash ${CLAUDE_DIR}/hooks/post-edit-audit.sh"}]}]}}