The model, the developer surface, and the user experience.
This document describes Harbor's permission system: how it decides whether an agent action is allowed, how site authors and extension contributors work with it, and what the human at the keyboard actually sees.
| If you are... | Read |
|---|---|
| Curious about the design | Part 1: How it works |
Building a website that uses window.ai / window.agent |
Part 2: For site authors |
| Hacking on Harbor itself | Part 3: For extension contributors |
| Using Harbor as your browser AI | Part 4: For users |
Every browser AI system eventually runs into the same wall: prompts. The first time a site asks to talk to your AI, you read carefully and click "allow." The hundredth time, you've already moved on. Anthropic reported that Claude Code users approve 93% of permission prompts — at that point the prompt is friction without protection.
Harbor's permission system is built around a different assumption: most decisions should be made without a prompt, but the consequences of being wrong should be small. That requires four things working together:
- A typed, declarative policy so the user (or their org) can decide once, in advance, what's fine — and so a tool can statically reason about what a policy will do.
- Asymmetric defaults so reading is cheap, persisting reads is opt-in, and writing is expensive.
- Information-flow tracking so sensitive data carries a label that follows it through the agent, and any attempt to send it to a remote model, a remote tool, or a network egress point requires explicit consent.
- A safety net — sensitivity gates, watchdog containment, and a non-bypassable enforcement chokepoint — that catches misbehavior automatically before a human has to react.
The system is provider-agnostic: permissions are expressed in terms of effect (local prompt vs remote prompt, same-origin egress vs cross-origin egress, reversible vs irreversible) rather than vendor brand. Whether you're using Firefox-ML, Chrome's on-device Gemini Nano, Ollama, OpenAI, or Anthropic, the same policy applies; the provider is metadata, not part of the permission name.
Every API call from a website goes through a single chokepoint: the PolicyEngine. The engine answers one question per call:
Given this principal (origin + session + capability token), this typed action (
model.prompt.remote,tool.call,network.egress.cross_origin, …), this resource (a tool, a URL, an element, a data label set), and this context (session mode, watchdog state, time, user gesture) — should this action be allowed, denied, previewed, or asked about?
It answers by walking a tier ladder, top to bottom, returning the first decisive answer.
Page calls window.ai or window.agent
│
▼
┌────────────────────────────────────────────┐
│ PolicyEngine │
│ │
│ Tier 0 Ambient ─────────────────────────► allow (no prompt, ever)
│ Tier 1 Managed deny ────────────────────► deny (org policy)
│ Tier 2 Sensitivity gate ────────────────► ask (sensitive site/element/tool)
│ Tier 3 Information-flow check ──────────► ask / deny (label-tainted egress)
│ Tier 4 Watchdog containment ────────────► downgrade / deny
│ Tier 5 Capability token check ──────────► deny (token doesn't grant this action)
│ Tier 6 Policy allow rule ───────────────► allow
│ Tier 7 Policy ask rule ─────────────────► ask
│ Tier 8 Per-origin grant ────────────────► allow / deny / ask
│ Tier 9 Default for action's effect ─────► ask (read) / preview (write)
└────────────────────────────────────────────┘
│
▼
audit + watchdog + execute
One important property of this ladder: Sensitivity, info-flow, watchdog, and capability tokens always run. They are above policy rules deliberately — a generous allow rule cannot defeat a label-aware egress check or a destructive-tool gate. This is the safety floor; nothing in the policy file or per-origin grants can disable it.
Every call is described by a typed action of the form verb.noun.qualifier. Internally, the engine works only in this form, and that's also what site authors and policy authors should use.
The typed namespace is organized by what the action actually does:
| Family | Action | What it does |
|---|---|---|
model.prompt.local |
LLM prompt against an on-device model (Firefox-ML, Chrome AI, Ollama loopback) | Data does not leave the machine |
model.prompt.remote.firstParty |
LLM prompt against a provider configured by this origin's account | Data leaves to a known third-party endpoint |
model.prompt.remote.thirdParty |
LLM prompt against the user's globally configured provider | Data leaves; user-controlled destination |
model.list |
Enumerate available providers/models | Metadata only |
tool.list |
Enumerate MCP tools | Metadata only |
tool.call |
Invoke an MCP tool | Effect depends on tool manifest |
tool.register |
Register a website-provided MCP server | Adds to the user's tool surface |
browser.read.activeTab |
Read DOM/text from the active tab | Reads page contents |
browser.read.screenshot |
Capture a screenshot of the active tab | Reads page visuals |
browser.read.tabs |
List metadata of open tabs | Reads tab titles/URLs |
browser.write.interact |
Click, fill, scroll on the active tab | Mutates page state |
browser.write.navigate |
Navigate the active tab | Mutates browsing context |
browser.write.tabsCreate |
Open a new tab | Mutates browsing context |
network.egress.same_origin |
HTTP(S) fetch to the same origin as the requester | Bounded data flow |
network.egress.cross_origin |
HTTP(S) fetch to a different origin | Possible exfiltration channel |
agent.delegate |
Invoke another registered agent | Capability passing |
agent.delegate.crossOrigin |
Invoke an agent in a different origin | Authority crossing trust boundaries |
agent.delegate.remote |
Invoke a remote A2A agent | Authority leaving the machine |
Every action carries metadata:
type ActionMeta = {
effect: 'metadata' | 'read' | 'egress' | 'write' | 'identity' | 'destructive';
locality: 'local' | 'same-origin' | 'cross-origin' | 'remote';
reversible: boolean;
defaultDataLabels: DataLabel[]; // labels this action's *output* carries
acceptsLabels: DataLabel[]; // labels this action's *input* may carry
defaultTTL: 'session' | 'tab' | 'task' | number;
};defaultDataLabels and acceptsLabels are how information-flow tracking plugs in (next section).
| Effect | Examples | Default behavior |
|---|---|---|
| metadata | model.list, tool.list, agent.discover |
Auto-allowed; never prompts |
| read | model.prompt.local, browser.read.activeTab, browser.read.screenshot |
Prompt once; TTL = session-bound by default; promotion to persistent requires either an explicit policy rule or a "pin after repeated trusted use" flow |
| egress | network.egress.cross_origin, model.prompt.remote.thirdParty |
Prompt with destination shown; TTL = task-bound; previewed when carrying labeled data |
| write | browser.write.interact, browser.write.navigate, tool.call (writes) |
Previewed before each commit |
| destructive | tool.call flagged destructive, tool.register of unknown servers |
Always confirmed; cannot be auto-allowed via "always" |
| identity | password fields, payment forms, OAuth prompts (detected by SensitivityEngine) | Always confirmed; no "always" option |
This is the core fix to a real defect in the v1 sketch: previously, model:prompt lumped local on-device LLMs with remote cloud calls, and browser:activeTab.read was a "long-lived read" by default — both of which are exfiltration channels in a remote-LLM setup. The new model splits them, makes egress its own effect, and makes read TTL session-bound by default.
The single most important upgrade over the v1 sketch is labels that follow data. When the engine intercepts a read of sensitive content, it tags the result with a label. When the same data is later about to leave the machine — through a remote LLM, a remote tool, a network.egress.cross_origin, an outbound email, or a file upload — the engine checks the labels and gates accordingly.
The label set, in order of severity:
| Label | Sources |
|---|---|
credentials |
Password fields, OAuth bearer tokens, API keys read from page |
payments |
Credit-card forms, bank-account fields, payment processor pages |
identity |
OAuth profile pages, account settings, government-ID-shaped strings |
regulated |
Healthcare portals, legal/financial regulated services |
confidential |
Pages tagged by the site (<meta name="harbor:confidential">) or by the user's policy |
Labels propagate through three boundaries:
- Page → agent context. When
browser.read.activeTabreturns text containing a password input or a marked-confidential meta tag, the result is annotated with the label set in the audit log and in the session's working memory. - Tool input → tool output. A tool call whose arguments carry labels produces output that inherits those labels (conservative approximation; we cannot read the tool's internal logic).
- LLM input → LLM output. A prompt whose user-or-tool messages carry labels produces output that inherits the union of those labels.
A label-aware egress check (Tier 3 in the ladder) fires when:
- An action with
locality: 'remote'or'cross-origin'is invoked - and its inputs carry one or more labels
- and the active policy does not explicitly permit that label to flow to that destination class
The default policy is to deny credentials, payments, and identity flowing to any remote destination, and to ask for confidential and regulated. Sites can override this for specific known destinations (network.egress.cross_origin(domain:api.mycompany.com, accept_labels:[confidential])).
This is a strictly stronger version of the v1 "exfil chain" timing heuristic. Instead of "did a sensitive read happen within 5 seconds of an outbound fetch," it asks "is this outbound fetch carrying labeled data?" — which doesn't require timing, doesn't have the false-positive rate of a sequence detector, and works even when the agent reasons across many steps.
Labels are pessimistic: a tainted input always taints the output. They are not a complete information-flow analysis (we are not running an interpreter over the agent's internal reasoning, the way CaMeL does for its custom Python). But they catch the obvious shapes of exfiltration — read a credential, summarize it, send the summary — that pure timing analysis cannot.
harbor-policy.json is a declarative document the user (or an organization) writes once. It lives in browser storage, can be edited from the sidebar, and can be shipped via managed-storage policies (Firefox policies.json, Chrome enterprise policies, MDM).
The schema is typed: every rule is {id, effect, principal, action, resource, context}. A short string form (Tool(specifier)) is also accepted for convenience and compiles to the same typed shape.
String DSL as sugar. A shorter Claude-Code-style form is accepted in allow/ask/deny arrays for convenience and compiled to typed rules at load time:
| String form | Compiles to |
|---|---|
tool.call(github/create_issue) |
{actions: ["tool.call"], resource: {tool_server: "github", tool_name: "create_issue"}} |
tool.call(github/*) |
{actions: ["tool.call"], resource: {tool_server: "github"}} |
network.egress.cross_origin(domain:api.openai.com) |
{actions: ["network.egress.cross_origin"], resource: {domain: "api.openai.com"}} |
The sidebar editor lets users author either form and shows the typed compiled output.
Precedence. Sources merge in this order, with later sources unable to override earlier ones:
- Managed (org policy via MDM / browser admin)
- User policy file (
harbor-policy.json) - Per-origin grants ("Allow always" / "Allow once" decisions remembered from prompts)
Within a single document, evaluation order is deny → ask → preview → allow, so a deny rule always wins over an allow rule.
Even with broad allow rules, the SensitivityEngine forces a confirmation when the action is structurally dangerous, regardless of policy. Four classifiers:
| Classifier | Triggers on |
|---|---|
| Domain | URL matches sensitiveDomains (banks, gov, healthcare, identity providers, anything in the user's list) |
| Element | Click/fill targets a form containing <input type="password">, <input autocomplete="cc-number">, an OAuth submit, or a destructive-button class (btn-delete, btn-danger); also emits the corresponding credentials/payments/identity data label |
| Tool (heuristic) | Tool name matches a destructive verb (delete, remove, purge, destroy, revoke, drop, wipe) |
| Tool (manifest) | MCP manifest declares sideEffect: "destructive", reversible: false, or has riskTags indicating elevated risk — only honored if the manifest is from a trusted source |
The MCP spec is explicit that tool descriptions and annotations should be treated as untrusted unless the server is itself trusted. Harbor honors this: a self-attested sideEffect: "reversible" in a manifest does not lower the prompt threshold unless the manifest comes from a trusted source. Trusted sources are:
- Bundled servers shipped with Harbor (signed by the build).
- Locally installed servers (the user copied/installed them; their manifest sits in local storage and is treated like local config).
- Servers signed by a trusted publisher key configured in policy.
- Servers explicitly trusted via a policy rule pinning their identity (
trustedManifestServers: ["github.com/example/server@v1.2.3"]).
For everything else — including auto-discovered MCP servers offered by the open web — manifest claims are advisory. The SensitivityEngine still uses them as a signal (a tool claiming to be destructive is treated as destructive even from an untrusted manifest, because that direction is safe to trust), but it never lowers the gate based on untrusted self-attestation.
When any classifier says "sensitive," PolicyEngine returns ask with promptKind: 'sensitive'. The user sees the action preview with extra context (what URL, what element, what tool, what arguments, what destination domain) and a "stop" button always available.
Sensitivity gates cannot be turned off site-by-site through ordinary "Allow always" — they require an explicit "I understand, always allow this category" decision in the sidebar that records exactly what was waived.
A session is the agent context for a particular task. Each session is bound to:
- An origin (the requesting site)
- A capability token — an opaque identifier that the engine maps to a specific allowed-action set, label-acceptance set, budget, TTL, and parent token (if delegated)
- A mode:
plan,execute, orwatch
The capability token is the unit of authority. It's how Harbor implements the review's recommendation that authority be attenuated, not ambient: every agent action checks the token, and a session in Plan mode literally has a token whose allowedActions excludes every effect: 'write' action — there is nothing for the engine to "force deny," because the authority isn't there to begin with.
type CapabilityToken = {
tokenId: string;
origin: string;
parentTokenId?: string; // for delegated subagents
allowedActions: TypedAction[]; // strict subset of parent if delegated
acceptedLabels: DataLabel[]; // labels this token can carry across egress
budgets: { toolCalls?: number; navigations?: number; remotePromptUsd?: number };
ttl: number; // expires at this timestamp
mode: 'plan' | 'execute' | 'watch';
};| Mode | What the token allows | When it's used |
|---|---|---|
| Plan | Reads, prompts, summarization. Token's allowedActions excludes all write and egress actions. |
Default for unknown origins. The agent can explore and propose, but cannot modify or transmit. |
| Execute | Normal evaluation per the tier ladder. Token has the requested action set. | Default for trusted origins, or after the user moves a session out of Plan. |
| Watch | Like Execute, but every write-effect or labeled-egress action returns preview and pauses for explicit user confirmation. |
Auto-engaged on sensitive domains; user can engage manually. |
Mode transitions don't just change a flag — they mint a new token with a different allowedActions set. Old tokens, if cached anywhere, become invalid; new ones are bound to the new mode.
When a session calls agent.delegate to invoke a subagent, the parent must mint a child token. The engine enforces:
- Child
allowedActions⊆ parentallowedActions - Child
acceptedLabels⊆ parentacceptedLabels - Child
budgets≤ parentbudgets(and consume from the parent's pool) - Child
ttl≤ parentttl - Child can have a tighter
modethan the parent (parent in Execute can spawn a child in Plan), but never broader
This is capability attenuation in the OCAP sense: the parent passes a strict subset of its authority to the child, and the child cannot accumulate authority by recursion. Sanity check: if you revoke the parent token, every descendant token is revoked.
The Watchdog can downgrade modes automatically (Execute → Watch → Plan) in response to bad behavior; this is implemented as re-minting the token with a tighter allowedActions set. The user can always re-upgrade them from the sidebar.
A background monitor that subscribes to the SessionRegistry and the PolicyEngine audit stream and maintains rolling statistics per (origin, sessionId):
- Tool-call rate (1m, 5m, 1h windows)
- Navigation rate
- Denial count and consecutive denials
- Budget consumption
- Unique cross-origin egress targets
- Label-flow events (data with label
Xwas about to flow to destination classY)
The Watchdog is a complement to the structural defenses (info-flow labels, sensitivity gates, capability tokens), not a replacement. The structural defenses prevent the action; the Watchdog notices the attempt and adjusts the envelope so the next attempt is harder.
| Rule | Trigger | Default response |
|---|---|---|
| Velocity spike | rate > 3× rolling average | Toast notification in sidebar |
| Repeated denials | ≥ 3 consecutive denials | Re-mint session token in Plan mode |
| Budget overspend | > 90% of budget consumed in a single burst | Suspend; ask to extend |
| Repeated label-egress attempts | 2+ blocked egress attempts carrying credentials/payments/identity in a single session |
Quarantine session |
| Scope escalation | Origin requests an action outside its current token's allowedActions twice in a row |
Terminate session; mark origin "needs review" |
| New resource class | navigation/tool category never used in last 50 actions, then used 3+ times | Notify only |
When a rule triggers, the Watchdog walks a graduated containment ladder, never jumping straight to a kill switch:
normal
└─ restricted-write (write tier denied; reads continue)
└─ sandbox (no remote fetch, no nav; read-only on current tab)
└─ quarantined (every action requires explicit allow)
└─ killed (session terminated; origin in cooldown)
Every transition is reversible by the user from the sidebar in one click.
Every PolicyEngine decision — whether it prompted, auto-allowed, denied, or downgraded — writes a record to a ring buffer (most recent ~5,000):
{
ts: 1699999999000,
origin: "https://example.com",
sessionId: "sess_abc",
tokenId: "tok_def",
action: "tool.call",
resource: { tool_server: "github", tool_name: "create_issue" },
inputLabels: ["confidential"],
decision: "allow",
source: "policy-allow",
matchedRuleId: "preview-github-writes",
reason: "matched rule preview-github-writes (preview), then allowed by user",
correlationId: "req_xyz"
}The sidebar's Activity tab shows these records grouped by origin, filterable by decision and by time range. From any row the user can:
- Revoke the current grant for that action on that origin.
- Add an
allow,ask, ordenyrule to the policy file (with a generated typed rule). - Drill into the full request payload for debugging.
This is also what makes silent decisions visible. If the engine auto-allowed a tool call because of a policy rule, the user sees that in the feed; nothing is hidden.
Each audit row has a "Why?" affordance and a "What if?" affordance.
Why? explains the decision in full: which tier of the ladder fired, which rule matched (with its id), which sensitivity classifier triggered (if any), what labels were on the input, and what the resulting effect was. This is the answer to the legitimate user question "why did Harbor block this?" — and it's the answer to the developer's question "why is my site getting ERR_BLOCKED_BY_POLICY?"
What if? is the simulator. It lets the user (or a developer in dev tools) replay the same decision against:
- A modified action set (what if the origin had asked for
model.prompt.localinstead of.remote?) - A modified policy (what if I added this allow rule?)
- A modified session mode (what if this had been in Execute instead of Plan?)
- A modified label set (what if the input wasn't tainted with
credentials?)
The simulator does not affect live state — it's a pure replay against the engine. Output is a side-by-side: actual decision vs simulated decision, with the same Why? trace for both. This is how a user debugs a policy without having to think in JSON, and how a developer authors capability requests against actual production traces.
Once the system is deployed we want to track, in aggregate and locally:
- Prompt rate per origin and per action, so we know when a site is creating prompt fatigue.
- Sensitivity false-positive rate — how often a sensitivity gate fires on something the user immediately allows. Tells us where to tighten the classifier.
- Label-egress block rate — how often the info-flow tracker prevents a labeled-data exfiltration attempt. Tells us whether the label set covers reality.
- Containment trigger frequency — how often the watchdog re-mints a token at lower authority, by rule.
- Token attenuation depth — how often agents delegate to subagents and how many levels deep delegation goes in practice.
This data is local-first; nothing is exported by default. The user can opt in to anonymized aggregates in the sidebar.
This is what you, as an author of a website using window.ai and window.agent, need to know.
const session = await agent.requestCapabilities({
mode: 'plan', // start narrow
require: [
{ action: 'model.prompt.local' },
{ action: 'browser.read.activeTab' },
],
optional: [
{ action: 'browser.write.interact' },
{ action: 'tool.call', server: 'github', toolTags: ['remote_write'] },
],
budget: {
navigations: 5,
remotePromptUsd: 0.10,
},
reason: 'Read this page, draft a fix, and optionally apply it if you approve.',
});
// session.token is your capability token. The engine uses it to enforce.
// session.granted lists what was granted (may be a subset of `require + optional`).A few things worth knowing up front:
- The user has many ways to say "yes once" or "yes forever" without you needing different code paths.
- The user has many ways to silently restrict you (policy rules, watchdog containment, label-egress denial) — your code needs to handle structured errors gracefully.
- Asking for
model.prompt.localinstead ofmodel.prompt.remote.thirdPartyis dramatically cheaper to grant. Prefer local when you can.
// Good
await agent.requestCapabilities({
require: [
{ action: 'tool.call', server: 'memory', toolNames: ['save_memory', 'search_memories'] },
],
reason: 'Save and search your notes across sessions',
});
// Bad — broad, unjustified, dishonest
await agent.requestCapabilities({
require: [
{ action: 'model.prompt.remote.thirdParty' },
{ action: 'tool.call' },
{ action: 'browser.read.activeTab' },
{ action: 'browser.write.interact' },
{ action: 'browser.write.navigate' },
{ action: 'network.egress.cross_origin' },
],
reason: 'AI features',
});The reason string appears prominently in the prompt. Users who write good harbor-policy.json rules use the reason as a signal of trustworthiness; vague reasons make it more likely your request gets denied or downgraded.
model.prompt.local (Firefox-ML, Chrome AI, Ollama loopback) keeps the user's data on the machine. model.prompt.remote.thirdParty sends data to a cloud provider. The engine treats them very differently:
// Good if you don't need a frontier model — easier to grant, no egress concerns
{ action: 'model.prompt.local' }
// Required if you need a remote model. Be explicit; the user sees this.
{ action: 'model.prompt.remote.thirdParty' }If your task can be done with a local model, ask for model.prompt.local only. The user's policy is much more likely to auto-allow it, and any data labels carried into the prompt won't trigger egress confirmations.
Some scope combinations are common enough that the prompt batches them as a single decision (e.g. model:prompt + browser:activeTab.read + browser:activeTab.screenshot is a "Reader assistant" preset). Asking for them together — instead of one at a time as you discover you need them — gets the user to a single decision faster.
Create your sessions in Plan mode and only request Execute (or specific write actions) when the user explicitly takes an action that requires writes. This is the same shape Cursor and Claude Code use, and it gives the user a natural moment to grant write authority.
Capability tokens are intentionally one-way: agent.upgradeSession() can narrow a session (Execute → Watch → Plan) but cannot widen it. To widen, you mint a fresh capability token by calling agent.requestCapabilities() again. The user gets a new prompt that names the additional authority — that's the "natural moment" for a re-grant, and it keeps the audit log honest about which token was acting when.
const planSession = await agent.requestCapabilities({
name: 'Article assistant',
mode: 'plan',
require: [
{ action: 'model.prompt.local' },
{ action: 'browser.read.activeTab' },
{ action: 'browser.read.screenshot' },
],
});
// Later, when the user clicks "Apply suggested edit", request a new
// session with write authority. The user sees a fresh prompt scoped
// to exactly the new actions.
const writeSession = await agent.requestCapabilities({
name: 'Apply suggested edit',
mode: 'execute',
require: [
{ action: 'browser.write.interact' },
],
reason: 'Apply the changes you approved',
});The user gets two prompts, but each is small and scoped to a real moment of intent. That's much cheaper to grant than one giant upfront prompt. The two sessions hold separate capability tokens; the original Plan session keeps working for further reads, and the new Execute session covers the write you just authorized. When the write is done, terminate it.
If you publish an MCP server, declare side effects:
{
"server": "github",
"tools": [
{ "name": "read_issue", "sideEffect": "none", "reversible": true },
{
"name": "create_issue",
"sideEffect": "remote",
"reversible": true,
"idempotent": false,
"dryRun": true,
"dataEgressDomains": ["api.github.com"],
"riskTags": ["remote_write"]
},
{
"name": "delete_repo",
"sideEffect": "destructive",
"reversible": false,
"riskTags": ["destructive", "irreversible"]
}
]
}The SensitivityEngine uses these to enrich its decisions. Two important caveats:
- Manifest claims that increase sensitivity (declaring something destructive) are honored from any source — that direction is safe to trust.
- Manifest claims that decrease sensitivity (declaring a destructive-looking tool to be reversible) are only honored from a trusted manifest source (bundled, locally installed, signed by a trusted publisher, or pinned in policy). For everything else, Harbor falls back to its heuristic — including a regex over the tool name (
/(delete|destroy|remove|purge|revoke|drop|wipe)/i).
If you want users to grant your tools generously, get your server signed or installed locally. The trust path is what unlocks fast-path decisions.
Existing code already handles ERR_PERMISSION_DENIED. New shapes you should expect:
try {
await agent.tools.call('filesystem/delete_file', { path });
} catch (err) {
switch (err.code) {
case 'ERR_PERMISSION_DENIED':
// Standard denial — same as before
break;
case 'ERR_BLOCKED_BY_POLICY':
// A user/org policy rule denied this. Don't retry.
// err.details.policyRule tells you which rule.
break;
case 'ERR_SESSION_CONTAINED':
// The Watchdog has restricted this session.
// err.details.containmentLevel = 'restricted-write' | 'sandbox' | ...
break;
case 'ERR_LABEL_FLOW_BLOCKED':
// The action's input carries a data label (credentials, payments, identity)
// that the policy does not allow to flow to this destination.
// err.details.labels = ['credentials']
// err.details.destination = 'api.openai.com'
// Don't retry blindly — strip the label or use a local model.
break;
case 'ERR_TOKEN_EXPIRED':
// Capability token has expired or been revoked. Re-request.
break;
}
}Treat ERR_BLOCKED_BY_POLICY, ERR_LABEL_FLOW_BLOCKED, and ERR_SESSION_CONTAINED as terminal: the user has already been told, and retrying just generates noise in their activity feed.
When a write-tier action triggers a preview, the user sees an ActionPreview rendered by Harbor. You can help that render well:
await agent.tools.call('email/send', {
to: 'alice@example.com',
subject: 'Your weekly summary',
body: '...',
}, {
preview: {
title: 'Send weekly summary email',
description: 'To Alice, with this week\'s reading list',
},
});If you don't supply preview, Harbor synthesizes one from the tool name and an arguments digest. Your version is almost always more useful to the user.
The mock harness exposes the new surfaces so your tests can drive them:
import { installWebAgentsMock } from './harbor-test/mock.js';
const mock = installWebAgentsMock(globalThis);
// Apply a typed policy
mock.permissions.applyPolicy({
rules: [
{ id: 'r1', effect: 'allow', actions: ['model.prompt.local', 'browser.read.activeTab'] },
{ id: 'r2', effect: 'deny', actions: ['tool.call'], resource: { tool_server: 'filesystem' } },
],
});
// Inject Watchdog state
mock.permissions.setSessionContainment(sessionId, 'sandbox');
// Force a sensitivity classification for an arbitrary URL
mock.permissions.markSensitive('https://example.com/payment');
// Inject data labels onto a read result, so you can test egress paths
mock.permissions.tagWithLabels({
origin: 'https://example.com',
action: 'browser.read.activeTab',
labels: ['credentials'],
});
// Drive the simulator
const sim = mock.permissions.simulate({
origin: 'https://example.com',
action: 'model.prompt.remote.thirdParty',
inputLabels: ['credentials'],
});
console.log(sim.decision); // 'deny', source: 'infoflow-deny'See TESTING_YOUR_APP.md for the full list.
This is what you need to know to add a new scope, a new sensitivity rule, or a new anomaly check.
extension/src/policy/
actions.ts # Typed action registry (verb.noun.qualifier)
labels.ts # DataLabel registry and propagation helpers
engine.ts # PolicyEngine.evaluate (the chokepoint)
rules.ts # Typed rule matcher; string DSL parser
store.ts # Loads and merges managed + user policy documents
origin-grants.ts # Per-origin "Allow always / once / deny" remembered from prompts
sensitivity.ts # Domain, element, and tool classifiers
manifest-trust.ts # Trusted manifest registry; signature verification
tokens.ts # Capability token mint / validate / attenuate
watchdog.ts # Rolling stats, anomaly rules, containment
consent-bus.ts # Pending-consent broadcaster (sidebar + popup)
audit.ts # Ring-buffer audit log
simulator.ts # Decision replay against modified context
The flow through the system is:
agents/handlers/* ─[requireAction]─► policy/engine.evaluate
│
├─► policy/sensitivity
├─► policy/labels (input/output flow)
├─► policy/tokens (capability check)
├─► policy/store (typed rules)
├─► policy/watchdog (state)
├─► policy/manifest-trust (for tool meta)
├─► consent-bus (when ask/preview)
└─► policy/audit
- Add the action to
actions.ts:export const TYPED_ACTIONS = { // ... 'browser.write.tabsMove': { title: 'Reorder open tabs', description: 'Change the position of tabs in the tab strip.', effect: 'write', locality: 'local', reversible: true, defaultDataLabels: [], acceptsLabels: [], defaultTTL: 'session', }, } as const;
- Wire the message type to that action in
REQUIRED_ACTIONSat the bottom ofagents/types.ts. - Gate the handler with
requireAction(ctx, sender, 'browser.write.tabsMove', { resource }). - Add unit tests in
policy/__tests__/engine.test.tscovering at least one allow path and one ask/deny path.
// extension/src/policy/labels.ts
export const DATA_LABELS = {
// ...
'authToken': {
title: 'Authentication token',
severity: 'critical',
defaultEgressPolicy: 'deny', // never egress without explicit allow
},
} as const;Then teach the SensitivityEngine to emit it:
// extension/src/policy/sensitivity.ts
export function classifyElement(info: ElementInfo): SensitivityResult {
// ...
if (/Bearer\s+[A-Za-z0-9._-]+/.test(info.nearbyText)) {
return { sensitivity: 'sensitive', labels: ['authToken'] };
}
}The engine automatically applies the egress policy at Tier 3.
(Same as before — sensitivity classifiers live in sensitivity.ts. New rule:)
// extension/src/policy/sensitivity.ts
export function classifyTool(server: string, name: string): SensitivityLabel {
// existing rules...
if (server === 'stripe' && /^(charge|refund|void)/.test(name)) {
return 'destructive';
}
return 'safe';
}The engine consults classifyTool at Tier 2 before any policy or grant is checked, so this is non-bypassable by allow rules.
// extension/src/policy/manifest-trust.ts
export const TRUSTED_PUBLISHERS = {
'mozilla': { publicKey: '...', name: 'Mozilla' },
'anthropic': { publicKey: '...', name: 'Anthropic' },
};
export function isTrustedManifest(manifest: ToolManifest): boolean {
if (manifest.source === 'bundled') return true;
if (manifest.source === 'local-install') return true;
if (manifest.signedBy && verifySignature(manifest)) return true;
if (policyConfig.trustedManifestServers?.includes(manifest.serverId)) return true;
return false;
}The engine asks isTrustedManifest before lowering a sensitivity gate based on a manifest claim. It never asks before raising one.
- Don't bypass
requireAction. Every API boundary uses it. If you're tempted to skip the check, you almost certainly want to register an action witheffect: 'metadata'instead. - Don't auto-allow a write-effect or egress-effect action from inside a handler. The effect is the contract; if you've decided a particular write should be auto-allowed, that's a policy
allowrule and belongs in the user'sharbor-policy.json. - Don't trust a remote-discovered manifest. Always go through
isTrustedManifestbefore honoring a manifest's downgrade of sensitivity. - Don't store secret data in audit records. Use a digest. Audit records are user-readable and exportable.
- Don't add a sensitivity false positive without an override path. Every "always confirm" rule needs a corresponding "always allow this on this origin" surface in the sidebar, or you've created an unfixable annoyance.
A rule is a pure function from WatchdogState to a (possibly empty) signal:
// extension/src/policy/watchdog.ts
export const rules: WatchdogRule[] = [
// existing rules...
{
name: 'unusual_navigation_burst',
evaluate(state) {
if (state.navigationsLastMinute > 30) {
return {
severity: 'warn',
message: `${state.origin} navigated ${state.navigationsLastMinute}x in 1 min`,
response: { kind: 'downgrade', to: 'restricted-write' },
};
}
return null;
},
},
];Rules are evaluated on every audit-log append. Test them with mocked Date.now and an injected stat history (watchdog.spec.ts).
The ConsentBus is a fanout that any number of UIs can subscribe to. Today there are two consumers: the sidebar inline panel (extension/src/sidebar/pending-consent.ts) and the popup window (extension/src/permission-prompt.ts). To add a third (e.g. an in-page chip rendered by the content script):
// extension/src/agents/content-script.ts
import { ConsentBus } from '../policy/consent-bus';
ConsentBus.subscribe((req) => {
if (req.preview.kind !== 'click' && req.preview.kind !== 'fill') return;
// Render a small in-page chip near the target element.
const chip = renderChip(req.preview);
chip.onAllow = () => ConsentBus.resolve(req.id, { decision: 'allow', ttl: 'session' });
chip.onDeny = () => ConsentBus.resolve(req.id, { decision: 'deny' });
});Multiple UIs may surface the same request; ConsentBus.resolve is idempotent (first response wins) and broadcasts a permissions_resolved event so the others dismiss themselves.
This is what the system looks like from the user's chair, and what you can do with it.
For most sites, most of the time, you won't see anything. Harbor's whole goal is to not interrupt you. The pattern looks like this:
- You install Harbor and either accept the default policy or write your own.
- You visit a site. The site's agent quietly does what it's allowed to do.
- You go on with your life.
You become aware of Harbor in three moments:
- The first time a new site asks for a meaningful capability — a one-time prompt with a clear description.
- When something dangerous is about to happen — a payment, a destructive action, a navigation to your bank — you'll be asked even if you've previously said "always allow."
- When something has gone wrong — a site is misbehaving, your budget is hitting its cap, an automated containment fired — Harbor surfaces it in the sidebar.
Harbor's sidebar is the home for everything in this document. It has four tabs:
| Tab | What's there |
|---|---|
| Sessions | Every active agent session, its origin, its mode (Plan/Execute/Watch), its containment level, its budget. One-click Stop, Pause, Mode change, Revoke. |
| Policy | Your harbor-policy.json editor. JSON, with schema validation and rule helpers. |
| Origins | Per-site grants — what you've said "Allow always" or "Allow once" to, on which sites. |
| Activity | Audit feed: every decision the engine has made, with the reason. Filter by site, by decision, by time. |
A small Harbor icon also lives in the browser toolbar with a single state indicator: green (idle), blue (an agent is acting), yellow (an agent is waiting on you), red (a containment has fired).
When a session starts, it's in one of three modes. Think of these as how much rope you're giving the agent.
The agent can read the page, see your tabs (with permission), summarize, search, and propose. It can't click anything, fill anything, navigate anywhere, or call any tool that changes the world. Use this mode whenever you want to know what the agent thinks, before letting it act.
You'll see a pale green border on the sidebar in this mode. The agent's responses might look like:
I'd suggest filing this bug as a GitHub issue with title "X" and body "Y". Want me to actually create it? You'd need to switch this session to Execute first.
Normal operation. The agent reads, prompts, calls tools, fills forms, clicks buttons. Things you've previously said yes to keep working without prompts. New sensitive things still confirm.
You'll see a yellow border on the sidebar. The Stop button in the sidebar header is your single point of control — pressing it terminates every active session immediately.
Like Execute, but every write-tier action previews and pauses. You see exactly what the agent is about to click, fill, or navigate to, and approve each step. Slow, but the right mode for banking, government services, healthcare, and anything you marked as sensitive.
You'll see a red border on the sidebar and a "Step / Skip / Stop" control next to each pending action.
You can change a session's mode at any time. The agent doesn't get to refuse.
When Harbor does ask, the prompt itself is more informative than before. Four kinds:
The traditional "this site wants permission to X" dialog, but with several new things:
- A clear effect badge (read / write / egress / destructive).
- The action's locality (
localfor on-device,remotefor cloud). - The site's
reasonin plain language, not a generic "AI features." - A default focused button that matches the effect — read effects default to Allow for this session, write effects default to Allow once, persistent grants are never the default.
- A third option: "Always allow if it matches a rule…" which generates a
harbor-policy.jsonrule from this grant and adds it to your policy file. That's how you build up a policy over time without ever opening the editor.
The change from v1: persistent grants ("Allow always") used to be the default for reads. They aren't anymore. Reads default to session-bound, and you have to actively pin a grant after using it a few times — usually after one trusted use, the prompt itself offers "I've used this site three times, always allow."
When a write or read crosses out of your machine — a remote-model prompt, a cross-origin fetch, a tool that talks to a third-party API — you see a destination-aware prompt:
┌────────────────────────────────────────────────┐
│ example.com wants to send data outside your │
│ machine │
│ │
│ Action: model.prompt.remote.thirdParty │
│ Destination: api.openai.com │
│ Data carries label: confidential │
│ │
│ Allow once | Allow for this session │
│ [Block this destination] │
└────────────────────────────────────────────────┘
If the data carries a credentials, payments, or identity label, there's no "Allow" path — only "Block." Those labels are configured to never leave the machine without an explicit policy rule the user wrote in the sidebar.
When the SensitivityEngine catches something — you're about to delete a repo, navigate to a bank, click submit on a payment form — you see a different kind of dialog:
┌────────────────────────────────────────────────┐
│ Sensitive action │
│ │
│ example.com wants to: │
│ ▸ Delete repository alice/old-project │
│ │
│ This action is irreversible. │
│ │
│ [Cancel] [Confirm once] │
└────────────────────────────────────────────────┘
There's no "always allow" option for sensitive actions, because that would defeat the purpose. You can move the rule to your sensitive-domains list later if you want to suppress this category of prompt for a specific origin, but it's a deliberate decision in the sidebar, not a one-click waiver.
Not a prompt — a notification. Looks like:
┌────────────────────────────────────────────────┐
│ Session restricted │
│ │
│ example.com tried to send data tagged │
│ `credentials` to an external service. │
│ This session is now read-only. │
│ │
│ [Show in activity] [Restore] [Stop session] │
└────────────────────────────────────────────────┘
You don't have to do anything. The agent has been quietly restricted, and you can choose to look closer or move on. The "Restore" button puts the session back into Execute mode if you trust the agent's intent; "Stop session" terminates it cleanly.
Every row in the Activity feed has a "Why?" link. Clicking it opens a panel that walks through the decision: which tier of the ladder fired, which rule matched, what labels were on the input, what the resulting effect was. Below that is a "What if?" panel that lets you replay the decision under different conditions:
- What if this had been a local prompt instead of remote?
- What if this rule was changed to allow?
- What if the input wasn't tainted with
confidential? - What if the session was in Execute instead of Plan?
The simulator is read-only — replaying doesn't change anything. It's how you investigate "why did Harbor block this?" without having to read JSON. It's also how you pre-check a policy change before saving it.
You don't have to write a policy file by hand. The sidebar offers four flows that produce one for you:
- From a prompt. When a first-use prompt appears, the "Always allow if it matches a rule…" button asks you what scope of rule you want — exactly this tool, all tools from this server, all read-prefixed tools — and adds it.
- From the activity feed. Any allowed action has a "Make this automatic" link that creates a matching
allowrule. Any denied or contained action has a "Always deny" link that creates adenyrule. - From the origins tab. Any per-origin grant can be promoted to a global rule with one click ("Allow this on every site that asks for it").
- From a preset. Harbor ships a small set of canned policies (Conservative, Balanced, Permissive, Developer, Researcher) you can use as a starting point and customize from there.
The JSON is always the source of truth, and you can always open the editor if you want to write rules directly. But you can run Harbor for a year without ever touching it.
A few things to check first, in order:
- Is the session containment level above "normal"? Sidebar → Sessions → look for a yellow or red badge. If yes, the Watchdog restricted it for a reason; the Activity tab will tell you what.
- Is there a deny rule matching what the site is trying to do? Sidebar → Policy. Look for a
denyrule that mentions the scope or tool the site is asking about. The Activity tab also shows the rule that fired. - Did you grant "Allow once"? Sidebar → Origins → look for the origin. If the grant has expired (10 minutes after grant, or when the tab closed), the next call will re-prompt.
- Is the site in Plan mode by default? If it's a new origin and you haven't moved it to Execute, every write-tier call will fail with
ERR_PERMISSION_DENIED. The Sessions tab shows the mode.
If a site is hammering you with prompts, that's almost always a sign the site is asking poorly — broad scopes with vague reasons, or asking for one scope at a time when it should batch. Use the activity feed to identify which scope is coming up most often, then either grant it always (if you trust the site) or write a deny rule (if you don't).
| You can... | Where |
|---|---|
| See every active agent session | Sidebar → Sessions |
| Change a session's mode (Plan / Execute / Watch) | Sidebar → Sessions → Mode dropdown |
| Stop a session immediately | Sidebar → Sessions → Stop, or sidebar header Stop All |
| Restore a contained session | Sidebar → Sessions → Restore, or directly from the containment notice |
| Edit your policy file | Sidebar → Policy |
| Add a rule from a recent action | Sidebar → Activity → "Make this automatic" |
| See exactly what an agent did and why | Sidebar → Activity |
| Revoke a per-origin grant | Sidebar → Origins → Revoke |
| Mark a domain as sensitive | Sidebar → Policy → sensitiveDomains array |
| Set per-session budgets | Sidebar → Policy → budgets block |
| Export your policy / activity for support | Sidebar → Policy → Export, Activity → Export |
- Action (typed). A
verb.noun.qualifieridentifier (model.prompt.local,network.egress.cross_origin) that describes one effect the engine can authorize. - Action metadata. Each action's declared
effect,locality,reversible,defaultDataLabels, andacceptsLabels. - Ambient. An action whose
effectismetadata— auto-allowed, never prompts. - Audit record. A single decision logged by the PolicyEngine, with full trace including matched rule, source, and labels.
- Capability token. An opaque handle bound to an
(origin, allowedActions, acceptedLabels, budgets, ttl, parentToken)tuple. The unit of authority a session holds. - Containment. A graduated reduction of a session's authority — implemented as re-minting the capability token with a tighter
allowedActionsset. - Data label. A tag (
credentials,payments,identity,confidential,regulated) attached to data flowing through the agent. Tracked through reads, prompts, and tool calls; checked at egress. - Decision simulator. A read-only replay tool in the activity feed for investigating why a decision was made and what would change under different conditions.
- Effect. One of
metadata,read,egress,write,identity,destructive. Drives default UX. - Locality. One of
local,same-origin,cross-origin,remote. Drives egress treatment. - Manifest provenance. Whether an MCP manifest is trusted (bundled, locally installed, signed, or pinned in policy) — determines whether the engine honors its self-attested sensitivity claims.
- Origin grant. A per-site allow/once/deny remembered from a prompt. Lowest-precedence layer; the policy file overrides it.
- PolicyEngine. The single function that decides whether to allow, deny, ask, or preview each API call.
- Policy file.
harbor-policy.jsonv2 — the user's declarative typed rules, merged with managed-storage policies. - Sensitivity. Structural classification (safe / sensitive / destructive) independent of policy. Cannot be lowered by an
allowrule. - Session mode.
plan,execute, orwatch— the per-session stance, implemented as a property of the capability token. - Sidebar. Harbor's primary UI surface, where sessions, policy, origins, and activity live.
- Watchdog. The background monitor that maintains rolling statistics and label-flow events; triggers containment by re-minting tokens.
Q: I've been using Harbor's older permission API — what do I need to change?
The internal change is large, but the user-facing API is similar in shape. The biggest concrete differences for site authors:
agent.requestPermissions({ scopes: [...] })is replaced byagent.requestCapabilities({ require: [...], optional: [...] })with typed actions.model:promptbecomesmodel.prompt.localormodel.prompt.remote.thirdParty— pick the right one for your use case.web:fetchbecomesnetwork.egress.same_originornetwork.egress.cross_origin, with destination-aware policy.- New error codes (
ERR_BLOCKED_BY_POLICY,ERR_LABEL_FLOW_BLOCKED,ERR_TOKEN_EXPIRED) need to be handled in your error path.
The sample apps and the demo site demonstrate the new shape.
Q: Why is model.prompt.local separate from model.prompt.remote?
Because they're not the same thing, even though v1 treated them as one. A local model (Firefox-ML, Chrome AI, Ollama) keeps your data on your machine; a remote model sends it to a cloud provider. A user (or a policy) might be totally fine with the first and not the second. Splitting them means the user can write allow model.prompt.local broadly while keeping model.prompt.remote.thirdParty on a tight leash, and means a site that only needs a local model never triggers an egress concern.
Q: Why aren't reads "Allow always" by default anymore?
Because reads can feed exfiltration. Reading a tab is harmless on its own; reading a tab and then sending the contents to a remote LLM is exfiltration. The new default is that reads stick around for the session but expire when the session ends; you have to actively pin them to make them persistent. After three trusted uses on the same site the prompt offers to do that for you in one click.
Q: Why isn't there a single "Auto-approve everything safe" toggle?
There is, sort of — install one of the bundled presets (Permissive) and you'll get exactly that behavior. We don't expose it as a single toggle because "safe" depends on what your trusted infrastructure looks like. The Permissive preset trusts a generous set of allow rules; if you have a stricter environment, edit them.
Q: Can a malicious site spoof a reason or a manifest to look harmless?
The reason string is always site-supplied, so trust the structural information (action, locality, destination, labels), not the prose. MCP manifests can also be untrusted — Harbor only honors downgrade claims (a tool calling itself reversible) when the manifest comes from a trusted source: bundled, locally installed, signed by a trusted publisher, or explicitly pinned in your policy. Upgrade claims (a tool calling itself destructive) are honored from any source because that direction is safe.
Q: How does this interact with prompt injection?
The PolicyEngine is the structural defense: even if the agent is convinced by injected instructions to do something dangerous, the engine evaluates the action against your policy and the SensitivityEngine and the data-label flow tracker, all of which are blind to the agent's reasoning. Sensitive actions still confirm; deny rules still deny; tainted data still can't reach a remote model without your consent. None of this prevents the agent from being misled, but it bounds what a misled agent can do.
Q: How does multi-agent delegation work?
Each session has a capability token. When the session calls agent.delegate to invoke a subagent, it must mint a child token, and the engine enforces strict attenuation: the child's allowedActions is a subset of the parent's, the child's acceptedLabels is a subset of the parent's, the child's budgets and TTL are bounded by the parent's. A subagent cannot accumulate authority by recursion, and revoking the parent token transitively revokes every descendant.
Q: Can my organization centrally manage this?
Yes. Ship a harbor-managed-policy.json via Firefox's policies.json, Chrome enterprise policies, or your MDM. It loads at the highest precedence and cannot be overridden by user policy or per-origin grants. Use this for company-wide deny rules, trusted-origin allowlists, label-egress restrictions, signed-publisher allowlists, and budget caps.
Q: Does the LLM-classifier "auto mode" exist for Harbor?
Not in the initial rollout. Anthropic-style classifier-as-approver is a candidate v2 feature, optionally backed by a local model so it doesn't leak data. For now, the equivalent is a well-tuned typed policy plus the SensitivityEngine plus label-aware egress checks plus the Watchdog. We expect those to be sufficient for most cases without needing a model in the authorization path.
Q: Where's the empirical evidence?
Honest answer: not yet. The architecture is grounded by analogy to comparable systems (Cedar / XACML for typed policy, OCAP for capability attenuation, CaMeL for information-flow tracking, Anthropic Auto Mode for graduated containment), but Harbor's specific tier ladder, label set, and watchdog rules need to run on real traffic before we can publish meaningful numbers. The telemetry section above lists what we plan to measure; we expect to publish summary statistics — sensitivity false-positive rate, label-egress block rate, attacker success on a public benchmark before/after containment — once we have a representative window of real-world usage.
- Security & Privacy spec — threat model and mitigations.
- Web Agents API reference — full API surface.
- Sessions guide — when to use
window.aivsagent.sessions. - Testing your app — how the mock harness exposes policy state.
{ "version": 2, "trustedOrigins": ["https://harbor.mycompany.com"], "sensitiveDomains": ["*.bank.com", "accounts.google.com", "*.gov"], "rules": [ { "id": "reader-default", "effect": "allow", "principal": { "origin": "https://docs.example.com" }, "actions": ["model.prompt.local", "browser.read.activeTab"], "resource": { "kind": "active_tab" }, "context": { "session_mode": ["plan", "execute"], "ttl": "30d" } }, { "id": "block-cross-origin-egress-of-credentials", "effect": "deny", "principal": { "origin": "*" }, "actions": ["network.egress.cross_origin", "model.prompt.remote.thirdParty"], "resource": { "data_labels": ["credentials", "payments", "identity"] } }, { "id": "preview-github-writes", "effect": "preview", "principal": { "origin": "https://app.example.com" }, "actions": ["tool.call"], "resource": { "tool_server": "github", "tool_tags": ["remote_write"] } }, { "id": "allow-known-llm-destinations", "effect": "allow", "principal": { "origin": "*" }, "actions": ["network.egress.cross_origin"], "resource": { "domain": ["api.openai.com", "api.anthropic.com"], "accept_labels": [] } } ], "budgets": { "perSession": { "maxToolCalls": 25, "maxNavigations": 10, "remotePromptUsd": 0.50 }, "perOrigin": { "rateToolCallsPerMin": 60 } } }