Security and ACL - The-40-Thieves/obsidian-tc GitHub Wiki
obsidian-tc is safe by default. Five mechanisms guard every tool call โ auth, ACL, the read-only kill switch, HITL confirmation, and idempotency + rate limiting โ evaluated in the Auth, ACL, and Policy layers of the dispatch pipeline (see Architecture). Full design: docs/G2.4-security.md.
Three modes: none, jwt, and oidc. JWT accepts HS256 (shared jwtSecret) or asymmetric RS256 / ES256 / EdDSA verified against a JWKS โ inline jwks or a file-loaded jwksFile, with an algorithms allowlist and kid-based key rotation. HS256 verifies only against the secret and asymmetric algs only against the JWKS, so the alg-confusion attack is structurally impossible. The server can run its own bundled OAuth authorization server (auth.as), with metadata documents, optional DCR, refresh and revocation; see docs/src/content/docs/security/auth-model.md. Tokens minted by token mint carry a jti and can be revoked before expiry with obsidian-tc auth revoke <jti> (checked on every request); auth rotate-key rotates the HS256 signing key, optionally with a grace window, and auth list shows issued tokens. The registry lives in its own <cacheDir>/auth.db (not regenerable: back it up, and the server refuses every bearer if it is lost after first use); auth.requireJti rejects tokens with no jti (securityProfile: "hardened" turns it on; the default flips at the next major). A bad signature, expired token, revoked token, or missing claim returns acl_denied โ the server does not distinguish a bad token from no access, to avoid information leak.
Fail-closed interlock: the config is refused at load when transports.http.enabled && auth.mode === "none" and the bind host is non-loopback. An unauthenticated server can never serve a routable host.
Scopes are op-on-path: read:vault, write:vault/02-projects/**, delete:vault/..., execute:<plugin>, admin. The ACL layer parses each tool's declarative annotation (e.g. acl: write on path), resolves the resource against the vault's readPaths / writePaths / deletePaths globs, and matches it against the caller's scopes. A path's rules scopes are additionally required of the caller to operate on that path (P1.4, last-match-wins) โ enforced centrally at dispatch on tool operations, and they also filter results: a search, listing or graph result naming a path whose scopes the caller lacks is dropped, as readPaths drops a path outside the read whitelist. The inspect_acl admin tool tests any (vault, path, op, scopes) tuple, including the path-required scopes.
The root acl is the inherited default; each vaults[] entry may carry its own acl block (same shape) to override it per vault โ e.g. writable in vault A, read-only in vault B, enforced at dispatch in one process. Set strictReadDefault: true to make an undefined readPaths fail closed on reads.
The scope check fires in the ACL layer only โ never scattered across every tool implementation โ so adding a scope class is a single parser change.
Path enforcement is inode-aware: an in-vault symlink pointing outside the vault is rejected by realpath canonicalization, and a hard-linked file (st_nlink > 1) is rejected under a folder ACL (a hard link aliases an inode realpath cannot dereference). Reads run on the opened fd. The .obsidian/.git/.trash default-deny folds case, so a case-variant control-directory path cannot evade it on a case-insensitive filesystem (Windows/macOS).
Every committed mutating tool call appends one hash-chained record to cache.db: the tool, the vault, the paths the call named with sha256 digests before and after (never content), the time, and attribution. Chains are per vault and signed with the auth registry's EdDSA key, with a signed head row so a removed last record is detectable. Attribution is tagged by trust: verified (host, server version, transport, and the principal/persona only when a bearer token was verified), unauthenticated (a stdio or auth.mode: none label), and self_reported (client name/version and the _meta io.obsidian-tc/provenance model/project/agent/machine, which a client can lie about). A stdio-only deployment has no registry key, so its chain is chain-only (unsigned). obsidian-tc provenance verify checks it (exit 1 on failure; unsigned records fail unless --allow-unsigned), doctor reports a provenance chain check, and provenance.retentionDays prunes the oldest prefix while keeping the chain verifiable. Full page: docs/src/content/docs/security/write-provenance.md.
Global acl.readOnly: true short-circuits every write/delete to a read_only_mode error before dispatch. The fastest way to make a server safe.
Destructive or large operations require confirmation. A tripped tool returns an elicit_required error carrying an elicit_token; the client re-invokes the same tool with that token to proceed. Tokens are single-use, 5-minute default TTL (configurable via elicitTtlSeconds), bound to (vault, tool_name, args_hash, caller) โ a token cannot authorize a different tool or different args, and replay returns token_already_consumed.
This is a custom token pattern, not MCP's native elicitation capability, so it works with any MCP client. A client that also advertises MCP elicitation gets an inputRequired round trip instead; a client that does neither (e.g. Claude Code) has no way to complete the confirmation on its own โ mint the token from the command line instead: obsidian-tc elicit --hash <args_hash> --tool <tool_name> (see docs/src/content/docs/security/hitl-elicit.md for the full flow and its authorization boundary).
| Condition | Default |
|---|---|
delete_attachment with references |
required if reference_count > 0
|
| Bulk create / set-property | required if count > 50
|
| Canvas node removal | required if removing > 10 nodes |
move_attachment references |
required if reference_count > 10
|
ocr_bulk file count |
required if > 20
|
| Cross-folder copy / move | required when crossing a top-level folder boundary |
Task done โ todo flip |
required if it was done > 7 days ago |
write_note overwrite |
required when overwriting a non-empty existing file |
write_attachment overwrite |
required when replacing an existing attachment (the prior bytes go to .trash) |
reset_vault_cache |
always required |
These conditions are hardcoded floors โ a client cannot configure them away, which keeps the confirmation gate present even under a permissive config. There is no per-vault or global threshold key in the config schema; the only elicitation-related setting is elicitTtlSeconds, which controls how long an issued token stays valid, not when confirmation is demanded. Execute-family tools (git_commit, command execution, bulk-destructive paths) sit on the same floor and always require confirmation.
Every note write exposes a prev_hash (compare-and-swap): pass the hash you last read, and the write is rejected with concurrent_modification if the note changed underneath you. This covers write_note (overwrite), append_note, and update_frontmatter โ defense-in-depth for multi-writer setups. Optional by default; set writes.requireCas: true to make it mandatory on the destructive paths, which then fail closed when prev_hash is absent.
Write tools accept an idempotency_key. A replay within the TTL (idempotencyTtlSeconds, default 24h) returns the cached result and skips re-execution โ safe retries for flaky networks and agent loops.
Each tool has a class โ read, write, or bulk โ enforced by a per-vault token bucket plus a max-concurrent-writes-per-vault cap. A trip returns rate_limit with retry_after_ms.
Buckets are process-local by default. throttle.backend moves them to sqlite (shared by every process using one cacheDir, and surviving restarts) or redis (shared across instances); throttle.failurePolicy (fail-open by default, or fail-closed) decides what a call does while a shared backend is unreachable. See "Rate-limit backends" on the docs site (Security section).
| Error | Meaning | Retryable |
|---|---|---|
acl_denied |
Path outside allowed scope, or bad/expired token | no |
read_only_mode |
Kill switch active | no |
elicit_required |
HITL confirmation needed (see details.elicit_token) |
yes, after elicit |
concurrent_modification |
File changed between read and write | yes, with re-read |
rate_limit |
Throttle hit | yes, after retry_after_ms
|
plugin_missing / plugin_unreachable
|
Bridge dependency absent / endpoint failed | no / yes |
bulk_partial |
Per-item failures (see details.results[]) |
partial |
Do not file security issues as public GitHub Issues. Follow SECURITY.md.