kira-glasses-bridge 2.0.0 → 2.2.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kira-glasses-bridge",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
4
4
  "description": "Connects your Mac to Kira, the assistant for Even G2 glasses: Claude Code sessions, files, folders and apps, by voice.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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, remembered for the rest of the process per session: `${sessionId}` -> Set of rule keys. */
137
- const sessionAllows = new Map()
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: force the prompt for anything the policy does not allow, so
161
- // a permissive local config (e.g. Bash(*) allowed) cannot bypass the person.
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 = policy(input.tool_name, input.tool_input ?? {}, cwd, session)
165
- return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: decision, permissionDecisionReason: decision === 'ask' ? 'Kira asks the user' : 'Kira policy' } }
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
- if (policy(toolName, input, cwd, session) === 'allow') return { behavior: 'allow', updatedInput: input }
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
- io.emit({ kind: 'request_resolved', id: request.id, answer: resolved.answer, by: resolved.by })
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 (!sessionAllows.has(session)) sessionAllows.set(session, new Set())
181
- sessionAllows.get(session).add(ruleKey(toolName, input))
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/git.mjs CHANGED
@@ -153,3 +153,31 @@ async function summary(before, root, files, diff, truncated, commits, treeAfter)
153
153
  truncated,
154
154
  }
155
155
  }
156
+
157
+ /**
158
+ * Make a project folder ready: create it, then either clone the repo into it
159
+ * (SSH first, HTTPS as fallback) or start a fresh git repo with a README.
160
+ */
161
+ export async function setupProjectFolder({ dir, name, description, sshUrl, httpsUrl }) {
162
+ const { mkdir, readdir, writeFile, stat } = await import('node:fs/promises')
163
+ const { execFile } = await import('node:child_process')
164
+ const run = (args, cwd) =>
165
+ new Promise(resolve => execFile('git', args, { cwd, maxBuffer: 8 * 1024 * 1024, timeout: 120_000 }, (err, stdout, stderr) => resolve({ ok: !err, out: String(stdout ?? ''), err: String(stderr ?? err?.message ?? '') })))
166
+ await mkdir(dir, { recursive: true })
167
+ const entries = (await readdir(dir)).filter(e => e !== '.DS_Store')
168
+ const already = await stat(`${dir}/.git`).then(s => s.isDirectory()).catch(() => false)
169
+ if (already) return { path: display(dir), existing: true, note: 'already a git repository' }
170
+ if (sshUrl || httpsUrl) {
171
+ if (entries.length) return { error: `${display(dir)} is not empty; choose an empty folder to clone into` }
172
+ let r = sshUrl ? await run(['clone', sshUrl, dir], dir) : { ok: false, err: 'no ssh url' }
173
+ if (!r.ok && httpsUrl) r = await run(['clone', httpsUrl, dir], dir)
174
+ if (!r.ok) return { error: `could not clone: ${r.err.trim().split('\n').pop()}` }
175
+ return { path: display(dir), cloned: true }
176
+ }
177
+ const r = await run(['init', '-q'], dir)
178
+ if (!r.ok) return { error: `git init failed: ${r.err.trim()}` }
179
+ if (!entries.includes('README.md')) await writeFile(`${dir}/README.md`, `# ${name ?? display(dir).split('/').pop()}\n${description ? `\n${description}\n` : ''}`)
180
+ await run(['add', '-A'], dir)
181
+ const c = await run(['commit', '-q', '-m', 'Initial commit'], dir)
182
+ return { path: display(dir), initialized: true, committed: c.ok, ...(c.ok ? {} : { note: 'git init done; the first commit needs git user.name and user.email configured' }) }
183
+ }
package/src/index.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  // machine on the user's behalf. Env: KIRA_BRIDGE_URL, KIRA_BRIDGE_TOKEN.
4
4
  import WebSocket from 'ws'
5
5
  import { listProjects, listSessions, readSession } from './claude-sessions.mjs'
6
- import { snapshot, changesSince, display } from './git.mjs'
6
+ import { snapshot, changesSince, display, setupProjectFolder } from './git.mjs'
7
7
  import { resolveCwd } from './claude-run.mjs'
8
8
  import { agentById, AGENTS } from './agents/index.mjs'
9
9
  import { normalizeAnswer } from './agents/protocol.mjs'
@@ -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))
@@ -118,6 +118,12 @@ const tools = {
118
118
  return tools.run_agent({ ...args, agent: 'claude' }, ws)
119
119
  },
120
120
 
121
+ /** A new project's folder on this Mac: created, cloned from its repo or started fresh. */
122
+ async setup_project_folder(args) {
123
+ const dir = resolveUserPath(String(args.path ?? ''))
124
+ return setupProjectFolder({ dir, name: args.name, description: args.description, sshUrl: args.ssh_url, httpsUrl: args.https_url })
125
+ },
126
+
121
127
  /** Read-only git facts for a folder: repo, branch, head, dirty files. */
122
128
  async git_status(args) {
123
129
  const s = await snapshot(resolveCwd(args.path ?? args.cwd))