Skip to content

v0.5.0: repo standard (0 FAIL), uv.lock, live-test runner, list_plans / find_plan_for_path / get_backup_freshness, F-04 + #1182 - #8

Merged
TadMSTR merged 19 commits into
mainfrom
feature/p2-conformance-and-tools
Oct 20, 2026
Merged

TadMSTR merged 19 commits into
mainfrom
feature/p2-conformance-and-tools

Conversation

@TadMSTR

@TadMSTR TadMSTR commented Oct 20, 2026

Copy link
Copy Markdown
Owner

Part 2 of the backrest-mcp hardening programme. Part 1 (v0.4.1, #7) fixed the get_config secret leak.

What changes

Conformance. With visibility: public declared, repo-conform.py went from 10 pass / 12 fail on 5628e2f to 25 pass / 0 fail. One SKIP remains: B11 needs a GITHUB_TOKEN. The changes:

  • src/ layout
  • bounded ranges and a committed uv.lock; CI installs --frozen and audits the locked runtime and dev sets separately
  • SHA-pinned actions with version comments, and top-level permissions: contents: read everywhere
  • gitleaks 8.30.1 (digest re-verified), CodeQL, Scorecard, CODEOWNERS, and Dependabot (uv + github-actions, with the codeql group on both channels)
  • ruff E F W I UP B SIM RUF plus ruff format
  • coverage floor 96 (measured 96.18%)
  • README badge row; .gitignore adds *.key/*.pem, and the bare core.* that matched core.py is gone (#581)

Commits are split for review. 3f1647b is the mechanical ruff format/autofix pass only, and 2f91593 the hand fixes. Read the feature commits on their own.

F-04 (p1 audit). The six nested policy messages are no longer copied whole. Each goes through its own allowlist of v1.13.0 field names; that is option (a). A hand-written test pins the names and was seen red under mutation. The live run warns, by key name only, on any field the allowlist lacks.

#1182. The restore prefix default is now ~/backrest-restore/, and ecosystem.config.js changed too, since it hard-coded /tmp. restore_snapshot refuses a prefix that is missing, a symlink, not a directory, foreign-owned, or group/world-writable. There is one test per refusal, and each check was mutated out to watch its test fail. Part (b), the window after the check, is documented as not closable from this side.

New read-only tools: list_plans, find_plan_for_path and get_backup_freshness. All go through redact.project_config, are LEAKCANARY-tested with quoted-key secret checks, and have table-driven boundary tests. find_plan_for_path approximates restic's exclude matching and says so in its response ("match": "approximate"). Read-only registration is now asserted as an exact set.

Live tests. scripts/run_live_conformance.py reads BACKREST_* in-process from BACKREST_LIVE_ENV_FILE. Exit codes:

  • 0: the live file ran and passed
  • 1: a failure
  • 2: env file unreadable
  • 3: every test skipped

On forge (production venv, this tree's src/): 10 ran, 0 skipped, 0 failed. With wrong credentials it exits 1, and the credential never appears in the output.

#4. NATS is off in production (NATS_URL unset, checked as a bool). connect() now takes NATS_CREDS_FILE or NATS_USER+NATS_PASSWORD, with tests.

Decisions to argue

  • index_snapshots was not added. The plan put it behind the write gate. do_repo_task(task="index") already sends TASK_INDEX_SNAPSHOTS (enum 1 in service.proto v1.13.0) behind the same BACKREST_READONLY=false gate. A second tool would add surface without adding capability.
  • gitleaks suppression. The p1 LEAKCANARY fixture's fake actionCommand matches gitleaks 8.30.1's curl-auth-header rule, and it sits in public history at 5628e2f. .gitleaksignore holds one fingerprint for it: no rule-wide or path-wide allowlist.
  • Follow-up, out of scope here. Once this is live, sysadmin's backrest-mcp grant could drop get_config in favour of list_plans. That is a /etc/forge/manifests change.

Deploy note

The src/ move breaks python -m backrest_mcp.server from the repo root until the editable install is redone. The deploy is one sysadmin task, in this order:

  1. git pull
  2. .venv/bin/pip install -e .
  3. import check
  4. pm2 restart
  5. mkdir -m 0700 ~/backrest-restore
  6. restart the scoped-mcp proxy
  7. weekly cron for the live runner

🤖 Generated with Claude Code

TadMSTR and others added 9 commits October 20, 2026 17:00
- git mv backrest_mcp -> src/backrest_mcp; setuptools finds packages under src/.
- Every range bounded. Runtime floors are what the production venv runs (fastmcp 3.4.0,
  httpx 0.28.1, pydantic 2.13.4, structlog 25.5.0), ceilings the next major (next minor
  for 0.x). ruff pinned exactly so the format gate does not drift.
- uv.lock committed (uv 0.11.18).
- ruff select expanded to the mandated families; the fixes follow in their own commits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No hand edits. Produced by `ruff format src tests` then `ruff check --fix src tests`
with ruff 0.16.10 under the expanded select. Hand fixes for the remaining E501/SIM117
follow separately.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Long SECURITY[deferred] comments wrapped, wording unchanged.
- client.post_streaming: one parenthesised async with (SIM117).
- from __future__ import annotations in the two empty __init__ modules (Python appendix).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n, CodeQL, Scorecard, Dependabot (B2 B6 B12 B14 F2-F6 F10 F11)

- ci.yml: top-level `permissions: contents: read`; every action SHA-pinned with a version
  comment; install from uv.lock (`uv sync --frozen`); ruff format --check; a dependency
  audit that reads the lock (runtime and dev sets separately) after `uv lock --check`;
  a wheel build imported from outside the tree (catches a broken src/ packages.find).
- release.yml: read at the top, `contents: write` only on the release job; checkout pin
  moved to the same commit as the other workflows.
- secret-scan.yml (gitleaks 8.30.1, digest re-verified 2026-10-20), codeql.yml,
  scorecard.yml, CODEOWNERS and dependabot.yml (uv + github-actions, codeql group on both
  channels), modelled on loki-mcp.
- tests/check_gitleaks_gate.py and tests/check_action_pins.py: two-sided gates copied from
  loki-mcp; both run red on a planted violation before passing the real tree.
- tests/test_workflows.py: SHA pin + version comment and top-level permissions in every
  workflow, enforced on every PR (repo-conform only runs on forge).
- .gitleaksignore: one fingerprint for the synthetic LEAKCANARY hook command in public
  history at 5628e2f (gitleaks 8.30.1 `curl-auth-header`). No rule- or path-wide allowlist.
- .gitignore: *.key/*.pem/*.p12/*.pfx; bare `core.*` replaced by `/core` + `core.[0-9]*`,
  which no longer matches core.py (#581, backrest half).
- README badge row: Claude Code first, License last.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…wlists (F-04)

redact.py copied prunePolicy, checkPolicy, forgetPolicy, commandPrefix, schedule and
retention whole. Safe against Backrest v1.13.0, but a field a later Backrest adds to one of
them would have passed straight through. Each is now projected through its own allowlist
(option a), recursing into Schedule/RetentionPolicy/TimeBucketedCounts; a scalar slot only
accepts str/int/float/bool.

- REPO_MESSAGES / PLAN_MESSAGES name the v1.13.0 JSON fields; a test pins them by hand.
- unknown_policy_keys(cfg) reports unlisted key paths (names only) for the live check.
- Tests: unknown sub-fields dropped at every level, non-scalar in a scalar slot dropped,
  and the pin test goes red when an allowlist gains a field (checked by mutation).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…1182)

- Default BACKREST_RESTORE_ALLOWED_PREFIX is ~/backrest-restore/, expanded at load. The old
  /tmp/backrest-restore/ did not exist on forge, and realpath() followed whatever symlink
  was planted at that name first.
- ecosystem.config.js set the /tmp path explicitly, so the code default would never have
  applied in production; it now uses path.join(HOME, "backrest-restore") + "/".
- safety.check_restore_prefix() refuses, with a fixed message and no API call, a prefix that
  is missing, a symlink, not a directory, not owned by the effective uid, or group/world
  writable. The trailing slash is stripped first because lstat("link/") follows the link.
- One test per refusal plus the allowed case and the default; each check was mutated out
  and its test went red.
- Part (b), the window between the check and Backrest creating the target, cannot be closed
  from this side. Documented in the docstring; restore_snapshot stays unregistered in
  production.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, via redact.py)

All three read GetConfig through redact.project_config and never see the raw config.

- list_plans: the projected plans, same shape as get_config's.
- find_plan_for_path(path): absolute path only (normalised, length- and NUL-checked); pure
  string matching against each plan's paths, with the first matching excludes/iexcludes
  pattern. Exclude matching approximates restic (anchored, unanchored basename, *, ?, [..],
  **, case-insensitive iexcludes; no negation or exclude files), so the response says
  "match": "approximate".
- get_backup_freshness(max_age_hours=None): per plan, the last backup operation that ended
  SUCCESS or WARNING among its last 200 operations (GetOperations, planId + repoGuid),
  age, latest status and a state: ok / stale / unscheduled. Threshold: the argument, else
  2x maxFrequencyHours, 2x maxFrequencyDays, or 26 h for cron. Disabled schedules are
  unscheduled, never stale. No cron parser added.
- GetOperations moved into _get_operations_raw, shared with get_operations.
- The server instructions point coverage questions at the new tools instead of get_config.
- plans.py holds the pure matching/threshold logic, table-tested at the boundaries.
- Every new tool passes the LEAKCANARY fixture with quoted-key secret checks; every read
  tool returns the generic error on a backend 500; read-only registration is now an exact set.

index_snapshots is not added: do_repo_task(task="index") already sends
TASK_INDEX_SNAPSHOTS behind the same BACKREST_READONLY=false gate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…edentials (#4)

- scripts/run_live_conformance.py: reads BACKREST_* in-process from BACKREST_LIVE_ENV_FILE
  (default ~/.secrets/forge.env, so #1181 can move the repo password without a code change),
  runs the live file with -p no:cacheprovider --tb=line, imports from this tree's src/, and
  exits non-zero on any failure (1), an unreadable env file (2), or every test skipped (3).
  Prints key names only. Unit-tested without a subprocess.
- Live tests for list_plans, find_plan_for_path("/home/ted") and get_backup_freshness:
  shape plus secret absence (repo password, quoted "password"/"env" keys), bool-then-assert
  with fixed messages. A live check warns, by key name only, when Backrest's nested policy
  messages carry a field redact.py does not list (F-04).
- Measured on forge 2026-10-20 with the production venv against worktree code: 10 ran,
  0 skipped, 0 failed, no unknown policy fields. With wrong credentials: 8 failed, exit 1,
  and the credential does not appear in the output.
- #4: NATS_URL is not set on the running process (checked as a bool), so NATS telemetry is
  off in production. connect() now takes NATS_CREDS_FILE (user_credentials) or
  NATS_USER + NATS_PASSWORD; half a pair warns by name and connects without. The
  SECURITY[deferred] comment for BKRST-1 is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…GELOG

- fail_under = 96 (measured 96.18%, 242 passed, 2026-10-20) with the dated F8 comment.
- README and AGENTS: the three new tools, src/ layout, uv-based dev setup, the live runner,
  the restore prefix rules. README's PM2 section no longer tells you to pass --env-file,
  which PM2 6.x does not have; ecosystem.config.js reads forge.env itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-advanced-security

Copy link
Copy Markdown

You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool.

What Enabling Code Scanning Means:

  • The 'Security' tab will display more code scanning analysis results (e.g., for the default branch).
  • Depending on your configuration and choice of analysis tool, future pull requests will be annotated with code scanning analysis results.
  • You will be able to see the analysis results for the pull request's branch on this overview once the scans have completed and the checks have passed.

For more information about GitHub Code Scanning, check out the documentation.

TadMSTR and others added 4 commits October 20, 2026 17:15
Phase 1.6: watch tests/test_workflows.py turn the test job red on an unpinned action.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Phase 1.6: the tag-pinned action is reverted; this round watches ruff turn the test job red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread src/backrest_mcp/server.py Fixed
- IV-47 (FAIL): find_plan_for_path's exclude matcher was superlinear in a caller-chosen
  path: the recursive matcher re-matched every ancestor and branched on each **, cubic for
  the common **/name form (measured 3.4 s at 400 components; the 4096-char cap allowed
  2048). It is now an NFA walk over the components, linear in components x pattern
  length, and normalise_path caps paths at 256 components. A differential test checks the
  new matcher against the old one on 4000 random cases; a time-bounded test runs four
  worst-case patterns at the cap.
- SC-06: the live runner passes only the four BACKREST_* keys the tests use
  (URL, USERNAME, PASSWORD, REPO_PASSWORD), not every BACKREST_* in forge.env (which
  includes the MCP bearer token). Live re-run: 10 ran, 0 skipped, 0 failed.
- Comments in tests/check_gitleaks_gate.py named 8.28.0 as this repo's pin; it is 8.30.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@TadMSTR

TadMSTR commented Oct 20, 2026

Copy link
Copy Markdown
Owner Author

forge agent report — composed by the security agent (model claude-opus-5-5) for build backrest-mcp-hardening-2026-10-p2-conformance-and-tools.
Posted through a shared automation account, so the author of this post is not a per-agent identity.
Full report, internal: host-forge/build-reports/backrest-mcp-hardening-2026-10-p2-conformance-and-tools/audit.md

Security audit started.

Scope Count
Repositories 2
Files in scope 39

Findings will be posted here when the audit completes.

@TadMSTR

TadMSTR commented Oct 20, 2026

Copy link
Copy Markdown
Owner Author

forge agent report — composed by the security agent (model claude-opus-5-5) for build backrest-mcp-hardening-2026-10-p2-conformance-and-tools.
Posted through a shared automation account, so the author of this post is not a per-agent identity.
Full report, internal: host-forge/build-reports/backrest-mcp-hardening-2026-10-p2-conformance-and-tools/audit.md

Security audit complete (round 1) — 6 findings.

Severity Count
Critical 0
High 0
Medium 0
Low 1
Info 5
Disposition Count
Resolved 0
Accepted 0
Deferred 0

Categories: ci-config, dependency, documentation, other, secrets-handling

Finding detail is in the internal report referenced above.

TadMSTR and others added 5 commits October 20, 2026 17:45
…f umask

Finding: F-03 from backrest-mcp-hardening-2026-10-p2-conformance-and-tools audit.
check_restore_prefix refused only group/world-WRITABLE prefixes, so 0755 and 0750 passed
while the docs said 0700. restic restores snapshot modes, so a tree whose privacy came
from a 0700 parent would be readable by other local uids under a 0755 prefix. It now
refuses any group or other bit (S_IRWXG | S_IRWXO). Tests for 0755/0750/0705/0740; the
old check fails 4 of them.

Finding: F-02 from the same audit. Five restore tests created the prefix with a plain
mkdir(), so under forge's session umask 0002 it was 0775 and refused before the code under
test ran. A _private_dir() helper chmods to 0700 explicitly; a test runs a restore under
umask 0002. test_safety.py: 29 passed under umask 022, 002 and 077 (old fixtures: 5
failed under 002).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Finding: F-04 from backrest-mcp-hardening-2026-10-p2-conformance-and-tools audit.
The live tests compared the plain repo password with the tool's JSON output, so a password
containing a quote, backslash, control or non-ASCII character (escaped in JSON) would pass
even if it leaked. _contains_secret() checks the raw, ASCII-escaped and UTF-8-escaped forms;
used by every live leak check including p1's get_config test. Bool-then-assert kept.
tests/test_live_helpers.py covers it in CI (the old substring check misses 'pa"ss').
Live re-run: 10 ran, 0 skipped, 0 failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Finding: F-05 from backrest-mcp-hardening-2026-10-p2-conformance-and-tools audit.
A plan whose schedule is disabled is 'unscheduled' and never stale, so it dropped out of
stale_count while producing no backups: a monitor reading stale_count alone would see
'schedule turned off' as healthy. The response now carries unscheduled_count, and the
docstring tells consumers to look at both. Semantics of stale are unchanged. Boundary
tests assert both counts; the live shape check requires both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Finding: F-06 from backrest-mcp-hardening-2026-10-p2-conformance-and-tools audit.
setup-uv was SHA-pinned but installed the latest uv, so uv lock --check, the pylock export
the dependency audit reads, and uv build could change between runs with no workflow edit.
All three jobs now pass version: "0.13.4" (the version CI has been running green on).
tests/test_workflows.py::test_setup_uv_pins_the_uv_version requires every setup-uv step
to carry a version; it finds 0 of 3 on the previous ci.yml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Finding: F-01 from backrest-mcp-hardening-2026-10-p2-conformance-and-tools audit.
README/AGENTS: install and update with UV_PROJECT_ENVIRONMENT=.venv uv sync --frozen
--extra dev (dev: the live runner needs pytest), then an import check and pip-audit on
the venv. Rehearsed on a scratch venv built from the production venv's pip freeze:
35 vulns / 8 packages before, 'No known vulnerabilities found' after; imports OK; live
runner 10 ran, 0 failed from the synced venv. The production venv itself was not touched.
CHANGELOG 0.5.0 updated for F-01..F-06.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@TadMSTR
TadMSTR merged commit 64eb4be into main Oct 20, 2026
11 checks passed
@TadMSTR
TadMSTR deleted the feature/p2-conformance-and-tools branch October 20, 2026 21:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants