feat(realism): canary cultivator on the realism contract

Stage 7 — final stage of the realism migration. Canary plants are
now scheduled by the same realism planner that handles inert content,
keeping the orchestrator as the single decision point and avoiding
duplicate diurnal / persona / rate-limit logic in the canary
subsystem.

New surface:

- decnet/canary/cultivator.py: cultivate(plan, repo) builds a
  CanaryContext, calls the right generator (canary_aws_creds ->
  aws_creds, canary_mysql_dump -> mysql_dump, …), persists the
  canary_tokens row before plant so the canary worker can attribute
  callbacks even on plant-time previews. Resolves canary placements
  to credible operator paths (~/.aws/credentials, ~/.ssh/id_rsa,
  /var/backups/db_backup.sql).
- realism/planner.py adds 8 canary content_classes uniformly weighted
  inside a 3% probability gate. Hard-capped: each tick at most one
  canary; create branch falls through to inert otherwise.
- scheduler.pick_file dispatches canary content_class to the
  cultivator; FileAction grows an optional content_bytes field so
  binary canary artifacts (DOCX/PDF/honeydoc) survive the wire
  intact instead of being utf-8 round-tripped.
- SSHDriver._run_file uses content_bytes when set, falls back to
  encoding the str content otherwise.

Stealth (per feedback_stealth.md): cultivator does not introduce
any DECNET literal; the underlying generators are already
stealth-clean and the test suite asserts the contract holds.

Tests cover round-tripping every canary class through the cultivator,
verifying placement-path conventions, persona-login normalisation
("John Smith" -> /home/johnsmith/.aws/credentials), and the
no-DECNET-leak invariant.
This commit is contained in:
2026-04-27 16:47:59 -04:00
parent 4e436da569
commit a07fb3fe08
6 changed files with 392 additions and 10 deletions

View File

@@ -198,15 +198,19 @@ class SSHDriver(ActivityDriver):
return result
async def _run_file(self, action: FileAction) -> ActivityResult:
# FileAction's content is a string; the realism path uses
# bytes-typed plant_file so binary blobs (DOCX/PDF, future
# canary artifacts) survive the wire. Encode-once here.
# FileAction.content_bytes wins when set — canary artifacts
# (DOCX/PDF/honeydoc binaries) need their bytes preserved
# exactly. Falls back to utf-8 encoding the str content for
# the inert-realism path.
# mtime carries through from the realism planner so the file
# doesn't stamp at wall-clock-now (the realism failure today).
body = action.content_bytes
if body is None:
body = action.content.encode("utf-8")
return await self.plant_file(
action.dst_name,
action.path,
action.content.encode("utf-8"),
body,
mode=0o644,
mtime=action.mtime,
)

View File

@@ -56,6 +56,11 @@ class FileAction:
content_class: str = ContentClass.NOTE.value
mtime: Optional[datetime] = None
description: str = "file:create"
# Canary artifacts (DOCX/PDF/honeydoc binaries) carry their bytes
# here so re-encoding ``content`` from utf-8 doesn't mangle them.
# When set, the SSH driver uses these bytes directly and ignores
# ``content``.
content_bytes: Optional[bytes] = None
@dataclass(frozen=True)
@@ -183,6 +188,38 @@ async def pick_file(
mtime=plan.mtime,
)
# Canary branch — the cultivator builds the bytes, picks the
# placement path, and persists the canary_tokens row. We map
# the resulting CanaryArtifact to a FileAction so the SSH
# driver's plant_file path is reused unchanged.
if plan.content_class.is_canary():
try:
from decnet.canary import cultivator as _cultivator
artifact = await _cultivator.cultivate(plan, repo)
except Exception: # noqa: BLE001
# Cultivation failed (no http_base/dns_zone configured,
# generator raised, repo write failed). Fall through to
# an inert file plant so the tick isn't wasted.
return FileAction(
dst_uuid=plan.decky_uuid,
dst_name=plan.decky_name,
path=plan.target_path or f"/tmp/.cache-{secrets.token_hex(3)}", # nosec B108
content=plan.body_hint or "",
persona=plan.persona,
content_class=plan.content_class.value,
mtime=plan.mtime,
)
return FileAction(
dst_uuid=plan.decky_uuid,
dst_name=plan.decky_name,
path=artifact.path,
content="", # ignored when content_bytes is set
content_bytes=artifact.content,
persona=plan.persona,
content_class=plan.content_class.value,
mtime=plan.mtime,
)
# Create branch. If LLM is wired, optionally swap body_hint for
# an LLM-authored body. Always keep the deterministic body_hint
# as the fallback the function call returns when LLM