From a9ff63275096215181999f57e3a20c54b6257c11 Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 13 Oct 2026 16:27:08 -0500 Subject: [PATCH] feat: write mode (Read-only by default), user preferences, auto-confirm rules Owner (2026-10-13): the assistant can go into write mode by user choice, and stays only a UI client of the /qqq/v1 API. - writeMode per conversation; the write tools exist only in Write mode, and a write tool called in Read-only mode is refused before any request. - Policy: writeModeAllowed, writeModePermission (role), autoConfirmAllowed; data access autoConfirmOk per table. - assistant_user_preference: default mode, auto-confirm of low-risk writes (single-record insert/update on auto-confirm-OK tables; never deletes); GET/PATCH /assistant/preferences as spec'd operations. - The denial matrix covers the write tools in both modes; WriteModeTest covers the tool list, policy, preferences and auto-confirm. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 + docs/schema-postgres.sql | 16 ++ .../assistant/access/DataAccessService.java | 12 + .../qbits/assistant/api/AssistantApi.java | 102 ++++++- .../assistant/api/specs/AssistantSpecs.java | 115 +++++++- .../qbits/assistant/engine/PolicyService.java | 50 +++- .../qbits/assistant/engine/TurnEngine.java | 36 ++- .../metadata/AssistantMetaDataBuilder.java | 5 + .../metadata/AssistantRecordScreens.java | 9 +- .../model/AssistantConversation.java | 33 +++ .../assistant/model/AssistantDataAccess.java | 33 +++ .../assistant/model/AssistantPolicy.java | 99 +++++++ .../model/AssistantUserPreference.java | 252 ++++++++++++++++++ .../assistant/persistence/AssistantStore.java | 11 + .../qbits/assistant/tools/UiApiToolbox.java | 15 +- .../resources/openapi/qqq-v1/assistant.yaml | 136 +++++++++- .../tools/UiApiToolsSecurityTest.java | 25 +- .../qbits/assistant/tools/WriteModeTest.java | 208 +++++++++++++++ .../openapi/qqq-v1/assistant-baseline.yaml | 136 +++++++++- 19 files changed, 1280 insertions(+), 19 deletions(-) create mode 100644 qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/model/AssistantUserPreference.java create mode 100644 qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/WriteModeTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index dbbb077..8f2ce7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ Phase 0/1 of the QQQ Assistant: read-only Q&A and navigation. Requires QQQ 4.1 w - the AI tool kernel (`qqq-backend-core` `actions.ai`, QRun-IO/qqq#1078) - the developer-panel contract (QRun-IO/qqq#1076). +### Added (0.1.0-RC.7): write mode (owner decision 2026-10-13) +- Per-conversation **Read-only** (default) or **Write** mode: `writeMode` on conversations (PATCH, create), the write tools exist only in Write mode, and a write tool called in Read-only mode is refused before any request (`mode:READ_ONLY`). +- Policy: `writeModeAllowed`, `writeModePermission` (role), `autoConfirmAllowed`. Data access: `autoConfirmOk` per table. +- `assistant_user_preference` (own rows): default mode of new conversations, auto-confirm of low-risk writes (single-record insert/update on auto-confirm-OK tables; never deletes). `GET`/`PATCH /qqq/v1/assistant/preferences`; `/config` reports `features.writeMode`, `features.autoConfirm` and `preferences`. +- The denial matrix covers the write tools in both modes. + ### Changed (0.1.0-RC.7, needs QQQ with QRun-IO/qqq#1128) - The assistant's endpoints are spec'd `/qqq/v1` operations (UI API extension `assistant` 1.0.0, prefix `/assistant`): same URLs, bodies and statuses, now in the served OpenAPI; turns and events are `text/event-stream`. The raw routes are gone. - Tools call core's `UiApiInvoker` with the caller's own request credentials (`UiApiCredentials`); the loopback stand-in is removed. The assistant's own operations are never invocable by its tools. diff --git a/docs/schema-postgres.sql b/docs/schema-postgres.sql index 8ed61b9..d176c05 100644 --- a/docs/schema-postgres.sql +++ b/docs/schema-postgres.sql @@ -327,3 +327,19 @@ CREATE TABLE assistant_profile_skill modify_date TIMESTAMP WITH TIME ZONE, CONSTRAINT assistant_profile_skill_pair UNIQUE (profile_id, skill_id) ); + +-- 0.1.0-RC.7: write mode (per conversation, user preference, policy) and auto-confirm. +ALTER TABLE assistant_conversation ADD COLUMN IF NOT EXISTS write_mode BOOLEAN; +ALTER TABLE assistant_policy ADD COLUMN IF NOT EXISTS write_mode_allowed BOOLEAN; +ALTER TABLE assistant_policy ADD COLUMN IF NOT EXISTS write_mode_permission VARCHAR(128); +ALTER TABLE assistant_policy ADD COLUMN IF NOT EXISTS auto_confirm_allowed BOOLEAN; +ALTER TABLE assistant_data_access ADD COLUMN IF NOT EXISTS auto_confirm_ok BOOLEAN; +CREATE TABLE assistant_user_preference +( + id SERIAL PRIMARY KEY, + user_id VARCHAR(255) NOT NULL UNIQUE, + default_write_mode BOOLEAN, + auto_confirm_low_risk BOOLEAN, + create_date TIMESTAMP WITH TIME ZONE, + modify_date TIMESTAMP WITH TIME ZONE +); diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/access/DataAccessService.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/access/DataAccessService.java index 5c1e2aa..e2800c2 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/access/DataAccessService.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/access/DataAccessService.java @@ -132,6 +132,18 @@ public class DataAccessService + /******************************************************************************* + ** Whether an admin marked the table's single-record inserts and updates as + ** low-risk (auto-confirmable, when the policy and the user allow it). + *******************************************************************************/ + public boolean autoConfirmOk(String tableName) + { + AssistantDataAccess row = rows().get(key(AssistantDataAccess.TYPE_TABLE, tableName)); + return (row != null && Boolean.TRUE.equals(row.getAutoConfirmOk()) && forTable(tableName).mode().allowsWrite()); + } + + + /******************************************************************************* ** Drop the cache (after a change to the table). *******************************************************************************/ diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/AssistantApi.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/AssistantApi.java index d240d43..5e516a2 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/AssistantApi.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/AssistantApi.java @@ -44,6 +44,7 @@ import com.kingsrook.qbits.assistant.model.AssistantPendingAction; import com.kingsrook.qbits.assistant.model.AssistantToolCall; import com.kingsrook.qbits.assistant.model.AssistantTurn; import com.kingsrook.qbits.assistant.model.AssistantTurnEvent; +import com.kingsrook.qbits.assistant.model.AssistantUserPreference; import com.kingsrook.qbits.assistant.persistence.AssistantStore; import com.kingsrook.qbits.assistant.tools.UiApiToolbox; import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome; @@ -139,7 +140,10 @@ public class AssistantApi result.put("shortcut", config.getShortcut()); result.put("model", reason == null ? activeProvider().model() : config.getModels().getMainModel()); result.put("limits", Map.of("maxTurnSeconds", config.getMaxTurnSeconds(), "maxToolCallsPerTurn", config.getMaxToolCallsPerTurn())); - result.put("features", Map.of("writes", runtime.getToolbox().anyWritableTable(), "pendingActions", true)); + boolean writeMode = reason == null && writeModeAvailable(); + result.put("features", Map.of("writes", runtime.getToolbox().anyWritableTable(), "pendingActions", true, "writeMode", writeMode, + "autoConfirm", writeMode && runtime.getPolicyService().current(false).tools().autoConfirmAllowed())); + result.put("preferences", reason == null ? preferences() : Map.of()); ProfileService.Persona persona = runtime.getProfileService().persona(session, null); Map profile = new LinkedHashMap<>(); profile.put("displayName", persona.displayName()); @@ -179,6 +183,12 @@ public class AssistantApi String title = body.get("title") instanceof String s && StringUtils.hasContent(s) ? truncate(s.trim(), 255) : null; Map context = body.get("context") instanceof Map m ? ContextBuilder.sanitizePageContext((Map) m) : null; AssistantConversation conversation = store.createConversation(userId, title, context == null || context.isEmpty() ? null : JsonUtils.toJson(context)); + boolean write = body.get("writeMode") instanceof Boolean requested ? requested : defaultWriteMode(); + if(write && writeModeAvailable()) + { + store.updateFields(AssistantConversation.TABLE_NAME, new QRecord().withValue("id", conversation.getId()).withValue("writeMode", true)); + conversation.setWriteMode(true); + } return (toJson(conversation)); } @@ -254,6 +264,19 @@ public class AssistantApi conversation.setStatus((String) status); } + if(body.containsKey("writeMode")) + { + if(!(body.get("writeMode") instanceof Boolean writeMode)) + { + throw (new AssistantApiException(400, "writeMode must be true or false.")); + } + if(writeMode && !writeModeAvailable()) + { + throw (new AssistantApiException(403, "WRITE_MODE_UNAVAILABLE", "Write mode is not available to you. An administrator controls it in the assistant policy.")); + } + update.setValue("writeMode", writeMode); + conversation.setWriteMode(writeMode); + } store.updateFields(AssistantConversation.TABLE_NAME, update); return (toJson(conversation)); } @@ -758,6 +781,82 @@ public class AssistantApi + /******************************************************************************* + ** Whether this user may use Write mode at all: the policy allows it, they + ** hold its permission (when one is set), and some table is READ_WRITE. + *******************************************************************************/ + public boolean writeModeAvailable() throws QException + { + return (runtime.getPolicyService().current(false).tools().writeModeAvailableTo(session) && runtime.getToolbox().anyWritableTable()); + } + + + + /******************************************************************************* + ** The default mode of the user's new conversations (Read-only unless they + ** chose Write). + *******************************************************************************/ + private boolean defaultWriteMode() throws QException + { + AssistantUserPreference preference = store.getPreference(userId); + return (preference != null && Boolean.TRUE.equals(preference.getDefaultWriteMode())); + } + + + + /******************************************************************************* + ** GET /preferences: the user's own preferences. + *******************************************************************************/ + public Map preferences() throws QException + { + AssistantUserPreference preference = userId == null ? null : store.getPreference(userId); + Map map = new LinkedHashMap<>(); + map.put("defaultWriteMode", preference != null && Boolean.TRUE.equals(preference.getDefaultWriteMode())); + map.put("autoConfirmLowRisk", preference != null && Boolean.TRUE.equals(preference.getAutoConfirmLowRisk())); + return (map); + } + + + + /******************************************************************************* + ** PATCH /preferences: change the user's own preferences. Auto-confirm can + ** be turned on only when the policy allows it. + *******************************************************************************/ + public Map patchPreferences(Map body) throws QException, AssistantApiException + { + requireAccess(); + AssistantUserPreference preference = store.getPreference(userId); + if(preference == null) + { + preference = store.insert(new AssistantUserPreference().withUserId(userId).withDefaultWriteMode(false).withAutoConfirmLowRisk(false)); + } + QRecord update = new QRecord().withValue("id", preference.getId()); + if(body.containsKey("defaultWriteMode")) + { + if(!(body.get("defaultWriteMode") instanceof Boolean value)) + { + throw (new AssistantApiException(400, "defaultWriteMode must be true or false.")); + } + update.setValue("defaultWriteMode", value); + } + if(body.containsKey("autoConfirmLowRisk")) + { + if(!(body.get("autoConfirmLowRisk") instanceof Boolean value)) + { + throw (new AssistantApiException(400, "autoConfirmLowRisk must be true or false.")); + } + if(value && !runtime.getPolicyService().current(false).tools().autoConfirmAllowed()) + { + throw (new AssistantApiException(403, "AUTO_CONFIRM_UNAVAILABLE", "Auto-confirm is not allowed by the assistant policy.")); + } + update.setValue("autoConfirmLowRisk", value); + } + store.updateFields(AssistantUserPreference.TABLE_NAME, update); + return (preferences()); + } + + + /******************************************************************************* ** The configured turn time limit (replay loops are capped by it). *******************************************************************************/ @@ -856,6 +955,7 @@ public class AssistantApi map.put("title", conversation.getTitle()); map.put("status", conversation.getStatus()); map.put("pinned", Boolean.TRUE.equals(conversation.getPinned())); + map.put("writeMode", Boolean.TRUE.equals(conversation.getWriteMode())); map.put("lastMessageAt", conversation.getLastMessageAt() == null ? null : conversation.getLastMessageAt().toString()); map.put("unreadCount", conversation.getUnreadCount() == null ? 0 : conversation.getUnreadCount()); map.put("createDate", conversation.getCreateDate() == null ? null : conversation.getCreateDate().toString()); diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/specs/AssistantSpecs.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/specs/AssistantSpecs.java index 518e982..dd0bcc4 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/specs/AssistantSpecs.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/specs/AssistantSpecs.java @@ -98,7 +98,9 @@ public final class AssistantSpecs .withSpec(GetActionSpec.class) .withSpec(ConfirmActionSpec.class) .withSpec(RejectActionSpec.class) - .withSpec(AccessSpec.class)); + .withSpec(AccessSpec.class) + .withSpec(GetPreferencesSpec.class) + .withSpec(UpdatePreferencesSpec.class)); } @@ -201,7 +203,7 @@ public final class AssistantSpecs "features", freeObject("writes: some table allows writes; pendingActions: writes wait for confirmation."), "profile", ref("AssistantProfileV1"))); rs.put("AssistantConversationV1", object("One of the user's own conversations.", - "id", integer(null), "title", string(null).withNullable(true), "status", string("ACTIVE or ARCHIVED."), "pinned", bool(null), + "id", integer(null), "title", string(null).withNullable(true), "status", string("ACTIVE or ARCHIVED."), "pinned", bool(null), "writeMode", bool("Write mode (default Read-only): the write tools exist only in Write mode."), "lastMessageAt", string("ISO-8601 instant.").withNullable(true), "unreadCount", integer(null), "createDate", string("ISO-8601 instant.").withNullable(true))); rs.put("AssistantConversationListResponseV1", object("The user's conversations, newest first.", "conversations", array(ref("AssistantConversationV1"), null))); rs.put("AssistantMessageV1", object("A message: the user's text or the assistant's reply as parts (text, tool, record_refs, navigate, notice).", @@ -221,6 +223,9 @@ public final class AssistantSpecs rs.put("AssistantTurnEventV1", object("One server-sent event of a turn (event: , id: , data: this object). Types: message.start, text.delta, tool.start, tool.result, ui.navigate, notice, usage, message.end, error, ping.", "seq", integer("Sequence number; resume with ?after=."), "type", string("The event type (also the SSE event name)."), "data", freeObject("The event's fields, by type."))); + rs.put("AssistantPreferencesV1", object("The user's own assistant preferences.", + "defaultWriteMode", bool("New conversations start in Write mode (when write mode is available)."), + "autoConfirmLowRisk", bool("Auto-confirm low-risk writes (only when the policy allows it; never deletes, bulk writes or processes)."))); rs.put("AssistantErrorV1", object("An error.", "error", string("A message for the user."), "code", string("BUDGET, RATE_LIMIT, NOT_CONFIGURED, KILL_SWITCH, DISABLED, NO_PERMISSION...").withNullable(true))); return (rs); } @@ -594,7 +599,8 @@ public final class AssistantSpecs @Override protected Schema defineRequestBodySchema() { - return (object("Optional title and the page context it starts from.", "title", string(null), "context", freeObject("route, tableName, recordId, filter, selectedIds."))); + return (object("Optional title, the page context it starts from, and the mode (default: the user's preference).", "title", string(null), + "context", freeObject("route, tableName, recordId, filter, selectedIds."), "writeMode", bool(null))); } @@ -719,7 +725,8 @@ public final class AssistantSpecs @Override protected Schema defineRequestBodySchema() { - return (object("The changed properties.", "title", string(null), "pinned", bool(null), "status", string("ACTIVE or ARCHIVED."))); + return (object("The changed properties.", "title", string(null), "pinned", bool(null), "status", string("ACTIVE or ARCHIVED."), + "writeMode", bool("Switch to Write mode (true) or Read-only (false); 403 when write mode is not available to the user."))); } @@ -1333,4 +1340,104 @@ public final class AssistantSpecs } } + + + /******************************************************************************* + ** GET /assistant/preferences + *******************************************************************************/ + public static class GetPreferencesSpec extends AssistantJsonSpec + { + @Override + protected HttpMethod method() + { + return (HttpMethod.GET); + } + + + + @Override + protected String path() + { + return (PREFIX + "/preferences"); + } + + + + @Override + protected String summary() + { + return ("The user's own assistant preferences"); + } + + + + @Override + protected String getResponseSchemaName() + { + return ("AssistantPreferencesV1"); + } + + + + @Override + protected Object handle(AssistantApi api, JsonInput input) throws Exception + { + return (api.preferences()); + } + } + + + + /******************************************************************************* + ** PATCH /assistant/preferences + *******************************************************************************/ + public static class UpdatePreferencesSpec extends AssistantJsonSpec + { + @Override + protected HttpMethod method() + { + return (HttpMethod.PATCH); + } + + + + @Override + protected String path() + { + return (PREFIX + "/preferences"); + } + + + + @Override + protected String summary() + { + return ("Change the user's own assistant preferences"); + } + + + + @Override + protected Schema defineRequestBodySchema() + { + return (object("The changed preferences.", "defaultWriteMode", bool(null), "autoConfirmLowRisk", bool(null))); + } + + + + @Override + protected String getResponseSchemaName() + { + return ("AssistantPreferencesV1"); + } + + + + @Override + protected Object handle(AssistantApi api, JsonInput input) throws Exception + { + return (api.patchPreferences(input.getBody())); + } + } + } diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/PolicyService.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/PolicyService.java index 75c8a60..942a35c 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/PolicyService.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/PolicyService.java @@ -30,6 +30,7 @@ import com.kingsrook.qbits.assistant.model.AssistantUsageDaily; import com.kingsrook.qbits.assistant.persistence.AssistantStore; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.session.QSession; import com.kingsrook.qqq.backend.core.utils.StringUtils; @@ -78,8 +79,50 @@ public class PolicyService ** @param redactPii redact secrets and personal identifiers from tool ** results (default true) *******************************************************************************/ - public record ToolPolicy(boolean writesRequireConfirmation, Set allowedTools, int maxRowsPerCall, boolean redactPii) + public record ToolPolicy(boolean writesRequireConfirmation, Set allowedTools, int maxRowsPerCall, boolean redactPii, + boolean writeModeAllowed, String writeModePermission, boolean autoConfirmAllowed, boolean writeMode, boolean autoConfirm) { + /******************************************************************************* + ** Write mode on, no auto-confirm, write mode allowed for everyone (tests + ** and hosts that build a policy by hand). + *******************************************************************************/ + public ToolPolicy(boolean writesRequireConfirmation, Set allowedTools, int maxRowsPerCall, boolean redactPii) + { + this(writesRequireConfirmation, allowedTools, maxRowsPerCall, redactPii, true, null, false, true, false); + } + + + + /******************************************************************************* + ** This policy for a conversation in Write (true) or Read-only (false) mode. + *******************************************************************************/ + public ToolPolicy withWriteMode(boolean writeMode) + { + return (new ToolPolicy(writesRequireConfirmation, allowedTools, maxRowsPerCall, redactPii, writeModeAllowed, writeModePermission, autoConfirmAllowed, writeMode, autoConfirm)); + } + + + + /******************************************************************************* + ** This policy with the user's auto-confirm (only effective when the policy + ** allows it). + *******************************************************************************/ + public ToolPolicy withAutoConfirm(boolean autoConfirm) + { + return (new ToolPolicy(writesRequireConfirmation, allowedTools, maxRowsPerCall, redactPii, writeModeAllowed, writeModePermission, autoConfirmAllowed, writeMode, + autoConfirm && autoConfirmAllowed)); + } + + + + /******************************************************************************* + ** Whether this user may switch a conversation to Write mode (the policy + ** allows it, and they hold the write-mode permission when one is set). + *******************************************************************************/ + public boolean writeModeAvailableTo(QSession session) + { + return (writeModeAllowed && (!StringUtils.hasContent(writeModePermission) || (session != null && session.hasPermission(writeModePermission.strip())))); + } public static final int DEFAULT_MAX_ROWS_PER_CALL = 50; public static final int MAX_ROWS_PER_CALL_CEILING = 200; @@ -90,7 +133,7 @@ public class PolicyService *******************************************************************************/ public static ToolPolicy defaults() { - return (new ToolPolicy(true, Set.of(), DEFAULT_MAX_ROWS_PER_CALL, true)); + return (new ToolPolicy(true, Set.of(), DEFAULT_MAX_ROWS_PER_CALL, true, true, null, false, false, false)); } @@ -126,7 +169,8 @@ public class PolicyService } } int maxRows = row.getMaxRowsPerCall() == null || row.getMaxRowsPerCall() < 1 ? DEFAULT_MAX_ROWS_PER_CALL : Math.min(MAX_ROWS_PER_CALL_CEILING, row.getMaxRowsPerCall()); - return (new ToolPolicy(!Boolean.FALSE.equals(row.getWritesRequireConfirmation()), allowed, maxRows, !Boolean.FALSE.equals(row.getRedactPii()))); + return (new ToolPolicy(!Boolean.FALSE.equals(row.getWritesRequireConfirmation()), allowed, maxRows, !Boolean.FALSE.equals(row.getRedactPii()), + !Boolean.FALSE.equals(row.getWriteModeAllowed()), row.getWriteModePermission(), Boolean.TRUE.equals(row.getAutoConfirmAllowed()), false, false)); } } diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEngine.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEngine.java index 0967698..57451da 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEngine.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEngine.java @@ -39,6 +39,7 @@ import com.kingsrook.qbits.assistant.model.AssistantMessage; import com.kingsrook.qbits.assistant.model.AssistantPendingAction; import com.kingsrook.qbits.assistant.model.AssistantToolCall; import com.kingsrook.qbits.assistant.model.AssistantTurn; +import com.kingsrook.qbits.assistant.model.AssistantUserPreference; import com.kingsrook.qbits.assistant.persistence.AssistantStore; import com.kingsrook.qbits.assistant.provider.AssistantModelProvider; import com.kingsrook.qbits.assistant.provider.CacheHint; @@ -137,6 +138,7 @@ public class TurnEngine implements Runnable private volatile boolean finished = false; private UiApiCredentials credentials; + private PolicyService.ToolPolicy toolPolicy; @@ -230,7 +232,7 @@ public class TurnEngine implements Runnable String appName = runtime.appNameFor(pageContext); ProfileService.Persona persona = runtime.getProfileService().persona(session, appName); String profileText = ProfileService.instructions(persona, runtime.getProfileService().relevantSkills(persona.profileId(), userText, appName)); - ModelRequest request = runtime.getContextBuilder().build(session, model, policy.mainEffort(), history, userText, pageContext, policy.tools(), profileText); + ModelRequest request = runtime.getContextBuilder().build(session, model, policy.mainEffort(), history, userText, pageContext, toolPolicy(), profileText); Integer maxPerRequest = active.row() == null ? null : active.row().getMaxOutputTokensPerRequest(); if(maxPerRequest != null && (request.getMaxTokens() == null || request.getMaxTokens() > maxPerRequest)) { @@ -240,7 +242,7 @@ public class TurnEngine implements Runnable { request.setParallelToolCalls(false); } - Set allowedTools = runtime.getContextBuilder().toolNames(policy.tools()); + Set allowedTools = runtime.getContextBuilder().toolNames(toolPolicy()); boolean toolLimitReached = false; while(true) @@ -804,6 +806,34 @@ public class TurnEngine implements Runnable + /******************************************************************************* + ** The policy's tool settings for this turn: Write mode only when the + ** conversation is in Write mode and write mode is available to this user; + ** auto-confirm only when the policy allows it and the user chose it. + *******************************************************************************/ + PolicyService.ToolPolicy toolPolicy() + { + if(toolPolicy == null) + { + PolicyService.ToolPolicy base = policy.tools(); + boolean write = Boolean.TRUE.equals(conversation.getWriteMode()) && base.writeModeAvailableTo(session); + boolean auto = false; + try + { + AssistantUserPreference preference = store.getPreference(userId); + auto = preference != null && Boolean.TRUE.equals(preference.getAutoConfirmLowRisk()); + } + catch(QException | RuntimeException e) + { + LOG.debug("Could not read the user's assistant preferences", e); + } + toolPolicy = base.withWriteMode(write).withAutoConfirm(auto); + } + return (toolPolicy); + } + + + /******************************************************************************* ** Where a data tool runs: the user's own session (by its UUID, for the UI ** API), the gateway, the policy's tool settings, and the pending-action @@ -811,7 +841,7 @@ public class TurnEngine implements Runnable *******************************************************************************/ private UiApiToolbox.Context toolContext() { - return (new UiApiToolbox.Context(session.getUuid(), userTimezone(), runtime.getUiApiGateway(), policy.tools(), this::proposeWrite, credentials)); + return (new UiApiToolbox.Context(session.getUuid(), userTimezone(), runtime.getUiApiGateway(), toolPolicy(), this::proposeWrite, credentials)); } diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/metadata/AssistantMetaDataBuilder.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/metadata/AssistantMetaDataBuilder.java index 8305198..c9d3964 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/metadata/AssistantMetaDataBuilder.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/metadata/AssistantMetaDataBuilder.java @@ -38,6 +38,7 @@ import com.kingsrook.qbits.assistant.model.AssistantTurn; import com.kingsrook.qbits.assistant.model.AssistantTurnEvent; import com.kingsrook.qbits.assistant.model.AssistantUsage; import com.kingsrook.qbits.assistant.model.AssistantUsageDaily; +import com.kingsrook.qbits.assistant.model.AssistantUserPreference; import com.kingsrook.qbits.assistant.processes.AssistantRetentionStep; import com.kingsrook.qqq.backend.core.actions.ai.AiExposure; import com.kingsrook.qqq.backend.core.actions.ai.AiTableMetaData; @@ -204,6 +205,10 @@ public class AssistantMetaDataBuilder .withUniqueKey(new UniqueKey("profileId", "skillId")) .withRecordLabelFormat("%s ยท %s").withRecordLabelFields("profileId", "skillId")); + tables.add(owned(base(AssistantUserPreference.TABLE_NAME, "Assistant User Preference", "tune", AssistantUserPreference.class), false) + .withUniqueKey(new UniqueKey("userId")) + .withRecordLabelFormat("%s").withRecordLabelFields("userId")); + tables.add(readOnly(owned(base(AssistantPendingAction.TABLE_NAME, "Assistant Pending Action", "pending_actions", AssistantPendingAction.class), false)) .withRecordLabelFormat("%s #%s").withRecordLabelFields("toolName", "id")); text(tables.get(tables.size() - 1), "paramsJson"); diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/metadata/AssistantRecordScreens.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/metadata/AssistantRecordScreens.java index 4bd295f..d298838 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/metadata/AssistantRecordScreens.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/metadata/AssistantRecordScreens.java @@ -429,8 +429,10 @@ public final class AssistantRecordScreens .withHelpContent(new QHelpContent("The table or process name, e.g. blog_post. Tables without a row use the default: NONE for sensitive tables, READ for the others. Processes default to NONE.")); table.getField("mode").withLabel("Assistant access").withDefaultValue(DataAccessMode.READ.name()) .withHelpContent(new QHelpContent("NONE: the assistant never sees it. READ: the read tools may use it. READ and write: the create, update and delete tools may too. The assistant always acts with the user's own permissions and record locks; this can only narrow them. A sensitive table (AI exposure NONE) stays NONE.")); + table.getField("autoConfirmOk").withLabel("Auto-confirm OK") + .withHelpContent(new QHelpContent("Single-record inserts and updates here count as low-risk: users who chose auto-confirm (where the policy allows it) skip the confirmation card. Never deletes.")); sections(table, - section("access", "Data Access", "admin_panel_settings", Tier.T1, "id", "objectType", "objectName", "mode"), + section("access", "Data Access", "admin_panel_settings", Tier.T1, "id", "objectType", "objectName", "mode", "autoConfirmOk"), section("notes", "Notes", "notes", Tier.T2, "notes")); } @@ -562,6 +564,9 @@ public final class AssistantRecordScreens help(table, "killSwitch", "On: the assistant takes no new requests from anyone."); help(table, "killSwitchReason", "Shown to users while the kill switch is on."); help(table, "writesRequireConfirmation", "On (or empty): every create, update or delete the assistant proposes waits for the user to confirm it in the panel. Off: writes run at once, still with the user's own permissions."); + help(table, "writeModeAllowed", "On (or empty): users may switch a conversation to Write mode, where the create, update and delete tools exist. Off: every conversation stays Read-only."); + help(table, "writeModePermission", "Optional: only users holding this permission (a role) may use Write mode."); + help(table, "autoConfirmAllowed", "On: users may choose to auto-confirm low-risk writes (single-record inserts and updates on tables marked auto-confirm OK). Never deletes, bulk writes or processes. Off (or empty): every write waits for confirmation."); help(table, "allowedTools", "Comma-separated tool names the assistant may use (e.g. list_tables, query_records, count_records). Empty allows every tool."); help(table, "maxRowsPerCall", "Most rows one tool call returns to the model. Empty: 50; at most 200."); help(table, "redactPii", "On (or empty): API keys, tokens, private keys, card numbers and social security numbers are redacted from tool results before the model sees them."); @@ -578,7 +583,7 @@ public final class AssistantRecordScreens sections(table, section("policy", "Policy", "policy", Tier.T1, "id", "name", "killSwitch", "killSwitchReason"), section("model", "Model", "smart_toy", Tier.T2, "mainModel", "mainEffort"), - section("tools", "Tools and data", "build", Tier.T2, "writesRequireConfirmation", "allowedTools", "maxRowsPerCall", "redactPii"), + section("tools", "Tools and data", "build", Tier.T2, "writeModeAllowed", "writeModePermission", "writesRequireConfirmation", "autoConfirmAllowed", "allowedTools", "maxRowsPerCall", "redactPii"), section("userDaily", "Per user, per day", "person", Tier.T2, "userDailyTokensSoft", "userDailyTokensHard", "userDailyCostMicrosSoft", "userDailyCostMicrosHard"), section("instanceMonthly", "Whole instance, per month", "domain", Tier.T2, "instanceMonthlyTokensSoft", "instanceMonthlyTokensHard", "instanceMonthlyCostMicrosSoft", "instanceMonthlyCostMicrosHard")); } diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/model/AssistantConversation.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/model/AssistantConversation.java index 182d449..972102b 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/model/AssistantConversation.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/model/AssistantConversation.java @@ -62,6 +62,9 @@ public class AssistantConversation extends QRecordEntity @QField(isEditable = false) private String contextJson; + @QField() + private Boolean writeMode; + @QField(isEditable = false) private Long totalTokensIn; @@ -499,4 +502,34 @@ public class AssistantConversation extends QRecordEntity return (this); } + + /******************************************************************************* + ** Getter for writeMode (Write mode for this conversation (default Read-only); the write tools exist only in Write mode) + *******************************************************************************/ + public Boolean getWriteMode() + { + return (this.writeMode); + } + + + + /******************************************************************************* + ** Setter for writeMode + *******************************************************************************/ + public void setWriteMode(Boolean writeMode) + { + this.writeMode = writeMode; + } + + + + /******************************************************************************* + ** Fluent setter for writeMode + *******************************************************************************/ + public AssistantConversation withWriteMode(Boolean writeMode) + { + this.writeMode = writeMode; + return (this); + } + } diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/model/AssistantDataAccess.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/model/AssistantDataAccess.java index b39e152..0ccbafc 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/model/AssistantDataAccess.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/model/AssistantDataAccess.java @@ -49,6 +49,9 @@ public class AssistantDataAccess extends QRecordEntity @QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS) private String notes; + @QField() + private Boolean autoConfirmOk; + @QField(isEditable = false) private Instant createDate; @@ -285,4 +288,34 @@ public class AssistantDataAccess extends QRecordEntity return (this); } + + /******************************************************************************* + ** Getter for autoConfirmOk (single-record inserts and updates here may be auto-confirmed (when the policy and the user allow it)) + *******************************************************************************/ + public Boolean getAutoConfirmOk() + { + return (this.autoConfirmOk); + } + + + + /******************************************************************************* + ** Setter for autoConfirmOk + *******************************************************************************/ + public void setAutoConfirmOk(Boolean autoConfirmOk) + { + this.autoConfirmOk = autoConfirmOk; + } + + + + /******************************************************************************* + ** Fluent setter for autoConfirmOk + *******************************************************************************/ + public AssistantDataAccess withAutoConfirmOk(Boolean autoConfirmOk) + { + this.autoConfirmOk = autoConfirmOk; + return (this); + } + } diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/model/AssistantPolicy.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/model/AssistantPolicy.java index eb146bc..d7cea9f 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/model/AssistantPolicy.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/model/AssistantPolicy.java @@ -94,6 +94,15 @@ public class AssistantPolicy extends QRecordEntity @QField() private Boolean redactPii; + @QField() + private Boolean writeModeAllowed; + + @QField(maxLength = 128) + private String writeModePermission; + + @QField() + private Boolean autoConfirmAllowed; + @QField(isEditable = false) private Instant createDate; @@ -735,4 +744,94 @@ public class AssistantPolicy extends QRecordEntity return (this); } + + /******************************************************************************* + ** Getter for writeModeAllowed (whether any conversation may switch to Write mode (default true)) + *******************************************************************************/ + public Boolean getWriteModeAllowed() + { + return (this.writeModeAllowed); + } + + + + /******************************************************************************* + ** Setter for writeModeAllowed + *******************************************************************************/ + public void setWriteModeAllowed(Boolean writeModeAllowed) + { + this.writeModeAllowed = writeModeAllowed; + } + + + + /******************************************************************************* + ** Fluent setter for writeModeAllowed + *******************************************************************************/ + public AssistantPolicy withWriteModeAllowed(Boolean writeModeAllowed) + { + this.writeModeAllowed = writeModeAllowed; + return (this); + } + + + /******************************************************************************* + ** Getter for writeModePermission (only holders of this permission may switch to Write mode (empty: everyone)) + *******************************************************************************/ + public String getWriteModePermission() + { + return (this.writeModePermission); + } + + + + /******************************************************************************* + ** Setter for writeModePermission + *******************************************************************************/ + public void setWriteModePermission(String writeModePermission) + { + this.writeModePermission = writeModePermission; + } + + + + /******************************************************************************* + ** Fluent setter for writeModePermission + *******************************************************************************/ + public AssistantPolicy withWriteModePermission(String writeModePermission) + { + this.writeModePermission = writeModePermission; + return (this); + } + + + /******************************************************************************* + ** Getter for autoConfirmAllowed (whether users may auto-confirm low-risk writes (default false)) + *******************************************************************************/ + public Boolean getAutoConfirmAllowed() + { + return (this.autoConfirmAllowed); + } + + + + /******************************************************************************* + ** Setter for autoConfirmAllowed + *******************************************************************************/ + public void setAutoConfirmAllowed(Boolean autoConfirmAllowed) + { + this.autoConfirmAllowed = autoConfirmAllowed; + } + + + + /******************************************************************************* + ** Fluent setter for autoConfirmAllowed + *******************************************************************************/ + public AssistantPolicy withAutoConfirmAllowed(Boolean autoConfirmAllowed) + { + this.autoConfirmAllowed = autoConfirmAllowed; + return (this); + } + } diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/model/AssistantUserPreference.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/model/AssistantUserPreference.java new file mode 100644 index 0000000..51fbe86 --- /dev/null +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/model/AssistantUserPreference.java @@ -0,0 +1,252 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.assistant.model; + + +import java.time.Instant; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.data.QField; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.data.QRecordEntity; + + +/******************************************************************************* + ** A user's own assistant preferences: the default mode of new conversations and auto-confirm of low-risk writes (only where the policy allows it). + *******************************************************************************/ +public class AssistantUserPreference extends QRecordEntity +{ + public static final String TABLE_NAME = "assistant_user_preference"; + + @QField(isEditable = false, isPrimaryKey = true) + private Integer id; + + @QField(isEditable = false, maxLength = 255) + private String userId; + + @QField() + private Boolean defaultWriteMode; + + @QField() + private Boolean autoConfirmLowRisk; + + @QField(isEditable = false) + private Instant createDate; + + @QField(isEditable = false) + private Instant modifyDate; + + + + /******************************************************************************* + ** Default constructor. + *******************************************************************************/ + public AssistantUserPreference() + { + } + + + + /******************************************************************************* + ** Constructor that populates from a QRecord. + *******************************************************************************/ + public AssistantUserPreference(QRecord record) throws QException + { + populateFromQRecord(record); + } + + + + /******************************************************************************* + ** Getter for id + *******************************************************************************/ + public Integer getId() + { + return (this.id); + } + + + + /******************************************************************************* + ** Setter for id + *******************************************************************************/ + public void setId(Integer id) + { + this.id = id; + } + + + + /******************************************************************************* + ** Fluent setter for id + *******************************************************************************/ + public AssistantUserPreference withId(Integer id) + { + this.id = id; + return (this); + } + + + /******************************************************************************* + ** Getter for userId + *******************************************************************************/ + public String getUserId() + { + return (this.userId); + } + + + + /******************************************************************************* + ** Setter for userId + *******************************************************************************/ + public void setUserId(String userId) + { + this.userId = userId; + } + + + + /******************************************************************************* + ** Fluent setter for userId + *******************************************************************************/ + public AssistantUserPreference withUserId(String userId) + { + this.userId = userId; + return (this); + } + + + /******************************************************************************* + ** Getter for defaultWriteMode + *******************************************************************************/ + public Boolean getDefaultWriteMode() + { + return (this.defaultWriteMode); + } + + + + /******************************************************************************* + ** Setter for defaultWriteMode + *******************************************************************************/ + public void setDefaultWriteMode(Boolean defaultWriteMode) + { + this.defaultWriteMode = defaultWriteMode; + } + + + + /******************************************************************************* + ** Fluent setter for defaultWriteMode + *******************************************************************************/ + public AssistantUserPreference withDefaultWriteMode(Boolean defaultWriteMode) + { + this.defaultWriteMode = defaultWriteMode; + return (this); + } + + + /******************************************************************************* + ** Getter for autoConfirmLowRisk + *******************************************************************************/ + public Boolean getAutoConfirmLowRisk() + { + return (this.autoConfirmLowRisk); + } + + + + /******************************************************************************* + ** Setter for autoConfirmLowRisk + *******************************************************************************/ + public void setAutoConfirmLowRisk(Boolean autoConfirmLowRisk) + { + this.autoConfirmLowRisk = autoConfirmLowRisk; + } + + + + /******************************************************************************* + ** Fluent setter for autoConfirmLowRisk + *******************************************************************************/ + public AssistantUserPreference withAutoConfirmLowRisk(Boolean autoConfirmLowRisk) + { + this.autoConfirmLowRisk = autoConfirmLowRisk; + return (this); + } + + + /******************************************************************************* + ** Getter for createDate + *******************************************************************************/ + public Instant getCreateDate() + { + return (this.createDate); + } + + + + /******************************************************************************* + ** Setter for createDate + *******************************************************************************/ + public void setCreateDate(Instant createDate) + { + this.createDate = createDate; + } + + + + /******************************************************************************* + ** Fluent setter for createDate + *******************************************************************************/ + public AssistantUserPreference withCreateDate(Instant createDate) + { + this.createDate = createDate; + return (this); + } + + + /******************************************************************************* + ** Getter for modifyDate + *******************************************************************************/ + public Instant getModifyDate() + { + return (this.modifyDate); + } + + + + /******************************************************************************* + ** Setter for modifyDate + *******************************************************************************/ + public void setModifyDate(Instant modifyDate) + { + this.modifyDate = modifyDate; + } + + + + /******************************************************************************* + ** Fluent setter for modifyDate + *******************************************************************************/ + public AssistantUserPreference withModifyDate(Instant modifyDate) + { + this.modifyDate = modifyDate; + return (this); + } + +} diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/persistence/AssistantStore.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/persistence/AssistantStore.java index ed019ac..dd248e3 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/persistence/AssistantStore.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/persistence/AssistantStore.java @@ -37,6 +37,7 @@ import com.kingsrook.qbits.assistant.model.AssistantTurn; import com.kingsrook.qbits.assistant.model.AssistantTurnEvent; import com.kingsrook.qbits.assistant.model.AssistantUsage; import com.kingsrook.qbits.assistant.model.AssistantUsageDaily; +import com.kingsrook.qbits.assistant.model.AssistantUserPreference; import com.kingsrook.qbits.assistant.provider.ModelUsage; import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction; import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; @@ -348,6 +349,16 @@ public class AssistantStore + /******************************************************************************* + ** The user's own preferences, or null. + *******************************************************************************/ + public AssistantUserPreference getPreference(String userId) throws QException + { + return (first(query(AssistantUserPreference.TABLE_NAME, owned(userId).withLimit(1), AssistantUserPreference.class))); + } + + + /******************************************************************************* ** One of the user's own pending actions, or null. *******************************************************************************/ diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/UiApiToolbox.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/UiApiToolbox.java index 78d08a2..051af7d 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/UiApiToolbox.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/UiApiToolbox.java @@ -335,7 +335,8 @@ public class UiApiToolbox List rs = new ArrayList<>(); for(ModelToolDefinition definition : definitions()) { - if((policy == null || policy.allows(definition.getName())) && (anyWritableTable || !WRITE_TOOLS.contains(definition.getName()))) + boolean write = WRITE_TOOLS.contains(definition.getName()); + if((policy == null || policy.allows(definition.getName())) && (!write || (anyWritableTable && (policy == null || policy.writeMode())))) { rs.add(definition); } @@ -383,6 +384,11 @@ public class UiApiToolbox deny(result, "policy:tool", "The administrator has not allowed the " + toolName + " tool."); return (outcome); } + if(WRITE_TOOLS.contains(toolName) && context.policy() != null && !context.policy().writeMode()) + { + deny(result, "mode:READ_ONLY", "This conversation is in Read-only mode: the assistant cannot change data. The user can switch it to Write mode in the panel."); + return (outcome); + } if(!context.gateway().isInvocable(operation[0], operation[1])) { deny(result, "uiApi:notSpecd", "The operation " + outcome.operation + " is not a spec'd UI API operation."); @@ -1146,7 +1152,12 @@ public class UiApiToolbox } } - if(context.policy() == null || context.policy().writesRequireConfirmation()) + /////////////////////////////////////////////////////////////////// + // auto-confirm: only when the policy allows it, the user chose // + // it, and the admin marked the table low-risk; never deletes // + /////////////////////////////////////////////////////////////////// + boolean autoConfirmed = context.policy() != null && context.policy().autoConfirm() && !DELETE_RECORD.equals(toolName) && dataAccess.autoConfirmOk(tableName); + if(!autoConfirmed && (context.policy() == null || context.policy().writesRequireConfirmation())) { String summary = describeWrite(toolName, table, recordId, values); Integer actionId = context.pendingActions() == null ? null diff --git a/qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml b/qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml index 0a6097a..30bcc45 100644 --- a/qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml +++ b/qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml @@ -112,6 +112,10 @@ components: type: "string" pinned: type: "boolean" + writeMode: + description: "Write mode (default Read-only): the write tools exist only\ + \ in Write mode." + type: "boolean" lastMessageAt: description: "ISO-8601 instant." nullable: true @@ -159,6 +163,18 @@ components: type: "object" type: "array" type: "object" + AssistantPreferencesV1: + description: "The user's own assistant preferences." + properties: + defaultWriteMode: + description: "New conversations start in Write mode (when write mode is\ + \ available)." + type: "boolean" + autoConfirmLowRisk: + description: "Auto-confirm low-risk writes (only when the policy allows\ + \ it; never deletes, bulk writes or processes)." + type: "boolean" + type: "object" AssistantProfileV1: description: "How the assistant presents itself (admin-configured; text only,\ \ it never changes access)." @@ -329,7 +345,8 @@ paths: content: application/json: schema: - description: "Optional title and the page context it starts from." + description: "Optional title, the page context it starts from, and the\ + \ mode (default: the user's preference)." properties: title: type: "string" @@ -337,6 +354,8 @@ paths: additionalProperties: true description: "route, tableName, recordId, filter, selectedIds." type: "object" + writeMode: + type: "boolean" type: "object" required: true responses: @@ -533,6 +552,10 @@ paths: status: description: "ACTIVE or ARCHIVED." type: "string" + writeMode: + description: "Switch to Write mode (true) or Read-only (false);\ + \ 403 when write mode is not available to the user." + type: "boolean" type: "object" required: true responses: @@ -1086,3 +1109,114 @@ paths: - "N/A" summary: "Explain access: which UI API operations the assistant could call for\ \ this user, and why not" + /qqq/v1/assistant/preferences: + get: + description: "The user's own assistant preferences" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantPreferencesV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "The user's own assistant preferences" + patch: + description: "Change the user's own assistant preferences" + requestBody: + content: + application/json: + schema: + description: "The changed preferences." + properties: + defaultWriteMode: + type: "boolean" + autoConfirmLowRisk: + type: "boolean" + type: "object" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantPreferencesV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Change the user's own assistant preferences" diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/UiApiToolsSecurityTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/UiApiToolsSecurityTest.java index e0f3e9f..9628b3b 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/UiApiToolsSecurityTest.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/UiApiToolsSecurityTest.java @@ -244,7 +244,7 @@ class UiApiToolsSecurityTest extends BaseAssistantTest void testConfirmedWriteRunsThroughTheUiApi() throws Exception { setAccess("blog_post", DataAccessMode.READ_WRITE); - UiApiToolbox.Outcome proposed = tool(AssistantTestFixture.ADMIN, PolicyService.ToolPolicy.defaults(), UiApiToolbox.CREATE_RECORD, + UiApiToolbox.Outcome proposed = tool(AssistantTestFixture.ADMIN, PolicyService.ToolPolicy.defaults().withWriteMode(true), UiApiToolbox.CREATE_RECORD, input("table", "blog_post", "values", Map.of("title", "Proposed by the assistant", "status", "DRAFT"))); assertThat(proposed.getResult().getOutcome()).isEqualTo(AiToolOutcome.OK); assertThat(proposed.getHttpStatus()).as("nothing runs before confirmation").isNull(); @@ -282,7 +282,7 @@ class UiApiToolsSecurityTest extends BaseAssistantTest void testOnlyTheOwnerDecides() throws Exception { setAccess("blog_post", DataAccessMode.READ_WRITE); - UiApiToolbox.Outcome proposed = tool(AssistantTestFixture.EDITOR, PolicyService.ToolPolicy.defaults(), UiApiToolbox.UPDATE_RECORD, + UiApiToolbox.Outcome proposed = tool(AssistantTestFixture.EDITOR, PolicyService.ToolPolicy.defaults().withWriteMode(true), UiApiToolbox.UPDATE_RECORD, input("table", "blog_post", "id", "1", "values", Map.of("title", "Changed"))); Integer actionId = proposed.getPendingActionId(); assertThat(actionId).isNotNull(); @@ -377,6 +377,27 @@ class UiApiToolsSecurityTest extends BaseAssistantTest } } + ////////////////////////////////////////////////////////////////// + // Read-only mode: the same writes never reach the UI API, for // + // the analyst and for the admin who could make them in the UI // + ////////////////////////////////////////////////////////////////// + PolicyService.ToolPolicy readOnly = noConfirmation().withWriteMode(false); + for(String who : List.of(AssistantTestFixture.ANALYST, AssistantTestFixture.ADMIN)) + { + for(Object[] row : new Object[][] { + {UiApiToolbox.CREATE_RECORD, input("table", "blog_post", "values", Map.of("title", "x"))}, + {UiApiToolbox.UPDATE_RECORD, input("table", "blog_post", "id", "1", "values", Map.of("title", "x"))}, + {UiApiToolbox.DELETE_RECORD, input("table", "blog_post", "id", "1")}}) + { + @SuppressWarnings("unchecked") + UiApiToolbox.Outcome outcome = tool(who, readOnly, (String) row[0], (Map) row[1]); + assertThat(outcome.getResult().getOutcome()).as(who + " " + row[0]).isEqualTo(AiToolOutcome.DENIED); + assertThat(outcome.getHttpStatus()).as(who + " " + row[0] + ": no request").isNull(); + assertThat(outcome.getResult().getPermissionCheck()).isEqualTo("mode:READ_ONLY"); + } + } + assertThat(ui("PATCH", "/table/blog_post/1", AssistantTestFixture.ADMIN, "{\"title\":\"Admin via the UI\"}").statusCode()).as("the admin may write in the UI").isBetween(200, 299); + ////////////////////////////////////////////////////////////////// // process: no tool, and no process has data access by default // ////////////////////////////////////////////////////////////////// diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/WriteModeTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/WriteModeTest.java new file mode 100644 index 0000000..ac72b05 --- /dev/null +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/WriteModeTest.java @@ -0,0 +1,208 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.assistant.tools; + + +import java.io.Serializable; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.assistant.api.AssistantApi; +import com.kingsrook.qbits.assistant.model.AssistantDataAccess; +import com.kingsrook.qbits.assistant.model.AssistantPolicy; +import com.kingsrook.qbits.assistant.model.AssistantToolCall; +import com.kingsrook.qbits.assistant.provider.ModelRequest; +import com.kingsrook.qbits.assistant.testing.AssistantTestFixture; +import com.kingsrook.qbits.assistant.testing.BaseAssistantTest; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +/******************************************************************************* + ** Write mode (owner, 2026-10-13): Read-only by default; the write tools exist + ** only in Write mode; the policy can turn write mode off or limit it to a + ** role; the user's preference sets the default; auto-confirm only where the + ** policy allows it, on tables marked low-risk, never for deletes. + *******************************************************************************/ +class WriteModeTest extends BaseAssistantTest +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @AfterEach + void clear() + { + QContext.clear(); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private void save(String table, Map values) throws QException + { + QContext.init(qInstance, AssistantTestFixture.sessionFor(AssistantTestFixture.ADMIN)); + try + { + QRecord record = new QRecord(); + record.setValues(new LinkedHashMap<>(values)); + QRecord saved = new InsertAction().execute(new InsertInput(table).withRecord(record)).getRecords().get(0); + assertThat(saved.getErrors()).isEmpty(); + } + finally + { + QContext.clear(); + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private AssistantApi api(String user) + { + QContext.init(qInstance, AssistantTestFixture.sessionFor(user)); + return (new AssistantApi(config.getRuntime(), QContext.getQSession())); + } + + + + /******************************************************************************* + ** Read-only by default: no write tools in the request; Write mode adds + ** them; the conversation remembers its mode. + *******************************************************************************/ + @Test + void testModeControlsTheToolList() throws Exception + { + save(AssistantDataAccess.TABLE_NAME, Map.of("objectType", "TABLE", "objectName", "page", "mode", "READ_WRITE")); + + AssistantApi admin = api(AssistantTestFixture.ADMIN); + Map conversation = admin.createConversation(new LinkedHashMap<>()); + assertThat(conversation.get("writeMode")).isEqualTo(false); + Integer id = (Integer) conversation.get("id"); + QContext.clear(); + + provider.thenText("ok").thenText("ok"); + runner.run(AssistantTestFixture.ADMIN, id, "read only please", null); + + QContext.init(qInstance, AssistantTestFixture.sessionFor(AssistantTestFixture.ADMIN)); + assertThat(new AssistantApi(config.getRuntime(), QContext.getQSession()).patchConversation(id, Map.of("writeMode", true)).get("writeMode")).isEqualTo(true); + QContext.clear(); + runner.run(AssistantTestFixture.ADMIN, id, "now write", null); + + List requests = provider.getRequests(); + assertThat(requests.get(0).getTools()).extracting(t -> t.getName()).doesNotContain(UiApiToolbox.CREATE_RECORD, UiApiToolbox.UPDATE_RECORD, UiApiToolbox.DELETE_RECORD); + assertThat(requests.get(1).getTools()).extracting(t -> t.getName()).contains(UiApiToolbox.CREATE_RECORD, UiApiToolbox.UPDATE_RECORD, UiApiToolbox.DELETE_RECORD); + } + + + + /******************************************************************************* + ** A write tool called in a Read-only conversation is refused before any + ** request (the model cannot get around the tool list). + *******************************************************************************/ + @Test + void testReadOnlyRefusesAWriteCall() throws Exception + { + save(AssistantDataAccess.TABLE_NAME, Map.of("objectType", "TABLE", "objectName", "page", "mode", "READ_WRITE")); + provider.thenToolUse(UiApiToolbox.UPDATE_RECORD, Map.of("table", "page", "id", "12", "values", Map.of("title", "Hacked"))).thenText("ok"); + var outcome = runner.run(AssistantTestFixture.ADMIN, "change page 12"); + assertThat(outcome.toolCalls()).extracting(AssistantToolCall::getOutcome).containsExactly("INVALID_INPUT"); + } + + + + /******************************************************************************* + ** The policy: write mode off for everyone, or only for a role; then 403. + *******************************************************************************/ + @Test + void testPolicyLimitsWriteMode() throws Exception + { + save(AssistantDataAccess.TABLE_NAME, Map.of("objectType", "TABLE", "objectName", "page", "mode", "READ_WRITE")); + AssistantApi editor = api(AssistantTestFixture.EDITOR); + Integer id = (Integer) editor.createConversation(new LinkedHashMap<>()).get("id"); + assertThat(editor.patchConversation(id, Map.of("writeMode", true)).get("writeMode")).isEqualTo(true); + QContext.clear(); + + save(AssistantPolicy.TABLE_NAME, Map.of("name", "default", "writeModePermission", "assistant.write")); + config.getRuntime().getPolicyService().invalidate(); + AssistantApi again = api(AssistantTestFixture.EDITOR); + assertThatThrownBy(() -> again.patchConversation(id, Map.of("writeMode", true))).hasMessageContaining("Write mode is not available"); + assertThat(again.config()).extracting(c -> ((Map) c.get("features")).get("writeMode")).isEqualTo(false); + } + + + + /******************************************************************************* + ** No READ_WRITE table: write mode is not available at all. + *******************************************************************************/ + @Test + void testNoWritableTableNoWriteMode() throws Exception + { + AssistantApi admin = api(AssistantTestFixture.ADMIN); + Integer id = (Integer) admin.createConversation(new LinkedHashMap<>()).get("id"); + assertThatThrownBy(() -> admin.patchConversation(id, Map.of("writeMode", true))).hasMessageContaining("Write mode is not available"); + } + + + + /******************************************************************************* + ** Preferences: the default mode applies to new conversations; auto-confirm + ** is refused unless the policy allows it, and then runs a low-risk insert at + ** once but never a delete. + *******************************************************************************/ + @Test + void testPreferencesAndAutoConfirm() throws Exception + { + save(AssistantDataAccess.TABLE_NAME, Map.of("objectType", "TABLE", "objectName", "page", "mode", "READ_WRITE", "autoConfirmOk", true)); + + AssistantApi admin = api(AssistantTestFixture.ADMIN); + admin.patchPreferences(Map.of("defaultWriteMode", true)); + assertThat(admin.createConversation(new LinkedHashMap<>()).get("writeMode")).isEqualTo(true); + assertThatThrownBy(() -> admin.patchPreferences(Map.of("autoConfirmLowRisk", true))).hasMessageContaining("not allowed"); + QContext.clear(); + + save(AssistantPolicy.TABLE_NAME, Map.of("name", "default", "autoConfirmAllowed", true)); + config.getRuntime().getPolicyService().invalidate(); + AssistantApi allowed = api(AssistantTestFixture.ADMIN); + assertThat(allowed.patchPreferences(Map.of("autoConfirmLowRisk", true)).get("autoConfirmLowRisk")).isEqualTo(true); + QContext.clear(); + + provider.thenToolUses("Saving.", List.of( + new Object[] {UiApiToolbox.CREATE_RECORD, Map.of("table", "page", "values", Map.of("title", "Auto"))}, + new Object[] {UiApiToolbox.DELETE_RECORD, Map.of("table", "page", "id", "12")})) + .thenText("done"); + var outcome = runner.run(AssistantTestFixture.ADMIN, "make a page and delete page 12"); + AssistantToolCall create = outcome.toolCalls().stream().filter(c -> UiApiToolbox.CREATE_RECORD.equals(c.getToolName())).findFirst().orElseThrow(); + AssistantToolCall delete = outcome.toolCalls().stream().filter(c -> UiApiToolbox.DELETE_RECORD.equals(c.getToolName())).findFirst().orElseThrow(); + assertThat(create.getHttpStatus()).as("auto-confirmed: ran through the UI API").isBetween(200, 299); + assertThat(create.getPendingActionId()).isNull(); + assertThat(delete.getHttpStatus()).as("a delete is never auto-confirmed").isNull(); + assertThat(delete.getPendingActionId()).isNotNull(); + } + +} diff --git a/qbit-assistant/src/test/resources/openapi/qqq-v1/assistant-baseline.yaml b/qbit-assistant/src/test/resources/openapi/qqq-v1/assistant-baseline.yaml index 0a6097a..30bcc45 100644 --- a/qbit-assistant/src/test/resources/openapi/qqq-v1/assistant-baseline.yaml +++ b/qbit-assistant/src/test/resources/openapi/qqq-v1/assistant-baseline.yaml @@ -112,6 +112,10 @@ components: type: "string" pinned: type: "boolean" + writeMode: + description: "Write mode (default Read-only): the write tools exist only\ + \ in Write mode." + type: "boolean" lastMessageAt: description: "ISO-8601 instant." nullable: true @@ -159,6 +163,18 @@ components: type: "object" type: "array" type: "object" + AssistantPreferencesV1: + description: "The user's own assistant preferences." + properties: + defaultWriteMode: + description: "New conversations start in Write mode (when write mode is\ + \ available)." + type: "boolean" + autoConfirmLowRisk: + description: "Auto-confirm low-risk writes (only when the policy allows\ + \ it; never deletes, bulk writes or processes)." + type: "boolean" + type: "object" AssistantProfileV1: description: "How the assistant presents itself (admin-configured; text only,\ \ it never changes access)." @@ -329,7 +345,8 @@ paths: content: application/json: schema: - description: "Optional title and the page context it starts from." + description: "Optional title, the page context it starts from, and the\ + \ mode (default: the user's preference)." properties: title: type: "string" @@ -337,6 +354,8 @@ paths: additionalProperties: true description: "route, tableName, recordId, filter, selectedIds." type: "object" + writeMode: + type: "boolean" type: "object" required: true responses: @@ -533,6 +552,10 @@ paths: status: description: "ACTIVE or ARCHIVED." type: "string" + writeMode: + description: "Switch to Write mode (true) or Read-only (false);\ + \ 403 when write mode is not available to the user." + type: "boolean" type: "object" required: true responses: @@ -1086,3 +1109,114 @@ paths: - "N/A" summary: "Explain access: which UI API operations the assistant could call for\ \ this user, and why not" + /qqq/v1/assistant/preferences: + get: + description: "The user's own assistant preferences" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantPreferencesV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "The user's own assistant preferences" + patch: + description: "Change the user's own assistant preferences" + requestBody: + content: + application/json: + schema: + description: "The changed preferences." + properties: + defaultWriteMode: + type: "boolean" + autoConfirmLowRisk: + type: "boolean" + type: "object" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantPreferencesV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Change the user's own assistant preferences" -- 2.54.0