@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,111 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { dirname, join } from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { fillTemplate, loadPermissionPromptText } from './permission-prompt-text'
|
|
8
|
+
|
|
9
|
+
function localesWith(files: Record<string, unknown>): string {
|
|
10
|
+
const dir = mkdtempSync(join(tmpdir(), 'pi-desktop-locales-'))
|
|
11
|
+
for (const [language, content] of Object.entries(files)) {
|
|
12
|
+
mkdirSync(join(dir, language))
|
|
13
|
+
writeFileSync(join(dir, language, 'translation.json'), JSON.stringify(content))
|
|
14
|
+
}
|
|
15
|
+
return dir
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// A valid code with no language file (the pseudo-language has none).
|
|
19
|
+
const LANGUAGE_WITHOUT_FILE = 'en-XA'
|
|
20
|
+
const TEST_LANGUAGE = 'de'
|
|
21
|
+
|
|
22
|
+
const ENGLISH = {
|
|
23
|
+
permissions: { prompt: { title: 'Allow {{tool}}?', body: '{{agent}} wants to run {{tool}}.', target: 'Target: {{path}}', command: 'Command: {{command}}' } },
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const PLACEHOLDER_ONLY_TEXT = {
|
|
27
|
+
title: '{{tool}}?',
|
|
28
|
+
body: '{{agent}}: {{tool}}',
|
|
29
|
+
target: '{{path}}',
|
|
30
|
+
command: '{{command}}',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test('reads the chosen language', () => {
|
|
34
|
+
const dir = localesWith({ en: ENGLISH, de: { permissions: { prompt: { title: '{{tool}} erlauben?', body: 'b', target: 't', command: 'c' } } } })
|
|
35
|
+
assert.equal(loadPermissionPromptText(dir, 'de').title, '{{tool}} erlauben?')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('falls back to English for each missing or empty value', () => {
|
|
39
|
+
const dir = localesWith({ en: ENGLISH, de: { permissions: { prompt: { title: '', body: '{{agent}}: {{tool}}' } } } })
|
|
40
|
+
const text = loadPermissionPromptText(dir, 'de')
|
|
41
|
+
assert.equal(text.title, 'Allow {{tool}}?')
|
|
42
|
+
assert.equal(text.body, '{{agent}}: {{tool}}')
|
|
43
|
+
assert.equal(text.command, 'Command: {{command}}')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('a missing language file uses English', () => {
|
|
47
|
+
const dir = localesWith({ en: ENGLISH })
|
|
48
|
+
assert.equal(loadPermissionPromptText(dir, LANGUAGE_WITHOUT_FILE).title, 'Allow {{tool}}?')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('no language (older GUI) reads English', () => {
|
|
52
|
+
const dir = localesWith({ en: ENGLISH })
|
|
53
|
+
assert.equal(loadPermissionPromptText(dir, null).title, 'Allow {{tool}}?')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('no locales folder uses placeholder-only templates', () => {
|
|
57
|
+
assert.deepEqual(loadPermissionPromptText(null, 'en'), PLACEHOLDER_ONLY_TEXT)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('a language code with path parts is not read', () => {
|
|
61
|
+
const dir = localesWith({ en: ENGLISH })
|
|
62
|
+
assert.equal(loadPermissionPromptText(dir, '../en').title, 'Allow {{tool}}?')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('fillTemplate replaces known names and keeps $ patterns literal', () => {
|
|
66
|
+
assert.equal(fillTemplate('Run {{tool}} on {{path}}', { tool: 'bash', path: '$&' }), 'Run bash on $&')
|
|
67
|
+
assert.equal(fillTemplate('{{unknown}} stays', {}), '{{unknown}} stays')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
// A translation that drops or mangles a required placeholder must not hide
|
|
71
|
+
// what the user is approving, so it is rejected in favor of the English text.
|
|
72
|
+
const COMMAND_WITHOUT_PLACEHOLDER = 'Befehl ausfuehren'
|
|
73
|
+
const COMMAND_WITH_SPACED_PLACEHOLDER = 'Befehl:\n{{ command }}'
|
|
74
|
+
const COMMAND_WITH_PLACEHOLDER = 'Befehl:\n{{command}}'
|
|
75
|
+
|
|
76
|
+
test('a translated command missing its placeholder falls back to English', () => {
|
|
77
|
+
const dir = localesWith({ en: ENGLISH, [TEST_LANGUAGE]: { permissions: { prompt: { command: COMMAND_WITHOUT_PLACEHOLDER } } } })
|
|
78
|
+
assert.equal(loadPermissionPromptText(dir, TEST_LANGUAGE).command, ENGLISH.permissions.prompt.command)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('a translated command with a malformed placeholder falls back to English', () => {
|
|
82
|
+
const dir = localesWith({ en: ENGLISH, [TEST_LANGUAGE]: { permissions: { prompt: { command: COMMAND_WITH_SPACED_PLACEHOLDER } } } })
|
|
83
|
+
assert.equal(loadPermissionPromptText(dir, TEST_LANGUAGE).command, ENGLISH.permissions.prompt.command)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('a translated command that keeps its placeholder is used', () => {
|
|
87
|
+
const dir = localesWith({ en: ENGLISH, [TEST_LANGUAGE]: { permissions: { prompt: { command: COMMAND_WITH_PLACEHOLDER } } } })
|
|
88
|
+
assert.equal(loadPermissionPromptText(dir, TEST_LANGUAGE).command, COMMAND_WITH_PLACEHOLDER)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const SHIPPED_LOCALES_DIR = join(dirname(fileURLToPath(import.meta.url)), 'locales')
|
|
92
|
+
const SHIPPED_LANGUAGE = 'en'
|
|
93
|
+
const SAMPLE_TOOL = 'bash'
|
|
94
|
+
const SAMPLE_AGENT = 'Pi'
|
|
95
|
+
const SAMPLE_PATH = 'src/a.ts'
|
|
96
|
+
const SAMPLE_COMMAND = 'ls -la'
|
|
97
|
+
|
|
98
|
+
test('the shipped English prompt text matches today\'s wording', () => {
|
|
99
|
+
const text = loadPermissionPromptText(SHIPPED_LOCALES_DIR, SHIPPED_LANGUAGE)
|
|
100
|
+
assert.equal(fillTemplate(text.title, { tool: SAMPLE_TOOL }), 'Allow bash?')
|
|
101
|
+
assert.equal(fillTemplate(text.body, { agent: SAMPLE_AGENT, tool: SAMPLE_TOOL }), 'Pi wants to run the bash tool.')
|
|
102
|
+
assert.equal(fillTemplate(text.target, { path: SAMPLE_PATH }), 'Target: src/a.ts')
|
|
103
|
+
assert.equal(fillTemplate(text.command, { command: SAMPLE_COMMAND }), 'Command:\nls -la')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('a corrupt English file uses placeholder-only templates', () => {
|
|
107
|
+
const dir = mkdtempSync(join(tmpdir(), 'pi-desktop-locales-'))
|
|
108
|
+
mkdirSync(join(dir, SHIPPED_LANGUAGE))
|
|
109
|
+
writeFileSync(join(dir, SHIPPED_LANGUAGE, 'translation.json'), '{not json')
|
|
110
|
+
assert.deepEqual(loadPermissionPromptText(dir, TEST_LANGUAGE), PLACEHOLDER_ONLY_TEXT)
|
|
111
|
+
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission prompt text for the bundled Pi extension.
|
|
3
|
+
*
|
|
4
|
+
* The extension runs inside the Pi (or OMP) process and cannot import
|
|
5
|
+
* i18next, so this reads the app's language files directly. The GUI passes
|
|
6
|
+
* the folder and the resolved language in PI_DESKTOP_LOCALES_DIR and
|
|
7
|
+
* PI_DESKTOP_LANGUAGE.
|
|
8
|
+
*
|
|
9
|
+
* Must import only from `node:*` — no Electron, no Pi APIs.
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync } from 'node:fs'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
|
|
14
|
+
export const PERMISSION_PROMPT_KEYS = ['title', 'body', 'target', 'command'] as const
|
|
15
|
+
export type PermissionPromptKey = (typeof PERMISSION_PROMPT_KEYS)[number]
|
|
16
|
+
export type PermissionPromptText = Record<PermissionPromptKey, string>
|
|
17
|
+
|
|
18
|
+
const LOCALE_FILE_NAME = 'translation.json'
|
|
19
|
+
const FALLBACK_LANGUAGE = 'en'
|
|
20
|
+
// BCP 47 shape only, so a language value can never name a path outside the folder.
|
|
21
|
+
const LANGUAGE_CODE = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/
|
|
22
|
+
// Used when no language file can be read. No English words (the English file
|
|
23
|
+
// is the single source for those), but every value the user must see to
|
|
24
|
+
// decide is still shown.
|
|
25
|
+
const PLACEHOLDER_TEMPLATES: PermissionPromptText = {
|
|
26
|
+
title: '{{tool}}?',
|
|
27
|
+
body: '{{agent}}: {{tool}}',
|
|
28
|
+
target: '{{path}}',
|
|
29
|
+
command: '{{command}}',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Shared by fillTemplate and the placeholder check below. Never call .test()
|
|
33
|
+
// or .exec() on this: a global regex keeps `lastIndex` state between calls.
|
|
34
|
+
// matchAll and replace() both reset it internally, so those stay safe.
|
|
35
|
+
const PLACEHOLDER_PATTERN = /\{\{(\w+)\}\}/g
|
|
36
|
+
|
|
37
|
+
function placeholderNames(template: string): Set<string> {
|
|
38
|
+
return new Set(Array.from(template.matchAll(PLACEHOLDER_PATTERN), (match) => match[1]))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Every placeholder the English template for a key uses is required in any
|
|
42
|
+
// translation of that key, so a translation can never drop or misspell a
|
|
43
|
+
// value the user must see to decide (e.g. the command being run).
|
|
44
|
+
const REQUIRED_PLACEHOLDERS = Object.fromEntries(
|
|
45
|
+
PERMISSION_PROMPT_KEYS.map((key) => [key, placeholderNames(PLACEHOLDER_TEMPLATES[key])])
|
|
46
|
+
) as Record<PermissionPromptKey, Set<string>>
|
|
47
|
+
|
|
48
|
+
function hasRequiredPlaceholders(key: PermissionPromptKey, value: string): boolean {
|
|
49
|
+
const found = placeholderNames(value)
|
|
50
|
+
for (const name of REQUIRED_PLACEHOLDERS[key]) {
|
|
51
|
+
if (!found.has(name)) return false
|
|
52
|
+
}
|
|
53
|
+
return true
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readPromptSection(localesDir: string, language: string): Partial<PermissionPromptText> {
|
|
57
|
+
if (!LANGUAGE_CODE.test(language)) return {}
|
|
58
|
+
try {
|
|
59
|
+
const parsed: unknown = JSON.parse(readFileSync(join(localesDir, language, LOCALE_FILE_NAME), 'utf-8'))
|
|
60
|
+
const section = (parsed as { permissions?: { prompt?: unknown } } | null)?.permissions?.prompt
|
|
61
|
+
if (!section || typeof section !== 'object') return {}
|
|
62
|
+
const text: Partial<PermissionPromptText> = {}
|
|
63
|
+
for (const key of PERMISSION_PROMPT_KEYS) {
|
|
64
|
+
const value = (section as Record<string, unknown>)[key]
|
|
65
|
+
if (typeof value === 'string' && value !== '' && hasRequiredPlaceholders(key, value)) text[key] = value
|
|
66
|
+
}
|
|
67
|
+
return text
|
|
68
|
+
} catch {
|
|
69
|
+
return {}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Prompt templates in `language` (null = English), with English for each missing value. */
|
|
74
|
+
export function loadPermissionPromptText(localesDir: string | null, language: string | null): PermissionPromptText {
|
|
75
|
+
if (!localesDir) return { ...PLACEHOLDER_TEMPLATES }
|
|
76
|
+
const english = readPromptSection(localesDir, FALLBACK_LANGUAGE)
|
|
77
|
+
const chosen = language === null || language === FALLBACK_LANGUAGE ? {} : readPromptSection(localesDir, language)
|
|
78
|
+
return { ...PLACEHOLDER_TEMPLATES, ...english, ...chosen }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Fill {{name}} placeholders. A function replacer keeps `$&` in values literal. */
|
|
82
|
+
export function fillTemplate(template: string, values: Readonly<Record<string, string>>): string {
|
|
83
|
+
return template.replace(PLACEHOLDER_PATTERN, (match, name: string) => values[name] ?? match)
|
|
84
|
+
}
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { strict as assert } from 'node:assert'
|
|
2
|
+
import { describe, it, beforeEach } from 'node:test'
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from 'node:fs'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import {
|
|
7
|
+
PERMISSION_RULES_VERSION,
|
|
8
|
+
WORKSPACE_RULES_DIR_NAME,
|
|
9
|
+
validatePermissionRulesFile,
|
|
10
|
+
globToRegExp,
|
|
11
|
+
getPrimaryInput,
|
|
12
|
+
evaluateRules,
|
|
13
|
+
decideToolCall,
|
|
14
|
+
workspaceRulesPath,
|
|
15
|
+
loadEffectiveRules,
|
|
16
|
+
clearRulesCache,
|
|
17
|
+
} from './permission-rules'
|
|
18
|
+
|
|
19
|
+
describe('validatePermissionRulesFile', () => {
|
|
20
|
+
it('accepts a valid file and returns it typed', () => {
|
|
21
|
+
const file = validatePermissionRulesFile({
|
|
22
|
+
version: 1,
|
|
23
|
+
rules: [
|
|
24
|
+
{ action: 'allow', tool: 'bash', match: 'npm test*' },
|
|
25
|
+
{ action: 'deny', tool: '*' },
|
|
26
|
+
],
|
|
27
|
+
})
|
|
28
|
+
assert.equal(file.version, PERMISSION_RULES_VERSION)
|
|
29
|
+
assert.equal(file.rules.length, 2)
|
|
30
|
+
assert.equal(file.rules[1].match, undefined)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('accepts an empty rules array', () => {
|
|
34
|
+
assert.deepEqual(validatePermissionRulesFile({ version: 1, rules: [] }).rules, [])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('rejects non-objects, arrays, and null', () => {
|
|
38
|
+
for (const bad of [null, 42, 'x', [], undefined]) {
|
|
39
|
+
assert.throws(() => validatePermissionRulesFile(bad), /object/)
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('rejects unknown top-level keys (typo protection)', () => {
|
|
44
|
+
assert.throws(
|
|
45
|
+
() => validatePermissionRulesFile({ version: 1, rules: [], extra: true }),
|
|
46
|
+
/unknown key "extra"/
|
|
47
|
+
)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('rejects wrong or missing version', () => {
|
|
51
|
+
assert.throws(() => validatePermissionRulesFile({ version: 2, rules: [] }), /version/)
|
|
52
|
+
assert.throws(() => validatePermissionRulesFile({ rules: [] }), /version/)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('rejects a rule with an unknown action', () => {
|
|
56
|
+
assert.throws(
|
|
57
|
+
() => validatePermissionRulesFile({ version: 1, rules: [{ action: 'actoin', tool: 'bash' }] }),
|
|
58
|
+
/action/
|
|
59
|
+
)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('rejects a rule with unknown keys (typo protection)', () => {
|
|
63
|
+
assert.throws(
|
|
64
|
+
() =>
|
|
65
|
+
validatePermissionRulesFile({
|
|
66
|
+
version: 1,
|
|
67
|
+
rules: [{ action: 'deny', tool: 'bash', mach: 'rm *' }],
|
|
68
|
+
}),
|
|
69
|
+
/unknown key "mach"/
|
|
70
|
+
)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('rejects empty or non-string tool and non-string match', () => {
|
|
74
|
+
assert.throws(() => validatePermissionRulesFile({ version: 1, rules: [{ action: 'deny', tool: '' }] }), /tool/)
|
|
75
|
+
assert.throws(() => validatePermissionRulesFile({ version: 1, rules: [{ action: 'deny', tool: 7 }] }), /tool/)
|
|
76
|
+
assert.throws(
|
|
77
|
+
() => validatePermissionRulesFile({ version: 1, rules: [{ action: 'deny', tool: 'bash', match: 3 }] }),
|
|
78
|
+
/match/
|
|
79
|
+
)
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('globToRegExp', () => {
|
|
84
|
+
it('matches literal text exactly (anchored)', () => {
|
|
85
|
+
assert.ok(globToRegExp('npm test', false).test('npm test'))
|
|
86
|
+
assert.ok(!globToRegExp('npm test', false).test('npm test --watch'))
|
|
87
|
+
assert.ok(!globToRegExp('npm test', false).test('x npm test'))
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('* matches any sequence including empty, spaces, slashes, and newlines', () => {
|
|
91
|
+
assert.ok(globToRegExp('npm *', false).test('npm run build'))
|
|
92
|
+
assert.ok(globToRegExp('rm -rf *', false).test('rm -rf /'))
|
|
93
|
+
assert.ok(globToRegExp('git *', false).test('git commit -m "line1\nline2"'))
|
|
94
|
+
assert.ok(globToRegExp('a*b', false).test('ab'))
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('escapes regex metacharacters in the pattern', () => {
|
|
98
|
+
assert.ok(globToRegExp('foo.sh', false).test('foo.sh'))
|
|
99
|
+
assert.ok(!globToRegExp('foo.sh', false).test('fooXsh'))
|
|
100
|
+
assert.ok(globToRegExp('a+b (c) [d] {e} ^$ |?', false).test('a+b (c) [d] {e} ^$ |?'))
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('honors case sensitivity flag', () => {
|
|
104
|
+
assert.ok(!globToRegExp('*.env*', false).test('.ENV'))
|
|
105
|
+
assert.ok(globToRegExp('*.env*', true).test('.ENV'))
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
describe('getPrimaryInput', () => {
|
|
110
|
+
it('uses command for bash', () => {
|
|
111
|
+
assert.deepEqual(getPrimaryInput('bash', { command: 'ls -la' }), { value: 'ls -la', kind: 'command' })
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('uses path for tools with a path input', () => {
|
|
115
|
+
assert.deepEqual(getPrimaryInput('edit', { path: 'src/a.ts', old: 'x' }), { value: 'src/a.ts', kind: 'path' })
|
|
116
|
+
assert.deepEqual(getPrimaryInput('read', { path: '/etc/hosts' }), { value: '/etc/hosts', kind: 'path' })
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('falls back to JSON for anything else', () => {
|
|
120
|
+
assert.deepEqual(getPrimaryInput('fetch', { url: 'https://x' }), { value: '{"url":"https://x"}', kind: 'json' })
|
|
121
|
+
assert.deepEqual(getPrimaryInput('bash', undefined), { value: 'null', kind: 'json' })
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
describe('evaluateRules', () => {
|
|
126
|
+
const LINUX = 'linux'
|
|
127
|
+
const rules = [
|
|
128
|
+
{ action: 'allow' as const, tool: 'bash', match: 'npm test*' },
|
|
129
|
+
{ action: 'deny' as const, tool: 'bash', match: 'rm -rf *' },
|
|
130
|
+
{ action: 'deny' as const, tool: '*', match: '*.env*' },
|
|
131
|
+
{ action: 'allow' as const, tool: 'grep' },
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
it('returns deny when a deny rule matches, with the matching rule', () => {
|
|
135
|
+
const result = evaluateRules(rules, 'bash', { command: 'rm -rf /tmp/x' }, LINUX)
|
|
136
|
+
assert.equal(result.decision, 'deny')
|
|
137
|
+
if (result.decision === 'deny') assert.equal(result.rule.match, 'rm -rf *')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('deny beats allow regardless of rule order', () => {
|
|
141
|
+
const both = [
|
|
142
|
+
{ action: 'allow' as const, tool: 'bash', match: 'rm *' },
|
|
143
|
+
{ action: 'deny' as const, tool: 'bash', match: 'rm *' },
|
|
144
|
+
]
|
|
145
|
+
assert.equal(evaluateRules(both, 'bash', { command: 'rm x' }, LINUX).decision, 'deny')
|
|
146
|
+
assert.equal(evaluateRules(both.slice().reverse(), 'bash', { command: 'rm x' }, LINUX).decision, 'deny')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('returns allow when only an allow rule matches', () => {
|
|
150
|
+
assert.equal(evaluateRules(rules, 'bash', { command: 'npm test --run' }, LINUX).decision, 'allow')
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('a rule without match applies to every invocation of its tool', () => {
|
|
154
|
+
assert.equal(evaluateRules(rules, 'grep', { pattern: 'x' }, LINUX).decision, 'allow')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('tool "*" applies the rule to every tool', () => {
|
|
158
|
+
assert.equal(evaluateRules(rules, 'write', { path: 'app/.env.local' }, LINUX).decision, 'deny')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('returns default when nothing matches', () => {
|
|
162
|
+
assert.equal(evaluateRules(rules, 'bash', { command: 'ls' }, LINUX).decision, 'default')
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('path matching normalizes backslashes and is case-insensitive on win32/darwin', () => {
|
|
166
|
+
const pathRules = [{ action: 'deny' as const, tool: '*', match: '*/secrets/*' }]
|
|
167
|
+
assert.equal(evaluateRules(pathRules, 'read', { path: 'C:\\proj\\secrets\\k.txt' }, 'win32').decision, 'deny')
|
|
168
|
+
assert.equal(evaluateRules(pathRules, 'read', { path: 'C:\\proj\\SECRETS\\k.txt' }, 'win32').decision, 'deny')
|
|
169
|
+
assert.equal(evaluateRules(pathRules, 'read', { path: '/proj/SECRETS/k.txt' }, 'darwin').decision, 'deny')
|
|
170
|
+
assert.equal(evaluateRules(pathRules, 'read', { path: '/proj/SECRETS/k.txt' }, 'linux').decision, 'default')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('command matching stays case-sensitive on all platforms', () => {
|
|
174
|
+
const cmdRules = [{ action: 'deny' as const, tool: 'bash', match: 'RM *' }]
|
|
175
|
+
assert.equal(evaluateRules(cmdRules, 'bash', { command: 'rm x' }, 'win32').decision, 'default')
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
describe('decideToolCall', () => {
|
|
180
|
+
const rules = [
|
|
181
|
+
{ action: 'allow' as const, tool: 'bash', match: 'npm test*' },
|
|
182
|
+
{ action: 'deny' as const, tool: 'bash', match: 'rm -rf *' },
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
it('deny rules block in every mode, including trusted and plan-readonly', () => {
|
|
186
|
+
for (const mode of ['trusted', 'plan-readonly', 'ask-edits', 'ask-commands', undefined]) {
|
|
187
|
+
const result = decideToolCall(mode, rules, 'bash', { command: 'rm -rf /' }, 'linux')
|
|
188
|
+
assert.equal(result.action, 'block')
|
|
189
|
+
if (result.action === 'block') assert.match(result.reason, /rm -rf \*/)
|
|
190
|
+
}
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('allow rules skip the prompt in ask modes', () => {
|
|
194
|
+
assert.deepEqual(decideToolCall('ask-commands', rules, 'bash', { command: 'npm test' }, 'linux'), { action: 'allow' })
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('preserves mode defaults when no rule matches', () => {
|
|
198
|
+
assert.deepEqual(decideToolCall('ask-edits', [], 'edit', { path: 'a' }, 'linux'), { action: 'prompt' })
|
|
199
|
+
assert.deepEqual(decideToolCall('ask-edits', [], 'write', { path: 'a' }, 'linux'), { action: 'prompt' })
|
|
200
|
+
assert.deepEqual(decideToolCall('ask-edits', [], 'bash', { command: 'x' }, 'linux'), { action: 'prompt' })
|
|
201
|
+
assert.deepEqual(decideToolCall('ask-edits', [], 'read', { path: 'a' }, 'linux'), { action: 'allow' })
|
|
202
|
+
assert.deepEqual(decideToolCall('ask-commands', [], 'bash', { command: 'x' }, 'linux'), { action: 'prompt' })
|
|
203
|
+
assert.deepEqual(decideToolCall('ask-commands', [], 'edit', { path: 'a' }, 'linux'), { action: 'allow' })
|
|
204
|
+
assert.deepEqual(decideToolCall('trusted', [], 'bash', { command: 'x' }, 'linux'), { action: 'allow' })
|
|
205
|
+
assert.deepEqual(decideToolCall(undefined, [], 'bash', { command: 'x' }, 'linux'), { action: 'allow' })
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
describe('loadEffectiveRules', () => {
|
|
210
|
+
const makeTmpDir = (): string => mkdtempSync(join(tmpdir(), 'pi-perm-rules-'))
|
|
211
|
+
const writeRules = (filePath: string, rules: unknown): void => {
|
|
212
|
+
writeFileSync(filePath, JSON.stringify({ version: 1, rules }), 'utf-8')
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
beforeEach(() => clearRulesCache())
|
|
216
|
+
|
|
217
|
+
it('returns none when neither file exists', () => {
|
|
218
|
+
const dir = makeTmpDir()
|
|
219
|
+
try {
|
|
220
|
+
const result = loadEffectiveRules(dir, join(dir, 'nope.json'))
|
|
221
|
+
assert.deepEqual(result, { rules: [], source: 'none' })
|
|
222
|
+
} finally {
|
|
223
|
+
rmSync(dir, { recursive: true, force: true })
|
|
224
|
+
}
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('loads global rules when no workspace file exists', () => {
|
|
228
|
+
const dir = makeTmpDir()
|
|
229
|
+
try {
|
|
230
|
+
const globalPath = join(dir, 'permission-rules.json')
|
|
231
|
+
writeRules(globalPath, [{ action: 'deny', tool: 'bash' }])
|
|
232
|
+
const result = loadEffectiveRules(dir, globalPath)
|
|
233
|
+
assert.equal(result.source, 'global')
|
|
234
|
+
assert.equal(result.rules.length, 1)
|
|
235
|
+
} finally {
|
|
236
|
+
rmSync(dir, { recursive: true, force: true })
|
|
237
|
+
}
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('a trusted workspace file fully replaces global rules', () => {
|
|
241
|
+
const dir = makeTmpDir()
|
|
242
|
+
try {
|
|
243
|
+
const globalPath = join(dir, 'global.json')
|
|
244
|
+
writeRules(globalPath, [{ action: 'deny', tool: 'bash' }])
|
|
245
|
+
mkdirSync(join(dir, WORKSPACE_RULES_DIR_NAME))
|
|
246
|
+
writeRules(workspaceRulesPath(dir), [{ action: 'allow', tool: 'grep' }])
|
|
247
|
+
const result = loadEffectiveRules(dir, globalPath, { workspaceTrusted: true })
|
|
248
|
+
assert.equal(result.source, 'workspace')
|
|
249
|
+
assert.deepEqual(result.rules, [{ action: 'allow', tool: 'grep' }])
|
|
250
|
+
} finally {
|
|
251
|
+
rmSync(dir, { recursive: true, force: true })
|
|
252
|
+
}
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('ignores an untrusted workspace allow rule but keeps global rules', () => {
|
|
256
|
+
const dir = makeTmpDir()
|
|
257
|
+
try {
|
|
258
|
+
const globalPath = join(dir, 'global.json')
|
|
259
|
+
writeRules(globalPath, [{ action: 'deny', tool: 'bash' }])
|
|
260
|
+
mkdirSync(join(dir, WORKSPACE_RULES_DIR_NAME))
|
|
261
|
+
writeRules(workspaceRulesPath(dir), [{ action: 'allow', tool: 'bash' }])
|
|
262
|
+
// Untrusted by default: the repo's allow rule must not take effect.
|
|
263
|
+
const result = loadEffectiveRules(dir, globalPath)
|
|
264
|
+
assert.deepEqual(result.rules, [{ action: 'deny', tool: 'bash' }])
|
|
265
|
+
} finally {
|
|
266
|
+
rmSync(dir, { recursive: true, force: true })
|
|
267
|
+
}
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('keeps an untrusted workspace deny rule layered on top of global', () => {
|
|
271
|
+
const dir = makeTmpDir()
|
|
272
|
+
try {
|
|
273
|
+
const globalPath = join(dir, 'global.json')
|
|
274
|
+
writeRules(globalPath, [{ action: 'allow', tool: 'grep' }])
|
|
275
|
+
mkdirSync(join(dir, WORKSPACE_RULES_DIR_NAME))
|
|
276
|
+
writeRules(workspaceRulesPath(dir), [
|
|
277
|
+
{ action: 'deny', tool: 'bash' },
|
|
278
|
+
{ action: 'allow', tool: 'edit' },
|
|
279
|
+
])
|
|
280
|
+
const result = loadEffectiveRules(dir, globalPath)
|
|
281
|
+
// Repo deny is honored; repo allow is dropped; global rules remain.
|
|
282
|
+
assert.ok(result.rules.some((r) => r.action === 'deny' && r.tool === 'bash'))
|
|
283
|
+
assert.ok(result.rules.some((r) => r.action === 'allow' && r.tool === 'grep'))
|
|
284
|
+
assert.ok(!result.rules.some((r) => r.action === 'allow' && r.tool === 'edit'))
|
|
285
|
+
} finally {
|
|
286
|
+
rmSync(dir, { recursive: true, force: true })
|
|
287
|
+
}
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('malformed global file yields no rules plus an error', () => {
|
|
291
|
+
const dir = makeTmpDir()
|
|
292
|
+
try {
|
|
293
|
+
const globalPath = join(dir, 'global.json')
|
|
294
|
+
writeFileSync(globalPath, '{ not json', 'utf-8')
|
|
295
|
+
const result = loadEffectiveRules(null, globalPath)
|
|
296
|
+
assert.equal(result.source, 'global')
|
|
297
|
+
assert.deepEqual(result.rules, [])
|
|
298
|
+
assert.ok(result.error)
|
|
299
|
+
} finally {
|
|
300
|
+
rmSync(dir, { recursive: true, force: true })
|
|
301
|
+
}
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('malformed workspace file falls back to global rules, keeping the error', () => {
|
|
305
|
+
const dir = makeTmpDir()
|
|
306
|
+
try {
|
|
307
|
+
const globalPath = join(dir, 'global.json')
|
|
308
|
+
writeRules(globalPath, [{ action: 'deny', tool: 'bash' }])
|
|
309
|
+
mkdirSync(join(dir, WORKSPACE_RULES_DIR_NAME))
|
|
310
|
+
writeFileSync(workspaceRulesPath(dir), '{ not json', 'utf-8')
|
|
311
|
+
const result = loadEffectiveRules(dir, globalPath)
|
|
312
|
+
assert.equal(result.source, 'global')
|
|
313
|
+
assert.equal(result.rules.length, 1)
|
|
314
|
+
assert.ok(result.error)
|
|
315
|
+
} finally {
|
|
316
|
+
rmSync(dir, { recursive: true, force: true })
|
|
317
|
+
}
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it('re-reads when mtime changes and caches when it does not', () => {
|
|
321
|
+
const dir = makeTmpDir()
|
|
322
|
+
try {
|
|
323
|
+
const globalPath = join(dir, 'global.json')
|
|
324
|
+
writeRules(globalPath, [{ action: 'deny', tool: 'bash' }])
|
|
325
|
+
assert.equal(loadEffectiveRules(null, globalPath).rules.length, 1)
|
|
326
|
+
writeRules(globalPath, [
|
|
327
|
+
{ action: 'deny', tool: 'bash' },
|
|
328
|
+
{ action: 'deny', tool: 'edit' },
|
|
329
|
+
])
|
|
330
|
+
// Force a distinct mtime even on coarse-grained filesystems.
|
|
331
|
+
const future = new Date(Date.now() + 5000)
|
|
332
|
+
utimesSync(globalPath, future, future)
|
|
333
|
+
assert.equal(loadEffectiveRules(null, globalPath).rules.length, 2)
|
|
334
|
+
} finally {
|
|
335
|
+
rmSync(dir, { recursive: true, force: true })
|
|
336
|
+
}
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
it('file deleted after caching falls back to none', () => {
|
|
340
|
+
const dir = makeTmpDir()
|
|
341
|
+
try {
|
|
342
|
+
const globalPath = join(dir, 'global.json')
|
|
343
|
+
writeRules(globalPath, [])
|
|
344
|
+
assert.equal(loadEffectiveRules(null, globalPath).source, 'global')
|
|
345
|
+
rmSync(globalPath)
|
|
346
|
+
assert.equal(loadEffectiveRules(null, globalPath).source, 'none')
|
|
347
|
+
} finally {
|
|
348
|
+
rmSync(dir, { recursive: true, force: true })
|
|
349
|
+
}
|
|
350
|
+
})
|
|
351
|
+
})
|