@sisu-ai/tool-terminal 7.0.0 → 7.1.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 (4) hide show
  1. package/LICENSE +201 -0
  2. package/dist/index.d.ts +26 -19
  3. package/dist/index.js +279 -161
  4. package/package.json +10 -6
package/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Tool } from '@sisu-ai/core';
1
+ import type { Tool } from "@sisu-ai/core";
2
2
  export interface TerminalToolConfig {
3
3
  roots: string[];
4
4
  readOnlyRoots?: string[];
@@ -28,6 +28,26 @@ export interface TerminalToolConfig {
28
28
  export declare const DEFAULT_CONFIG: TerminalToolConfig;
29
29
  export declare const TERMINAL_COMMANDS_ALLOW: ReadonlyArray<string>;
30
30
  export declare function defaultTerminalConfig(overrides?: Partial<TerminalToolConfig>): TerminalToolConfig;
31
+ interface TerminalPolicy {
32
+ allowed: boolean;
33
+ reason?: string;
34
+ allowedCommands?: string[];
35
+ allowedRoots?: string[];
36
+ }
37
+ interface TerminalRunResult {
38
+ exitCode: number;
39
+ stdout: string;
40
+ stderr: string;
41
+ durationMs: number;
42
+ policy: TerminalPolicy;
43
+ message?: string;
44
+ cwd: string;
45
+ }
46
+ interface TerminalReadResult {
47
+ contents: string;
48
+ policy: TerminalPolicy;
49
+ message?: string;
50
+ }
31
51
  export declare function createTerminalTool(config?: Partial<TerminalToolConfig>): {
32
52
  start_session: (args?: {
33
53
  cwd?: string;
@@ -42,17 +62,7 @@ export declare function createTerminalTool(config?: Partial<TerminalToolConfig>)
42
62
  env?: Record<string, string>;
43
63
  stdin?: string;
44
64
  sessionId?: string;
45
- }) => Promise<{
46
- exitCode: number;
47
- stdout: string;
48
- stderr: string;
49
- durationMs: number;
50
- policy: {
51
- allowed: boolean;
52
- reason?: string;
53
- };
54
- cwd: string;
55
- }>;
65
+ }) => Promise<TerminalRunResult>;
56
66
  cd: (args: {
57
67
  path: string;
58
68
  sessionId?: string;
@@ -64,16 +74,14 @@ export declare function createTerminalTool(config?: Partial<TerminalToolConfig>)
64
74
  path: string;
65
75
  encoding?: "utf8" | "base64";
66
76
  sessionId?: string;
67
- }) => Promise<{
68
- contents: string;
69
- }>;
77
+ }) => Promise<TerminalReadResult>;
70
78
  tools: (Tool<{
71
79
  command: string;
72
80
  cwd?: string;
73
81
  env?: Record<string, string>;
74
82
  stdin?: string;
75
83
  sessionId?: string;
76
- }, any> | Tool<{
84
+ }, TerminalRunResult> | Tool<{
77
85
  path: string;
78
86
  sessionId?: string;
79
87
  }, {
@@ -83,8 +91,7 @@ export declare function createTerminalTool(config?: Partial<TerminalToolConfig>)
83
91
  path: string;
84
92
  encoding?: "utf8" | "base64";
85
93
  sessionId?: string;
86
- }, {
87
- contents: string;
88
- }>)[];
94
+ }, TerminalReadResult>)[];
89
95
  };
90
96
  export type TerminalTool = ReturnType<typeof createTerminalTool>;
97
+ export {};
package/dist/index.js CHANGED
@@ -1,13 +1,13 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { promises as fs, realpathSync } from 'node:fs';
3
- import path from 'node:path';
4
- import { spawn } from 'node:child_process';
5
- import { minimatch } from 'minimatch';
6
- import { z } from 'zod';
1
+ import { randomUUID } from "node:crypto";
2
+ import { promises as fs, realpathSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { spawn } from "node:child_process";
5
+ import { minimatch } from "minimatch";
6
+ import { z } from "zod";
7
7
  const DEFAULT_PATH_DIRS = (() => {
8
- const base = ['/usr/bin', '/bin', '/usr/local/bin'];
9
- if (process.platform === 'darwin')
10
- base.push('/opt/homebrew/bin');
8
+ const base = ["/usr/bin", "/bin", "/usr/local/bin"];
9
+ if (process.platform === "darwin")
10
+ base.push("/opt/homebrew/bin");
11
11
  return base;
12
12
  })();
13
13
  export const DEFAULT_CONFIG = {
@@ -15,17 +15,17 @@ export const DEFAULT_CONFIG = {
15
15
  capabilities: { read: true, write: false, delete: false, exec: true },
16
16
  commands: {
17
17
  allow: [
18
- 'pwd',
19
- 'ls',
20
- 'stat',
21
- 'wc',
22
- 'head',
23
- 'tail',
24
- 'cat',
25
- 'cut',
26
- 'sort',
27
- 'uniq',
28
- 'grep'
18
+ "pwd",
19
+ "ls",
20
+ "stat",
21
+ "wc",
22
+ "head",
23
+ "tail",
24
+ "cat",
25
+ "cut",
26
+ "sort",
27
+ "uniq",
28
+ "grep",
29
29
  ],
30
30
  },
31
31
  execution: {
@@ -36,15 +36,20 @@ export const DEFAULT_CONFIG = {
36
36
  },
37
37
  allowPipe: false,
38
38
  allowSequence: false,
39
- sessions: { enabled: true, ttlMs: 120_000, maxPerAgent: 4 }
39
+ sessions: { enabled: true, ttlMs: 120_000, maxPerAgent: 4 },
40
40
  };
41
41
  // Reusable exports for consumers who want to surface or extend policy
42
- export const TERMINAL_COMMANDS_ALLOW = Object.freeze([...DEFAULT_CONFIG.commands.allow]);
42
+ export const TERMINAL_COMMANDS_ALLOW = Object.freeze([
43
+ ...DEFAULT_CONFIG.commands.allow,
44
+ ]);
43
45
  export function defaultTerminalConfig(overrides) {
44
46
  return {
45
47
  ...DEFAULT_CONFIG,
46
48
  ...overrides,
47
- capabilities: { ...DEFAULT_CONFIG.capabilities, ...(overrides?.capabilities ?? {}) },
49
+ capabilities: {
50
+ ...DEFAULT_CONFIG.capabilities,
51
+ ...(overrides?.capabilities ?? {}),
52
+ },
48
53
  commands: {
49
54
  allow: overrides?.commands?.allow ?? DEFAULT_CONFIG.commands.allow,
50
55
  },
@@ -56,7 +61,7 @@ export function defaultTerminalConfig(overrides) {
56
61
  }
57
62
  function isCommandAllowed(verb, policy) {
58
63
  const opts = { nocase: true };
59
- return policy.allow.some(p => minimatch(verb, p, opts));
64
+ return policy.allow.some((p) => minimatch(verb, p, opts));
60
65
  }
61
66
  function canonicalize(p) {
62
67
  try {
@@ -69,24 +74,28 @@ function canonicalize(p) {
69
74
  }
70
75
  function isPathAllowed(absPath, cfg, mode) {
71
76
  const real = canonicalize(absPath);
72
- const roots = cfg.roots.map(r => canonicalize(r));
73
- const inside = roots.some(r => real === r || real.startsWith(r + path.sep));
77
+ const roots = cfg.roots.map((r) => canonicalize(r));
78
+ const inside = roots.some((r) => real === r || real.startsWith(r + path.sep));
74
79
  if (!inside)
75
80
  return false;
76
- if (mode !== 'read' && cfg.readOnlyRoots) {
77
- const ro = cfg.readOnlyRoots.map(r => canonicalize(r));
78
- const inRo = ro.some(r => real === r || real.startsWith(r + path.sep));
81
+ if (mode !== "read" && cfg.readOnlyRoots) {
82
+ const ro = cfg.readOnlyRoots.map((r) => canonicalize(r));
83
+ const inRo = ro.some((r) => real === r || real.startsWith(r + path.sep));
79
84
  if (inRo)
80
85
  return false;
81
86
  }
82
87
  return true;
83
88
  }
84
89
  function looksLikePath(arg) {
85
- return arg.startsWith('.') || arg.includes('/') || /^(?:[A-Za-z]:[\\/]|\\\\)/.test(arg);
90
+ if (/^https?:\/\//i.test(arg))
91
+ return false;
92
+ return (arg.startsWith(".") ||
93
+ arg.includes("/") ||
94
+ /^(?:[A-Za-z]:[\\/]|\\\\)/.test(arg));
86
95
  }
87
96
  function parseArgs(cmd) {
88
97
  const out = [];
89
- let current = '';
98
+ let current = "";
90
99
  let single = false;
91
100
  let double = false;
92
101
  for (let i = 0; i < cmd.length; i++) {
@@ -102,14 +111,14 @@ function parseArgs(cmd) {
102
111
  if (!single && !double && /\s/.test(ch)) {
103
112
  if (current) {
104
113
  out.push(current);
105
- current = '';
114
+ current = "";
106
115
  }
107
116
  continue;
108
117
  }
109
118
  current += ch;
110
119
  }
111
120
  if (single || double)
112
- throw new Error('unbalanced quotes');
121
+ throw new Error("unbalanced quotes");
113
122
  if (current)
114
123
  out.push(current);
115
124
  return out;
@@ -117,7 +126,7 @@ function parseArgs(cmd) {
117
126
  function splitPipeline(cmd) {
118
127
  // Split on '|' outside quotes
119
128
  const out = [];
120
- let current = '';
129
+ let current = "";
121
130
  let single = false;
122
131
  let double = false;
123
132
  for (let i = 0; i < cmd.length; i++) {
@@ -132,11 +141,11 @@ function splitPipeline(cmd) {
132
141
  current += ch;
133
142
  continue;
134
143
  }
135
- if (ch === '|' && !single && !double) {
144
+ if (ch === "|" && !single && !double) {
136
145
  const seg = current.trim();
137
146
  if (seg)
138
147
  out.push(seg);
139
- current = '';
148
+ current = "";
140
149
  continue;
141
150
  }
142
151
  current += ch;
@@ -148,7 +157,7 @@ function splitPipeline(cmd) {
148
157
  }
149
158
  function splitSequence(cmd) {
150
159
  const out = [];
151
- let current = '';
160
+ let current = "";
152
161
  let single = false;
153
162
  let double = false;
154
163
  let nextOp = null;
@@ -166,29 +175,29 @@ function splitSequence(cmd) {
166
175
  continue;
167
176
  }
168
177
  if (!single && !double) {
169
- if (ch === ';') {
178
+ if (ch === ";") {
170
179
  const seg = current.trim();
171
180
  if (seg)
172
181
  out.push({ cmd: seg, op: nextOp });
173
- current = '';
174
- nextOp = ';';
182
+ current = "";
183
+ nextOp = ";";
175
184
  continue;
176
185
  }
177
- if (ch === '&' && nxt === '&') {
186
+ if (ch === "&" && nxt === "&") {
178
187
  const seg = current.trim();
179
188
  if (seg)
180
189
  out.push({ cmd: seg, op: nextOp });
181
- current = '';
182
- nextOp = '&&';
190
+ current = "";
191
+ nextOp = "&&";
183
192
  i++;
184
193
  continue;
185
194
  }
186
- if (ch === '|' && nxt === '|') {
195
+ if (ch === "|" && nxt === "|") {
187
196
  const seg = current.trim();
188
197
  if (seg)
189
198
  out.push({ cmd: seg, op: nextOp });
190
- current = '';
191
- nextOp = '||';
199
+ current = "";
200
+ nextOp = "||";
192
201
  i++;
193
202
  continue;
194
203
  }
@@ -202,61 +211,66 @@ function splitSequence(cmd) {
202
211
  }
203
212
  function commandPolicyCheck(args, cfg) {
204
213
  if (!cfg.capabilities.exec)
205
- return { allowed: false, reason: 'exec disabled' };
206
- if (!isPathAllowed(args.cwd, cfg, 'exec'))
207
- return { allowed: false, reason: 'cwd outside roots' };
214
+ return { allowed: false, reason: "exec disabled" };
215
+ if (!isPathAllowed(args.cwd, cfg, "exec"))
216
+ return { allowed: false, reason: "cwd outside roots" };
208
217
  let parsed;
209
218
  try {
210
219
  parsed = parseArgs(args.command);
211
220
  }
212
221
  catch {
213
- return { allowed: false, reason: 'invalid quoting' };
222
+ return { allowed: false, reason: "invalid quoting" };
214
223
  }
215
224
  if (parsed.length === 0)
216
- return { allowed: false, reason: 'empty command' };
225
+ return { allowed: false, reason: "empty command" };
217
226
  // Detect shell/control operators; allow only configured ones
218
227
  const found = [];
219
228
  const cmdStr = args.command;
220
229
  if (/&&/.test(cmdStr))
221
- found.push('&&');
230
+ found.push("&&");
222
231
  const hasOrOr = /\|\|/.test(cmdStr);
223
232
  if (hasOrOr)
224
- found.push('||');
233
+ found.push("||");
225
234
  // Consider single '|' only after removing '||'
226
- if (/\|/.test(cmdStr.replace(/\|\|/g, '')))
227
- found.push('|');
235
+ if (/\|/.test(cmdStr.replace(/\|\|/g, "")))
236
+ found.push("|");
228
237
  if (/;/.test(cmdStr))
229
- found.push(';');
238
+ found.push(";");
230
239
  if (/\$\(/.test(cmdStr))
231
- found.push('$(...)');
240
+ found.push("$(...)");
232
241
  if (/`/.test(cmdStr))
233
- found.push('`...`');
242
+ found.push("`...`");
234
243
  if (/>/.test(cmdStr))
235
- found.push('>');
236
- if (/<\<?/.test(cmdStr))
237
- found.push('<');
244
+ found.push(">");
245
+ if (/<<?/.test(cmdStr))
246
+ found.push("<");
238
247
  if (/(^|\s)&(\s|$)/.test(cmdStr))
239
- found.push('&');
248
+ found.push("&");
240
249
  const allowPipe = cfg.allowPipe ?? false;
241
250
  const allowSequence = cfg.allowSequence ?? false;
242
- const unallowed = found.filter(op => {
243
- if (op === '|' && allowPipe)
251
+ const unallowed = found.filter((op) => {
252
+ if (op === "|" && allowPipe)
244
253
  return false;
245
- if ((op === '&&' || op === '||' || op === ';') && allowSequence)
254
+ if ((op === "&&" || op === "||" || op === ";") && allowSequence)
246
255
  return false;
247
256
  return true;
248
257
  });
249
258
  if (unallowed.length > 0) {
250
- const unique = Array.from(new Set(unallowed)).join(', ');
251
- return { allowed: false, reason: `shell operators not allowed (${unique}). Enable allowPipe and/or allowSequence in config to opt in.` };
259
+ const unique = Array.from(new Set(unallowed)).join(", ");
260
+ return {
261
+ allowed: false,
262
+ reason: `shell operators not allowed (${unique}). Enable allowPipe and/or allowSequence in config to opt in.`,
263
+ };
252
264
  }
253
265
  const [verb, ...rest] = parsed;
254
266
  if (!isCommandAllowed(verb, cfg.commands))
255
- return { allowed: false, reason: 'command denied' };
267
+ return { allowed: false, reason: "command denied" };
256
268
  for (const a of rest) {
257
269
  if (looksLikePath(a)) {
258
- const abs = path.isAbsolute(a) || /^(?:[A-Za-z]:\\|\\)/.test(a) ? a : path.join(args.cwd, a);
259
- if (!isPathAllowed(abs, cfg, 'read')) {
270
+ const abs = path.isAbsolute(a) || /^(?:[A-Za-z]:\\|\\)/.test(a)
271
+ ? a
272
+ : path.join(args.cwd, a);
273
+ if (!isPathAllowed(abs, cfg, "read")) {
260
274
  return { allowed: false, reason: `path outside roots: ${a}` };
261
275
  }
262
276
  }
@@ -265,25 +279,33 @@ function commandPolicyCheck(args, cfg) {
265
279
  if (allowPipe && /\|/.test(args.command)) {
266
280
  const segments = splitPipeline(args.command);
267
281
  if (segments.length < 2)
268
- return { allowed: false, reason: 'invalid pipeline' };
282
+ return { allowed: false, reason: "invalid pipeline" };
269
283
  for (const seg of segments) {
270
284
  let segArgs;
271
285
  try {
272
286
  segArgs = parseArgs(seg);
273
287
  }
274
288
  catch {
275
- return { allowed: false, reason: 'invalid quoting in pipeline segment' };
289
+ return {
290
+ allowed: false,
291
+ reason: "invalid quoting in pipeline segment",
292
+ };
276
293
  }
277
294
  if (segArgs.length === 0)
278
- return { allowed: false, reason: 'empty pipeline segment' };
295
+ return { allowed: false, reason: "empty pipeline segment" };
279
296
  const [v, ...r] = segArgs;
280
297
  if (!isCommandAllowed(v, cfg.commands))
281
298
  return { allowed: false, reason: `command denied in pipeline: ${v}` };
282
299
  for (const a of r) {
283
300
  if (looksLikePath(a)) {
284
- const abs = path.isAbsolute(a) || /^(?:[A-Za-z]:\\|\\)/.test(a) ? a : path.join(args.cwd, a);
285
- if (!isPathAllowed(abs, cfg, 'read'))
286
- return { allowed: false, reason: `path outside roots in pipeline: ${a}` };
301
+ const abs = path.isAbsolute(a) || /^(?:[A-Za-z]:\\|\\)/.test(a)
302
+ ? a
303
+ : path.join(args.cwd, a);
304
+ if (!isPathAllowed(abs, cfg, "read"))
305
+ return {
306
+ allowed: false,
307
+ reason: `path outside roots in pipeline: ${a}`,
308
+ };
287
309
  }
288
310
  }
289
311
  }
@@ -291,7 +313,7 @@ function commandPolicyCheck(args, cfg) {
291
313
  if (allowSequence && /(?:&&|\|\||;)/.test(args.command)) {
292
314
  const seq = splitSequence(args.command);
293
315
  if (seq.length === 0)
294
- return { allowed: false, reason: 'invalid sequence' };
316
+ return { allowed: false, reason: "invalid sequence" };
295
317
  for (const part of seq) {
296
318
  const res = commandPolicyCheck({ command: part.cmd, cwd: args.cwd }, cfg);
297
319
  if (!res.allowed)
@@ -316,7 +338,7 @@ export function createTerminalTool(config) {
316
338
  return s;
317
339
  }
318
340
  function buildEnv(extra) {
319
- const allowed = new Set(['PATH', 'HOME', 'LANG', 'TERM']);
341
+ const allowed = new Set(["PATH", "HOME", "LANG", "TERM"]);
320
342
  const env = {};
321
343
  for (const key of allowed) {
322
344
  const v = process.env[key];
@@ -328,15 +350,15 @@ export function createTerminalTool(config) {
328
350
  env[k] = v;
329
351
  }
330
352
  // Enforce a controlled PATH from config (ignores provided PATH to avoid hijack)
331
- env.PATH = cfg.execution.pathDirs.join(':');
353
+ env.PATH = cfg.execution.pathDirs.join(":");
332
354
  return env;
333
355
  }
334
356
  function start_session(args) {
335
357
  if (!cfg.sessions.enabled)
336
- throw new Error('sessions disabled');
358
+ throw new Error("sessions disabled");
337
359
  const cwd = canonicalize(args?.cwd ? path.resolve(args.cwd) : cfg.roots[0]);
338
- if (!isPathAllowed(cwd, cfg, 'exec')) {
339
- throw new Error('cwd outside allowed roots');
360
+ if (!isPathAllowed(cwd, cfg, "exec")) {
361
+ throw new Error("cwd outside allowed roots");
340
362
  }
341
363
  const sessionId = randomUUID();
342
364
  const expiresAt = Date.now() + cfg.sessions.ttlMs;
@@ -348,7 +370,19 @@ export function createTerminalTool(config) {
348
370
  const cwd = canonicalize(path.resolve(args.cwd ?? session?.cwd ?? cfg.roots[0]));
349
371
  const pre = commandPolicyCheck({ command: args.command, cwd }, cfg);
350
372
  if (!pre.allowed) {
351
- return { exitCode: -1, stdout: '', stderr: '', durationMs: 0, policy: pre, cwd };
373
+ return {
374
+ exitCode: -1,
375
+ stdout: "",
376
+ stderr: "",
377
+ durationMs: 0,
378
+ policy: {
379
+ allowed: false,
380
+ reason: pre.reason,
381
+ allowedCommands: cfg.commands.allow,
382
+ },
383
+ message: `Command denied by policy. Allowed commands: ${cfg.commands.allow.join(", ")}.`,
384
+ cwd,
385
+ };
352
386
  }
353
387
  const pipelinesAllowed = cfg.allowPipe ?? false;
354
388
  const sequencesAllowed = cfg.allowSequence ?? false;
@@ -358,17 +392,23 @@ export function createTerminalTool(config) {
358
392
  if (hasSeq) {
359
393
  const seq = splitSequence(args.command);
360
394
  let lastExit = 0;
361
- let out = '';
362
- let err = '';
395
+ let out = "";
396
+ let err = "";
363
397
  let durTotal = 0;
364
398
  for (let i = 0; i < seq.length; i++) {
365
399
  const { cmd: subCmd, op } = seq[i];
366
- const shouldRun = i === 0 ? true : (op === ';' ? true : (op === '&&' ? lastExit === 0 : lastExit !== 0));
400
+ const shouldRun = i === 0
401
+ ? true
402
+ : op === ";"
403
+ ? true
404
+ : op === "&&"
405
+ ? lastExit === 0
406
+ : lastExit !== 0;
367
407
  if (!shouldRun)
368
408
  continue;
369
409
  const res = await run_command({ ...args, command: subCmd, cwd });
370
- out += res.stdout || '';
371
- err += res.stderr || '';
410
+ out += res.stdout || "";
411
+ err += res.stderr || "";
372
412
  durTotal += res.durationMs || 0;
373
413
  lastExit = res.exitCode;
374
414
  }
@@ -376,34 +416,51 @@ export function createTerminalTool(config) {
376
416
  session.cwd = cwd;
377
417
  session.expiresAt = Date.now() + cfg.sessions.ttlMs;
378
418
  }
379
- return { exitCode: lastExit, stdout: out, stderr: err, durationMs: durTotal, policy: { allowed: true }, cwd };
419
+ return {
420
+ exitCode: lastExit,
421
+ stdout: out,
422
+ stderr: err,
423
+ durationMs: durTotal,
424
+ policy: { allowed: true },
425
+ cwd,
426
+ };
380
427
  }
381
428
  const argv = parseArgs(args.command);
382
429
  const [cmd, ...cmdArgs] = argv;
383
430
  const env = buildEnv({ ...(session?.env ?? {}), ...(args.env ?? {}) });
384
431
  const start = Date.now();
385
432
  return await new Promise((resolve) => {
386
- let stdout = '', stderr = '';
433
+ let stdout = "", stderr = "";
387
434
  let outBytes = 0, errBytes = 0;
388
435
  const children = [];
389
- const killAll = () => { for (const c of children) {
390
- try {
391
- c.kill('SIGKILL');
436
+ const killAll = () => {
437
+ for (const c of children) {
438
+ try {
439
+ c.kill("SIGKILL");
440
+ }
441
+ catch {
442
+ // ignore kill errors
443
+ }
392
444
  }
393
- catch { }
394
- } };
395
- const onStdout = (d) => { outBytes += d.length; if (outBytes <= cfg.execution.maxStdoutBytes)
396
- stdout += d.toString();
397
- else
398
- killAll(); };
399
- const onStderr = (d) => { errBytes += d.length; if (errBytes <= cfg.execution.maxStderrBytes)
400
- stderr += d.toString();
401
- else
402
- killAll(); };
445
+ };
446
+ const onStdout = (d) => {
447
+ outBytes += d.length;
448
+ if (outBytes <= cfg.execution.maxStdoutBytes)
449
+ stdout += d.toString();
450
+ else
451
+ killAll();
452
+ };
453
+ const onStderr = (d) => {
454
+ errBytes += d.length;
455
+ if (errBytes <= cfg.execution.maxStderrBytes)
456
+ stderr += d.toString();
457
+ else
458
+ killAll();
459
+ };
403
460
  const timeout = setTimeout(() => killAll(), cfg.execution.timeoutMs);
404
461
  if (hasPipe) {
405
462
  const segments = splitPipeline(args.command);
406
- const argvList = segments.map(seg => parseArgs(seg));
463
+ const argvList = segments.map((seg) => parseArgs(seg));
407
464
  let prev;
408
465
  let finished = false;
409
466
  const finish = (exitCode, errMsg) => {
@@ -419,13 +476,20 @@ export function createTerminalTool(config) {
419
476
  if (errMsg) {
420
477
  stderr += (stderr ? "\n" : "") + errMsg;
421
478
  }
422
- resolve({ exitCode, stdout, stderr, durationMs: dur, policy: { allowed: true }, cwd });
479
+ resolve({
480
+ exitCode,
481
+ stdout,
482
+ stderr,
483
+ durationMs: dur,
484
+ policy: { allowed: true },
485
+ cwd,
486
+ });
423
487
  };
424
488
  for (let i = 0; i < argvList.length; i++) {
425
489
  const [pcmd, ...pargs] = argvList[i];
426
490
  const proc = spawn(pcmd, pargs, { cwd, env, shell: false });
427
491
  children.push(proc);
428
- proc.on('error', (err) => {
492
+ proc.on("error", (err) => {
429
493
  killAll();
430
494
  finish(-1, String(err?.message ?? err));
431
495
  });
@@ -437,10 +501,10 @@ export function createTerminalTool(config) {
437
501
  prev.stdout.pipe(proc.stdin);
438
502
  }
439
503
  if (i === argvList.length - 1 && proc.stdout) {
440
- proc.stdout.on('data', onStdout);
504
+ proc.stdout.on("data", onStdout);
441
505
  }
442
506
  if (proc.stderr)
443
- proc.stderr.on('data', onStderr);
507
+ proc.stderr.on("data", onStderr);
444
508
  // Close stdin of previous once piped
445
509
  if (prev && prev.stdin) {
446
510
  prev.stdin.end();
@@ -448,8 +512,8 @@ export function createTerminalTool(config) {
448
512
  prev = proc;
449
513
  }
450
514
  const last = children[children.length - 1];
451
- last.on('close', (code) => finish(code ?? -1));
452
- last.on('error', (err) => finish(-1, String(err?.message ?? err)));
515
+ last.on("close", (code) => finish(code ?? -1));
516
+ last.on("error", (err) => finish(-1, String(err?.message ?? err)));
453
517
  }
454
518
  else {
455
519
  const child = spawn(cmd, cmdArgs, { cwd, env, shell: false });
@@ -457,130 +521,184 @@ export function createTerminalTool(config) {
457
521
  if (args.stdin)
458
522
  child.stdin.write(args.stdin);
459
523
  child.stdin.end();
460
- child.stdout.on('data', onStdout);
461
- child.stderr.on('data', onStderr);
462
- child.on('close', (code) => {
524
+ child.stdout.on("data", onStdout);
525
+ child.stderr.on("data", onStderr);
526
+ child.on("close", (code) => {
463
527
  clearTimeout(timeout);
464
528
  const dur = Date.now() - start;
465
529
  if (session) {
466
530
  session.cwd = cwd;
467
531
  session.expiresAt = Date.now() + cfg.sessions.ttlMs;
468
532
  }
469
- resolve({ exitCode: code ?? -1, stdout, stderr, durationMs: dur, policy: { allowed: true }, cwd });
533
+ resolve({
534
+ exitCode: code ?? -1,
535
+ stdout,
536
+ stderr,
537
+ durationMs: dur,
538
+ policy: { allowed: true },
539
+ cwd,
540
+ });
470
541
  });
471
- child.on('error', (err) => {
542
+ child.on("error", (err) => {
472
543
  clearTimeout(timeout);
473
544
  const dur = Date.now() - start;
474
- resolve({ exitCode: -1, stdout, stderr: String(err.message), durationMs: dur, policy: { allowed: true }, cwd });
545
+ resolve({
546
+ exitCode: -1,
547
+ stdout,
548
+ stderr: String(err.message),
549
+ durationMs: dur,
550
+ policy: { allowed: true },
551
+ cwd,
552
+ });
475
553
  });
476
554
  }
477
555
  });
478
556
  }
479
557
  function cd(args) {
480
558
  let session = getSession(args.sessionId);
559
+ let createdSessionId;
481
560
  // If no valid session is provided, create one anchored at the first root
482
561
  if (!session) {
483
562
  const cwd = canonicalize(cfg.roots[0]);
484
- const sessionId = randomUUID();
563
+ createdSessionId = randomUUID();
485
564
  const expiresAt = Date.now() + cfg.sessions.ttlMs;
486
565
  session = { cwd, env: {}, expiresAt };
487
- sessions.set(sessionId, session);
488
- args._createdSessionId = sessionId;
566
+ sessions.set(createdSessionId, session);
489
567
  }
490
568
  const newPath = canonicalize(path.resolve(session.cwd, args.path));
491
- if (!isPathAllowed(newPath, cfg, 'exec')) {
492
- throw new Error('path outside allowed roots');
569
+ if (!isPathAllowed(newPath, cfg, "exec")) {
570
+ throw new Error("path outside allowed roots");
493
571
  }
494
572
  session.cwd = newPath;
495
573
  session.expiresAt = Date.now() + cfg.sessions.ttlMs;
496
- return { cwd: session.cwd, sessionId: args._createdSessionId ?? args.sessionId };
574
+ return {
575
+ cwd: session.cwd,
576
+ sessionId: createdSessionId ?? args.sessionId,
577
+ };
497
578
  }
498
579
  async function read_file(args) {
499
580
  if (!cfg.capabilities.read)
500
- throw new Error('read disabled');
581
+ throw new Error("read disabled");
501
582
  const session = getSession(args.sessionId);
502
583
  const cwd = session?.cwd ?? cfg.roots[0];
503
584
  const abs = canonicalize(path.resolve(cwd, args.path));
504
- if (!isPathAllowed(abs, cfg, 'read'))
505
- throw new Error('path outside allowed roots');
585
+ if (!isPathAllowed(abs, cfg, "read")) {
586
+ return {
587
+ contents: "",
588
+ policy: {
589
+ allowed: false,
590
+ reason: "path outside allowed roots",
591
+ allowedRoots: cfg.roots,
592
+ },
593
+ message: `Path denied by policy. Allowed roots: ${cfg.roots.join(", ")}.`,
594
+ };
595
+ }
506
596
  const buf = await fs.readFile(abs);
507
- const encoding = args.encoding ?? 'utf8';
508
- const contents = encoding === 'base64' ? buf.toString('base64') : buf.toString('utf8');
509
- return { contents };
597
+ const encoding = args.encoding ?? "utf8";
598
+ const contents = encoding === "base64" ? buf.toString("base64") : buf.toString("utf8");
599
+ return { contents, policy: { allowed: true } };
510
600
  }
511
601
  const runCommandTool = {
512
- name: 'terminalRun',
602
+ name: "terminalRun",
513
603
  description: [
514
604
  `Run a non-interactive command within allowed roots (${cfg.roots}).`,
515
- `Use for listing files (ls), printing files (cat), simple text processing etc. Allowed commands are ${cfg.commands.allow.join(', ')}.`,
516
- 'Shell operators are rejected and the environment is sanitized before execution.',
517
- 'Always prefer passing a safe single command.',
518
- 'Tips: pass cwd to run in a specific folder; use terminalCd first to set a working directory for subsequent calls; prefer terminalReadFile when you only need file contents.'
519
- ].join(' '),
605
+ `Use for listing files (ls), printing files (cat), simple text processing etc. Allowed commands are ${cfg.commands.allow.join(", ")}.`,
606
+ "Shell operators are rejected and the environment is sanitized before execution.",
607
+ "Always prefer passing a safe single command.",
608
+ "Tips: pass cwd to run in a specific folder; use terminalCd first to set a working directory for subsequent calls; prefer terminalReadFile when you only need file contents.",
609
+ ].join(" "),
520
610
  schema: z.object({
521
611
  command: z.string(),
522
612
  cwd: z.string().optional(),
523
613
  env: z.record(z.string()).optional(),
524
614
  stdin: z.string().optional(),
525
- sessionId: z.string().optional()
615
+ sessionId: z.string().optional(),
526
616
  }),
527
617
  handler: async (a, ctx) => {
528
618
  const s = getSession(a.sessionId);
529
619
  const effCwd = path.resolve(a.cwd ?? s?.cwd ?? cfg.roots[0]);
530
620
  const policy = commandPolicyCheck({ command: a.command, cwd: effCwd }, cfg);
531
- ctx?.log?.debug?.('[terminalRun] policy', { command: a.command, cwd: effCwd, policy });
621
+ ctx?.log?.debug?.("[terminalRun] policy", {
622
+ command: a.command,
623
+ cwd: effCwd,
624
+ policy,
625
+ });
532
626
  const res = await run_command(a);
533
- ctx?.log?.info?.('[terminalRun] result', {
627
+ ctx?.log?.info?.("[terminalRun] result", {
534
628
  command: a.command,
535
629
  cwd: res.cwd,
536
630
  exitCode: res.exitCode,
537
631
  durationMs: res.durationMs,
538
- stdoutBytes: Buffer.byteLength(res.stdout || ''),
539
- stderrBytes: Buffer.byteLength(res.stderr || ''),
632
+ stdoutBytes: Buffer.byteLength(res.stdout || ""),
633
+ stderrBytes: Buffer.byteLength(res.stderr || ""),
540
634
  policy: res.policy,
541
635
  });
542
636
  return res;
543
- }
637
+ },
544
638
  };
545
639
  const cdTool = {
546
- name: 'terminalCd',
640
+ name: "terminalCd",
547
641
  description: [
548
- 'Change the working directory for subsequent terminal operations.',
549
- 'Accepts a path relative to the current directory or absolute within the configured roots.',
550
- 'If no session exists, creates one and returns sessionId.',
551
- 'Use before terminalRun when you need to run multiple commands in the same folder.'
552
- ].join(' '),
642
+ "Change the working directory for subsequent terminal operations.",
643
+ "Accepts a path relative to the current directory or absolute within the configured roots.",
644
+ "If no session exists, creates one and returns sessionId.",
645
+ "Use before terminalRun when you need to run multiple commands in the same folder.",
646
+ ].join(" "),
553
647
  schema: z.object({ path: z.string(), sessionId: z.string().optional() }),
554
648
  handler: async ({ path: relPath, sessionId }, ctx) => {
555
649
  const s = getSession(sessionId);
556
650
  const base = s?.cwd ?? cfg.roots[0];
557
651
  const target = path.resolve(base, relPath);
558
- const allowed = isPathAllowed(target, cfg, 'exec');
559
- ctx?.log?.debug?.('[terminalCd] request', { base, path: relPath, target, allowed });
652
+ const allowed = isPathAllowed(target, cfg, "exec");
653
+ ctx?.log?.debug?.("[terminalCd] request", {
654
+ base,
655
+ path: relPath,
656
+ target,
657
+ allowed,
658
+ });
560
659
  const res = cd({ path: relPath, sessionId });
561
- ctx?.log?.info?.('[terminalCd] result', res);
660
+ ctx?.log?.info?.("[terminalCd] result", res);
562
661
  return res;
563
- }
662
+ },
564
663
  };
565
664
  const readFileTool = {
566
- name: 'terminalReadFile',
665
+ name: "terminalReadFile",
567
666
  description: [
568
- 'Read a small text file from the sandboxed workspace.',
569
- 'Prefer this instead of running `cat` when you only need file contents.',
570
- 'Path must be inside allowed roots; returns UTF-8 text by default.'
571
- ].join(' '),
572
- schema: z.object({ path: z.string(), encoding: z.enum(['utf8', 'base64']).optional(), sessionId: z.string().optional() }),
667
+ "Read a small text file from the sandboxed workspace.",
668
+ "Prefer this instead of running `cat` when you only need file contents.",
669
+ "Path must be inside allowed roots; returns UTF-8 text by default.",
670
+ ].join(" "),
671
+ schema: z.object({
672
+ path: z.string(),
673
+ encoding: z.enum(["utf8", "base64"]).optional(),
674
+ sessionId: z.string().optional(),
675
+ }),
573
676
  handler: async (a, ctx) => {
574
677
  const s = getSession(a.sessionId);
575
678
  const base = s?.cwd ?? cfg.roots[0];
576
679
  const abs = path.resolve(base, a.path);
577
- const allowed = isPathAllowed(abs, cfg, 'read');
578
- ctx?.log?.debug?.('[terminalReadFile] request', { base, path: a.path, abs, allowed });
680
+ const allowed = isPathAllowed(abs, cfg, "read");
681
+ ctx?.log?.debug?.("[terminalReadFile] request", {
682
+ base,
683
+ path: a.path,
684
+ abs,
685
+ allowed,
686
+ });
579
687
  const res = await read_file(a);
580
- ctx?.log?.info?.('[terminalReadFile] result', { abs, bytes: Buffer.byteLength(res.contents || ''), encoding: a.encoding || 'utf8' });
688
+ ctx?.log?.info?.("[terminalReadFile] result", {
689
+ abs,
690
+ bytes: Buffer.byteLength(res.contents || ""),
691
+ encoding: a.encoding || "utf8",
692
+ });
581
693
  return res;
582
- }
694
+ },
583
695
  };
584
696
  // Do not expose start_session as a tool by default to keep the model API simple.
585
- return { start_session, run_command, cd, read_file, tools: [runCommandTool, cdTool, readFileTool] };
697
+ return {
698
+ start_session,
699
+ run_command,
700
+ cd,
701
+ read_file,
702
+ tools: [runCommandTool, cdTool, readFileTool],
703
+ };
586
704
  }
package/package.json CHANGED
@@ -1,21 +1,18 @@
1
1
  {
2
2
  "name": "@sisu-ai/tool-terminal",
3
- "version": "7.0.0",
3
+ "version": "7.1.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "files": [
8
8
  "dist"
9
9
  ],
10
- "scripts": {
11
- "build": "tsc -b"
12
- },
13
10
  "dependencies": {
14
11
  "minimatch": "^9.0.3",
15
12
  "zod": "^3.23.8"
16
13
  },
17
14
  "peerDependencies": {
18
- "@sisu-ai/core": "^2.3.0"
15
+ "@sisu-ai/core": "^2.3.1"
19
16
  },
20
17
  "repository": {
21
18
  "type": "git",
@@ -25,5 +22,12 @@
25
22
  "homepage": "https://github.com/finger-gun/sisu#readme",
26
23
  "bugs": {
27
24
  "url": "https://github.com/finger-gun/sisu/issues"
25
+ },
26
+ "scripts": {
27
+ "build": "tsc -b",
28
+ "clean": "rm -rf dist",
29
+ "lint": "eslint src --ext .ts,.js",
30
+ "lint:fix": "eslint src --ext .ts,.js --fix",
31
+ "typecheck": "tsc --noEmit"
28
32
  }
29
- }
33
+ }