@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.
Files changed (113) hide show
  1. package/LICENSE +200 -0
  2. package/README.md +329 -0
  3. package/bin/sessrumnir-web.sh +117 -0
  4. package/bin/sessrumnir.js +169 -0
  5. package/out/main/index.js +10850 -0
  6. package/out/preload/index.js +719 -0
  7. package/out/renderer/assets/cormorant-cyrillic-400-normal-C8QS47vb.woff2 +0 -0
  8. package/out/renderer/assets/cormorant-cyrillic-400-normal-D3EsxgFc.woff +0 -0
  9. package/out/renderer/assets/cormorant-cyrillic-500-normal-B7dJQtg-.woff +0 -0
  10. package/out/renderer/assets/cormorant-cyrillic-500-normal-BLlg2W5x.woff2 +0 -0
  11. package/out/renderer/assets/cormorant-cyrillic-600-normal-BcIsDXzC.woff2 +0 -0
  12. package/out/renderer/assets/cormorant-cyrillic-600-normal-CBymlcNF.woff +0 -0
  13. package/out/renderer/assets/cormorant-cyrillic-ext-400-normal-Bgrpe4p1.woff +0 -0
  14. package/out/renderer/assets/cormorant-cyrillic-ext-400-normal-BlcaxZtM.woff2 +0 -0
  15. package/out/renderer/assets/cormorant-cyrillic-ext-500-normal-CdQuyvtc.woff +0 -0
  16. package/out/renderer/assets/cormorant-cyrillic-ext-500-normal-pZw22qtS.woff2 +0 -0
  17. package/out/renderer/assets/cormorant-cyrillic-ext-600-normal-Dr-wLyOm.woff +0 -0
  18. package/out/renderer/assets/cormorant-cyrillic-ext-600-normal-dLR2-a_v.woff2 +0 -0
  19. package/out/renderer/assets/cormorant-latin-400-normal-BGH8Vunh.woff2 +0 -0
  20. package/out/renderer/assets/cormorant-latin-400-normal-C3_-2Ua-.woff +0 -0
  21. package/out/renderer/assets/cormorant-latin-500-normal-Dj3SQ6fR.woff +0 -0
  22. package/out/renderer/assets/cormorant-latin-500-normal-EBdSCOD3.woff2 +0 -0
  23. package/out/renderer/assets/cormorant-latin-600-normal-B254Xisw.woff2 +0 -0
  24. package/out/renderer/assets/cormorant-latin-600-normal-DUW-TI76.woff +0 -0
  25. package/out/renderer/assets/cormorant-latin-ext-400-normal-DuQ88yz3.woff2 +0 -0
  26. package/out/renderer/assets/cormorant-latin-ext-400-normal-DuXFa1Dr.woff +0 -0
  27. package/out/renderer/assets/cormorant-latin-ext-500-normal-AH9qog1s.woff2 +0 -0
  28. package/out/renderer/assets/cormorant-latin-ext-500-normal-DAuUCO41.woff +0 -0
  29. package/out/renderer/assets/cormorant-latin-ext-600-normal-CD-dlDpD.woff +0 -0
  30. package/out/renderer/assets/cormorant-latin-ext-600-normal-D3T6BBXG.woff2 +0 -0
  31. package/out/renderer/assets/cormorant-vietnamese-400-normal-C-RiYxEf.woff2 +0 -0
  32. package/out/renderer/assets/cormorant-vietnamese-400-normal-DmUuA7Y2.woff +0 -0
  33. package/out/renderer/assets/cormorant-vietnamese-500-normal-DsPuwQHi.woff2 +0 -0
  34. package/out/renderer/assets/cormorant-vietnamese-500-normal-tGBW_mI7.woff +0 -0
  35. package/out/renderer/assets/cormorant-vietnamese-600-normal-Dar_cCT6.woff2 +0 -0
  36. package/out/renderer/assets/cormorant-vietnamese-600-normal-GIs8CF2x.woff +0 -0
  37. package/out/renderer/assets/ibm-plex-mono-cyrillic-300-normal-Ba-HN6uq.woff +0 -0
  38. package/out/renderer/assets/ibm-plex-mono-cyrillic-300-normal-ByB2hQUF.woff2 +0 -0
  39. package/out/renderer/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
  40. package/out/renderer/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
  41. package/out/renderer/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
  42. package/out/renderer/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
  43. package/out/renderer/assets/ibm-plex-mono-cyrillic-ext-300-normal-B8O3uHbr.woff +0 -0
  44. package/out/renderer/assets/ibm-plex-mono-cyrillic-ext-300-normal-BbtBtHoB.woff2 +0 -0
  45. package/out/renderer/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
  46. package/out/renderer/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
  47. package/out/renderer/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
  48. package/out/renderer/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
  49. package/out/renderer/assets/ibm-plex-mono-latin-300-normal-BPU54Tw3.woff +0 -0
  50. package/out/renderer/assets/ibm-plex-mono-latin-300-normal-BRS-C4KA.woff2 +0 -0
  51. package/out/renderer/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  52. package/out/renderer/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  53. package/out/renderer/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
  54. package/out/renderer/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  55. package/out/renderer/assets/ibm-plex-mono-latin-ext-300-normal-BEtQ2wm2.woff2 +0 -0
  56. package/out/renderer/assets/ibm-plex-mono-latin-ext-300-normal-CQbCaAQ4.woff +0 -0
  57. package/out/renderer/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
  58. package/out/renderer/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
  59. package/out/renderer/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
  60. package/out/renderer/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff +0 -0
  61. package/out/renderer/assets/ibm-plex-mono-vietnamese-300-normal-B6fSpYYV.woff +0 -0
  62. package/out/renderer/assets/ibm-plex-mono-vietnamese-300-normal-CpdbWO_2.woff2 +0 -0
  63. package/out/renderer/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
  64. package/out/renderer/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
  65. package/out/renderer/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff +0 -0
  66. package/out/renderer/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 +0 -0
  67. package/out/renderer/assets/index-CgJYtdL8.js +97387 -0
  68. package/out/renderer/assets/index-CyRDW9WV.css +4824 -0
  69. package/out/renderer/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  70. package/out/renderer/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  71. package/out/renderer/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  72. package/out/renderer/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  73. package/out/renderer/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  74. package/out/renderer/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  75. package/out/renderer/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  76. package/out/renderer/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  77. package/out/renderer/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  78. package/out/renderer/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  79. package/out/renderer/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  80. package/out/renderer/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  81. package/out/renderer/assets/newsreader-latin-ext-wght-italic-DFS986qL.woff2 +0 -0
  82. package/out/renderer/assets/newsreader-latin-ext-wght-normal-C-3rgBeH.woff2 +0 -0
  83. package/out/renderer/assets/newsreader-latin-wght-italic-Bxi8ein9.woff2 +0 -0
  84. package/out/renderer/assets/newsreader-latin-wght-normal-CCVVNp6i.woff2 +0 -0
  85. package/out/renderer/assets/newsreader-vietnamese-wght-italic-xAqemagA.woff2 +0 -0
  86. package/out/renderer/assets/newsreader-vietnamese-wght-normal-Czsa-EzN.woff2 +0 -0
  87. package/out/renderer/assets/openmoji-color-9qo_2DLG.woff2 +0 -0
  88. package/out/renderer/assets/pi-logo-8LswgQgB.svg +17 -0
  89. package/out/renderer/index.html +19 -0
  90. package/out/renderer/theme-boot.js +30 -0
  91. package/out/shared/src/shared/tsconfig.tsbuildinfo +1 -0
  92. package/package.json +183 -0
  93. package/resources/icons/generate_icons.py +38 -0
  94. package/resources/icons/icon-128.png +0 -0
  95. package/resources/icons/icon-16.png +0 -0
  96. package/resources/icons/icon-256.png +0 -0
  97. package/resources/icons/icon-32.png +0 -0
  98. package/resources/icons/icon-48.png +0 -0
  99. package/resources/icons/icon-512.png +0 -0
  100. package/resources/icons/icon-64.png +0 -0
  101. package/resources/icons/icon.icns +0 -0
  102. package/resources/icons/icon.ico +0 -0
  103. package/resources/icons/icon.png +0 -0
  104. package/resources/icons/icon.svg +55 -0
  105. package/resources/linux/after-install.sh +23 -0
  106. package/resources/linux/after-remove.sh +18 -0
  107. package/resources/locales/en/translation.json +1735 -0
  108. package/resources/permission-prompt-text.test.ts +111 -0
  109. package/resources/permission-prompt-text.ts +84 -0
  110. package/resources/permission-rules.test.ts +351 -0
  111. package/resources/permission-rules.ts +293 -0
  112. package/resources/pi-desktop-permissions.ts +62 -0
  113. 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