Locara

31 — Capability Static Analyzer

The static analyzer is what makes “secure by default” credible at submission time. It scans an app’s source code and verifies that the code only uses capabilities the manifest declares.

This document specifies the analyzer’s architecture, its rules, its limitations, and how it interacts with the rest of the trust pipeline.

What the analyzer is for

Three independent layers enforce capabilities (see 03-capabilities.md):

  1. macOS App Sandbox — kernel-level deny.
  2. Locara runtime — Rust-side capability checks at every primitive call.
  3. Static analyzer — pre-runtime check that the source code only attempts what the manifest declares.

Layers 1 and 2 are runtime enforcement. The analyzer is build-time — it catches mismatches before an app ships, so we don’t rely on runtime denial as the first line of defense.

The analyzer’s job:

  • Find every capability-touching call in the source.
  • Match against the manifest’s declarations.
  • Block submission if the code uses a capability the manifest doesn’t grant.
  • Warn for ambiguous cases (dead code, dynamic dispatch).

What the analyzer is NOT

  • Not a sandbox. Static analysis can be evaded; runtime enforcement is the real defense.
  • Not a malware detector. We catch capability mismatches, not “is this app secretly evil.”
  • Not a correctness checker. It doesn’t verify the app does what it says it does.
  • Not a substitute for review. Human review still happens for high-risk capability profiles.

Architecture

┌──────────────────────────────────────────────────────────┐
│  src/**/*.{ts,tsx}      ← App's source                   │
│         │                                                │
│         ▼                                                │
│  TypeScript AST parser (TypeScript Compiler API)         │
│         │                                                │
│         ▼                                                │
│  Capability-touching call detector                       │
│  (matches against rule catalog)                          │
│         │                                                │
│         ▼                                                │
│  Capability use graph                                    │
│  (each call mapped to required capability)               │
│         │                                                │
│         ▼                                                │
│  Manifest matcher                                        │
│  (compares graph to declared capabilities)               │
│         │                                                │
│         ▼                                                │
│  Diagnostics                                             │
│  (errors, warnings, suggested manifest edits)            │
└──────────────────────────────────────────────────────────┘

Implemented in TypeScript using the TypeScript Compiler API. Lives in crates/locara-cli/src/analyzer/ (Rust binary) or packages/manifest/src/analyzer/ (TypeScript) — TBD whether we run analyzer in Rust via deno_ast or in TS via tsc.

(open: A vs B) Rust-via-deno_ast for performance + single-binary install vs TypeScript-via-tsc for tighter ecosystem integration. Leaning TypeScript because the rule catalog evolves with the SDK and they should stay in sync.

Rule catalog

The analyzer has a published rule catalog. Each rule defines:

  • Pattern — what the AST looks like.
  • Required capability — what the manifest must declare.
  • Severity — error, warning, or info.

Examples:

// Rule: locara-net-fetch
{
  id: "locara-net-fetch",
  pattern: { type: "CallExpression", callee: { name: "fetch" } },
  requiredCapability: "net",
  severity: "error",
  rationale: "fetch() makes network requests; declare net capability."
}

// Rule: locara-fs-user-selected
{
  id: "locara-fs-user-selected",
  pattern: { type: "CallExpression", callee: { object: { name: "fs" }, property: { name: "pick" } } },
  requiredCapability: "fs.user-selected",
  severity: "error"
}

// Rule: locara-llm-model-not-declared
{
  id: "locara-llm-model-not-declared",
  pattern: {
    type: "CallExpression",
    callee: { object: { name: "llm" }, property: { name: "chat" } },
    args: [{ properties: { model: { type: "Literal" } } }]
  },
  requiredCapability: "models[<extracted-from-arg>]",
  severity: "error",
  rationale: "Model used in llm.chat must be declared in capabilities.models[]"
}

Detected categories

Network

  • fetch(), XMLHttpRequest, WebSocket, EventSource
  • Dynamic import() of remote URLs
  • Library calls known to make network requests
  • → Requires net capability (true or scoped allowlist)

Filesystem

  • @locara/sdk fs.* calls
  • @locara/sdk db.* calls (require fs.app-data)
  • Direct file reads in Tauri (rare; usually flagged as suspicious)
  • Drag-drop event handlers consuming File objects (require fs.user-selected)
  • → Requires fs.user-selected or fs.app-data or fs.user-paths

Device APIs

  • navigator.mediaDevices.getUserMedia()
  • @locara/sdk audio.record(), image.capture()
  • → Requires device.microphone / device.camera

LLM / models

  • Every llm.*, embed.*, transcribe.*, vlm.*, ocr.* call
  • Model argument is extracted; verified against capabilities.models[]
  • → Requires the specific model in models[] (with hash)

Tools

  • Every tools.invoke('name', ...) call
  • Tool name extracted; verified against capabilities.tools[]
  • → Requires the tool in tools[]

IPC

  • Every reference to $SHARED/... paths
  • Verified against ipc.shared_folders[]

Dynamic / suspicious patterns

  • eval(), Function(string), new Function(...) — flagged as ERROR (forbidden in v1).
  • Dynamic property access into @locara/sdk (locara[someVar]) — flagged as warning.
  • String concatenation building network URLs — flagged as warning if net is allowlist-mode.

Resolution: declared vs used

For each capability in the use graph, the analyzer compares to the manifest:

Use graphManifestOutcome
Used + declaredPass
Used + not declaredERROR — submission blocked
Used + declared but scope mismatch (e.g., wrong host)ERROR
Not used + declaredWarning — “unused capability”
Used in dead code branch + not declaredWarning — flagged for human review

Handling library calls

The hardest case: app calls @locara/sdk function, which internally calls fetch. Whose responsibility?

Resolution rule: the SDK function declares its own capability requirements in its TypeScript JSDoc. The analyzer reads those declarations and propagates the requirement to the caller.

Example:

// In @locara/sdk/llm.ts
/**
 * @locara-capability llm.chat
 * @locara-capability-extract model from args.model
 */
export async function chat(args: ChatArgs): Promise<ChatResponse> {
  // ...
}

When app code calls llm.chat({ model: 'qwen-3b-q4', ... }), the analyzer sees the JSDoc, extracts model = 'qwen-3b-q4', and requires that model to be in capabilities.models[].

This means the SDK is the authoritative source for capability requirements. Third-party libraries that don’t have capability annotations are treated suspiciously; the analyzer warns and human review escalates.

Handling third-party npm packages

(committed) Apps can use third-party npm packages. But the analyzer treats them as “opaque” by default:

  • A package’s runtime behavior is unknown to the analyzer.
  • If the app imports from a package, all capabilities the package’s code might use must be declared.

Mitigation: a curated “vetted packages” list (small) that the analyzer trusts. Adding to the vetted list requires a published capability manifest from the package author + Locara review.

For now, popular utility packages (lodash, date-fns, zod, react, etc.) are pre-vetted as capability-free.

This is conservative but safe. Apps that want fancy npm dependencies pay the trust tax.

Output

locara verify runs the analyzer and produces:

$ locara verify
✓ Manifest schema valid
✓ Identity matches local git
✗ src/lib/transcribe.ts:47:3 — capability "net" used but not declared
    code:
      const response = await fetch('https://api.openai.com/v1/...')
                              ^
    capability used: net (host: api.openai.com)
    fix:
      either remove the fetch() call,
      or add to manifest:
        "capabilities": {
          "net": { "allowed_hosts": ["api.openai.com"] }
        }
      (note: doing so loses the "fully local" badge)

⚠ src/lib/utils.ts:23 — file read in dead code branch
    capability would be: fs.user-selected (currently declared)
    
2 issues (1 error, 1 warning)

For machine consumption, --json output:

{
  "errors": [
    {
      "rule": "locara-net-fetch",
      "severity": "error",
      "file": "src/lib/transcribe.ts",
      "line": 47,
      "column": 3,
      "capability_required": { "type": "net", "host": "api.openai.com" },
      "fix_hint": "..."
    }
  ],
  "warnings": [...]
}

False positive handling

Static analysis is imprecise. False positives waste developer time. Mitigations:

  1. Configurable rule severity in a locara.config.json (similar to ESLint). Devs can downgrade specific rules with justification.
  2. Inline pragma comments to suppress specific lines:
    // @locara-allow: net (this fetch is fired only in dev mode)
    if (DEV_MODE) {
      await fetch('http://localhost:3000/debug');
    }
    
  3. Reviewer override at the registry level for cases where the analyzer is provably wrong.

But: ALL suppressions are visible in the manifest review; abuse is caught.

Performance

The analyzer must run fast enough that locara verify is sub-second for typical apps:

  • Target: < 1 second for an app with 100 source files.
  • Strategy: incremental analysis (only re-analyze changed files); cache rule matches.

Adversarial robustness

The analyzer is part of the trust pipeline, so we test it adversarially. A separate document in tests/security/analyzer-evasion-corpus/ maintains attempted bypasses:

  • Eval-like dynamic dispatch
  • String concatenation building API URLs
  • Imports from packages that themselves call sensitive APIs
  • Source code with deliberate mis-formatting

Each evasion attempt becomes a permanent regression test.

Limitations

The analyzer cannot catch:

  • Capability use that only happens at runtime via dynamically-fetched code (which we ban via the no-eval rule).
  • Capability use inside opaque third-party packages (which we mitigate via the vetted-packages list).
  • Capability use through native code (Rust shouldn’t expose anything not gated by the manifest).
  • Capability use through the wasm tool runtime (which has its own capability composition).

When a limitation is hit, the runtime layer catches it. Defense in depth.

Versioning

The analyzer rule catalog is versioned. When new capabilities ship, the catalog updates in lockstep with the SDK. Apps can pin a rule catalog version in their locara.json for reproducibility.

Open questions

  • (open) Should the analyzer also run via LSP (live in editors) for instant feedback? Probably yes, phase 2+.
  • (open) Should the analyzer enforce style (e.g., always use @locara/sdk instead of bare fetch)? Maybe; would need community consensus.
  • (open) Metadata for vetted packages — where does the trust score / capability declaration live? Probably in a Locara-curated registry alongside the model + tool registries.

Cross-references