Audit Command Wrapper
PostToolUse hook that records every wrapped command invocation (git, gh, etc.) to the audit log. Makes the appearance side of the audit trail reliable — absence of an audit line for an expected command is itself visible during forensic review. Records WRAPPED_CMD, WRAPPED_ARGV, WRAPPED_EXIT, and WRAPPED_DURATION.
id hook/audit-commandv1.0.1by convergent-systems-key
- Event
PostToolUse- Trigger
always- Language
python- Side effects
- appends invocation record to ~/.ai/audit/interactions/<YYYY-MM>.jsonl
- Platforms
linuxmacoswindows- Notes
- Logic is cross-platform. Wiring: use 'ai hooks run audit-command' in settings.json — the ai binary discovers Python on each OS. Reads env vars WRAPPED_CMD etc. — set by the ai command wrappers on all platforms.
- Depends on
- hook/lib
Script · audit-command.py
#!/usr/bin/env python3
"""hooks/audit-command.py — wrapper postHook that records every wrapped
command invocation to the audit log.
Per SPEC.md §10.5.4: "If a bypass leaves no audit line where one
would normally appear, the gap itself is visible." This hook makes
the appearance side reliable; the absence side is what surfaces in
forensic review.
Inputs (from the wrapper environment):
WRAPPED_CMD — the underlying command ("git", "gh", etc.)
WRAPPED_ARGV — the argv it was invoked with, JSON-encoded
WRAPPED_EXIT — the exit code of the real command
WRAPPED_DURATION — wall-clock seconds the command took (string)
Self-check:
--self-check
"""
from __future__ import annotations
import argparse
import datetime as dt
import json
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import _lib # noqa: E402
def audit_dir() -> Path:
root = os.environ.get("AI_ROOT", str(Path.home() / ".ai"))
return Path(root) / "audit" / "interactions"
def month_file() -> Path:
now = dt.datetime.now(dt.timezone.utc)
return audit_dir() / f"{now.strftime('%Y-%m')}.jsonl"
def chronon() -> str:
now = dt.datetime.now(dt.timezone.utc)
return now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z"
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(add_help=True)
parser.add_argument("--self-check", action="store_true")
# The command wrapper invokes every hook with --mode=wrapper. This hook is
# wrapper-only and reads its inputs from the WRAPPED_* env vars, so the flag
# is accepted and ignored rather than selecting a code path.
parser.add_argument("--mode", choices=["claude", "wrapper"], default=None,
help="invocation mode (set by the command wrapper)")
args = parser.parse_args(argv)
if args.self_check:
try:
audit_dir().mkdir(parents=True, exist_ok=True)
except Exception as e:
_lib.log("self-check FAIL:", e)
return 1
_lib.log("self-check OK")
return 0
cmd = os.environ.get("WRAPPED_CMD", "")
argv_json = os.environ.get("WRAPPED_ARGV", "[]")
exit_code = os.environ.get("WRAPPED_EXIT", "0")
duration = os.environ.get("WRAPPED_DURATION", "")
try:
wrapped_argv = json.loads(argv_json)
except json.JSONDecodeError:
wrapped_argv = []
# Redact the argv (the secret patterns include URL-with-credentials).
redacted_argv = [_lib.redact(str(a)) for a in wrapped_argv]
probe_payload = json.dumps(redacted_argv)[:1000]
event = {
"chronon": chronon(),
"trace": os.environ.get("AI_SESSION_ID", ""),
"cwd": os.getcwd(),
"actor": "tool",
"kind": "invocation-result",
"engine": "command-wrapper",
"probe": cmd,
"probe_payload": probe_payload,
"emission_marker": f"exit={exit_code} duration={duration}",
}
try:
audit_dir().mkdir(parents=True, exist_ok=True)
with open(month_file(), "a", encoding="utf-8") as f:
f.write(json.dumps(event, ensure_ascii=False) + "\n")
except Exception as e:
# Never block; audit failures are themselves auditable.
_lib.log("audit-command append failed:", e)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
auditlogginggovernancecommandsclaude-code
Author convergent-systems-key. Catalog data license CC-BY-4.0.