killeros 2.0.20 → 2.0.22

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/killeros/hooks.ts CHANGED
@@ -197,16 +197,27 @@ function appendBounded(output: HookOutputBuffer, chunk: Buffer | string): void {
197
197
  output.text += output.decoder.write(captured);
198
198
  }
199
199
 
200
+ /** Terminates a Windows hook tree without depending on the caller's PATH. */
201
+ function terminateWindowsHookTree(child: HookChildProcess): Promise<boolean> {
202
+ return new Promise((resolve) => {
203
+ try {
204
+ const taskkill = process.env.SystemRoot
205
+ ? path.join(process.env.SystemRoot, "System32", "taskkill.exe")
206
+ : "taskkill";
207
+ const killer = spawn(taskkill, ["/pid", String(child.pid), "/T", "/F"], {
208
+ shell: false,
209
+ stdio: "ignore",
210
+ windowsHide: true,
211
+ });
212
+ killer.once("error", () => resolve(false));
213
+ killer.once("close", (code) => resolve(code === 0));
214
+ } catch {
215
+ resolve(false);
216
+ }
217
+ });
218
+ }
219
+
200
220
  function terminateHookProcess(child: HookChildProcess, force: boolean): void {
201
- if (process.platform === "win32" && force && child.pid) {
202
- const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
203
- shell: false,
204
- stdio: "ignore",
205
- windowsHide: true,
206
- });
207
- killer.unref();
208
- return;
209
- }
210
221
  if (process.platform !== "win32" && child.pid) {
211
222
  try {
212
223
  process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM");
@@ -282,6 +293,7 @@ export function executeHook(
282
293
  let timer: NodeJS.Timeout | undefined;
283
294
  let forceTimer: NodeJS.Timeout | undefined;
284
295
  let settleTimer: NodeJS.Timeout | undefined;
296
+ let windowsCleanupPending = false;
285
297
  const finish = (code: number, exitUnconfirmed = false): void => {
286
298
  if (completed) return;
287
299
  completed = true;
@@ -304,6 +316,21 @@ export function executeHook(
304
316
  const beginTermination = (reason: "timeout" | "cancelled"): void => {
305
317
  if (completed || termination) return;
306
318
  termination = reason;
319
+ if (process.platform === "win32" && child.pid) {
320
+ windowsCleanupPending = true;
321
+ void terminateWindowsHookTree(child).then((confirmed) => {
322
+ if (completed) return;
323
+ if (!confirmed) terminateHookProcess(child, true);
324
+ finish(terminationCode(), !confirmed);
325
+ windowsCleanupPending = false;
326
+ });
327
+ settleTimer = setTimeout(() => {
328
+ terminateHookProcess(child, true);
329
+ finish(terminationCode(), true);
330
+ windowsCleanupPending = false;
331
+ }, 2_000);
332
+ return;
333
+ }
307
334
  terminateHookProcess(child, false);
308
335
  forceTimer = setTimeout(() => {
309
336
  if (completed) return;
@@ -318,9 +345,11 @@ export function executeHook(
318
345
  child.stderr.on("data", (chunk) => appendBounded(stderr, chunk));
319
346
  child.on("error", (error) => {
320
347
  appendBounded(stderr, error.message);
321
- finish(termination ? terminationCode() : 1);
348
+ if (!windowsCleanupPending) finish(termination ? terminationCode() : 1);
349
+ });
350
+ child.once("close", (code) => {
351
+ if (!windowsCleanupPending) finish(termination ? terminationCode() : code ?? 1);
322
352
  });
323
- child.once("close", (code) => finish(termination ? terminationCode() : code ?? 1));
324
353
  timer = setTimeout(() => beginTermination("timeout"), Math.max(1, Math.min(timeoutMs, HOOK_TIMEOUT_MAX_MS)));
325
354
  });
326
355
  }
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import { promises as fs } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { StringDecoder } from "node:string_decoder";
5
+ import { containsLikelySecret } from "./secret-detector.ts";
5
6
 
6
7
  export const INIT_READ_TOOL = "killeros_init_read";
7
8
  export const INIT_LIST_TOOL = "killeros_init_list";
@@ -19,7 +20,7 @@ const EXCLUDED_GUIDANCE = new Set([
19
20
  ".cursorrules", "agents.md", "agents.local.md", "claude.md", "claude.local.md", "copilot-instructions.md", "gemini.md", "memory.md", "skill.md",
20
21
  ]);
21
22
  const ROOT_EVIDENCE = [
22
- "README.md", "README.rst", "README.txt", "CONTRIBUTING.md", "package.json", "pyproject.toml", "requirements.txt", "Cargo.toml", "go.mod", "Makefile", "Dockerfile", "compose.yaml", "compose.yml", "config.yaml", "config.yml", "tsconfig.json", "vite.config.ts", "vite.config.js", "eslint.config.js", "eslint.config.mjs",
23
+ "README.md", "README.rst", "README.txt", "CONTRIBUTING.md", "package.json", "pyproject.toml", "requirements.txt", "Cargo.toml", "go.mod", "Makefile", "Dockerfile", "compose.yaml", "compose.yml", "tsconfig.json", "vite.config.ts", "vite.config.js", "eslint.config.js", "eslint.config.mjs",
23
24
  ] as const;
24
25
  const NESTED_EVIDENCE = new Set(["package.json", "pyproject.toml", "requirements.txt", "Cargo.toml", "go.mod"]);
25
26
 
@@ -43,6 +44,10 @@ function sensitiveEvidencePath(relativePath: string): boolean {
43
44
  const name = path.posix.basename(normalized);
44
45
  return /^\.env(?:\.|$)/u.test(name)
45
46
  || [".npmrc", ".pypirc", ".netrc", "id_rsa", "id_ed25519", "credentials.json"].includes(name)
47
+ || /(?:^|\/)\.aws\/credentials$/u.test(normalized)
48
+ || /(?:^|\/)\.docker\/config\.json$/u.test(normalized)
49
+ || /(?:^|\/)\.kube\/config$/u.test(normalized)
50
+ || /(?:credential|secret|token)/u.test(name)
46
51
  || /^service-account.*\.json$/u.test(name)
47
52
  || /\.(?:pem|key|p12|pfx|jks|keystore)$/u.test(name);
48
53
  }
@@ -200,6 +205,13 @@ async function validateAndRead(projectRoot: string, absolutePath: string, limit:
200
205
  }
201
206
  }
202
207
 
208
+ /** Reads evidence only when its bounded content does not resemble a credential. */
209
+ async function readEvidenceFile(projectRoot: string, absolutePath: string, limit: number): Promise<{ content: string; truncated: boolean }> {
210
+ const result = await validateAndRead(projectRoot, absolutePath, limit);
211
+ if (containsLikelySecret(result.content)) throw new Error("file is not available to /init");
212
+ return result;
213
+ }
214
+
203
215
  function normalizeRequestedPath(requestedPath: string): string {
204
216
  if (!requestedPath || requestedPath.trim() !== requestedPath || requestedPath.startsWith("~")
205
217
  || /^file:/iu.test(requestedPath) || path.isAbsolute(requestedPath)) {
@@ -237,7 +249,7 @@ export async function buildInitEvidence(projectRoot: string): Promise<InitEviden
237
249
  const relativePath = canonicalPaths.get(evidenceKey(requested));
238
250
  if (!relativePath || Buffer.byteLength(snapshot, "utf8") >= SNAPSHOT_LIMIT) continue;
239
251
  try {
240
- const result = await validateAndRead(projectRoot, path.join(projectRoot, relativePath), AUTOMATIC_FILE_LIMIT);
252
+ const result = await readEvidenceFile(projectRoot, path.join(projectRoot, relativePath), AUTOMATIC_FILE_LIMIT);
241
253
  const suffix = result.truncated ? "\n[truncated by /init]" : "";
242
254
  snapshot = appendWithinLimit(snapshot, `\n\n## ${relativePath}\n${result.content}${suffix}`, SNAPSHOT_LIMIT);
243
255
  } catch {
@@ -251,7 +263,7 @@ export async function readInitEvidence(index: InitEvidenceIndex, requestedPath:
251
263
  const normalized = normalizeRequestedPath(requestedPath);
252
264
  const relativePath = index.canonicalPaths.get(evidenceKey(normalized));
253
265
  if (!relativePath) throw new Error(`${requestedPath} is not available to /init`);
254
- const result = await validateAndRead(index.projectRoot, path.join(index.projectRoot, relativePath), READ_LIMIT);
266
+ const result = await readEvidenceFile(index.projectRoot, path.join(index.projectRoot, relativePath), READ_LIMIT);
255
267
  return result.truncated ? `${result.content}\n[truncated by /init at ${READ_LIMIT} bytes]` : result.content;
256
268
  }
257
269
 
@@ -1,8 +1,8 @@
1
- import { closeSync, openSync, readSync } from "node:fs";
1
+ import { closeSync, fstatSync, lstatSync, openSync, readSync, realpathSync } from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { StringDecoder } from "node:string_decoder";
5
- import { type ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
+ import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
6
  import type { InitRuntime } from "./runtime.ts";
7
7
 
8
8
  const PERSONAL_INSTRUCTIONS_FILE = "AGENTS.local.md";
@@ -11,7 +11,12 @@ const PERSONAL_INSTRUCTIONS_LIMIT = 32 * 1024;
11
11
  function readBoundedText(filePath: string, limit = PERSONAL_INSTRUCTIONS_LIMIT): string | undefined {
12
12
  let descriptor: number | undefined;
13
13
  try {
14
+ const pathStat = lstatSync(filePath);
15
+ if (!pathStat.isFile() || pathStat.isSymbolicLink() || pathStat.nlink !== 1) return undefined;
14
16
  descriptor = openSync(filePath, "r");
17
+ const openedStat = fstatSync(descriptor);
18
+ if (!openedStat.isFile() || openedStat.nlink !== 1
19
+ || openedStat.dev !== pathStat.dev || openedStat.ino !== pathStat.ino) return undefined;
15
20
  const buffer = Buffer.alloc(limit + 1);
16
21
  const bytesRead = readSync(descriptor, buffer, 0, buffer.length, 0);
17
22
  const decoder = new StringDecoder("utf8");
@@ -33,9 +38,59 @@ function readBoundedText(filePath: string, limit = PERSONAL_INSTRUCTIONS_LIMIT):
33
38
  }
34
39
  }
35
40
 
41
+ /** Checks path containment using host filesystem casing rules. */
42
+ function pathInside(root: string, candidate: string): boolean {
43
+ const normalizedRoot = process.platform === "win32" ? root.toLowerCase() : root;
44
+ const normalizedCandidate = process.platform === "win32" ? candidate.toLowerCase() : candidate;
45
+ const relative = path.relative(normalizedRoot, normalizedCandidate);
46
+ return Boolean(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
47
+ }
48
+
49
+ /** Reads one stable, unlinked import from inside Pi's agent directory. */
50
+ function readImportedText(filePath: string): string | undefined {
51
+ let descriptor: number | undefined;
52
+ try {
53
+ const allowedPath = path.resolve(getAgentDir());
54
+ const requestedPath = path.resolve(filePath);
55
+ if (!pathInside(allowedPath, requestedPath)) return undefined;
56
+
57
+ const allowedRealPath = realpathSync(allowedPath);
58
+ const requestedRealPath = realpathSync(requestedPath);
59
+ if (!pathInside(allowedRealPath, requestedRealPath)) return undefined;
60
+
61
+ let current = allowedPath;
62
+ for (const segment of path.relative(allowedPath, requestedPath).split(path.sep)) {
63
+ current = path.join(current, segment);
64
+ if (lstatSync(current).isSymbolicLink()) return undefined;
65
+ }
66
+
67
+ const pathStat = lstatSync(requestedPath);
68
+ if (!pathStat.isFile() || pathStat.isSymbolicLink() || pathStat.nlink !== 1) return undefined;
69
+ descriptor = openSync(requestedPath, "r");
70
+ const openedStat = fstatSync(descriptor);
71
+ if (!openedStat.isFile() || openedStat.nlink !== 1
72
+ || openedStat.dev !== pathStat.dev || openedStat.ino !== pathStat.ino) return undefined;
73
+
74
+ const buffer = Buffer.alloc(PERSONAL_INSTRUCTIONS_LIMIT + 1);
75
+ const bytesRead = readSync(descriptor, buffer, 0, buffer.length, 0);
76
+ if (bytesRead > PERSONAL_INSTRUCTIONS_LIMIT) return undefined;
77
+ const content = new StringDecoder("utf8").write(buffer.subarray(0, bytesRead));
78
+ return content.trim() ? content : undefined;
79
+ } catch {
80
+ return undefined;
81
+ } finally {
82
+ if (descriptor !== undefined) {
83
+ try {
84
+ closeSync(descriptor);
85
+ } catch {
86
+ // Ignore cleanup failures after rejecting or reading an import.
87
+ }
88
+ }
89
+ }
90
+ }
91
+
36
92
  export function resolvePersonalInstructions(cwd: string): string | undefined {
37
- const localPath = path.join(cwd, PERSONAL_INSTRUCTIONS_FILE);
38
- const local = readBoundedText(localPath);
93
+ const local = readBoundedText(path.join(cwd, PERSONAL_INSTRUCTIONS_FILE));
39
94
  if (!local) return undefined;
40
95
 
41
96
  const importMatch = local.trim().match(/^@(.+)$/u);
@@ -45,7 +100,9 @@ export function resolvePersonalInstructions(cwd: string): string | undefined {
45
100
  const importedPath = requestedPath.startsWith("~/") || requestedPath.startsWith("~\\")
46
101
  ? path.join(os.homedir(), requestedPath.slice(2))
47
102
  : path.resolve(cwd, requestedPath);
48
- content = readBoundedText(importedPath) ?? local;
103
+ const imported = readImportedText(importedPath);
104
+ if (!imported) return undefined;
105
+ content = imported;
49
106
  }
50
107
  return `<personal_instructions>\n${content}\n</personal_instructions>`;
51
108
  }