Skip to content

Commit b431b57

Browse files
alari76claude
andcommitted
Refactor: reduce code complexity across 7 modules per audit findings
- Unify processMessage/rebuildFromHistory via shared applyMessageMut() - Extract usePromptState hook (7 useState → 1 hook) from useChatSocket - Extract RepoSection component (331 lines) from LeftSidebar - Extract 8 WorkflowsView sub-components into src/components/workflows/ - Extract useSessionOrchestration hook (153 lines) from App.tsx - Extract WebhookHandlerBase generic class shared by webhook + stepflow - Split SessionManager: extract ApprovalManager, SessionNaming, SessionPersistence Net: -1200 lines from existing files, zero behavior changes. All 855 tests pass, build clean, zero lint errors. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 587a1ce commit b431b57

22 files changed

Lines changed: 1899 additions & 1200 deletions

dist/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
66
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
77
<title>Codekin</title>
8-
<script type="module" crossorigin src="/assets/index-TfzCnhC7.js"></script>
8+
<script type="module" crossorigin src="/assets/index-DPudnR8n.js"></script>
99
<link rel="stylesheet" crossorigin href="/assets/index-dNFIrKVU.css">
1010
</head>
1111
<body class="bg-neutral-12 text-neutral-2">

server/approval-manager.ts

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
/**
2+
* Approval manager for Codekin.
3+
*
4+
* Manages repo-level auto-approval rules for tools and Bash commands.
5+
* Approvals are stored per-repo (keyed by workingDir) in
6+
* ~/.codekin/repo-approvals.json, so they persist across sessions
7+
* sharing the same repo.
8+
*/
9+
10+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs'
11+
import { join } from 'path'
12+
import { DATA_DIR } from './config.js'
13+
14+
const REPO_APPROVALS_FILE = join(DATA_DIR, 'repo-approvals.json')
15+
const PERSIST_DEBOUNCE_MS = 2000
16+
17+
export class ApprovalManager {
18+
/** Repo-level auto-approval store, keyed by workingDir (repo path). */
19+
private repoApprovals = new Map<string, { tools: Set<string>; commands: Set<string>; patterns: Set<string> }>()
20+
private _approvalPersistTimer: ReturnType<typeof setTimeout> | null = null
21+
22+
constructor() {
23+
this.restoreRepoApprovalsFromDisk()
24+
}
25+
26+
/** Get or create the approval entry for a repo (workingDir). */
27+
private getRepoApprovalEntry(workingDir: string): { tools: Set<string>; commands: Set<string>; patterns: Set<string> } {
28+
let entry = this.repoApprovals.get(workingDir)
29+
if (!entry) {
30+
entry = { tools: new Set(), commands: new Set(), patterns: new Set() }
31+
this.repoApprovals.set(workingDir, entry)
32+
}
33+
return entry
34+
}
35+
36+
/** Add an auto-approval rule for a repo and persist. */
37+
private addRepoApproval(workingDir: string, opts: { tool?: string; command?: string; pattern?: string }): void {
38+
const entry = this.getRepoApprovalEntry(workingDir)
39+
if (opts.tool) entry.tools.add(opts.tool)
40+
if (opts.command) entry.commands.add(opts.command)
41+
if (opts.pattern) entry.patterns.add(opts.pattern)
42+
this.persistRepoApprovalsDebounced()
43+
}
44+
45+
/**
46+
* Command prefixes where prefix-based auto-approval is safe.
47+
* Only commands whose behavior is determined by later arguments (not by target)
48+
* should be listed here. Dangerous commands like rm, sudo, curl, etc. require
49+
* exact match to prevent escalation (e.g. approving `rm -rf /tmp/x` should NOT
50+
* also approve `rm -rf /`).
51+
*/
52+
private static readonly SAFE_PREFIX_COMMANDS = new Set([
53+
'git add', 'git commit', 'git diff', 'git log', 'git show', 'git stash',
54+
'git status', 'git branch', 'git checkout', 'git switch', 'git rebase',
55+
'git fetch', 'git pull', 'git merge', 'git tag', 'git rev-parse',
56+
'npm run', 'npm test', 'npm install', 'npm ci', 'npm exec',
57+
'npx', 'node', 'bun', 'deno',
58+
'cargo build', 'cargo test', 'cargo run', 'cargo check', 'cargo clippy',
59+
'make', 'cmake',
60+
'python', 'python3', 'pip install',
61+
'go build', 'go test', 'go run', 'go vet',
62+
'tsc', 'eslint', 'prettier',
63+
'cat', 'head', 'tail', 'wc', 'sort', 'uniq', 'diff', 'less',
64+
'ls', 'pwd', 'echo', 'date', 'which', 'whoami', 'env', 'printenv',
65+
'find', 'grep', 'rg', 'ag', 'fd',
66+
'mkdir', 'touch',
67+
])
68+
69+
/**
70+
* Check if a tool/command is auto-approved for a repo.
71+
* For Bash commands, uses prefix matching only for safe commands;
72+
* dangerous commands require exact match to prevent escalation.
73+
*/
74+
checkAutoApproval(workingDir: string, toolName: string, toolInput: Record<string, unknown>): boolean {
75+
const approvals = this.getRepoApprovalEntry(workingDir)
76+
if (approvals.tools.has(toolName)) return true
77+
if (toolName === 'Bash') {
78+
const cmd = String(toolInput.command || '').trim()
79+
// Exact match always works
80+
if (approvals.commands.has(cmd)) return true
81+
// Pattern match (e.g. "cat *" matches any cat command)
82+
for (const pattern of approvals.patterns) {
83+
if (this.matchesPattern(pattern, cmd)) return true
84+
}
85+
// Prefix match only for safe commands
86+
const cmdPrefix = this.commandPrefix(cmd)
87+
if (cmdPrefix && ApprovalManager.SAFE_PREFIX_COMMANDS.has(cmdPrefix)) {
88+
for (const approved of approvals.commands) {
89+
if (this.commandPrefix(approved) === cmdPrefix) return true
90+
}
91+
}
92+
}
93+
return false
94+
}
95+
96+
/** Extract the command prefix (first two tokens) for prefix-based matching. */
97+
private commandPrefix(cmd: string): string {
98+
const tokens = cmd.split(/\s+/).filter(Boolean)
99+
if (tokens.length === 0) return ''
100+
if (tokens.length === 1) return tokens[0]
101+
return `${tokens[0]} ${tokens[1]}`
102+
}
103+
104+
/**
105+
* Derive a glob pattern from a tool invocation for "Approve Pattern".
106+
* Returns a string like "cat *" or "git diff *", or null if no safe pattern applies.
107+
* Patterns use the format "<prefix> *" meaning "this prefix followed by anything".
108+
*/
109+
derivePattern(toolName: string, toolInput: Record<string, unknown>): string | null {
110+
if (toolName !== 'Bash') return null
111+
const cmd = String(toolInput.command || '').trim()
112+
const tokens = cmd.split(/\s+/).filter(Boolean)
113+
if (tokens.length === 0) return null
114+
115+
// Check single-token safe commands (cat, grep, ls, etc.)
116+
const first = tokens[0]
117+
if (ApprovalManager.SAFE_PREFIX_COMMANDS.has(first)) {
118+
return `${first} *`
119+
}
120+
121+
// Check two-token safe commands (git diff, npm run, etc.)
122+
if (tokens.length >= 2) {
123+
const twoToken = `${tokens[0]} ${tokens[1]}`
124+
if (ApprovalManager.SAFE_PREFIX_COMMANDS.has(twoToken)) {
125+
return `${twoToken} *`
126+
}
127+
}
128+
129+
return null
130+
}
131+
132+
/**
133+
* Check if a bash command matches a stored pattern.
134+
* Patterns of the form "<prefix> *" match any command starting with <prefix>.
135+
*/
136+
private matchesPattern(pattern: string, cmd: string): boolean {
137+
if (pattern.endsWith(' *')) {
138+
const prefix = pattern.slice(0, -2)
139+
return cmd === prefix || cmd.startsWith(prefix + ' ')
140+
}
141+
return cmd === pattern
142+
}
143+
144+
/** Save an "Always Allow" approval for a tool/command. */
145+
saveAlwaysAllow(workingDir: string, toolName: string, toolInput: Record<string, unknown>): void {
146+
if (toolName === 'Bash') {
147+
const cmd = String(toolInput.command || '').trim()
148+
this.addRepoApproval(workingDir, { command: cmd })
149+
console.log(`[auto-approve] saved command for repo ${workingDir}: ${cmd.slice(0, 80)}`)
150+
} else {
151+
this.addRepoApproval(workingDir, { tool: toolName })
152+
console.log(`[auto-approve] saved tool for repo ${workingDir}: ${toolName}`)
153+
}
154+
}
155+
156+
/** Save a pattern-based approval (e.g. "cat *") for a tool/command. */
157+
savePatternApproval(workingDir: string, toolName: string, toolInput: Record<string, unknown>): void {
158+
const pattern = this.derivePattern(toolName, toolInput)
159+
if (pattern) {
160+
this.addRepoApproval(workingDir, { pattern })
161+
console.log(`[auto-approve] saved pattern for repo ${workingDir}: ${pattern}`)
162+
} else {
163+
// No pattern derivable — skip saving rather than silently escalating to always-allow
164+
console.log(`[auto-approve] no pattern derivable for ${toolName}, skipping pattern save`)
165+
}
166+
}
167+
168+
/** Return the auto-approved tools, commands, and patterns for a repo (workingDir). */
169+
getApprovals(workingDir: string): { tools: string[]; commands: string[]; patterns: string[] } {
170+
const entry = this.repoApprovals.get(workingDir)
171+
if (!entry) return { tools: [], commands: [], patterns: [] }
172+
return {
173+
tools: Array.from(entry.tools).sort(),
174+
commands: Array.from(entry.commands).sort(),
175+
patterns: Array.from(entry.patterns).sort(),
176+
}
177+
}
178+
179+
/** Remove an auto-approval rule for a repo (workingDir) and persist to disk.
180+
* Returns 'invalid' if no tool/command provided, or boolean indicating if something was deleted.
181+
* Pass skipPersist=true for bulk operations (caller must call persistRepoApprovals after). */
182+
removeApproval(workingDir: string, opts: { tool?: string; command?: string; pattern?: string }, skipPersist = false): 'invalid' | boolean {
183+
const tool = typeof opts.tool === 'string' ? opts.tool.trim() : ''
184+
const command = typeof opts.command === 'string' ? opts.command.trim() : ''
185+
const pattern = typeof opts.pattern === 'string' ? opts.pattern.trim() : ''
186+
if (!tool && !command && !pattern) return 'invalid'
187+
const entry = this.repoApprovals.get(workingDir)
188+
if (!entry) return false
189+
let removed = false
190+
if (tool) removed = entry.tools.delete(tool) || removed
191+
if (command) removed = entry.commands.delete(command) || removed
192+
if (pattern) removed = entry.patterns.delete(pattern) || removed
193+
if (removed && !skipPersist) this.persistRepoApprovals()
194+
return removed
195+
}
196+
197+
/** Write repo-level approvals to disk (atomic rename). */
198+
persistRepoApprovals(): void {
199+
const data: Record<string, { tools: string[]; commands: string[]; patterns: string[] }> = {}
200+
for (const [dir, entry] of this.repoApprovals) {
201+
// Only persist non-empty entries
202+
if (entry.tools.size > 0 || entry.commands.size > 0 || entry.patterns.size > 0) {
203+
data[dir] = {
204+
tools: Array.from(entry.tools).sort(),
205+
commands: Array.from(entry.commands).sort(),
206+
patterns: Array.from(entry.patterns).sort(),
207+
}
208+
}
209+
}
210+
211+
try {
212+
mkdirSync(DATA_DIR, { recursive: true })
213+
const tmp = REPO_APPROVALS_FILE + '.tmp'
214+
writeFileSync(tmp, JSON.stringify(data, null, 2))
215+
renameSync(tmp, REPO_APPROVALS_FILE)
216+
} catch (err) {
217+
console.error('Failed to persist repo approvals:', err)
218+
}
219+
}
220+
221+
private persistRepoApprovalsDebounced(): void {
222+
if (this._approvalPersistTimer) return
223+
this._approvalPersistTimer = setTimeout(() => {
224+
this._approvalPersistTimer = null
225+
this.persistRepoApprovals()
226+
}, PERSIST_DEBOUNCE_MS)
227+
}
228+
229+
private restoreRepoApprovalsFromDisk(): void {
230+
if (!existsSync(REPO_APPROVALS_FILE)) return
231+
232+
try {
233+
const raw = readFileSync(REPO_APPROVALS_FILE, 'utf-8')
234+
const data = JSON.parse(raw) as Record<string, { tools?: string[]; commands?: string[]; patterns?: string[] }>
235+
236+
for (const [dir, entry] of Object.entries(data)) {
237+
this.repoApprovals.set(dir, {
238+
tools: new Set(entry.tools || []),
239+
commands: new Set(entry.commands || []),
240+
patterns: new Set(entry.patterns || []),
241+
})
242+
}
243+
244+
console.log(`Restored repo approvals for ${Object.keys(data).length} repo(s) from disk`)
245+
} catch (err) {
246+
console.error('Failed to restore repo approvals from disk:', err)
247+
}
248+
}
249+
}

0 commit comments

Comments
 (0)