Skip to content

🔐 Security architecture

Status: reference. Written 2026-09-01. Updated 2026-09-02, adding the SonarQube scan action (a credential-bearing third-party action the first version of that section did not name) and its token to the release controls. Updated 2026-10-01: every action is now SHA-pinned, and the release workflow installs Poetry wheels-only (#829).

How chitragupta protects the locally managed research corpus and its derived artefacts, where its trust boundaries are, and what remains the operator's responsibility.

Written for users operating a corpus and developers changing the pipeline or its release automation.

Assumed: ARCHITECTURE.md for the four-layer design, CLI.md for commands, and CONFIG.md for settings. Not covered here: a general system overview, an organisational security policy, or a guarantee that untrusted documents and tools are safe to execute.

🧭 Table of contents

🎯 Security goals and explicit non-goals

Chitragupta is a local-first research-corpus pipeline. It reads the BibTeX and PDF files that you manage, writes derived artefacts below the configured content directory, and uses locally installed tools such as pdftotext, Pandoc, and TeX Live when a command needs them.

Its main security goals are to keep ordinary pipeline processing bounded to the configured project, preserve the provenance of citekeys, avoid shell interpolation when it invokes local tools, and limit credentials in the release path.

This project does not claim to provide:

  • a sandbox for PDFs, TeX, Pandoc, Docling, or other local dependencies;
  • malware detection, document sanitisation, or protection against a malicious parser or renderer;
  • validation that a cited paper supports the surrounding claim;
  • protection from users, processes, or administrators that can already read or modify the project directory, its configuration, its inherited environment, or executables on PATH;
  • perfect protection against filesystem races, compromised dependencies, or host-level compromise; or
  • a substitute for human review of generated research prose.

The citation gate is deliberately narrow: it verifies that a citekey in a draft exists in the synced ledger. It does not determine whether the citation is relevant, accurate, or sufficient. The advisory review tools and, ultimately, the author remain responsible for those judgements; see ARCHITECTURE.md.

🧱 Assets and trust boundaries

Asset Why it matters Trust boundary / owner
papers/bibliography.bib and referenced PDFs Source bibliography, citekeys, metadata, and source documents User-managed local input; review imported exports and PDFs before choosing to process them
config.toml and environment variables Select the project, bibliography, content directory, parser, and other runtime behaviour Trusted operator configuration; environment variables override TOML values
content/ledger.sqlite The synced set of citekeys that the gate treats as authoritative Pipeline-managed local state under the configured content root
Parsed-text, Docling, Chroma, and topic caches Derived corpus text, embeddings, passages, and topic artefacts Local derived data; may contain information extracted from the corpus
Drafts, dossiers, renders, and review reports Research drafts and their supporting records User-reviewed work product; generated prose is not automatically authoritative
Local executables and libraries pdftotext, Pandoc, LuaLaTeX, pdflatex, Docling, Python packages, and GPU tooling process corpus data Trusted local toolchain chosen and maintained by the operator
GitHub Actions and PyPI release artefacts Build, documentation, release archives, wheels, and packages Repository-maintainer and CI trust boundary
Claude Code or another external AI session A drafting session can receive corpus-derived context or user instructions Optional external-service boundary; not required by the corpus pipeline

The deterministic corpus layer does not fetch papers or call an LLM API. It processes the bibliography and attachment paths you provide. The optional drafting workflow uses the skills of an agent harness (Claude Code, Codex or OpenCode; see LLM-AGENTS.md), so any information placed in that session is subject to the AI service, account, client, and network controls selected by the operator. The repository itself contains no LLM API key requirement.

🛡 Controls in the implementation

Citekey provenance gate

python -m chitragupta.draft gate <draft> reads known citekeys from content/ledger.sqlite and refuses citations that no corpus sync recorded. The bibliography is the only citekey source: the pipeline does not generate, rename, or fetch citekeys.

Before sync creates derived filenames, chitragupta/bib_reader.py rejects citekeys unsafe for filesystem use. An unsafe entry is skipped with a warning rather than silently rewritten. The same validator (chitragupta/citekey_safety.py) also guards the review layer's reads: citekeys there are extracted from a draft, not the bib file, so a draft citing \citep{../../secret} must resolve, and does resolve, to no source text rather than to a file outside the content tree. Every per-citekey file name is built by citekey_safety.citekey_path(), which refuses such a key, so a command-line citekey such as draft tldr show ../../x is refused too, before any file is read.

The drafting skills run the gate before presenting a draft. On Claude Code, Codex and OpenCode, a hook or plugin also runs it after writes the agent's own file tools make under content/drafts/ (HARNESS.md). It does not see a write made through the shell, Continue runs none of these hooks, and Codex behind llama.cpp is never offered the hooked tool (LOCAL-MODELS.md), so not every draft write is checked automatically. draft render runs the gate in every case. Because this is a post-write check, an invalid draft can exist on disk until it is corrected. Run the gate yourself before sharing, rendering, committing, or relying on a draft. HOOKS.md documents the hook boundary and its launcher checks.

Commands that accept draft paths use config.require_inside_content() before reading or writing them. chitragupta/config_path.py resolves both the candidate path and CONTENT_DIR before comparing them. This makes .. components and existing symlinks answer for their actual destination rather than their textual spelling.

The rendering and review paths apply additional output-directory checks so a configured or encountered symlink cannot redirect their derived output outside the resolved content root. These checks define the configured content directory as the pipeline's working boundary; they are not an access-control system, and path validation cannot eliminate a race with a process that can alter the filesystem concurrently.

The files a draft references get the same treatment. A figure or image reference is resolved before the figure-layout aid compiles it or a render copies it beside the output. One that lands outside the draft's own directory is skipped, whether it is absolute, ..-escaping, or a symlink (of the file or of a directory on the way to it). A render goes further for the symlink case: pandoc and the TeX engine open a draft's images and \input files by the name the draft spells, so a draft that names one through a symlink out of its directory is refused with [error] before either tool runs, rather than rendered without it. A link that stays inside the draft still works.

Safer archive restore

chitragupta.dossier exports and restores draft records as tar.gz bundles. Restore is a dry run unless the operator supplies --force.

Before extraction, chitragupta/dossier/_archive.py refuses the entire archive if a member is not a regular file or directory, is absolute, contains .., or is outside the allowlisted top-level roots: drafts/, dossiers/, rendered/, and review/. Extraction also uses Python's tarfile filter="data" protection. These checks resist common traversal, link, device-node, and unexpected-root archive attacks; they do not make restored Markdown, TeX, PDFs, or other regular files semantically trustworthy.

Parameterised local-tool invocation

The Python implementation invokes local commands through argument vectors, including the PDF extractor and the Pandoc command built by the render module. The repository has no subprocess call with shell=True.

This avoids passing corpus paths and tool arguments through a shell parser. It does not protect against vulnerabilities or unsafe behaviour in the called executable, a malicious executable earlier on PATH, or dangerous content interpreted by the toolchain. The PDF and rendering sections of CLI.md identify which commands require local tools.

Every program that chitragupta/ and .claude/hooks/ launch is run by absolute path, resolved by chitragupta/programs.py from the absolute entries of PATH and nothing else (#974). OpenCode's adapter, .opencode/chitragupta/gate.js, applies the same rule in Node before it spawns python (#1025). A bare name handed to subprocess.run would leave the lookup to the OS, and on Windows CreateProcess searches the current directory before PATH, as shutil.which does there too. On any host a relative or empty PATH entry is the current directory under another name. The current directory is often a cloned or shared project, so a pdftotext.exe or python.exe planted in it would otherwise run with the user's privileges. The same applies to the TeX engine Pandoc starts, whose --pdf-engine is passed as an absolute path; what Pandoc or TeX start in turn (rsvg-convert for an SVG figure, say) is their own lookup and outside this. On Windows a name gets .exe, as CreateProcess would give it, so a .bat or .cmd earlier on PATH is never chosen: a batch file runs through cmd.exe, which re-parses its arguments. An absolute PATH entry inside the project, such as an activated .venv/bin, is still searched: that entry is the operator's choice, not the directory's. tests/test_bare_launch_scan.py scans every launch in both directories and fails on one whose program is not resolved this way, and fails on any child_process launch in the repository's JavaScript whose program is a string literal.

Every TeX engine this codebase starts (LuaLaTeX for a pdf render through Pandoc, pdflatex for the figure-layout aid's probe) runs with -no-shell-escape and with kpathsea's openin_any=p. The first turns off \write18 entirely, including TeX Live's default restricted allow-list. The second is kpathsea's paranoid read mode: TeX may not open a name with a .. or a dot-directory in it, nor an absolute name outside the output directory, though a relative name it finds on its search paths (TEXINPUTS, the TeX installation) is still allowed. A draft whose raw LaTeX says \input{/home/alice/.netrc} therefore fails to render and names the file instead of printing it. Pandoc's own --sandbox is deliberately not used: it confines Pandoc's reads, not the engine's, and it drops a docx render's images.

Accepted gap: MiKTeX ignores openin_any. openin_any is a kpathsea variable, and MiKTeX, the TeX distribution docs/WINDOWS.md installs, does not use kpathsea. On a MiKTeX host the variable is set and nothing reads it, so the paranoid read mode above does not apply there: nothing this codebase sets stops a draft's raw \input{C:/Users/alice/secret.txt}, and whether MiKTeX's own configuration does has not been measured. The .bib path stays closed, because the Lua filter below does not depend on the engine. A Windows host that renders drafts someone else can write should use TeX Live, which honours the variable.

A .bib field's TeX does not reach the engine as code. pandoc's BibTeX reader turns a command it does not understand into raw LaTeX, and keeps $...$ as a math node whose source it prints verbatim; citeproc carries both into the reference list. Every render that runs citeproc (all but --fragment) therefore runs assets/pandoc/bib_raw_tex_as_text.lua just before it, which rewrites every bibliography entry citeproc is about to read so that its raw TeX prints as literal text, and does the same for a math node that contains one of the few primitives that run code or reach a file (\directlua, \csname, \input, \write and the like). A shared .bib whose title says \input{/home/alice/.netrc} or \directlua{...}, in text or inside $...$, renders with that text visible and nothing read or run. Ordinary content is untouched: accents, \emph, \textsubscript and plain mathematics such as $\alpha \leq \beta$ are left exactly as the reader produced them.

Accepted residual: Lua in the author's own TeX. A pdf render runs LuaLaTeX (#996), which embeds a Lua interpreter, and openin_any and -no-shell-escape fence TeX's own file access, not Lua's. TeX in a draft body or in a figures/*.tex file can run Lua with \directlua, or with an equivalent built through \csname, so a blocklist on the text cannot catch it. That Lua can read any file the user can read, write anywhere the user can write, and read environment variables (measured on TeX Live 2023: plans/996-unicode-pdf-engine.md, Q2). The project accepts this: a draft and its figures are the author's own text, and rendering one is running it. Only the .bib path, which carries a collaborator's text, is closed. In a shared tree where someone else can write your drafts or figures, treat a pdf render as running their code. Two possible follow-ups are recorded and not implemented: confining Lua inside LuaTeX, and running the engine under a Landlock ruleset (available on this host: Linux 5.15, Landlock ABI 1).

Paranoid mode judges a name as TeX spells it and follows symlinks, which is why the render refuses a draft's own symlinked references itself (above). Four limits follow:

  • A name that is not in the draft's text (one in a bibliography field, say) that points at a symlink someone planted in the draft's directory is still followed.
  • A tex output and a --fragment unit carry the same raw LaTeX to whoever compiles them later, with their own engine and their own settings, which this project does not control.
  • An .eps figure no longer converts, because graphicx converts one through repstopdf over shell escape; use a PDF or PNG figure instead.
  • Pandoc compiles in the system temp directory, so a pdf render needs a TMPDIR with no dot-directory in its path (not ~/.cache/tmp). One that has one is reported before Pandoc runs.

Local-first corpus processing

chitragupta corpus sync reads a user-supplied BibTeX export and its referenced local PDFs. It writes the ledger and parsed text locally. It does not download papers, crawl a source, or call an LLM API.

The optional enrichment layer also processes the same corpus locally, but some optional model dependencies may download models on first use. Review the model and package source before enabling that layer. See CONFIG.md and CONFIG.md.

Exported topic-graph pages

corpus discover --html FILE and --app DIR write pages a reader opens from file:// and may pass on. Topic labels, titles and citekeys in them are corpus-derived, and a label can come from a PDF's extracted keywords. So every string that is built into markup goes through a five-character escape (&, <, >, " and ') in both pages, and the --app directory's index.html also declares a Content-Security-Policy (default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'). Under that policy, markup that gets past the escaping still cannot run an inline handler, re-point the page's relative URLs with a <base>, or submit a form. The single-file --html page carries no policy: its script is inline by design, so script-src 'self' would block the page itself.

One writer at a time

sync and chitragupta enrich use the same SQLite-backed mutex at content/pipeline.lock.db. The lock holds BEGIN IMMEDIATE; a competing writer exits with code 2 rather than interleaving writes. Read-only operations deliberately remain available while a writer runs.

This prevents the pipeline's own writers from overlapping on a local filesystem. SQLite locking is not reliable on network filesystems, so this control assumes content/ is local storage. Treat a local filesystem as an operational requirement for writer safety, not an optional tuning choice. ARCHITECTURE.md describes the lock and its scope, and Operational requirement: local filesystem for runlock states the deployment requirement and mitigations.

Dependency and release controls

The checkout uses poetry.lock to fix resolved Python package versions and hashes. The install path documents when it must allow a source archive, while the documentation workflow uses wheels only where that is compatible with its dependency set.

The workflows use explicit permissions, scoped per workflow or per job depending on the file. docs.yml grants contents: read on the build job and pages: write + id-token: write only on deploy. ci.yml grants workflow-level contents: read. release.yml grants workflow-level contents: write, then narrows the publish-pypi job to id-token: write only. PyPI Trusted Publishing exchanges that OIDC token for a short-lived token instead of storing a long-lived PyPI API token.

Every action the workflows use is commit-SHA pinned, with a # vX.Y.Z comment naming the release the SHA was resolved from. Four of them are third-party actions that publish releases, publish to PyPI, or upload external analysis: softprops/action-gh-release, pypa/gh-action-pypi-publish, codecov/codecov-action, and SonarSource/sonarqube-scan-action. The rest are GitHub's own actions/*. These were left on major tags such as @v4 until #829, because Sonar's S7637 did not flag them. But a tag can be moved to point at different code whoever owns the action, and actions/download-artifact is the one step between the built wheel and the PyPI publish. So .github/dependabot.yml proposes the pin bumps as one grouped weekly PR, and DEVELOPER-AGENTS.md's "Issues and pull requests" says how to finish one.

The release workflow installs Poetry wheels-only (--only-binary=:all:), like the CI and documentation workflows. A pinned version fixes which release is installed, not whether pip builds it, or a dependency, from source when no wheel matches.

Two of the four third-party actions consume a long-lived repository secret rather than an OIDC token exchanged per run: secrets.CODECOV_TOKEN and secrets.SONAR_TOKEN, both in ci.yml. Those two are the part of this section worth re-auditing when a maintainer leaves, when a service is dropped, or on any schedule your organisation applies to third-party credentials. They cannot expire on their own the way the PyPI publish path's token does, which is why that path deliberately has no equivalent.

⚠ Residual risks and operating guidance

PDFs and the local toolchain are trusted execution dependencies

A PDF is data to chitragupta, but it is input to substantial native and Python parsing stacks. Pandoc and TeX process generated rendering inputs. Keep Poppler, Pandoc, TeX Live, Python, and optional enrichment dependencies current through the package-management process appropriate to your host.

Do not process a suspicious corpus with elevated privileges. For material from an untrusted source, prefer a separate unprivileged account or an isolated disposable environment that meets your organisation's operating requirements. This project does not supply that isolation.

Local permissions remain decisive

The pipeline runs with the permissions of the invoking account. Restrict access to the project, bibliography, PDFs, content/, logs, backups, and archives according to the sensitivity of the corpus. Check ownership and permissions after running installation or scheduled jobs, especially when using sudo.

Backups and dossier export bundles can contain drafts, review reports, and rendered outputs. Protect them with the same care as the original corpus.

Archive restore denial-of-service limits

dossier restore validates archive member type and path before extraction. It refuses links, device nodes, traversal paths, and members outside drafts/, dossiers/, rendered/, and review/.

Those checks reduce traversal-style risk, but the current implementation does not enforce hard limits on member count, per-member size, or total extracted bytes. A path-valid archive can still consume excessive disk space, inode budget, memory, or runtime when restored with --force.

Operator guidance: restore only trusted archives, keep the default dry run first, and preflight unusually large bundles before extraction. When possible, restore in a quota-limited workspace or disposable environment so an oversized archive cannot exhaust shared host resources.

Operational requirement: local filesystem for runlock

The runlock (content/pipeline.lock.db) and the ledger depend on SQLite locking semantics that are reliable on local filesystems. Treat local storage for content/ as a deployment requirement for any writer workflow (sync, enrich, and scheduled runs).

On network or synchronised filesystems (for example NFS, SMB, or cloud-sync mounts), lock visibility and ordering are not reliable. That can allow overlapping writers, partial-read races, and inconsistent pipeline state despite the lock file being present.

Mitigation options: keep the writable content/ tree on local disk; use one designated writer host and replicate outputs afterwards; or serialize writes through external orchestration while avoiding shared writable network mounts for the active pipeline state.

Configuration and environment are part of the trust boundary

CONFIG_PATH, CHITRAGUPTA_PROJECT, BIB_FILE, CONTENT_DIR, and the other documented setting environment variables can alter which files are read or written and which optional components run. Environment values override config.toml.

Use an explicit, minimal environment for cron, systemd, CI, or other automation. Do not run scheduled jobs from a shell profile that imports unreviewed environment variables. Limit write access to config.toml and avoid pointing CONTENT_DIR at a shared or unintended location. CONFIG.md gives the precedence rules.

Generated drafting content needs human review

The citation gate proves citekey membership in the ledger, not factual correctness, citation entailment, copyright compliance, or the absence of harmful wording. Review generated drafts, citations, quotations, references, and final rendered output before publication. The review commands are evidence for a human decision and deliberately do not block a draft.

Optional external AI sessions may create egress

The corpus, gate, rendering, and review commands do not require an LLM API key. If you use the shipped Claude Code skills or another AI-assisted workflow, the session itself may send prompts, excerpts, paths, or other context to an external service depending on your client and settings.

Before using AI assistance with confidential, licensed, personal, or export-controlled research material, assess the service's data-handling terms and your organisation's rules. Minimise the context shared, use an approved account and client configuration, and choose a local workflow when external processing is not acceptable.

✅ Safe operating practices

  1. Keep papers/, content/, config.toml, logs, and backups under permissions appropriate for the corpus. Run the pipeline as an unprivileged user.
  2. Treat imported BibTeX exports, PDFs, archives, custom CSL files, TeX, and configuration overrides as inputs that require review.
  3. Keep content/ on a local filesystem. Do not run concurrent writers against an NFS, SMB, cloud-synchronised, or otherwise shared content directory.
  4. Let sync and enrich finish; treat exit code 2 as a competing writer rather than a reason to bypass the lock. Investigate repeated failures and the pipeline log rather than deleting lock files.
  5. Use dossier restore without --force first. Review the planned new and overwritten files before permitting the extraction.
  6. Install and update dependencies from the reviewed lockfile or released package path. Review dependency, action, and workflow changes as security-sensitive changes.
  7. Run python -m chitragupta.draft gate <draft> immediately before sharing or publishing a draft. Then perform the relevant human review and advisory checks.
  8. Use the project's normal documented release workflow. Do not add PyPI API tokens to repository files, configuration, or CI logs.
  9. For scheduled runs, use explicit absolute paths, an explicit working directory, and a minimal service environment. Follow the examples in CLI.md.

📣 Reporting a suspected security issue

Do not include credentials, private PDFs, unpublished drafts, personal data, or a working exploit in a public issue.

Check the repository's GitHub Security page and any current project-maintainer guidance for a private reporting channel, and use that channel when it is available. GitHub reads docs/SECURITY.md (this file) as the repository's security policy, so the Security page shows you this text. Do not take finding it there as evidence that a private channel exists: a policy and a channel are separate facts. If no private channel is published, disclose only the minimum non-sensitive information needed to request a private conversation; do not assume an email address, response time, bounty, or coordinated-disclosure policy that the project has not published.

A useful initial report states the affected released version or commit, operating system, command or component, expected and observed behaviour, security impact, and safe reproduction steps. Maintainers determine triage, remediation, release, and disclosure arrangements.