killeros 2.0.13 → 2.0.14

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
@@ -4,6 +4,17 @@ All notable changes to KillerOS are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.0.14] - 2026-08-22
8
+
9
+ ### Fixed
10
+
11
+ - Prevented direct tag pushes from publishing commits that have not passed CI on `main`.
12
+ - Kept oversized hook payloads valid JSON and marked their bounded preview as truncated.
13
+ - Stripped terminal escape sequences and unsafe controls from model-controlled question and goal text.
14
+ - Aligned hook timeout validation and execution on the documented five-minute maximum.
15
+ - Required file-backed goals to create or change their deliverable after the goal starts, including after session restore.
16
+ - Removed a CI test dependency on an intentionally untracked internal document.
17
+
7
18
  ## [2.0.13] - 2026-08-21
8
19
 
9
20
  ### Added
package/README.md CHANGED
@@ -24,10 +24,10 @@ Install from GitHub:
24
24
  pi install git:github.com/KyrosHendrix/pi-KillerOS
25
25
  ```
26
26
 
27
- Pin an install to version `v2.0.13`:
27
+ Pin an install to version `v2.0.14`:
28
28
 
29
29
  ```bash
30
- pi install git:github.com/KyrosHendrix/pi-KillerOS@v2.0.13
30
+ pi install git:github.com/KyrosHendrix/pi-KillerOS@v2.0.14
31
31
  ```
32
32
 
33
33
  Add `-l` to either command to install only for the current project. Restart Pi after installing.
@@ -175,7 +175,7 @@ For a normal release:
175
175
 
176
176
  After the full CI workflow passes on `main`, the release workflow validates the commit and changelog, publishes the package to npm through trusted publishing, and creates the matching tag and GitHub release. The [`pi-package` keyword](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/packages.md) makes the npm package visible in Pi's package catalog.
177
177
 
178
- Do not create a tag for the normal path. If a release is missing, use the manual tag recovery path and push the matching version tag. The recovery path checks the tag against the package and changelog, skips npm publication when that version already exists, and creates only the missing GitHub release.
178
+ Do not push version tags manually. Tag pushes cannot publish; every published commit must pass the full `main` CI workflow.
179
179
 
180
180
  ## Security
181
181
 
package/killeros/goals.ts CHANGED
@@ -9,7 +9,8 @@ import { BoundedText } from "./bounded-text.ts";
9
9
  import { formatTime, formatTokens } from "./display.ts";
10
10
  import { reportError } from "./errors.ts";
11
11
  import { resolvePersonalInstructions } from "./personal-instructions.ts";
12
- import type { GoalBlockerAudit, GoalFileVerification, GoalRuntime, GoalState, GoalStatus, InitRuntime } from "./runtime.ts";
12
+ import type { GoalBlockerAudit, GoalFileBaseline, GoalFileVerification, GoalRuntime, GoalState, GoalStatus, InitRuntime } from "./runtime.ts";
13
+ import { safeTerminalText } from "./safe-terminal-text.ts";
13
14
 
14
15
  const GOAL_ENTRY_TYPE = "killeros-goal";
15
16
  const GOAL_CONTINUATION_TYPE = "killeros-goal-continuation";
@@ -67,13 +68,21 @@ function finiteNonNegative(value: unknown): value is number {
67
68
  return typeof value === "number" && Number.isFinite(value) && value >= 0;
68
69
  }
69
70
 
71
+ function isGoalFileBaseline(value: unknown): value is GoalFileBaseline {
72
+ if (!value || typeof value !== "object") return false;
73
+ const candidate = value as { exists?: unknown; size?: unknown; mtimeMs?: unknown };
74
+ if (candidate.exists === false) return candidate.size === undefined && candidate.mtimeMs === undefined;
75
+ return candidate.exists === true && finiteNonNegative(candidate.size) && finiteNonNegative(candidate.mtimeMs);
76
+ }
77
+
70
78
  function isGoalFileVerification(value: unknown): value is GoalFileVerification {
71
79
  if (!value || typeof value !== "object") return false;
72
80
  const candidate = value as Partial<GoalFileVerification>;
73
81
  return candidate.kind === "file"
74
82
  && typeof candidate.path === "string"
75
83
  && candidate.path === candidate.path.trim()
76
- && isAbsoluteFilePath(candidate.path);
84
+ && isAbsoluteFilePath(candidate.path)
85
+ && isGoalFileBaseline(candidate.baseline);
77
86
  }
78
87
 
79
88
  function isAbsoluteFilePath(value: string): boolean {
@@ -81,13 +90,23 @@ function isAbsoluteFilePath(value: string): boolean {
81
90
  return path.isAbsolute(value) || path.win32.isAbsolute(value);
82
91
  }
83
92
 
93
+ function captureGoalFileBaseline(filePath: string): GoalFileBaseline {
94
+ try {
95
+ const artifact = lstatSync(filePath);
96
+ return { exists: true, size: artifact.size, mtimeMs: artifact.mtimeMs };
97
+ } catch {
98
+ return { exists: false };
99
+ }
100
+ }
101
+
84
102
  function inferGoalVerification(objective: string): GoalFileVerification | undefined {
85
103
  const destination = /\b(?:create|write|save|generate)\b[^\r\n]{0,160}?\b(?:file|document|markdown|report|spreadsheet|presentation|image)\b\s+(?:to|at|as|destination(?:\s+is)?|output(?:\s+(?:to|at))?)\b\s*(?:`([^`\r\n]+)`|"([^"\r\n]+)"|'([^'\r\n]+)'|([A-Za-z]:\\[^\s,;]+|\/[^\s,;]+))/giu;
86
104
  const paths = [...objective.matchAll(destination)]
87
105
  .map((match) => (match[1] ?? match[2] ?? match[3] ?? match[4] ?? "").trim())
88
106
  .filter(isAbsoluteFilePath);
89
107
  const unique = [...new Set(paths)];
90
- return unique.length === 1 ? { kind: "file", path: unique[0]! } : undefined;
108
+ const filePath = unique.length === 1 ? unique[0] : undefined;
109
+ return filePath ? { kind: "file", path: filePath, baseline: captureGoalFileBaseline(filePath) } : undefined;
91
110
  }
92
111
 
93
112
  function verifyGoalDeliverable(verification: GoalFileVerification): void {
@@ -100,6 +119,11 @@ function verifyGoalDeliverable(verification: GoalFileVerification): void {
100
119
  if (!artifact.isFile()) {
101
120
  throw new Error(`Goal deliverable is not a regular file at the required path: ${verification.path}`);
102
121
  }
122
+ if (verification.baseline.exists
123
+ && artifact.size === verification.baseline.size
124
+ && artifact.mtimeMs === verification.baseline.mtimeMs) {
125
+ throw new Error(`Goal deliverable has not changed since the goal started: ${verification.path}`);
126
+ }
103
127
  }
104
128
 
105
129
  function isGoalBlockerAudit(value: unknown, turns: number, status: GoalStatus): value is GoalBlockerAudit {
@@ -555,9 +579,10 @@ export function registerGoal(
555
579
  const icon = state.status === "active" ? "✻" : state.status === "paused" ? "Ⅱ" : state.status === "blocked" ? "!" : "✓";
556
580
  const color: ThemeColor = state.status === "active" ? "accent" : state.status === "paused" ? "warning" : state.status === "blocked" ? "error" : "success";
557
581
  const status = theme.fg(color, `${icon} Goal ${state.status}`);
558
- if (!options.expanded) return new BoundedText(`${status}${theme.fg("dim", ` · ${state.objective}`)}`, 3);
559
- const lines = [status, theme.fg("dim", state.objective)];
560
- if (state.result) lines.push(theme.fg("muted", state.result));
582
+ const objective = safeTerminalText(state.objective);
583
+ if (!options.expanded) return new BoundedText(`${status}${theme.fg("dim", ` · ${objective}`)}`, 3);
584
+ const lines = [status, theme.fg("dim", objective)];
585
+ if (state.result) lines.push(theme.fg("muted", safeTerminalText(state.result)));
561
586
  return new BoundedText(lines.join("\n"));
562
587
  });
563
588
 
@@ -615,18 +640,18 @@ export function registerGoal(
615
640
  };
616
641
  },
617
642
  renderCall(args, theme) {
618
- return new Text(`${theme.fg("toolTitle", theme.bold("goal "))}${theme.fg("muted", args.status)}`, 0, 0);
643
+ return new Text(`${theme.fg("toolTitle", theme.bold("goal "))}${theme.fg("muted", safeTerminalText(args.status))}`, 0, 0);
619
644
  },
620
645
  renderResult(result, options, theme, context) {
621
646
  if (context?.isError) {
622
647
  const first = result.content[0];
623
- const message = first?.type === "text" ? first.text : "Goal update failed";
648
+ const message = first?.type === "text" ? safeTerminalText(first.text) : "Goal update failed";
624
649
  return new BoundedText(theme.fg("error", message), options.expanded ? undefined : 3);
625
650
  }
626
651
  const details = result.details;
627
652
  if (!details) return new BoundedText(theme.fg("dim", "Goal updated"));
628
653
  const label = details.status === "complete" ? "✓ Complete" : details.status === "blocked" ? "! Blocked" : `! Blocker audit ${details.streak}/3`;
629
- const text = `${theme.fg(details.status === "complete" ? "success" : "warning", label)}${theme.fg("dim", ` · ${details.evidence}`)}`;
654
+ const text = `${theme.fg(details.status === "complete" ? "success" : "warning", label)}${theme.fg("dim", ` · ${safeTerminalText(details.evidence)}`)}`;
630
655
  return new BoundedText(text, options.expanded ? undefined : 3);
631
656
  },
632
657
  });
package/killeros/hooks.ts CHANGED
@@ -4,7 +4,6 @@ import path from "node:path";
4
4
  import { StringDecoder } from "node:string_decoder";
5
5
  import { CONFIG_DIR_NAME, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
6
6
  import { reportError } from "./errors.ts";
7
- import { MAX_NODE_TIMER_MS } from "./limits.ts";
8
7
 
9
8
  type KillerosHookEvent = "tool_call" | "tool_result" | "agent_settled";
10
9
 
@@ -29,6 +28,8 @@ interface HookExecutionResult {
29
28
 
30
29
  const HOOK_EVENTS: readonly KillerosHookEvent[] = ["tool_call", "tool_result", "agent_settled"];
31
30
  const HOOK_OUTPUT_LIMIT = 16 * 1024;
31
+ const HOOK_PAYLOAD_LIMIT = 8_000;
32
+ const HOOK_TIMEOUT_MAX_MS = 300_000;
32
33
 
33
34
  function loadKillerosHooks(ctx: ExtensionContext): KillerosHookConfig {
34
35
  const configPath = path.join(ctx.cwd, CONFIG_DIR_NAME, "killeros-hooks.json");
@@ -49,11 +50,14 @@ function loadKillerosHooks(ctx: ExtensionContext): KillerosHookConfig {
49
50
  ctx.ui.notify(`Ignored ${event} hook ${index + 1}: matchers are only valid for tool events`, "warning");
50
51
  return false;
51
52
  }
53
+ if (hook?.timeoutMs !== undefined && (!Number.isSafeInteger(hook.timeoutMs) || hook.timeoutMs <= 0 || hook.timeoutMs > HOOK_TIMEOUT_MAX_MS)) {
54
+ ctx.ui.notify(`Ignored ${event} hook ${index + 1}: timeoutMs must be an integer from 1 to ${HOOK_TIMEOUT_MAX_MS}`, "warning");
55
+ return false;
56
+ }
52
57
  const valid = hook
53
58
  && typeof hook.command === "string"
54
59
  && hook.command.trim().length > 0
55
- && (hook.matcher === undefined || typeof hook.matcher === "string")
56
- && (hook.timeoutMs === undefined || Number.isSafeInteger(hook.timeoutMs) && hook.timeoutMs > 0 && hook.timeoutMs <= MAX_NODE_TIMER_MS);
60
+ && (hook.matcher === undefined || typeof hook.matcher === "string");
57
61
  if (!valid) {
58
62
  ctx.ui.notify(`Ignored invalid ${event} hook ${index + 1} in ${configPath}`, "warning");
59
63
  return false;
@@ -189,15 +193,22 @@ export function executeHook(
189
193
  finish(termination ? terminationCode() : 1);
190
194
  });
191
195
  child.once("close", (code) => finish(termination ? terminationCode() : code ?? 1));
192
- timer = setTimeout(() => beginTermination("timeout"), Math.max(1_000, Math.min(timeoutMs, 300_000)));
196
+ timer = setTimeout(() => beginTermination("timeout"), Math.max(1, Math.min(timeoutMs, HOOK_TIMEOUT_MAX_MS)));
193
197
  });
194
198
  }
195
199
 
200
+ function serializeHookPayload(payload: unknown): string {
201
+ const serialized = JSON.stringify(payload) ?? "null";
202
+ if (serialized.length <= HOOK_PAYLOAD_LIMIT) return serialized;
203
+ const previewLength = Math.floor((HOOK_PAYLOAD_LIMIT - 64) / 2);
204
+ return JSON.stringify({ truncated: true, preview: serialized.slice(0, previewLength) });
205
+ }
206
+
196
207
  function hookEnvironment(event: KillerosHookEvent, toolName = "", payload: unknown = {}): Record<string, string> {
197
208
  return {
198
209
  KILLEROS_EVENT: event,
199
210
  KILLEROS_TOOL: toolName,
200
- KILLEROS_PAYLOAD: JSON.stringify(payload).slice(0, 8_000),
211
+ KILLEROS_PAYLOAD: serializeHookPayload(payload),
201
212
  };
202
213
  }
203
214
 
@@ -16,6 +16,7 @@ import {
16
16
  } from "@earendil-works/pi-tui";
17
17
  import { Type, type Static } from "typebox";
18
18
  import { BoundedText } from "./bounded-text.ts";
19
+ import { safeTerminalText } from "./safe-terminal-text.ts";
19
20
 
20
21
  const OptionSchema = Type.Object({
21
22
  label: Type.String({ minLength: 1, maxLength: 200, description: "Display label for the option" }),
@@ -264,11 +265,12 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
264
265
  if (ctx.mode !== "tui") throw new Error("The question tool requires interactive TUI mode");
265
266
  if (signal?.aborted) throw new Error("Question cancelled before it opened");
266
267
 
268
+ const question = safeTerminalText(params.question);
267
269
  const options: DisplayOption[] = [
268
270
  ...params.options.map((option, index) => ({
269
- label: option.label,
270
- description: option.description,
271
- preview: option.preview,
271
+ label: safeTerminalText(option.label),
272
+ description: option.description === undefined ? undefined : safeTerminalText(option.description),
273
+ preview: option.preview === undefined ? undefined : safeTerminalText(option.preview),
272
274
  originalIndex: index + 1,
273
275
  isOther: false,
274
276
  })),
@@ -599,7 +601,7 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
599
601
  } else lines = [`${selected ? `> ${selected.label}` : "No matching options"} · ${position}`];
600
602
  } else if (rowBudget <= 5) {
601
603
  lines = [
602
- ...boundedQuestionLines(params.question, width, Math.max(1, rowBudget - 3)),
604
+ ...boundedQuestionLines(question, width, Math.max(1, rowBudget - 3)),
603
605
  editMode !== "none"
604
606
  ? `${editMode === "filter" ? "Filter" : "Answer"} ${editMode === "filter" ? filterCount : editorCount}/${CUSTOM_INPUT_MAX_CHARACTERS.toLocaleString()}`
605
607
  : selected ? optionLabel(selected, optionIndex) : "No matching options",
@@ -607,7 +609,7 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
607
609
  editMode !== "none" ? editHint : browseHint,
608
610
  ];
609
611
  } else {
610
- const questionLines = boundedQuestionLines(params.question, width, Math.max(1, rowBudget - 5));
612
+ const questionLines = boundedQuestionLines(question, width, Math.max(1, rowBudget - 5));
611
613
  const contentRows = rowBudget - questionLines.length - 4;
612
614
  const optionCapacity = Math.max(1, Math.min(5, Math.ceil(contentRows / 2)));
613
615
  const detailCapacity = Math.max(0, contentRows - optionCapacity);
@@ -723,20 +725,26 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
723
725
  renderCall(args, theme, context) {
724
726
  const { mode, minSelections: minimum, maxSelections: maximum } = normalizeQuestionSelection(args);
725
727
  const multiple = mode === "multiple";
728
+ const question = safeTerminalText(args.question);
729
+ const options = args.options.map((option) => ({
730
+ label: safeTerminalText(option.label),
731
+ description: option.description === undefined ? undefined : safeTerminalText(option.description),
732
+ preview: option.preview === undefined ? undefined : safeTerminalText(option.preview),
733
+ }));
726
734
  if (!context.expanded) {
727
735
  const title = multiple ? "question (multi-select) " : "question ";
728
- const detail = multiple ? `${args.options.length} options · choose ${minimum}–${maximum}` : `${args.options.length} option${args.options.length === 1 ? "" : "s"}`;
729
- return new BoundedText(`${theme.fg("toolTitle", theme.bold(title))}${theme.fg("muted", oneLine(args.question))}\n${theme.fg("dim", ` ${detail}`)}`, 3);
736
+ const detail = multiple ? `${options.length} options · choose ${minimum}–${maximum}` : `${options.length} option${options.length === 1 ? "" : "s"}`;
737
+ return new BoundedText(`${theme.fg("toolTitle", theme.bold(title))}${theme.fg("muted", oneLine(question))}\n${theme.fg("dim", ` ${detail}`)}`, 3);
730
738
  }
731
739
  const title = multiple ? "question (multi-select) " : "question ";
732
- const lines = [`${theme.fg("toolTitle", theme.bold(title))}${theme.fg("muted", args.question)}`];
733
- if (multiple) lines.push(theme.fg("dim", `${args.options.length} options · choose ${minimum}–${maximum}`));
734
- args.options.forEach((option, index) => {
740
+ const lines = [`${theme.fg("toolTitle", theme.bold(title))}${theme.fg("muted", question)}`];
741
+ if (multiple) lines.push(theme.fg("dim", `${options.length} options · choose ${minimum}–${maximum}`));
742
+ options.forEach((option, index) => {
735
743
  lines.push(theme.fg("text", `${multiple ? "[ ] " : ""}${index + 1}. ${option.label}`));
736
744
  if (option.description) lines.push(theme.fg("muted", ` ${option.description}`));
737
745
  if (option.preview) lines.push(theme.fg("dim", option.preview));
738
746
  });
739
- lines.push(theme.fg("text", `${multiple ? "[ ] " : ""}${args.options.length + 1}. Type a custom answer`));
747
+ lines.push(theme.fg("text", `${multiple ? "[ ] " : ""}${options.length + 1}. Type a custom answer`));
740
748
  return new BoundedText(lines.join("\n"));
741
749
  },
742
750
 
@@ -744,14 +752,16 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
744
752
  const details = result.details;
745
753
  if (!details) {
746
754
  const first = result.content[0];
747
- return new BoundedText(first?.type === "text" ? first.text : "", options.expanded ? undefined : 3);
755
+ return new BoundedText(first?.type === "text" ? safeTerminalText(first.text) : "", options.expanded ? undefined : 3);
748
756
  }
749
757
  if (details.cancelled || ("answer" in details && details.answer === null)) return new BoundedText(theme.fg("warning", "Cancelled"));
750
758
  if ("mode" in details && details.mode === "multiple") {
751
- return new MultipleResultText(details.answers, options.expanded, details.customAnswer, theme.fg.bind(theme));
759
+ const answers = details.answers.map(safeTerminalText);
760
+ const customAnswer = details.customAnswer === undefined ? undefined : safeTerminalText(details.customAnswer);
761
+ return new MultipleResultText(answers, options.expanded, customAnswer, theme.fg.bind(theme));
752
762
  }
753
763
  if (!("answer" in details) || details.answer === null) return new BoundedText("");
754
- const answer = details.answer;
764
+ const answer = safeTerminalText(details.answer);
755
765
  if (details.wasCustom) {
756
766
  return new BoundedText(`${theme.fg("success", "✓ ")}${theme.fg("muted", "(wrote) ")}${theme.fg("accent", answer)}`, options.expanded ? undefined : 3);
757
767
  }
@@ -28,9 +28,14 @@ export interface GoalBlockerAudit {
28
28
  lastTurn: number;
29
29
  }
30
30
 
31
+ export type GoalFileBaseline =
32
+ | { exists: false }
33
+ | { exists: true; size: number; mtimeMs: number };
34
+
31
35
  export interface GoalFileVerification {
32
36
  kind: "file";
33
37
  path: string;
38
+ baseline: GoalFileBaseline;
34
39
  }
35
40
 
36
41
  export interface GoalState {
@@ -0,0 +1,6 @@
1
+ import { stripTerminalSequences } from "@earendil-works/pi-tui";
2
+
3
+ /** Remove terminal commands and unsafe controls while preserving line feeds. */
4
+ export function safeTerminalText(value: string): string {
5
+ return stripTerminalSequences(value).replace(/[\u0000-\u0009\u000B-\u001F\u007F-\u009F]/gu, "");
6
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "killeros",
3
- "version": "2.0.13",
3
+ "version": "2.0.14",
4
4
  "description": "TUI, goals, and workflow automation for the Pi coding agent",
5
5
  "type": "module",
6
6
  "keywords": [