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.
Files changed (3) hide show
  1. package/README.md +11 -1
  2. package/index.ts +104 -33
  3. 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", "cp", "touch", "mkdir", "rmdir", "tee", "truncate", "dd", "ln",
123
- "install", "patch", "chmod", "chown", "sed",
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(base: string, token: string): { abs: string; external: boolean } {
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: base, external: false };
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, base) };
153
+ return { abs: t, external: isExternalAbs(t, sessionCwd) };
139
154
  }
140
- const abs = path.resolve(base, t);
141
- return { abs, external: isExternalAbs(abs, base) };
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
- function guardShellExternalWrite(cmd: string, sessionCwd: string): string | null {
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
- const isGit = /^git(\s|$)/.test(seg);
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) return r.abs;
193
+ const r = guardResolve(effCwd, sessionCwd, tgt);
194
+ if (r.external) out.block = r.abs;
175
195
  }
176
- // 2. git segments: arguments are left to pi-permission-system's rules;
177
- // only an external -C destination counts as an external write risk.
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) return r.abs;
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: check every path-looking argument
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(/[\\/]/).pop() || head;
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
- if (!isPsWrite && !isShWrite) continue;
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
- const t = tokens[k];
195
- if (t.startsWith("-")) continue;
196
- if (/^[A-Za-z]:\{1,2}/.test(t) || t.startsWith("/")) { // absolute
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)) return t;
246
+ if (isExternalAbs(t, sessionCwd)) out.block = t;
199
247
  } else if (/[\$`~]/.test(t)) {
200
- return t; // unresolvable -> conservative
248
+ out.block = t; // unresolvable -> conservative
201
249
  } else {
202
250
  const abs = path.resolve(effCwd, t);
203
- if (isExternalAbs(abs, sessionCwd)) return t;
251
+ if (isExternalAbs(abs, sessionCwd)) out.block = t;
204
252
  }
205
- if (isShWrite && (headBase === "sed")) break; // sed: only check -i file once
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 bad = guardShellExternalWrite(cmd, sessionCwd);
227
- if (!bad) return null;
228
- return `pi-task-tracker scope guard (externalWrite=${mode}): this command writes to '${bad}' outside the session directory. External changes cannot be tracked or rolled back. Stay inside the session directory, or have the user adjust taskTracker.externalWrite / run it manually.`;
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 = `External changes cannot be tracked or rolled back by pi-task-tracker.`;
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.1.9",
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",