Security Model
Implementation details of Familiars' security architecture. Covers risk classification, the approval gate, the sensitive information detection pipeline, encryption primitives, device authentication, Bridge Token authentication, and sandbox permissions. Sources:
src/tools/security.py,src/tools/sensitive_detector.py,src/base/crypto.py,src/base/auth/,src/base/bridge_token.py,src/tools/turn_secrets.py,SECURITY.md.
1 · Risk Classification System
1.1 Four-Level Risk Buckets
src/tools/security.py defines four risk levels, each mapped to an integer:
| Level | Value | Semantics | Typical Tools |
|---|---|---|---|
low | 0 | Pure query, no side effects | ls, get_file_info, read_text_file, find_files, grep_files, list_archive, list_my_processes, screen_size, clipboard_get_type, network, host_status, wait, *_status/*_list |
medium | 1 | Has side effects but reversible | write, edit, compress, decompress, screenshot, monitor_start, browser_open, web_search, create_schedule, fetch_email_*, clipboard_paste_image, open_with_default_app, run_ffmpeg, run_pandoc, http_request, all generate_pdf/word/ppt/excel, all word_*/ppt_*/excel_* fine-grained builders |
high | 2 | Irreversible or high-risk | delete_file, execute_shell_script, execute_python_file, execute_node_file, run_pip, run_npm, run_git, open_app, kill_process, download, web_fetch, web_dive, move_or_rename, sqlite_query, ssh_exec, scp_upload, social_send, social_connect/disconnect, send_email, configure_email, generate_image/video, shut_down, lock_screen, operate, all mouse_*/keyboard_* (except mouse_position), all browser_click/type/eval_js/close_tab, clipboard_copy/paste/clear/copy_image/copy_files, start_screen_record, sign_pdf, hang_up, set_audio_routing, run_call_conversation |
super | 99 | Irreversible and affects humans — approval is always forced regardless of the user threshold | dial_number (real phone call) |
The super level is deliberately designed far from high (99 vs 2), so that the user threshold (which can at most be raised to high=2) can never override it. dial_number prompts for approval on every call — even with intense_mode=True, FAMILIARS_APPROVAL_DISABLED=1, and the current turn's whitelist, none of them take effect.
1.2 The TOOL_RISK Dictionary
TOOL_RISK: dict[str, str] is the single source of truth, mapping 150+ tool method names → risk level. Tools not listed default to low. wrap_class_methods(cls) iterates the class methods when a tool class is loaded and automatically wraps every method named in TOOL_RISK with the @requires_approval decorator.
1.3 Score-Based Dynamic Risk Escalation
In addition to the static classification, every tool call goes through _compute_effective_risk(tool_name, base_risk, kwargs) for dynamic escalation:
- Take the tool's base score (from the
TOOL_BASE_SCOREdictionary; tools not listed are converted via_score_from_level(base_risk)to{low:10, medium:40, high:70, super:95}) - Run a set of lightweight regexes (
_ARG_SCAN_PATTERNS, lazily compiled and cached) over all string values inkwargs(recursing to depth 4); a match accumulates bonus score - Map the total score:
[0,30)=low,[30,60)=medium,[60,90)=high,90+=super
Only escalation, never downgrade. For example, write(path, content) has a static base of 45 (medium); if content contains a bank account number (cn-bank-card bonus=50), the total is 95 → escalated to super.
Regex scan rules and bonus scores:
| Rule | Regex | Bonus |
|---|---|---|
aws-access-key | AKIA[0-9A-Z]{16} | 50 |
github-pat | ghp_[A-Za-z0-9]{36} | 50 |
openai-key | sk-(proj-)?[A-Za-z0-9_-]{20,} | 50 |
anthropic-key | sk-ant-[A-Za-z0-9_-]{20,} | 50 |
google-api-key | AIza[0-9A-Za-z_-]{35} | 50 |
private-key-pem | BEGIN [A-Z ]*PRIVATE KEY | 50 |
jwt | eyJ...{10,}\..{10,}\..{10,} | 40 |
password-assign | (password|pwd|secret)[:=]...{6,} | 40 |
cn-mobile | 1[3-9]\d{9} | 30 |
cn-id-card | \d{17}[\dXx] | 45 |
cn-bank-card | \d{16,19} | 50 |
enc-token | ENC:: | 25 |
2 · Approval Gate Mechanism
2.1 The @requires_approval Decorator
requires_approval(level=None) is a decorator factory. Execution flow for a wrapped function:
tool call
│
├─ risk == "super" → go directly to _ask_user("super") → reject → raise ToolDenied
│
├─ demo gate installed (anonymous demo bridge) → _demo_gate() returns a synthetic response or falls through
│
├─ globally disabled / intense_mode / turbo_mode / operate_scope → execute directly
│
├─ skill-scope authorized → execute directly
│
├─ current-turn whitelist ("always allow this turn") → execute directly
│
└─ normal path:
effective_risk = _compute_effective_risk(...)
if effective_risk < user_threshold → execute
else → _ask_user(effective_risk) → approved → execute / rejected → raise ToolDenied2.2 The User Threshold security_level
UserConfig.security_level (which mirrors DialogConfig.security_level, default "medium") determines the threshold:
| User Choice | Threshold | Meaning |
|---|---|---|
low | 0 | All tools (low+medium+high+super) prompt for approval |
medium (default) | 1 | medium+high+super prompt for approval; low executes directly |
high | 2 | Only high+super prompt for approval (most permissive) |
Internally, _user_threshold() uses min(threshold, _LEVELS["high"]) to ensure the threshold never exceeds high — super is reserved by the framework and cannot be overridden by any user setting.
2.3 Approval Request Channel
_ask_user() delivers an ApprovalRequest to the in-process CommHub via CommClient.request_approval(), then waits indefinitely for the user's response (timeout=None). The early FAMILIARS_APPROVAL_TIMEOUT has been removed — a timeout no longer rejects by default (to avoid repeating the ~10h word_new stall incident).
_resolve_actor_for_approval() uses a three-level fallback to find the agent/dialogue id:
- The
_current_agent/_current_dialogueContextVars in the security module - The
agent_id_var/dialogue_id_varin the observability module - Broadcast (agent_id=0) + log WARNING + optional stack trace (
FAMILIARS_DIAG_ACTOR_DRIFTenabled by default)
2.4 Environment Variable Switches
| Environment Variable | Effect |
|---|---|
FAMILIARS_APPROVAL_DISABLED=1 | Globally short-circuits the approval gate — all tools execute directly (development/batch use only) |
FAMILIARS_DIAG_ACTOR_DRIFT=0 | Disables stack traces for misrouted approvals (production noise reduction) |
2.5 intense_mode / turbo_mode
intense_mode: can be temporarily set to True globally / per-agent / per WeChat/QQ trigger → all tools execute directly (includinghigh);superstill prompts.bind_actor()snapshots it from theconfig.jsonoverlay into the_current_intenseContextVar at the start of every turn.turbo_mode: skips approvals but keeps plan verification andask_user_questions. Mutually exclusive withintense_mode.operate_scope(): theoperate()tool internally uses a context manager to set_in_operate=True; approvingoperateitself authorizes all its internal sub-tool calls.
2.6 skill-scope Authorization
authorize_skill(agent_id, skill_id) records a one-time user approval. Afterwards, all of that skill's steps (identified via the _current_skill_id ContextVar) bypass approval. ensure_skill_approved() is called once when a skill starts, showing the step list + aggregate risk (max(step.tool_binding.risk)). revoke_skill_authorization() is called when authorization is revoked from the settings UI or when a skill is deleted.
2.7 Current-Turn Whitelist
allow_tool_for_turn(agent_id, tool_name) is called when the user clicks "Always allow for this turn". bind_actor() clears the agent's whitelist at the start of every turn (_always_allow.pop(agent_id)), ensuring the whitelist never leaks across turns.
2.8 The ToolDenied Exception
ToolDenied(PermissionError) — inherits from PermissionError, so the tool loop sees a clean exception class, formatted back to the model as a role:tool content string.
3 · Sensitive Information Detection Pipeline
3.1 Three-Layer Detection Architecture
SensitiveDetector in src/tools/sensitive_detector.py runs three detection layers in order; the findings of each layer are unioned and deduplicated (_dedup prefers earlier layer + higher score):
| Layer | Engine | Strengths | Latency |
|---|---|---|---|
| Layer 1: gitleaks | Binary gitleaks detect (external/gitleaks) or built-in regex fallback | High-confidence credentials/tokens/secrets (AWS keys, GitHub PATs, JWTs, PEM private keys…) | Binary ~1-3s; regex <10ms |
| Layer 2: presidio | Microsoft Presidio + spaCy NER (en+zh bilingual) | PII entities (PERSON, EMAIL_ADDRESS, PHONE_NUMBER, CREDIT_CARD, IBAN_CODE, US_SSN…) | First run 30s-10min (spaCy cold start); hot path ~50-200ms |
| Layer 3: mini-LLM | Currently disabled (no-op). The user chose not to run a local small model for detection. The symbol is retained for recipe override integration | Long-tail items such as project codenames, custom IDs, employer names | N/A |
Layer 1 fallback regexes: _GITLEAKS_FALLBACK contains 12 regexes (aws-access-key, aws-secret-key, github-pat, github-fine-grained, openai-key, anthropic-key, slack-token, google-api-key, private-key-pem, jwt, generic-bearer, password-assignment).
Chinese locale PII regexes: _LOCALE_PII_FALLBACK contains 3 entries (cn-mobile, cn-id-card-18, cn-bank-card), each with validators (Luhn check for bank account numbers, shape check for 18-digit ID cards) to suppress false positives.
Layer 2 multilingual engines: it first tries to load zh_core_web_lg/md/sm/trf + en_core_web_lg/md/sm. If no zh model is available it falls back to en-only (it does not use the AnalyzerEngine() default constructor — that hard-codes triggering the auto-download of en_core_web_lg). _has_cjk() inspects the input text to decide the default language. spaCy auto-download is globally blocked by _disable_spacy_auto_download() (spacy.cli.download is replaced with a no-op).
3.2 mask_secrets (Fast Redaction)
mask_secrets(text) is a pure-regex fast path — it only runs the Layer 1 gitleaks fallback + locale PII, and never touches Presidio or mini-LLM. Used for:
- memorizer prompt input
- audit log excerpts
- shadow dumps
- any text before it goes to a cloud LLM (
ConversationSession.call(scrub=True))
Matched spans are replaced with [REDACTED-<rule>], e.g. [REDACTED-github-pat], [REDACTED-cn-mobile].
3.3 scrub_text / unscrub_text (Placeholder Replacement Pipeline)
scrub_text(text) → (scrubbed_text, [(placeholder, original_value), ...]) replaces sensitive spans with «SECRET_N» placeholders and returns the mapping. unscrub_text restores the original text from placeholders. The mapping is call-scoped (returned to the caller for restoration inside LLM replies). The mini-LLM deliberately does not run on this path — scrub sits on the LLM hot path and must be synchronous and cheap.
3.4 The Commit Pipeline
After a finding is detected, SensitiveDetector.commit() encrypts each finding to workspace/SECRET.json on disk via MemoryWriter._save_secrets_store:
- Naming:
_name_secret()is purely mechanical naming (no LLM) —key = "<sanitized_rule>.<kind>"(e.g.github_pat.credential), with acontext_hint(a partial excerpt; the original value is masked with<VALUE>) - Encryption:
crypto.encrypt_value(finding.value)→ Fernet encryption - Storage:
store[key] = {type, aliases, secrets: {field_name: ciphertext}, context_hint, fingerprints: {field_name: {value_sha256, scope, source_url, cookie...}}}
commit_with_namings() accepts precomputed namings (to avoid paying for the naming LLM twice) and supports an extras parameter written into fingerprints (browser credential capture uses it to record cookie scope/domain/name/path).
revert_commit() rolls back: it deletes the field, and if the entry becomes empty it deletes the key. Used for the Case-B undo toast.
3.5 Helper Queries
known_fingerprints()→ returns allvalue_sha256values in SECRET.json, so the capture flow can skip already-stored valuesknown_hosts()→ returns all hostnames that have stored credentials (parsed fromfingerprints[*].source_urlandcookie.domain)iter_cookie_entries()→ decrypts all cookie-scoped entries and returns them in a format thatplaywright.context.add_cookies()can consume directly
4 · Encryption Primitives
4.1 HKDF Key Derivation
src/base/crypto.py implements machine-bound symmetric encryption:
hostname + "|" + username + "|" + salt_bytes
→ HKDF-SHA256(salt=workspace/.salt, info=b"familiars-secrets-v1", length=32)
→ 32-byte raw key
→ base64url encode
→ Fernet key- HKDF info string:
b"familiars-secrets-v1"— a versioned crypto primitive. Rotating it permanently invalidates all ciphertexts inworkspace/SECRET.json. - salt:
workspace/.salt, generated withos.urandom(32)and persisted on first use. - Machine identity:
platform.node()+os.getenv('USER', os.getenv('USERNAME', 'unknown')). - Fernet: AES-128-CBC + HMAC-SHA256; every encryption carries its own IV + timestamp + HMAC.
- Key cache:
_cached_keyis derived once per process and reused.
4.2 Security Boundaries
- Copying
SECRET.jsonalone to someone else → cannot be decrypted (missing machine identity) - Cloning the whole machine but not copying
.salt→ cannot be decrypted - Copying both
SECRET.json+.saltbut the hostname/username changed → cannot be decrypted - Reinstalling the OS / changing the username / switching computers → even you cannot decrypt it; back up in advance
4.3 encrypt_value / decrypt_value
from src.base.crypto import encrypt_value, decrypt_value
ciphertext = encrypt_value("my_github_password") # → "gAAAAAB..."
plaintext = decrypt_value(ciphertext) # → "my_github_password"decrypt_value raises cryptography.fernet.InvalidToken if the ciphertext is corrupted, the key does not match, or the TTL has expired (no TTL by default).
5 · Device Authentication
5.1 Device Fingerprint Generation
src/base/auth/device.py:get_device_id() generates a stable device fingerprint across platforms:
| Platform | Hardware ID | MAC | Hostname |
|---|---|---|---|
| macOS | IOPlatformSerialNumber (read via ioreg) | ifconfig en0 | socket.gethostname() |
| Windows | MachineGuid (HKLM\SOFTWARE\Microsoft\Cryptography, generated at OS install time, survives upgrades) | psutil.net_if_addrs() first non-loopback, non-all-zero MAC | Same |
| Linux | /etc/machine-id or /var/lib/dbus/machine-id | psutil.net_if_addrs() | Same |
All parts are joined with "|" → SHA-256, take the first 16 hex chars → dev_<16hex>.
When all hardware queries fail, it falls back to hostname alone (an explicit trade-off: two machines sharing the default DESKTOP-XYZ name may collide, but it never raises on an adversarial machine).
5.2 AuthManager
The AuthManager singleton (src/base/auth/manager.py) manages the local user identity:
- Storage:
workspace/auth.json(currently plaintext JSON) register(nickname="")→ creates an identity on first use:user_id = "u_" + sha256(device_id)[:12],device_token = "dt_" + secrets.token_hex(16)login()→ verifies that thedevice_idinauth.jsonmatches the currentget_device_id()- The legacy
walletkey is silently ignored byload()
workspace/auth.json structure:
{
"identity": {
"user_id": "u_a1b2c3...",
"device_id": "dev_abcdef...",
"device_token": "dt_0123456789abcdef...",
"created_at": "2026-08-29T...",
"nickname": ""
}
}6 · Bridge Token Authentication
6.1 Generation and Storage
src/base/bridge_token.py: on first startup it generates a 256-bit token (secrets.token_hex(32)), writes it to workspace/.bridge_token, and attempts chmod 0o600 (best-effort on Windows, ignoring OSError). Cached in-process via _cached.
6.2 Validation
- Every HTTP request must carry the
X-Bridge-Tokenheader or a?token=...query parameter verify_bridge_token(presented)performs a constant-time comparison usingsecrets.compare_digest/healthis the only endpoint exempt from the token- WebSocket connections validate
?token=...beforeaccept(); a missing or wrong token → rejected with 4001 (avoids triggering a 5GB VRAM TTS load)
6.3 Security Boundaries
An arbitrary tab in the browser:
- cannot read
workspace/.bridge_token(it is not under any web-serving root) - cannot pass the self-signed certificate whitelist (
workspace/.bridge.crt)
Remaining risk: a local process that can read the workspace directory (an IDE, a sync drive, a backup client, a malicious extension) can read .bridge_token → impersonate the Electron frontend and call arbitrary APIs. Mitigation: never git add .bridge_token; do not put workspace/ in a sync-drive directory.
6.4 HTTPS Transport
The bridge uses hypercorn + HTTPS/2 + a self-signed certificate by default (workspace/.bridge.crt + .bridge.key, auto-generated on first startup). FAMILIARS_HTTP_INSECURE=1 falls back to plaintext HTTP + uvicorn (diagnostic/fallback path).
7 · Turn Secrets (Last-Mile Replacement)
src/tools/turn_secrets.py solves the problem of "the model needs to call get_user_secret to obtain a plaintext value, but the plaintext must not enter the model's context":
7.1 Lifecycle
new turn → begin_turn(agent_id, dialogue_id, turn_id)
model calls get_user_secret(name) → register(ciphertext, plaintext) → returns "ENC::gAAAA..." to the model
model calls foo(token="ENC::gAAAA...") → substitute_args → replaced with plaintext → foo(plaintext)
turn ends → end_turn() → registry cleared (values overwritten, then refs dropped)7.2 Implementation
TurnSecretsholdsmapping: dict[str, str](token → plaintext)CIPHERTEXT_PREFIX = "ENC::"— all registered tokens carry this prefix, letting the dispatcher quickly decide "no secret this turn → skip the arg walk"- Keyed by a ContextVar of
(agent_id, dialogue_id, turn_id), so concurrent generations do not cross wires substitute_in(value, mapping)recursively replaces ciphertext tokens in lists/dicts/strings, matching longest-first to avoid partial overlapsclear()first overwrites all values with""then drops them — a best-effort scrub
8 · Sandbox Permissions
8.1 PERMANENT_DENY
The PERMANENT_DENY list in src/tools/security.py contains true escape primitives (ctypes/pickle/importlib, etc.) that can never be requested.
8.2 Requestable Permissions
Modules such as os/subprocess/requests become available after being declared via capabilities.extra_modules + per-(recipe, agent) approval. ast_guard.py and worker.py mirror the DENY list.
8.3 Process Gate
kill_process only allows killing PIDs in the _spawned_pids set (processes the agent itself started via open_app). Chrome that the user opened themselves → rejected. Prevents an agent from hallucinating and killing the wrong process.
9 · Data-to-Cloud Defense Summary
| Defense | Location | Role |
|---|---|---|
| mask_secrets | Before every text that enters the LLM | gitleaks regexes + locale PII → [REDACTED-*] |
| scrub_text | ConversationSession.call | Placeholder replacement + call-scoped restoration |
| sensitive_detector | Sensitive-information discovery within dialogue | Three-layer detection → encrypted commit to SECRET.json |
| turn_secrets | ToolDispatcher dispatch | ENC:: token → plaintext, replaced only in-process |
| Approval gate | Every tool function entry | Risk classification + user approval |
| Bridge Token | HTTP/WS middleware | 256-bit token blocks external calls |
| HTTPS/2 | Transport layer | Self-signed certificate prevents man-in-the-middle |
| HKDF + Fernet | Disk storage | Machine-bound AES-128-CBC + HMAC |
User-level documentation is in
SECURITY.mdat the repository root (11 chapters). For the security vulnerability reporting process, seeSECURITY_REPORT.md.
