@andares/pi-toolkits 0.10.0 → 0.11.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 (40) hide show
  1. package/README.md +88 -2
  2. package/README.zh-CN.md +72 -2
  3. package/package.json +7 -3
  4. package/src/features/ask/index.ts +2 -1
  5. package/src/features/ask/prompt.ts +2 -1
  6. package/src/features/init/analyze.ts +162 -0
  7. package/src/features/init/apply.test.ts +264 -0
  8. package/src/features/init/apply.ts +693 -0
  9. package/src/features/init/audit.test.ts +292 -0
  10. package/src/features/init/audit.ts +586 -0
  11. package/src/features/init/coverage.test.ts +93 -0
  12. package/src/features/init/coverage.ts +163 -0
  13. package/src/features/init/detect.test.ts +237 -0
  14. package/src/features/init/detect.ts +808 -0
  15. package/src/features/init/dispatch.ts +135 -0
  16. package/src/features/init/index.test.ts +331 -0
  17. package/src/features/init/index.ts +569 -0
  18. package/src/features/init/plan.test.ts +261 -0
  19. package/src/features/init/plan.ts +867 -0
  20. package/src/features/init/render.ts +131 -0
  21. package/src/features/init/spec.ts +429 -0
  22. package/src/features/init/templates/agents-skeleton.md +44 -0
  23. package/src/features/init/templates/memory-readme.md +99 -0
  24. package/src/features/init/templates/signpost.md +15 -0
  25. package/src/features/init/templates/state-gitignore +6 -0
  26. package/src/features/init/templates.test.ts +58 -0
  27. package/src/features/init/templates.ts +79 -0
  28. package/src/features/init/test-helpers.ts +89 -0
  29. package/src/features/init/tool.ts +72 -0
  30. package/src/features/init/types.ts +284 -0
  31. package/src/features/init/ui.ts +160 -0
  32. package/src/features/permission/audit.test.ts +10 -10
  33. package/src/features/permission/constants.test.ts +28 -2
  34. package/src/features/permission/constants.ts +28 -3
  35. package/src/features/permission/index.test.ts +76 -33
  36. package/src/features/permission/policy.test.ts +48 -11
  37. package/src/features/permission/policy.ts +16 -1
  38. package/src/features/permission/units.test.ts +25 -9
  39. package/src/features/permission/units.ts +49 -24
  40. package/src/index.ts +3 -0
@@ -0,0 +1,284 @@
1
+ /**
2
+ * init feature — types shared by detect / plan / apply / audit.
3
+ *
4
+ * The split is intentional: `Facts` is what the disk says (no judgement),
5
+ * `Plan` is what the command intends to do about it (deterministic actions for
6
+ * code, tasks for the model), and `Finding` is what the audit can prove.
7
+ */
8
+ import type { ProjectLanguage } from "./spec.js";
9
+
10
+ export type Severity = "error" | "warning" | "info";
11
+
12
+ /** One audit result. `code` is stable so reports stay machine-readable. */
13
+ export interface Finding {
14
+ readonly code: string;
15
+ readonly severity: Severity;
16
+ readonly message: string;
17
+ readonly path?: string;
18
+ readonly line?: number;
19
+ }
20
+
21
+ /* -------------------------------------------------------------------------- */
22
+ /* Facts */
23
+ /* -------------------------------------------------------------------------- */
24
+
25
+ export interface FileInfo {
26
+ /** Path relative to the project root, POSIX separators. */
27
+ readonly rel: string;
28
+ readonly bytes: number;
29
+ readonly lines: number;
30
+ readonly mtimeMs: number;
31
+ }
32
+
33
+ export interface Heading {
34
+ readonly level: number;
35
+ readonly text: string;
36
+ readonly line: number;
37
+ }
38
+
39
+ export interface DocInfo extends FileInfo {
40
+ readonly text: string;
41
+ readonly headings: readonly Heading[];
42
+ }
43
+
44
+ export interface RootInfo {
45
+ readonly label: string;
46
+ /** Path as registered (relative to the project root when inside it). */
47
+ readonly path: string;
48
+ readonly absolute: string;
49
+ readonly role?: string;
50
+ readonly agentsMd: "none" | "scope-card" | "independent" | "unknown";
51
+ readonly source: "state" | "add-dir";
52
+ readonly exists: boolean;
53
+ readonly lastSeen?: string;
54
+ }
55
+
56
+ export interface FrameworkState {
57
+ version: number;
58
+ tool: string;
59
+ created: string;
60
+ updated: string;
61
+ profile: "core" | "full";
62
+ modules: string[];
63
+ lang: ProjectLanguage;
64
+ budgets?: {
65
+ agentsMdSoftChars?: number;
66
+ agentsMdHardChars?: number;
67
+ memoryFileChars?: number;
68
+ };
69
+ roots: RootInfo[];
70
+ managed: Array<{ path: string; sha256: string; template: string }>;
71
+ migrations: Array<{ at: string; to: string; notes?: string }>;
72
+ }
73
+
74
+ export interface RefArea {
75
+ files: number;
76
+ hits: number;
77
+ }
78
+
79
+ /** Bare `memory/` / `implementations/` references that a move would break. */
80
+ export interface RefScan {
81
+ /** Text files inspected. */
82
+ files: number;
83
+ /** Bare occurrences (already excludes `.agents/…` and `my-memory/`-style). */
84
+ hits: number;
85
+ byArea: Record<string, RefArea>;
86
+ examples: Array<{ path: string; line: number; text: string }>;
87
+ /** Files that contain at least one bare occurrence. */
88
+ paths: string[];
89
+ }
90
+
91
+ export interface ProjectFacts {
92
+ manifests: string[];
93
+ lockfiles: string[];
94
+ license?: string;
95
+ ci: string[];
96
+ scripts: Record<string, string>;
97
+ pkgManager?: string;
98
+ }
99
+
100
+ export interface ContextFiles {
101
+ agents?: DocInfo;
102
+ claude?: DocInfo;
103
+ override?: DocInfo;
104
+ piAgents?: DocInfo;
105
+ }
106
+
107
+ export interface Facts {
108
+ root: string;
109
+ isGit: boolean;
110
+ gitClean?: boolean;
111
+ gitDirtyCount?: number;
112
+ /** Present when the state file exists and parsed. */
113
+ state?: FrameworkState;
114
+ stateError?: string;
115
+ context: ContextFiles;
116
+ /** Framework-relevant directories that exist (relative paths). */
117
+ dirs: Record<string, boolean>;
118
+ /** Legacy locations that the framework wants under `.agents/`. */
119
+ deprecated: { memory: boolean; implementations: boolean; prompts: boolean };
120
+ /** File counts for the three legacy locations (all file types). */
121
+ deprecatedFiles: { memory: number; implementations: number; prompts: number };
122
+ /** `todos/` is retired; content becomes specs. */
123
+ retiredTodos: boolean;
124
+ /** Zero-byte files sitting at the root (crash artefacts, placeholders). */
125
+ rootEmptyFiles: string[];
126
+ /** Memory docs found at the canonical location, sorted by name. */
127
+ memory: DocInfo[];
128
+ /** Extra memory docs found in a legacy `memory/` directory. */
129
+ legacyMemory: DocInfo[];
130
+ specs: DocInfo[];
131
+ implementations: FileInfo[];
132
+ todos: DocInfo[];
133
+ legacyDocs: DocInfo[];
134
+ nestedAgents: FileInfo[];
135
+ /** Memory-module ids that already have a file (design, terms, review, …). */
136
+ modulesPresent: string[];
137
+ refs: RefScan;
138
+ project: ProjectFacts;
139
+ language: ProjectLanguage;
140
+ roots: RootInfo[];
141
+ warnings: string[];
142
+ }
143
+
144
+ /* -------------------------------------------------------------------------- */
145
+ /* Plan */
146
+ /* -------------------------------------------------------------------------- */
147
+
148
+ export type PlanMode =
149
+ | "fresh"
150
+ | "adopt"
151
+ | "reconcile"
152
+ | "upgrade"
153
+ | "audit-only"
154
+ | "unsupported";
155
+
156
+ export interface SnapshotAction {
157
+ readonly kind: "snapshot";
158
+ readonly paths: string[];
159
+ readonly files: number;
160
+ }
161
+
162
+ export interface MoveAction {
163
+ readonly kind: "move-dir";
164
+ readonly from: string;
165
+ readonly to: string;
166
+ readonly files: number;
167
+ /** untracked files ride along with a plain rename instead of `git mv`. */
168
+ readonly trackedOnly: boolean;
169
+ }
170
+
171
+ export interface MoveFileAction {
172
+ readonly kind: "move-file";
173
+ readonly from: string;
174
+ readonly to: string;
175
+ }
176
+
177
+ export interface RewriteAction {
178
+ readonly kind: "rewrite-refs";
179
+ /** Applied in order; first match wins (longest prefixes first). */
180
+ readonly pairs: Array<{ from: string; to: string }>;
181
+ readonly files: number;
182
+ readonly hits: number;
183
+ }
184
+
185
+ export interface CreateFileAction {
186
+ readonly kind: "create-file";
187
+ readonly path: string;
188
+ /** Template name in `templates/` or "inline" with `content`. */
189
+ readonly template: string;
190
+ readonly tokens: Record<string, string>;
191
+ readonly note: string;
192
+ }
193
+
194
+ export interface CreateDirsAction {
195
+ readonly kind: "create-dirs";
196
+ readonly paths: string[];
197
+ }
198
+
199
+ export interface WriteStateAction {
200
+ readonly kind: "write-state";
201
+ readonly state: FrameworkState;
202
+ }
203
+
204
+ export type Action =
205
+ | SnapshotAction
206
+ | MoveAction
207
+ | MoveFileAction
208
+ | RewriteAction
209
+ | CreateDirsAction
210
+ | CreateFileAction
211
+ | WriteStateAction;
212
+
213
+ export type TaskKind =
214
+ | "survey"
215
+ | "edit-agents"
216
+ | "merge-plan"
217
+ | "merge-legacy"
218
+ | "triage-todos"
219
+ | "fill-file"
220
+ | "root-cards"
221
+ | "budget-debt"
222
+ | "coverage";
223
+
224
+ export interface Task {
225
+ readonly id: string;
226
+ readonly kind: TaskKind;
227
+ readonly detail: string;
228
+ readonly targets?: string[];
229
+ readonly acceptance: string[];
230
+ }
231
+
232
+ export interface Plan {
233
+ readonly mode: PlanMode;
234
+ readonly root: string;
235
+ readonly language: ProjectLanguage;
236
+ readonly frameworkVersion: number;
237
+ readonly toolVersion: string;
238
+ readonly createdAt: string;
239
+ readonly actions: Action[];
240
+ readonly tasks: Task[];
241
+ /** Paths this run writes for the model and the operator. */
242
+ readonly artifacts: { plan?: string; coverage?: string; rewrite?: string; snapshot?: string };
243
+ /** Human summary lines shown in the confirmation panel. */
244
+ readonly summary: string[];
245
+ readonly warnings: string[];
246
+ readonly facts: Facts;
247
+ }
248
+
249
+ /* -------------------------------------------------------------------------- */
250
+ /* Apply */
251
+ /* -------------------------------------------------------------------------- */
252
+
253
+ export interface ApplyResult {
254
+ readonly snapshot?: { dir: string; files: number };
255
+ readonly moved: Array<{ from: string; to: string; files: number }>;
256
+ readonly rewrites?: { files: number; hits: number; report: string };
257
+ readonly created: string[];
258
+ readonly skipped: Array<{ path: string; reason: string }>;
259
+ readonly statePath?: string;
260
+ /** Machine-readable work order for the model stage. */
261
+ readonly planPath?: string;
262
+ readonly summary: string[];
263
+ }
264
+
265
+ /* -------------------------------------------------------------------------- */
266
+ /* Coverage */
267
+ /* -------------------------------------------------------------------------- */
268
+
269
+ export type CoverageStatus = "hit" | "mapped" | "open";
270
+
271
+ export interface CoverageRow {
272
+ readonly source: string;
273
+ readonly heading: string;
274
+ readonly status: CoverageStatus;
275
+ readonly destination?: string;
276
+ readonly reason?: string;
277
+ }
278
+
279
+ export interface CoverageReport {
280
+ readonly rows: CoverageRow[];
281
+ readonly open: number;
282
+ readonly mapped: number;
283
+ readonly hit: number;
284
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * init feature — the plan preview and confirmation.
3
+ *
4
+ * Migration moves files, so the operator must see what will happen before it
5
+ * happens. Three paths:
6
+ *
7
+ * - **TUI**: a bounded preview panel (summary, warnings, task ids) with one
8
+ * keystroke answers — `y`/Enter applies, `d` writes nothing (dry run),
9
+ * Esc cancels.
10
+ * - **dialog-capable non-TUI** (RPC): the host's selector.
11
+ * - **no UI** (print/json): fail closed — report only, never write.
12
+ *
13
+ * The preview is bounded on purpose: a plan can list hundreds of tasks, and a
14
+ * panel that scrolls off the screen is worse than a summary plus the full plan
15
+ * written to `.agents/framework/plans/` on the next step.
16
+ */
17
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
18
+ import { type Component, matchesKey, Text } from "@earendil-works/pi-tui";
19
+ import type { Plan } from "./types.js";
20
+
21
+ export type PlanDecision = "proceed" | "dry-run" | "cancel";
22
+
23
+ const MAX_PREVIEW_LINES = 16;
24
+
25
+ function previewLines(plan: Plan): string[] {
26
+ const lines: string[] = [`/ask-init — ${plan.mode}`, ""];
27
+ lines.push(...plan.summary);
28
+ if (plan.warnings.length > 0) {
29
+ lines.push("", "warnings:");
30
+ for (const warning of plan.warnings.slice(0, 4)) lines.push(` ! ${warning}`);
31
+ if (plan.warnings.length > 4) lines.push(` … ${plan.warnings.length - 4} more`);
32
+ }
33
+ if (plan.tasks.length > 0) {
34
+ lines.push("", `model tasks: ${plan.tasks.map((task) => task.id).join(", ")}`);
35
+ }
36
+ if (lines.length > MAX_PREVIEW_LINES) {
37
+ lines.splice(MAX_PREVIEW_LINES);
38
+ lines.push("…");
39
+ }
40
+ return lines;
41
+ }
42
+
43
+ class PlanPanel implements Component {
44
+ constructor(
45
+ private readonly body: Text,
46
+ private readonly footer: Text,
47
+ private readonly done: (decision: PlanDecision) => void,
48
+ ) {}
49
+
50
+ handleInput(data: string): void {
51
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
52
+ this.done("cancel");
53
+ return;
54
+ }
55
+ if (matchesKey(data, "enter") || data === "y" || data === "Y") {
56
+ this.done("proceed");
57
+ return;
58
+ }
59
+ if (data === "d" || data === "D") {
60
+ this.done("dry-run");
61
+ return;
62
+ }
63
+ }
64
+
65
+ render(width: number): string[] {
66
+ return [...this.body.render(width), ...this.footer.render(width)];
67
+ }
68
+
69
+ invalidate(): void {
70
+ this.body.invalidate();
71
+ this.footer.invalidate();
72
+ }
73
+ }
74
+
75
+ /** Ask the operator what to do with this plan. */
76
+ export async function confirmPlan(
77
+ ctx: ExtensionContext,
78
+ plan: Plan,
79
+ ): Promise<PlanDecision> {
80
+ if (!ctx.hasUI) return "dry-run";
81
+
82
+ if (ctx.mode !== "tui") {
83
+ const choice = await ctx.ui.select(
84
+ `/ask-init ${plan.mode}: ${plan.summary[0] ?? ""}`,
85
+ ["Proceed", "Report only (no writes)", "Cancel"],
86
+ );
87
+ if (choice === "Proceed") return "proceed";
88
+ if (choice === "Report only (no writes)") return "dry-run";
89
+ return "cancel";
90
+ }
91
+
92
+ return ctx.ui.custom<PlanDecision>((_tui, _theme, _keybindings, done) => {
93
+ const body = new Text(previewLines(plan).join("\n"));
94
+ const footer = new Text(
95
+ "[Enter/y] apply [d] report only, write nothing [Esc] cancel",
96
+ );
97
+ return new PlanPanel(body, footer, done);
98
+ });
99
+ }
100
+
101
+ /* -------------------------------------------------------------------------- */
102
+ /* Read-only report panel */
103
+ /* -------------------------------------------------------------------------- */
104
+
105
+ const MAX_REPORT_LINES = 22;
106
+
107
+ class ReportPanel implements Component {
108
+ constructor(
109
+ private readonly body: Text,
110
+ private readonly footer: Text,
111
+ private readonly done: () => void,
112
+ ) {}
113
+
114
+ handleInput(data: string): void {
115
+ if (
116
+ matchesKey(data, "escape") ||
117
+ matchesKey(data, "enter") ||
118
+ matchesKey(data, "ctrl+c") ||
119
+ data === "q"
120
+ ) {
121
+ this.done();
122
+ }
123
+ }
124
+
125
+ render(width: number): string[] {
126
+ return [...this.body.render(width), ...this.footer.render(width)];
127
+ }
128
+
129
+ invalidate(): void {
130
+ this.body.invalidate();
131
+ this.footer.invalidate();
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Show a report (plan or audit) without writing anything. In TUI it opens a
137
+ * bounded panel; elsewhere the first lines go through `notify`, which is the
138
+ * only output channel print/json modes have.
139
+ */
140
+ export async function showReport(
141
+ ctx: ExtensionContext,
142
+ title: string,
143
+ text: string,
144
+ options: { path?: string } = {},
145
+ ): Promise<void> {
146
+ const lines = text.split("\n");
147
+ if (!ctx.hasUI || ctx.mode !== "tui") {
148
+ const head = lines.filter((line) => line.trim().length > 0).slice(0, 6).join("\n");
149
+ ctx.ui.notify(`${title}\n${head}${options.path ? `\n→ ${options.path}` : ""}`, "info");
150
+ return;
151
+ }
152
+ const clipped = lines.slice(0, MAX_REPORT_LINES);
153
+ if (lines.length > MAX_REPORT_LINES) clipped.push(`… ${lines.length - MAX_REPORT_LINES} more lines`);
154
+ if (options.path) clipped.push("", `full report: ${options.path}`);
155
+ await ctx.ui.custom<void>((_tui, _theme, _keybindings, done) => {
156
+ const body = new Text(clipped.join("\n"));
157
+ const footer = new Text("[Enter/Esc] close");
158
+ return new ReportPanel(body, footer, () => done(undefined));
159
+ });
160
+ }
@@ -45,7 +45,7 @@ function audit(command: string, ctx: PolicyContext, prompt?: readonly string[])
45
45
  describe("permission audit payload", () => {
46
46
  it("records the decomposition, target scope and deciding layer", () => {
47
47
  const cwd = project();
48
- const payload = audit("rm -rf /tmp/outside/x", makeContext(cwd));
48
+ const payload = audit("rm -rf /var/pi-perm-outside/x", makeContext(cwd));
49
49
 
50
50
  expect(payload.modes).toEqual({
51
51
  inRead: "allow",
@@ -58,7 +58,7 @@ describe("permission audit payload", () => {
58
58
  expect.objectContaining({
59
59
  head: "rm",
60
60
  command: "rm",
61
- args: ["-rf", "/tmp/outside/x"],
61
+ args: ["-rf", "/var/pi-perm-outside/x"],
62
62
  base: canonicalize(cwd),
63
63
  effect: "write",
64
64
  opaque: false,
@@ -66,8 +66,8 @@ describe("permission audit payload", () => {
66
66
  layer: "mode",
67
67
  targets: [
68
68
  {
69
- path: "/tmp/outside/x",
70
- canonical: "/tmp/outside/x",
69
+ path: "/var/pi-perm-outside/x",
70
+ canonical: canonicalize("/var/pi-perm-outside/x"),
71
71
  effect: "write",
72
72
  source: "operand",
73
73
  scope: "out",
@@ -79,11 +79,11 @@ describe("permission audit payload", () => {
79
79
  action: "ask",
80
80
  layer: "mode",
81
81
  unit: 0,
82
- target: "/tmp/outside/x",
82
+ target: canonicalize("/var/pi-perm-outside/x"),
83
83
  effect: "write",
84
84
  scope: "out",
85
85
  grantScope: "root",
86
- grantRoot: "/tmp/outside",
86
+ grantRoot: canonicalize("/var/pi-perm-outside"),
87
87
  });
88
88
  });
89
89
 
@@ -137,20 +137,20 @@ describe("permission audit payload", () => {
137
137
 
138
138
  it("carries the exact prompt lines when asked", () => {
139
139
  const cwd = project();
140
- const payload = audit("rm -rf /tmp/outside/x", makeContext(cwd), [
140
+ const payload = audit("rm -rf /var/pi-perm-outside/x", makeContext(cwd), [
141
141
  "Permission required",
142
- "Command: rm -rf /tmp/outside/x",
142
+ "Command: rm -rf /var/pi-perm-outside/x",
143
143
  ]);
144
144
  expect(payload.prompt).toEqual([
145
145
  "Permission required",
146
- "Command: rm -rf /tmp/outside/x",
146
+ "Command: rm -rf /var/pi-perm-outside/x",
147
147
  ]);
148
148
  });
149
149
 
150
150
  it("clips long arguments but keeps the target", () => {
151
151
  const cwd = project();
152
152
  const longArg = `--flag=${"x".repeat(2000)}`;
153
- const payload = audit(`rm -rf /tmp/outside/x ${longArg}`, makeContext(cwd));
153
+ const payload = audit(`rm -rf /var/pi-perm-outside/x ${longArg}`, makeContext(cwd));
154
154
  const unit = (payload.units as Array<Record<string, unknown>>)[0];
155
155
  const args = unit.args as string[];
156
156
  expect(args[2]).toContain("[+");
@@ -2,10 +2,16 @@
2
2
  * Fixed safety tables: credential paths are denied in every mode, destructive
3
3
  * commands always ask.
4
4
  */
5
- import { homedir } from "node:os";
5
+ import { homedir, tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import { describe, expect, it } from "vitest";
8
- import { dangerReason, secretReason } from "./constants.js";
8
+ import { canonicalize } from "./paths.js";
9
+ import {
10
+ dangerReason,
11
+ isTemporaryPath,
12
+ secretReason,
13
+ temporaryRoots,
14
+ } from "./constants.js";
9
15
 
10
16
  const HOME = homedir();
11
17
 
@@ -37,11 +43,30 @@ describe("secrets table", () => {
37
43
  });
38
44
  });
39
45
 
46
+ describe("temporary write zone", () => {
47
+ it("covers the OS temp root and the common volatile directories", () => {
48
+ expect(temporaryRoots()).toContain(canonicalize(tmpdir()));
49
+ expect(temporaryRoots()).toContain(canonicalize("/var/tmp"));
50
+ expect(temporaryRoots()).toContain(canonicalize("/dev/shm"));
51
+ expect(isTemporaryPath(join(tmpdir(), "pi", "x"))).toBe(true);
52
+ expect(isTemporaryPath(join("/var/tmp", "x"))).toBe(true);
53
+ expect(isTemporaryPath(join("/dev/shm", "x"))).toBe(true);
54
+ });
55
+
56
+ it("does not over-match a sibling prefix or an ordinary path", () => {
57
+ expect(isTemporaryPath(`${tmpdir()}-evil/x`)).toBe(false);
58
+ expect(isTemporaryPath("/var/tmp-evil/x")).toBe(false);
59
+ expect(isTemporaryPath("/proj/x")).toBe(false);
60
+ });
61
+ });
62
+
40
63
  describe("danger table", () => {
41
64
  it("catches the destructive core", () => {
42
65
  expect(dangerReason("rm", ["-rf", "/"], ["/"], "/proj")).toContain("recursive delete");
43
66
  expect(dangerReason("rm", ["-rf", HOME], [HOME], "/proj")).toContain("recursive delete");
44
67
  expect(dangerReason("rm", ["-rf", "/proj"], ["/proj"], "/proj")).toContain("recursive delete");
68
+ expect(dangerReason("rm", ["-rf", tmpdir()], [tmpdir()], "/proj")).toContain("recursive delete");
69
+ expect(dangerReason("rm", ["-rf", "/var/tmp"], ["/var/tmp"], "/proj")).toContain("recursive delete");
45
70
  expect(dangerReason("git", ["push", "--force"], [], "/proj")).toBe("forced git push");
46
71
  expect(dangerReason("git", ["push", "-f"], [], "/proj")).toBe("forced git push");
47
72
  expect(dangerReason("git", ["reset", "--hard"], [], "/proj")).toBe("git reset --hard");
@@ -53,6 +78,7 @@ describe("danger table", () => {
53
78
 
54
79
  it("does not flag ordinary commands", () => {
55
80
  expect(dangerReason("rm", ["-rf", "/proj/build"], ["/proj/build"], "/proj")).toBeUndefined();
81
+ expect(dangerReason("rm", ["-rf", join(tmpdir(), "x")], [join(tmpdir(), "x")], "/proj")).toBeUndefined();
56
82
  expect(dangerReason("git", ["push"], [], "/proj")).toBeUndefined();
57
83
  expect(dangerReason("git", ["log"], [], "/proj")).toBeUndefined();
58
84
  expect(dangerReason("npm", ["view", "react"], [], "/proj")).toBeUndefined();
@@ -13,8 +13,9 @@
13
13
  * when their scope says allow (`rm -rf .` inside the project is still a
14
14
  * guarded operation). Scope alone cannot express "destructive".
15
15
  */
16
- import { homedir } from "node:os";
16
+ import { homedir, tmpdir } from "node:os";
17
17
  import { basename, join, sep } from "node:path";
18
+ import { canonicalize } from "./paths.js";
18
19
 
19
20
  /** Three-way mode, used for outside-project operations. */
20
21
  export type TriMode = "ask" | "allow" | "deny";
@@ -85,6 +86,29 @@ function isSecretEnvFile(pathValue: string): boolean {
85
86
  return name === ".env" || name.startsWith(".env.");
86
87
  }
87
88
 
89
+ /**
90
+ * Ephemeral directories whose writes are built-in safe: a scratch artifact
91
+ * under the OS temp root is not a boundary crossing worth a dialog. Kept to
92
+ * genuinely volatile locations; `~/.cache` is deliberately not included (it
93
+ * is a home-directory state area, not a scratch area).
94
+ *
95
+ * Resolved once at module load so the comparisons use the same spelling as the
96
+ * policy's canonical paths (`/tmp` is a symlink to `/private/tmp` on macOS).
97
+ */
98
+ const TEMPORARY_ROOTS: readonly string[] = [tmpdir(), "/var/tmp", "/dev/shm"].map(
99
+ (root) => canonicalize(root),
100
+ );
101
+
102
+ export function temporaryRoots(): string[] {
103
+ return [...TEMPORARY_ROOTS];
104
+ }
105
+
106
+ /** `true` when `pathValue` is inside one of {@link temporaryRoots}. */
107
+ export function isTemporaryPath(pathValue: string): boolean {
108
+ const canonical = canonicalize(pathValue);
109
+ return TEMPORARY_ROOTS.some((root) => pathWithin(root, canonical));
110
+ }
111
+
88
112
  /** Absolute secret prefixes, `~` expanded. */
89
113
  export function secretRoots(): string[] {
90
114
  return SECRET_ROOTS.map((root) =>
@@ -118,11 +142,12 @@ export function dangerReason(
118
142
  const inDangerousRoot = (pathValue: string): boolean =>
119
143
  pathValue === "/" ||
120
144
  pathValue === home ||
121
- (base !== null && pathValue === base);
145
+ (base !== null && pathValue === base) ||
146
+ TEMPORARY_ROOTS.includes(canonicalize(pathValue));
122
147
 
123
148
  if (command === "sudo" || command === "doas") return "privileged command";
124
149
  if (command === "rm" && targets.some(inDangerousRoot)) {
125
- return "recursive delete of /, the home directory, or the working directory";
150
+ return "recursive delete of /, the home directory, the working directory, or a temp root";
126
151
  }
127
152
  if (command === "git") {
128
153
  const [verb, ...rest] = args;