Skip to content
Open
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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 1 addition & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,5 @@ dist
build

# Plans and CI
.claude
.the AI assistant
.github
12 changes: 12 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,15 @@ scripts/embedding-bits-manifest.json text eol=lf
# literal "dev" placeholder and /health reported version "unknown". eol=lf so
# the substituted SHA is not followed by CRLF on Windows checkouts.
COMMIT text eol=lf export-subst

# N-326-C5 trust-root SSOT: the checked-in FIDO2-sk allowed_signers file
# AND its golden-hash companion MUST be byte-stable across Windows and
# Linux checkouts. `test_repo_trust_root_matches_golden_hash` computes
# `sha256(read_bytes())` and compares against the golden — same drift
# class as the embedding-bits manifest above (CI on Linux sees LF blob
# bytes; Windows CRLF-autocrlf checkout sees `\r\n` → different hash →
# false-positive DRIFT). Pinning LF makes the on-disk bytes match the
# blob bytes on every platform, so the golden hash is one canonical
# value everywhere.
.github/op-approve/allowed_signers text eol=lf
tests/goldens/allowed_signers.sha256 text eol=lf
98 changes: 78 additions & 20 deletions .githooks/pre-push
Original file line number Diff line number Diff line change
@@ -1,25 +1,76 @@
#!/usr/bin/env bash
#
# pre-push: run pip-audit BEFORE the push lands on origin so CI
# never has to fail on a CVE you could have caught locally.
# pre-push guards (run BEFORE the push lands on origin so CI never has to
# fail on something you could have caught locally):
# 1. e2e real-browser suite — when a UI/e2e surface changed
# 2. pip-audit vuln scan — when pip-audit is installed
# 3. requirements.lock drift — when pip-compile is installed
#
# Bypass with `git push --no-verify` ONLY in genuine emergencies
# (the hook prints how when it fires). Bypassing is logged in the
# user's local shell history; intentional bypass is auditable.
# Each guard is conditional (skips cleanly if its tool isn't present) and
# bypassable with `git push --no-verify` ONLY in genuine emergencies (the
# hook prints how when it fires). Bypassing is logged in the user's local
# shell history; intentional bypass is auditable.
#
# Activated by `scripts/install-git-hooks.sh` (runs `git config
# core.hooksPath .githooks` so the repo-tracked hooks become live).
set -euo pipefail

# Only run if pip-audit is installed; nudge if not. We don't want to
ROOT=$(git rev-parse --show-toplevel)

# ---------------------------------------------------------------------------
# Guard 1 — e2e real-browser suite. A spec (tests/e2e) that asserts the wrong
# thing — or a UI/web change that breaks one — must not land RED in CI:
# .github/workflows/e2e.yml is the ONLY GitHub job that runs pytest, so a red
# e2e is otherwise unguarded. Runs ONLY when (a) Playwright is importable
# locally AND (b) this push touches an e2e / web / template surface — docs and
# non-UI pushes pay nothing. Placed FIRST so it runs independently of whether
# pip-audit is installed (the pip-audit guard below `exit 0`s when absent).
# Origin (2026-06-20): an N-109 e2e spec asserted `.to_be_visible()` on a
# `data-multiselect`-HIDDEN <select> and was red in CI for hours, unnoticed,
# because the committed spec had never been run through the harness (which
# DOES run locally on Windows). This guard makes that class unpushable.
PY=python; command -v python >/dev/null 2>&1 || PY=python3
if "$PY" -c "import playwright" >/dev/null 2>&1; then
# What is being pushed? Commits on HEAD not yet on the upstream tracking
# ref. No upstream (new branch) → fall back to origin/main; if neither is
# resolvable, RUN (fail-safe toward catching).
RANGE=""
if UPSTREAM=$(git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null); then
RANGE="${UPSTREAM}..HEAD"
elif git rev-parse --verify origin/main >/dev/null 2>&1; then
RANGE="origin/main..HEAD"
fi
CHANGED=""
[ -n "$RANGE" ] && CHANGED=$(git diff --name-only "$RANGE" 2>/dev/null || true)
if [ -z "$RANGE" ] || printf '%s\n' "$CHANGED" \
| grep -qE '^tests/e2e/|^src/email_triage/web/|\.html$'; then
echo "pre-push: e2e real-browser suite (UI/e2e surface changed; --no-verify to bypass)"
if ! (cd "$ROOT" && "$PY" -m pytest tests/e2e --browser chromium -q -o addopts=""); then
cat >&2 <<'EOF'

pre-push BLOCKED: tests/e2e failed (the real-browser suite CI runs).
Fix the spec/UI before pushing — a red e2e lands red in CI (e2e.yml is the
only GitHub job that runs pytest). Re-run locally:
python -m pytest tests/e2e --browser chromium -q -o addopts=""
Bypass (rarely correct): git push --no-verify

EOF
exit 1
fi
fi
else
echo "pre-push: playwright not importable; skipping e2e guard (CI still runs it)."
fi

# ---------------------------------------------------------------------------
# Guard 2 — pip-audit. Only run if installed; nudge if not. We don't want to
# auto-install via the hook — operator opts in via the install script.
if ! command -v pip-audit >/dev/null 2>&1; then
echo "pre-push: pip-audit not installed; skipping vuln scan."
echo " Install with: pip install pip-audit"
exit 0
fi

ROOT=$(git rev-parse --show-toplevel)
SCRIPT="$ROOT/scripts/run-pip-audit.sh"
if [ ! -x "$SCRIPT" ]; then
echo "pre-push: $SCRIPT missing or not executable; skipping."
Expand All @@ -43,24 +94,31 @@ EOF
exit 1
fi

# Lockfile-freshness guard. If pyproject.toml has dependency edits
# ---------------------------------------------------------------------------
# Guard 3 — lockfile-freshness. If pyproject.toml has dependency edits
# that haven't been baked into requirements.lock, the Containerfile +
# CI installs would silently fall back to whatever the resolver
# picks today. Catch the drift before push so CI doesn't have to.
# Skipped when pip-compile isn't available (operator-side optional).
if command -v pip-compile >/dev/null 2>&1; then
TMP_LOCK="$(mktemp)"
# Extras MUST match the ones the Containerfile installs; otherwise
# the lock excludes deps the runtime needs (aioimaplib, msal, etc.)
# and the container build silently ships a broken image.
# Run pip-compile from $ROOT with a relative pyproject path. When
# an absolute path is passed, pip-compile annotates each # via
# comment with the full path, which then drifts vs the committed
# lockfile (which is generated with relative paths). Same TMP file
# path either way; only the annotation source differs.
# Seed TMP with the COMMITTED lock so pip-compile does a MINIMAL
# update (honouring existing pins) rather than a from-scratch
# resolve. Without this seed, a fresh resolve bumps unrelated
# transitive deps to their latest (anyio, cbor2, certifi, …) and
# the guard FALSE-FAILS every normally-maintained lock — the bug
# that surfaced the day repo hooks were first activated (2026-06-20).
cp "$ROOT/requirements.lock" "$TMP_LOCK"
# Flags MUST match the canonical command recorded in the lock's own
# header — ``--extra=cache --extra=imap --extra=keyfile
# --extra=office365 --extra=openai --no-strip-extras``. Dropping any
# (e.g. ``cache``/redis or ``--no-strip-extras``) makes the regen
# diverge from the committed lock and false-fail. Run from $ROOT with
# a relative pyproject path so the ``# via`` annotations match the
# committed (relative-path) lock.
if (cd "$ROOT" && pip-compile --quiet \
--extra=keyfile --extra=imap \
--extra=office365 --extra=openai \
--extra=cache --extra=imap --extra=keyfile \
--extra=office365 --extra=openai --no-strip-extras \
--output-file="$TMP_LOCK" pyproject.toml) >/dev/null 2>&1; then
# Strip the auto-comment header (mentions the output filename
# which differs between the canonical lock and the tmp file)
Expand Down Expand Up @@ -88,8 +146,8 @@ if command -v pip-compile >/dev/null 2>&1; then
pre-push BLOCKED: requirements.lock is stale vs pyproject.toml.

Refresh:
pip-compile --extra=keyfile --extra=imap \\
--extra=office365 --extra=openai \\
pip-compile --extra=cache --extra=imap --extra=keyfile \\
--extra=office365 --extra=openai --no-strip-extras \\
--output-file=requirements.lock pyproject.toml
# then re-add the pywin32 marker (see existing comment in lock)
git add requirements.lock
Expand Down
20 changes: 20 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# CODEOWNERS — email-triage trust-root gate coupling (N-326-C5)
#
# Purpose: any PR that touches the trust-root pubkey mirror OR the
# golden-hash file requires operator (Alex Doe) review before
# merge. GitHub enforces this at the PR-review layer; the SME-gate CI
# check enforces the signed-proof trailer half; together they close the
# `80cfbbdc` trust-breach class end-to-end (see
# `internal documentation` + `internal documentation`
# §4.7 N-333 + §4.9 N-326-C5).
#
# Coupling rationale (N-326-C5 SME-panel DA fold #8): a malicious PR
# that swaps `.github/op-approve/allowed_signers` (the CI-side trust
# root mirror) MUST also swap `tests/goldens/allowed_signers.sha256`
# (the byte-integrity golden). CODEOWNERS on BOTH forms the joint gate
# — the trust-root pin at `tests/test_n326_c5_allowed_signers_ssot.py`
# checks byte-equality against the golden; without CODEOWNERS on both,
# a direct-push to `feat/v0.2.0` would sidestep the operator review.

.github/op-approve/** @C-Doe
tests/goldens/** @C-Doe
85 changes: 85 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Dependabot — weekly dependency freshness sweep + CVE alerts.
#
# Layered strategy (see mempalace `feedback_dependency_freshness_audit_at_push`):
# - THIS FILE: Dependabot opens grouped PRs for pip / GH Actions / Docker
# bumps on a schedule. Each PR runs the full CI matrix (tests.yml,
# security.yml, e2e.yml) → catches breaks pre-merge.
# - `.github/workflows/security.yml` scheduled trigger: nightly pip-audit
# so a NEW CVE lands within 24h even without a push.
# - Push-time pre-push hook + security.yml on-push: catches CVEs
# introduced by a manual bump.
#
# Grouping keeps PR volume manageable — one PR per (ecosystem × update-type)
# tier per run instead of 50+ per week. Patch bumps get their own group so
# they can (eventually) auto-merge on green CI once we're comfortable with
# the pattern. Minor + major stay human-review by default.
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "06:00"
timezone: "America/New_York"
open-pull-requests-limit: 8
target-branch: "main"
groups:
python-patch:
# Safe drift-only bumps (tzdata, certifi patches, click x.y.z→x.y.z+1).
# Candidates for future auto-merge on green CI.
update-types: ["patch"]
python-minor:
# Minor bumps — API-stable per semver but occasional regressions;
# human review before merge.
update-types: ["minor"]
python-major:
# Major bumps — breaking-change risk; MUST be human-reviewed
# (cryptography 48→49, webauthn 2→3, etc.).
update-types: ["major"]
labels:
- "dependencies"
- "python"
commit-message:
prefix: "deps"
prefix-development: "deps-dev"
include: "scope"
reviewers:
- "C-Doe"
ignore:
# torch pin is CPU-only (`torch==2.12.0+cpu`); Dependabot cannot resolve
# the `+cpu` local-version identifier. Managed manually.
- dependency-name: "torch"

- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "06:30"
timezone: "America/New_York"
open-pull-requests-limit: 4
target-branch: "main"
groups:
actions-all:
patterns: ["*"]
labels:
- "dependencies"
- "github-actions"
commit-message:
prefix: "ci"

- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "07:00"
timezone: "America/New_York"
open-pull-requests-limit: 2
target-branch: "main"
labels:
- "dependencies"
- "docker"
commit-message:
prefix: "docker"
1 change: 1 addition & 0 deletions .github/op-approve/allowed_signers
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
op-approval@bob sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIDAT/EtBYL5Ph+rCBvEHZDO3P9XV1GeM9f1rLVTtUr24AAAAD3NzaDpvcC1hcHByb3ZhbA==
80 changes: 80 additions & 0 deletions .github/workflows/arm64-build-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
name: ARM64 build check

# + N-432 Path-3 (2026-07-20) — VALIDATE-ONLY counterpart to release.yml.
#
# release.yml builds AND PUSHES the multi-arch image to the public-mirror GHCR
# slot, and only fires on push-to-main / a `v*` tag. That makes it unsuitable
# for *validating* the arm64 leg before a release: you cannot run it without
# publishing. This workflow fans the SAME Containerfile out to
# linux/amd64,linux/arm64 with **push: false** — it builds both arches (post-
# Path-3 that means BOTH arches now source-build coleifer sqlcipher3 against a
# freshly-generated SQLCipher v4.17.0 amalgamation; the arm64 leg's amalgamation
# tclsh generation + gcc compile happens under QEMU emulation — expect the
# arm64 leg to dominate wall-clock at ~18-25 min per run). The in-Containerfile
# runtime smoke (`import sqlcipher3` + assert sqlite_version_info >= (3,51,3) +
# cipher_version 4.17.x) RUNs under each platform's emulation, FAILING the build
# if either the walresetbug fix regressed or SQLCipher pinned line drifted.
# Nothing is pushed, tagged, or released — a green run proves "the multi-arch
# image builds and loads its walresetbug-clean crypto" with zero public artifact.
#
# It does NOT prove runtime correctness on real ARM silicon (no PHI round-trip,
# no app-boot) — that stays deferred to native-hardware validation. This
# is the build/load gate, not the hardware gate. See internal documentation +
# STATE_2026-07-20_0030_edt_walresetbug_path3_containerfile_drafted.md.

on:
workflow_dispatch: {}
pull_request:
branches: [main]
paths:
- 'Containerfile'
- 'pyproject.toml'
- '.github/workflows/arm64-build-check.yml'
push:
branches: ['feat/**']
paths:
- 'Containerfile'
- 'pyproject.toml'
- '.github/workflows/arm64-build-check.yml'

permissions:
contents: read

jobs:
build-check:
name: Multi-arch build (no push)
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- name: Checkout
uses: actions/checkout@v4

# Register QEMU so the amd64 runner can build the linux/arm64 leg
# (incl. the arm64 source-build of sqlcipher3). MUST precede buildx.
- name: Set up QEMU (multi-arch emulation)
uses: docker/setup-qemu-action@v3
with:
platforms: arm64

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Build both arches (no push — validation only)
uses: docker/build-push-action@v6
with:
context: .
file: Containerfile
# Same multi-arch fan-out as release.yml. Post-Path-3 (2026-07-20)
# both arches take the unified source-build path (no TARGETARCH split);
# the arm64 leg runs the SQLCipher amalgamation-gen + gcc compile
# under QEMU (slow but correct). Building the default (runtime) stage
# runs the per-platform runtime smoke — `import sqlcipher3` +
# sqlite_version_info >= (3,51,3) + cipher_version 4.17.x.
platforms: linux/amd64,linux/arm64
push: false
# No registry interaction at all: GHA-local cache only, no GHCR
# pull/push, no provenance attestation (attestation needs a push).
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false
sbom: false
Loading
Loading