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
6 changes: 6 additions & 0 deletions src/lib/helpers/enums.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@ const routingMode = {
};
export const RoutingMode = Object.freeze(routingMode);

const ruleCriteriaMode = {
Llm: "llm",
PythonScript: "python_script"
};
export const RuleCriteriaMode = Object.freeze(ruleCriteriaMode);

const functionVisMode = {
Manual: "manual",
Auto: "auto"
Expand Down
43 changes: 43 additions & 0 deletions src/lib/helpers/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,42 @@ export const globalMenuStore = createGlobalMenuStore();
/** @type {Writable<import('$userTypes').UserModel>} */
export const userStore = writable({ id: "", full_name: "", expires: 0, token: null });

/** @type {boolean} */
let openerSessionRestored = false;

/**
* The signed-in session lives in sessionStorage, which is per-tab: a tab opened
* from an in-app link starts without it (browsers only clone session storage in
* some cases, and never when the link severs the opener), which would bounce the
* user to the login page. When this tab was opened by another tab of the same
* app, copy the session across once instead.
*
* Reading the opener is only permitted — and only attempted — same-origin, and
* existing keys in this tab always win.
*/
export function restoreSessionFromOpener() {
if (!browser || openerSessionRestored) return;
openerSessionRestored = true;

Comment on lines +53 to +56

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

2. Opener restore is one-shot 🐞 Bug ☼ Reliability

restoreSessionFromOpener() sets openerSessionRestored=true before copying any keys, so later calls
will not retry copying keys that were missing during the first attempt. This can leave the new tab
without tenant_id/tenant_name (or other keys) if the opener populates them after the first restore
attempt.
Agent Prompt
### Issue description
`restoreSessionFromOpener()` is guarded by a global `openerSessionRestored` flag that is set to `true` before attempting any key copies. If the first call occurs before the opener has populated some keys (e.g., tenant_id/tenant_name set later), subsequent calls will never attempt to copy those missing keys.

### Issue Context
`getUserStore()` calls this only when `user` is absent; `getTenantId()`/`getTenantName()` call it unconditionally, but are blocked after the first attempt.

### Fix Focus Areas
- src/lib/helpers/store.js[40-74]
- src/lib/helpers/store.js[101-128]

### Suggested change
Allow repeated attempts to copy *still-missing* keys without overwriting existing values. For example:
- Remove the global one-shot flag entirely, or
- Replace it with per-key tracking (e.g., `restoredKeys` set) and only skip keys already present or already successfully restored, or
- Only set `openerSessionRestored=true` after a same-origin opener is confirmed and the copy loop has run, and allow retrying when any of `[userKey, tenantKey, tenantNameKey]` remains missing.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

try {
const opener = window.opener;
if (!opener || opener.closed) return;
// Touching location.origin on a cross-origin opener throws, hence the try.
if (opener.location.origin !== window.location.origin) return;

[userKey, tenantKey, tenantNameKey].forEach(key => {
if (sessionStorage.getItem(key)) return;

const value = opener.sessionStorage.getItem(key);
if (value) {
sessionStorage.setItem(key, value);
}
});
} catch (e) {
// No accessible opener (closed, cross-origin, or blocked) — nothing to restore.
}
}

/**
* @returns {Writable<import('$userTypes').UserModel>}
*/
Expand All @@ -48,6 +84,11 @@ export function getUserStore() {
}

let json = sessionStorage.getItem(userKey);
if (!json) {
restoreSessionFromOpener();
json = sessionStorage.getItem(userKey);
}

if (json) {
return JSON.parse(json);
}
Expand All @@ -60,6 +101,7 @@ export function getUserStore() {
/** @returns {string} */
export function getTenantId() {
if (!browser) return '';
restoreSessionFromOpener();
return sessionStorage.getItem(tenantKey) || '';
}

Expand All @@ -81,6 +123,7 @@ export function clearTenantId() {
/** @returns {string} */
export function getTenantName() {
if (!browser) return '';
restoreSessionFromOpener();
return sessionStorage.getItem(tenantNameKey) || '';
}

Expand Down
23 changes: 21 additions & 2 deletions src/lib/helpers/types/agentTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -244,15 +244,34 @@
* @property {boolean} [expanded]
*/

/**
* Trigger option returned by the rule-options endpoints. Note this is the
* option catalog, not a rule configured on an agent.
* @typedef {Object} AgentRuleOption
* @property {string} trigger_name
* @property {any?} [output_args]
* @property {string?} [json_args]
* @property {string?} [statement]
* @property {string?} [mode] - Default criteria mode for this trigger
*/

/**
* @typedef {Object} RuleCriteria
* @property {string?} [mode] - Criteria mode: llm, code, etc. Takes precedence over the mode carried on the trigger options.
* @property {string?} [criteria] - Criteria text
*/

/**
* @typedef {Object} AgentRule
* @property {string} trigger_name
* @property {string} trigger_name
* @property {string?} [displayName]
* @property {boolean} disabled
* @property {any?} [config]
* @property {string?} [message] - Message sent to agent
* @property {RuleCriteria?} [criteria]
* @property {any?} [output_args]
* @property {string?} [json_args]
* @property {string?} [statement]
* @property {string?} [default_mode] - Criteria mode carried on the trigger options, used as fallback
* @property {boolean} [expanded]
*/

Expand Down
4 changes: 2 additions & 2 deletions src/lib/services/agent-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ export async function getAgentUtilityOptions() {

/**
* Get agent rule options
* @returns {Promise<import('$agentTypes').AgentRule[]>}
* @returns {Promise<import('$agentTypes').AgentRuleOption[]>}
*/
export async function getAgentRuleOptions() {
const url = endpoints.agentRuleOptionsUrl;
Expand All @@ -119,7 +119,7 @@ export async function getAgentRuleOptions() {
/**
* Get agent rule options by agent id
* @param {string} agentId
* @returns {Promise<import('$agentTypes').AgentRule[]>}
* @returns {Promise<import('$agentTypes').AgentRuleOption[]>}
*/
export async function getAgentRuleOptionsById(agentId) {
const url = endpoints.agentRuleOptionsByIdUrl.replace("{agentId}", agentId);
Expand Down
27 changes: 27 additions & 0 deletions src/lib/styles/pages/_agent.scss
Original file line number Diff line number Diff line change
Expand Up @@ -2108,6 +2108,33 @@ $panel-radius: 0.5rem;
}


/* "Criteria Text" doubles as a link to the agent's code scripts page. */
.ari-label-link {
display: inline-flex;
align-items: center;
gap: 0.25rem;
color: var(--color-primary);
text-decoration: none;
transition: filter 0.15s ease;

&:hover {
color: var(--color-primary);
text-decoration: underline;
filter: brightness(1.15);
}

&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
border-radius: 0.25rem;
}

i {
font-size: 0.875rem;
}
}


.ari-textarea {
width: 100%;
padding: 0.375rem 0.5rem;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,20 @@
import Markdown from '$lib/common/markdown/Markdown.svelte';
import BotsharpTooltip from '$lib/common/tooltip/BotsharpTooltip.svelte';
import Select from '$lib/common/dropdowns/Select.svelte';
import { RuleCriteriaMode } from '$lib/helpers/enums';

const textLimit = 1024;

const criteriaModeOptions = [
{ label: 'LLM', value: RuleCriteriaMode.Llm },
{ label: 'Python Script', value: RuleCriteriaMode.PythonScript }
];

/**
* @type {{
* rule: import('$agentTypes').AgentRule,
* ruleIndex: number,
* agentId?: string,
* collapsed?: boolean,
* ruleOptions?: any[],
* windowWidth: number,
Expand All @@ -23,6 +30,7 @@
let {
rule,
ruleIndex,
agentId = '',
collapsed = true,
ruleOptions = [],
windowWidth,
Expand All @@ -35,7 +43,17 @@

// Code script can only be generated by admins, once a trigger is picked and criteria text exists.
let canCompile = $derived(
!!rule.trigger_name && !!rule.config?.criteria?.trim()
!!rule.trigger_name && !!rule.criteria?.criteria?.trim()
);

// The rule's own mode wins; otherwise the trigger option's mode applies.
let modePlaceholder = $derived(
rule.default_mode ? `Trigger default (${rule.default_mode})` : 'Trigger default'
);

// Deep link to the code scripts page, preselected on this agent.
let codeScriptUrl = $derived(
agentId ? `/page/agent/code-scripts?agentId=${encodeURIComponent(agentId)}` : ''
);

/**
Expand Down Expand Up @@ -82,15 +100,32 @@

/**
* @param {any} e
* @param {string} field
*/
function changeCriteria(e) {
function changeText(e, field) {
onchange?.({
ruleIdx: ruleIndex,
field: 'criteria',
field: field,
value: e?.target?.value || ''
});
}

/**
* The session token lives in sessionStorage, which a new tab does not get
* when the link drops the opener (`rel="noopener"`, which `target="_blank"`
* also implies on its own). Opening through window.open keeps this tab as
* the opener, so the new tab can pull the session across and skip the login
* page. Also covers ctrl/cmd-click, which would otherwise take the default
* opener-less path.
* @param {any} e
*/
function openCodeScripts(e) {
if (!codeScriptUrl) return;

e.preventDefault();
window.open(codeScriptUrl, '_blank');
Comment on lines +123 to +126

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

3. Opener kept for new tab 🐞 Bug ⛨ Security

openCodeScripts() uses window.open(url, '_blank') specifically to preserve window.opener for session
restoration, which means the opened tab can navigate the opener (reverse-tabnabbing/opener
manipulation) if it ever reaches attacker-controlled content. This is a security tradeoff introduced
by relying on opener for credential/session transfer.
Agent Prompt
### Issue description
The new tab is intentionally opened with an opener reference so it can read `opener.sessionStorage` and restore the session. Keeping `window.opener` increases exposure to reverse-tabnabbing/opener manipulation if the opened tab ever navigates to untrusted content (e.g., via an XSS, open redirect, or user-driven navigation).

### Issue Context
This behavior is paired with `restoreSessionFromOpener()` which reads `window.opener.sessionStorage`.

### Fix Focus Areas
- src/routes/page/agent/[agentId]/agent-components/rules/agent-rule-item.svelte[113-127]
- src/lib/helpers/store.js[43-74]

### Safer alternatives
Prefer a session-sharing mechanism that doesn’t require opener (so you can use `noopener`):
- Use `localStorage` + `storage` event / `BroadcastChannel` to request/response a session copy.
- Use a short-lived one-time token in the URL (issued server-side) rather than copying sessionStorage.
If opener must be used, consider constraining capabilities (e.g., immediately `window.opener = null` after the child has copied needed values, if feasible in the child page).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

}

function compile() {
oncompile?.({
ruleIdx: ruleIndex,
Expand Down Expand Up @@ -159,6 +194,8 @@
tag={`rule-trigger-${ruleIndex}`}
containerStyles={'width: 100%;'}
placeholder={'Select a trigger'}
searchMode
searchPlaceholder={'Search triggers'}
disabled={rule.disabled}
selectedValues={rule.trigger_name ? [rule.trigger_name] : []}
options={ruleOptions.filter(o => !!o.name).map(o => ({ label: o.displayName || o.name, value: o.name }))}
Expand All @@ -181,7 +218,65 @@
<div class="ari-row ari-row-secondary" transition:slide={{ duration: 200 }}>
<div class="ari-label ari-label-strong">
<div class="ari-cell">
{'Criteria'}
{'Message'}
</div>
</div>
<div class="ari-value">
<div class="ari-input-wrap ari-cell">
<textarea
class="ari-textarea"
rows="3"
maxlength={textLimit}
placeholder="Message sent to the agent when this rule fires..."
disabled={rule.disabled}
value={rule.message || ''}
oninput={e => changeText(e, 'message')}
></textarea>
</div>
<div class="ari-delete ari-cell"></div>
</div>
</div>

<div class="ari-row ari-row-secondary" transition:slide={{ duration: 200 }}>
<div class="ari-label ari-label-strong">
<div class="ari-cell">
{'Criteria Mode'}
</div>
</div>
<div class="ari-value">
<div class="ari-input-wrap ari-cell">
<Select
tag={`rule-criteria-mode-${ruleIndex}`}
containerStyles={'width: 100%;'}
placeholder={modePlaceholder}
disabled={rule.disabled}
selectedValues={rule.criteria?.mode ? [rule.criteria.mode] : []}
options={criteriaModeOptions}
onselect={e => changeRule(e, 'criteria_mode')}
/>
</div>
<div class="ari-delete ari-cell"></div>
</div>
</div>

<div class="ari-row ari-row-secondary" transition:slide={{ duration: 200 }}>
<div class="ari-label ari-label-strong">
<div class="ari-cell">
{#if codeScriptUrl}
<a
class="ari-label-link"
href={codeScriptUrl}
target="_blank"
title="Open this agent's code scripts"
onclick={e => openCodeScripts(e)}
onauxclick={e => { if (e.button === 1) openCodeScripts(e); }}
>
<span>{'Criteria Text'}</span>
<i class="bx bx-link-external" aria-hidden="true"></i>
</a>
{:else}
{'Criteria Text'}
{/if}
</div>
{#if canCompile}
<div class="ari-cell">
Expand All @@ -207,8 +302,8 @@
maxlength={textLimit}
placeholder="Describe when this rule should trigger..."
disabled={rule.disabled}
value={rule.config?.criteria || ''}
oninput={e => changeCriteria(e)}
value={rule.criteria?.criteria || ''}
oninput={e => changeText(e, 'criteria')}
></textarea>
</div>
<div class="ari-delete ari-cell"></div>
Expand Down
Loading
Loading