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
20 changes: 10 additions & 10 deletions harness/src/approval-gate/settings/add-always-allow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { ApprovalSettings } from '../schemas.js';
import type { ISdk } from '../../runtime/iii.js';
import { type MutationReply, functionIdField, sessionIdField } from './types.js';
import { mutationError, ok } from './reply.js';
import { readSettings, writeSettings } from './store.js';
import { readSettings, updateSettings } from './store.js';

const PayloadSchema = z.object({
session_id: sessionIdField,
Expand All @@ -26,15 +26,15 @@ export async function addAlwaysAllow(
if (current.always_allow.some((entry) => entry.function_id === function_id)) {
return current;
}
const next: ApprovalSettings = {
...current,
always_allow: [
...current.always_allow,
{ function_id, granted_at: Date.now(), granted_by: 'user_click' },
],
};
await writeSettings(iii, session_id, next);
return next;
// Known race: the pre-read only narrows the duplicate-entry window; concurrent
// adds of the same id can both append. Harmless — matched/removed set-wise.
return updateSettings(iii, session_id, [
{
type: 'append',
path: 'always_allow',
value: { function_id, granted_at: Date.now(), granted_by: 'user_click' },
},
]);
}

export function registerAddAlwaysAllow(iii: ISdk): void {
Expand Down
19 changes: 9 additions & 10 deletions harness/src/approval-gate/settings/approve-always.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { ApprovalSettings } from '../schemas.js';
import type { ISdk } from '../../runtime/iii.js';
import { type MutationReply, functionIdField, sessionIdField } from './types.js';
import { mutationError, ok } from './reply.js';
import { readSettings, writeSettings } from './store.js';
import { readSettings, updateSettings } from './store.js';

const PayloadSchema = z.object({
session_id: sessionIdField,
Expand All @@ -28,15 +28,14 @@ export async function approveAlways(
if (current.approved_always.some((entry) => entry.function_id === function_id)) {
return current;
}
const next: ApprovalSettings = {
...current,
approved_always: [
...current.approved_always,
{ function_id, granted_at: Date.now(), granted_by: 'user_click' },
],
};
await writeSettings(iii, session_id, next);
return next;
// Known race: see addAlwaysAllow — pre-read only narrows the duplicate window.
return updateSettings(iii, session_id, [
{
type: 'append',
path: 'approved_always',
value: { function_id, granted_at: Date.now(), granted_by: 'user_click' },
},
]);
}

export function registerApproveAlways(iii: ISdk): void {
Expand Down
14 changes: 7 additions & 7 deletions harness/src/approval-gate/settings/remove-always-allow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { ApprovalSettings } from '../schemas.js';
import type { ISdk } from '../../runtime/iii.js';
import { type MutationReply, functionIdField, sessionIdField } from './types.js';
import { mutationError, ok } from './reply.js';
import { readSettings, writeSettings } from './store.js';
import { readSettings, updateSettings } from './store.js';

const PayloadSchema = z.object({
session_id: sessionIdField,
Expand All @@ -23,12 +23,12 @@ export async function removeAlwaysAllow(
function_id: string,
): Promise<ApprovalSettings> {
const current = await readSettings(iii, session_id);
const next: ApprovalSettings = {
...current,
always_allow: current.always_allow.filter((entry) => entry.function_id !== function_id),
};
await writeSettings(iii, session_id, next);
return next;
const always_allow = current.always_allow.filter((entry) => entry.function_id !== function_id);
// No array-element-remove op, so set just the always_allow field (not the whole
// record). Known race: concurrent add/remove on this field is last-writer-wins.
return updateSettings(iii, session_id, [
{ type: 'set', path: 'always_allow', value: always_allow },
]);
}

export function registerRemoveAlwaysAllow(iii: ISdk): void {
Expand Down
11 changes: 6 additions & 5 deletions harness/src/approval-gate/settings/set-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { zodToJsonSchema } from 'zod-to-json-schema';
import { PermissionModeSchema, type ApprovalSettings, type PermissionMode } from '../schemas.js';
import type { ISdk } from '../../runtime/iii.js';
import { mutationError, ok } from './reply.js';
import { readSettings, writeSettings } from './store.js';
import { updateSettings } from './store.js';
import { type MutationReply, sessionIdField } from './types.js';

const PayloadSchema = z.object({
Expand All @@ -22,10 +22,11 @@ export async function setMode(
session_id: string,
mode: PermissionMode,
): Promise<ApprovalSettings> {
const current = await readSettings(iii, session_id);
const next: ApprovalSettings = { ...current, mode, mode_set_at: Date.now() };
await writeSettings(iii, session_id, next);
return next;
// Field-scoped, no read: disjoint from add_always_allow/approve_always so both survive.
return updateSettings(iii, session_id, [
{ type: 'set', path: 'mode', value: mode },
{ type: 'set', path: 'mode_set_at', value: Date.now() },
]);
}

export function registerSetMode(iii: ISdk): void {
Expand Down
76 changes: 45 additions & 31 deletions harness/src/approval-gate/settings/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,50 +5,64 @@ import {
type ApprovalSettings,
} from '../schemas.js';
import type { ISdk } from '../../runtime/iii.js';
import { logger } from '../../runtime/otel.js';
import { createState, type UpdateOp } from '../../runtime/state.js';
import { getDefaultMode } from './default-mode.js';

/**
* Default settings for a session that has none stored yet. The `mode`
* comes from the harness `permissions.default_mode` (manual | auto | full)
* rather than the hardcoded constant, so the operator-configured default
* applies to new sessions.
*/
// Reads degrade to defaults on failure (tolerant); writes rethrow so handlers
// can reply with mutationError (strict).
const tolerantState = (iii: ISdk) => createState(iii, { tolerant: true });
const strictState = (iii: ISdk) => createState(iii, { tolerant: false });

/** Default settings; `mode` follows the operator-configured permissions.default_mode. */
function defaultSettings(): ApprovalSettings {
return { ...DEFAULT_APPROVAL_SETTINGS, mode: getDefaultMode() };
}

/**
* Backfill missing fields from defaults, then validate. Field-scoped writes can
* persist a partial record (the first add_always_allow stores only
* `{ always_allow }`); merging over defaults keeps it valid on read.
*/
export function parseSettings(raw: unknown): ApprovalSettings {
const base = defaultSettings();
const merged =
raw && typeof raw === 'object' && !Array.isArray(raw)
? { ...base, ...(raw as Record<string, unknown>) }
: base;
const parsed = ApprovalSettingsSchema.safeParse(merged);
return parsed.success ? parsed.data : base;
}

export async function readSettings(iii: ISdk, session_id: string): Promise<ApprovalSettings> {
try {
const raw = await iii.trigger<unknown, unknown>({
function_id: 'state::get',
payload: { scope: SETTINGS_STATE_SCOPE, key: session_id },
});
const parsed = ApprovalSettingsSchema.safeParse(raw);
return parsed.success ? parsed.data : defaultSettings();
} catch (err) {
logger.warn('approval-settings read failed; using defaults', {
session_id,
err: String(err),
});
return defaultSettings();
}
const raw = await tolerantState(iii).get<unknown>({
scope: SETTINGS_STATE_SCOPE,
key: session_id,
});
return parseSettings(raw);
}

export async function writeSettings(
/**
* Apply field-scoped ops atomically. Each op targets one field, so concurrent
* mutations of different fields compose under the engine's per-key write-lock
* instead of clobbering the whole record (the old read/write-whole lost update).
*/
export async function updateSettings(
iii: ISdk,
session_id: string,
settings: ApprovalSettings,
): Promise<void> {
await iii.trigger({
function_id: 'state::set',
payload: { scope: SETTINGS_STATE_SCOPE, key: session_id, value: settings },
ops: UpdateOp[],
): Promise<ApprovalSettings> {
const result = await strictState(iii).update<unknown>({
scope: SETTINGS_STATE_SCOPE,
key: session_id,
ops,
});
if (result?.errors && result.errors.length > 0) {
throw new Error(`approval-settings update rejected: ${JSON.stringify(result.errors)}`);
}
return parseSettings(result?.new_value ?? null);
}

export async function clearSettings(iii: ISdk, session_id: string): Promise<void> {
await iii.trigger({
function_id: 'state::set',
payload: { scope: SETTINGS_STATE_SCOPE, key: session_id, value: null },
});
// Delete the key rather than write a null tombstone; reads default either way.
await strictState(iii).delete({ scope: SETTINGS_STATE_SCOPE, key: session_id });
}
Loading
Loading