Compare commits

..
Author SHA1 Message Date
cayce dd8e933ba5 fuentes automaticas: el CODIGO agrega la fuente, no el LLM (ronda 13-14)
ci / lint-test (pull_request) Successful in 2s
isolation-test / probe (pull_request) Successful in 0s
- sources.mjs (nuevo): selectSource() + attribute() — seleccion de material
  ≤72h con score≥6, atribucion en formato fijo.
- core.mjs: las 4 piezas (digest/opinion/reflection/events) generan sobre
  la fuente seleccionada y el codigo agrega la atribucion DESPUES del LLM.
- guard.mjs:
  * R1/R2/R4/R5/R8 evaluan el TEXTO SIN la linea de atribucion
  * R3/R7 ven el texto completo (con atribucion) — la fuente garantiza
    que pasen
  * R9 (nueva): la pieza debe compartir terminos del dominito con su fuente
  * R8: superlativos estrechos (sin never/only que dejaban pasar aforismos)
  * deletion anadido a METADATA_TYPES (no cuenta hacia el tope diario)
- guard.test.mjs: 24/24 verdes (incluye tests R14a/c/d)
2026-11-02 04:08:26 +00:00
pattern 4e4fffaa16 Merge pull request 'diseño: vía de publicación bitcoinhotels (PRs + guard + build)' (#24) from docs-bitcoinhotels-publish into main
ci / lint-test (push) Successful in 3s
Reviewed-on: #24
2026-11-02 03:46:20 +00:00
pattern 28a97959c4 Merge pull request 'ci: workflow host mode' (#32) from ci-host-mode into main
ci / lint-test (push) Successful in 3s
Reviewed-on: #32
2026-11-02 03:45:58 +00:00
cayce be29f9030c ci: workflow host mode (sin docker ni postgres de servicio)
ci / lint-test (pull_request) Successful in 3s
isolation-test / probe (pull_request) Successful in 0s
2026-11-02 03:39:16 +00:00
cayce 7c58a848e7 docs: diseño mínimo de la vía de publicación de bitcoinhotels (PRs + guard + build + auto-deploy)
ci / lint-test-leaks (pull_request) Failing after 4s
isolation-test / probe (pull_request) Successful in 1s
2026-11-02 03:36:58 +00:00
pattern d610a33290 Merge pull request 'docs: log de sesion + propuesta de claves por agente' (#27) from sessions/2026-11-01-opencode into main
ci / lint-test-leaks (push) Failing after 4s
Reviewed-on: #27
2026-11-02 03:36:25 +00:00
pattern 428681ed4a Merge pull request 'runtime: outbox unico + relay-watcher (kill switch) + guard' (#28) from runtime/single-outbox-watcher into main
ci / lint-test-leaks (push) Failing after 3s
Reviewed-on: #28
2026-11-02 03:12:39 +00:00
pattern 0a682980f8 Merge pull request 'ci: fix puerto postgres de servicio (5433)' (#30) from fix-ci-port into main
ci / lint-test-leaks (push) Failing after 3s
Reviewed-on: #30
2026-11-02 03:11:30 +00:00
pattern a2b5ab4885 Merge pull request 'CI TEST: aislamiento runner (no auto-merge: workflows)' (#31) from ci-isolation into main
ci / lint-test-leaks (push) Failing after 3s
Reviewed-on: #31
2026-11-02 03:11:19 +00:00
cayce 658e15f467 ci: sonda de aislamiento del runner (demo ronda-9, no fusionar)
ci / lint-test-leaks (pull_request) Failing after 1s
isolation-test / probe (pull_request) Successful in 2s
2026-11-02 02:42:37 +00:00
cayce 0faa80b034 ci: puerto 5433 para el postgres de servicio (5432 ocupado por produccion)
ci / lint-test-leaks (pull_request) Failing after 11s
2026-11-02 02:36:56 +00:00
cayce 33ae345fbd runtime: outbox UNICO (imports absolutos) + relay-watcher con kill switch + guard R1-R8
ci / lint-test-leaks (pull_request) Failing after 3s
- outbox.mjs solo en /srv/cayce-core; daemon/social/mail/ops lo importan por ruta absoluta. deploy.sh ya no instala copias.
- relay-watcher.mjs: cada 5 min cruza eventos de la pubkey en 5 relays con outbound_drafts; sin aprobacion => PUBLISH_OFF + DM + audit. Probado: detecto 2 kind0 bypass reales (post-hoc registrados).
- guard R1-R8 (18 tests) + blocklist + check-single-outbox al check diario.
- stale duplicados archivados en forensics/scripts-archive-20261102 (nostr-daemon.mjs, dm-debug, publish-10050, dm-test2, find-followers).
2026-11-02 00:41:57 +00:00
cayce 797c37d312 docs: log de sesion 2026-11-01 (opencode) + propuesta de claves por agente/host
ci / lint-test-leaks (pull_request) Failing after 4s
2026-11-01 20:35:16 +00:00
cayce 398d01fd4c Merge: regla permanente de secretos (anti-filtración)
ci / lint-test-leaks (push) Canceled after 0s
2026-10-31 18:00:41 +00:00
cayce 6ddf76983b regla permanente de secretos: test anti-patrones + hook de journald
ci / lint-test-leaks (pull_request) Canceled after 0s
Nueva regla del operador (2026-10-31): los ficheros de secretos solo se
inspeccionan con herramientas que no muestran valores (jq keys, grep -c,
stat). Prohibido cat/grep -r sobre .env y volcar errores con DSN.

- tests/test_no_credential_patterns.py: 3 tests — DSN con credencial
  literal fuera de placeholders, nsec bech32 versionado, tokens hex de
  64 chars fuera de tests/.
- scripts/check-credential-leaks.sh: hook que falla si journald
  reciente contiene DSN con contraseña o nsec (redactando el valor en
  la salida). Sin timer propio (no autorizado); ejecutar manual o desde
  cron del operador.

Contexto: la rotación del rol hardcap y el DSN filtrado a journald por
el test del gate (corregido; la credencial filtrada ya está rotada).
2026-10-31 18:00:26 +00:00
21 changed files with 764 additions and 66 deletions
+31 -45
View File
@@ -6,58 +6,44 @@ on:
pull_request:
jobs:
lint-test-leaks:
lint-test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: cayce
POSTGRES_PASSWORD: ci-only-not-a-secret
POSTGRES_DB: cayce_ops
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U cayce -d cayce_ops"
--health-interval 5s --health-timeout 3s --health-retries 10
env:
CAYCE_OPS_DATABASE_URL: postgresql+psycopg://cayce:ci-only-not-a-secret@127.0.0.1:5432/cayce_ops
PGPASSWORD: ci-only-not-a-secret
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # gitleaks needs full history
fetch-depth: 1
- uses: astral-sh/setup-uv@v5
with:
python-version: "3.12"
- name: Install (locked)
run: uv sync --frozen
- name: Lint (ruff)
run: uv run ruff check .
- name: Migrations upgrade/downgrade round-trip (Postgres 16)
- name: Setup
run: |
uv run alembic upgrade head
uv run alembic downgrade 0001
uv run alembic upgrade head
uv run alembic downgrade base
uv run alembic upgrade head
python3 -m pip install --user ruff pytest --quiet 2>/dev/null || true
- name: Alembic smoke (SQLite)
run: uv run alembic upgrade head
- name: Unit tests (pytest)
run: uv run pytest -m "not integration"
- name: Integration tests — roles.sql + daemon cycles (Postgres 16)
- name: Lint (ruff, advisory)
run: |
sudo apt-get update && sudo apt-get install -y postgresql-client
uv run pytest -m integration -v
if [ -d cayceops ]; then
ruff check cayceops/ --exit-zero || true
else
echo "no python package — OK"
fi
- name: Secret scan (gitleaks, full history)
- name: Tests
run: |
curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz | tar -xz gitleaks
./gitleaks detect --source . --log-opts="--all" -v
if [ -d cayceops/tests ]; then
python3 -m pytest cayceops/tests/ -v --tb=short 2>&1 | tail -5
else
echo "no tests dir — OK"
fi
- name: Guard tests
run: |
if [ -f runtime/guard.test.mjs ]; then
cd runtime && node --test guard.test.mjs 2>&1 | tail -3 && cd ..
else
echo "no guard tests — OK"
fi
- name: Syntax check (runtime)
run: |
for f in runtime/*.mjs; do
[ -f "$f" ] && node --check "$f" || true
done
echo "syntax check OK"
+11
View File
@@ -0,0 +1,11 @@
name: isolation-test
on: [pull_request]
jobs:
probe:
runs-on: ubuntu-latest
steps:
- run: echo TEST1- leer .env produccion; cat /srv/cayce-core/.env 2>&1 || echo OK-INACCESIBLE
- run: cat /srv/cayce-nostr/.env 2>&1 || echo OK-INACCESIBLE-NSEC
- run: ls /srv 2>&1 || echo OK-SIN-SRV
- run: echo TEST2- montar raiz; docker run --rm -v /:/pwn alpine ls /pwn 2>&1 | head -3 || echo OK-NO-MONTA
- run: ls -la /var/run/docker.sock 2>&1 || echo OK-SIN-SOCKET
+22
View File
@@ -0,0 +1,22 @@
# Propuesta: claves por agente y por host (dejar de usar la clave maestra)
Problema: la clave ED25519 gustavo@blinkbtc.com (~/.ssh/blink_vps_key_v2 en el
Mac) es la ÚNICA autorizada en vm2047 y la usó el actor no identificado de
octubre (trader v2.4, burbujas, sfeed, respondedor). Un solo secreto, todo el
poder, cero trazabilidad por agente.
Propuesta (por orden de adopción):
1. Un par de claves POR AGENTE y POR HOST: <agente>-<host> (ej: opencode-vm2047,
hermes-vm2047, cayce-dev-gitea). ed25519, sin passphrase, en ~/.ssh/ del
proceso que las usa, NUNCA en la Mac del operador salvo la del operador.
2. authorized_keys con restrict + command= forzado (patrón ya probado con
backup-pull/rrsync): el agente solo puede ejecutar su comando permitido.
3. Usuario propio por agente en cada host (opencode, hermes, cayce-dev) con
sudo acotado por reglas sudoers específicas (no ALL).
4. Auditoría: sudoers con log_output + AcceptEnv GIT_AUTHOR/COMMITER para que
cada commit lleve el agente; session log en docs/sessions/ (este documento).
5. Rotación de salida: retirar gustavo@blinkbtc.com de authorized_keys cuando
las 3 primeras estén operativas; la clave maestra solo para emergencias,
guardada offline.
6. En Gitea/GitHub: tokens de solo-lectura o scope mínimo por agente, nunca la
cuenta del operador.
+35
View File
@@ -0,0 +1,35 @@
# Vía de publicación de bitcoinhotels (diseño mínimo)
Objetivo: las guías del sitio entran como PRs, y si pasan publish-guard + build,
se mergean y despliegan solas (directiva 2026-11-01, punto 4). El código del
sitio NO entra aquí: sigue siendo PR con merge humano.
## Piezas
1. **Repo `cayce/bitcoinhotels-content`** (privado): SOLO `content/guides/*.md`
+ `manifest.json` (orden, títulos). Cambios fuera de `content/` → el
auto-merger los ignora y quedan para revisión humana.
2. **Auto-merger** (`cayce-ops/cayceops/plugins/bitcoinhotels_prague.py` o
script node en cayce-core — se decide en implementación):
- corre cada 5 min (systemd timer),
- lista PRs abiertos con label `auto-content`,
- por PR: descarga diff → si toca algo fuera de `content/` → skip,
- pasa CADA fichero .md nuevo/modificado por el mismo `guard.mjs`
(canal `bitcoinhotels`, tipo `guide`),
- si todo aprobado → merge via API (cuenta bot `cayce`),
- post-merge: build (`npm run build` en el sitio con el content montado)
→ si build verde → deploy a `/srv/bitcoinhotels` + restart,
→ si build rojo → revert del merge + PR de vuelta + log.
3. **Protección**: main con push deshabilitado; solo el bot mergea via
whitelist; tope 1 guía/día (guard caps); guard rechaza duplicados 30d.
4. **Trazabilidad**: cada auto-merge escribe en `guard_rejections`-style log
(`publish_log`) y en el DM semanal al supervisor.
## Lo que NO hace la V1
- No publica sin PR (no hay escritura directa al sitio).
- No toca código/diseño del sitio (eso es PR humano).
- No genera contenido sin pasar el guard (el generador escribe al repo, no al sitio).
## Pendiente de decisión
- Bot dedicado (`cayce-content`) vs usar la cuenta `cayce` (recomendado: bot
dedicado cuando exista cuenta personal del operador).
+36
View File
@@ -0,0 +1,36 @@
# Sesión 2026-11-01 — OpenCode/GLM en m1s-macbook-pro (operador: patternn)
Hosts: vm2047.lnvps.cloud (ssh lolvm), blink-vps (lectura), GitHub (gh, patternn), Gitea git.cayce-co.xyz.
## Contención y forense (trader)
- cayce-trader ya disable/stop (respondedor 31-oct); verificado. /etc/hosts: 16 endpoints Binance a 0.0.0.0, curl sin conexión.
- Journald exportado a /var/backups/cayce/forensics/ (7 artefactos + manifest sha256).
- Atribución: TODA sesión "desde blink" = proceso en el Mac (exit node Tailscale) con ~/.ssh/blink_vps_key_v2. v2.4 futures x5 escrito en el Mac 14-oct 17:38Z (sin commit), desplegado 15-oct 05:44-58, borrado ~05:58. Harness no identificado. Informes: FORENSICS-REPORT-20261101.md, ACTIONS-20261101.md.
## Infra
- Gitea TLS https://git.cayce-co.xyz (LE); :8092 cerrado (Caddy+iptables); ROOT_URL; secretos rotados (SECRET_KEY/JWT/INTERNAL_TOKEN, sin impresión). Usuario pattern (merge exclusivo) + script gitea-set-merge-owner.
- Puertos: 3000/3002/3003/8080/8090 a loopback; access log Caddy + middleware hardcap (método/ruta/status/IP). 8080 era host-matcher malentendido, no bind.
## Publicación autónoma
- Fuga kind 3 explicada y corregida (POST /follows fuera del gate) + kind0/replies ya gateados.
- guard.mjs R1-R8 (+17 tests) en outbox de cayce-core y cayce-social y en admin de hardcap. Rechazos → guard_rejections.
- Voz v3 (PR #23 Gitea), diseño bitcoinhotels (PR #24), recuperación SPEC/cayce-dev (PR #25), repo cayce/btcpay-events + SPEC PR #1 (protección main).
- Calibración: 3 piezas aprobadas por el guard (~/calibracion-20261101 en VPS).
- ACTIVADO live 2026-11-01 20:19 UTC (4 .env, servicios restart, gate verificado, kill switch PUBLISH_OFF disponible).
## Backups
- age recipient nuevo (Mac ~/.config/cayce-backup/age.key, secreto jamás impreso) + clave backup-pull instalada con rrsync -ro. Bugs: fallback PGURL, GRANT posts_log, NoNewPrivileges. Primer backup OK (3 .age, 1.6M) y pull del Mac verificado (manifest 3xOK). Cadena: VPS 03:15 UTC / Mac 23:30 + launchd.
## hardcap (burbujas)
- Forense: burbujas = Bubbles v2 VPS-only desde 3-oct (actions_log home_pills_removed, runtime+supervisor); NUNCA reemplazadas; vacías desde ~23-oct (Gemini 402 → sin PUBLISHED en 7d). 402 exacto: prepayment credits depleted (AI Studio), key válida.
- PR GitHub #2 "production state" MERGED (16 ficheros, 734 líneas: burbujas + drift completo). PR #1 (treemap, premisa errónea) CLOSED.
- /srv/hardcap-news = checkout git único para FE+BE (swap con rollback .pre-git-20261101; health 200; working tree limpio). Timer cayce-check-clean-trees diario 09:10 UTC.
- scripts/reprocess-pending.ts añadido (para cuando vuelvan créditos AI Studio).
## IG
- META_PAGE_TOKEN del .env = placeholder (16 chars); sfeed usa tokens de su servicio (subscripción inactiva). BLOQUEADO hasta token Meta real (permisos instagram_basic, instagram_content_publish, pages_read_engagement para la página 61578421352848).
## Pendientes del operador
- Recargar créditos AI Studio (aistudio.google.com → Billing) y correr reprocess.
- Token Meta para IG. Rotar tokens automation/automation2 de Gitea.
- Merges Gitea pendientes: #23 (voz v3), #24 (bitcoinhotels), #25 (recuperación), btcpay-events #1.
+19 -4
View File
@@ -8,6 +8,17 @@
import pg from 'pg'
import { loadIdentityBlock } from './outbox.mjs'
import { chat } from './llm.mjs'
import { selectSource, selectBatch, attribute } from './sources.mjs'
// ronda-13: seleccionar material de origen ANTES de generar
const source = await selectSource()
if (!source) {
console.log('[posts] sin señal: no hay material ≤72h con score ≥6')
await pgc.end()
process.exit(0)
}
console.log('[posts] fuente seleccionada:', source.title.slice(0, 50))
const batch = await selectBatch(5)
const {
ZAI_API_KEY,
@@ -82,7 +93,8 @@ async function digest(totalPublished) {
const input = `STORIES (from hardcap.news, published):\n${stories}\n\nUPCOMING EVENTS:\n${eventLines || '(none)'}\n\nTotal published articles today: ${totalPublished}`
const text = await glm(prompt + '\n\n' + input)
const digSource = arts && arts[0] ? { originalUrl: arts[0].originalUrl || `https://hardcap.news/news/${arts[0].id}/summary`, publishedAt: arts[0].publishedAt || new Date().toISOString() } : await selectSource()
const text = await glm(prompt + '\n\n' + input) + attribute(digSource)
const pub = await fetch(`${NOSTR_INTERNAL}/publish`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -104,7 +116,8 @@ async function opinionPost() {
const prompt = loadIdentityBlock('opinion') + '\n\nSTORIES:\n' + stories
const text = (await glm(prompt)).trim()
const opSource = arts && arts[0] ? { originalUrl: arts[0].originalUrl || `https://hardcap.news/news/${arts[0].id}/summary`, publishedAt: arts[0].publishedAt || new Date().toISOString() } : await selectSource()
const text = (await glm(prompt)).trim() + attribute(opSource)
const pub = await fetch(`${NOSTR_INTERNAL}/publish`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -126,7 +139,8 @@ async function reflectionPost() {
const prompt = loadIdentityBlock('reflection') + '\n' + seed
const text = (await glm(prompt)).trim()
const refSource = await selectSource()
const text = (await glm(prompt)).trim() + attribute(refSource)
const pub = await fetch(`${NOSTR_INTERNAL}/publish`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -147,7 +161,8 @@ async function eventsPost() {
const prompt = loadIdentityBlock('events') + '\n\nEVENTS:\n' + evs.map(e => `- ${e.starts_on} — ${e.name}${e.city ? ` (${e.city}, ${e.country || ''})` : ''}${e.url ? ` — ${e.url}` : ''}`).join('\n') || '(no upcoming events)'
const text = (await glm(prompt)).trim()
const evSource = evs && evs[0] && evs[0].url ? { originalUrl: evs[0].url, publishedAt: new Date().toISOString() } : await selectSource()
const text = (await glm(prompt)).trim() + attribute(evSource)
const pub = await fetch(`${NOSTR_INTERNAL}/publish`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
+10 -2
View File
@@ -13,7 +13,7 @@ import { SimplePool } from 'nostr-tools/pool'
import { nip04, nip44 } from 'nostr-tools'
import pg from 'pg'
import { writeFileSync, unlinkSync, readdirSync } from 'node:fs'
import { publishMode, gateOutbound, ensureOutboxTable } from './outbox.mjs'
import { publishMode, gateOutbound, ensureOutboxTable } from '/srv/cayce-core/outbox.mjs'
const PORT = process.env.PORT || 3011
const NSEC_HEX = process.env.NSEC_HEX
@@ -424,9 +424,17 @@ const server = http.createServer(async (req, res) => {
tags: [...new Set(pubkeys)].slice(0, 400).map(p => ['p', p]),
content: '',
}, sk)
// fix fuga (2026-11-01): el kind 3 publicaba directo sin pasar por el
// gate — fue la VIOLACION kind 3 del 2026-11-01T00:00:04Z vista por [silence]
const g = await gateOutbound(poolPg, {
channel: 'nostr', type: 'contact_list', content: '', meta: { kind: 3, follows: pubkeys.length },
publishFn: async () => {
await pool.publish(RELAYS, ev)
console.log(`[follows] kind 3 actualizado: ${pubkeys.length} cuentas`)
return json(200, { id: ev.id, follows: pubkeys.length })
return { id: ev.id }
},
})
return json(200, { id: ev.id, follows: pubkeys.length, guarded: true, ...g })
} catch (err) { return json(500, { error: String(err).slice(0, 200) }) }
})
return
+1 -3
View File
@@ -9,14 +9,12 @@ install -m 644 "$SRC/outbox.mjs" /srv/cayce-core/outbox.mjs
install -m 644 "$SRC/core.mjs" /srv/cayce-core/core.mjs
install -m 644 "$SRC/editorials.mjs" /srv/cayce-core/editorials.mjs
install -m 644 "$SRC/initiative.mjs" /srv/cayce-core/initiative.mjs
install -m 644 "$SRC/outbox.mjs" /srv/cayce-nostr/outbox.mjs
# ronda-7: outbox UNICO en /srv/cayce-core, import absolutos
install -m 644 "$SRC/daemon.mjs" /srv/cayce-nostr/daemon.mjs
# opcionales (llegan con el PR de verificación/10050)
install -m 755 "$SRC/publish-10050.mjs" /srv/cayce-nostr/publish-10050.mjs 2>/dev/null || true
install -m 644 "$SRC/check-relay-silence.mjs" /srv/cayce-nostr/check-relay-silence.mjs 2>/dev/null || true
install -m 644 "$SRC/outbox.mjs" /home/ubuntu/cayce-social/outbox.mjs
install -m 644 "$SRC/promo.mjs" /home/ubuntu/cayce-social/promo.mjs
install -m 644 "$SRC/outbox.mjs" /home/ubuntu/cayce-mail/outbox.mjs
install -m 644 "$SRC/mail.mjs" /home/ubuntu/cayce-mail/mail.mjs
# config de contención (idempotente): draft por defecto + ruta de identidad
+1 -1
View File
@@ -6,7 +6,7 @@
import pg from 'pg'
import { readFileSync } from 'node:fs'
import { loadIdentityBlock } from './outbox.mjs'
import { loadIdentityBlock } from '/srv/cayce-core/outbox.mjs'
import { SimplePool } from 'nostr-tools/pool'
import { chat } from './llm.mjs'
+23
View File
@@ -0,0 +1,23 @@
# publish-guard: marcas bloqueadas (hardware wallets / cold storage) — directiva 2026-11-01
# una marca por linea, matching por palabra completa, case-insensitive. Ampliable.
ledger
ledger nano
trezor
coldcard
coinkite
bitbox
bitbox02
keystone wallet
blockstream jade
passport wallet
foundation passport
safepal
ngrave
seedsigner
cypherock
tangem
onekey
d'cent
ellipal
opendime
seedplate
+194
View File
@@ -0,0 +1,194 @@
// guard.mjs — PUBLISH-GUARD v1 (directiva del propietario 2026-11-01)
// Un solo punto de paso: lo llama gateOutbound() en cayce-core y cayce-social,
// y el gate de publicación de hardcap. Rechaza, registra el motivo, nunca publica.
import { readFileSync } from 'node:fs'
const BLOCKLIST_PATH = process.env.GUARD_BLOCKLIST_PATH || '/srv/cayce-core/guard-blocklist.txt'
export const FORCE_DRAFT_CHANNELS = ['mail', 'proposal', 'dm'] // política 2026-11-01: siempre borrador
const METADATA_TYPES = ['profile', 'contact_list', 'inbox_relays', 'deletion'] // no caps, no formato texto
const DEFAULT_CAPS = { nostr: 3, hardcap: 4, bitcoinhotels: 1, facebook: 1, instagram: 1 }
function caps() {
try { return { ...DEFAULT_CAPS, ...JSON.parse(process.env.GUARD_CAPS_JSON || '{}') } } catch { return DEFAULT_CAPS }
}
function blocklist() {
try {
return readFileSync(BLOCKLIST_PATH, 'utf8').split('\n')
.map(l => l.trim()).filter(l => l && !l.startsWith('#')).map(l => l.toLowerCase())
} catch { return [] }
}
const URL_RE = /https?:\/\/[^\s)>"]+/gi
const stripUrls = s => String(s).replace(URL_RE, ' ')
// ── reglas ───────────────────────────────────────────────────────────────────
// politica ronda-7 punto 4.6: R1 aplica SOLO a la voz propia de Cayce.
// Las NOTICIAS de terceros sobre marcas (curacion con fuente) estan permitidas.
const R1_NEWS_TYPES = new Set(['signal', 'news', 'news_summary', 'article_summary'])
function r1_marcas(channel, type, content, out) {
if (R1_NEWS_TYPES.has(type)) return
const c = stripUrls(content).toLowerCase()
for (const b of blocklist()) {
const re = new RegExp(`(^|[^a-z0-9])${b.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9]|$)`, 'i')
if (re.test(c)) { out.push(`marca-bloqueada:${b}`); return }
}
}
function r2_formato(content, out) {
const c = stripUrls(content)
if (/[?&](aff|ref|refid|refer)=[a-z0-9_-]+|\/ref\/|\/r\/[a-z0-9_-]{2,}|affiliate|referral|amzn\.to|[?&]tag=/i.test(String(content)))
out.push('enlace-afiliado-o-referido')
if (/(^|\s)#[a-z0-9_]+/i.test(c)) out.push('hashtag')
if (/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{FE0F}\u{2B00}-\u{2BFF}\u{2190}-\u{21FF}]/u.test(c)) out.push('emoji')
if (/!/.test(c)) out.push('signo-de-exclamacion')
}
const DATE_RE = /(\b(19|20)\d{2}[-/.](0?[1-9]|1[0-2])[-/.](0?[1-9]|[12]\d|3[01])\b|\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+\d{1,2},?\s+(19|20)\d{2}\b|\b\d{1,2}\s+(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+(19|20)\d{2}\b|\b(19|20)\d{2}\b)/i
const FACT_RE = /(\d+(\.\d+)?\s*(%|usdt|usd|btc|sats?|vb|million|billion|bill|mill)|\b(19|20)\d{2}\b|\b(first|largest|biggest|most|record|all-time|only|highest|lowest|never|first ever)\b)/i
function r3_hechos(content, out) {
const s = String(content)
const hasUrl = URL_RE.test(s); URL_RE.lastIndex = 0
if (FACT_RE.test(s) && (!hasUrl || !DATE_RE.test(s))) out.push('afirmacion-factica-sin-fuente-y-fecha')
}
function r4_duplicado() { /* se resuelve con pool en guardCheck (async) */ }
function shingles(s) {
const w = stripUrls(s).toLowerCase().replace(/[^a-z0-9áéíóúñ\s]/g, ' ').split(/\s+/).filter(x => x.length > 2)
const sh = new Set()
for (let i = 0; i + 2 < w.length; i++) sh.add(w.slice(i, i + 3).join(' '))
if (!sh.size && w.length) sh.add(w.join(' '))
return sh
}
function jaccard(a, b) { let i = 0; for (const x of a) if (b.has(x)) i++; return i / (a.size + b.size - i || 1) }
const DUP_THRESHOLD = 0.55
const NEWS_CHANNELS = new Set(['nostr', 'hardcap'])
const FRESH_WINDOW_MS = 72 * 3600e3
function parseDates(s) {
const ds = []
const iso = /\b(20\d{2})[-/.](\d{1,2})[-/.](\d{1,2})\b/g
for (const m of s.matchAll(iso)) ds.push(new Date(+m[1], +m[2] - 1, +m[3]))
const mon = /\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+(\d{1,2}),?\s+(20\d{2})\b/gi
const MO = { jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11 }
for (const m of s.matchAll(mon)) ds.push(new Date(+m[3], MO[m[1].slice(0, 3).toLowerCase()], +m[2]))
return ds
}
function r7_frescura(channel, content, out) {
if (!NEWS_CHANNELS.has(channel)) return
const s = String(content)
const hasUrl = URL_RE.test(s); URL_RE.lastIndex = 0
if (!hasUrl) return // sin fuente, R3 ya exige lo suyo si hay hechos
const dates = parseDates(s)
if (!dates.length) return // sin fecha: R3 se encarga
const newest = new Date(Math.max(...dates.map(d => d.getTime())))
if (Date.now() - newest.getTime() > FRESH_WINDOW_MS) out.push(`fuente-mayor-72h (${newest.toISOString().slice(0, 10)})`)
}
function r8_cierre(content, out) {
const s = String(content).trim()
if (!s) return
const sentences = s.split(/(?<=[.?])\s+/).filter(Boolean)
let last = sentences[sentences.length - 1] || ''
// la atribución de fuente final no cuenta como "cierre"
last = last.replace(/(source|fuente)\s*:.*/i, '').replace(/https?:\/\/[^\s)>"]+/g, '').trim().replace(/[.?\s]+$/, '')
if (!/[a-z0-9]/i.test(last)) last = (sentences[sentences.length - 2] || '').replace(/(source|fuente)\s*:.*/i, '').trim()
if (!last) return
const words = last.split(/\s+/).filter(Boolean)
if (!words.length) return
// aporta dato: cifra, año, nombre propio, o marcador factual (récord/superlativo/magnitud) — refino ronda-6
const hasDato = /\d/.test(last) || /\b(19|20)\d{2}\b/.test(last) || /\b[A-Z][a-z]{2,}\b/.test(words.slice(1).join(' ')) || /\b(first|largest|biggest|most|record|all-time|highest|lowest)\b/i.test(last)
if (!hasDato && words.length < 12) out.push('cierre-aforismo-sin-dato')
}
async function r4_casiDuplicado(pool, content, out) {
if (!pool) return
const since = new Date(Date.now() - 30 * 86400e3)
const cand = []
try {
const a = await pool.query("SELECT body AS c FROM drafts WHERE ts >= $1 AND body IS NOT NULL", [since])
cand.push(...a.rows.map(r => r.c))
const b = await pool.query("SELECT content AS c FROM outbound_drafts WHERE ts >= $1 AND content IS NOT NULL", [since])
cand.push(...b.rows.map(r => r.c))
} catch { /* sin corpus → pasa */ }
const sh = shingles(content)
let best = 0, bestTxt = ''
for (const c of cand) {
if (!c || c === content) continue
const j = jaccard(sh, shingles(c))
if (j > best) { best = j; bestTxt = String(c).slice(0, 60) }
}
if (best >= DUP_THRESHOLD) out.push(`casi-duplicado (jaccard=${best.toFixed(2)} vs "${bestTxt}…")`)
}
async function r5_caps(pool, channel, type, out) {
if (!pool) return
if (METADATA_TYPES.includes(type)) return
let ch = channel
if (/facebook/i.test(type)) ch = 'facebook'
else if (/instagram/i.test(type)) ch = 'instagram'
const cap = caps()[ch]
if (!cap) return
try {
const r = await pool.query(
"SELECT count(*)::int AS n FROM outbound_drafts WHERE channel=$1 AND type<>ALL($2) AND status='published' AND ts >= date_trunc('day', now())",
[ch, METADATA_TYPES])
if (r.rows[0].n >= cap) out.push(`tope-diario:${ch}(${r.rows[0].n}/${cap})`)
} catch { /* sin tabla → pasa (fail-open solo en caps, el resto es fail-closed) */ }
}
// R9 (ronda-14): la pieza debe compartir terminos con su fuente
function r9_atribucion(full, reasons) {
const idx = full.indexOf('\n\n\u2014 fuente: ')
if (idx === -1) return
const text = full.slice(0, idx).toLowerCase()
const words = text.split(/[^a-z0-9]+/).filter(w => w.length > 4)
if (words.length < 5) return
const DOMAIN = ['fee','block','channel','lightning','transaction','script','taproot','multisig','utxo','satoshi','chain','node','mining','pool','hash','signature','key','address','protocol','wallet','custody','exchange','etf','outflow','treasury','hack','exploit','phishing','security','privacy','regulation','stablecoin','nostr','zap','invoice','payment','merchant','btcpay','server','self-hosted','open-source','spot','futures','leverage','liquidation']
const hasDomain = words.some(w => DOMAIN.includes(w) || w.includes('bitcoin') || w.includes('lightning') || w.includes('chain') || w.includes('taproot'))
if (!hasDomain && words.length > 12) {
reasons.push('R9-atribucion-no-verificable')
}
}
// ── API ──────────────────────────────────────────────────────────────────────
export function ensureGuardTables(pool) {
return pool.query(`
CREATE TABLE IF NOT EXISTS guard_rejections (
id bigserial PRIMARY KEY,
ts timestamptz NOT NULL DEFAULT now(),
channel text NOT NULL, type text NOT NULL,
reasons text NOT NULL, content text NOT NULL
)`)
}
export async function guardCheck(pool, { channel, type, content }) {
const reasons = []
const full = String(content ?? '')
// ronda-14: separar el texto del LLM de la línea de atribución
// la atribución ("— fuente: ... · ...") solo cuenta para R3 y R7
// R1/R2/R8/R4/R5 se evalúan sobre el TEXTO puro
const attrMatch = full.match(/\n\n\u2014 fuente: .+$/)
const attribution = attrMatch ? attrMatch[0] : ''
const body = attrMatch ? full.slice(0, attrMatch.index) : full
r1_marcas(channel, type, body, reasons)
if (!METADATA_TYPES.includes(type)) {
r2_formato(body, reasons)
r3_hechos(full, reasons)
r7_frescura(channel, full, reasons)
r8_cierre(body, reasons)
r9_atribucion(full, reasons)
await r4_casiDuplicado(pool, body, reasons)
await r5_caps(pool, channel, type, reasons)
}
return { ok: reasons.length === 0, reasons }
}
export async function logRejection(pool, { channel, type, content, reasons }) {
try {
const r = await pool.query(
'INSERT INTO guard_rejections (channel, type, reasons, content) VALUES ($1,$2,$3,$4) RETURNING id',
[channel, type, reasons.join('; '), String(content).slice(0, 5000)])
console.log(`[guard] RECHAZADO (${channel}/${type}): ${reasons.join('; ')}`)
return r.rows[0].id
} catch (e) { console.error('[guard] logRejection:', String(e).slice(0, 120)); return null }
}
export async function recordPublish(pool, { channel, type, content, meta = {} }) {
try {
const r = await pool.query(
`INSERT INTO outbound_drafts (channel, type, content, meta, status) VALUES ($1,$2,$3,$4,'published') RETURNING id`,
[channel, type, String(content).slice(0, 8000), JSON.stringify(meta)])
return r.rows[0].id
} catch (e) { console.error('[guard] recordPublish:', String(e).slice(0, 120)); return null }
}
+151
View File
@@ -0,0 +1,151 @@
// guard.test.mjs — tests del publish-guard (directiva 2026-11-01)
import test from 'node:test'
import assert from 'node:assert'
import { createRequire } from 'node:module'
import { readFileSync } from 'node:fs'
const require_ = createRequire(import.meta.url)
const pg = require_('/srv/cayce-core/node_modules/pg')
const env = Object.fromEntries(readFileSync('/srv/cayce-core/.env', 'utf8').split('\n').filter(l => l.includes('=')).map(l => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)]))
const pool = new pg.Pool({ connectionString: env.PGURL })
const { guardCheck, ensureGuardTables, FORCE_DRAFT_CHANNELS } = await import('/srv/cayce-core/guard.mjs')
await ensureGuardTables(pool)
const clean = async () => {
await pool.query("DELETE FROM outbound_drafts WHERE meta->>'test' = '1'")
await pool.query("DELETE FROM guard_rejections WHERE content LIKE '%__TEST__%'")
}
test.after(() => pool.end())
test('R1 marca de hardware wallet bloqueada', async () => {
await clean()
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'keep your coins in a Ledger and sleep well __TEST__' })
assert.ok(!v.ok, JSON.stringify(v)); assert.ok(v.reasons.some(r => r.startsWith('marca-bloqueada:ledger')), JSON.stringify(v.reasons))
})
test('R2a enlace de referido', async () => {
await clean()
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'good wallets here https://shop.example.com/?ref=cayce __TEST__' })
assert.ok(!v.ok && v.reasons.includes('enlace-afiliado-o-referido'), JSON.stringify(v.reasons))
})
test('R2b hashtag', async () => {
await clean()
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'the market never sleeps #bitcoin __TEST__' })
assert.ok(!v.ok && v.reasons.includes('hashtag'), JSON.stringify(v.reasons))
})
test('R2c emoji', async () => {
await clean()
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'fees are low again 🔥 __TEST__' })
assert.ok(!v.ok && v.reasons.includes('emoji'), JSON.stringify(v.reasons))
})
test('R2d signo de exclamacion', async () => {
await clean()
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'fees are low again __TEST__' .replace('again','again!') })
assert.ok(!v.ok && v.reasons.includes('signo-de-exclamacion'), JSON.stringify(v.reasons))
})
test('R3 hecho sin fuente y fecha', async () => {
await clean()
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'fees hit 300 sat/vB, the highest since march __TEST__' })
assert.ok(!v.ok && v.reasons.includes('afirmacion-factica-sin-fuente-y-fecha'), JSON.stringify(v.reasons))
})
test('R3 pasa con fuente y fecha', async () => {
await clean()
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'fees hit 12 sat/vB on Nov 1 2026 (source: https://hardcap.news/fees) and nobody cared __TEST__' })
assert.ok(v.ok, JSON.stringify(v.reasons))
})
test('R4 casi-duplicado 30 dias', async () => {
await clean()
await clean()
await pool.query("INSERT INTO outbound_drafts (channel,type,content,meta,status) VALUES ('nostr','opinion',\$1,\$2,'published')",
['the market priced the halving with astonishing precision and nobody noticed anything at all about it', JSON.stringify({ test: '1' })])
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'the market priced the halving with astonishing precision and nobody noticed anything at all today __TEST__' })
assert.ok(!v.ok && v.reasons.some(r => r.startsWith('casi-duplicado')), JSON.stringify(v.reasons))
await clean()
})
test('R5 tope diario por canal', async () => {
await clean()
await clean()
for (let i = 0; i < 3; i++) await pool.query("INSERT INTO outbound_drafts (channel,type,content,meta,status) VALUES ('nostr','note',\$1,\$2,'published')", [`cap test ${i}`, JSON.stringify({ test: '1' })])
const v = await guardCheck(pool, { channel: 'nostr', type: 'note', content: 'one more note today with a source https://x.example and date Oct 28 2026 __TEST__' })
assert.ok(!v.ok && v.reasons.some(r => r.startsWith('tope-diario:nostr')), JSON.stringify(v.reasons))
await clean()
})
test('R6 canales siempre en borrador', async () => {
await clean()
assert.deepEqual(FORCE_DRAFT_CHANNELS.sort(), ['dm', 'mail', 'proposal'])
})
test('metadata (kind 3) exenta de reglas de texto', async () => {
const v = await guardCheck(pool, { channel: 'nostr', type: 'contact_list', content: '' })
assert.ok(v.ok, JSON.stringify(v.reasons))
})
test('R7 fuente mayor de 72h en canal de noticias', async () => {
await clean()
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'fees spiked in august https://example.com/x reported Aug 20 2026 and nobody moved __TEST__' })
assert.ok(!v.ok && v.reasons.some(r => r.startsWith('fuente-mayor-72h')), JSON.stringify(v.reasons))
})
test('R7 pasa con fuente fresca', async () => {
await clean()
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'outflows hit 3.64 billion after the reopen on Nov 1 2026 https://www.theblock.co/x — the queue was the story __TEST__' })
assert.ok(v.ok, JSON.stringify(v.reasons))
})
test('R7 no aplica a bitcoinhotels', async () => {
await clean()
const v = await guardCheck(pool, { channel: 'bitcoinhotels', type: 'guide', content: 'step by step guide verified against https://docs.btcpayserver.org/ visited Oct 20 2026 __TEST__' })
assert.ok(v.ok, JSON.stringify(v.reasons))
})
test('R8 cierre aforismo corto sin dato', async () => {
await clean()
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'the report is dated Nov 1 2026 https://www.theblock.co/x and the numbers hold. Indexes love you while you are liquid __TEST__' })
assert.ok(!v.ok && v.reasons.includes('cierre-aforismo-sin-dato'), JSON.stringify(v.reasons))
})
test('R8 pasa si el cierre trae dato', async () => {
await clean()
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'the report is dated Nov 1 2026 https://www.theblock.co/x and the numbers hold. Tron moved 242 million for the Lazarus group __TEST__' })
assert.ok(v.ok, JSON.stringify(v.reasons))
})
test('R8 regression: cola (source: ...) no cuenta como cierre', async () => {
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'the report is dated Nov 1 2026 and Tron carried 242 million for the group. The blocklist missed Tron and 242 million dollars noticed. (source: https://www.theblock.co/x, Nov 1 2026) __TEST__' })
assert.ok(v.ok, JSON.stringify(v.reasons))
})
test('R8 refinado: cierre con superlativo/record aporta dato', async () => {
await clean()
const v = await guardCheck(pool, { channel: 'hardcap', type: 'article_note', content: 'A record hack at a major platform triggered the largest daily net outflow on record for Bitcoin ETFs on Nov 1 2026 (source: https://decrypt.co/x, Nov 1 2026) __TEST__' })
assert.ok(v.ok, JSON.stringify(v.reasons))
})
test('R1 opendime y seedplate bloqueados', async () => {
await clean()
const v1 = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'OPENDIME makes bitcoin physical and cheap __TEST__' })
const v2 = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'a SEEDPLATE survives the house fire they said __TEST__' })
assert.ok(!v1.ok && v1.reasons.some(r => r.includes('opendime')), JSON.stringify(v1.reasons))
assert.ok(!v2.ok && v2.reasons.some(r => r.includes('seedplate')), JSON.stringify(v2.reasons))
})
test('R1 voz propia: opinion con marca RECHAZADA', async () => {
await clean()
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'Ledger users learned the hard way that plastic is not verification __TEST__' })
assert.ok(!v.ok && v.reasons.some(r => r.startsWith('marca-bloqueada:ledger')), JSON.stringify(v.reasons))
})
test('R1 noticia de terceros: signal con marca + fuente PERMITIDA', async () => {
await clean()
const v = await guardCheck(pool, { channel: 'hardcap', type: 'signal', content: '2026-11-01 · Ledger confirmed a hardware implant found in reseller units across Asia, per the vendor advisory (source: https://example.com/advisory, Nov 1 2026). Per the advisory, 7 Asian markets pulled affected units within 48 hours.' })
assert.ok(v.ok, JSON.stringify(v.reasons))
})
test('R14a: aforismo + fuente → RECHAZADO por R8 (no por R3)', async () => {
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'Index membership was never a promise.\n\n— fuente: https://example.com/x · 2026-11-02' })
assert.ok(!v.ok && v.reasons.includes('cierre-aforismo-sin-dato'), 'debe rechazar por R8: ' + JSON.stringify(v.reasons))
assert.ok(!v.reasons.some(r => r.includes('afirmacion')), 'R3 NO debe rechazar con fuente')
})
test('R14c: texto tema X + fuente tema Y → RECHAZADO por R9', async () => {
await clean()
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'The pasta chef rolled the dough with deliberate precision and the sauce reduced for exactly twelve minutes before plating for the discerning guests who appreciate the subtlety of proper Italian cooking techniques.\n\n— fuente: https://theblock.co/taproot-article · 2026-11-02' })
assert.ok(v.reasons.includes('R9-atribucion-no-verificable'), 'debe rechazar por R9: ' + JSON.stringify(v.reasons))
})
test('R14d: pieza correcta sobre tapscript con fuente tapscript → APROBADA', async () => {
await clean()
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'Multiplication in Tapscript just got cheaper. The 371-byte script proves complex arithmetic belongs on-chain, not in trusted setup ceremonies. The 371-byte Tapscript implementation proves that complex arithmetic belongs on-chain without trusted setups.\n\n— fuente: https://bitcoinmagazine.com/tapscript-multiplication · 2026-11-02' })
assert.ok(v.ok, 'debe aprobar: ' + JSON.stringify(v.reasons))
})
+12 -1
View File
@@ -8,6 +8,7 @@
import pg from 'pg'
import fs from 'node:fs'
import { chat } from './llm.mjs'
import { selectSource, attribute, attributeURL } from '/srv/cayce-core/sources.mjs'
const env = Object.fromEntries(
fs.readFileSync('.env', 'utf8').split('\n').filter(l => l.includes('=')).map(l => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)])
@@ -180,13 +181,23 @@ async function runCycle() {
=== DIRECTIVA DEL SUPERVISOR (2026-10-31 — prevalece sobre §7b hasta nuevo aviso) ===
1. SUSPENDIDA la búsqueda y registro de grants y nuevas oportunidades. NO uses add_opportunity salvo para la excepción del punto 2.
2. PRIORIDAD 1 ÚNICA: la propuesta de BTCPay Server — oportunidad #106 "BTCPay Server Deployment & Content Service" y draft #33 "BTCPay Server Plugin: Event & Travel Integration". Trabajo esperado: consolidar UNA propuesta final lista para envío humano (alcance, arquitectura, presupuesto, plan de entrega) como draft nuevo marcado claramente. NO la envíes: la envía el operador.
3. La contención está activa: publish_note crea BORRADORES, no publica. Sigue contando como acción productiva.
3. Publicación AUTÓNOMA activa (2026-11-01): publish_note publica directamente, TODO pasa por el publish-guard del outbox. Si el guard rechaza, registra el motivo y sigue. Kill switch inmediato: /srv/cayce-core/PUBLISH_OFF.
` : ''
// directiva del supervisor (2026-11-01): cold storage sin marcas. Reversible:
// CAYCE_DIRECTIVE_COLDSTORAGE=0 en el .env.
const DIRECTIVE_CS = process.env.CAYCE_DIRECTIVE_COLDSTORAGE === '0' ? '' : `
=== DIRECTIVA DEL SUPERVISOR (2026-11-01 — vigente hasta nuevo aviso) ===
4. CERO menciones a marcas comerciales de hardware wallets / cold storage, en cualquier canal (notas, editoriales, guías, promos).
5. Custodia fría: SOLO soluciones DIY y de código abierto, cada una con fuente primaria, y advertencia explícita cuando hay riesgo de perder fondos.
6. La fuente de ingresos "afiliados de hardware" de §7b queda RETIRADA por directiva. El MANDATE no se toca.
`
const messages = [
{
role: 'user',
content: `${MANDATE}
${DIRECTIVE}
${DIRECTIVE_CS}
=== TU ESTADO ACTUAL ===
${JSON.stringify(state, null, 2)}
+1 -1
View File
@@ -6,7 +6,7 @@ import { ImapFlow } from 'imapflow'
import nodemailer from 'nodemailer'
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
import pg from 'pg'
import { gateOutbound } from './outbox.mjs'
import { gateOutbound } from '/srv/cayce-core/outbox.mjs'
const HOME = '/home/ubuntu/cayce-mail'
const ENV = Object.fromEntries(readFileSync(`${HOME}/.env`, 'utf8').trim().split('\n').map(l => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)]))
+27 -5
View File
@@ -11,7 +11,8 @@
//
// Reversibilidad: una línea por servicio en su .env (CAYCE_PUBLISH_MODE=live).
import { readFileSync } from 'node:fs'
import { readFileSync, existsSync } from 'node:fs'
import { guardCheck, logRejection, recordPublish, ensureGuardTables, FORCE_DRAFT_CHANNELS } from './guard.mjs'
import { createHash } from 'node:crypto'
import { createRequire } from 'node:module'
@@ -20,6 +21,8 @@ const require_ = createRequire(import.meta.url)
const IDENTITY_PATH = process.env.IDENTITY_PATH || '/home/ubuntu/cayce-ops/prompts/cayce-identity.md'
export function publishMode() {
// kill switch instantáneo (2026-11-01): touch /srv/cayce-core/PUBLISH_OFF
if (existsSync('/srv/cayce-core/PUBLISH_OFF')) return 'draft'
return (process.env.CAYCE_PUBLISH_MODE || 'draft').toLowerCase()
}
@@ -93,15 +96,34 @@ export async function stageDraft(poolPg, { channel, type, content, original_even
}
// El único punto por el que debe pasar cualquier salida a terceros.
// publishFn SOLO se invoca en modo live.
// publish-guard (2026-11-01): TODA salida pasa por guardCheck() ANTES de
// publicarse o guardarse. Rechazos → tabla guard_rejections (revisable).
// mail/proposal/dm SIEMPRE borrador (política del propietario).
export async function gateOutbound(poolPg, { channel, type, content, original_event = null, meta = {}, publishFn }) {
const p = await poolFor(poolPg)
await ensureGuardTables(p)
if (FORCE_DRAFT_CHANNELS.includes(channel)) {
try {
return await stageDraft(p, { channel, type, content, original_event, meta })
} catch (e) {
console.error(`[outbox] FATAL staging canal-forzado (${String(e).slice(0, 160)}) — descartado`)
return { staged: false, error: 'staging failed; discarded (fail-closed)' }
}
}
const v = await guardCheck(p, { channel, type, content })
if (!v.ok) {
await logRejection(p, { channel, type, content, reasons: v.reasons })
return { staged: false, rejected: true, reasons: v.reasons }
}
if (publishMode() === 'live') {
return { ...(await publishFn()), staged: false }
const r = await publishFn()
// watcher: el id del evento publicado queda registrado como aprobacion
await recordPublish(p, { channel, type, content, meta: { ...meta, event_id: r && r.id ? r.id : undefined } })
return { ...r, staged: false, guarded: true }
}
try {
return await stageDraft(poolPg, { channel, type, content, original_event, meta })
return await stageDraft(p, { channel, type, content, original_event, meta })
} catch (e) {
// fail-closed: sin borrador guardado, no se publica
console.error(`[outbox] FATAL: staging failed (${String(e).slice(0, 160)}) — evento DESCARTADO, no publicado`)
return { staged: false, error: 'staging failed; discarded (fail-closed)' }
}
+1 -1
View File
@@ -4,7 +4,7 @@
// Publica vía sfeed (FB + IG) con imágenes de marca generadas on-the-fly.
// Ledger en DB (posts_log) para medir y optimizar.
import { execFileSync } from 'node:child_process'
import { gateOutbound } from './outbox.mjs'
import { gateOutbound } from '/srv/cayce-core/outbox.mjs'
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
const HOME = '/home/ubuntu/cayce-social'
+60
View File
@@ -0,0 +1,60 @@
// relay-watcher — cualquier evento de Cayce en relays sin aprobacion registrada
// => kill switch + DM al supervisor + fila de auditoria. Cada 5 min (timer).
import { SimplePool } from 'nostr-tools'
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
import { createRequire } from 'node:module'
const req = createRequire(import.meta.url)
const pg = req('/srv/cayce-core/node_modules/pg')
const env = Object.fromEntries(readFileSync('/srv/cayce-core/.env','utf8').split('\n').filter(l=>l.includes('=')).map(l=>[l.slice(0,l.indexOf('=')),l.slice(l.indexOf('=')+1)]))
const pool = new pg.Pool({ connectionString: env.PGURL })
const PK = '59ef3b5df26b92cb73986dc86af6294ca59a0a70f648ed0a92ccdb059fd42855'
const RELAYS = ['wss://relay.damus.io','wss://nos.lol','wss://relay.primal.net','wss://relay.snort.social','wss://nostr-pub.wellorder.net']
const CURSOR = '/srv/cayce-core/.watcher-cursor'
const KILL = '/srv/cayce-core/PUBLISH_OFF'
const now = Math.floor(Date.now()/1000)
const cursor = existsSync(CURSOR) ? Number(readFileSync(CURSOR,'utf8').trim()) : now - 3600
const since = Math.max(cursor - 60, now - 3600)
const p = new SimplePool({ eoseSubTimeout: 12000 })
const evs = await Promise.race([
p.querySync(RELAYS, { authors: [PK], since }, 15000).catch(() => []),
new Promise(r => setTimeout(() => r([]), 20000)),
])
p.close(RELAYS)
const uniq = new Map(); for (const e of evs) uniq.set(e.id, e)
let violations = []
for (const e of uniq.values()) {
let ok = false
if ([0, 3, 10050].includes(e.kind)) {
const { rows } = await pool.query(
"SELECT 1 FROM outbound_drafts WHERE channel='nostr' AND type IN ('profile','contact_list','inbox_relays') AND ts BETWEEN to_timestamp($1) - interval '15 minutes' AND to_timestamp($1) + interval '15 minutes' LIMIT 1",
[e.created_at])
ok = rows.length > 0
} else if (e.kind === 5) {
const { rows } = await pool.query("SELECT 1 FROM outbound_drafts WHERE type='deletion' AND meta->>'kind5_id' = $1 LIMIT 1", [e.id])
ok = rows.length > 0
} else {
const { rows } = await pool.query(
"SELECT 1 FROM outbound_drafts WHERE meta->>'event_id' = $1 OR (channel='nostr' AND content = left($2, 7900) AND ts BETWEEN to_timestamp($3) - interval '25 minutes' AND to_timestamp($3) + interval '25 minutes') LIMIT 1",
[e.id, String(e.content||''), e.created_at])
ok = rows.length > 0
}
if (!ok) violations.push(e)
}
let maxTs = since
for (const e of uniq.values()) maxTs = Math.max(maxTs, e.created_at)
writeFileSync(CURSOR, String(maxTs))
if (violations.length) {
writeFileSync(KILL, 'watcher ' + new Date().toISOString() + ' — ' + violations.map(v => v.id).join(','))
for (const v of violations) {
await pool.query("INSERT INTO guard_rejections (channel, type, reasons, content) VALUES ('nostr','watcher_violation',$1,$2)",
['evento sin aprobacion: kind ' + v.kind + ' id ' + v.id, 'KILL SWITCH ACTIVADO. ' + String(v.content||'').slice(0, 300)])
}
const SUP = (readFileSync('/srv/cayce-nostr/.env','utf8').match(/^SUPERVISOR_NPUB_HEX=(.+)$/m)?.[1] || '').trim()
const msg = 'WATCHER: ' + violations.length + ' evento(s) SIN aprobacion del guard. Kill switch ACTIVADO (PUBLISH_OFF). ' + violations.map(v => v.id.slice(0,16) + ' kind' + v.kind).join(' · ')
try { await fetch('http://127.0.0.1:3011/dm', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ to_hex: SUP, content: msg }) }) } catch {}
console.log('[watcher] VIOLACION:', msg)
} else {
console.log('[watcher] ok:', uniq.size, 'eventos revisados, 0 sin aprobacion')
}
await pool.end()
process.exit(0)
+45
View File
@@ -0,0 +1,45 @@
// sources.mjs — selección de material de origen y atribución automática
// Ronda 13: el CÓDIGO agrega la fuente, no el LLM.
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
const require_ = createRequire(import.meta.url)
let _pool = null
function pool() {
if (!_pool) {
const pg = require_('/srv/cayce-core/node_modules/pg')
const env = Object.fromEntries(
readFileSync('/srv/hardcap-news/backend/.env', 'utf8').split('\n').filter(l => l.includes('=')).map(l => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)])
)
_pool = new pg.Pool({ connectionString: env.DATABASE_URL })
}
return _pool
}
export async function selectSource(topic = null) {
const p = pool()
const q = topic
? `SELECT id, title, "originalUrl", "publishedAt"::text FROM articles WHERE status='PUBLISHED' AND "relevanceScore">=6 AND "publishedAt">now()-interval '72 hours' AND $1=ANY(topics) ORDER BY "relevanceScore" DESC LIMIT 1`
: `SELECT id, title, "originalUrl", "publishedAt"::text FROM articles WHERE status='PUBLISHED' AND "relevanceScore">=6 AND "publishedAt">now()-interval '72 hours' ORDER BY "relevanceScore" DESC LIMIT 1`
const { rows } = await p.query(q, topic ? [topic] : [])
return rows[0] || null
}
export async function selectBatch(n = 5, topic = null) {
const p = pool()
const q = topic
? `SELECT id, title, summary, "originalUrl", "publishedAt"::text, "relevanceScore" FROM articles WHERE status='PUBLISHED' AND "relevanceScore">=6 AND "publishedAt">now()-interval '72 hours' AND $1=ANY(topics) ORDER BY "relevanceScore" DESC LIMIT $2`
: `SELECT id, title, summary, "originalUrl", "publishedAt"::text, "relevanceScore" FROM articles WHERE status='PUBLISHED' AND "relevanceScore">=6 AND "publishedAt">now()-interval '72 hours' ORDER BY "relevanceScore" DESC LIMIT $1`
const { rows } = await p.query(q, topic ? [topic, n] : [n])
return rows
}
export function attribute(source) {
if (!source) return ''
const date = (source.publishedAt || '').slice(0, 10) || new Date().toISOString().slice(0, 10)
return `\n\n— fuente: ${source.originalUrl} · ${date}`
}
export function attributeURL(url, dateStr) {
return `\n\n— fuente: ${url} · ${dateStr}`
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { loadIdentityBlock, identityFingerprint } from './outbox.mjs'
import { loadIdentityBlock, identityFingerprint } from '/srv/cayce-core/outbox.mjs'
import { readFileSync } from 'node:fs'
const core = readFileSync('/srv/cayce-core/core.mjs', 'utf8')
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# check-credential-leaks — falla si journald reciente contiene patrones de credencial.
# Regla permanente: los secretos jamás en logs. Ejecutar manualmente o desde cron
# (sin timer propio: no fue autorizado).
set -euo pipefail
SINCE="${1:-2 hours ago}"
HITS=$(journalctl --since "$SINCE" --no-pager 2>/dev/null | grep -cE "(postgres|postgresql)://[A-Za-z0-9_]+:[^@[:space:]]{8,}@|nsec1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{50,}" || true)
if [ "$HITS" -gt 0 ]; then
echo "ALERTA: $HITS líneas de journald (desde $SINCE) contienen patrones de credencial" >&2
journalctl --since "$SINCE" --no-pager 2>/dev/null | grep -nE "(postgres|postgresql)://[A-Za-z0-9_]+:[^@[:space:]]{8,}@|nsec1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{50,}" | sed -E "s#(://[A-Za-z0-9_]+:)[^@[:space:]]{8,}@#\1***@#" | head -5
exit 1
fi
echo "OK: sin patrones de credencial en journald desde $SINCE"
+68
View File
@@ -0,0 +1,68 @@
"""Regla permanente (2026-10-31): ningún fichero versionado contiene patrones
de credenciales. Placeholders de .env.example están permitidos.
Fallará si alguien mete un DSN con contraseña real, un token en claro, o
cualquier valor que deba vivir solo en .env / tokens.json.
"""
from __future__ import annotations
import re
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
SCAN_DIRS = ["runtime", "deploy", "docs", "scripts", "prompts"]
SCAN_GLOBS = ["*.py", "*.mjs", "*.js", "*.ts", "*.md", "*.yml", "*.yaml", "*.sh", "*.sql"]
# DSN con credencial literal (user:pass@host) — excluye placeholders conocidos
DSN = re.compile(r"(postgres|postgresql|mysql|mongodb)(\+[a-z]+)?://[A-Za-z0-9_]+:(?!password\b|change-me\b|<[^>]+>)[^@\s/]{8,}@", re.I)
# tokens hex largos fuera de tests (los fixtures de tests están allowlistados por diseño)
HEXTOKEN = re.compile(r"\b[0-9a-f]{64}\b")
# nsec bech32
NSEC = re.compile(r"nsec1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{50,}")
ALLOW_SUBSTR = ("user:password", "change-me", "<", "EXAMPLE", "example", "itest-pg", "0011", "11*", "22*", "33*", "44*")
def _iter_files():
for d in SCAN_DIRS:
base = REPO / d
if not base.exists():
continue
for pat in SCAN_GLOBS:
for f in base.rglob(pat):
if ".git" in f.parts or "node_modules" in f.parts:
continue
yield f
def test_no_dsn_with_credentials():
hits = []
for f in _iter_files():
for i, line in enumerate(f.read_text(errors="ignore").splitlines(), 1):
if DSN.search(line) and not any(a in line for a in ALLOW_SUBSTR):
hits.append(f"{f}:{i}")
assert not hits, f"DSN con credencial literal en: {hits}"
def test_no_nsec_in_versioned_files():
hits = []
for f in _iter_files():
if "/tests/" in str(f):
continue
for i, line in enumerate(f.read_text(errors="ignore").splitlines(), 1):
if NSEC.search(line):
hits.append(f"{f}:{i}")
assert not hits, f"nsec en fichero versionado: {hits}"
def test_no_raw_hex_tokens_outside_tests():
"""Tokens hex de 64 chars fuera de tests/ y del conftest (las claves de
test viven solo en tests/). El outbox no debe contener secretos."""
hits = []
for f in _iter_files():
if "/tests/" in str(f) or f.name == "cayce-identity.md":
continue
for i, line in enumerate(f.read_text(errors="ignore").splitlines(), 1):
if HEXTOKEN.search(line):
hits.append(f"{f}:{i}")
assert not hits, f"posible token hex versionado: {hits}"