Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
f602afb
feat(integration): carry verified local journal .github/workflows/rel…
groupthinking Nov 6, 2026
e8c80d6
feat(integration): carry verified local journal apps/web/src/lib/acti…
groupthinking Nov 6, 2026
51c9406
feat(integration): carry verified local journal apps/web/src/lib/acti…
groupthinking Nov 6, 2026
89130b4
feat(integration): carry verified local journal apps/web/src/lib/rela…
groupthinking Nov 6, 2026
bf0af73
feat(integration): carry verified local journal docs/relay-durable-re…
groupthinking Nov 6, 2026
0ac011a
feat(integration): carry verified local journal scripts/relay_harness…
groupthinking Nov 6, 2026
90fc3cf
feat(integration): carry verified local journal scripts/relay_harness…
groupthinking Nov 6, 2026
d7c4240
feat(integration): carry verified local journal scripts/relay_harness…
groupthinking Nov 6, 2026
84f8190
feat(integration): carry verified local journal scripts/relay_harness…
groupthinking Nov 6, 2026
b73ff6c
feat(integration): carry verified local journal tests/relay-local-rev…
groupthinking Nov 6, 2026
b3d770d
test(integration): carry Python journal integrity regressions
groupthinking Nov 6, 2026
4f86829
feat(integration): enforce local journal policy alongside signed task…
groupthinking Nov 6, 2026
270cd1f
test(integration): guard review-only execution and exact preview pers…
groupthinking Nov 6, 2026
dfca088
test(integration): isolate review journal mode in web suite
groupthinking Nov 6, 2026
9386ffc
test(integration): exercise combined route through real local journal
groupthinking Nov 6, 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
32 changes: 32 additions & 0 deletions .github/workflows/relay-review-journal.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Durable local review journal

on:
pull_request:
paths:
- 'scripts/relay_harness/**'
- 'apps/web/src/lib/*review*.mjs'
- 'apps/web/src/lib/*review*.d.mts'
- 'apps/web/src/app/api/agents/actions/route.ts'
- 'tests/*relay*'
- '.github/workflows/relay-review-journal.yml'
workflow_dispatch:

permissions:
contents: read

jobs:
local-recovery-and-route:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
- name: SQLite, evidence, denial and process recovery regressions
run: python3 -m unittest discover -s tests -p test_relay_local_harness.py -v
- name: Actual local bridge and review route integration
run: node --test tests/relay-local-review.test.mjs tests/relay-review-route.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { NextRequest } from 'next/server';

const state = vi.hoisted(() => ({
journal: null as null | { persist: ReturnType<typeof vi.fn> },
model: vi.fn(),
execute: vi.fn(),
identity: 'trusted@example.invalid' as string | null,
}));
vi.mock('@/lib/action-review-journal.mjs', () => ({
resolveActionReviewJournal: () => state.journal,
}));
vi.mock('@/lib/action-agent', () => ({
AVAILABLE_TOOL_NAMES: ['create_workflow_task'],
runActionAgent: state.model,
executePreparedActions: state.execute,
}));
vi.mock('@/lib/billing/billing-context', () => ({
resolveTrustedBillingEmail: async () => state.identity,
}));
vi.mock('@/lib/billing/entitlement-store', () => ({
isProSubscriber: async () => false,
}));
vi.mock('@/lib/workflow-task-store', () => ({
workflowTaskOwner: async () => null,
readWorkflowTask: vi.fn(),
}));
vi.mock('@/lib/gemini-client', () => ({ hasGeminiKey: () => false }));

import { POST } from '@/app/api/agents/actions/route';

const action = {
tool: 'create_workflow_task',
input: { title: 'Review target' },
status: 'pending' as const,
result: 'Prepared for review. No tool has been executed.',
isError: false,
};
function request(body: unknown) {
return new NextRequest('http://localhost/api/agents/actions', {
method: 'POST',
headers: { 'content-type': 'application/json', origin: 'http://localhost' },
body: JSON.stringify(body),
});
}
describe('combined local journal and durable-task route boundary', () => {
beforeEach(() => {
state.model.mockReset();
state.execute.mockReset();
state.identity = 'trusted@example.invalid';
state.journal = { persist: vi.fn().mockResolvedValue({
version: 'relay.local-receipt.v1', taskId: 'local-task',
scope: 'local_artifact_only', state: 'complete',
artifactVerified: true, originalActionsExecuted: false,
originalTaskComplete: false,
}) };
state.model.mockResolvedValue({ provider: 'test', actions: [action] });
});
afterEach(() => { state.journal = null; });

it('persists exact preview fields and never issues an executable token in review-only mode', async () => {
const response = await POST(request({ mode: 'preview', transcript: 'A sufficiently long source transcript' }));
const body = await response.json();
expect(response.status).toBe(200);
expect(body.actions).toEqual([action]);
expect(body.confirmationToken).toBeUndefined();
expect(body.localArtifactReceipt.originalActionsExecuted).toBe(false);
expect(state.journal?.persist).toHaveBeenCalledWith([action], 'trusted@example.invalid');
expect(state.model).toHaveBeenCalledWith(expect.objectContaining({ executeTools: false }));
expect(state.execute).not.toHaveBeenCalled();
});
it('denies all execute requests before invoking any action', async () => {
const response = await POST(request({ mode: 'execute', actions: [action], jobId: 'forged', confirmationToken: 'forged' }));
expect(response.status).toBe(409);
expect(state.model).not.toHaveBeenCalled();
expect(state.execute).not.toHaveBeenCalled();
expect(state.journal?.persist).not.toHaveBeenCalled();
});
it('requires trusted identity before preview and local persistence', async () => {
state.identity = null;
const response = await POST(request({ transcript: 'A sufficiently long source transcript' }));
expect(response.status).toBe(401);
expect(state.model).not.toHaveBeenCalled();
});
it('fails closed on journal persistence failure', async () => {
state.journal?.persist.mockRejectedValueOnce(new Error('disk unavailable'));
const response = await POST(request({ transcript: 'A sufficiently long source transcript' }));
const body = await response.json();
expect(response.status).toBe(502);
expect(body.success).toBe(false);
expect(body.actions).toEqual([]);
expect(state.execute).not.toHaveBeenCalled();
});
it('preserves existing preview path when local journal is disabled', async () => {
state.journal = null;
const response = await POST(request({ transcript: 'A sufficiently long source transcript' }));
const body = await response.json();
expect(response.status).toBe(200);
expect(body.actions).toEqual([action]);
expect(body.localArtifactReceipt).toBeUndefined();
expect(state.execute).not.toHaveBeenCalled();
});
});
27 changes: 26 additions & 1 deletion apps/web/src/app/api/agents/actions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { hasGeminiKey } from '@/lib/gemini-client';
import { dedupeAgentActions, type AgentAction } from '@/lib/action-lifecycle';
import { resolveTrustedBillingEmail } from '@/lib/billing/billing-context';
import { isProSubscriber } from '@/lib/billing/entitlement-store';
import { resolveActionReviewJournal } from '@/lib/action-review-journal.mjs';

const MAX_CONFIRMED_ACTIONS = 50;
const PRO_ACTION_TOOLS = new Set([
Expand Down Expand Up @@ -77,7 +78,19 @@ export async function POST(request: Request): Promise<NextResponse> {
if (!body || typeof body !== 'object' || Array.isArray(body)) {
return NextResponse.json({ success: false, error: 'Request body must be a JSON object', actions: [] }, { status: 400 });
}
// Local-only review is a host policy, never a client-selectable mode.
// Fail closed on invalid configuration before either preview or execution.
let reviewJournal: ReturnType<typeof resolveActionReviewJournal>;
try {
reviewJournal = resolveActionReviewJournal();
} catch {
return NextResponse.json({ success: false, error: 'Local review journal is unavailable', actions: [] }, { status: 503 });
}

const { mode, transcript, videoTitle, jobId, actions, confirmationToken } = body;
if (reviewJournal && mode === 'execute') {
return NextResponse.json({ success: false, error: 'This host is configured for durable review only; no actions may execute', actions: [] }, { status: 409 });
}
if (mode === 'execute') {
if (
!Array.isArray(actions) ||
Expand Down Expand Up @@ -180,6 +193,11 @@ export async function POST(request: Request): Promise<NextResponse> {
);
}

const journalIdentity = reviewJournal ? await resolveTrustedBillingEmail(request) : null;
if (reviewJournal && !journalIdentity) {
return NextResponse.json({ success: false, error: 'Sign in before persisting a local review plan', actions: [] }, { status: 401 });
}
Comment on lines +196 to +199

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

In local-review-only mode, a cookie-authenticated POST from another same-site origin can persist attacker-selected plans under the victim’s identity because preview writes lack an origin check.

Fix on Vercel


try {
const result = await runActionAgent({
transcript,
Expand All @@ -188,8 +206,14 @@ export async function POST(request: Request): Promise<NextResponse> {
executeTools: false,
});

// Persistence is mandatory when local-review-only is enabled. Never
// return an ephemeral success or an executable confirmation token.
const localArtifactReceipt = reviewJournal
? await reviewJournal.persist(result.actions, journalIdentity)
: undefined;

const hasTasks = result.actions.some(action => action.tool === 'create_workflow_task');
const previewOwner = hasTasks ? await workflowTaskOwner(request) : null;
const previewOwner = !reviewJournal && hasTasks ? await workflowTaskOwner(request) : null;
// Only server-generated plans can receive a receipt. Client run IDs remain
// correlation hints; confirmed task identity uses the returned server ID.
if (previewOwner && result.actions.some(action => !AVAILABLE_TOOL_NAMES.includes(action.tool))) {
Expand All @@ -202,6 +226,7 @@ export async function POST(request: Request): Promise<NextResponse> {
provider: result.provider,
actions: result.actions,
...(confirmation ?? {}),
...(localArtifactReceipt ? { localArtifactReceipt } : {}),
});
} catch (error) {
console.error('Action agent error:', error);
Expand Down
28 changes: 28 additions & 0 deletions apps/web/src/lib/action-review-journal.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { AgentAction } from './action-lifecycle';

export interface LocalArtifactReceipt {
version: 'relay.local-receipt.v1';
taskId: string;
scope: 'local_artifact_only';
state: 'complete';
artifactVerified: true;
artifactHash: string;
contractHash: string;
historyHead: string;
originalActionsExecuted: false;
originalTaskComplete: false;
cost: {
amount: null;
currency: null;
status: 'unknown';
note: string;
};
}

export interface ActionReviewJournal {
persist(actions: AgentAction[], trustedIdentity: string | null): Promise<LocalArtifactReceipt>;
}

export function resolveActionReviewJournal(
env?: NodeJS.ProcessEnv,
): ActionReviewJournal | null;
53 changes: 53 additions & 0 deletions apps/web/src/lib/action-review-journal.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/** Server-owned opt-in for persistent local review artifacts, never execution. */
import 'server-only';
import { createHash } from 'node:crypto';
import { createLocalReviewBridge } from './relay-local-review.mjs';

/**
* Only operator environment config may select the interpreter/state directory.
* A nonempty invalid mode fails closed rather than silently disabling a guard.
*/
export function resolveActionReviewJournal(env = process.env) {
const mode = env.RELAY_REVIEW_JOURNAL_MODE;
if (!mode) return null;
if (mode !== 'local-review-only') throw new Error('Invalid local review journal mode');
if (env.VERCEL || env.AWS_LAMBDA_FUNCTION_NAME || env.K_SERVICE) {
throw new Error('Local review journal requires a persistent non-serverless host');
}
const bridge = createLocalReviewBridge({
stateDirectory: env.RELAY_REVIEW_STATE_DIRECTORY,
scriptsDirectory: env.RELAY_REVIEW_SCRIPTS_DIRECTORY,
pythonExecutable: env.RELAY_REVIEW_PYTHON_EXECUTABLE,
});
return Object.freeze({
async persist(actions, trustedIdentity) {
if (typeof trustedIdentity !== 'string' || !trustedIdentity.trim()) {
throw new Error('Trusted identity required for durable review plans');
}
// Do not store identity/transcript or trust a caller-supplied jobId as a
// cross-user idempotency key. Exact plans are independently actor-scoped.
const actorHash = createHash('sha256').update(trustedIdentity).digest('hex');
const key = createHash('sha256').update(JSON.stringify({ actorHash, actions })).digest('hex');
// v1 artifacts omitted optional preview fields. Do not rewrite immutable
// evidence or reuse its receipt for the exact-plan serialization.
const result = await bridge.persistReview(actions, `review-v2-${key}`);
if (!result.localReceipt.complete || result.originalActionsExecuted !== false ||
result.originalTaskComplete !== false) {
throw new Error('The local review artifact did not verify');
}
return {
version: result.localReceipt.version,
taskId: result.taskId,
scope: 'local_artifact_only',
state: result.localReceipt.state,
artifactVerified: true,
artifactHash: result.localReceipt.evidence.sha256,
contractHash: result.localReceipt.contract_hash,
historyHead: result.localReceipt.history_head,
originalActionsExecuted: false,
originalTaskComplete: false,
cost: result.localReceipt.cost,
};
},
});
}
95 changes: 95 additions & 0 deletions apps/web/src/lib/relay-local-review.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* Additive local-host bridge for EventRelay's existing review-only AgentAction[].
* This is NOT production route wiring, an approval service, or an action executor.
* @typedef {import('./action-lifecycle.ts').AgentAction} AgentAction
*/
import { execFile } from 'node:child_process';
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, isAbsolute } from 'node:path';
import { promisify } from 'node:util';

const executeFile = promisify(execFile);

/**
* Configuration is owned by a trusted local host, NEVER model/request input.
* The host must preserve the private state directory between calls.
* @param {{stateDirectory:string, scriptsDirectory:string, pythonExecutable:string}} config
*/
export function createLocalReviewBridge(config) {
const { stateDirectory, scriptsDirectory, pythonExecutable } = config;
for (const value of [stateDirectory, scriptsDirectory, pythonExecutable]) {
if (typeof value !== 'string' || !isAbsolute(value)) {
throw new Error('Trusted host paths must be absolute');
}
}

async function invoke(args) {
let stdout;
try {
({ stdout } = await executeFile(pythonExecutable,
['-m', 'relay_harness', '--state', stateDirectory, ...args], {
shell: false,
env: { PYTHONPATH: scriptsDirectory, PYTHONUTF8: '1' },
timeout: 10_000,
maxBuffer: 2 * 1024 * 1024,
}));
} catch (error) {
// Exit 2 is an honest, structured non-completion receipt. Timeout/unknown
// process outcomes are NEVER retried here; caller must inspect receipt.
if (error.code === 2 && error.stdout) stdout = error.stdout;
else throw error;
}
return JSON.parse(stdout);
}

function checkedReceipt(receipt) {
if (receipt.version !== 'relay.local-receipt.v1' ||
receipt.scope !== 'local_artifact_only' ||
receipt.external_task_complete !== false ||
typeof receipt.complete !== 'boolean') {
throw new Error('Invalid local-only receipt envelope');
}
return receipt;
}

return Object.freeze({
/**
* The actions should come from runActionAgent({..., executeTools:false}).
* @param {AgentAction[]} actions
* @param {string} idempotencyKey
*/
async persistReview(actions, idempotencyKey) {
if (typeof idempotencyKey !== 'string' || !idempotencyKey.trim() || idempotencyKey.length > 200) {
throw new Error('Invalid idempotency key');
}
const json = JSON.stringify(actions);
if (typeof json !== 'string' || Buffer.byteLength(json) > 1024 * 1024) {
throw new Error('Review actions exceed input budget');
}
const directory = await mkdtemp(join(tmpdir(), 'relay-local-review-'));
try {
const input = join(directory, 'actions.json');
await writeFile(input, json, { mode: 0o600, flag: 'wx' });
const submitted = await invoke(['import-review', input, '--key', idempotencyKey]);
if (!/^[0-9a-f]{32}$/.test(submitted.task_id)) throw new Error('Invalid journal task id');
const localReceipt = checkedReceipt(await invoke(['run', submitted.task_id]));
return {
taskId: submitted.task_id,
localReceipt,
originalActionsExecuted: false,
originalTaskComplete: false,
};
} finally {
await rm(directory, { recursive: true, force: true });
}
},

async receipt(taskId) {
if (typeof taskId !== 'string' || !/^[0-9a-f]{32}$/.test(taskId)) {
throw new Error('Invalid journal task id');
}
return checkedReceipt(await invoke(['receipt', taskId]));
},
});
}
2 changes: 2 additions & 0 deletions apps/web/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export default defineConfig({
VERCEL_AI_GATEWAY_API_KEY: '',
VERCEL_AI_GATEWAY_API: '',
VERCEL_API_KEY: '',
// Prevent ambient local-only configuration from changing unrelated tests.
RELAY_REVIEW_JOURNAL_MODE: '',
},
},
resolve: {
Expand Down
Loading
Loading