import from local vendored copy (2026-05-06)
Some checks failed
CI / validate (push) Failing after 0s
Some checks failed
CI / validate (push) Failing after 0s
This commit is contained in:
commit
bdb32b1893
5
.github/workflows/ci.yml
vendored
Normal file
5
.github/workflows/ci.yml
vendored
Normal 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
21
.gitignore
vendored
Normal 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
|
||||
1
.molecule-ci/scripts/requirements.txt
Normal file
1
.molecule-ci/scripts/requirements.txt
Normal file
@ -0,0 +1 @@
|
||||
pyyaml
|
||||
52
.molecule-ci/scripts/validate-plugin.py
Normal file
52
.molecule-ci/scripts/validate-plugin.py
Normal 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
98
CLAUDE.md
Normal 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
19
README.md
Normal 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
0
adapters/__init__.py
Normal file
2
adapters/claude_code.py
Normal file
2
adapters/claude_code.py
Normal 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
46
hooks/_lib.py
Executable 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
38
hooks/post-edit-audit.py
Executable 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
2
hooks/post-edit-audit.sh
Executable 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
57
known-issues.md
Normal 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
11
plugin.yaml
Normal 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
1
settings-fragment.json
Normal file
@ -0,0 +1 @@
|
||||
{"hooks":{"PostToolUse":[{"matcher":"Edit|Write|NotebookEdit","hooks":[{"type":"command","command":"bash ${CLAUDE_DIR}/hooks/post-edit-audit.sh"}]}]}}
|
||||
Loading…
Reference in New Issue
Block a user