- adminAuth: exige Authorization: Bearer <HARDCAP_ADMIN_TOKEN> (o
X-Admin-Token). Comparación timingSafeEqual sobre sha256 de ambos
valores (sin leak de longitud ni timing). Fail-closed: sin token
configurado, TODAS las rutas admin responden 503.
- 401s ahora se loguean con método, ruta e IP (primera trazabilidad de
acceso que tiene el admin API desde que existe).
- token generado (32 bytes hex) y almacenado SOLO en
/srv/hardcap-backend/.env — nunca impreso ni versionado.
- test-admin-auth.mjs: 6 tests (sin token, incorrecto, vacío, correcto,
header alternativo, misma-longitud-distinto) — todos pasan en la VPS.
- desplegado en src + dist; servicio reiniciado y activo.
Nota de contexto: el admin API carecía de auth desde su creación
(index.ts: \x27TODO: add auth middleware before production\x27) y estuvo
alcanzable sin credenciales en :3001 hasta la contención de hoy.
- hardcap PATCH /admin/articles/:id → PUBLISHED now stages into
outbound_drafts (channel hardcap) instead of publishing, when
HARDCAP_PUBLISH_MODE=draft (default). Fail-closed. Verified E2E:
PATCH on a QUEUED article returned {staged:true, draft_id:7} and the
article stayed QUEUED.
- bitcoinhotels.org: audited — frontend only (Next.js), no publishing
path; nothing to contain.
- outbox.mjs: self-sufficient pool (createRequire from
/srv/cayce-core/node_modules/pg — hosts may lack pg; found by the
promo simulation which now works end-to-end).
- runtime/hardcap/admin.ts: versioned copy of the patched route
(hardcap-backend itself is not a git repo).
- deploy.sh: hardcap env wiring (HARDCAP_PUBLISH_MODE, IDENTITY_PATH,
CAYCE_PGURL appended server-side).
🔴 UNRELATED CRITICAL FINDING (needs operator decision, not patched
without approval): hardcap admin API has NO authentication and binds
*:3001 — reachable from the public internet (HTTP 200 from outside,
including DELETE /admin/articles/all). Recommend: auth middleware +
bind 127.0.0.1 + firewall, plus rotation of credentials that appeared
in journald (hardcap DB role DSN leaked via a psql error line during
testing — fixed in the same patch, but journald retains it).
- publish-10050.mjs: publishes Cayce kind 10050 (NIP-65 message-relays/
inbox) listing the 6 relays where the listener actually reads DMs.
EXECUTED once with operator authorization: event id
188ef397824e8f29b5e1bea661ae65b1f8532ee97617747b9fbde57c6e8dfce9,
accepted 6/6 relays.
- initiative.mjs: the cycle DM to the supervisor now includes a summary
of outbound_drafts from the last 24h (id, type, first 15 words) so the
operator reviews what the agent WANTED to publish.
- initiative.mjs: supervisor directive (CAYCE_DIRECTIVE_BTCPAY=1,
reversible) injected into the cycle prompt: grants/opportunity search
SUSPENDED, single priority = consolidate the BTCPay Server proposal
(opportunity #106 / draft #33) ready for HUMAN sending. MANDATE text
untouched (only the supervisor can change it).
- initiative.mjs: add_opportunity now rejects duplicates (the agent had
re-registered OpenSats ×10, HRF ×6).
- opportunity #106 status -> priority-1.
- check-relay-silence.mjs: queries the relays for Cayce-signed events
since a timestamp; allows only kind 10050 and supervisor DMs; exits 1
on any violation. Verified: 0 events, 0 violations since deployment.
- deploy.sh: sudo for the daemon restart, optional files tolerated.
Gate: make check green (96 tests).
Problem: Cayce publishes straight to relays with no approval gate —
cayce-initiative publish_note, core.mjs digest/opinion/reflection/events,
mention auto-replies, social promo (Facebook/Instagram via sfeed), and
mail — while the operator believed approvals existed (they never did;
ADR-0005 Telegram queue was never implemented).
Mechanism (outbox.mjs, deployed to all runtimes):
- CAYCE_PUBLISH_MODE=draft (default): gateOutbound() stages EVERY outbound
artifact into outbound_drafts (entity DB) with identity_path +
identity_hash (sha256 of prompts/cayce-identity.md) before anything
else happens. Fail-closed: if staging fails, the event is discarded,
never published.
- CAYCE_PUBLISH_MODE=live: original behavior, unchanged (reversible with
one line per service .env).
- Chokepoint: the nostr daemon /publish endpoint gates ALL callers
(initiative publish_note, core.mjs ×4, banner kind 0); the daemon own
mention-reply path gates too, storing original_event for context;
startup profile publish gates as well. DM-to-supervisor (/dm) is
internal and stays live.
Identity (point 8b):
- prompts/cayce-identity.md: the 5 hardcoded voice blocks extracted
VERBATIM (verified byte-identical by runtime test) with block markers.
- core.mjs / editorials.mjs now load their voice blocks from this file
(loadIdentityBlock); identity hash recorded on every draft.
- runtime/ holds the versioned copies of the previously-unversioned
runtime scripts + deploy.sh installer.
No timers enabled or disabled. No production data modified.
The previous redaction (${PG_URL%%@*@}) never matched when the URL has a
single @, so the full DSN (user:password@host) went to the log/journal.
Now: scheme + ***:***@ + hostport/db only.
Found during the point-3 E2E test; the leaked value in that transcript
was the already-public test DB credential (itest probe DB).
/usr/local/bin/cayce-check-backup-freshness (from
deploy/backup/check-freshness.sh): exits 1 if the newest
/var/backups/cayce/YYYY-MM-DD/ dir has mtime older than 26h, or if no
backups exist while the age recipient IS configured (backups expected).
Info-exit-0 when backups have never been configured. Not attached to a
timer (only cayce-backup.timer was authorized); documented for manual
or cron use.
Verified: fresh dir => OK 0h exit 0; dir with 2-day mtime => ALERT 48h
exit 1; no-dir + recipient => ALERT; no-dir + no-recipient => INFO.
RESTORE.md documents the exact from-zero recovery on a fresh VPS:
download+verify (sha256 over encrypted artifacts, no private key needed
on the pull side), decrypt on the operator Mac, Postgres pg_restore,
Gitea unzip+migrate, Caddy/systemd/.env config restore, repo re-clone,
backup pipeline reinstall with the SAME age recipient, and a
post-restore checklist.
E2E test executed on the VPS (no production touched — cayce-ops has no
production DB; a probe DB was created and dropped):
- manifest.sha256: 3/3 artifacts OK (verified WITHOUT any private key)
- age decrypt roundtrip with a throwaway keypair (destroyed afterwards)
- pg_restore TOC valid; full restore into a scratch DB: 5000/5000 rows
- gitea zip valid, config tar valid (34 entries)
- all test material removed: probe DB dropped, throwaway key deleted,
recipient file removed, test backup deleted (was encrypted to the
destroyed throwaway key)
System user backup-pull created on the VPS (no sudo, locked password).
authorized_keys restricted with restrict + forced command
rrsync -ro /var/backups/cayce. Verified with a since-deleted throwaway
key: no shell, no arbitrary commands, no sftp/scp, no reads outside
/var/backups/cayce, no writes; the positive rsync pull works.
authorized_keys is left EMPTY — the operator adds their real key with
the exact line documented in deploy/backup/backup-pull-setup.md.
deploy/backup/make-backup.sh (installed as /usr/local/bin/cayce-make-backup):
- pg_dump custom-format of the cayce-ops DB (URL from env or .env,
SQLAlchemy driver qualifier stripped, credentials never logged)
- gitea dump (repos + sqlite + attachments), live; consistency notes in RESTORE.md
- config tar: Caddyfile(+d), gitea app.ini + internal_token, cayce-* units, .env
- EACH artifact encrypted with age BEFORE leaving the tmpdir; recipient
read from /etc/cayce-backup/age-recipient.pub (public key only). The
private age key never touches this server.
- plaintext exists ONLY in a mktemp dir removed by an EXIT trap; a partial
day directory is removed on failure; any step failure => exit != 0 =>
journald entry
- manifest.sha256 over the encrypted artifacts (pull side verifies
integrity WITHOUT the age private key)
- destination /var/backups/cayce/YYYY-MM-DD/ (750 root:backup-pull, 640)
- retention: 7 daily dirs, pruned by mtime
systemd: cayce-backup.service (oneshot, hardened: ProtectSystem=strict,
NoNewPrivileges, ProtectHome=read-only) + cayce-backup.timer (daily
03:15 UTC, Persistent). Timer ENABLED on the VPS per operator instruction;
runs fail loudly (exit 1, clear journald message) until the age recipient
is provided — that is the designed not-configured state.
The previous text asserted participation in an external jurisdiction
pilot without a verifiable source. Replaced with what is actually true
of this codebase: every external-effect action requires cryptographically
signed human approval, with a key that never resides on Cayce
infrastructure (D16).
Merchants write in Spanish; negations like nunca, lamentablemente or
no podemos must veto approval exactly like their English counterparts.
Previously an all-Spanish rejection mail could fall through to OTHER,
and a mixed mail with the approval word in English approved despite a
Spanish negation.
- negation list adds: nunca, jamas, tampoco, ningun/ninguna, nadie,
nada, no podemos, no es posible, no aceptamos, rechaz*, descartad*,
lamentablemente, no ha sido aprobad*, no procede
- new Spanish REJECTION rule (rechaz*, solicitud denegada, no cumple
los requisitos) and Spanish APPROVAL rule (aprob*, aceptad*,
bienvenid*, encantados de trabajar)
- veto rule unchanged: any negation anywhere -> needs_review
- tests: 15 new (12 veto cases incl. 2 mixed-language, pure-Spanish
approval still approves, Spanish rejection classifies rejection)
Gate: ruff 0 · 96 unit + 1 PG integration · gitleaks clean.
- verify_audit_chain(): rows with entry_hash IS NULL are only tolerated
BEFORE the first hashed row (documented legacy prefix). A NULL hash
AFTER the chain started is tampering and fails verification at that
row id — previously any NULL row anywhere was silently skipped, so an
attacker with UPDATE could erase intermediate evidence undetected.
- migration 0004: audit_log becomes append-only. SQLite: BEFORE UPDATE/
DELETE triggers RAISE(ABORT). Postgres: BEFORE triggers RAISE EXCEPTION.
Closes the hole for the table owner and direct SQL connections.
- monitor.verify_chain_against_anchors(): compares the chain tail against
anchors published to Nostr. Explicit statuses: no_anchors (never
anchored / notes missing on relays), local_only (no fetcher), ok
(relay note attests the tail or the last local anchor), mismatch.
- tests: NULL-before-genesis ok, NULL-in-middle fails at that id, NULL
late fails, UPDATE rejected, DELETE rejected, INSERT still allowed,
no-anchors explicit, local-only explicit, mismatch detected, match ok.
Gate: ruff 0 · 81 unit + 1 PG integration · alembic 0001→0004 on PG ·
gitleaks clean · make check exit 0.
Background: CAYCE_OPS_OPERATOR_PUBKEY was set to Cayce nostr pubkey —
Cayce was approving itself. Signature was technically valid but semantically
null: human approval signed by the agent is not human approval.
- signing.py: validate_no_self_approval() rejects the operator pubkey when
it matches (a) the pubkey derived from any private key in the config,
or (b) any pubkey in CAYCE_OPS_FORBIDDEN_OPERATOR_PUBKEYS deny-list.
- executor/maild/publisher _signing() now calls the validation, so every
daemon and every CLI approve-* path refuses to operate (D16).
- tests: 6 new (agent-key refusal, human key passes, empty passes,
explicit denylist, end-to-end daemon refusal, derivation roundtrip).
- docs/DECISIONS.md: D16.
The operator signature is now produced OUTSIDE the VPS. CAYCE_OPS_OPERATOR_PUBKEY
must stay empty until the external signer exists — daemons refuse, which is
correct. The Cayce nsec itself is NOT touched (still the agent identity for
publishing and anchoring).
- README: the stack belongs to Cayce, an autonomous entity operating under
the Próspera ZEDE agentic-companies jurisdiction pilot; the experiment is
managed by Cayce itself
- tests: approver actor renamed to the generic label operator
- zero occurrences of personal names in the tree (verified with grep)
One commit per point (audit-1..audit-7). Gate at merge: ruff 0 ·
65 unit tests + 1 PG integration test · alembic 0001→0003 with PG
round-trip · gitleaks clean.
- cayce-ops-plan.service/.timer (daily): the @ template cannot express
publisher plan, so it gets its own unit
- DECISIONS: D13 no non-human approvals; D14 signatures bind content +
deployment; D15 negation veto classifier
ci.yml gains a postgres:16 service; alembic upgrade/downgrade/upgrade
round-trip runs on PG, unit tests run -m "not integration", and the
roles.sql integration suite runs with a postgresql-client install.
(Gate executed locally against Postgres 18.6 — see PR description.)
Note: the send-path refactor (send_approved CLI -> _send_approved with
factory, send_failed audits in their own transaction, DaemonRun record,
non-zero exit on any failure class) landed within the audit-1 commit —
maild.py was staged whole. This empty commit marks the point in the
review trail; the behavior is covered by test_send_failures_audited_and_
exit_code.
- audit() writes are serialized: pg_advisory_xact_lock on Postgres;
in-process RLock held until session commit/rollback on SQLite
(test: 4 concurrent writers x 25 entries -> chain verifies, 100 rows)
- legacy rows (entry_hash NULL, pre-0002) are skipped: the chain begins
GENESIS at the first hashed row; migrated DBs verify clean by design
- monitor anchor: publishes the last chain hash as a signed NIP-01 note
once per day, logs it in the audit log (self-verified before publish)
tests: concurrent writers; legacy-genesis verification; anchor signs,
logs once per day.
- one least-privilege role per daemon; cayce_approver is the ONLY writer of
status=APPROVED / operator_signature / approved columns
- per-table BEFORE triggers reject approval writes from daemon roles
- no literal passwords: refuses to run without the six -v password vars
(psql exits 3 via missing-table SELECT guard)
- idempotent: revokes + drops trigger/function before recreating
- tests/integration/test_pg_roles.py: applies roles.sql via psql -v, runs a
full cycle per daemon role (propose -> approve -> execute; compose ->
approve -> send; plan -> approve -> publish; anchor) and asserts the
denials; runs the alembic upgrade->downgrade->upgrade round-trip on
Postgres (point 6). Skips cleanly without a reachable PG.
Replaces per-pattern negation lists: if ANY negation token (not, no, never,
cannot, cannot, will not, has not, is not, no longer, unable, decline,
regret, unfortunately) appears anywhere in the mail, an approval
classification downgrades to needs_review. Approval requires a mail with
zero negations. The LLM prompt braces stay escaped (round-1 fix retained).
tests: the six mandated sentences all classify non-approval.
Critical 1 (approval forgery): audit_log is now hash-chained
(prev_hash/entry_hash, verify_audit_chain); executor approve REQUIRES a
BIP-340 signature over sha256(cayce-ops:approve:<id>) when
CAYCE_OPS_OPERATOR_PUBKEY is set; deploy/roles.sql ships least-privilege
per-daemon DB roles.
Critical 2 (threading dead): sends create real OUTBOUND rows (own
Message-ID, In-Reply-To -> inbound); inbound message_id never overwritten;
maild compose adds the first-contact path; _match_proposal falls back to
inbound chains.
Critical 3 (duplicate sends): per-message transactions in send path —
SMTP failure on message N no longer resurrects 1..N-1; failures audited
(send_failed) and the row stays queued.
Critical 4 (classifier): negated approvals (cannot accept / not approved /
not accepting) classify REJECTION at 0.95 before the bare-keyword rule;
LLM prompt braces escaped (.format no longer KeyError into silent fallback).
Critical 5 (systemd): every daemon has a run subcommand
(maild run = poll+send, publisher run = publish-due, monitor run = build);
deploy README documents the separate daily publisher plan timer.
Mediums: approved_hash binds drafts/captions to the approved text (edited
text refuses to send/publish until re-approved); risk_score renamed
match_score (migration 0002) and auto-approve orders HIGHEST relevance
first; compose binds postgres to 127.0.0.1; IMAP marks \\Seen only after
commit (fetch returns (num, raw), mark post-commit); disabled channels are
terminal (ContentStatus.SKIPPED) and publish failures burn a bounded
attempt budget before abandonment; make hooks installs the pre-push gate.
tests: 12 new — 56 total, all green; ruff 0 findings; gitleaks clean.
All 32 findings resolved: regex literals kept single-line with noqa
(pattern IS the reviewed artifact), HTML rows string-concatenated for
line length, walrus tuple in BIP-340 verify split into named generator,
unused pytest import dropped. No behavior changes; 44/44 tests pass.
Full-gate discipline restored: ruff exit 0.
Component map + data flow, every external call (where/what/trigger),
human-gate vs AUTONOMOUS_MODE matrix, env+permissions per component,
pinned deps with purpose, known risks (8, each with mitigation),
test+stack instructions. Audited-commit hash lands in the issue.
- self-contained HTML (no external assets/JS), everything html-escaped
- daemon health from daemon_runs (last status + last run per daemon)
- proposal queue counts by state; human-gate counters (mail drafts awaiting
approval, content draft/approved)
- audit tail: actor/action/result only — no detail payloads, no addresses,
no subjects, no content bodies (tests assert the negatives)
- DaemonRun bookkeeping like every other daemon
tests: 3 new (sections render, leak-negatives, XSS escape) — 44 total