pi-task-tracker 0.1.9 → 0.2.0
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/README.md +11 -1
- package/index.ts +104 -33
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -75,7 +75,7 @@ Optional `taskTracker` key in `~/.pi/agent/settings.json` (all default to `true`
|
|
|
75
75
|
- `todo` — maintain `TODO.md`
|
|
76
76
|
- `readme` — maintain the `README.md` project-structure section
|
|
77
77
|
- `autoCommit` — per-task commits; these are the `/rollback` checkpoints, so disabling this also disables rollback targets
|
|
78
|
-
- `externalWrite` — policy for tool calls that would create/modify files OUTSIDE the session directory: `"block"` (default, hard deny — external changes cannot be tracked or rolled back), `"ask"` (per-attempt confirmation), `"off"` (no enforcement; `pi-permission-system` rules still apply). Reads stay free. Shell commands are analyzed statically (redirects, `rm/mv/cp/touch/...`, `cd` tracking, PowerShell equivalents); git protection is inherited from `pi-permission-system` — routine git passes untouched and history-rewriting git still prompts there; only redirect targets and `-C` destinations on git segments are checked.
|
|
78
|
+
- `externalWrite` — policy for tool calls that would create/modify files OUTSIDE the session directory: `"block"` (default, hard deny — external changes cannot be tracked or rolled back), `"ask"` (per-attempt confirmation), `"off"` (no enforcement; `pi-permission-system` rules still apply). Reads stay free. Copying files FROM outside INTO the session is allowed (`cp` checks only its destination); moving/deleting external paths is blocked. Git history protection is **inherited**: history-rewriting git (rebase/reset/amend/force-push/clean/...) prompts for confirmation right here, so `pi-permission-system` can be uninstalled without losing that layer; routine git passes untouched, an external `git init`/`clone` is allowed with a heads-up that the new repo is not tracked. Shell commands are analyzed statically (redirects, `rm/mv/cp/touch/...`, `cd` tracking, PowerShell equivalents); git protection is inherited from `pi-permission-system` — routine git passes untouched and history-rewriting git still prompts there; only redirect targets and `-C` destinations on git segments are checked.
|
|
79
79
|
|
|
80
80
|
## Requirements
|
|
81
81
|
|
|
@@ -97,3 +97,13 @@ Optional `taskTracker` key in `~/.pi/agent/settings.json` (all default to `true`
|
|
|
97
97
|
## License
|
|
98
98
|
|
|
99
99
|
MIT
|
|
100
|
+
|
|
101
|
+
## Overview
|
|
102
|
+
|
|
103
|
+
TODO: Add project description.
|
|
104
|
+
|
|
105
|
+
## Project Structure
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
pi-task-tracker/
|
|
109
|
+
```
|
package/index.ts
CHANGED
|
@@ -119,9 +119,24 @@ function findEnclosingRepo(dir: string): string | null {
|
|
|
119
119
|
const GUARD_WRITE_TOOLS = new Set(["write", "edit", "replace", "insert", "undo_last_change"]);
|
|
120
120
|
const GUARD_READ_TOOLS = new Set(["read", "grep", "find", "ls", "anchor_grep", "glob"]);
|
|
121
121
|
const GUARD_SHELL_WRITE_CMDS = new Set([
|
|
122
|
-
"rm", "mv", "
|
|
123
|
-
"
|
|
122
|
+
"rm", "mv", "touch", "mkdir", "rmdir", "tee", "truncate", "dd",
|
|
123
|
+
"patch", "chmod", "chmod", "chown", "sed",
|
|
124
124
|
]);
|
|
125
|
+
// copy-like commands only WRITE their destination (last path argument);
|
|
126
|
+
// the source is a read, so `cp /external/file ./` stays allowed
|
|
127
|
+
const GUARD_COPY_CMDS = new Set(["cp", "install", "ln", "rsync"]);
|
|
128
|
+
// interpreters: their -c/script payload can write anywhere; if the command
|
|
129
|
+
// text mentions an external absolute path we block, otherwise we cannot
|
|
130
|
+
// judge script contents (documented limitation)
|
|
131
|
+
const GUARD_INTERPRETERS = new Set(["python", "python3", "py", "node", "deno", "bun", "ruby", "perl", "php"]);
|
|
132
|
+
// inherited verbatim from pi-permissions.jsonc (pi-permission-system):
|
|
133
|
+
// git operations that DELETE or REWRITE history require user confirmation.
|
|
134
|
+
const GUARD_GIT_ASK = [
|
|
135
|
+
"git rebase", "git reset", "git commit --amend", "git push --force",
|
|
136
|
+
"git push -f", "git revert", "git branch -D", "git branch -d", "git tag -d",
|
|
137
|
+
"git clean", "git stash drop", "git stash clear", "git filter-branch",
|
|
138
|
+
];
|
|
139
|
+
const GUARD_GIT_ASK_CACHE_TTL = 0;
|
|
125
140
|
const GUARD_PS_WRITE_CMDS = new Set([
|
|
126
141
|
"set-content", "out-file", "add-content", "new-item", "remove-item",
|
|
127
142
|
"copy-item", "move-item", "clear-content", "ni", "ri", "mi", "ac", "sc", "sp",
|
|
@@ -129,16 +144,16 @@ const GUARD_PS_WRITE_CMDS = new Set([
|
|
|
129
144
|
|
|
130
145
|
// Resolve a shell token to an absolute path; unresolvable tokens (env vars,
|
|
131
146
|
// command substitution) are treated as external - conservative by design.
|
|
132
|
-
function guardResolve(
|
|
147
|
+
function guardResolve(effCwd: string, sessionCwd: string, token: string): { abs: string; external: boolean } {
|
|
133
148
|
let t = token.replace(/^["']|["']$/g, "").trim();
|
|
134
|
-
if (!t) return { abs:
|
|
149
|
+
if (!t) return { abs: effCwd, external: false };
|
|
135
150
|
if (t.startsWith("~")) return { abs: t, external: true };
|
|
136
151
|
if (/[\$`]/.test(t)) return { abs: t, external: true };
|
|
137
152
|
if (/^[A-Za-z]:[\\/]/.test(t) || t.startsWith("/")) {
|
|
138
|
-
return { abs: t, external: isExternalAbs(t,
|
|
153
|
+
return { abs: t, external: isExternalAbs(t, sessionCwd) };
|
|
139
154
|
}
|
|
140
|
-
const abs = path.resolve(
|
|
141
|
-
return { abs, external: isExternalAbs(abs,
|
|
155
|
+
const abs = path.resolve(effCwd, t);
|
|
156
|
+
return { abs, external: isExternalAbs(abs, sessionCwd) };
|
|
142
157
|
}
|
|
143
158
|
|
|
144
159
|
function isExternalAbs(abs: string, base: string): boolean {
|
|
@@ -152,60 +167,93 @@ function guardIsDevNull(t: string): boolean {
|
|
|
152
167
|
|
|
153
168
|
// Analyze one shell command for writes that would land outside `sessionCwd`.
|
|
154
169
|
// Returns the offending path (for the deny reason) or null.
|
|
155
|
-
|
|
170
|
+
// Analyze one shell command. Returns { block, ask, notify } hits (any may be
|
|
171
|
+
// absent). External-write analysis is best effort: redirect targets,
|
|
172
|
+
// write-command arguments (copy-like: destination only), interpreter payloads
|
|
173
|
+
// mentioning external absolute paths, cd tracking. Git arguments are NOT
|
|
174
|
+
// analyzed (inherited protection: only history-rewriting git asks).
|
|
175
|
+
function guardShellExternalWrite(cmd: string, sessionCwd: string): { block?: string; ask?: string; notify?: string } | null {
|
|
156
176
|
const segments = cmd.split(/\n|&&|\|\||;|\|/).map((s) => s.trim()).filter(Boolean);
|
|
157
177
|
let effCwd = sessionCwd;
|
|
178
|
+
const out: { block?: string; ask?: string; notify?: string } = {};
|
|
158
179
|
for (const seg of segments) {
|
|
159
180
|
const cd = seg.match(/^cd\s+(.+)$/i) || seg.match(/^sl\s+(.+)$/i) || seg.match(/^set-location\s+(.+)$/i);
|
|
160
181
|
if (cd) {
|
|
161
|
-
const r = guardResolve(effCwd, cd[1]);
|
|
182
|
+
const r = guardResolve(effCwd, sessionCwd, cd[1]);
|
|
162
183
|
// switch to the real effective cwd even when external: only WRITE ops
|
|
163
184
|
// are analyzed below, so `cd elsewhere && cat f` still passes while
|
|
164
185
|
// `cd elsewhere && touch a.txt` is correctly judged external.
|
|
165
186
|
effCwd = r.abs;
|
|
166
187
|
continue;
|
|
167
188
|
}
|
|
168
|
-
|
|
169
|
-
// 1. redirects: > >> 2> 2>> &> &>> >| (not heredocs, not fd dups like 2>&1)
|
|
189
|
+
// 2. redirects: > >> 2> 2>> &> &>> >| (not heredocs, not fd dups like 2>&1)
|
|
170
190
|
for (const m of seg.matchAll(/(?:^|[^<>&=(])(?:\d?>{1,2}|&>>?|>\|)\s*([^\s;&|]+)/g)) {
|
|
171
191
|
const tgt = m[1];
|
|
172
192
|
if (/^&?\d+$/.test(tgt) || guardIsDevNull(tgt)) continue;
|
|
173
|
-
const r = guardResolve(effCwd, tgt);
|
|
174
|
-
if (r.external)
|
|
193
|
+
const r = guardResolve(effCwd, sessionCwd, tgt);
|
|
194
|
+
if (r.external) out.block = r.abs;
|
|
175
195
|
}
|
|
176
|
-
|
|
177
|
-
//
|
|
196
|
+
const isGit = /^git(\s|$)/.test(seg);
|
|
197
|
+
// 1. inherited git protection: history-rewriting git asks (matches
|
|
198
|
+
// pi-permissions.jsonc bash patterns; prefix match like "git rebase*")
|
|
178
199
|
if (isGit) {
|
|
200
|
+
const low = seg.toLowerCase();
|
|
201
|
+
if (GUARD_GIT_ASK.some((p) => low.startsWith(p))) {
|
|
202
|
+
out.ask = seg.slice(0, 80);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (/^git\s+(init|clone)/.test(low)) {
|
|
206
|
+
const r = guardResolve(effCwd, sessionCwd, seg.split(/\s+/)[1] === "clone" ? (seg.match(/\s+(\S+)\s+\S+$/) || [])[1] || "." : ".");
|
|
207
|
+
if (r.external) {
|
|
208
|
+
out.notify = `git ${seg.startsWith("git clone") ? "clone" : "init"} outside the session directory (${r.abs}). pi-task-tracker does NOT track that directory; start a separate pi session there if you want it tracked.`;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// external -C destination on any git command
|
|
179
212
|
const c = seg.match(/\s-C\s+(\S+)/);
|
|
180
213
|
if (c) {
|
|
181
|
-
const r = guardResolve(effCwd, c[1]);
|
|
182
|
-
if (r.external)
|
|
214
|
+
const r = guardResolve(effCwd, sessionCwd, c[1]);
|
|
215
|
+
if (r.external) out.block = r.abs;
|
|
183
216
|
}
|
|
184
217
|
continue;
|
|
185
218
|
}
|
|
186
|
-
// 3. write-ish commands:
|
|
219
|
+
// 3. write-ish commands: copy-like checks ONLY the destination (last path
|
|
220
|
+
// argument) so `cp /external/file ./` stays allowed; others check all
|
|
187
221
|
const tokens = seg.split(/\s+/);
|
|
188
222
|
const head = (tokens[0] || "").toLowerCase().replace(/\.exe$/, "");
|
|
189
|
-
const headBase = head.split(/[
|
|
223
|
+
const headBase = head.split(/[\/]/).pop() || head;
|
|
190
224
|
const isPsWrite = GUARD_PS_WRITE_CMDS.has(headBase);
|
|
191
225
|
const isShWrite = GUARD_SHELL_WRITE_CMDS.has(headBase);
|
|
192
|
-
|
|
226
|
+
const isCopy = GUARD_COPY_CMDS.has(headBase);
|
|
227
|
+
const isInterp = GUARD_INTERPRETERS.has(headBase);
|
|
228
|
+
if (!isPsWrite && !isShWrite && !isCopy && !isInterp) continue;
|
|
229
|
+
if (isInterp) {
|
|
230
|
+
// interpreter: block only if the command TEXT names an external path
|
|
231
|
+
for (const m of seg.matchAll(/[A-Za-z]:[\/]\S*|\/(?:[\w.-]+\/)+[\w.-]+/g)) {
|
|
232
|
+
if (guardIsDevNull(m[0])) continue;
|
|
233
|
+
if (isExternalAbs(m[0], sessionCwd)) { out.block = m[0]; break; }
|
|
234
|
+
}
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const pathArgs = [];
|
|
193
238
|
for (let k = 1; k < tokens.length; k++) {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
239
|
+
if (tokens[k].startsWith("-")) continue;
|
|
240
|
+
pathArgs.push(tokens[k]);
|
|
241
|
+
}
|
|
242
|
+
const checkList = isCopy ? pathArgs.slice(-1) : pathArgs;
|
|
243
|
+
for (const t of checkList) {
|
|
244
|
+
if (/^[A-Za-z]:[\{1,2}]/.test(t) || t.startsWith("/")) { // absolute
|
|
197
245
|
if (guardIsDevNull(t)) continue;
|
|
198
|
-
if (isExternalAbs(t, sessionCwd))
|
|
246
|
+
if (isExternalAbs(t, sessionCwd)) out.block = t;
|
|
199
247
|
} else if (/[\$`~]/.test(t)) {
|
|
200
|
-
|
|
248
|
+
out.block = t; // unresolvable -> conservative
|
|
201
249
|
} else {
|
|
202
250
|
const abs = path.resolve(effCwd, t);
|
|
203
|
-
if (isExternalAbs(abs, sessionCwd))
|
|
251
|
+
if (isExternalAbs(abs, sessionCwd)) out.block = t;
|
|
204
252
|
}
|
|
205
|
-
if (
|
|
253
|
+
if (out.block) break;
|
|
206
254
|
}
|
|
207
255
|
}
|
|
208
|
-
return null;
|
|
256
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
209
257
|
}
|
|
210
258
|
|
|
211
259
|
const GUARD_ASK_PROMPT = "pi-task-tracker external-write guard";
|
|
@@ -215,7 +263,7 @@ async function guardExternalWriteHit(
|
|
|
215
263
|
event: { toolName: string; input?: unknown },
|
|
216
264
|
sessionCwd: string,
|
|
217
265
|
mode: "block" | "ask",
|
|
218
|
-
ctx?: { hasUI?: boolean; ui?: { confirm: (t: string, m: string) => Promise<boolean
|
|
266
|
+
ctx?: { hasUI?: boolean; ui?: { confirm: (t: string, m: string) => Promise<boolean>; notify: (m: string, kind?: string) => void } }
|
|
219
267
|
): Promise<string | null> {
|
|
220
268
|
if (!sessionCwd) return null;
|
|
221
269
|
const toolName = event.toolName || "";
|
|
@@ -223,9 +271,32 @@ async function guardExternalWriteHit(
|
|
|
223
271
|
// shell tools: static best-effort analysis (bash/powershell)
|
|
224
272
|
if (toolName === "bash" || toolName === "powershell") {
|
|
225
273
|
const cmd = (event.input as { command?: string } | undefined)?.command || "";
|
|
226
|
-
const
|
|
227
|
-
if (!
|
|
228
|
-
|
|
274
|
+
const hit = guardShellExternalWrite(cmd, sessionCwd);
|
|
275
|
+
if (!hit) return null;
|
|
276
|
+
if (hit.notify && ctx?.hasUI && ctx.ui) ctx.ui.notify(hit.notify, "info");
|
|
277
|
+
if (hit.ask) {
|
|
278
|
+
if (!ctx?.hasUI || !ctx.ui) return `pi-task-tracker guard: history-rewriting git ('${hit.ask}') requires approval but no UI is available.`;
|
|
279
|
+
const ok = await ctx.ui.confirm(
|
|
280
|
+
"pi-task-tracker git guard (inherited)",
|
|
281
|
+
`History-rewriting git command: '${hit.ask}'. This protection was inherited from pi-permission-system. Allow?`,
|
|
282
|
+
);
|
|
283
|
+
if (ok) return null;
|
|
284
|
+
return `pi-task-tracker guard: user denied '${hit.ask}'. Do not retry or bypass.`;
|
|
285
|
+
}
|
|
286
|
+
if (hit.block) {
|
|
287
|
+
const why = "External changes cannot be tracked or rolled back by pi-task-tracker.";
|
|
288
|
+
if (mode === "ask") {
|
|
289
|
+
if (!ctx?.hasUI || !ctx.ui) return `pi-task-tracker scope guard: '${hit.block}' is outside the session directory and no UI is available. ${why}`;
|
|
290
|
+
const ok = await ctx.ui.confirm(
|
|
291
|
+
"pi-task-tracker external-write guard",
|
|
292
|
+
`This command writes to '${hit.block}' outside the session directory. ${why} Allow anyway?`,
|
|
293
|
+
);
|
|
294
|
+
if (ok) return null;
|
|
295
|
+
return `pi-task-tracker scope guard: user denied external write to '${hit.block}'. Do not retry or bypass.`;
|
|
296
|
+
}
|
|
297
|
+
return `pi-task-tracker scope guard (externalWrite=block): this command writes to '${hit.block}' outside the session directory. ${why} Stay inside the session directory, or have the user adjust taskTracker.externalWrite / run it manually.`;
|
|
298
|
+
}
|
|
299
|
+
return null;
|
|
229
300
|
}
|
|
230
301
|
|
|
231
302
|
// path-bearing tools
|
|
@@ -235,7 +306,7 @@ async function guardExternalWriteHit(
|
|
|
235
306
|
const isWriteish = GUARD_WRITE_TOOLS.has(toolName);
|
|
236
307
|
if (!isWriteish && (GUARD_READ_TOOLS.has(toolName) || coveredByGuardHeuristics(toolName))) return null;
|
|
237
308
|
if (!isExternalAbs2(sessionCwd, p)) return null;
|
|
238
|
-
const why =
|
|
309
|
+
const why = "External changes cannot be tracked or rolled back by pi-task-tracker.";
|
|
239
310
|
if (mode === "ask") {
|
|
240
311
|
if (!ctx?.hasUI || !ctx.ui) return `pi-task-tracker scope guard: '${p}' is outside the session directory and no UI is available to approve external writes. ${why}`;
|
|
241
312
|
const ok = await ctx.ui.confirm(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-task-tracker",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Task workflow tracking for the pi coding agent: TODO/README maintenance, per-task git auto-commits as checkpoints, and an interactive /rollback. Hashline-aware, nested-repo aware, subagent-artifact-safe.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|