istota AGENTS.md

istota AGENTS.md is an instructions file for Codex, OpenCode from istota-project/istota. It costs 34,211 tokens per session, scanned C, original, MIT.

A project instruction file for Istota, a Claude Code-powered assistant bot with a Nextcloud Talk interface. It describes the project structure, operating rules, configuration, messaging surfaces, web interface, and other modules.

In plain words
What is it for?
It is for guiding changes to Istota's agent loop, task execution, scheduling, messaging, web chat, web interface, briefings, health features, and configuration.
Why use it?
It gives a coding agent the project-specific context needed to work consistently in the Istota codebase. Without it, the agent may miss how the bot's parts fit together.

Instructions file for CodexOpenCode

Written for Codex and OpenCode: the file is AGENTS.md. Also seen: reads .claude/ paths; positional $N argument; mentions Claude Code.

Install

Getting it into your agent

One page per mod, every tool's command on it. A separate URL per tool would split the same page into five that compete with each other.

agentmods
npx agentmods add instructions/istota-project/istota/agents-md
Clone the repo
git clone --depth 1 https://github.com/istota-project/istota

Made for: Codex, OpenCode.

Wrote this? Show the measurements

A badge with what this costs and how it scanned, read live from this page, so it follows the numbers instead of freezing them. Markdown for a README, HTML for a documentation site or a project page.

agentmods badge for istota AGENTS.md

README.md
[![agentmods](https://agentmods.dev/badge/instructions/istota-project/istota/agents-md.svg)](https://agentmods.dev/instructions/istota-project/istota/agents-md)
Your own site
<a href="https://agentmods.dev/instructions/istota-project/istota/agents-md"><img src="https://agentmods.dev/badge/instructions/istota-project/istota/agents-md.svg" alt="Measured on agentmods" height="20"></a>
Per session 34,211 This file is loaded in full into every session.
When invoked 34,211 The same file — it is already loaded in full.
Security scan C 2 findings. Scan, not verified.
Origin original No closer match found in the catalogue.
Token cost

What it costs to keep this loaded

Counted locally with the o200k_base tokenizer, which is exact for GPT models; Claude uses its own tokenizer and its counts differ. Treat this as one consistent yardstick across the catalogue rather than a bill. Prices are per million input tokens.

ModelPer sessionOnce invoked
Fable 5.1 $0.34211 $0.34211
Opus 5 $0.17105 $0.17105
Sonnet 5 $0.06842 $0.06842
Haiku 4.5 $0.03421 $0.03421

Measured today against content hash 63e0146209d7, method: parsed. Prices are Anthropic first-party input rates as of 2026-09-05, from the pricing page.

Security

Grade C, and why

istota AGENTS.md scanned grade C with 2 findings against 26 rules in 11 categories — prompt injection, anti-refusal, data exfiltration, privilege escalation, supply chain, agent snooping, system-prompt leakage, SSRF and excessive agency — measured today.

A static scan of the body, not an audit. Every finding is printed with the line that produced it so you can judge whether it matters here. A mod is markdown that instructs an agent; that is exactly why what it instructs is worth reading.

Recursive force deletehighDestructive command

rm -rf with a variable or a broad path is one typo away from removing the wrong tree.

├── notification_resolvers/ # One module per notification source: its `SOURCE` id, its `dedup_key` spelling, the `write` / `resolve_for_*` producer helpers and the resolver, all in one file so the three cannot drift — th

Makes network callslowCapability

Not a fault in itself. Listed so you know the mod talks to something, and to what.

├── task_env.py # `build_task_runtime`: one task's runtime environment, assembled in one place — the model's env, the three-way credential split behind it (clean / proxy-only / credential), the `SkillProxy` and `NetworkP
AGENTS.md · 266 lines

How it starts

The opening of the file, as written. The whole thing — 266 lines — stays where its author put it; the contents beside it link to each section on GitHub.

Istota - Claude Code Bot

Claude Code-powered assistant bot with Nextcloud Talk interface.

Production server: your-server (SSH, installed at /srv/app/istota).

For module-specific internals, see .claude/rules/:

  • brain.md — Brain protocol + ClaudeCodeBrain + NativeBrain (in-process agent loop), plus per-room brain selection
  • executor.mdexecute_task(), env mapping, prompt assembly, security
  • scheduler.md — daemon loop, worker pool, DB tables, deferred ops
  • config.md — every dataclass field + TOML mapping
  • skills.md — skill metadata, single-axis selection (eager vs menu), per-skill user overlays, CLI modules
  • transport.md — Transport seam over messaging surfaces (Talk + email; Matrix / web chat designed-for), plus the room model: the four questions a surface answers about rooms, and where each is answered
  • web-chat.md — web chat surface: rooms, composer, drafts, send durability, message replies, room-event stream
  • web-ui.md — web UI backend: route/endpoint map, admin Logs + Configuration panes, settings/module-services split (the design language itself lives in web/AGENTS.md)
  • briefings.md — block/source briefings, shared blocks, titles, HTML email
  • health.md — health module schema, documents store, OCR/explainer, surfaces
  • location.md — GPS pings, place detection, visits, Overland/Garmin ingest
  • feeds.md — native RSS/Atom/Tumblr/Are.na poller, per-user SQLite, image dedupe
  • money.md — quarterly tax estimator, portfolio snapshots, classifications
  • memory.md — USER.md/CHANNEL.md, per-skill overlays, knowledge graph, playbooks, sleep cycle
  • deployment.md — Ansible role, Docker stack, the Nextcloud rclone mount
  • testbed.mdtestbed/: the two compose shapes, profiles, the Service protocol and the four rules binding one, session-scoped reset and the watermark, what each shape concedes, the prompt goldens

Project Structure

src/istota/
├── brain/                # Pluggable model invocation (Brain protocol)
├── memory/               # search.py, knowledge_graph.py, sleep_cycle.py, curation/
├── skills/               # 36 self-contained skills (skill.md + optional CLI)
├── cli.py                # Local CLI (task, resource, briefing, secret, user, run, serve, setup, …)
├── serve.py              # Combined local launcher (`istota serve`): scheduler thread + uvicorn in one process
├── setup_wizard.py       # Interactive first-run installer (`istota setup`) for the local single-user shape
├── updater.py            # `istota update` — self-update for the standalone install: reads install.json provenance, git fetch/reset the recorded checkout (stable channel = latest release tag, main channel = branch tip), `uv tool install --reinstall`, fresh-code migrations. Refuses on the server shape
├── config.py             # TOML loader + DB-overlay (user_profiles / user_resources / briefing_configs)
├── config_mapper.py      # The mechanical half of `load_config`: walks `dataclasses.fields` over the `Config` tree and maps a parsed TOML document onto it, instead of restating the schema as 1159 hand-written `if "x" in data` lines. That second copy carried three defect classes, each of them a thing a duplicated schema does rather than a thing anybody decided — **a field the loader never read** (eleven settings declared on a dataclass, written by the Ansible template or the Docker render, documented in `config.example.toml`, and simply missing a line here, so the operator set the value and the daemon ran the hardcoded default; `security.sandbox_ro_paths` was found that way, and `scheduler.max_subtasks_per_task`, a cap on prompt-injection blast radius, was found the same way while writing this module), **two defaults for one field** (the dataclass said `True` and the loader's `.get(key, default)` said `False`, so a bare `[sleep_cycle]` header with no keys under it switched the nightly memory extraction off — here the dataclass default is the only default), and **a typo that did nothing** (the old loader could not tell an unknown key from a misspelled one, so `[breifings]` and `max_subtask_dept` were discarded in silence; the walk knows the whole schema and reports what it did not recognise, still only as a warning, since refusing to boot on an unknown key turns a forward-compatible config into a hard failure on rollback). What it deliberately does **not** own is judgement: anything that validates, migrates a legacy key, reads two fields to decide one, or builds something that is not a plain field stays hand-written in `config.py` and is registered here as a hook against its dotted key
├── context.py            # Hybrid conversation context selection
├── db.py                 # SQLite operations (framework tables)
├── db_health.py          # `PRAGMA quick_check` + self-healing `REINDEX` backstop for local SQLite DBs (module DBs no longer on the FUSE mount)
├── db_relocate.py        # One-time migrator: per-user module DBs from the mount → local disk (`Config.module_db_path`), flipping DELETE→WAL. `python -m istota.db_relocate`
├── db_backup.py          # Timed online-backup snapshot of local DBs (framework + per-user modules) to dated dirs in a backups dir on the mount (off-host durability). Retention (keep N newest), row-count collapse guard (quarantine an emptied DB as `*.suspect`), 0700/0600 perms
├── db_restore.py         # Restore a cold snapshot back to local disk (newest good, or `--date`); row-count sanity refuses an empty snapshot without `--force`. `python -m istota.db_restore [--all|--framework|--user U --module M] [--date …] [--list] [--dry-run]`
├── executor.py           # Per-task orchestration (memory/skills/sandbox)
├── executor_stream.py    # `TaskStreamAdapter`: the brain's widened `StreamEvent` stream adapted to `TaskEvent`s, one instance per task. The coalescing buffers for streamed answer text and for reasoning (separate, because on a stream surface the two render to different places — the answer streams prominent, thinking folds into the activity chip), the narration gate and the delta-vs-whole-turn dedupe. `on_event` is what goes on `BrainRequest.on_progress`; `flush_thinking` / `settle_at_tool_boundary` / `finish` are what `execute_task` calls at the reroute boundary and at the end of the run. Stateful and single-threaded — events arrive serialized, so nothing takes a lock. `task_is_stream_surface` is imported inside `__init__` rather than at module scope, and that is a **test** constraint rather than a cycle: `transport/registry` imports only `._types`, so a module-scope import would resolve cleanly, but two suites patch the name through `istota.transport.registry` and a name bound at import time would make both patches inert while the tests still passed
├── task_env.py           # `build_task_runtime`: one task's runtime environment, assembled in one place — the model's env, the three-way credential split behind it (clean / proxy-only / credential), the `SkillProxy` and `NetworkProxy` objects, and the sandbox's read-only bind list, returned as a `TaskRuntime`. Lifted out of `execute_task`, where it was ~320 lines sharing one namespace of locals with prompt composition and memory recall. **Both context managers are constructed here and entered by the caller**, which is a lifetime this module cannot own: they have to be live across the primary brain call, the reroute and the fallback call, so the `ExitStack` stays in `execute_task` wrapping `run_with_failover`. A `with` here would close them before the brain ran. **Four orderings are load-bearing** and are stated in the module docstring because each is invisible at its own line: `proxy_base_env` is snapshotted *before* `ISTOTA_SANDBOXED` is set, since the proxy runs skills host-side where the marker would be a lie; `_split_credential_env` runs proxy-only first and the credential split operates on the residue, so a var declared both ways goes to the proxy bucket and never becomes a credential; and `HOOK_PATH_PREPEND_KEY` is skipped by the hook merge and consumed *after* the snapshot, because a task-temp directory on the host-side CLIs' PATH is a code-execution path no bind can contain; and both ISSUE-410 reachability top-ups run *after* the two credential splits, so a name a manifest declared `sensitive` stays where the split put it rather than being read back out of the daemon's environment. **Then the ISSUE-410 reachability top-ups, and they go to two different places on a deliberate axis.** `build_clean_env` is an allowlist carrying none of these names, so a host-side skill CLI on a deployment behind an egress proxy, a private CA or a gateway had no way to know any of it existed; `code_review` was the loud case, since `build_model_cli_env` runs *inside* that subprocess and its top-up loop was therefore reading `proxy_base_env` rather than the daemon's environment. The axis is **not** "secret or not" — it is whether handing the value to a CLI that never asked for it can do harm. A **trust store path cannot**, since it only ever adds a CA, so `skill_cli_tls_env` puts `SKILL_CLI_TLS_VARS` in the shared snapshot for every host-side CLI (`CURL_CA_BUNDLE` joins the four, matching `forge_cli._CARRY_EXACT`, which already answered this question that way — it is the name `curl` reads and the only one `requests` falls back to). A **proxy URL redirects traffic**, which is the half the obvious reading misses: it is not inert for a CLI with no use for it, it captures that CLI's requests *including the ones aimed at this deployment's own services* — `browse` calls `BROWSER_API_URL`, loopback by default, over httpx with `trust_env=True`, which honours `HTTP_PROXY` and does not exempt loopback, and `tests/support/env_isolation.py` records exactly that failure already happening here to nineteen loopback stub servers. It can also carry basic-auth userinfo, and `skill_proxy` returns a skill CLI's stderr to the model verbatim. So `skill_model_reachability` puts `SKILL_MODEL_REACHABILITY_VARS` (the proxy triple plus `ANTHROPIC_BASE_URL`, which no other skill reads and whose path can carry a key) through ISSUE-409's **per-skill** map instead, and `_PROXY_LOOKUP_BLOCKED` gains them so the union `credential-fetch` reads from does not. What is deliberately left undone, and is not what ISSUE-410 filed: `feeds` fetches external URLs and would want an egress proxy, which means first exempting each deployment's own internal endpoints — a design question about per-skill network policy. Both halves are a **gap-filler, never an override** — `credential_env` is passed in beside `proxy_base_env` so a name a manifest declared `sensitive`, which `_split_credential_env` just *moved* into the per-skill map, is not read back out of the daemon's environment; note the cost of that being right, since `derive_credential_set` is index-wide: one manifest declaring `SSL_CERT_FILE` sensitive takes it from every other skill at once. No collision with a sandboxed task's CONNECT bridge: that value is `exec env HTTPS_PROXY=…` applied by `sandbox_plan` inside the namespace at exec time, so it enters no env dict, and a host-side CLI is outside the namespace where the daemon's own proxy is the right answer. All of it is inside the `skill_proxy_enabled` branch, since `proxy_base_env` exists only there. **And two predicates that read alike and are not**: the network-proxy gate asks `config.security.sandbox_enabled` (what the operator asked for) while the sandboxed marker asks `effective_sandboxing` (what they got). A reader will want to unify them; they differ on a Linux host with no bwrap, which is a shipped shape. Imports `executor` at **function** scope — `executor` imports this module at module scope, and both ways is a cycle — which also keeps `monkeypatch.setattr(executor, "_bwrap_available", …)` working, since the names resolve at call time
├── events.py             # Task event streaming: TaskEvent, EventWriter, EventSubscriber + task_events log
├── consumers/            # Event consumers: TalkEventSubscriber, LogChannelSubscriber, PushNotificationSubscriber
├── scheduler.py          # Task processor, briefings, all polling
├── transport/            # Transport seam: IncomingMessage, registry, ingest, routing (delivery plan), talk/ email/ ntfy/ istota_file/ repl/ web/ (6 Transports, push + stream)
├── surfaces.py           # What role each surface plays in the room model, in one table. A room is one conversation bound to several surfaces, and which surfaces those can be — and what each may do with a room — used to be decided by ten hardcoded declarations of surface names across five files in four spellings. They agreed only because there are exactly two room surfaces today and one surface that can post into a room without being one, and because **three different questions shared the same literals**: whether a surface creates and owns rooms (`room_role`: `member` for talk and web, `guest` for email, which joins an existing room's transcript and never mints one — ISSUE-136's "existence, never creation" rule is that value), whether it has a *view* of the room so a turn written into the room is already in front of its users (`room_view`, `canonical` where the view is rendered from the `messages` store we own and `external` where the store is somebody else's), and — a third question this table deliberately does **not** answer — whether a surface may deposit a `role='user'` row in a room at all, which admits email and stays a literal in `db.py`. Converting that third one to the ownership predicate would have stopped an email `!confirm` recording an exchange its own docstring calls a durable authorization record, in a change whose whole claim was that it altered no behaviour. **The durable-place test** is what makes email's `None` principled rather than a special case: is there a durable, addressable place a person opens to read the whole conversation, that we can write into? A Talk room yes, an email thread no — it is reconstructed from messages scattered across mailboxes we cannot write into. Bidirectional sync is not a field, because it is not a property a surface has: it is the conjunction of the first two, and a flag would let an operator ask for states no surface can implement. **Static on purpose, and that is not cosmetic**: `routing._room_view` reads the same fact through a config-built registry and collapses "not a room view" with "surface not resolvable", which is safe for a delivery planner branching only on `canonical` and is not safe here — on a deployment with `talk.enabled = false` `web_app._user_row_display` would render every historic Talk turn as an external message, and the scheduler's confirmation gate would put the prompt on the mirror Talk leg. Two readers of `room_view`, two questions, each docstring naming the other. `origin_surface_for_source_type` is likewise **not** `registry._surface_for_source_type`: that one answers "where do I deliver this result" and maps every non-surface source type to `talk`, so asking it "where did this originate" flips seven of the eleven shipped `tasks.source_type` values and the empty string besides — and since the confirmation gate reads its predicate negated, cron, briefing and heartbeat tasks would park with the question delivered nowhere until `expire_stale_confirmations` killed them two hours later. Each transport still declares its own three fields on `TransportCapabilities`, because somebody adding a surface is looking at the transport class; a test holds the two in step and another requires every surface `make_registry` can produce to have a record, so adding a surface is filling in a record rather than finding the lists. Every reader takes `object`, not `str` — callers pass values straight off database rows — and answers "not a room surface" for anything unrecognised, which is the safe direction at six of the seven sites reading it and not at `web_app._user_row_display`, where the default renders a genuine Talk turn as an external message. Full reference in `.claude/rules/transport.md`. stdlib-only leaf, imports nothing: `transport` imports `db` at module level, so a table both sides read has to sit below them
├── email_support.py      # Shared non-transport email plumbing (get_email_config, thread helpers, cleanup) used by transport + briefing/notifications/tasks-file
├── tasks_file_poller.py  # TASKS.md monitoring
├── heartbeat.py          # Health-check system
├── host_pressure.py      # Host memory instrumentation: PSI/meminfo/tmpfs sampling, the fixed-cadence `host_pressure` breadcrumb line (incl. `shmem_unaccounted` = Shmem − Σ tmpfs used, and the daemon's own cgroup `memory.events` counters — read by walking *up* to the unit, since the delegated leaf carries no controller files), and a threshold snapshot that attributes shmem to mounts, containers, running tasks' bwrap sandboxes and `memfd` fd holders. The sandbox rows are `read_sandbox_shm`, which the scheduler feeds `(task_id, worker_pid)` for **every** running task from the task table: `read_tmpfs_usage` reads the *daemon's* mount table and a task's private `/` and `/tmp` appear in no table it consults, so the residue's most common holder was the one thing attribution could never name (ISSUE-286). **`worker_pid` is not the pid to read** — it is the outer `bwrap`, which stays in the daemon's own mount namespace, so reading it hands back the daemon's mount table and the rows restate the host tmpfs listed above them, once per task, under a `sandbox task=` label; `find_sandboxed_pid` descends breadth-first (bounded, cycle-safe) to the nearest descendant whose `ns/mnt` differs from `/proc/self/ns/mnt`, and a row is only ever emitted against a pid observed to differ. No descendant differs on an uncontained deployment, which reports unavailable rather than falling back. This is the same bwrap fork that forced cgroup placement into `preexec_fn` (ISSUE-285). Not folded into `tmpfs_sum_kb` or the residue — those feed `snapshot_trigger`, and the point is to explain the residue, not move it. `task_pids=None` renders `sandbox not-queried`, `[]` renders `sandbox none-running`; a caller with no task table has not established that nothing is running, and a running task with no `worker_pid` (NativeBrain never calls `on_pid`) is carried as `0` and rendered per-task rather than filtered out, since dropping it printed `none-running` on a busy host. Only the `linux` tier can check any of this — every other test writes the `/proc` tree it then reads. Two predicates, not one: `is_under_pressure` (PSI + MemAvailable) gates the scheduler's admission of new work, `snapshot_trigger` adds a third arm on the shmem residue and gates attribution only — a residue swap is absorbing is a reason to collect evidence, not to refuse work. stdlib-only leaf, every reader takes its `/proc` root as a parameter, never raises. `python -m istota.host_pressure [--snapshot]`
├── webhook_receiver.py   # FastAPI: Overland GPS, etc.
├── garmin_routes.py      # Module-agnostic Garmin auth router (/api/garmin/*), shared by Health + Location
├── web_app.py            # Authenticated web UI (Nextcloud OAuth2 + admin dashboard)
├── web_shutdown.py       # Whether the web process is stopping, in one place the three SSE generators can see (`/chat/stream`, the task stream, the admin log tail). Those poll until the *client* goes away, so nothing server-side ever ended them: uvicorn's shutdown ran out its whole graceful window and then cancelled the ASGI task, and `run_asgi` logs **any** exception out of an ASGI app — `CancelledError` included — as `ERROR: Exception in ASGI application` with a full traceback, so an ordinary Ctrl-C and every deploy restart printed a stack trace and paid the window. uvicorn offers the generator nothing to observe (`connection.shutdown()` on a connection mid-response only clears keep-alive, so `request.is_disconnected()` stays False, and the lifespan shutdown event fires *after* the connection wait), so the signal is made here: `install_signal_hook` wraps the SIGINT/SIGTERM handler uvicorn installed and `sleep_unless_shutdown` wakes every sleeping stream at once, after which each returns and its response completes normally — leaving nothing to wait on and nothing to cancel. **Called from the web app's lifespan rather than from `serve.py`**, which is the one startup path shared by `istota serve` and a plain `uvicorn istota.web_app:app`, and which runs after `capture_signals` has installed the handler being wrapped. A non-callable handler (`SIG_DFL`/`SIG_IGN`) is left alone — the default disposition for SIGINT is what ends the process, and replacing it with a wrapper that only takes a note would break Ctrl-C rather than improve it. A notice, not a boundary: `serve.py`'s bounded `timeout_graceful_shutdown` and its force-quit abort are still the backstops for a stream that ignores it, so nothing here raises and a hook that cannot be installed is a debug line. **Nothing on the notice path may take a lock, and that is what the module's shape is for**: a Python signal handler runs on the main thread between bytecodes of whatever that thread was doing, and under both deployment shapes the main thread *is* the event loop the streams run on — so a `begin_shutdown` acquiring a lock could be entered while the interrupted code held it, on a non-reentrant lock, from a handler that has not yet delegated to uvicorn's `handle_exit`, leaving the process hung with the stop signal swallowed and only SIGKILL left. That is the exact failure the module removes, so the state is a plain flag and a plain list (`append` / `remove` / `list()`, each atomic under the GIL) and the register/signal race is closed by ordering instead — the flag is published before the waiter list is copied, and a sleeper re-reads it after its own append, so whichever side goes first the sleeper never waits out its interval. Each waiter carries its own loop, so the wake is `call_soon_threadsafe`, the one loop method asyncio documents as safe from a signal handler; the delegation to the wrapped handler is guarded so nothing here can eat a stop signal. **Starting a server is also the reset point** — uvicorn restores the pre-server handlers when it returns so the hook removes itself, but the latch would not, and a second server in one process would then answer every stream at its first loop check, silently and for the life of the process. And it is **signal-driven**, which is a caveat with one caller today: `serve.py`'s supervisor sets `should_exit` when the scheduler thread dies without any signal being involved, so it raises the notice itself. stdlib-only leaf, imports nothing from the package
├── usage.py              # Normalized per-attempt token/cost telemetry (`BrainUsage`, `ModelUsage`, `from_cli_result`, `from_task_usage`). The one place each brain's own reporting shape is converted to a single vocabulary, so the schema and the read surfaces never learn which brain produced a row. Pure: no DB, no config, no brain imports, and neither adapter raises — they sit on the brain's return path
├── usage_render.py       # The cost render rule for token-usage surfaces, in one Python place: `COST_PLACEHOLDER`, `render_cost`, `fmt_money`, `fmt_int`, `fmt_context`. A currency figure appears only where `cost_basis = 'api'`, and nothing is summed across bases — a subscription's list-price equivalent and a catalog estimate both read as spend at a glance, so neither reaches the column. It sits here rather than in `cli.py` because there are two Python importers now: `cli.py` is the CLI entry point with a heavy import graph, and `commands.py` (`!usage`) is imported on the Talk polling path and cannot pull it in. `web/src/lib/usageFormat.ts` states the same rule in TypeScript, and `tests/test_cli_render_cost.py` + `usageFormat.parity.test.ts` are what hold the two languages together; a third Python copy would make that job strictly harder. stdlib-only leaf
├── subscription_usage.py # The Claude Code plan's rate-limit windows, from `GET https://api.anthropic.com/api/oauth/usage`. One fetch, one parser and one deployment-wide disk cache (`{db_path.parent}/subscription_usage.json`) shared by three surfaces — the `runtime.subscription_usage` doctor check, the `/admin` card and `!usage` — so the dashboard's 60s refresh costs nothing and no surface learns the endpoint's shape. A fourth reader is not a surface: `cached_reset_seconds` / `soonest_reset_seconds` tell the brain-availability breaker when a quota comes back (ISSUE-374), and they take the **disk cache alone** — no credential resolution, no socket, no dependence on a fetch having succeeded — because unlike the other three they run on a *task's* failure path rather than a diagnostic one. They can afford to: `resets_at` is absolute and the cache reader recomputes the countdown, so a reading of any age answers the question. They return `int | None` rather than a snapshot, which is the one place the "every entry point returns a `UsageSnapshot`" rule below is narrower than it reads; nothing raises either way. On a subscription deployment the cost column is deliberately blank (a plan-equivalent list price is not spend), which makes these percentages the real budget. Prefers the payload's `limits[]` array over the top-level window keys: that array carries a display-ready scope name, while the top-level namespace is shared with unreleased codenames, so the fallback path renders an **allowlist** and drops the rest silently rather than painting an unshipped feature name onto a public project's dashboard. The credential is read, never written and never refreshed — the server's `setup-token` has a *string* sentinel expiry where the keychain blob holds epoch milliseconds as an int, it carries no refresh token, and a daemon rewriting `~/.claude/.credentials.json` would race the `claude` subprocesses it spawns for that same file. Nothing raises: every entry point returns a `UsageSnapshot` and a failure is a snapshot with a non-empty `error`, because one caller is the daemon's boot sequence. A failure is recorded beside the cache so the TTL is also the retry interval, and only failures the *endpoint* produced go in that shared file — "no credential here" is a fact about the calling process's own environment and home, not about the deployment, so it is bounded process-locally instead. stdlib-only leaf, paths are parameters
├── doctor.py             # Runtime self-check: every environmental fact istota depends on, named once. A whole class of bug here is invisible to the suite by construction — the tests assert against Python objects on a developer's macOS host, while production is a built image, a rendered `config.toml`, a `PATH` and a bubblewrap namespace, and the forge-CLI failures (ISSUE-263 and its neighbours) were all a disagreement between what the code assumed about its runtime and what the runtime was, none of them a Python defect. Each check answers one question about the host, returns a `CheckResult` and never raises. Six entry points: daemon start-up, a scheduler interval, `istota doctor [--only NAME]`, the admin dashboard's Health pane, `!check` and the `self-check` heartbeat — and it is also the **oracle the image and smoke tiers reuse** rather than hand-writing assertions that drift from the code. The last two were the counterexample to that claim until recently: `commands.cmd_check` and `heartbeat._check_self` each carried a near-verbatim copy of the other, the same five probes in the same order, drifting from this registry and from each other, and `tests/test_doctor.py::test_no_hand_rolled_health_probe` is what stops them growing back. Two constraints are easy to violate by accident. **No check on the config-load path may spawn a process**: `_validate_forge_clis` is called unconditionally from `load_config`, which runs in the daemon, the web app, the webhook receiver, every CLI invocation and every host-side skill CLI the proxy spawns *per call*, so `probe=False` is what keeps a free `os.path.exists` from becoming five `--version` spawns. And **a check is only an oracle for a test if the test names the environment that makes it run**, which is why the `developer.*` checks `SKIP` rather than pass when no token is configured. `security.skill_model_credential` is the check ISSUE-409 wanted and nothing had: it answers whether a skill CLI that calls a model of its own — `code_review`, the only one — can authenticate, in **two results because they fail independently**. The `.wiring` half is the drift guard and the one that catches a *re*-regression: `executor.SKILL_MODEL_CALLERS` is a list of skill *names* matched against the loaded index at task-build time, so renaming or removing a skill directory silently stops the injection with no import to break and nothing on a deployment to go red. The `.value` half asks only whether the daemon holds one of `SKILL_MODEL_CREDENTIAL_VARS`, reporting the **name and never the value**, since a `CheckResult` is rendered into the boot log and the admin dashboard; it `SKIP`s with the proxy off, where the CLI is re-exec'd with the daemon's own environment and there is no injection to assert about, and it `SKIP`s again where the environment it is reading is not the daemon's at all. That second one is the correction to the first version of this check: `istota doctor` is a command a *task* can run, a task env is the one ISSUE-390 deliberately strips the token out of, and the two API-key names reach a task env by no route at all — so on that shape all three names are absent whatever the daemon holds, and the check reported `FAIL` and exited 1 about a deployment whose reviews were working. `_non_daemon_env_markers` names the three markers istota's own env builders set (`ISTOTA_TASK_ID` from `build_task_runtime`, which survives into `proxy_base_env` too and is therefore the general one; `ISTOTA_SANDBOXED`; `PRECOMMIT_SCANS_REQUIRED` from `build_stripped_env`, which is what a cron `command` job runs with and whose credential-pattern filter removes all three names by construction), rather than testing the `ISTOTA_` namespace — the daemon's own environment carries `ISTOTA_CONFIG_PATH` and `ISTOTA_ADMINS_FILE`, so a namespace test would skip everywhere and the check could never fail. **Presence stays `OK` wherever it is read**, and that asymmetry is what keeps the skip narrow rather than a hole: `build_clean_env` copies the token out of the daemon's environment into every task's, so seeing it inside a task does answer the question, and only absence is the unanswerable direction. Spawns nothing, so it is safe under `probe=False`. **`config_visibility` is a gate in front of the whole registry, and the one thing here that is not a check** (ISSUE-412): `load_config` returns a bare `Config()` when no candidate resolves and says so nowhere, so every path-and-policy check then answers about defaults — a relative `data/istota.db`, the default temp dir, a whole `[security]` block the operator never wrote — while reading exactly like a run about the real deployment. Inside a task that is *unconditional*: `build_clean_env` exports `ISTOTA_CONFIG_PATH` naming a file under `config/`, and `config/` is bound into no sandbox by design, so the exported path never resolves in there. Keyed on `config.config_path` rather than on the environment, the way `executor` and `scheduler` already ask the same question — the environment says what was *asked for*, and a deployment can resolve a config with neither variable set. **It splits on the same principle `_deployment_sandboxing` did and deliberately not on the same predicate**, which is the trap: a boundary doing its job is not a fault, so a *task* gets a `SKIP` rather than the ISSUE-381 shape 492a91e9 removed — but the question here is "is this process the daemon", not "would a bwrap probe from here be nested", and `ISTOTA_SANDBOXED` answers only the second. `task_env` sets that marker under `skill_proxy_enabled and effective_sandboxing` while `executor` exports `ISTOTA_CONFIG_PATH` on `config_path is not None` alone, so on a `sandbox_enabled` deployment with the proxy off — warned about since ISSUE-393, and shipped — the marker is absent while `config/` is just as unreachable, and keying on it would `FAIL` a task with a remedy it cannot follow. `_is_task_process` reads `ISTOTA_TASK_ID` or `ISTOTA_SANDBOXED` and **not** `_non_daemon_env_markers`, which is wider by one: `PRECOMMIT_SCANS_REQUIRED` marks a cron `command` job and a heartbeat shell command, both of which run unsandboxed as the daemon user with the config directory in front of them. A task on an *unconfined* deployment reaches none of this, since its exported path resolves — so the wider predicate costs only that a genuinely broken install seen from inside a task reads as a `SKIP`, the trade 492a91e9 accepted for the same reason: every authoritative surface runs in the daemon. **Everything else is a `FAIL`, including "nothing named and nothing found"**, because that invocation used to run 31 checks against defaults and exit 1 on the several that fail, and `verdict`'s own docstring already draws the line for the empty list — a run that checked nothing must not read as a run that passed everything. Only the task arm exits 0. **`cmd_doctor` renders it instead of the registry rather than beside it**, which is the decision the issue wanted: pushing three-state discipline into every config-reading check would touch most of the 31 and would still leave a reader taking `OK runtime.framework_db` as a statement about the deployment, since one honest line among 31 fictional ones does not stop that. **`--scope image` is exempt and `--only` is not**: `IMAGE` is defined a few lines below the status constants as what "can be answered by a bare `docker run` with no volumes", which is precisely a host with no config on any search path, so swallowing that scope would make a loaded config a precondition for the one scope declared not to need one — and it is the image tier's whole oracle. `--only` selects by name across both scopes and carries no such declaration. The gate is on the **CLI** path only. `!check`, the `self-check` heartbeat, the boot run, the interval sweep and the admin pane are in-process daemon and web callers, none of them reachable from a task, so the sandbox case cannot arise there; on the separate shape of a *daemon* started with no config file anywhere they still report the same 31 fictional results, which is left alone deliberately — the fix for that shape is the daemon refusing to boot, not a diagnostic apologising for it. The task arm's whole point is in `detail` rather than `remedy`, because `render_text` prints a remedy only for `WARN` and `FAIL`; the named path goes through `_named_path`, which collapses and caps with a visible cut since both sources are writable by anyone who can set an environment and an operator told a path did not resolve must not be shown a different path from the one that was tried; and the arm is chosen from `source` rather than from the rendered value, since a whitespace path collapses to empty and would otherwise report the wrong cause.
├── map_basemap.py        # Where the map's background tiles come from, decided in one place (ISSUE-334). `LocationMap.svelte` named `basemaps.cartocdn.com` twice as literals; CARTO now requires a key on those URLs and watermarks an unauthenticated request with "API KEY REQUIRED", so every map surface rendered defaced tiles and nothing in the deployment could change that without a code edit. A provider name plus a few strings of `[web.map]` config resolve to the concrete URLs the browser fetches; adding a provider is a row in `PROVIDERS`. **Two consumers that must agree or the feature is worse than useless** — `GET /istota/api/map/basemap` and doctor's `web.basemap` check — since a second copy of the URL shapes in the checker would let doctor pass while the map was blank. Never raises and never returns an unusable spec: an unknown provider, a `custom` with no URL or a non-http(s) one, **and a keyed provider with no key** all fall back to `openfreemap` and set `fell_back`. That last one is the case worth being explicit about — returning the keyless CARTO templates with a `needs_key` flag was the original bug wearing a label, since nothing in a browser can act on a flag and the user still got the watermark; the flag survives on the fallback spec as the *reason*, which is what lets doctor say "carto, with no key" rather than "did not resolve as written". A user's own stored CARTO key (`MODULE_SERVICE_SCHEMA["location"]["carto"]`) selects CARTO for that user, overriding `provider`, because otherwise pasting a key would do nothing visible and the reason would live in a file they cannot reach; the endpoint returns it only already embedded in the tile URL, never as a field. `api_key` **is not a secret** — MapLibre puts it in the tile URL, so it ships to every browser that loads a map. stdlib-only leaf
├── admin_logs.py         # Read-only log sources for the admin UI: the rotating app log file (+ its rotation chain, backward-scanning paged reader + live tail) and the `task_logs` table. A caller names a *source id*, never a path
├── admin_config_view.py  # Redacted, sectioned rendering of the loaded Config for the admin UI (field-level + dotted keys, so it can back an editor later; credentials never leave the process)
├── secrets_store.py      # Encrypted credential store (Fernet via scrypt-derived key)
├── secret_schema.py      # Shared service/key schema for `istota secret` CLI + web UI
├── google_scopes.py      # The Google service ↔ OAuth scope table: what a user may pick per service (none / read / read-write), bounded by the operator's configured ceiling; read by the picker, the granted-scope readback and the connect flow
├── modules.py            # MODULE_NAMES (feeds, money, location, health, briefings) + EXPERIMENTAL_MODULES (empty)
├── experimental.py       # Operator feature-flag gate (`@requires_feature`, env helpers)
├── user_profiles.py      # Per-user profile store (Phase 6)
├── user_briefings.py     # Per-user briefings store (Phase 7b)
├── notifications.py      # Talk / Email / ntfy dispatcher (delivery). Distinct from the two below, which are the inbox
├── notification_store.py # The `notifications` table: the durable open set of what is waiting on a user, behind the bell. The write and the send are **two calls** — most producers raise from inside an open write transaction, and a second connection opened there waits the full 30s busy timeout on the lock the caller holds, raises, and is swallowed by the never-raises contract (the same hazard `run_cleanup_checks` buffers its expiry notices for). So a producer calls `write_notification` on its own connection, buffers the `RaiseResult`, and calls `deliver_pending` after its `with` block; `raise_notification` is for a caller that demonstrably holds no lock. The upsert is a read-modify-write, not `ON CONFLICT DO UPDATE`: the branch depends on the row's state *before* the write and SQLite exposes no way to see that from an upsert — insert and reopen deliver, a bump does not (that dedup is what a chat channel structurally cannot do). `last_delivered_at` records only a *successful* send, since `send_notification` returns False with no destination configured and suppressing a redelivery on a send that reached nobody is the failure the inbox exists to fix. `resolve_by_object` takes `user_id` first-class and required — panel ids come from the per-user health DB where every user has a panel `12`. `mark_seen` takes `(id, updated_at)` pairs so a row bumped between the client's fetch and its POST is stamped but not closed. `list_open` runs a liveness pass over the *whole* open set (the oldest rows are the ones with dead objects, and a render-bounded sweep never reaches them), enforces the URL allowlist at runtime on every view, and counts survivors rather than re-querying. Never raises
├── notification_sources.py # The resolver seam: `NotificationRow`/`NotificationView`/`NotificationAction`, the registry, and `SAFE_PATH_RE`. A resolver returning `None` means the object is gone — that single value is the whole anti-staleness story, so an item answered over Talk can never render stale. Resolvers are a backstop, not the primary close path. `auto_resolve_on_seen` splits object-backed rows (something closes them) from fire-and-forget ones (nothing will, so seeing them closes them). Runs in the **web process**: no heavy module-scope imports, registration explicit rather than by import side effect. Imports nothing from the package
├── notification_resolvers/  # One module per notification source: its `SOURCE` id, its `dedup_key` spelling, the `write` / `resolve_for_*` producer helpers and the resolver, all in one file so the three cannot drift — the key is load-bearing (`UNIQUE (user_id, source, dedup_key)` is what the backfill claims idempotency from). Six ship today. Five are **object-backed** (`auto_resolve_on_seen = False`), so something outside the table closes them: `confirmation` (a task in `pending_confirmation`), `outbound_draft` (a held reply), `cron_job` (a scheduled job the scheduler switched off after N consecutive failures), `connected_service` (a stored credential the remote rejected — Garmin today) and `health_panel` (a bloodwork panel left in `draft` after OCR). The sixth, `task_alert`, is **fire-and-forget** (`auto_resolve_on_seen = True`) and is the one where the delivery rule below bites hardest: it carries the five one-shot alerts that used to be a push and nothing behind it — a deferred alert the model wrote inside the sandbox, a mail-throttle notice, an expired confirmation, the DMARC canary's verdict, and a task result that reached nobody — and three of those five producers keep their own send entirely. Its resolver **never returns `None` on any path**, because `None` means "the object is gone" and `list_open` marks those `stale`; a row with no object behind it would then close a notice the user has not read, silently and for good, since nothing raises it a second time. Every axis its `dedup_key` is built from is bounded (`_slug`), since `alert_type` comes from the model's JSON and `verdict` from a parsed mail header, and an unbounded axis means one durable row per attacker-chosen value, each firing a push. Bodies take `flatten_body`, which strips **less** than the title's `flatten`: the label rule deletes `* _ ~` and every newline, and in a body that is corruption — `rm -rf ~/Documents` would be delivered as a different command. **Each resolver's close predicate is the thing that actually ends the condition, which is not always the obvious column** — `cron_job` watches `auto_disabled_at`, not `enabled`, because `sync_cron_jobs_to_db` treats CRON.md as authoritative for `enabled` and runs every tick, so a file-defined job is switched back on within a tick of being auto-disabled; the same sync leaves the state columns alone. **And that source has two close predicates, because one column means two different things depending on the row.** A `_module.*` job closes on a success since `row.updated_at` (`last_success_at`, whose one writer is `reset_scheduled_job_failures`), never on `auto_disabled_at`: `_sync_module_jobs` has a rescue arm that clears that column — along with `consecutive_failures` and `last_error` — on an hourly cooldown whether or not anything was fixed, so on a module row it is a retry rather than a repair and says nothing about the condition. Reading it as a close is what made the source unsafe for these rows and got the whole prefix excluded from it: raised on the suspend, marked stale by the rescue, then *reopened* by the next suspension — and a reopen delivers. The exclusion was the wrong half to fix, because `_sync_module_jobs` also seeds every module row `skip_log_channel = 1`, so a job failing on every run reached neither the inbox nor the log channel and left nothing behind but `scheduled_jobs.last_error` (ISSUE-391). With the predicate corrected the row stays open across the loop and later suspensions *bump* it, which does not deliver — one push per outage. The module branch also renders the **stored** `row.title` / `row.body` rather than recomputing from the live job, since by the time most panel reads land the rescue has wiped the failure count and the error the row exists to carry. A module job failing for an actionable reason still raises its own more specific source in parallel (a dead Garmin credential raises `connected_service`). **Delivery is per producer, not per source.** `connected_service` delivers because the wiped credential takes the sync job with it and nothing will notice again; `health_panel` writes and never delivers, because the producer is the upload handler and the user is on the review screen it just returned them to. **A source can have several producers and the row does not say which** — the confirmation row is written by both the inbound email gate and the scheduler's mid-run park, with identical `source_type`, so anything the resolver renders has to be right for both: it reads `tasks.confirmation_prompt`, which is the gate's own composed message in one case and the model's question in the other, and never `tasks.prompt`, which for a gated email *is* the withheld body. Each resolver coerces `object_id` with `int()` before interpolating it into an API path and re-checks that the object it found belongs to the row's user — the id is a value on the row, not something the query scoped. `connected_service` keys on a service *name* rather than an integer, so an explicit allowlist stands in for the coercion. `health_panel` is the cross-user trap the store's `user_id` argument exists for: panel ids come from the per-user health module DB, so the row goes against `ctx.framework_db_path` (never `ctx.db_path`) and the resolver opens the module for `row.user_id` rather than for whoever is reading. Registered explicitly by `notification_sources._register_all`, each import guarded so one broken module costs its own source and not the registry
├── claude_runtime_env.py # What a task env carries **only because the outer process is the `claude` CLI** — `CLAUDE_CODE_OAUTH_TOKEN` today — and the copy that takes it back out. `build_clean_env` sets it for every task whatever brain will run it, since `ClaudeCodeBrain` and `TmuxClaudeBrain` authenticate with it; `NativeBrain` authenticates from its own configured provider key and reads nothing here, so on that path the variable has no reader at all and only a route out: `echo "$CLAUDE_CODE_OAUTH_TOKEN"` in a Bash call comes back as a `ToolResultMessage` addressed to whatever provider native is pointed at, which is a provider boundary wherever that is not the credential's issuer (ISSUE-390). The **environment counterpart of the Claude-only mount block** — ISSUE-389 put `~/.claude/.credentials.json` and its neighbours behind `SandboxProfile.CLAUDE`, and mounts and environment being separate mechanisms is exactly why that split left this route open. **Three enforcement points, and the last two are the less obvious ones**: `NativeBrain._hello_payload` for what a Bash child is handed, `NativeBrain._start_tool_server` for what the tool-server process itself carries — a Bash child runs at the same uid in the same PID namespace and reads its parent's `/proc/<pid>/environ`, so stripping only the frame leaves the token reachable — and `execute_task`'s `proxy_base_env` for the host-side skill CLIs — the model reaches those through the same Bash tool, they run *unsandboxed as the daemon user*, and the token is declared in no skill manifest, so neither `derive_credential_set` nor `derive_proxy_only_set` was ever going to take it out. That third point had one reader the original reasoning missed and ISSUE-409 corrected: `code_review` calls `make_brain` inside a skill CLI and spawns the `claude` binary per reviewer, the only skill under `src/istota/skills/` that touches a brain, so from ISSUE-390 every review on a subscription deployment came back `skipped / review_failed` about a second in — the shape of a `claude -p` exiting on "Not logged in" before it opens a socket. The strip stands; the exception is scoped. `executor.SKILL_MODEL_CALLERS` names the skills and `skill_model_credentials` copies the credential into the proxy's **per-skill** map, a copy rather than a fourth `_split_credential_env` because the same value authenticates the task's own `ClaudeCodeBrain` and a split would unauthenticate the task in order to fix the skill; it covers `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` beside the OAuth token, since `build_model_cli_env` runs *inside* the skill subprocess and those two reach a task env by no route at all, so that shape was broken a change earlier. And `_PROXY_LOOKUP_BLOCKED` gains the same names, because injection is scoped to one skill while the `credential-fetch` allowlist is a union that anything holding the socket — the model included — can read from. **A name list rather than a `CLAUDE_*` prefix rule**, because a prefix would also swallow an operator's own `passthrough_env_vars` entry, which is their decision and not this module's; the drift a list buys is covered by a guard asserting over the *keys* `build_clean_env` produces rather than their values, since a value-based check sees only an untransformed identity copy and misses both a rename and a transformation (`PATH` is already transformed in that same function). **Copies, never mutates** — the mapping is `req.env`, which `ClaudeCodeBrain` writes `IS_SANDBOX` and `CLAUDE_CODE_DISABLE_ADVISOR_TOOL` onto in place and which `_run_fallback` carries across a reroute with `dataclasses.replace` without rebuilding, so stripping in place would unauthenticate the CLI on a `native -> claude_code` fallback, on the shape where that token is the only credential there is. **`None` and `{}` are opposite instructions and stay distinct**: `ToolEnv.subprocess_env` reads `None` as "inherit the parent environment" and the parent is the daemon, whose environment is where the token was read from — so a fully-stripped env collapsing to `None` would hand the child the whole daemon env, making the strip strictly worse than doing nothing, and the caller therefore puts `or None` on the input. What it deliberately does **not** claim is that no credential crosses — but the scope is narrower than that sounds and is not arbitrary. The skill credentials (`NC_PASS`, the mail passwords, the forge tokens) are already removed by `_split_credential_env`, gated on `security.skill_proxy_enabled`, which defaults on; the Claude token is the one name that gating never reaches, since `build_clean_env` sets it unconditionally and no manifest declares it, so it survived on the *sandboxed* production shape where every other credential was already gone. Where the proxy is off, `setup_wizard` also sets `sandbox_enabled = false`, so nothing is confined and there is no boundary for an env var to cross; where it is off with a sandbox on, `load_config` already warns (ISSUE-393). stdlib-only leaf, imports nothing from the package — `executor` imports `.brain`, so `brain/native.py` cannot import `executor` at module scope at all, and a function-local import there would make every direct brain caller pay that import graph inside the agent loop
├── ntfy_headers.py       # RFC 2047 encoding for ntfy header values (stdlib-only leaf, shared by transport/ntfy + skills/ntfy so the skill subprocess needn't import the transport package)
├── kv_namespaces.py      # Which `istota_kv` namespaces the model may not touch. A leading `_` marks framework state that happens to live in the KV store — today the USER.md curation audit trail and the fingerprints bypass detection compares against (`memory/curation/audit.py`), which used to be sidecar files next to USER.md and are now rows, plus `_provisioned_rooms`, the Talk token `provision_rooms.py` provisioned for each default room name and whether an invite to it is still outstanding (ISSUE-342, ISSUE-408), where the prefix is what stops a task re-pointing a deploy at a room of its choosing — or, since the outcome joined the token, at one it would then be re-invited into, `_avatar_import`, what the Nextcloud profile-picture import tick wrote down for `doctor`'s socket-free `web.avatar_import` check, and `_session_log_sweep`, the same shape for the native-brain transcript sweep — where `doctor` reads back whether the size ceiling rather than `retention_days` is what is binding — a `shared_kv` namespace, which the `kv` skill's `--shared` mode would otherwise reach, and which the guard covers because it runs in `main` before any verb. A prefix rather than a list, so a further reserved namespace costs nothing at either enforcement point — and there are **two**, neither substituting for the other: `skills/kv` refuses the namespace on every verb before it does anything, which covers a CLI call made host-side through the skill proxy, and `scheduler_deferred` refuses it again when applying a deferred op, which covers a **sandboxed** task — the sandbox has no database, so `kv set` there writes a JSON op file the scheduler replays afterwards, and guarding only the CLI would leave open the path most tasks take. `kv namespaces` also filters them out, since a listing that offers a name every other verb refuses is worse than no listing. Defence in depth rather than the boundary: the boundary is that the framework DB is bound into no sandbox at any path. `is_reserved_namespace` takes `object`, not `str` — both callers pass a value off an argparse namespace or out of model-written JSON. stdlib-only leaf, imports nothing, so the `kv` skill subprocess needn't pull in `istota.db`
├── git_hardening.py      # The `-c` overrides that stop a repository's own config running a program (`core.fsmonitor`, `diff.external`, the `gpg.*` programs, plus the output-reshaping keys a parser depends on). Repo-local config is not covered by `GIT_CONFIG_NOSYSTEM`/`GIT_CONFIG_GLOBAL`, and under `developer.repos_dir` it is model-written. Extracted from `skills/code_review/engine.py`, which paid for the list and still re-exports it, so `worktree_reaper` can reach it without importing `istota.skills` (whose `__init__` star-imports every skill, ~190ms) — same reason `forge_bin.py` exists. stdlib-only leaf; no imports at all
├── git_remote_scrub.py   # Strips credentials out of the git configs under `developer.repos_dir` (ISSUE-270). **Two callers, two different roots, and neither is the whole tree any more**: the developer skill's `setup_env` sweeps the task's own subtree, `{repos_dir}/{user_id}`, before that subtree is bound into the sandbox, and hands the derived package cache under it as `skip` (uv's `archive-v0` is one directory per unpacked wheel, so the walk would otherwise list thousands and report them unswept); `worktree_reaper` sweeps the configured root from the scheduler. `_MAX_DEPTH = 4` is the one place the difference bites — a bare clone is at depth 2 below a user's subtree and depth 3 below the root, so the slack for a repository filed one directory deeper by hand is two levels on the first caller and one on the second. `git config` is the parser, never the file: `--includes` + `--show-origin` so a value pulled in from another file is found and corrected where it lives, `-z` so a value containing a newline cannot forge an entry, `--fixed-value` so a multivar `remote.*.url` keeps its clean siblings. Covers the key as well as the value (`url.<base>.insteadOf` hides the secret in the key while `remote -v` looks clean), per-worktree `config.worktree`, and `http.*.extraheader`. Detects a userinfo password or a known forge-token prefix — not a bare `@`, so `git@host:path` is left alone. Never raises (a setup-path guard), never logs the value, and reports a credential it could not remove rather than returning silence. stdlib-only leaf
├── repos_relocate.py     # One-shot migrator: `developer.repos_dir` from one shared tree into per-user subtrees, `{repos_dir}/{namespace}/` → `{repos_dir}/{user_id}/{namespace}/`. `python -m istota.repos_relocate [--dry-run|--list]`, invoked by the Ansible role after the code is in place and before the units restart, on the `db_relocate` precedent: the judgement lives in Python where it can be tested rather than in untested YAML. **Ownership is the whole problem.** Nothing on disk says which user owns a clone — a forge namespace is not a user id — so it reads `load_admin_users()`, assigns every namespace to the single admin where there is exactly one, and **refuses** where there are none (which `Config.is_admin` reads as "everyone") or several, printing what it found. Guessing wrong hands one admin's clones to another, which is the exact failure the split exists to prevent. Worktrees are siblings of the bare clone inside the namespace directory, so one same-filesystem rename per namespace takes the clone and its worktrees together; `git worktree repair` then fixes the absolute paths git records in both directions, and it runs **immediately after its own namespace's rename** rather than after all of them, so a process death costs one namespace's worktrees instead of every namespace's. `.istota-layout.in-progress` records what moved so the next run finishes the repairs; ownership is never read back from it. Success writes `{repos_dir}/.istota-layout` containing `2` — a marker rather than an inference, because a forge namespace can legitimately be named the same as a user id. **Guards.** It refuses while any task is `locked` or `running` (`db.get_users_with_live_tasks`, the sweeper's own set) since moving a clone out from under a live task destroys it, and an unreadable task table refuses too rather than reading as "nobody is working"; `{repos_dir}/{user_id}` is held to the same containment equality `executor.get_user_repos_dir` uses, checked in `plan` and again in `apply`, with the mode set through an `O_NOFOLLOW` fd — a symlink planted at the admin's name was otherwise taken by `mkdir(exist_ok=True)`, `chmod` and `rename` alike, and every repository left the tree with the run exiting 0. A worktree record is only handed to git if its `.git` already names the clone being repaired, because `git worktree repair <path>` resolves the repository from `<path>/.git` and ignores `-C`, so a model-written `gitdir` record could otherwise redirect an unrelated repository's worktree. Both comparisons run on realpaths, since git records resolved paths and a `repos_dir` behind a symlinked ancestor made every record read as outside the namespace — nothing repaired, marker written, exit 0. A dotted entry, a symlink, a non-directory or anything that does not resolve to a child of the root is reported and **left in place** — including `.package-caches`, the shared cache root the previous layout used, whose per-user directories are named from disk and which is orphaned by the derivation rather than moved into somebody's subtree. Every `git` call carries `git_hardening.GIT_HARDENING`, for the reason it always does under `repos_dir`. Three exit codes an operator reads apart: 0 success (including "already migrated" and "nothing to migrate"), 1 refusal with nothing touched, 2 partial with something needing a hand. A refusal also prints `refusal: <reason>` on a line of its own, because the Ansible role has to tell one apart from another and prose is not a contract: `live_tasks` is the only reason the play carries on past, since it is the only one that resolves without anybody doing anything — the tasks finish and the next deploy migrates. Failing the play there was never a safeguard, because it does not stop the code reaching the host (the auto-update path resets the checkout to `main` and restarts the units whatever the play reports), so it left the same half-migrated tree *and* stopped the rest of the deploy; the role reports it loudly instead. Every other reason still fails, and the line is that none of them self-resolves. The refusal is also precise about what it implies: the task table is only consulted when a rename is actually pending, so `live_tasks` means the host is still on the old shape with work to move. Never raises out of `main`, and stdout is reconfigured to `backslashreplace` so printing a non-UTF-8 path cannot turn a completed migration into a traceback
├── skill_proxy.py        # Unix-socket proxy for credential isolation
├── tool_server.py         # The native brain's tool server: `python -m istota.tool_server --fd N`, one process per **task attempt**, spawned through `build_bwrap_cmd(..., profile=NATIVE)` and placed in the task cgroup from `preexec_fn`. It builds one `ToolEnv` from the `hello` frame and binds the six core tools to it (`build_default_tools`), which is what put `Read`/`Write`/`Edit`/`Grep`/`Glob` inside a namespace for the first time — they used to run on daemon worker threads behind a Python path allowlist, a second filesystem policy that every bind change had to be copied into and whose check and open were separate syscalls, and `Bash` used to rebuild a whole bwrap namespace per call carrying the `claude` CLI's runtime state and credential (ISSUE-389). Three properties are decisions rather than consequences. **It wraps nothing**: a nested bwrap inside a namespace built with `--unshare-user --disable-userns` fails every call rather than weakening anything, so `ToolEnv` lost the field. **It places nothing**: membership is inherited at `fork`, so every command is contained by being forked from here, and `cgroup.procs` is under a path no sandbox binds. And **it merges `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` from its own environment into `subprocess_env`** — the network wrapper `exec env`s them onto *this* process while the frame's env was built in the daemon, which has no bridge and no port, so without the merge every network-using build fails with an error pointing nowhere near here; `NO_PROXY` is deliberately the **empty string** there (it blanks an inherited exemption list), so the merge tests presence and never truthiness — and the wrapper's value **wins** a collision with the daemon's, because inside `--unshare-net` the bridge on loopback is the only proxy anything can reach and a daemon-side `passthrough_env_vars` entry naming a corporate proxy is a host address with no route from in there. Nothing raises past `main`: a failure sends `fatal` and exits non-zero, and frame *contents* are never logged, since they carry the model's tool arguments and the tool output
├── tool_server_protocol.py  # The wire format between the two, stdlib-only: no sockets, no asyncio, one `ProtocolError` out of `encode` and the decoders. 4-byte big-endian length then a UTF-8 JSON object; the 32 MiB cap is enforced on **both** ends, since a cap only on the reader lets a writer build a frame it can never deliver and a cap only on the writer lets a garbled length ask for a 4 GiB buffer. Eight messages — `hello` / `call` / `abort` / `shutdown` down, `ready` / `update` / `result` / `fatal` up. `ToolResult.details` does not cross (`agent/loop.py` is its only reader, and only through the after-hook override), and content blocks travel as plain dicts rather than through a dataclass serializer because that is already what they are. `FrameDecoder` is a class rather than a read-one-frame helper because that is what makes the truncation rule expressible: bytes left at EOF are a *fault*, raised by `close()`, not a quiet end — a tool call whose result never arrived must read as a broken server rather than as a call nobody made
├── worktree_reaper.py    # Removes a developer worktree under `developer.repos_dir` once its work has landed (ISSUE-288). `developer.repos_dir` is a root of per-user subtrees, so what it walks is `{repos_dir}/{user_id}/{namespace}/{project}.git` — depth 3, inside `find_git_dirs`' `_MAX_DEPTH` of 4, which is the whole reason the per-user split needed no change here beyond a test that says so. It still sweeps the configured root rather than one subtree, because it runs from the scheduler with the daemon's own view and has no task to belong to; the scheduler passes every derived `{repos_dir}/*/.package-caches` as `skip`, enumerated from disk since a cache appears on a user's first task. That prune is not tidiness: a git directory inside a package cache is otherwise evaluated as a reap candidate, which runs `git fetch` against a model-written `remote.origin.url` from the unsandboxed scheduler, outside the CONNECT allowlist, every sweep. Runs from the **scheduler**, on `scheduler.worktree_reap_interval` — not from the developer skill's `setup_env`, because `dispatch_setup_env_hooks` calls every skill's hook whatever the task selected, so a sweep there fired before every Talk reply, every cron job and every heartbeat tick (and the heartbeat builds a task with `id=0`). A delete path belongs on a stated cadence. A worktree goes only when losing it cannot lose anything: inside `repos_dir`, not a repository's main worktree, unlocked, idle for `worktree_retention_hours` (measured across *both* the checkout and its administrative dir under the bare clone, because a `git commit` touches no working-tree file and an edit touches no admin file — the pointer may be relative, and resolving it against the daemon's cwd would silently drop the git half; the admin half reads every *entry* in that directory, plus `logs/HEAD` one level down, and deliberately *not* the directory's own mtime — a `.lock` create-and-remove stamps the directory for the whole window while leaving no entry behind, which bought a worktree a fresh day of exemption on evidence that no work had happened, observed at 5h26m on the live clone. Entries rather than a name list, because `git fetch` in a linked worktree writes only `FETCH_HEAD` and the developer skill fetches at the start of every task, so the obvious five-name list read a live checkout as idle and deleted it mid-task in review; the same gap covered `rebase-merge/`, `sequencer/`, `BISECT_LOG` and `config.worktree`. A still-held `.lock` is a git process running now and reads as activity on its own honest mtime, which matters because `worktree remove` without `--force` was measured deleting a checkout with an `index.lock` present and with a rebase in progress — git's refusal covers a tracked modification and the administrative `worktree lock`, not that. ISSUE-316), `status --porcelain -z --untracked-files=all --ignored=matching` reporting nothing but reconstructible build directories, and then containment: `merge-base --is-ancestor <head> refs/remotes/origin/HEAD`, or failing that, head not a merge commit **and** `git cherry refs/remotes/origin/HEAD <head>` reporting no `+` line. The two containment questions are both needed and the order is load-bearing (ISSUE-316). `git cherry` is the answer for the large case: a squash or rebase merge is how an MR normally lands and leaves the branch an ancestor of nothing while every commit on it has a patch-id equivalent upstream — but it never examines a merge's own delta, which is why a merge head is refused. The ancestor test runs *first* because a head already reachable from upstream needs no patch-id reasoning and has nothing left to hide; asking the refusal first held every worktree cut from a merge tip and never committed to, and since the developer skill cuts from `origin/HEAD` and that branch lands MRs as merges four times in five, that was every abandoned probe — the exact set with nothing in it to lose. Exit 1 means not contained and anything else is an error read the same way, so an unanswerable question still holds the worktree. Fetches before comparing, best-effort — the git credential helper is registered per task via `setup_env`'s `GIT_CONFIG_KEY_*`, so the scheduler has none and the fetch usually fails on a private repo. Safe in the only direction that matters (a stale `origin/HEAD` holds more back, never reaps more); the cost is timeliness, since freshness then comes from the developer skill's own fetch at the next task on that repo, and a repository nobody touches again keeps its merged worktrees. The listing is read `--porcelain -z`: git does not quote a newline in a worktree path in the line-oriented form, so one truncates its record and forges the next, naming a victim path with an attacker-chosen head — and a duplicate path refuses the whole repository. Every git call carries `GIT_HARDENING` (repo config is model-written, and a plain `git status` runs `core.fsmonitor` as the daemon user) and `GIT_OPTIONAL_LOCKS=0` (without it `status` rewrites the index, which is one of the mtimes the window reads, so every sweep would reset the clock it depends on). `--ignored=matching` rather than the default, because with `-uall` in force the default expands an ignored directory into one line per file — 31,074 of them for the live server's one held worktree, against 69 — and it collapses a directory only where the directory itself matched a pattern, so a `logs/` full of `*.log` files is still listed file by file and nothing an ignore rule covers hides behind a discounted line. `_RECONSTRUCTIBLE_DIRS` (ISSUE-304) is an explicit name list, never "ignore every `!!` line": `node_modules`, `.venv`, `__pycache__` and the tool caches hold nothing that is not derivable from committed files, while this repo's own gitignore hides `data/` and `lib/`. `_is_reconstructible` matches **any** path component, not just the last — `node_modules/**` and `node_modules/*` never match the directory itself, so git collapses at the children and a last-component test sees `pkg` and pins the worktree forever. `_WALK_SKIP` now *subtracts* this list, and that is load-bearing: `os.walk` prunes by name before anything stats the directory, so a name on both lists is invisible to both guards at once, and an `npm install` or a `uv sync` touching nothing else reads as idle **and** clean and is deleted mid-install. Skipping those names was only free while the dirty check still held them, which is the premise ISSUE-304 removed — whatever `_is_dirty` stops counting, the walk has to start counting. `tests/test_worktree_reaper.py::test_the_two_name_lists_are_disjoint` holds the invariant. The accepted residual risk is stated rather than implied: an in-place edit *inside* a discounted directory (a patched package under `node_modules`) is lost. `worktree remove` without `--force`, with the dirty check repeated immediately before it, because git's own refusal covers tracked modifications and would delete a gitignored `.env` without a word — verified directly, since the `node_modules` half of that is now the behaviour the discount relies on; the branch ref goes with `update-ref -d <ref> <oldvalue>`, pinned to the head `cherry` approved, since a bare clone's HEAD deliberately points at a deleted ref (ISSUE-125). Retention is clamped to a one-hour floor — a shorter window deletes the checkout of a task still setting it up. Held-back worktrees are counted and logged. stdlib-only leaf, root is a parameter, never raises
├── sandbox_cache_sweeper.py  # Bounds the on-disk package caches the sandbox keeps per user (ISSUE-317). ISSUE-305 moved a task's uv and npm caches off bubblewrap's root tmpfs so they stop being unattributable RAM thrown away at task exit; the cost is that they then **persist**, and nothing pruned them — one `uv sync --all-extras` is about 1.8 GB, on the volume `worktree_reaper` is already fighting for. Runs from the **scheduler**, on `scheduler.sandbox_cache_sweep_interval`, for the reason the reaper does: `dispatch_setup_env_hooks` calls every skill's `setup_env` whatever the task selected, so a sweep there fires before every Talk reply and every heartbeat tick, and a delete path belongs on a stated cadence. **Two layouts, because `resolve_sandbox_cache_dir` has two branches**, and `scheduler.sandbox_cache_sweep_root` is what picks: with `developer.enabled` and `developer.repos_dir` set the cache is derived at `{repos_dir}/{user_id}/.package-caches`, and without them it is `{security.sandbox_cache_dir}/{user_id}` as before. That function reproduces the resolver's *branch selection* and deliberately not its refusals, which is the half worth being exact about — a root the resolver never writes into is a sweep that finds nothing while the real caches grow, silently and in the direction of the disk leak this module exists to close. **A size ceiling, not an age rule** — a single dependency resolution writes more than any sane window's worth of bytes at once, so a rule phrased in days either keeps everything or throws away a cache minutes old and about to be reused; the budget is `security.sandbox_cache_max_gb`, per user, over the whole of that user's cache directory, clamped to a 1 GiB floor because below that the ceiling is under one resolution's working set and every sweep would wipe a cache that is doing its job. Every visited cache gets the cheap reclaim first (`uv cache prune`, `npm cache verify`), which keeps the warm entries; only one still over its ceiling afterwards is wiped with their own `clean` verbs. **It deletes no file itself** — not the root, not a per-user directory, not a cache entry: a tool that is missing, that fails or that times out is reported and the cache is left alone, because the difference between "uv's cache" and "everything the model put in this directory" is what uv knows and this module does not. **Three guards stand between a running task and a wipe**, since unlinking an entry under a `uv sync` turns its next `link(2)` into `ENOENT`: `scheduler.check_sandbox_cache_sweep` passes in every user with a `locked` or `running` task (`db.get_users_with_live_tasks`) and such a user is skipped *entirely*, cheap reclaim included, since `prune` unlinks as surely as `clean` — and an unreadable task table cancels the sweep rather than arriving as an empty set that reads as "nobody is working"; an idle window on the tree's newest mtime then catches a writer the task table never knew about; and `--force` is never passed to uv, so uv's own in-use check still stands, which is the only guard that sees a sync against a fully warm cache, since that writes nothing and merely hardlinks out (npm's `--force` on `cache clean` is a different flag with no in-use check behind it). Containment is one rule and it is an **equality** in both shapes: a candidate must resolve to exactly the path the layout names, which a symlink fails by construction. The weaker "resolved parent is the root" reads as though it excludes every symlink and does not — `{root}/zzz` pointing at `{root}/bob` passes it, and `user_id` then comes from the entry, so the busy check asks about `zzz` while the reclaim verb runs against bob's real cache, which is guard 1 defeated by a name. What differs between the two layouts is not the rule but what a *name* is worth: under one level the root is an operator-named directory outside `repos_dir`, while under two the root **is** `developer.repos_dir`, bound read-write into that user's own admin tasks, so any entry in it is model-plantable. So on the derived layout the **caller supplies the user ids** (`config.users`) and this module derives one path each, reading no name back out of the tree; getting that list wrong is safe in both directions — a missing user is a cache that goes unswept, an invented one measures zero — and an empty list is reported rather than passing as a silent no-op sweep. `report_orphan_caches` is the price of that, made visible: a cache belonging to nobody the caller listed is now swept by nothing forever, where the one-level shape caught it by enumerating, so it is named and acted on by nothing. The *resolved* path is what goes on to the subprocess, never the entry as read: the check and the use are separated by a tree walk and up to four subprocesses, so an unresolved path can be renamed away and replaced with a symlink inside the window. **The identity pin is what the derived layout forced**, and it corrected a claim this module used to make — that resolving before the subprocess left nothing to swap. That held under one level for a structural reason nobody had recorded: the cache root's parent was never bound into a sandbox. Under two levels `.package-caches` is an ordinary entry in a directory the task writes, so `(st_dev, st_ino)` is pinned through an `O_NOFOLLOW` open and re-asserted before each round and before each tool; a directory that changed identity mid-sweep reports `ACTION_SWAPPED` and nothing further runs against it. Reverted, the control reports the victim's cache `wiped` at `after_bytes=0`. The residual is stated rather than denied: the tool still opens the cache by name after the check, and closing that means handing uv and npm an fd-anchored path. `ACTION_OUTSIDE` is counted on its own rather than folded into a skipped total, since it is the primary detector of a planted symlink. For the same reason the tools run with their cwd in a fresh temp dir, with `uv --no-config` and npm's user and global config pointed at `os.devnull`: the cache itself is model-written, and a host-side tool started inside it would read a `uv.toml` or an `.npmrc` the model wrote, as the daemon user. Measurement is du-style — `st_blocks * 512` because a volume is filled by blocks, each inode counted once because uv's cache is full of hardlinks and counting one per link reports an overage no reclaim can clear. `XDG_CACHE_HOME` points at the user root, so a third tool's cache (`huggingface/`) counts toward the budget while neither verb can touch it; that reports `still-over` and names the largest remaining subdirectory rather than looping or reaching for the filesystem. The `uv` / `npm` subdirectory names and the derived cache's own `.package-caches` are restated rather than imported from `executor`, held equal by `tests/test_sandbox_cache_sweeper.py`. stdlib-only leaf, root and policy are parameters, never raises
├── session/session_log.py    # The append-only JSONL transcript of one **NativeBrain task attempt** — the run, not a rendering of it. Nothing persisted the conversation the native brain holds with the model: `tasks.execution_trace` carries tool *labels* and no tool output at all, `task_events` carries a capped payload and only when streaming is on, and the control directory's `prompt.txt` is the input rather than the run, so a native task that answered wrongly could not be reconstructed. `ClaudeCodeBrain` never had that problem — the `claude` CLI writes its own session JSONL and `build_bwrap_cmd` binds it out of the sandbox — so the asymmetry was accidental. **One file per *attempt*, not per task**: a retry re-executes the prompt with a fresh message list, so two attempts are two runs and merging them would produce a transcript that never existed (`task_usage` draws the same line at `attempt_seq`; `attempt` is 1-based here, i.e. `attempt_count + 1`, since that column counts *prior* attempts). `{root}/{user_id}/{ts}_task-{id}-{attempt}.jsonl`, `0600` behind a `0700` directory, `O_EXCL` so a collision is renamed rather than overwritten. Records are linear, no `id`/`parentId` — resume is a non-goal, and the two fields are a `FORMAT_VERSION` bump away if it stops being one. **Two rules keep it bounded**: an image is a descriptor (`media_type`/`bytes`/`sha256`), never base64, and text is capped per block **head and tail** rather than head alone, because a truncated build log's tail is where the error is; tool arguments get their own cap and an honest marker, since a truncated *fragment* of a JSON object is worse than one. `result_text` is the deliberate exemption, and on both char caps `0` means *no cap* rather than *off*, which is the opposite of what it means on the two retention limits beside them. **It never raises and never nags**: first failure logs one warning, disables the writer, closes the handle, every later call a no-op — a task must not fail because a log could not be written, and a full disk must not warn once per tool call all day. `root=None` is the disabled writer, which is what makes `enabled = false` free at every call site; `make_session_log` also returns it when `task_id <= 0` or `user_id` is empty, so the heartbeat's synthetic `id=0` task and every direct (non-task) brain caller get no transcript at all. Flushed, never `fsync`ed. What it does **not** own is the header's redaction: `open()` copies the caller's mapping through, so keeping `api_key` and `extra_headers` out and reducing `base_url` to its host belongs to `brain/native.py`, stated here so a reader looking for the rule finds out where it lives rather than assuming it is enforced below. `sweep_session_logs` is here rather than in the scheduler on the `worktree_reaper` precedent — the delete rule and the write rule in one file — and enforces **two independent rules under an `or` gate** (the scheduler's step 7b): age for privacy, bytes for the disk, because a rule phrased in days either keeps everything or throws away something minutes old, the growth arriving in bursts rather than at a rate (`sandbox_cache_sweeper` wrote that reasoning down first). The ceiling is **deployment-wide across every user**, since the thing being protected is a filesystem and a filesystem has no per-user quota — under a per-user one the real limit is `users x ceiling`, a number appearing nowhere in the config. That forces a fairness question per-user eviction never faced, and the answer is **largest-user-first, then oldest within that user**: plain global oldest-first inverts the outcome, because the globally oldest files belong to the *quietest* users precisely because they are quiet, so one flood would clear everyone else's history. A file stamped inside `LIVE_WINDOW_SECONDS` is never evicted, and a tree that cannot get under the ceiling without touching those reports `still_over` and stops rather than looping. Measurement is `st_blocks * 512`, du-style, with directory inodes deliberately uncounted — a per-user directory is overhead no eviction can reclaim, and counting it would leave a many-user deployment permanently over a ceiling nothing could clear. Enumerating users from disk is safe here in the way it is not in `sandbox_cache_sweeper`: that tree is bound read-write into a sandbox and an entry in it is model-plantable, this one is bound into no sandbox at any path, so a directory here can only have been created by the writer. **An operator-set `dir` is trusted, the way `security.sandbox_cache_dir` is** — decided rather than overlooked: the root is the whole input, it comes from the config file, no model-supplied name is resolved against it, and the only ancestor to bound against would refuse both `/var/log/istota` and the relative value the resolver must honour as given. The cost is written where an operator meets it, because the walk reaches *any* depth below a first-level subdirectory rather than one level. The sweep's outcome goes to the reserved `_session_log_sweep` KV namespace, which is what lets `doctor` say the ceiling and not `retention_days` is what is binding. stdlib-only leaf apart from `istota.llm.types` (the serializer's `isinstance` dispatch): no config, no brain, no DB, roots and policy are parameters
├── session/session_log_read.py  # Reading a transcript back: one set of parsing rules, two consumers. `istota session list|show|tail|stats` and the `tasks transcript` skill verb ask the same questions of the same bytes, and a second copy of the parsing is how they start disagreeing about what a transcript says — the CLI rendering a file the skill calls unavailable, with neither wrong about anything a test could see. **Three rules are the difference between this and a `json.loads` loop.** A file whose line 1 is not a `session` record is **unreadable and reported as such, never rendered**: a loop renders whatever parses, which is how a truncated file, somebody else's JSONL or a log whose header write failed gets presented as a transcript (pi draws the same line, and the writer's `open()` makes it safe — the header reaches the handle before any other record can). A malformed line in the middle is skipped and **counted**, since skipping silently leaves the caller believing it saw the whole run. A trailing line with no newline is a *live write*, not damage, and is counted apart for that reason. **Nothing raises, and that holds outside the annotated types as well as inside them** — one caller is a CLI a script runs and the other answers a task, where a traceback is a failed task. The half of that which is easy to lose is the **record contents**: every field comes off `json.loads`, so its type is whatever was on the line rather than whatever the writer meant, and `len(record["summary"])` on a number is a `TypeError` that escaped `digest` and reached `istota session show` as a traceback until `_as_str`/`_as_list` went in front of every such read. **Two finders, and the split is a boundary rather than a convenience**: `find_logs` takes a `user_id` and refuses anything that is not a single path component — *including the empty string*, which must never quietly widen to every user, since the skill verb's whole scoping story is that it passes `ISTOTA_USER_ID` and gets that user's files — while `find_all_logs` enumerates everyone and exists for the operator CLI, already running as the daemon user with the tree in front of it. A caller meaning one user cannot reach the second by passing a falsy value to the first. Identity comes off the **file name and the directory**, never the header, so a log whose header claims another task or another user cannot relabel a row. **Rendering is not here**: `digest` and `excerpt` return data, the CLI renders it for a human and the skill verb renders it for a model wrapped in untrusted-content delimiters its own module owns — a tool result in a transcript is raw web page, email body and feed item, and framing it is the consumer's job because only the consumer knows who is about to read it. stdlib-only apart from `session_log` itself, which it imports for the file-name convention and the `user_id` component test; roots and paths are parameters
├── user_scope.py         # Scoping a user id under a root, in one place (ISSUE-402). `{root}/{user_id}` is written as a plain join in several places that are each a boundary, and the join is not the check it reads as: `PurePath` discards an empty component and a `.`, so `mount / "Users" / ""` is `{mount}/Users` — the parent of every user's directory — an absolute component replaces the root outright, and `..` is a child by name and the parent on disk. Where that result is bound **read-write** into a sandbox, or is the allowlist a host-side skill CLI is scoped by, each of those is one task reaching every user's files. The rule itself was not new — `executor.get_user_repos_dir` had applied it to `developer.repos_dir` since ISSUE-319 and its docstring already named the three values truthiness lets through — so what the issue found was the codebase holding both the correct pattern and the reasoning for it and having applied it to one of the two joins. **Two checks, and neither catches the other's cases**: the lexical one refuses a component that never became a child, the resolved one refuses `..` and every symlink, which are children by name and somewhere else on disk. `"."` is the case that shows why both are needed — it *passes* the resolved test, since `root.resolve() / "."` is `root.resolve()`, and only the lexical test sees it. **Validated resolved, returned as written**, like `resolve_sandbox_cache_dir`: `sandbox_plan._bind` uses the string it is handed as the in-namespace destination, so a resolved return would put a symlinked deployment root at a different name inside the namespace from everything bound under it, hence on another mount. Returns `None` rather than falling back in every case — the fallback would be the shared root, which is the exposure — so a caller drops the bind, the root or the allowlist entry instead of widening it. `is_scopable_user_id` is the lexical half without a root, for the producers that can ask before there is a path: `db.create_task` defaulted `user_id` to `""` and validated nothing against a `TEXT NOT NULL` column SQLite satisfies with `''`, so an unowned task row was one omitted argument away. It refuses `.` and `..` as whole values and not as substrings, since a Nextcloud username containing a dot is ordinary. Four consumers: `sandbox_plan.build_mount_plan` (the bind, and the per-resource skip that compares against it — one collapsed there meant every resource under `Users/` read as covered by a bind that is not emitted), `executor.image_bind_roots`, `executor.get_user_repos_dir` and `skill_host_paths.allowed_host_roots`, which guarded the conversation token beside an unguarded user id. `project_fs_roots` needs no entry of its own: it projects the same plan, so the join is fixed once for the bind and the native brain's write roots together. stdlib-only leaf, imports nothing from the package, so `skill_host_paths` can reach it from a skill subprocess without pulling in the sandbox planner. Never raises — not for a hostile `user_id` and not for one of the wrong type
├── skill_host_paths.py   # Host-path allowlist shared by the skill CLIs that take one (devbox `cp-in`/`cp-out`, `kv set --value-file`). A skill CLI runs host-side, so a path argument is an arbitrary read/write unless scoped; the roots mirror what the sandbox binds for that caller. `resolve_under_repos` is the same idea for the developer skills' `--worktree` arguments, and it is where the per-user repos split had to be repeated: `developer_repos_root()` re-derives `{DEVELOPER_REPOS_DIR}`'s scope from `ISTOTA_USER_ID` rather than trusting the variable it was handed, and returns `None` — never the shared root — when the two disagree or the user id is absent. Refusing is this module's established posture for a root it cannot resolve. stdlib-only leaf, importable from a skill subprocess
├── task_cgroup.py        # A cgroup v2 group per task (A6): `<unit cgroup>/task-<id>-<attempt>/` with `memory.max`, `pids.max` and `cpu.max`, so a runaway process tree is OOM-killed inside its own cgroup instead of taking the host. The directory is a *sibling* of the daemon's `supervisor/` leaf, not a child of it — cgroup v2 refuses to let a cgroup both hold processes and enable controllers for its children, so a group made inside the daemon's own would silently contain no `memory.max` at all; `resolve_root` takes the *last* `.service`/`.scope` component (the first is `[email protected]` under a user manager, which is delegated too and so fails by succeeding). The kernel is the capability probe: interface files cannot be created by a writer, so a successful `memory.max` write *is* the proof the controller is delegated — which is why the startup report calls `probe()` rather than trusting `resolve_root`, since that resolves on every systemd host and would print "containment on" for a deployment where every task runs uncontained. Placement is `preexec_fn`, not a write after `Popen` returns: membership is inherited *at* `fork`, so moving a pid afterwards leaves the children it already forked outside the group forever, and `bwrap` forks during namespace setup every time (ISSUE-285) — the parent opens `cgroup.procs`, the child writes `0` between `fork` and `exec`, and `verify_placement` reads the membership back because the child has no way to report a failure. `place()` remains only for TmuxClaudeBrain, whose pane pid the tmux server spawned. `destroy` writes `cgroup.kill` on `EBUSY` (Linux 5.14+), killing descendants that escaped their process group, and `read_events` names an OOM kill before `rmdir` takes the counters. Fails open on any deployment without `Delegate=` and says so at startup. stdlib-only leaf, roots are parameters, never raises
├── shell_exec.py         # `shell_argv(command)` — how a command string becomes a shell argv, with `pipefail` on. Three callers treat the exit status as the answer and each ran under a shell that starts with the option off, so a pipeline reported its *last* stage: the native brain's Bash tool (which appends `[exit code: N]` to what the model reads), the scheduler's CRON `command:` rows (whose status drives auto-disable after five consecutive failures) and the heartbeat's `shell-command` checks (whose status is the health verdict). `<runner> … | tail` came back 0 on a run that failed, and each of those three consequences followed from a number wrong in the reassuring direction — the same defect ISSUE-307 fixed in `devbox exec`, which is why the rule lives here rather than being written a fourth time. `bash` is named rather than probed by the sandboxed caller: bubblewrap binds `/usr` and need not reproduce the host's `/bin` symlink, so an absolute path resolved on the host is not necessarily a path in the namespace where the command runs. The fallback is `/bin/sh -c`, which is exactly what `shell=True` did before, so a host without bash loses nothing — but it also silently gets none of the fix, so the fallback logs once. **It is an interpreter swap and `pipefail` is one consequence**: bash and dash also differ on `echo` backslash expansion and on `$0`, and bash sources `$BASH_ENV` for a non-interactive shell where dash sources nothing, which is why `executor.build_stripped_env` strips that one variable. **`pipefail_env()` is the same rule delivered through the environment instead of the argv** (ISSUE-321), and it exists because the largest consumer of shells in the deployment spawns its own: a `ClaudeCodeBrain` or `TmuxClaudeBrain` task runs its commands through the Claude Code CLI's Bash tool — `bash -c 'source <shell-snapshot> && eval <cmd>'`, in a process istota launches and does not instrument — so `shell_argv` cannot reach it and that shell started with the option off, on the surface where the great majority of tool calls happen. It returns `{"SHELLOPTS": "pipefail"}`, which bash imports at startup, and `executor.build_clean_env` applies it last. `SHELLOPTS` rather than `BASH_ENV` because it names shell *options* and cannot name a file to source, so it opens no exec inlet — `pipefail:$(touch /tmp/x)` is rejected as an invalid option name rather than evaluated — and `_SHELL_STARTUP_ENV_VARS` (now `BASH_ENV`, `SHELLOPTS`, `BASHOPTS`) is filtered out of *both* env builders first, so no inherited value survives to be trusted. Being inherited rather than a flag it also reaches a pipeline inside a nested `bash script.sh`, which `-o pipefail` does not; it reaches nothing that is not bash, so a `#!/bin/sh` script gets it on macOS (bash in sh-mode) and not on Debian (dash). The cron and heartbeat paths deliberately keep flag depth only — those commands are operator-authored. `is_sigpipe_failure` is the text predicate the scheduler classifies on — same shape as `is_signal_termination` — so a cron row killed by SIGPIPE is failed rather than retried, because the ladder re-runs the whole command string and its producer already ran. Also `SIGPIPE_EXIT` / `SIGPIPE_NOTE`: 141 is the one new status with a fixed code, so it is the one that can be annotated; the other cost — a non-final stage exiting non-zero to *report* something, so `grep -c x f | wc -l` returns 1 where it returned 0 — has no marker and is documented instead. stdlib-only leaf, imports nothing from the package, never raises
├── process_group.py      # `kill_process_group(pid, sig)` — signal a subprocess and every descendant sharing its group, falling back to the single process when the pid leads no group of its own (a non-leader shares the daemon's group, so signalling it would kill the scheduler; both of today's `worker_pid` writers record leaders, so the fallback guards a future caller rather than either brain). Used by both kill paths in ClaudeCodeBrain's streaming spawn, by `!stop` and the web cancel endpoint, and by the scheduler / native-bash timeout kills. Never raises — the brain's timeout calls it from a `threading.Timer` callback, where an exception would report a timeout while leaving the process alive. stdlib-only leaf
├── network_proxy.py      # CONNECT proxy for network isolation
├── forge_cli.py          # The `gh` / `glab` wrapper. One file serving the sandbox and the devbox (`docker/devbox/lib/istota_forge_cli.py` is a byte-identical copy kept in sync by `scripts/sync-devbox-lib.sh`): it decides which forge it is from argv[0], checks the argv against a code-owned deny policy, fetches the token from whichever credential socket is present, and execs the real binary with the token in its own environment. It locates its policy beside the copy of itself that is executing and takes `real_bin`, the forge URL and the config dirs from that file — never from `os.environ`, since the wrapper runs as a child of the model's own shell. Refuses rather than falling back to a public host when no URL resolves. stdlib-only leaf
├── devbox_proxy.py       # Per-user host-side daemon: git credentials injected server-side, plus the forge token (and its URL) for the container's `gh` / `glab`. Makes no outbound requests of its own
├── devbox_proxy_protocol.py  # Wire protocol for devbox_proxy (single-line JSON, 16 MiB cap)
├── devbox_exec_protocol.py   # The exec transport's wire format, and the only place it is written down on the daemon side (`docker/devbox/lib/istota_devbox_exec_protocol.py` is the byte-identical vendored copy, kept in sync by `scripts/sync-devbox-lib.sh`). One JSON request line, one JSON acknowledgement line, then 8-byte-framed binary in both directions — stream 0 stdin and a `write_file` body, 1 stdout and a `read_file` body, 2 stderr, 3 a JSON control object. **There is no `env` field and there must never be one**: it is not a boundary (a hand-written client sends what it likes, and `dev` has passwordless sudo), and the version that forwarded the model's environment would have overwritten the container's own cache placement with sandbox paths that do not exist there. The caches are set on the container at start instead, where there is one spelling per deployment. The last frame is always a control frame reporting what `waitpid` said, and `signal` is set only for a child the kernel actually signalled — never inferred from `128+N`, because `yes | head -1` under `bash -o pipefail -c` makes bash *exit* 141 and reporting SIGPIPE there would fabricate a fact in the one subsystem with three wrong-exit-status bugs behind it. The hint travels as a `note`, exactly as `shell_exec.py` handles the same status. stdlib-only leaf, no sockets, no asyncio
├── devbox_exec_client.py     # The other end, copied into each task's shim directory at setup and exec'd by the shims. Takes its working directory from `os.getcwd()` rather than `$PWD` — `$PWD` is the *logical* path, so a `cd` through a symlink means something different from where the process is, and the server's containment test is a `realpath`. **The one contract it exists to keep is that a command whose fate is unknown is never reported as 0**: a connection that ends after the acknowledgement and before the terminal frame exits 123 and says so, which is what a container restart or an OOM of the container itself looks like. 120 is nothing-ran and covers two causes rather than one — the socket could not be reached, *or* the server refused the request in its acknowledgement, which on the shim path most often means a working directory outside the repos root; the stderr line is what separates them. 121 the server's protocol is one this client does not know, 122 a malformed frame. Not 125/126/127, which collide with `docker`'s own status and with the shell's "not executable" and "command not found". No signal handlers: nothing in the daemon ever sends this process a signal it could catch, and an operator's Ctrl-C already kills it, closes the connection and makes the server reap. stdlib-only, imports only the protocol
├── nextcloud_api.py      # NC user metadata
├── provision_rooms.py    # Default Talk rooms (general/logs/alerts) for a user: reuse by remembered token first, participant-scoped name lookup only on a first provision, group (not public) rooms, and seeding log_channel/alerts_channel only where empty. Behind `istota nextcloud provision-rooms`, called by the Ansible role on **every** deploy — the bare-metal counterpart to the docker entrypoint. The token record is the ISSUE-342 fix and it lives in the reserved `_provisioned_rooms` KV namespace rather than a new table: a display-name match cannot survive a rename, and the name belongs to the user, so renaming `general` to `#general` in the web UI (which propagates to Talk) left the next deploy unable to recognise the room and minting a second one. `general` was the reliable victim — `pending_channel_rooms` drops `logs` and `alerts` from the work list once their profile columns hold a token, and `CHANNEL_FIELDS` gives `general` no column, so it was the one name re-derived from Talk every time. Reusing a remembered room never counts as `seedable`, so a channel column the user cleared on purpose is not refilled. The docker entrypoint never had the bug: it persists `GENERAL_TOKEN` in its provisioning flag file and skips the lookup, reusing a token where this reused a name. **The record carries the invite outcome as well as the token, and the orphan-adoption retry is gated on it** (ISSUE-408): a bot-only room is equally what a failed invite leaves behind and what a user leaves by walking out of their own `general`, so the participant list cannot settle which — and reading it as the first meant every deploy put them back, for ever, with no column to clear as an opt-out. Only a token whose last invite is recorded as having failed is retried; the name-matching arm is untouched, since a room with no record has no outcome to contradict. Three consequences, each a decision: a pre-ISSUE-408 record carries no outcome and is read as "no failure", so leaving works from the first deploy after the upgrade while a genuinely stranded room is no longer auto-retried — it reports `user not a member` rather than `existing`, and clearing its KV key returns it to the name arm. A run that observed nothing (`_is_orphan` treats an empty participant list as a failed read) carries the previous outcome forward instead of writing its own silence as success, or one transient Talk error would erase a recorded failure and disable the retry permanently — which is why `record_invite_failed` ("is an invite outstanding", what is persisted) is a different property from `invite_failed` ("did *this run* try and fail", what the CLI warning and the Ansible `failed_when` read). And `--adopt` records no failure, since it never contacts Talk
├── nextcloud/            # OCS + WebDAV client: _http (ocs_request/dav_request/OcsError/path scoping), capabilities, shares, users, dav, notifications
├── nextcloud_client.py   # Back-compat shim: the None-returning variants four best-effort daemon paths depend on
├── storage.py            # Bot-managed Nextcloud storage
├── briefings/            # Block/source briefings module — DB, source resolvers, generation, reader/settings routes, migration
├── feeds/                # Native RSS/Atom/Tumblr/Are.na — poller, SQLite, routes, OPML, image_dedupe (repeat-image suppression)
├── health/               # Body stats, bloodwork, biomarker trends, encounters, immunizations, Garmin, OCR
├── location/             # Per-user location.db module (pings, places, visits, state, migration)
├── location_logic.py     # Place stats / cluster discovery (shared web ⇄ skill)
├── scheduler_deferred.py # Deferred-op replay (subtasks, KG, KV, health_ops, …)
├── shared_file_organizer.py
├── commands.py           # surface-agnostic !command dispatch (CommandContext + registry push/stream)
├── toml_fence.py         # Where a ```toml fence starts and ends, for the four modules that parse one out of a user-written markdown file: `cron_loader` (CRON.md), `heartbeat` (HEARTBEAT.md), `user_briefings` (BRIEFINGS.md) and `money._config_io`. All four carried the same expression by copy and all four had the same defect (ISSUE-386) — neither marker anchored to a line, so the block ended at the first backtick run appearing anywhere after the fence opened, in a comment or inside a string value. What that cost differed per caller, which is why it was only ever found in one: `cron_loader` drives an orphan sweep, so a truncation landing on a table boundary produced *valid TOML holding a subset of the jobs* and the sweep deleted the rest, silently and for good; the other three merely dropped a check or a briefing. **Every bound is loose on purpose.** The expression this replaced had no `^` at all, so it accepted any prefix — which makes almost any bound a narrowing, and a narrowing breaks a file that used to work. So the indent is unbounded rather than CommonMark's three spaces, a marker is `` `{3,} `` on either side, and the trailing class is `[^\S\n]`, which is what carries a CRLF's `\r` and the non-breaking space a paste from a rendered page leaves; a leading BOM is named separately because it is not `\s`. None of that weakens the fix, which is only that a marker must be alone on its line. **Two searches rather than one expression, and that is not a refactor**: the obvious `open(.*?)close` form is quadratic when no closer matches, since every opener is a fresh start position that rescans to EOF — measured at 4.2s for 64 KB and 65.8s for 256 KB, on files that are user-writable over the mount and that `cron_loader`'s caller parses on the scheduler's own tick with no timeout. What it deliberately does **not** own is what a caller does when it finds no block: `cron_loader` uses the markers directly and keeps its own hold guard (`BACKTICK_RUN_RE`), because a file it cannot resolve must never read as "the user has authored no jobs" — that verdict is `is_template`, and it authorizes `_sync_cron_files` to rewrite the whole document from the table. The other three have no such branch and take `find_toml_block`. stdlib-only leaf
├── cron_loader.py        # CRON.md → DB sync
└── logging_setup.py

Read the full file on GitHub · 266 lines

Changes

What this file has done since we first saw it

Hashed on every crawl. A supply-chain change to an agent config is a question of when, not whether, so the history is kept rather than the latest state alone.

  1. today Changed · +8 lines · +521 tokens per session 63e0146209d7
  2. yesterday Changed · +21 lines · +12,991 tokens per session 2e21acafa8dc
  3. 5d ago First seen · 237 lines · 20,699 tokens per session scan C f830072535e3

Subscribe to this mod's changes

istota AGENTS.md is an instructions file published in the GitHub repository istota-project/istota (12 stars, last pushed yesterday), licensed MIT. It adds 34,211 tokens to every session, about $0.1711 per session on Opus 5. A static security scan graded it C with 2 findings (recursive force delete, makes network calls). No closer match exists in the catalogue, so it is treated as the original; first seen 2026-08-30.