Skip to content

Commit fbe0528

Browse files
dormsternclaude
andcommitted
feat: v0.2.0 — output scanning, security model transparency, authz positioning
Output scanner: post-execution deny-keyword detection flags suspicious AnchorBrowser output in audit trail. Domain hints enrich audit events. CLI refactored to shared audit logger with flagged event count. README: added Security Model section (honest IAM analogy + what's enforced vs not), Roadmap (v0.2/v1.0), authz framing. SECURITY.md: added Trust Model with full threat matrix. Positioning cheat sheet for calls. 74 tests passing (was 61). Zero breaking changes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9392545 commit fbe0528

11 files changed

Lines changed: 288 additions & 24 deletions

File tree

README.md

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,8 +205,41 @@ flowchart LR
205205
### Three layers of protection
206206

207207
1. **Credential isolation** — your password stays in an isolated cloud browser. The agent gets a pre-authenticated session, never the credentials themselves.
208-
2. **Scoped boundaries** — the agent can only do what your policy allows. Read inbox? Yes. Delete contacts? Blocked before it starts.
209-
3. **Audit + kill switch** — every action logged (allowed and blocked). Budget enforced. Instant session destruction when you're done.
208+
2. **Scoped boundaries** — tasks that don't match your policy are blocked before they start. Deny-first pattern matching with Unicode bypass protection.
209+
3. **Audit + kill switch** — every action logged (allowed and blocked). Budget enforced. Session destruction when you're done.
210+
211+
## Security Model
212+
213+
In security terms, leashed is **application-layer authz for AI agents** — it governs what agents are *authorized to do*, not who they are or what credentials they hold. Think of it like an AWS IAM policy that checks what you *request*, not what the underlying service *executes*.
214+
215+
### What leashed enforces today (v0.1)
216+
217+
| Layer | Enforced | How |
218+
|-------|----------|-----|
219+
| Task gating | Yes | Deny-first glob pattern matching on task strings |
220+
| Time + action budgets | Yes | Configurable expiration and action limits |
221+
| Credential isolation | Yes | Passwords stay in AnchorBrowser's isolated session, never exposed to the agent |
222+
| Session destruction | Yes | `leash.yank()` destroys the cloud browser session |
223+
| Audit trail | Yes | Every task request (allowed + blocked) logged to JSONL |
224+
| Unicode bypass protection | Yes | Strips zero-width chars, combining marks, BiDi controls |
225+
226+
### What leashed does NOT enforce (yet)
227+
228+
| Layer | Status | Why |
229+
|-------|--------|-----|
230+
| Browser action validation | Roadmap (v1.0) | AnchorBrowser executes tasks autonomously — leashed has no visibility into actual browser clicks/navigation |
231+
| URL/domain restrictions | Roadmap (v1.0) | Requires AnchorBrowser session-level allowlists (not yet available in their SDK) |
232+
| Semantic equivalence | By design | `"forward email"` and `"send email to myself"` are different strings — glob patterns match literally, not semantically |
233+
234+
### The honest version
235+
236+
The policy engine checks the **task description string** — the human-readable instruction you pass to `leash.task()`. If the string matches a deny pattern, it never reaches the browser. If it's allowed, AnchorBrowser's AI executes it autonomously.
237+
238+
This means: a well-intentioned agent that uses descriptive task names gets real governance. A deliberately adversarial agent that lies about what it's doing can bypass pattern matching — just like a developer with an IAM read-only key could name their Lambda "ReadOnlyFunction" while it actually writes to S3.
239+
240+
**leashed is a seatbelt, not a cage.** It stops the 95% of accidents that come from misconfiguration, scope creep, and unintended actions. It does not stop a determined attacker with direct API access.
241+
242+
For defense-in-depth, see [SECURITY.md](./SECURITY.md).
210243

211244
## CLI
212245

@@ -218,6 +251,23 @@ npx leashed yank # Kill switch — destroy session immediately
218251

219252
[Full API reference & policy examples →](./docs/API.md)
220253

254+
## Roadmap
255+
256+
leashed is v0.1 — the governance primitives. Here's what's coming:
257+
258+
### v0.2 — Output Scanning
259+
- Post-execution validation: scan AnchorBrowser output for policy-violating content
260+
- Domain hints in policy: `domains: [linkedin.com]` for documentation and audit enrichment
261+
- Structured output schemas for safer result parsing
262+
263+
### v1.0 — Session-Level Enforcement (with AnchorBrowser)
264+
- URL allowlists at the session level — the browser itself refuses to navigate outside your policy
265+
- Browser action audit trail — not just task requests, but actual clicks, form fills, and navigation
266+
- Webhook callbacks for real-time policy violation alerts
267+
- This is the "IAM enforcement" layer — restrictions enforced by the infrastructure, not just the intent
268+
269+
Want to help shape v1.0? [Open an issue](https://github.com/dormstern/leashed/issues) or reach out.
270+
221271
## Empowered by AnchorBrowser
222272

223273
leashed runs on [AnchorBrowser](https://anchorbrowser.io) — ephemeral, hardened cloud browser sessions purpose-built for AI agents. Each session is isolated, auto-expires, and leaves no trace. [Cloudflare](https://cloudflare.com) verified bot partner. SOC2 Type 2 and ISO27001 certified. Trusted by [Google](https://google.com), [Coinbase](https://coinbase.com), and [Composio](https://composio.dev). Stealth proxies, CAPTCHA solving, anti-fingerprinting, and full session isolation out of the box.

SECURITY.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,29 @@ We will acknowledge receipt within 48 hours and aim to release a fix within 7 da
3131
- Glob pattern matching operates on the literal task string. It cannot detect semantic equivalents (e.g., "forward" vs "send").
3232
- The audit log is a local file. For tamper-proof logging, export to an immutable store (S3 with object lock, a database, or syslog).
3333
- The expire timer and kill switch are best-effort — an in-flight AnchorBrowser task may complete after the kill signal.
34+
35+
## Trust Model
36+
37+
leashed operates at the **intent layer** — it evaluates task description strings before forwarding to AnchorBrowser. It does NOT have visibility into browser-level execution.
38+
39+
### Threat model
40+
41+
| Threat | Mitigated? | Notes |
42+
|--------|-----------|-------|
43+
| Accidental scope creep (agent uses descriptive task names) | Yes | Policy gating blocks unintended categories |
44+
| Credential exposure to agent code | Yes | Credentials stay in AnchorBrowser's isolated session |
45+
| Unlimited session duration | Yes | Time-based expiration + action budgets |
46+
| Session left running after use | Yes | `leash.yank()` + CLI `npx leashed yank` |
47+
| Unicode obfuscation of task strings | Yes | sanitizeTask() strips invisible characters |
48+
| Deliberately adversarial task labeling | Partially | Pattern matching is literal, not semantic |
49+
| Direct AnchorBrowser API bypass | No | Agent with API key can skip leashed entirely |
50+
| In-browser action divergence | No | AnchorBrowser AI executes autonomously |
51+
| Prompt injection via web content | No | AnchorBrowser's responsibility — report to them |
52+
53+
### Defense-in-depth recommendations
54+
55+
1. Use `default: deny` and explicit allow lists
56+
2. Keep `max_actions` low — budget limits blast radius even if patterns are bypassed
57+
3. Use `expire_after` — session auto-kills limit exposure window
58+
4. Review audit logs regularly — `npx leashed audit` or export JSONL to your SIEM
59+
5. For production: complement leashed with AnchorBrowser's own session monitoring

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "leashed",
3-
"version": "0.1.2",
3+
"version": "0.2.0",
44
"description": "AI got hands. This is the leash. Policy, audit, kill switch for any AI agent with access to your accounts.",
55
"type": "module",
66
"main": "dist/index.js",

src/cli.ts

Lines changed: 7 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { readFileSync, existsSync } from 'node:fs'
44
import AnchorClient from 'anchorbrowser'
5-
import type { AuditEvent } from './types.js'
5+
import { createAuditLogger } from './audit.js'
66
import { SESSION_FILE, DEFAULT_AUDIT_FILE } from './constants.js'
77

88
const AUDIT_FILE = DEFAULT_AUDIT_FILE
@@ -56,22 +56,8 @@ async function killSession() {
5656
try { unlinkSync(SESSION_FILE) } catch {}
5757
}
5858

59-
function readAuditEvents(): AuditEvent[] {
60-
if (!existsSync(AUDIT_FILE)) return []
61-
const events: AuditEvent[] = []
62-
for (const line of readFileSync(AUDIT_FILE, 'utf-8').split('\n')) {
63-
if (!line.trim()) continue
64-
try {
65-
events.push(JSON.parse(line) as AuditEvent)
66-
} catch {
67-
// Skip corrupt lines
68-
}
69-
}
70-
return events
71-
}
72-
7359
function printStatus() {
74-
const events = readAuditEvents()
60+
const events = createAuditLogger(AUDIT_FILE).export()
7561
if (events.length === 0) {
7662
console.log('No audit events found.')
7763
return
@@ -80,6 +66,7 @@ function printStatus() {
8066
const allowed = events.filter(e => e.action === 'allowed').length
8167
const blocked = events.filter(e => e.action === 'blocked').length
8268
const errors = events.filter(e => e.action === 'error').length
69+
const flagged = events.filter(e => e.flags && e.flags.length > 0).length
8370
const killed = events.some(e => e.action === 'killed')
8471
const agent = events[0]?.agent ?? 'unknown'
8572

@@ -88,11 +75,12 @@ function printStatus() {
8875
console.log(`Allowed: ${allowed}`)
8976
console.log(`Blocked: ${blocked}`)
9077
console.log(`Errors: ${errors}`)
78+
console.log(`Flagged: ${flagged}`)
9179
console.log(`Total: ${events.length}`)
9280
}
9381

9482
function printAudit() {
95-
const events = readAuditEvents()
83+
const events = createAuditLogger(AUDIT_FILE).export()
9684
if (events.length === 0) {
9785
console.log('No audit events found.')
9886
return
@@ -106,7 +94,8 @@ function printAudit() {
10694
const action = e.action.padEnd(9)
10795
const task = e.task.length > 40 ? e.task.slice(0, 37) + '...' : e.task
10896
const reason = e.reason ? ` (${e.reason})` : ''
109-
console.log(`${time} ${action} ${task}${reason}`)
97+
const flagIndicator = e.flags && e.flags.length > 0 ? ' [!]' : ''
98+
console.log(`${time} ${action} ${task}${reason}${flagIndicator}`)
11099
}
111100
}
112101

src/index.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ import { writeFileSync, existsSync } from 'node:fs'
22
import { loadPolicy, evaluatePolicy } from './policy.js'
33
import { createAuditLogger, type AuditLogger } from './audit.js'
44
import { createSessionManager, type SessionManager } from './session.js'
5-
import type { LeashConfig, LeashResult, AuditEvent, LeashStatus } from './types.js'
5+
import { scanOutput } from './output-scanner.js'
6+
import type { LeashConfig, LeashResult, AuditEvent, LeashStatus, OutputFlag } from './types.js'
67
import { SESSION_FILE, DEFAULT_AUDIT_FILE } from './constants.js'
78

8-
export type { LeashConfig, LeashResult, AuditEvent, LeashStatus } from './types.js'
9+
export type { LeashConfig, LeashResult, AuditEvent, LeashStatus, OutputFlag } from './types.js'
910
export { loadPolicy, evaluatePolicy, matchesPattern } from './policy.js'
1011
export { createAuditLogger } from './audit.js'
1112
export { createSessionManager } from './session.js'
13+
export { scanOutput } from './output-scanner.js'
1214
export { SESSION_FILE, DEFAULT_AUDIT_FILE } from './constants.js'
1315

1416
export interface Leash {
@@ -98,6 +100,7 @@ export function createLeash(
98100
task: description,
99101
action: 'blocked',
100102
reason: expiredReason,
103+
...(config.domains?.length ? { domains: config.domains } : {}),
101104
}
102105
logger.log(event)
103106
blockedCount++
@@ -115,6 +118,7 @@ export function createLeash(
115118
task: description,
116119
action: 'blocked',
117120
reason: decision.reason,
121+
...(config.domains?.length ? { domains: config.domains } : {}),
118122
}
119123
logger.log(event)
120124
blockedCount++
@@ -136,17 +140,25 @@ export function createLeash(
136140
// Non-fatal: CLI yank won't work but task still succeeds
137141
}
138142

143+
// Post-execution output scan — detect deny keywords in output
144+
const flags = scanOutput(output, config)
145+
139146
const event: AuditEvent = {
140147
id: auditId,
141148
timestamp: new Date().toISOString(),
142149
agent: agentName,
143150
task: description,
144151
action: 'allowed',
145152
duration,
153+
...(flags.length > 0 ? { flags } : {}),
154+
...(config.domains?.length ? { domains: config.domains } : {}),
146155
}
147156
logger.log(event)
148157
allowedCount++
149-
return { allowed: true, output, auditId }
158+
159+
const result: LeashResult = { allowed: true, output, auditId }
160+
if (flags.length > 0) result.flags = flags
161+
return result
150162
} catch (err) {
151163
const event: AuditEvent = {
152164
id: auditId,

src/output-scanner.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { LeashConfig, OutputFlag } from './types.js'
2+
3+
/**
4+
* Scan AnchorBrowser output for keywords from deny patterns.
5+
* Detection only — flags suspicious content for audit review.
6+
*/
7+
export function scanOutput(output: string, config: LeashConfig): OutputFlag[] {
8+
if (!output || !config.deny?.length) return []
9+
10+
const flags: OutputFlag[] = []
11+
const normalizedOutput = output.toLowerCase()
12+
13+
for (const pattern of config.deny) {
14+
const keyword = extractKeyword(pattern)
15+
if (!keyword) continue
16+
17+
const idx = normalizedOutput.indexOf(keyword)
18+
if (idx !== -1) {
19+
const start = Math.max(0, idx - 20)
20+
const end = Math.min(output.length, idx + keyword.length + 20)
21+
const snippet = output.slice(start, end).trim()
22+
flags.push({ pattern, keyword, snippet })
23+
}
24+
}
25+
26+
return flags
27+
}
28+
29+
/**
30+
* Extract a matchable keyword from a glob pattern.
31+
* "*send*" → "send", "delete*" → "delete", "*" → null
32+
*/
33+
function extractKeyword(pattern: string): string | null {
34+
const keyword = pattern.replace(/\*/g, '').trim().toLowerCase()
35+
return keyword.length >= 2 ? keyword : null
36+
}

src/policy.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export function loadPolicy(configOrPath: string | LeashConfig): LeashConfig {
5151
default: policy.default ?? 'deny',
5252
expire: policy.expire_after,
5353
maxActions: policy.max_actions,
54+
domains: policy.domains,
5455
}
5556
}
5657
return {

src/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,21 @@ export interface LeashConfig {
55
expire?: string
66
maxActions?: number
77
agent?: string
8+
domains?: string[]
9+
}
10+
11+
export interface OutputFlag {
12+
pattern: string
13+
keyword: string
14+
snippet: string
815
}
916

1017
export interface LeashResult {
1118
allowed: boolean
1219
output?: string
1320
reason?: string
1421
auditId: string
22+
flags?: OutputFlag[]
1523
}
1624

1725
export interface AuditEvent {
@@ -22,6 +30,8 @@ export interface AuditEvent {
2230
action: 'allowed' | 'blocked' | 'error' | 'killed'
2331
reason?: string
2432
duration?: number
33+
flags?: OutputFlag[]
34+
domains?: string[]
2535
}
2636

2737
export interface LeashStatus {
@@ -42,4 +52,5 @@ export interface YamlPolicy {
4252
default?: 'allow' | 'deny'
4353
expire_after?: string
4454
max_actions?: number
55+
domains?: string[]
4556
}

tests/output-scanner.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { scanOutput } from '../src/output-scanner.js'
3+
import type { LeashConfig } from '../src/types.js'
4+
5+
describe('scanOutput', () => {
6+
const config: LeashConfig = {
7+
allow: ['read*', 'check*'],
8+
deny: ['*send*', '*delete*', '*export*'],
9+
default: 'deny',
10+
}
11+
12+
it('returns empty array when no deny keywords match output', () => {
13+
const flags = scanOutput('Here are your 5 unread messages from today.', config)
14+
expect(flags).toEqual([])
15+
})
16+
17+
it('flags output containing a deny keyword', () => {
18+
const flags = scanOutput('Successfully exported 500 contacts to CSV file.', config)
19+
expect(flags).toHaveLength(1)
20+
expect(flags[0].pattern).toBe('*export*')
21+
expect(flags[0].keyword).toBe('export')
22+
expect(flags[0].snippet).toContain('export')
23+
})
24+
25+
it('flags multiple deny keywords in same output', () => {
26+
const flags = scanOutput('Deleted 3 messages and exported the archive.', config)
27+
expect(flags).toHaveLength(2)
28+
const keywords = flags.map(f => f.keyword)
29+
expect(keywords).toContain('delete')
30+
expect(keywords).toContain('export')
31+
})
32+
33+
it('is case-insensitive', () => {
34+
const flags = scanOutput('EXPORTED all contacts to spreadsheet', config)
35+
expect(flags).toHaveLength(1)
36+
expect(flags[0].keyword).toBe('export')
37+
})
38+
39+
it('returns empty array for empty output', () => {
40+
expect(scanOutput('', config)).toEqual([])
41+
})
42+
43+
it('returns empty array for null-ish output', () => {
44+
expect(scanOutput(null as unknown as string, config)).toEqual([])
45+
expect(scanOutput(undefined as unknown as string, config)).toEqual([])
46+
})
47+
48+
it('returns empty array when config has no deny patterns', () => {
49+
const noDeny: LeashConfig = { allow: ['*'], default: 'allow' }
50+
const flags = scanOutput('exported everything', noDeny)
51+
expect(flags).toEqual([])
52+
})
53+
54+
it('skips wildcard-only patterns', () => {
55+
const wildcardConfig: LeashConfig = { deny: ['*'], default: 'deny' }
56+
const flags = scanOutput('some output text', wildcardConfig)
57+
expect(flags).toEqual([])
58+
})
59+
60+
it('provides context snippet around matched keyword', () => {
61+
const output = 'The system successfully exported all 500 contacts to a CSV file on disk.'
62+
const flags = scanOutput(output, config)
63+
expect(flags).toHaveLength(1)
64+
// Snippet should contain surrounding context, not just the keyword
65+
expect(flags[0].snippet.length).toBeGreaterThan('export'.length)
66+
})
67+
})

0 commit comments

Comments
 (0)