@zerwiz/sessrumnir 0.1.8-alpha
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/LICENSE +200 -0
- package/README.md +329 -0
- package/bin/sessrumnir-web.sh +117 -0
- package/bin/sessrumnir.js +169 -0
- package/out/main/index.js +10850 -0
- package/out/preload/index.js +719 -0
- package/out/renderer/assets/cormorant-cyrillic-400-normal-C8QS47vb.woff2 +0 -0
- package/out/renderer/assets/cormorant-cyrillic-400-normal-D3EsxgFc.woff +0 -0
- package/out/renderer/assets/cormorant-cyrillic-500-normal-B7dJQtg-.woff +0 -0
- package/out/renderer/assets/cormorant-cyrillic-500-normal-BLlg2W5x.woff2 +0 -0
- package/out/renderer/assets/cormorant-cyrillic-600-normal-BcIsDXzC.woff2 +0 -0
- package/out/renderer/assets/cormorant-cyrillic-600-normal-CBymlcNF.woff +0 -0
- package/out/renderer/assets/cormorant-cyrillic-ext-400-normal-Bgrpe4p1.woff +0 -0
- package/out/renderer/assets/cormorant-cyrillic-ext-400-normal-BlcaxZtM.woff2 +0 -0
- package/out/renderer/assets/cormorant-cyrillic-ext-500-normal-CdQuyvtc.woff +0 -0
- package/out/renderer/assets/cormorant-cyrillic-ext-500-normal-pZw22qtS.woff2 +0 -0
- package/out/renderer/assets/cormorant-cyrillic-ext-600-normal-Dr-wLyOm.woff +0 -0
- package/out/renderer/assets/cormorant-cyrillic-ext-600-normal-dLR2-a_v.woff2 +0 -0
- package/out/renderer/assets/cormorant-latin-400-normal-BGH8Vunh.woff2 +0 -0
- package/out/renderer/assets/cormorant-latin-400-normal-C3_-2Ua-.woff +0 -0
- package/out/renderer/assets/cormorant-latin-500-normal-Dj3SQ6fR.woff +0 -0
- package/out/renderer/assets/cormorant-latin-500-normal-EBdSCOD3.woff2 +0 -0
- package/out/renderer/assets/cormorant-latin-600-normal-B254Xisw.woff2 +0 -0
- package/out/renderer/assets/cormorant-latin-600-normal-DUW-TI76.woff +0 -0
- package/out/renderer/assets/cormorant-latin-ext-400-normal-DuQ88yz3.woff2 +0 -0
- package/out/renderer/assets/cormorant-latin-ext-400-normal-DuXFa1Dr.woff +0 -0
- package/out/renderer/assets/cormorant-latin-ext-500-normal-AH9qog1s.woff2 +0 -0
- package/out/renderer/assets/cormorant-latin-ext-500-normal-DAuUCO41.woff +0 -0
- package/out/renderer/assets/cormorant-latin-ext-600-normal-CD-dlDpD.woff +0 -0
- package/out/renderer/assets/cormorant-latin-ext-600-normal-D3T6BBXG.woff2 +0 -0
- package/out/renderer/assets/cormorant-vietnamese-400-normal-C-RiYxEf.woff2 +0 -0
- package/out/renderer/assets/cormorant-vietnamese-400-normal-DmUuA7Y2.woff +0 -0
- package/out/renderer/assets/cormorant-vietnamese-500-normal-DsPuwQHi.woff2 +0 -0
- package/out/renderer/assets/cormorant-vietnamese-500-normal-tGBW_mI7.woff +0 -0
- package/out/renderer/assets/cormorant-vietnamese-600-normal-Dar_cCT6.woff2 +0 -0
- package/out/renderer/assets/cormorant-vietnamese-600-normal-GIs8CF2x.woff +0 -0
- package/out/renderer/assets/ibm-plex-mono-cyrillic-300-normal-Ba-HN6uq.woff +0 -0
- package/out/renderer/assets/ibm-plex-mono-cyrillic-300-normal-ByB2hQUF.woff2 +0 -0
- package/out/renderer/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
- package/out/renderer/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
- package/out/renderer/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
- package/out/renderer/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
- package/out/renderer/assets/ibm-plex-mono-cyrillic-ext-300-normal-B8O3uHbr.woff +0 -0
- package/out/renderer/assets/ibm-plex-mono-cyrillic-ext-300-normal-BbtBtHoB.woff2 +0 -0
- package/out/renderer/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
- package/out/renderer/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
- package/out/renderer/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
- package/out/renderer/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
- package/out/renderer/assets/ibm-plex-mono-latin-300-normal-BPU54Tw3.woff +0 -0
- package/out/renderer/assets/ibm-plex-mono-latin-300-normal-BRS-C4KA.woff2 +0 -0
- package/out/renderer/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- package/out/renderer/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/out/renderer/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
- package/out/renderer/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- package/out/renderer/assets/ibm-plex-mono-latin-ext-300-normal-BEtQ2wm2.woff2 +0 -0
- package/out/renderer/assets/ibm-plex-mono-latin-ext-300-normal-CQbCaAQ4.woff +0 -0
- package/out/renderer/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
- package/out/renderer/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
- package/out/renderer/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
- package/out/renderer/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff +0 -0
- package/out/renderer/assets/ibm-plex-mono-vietnamese-300-normal-B6fSpYYV.woff +0 -0
- package/out/renderer/assets/ibm-plex-mono-vietnamese-300-normal-CpdbWO_2.woff2 +0 -0
- package/out/renderer/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
- package/out/renderer/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
- package/out/renderer/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff +0 -0
- package/out/renderer/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 +0 -0
- package/out/renderer/assets/index-CgJYtdL8.js +97387 -0
- package/out/renderer/assets/index-CyRDW9WV.css +4824 -0
- package/out/renderer/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- package/out/renderer/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- package/out/renderer/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- package/out/renderer/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- package/out/renderer/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/out/renderer/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/out/renderer/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- package/out/renderer/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/out/renderer/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/out/renderer/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/out/renderer/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/out/renderer/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/out/renderer/assets/newsreader-latin-ext-wght-italic-DFS986qL.woff2 +0 -0
- package/out/renderer/assets/newsreader-latin-ext-wght-normal-C-3rgBeH.woff2 +0 -0
- package/out/renderer/assets/newsreader-latin-wght-italic-Bxi8ein9.woff2 +0 -0
- package/out/renderer/assets/newsreader-latin-wght-normal-CCVVNp6i.woff2 +0 -0
- package/out/renderer/assets/newsreader-vietnamese-wght-italic-xAqemagA.woff2 +0 -0
- package/out/renderer/assets/newsreader-vietnamese-wght-normal-Czsa-EzN.woff2 +0 -0
- package/out/renderer/assets/openmoji-color-9qo_2DLG.woff2 +0 -0
- package/out/renderer/assets/pi-logo-8LswgQgB.svg +17 -0
- package/out/renderer/index.html +19 -0
- package/out/renderer/theme-boot.js +30 -0
- package/out/shared/src/shared/tsconfig.tsbuildinfo +1 -0
- package/package.json +183 -0
- package/resources/icons/generate_icons.py +38 -0
- package/resources/icons/icon-128.png +0 -0
- package/resources/icons/icon-16.png +0 -0
- package/resources/icons/icon-256.png +0 -0
- package/resources/icons/icon-32.png +0 -0
- package/resources/icons/icon-48.png +0 -0
- package/resources/icons/icon-512.png +0 -0
- package/resources/icons/icon-64.png +0 -0
- package/resources/icons/icon.icns +0 -0
- package/resources/icons/icon.ico +0 -0
- package/resources/icons/icon.png +0 -0
- package/resources/icons/icon.svg +55 -0
- package/resources/linux/after-install.sh +23 -0
- package/resources/linux/after-remove.sh +18 -0
- package/resources/locales/en/translation.json +1735 -0
- package/resources/permission-prompt-text.test.ts +111 -0
- package/resources/permission-prompt-text.ts +84 -0
- package/resources/permission-rules.test.ts +351 -0
- package/resources/permission-rules.ts +293 -0
- package/resources/pi-desktop-permissions.ts +62 -0
- package/resources/ymir-sessrumnir.desktop.in +12 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission rules engine for Pi Desktop.
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth shared by:
|
|
5
|
+
* - the bundled Pi extension `pi-desktop-permissions.ts` (loaded by Pi via
|
|
6
|
+
* jiti, which resolves this relative TS import at runtime), and
|
|
7
|
+
* - the Electron main process (bundled by electron-vite).
|
|
8
|
+
*
|
|
9
|
+
* Must therefore import only from `node:*` — no Electron, no Pi APIs.
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync, statSync } from 'node:fs'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
|
|
14
|
+
export const PERMISSION_RULES_VERSION = 1
|
|
15
|
+
export const PERMISSION_RULES_FILE_NAME = 'permission-rules.json'
|
|
16
|
+
export const WORKSPACE_RULES_DIR_NAME = '.pi-desktop'
|
|
17
|
+
export const ANY_TOOL = '*'
|
|
18
|
+
|
|
19
|
+
export type PermissionRuleAction = 'allow' | 'deny'
|
|
20
|
+
|
|
21
|
+
export interface PermissionRule {
|
|
22
|
+
action: PermissionRuleAction
|
|
23
|
+
tool: string
|
|
24
|
+
match?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PermissionRulesFile {
|
|
28
|
+
version: typeof PERMISSION_RULES_VERSION
|
|
29
|
+
rules: PermissionRule[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const FILE_KEYS = new Set(['version', 'rules'])
|
|
33
|
+
const RULE_KEYS = new Set(['action', 'tool', 'match'])
|
|
34
|
+
const RULE_ACTIONS = new Set<PermissionRuleAction>(['allow', 'deny'])
|
|
35
|
+
|
|
36
|
+
/** Validate untrusted JSON into a PermissionRulesFile. Throws on any problem. */
|
|
37
|
+
export function validatePermissionRulesFile(data: unknown): PermissionRulesFile {
|
|
38
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
39
|
+
throw new Error('permission rules must be a JSON object')
|
|
40
|
+
}
|
|
41
|
+
const file = data as Record<string, unknown>
|
|
42
|
+
for (const key of Object.keys(file)) {
|
|
43
|
+
if (!FILE_KEYS.has(key)) throw new Error(`unknown key "${key}" in permission rules file`)
|
|
44
|
+
}
|
|
45
|
+
if (file.version !== PERMISSION_RULES_VERSION) {
|
|
46
|
+
throw new Error(`unsupported permission rules version (expected ${PERMISSION_RULES_VERSION})`)
|
|
47
|
+
}
|
|
48
|
+
if (!Array.isArray(file.rules)) throw new Error('"rules" must be an array')
|
|
49
|
+
|
|
50
|
+
const rules: PermissionRule[] = file.rules.map((entry, index) => {
|
|
51
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
52
|
+
throw new Error(`rule ${index + 1} must be an object`)
|
|
53
|
+
}
|
|
54
|
+
const rule = entry as Record<string, unknown>
|
|
55
|
+
for (const key of Object.keys(rule)) {
|
|
56
|
+
if (!RULE_KEYS.has(key)) throw new Error(`unknown key "${key}" in rule ${index + 1}`)
|
|
57
|
+
}
|
|
58
|
+
if (typeof rule.action !== 'string' || !RULE_ACTIONS.has(rule.action as PermissionRuleAction)) {
|
|
59
|
+
throw new Error(`rule ${index + 1}: action must be "allow" or "deny"`)
|
|
60
|
+
}
|
|
61
|
+
if (typeof rule.tool !== 'string' || rule.tool.length === 0) {
|
|
62
|
+
throw new Error(`rule ${index + 1}: tool must be a non-empty string`)
|
|
63
|
+
}
|
|
64
|
+
if (rule.match !== undefined && typeof rule.match !== 'string') {
|
|
65
|
+
throw new Error(`rule ${index + 1}: match must be a string`)
|
|
66
|
+
}
|
|
67
|
+
const validated: PermissionRule = {
|
|
68
|
+
action: rule.action as PermissionRuleAction,
|
|
69
|
+
tool: rule.tool,
|
|
70
|
+
}
|
|
71
|
+
if (rule.match !== undefined) validated.match = rule.match
|
|
72
|
+
return validated
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
return { version: PERMISSION_RULES_VERSION, rules }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const REGEXP_META = /[.*+?^${}()|[\]\\]/g
|
|
79
|
+
|
|
80
|
+
function escapeRegExp(text: string): string {
|
|
81
|
+
return text.replace(REGEXP_META, '\\$&')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Compile a permission glob to an anchored RegExp. `*` matches any sequence
|
|
86
|
+
* of characters (the `s` flag makes `.` span newlines in multi-line
|
|
87
|
+
* commands); everything else is literal.
|
|
88
|
+
*/
|
|
89
|
+
export function globToRegExp(pattern: string, caseInsensitive: boolean): RegExp {
|
|
90
|
+
const body = pattern.split(ANY_TOOL).map(escapeRegExp).join('.*')
|
|
91
|
+
return new RegExp(`^${body}$`, caseInsensitive ? 'is' : 's')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type PrimaryInputKind = 'command' | 'path' | 'json'
|
|
95
|
+
|
|
96
|
+
export interface PrimaryInput {
|
|
97
|
+
value: string
|
|
98
|
+
kind: PrimaryInputKind
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const BASH_TOOL = 'bash'
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The single string a rule's glob is tested against: the bash command, a file
|
|
105
|
+
* tool's path, or (for anything else) the JSON-stringified input.
|
|
106
|
+
*/
|
|
107
|
+
export function getPrimaryInput(toolName: string, input: unknown): PrimaryInput {
|
|
108
|
+
if (input && typeof input === 'object' && !Array.isArray(input)) {
|
|
109
|
+
const data = input as Record<string, unknown>
|
|
110
|
+
if (toolName === BASH_TOOL && typeof data.command === 'string') {
|
|
111
|
+
return { value: data.command, kind: 'command' }
|
|
112
|
+
}
|
|
113
|
+
if (typeof data.path === 'string') {
|
|
114
|
+
return { value: data.path, kind: 'path' }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { value: JSON.stringify(input ?? null), kind: 'json' }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type RuleDecision =
|
|
121
|
+
| { decision: 'allow' | 'deny'; rule: PermissionRule }
|
|
122
|
+
| { decision: 'default' }
|
|
123
|
+
|
|
124
|
+
// Platforms whose default filesystems are case-insensitive; path globs match
|
|
125
|
+
// case-insensitively there so a deny on "*.env*" also catches ".ENV".
|
|
126
|
+
const CASE_INSENSITIVE_PATH_PLATFORMS = new Set(['win32', 'darwin'])
|
|
127
|
+
const BACKSLASH = /\\/g
|
|
128
|
+
|
|
129
|
+
function ruleMatches(rule: PermissionRule, toolName: string, primary: PrimaryInput, platform: string): boolean {
|
|
130
|
+
if (rule.tool !== ANY_TOOL && rule.tool !== toolName) return false
|
|
131
|
+
if (rule.match === undefined) return true
|
|
132
|
+
let value = primary.value
|
|
133
|
+
let pattern = rule.match
|
|
134
|
+
const caseInsensitive = primary.kind === 'path' && CASE_INSENSITIVE_PATH_PLATFORMS.has(platform)
|
|
135
|
+
if (primary.kind === 'path') {
|
|
136
|
+
value = value.replace(BACKSLASH, '/')
|
|
137
|
+
pattern = pattern.replace(BACKSLASH, '/')
|
|
138
|
+
}
|
|
139
|
+
return globToRegExp(pattern, caseInsensitive).test(value)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Deny beats allow; rule order in the file is irrelevant. */
|
|
143
|
+
export function evaluateRules(
|
|
144
|
+
rules: PermissionRule[],
|
|
145
|
+
toolName: string,
|
|
146
|
+
input: unknown,
|
|
147
|
+
platform: string
|
|
148
|
+
): RuleDecision {
|
|
149
|
+
const primary = getPrimaryInput(toolName, input)
|
|
150
|
+
const deny = rules.find((rule) => rule.action === 'deny' && ruleMatches(rule, toolName, primary, platform))
|
|
151
|
+
if (deny) return { decision: 'deny', rule: deny }
|
|
152
|
+
const allow = rules.find((rule) => rule.action === 'allow' && ruleMatches(rule, toolName, primary, platform))
|
|
153
|
+
if (allow) return { decision: 'allow', rule: allow }
|
|
154
|
+
return { decision: 'default' }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export type ToolCallDecision =
|
|
158
|
+
| { action: 'block'; reason: string }
|
|
159
|
+
| { action: 'allow' }
|
|
160
|
+
| { action: 'prompt' }
|
|
161
|
+
|
|
162
|
+
// Pi's original ask modes only needed the three built-ins below. OMP exposes
|
|
163
|
+
// additional write/execute surfaces, so keep the same safety posture when the
|
|
164
|
+
// engine is selected explicitly.
|
|
165
|
+
const ASK_EDITS_GATED_TOOLS = new Set([
|
|
166
|
+
'edit', 'write', 'bash', 'ast_edit', 'eval', 'browser', 'computer', 'lsp', 'dap', 'task',
|
|
167
|
+
])
|
|
168
|
+
const ASK_COMMANDS_GATED_TOOLS = new Set([
|
|
169
|
+
'bash', 'eval', 'browser', 'computer', 'dap', 'lsp', 'task',
|
|
170
|
+
])
|
|
171
|
+
|
|
172
|
+
const MODE_ASK_EDITS = 'ask-edits'
|
|
173
|
+
const MODE_ASK_COMMANDS = 'ask-commands'
|
|
174
|
+
|
|
175
|
+
export function decideToolCall(
|
|
176
|
+
mode: string | undefined,
|
|
177
|
+
rules: PermissionRule[],
|
|
178
|
+
toolName: string,
|
|
179
|
+
input: unknown,
|
|
180
|
+
platform: string
|
|
181
|
+
): ToolCallDecision {
|
|
182
|
+
const result = evaluateRules(rules, toolName, input, platform)
|
|
183
|
+
if (result.decision === 'deny') {
|
|
184
|
+
const suffix = result.rule.match === undefined ? '' : ` ${result.rule.match}`
|
|
185
|
+
return { action: 'block', reason: `Blocked by permission rule: deny ${result.rule.tool}${suffix}` }
|
|
186
|
+
}
|
|
187
|
+
if (result.decision === 'allow') return { action: 'allow' }
|
|
188
|
+
if (mode === MODE_ASK_EDITS && ASK_EDITS_GATED_TOOLS.has(toolName)) return { action: 'prompt' }
|
|
189
|
+
if (mode === MODE_ASK_COMMANDS && ASK_COMMANDS_GATED_TOOLS.has(toolName)) return { action: 'prompt' }
|
|
190
|
+
return { action: 'allow' }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function workspaceRulesPath(cwd: string): string {
|
|
194
|
+
return join(cwd, WORKSPACE_RULES_DIR_NAME, PERMISSION_RULES_FILE_NAME)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface EffectiveRules {
|
|
198
|
+
rules: PermissionRule[]
|
|
199
|
+
source: 'workspace' | 'global' | 'none'
|
|
200
|
+
error?: string
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
interface CachedRulesFile {
|
|
204
|
+
mtimeMs: number
|
|
205
|
+
rules: PermissionRule[]
|
|
206
|
+
error?: string
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const rulesFileCache = new Map<string, CachedRulesFile>()
|
|
210
|
+
|
|
211
|
+
/** Test isolation only. */
|
|
212
|
+
export function clearRulesCache(): void {
|
|
213
|
+
rulesFileCache.clear()
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Read + validate one rules file, re-parsing only when its mtime changes.
|
|
218
|
+
* Returns null when the file does not exist (or cannot be stat-ed).
|
|
219
|
+
* A malformed file is cached as "no rules + error" and logged once per mtime.
|
|
220
|
+
*/
|
|
221
|
+
function loadRulesFile(filePath: string): CachedRulesFile | null {
|
|
222
|
+
let mtimeMs: number
|
|
223
|
+
try {
|
|
224
|
+
mtimeMs = statSync(filePath).mtimeMs
|
|
225
|
+
} catch {
|
|
226
|
+
rulesFileCache.delete(filePath)
|
|
227
|
+
return null
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const cached = rulesFileCache.get(filePath)
|
|
231
|
+
if (cached && cached.mtimeMs === mtimeMs) return cached
|
|
232
|
+
|
|
233
|
+
let entry: CachedRulesFile
|
|
234
|
+
try {
|
|
235
|
+
const parsed = JSON.parse(readFileSync(filePath, 'utf-8'))
|
|
236
|
+
entry = { mtimeMs, rules: validatePermissionRulesFile(parsed).rules }
|
|
237
|
+
} catch (error) {
|
|
238
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
239
|
+
console.warn(`[pi-desktop] invalid permission rules file ${filePath}: ${message}`)
|
|
240
|
+
entry = { mtimeMs, rules: [], error: message }
|
|
241
|
+
}
|
|
242
|
+
rulesFileCache.set(filePath, entry)
|
|
243
|
+
return entry
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Resolve the effective rules.
|
|
248
|
+
*
|
|
249
|
+
* A workspace file may come from an untrusted cloned repository, so its `allow`
|
|
250
|
+
* rules — which could silently bypass ask-mode prompts — take effect only when
|
|
251
|
+
* the workspace is trusted (`options.workspaceTrusted`). Concretely:
|
|
252
|
+
* - Trusted workspace: a valid workspace file fully replaces the global file.
|
|
253
|
+
* - Untrusted workspace (default): only its `deny` rules are honored, layered on
|
|
254
|
+
* top of the global rules, so a repo can tighten permissions but never grant.
|
|
255
|
+
*
|
|
256
|
+
* A malformed workspace file falls back to the global file (so global deny rules
|
|
257
|
+
* keep applying) and carries the error. If both files are malformed, the
|
|
258
|
+
* workspace file's error is the one reported.
|
|
259
|
+
*/
|
|
260
|
+
export function loadEffectiveRules(
|
|
261
|
+
cwd: string | null,
|
|
262
|
+
globalPath: string | null,
|
|
263
|
+
options?: { workspaceTrusted?: boolean }
|
|
264
|
+
): EffectiveRules {
|
|
265
|
+
const workspaceTrusted = options?.workspaceTrusted ?? false
|
|
266
|
+
let workspaceError: string | undefined
|
|
267
|
+
let workspaceDenyRules: PermissionRule[] = []
|
|
268
|
+
if (cwd) {
|
|
269
|
+
const workspace = loadRulesFile(workspaceRulesPath(cwd))
|
|
270
|
+
if (workspace && workspace.error === undefined) {
|
|
271
|
+
if (workspaceTrusted) {
|
|
272
|
+
return { rules: workspace.rules, source: 'workspace' }
|
|
273
|
+
}
|
|
274
|
+
// Untrusted: keep only deny rules (a repo may tighten, never grant).
|
|
275
|
+
workspaceDenyRules = workspace.rules.filter((rule) => rule.action === 'deny')
|
|
276
|
+
} else {
|
|
277
|
+
workspaceError = workspace?.error
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (globalPath) {
|
|
281
|
+
const global = loadRulesFile(globalPath)
|
|
282
|
+
if (global) {
|
|
283
|
+
const error = workspaceError ?? global.error
|
|
284
|
+
const rules = [...global.rules, ...workspaceDenyRules]
|
|
285
|
+
return { rules, source: 'global', ...(error ? { error } : {}) }
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
// No global file: still honor an untrusted workspace's deny rules if present.
|
|
289
|
+
if (workspaceDenyRules.length > 0) {
|
|
290
|
+
return { rules: workspaceDenyRules, source: 'workspace', ...(workspaceError ? { error: workspaceError } : {}) }
|
|
291
|
+
}
|
|
292
|
+
return { rules: [], source: 'none', ...(workspaceError ? { error: workspaceError } : {}) }
|
|
293
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import { decideToolCall, loadEffectiveRules } from './permission-rules'
|
|
3
|
+
import { fillTemplate, loadPermissionPromptText } from './permission-prompt-text'
|
|
4
|
+
|
|
5
|
+
const mode = process.env.PI_DESKTOP_PERMISSION_MODE
|
|
6
|
+
const globalRulesPath = process.env.PI_DESKTOP_PERMISSION_RULES_PATH ?? null
|
|
7
|
+
// Set by the GUI when spawning Pi for a workspace the user has trusted. Only
|
|
8
|
+
// then do this repo's own `allow` rules take effect; otherwise its allow rules
|
|
9
|
+
// are ignored and only its deny rules apply (see loadEffectiveRules).
|
|
10
|
+
const workspaceTrusted = process.env.PI_DESKTOP_WORKSPACE_TRUSTED === '1'
|
|
11
|
+
// Which CLI this extension is running inside, as the GUI names it. The
|
|
12
|
+
// extension cannot detect its own host, so an unset value means an older GUI
|
|
13
|
+
// and falls back to Pi rather than guessing.
|
|
14
|
+
const agentLabel = process.env.PI_DESKTOP_AGENT_LABEL || 'Pi'
|
|
15
|
+
// Read once: a running Pi keeps its language until it restarts.
|
|
16
|
+
const promptText = loadPermissionPromptText(
|
|
17
|
+
process.env.PI_DESKTOP_LOCALES_DIR ?? null,
|
|
18
|
+
process.env.PI_DESKTOP_LANGUAGE ?? null,
|
|
19
|
+
)
|
|
20
|
+
const MAX_INPUT_SUMMARY_LENGTH = 2000
|
|
21
|
+
|
|
22
|
+
function summarizeInput(input: unknown): string {
|
|
23
|
+
if (!input || typeof input !== 'object') return ''
|
|
24
|
+
const data = input as Record<string, unknown>
|
|
25
|
+
const path = typeof data.path === 'string' ? data.path : undefined
|
|
26
|
+
const command = typeof data.command === 'string' ? data.command : undefined
|
|
27
|
+
|
|
28
|
+
if (path) return fillTemplate(promptText.target, { path })
|
|
29
|
+
if (command) return fillTemplate(promptText.command, { command })
|
|
30
|
+
|
|
31
|
+
return JSON.stringify(data, null, 2).slice(0, MAX_INPUT_SUMMARY_LENGTH)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export default function piDesktopPermissions(pi: ExtensionAPI): void {
|
|
35
|
+
pi.on('tool_call', async (event, ctx) => {
|
|
36
|
+
// Rules are re-read per call (mtime-cached), so edits apply without a
|
|
37
|
+
// Pi restart. cwd is the workspace Pi was spawned in.
|
|
38
|
+
const effective = loadEffectiveRules(process.cwd(), globalRulesPath, { workspaceTrusted })
|
|
39
|
+
const decision = decideToolCall(mode, effective.rules, event.toolName, event.input, process.platform)
|
|
40
|
+
|
|
41
|
+
if (decision.action === 'block') {
|
|
42
|
+
return { block: true, reason: decision.reason }
|
|
43
|
+
}
|
|
44
|
+
if (decision.action === 'allow') return
|
|
45
|
+
|
|
46
|
+
const summary = summarizeInput(event.input)
|
|
47
|
+
const confirmed = await ctx.ui.confirm(
|
|
48
|
+
fillTemplate(promptText.title, { tool: event.toolName }),
|
|
49
|
+
[
|
|
50
|
+
fillTemplate(promptText.body, { agent: agentLabel, tool: event.toolName }),
|
|
51
|
+
summary,
|
|
52
|
+
].filter(Boolean).join('\n\n')
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if (!confirmed) {
|
|
56
|
+
return {
|
|
57
|
+
block: true,
|
|
58
|
+
reason: `User denied ${event.toolName} permission in Pi Desktop.`,
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
[Desktop Entry]
|
|
2
|
+
Type=Application
|
|
3
|
+
Version=1.0
|
|
4
|
+
Name=Ymir · Sessrúmnir
|
|
5
|
+
GenericName=Ymir Seat-Hall
|
|
6
|
+
Comment=The seat-hall — where the Allfather converses with Brokk
|
|
7
|
+
Exec=bash __YMIR_ROOT__/bin/sessrumnir.sh start
|
|
8
|
+
Icon=__YMIR_ROOT__/apps/sessrumnir/resources/icons/icon.png
|
|
9
|
+
Terminal=false
|
|
10
|
+
Categories=Development;Utility;
|
|
11
|
+
StartupWMClass=sessrumnir
|
|
12
|
+
StartupNotify=true
|