me like nix
0

Configure Feed

Select the types of activity you want to include in your feed.

add pi and some slop nonsense

author
Sean Aye
date (May 10, 2026, 3:12 PM -0400) commit e42bf694 parent aa500a1b change-id wtutqkmu
+2855
+6
modules/_config/pi/AGENTS.md
··· 1 + # Global Agent Rules 2 + 3 + ## Workflow 4 + 5 + - **Commit after verification**: After a successful verification step (e.g. `cargo check` with 0 errors), always create a jj revision: `jj desc -m "description of changes" && jj new` 6 + - **Never run migrations without asking**: Always prompt the user before running database migrations (`just migrate run`, `sqlx migrate run`, `just clear_db`, `sqlx database reset`, etc.)
+20
modules/_config/pi/extensions/permission-pi/node_modules/.package-lock.json
··· 1 + { 2 + "name": "permission-pi", 3 + "version": "1.0.2", 4 + "lockfileVersion": 3, 5 + "requires": true, 6 + "packages": { 7 + "node_modules/shell-quote": { 8 + "version": "1.8.3", 9 + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", 10 + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", 11 + "license": "MIT", 12 + "engines": { 13 + "node": ">= 0.4" 14 + }, 15 + "funding": { 16 + "url": "https://github.com/sponsors/ljharb" 17 + } 18 + } 19 + } 20 + }
+30
modules/_config/pi/extensions/permission-pi/node_modules/shell-quote/.eslintrc
··· 1 + { 2 + "root": true, 3 + 4 + "extends": "@ljharb", 5 + 6 + "rules": { 7 + "array-bracket-newline": 0, 8 + "complexity": 0, 9 + "eqeqeq": 1, 10 + "func-style": [2, "declaration"], 11 + "max-depth": 0, 12 + "max-lines-per-function": 0, 13 + "max-statements": 0, 14 + "multiline-comment-style": 0, 15 + "no-negated-condition": 1, 16 + "no-param-reassign": 1, 17 + "no-lonely-if": 1, 18 + "no-shadow": 1, 19 + "no-template-curly-in-string": 0, 20 + }, 21 + 22 + "overrides": [ 23 + { 24 + "files": "example/**", 25 + "rules": { 26 + "no-console": 0, 27 + }, 28 + }, 29 + ], 30 + }
+12
modules/_config/pi/extensions/permission-pi/node_modules/shell-quote/.github/FUNDING.yml
··· 1 + # These are supported funding model platforms 2 + 3 + github: [ljharb] 4 + patreon: # Replace with a single Patreon username 5 + open_collective: # Replace with a single Open Collective username 6 + ko_fi: # Replace with a single Ko-fi username 7 + tidelift: npm/shell-quote 8 + community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 + liberapay: # Replace with a single Liberapay username 10 + issuehunt: # Replace with a single IssueHunt username 11 + otechie: # Replace with a single Otechie username 12 + custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+14
modules/_config/pi/extensions/permission-pi/node_modules/shell-quote/.nycrc
··· 1 + { 2 + "all": true, 3 + "check-coverage": false, 4 + "reporter": ["text-summary", "text", "html", "json"], 5 + "lines": 86, 6 + "statements": 85.93, 7 + "functions": 82.43, 8 + "branches": 76.06, 9 + "exclude": [ 10 + "coverage", 11 + "example", 12 + "test" 13 + ] 14 + }
+24
modules/_config/pi/extensions/permission-pi/node_modules/shell-quote/LICENSE
··· 1 + The MIT License 2 + 3 + Copyright (c) 2013 James Halliday (mail@substack.net) 4 + 5 + Permission is hereby granted, free of charge, 6 + to any person obtaining a copy of this software and 7 + associated documentation files (the "Software"), to 8 + deal in the Software without restriction, including 9 + without limitation the rights to use, copy, modify, 10 + merge, publish, distribute, sublicense, and/or sell 11 + copies of the Software, and to permit persons to whom 12 + the Software is furnished to do so, 13 + subject to the following conditions: 14 + 15 + The above copyright notice and this permission notice 16 + shall be included in all copies or substantial portions of the Software. 17 + 18 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 19 + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 20 + OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 21 + IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 22 + ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 23 + TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 24 + SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+161
modules/_config/pi/extensions/permission-pi/node_modules/shell-quote/README.md
··· 1 + # shell-quote <sup>[![Version Badge][npm-version-svg]][package-url]</sup> 2 + 3 + [![github actions][actions-image]][actions-url] 4 + [![coverage][codecov-image]][codecov-url] 5 + [![License][license-image]][license-url] 6 + [![Downloads][downloads-image]][downloads-url] 7 + 8 + [![npm badge][npm-badge-png]][package-url] 9 + 10 + Parse and quote shell commands. 11 + 12 + # example 13 + 14 + ## quote 15 + 16 + ``` js 17 + var quote = require('shell-quote/quote'); 18 + var s = quote([ 'a', 'b c d', '$f', '"g"' ]); 19 + console.log(s); 20 + ``` 21 + 22 + output 23 + 24 + ``` 25 + a 'b c d' \$f '"g"' 26 + ``` 27 + 28 + ## parse 29 + 30 + ``` js 31 + var parse = require('shell-quote/parse'); 32 + var xs = parse('a "b c" \\$def \'it\\\'s great\''); 33 + console.dir(xs); 34 + ``` 35 + 36 + output 37 + 38 + ``` 39 + [ 'a', 'b c', '\\$def', 'it\'s great' ] 40 + ``` 41 + 42 + ## parse with an environment variable 43 + 44 + ``` js 45 + var parse = require('shell-quote/parse'); 46 + var xs = parse('beep --boop="$PWD"', { PWD: '/home/robot' }); 47 + console.dir(xs); 48 + ``` 49 + 50 + output 51 + 52 + ``` 53 + [ 'beep', '--boop=/home/robot' ] 54 + ``` 55 + 56 + ## parse with custom escape character 57 + 58 + ``` js 59 + var parse = require('shell-quote/parse'); 60 + var xs = parse('beep ^--boop="$PWD"', { PWD: '/home/robot' }, { escape: '^' }); 61 + console.dir(xs); 62 + ``` 63 + 64 + output 65 + 66 + ``` 67 + [ 'beep --boop=/home/robot' ] 68 + ``` 69 + 70 + ## parsing shell operators 71 + 72 + ``` js 73 + var parse = require('shell-quote/parse'); 74 + var xs = parse('beep || boop > /byte'); 75 + console.dir(xs); 76 + ``` 77 + 78 + output: 79 + 80 + ``` 81 + [ 'beep', { op: '||' }, 'boop', { op: '>' }, '/byte' ] 82 + ``` 83 + 84 + ## parsing shell comment 85 + 86 + ``` js 87 + var parse = require('shell-quote/parse'); 88 + var xs = parse('beep > boop # > kaboom'); 89 + console.dir(xs); 90 + ``` 91 + 92 + output: 93 + 94 + ``` 95 + [ 'beep', { op: '>' }, 'boop', { comment: '> kaboom' } ] 96 + ``` 97 + 98 + # methods 99 + 100 + ``` js 101 + var quote = require('shell-quote/quote'); 102 + var parse = require('shell-quote/parse'); 103 + ``` 104 + 105 + ## quote(args) 106 + 107 + Return a quoted string for the array `args` suitable for using in shell 108 + commands. 109 + 110 + ## parse(cmd, env={}) 111 + 112 + Return an array of arguments from the quoted string `cmd`. 113 + 114 + Interpolate embedded bash-style `$VARNAME` and `${VARNAME}` variables with 115 + the `env` object which like bash will replace undefined variables with `""`. 116 + 117 + `env` is usually an object but it can also be a function to perform lookups. 118 + When `env(key)` returns a string, its result will be output just like `env[key]` 119 + would. When `env(key)` returns an object, it will be inserted into the result 120 + array like the operator objects. 121 + 122 + When a bash operator is encountered, the element in the array with be an object 123 + with an `"op"` key set to the operator string. For example: 124 + 125 + ``` 126 + 'beep || boop > /byte' 127 + ``` 128 + 129 + parses as: 130 + 131 + ``` 132 + [ 'beep', { op: '||' }, 'boop', { op: '>' }, '/byte' ] 133 + ``` 134 + 135 + # install 136 + 137 + With [npm](http://npmjs.org) do: 138 + 139 + ``` 140 + npm install shell-quote 141 + ``` 142 + 143 + # license 144 + 145 + MIT 146 + 147 + [package-url]: https://npmjs.org/package/shell-quote 148 + [npm-version-svg]: https://versionbadg.es/ljharb/shell-quote.svg 149 + [deps-svg]: https://david-dm.org/ljharb/shell-quote.svg 150 + [deps-url]: https://david-dm.org/ljharb/shell-quote 151 + [dev-deps-svg]: https://david-dm.org/ljharb/shell-quote/dev-status.svg 152 + [dev-deps-url]: https://david-dm.org/ljharb/shell-quote#info=devDependencies 153 + [npm-badge-png]: https://nodei.co/npm/shell-quote.png?downloads=true&stars=true 154 + [license-image]: https://img.shields.io/npm/l/shell-quote.svg 155 + [license-url]: LICENSE 156 + [downloads-image]: https://img.shields.io/npm/dm/shell-quote.svg 157 + [downloads-url]: https://npm-stat.com/charts.html?package=shell-quote 158 + [codecov-image]: https://codecov.io/gh/ljharb/shell-quote/branch/main/graphs/badge.svg 159 + [codecov-url]: https://app.codecov.io/gh/ljharb/shell-quote/ 160 + [actions-image]: https://img.shields.io/endpoint?url=https://github-actions-badge-u3jn4tfpocch.runkit.sh/ljharb/shell-quote 161 + [actions-url]: https://github.com/ljharb/shell-quote/actions
+4
modules/_config/pi/extensions/permission-pi/node_modules/shell-quote/index.js
··· 1 + 'use strict'; 2 + 3 + exports.quote = require('./quote'); 4 + exports.parse = require('./parse');
+72
modules/_config/pi/extensions/permission-pi/node_modules/shell-quote/package.json
··· 1 + { 2 + "name": "shell-quote", 3 + "description": "quote and parse shell commands", 4 + "version": "1.8.3", 5 + "author": { 6 + "name": "James Halliday", 7 + "email": "mail@substack.net", 8 + "url": "http://substack.net" 9 + }, 10 + "funding": { 11 + "url": "https://github.com/sponsors/ljharb" 12 + }, 13 + "bugs": "https://github.com/ljharb/shell-quote/issues", 14 + "devDependencies": { 15 + "@ljharb/eslint-config": "^21.1.1", 16 + "auto-changelog": "^2.5.0", 17 + "encoding": "^0.1.13", 18 + "eslint": "=8.8.0", 19 + "evalmd": "^0.0.19", 20 + "in-publish": "^2.0.1", 21 + "jackspeak": "=2.1.1", 22 + "npmignore": "^0.3.1", 23 + "nyc": "^10.3.2", 24 + "safe-publish-latest": "^2.0.0", 25 + "tape": "^5.9.0" 26 + }, 27 + "homepage": "https://github.com/ljharb/shell-quote", 28 + "keywords": [ 29 + "command", 30 + "parse", 31 + "quote", 32 + "shell" 33 + ], 34 + "license": "MIT", 35 + "main": "index.js", 36 + "repository": { 37 + "type": "git", 38 + "url": "http://github.com/ljharb/shell-quote.git" 39 + }, 40 + "scripts": { 41 + "prepack": "npmignore --auto --commentLines=autogenerated", 42 + "prepublish": "not-in-publish || npm run prepublishOnly", 43 + "prepublishOnly": "safe-publish-latest", 44 + "prelint": "evalmd README.md", 45 + "lint": "eslint --ext=js,mjs .", 46 + "pretest": "npm run lint", 47 + "tests-only": "nyc tape 'test/**/*.js'", 48 + "test": "npm run tests-only", 49 + "posttest": "npx npm@'>=10.2' audit --production", 50 + "version": "auto-changelog && git add CHANGELOG.md", 51 + "postversion": "auto-changelog && git add CHANGELOG.md && git commit --no-edit --amend && git tag -f \"v$(node -e \"console.log(require('./package.json').version)\")\"" 52 + }, 53 + "auto-changelog": { 54 + "output": "CHANGELOG.md", 55 + "template": "keepachangelog", 56 + "unreleased": false, 57 + "commitLimit": false, 58 + "backfillLimit": false, 59 + "hideCredit": true, 60 + "startingVersion": "1.7.4" 61 + }, 62 + "publishConfig": { 63 + "ignore": [ 64 + ".github/workflows", 65 + "example", 66 + "CHANGELOG.md" 67 + ] 68 + }, 69 + "engines": { 70 + "node": ">= 0.4" 71 + } 72 + }
+226
modules/_config/pi/extensions/permission-pi/node_modules/shell-quote/parse.js
··· 1 + 'use strict'; 2 + 3 + // '<(' is process substitution operator and 4 + // can be parsed the same as control operator 5 + var CONTROL = '(?:' + [ 6 + '\\|\\|', 7 + '\\&\\&', 8 + ';;', 9 + '\\|\\&', 10 + '\\<\\(', 11 + '\\<\\<\\<', 12 + '>>', 13 + '>\\&', 14 + '<\\&', 15 + '[&;()|<>]' 16 + ].join('|') + ')'; 17 + var controlRE = new RegExp('^' + CONTROL + '$'); 18 + var META = '|&;()<> \\t'; 19 + var SINGLE_QUOTE = '"((\\\\"|[^"])*?)"'; 20 + var DOUBLE_QUOTE = '\'((\\\\\'|[^\'])*?)\''; 21 + var hash = /^#$/; 22 + 23 + var SQ = "'"; 24 + var DQ = '"'; 25 + var DS = '$'; 26 + 27 + var TOKEN = ''; 28 + var mult = 0x100000000; // Math.pow(16, 8); 29 + for (var i = 0; i < 4; i++) { 30 + TOKEN += (mult * Math.random()).toString(16); 31 + } 32 + var startsWithToken = new RegExp('^' + TOKEN); 33 + 34 + function matchAll(s, r) { 35 + var origIndex = r.lastIndex; 36 + 37 + var matches = []; 38 + var matchObj; 39 + 40 + while ((matchObj = r.exec(s))) { 41 + matches.push(matchObj); 42 + if (r.lastIndex === matchObj.index) { 43 + r.lastIndex += 1; 44 + } 45 + } 46 + 47 + r.lastIndex = origIndex; 48 + 49 + return matches; 50 + } 51 + 52 + function getVar(env, pre, key) { 53 + var r = typeof env === 'function' ? env(key) : env[key]; 54 + if (typeof r === 'undefined' && key != '') { 55 + r = ''; 56 + } else if (typeof r === 'undefined') { 57 + r = '$'; 58 + } 59 + 60 + if (typeof r === 'object') { 61 + return pre + TOKEN + JSON.stringify(r) + TOKEN; 62 + } 63 + return pre + r; 64 + } 65 + 66 + function parseInternal(string, env, opts) { 67 + if (!opts) { 68 + opts = {}; 69 + } 70 + var BS = opts.escape || '\\'; 71 + var BAREWORD = '(\\' + BS + '[\'"' + META + ']|[^\\s\'"' + META + '])+'; 72 + 73 + var chunker = new RegExp([ 74 + '(' + CONTROL + ')', // control chars 75 + '(' + BAREWORD + '|' + SINGLE_QUOTE + '|' + DOUBLE_QUOTE + ')+' 76 + ].join('|'), 'g'); 77 + 78 + var matches = matchAll(string, chunker); 79 + 80 + if (matches.length === 0) { 81 + return []; 82 + } 83 + if (!env) { 84 + env = {}; 85 + } 86 + 87 + var commented = false; 88 + 89 + return matches.map(function (match) { 90 + var s = match[0]; 91 + if (!s || commented) { 92 + return void undefined; 93 + } 94 + if (controlRE.test(s)) { 95 + return { op: s }; 96 + } 97 + 98 + // Hand-written scanner/parser for Bash quoting rules: 99 + // 100 + // 1. inside single quotes, all characters are printed literally. 101 + // 2. inside double quotes, all characters are printed literally 102 + // except variables prefixed by '$' and backslashes followed by 103 + // either a double quote or another backslash. 104 + // 3. outside of any quotes, backslashes are treated as escape 105 + // characters and not printed (unless they are themselves escaped) 106 + // 4. quote context can switch mid-token if there is no whitespace 107 + // between the two quote contexts (e.g. all'one'"token" parses as 108 + // "allonetoken") 109 + var quote = false; 110 + var esc = false; 111 + var out = ''; 112 + var isGlob = false; 113 + var i; 114 + 115 + function parseEnvVar() { 116 + i += 1; 117 + var varend; 118 + var varname; 119 + var char = s.charAt(i); 120 + 121 + if (char === '{') { 122 + i += 1; 123 + if (s.charAt(i) === '}') { 124 + throw new Error('Bad substitution: ' + s.slice(i - 2, i + 1)); 125 + } 126 + varend = s.indexOf('}', i); 127 + if (varend < 0) { 128 + throw new Error('Bad substitution: ' + s.slice(i)); 129 + } 130 + varname = s.slice(i, varend); 131 + i = varend; 132 + } else if ((/[*@#?$!_-]/).test(char)) { 133 + varname = char; 134 + i += 1; 135 + } else { 136 + var slicedFromI = s.slice(i); 137 + varend = slicedFromI.match(/[^\w\d_]/); 138 + if (!varend) { 139 + varname = slicedFromI; 140 + i = s.length; 141 + } else { 142 + varname = slicedFromI.slice(0, varend.index); 143 + i += varend.index - 1; 144 + } 145 + } 146 + return getVar(env, '', varname); 147 + } 148 + 149 + for (i = 0; i < s.length; i++) { 150 + var c = s.charAt(i); 151 + isGlob = isGlob || (!quote && (c === '*' || c === '?')); 152 + if (esc) { 153 + out += c; 154 + esc = false; 155 + } else if (quote) { 156 + if (c === quote) { 157 + quote = false; 158 + } else if (quote == SQ) { 159 + out += c; 160 + } else { // Double quote 161 + if (c === BS) { 162 + i += 1; 163 + c = s.charAt(i); 164 + if (c === DQ || c === BS || c === DS) { 165 + out += c; 166 + } else { 167 + out += BS + c; 168 + } 169 + } else if (c === DS) { 170 + out += parseEnvVar(); 171 + } else { 172 + out += c; 173 + } 174 + } 175 + } else if (c === DQ || c === SQ) { 176 + quote = c; 177 + } else if (controlRE.test(c)) { 178 + return { op: s }; 179 + } else if (hash.test(c)) { 180 + commented = true; 181 + var commentObj = { comment: string.slice(match.index + i + 1) }; 182 + if (out.length) { 183 + return [out, commentObj]; 184 + } 185 + return [commentObj]; 186 + } else if (c === BS) { 187 + esc = true; 188 + } else if (c === DS) { 189 + out += parseEnvVar(); 190 + } else { 191 + out += c; 192 + } 193 + } 194 + 195 + if (isGlob) { 196 + return { op: 'glob', pattern: out }; 197 + } 198 + 199 + return out; 200 + }).reduce(function (prev, arg) { // finalize parsed arguments 201 + // TODO: replace this whole reduce with a concat 202 + return typeof arg === 'undefined' ? prev : prev.concat(arg); 203 + }, []); 204 + } 205 + 206 + module.exports = function parse(s, env, opts) { 207 + var mapped = parseInternal(s, env, opts); 208 + if (typeof env !== 'function') { 209 + return mapped; 210 + } 211 + return mapped.reduce(function (acc, s) { 212 + if (typeof s === 'object') { 213 + return acc.concat(s); 214 + } 215 + var xs = s.split(RegExp('(' + TOKEN + '.*?' + TOKEN + ')', 'g')); 216 + if (xs.length === 1) { 217 + return acc.concat(xs[0]); 218 + } 219 + return acc.concat(xs.filter(Boolean).map(function (x) { 220 + if (startsWithToken.test(x)) { 221 + return JSON.parse(x.split(TOKEN)[1]); 222 + } 223 + return x; 224 + })); 225 + }, []); 226 + };
+3
modules/_config/pi/extensions/permission-pi/node_modules/shell-quote/print.py
··· 1 + #!/usr/bin/env python3 2 + import sys 3 + print(sys.argv[1])
+19
modules/_config/pi/extensions/permission-pi/node_modules/shell-quote/quote.js
··· 1 + 'use strict'; 2 + 3 + module.exports = function quote(xs) { 4 + return xs.map(function (s) { 5 + if (s === '') { 6 + return '\'\''; 7 + } 8 + if (s && typeof s === 'object') { 9 + return s.op.replace(/(.)/g, '\\$1'); 10 + } 11 + if ((/["\s\\]/).test(s) && !(/'/).test(s)) { 12 + return "'" + s.replace(/(['])/g, '\\$1') + "'"; 13 + } 14 + if ((/["'\s]/).test(s)) { 15 + return '"' + s.replace(/(["\\$`!])/g, '\\$1') + '"'; 16 + } 17 + return String(s).replace(/([A-Za-z]:)?([#!"$&'()*,:;<=>?@[\\\]^`{|}])/g, '$1\\$2'); 18 + }).join(' '); 19 + };
+11
modules/_config/pi/extensions/permission-pi/node_modules/shell-quote/security.md
··· 1 + # Security Policy 2 + 3 + ## Supported Versions 4 + 5 + Only the latest major version is supported at any given time. 6 + 7 + ## Reporting a Vulnerability 8 + 9 + To report a security vulnerability, please use the 10 + [Tidelift security contact](https://tidelift.com/security). 11 + Tidelift will coordinate the fix and disclosure.
+16
modules/_config/pi/extensions/permission-pi/node_modules/shell-quote/test/comment.js
··· 1 + 'use strict'; 2 + 3 + var test = require('tape'); 4 + var parse = require('../').parse; 5 + 6 + test('comment', function (t) { 7 + t.same(parse('beep#boop'), ['beep', { comment: 'boop' }]); 8 + t.same(parse('beep #boop'), ['beep', { comment: 'boop' }]); 9 + t.same(parse('beep # boop'), ['beep', { comment: ' boop' }]); 10 + t.same(parse('beep # > boop'), ['beep', { comment: ' > boop' }]); 11 + t.same(parse('beep # "> boop"'), ['beep', { comment: ' "> boop"' }]); 12 + t.same(parse('beep "#"'), ['beep', '#']); 13 + t.same(parse('beep #"#"#'), ['beep', { comment: '"#"#' }]); 14 + t.same(parse('beep > boop # > foo'), ['beep', { op: '>' }, 'boop', { comment: ' > foo' }]); 15 + t.end(); 16 + });
+52
modules/_config/pi/extensions/permission-pi/node_modules/shell-quote/test/env.js
··· 1 + 'use strict'; 2 + 3 + var test = require('tape'); 4 + var parse = require('../').parse; 5 + 6 + test('expand environment variables', function (t) { 7 + t.same(parse('a $XYZ c', { XYZ: 'b' }), ['a', 'b', 'c']); 8 + t.same(parse('a${XYZ}c', { XYZ: 'b' }), ['abc']); 9 + t.same(parse('a${XYZ}c $XYZ', { XYZ: 'b' }), ['abc', 'b']); 10 + t.same(parse('"-$X-$Y-"', { X: 'a', Y: 'b' }), ['-a-b-']); 11 + t.same(parse("'-$X-$Y-'", { X: 'a', Y: 'b' }), ['-$X-$Y-']); 12 + t.same(parse('qrs"$zzz"wxy', { zzz: 'tuv' }), ['qrstuvwxy']); 13 + t.same(parse("qrs'$zzz'wxy", { zzz: 'tuv' }), ['qrs$zzzwxy']); 14 + t.same(parse('qrs${zzz}wxy'), ['qrswxy']); 15 + t.same(parse('qrs$wxy $'), ['qrs', '$']); 16 + t.same(parse('grep "xy$"'), ['grep', 'xy$']); 17 + t.same(parse('ab$x', { x: 'c' }), ['abc']); 18 + t.same(parse('ab\\$x', { x: 'c' }), ['ab$x']); 19 + t.same(parse('ab${x}def', { x: 'c' }), ['abcdef']); 20 + t.same(parse('ab\\${x}def', { x: 'c' }), ['ab${x}def']); 21 + t.same(parse('"ab\\${x}def"', { x: 'c' }), ['ab${x}def']); 22 + 23 + t.end(); 24 + }); 25 + 26 + test('expand environment variables within here-strings', function (t) { 27 + t.same(parse('a <<< $x', { x: 'Joe' }), ['a', { op: '<<<' }, 'Joe']); 28 + t.same(parse('a <<< ${x}', { x: 'Joe' }), ['a', { op: '<<<' }, 'Joe']); 29 + t.same(parse('a <<< "$x"', { x: 'Joe' }), ['a', { op: '<<<' }, 'Joe']); 30 + t.same(parse('a <<< "${x}"', { x: 'Joe' }), ['a', { op: '<<<' }, 'Joe']); 31 + 32 + t.end(); 33 + }); 34 + 35 + test('environment variables with metacharacters', function (t) { 36 + t.same(parse('a $XYZ c', { XYZ: '"b"' }), ['a', '"b"', 'c']); 37 + t.same(parse('a $XYZ c', { XYZ: '$X', X: 5 }), ['a', '$X', 'c']); 38 + t.same(parse('a"$XYZ"c', { XYZ: "'xyz'" }), ["a'xyz'c"]); 39 + 40 + t.end(); 41 + }); 42 + 43 + test('special shell parameters', function (t) { 44 + var chars = '*@#?-$!0_'.split(''); 45 + t.plan(chars.length); 46 + 47 + chars.forEach(function (c) { 48 + var env = {}; 49 + env[c] = 'xxx'; 50 + t.same(parse('a $' + c + ' c', env), ['a', 'xxx', 'c']); 51 + }); 52 + });
+21
modules/_config/pi/extensions/permission-pi/node_modules/shell-quote/test/env_fn.js
··· 1 + 'use strict'; 2 + 3 + var test = require('tape'); 4 + var parse = require('../').parse; 5 + 6 + function getEnv() { 7 + return 'xxx'; 8 + } 9 + 10 + function getEnvObj() { 11 + return { op: '@@' }; 12 + } 13 + 14 + test('functional env expansion', function (t) { 15 + t.plan(4); 16 + 17 + t.same(parse('a $XYZ c', getEnv), ['a', 'xxx', 'c']); 18 + t.same(parse('a $XYZ c', getEnvObj), ['a', { op: '@@' }, 'c']); 19 + t.same(parse('a${XYZ}c', getEnvObj), ['a', { op: '@@' }, 'c']); 20 + t.same(parse('"a $XYZ c"', getEnvObj), ['a ', { op: '@@' }, ' c']); 21 + });
+102
modules/_config/pi/extensions/permission-pi/node_modules/shell-quote/test/op.js
··· 1 + 'use strict'; 2 + 3 + var test = require('tape'); 4 + var parse = require('../').parse; 5 + 6 + test('single operators', function (t) { 7 + t.same(parse('beep | boop'), ['beep', { op: '|' }, 'boop']); 8 + t.same(parse('beep|boop'), ['beep', { op: '|' }, 'boop']); 9 + t.same(parse('beep \\| boop'), ['beep', '|', 'boop']); 10 + t.same(parse('beep "|boop"'), ['beep', '|boop']); 11 + 12 + t.same(parse('echo zing &'), ['echo', 'zing', { op: '&' }]); 13 + t.same(parse('echo zing&'), ['echo', 'zing', { op: '&' }]); 14 + t.same(parse('echo zing\\&'), ['echo', 'zing&']); 15 + t.same(parse('echo "zing\\&"'), ['echo', 'zing\\&']); 16 + 17 + t.same(parse('beep;boop'), ['beep', { op: ';' }, 'boop']); 18 + t.same(parse('(beep;boop)'), [ 19 + { op: '(' }, 'beep', { op: ';' }, 'boop', { op: ')' } 20 + ]); 21 + 22 + t.same(parse('beep>boop'), ['beep', { op: '>' }, 'boop']); 23 + t.same(parse('beep 2>boop'), ['beep', '2', { op: '>' }, 'boop']); 24 + t.same(parse('beep<boop'), ['beep', { op: '<' }, 'boop']); 25 + 26 + t.end(); 27 + }); 28 + 29 + test('double operators', function (t) { 30 + t.same(parse('beep || boop'), ['beep', { op: '||' }, 'boop']); 31 + t.same(parse('beep||boop'), ['beep', { op: '||' }, 'boop']); 32 + t.same(parse('beep ||boop'), ['beep', { op: '||' }, 'boop']); 33 + t.same(parse('beep|| boop'), ['beep', { op: '||' }, 'boop']); 34 + t.same(parse('beep || boop'), ['beep', { op: '||' }, 'boop']); 35 + 36 + t.same(parse('beep && boop'), ['beep', { op: '&&' }, 'boop']); 37 + t.same( 38 + parse('beep && boop || byte'), 39 + ['beep', { op: '&&' }, 'boop', { op: '||' }, 'byte'] 40 + ); 41 + t.same( 42 + parse('beep&&boop||byte'), 43 + ['beep', { op: '&&' }, 'boop', { op: '||' }, 'byte'] 44 + ); 45 + t.same( 46 + parse('beep\\&\\&boop||byte'), 47 + ['beep&&boop', { op: '||' }, 'byte'] 48 + ); 49 + t.same( 50 + parse('beep\\&&boop||byte'), 51 + ['beep&', { op: '&' }, 'boop', { op: '||' }, 'byte'] 52 + ); 53 + t.same( 54 + parse('beep;;boop|&byte>>blip'), 55 + ['beep', { op: ';;' }, 'boop', { op: '|&' }, 'byte', { op: '>>' }, 'blip'] 56 + ); 57 + 58 + t.same(parse('beep 2>&1'), ['beep', '2', { op: '>&' }, '1']); 59 + 60 + t.same( 61 + parse('beep<(boop)'), 62 + ['beep', { op: '<(' }, 'boop', { op: ')' }] 63 + ); 64 + t.same( 65 + parse('beep<<(boop)'), 66 + ['beep', { op: '<' }, { op: '<(' }, 'boop', { op: ')' }] 67 + ); 68 + 69 + t.end(); 70 + }); 71 + 72 + test('duplicating input file descriptors', function (t) { 73 + // duplicating stdout to file descriptor 3 74 + t.same(parse('beep 3<&1'), ['beep', '3', { op: '<&' }, '1']); 75 + 76 + // duplicating stdout to file descriptor 0, i.e. stdin 77 + t.same(parse('beep <&1'), ['beep', { op: '<&' }, '1']); 78 + 79 + // closes stdin 80 + t.same(parse('beep <&-'), ['beep', { op: '<&' }, '-']); 81 + 82 + t.end(); 83 + }); 84 + 85 + test('here strings', function (t) { 86 + t.same(parse('cat <<< "hello world"'), ['cat', { op: '<<<' }, 'hello world']); 87 + t.same(parse('cat <<< hello'), ['cat', { op: '<<<' }, 'hello']); 88 + t.same(parse('cat<<<hello'), ['cat', { op: '<<<' }, 'hello']); 89 + t.same(parse('cat<<<"hello world"'), ['cat', { op: '<<<' }, 'hello world']); 90 + 91 + t.end(); 92 + }); 93 + 94 + test('glob patterns', function (t) { 95 + t.same( 96 + parse('tap test/*.test.js'), 97 + ['tap', { op: 'glob', pattern: 'test/*.test.js' }] 98 + ); 99 + 100 + t.same(parse('tap "test/*.test.js"'), ['tap', 'test/*.test.js']); 101 + t.end(); 102 + });
+44
modules/_config/pi/extensions/permission-pi/node_modules/shell-quote/test/parse.js
··· 1 + 'use strict'; 2 + 3 + var test = require('tape'); 4 + var parse = require('../').parse; 5 + 6 + test('parse shell commands', function (t) { 7 + t.same(parse(''), [], 'parses an empty string'); 8 + 9 + t['throws']( 10 + function () { parse('${}'); }, 11 + Error, 12 + 'empty substitution throws' 13 + ); 14 + t['throws']( 15 + function () { parse('${'); }, 16 + Error, 17 + 'incomplete substitution throws' 18 + ); 19 + 20 + t.same(parse('a \'b\' "c"'), ['a', 'b', 'c']); 21 + t.same( 22 + parse('beep "boop" \'foo bar baz\' "it\'s \\"so\\" groovy"'), 23 + ['beep', 'boop', 'foo bar baz', 'it\'s "so" groovy'] 24 + ); 25 + t.same(parse('a b\\ c d'), ['a', 'b c', 'd']); 26 + t.same(parse('\\$beep bo\\`op'), ['$beep', 'bo`op']); 27 + t.same(parse('echo "foo = \\"foo\\""'), ['echo', 'foo = "foo"']); 28 + t.same(parse(''), []); 29 + t.same(parse(' '), []); 30 + t.same(parse('\t'), []); 31 + t.same(parse('a"b c d"e'), ['ab c de']); 32 + t.same(parse('a\\ b"c d"\\ e f'), ['a bc d e', 'f']); 33 + t.same(parse('a\\ b"c d"\\ e\'f g\' h'), ['a bc d ef g', 'h']); 34 + t.same(parse("x \"bl'a\"'h'"), ['x', "bl'ah"]); 35 + t.same(parse("x bl^'a^'h'", {}, { escape: '^' }), ['x', "bl'a'h"]); 36 + t.same(parse('abcH def', {}, { escape: 'H' }), ['abc def']); 37 + 38 + t.deepEqual(parse('# abc def ghi'), [{ comment: ' abc def ghi' }], 'start-of-line comment content is unparsed'); 39 + t.deepEqual(parse('xyz # abc def ghi'), ['xyz', { comment: ' abc def ghi' }], 'comment content is unparsed'); 40 + 41 + t.deepEqual(parse('-x "" -y'), ['-x', '', '-y'], 'empty string is preserved'); 42 + 43 + t.end(); 44 + });
+60
modules/_config/pi/extensions/permission-pi/node_modules/shell-quote/test/quote.js
··· 1 + 'use strict'; 2 + 3 + var test = require('tape'); 4 + var quote = require('../').quote; 5 + 6 + test('quote', function (t) { 7 + t.equal(quote(['a', 'b', 'c d']), 'a b \'c d\''); 8 + t.equal( 9 + quote(['a', 'b', "it's a \"neat thing\""]), 10 + 'a b "it\'s a \\"neat thing\\""' 11 + ); 12 + t.equal( 13 + quote(['$', '`', '\'']), 14 + '\\$ \\` "\'"' 15 + ); 16 + t.equal(quote([]), ''); 17 + t.equal(quote(['a\nb']), "'a\nb'"); 18 + t.equal(quote([' #(){}*|][!']), "' #(){}*|][!'"); 19 + t.equal(quote(["'#(){}*|][!"]), '"\'#(){}*|][\\!"'); 20 + t.equal(quote(['X#(){}*|][!']), 'X\\#\\(\\)\\{\\}\\*\\|\\]\\[\\!'); 21 + t.equal(quote(['a\n#\nb']), "'a\n#\nb'"); 22 + t.equal(quote(['><;{}']), '\\>\\<\\;\\{\\}'); 23 + t.equal(quote(['a', 1, true, false]), 'a 1 true false'); 24 + t.equal(quote(['a', 1, null, undefined]), 'a 1 null undefined'); 25 + t.equal(quote(['a\\x']), "'a\\x'"); 26 + t.equal(quote(['a"b']), '\'a"b\''); 27 + t.equal(quote(['"a"b"']), '\'"a"b"\''); 28 + t.equal(quote(['a\\"b']), '\'a\\"b\''); 29 + t.equal(quote(['a\\b']), '\'a\\b\''); 30 + t.end(); 31 + }); 32 + 33 + test('quote ops', function (t) { 34 + t.equal(quote(['a', { op: '|' }, 'b']), 'a \\| b'); 35 + t.equal( 36 + quote(['a', { op: '&&' }, 'b', { op: ';' }, 'c']), 37 + 'a \\&\\& b \\; c' 38 + ); 39 + t.end(); 40 + }); 41 + 42 + test('quote windows paths', { skip: 'breaking change, disabled until 2.x' }, function (t) { 43 + var path = 'C:\\projects\\node-shell-quote\\index.js'; 44 + 45 + t.equal(quote([path, 'b', 'c d']), 'C:\\projects\\node-shell-quote\\index.js b \'c d\''); 46 + 47 + t.end(); 48 + }); 49 + 50 + test("chars for windows paths don't break out", function (t) { 51 + var x = '`:\\a\\b'; 52 + t.equal(quote([x]), "'`:\\a\\b'"); 53 + t.end(); 54 + }); 55 + 56 + test('empty strings', function (t) { 57 + t.equal(quote(['-x', '', 'y']), '-x \'\' y'); 58 + 59 + t.end(); 60 + });
+31
modules/_config/pi/extensions/permission-pi/node_modules/shell-quote/test/set.js
··· 1 + 'use strict'; 2 + 3 + var test = require('tape'); 4 + var parse = require('../').parse; 5 + 6 + test('set env vars', function (t) { 7 + t.same( 8 + parse('ABC=444 x y z'), 9 + ['ABC=444', 'x', 'y', 'z'] 10 + ); 11 + t.same( 12 + parse('ABC=3\\ 4\\ 5 x y z'), 13 + ['ABC=3 4 5', 'x', 'y', 'z'] 14 + ); 15 + t.same( 16 + parse('X="7 8 9" printx'), 17 + ['X=7 8 9', 'printx'] 18 + ); 19 + t.same( 20 + parse('X="7 8 9"; printx'), 21 + ['X=7 8 9', { op: ';' }, 'printx'] 22 + ); 23 + t.same( 24 + parse('X="7 8 9"; printx', function () { 25 + t.fail('should not have matched any keys'); 26 + }), 27 + ['X=7 8 9', { op: ';' }, 'printx'] 28 + ); 29 + 30 + t.end(); 31 + });
+28
modules/_config/pi/extensions/permission-pi/package-lock.json
··· 1 + { 2 + "name": "permission-pi", 3 + "version": "1.0.2", 4 + "lockfileVersion": 3, 5 + "requires": true, 6 + "packages": { 7 + "": { 8 + "name": "permission-pi", 9 + "version": "1.0.2", 10 + "license": "MIT", 11 + "dependencies": { 12 + "shell-quote": "^1.8.3" 13 + } 14 + }, 15 + "node_modules/shell-quote": { 16 + "version": "1.8.3", 17 + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", 18 + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", 19 + "license": "MIT", 20 + "engines": { 21 + "node": ">= 0.4" 22 + }, 23 + "funding": { 24 + "url": "https://github.com/sponsors/ljharb" 25 + } 26 + } 27 + } 28 + }
+23
modules/_config/pi/extensions/permission-pi/package.json
··· 1 + { 2 + "name": "permission-pi", 3 + "version": "1.0.2", 4 + "description": "Layered permission control extension for pi-coding-agent", 5 + "type": "module", 6 + "keywords": [ 7 + "permission", 8 + "security", 9 + "pi-coding-agent", 10 + "extension", 11 + "pi-package" 12 + ], 13 + "author": "", 14 + "license": "MIT", 15 + "pi": { 16 + "extensions": [ 17 + "./permission.ts" 18 + ] 19 + }, 20 + "dependencies": { 21 + "shell-quote": "^1.8.3" 22 + } 23 + }
+1194
modules/_config/pi/extensions/permission-pi/permission-core.ts
··· 1 + /** 2 + * Core permission logic - command classification and settings 3 + * 4 + * This module contains pure functions for: 5 + * - Parsing shell commands 6 + * - Classifying commands by required permission level 7 + * - Detecting dangerous commands 8 + * - Managing settings persistence 9 + */ 10 + 11 + import * as fs from "node:fs"; 12 + import * as path from "node:path"; 13 + import { parse } from "shell-quote"; 14 + 15 + // ============================================================================ 16 + // TYPES 17 + // ============================================================================ 18 + 19 + export type PermissionLevel = "minimal" | "low" | "medium" | "high" | "bypassed"; 20 + 21 + export type PermissionMode = "ask" | "block"; 22 + 23 + export const LEVELS: PermissionLevel[] = ["minimal", "low", "medium", "high", "bypassed"]; 24 + export const PERMISSION_MODES: PermissionMode[] = ["ask", "block"]; 25 + 26 + export const LEVEL_INDEX: Record<PermissionLevel, number> = { 27 + minimal: 0, 28 + low: 1, 29 + medium: 2, 30 + high: 3, 31 + bypassed: 4, 32 + }; 33 + 34 + export const LEVEL_INFO: Record<PermissionLevel, { label: string; desc: string }> = { 35 + minimal: { label: "Minimal", desc: "Read-only" }, 36 + low: { label: "Low", desc: "File ops only" }, 37 + medium: { label: "Medium", desc: "Dev operations" }, 38 + high: { label: "High", desc: "Full operations" }, 39 + bypassed: { label: "Bypassed", desc: "All checks disabled" }, 40 + }; 41 + 42 + export const PERMISSION_MODE_INFO: Record<PermissionMode, { label: string; desc: string }> = { 43 + ask: { label: "Ask", desc: "Prompt when permission is required" }, 44 + block: { label: "Block", desc: "Block instead of prompting" }, 45 + }; 46 + 47 + export const LEVEL_ALLOWED_DESC: Record<PermissionLevel, string> = { 48 + minimal: "read-only (cat, ls, grep, git status/diff/log, npm list, version checks)", 49 + low: "read-only + file write/edit", 50 + medium: "dev ops (install packages, build, test, git commit/pull, file operations)", 51 + high: "full operations except dangerous commands", 52 + bypassed: "all operations", 53 + }; 54 + 55 + export interface Classification { 56 + level: PermissionLevel; 57 + dangerous: boolean; 58 + } 59 + 60 + // ============================================================================ 61 + // CONFIGURATION TYPES 62 + // ============================================================================ 63 + 64 + export interface PermissionConfig { 65 + /** Override patterns to force specific permission levels */ 66 + overrides?: { 67 + minimal?: string[]; 68 + low?: string[]; 69 + medium?: string[]; 70 + high?: string[]; 71 + dangerous?: string[]; 72 + }; 73 + /** Prefix mappings to normalize commands before classification */ 74 + prefixMappings?: Array<{ 75 + from: string; 76 + to: string; 77 + }>; 78 + } 79 + 80 + // ============================================================================ 81 + // CONFIGURATION CACHING 82 + // ============================================================================ 83 + 84 + let configCache: PermissionConfig | null = null; 85 + let configCacheTime = 0; 86 + /** Cache TTL in milliseconds - balance between responsiveness and performance */ 87 + const CONFIG_CACHE_TTL = 5000; // 5 seconds 88 + 89 + let regexCache: Map<string, RegExp> = new Map(); 90 + /** Maximum cached regex patterns to prevent memory exhaustion */ 91 + const MAX_REGEX_CACHE_SIZE = 500; 92 + 93 + function getCachedConfig(): PermissionConfig { 94 + const now = Date.now(); 95 + if (!configCache || now - configCacheTime > CONFIG_CACHE_TTL) { 96 + configCache = loadPermissionConfig(); 97 + configCacheTime = now; 98 + } 99 + return configCache; 100 + } 101 + 102 + function getCachedRegex(pattern: string): RegExp { 103 + let regex = regexCache.get(pattern); 104 + if (!regex) { 105 + // Evict oldest entries if cache is full (simple FIFO eviction) 106 + if (regexCache.size >= MAX_REGEX_CACHE_SIZE) { 107 + const firstKey = regexCache.keys().next().value; 108 + if (firstKey) regexCache.delete(firstKey); 109 + } 110 + regex = globToRegex(pattern); 111 + regexCache.set(pattern, regex); 112 + } 113 + return regex; 114 + } 115 + 116 + export function invalidateConfigCache(): void { 117 + configCache = null; 118 + regexCache.clear(); 119 + } 120 + 121 + /** 122 + * Validate and sanitize permission config 123 + * Returns a safe config object with invalid entries removed 124 + */ 125 + function validateConfig(config: unknown): PermissionConfig { 126 + if (!config || typeof config !== 'object') { 127 + return {}; 128 + } 129 + 130 + const result: PermissionConfig = {}; 131 + const raw = config as Record<string, unknown>; 132 + 133 + // Validate overrides 134 + if (raw.overrides && typeof raw.overrides === 'object') { 135 + const overrides = raw.overrides as Record<string, unknown>; 136 + result.overrides = {}; 137 + 138 + const levels = ['minimal', 'low', 'medium', 'high', 'dangerous'] as const; 139 + for (const level of levels) { 140 + const patterns = overrides[level]; 141 + if (Array.isArray(patterns)) { 142 + // Filter to only valid string patterns, limit count 143 + const validPatterns = patterns 144 + .filter((p): p is string => typeof p === 'string' && p.length > 0) 145 + .slice(0, 100); // Max 100 patterns per level 146 + if (validPatterns.length > 0) { 147 + result.overrides[level] = validPatterns; 148 + } 149 + } 150 + } 151 + } 152 + 153 + // Validate prefix mappings 154 + if (Array.isArray(raw.prefixMappings)) { 155 + const validMappings = raw.prefixMappings 156 + .filter((m): m is { from: string; to: string } => 157 + m && typeof m === 'object' && 158 + typeof (m as any).from === 'string' && (m as any).from.length > 0 && 159 + typeof (m as any).to === 'string' 160 + ) 161 + .slice(0, 50); // Max 50 prefix mappings 162 + if (validMappings.length > 0) { 163 + result.prefixMappings = validMappings; 164 + } 165 + } 166 + 167 + return result; 168 + } 169 + 170 + // ============================================================================ 171 + // PATTERN MATCHING 172 + // ============================================================================ 173 + 174 + /** 175 + * Convert a glob-like pattern to a RegExp 176 + * Supports: * (any chars), ? (single char) 177 + * Patterns are matched against the full command string 178 + */ 179 + function globToRegex(pattern: string): RegExp { 180 + try { 181 + // Limit pattern complexity to prevent ReDoS 182 + // Reject patterns with too many consecutive * (creates .*.*.*... patterns) 183 + if (/\*{5,}/.test(pattern)) { 184 + // More than 4 consecutive * - reject to prevent exponential backtracking 185 + return /(?!)/; 186 + } 187 + 188 + // Escape regex special chars first (except * and ? which we handle specially) 189 + // Note: - is not special outside character classes, so we don't need to escape it 190 + let regex = pattern 191 + .replace(/[.+^${}()|[\]\\]/g, '\\$&') 192 + .replace(/\*/g, '.*') // * -> match any characters 193 + .replace(/\?/g, '.'); // ? -> match single character 194 + 195 + return new RegExp(`^${regex}$`, 'i'); 196 + } catch { 197 + // Return a pattern that never matches on invalid input 198 + return /(?!)/; 199 + } 200 + } 201 + 202 + /** 203 + * Check if a command matches any pattern in the list 204 + */ 205 + function matchesAnyPattern(command: string, patterns: string[] | undefined | null): boolean { 206 + if (!patterns || !Array.isArray(patterns) || patterns.length === 0) { 207 + return false; 208 + } 209 + return patterns.some(pattern => 210 + typeof pattern === 'string' && getCachedRegex(pattern).test(command) 211 + ); 212 + } 213 + 214 + /** 215 + * Apply prefix mappings to normalize command before classification 216 + * e.g., "fvm flutter build" → "flutter build" 217 + */ 218 + function applyPrefixMappings( 219 + command: string, 220 + mappings: PermissionConfig['prefixMappings'] 221 + ): string { 222 + if (!mappings || !Array.isArray(mappings) || mappings.length === 0) return command; 223 + 224 + const trimmed = command.trim(); 225 + const trimmedLower = trimmed.toLowerCase(); 226 + 227 + for (const mapping of mappings) { 228 + // Validate mapping structure 229 + if (!mapping || typeof mapping.from !== 'string' || typeof mapping.to !== 'string') { 230 + continue; 231 + } 232 + 233 + const { from, to } = mapping; 234 + const fromLower = from.toLowerCase(); 235 + 236 + if (trimmedLower.startsWith(fromLower)) { 237 + // Check for word boundary (whitespace or end of string after prefix) 238 + const afterPrefix = trimmed.substring(fromLower.length); 239 + // Use regex to check for whitespace boundary (handles tabs, multiple spaces) 240 + if (afterPrefix === '' || /^\s/.test(afterPrefix)) { 241 + // Replace prefix with mapped value, preserve rest with trimmed leading space 242 + const remainder = afterPrefix.replace(/^\s+/, ''); 243 + if (to === '') { 244 + return remainder; 245 + } 246 + return remainder ? `${to} ${remainder}` : to; 247 + } 248 + } 249 + } 250 + 251 + return command; 252 + } 253 + 254 + /** 255 + * Check if command matches any configured override 256 + * Returns the override classification or null if no match 257 + */ 258 + function checkOverrides( 259 + command: string, 260 + overrides: PermissionConfig['overrides'] 261 + ): Classification | null { 262 + if (!overrides) return null; 263 + 264 + const trimmed = command.trim(); 265 + 266 + // Check dangerous first (highest priority) 267 + if (overrides.dangerous && matchesAnyPattern(trimmed, overrides.dangerous)) { 268 + return { level: 'high', dangerous: true }; 269 + } 270 + 271 + // Check levels in order of specificity (high to low) 272 + if (overrides.high && matchesAnyPattern(trimmed, overrides.high)) { 273 + return { level: 'high', dangerous: false }; 274 + } 275 + 276 + if (overrides.medium && matchesAnyPattern(trimmed, overrides.medium)) { 277 + return { level: 'medium', dangerous: false }; 278 + } 279 + 280 + if (overrides.low && matchesAnyPattern(trimmed, overrides.low)) { 281 + return { level: 'low', dangerous: false }; 282 + } 283 + 284 + if (overrides.minimal && matchesAnyPattern(trimmed, overrides.minimal)) { 285 + return { level: 'minimal', dangerous: false }; 286 + } 287 + 288 + return null; // No override matched 289 + } 290 + 291 + // ============================================================================ 292 + // SETTINGS PERSISTENCE 293 + // ============================================================================ 294 + 295 + function getSettingsPath(): string { 296 + return path.join(process.env.HOME || "", ".pi", "agent", "settings.json"); 297 + } 298 + 299 + function loadSettings(): Record<string, unknown> { 300 + try { 301 + return JSON.parse(fs.readFileSync(getSettingsPath(), "utf-8")); 302 + } catch { 303 + return {}; 304 + } 305 + } 306 + 307 + function saveSettings(settings: Record<string, unknown>): void { 308 + const settingsPath = getSettingsPath(); 309 + const dir = path.dirname(settingsPath); 310 + const tempPath = `${settingsPath}.tmp`; 311 + 312 + try { 313 + if (!fs.existsSync(dir)) { 314 + fs.mkdirSync(dir, { recursive: true }); 315 + } 316 + // Atomic write: write to temp file first, then rename 317 + fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + "\n"); 318 + fs.renameSync(tempPath, settingsPath); // Atomic on POSIX systems 319 + } catch (e) { 320 + // Clean up temp file on error 321 + try { 322 + if (fs.existsSync(tempPath)) { 323 + fs.unlinkSync(tempPath); 324 + } 325 + } catch {} 326 + throw e; 327 + } 328 + } 329 + 330 + export function loadGlobalPermission(): PermissionLevel | null { 331 + const settings = loadSettings(); 332 + const level = (settings.permissionLevel as string)?.toLowerCase(); 333 + if (level && LEVELS.includes(level as PermissionLevel)) { 334 + return level as PermissionLevel; 335 + } 336 + return null; 337 + } 338 + 339 + export function saveGlobalPermission(level: PermissionLevel): void { 340 + const settings = loadSettings(); 341 + settings.permissionLevel = level; 342 + saveSettings(settings); 343 + } 344 + 345 + export function loadGlobalPermissionMode(): PermissionMode | null { 346 + const settings = loadSettings(); 347 + const mode = (settings.permissionMode as string)?.toLowerCase(); 348 + if (mode && PERMISSION_MODES.includes(mode as PermissionMode)) { 349 + return mode as PermissionMode; 350 + } 351 + return null; 352 + } 353 + 354 + export function saveGlobalPermissionMode(mode: PermissionMode): void { 355 + const settings = loadSettings(); 356 + settings.permissionMode = mode; 357 + saveSettings(settings); 358 + } 359 + 360 + export function loadPermissionConfig(): PermissionConfig { 361 + const settings = loadSettings(); 362 + return validateConfig(settings.permissionConfig); 363 + } 364 + 365 + export function savePermissionConfig(config: PermissionConfig): void { 366 + const settings = loadSettings(); 367 + settings.permissionConfig = config; 368 + saveSettings(settings); 369 + } 370 + 371 + // ============================================================================ 372 + // COMMAND PARSING 373 + // ============================================================================ 374 + 375 + interface ParsedCommand { 376 + segments: string[][]; // Commands split by operators 377 + operators: string[]; // |, &&, ||, ; 378 + raw: string; 379 + hasShellTricks?: boolean; 380 + /** Output redirections to non-special files (>, >>) */ 381 + writesFiles?: boolean; 382 + } 383 + 384 + // Shell execution commands that can run arbitrary code 385 + const SHELL_EXECUTION_COMMANDS = new Set([ 386 + "eval", "exec", "source", ".", // shell builtins 387 + "env", // can execute commands: env rm -rf / 388 + "command", // bypasses aliases, can execute arbitrary commands 389 + "builtin", // uses shell builtins directly 390 + // Wrapper commands that can execute arbitrary commands 391 + "time", "nice", "nohup", "timeout", "watch", "strace", 392 + // Note: xargs is handled in CONDITIONAL_WRITE_COMMANDS with smart logic 393 + ]); 394 + 395 + // Patterns that indicate command substitution or shell tricks in raw command 396 + // Only patterns that can actually execute arbitrary code 397 + const SHELL_TRICK_PATTERNS = [ 398 + /\$\((?!\()[^)]+\)/, // $(command) - command substitution (exclude $(( for arithmetic) 399 + /`[^`]+`/, // `command` - backtick substitution 400 + /<\([^)]+\)/, // <(command) - process substitution (input) 401 + />\([^)]+\)/, // >(command) - process substitution (output) 402 + ]; 403 + 404 + // Check if ${...} contains nested command substitution 405 + // Simple ${VAR} is safe, but ${VAR:-$(cmd)} or ${VAR:-`cmd`} is dangerous 406 + function hasDangerousExpansion(command: string): boolean { 407 + const braceExpansions = command.match(/\$\{[^}]+\}/g) || []; 408 + for (const expansion of braceExpansions) { 409 + // Check for nested $() or backticks inside ${...} 410 + if (/\$\(|\`/.test(expansion)) { 411 + return true; 412 + } 413 + } 414 + return false; 415 + } 416 + 417 + function detectShellTricks(command: string): boolean { 418 + // Check basic patterns first 419 + if (SHELL_TRICK_PATTERNS.some(pattern => pattern.test(command))) { 420 + return true; 421 + } 422 + // Check for dangerous ${...} expansions with nested command substitution 423 + if (hasDangerousExpansion(command)) { 424 + return true; 425 + } 426 + return false; 427 + } 428 + 429 + /** 430 + * Check if a command contains arithmetic expansion $((..)) 431 + * Used to avoid false positives from shell-quote parsing 432 + */ 433 + function hasArithmeticExpansion(command: string): boolean { 434 + return /\$\(\(/.test(command); 435 + } 436 + 437 + // Output redirection operators that write to files 438 + const OUTPUT_REDIRECTION_OPS = new Set([">", ">>", ">|", "&>", "&>>"]); 439 + 440 + // Safe redirection targets (not actual file writes) 441 + const SAFE_REDIRECTION_TARGETS = new Set([ 442 + "/dev/null", "/dev/stdout", "/dev/stderr", 443 + "/dev/fd/1", "/dev/fd/2", 444 + ]); 445 + 446 + function parseCommand(command: string): ParsedCommand { 447 + const hasShellTricks = detectShellTricks(command); 448 + 449 + // shell-quote can throw on complex patterns it doesn't understand 450 + // In that case, treat the command as having shell tricks (require high permission) 451 + let tokens: ReturnType<typeof parse>; 452 + try { 453 + tokens = parse(command); 454 + } catch { 455 + // Parse failed - treat as dangerous 456 + return { 457 + segments: [], 458 + operators: [], 459 + raw: command, 460 + hasShellTricks: true 461 + }; 462 + } 463 + 464 + const segments: string[][] = []; 465 + const operators: string[] = []; 466 + let currentSegment: string[] = []; 467 + let foundCommandSubstitution = false; 468 + let writesFiles = false; 469 + 470 + // Redirection operators - these don't start new command segments 471 + const REDIRECTION_OPS = new Set([">", "<", ">>", ">&", "<&", ">|", "<>", "&>", "&>>"]); 472 + let pendingOutputRedirect = false; 473 + 474 + for (let i = 0; i < tokens.length; i++) { 475 + const token = tokens[i]; 476 + 477 + if (pendingOutputRedirect) { 478 + // This token is a redirection target 479 + pendingOutputRedirect = false; 480 + if (typeof token === "string") { 481 + // Check if this is writing to a real file (not /dev/null etc.) 482 + if (!SAFE_REDIRECTION_TARGETS.has(token) && !token.startsWith("/dev/fd/")) { 483 + writesFiles = true; 484 + } 485 + } 486 + continue; 487 + } 488 + 489 + if (typeof token === "string") { 490 + currentSegment.push(token); 491 + } else if (token && typeof token === "object") { 492 + if ("op" in token) { 493 + const op = token.op as string; 494 + if (REDIRECTION_OPS.has(op)) { 495 + // Check if this is an output redirection 496 + if (OUTPUT_REDIRECTION_OPS.has(op)) { 497 + pendingOutputRedirect = true; 498 + } else { 499 + // Input redirection or fd duplication - skip next token 500 + // For >&, <& we need to check if it's fd duplication (2>&1) or file redirect 501 + if (op === ">&" || op === "<&") { 502 + const nextToken = tokens[i + 1]; 503 + if (typeof nextToken === "string" && /^\d+$/.test(nextToken)) { 504 + // fd duplication like 2>&1, skip it 505 + i++; 506 + } else { 507 + // File redirect like >&file 508 + pendingOutputRedirect = true; 509 + } 510 + } 511 + } 512 + } else { 513 + // Only treat actual command separators as segment boundaries 514 + // ( and ) are grouping/subshell/arithmetic operators, not separators 515 + const COMMAND_SEPARATORS = new Set(["|", "&&", "||", ";", "&"]); 516 + if (COMMAND_SEPARATORS.has(op)) { 517 + if (currentSegment.length > 0) { 518 + segments.push(currentSegment); 519 + currentSegment = []; 520 + } 521 + operators.push(op); 522 + } 523 + // Ignore ( and ) - they don't create new command segments 524 + } 525 + } else if ("comment" in token) { 526 + // Comment - ignore 527 + } else { 528 + // shell-quote returns special objects for: 529 + // - { op: 'glob', pattern: '*.js' } - globs 530 + // - { op: string } - operators 531 + // Any other object type indicates shell parsing complexity 532 + // that we should treat as potentially dangerous 533 + foundCommandSubstitution = true; 534 + } 535 + } 536 + } 537 + 538 + if (currentSegment.length > 0) { 539 + segments.push(currentSegment); 540 + } 541 + 542 + return { 543 + segments, 544 + operators, 545 + raw: command, 546 + hasShellTricks: hasShellTricks || foundCommandSubstitution, 547 + writesFiles 548 + }; 549 + } 550 + 551 + function getCommandName(tokens: string[]): string { 552 + if (tokens.length === 0) return ""; 553 + 554 + let cmd = tokens[0]; 555 + 556 + // Strip path prefix 557 + if (cmd.includes("/")) { 558 + cmd = cmd.split("/").pop() || cmd; 559 + } 560 + 561 + // Strip leading backslash (alias bypass) 562 + if (cmd.startsWith("\\")) { 563 + cmd = cmd.slice(1); 564 + } 565 + 566 + return cmd.toLowerCase(); 567 + } 568 + 569 + // ============================================================================ 570 + // DANGEROUS COMMAND DETECTION 571 + // ============================================================================ 572 + 573 + function isDangerousCommand(tokens: string[]): boolean { 574 + if (tokens.length === 0) return false; 575 + 576 + const cmd = getCommandName(tokens); 577 + const args = tokens.slice(1); 578 + const argsStr = args.join(" "); 579 + 580 + // sudo - always dangerous 581 + if (cmd === "sudo") return true; 582 + 583 + // rm with recursive + force 584 + if (cmd === "rm") { 585 + let hasRecursive = false; 586 + let hasForce = false; 587 + 588 + for (const arg of args) { 589 + if (arg === "--recursive") hasRecursive = true; 590 + if (arg === "--force") hasForce = true; 591 + if (arg.startsWith("-") && !arg.startsWith("--")) { 592 + if (arg.includes("r") || arg.includes("R")) hasRecursive = true; 593 + if (arg.includes("f")) hasForce = true; 594 + } 595 + } 596 + 597 + if (hasRecursive && hasForce) return true; 598 + } 599 + 600 + // chmod 777 or a+rwx 601 + if (cmd === "chmod") { 602 + if (argsStr.includes("777") || argsStr.includes("a+rwx")) return true; 603 + } 604 + 605 + // dd to device 606 + if (cmd === "dd") { 607 + if (argsStr.match(/of=\/dev\//)) return true; 608 + } 609 + 610 + // Dangerous system commands 611 + if (["fdisk", "parted", "format"].includes(cmd)) return true; 612 + if (cmd.startsWith("mkfs")) return true; // mkfs, mkfs.ext4, mkfs.xfs, etc. 613 + 614 + // Shutdown/reboot 615 + if (["shutdown", "reboot", "halt", "poweroff", "init"].includes(cmd)) return true; 616 + 617 + // Fork bomb pattern 618 + if (tokens.join("").includes(":(){ :|:& };:")) return true; 619 + 620 + return false; 621 + } 622 + 623 + // ============================================================================ 624 + // LEVEL CLASSIFICATION 625 + // ============================================================================ 626 + 627 + // Common redirection targets (treated as read-only) 628 + const REDIRECTION_TARGETS = new Set([ 629 + "/dev/null", "/dev/stdin", "/dev/stdout", "/dev/stderr", 630 + "/dev/zero", "/dev/full", "/dev/random", "/dev/urandom", 631 + "/dev/fd", "/dev/tty", "/dev/ptmx", 632 + ]); 633 + 634 + // File descriptor numbers used in redirections (e.g., 2>&1) 635 + const FD_NUMBERS = new Set(["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]); 636 + 637 + // MINIMAL level - read-only commands 638 + const MINIMAL_COMMANDS = new Set([ 639 + // File reading 640 + "cat", "less", "more", "head", "tail", "bat", "tac", 641 + // Directory listing/navigation 642 + "ls", "tree", "pwd", "dir", "vdir", "cd", "pushd", "popd", "dirs", 643 + // Search (note: find handled specially due to -exec/-delete) 644 + "grep", "egrep", "fgrep", "rg", "ag", "ack", "fd", "locate", "which", "whereis", 645 + // Info 646 + "echo", "printf", "whoami", "id", "date", "cal", "uname", "hostname", "uptime", 647 + "type", "file", "stat", "wc", "du", "df", "free", 648 + "ps", "top", "htop", "pgrep", "sleep", 649 + // Man/help 650 + "man", "help", "info", 651 + // Pipeline utilities (note: xargs, tee handled specially - they can write/execute) 652 + "sort", "uniq", "cut", "awk", "sed", "tr", "column", "paste", "join", 653 + "comm", "diff", "cmp", "patch", 654 + // Shell test commands (read-only conditionals) 655 + "test", "[", "[[", "true", "false", 656 + ]); 657 + 658 + // Commands that can write files based on arguments 659 + // find: -exec, -execdir, -ok, -okdir, -delete can modify filesystem 660 + // xargs: executes commands with input as arguments (but safe if running read-only commands) 661 + // tee: writes to files (but read-only when used with /dev/null or --) 662 + 663 + /** 664 + * Extract the command that xargs will execute. 665 + * Parses xargs options to find the first non-option argument. 666 + * Returns null if no command specified (xargs defaults to /bin/echo). 667 + */ 668 + function extractXargsCommand(tokens: string[]): string | null { 669 + const args = tokens.slice(1); // Skip 'xargs' itself 670 + 671 + // xargs options that consume the next argument 672 + const OPTIONS_WITH_ARG = new Set(["-I", "-d", "-E", "-L", "-n", "-P", "-s", "-a"]); 673 + 674 + let i = 0; 675 + while (i < args.length) { 676 + const arg = args[i]; 677 + 678 + // End of options marker 679 + if (arg === "--") { 680 + i++; 681 + break; 682 + } 683 + 684 + // Not an option - this is the command 685 + if (!arg.startsWith("-")) { 686 + break; 687 + } 688 + 689 + // Long options (--null, --max-args=5, etc.) 690 + if (arg.startsWith("--")) { 691 + // Long options either are flags or use = for values, so just skip 692 + i++; 693 + continue; 694 + } 695 + 696 + // Short option that takes a required argument 697 + // Could be: -I {} (separate) or -I{} (attached) 698 + const optLetter = arg.substring(0, 2); // e.g., "-I" 699 + if (OPTIONS_WITH_ARG.has(optLetter)) { 700 + if (arg.length > 2) { 701 + // Argument attached: -I{} or -n10 702 + i++; 703 + } else { 704 + // Argument is next token: -I {} 705 + i += 2; 706 + } 707 + continue; 708 + } 709 + 710 + // -i and -e can have optional attached argument (deprecated forms) 711 + // -i[replstr], -e[eof-str] 712 + if (arg.startsWith("-i") || arg.startsWith("-e")) { 713 + i++; 714 + continue; 715 + } 716 + 717 + // Other short options are flags (can be combined): -0, -t, -p, -r, -x 718 + // e.g., -0tr means -0 -t -r 719 + i++; 720 + } 721 + 722 + // Return the command if found 723 + if (i < args.length) { 724 + const cmd = args[i]; 725 + // Strip path prefix (e.g., /usr/bin/cat -> cat) 726 + if (cmd.includes("/")) { 727 + return cmd.split("/").pop()?.toLowerCase() || null; 728 + } 729 + return cmd.toLowerCase(); 730 + } 731 + 732 + // No command found - xargs defaults to /bin/echo (safe) 733 + return null; 734 + } 735 + 736 + const CONDITIONAL_WRITE_COMMANDS: Record<string, (tokens: string[]) => boolean> = { 737 + find: (tokens) => { 738 + const dangerousFlags = ["-exec", "-execdir", "-ok", "-okdir", "-delete"]; 739 + return tokens.some(t => dangerousFlags.includes(t.toLowerCase())); 740 + }, 741 + xargs: (tokens) => { 742 + // xargs executes commands with input as arguments 743 + // Safe if running a read-only command from MINIMAL_COMMANDS 744 + const xargsCmd = extractXargsCommand(tokens); 745 + 746 + // No command = defaults to /bin/echo (safe, just prints) 747 + if (xargsCmd === null) return false; 748 + 749 + // Check if the command xargs will run is read-only 750 + if (MINIMAL_COMMANDS.has(xargsCmd)) return false; 751 + 752 + // Unknown or non-minimal command - not safe 753 + return true; 754 + }, 755 + tee: (tokens) => { 756 + // tee writes to files unless only used with /dev/null or -- 757 + const args = tokens.slice(1).filter(t => !t.startsWith("-")); 758 + if (args.length === 0) return false; // tee with no file args writes to stdout only 759 + // Check if all file args are /dev/null 760 + return !args.every(a => a === "/dev/null"); 761 + }, 762 + }; 763 + 764 + const MINIMAL_GIT_SUBCOMMANDS = new Set([ 765 + "status", "log", "diff", "show", "branch", "remote", "tag", 766 + "ls-files", "ls-tree", "cat-file", "rev-parse", "describe", 767 + "shortlog", "blame", "annotate", "whatchanged", "reflog", 768 + "fetch", // read-only: just downloads refs, doesn't change working tree 769 + ]); 770 + 771 + const MINIMAL_PACKAGE_SUBCOMMANDS: Record<string, Set<string>> = { 772 + npm: new Set(["list", "ls", "info", "view", "outdated", "audit", "explain", "why", "search"]), 773 + yarn: new Set(["list", "info", "why", "outdated", "audit"]), 774 + pnpm: new Set(["list", "ls", "outdated", "audit", "why"]), 775 + bun: new Set(["pm", "ls"]), 776 + pip: new Set(["list", "show", "freeze", "check"]), 777 + pip3: new Set(["list", "show", "freeze", "check"]), 778 + cargo: new Set(["tree", "metadata", "search", "info"]), 779 + go: new Set(["list", "version", "env"]), 780 + gem: new Set(["list", "info", "search", "query"]), 781 + composer: new Set(["show", "info", "search", "outdated", "audit"]), 782 + dotnet: new Set(["list", "nuget"]), 783 + flutter: new Set(["doctor", "devices", "config"]), 784 + dart: new Set(["info"]), 785 + }; 786 + 787 + function isMinimalLevel(tokens: string[]): boolean { 788 + if (tokens.length === 0) return true; 789 + 790 + const cmd = getCommandName(tokens); 791 + const fullCmd = tokens[0]; // Keep full path for checking redirection targets 792 + const subCmd = tokens.length > 1 ? tokens[1].toLowerCase() : ""; 793 + 794 + // Check if this is a file descriptor number from redirection parsing (e.g., "1" from 2>&1) 795 + if (tokens.length === 1 && FD_NUMBERS.has(fullCmd)) return true; 796 + 797 + // Check if this is a common redirection target (e.g., /dev/null) 798 + if (REDIRECTION_TARGETS.has(fullCmd)) return true; 799 + 800 + // Check conditional write commands (find with -exec, xargs, tee with files) 801 + const conditionalCheck = CONDITIONAL_WRITE_COMMANDS[cmd]; 802 + if (conditionalCheck) { 803 + // If the command would write/execute, it's not minimal level 804 + if (conditionalCheck(tokens)) { 805 + return false; 806 + } 807 + // Otherwise it's safe (e.g., find without -exec, tee to /dev/null) 808 + return true; 809 + } 810 + 811 + // Basic read-only commands 812 + if (MINIMAL_COMMANDS.has(cmd)) return true; 813 + 814 + // Version checks 815 + if (tokens.includes("--version") || tokens.includes("-v") || tokens.includes("-V")) { 816 + return true; 817 + } 818 + 819 + // Git read operations 820 + if (cmd === "git" && subCmd && MINIMAL_GIT_SUBCOMMANDS.has(subCmd)) { 821 + // Some git commands are only read-only without additional args 822 + // e.g., "git branch" lists branches (minimal), "git branch new" creates (medium) 823 + // e.g., "git tag" lists tags (minimal), "git tag v1.0" creates (medium) 824 + const READ_ONLY_WITHOUT_ARGS = new Set(["branch", "tag", "remote"]); 825 + if (READ_ONLY_WITHOUT_ARGS.has(subCmd)) { 826 + // Check if there are args beyond flags (starting with -) 827 + const nonFlagArgs = tokens.slice(2).filter(t => !t.startsWith("-")); 828 + if (nonFlagArgs.length > 0) { 829 + return false; // Has args, not read-only 830 + } 831 + } 832 + return true; 833 + } 834 + 835 + // Package manager read operations 836 + if (MINIMAL_PACKAGE_SUBCOMMANDS[cmd]?.has(subCmd)) { 837 + return true; 838 + } 839 + 840 + return false; 841 + } 842 + 843 + // MEDIUM level - build/install/test operations only (NOT running code) 844 + const MEDIUM_PACKAGE_PATTERNS: Array<[string, RegExp]> = [ 845 + // Node.js - install, build, test only (NOT run/start/exec which execute arbitrary code) 846 + ["npm", /^(install|ci|add|remove|uninstall|update|rebuild|dedupe|prune|link|pack|test|build)$/], 847 + ["yarn", /^(install|add|remove|upgrade|import|link|pack|test|build)$/], 848 + ["pnpm", /^(install|add|remove|update|link|pack|test|build)$/], 849 + ["bun", /^(install|add|remove|update|link|test|build)$/], 850 + // npx/bunx/pnpx run arbitrary packages - HIGH (not included here) 851 + 852 + // Python - install/build only (NOT running scripts) 853 + ["pip", /^install$/], 854 + ["pip3", /^install$/], 855 + ["pipenv", /^(install|update|sync|lock|uninstall)$/], 856 + ["poetry", /^(install|add|remove|update|lock|build)$/], 857 + ["conda", /^(install|update|remove|create)$/], 858 + ["uv", /^(pip|sync|lock)$/], 859 + // python/python3 run arbitrary code - HIGH (not included here) 860 + ["pytest", /./], // test runner is safe 861 + 862 + // Rust - build/test/lint only (NOT cargo run) 863 + ["cargo", /^(install|add|remove|fetch|update|build|test|check|clippy|fmt|doc|bench|clean)$/], 864 + ["rustfmt", /./], 865 + // rustc compiles but doesn't run - medium 866 + ["rustc", /./], 867 + 868 + // Go - build/test only (NOT go run) 869 + ["go", /^(get|mod|build|test|generate|fmt|vet|clean|install)$/], 870 + 871 + // Ruby - install/build only 872 + ["gem", /^install$/], 873 + ["bundle", /^(install|update|add|remove|binstubs)$/], 874 + ["bundler", /^(install|update|add|remove)$/], 875 + // CocoaPods - dependency management only 876 + ["pod", /^(install|update|repo)$/], 877 + // rake/rails can run arbitrary code - HIGH (not included here) 878 + ["rspec", /./], // test runner 879 + 880 + // PHP - install only 881 + ["composer", /^(install|require|remove|update|dump-autoload)$/], 882 + // php runs code - HIGH (not included here) 883 + ["phpunit", /./], // test runner 884 + 885 + // Java/Kotlin - compile/test only (NOT run) 886 + ["mvn", /^(install|compile|test|package|clean|dependency|verify)$/], 887 + ["gradle", /^(build|test|clean|assemble|dependencies|check)$/], 888 + // gradlew can run arbitrary tasks - HIGH (not included here) 889 + 890 + // .NET - build/test only (NOT run/watch) 891 + ["dotnet", /^(restore|add|build|test|clean|publish|pack|new)$/], 892 + ["nuget", /^install$/], 893 + 894 + // Dart/Flutter - build/test only (NOT run) 895 + ["dart", /^(pub|compile|test|analyze|format|fix)$/], 896 + ["flutter", /^(pub|build|test|analyze|clean|create|doctor)$/], 897 + ["pub", /^(get|upgrade|downgrade|cache|deps)$/], 898 + 899 + // Swift - build/test only (NOT run) 900 + ["swift", /^(package|build|test)$/], 901 + ["swiftc", /./], 902 + 903 + // Elixir - build/test only (NOT run) 904 + ["mix", /^(deps|compile|test|ecto|phx\.gen)$/], 905 + // elixir runs code - HIGH (not included here) 906 + 907 + // Haskell - build/test only (NOT run) 908 + ["cabal", /^(install|build|test|update)$/], 909 + ["stack", /^(install|build|test|setup)$/], 910 + // ghc compiles but doesn't run - medium 911 + ["ghc", /./], 912 + 913 + // Others 914 + ["nimble", /^install$/], 915 + ["zig", /^(build|test|fetch)$/], 916 + ["cmake", /./], 917 + ["make", /./], 918 + ["ninja", /./], 919 + ["meson", /./], 920 + 921 + // Linters/formatters - static analysis only (MEDIUM) 922 + ["eslint", /./], 923 + ["prettier", /./], 924 + ["black", /./], 925 + ["flake8", /./], 926 + ["pylint", /./], 927 + ["ruff", /./], 928 + ["pyflakes", /./], 929 + ["bandit", /./], 930 + ["mypy", /./], 931 + ["pyright", /./], 932 + ["tsc", /./], 933 + ["tslint", /./], 934 + ["standard", /./], 935 + ["xo", /./], 936 + ["rubocop", /./], 937 + ["standardrb", /./], 938 + ["reek", /./], 939 + ["brakeman", /./], 940 + ["golangci-lint", /./], 941 + ["gofmt", /./], 942 + ["go vet", /./], 943 + ["golint", /./], 944 + ["staticcheck", /./], 945 + ["errcheck", /./], 946 + ["misspell", /./], 947 + ["swiftlint", /./], 948 + ["swiftformat", /./], 949 + ["ktlint", /./], 950 + ["detekt", /./], 951 + ["dartanalyzer", /./], // dart analyze alternative name 952 + ["dartfmt", /./], 953 + ["clang-tidy", /./], 954 + ["clang-format", /./], 955 + ["cppcheck", /./], 956 + ["checkstyle", /./], 957 + ["pmd", /./], 958 + ["spotbugs", /./], 959 + ["sonarqube", /./], 960 + ["phpcs", /./], 961 + ["phpmd", /./], 962 + ["phpstan", /./], 963 + ["psalm", /./], 964 + ["php-cs-fixer", /./], 965 + ["luacheck", /./], 966 + ["shellcheck", /./], 967 + ["checkov", /./], 968 + ["tflint", /./], 969 + ["buf", /./], // protobuf linter 970 + ["sqlfluff", /./], 971 + ["yamllint", /./], 972 + ["markdownlint", /./], 973 + ["djlint", /./], 974 + ["djhtml", /./], 975 + ["commitlint", /./], 976 + 977 + // Test runners 978 + ["jest", /./], 979 + ["mocha", /./], 980 + ["vitest", /./], 981 + 982 + // File ops 983 + ["mkdir", /./], 984 + ["touch", /./], 985 + ["cp", /./], 986 + ["mv", /./], 987 + ["ln", /./], 988 + 989 + // Database (local dev) 990 + ["prisma", /^(generate|migrate|db|studio)$/], 991 + ["sequelize", /^(db|migration)$/], 992 + ["typeorm", /^(migration)$/], 993 + ]; 994 + 995 + const MEDIUM_GIT_SUBCOMMANDS = new Set([ 996 + "add", "commit", "pull", "checkout", "switch", "branch", 997 + "merge", "rebase", "cherry-pick", "stash", "revert", "tag", 998 + "rm", "mv", "reset", "clone", // reset without --hard, clone is reversible 999 + // NOT included (irreversible): 1000 + // - clean: permanently deletes untracked files 1001 + // - restore: can discard uncommitted changes permanently 1002 + ]); 1003 + 1004 + // Safe npm/yarn/pnpm/bun run scripts (build, test, lint - not dev, start, serve) 1005 + const SAFE_RUN_SCRIPTS = new Set([ 1006 + "build", "compile", "test", "lint", "format", "fmt", "check", "typecheck", 1007 + "type-check", "types", "validate", "verify", "prepare", "prepublish", 1008 + "prepublishOnly", "prepack", "postpack", "clean", "lint:fix", "format:check", 1009 + "build:prod", "build:dev", "build:production", "build:development", 1010 + "test:unit", "test:integration", "test:e2e", "test:coverage", 1011 + ]); 1012 + 1013 + // Scripts that run servers or arbitrary code 1014 + const UNSAFE_RUN_SCRIPTS = new Set([ 1015 + "start", "dev", "develop", "serve", "server", "watch", "preview", 1016 + "start:dev", "start:prod", "dev:server", 1017 + ]); 1018 + 1019 + function isSafeRunScript(script: string): boolean { 1020 + const s = script.toLowerCase(); 1021 + // Check explicit safe list 1022 + if (SAFE_RUN_SCRIPTS.has(s)) return true; 1023 + // Check if starts with safe prefix 1024 + if (s.startsWith("build") || s.startsWith("test") || s.startsWith("lint") || 1025 + s.startsWith("format") || s.startsWith("check") || s.startsWith("type")) { 1026 + return true; 1027 + } 1028 + // Check explicit unsafe list 1029 + if (UNSAFE_RUN_SCRIPTS.has(s)) return false; 1030 + // Check unsafe prefixes 1031 + if (s.startsWith("start") || s.startsWith("dev") || s.startsWith("serve") || 1032 + s.startsWith("watch")) { 1033 + return false; 1034 + } 1035 + // Default: unknown scripts are unsafe 1036 + return false; 1037 + } 1038 + 1039 + function isMediumLevel(tokens: string[]): boolean { 1040 + if (tokens.length === 0) return false; 1041 + 1042 + const cmd = getCommandName(tokens); 1043 + const subCmd = tokens.length > 1 ? tokens[1].toLowerCase() : ""; 1044 + const thirdArg = tokens.length > 2 ? tokens[2] : ""; 1045 + 1046 + // Git local operations (not push) 1047 + if (cmd === "git") { 1048 + if (subCmd === "push") return false; // push is HIGH 1049 + if (subCmd === "reset" && tokens.includes("--hard")) return false; // hard reset is HIGH 1050 + if (MEDIUM_GIT_SUBCOMMANDS.has(subCmd)) return true; 1051 + } 1052 + 1053 + // Handle npm/yarn/pnpm/bun run <script> specially 1054 + if (["npm", "yarn", "pnpm", "bun"].includes(cmd) && subCmd === "run") { 1055 + // Need a script name 1056 + if (!thirdArg || thirdArg.startsWith("-")) return false; 1057 + return isSafeRunScript(thirdArg); 1058 + } 1059 + 1060 + // Package managers and build tools 1061 + for (const [pattern, subPattern] of MEDIUM_PACKAGE_PATTERNS) { 1062 + if (cmd === pattern) { 1063 + if (!subCmd || subPattern.test(subCmd)) { 1064 + return true; 1065 + } 1066 + } 1067 + } 1068 + 1069 + return false; 1070 + } 1071 + 1072 + // HIGH level - git push, remote operations 1073 + function isHighLevel(tokens: string[]): boolean { 1074 + if (tokens.length === 0) return false; 1075 + 1076 + const cmd = getCommandName(tokens); 1077 + const subCmd = tokens.length > 1 ? tokens[1].toLowerCase() : ""; 1078 + const argsStr = tokens.slice(1).join(" "); 1079 + 1080 + // Git push 1081 + if (cmd === "git" && subCmd === "push") return true; 1082 + 1083 + // Git reset --hard 1084 + if (cmd === "git" && subCmd === "reset" && tokens.includes("--hard")) return true; 1085 + 1086 + // curl/wget piped to shell (detected at pipeline level) 1087 + if (cmd === "curl" || cmd === "wget") return true; 1088 + 1089 + // Running remote scripts 1090 + if (cmd === "bash" || cmd === "sh" || cmd === "zsh") { 1091 + if (argsStr.includes("http://") || argsStr.includes("https://")) return true; 1092 + } 1093 + 1094 + // Docker operations 1095 + if (cmd === "docker" && ["push", "login", "logout"].includes(subCmd)) return true; 1096 + 1097 + // Deployment tools 1098 + if (["kubectl", "helm", "terraform", "pulumi", "ansible"].includes(cmd)) return true; 1099 + 1100 + // SSH/SCP 1101 + if (["ssh", "scp", "rsync"].includes(cmd)) return true; 1102 + 1103 + return false; 1104 + } 1105 + 1106 + // ============================================================================ 1107 + // CLASSIFY COMMAND 1108 + // ============================================================================ 1109 + 1110 + function classifySegment(tokens: string[]): Classification { 1111 + if (tokens.length === 0) { 1112 + return { level: "minimal", dangerous: false }; 1113 + } 1114 + 1115 + const cmd = getCommandName(tokens); 1116 + 1117 + // Shell execution commands that can run arbitrary code - always HIGH 1118 + // These bypass normal command classification since they execute their arguments 1119 + if (SHELL_EXECUTION_COMMANDS.has(cmd)) { 1120 + return { level: "high", dangerous: false }; 1121 + } 1122 + 1123 + if (isDangerousCommand(tokens)) { 1124 + return { level: "high", dangerous: true }; 1125 + } 1126 + 1127 + if (isMinimalLevel(tokens)) { 1128 + return { level: "minimal", dangerous: false }; 1129 + } 1130 + 1131 + if (isMediumLevel(tokens)) { 1132 + return { level: "medium", dangerous: false }; 1133 + } 1134 + 1135 + if (isHighLevel(tokens)) { 1136 + return { level: "high", dangerous: false }; 1137 + } 1138 + 1139 + // Default: require HIGH for unknown commands 1140 + return { level: "high", dangerous: false }; 1141 + } 1142 + 1143 + export function classifyCommand(command: string, config?: PermissionConfig): Classification { 1144 + // Load config if not provided (for testing) 1145 + const effectiveConfig = config ?? getCachedConfig(); 1146 + 1147 + // Step 1: Apply prefix normalization 1148 + const normalizedCommand = applyPrefixMappings(command, effectiveConfig.prefixMappings); 1149 + 1150 + const parsed = parseCommand(normalizedCommand); 1151 + 1152 + // If command contains shell tricks (command substitution, backticks, etc.), 1153 + // require HIGH level as we cannot reliably classify the embedded commands 1154 + if (parsed.hasShellTricks) { 1155 + return { level: "high", dangerous: false }; 1156 + } 1157 + 1158 + // Step 2: Check for override on NORMALIZED command (consistent with classification) 1159 + const override = checkOverrides(normalizedCommand, effectiveConfig.overrides); 1160 + if (override) { 1161 + return override; 1162 + } 1163 + 1164 + let maxLevel: PermissionLevel = "minimal"; 1165 + let dangerous = false; 1166 + 1167 + // If command writes to files via redirection (>, >>), require at least LOW 1168 + if (parsed.writesFiles) { 1169 + maxLevel = "low"; 1170 + } 1171 + 1172 + for (let i = 0; i < parsed.segments.length; i++) { 1173 + const segment = parsed.segments[i]; 1174 + const segmentClass = classifySegment(segment); 1175 + 1176 + if (segmentClass.dangerous) { 1177 + dangerous = true; 1178 + } 1179 + 1180 + if (LEVEL_INDEX[segmentClass.level] > LEVEL_INDEX[maxLevel]) { 1181 + maxLevel = segmentClass.level; 1182 + } 1183 + 1184 + // Check for piping to shell 1185 + if (i < parsed.segments.length - 1 && parsed.operators[i] === "|") { 1186 + const nextCmd = getCommandName(parsed.segments[i + 1]); 1187 + if (["bash", "sh", "zsh", "node", "python", "python3", "ruby", "perl"].includes(nextCmd)) { 1188 + maxLevel = "high"; 1189 + } 1190 + } 1191 + } 1192 + 1193 + return { level: maxLevel, dangerous }; 1194 + }
+609
modules/_config/pi/extensions/permission-pi/permission.ts
··· 1 + /** 2 + * Permission Extension for pi-coding-agent 3 + * 4 + * Implements layered permission control. 5 + * 6 + * Interactive mode: 7 + * Use `/permission` command to view or change the level. 8 + * Use `/permission-mode` to switch between ask vs block. 9 + * When changing via command, you'll be asked: session-only or global? 10 + * 11 + * Print mode (pi -p): 12 + * Set PI_PERMISSION_LEVEL env var: PI_PERMISSION_LEVEL=medium pi -p "task" 13 + * Operations beyond level will exit with helpful error message. 14 + * Use PI_PERMISSION_LEVEL=bypassed for CI/containers (dangerous!) 15 + * 16 + * Levels: 17 + * minimal - Read-only mode (default) 18 + * ✅ Read files, ls, grep, git status/log/diff 19 + * ❌ No file modifications, no commands with side effects 20 + * 21 + * low - File operations only 22 + * ✅ Create/edit files in project directory 23 + * ❌ No package installs, no git commits, no builds 24 + * 25 + * medium - Development operations 26 + * ✅ npm/pip install, git commit/pull, make/build 27 + * ❌ No git push, no sudo, no production changes 28 + * 29 + * high - Full operations 30 + * ✅ git push, deployments, scripts 31 + * ⚠️ Still prompts for destructive commands (rm -rf, etc.) 32 + * 33 + * Usage: 34 + * pi --extension ./permission-hook.ts 35 + * 36 + * Or add to ~/.pi/agent/extensions/ or .pi/extensions/ for automatic loading. 37 + */ 38 + 39 + import { exec } from "node:child_process"; 40 + import fs from "node:fs"; 41 + import os from "node:os"; 42 + import path from "node:path"; 43 + import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; 44 + import { 45 + type PermissionLevel, 46 + type PermissionMode, 47 + LEVELS, 48 + LEVEL_INDEX, 49 + LEVEL_INFO, 50 + LEVEL_ALLOWED_DESC, 51 + PERMISSION_MODES, 52 + PERMISSION_MODE_INFO, 53 + loadGlobalPermission, 54 + saveGlobalPermission, 55 + loadGlobalPermissionMode, 56 + saveGlobalPermissionMode, 57 + classifyCommand, 58 + loadPermissionConfig, 59 + savePermissionConfig, 60 + invalidateConfigCache, 61 + type PermissionConfig, 62 + } from "./permission-core.js"; 63 + 64 + // Re-export types and constants needed by the hook 65 + export { 66 + type PermissionLevel, 67 + type PermissionMode, 68 + LEVELS, 69 + LEVEL_INFO, 70 + PERMISSION_MODES, 71 + PERMISSION_MODE_INFO, 72 + }; 73 + 74 + // ============================================================================ 75 + // SOUND NOTIFICATION 76 + // ============================================================================ 77 + 78 + function playPermissionSound(): void { 79 + const isMac = process.platform === "darwin"; 80 + 81 + if (isMac) { 82 + exec('afplay /System/Library/Sounds/Funk.aiff 2>/dev/null', (err) => { 83 + if (err) process.stdout.write("\x07"); 84 + }); 85 + } else { 86 + process.stdout.write("\x07"); 87 + } 88 + } 89 + 90 + // ============================================================================ 91 + // STATUS TEXT 92 + // ============================================================================ 93 + 94 + const BOLD = "\x1b[1m"; 95 + const RESET = "\x1b[0m"; 96 + const RED = "\x1b[31m"; 97 + const YELLOW = "\x1b[33m"; 98 + const GREEN = "\x1b[32m"; 99 + const CYAN = "\x1b[36m"; 100 + const DIM = "\x1b[2m"; 101 + 102 + const LEVEL_COLORS: Record<PermissionLevel, string> = { 103 + minimal: RED, 104 + low: YELLOW, 105 + medium: CYAN, 106 + high: GREEN, 107 + bypassed: DIM, 108 + }; 109 + 110 + function getStatusText(level: PermissionLevel): string { 111 + const info = LEVEL_INFO[level]; 112 + const color = LEVEL_COLORS[level]; 113 + return `${BOLD}${color}${info.label}${RESET} ${DIM}- ${info.desc}${RESET}`; 114 + } 115 + 116 + // ============================================================================ 117 + // MODE DETECTION 118 + // ============================================================================ 119 + 120 + function getPiModeFromArgv(argv: string[] = process.argv): string | undefined { 121 + // Support both: --mode rpc and --mode=rpc 122 + const eq = argv.find((a) => a.startsWith("--mode=")); 123 + if (eq) return eq.slice("--mode=".length); 124 + 125 + const idx = argv.indexOf("--mode"); 126 + if (idx !== -1 && idx + 1 < argv.length) return argv[idx + 1]; 127 + 128 + return undefined; 129 + } 130 + 131 + function hasInteractiveUI(ctx: any): boolean { 132 + if (!ctx?.hasUI) return false; 133 + 134 + // In non-interactive modes (rpc/json/print), UI prompts are not desired. 135 + // We still allow notifications, but block instead of asking. 136 + const mode = getPiModeFromArgv()?.toLowerCase(); 137 + if (mode && mode !== "interactive") return false; 138 + 139 + return true; 140 + } 141 + 142 + function isQuietMode(ctx: any): boolean { 143 + if (ctx?.quiet || ctx?.isQuiet) return true; 144 + if (ctx?.ui?.quiet || ctx?.ui?.isQuiet) return true; 145 + if (ctx?.settings?.quietStartup || ctx?.settings?.quiet) return true; 146 + 147 + const envQuiet = process.env.PI_QUIET?.toLowerCase(); 148 + if (envQuiet && ["1", "true", "yes"].includes(envQuiet)) return true; 149 + 150 + if (process.argv.includes("--quiet") || process.argv.includes("-q")) return true; 151 + 152 + return isQuietStartupFromSettings(); 153 + } 154 + 155 + function isQuietStartupFromSettings(): boolean { 156 + const settingsPath = path.join(os.homedir(), ".pi", "agent", "settings.json"); 157 + try { 158 + const raw = fs.readFileSync(settingsPath, "utf-8"); 159 + const settings = JSON.parse(raw) as { quietStartup?: boolean }; 160 + return settings.quietStartup === true; 161 + } catch { 162 + return false; 163 + } 164 + } 165 + 166 + // ============================================================================ 167 + // STATE MANAGEMENT 168 + // ============================================================================ 169 + 170 + export interface PermissionState { 171 + currentLevel: PermissionLevel; 172 + isSessionOnly: boolean; 173 + permissionMode: PermissionMode; 174 + isModeSessionOnly: boolean; 175 + } 176 + 177 + export function createInitialState(): PermissionState { 178 + return { 179 + currentLevel: "minimal", 180 + isSessionOnly: false, 181 + permissionMode: "ask", 182 + isModeSessionOnly: false, 183 + }; 184 + } 185 + 186 + function setLevel( 187 + state: PermissionState, 188 + level: PermissionLevel, 189 + saveGlobally: boolean, 190 + ctx: any 191 + ): void { 192 + state.currentLevel = level; 193 + state.isSessionOnly = !saveGlobally; 194 + if (saveGlobally) { 195 + saveGlobalPermission(level); 196 + } 197 + if (ctx.ui?.setStatus) { 198 + ctx.ui.setStatus("authority", getStatusText(level)); 199 + } 200 + } 201 + 202 + function setMode( 203 + state: PermissionState, 204 + mode: PermissionMode, 205 + saveGlobally: boolean, 206 + ctx: any 207 + ): void { 208 + state.permissionMode = mode; 209 + state.isModeSessionOnly = !saveGlobally; 210 + if (saveGlobally) { 211 + saveGlobalPermissionMode(mode); 212 + } 213 + } 214 + 215 + // ============================================================================ 216 + // HANDLERS 217 + // ============================================================================ 218 + 219 + /** Handle /permission config subcommand */ 220 + async function handleConfigSubcommand( 221 + state: PermissionState, 222 + args: string, 223 + ctx: any 224 + ): Promise<void> { 225 + const parts = args.trim().split(/\s+/); 226 + const action = parts[0]; 227 + 228 + if (action === "show") { 229 + const config = loadPermissionConfig(); 230 + const configStr = JSON.stringify(config, null, 2); 231 + ctx.ui.notify(`Permission Config:\n${configStr}`, "info"); 232 + return; 233 + } 234 + 235 + if (action === "reset") { 236 + savePermissionConfig({}); 237 + invalidateConfigCache(); 238 + ctx.ui.notify("Permission config reset to defaults", "info"); 239 + return; 240 + } 241 + 242 + // Show help 243 + const help = `Usage: /permission config <action> 244 + 245 + Actions: 246 + show - Display current configuration 247 + reset - Reset to default configuration 248 + 249 + Edit ~/.pi/agent/settings.json directly for full control: 250 + 251 + { 252 + "permissionConfig": { 253 + "overrides": { 254 + "minimal": ["tmux list-*", "tmux show-*"], 255 + "medium": ["tmux *", "screen *"], 256 + "high": ["rm -rf *"], 257 + "dangerous": ["dd if=* of=/dev/*"] 258 + }, 259 + "prefixMappings": [ 260 + { "from": "fvm flutter", "to": "flutter" }, 261 + { "from": "nvm exec", "to": "" } 262 + ] 263 + } 264 + }`; 265 + 266 + ctx.ui.notify(help, "info"); 267 + } 268 + 269 + /** Handle /permission command */ 270 + export async function handlePermissionCommand( 271 + state: PermissionState, 272 + args: string, 273 + ctx: any 274 + ): Promise<void> { 275 + const arg = args.trim().toLowerCase(); 276 + 277 + // Handle config subcommand 278 + if (arg === "config" || arg.startsWith("config ")) { 279 + const configArgs = arg.replace(/^config\s*/, ''); 280 + await handleConfigSubcommand(state, configArgs, ctx); 281 + return; 282 + } 283 + 284 + // Direct level set: /permission medium 285 + if (arg && LEVELS.includes(arg as PermissionLevel)) { 286 + const newLevel = arg as PermissionLevel; 287 + 288 + if (hasInteractiveUI(ctx)) { 289 + const scope = await ctx.ui.select("Save permission level to:", [ 290 + "Session only", 291 + "Global (persists)", 292 + ]); 293 + if (!scope) return; 294 + 295 + setLevel(state, newLevel, scope === "Global (persists)", ctx); 296 + const saveMsg = scope === "Global (persists)" ? " (saved globally)" : " (session only)"; 297 + ctx.ui.notify(`Permission: ${LEVEL_INFO[newLevel].label}${saveMsg}`, "info"); 298 + } else { 299 + setLevel(state, newLevel, false, ctx); 300 + ctx.ui.notify(`Permission: ${LEVEL_INFO[newLevel].label}`, "info"); 301 + } 302 + return; 303 + } 304 + 305 + // Show current level (no UI) 306 + if (!hasInteractiveUI(ctx)) { 307 + ctx.ui.notify( 308 + `Current permission: ${LEVEL_INFO[state.currentLevel].label} (${LEVEL_INFO[state.currentLevel].desc})`, 309 + "info" 310 + ); 311 + return; 312 + } 313 + 314 + // Show selector 315 + const options = LEVELS.map((level) => { 316 + const info = LEVEL_INFO[level]; 317 + const marker = level === state.currentLevel ? " ← current" : ""; 318 + return `${info.label}: ${info.desc}${marker}`; 319 + }); 320 + 321 + const choice = await ctx.ui.select("Select permission level", options); 322 + if (!choice) return; 323 + 324 + const selectedLabel = choice.split(":")[0].trim(); 325 + const newLevel = LEVELS.find((l) => LEVEL_INFO[l].label === selectedLabel); 326 + if (!newLevel || newLevel === state.currentLevel) return; 327 + 328 + const scope = await ctx.ui.select("Save to:", ["Session only", "Global (persists)"]); 329 + if (!scope) return; 330 + 331 + setLevel(state, newLevel, scope === "Global (persists)", ctx); 332 + const saveMsg = scope === "Global (persists)" ? " (saved globally)" : " (session only)"; 333 + ctx.ui.notify(`Permission: ${LEVEL_INFO[newLevel].label}${saveMsg}`, "info"); 334 + } 335 + 336 + /** Handle /permission-mode command */ 337 + export async function handlePermissionModeCommand( 338 + state: PermissionState, 339 + args: string, 340 + ctx: any 341 + ): Promise<void> { 342 + const arg = args.trim().toLowerCase(); 343 + 344 + if (arg && PERMISSION_MODES.includes(arg as PermissionMode)) { 345 + const newMode = arg as PermissionMode; 346 + 347 + if (hasInteractiveUI(ctx)) { 348 + const scope = await ctx.ui.select("Save permission mode to:", [ 349 + "Session only", 350 + "Global (persists)", 351 + ]); 352 + if (!scope) return; 353 + 354 + setMode(state, newMode, scope === "Global (persists)", ctx); 355 + const saveMsg = scope === "Global (persists)" ? " (saved globally)" : " (session only)"; 356 + ctx.ui.notify(`Permission mode: ${PERMISSION_MODE_INFO[newMode].label}${saveMsg}`, "info"); 357 + } else { 358 + setMode(state, newMode, false, ctx); 359 + ctx.ui.notify(`Permission mode: ${PERMISSION_MODE_INFO[newMode].label}`, "info"); 360 + } 361 + return; 362 + } 363 + 364 + if (!hasInteractiveUI(ctx)) { 365 + ctx.ui.notify( 366 + `Current permission mode: ${PERMISSION_MODE_INFO[state.permissionMode].label} (${PERMISSION_MODE_INFO[state.permissionMode].desc})`, 367 + "info" 368 + ); 369 + return; 370 + } 371 + 372 + const options = PERMISSION_MODES.map((mode) => { 373 + const info = PERMISSION_MODE_INFO[mode]; 374 + const marker = mode === state.permissionMode ? " ← current" : ""; 375 + return `${info.label}: ${info.desc}${marker}`; 376 + }); 377 + 378 + const choice = await ctx.ui.select("Select permission mode", options); 379 + if (!choice) return; 380 + 381 + const selectedLabel = choice.split(":")[0].trim(); 382 + const newMode = PERMISSION_MODES.find((m) => PERMISSION_MODE_INFO[m].label === selectedLabel); 383 + if (!newMode || newMode === state.permissionMode) return; 384 + 385 + const scope = await ctx.ui.select("Save to:", ["Session only", "Global (persists)"]); 386 + if (!scope) return; 387 + 388 + setMode(state, newMode, scope === "Global (persists)", ctx); 389 + const saveMsg = scope === "Global (persists)" ? " (saved globally)" : " (session only)"; 390 + ctx.ui.notify(`Permission mode: ${PERMISSION_MODE_INFO[newMode].label}${saveMsg}`, "info"); 391 + } 392 + 393 + /** Handle session_start - initialize level and show status */ 394 + export function handleSessionStart(state: PermissionState, ctx: any): void { 395 + // Check env var first (for print mode) 396 + const envLevel = process.env.PI_PERMISSION_LEVEL?.toLowerCase(); 397 + if (envLevel && LEVELS.includes(envLevel as PermissionLevel)) { 398 + state.currentLevel = envLevel as PermissionLevel; 399 + } else { 400 + const globalLevel = loadGlobalPermission(); 401 + if (globalLevel) { 402 + state.currentLevel = globalLevel; 403 + } 404 + } 405 + 406 + if (ctx.hasUI) { 407 + const globalMode = loadGlobalPermissionMode(); 408 + if (globalMode) { 409 + state.permissionMode = globalMode; 410 + } 411 + } 412 + 413 + if (ctx.hasUI) { 414 + if (ctx.ui?.setStatus) { 415 + ctx.ui.setStatus("authority", getStatusText(state.currentLevel)); 416 + } 417 + if (state.currentLevel === "bypassed") { 418 + ctx.ui.notify("⚠️ Permission bypassed - all checks disabled!", "warning"); 419 + } else if (!isQuietMode(ctx)) { 420 + ctx.ui.notify(`Permission: ${LEVEL_INFO[state.currentLevel].label} (use /permission to change)`, "info"); 421 + } 422 + if (state.permissionMode === "block") { 423 + ctx.ui.notify("Permission mode: Block (use /permission-mode to change)", "info"); 424 + } 425 + } 426 + } 427 + 428 + /** Handle bash tool_call - check permission and prompt if needed */ 429 + export async function handleBashToolCall( 430 + state: PermissionState, 431 + command: string, 432 + ctx: any 433 + ): Promise<{ block: true; reason: string } | undefined> { 434 + if (state.currentLevel === "bypassed") return undefined; 435 + 436 + const classification = classifyCommand(command); 437 + 438 + // Dangerous commands - always prompt unless in block mode 439 + if (classification.dangerous) { 440 + if (!hasInteractiveUI(ctx)) { 441 + return { 442 + block: true, 443 + reason: `Dangerous command requires confirmation: ${command} 444 + User can re-run with: PI_PERMISSION_LEVEL=bypassed pi -p "..."` 445 + }; 446 + } 447 + 448 + if (state.permissionMode === "block") { 449 + return { 450 + block: true, 451 + reason: `Blocked by permission mode (block). Dangerous command: ${command} 452 + Use /permission-mode ask to enable confirmations.` 453 + }; 454 + } 455 + 456 + playPermissionSound(); 457 + const choice = await ctx.ui.select( 458 + `⚠️ Dangerous command`, 459 + ["Allow once", "Cancel"] 460 + ); 461 + 462 + if (choice !== "Allow once") { 463 + return { block: true, reason: "Cancelled" }; 464 + } 465 + return undefined; 466 + } 467 + 468 + // Check level 469 + const requiredIndex = LEVEL_INDEX[classification.level]; 470 + const currentIndex = LEVEL_INDEX[state.currentLevel]; 471 + 472 + if (requiredIndex <= currentIndex) return undefined; 473 + 474 + const requiredLevel = classification.level; 475 + const requiredInfo = LEVEL_INFO[requiredLevel]; 476 + 477 + // Print mode: block 478 + if (!hasInteractiveUI(ctx)) { 479 + return { 480 + block: true, 481 + reason: `Blocked by permission (${state.currentLevel}). Command: ${command} 482 + Allowed at this level: ${LEVEL_ALLOWED_DESC[state.currentLevel]} 483 + User can re-run with: PI_PERMISSION_LEVEL=${requiredLevel} pi -p "..."` 484 + }; 485 + } 486 + 487 + if (state.permissionMode === "block") { 488 + return { 489 + block: true, 490 + reason: `Blocked by permission (${state.currentLevel}, mode: block). Command: ${command} 491 + Requires ${requiredInfo.label}. Allowed at this level: ${LEVEL_ALLOWED_DESC[state.currentLevel]} 492 + Use /permission ${requiredLevel} or /permission-mode ask to enable prompts.` 493 + }; 494 + } 495 + 496 + // Interactive mode: prompt 497 + playPermissionSound(); 498 + const choice = await ctx.ui.select( 499 + `Requires ${requiredInfo.label}`, 500 + ["Allow once", `Allow all (${requiredInfo.label})`, "Cancel"] 501 + ); 502 + 503 + if (choice === "Allow once") return undefined; 504 + 505 + if (choice === `Allow all (${requiredInfo.label})`) { 506 + setLevel(state, requiredLevel, true, ctx); 507 + ctx.ui.notify(`Permission → ${requiredInfo.label} (saved globally)`, "info"); 508 + return undefined; 509 + } 510 + 511 + return { block: true, reason: "Cancelled" }; 512 + } 513 + 514 + /** Options for handleWriteToolCall */ 515 + export interface WriteToolCallOptions { 516 + state: PermissionState; 517 + toolName: string; 518 + filePath: string; 519 + ctx: any; 520 + } 521 + 522 + /** Handle write/edit tool_call - check permission and prompt if needed */ 523 + export async function handleWriteToolCall( 524 + opts: WriteToolCallOptions 525 + ): Promise<{ block: true; reason: string } | undefined> { 526 + const { state, toolName, filePath, ctx } = opts; 527 + 528 + if (state.currentLevel === "bypassed") return undefined; 529 + 530 + if (LEVEL_INDEX[state.currentLevel] >= LEVEL_INDEX["low"]) return undefined; 531 + 532 + const action = toolName === "write" ? "Write" : "Edit"; 533 + const message = `Requires Low: ${action} ${filePath}`; 534 + 535 + // Print mode: block 536 + if (!hasInteractiveUI(ctx)) { 537 + return { 538 + block: true, 539 + reason: `Blocked by permission (${state.currentLevel}). ${action}: ${filePath} 540 + Allowed at this level: ${LEVEL_ALLOWED_DESC[state.currentLevel]} 541 + User can re-run with: PI_PERMISSION_LEVEL=low pi -p "..."` 542 + }; 543 + } 544 + 545 + if (state.permissionMode === "block") { 546 + return { 547 + block: true, 548 + reason: `Blocked by permission (${state.currentLevel}, mode: block). ${action}: ${filePath} 549 + Requires Low. Allowed at this level: ${LEVEL_ALLOWED_DESC[state.currentLevel]} 550 + Use /permission low or /permission-mode ask to enable prompts.` 551 + }; 552 + } 553 + 554 + // Interactive mode: prompt 555 + playPermissionSound(); 556 + const choice = await ctx.ui.select( 557 + message, 558 + ["Allow once", "Allow all (Low)", "Cancel"] 559 + ); 560 + 561 + if (choice === "Allow once") return undefined; 562 + 563 + if (choice === "Allow all (Low)") { 564 + setLevel(state, "low", true, ctx); 565 + ctx.ui.notify(`Permission → Low (saved globally)`, "info"); 566 + return undefined; 567 + } 568 + 569 + return { block: true, reason: "Cancelled" }; 570 + } 571 + 572 + // ============================================================================ 573 + // Extension entry point 574 + // ============================================================================ 575 + 576 + export default function (pi: ExtensionAPI) { 577 + const state = createInitialState(); 578 + 579 + pi.registerCommand("permission", { 580 + description: "View or change permission level", 581 + handler: (args, ctx) => handlePermissionCommand(state, args, ctx), 582 + }); 583 + 584 + pi.registerCommand("permission-mode", { 585 + description: "Set permission prompt mode (ask or block)", 586 + handler: (args, ctx) => handlePermissionModeCommand(state, args, ctx), 587 + }); 588 + 589 + pi.on("session_start", async (_event, ctx) => { 590 + handleSessionStart(state, ctx); 591 + }); 592 + 593 + pi.on("tool_call", async (event, ctx) => { 594 + if (event.toolName === "bash") { 595 + return handleBashToolCall(state, event.input.command as string, ctx); 596 + } 597 + 598 + if (event.toolName === "write" || event.toolName === "edit") { 599 + return handleWriteToolCall({ 600 + state, 601 + toolName: event.toolName, 602 + filePath: event.input.path as string, 603 + ctx, 604 + }); 605 + } 606 + 607 + return undefined; 608 + }); 609 + }
+67
modules/_config/pi/skills/caveman/SKILL.md
··· 1 + --- 2 + name: caveman 3 + description: > 4 + Ultra-compressed communication mode. Cuts token usage ~75% by speaking like caveman 5 + while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra, 6 + wenyan-lite, wenyan-full, wenyan-ultra. 7 + Use when user says "caveman mode", "talk like caveman", "use caveman", "less tokens", 8 + "be brief", or invokes /caveman. Also auto-triggers when token efficiency is requested. 9 + --- 10 + 11 + Respond terse like smart caveman. All technical substance stay. Only fluff die. 12 + 13 + ## Persistence 14 + 15 + ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift. Still active if unsure. Off only: "stop caveman" / "normal mode". 16 + 17 + Default: **full**. Switch: `/caveman lite|full|ultra`. 18 + 19 + ## Rules 20 + 21 + Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). Technical terms exact. Code blocks unchanged. Errors quoted exact. 22 + 23 + Pattern: `[thing] [action] [reason]. [next step].` 24 + 25 + Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..." 26 + Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:" 27 + 28 + ## Intensity 29 + 30 + | Level | What change | 31 + |-------|------------| 32 + | **lite** | No filler/hedging. Keep articles + full sentences. Professional but tight | 33 + | **full** | Drop articles, fragments OK, short synonyms. Classic caveman | 34 + | **ultra** | Abbreviate (DB/auth/config/req/res/fn/impl), strip conjunctions, arrows for causality (X → Y), one word when one word enough | 35 + | **wenyan-lite** | Semi-classical. Drop filler/hedging but keep grammar structure, classical register | 36 + | **wenyan-full** | Maximum classical terseness. Fully 文言文. 80-90% character reduction. Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其) | 37 + | **wenyan-ultra** | Extreme abbreviation while keeping classical Chinese feel. Maximum compression, ultra terse | 38 + 39 + Example — "Why React component re-render?" 40 + - lite: "Your component re-renders because you create a new object reference each render. Wrap it in `useMemo`." 41 + - full: "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`." 42 + - ultra: "Inline obj prop → new ref → re-render. `useMemo`." 43 + - wenyan-lite: "組件頻重繪,以每繪新生對象參照故。以 useMemo 包之。" 44 + - wenyan-full: "物出新參照,致重繪。useMemo .Wrap之。" 45 + - wenyan-ultra: "新參照→重繪。useMemo Wrap。" 46 + 47 + Example — "Explain database connection pooling." 48 + - lite: "Connection pooling reuses open connections instead of creating new ones per request. Avoids repeated handshake overhead." 49 + - full: "Pool reuse open DB connections. No new connection per request. Skip handshake overhead." 50 + - ultra: "Pool = reuse DB conn. Skip handshake → fast under load." 51 + - wenyan-full: "池reuse open connection。不每req新開。skip handshake overhead。" 52 + - wenyan-ultra: "池reuse conn。skip handshake → fast。" 53 + 54 + ## Auto-Clarity 55 + 56 + Drop caveman for: security warnings, irreversible action confirmations, multi-step sequences where fragment order risks misread, user asks to clarify or repeats question. Resume caveman after clear part done. 57 + 58 + Example — destructive op: 59 + > **Warning:** This will permanently delete all rows in the `users` table and cannot be undone. 60 + > ```sql 61 + > DROP TABLE users; 62 + > ``` 63 + > Caveman resume. Verify backup exist first. 64 + 65 + ## Boundaries 66 + 67 + Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end.
+6
modules/apps.nix
··· 92 92 '') 93 93 ]; 94 94 95 + # Pi coding agent config 96 + home.file.".pi/agent/AGENTS.md".source = ./_config/pi/AGENTS.md; 97 + home.file.".pi/agent/skills/caveman".source = ./_config/pi/skills/caveman; 98 + home.file.".pi/agent/extensions/permission-pi".source = ./_config/pi/extensions/permission-pi; 99 + 100 + # Claude Code config 95 101 home.file.".claude/settings.json".text = builtins.toJSON { 96 102 preferences = { 97 103 thinking = "high";