@wrongstack/tools 0.273.0 → 0.274.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.
package/dist/exec.d.ts CHANGED
@@ -1,5 +1,24 @@
1
1
  import { Tool } from '@wrongstack/core';
2
2
 
3
+ /**
4
+ * Apply the configured exec command policy. Recomputes the effective allowlist
5
+ * as `DEFAULT ∪ allow − deny`. Call once at boot from
6
+ * `config.tools.exec.{allow,deny}`. Idempotent (always rebuilt from defaults).
7
+ *
8
+ * SECURITY: `allow` must originate from TRUSTED config only — the config loader
9
+ * strips `tools.exec.allow` from the untrusted in-project repo config before it
10
+ * reaches here. `deny` is safe from any source (it only narrows).
11
+ */
12
+ declare function configureExecPolicy(opts?: {
13
+ allow?: readonly string[] | undefined;
14
+ deny?: readonly string[] | undefined;
15
+ }): void;
16
+ /** Reset the exec allowlist to the built-in defaults (tests / re-init). */
17
+ declare function resetExecPolicy(): void;
18
+ /** Whether `cmd` is currently in the effective exec allowlist. */
19
+ declare function isExecCommandAllowed(cmd: string): boolean;
20
+ /** Snapshot of the effective allowlist (sorted) — for tests / diagnostics. */
21
+ declare function getExecAllowlist(): string[];
3
22
  interface ExecInput {
4
23
  command: string;
5
24
  args?: string[] | undefined;
@@ -17,4 +36,4 @@ interface ExecOutput {
17
36
  }
18
37
  declare const execTool: Tool<ExecInput, ExecOutput>;
19
38
 
20
- export { execTool };
39
+ export { configureExecPolicy, execTool, getExecAllowlist, isExecCommandAllowed, resetExecPolicy };
package/dist/exec.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { toErrorMessage } from '@wrongstack/core/utils/error';
2
3
  import * as Core from '@wrongstack/core';
3
4
  import { buildChildEnv, expectDefined, wstackGlobalRoot } from '@wrongstack/core';
4
5
  import * as fs from 'node:fs';
@@ -783,49 +784,86 @@ function assertSafeWin32ShellArgs(args) {
783
784
 
784
785
  // src/exec.ts
785
786
  var isWin = process.platform === "win32";
786
- var ALLOWED_COMMANDS = {
787
- node: ["--version", "-r", "--input-type=module"],
788
- npm: ["--version", "list", "pkg", "doctor", "view", "outdated", "audit"],
789
- pnpm: ["--version", "remove", "list", "view", "outdated", "audit"],
790
- npx: ["--version"],
791
- git: [
792
- "--version",
793
- "status",
794
- "log",
795
- "diff",
796
- "branch",
797
- "checkout",
798
- "stash",
799
- "add",
800
- "commit",
801
- "push",
802
- "pull"
803
- ],
804
- ls: ["-la", "-l", "-a"],
805
- cat: [],
806
- head: ["-n"],
807
- tail: ["-n"],
808
- wc: ["-l", "-w", "-c"],
809
- grep: [],
810
- find: [],
811
- echo: [],
812
- mkdir: ["-p"],
813
- cp: ["-r"],
814
- mv: [],
815
- rm: ["-rf"],
816
- touch: [],
817
- bun: ["--version"],
818
- tsc: ["--version", "--noEmit", "--project"],
819
- vitest: ["--version", "run", "--coverage"],
820
- biome: ["--version", "lint", "format", "check"],
821
- cargo: ["--version", "build", "test", "check"],
822
- rustc: ["--version"],
823
- go: ["version", "run", "build", "test"],
824
- python: ["--version"],
825
- pip: ["--version", "list"],
826
- docker: ["--version", "ps", "images"],
827
- kubectl: ["version", "get", "describe", "logs"]
828
- };
787
+ var DEFAULT_ALLOWED_COMMANDS = /* @__PURE__ */ new Set([
788
+ // JS / TS toolchain
789
+ "node",
790
+ "npm",
791
+ "pnpm",
792
+ "yarn",
793
+ "npx",
794
+ "bun",
795
+ "deno",
796
+ "tsc",
797
+ "vitest",
798
+ "jest",
799
+ "biome",
800
+ "eslint",
801
+ "prettier",
802
+ // version control
803
+ "git",
804
+ // Rust
805
+ "cargo",
806
+ "rustc",
807
+ // Go
808
+ "go",
809
+ // Python
810
+ "python",
811
+ "python3",
812
+ "pip",
813
+ "pip3",
814
+ // Ruby
815
+ "ruby",
816
+ "gem",
817
+ "bundle",
818
+ // JVM
819
+ "java",
820
+ "javac",
821
+ "mvn",
822
+ "gradle",
823
+ "gradlew",
824
+ // .NET
825
+ "dotnet",
826
+ // C / C++ / native build
827
+ "make",
828
+ "cmake",
829
+ // containers / orchestration (read-only subcommands; see BLOCKED_ARG_PATTERNS)
830
+ "docker",
831
+ "kubectl",
832
+ // common POSIX file/text utilities
833
+ "ls",
834
+ "cat",
835
+ "head",
836
+ "tail",
837
+ "wc",
838
+ "grep",
839
+ "find",
840
+ "echo",
841
+ "mkdir",
842
+ "cp",
843
+ "mv",
844
+ "rm",
845
+ "touch"
846
+ ]);
847
+ var allowedCommands = new Set(DEFAULT_ALLOWED_COMMANDS);
848
+ var normalizeCmd = (c) => c.trim();
849
+ function configureExecPolicy(opts = {}) {
850
+ const next = new Set(DEFAULT_ALLOWED_COMMANDS);
851
+ for (const c of opts.allow ?? []) {
852
+ const n = normalizeCmd(c);
853
+ if (n) next.add(n);
854
+ }
855
+ for (const c of opts.deny ?? []) next.delete(normalizeCmd(c));
856
+ allowedCommands = next;
857
+ }
858
+ function resetExecPolicy() {
859
+ allowedCommands = new Set(DEFAULT_ALLOWED_COMMANDS);
860
+ }
861
+ function isExecCommandAllowed(cmd) {
862
+ return allowedCommands.has(normalizeCmd(cmd));
863
+ }
864
+ function getExecAllowlist() {
865
+ return [...allowedCommands].sort();
866
+ }
829
867
  var MAX_ARGS = 20;
830
868
  var MAX_OUTPUT = 2e5;
831
869
  var DEFAULT_TIMEOUT_MS = 3e4;
@@ -887,7 +925,7 @@ var execTool = {
887
925
  name: "exec",
888
926
  category: "Shell",
889
927
  description: "Execute a **whitelisted, restricted set of commands** with strict argument validation. This is the **preferred and safer** alternative to the `bash` tool for running development tools (node, npm, pnpm, tsc, git, tests, linters, etc.). It prevents arbitrary command injection and limits what the model can do.",
890
- usageHint: "PREFERRED SHELL TOOL for most cases.\n\nUse this instead of `bash` whenever possible.\n- `command` must be one of the allowed commands (node, npm, pnpm, git, tsc, eslint, vitest, etc.).\n- Arguments are passed as a clean array (no shell interpretation).\n- `cwd` is validated to stay inside the project.\n- For anything that requires real shell features (pipes, complex redirection, arbitrary commands), fall back to `bash` (with strong justification).\nThis tool significantly reduces the risk compared to full shell access.",
928
+ usageHint: "PREFERRED SHELL TOOL for most cases.\n\nUse this instead of `bash` whenever possible.\n- `command` must be in the allowlist. Defaults cover JS (node/npm/pnpm/yarn/bun/deno/tsc/vitest/eslint/biome), Go (`go build`/`go test`), Rust (cargo), Python (python/pip), Ruby (gem/bundle), JVM (java/mvn/gradle), .NET (dotnet), native (make/cmake), and git. Users can extend it via `tools.exec.allow` in config.\n- Arguments are passed as a clean array (no shell interpretation).\n- `cwd` is validated to stay inside the project.\n- If a command is not allowlisted, the error explains how to add it; for one-off arbitrary commands, fall back to `bash` (with strong justification).\nThis tool significantly reduces the risk compared to full shell access.",
891
929
  permission: "confirm",
892
930
  mutating: true,
893
931
  riskTier: "standard",
@@ -941,12 +979,12 @@ var execTool = {
941
979
  truncated: false,
942
980
  allowed: false
943
981
  };
944
- if (!(cmd in ALLOWED_COMMANDS)) {
982
+ if (!isExecCommandAllowed(cmd)) {
945
983
  return {
946
984
  command: cmd,
947
985
  args: input.args ?? [],
948
986
  stdout: "",
949
- stderr: `Command "${cmd}" not in allowlist. Use the bash tool for arbitrary commands.`,
987
+ stderr: `Command "${cmd}" not in allowlist. Add it to your ~/.wrongstack/config.json under "tools": { "exec": { "allow": ["${cmd}"] } }, or use the bash tool for one-off arbitrary commands.`,
950
988
  exitCode: 1,
951
989
  truncated: false,
952
990
  allowed: false
@@ -989,19 +1027,58 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId) {
989
1027
  let stdout = "";
990
1028
  let stderr = "";
991
1029
  let killed = false;
1030
+ const resolvedOnce = { value: false };
1031
+ const finish = (result) => {
1032
+ if (resolvedOnce.value) return;
1033
+ resolvedOnce.value = true;
1034
+ resolve2(result);
1035
+ };
992
1036
  const startedAt = Date.now();
993
1037
  const spool = createOutputSpool({ tool: `exec-${cmd}`, thresholdBytes: MAX_OUTPUT });
994
1038
  const resolved = resolveWin32Command(cmd);
995
1039
  const needsShell = isWin && (resolved.endsWith(".cmd") || resolved.endsWith(".bat"));
996
1040
  const spawnCmd = needsShell ? cmd : resolved;
997
1041
  if (needsShell) assertSafeWin32ShellArgs(args);
998
- const child = spawn(spawnCmd, args, {
999
- cwd,
1000
- env: buildChildEnv(sessionId),
1001
- stdio: ["ignore", "pipe", "pipe"],
1002
- windowsHide: true,
1003
- ...isWin ? {} : { signal },
1004
- ...needsShell ? { shell: true, windowsVerbatimArguments: true } : {}
1042
+ let child;
1043
+ try {
1044
+ child = spawn(spawnCmd, args, {
1045
+ cwd,
1046
+ env: buildChildEnv(sessionId),
1047
+ stdio: ["ignore", "pipe", "pipe"],
1048
+ windowsHide: true,
1049
+ ...isWin ? {} : { signal },
1050
+ ...needsShell ? { shell: true, windowsVerbatimArguments: true } : {}
1051
+ });
1052
+ } catch (err) {
1053
+ spool.finalize();
1054
+ finish({
1055
+ command: cmd,
1056
+ args,
1057
+ stdout: "",
1058
+ stderr: `spawn failed: ${toErrorMessage(err)}`,
1059
+ exitCode: 1,
1060
+ truncated: false,
1061
+ allowed: true
1062
+ });
1063
+ return;
1064
+ }
1065
+ child.on("error", (err) => {
1066
+ const isAbort = err && err.code === "ABORT_ERR";
1067
+ const stderrText = isAbort ? `Aborted: ${err.message}` : err.message;
1068
+ clearTimeout(timer);
1069
+ if (isWin) signal.removeEventListener("abort", onAbort);
1070
+ if (typeof pid === "number") registry.unregister(pid);
1071
+ registry.afterCall(Date.now() - startedAt, true);
1072
+ spool.finalize();
1073
+ finish({
1074
+ command: cmd,
1075
+ args,
1076
+ stdout: normalizeCommandOutput(stdout),
1077
+ stderr: stderrText,
1078
+ exitCode: isAbort ? 124 : 1,
1079
+ truncated: Buffer.byteLength(stdout, "utf8") > COMMAND_OUTPUT_MAX_BYTES,
1080
+ allowed: true
1081
+ });
1005
1082
  });
1006
1083
  const registry = getProcessRegistry();
1007
1084
  const pid = child.pid;
@@ -1041,7 +1118,7 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId) {
1041
1118
  const exitCode = killed ? 124 : code ?? 1;
1042
1119
  registry.afterCall(durationMs, exitCode !== 0);
1043
1120
  const spooled = spool.finalize();
1044
- resolve2({
1121
+ finish({
1045
1122
  command: cmd,
1046
1123
  args,
1047
1124
  stdout: normalizeCommandOutput(stdout) + (spooled ? spoolNote(spooled) : ""),
@@ -1051,25 +1128,9 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId) {
1051
1128
  allowed: true
1052
1129
  });
1053
1130
  });
1054
- child.on("error", (err) => {
1055
- clearTimeout(timer);
1056
- if (isWin) signal.removeEventListener("abort", onAbort);
1057
- if (typeof pid === "number") registry.unregister(pid);
1058
- registry.afterCall(Date.now() - startedAt, true);
1059
- spool.finalize();
1060
- resolve2({
1061
- command: cmd,
1062
- args,
1063
- stdout: normalizeCommandOutput(stdout),
1064
- stderr: err.message,
1065
- exitCode: 1,
1066
- truncated: Buffer.byteLength(stdout, "utf8") > COMMAND_OUTPUT_MAX_BYTES,
1067
- allowed: true
1068
- });
1069
- });
1070
1131
  });
1071
1132
  }
1072
1133
 
1073
- export { execTool };
1134
+ export { configureExecPolicy, execTool, getExecAllowlist, isExecCommandAllowed, resetExecPolicy };
1074
1135
  //# sourceMappingURL=exec.js.map
1075
1136
  //# sourceMappingURL=exec.js.map