Files
cayce-ops/cayceops/cli.py
T
cayce 90e6cfcc19 fix: address external audit — criticals 1-5 + mediums
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.
2026-10-19 23:06:29 +00:00

196 lines
5.7 KiB
Python

"""Cayce Ops CLI (Typer).
Component sub-apps are imported lazily so the skeleton CLI works before every
component module lands, and so one broken component can't take down the CLI.
"""
from __future__ import annotations
import typer
from cayceops import __version__
app = typer.Typer(name="cayceops", help="Cayce autonomous operations stack", no_args_is_help=True)
db_app = typer.Typer(name="db", help="Schema lifecycle")
app.add_typer(db_app)
@db_app.command("init")
def db_init() -> None:
"""Create all tables from ORM metadata (dev/test)."""
from cayceops.db import init_db
init_db()
typer.echo("schema created (metadata.create_all)")
@db_app.command("upgrade")
def db_upgrade() -> None:
"""Run Alembic migrations to head (production path)."""
from cayceops.db import upgrade_db
upgrade_db()
typer.echo("migrations applied")
@app.command()
def version() -> None:
"""Print the version."""
typer.echo(f"cayce-ops {__version__}")
def _load_component(name: str):
"""Import a component module by name (lazy)."""
import importlib
return importlib.import_module(f"cayceops.components.{name}")
# --- scanner -----------------------------------------------------------------
scanner_app = typer.Typer(name="scanner", help="Opportunity discovery (read-only)")
app.add_typer(scanner_app)
@scanner_app.command("run")
def scanner_run() -> None:
"""Scan configured sources and file proposals. Never executes anything."""
raise typer.Exit(_load_component("scanner").run())
# --- executor ----------------------------------------------------------------
executor_app = typer.Typer(name="executor", help="Execute approved proposals")
app.add_typer(executor_app)
@executor_app.command("run")
def executor_run() -> None:
"""Process approved proposals through registered action plugins."""
raise typer.Exit(_load_component("executor").run())
@executor_app.command("approve")
def executor_approve(
proposal_id: int,
actor: str = typer.Option(..., help="Human approver id"),
signature: str = typer.Option(
None, help="BIP-340 sig (required when operator pubkey is set)"
),
) -> None:
"""Approve a proposal (human action, audited)."""
raise typer.Exit(
_load_component("executor").approve(proposal_id, actor, signature=signature)
)
@executor_app.command("reject")
def executor_reject(proposal_id: int, actor: str = typer.Option(...)) -> None:
"""Reject a proposal (human action, audited)."""
raise typer.Exit(_load_component("executor").reject(proposal_id, actor))
# --- maild -------------------------------------------------------------------
maild_app = typer.Typer(name="maild", help="Merchant mail negotiation")
app.add_typer(maild_app)
@maild_app.command("poll")
def maild_poll() -> None:
"""Poll IMAP, classify inbound mail, draft replies."""
raise typer.Exit(_load_component("maild").poll())
@maild_app.command("approve-draft")
def maild_approve(msg_id: int, actor: str = typer.Option(...)) -> None:
"""Approve a drafted reply so SMTP may send it."""
raise typer.Exit(_load_component("maild").approve_draft(msg_id, actor))
@maild_app.command("send-approved")
def maild_send() -> None:
"""Send all approved drafts via SMTP (per-message commit)."""
raise typer.Exit(_load_component("maild").send_approved())
@maild_app.command("compose")
def maild_compose(
proposal_id: int,
to_addr: str = typer.Option(...),
subject: str = typer.Option(...),
body: str = typer.Option(...),
) -> None:
"""Create the FIRST-CONTACT outbound draft for a proposal (drafted)."""
raise typer.Exit(
_load_component("maild").compose(proposal_id, to_addr, subject, body)
)
@maild_app.command("run")
def maild_run() -> None:
"""One daemon cycle: poll inbound, then send approved drafts."""
comp = _load_component("maild")
code = comp.poll()
if code == 0:
code = comp.send_approved()
raise typer.Exit(code)
# --- publisher ---------------------------------------------------------------
publisher_app = typer.Typer(name="publisher", help="Themed content calendar")
app.add_typer(publisher_app)
@publisher_app.command("plan")
def publisher_plan() -> None:
"""Fill the calendar with drafts for the coming days (per theme rotation)."""
raise typer.Exit(_load_component("publisher").plan())
@publisher_app.command("image")
def publisher_image(item_id: int) -> None:
"""Render the brand image for a content item."""
raise typer.Exit(_load_component("publisher").make_image(item_id))
@publisher_app.command("approve")
def publisher_approve(item_id: int, actor: str = typer.Option(...)) -> None:
"""Approve a draft content item."""
raise typer.Exit(_load_component("publisher").approve(item_id, actor))
@publisher_app.command("publish-due")
def publisher_publish() -> None:
"""Publish approved content items that are due."""
raise typer.Exit(_load_component("publisher").publish_due())
@publisher_app.command("run")
def publisher_run() -> None:
"""One daemon cycle: publish due approved items (plan is cron'd separately)."""
raise typer.Exit(_load_component("publisher").publish_due())
# --- monitor -----------------------------------------------------------------
monitor_app = typer.Typer(name="monitor", help="Static status page")
app.add_typer(monitor_app)
@monitor_app.command("build")
def monitor_build() -> None:
"""Render the static status page (no sensitive data)."""
raise typer.Exit(_load_component("monitor").build())
@monitor_app.command("run")
def monitor_run() -> None:
"""One daemon cycle: build the status page."""
raise typer.Exit(_load_component("monitor").build())
def main() -> None: # pragma: no cover
app()
if __name__ == "__main__": # pragma: no cover
main()