OpsHub is in pre-1.0 (0.x) development. Security fixes are issued only for
the latest minor version line:
| Version | Status | Security fixes |
|---|---|---|
| 0.1.x | Current | Yes |
| < 0.1.0 | Pre-release | No (development snapshots only) |
When the next minor version ships (e.g. 0.2.0), the previous line (0.1.x) will receive a 30-day grace period for backported security fixes before being declared end-of-life.
If you discover a security vulnerability in OpsHub, please report it through GitHub Private Vulnerability Reporting:
- Go to the Security tab of this repository
- Click Report a vulnerability
- Fill in the details and submit
We acknowledge reports within 3 business days and aim to release a fix as soon as the severity-appropriate timeline allows:
| Severity | Target time to fix |
|---|---|
| Critical (RCE, secret leak, auth bypass) | 7 days |
| High | 14 days |
| Medium / Low | 30 days |
Please do not open a public issue for security vulnerabilities.
OpsHub is a local-first single-user CLI / secretary agent platform
(docs/principles.md §1 Local-first). The threat model assumes:
- The CLI runs on a workstation the operator trusts and controls
- The SQLite database, config file, and keyring contents are protected by OS user-level access controls
- LLM / embedding / connector API tokens are stored in the OS keychain (ADR-0014) — they never appear in the event log, the source body store, MCP tool arguments, or stdout
- The MCP server runs in stdio one transport only — no HTTP / SSE listener is implemented (ADR-0022 §(a)). Network-listen surface is structurally non-applicable
Phase 10 (ADR-0020, supersedes ADR-0005) switched OpsHub from "retain summaries only" to retain full source body locally (Slack message bodies, GitHub issue / PR bodies, Outlook bodies, Box text extraction). This is the unavoidable backbone of the secretary agent platform — recall, cross-source search, and reply-draft style replication all require body access. The shift carries operator responsibilities:
- Encryption at rest (
[storage] encryption = trueinopshub.toml, ADR-0021) is opt-in. Operators handling regulated / sensitive bodies should enable it; the SQLCipher backend ships under theencryptionextras and reuses the keyring path (db:encryption_keyslot, env overrideOPSHUB_DB_ENCRYPTION_KEY). Without encryption, the DB stores bodies as on-disk plaintext — pinned bytests/unit/core/test_encryption.py::test_unencrypted_db_leaks_body_as_plaintextso the regression cannot land silently. - Ingest excludes (
~/.config/opshub/excludes.yaml, ADR-0020 §(b)) keep specific channels / senders / repos / paths out of the body store entirely — operators decide what not to retain. - Backups — encrypted or not, the SQLite file under
$XDG_DATA_HOME/opshub/db/is now the canonical store of your source body history. Treat it like you would your password vault. - Multi-user / shared workstation operation is unchanged: out of scope.
Phase 11 widens the body-retention surface to:
- Microsoft Teams chat bodies (ADR-0010 §改訂 (a)) — chat messages ingested through Microsoft Graph delta query land in
sources.bodytaggedprovenance_origin="external"+provenance_trust="untrusted"(ADR-0020 §(e)). Theteamsconnector uses User Token (delegated permissions) by default (ADR-0010 §改訂 (d)); a Bot Token (application permissions) is an alternative that grants tenant-wide chat access — operators must consider the privacy implication before opting in (docs/teams-setup.md"Bot Token" section). - Outlook deep body (ADR-0010 §改訂 (a) + Phase 11 F3) — the existing
ms365connector's Outlook mapper now retains the full email body (head-truncated at 500K chars). Pre-Phase-11 rows withbody = NULLcontinue to round-trip cleanly. - Office document content extraction (ADR-0025) — when an operator opts in (
[connectors.box_drive] content_extraction = true/[connectors.onedrive_drive] content_extraction = true),.docx/.xlsx/.pptxfiles on the scanned FS mount are extracted via markitdown and stored under newsource_types (word_document/excel_spreadsheet/powerpoint_slide_deck). The opt-in narrows the ADR-0019 §不変条件 (b)open()ban to a single extractor path (ADR-0019 §決定 (b')) — diff path (stat()-only fingerprint) stays unchanged, so CldAPI / File Provider Extension hydration cost is bounded.
Phase 11 operator responsibilities continue along the same axes as Phase 10:
- Encryption at rest covers the new body classes transparently — the
[storage] encryption = trueswitch already encrypts the entire SQLite DB, so Office body content and Teams chat bodies inherit the same protection without additional knobs. - Ingest excludes extend to Teams via the existing
channels/sendersselector (matched againstchat_id/chat_topic/sender_id) and to FS-scan connectors via the existingpathsselector (matched againstrel_path). The same~/.config/opshub/excludes.yamlcovers all 7 connectors. The file uses top-level flat keys only (channels/senders/repos/pathsper ADR-0020 §(b)); nested per-connector forms (e.g.teams: { channels: [...] }) are rejected withConfigErrorso a typo / stale doc never silently disables an intended exclusion (pinned bytests/unit/core/test_excludes.py::test_nested_form_raises). - Teams token handling — Microsoft Graph User Tokens are JWT-shaped bearers (not the
xoxp-form Slack uses). Tokens flow through the existingcore/secrets+ keyring path (ADR-0014 reuse) withOPSHUB_CONNECTOR_TEAMS_TOKENenv override. The mapper never embeds the token in observed messages, and the MCP layer'sredact_secretscontinues to strip JWT-shaped fragments from tool returns (ADR-0022 §(b)). - Office extraction is
content_extraction = trueopt-in — default-off keeps Phase 9/10 operators on the metadata-only FS scan path. Enabling it pulls only the markitdown sub-deps the extras declare (mammoth/openpyxl/python-pptx), so the cold-install footprint stays small. - External write-back remains forbidden (ADR-0010 §禁止事項 7). The Teams connector deliberately exposes no
send/post/replycallable; reply-draft skill output is local only.
Phase 12 (ADR-0004 revision §決定 (c-2) + ADR-0022 revision §決定 (f) + ADR-0016 revision §決定 (l)) grows the secretary skill catalog from 5 to 14 and widens the MCP surface with 4 new tools (search FTS5 + propose.apply HITL idempotent + physical-column time filters on the existing 4 read tools). The MCP boundary surface picks up one carve-out but no relaxations:
propose.applyis the first write-class MCP tool to advertisedestructive=false(ADR-0022 §決定 (f),WriteCategory.PROPOSE_APPLY,destructive=false+idempotent=true). All other write tools (task.create/inbox.add/connector.sync/propose.generate) keepdestructive=true. The carve-out is admissible because the apply path is idempotent at the handler layer: the handler catchesOpsHubError("already applied" / "already rejected")fromProposalService.applyand normalises the second-call response to{ok: true, already_applied: true, applied_entity_id}so retries never throw and never produce a second persist. The annotation honesty contract (tests/unit/mcp/test_registry_policy) pins this — a write tool advertisingdestructive=falsewithout an idempotent handler is a regression.- The HITL apply contract is unchanged (ADR-0016 §決定 (c)).
propose.applyis still operator-triggered — the host LLM cannot self-dispatch it; the MCP annotation policy continues to require human confirmation on every write tool (ADR-0022 annotation policy, Phase 10). - No new external write-back path. The 4 new HITL-write skills (
reply-draftwas already Phase 10;inbox-triage/source-extract/meeting-followupare new) all draft into localproposals/inbox_items/tasksprojections; none of them post back to SaaS. The Phase 10/11 write-back ban (ADR-0010 §禁止事項 7, 緊張点③) continues to hold. The two text-only draft skills (handoff-draft/announcement-draft) deliberately have no persist path at all — they emit Markdown for the operator to copy/paste, and there is nopropose applyroute for them. - No DB schema changes / no event-schema changes / no new connectors. The new MCP tools read from existing projections (
task.list/inbox.list/decision.list/source.list/sources_fts) or wrap existing services (ProposalService.apply). No new attack surface in storage / projections / connectors. searchMCP tool surface: FTS5 input is phrase-quoted by default; the CLI-only--raw-queryflag is intentionally absent from the MCP schema so a host LLM cannot smuggle raw MATCH operators through the boundary. Pinned by the schema test intests/unit/mcp/test_phase12_handlers.py.
Phase 13 (ADR-0010 revision §Phase 13 (e)-(h) + ADR-0014 revision (rotation pin list 3rd entry) + ADR-0025 revision (§決定 (d') + (j))) widens the body-retention surface to:
- Google Workspace native bodies (ADR-0025 §決定 (d') + (j)) — when an operator opts in (
[connectors.google_workspace] content_extraction = true+[office]extras),google_doc/google_slides/google_sheetsfiles are exported via Drive APIfiles.export(fileId, mimeType=<MS Office mediatype>)and routed through the Phase 11 markitdown path (core/document_extract.extract_workspace_export(bytes, source_type)). The bodies land insources.bodytaggedprovenance_origin="external"+provenance_trust="untrusted"(ADR-0020 §(e)) — same poisoning-mitigation contract as Phase 10 / 11 bodies. Non-native Drive files (the catch-allgoogle_workspace_filesource_type) stay metadata-only regardless of the opt-in.
Phase 13 operator responsibilities continue along the same axes as Phase 10 / 11:
- Encryption at rest covers the new Google Workspace body classes transparently — the
[storage] encryption = trueswitch already encrypts the entire SQLite DB, sogoogle_doc/google_slides/google_sheetsbodies inherit the same protection without additional knobs. - Ingest excludes extend to the Google Workspace connector via the existing
pathsselector in~/.config/opshub/excludes.yaml(ADR-0020 §(b)) — matched against the Drive item path so an operator can keep a specific Drive folder out of the body store at fetch time. The top-level flat key convention is the same as Phase 10 / 11 (nestedgoogle_workspace: {...}forms are rejected withConfigError). - Token handling — Google Refresh Tokens flow through the existing
core/secrets+ keyring path (ADR-0014 reuse) underconnector:google_workspace:refresh_tokenwithOPSHUB_CONNECTOR_GOOGLE_WORKSPACE_REFRESH_TOKENenv override. The connector follows the MS365 / Box rotation-write-back pattern (ADR-0010 §Phase 13 改訂 (h)): when Google returns a fresh refresh token during a refresh round-trip, the new value is written back to the keychain so the next process resumes cleanly. This is deliberately separate from the Teams verbatim user-token pattern — the two patterns coexist in ADR-0010 because Google Drive's ~1 hour access-token TTL makes verbatim-token storage operator-hostile (re-paste every hour), while Teams' operator-driven MSAL device-code flow is verbatim by design. Tokens never appear in observed Drive items, and the MCP layer'sredact_secretscontinues to strip JWT-shaped fragments from tool returns (ADR-0022 §(b)). - Workspace export extraction is
content_extraction = trueopt-in — default-off keeps Phase 13 G3 operators on the metadata-only path (nofiles.exportround-trip, no markitdown cost). Enabling it requires both the[connectors-google-workspace]extras (for the Drive API client) and the[office]extras (for markitdown). The Phase 11 size / chars caps (ADR-0025 §決定 (b)) carry over verbatim; the cap is applied to the exported MS Office bytes rather than the Google-native size that Drive does not expose for native Docs / Sheets / Slides. - External write-back remains forbidden (ADR-0010 §禁止事項 7). The
google_workspaceconnector deliberately exposes no Drive write API callables (files.update/files.create/files.copy/comments.create/permissions.*); reply-draft / handoff / announcement skill output is local only. Theforbidden_callablesstructural guard in the lifecycle integration tests covers the new connector too. - Drive push notification (
files.watch) is forbidden (ADR-0010 §Phase 13 改訂 (e) §禁止事項拡張). The connector polls Drive APIchanges.listonly —files.watchwould inject server-driven activity into opshub and break form-A (no proactive runtime, ADR-0004). Re-enablingfiles.watchrequires a new ADR + opt-in + form-A revisit.
Phase 14 (ADR-0010 revision §Phase 14 (i)-(m) + ADR-0014 revision (scope expansion + shared auth foundation)) adds two new connectors — google_mail (Gmail) and google_calendar (Google Calendar) — that share the existing Phase 13 Google OAuth principal via a new shared connectors/google_auth/ foundation. No new DB schema, no breaking CLI changes, no new extras (connectors-google-workspace is reused for httpx). The body-retention surface widens to:
- Gmail bodies (
gmail_message、Outlook と symmetric な抽出) — text/plain preferred → text/html raw kept; no markitdown; no attachment retention. Bodies land insources.bodytaggedprovenance_origin="external"+provenance_trust="untrusted"(ADR-0020 §(e)) — same poisoning-mitigation contract as Phase 10 / 11 / 13 bodies. Labels prepend as[Labels: INBOX, ImportantWork]; truncation appends[gmail body truncated: N / M chars];threadIdis kept as a field. - Google Calendar bodies (
google_calendar、MS365 Calendar と symmetric な抽出) — master event only (recurringEventId無し)。Google API returns recurring overrides as standalone events; these are emitted as separateSourceObservedrecords sharingsource_type="google_calendar"withOverride of: <master_id> (originalStart: <iso>)back-pointer in the body. Summary followsf"{start_iso} - {end_iso} ({attendees_count} attendees)"; attendee email list / description / location are appended to the body; RRULE is kept as a field.
Phase 14 operator responsibilities continue along the same axes as Phase 13:
- Encryption at rest covers the new Gmail / Calendar body classes transparently — the
[storage] encryption = trueswitch already encrypts the entire SQLite DB, sogmail_message/google_calendarbodies inherit the same protection without additional knobs. - Ingest excludes extend to both connectors via the existing top-level flat selectors in
~/.config/opshub/excludes.yaml(ADR-0020 §(b)) —sendersfor Gmail addresses,paths/ event titles for Calendar. - Token handling — Gmail / Calendar reuse the existing
connector:google_workspace:refresh_tokenkeyring slot withOPSHUB_CONNECTOR_GOOGLE_WORKSPACE_REFRESH_TOKENenv override. Phase 14 widens the scope at the OAuth principal layer to the 3-scope fixed listdrive.readonly + gmail.readonly + calendar.readonly(ADR-0014 revision). One re-consent applies to all three connectors (1 Google account = 1 principal sharing Drive + Gmail + Calendar). The token-rotation-write-back pattern from Phase 13 (ADR-0010 §Phase 13 改訂 (h)) carries over unchanged; the rotation pin test moved to the shared auth foundation (tests/unit/connectors/google_auth/) so a single test covers all three connectors. - External write-back remains forbidden (ADR-0010 §禁止事項 7). Gmail
sendAPI and Calendarevents.insert/events.patch/events.deleteare deliberately not implemented in either connector; reply-draft / handoff / announcement skill output is local only. Theforbidden_callablesstructural guard in the lifecycle integration tests covers both new connectors too. - Gmail / Calendar push notification (
users.watch/events.watch) is forbidden (ADR-0010 §Phase 14 改訂 (i)). Both connectors poll only —watchwould inject server-driven activity into opshub and break form-A. Re-enabling requires a new ADR + opt-in + form-A revisit (Phase 15+ proactive-secretary work re-evaluates Drive / Gmail / Calendar push notifications together). - Mapper symmetry is mechanically verified by
tests/unit/connectors/test_mapper_symmetry.py— Outlook ↔ Gmail (8 cases) and ms365_calendar ↔ google_calendar (6 cases), 14 cases total. The symmetry stops mapper drift from creating asymmetric body shapes that would surprise downstream skills (e.g.find-documentfiltering by source_type).
We treat the following as security issues:
- API token leaks — a token reaching
stdout/stderr/ event payload / log files / MCP tool arguments / MCP tool results / source body store. Thecore.sanitise.sanitise_error_messagehelper redacts known patterns (sk-.../ghp_.../Bearer ...); the MCP layer additionally runs every tool return throughopshub.mcp._redact.redact_secrets(ADR-0022 §(b)) — bypasses are bugs (ADR-0015 §決定 (g)). - Prompt injection / memory poisoning — an attacker who can write content
to a source body (e.g. a GitHub issue body or Slack message that gets ingested via a
connector) causing the LLM to execute attacker-controlled instructions
past the
<source>...</source>boundary established by ADR-0015 §決定 (f). Phase 10 added the new attack surface "poisoned body in the local store" (ADR-0020 §Negative) — mitigated byprovenance_origin="external"+provenance_trust="untrusted"tags on all connector-fetched bodies and the append-only rollback path (rebuild from event log discards poisoned projections). - Event-log integrity — bypassing
EventStore.appendto mutate state without an event (ADR-0002). - Apply-path bypass —
ProposalService.applyreachingtasks/decisionsprojections without going throughTaskService.create_task/DecisionService.record_decision(ADR-0016 §決定 (g)). - Auto-apply — any code path that bypasses the human-in-the-loop apply
contract (ADR-0016 §決定 (c) —
opshub propose applymust be operator-triggered). - MCP boundary violations — a write-class MCP tool advertising
readOnlyHint=true, an MCP tool input schema accepting a SaaS token, the MCP server opening a network listener (HTTP / SSE), or an MCP handler echoing full body text instead of a ≤200-char snippet (ADR-0022 §(a)–§(d)). Pinned bytests/unit/mcp/test_no_network_listenandtests/unit/mcp/test_registry_policy. - External write-back path appearing — any code that posts to Slack /
GitHub / Box / MS365 / writes back to SaaS (ADR-0010
§禁止事項 7, Phase 10 Sub-issue E). The secretary deliberately drafts only;
the operator sends. A future PR adding a
send/post/comment_createconnector method without a separate ADR + opt-in is a security regression.
- Running OpsHub on an untrusted multi-user host (the threat model is single-user; multi-user is not supported)
- Compromise of the operator's workstation (keychain access, filesystem access, swap / hibernate disk recovery) — those are upstream OS concerns
- Phishing / social engineering of the operator
- LLM hallucinations that produce incorrect but non-malicious content (this is an LLM quality issue, not a vulnerability)
- Memory consumption with maliciously large inputs (file an issue as a bug if you hit it, but not a security advisory)
- Operator choice not to enable
[storage] encryptionwhile retaining sensitive bodies — this is a documented opt-in (see Phase 10 body retention — what changed above)
Phase 14 epic #317 (ADR-0027) added a
structured verbosity surface (-v / -q / --debug / --log-format /
--log-file plus the matching OPSHUB_LOG_LEVEL / OPSHUB_LOG_FORMAT /
OPSHUB_DEBUG / OPSHUB_LOG_FILE env vars) so an operator can investigate
incidents without compromising token / key hygiene. The following
invariants hold at every verbosity level:
- All log values pass through the redaction processor (R1). A
structlog processor in
src/opshub/core/logging.pywalks every event value — including the traceback string expanded bystructlog.processors.format_exc_info— throughopshub.core.sanitise.sanitise_error_message. Known token shapes (sk-...,ghp_...,github_pat_...,xoxp-/xoxb-/xoxa-/xoxr-/xoxs-,AKIA...,AIza...,eyJ...eyJ...JWTs, andBearer ...) are rewritten to their marker form before any renderer serialises the event. The processor is inserted afterformat_exc_info(sologger.exception(...)traceback strings are also scrubbed) and before the JSON / console renderer (so the scrubbed dict is what gets serialised). --debugtracebacks are sanitised (R2). Themain()error wrapper renders full tracebacks throughopshub.core.logging.format_debug_traceback, which joinstraceback.format_exceptionoutput and pipes the result throughsanitise_error_message. The CLI never prints a rawstr(exc).- Connector sync stays type-name only by default (R3). The default
opshub connector sync <name>failure path writes only the exception type name to theConnectorSyncFailedevent and to the one-linesync failed: <Type>summary on stderr. The exception message is never persisted there. (The success path —synced <name>: N item(s) observed— stays on stdout, so scripts redirecting stdout to capture results still work; only the failure summary rides stderr.) Only when--debug(orOPSHUB_DEBUG=1) is set does the CLI additionally print a sanitised exception message + sanitised traceback to stderr; the stderr summary line and the event-logerror_messagefield stay byte-identical to the default path. --log-fileis created at mode 0600 (R5). When--log-file PATHorOPSHUB_LOG_FILE=PATHis set, the file is opened viaos.open(path, O_CREAT | O_WRONLY | O_APPEND, 0o600), so the permission bits are tight from byte zero. Existing files have their mode preserved (operators may have intentionally tightened them). Operators sharing a log file with a third party should still review the content — module paths, line numbers, and internal IDs survive redaction by design.- MCP boundary keeps no token passthrough, even under
--debug. Theredact_secretslayer on the MCP tool response path (ADR-0022 §(b)) is independent of the structlog processor and runs on everyCallToolround-trip. A--debug(orOPSHUB_DEBUG=1) launch ofopshub mcp servedoes not relax this; the agent-host transcript never sees a raw token.
The contract is operator-visible in docs/troubleshooting.md
(Phase 14 epic #317), and the wiring is pinned by
tests/unit/core/test_logging.py (redaction + 0600 mode +
format_debug_traceback),
tests/integration/test_cli_troubleshooting_options.py (CLI > env >
default precedence + OPSHUB_DEBUG=1 export on -vv / --debug),
tests/unit/cli/test_connector_debug.py (sync default = type-name only,
--debug opt-in adds sanitised stderr), and
tests/unit/mcp/test_logging_redaction.py (MCP boundary redaction).
OpsHub does not implement custom cryptography. We rely on:
- OS keychain (via keyring) for SaaS token storage and the SQLCipher DB key (ADR-0014 reuse, ADR-0021 §(b))
- TLS (via the upstream SDK or
httpx) for all SaaS API calls - SQLCipher (via
sqlcipher3-binary,encryptionextras) for at-rest AES-256 encryption of the entire SQLite DB (ADR-0021, opt-in). Operators not enabling it should layer OS-level filesystem encryption (FileVault / LUKS / BitLocker) instead
We will credit reporters in the CHANGELOG and the corresponding GitHub Security Advisory unless anonymity is requested.