linkedin-studio/docs/ingestion-guard/plan.md
Kjell Tore Guttormsen d67552eab1 docs(linkedin-studio): ingestion-guard adoption plan — persist-gate integration map (status: planned)
Read `llm-ingestion-guard` v0.2 adoption brief; mapped this repo's untrusted-ingest
surface (brief §7 checklist) against 882f6ee via two independent read-only surveys.

- New `docs/ingestion-guard/plan.md`: when/where to wire the guard at the deterministic
  persist gates (`screen_output` at trends `capture`, `brain ingest`, specifics-bank
  `ekstern` bindings, analytics CSV). Ranked by automated-reinjection risk; the trends ->
  `session-start.mjs` reinjection is the one live poison->trusted-read loop (priority 1).
- Honest scope: only the `screen_output` half maps cleanly (fetch/transform happen inside
  the model turn — no `your_model()` code seam); `prepare_input` has no clean wiring point.
- Python<->Node interop is the blocker (plan §7); no code wired (brief = plan-only).
- OKF `import_bundle` has no seam today (brain is export-only); relevant only when SB-S4
  connector or a cross-plugin shared skill lands.

Machine-readable marker line for the guard repo's roll-up lives in STATE.md
(LOCAL-ONLY / gitignored, so the roll-up is machine-local).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: 57adea42-c8f1-4b88-acb6-2453e1239c79
2026-07-16 10:54:09 +02:00

16 KiB
Raw Blame History

Ingestion-guard integration plan — when & where to wire llm-ingestion-guard

Status: planned (2026-07-16). Plan only — nothing wired yet, per the adoption brief's "don't implement now" instruction. This document is the repo's durable answer to when and where a write-time ingestion guard earns its place at our persist gates.

Guard: llm-ingestion-guard v0.2 (alpha, public API may change). Write-time sibling of query-time chatbot guardrails; hardens untrusted content flowing through an LLM step into a persisted, downstream-read store. Python, stdlib-only core, 3.10+.

Adoption brief (authoritative, self-contained): https://git.fromaitochitta.com/open/llm-ingestion-pipeline-security/raw/branch/main/docs/ADOPTION-BRIEF.md (§7 planning checklist, §3 the 8-step contract, §4 the OKF adapter, §8 honest limitations).

Grounded against 882f6ee by two independent read-only surveys (ingest surface + persist seams), 2026-07-16. Every seam below carries a file:line anchor.

1. Decision

Status = planned. The plugin has live untrusted-ingest paths (a react-to-URL command, an auto-fetching trend agent, an external-research newsletter fan-out), and it persists their output into stores a downstream agent later reads as trusted context. That satisfies the brief's §7 decisive condition. Nothing is wired. The guard is registered here as a dependency to add before the first automated external-origin ingest path goes fully live — and one such path (trends → session re-injection, §5.1) is arguably already live, so it is the first integration candidate, not a distant one.

We do not implement now. The brief is explicit ("kun planlegg og oppdater planene"), and wiring is gated on an unresolved interop decision (§7).

2. What the guard is (and is not) for us

It is not a query-time guardrail between the user and the model. It is a write gate: the last place the provenance of a piece of content is still known before it is committed to a store that a later agent reads as trusted. For this plugin that store is the second-brain (brain/profile.md, ingest/published/), the trends store, the specifics-bank, the post queue, and the state file's ## Recent Posts — all of which are re-surfaced into future model context. A poisoned concept committed at write time is read back later with its origin forgotten; the write gate is the only place to catch it.

3. Ingest-surface analysis (untrusted vs first-party)

Scored against the brief's §7 checklist. "Downstream trusted reader" = a command/agent/hook that later reads the store as context, which is what turns a write into a poisoning surface.

# Path Origin Untrusted? Persist target Downstream trusted reader Live?
1 trend-spotter agent (agents/trend-spotter.md) → trends CLI capture (scripts/trends/src/cli.ts:294) Auto-fetched web/vendor/regulator content (WebSearch/WebFetch + research MCPs) YES — external trends/trends.jsonsource/title/url/summary stored verbatim (scripts/trends/src/item.ts:24-48) session-start.mjs:38-78 auto-reinjects into the next session's context — no human in the loop LIVE
2 brain ingest / scanInbox (scripts/brain/src/ingest.ts:172-192,:247) User's own published posts, dropped into ingest/inbox/ (manual) Origin first-party today; untrusted-capable (drop-zone accepts any file; SB-S4 connector would automate it) ingest/published/<id>.md voice-trainer gold source (agents/voice-trainer.md:136,144); brain consolidatebrain/profile.md; assemble/reconcile LIVE (manual)
3 /linkedin:newsletter research fan-out (commands/newsletter.md:461-475) Open-web research agents (WebSearch/WebFetch) YES — external specifics-bank ekstern bindings (scripts/specifics-bank/src/kilder.ts:51-52) + NN-kilder.md + queue.json Edition prose + sources ledger; queue readers LIVE
4 /linkedin:react, :post, :pipeline, :batch URL ingest (commands/react.md:52, post.md:62, pipeline.md:13, batch.md:12) External URL (news, blog, YouTube, social threads) via WebFetch YES — external react/post: clipboard + state ## Recent Posts metadata only (raw content not persisted); pipeline/batch: draft files + queue.json state ## Recent Posts re-injected (user-prompt-context.mjs:109-117, session-start.mjs:341); queue readers LIVE
5 /linkedin:import CSV (scripts/analytics/src/parsers/csv-parser.ts) User's own LinkedIn analytics CSV export First-party origin; container-layer parse surface analytics/posts/*.json (storage.ts:143-160) report/audit/analyze, analytics-interpreter, brain assemble LIVE
6 /linkedin:competitive, :outreach (commands/competitive.md:33, outreach.md:223) WebSearch of competitor/partner/event content YES — external Nothing durable (inline report only) — (no persist) LIVE
7 Received third-party OKF bundle External bundle YES — external does not exist FUTURE / not built
8 setup, onboarding, first-post, quick, specifics-bank, voice-samples User's own typed/pasted content No — first-party state file / voice-samples / specifics-bank content commands LIVE

4. Where the guard applies — and where it deliberately does not

Applies (wire here): the untrusted boundaries — rows 15. Trust follows the data's origin, not the insertion channel (brief §7): a manual paste of an external article (/linkedin:react) is still external.

Does not apply (out of scope by design):

  • First-party authoring (row 8): onboarding, setup, typed post ideas, specifics-bank (human-only), voice-samples, the user's own profile edits. The guard's threat model does not target trusted-author in-place edits.
  • Fetch-but-no-persist (row 6): competitive/outreach fetch external content but write nothing durable — there is no downstream-trusted store to poison, so the write-time guard has no seam. (Their risk is query-time, a different tool's job.)
  • Row 2 today is first-party by origin (the user's own posts). It becomes an untrusted boundary the moment SB-S4 (the EU/EEA DMA connector) or any received-bundle path automates the inbox — see §9.

5. Integration points (persist gates) + minimal wiring

Key structural finding: every external fetch in this plugin goes through the model's WebFetch/WebSearch/ MCP tools — there is no raw-HTTP your_model() seam in plugin code. The fetch → transform → persist pipeline is: model tool call → agent reasoning → deterministic CLI write. So the guard's classic two-bookend model (prepare_inputyour_modelscreen_output) only half-maps: screen_output (scan-before-persist) wires in cleanly at the deterministic CLI write points; prepare_input (sanitize+fence before the model) has no clean code seam because the fetch and transform happen inside the model's turn (§6).

Coverage gap to know: hooks/scripts/content-gatekeeper.mjs is the only PreToolUse(Write|Edit) choke-point, but it inspects the file path, never the content bytes, and only fires for the Write/Edit tool on drafts/assets paths. The four durable trusted-context stores below are all written via Bash-invoked node/tsx, so they bypass the gate entirely. A byte-level guard must wire at the CLI write points, not solely at content-gatekeeper.

Ranked by automated-reinjection risk (highest first):

  • Seam: before store.ts persists to trends/trends.json, in scripts/trends/src/cli.ts capture.
  • Wiring: screen_output(item.title + "\n" + item.summary, PRESET_USER_UPLOAD); on Disposition.FAIL_SECURE, route the item into the CLI's existing errors[] channel (cli.ts:294-320) instead of persisting — a drop, not a crash.
  • Why first: external content, stored verbatim, auto-surfaced back into future model context by session-start.mjs with no human gate. This is the sharpest write→trusted-read loop in the plugin.

5.2 brain ingest / scanInbox — the voice/profile gold seam (priority 2)

  • Seam: writePublished(rec) in scripts/brain/src/ingest.ts:172-192; especially the scanInbox (:247) path that reads user-dropped ingest/inbox/*.md.
  • Wiring: screen_output(body, ...) before write; FAIL_SECURE → do not promote to ingest/published/, log to a rejects sidecar. Once a bundle-shaped receive lands, switch to okf.import_bundle (§8).
  • Why: ingest/published/ is the ranked-#1 gold source for voice-trainer and feeds profile.md via consolidation — the highest-trust downstream read in the plugin. Note the existing provenance=published guard here is an anti-model-collapse control (authorship axis), not an anti-injection control (origin axis) — the two are orthogonal; this seam has the former, not the latter.

5.3 newsletter research → specifics-bank ekstern bindings (priority 3)

  • Seam: before an external research finding persists as an ekstern binding (scripts/specifics-bank/src/kilder.ts:51-52).
  • Wiring: screen_output on the claim text + source; FAIL_SECURE → quarantine, surface to the operator in the fact-check sweep the newsletter pipeline already runs.

5.4 analytics CSV import — the container-layer gate (priority 4)

  • Seam: saveBatch in scripts/analytics/src/utils/storage.ts:143-160 (or in parseLinkedInCSV).
  • Wiring: row-content scan (CSV formula-injection = + - @, active-content) complementing the existing filename-only sanitization (sanitizeDate/Id/Week/Month + verifyPathWithinDirectory, storage.ts:110-137). Lower priority: origin is first-party and the JSON is never re-emitted to a spreadsheet, so the practical blast radius is small — but there is currently no row-content sanitization layer, so it is a real (if narrow) gap.

6. The prepare_input caveat (honest scope of what we can wire)

The input-side bookend (sanitize + fence before the model, contract steps 12) has no clean code seam here: the untrusted content is fetched by the model's WebFetch tool and transformed by the drafting agent within the same model turn, so plugin code never holds the raw input to wrap. Partial mitigations exist — a sanitize pass on fetched text inside the URL commands' prompts, or reading tool_input.content in content-gatekeeper for the Write-tool draft path — but neither is the clean prepare_input(untrusted) call the library assumes. What we can wire cleanly and fully is screen_output at the persist gates (§5).

Partly, the contract's real security already holds structurally: the drafting agents largely reason over fetched text (close to "tool-less transform"), and the persist step is a deterministic CLI ("output as data, parsed to a schema"). The lexicon/entropy scan is defense-in-depth on top of that, not the wall.

7. Python ↔ Node interop — the real integration cost

The guard is Python (stdlib, 3.10+). This plugin's runtime is Node ESM .mjs hooks (deliberately zero-npm-dep) + TypeScript engine via tsx. The only Python in the repo is one build-time script (hooks/scripts/compile-hooks.py), never on a data path. So wiring the guard means crossing a subprocess boundary. Options:

Option Shape Trade-off
(a) spawnSync('python3', …) at the engine CLI write points (recommended) The scripts/{trends,brain,specifics-bank,analytics} CLIs (already tsx, not the zero-dep hook hot path) shell out to a python -m llm_ingestion_guard scan Adds a python3 + pip install llm-ingestion-guard runtime dependency to the engine layer only; keeps the zero-dep Node hooks untouched. Cleanest fit.
(b) Port minimal sanitize + scan_output to a .mjs twin Reimplement in Node Defeats the point of adopting a maintained guard; the coverage matrix (126/126) would not apply to the port. Rejected unless a hard no-Python constraint appears.
(c) Bash step inside the command, not the hook Command invokes the scan before the CLI write Non-deterministic (depends on the agent running the step); weaker than a code-enforced gate.

Blocker to resolve first: whether a python3 + one-package runtime dependency is acceptable given the plugin's zero-dep design value. This is the gate on any wiring work.

8. OKF import_bundle — future / conditional

The plugin is export-only toward OKF: its brain emits OKF-compatible form (type: + per-level index.md + root okf_version, landed 2026-06-26, docs/okf-convergence-brief.md). There is no import_bundle and no third-party-bundle receive path anywhere in the repo, and inbox auto-classify/convert is explicitly deferred (brief §11). So the guard's okf.import_bundle(bundle, origin=EXTERNAL, channel=AUTOMATIC) adapter has no seam today.

It becomes relevant if/when either lands: (i) SB-S4 — the EU/EEA DMA portability connector auto-feeding ingest/inbox/; or (ii) a cross-plugin shared retrieval skill (a separate standalone plugin, per the convergence brief §8) that merges other plugins' brains. At that point, wire okf.import_bundle at scanInbox with allow_reserved chosen per channel (received bundle → True; materialised individual uploads → False, per brief §4).

9. When — roadmap triggers

  1. Now: planned. No wiring. (This doc + the STATE.md marker.)
  2. First wiring candidate — trends screen_output (§5.1): the trends→session-reinjection loop is already live, so per the brief's "include it before the first untrusted ingest path goes live," this is the earliest concrete target once §7 is resolved.
  3. Hard trigger (not optional) — before SB-S4 or any received-bundle path: the EU/EEA DMA connector, or a cross-plugin shared skill, turns ingest/inbox/ from "user's own manual paste" into an automated external-origin ingress. Wire §5.2 (and §8's import_bundle) before that path goes live — this is the brief's "when, not if" moment.
  4. Opportunistic: §5.3 (newsletter) and §5.4 (CSV container-layer) can ride whichever hardening session touches those CLIs.

Sequencing note: none of this is on the current docs/plan-2026-07/ roadmap (N1N32). It is a new, security-scoped work item to slot in after §7 is decided — most naturally as its own hardening slice, not by displacing the agreed N-plan.

10. Honest limitations (carried from brief §8 — a green scan is not "safe")

  • Semantic / factual poisoning is invisible to lexicon + entropy — a plausible-but-wrong concept (wrong metric, wrong runbook step) carries no suspicious token and passes clean. Highest impact for a second-brain. Our existing anti-sycophancy / evidence-threshold / keep-both-timestamped stance in the consolidation loop is the human-in-the-loop mitigation; the deterministic guard does not judge semantics.
  • Dormant / broken-link injection: a link to a not-yet-existing target passes a write-time scan; payload planted later. Relevant to the brain's cross-links.
  • A document that describes attacks is a false positive — security notes documenting injection payloads trip carrier-strip. Matters if the plugin ever ingests security content.
  • Text-only, extracted-text-only — no file parsing in the core; extract text first, scan with high-untrust upload provenance.

11. Existing write-time precedents the guard complements (not replaces)

The plugin already has three narrow write-gate defenses; the guard generalizes the class rather than duplicating them:

  • state-updater.mjs uses a replacement function (not string) on every section-append to neutralize $&/$`/$'/$$/$n from $-bearing user topics (:14-25,:117-125) — defends the regex mechanics, not content semantics.
  • analytics/storage.ts sanitizes filenames + verifyPathWithinDirectory (:110-137) — path-traversal defense, not row content.
  • brain provenance=published — model-collapse guard (authorship), orthogonal to injection (origin).

12. Out of scope for this plan

No guard code, no pip install, no CLI wiring, no hook changes. This is the when/where map and the dependency registration only, per the adoption brief. Implementation is a separate, operator-approved work item gated on §7.