Files
OpenHands/frontend/src/components/modals/settings/SettingsModal.tsx
adragos e0b67ad2f1 feat: add Security Analyzer functionality (#3058)
* feat: Initial work on security analyzer

* feat: Add remote invariant client

* chore: improve fault tolerance of client

* feat: Add button to enable Invariant Security Analyzer

* [feat] confirmation mode for bash actions

* feat: Add Invariant Tab with security risk outputs

* feat: Add modal setting for Confirmation Mode

* fix: frontend tests for confirmation mode switch

* fix: add missing CONFIRMATION_MODE value in SettingsModal.test.tsx

* fix: update test to integrate new setting

* feat: Initial work on security analyzer

* feat: Add remote invariant client

* chore: improve fault tolerance of client

* feat: Add button to enable Invariant Security Analyzer

* feat: Add Invariant Tab with security risk outputs

* feat: integrate security analyzer with confirmation mode

* feat: improve invariant analyzer tab

* feat: Implement user confirmation for running bash/python code

* fix: don't display rejected actions

* fix: make confirmation show only on assistant messages

* feat: download traces, update policy, implement settings, auto-approve based on defined risk

* Fix: low risk not being shown because it's 0

* fix: duplicate logs in tab

* fix: log duplication

* chore: prepare for merge, remove logging

* Merge confirmation_mode from OpenDevin main

* test: update tests to pass

* chore: finish merging changes, security analyzer now operational again

* feat: document Security Analyzers

* refactor: api, monitor

* chore: lint, fix risk None, revert policy

* fix: check security_risk for None

* refactor: rename instances of invariant to security analyzer

* feat: add /api/options/security-analyzers endpoint

* Move security analyzer from tab to modal

* Temporary fix lock when security analyzer is not chosen

* feat: don't show lock at all when security analyzer is not enabled

* refactor:
- Frontend:
* change type of SECURITY_ANALYZER from bool to string
* add combobox to select SECURITY_ANALYZER, current options are "invariant and "" (no security analyzer)
* Security is now a modal, lock in bottom right is visible only if there's a security analyzer selected
- Backend:
* add close to SecurityAnalyzer
* instantiate SecurityAnalyzer based on provided string from frontend

* fix: update close to be async, to be consistent with other close on resources

* fix: max height of modal (prevent overflow)

* feat: add logo

* small fixes

* update docs for creating a security analyzer module

* fix linting

* update timeout for http client

* fix: move security_analyzer config from agent to session

* feat: add security_risk to browser actions

* add optional remark on combobox

* fix: asdict not called on dataclass, remove invariant dependency

* fix: exclude None values when serializing

* feat: take default policy from invariant-server instead of being hardcoded

* fix: check if policy is None

* update image name

* test: fix some failing runs

* fix: security analyzer tests

* refactor: merge confirmation_mode and security_analyzer into SecurityConfig. Change invariant error message for docker

* test: add tests for invariant parsing actions / observations

* fix: python linting for test_security.py

* Apply suggestions from code review

Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>

* use ActionSecurityRisk | None intead of Optional

* refactor action parsing

* add extra check

* lint parser.py

* test: add field keep_prompt to test_security

* docs: add information about how to enable the analyzer

* test: Remove trailing whitespace in README.md text

---------

Co-authored-by: Mislav Balunovic <mislav.balunovic@gmail.com>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
Co-authored-by: Xingyao Wang <xingyao6@illinois.edu>
2024-08-13 11:29:41 +00:00

204 lines
6.1 KiB
TypeScript

import { Spinner } from "@nextui-org/react";
import i18next from "i18next";
import React, { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import {
fetchAgents,
fetchModels,
fetchSecurityAnalyzers,
} from "#/services/options";
import { AvailableLanguages } from "#/i18n";
import { I18nKey } from "#/i18n/declaration";
import Session from "#/services/session";
import { RootState } from "../../../store";
import AgentState from "../../../types/AgentState";
import {
Settings,
getSettings,
getDefaultSettings,
getSettingsDifference,
settingsAreUpToDate,
maybeMigrateSettings,
saveSettings,
} from "#/services/settings";
import toast from "#/utils/toast";
import BaseModal from "../base-modal/BaseModal";
import SettingsForm from "./SettingsForm";
interface SettingsProps {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
}
const REQUIRED_SETTINGS = ["LLM_MODEL", "AGENT"];
function SettingsModal({ isOpen, onOpenChange }: SettingsProps) {
const { t } = useTranslation();
const [models, setModels] = React.useState<string[]>([]);
const [agents, setAgents] = React.useState<string[]>([]);
const [securityAnalyzers, setSecurityAnalyzers] = React.useState<string[]>(
[],
);
const [settings, setSettings] = React.useState<Settings>({} as Settings);
const [agentIsRunning, setAgentIsRunning] = React.useState<boolean>(false);
const [loading, setLoading] = React.useState(true);
const { curAgentState } = useSelector((state: RootState) => state.agent);
useEffect(() => {
maybeMigrateSettings();
setSettings(getSettings());
}, []);
useEffect(() => {
const isRunning =
curAgentState === AgentState.RUNNING ||
curAgentState === AgentState.PAUSED ||
curAgentState === AgentState.AWAITING_USER_INPUT ||
curAgentState === AgentState.AWAITING_USER_CONFIRMATION;
setAgentIsRunning(isRunning);
}, [curAgentState]);
React.useEffect(() => {
(async () => {
try {
setModels(await fetchModels());
setAgents(await fetchAgents());
setSecurityAnalyzers(await fetchSecurityAnalyzers());
} catch (error) {
toast.error("settings", t(I18nKey.CONFIGURATION$ERROR_FETCH_MODELS));
} finally {
setLoading(false);
}
})();
}, []);
const handleModelChange = (model: string) => {
setSettings((prev) => ({
...prev,
LLM_MODEL: model,
}));
};
const handleAgentChange = (agent: string) => {
setSettings((prev) => ({ ...prev, AGENT: agent }));
};
const handleLanguageChange = (language: string) => {
const key =
AvailableLanguages.find((lang) => lang.label === language)?.value ||
language;
// The appropriate key is assigned when the user selects a language.
// Otherwise, their input is reflected in the inputValue field of the Autocomplete component.
setSettings((prev) => ({ ...prev, LANGUAGE: key }));
};
const handleAPIKeyChange = (key: string) => {
setSettings((prev) => ({ ...prev, LLM_API_KEY: key }));
};
const handleConfirmationModeChange = (confirmationMode: boolean) => {
setSettings((prev) => ({ ...prev, CONFIRMATION_MODE: confirmationMode }));
};
const handleSecurityAnalyzerChange = (securityAnalyzer: string) => {
setSettings((prev) => ({
...prev,
CONFIRMATION_MODE: true,
SECURITY_ANALYZER: securityAnalyzer,
}));
};
const handleResetSettings = () => {
setSettings(getDefaultSettings);
};
const handleSaveSettings = () => {
const updatedSettings = getSettingsDifference(settings);
saveSettings(settings);
i18next.changeLanguage(settings.LANGUAGE);
Session.startNewSession();
const sensitiveKeys = ["LLM_API_KEY"];
Object.entries(updatedSettings).forEach(([key, value]) => {
if (!sensitiveKeys.includes(key)) {
toast.settingsChanged(`${key} set to "${value}"`);
} else {
toast.settingsChanged(`${key} has been updated securely.`);
}
});
localStorage.setItem(
`API_KEY_${settings.LLM_MODEL || models[0]}`,
settings.LLM_API_KEY,
);
};
let subtitle = t(I18nKey.CONFIGURATION$MODAL_SUB_TITLE);
if (loading) {
subtitle = t(I18nKey.CONFIGURATION$AGENT_LOADING);
} else if (agentIsRunning) {
subtitle = t(I18nKey.CONFIGURATION$AGENT_RUNNING);
} else if (!settingsAreUpToDate()) {
subtitle = t(I18nKey.CONFIGURATION$SETTINGS_NEED_UPDATE_MESSAGE);
}
const saveIsDisabled = REQUIRED_SETTINGS.some(
(key) => !settings[key as keyof Settings],
);
return (
<BaseModal
isOpen={isOpen}
onOpenChange={onOpenChange}
title={t(I18nKey.CONFIGURATION$MODAL_TITLE)}
isDismissable={settingsAreUpToDate()}
subtitle={subtitle}
actions={[
{
label: t(I18nKey.CONFIGURATION$MODAL_SAVE_BUTTON_LABEL),
action: handleSaveSettings,
isDisabled: saveIsDisabled,
closeAfterAction: true,
className: "bg-primary rounded-lg",
},
{
label: t(I18nKey.CONFIGURATION$MODAL_RESET_BUTTON_LABEL),
action: handleResetSettings,
closeAfterAction: false,
className: "bg-neutral-500 rounded-lg",
},
{
label: t(I18nKey.CONFIGURATION$MODAL_CLOSE_BUTTON_LABEL),
action: () => {
setSettings(getSettings()); // reset settings from any changes
},
isDisabled: !settingsAreUpToDate(),
closeAfterAction: true,
className: "bg-rose-600 rounded-lg",
},
]}
>
{loading && <Spinner />}
{!loading && (
<SettingsForm
disabled={agentIsRunning}
settings={settings}
models={models}
agents={agents}
securityAnalyzers={securityAnalyzers}
onModelChange={handleModelChange}
onAgentChange={handleAgentChange}
onLanguageChange={handleLanguageChange}
onAPIKeyChange={handleAPIKeyChange}
onConfirmationModeChange={handleConfirmationModeChange}
onSecurityAnalyzerChange={handleSecurityAnalyzerChange}
/>
)}
</BaseModal>
);
}
export default SettingsModal;