Aartiq
Aartiq™
Download
Security Model

Defense-in-Depth Security

Aartiq uses a defense-in-depth model with six layers: visual sandbox, syntactic firewall, human-in-the-loop authorization, directory allowlist, OS-level sandboxing, and capability-scoped execution — but only two of them are enforcement boundaries (the sandbox and approval, applied by the OS); the other four reduce blast radius without being boundaries. Source implementations: src/lib/Security.ts, src/lib/SecurityValidator.js, src/core/command-validator.js, src/core/directory-allowlist.js, src/core/sandbox-executor.js

Philosophy

We did not set out to build a fortress. We set out to build a browser that can act on your behalf without becoming a liability — so that when the model is wrong, the damage stops at a boundary it cannot cross. Every layer below is a deliberate “no”: no raw page HTML in the model's context, no unverified command, no unsandboxed fallback, no silent yes. Security here is not a toggle you flip; it is the shape of the thing. The claims on this page are stated plainly, with their limits, because a security claim you cannot disprove is not a claim — it is a wish.

6

Security Layers

2

Enforcement Boundaries (OS-applied), of six layers

600K

PBKDF2 Key-Derivation Iterations

The regex blocklist in SecurityValidator.js is documented as a fast first-pass reject layer only — not the primary defense. Primary enforcement continues through the remaining layers: the risk-tiered permission store (checkShellPermission), the capability controller's ticket-based approval (capability-controller.js), and the fail-closed OS sandbox (sandbox-executor.js). The six-layer model cited below reflects this defense-in-depth design.

Architecture

The Six Layers

1

Visual Sandbox

The AI perceives web pages as sanitized extractions of the rendered page — DOM text through the accessibility snapshot and the DOM-extraction pipeline, plus screenshots + OCR for image commands — never raw, unprocessed HTML. This significantly reduces DOM-based manipulation attacks, but it is a mitigation, not an absolute guarantee.

How It Works

  • Most commands read the page as text: navigation, search and page reads go through the accessibility snapshot and the DOM-extraction pipeline (src/lib/web-extractor.js, Readability-based), with extraction after load at src/lib/mcp-browser-server.js:1395-1435. Screenshots (Electron webContents.capturePage — src/main/handlers/browser-handlers.js) and Tesseract.js OCR (src/lib/tesseract-service.js) serve the image commands (OCR_SCREEN and vision reads). The AI never runs inside the page's JavaScript realm.
  • SecureDOMReader (src/components/ai/SecureDOMReader.ts) provides a text fallback path. It blocks script/style/iframe/object/embed/form/input/button tags and nav/footer/header/modal/overlay/ads classes before text extraction.
  • PII redaction: emails, phone numbers, card numbers, bearer tokens, session IDs, and password/api-key assignments are replaced with [REDACTED] placeholders before content reaches the model.
  • SecureDOMParser (src/lib/Security.ts) runs the extracted content against shell-primitive, encoding, and injection pattern groups, decodes base64/hex payloads, and rewrites dangerous matches to [BLOCKED: LAYER].
  • AI Fortress masks API keys and secrets before content reaches the LLM (src/lib/Security.ts, src/components/AIChatSidebar.tsx).
  • The AI context is explicitly built as read-only: the model cannot modify the DOM; interaction is limited to approved click/fill commands (FIND_AND_CLICK / CLICK_ELEMENT).
  • Source files: src/lib/Security.ts, src/lib/html-sanitizer.js, src/components/ai/SecureDOMReader.ts

Benefits

  • Significantly reduces prompt-injection via DOM manipulation — hidden, scripted, or style-obfuscated content is stripped before it reaches the model
  • Page JavaScript cannot directly invoke the AI's execution layer (Electron context isolation + no DOM-write access); scripts are stripped from AI-visible content
  • Hidden elements and blocked tags/classes never appear in AI-visible content
  • Malicious scripts, event handlers (on*=), javascript:, data:, vbscript:, iframe/embed/object are removed from the AI's reading path
What this does NOT guarantee
  • Visual and sanitized extraction reduces the attack surface for certain DOM-based prompt injection techniques; it does NOT prevent prompt injection entirely.
  • OCR processes visible text on rendered pages, but visible text can itself contain adversarial instructions. OCR does not distinguish legitimate content from attacker-injected instructions.
  • Visual layout analysis and OCR cannot guarantee semantic immunity against jailbreaks or instruction overrides rendered into the viewport.
2

Syntactic Firewall

Every command is analyzed for dangerous patterns before execution.

How It Works

  • Commands are scanned for destructive shell primitives and blocked commands (rm, sudo, su, passwd, chgrp, dd if=, mkfs, fork-bomb, command substitution)
  • Encoded payloads and obfuscation (hex, base64, HTML entities) are decoded via extractBase64Strings and re-checked against injection patterns
  • Jailbreak patterns ('ignore all previous instructions', etc.) are blocked before content reaches the model
  • Network-triggering commands (curl, wget) are not blocked here — the risk-tier classifier places them at medium (asked every time, Allow Always withheld for network-capable binaries — src/lib/shell-command-tiers.js), and the OS sandbox denies network by default
  • This layer is explicitly documented as a fast first-pass reject — not sufficient on its own (SecurityValidator.js header)
  • Source files: src/lib/SecurityValidator.js, src/lib/Security.ts, src/core/command-validator.js

Benefits

  • Stops known attack patterns at the gate
  • Prevents accidental destructive commands
  • Provides logging for security audits
  • Custom rules can be added by administrators
Blocked Patterns
rmRejected outright — BLOCKED_COMMANDS, not approval-gated
sudo / su / passwd / chgrpRejected outright — privilege or account change
dd if=Direct disk write
:(){ :|:& };:Fork bomb
$( ... )Command substitution
\x.. hex / chmod 777Encoded payload / permissive mode
Monitored Patterns
curl / wgetMedium tier — approval every time, no Allow Always; sandbox denies network
chmod / chownPermission change (high tier — explicit confirmation)
kill / shutdown / mountProcess/system change (high tier — explicit confirmation)
What this does NOT guarantee
  • The Syntactic Firewall is a fast first-pass heuristic, NOT a fundamental security boundary.
  • Regexes and token inspection can be bypassed by creative command construction, aliasing, encoding variations, or multi-step execution chains.
  • SecurityValidator.js does not guarantee that non-blocked commands are safe — the OS sandbox (Seatbelt / bubblewrap / AppContainer) and human approval are the primary security boundaries.
3

Human-in-the-Loop

Critical actions require explicit human approval before execution.

How It Works

  • AI generates a command; the parser assigns a risk field (default medium).
  • checkShellPermission() classifies low/medium/high/critical and checks the PermissionStore allowlist; with no store it denies (fail-closed) — src/core/command-validator.js:77-133.
  • Low risk: auto-runs only if autoApproveLowRisk is on (default off); otherwise a lightweight approval.
  • QR + 6-digit PIN covers remote shell commands and high-risk actions: power actions from a paired device — src/main/handlers/sync-handlers.js; desktop AI-initiated high-risk actions, where the desktop generates a QR encoding aartiq://approve?id=<token>&pin=<6-digit> and the paired mobile must return the PIN (the Approve button stays disabled until mobileApproved && pinVerified) — src/components/ai/ClickPermissionModal.tsx:235-302; high-risk MCP tool calls — src/lib/mcp-browser-server.js:157-168; and remote-origin shell execution, which is registered with origin policy { local: 'never', remote: 'always' } and strictly requires single-use, input-hash-bound ticket redemption before execution — src/main/handlers/sync-handlers.js:231-270.
  • Native platform approval dialog: dialog buttons are accurately labeled ['Deny', 'Approve'], removing misleading Touch ID text from standard message boxes. Real native biometric verification is implemented: macOS LocalAuthentication (LAPolicy.deviceOwnerAuthentication Touch ID or Mac password), Windows Hello (UserConsentVerifier / WebAuthn), and Linux polkit (BiometricAuthManager). The requireBiometricPerSession / requireBiometricEveryTime flags are strictly enforced and fail closed (deny) if unsupported or cancelled — src/main/handlers/native-approval-manager.js.
  • Master PIN (PBKDF2-SHA256 with 600,000 rounds; PINs created at the earlier 100,000-round cost keep verifying and are re-hashed to 600,000 on first successful unlock) is stored securely in Native OS Keychains (Apple Keychain, Windows Credential Manager DPAPI, Linux Secret Service) with a 5-attempt lockout, providing hardware-backed credential protection for remote and high-risk approvals — src/lib/MasterPINService.ts.
  • The renderer only enables Approve when both mobileApproved and pinVerified are true (or the biometric verification succeeds) — src/components/ai/ClickPermissionModal.tsx:247-302.
  • Critical risk is denied at the gate — checkShellPermission returns false for critical, src/core/command-validator.js:87-90 — and a human decision at that point is a plain Allow / Deny through the shell permission bridge (src/main/handlers/utils.js:222-229), not a ticket. Single-use, input-hash-bound tickets cover MCP high-risk tool calls and approve-style capability actions — src/core/capability-controller.js:29-100, src/core/approval-ticket-manager.js:139-278, src/lib/approval-gate.js:53-147.
  • Command only executes after explicit approval; timeouts and missing renderers resolve to deny — src/core/shell-permission-bridge.js:42-71.
  • Permission writes that arrive over the native macOS / CLI bridge are routed through src/lib/native-bridge-permission-routes.js into the same PermissionStore the gate reads, so a grant made over the bridge reaches checkShellPermission without a restart, an invalid access level fails closed, and the write is appended to the audit trail
  • Source files: src/core/command-validator.js, src/lib/permission-store.js, src/main/handlers/sync-handlers.js, src/main/handlers/utils.js, src/main/handlers/native-approval-manager.js, src/components/ai/ClickPermissionModal.tsx, src/core/capability-controller.js, src/lib/MasterPINService.ts

Benefits

  • No automated execution of destructive commands
  • QR approval ensures physical presence
  • Mobile app confirms identity via Dual-Gate Permission Relay (Master PIN + Android Screen Lock)
  • Real native biometric verification (Touch ID / Windows Hello / polkit)
  • Master PIN stored securely in Native OS Keychains

Approval — AI browser actions

Low Risk
Auto / Shift+Tab

Read-only actions, navigation, volume changes. Auto-run only if autoApproveLowRisk is enabled (default false); otherwise a quick approval.

• Taking screenshots

• Navigating to URLs

• Adjusting volume

Medium Risk
Approval dialog

Actions that modify browser state or open apps. The dialog offers Allow Once / Always Allow / Deny (labels at src/components/ai/ClickPermissionModal.tsx:294-302); a shell command in this band instead routes through the shell permission bridge (src/core/shell-permission-bridge.js) and the choice persists via the permission store.

• Filling forms

• Clicking buttons

• Opening applications

High Risk
QR + PIN (paired mobile)

The desktop dialog cannot be approved without the paired mobile scanning the QR and returning the 6-digit PIN — mobileApproved && pinVerified gate the Approve button — src/components/ai/ClickPermissionModal.tsx:235-302. The QR carries a single-use token. High-risk MCP tool calls take the same QR route (src/lib/mcp-browser-server.js:158).

• Shell command execution

• External app automation

• File modifications

Critical Risk
Never approved

Always denied by checkShellPermission (src/core/command-validator.js:87-90); what follows is a plain Allow / Deny prompt (src/main/handlers/utils.js:222-229), not a ticket. No capability assigns 'critical' except remote-origin shell escalation (medium→high, high→critical — src/main/handlers/sync-handlers.js:231-238).

• Destructive / irreversible operations

• Privilege escalation

4

Directory Allowlist

AI file access is restricted to explicitly approved directories with fine-grained read/write permissions. This is a policy layer; the enforcement boundary is the OS sandbox.

How It Works

  • Each directory in the allowlist specifies an access level (Read Only or Read & Write) and recursive flag (src/lib/permission-store.js)
  • Default allowlist is narrowed to a single dedicated app workspace directory (~/.aartiq/sandbox-workspace) plus /tmp. No personal profile folders (home, Desktop, Documents, Downloads) are granted by default.
  • Sensitive path deny list: ~/.ssh, ~/.gnupg, ~/.aws, ~/.config/gcloud, ~/.azure, ~/.kube, browser profiles, password managers, keychains, shell history, and .env files are unconditionally denied, even if a user allows their parent directory.
  • Path canonicalization resolves symlinks via fs.realpathSync before checking against allowlists and deny lists — symlink traversal is strictly blocked (src/core/directory-allowlist.js)
  • Deny rules are enforced in OS-level sandbox profiles too: Seatbelt deny rules on macOS, bubblewrap tmpfs mask mounts on Linux, and AppContainer ACLs on Windows.
  • Just-in-time permission prompts request approval before accessing new directories, defaulting to read-only with the full resolved path shown.
  • Batched multi-directory approval allows granting access to multiple paths at once
  • File management operations (move, copy, open, print) are routed around the shell sandbox
  • Read/write separation: a read grant must never allow deleting/overwriting — enforced in isPathAllowed() for both read and write operations
  • Source files: src/core/directory-allowlist.js, src/lib/permission-store.js, src/core/sandbox.js, src/main/handlers/permission-handlers.js

Benefits

  • Scopes AI file access to an explicit allowlist — any path outside it is denied with a structured reason
  • Default access narrowed to dedicated app workspace (~/.aartiq/sandbox-workspace) + temp directory, protecting personal files
  • Sensitive credential directories (~/.ssh, ~/.aws, keychains, .env) strictly denied across both app logic and OS sandbox profiles
  • Symlink traversal attacks are blocked via realpath resolution
  • Read-only entries never receive write access — enforced in the sandbox profile (macOS/Linux) and by isPathAllowed() on all platforms
  • Audit trail of all directory access grants with timestamps (aartiq-audit.jsonl)
What this does NOT guarantee
  • TOCTOU races: the path is checked at validation time; the filesystem may change before the operation executes
  • Hard links: a hard link inside the allowlist to a file outside it bypasses path-based checks
  • Bind mounts / mount namespaces: an attacker with mount privileges can remap filesystem views
  • Permission changes after authorization: a granted path may later have its permissions widened
  • Filesystem namespaces: Linux mount namespaces can present different filesystem hierarchies
  • Helper processes: a sandboxed command that spawns an allowed helper (e.g. python) may escape the allowlist
  • Alternate APIs: some operations (archive extraction, memory-mapped files, certain IPC) may bypass the checked path
  • Platform-specific filesystem semantics: Windows reparse points, macOS firmlinks, etc. may behave differently
  • The real enforcement boundary is the OS sandbox (Seatbelt / bubblewrap / AppContainer), not the allowlist check alone
5

OS-Level Sandboxing

Shell commands execute inside platform-specific OS sandboxes that enforce process, filesystem, and network boundaries. Execution is FAIL-CLOSED: if the sandbox cannot be built and verified, the command is never run — there is no automatic fallback to unsandboxed execution.

How It Works

  • macOS: Seatbelt (sandbox-exec) with a `(deny default)` baseline — an operation class the profile does not grant is denied — over which (deny file-read*) and (deny file-write*) re-allow only system paths + allowlisted directories + workspace, (deny network*), (deny system-socket) to block AF_UNIX IPC, (deny signal) confined to self/children, (deny file-map-executable) mirroring the exec allowlist, and (deny file-write-mount file-write-umount); three plumbing grants (sysctl-read, mach-lookup, file-ioctl) keep node/python/shell working
  • The Seatbelt profile is written to a temp file and validated with a pre-flight `sandbox-exec -f <profile> /usr/bin/true` run; if the profile fails to compile, the command is rejected (SANDBOX_POLICY_INVALID)
  • Linux: bubblewrap (bwrap) with unshared pid/net/ipc/uts/user/cgroup namespaces, a new session (--new-session), read-only system mounts (/usr, /bin, /sbin, /lib, /lib64, /etc), private /tmp, and --unshare-net
  • bubblewrap gets an extra capability pre-flight: `--version` succeeds even when user namespaces are disabled, so we run a real `--unshare-pid/net/ipc/uts/user/cgroup /bin/true` probe and fail closed if the namespaces we require cannot be created (common in locked-down containers and some CI runners)
  • Windows: AppContainer (src/core/win-job-runner.ps1) — the target process is created SUSPENDED with the SECURITY_CAPABILITIES proc-thread attribute on CreateProcessW (the documented LaunchAppContainer pattern — CreateProcessAsUserW does not support this attribute), so the kernel builds the container token at process start with ZERO capabilities: no network, no device, no user-handle access, enforced from the very first instruction; the process is assigned to a Job Object and verified via IsProcessInJob before resuming; limits (KILL_ON_JOB_CLOSE, active-process cap, job memory, die-on-unhandled-exception) are applied and verified before the target runs a single instruction. The separate useAppContainer:false restricted-token path deletes dangerous privileges and applies a Low mandatory-integrity label (S-1-16-4096)
  • Windows filesystem isolation is at the OS layer: the AppContainer package SID is granted ACL access ONLY to allowlisted directories, the sandbox workspace, and the resolved executable (icacls). Anything not allowlisted stays DENIED. Grants are revoked and the AppContainer profile deleted after each run
  • Windows network isolation is at the OS layer: the AppContainer carries ZERO capabilities, so it cannot initiate network connections at all; TEMP/TMP/LOCALAPPDATA are rerouted into the per-run profile folder
  • All platforms: environment is sanitized — only allowlisted variables (PATH, HOME, USER, LANG, LC_ALL, TMPDIR, SHELL, TERM, etc.) pass through; API keys and tokens never reach the sandboxed process (buildSafeEnv)
  • Every result carries an explicit `isolation` object ({ filesystem, network, process }) so callers cannot mistake process containment for filesystem/network isolation: macOS, Linux, and Windows all report {true, true, true} when the platform sandbox is active, and any setup failure or unsandboxed run reports all false. No single boolean 'sandboxed' is trusted on its own
  • Network inside the sandbox is denied by default on all platforms: macOS (deny network*), Linux (--unshare-net), and Windows (zero AppContainer capabilities). Per-domain network allowlisting is NOT supported on any platform — requesting it fails closed
  • macOS residual (not eliminated): the deny-by-default baseline still grants Mach service lookup explicitly (required for node/python/shell), so Mach IPC remains usable — every other unenumerated IPC class is denied — and Apple Events cannot be filtered by current sandbox-exec (operation not exposed), so a sandboxed command could still ask another app to act on its behalf
  • Source files: src/core/sandbox-executor.js, src/core/win-job-runner.ps1, src/core/directory-allowlist.js

Benefits

  • Defense in depth: even if the regex blocklist is bypassed, the OS sandbox still confines what the command can read, write, execute, and reach on the network
  • On all three platforms the OS sandbox prevents writes outside the workspace + allowlisted write directories — on Windows this is enforced by OS ACL grants on the AppContainer package SID
  • Credential leakage via ambient environment variables is prevented by the env allowlist
  • Network exfiltration is blocked by default-deny networking inside the sandbox (macOS/Linux/Windows), not by firewall rules
What this does NOT guarantee
  • On Windows before v0.3.8 the Job Object confined processes only; AppContainer (v0.3.8) adds OS-layer filesystem (package-SID ACL grants) and network (zero capabilities) isolation. The Windows runtime matrix runs in CI on windows-latest and is currently success — the full three-sandbox Jest matrix on Windows (61 passing, 30 platform-skipped of 91) completes green, and every runtime containment test (suspended AppContainer start, verified job assignment, grandchild containment, secret isolation, OS-enforced ACL allowlist denial, KILL_ON_JOB_CLOSE) returns a verified sandbox result. The documentation claims the process is created suspended with SECURITY_CAPABILITIES on CreateProcessW, then the Job Object is assigned and verified via IsProcessInJob before resuming. This is the right design — you should verify the actual PowerShell/C++/Node implementation (src/core/win-job-runner.ps1) rather than trusting the documentation alone.
  • A sandbox confines what a command can do. It does not make a malicious command safe, and it does not decide what the AI asks for. Human approval is a social control, not a cryptographic one; a coerced or careless approval still executes.
  • Seatbelt and bubblewrap constrain the process, not the data it is handed. If you allowlist a directory that contains secrets, the sandboxed command can read them. Allowlists are trust boundaries you draw — only as good as where you draw them.
  • These guarantees apply to code executed through executeSandboxed(). The Electron main process, the renderer, native modules, and helper apps are NOT inside the sandbox. Sandboxing reduces blast radius; it is not a substitute for least-privilege OS accounts, patched dependencies, or simply not running untrusted code.
  • Fail-closed means we refuse to run rather than run uncontained. It does not mean every malicious input is harmless — a command that is allowed by policy and approved by a human runs, inside the sandbox, with whatever access the policy grants.
6

Capability-Scoped Execution

Actions must be explicitly registered with a named handler and approval tier. Unregistered actions are rejected.

How It Works

  • Each allowed action is registered with the CapabilityController
  • Approval tiers: never (auto-approved), first-time-per-session, always (explicit confirm)
  • Ticket-based authorization ensures single-use approval for high-risk actions
  • Unregistered actions don't exist as callable surfaces — prompt injection cannot invoke them
  • Source files: src/core/capability-controller.js, src/core/command-validator.js

Benefits

  • Removes dangerous primitives from the attack surface entirely
  • Prompt injection cannot invoke an action through the capability interface unless that action is registered and authorized
  • Ticket system prevents replay attacks on approved actions
  • Granular control over what the AI can and cannot do

Threat Model

Threat Scenarios

See how each security layer protects against common attack vectors.

Prompt Injection via Hidden Text

A malicious webpage hides prompt injection instructions in invisible text

Defense

Visual Sandbox strips hidden DOM elements and scripts before the AI sees content. OCR captures only visible, rendered text. This significantly reduces DOM-based prompt injection but does not prevent visible-text injection — adversarial instructions rendered on the page can still reach the model.

Visual Sandbox

Malicious JavaScript Redirect

A webpage uses JavaScript to redirect the AI to a phishing site

Defense

The AI sees sanitized extractions of the rendered page — DOM text or screenshots/OCR — never live page JavaScript, and extracted text passes the injection scan before it reaches the model.

Visual Sandbox

Social Engineering via Commands

An attacker tricks the AI into running 'rm -rf /'

Defense

The Syntactic Firewall attempts to block known dangerous shell patterns (rm -rf /, sudo, fork bombs, command substitution) before execution. It is a fast first-pass filter — creative command construction can bypass it. The OS sandbox and approval gates are the real boundaries.

Syntactic Firewall

Context Injection via Context Switching

A webpage contains instructions that attempt to override AI behavior

Defense

User-provided content is filtered for known injection patterns before reaching the AI context. Pattern-based filtering is not foolproof; novel jailbreaks can evade it.

Syntactic Firewall

Unauthorized Shell Execution

AI executes a destructive shell command

Defense

Human-in-the-Loop requires explicit approval for all shell commands. High-risk commands require QR approval via the paired mobile device.

HITL

Remote Code Execution

AI is tricked into downloading and running malicious code

Defense

Shell commands triggering downloads (curl, wget) are classified medium risk — asked every time, with no Allow Always option — and the OS sandbox denies network by default, so an approved fetch still cannot reach the network. Any shell execution requires human approval.

HITL + Firewall

Symlink Traversal Attack

Attacker creates a symlink in an allowed directory pointing to /etc/passwd or other sensitive files

Defense

Path canonicalization resolves symlinks via fs.realpath() before checking against the allowlist. This catches standard symlink traversal. It does not protect against TOCTOU races, hard links, bind mounts, or filesystem namespace tricks — the OS sandbox is the enforcement boundary.

Directory Allowlist

Credential Leakage via Environment Variables

AI executes a command that inherits the parent process's environment with API keys and tokens

Defense

OS-level sandboxing strips all ambient environment variables. Only explicitly allowlisted non-credential variables are passed to child processes.

OS-Level Sandboxing

Network Exfiltration via Shell

AI is tricked into executing curl to upload sensitive data to an attacker's server

Defense

The sandbox denies network by default: macOS Seatbelt emits (deny network*), Linux bubblewrap runs with --unshare-net, Windows AppContainer carries zero capabilities. curl/wget is additionally classified medium risk by the command validator — asked every time, no Allow Always — and all shell execution requires human approval. Per-domain allowlisting is not supported on any platform.

OS-Level Sandboxing

Unauthorized API Invocation

Prompt injection attempts to invoke an unregistered shell command or system action

Defense

Capability-Scoped Execution rejects unregistered actions entirely. If there's no registered run_shell_command action, prompt injection cannot invoke one through the capability interface unless that action is registered and authorized.

Capability-Scoped

Permissions

Permission Levels

Screen Reading

Required for AI to see page content

Required

Shell Execution

Required for terminal commands

High Risk

App Launching

Required for opening applications

Medium Risk

File System Access

Required for PDF generation and downloads

Required

Network Access

Required for web browsing and API calls

Required

Clipboard Access

Required for copy/paste functionality

Medium Risk

Directory Allowlist

Controls which directories AI can access

High Risk

OS-Level Sandboxing

Enforces filesystem and network boundaries

Required

What an "Allow Always" answer stores

  • • An exact match on the full normalised command line the user was shown — not a prefix and not the binary alone, so a command that reads differently next time no longer matches.
  • • Every grant carries a 30-day lifetime; the gate sweeps it with an audit-log entry and the dialog asks again (src/lib/approval-gate.js, pinned by tests/allow-always-lifetime.test.js).
  • • Only binaries in the classifier's table are offered Allow Always at all — anything the classifier has never seen is offered Allow Once only, because a grant for an undescribed command is a promise about behaviour rather than about text.

Risk Assessment

Shell Risk Tiers

Shell commands are classified into one of four risk tiers by the permission-store classifier (regenerated into src/data/shell-tiers.generated.json by scripts/gen-shell-tiers.ts) before they reach the permission gate. Higher tiers require stronger, more explicit approval. Browser actions follow their own approval table above.

Low Risk

Opt-in auto-approve

Only behind the opt-in autoApproveLowRiskShell setting, which defaults to off. Nothing is granted at startup.

lscatpwdfindgrepechoNAVIGATE

Known limit: The setting covers the whole low tier rather than named commands, so turning it on is a decision about a category. It is also independent of the MCP tool path: shell commands read autoApproveLowRiskShell from the permission store, MCP tool calls read a separate security_autoApproveLowRisk key, both default to off, and enabling one does not enable the other.

Approval

Asked every time, unless you turn on autoApproveLowRiskShell. With it off — the default — a low-risk command shows the same dialog as any other.

Medium Risk

Asked every time

No. There is no setting that auto-approves a medium shell command.

cpmvmkdirtouchnpmgitnodepythoncurlwgetosascript

Known limit: An unrecognised command lands here rather than in low, so this tier also means "we have never heard of it". "Allow Always" is withheld for network-capable and script-capable binaries, but a local write like cp or mkdir can still take an exact-match Always grant — valid for 30 days.

Approval

Asked every time. autoApproveMidRisk does not reach shell commands — it still applies to MCP tool actions, which is a separate question.

High Risk

Explicit confirmation

Only if a grant exists for that exact command line, or a SHELL_HIGH / SHELL_ALL grant was made deliberately.

chmodfind . -deletekillddmountiptablesshutdown

Known limit: An Allow Always grant is never offered for a destructive command, so the Always button is absent here and Allow Once is the strongest answer available. `chmod` sits in this tier because it matches a destructive pattern, not because it is privileged in the usual sense — it was already high and moving it down would have weakened a default.

Approval

Asked every time, then offered as Allow Once / Always / Deny.

Critical Risk

Never auto-approved

Never. Refused before the grant store and the auto-approve settings are consulted, and unreachable from every one of them.

Known limit: No command in the tier table is assigned this tier. It is only synthesised at runtime for commands arriving from a remote device. Remote-origin shell execution strictly requires single-use, input-hash-bound QR+PIN ticket redemption.

Approval

Denied at the policy gate unconditionally, then offered to the user as an interactive Allow / Deny prompt.

Remote & Power Actions

QR Code Approval

The QR flow is used for power actions, desktop AI-initiated high-risk actions, high-risk MCP tool calls, and remote-origin shell commands. A remote shell request never executes directly: the capability controller forces approval for it — registered as local "never" / remote "always", with a hard rule that remote origin can never resolve to "never" — and issues a single-use ticket whose PIN is never sent over the network: it lives only on the ticket and in a QR the desktop renders on its own screen. The phone reads the PIN by scanning that QR, the desktop dialog for such a ticket only displays it and can deny, and the command runs only after that ticket comes back with its per-ticket PIN and the command's input hash verifies (src/main/handlers/sync-handlers.js; guarded by aartiq-browser/tests/remote-shell-approval.test.js).

Mobile App Approval

Power actions (shutdown, restart, sleep, lock), desktop AI-initiated high-risk actions, and remote-origin shell commands all require confirmation via the paired mobile app (QR + PIN); a remote shell executes only after its single-use ticket is approved with the per-ticket PIN.

1
Action Triggered

AI attempts high-risk command

2
QR Displayed

Desktop shows unique QR code

3
Scan & Verify

Mobile app scans QR

4
PIN Confirmation

Enter 6-digit verification code

5
Command Executed

Action proceeds after approval

Security Guarantees

  • QR codes are single-use only
  • Each QR code is cryptographically unique
  • PIN codes are generated per-session
  • Mobile must be paired via secure handshake
  • Failed attempts are logged with timestamps
  • All approvals are logged with timestamps

Remote Access

Remote Device Security

Commands originating from a paired mobile device receive the same validation as local commands — plus additional scrutiny because the origin is remote.

Elevated Risk for Remote Origin

WiFi Sync commands from paired mobile devices pass through the exact same validation and permission checks as local commands. One action goes further: a remote-origin shell-command has its risk tier bumped by one level before it reaches the capability controller.

  • shell-command only: low → medium
  • shell-command only: medium → high
  • shell-command only: high → critical (denied at the policy gate, then a plain Allow/Deny prompt)
  • shutdown / restart / sleep / lock additionally require a QR + PIN approval regardless of risk tier
  • Other remote actions (send-prompt, get-clipboard, update-setting) run the same local validation but do NOT receive the tier bump
  • Note: no registry ever assigns the critical tier outside this remote shell path, so 'critical is never auto-approved' is true but describes a mostly-unused label — see the source (src/main/handlers/sync-handlers.js)

High-Risk Remote Actions

Power actions and shell commands from a remote device require QR/PIN approval before execution, matching the on-device high-risk flow.

  • Shutdown, restart, sleep, and lock require QR/PIN approval
  • Remote shell commands are validated by SecurityValidator, routed through the capability controller, and executed via execFile (no shell interpretation)
  • The agent API and native bridge bind to 127.0.0.1 only; the MCP browser bridge (port 3001) also binds to 127.0.0.1 by default and needs an explicit setting to listen beyond loopback
  • The three HTTP bridges — the MCP bridge, the agent API and the native bridge — wrap every route in a session-token check (checkLocalRequest — src/lib/local-server-auth.js:278-338, applied at src/lib/mcp-browser-server.js:1596, src/lib/agent-api/server.ts:103 and main.js:1319): a client that has not been given the token is refused rather than connected, the token does not expire while Aartiq runs, and it persists in ~/.aartiq-token, ~/.aartiq-mcp-token or ~/.aartiq-agent-token (mode 0600), so a client configured once keeps working across restarts. Each of the three also accepts per-client credentials minted with its primary token (POST /clients — sha256 persisted, the value returned once) and revoked one at a time (POST /clients/revoke), so retiring one client leaves its peers and the primary token working, and a revoked credential is refused with its own 401 code. The other two listeners authenticate differently: the WiFi sync WebSocket upgrade refuses foreign Origins and Host headers that do not name this machine, and every sync action — handshake, unpair, clipboard, remote control — requires the trusted device's short-lived access token (src/lib/WiFiSyncService.ts); the PDF sync listener requires its token on every file endpoint, compared in constant time (src/service/pdf-sync.js)

Listeners

Network Listeners

Every socket the application opens, and what actually protects it.

ServicePortDefault bind addressReachable from LAN whenAuthentication
MCP browser bridge3001127.0.0.1the security_mcpBridgeRemote setting is exactly true (defaults to false; no UI control sets it)A token required on every route including SSE, read-or-created in ~/.aartiq-mcp-token (mode 0600) so a configured client survives restarts. Host must be the loopback host and this listener's own port; any browser Origin must be on an allow-list of the app's own origins. Per-client credentials: POST /clients mints one with the primary token (returned once) and POST /clients/revoke retires just it — its peers and the primary token keep working.
WiFi sync (desktop ↔ mobile)3004all interfaces (0.0.0.0 / ::)the phone reaches this over the LAN, so all interfaces is the default; `AARTIQ_WIFI_SYNC_HOST` narrows the bind to an address you name (127.0.0.1 closes it to this machine)Short-lived 15-minute access tokens and 7-day refresh tokens bound to device ID. Every sync action — unpair included — requires an active, unexpired token, with brute-force lockout. The WebSocket upgrade itself refuses foreign Origins and Host headers that do not name this machine (DNS rebinding).
Native macOS / CLI bridge46203127.0.0.1never — the host is a literal in the source, not a switch anyone can flipA token required on every route, read from ~/.aartiq-token (mode 0600), plus the same Host and Origin checks. Per-client credentials: POST /clients mints one with the primary token (returned once) and POST /clients/revoke retires just it — its peers and the primary token keep working.
Agent API tool server46204127.0.0.1config.remote === true (defaults to false; no UI, env var, or IPC path sets it)A token required on every HTTP route, read-or-created in ~/.aartiq-agent-token (mode 0600) so an agent configured once keeps working across restarts, plus the same Host and Origin checks. An unknown x-agent-id is still auto-registered, but as a limited-trust agent — it no longer stands in for authentication. Per-client credentials: POST /clients mints one with the primary token (returned once) and POST /clients/revoke retires just it — its peers and the primary token keep working.
Background task service (separate Electron app)3999127.0.0.1AARTIQ_SERVICE_HOST is set to a routable address (defaults to 127.0.0.1; no switch in the app)Authentication token required on all file endpoints (Bearer, X-Aartiq-Token, or ?token=) compared in constant time against the service token (options.authToken, AARTIQ_PDF_SYNC_TOKEN, or a generated per-process token), plus Host header validation against DNS rebinding.

One of these binds all interfaces by default — WiFi sync (desktop ↔ mobile) (3004)— and only the host environment variable narrows the bind. If you run Aartiq on a shared or untrusted network, that is the part to think about first.

Documented, but not listeners

  • • Port 9922 (Nexus bridge) — Not present. A dead variable remains in main.js.
  • • Port 9877 (Raycast HTTP API) — Not present. Port constant is declared and never read.
  • • Port 9876 (Flutter bridge) — Implemented and correctly token-gated, but never instantiated.
  • • Port 3005 — UDP broadcast destination, not a listener. The discovery socket binds an ephemeral port.
  • • Port 3003 — Next.js dev server. Development only — never started in a packaged build.

Encryption

E2E Encryption

AES-256-GCM

All sensitive data at rest is encrypted using AES-256-GCM with authenticated encryption and PBKDF2 key derivation.

AlgorithmAES-256-GCM
Key DerivationPBKDF2-SHA256
Iterations600,000
IV Length12 bytes

Implementation

Authenticated encryption (GCM mode) — tampered ciphertext is rejected
Random salt + IV per encryption operation
PBKDF2 key derivation with 600,000 iterations (OWASP 2023+)
No silent fallback — encryption requires a passphrase or throws
encodeLocalOnly() as an explicit escape hatch for non-sensitive data

Use Cases

Sync credentialsEncrypted with user passphrase
API keysAES-256-GCM with derived key
Chat historyEnd-to-end encrypted sync
File transfersP2P encrypted relay
Vault passwordsField-level encryption with keychain
Legacy dataProactive migration to E2EE2: format

Source: src/lib/crypto-utils.ts

Vault Migration

Legacy vault entries are automatically detected and re-encrypted to the modern E2EE2 format — the older formats used weaker key derivation.

LCL:Legacy

Plaintext base64 — no encryption, no salt

E2EE:Legacy

PBKDF2 100K iterations, no salt

E2EE2:Current

PBKDF2 600K iterations, per-entry random salt + IV

  • Atomic vault writes — backup before migration, rollback on failure
  • Proactive migration re-encrypts LCL: and E2EE: entries to E2EE2: on demand
  • Migration requires biometric / native verification before re-encryption begins

API Key Storage

API Key Protection

Key Redaction

API keys are automatically masked in logs and console output

Bearer|token|api[_-]?key|secret→ [REDACTED]

Secure Storage

Keys stored in encrypted electron-store with OS keychain integration

Environment Isolation

Keys are never exposed to renderer process without explicit access

Auto-Masking

AI prompts are scrubbed for API keys before processing

sk-... (OpenAI)AIza... (Google)anthropic-... (Anthropic)gsk_... (Groq)

Source Files

src/lib/firebaseConfigStorage.ts, src/lib/shared-keychain.js

Token Generation

Method

crypto.getRandomValues()

Entropy

256-bit CSPRNG

Uses

Session tokens, pairing codes, QR verification

Capability Model

Capability-Scoped

Instead of trying to detect dangerous requests via regex, the system constrains what actions the AI can invoke at all — each with its own approval policy.

Register

Each allowed action is explicitly registered with a named handler and an approval tier. If an action isn't registered, it doesn't exist as a callable surface. The controller is wired into both the main process (main.js) and the command executor (command-executor.js).

registerAction({
name: "click_element",
requiresApproval: "first-time-per-session"
})

Execute

Execution is gated by the controller. Unregistered actions are rejected outright. Registered actions are allowed or queued for approval based on their tier.

neverApproved automatically (read-only)
first-time-per-sessionApproved once per session
alwaysRequires explicit confirmation each time

Why this matters

Regex-based threat detection can be bypassed — obfuscation, synonyms, and encoding all defeat pattern matching. A capability-scoped model doesn't try to detect danger in text; it removes the dangerous primitive from the attack surface entirely. If there's no registered run_shell_command action, prompt injection cannot invoke one through the capability interface unless that action is registered and authorized.

Verification

Security Test Coverage

Every layer above is backed by automated regression tests. The suite is dispatched via GitHub Actions CI (.github/workflows/jest.yml) on demand (latest green run: #83). It is not triggered on every push.

Full aartiq-browser suite + three sandbox runtimes — success (5/5 jobs)

1438

Total declared tests (1426 passed / 12 skipped / 0 failed on macOS (local), generated 2026-10-08)

91

OS-sandbox tests (fail-closed + adversarial)

21

Approval-ticket regression tests

6

Security layers under test

sandbox-security.test.js

macOS Seatbelt fail-closed + real OS-enforcement (read/write denied outside the allowlist, /tmp allowed, network bind denied, AF_UNIX socket denied, cross-process signal denied, self-signal allowed, symlink-escape denied, child processes contained), plus Linux bubblewrap, Windows AppContainer contract tests and command-tokenizer/env-sanitization checks.

windows-job-sandbox.test.js

JS contract (isolation flags, fail-closed network/allowlist policy) passes everywhere; the runtime matrix — suspended AppContainer start, OS-enforced ACL allowlist, verified job basis, grandchild containment, secret isolation, and KILL_ON_JOB_CLOSE — runs on Windows CI (windows-latest), where run #83 reported 61 passing and 0 failed of 91, every containment test returning a verified sandbox result. The design and source were also reviewed independently: Windows AppContainer Sandbox Audit (2026-09-13).

linux-bwrap-sandbox.test.js

JS contract (unshare flags, --bind vs --ro-bind, fail-closed when bwrap is missing or present-but-incapable of creating namespaces) runs everywhere; the runtime matrix (no read/write outside allowlist, private /tmp, network denied, symlink-escape denied) runs on Linux hosts where bwrap is installed.

approval-ticket-security.test.js

A dedicated regression suite for the ticket-based approval + capability-controller system. It locks in the fixes for the audit findings and fails if any invariant regresses.

Red 1 — redeemTicket verifies the params/context hash (tamper → 'tampered')
Red 2 — persistent grant cannot override an 'always' approval
Red 3 — 'first-time-per-session' never becomes a persistent grant
Red 4 — call-shape hashing agrees on context at register + verify
Red 5 / Orange 6 — missing params fail constraints; 'optional' allows absence
Orange 7/8 — registration gated to a ticket; pattern validates the action
Orange 9 — regex patterns length-limited against catastrophic backtracking
Orange 10 — unknown ticket IDs are rejected
Yellow 11 — tickets bound to capabilityVersion; replaceAction invalidates them
Yellow 12 — returned ticket params are defensive clones
Arch — approval produces a pure AuthorizationDecision; the executor consumes it and never reconstructs authorization
Arch — a v1 ticket is rejected (never executed) after the action is replaced with v2

Source: aartiq-browser/tests/approval-ticket-security.test.js