pi-task-tracker 0.1.7 → 0.1.9

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 +3 -0
  2. package/index.ts +174 -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. 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
 
@@ -85,6 +87,7 @@ Optional `taskTracker` key in `~/.pi/agent/settings.json` (all default to `true`
85
87
 
86
88
  - Rollback granularity is per **task** (agent turn-set), not per message. If you need message-level time travel including your own manual edits, a snapshot-based extension is a better fit — at the cost of full-worktree scans every turn.
87
89
  - Files created by shell commands inside a nested repo that was never touched via write/edit during the session are not captured.
90
+ - **Out-of-scope by design** — files outside the session directory (and outside its owning repo) are neither recorded nor rolled back: no new commits in *their* repos, no worktree changes. `/rollback` only ever touches the session's own repo scope, so sibling projects are safe even when the agent writes there via absolute paths — but such changes have no checkpoint.
88
91
  - An embedded (unregistered) sub-repo shows as `?? dir/` in the outer `git status` — that's standard git behavior; this extension never stages it.
89
92
 
90
93
  ## 本简介(中文)
package/index.ts CHANGED
@@ -106,6 +106,162 @@ 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", "cp", "touch", "mkdir", "rmdir", "tee", "truncate", "dd", "ln",
123
+ "install", "patch", "chmod", "chown", "sed",
124
+ ]);
125
+ const GUARD_PS_WRITE_CMDS = new Set([
126
+ "set-content", "out-file", "add-content", "new-item", "remove-item",
127
+ "copy-item", "move-item", "clear-content", "ni", "ri", "mi", "ac", "sc", "sp",
128
+ ]);
129
+
130
+ // Resolve a shell token to an absolute path; unresolvable tokens (env vars,
131
+ // command substitution) are treated as external - conservative by design.
132
+ function guardResolve(base: string, token: string): { abs: string; external: boolean } {
133
+ let t = token.replace(/^["']|["']$/g, "").trim();
134
+ if (!t) return { abs: base, external: false };
135
+ if (t.startsWith("~")) return { abs: t, external: true };
136
+ if (/[\$`]/.test(t)) return { abs: t, external: true };
137
+ if (/^[A-Za-z]:[\\/]/.test(t) || t.startsWith("/")) {
138
+ return { abs: t, external: isExternalAbs(t, base) };
139
+ }
140
+ const abs = path.resolve(base, t);
141
+ return { abs, external: isExternalAbs(abs, base) };
142
+ }
143
+
144
+ function isExternalAbs(abs: string, base: string): boolean {
145
+ const rel = path.relative(base, abs);
146
+ return rel.startsWith("..") || path.isAbsolute(rel);
147
+ }
148
+
149
+ function guardIsDevNull(t: string): boolean {
150
+ return /^\/dev\/null/i.test(t) || /^nul$/i.test(t) || /^\$null$/i.test(t);
151
+ }
152
+
153
+ // Analyze one shell command for writes that would land outside `sessionCwd`.
154
+ // Returns the offending path (for the deny reason) or null.
155
+ function guardShellExternalWrite(cmd: string, sessionCwd: string): string | null {
156
+ const segments = cmd.split(/\n|&&|\|\||;|\|/).map((s) => s.trim()).filter(Boolean);
157
+ let effCwd = sessionCwd;
158
+ for (const seg of segments) {
159
+ const cd = seg.match(/^cd\s+(.+)$/i) || seg.match(/^sl\s+(.+)$/i) || seg.match(/^set-location\s+(.+)$/i);
160
+ if (cd) {
161
+ const r = guardResolve(effCwd, cd[1]);
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;
167
+ }
168
+ const isGit = /^git(\s|$)/.test(seg);
169
+ // 1. redirects: > >> 2> 2>> &> &>> >| (not heredocs, not fd dups like 2>&1)
170
+ for (const m of seg.matchAll(/(?:^|[^<>&=(])(?:\d?>{1,2}|&>>?|>\|)\s*([^\s;&|]+)/g)) {
171
+ const tgt = m[1];
172
+ if (/^&?\d+$/.test(tgt) || guardIsDevNull(tgt)) continue;
173
+ const r = guardResolve(effCwd, tgt);
174
+ if (r.external) return r.abs;
175
+ }
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.
178
+ if (isGit) {
179
+ const c = seg.match(/\s-C\s+(\S+)/);
180
+ if (c) {
181
+ const r = guardResolve(effCwd, c[1]);
182
+ if (r.external) return r.abs;
183
+ }
184
+ continue;
185
+ }
186
+ // 3. write-ish commands: check every path-looking argument
187
+ const tokens = seg.split(/\s+/);
188
+ const head = (tokens[0] || "").toLowerCase().replace(/\.exe$/, "");
189
+ const headBase = head.split(/[\\/]/).pop() || head;
190
+ const isPsWrite = GUARD_PS_WRITE_CMDS.has(headBase);
191
+ const isShWrite = GUARD_SHELL_WRITE_CMDS.has(headBase);
192
+ if (!isPsWrite && !isShWrite) continue;
193
+ 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
197
+ if (guardIsDevNull(t)) continue;
198
+ if (isExternalAbs(t, sessionCwd)) return t;
199
+ } else if (/[\$`~]/.test(t)) {
200
+ return t; // unresolvable -> conservative
201
+ } else {
202
+ const abs = path.resolve(effCwd, t);
203
+ if (isExternalAbs(abs, sessionCwd)) return t;
204
+ }
205
+ if (isShWrite && (headBase === "sed")) break; // sed: only check -i file once
206
+ }
207
+ }
208
+ return null;
209
+ }
210
+
211
+ const GUARD_ASK_PROMPT = "pi-task-tracker external-write guard";
212
+
213
+ // Returns null (allow), "__ask__" (ask mode, user approved), or a deny reason.
214
+ async function guardExternalWriteHit(
215
+ event: { toolName: string; input?: unknown },
216
+ sessionCwd: string,
217
+ mode: "block" | "ask",
218
+ ctx?: { hasUI?: boolean; ui?: { confirm: (t: string, m: string) => Promise<boolean> } }
219
+ ): Promise<string | null> {
220
+ if (!sessionCwd) return null;
221
+ const toolName = event.toolName || "";
222
+
223
+ // shell tools: static best-effort analysis (bash/powershell)
224
+ if (toolName === "bash" || toolName === "powershell") {
225
+ 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.`;
229
+ }
230
+
231
+ // path-bearing tools
232
+ const input = (event.input || {}) as Record<string, unknown>;
233
+ const p = typeof input.path === "string" ? input.path : typeof input.file_path === "string" ? input.file_path : "";
234
+ if (!p) return null;
235
+ const isWriteish = GUARD_WRITE_TOOLS.has(toolName);
236
+ if (!isWriteish && (GUARD_READ_TOOLS.has(toolName) || coveredByGuardHeuristics(toolName))) return null;
237
+ if (!isExternalAbs2(sessionCwd, p)) return null;
238
+ const why = `External changes cannot be tracked or rolled back by pi-task-tracker.`;
239
+ if (mode === "ask") {
240
+ 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
+ const ok = await ctx.ui.confirm(
242
+ "pi-task-tracker external-write guard",
243
+ `Tool '${toolName}' targets '${p}' outside the session directory. ${why} Allow anyway?`,
244
+ );
245
+ if (ok) return "__ask__";
246
+ return `pi-task-tracker scope guard: user denied external write to '${p}'. Do not retry or bypass.`;
247
+ }
248
+ 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.`;
249
+ }
250
+
251
+ function isExternalAbs2(base: string, p: string): boolean {
252
+ const abs = path.isAbsolute(p) ? p : path.resolve(base, p);
253
+ const rel = path.relative(base, abs);
254
+ return rel.startsWith("..") || path.isAbsolute(rel);
255
+ }
256
+
257
+ // Mirror of pi-permission-system's path-bearing heuristics: tools it already
258
+ // checks (or read-only ones) are left to it.
259
+ function coveredByGuardHeuristics(toolName: string): boolean {
260
+ const t = toolName.trim().toLowerCase();
261
+ const parts = t.split(/[^a-z0-9]+/).filter(Boolean);
262
+ return ["read", "write", "edit", "find", "grep", "search", "list", "ls"].some((s) => t.endsWith(s) || parts.includes(s));
263
+ }
264
+
109
265
  // Repos whose identity was already ensured this process (git config persists,
110
266
  // so one check per repo per pi session is enough - saves 2 spawns per repo
111
267
  // per task).
@@ -340,20 +496,25 @@ interface TrackerConfig {
340
496
  todo: boolean;
341
497
  readme: boolean;
342
498
  autoCommit: boolean;
499
+ /** Writes outside the session directory: "block" (default) | "ask" | "off" */
500
+ externalWrite: "block" | "ask" | "off";
343
501
  }
344
502
 
345
503
  function readConfig(): TrackerConfig {
504
+ const fallback: TrackerConfig = { todo: true, readme: true, autoCommit: true, externalWrite: "block" };
346
505
  try {
347
506
  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"));
507
+ const file = process.env.PI_TRACKER_SETTINGS || path.join(dir, "settings.json");
508
+ const raw = JSON.parse(fs.readFileSync(file, "utf-8"));
349
509
  const t = raw.taskTracker && typeof raw.taskTracker === "object" ? raw.taskTracker : {};
350
510
  return {
351
511
  todo: t.todo !== false,
352
512
  readme: t.readme !== false,
353
513
  autoCommit: t.autoCommit !== false,
514
+ externalWrite: t.externalWrite === "ask" || t.externalWrite === "off" ? t.externalWrite : "block",
354
515
  };
355
516
  } catch {
356
- return { todo: true, readme: true, autoCommit: true };
517
+ return fallback;
357
518
  }
358
519
  }
359
520
 
@@ -434,8 +595,17 @@ export default function (pi: ExtensionAPI) {
434
595
  fs.appendFileSync(todoPath, line, "utf-8");
435
596
  });
436
597
 
437
- pi.on("tool_call", async (event) => {
438
- if (!cwd) return;
598
+ pi.on("tool_call", async (event, ctx) => {
599
+ // External-write guard: keep the writable scope equal to the trackable
600
+ // scope. See the "External-write guard" block above for the policy.
601
+ if (config.externalWrite !== "off") {
602
+ const hit = await guardExternalWriteHit(event, cwd, config.externalWrite === "off" ? "block" : config.externalWrite, ctx);
603
+ if (hit) {
604
+ if (hit === "__ask__") return; // ask mode: user approved in guardExternalWriteHit
605
+ return { block: true, reason: hit };
606
+ }
607
+ }
608
+
439
609
 
440
610
  if (isToolCallEventType("write", event)) {
441
611
  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.7",
3
+ "version": "0.1.9",
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",