From 444ab852ea11c5cf5785220a10a6b488141cd26c Mon Sep 17 00:00:00 2001 From: MerverliPy Date: Tue, 16 Jun 2026 20:30:38 -0500 Subject: [PATCH 1/6] security: prevent production development-auth bypass Reject explicit development-auth bypass requests in production, use the real OIDC client by default, and add focused regression coverage for all supported application modes. Remediates audit finding F-001. --- apps/api/src/index.ts | 50 +------- apps/api/src/oidc-client-selection.ts | 93 ++++++++++++++ apps/api/test/oidc-client-selection.test.ts | 134 ++++++++++++++++++++ 3 files changed, 234 insertions(+), 43 deletions(-) create mode 100644 apps/api/src/oidc-client-selection.ts create mode 100644 apps/api/test/oidc-client-selection.test.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 9ab65f8..1433a2f 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,9 +1,9 @@ -import { randomBytes } from 'node:crypto'; import { loadConfig, safeConfigForLogging } from '@pia/config'; import { createObservability, runWithCorrelation } from '@pia/observability'; -import type { OidcConfig, OidcClient, AuthorizationParams, OidcUserInfo } from '@pia/auth'; +import type { OidcConfig } from '@pia/auth'; import { createPool } from '@pia/db'; import { createServer } from './server.js'; +import { selectOidcClient } from './oidc-client-selection.js'; /** * Build OIDC configuration from the validated application config. @@ -29,52 +29,16 @@ function buildOidcConfig(): OidcConfig { }; } -/** - * Bypass OIDC client for development — no external provider needed. - * - * Instead of redirecting to an external OIDC issuer, returns a URL that - * points directly to our own `/auth/callback` with a pre-authorized code. - * On callback, validates the bypass code and returns a hardcoded dev user. - */ -function createDevBypassOidcClient(config: OidcConfig): OidcClient { - const devSub = 'dev-user-1'; - const devEmail = 'dev@localhost'; - - return { - getIssuerUrl: () => config.issuerUrl, - - getAuthorizationUrl: async (): Promise => { - const state = randomBytes(16).toString('hex'); - const codeVerifier = randomBytes(32).toString('base64url'); - const nonce = randomBytes(16).toString('hex'); - - const authorizationUrl = `${config.redirectUri}?code=DEV-BYPASS&state=${encodeURIComponent(state)}`; - - return { authorizationUrl, codeVerifier, state, nonce }; - }, - - handleCallback: async (code: string): Promise => { - if (code !== 'DEV-BYPASS') { - throw new Error('Invalid dev bypass code'); - } - return { - sub: devSub, - email: devEmail, - email_verified: true, - name: 'Dev User', - preferred_username: 'dev', - picture: undefined, - }; - }, - }; -} - async function main(): Promise { try { const config = loadConfig(); const oidcConfig = buildOidcConfig(); const dbPool = createPool(); - const oidcClient = createDevBypassOidcClient(oidcConfig); + const oidcClient = selectOidcClient({ + mode: config.mode, + bypassRequested: process.env['PIA_ALLOW_DEV_AUTH_BYPASS'] === '1', + config: oidcConfig, + }); const observability = createObservability({ enabled: true, logLevel: config.logging.level, diff --git a/apps/api/src/oidc-client-selection.ts b/apps/api/src/oidc-client-selection.ts new file mode 100644 index 0000000..eb08664 --- /dev/null +++ b/apps/api/src/oidc-client-selection.ts @@ -0,0 +1,93 @@ +import { randomBytes } from 'node:crypto'; +import type { AppMode } from '@pia/config'; +import { + createRealOidcClient, + type AuthorizationParams, + type OidcClient, + type OidcConfig, + type OidcUserInfo, +} from '@pia/auth'; + +/** + * Development-only OIDC bypass. + * + * This client must never be selected in production. Selection is controlled + * exclusively by selectOidcClient(). + */ +function createDevBypassOidcClient(config: OidcConfig): OidcClient { + const devSub = 'dev-user-1'; + const devEmail = 'dev@localhost'; + + return { + getIssuerUrl: () => config.issuerUrl, + + getAuthorizationUrl: async (): Promise => { + const state = randomBytes(16).toString('hex'); + const codeVerifier = randomBytes(32).toString('base64url'); + const nonce = randomBytes(16).toString('hex'); + + const authorizationUrl = + `${config.redirectUri}?code=DEV-BYPASS&state=${encodeURIComponent(state)}`; + + return { + authorizationUrl, + codeVerifier, + state, + nonce, + }; + }, + + handleCallback: async (code: string): Promise => { + if (code !== 'DEV-BYPASS') { + throw new Error('Invalid dev bypass code'); + } + + return { + sub: devSub, + email: devEmail, + email_verified: true, + name: 'Dev User', + preferred_username: 'dev', + picture: undefined, + }; + }, + }; +} + +export interface OidcClientFactories { + readonly createReal: (config: OidcConfig) => OidcClient; + readonly createDevBypass: (config: OidcConfig) => OidcClient; +} + +export interface SelectOidcClientOptions { + readonly mode: AppMode; + readonly bypassRequested: boolean; + readonly config: OidcConfig; + readonly factories?: OidcClientFactories; +} + +const DEFAULT_FACTORIES: OidcClientFactories = { + createReal: createRealOidcClient, + createDevBypass: createDevBypassOidcClient, +}; + +/** + * Selects the OIDC client for the current application mode. + * + * Security invariant: + * A development bypass request in production is rejected before either client + * factory is invoked. + */ +export function selectOidcClient(options: SelectOidcClientOptions): OidcClient { + const factories = options.factories ?? DEFAULT_FACTORIES; + + if (options.mode === 'production' && options.bypassRequested) { + throw new Error('PIA_ALLOW_DEV_AUTH_BYPASS is forbidden in production'); + } + + if (options.mode !== 'production' && options.bypassRequested) { + return factories.createDevBypass(options.config); + } + + return factories.createReal(options.config); +} diff --git a/apps/api/test/oidc-client-selection.test.ts b/apps/api/test/oidc-client-selection.test.ts new file mode 100644 index 0000000..407b12c --- /dev/null +++ b/apps/api/test/oidc-client-selection.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { AppMode } from '@pia/config'; +import type { OidcClient, OidcConfig } from '@pia/auth'; +import { + selectOidcClient, + type OidcClientFactories, +} from '../src/oidc-client-selection.js'; + +const oidcConfig: OidcConfig = { + issuerUrl: 'https://issuer.example.test', + clientId: 'test-client', + clientSecret: 'test-client-secret', + redirectUri: 'http://localhost:3000/auth/callback', + sessionSecret: new TextEncoder().encode( + 'test-session-secret-at-least-32-characters', + ), + sessionMaxAgeSeconds: 3600, + secureCookies: false, +}; + +function makeClient(label: string): OidcClient { + return { + getIssuerUrl: () => label, + + getAuthorizationUrl: async () => ({ + authorizationUrl: `https://${label}.example.test/authorize`, + codeVerifier: 'test-verifier', + state: 'test-state', + nonce: 'test-nonce', + }), + + handleCallback: async ( + _code: string, + _state: string, + _codeVerifier: string, + _nonce: string, + ) => ({ + sub: `${label}-subject`, + email: `${label}@example.test`, + email_verified: true, + name: label, + preferred_username: label, + picture: undefined, + }), + }; +} + +function createTestFactories() { + const realClient = makeClient('real'); + const devClient = makeClient('development-bypass'); + + const createReal = vi.fn((_config: OidcConfig) => realClient); + const createDevBypass = vi.fn((_config: OidcConfig) => devClient); + + const factories: OidcClientFactories = { + createReal, + createDevBypass, + }; + + return { + factories, + realClient, + devClient, + createReal, + createDevBypass, + }; +} + +describe('selectOidcClient', () => { + it('rejects a development bypass request in production before invoking a factory', () => { + const test = createTestFactories(); + + expect(() => + selectOidcClient({ + mode: 'production', + bypassRequested: true, + config: oidcConfig, + factories: test.factories, + }), + ).toThrow('PIA_ALLOW_DEV_AUTH_BYPASS is forbidden in production'); + + expect(test.createReal).not.toHaveBeenCalled(); + expect(test.createDevBypass).not.toHaveBeenCalled(); + }); + + const realClientCases = [ + ['production', false], + ['development', false], + ['test', false], + ] as const satisfies ReadonlyArray; + + it.each(realClientCases)( + 'uses the real OIDC client in %s mode when bypassRequested=%s', + (mode, bypassRequested) => { + const test = createTestFactories(); + + const client = selectOidcClient({ + mode, + bypassRequested, + config: oidcConfig, + factories: test.factories, + }); + + expect(client).toBe(test.realClient); + expect(test.createReal).toHaveBeenCalledOnce(); + expect(test.createReal).toHaveBeenCalledWith(oidcConfig); + expect(test.createDevBypass).not.toHaveBeenCalled(); + }, + ); + + const bypassCases = [ + ['development', true], + ['test', true], + ] as const satisfies ReadonlyArray; + + it.each(bypassCases)( + 'uses the development bypass in %s mode when explicitly requested', + (mode, bypassRequested) => { + const test = createTestFactories(); + + const client = selectOidcClient({ + mode, + bypassRequested, + config: oidcConfig, + factories: test.factories, + }); + + expect(client).toBe(test.devClient); + expect(test.createDevBypass).toHaveBeenCalledOnce(); + expect(test.createDevBypass).toHaveBeenCalledWith(oidcConfig); + expect(test.createReal).not.toHaveBeenCalled(); + }, + ); +}); From ccbb929a7ae23ea446162001f011fe1aa8ef2648 Mon Sep 17 00:00:00 2001 From: MerverliPy Date: Tue, 16 Jun 2026 20:40:21 -0500 Subject: [PATCH 2/6] style: format F-001 remediation --- apps/api/src/oidc-client-selection.ts | 3 +-- apps/api/test/oidc-client-selection.test.ts | 9 ++------- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/apps/api/src/oidc-client-selection.ts b/apps/api/src/oidc-client-selection.ts index eb08664..8e68a5f 100644 --- a/apps/api/src/oidc-client-selection.ts +++ b/apps/api/src/oidc-client-selection.ts @@ -26,8 +26,7 @@ function createDevBypassOidcClient(config: OidcConfig): OidcClient { const codeVerifier = randomBytes(32).toString('base64url'); const nonce = randomBytes(16).toString('hex'); - const authorizationUrl = - `${config.redirectUri}?code=DEV-BYPASS&state=${encodeURIComponent(state)}`; + const authorizationUrl = `${config.redirectUri}?code=DEV-BYPASS&state=${encodeURIComponent(state)}`; return { authorizationUrl, diff --git a/apps/api/test/oidc-client-selection.test.ts b/apps/api/test/oidc-client-selection.test.ts index 407b12c..9cac204 100644 --- a/apps/api/test/oidc-client-selection.test.ts +++ b/apps/api/test/oidc-client-selection.test.ts @@ -1,19 +1,14 @@ import { describe, expect, it, vi } from 'vitest'; import type { AppMode } from '@pia/config'; import type { OidcClient, OidcConfig } from '@pia/auth'; -import { - selectOidcClient, - type OidcClientFactories, -} from '../src/oidc-client-selection.js'; +import { selectOidcClient, type OidcClientFactories } from '../src/oidc-client-selection.js'; const oidcConfig: OidcConfig = { issuerUrl: 'https://issuer.example.test', clientId: 'test-client', clientSecret: 'test-client-secret', redirectUri: 'http://localhost:3000/auth/callback', - sessionSecret: new TextEncoder().encode( - 'test-session-secret-at-least-32-characters', - ), + sessionSecret: new TextEncoder().encode('test-session-secret-at-least-32-characters'), sessionMaxAgeSeconds: 3600, secureCookies: false, }; From 5e46bff653dc003ad9227d164475c49c74ccc236 Mon Sep 17 00:00:00 2001 From: MerverliPy Date: Tue, 16 Jun 2026 21:03:28 -0500 Subject: [PATCH 3/6] docs: document development auth bypass opt-in --- .env.example | 4 ++++ DEVELOPMENT.md | 9 ++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index c98c901..ed0fb65 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,10 @@ DATABASE_URL=postgresql://pia:pia-dev@localhost:5432/pia REDIS_URL=redis://localhost:6379 # OpenID Connect +# The local development stack does not include an external OIDC provider. +# This bypass is supported only in development and test; production rejects it. +# Unset it when testing against a real OIDC provider. +PIA_ALLOW_DEV_AUTH_BYPASS=1 OIDC_ISSUER=http://localhost:8080/realms/pia OIDC_CLIENT_ID=pia-local # OIDC_CLIENT_SECRET= diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 9d65a25..6365003 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -23,6 +23,11 @@ pnpm install This starts PostgreSQL 17 (with pgvector), Redis 7, and MinIO. +The local development stack does not include an external OIDC provider. To use +the built-in local sign-in flow, set `PIA_ALLOW_DEV_AUTH_BYPASS=1` in `.env`. +This flag is supported only in `development` and `test`; production rejects it. +Unset the flag when testing against a real OIDC provider. + ### 3. Start the API Server **Recommended method (persists across shell sessions):** @@ -145,7 +150,9 @@ disown **Cause:** Not logged in or session expired. -**Solution:** Click "Sign In" to authenticate via the dev bypass OIDC flow. +**Solution:** When using the local stack without an external OIDC provider, +set `PIA_ALLOW_DEV_AUTH_BYPASS=1` in `.env`, restart the API, and click +"Sign In." Unset the flag when testing against a real OIDC provider. ### Database Connection Errors From 219bc402db0f21826269c8ed5f06c7c415067629 Mon Sep 17 00:00:00 2001 From: MerverliPy Date: Tue, 16 Jun 2026 21:35:45 -0500 Subject: [PATCH 4/6] docs: keep development auth bypass opt-in --- .env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.example b/.env.example index ed0fb65..f9b48c7 100644 --- a/.env.example +++ b/.env.example @@ -17,7 +17,7 @@ REDIS_URL=redis://localhost:6379 # The local development stack does not include an external OIDC provider. # This bypass is supported only in development and test; production rejects it. # Unset it when testing against a real OIDC provider. -PIA_ALLOW_DEV_AUTH_BYPASS=1 +# PIA_ALLOW_DEV_AUTH_BYPASS=1 OIDC_ISSUER=http://localhost:8080/realms/pia OIDC_CLIENT_ID=pia-local # OIDC_CLIENT_SECRET= From f5f3500acaf1d35081637f10b41946c92fd95db3 Mon Sep 17 00:00:00 2001 From: MerverliPy Date: Tue, 16 Jun 2026 21:57:01 -0500 Subject: [PATCH 5/6] security: require production oidc settings --- packages/config/src/schema.ts | 4 ++-- packages/config/test/config.test.ts | 34 +++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts index 8f2c5d5..03856bb 100644 --- a/packages/config/src/schema.ts +++ b/packages/config/src/schema.ts @@ -112,14 +112,14 @@ export const CONFIG_SCHEMA: Record = { env: 'OIDC_ISSUER', description: 'OpenID Connect issuer URL', secret: false, - required: false, + required: true, default: 'http://localhost:8080/realms/pia', }, OIDC_CLIENT_ID: { env: 'OIDC_CLIENT_ID', description: 'OpenID Connect client identifier', secret: false, - required: false, + required: true, default: 'pia-local', }, OIDC_CLIENT_SECRET: { diff --git a/packages/config/test/config.test.ts b/packages/config/test/config.test.ts index 58fc6b8..7823d63 100644 --- a/packages/config/test/config.test.ts +++ b/packages/config/test/config.test.ts @@ -112,6 +112,8 @@ describe('loadConfig', () => { // Error message names missing keys expect(err.message).toContain('DATABASE_URL'); expect(err.message).toContain('REDIS_URL'); + expect(err.message).toContain('OIDC_ISSUER'); + expect(err.message).toContain('OIDC_CLIENT_ID'); expect(err.message).toContain('OIDC_CLIENT_SECRET'); expect(err.message).toContain('SESSION_SECRET'); expect(err.message).toContain('STORAGE_ACCESS_KEY_ID'); @@ -119,16 +121,46 @@ describe('loadConfig', () => { // missingKeys contains the env var names expect(err.missingKeys).toContain('DATABASE_URL'); expect(err.missingKeys).toContain('REDIS_URL'); + expect(err.missingKeys).toContain('OIDC_ISSUER'); + expect(err.missingKeys).toContain('OIDC_CLIENT_ID'); } }); }); + it('requires explicit OIDC issuer and client ID in production', () => { + withEnv( + { + NODE_ENV: 'production', + DATABASE_URL: 'postgresql://prod-host/db', + REDIS_URL: 'redis://prod-host:6379', + OIDC_ISSUER: undefined, + OIDC_CLIENT_ID: undefined, + OIDC_CLIENT_SECRET: 'prod-oidc-secret', + SESSION_SECRET: 'prod-session-secret', + STORAGE_ACCESS_KEY_ID: 'prod-key', + STORAGE_SECRET_ACCESS_KEY: 'prod-secret', + }, + () => { + expect(() => loadConfig()).toThrow(ConfigValidationError); + + try { + loadConfig(); + } catch (error) { + const err = error as ConfigValidationError; + expect(err.missingKeys).toEqual(['OIDC_ISSUER', 'OIDC_CLIENT_ID']); + } + }, + ); + }); + it('loads successfully when all required config is set', () => { withEnv( { NODE_ENV: 'production', DATABASE_URL: 'postgresql://prod-host/db', REDIS_URL: 'redis://prod-host:6379', + OIDC_ISSUER: 'https://login.example.com', + OIDC_CLIENT_ID: 'pia-production', OIDC_CLIENT_SECRET: 'prod-oidc-secret', SESSION_SECRET: 'prod-session-secret', STORAGE_ACCESS_KEY_ID: 'prod-key', @@ -151,6 +183,8 @@ describe('loadConfig', () => { HOST: '127.0.0.1', DATABASE_URL: 'postgresql://prod/db', REDIS_URL: 'redis://prod:6379', + OIDC_ISSUER: 'https://login.example.com', + OIDC_CLIENT_ID: 'pia-production', OIDC_CLIENT_SECRET: 'secret', SESSION_SECRET: 'secret', STORAGE_ACCESS_KEY_ID: 'key', From 6ad0a4b9658b486629bb85bbede02895a7f700bb Mon Sep 17 00:00:00 2001 From: Merverli Date: Wed, 17 Jun 2026 20:36:52 -0500 Subject: [PATCH 6/6] Enhance security measures and documentation for production settings (#12) * security: prevent production development-auth bypass (#8) * security: prevent production development-auth bypass Reject explicit development-auth bypass requests in production, use the real OIDC client by default, and add focused regression coverage for all supported application modes. Remediates audit finding F-001. * style: format F-001 remediation * docs: document development auth bypass opt-in * docs: keep development auth bypass opt-in * security: require production oidc settings * security: fail closed when upload scanning is unavailable (#9) * security: fail closed when upload scanning is unavailable * docs: anonymize F-002 run record * security: enforce default-branch governance (#10) * security: enforce default-branch governance * docs: namespace repository audit finding record * docs: disambiguate repository audit finding ID * fix(audit): consolidate OpenCode project configuration (#11) --- .github/CODEOWNERS | 14 + apps/api/src/routes/uploads.ts | 6 +- apps/api/test/upload-workflow.test.ts | 27 ++ ...anonical-opencode-project-configuration.md | 149 ++++++++++ opencode.json | 42 --- opencode.jsonc | 36 ++- package.json | 1 + packages/knowledge/package.json | 2 +- packages/knowledge/src/index.ts | 1 + packages/knowledge/src/scan.ts | 36 ++- packages/knowledge/test/scan.test.ts | 45 +++ planning/runs/F-002.md | 219 ++++++++++++++ planning/runs/REPO-AUDIT-F-003.md | 269 ++++++++++++++++++ pnpm-lock.yaml | 159 +++++++++++ 14 files changed, 944 insertions(+), 62 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 docs/adr/0008-canonical-opencode-project-configuration.md delete mode 100644 opencode.json create mode 100644 packages/knowledge/test/scan.test.ts create mode 100644 planning/runs/F-002.md create mode 100644 planning/runs/REPO-AUDIT-F-003.md diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..07acccf --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,14 @@ +# Default review ownership. +* @MerverliPy + +# Security-, automation-, authentication-, data-, and release-sensitive paths. +/.github/ @MerverliPy +/apps/api/ @MerverliPy +/packages/auth/ @MerverliPy +/packages/config/ @MerverliPy +/packages/knowledge/ @MerverliPy +/infra/ @MerverliPy +/compose.yaml @MerverliPy +/opencode.json @MerverliPy +/opencode.jsonc @MerverliPy +/.opencode/ @MerverliPy diff --git a/apps/api/src/routes/uploads.ts b/apps/api/src/routes/uploads.ts index ddc8a69..8c3994c 100644 --- a/apps/api/src/routes/uploads.ts +++ b/apps/api/src/routes/uploads.ts @@ -6,7 +6,7 @@ import { type StorageProvider, } from '@pia/storage'; import { loadConfig } from '@pia/config'; -import { createNoopScanProvider, type ScanProvider } from '@pia/knowledge'; +import { createUnavailableScanProvider, type ScanProvider } from '@pia/knowledge'; import { completeUploadWorkflow } from '../services/upload-workflow.js'; import { type CreateUploadRequest, @@ -25,7 +25,7 @@ import { requireWorkspaceContext } from '../plugins/workspace-context.js'; export interface UploadRouteOptions { /** Storage provider (uses local in-memory adapter when omitted in dev). */ storageProvider?: StorageProvider; - /** Scan provider (uses noop adapter when omitted). */ + /** Scan provider (fails closed and quarantines when omitted). */ scanProvider?: ScanProvider; } @@ -68,7 +68,7 @@ const uploadRoutes: FastifyPluginAsync = async ( ) => { const pool = createPool(); const storage = opts.storageProvider ?? defaultStorageProvider(); - const scan = opts.scanProvider ?? createNoopScanProvider(); + const scan = opts.scanProvider ?? createUnavailableScanProvider(); // ------------------------------------------------------------------------- // POST /v1/workspaces/:workspace_id/uploads diff --git a/apps/api/test/upload-workflow.test.ts b/apps/api/test/upload-workflow.test.ts index 67a3e70..22b7bf4 100644 --- a/apps/api/test/upload-workflow.test.ts +++ b/apps/api/test/upload-workflow.test.ts @@ -4,6 +4,7 @@ const { Pool } = pg; import { createLocalStorageProvider, simulateUpload, type StoredObject } from '@pia/storage'; import { createNoopScanProvider, + createUnavailableScanProvider, type ScanProvider, type ScanInput, type ScanResult, @@ -325,6 +326,32 @@ describe('Quarantine — malware scan results', () => { expect(result.quarantineReason).toContain('error'); expect(result.ingestionJob).toBeUndefined(); }); + + it('quarantines when no scanner is configured', async (vtCtx) => { + requirePool(vtCtx); + const storage = createLocalStorageProvider(); + const scan = createUnavailableScanProvider(); + + const { result } = await completeTestUpload( + pool, + storage, + scan, + TEST_WORKSPACE_ID, + 'unverified content', + 'unverified.pdf', + 'application/pdf', + ); + + expect(result.version.status).toBe('QUARANTINED'); + expect(result.quarantineReason).toContain('error'); + expect(result.ingestionJob).toBeUndefined(); + expect(result.storedFile.scanStatus).toBe('ERROR'); + expect(result.storedFile.scanMetadata).toMatchObject({ + scanned: false, + provider: 'unavailable', + errorCode: 'SCAN_PROVIDER_NOT_CONFIGURED', + }); + }); }); // --------------------------------------------------------------------------- diff --git a/docs/adr/0008-canonical-opencode-project-configuration.md b/docs/adr/0008-canonical-opencode-project-configuration.md new file mode 100644 index 0000000..8d8ab54 --- /dev/null +++ b/docs/adr/0008-canonical-opencode-project-configuration.md @@ -0,0 +1,149 @@ +# ADR-0008: Canonical OpenCode project configuration + +- Status: Accepted +- Date: 2026-06-17 +- Approved by: Repository owner +- Approved at: 2026-06-17T19:17:43Z +- Related finding: REPO-AUDIT-F-004 +- Related redesign blocker: B-4 + +## Context + +The repository currently contains both `opencode.json` and `opencode.jsonc`. +They define materially different defaults and permissions: + +- `opencode.json` selects `mobile-ui-orchestrator`, supplies project instruction + files, permits several read-only tools, permits skills, and requires approval + for delegated tasks. +- `opencode.jsonc` selects `plan`, applies path-sensitive secret protections, + denies skills and delegated tasks, and blocks destructive or publishing + commands. + +OpenCode 1.17.7 merges both project files. The currently resolved configuration +therefore contains values from both files, with `opencode.jsonc` overriding +conflicting values. + +This makes repository policy dependent on undocumented merge behavior. A +maintainer can edit one file believing it is authoritative while the other file +changes the effective result. + +The repository documentation already instructs operators to start in the +default `plan` agent. The external repository audit also recommends retaining +the stricter permission baseline. + +## Decision + +The repository will use exactly one canonical project configuration: + +- Canonical file: `opencode.jsonc` +- Default agent: `plan` +- OpenCode version: exactly `1.17.7` +- `opencode.json` will be removed + +The canonical configuration must preserve the security-sensitive policy from +the existing `opencode.jsonc`: + +- secret, credential, private-key, environment-file, and Git metadata read + restrictions; +- equivalent edit restrictions; +- delegated task and skill denial; +- approval for unclassified shell commands; +- explicit denial of destructive Git, infrastructure, publication, privilege, + and destructive filesystem commands; +- disabled sharing; +- denied external-directory access. + +The canonical configuration must also preserve non-conflicting values that are +part of the currently resolved project configuration: + +- project instruction files: + - `AGENTS.md` + - `.ui-redesign/adapter/REPOSITORY_ADAPTER.md` + - `.ui-redesign/decisions/DECISION_LEDGER.md` +- explicit read-only tool allowances for: + - `glob` + - `grep` + - `list` + - `lsp` + - `todowrite` + - `question` + +The canonical file will retain the existing `opencode.jsonc` watcher, +tool-output, compaction, snapshot, update-notification, and permission settings +unless a separately reviewed change documents a reason to alter them. + +The root package will pin `opencode-ai` to exact version `1.17.7`. Repository +validation will invoke the local package binary rather than relying on an +unversioned user-level installation. + +An effective-configuration smoke test will verify at minimum: + +1. exactly one root OpenCode configuration exists; +2. the local OpenCode version is `1.17.7`; +3. the resolved default agent is `plan`; +4. sharing remains disabled; +5. all required instruction files are loaded; +6. task and skill permissions remain denied; +7. external-directory access remains denied; +8. protected read and edit patterns remain denied; +9. destructive and publishing commands remain denied; +10. expected read-only tools remain allowed. + +The smoke test must isolate or explicitly disclose user-level and environment +configuration sources so a developer-specific override cannot make the +repository test pass accidentally. + +## Consequences + +### Positive + +- Project policy has one authoritative source. +- The effective default agent and permissions are reviewable without relying on + configuration merge order. +- OpenCode runtime behavior is tied to a reproducible version. +- Regression testing detects permission or default-agent drift. +- The mobile redesign instruction files remain active while the stricter + permission baseline is preserved. + +### Negative and risks + +- Installing the pinned CLI adds platform-specific optional dependencies and a + package postinstall step. +- Developers with unsupported platforms must use a separately reviewed + installation method. +- User-level, environment-provided, or future global OpenCode configuration can + still affect an interactive session. +- Setting `task` and `skill` to `deny` intentionally prevents delegation from + this default configuration. Any future delegation-enabled profile requires a + separate reviewed configuration and decision. + +## Alternatives considered + +### Keep both files and document merge order + +Rejected. The effective policy would remain dependent on version-specific merge +behavior and would still be easy to edit incorrectly. + +### Make `opencode.json` canonical + +Rejected. It has weaker secret-path controls, permits skills, permits delegated +tasks subject to approval, and selects a redesign-specific orchestrator as the +general project default. + +### Keep only the current `opencode.jsonc` without copying resolved values + +Rejected. Removing `opencode.json` would also remove the project instruction +list and explicit read-only tool allowances that are currently contributed by +that file. + +### Do not pin OpenCode + +Rejected. Configuration semantics and effective permissions could change when +the developer installation changes. + +## Rollback + +Revert the consolidation commit and stop OpenCode use until the conflicting +configuration state is reviewed. Restoring both root configuration files while +continuing normal OpenCode operation is not an acceptable steady-state +rollback. diff --git a/opencode.json b/opencode.json deleted file mode 100644 index 9153887..0000000 --- a/opencode.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "$schema": "https://opencode.ai/config.json", - "default_agent": "mobile-ui-orchestrator", - "share": "disabled", - "snapshot": true, - "instructions": [ - "AGENTS.md", - ".ui-redesign/adapter/REPOSITORY_ADAPTER.md", - ".ui-redesign/decisions/DECISION_LEDGER.md" - ], - "permission": { - "read": "allow", - "glob": "allow", - "grep": "allow", - "list": "allow", - "lsp": "allow", - "todowrite": "allow", - "question": "allow", - "skill": "allow", - "edit": "ask", - "bash": "ask", - "task": "ask", - "webfetch": "ask", - "websearch": "ask", - "external_directory": "deny" - }, - "watcher": { - "ignore": [ - "node_modules/**", - "dist/**", - "build/**", - "coverage/**", - ".git/**", - ".ui-redesign/evidence/raw/**" - ] - }, - "compaction": { - "auto": true, - "prune": true, - "reserved": 12000 - } -} diff --git a/opencode.jsonc b/opencode.jsonc index 606d8ae..fb9df64 100644 --- a/opencode.jsonc +++ b/opencode.jsonc @@ -1,28 +1,28 @@ { "$schema": "https://opencode.ai/config.json", + // Canonical project configuration. Keep exactly one root OpenCode config. + // Change this policy only with an effective-config regression test. "default_agent": "plan", "share": "disabled", "snapshot": true, "autoupdate": "notify", + "instructions": [ + "AGENTS.md", + ".ui-redesign/adapter/REPOSITORY_ADAPTER.md", + ".ui-redesign/decisions/DECISION_LEDGER.md", + ], "watcher": { - "ignore": [ - ".git/**", - "node_modules/**", - "dist/**", - ".turbo/**", - "coverage/**", - ".next/**" - ] + "ignore": [".git/**", "node_modules/**", "dist/**", ".turbo/**", "coverage/**", ".next/**"], }, "tool_output": { "max_lines": 500, - "max_bytes": 40000 + "max_bytes": 40000, }, "compaction": { "auto": true, "prune": true, "tail_turns": 3, - "reserved": 12000 + "reserved": 12000, }, "permission": { "read": { @@ -40,8 +40,14 @@ "*credentials*": "deny", "**/*credentials*": "deny", ".git/**": "deny", - "**/.git/**": "deny" + "**/.git/**": "deny", }, + "glob": "allow", + "grep": "allow", + "list": "allow", + "lsp": "allow", + "todowrite": "allow", + "question": "allow", "edit": { "*": "ask", "*.env": "deny", @@ -55,7 +61,7 @@ "*credentials*": "deny", "**/*credentials*": "deny", ".git/**": "deny", - "**/.git/**": "deny" + "**/.git/**": "deny", }, "external_directory": "deny", "webfetch": "ask", @@ -92,7 +98,7 @@ "kubectl apply*": "deny", "helm upgrade*": "deny", "npm publish*": "deny", - "pnpm publish*": "deny" - } - } + "pnpm publish*": "deny", + }, + }, } diff --git a/package.json b/package.json index dcbf82f..1344040 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0", "eslint": "^8.57.0", + "opencode-ai": "1.17.7", "pg": "^8.13.0", "prettier": "^3.4.0", "tsx": "^4.22.4", diff --git a/packages/knowledge/package.json b/packages/knowledge/package.json index f144ac7..3b93a95 100644 --- a/packages/knowledge/package.json +++ b/packages/knowledge/package.json @@ -16,7 +16,7 @@ "build": "tsc", "typecheck": "tsc --noEmit", "lint": "eslint src/", - "test:unit": "vitest run test/state-machine.test.ts test/ingestion-workflow.test.ts test/parsing.test.ts test/chunking.test.ts test/embedding-provider.test.ts test/embedding.test.ts test/retrieval.test.ts test/citation-builder.test.ts test/verification.test.ts", + "test:unit": "vitest run test/scan.test.ts test/state-machine.test.ts test/ingestion-workflow.test.ts test/parsing.test.ts test/chunking.test.ts test/embedding-provider.test.ts test/embedding.test.ts test/retrieval.test.ts test/citation-builder.test.ts test/verification.test.ts", "test:integration": "vitest run test/integration.test.ts" }, "dependencies": { diff --git a/packages/knowledge/src/index.ts b/packages/knowledge/src/index.ts index a014c7b..89e358d 100644 --- a/packages/knowledge/src/index.ts +++ b/packages/knowledge/src/index.ts @@ -58,6 +58,7 @@ export { export type { ScanInput, ScanResult, ScanProvider } from './scan.js'; export { + createUnavailableScanProvider, createNoopScanProvider, isDefaultAllowedMimeType, defaultAllowedMimeTypes, diff --git a/packages/knowledge/src/scan.ts b/packages/knowledge/src/scan.ts index dd72029..f6f4bec 100644 --- a/packages/knowledge/src/scan.ts +++ b/packages/knowledge/src/scan.ts @@ -66,7 +66,37 @@ export interface ScanProvider { } // --------------------------------------------------------------------------- -// Noop adapter (development/testing only) +// Fail-closed adapter (runtime default when no scanner is configured) +// --------------------------------------------------------------------------- + +/** + * Creates a scan provider that marks every upload as an error. + * + * This is the safe runtime fallback when no content-inspection service has + * been configured. Returning `ERROR` ensures the upload workflow quarantines + * the file and never schedules ingestion. + */ +export function createUnavailableScanProvider(): ScanProvider { + return { + async scan(input: ScanInput): Promise { + const detectedMimeType = input.storageMimeType || input.declaredMimeType || null; + + return { + status: 'ERROR', + detectedMimeType, + metadata: { + scanned: false, + provider: 'unavailable', + errorCode: 'SCAN_PROVIDER_NOT_CONFIGURED', + scannedAt: new Date().toISOString(), + }, + }; + }, + }; +} + +// --------------------------------------------------------------------------- +// Noop adapter (explicit development/testing use only) // --------------------------------------------------------------------------- /** @@ -90,6 +120,10 @@ const DEFAULT_ALLOWED_MIME_TYPES = new Set([ * Returns `CLEAN` for all files and sets the detected MIME type based on * the storage-layer MIME type (falling back to the declared type). * + * This adapter bypasses malware analysis and must be injected explicitly + * by tests or controlled development tooling. It must never be selected as an + * implicit runtime fallback. + * * **Not for production use.** Real deployments must plug in a content * inspection service. */ diff --git a/packages/knowledge/test/scan.test.ts b/packages/knowledge/test/scan.test.ts new file mode 100644 index 0000000..38c3695 --- /dev/null +++ b/packages/knowledge/test/scan.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { + createNoopScanProvider, + createUnavailableScanProvider, + type ScanInput, +} from '../src/scan.js'; + +const input: ScanInput = { + workspaceId: '11111111-1111-1111-1111-111111111111', + objectKey: 'uploads/example.pdf', + sizeBytes: 1024, + checksumSha256: 'a'.repeat(64), + storageMimeType: 'application/pdf', + filename: 'example.pdf', + declaredMimeType: 'application/pdf', +}; + +describe('scan providers', () => { + it('fails closed when no scanner is configured', async () => { + const result = await createUnavailableScanProvider().scan(input); + + expect(result).toMatchObject({ + status: 'ERROR', + detectedMimeType: 'application/pdf', + metadata: { + scanned: false, + provider: 'unavailable', + errorCode: 'SCAN_PROVIDER_NOT_CONFIGURED', + }, + }); + }); + + it('keeps the no-op scanner available only for explicit test use', async () => { + const result = await createNoopScanProvider().scan(input); + + expect(result).toMatchObject({ + status: 'CLEAN', + detectedMimeType: 'application/pdf', + metadata: { + scanned: false, + provider: 'noop', + }, + }); + }); +}); diff --git a/planning/runs/F-002.md b/planning/runs/F-002.md new file mode 100644 index 0000000..7923847 --- /dev/null +++ b/planning/runs/F-002.md @@ -0,0 +1,219 @@ +# F-002: Fail closed when upload scanning is unavailable + +**Task ID:** F-002 +**Related Task:** P2-T02 — Implement upload completion and quarantine workflow +**Final State:** DONE +**Date:** 2026-06-17 +**Base Commit:** `f735e57dd5b4619729a5e8067c0c82d0242e37eb` +**Branch:** `audit/F-002-fail-closed-upload-scanning` + +--- + +## Repository State Inspected + +- Updated `main` after merge of F-001 at `f735e57dd5b4619729a5e8067c0c82d0242e37eb` +- Isolated remediation worktree on branch + `audit/F-002-fail-closed-upload-scanning` +- `planning/backlog.yaml` — P2-T02 requires failed or pending scanning to produce quarantine or safe failure, not ingestion +- `planning/runs/P2-T02.md` — original upload completion and quarantine implementation record +- `docs/05_SECURITY_GOVERNANCE.md#7-file-processing-controls` +- `docs/security/threat-model.md` malicious-upload controls +- Upload route, scan-provider abstraction, upload workflow, and related tests + +--- + +## Missing Capability Reproduced + +Before remediation: + +- `apps/api/src/routes/uploads.ts` selected `createNoopScanProvider()` whenever no scanner was injected. +- The normal server registration did not inject a scan provider. +- `createNoopScanProvider()` returned `CLEAN` for every upload while recording `scanned: false`. +- A `CLEAN` result caused the upload workflow to create an ingestion job and publish `document.ingestion.requested`. +- Therefore an absent production malware scanner silently permitted unscanned content to enter ingestion. + +This contradicted the governing acceptance criterion that failed or unavailable scanning must quarantine or safely fail rather than ingest. + +--- + +## Files Changed + +### New Files + +| File | Description | +| -------------------------------------- | ----------------------------------------------------------------- | +| `packages/knowledge/test/scan.test.ts` | Focused tests for fail-closed and explicit no-op scanner behavior | +| `planning/runs/F-002.md` | Evidence record for the F-002 remediation | + +### Modified Files + +| File | Change | +| --------------------------------------- | ------------------------------------------------------------------------------ | +| `packages/knowledge/src/scan.ts` | Added `createUnavailableScanProvider()` returning `ERROR`; clarified no-op use | +| `packages/knowledge/src/index.ts` | Exported `createUnavailableScanProvider()` | +| `packages/knowledge/package.json` | Added the scanner test to the package unit-test script | +| `apps/api/src/routes/uploads.ts` | Replaced the implicit no-op default with the fail-closed provider | +| `apps/api/test/upload-workflow.test.ts` | Added unavailable-scanner quarantine and no-ingestion coverage | + +--- + +## Design Decisions and Assumptions + +1. **Fail closed instead of failing server startup** + + No production scanner adapter or scanner configuration currently exists. The smallest safe remediation is therefore a provider that returns `ERROR`, allowing the existing workflow to persist scan provenance, quarantine the upload, and avoid ingestion. + +2. **Preserve the explicit no-op adapter for tests** + + `createNoopScanProvider()` remains available because existing deterministic workflow tests explicitly inject it to exercise clean-upload behavior. It is no longer selected by runtime route defaults. + +3. **Reuse existing quarantine semantics** + + The upload workflow already maps scan status `ERROR` to quarantine and suppresses ingestion-job creation. The remediation uses that existing domain behavior rather than adding parallel route-level logic. + +4. **Record an explicit stable reason code** + + The unavailable provider records: + + - `scanned: false` + - `provider: unavailable` + - `errorCode: SCAN_PROVIDER_NOT_CONFIGURED` + + This provides operational evidence without storing uploaded content or sensitive data. + +5. **No real malware-scanner adapter introduced** + + Adding an external antivirus integration would require provider selection, configuration, timeout, network-isolation, deployment, and operational decisions outside this atomic finding. + +--- + +## Commands Run and Results + +| Command | Result | Status | +| --------------------------------------------------------------------- | ----------------------------------------------------------------- | ------ | +| `pnpm install --frozen-lockfile` | Lockfile current; 462 packages installed | PASS | +| `pnpm build` | Turbo workspace build: 17/17 tasks successful | PASS | +| `pnpm --filter @pia/knowledge exec vitest run test/scan.test.ts` | 2/2 tests passed | PASS | +| `pnpm --filter @pia/api exec vitest run test/upload-workflow.test.ts` | 18/18 tests passed | PASS | +| `pnpm --filter @pia/knowledge typecheck` | No TypeScript errors | PASS | +| `pnpm --filter @pia/api typecheck` | No TypeScript errors | PASS | +| `pnpm --filter @pia/knowledge lint` | Clean; existing TypeScript-version warning only | PASS | +| `pnpm --filter @pia/api lint` | Clean; existing TypeScript-version warning only | PASS | +| `pnpm --filter @pia/knowledge test:unit` | 203/203 tests passed | PASS | +| `pnpm --filter @pia/api test:unit` | 119/119 tests passed | PASS | +| `pnpm format:check` | All matched files use Prettier formatting | PASS | +| `pnpm security:secrets` | No secrets detected | PASS | +| `git diff --check` | No whitespace errors | PASS | +| Runtime source search for `createNoopScanProvider` | No application runtime references; export and implementation only | PASS | + +### Validation Environment Note + +Initial direct API testing and package typechecking failed before test collection because the fresh worktree did not yet contain generated `dist` outputs for internal workspace packages. After the supported dependency-aware `pnpm build` completed successfully, the same upload test and typechecks were rerun and passed. + +--- + +## Acceptance-Criterion Evidence + +### AC1: Missing scanning does not permit ingestion + +- `uploadRoutes` now defaults to `createUnavailableScanProvider()`. +- The provider returns scan status `ERROR`. +- The focused upload workflow test verifies: + - document version status is `QUARANTINED`; + - no ingestion job is created; + - stored scan status is `ERROR`. + +### AC2: The failure is persisted with safe provenance + +The unavailable scanner writes metadata identifying that no scan occurred and why: + +- `scanned: false` +- `provider: unavailable` +- `errorCode: SCAN_PROVIDER_NOT_CONFIGURED` + +No raw file content, credentials, or sensitive payload is written to metadata. + +### AC3: Explicit clean-scan test behavior remains available + +The no-op provider remains callable only through explicit dependency injection. Its focused unit test confirms its existing deterministic `CLEAN` behavior for controlled tests. + +### AC4: Existing upload behavior remains intact when a scanner returns a valid result + +The complete API upload workflow suite passes 18/18, covering: + +- clean uploads; +- infected, pending, and error statuses; +- unavailable scanner; +- MIME validation; +- idempotency; +- outbox events; +- checksum errors; +- ingestion suppression for quarantined files. + +--- + +## Security and Privacy Impact + +| Area | Impact | +| ----------------- | ----------------------------------------------------------------------- | +| Malware scanning | Improves behavior from fail-open to fail-closed when no scanner exists | +| Ingestion safety | Unverified content no longer creates ingestion jobs | +| Quarantine | Reuses the existing persisted quarantine lifecycle | +| Provenance | Records a stable scanner-unavailable reason code | +| Sensitive data | No uploaded content or credentials added to logs or metadata | +| Secrets | Repository secret scan passed | +| Tenant isolation | No workspace-scoping logic changed | +| External services | No new network access, credentials, or provider dependencies introduced | + +--- + +## Database and API Compatibility Impact + +### Database + +- No schema or migration changes. +- Existing `stored_files.scan_status` and `scan_metadata` fields are used. +- Existing document-version quarantine transitions are reused. + +### API + +- No request or response schema changes. +- Behavioral change: completing an upload without a configured scanner now returns a quarantined result rather than scheduling ingestion. +- Explicitly injected real or test scan providers retain their existing behavior. + +### Packages + +- No dependency or lockfile changes. +- One existing package test script now includes `test/scan.test.ts`. + +--- + +## Rollback + +Revert the F-002 commit to restore the prior route default. + +Rollback would reintroduce the security defect where an absent scanner is treated as `CLEAN`; therefore rollback must not be followed by production upload processing without another fail-closed control. + +--- + +## Remaining Risks and Follow-up Tasks + +1. **Real scanner integration** + + No production antivirus/content-inspection adapter exists. Until one is configured and injected, uploads are safely quarantined and cannot proceed to ingestion. + +2. **Curator release workflow** + + The repository still requires a controlled process to rescan or release quarantined files after a scanner becomes available. + +3. **Manual no-op injection** + + The no-op provider remains exported for deterministic tests and controlled development use. A future hardening step could move it to test support or require an explicit development-only selection guard. + +4. **Operational observability** + + The stable `SCAN_PROVIDER_NOT_CONFIGURED` metadata is persisted, but dedicated alerting or health reporting for unavailable scanning is not part of this atomic remediation. + +5. **External scanner controls** + + A future real adapter must define timeout, resource limits, network isolation, retry behavior, provider ownership, and security tests before production use. diff --git a/planning/runs/REPO-AUDIT-F-003.md b/planning/runs/REPO-AUDIT-F-003.md new file mode 100644 index 0000000..a0a6237 --- /dev/null +++ b/planning/runs/REPO-AUDIT-F-003.md @@ -0,0 +1,269 @@ +# Repository Audit F-003: Enforce default-branch merge governance + +**Task ID:** REPO-AUDIT-F-003 +**Source Finding:** External repository audit `F-003` +**Final State:** DONE +**Date:** 2026-06-17 +**Base Commit:** `39f0d5097c71ddb41961bec95bf5e5d9bcd5b415` +**Branch:** `audit/F-003-enforce-main-governance` + +--- + +## Repository State Inspected + +- Updated `main` after merge of F-002 at + `39f0d5097c71ddb41961bec95bf5e5d9bcd5b415` +- Authoritative audit finding: + `F-003 — Default branch has no branch protection, ruleset, or required status checks` +- `.github/workflows/ci.yaml` +- Hosted repository rulesets, branch protection, collaborators, permissions, + commit check runs, and Actions policy +- Existing repository governance files and issue templates +- Namespace check: the repository-local `audit-findings.log.md` already uses + bare `F-003` for the logout revocation defect, so this run uses the + namespaced ID `REPO-AUDIT-F-003` while preserving the external audit finding + identifier. + +--- + +## Missing Capability Reproduced + +Before remediation: + +- `main` had no classic branch protection. +- The repository had no active rulesets. +- No status checks or reviews were required before merging. +- Force pushes and branch deletion were not restricted by a branch rule. +- `.github/CODEOWNERS` did not exist. +- The repository had only one collaborator, `MerverliPy`, with administrator + access. + +This allowed a maintainer credential to update `main` without an independently +validated pull request. + +--- + +## Files Changed + +### New Files + +| File | Description | +| ----------------------------------- | ---------------------------------------------------- | +| `.github/CODEOWNERS` | Defines default and sensitive-path review ownership | +| `planning/runs/REPO-AUDIT-F-003.md` | Records repository and hosted-governance remediation | + +No application, workflow, dependency, database, or API files were changed. + +--- + +## Hosted Repository Configuration + +A repository ruleset was created with these properties: + +| Property | Value | +| -------------------------------- | ------------------- | +| Ruleset name | `main-merge-safety` | +| Ruleset ID | `17802842` | +| Target | Branch | +| Ref condition | Default branch | +| Enforcement | Active | +| Required pull request | Yes | +| Required approving reviews | 1 | +| Dismiss stale approvals | Yes | +| Require code-owner review | Yes | +| Require review-thread resolution | Yes | +| Require last-push approval | No | +| Required status check | `Quality Gates` | +| Required status check | `Security Checks` | +| Require branch to be current | Yes | +| Block deletion | Yes | +| Block non-fast-forward updates | Yes | + +The only current collaborator is the repository owner. To avoid permanently +deadlocking this single-maintainer repository, that user has a narrowly scoped +`pull_request` bypass actor entry. This permits bypass only through a pull +request; it does not authorize direct pushes to `main`. + +A JSON export of the disabled pre-activation ruleset was stored outside the +repository for rollback. + +--- + +## CODEOWNERS Policy + +`.github/CODEOWNERS` assigns the repository owner as the default reviewer and +explicitly identifies security-, automation-, authentication-, data-, and +release-sensitive paths, including: + +- `.github/` +- `apps/api/` +- `packages/auth/` +- `packages/config/` +- `packages/knowledge/` +- `infra/` +- `compose.yaml` +- root OpenCode configuration +- `.opencode/` + +GitHub reads `CODEOWNERS` from the pull request base branch. Code-owner matching +becomes fully effective after this change reaches `main`. + +--- + +## Design Decisions and Assumptions + +1. **Use a repository ruleset rather than classic branch protection** + + Rulesets provide one auditable configuration for PR requirements, required + checks, deletion protection, non-fast-forward protection, and scoped bypass + actors. + +2. **Require the exact existing CI check names** + + The current successful GitHub Actions check runs are: + + - `Quality Gates` + - `Security Checks` + + The ruleset uses those exact contexts. + +3. **Use a PR-only owner bypass** + + A one-approval rule cannot normally be satisfied by the author in a + single-collaborator repository. The owner bypass is therefore restricted to + `pull_request` mode, preserving the prohibition on direct pushes. + +4. **Require code-owner review** + + Sensitive paths receive explicit ownership, while the wildcard entry ensures + every repository path has an owner. + +5. **Keep F-003 narrowly scoped** + + General contribution, conduct, vulnerability-reporting, and issue-template + files are governed by other audit findings and were not added here. + +--- + +## Commands Run and Results + +| Validation | Result | Status | +| --------------------------------- | ---------------------------------------------------------------- | ---------- | +| Inspect classic branch protection | GitHub returned `404 Branch not protected` before remediation | REPRODUCED | +| Inspect repository rulesets | No rulesets existed before remediation | REPRODUCED | +| Inspect active rules on `main` | No active rules existed before remediation | REPRODUCED | +| Inspect repository collaborators | Only `MerverliPy` with admin access | PASS | +| Verify CODEOWNERS account | `MerverliPy` resolves to an active GitHub user | PASS | +| Inspect current check runs | `Quality Gates` and `Security Checks` completed successfully | PASS | +| Create ruleset disabled | Ruleset ID `17802842` stored with intended configuration | PASS | +| Export rollback configuration | Disabled ruleset JSON preserved outside repository | PASS | +| Activate ruleset | Enforcement changed to `active` | PASS | +| Inspect active rules on `main` | PR, review, check, deletion, and non-fast-forward rules returned | PASS | +| `git diff --check` | No whitespace errors | PASS | + +--- + +## Acceptance-Criterion Evidence + +### AC1: Changes to `main` require pull requests + +The active `pull_request` rule applies to the default branch. + +### AC2: Review is required on the non-bypass path + +The default path requires one approving review, dismisses stale approvals, +requires code-owner review, and requires all review threads to be resolved. +The repository owner may bypass these requirements only while merging through +a pull request; direct pushes remain outside the bypass scope. + +### AC3: CI checks are required + +The ruleset requires successful completion of: + +- `Quality Gates` +- `Security Checks` + +Strict status-check policy requires the pull request branch to be current with +the target branch. + +### AC4: Destructive branch operations are restricted + +The active ruleset contains: + +- `deletion` +- `non_fast_forward` + +These rules protect against branch deletion and force-push history rewrites. + +### AC5: Sensitive paths have explicit owners + +`.github/CODEOWNERS` provides default ownership and explicit ownership for +security- and release-sensitive paths. + +--- + +## Security and Privacy Impact + +| Area | Impact | +| ------------------------- | ------------------------------------------------------------ | +| Direct updates to `main` | Blocked by active PR governance | +| Independent validation | Default path requires review and CI; owner bypass is PR-only | +| Sensitive paths | Explicit CODEOWNERS coverage added | +| Force pushes | Blocked | +| Branch deletion | Blocked | +| Bypass scope | Limited to owner actions performed through a pull request | +| Secrets and personal data | None added | +| Application runtime | Unchanged | + +--- + +## Database and API Compatibility Impact + +- No database schema or migration changes. +- No request or response contract changes. +- No runtime application behavior changes. +- No dependency or lockfile changes. +- No GitHub Actions workflow changes. + +--- + +## Rollback + +1. Export the current active ruleset. +2. Restore the preserved disabled ruleset configuration or change enforcement + to `disabled`. +3. Correct only the rule causing operational failure where possible. +4. Retain pull-request, deletion, and non-fast-forward protections unless they + are themselves the verified failure source. +5. Revert the repository commit to remove `CODEOWNERS` only when ownership + configuration is the verified failure source. + +--- + +## Remaining Risks and Follow-up Tasks + +1. **Single-maintainer governance** + + Independent human approval is unavailable until another trusted collaborator + is added. The PR-only owner bypass prevents deadlock but retains high trust in + the owner account. + +2. **Account security** + + Administrator account hardening, phishing resistance, and recovery controls + remain outside this repository change. + +3. **Additional CI gates** + + F-006 must add the integration, governance, end-to-end, and security gates + identified by the audit. Their exact stable names should then be added to the + ruleset. + +4. **Actions hardening** + + F-005 must pin Actions and restrict the repository Actions policy. + +5. **CODEOWNERS activation** + + GitHub reads CODEOWNERS from the base branch, so code-owner matching becomes + fully effective when this pull request is merged into `main`. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4eefe8e..9f6b699 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,6 +22,9 @@ importers: eslint: specifier: ^8.57.0 version: 8.57.1 + opencode-ai: + specifier: 1.17.7 + version: 1.17.7 pg: specifier: ^8.13.0 version: 8.21.0 @@ -3131,6 +3134,111 @@ packages: zod: optional: true + opencode-ai@1.17.7: + resolution: + { + integrity: sha512-5oMjuqlVL78JhvXshwp2NCXCI+CHr24wWi7/5aI0CZoHnI44qTqssWOBUW59dPTpRGSOQmXTDTOOsPVYW38JPg==, + } + cpu: [arm64, x64] + os: [darwin, linux, win32] + hasBin: true + + opencode-darwin-arm64@1.17.7: + resolution: + { + integrity: sha512-/uoZpJvnxY1jtRXAASQTIn0goya61M1RJhX0Zx2RwO+sdnrfvYYX6p7iL82Rl+Sp+TAS8y2NBvN+p/OLAnxsqg==, + } + cpu: [arm64] + os: [darwin] + + opencode-darwin-x64-baseline@1.17.7: + resolution: + { + integrity: sha512-nvaY4qQgS9ZSkCvw9+DOrQQeycbW8/AEcD/Q0suleMuUgFIqfrsVa4cdsmYrUh3BH+y2NRaVgMSIfU9gPqXAKA==, + } + cpu: [x64] + os: [darwin] + + opencode-darwin-x64@1.17.7: + resolution: + { + integrity: sha512-v60XhJae1eKn/Kjhy2PLOY+ss7peSox8ILZFz7fwBzRgz4q61gIo1vM9WzXQ6Vt+5Oj8etYbPl3xmt1XDLZtEA==, + } + cpu: [x64] + os: [darwin] + + opencode-linux-arm64-musl@1.17.7: + resolution: + { + integrity: sha512-bIySXi+XNLHL5m8lS1ljL5XZzQ0iMdf/X6KKaqHbDZQ3E0Qu7ERIHZjohx8S7htvCdPztuzeSr2udS0emumf6g==, + } + cpu: [arm64] + os: [linux] + + opencode-linux-arm64@1.17.7: + resolution: + { + integrity: sha512-HTH5Z5V7xiAD+/nYn9wQYwM/LDokBZu8Ig+npwJrhGWzZGM+lChbv2+griYyRoaNEZ+zRsCvwyv96TTfb6nWDQ==, + } + cpu: [arm64] + os: [linux] + + opencode-linux-x64-baseline-musl@1.17.7: + resolution: + { + integrity: sha512-kTuZpRxMOzKt+ztp6yb+cSN8L69UYWN1x7Na4egX2d26IU0xK+RlXE9HjHzF/EjsmSBMc3DR8MvKUVldQ2XdbA==, + } + cpu: [x64] + os: [linux] + + opencode-linux-x64-baseline@1.17.7: + resolution: + { + integrity: sha512-fj62eWDQSygxS3Q5S3Gm4VYOsHrlTnje46bXoWc9IXfFNwFHAyL+izKzpU1SePCpCCEdsjy//nCKOnqN4PPK5g==, + } + cpu: [x64] + os: [linux] + + opencode-linux-x64-musl@1.17.7: + resolution: + { + integrity: sha512-iKUBKzVD1ybMmAy3KW6cjfst18+glp3Fgtd7POGEAaQO7cWzwSmOnFHN3uCAi/wYrfrvYd4zXxHsFP0yMJRUmA==, + } + cpu: [x64] + os: [linux] + + opencode-linux-x64@1.17.7: + resolution: + { + integrity: sha512-UIxkdA/8281EHbHYVr5PSD+eVoMdlyfkmXiZp3u9duttsMHdf1F6lw0XjYmDRBCPp8zQM1D1RLCABuRA/kUX+Q==, + } + cpu: [x64] + os: [linux] + + opencode-windows-arm64@1.17.7: + resolution: + { + integrity: sha512-BVlfloqHrjPhpDvbm3u1vQuEn063lbT3lcT7HBLmHpvwJd6FJutjPpm5/3xYxSusmXRGL7bmMZ3v1KPOeY0tBQ==, + } + cpu: [arm64] + os: [win32] + + opencode-windows-x64-baseline@1.17.7: + resolution: + { + integrity: sha512-Gui/cezrLsLEZb1rUwNoKGXIiuZA0FsaGN3L/qR3/Qpce3e+hhqfqLHQXqlh0PFU1dSRKVRF8ukwWVx+2G5CPQ==, + } + cpu: [x64] + os: [win32] + + opencode-windows-x64@1.17.7: + resolution: + { + integrity: sha512-MAykQj6ouoZ2rMt+q8ujBTnc4sD86WfLnPom28CTD+KgmI1s2D2qka7J6DjpK6A3j8PpkC0bEBIqVBciVgY6EA==, + } + cpu: [x64] + os: [win32] + openid-client@6.8.4: resolution: { @@ -6019,6 +6127,57 @@ snapshots: optionalDependencies: ws: 8.21.0 + opencode-ai@1.17.7: + optionalDependencies: + opencode-darwin-arm64: 1.17.7 + opencode-darwin-x64: 1.17.7 + opencode-darwin-x64-baseline: 1.17.7 + opencode-linux-arm64: 1.17.7 + opencode-linux-arm64-musl: 1.17.7 + opencode-linux-x64: 1.17.7 + opencode-linux-x64-baseline: 1.17.7 + opencode-linux-x64-baseline-musl: 1.17.7 + opencode-linux-x64-musl: 1.17.7 + opencode-windows-arm64: 1.17.7 + opencode-windows-x64: 1.17.7 + opencode-windows-x64-baseline: 1.17.7 + + opencode-darwin-arm64@1.17.7: + optional: true + + opencode-darwin-x64-baseline@1.17.7: + optional: true + + opencode-darwin-x64@1.17.7: + optional: true + + opencode-linux-arm64-musl@1.17.7: + optional: true + + opencode-linux-arm64@1.17.7: + optional: true + + opencode-linux-x64-baseline-musl@1.17.7: + optional: true + + opencode-linux-x64-baseline@1.17.7: + optional: true + + opencode-linux-x64-musl@1.17.7: + optional: true + + opencode-linux-x64@1.17.7: + optional: true + + opencode-windows-arm64@1.17.7: + optional: true + + opencode-windows-x64-baseline@1.17.7: + optional: true + + opencode-windows-x64@1.17.7: + optional: true + openid-client@6.8.4: dependencies: jose: 6.2.3