terminal-commands 0.1.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.
@@ -0,0 +1,37 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import { authorizeToolCall } from "../src/agent-core.js";
5
+
6
+ test("allows read-only tools without an approval callback", async () => {
7
+ await authorizeToolCall("read_text_file", { path: "notes.txt" }, false);
8
+ });
9
+
10
+ test("rejects destructive tools when local approval is disabled", async () => {
11
+ await assert.rejects(
12
+ authorizeToolCall("write_text_file", { path: "notes.txt" }, false),
13
+ /disabled unless explicitly enabled/,
14
+ );
15
+ });
16
+
17
+ test("requires and honors an explicit approval for destructive tools", async () => {
18
+ await assert.rejects(
19
+ authorizeToolCall("run_program", { program: "git" }, true),
20
+ /not approved/,
21
+ );
22
+ await assert.rejects(
23
+ authorizeToolCall(
24
+ "run_program",
25
+ { program: "git" },
26
+ true,
27
+ async () => false,
28
+ ),
29
+ /not approved/,
30
+ );
31
+ await authorizeToolCall(
32
+ "run_program",
33
+ { program: "git" },
34
+ true,
35
+ async () => true,
36
+ );
37
+ });
@@ -0,0 +1,121 @@
1
+ import assert from "node:assert/strict";
2
+ import path from "node:path";
3
+ import test from "node:test";
4
+
5
+ import {
6
+ OPTIONS,
7
+ normalizeRoots,
8
+ optionEnvironment,
9
+ parseCommandLine,
10
+ renderHelp,
11
+ } from "../src/cli-options.js";
12
+
13
+ const delimited = (...values: string[]) => values.join(path.delimiter);
14
+
15
+ test("collects repeated roots into the roots environment variable", () => {
16
+ const parsed = parseCommandLine([
17
+ "connect",
18
+ "--root",
19
+ "/tmp/a",
20
+ "--root=/tmp/b",
21
+ ]);
22
+ assert.equal(parsed.command, "connect");
23
+ assert.equal(
24
+ optionEnvironment(parsed).MACHINE_TERMINAL_ROOTS,
25
+ delimited("/tmp/a", "/tmp/b"),
26
+ );
27
+ });
28
+
29
+ test("splits a delimited root list passed to one flag", () => {
30
+ const parsed = parseCommandLine([
31
+ "connect",
32
+ "-r",
33
+ delimited("/tmp/a", "/tmp/b"),
34
+ ]);
35
+ assert.equal(
36
+ optionEnvironment(parsed).MACHINE_TERMINAL_ROOTS,
37
+ delimited("/tmp/a", "/tmp/b"),
38
+ );
39
+ });
40
+
41
+ test("collects an executable allowlist as a comma-delimited list", () => {
42
+ const parsed = parseCommandLine([
43
+ "connect",
44
+ "--allowed-program",
45
+ "git,node",
46
+ "--allowed-program",
47
+ "rg",
48
+ ]);
49
+ assert.equal(
50
+ optionEnvironment(parsed).MACHINE_TERMINAL_ALLOWED_PROGRAMS,
51
+ "git,node,rg",
52
+ );
53
+ });
54
+
55
+ test("maps on/off options to 1 and 0", () => {
56
+ const on = parseCommandLine(["connect", "--allow-destructive"]);
57
+ const off = parseCommandLine(["connect", "--no-allow-shell"]);
58
+ assert.equal(on.options.get("allow-destructive"), true);
59
+ assert.equal(
60
+ optionEnvironment(on).MACHINE_TERMINAL_AGENT_ALLOW_DESTRUCTIVE,
61
+ "1",
62
+ );
63
+ assert.equal(optionEnvironment(off).MACHINE_TERMINAL_ALLOW_SHELL, "0");
64
+ });
65
+
66
+ test("keeps the last value of a single-value option", () => {
67
+ const parsed = parseCommandLine([
68
+ "connect",
69
+ "--name",
70
+ "first",
71
+ "--name",
72
+ "second",
73
+ ]);
74
+ assert.equal(
75
+ optionEnvironment(parsed).MACHINE_TERMINAL_DEVICE_NAME,
76
+ "second",
77
+ );
78
+ });
79
+
80
+ test("rejects unknown, misplaced, and incomplete options", () => {
81
+ assert.throws(
82
+ () => parseCommandLine(["connect", "--roots"]),
83
+ /Unknown option/,
84
+ );
85
+ assert.throws(
86
+ () => parseCommandLine(["logout", "--root", "/tmp"]),
87
+ /not available for `logout`/,
88
+ );
89
+ assert.throws(() => parseCommandLine(["connect", "--root"]), /needs a value/);
90
+ assert.throws(
91
+ () => parseCommandLine(["connect", "--root", "--name", "x"]),
92
+ /needs a value/,
93
+ );
94
+ assert.throws(() => parseCommandLine(["dance"]), /Unknown command/);
95
+ assert.throws(
96
+ () => parseCommandLine(["connect", "extra"]),
97
+ /Unexpected argument/,
98
+ );
99
+ });
100
+
101
+ test("defaults to help and supports per-command help", () => {
102
+ assert.equal(parseCommandLine([]).help, true);
103
+ const parsed = parseCommandLine(["connect", "--help"]);
104
+ assert.equal(parsed.command, "connect");
105
+ assert.equal(parsed.help, true);
106
+ });
107
+
108
+ test("normalizes roots to absolute, de-duplicated paths", () => {
109
+ const base = path.resolve(path.sep, "tmp", "work");
110
+ const roots = normalizeRoots(["b", "./b", path.join("a", "..", "b")], base);
111
+ assert.deepEqual(roots, [path.join(base, "b")]);
112
+ });
113
+
114
+ test("help lists every option with its environment variable", () => {
115
+ const help = renderHelp();
116
+ for (const option of OPTIONS) {
117
+ assert.ok(help.includes(`--${option.name}`), `missing --${option.name}`);
118
+ if (option.env)
119
+ assert.ok(help.includes(option.env), `missing ${option.env}`);
120
+ }
121
+ });
@@ -0,0 +1,44 @@
1
+ import assert from "node:assert/strict";
2
+ import { promises as fs } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+
7
+ import {
8
+ clearCredentials,
9
+ credentialsPath,
10
+ loadCredentials,
11
+ loadOrCreateDeviceId,
12
+ saveCredentials,
13
+ } from "../src/credential-store.js";
14
+
15
+ test("stores credentials privately and keeps a stable generated device id", async () => {
16
+ const temporary = await fs.mkdtemp(
17
+ path.join(os.tmpdir(), "terminal-commands-test-"),
18
+ );
19
+ const environment = { XDG_CONFIG_HOME: temporary };
20
+ try {
21
+ await saveCredentials(
22
+ {
23
+ accessToken: "secret",
24
+ expiresAt: Date.now() + 60_000,
25
+ issuer: "https://auth.example.test/",
26
+ audience: "https://mcp.example.test",
27
+ },
28
+ environment,
29
+ );
30
+ assert.equal((await loadCredentials(environment))?.accessToken, "secret");
31
+ if (process.platform !== "win32") {
32
+ assert.equal(
33
+ (await fs.stat(credentialsPath(environment))).mode & 0o777,
34
+ 0o600,
35
+ );
36
+ }
37
+ const first = await loadOrCreateDeviceId(environment);
38
+ assert.equal(await loadOrCreateDeviceId(environment), first);
39
+ await clearCredentials(environment);
40
+ assert.equal(await loadCredentials(environment), null);
41
+ } finally {
42
+ await fs.rm(temporary, { recursive: true, force: true });
43
+ }
44
+ });
@@ -0,0 +1,88 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import {
5
+ pollForDeviceToken,
6
+ refreshDeviceToken,
7
+ requestDeviceCode,
8
+ type DeviceAuthConfig,
9
+ type FetchLike,
10
+ } from "../src/device-auth.js";
11
+
12
+ const config: DeviceAuthConfig = {
13
+ issuer: "https://auth.example.test/",
14
+ clientId: "public-cli-client",
15
+ audience: "https://mcp.example.test",
16
+ scope: "terminal:connect offline_access",
17
+ };
18
+
19
+ test("starts device authorization with the configured audience and scope", async () => {
20
+ let submitted = "";
21
+ const fetchImpl: FetchLike = async (input, init) => {
22
+ assert.equal(String(input), "https://auth.example.test/oauth/device/code");
23
+ submitted = String(init?.body);
24
+ return new Response(
25
+ JSON.stringify({
26
+ device_code: "device-code",
27
+ user_code: "ABCD-EFGH",
28
+ verification_uri: "https://auth.example.test/activate",
29
+ expires_in: 600,
30
+ interval: 1,
31
+ }),
32
+ { status: 200 },
33
+ );
34
+ };
35
+ const result = await requestDeviceCode(config, fetchImpl);
36
+ assert.equal(result.user_code, "ABCD-EFGH");
37
+ assert.match(submitted, /audience=https%3A%2F%2Fmcp\.example\.test/);
38
+ assert.match(submitted, /terminal%3Aconnect/);
39
+ });
40
+
41
+ test("exchanges an approved device code without exposing tokens", async () => {
42
+ const fetchImpl: FetchLike = async () =>
43
+ new Response(
44
+ JSON.stringify({
45
+ access_token: "access-token",
46
+ refresh_token: "refresh-token",
47
+ expires_in: 3600,
48
+ token_type: "Bearer",
49
+ }),
50
+ { status: 200 },
51
+ );
52
+ const result = await pollForDeviceToken(
53
+ config,
54
+ "device-code",
55
+ 10,
56
+ 1,
57
+ undefined,
58
+ fetchImpl,
59
+ );
60
+ assert.equal(result.accessToken, "access-token");
61
+ assert.equal(result.refreshToken, "refresh-token");
62
+ assert.equal(result.issuer, config.issuer);
63
+ assert.equal(result.audience, config.audience);
64
+ assert.ok(result.expiresAt > Date.now());
65
+ });
66
+
67
+ test("refreshes an expired CLI access token", async () => {
68
+ let submitted = "";
69
+ const fetchImpl: FetchLike = async (_input, init) => {
70
+ submitted = String(init?.body);
71
+ return new Response(
72
+ JSON.stringify({ access_token: "new-token", expires_in: 900 }),
73
+ {
74
+ status: 200,
75
+ },
76
+ );
77
+ };
78
+ const result = await refreshDeviceToken(
79
+ config,
80
+ "old-refresh-token",
81
+ fetchImpl,
82
+ );
83
+ assert.equal(result.accessToken, "new-token");
84
+ assert.equal(result.refreshToken, "old-refresh-token");
85
+ assert.equal(result.issuer, config.issuer);
86
+ assert.equal(result.audience, config.audience);
87
+ assert.match(submitted, /grant_type=refresh_token/);
88
+ });
@@ -0,0 +1,29 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import { runProgram } from "../src/runner.js";
5
+
6
+ test("runProgram captures stdout without a shell", async () => {
7
+ const result = await runProgram({
8
+ program: process.execPath,
9
+ args: ["-e", "process.stdout.write('ok')"],
10
+ cwd: process.cwd(),
11
+ timeoutMs: 5_000,
12
+ maxOutputChars: 10_000,
13
+ });
14
+ assert.equal(result.exitCode, 0);
15
+ assert.equal(result.stdout, "ok");
16
+ assert.equal(result.stderr, "");
17
+ });
18
+
19
+ test("runProgram reports non-zero exits", async () => {
20
+ const result = await runProgram({
21
+ program: process.execPath,
22
+ args: ["-e", "process.stderr.write('bad'); process.exit(7)"],
23
+ cwd: process.cwd(),
24
+ timeoutMs: 5_000,
25
+ maxOutputChars: 10_000,
26
+ });
27
+ assert.equal(result.exitCode, 7);
28
+ assert.equal(result.stderr, "bad");
29
+ });
@@ -0,0 +1,113 @@
1
+ import assert from "node:assert/strict";
2
+ import {
3
+ mkdtemp,
4
+ mkdir,
5
+ readFile,
6
+ rm,
7
+ symlink,
8
+ writeFile,
9
+ } from "node:fs/promises";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import test from "node:test";
13
+
14
+ import {
15
+ assertProgramAllowed,
16
+ configuredRoots,
17
+ isWithinRoot,
18
+ resolveReadablePath,
19
+ resolveWritablePath,
20
+ safeEnvironment,
21
+ } from "../src/security.js";
22
+
23
+ test("configuredRoots resolves and deduplicates paths", () => {
24
+ const raw = [".", "."].join(path.delimiter);
25
+ assert.deepEqual(configuredRoots(raw), [process.cwd()]);
26
+ });
27
+
28
+ test("configuredRoots defaults to the launch directory", () => {
29
+ assert.deepEqual(configuredRoots(undefined), [process.cwd()]);
30
+ });
31
+
32
+ test("isWithinRoot rejects sibling prefix paths", () => {
33
+ assert.equal(isWithinRoot("/tmp/work", "/tmp/work"), true);
34
+ assert.equal(isWithinRoot("/tmp/work/file", "/tmp/work"), true);
35
+ assert.equal(isWithinRoot("/tmp/work-evil/file", "/tmp/work"), false);
36
+ });
37
+
38
+ test("safeEnvironment excludes secrets", () => {
39
+ const filtered = safeEnvironment({
40
+ PATH: "/bin",
41
+ OPENAI_API_KEY: "secret",
42
+ NPM_TOKEN: "secret",
43
+ });
44
+ assert.deepEqual(filtered, { PATH: "/bin" });
45
+ });
46
+
47
+ test("shells and privilege tools are blocked by default", () => {
48
+ const previous = process.env.MACHINE_TERMINAL_ALLOW_SHELL;
49
+ try {
50
+ delete process.env.MACHINE_TERMINAL_ALLOW_SHELL;
51
+ for (const program of ["bash", "sh", "sudo", "su", "zsh"]) {
52
+ assert.throws(
53
+ () => assertProgramAllowed(`/bin/${program}`),
54
+ /blocked by default/,
55
+ );
56
+ }
57
+ } finally {
58
+ if (previous === undefined) delete process.env.MACHINE_TERMINAL_ALLOW_SHELL;
59
+ else process.env.MACHINE_TERMINAL_ALLOW_SHELL = previous;
60
+ }
61
+ });
62
+
63
+ test("an explicit program allowlist is enforced", () => {
64
+ const previous = process.env.MACHINE_TERMINAL_ALLOWED_PROGRAMS;
65
+ process.env.MACHINE_TERMINAL_ALLOWED_PROGRAMS = "git,node";
66
+ assert.doesNotThrow(() => assertProgramAllowed("git"));
67
+ assert.throws(() => assertProgramAllowed("python3"), /not in/);
68
+ if (previous === undefined)
69
+ delete process.env.MACHINE_TERMINAL_ALLOWED_PROGRAMS;
70
+ else process.env.MACHINE_TERMINAL_ALLOWED_PROGRAMS = previous;
71
+ });
72
+
73
+ test("readable paths cannot escape configured roots", async () => {
74
+ const workspace = await mkdtemp(path.join(os.tmpdir(), "terminal-commands-"));
75
+ const root = path.join(workspace, "root");
76
+ await writeFile(path.join(workspace, "outside.txt"), "outside");
77
+ await mkdir(root);
78
+ await writeFile(path.join(root, "inside.txt"), "inside");
79
+
80
+ try {
81
+ await assert.rejects(
82
+ resolveReadablePath("../outside.txt", [root], root),
83
+ /outside MACHINE_TERMINAL_ROOTS/,
84
+ );
85
+ assert.equal(
86
+ await readFile(
87
+ await resolveReadablePath("inside.txt", [root], root),
88
+ "utf8",
89
+ ),
90
+ "inside",
91
+ );
92
+ } finally {
93
+ await rm(workspace, { recursive: true, force: true });
94
+ }
95
+ });
96
+
97
+ test("writable paths reject symbolic links", async () => {
98
+ const workspace = await mkdtemp(path.join(os.tmpdir(), "terminal-commands-"));
99
+ const root = path.join(workspace, "root");
100
+ const target = path.join(workspace, "target.txt");
101
+ await mkdir(root);
102
+ await writeFile(target, "target");
103
+ await symlink(target, path.join(root, "link.txt"));
104
+
105
+ try {
106
+ await assert.rejects(
107
+ resolveWritablePath("link.txt", [root], root),
108
+ /symbolic links is not allowed/,
109
+ );
110
+ } finally {
111
+ await rm(workspace, { recursive: true, force: true });
112
+ }
113
+ });
package/test/smoke.mjs ADDED
@@ -0,0 +1,34 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
3
+
4
+ const client = new Client({
5
+ name: "terminal-commands-smoke-test",
6
+ version: "0.1.0",
7
+ });
8
+ const transport = new StreamableHTTPClientTransport(
9
+ new URL("http://127.0.0.1:3333/mcp"),
10
+ );
11
+
12
+ try {
13
+ await client.connect(transport);
14
+ const tools = await client.listTools();
15
+ const expected = [
16
+ "get_system_info",
17
+ "list_directory",
18
+ "read_text_file",
19
+ "run_program",
20
+ "write_text_file",
21
+ ];
22
+ const actual = tools.tools.map((tool) => tool.name).sort();
23
+ if (JSON.stringify(actual) !== JSON.stringify(expected)) {
24
+ throw new Error(`Unexpected tools: ${actual.join(", ")}`);
25
+ }
26
+ const result = await client.callTool({
27
+ name: "get_system_info",
28
+ arguments: {},
29
+ });
30
+ if (result.isError) throw new Error("get_system_info returned an error");
31
+ console.log(`MCP smoke test passed; discovered ${actual.length} tools.`);
32
+ } finally {
33
+ await client.close();
34
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "strict": true,
7
+ "noUncheckedIndexedAccess": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "types": ["node"]
11
+ },
12
+ "include": ["src/**/*.ts", "test/**/*.ts"]
13
+ }