pi-task-tracker 0.1.8 → 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 +12 -0
  2. package/index.ts +245 -4
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -16,6 +16,7 @@ Task workflow tracking for the [pi coding agent](https://www.npmjs.com/package/@
16
16
  - **Nested-repo aware** — changes inside a sub-repository (a `.git` below your session root) are committed *there* — the innermost repo wins — and never pollute the outer repo. The outer task commit records anchors (`Nested: <repo>@<sha>`), so `/rollback` restores nested repos to their exact matching commit.
17
17
  - **Pre-existing double tracking respected** — if a file is already tracked by both an outer repo and a nested repo, changes go to the nested repo (innermost wins), pi never untracks the outer copy, and `/rollback` never lets the outer repo restore its stale copy over the inner worktree.
18
18
  - **Subdirectory start** — start pi in a subdirectory of an existing repo and no fresh `.git` is created there: all session changes (including TODO.md / README.md) are recorded in the enclosing repository, and `/rollback` restores the enclosing repo scoped to the session subtree.
19
+ - **Scope guard** — the writable scope equals the trackable scope: out-of-session writes are blocked by default (`taskTracker.externalWrite`), so nothing can be changed that `/rollback` could not restore. Git rules are inherited from `pi-permission-system`.
19
20
  - **Subagent-safe** — `.pi-subagents/` session artifacts are never recorded, listed, or committed.
20
21
 
21
22
  ## Lightweight by design (measured, not claimed)
@@ -74,6 +75,7 @@ Optional `taskTracker` key in `~/.pi/agent/settings.json` (all default to `true`
74
75
  - `todo` — maintain `TODO.md`
75
76
  - `readme` — maintain the `README.md` project-structure section
76
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. 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.
77
79
 
78
80
  ## Requirements
79
81
 
@@ -95,3 +97,13 @@ Optional `taskTracker` key in `~/.pi/agent/settings.json` (all default to `true`
95
97
  ## License
96
98
 
97
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
@@ -106,6 +106,233 @@ function findEnclosingRepo(dir: string): string | null {
106
106
  }
107
107
  }
108
108
 
109
+
110
+ // ---- External-write guard ----
111
+ // Enforces the tracking boundary as a write boundary: pi-task-tracker can
112
+ // only track (and therefore only roll back) the session directory, so by
113
+ // default (config taskTracker.externalWrite="block") tool calls that would
114
+ // create/modify files OUTSIDE it are denied. Reads stay free. Git command
115
+ // protection is inherited from pi-permission-system's bash rules (rebase/
116
+ // reset/amend/force-push/... ask there): this guard never inspects git
117
+ // arguments, only redirect targets and -C destinations on git segments.
118
+
119
+ const GUARD_WRITE_TOOLS = new Set(["write", "edit", "replace", "insert", "undo_last_change"]);
120
+ const GUARD_READ_TOOLS = new Set(["read", "grep", "find", "ls", "anchor_grep", "glob"]);
121
+ const GUARD_SHELL_WRITE_CMDS = new Set([
122
+ "rm", "mv", "touch", "mkdir", "rmdir", "tee", "truncate", "dd",
123
+ "patch", "chmod", "chmod", "chown", "sed",
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;
140
+ const GUARD_PS_WRITE_CMDS = new Set([
141
+ "set-content", "out-file", "add-content", "new-item", "remove-item",
142
+ "copy-item", "move-item", "clear-content", "ni", "ri", "mi", "ac", "sc", "sp",
143
+ ]);
144
+
145
+ // Resolve a shell token to an absolute path; unresolvable tokens (env vars,
146
+ // command substitution) are treated as external - conservative by design.
147
+ function guardResolve(effCwd: string, sessionCwd: string, token: string): { abs: string; external: boolean } {
148
+ let t = token.replace(/^["']|["']$/g, "").trim();
149
+ if (!t) return { abs: effCwd, external: false };
150
+ if (t.startsWith("~")) return { abs: t, external: true };
151
+ if (/[\$`]/.test(t)) return { abs: t, external: true };
152
+ if (/^[A-Za-z]:[\\/]/.test(t) || t.startsWith("/")) {
153
+ return { abs: t, external: isExternalAbs(t, sessionCwd) };
154
+ }
155
+ const abs = path.resolve(effCwd, t);
156
+ return { abs, external: isExternalAbs(abs, sessionCwd) };
157
+ }
158
+
159
+ function isExternalAbs(abs: string, base: string): boolean {
160
+ const rel = path.relative(base, abs);
161
+ return rel.startsWith("..") || path.isAbsolute(rel);
162
+ }
163
+
164
+ function guardIsDevNull(t: string): boolean {
165
+ return /^\/dev\/null/i.test(t) || /^nul$/i.test(t) || /^\$null$/i.test(t);
166
+ }
167
+
168
+ // Analyze one shell command for writes that would land outside `sessionCwd`.
169
+ // Returns the offending path (for the deny reason) or 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 {
176
+ const segments = cmd.split(/\n|&&|\|\||;|\|/).map((s) => s.trim()).filter(Boolean);
177
+ let effCwd = sessionCwd;
178
+ const out: { block?: string; ask?: string; notify?: string } = {};
179
+ for (const seg of segments) {
180
+ const cd = seg.match(/^cd\s+(.+)$/i) || seg.match(/^sl\s+(.+)$/i) || seg.match(/^set-location\s+(.+)$/i);
181
+ if (cd) {
182
+ const r = guardResolve(effCwd, sessionCwd, cd[1]);
183
+ // switch to the real effective cwd even when external: only WRITE ops
184
+ // are analyzed below, so `cd elsewhere && cat f` still passes while
185
+ // `cd elsewhere && touch a.txt` is correctly judged external.
186
+ effCwd = r.abs;
187
+ continue;
188
+ }
189
+ // 2. redirects: > >> 2> 2>> &> &>> >| (not heredocs, not fd dups like 2>&1)
190
+ for (const m of seg.matchAll(/(?:^|[^<>&=(])(?:\d?>{1,2}|&>>?|>\|)\s*([^\s;&|]+)/g)) {
191
+ const tgt = m[1];
192
+ if (/^&?\d+$/.test(tgt) || guardIsDevNull(tgt)) continue;
193
+ const r = guardResolve(effCwd, sessionCwd, tgt);
194
+ if (r.external) out.block = r.abs;
195
+ }
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*")
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
212
+ const c = seg.match(/\s-C\s+(\S+)/);
213
+ if (c) {
214
+ const r = guardResolve(effCwd, sessionCwd, c[1]);
215
+ if (r.external) out.block = r.abs;
216
+ }
217
+ continue;
218
+ }
219
+ // 3. write-ish commands: copy-like checks ONLY the destination (last path
220
+ // argument) so `cp /external/file ./` stays allowed; others check all
221
+ const tokens = seg.split(/\s+/);
222
+ const head = (tokens[0] || "").toLowerCase().replace(/\.exe$/, "");
223
+ const headBase = head.split(/[\/]/).pop() || head;
224
+ const isPsWrite = GUARD_PS_WRITE_CMDS.has(headBase);
225
+ const isShWrite = GUARD_SHELL_WRITE_CMDS.has(headBase);
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 = [];
238
+ for (let k = 1; k < tokens.length; k++) {
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
245
+ if (guardIsDevNull(t)) continue;
246
+ if (isExternalAbs(t, sessionCwd)) out.block = t;
247
+ } else if (/[\$`~]/.test(t)) {
248
+ out.block = t; // unresolvable -> conservative
249
+ } else {
250
+ const abs = path.resolve(effCwd, t);
251
+ if (isExternalAbs(abs, sessionCwd)) out.block = t;
252
+ }
253
+ if (out.block) break;
254
+ }
255
+ }
256
+ return Object.keys(out).length > 0 ? out : null;
257
+ }
258
+
259
+ const GUARD_ASK_PROMPT = "pi-task-tracker external-write guard";
260
+
261
+ // Returns null (allow), "__ask__" (ask mode, user approved), or a deny reason.
262
+ async function guardExternalWriteHit(
263
+ event: { toolName: string; input?: unknown },
264
+ sessionCwd: string,
265
+ mode: "block" | "ask",
266
+ ctx?: { hasUI?: boolean; ui?: { confirm: (t: string, m: string) => Promise<boolean>; notify: (m: string, kind?: string) => void } }
267
+ ): Promise<string | null> {
268
+ if (!sessionCwd) return null;
269
+ const toolName = event.toolName || "";
270
+
271
+ // shell tools: static best-effort analysis (bash/powershell)
272
+ if (toolName === "bash" || toolName === "powershell") {
273
+ const cmd = (event.input as { command?: string } | undefined)?.command || "";
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;
300
+ }
301
+
302
+ // path-bearing tools
303
+ const input = (event.input || {}) as Record<string, unknown>;
304
+ const p = typeof input.path === "string" ? input.path : typeof input.file_path === "string" ? input.file_path : "";
305
+ if (!p) return null;
306
+ const isWriteish = GUARD_WRITE_TOOLS.has(toolName);
307
+ if (!isWriteish && (GUARD_READ_TOOLS.has(toolName) || coveredByGuardHeuristics(toolName))) return null;
308
+ if (!isExternalAbs2(sessionCwd, p)) return null;
309
+ const why = "External changes cannot be tracked or rolled back by pi-task-tracker.";
310
+ if (mode === "ask") {
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}`;
312
+ const ok = await ctx.ui.confirm(
313
+ "pi-task-tracker external-write guard",
314
+ `Tool '${toolName}' targets '${p}' outside the session directory. ${why} Allow anyway?`,
315
+ );
316
+ if (ok) return "__ask__";
317
+ return `pi-task-tracker scope guard: user denied external write to '${p}'. Do not retry or bypass.`;
318
+ }
319
+ return `pi-task-tracker scope guard (externalWrite=block): tool '${toolName}' targets '${p}' outside the session directory. ${why} Ask the user to adjust taskTracker.externalWrite or perform the change manually.`;
320
+ }
321
+
322
+ function isExternalAbs2(base: string, p: string): boolean {
323
+ const abs = path.isAbsolute(p) ? p : path.resolve(base, p);
324
+ const rel = path.relative(base, abs);
325
+ return rel.startsWith("..") || path.isAbsolute(rel);
326
+ }
327
+
328
+ // Mirror of pi-permission-system's path-bearing heuristics: tools it already
329
+ // checks (or read-only ones) are left to it.
330
+ function coveredByGuardHeuristics(toolName: string): boolean {
331
+ const t = toolName.trim().toLowerCase();
332
+ const parts = t.split(/[^a-z0-9]+/).filter(Boolean);
333
+ return ["read", "write", "edit", "find", "grep", "search", "list", "ls"].some((s) => t.endsWith(s) || parts.includes(s));
334
+ }
335
+
109
336
  // Repos whose identity was already ensured this process (git config persists,
110
337
  // so one check per repo per pi session is enough - saves 2 spawns per repo
111
338
  // per task).
@@ -340,20 +567,25 @@ interface TrackerConfig {
340
567
  todo: boolean;
341
568
  readme: boolean;
342
569
  autoCommit: boolean;
570
+ /** Writes outside the session directory: "block" (default) | "ask" | "off" */
571
+ externalWrite: "block" | "ask" | "off";
343
572
  }
344
573
 
345
574
  function readConfig(): TrackerConfig {
575
+ const fallback: TrackerConfig = { todo: true, readme: true, autoCommit: true, externalWrite: "block" };
346
576
  try {
347
577
  const dir = process.env.PI_CODING_AGENT_DIR || path.join(os.homedir(), ".pi", "agent");
348
- const raw = JSON.parse(fs.readFileSync(path.join(dir, "settings.json"), "utf-8"));
578
+ const file = process.env.PI_TRACKER_SETTINGS || path.join(dir, "settings.json");
579
+ const raw = JSON.parse(fs.readFileSync(file, "utf-8"));
349
580
  const t = raw.taskTracker && typeof raw.taskTracker === "object" ? raw.taskTracker : {};
350
581
  return {
351
582
  todo: t.todo !== false,
352
583
  readme: t.readme !== false,
353
584
  autoCommit: t.autoCommit !== false,
585
+ externalWrite: t.externalWrite === "ask" || t.externalWrite === "off" ? t.externalWrite : "block",
354
586
  };
355
587
  } catch {
356
- return { todo: true, readme: true, autoCommit: true };
588
+ return fallback;
357
589
  }
358
590
  }
359
591
 
@@ -434,8 +666,17 @@ export default function (pi: ExtensionAPI) {
434
666
  fs.appendFileSync(todoPath, line, "utf-8");
435
667
  });
436
668
 
437
- pi.on("tool_call", async (event) => {
438
- if (!cwd) return;
669
+ pi.on("tool_call", async (event, ctx) => {
670
+ // External-write guard: keep the writable scope equal to the trackable
671
+ // scope. See the "External-write guard" block above for the policy.
672
+ if (config.externalWrite !== "off") {
673
+ const hit = await guardExternalWriteHit(event, cwd, config.externalWrite === "off" ? "block" : config.externalWrite, ctx);
674
+ if (hit) {
675
+ if (hit === "__ask__") return; // ask mode: user approved in guardExternalWriteHit
676
+ return { block: true, reason: hit };
677
+ }
678
+ }
679
+
439
680
 
440
681
  if (isToolCallEventType("write", event)) {
441
682
  const p = event.input.path as string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-task-tracker",
3
- "version": "0.1.8",
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",