Commit Graph

35 Commits

Author SHA1 Message Date
CyberMind
6aa5e38d7d
Mail Phase 2 — Rspamd migration (closes #153) (#160)
* test(mail): Phase 2 scaffolding — rspamd.sh + templates + routers + bats baseline (ref #153)

* feat(mail): rspamd.sh — install_rspamd + re-entry guard (ref #153)

* feat(mail): Rspamd config templates (9 .conf files) — DKIM/ARC/DMARC/greylist/ratelimit (ref #153)

* feat(mail): lib/mail/rspamd.sh — configure helpers + DKIM keygen + D9 purge gate (ref #153)

* feat(mail): Milestone C — rspamd_client + /rspamd/* router + legacy shims (ref #153)

- rspamd_client.py: httpx wrapper around the Rspamd HTTP controller
  (read /stat /history /graph; write /reload /learnspam /learnham).
- routers/rspamd.py: 11 new endpoints under /api/v1/mail/rspamd/*
  (status, history, scores, reload, learn-{spam,ham}, whitelist CRUD,
  dkim/{domain}/{status,keygen}).
- routers/legacy.py: 14 deprecation shims for the Phase 1 /dkim/*
  /spam/* /grey/* surface. Each emits X-Deprecated-Endpoint: rspamd
  and forwards to the Rspamd equivalent. Removed in v3.0.
- main.py: drops the 14 inline legacy handlers and mounts the two
  routers via app.include_router(). 62/62 Phase 1 endpoint pytest
  still passes.

* feat(mail): Milestone D — mailctl cmd_rspamd dispatcher (ref #153)

Adds rspamd subcommand to mailctl: install / start / stop / restart /
reload / status / dkim-keygen / dns-records / learn-spam / learn-ham /
purge-legacy. The install path calls lib/mail/rspamd.sh helpers in
order: install_rspamd → configure_rspamd_milter → configure_rspamd_controller
→ configure_rspamd_dkim → configure_rspamd_postfix_milter.

The purge-legacy verb requires Rspamd to respond healthy on :11334
before removing SA + OpenDKIM (D9 safety gate).

* feat(mail): Milestone E — install adds rspamd; mail.toml [mail.rspamd]; postinst secrets (ref #153)

- install_mail_packages: now installs rspamd + redis-server + enables
  postfix/dovecot/rspamd at LXC boot (Phase 1 follow-up). Drops Apache/
  Roundcube — they live in the roundcube LXC after the rev. 3 split.
- mail.toml: [mail.rspamd] section (greylist, bayes_autolearn,
  ratelimit_outbound=200/h/user, web_ui_host). horde_url added.
- debian/postinst: on upgrade <2.3, generate /etc/secubox/secrets/
  rspamd-controller.pw (32-byte random) and mkdir /data/volumes/mail/
  rspamd/{dkim,bayes,history,settings} chown 100110 (_rspamd in unpriv).

* feat(mail): Milestone F — rspamd-route-sync-patch deploy helper (ref #153)

Idempotent one-shot script run at deploy time:
1. Removes 10.100.0.{10,11,12} from sync-mitmproxy-routes.sh's
   DEAD_CONTAINER_IPS list so the periodic timer does not reroute
   the mail/horde/roundcube LXC routes to webui.
2. Adds rspamd.gk2.secubox.in → [10.100.0.10, 11334] to both the
   host's /srv/mitmproxy/haproxy-routes.json and the mitmproxy LXC
   copy, then restarts the mitmdump worker.

No source-side nginx vhost added: rspamd UI goes through the
existing HAProxy → mitmproxy_inspector → 10.100.0.10:11334 path.

* feat(mail): bump secubox-mail to 2.3.0 (Phase 2 Rspamd) (ref #153)

* test(mail): Phase 2 13-gate acceptance smoke (ref #153)

Run with: bash tests/scripts/test-mail-phase2-acceptance.sh root@admin.gk2.secubox.in

Gates 1-3 are source-side (parses, pytest, deb path coverage).
Gates 4-11 are board-side (Rspamd listening, Postfix milter wired,
DKIM key generated, modules loaded, web UI via WAF).
Gates 12-13 are post-cutover (legacy purged + Phase 1 regression
check — 5 secubox.in users intact, webmail still WAF-routed).

Every board call uses timeout — Phase 1 lesson, never raw pipes.

* fix(mail): debian/rules ships templates/rspamd/ (path-coverage fix, ref #153)

Phase 1 lesson redux: debian/rules silently misses files unless every
new source-tree subdir gets its explicit cp line. Adds the templates/
rspamd/ install line so the 9 local.d configs + the postfix milter
snippet land at /usr/lib/secubox/mail/templates/rspamd/.

* fix(mail): live-deploy fixes — _rspamd uid via lxc-attach + smoke gate 7/12 (ref #153)

Two issues surfaced during Phase 2 live deploy on admin.gk2.secubox.in:

1. configure_rspamd_controller hardcoded chown to host uid 100110, assuming
   _rspamd is uid 110 inside the LXC. On this Debian 12 image _rspamd is
   uid 107 — rspamd crash-looped with "Permission denied" on secrets.inc.
   Fix: chown via `lxc-attach -- chown _rspamd:_rspamd` so the kernel's
   idmap maps the right uid regardless of image package set. Mode 0640
   instead of 0600 so the worker (uid _rspamd) can read it even if launched
   from a slightly different group context.

2. Smoke gate 7 grepped for the literal string "rspamd" in the first 5
   lines of `rspamc stat`, but rspamc only prints "Results for command:
   stat" + counters. Fix: grep for an always-present stat marker
   ("Messages scanned" or "Pools allocated") with a wider head -10 window.

3. Smoke gate 12 ran `dpkg -l opendkim spamassassin` under `set -e` and
   silently aborted the smoke when the named packages were unknown (the
   success case after purge). Fix: append `|| true` so the grep below is
   the actual gate.

All 13 gates green on admin.gk2.secubox.in after these fixes + the manual
deploy steps (DKIM keygen via lxc-attach, bind-mount entries, HAProxy +
mitmproxy route map for rspamd.gk2.secubox.in).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(mail): rspamd lib + sbin write through live LXC (ref #153)

Phase 2 deploy on admin.gk2.secubox.in surfaced three brittleness points
that the live-deploy fixes patched ad-hoc:

1. configure_rspamd_milter wrote to ${LXC_BASE}/<container>/rootfs/etc/
   rspamd/local.d/ — but on this board the runtime LXC mounts a different
   rootfs (/data/lxc/<container>/rootfs/ per lxc.rootfs.path). Files written
   to the host-side guess landed in a stale shell the running LXC didn't
   see, and the first install's local.d was empty.

2. configure_rspamd_controller had the same path-guess issue plus
   hardcoded chown 100110:100110 (had to be patched in commit 637b2221
   because _rspamd is uid 107 on this image, not 110).

3. rspamd_keygen called rspamadm on the host PATH — rspamadm only exists
   inside the rspamd-installed LXC, so the function errored out on every
   install. The Phase 2 deploy worked around it manually.

This refactor makes all three write THROUGH the live LXC via lxc-attach:
- Each local.d template is streamed in with `lxc-attach -- tee` (kernel
  resolves the rootfs path; idmap applies to the resulting file uid).
- secrets.inc + worker-controller.inc likewise written via tee, then
  chown'd inside the LXC to _rspamd:_rspamd (kernel maps to the correct
  outside-LXC subuid regardless of image).
- rspamd_keygen takes a `container` arg, runs `rspamadm dkim_keygen`
  inside the LXC, writes the keypair to /etc/rspamd-keys/<domain>/ (the
  bind-mounted path; falls back to /var/lib/rspamd/dkim/ if the bind
  mount isn't active yet), and mirrors the DNS TXT to the host data dir
  for DNS-publishing tooling.
- mailctl's `dkim-keygen` subcommand now delegates to the lib function.
- rspamd-route-sync-patch.sh verifies each write (reads back + asserts
  the entry equals expected) and fails loudly on mismatch — the Phase 2
  deploy needed a manual second pass for the mitmproxy LXC copy.

Pre-conditions: configure_rspamd_milter and configure_rspamd_controller
now require the LXC to be RUNNING (added an explicit lxc-info guard).
This matches the cmd_rspamd install path which starts the LXC via
install_rspamd before configuring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 08:21:23 +02:00
35ba4c2c97 fix(mail): replay Phase 1 fix commits lost in PR #141 squash-merge
The squash-merge of PR #141 dropped the last 4 commits from feature/136,
leaving master with a broken secubox-mail 2.2.0:

* lib/install.sh / lib/lxc.sh / lib/migrate.sh shipped to wrong path
  (debian/rules expects lib/mail/, package only ships users.sh).
* mailctl cmd_install/cmd_start/cmd_stop/cmd_sync/cmd_dkim still call
  /usr/sbin/mailserverctl + /usr/sbin/roundcubectl — those are now
  deprecation shims that exec mailctl, producing the same fork-bomb
  recursion that took down 192.168.1.200 during initial deploy.
* mail-migrate-to-single-lxc.sh in-tree fallback expected old lib path.
* helpers.bash bats fixtures source wrong lib paths.
* Phase 1 post-mortem doc + smoke-gate-11 + cmd_install fix were absent.

This replays the affected files from feature/136 tip
(85394e10) onto master. Net effect identical to a clean merge.

Required before Phase 2 (which sources lib/mail/install.sh).
2026-05-15 17:15:00 +02:00
CyberMind
3219050775
Mail stack: Phase 1 — source-catch-up + legacy package cleanup (#141)
* test(mail): phase1 scaffolding — lib/ stubs + bats red baseline (ref #136)

* feat(mail): lib/lxc.sh — unprivileged veth helpers + bats assertions (ref #136)

* feat(mail): lib/migrate.sh — legacy LXC/toml detectors + I13 data guard (ref #136)

* feat(mail): lib/install.sh — extract Postfix/Dovecot/Roundcube helpers (ref #136)

* feat(mail): mailctl 2.2.0 — canonical paths/IP + migrate-config (ref #136)

* feat(mail): mail.toml schema for single LXC + canonical paths (ref #136)

* fix(mail): API reads canonical config keys; legacy aliases retained (ref #136)

* feat(mail): mailserverctl + roundcubectl reduced to deprecation shims (ref #136)

* feat(mail): mail-migrate-to-single-lxc.sh — defensive scanner (ref #136)

* feat(mail): postinst runs migrate-config + scanner on upgrade <2.2 (ref #136)

* feat(mail): deprecate 3 nginx snippets + WAF integration NEWS (ref #136)

* feat(mail): HAProxy mail-TCP snippet targeting 10.100.0.10 (ref #136)

* feat(mail): bump to 2.2.0 with Breaks/Replaces (ref #136)

* feat(mail): mark mail-lxc/webmail-lxc/webmail transitional 2.2.0 (ref #136)

* test(mail): phase1 endpoint presence pytest (62 routes) (ref #136)

* test(mail): phase1 end-to-end acceptance smoke (ref #136)

---------

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
2026-05-15 13:26:21 +02:00
e3a4efd4e6 test+docs: live smoke script + HISTORY/WIP entries for auth rework (ref #120) 2026-05-13 10:07:35 +02:00
aaf101553e test(metablog-webhook): 3-gate bash smoke (ref #113)
Gate 1: GET endpoint reachable (405 or 404, not connection refused)
Gate 2: POST without X-Gitea-Signature returns 401
Gate 3: POST with valid HMAC on a fake site returns 200 + skip=unknown-site

Gate 3 is skipped when WEBHOOK_SECRET env var is not set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 07:31:20 +02:00
a1126e85d3 test(metablog-ui): 3-gate smoke for dashboard + drill-in (ref #103)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:21:28 +02:00
d4b4160aad test(metablog): 3-gate smoke for site.json schema (ref #101)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:30:50 +02:00
2f440c6194 Merge remote-tracking branch 'origin/master' into feature/95-streamlit-per-site-version-pinning-conta
# Conflicts:
#	.claude/HISTORY.md
#	.claude/WIP.md
#	.gitignore
2026-05-12 16:59:00 +02:00
744770750e Merge remote-tracking branch 'origin/master' into feature/94-metablogizer-gitea-ingest-import-166-sit
# Conflicts:
#	.claude/HISTORY.md
#	.claude/WIP.md
2026-05-12 16:57:43 +02:00
4a388994d3 Merge remote-tracking branch 'origin/master' into feature/49-feat-metablogizer-streamlit-version-mana
# Conflicts:
#	.claude/HISTORY.md
2026-05-12 16:56:06 +02:00
93abde840e test(streamlit): Deploy/rollback cycle smoke (canary: yijing) (ref #95)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:29:34 +02:00
ef047dfba1 test(streamlit): 3-app smoke for Gitea ingest (ref #95)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:29:34 +02:00
a0e9ba8beb test(metablog): Smoke test for 3-site Gitea ingest (ref #94)
fix(metablog-ingest-site): Handle .git repos with unborn branch (no commits):
- Use git rev-parse --verify HEAD (exits non-zero on unborn branch) instead of
  rev-parse HEAD (which returns "HEAD" string even with no commits)
- Add fall-through path: .git exists + no commits → git symbolic-ref HEAD
  refs/heads/main + commit + push (same as fresh-init path)

test: Add tests/scripts/test-metablog-ingest.sh smoke test:
- Step 1: dry-run --limit 3 verifies report total=3
- Step 2: live --limit 3 accepts any ok+skip combo >= 3
- Step 3: idempotent re-run asserts all 3 skip-already-current
- Step 4: git ls-remote via MOCHAbin SSH verifies remote HEAD exists
- Step 5: clone+diff via MOCHAbin SSH (diff --exclude=.git) verifies
  clone == source

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:10:06 +02:00
CyberMind
122cd6cebc
WebUI Obfuscation - admin.HOSTNAME.secubox.in Only (#96)
* docs(spec+plan): WebUI obfuscation design and implementation plan (ref #44)

Spec: docs/superpowers/specs/2026-05-12-webui-obfuscation-design.md
Plan: docs/superpowers/plans/2026-05-12-webui-obfuscation.md

The design locks the SecuBox WebUI to admin.<HOSTNAME>.<DOMAIN_SUFFIX>
via strict-regex ACLs in HAProxy AND nginx, both driven by
/etc/default/secubox as the single source of truth. New package
secubox-defaults ships the env file; secubox-haproxy API exposes
/webui/admin-domain (info), /webui/nginx-config (JWT, rendered vhost),
/webui/refresh (JWT, cache invalidate). A safe renderer script writes
the nginx vhost atomically; haproxyctl + Python /generate both inject
the strict ACL at the top of http-in and https-in frontends.

Plan has 15 tasks with TDD steps, exact file paths, and complete code
in every step (no TBD/TODO). Acceptance criteria + integration test
script included.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(secubox-defaults): scaffold package skeleton (ref #44)

* feat(secubox-defaults): ship /etc/default/secubox env file (ref #44)

* feat(secubox-defaults): postinst autodetect + dpkg-trigger (ref #44)

* feat(secubox-haproxy): webui_identity helper + tests (ref #44)

Implement webui_identity module that parses /etc/default/secubox
and exposes canonical admin URL + regex pattern for HAProxy acl.

New module:
- api/webui_identity.py: Parses SECUBOX_HOSTNAME and SECUBOX_DOMAIN_SUFFIX
  with LRU cache and invalidation support.

Tests (TDD):
- tests/test_webui_identity.py: 5 test cases covering basic parsing,
  missing hostname validation, custom suffix handling, comment handling,
  and cache invalidation.
- tests/conftest.py: sys.path bootstrap for api/ imports.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(webui_identity): catch OSError on defaults read + test (ref #44)

* feat(secubox-haproxy): GET /webui/admin-domain endpoint (ref #44)

* feat(secubox-haproxy): GET /webui/nginx-config rendered vhost (ref #44)

* feat(secubox-haproxy): POST /webui/refresh cache invalidation (ref #44)

* feat(secubox-haproxy): secubox-render-nginx-webui safe renderer (ref #44)

* feat(haproxyctl): inject strict-regex WebUI ACL at frontend top (ref #44)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(secubox-haproxy): /generate symmetry with strict WebUI ACL (ref #44)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(secubox-haproxy): dpkg-trigger refresh on secubox-defaults-changed (ref #44)

* build(secubox-haproxy): depend on secubox-defaults (ref #44)

* test(infra): integration test for WebUI obfuscation (ref #44)

* docs: HISTORY Session 159 — WebUI obfuscation (ref #44)

Records the complete WebUI obfuscation work: new secubox-defaults
package, three /webui/* endpoints, nginx renderer script, haproxyctl
+ Python generator symmetry, dpkg trigger plumbing, integration test.
Includes notes on the brainstorming → spec → plan → subagent-driven
execution flow and the 6 fix iterations caught by reviewers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:07:11 +02:00
0373579dc8 test(gitea): Smoke test for the 5 validation gates (ref #49)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 12:54:19 +02:00
CyberMind
b0b6e78cab
Merge pull request #84 from CyberMind-FR/feature/license-headers-phase-a
feat(license): CMSD-1.0 header tool, CI, and conventions (Phase A)
2026-05-12 11:41:43 +02:00
b88b8ada95 fix(license): tighten detect_existing + missing-file = repo-wide (ref #81)
Two bug fixes to the CMSD-1.0 license-header tool:

1. detect_existing() previously matched the SPDX token anywhere in the
   first 10 lines, including inside docstrings and prose comments.
   Tightened the matcher to require comment markers (#, //, *, <!--)
   and whitespace before the SPDX token.

   Regression tests:
   - test_detect_existing_no_false_match_in_docstring
   - test_detect_existing_no_false_match_inline_comment_prose

2. _read_enrollment() now returns ["**"] when the allowlist file is
   absent, per spec §5.2 "missing file → repo-wide enforcement".
   Previously it returned [] (nothing enforced), making Phase C closure
   impossible without an additional file.

   Empty allowlist file still returns [] (Phase A initial), so
   test_main_empty_allowlist_passes_check is unaffected.

   New tests:
   - test_read_enrollment_missing_file_means_repo_wide
   - test_main_check_missing_allowlist_enforces_repo_wide

Suite: 49 → 53 passing. --check still exits 0 repo-wide.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:39:57 +02:00
2763009fad fix(license): walk() accepts repo_root for subdir invocations (ref #81)
Discovered during Phase B pilot: running `--fix packages/secubox-hub`
yielded zero files because walk() computed `rel` relative to the walk
root (the subdir) while allowlist patterns are repo-relative.

Add optional `repo_root` parameter to walk(); when present, allowlist
matching uses paths relative to it instead of the walk root.
Backwards-compatible (defaults to None, preserving existing tests).
main() passes the discovered repo_root through.

Regression test added: test_walk_subdir_with_repo_root_arg.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:03:32 +02:00
6bd4fb3e11 feat(metrics): Add get_ssl_status() for certificate health
- Reads cert from /etc/letsencrypt/live/{domain}/cert.pem
- Thresholds: >7j ok, 3-7j warn, <3j error, ≤0 expired
- 5 unit tests with mocked certificates

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-12 10:57:31 +02:00
e90a07a578 chore(license): self-host header on tool and tests + initial allowlist (ref #81)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 10:00:09 +02:00
c93cfdb639 feat(license): CLI dispatch with --check/--fix/--list/--diff (ref #81)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 09:57:19 +02:00
72733385eb feat(license): walk() with skip-list and enrollment allowlist (ref #81)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 09:54:35 +02:00
2bd965f481 feat(license): apply() for HTML (doctype) and Markdown (frontmatter), plus TOML/YAML/conf (ref #81)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 09:50:48 +02:00
ada5040693 feat(license): apply() for JS/TS/CSS/C/H with idempotence (ref #81)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 09:49:28 +02:00
6474728dc4 feat(license): apply() for Bash with shebang-aware placement (ref #81)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 09:48:43 +02:00
9b0fce3fac test(license): cover Python shebang and encoding-declaration placement (ref #81)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 09:47:43 +02:00
63e62c1d54 feat(license): apply() for plain Python, with idempotence and foreign-skip (ref #81)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 09:45:34 +02:00
5b4d3e25fb feat(license): detect_existing distinguishes CMSD/foreign/none (ref #81)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 09:41:04 +02:00
70ac535e68 feat(license): render_header for slash/block/html comment styles (ref #81)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 09:38:20 +02:00
f08eb38073 feat(license): render_header for hash-comment languages (ref #81)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 09:34:43 +02:00
c7166597f5 chore(license): scaffold license-headers tool and test module (ref #81)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 09:29:28 +02:00
6f59de25c7 feat(scripts): Add tier-manifest helper for APT staging (ref #80)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 09:16:32 +02:00
0daed3c8d9 fix(opad): use model_json_schema() for Pydantic v2
Replace deprecated .schema() with .model_json_schema()

Refs: CM-WALL-OPAD-2026-05
2026-05-12 09:15:09 +02:00
4529b5c11a docs(spec): CMSD-1.0 license headers across the codebase
Brainstormed design for adding the SPDX-CMSD-1.0 header to every
first-party source file (~2,170 across 6 languages), backed by a
reusable Python tool (scripts/license-headers.py), a CI check, and
a phased per-package rollout. Spec covers scope, header rendering
per language, placement rules, tool architecture, CI integration
with an enrollment allowlist, and verification steps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 09:10:37 +02:00
7b93160478 feat(opad): add Pydantic models and validation tests
Pydantic v1 models equivalent to JSON Schema:
- OPADProfile: Complete 3-prong configuration
- Enums: OPADMode, PolicyAction, LogLevel, Protocol
- Observation, Injection, Policy configs
- OPADEvent and OPADInjectResult for logging

Tests verify JSON Schema and Pydantic equivalence.
All 18 tests passing.

Refs: CM-WALL-OPAD-2026-05

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-12 09:05:22 +02:00