Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
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
23 changes: 16 additions & 7 deletions .github/workflows/strix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,12 +95,13 @@ jobs:
exit 1

- name: Self-test Strix gate script
working-directory: ${{ runner.temp }}/trusted-workspace
run: bash "$TRUSTED_STRIX_GATE_TEST"

- name: Gate Strix secrets
id: gate
env:
STRIX_MODEL: ${{ secrets.STRIX_LLM == 'vertex_ai/gemini-3.1-pro-preview-customtools' && 'vertex_ai/gemini-2.5-flash' || secrets.STRIX_LLM || 'vertex_ai/gemini-2.5-flash' }}
STRIX_MODEL: ${{ secrets.STRIX_LLM || 'vertex_ai/gemini-3.1-pro-preview-customtools' }}
STRIX_OPENAI_API_KEY: ${{ secrets.STRIX_OPENAI_API_KEY }}
STRIX_VERTEX_CREDENTIALS: ${{ secrets.GCP_SA_KEY }}
run: |
Expand All @@ -117,7 +118,7 @@ jobs:
exit 1
fi
;;
vertex_ai/gemini-2.5-flash)
vertex_ai/gemini-3.1-pro-preview-customtools | vertex_ai/gemini-2.5-flash)
echo 'enabled=true' >> "$GITHUB_OUTPUT"
echo 'provider_mode=vertex_ai' >> "$GITHUB_OUTPUT"
sanitized_vertex_credentials="$(printf '%s' "$STRIX_VERTEX_CREDENTIALS" | tr -d '\r')"
Expand All @@ -135,8 +136,8 @@ jobs:

- name: Install Strix
if: steps.gate.outputs.enabled == 'true'
working-directory: ${{ runner.temp }}/trusted-workspace
run: |
cd "$TRUSTED_WORKSPACE"
python3 -m pip install --upgrade pip
python3 -m pip install --no-cache-dir -r requirements-strix-ci.txt

Expand Down Expand Up @@ -204,7 +205,7 @@ jobs:
- name: Prepare Strix model input file
if: steps.gate.outputs.enabled == 'true'
env:
STRIX_MODEL: ${{ secrets.STRIX_LLM == 'vertex_ai/gemini-3.1-pro-preview-customtools' && 'vertex_ai/gemini-2.5-flash' || secrets.STRIX_LLM || 'vertex_ai/gemini-2.5-flash' }}
STRIX_MODEL: ${{ secrets.STRIX_LLM || 'vertex_ai/gemini-3.1-pro-preview-customtools' }}
run: |
umask 077
strix_llm_file="$RUNNER_TEMP/strix_llm.txt"
Expand All @@ -216,7 +217,7 @@ jobs:
gpt-*)
printf 'openai/%s' "$strix_model" > "$strix_llm_file"
;;
vertex_ai/gemini-2.5-flash)
vertex_ai/gemini-3.1-pro-preview-customtools | vertex_ai/gemini-2.5-flash)
printf '%s' "$strix_model" > "$strix_llm_file"
;;
*)
Expand All @@ -228,6 +229,10 @@ jobs:

- name: Run Strix (quick)
if: steps.gate.outputs.enabled == 'true'
# Security invariant for pull_request_target: execute only from the
# trusted base checkout. The gate copies PR-head blobs into an isolated
# temporary scope and scans that scope as data.
working-directory: ${{ runner.temp }}/trusted-workspace
env:
STRIX_LLM_FILE: ${{ env.STRIX_LLM_FILE }}
STRIX_LLM_DEFAULT_PROVIDER: ${{ steps.gate.outputs.provider_mode == 'vertex_ai' && 'vertex_ai' || 'openai' }}
Expand All @@ -248,6 +253,8 @@ jobs:
STRIX_LLM_MAX_RETRIES: 1
STRIX_TRANSIENT_RETRY_PER_MODEL: 2
STRIX_TRANSIENT_RETRY_BACKOFF_SECONDS: 3
STRIX_FAIL_ON_PROVIDER_SIGNAL: "1"
STRIX_VERTEX_FALLBACK_MODELS: ""
STRIX_PR_SCOPE_MAX_FILES_PER_BATCH: 1
STRIX_FAIL_ON_MIN_SEVERITY: MEDIUM
STRIX_DISABLE_PR_SCOPING: ${{ (github.event_name == 'pull_request_target' || github.event.inputs.pr_number != '') && '0' || '1' }}
Expand All @@ -266,11 +273,12 @@ jobs:
export "STRIX_MEMORY_COMPRESSOR_${budget_suffix}=10"
export "STRIX_PROCESS_${budget_suffix}_SECONDS=$process_budget_seconds"
export "STRIX_TOTAL_${budget_suffix}_SECONDS=4800"
cd "$TRUSTED_WORKSPACE"
bash "$TRUSTED_STRIX_GATE"

- name: Collect Strix reports for artifact upload
if: ${{ always() && !cancelled() && steps.gate.outputs.enabled == 'true' }}
env:
PR_HEAD_SHA: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.event.inputs.pr_head_sha }}
run: |
set -euo pipefail
mkdir -p "$GITHUB_WORKSPACE/strix_runs"
Expand All @@ -285,10 +293,11 @@ jobs:
copied_reports=1
fi
if [ "$copied_reports" -eq 0 ]; then
summary_head_sha="${PR_HEAD_SHA:-$GITHUB_SHA}"
{
echo "Strix scan completed without structured report files."
echo "run_id=$GITHUB_RUN_ID"
echo "head_sha=$GITHUB_SHA"
echo "head_sha=$summary_head_sha"
} > "$GITHUB_WORKSPACE/strix_runs/scan-summary.txt"
fi

Expand Down
26 changes: 19 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,17 @@
- Strix Security Scan must not route through GitHub Models, `github.token`,
generic `LLM_API_KEY`, GPT-4o, or GPT-4.1. The current organization-secret
route is `STRIX_LLM` with `GCP_SA_KEY`;
`vertex_ai/gemini-3.1-pro-preview-customtools` must be quarantined to
`vertex_ai/gemini-2.5-flash` until it has no-timeout PR-scoped and
protected-branch full-scan evidence. Expose Google/Vertex credentials only for
Vertex provider mode. Direct OpenAI
`vertex_ai/gemini-3.1-pro-preview-customtools` is the default approved Vertex
model now that organization-secret visibility is available, with
`vertex_ai/gemini-2.5-flash` allowed only as an explicit legacy selection.
Expose Google/Vertex credentials only for Vertex provider mode. Direct OpenAI
GPT-5.4-or-newer scans remain supported only when selected explicitly with
`STRIX_OPENAI_API_KEY`. Do not silently fall back between providers, and
record provider evidence in the PR. Known third-party Strix/Pydantic
do not treat timeout-class provider infrastructure failures as clean PR
evidence even when Strix printed zero vulnerabilities before failing. Disable
silent Vertex fallback models in the workflow unless a future PR proves a new
exact fallback contract with no Timeout/Fatal/Warn/Denied output. Record
provider evidence in the PR. Known third-party Strix/Pydantic
serializer warnings must be filtered narrowly inside the Strix gate child
process, not as a visible workflow env entry, so Warn-class logs are not
accepted as clean evidence and warning-filter variable names do not pollute
Expand Down Expand Up @@ -97,6 +101,11 @@
provider `base_url` values must fail closed unless they are HTTPS, exact-host
allowlisted by `ALLOWED_LLM_BASE_URL_HOSTS`, and resolve only to global
addresses.
- OIDC issuer and JWKS URLs are outbound identity-provider fetch surfaces. They
must use HTTPS, must not include userinfo or fragments, must reject localhost
and non-global IP literals, and must be exact-host allowlisted by
`ALLOWED_OIDC_HOSTS` before any JWKS preload or token verification path can
use them.
- Email-derived tasks must stay source-linked to the email/thread and tenant
owner scope. Do not expose new sequential database ids through task APIs; use
opaque public ids for user-visible ticket tasks. Task titles are plain text:
Expand Down Expand Up @@ -192,8 +201,11 @@
Pydantic import tracebacks for missing runtime settings.
- DAV/WebDAV/CalDAV routes are private integration surfaces unless explicitly
documented otherwise. Register them with the default signed-session dependency,
escape XML response fields before interpolation, and keep path values separate
from log-safe display values.
require the signed-session dependency in handler signatures, enforce route
owner scope before capability/discovery/read/writeback responses, reject
ownerless DAV paths instead of treating them as shared roots, escape XML
response fields before interpolation, and keep path values separate from
log-safe display values.
- Self-hosted runner WebSocket routes must validate both a signed bearer session
and a server-side WorkspaceRunnerConfig registration token before accepting the
socket. Do not use the raw path token as identity, a log value, or the sole
Expand Down
22 changes: 11 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,17 @@ mail/calendar/file systems.
- PR automation is metadata-only and uses current-head robot-review evidence plus
required checks. Human approval is not awaited by default under repo policy.
- Strix PR/security evidence uses the organization-secret provider selected by
`STRIX_LLM` with `GCP_SA_KEY`, but the
`vertex_ai/gemini-3.1-pro-preview-customtools` value is quarantined to
`vertex_ai/gemini-2.5-flash` until it has no-timeout PR and full-scan
evidence. Direct OpenAI GPT-5.4-or-newer remains
supported only with an explicit `STRIX_OPENAI_API_KEY`. The workflow fails
closed rather than falling back to GitHub Models, `github.token`, generic
`LLM_API_KEY`, or GPT-4-era models. Known third-party Strix/Pydantic
serializer warnings are filtered narrowly instead of allowing Warn-class logs
into passing evidence, and runtime scan-budget variables are not listed as
visible timeout-named workflow `env:` entries. Pending CodeRabbit or check
evidence is a wait state, not a hard blocker.
`STRIX_LLM` with `GCP_SA_KEY`; missing `STRIX_LLM` defaults to the approved
`vertex_ai/gemini-3.1-pro-preview-customtools` Vertex route now that
organization-secret visibility is available. Direct OpenAI GPT-5.4-or-newer
remains supported only with an explicit `STRIX_OPENAI_API_KEY`. The workflow
fails closed rather than falling back to GitHub Models, `github.token`,
generic `LLM_API_KEY`, GPT-4-era models, or timeout-class provider
infrastructure failures. Known third-party Strix/Pydantic serializer warnings
are filtered narrowly instead of allowing Warn-class logs into passing
evidence, and runtime scan-budget variables are not listed as visible
timeout-named workflow `env:` entries. Pending CodeRabbit or check evidence is
a wait state, not a hard blocker.
- Security governance is source-backed through signed
`/api/security/access-surface`. The endpoint reads scoped WebDAV, CalDAV, and
connector evidence plus durable `security_audit_events`, reuses the deny-first
Expand Down
34 changes: 32 additions & 2 deletions backend/api/dav.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,53 @@
import logging
from html import escape as escape_xml_text

from fastapi import APIRouter, Request, Response
from fastapi import APIRouter, Depends, HTTPException, Request, Response

from api.auth import AuthContext, get_auth_context

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/dav", tags=["dav"])


def _dav_path_owner_user_id(path: str) -> str | None:
normalized_path = path.strip("/")
if not normalized_path:
return None
owner_user_id, _, _ = normalized_path.partition("/")
return owner_user_id or None


def _ensure_dav_owner_scope(path: str, auth_context: AuthContext) -> None:
owner_user_id = _dav_path_owner_user_id(path)
if owner_user_id is None:
raise HTTPException(
status_code=403,
detail="DAV path must include an owner user",
)
if owner_user_id == auth_context.user_id:
return
raise HTTPException(
status_code=403,
detail="DAV path belongs to a different user",
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved


@router.api_route(
"/{path:path}",
methods=["PROPFIND", "REPORT", "MKCOL", "GET", "PUT", "DELETE", "OPTIONS"],
)
async def dav_handler(request: Request, path: str):
async def dav_handler(
request: Request,
path: str,
auth_context: AuthContext = Depends(get_auth_context),
):
"""
Skeleton endpoint for CalDAV / WebDAV routing.
In the future, this will parse XML namespaces and bridge
Naruon's Tasks and Events into DAV compliant responses.
"""
_ensure_dav_owner_scope(path, auth_context)
safe_path = path.replace("\n", "").replace("\r", "")
logger.info("DAV Request: %s /%s", request.method, safe_path)

Expand Down
20 changes: 20 additions & 0 deletions backend/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from core.runtime_secrets import (
validate_auth_session_hmac_secret_value,
)
from core.url_validation import parse_allowed_hosts, validate_https_url_host


class Settings(BaseSettings):
Expand Down Expand Up @@ -39,6 +40,7 @@ class Settings(BaseSettings):
OIDC_ISSUER_URL: str | None = None
OIDC_CLIENT_ID: str | None = None
OIDC_JWKS_URL: str | None = None
ALLOWED_OIDC_HOSTS: str = ""

model_config = SettingsConfigDict(
env_file=("~/.env", "../.env", ".env"),
Expand Down Expand Up @@ -69,6 +71,24 @@ def validate_session_secret(self) -> "Settings":
raise ValueError(
"OIDC_ISSUER_URL, OIDC_CLIENT_ID, and OIDC_JWKS_URL must be set together"
)
if len(configured_oidc_values) == len(oidc_values):
allowed_oidc_hosts = parse_allowed_hosts(self.ALLOWED_OIDC_HOSTS)
if not allowed_oidc_hosts:
raise ValueError(
"ALLOWED_OIDC_HOSTS must list trusted OIDC issuer and JWKS hosts"
)
validate_https_url_host(
"OIDC_ISSUER_URL",
self.OIDC_ISSUER_URL or "",
allowed_oidc_hosts,
"ALLOWED_OIDC_HOSTS",
)
validate_https_url_host(
"OIDC_JWKS_URL",
self.OIDC_JWKS_URL or "",
allowed_oidc_hosts,
"ALLOWED_OIDC_HOSTS",
)
return self


Expand Down
56 changes: 56 additions & 0 deletions backend/core/url_validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
from __future__ import annotations

import ipaddress
from urllib.parse import urlsplit


def parse_allowed_hosts(raw_hosts: str) -> frozenset[str]:
hosts: set[str] = set()
for raw_host in raw_hosts.split(","):
host = _normalize_host(raw_host)
if host:
hosts.add(host)
return frozenset(hosts)


def validate_https_url_host(
setting_name: str,
url_value: str,
allowed_hosts: frozenset[str],
allowed_hosts_setting_name: str,
) -> None:
parsed = urlsplit(url_value)
if parsed.scheme.lower() != "https":
raise ValueError(f"{setting_name} must use https")
if parsed.username or parsed.password:
raise ValueError(f"{setting_name} must not include userinfo")
if parsed.fragment:
raise ValueError(f"{setting_name} must not include a fragment")
if not parsed.hostname:
raise ValueError(f"{setting_name} must include a host")

host = _normalize_host(parsed.hostname)
if host not in allowed_hosts:
raise ValueError(
f"{setting_name} host must be listed in {allowed_hosts_setting_name}"
)
_reject_unsafe_ip_literal(setting_name, host)


def _normalize_host(raw_host: str) -> str:
host = raw_host.strip().lower().rstrip(".")
if host.startswith("[") and host.endswith("]"):
host = host[1:-1]
return host


def _reject_unsafe_ip_literal(setting_name: str, host: str) -> None:
try:
ip_address = ipaddress.ip_address(host)
except ValueError:
if host == "localhost" or host.endswith(".localhost"):
raise ValueError(f"{setting_name} host must not be localhost")
return

if not ip_address.is_global:
raise ValueError(f"{setting_name} IP host must be globally routable")
18 changes: 18 additions & 0 deletions backend/scripts/start_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from pathlib import Path

from core.runtime_secrets import validate_auth_session_hmac_secret_value
from core.url_validation import parse_allowed_hosts, validate_https_url_host

DEFAULT_SERVER_HOST = "127.0.0.1"
DEFAULT_SERVER_PORT = 8000
Expand Down Expand Up @@ -83,6 +84,23 @@ def validate_runtime_settings() -> list[str]:
messages.append(
"OIDC_ISSUER_URL, OIDC_CLIENT_ID, and OIDC_JWKS_URL must be set together"
)
if len(configured_oidc) == len(OIDC_SETTINGS):
allowed_oidc_hosts = parse_allowed_hosts(values.get("ALLOWED_OIDC_HOSTS", ""))
if not allowed_oidc_hosts:
messages.append(
"ALLOWED_OIDC_HOSTS must list trusted OIDC issuer and JWKS hosts"
)
else:
for setting_name in ("OIDC_ISSUER_URL", "OIDC_JWKS_URL"):
try:
validate_https_url_host(
setting_name,
values[setting_name],
allowed_oidc_hosts,
"ALLOWED_OIDC_HOSTS",
)
except ValueError as exc:
messages.append(str(exc))

return messages

Expand Down
Loading
Loading