🔐 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
- Assets and trust boundaries
- Controls in the implementation
- Residual risks and operating guidance
- Safe operating practices
- Reporting a suspected security issue
🎯 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.
Content-root containment and symlink-aware 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
texoutput and a--fragmentunit 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
.epsfigure no longer converts, because graphicx converts one throughrepstopdfover shell escape; use a PDF or PNG figure instead. - Pandoc compiles in the system temp directory, so a
pdfrender needs aTMPDIRwith 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¶
- Keep
papers/,content/,config.toml, logs, and backups under permissions appropriate for the corpus. Run the pipeline as an unprivileged user. - Treat imported BibTeX exports, PDFs, archives, custom CSL files, TeX, and configuration overrides as inputs that require review.
- Keep
content/on a local filesystem. Do not run concurrent writers against an NFS, SMB, cloud-synchronised, or otherwise shared content directory. - Let
syncandenrichfinish; treat exit code2as a competing writer rather than a reason to bypass the lock. Investigate repeated failures and the pipeline log rather than deleting lock files. - Use
dossier restorewithout--forcefirst. Review the planned new and overwritten files before permitting the extraction. - Install and update dependencies from the reviewed lockfile or released package path. Review dependency, action, and workflow changes as security-sensitive changes.
- Run
python -m chitragupta.draft gate <draft>immediately before sharing or publishing a draft. Then perform the relevant human review and advisory checks. - Use the project's normal documented release workflow. Do not add PyPI API tokens to repository files, configuration, or CI logs.
- 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.