Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
238af1b
fix(cli-auth): atomic writes + observable failures on PAT rotation
dgokeeffe May 6, 2026
56503ef
sec(enterprise): SECURITY.md + auth-leak fix + telemetry opt-out + st…
dgokeeffe May 16, 2026
13e3a02
ci(release): generate signed SBOM on each release
dgokeeffe May 16, 2026
3671a63
docs(legal): add Project Support / AS-IS disclaimer to README
dgokeeffe May 17, 2026
73fc328
fix(security): close unauth info-disclosure surface (setup-status, pa…
dgokeeffe May 17, 2026
1f084a8
docs: move SBOM verification to docs/SECURITY.md, drop stale plans
mpkrass7 May 19, 2026
3fdad07
chore(deps-dev): update pytest-playwright requirement
dependabot[bot] Jun 7, 2026
64ca345
chore(deps): bump click from 8.3.3 to 8.4.1
dependabot[bot] Jun 7, 2026
574ad2f
chore(deps): bump idna from 3.16 to 3.17
dependabot[bot] Jun 7, 2026
b03d354
chore(deps): bump pydantic-core from 2.46.4 to 2.47.0
dependabot[bot] Jun 7, 2026
b3fca4c
docs: document Omnigent host integration
dgokeeffe Jul 10, 2026
557592b
docs: correct Omnigent identity and grant guidance
dgokeeffe Jul 18, 2026
bced517
docs(auth): describe loopback SP token broker
dgokeeffe Jul 19, 2026
681d91f
chore(deps): bump actions/setup-python from 6.2.0 to 7.0.0
dependabot[bot] Jul 26, 2026
adc436b
chore(deps): bump softprops/action-gh-release from 3.0.0 to 3.0.2
dependabot[bot] Jul 26, 2026
b3d2fc4
chore(deps): bump actions/checkout from 7.0.0 to 7.0.1
dependabot[bot] Jul 26, 2026
0eb89ce
chore(deps): bump astral-sh/setup-uv from 8.1.0 to 9.0.0
dependabot[bot] Aug 2, 2026
70a3839
fix(tests): scope e2e prerequisite skip to the e2e directory
dgokeeffe Aug 5, 2026
c4b0b31
Merge PR #102
dgokeeffe Aug 5, 2026
a6cca67
Merge PR #103
dgokeeffe Aug 5, 2026
6ad8723
Merge PR #104
dgokeeffe Aug 5, 2026
2d249fb
Merge PR #105
dgokeeffe Aug 5, 2026
e1d9459
Merge PR #80
dgokeeffe Aug 5, 2026
de4f2fa
Merge PR #81
dgokeeffe Aug 5, 2026
f181567
Merge PR #83
dgokeeffe Aug 5, 2026
cacb024
Merge PR #84
dgokeeffe Aug 5, 2026
831cbcf
Merge branch 'staging-main' into pr41-merge
dgokeeffe Aug 5, 2026
2c0f843
docs: keep litellm design doc referenced by app.py/setup_opencode/set…
dgokeeffe Aug 5, 2026
71a0abd
Merge PR #41: signed SBOM on release
dgokeeffe Aug 5, 2026
4f4a9a5
Merge branch 'staging-main' into pr43-merge
dgokeeffe Aug 5, 2026
d1026e4
Merge PR #43: Project Support / AS-IS disclaimer
dgokeeffe Aug 5, 2026
1f84863
Merge branch 'staging-main' into pr93-merge
dgokeeffe Aug 5, 2026
ad5a234
Merge PR #93: document Omnigent host integration + loopback SP broker
dgokeeffe Aug 5, 2026
6b468e6
Merge staging-main into fix/cli-auth-rotation-race
dgokeeffe Aug 5, 2026
4af9aae
Merge PR #23: atomic writes + observable failures on PAT rotation
dgokeeffe Aug 5, 2026
0282fe9
chore(config): declare every ENABLE_<CLI> toggle in each app.yaml ove…
dgokeeffe Aug 5, 2026
fdc9c4d
Merge chore/overlay-toggle-parity (salvaged from #30)
dgokeeffe Aug 5, 2026
c0e72b1
Merge staging-main into fix/info-disclosure-trim
dgokeeffe Aug 5, 2026
45fbdba
Merge PR #44: close unauth info-disclosure on setup-status, pat-statu…
dgokeeffe Aug 5, 2026
d321d41
Merge staging-main into fix/enterprise-security-quick-wins
dgokeeffe Aug 5, 2026
6feb3ba
Merge remote-tracking branch 'origin/main' into pr40-merge
dgokeeffe Aug 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions .github/SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# Security policy

CoDA (Coding Agents on Databricks Apps) runs inside a customer's Databricks workspace and holds the user's PAT in-process. Vulnerabilities in CoDA can affect the security posture of every workspace that deploys it — we take responsible disclosure seriously.

## Reporting a vulnerability

**Please do not open a public GitHub issue for security vulnerabilities.**

We support two private disclosure channels, in order of preference:

1. **GitHub private vulnerability reporting** (preferred).
Open a security advisory at
<https://github.com/databrickslabs/coding-agents-databricks-apps/security/advisories/new>.
The advisory is visible only to maintainers and to the reporter.

2. **Email**: `databrickslabs@databricks.com` with subject prefix
`[SECURITY][coding-agents-databricks-apps]`. Encrypt sensitive
details with PGP if available
([Databricks Labs PGP key](https://github.com/databrickslabs/.github/blob/main/SECURITY.md#pgp)).

When reporting, please include:

- The version / commit SHA you observed the vulnerability on
- Reproduction steps (a minimal `app.yaml` + repro command is ideal)
- The impact you believe an attacker could achieve
- Any mitigating circumstances or proof-of-concept code

## Response timeline

We commit to the following turnaround on a best-effort basis:

| Phase | Target |
|---|---|
| Acknowledgement of receipt | 2 business days |
| Initial triage + severity assignment | 5 business days |
| Fix or mitigation plan | 14 business days |
| Coordinated disclosure | 90 days from initial report (or sooner if a fix is shipped) |

Severity assignment follows [CVSS v3.1](https://www.first.org/cvss/v3.1/specification-document):

| Severity | Patch SLA |
|---|---|
| Critical (9.0–10.0) | 7 days |
| High (7.0–8.9) | 14 days |
| Medium (4.0–6.9) | 30 days |
| Low (< 4.0) | next scheduled release |

The SLAs above are calendar days from confirmed-and-reproducible to shipped patch. The 7-day cooldown we apply to npm and PyPI dependencies (see `utils.get_npm_version` and `[tool.uv] exclude-newer` in `pyproject.toml`) does *not* apply to CoDA's own security patches — those ship as soon as the fix is reviewed and tested.

## Scope

In scope:

- The CoDA application code (`app.py`, `setup_*.py`, `install_*.sh`, `pat_rotator.py`, `utils.py`, `enterprise_config.py`, etc.)
- The release artifacts attached to GitHub Releases
- The deployment pipeline (`Makefile`, `databricks.yml`, `app.yaml.template`)

Out of scope (report to the relevant project):

- Vulnerabilities in Databricks Apps itself (report to Databricks security
via your support channel)
- Vulnerabilities in upstream agent CLIs (Claude Code, OpenCode, Codex,
Gemini CLI, Hermes) — report to those projects
- Vulnerabilities in upstream Python or npm packages — report to those
maintainers, then notify us so we can update the pin

## Coordinated disclosure

We follow the principles in
[disclose.io](https://disclose.io/terms/) and will:

- Not pursue legal action against good-faith researchers
- Credit reporters in the release notes / advisory unless they prefer
anonymity
- Share an advance copy of the patch advisory with the reporter before
public disclosure

## Supply chain controls

For reviewers conducting vendor security assessments (SIG, CAIQ, etc.):

- **npm dependencies** are resolved with a 7-day release-age cooldown
(`utils.get_npm_version`), and pinned to specific versions before each
`npm install -g` (see `setup_codex.py`, `setup_gemini.py`,
`setup_opencode.py`).
- **PyPI dependencies** use `[tool.uv] exclude-newer = "7 days"` (see
`pyproject.toml`) and are pinned in `requirements.txt` / `uv.lock`.
- **Hermes** is installed from a SHA-pinned git URL (see
`enterprise_config.DEFAULT_HERMES_PIN_SHA`, overridable via `HERMES_PIP_URL`
for mirrored installs); the pin is rotated deliberately on CoDA releases,
not auto-updated.
- **Enterprise mode** (see `docs/enterprise.md`) routes all dependency
fetches through an operator-configured proxy (JFrog Artifactory / Nexus
/ internal PyPI) instead of public registries.
- **CVE scanning** runs on every push via `.github/workflows/dependency-audit.yml`.
- **Software Bill of Materials (SBOM)** is attached to each GitHub Release
as a CycloneDX-format JSON file, signed with cosign keyless OIDC (see
`.github/workflows/release.yml`). [docs/SECURITY.md](../docs/SECURITY.md)
has the verification commands.

## Known limitations

`docs/enterprise.md` § *Security model and known limits* enumerates the
deliberate trade-offs in the current design (no mirror-binary checksum
verification, no mirror allow-listing, single-user authorization model,
etc.). These are not vulnerabilities — they are documented threat-model
boundaries. Disclosed gaps are tracked publicly there so reviewers can
make informed risk decisions.
25 changes: 24 additions & 1 deletion app.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,30 @@ def _log_uncaught_thread(args):
# PAT auto-rotation — initialized after sessions dict is defined (see below)

app = Flask(__name__, static_folder='static', static_url_path='/static')
app.secret_key = os.urandom(24)


def _resolve_secret_key():
"""Return the Flask secret_key, which signs session cookies.

Prefers FLASK_SECRET_KEY (typically wired to a Databricks secret in
app.yaml) so cookies survive worker restarts and stay valid across workers
if we ever scale beyond one. Falls back to a fresh random key, which is fine
for local dev — sessions there are short-lived and single-process — but logs
a warning because in production it silently invalidates every session on
each restart.
"""
configured = os.environ.get("FLASK_SECRET_KEY", "").strip()
if configured:
return configured.encode()
logger.warning(
"FLASK_SECRET_KEY not set — generated an ephemeral key. "
"Existing sessions will be invalidated on every worker restart. "
"For production, wire FLASK_SECRET_KEY to a Databricks secret in app.yaml."
)
return os.urandom(24)


app.secret_key = _resolve_secret_key()
app.config['MAX_CONTENT_LENGTH'] = 32 * 1024 * 1024 # 32 MB — aligned with Claude Code's 30 MB file limit

# WebSocket support via Flask-SocketIO (simple-websocket transport, threading mode)
Expand Down
14 changes: 14 additions & 0 deletions app.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,20 @@ env:
- name: CHALLENGE_REPO_READ_TOKEN
valueFrom: challenge-repo-token

# ─── Enterprise security knobs ────────────────────────────────────────────
# Stable Flask session-cookie key. Without this, the key is regenerated on
# every worker restart and existing session cookies get invalidated. Wire to
# a Databricks secret in production (see app.yaml.template for syntax).
# - name: FLASK_SECRET_KEY
# valueFrom: coda-prod/flask-secret-key
#
# Set to "true" to disable CoDA's outbound telemetry to Databricks (event-name
# pings via the SDK User-Agent — see telemetry.py). Regulated deployments
# (banks, retail PII, etc.) typically need this off so the third-party-risk
# register has no undisclosed data flow.
# - name: CODA_TELEMETRY_DISABLED
# value: "true"

# ─── Enterprise mode (proxy / registry redirects) ────────────────────────
# Uncomment and set the env vars below to run CoDA in locked-down enterprise
# networks. All are optional — set only what your environment requires.
Expand Down
4 changes: 4 additions & 0 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Security

This page covers **release provenance verification**. To *report* a
vulnerability, see [.github/SECURITY.md](../.github/SECURITY.md) — the
disclosure policy, response timelines, and supply-chain controls live there.

## Verifying release provenance

Each GitHub Release ships with:
Expand Down
22 changes: 22 additions & 0 deletions telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,36 @@ def set_product_info(ws):
setattr(ws.config, "_product_info", ("coda", _get_version()))


def _telemetry_disabled() -> bool:
"""True when the operator has opted out of CoDA telemetry.

Enterprise security teams (banks, regulated retail, etc.) require an
inventory of every data flow that leaves their workspace boundary.
`CODA_TELEMETRY_DISABLED=true` in app.yaml makes log_telemetry() a
no-op so deployments can pass third-party-risk review with no outbound
telemetry to disclose.

Telemetry is on by default for backward compatibility; opt-out is
operator-controlled per-deployment.
"""
return os.environ.get("CODA_TELEMETRY_DISABLED", "").strip().lower() in (
"true", "1", "yes", "on"
)


def log_telemetry(key, value):
"""Send a telemetry key-value pair via the Databricks SDK User-Agent header.

Creates a throwaway WorkspaceClient from ~/.databrickscfg, adds the
key-value to the User-Agent, and fires clusters.select_spark_version()
to transmit. Runs in a background daemon thread. Errors are caught and
logged, never raised.

No-op if `CODA_TELEMETRY_DISABLED` is set to a truthy value — the
enterprise opt-out path.
"""
if _telemetry_disabled():
return

def _send():
try:
Expand Down
10 changes: 10 additions & 0 deletions tests/test_auth_enforcement.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,16 @@ def test_resize_denied_for_non_owner(self):
def test_resize_allowed_for_owner(self):
self._assert_not_denied("POST", "/api/resize", {"session_id": "fake", "cols": 80, "rows": 24})

# -- GET /api/app-state --
# Historically auth-exempt; removed because it leaks app_owner email +
# PAT rotation timing to unauthenticated callers (review finding E-4).

def test_app_state_denied_for_non_owner(self):
self._assert_denied("GET", "/api/app-state")

def test_app_state_allowed_for_owner(self):
self._assert_not_denied("GET", "/api/app-state")


# ---------------------------------------------------------------------------
# 1b. /api/configure-pat MUST enforce owner check (hotfix)
Expand Down
110 changes: 110 additions & 0 deletions tests/test_telemetry_opt_out.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"""Tests for the telemetry opt-out path (CODA_TELEMETRY_DISABLED).

Enterprise procurement teams require an inventory of every outbound data
flow. The opt-out lets operators ship CoDA with no disclosed telemetry,
which is often the only way to pass third-party-risk review for regulated
workspaces.
"""

from __future__ import annotations

from unittest import mock

import pytest


@pytest.fixture(autouse=True)
def _clear_env(monkeypatch):
monkeypatch.delenv("CODA_TELEMETRY_DISABLED", raising=False)


def test_telemetry_disabled_default_false():
from telemetry import _telemetry_disabled

assert _telemetry_disabled() is False


@pytest.mark.parametrize("value", ["true", "TRUE", "1", "yes", "on", " true "])
def test_telemetry_disabled_truthy_values(value, monkeypatch):
monkeypatch.setenv("CODA_TELEMETRY_DISABLED", value)
from telemetry import _telemetry_disabled

assert _telemetry_disabled() is True


@pytest.mark.parametrize("value", ["false", "0", "no", "off", "", "maybe"])
def test_telemetry_disabled_falsy_values(value, monkeypatch):
monkeypatch.setenv("CODA_TELEMETRY_DISABLED", value)
from telemetry import _telemetry_disabled

assert _telemetry_disabled() is False


def test_log_telemetry_noop_when_disabled(monkeypatch):
"""When opt-out is set, log_telemetry must not spawn the background thread."""
monkeypatch.setenv("CODA_TELEMETRY_DISABLED", "true")
from telemetry import log_telemetry

with mock.patch("telemetry.threading.Thread") as mock_thread:
log_telemetry("test_event", "1")
mock_thread.assert_not_called()


def test_log_telemetry_fires_when_enabled(monkeypatch):
"""Default (opt-out unset) must still spawn the telemetry thread."""
from telemetry import log_telemetry

with mock.patch("telemetry.threading.Thread") as mock_thread:
mock_thread.return_value.start = mock.Mock()
log_telemetry("test_event", "1")
mock_thread.assert_called_once()
mock_thread.return_value.start.assert_called_once()


# ---------------------------------------------------------------------------
# Stable Flask session key (FLASK_SECRET_KEY)
# ---------------------------------------------------------------------------


class TestFlaskSecretKey:
"""`app.secret_key` signs session cookies. Regenerating it per worker start
silently invalidates every live session, so operators can pin it."""

def _resolver(self):
import app as app_module

return app_module._resolve_secret_key

def test_uses_configured_key(self, monkeypatch):
monkeypatch.setenv("FLASK_SECRET_KEY", "s3cret-from-databricks")

assert self._resolver()() == b"s3cret-from-databricks"

def test_configured_key_is_stable_across_calls(self, monkeypatch):
"""The whole point: two workers reading the same env var agree."""
monkeypatch.setenv("FLASK_SECRET_KEY", "s3cret-from-databricks")
resolve = self._resolver()

assert resolve() == resolve()

def test_whitespace_only_key_is_treated_as_unset(self, monkeypatch):
"""An env var wired to an empty secret must not become the signing key."""
monkeypatch.setenv("FLASK_SECRET_KEY", " ")
resolve = self._resolver()

key = resolve()
assert key != b" "
assert len(key) == 24 # the os.urandom fallback

def test_falls_back_to_random_and_warns(self, monkeypatch, caplog):
import logging

monkeypatch.delenv("FLASK_SECRET_KEY", raising=False)
resolve = self._resolver()

with caplog.at_level(logging.WARNING, logger="app"):
first, second = resolve(), resolve()

assert first != second, "fallback must be random per call"
assert len(first) == 24
assert any("FLASK_SECRET_KEY not set" in r.message for r in caplog.records)