Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
{
"name": "maxexpresskit",
"source": "./",
"version": "0.1.2",
"version": "0.1.3",
"description": "Three guardrails for Claude Code: compliance, drift, ledger."
}
]
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "maxexpresskit",
"description": "Three guardrails for Claude Code: compliance, drift, ledger.",
"version": "0.1.2",
"version": "0.1.3",
"author": {
"name": "Max Bachaud"
}
Expand Down
3 changes: 3 additions & 0 deletions .markdownlint.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"MD024": { "siblings_only": true }
}
4 changes: 2 additions & 2 deletions .mek/drift-baseline.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"schema_version": 1,
"updated_at": "2026-05-11T07:50:48.679821+00:00",
"updated_at": "2026-05-11T16:58:45.390223+00:00",
"preset": "python",
"dimensions": {
"test_pass_rate": {
Expand All @@ -16,7 +16,7 @@
"floor": 0.95
},
"coverage": {
"auto": 0.757201646090535,
"auto": 0.8320000000000001,
"manual": null,
"confidence": 0.9,
"floor": 0.7
Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,23 @@
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.1.3] — 2026-05-11

### Fixed

- **`score_lint` confidence honesty** — when `ruff` couldn't be launched or its output couldn't be parsed, the scorer previously returned `(0.99, 1.0)`, claiming high confidence in fabricated data. Now returns the same `(0.0, 0.0)` "unmeasured" sentinel as `score_security`. Also switched the invocation from bare `"ruff"` to `[sys.executable, "-m", "ruff", ...]` for the same PATH-resolution reasons we hit in v0.1.2 for the other scorers.

### Added

- **Privacy default for HITL approvals** — `/mek-init` now drops a `compliance/.gitignore` that ignores `approvals/` by default. HITL records often carry names, infra details, and rationale that don't belong in public git history. Add `!approvals/<file>` negations to opt specific (redacted) approvals into tracking.
- **`/mek-compliance-audit` privacy check** — surfaces files tracked under `compliance/approvals/` as a warning. `--strict` fails the audit when any such file exists.
- **`docs/compliance.md` hardening section** — documents both the static-block pattern (`repo_visibility_flip = "block"` in mek.toml) and a conditional-block recipe for projects that want visibility flips to fail only when approvals exist on disk.

### Tests

- 4 new unit tests on the lint sentinel (`tests/unit/test_drift_python_preset.py`).
- 1 new integration test that the scaffold ships `compliance/.gitignore`.

## [0.1.2] — 2026-05-11

### Added
Expand Down
6 changes: 4 additions & 2 deletions commands/mek-compliance-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ For each file:
- Check `mtime`. Mark `stale` if older than `compliance.staleness_days` (default 90) AND no `last_reviewed:` frontmatter within the window.
- For HITL approval files: check for a `Signed off by:` line.

Output: a markdown table to stdout. With `--write <path>`, also write the report to that file.
**Privacy check: tracked approvals.** HITL approval files frequently contain names, infrastructure details, and other sensitive data, so the default scaffold gitignores `compliance/approvals/`. Run `git ls-files compliance/approvals/` — if it returns any paths, surface them as a warning (`tracked HITL approvals detected: <paths>. Confirm these are intentionally version-controlled, or add to compliance/.gitignore.`). Treat this as advisory (does not by itself set exit 1) unless the user passes `--strict`, in which case tracked unredacted approvals fail the audit.

Output: a markdown table to stdout plus a separate "Tracked approvals" section when present. With `--write <path>`, also write the report to that file.

Exit code:

- `0` if everything is fresh and signed.
- `1` if any artifact is stale or unsigned.
- `1` if any artifact is stale or unsigned, OR if `--strict` is passed and any tracked file lives under `compliance/approvals/`.
- `--soft` coerces exit to `0` (for CI advisory mode).
2 changes: 2 additions & 0 deletions commands/mek-init.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ Behavior:
- `scaffold/compliance/HITL_TEMPLATE.md` → `./compliance/HITL_TEMPLATE.md`
- `scaffold/compliance/DECISION_LOG.md` → `./compliance/DECISION_LOG.md`
- `scaffold/compliance/RISKY_OPS.yaml` → `./compliance/RISKY_OPS.yaml`
- `scaffold/compliance/.gitignore` → `./compliance/.gitignore` (ignores `approvals/` by default — see [docs/compliance.md](../docs/compliance.md) for the rationale)
3. Print a summary of what was created and the next steps:
- Run `/mek-drift init` to seed the drift baseline.
- Open `compliance/RISKY_OPS.yaml` and add project-specific patterns.
- HITL approvals belong under `compliance/approvals/`. That path is gitignored by default; add explicit `!approvals/<file>` negations to track redacted approvals.

This is a tool-using flow (Read, Write the files). Don't run shell `cp`.
50 changes: 48 additions & 2 deletions docs/compliance.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# compliance

Ambient HITL/audit nudge. Triggers on five risky-op categories:
Ambient HITL/audit nudge. Triggers on six risky-op categories:

- `rm_rf`, `deploy`, `schema_migration`, `money_write`, `force_push_main`.
- `rm_rf`, `deploy`, `schema_migration`, `money_write`, `force_push_main`, `repo_visibility_flip`.

For each, the `pre_risky_op.py` hook checks `mek.toml > [compliance.gates] > <op>`:

Expand All @@ -13,3 +13,49 @@ For each, the `pre_risky_op.py` hook checks `mek.toml > [compliance.gates] > <op
To record a decision, copy `compliance/HITL_TEMPLATE.md` and append the row to `compliance/DECISION_LOG.md`.

Run `/mek-compliance-audit` to find stale artifacts and unsigned approvals.

## HITL approvals and privacy

`compliance/approvals/` is the conventional landing zone for signed HITL records — one file per approval. These files frequently contain names, infrastructure details, and rationale you probably don't want in public git history.

MEK's scaffold (`/mek-init`) drops a `compliance/.gitignore` that ignores `approvals/` by default. To track a specific approval (e.g., after redacting it), add an explicit negation:

```gitignore
approvals/
!approvals/2026-05-11-redacted-public.md
```

`/mek-compliance-audit` includes an advisory check that surfaces any files currently tracked under `compliance/approvals/` so you can confirm the exposure is intentional. Pass `--strict` to fail the audit on any tracked approval.

## Hardening: block visibility flips when approvals exist

By default `repo_visibility_flip` is gated as `warn` — the operator is reminded but can proceed. Two ways to tighten this if your project keeps HITL approvals on disk:

**Static block (simplest, works today.)** In `mek.toml`:

```toml
[compliance.gates]
repo_visibility_flip = "block"
```

Every `gh repo edit … --visibility public|internal` is denied until you flip the gate back to `warn` for the specific run. The decision then lives in `DECISION_LOG.md` as proof that someone consciously unlocked it.

**Conditional block (state-aware, ~15-line hook).** If you only want the hard block when `compliance/approvals/` has content on disk, wrap `pre_risky_op.py` with a project-local hook that escalates dynamically. Sketch:

```python
# .claude/hooks/visibility_guard.py
import json, subprocess, sys
from pathlib import Path

payload = json.load(sys.stdin)
cmd = payload.get("tool_input", {}).get("command", "")
if "gh repo edit" not in cmd or "--visibility" not in cmd:
sys.exit(0)
approvals = Path("compliance/approvals")
if approvals.exists() and any(approvals.iterdir()):
print("[guard] approvals/ has content; refusing visibility flip.", file=sys.stderr)
sys.exit(2)
sys.exit(0)
```

Register it as a `PreToolUse` hook with matcher `Bash`. This is opt-in territory — MEK ships the static gate; the conditional version is a project's choice.
14 changes: 11 additions & 3 deletions lib/drift_scoring/python_preset.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,20 @@ def score_test_pass_rate(project_root: Path) -> tuple[float, float]:


def score_lint(project_root: Path) -> tuple[float, float]:
code, out = _run(["ruff", "check", "."], project_root)
if code == 0:
"""Run ruff. Returns the unmeasured sentinel (0.0, 0.0) if ruff isn't
installed or its output couldn't be parsed — so callers don't mistake
a launch failure for a clean repo.
"""
code, out = _run([sys.executable, "-m", "ruff", "check", "."], project_root)
# Discriminate "ruff actually ran" by looking for one of its expected
# output markers. If neither appears, we have no real measurement.
if "All checks passed" in out:
return 1.0, 1.0
import re
m = re.search(r"Found (\d+) error", out)
errors = int(m.group(1)) if m else 1
if not m:
return 0.0, 0.0 # unmeasured — same sentinel as score_security
errors = int(m.group(1))
# Heuristic: 0 errors = 1.0, 100+ errors = 0.0, linear in between.
score = max(0.0, 1.0 - errors / 100.0)
return score, 1.0
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "maxexpresskit",
"version": "0.1.2",
"version": "0.1.3",
"description": "Three guardrails for Claude Code: compliance, drift, ledger.",
"license": "Apache-2.0",
"private": true
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "maxexpresskit"
version = "0.1.2"
version = "0.1.3"
description = "Three guardrails for Claude Code: compliance, drift, ledger."
requires-python = ">=3.11"
license = "Apache-2.0"
Expand Down
14 changes: 14 additions & 0 deletions scaffold/compliance/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# By default, MEK does NOT track HITL approvals — they frequently contain
# names, infrastructure details, internal URLs, and other data you probably
# don't want in git history (especially on public repos).
#
# To track a specific approval file explicitly (e.g., after redacting it),
# add a negation below the `approvals/` line. Example:
#
# approvals/
# !approvals/2026-05-11-redacted-public.md
#
# Or remove the `approvals/` line entirely if your project's policy is to
# track all approvals.

approvals/
7 changes: 7 additions & 0 deletions tests/integration/test_scaffold_payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ def test_scaffold_has_compliance_templates():
assert (SCAFFOLD / "compliance" / "RISKY_OPS.yaml").is_file()


def test_scaffold_gitignores_approvals_dir():
# HITL approvals contain sensitive data — opt-out-shaped privacy default.
gi = SCAFFOLD / "compliance" / ".gitignore"
assert gi.is_file()
assert "approvals/" in gi.read_text(encoding="utf-8")


def test_scaffold_mek_toml_parses():
import sys
if sys.version_info >= (3, 11):
Expand Down
38 changes: 38 additions & 0 deletions tests/unit/test_drift_python_preset.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Regression tests for the python drift preset's confidence honesty."""
from pathlib import Path

from lib.drift_scoring import python_preset


def _fake_run(out: str, code: int = 0):
"""Return a _run replacement that yields the given (code, out)."""
def _run(cmd, cwd, merge_stderr=True):
return code, out
return _run


def test_score_lint_clean_returns_full_confidence(monkeypatch):
monkeypatch.setattr(python_preset, "_run", _fake_run("All checks passed!\n"))
assert python_preset.score_lint(Path(".")) == (1.0, 1.0)


def test_score_lint_with_errors_returns_full_confidence(monkeypatch):
monkeypatch.setattr(python_preset, "_run", _fake_run("Found 3 errors.\n", code=1))
score, confidence = python_preset.score_lint(Path("."))
assert confidence == 1.0
assert score == 0.97 # 1.0 - 3/100


def test_score_lint_missing_ruff_returns_unmeasured_sentinel(monkeypatch):
# Subprocess failed to launch ruff: empty stdout, nonzero exit.
monkeypatch.setattr(python_preset, "_run", _fake_run("", code=1))
assert python_preset.score_lint(Path(".")) == (0.0, 0.0)


def test_score_lint_unparseable_output_returns_unmeasured_sentinel(monkeypatch):
# Some future ruff version changes its output format — we'd rather report
# "no data" than fabricate a 0.99 score.
monkeypatch.setattr(
python_preset, "_run", _fake_run("some unexpected output\n", code=1)
)
assert python_preset.score_lint(Path(".")) == (0.0, 0.0)
Loading