pi-task-tracker 0.1.8 → 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.
- package/README.md +2 -0
- package/index.ts +174 -4
- 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
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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",
|