brainiac-cli 0.18.2__tar.gz → 0.19.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {brainiac_cli-0.18.2/src/brainiac_cli.egg-info → brainiac_cli-0.19.1}/PKG-INFO +1 -1
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/pyproject.toml +1 -1
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_assets/AGENTS.md +5 -1
- brainiac_cli-0.19.1/src/brain/_assets/overlay/template/cos/drafts.md +25 -0
- brainiac_cli-0.19.1/src/brain/_assets/overlay/template/cos/priorities.md +42 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_assets/scripts/brain-brief.sh +8 -6
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_assets/scripts/brain-synthesis.sh +18 -2
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_version.py +1 -1
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/classification.py +25 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/cli.py +24 -2
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/core.py +54 -2
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/cos.py +183 -3
- brainiac_cli-0.19.1/src/brain/cos_chips.py +361 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/doctor.py +60 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/index.py +50 -1
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/maintenance.py +70 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1/src/brainiac_cli.egg-info}/PKG-INFO +1 -1
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brainiac_cli.egg-info/SOURCES.txt +2 -0
- brainiac_cli-0.18.2/src/brain/_assets/overlay/template/cos/priorities.md +0 -19
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/LICENSE +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/MANIFEST.in +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/README.md +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/setup.cfg +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/__init__.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/__main__.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_assets/overlay/template/brand/brand-guide.md +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_assets/overlay/template/keywords/glossary.md +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_assets/overlay/template/people/roster.md +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_assets/overlay/template/voice/voice-profile.md +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_assets/routines/manifest.json +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_assets/scripts/brain-brief-mac.plist +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_assets/scripts/brain-synthesis-mac.plist +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_assets/scripts/install-brief-mac.sh +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_assets/scripts/install-brief-windows.ps1 +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_assets/scripts/register_tasks.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_assets/templates/company.md +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_assets/templates/concept.md +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_assets/templates/daily.md +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_assets/templates/decision.md +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_assets/templates/meeting.md +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_assets/templates/person.md +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_assets/templates/project.md +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_assets/templates/state-moc.md +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/anchor.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/audit.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/backup.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/brief.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/capture.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/chunk.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/config.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/connect.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/context.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/egress.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/embed.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/encryption.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/frontmatter.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/golden_probe.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/graph.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/graphify.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/inbox.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/ingest/__init__.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/ingest/handlers/__init__.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/ingest/handlers/base.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/ingest/handlers/docx.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/ingest/handlers/email.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/ingest/handlers/html.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/ingest/handlers/image.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/ingest/handlers/pdf.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/ingest/handlers/pptx.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/ingest/handlers/tables.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/ingest/handlers/text.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/ingest/handlers/xlsx.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/ingest/handlers/zip.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/ingest/pipeline.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/ingest/transcript.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/init.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/mcp_adapter.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/multihop.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/notes.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/overlay.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/projection.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/rerank.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/retro.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/snapshot.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/spine.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/update.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/vectors.py +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brainiac_cli.egg-info/dependency_links.txt +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brainiac_cli.egg-info/entry_points.txt +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brainiac_cli.egg-info/requires.txt +0 -0
- {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brainiac_cli.egg-info/top_level.txt +0 -0
|
@@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta"
|
|
|
8
8
|
# check. The IMPORT package stays `brain` and the console script stays
|
|
9
9
|
# `brain` — only the distribution/project name changes.
|
|
10
10
|
name = "brainiac-cli"
|
|
11
|
-
version = "0.
|
|
11
|
+
version = "0.19.1"
|
|
12
12
|
description = "Brainiac — local any-LLM second-brain core engine + brain CLI (Markdown truth, derived SQLite index)."
|
|
13
13
|
readme = "README.md"
|
|
14
14
|
license = "Apache-2.0"
|
|
@@ -356,7 +356,11 @@ rules, in order:
|
|
|
356
356
|
`-- N withheld …` line in text): when you see it, **re-run with
|
|
357
357
|
`--max-tier Restricted`** (or `MNPI` for the most sensitive) — the
|
|
358
358
|
human-gated elevation — instead of concluding the vault has nothing and
|
|
359
|
-
web-searching to compensate.
|
|
359
|
+
web-searching to compensate. **On `--role vm` this elevation is NOT
|
|
360
|
+
self-serve:** the VM leg clamps `--max-tier` to a hard ceiling
|
|
361
|
+
(`$BRAIN_VM_MAX_EGRESS_TIER`, default `Internal`), so a typed higher tier is
|
|
362
|
+
silently capped and the elevation hint is suppressed — raising a VM's ceiling
|
|
363
|
+
is a host-operator action, not something the model does on its own.
|
|
360
364
|
|
|
361
365
|
3. **Never leak internal topics into a web search.** A web query for a
|
|
362
366
|
Confidential-or-above subject — a deal codename, a counterparty, an internal
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
---
|
|
2
|
+
overlay_type: cos
|
|
3
|
+
title: "Reply drafts — owner setting"
|
|
4
|
+
setting: drafts
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
enabled: true
|
|
8
|
+
|
|
9
|
+
## What this controls
|
|
10
|
+
|
|
11
|
+
Whether the nightly chief-of-staff composes reply drafts into your Drafts
|
|
12
|
+
folder for threads that warrant a response. It NEVER sends — sending is yours,
|
|
13
|
+
structurally.
|
|
14
|
+
|
|
15
|
+
- `enabled: true` (or this file absent) — compose drafts, cap 10/run.
|
|
16
|
+
- `enabled: false` — no drafts; the run spends that budget on battlecards.
|
|
17
|
+
|
|
18
|
+
## Why this file exists
|
|
19
|
+
|
|
20
|
+
Without it a run has nowhere to read your answer, so a low draft-engagement
|
|
21
|
+
count invites it to quietly stop drafting and ask you again every night. Your
|
|
22
|
+
ruling lives here instead: durable, and outside the rolling skill-memory cap.
|
|
23
|
+
|
|
24
|
+
Engagement (did you send/edit a draft?) is still measured and reported. It is
|
|
25
|
+
DATA, not a verdict — an untouched draft may simply mean you have not looked.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
---
|
|
2
|
+
overlay_type: cos
|
|
3
|
+
title: "COS priority overrides — starter template"
|
|
4
|
+
updated: 2026-07-13
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# COS priority overrides
|
|
8
|
+
|
|
9
|
+
OPTIONAL category (CUT-01E). `brain cos-priority-map` generates the
|
|
10
|
+
VM-readable priority map from your `type: person` / `type: company` notes;
|
|
11
|
+
list ids below to override the computed placement. One list line per note:
|
|
12
|
+
|
|
13
|
+
`- <note-id>: high|normal|low|exclude`
|
|
14
|
+
|
|
15
|
+
<!-- examples (replace with your own note ids):
|
|
16
|
+
- contoso-acquisition-lead: high
|
|
17
|
+
- northwind-vendor-contact: low
|
|
18
|
+
- retired-supplier-x: exclude
|
|
19
|
+
-->
|
|
20
|
+
|
|
21
|
+
## Priority-chip taxonomy (chief-of-staff kernel v4.6 — companion block)
|
|
22
|
+
|
|
23
|
+
The nightly run projects the act queue onto exactly three Outlook
|
|
24
|
+
categories (recorded verbatim — category names are IMMUTABLE once created;
|
|
25
|
+
a rename means delete + recreate + re-chip every tagged message):
|
|
26
|
+
|
|
27
|
+
- `P0 · Now` — red
|
|
28
|
+
- `P1 · Today` — orange
|
|
29
|
+
- `P2 · This week` — blue
|
|
30
|
+
|
|
31
|
+
`chips_confirmed` is the RUNTIME chip gate the nightly reads: chips are
|
|
32
|
+
withheld (the legacy flat Action mark continues) until the owner's recorded
|
|
33
|
+
YES to the queued name/color confirmation question, at which point the
|
|
34
|
+
answering session sets:
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
chips_confirmed: false
|
|
38
|
+
chips_confirmed_date:
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
(The answering session flips the value to `true` and dates it. Never
|
|
42
|
+
pre-fill `true` — an uncommented `true` line IS the gate opening.)
|
|
@@ -63,12 +63,14 @@ fi
|
|
|
63
63
|
|
|
64
64
|
{
|
|
65
65
|
echo "=== brain-daily-brief / brain-nightly $(date -u +%Y-%m-%dT%H:%M:%SZ) ==="
|
|
66
|
-
# cos-broker
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
66
|
+
# cos-broker first — CORRECTION (2026-07-17): `brain maintain` ALSO runs the
|
|
67
|
+
# fold + the ingest sweep (core.py maintain, the CUT-01E block); the original
|
|
68
|
+
# claim here that "nothing else schedules it" was WRONG. The 2026-07-16 field
|
|
69
|
+
# failure (spine DB empty, candidates unclaimed ~29h) was actually the plist
|
|
70
|
+
# pinning a 0.17.0 engine whose fold predates the spine stage — maintain was
|
|
71
|
+
# calling the fold hourly all along, at a version that couldn't render it.
|
|
72
|
+
# This explicit call stays as defense-in-depth: idempotent, cheap, and it
|
|
73
|
+
# keeps the drain floor if maintain's internal block ever regresses.
|
|
72
74
|
# NON-FATAL: a broker failure must never cost the vault its capture-drain floor.
|
|
73
75
|
"$BRAIN_BIN" cos-broker --json || echo "brain-brief: cos-broker fold failed (non-fatal), continuing to maintain"
|
|
74
76
|
# maintain: sync --publish (drain pending captures + reconcile index +
|
|
@@ -28,7 +28,10 @@ mkdir -p "$LOG_DIR"
|
|
|
28
28
|
LOG="$LOG_DIR/synthesis-$(date +%F).log"
|
|
29
29
|
REGISTRY="${BRAIN_WORKSPACES_JSON:-$HOME/.brainiac/workspaces.json}"
|
|
30
30
|
CLAUDE_BIN="${BRAIN_CLAUDE_BIN:-$(command -v claude || echo "$HOME/.local/bin/claude")}"
|
|
31
|
-
|
|
31
|
+
# 120, not 60: the 2026-07-19 08:00 run died error_max_turns at 61 while the
|
|
32
|
+
# 22:26 rerun needed 96 turns for the same scope — 60 cannot fit a real
|
|
33
|
+
# synthesis pass over a week's transcript batch.
|
|
34
|
+
MAX_TURNS="${BRAIN_SYNTHESIS_MAX_TURNS:-120}"
|
|
32
35
|
|
|
33
36
|
log() { printf '%s %s\n' "$(date '+%F %T')" "$*" >> "$LOG"; }
|
|
34
37
|
|
|
@@ -95,17 +98,30 @@ PY
|
|
|
95
98
|
# A per-run unique name (date + pid + two $RANDOM draws) avoids the
|
|
96
99
|
# same-second collision the old date-second+pid name allowed (finding [9]).
|
|
97
100
|
OUT_JSON="$LOG_DIR/synthesis-out-$(date +%F)-$$-${RANDOM}${RANDOM}.json"
|
|
101
|
+
# Allowlist covers the read-only diagnostics the watchdog prompt itself
|
|
102
|
+
# requires (logs, state files, index probes) — the 2026-07-19 runs burned
|
|
103
|
+
# 10+ turns on ls/cat/find/sqlite3/python3 denials. `Bash(python3 *)` is
|
|
104
|
+
# not a widening in practice: Write + `Bash(python3 tools/*)` already
|
|
105
|
+
# allowed arbitrary code by writing a file into tools/ first.
|
|
98
106
|
( cd "$WS" && BRAIN_VAULT="$VAULT" "$CLAUDE_BIN" -p "$PROMPT" \
|
|
99
107
|
--max-turns "$MAX_TURNS" \
|
|
100
108
|
--permission-mode acceptEdits \
|
|
101
109
|
--output-format stream-json \
|
|
102
110
|
--verbose \
|
|
103
|
-
--allowedTools "Read,Grep,Glob,Edit,Write,Bash(brain *),Bash(python3
|
|
111
|
+
--allowedTools "Read,Grep,Glob,Edit,Write,Bash(brain *),Bash(python3 *),Bash(ls *),Bash(cat *),Bash(head *),Bash(tail *),Bash(find *),Bash(grep *),Bash(wc *),Bash(sed *),Bash(sort *),Bash(uniq *),Bash(file *),Bash(diff *),Bash(sqlite3 *)" \
|
|
104
112
|
) < /dev/null > "$OUT_JSON" 2>>"$LOG"
|
|
105
113
|
# < /dev/null: claude -p hangs forever reading a non-TTY stdin — and inside
|
|
106
114
|
# this while-read loop it would otherwise inherit (and eat) the registry pipe
|
|
107
115
|
RC=$?
|
|
108
116
|
log "END synthesis: vault=$VAULT rc=$RC"
|
|
117
|
+
# Immediate failure ping (owner ask 2026-07-19): WATCHDOG-01 only trips at
|
|
118
|
+
# >8 days stale, which let two consecutive Sunday failures hide for two
|
|
119
|
+
# weeks. Best-effort banner, never fails the run; the durable channels are
|
|
120
|
+
# the log line above + synthesis-state.json (read by the session-start
|
|
121
|
+
# alerts hook).
|
|
122
|
+
if [ "$RC" -ne 0 ] && command -v osascript >/dev/null 2>&1; then
|
|
123
|
+
osascript -e "display notification \"weekly synthesis FAILED (rc=$RC) for $VAULT — see $LOG\" with title \"Brainiac health\"" >/dev/null 2>&1 || true
|
|
124
|
+
fi
|
|
109
125
|
# Render a human-readable transcript from the structured NDJSON into $LOG
|
|
110
126
|
# (finding [5]): the prior `tee`'d raw NDJSON REPLACED the readable session
|
|
111
127
|
# transcript the weekly-watchdog prompt tells an operator to open. Structured
|
|
@@ -44,6 +44,31 @@ if DEFAULT_MAX_TIER not in RANK:
|
|
|
44
44
|
# Public + Internal only unless a human explicitly elevates with --max-tier.
|
|
45
45
|
VM_DEFAULT_MAX_TIER = "Internal"
|
|
46
46
|
|
|
47
|
+
# Hard server-side ceiling for the untrusted VM leg (codex 2026-07-19). On
|
|
48
|
+
# role=vm, --max-tier is a caller-controlled argument the LLM can set itself;
|
|
49
|
+
# without a clamp the egress-starvation hint ("re-run with --max-tier
|
|
50
|
+
# Restricted") turns the intended HUMAN-gated elevation into an instruction the
|
|
51
|
+
# model follows autonomously. This is the CLI analogue of the MCP adapter's
|
|
52
|
+
# BRAIN_MAX_EGRESS_TIER: a VM caller can never exceed this ceiling regardless of
|
|
53
|
+
# what it types. The host operator raises it deliberately via the env var (the
|
|
54
|
+
# real human gate); an unrecognised value falls back to VM_DEFAULT_MAX_TIER.
|
|
55
|
+
VM_EGRESS_CEILING_ENV = "BRAIN_VM_MAX_EGRESS_TIER"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def vm_egress_ceiling() -> str:
|
|
59
|
+
"""The tier a role=vm caller may never exceed. Operator-settable via
|
|
60
|
+
$BRAIN_VM_MAX_EGRESS_TIER; defaults to the conservative VM cap."""
|
|
61
|
+
val = os.environ.get(VM_EGRESS_CEILING_ENV, "").strip()
|
|
62
|
+
return val if val in RANK else VM_DEFAULT_MAX_TIER
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def clamp_to(requested: str, ceiling: str) -> str:
|
|
66
|
+
"""Return the lower (less-sensitive) of two tiers by rank — a caller can
|
|
67
|
+
never widen egress past the ceiling, only narrow within it."""
|
|
68
|
+
req = requested if requested in RANK else DEFAULT_DENY_TIER
|
|
69
|
+
ceil = ceiling if ceiling in RANK else VM_DEFAULT_MAX_TIER
|
|
70
|
+
return req if RANK[req] <= RANK[ceil] else ceil
|
|
71
|
+
|
|
47
72
|
|
|
48
73
|
def normalize(value: object) -> str:
|
|
49
74
|
"""Map a raw frontmatter value to a recognised tier, default-deny on miss."""
|
|
@@ -164,6 +164,12 @@ def _emit(obj: Any, as_json: bool, human: str | None = None) -> None:
|
|
|
164
164
|
sys.stdout.write((human if human is not None else str(obj)) + "\n")
|
|
165
165
|
|
|
166
166
|
|
|
167
|
+
# Set True by main() on role=vm: the untrusted leg must not be told to
|
|
168
|
+
# "re-run with --max-tier Restricted" — that hint is the self-elevation nudge
|
|
169
|
+
# codex flagged, and the VM ceiling clamp makes the instruction a no-op anyway.
|
|
170
|
+
_SUPPRESS_ELEVATION_HINT = False
|
|
171
|
+
|
|
172
|
+
|
|
167
173
|
def _filter_dicts(items: list[dict], max_tier: str) -> tuple[list[dict], dict]:
|
|
168
174
|
# THE single egress chokepoint — every content-returning subcommand routes
|
|
169
175
|
# through egress.apply_gate so a new content path cannot silently bypass the
|
|
@@ -174,7 +180,8 @@ def _filter_dicts(items: list[dict], max_tier: str) -> tuple[list[dict], dict]:
|
|
|
174
180
|
# web search — leaking internal topics outward. Say WHY it's thin and HOW to
|
|
175
181
|
# elevate, in the report dict so it surfaces in BOTH --json (agent-facing)
|
|
176
182
|
# and the text footer. The tier stays the human gate; this only signposts it.
|
|
177
|
-
if report.get("withheld", 0) > 0 and max_tier != cls.TIERS[-1]
|
|
183
|
+
if (report.get("withheld", 0) > 0 and max_tier != cls.TIERS[-1]
|
|
184
|
+
and not _SUPPRESS_ELEVATION_HINT):
|
|
178
185
|
report["hint"] = (
|
|
179
186
|
f"{report['withheld']} note(s) withheld above the {max_tier} cap — "
|
|
180
187
|
f"re-run with --max-tier Restricted (or MNPI for the most sensitive) "
|
|
@@ -1080,6 +1087,7 @@ VM_ALLOWED = frozenset({
|
|
|
1080
1087
|
|
|
1081
1088
|
|
|
1082
1089
|
def main(argv: list[str] | None = None) -> int:
|
|
1090
|
+
global _SUPPRESS_ELEVATION_HINT
|
|
1083
1091
|
try:
|
|
1084
1092
|
return _main(argv)
|
|
1085
1093
|
except Exception as exc: # H-4: top-level guard -- never a raw traceback
|
|
@@ -1088,6 +1096,11 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1088
1096
|
_emit({"error": type(exc).__name__, "detail": str(exc)} if as_json
|
|
1089
1097
|
else f"{exc.__class__.__name__}: {exc}", as_json)
|
|
1090
1098
|
return 3
|
|
1099
|
+
finally:
|
|
1100
|
+
# The VM hint-suppression flag is INVOCATION-scoped (set by _main per
|
|
1101
|
+
# role): reset it here so it never leaks into a later main() call or a
|
|
1102
|
+
# direct _filter_dicts caller in the same process (e.g. across tests).
|
|
1103
|
+
_SUPPRESS_ELEVATION_HINT = False
|
|
1091
1104
|
|
|
1092
1105
|
|
|
1093
1106
|
def _main(argv: list[str] | None = None) -> int:
|
|
@@ -1103,6 +1116,14 @@ def _main(argv: list[str] | None = None) -> int:
|
|
|
1103
1116
|
if getattr(args, "max_tier", "unset") is None:
|
|
1104
1117
|
args.max_tier = (cls.VM_DEFAULT_MAX_TIER if role == config.ROLE_VM
|
|
1105
1118
|
else cls.DEFAULT_MAX_TIER)
|
|
1119
|
+
# VM egress ceiling (codex 2026-07-19): on the untrusted leg, --max-tier is
|
|
1120
|
+
# a value the LLM can set itself, and the starvation hint nudges it to. Clamp
|
|
1121
|
+
# any VM-side max_tier to the operator-set ceiling so a typed --max-tier MNPI
|
|
1122
|
+
# cannot self-elevate past it; the hint is also suppressed for role=vm below.
|
|
1123
|
+
global _SUPPRESS_ELEVATION_HINT
|
|
1124
|
+
_SUPPRESS_ELEVATION_HINT = (role == config.ROLE_VM) # deterministic per call
|
|
1125
|
+
if role == config.ROLE_VM and hasattr(args, "max_tier"):
|
|
1126
|
+
args.max_tier = cls.clamp_to(str(args.max_tier), cls.vm_egress_ceiling())
|
|
1106
1127
|
# DV-03: the VM leg fails closed on a dead embedder rather than silently
|
|
1107
1128
|
# answering semantic queries with random hash vectors (no-op when a real
|
|
1108
1129
|
# embedder is present or hash was chosen explicitly).
|
|
@@ -1371,7 +1392,8 @@ def _main(argv: list[str] | None = None) -> int:
|
|
|
1371
1392
|
+ srep["withheld_unlabelled_default_deny"],
|
|
1372
1393
|
"max_tier": args.max_tier,
|
|
1373
1394
|
}
|
|
1374
|
-
if report["withheld"] > 0 and args.max_tier != cls.TIERS[-1]
|
|
1395
|
+
if (report["withheld"] > 0 and args.max_tier != cls.TIERS[-1]
|
|
1396
|
+
and not _SUPPRESS_ELEVATION_HINT):
|
|
1375
1397
|
report["hint"] = (
|
|
1376
1398
|
f"{report['withheld']} note(s) withheld above the "
|
|
1377
1399
|
f"{args.max_tier} cap — re-run with a higher --max-tier.")
|
|
@@ -2125,19 +2125,59 @@ class BrainCore:
|
|
|
2125
2125
|
"""Append ``entry_md`` to ``hot.md`` guarded by an idempotency-key
|
|
2126
2126
|
HTML comment; a no-op (returns ``False``) if the key is already
|
|
2127
2127
|
present — the per-branch/per-run-date idempotency guard for every
|
|
2128
|
-
scheduled fold that queues a hot-queue entry (HARDENED:codex).
|
|
2128
|
+
scheduled fold that queues a hot-queue entry (HARDENED:codex).
|
|
2129
|
+
|
|
2130
|
+
Two shared gates live HERE so every fold renderer inherits them:
|
|
2131
|
+
(1) vault-absolute paths are rewritten vault-relative (retro
|
|
2132
|
+
signature ``absolute-paths`` — an absolute path in fold output goes
|
|
2133
|
+
stale on a vault move); (2) the idempotency check also scans rotated
|
|
2134
|
+
``archive/hot-*.md`` segments, so an entry rotated out by
|
|
2135
|
+
``_rotate_hot_md`` is never re-appended under the same key."""
|
|
2136
|
+
import os as _os
|
|
2129
2137
|
path = self._hot_md_path()
|
|
2130
2138
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
2139
|
+
for root in {str(self.vault), str(Path(self.vault).resolve())}:
|
|
2140
|
+
entry_md = entry_md.replace(root.rstrip(_os.sep) + _os.sep, "")
|
|
2131
2141
|
marker = f"<!-- idempotency-key: {key} -->"
|
|
2132
2142
|
existing = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
2133
2143
|
if marker in existing:
|
|
2134
2144
|
return False
|
|
2145
|
+
# ponytail: linear scan of rotated segments; index them if they ever
|
|
2146
|
+
# number in the hundreds.
|
|
2147
|
+
for seg in sorted((path.parent / "archive").glob("hot-*.md")):
|
|
2148
|
+
try:
|
|
2149
|
+
if marker in seg.read_text(encoding="utf-8"):
|
|
2150
|
+
return False
|
|
2151
|
+
except OSError:
|
|
2152
|
+
continue
|
|
2135
2153
|
with path.open("a", encoding="utf-8") as fh:
|
|
2136
2154
|
if existing and not existing.endswith("\n"):
|
|
2137
2155
|
fh.write("\n")
|
|
2138
2156
|
fh.write(marker + "\n" + entry_md.rstrip("\n") + "\n\n")
|
|
2139
2157
|
return True
|
|
2140
2158
|
|
|
2159
|
+
def _rotate_hot_md(self, today: Any) -> bool:
|
|
2160
|
+
"""Rotate aged, resolved hot.md entries into ``archive/hot-<date>.md``
|
|
2161
|
+
once the live file exceeds the soft cap (retro signature
|
|
2162
|
+
``hot-md-bloat``) — same auto-rotate posture handoff.md already has.
|
|
2163
|
+
Pure judgment lives in ``maintenance.rotate_hot_md``; this method is
|
|
2164
|
+
the file I/O. Returns True when a rotation happened."""
|
|
2165
|
+
from . import maintenance as maint
|
|
2166
|
+
path = self._hot_md_path()
|
|
2167
|
+
if not path.exists():
|
|
2168
|
+
return False
|
|
2169
|
+
kept, rotated = maint.rotate_hot_md(
|
|
2170
|
+
path.read_text(encoding="utf-8"), today)
|
|
2171
|
+
if not rotated:
|
|
2172
|
+
return False
|
|
2173
|
+
archive_dir = path.parent / "archive"
|
|
2174
|
+
archive_dir.mkdir(parents=True, exist_ok=True)
|
|
2175
|
+
seg = archive_dir / f"hot-{today.isoformat()}.md"
|
|
2176
|
+
with seg.open("a", encoding="utf-8") as fh:
|
|
2177
|
+
fh.write(rotated)
|
|
2178
|
+
path.write_text(kept, encoding="utf-8")
|
|
2179
|
+
return True
|
|
2180
|
+
|
|
2141
2181
|
# -- Tier-2 owner question queue (PUSH redesign, 2026-07-13) --------------
|
|
2142
2182
|
def _inbox_path(self) -> Path:
|
|
2143
2183
|
return config.memory_dir(self.vault) / "inbox.jsonl"
|
|
@@ -2970,8 +3010,13 @@ class BrainCore:
|
|
|
2970
3010
|
curate_res["stale_links"], curate_res["revisit_sample"], d),
|
|
2971
3011
|
)
|
|
2972
3012
|
if promote_res.get("candidates"):
|
|
3013
|
+
# Idempotency key = hash of the DISTINCT candidate-id
|
|
3014
|
+
# set, NOT the run date — an unchanged candidate set
|
|
3015
|
+
# isn't re-reported every run under a fresh key
|
|
3016
|
+
# (retro signature: duplicate-findings).
|
|
2973
3017
|
self._append_hot_once(
|
|
2974
|
-
|
|
3018
|
+
"maintain:promote-scan:"
|
|
3019
|
+
+ maint.promote_scan_finding_key(promote_res["candidates"]),
|
|
2975
3020
|
maint.render_promote_scan_hot_entry(promote_res["candidates"], d),
|
|
2976
3021
|
)
|
|
2977
3022
|
results["digest_html"] = self.digest_html(days=7, today=d)
|
|
@@ -3371,6 +3416,13 @@ class BrainCore:
|
|
|
3371
3416
|
results["notifications"] = notifications
|
|
3372
3417
|
|
|
3373
3418
|
if not dry_run:
|
|
3419
|
+
# hot-md-bloat: rotate aged/resolved entries to archive/ once
|
|
3420
|
+
# the live file exceeds the soft cap. Best-effort hygiene —
|
|
3421
|
+
# never allowed to fail the maintain run.
|
|
3422
|
+
try:
|
|
3423
|
+
self._rotate_hot_md(d)
|
|
3424
|
+
except Exception: # noqa: BLE001
|
|
3425
|
+
pass
|
|
3374
3426
|
# In-process graphify (owner decision 2026-07-11): the
|
|
3375
3427
|
# `_graphify_drift` marker is written by `_run_bounded_graphify`
|
|
3376
3428
|
# within THIS process, into THIS `state` dict, so `state` already
|
|
@@ -963,6 +963,132 @@ def shadow_ledger_entries(vault) -> list[dict[str, Any]]:
|
|
|
963
963
|
return list(by_key.values())
|
|
964
964
|
|
|
965
965
|
|
|
966
|
+
# -- behavioural grading (2026-07-17, owner decision) --------------------------
|
|
967
|
+
# The corrections-based calibration required ~10 mornings of the owner grading
|
|
968
|
+
# email by hand; across 6 rounds / 308 verdicts he filed ZERO — the ritual will
|
|
969
|
+
# never happen, so auto-archive was permanently gated on evidence that could
|
|
970
|
+
# not accrue. Behavioural grading replaces stated preference with revealed
|
|
971
|
+
# preference: the VM already reads the mailbox nightly, so it OBSERVES what the
|
|
972
|
+
# owner actually did to previously-verdicted mail (read / replied / flagged /
|
|
973
|
+
# archived himself / untouched) and drops raw observations; THIS module grades
|
|
974
|
+
# them, in one testable place. Owner ruling: also mine his own archive actions
|
|
975
|
+
# as pattern evidence — "albeit not exclusively".
|
|
976
|
+
|
|
977
|
+
BEHAVIOUR_OBSERVATIONS = ("owner_replied", "owner_flagged", "owner_read",
|
|
978
|
+
"owner_archived", "untouched")
|
|
979
|
+
|
|
980
|
+
# Legacy VM-drop key scheme (pre-canonical-msg_key): `content-rejoin:<sender>|
|
|
981
|
+
# <subject>` rows join 0/10 to any verdict row's msg_key (field evidence,
|
|
982
|
+
# behaviour-r12.jsonl 2026-07-18) — grading them double-counts mail that is
|
|
983
|
+
# already graded under its canonical key, so they are excluded from BOTH the
|
|
984
|
+
# numerator and denominator of the consistency/contradiction rates.
|
|
985
|
+
LEGACY_REJOIN_PREFIX = "content-rejoin:"
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def grade_behaviour(bucket: str, observed: str, *,
|
|
989
|
+
auto_archived: bool = False) -> str:
|
|
990
|
+
"""One verdict row + one observed behaviour -> a grade.
|
|
991
|
+
|
|
992
|
+
The error that matters for auto-archive is exactly one: "would have
|
|
993
|
+
archived mail the owner needed". So a NOISE verdict the owner then replied
|
|
994
|
+
to or flagged is `contradicted` — the hard, gate-relevant failure. A noise
|
|
995
|
+
row he merely opened is `read_anyway`: a weak signal (people open
|
|
996
|
+
newsletters), reported but never gated on. Noise he left untouched or
|
|
997
|
+
archived himself is `consistent`. An act/read row he archived without
|
|
998
|
+
engaging is `overcalled` — over-caution, harmless, informational.
|
|
999
|
+
"""
|
|
1000
|
+
b, o = str(bucket).lower(), str(observed).lower()
|
|
1001
|
+
# Aged-read lane (owner policy 2026-07-17): priority-list mail may be
|
|
1002
|
+
# auto-archived when read + no-action + >7d old. Those rows are bucket
|
|
1003
|
+
# `read`, not `noise` — so the drift contradiction must key on the ACTION
|
|
1004
|
+
# (we auto-archived it), not the bucket: the owner replying to or flagging
|
|
1005
|
+
# ANY row we auto-archived is the gate error, whichever lane moved it.
|
|
1006
|
+
if auto_archived and o in ("owner_replied", "owner_flagged"):
|
|
1007
|
+
return "contradicted"
|
|
1008
|
+
if b == "noise":
|
|
1009
|
+
if o in ("owner_replied", "owner_flagged"):
|
|
1010
|
+
return "contradicted"
|
|
1011
|
+
if o == "owner_read":
|
|
1012
|
+
return "read_anyway"
|
|
1013
|
+
if o in ("untouched", "owner_archived"):
|
|
1014
|
+
return "consistent"
|
|
1015
|
+
elif b in ("act", "read") and o == "owner_archived":
|
|
1016
|
+
return "overcalled"
|
|
1017
|
+
return "neutral"
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def behaviour_entries(vault) -> list[dict[str, Any]]:
|
|
1021
|
+
"""Raw behaviour observations from the VM drop (``behaviour-*.jsonl``),
|
|
1022
|
+
deduped by (round, msg_key) — last write wins, same idempotency shape as
|
|
1023
|
+
the shadow ledger. Rows are VM-authored and untrusted: consumed as data."""
|
|
1024
|
+
vdir = verdict_drop_dir(vault)
|
|
1025
|
+
files = sorted(vdir.glob("behaviour-*.jsonl")) if vdir.is_dir() else []
|
|
1026
|
+
by_key: dict[tuple[int, str], dict[str, Any]] = {}
|
|
1027
|
+
for f in files:
|
|
1028
|
+
for e in _read_jsonl(f):
|
|
1029
|
+
r, k = e.get("round"), e.get("msg_key")
|
|
1030
|
+
if isinstance(r, int) and isinstance(k, str):
|
|
1031
|
+
by_key[(r, k)] = e
|
|
1032
|
+
return list(by_key.values())
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def behaviour_report(vault) -> dict[str, Any]:
|
|
1036
|
+
"""Aggregate observed-behaviour evidence: per-bucket grade counts, the
|
|
1037
|
+
noise-safety numbers an auto-archive re-arm decision needs, and the
|
|
1038
|
+
owner's own archive patterns (top senders he archives himself — evidence
|
|
1039
|
+
for FUTURE noise-signals, never an actuator by itself)."""
|
|
1040
|
+
entries = behaviour_entries(vault)
|
|
1041
|
+
# Exclusion (2026-07-18 field report): legacy `content-rejoin:` keys — and,
|
|
1042
|
+
# when a shadow ledger exists, any row whose msg_key joins no verdict —
|
|
1043
|
+
# never enter the rates. No ledger at all ⇒ only the legacy scheme is
|
|
1044
|
+
# excludable (can't prove a join miss against nothing).
|
|
1045
|
+
ledger = known_ledger_keys(vault)
|
|
1046
|
+
verdict_keys = {k for _, k in ledger} if ledger else None
|
|
1047
|
+
excluded = 0
|
|
1048
|
+
joined: list[dict[str, Any]] = []
|
|
1049
|
+
for e in entries:
|
|
1050
|
+
k = str(e.get("msg_key", ""))
|
|
1051
|
+
if k.startswith(LEGACY_REJOIN_PREFIX) or (
|
|
1052
|
+
verdict_keys is not None and k not in verdict_keys):
|
|
1053
|
+
excluded += 1
|
|
1054
|
+
continue
|
|
1055
|
+
joined.append(e)
|
|
1056
|
+
entries = joined
|
|
1057
|
+
per_bucket: dict[str, dict[str, int]] = {}
|
|
1058
|
+
contradicted_rows: list[dict[str, Any]] = []
|
|
1059
|
+
owner_archive_patterns: dict[str, int] = {}
|
|
1060
|
+
rounds: set[int] = set()
|
|
1061
|
+
for e in entries:
|
|
1062
|
+
b = str(e.get("bucket", "?")).lower()
|
|
1063
|
+
o = str(e.get("observed", "?")).lower()
|
|
1064
|
+
g = grade_behaviour(b, o, auto_archived=bool(e.get("auto_archived")))
|
|
1065
|
+
per_bucket.setdefault(b, {})[g] = per_bucket.setdefault(b, {}).get(g, 0) + 1
|
|
1066
|
+
rounds.add(int(e["round"]))
|
|
1067
|
+
if g == "contradicted":
|
|
1068
|
+
contradicted_rows.append(
|
|
1069
|
+
{k: e.get(k) for k in ("round", "msg_key", "sender", "subject",
|
|
1070
|
+
"observed")})
|
|
1071
|
+
if o == "owner_archived":
|
|
1072
|
+
key = str(e.get("sender") or e.get("sender_domain") or "unknown").lower()
|
|
1073
|
+
owner_archive_patterns[key] = owner_archive_patterns.get(key, 0) + 1
|
|
1074
|
+
noise = per_bucket.get("noise", {})
|
|
1075
|
+
noise_observed = sum(noise.values())
|
|
1076
|
+
contradicted = noise.get("contradicted", 0)
|
|
1077
|
+
return {
|
|
1078
|
+
"observations": len(entries),
|
|
1079
|
+
"excluded_unjoined": excluded,
|
|
1080
|
+
"rounds_observed": len(rounds),
|
|
1081
|
+
"per_bucket": per_bucket,
|
|
1082
|
+
"noise_observed": noise_observed,
|
|
1083
|
+
"noise_contradicted": contradicted,
|
|
1084
|
+
"noise_consistency": (round((noise_observed - contradicted) / noise_observed, 4)
|
|
1085
|
+
if noise_observed else None),
|
|
1086
|
+
"contradicted_rows": contradicted_rows[:20],
|
|
1087
|
+
"owner_archive_patterns": dict(sorted(owner_archive_patterns.items(),
|
|
1088
|
+
key=lambda kv: -kv[1])[:20]),
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
|
|
966
1092
|
def calibration_report(vault) -> dict[str, Any]:
|
|
967
1093
|
"""Shadow-mode trust-gate report: calibration = reduce(verdicts,
|
|
968
1094
|
correction_events). A verdict is bucket-correct when no correction exists
|
|
@@ -998,6 +1124,10 @@ def calibration_report(vault) -> dict[str, Any]:
|
|
|
998
1124
|
"overall_bucket_precision": (round(bucket_correct / total, 4)
|
|
999
1125
|
if total else None),
|
|
1000
1126
|
"per_bucket": buckets,
|
|
1127
|
+
# revealed preference alongside stated preference: the corrections
|
|
1128
|
+
# count above stays authoritative where it exists, but 0 corrections
|
|
1129
|
+
# no longer means 0 evidence.
|
|
1130
|
+
"behaviour": behaviour_report(vault),
|
|
1001
1131
|
}
|
|
1002
1132
|
|
|
1003
1133
|
|
|
@@ -1451,7 +1581,18 @@ def _is_keeper_counterparty(vault, counterparty: str | None) -> bool:
|
|
|
1451
1581
|
if not counterparty:
|
|
1452
1582
|
return False
|
|
1453
1583
|
overrides = load_priority_overrides(vault)
|
|
1454
|
-
|
|
1584
|
+
name = str(counterparty).lower()
|
|
1585
|
+
if overrides.get(name) == "high":
|
|
1586
|
+
return True
|
|
1587
|
+
# Override keys are NOTE-ID SLUGS (the only form _OVERRIDE_LINE_RE parses),
|
|
1588
|
+
# but a commitment's counterparty is a display name from mail — e.g. a name
|
|
1589
|
+
# like "Renée Dûval" could never equal "renee-duval", so keeper detection
|
|
1590
|
+
# silently never fired (found 2026-07-17, the day the first real roster was
|
|
1591
|
+
# written). Compare in slug space, accents folded.
|
|
1592
|
+
import unicodedata
|
|
1593
|
+
folded = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode()
|
|
1594
|
+
slug = re.sub(r"[^a-z0-9]+", "-", folded).strip("-")
|
|
1595
|
+
return overrides.get(slug) == "high"
|
|
1455
1596
|
|
|
1456
1597
|
|
|
1457
1598
|
def _spine_ingest_commitment(vault, meta: dict[str, Any], *, source_ref: str,
|
|
@@ -1527,6 +1668,13 @@ DEFAULT_INGEST_SWEEP_MAX_BYTES = 200 * 1024 * 1024
|
|
|
1527
1668
|
INGEST_SWEEP_DOWNLOADS_ENV = "BRAIN_COS_DOWNLOADS_DIR"
|
|
1528
1669
|
INGEST_SWEEP_SKEW_SECONDS = 300 # manifest ts vs file mtime clock skew
|
|
1529
1670
|
INGEST_SWEEP_SIZE_TOLERANCE = 0.10 # when the manifest carries a size
|
|
1671
|
+
# Host-observed freshness floor (codex 2026-07-19): a candidate whose mtime is
|
|
1672
|
+
# older than this relative to the sweep's OWN clock is a pre-existing host file,
|
|
1673
|
+
# not something the VM just downloaded — the un-forgeable provenance anchor. The
|
|
1674
|
+
# maintain umbrella fires hourly; a 6h window tolerates a few missed runs / a
|
|
1675
|
+
# sleeping Mac while still excluding files that have sat in Downloads for days.
|
|
1676
|
+
INGEST_SWEEP_RECENCY_ENV = "BRAIN_COS_SWEEP_RECENCY_SECONDS"
|
|
1677
|
+
DEFAULT_INGEST_SWEEP_RECENCY_SECONDS = 6 * 3600
|
|
1530
1678
|
|
|
1531
1679
|
|
|
1532
1680
|
def ingest_manifest_dir(vault=None) -> Path:
|
|
@@ -1550,6 +1698,14 @@ def _sweep_max_bytes() -> int:
|
|
|
1550
1698
|
return DEFAULT_INGEST_SWEEP_MAX_BYTES
|
|
1551
1699
|
|
|
1552
1700
|
|
|
1701
|
+
def _sweep_recency_seconds() -> int:
|
|
1702
|
+
try:
|
|
1703
|
+
return int(os.environ.get(INGEST_SWEEP_RECENCY_ENV,
|
|
1704
|
+
DEFAULT_INGEST_SWEEP_RECENCY_SECONDS))
|
|
1705
|
+
except ValueError:
|
|
1706
|
+
return DEFAULT_INGEST_SWEEP_RECENCY_SECONDS
|
|
1707
|
+
|
|
1708
|
+
|
|
1553
1709
|
def _unique_dest(inbox: Path, filename: str) -> Path:
|
|
1554
1710
|
dest = inbox / filename
|
|
1555
1711
|
if not dest.exists():
|
|
@@ -1572,8 +1728,12 @@ def ingest_sweep(vault, *, downloads_dir: Path | str | None = None,
|
|
|
1572
1728
|
- filenames are basename-only — any path separator / ``..`` is refused;
|
|
1573
1729
|
- a symlinked candidate (or manifest file) is refused, never followed;
|
|
1574
1730
|
- files above the size cap (default 200 MB) are refused;
|
|
1575
|
-
- a
|
|
1576
|
-
|
|
1731
|
+
- a candidate must be a FRESH download — mtime within the recency window of
|
|
1732
|
+
the sweep's own clock — so a VM manifest cannot claim a pre-existing host
|
|
1733
|
+
Downloads file it did not just download (codex 2026-07-19, VM/host
|
|
1734
|
+
boundary): the mtime is the one provenance signal the VM cannot forge;
|
|
1735
|
+
- a match requires exact filename, size within tolerance when the manifest
|
|
1736
|
+
carries one, and mtime newer than the manifest ts minus skew;
|
|
1577
1737
|
- claims are append-only, one per manifest line — a re-run never
|
|
1578
1738
|
double-moves (idempotent);
|
|
1579
1739
|
- NOTHING the manifest does not name is ever touched, moved, or deleted.
|
|
@@ -1653,6 +1813,26 @@ def ingest_sweep(vault, *, downloads_dir: Path | str | None = None,
|
|
|
1653
1813
|
report["refused"].append({"filename": fname,
|
|
1654
1814
|
"reason": "over size cap"})
|
|
1655
1815
|
continue
|
|
1816
|
+
# SECURITY (codex 2026-07-19, VM/host boundary): the manifest is
|
|
1817
|
+
# VM-writable, so its own fields cannot prove provenance — the VM
|
|
1818
|
+
# can name any basename and BACKDATE `ts` to defeat the staleness
|
|
1819
|
+
# check on an old host file. The one thing a VM-side attacker CANNOT
|
|
1820
|
+
# forge is a host file's mtime, so the sweep's OWN clock is the
|
|
1821
|
+
# anchor: only a file whose mtime is recent relative to `now` — i.e.
|
|
1822
|
+
# one genuinely downloaded within this maintenance window — is
|
|
1823
|
+
# eligible. A guessed sensitive file that has been sitting in
|
|
1824
|
+
# ~/Downloads (tax.pdf, statement.pdf …) is refused regardless of
|
|
1825
|
+
# what the manifest claims. Widen the window via env for slow hosts.
|
|
1826
|
+
age = now.timestamp() - cand.stat().st_mtime
|
|
1827
|
+
if age > _sweep_recency_seconds():
|
|
1828
|
+
report["unmatched"].append(fname)
|
|
1829
|
+
report.setdefault("unmatched_reasons", []).append({
|
|
1830
|
+
"filename": fname,
|
|
1831
|
+
"reason": f"not a fresh download: host mtime is {age / 3600.0:.1f}h "
|
|
1832
|
+
f"old (recency window {_sweep_recency_seconds() // 3600}h) "
|
|
1833
|
+
f"— a pre-existing host file the VM manifest cannot claim",
|
|
1834
|
+
})
|
|
1835
|
+
continue
|
|
1656
1836
|
want = entry.get("approx_size_bytes")
|
|
1657
1837
|
if isinstance(want, int) and want > 0:
|
|
1658
1838
|
if abs(size - want) > max(want * INGEST_SWEEP_SIZE_TOLERANCE, 4096):
|