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.
Files changed (3) hide show
  1. package/README.md +11 -1
  2. package/index.ts +115 -42
  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. **`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", "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(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: 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, 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(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
- 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
+ // 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
- let effCwd = sessionCwd;
184
+ const out: { block?: string; ask?: string; notify?: string } = {};
158
185
  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;
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
- const isGit = /^git(\s|$)/.test(seg);
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(effCwd, tgt);
174
- if (r.external) return r.abs;
194
+ const r = guardResolve(sessionCwd, tgt);
195
+ if (r.external) out.block = r.abs;
175
196
  }
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.
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(effCwd, c[1]);
182
- if (r.external) return r.abs;
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: check every path-looking argument
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(/[\\/]/).pop() || head;
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
- if (!isPsWrite && !isShWrite) continue;
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
- const t = tokens[k];
195
- if (t.startsWith("-")) continue;
196
- if (/^[A-Za-z]:\{1,2}/.test(t) || t.startsWith("/")) { // absolute
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)) return t;
248
+ if (isExternalAbs(t, sessionCwd)) out.block = t;
199
249
  } else if (/[\$`~]/.test(t)) {
200
- return t; // unresolvable -> conservative
250
+ out.block = t; // unresolvable -> conservative
201
251
  } else {
202
- const abs = path.resolve(effCwd, t);
203
- if (isExternalAbs(abs, sessionCwd)) return t;
252
+ const abs = path.resolve(sessionCwd, t);
253
+ if (isExternalAbs(abs, sessionCwd)) out.block = t;
204
254
  }
205
- if (isShWrite && (headBase === "sed")) break; // sed: only check -i file once
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 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.`;
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 = `External changes cannot be tracked or rolled back by pi-task-tracker.`;
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.9",
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",