kira-glasses-bridge 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agents/claude.mjs +19 -31
- package/src/agents/policy.mjs +97 -0
- package/src/index.mjs +1 -1
package/package.json
CHANGED
package/src/agents/claude.mjs
CHANGED
|
@@ -4,14 +4,11 @@ import { query } from '@anthropic-ai/claude-agent-sdk'
|
|
|
4
4
|
import { homedir } from 'node:os'
|
|
5
5
|
import { relative, resolve, sep } from 'node:path'
|
|
6
6
|
import { PERMISSION_OPTIONS, PLAN_OPTIONS, requestId } from './protocol.mjs'
|
|
7
|
+
import { decide, ruleFor } from './policy.mjs'
|
|
7
8
|
|
|
8
9
|
const TEXT_CAP = 4000
|
|
9
10
|
const DIFF_CAP = 6000
|
|
10
11
|
|
|
11
|
-
const READ_ONLY = new Set(['Read', 'Glob', 'Grep', 'LS', 'WebFetch', 'WebSearch', 'TodoWrite', 'TodoRead', 'NotebookRead', 'Task', 'Skill'])
|
|
12
|
-
const EDITS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
|
|
13
|
-
const SAFE_SHELL = /^(?:ls|pwd|cat|head|tail|wc|echo|which|type|env|printenv|date|whoami|tree|find|grep|rg|awk|sort|uniq|diff|stat|file|du|df|git (?:status|diff|log|show|branch|rev-parse|remote|ls-files|blame|describe|stash list)|node (?:-v|--version)|npm (?:-v|--version|ls|list|view|test|run (?:test|lint|typecheck|check|build))|pnpm (?:-v|--version|ls|list|test|typecheck|lint|build|run (?:test|lint|typecheck|check|build))|yarn (?:-v|--version|test|lint|typecheck|build)|cargo (?:check|test|build|fmt|clippy)|go (?:test|build|vet|version)|python3? -m pytest|pytest|make (?:test|check|lint|build))(?:\s|$)/
|
|
14
|
-
|
|
15
12
|
const short = (s, n = 160) => (String(s ?? '').length > n ? `${String(s).slice(0, n)}…` : String(s ?? ''))
|
|
16
13
|
|
|
17
14
|
function displayPath(p, cwd) {
|
|
@@ -133,40 +130,30 @@ function applyAnswer(name, input, request, answer, suggestions) {
|
|
|
133
130
|
* @param io.ask(request) resolves to { answer, by } when a person answers (the caller times it out)
|
|
134
131
|
* @returns { ok, text, error, sessionId, stats }
|
|
135
132
|
*/
|
|
136
|
-
/** "Always allow" answers,
|
|
137
|
-
const
|
|
138
|
-
function ruleKey(toolName, input) {
|
|
139
|
-
if (toolName === 'Bash') return `Bash:${String(input.command ?? '').trim().split(/\s+/)[0]}`
|
|
140
|
-
return toolName
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* The bridge's own policy, applied to every tool call regardless of what the
|
|
145
|
-
* user's local Claude Code settings would do. Returns 'allow' or 'ask'.
|
|
146
|
-
*/
|
|
147
|
-
function policy(toolName, input, cwd, session) {
|
|
148
|
-
if (READ_ONLY.has(toolName)) return 'allow'
|
|
149
|
-
if (EDITS.has(toolName) && inside(cwd, input.file_path ?? input.notebook_path)) return 'allow'
|
|
150
|
-
if (toolName === 'Bash' && SAFE_SHELL.test(String(input.command ?? '').trim())) return 'allow'
|
|
151
|
-
if (session && sessionAllows.get(session)?.has(ruleKey(toolName, input))) return 'allow'
|
|
152
|
-
return 'ask'
|
|
153
|
-
}
|
|
133
|
+
/** "Always allow" answers, kept for the rest of the process per session: sessionId -> Set of rules. */
|
|
134
|
+
const sessionRules = new Map()
|
|
154
135
|
|
|
155
|
-
export async function run({ prompt, sessionId, cwd }, io) {
|
|
136
|
+
export async function run({ prompt, sessionId, cwd, policy }, io) {
|
|
156
137
|
let session = sessionId ?? null
|
|
157
138
|
let text = ''
|
|
158
139
|
let error = null
|
|
140
|
+
const rulesFor = () => (session && sessionRules.get(session)) || new Set()
|
|
141
|
+
const judge = (toolName, input) => decide(policy, toolName, input ?? {}, cwd, rulesFor())
|
|
159
142
|
|
|
160
|
-
// Pre-tool hook:
|
|
161
|
-
// a permissive local config
|
|
143
|
+
// Pre-tool hook: the policy is authoritative. It forces the prompt for what
|
|
144
|
+
// it does not allow (so a permissive local config cannot bypass the person)
|
|
145
|
+
// and refuses outright what it denies.
|
|
162
146
|
const preToolUse = async input => {
|
|
163
147
|
if (input.hook_event_name !== 'PreToolUse') return {}
|
|
164
|
-
const decision =
|
|
165
|
-
|
|
148
|
+
const { decision, reason } = judge(input.tool_name, input.tool_input)
|
|
149
|
+
if (decision === 'deny') io.emit({ kind: 'request_resolved', id: `policy-${Date.now().toString(36)}`, answer: { optionIds: ['deny'] }, by: 'policy', tool: input.tool_name, reason })
|
|
150
|
+
return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: decision, permissionDecisionReason: `Kira: ${reason}` } }
|
|
166
151
|
}
|
|
167
152
|
|
|
168
153
|
const canUseTool = async (toolName, input, { signal, suggestions }) => {
|
|
169
|
-
|
|
154
|
+
const { decision, reason } = judge(toolName, input)
|
|
155
|
+
if (decision === 'allow') return { behavior: 'allow', updatedInput: input }
|
|
156
|
+
if (decision === 'deny') return { behavior: 'deny', message: `Refused by the user's policy (${reason}). Do not achieve the same effect another way; say what you could not do.` }
|
|
170
157
|
const request = toRequest(toolName, input, cwd, suggestions)
|
|
171
158
|
io.emit({ kind: 'request', request })
|
|
172
159
|
let resolved
|
|
@@ -175,10 +162,11 @@ export async function run({ prompt, sessionId, cwd }, io) {
|
|
|
175
162
|
} catch (err) {
|
|
176
163
|
resolved = { answer: { optionIds: [], text: err.message }, by: 'timeout' }
|
|
177
164
|
}
|
|
178
|
-
|
|
165
|
+
const rule = ruleFor(toolName, input)
|
|
166
|
+
io.emit({ kind: 'request_resolved', id: request.id, answer: resolved.answer, by: resolved.by, rule })
|
|
179
167
|
if ((resolved.answer.optionIds ?? []).includes('always') && session) {
|
|
180
|
-
if (!
|
|
181
|
-
|
|
168
|
+
if (!sessionRules.has(session)) sessionRules.set(session, new Set())
|
|
169
|
+
sessionRules.get(session).add(rule)
|
|
182
170
|
}
|
|
183
171
|
return applyAnswer(toolName, input, request, resolved.answer, suggestions)
|
|
184
172
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// The agent policy, enforced here on the Mac. The API sends the user's policy
|
|
2
|
+
// with every job; this decides allow / ask / deny for each tool call.
|
|
3
|
+
//
|
|
4
|
+
// { edits, editsOutside, shell, web, delegate, plan, allowRules, askRules, denyRules }
|
|
5
|
+
//
|
|
6
|
+
// Shell commands are split into their pieces (`;`, `&&`, `||`, `|`, newlines)
|
|
7
|
+
// and every piece has to be safe for the whole command to be safe.
|
|
8
|
+
import { relative, resolve, sep } from 'node:path'
|
|
9
|
+
|
|
10
|
+
export const READ_ONLY = new Set(['Read', 'Glob', 'Grep', 'LS', 'TodoWrite', 'TodoRead', 'NotebookRead', 'Skill'])
|
|
11
|
+
export const EDITS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
|
|
12
|
+
export const WEB = new Set(['WebFetch', 'WebSearch'])
|
|
13
|
+
|
|
14
|
+
/** Read-only commands: looking, listing, testing, type-checking, building. Nothing that writes, sends or installs. */
|
|
15
|
+
const SAFE_CMD = /^(?:ls|ll|pwd|cat|head|tail|wc|echo|printf|which|type|env|printenv|date|whoami|id|uname|tree|find|fd|grep|rg|ag|awk|cut|tr|sort|uniq|diff|comm|stat|file|du|df|basename|dirname|realpath|readlink|jq|yq|xxd|hexdump|strings|test|\[|true|false|sleep|md5|shasum|sha256sum|md5sum|column|nl|less|more|open -n --args|sed -n|sed -E -n|sed -n -E|git (?:status|diff|log|show|branch|rev-parse|remote|ls-files|ls-tree|blame|describe|stash list|tag|config --get|config -l|cat-file|grep|shortlog|count-objects|reflog)|node (?:-v|--version|-e|-p|--eval|--print)|npm (?:-v|--version|ls|list|view|outdated|why|test|run (?:test|lint|typecheck|check|build|format:check))|pnpm (?:-v|--version|ls|list|why|outdated|test|typecheck|lint|build|run (?:test|lint|typecheck|check|build))|yarn (?:-v|--version|list|test|lint|typecheck|build)|npx (?:tsc|eslint|prettier --check|jest|vitest|playwright test)|tsc|eslint|prettier --check|jest|vitest|cargo (?:check|test|build|fmt --check|clippy|tree|metadata)|go (?:test|build|vet|version|list|env)|python3? (?:-V|--version|-m pytest|-c)|pytest|ruby -v|make (?:test|check|lint|build)|mvn (?:test|compile)|gradle (?:test|build)|swift (?:build|test)|xcodebuild -list|brew (?:list|info|--version)|docker (?:ps|images|version)|gh (?:pr (?:list|view|status|checks)|issue (?:list|view)|run (?:list|view)|repo view|api)|heroku (?:logs|ps|releases|config(?::get)?|apps|info))(?:\s|$)/
|
|
16
|
+
|
|
17
|
+
/** Redirections that write somewhere other than nowhere. */
|
|
18
|
+
const WRITES = /(^|[^&\d])>(?!&)|>>|\btee\b|\bxargs\b|\beval\b|\bexec\b|`/
|
|
19
|
+
|
|
20
|
+
function pieces(command) {
|
|
21
|
+
// Strip quoted strings so separators inside them do not split; keep a placeholder.
|
|
22
|
+
const stripped = String(command).replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, '""')
|
|
23
|
+
return stripped
|
|
24
|
+
.split(/\n|;|&&|\|\||\|/)
|
|
25
|
+
.map(p => p.trim())
|
|
26
|
+
// Shell keywords are structure, not commands: drop them, and a "for x in …" header entirely.
|
|
27
|
+
.map(p => p.replace(/^(?:if|then|else|elif|fi|do|done|while|until)\b\s*/, '').replace(/^for\s+\w+\s+in\b.*$/, '').trim())
|
|
28
|
+
.filter(Boolean)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function matchesRule(rules, toolName, command) {
|
|
32
|
+
const c = (command ?? '').trim()
|
|
33
|
+
for (const r of rules) {
|
|
34
|
+
const rule = String(r).trim()
|
|
35
|
+
if (!rule) continue
|
|
36
|
+
if (rule === toolName) return true
|
|
37
|
+
if (toolName === 'Bash' && c && (c === rule || c.startsWith(rule + ' ') || pieces(c).some(p => p === rule || p.startsWith(rule + ' ')))) return true
|
|
38
|
+
}
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function insideFolder(cwd, p) {
|
|
43
|
+
const rel = relative(cwd, resolve(cwd, String(p ?? '')))
|
|
44
|
+
return !!rel && !rel.startsWith('..') && !rel.startsWith(sep)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Is this shell command read-only, judged piece by piece? Command substitutions are judged by what is inside them. */
|
|
48
|
+
export function shellIsSafe(command, allowRules = []) {
|
|
49
|
+
let c = String(command ?? '').trim()
|
|
50
|
+
if (!c) return false
|
|
51
|
+
// $(inner): safe only if the inner command is; then drop it so the outer check sees plain text.
|
|
52
|
+
for (let guard = 0; guard < 20; guard++) {
|
|
53
|
+
const m = /\$\(([^()]*)\)/.exec(c)
|
|
54
|
+
if (!m) break
|
|
55
|
+
if (!shellIsSafe(m[1], allowRules)) return false
|
|
56
|
+
c = c.slice(0, m.index) + '""' + c.slice(m.index + m[0].length)
|
|
57
|
+
}
|
|
58
|
+
if (WRITES.test(c.replace(/2>&1|2>\/dev\/null|>\/dev\/null|&>\/dev\/null/g, ''))) return false
|
|
59
|
+
return pieces(c).every(p => SAFE_CMD.test(p) || allowRules.some(r => r && (p === r || p.startsWith(String(r) + ' '))))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The rule key an "always allow" answer produces. */
|
|
63
|
+
export function ruleFor(toolName, input) {
|
|
64
|
+
if (toolName !== 'Bash') return toolName
|
|
65
|
+
const first = pieces(String(input?.command ?? ''))[0] ?? ''
|
|
66
|
+
const words = first.split(/\s+/)
|
|
67
|
+
// Two words for package managers and git ("npm run", "git push"), one otherwise.
|
|
68
|
+
return /^(?:git|npm|pnpm|yarn|npx|cargo|go|docker|gh|heroku|make)$/.test(words[0]) && words[1] ? `${words[0]} ${words[1]}` : words[0]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Decide for one tool call. Returns { decision: 'allow'|'ask'|'deny', reason }.
|
|
73
|
+
* @param sessionRules rules allowed earlier in this session ("always allow")
|
|
74
|
+
*/
|
|
75
|
+
export function decide(policy, toolName, input, cwd, sessionRules = new Set()) {
|
|
76
|
+
const p = policy ?? {}
|
|
77
|
+
const command = toolName === 'Bash' ? String(input?.command ?? '') : ''
|
|
78
|
+
if (matchesRule(p.denyRules ?? [], toolName, command)) return { decision: 'deny', reason: 'refused by your policy' }
|
|
79
|
+
if (toolName === 'AskUserQuestion') return { decision: 'ask', reason: 'a question for you' }
|
|
80
|
+
if (matchesRule(p.askRules ?? [], toolName, command)) return { decision: 'ask', reason: 'your policy always asks for this' }
|
|
81
|
+
if (matchesRule([...(p.allowRules ?? []), ...sessionRules], toolName, command)) return { decision: 'allow', reason: 'allowed by your policy' }
|
|
82
|
+
if (READ_ONLY.has(toolName)) return { decision: 'allow', reason: 'read-only' }
|
|
83
|
+
if (EDITS.has(toolName)) {
|
|
84
|
+
const file = input?.file_path ?? input?.notebook_path
|
|
85
|
+
if (insideFolder(cwd, file)) return { decision: p.edits === 'ask' ? 'ask' : 'allow', reason: 'edit inside the project' }
|
|
86
|
+
return { decision: p.editsOutside === 'deny' ? 'deny' : 'ask', reason: 'edit outside the project folder' }
|
|
87
|
+
}
|
|
88
|
+
if (toolName === 'Bash') {
|
|
89
|
+
if (p.shell === 'allow') return { decision: 'allow', reason: 'shell allowed by your policy' }
|
|
90
|
+
if (p.shell !== 'ask' && shellIsSafe(command, p.allowRules ?? [])) return { decision: 'allow', reason: 'read-only command' }
|
|
91
|
+
return { decision: 'ask', reason: 'shell command' }
|
|
92
|
+
}
|
|
93
|
+
if (WEB.has(toolName)) return { decision: p.web === 'ask' ? 'ask' : 'allow', reason: 'web' }
|
|
94
|
+
if (toolName === 'Task') return { decision: p.delegate === 'ask' ? 'ask' : 'allow', reason: 'subagent' }
|
|
95
|
+
if (toolName === 'ExitPlanMode') return { decision: p.plan === 'allow' ? 'allow' : 'ask', reason: 'plan approval' }
|
|
96
|
+
return { decision: 'ask', reason: 'unfamiliar tool' }
|
|
97
|
+
}
|
package/src/index.mjs
CHANGED
|
@@ -103,7 +103,7 @@ const tools = {
|
|
|
103
103
|
}, { once: true })
|
|
104
104
|
}),
|
|
105
105
|
}
|
|
106
|
-
void agent.run({ prompt, sessionId: args.session_id, cwd: dir }, io).then(async outcome => {
|
|
106
|
+
void agent.run({ prompt, sessionId: args.session_id, cwd: dir, policy: args.policy ?? {} }, io).then(async outcome => {
|
|
107
107
|
for (const key of [...answerWaits.keys()]) if (key.startsWith(`${jobId}:`)) answerWaits.delete(key)
|
|
108
108
|
let change = null
|
|
109
109
|
if (before) change = await changesSince(before).catch(err => (log(`git after job failed: ${err.message}`), null))
|