@pugi/cli 0.1.0-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,204 @@
1
+ import { basename, resolve } from 'node:path';
2
+ const protectedBasenames = new Set([
3
+ '.env',
4
+ '.npmrc',
5
+ '.yarnrc',
6
+ '.pypirc',
7
+ '.gitconfig',
8
+ 'id_rsa',
9
+ 'id_ed25519',
10
+ ]);
11
+ const protectedSuffixes = ['.pem', '.key', '.crt', '.p12', '.dump', '.sql'];
12
+ // Hard-deny list for `bash` actions. Substring match against the
13
+ // normalized (trimmed, original-case) command — the model can
14
+ // concatenate arguments any way it wants, but if ANY substring on
15
+ // this list appears the command is refused before /bin/sh sees it.
16
+ //
17
+ // Coverage classes:
18
+ // - destructive filesystem wipes: rm -rf, dd, mkfs, shred, wipefs
19
+ // - destructive system ops: chmod 777, chown -R /, setuid bits
20
+ // - dangerous shell tricks: eval, fork bombs, /dev/sda writes
21
+ // - git history loss: reset --hard, clean -fdx
22
+ // - container / infra wipes: docker system prune, k8s delete --all
23
+ // - destructive SQL: DROP DATABASE / DROP TABLE
24
+ // - firewall disable: ufw disable, iptables -F
25
+ // - credential exfil to stdout: cat ~/.ssh/id_rsa, gpg --export
26
+ //
27
+ // Code Reviewer P1 2026-05-23 flagged the previous list as missing
28
+ // dd/mkfs/chmod 777/eval/fork bomb — added below. List intentionally
29
+ // errs toward over-block: a false positive blocks a destructive
30
+ // pattern the human can `pugi bash -- run` manually with confirmation,
31
+ // while a false negative is catastrophic.
32
+ const destructiveBashPatterns = [
33
+ // Filesystem wipe
34
+ 'rm -rf /',
35
+ 'rm -rf ~',
36
+ 'rm -rf .',
37
+ 'rm -rf *',
38
+ 'rm -rf "/',
39
+ 'rm -rf "~',
40
+ 'dd if=/dev/zero',
41
+ 'dd if=/dev/random',
42
+ 'dd of=/dev/sda',
43
+ 'dd of=/dev/disk',
44
+ 'mkfs',
45
+ 'shred ',
46
+ 'wipefs',
47
+ '> /dev/sda',
48
+ '> /dev/disk',
49
+ // Permission wipe
50
+ 'chmod 777 /',
51
+ 'chmod -R 777 /',
52
+ 'chown -R root /',
53
+ // Shell tricks
54
+ ':(){ :|:& };:',
55
+ 'eval "$',
56
+ "eval '$",
57
+ // Git history loss
58
+ 'git reset --hard',
59
+ 'git clean -fdx',
60
+ 'git push --force origin main',
61
+ 'git push -f origin main',
62
+ // Container / infra
63
+ 'docker system prune',
64
+ 'docker rm -f $(docker',
65
+ 'kubectl delete --all',
66
+ // SQL destructive. Code Reviewer P2 retro 2026-05-23: the
67
+ // substring match was case-sensitive; lowercase `drop database`
68
+ // bypassed the gate. We now uppercase-normalise the target
69
+ // (`hardDenyReason` below) so both upper- and mixed-case SQL
70
+ // hits the same pattern. Patterns themselves stay uppercase
71
+ // since they're compared against the uppercased command.
72
+ 'DROP DATABASE',
73
+ 'DROP TABLE',
74
+ 'TRUNCATE TABLE',
75
+ // Firewall / network
76
+ 'ufw disable',
77
+ 'iptables -F',
78
+ 'iptables --flush',
79
+ // Credential exfil
80
+ 'cat ~/.ssh/id_rsa',
81
+ 'cat ~/.ssh/id_ed25519',
82
+ 'gpg --export-secret',
83
+ // SSH config tampering — write paths only. Code Reviewer P2
84
+ // retro 2026-05-23: a bare `sshd_config` substring also blocked
85
+ // read-only `cat /etc/sshd_config` / `grep PermitRootLogin
86
+ // sshd_config`. Scoped to redirections + tee so the model can
87
+ // still inspect the file but cannot write to it through bash.
88
+ '> sshd_config',
89
+ '>> sshd_config',
90
+ '> /etc/ssh/sshd_config',
91
+ '>> /etc/ssh/sshd_config',
92
+ 'tee sshd_config',
93
+ 'tee /etc/ssh/sshd_config',
94
+ 'tee -a sshd_config',
95
+ 'tee -a /etc/ssh/sshd_config',
96
+ // History destruction
97
+ 'history -c',
98
+ ' >/dev/null 2>&1; rm',
99
+ ];
100
+ export function decidePermission(action, settings, root) {
101
+ const hardDeny = hardDenyReason(action);
102
+ if (hardDeny) {
103
+ return { decision: 'deny', reason: hardDeny, source: 'hard_deny' };
104
+ }
105
+ const protectedReason = protectedTargetReason(action, root);
106
+ if (protectedReason) {
107
+ return decisionForMode(settings.permissions.mode, protectedReason, 'protected_file', 'high');
108
+ }
109
+ const signature = `${action.kind}:${action.target}`;
110
+ if (matchesAny(signature, settings.permissions.deny)) {
111
+ return { decision: 'deny', reason: `Denied by rule: ${signature}`, source: 'settings.deny' };
112
+ }
113
+ if (matchesAny(signature, settings.permissions.notAutomatic) || matchesAny(signature, settings.workflow.notAutomatic)) {
114
+ return { decision: 'ask', reason: `Marked not automatic: ${signature}`, risk: 'medium' };
115
+ }
116
+ if (matchesAny(signature, settings.permissions.allow)) {
117
+ return { decision: 'allow', reason: `Allowed by rule: ${signature}`, source: 'settings.allow' };
118
+ }
119
+ return decisionForMode(settings.permissions.mode, `Default ${settings.permissions.mode} policy`, 'mode', riskForAction(action));
120
+ }
121
+ export function protectedTargetReason(action, root) {
122
+ if (action.kind !== 'edit' && action.kind !== 'read')
123
+ return null;
124
+ const target = resolve(root, action.target);
125
+ const name = basename(target);
126
+ if (protectedBasenames.has(name) || name.startsWith('.env.')) {
127
+ return `Protected file: ${action.target}`;
128
+ }
129
+ if (protectedSuffixes.some((suffix) => name.endsWith(suffix))) {
130
+ return `Protected file suffix: ${action.target}`;
131
+ }
132
+ if (target.includes('/.ssh/') || target.includes('/.gnupg/') || target.includes('/.aws/')) {
133
+ return `Protected credential path: ${action.target}`;
134
+ }
135
+ return null;
136
+ }
137
+ function hardDenyReason(action) {
138
+ if (action.kind !== 'bash')
139
+ return null;
140
+ const normalized = action.target.trim();
141
+ // Patterns whose match must be case-insensitive — primarily SQL verbs
142
+ // that the model can emit as `drop database`, `Drop Database`, etc.
143
+ // Code Reviewer P2 retro 2026-05-23: substring match was previously
144
+ // case-sensitive so lowercase SQL silently bypassed the deny list.
145
+ // We compare an uppercased copy of the command against the uppercase
146
+ // patterns below; non-SQL patterns stay exact-match against the
147
+ // original-case target to avoid over-matching benign filenames.
148
+ const normalizedUpper = normalized.toUpperCase();
149
+ const caseInsensitivePatterns = new Set([
150
+ 'DROP DATABASE',
151
+ 'DROP TABLE',
152
+ 'TRUNCATE TABLE',
153
+ ]);
154
+ const matched = destructiveBashPatterns.find((pattern) => {
155
+ if (caseInsensitivePatterns.has(pattern)) {
156
+ return normalizedUpper.includes(pattern);
157
+ }
158
+ return normalized.includes(pattern);
159
+ });
160
+ return matched ? `Destructive command blocked: ${matched}` : null;
161
+ }
162
+ function decisionForMode(mode, reason, source, risk) {
163
+ switch (mode) {
164
+ case 'plan':
165
+ return risk === 'low'
166
+ ? { decision: 'allow', reason, source }
167
+ : { decision: 'deny', reason: `${reason}; plan mode blocks mutating actions`, source: 'mode.plan' };
168
+ case 'ask':
169
+ return { decision: 'ask', reason, risk };
170
+ case 'acceptEdits':
171
+ return risk === 'medium'
172
+ ? { decision: 'allow', reason, source: 'mode.acceptEdits' }
173
+ : { decision: 'ask', reason, risk };
174
+ case 'auto':
175
+ return risk === 'high' ? { decision: 'ask', reason, risk } : { decision: 'allow', reason, source: 'mode.auto' };
176
+ case 'dontAsk':
177
+ return risk === 'high'
178
+ ? { decision: 'deny', reason: `${reason}; dontAsk denies high-risk actions`, source: 'mode.dontAsk' }
179
+ : { decision: 'allow', reason, source: 'mode.dontAsk' };
180
+ case 'bypassPermissions':
181
+ return { decision: 'allow', reason, source: 'mode.bypassPermissions' };
182
+ }
183
+ }
184
+ function riskForAction(action) {
185
+ if (action.kind === 'read')
186
+ return 'low';
187
+ if (action.kind === 'edit')
188
+ return 'medium';
189
+ if (action.kind === 'bash')
190
+ return 'high';
191
+ if (action.kind === 'workflow')
192
+ return 'medium';
193
+ return 'medium';
194
+ }
195
+ function matchesAny(value, rules) {
196
+ return rules.some((rule) => {
197
+ if (rule === value)
198
+ return true;
199
+ if (rule.endsWith('*'))
200
+ return value.startsWith(rule.slice(0, -1));
201
+ return false;
202
+ });
203
+ }
204
+ //# sourceMappingURL=permission.js.map
@@ -0,0 +1,90 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { appendFileSync, existsSync, mkdirSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ export function openSession(root) {
5
+ const pugiDir = resolve(root, '.pugi');
6
+ const enabled = existsSync(pugiDir);
7
+ const id = `local-${Date.now()}-${randomUUID()}`;
8
+ const eventsPath = resolve(pugiDir, 'events.jsonl');
9
+ if (enabled) {
10
+ mkdirSync(pugiDir, { recursive: true });
11
+ appendEvent({ id: randomUUID(), sessionId: id, timestamp: now(), type: 'session', name: 'created' }, eventsPath);
12
+ }
13
+ return {
14
+ id,
15
+ root,
16
+ pugiDir,
17
+ eventsPath,
18
+ enabled,
19
+ };
20
+ }
21
+ export function recordCommandStarted(session, command) {
22
+ if (!session.enabled)
23
+ return;
24
+ appendEvent({
25
+ id: randomUUID(),
26
+ sessionId: session.id,
27
+ timestamp: now(),
28
+ type: 'session',
29
+ name: 'command_started',
30
+ command,
31
+ }, session.eventsPath);
32
+ }
33
+ export function recordCommandCompleted(session, command, status) {
34
+ if (!session.enabled)
35
+ return;
36
+ appendEvent({
37
+ id: randomUUID(),
38
+ sessionId: session.id,
39
+ timestamp: now(),
40
+ type: 'session',
41
+ name: 'command_completed',
42
+ command,
43
+ status,
44
+ }, session.eventsPath);
45
+ }
46
+ export function recordToolCall(session, tool, inputSummary) {
47
+ const id = randomUUID();
48
+ if (!session.enabled)
49
+ return id;
50
+ appendEvent({
51
+ id,
52
+ sessionId: session.id,
53
+ timestamp: now(),
54
+ type: 'tool_call',
55
+ tool,
56
+ inputSummary,
57
+ }, session.eventsPath);
58
+ return id;
59
+ }
60
+ export function recordToolResult(session, toolCallId, status, outputSummary) {
61
+ if (!session.enabled)
62
+ return;
63
+ appendEvent({
64
+ id: randomUUID(),
65
+ sessionId: session.id,
66
+ timestamp: now(),
67
+ type: 'tool_result',
68
+ toolCallId,
69
+ status,
70
+ outputSummary,
71
+ }, session.eventsPath);
72
+ }
73
+ export function recordFileMutation(session, input) {
74
+ if (!session.enabled)
75
+ return;
76
+ appendEvent({
77
+ id: randomUUID(),
78
+ sessionId: session.id,
79
+ timestamp: now(),
80
+ type: 'file_mutation',
81
+ ...input,
82
+ }, session.eventsPath);
83
+ }
84
+ function appendEvent(event, eventsPath) {
85
+ appendFileSync(eventsPath, `${JSON.stringify(event)}\n`, { encoding: 'utf8', mode: 0o600 });
86
+ }
87
+ function now() {
88
+ return new Date().toISOString();
89
+ }
90
+ //# sourceMappingURL=session.js.map
@@ -0,0 +1,46 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { z } from 'zod';
4
+ const pugiSettingsSchema = z.object({
5
+ schema: z.number().int().positive().default(1),
6
+ workflow: z
7
+ .object({
8
+ brand: z.string().default('pugi'),
9
+ legacyName: z.string().optional(),
10
+ approvals: z.enum(['auto', 'manual']).default('auto'),
11
+ notAutomatic: z.array(z.string()).default([]),
12
+ defaultBaseBranch: z.string().default('dev'),
13
+ branchPrefixes: z.array(z.string()).default(['feature', 'fix', 'refactor', 'chore']),
14
+ aiCoAuthorTrailers: z.boolean().default(false),
15
+ })
16
+ .default({}),
17
+ permissions: z
18
+ .object({
19
+ mode: z.enum(['plan', 'ask', 'acceptEdits', 'auto', 'dontAsk', 'bypassPermissions']).default('auto'),
20
+ allow: z.array(z.string()).default([]),
21
+ deny: z.array(z.string()).default([]),
22
+ notAutomatic: z.array(z.string()).default([]),
23
+ })
24
+ .default({}),
25
+ privacy: z
26
+ .object({
27
+ mode: z.enum(['balanced', 'embeddings-only', 'airgapped']).default('balanced'),
28
+ telemetry: z.enum(['off', 'anonymous', 'community']).default('off'),
29
+ })
30
+ .default({}),
31
+ artifacts: z
32
+ .object({
33
+ defaultPath: z.string().default('.pugi/artifacts'),
34
+ promoteExplicitly: z.boolean().default(true),
35
+ })
36
+ .default({}),
37
+ });
38
+ export function loadSettings(root) {
39
+ const settingsPath = resolve(root, '.pugi/settings.json');
40
+ if (!existsSync(settingsPath)) {
41
+ return pugiSettingsSchema.parse({});
42
+ }
43
+ const parsed = JSON.parse(readFileSync(settingsPath, 'utf8'));
44
+ return pugiSettingsSchema.parse(parsed);
45
+ }
46
+ //# sourceMappingURL=settings.js.map
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from './runtime/cli.js';
3
+ runCli(process.argv.slice(2)).catch((error) => {
4
+ const message = error instanceof Error ? error.message : String(error);
5
+ console.error(`pugi: ${message}`);
6
+ process.exitCode = 1;
7
+ });
8
+ //# sourceMappingURL=index.js.map