@datename/fastcode-termux 0.1.6-beta → 0.1.8-beta.0

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 (165) hide show
  1. package/LICENSE +4 -0
  2. package/README.md +50 -17
  3. package/dist/agent/approval-json.d.ts +43 -0
  4. package/dist/agent/approval-json.js +69 -0
  5. package/dist/agent/approval-native.d.ts +3 -0
  6. package/dist/agent/approval-native.js +165 -0
  7. package/dist/agent/approval-store.d.ts +16 -0
  8. package/dist/agent/approval-store.js +21 -0
  9. package/dist/agent/approval.d.ts +33 -0
  10. package/dist/agent/approval.js +40 -5
  11. package/dist/agent/compaction.d.ts +2 -0
  12. package/dist/agent/compaction.js +60 -17
  13. package/dist/agent/guards.d.ts +16 -16
  14. package/dist/agent/guards.js +978 -24
  15. package/dist/agent/policy/defaults.d.ts +16 -0
  16. package/dist/agent/policy/defaults.js +17 -0
  17. package/dist/agent/policy/egress.d.ts +32 -0
  18. package/dist/agent/policy/egress.js +280 -0
  19. package/dist/agent/process-tree.d.ts +20 -0
  20. package/dist/agent/process-tree.js +81 -10
  21. package/dist/agent/protocol.d.ts +20 -1
  22. package/dist/agent/protocol.js +165 -38
  23. package/dist/agent/registry.d.ts +3 -1
  24. package/dist/agent/registry.js +168 -11
  25. package/dist/agent/runner.d.ts +21 -0
  26. package/dist/agent/runner.js +44 -5
  27. package/dist/agent/shell-ast.d.ts +11 -0
  28. package/dist/agent/shell-ast.js +204 -0
  29. package/dist/agent/shell-session.d.ts +14 -0
  30. package/dist/agent/shell-session.js +251 -40
  31. package/dist/agent/tool-schemas.js +26 -7
  32. package/dist/agent/tools/append_file.js +3 -1
  33. package/dist/agent/tools/apply_patch.js +44 -4
  34. package/dist/agent/tools/copy_file.js +26 -3
  35. package/dist/agent/tools/delete_file.js +22 -3
  36. package/dist/agent/tools/grep.js +35 -4
  37. package/dist/agent/tools/make_dir.js +3 -1
  38. package/dist/agent/tools/move_path.d.ts +7 -0
  39. package/dist/agent/tools/move_path.js +31 -5
  40. package/dist/agent/tools/read_file.js +63 -3
  41. package/dist/agent/tools/read_many.js +21 -3
  42. package/dist/agent/tools/readers.d.ts +53 -1
  43. package/dist/agent/tools/readers.js +236 -26
  44. package/dist/agent/tools/replace_in_file.js +38 -5
  45. package/dist/agent/tools/shell.js +2 -2
  46. package/dist/agent/tools/types.d.ts +26 -0
  47. package/dist/agent/tools/validate.d.ts +15 -2
  48. package/dist/agent/tools/validate.js +66 -3
  49. package/dist/agent/tools/write_file.js +3 -1
  50. package/dist/app.d.ts +2 -1
  51. package/dist/app.js +526 -85
  52. package/dist/cli-main.js +363 -0
  53. package/dist/cli.js +397 -337
  54. package/dist/commands/about.js +1 -1
  55. package/dist/commands/clear.d.ts +1 -0
  56. package/dist/commands/clear.js +1 -1
  57. package/dist/commands/compact.d.ts +12 -0
  58. package/dist/commands/compact.js +14 -2
  59. package/dist/commands/context.js +5 -4
  60. package/dist/commands/index.js +2 -0
  61. package/dist/commands/jobs.js +3 -2
  62. package/dist/commands/kill.js +3 -1
  63. package/dist/commands/login.d.ts +2 -2
  64. package/dist/commands/login.js +15 -15
  65. package/dist/commands/model.d.ts +1 -13
  66. package/dist/commands/model.js +6 -38
  67. package/dist/commands/provider.d.ts +1 -15
  68. package/dist/commands/provider.js +4 -22
  69. package/dist/commands/skills.js +34 -4
  70. package/dist/commands/threads.d.ts +9 -0
  71. package/dist/commands/threads.js +26 -0
  72. package/dist/config/store.d.ts +21 -0
  73. package/dist/config/store.js +47 -9
  74. package/dist/config/trust.d.ts +9 -0
  75. package/dist/config/trust.js +41 -2
  76. package/dist/headless.d.ts +7 -0
  77. package/dist/headless.js +213 -0
  78. package/dist/index.js +19 -3
  79. package/dist/lib/debug.d.ts +2 -0
  80. package/dist/lib/debug.js +22 -0
  81. package/dist/lib/diagnostics.d.ts +19 -0
  82. package/dist/lib/diagnostics.js +32 -6
  83. package/dist/lib/highlight-subset.d.ts +21 -0
  84. package/dist/lib/highlight-subset.js +88 -0
  85. package/dist/lib/lifecycle.d.ts +5 -0
  86. package/dist/lib/lifecycle.js +62 -0
  87. package/dist/lib/markdown.js +22 -4
  88. package/dist/providers/deepseek.js +1 -1
  89. package/dist/providers/openaiCompatible.d.ts +24 -2
  90. package/dist/providers/openaiCompatible.js +20 -6
  91. package/dist/providers/spec.d.ts +12 -0
  92. package/dist/providers/spec.js +29 -0
  93. package/dist/session/log.d.ts +14 -2
  94. package/dist/session/log.js +56 -27
  95. package/dist/session/projection.d.ts +34 -1
  96. package/dist/session/projection.js +315 -46
  97. package/dist/skills/manager.d.ts +15 -0
  98. package/dist/skills/manager.js +135 -18
  99. package/dist/types.d.ts +15 -0
  100. package/dist/ui/StatusBar.js +1 -1
  101. package/package.json +3 -2
  102. package/src/agent/approval-json.ts +103 -0
  103. package/src/agent/approval-native.ts +168 -0
  104. package/src/agent/approval-store.ts +38 -0
  105. package/src/agent/approval.ts +58 -11
  106. package/src/agent/compaction.ts +62 -17
  107. package/src/agent/guards.ts +1049 -25
  108. package/src/agent/policy/defaults.ts +34 -0
  109. package/src/agent/policy/egress.ts +335 -0
  110. package/src/agent/process-tree.ts +105 -23
  111. package/src/agent/protocol.ts +187 -39
  112. package/src/agent/registry.ts +186 -14
  113. package/src/agent/runner.ts +50 -7
  114. package/src/agent/shell-ast.ts +227 -0
  115. package/src/agent/shell-session.ts +265 -41
  116. package/src/agent/tool-schemas.ts +26 -7
  117. package/src/agent/tools/append_file.ts +3 -1
  118. package/src/agent/tools/apply_patch.ts +45 -4
  119. package/src/agent/tools/copy_file.ts +27 -3
  120. package/src/agent/tools/delete_file.ts +22 -3
  121. package/src/agent/tools/grep.ts +36 -4
  122. package/src/agent/tools/make_dir.ts +3 -1
  123. package/src/agent/tools/move_path.ts +38 -5
  124. package/src/agent/tools/read_file.ts +64 -3
  125. package/src/agent/tools/read_many.ts +21 -3
  126. package/src/agent/tools/readers.ts +289 -26
  127. package/src/agent/tools/replace_in_file.ts +39 -5
  128. package/src/agent/tools/shell.ts +2 -2
  129. package/src/agent/tools/types.ts +43 -1
  130. package/src/agent/tools/validate.ts +73 -4
  131. package/src/agent/tools/write_file.ts +3 -1
  132. package/src/app.ts +533 -82
  133. package/src/{cli.ts → cli-main.ts} +34 -6
  134. package/src/commands/about.ts +1 -1
  135. package/src/commands/clear.ts +1 -1
  136. package/src/commands/compact.ts +14 -2
  137. package/src/commands/context.ts +5 -4
  138. package/src/commands/index.ts +2 -0
  139. package/src/commands/jobs.ts +3 -2
  140. package/src/commands/kill.ts +3 -1
  141. package/src/commands/login.ts +15 -15
  142. package/src/commands/model.ts +6 -38
  143. package/src/commands/provider.ts +4 -22
  144. package/src/commands/skills.ts +34 -4
  145. package/src/commands/threads.ts +28 -0
  146. package/src/config/store.ts +47 -9
  147. package/src/config/trust.ts +45 -2
  148. package/src/headless.ts +225 -0
  149. package/src/index.ts +19 -3
  150. package/src/lib/debug.ts +24 -0
  151. package/src/lib/diagnostics.ts +32 -6
  152. package/src/lib/highlight-subset.ts +93 -0
  153. package/src/lib/lifecycle.ts +69 -0
  154. package/src/lib/markdown.ts +22 -4
  155. package/src/providers/deepseek.ts +1 -1
  156. package/src/providers/openaiCompatible.ts +47 -10
  157. package/src/providers/spec.ts +42 -0
  158. package/src/session/log.ts +70 -27
  159. package/src/session/projection.ts +360 -47
  160. package/src/skills/manager.ts +140 -18
  161. package/src/types/highlight-shims.d.ts +14 -0
  162. package/src/types.ts +16 -1
  163. package/src/ui/StatusBar.ts +1 -1
  164. package/tsconfig.json +1 -0
  165. /package/dist/{cli.d.ts → cli-main.d.ts} +0 -0
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Lightweight egress defaults. Kept separate from the policy engine so
3
+ * config/store can include the default object without loading guards.
4
+ */
5
+ export type EgressMode = "off" | "audit" | "block";
6
+
7
+ export interface EgressConfig {
8
+ enabled: boolean;
9
+ mode: EgressMode;
10
+ denyPaths: string[];
11
+ denyGlobs: string[];
12
+ denyContentMarkers: string[];
13
+ maxScanBytes: number;
14
+ scanToolOutput: boolean;
15
+ }
16
+
17
+ export const DEFAULT_CONTENT_MARKERS = [
18
+ "-----BEGIN [A-Z ]*PRIVATE KEY-----",
19
+ "AKIA[0-9A-Z]{16}",
20
+ "gh[pousr]_[A-Za-z0-9]{36,}",
21
+ "xox[baprs]-[A-Za-z0-9-]{10,}",
22
+ "AIza[0-9A-Za-z_-]{35}",
23
+ "sk-[A-Za-z0-9]{32,}",
24
+ ];
25
+
26
+ export const DEFAULT_EGRESS_CONFIG: EgressConfig = {
27
+ enabled: false,
28
+ mode: "audit",
29
+ denyPaths: [],
30
+ denyGlobs: [],
31
+ denyContentMarkers: [...DEFAULT_CONTENT_MARKERS],
32
+ maxScanBytes: 1_000_000,
33
+ scanToolOutput: true,
34
+ };
@@ -0,0 +1,335 @@
1
+ /**
2
+ * Application-layer egress policy.
3
+ *
4
+ * This is intentionally a pure policy module plus a thin registry hook. It is
5
+ * not a kernel and cannot stop code that bypasses FastCode's tool registry.
6
+ *
7
+ * Default posture: audit only (`enabled:false`, `mode:"audit"`). Set
8
+ * `egress.enabled=true` and `egress.mode="block"` to enforce.
9
+ */
10
+ import { basename, relative, resolve } from "node:path";
11
+ import { isCriticalReadTarget, isSensitiveTarget, isSharedStoragePath } from "../guards.js";
12
+ import type { EgressAudit, ToolCall } from "../tools/types.js";
13
+ import { DEFAULT_CONTENT_MARKERS, DEFAULT_EGRESS_CONFIG, type EgressConfig, type EgressMode } from "./defaults.js";
14
+
15
+ export type { EgressConfig, EgressMode } from "./defaults.js";
16
+ export { DEFAULT_CONTENT_MARKERS, DEFAULT_EGRESS_CONFIG } from "./defaults.js";
17
+
18
+ export type EgressAction = "read" | "write" | "delete" | "egress" | "payload";
19
+
20
+ export interface EgressSubject {
21
+ outlet: string;
22
+ action: EgressAction;
23
+ target?: string;
24
+ payload?: string;
25
+ outbound?: boolean;
26
+ }
27
+
28
+ export interface EgressDecision {
29
+ outlet: string;
30
+ target?: string;
31
+ rule: string;
32
+ mode: EgressMode;
33
+ wouldBlock: boolean;
34
+ code: "EGRESS_DENY";
35
+ reason: string;
36
+ }
37
+
38
+ export interface EgressEvaluation {
39
+ action: "allow" | "audit" | "deny";
40
+ decision?: EgressDecision;
41
+ }
42
+
43
+ function strings(value: any): string[] {
44
+ return Array.isArray(value) ? value.filter((entry) => typeof entry === "string" && entry.trim()).map((entry) => entry.trim()) : [];
45
+ }
46
+
47
+ function positiveInt(value: any, fallback: number): number {
48
+ const number = Number(value);
49
+ return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
50
+ }
51
+
52
+ export function normalizeEgressConfig(raw: any): EgressConfig {
53
+ const source = raw && typeof raw === "object" ? raw : {};
54
+ const mode: EgressMode = source.mode === "block" ? "block" : source.mode === "off" ? "off" : "audit";
55
+ return {
56
+ enabled: source.enabled === true,
57
+ mode,
58
+ denyPaths: strings(source.denyPaths),
59
+ denyGlobs: strings(source.denyGlobs),
60
+ // Built-in markers cannot be removed by configuration.
61
+ denyContentMarkers: Array.from(new Set([...DEFAULT_CONTENT_MARKERS, ...strings(source.denyContentMarkers)])),
62
+ maxScanBytes: positiveInt(source.maxScanBytes, DEFAULT_EGRESS_CONFIG.maxScanBytes),
63
+ scanToolOutput: source.scanToolOutput !== false,
64
+ };
65
+ }
66
+
67
+ interface CompiledPolicy {
68
+ markers: RegExp[];
69
+ globs: RegExp[];
70
+ denyPaths: string[];
71
+ }
72
+
73
+ const compileCache = new Map<string, CompiledPolicy>();
74
+
75
+ function safeRegExp(pattern: string): RegExp | undefined {
76
+ try {
77
+ return new RegExp(pattern, "i");
78
+ }
79
+ catch {
80
+ return undefined;
81
+ }
82
+ }
83
+
84
+ function globToRegExp(glob: string): RegExp {
85
+ let source = "^";
86
+ for (let index = 0; index < glob.length; index += 1) {
87
+ const char = glob[index];
88
+ if (char === "*") {
89
+ if (glob[index + 1] === "*") {
90
+ source += ".*";
91
+ index += 1;
92
+ if (glob[index + 1] === "/")
93
+ index += 1;
94
+ }
95
+ else {
96
+ source += "[^/]*";
97
+ }
98
+ }
99
+ else if (char === "?") {
100
+ source += "[^/]";
101
+ }
102
+ else if (/[.+^${}()|[\]\\]/.test(char)) {
103
+ source += "\\" + char;
104
+ }
105
+ else {
106
+ source += char;
107
+ }
108
+ }
109
+ return new RegExp(source + "$", "i");
110
+ }
111
+
112
+ function compile(config: EgressConfig): CompiledPolicy {
113
+ const key = JSON.stringify(config);
114
+ const cached = compileCache.get(key);
115
+ if (cached)
116
+ return cached;
117
+ const compiled: CompiledPolicy = {
118
+ markers: config.denyContentMarkers.map(safeRegExp).filter(Boolean) as RegExp[],
119
+ globs: config.denyGlobs.map(globToRegExp),
120
+ denyPaths: config.denyPaths.map((entry) => resolve(entry).replace(/\\/g, "/")),
121
+ };
122
+ compileCache.set(key, compiled);
123
+ if (compileCache.size > 8)
124
+ compileCache.delete(compileCache.keys().next().value as string);
125
+ return compiled;
126
+ }
127
+
128
+ function expandHome(target: string): string {
129
+ const home = process.env["HOME"] || process.env["USERPROFILE"] || "";
130
+ if (!home)
131
+ return target;
132
+ if (target === "~")
133
+ return home;
134
+ if (target.startsWith("~/") || target.startsWith("~\\"))
135
+ return home + target.slice(1);
136
+ return target.replace(/^\$(?:HOME|\{HOME\})/i, home);
137
+ }
138
+
139
+ function normalizeTarget(target: string, cwd: string): string {
140
+ return resolve(cwd, expandHome(target)).replace(/\\/g, "/");
141
+ }
142
+
143
+ function isInside(child: string, parent: string): boolean {
144
+ const rel = relative(parent, child).replace(/\\/g, "/");
145
+ return rel === "" || (!rel.startsWith("..") && !rel.startsWith("/"));
146
+ }
147
+
148
+ function markerHit(payload: string, compiled: CompiledPolicy): string | undefined {
149
+ for (const marker of compiled.markers) {
150
+ if (marker.test(payload))
151
+ return marker.source;
152
+ }
153
+ return undefined;
154
+ }
155
+
156
+ function scanWindow(payload: string, maxBytes: number): string {
157
+ if (Buffer.byteLength(payload, "utf8") <= maxBytes)
158
+ return payload;
159
+ const buffer = Buffer.from(payload, "utf8");
160
+ const half = Math.max(1, Math.floor(maxBytes / 2));
161
+ return `${buffer.subarray(0, half).toString("utf8")}\n${buffer.subarray(buffer.length - half).toString("utf8")}`;
162
+ }
163
+
164
+ function evaluateSubject(subject: EgressSubject, cwd: string, config: EgressConfig, compiled: CompiledPolicy): EgressDecision | undefined {
165
+ const decide = (rule: string, reason: string): EgressDecision => ({
166
+ outlet: subject.outlet,
167
+ target: subject.target,
168
+ rule,
169
+ mode: config.mode,
170
+ wouldBlock: true,
171
+ code: "EGRESS_DENY",
172
+ reason,
173
+ });
174
+ if (subject.action === "payload") {
175
+ const payload = subject.payload ?? "";
176
+ if (!payload)
177
+ return undefined;
178
+ const overBudget = Buffer.byteLength(payload, "utf8") > config.maxScanBytes;
179
+ if (overBudget && subject.outbound)
180
+ return decide("scan_budget_exceeded", `payload exceeds the ${config.maxScanBytes}-byte egress scan budget`);
181
+ const marker = markerHit(scanWindow(payload, config.maxScanBytes), compiled);
182
+ if (marker)
183
+ return decide("content_marker", `payload matched denyContentMarkers (${marker})`);
184
+ return undefined;
185
+ }
186
+ const target = subject.target;
187
+ if (!target)
188
+ return undefined;
189
+ if (subject.action === "read" && isCriticalReadTarget(target))
190
+ return decide("critical_credential_read", "reading a critical private key or system credential file");
191
+ if (subject.action !== "read" && isSensitiveTarget(target))
192
+ return decide("sensitive_path", "writing or deleting a protected credential/session file");
193
+ if (subject.action !== "read" && isSharedStoragePath(target))
194
+ return decide("shared_storage", "writing to shared storage is treated as an egress surface");
195
+ const normalized = normalizeTarget(target, cwd);
196
+ for (const denyPath of compiled.denyPaths) {
197
+ if (isInside(normalized, denyPath))
198
+ return decide("config:deny_path", `path matches denyPaths entry ${denyPath}`);
199
+ }
200
+ for (const glob of compiled.globs) {
201
+ if (glob.test(normalized) || glob.test(basename(normalized)))
202
+ return decide("config:deny_glob", "path matches a denyGlobs entry");
203
+ }
204
+ return undefined;
205
+ }
206
+
207
+ function pathSubject(outlet: string, action: EgressAction, target: unknown): EgressSubject | undefined {
208
+ return typeof target === "string" && target ? { outlet, action, target } : undefined;
209
+ }
210
+
211
+ function patchTargets(patch: string): EgressSubject[] {
212
+ const subjects: EgressSubject[] = [];
213
+ const filePattern = /^\*\*\* (Add|Update|Delete) File: (.+)$/gm;
214
+ for (const match of patch.matchAll(filePattern)) {
215
+ const action: EgressAction = match[1] === "Delete" ? "delete" : "write";
216
+ subjects.push({ outlet: "apply_patch.path", action, target: match[2].trim() });
217
+ }
218
+ const movePattern = /^\*\*\* Move to: (.+)$/gm;
219
+ for (const match of patch.matchAll(movePattern))
220
+ subjects.push({ outlet: "apply_patch.move", action: "write", target: match[1].trim() });
221
+ return subjects;
222
+ }
223
+
224
+ export function egressSubjects(call: ToolCall): EgressSubject[] {
225
+ const subjects: EgressSubject[] = [];
226
+ const add = (subject: EgressSubject | undefined) => {
227
+ if (subject)
228
+ subjects.push(subject);
229
+ };
230
+ switch (call.tool) {
231
+ case "read_file":
232
+ case "list_files":
233
+ case "stat_path":
234
+ case "grep":
235
+ add(pathSubject(`${call.tool}.path`, "read", typeof call.path === "string" ? call.path : "."));
236
+ break;
237
+ case "read_many":
238
+ if (Array.isArray(call.paths)) {
239
+ for (const path of call.paths)
240
+ add(pathSubject("read_many.paths", "read", path));
241
+ }
242
+ break;
243
+ case "write_file":
244
+ case "append_file":
245
+ add(pathSubject(`${call.tool}.path`, "write", call.path));
246
+ if (typeof call.content === "string")
247
+ subjects.push({ outlet: `${call.tool}.content`, action: "payload", payload: call.content, outbound: false });
248
+ break;
249
+ case "replace_in_file":
250
+ add(pathSubject("replace_in_file.path", "write", call.path));
251
+ if (typeof call.replace === "string")
252
+ subjects.push({ outlet: "replace_in_file.replace", action: "payload", payload: call.replace, outbound: false });
253
+ break;
254
+ case "delete_file":
255
+ add(pathSubject("delete_file.path", "delete", call.path));
256
+ break;
257
+ case "make_dir":
258
+ add(pathSubject("make_dir.path", "write", call.path));
259
+ break;
260
+ case "copy_file":
261
+ add(pathSubject("copy_file.from", "egress", call.from));
262
+ add(pathSubject("copy_file.to", "write", call.to));
263
+ break;
264
+ case "move_path":
265
+ add(pathSubject("move_path.from", "egress", call.from));
266
+ add(pathSubject("move_path.to", "write", call.to));
267
+ break;
268
+ case "apply_patch":
269
+ if (typeof call.patch === "string") {
270
+ subjects.push({ outlet: "apply_patch.patch", action: "payload", payload: call.patch, outbound: false });
271
+ subjects.push(...patchTargets(call.patch));
272
+ }
273
+ break;
274
+ case "shell":
275
+ if (typeof call.command === "string")
276
+ subjects.push({ outlet: "shell.command", action: "payload", payload: call.command, outbound: true });
277
+ break;
278
+ default:
279
+ break;
280
+ }
281
+ return subjects;
282
+ }
283
+
284
+ export function evaluateEgressCall(call: ToolCall, cwd: string, config: EgressConfig): EgressEvaluation {
285
+ if (config.mode === "off")
286
+ return { action: "allow" };
287
+ const compiled = compile(config);
288
+ for (const subject of egressSubjects(call)) {
289
+ const decision = evaluateSubject(subject, cwd, config, compiled);
290
+ if (!decision)
291
+ continue;
292
+ const enforce = config.enabled && config.mode === "block";
293
+ return { action: enforce ? "deny" : "audit", decision };
294
+ }
295
+ return { action: "allow" };
296
+ }
297
+
298
+ export function evaluateEgressOutput(tool: string, output: string, config: EgressConfig): EgressEvaluation {
299
+ if (!config.scanToolOutput || config.mode === "off" || !output)
300
+ return { action: "allow" };
301
+ const compiled = compile(config);
302
+ const decision = evaluateSubject({
303
+ outlet: `${tool}.output`,
304
+ action: "payload",
305
+ payload: output,
306
+ outbound: true,
307
+ }, process.cwd(), config, compiled);
308
+ if (!decision)
309
+ return { action: "allow" };
310
+ const enforce = config.enabled && config.mode === "block";
311
+ return { action: enforce ? "deny" : "audit", decision };
312
+ }
313
+
314
+ export function egressAudit(decision: EgressDecision): EgressAudit {
315
+ return {
316
+ outlet: decision.outlet,
317
+ target: decision.target,
318
+ rule: decision.rule,
319
+ mode: decision.mode === "block" ? "block" : "audit",
320
+ wouldBlock: decision.wouldBlock,
321
+ code: decision.code,
322
+ reason: decision.reason,
323
+ };
324
+ }
325
+
326
+ export function formatEgressDenial(decision: EgressDecision): string {
327
+ const target = decision.target ? ` target=${JSON.stringify(decision.target)}` : "";
328
+ return `[egress] DENY rule=${decision.rule}${target} reason=${decision.reason}\n` +
329
+ "This decision cannot be overridden by FASTCODE_ALLOW_DANGEROUS, trust, sharedDirs, or session/always approval.";
330
+ }
331
+
332
+ export function formatEgressAudit(audit: EgressAudit): string {
333
+ const target = audit.target ? ` target=${JSON.stringify(audit.target)}` : "";
334
+ return `[egress] AUDIT rule=${audit.rule}${target} would_block=${audit.wouldBlock} reason=${audit.reason}`;
335
+ }
@@ -1,32 +1,114 @@
1
1
  import { spawn, type ChildProcess } from "node:child_process";
2
2
 
3
- export function killProcessTree(child: ChildProcess | undefined, signal: NodeJS.Signals = "SIGTERM"): void {
4
- if (!child || typeof child.pid !== "number")
5
- return;
6
- if (process.platform === "win32") {
3
+ export const KILL_GRACE_MS = 1000;
4
+
5
+ const pendingKills = new WeakMap<ChildProcess, ReturnType<typeof setTimeout>>();
6
+
7
+ export interface TerminateOptions {
8
+ /** Milliseconds to wait after SIGTERM before escalating to SIGKILL. */
9
+ graceMs?: number;
10
+ }
11
+
12
+ function sendSignal(pid: number, signal: NodeJS.Signals): "sent" | "gone" {
7
13
  try {
8
- const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" });
9
- killer.unref();
14
+ process.kill(-pid, signal);
15
+ return "sent";
10
16
  }
11
- catch {
12
- try {
13
- child.kill();
14
- }
15
- catch {
16
- // The process may already be gone.
17
- }
17
+ catch (err: any) {
18
+ if (err?.code !== "ESRCH")
19
+ return "sent";
20
+ try {
21
+ process.kill(pid, signal);
22
+ return "sent";
23
+ }
24
+ catch (err2: any) {
25
+ return err2?.code === "ESRCH" ? "gone" : "sent";
26
+ }
18
27
  }
19
- return;
20
- }
21
- try {
22
- process.kill(-child.pid, signal);
23
- }
24
- catch {
28
+ }
29
+
30
+ function isAlive(pid: number): boolean {
25
31
  try {
26
- child.kill(signal);
32
+ process.kill(-pid, 0);
33
+ return true;
27
34
  }
28
- catch {
29
- // Same as above.
35
+ catch (err: any) {
36
+ if (err?.code !== "ESRCH")
37
+ return true;
38
+ try {
39
+ process.kill(pid, 0);
40
+ return true;
41
+ }
42
+ catch (err2: any) {
43
+ return err2?.code !== "ESRCH";
44
+ }
45
+ }
46
+ }
47
+
48
+ function clearPending(child: ChildProcess): void {
49
+ const timer = pendingKills.get(child);
50
+ if (timer) {
51
+ clearTimeout(timer);
52
+ pendingKills.delete(child);
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Send a single signal to the process group (falling back to the pid).
58
+ * Kept for callers that need one-shot signalling; shell sessions should use
59
+ * terminateProcessTree so SIGKILL escalation is always available.
60
+ */
61
+ export function killProcessTree(child: ChildProcess | undefined, signal: NodeJS.Signals = "SIGTERM"): void {
62
+ if (!child || typeof child.pid !== "number")
63
+ return;
64
+ if (process.platform === "win32") {
65
+ try {
66
+ const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" });
67
+ killer.unref();
68
+ }
69
+ catch {
70
+ try {
71
+ child.kill();
72
+ }
73
+ catch {
74
+ // The process may already be gone.
75
+ }
76
+ }
77
+ return;
78
+ }
79
+ sendSignal(child.pid, signal);
80
+ }
81
+
82
+ /**
83
+ * Terminate a spawned process group with a SIGTERM -> grace -> SIGKILL flow.
84
+ *
85
+ * Boundary: this only guarantees the spawned process group is killed. A
86
+ * descendant that calls setsid() leaves the group and may escape; we do not
87
+ * scan for ppid=1 processes because that risks killing unrelated background
88
+ * jobs on Termux. Callers should report
89
+ * "process group killed; setsid descendants may escape".
90
+ */
91
+ export function terminateProcessTree(child: ChildProcess | undefined, options: TerminateOptions = {}): void {
92
+ if (!child || typeof child.pid !== "number")
93
+ return;
94
+ if (process.platform === "win32") {
95
+ killProcessTree(child, "SIGTERM");
96
+ return;
30
97
  }
31
- }
98
+ const pid = child.pid;
99
+ clearPending(child);
100
+ if (sendSignal(pid, "SIGTERM") === "gone")
101
+ return;
102
+ const graceMs = Math.max(0, Math.floor(options.graceMs ?? KILL_GRACE_MS));
103
+ const timer = setTimeout(() => {
104
+ pendingKills.delete(child);
105
+ if (child.exitCode !== null || child.signalCode !== null)
106
+ return;
107
+ if (!isAlive(pid))
108
+ return;
109
+ sendSignal(pid, "SIGKILL");
110
+ }, graceMs);
111
+ timer.unref?.();
112
+ pendingKills.set(child, timer);
113
+ child.once("exit", () => clearPending(child));
32
114
  }