@akira-tl/forgerelay 0.9.3 → 0.10.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 (109) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +10 -4
  3. package/dist/cli/init.js +111 -24
  4. package/dist/cli/maintenance-prune.js +479 -0
  5. package/dist/cli/maintenance-retention.js +93 -0
  6. package/dist/cli/maintenance.js +598 -0
  7. package/dist/cli/setup-support.js +21 -0
  8. package/dist/cli/shell/setup.js +99 -0
  9. package/dist/cli.js +72 -16
  10. package/dist/mcp/hooks/hooks.js +6 -3
  11. package/dist/mcp/oauth/public-url.js +3 -0
  12. package/dist/mcp/oauth/router.js +14 -9
  13. package/dist/mcp/process/process-platform.js +24 -26
  14. package/dist/mcp/process/process-sessions.js +6 -3
  15. package/dist/mcp/server/transport/http-server.js +17 -9
  16. package/dist/mcp/server/workspace/runtime/workspace-open-presentation.js +6 -0
  17. package/dist/mcp/server/workspace/runtime/workspace-tools.js +1 -1
  18. package/dist/mcp/server-instructions.js +10 -1
  19. package/dist/runtime/config/config.js +60 -6
  20. package/dist/runtime/instructions/shell-instructions.js +100 -0
  21. package/dist/runtime/security/runtime-privilege.js +120 -0
  22. package/dist/runtime/shell/command-shell-runtime.js +235 -0
  23. package/dist/runtime/state/runtime-lease.js +109 -0
  24. package/dist/ui/.vite/manifest.json +276 -276
  25. package/dist/ui/activity-panel-app.html +2 -2
  26. package/dist/ui/assets/{activity-panel-app-Sl1Jro8v.js → activity-panel-app-BkODhDLg.js} +1 -1
  27. package/dist/ui/assets/{activity-panel-app-7Xe0alhm.css → activity-panel-app-DkfJzTZ3.css} +1 -1
  28. package/dist/ui/assets/{angular-html-B_yn91wW.js → angular-html-DjuELMdF.js} +1 -1
  29. package/dist/ui/assets/{angular-ts-DSgtloKz.js → angular-ts-2EXVZIIX.js} +1 -1
  30. package/dist/ui/assets/{apl-NatWtF8X.js → apl-Bb4IeT5V.js} +1 -1
  31. package/dist/ui/assets/{astro-BYFEVBuN.js → astro-Bjavu3nT.js} +1 -1
  32. package/dist/ui/assets/{blade-DoO7yvMR.js → blade-DKW2uHZo.js} +1 -1
  33. package/dist/ui/assets/{c-Rl2hMCcg.js → c-oLeHmzbj.js} +1 -1
  34. package/dist/ui/assets/{cobol-ByC5mWiB.js → cobol-CcqFMr5T.js} +1 -1
  35. package/dist/ui/assets/{coffee-Dez77Qov.js → coffee-BA20zqRF.js} +1 -1
  36. package/dist/ui/assets/{cpp-DtaAQT9h.js → cpp-D750WmDa.js} +1 -1
  37. package/dist/ui/assets/{crystal-Bgmbf8ec.js → crystal-Cv5I6OMI.js} +1 -1
  38. package/dist/ui/assets/{css-C1tjBtVN.js → css-CjPPWPeH.js} +1 -1
  39. package/dist/ui/assets/{edge-DeH6u8Wg.js → edge-aYRuudEZ.js} +1 -1
  40. package/dist/ui/assets/{elixir-_H3KnmWT.js → elixir-ChLitmz7.js} +1 -1
  41. package/dist/ui/assets/{elm-CsCe3U28.js → elm-Fs7L7u36.js} +1 -1
  42. package/dist/ui/assets/{erb-CbM-L2g8.js → erb-B-qXHxN1.js} +1 -1
  43. package/dist/ui/assets/{git-rebase-D0s5pDh9.js → git-rebase-DOVN5OEg.js} +1 -1
  44. package/dist/ui/assets/{glimmer-js-BfOToq_A.js → glimmer-js-BFt_vCTW.js} +1 -1
  45. package/dist/ui/assets/{glimmer-ts-CCUOkL2K.js → glimmer-ts-ufCHlR6C.js} +1 -1
  46. package/dist/ui/assets/{glsl-DH4ec9dE.js → glsl-Ex4JXGuC.js} +1 -1
  47. package/dist/ui/assets/{graphql-DilAMBoI.js → graphql-pQPXtBk5.js} +1 -1
  48. package/dist/ui/assets/{hack-BDkNDZlF.js → hack-B5hDguhd.js} +1 -1
  49. package/dist/ui/assets/{haml-D_QBp6K7.js → haml-BrNeI29U.js} +1 -1
  50. package/dist/ui/assets/{handlebars-DePUrTpC.js → handlebars-DCqTsL9-.js} +1 -1
  51. package/dist/ui/assets/{heavy-payload-DOwZpO6n.js → heavy-payload-DsmmHgvt.js} +3 -3
  52. package/dist/ui/assets/{html-By4dNnM4.js → html-D8k0CC9V.js} +1 -1
  53. package/dist/ui/assets/{html-derivative-DDw9Ef4Q.js → html-derivative-CC8flWmC.js} +1 -1
  54. package/dist/ui/assets/{http-B0QxWc9M.js → http-BYnWqNep.js} +1 -1
  55. package/dist/ui/assets/{hurl-vMQ4lda1.js → hurl-BND-z85s.js} +1 -1
  56. package/dist/ui/assets/{java-DYRe2lWG.js → java-Dug0oB9T.js} +1 -1
  57. package/dist/ui/assets/{javascript-CxyGqwcL.js → javascript-oDJ7WITC.js} +1 -1
  58. package/dist/ui/assets/{jinja-JUwV-fZt.js → jinja-Cv0t_F1U.js} +1 -1
  59. package/dist/ui/assets/{jison-7ZqCEvS8.js → jison-Car3MF55.js} +1 -1
  60. package/dist/ui/assets/{json-dbltgiiA.js → json-CEViCsf4.js} +1 -1
  61. package/dist/ui/assets/{jsx-oDopT7YQ.js → jsx-BfZ0zgSH.js} +1 -1
  62. package/dist/ui/assets/{julia-CkKiNSav.js → julia-CmX0b3Tn.js} +1 -1
  63. package/dist/ui/assets/{just-DuqCAKJ3.js → just-BuugLCVk.js} +1 -1
  64. package/dist/ui/assets/{latex-sudcRDSn.js → latex-FpTXqO03.js} +1 -1
  65. package/dist/ui/assets/{liquid-Di-08Js7.js → liquid-B37OzUFu.js} +1 -1
  66. package/dist/ui/assets/{lua-YlqdMoV0.js → lua-BHjcb8oq.js} +1 -1
  67. package/dist/ui/assets/{marko-CJRNyJDb.js → marko-CApj_fsH.js} +1 -1
  68. package/dist/ui/assets/{mdc-t12CzZHs.js → mdc-Ed5vQWq8.js} +1 -1
  69. package/dist/ui/assets/{nginx-57-b4XwT.js → nginx-u_rVEBox.js} +1 -1
  70. package/dist/ui/assets/{nim-BzrswYQa.js → nim-Ba9EdchT.js} +1 -1
  71. package/dist/ui/assets/{perl-D9JR_iIh.js → perl-BpU_6Wvz.js} +1 -1
  72. package/dist/ui/assets/{php-Py2gFHil.js → php-bKabofky.js} +1 -1
  73. package/dist/ui/assets/{pug-PzLLy0h1.js → pug-C_aLNkqe.js} +1 -1
  74. package/dist/ui/assets/{qml-By8fwgV4.js → qml-D9VisnRy.js} +1 -1
  75. package/dist/ui/assets/{r-CcezTbLq.js → r-CYqB1sLx.js} +1 -1
  76. package/dist/ui/assets/{razor-dG-vdhL_.js → razor-DNJKI8Rs.js} +1 -1
  77. package/dist/ui/assets/{regexp-BlsQ-N_8.js → regexp-DHrTHXkz.js} +1 -1
  78. package/dist/ui/assets/{rst-C2awDCat.js → rst-CbNdSFHv.js} +1 -1
  79. package/dist/ui/assets/{ruby-zA2W_49d.js → ruby-FyKG-pkF.js} +1 -1
  80. package/dist/ui/assets/{sas-D49bhyaN.js → sas-BbSPDl3X.js} +1 -1
  81. package/dist/ui/assets/{scss-BQb-cp01.js → scss-2nX9g6gD.js} +1 -1
  82. package/dist/ui/assets/{shellscript-Dga4H_Ni.js → shellscript-CmON6x2x.js} +1 -1
  83. package/dist/ui/assets/{shellsession-n3-5CqcF.js → shellsession-DtqIGlAX.js} +1 -1
  84. package/dist/ui/assets/{soy-XYBOf_BA.js → soy-BzUJMmJT.js} +1 -1
  85. package/dist/ui/assets/{sql-ET4u5Cpl.js → sql-CgVcFZmb.js} +1 -1
  86. package/dist/ui/assets/{stata-CLHcJHO_.js → stata-CNitlMot.js} +1 -1
  87. package/dist/ui/assets/{surrealql-BdjQ60T4.js → surrealql-BA8-xsRZ.js} +1 -1
  88. package/dist/ui/assets/{svelte-B2RPtKJe.js → svelte-El02yAxv.js} +1 -1
  89. package/dist/ui/assets/{templ-CrByPW_W.js → templ-DeUQ84aR.js} +1 -1
  90. package/dist/ui/assets/{tex-DZBkg363.js → tex-Cu-FqpPS.js} +1 -1
  91. package/dist/ui/assets/{ts-tags-DJR3Gphb.js → ts-tags-X42N6TGA.js} +1 -1
  92. package/dist/ui/assets/{tsx-CajzROyy.js → tsx-C1aPp9rY.js} +1 -1
  93. package/dist/ui/assets/{twig-BZjk5Zda.js → twig-Cj1Q4SQc.js} +1 -1
  94. package/dist/ui/assets/{typescript-B-EWToLj.js → typescript-Du9lVt1G.js} +1 -1
  95. package/dist/ui/assets/{vue-D_rHCCr8.js → vue-_uMGbFv8.js} +1 -1
  96. package/dist/ui/assets/{vue-html-CC4WlvVt.js → vue-html-D61naG-Q.js} +1 -1
  97. package/dist/ui/assets/{vue-vine-BgNMVgpk.js → vue-vine-CkFae4yV.js} +1 -1
  98. package/dist/ui/assets/{xml-BI86-WbH.js → xml-Cbwsc6ws.js} +1 -1
  99. package/dist/ui/assets/{xsl-DXeLZV4J.js → xsl-NyBx72XB.js} +1 -1
  100. package/dist/ui/assets/{yaml-60_PFlWa.js → yaml-Cgb6Uq9u.js} +1 -1
  101. package/dist/workspaces/context.js +32 -5
  102. package/dist/workspaces/presentation/workspace-presentation.js +1 -0
  103. package/dist/workspaces/sessions.js +2 -0
  104. package/dist/workspaces.js +2 -1
  105. package/docs/configuration.md +101 -14
  106. package/package.json +2 -2
  107. package/scripts/release/release-gate.test.mjs +1 -0
  108. package/scripts/release-proof.mjs +85 -9
  109. package/scripts/release-proof.test.mjs +79 -1
@@ -1,3 +1,4 @@
1
+ import { commandShellAgentInstruction } from "../runtime/shell/command-shell-runtime.js";
1
2
  export const toolNames = {
2
3
  openWorkspace: "open_workspace",
3
4
  closeWorkspace: "close_workspace",
@@ -14,7 +15,7 @@ export function buildShellMutationPolicy() {
14
15
  return "Shell commands may modify ordinary project files when that is a natural part of the user's requested development task. They may also perform external device or hardware mutations when the user's current request explicitly asks for the actual device-changing operation, including firmware flashing or equivalent persistent device updates; do not infer such authorization from a check, audit, probe, backup, verification, dry-run, or build-only request. Never use shell commands to modify security- or privilege-sensitive operating-system files or credential material such as /etc/sudoers, /etc/passwd, /etc/shadow, PAM or authentication policy, SSH private keys, or equivalent privileged system files. Modify configuration files through shell only when the user's request explicitly calls for that configuration change; do not infer permission merely because changing configuration would be convenient.";
15
16
  }
16
17
  export function buildServerInstructions(config) {
17
- return joinInstructions(capabilityContractInstructions(config), selectedWorkflowInstructions(config), config.appendInstructions);
18
+ return joinInstructions(runtimePrivilegeInstructions(config), commandShellAgentInstruction(config.commandShellRuntime), capabilityContractInstructions(config), selectedWorkflowInstructions(config), config.appendInstructions);
18
19
  }
19
20
  export function buildToolDescriptions(config) {
20
21
  const skillCapability = config.skillsEnabled
@@ -34,6 +35,14 @@ export function buildToolDescriptions(config) {
34
35
  shellCommand: "Shell command to run with the local user's authority.",
35
36
  };
36
37
  }
38
+ function runtimePrivilegeInstructions(config) {
39
+ if (!config.runtimePrivilege || config.runtimePrivilege.level === "standard")
40
+ return "";
41
+ const status = config.runtimePrivilege.level === "elevated"
42
+ ? "elevated operating-system privileges"
43
+ : "an unverified operating-system privilege level accepted through --allow-elevated";
44
+ return `Runtime security: ForgeRelay is running with ${status}. Agent and Hook shell commands inherit this authority. Do not perform system-wide, privileged, security-sensitive, or irreversible mutations unless the user's current request explicitly authorizes that exact change. This safety notice is mandatory and is not disabled by shell-guidance settings.`;
45
+ }
37
46
  function capabilityContractInstructions(config) {
38
47
  const staleWorkspacePolicy = config.toolMode === "codex"
39
48
  ? ""
@@ -1,8 +1,11 @@
1
+ import { isIP } from "node:net";
1
2
  import { homedir } from "node:os";
2
3
  import { join, resolve } from "node:path";
3
4
  import { expandHomePath } from "../../mcp/filesystem/roots.js";
4
5
  import { mergeHookConfigs, parseHookConfig } from "../../mcp/hooks/hooks.js";
5
6
  import { forgerelayAgentsDir, forgerelaySkillsDir, generateInstanceId, loadForgeRelayFiles, } from "./user-config.js";
7
+ import { shellInstructionPath } from "../instructions/shell-instructions.js";
8
+ import { resolveConfiguredCommandShellRuntime, } from "../shell/command-shell-runtime.js";
6
9
  const DEFAULT_OAUTH_ACCESS_TOKEN_TTL_SECONDS = 60 * 60;
7
10
  const DEFAULT_OAUTH_REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60;
8
11
  const DEFAULT_ARTIFACT_MAX_FILE_BYTES = 100 * 1024 * 1024;
@@ -118,12 +121,11 @@ function parseNonNegativeInteger(value, fallback, name) {
118
121
  }
119
122
  return parsed;
120
123
  }
121
- function parseLoggingConfig(env, trustProxyDefault) {
124
+ function parseLoggingConfig(env, trustProxy) {
122
125
  const format = parseLogFormat(productEnv(env, "LOG_FORMAT"));
123
126
  const requests = productEnv(env, "LOG_REQUESTS");
124
127
  const toolCalls = productEnv(env, "LOG_TOOL_CALLS");
125
128
  const shellCommands = productEnv(env, "LOG_SHELL_COMMANDS");
126
- const trustProxy = productEnv(env, "TRUST_PROXY");
127
129
  return {
128
130
  level: parseLogLevel(productEnv(env, "LOG_LEVEL")),
129
131
  format,
@@ -131,11 +133,57 @@ function parseLoggingConfig(env, trustProxyDefault) {
131
133
  assets: parseBoolean(productEnv(env, "LOG_ASSETS")),
132
134
  toolCalls: toolCalls === undefined ? true : parseBoolean(toolCalls),
133
135
  shellCommands: shellCommands === undefined ? format === "pretty" : parseBoolean(shellCommands),
134
- trustProxy: trustProxy === undefined ? trustProxyDefault : parseBoolean(trustProxy),
136
+ trustProxy,
135
137
  };
136
138
  }
137
- function shouldTrustOneProxyByDefault(host, publicBaseUrl) {
138
- return isLoopbackHost(host) && !isLoopbackHost(new URL(publicBaseUrl).hostname);
139
+ function resolveProxyTrust(env, config, host, publicBaseUrl) {
140
+ const legacyTrustProxy = productEnv(env, "TRUST_PROXY");
141
+ if (legacyTrustProxy !== undefined) {
142
+ if (!parseBoolean(legacyTrustProxy))
143
+ return false;
144
+ if (!isLoopbackHost(host)) {
145
+ throw new Error("FORGERELAY_TRUST_PROXY=1 is only safe with a loopback bind. Use FORGERELAY_TRUSTED_PROXIES with explicit proxy IP addresses or CIDRs for LAN binds.");
146
+ }
147
+ return ["loopback"];
148
+ }
149
+ const envTrustedProxies = productEnv(env, "TRUSTED_PROXIES");
150
+ const explicitTrustedProxies = parseTrustedProxies(envTrustedProxies === undefined ? config.trustedProxies : envTrustedProxies);
151
+ if (explicitTrustedProxies !== undefined)
152
+ return explicitTrustedProxies;
153
+ return isLoopbackHost(host) && !isLoopbackHost(new URL(publicBaseUrl).hostname)
154
+ ? ["loopback"]
155
+ : false;
156
+ }
157
+ function parseTrustedProxies(value) {
158
+ if (value === undefined)
159
+ return undefined;
160
+ const entries = (Array.isArray(value) ? value : value.split(","))
161
+ .map((entry) => entry.trim())
162
+ .filter(Boolean);
163
+ if (entries.length === 0)
164
+ return undefined;
165
+ if (entries.some((entry) => !isTrustedProxyAddress(entry))) {
166
+ throw new Error("FORGERELAY_TRUSTED_PROXIES must list trusted proxy IP addresses or CIDRs; only the internal `loopback` alias is also accepted.");
167
+ }
168
+ return Array.from(new Set(entries));
169
+ }
170
+ function isTrustedProxyAddress(value) {
171
+ if (value === "loopback")
172
+ return true;
173
+ if (value === "*" || value === "0.0.0.0/0" || value === "::/0")
174
+ return false;
175
+ if (isIP(value) !== 0)
176
+ return true;
177
+ const slashIndex = value.lastIndexOf("/");
178
+ if (slashIndex <= 0 || slashIndex === value.length - 1)
179
+ return false;
180
+ const address = value.slice(0, slashIndex);
181
+ const prefixText = value.slice(slashIndex + 1);
182
+ const family = isIP(address);
183
+ const prefix = Number(prefixText);
184
+ if (!Number.isInteger(prefix) || prefix < 0)
185
+ return false;
186
+ return family === 4 ? prefix <= 32 : family === 6 ? prefix <= 128 : false;
139
187
  }
140
188
  function isLoopbackHost(host) {
141
189
  const normalized = host.trim().toLowerCase().replace(/^\[(.*)\]$/, "$1");
@@ -221,6 +269,8 @@ export function loadConfig(env = process.env) {
221
269
  const port = parsePort(env.PORT ?? files.config.port);
222
270
  const publicDeployment = resolvePublicDeployment(env, files.config, host, port);
223
271
  const publicBaseUrl = publicDeployment.canonicalBaseUrl;
272
+ const proxyTrust = resolveProxyTrust(env, files.config, host, publicBaseUrl);
273
+ const commandShellRuntime = resolveConfiguredCommandShellRuntime(files.config.commandShell, process.platform, env);
224
274
  const derivedAllowedHosts = [
225
275
  "localhost",
226
276
  "127.0.0.1",
@@ -239,6 +289,7 @@ export function loadConfig(env = process.env) {
239
289
  allowedHosts: parseAllowedHosts(productEnv(env, "ALLOWED_HOSTS"), derivedAllowedHosts),
240
290
  publicBaseUrl,
241
291
  publicBaseUrls: publicDeployment.baseUrls,
292
+ proxyTrust,
242
293
  toolMode: parseToolMode(env),
243
294
  workflowInstructions: parseWorkflowInstructions(productEnv(env, "WORKFLOW_INSTRUCTIONS"), files.config.workflowInstructions),
244
295
  appendInstructions: parseAppendInstructions(productEnv(env, "APPEND_INSTRUCTIONS"), files.config.appendInstructions),
@@ -265,7 +316,10 @@ export function loadConfig(env = process.env) {
265
316
  agentDir: resolve(expandHomePath(productEnv(env, "AGENT_DIR") ?? files.config.agentDir ?? defaultAgentDir())),
266
317
  systemInstructionsPath: parseSystemInstructionsPath(productEnv(env, "SYSTEM_INSTRUCTIONS_PATH") ?? files.config.systemInstructionsPath),
267
318
  hooks: mergeHookConfigs(parseHookConfig(files.config.hooks), parseHookConfig(files.hooks), files.hookFiles),
268
- logging: parseLoggingConfig(env, shouldTrustOneProxyByDefault(host, publicBaseUrl)),
319
+ logging: parseLoggingConfig(env, proxyTrust !== false),
320
+ commandShellRuntime,
321
+ shellInstructionsEnabled: files.config.shellInstructions !== false,
322
+ shellInstructionPath: shellInstructionPath(files.dir, commandShellRuntime.family),
269
323
  };
270
324
  }
271
325
  function numberConfigValue(value) {
@@ -0,0 +1,100 @@
1
+ import { constants as fsConstants } from "node:fs";
2
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { join, resolve } from "node:path";
4
+ const TEMPLATE_REPOSITORY_RAW = "https://raw.githubusercontent.com/Akira-TL/forgerelay";
5
+ const TEMPLATE_DIRECTORY = "templates/instructions";
6
+ export function shellInstructionFamily(family) {
7
+ if (family === "pwsh" || family === "powershell" || family === "cmd" || family === "zsh" || family === "fish") {
8
+ return family;
9
+ }
10
+ return undefined;
11
+ }
12
+ export function shellInstructionPath(configDir, family) {
13
+ const instructionFamily = shellInstructionFamily(family);
14
+ return instructionFamily ? join(resolve(configDir), "instructions", `${instructionFamily}.md`) : undefined;
15
+ }
16
+ export function shellInstructionFamiliesToSeed(platform, selectedFamily) {
17
+ const selectedPosixGuidance = selectedFamily === "zsh" || selectedFamily === "fish"
18
+ ? [selectedFamily]
19
+ : [];
20
+ if (platform === "win32")
21
+ return ["pwsh", "powershell", "cmd", ...selectedPosixGuidance];
22
+ return selectedPosixGuidance;
23
+ }
24
+ export function shellInstructionTemplateUrl(version, family) {
25
+ const normalizedVersion = version.trim().replace(/^v/, "");
26
+ if (!normalizedVersion)
27
+ throw new Error("ForgeRelay version is required to fetch shell Instructions.");
28
+ return `${TEMPLATE_REPOSITORY_RAW}/v${encodeURIComponent(normalizedVersion)}/${TEMPLATE_DIRECTORY}/${family}.md`;
29
+ }
30
+ export async function seedShellInstructionFiles(options) {
31
+ const instructionsDir = join(resolve(options.configDir), "instructions");
32
+ await mkdir(instructionsDir, { recursive: true, mode: 0o700 });
33
+ const fetchText = options.fetchText ?? fetchInstructionText;
34
+ const uniqueFamilies = [...new Set(options.families)];
35
+ const results = [];
36
+ for (const family of uniqueFamilies) {
37
+ const path = join(instructionsDir, `${family}.md`);
38
+ const sourceUrl = shellInstructionTemplateUrl(options.version, family);
39
+ if (!options.reset && await fileExists(path)) {
40
+ results.push({ family, path, status: "preserved", sourceUrl });
41
+ continue;
42
+ }
43
+ try {
44
+ const content = await fetchText(sourceUrl);
45
+ if (!content.trim())
46
+ throw new Error("template response was empty");
47
+ await writeFile(path, content.endsWith("\n") ? content : `${content}\n`, {
48
+ encoding: "utf8",
49
+ mode: 0o600,
50
+ flag: options.reset ? "w" : "wx",
51
+ });
52
+ results.push({ family, path, status: "created", sourceUrl });
53
+ }
54
+ catch (error) {
55
+ if (!options.reset && await fileExists(path)) {
56
+ results.push({ family, path, status: "preserved", sourceUrl });
57
+ continue;
58
+ }
59
+ results.push({
60
+ family,
61
+ path,
62
+ status: "failed",
63
+ sourceUrl,
64
+ error: error instanceof Error ? error.message : String(error),
65
+ });
66
+ }
67
+ }
68
+ return results;
69
+ }
70
+ export async function readShellInstruction(path) {
71
+ try {
72
+ return await readFile(path, "utf8");
73
+ }
74
+ catch (error) {
75
+ if (isMissingFile(error))
76
+ return undefined;
77
+ throw error;
78
+ }
79
+ }
80
+ async function fetchInstructionText(url) {
81
+ const response = await fetch(url, {
82
+ headers: { "user-agent": "forgerelay-shell-instructions" },
83
+ });
84
+ if (!response.ok) {
85
+ throw new Error(`HTTP ${response.status} while fetching ${url}`);
86
+ }
87
+ return response.text();
88
+ }
89
+ async function fileExists(path) {
90
+ try {
91
+ await access(path, fsConstants.F_OK);
92
+ return true;
93
+ }
94
+ catch {
95
+ return false;
96
+ }
97
+ }
98
+ function isMissingFile(error) {
99
+ return error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR");
100
+ }
@@ -0,0 +1,120 @@
1
+ import { spawnSync } from "node:child_process";
2
+ const WINDOWS_ELEVATED_INTEGRITY_SIDS = [
3
+ "S-1-16-12288", // High Mandatory Level
4
+ "S-1-16-16384", // System Mandatory Level
5
+ "S-1-16-20480", // Protected Process Mandatory Level
6
+ ];
7
+ const WINDOWS_LOCAL_SYSTEM_SID = "S-1-5-18";
8
+ export function detectRuntimePrivilege(dependencies = {}) {
9
+ const platform = dependencies.platform ?? process.platform;
10
+ if (platform === "win32") {
11
+ return detectWindowsRuntimePrivilege(dependencies.runWindowsWhoami ?? runWindowsWhoami);
12
+ }
13
+ if (platform === "linux" || platform === "darwin") {
14
+ const geteuid = dependencies.geteuid ?? process.geteuid?.bind(process);
15
+ if (!geteuid) {
16
+ return {
17
+ level: "unknown",
18
+ platform,
19
+ source: "unsupported",
20
+ detail: "effective user id is unavailable",
21
+ };
22
+ }
23
+ return {
24
+ level: geteuid() === 0 ? "elevated" : "standard",
25
+ platform,
26
+ source: "posix-euid",
27
+ };
28
+ }
29
+ return {
30
+ level: "unknown",
31
+ platform,
32
+ source: "unsupported",
33
+ detail: `privilege detection is not implemented for ${platform}`,
34
+ };
35
+ }
36
+ export function assertRuntimePrivilegeAllowed(state, allowElevated) {
37
+ if (state.level === "standard" || allowElevated)
38
+ return;
39
+ const detected = state.level === "elevated"
40
+ ? "ForgeRelay detected elevated operating-system privileges."
41
+ : `ForgeRelay could not safely determine whether this process is elevated${state.detail ? ` (${state.detail})` : ""}.`;
42
+ throw new Error([
43
+ detected,
44
+ "ForgeRelay refuses to start its Agent/Hook runtime with system-level or unknown privilege by default because commands may make system-wide or irreversible changes.",
45
+ "",
46
+ "Run ForgeRelay as a normal user, or if elevated execution is intentional, acknowledge the risk for this invocation:",
47
+ " forgerelay serve --allow-elevated",
48
+ "",
49
+ "The --allow-elevated acknowledgement is invocation-scoped and is not a persistent configuration setting.",
50
+ ].join("\n"));
51
+ }
52
+ export function formatRuntimePrivilege(state) {
53
+ const detail = state.detail ? ` (${state.detail})` : "";
54
+ return `${state.level}${detail}`;
55
+ }
56
+ export function elevatedRuntimeWarning(state) {
57
+ const status = state.level === "elevated"
58
+ ? "elevated operating-system privileges"
59
+ : state.level === "unknown"
60
+ ? "an unverified operating-system privilege level"
61
+ : "standard operating-system privileges";
62
+ return [
63
+ `WARNING: --allow-elevated was supplied and ForgeRelay is starting with ${status}.`,
64
+ "Agent and Hook commands run with the ForgeRelay process authority and may make system-wide or irreversible changes.",
65
+ "Continue only when this level of operating-system access is intentional.",
66
+ ].join("\n");
67
+ }
68
+ function detectWindowsRuntimePrivilege(runWhoami) {
69
+ const user = runWhoami(["/user", "/fo", "csv", "/nh"]);
70
+ const groups = runWhoami(["/groups", "/fo", "csv", "/nh"]);
71
+ if (user.status !== 0 || groups.status !== 0) {
72
+ const reason = [user.stderr, groups.stderr]
73
+ .map((value) => value.trim())
74
+ .filter(Boolean)
75
+ .join("; ");
76
+ return {
77
+ level: "unknown",
78
+ platform: "win32",
79
+ source: "windows-token",
80
+ detail: reason || "whoami.exe could not inspect the current process token",
81
+ };
82
+ }
83
+ if (containsSid(user.stdout, WINDOWS_LOCAL_SYSTEM_SID)) {
84
+ return {
85
+ level: "elevated",
86
+ platform: "win32",
87
+ source: "windows-token",
88
+ detail: "LocalSystem token",
89
+ };
90
+ }
91
+ const elevatedIntegritySid = WINDOWS_ELEVATED_INTEGRITY_SIDS.find((sid) => containsSid(groups.stdout, sid));
92
+ if (elevatedIntegritySid) {
93
+ return {
94
+ level: "elevated",
95
+ platform: "win32",
96
+ source: "windows-token",
97
+ detail: `integrity ${elevatedIntegritySid}`,
98
+ };
99
+ }
100
+ return {
101
+ level: "standard",
102
+ platform: "win32",
103
+ source: "windows-token",
104
+ };
105
+ }
106
+ function containsSid(output, sid) {
107
+ const escaped = sid.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
108
+ return new RegExp(`(?:^|[^0-9-])${escaped}(?:$|[^0-9-])`, "i").test(output);
109
+ }
110
+ function runWindowsWhoami(args) {
111
+ const result = spawnSync("whoami.exe", args, {
112
+ encoding: "utf8",
113
+ windowsHide: true,
114
+ });
115
+ return {
116
+ status: result.status,
117
+ stdout: result.stdout ?? "",
118
+ stderr: result.stderr ?? result.error?.message ?? "",
119
+ };
120
+ }
@@ -0,0 +1,235 @@
1
+ import { existsSync, readlinkSync } from "node:fs";
2
+ import { isAbsolute, resolve, win32 } from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ const POSIX_SH_NAMES = new Set(["ash", "dash", "ksh", "sh"]);
5
+ export function resolveCommandShellRuntime(input) {
6
+ const selected = input.explicit
7
+ ? { source: "explicit", value: input.explicit }
8
+ : input.launcher
9
+ ? { source: "launcher", value: input.launcher }
10
+ : input.recordedFallback
11
+ ? { source: "recorded", value: input.recordedFallback }
12
+ : undefined;
13
+ if (!selected) {
14
+ throw new Error("Unable to determine ForgeRelay command shell. Configure a shell explicitly or run `forgerelay init` to record a fallback for non-interactive launches.");
15
+ }
16
+ return runtimeFromSelection(selected.value, selected.source);
17
+ }
18
+ /**
19
+ * Preserve the pre-P3 execution contract while the owner-facing init/config
20
+ * surface is introduced incrementally. This is deliberately separate from the
21
+ * strict precedence resolver above: new configuration should call
22
+ * resolveCommandShellRuntime instead of inventing another fallback.
23
+ */
24
+ export function resolveConfiguredCommandShellRuntime(preference, platform = process.platform, environment = process.env) {
25
+ const legacyExplicit = environment.FORGERELAY_COMMAND_SHELL?.trim();
26
+ if (legacyExplicit) {
27
+ const explicit = normalizeCommandShellSelection({ executable: legacyExplicit }, platform, environment, true);
28
+ return resolveCommandShellRuntime({ explicit });
29
+ }
30
+ if (!preference)
31
+ return resolveCompatibilityCommandShellRuntime(platform, environment);
32
+ if (preference.mode === "pinned") {
33
+ const explicit = normalizeCommandShellSelection(preference, platform, environment, true);
34
+ return resolveCommandShellRuntime({ explicit });
35
+ }
36
+ const launcher = detectLauncherCommandShell({ platform, environment });
37
+ if (launcher)
38
+ return resolveCommandShellRuntime({ launcher });
39
+ const recordedFallback = normalizeCommandShellSelection(preference, platform, environment, true);
40
+ return resolveCommandShellRuntime({ recordedFallback });
41
+ }
42
+ export function resolveCompatibilityCommandShellRuntime(platform = process.platform, environment = process.env) {
43
+ if (platform === "win32") {
44
+ return runtimeFromSelection({
45
+ family: "cmd",
46
+ executable: environment.ComSpec ?? environment.COMSPEC ?? "cmd.exe",
47
+ }, "compatibility-default");
48
+ }
49
+ const configured = environment.FORGERELAY_COMMAND_SHELL?.trim();
50
+ if (configured) {
51
+ return runtimeFromSelection({ executable: configured }, "explicit");
52
+ }
53
+ if (platform === "linux" || platform === "darwin") {
54
+ return runtimeFromSelection({ family: "bash", executable: "/bin/bash" }, "compatibility-default");
55
+ }
56
+ return runtimeFromSelection({ family: "sh", executable: "/bin/sh" }, "compatibility-default");
57
+ }
58
+ export function detectLauncherCommandShell(dependencies = {}) {
59
+ const platform = dependencies.platform ?? process.platform;
60
+ const environment = dependencies.environment ?? process.env;
61
+ // npm lifecycle scripts insert their own /bin/sh or cmd.exe. Treating that
62
+ // wrapper as the user's launcher would recreate the npm-script-shell bug P3
63
+ // is specifically intended to avoid. The recorded fallback is safer here.
64
+ if (environment.npm_lifecycle_event)
65
+ return undefined;
66
+ const ppid = dependencies.ppid ?? process.ppid;
67
+ if (!Number.isInteger(ppid) || ppid < 1)
68
+ return undefined;
69
+ const parentExecutable = (dependencies.parentExecutable ?? defaultParentExecutable)(platform, ppid);
70
+ if (!parentExecutable)
71
+ return undefined;
72
+ const family = inferCommandShellFamily(parentExecutable);
73
+ if (!family)
74
+ return undefined;
75
+ return {
76
+ family,
77
+ executable: resolveExecutablePath(parentExecutable, platform, environment),
78
+ };
79
+ }
80
+ export function normalizeCommandShellSelection(selection, platform = process.platform, environment = process.env, requireAvailable = false) {
81
+ const executable = resolveExecutablePath(selection.executable, platform, environment);
82
+ if (requireAvailable && !executableExists(executable, platform, environment)) {
83
+ throw new Error(`Configured command-shell executable is unavailable: ${selection.executable}. Choose an installed shell or update the explicit executable path.`);
84
+ }
85
+ return { ...selection, executable };
86
+ }
87
+ export function defaultCommandShellSelection(family, platform = process.platform, environment = process.env) {
88
+ const candidate = family === "bash" && platform !== "win32"
89
+ ? "/bin/bash"
90
+ : family === "sh" && platform !== "win32"
91
+ ? "/bin/sh"
92
+ : family === "cmd"
93
+ ? environment.ComSpec ?? environment.COMSPEC ?? "cmd.exe"
94
+ : family === "powershell"
95
+ ? "powershell.exe"
96
+ : family;
97
+ return normalizeCommandShellSelection({ family, executable: candidate }, platform, environment);
98
+ }
99
+ export function inferCommandShellFamily(executable) {
100
+ const name = executableBasename(executable).toLowerCase().replace(/\.exe$/i, "");
101
+ if (name === "bash")
102
+ return "bash";
103
+ if (name === "zsh")
104
+ return "zsh";
105
+ if (name === "fish")
106
+ return "fish";
107
+ if (POSIX_SH_NAMES.has(name))
108
+ return "sh";
109
+ if (name === "pwsh")
110
+ return "pwsh";
111
+ if (name === "powershell")
112
+ return "powershell";
113
+ if (name === "cmd")
114
+ return "cmd";
115
+ return undefined;
116
+ }
117
+ export function formatCommandShellRuntime(runtime) {
118
+ const version = runtime.version ? ` ${runtime.version}` : "";
119
+ return `${runtime.family}${version} (${runtime.executable}; ${runtime.source})`;
120
+ }
121
+ export function snapshotCommandShellRuntime(runtime) {
122
+ return { ...runtime, capabilities: [...runtime.capabilities] };
123
+ }
124
+ export function commandShellAgentInstruction(runtime) {
125
+ if (runtime.family === "bash")
126
+ return "";
127
+ const version = runtime.version ? ` ${runtime.version}` : "";
128
+ return [
129
+ `Command shell runtime: ${runtime.family}${version}.`,
130
+ `Executable: ${runtime.executable}.`,
131
+ `Selection source: ${runtime.source}.`,
132
+ "Write shell commands for this runtime rather than assuming Bash syntax.",
133
+ ].join(" ");
134
+ }
135
+ function runtimeFromSelection(selection, source) {
136
+ const executable = selection.executable.trim();
137
+ if (!executable)
138
+ throw new Error(`Command shell ${source} selection is missing an executable.`);
139
+ const family = selection.family ?? inferCommandShellFamily(executable);
140
+ if (!family) {
141
+ throw new Error(`Unable to identify the command-shell family for ${executable}. Configure the shell family explicitly instead of allowing ForgeRelay to guess the command language.`);
142
+ }
143
+ return {
144
+ family,
145
+ executable,
146
+ source,
147
+ ...(selection.version?.trim() ? { version: selection.version.trim() } : {}),
148
+ capabilities: Array.from(new Set(selection.capabilities ?? defaultCapabilities(family))),
149
+ };
150
+ }
151
+ function defaultCapabilities(family) {
152
+ switch (family) {
153
+ case "bash":
154
+ return ["bash", "profile-isolation", "posix-command-language"];
155
+ case "zsh":
156
+ return ["zsh", "profile-isolation", "posix-command-language"];
157
+ case "sh":
158
+ return ["posix-sh", "posix-command-language"];
159
+ case "fish":
160
+ return ["fish-command-language"];
161
+ case "pwsh":
162
+ return ["powershell-command-language", "powershell-core"];
163
+ case "powershell":
164
+ return ["powershell-command-language", "windows-powershell"];
165
+ case "cmd":
166
+ return ["cmd-command-language"];
167
+ }
168
+ }
169
+ function executableBasename(executable) {
170
+ const normalized = executable.trim().replace(/^"|"$/g, "");
171
+ return win32.basename(normalized.replaceAll("/", "\\"));
172
+ }
173
+ function executableExists(executable, platform, environment) {
174
+ if (existsSync(executable))
175
+ return true;
176
+ return resolveExecutablePath(executable, platform, environment) !== executable;
177
+ }
178
+ function resolveExecutablePath(executable, platform, environment) {
179
+ const trimmed = executable.trim().replace(/^"|"$/g, "");
180
+ if (isAbsolute(trimmed) || win32.isAbsolute(trimmed))
181
+ return trimmed;
182
+ if (trimmed.includes("/") || trimmed.includes("\\"))
183
+ return resolve(trimmed);
184
+ const pathValue = environment.PATH ?? environment.Path ?? environment.path;
185
+ if (!pathValue)
186
+ return trimmed;
187
+ const delimiter = platform === "win32" ? ";" : ":";
188
+ const extensions = platform === "win32"
189
+ ? (environment.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)
190
+ : [""];
191
+ const hasExtension = /\.[A-Za-z0-9]+$/.test(trimmed);
192
+ for (const directory of pathValue.split(delimiter).filter(Boolean)) {
193
+ const candidates = platform === "win32" && !hasExtension
194
+ ? extensions.map((extension) => win32.join(directory, `${trimmed}${extension}`))
195
+ : [platform === "win32" ? win32.join(directory, trimmed) : resolve(directory, trimmed)];
196
+ for (const candidate of candidates) {
197
+ if (existsSync(candidate))
198
+ return candidate;
199
+ }
200
+ }
201
+ return trimmed;
202
+ }
203
+ function defaultParentExecutable(platform, ppid) {
204
+ if (platform === "linux") {
205
+ try {
206
+ return readlinkSync(`/proc/${ppid}/exe`);
207
+ }
208
+ catch {
209
+ return psParentExecutable(ppid);
210
+ }
211
+ }
212
+ if (platform === "darwin")
213
+ return psParentExecutable(ppid);
214
+ if (platform === "win32")
215
+ return windowsParentExecutable(ppid);
216
+ return undefined;
217
+ }
218
+ function psParentExecutable(ppid) {
219
+ const result = spawnSync("ps", ["-p", String(ppid), "-o", "comm="], {
220
+ encoding: "utf8",
221
+ });
222
+ if (result.status !== 0)
223
+ return undefined;
224
+ return result.stdout?.trim() || undefined;
225
+ }
226
+ function windowsParentExecutable(ppid) {
227
+ const result = spawnSync("tasklist.exe", ["/fi", `PID eq ${ppid}`, "/fo", "csv", "/nh"], { encoding: "utf8", windowsHide: true });
228
+ if (result.status !== 0)
229
+ return undefined;
230
+ const output = result.stdout?.trim();
231
+ if (!output || output.startsWith("INFO:"))
232
+ return undefined;
233
+ const quoted = /^"([^"]+)"/.exec(output);
234
+ return quoted?.[1] ?? (output.split(",", 1)[0]?.replace(/^"|"$/g, "").trim() || undefined);
235
+ }