pi-python-helper 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/package.json +2 -2
- package/src/core/safety.ts +0 -181
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,18 @@ does not guarantee a stable public tool schema.
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.2] - 2026-09-21
|
|
11
|
+
|
|
12
|
+
### Removed
|
|
13
|
+
|
|
14
|
+
- `src/core/safety.ts` is gone. The tools gate state changes with an explicit `execute: true`, so the Python risk classifier had no production caller and was maintained dead code. The expected classification of Python commands is kept as an executable spec in `test/core.test.ts`, exercised against `pi-helper-core`'s classifier.
|
|
15
|
+
|
|
16
|
+
## [0.4.1] - 2026-09-21
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- `src/core/safety.ts` now delegates to `pi-helper-core`'s classifier and supplies only the Python package-manager, environment, and migration rules; the segment-splitting, safe-override precedence, and compound-merge logic is no longer duplicated. Requires `pi-helper-core` 0.1.2, which stops treating `--frozen`/`--locked`/`--list` as read-only flags so `uv sync --frozen` is classified as mutating again.
|
|
21
|
+
|
|
10
22
|
## [0.4.0] - 2026-09-21
|
|
11
23
|
|
|
12
24
|
### Changed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-python-helper",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "Python (uv) development tools for the pi coding agent",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -71,6 +71,6 @@
|
|
|
71
71
|
"typescript": "^7.0.2"
|
|
72
72
|
},
|
|
73
73
|
"dependencies": {
|
|
74
|
-
"pi-helper-core": "^0.1.
|
|
74
|
+
"pi-helper-core": "^0.1.2"
|
|
75
75
|
}
|
|
76
76
|
}
|
package/src/core/safety.ts
DELETED
|
@@ -1,181 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Python has no equivalent of a `cmd_vel` topic that deterministically signals
|
|
3
|
-
* actuation, so risk cannot be inferred from a domain name. Instead every
|
|
4
|
-
* command is classified from its own text, and a compound command inherits the
|
|
5
|
-
* highest risk of its segments.
|
|
6
|
-
*/
|
|
7
|
-
export type CommandRisk = 'read' | 'mutating' | 'irreversible';
|
|
8
|
-
|
|
9
|
-
const RISK_ORDER: Record<CommandRisk, number> = { read: 0, mutating: 1, irreversible: 2 };
|
|
10
|
-
|
|
11
|
-
interface RiskPattern {
|
|
12
|
-
risk: Exclude<CommandRisk, 'read'>;
|
|
13
|
-
pattern: RegExp;
|
|
14
|
-
reason: string;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Safe overrides are matched before risk patterns so a read-only flag does not
|
|
19
|
-
* inherit the risk of the command it qualifies (`uv lock --check`, `-e .`).
|
|
20
|
-
*/
|
|
21
|
-
const SAFE_OVERRIDES: RegExp[] = [
|
|
22
|
-
/\buv\s+lock\b[^&|;]*--check\b/,
|
|
23
|
-
/\buv\s+sync\b[^&|;]*--dry-run\b/,
|
|
24
|
-
/\buv\s+(?:tree|export|version|help)\b/,
|
|
25
|
-
/\buv\s+pip\s+(?:list|freeze|check)\b/,
|
|
26
|
-
/\bpytest\b[^&|;]*--collect-only\b/,
|
|
27
|
-
/\bruff\s+(?:check|format)\b[^&|;]*(?:--diff|--check|--no-cache)\b/,
|
|
28
|
-
/\bmypy\b[^&|;]*--no-incremental\b/,
|
|
29
|
-
/\bgit\s+(?:diff|log|status|show|rev-parse|ls-files|branch\s+--show-current)\b/,
|
|
30
|
-
];
|
|
31
|
-
|
|
32
|
-
const RISK_PATTERNS: RiskPattern[] = [
|
|
33
|
-
// Irreversible: cannot be undone by a local revert.
|
|
34
|
-
{
|
|
35
|
-
risk: 'irreversible',
|
|
36
|
-
pattern: /\b(?:uv|poetry|flit|hatch)\s+publish\b|\btwine\s+upload\b/,
|
|
37
|
-
reason: 'Publishing to a package index is public and cannot be retracted.',
|
|
38
|
-
},
|
|
39
|
-
{
|
|
40
|
-
risk: 'irreversible',
|
|
41
|
-
pattern: /\bgit\s+push\b[^&|;]*(?:--force(?:-with-lease)?|-f\b)/,
|
|
42
|
-
reason: 'Force pushing rewrites shared remote history.',
|
|
43
|
-
},
|
|
44
|
-
{
|
|
45
|
-
risk: 'irreversible',
|
|
46
|
-
pattern: /\bgit\s+(?:reset\s+--hard|clean\b[^&|;]*-[a-z]*f)/,
|
|
47
|
-
reason: 'Hard reset or clean discards uncommitted work permanently.',
|
|
48
|
-
},
|
|
49
|
-
{
|
|
50
|
-
risk: 'irreversible',
|
|
51
|
-
pattern: /\b(?:conda|mamba)\s+env\s+remove\b|\bconda\s+remove\b[^&|;]*--all\b/,
|
|
52
|
-
reason: 'Removing an environment destroys installed state.',
|
|
53
|
-
},
|
|
54
|
-
{
|
|
55
|
-
risk: 'irreversible',
|
|
56
|
-
pattern: /\brm\b[^&|;]*-[a-z]*[rf][a-z]*/,
|
|
57
|
-
reason: 'Recursive or forced deletion is not recoverable.',
|
|
58
|
-
},
|
|
59
|
-
{
|
|
60
|
-
risk: 'irreversible',
|
|
61
|
-
pattern:
|
|
62
|
-
/\b(?:drop|truncate)\s+(?:table|database|schema)\b|\bdelete\s+from\b(?![^&|;]*\bwhere\b)/i,
|
|
63
|
-
reason: 'Destructive SQL without a narrowing predicate.',
|
|
64
|
-
},
|
|
65
|
-
{
|
|
66
|
-
risk: 'irreversible',
|
|
67
|
-
pattern: /\b(?:alembic|manage\.py)\b[^&|;]*(?:downgrade|\bzero\b)/,
|
|
68
|
-
reason: 'Reversing a database migration can drop data.',
|
|
69
|
-
},
|
|
70
|
-
{
|
|
71
|
-
risk: 'irreversible',
|
|
72
|
-
pattern: /\bdocker\s+(?:system|volume|image)\s+(?:prune|rm)\b/,
|
|
73
|
-
reason: 'Docker prune removes volumes or images outside the project.',
|
|
74
|
-
},
|
|
75
|
-
|
|
76
|
-
// Mutating: changes project, environment, or remote state but is recoverable.
|
|
77
|
-
{
|
|
78
|
-
risk: 'mutating',
|
|
79
|
-
pattern: /\b(?:uv|pdm|poetry)\s+(?:add|remove|sync|lock|update|venv)\b/,
|
|
80
|
-
reason: 'Modifies the environment or the lockfile.',
|
|
81
|
-
},
|
|
82
|
-
{
|
|
83
|
-
risk: 'mutating',
|
|
84
|
-
pattern: /\buv\s+pip\s+(?:install|uninstall|sync)\b/,
|
|
85
|
-
reason: 'Changes installed packages in the active environment.',
|
|
86
|
-
},
|
|
87
|
-
{
|
|
88
|
-
risk: 'mutating',
|
|
89
|
-
pattern: /\b(?:pip|pip3)\s+(?:install|uninstall)\b/,
|
|
90
|
-
reason: 'Changes installed packages in the active environment.',
|
|
91
|
-
},
|
|
92
|
-
{
|
|
93
|
-
risk: 'mutating',
|
|
94
|
-
pattern: /\b(?:conda|mamba)\s+(?:install|create|update|remove)\b/,
|
|
95
|
-
reason: 'Changes conda environment state.',
|
|
96
|
-
},
|
|
97
|
-
{
|
|
98
|
-
risk: 'mutating',
|
|
99
|
-
pattern: /\b(?:alembic|manage\.py)\b[^&|;]*\b(?:upgrade|migrate|makemigrations)\b/,
|
|
100
|
-
reason: 'Applies a schema change to a database.',
|
|
101
|
-
},
|
|
102
|
-
{
|
|
103
|
-
risk: 'mutating',
|
|
104
|
-
pattern: /\bpre-commit\s+(?:install|autoupdate|run|clean)\b/,
|
|
105
|
-
reason: 'Rewrites hook configuration or working tree files.',
|
|
106
|
-
},
|
|
107
|
-
{
|
|
108
|
-
risk: 'mutating',
|
|
109
|
-
pattern: /\bgit\s+(?:commit|add|checkout|switch|restore|stash|merge|rebase|push|tag)\b/,
|
|
110
|
-
reason: 'Changes repository or remote state.',
|
|
111
|
-
},
|
|
112
|
-
{
|
|
113
|
-
risk: 'mutating',
|
|
114
|
-
pattern: /\b(?:rm|mv|chmod|chown|truncate)\b/,
|
|
115
|
-
reason: 'Changes files on disk.',
|
|
116
|
-
},
|
|
117
|
-
];
|
|
118
|
-
|
|
119
|
-
/** Split a compound command so no segment can hide behind a safe sibling. */
|
|
120
|
-
export function splitCommandSegments(command: string): string[] {
|
|
121
|
-
return command
|
|
122
|
-
.split(/&&|\|\||;|\n|\|/)
|
|
123
|
-
.map((segment) => segment.trim())
|
|
124
|
-
.filter((segment) => segment.length > 0);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function classifySegment(segment: string): { risk: CommandRisk; reason?: string } {
|
|
128
|
-
if (SAFE_OVERRIDES.some((pattern) => pattern.test(segment))) return { risk: 'read' };
|
|
129
|
-
for (const entry of RISK_PATTERNS) {
|
|
130
|
-
if (entry.pattern.test(segment)) return { risk: entry.risk, reason: entry.reason };
|
|
131
|
-
}
|
|
132
|
-
return { risk: 'read' };
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
export interface CommandClassification {
|
|
136
|
-
risk: CommandRisk;
|
|
137
|
-
reasons: { segment: string; risk: CommandRisk; reason: string }[];
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** `curl ... | sh` cannot be seen after segment splitting, so it is matched first. */
|
|
141
|
-
const PIPE_TO_SHELL = /\b(?:curl|wget)\b[^;&\n]*\|\s*(?:sudo\s+)?(?:ba|z|k)?sh\b/;
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Classify a shell command by the highest risk of its segments. Used to warn
|
|
145
|
-
* before a tool runs something that cannot be undone, and to gate this
|
|
146
|
-
* package's own environment-modifying commands behind explicit opt-in.
|
|
147
|
-
*/
|
|
148
|
-
export function classifyCommand(command: string): CommandClassification {
|
|
149
|
-
const piped = command.match(PIPE_TO_SHELL);
|
|
150
|
-
if (piped) {
|
|
151
|
-
return {
|
|
152
|
-
risk: 'irreversible',
|
|
153
|
-
reasons: [
|
|
154
|
-
{
|
|
155
|
-
segment: piped[0].trim(),
|
|
156
|
-
risk: 'irreversible',
|
|
157
|
-
reason: 'Piping a download into a shell runs unreviewed code.',
|
|
158
|
-
},
|
|
159
|
-
],
|
|
160
|
-
};
|
|
161
|
-
}
|
|
162
|
-
const reasons: CommandClassification['reasons'] = [];
|
|
163
|
-
let risk: CommandRisk = 'read';
|
|
164
|
-
for (const segment of splitCommandSegments(command)) {
|
|
165
|
-
const classified = classifySegment(segment);
|
|
166
|
-
if (RISK_ORDER[classified.risk] > RISK_ORDER[risk]) risk = classified.risk;
|
|
167
|
-
if (classified.risk !== 'read' && classified.reason) {
|
|
168
|
-
reasons.push({ segment, risk: classified.risk, reason: classified.reason });
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
return { risk, reasons };
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
export function isMutatingCommand(command: string): boolean {
|
|
175
|
-
return classifyCommand(command).risk !== 'read';
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/** Commands that this package runs itself always carry a known risk class. */
|
|
179
|
-
export function riskOf(args: string[]): CommandRisk {
|
|
180
|
-
return classifyCommand(args.join(' ')).risk;
|
|
181
|
-
}
|