pi-task-tracker 0.1.9 → 0.2.1
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 +115 -42
- 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. **`cd`/`Set-Location` are disabled outright** (sandbox design: pi's bash tool runs each command in a fresh child process pinned to the session directory anyway, so a `cd` can never move the session — denying it keeps the analysis simple and the writable scope exactly the trackable scope).
|
|
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(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: sessionCwd, 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(sessionCwd, t);
|
|
156
|
+
return { abs, external: isExternalAbs(abs, sessionCwd) };
|
|
142
157
|
}
|
|
143
158
|
|
|
144
159
|
function isExternalAbs(abs: string, base: string): boolean {
|
|
@@ -152,60 +167,95 @@ 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
|
+
// Analyze one shell command. cd/Set-Location are DISABLED outright (sandbox
|
|
176
|
+
// design: the process cwd is pinned to the session directory, which also
|
|
177
|
+
// removes any need for cd-tracking in this analysis). Returns
|
|
178
|
+
// { block, ask, notify } hits. External-write analysis is best effort:
|
|
179
|
+
// redirect targets, write-command arguments (copy-like: destination only),
|
|
180
|
+
// interpreter payloads mentioning external absolute paths. Git arguments are
|
|
181
|
+
// NOT analyzed (inherited protection: only history-rewriting git asks).
|
|
182
|
+
function guardShellExternalWrite(cmd: string, sessionCwd: string): { block?: string; ask?: string; notify?: string } | null {
|
|
156
183
|
const segments = cmd.split(/\n|&&|\|\||;|\|/).map((s) => s.trim()).filter(Boolean);
|
|
157
|
-
|
|
184
|
+
const out: { block?: string; ask?: string; notify?: string } = {};
|
|
158
185
|
for (const seg of segments) {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
// switch to the real effective cwd even when external: only WRITE ops
|
|
163
|
-
// are analyzed below, so `cd elsewhere && cat f` still passes while
|
|
164
|
-
// `cd elsewhere && touch a.txt` is correctly judged external.
|
|
165
|
-
effCwd = r.abs;
|
|
166
|
-
continue;
|
|
186
|
+
if (/^(cd|sl|set-location)\s/i.test(seg)) {
|
|
187
|
+
out.block = "cd is disabled in this sandbox; run commands from the session directory";
|
|
188
|
+
return out;
|
|
167
189
|
}
|
|
168
|
-
|
|
169
|
-
// 1. redirects: > >> 2> 2>> &> &>> >| (not heredocs, not fd dups like 2>&1)
|
|
190
|
+
// 2. redirects: > >> 2> 2>> &> &>> >| (not heredocs, not fd dups like 2>&1)
|
|
170
191
|
for (const m of seg.matchAll(/(?:^|[^<>&=(])(?:\d?>{1,2}|&>>?|>\|)\s*([^\s;&|]+)/g)) {
|
|
171
192
|
const tgt = m[1];
|
|
172
193
|
if (/^&?\d+$/.test(tgt) || guardIsDevNull(tgt)) continue;
|
|
173
|
-
const r = guardResolve(
|
|
174
|
-
if (r.external)
|
|
194
|
+
const r = guardResolve(sessionCwd, tgt);
|
|
195
|
+
if (r.external) out.block = r.abs;
|
|
175
196
|
}
|
|
176
|
-
|
|
177
|
-
//
|
|
197
|
+
const isGit = /^git(\s|$)/.test(seg);
|
|
198
|
+
// 1. inherited git protection: history-rewriting git asks (matches
|
|
199
|
+
// the old pi-permissions.jsonc bash patterns; prefix match)
|
|
178
200
|
if (isGit) {
|
|
201
|
+
const low = seg.toLowerCase();
|
|
202
|
+
if (GUARD_GIT_ASK.some((p) => low.startsWith(p))) {
|
|
203
|
+
out.ask = seg.slice(0, 80);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (/^git\s+(init|clone)/.test(low)) {
|
|
207
|
+
const target = (seg.split(/\s+/).filter((x) => x && !x.startsWith("-")).pop()) || ".";
|
|
208
|
+
const r = guardResolve(sessionCwd, target);
|
|
209
|
+
if (r.external) {
|
|
210
|
+
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.`;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// external -C destination on any git command
|
|
179
214
|
const c = seg.match(/\s-C\s+(\S+)/);
|
|
180
215
|
if (c) {
|
|
181
|
-
const r = guardResolve(
|
|
182
|
-
if (r.external)
|
|
216
|
+
const r = guardResolve(sessionCwd, c[1]);
|
|
217
|
+
if (r.external) out.block = r.abs;
|
|
183
218
|
}
|
|
184
219
|
continue;
|
|
185
220
|
}
|
|
186
|
-
// 3. write-ish commands:
|
|
221
|
+
// 3. write-ish commands: copy-like checks ONLY the destination (last path
|
|
222
|
+
// argument) so `cp /external/file ./` stays allowed; others check all
|
|
187
223
|
const tokens = seg.split(/\s+/);
|
|
188
224
|
const head = (tokens[0] || "").toLowerCase().replace(/\.exe$/, "");
|
|
189
|
-
const headBase = head.split(/[
|
|
225
|
+
const headBase = head.split(/[\/]/).pop() || head;
|
|
190
226
|
const isPsWrite = GUARD_PS_WRITE_CMDS.has(headBase);
|
|
191
227
|
const isShWrite = GUARD_SHELL_WRITE_CMDS.has(headBase);
|
|
192
|
-
|
|
228
|
+
const isCopy = GUARD_COPY_CMDS.has(headBase);
|
|
229
|
+
const isInterp = GUARD_INTERPRETERS.has(headBase);
|
|
230
|
+
if (!isPsWrite && !isShWrite && !isCopy && !isInterp) continue;
|
|
231
|
+
if (isInterp) {
|
|
232
|
+
// interpreter: block only if the command TEXT names an external path
|
|
233
|
+
for (const m of seg.matchAll(/[A-Za-z]:[\/]\S*|\/(?:[\w.-]+\/)+[\w.-]+/g)) {
|
|
234
|
+
if (guardIsDevNull(m[0])) continue;
|
|
235
|
+
if (isExternalAbs(m[0], sessionCwd)) { out.block = m[0]; break; }
|
|
236
|
+
}
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const pathArgs = [];
|
|
193
240
|
for (let k = 1; k < tokens.length; k++) {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
241
|
+
if (tokens[k].startsWith("-")) continue;
|
|
242
|
+
pathArgs.push(tokens[k]);
|
|
243
|
+
}
|
|
244
|
+
const checkList = isCopy ? pathArgs.slice(-1) : pathArgs;
|
|
245
|
+
for (const t of checkList) {
|
|
246
|
+
if (/^[A-Za-z]:[\\{1,2}]/.test(t) || t.startsWith("/")) { // absolute
|
|
197
247
|
if (guardIsDevNull(t)) continue;
|
|
198
|
-
if (isExternalAbs(t, sessionCwd))
|
|
248
|
+
if (isExternalAbs(t, sessionCwd)) out.block = t;
|
|
199
249
|
} else if (/[\$`~]/.test(t)) {
|
|
200
|
-
|
|
250
|
+
out.block = t; // unresolvable -> conservative
|
|
201
251
|
} else {
|
|
202
|
-
const abs = path.resolve(
|
|
203
|
-
if (isExternalAbs(abs, sessionCwd))
|
|
252
|
+
const abs = path.resolve(sessionCwd, t);
|
|
253
|
+
if (isExternalAbs(abs, sessionCwd)) out.block = t;
|
|
204
254
|
}
|
|
205
|
-
if (
|
|
255
|
+
if (out.block) break;
|
|
206
256
|
}
|
|
207
257
|
}
|
|
208
|
-
return null;
|
|
258
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
209
259
|
}
|
|
210
260
|
|
|
211
261
|
const GUARD_ASK_PROMPT = "pi-task-tracker external-write guard";
|
|
@@ -215,7 +265,7 @@ async function guardExternalWriteHit(
|
|
|
215
265
|
event: { toolName: string; input?: unknown },
|
|
216
266
|
sessionCwd: string,
|
|
217
267
|
mode: "block" | "ask",
|
|
218
|
-
ctx?: { hasUI?: boolean; ui?: { confirm: (t: string, m: string) => Promise<boolean
|
|
268
|
+
ctx?: { hasUI?: boolean; ui?: { confirm: (t: string, m: string) => Promise<boolean>; notify: (m: string, kind?: string) => void } }
|
|
219
269
|
): Promise<string | null> {
|
|
220
270
|
if (!sessionCwd) return null;
|
|
221
271
|
const toolName = event.toolName || "";
|
|
@@ -223,9 +273,32 @@ async function guardExternalWriteHit(
|
|
|
223
273
|
// shell tools: static best-effort analysis (bash/powershell)
|
|
224
274
|
if (toolName === "bash" || toolName === "powershell") {
|
|
225
275
|
const cmd = (event.input as { command?: string } | undefined)?.command || "";
|
|
226
|
-
const
|
|
227
|
-
if (!
|
|
228
|
-
|
|
276
|
+
const hit = guardShellExternalWrite(cmd, sessionCwd);
|
|
277
|
+
if (!hit) return null;
|
|
278
|
+
if (hit.notify && ctx?.hasUI && ctx.ui) ctx.ui.notify(hit.notify, "info");
|
|
279
|
+
if (hit.ask) {
|
|
280
|
+
if (!ctx?.hasUI || !ctx.ui) return `pi-task-tracker guard: history-rewriting git ('${hit.ask}') requires approval but no UI is available.`;
|
|
281
|
+
const ok = await ctx.ui.confirm(
|
|
282
|
+
"pi-task-tracker git guard (inherited)",
|
|
283
|
+
`History-rewriting git command: '${hit.ask}'. This protection was inherited from pi-permission-system. Allow?`,
|
|
284
|
+
);
|
|
285
|
+
if (ok) return null;
|
|
286
|
+
return `pi-task-tracker guard: user denied '${hit.ask}'. Do not retry or bypass.`;
|
|
287
|
+
}
|
|
288
|
+
if (hit.block) {
|
|
289
|
+
const why = "External changes cannot be tracked or rolled back by pi-task-tracker.";
|
|
290
|
+
if (mode === "ask") {
|
|
291
|
+
if (!ctx?.hasUI || !ctx.ui) return `pi-task-tracker scope guard: '${hit.block}' is outside the session directory and no UI is available. ${why}`;
|
|
292
|
+
const ok = await ctx.ui.confirm(
|
|
293
|
+
"pi-task-tracker external-write guard",
|
|
294
|
+
`This command writes to '${hit.block}' outside the session directory. ${why} Allow anyway?`,
|
|
295
|
+
);
|
|
296
|
+
if (ok) return null;
|
|
297
|
+
return `pi-task-tracker scope guard: user denied external write to '${hit.block}'. Do not retry or bypass.`;
|
|
298
|
+
}
|
|
299
|
+
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.`;
|
|
300
|
+
}
|
|
301
|
+
return null;
|
|
229
302
|
}
|
|
230
303
|
|
|
231
304
|
// path-bearing tools
|
|
@@ -235,7 +308,7 @@ async function guardExternalWriteHit(
|
|
|
235
308
|
const isWriteish = GUARD_WRITE_TOOLS.has(toolName);
|
|
236
309
|
if (!isWriteish && (GUARD_READ_TOOLS.has(toolName) || coveredByGuardHeuristics(toolName))) return null;
|
|
237
310
|
if (!isExternalAbs2(sessionCwd, p)) return null;
|
|
238
|
-
const why =
|
|
311
|
+
const why = "External changes cannot be tracked or rolled back by pi-task-tracker.";
|
|
239
312
|
if (mode === "ask") {
|
|
240
313
|
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
314
|
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.1
|
|
3
|
+
"version": "0.2.1",
|
|
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",
|