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
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.
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-gatedsudo / su / passwd / chgrpRejected outright — privilege or account changedd if=Direct disk write:(){ :|:& };:Fork bomb$( ... )Command substitution\x.. hex / chmod 777Encoded payload / permissive modeMonitored Patterns
curl / wgetMedium tier — approval every time, no Allow Always; sandbox denies networkchmod / 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.
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+TabRead-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 dialogActions 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 approvedAlways 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
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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Permissions
Permission Levels
Screen Reading
Required for AI to see page content
Shell Execution
Required for terminal commands
App Launching
Required for opening applications
File System Access
Required for PDF generation and downloads
Network Access
Required for web browsing and API calls
Clipboard Access
Required for copy/paste functionality
Directory Allowlist
Controls which directories AI can access
OS-Level Sandboxing
Enforces filesystem and network boundaries
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-approveOnly behind the opt-in autoApproveLowRiskShell setting, which defaults to off. Nothing is granted at startup.
lscatpwdfindgrepechoNAVIGATEKnown 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 timeNo. There is no setting that auto-approves a medium shell command.
cpmvmkdirtouchnpmgitnodepythoncurlwgetosascriptKnown 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 confirmationOnly if a grant exists for that exact command line, or a SHELL_HIGH / SHELL_ALL grant was made deliberately.
chmodfind . -deletekillddmountiptablesshutdownKnown 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-approvedNever. 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.
Action Triggered
AI attempts high-risk command
QR Displayed
Desktop shows unique QR code
Scan & Verify
Mobile app scans QR
PIN Confirmation
Enter 6-digit verification code
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.
| Service | Port | Default bind address | Reachable from LAN when | Authentication |
|---|---|---|---|---|
| MCP browser bridge | 3001 | 127.0.0.1 | the 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) | 3004 | all 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 bridge | 46203 | 127.0.0.1 | never — the host is a literal in the source, not a switch anyone can flip | A 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 server | 46204 | 127.0.0.1 | config.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) | 3999 | 127.0.0.1 | AARTIQ_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.
AES-256-GCMPBKDF2-SHA256600,00012 bytesImplementation
Use Cases
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:LegacyPlaintext base64 — no encryption, no salt
E2EE:LegacyPBKDF2 100K iterations, no salt
E2EE2:CurrentPBKDF2 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 CSPRNGUses
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).
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 sessionalwaysRequires explicit confirmation each timeWhy 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.
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.
Source: aartiq-browser/tests/approval-ticket-security.test.js