feat(security): enable prompt injection mitigation by default for internal users via non-overridable env vars (#9612)

This commit is contained in:
dorien-koelemeijer
2026-06-10 10:13:29 +10:00
committed by GitHub
parent d396767be8
commit f52d717dce
6 changed files with 129 additions and 57 deletions
@@ -47,8 +47,6 @@ pub async fn run() -> Result<()> {
boot_marker("main entered");
crate::logging::setup_logging(Some("goosed"))?;
goose::security::set_security_defaults();
let settings = configuration::Settings::new()?;
let secret_key = std::env::var("GOOSE_SERVER__SECRET_KEY")
+28 -29
View File
@@ -10,30 +10,16 @@ use crate::conversation::message::{Message, ToolRequest};
use crate::permission::permission_judge::PermissionCheckResult;
use anyhow::Result;
use scanner::PromptInjectionScanner;
use std::env;
use std::sync::OnceLock;
use uuid::Uuid;
fn set_default_if_not_exist(config: &Config, key: &str, default_env: &str) {
if config.get_param::<bool>(key).is_ok() {
return;
}
if let Ok(parsed) = config.get_param::<bool>(default_env) {
let _ = config.set_param(key, parsed);
}
}
pub fn set_security_defaults() {
let config = Config::global();
set_default_if_not_exist(
config,
"SECURITY_PROMPT_ENABLED",
"DEFAULT_SECURITY_PROMPT_ENABLED",
);
set_default_if_not_exist(
config,
"SECURITY_COMMAND_CLASSIFIER_ENABLED",
"DEFAULT_SECURITY_COMMAND_CLASSIFIER_ENABLED",
);
pub(crate) fn get_override(env_key: &str) -> Option<bool> {
env::var(env_key).ok().and_then(|v| match v.as_str() {
"true" => Some(true),
"false" => Some(false),
_ => None,
})
}
pub struct SecurityManager {
@@ -52,15 +38,17 @@ pub struct SecurityResult {
impl SecurityManager {
pub fn new() -> Self {
set_security_defaults();
Self {
scanner: OnceLock::new(),
}
}
pub fn is_prompt_injection_detection_enabled(&self) -> bool {
let config = Config::global();
if let Some(overridden) = get_override("SECURITY_PROMPT_ENABLED_OVERRIDE") {
return overridden;
}
let config = Config::global();
config
.get_param::<bool>("SECURITY_PROMPT_ENABLED")
.unwrap_or(false)
@@ -73,9 +61,15 @@ impl SecurityManager {
.get_param::<bool>("SECURITY_PROMPT_CLASSIFIER_ENABLED")
.unwrap_or(false);
let command_enabled = config
.get_param::<bool>("SECURITY_COMMAND_CLASSIFIER_ENABLED")
.unwrap_or(false);
let command_enabled = if let Some(overridden) =
get_override("SECURITY_COMMAND_CLASSIFIER_ENABLED_OVERRIDE")
{
overridden
} else {
config
.get_param::<bool>("SECURITY_COMMAND_CLASSIFIER_ENABLED")
.unwrap_or(false)
};
prompt_enabled || command_enabled
}
@@ -95,9 +89,14 @@ impl SecurityManager {
let scanner = self.scanner.get_or_init(|| {
let config = Config::global();
let command_classifier_enabled = config
.get_param::<bool>("SECURITY_COMMAND_CLASSIFIER_ENABLED")
.unwrap_or(false);
let command_classifier_enabled =
if let Some(overridden) = get_override("SECURITY_COMMAND_CLASSIFIER_ENABLED_OVERRIDE") {
overridden
} else {
config
.get_param::<bool>("SECURITY_COMMAND_CLASSIFIER_ENABLED")
.unwrap_or(false)
};
let prompt_classifier_enabled = config
.get_param::<bool>("SECURITY_PROMPT_CLASSIFIER_ENABLED")
.unwrap_or(false);
+13 -3
View File
@@ -68,9 +68,19 @@ impl PromptInjectionScanner {
ClassifierType::Prompt => "PROMPT",
};
let enabled = config
.get_param::<bool>(&format!("SECURITY_{}_CLASSIFIER_ENABLED", prefix))
.unwrap_or(false);
let enabled = match classifier_type {
ClassifierType::Command => {
crate::security::get_override("SECURITY_COMMAND_CLASSIFIER_ENABLED_OVERRIDE")
.unwrap_or_else(|| {
config
.get_param::<bool>("SECURITY_COMMAND_CLASSIFIER_ENABLED")
.unwrap_or(false)
})
}
ClassifierType::Prompt => config
.get_param::<bool>("SECURITY_PROMPT_CLASSIFIER_ENABLED")
.unwrap_or(false),
};
if !enabled {
anyhow::bail!("{} classifier not enabled", prefix);
@@ -65,6 +65,15 @@ const i18n = defineMessages({
id: 'securityToggle.apiTokenDescription',
defaultMessage: 'Authentication token for the classification service',
},
overrideNotice: {
id: 'securityToggle.overrideNotice',
defaultMessage: 'This setting is managed by your organization and cannot be changed.',
},
warpNotice: {
id: 'securityToggle.warpNotice',
defaultMessage:
'Command injection detection works best when connected to WARP (required to reach the classification service).',
},
commandEndpointDescription: {
id: 'securityToggle.commandEndpointDescription',
defaultMessage: 'Enter the full URL for your command injection classification service',
@@ -175,6 +184,19 @@ export const SecurityToggle = () => {
const intl = useIntl();
const { config, upsert } = useConfig();
const promptEnabledOverride = window.appConfig?.get('SECURITY_PROMPT_ENABLED_OVERRIDE') as
| string
| undefined;
const commandClassifierOverride = window.appConfig?.get(
'SECURITY_COMMAND_CLASSIFIER_ENABLED_OVERRIDE'
) as string | undefined;
const isPromptOverridden =
promptEnabledOverride === 'true' || promptEnabledOverride === 'false';
const isCommandClassifierOverridden =
commandClassifierOverride === 'true' || commandClassifierOverride === 'false';
const promptOverrideValue = promptEnabledOverride === 'true';
const commandClassifierOverrideValue = commandClassifierOverride === 'true';
const modelMapping = useMemo(() => {
const mappingEnv = window.appConfig?.get('SECURITY_ML_MODEL_MAPPING') as string | undefined;
if (!mappingEnv) {
@@ -224,7 +246,9 @@ export const SecurityToggle = () => {
return Object.values(modelMapping).some((modelInfo) => modelInfo.model_type === 'command');
}, [modelMapping]);
const effectiveCommandClassifierEnabled = commandClassifierEnabled ?? false;
const effectiveCommandClassifierEnabled = isCommandClassifierOverridden
? commandClassifierOverrideValue
: (commandClassifierEnabled ?? false);
const effectiveModel = mlModel || availablePromptModels[0]?.value || '';
const [thresholdInput, setThresholdInput] = useState(configThreshold.toString());
const [endpointInput, setEndpointInput] = useState(mlEndpoint);
@@ -290,6 +314,8 @@ export const SecurityToggle = () => {
await upsert('SECURITY_COMMAND_CLASSIFIER_TOKEN', token, true); // true = secret
};
const effectiveEnabled = isPromptOverridden ? promptOverrideValue : enabled;
return (
<div className="space-y-4">
<div className="flex items-center justify-between py-2 px-2 hover:bg-background-secondary rounded-lg transition-all">
@@ -298,22 +324,32 @@ export const SecurityToggle = () => {
<p className="text-xs text-text-secondary max-w-md mt-[2px]">
{intl.formatMessage(i18n.promptInjectionDescription)}
</p>
{isPromptOverridden && (
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
{intl.formatMessage(i18n.overrideNotice)}
</p>
)}
</div>
<div className="flex items-center">
<Switch checked={enabled} onCheckedChange={handleToggle} variant="mono" />
<div className={`flex items-center ${isPromptOverridden ? 'opacity-40' : ''}`}>
<Switch
checked={isPromptOverridden ? promptOverrideValue : enabled}
onCheckedChange={handleToggle}
disabled={isPromptOverridden}
variant="mono"
/>
</div>
</div>
<div
className={`overflow-hidden transition-all duration-300 ease-in-out ${
enabled ? 'max-h-[1000px] opacity-100' : 'max-h-0 opacity-0'
effectiveEnabled ? 'max-h-[1000px] opacity-100' : 'max-h-0 opacity-0'
}`}
>
<div className="space-y-4 px-2 pb-2">
{/* Detection Threshold */}
<div className={enabled ? '' : 'opacity-50'}>
<div className={effectiveEnabled ? '' : 'opacity-50'}>
<label
className={`text-sm font-medium ${enabled ? 'text-text-primary' : 'text-text-secondary'}`}
className={`text-sm font-medium ${effectiveEnabled ? 'text-text-primary' : 'text-text-secondary'}`}
>
{intl.formatMessage(i18n.detectionThreshold)}
</label>
@@ -338,9 +374,9 @@ export const SecurityToggle = () => {
handleThresholdChange(value);
}
}}
disabled={!enabled}
disabled={!effectiveEnabled}
className={`w-24 px-2 py-1 text-sm border rounded ${
enabled
effectiveEnabled
? 'border-border-primary bg-background-primary text-text-primary'
: 'border-border-primary bg-background-secondary text-text-secondary cursor-not-allowed'
}`}
@@ -353,26 +389,40 @@ export const SecurityToggle = () => {
<div className="flex items-center justify-between py-2 hover:bg-background-secondary rounded-lg transition-all">
<div>
<h4
className={`text-sm font-medium ${enabled ? 'text-text-primary' : 'text-text-secondary'}`}
className={`text-sm font-medium ${effectiveEnabled ? 'text-text-primary' : 'text-text-secondary'}`}
>
{intl.formatMessage(i18n.enableCommandInjection)}
</h4>
<p className="text-xs text-text-secondary max-w-md mt-[2px]">
{intl.formatMessage(i18n.commandInjectionDescription)}
</p>
{isCommandClassifierOverridden && (
<>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
{intl.formatMessage(i18n.overrideNotice)}
</p>
{commandClassifierOverrideValue && (
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
{intl.formatMessage(i18n.warpNotice)}
</p>
)}
</>
)}
</div>
<div className="flex items-center">
<div
className={`flex items-center ${isCommandClassifierOverridden ? 'opacity-40' : ''}`}
>
<Switch
checked={effectiveCommandClassifierEnabled}
onCheckedChange={handleCommandClassifierToggle}
disabled={!enabled}
disabled={!effectiveEnabled || isCommandClassifierOverridden}
variant="mono"
/>
</div>
</div>
{hasCommandModel ? (
enabled &&
effectiveEnabled &&
effectiveCommandClassifierEnabled && (
<div className="text-sm text-gray-700 dark:text-gray-300 mt-2">
{intl.formatMessage(i18n.commandClassifierActive)}
@@ -381,12 +431,16 @@ export const SecurityToggle = () => {
) : (
<div
className={`overflow-hidden transition-all duration-300 ease-in-out ${
enabled && effectiveCommandClassifierEnabled
effectiveEnabled && effectiveCommandClassifierEnabled
? 'max-h-[32rem] opacity-100 mt-3'
: 'max-h-0 opacity-0'
}`}
>
<div className={enabled && effectiveCommandClassifierEnabled ? '' : 'opacity-50'}>
<div
className={
effectiveEnabled && effectiveCommandClassifierEnabled ? '' : 'opacity-50'
}
>
<ClassifierEndpointInputs
endpointValue={commandEndpointInput}
tokenValue={commandTokenInput}
@@ -394,7 +448,7 @@ export const SecurityToggle = () => {
onTokenChange={setCommandTokenInput}
onEndpointBlur={handleCommandEndpointChange}
onTokenBlur={handleCommandTokenChange}
disabled={!enabled || !effectiveCommandClassifierEnabled}
disabled={!effectiveEnabled || !effectiveCommandClassifierEnabled}
endpointPlaceholder="https://example.com/classify"
tokenPlaceholder="token..."
endpointLabel={intl.formatMessage(i18n.classificationEndpoint)}
@@ -412,7 +466,7 @@ export const SecurityToggle = () => {
<div className="flex items-center justify-between py-2 hover:bg-background-secondary rounded-lg transition-all">
<div>
<h4
className={`text-sm font-medium ${enabled ? 'text-text-primary' : 'text-text-secondary'}`}
className={`text-sm font-medium ${effectiveEnabled ? 'text-text-primary' : 'text-text-secondary'}`}
>
{intl.formatMessage(i18n.enablePromptInjectionMl)}
</h4>
@@ -424,7 +478,7 @@ export const SecurityToggle = () => {
<Switch
checked={mlEnabled}
onCheckedChange={handleMlToggle}
disabled={!enabled}
disabled={!effectiveEnabled}
variant="mono"
/>
</div>
@@ -433,15 +487,17 @@ export const SecurityToggle = () => {
{/* Configuration Section */}
<div
className={`overflow-hidden transition-all duration-300 ease-in-out ${
enabled && mlEnabled ? 'max-h-[32rem] opacity-100 mt-3' : 'max-h-0 opacity-0'
effectiveEnabled && mlEnabled
? 'max-h-[32rem] opacity-100 mt-3'
: 'max-h-0 opacity-0'
}`}
>
<div className={enabled && mlEnabled ? '' : 'opacity-50'}>
<div className={effectiveEnabled && mlEnabled ? '' : 'opacity-50'}>
{showModelDropdown ? (
<div className="space-y-3">
<div>
<label
className={`text-sm font-medium ${enabled && mlEnabled ? 'text-text-primary' : 'text-text-secondary'}`}
className={`text-sm font-medium ${effectiveEnabled && mlEnabled ? 'text-text-primary' : 'text-text-secondary'}`}
>
{intl.formatMessage(i18n.detectionModel)}
</label>
@@ -451,9 +507,9 @@ export const SecurityToggle = () => {
<select
value={effectiveModel}
onChange={(e) => handleModelChange(e.target.value)}
disabled={!enabled || !mlEnabled}
disabled={!effectiveEnabled || !mlEnabled}
className={`w-full px-3 py-2 text-sm border rounded ${
enabled && mlEnabled
effectiveEnabled && mlEnabled
? 'border-border-primary bg-background-primary text-text-primary'
: 'border-border-primary bg-background-secondary text-text-secondary cursor-not-allowed'
}`}
@@ -474,7 +530,7 @@ export const SecurityToggle = () => {
onTokenChange={setTokenInput}
onEndpointBlur={handleEndpointChange}
onTokenBlur={handleTokenChange}
disabled={!enabled || !mlEnabled}
disabled={!effectiveEnabled || !mlEnabled}
endpointPlaceholder="https://router.huggingface.co/hf-inference/models/protectai/deberta-v3-base-prompt-injection-v2"
tokenPlaceholder="hf_..."
endpointLabel={intl.formatMessage(i18n.classificationEndpoint)}
+6
View File
@@ -3731,6 +3731,9 @@
"securityToggle.mlTokenDescription": {
"defaultMessage": "Authentication token for the ML service (e.g., HuggingFace token)"
},
"securityToggle.overrideNotice": {
"defaultMessage": "This setting is managed by your organization and cannot be changed."
},
"securityToggle.promptInjectionDescription": {
"defaultMessage": "Detect and prevent potential prompt injection attacks"
},
@@ -3740,6 +3743,9 @@
"securityToggle.thresholdDescription": {
"defaultMessage": "Higher values are more strict (0.01 = very lenient, 1.0 = maximum strict)"
},
"securityToggle.warpNotice": {
"defaultMessage": "Command injection detection works best when connected to WARP (required to reach the classification service)."
},
"sessionHistory.cancel": {
"defaultMessage": "Cancel"
},
+3
View File
@@ -899,6 +899,9 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => {
recipeParameters: recipeParameters,
scheduledJobId: scheduledJobId,
SECURITY_ML_MODEL_MAPPING: process.env.SECURITY_ML_MODEL_MAPPING,
SECURITY_PROMPT_ENABLED_OVERRIDE: process.env.SECURITY_PROMPT_ENABLED_OVERRIDE,
SECURITY_COMMAND_CLASSIFIER_ENABLED_OVERRIDE:
process.env.SECURITY_COMMAND_CLASSIFIER_ENABLED_OVERRIDE,
}),
],
partition: 'persist:goose',