Input Validation
Vocalance validates user input at two layers: at the point of entry (UI or service layer) and again on every ingestion from disk.
Settings Bounds
User-adjustable settings are declared as Pydantic fields with explicit
ge/le constraints:
class LLMConfig(BaseModel):
context_length: int = Field(default=2048, ge=512, le=32768)
max_tokens: int = Field(default=512, ge=64, le=4096)
class GridConfig(BaseModel):
default_rect_count: int = Field(default=9, ge=4, le=400)
Values outside these ranges are rejected by Pydantic at deserialisation time.
Which settings are user-writable is separately controlled by an allowlist of dot-path keys in vocalance/app/services/storage/user_configurable_settings.py:
ALLOWED_USER_SETTING_PATHS: FrozenSet[str] = frozenset({
"llm.context_length",
"llm.max_tokens",
"llm.selected_model_id",
"grid.default_rect_count",
"sound_recognizer.sound_threshold",
"sound_recognizer.ambient_threshold",
"vad.command_silent_chunks_for_end",
})
Any key in AppUserConfigDocument.overrides not present in this set is
silently discarded before the config is applied to the live GlobalAppConfig.
Every update is additionally dry-run validated (model_copy(deep=True) +
apply) before being persisted — a value that passes the allowlist but fails a
Pydantic constraint is still rejected.
Ingestion Guards
Custom commands and sound mappings are marked security-sensitive at the storage layer. These guards form part of the tampered local storage defences described in Tampered Local Storage.
_SECURITY_SENSITIVE_MODELS: FrozenSet[Type[StorageData]] = frozenset({
CommandsData,
SoundMappingsData,
})
A ValidationError or read error on either model causes the storage service
to return an empty safe default, record the file as corrupt, and publish a
StorageCorruptionWarningEvent. The UI surfaces a modal offering to delete
the corrupt file. A tampered or malformed file causes a safe reset — not a crash
and not partial execution.
Hotkey Allowlist
Custom commands map a spoken phrase to a hotkey. This is the highest-risk user-configurable input: an unvalidated value could trigger arbitrary key sequences or macro chains.
Validation runs at three independent points — UI submission, service-layer persistence, and storage ingestion on every load — using a single function in vocalance/app/config/hotkey_validation.py:
def is_valid_custom_hotkey(value: str) -> bool:
if not value or not value.strip():
return False
if any(c in value for c in (",", ";")):
return False
tokens = [t for t in value.replace(" ", "+").split("+") if t]
return bool(tokens) and all(is_allowed_key(t) for t in tokens)
def is_allowed_key(token: str) -> bool:
candidate = token.strip().lower()
return bool(
candidate and (
(len(candidate) == 1 and (candidate.isalnum() or candidate in SYMBOL_KEYS))
or candidate in MODIFIER_KEYS
or candidate in NAMED_KEYS
or FUNCTION_KEY_RE.fullmatch(candidate)
)
)
Two properties are enforced:
No sequences.
,and;are rejected outright — they could express multiple sequential actions (e.g.ctrl+c, ctrl+v). A custom command can only express a single simultaneous combination.Token allowlist. Each
+-separated token must be a modifier (ctrl,alt,shift,win), a named key (enter,tab, arrow keys, etc.), a single alphanumeric or symbol character, orf1–f24.
The underlying assumption is that a single simultaneous combination, however many keys it includes, has a bounded and predictable effect. A sequence does not.
Any command on disk that fails this check is dropped at load time and never reaches the automation service:
@model_validator(mode="after")
def filter_invalid_custom_commands(self) -> "CommandsData":
valid: Dict[str, AutomationCommand] = {}
for phrase, cmd in self.custom_commands.items():
if cmd.action_type == "hotkey" and is_valid_custom_hotkey(cmd.action_value):
valid[phrase] = cmd
else:
logger.warning(
"Security: dropping command %r on load — "
"action_type=%r action_value=%r failed validation",
phrase, cmd.action_type, cmd.action_value,
)
self.custom_commands = valid
return self
Commands with any action_type other than "hotkey" are also dropped. The
UI only creates hotkey commands; the storage layer enforces this independently.
Alias Sanitisation
Aliases substitute a spoken trigger with text typed via the clipboard. An alias value containing a newline, carriage return, or Unicode line-break equivalent could silently execute its preceding text if pasted into a terminal.
All alias keys and values are validated against vocalance/app/config/alias_validation.py:
DANGEROUS_CHAR_RE = re.compile(r"[\x00-\x1f\x7f\u0085\u2028\u2029]")
def is_valid_alias_text(value: str) -> bool:
return not DANGEROUS_CHAR_RE.search(value)
This blocks all ASCII control characters (\x00–\x1f: newline,
carriage return, tab, escape, and others), DEL (\x7f), Unicode NEL
(\u0085), and the Unicode line and paragraph separators (\u2028,
\u2029) — all of which many parsers treat as line terminators.
At ingestion, DictationAliasData enforces both the control-character check
and length limits (100 chars for keys, 2000 for values):
@field_validator("aliases")
@classmethod
def validate_aliases(cls, v: Dict[str, str]) -> Dict[str, str]:
for key, value in v.items():
if len(key) > MAX_ALIAS_KEY_LENGTH:
raise ValueError(...)
if len(value) > MAX_ALIAS_VALUE_LENGTH:
raise ValueError(...)
if not is_valid_alias_text(key) or not is_valid_alias_text(value):
raise ValueError(f"Alias '{key}' contains characters that are not permitted")
return v
A failing alias raises ValidationError; the storage service returns empty
defaults. Alias output is always inert printable text — it cannot trigger
execution in any context that treats control characters as line terminators.