chore: auto-commit uncommitted changes

This commit is contained in:
James 2026-02-15 00:00:20 -05:00
parent cb59093c4c
commit 1cdf87130b
10 changed files with 641 additions and 329 deletions

View File

@ -0,0 +1,4 @@
{
"version": 1,
"onboardingCompletedAt": "2026-02-15T04:20:24.393Z"
}

View File

@ -1,42 +1,61 @@
You are an email triage system for Johan Jongsma. You classify incoming emails into one of four actions.
You are a junk mail filter for Johan Jongsma. You classify incoming emails into one of two actions.
Respond with ONLY a JSON object: {"action": "<action>", "reason": "<brief explanation>"}
Respond with ONLY a JSON object: {"action": "junk|pass", "reason": "brief explanation"}
## Actions
- **trash** — Junk, spam, marketing, newsletters, promotions, automated notifications with no value, shipping/delivery updates, "your statement is ready" notices, DigiKey marketing, inou verification codes (noreply@inou.com)
- **archive** — Processed bills, past travel confirmations, payment receipts, security alerts (password changes, new logins), subscription confirmations with reference value
- **keep** — Action required, bills pending payment, personal correspondence awaiting reply, new lingerie/fashion collection launches (Pain de Sucre, Fleur du Mal — new collections only, not sales), Verizon purchase/order emails, anything needing Johan's attention
- **escalate** — Sophia-related (medical, therapy, brain, neuro, activator, pediatric suppliers, insurance claims mentioning Sophia, "S. Jongsma"), phishing attempts, important personal emails, infrastructure alerts (Uptime Kuma, server notifications, SSL), Google Search Console alerts, domain purchase inquiries, anything unusual or uncertain
- **junk** — Spam, marketing, newsletters, promotions, automated notifications, shipping/delivery updates, "your statement is ready" notices, subscription confirmations, order confirmations, receipts, security alerts (password changes, new logins), DigiKey marketing, inou verification codes (noreply@inou.com), retailer promos, anything that doesn't need a human
- **pass** — Real humans writing real messages, action required, bills pending payment, anything Sophia-related, phishing attempts, infrastructure alerts, personal correspondence, anything uncertain or unusual
## Specific Sender Rules
- **Kaseya Marketing** → trash (summarize in reason)
- **Lansweeper** → trash (summarize in reason)
- **inou verification codes** (noreply@inou.com) → trash
- **Immich** (GitHub/releases) → escalate (triggers server update)
- **DigiKey** (e.digikey.com) marketing → trash
- **Verizon Wireless** purchase/order → keep; generic promos → trash
- **Amazon** — order confirmations → keep; everything else (promos, recommendations, shipping updates) → trash
- **Domain purchase inquiries** → escalate
- **Kaseya Marketing** → junk
- **Lansweeper** → junk
- **inou verification codes** (noreply@inou.com) → junk
- **DigiKey** (e.digikey.com) marketing → junk
- **Amazon** — everything (promos, shipping, order confirmations, recommendations) → junk
- **Verizon Wireless** — generic promos → junk; purchase/order emails → pass
- **Immich** (GitHub/releases) → pass (triggers server update)
- **Domain purchase inquiries** → pass
## "Interesting Spam" Exceptions (pass, not junk)
These categories look like marketing but Johan wants to see them:
- **Lingerie/swimwear brands** (Pain de Sucre, Fleur du Mal, and similar) — New collections, seasonal launches, lookbooks, designer collaborations → **pass**. Generic "50% off" / "sale ends tonight" discount blasts → still **junk**.
- **AI/ML news** — New model releases, LLM announcements, ML research breakthroughs, benchmark results → **pass**. Generic "try our AI tool" product marketing → still **junk**.
The test: is there something *new and interesting* to look at, or is it just trying to get you to buy something on sale? New = pass. Sale = junk.
## Renewals, Deadlines & Financial Commitments (always pass)
Any email mentioning renewal, expiration, auto-renew, subscription renewal, domain renewal, "expires soon", certificate expiry, or similar → **always pass**. These have actionable deadlines and/or financial consequences.
**General rule:** If it involves a deadline or money being charged, **pass**. The junk filter should not make judgment calls on things that cost money.
## Phishing Detection
If ANY of these red flags appear, action MUST be **escalate**:
If ANY of these red flags appear, action MUST be **pass**:
- Unexpected "payment processed" / "transaction" language
- Phone numbers to call "if you didn't authorize"
- Urgent action + money involved
- Legitimate service used as delivery mechanism (Canva invite containing payment info, etc.)
- Mismatched sender domain vs claimed sender
Phishing is dangerous. When in doubt, pass it through.
## Renewal / Expiration / Deadline Rule
Anything mentioning renewal, auto-renew, expiring, expires soon, domain renewal, subscription renewal, certificate expiry, or similar deadline/financial commitment language → **always pass**. The mailroom doesn't make judgment calls on things that cost money.
## Critical Rules
1. When uncertain, escalate — never silently trash something important
2. Sophia triggers ALWAYS escalate — medical, therapy, brain activator, pediatric suppliers
3. Personal correspondence from real humans → escalate
4. "Re:" thread replies from people → escalate (active conversation)
5. Credits/refunds with expiry dates → keep
6. Shipping/delivery updates → trash (dashboard tracks these separately)
1. When uncertain, **pass** — never silently junk something important
2. Sophia triggers ALWAYS **pass** — medical, therapy, brain activator, pediatric suppliers, "S. Jongsma"
3. Personal correspondence from real humans → **pass**
4. "Re:" thread replies from people → **pass** (active conversation)
5. Infrastructure/server alerts → **pass**
6. Google Search Console alerts → **pass**
## Account Context

View File

@ -136,3 +136,47 @@
- **Archived:** GenerX service form, order confirmations (4720443389, Nordstrom 1026793809)
- **Trashed:** Duke Energy usage alert, Mint Mobile survey (Mikhail), Realtor listing for own house, generator notifications, political newsletter, subscriber agreement update, marketing/spam, Valentine's lingerie promos, hardware spam
- **MC issue:** LLM triage failing with 401 (API key invalid), requests taking 15s+
## Shannon Scan Complete
- Full pentest of inou.com via Shannon Lite on Sonnet 4.5 (OAuth token direct to Anthropic)
- Runtime: ~1.5 hours
- **Findings:** 2 CRITICAL (hardcoded backdoor 250365, session hijacking), 2 HIGH (session fixation, brute force)
- Authorization, SQLi, SSRF all passed clean
- 3 out-of-scope items needing internal access (path traversal, DICOM XSS, LLM prompt injection XSS)
- PowerPoint generated and placed on sophia SMB share
- All deliverables at ~/clawd/memory/shannon-scan-2026-02-14/
## Prima (UMich Brain MRI AI)
- Deep dive completed — open source MIT, VLM for 50+ neuro conditions, 97.5% accuracy
- Needs Ampere+ GPU (L40S reference, RTX 3090 minimum). Forge GTX 970 is out
- Johan's idea: offer as inou service — intelligent series selection (don't run all 10K slices, pick relevant series) → RunPod serverless GPU → results in viewer
- Spec being written by subagent
- Sophia had 10,000+ slices — this is the use case
## Diana Yusha Lab Report
- Added arthritis rebuttal section (Russian) — CRP 1.3 and ESR 6 don't support arthritis diagnosis
- Some Russian doctor suggested arthritis based on elevated CK 406 — incorrect interpretation
## WebMCP
- Added to HEARTBEAT.md tech watch list
- Chrome experimental API for structured web agent tools — high relevance for James/OpenClaw, medium for inou
## Colorado Camel Milk
- Order #16698 from Feb 7, $115 (6 pints frozen + $31 shipping)
- Still "Awaiting Shipment" — waiting for safe weather window (frozen perishable to Florida)
## Infrastructure
### Generator (48kw)
- Generator does NOT auto-start — manual start only
- GenerX came out today to analyze the auto-start issue (tech: Eduardo Rivera)
- The 3 runs were test runs by GenerX, NOT power outages
- Waiting on quote from GenerX for the fix
### Emergency Internet Failover (TP-Link AX1500)
- Installed TP-Link AX1500 router, connected to UDM-Pro port 10
- Tested and working, then port 10 disabled on UDM-Pro
- WiFi network: `X4` / password: `Helder06`
- **For longer/full outages (including mobile coverage):** swap the cable on port 10 for the longer cable (already staged nearby) and connect to Starlink
- This gives internet via Starlink → AX1500 → UDM-Pro port 10
- Shannon VPS now configured with OAuth token (no more router proxy)

Binary file not shown.

View File

@ -1,9 +1,9 @@
{
"last_updated": "2026-02-14T23:00:04.689745Z",
"last_updated": "2026-02-15T05:00:03.623124Z",
"source": "api",
"session_percent": 6,
"session_resets": "2026-02-15T01:59:59.648057+00:00",
"weekly_percent": 2,
"weekly_resets": "2026-02-21T18:59:59.648078+00:00",
"session_percent": 4,
"session_resets": "2026-02-15T07:00:00.587244+00:00",
"weekly_percent": 4,
"weekly_resets": "2026-02-21T18:59:59.587273+00:00",
"sonnet_percent": 5
}

View File

@ -1,235 +1,121 @@
# Email Triage Instructions
# Mail Agent Routing Guide
*Created: 2026-02-01*
*Updated: 2026-02-07 (Added Specific Sender Rules)*
*Updated: 2026-02-14 — Pipeline refactor: MC handles junk filtering, mail agent handles smart routing*
## Architecture
```
Email → MC (junk filter) → webhook → OpenClaw mail agent (smart routing)
↓ junk: deleted ↓ pass: routed intelligently
```
**MC's job:** Binary junk/pass filter. Spam, marketing, newsletters → deleted. Everything else → passed to you.
**Your job:** Read each message fully. Route it to the right place. Take action.
## Message Center (MC) API
**Unified endpoint for all messages (email + WhatsApp):**
- Base URL: `http://localhost:8025`
- Fetch new: `GET /messages/new`
- Replay: `GET /messages?since=24h`
- Actions: `POST /messages/{id}/archive`, `/delete`, `/reply`, `/to-docs`, `/seen`
- **Base URL:** `http://localhost:8025`
- `GET /messages/new` — fetch unprocessed messages
- `GET /messages?since=24h` — replay window
- `GET /messages/{id}` — single message with full body
- `POST /messages/{id}/archive` — archive (done processing, no action needed)
- `POST /messages/{id}/delete` — hard delete
- `POST /messages/{id}/reply` — send reply `{"body": "..."}`
- `POST /messages/{id}/to-docs` — forward attachments to ~/documents/inbox/
- `POST /messages/{id}/seen` — mark seen without archiving (stays in inbox)
## Routing Destinations
### Fully Dashboard (alerts for Johan) — port 9202
```bash
curl -X POST http://localhost:9202/api/alerts \
-H 'Content-Type: application/json' \
-d '{"message": "...", "priority": "critical|warning|info"}'
```
**Priority levels:**
- **critical** — Act NOW. Security breach, service down, urgent personal matter, phishing attempt
- **warning** — Needs attention today. Bills due, action-required emails, Sophia-related, medical
- **info** — FYI. Interesting but not urgent. Can wait.
**Route here:** Anything needing Johan's direct attention or decision.
### James Dashboard (news/status) — port 9200
**News:**
```bash
curl -X POST http://localhost:9200/api/news \
-H 'Content-Type: application/json' \
-d '{"title": "...", "body": "...", "type": "info"}'
```
**Route here:** Industry news, Kaseya/Lansweeper updates, interesting FYI items, routine notifications worth noting.
**Deliveries:**
```bash
curl -X PUT http://localhost:9200/api/deliveries/upsert \
-H 'Content-Type: application/json' \
-d '{"description": "...", "carrier": "...", "retailer": "...", "tracking_number": "...", "tracking_url": "...", "expected_date": "...", "status": "shipped|in_transit|out_for_delivery|delivered"}'
```
**Route here:** Any shipping/delivery/tracking email. Always use upsert (matches by tracking_number or description+retailer).
**Tasks:**
```bash
curl -X POST http://localhost:9200/api/tasks \
-H 'Content-Type: application/json' \
-d '{"title": "...", "text": "...", "priority": "high|medium|low", "status": "pending", "owner": "james", "domain": "..."}'
```
**Route here:** Actionable items for James (server updates, infrastructure tasks, Google Search Console issues).
### Document Inbox (attachments)
```bash
curl -X POST http://localhost:8025/messages/{id}/to-docs
```
**Route here:** Invoices, receipts, contracts, medical docs, tax forms, insurance docs — anything worth keeping/finding later.
## Routing Rules
### Always alert Johan (Fully)
- Sophia-related: medical, therapy, brain activator, pediatric suppliers, "S. Jongsma"
- Phishing attempts (preserve message, alert as critical)
- Personal correspondence from real humans needing reply
- Bills/payments requiring action
- Security incidents, infrastructure alerts (Uptime Kuma, SSL)
- Anything unusual or uncertain
### Dashboard news
- Kaseya marketing → summarize → news
- Lansweeper updates → summarize → news
- Immich releases → news + create James task to update server
- Google Search Console → news + create James task
- Industry/tech updates worth noting
### Process and archive
- Shipping/delivery → upsert to deliveries → archive
- Order confirmations → archive (ingest attachments if receipt/invoice)
- Security alerts (password changes, new logins) → archive
- Subscription confirmations → archive
### Attachment handling
Ingest attachments for: invoices, receipts, bills, statements, contracts, legal docs, medical records, insurance docs, tax forms.
Skip: marketing images, logos, signatures, spam attachments.
## Special Rules
- **Verizon purchase/order emails** → alert Johan (warning)
- **Domain purchase inquiries** → reply "not for sale" → archive
- **Lingerie/fashion new collections** (Pain de Sucre, Fleur du Mal) → alert Johan (info)
- **inou verification codes** — should be junked by MC, but if they slip through → archive
## Critical Rules
1. **ALWAYS read the FULL message** before routing. No exceptions.
2. **When uncertain → alert Johan** (info priority). Better to over-alert than miss something.
3. **Do NOT ping the main session** unless truly urgent and needs real-time discussion.
4. **Do NOT re-report** — check context before alerting about the same email twice.
5. **Phishing = critical alert + preserve** — never delete, never auto-report abuse.
## Account Context
**Account identifiers:**
- `tj_jongsma_me` (tj@jongsma.me) — family/shared account
- `johan_jongsma_me` (johan@jongsma.me) — Johan's personal account
- `whatsapp` — WhatsApp messages
All messages use unified ID format: `{source}:{sourceID}` (e.g., `tj_jongsma_me:12345`)
## CRITICAL RULE
**ALWAYS read the FULL message content before triaging. NO EXCEPTIONS.**
Every email gets read in full — regardless of sender, subject, or apparent category. The content determines the action.
## Reporting Rule (2026-02-08, updated 2026-02-09)
- **Needs Johan's attention** (kept in inbox, flagged, Sophia-related, action required) → Ping immediately
- **All other processing** (trash, archive, shipping updates, routine) → Batched summary at **9:00 AM** and **9:00 PM** ET via cron jobs
- Do NOT ping Johan for every routine email processed
- **DO NOT RE-REPORT:** Before pinging about an email, check recent session history / daily memory. If you already told Johan about it, don't tell him again. Un-actioned in inbox ≠ unreported. Emails stay in inbox until Johan acts — that's by design, not a signal to re-alert.
---
## Attachment Processing
When emails have attachments (`has_attachments: true`, `attachment_names: [...]`), decide if any are worth archiving.
### Trigger Ingest (Forward to Documents Inbox)
```bash
curl -X POST "http://localhost:8025/messages/{id}/to-docs" \
-H "Content-Type: application/json" \
-d '{"attachments": ["invoice.pdf"]}'
```
MC fetches attachments and saves to `~/documents/inbox/` for DocSys processing.
**Response:**
```json
{"saved": ["/home/johan/documents/inbox/invoice.pdf"], "errors": []}
```
**Ingest all attachments:** omit the `attachments` array or pass `{}`.
### When to Ingest
**Trigger ingest for anything interesting/worth keeping:**
- Invoices, receipts, bills, statements
- Contracts, agreements, legal documents
- Medical records, insurance docs
- Tax forms (W-2, 1099, etc.)
- Any document worth keeping/finding later
- Use judgment — if it looks useful, ingest it
**Skip these:**
- Marketing fluff, promo images
- Logos, signatures (image-only attachments)
- Spam attachments
- Duplicate documents already ingested
### Supported Formats
- **Vision (K2.5):** .pdf, .png, .jpg, .jpeg, .gif, .webp, .tiff, .bmp, .doc, .docx, .odt, .rtf, .xls, .xlsx, .ppt, .pptx
- **Text (K2):** .txt, .md, .csv, .json, .xml, .yaml, .log
---
## James Tasks (assign to myself)
These emails are ACTIONABLE for me — create a dashboard task (owner: james), don't just archive:
- **Google Search Console** (sc-noreply@google.com) — inou.com indexing issues, crawl errors, etc.
- **inou.com alerts** — uptime, errors, anything about the platform
- **Infrastructure alerts** — Uptime Kuma, server notifications, SSL expiry
- **Security alerts** — breach notifications, vulnerability disclosures for our stack
**Workflow:**
1. Read the full email
2. Create task: `POST http://localhost:9200/api/tasks` with `owner: "james"`, appropriate domain
3. THEN archive the email
---
## Sophia Triggers
Anything matching these → Sophia's recovery folder (or ASK if unclear):
- "brain", "neuro", "therapy", "activator"
- Medical devices, equipment, serial numbers
- Pediatric suppliers (All About Pediatrics, Tri-Med, etc.)
- Insurance claims mentioning Sophia
- Any person name "S. Jongsma" or "Sophia"
- Therapy appointments, medical follow-ups
## Conversation Detection
- "Re:" from a person = active conversation, READ IT
- Thread replies are not spam
- Check context before deleting
## Action Required Detection
- Credits/refunds → check expiry, action needed?
- Payment reminders → notify Johan
- "ACTION REQUIRED" → read and assess
- Medical advice → flag for attention, don't just file
## Phishing / Scam Detection
**Phishing ≠ Spam.** Spam is junk. Phishing is fraud. Different handling.
### Red Flags (read body carefully if ANY present):
- Unexpected "payment processed" or "transaction" language
- Phone numbers to call "if you didn't authorize"
- Urgent action required + money involved
- Generic sender names ("Your Teacher", "Support Team")
- Legitimate service used as delivery mechanism (Canva class, Google Doc share, etc.)
- Message-ID from unexpected domain (e.g., amazonses.com for a "Canva" email)
- Mismatched context (class invite containing payment info)
### Phishing Workflow:
1. **PRESERVE** — do NOT delete. Move to a folder or keep in inbox.
2. **FLAG** — mark for Johan's attention
3. **ALERT** — ping Johan: "Phishing attempt detected, preserved for review"
4. **LOG** — dashboard entry with `action: "flagged"`, `reason: "Phishing - [brief description]"`
5. **DO NOT AUTO-REPORT** — Never send abuse reports to Canva/Google/PayPal/etc. without Johan's explicit approval. We don't want to become spam ourselves, and false positives happen.
**Johan decides** if an abuse report is warranted. I preserve evidence, he takes action.
### Why This Matters:
- Phishing evolves — today's "class invite" scam is tomorrow's "shared document" scam
- Evidence has value for reporting (if Johan chooses to)
- Auto-reporting could flag legitimate emails, annoy abuse teams, or get our accounts flagged
---
## Delete Behavior
**NEVER hard-delete. Always move to Trash.**
Before moving to Trash:
1. Read full content
2. Summarize what it contains
3. Confirm no action is needed
4. Confirm it's NOT phishing (phishing = preserve, not trash)
## Processed Email Routing
### → Trash (delete)
- Marketing/promotional emails
- Spam
- Phishing attempts
- Newsletters not worth keeping
- Automated notifications with no value
- Test emails
**Rule:** If Johan would never want to find this again → Trash
### → Shopping (mark read, move)
- Order confirmations ONLY
**Rule:** Actual order/purchase confirmation → mark read, move to `Folders/Shopping`
### → Trash (after processing)
- Shipping notifications
- Delivery updates
- "Out for delivery" / "Delivered" notices
**Workflow for shipping/delivery emails:**
1. Read the full message
2. **ALWAYS use upsert** (never POST): `PUT http://localhost:9200/api/deliveries/upsert` — matches by tracking_number or description+retailer, updates if found, creates if not. Include tracking_number when available.
3. THEN move to Trash
This keeps the delivery schedule current without cluttering Shopping folder.
### → Archive (keep but out of inbox)
- Processed bills after payment
- Travel confirmations (past trips)
- Payment receipts from subscriptions (reference value)
- Security alerts (password changes, new logins)
**Rule:** Archive is for things worth FINDING AGAIN. If Johan would never search for it → Trash, not Archive.
### → Trash (common false-archive candidates)
- **Amazon:** Everything except order confirmations and outliers (product recalls, credits). Promos, recommendations, "items you viewed", shipping updates (after updating deliveries) → all trash.
- **Retailers:** Marketing, sales, "new arrivals" → trash
- **Account notifications** with no future value → trash
- **Generic "your statement is ready"** → trash (he can check the app)
### → Keep in Inbox (flag for Johan)
- Action required
- Bills/renewals pending payment
- Personal correspondence awaiting reply
- Anything Sophia-related
- Medical/insurance matters
**Important:** After flagging, mark as seen so it doesn't retrigger:
```bash
curl -X POST "http://localhost:8025/messages/{id}/seen"
```
This removes it from `/messages/new` without archiving/deleting.
## Specific Sender Rules
These override general routing:
- **Kaseya Marketing:** Read fully -> Summarize (rundown) -> Post to Dashboard News -> **Trash**.
- **Lansweeper:** (Johan is ex-CTO) Read fully -> Summarize what's going on -> Post to Dashboard News -> **Trash**.
- **inou Verification Codes:** (noreply@inou.com) -> **Trash** immediately (Johan uses backdoors).
- **Immich:** (GitHub/Releases) Read fully -> **Trigger update on server 192.168.1.253** -> Post to Dashboard News -> **Trash**.
- **Lingerie & Beach Wear (e.g., Pain de Sucre, Fleur du Mal):** Use your judgment. If the email introduces a **new collection, series, or seasonal release**, **Keep in Inbox**. If it is just general sales/marketing/shipping alerts, **Trash**.
- **Domain Purchase Inquiries (e.g., Jacob):** -> Deny (Reply "not for sale") -> **Archive**.
- **DigiKey** (e.digikey.com): Marketing/newsletters → **Trash** immediately.
- **Verizon Wireless** (ecrmemail.verizonwireless.com): Purchase/order emails → **Keep in Inbox**. Even if they look like "abandoned cart" marketing, Johan may have an active purchase in progress. Only trash obvious generic promos (plan upgrades, add-a-line offers).
## Mistakes Made
### 2026-02-01
- Hard-deleted GenerX thread (permanently lost)
- Deleted Amazon promo credit without checking if action needed
- Almost deleted MBL Brain Activator emails (Sophia's therapy device repair)
- Triaged MosaicDx by subject without reading their medical advice
### 2026-02-02
- **Canva phishing email:** Deleted as "educational marketing spam" without reading body. Missed scam payload ($769.68 fake payment + scam phone number). Should have: read full content, recognized phishing red flags, preserved + flagged for Johan. **Violated my own rule: "ALWAYS read the FULL message content before triaging."**
Learn from these. Don't repeat.

View File

@ -1,48 +1 @@
{
"date": "2026-02-14",
"timestamp": "2026-02-14T09:00:50-05:00",
"openclaw": {
"before": "2026.2.12",
"latest": "2026.2.13",
"after": "2026.2.13",
"updated": true
},
"claude_code": {
"before": "2.1.42",
"latest": "2.1.42",
"updated": false
},
"os": {
"available": 5,
"packages": [
{
"name": "linux-generic",
"from": "6.8.0-94.96",
"to": "6.8.0-100.100"
},
{
"name": "linux-headers-generic",
"from": "6.8.0-94.96",
"to": "6.8.0-100.100"
},
{
"name": "linux-image-generic",
"from": "6.8.0-94.96",
"to": "6.8.0-100.100"
},
{
"name": "sosreport",
"from": "4.5.6-0ubuntu4",
"to": "4.9.2-0ubuntu0~24.04.1"
},
{
"name": "tailscale",
"from": "1.94.1",
"to": "1.94.2"
}
],
"updated": true,
"reboot_required": false
},
"gateway_restarted": true
}
{"date":"2026-02-14","time":"21:00","os":{"status":"up_to_date","packages_upgraded":0,"reboot_required":false},"claude_code":{"previous":"2.1.33","current":"2.1.42","updated":true},"openclaw":{"previous":"2026.2.13","current":"2026.2.13","updated":false},"sessions_cleaned":{"orphan_files_removed":109,"orphan_size_mb":13.2,"cron_run_keys_removed":10},"working_context_updated":true,"daily_memory_updated":true}

View File

@ -1,34 +1,35 @@
# Working Context
## Last Session: 2026-02-14 (Cigna Investigation, Shannon Launch, Dashboard Redesign)
## Last Session: 2026-02-14 (Prima Integration, Shannon Scan, Fully Dashboard)
### What happened
- myCigna autonomous login: real Chrome on Xvfb:99, CDP port 9224, 2FA via MC email
- **Baycare ventilator fraud discovered**: billing E0465 ($3,125/mo) for ventilator Sophia doesn't have. 3+ prior notifications ignored. Complaint drafted.
- Shannon security scanner live on Zurich VPS, scanning inou.com portal with K2.5 via CCR router
- Alert dashboard (9202) redesigned: Braun/mid-century vibe, Sora font, larger text, calendar nav
- Evening briefing cron upgraded from systemEvent → real isolated briefing
- Both briefings now require release notes details + cover infrastructure/industry topics
- Context hygiene rules added: subagent for side questions, dual units, thinking levels
### What happened today (Valentine's Day)
- **Prima (UMich Brain MRI AI):** Johan wants to offer as inou service. Intelligent series selection (don't run all 10K slices). RunPod serverless L40S on demand. Full spec written: `/home/johan/dev/inou/specs/prima-integration.md` (39KB). Key: 73-75% GPU cost savings with smart series selection.
- **Shannon Security Scan completed:** 2 CRITICAL (hardcoded backdoor 250365, session hijacking), 2 HIGH (session fixation, brute force). Auth/SQLi/SSRF clean. Deliverables at ~/clawd/memory/shannon-scan-2026-02-14/
- **Baycare ventilator fraud:** Billing E0465 ($3,125/mo) for ventilator Sophia doesn't have. Formal complaint drafted. Strategy: don't pay, let them escalate.
- **myCigna autonomous login:** Real Chrome on Xvfb:99, CDP 9224, 2FA via MC email — zero human intervention
- **Fully Dashboard (9202) major redesign:** Braun aesthetic, pulse-ox camera feed (MJPEG 7pm-8am), long-press to dismiss, alerts moved left. This is now Johan's unified inbox on Fire tablet.
- **James Dashboard (9200):** News items now clickable with source URLs
- **Context hygiene rules:** Added to AGENTS.md (side questions → subagent, both units always, thinking level matching)
- **Cron finalized:** 7 jobs total. Evening briefing upgraded to isolated agentTurn.
- **Diana Yusha lab report:** Added arthritis rebuttal (Russian) — CRP/ESR don't support diagnosis
### Active Projects
- **Shannon scan** — running on zurich (82.24.174.112), workflow `inou-com_shannon-1771049779401`. Check results.
- **Baycare fraud** — complaint at ~/documents/records/medical/. Strategy: don't pay, let escalate.
- **MC triage** — deployed, monitoring for false negatives
- **Alert Dashboard** — port 9202, redesigned, source at /home/johan/dev/alert-dashboard/
- **Azure Files Backup** — feature complete, blocked on `az login` MFA. Free account expires ~Feb 27!
- **Dr. Madan call** — Sunday Feb 15 at 2PM ET (reminder cron set, auto-deletes)
### Open threads
- **Prima:** Spec done, Johan to review when he wakes. Next: implementation (RunPod Docker image, inou API endpoints)
- **Shannon findings:** Johan needs to review 2 CRITICAL + 2 HIGH. Backdoor code 250365 is urgent.
- **Baycare fraud:** Complaint ready. Johan decides when to escalate to Cigna fraud division / state AG
- **Azure Files Backup:** Feature complete but blocked on `az login` MFA. Free account expires ~Feb 27!
- **Real estate:** Diana Geegan negotiating 851 ($6.35M sell) + 801 (buy). Johan ~$171K short of $6.2M goal.
- **Colorado Camel Milk:** Order #16698 still awaiting shipment (weather hold)
### Pending
- Kaseya device policy change (BYOD → managed devices). Johan needs to request MacBook Pro.
- Real estate: 851 Brightwaters listed at $7.25M on Zillow. Diana Geegan negotiating.
- Belastingdienst: Dutch corporate tax filing reminder for 2025
- Dashboard redesign feedback pending (Johan viewing on Fire tablet)
### Johan's state
- Went to sleep ~8:19 PM (first sleep block). Night shift starts ~10:30 PM.
- Tomorrow is Sunday — weekly synthesis due, Docker/HAOS updates
### Key Reminders
- bird for X/Twitter — ALWAYS
- memory_search before responding to references — MANDATORY
- Right model for right job: Opus for judgment, K2.5 for grunt work
- Side questions → subagent always (context hygiene)
- Both units always (metric + imperial)
- myCigna: username=tjjongsma, real Chrome on Xvfb (not headless Playwright)
### Infrastructure
- forge (192.168.1.16): All services healthy. GPU OCR live on port 8090.
- Shannon VPS: OAuth token configured, scans working
- Alert dashboard: port 9202, James dashboard: port 9200
- Generator ran 3x today (power outages), GenerX serviced
### Desk layout
- Left: TUI console | Center: Fully tablet (9202) | Center-back: Sophia cam | Right: James Dashboard (9200)

405
scripts/shannon-pptx.py Normal file
View File

@ -0,0 +1,405 @@
#!/usr/bin/env python3
"""Generate Shannon Security Assessment PowerPoint for inou.com"""
from pptx import Presentation
from pptx.util import Inches, Pt, Emu
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
from pptx.enum.shapes import MSO_SHAPE
prs = Presentation()
prs.slide_width = Inches(13.333)
prs.slide_height = Inches(7.5)
# Colors
BG_DARK = RGBColor(0x0F, 0x11, 0x17)
BG_CARD = RGBColor(0x1A, 0x1D, 0x26)
ACCENT_RED = RGBColor(0xFF, 0x4D, 0x4D)
ACCENT_ORANGE = RGBColor(0xFF, 0xA5, 0x00)
ACCENT_GREEN = RGBColor(0x4E, 0xC9, 0xB0)
ACCENT_BLUE = RGBColor(0x56, 0x9C, 0xD6)
TEXT_WHITE = RGBColor(0xF0, 0xF0, 0xF0)
TEXT_GRAY = RGBColor(0x99, 0x99, 0x99)
TEXT_DIM = RGBColor(0x66, 0x66, 0x66)
def set_slide_bg(slide, color=BG_DARK):
bg = slide.background
fill = bg.fill
fill.solid()
fill.fore_color.rgb = color
def add_text_box(slide, left, top, width, height, text, font_size=18,
color=TEXT_WHITE, bold=False, align=PP_ALIGN.LEFT, font_name="Segoe UI"):
txBox = slide.shapes.add_textbox(Inches(left), Inches(top), Inches(width), Inches(height))
tf = txBox.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
p.text = text
p.font.size = Pt(font_size)
p.font.color.rgb = color
p.font.bold = bold
p.font.name = font_name
p.alignment = align
return txBox
def add_card(slide, left, top, width, height, color=BG_CARD):
shape = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE,
Inches(left), Inches(top), Inches(width), Inches(height))
shape.fill.solid()
shape.fill.fore_color.rgb = color
shape.line.fill.background()
shape.shadow.inherit = False
return shape
def add_severity_badge(slide, left, top, severity, color):
shape = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE,
Inches(left), Inches(top), Inches(1.8), Inches(0.4))
shape.fill.solid()
shape.fill.fore_color.rgb = color
shape.line.fill.background()
tf = shape.text_frame
tf.word_wrap = False
p = tf.paragraphs[0]
p.text = severity
p.font.size = Pt(14)
p.font.color.rgb = TEXT_WHITE
p.font.bold = True
p.font.name = "Segoe UI"
p.alignment = PP_ALIGN.CENTER
tf.paragraphs[0].space_before = Pt(2)
def add_bullet_list(slide, left, top, width, height, items, font_size=14, color=TEXT_WHITE):
txBox = slide.shapes.add_textbox(Inches(left), Inches(top), Inches(width), Inches(height))
tf = txBox.text_frame
tf.word_wrap = True
for i, item in enumerate(items):
if i == 0:
p = tf.paragraphs[0]
else:
p = tf.add_paragraph()
p.text = item
p.font.size = Pt(font_size)
p.font.color.rgb = color
p.font.name = "Segoe UI"
p.space_after = Pt(6)
return txBox
# ============================================================
# SLIDE 1: Title
# ============================================================
slide = prs.slides.add_slide(prs.slide_layouts[6]) # Blank
set_slide_bg(slide)
add_text_box(slide, 1, 0.8, 11, 0.6, "🔐 SHANNON", 16, TEXT_DIM, font_name="Consolas")
add_text_box(slide, 1, 1.4, 11, 1.2, "Security Assessment Report", 48, TEXT_WHITE, bold=True)
add_text_box(slide, 1, 2.6, 11, 0.6, "inou.com — Penetration Test Results", 24, ACCENT_BLUE)
add_text_box(slide, 1, 4.0, 5, 0.4, "Date: February 14, 2026", 16, TEXT_GRAY)
add_text_box(slide, 1, 4.5, 5, 0.4, "Tool: Shannon Lite v1.0.0 (Keygraph)", 16, TEXT_GRAY)
add_text_box(slide, 1, 5.0, 5, 0.4, "Model: Claude Sonnet 4.5 (Anthropic)", 16, TEXT_GRAY)
add_text_box(slide, 1, 5.5, 5, 0.4, "Runtime: ~1.5 hours", 16, TEXT_GRAY)
add_text_box(slide, 1, 6.0, 5, 0.4, "Scope: Auth, XSS, Injection, SSRF, AuthZ", 16, TEXT_GRAY)
# Decorative line
shape = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE,
Inches(1), Inches(3.4), Inches(3), Pt(3))
shape.fill.solid()
shape.fill.fore_color.rgb = ACCENT_RED
shape.line.fill.background()
# ============================================================
# SLIDE 2: Executive Summary
# ============================================================
slide = prs.slides.add_slide(prs.slide_layouts[6])
set_slide_bg(slide)
add_text_box(slide, 0.8, 0.4, 11, 0.6, "Executive Summary", 36, TEXT_WHITE, bold=True)
# Stats cards
for i, (num, label, color) in enumerate([
("4", "Confirmed\nVulnerabilities", ACCENT_RED),
("2", "Critical", ACCENT_RED),
("2", "High", ACCENT_ORANGE),
("3", "Out of Scope\n(Need Internal)", TEXT_GRAY),
]):
x = 0.8 + i * 3.1
add_card(slide, x, 1.3, 2.8, 1.8)
add_text_box(slide, x + 0.3, 1.4, 2.2, 0.9, num, 54, color, bold=True, align=PP_ALIGN.CENTER)
add_text_box(slide, x + 0.3, 2.2, 2.2, 0.7, label, 14, TEXT_GRAY, align=PP_ALIGN.CENTER)
# Summary table
add_card(slide, 0.8, 3.5, 11.7, 3.6)
headers = ["Category", "Findings", "Severity", "Status"]
cols = [1.0, 4.5, 8.5, 10.5]
for j, h in enumerate(headers):
add_text_box(slide, cols[j], 3.6, 2.5, 0.4, h, 13, ACCENT_BLUE, bold=True)
rows = [
("Authentication", "4 vulnerabilities", "2× CRITICAL, 2× HIGH", "⚠️ Exploited"),
("Authorization", "0 vulnerabilities", "", "✅ Passed"),
("XSS", "2 code-level issues", "Requires auth", "🔒 Out of scope"),
("Injection", "1 path traversal", "Requires server access", "🔒 Out of scope"),
("SSRF", "0 vulnerabilities", "", "✅ Passed"),
]
for i, (cat, finding, sev, status) in enumerate(rows):
y = 4.2 + i * 0.55
color = ACCENT_RED if "Exploited" in status else ACCENT_GREEN if "Passed" in status else TEXT_GRAY
add_text_box(slide, cols[0], y, 3, 0.4, cat, 13, TEXT_WHITE, bold=True)
add_text_box(slide, cols[1], y, 3.5, 0.4, finding, 13, TEXT_WHITE)
add_text_box(slide, cols[2], y, 2, 0.4, sev, 13, color)
add_text_box(slide, cols[3], y, 2, 0.4, status, 13, color)
# ============================================================
# SLIDE 3: CRITICAL — Hardcoded Backdoor
# ============================================================
slide = prs.slides.add_slide(prs.slide_layouts[6])
set_slide_bg(slide)
add_text_box(slide, 0.8, 0.4, 11, 0.5, "AUTH-VULN-07", 14, TEXT_DIM, font_name="Consolas")
add_text_box(slide, 0.8, 0.8, 11, 0.7, "Universal Authentication Bypass via\nHardcoded Backdoor Code", 32, TEXT_WHITE, bold=True)
add_severity_badge(slide, 0.8, 1.7, "🔴 CRITICAL", ACCENT_RED)
add_card(slide, 0.8, 2.3, 5.5, 4.5)
add_text_box(slide, 1.1, 2.4, 5, 0.4, "What Shannon Found", 16, ACCENT_BLUE, bold=True)
add_bullet_list(slide, 1.1, 2.9, 5, 3.5, [
"• Hardcoded verification code: 250365",
"• Works for ANY email address — instant account takeover",
"• Bypasses email ownership verification completely",
"• Affects both web (/verify) and mobile API",
"• Source comment: \"TODO: Remove backdoor code 250365",
" before production\"",
"",
"• HIPAA violation: unauthorized access to PHI",
], 13, TEXT_WHITE)
add_card(slide, 6.6, 2.3, 5.9, 4.5)
add_text_box(slide, 6.9, 2.4, 5.4, 0.4, "Proof of Exploitation", 16, ACCENT_RED, bold=True)
add_text_box(slide, 6.9, 2.9, 5.4, 4,
"1. Navigate to inou.com/start\n"
"2. Enter ANY email address\n"
"3. Click \"Continue\"\n"
"4. Enter code: 250365\n"
"5. Full account access granted\n\n"
"Tested with:\n"
" pentest@example.com → ✅ authenticated\n"
" victim@example.com → ✅ authenticated\n\n"
"Code location:\n"
" lib/dbcore.go:347\n"
" if code != 250365 && ...",
13, TEXT_WHITE, font_name="Consolas")
# ============================================================
# SLIDE 4: CRITICAL — Session Hijacking
# ============================================================
slide = prs.slides.add_slide(prs.slide_layouts[6])
set_slide_bg(slide)
add_text_box(slide, 0.8, 0.4, 11, 0.5, "AUTH-VULN-05", 14, TEXT_DIM, font_name="Consolas")
add_text_box(slide, 0.8, 0.8, 11, 0.7, "Session Hijacking via Missing\nServer-Side Invalidation", 32, TEXT_WHITE, bold=True)
add_severity_badge(slide, 0.8, 1.7, "🔴 CRITICAL", ACCENT_RED)
add_card(slide, 0.8, 2.3, 5.5, 4.5)
add_text_box(slide, 1.1, 2.4, 5, 0.4, "The Problem", 16, ACCENT_BLUE, bold=True)
add_bullet_list(slide, 1.1, 2.9, 5, 3.5, [
"• /logout only clears the client-side cookie",
"• Session token NOT invalidated server-side",
"• Stolen cookies remain valid INDEFINITELY",
"• No session timeout mechanism exists",
"• Mobile API has NO logout endpoint at all",
"• No audit log of active sessions",
"• No ability to revoke sessions remotely",
], 13, TEXT_WHITE)
add_card(slide, 6.6, 2.3, 5.9, 4.5)
add_text_box(slide, 6.9, 2.4, 5.4, 0.4, "Attack Flow", 16, ACCENT_RED, bold=True)
add_text_box(slide, 6.9, 2.9, 5.4, 4,
"1. Victim authenticates → cookie set\n"
" login=d74520ade621d4b8\n\n"
"2. Attacker captures cookie\n"
" (via XSS, MITM, physical access)\n\n"
"3. Victim logs out\n"
" → Cookie cleared client-side ONLY\n\n"
"4. Attacker injects stolen cookie\n"
" → Full access STILL WORKS ✅\n\n"
"5. Attacker has permanent access\n"
" → No expiration, no revocation",
13, TEXT_WHITE, font_name="Consolas")
# ============================================================
# SLIDE 5: HIGH — Session Fixation + Brute Force
# ============================================================
slide = prs.slides.add_slide(prs.slide_layouts[6])
set_slide_bg(slide)
add_text_box(slide, 0.8, 0.4, 11, 0.6, "High Severity Findings", 36, TEXT_WHITE, bold=True)
# Left card: Session Fixation
add_card(slide, 0.8, 1.2, 5.8, 5.8)
add_severity_badge(slide, 1.1, 1.4, "🟠 HIGH", ACCENT_ORANGE)
add_text_box(slide, 1.1, 1.9, 5.2, 0.5, "AUTH-VULN-04: Session Fixation", 18, TEXT_WHITE, bold=True)
add_bullet_list(slide, 1.1, 2.5, 5.2, 4, [
"Session IDs (dossierID) not rotated after auth",
"",
"• Same dossierID reused across ALL logins",
"• DossierID is deterministic per email",
"• Attacker authenticates → captures ID",
"• Logs out → re-authenticates",
"• Session ID is IDENTICAL both times",
"",
"Proven: login=f4d22b2137cf536c",
"persisted across logout/re-login cycle",
"",
"Combined with missing invalidation →",
"predictable, permanent session tokens",
], 13, TEXT_WHITE)
# Right card: Brute Force
add_card(slide, 6.9, 1.2, 5.8, 5.8)
add_severity_badge(slide, 7.2, 1.4, "🟠 HIGH", ACCENT_ORANGE)
add_text_box(slide, 7.2, 1.9, 5.2, 0.5, "AUTH-VULN-01: Brute Force", 18, TEXT_WHITE, bold=True)
add_bullet_list(slide, 7.2, 2.5, 5.2, 4, [
"Zero rate limiting on /verify endpoint",
"",
"• No rate limiting (0 HTTP 429s)",
"• No account lockout mechanism",
"• No CAPTCHA after failed attempts",
"• No attempt tracking or monitoring",
"",
"20 rapid requests in 3.1 seconds",
"→ All returned HTTP 200",
"",
"Brute force time estimates:",
" Sequential: ~21 hours",
" 10 parallel: ~2.1 hours",
" 100 parallel: ~12 minutes",
], 13, TEXT_WHITE)
# ============================================================
# SLIDE 6: Out of Scope (Internal Access Required)
# ============================================================
slide = prs.slides.add_slide(prs.slide_layouts[6])
set_slide_bg(slide)
add_text_box(slide, 0.8, 0.4, 11, 0.6, "Out of Scope — Require Internal Access", 36, TEXT_WHITE, bold=True)
add_text_box(slide, 0.8, 1.0, 11, 0.4, "Valid vulnerabilities in code, but exploitation requires auth or server access", 16, TEXT_GRAY)
# Card 1: Path Traversal
add_card(slide, 0.8, 1.6, 3.8, 5.2)
add_text_box(slide, 1.1, 1.7, 3.3, 0.4, "INJ-VULN-01", 12, TEXT_DIM, font_name="Consolas")
add_text_box(slide, 1.1, 2.0, 3.3, 0.5, "Path Traversal in\nFile Upload", 18, TEXT_WHITE, bold=True)
add_bullet_list(slide, 1.1, 2.7, 3.3, 3.8, [
"Sanitization on wrong variable",
"(fileName vs relPath)",
"",
"filepath.Base() applied to",
"fileName, NOT relPath",
"",
"Enables arbitrary file write:",
"• /etc/cron.d/ (persistence)",
"• Web shell placement",
"• Config overwrite",
"",
"upload.go:182-186, :451-462",
], 11, TEXT_WHITE)
# Card 2: DICOM XSS
add_card(slide, 4.9, 1.6, 3.8, 5.2)
add_text_box(slide, 5.2, 1.7, 3.3, 0.4, "XSS-VULN-01", 12, TEXT_DIM, font_name="Consolas")
add_text_box(slide, 5.2, 2.0, 3.3, 0.5, "DICOM Stored XSS\nvia SeriesDescription", 18, TEXT_WHITE, bold=True)
add_bullet_list(slide, 5.2, 2.7, 3.3, 3.8, [
"DICOM tag 0008,103E",
"stored without HTML encoding",
"",
"Rendered via innerHTML in",
"/api/series endpoint",
"",
"Attack: upload crafted DICOM",
"with XSS in SeriesDescription",
"",
"Blocked by: auth requirement",
"(needs valid login cookie)",
], 11, TEXT_WHITE)
# Card 3: LLM Prompt Injection XSS
add_card(slide, 9.0, 1.6, 3.8, 5.2)
add_text_box(slide, 9.3, 1.7, 3.3, 0.4, "XSS-VULN-02", 12, TEXT_DIM, font_name="Consolas")
add_text_box(slide, 9.3, 2.0, 3.3, 0.5, "LLM Prompt Injection\n→ Stored XSS", 18, TEXT_WHITE, bold=True)
add_bullet_list(slide, 9.3, 2.7, 3.3, 3.8, [
"Freeform tracker input passed",
"to Google Gemini LLM",
"",
"LLM output stored unsanitized",
"Rendered via insertAdjacentHTML",
"",
"Attack: prompt injection to",
"make LLM generate XSS payload",
"in tracker question field",
"",
"Blocked by: auth requirement",
"(needs valid login cookie)",
], 11, TEXT_WHITE)
# ============================================================
# SLIDE 7: What Passed
# ============================================================
slide = prs.slides.add_slide(prs.slide_layouts[6])
set_slide_bg(slide)
add_text_box(slide, 0.8, 0.4, 11, 0.6, "What Passed ✅", 36, ACCENT_GREEN, bold=True)
for i, (title, detail) in enumerate([
("Authorization Controls",
"10 authorization candidates tested — ALL passed. RBAC correctly prevents cross-user access to dossiers and medical data. API layer properly enforces ownership checks."),
("SQL Injection",
"All database queries use parameterized statements. No injection vectors found in any endpoint. SQLite with AES-256-GCM encryption at rest."),
("Command Injection",
"No command injection vectors found. External binary calls properly sanitized."),
("SSRF",
"Initial finding reclassified as Open Redirect (HTTP 303 client-side redirect, not server-side fetch). No true SSRF present."),
("Network Hardening",
"Internal services (API on 8082, DICOM on 8765) properly bound to localhost only. Single public port (8443). No subdomains exposed."),
]):
y = 1.3 + i * 1.15
add_card(slide, 0.8, y, 11.7, 1.0)
add_text_box(slide, 1.1, y + 0.05, 2.8, 0.4, title, 16, ACCENT_GREEN, bold=True)
add_text_box(slide, 1.1, y + 0.45, 11, 0.5, detail, 12, TEXT_GRAY)
# ============================================================
# SLIDE 8: Recommendations
# ============================================================
slide = prs.slides.add_slide(prs.slide_layouts[6])
set_slide_bg(slide)
add_text_box(slide, 0.8, 0.4, 11, 0.6, "Remediation Priorities", 36, TEXT_WHITE, bold=True)
recs = [
("1", "IMMEDIATE", "Remove backdoor code 250365",
"lib/dbcore.go:347 — Delete the hardcoded bypass. Deploy today.", ACCENT_RED),
("2", "IMMEDIATE", "Implement server-side session invalidation",
"Add session revocation to /logout. Add session expiration (e.g., 24h). Add session management UI.", ACCENT_RED),
("3", "THIS WEEK", "Add rate limiting to /verify",
"Max 5 attempts per email per 15 minutes. Add CAPTCHA after 3 failures. Log failed attempts.", ACCENT_ORANGE),
("4", "THIS WEEK", "Rotate session IDs on authentication",
"Generate new dossierID or session token after each successful /verify. Invalidate old tokens.", ACCENT_ORANGE),
("5", "NEXT SPRINT", "Sanitize DICOM metadata + LLM output",
"HTML-encode SeriesDescription before rendering. Sanitize all LLM output before insertAdjacentHTML.", ACCENT_BLUE),
("6", "NEXT SPRINT", "Fix path traversal in upload.go",
"Apply filepath.Base() to relPath (not just fileName). Validate paths stay within upload directory.", ACCENT_BLUE),
("7", "HARDENING", "Add HSTS headers + restrict CORS",
"Strict-Transport-Security on all responses. Replace wildcard CORS with specific origins.", TEXT_GRAY),
]
for i, (num, timeline, title, detail, color) in enumerate(recs):
y = 1.2 + i * 0.85
add_card(slide, 0.8, y, 11.7, 0.75)
add_text_box(slide, 1.0, y + 0.05, 0.3, 0.35, num, 18, color, bold=True, font_name="Consolas")
add_text_box(slide, 1.5, y + 0.02, 1.6, 0.35, timeline, 11, color, bold=True)
add_text_box(slide, 3.2, y + 0.02, 4, 0.35, title, 14, TEXT_WHITE, bold=True)
add_text_box(slide, 3.2, y + 0.38, 9, 0.35, detail, 11, TEXT_GRAY)
# ============================================================
# Save
# ============================================================
out = "/home/johan/clawd/memory/shannon-scan-2026-02-14/inou-security-assessment-2026-02-14.pptx"
prs.save(out)
print(f"Saved: {out}")