pi-scout 0.1.2 → 0.1.4

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/CHANGELOG.md CHANGED
@@ -1,11 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.4 - 2026-07-17
4
+
3
5
  All notable changes to this project will be documented in this file.
4
6
 
5
7
  This project follows the spirit of [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and uses semantic versioning for releases.
6
8
 
7
9
  ## [Unreleased]
8
10
 
11
+ ### Fixed
12
+
13
+ - Store temporary clones in a private per-user directory and reject unsafe clone roots.
14
+ - Serialize repository state mutations and persist state through atomic file replacement.
15
+
16
+ ## [0.1.3] - 2026-07-01
17
+
18
+ ### Changed
19
+
20
+ - Update Pi core development dependency for Pi 0.80 compatibility.
21
+
9
22
  ## [0.1.2] - 2026-06-06
10
23
 
11
24
  ### Changed
package/README.md CHANGED
@@ -13,7 +13,7 @@ A source-distributed [Pi](https://pi.dev) package for registering local referenc
13
13
  - Compact per-turn system prompt guidance with registered repo names and local clone paths.
14
14
  - Automatic pruning: if the OS cleans a temporary clone, Pi Scout removes that stale entry before adding prompt context.
15
15
 
16
- Registered repositories are cloned under `/tmp/pi-scout/<name>-<id>` on Unix-like systems, or the OS temp directory on Windows. Set `PI_SCOUT_TMPDIR` to override the parent temp directory. Pi Scout uses shallow clones with depth `1` by default because it is for code exploration, not history exploration. Pi Scout keeps records in Pi's agent directory and reuses them across sessions while the cloned directories still exist.
16
+ Registered repositories are cloned in a private, per-user directory under the OS temp directory (`<temp>/pi-scout-<uid>` on Unix-like systems). Root and clone permissions are restricted to the current user on Unix. Set `PI_SCOUT_TMPDIR` to override the parent temp directory. Pi Scout uses shallow clones with depth `1` by default because it is for code exploration, not history exploration. Pi Scout keeps records in Pi's agent directory and reuses them across sessions while the cloned directories still exist.
17
17
 
18
18
  ## Installation
19
19
 
@@ -87,6 +87,7 @@ After a repository is registered, the agent sees its local path in the system pr
87
87
  - Pi Scout uses local file access for exploration. It does not provide web search or remote content-fetching tools.
88
88
  - Registering a Git URL still uses `git clone`, so Git may contact the configured remote.
89
89
  - Registered repositories are intended as read-only references unless the user explicitly asks otherwise.
90
+ - Repository state changes are serialized and persisted with atomic file replacement.
90
91
  - The system prompt includes only registered repo names and local paths, not origin URLs or branch metadata.
91
92
 
92
93
  ## Development
@@ -94,5 +95,6 @@ After a repository is registered, the agent sees its local path in the system pr
94
95
  ```bash
95
96
  npm install
96
97
  npm run check
98
+ npm test
97
99
  npm run pack:dry-run
98
100
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-scout",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Register local reference codebases for Pi agent exploration.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -44,6 +44,7 @@
44
44
  "scripts": {
45
45
  "check": "tsc --noEmit",
46
46
  "typecheck": "tsc --noEmit",
47
+ "test": "node --import tsx --test tests/*.test.mjs",
47
48
  "pack:dry-run": "npm pack --dry-run"
48
49
  },
49
50
  "peerDependencies": {
@@ -51,9 +52,10 @@
51
52
  "typebox": "*"
52
53
  },
53
54
  "devDependencies": {
54
- "@earendil-works/pi-coding-agent": "^0.75.4",
55
- "@types/node": "^25.6.2",
56
- "typebox": "^1.1.33",
55
+ "@earendil-works/pi-coding-agent": "^0.80.0",
56
+ "@types/node": "^26.1.0",
57
+ "tsx": "^4.23.0",
58
+ "typebox": "^1.3.4",
57
59
  "typescript": "^6.0.3"
58
60
  },
59
61
  "publishConfig": {
package/src/repo.ts CHANGED
@@ -1,9 +1,9 @@
1
- import { mkdir, rm } from "node:fs/promises";
1
+ import { chmod, lstat, mkdir, rm } from "node:fs/promises";
2
2
  import { basename, join } from "node:path";
3
3
  import { platform, tmpdir } from "node:os";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
- import { loadPrunedState, saveState, type ScoutRepo } from "./state.js";
6
+ import { mutateState, type ScoutRepo } from "./state.js";
7
7
 
8
8
  export interface RegisterRepoOptions {
9
9
  source: string;
@@ -21,7 +21,8 @@ export async function registerRepo(pi: ExtensionAPI, options: RegisterRepoOption
21
21
  const name = sanitizeName(options.name?.trim() || inferName(source) || `repo-${id}`);
22
22
  const root = getScoutCloneRoot();
23
23
  const destination = join(root, `${name}-${id}`);
24
- await mkdir(root, { recursive: true });
24
+ await ensurePrivateCloneRoot(root);
25
+ await mkdir(destination, { mode: 0o700 });
25
26
 
26
27
  const args = ["clone"];
27
28
  if (options.branch?.trim()) args.push("--branch", options.branch.trim());
@@ -31,9 +32,11 @@ export async function registerRepo(pi: ExtensionAPI, options: RegisterRepoOption
31
32
 
32
33
  const result = await pi.exec("git", args, { signal: options.signal, timeout: 120_000 });
33
34
  if (result.code !== 0) {
35
+ await rm(destination, { recursive: true, force: true });
34
36
  const stderr = result.stderr?.trim() || result.stdout?.trim() || "git clone failed";
35
37
  throw new Error(stderr);
36
38
  }
39
+ if (platform() !== "win32") await chmod(destination, 0o700);
37
40
 
38
41
  const now = new Date().toISOString();
39
42
  const repo: ScoutRepo = {
@@ -46,9 +49,9 @@ export async function registerRepo(pi: ExtensionAPI, options: RegisterRepoOption
46
49
  lastSeenAt: now,
47
50
  };
48
51
 
49
- const state = await loadPrunedState();
50
- state.repos.push(repo);
51
- await saveState(state);
52
+ await mutateState((state) => {
53
+ state.repos.push(repo);
54
+ });
52
55
  return repo;
53
56
  }
54
57
 
@@ -56,12 +59,11 @@ export async function removeRepo(idOrName: string, options: { deleteClone?: bool
56
59
  const needle = idOrName.trim();
57
60
  if (!needle) return undefined;
58
61
 
59
- const state = await loadPrunedState();
60
- const index = state.repos.findIndex((repo) => repo.id === needle || repo.name === needle);
61
- if (index === -1) return undefined;
62
-
63
- const [removed] = state.repos.splice(index, 1);
64
- await saveState(state);
62
+ const removed = await mutateState((state) => {
63
+ const index = state.repos.findIndex((repo) => repo.id === needle || repo.name === needle);
64
+ if (index === -1) return undefined;
65
+ return state.repos.splice(index, 1)[0];
66
+ });
65
67
 
66
68
  if (options.deleteClone && removed) {
67
69
  await rm(removed.path, { recursive: true, force: true });
@@ -75,10 +77,29 @@ export function formatRepo(repo: ScoutRepo): string {
75
77
  return `${repo.name}${branch}\n id: ${repo.id}\n source: ${repo.source}\n path: ${repo.path}`;
76
78
  }
77
79
 
78
- function getScoutCloneRoot(): string {
79
- if (process.env.PI_SCOUT_TMPDIR) return join(process.env.PI_SCOUT_TMPDIR, "pi-scout");
80
- if (platform() !== "win32") return "/tmp/pi-scout";
81
- return join(tmpdir(), "pi-scout");
80
+ export function getScoutCloneRoot(): string {
81
+ const parent = process.env.PI_SCOUT_TMPDIR || tmpdir();
82
+ if (platform() === "win32") return join(parent, "pi-scout");
83
+ return join(parent, `pi-scout-${getCurrentUid()}`);
84
+ }
85
+
86
+ export async function ensurePrivateCloneRoot(root: string): Promise<void> {
87
+ await mkdir(root, { recursive: true, mode: 0o700 });
88
+ if (platform() === "win32") return;
89
+
90
+ const info = await lstat(root);
91
+ if (!info.isDirectory() || info.isSymbolicLink()) {
92
+ throw new Error(`Unsafe Pi Scout clone root: ${root} is not a real directory.`);
93
+ }
94
+ if (info.uid !== getCurrentUid()) {
95
+ throw new Error(`Unsafe Pi Scout clone root: ${root} is not owned by current user.`);
96
+ }
97
+ await chmod(root, 0o700);
98
+ }
99
+
100
+ function getCurrentUid(): number {
101
+ if (!process.getuid) throw new Error("Pi Scout cannot determine current user ID.");
102
+ return process.getuid();
82
103
  }
83
104
 
84
105
  function inferName(source: string): string {
package/src/state.ts CHANGED
@@ -1,6 +1,7 @@
1
- import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
- import { join } from "node:path";
3
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
1
+ import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
2
+ import { basename, dirname, join } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { getAgentDir, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
4
5
 
5
6
  export interface ScoutRepo {
6
7
  id: string;
@@ -36,7 +37,25 @@ export async function loadState(): Promise<ScoutState> {
36
37
 
37
38
  export async function saveState(state: ScoutState): Promise<void> {
38
39
  await mkdir(STATE_DIR, { recursive: true });
39
- await writeFile(STATE_PATH, `${JSON.stringify(state, null, 2)}\n`, "utf8");
40
+ const temporaryPath = join(dirname(STATE_PATH), `.${basename(STATE_PATH)}.${randomUUID()}.tmp`);
41
+ try {
42
+ await writeFile(temporaryPath, `${JSON.stringify(state, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
43
+ await rename(temporaryPath, STATE_PATH);
44
+ } catch (error) {
45
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
46
+ throw error;
47
+ }
48
+ }
49
+
50
+ export async function mutateState<T>(
51
+ mutation: (state: ScoutState) => Promise<T> | T,
52
+ ): Promise<T> {
53
+ return withFileMutationQueue(STATE_PATH, async () => {
54
+ const state = (await pruneMissingRepos(await loadState())).state;
55
+ const result = await mutation(state);
56
+ await saveState(state);
57
+ return result;
58
+ });
40
59
  }
41
60
 
42
61
  export async function pruneMissingRepos(state: ScoutState): Promise<{ state: ScoutState; removed: ScoutRepo[] }> {
@@ -52,13 +71,15 @@ export async function pruneMissingRepos(state: ScoutState): Promise<{ state: Sco
52
71
  }
53
72
  }
54
73
 
55
- const next = { repos };
56
- if (removed.length > 0) await saveState(next);
57
- return { state: next, removed };
74
+ return { state: { repos }, removed };
58
75
  }
59
76
 
60
77
  export async function loadPrunedState(): Promise<ScoutState> {
61
- return (await pruneMissingRepos(await loadState())).state;
78
+ return withFileMutationQueue(STATE_PATH, async () => {
79
+ const { state, removed } = await pruneMissingRepos(await loadState());
80
+ if (removed.length > 0) await saveState(state);
81
+ return state;
82
+ });
62
83
  }
63
84
 
64
85
  async function pathExists(path: string): Promise<boolean> {