Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,11 @@ Click the settings icon to choose where config is saved:
- **User**: `~/.config/abbenay/config.yaml` (default)
- **Workspace**: `<workspace>/.config/abbenay/config.yaml` (requires VS Code connection)

HTTP `POST /api/config` validates the body with Zod (`ConfigFile` shape) and
only allows workspace `location` values that match a currently connected /
allowlisted workspace path. Path traversal (`..`) and unknown locations are
rejected with no file write.

## VS Code Extension

The VS Code extension:
Expand Down
15 changes: 15 additions & 0 deletions docs/CORE.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,21 @@ config.providers!['my-openai'].models!['gpt-4o'] = { temperature: 0.7 };
saveConfig(config);
```

### Config validation (Zod)

`ConfigFileSchema` and `PolicyConfigSchema` (plus `parseConfigFile`) validate
config-shaped objects before persistence. The daemon HTTP API uses the same
schemas for `POST /api/config` and related routes.

```typescript
import { parseConfigFile } from '@abbenay/core';

const result = parseConfigFile(raw);
if (!result.success) {
console.error(result.error.issues);
}
```

## API Reference

### CoreState
Expand Down
4 changes: 3 additions & 1 deletion docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,14 @@ Pure unit tests with no I/O, no network, no processes.
|------|----------------|
| `src/core/mock.test.ts` | Mock engine: echo, fixed, error, empty, slow modes |
| `src/core/config.test.ts` | Config loading, merging, validation |
| `src/core/config-schema.test.ts` | Zod ConfigFile / PolicyConfig schemas (types, field injection) |
| `src/core/tool-registry.test.ts` | Glob matching, tool registration, resolution, policy filtering |
| `src/core/tool-approval.test.ts` | Shared validator: disabled / auto_approve / require_approval / default ask |
| `src/daemon/mcp-server.test.ts` | MCP authorizeAndExecute honors tool_policy (no bypass) |
| `src/state.test.ts` | DaemonState: provider listing, model listing, chat flow |
| `src/daemon/chat-prompt.test.ts` | `parseApprovalInput` case-sensitive routing |
| `src/daemon/web/openai-compat.test.ts` | OpenAI format mapping: models, finish reasons, stream chunks, complete responses |
| `src/daemon/web/validate-body.test.ts` | HTTP body parse helpers + workspace path allowlist / traversal |
| `src/core/session-store.test.ts` | SessionStore: CRUD, appendMessage, updateTitle, updateSummary, index consistency |
| `src/core/session-summarizer.test.ts` | generateSessionSummary, maybeSummarize interval logic, error handling |

Expand All @@ -86,7 +88,7 @@ Tests that start real servers, make real HTTP/gRPC calls.
| `tests/integration/grpc-streaming.test.ts` | gRPC unary RPCs + streaming + cancellation + concurrency |
| `tests/integration/grpc-tls.test.ts` | gRPC TLS bind policy + SetSecret/GetSecret over TLS |
| `tests/integration/grpc-bind-e2e.test.ts` | Subprocess E2E: localhost OK, 0.0.0.0 refuse, TLS/insecure OK |
| `tests/integration/web-sse.test.ts` | Web API endpoints + SSE chat streaming + errors + disconnect |
| `tests/integration/web-sse.test.ts` | Web API endpoints + SSE chat + config Zod validation / workspace allowlist |
| `tests/integration/openai-compat.test.ts` | OpenAI-compatible API: /v1/models, streaming, non-streaming, errors, tool calls |
| `tests/integration/sessions.test.ts` | Session REST API: CRUD endpoints, session chat SSE streaming + persistence |
| `tests/integration/mcp-http-policy.test.ts` | `/mcp` auth + connection consent + tool_policy E2E |
Expand Down
27 changes: 27 additions & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -556,3 +556,30 @@ query-secret patterns (`?key=`, `query.apiKey` / `query['apiKey']`,
**Rationale:** Query-string secrets leak into access logs, reverse proxies,
browser history, and `Referer` headers. Header/body transport matches other
engines (Bearer / `x-api-key`) and closes findings H2/H3.

---

## DR-036: Zod validation for all HTTP API request bodies

**Date:** 2026-07-17
**Decision:** Validate every mutating HTTP web API request body with a Zod
schema before business logic runs. Invalid bodies return HTTP 400. Config
writes (`POST /api/config`, provider configure/delete with workspace target)
additionally require `location` / `workspacePath` to be `'user'` or an
allowlisted connected workspace path; path segments containing `..` are
rejected (400) and non-allowlisted paths return 403 with no file write.
Shared `ConfigFile` / `PolicyConfig` schemas live in `@abbenay/core`
(`config-schema.ts`) and are reused by the web layer (`api-schemas.ts`).
Most schemas use `.strict()` so unexpected fields are rejected
(field-injection defense). OpenAI `/v1/chat/completions` instead **strips**
unknown client fields (e.g. `stream_options`, `user`) so DR-020 SDKs keep
working, while still requiring `model` / non-empty `messages` and allowing
optional `tools` for DR-032 passthrough. `ConfigFileSchema` /
`ModelConfigSchema` include `openai_compat` and `openai_compat_tools` so
dashboard/`POST /api/config` can persist DR-032 settings without 400s.
Workspace location checks also reject NUL bytes and compare `realpath` when
the path exists.
**Rationale:** Unvalidated `req.body` destructuring allowed arbitrary config
writes, path traversal into workspace config paths, type confusion, and
field injection (findings H4/H5). Auth (DR-030) authenticates the caller but
does not constrain payload shape or write targets.
123 changes: 123 additions & 0 deletions packages/daemon/src/core/config-schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* Unit tests for ConfigFile / PolicyConfig Zod schemas.
*/

import { describe, it, expect } from 'vitest';
import {
ConfigFileSchema,
PolicyConfigSchema,
parseConfigFile,
VirtualNameSchema,
} from './config-schema.js';

describe('VirtualNameSchema', () => {
it('accepts valid virtual names', () => {
expect(VirtualNameSchema.safeParse('openai').success).toBe(true);
expect(VirtualNameSchema.safeParse('my-provider_v2').success).toBe(true);
});

it('rejects uppercase, spaces, and slashes', () => {
expect(VirtualNameSchema.safeParse('OpenAI').success).toBe(false);
expect(VirtualNameSchema.safeParse('my provider').success).toBe(false);
expect(VirtualNameSchema.safeParse('a/b').success).toBe(false);
});
});

describe('ConfigFileSchema', () => {
it('accepts a minimal valid config', () => {
const result = parseConfigFile({ providers: {} });
expect(result.success).toBe(true);
});

it('accepts a full provider + models shape', () => {
const result = parseConfigFile({
providers: {
openai: {
engine: 'openai',
models: {
'gpt-4o': { temperature: 0.2, max_tokens: 1024 },
},
},
},
tool_policy: { auto_approve: ['mcp:safe/*'] },
});
expect(result.success).toBe(true);
});

it('rejects unknown top-level keys (field injection)', () => {
const withEvil = ConfigFileSchema.safeParse({ providers: {}, evil: true });
expect(withEvil.success).toBe(false);
});

it('rejects wrong types for required provider fields', () => {
const result = parseConfigFile({
providers: {
openai: { engine: 42 },
},
});
expect(result.success).toBe(false);
});

it('rejects out-of-range temperature', () => {
const result = parseConfigFile({
providers: {
openai: {
engine: 'openai',
models: { 'gpt-4o': { temperature: 9 } },
},
},
});
expect(result.success).toBe(false);
});

it('accepts openai_compat tools passthrough (DR-032)', () => {
const result = parseConfigFile({
providers: {},
openai_compat: { tools: 'passthrough' },
});
expect(result.success).toBe(true);
});

it('accepts per-model openai_compat_tools override', () => {
const result = parseConfigFile({
providers: {
openai: {
engine: 'openai',
models: { 'gpt-4o': { openai_compat_tools: 'passthrough' } },
},
},
});
expect(result.success).toBe(true);
});

it('rejects invalid openai_compat.tools values', () => {
const result = parseConfigFile({
openai_compat: { tools: 'auto' },
});
expect(result.success).toBe(false);
});

it('rejects unknown keys under openai_compat', () => {
const result = ConfigFileSchema.safeParse({
openai_compat: { tools: 'passthrough', evil: true },
});
expect(result.success).toBe(false);
});
});

describe('PolicyConfigSchema', () => {
it('accepts nested policy fields', () => {
const result = PolicyConfigSchema.safeParse({
sampling: { temperature: 0.3 },
output: { format: 'json_only', max_tokens: 512 },
});
expect(result.success).toBe(true);
});

it('rejects invalid format enum', () => {
const result = PolicyConfigSchema.safeParse({
output: { format: 'xml' },
});
expect(result.success).toBe(false);
});
});
Loading
Loading