Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
7efea7a
feat(runtime): add channel-neutral execution context
chitcommit Oct 3, 2026
21b56a7
feat(runtime): mount execution context after authorization
chitcommit Oct 3, 2026
61e758d
feat(runtime): type execution context in Hono variables
chitcommit Oct 3, 2026
0b49f10
feat(audit): attach execution provenance to ledger writes
chitcommit Oct 3, 2026
8993c9b
feat(mcp): label canonical capability and intent
chitcommit Oct 3, 2026
dffa4d5
test(runtime): prove channel-neutral execution context
chitcommit Oct 3, 2026
74cd44e
docs(agents): define channel-neutral execution context
chitcommit Oct 3, 2026
4c3dd02
docs(security): document cross-channel provenance boundary
chitcommit Oct 3, 2026
c500607
docs(charter): declare portable execution context
chitcommit Oct 3, 2026
f6923b4
docs(chitty): describe provider-neutral runtime context
chitcommit Oct 3, 2026
6d9d509
fix(runtime): sanitize claimed provenance and intent inference
chitcommit Oct 3, 2026
055c2a6
fix(audit): type execution provenance enrichment
chitcommit Oct 3, 2026
b5b498a
fix(mcp): declare execution intent per tool
chitcommit Oct 3, 2026
f54aacd
test(runtime): cover provenance sanitization and exact intent matching
chitcommit Oct 3, 2026
1b676cf
test(mcp): align unknown-tool validation behavior
chitcommit Oct 3, 2026
1074941
fix(runtime): fail safe on intent and distinguish claimed provenance
chitcommit Oct 3, 2026
458d9a6
fix(mcp): canonicalize resource capabilities and preserve errors
chitcommit Oct 3, 2026
82970eb
test(mcp): verify real execution capability wiring
chitcommit Oct 3, 2026
a749d27
test(audit): verify ledger execution provenance enrichment
chitcommit Oct 3, 2026
8007be6
docs(security): mark provenance and intent non-authoritative
chitcommit Oct 3, 2026
b21d4c4
docs(agents): clarify audit-only execution intent
chitcommit Oct 3, 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
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ Mounted at `/mcp`. Resources include:
- Allocation rule preview
- Schedule E line summary

Authentication: verified ChittyAuth bearer JWT for end-user/agent callers; legacy internal service-token callers remain supported with explicit `X-Chitty-User-Id`. Tenant authorization still comes from `tenant_users`. See [SECURITY.md](SECURITY.md).
Authentication: verified ChittyAuth bearer JWT for end-user/agent callers; legacy internal service-token callers remain supported with explicit `X-Chitty-User-Id`. Tenant authorization still comes from `tenant_users`. After authorization, every protected request receives a channel-neutral execution context carrying actor, tenant scope, capability/intent, source provenance, and trace metadata. Source metadata never grants financial authority. See [SECURITY.md](SECURITY.md).

> ⚠️ **Phase 2 remaining**: ChittyConnect MCP integration not yet wired. Internal MCP routes work today; cross-service MCP discovery via ChittyConnect is pending.

Expand Down Expand Up @@ -83,6 +83,10 @@ When working in this repo, prefer these subagents (see user's `~/.claude/agents/
- COA modifications (L4) — only `tenant_users.role` ∈ {owner, admin}
- Webhook signature verification — never bypassed for any agent caller

### Channel-Neutral Execution Context

Protected requests are normalized after authentication + tenant membership checks. The runtime context is request-scoped only (no new database/schema) and contains the verified actor, authorized tenant scope, inferred or route-specific capability/intent, source provenance, and trace metadata. `X-Source-Service` plus W3C `traceparent`/`baggage` carry portable provenance for ChatGPT, Claude, ChittyClaw/OpenClaw, and other adapters. Platform workspace/channel metadata and inferred intent are audit context only and MUST NOT affect `tenant_users` authorization or mutation authority.

**ChittyFinance does delegate these to external agents:**
- Identity (ChittyID via OAuth 2.0 PKCE)
- Token validation (ChittyAuth)
Expand Down
3 changes: 2 additions & 1 deletion CHARTER.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ ChittyFinance is a **full-stack financial management platform** for the ChittyOS
- Forensic accounting (Benford's Law, duplicate detection, flow of funds, damages calculation)
- GitHub integration for project cost attribution
- Tenant-scoped financial data isolation
- Channel-neutral request execution context for ChatGPT, Claude, ChittyClaw/OpenClaw, and other adapters (actor + authorized scope + capability/intent + provenance + trace; request-scoped only)
- Inbound email handling at `finance@chitty.cc` (Cloudflare Email Service)

### IS NOT Responsible For
Expand Down Expand Up @@ -114,7 +115,7 @@ IT CAN BE LLC (holding)
### MCP
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/mcp` | POST | JSON-RPC MCP resources/tools; caller identity via ChittyAuth bearer JWT or legacy service-token compatibility lane; tenant authorization remains membership-scoped |
| `/mcp` | POST | JSON-RPC MCP resources/tools; caller identity via ChittyAuth bearer JWT or legacy service-token compatibility lane; tenant authorization remains membership-scoped; MCP methods label canonical capability + read/suggest/execute intent |

### Financial Data
| Endpoint | Method | Purpose |
Expand Down
2 changes: 1 addition & 1 deletion CHITTY.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Full-stack financial management platform providing intelligent tracking, AI-powe

## Architecture

Dual-mode: Hono on Cloudflare Workers (production) with Neon PostgreSQL multi-tenant, or Hono via `@hono/node-server` with SQLite (local dev). React frontend with Vite.
Dual-mode: Hono on Cloudflare Workers (production) with Neon PostgreSQL multi-tenant, or Hono via `@hono/node-server` with SQLite (local dev). React frontend with Vite. Protected requests are normalized into a request-scoped execution context after identity and tenant authorization so ChatGPT, Claude, ChittyClaw/OpenClaw, and web clients can share the same financial capability surface without sharing platform-specific authority logic.

### Stack
- **Runtime**: Cloudflare Workers + Hono (production) / Hono node-server (dev) / Express (legacy `dev:legacy` fallback)
Expand Down
8 changes: 8 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ We follow coordinated disclosure and will credit reporters unless anonymity is p
- MCP `finance://tenants` enumerates only the authenticated caller's memberships
- All storage methods enforce `tenantId` filtering
- Inter-tenant data access prevented at the storage abstraction layer
- Channel/workspace/session provenance is resolved only after actor + tenant authorization and cannot select or elevate tenant access

### Secret Management

Expand All @@ -56,6 +57,13 @@ We follow coordinated disclosure and will credit reporters unless anonymity is p
- No secrets in code, KV, or R2
- Pre-commit hooks scan for credential patterns

### Cross-Channel Provenance

- Existing `X-Source-Service` is recorded as a caller-claimed adapter identity for audit correlation; it is not an attestation
- W3C `traceparent` carries distributed trace linkage; W3C `baggage` may carry `chitty.source`, `chitty.channel`, `chitty.workspace`, and `chitty.session`
- Provenance fields and inferred `intent` are non-authoritative audit context: they are never used to derive `userId`, tenant membership, financial role, or write permission
- ChittyLedger audit writes inherit the request execution context automatically

### OAuth & Webhook Security

- **OAuth state**: HMAC-SHA256 signed tokens, 10-minute expiry, timing-safe verification
Expand Down
124 changes: 124 additions & 0 deletions server/__tests__/execution-context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { describe, expect, it } from 'vitest';
import { Hono } from 'hono';
import type { HonoEnv } from '../env';
import {
executionContextMiddleware,
inferExecutionIntent,
setExecutionOperation,
} from '../middleware/execution-context';

function buildApp() {
const app = new Hono<HonoEnv>();

app.use('*', async (c, next) => {
c.set('userId', 'user-1');
c.set('authMethod', 'chittyauth');
c.set('tenantId', 'tenant-authorized');
await next();
});
app.use('*', executionContextMiddleware);

app.get('/api/test', (c) => c.json(c.get('executionContext')));
app.post('/api/allocations/preview', (c) => c.json(c.get('executionContext')));
app.post('/api/allocations/execute', (c) => c.json(c.get('executionContext')));
app.post('/mcp-test', (c) => {
setExecutionOperation(c, 'finance.mcp.tool:get-property-advice', 'suggest');
return c.json(c.get('executionContext'));
});

return app;
}

describe('execution context', () => {
it('infers read, preview, suggest, and execute mechanically', () => {
expect(inferExecutionIntent('GET', '/api/accounts')).toBe('read');
expect(inferExecutionIntent('POST', '/api/allocations/preview')).toBe('preview');
expect(inferExecutionIntent('POST', '/api/classification/suggest')).toBe('suggest');
expect(inferExecutionIntent('POST', '/api/allocations/execute')).toBe('execute');
expect(inferExecutionIntent('POST', '/api/x/preview-and-commit')).toBe('execute');
});

it('captures sanitized channel-neutral provenance from source header and W3C baggage', async () => {
const app = buildApp();
const res = await app.request('/api/test', {
headers: {
'X-Source-Service': 'chittyclaw',
baggage: 'chitty.channel=slack;prop=1,chitty.workspace=workspace-1,chitty.session=session-1',
traceparent: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01',
},
});
const body = await res.json() as any;

expect(body.actor).toEqual({ userId: 'user-1', authMethod: 'chittyauth' });
expect(body.source).toEqual({
service: 'chittyclaw',
claimed: true,
channel: 'slack',
workspace: 'workspace-1',
session: 'session-1',
});
expect(body.scope).toEqual({ tenantId: 'tenant-authorized' });
expect(body.intent).toBe('read');
expect(body.trace.traceparent).toContain('4bf92f3577b34da6a3ce929d0e0e4736');
expect(body.trace.requestId).toBeTruthy();
});

it('drops malformed or oversized provenance values', async () => {
const app = buildApp();
const res = await app.request('/api/test', {
headers: {
'X-Source-Service': 'x'.repeat(129),
baggage: 'chitty.channel=' + 'y'.repeat(9000),
traceparent: 'not-a-traceparent',
},
});
const body = await res.json() as any;

expect(body.source.service).toBe('finance.chitty.cc');
expect(body.source.channel).toBeUndefined();
expect(body.trace.traceparent).toBeUndefined();
});

it('uses chitty.source baggage only as a claimed provenance fallback', async () => {
const app = buildApp();
const body = await (await app.request('/api/test', {
headers: { baggage: 'chitty.source=claude' },
})).json() as any;

expect(body.source.service).toBe('claude');
expect(body.source.claimed).toBe(true);
});

it('does not derive financial scope from source metadata', async () => {
const app = buildApp();
const res = await app.request('/api/test', {
headers: {
baggage: 'chitty.workspace=tenant-attacker,chitty.session=tenant-other',
},
});
const body = await res.json() as any;

expect(body.scope.tenantId).toBe('tenant-authorized');
expect(body.source.workspace).toBe('tenant-attacker');
});

it('marks preview and execute routes distinctly', async () => {
const app = buildApp();

const preview = await (await app.request('/api/allocations/preview', { method: 'POST' })).json() as any;
const execute = await (await app.request('/api/allocations/execute', { method: 'POST' })).json() as any;

expect(preview.intent).toBe('preview');
expect(execute.intent).toBe('execute');
});

it('allows MCP to override the generic HTTP operation with canonical capability intent', async () => {
const app = buildApp();
const body = await (await app.request('/mcp-test', { method: 'POST' })).json() as any;

expect(body.capability).toBe('finance.mcp.tool:get-property-advice');
expect(body.intent).toBe('suggest');
expect(body.actor.userId).toBe('user-1');
expect(body.scope.tenantId).toBe('tenant-authorized');
});
});
36 changes: 35 additions & 1 deletion server/__tests__/ledger-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Ledger client tests — mocked fetch, no real network calls
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { postLedgerEntry, resolveLedgerBase, logToLedger } from '../lib/ledger-client';
import { postLedgerEntry, resolveLedgerBase, logToLedger, ledgerLog } from '../lib/ledger-client';

const MOCK_RESPONSE = { id: 'uuid-1', sequenceNumber: '42', hash: 'abc123def456' };

Expand Down Expand Up @@ -107,4 +107,38 @@ describe('ledger-client', () => {
)).resolves.toBeUndefined();
});
});

describe('ledgerLog', () => {
it('attaches execution provenance while preserving existing metadata', async () => {
fetchSpy.mockResolvedValue(new Response(JSON.stringify(MOCK_RESPONSE), { status: 200 }));

let pending: Promise<unknown> | undefined;
const execution = {
actor: { userId: 'user-1', authMethod: 'chittyauth' as const },
source: { service: 'chittyclaw', claimed: true, channel: 'slack' },
scope: { tenantId: 'tenant-1' },
capability: 'finance.allocations.execute',
intent: 'execute' as const,
trace: { requestId: 'req-1' },
};

ledgerLog({
executionCtx: { waitUntil: (promise) => { pending = promise; } },
get: () => execution,
}, {
entityType: 'audit',
action: 'allocation.executed',
metadata: { period: '2026-09' },
}, {
CHITTY_LEDGER_BASE: 'https://ledger.chitty.cc',
CHITTY_AUTH_SERVICE_TOKEN: 'tok-123',
});

await pending;

const body = JSON.parse(fetchSpy.mock.calls[0][1].body);
expect(body.metadata.period).toBe('2026-09');
expect(body.metadata.execution).toEqual(execution);
});
});
});
28 changes: 25 additions & 3 deletions server/__tests__/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,17 +36,28 @@ function createMockStorage() {
function buildApp() {
const app = new Hono<HonoEnv>();
const storage = createMockStorage();
let observedExecution: any;

// Inject mock storage + authorized caller scope into context
app.use('*', async (c, next) => {
c.set('storage', storage as any);
c.set('tenantId', 'test-tenant');
c.set('userId', 'user-1');
c.set('authMethod', 'chittyauth');
c.set('executionContext', {
actor: { userId: 'user-1', authMethod: 'chittyauth' },
source: { service: 'test', claimed: false },
scope: { tenantId: 'test-tenant' },
capability: 'finance.http.post:/mcp',
intent: 'execute',
trace: { requestId: 'test-request' },
});
await next();
observedExecution = c.get('executionContext');
});

app.route('/', mcpRoutes);
return { app, storage };
return { app, storage, getExecution: () => observedExecution };
}

function rpc(app: Hono<HonoEnv>, method: string, params?: Record<string, any>, id: number | string = 1) {
Expand All @@ -60,9 +71,10 @@ function rpc(app: Hono<HonoEnv>, method: string, params?: Record<string, any>, i
describe('MCP endpoint', () => {
let app: Hono<HonoEnv>;
let storage: ReturnType<typeof createMockStorage>;
let getExecution: () => any;

beforeEach(() => {
({ app, storage } = buildApp());
({ app, storage, getExecution } = buildApp());
});

// ── Protocol ──
Expand All @@ -76,6 +88,8 @@ describe('MCP endpoint', () => {
expect(body.result.serverInfo.name).toBe('chittyfinance');
expect(body.result.capabilities.resources).toBeDefined();
expect(body.result.capabilities.tools).toBeDefined();
expect(getExecution().capability).toBe('finance.mcp.initialize');
expect(getExecution().intent).toBe('read');
});

it('rejects bad JSON', async () => {
Expand Down Expand Up @@ -128,6 +142,8 @@ describe('MCP endpoint', () => {
expect(data.totalProperties).toBe(2);
expect(data.totalValue).toBe(600000);
expect(data.totalNOI).toBe(30000); // 15000 * 2
expect(getExecution().capability).toBe('finance.mcp.resources.read:portfolio-summary');
expect(getExecution().intent).toBe('read');
});

it('reads finance://properties', async () => {
Expand Down Expand Up @@ -197,6 +213,8 @@ describe('MCP endpoint', () => {
expect(body.result.content).toHaveLength(1);
expect(body.result.content[0].text).toContain('City Studio');
expect(body.result.content[0].text).toContain('Rule-based advice');
expect(getExecution().capability).toBe('finance.mcp.tool:get-property-advice');
expect(getExecution().intent).toBe('suggest');
});

it('calls refresh-valuation', async () => {
Expand All @@ -207,6 +225,8 @@ describe('MCP endpoint', () => {
const body = await res.json() as any;
expect(body.result.content[0].text).toContain('Valuation refresh queued');
expect(body.result.content[0].text).toContain('City Studio');
expect(getExecution().capability).toBe('finance.mcp.tool:refresh-valuation');
expect(getExecution().intent).toBe('execute');
});

it('returns not-found for missing property in tool call', async () => {
Expand All @@ -219,14 +239,16 @@ describe('MCP endpoint', () => {
expect(body.result.content[0].text).toContain('not found');
});

it('returns error for unknown tool', async () => {
it('preserves the existing unknown-tool error path', async () => {
const res = await rpc(app, 'tools/call', {
name: 'nonexistent-tool',
arguments: {},
});
expect(res.status).toBe(500);
const body = await res.json() as any;
expect(body.error.code).toBe(-32000);
expect(getExecution().capability).toBe('finance.mcp.tools.call');
expect(getExecution().intent).toBe('execute');
});

it('returns error for missing tool name', async () => {
Expand Down
5 changes: 3 additions & 2 deletions server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { hybridAuth, serviceAuth } from './middleware/auth';
import { sessionRoutes } from './routes/session';
import { callerContext } from './middleware/caller';
import { tenantMiddleware } from './middleware/tenant';
import { executionContextMiddleware } from './middleware/execution-context';
import { healthRoutes } from './routes/health';
import { docRoutes } from './routes/docs';
import { accountRoutes } from './accounting/accounts';
Expand Down Expand Up @@ -73,7 +74,7 @@ export function createApp(deps: AppDeps = {}) {

// storageMiddleware runs first so hybridAuth can resolve JWT → chittyId → userId
const authAndContext: MiddlewareHandler<HonoEnv>[] = [storageMiddleware, hybridAuth, callerContext];
const protectedRoute: MiddlewareHandler<HonoEnv>[] = [...authAndContext, tenantMiddleware];
const protectedRoute: MiddlewareHandler<HonoEnv>[] = [...authAndContext, tenantMiddleware, executionContextMiddleware];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Mount execution context on allocation routes

The new middleware only runs through protectedRoute, but protectedPrefixes does not include /api/allocations. Consequently, real requests to /api/allocations/preview and /api/allocations/execute never receive an executionContext, despite the new test asserting those intents using an isolated app that mounts the middleware globally; in particular, the allocation.executed ledger entry remains unenriched. Add the allocation prefix to the production middleware registration rather than relying on the test-only setup.

Useful? React with 👍 / 👎.


const app = new Hono<HonoEnv>();

Expand All @@ -84,7 +85,7 @@ export function createApp(deps: AppDeps = {}) {
app.use('*', cors({
origin: ['https://app.command.chitty.cc', 'https://command.chitty.cc', 'https://finance.chitty.cc', 'http://localhost:5000', 'http://localhost:3000'],
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization', 'X-Tenant-ID', 'X-Source-Service', 'X-Account-ID', 'Stripe-Signature'],
allowHeaders: ['Content-Type', 'Authorization', 'X-Tenant-ID', 'X-Source-Service', 'X-Account-ID', 'Stripe-Signature', 'traceparent', 'baggage'],
credentials: true,
}));

Expand Down
2 changes: 2 additions & 0 deletions server/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,13 @@ export interface Env {

import type { SystemStorage } from './storage/system';
import type { Database } from './db/connection';
import type { FinanceExecutionContext } from './middleware/execution-context';

export interface Variables {
tenantId: string;
userId: string;
authMethod: 'service' | 'chittyauth' | 'session';
executionContext: FinanceExecutionContext;
storage: SystemStorage;
/**
* The same drizzle handle SystemStorage was built on. Routes go through storage
Expand Down
Loading
Loading