Skip to content

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:

LevelValueSemanticsTypical Tools
low0Pure query, no side effectsls, 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
medium1Has side effects but reversiblewrite, 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
high2Irreversible or high-riskdelete_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
super99Irreversible and affects humans — approval is always forced regardless of the user thresholddial_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:

  1. Take the tool's base score (from the TOOL_BASE_SCORE dictionary; tools not listed are converted via _score_from_level(base_risk) to {low:10, medium:40, high:70, super:95})
  2. Run a set of lightweight regexes (_ARG_SCAN_PATTERNS, lazily compiled and cached) over all string values in kwargs (recursing to depth 4); a match accumulates bonus score
  3. 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:

RuleRegexBonus
aws-access-keyAKIA[0-9A-Z]{16}50
github-patghp_[A-Za-z0-9]{36}50
openai-keysk-(proj-)?[A-Za-z0-9_-]{20,}50
anthropic-keysk-ant-[A-Za-z0-9_-]{20,}50
google-api-keyAIza[0-9A-Za-z_-]{35}50
private-key-pemBEGIN [A-Z ]*PRIVATE KEY50
jwteyJ...{10,}\..{10,}\..{10,}40
password-assign(password|pwd|secret)[:=]...{6,}40
cn-mobile1[3-9]\d{9}30
cn-id-card\d{17}[\dXx]45
cn-bank-card\d{16,19}50
enc-tokenENC::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 ToolDenied

2.2 The User Threshold security_level ​

UserConfig.security_level (which mirrors DialogConfig.security_level, default "medium") determines the threshold:

User ChoiceThresholdMeaning
low0All tools (low+medium+high+super) prompt for approval
medium (default)1medium+high+super prompt for approval; low executes directly
high2Only 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:

  1. The _current_agent / _current_dialogue ContextVars in the security module
  2. The agent_id_var / dialogue_id_var in the observability module
  3. Broadcast (agent_id=0) + log WARNING + optional stack trace (FAMILIARS_DIAG_ACTOR_DRIFT enabled by default)

2.4 Environment Variable Switches ​

Environment VariableEffect
FAMILIARS_APPROVAL_DISABLED=1Globally short-circuits the approval gate — all tools execute directly (development/batch use only)
FAMILIARS_DIAG_ACTOR_DRIFT=0Disables 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 (including high); super still prompts. bind_actor() snapshots it from the config.json overlay into the _current_intense ContextVar at the start of every turn.
  • turbo_mode: skips approvals but keeps plan verification and ask_user_questions. Mutually exclusive with intense_mode.
  • operate_scope(): the operate() tool internally uses a context manager to set _in_operate=True; approving operate itself 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):

LayerEngineStrengthsLatency
Layer 1: gitleaksBinary gitleaks detect (external/gitleaks) or built-in regex fallbackHigh-confidence credentials/tokens/secrets (AWS keys, GitHub PATs, JWTs, PEM private keys…)Binary ~1-3s; regex <10ms
Layer 2: presidioMicrosoft 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-LLMCurrently disabled (no-op). The user chose not to run a local small model for detection. The symbol is retained for recipe override integrationLong-tail items such as project codenames, custom IDs, employer namesN/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:

  1. Naming: _name_secret() is purely mechanical naming (no LLM) — key = "<sanitized_rule>.<kind>" (e.g. github_pat.credential), with a context_hint (a partial excerpt; the original value is masked with <VALUE>)
  2. Encryption: crypto.encrypt_value(finding.value) → Fernet encryption
  3. 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 all value_sha256 values in SECRET.json, so the capture flow can skip already-stored values
  • known_hosts() → returns all hostnames that have stored credentials (parsed from fingerprints[*].source_url and cookie.domain)
  • iter_cookie_entries() → decrypts all cookie-scoped entries and returns them in a format that playwright.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 in workspace/SECRET.json.
  • salt: workspace/.salt, generated with os.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_key is derived once per process and reused.

4.2 Security Boundaries ​

  • Copying SECRET.json alone to someone else → cannot be decrypted (missing machine identity)
  • Cloning the whole machine but not copying .salt → cannot be decrypted
  • Copying both SECRET.json + .salt but 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 ​

python
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:

PlatformHardware IDMACHostname
macOSIOPlatformSerialNumber (read via ioreg)ifconfig en0socket.gethostname()
WindowsMachineGuid (HKLM\SOFTWARE\Microsoft\Cryptography, generated at OS install time, survives upgrades)psutil.net_if_addrs() first non-loopback, non-all-zero MACSame
Linux/etc/machine-id or /var/lib/dbus/machine-idpsutil.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 the device_id in auth.json matches the current get_device_id()
  • The legacy wallet key is silently ignored by load()

workspace/auth.json structure:

json
{
  "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-Token header or a ?token=... query parameter
  • verify_bridge_token(presented) performs a constant-time comparison using secrets.compare_digest
  • /health is the only endpoint exempt from the token
  • WebSocket connections validate ?token=... before accept(); 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 ​

  • TurnSecrets holds mapping: 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 overlaps
  • clear() 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 ​

DefenseLocationRole
mask_secretsBefore every text that enters the LLMgitleaks regexes + locale PII → [REDACTED-*]
scrub_textConversationSession.callPlaceholder replacement + call-scoped restoration
sensitive_detectorSensitive-information discovery within dialogueThree-layer detection → encrypted commit to SECRET.json
turn_secretsToolDispatcher dispatchENC:: token → plaintext, replaced only in-process
Approval gateEvery tool function entryRisk classification + user approval
Bridge TokenHTTP/WS middleware256-bit token blocks external calls
HTTPS/2Transport layerSelf-signed certificate prevents man-in-the-middle
HKDF + FernetDisk storageMachine-bound AES-128-CBC + HMAC

User-level documentation is in SECURITY.md at the repository root (11 chapters). For the security vulnerability reporting process, see SECURITY_REPORT.md.

Familiars · 多 Agent AI 桌面应用