@akira-tl/forgerelay 0.9.4 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +4 -3
  3. package/capabilities/shell-processes/GUIDE.md +1 -1
  4. package/dist/cli/init.js +86 -1
  5. package/dist/cli/shell/setup.js +99 -0
  6. package/dist/cli.js +43 -10
  7. package/dist/mcp/hooks/hooks.js +6 -3
  8. package/dist/mcp/process/process-platform.js +34 -26
  9. package/dist/mcp/process/process-sessions.js +6 -3
  10. package/dist/mcp/server/transport/http-server.js +4 -1
  11. package/dist/mcp/server/workspace/runtime/workspace-open-presentation.js +6 -0
  12. package/dist/mcp/server/workspace/runtime/workspace-tools.js +1 -1
  13. package/dist/mcp/server-instructions.js +10 -1
  14. package/dist/runtime/config/config.js +6 -0
  15. package/dist/runtime/instructions/powershell-skill.js +65 -0
  16. package/dist/runtime/instructions/shell-instructions.js +100 -0
  17. package/dist/runtime/security/runtime-privilege.js +120 -0
  18. package/dist/runtime/shell/command-shell-runtime.js +278 -0
  19. package/dist/ui/.vite/manifest.json +276 -276
  20. package/dist/ui/activity-panel-app.html +2 -2
  21. package/dist/ui/assets/{activity-panel-app-Sl1Jro8v.js → activity-panel-app-BkODhDLg.js} +1 -1
  22. package/dist/ui/assets/{activity-panel-app-7Xe0alhm.css → activity-panel-app-DkfJzTZ3.css} +1 -1
  23. package/dist/ui/assets/{angular-html-B_yn91wW.js → angular-html-DjuELMdF.js} +1 -1
  24. package/dist/ui/assets/{angular-ts-DSgtloKz.js → angular-ts-2EXVZIIX.js} +1 -1
  25. package/dist/ui/assets/{apl-NatWtF8X.js → apl-Bb4IeT5V.js} +1 -1
  26. package/dist/ui/assets/{astro-BYFEVBuN.js → astro-Bjavu3nT.js} +1 -1
  27. package/dist/ui/assets/{blade-DoO7yvMR.js → blade-DKW2uHZo.js} +1 -1
  28. package/dist/ui/assets/{c-Rl2hMCcg.js → c-oLeHmzbj.js} +1 -1
  29. package/dist/ui/assets/{cobol-ByC5mWiB.js → cobol-CcqFMr5T.js} +1 -1
  30. package/dist/ui/assets/{coffee-Dez77Qov.js → coffee-BA20zqRF.js} +1 -1
  31. package/dist/ui/assets/{cpp-DtaAQT9h.js → cpp-D750WmDa.js} +1 -1
  32. package/dist/ui/assets/{crystal-Bgmbf8ec.js → crystal-Cv5I6OMI.js} +1 -1
  33. package/dist/ui/assets/{css-C1tjBtVN.js → css-CjPPWPeH.js} +1 -1
  34. package/dist/ui/assets/{edge-DeH6u8Wg.js → edge-aYRuudEZ.js} +1 -1
  35. package/dist/ui/assets/{elixir-_H3KnmWT.js → elixir-ChLitmz7.js} +1 -1
  36. package/dist/ui/assets/{elm-CsCe3U28.js → elm-Fs7L7u36.js} +1 -1
  37. package/dist/ui/assets/{erb-CbM-L2g8.js → erb-B-qXHxN1.js} +1 -1
  38. package/dist/ui/assets/{git-rebase-D0s5pDh9.js → git-rebase-DOVN5OEg.js} +1 -1
  39. package/dist/ui/assets/{glimmer-js-BfOToq_A.js → glimmer-js-BFt_vCTW.js} +1 -1
  40. package/dist/ui/assets/{glimmer-ts-CCUOkL2K.js → glimmer-ts-ufCHlR6C.js} +1 -1
  41. package/dist/ui/assets/{glsl-DH4ec9dE.js → glsl-Ex4JXGuC.js} +1 -1
  42. package/dist/ui/assets/{graphql-DilAMBoI.js → graphql-pQPXtBk5.js} +1 -1
  43. package/dist/ui/assets/{hack-BDkNDZlF.js → hack-B5hDguhd.js} +1 -1
  44. package/dist/ui/assets/{haml-D_QBp6K7.js → haml-BrNeI29U.js} +1 -1
  45. package/dist/ui/assets/{handlebars-DePUrTpC.js → handlebars-DCqTsL9-.js} +1 -1
  46. package/dist/ui/assets/{heavy-payload-DOwZpO6n.js → heavy-payload-DsmmHgvt.js} +3 -3
  47. package/dist/ui/assets/{html-By4dNnM4.js → html-D8k0CC9V.js} +1 -1
  48. package/dist/ui/assets/{html-derivative-DDw9Ef4Q.js → html-derivative-CC8flWmC.js} +1 -1
  49. package/dist/ui/assets/{http-B0QxWc9M.js → http-BYnWqNep.js} +1 -1
  50. package/dist/ui/assets/{hurl-vMQ4lda1.js → hurl-BND-z85s.js} +1 -1
  51. package/dist/ui/assets/{java-DYRe2lWG.js → java-Dug0oB9T.js} +1 -1
  52. package/dist/ui/assets/{javascript-CxyGqwcL.js → javascript-oDJ7WITC.js} +1 -1
  53. package/dist/ui/assets/{jinja-JUwV-fZt.js → jinja-Cv0t_F1U.js} +1 -1
  54. package/dist/ui/assets/{jison-7ZqCEvS8.js → jison-Car3MF55.js} +1 -1
  55. package/dist/ui/assets/{json-dbltgiiA.js → json-CEViCsf4.js} +1 -1
  56. package/dist/ui/assets/{jsx-oDopT7YQ.js → jsx-BfZ0zgSH.js} +1 -1
  57. package/dist/ui/assets/{julia-CkKiNSav.js → julia-CmX0b3Tn.js} +1 -1
  58. package/dist/ui/assets/{just-DuqCAKJ3.js → just-BuugLCVk.js} +1 -1
  59. package/dist/ui/assets/{latex-sudcRDSn.js → latex-FpTXqO03.js} +1 -1
  60. package/dist/ui/assets/{liquid-Di-08Js7.js → liquid-B37OzUFu.js} +1 -1
  61. package/dist/ui/assets/{lua-YlqdMoV0.js → lua-BHjcb8oq.js} +1 -1
  62. package/dist/ui/assets/{marko-CJRNyJDb.js → marko-CApj_fsH.js} +1 -1
  63. package/dist/ui/assets/{mdc-t12CzZHs.js → mdc-Ed5vQWq8.js} +1 -1
  64. package/dist/ui/assets/{nginx-57-b4XwT.js → nginx-u_rVEBox.js} +1 -1
  65. package/dist/ui/assets/{nim-BzrswYQa.js → nim-Ba9EdchT.js} +1 -1
  66. package/dist/ui/assets/{perl-D9JR_iIh.js → perl-BpU_6Wvz.js} +1 -1
  67. package/dist/ui/assets/{php-Py2gFHil.js → php-bKabofky.js} +1 -1
  68. package/dist/ui/assets/{pug-PzLLy0h1.js → pug-C_aLNkqe.js} +1 -1
  69. package/dist/ui/assets/{qml-By8fwgV4.js → qml-D9VisnRy.js} +1 -1
  70. package/dist/ui/assets/{r-CcezTbLq.js → r-CYqB1sLx.js} +1 -1
  71. package/dist/ui/assets/{razor-dG-vdhL_.js → razor-DNJKI8Rs.js} +1 -1
  72. package/dist/ui/assets/{regexp-BlsQ-N_8.js → regexp-DHrTHXkz.js} +1 -1
  73. package/dist/ui/assets/{rst-C2awDCat.js → rst-CbNdSFHv.js} +1 -1
  74. package/dist/ui/assets/{ruby-zA2W_49d.js → ruby-FyKG-pkF.js} +1 -1
  75. package/dist/ui/assets/{sas-D49bhyaN.js → sas-BbSPDl3X.js} +1 -1
  76. package/dist/ui/assets/{scss-BQb-cp01.js → scss-2nX9g6gD.js} +1 -1
  77. package/dist/ui/assets/{shellscript-Dga4H_Ni.js → shellscript-CmON6x2x.js} +1 -1
  78. package/dist/ui/assets/{shellsession-n3-5CqcF.js → shellsession-DtqIGlAX.js} +1 -1
  79. package/dist/ui/assets/{soy-XYBOf_BA.js → soy-BzUJMmJT.js} +1 -1
  80. package/dist/ui/assets/{sql-ET4u5Cpl.js → sql-CgVcFZmb.js} +1 -1
  81. package/dist/ui/assets/{stata-CLHcJHO_.js → stata-CNitlMot.js} +1 -1
  82. package/dist/ui/assets/{surrealql-BdjQ60T4.js → surrealql-BA8-xsRZ.js} +1 -1
  83. package/dist/ui/assets/{svelte-B2RPtKJe.js → svelte-El02yAxv.js} +1 -1
  84. package/dist/ui/assets/{templ-CrByPW_W.js → templ-DeUQ84aR.js} +1 -1
  85. package/dist/ui/assets/{tex-DZBkg363.js → tex-Cu-FqpPS.js} +1 -1
  86. package/dist/ui/assets/{ts-tags-DJR3Gphb.js → ts-tags-X42N6TGA.js} +1 -1
  87. package/dist/ui/assets/{tsx-CajzROyy.js → tsx-C1aPp9rY.js} +1 -1
  88. package/dist/ui/assets/{twig-BZjk5Zda.js → twig-Cj1Q4SQc.js} +1 -1
  89. package/dist/ui/assets/{typescript-B-EWToLj.js → typescript-Du9lVt1G.js} +1 -1
  90. package/dist/ui/assets/{vue-D_rHCCr8.js → vue-_uMGbFv8.js} +1 -1
  91. package/dist/ui/assets/{vue-html-CC4WlvVt.js → vue-html-D61naG-Q.js} +1 -1
  92. package/dist/ui/assets/{vue-vine-BgNMVgpk.js → vue-vine-CkFae4yV.js} +1 -1
  93. package/dist/ui/assets/{xml-BI86-WbH.js → xml-Cbwsc6ws.js} +1 -1
  94. package/dist/ui/assets/{xsl-DXeLZV4J.js → xsl-NyBx72XB.js} +1 -1
  95. package/dist/ui/assets/{yaml-60_PFlWa.js → yaml-Cgb6Uq9u.js} +1 -1
  96. package/dist/workspaces/context.js +32 -5
  97. package/dist/workspaces/presentation/workspace-presentation.js +1 -0
  98. package/dist/workspaces/sessions.js +2 -0
  99. package/dist/workspaces.js +2 -1
  100. package/docs/configuration.md +1 -1
  101. package/docs/gotchas.md +5 -3
  102. package/package.json +3 -2
  103. package/scripts/ci/pwsh-acceptance.mjs +308 -0
  104. package/scripts/ci/verify.mjs +1 -0
  105. package/scripts/release/release-gate.test.mjs +1 -0
  106. package/scripts/release-proof.mjs +85 -9
  107. 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
  ? ""
@@ -4,6 +4,8 @@ import { join, resolve } from "node:path";
4
4
  import { expandHomePath } from "../../mcp/filesystem/roots.js";
5
5
  import { mergeHookConfigs, parseHookConfig } from "../../mcp/hooks/hooks.js";
6
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";
7
9
  const DEFAULT_OAUTH_ACCESS_TOKEN_TTL_SECONDS = 60 * 60;
8
10
  const DEFAULT_OAUTH_REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60;
9
11
  const DEFAULT_ARTIFACT_MAX_FILE_BYTES = 100 * 1024 * 1024;
@@ -268,6 +270,7 @@ export function loadConfig(env = process.env) {
268
270
  const publicDeployment = resolvePublicDeployment(env, files.config, host, port);
269
271
  const publicBaseUrl = publicDeployment.canonicalBaseUrl;
270
272
  const proxyTrust = resolveProxyTrust(env, files.config, host, publicBaseUrl);
273
+ const commandShellRuntime = resolveConfiguredCommandShellRuntime(files.config.commandShell, process.platform, env);
271
274
  const derivedAllowedHosts = [
272
275
  "localhost",
273
276
  "127.0.0.1",
@@ -314,6 +317,9 @@ export function loadConfig(env = process.env) {
314
317
  systemInstructionsPath: parseSystemInstructionsPath(productEnv(env, "SYSTEM_INSTRUCTIONS_PATH") ?? files.config.systemInstructionsPath),
315
318
  hooks: mergeHookConfigs(parseHookConfig(files.config.hooks), parseHookConfig(files.hooks), files.hookFiles),
316
319
  logging: parseLoggingConfig(env, proxyTrust !== false),
320
+ commandShellRuntime,
321
+ shellInstructionsEnabled: files.config.shellInstructions !== false,
322
+ shellInstructionPath: shellInstructionPath(files.dir, commandShellRuntime.family),
317
323
  };
318
324
  }
319
325
  function numberConfigValue(value) {
@@ -0,0 +1,65 @@
1
+ import { constants as fsConstants } from "node:fs";
2
+ import { access, mkdir, 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 POWERSHELL_SKILL_PATH = "templates/skills/powershell/SKILL.md";
6
+ export function powerShellSkillPath(configDir) {
7
+ return join(resolve(configDir), "skills", "powershell", "SKILL.md");
8
+ }
9
+ export function powerShellSkillTemplateUrl(version) {
10
+ const normalizedVersion = version.trim().replace(/^v/, "");
11
+ if (!normalizedVersion)
12
+ throw new Error("ForgeRelay version is required to fetch the PowerShell Skill.");
13
+ return `${TEMPLATE_REPOSITORY_RAW}/v${encodeURIComponent(normalizedVersion)}/${POWERSHELL_SKILL_PATH}`;
14
+ }
15
+ export async function seedPowerShellSkill(options) {
16
+ const path = powerShellSkillPath(options.configDir);
17
+ const sourceUrl = powerShellSkillTemplateUrl(options.version);
18
+ if (!options.reset && await fileExists(path)) {
19
+ return { path, status: "preserved", sourceUrl };
20
+ }
21
+ await mkdir(join(resolve(options.configDir), "skills", "powershell"), {
22
+ recursive: true,
23
+ mode: 0o700,
24
+ });
25
+ const fetchText = options.fetchText ?? fetchSkillText;
26
+ try {
27
+ const content = await fetchText(sourceUrl);
28
+ if (!content.trim())
29
+ throw new Error("template response was empty");
30
+ await writeFile(path, content.endsWith("\n") ? content : `${content}\n`, {
31
+ encoding: "utf8",
32
+ mode: 0o600,
33
+ flag: options.reset ? "w" : "wx",
34
+ });
35
+ return { path, status: "created", sourceUrl };
36
+ }
37
+ catch (error) {
38
+ if (!options.reset && await fileExists(path)) {
39
+ return { path, status: "preserved", sourceUrl };
40
+ }
41
+ return {
42
+ path,
43
+ status: "failed",
44
+ sourceUrl,
45
+ error: error instanceof Error ? error.message : String(error),
46
+ };
47
+ }
48
+ }
49
+ async function fetchSkillText(url) {
50
+ const response = await fetch(url, {
51
+ headers: { "user-agent": "forgerelay-powershell-skill" },
52
+ });
53
+ if (!response.ok)
54
+ throw new Error(`HTTP ${response.status} while fetching ${url}`);
55
+ return response.text();
56
+ }
57
+ async function fileExists(path) {
58
+ try {
59
+ await access(path, fsConstants.F_OK);
60
+ return true;
61
+ }
62
+ catch {
63
+ return false;
64
+ }
65
+ }
@@ -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,278 @@
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, metadata = {}) {
25
+ const legacyExplicit = environment.FORGERELAY_COMMAND_SHELL?.trim();
26
+ let runtime;
27
+ if (legacyExplicit) {
28
+ const explicit = normalizeCommandShellSelection({ executable: legacyExplicit }, platform, environment, true);
29
+ runtime = resolveCommandShellRuntime({ explicit });
30
+ }
31
+ else if (!preference) {
32
+ runtime = resolveCompatibilityCommandShellRuntime(platform, environment);
33
+ }
34
+ else if (preference.mode === "pinned") {
35
+ const explicit = normalizeCommandShellSelection(preference, platform, environment, true);
36
+ runtime = resolveCommandShellRuntime({ explicit });
37
+ }
38
+ else {
39
+ const launcher = detectLauncherCommandShell({ platform, environment });
40
+ if (launcher) {
41
+ runtime = resolveCommandShellRuntime({ launcher });
42
+ }
43
+ else {
44
+ const recordedFallback = normalizeCommandShellSelection(preference, platform, environment, true);
45
+ runtime = resolveCommandShellRuntime({ recordedFallback });
46
+ }
47
+ }
48
+ return enrichConfiguredCommandShellRuntime(runtime, metadata.probePowerShell7Version ?? probePowerShell7Version);
49
+ }
50
+ export function resolveCompatibilityCommandShellRuntime(platform = process.platform, environment = process.env) {
51
+ if (platform === "win32") {
52
+ return runtimeFromSelection({
53
+ family: "cmd",
54
+ executable: environment.ComSpec ?? environment.COMSPEC ?? "cmd.exe",
55
+ }, "compatibility-default");
56
+ }
57
+ const configured = environment.FORGERELAY_COMMAND_SHELL?.trim();
58
+ if (configured) {
59
+ return runtimeFromSelection({ executable: configured }, "explicit");
60
+ }
61
+ if (platform === "linux" || platform === "darwin") {
62
+ return runtimeFromSelection({ family: "bash", executable: "/bin/bash" }, "compatibility-default");
63
+ }
64
+ return runtimeFromSelection({ family: "sh", executable: "/bin/sh" }, "compatibility-default");
65
+ }
66
+ export function detectLauncherCommandShell(dependencies = {}) {
67
+ const platform = dependencies.platform ?? process.platform;
68
+ const environment = dependencies.environment ?? process.env;
69
+ // npm lifecycle scripts insert their own /bin/sh or cmd.exe. Treating that
70
+ // wrapper as the user's launcher would recreate the npm-script-shell bug P3
71
+ // is specifically intended to avoid. The recorded fallback is safer here.
72
+ if (environment.npm_lifecycle_event)
73
+ return undefined;
74
+ const ppid = dependencies.ppid ?? process.ppid;
75
+ if (!Number.isInteger(ppid) || ppid < 1)
76
+ return undefined;
77
+ const parentExecutable = (dependencies.parentExecutable ?? defaultParentExecutable)(platform, ppid);
78
+ if (!parentExecutable)
79
+ return undefined;
80
+ const family = inferCommandShellFamily(parentExecutable);
81
+ if (!family)
82
+ return undefined;
83
+ return {
84
+ family,
85
+ executable: resolveExecutablePath(parentExecutable, platform, environment),
86
+ };
87
+ }
88
+ export function normalizeCommandShellSelection(selection, platform = process.platform, environment = process.env, requireAvailable = false) {
89
+ const executable = resolveExecutablePath(selection.executable, platform, environment);
90
+ if (requireAvailable && !executableExists(executable, platform, environment)) {
91
+ throw new Error(`Configured command-shell executable is unavailable: ${selection.executable}. Choose an installed shell or update the explicit executable path.`);
92
+ }
93
+ return { ...selection, executable };
94
+ }
95
+ export function defaultCommandShellSelection(family, platform = process.platform, environment = process.env) {
96
+ const candidate = family === "bash" && platform !== "win32"
97
+ ? "/bin/bash"
98
+ : family === "sh" && platform !== "win32"
99
+ ? "/bin/sh"
100
+ : family === "cmd"
101
+ ? environment.ComSpec ?? environment.COMSPEC ?? "cmd.exe"
102
+ : family === "powershell"
103
+ ? "powershell.exe"
104
+ : family;
105
+ return normalizeCommandShellSelection({ family, executable: candidate }, platform, environment);
106
+ }
107
+ export function inferCommandShellFamily(executable) {
108
+ const name = executableBasename(executable).toLowerCase().replace(/\.exe$/i, "");
109
+ if (name === "bash")
110
+ return "bash";
111
+ if (name === "zsh")
112
+ return "zsh";
113
+ if (name === "fish")
114
+ return "fish";
115
+ if (POSIX_SH_NAMES.has(name))
116
+ return "sh";
117
+ if (name === "pwsh")
118
+ return "pwsh";
119
+ if (name === "powershell")
120
+ return "powershell";
121
+ if (name === "cmd")
122
+ return "cmd";
123
+ return undefined;
124
+ }
125
+ export function formatCommandShellRuntime(runtime) {
126
+ const version = runtime.version ? ` ${runtime.version}` : "";
127
+ return `${runtime.family}${version} (${runtime.executable}; ${runtime.source})`;
128
+ }
129
+ export function snapshotCommandShellRuntime(runtime) {
130
+ return { ...runtime, capabilities: [...runtime.capabilities] };
131
+ }
132
+ function enrichConfiguredCommandShellRuntime(runtime, powerShell7VersionProbe) {
133
+ if (runtime.family !== "pwsh")
134
+ return runtime;
135
+ const version = powerShell7VersionProbe(runtime.executable).trim();
136
+ const major = Number.parseInt(version.split(".", 1)[0] ?? "", 10);
137
+ if (!version || !Number.isInteger(major)) {
138
+ throw new Error(`Unable to determine PowerShell 7 version from ${runtime.executable}.`);
139
+ }
140
+ if (major < 7) {
141
+ throw new Error(`Configured pwsh runtime must be PowerShell 7 or newer; detected ${version} at ${runtime.executable}.`);
142
+ }
143
+ return {
144
+ ...runtime,
145
+ version,
146
+ capabilities: Array.from(new Set([
147
+ ...runtime.capabilities,
148
+ "profile-isolation",
149
+ "pipeline-chain-operators",
150
+ ])),
151
+ };
152
+ }
153
+ function probePowerShell7Version(executable) {
154
+ const result = spawnSync(executable, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "$PSVersionTable.PSVersion.ToString()"], { encoding: "utf8", windowsHide: true, timeout: 5_000 });
155
+ if (result.error) {
156
+ throw new Error(`Unable to query PowerShell 7 version from ${executable}: ${result.error.message}`);
157
+ }
158
+ if (result.status !== 0) {
159
+ const detail = result.stderr?.trim();
160
+ throw new Error(`Unable to query PowerShell 7 version from ${executable}: exited with code ${result.status ?? "unknown"}${detail ? `: ${detail}` : ""}.`);
161
+ }
162
+ const version = result.stdout?.trim();
163
+ if (!version)
164
+ throw new Error(`Unable to query PowerShell 7 version from ${executable}: no version was reported.`);
165
+ return version;
166
+ }
167
+ export function commandShellAgentInstruction(runtime) {
168
+ if (runtime.family === "bash")
169
+ return "";
170
+ const version = runtime.version ? ` ${runtime.version}` : "";
171
+ return [
172
+ `Command shell runtime: ${runtime.family}${version}.`,
173
+ `Executable: ${runtime.executable}.`,
174
+ `Selection source: ${runtime.source}.`,
175
+ "Write shell commands for this runtime rather than assuming Bash syntax.",
176
+ ].join(" ");
177
+ }
178
+ function runtimeFromSelection(selection, source) {
179
+ const executable = selection.executable.trim();
180
+ if (!executable)
181
+ throw new Error(`Command shell ${source} selection is missing an executable.`);
182
+ const family = selection.family ?? inferCommandShellFamily(executable);
183
+ if (!family) {
184
+ 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.`);
185
+ }
186
+ return {
187
+ family,
188
+ executable,
189
+ source,
190
+ ...(selection.version?.trim() ? { version: selection.version.trim() } : {}),
191
+ capabilities: Array.from(new Set(selection.capabilities ?? defaultCapabilities(family))),
192
+ };
193
+ }
194
+ function defaultCapabilities(family) {
195
+ switch (family) {
196
+ case "bash":
197
+ return ["bash", "profile-isolation", "posix-command-language"];
198
+ case "zsh":
199
+ return ["zsh", "profile-isolation", "posix-command-language"];
200
+ case "sh":
201
+ return ["posix-sh", "posix-command-language"];
202
+ case "fish":
203
+ return ["fish-command-language"];
204
+ case "pwsh":
205
+ return ["powershell-command-language", "powershell-core"];
206
+ case "powershell":
207
+ return ["powershell-command-language", "windows-powershell"];
208
+ case "cmd":
209
+ return ["cmd-command-language"];
210
+ }
211
+ }
212
+ function executableBasename(executable) {
213
+ const normalized = executable.trim().replace(/^"|"$/g, "");
214
+ return win32.basename(normalized.replaceAll("/", "\\"));
215
+ }
216
+ function executableExists(executable, platform, environment) {
217
+ if (existsSync(executable))
218
+ return true;
219
+ return resolveExecutablePath(executable, platform, environment) !== executable;
220
+ }
221
+ function resolveExecutablePath(executable, platform, environment) {
222
+ const trimmed = executable.trim().replace(/^"|"$/g, "");
223
+ if (isAbsolute(trimmed) || win32.isAbsolute(trimmed))
224
+ return trimmed;
225
+ if (trimmed.includes("/") || trimmed.includes("\\"))
226
+ return resolve(trimmed);
227
+ const pathValue = environment.PATH ?? environment.Path ?? environment.path;
228
+ if (!pathValue)
229
+ return trimmed;
230
+ const delimiter = platform === "win32" ? ";" : ":";
231
+ const extensions = platform === "win32"
232
+ ? (environment.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)
233
+ : [""];
234
+ const hasExtension = /\.[A-Za-z0-9]+$/.test(trimmed);
235
+ for (const directory of pathValue.split(delimiter).filter(Boolean)) {
236
+ const candidates = platform === "win32" && !hasExtension
237
+ ? extensions.map((extension) => win32.join(directory, `${trimmed}${extension}`))
238
+ : [platform === "win32" ? win32.join(directory, trimmed) : resolve(directory, trimmed)];
239
+ for (const candidate of candidates) {
240
+ if (existsSync(candidate))
241
+ return candidate;
242
+ }
243
+ }
244
+ return trimmed;
245
+ }
246
+ function defaultParentExecutable(platform, ppid) {
247
+ if (platform === "linux") {
248
+ try {
249
+ return readlinkSync(`/proc/${ppid}/exe`);
250
+ }
251
+ catch {
252
+ return psParentExecutable(ppid);
253
+ }
254
+ }
255
+ if (platform === "darwin")
256
+ return psParentExecutable(ppid);
257
+ if (platform === "win32")
258
+ return windowsParentExecutable(ppid);
259
+ return undefined;
260
+ }
261
+ function psParentExecutable(ppid) {
262
+ const result = spawnSync("ps", ["-p", String(ppid), "-o", "comm="], {
263
+ encoding: "utf8",
264
+ });
265
+ if (result.status !== 0)
266
+ return undefined;
267
+ return result.stdout?.trim() || undefined;
268
+ }
269
+ function windowsParentExecutable(ppid) {
270
+ const result = spawnSync("tasklist.exe", ["/fi", `PID eq ${ppid}`, "/fo", "csv", "/nh"], { encoding: "utf8", windowsHide: true });
271
+ if (result.status !== 0)
272
+ return undefined;
273
+ const output = result.stdout?.trim();
274
+ if (!output || output.startsWith("INFO:"))
275
+ return undefined;
276
+ const quoted = /^"([^"]+)"/.exec(output);
277
+ return quoted?.[1] ?? (output.split(",", 1)[0]?.replace(/^"|"$/g, "").trim() || undefined);
278
+ }