spl-lang 0.1.1 → 0.2.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/README.md +47 -6
- package/dist/ast.d.ts +5 -3
- package/dist/cli.js +13 -2
- package/dist/copilot.d.ts +5 -3
- package/dist/copilot.js +16 -7
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/interpreter.js +8 -5
- package/dist/logger.d.ts +3 -0
- package/dist/logger.js +7 -0
- package/dist/mcp.d.ts +7 -0
- package/dist/mcp.js +108 -0
- package/dist/parser.js +13 -6
- package/dist/restrict.d.ts +3 -0
- package/dist/restrict.js +27 -1
- package/dist/tools.d.ts +6 -0
- package/dist/tools.js +194 -19
- package/docs/syntax.ebnf +2 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -19,13 +19,13 @@ Requires Node 20+ and an authenticated GitHub Copilot.
|
|
|
19
19
|
```
|
|
20
20
|
spl <script.spl> [--log-level error|info|debug] [--silent] [--model <id>]
|
|
21
21
|
[--store <file.json> | --store-module <module>]
|
|
22
|
-
[--timeout <s>] [--cwd <dir>] [--check]
|
|
22
|
+
[--timeout <s>] [--cwd <dir>] [--mcp <file.json>] [--check]
|
|
23
23
|
spl login # GitHub device flow: prints a code, you confirm it in the browser
|
|
24
24
|
spl logout # removes the credentials saved by `spl login`
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
* stdout: the value of `success` or a top-level `return` (strings raw, everything else as JSON) or the failure payload.
|
|
28
|
-
* stderr: logs, including
|
|
28
|
+
* stderr: logs, including full LLM requests and raw responses (`[llm]`) and executed statement traces with line numbers (`[deterministic]`) at every non-silent log level (`error`, `info`, `debug`). Model and reasoning effort are included at `info` and `debug`. Logs may contain sensitive data; use `--silent` to suppress them.
|
|
29
29
|
* Exit code: `0` success, `1` uncaught failure, `2` syntax/usage error.
|
|
30
30
|
|
|
31
31
|
`spl login` does nothing if the runtime is already authenticated (an earlier login, a Copilot CLI / `gh` login or
|
|
@@ -94,17 +94,57 @@ Decisions worth knowing (the PRD leaves them open):
|
|
|
94
94
|
## Restrictions
|
|
95
95
|
|
|
96
96
|
Two layers: the LLM is told about active restrictions in the prompt, and the runtime enforces them. The LLM only gets
|
|
97
|
-
the five runtime tools (`read_file`, `write_file`, `web_fetch`, `web_search`, `shell`); every call is checked against
|
|
97
|
+
the five runtime tools (`read_file`, `write_file`, `web_fetch`, `web_search`, `shell`) plus configured MCP tools; every call is checked against
|
|
98
98
|
the active `restrict` scopes and any other Copilot permission request is rejected. A violation raises a
|
|
99
99
|
`restriction_violation` failure (catchable with `try/except`).
|
|
100
100
|
|
|
101
101
|
* Paths are canonicalized (symlinks resolved, `..` removed) relative to `--cwd` before the check.
|
|
102
102
|
* `net` matches exact hosts (and `*.example.com` wildcards); redirects are re-checked on every hop. `web_search`
|
|
103
103
|
needs `html.duckduckgo.com` to be allowed.
|
|
104
|
-
* `exec` matches the executable name.
|
|
105
|
-
|
|
106
|
-
|
|
104
|
+
* `exec` matches the executable name. Executables in pipelines and chains are checked against `restrict exec`,
|
|
105
|
+
and path-like and URL arguments and redirection targets are checked against `fs`/`net`. This is best effort for
|
|
106
|
+
programs that touch files or the network on their own.
|
|
107
107
|
* Nested restrictions of one kind intersect and can never widen.
|
|
108
|
+
* Clauses can be combined: `restrict './src', net 'example.com', exec 'git', mcp 'docs/search' { ... }`.
|
|
109
|
+
Repeating a kind intersects its clauses, just like nesting.
|
|
110
|
+
* `mcp` allows configured servers (`'docs'` or `'docs/*'`) or individual tools (`'docs/search'`).
|
|
111
|
+
Selectors are case-sensitive; nested scopes cannot widen the configuration's tool allowlist.
|
|
112
|
+
|
|
113
|
+
On Windows, `dir` switches such as `/s`, `/b` and `/a:-d` are recognized as options,
|
|
114
|
+
not filesystem paths. Paths and redirection targets remain subject to filesystem restrictions.
|
|
115
|
+
|
|
116
|
+
## MCP tools
|
|
117
|
+
|
|
118
|
+
Pass `--mcp .vscode/mcp.json` to load MCP servers in VS Code's JSON format:
|
|
119
|
+
|
|
120
|
+
See the [MCP configuration reference](../docs/cli.html#mcp) for HTTP/SSE and stdio examples,
|
|
121
|
+
all supported fields, authentication, variable expansion and validation commands.
|
|
122
|
+
Remote servers must explicitly set `"type": "http"` or `"type": "sse"`; omitting it defaults to stdio.
|
|
123
|
+
|
|
124
|
+
```json
|
|
125
|
+
{
|
|
126
|
+
"servers": {
|
|
127
|
+
"docs": {
|
|
128
|
+
"type": "stdio",
|
|
129
|
+
"command": "node",
|
|
130
|
+
"args": ["${workspaceFolder}/tools/docs-server.js"],
|
|
131
|
+
"tools": ["search"]
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The LLM can call these tools during `?`, `think`, and tool-backed calls. Stdio servers support `command`,
|
|
138
|
+
`args`, `env`, and `cwd`; `http`/`sse` servers use `url` and optional `headers`. All support optional `tools`
|
|
139
|
+
(`["*"]` or omitted means all, `[]` disables the server) and `timeout` in milliseconds.
|
|
140
|
+
`${workspaceFolder}` expands to `--cwd`; `${env:NAME}` expands environment variables. Server working
|
|
141
|
+
directories default to `--cwd`; relative server `cwd` values resolve against it. The config filename is
|
|
142
|
+
relative to the invoking directory. `--check` also validates this config without starting servers.
|
|
143
|
+
Interactive `inputs`, unsupported fields/variables, JSON comments and trailing commas are rejected.
|
|
144
|
+
|
|
145
|
+
Only configured, scope-allowed MCP tools are exposed and approved. Trust your MCP servers: `restrict mcp`
|
|
146
|
+
controls tool access, not a server's internal filesystem/network operations. Other restriction kinds do
|
|
147
|
+
not sandbox MCP server processes or their configured connections.
|
|
108
148
|
|
|
109
149
|
## Reproducibility
|
|
110
150
|
|
|
@@ -125,6 +165,7 @@ syntax is checked by `tests/examples.test.ts`; `triage-issues` is also run on Co
|
|
|
125
165
|
| Script | Shows |
|
|
126
166
|
|--------|-------|
|
|
127
167
|
| `commit-message.spl` | `shell` under `restrict exec 'git'`, validation helper, retry loop |
|
|
168
|
+
| `mcp.spl` | MCP search via `--mcp`, combined filesystem/exec/MCP restrictions |
|
|
128
169
|
| `triage-issues.spl` | `fmt='object'` + `assert` validation with feedback retries, aggregation, summary |
|
|
129
170
|
| `extract-invoice.spl` | structured extraction cross-checked with arithmetic before it is accepted |
|
|
130
171
|
| `code-review.spl` | `think` then structure, `restrict` on read and on write, `fail` as a CI gate |
|
package/dist/ast.d.ts
CHANGED
|
@@ -83,7 +83,7 @@ export interface ThinkMods {
|
|
|
83
83
|
show?: boolean;
|
|
84
84
|
}
|
|
85
85
|
export type ToolName = 'read_file' | 'write_file' | 'web_fetch' | 'web_search' | 'shell';
|
|
86
|
-
export type RestrictKind = 'fs' | 'net' | 'exec';
|
|
86
|
+
export type RestrictKind = 'fs' | 'net' | 'exec' | 'mcp';
|
|
87
87
|
export type AssignTarget = {
|
|
88
88
|
t: 'name';
|
|
89
89
|
name: string;
|
|
@@ -162,8 +162,10 @@ export type Stmt = Base & ({
|
|
|
162
162
|
body: Stmt[];
|
|
163
163
|
} | {
|
|
164
164
|
t: 'restrict';
|
|
165
|
-
|
|
166
|
-
|
|
165
|
+
clauses: {
|
|
166
|
+
kind: RestrictKind;
|
|
167
|
+
arg: Expr;
|
|
168
|
+
}[];
|
|
167
169
|
body: Stmt[];
|
|
168
170
|
});
|
|
169
171
|
export interface Directives {
|
package/dist/cli.js
CHANGED
|
@@ -10,6 +10,7 @@ import { parse } from './parser.js';
|
|
|
10
10
|
import { run } from './interpreter.js';
|
|
11
11
|
import { JsonFileStore, MemoryStore, loadStoreModule } from './store.js';
|
|
12
12
|
import { toJson } from './values.js';
|
|
13
|
+
import { loadMcpConfig } from './mcp.js';
|
|
13
14
|
const HELP = `spl - Semantic Prompt Language interpreter
|
|
14
15
|
|
|
15
16
|
Usage: spl <script.spl> [options]
|
|
@@ -18,13 +19,15 @@ Usage: spl <script.spl> [options]
|
|
|
18
19
|
|
|
19
20
|
Options:
|
|
20
21
|
--log-level <level> error | info | debug (default: info). Logs go to stderr.
|
|
22
|
+
All levels include LLM transcripts and [deterministic] statement traces.
|
|
21
23
|
--silent suppress all logs; only the result is printed
|
|
22
24
|
--model <id> model override (takes precedence over @{model=...})
|
|
25
|
+
--mcp <file.json> MCP servers in VS Code mcp.json format
|
|
23
26
|
--store <file.json> persist top-level variables in a JSON file
|
|
24
27
|
--store-module <path> custom variable store: module exporting createStore() -> { load(), save(vars) }
|
|
25
28
|
--timeout <seconds> per LLM call timeout (default: 300)
|
|
26
29
|
--cwd <dir> base directory for relative paths and restrictions (default: current directory)
|
|
27
|
-
--check
|
|
30
|
+
--check validate script syntax and any --mcp config without executing
|
|
28
31
|
-v, --version print version
|
|
29
32
|
-h, --help print this help
|
|
30
33
|
|
|
@@ -72,6 +75,7 @@ async function main() {
|
|
|
72
75
|
'log-level': { type: 'string' },
|
|
73
76
|
silent: { type: 'boolean' },
|
|
74
77
|
model: { type: 'string' },
|
|
78
|
+
mcp: { type: 'string' },
|
|
75
79
|
store: { type: 'string' },
|
|
76
80
|
'store-module': { type: 'string' },
|
|
77
81
|
timeout: { type: 'string' },
|
|
@@ -130,6 +134,13 @@ async function main() {
|
|
|
130
134
|
}
|
|
131
135
|
throw e;
|
|
132
136
|
}
|
|
137
|
+
let mcpServers;
|
|
138
|
+
try {
|
|
139
|
+
mcpServers = values.mcp ? loadMcpConfig(values.mcp, cwd) : undefined;
|
|
140
|
+
}
|
|
141
|
+
catch (e) {
|
|
142
|
+
return usageError(e.message);
|
|
143
|
+
}
|
|
133
144
|
if (values.check)
|
|
134
145
|
return 0;
|
|
135
146
|
let store;
|
|
@@ -139,7 +150,7 @@ async function main() {
|
|
|
139
150
|
catch (e) {
|
|
140
151
|
return usageError(e.message);
|
|
141
152
|
}
|
|
142
|
-
const backend = new CopilotBackend({ logger, cwd, timeoutMs: timeoutSec * 1000 });
|
|
153
|
+
const backend = new CopilotBackend({ logger, cwd, timeoutMs: timeoutSec * 1000, mcpServers });
|
|
143
154
|
try {
|
|
144
155
|
const result = await run(program, { backend, logger, store, cwd, model: values.model });
|
|
145
156
|
if (result.status === 'failure') {
|
package/dist/copilot.d.ts
CHANGED
|
@@ -3,10 +3,12 @@ import { Failure } from './errors.js';
|
|
|
3
3
|
import { type LlmBackend, type LlmRequest, type LlmResponse } from './llm.js';
|
|
4
4
|
import type { Logger } from './logger.js';
|
|
5
5
|
import type { Restrictions } from './restrict.js';
|
|
6
|
+
import { type McpServers } from './mcp.js';
|
|
6
7
|
export interface CopilotBackendOptions {
|
|
7
8
|
logger: Logger;
|
|
8
9
|
cwd: string;
|
|
9
10
|
timeoutMs: number;
|
|
11
|
+
mcpServers?: McpServers;
|
|
10
12
|
}
|
|
11
13
|
export declare class CopilotBackend implements LlmBackend {
|
|
12
14
|
private readonly opts;
|
|
@@ -23,7 +25,7 @@ export declare class CopilotBackend implements LlmBackend {
|
|
|
23
25
|
close(): Promise<void>;
|
|
24
26
|
}
|
|
25
27
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
+
* Only runtime tools and explicitly configured MCP tools are approved.
|
|
29
|
+
* Requests that violate a restriction are additionally reported as violations.
|
|
28
30
|
*/
|
|
29
|
-
export declare function decidePermission(r: PermissionRequest, restrictions: Restrictions, block: (f: Failure) => void): PermissionRequestResult;
|
|
31
|
+
export declare function decidePermission(r: PermissionRequest, restrictions: Restrictions, block: (f: Failure) => void, mcpServers?: McpServers): PermissionRequestResult;
|
package/dist/copilot.js
CHANGED
|
@@ -3,10 +3,11 @@ import { loadToken } from './auth.js';
|
|
|
3
3
|
import { Failure } from './errors.js';
|
|
4
4
|
import { LlmError } from './llm.js';
|
|
5
5
|
import { TOOL_SPECS } from './tools.js';
|
|
6
|
+
import { configuredMcpTool, filterMcpServers } from './mcp.js';
|
|
6
7
|
const SYSTEM_MESSAGE = [
|
|
7
8
|
'You are the execution engine of an automated script. Your reply is stored in a variable and used by the program.',
|
|
8
9
|
'Reply with exactly what was asked for: no preamble, no sign-off, no commentary.',
|
|
9
|
-
'Use the provided tools
|
|
10
|
+
'Use the provided runtime tools and configured MCP tools only when the task needs them.',
|
|
10
11
|
].join(' ');
|
|
11
12
|
const TOOL_NAMES = Object.keys(TOOL_SPECS);
|
|
12
13
|
export class CopilotBackend {
|
|
@@ -100,13 +101,15 @@ export class CopilotBackend {
|
|
|
100
101
|
},
|
|
101
102
|
});
|
|
102
103
|
});
|
|
104
|
+
const mcpServers = filterMcpServers(this.opts.mcpServers ?? {}, req.restrictions);
|
|
103
105
|
const baseConfig = {
|
|
104
106
|
model: req.model,
|
|
105
107
|
tools,
|
|
106
|
-
availableTools: TOOL_NAMES,
|
|
108
|
+
availableTools: [...TOOL_NAMES, ...Object.keys(mcpServers).map((name) => `mcp:${name}-*`)],
|
|
109
|
+
mcpServers,
|
|
107
110
|
systemMessage: { mode: 'append', content: SYSTEM_MESSAGE },
|
|
108
111
|
workingDirectory: this.opts.cwd,
|
|
109
|
-
onPermissionRequest: (r) => decidePermission(r, req.restrictions, block),
|
|
112
|
+
onPermissionRequest: (r) => decidePermission(r, req.restrictions, block, mcpServers),
|
|
110
113
|
};
|
|
111
114
|
try {
|
|
112
115
|
try {
|
|
@@ -160,11 +163,17 @@ export class CopilotBackend {
|
|
|
160
163
|
}
|
|
161
164
|
}
|
|
162
165
|
/**
|
|
163
|
-
*
|
|
164
|
-
*
|
|
166
|
+
* Only runtime tools and explicitly configured MCP tools are approved.
|
|
167
|
+
* Requests that violate a restriction are additionally reported as violations.
|
|
165
168
|
*/
|
|
166
|
-
export function decidePermission(r, restrictions, block) {
|
|
169
|
+
export function decidePermission(r, restrictions, block, mcpServers = {}) {
|
|
167
170
|
try {
|
|
171
|
+
if (r.kind === 'mcp') {
|
|
172
|
+
restrictions.checkMcp(r.serverName, r.toolName);
|
|
173
|
+
if (configuredMcpTool(mcpServers, r.serverName, r.toolName))
|
|
174
|
+
return { kind: 'approve-once' };
|
|
175
|
+
return { kind: 'reject', feedback: 'MCP server/tool is not enabled by --mcp configuration.' };
|
|
176
|
+
}
|
|
168
177
|
if (r.kind === 'custom-tool' && TOOL_NAMES.includes(r.toolName)) {
|
|
169
178
|
return { kind: 'approve-once' }; // the tool itself performs the restriction checks
|
|
170
179
|
}
|
|
@@ -188,5 +197,5 @@ export function decidePermission(r, restrictions, block) {
|
|
|
188
197
|
return { kind: 'reject', feedback: JSON.stringify(e.payload()) };
|
|
189
198
|
}
|
|
190
199
|
}
|
|
191
|
-
return { kind: 'reject', feedback: 'Only
|
|
200
|
+
return { kind: 'reject', feedback: 'Only runtime tools and configured MCP tools may be used.' };
|
|
192
201
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export { run, type RunOptions, type RunResult } from './interpreter.js';
|
|
|
3
3
|
export { Failure, SplSyntaxError, type FailurePayload, type StatusCode } from './errors.js';
|
|
4
4
|
export { Logger, type LogLevel } from './logger.js';
|
|
5
5
|
export { CopilotBackend } from './copilot.js';
|
|
6
|
+
export { loadMcpConfig, type McpServers } from './mcp.js';
|
|
6
7
|
export { login, logout, authStatus, AuthError } from './auth.js';
|
|
7
8
|
export { JsonFileStore, MemoryStore, loadStoreModule, type VariableStore } from './store.js';
|
|
8
9
|
export type { LlmBackend, LlmRequest, LlmResponse } from './llm.js';
|
package/dist/index.js
CHANGED
|
@@ -3,5 +3,6 @@ export { run } from './interpreter.js';
|
|
|
3
3
|
export { Failure, SplSyntaxError } from './errors.js';
|
|
4
4
|
export { Logger } from './logger.js';
|
|
5
5
|
export { CopilotBackend } from './copilot.js';
|
|
6
|
+
export { loadMcpConfig } from './mcp.js';
|
|
6
7
|
export { login, logout, authStatus, AuthError } from './auth.js';
|
|
7
8
|
export { JsonFileStore, MemoryStore, loadStoreModule } from './store.js';
|
package/dist/interpreter.js
CHANGED
|
@@ -86,6 +86,7 @@ class Interpreter {
|
|
|
86
86
|
}
|
|
87
87
|
async execStmt(s, env) {
|
|
88
88
|
try {
|
|
89
|
+
this.opts.logger.deterministic(`line ${s.line} (${s.t}): ${this.program.lines[s.line - 1]?.trim() ?? ''}`);
|
|
89
90
|
return await this.execStmtInner(s, env);
|
|
90
91
|
}
|
|
91
92
|
catch (e) {
|
|
@@ -189,11 +190,13 @@ class Interpreter {
|
|
|
189
190
|
}
|
|
190
191
|
}
|
|
191
192
|
case 'restrict': {
|
|
192
|
-
const restriction = makeRestriction(s.kind, await this.eval(s.arg, env), this.cwd);
|
|
193
193
|
const saved = this.restrictions;
|
|
194
|
-
this.restrictions = saved.with(restriction);
|
|
195
|
-
this.opts.logger.debug(`restrict ${s.kind}: ${restriction.values.join(', ')}`);
|
|
196
194
|
try {
|
|
195
|
+
for (const clause of s.clauses) {
|
|
196
|
+
const restriction = makeRestriction(clause.kind, await this.eval(clause.arg, env), this.cwd);
|
|
197
|
+
this.restrictions = this.restrictions.with(restriction);
|
|
198
|
+
this.opts.logger.debug(`restrict ${clause.kind}: ${restriction.values.join(', ')}`);
|
|
199
|
+
}
|
|
197
200
|
return await this.execBlock(s.body, env);
|
|
198
201
|
}
|
|
199
202
|
finally {
|
|
@@ -499,12 +502,12 @@ class Interpreter {
|
|
|
499
502
|
tools: new ToolHost(this.restrictions, this.cwd),
|
|
500
503
|
pinned: c.pinned,
|
|
501
504
|
};
|
|
502
|
-
logger.
|
|
505
|
+
logger.llm(`llm request (${c.kind}):\n${req.prompt}`);
|
|
503
506
|
const started = Date.now();
|
|
504
507
|
try {
|
|
505
508
|
const res = await backend.call(req);
|
|
506
509
|
logger.info(`llm ${c.kind} call: ${JSON.stringify(res.effective)} (${Date.now() - started} ms)`);
|
|
507
|
-
logger.
|
|
510
|
+
logger.llm(`llm response:\n${res.text}`);
|
|
508
511
|
return res.text.trim();
|
|
509
512
|
}
|
|
510
513
|
catch (e) {
|
package/dist/logger.d.ts
CHANGED
|
@@ -9,4 +9,7 @@ export declare class Logger {
|
|
|
9
9
|
warn(msg: string): void;
|
|
10
10
|
info(msg: string): void;
|
|
11
11
|
debug(msg: string): void;
|
|
12
|
+
/** LLM transcripts are visible at every non-silent level, without labeling them as errors. */
|
|
13
|
+
llm(msg: string): void;
|
|
14
|
+
deterministic(msg: string): void;
|
|
12
15
|
}
|
package/dist/logger.js
CHANGED
|
@@ -24,4 +24,11 @@ export class Logger {
|
|
|
24
24
|
debug(msg) {
|
|
25
25
|
this.emit('debug', 'debug', msg);
|
|
26
26
|
}
|
|
27
|
+
/** LLM transcripts are visible at every non-silent level, without labeling them as errors. */
|
|
28
|
+
llm(msg) {
|
|
29
|
+
this.emit('error', 'llm', msg);
|
|
30
|
+
}
|
|
31
|
+
deterministic(msg) {
|
|
32
|
+
this.emit('error', 'deterministic', msg);
|
|
33
|
+
}
|
|
27
34
|
}
|
package/dist/mcp.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { MCPServerConfig } from '@github/copilot-sdk';
|
|
2
|
+
import type { Restrictions } from './restrict.js';
|
|
3
|
+
export type McpServers = Record<string, MCPServerConfig>;
|
|
4
|
+
/** Loads the supported VS Code mcp.json subset; never executes commands or connects to servers. */
|
|
5
|
+
export declare function loadMcpConfig(file: string, cwd: string): McpServers;
|
|
6
|
+
export declare function filterMcpServers(servers: McpServers, restrictions: Restrictions): McpServers;
|
|
7
|
+
export declare function configuredMcpTool(servers: McpServers, server: string, tool: string): boolean;
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
const object = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
4
|
+
/** Loads the supported VS Code mcp.json subset; never executes commands or connects to servers. */
|
|
5
|
+
export function loadMcpConfig(file, cwd) {
|
|
6
|
+
const fail = (message) => { throw new Error(`MCP config '${file}': ${message}`); };
|
|
7
|
+
let config;
|
|
8
|
+
try {
|
|
9
|
+
config = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
10
|
+
}
|
|
11
|
+
catch (e) {
|
|
12
|
+
return fail(`cannot read JSON: ${e.message}`);
|
|
13
|
+
}
|
|
14
|
+
if (!object(config) || !object(config.servers))
|
|
15
|
+
return fail("expected an object with a 'servers' object");
|
|
16
|
+
if (config.inputs !== undefined)
|
|
17
|
+
return fail("'inputs' are not supported; use environment variables instead");
|
|
18
|
+
const expand = (value) => value.replace(/\$\{([^}]+)\}/g, (_, key) => {
|
|
19
|
+
if (key === 'workspaceFolder')
|
|
20
|
+
return cwd;
|
|
21
|
+
if (key.startsWith('env:')) {
|
|
22
|
+
const name = key.slice(4);
|
|
23
|
+
const value = process.env[name];
|
|
24
|
+
if (value !== undefined)
|
|
25
|
+
return value;
|
|
26
|
+
return fail(`environment variable '${name}' is not set`);
|
|
27
|
+
}
|
|
28
|
+
return fail(`unsupported variable '\${${key}}'`);
|
|
29
|
+
});
|
|
30
|
+
const strings = (value, field) => {
|
|
31
|
+
if (value === undefined)
|
|
32
|
+
return undefined;
|
|
33
|
+
if (!object(value))
|
|
34
|
+
return fail(`${field} must be an object of strings`);
|
|
35
|
+
const entries = Object.entries(value).map(([key, val]) => {
|
|
36
|
+
if (typeof val !== 'string')
|
|
37
|
+
return fail(`${field}.${key} must be a string`);
|
|
38
|
+
return [key, expand(val)];
|
|
39
|
+
});
|
|
40
|
+
return Object.fromEntries(entries);
|
|
41
|
+
};
|
|
42
|
+
const servers = Object.create(null);
|
|
43
|
+
for (const [name, raw] of Object.entries(config.servers)) {
|
|
44
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(name))
|
|
45
|
+
return fail(`invalid server name '${name}'`);
|
|
46
|
+
if (!object(raw))
|
|
47
|
+
return fail(`server '${name}' must be an object`);
|
|
48
|
+
const type = raw.type ?? 'stdio';
|
|
49
|
+
const local = type === 'stdio';
|
|
50
|
+
if (!local && type !== 'http' && type !== 'sse')
|
|
51
|
+
return fail(`server '${name}' has unsupported type '${String(type)}'`);
|
|
52
|
+
const fields = local ? ['type', 'command', 'args', 'env', 'cwd', 'tools', 'timeout'] : ['type', 'url', 'headers', 'tools', 'timeout'];
|
|
53
|
+
for (const field of Object.keys(raw)) {
|
|
54
|
+
if (!fields.includes(field))
|
|
55
|
+
return fail(`server '${name}' has unsupported field '${field}'`);
|
|
56
|
+
}
|
|
57
|
+
if (raw.tools !== undefined && (!Array.isArray(raw.tools) || raw.tools.some((t) => typeof t !== 'string' || !/^(?:\*|[A-Za-z0-9_.-]+)$/.test(t)))) {
|
|
58
|
+
return fail(`server '${name}': tools must be an array of tool names or '*'`);
|
|
59
|
+
}
|
|
60
|
+
if (raw.timeout !== undefined && (typeof raw.timeout !== 'number' || !Number.isFinite(raw.timeout) || raw.timeout <= 0)) {
|
|
61
|
+
return fail(`server '${name}': timeout must be a positive number of milliseconds`);
|
|
62
|
+
}
|
|
63
|
+
const common = { tools: raw.tools, timeout: raw.timeout };
|
|
64
|
+
if (local) {
|
|
65
|
+
if (typeof raw.command !== 'string' || raw.command.trim() === '')
|
|
66
|
+
return fail(`server '${name}': command must be a non-empty string`);
|
|
67
|
+
if (raw.args !== undefined && (!Array.isArray(raw.args) || raw.args.some((v) => typeof v !== 'string'))) {
|
|
68
|
+
return fail(`server '${name}': args must be an array of strings`);
|
|
69
|
+
}
|
|
70
|
+
if (raw.cwd !== undefined && typeof raw.cwd !== 'string')
|
|
71
|
+
return fail(`server '${name}': cwd must be a string`);
|
|
72
|
+
servers[name] = {
|
|
73
|
+
...common, type: 'stdio', command: expand(raw.command),
|
|
74
|
+
args: raw.args?.map(expand),
|
|
75
|
+
env: strings(raw.env, `${name}.env`),
|
|
76
|
+
workingDirectory: raw.cwd === undefined ? cwd : path.resolve(cwd, expand(raw.cwd)),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
if (typeof raw.url !== 'string')
|
|
81
|
+
return fail(`server '${name}': url must be a string`);
|
|
82
|
+
const url = expand(raw.url);
|
|
83
|
+
let parsed;
|
|
84
|
+
try {
|
|
85
|
+
parsed = new URL(url);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return fail(`server '${name}': invalid URL`);
|
|
89
|
+
}
|
|
90
|
+
if (!['http:', 'https:'].includes(parsed.protocol))
|
|
91
|
+
return fail(`server '${name}': URL must use http or https`);
|
|
92
|
+
servers[name] = { ...common, type, url, headers: strings(raw.headers, `${name}.headers`) };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return servers;
|
|
96
|
+
}
|
|
97
|
+
export function filterMcpServers(servers, restrictions) {
|
|
98
|
+
return Object.fromEntries(Object.entries(servers).flatMap(([name, config]) => {
|
|
99
|
+
const tools = restrictions.mcpTools(name, config.tools);
|
|
100
|
+
return tools.length ? [[name, { ...config, tools }]] : [];
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
export function configuredMcpTool(servers, server, tool) {
|
|
104
|
+
if (!Object.hasOwn(servers, server))
|
|
105
|
+
return false;
|
|
106
|
+
const tools = servers[server].tools;
|
|
107
|
+
return tools === undefined || tools.includes('*') || tools.includes(tool);
|
|
108
|
+
}
|
package/dist/parser.js
CHANGED
|
@@ -355,14 +355,21 @@ class Parser {
|
|
|
355
355
|
parseRestrict() {
|
|
356
356
|
const line = this.tok.line;
|
|
357
357
|
this.next();
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
358
|
+
const clauses = [];
|
|
359
|
+
for (;;) {
|
|
360
|
+
let kind = 'fs';
|
|
361
|
+
const t = this.tok;
|
|
362
|
+
if (t.type === 'name' && (t.value === 'fs' || t.value === 'net' || t.value === 'exec' || t.value === 'mcp')) {
|
|
363
|
+
kind = t.value;
|
|
364
|
+
this.next();
|
|
365
|
+
}
|
|
366
|
+
clauses.push({ kind, arg: this.parseExpr() });
|
|
367
|
+
if (!this.isOp(','))
|
|
368
|
+
break;
|
|
362
369
|
this.next();
|
|
370
|
+
this.skipNl();
|
|
363
371
|
}
|
|
364
|
-
|
|
365
|
-
return { t: 'restrict', line, kind, arg, body: this.parseBlock() };
|
|
372
|
+
return { t: 'restrict', line, clauses, body: this.parseBlock() };
|
|
366
373
|
}
|
|
367
374
|
parseSimpleStatement() {
|
|
368
375
|
const line = this.tok.line;
|
package/dist/restrict.d.ts
CHANGED
|
@@ -20,6 +20,9 @@ export declare class Restrictions {
|
|
|
20
20
|
checkUrl(url: string): URL;
|
|
21
21
|
checkHost(host: string): void;
|
|
22
22
|
checkExec(name: string): void;
|
|
23
|
+
checkMcp(server: string, tool: string): void;
|
|
24
|
+
/** Narrows configured tools before the SDK starts servers or advertises their tools. */
|
|
25
|
+
mcpTools(server: string, configured?: readonly string[]): string[];
|
|
23
26
|
/** Human-readable guidance for the LLM (the first enforcement layer). */
|
|
24
27
|
guidance(): string[];
|
|
25
28
|
}
|
package/dist/restrict.js
CHANGED
|
@@ -127,6 +127,24 @@ export class Restrictions {
|
|
|
127
127
|
}
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
|
+
checkMcp(server, tool) {
|
|
131
|
+
for (const r of this.list) {
|
|
132
|
+
if (r.kind === 'mcp' && !r.values.some((v) => v === server || v === `${server}/*` || v === `${server}/${tool}`)) {
|
|
133
|
+
throw violation(`MCP tool '${server}/${tool}' blocked: allowed MCP servers/tools are ${r.values.join(', ')}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** Narrows configured tools before the SDK starts servers or advertises their tools. */
|
|
138
|
+
mcpTools(server, configured = ['*']) {
|
|
139
|
+
let tools = [...configured];
|
|
140
|
+
for (const r of this.list) {
|
|
141
|
+
if (r.kind !== 'mcp' || r.values.includes(server) || r.values.includes(`${server}/*`))
|
|
142
|
+
continue;
|
|
143
|
+
const allowed = r.values.filter((v) => v.startsWith(`${server}/`)).map((v) => v.slice(server.length + 1));
|
|
144
|
+
tools = tools.includes('*') ? allowed : tools.filter((t) => allowed.includes(t));
|
|
145
|
+
}
|
|
146
|
+
return [...new Set(tools)];
|
|
147
|
+
}
|
|
130
148
|
/** Human-readable guidance for the LLM (the first enforcement layer). */
|
|
131
149
|
guidance() {
|
|
132
150
|
return this.list.map((r) => {
|
|
@@ -134,7 +152,9 @@ export class Restrictions {
|
|
|
134
152
|
return `File system: you may read and write only inside: ${r.values.join(', ')}. Any other path will be blocked.`;
|
|
135
153
|
if (r.kind === 'net')
|
|
136
154
|
return `Network: you may only access these hosts: ${r.values.join(', ')}. Any other host will be blocked.`;
|
|
137
|
-
|
|
155
|
+
if (r.kind === 'mcp')
|
|
156
|
+
return `MCP: you may only call these servers/tools: ${r.values.join(', ')}. Anything else will be blocked.`;
|
|
157
|
+
return `Shell: you may only run these executables: ${r.values.join(', ')}. Anything else will be blocked.`;
|
|
138
158
|
});
|
|
139
159
|
}
|
|
140
160
|
}
|
|
@@ -154,5 +174,11 @@ export function makeRestriction(kind, raw, cwd) {
|
|
|
154
174
|
return { kind, values: [...new Set(items.map((i) => canonicalPath(i, cwd)))] };
|
|
155
175
|
if (kind === 'net')
|
|
156
176
|
return { kind, values: [...new Set(items.map(normalizeHost))] };
|
|
177
|
+
if (kind === 'mcp') {
|
|
178
|
+
if (items.some((v) => !/^[A-Za-z0-9_.-]+(?:\/(?:[A-Za-z0-9_.-]+|\*))?$/.test(v))) {
|
|
179
|
+
throw new Failure('runtime_error', 'restrict mcp expects server names or server/tool selectors');
|
|
180
|
+
}
|
|
181
|
+
return { kind, values: [...new Set(items)] };
|
|
182
|
+
}
|
|
157
183
|
return { kind, values: [...new Set(items.map(normalizeExec))] };
|
|
158
184
|
}
|
package/dist/tools.d.ts
CHANGED
|
@@ -12,6 +12,10 @@ export interface ToolSpec {
|
|
|
12
12
|
export declare const TOOL_SPECS: Record<ToolName, ToolSpec>;
|
|
13
13
|
/** Splits a simple command line, honoring '...' and "..." quoting. No escapes, no expansion. */
|
|
14
14
|
export declare function splitCommand(command: string): string[];
|
|
15
|
+
/** Splits a command string by chain/pipeline separators (&&, ||, |, ;, &, \n) outside quotes. */
|
|
16
|
+
export declare function splitChainedCommands(command: string): string[];
|
|
17
|
+
/** Finds nested $(...) or `...` command substitutions outside single quotes. */
|
|
18
|
+
export declare function extractSubstitutions(command: string): string[];
|
|
15
19
|
export interface SearchResult {
|
|
16
20
|
title: string;
|
|
17
21
|
url: string;
|
|
@@ -34,5 +38,7 @@ export declare class ToolHost {
|
|
|
34
38
|
/** Searches DuckDuckGo's HTML endpoint (no API key); it is a network call, so `restrict net` applies to it. */
|
|
35
39
|
private webSearch;
|
|
36
40
|
private shell;
|
|
41
|
+
private checkCommandRestrictions;
|
|
42
|
+
private checkSubcommand;
|
|
37
43
|
private run;
|
|
38
44
|
}
|
package/dist/tools.js
CHANGED
|
@@ -6,8 +6,6 @@ const MAX_TEXT = 200_000;
|
|
|
6
6
|
const SHELL_TIMEOUT_MS = 120_000;
|
|
7
7
|
const FETCH_TIMEOUT_MS = 30_000;
|
|
8
8
|
const MAX_REDIRECTS = 5;
|
|
9
|
-
/** Characters that let a command chain, redirect or substitute; rejected while any restriction is active. */
|
|
10
|
-
const SHELL_META = /[&|;<>`$()\r\n^%!]/;
|
|
11
9
|
export const TOOL_SPECS = {
|
|
12
10
|
read_file: {
|
|
13
11
|
name: 'read_file',
|
|
@@ -86,6 +84,127 @@ export function splitCommand(command) {
|
|
|
86
84
|
args.push(cur);
|
|
87
85
|
return args;
|
|
88
86
|
}
|
|
87
|
+
function findMatchingParen(str, openIndex) {
|
|
88
|
+
let depth = 0;
|
|
89
|
+
for (let i = openIndex; i < str.length; i++) {
|
|
90
|
+
if (str[i] === '(')
|
|
91
|
+
depth++;
|
|
92
|
+
else if (str[i] === ')') {
|
|
93
|
+
depth--;
|
|
94
|
+
if (depth === 0)
|
|
95
|
+
return i;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return -1;
|
|
99
|
+
}
|
|
100
|
+
/** Splits a command string by chain/pipeline separators (&&, ||, |, ;, &, \n) outside quotes. */
|
|
101
|
+
export function splitChainedCommands(command) {
|
|
102
|
+
const subcmds = [];
|
|
103
|
+
let cur = '';
|
|
104
|
+
let quote;
|
|
105
|
+
for (let i = 0; i < command.length; i++) {
|
|
106
|
+
const ch = command[i];
|
|
107
|
+
if (quote) {
|
|
108
|
+
if (ch === quote)
|
|
109
|
+
quote = undefined;
|
|
110
|
+
cur += ch;
|
|
111
|
+
}
|
|
112
|
+
else if (ch === '"' || ch === "'") {
|
|
113
|
+
quote = ch;
|
|
114
|
+
cur += ch;
|
|
115
|
+
}
|
|
116
|
+
else if (ch === '\n' || ch === ';') {
|
|
117
|
+
if (cur.trim())
|
|
118
|
+
subcmds.push(cur.trim());
|
|
119
|
+
cur = '';
|
|
120
|
+
}
|
|
121
|
+
else if (ch === '&' && command[i + 1] === '&') {
|
|
122
|
+
if (cur.trim())
|
|
123
|
+
subcmds.push(cur.trim());
|
|
124
|
+
cur = '';
|
|
125
|
+
i++;
|
|
126
|
+
}
|
|
127
|
+
else if (ch === '|' && command[i + 1] === '|') {
|
|
128
|
+
if (cur.trim())
|
|
129
|
+
subcmds.push(cur.trim());
|
|
130
|
+
cur = '';
|
|
131
|
+
i++;
|
|
132
|
+
}
|
|
133
|
+
else if (ch === '|') {
|
|
134
|
+
if (cur.trim())
|
|
135
|
+
subcmds.push(cur.trim());
|
|
136
|
+
cur = '';
|
|
137
|
+
}
|
|
138
|
+
else if (ch === '&') {
|
|
139
|
+
const prevNonSpace = cur.trimEnd().slice(-1);
|
|
140
|
+
if (prevNonSpace === '>') {
|
|
141
|
+
cur += ch;
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
if (cur.trim())
|
|
145
|
+
subcmds.push(cur.trim());
|
|
146
|
+
cur = '';
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
cur += ch;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (quote)
|
|
154
|
+
throw new Failure('restriction_violation', 'shell command rejected: unterminated quote');
|
|
155
|
+
if (cur.trim())
|
|
156
|
+
subcmds.push(cur.trim());
|
|
157
|
+
return subcmds;
|
|
158
|
+
}
|
|
159
|
+
/** Finds nested $(...) or `...` command substitutions outside single quotes. */
|
|
160
|
+
export function extractSubstitutions(command) {
|
|
161
|
+
const subs = [];
|
|
162
|
+
let quote;
|
|
163
|
+
for (let i = 0; i < command.length; i++) {
|
|
164
|
+
const ch = command[i];
|
|
165
|
+
if (quote === "'") {
|
|
166
|
+
if (ch === "'")
|
|
167
|
+
quote = undefined;
|
|
168
|
+
}
|
|
169
|
+
else if (quote === '"') {
|
|
170
|
+
if (ch === '"') {
|
|
171
|
+
quote = undefined;
|
|
172
|
+
}
|
|
173
|
+
else if (ch === '`') {
|
|
174
|
+
const end = command.indexOf('`', i + 1);
|
|
175
|
+
if (end !== -1) {
|
|
176
|
+
subs.push(command.slice(i + 1, end));
|
|
177
|
+
i = end;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
else if (ch === '$' && command[i + 1] === '(') {
|
|
181
|
+
const end = findMatchingParen(command, i + 1);
|
|
182
|
+
if (end !== -1) {
|
|
183
|
+
subs.push(command.slice(i + 2, end));
|
|
184
|
+
i = end;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
else if (ch === "'" || ch === '"') {
|
|
189
|
+
quote = ch;
|
|
190
|
+
}
|
|
191
|
+
else if (ch === '`') {
|
|
192
|
+
const end = command.indexOf('`', i + 1);
|
|
193
|
+
if (end !== -1) {
|
|
194
|
+
subs.push(command.slice(i + 1, end));
|
|
195
|
+
i = end;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
else if (ch === '$' && command[i + 1] === '(') {
|
|
199
|
+
const end = findMatchingParen(command, i + 1);
|
|
200
|
+
if (end !== -1) {
|
|
201
|
+
subs.push(command.slice(i + 2, end));
|
|
202
|
+
i = end;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return subs;
|
|
207
|
+
}
|
|
89
208
|
const SEARCH_ENDPOINT = 'https://html.duckduckgo.com/html/';
|
|
90
209
|
const MAX_SEARCH_RESULTS = 10;
|
|
91
210
|
const decodeEntities = (s) => s
|
|
@@ -121,6 +240,10 @@ export function parseSearchResults(html) {
|
|
|
121
240
|
}
|
|
122
241
|
const looksLikeUrl = (s) => /^[a-z][a-z0-9+.-]*:\/\//i.test(s);
|
|
123
242
|
const looksLikePath = (s) => /[\\/]/.test(s) || s.startsWith('.') || s.startsWith('~') || /^[a-z]:/i.test(s);
|
|
243
|
+
function isDirSwitch(executable, arg) {
|
|
244
|
+
return process.platform === 'win32' && executable.toLowerCase() === 'dir'
|
|
245
|
+
&& /^\/(?:-?[bcdlnpqrswx4]|[aot](?::[a-z-]+)?)$/i.test(arg);
|
|
246
|
+
}
|
|
124
247
|
/**
|
|
125
248
|
* Executes the built-in tools. Every call is checked against the active restrictions,
|
|
126
249
|
* whether it comes from the script itself or from the LLM.
|
|
@@ -209,17 +332,77 @@ export class ToolHost {
|
|
|
209
332
|
return results.map((r, i) => `${i + 1}. ${r.title}\n ${r.url}${r.snippet ? `\n ${r.snippet}` : ''}`).join('\n');
|
|
210
333
|
}
|
|
211
334
|
async shell(command) {
|
|
212
|
-
if (!
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
335
|
+
if (!command || command.trim() === '')
|
|
336
|
+
throw new Failure('tool_error', 'shell: empty command');
|
|
337
|
+
if (this.restrictions.active) {
|
|
338
|
+
try {
|
|
339
|
+
this.checkCommandRestrictions(command);
|
|
340
|
+
}
|
|
341
|
+
catch (e) {
|
|
342
|
+
if (e instanceof Failure && e.statusCode === 'restriction_violation') {
|
|
343
|
+
throw new Failure('restriction_violation', `${e.reason} (shell command: ${JSON.stringify(command)})`);
|
|
344
|
+
}
|
|
345
|
+
throw e;
|
|
346
|
+
}
|
|
217
347
|
}
|
|
218
|
-
|
|
219
|
-
|
|
348
|
+
return this.run(command, { shell: true });
|
|
349
|
+
}
|
|
350
|
+
checkCommandRestrictions(command) {
|
|
351
|
+
const subcmds = splitChainedCommands(command);
|
|
352
|
+
if (subcmds.length === 0)
|
|
220
353
|
throw new Failure('tool_error', 'shell: empty command');
|
|
221
|
-
|
|
222
|
-
|
|
354
|
+
for (const sub of subcmds) {
|
|
355
|
+
this.checkSubcommand(sub);
|
|
356
|
+
}
|
|
357
|
+
for (const sub of extractSubstitutions(command)) {
|
|
358
|
+
this.checkCommandRestrictions(sub);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
checkSubcommand(subcmd) {
|
|
362
|
+
const argv = splitCommand(subcmd);
|
|
363
|
+
if (argv.length === 0)
|
|
364
|
+
return;
|
|
365
|
+
let execIdx = 0;
|
|
366
|
+
while (execIdx < argv.length && /^[a-zA-Z_][a-zA-Z0-9_]*=/.test(argv[execIdx])) {
|
|
367
|
+
execIdx++;
|
|
368
|
+
}
|
|
369
|
+
if (execIdx >= argv.length)
|
|
370
|
+
return;
|
|
371
|
+
const exe = argv[execIdx].replace(/^[()]+/, '').replace(/[()]+$/, '');
|
|
372
|
+
if (exe)
|
|
373
|
+
this.restrictions.checkExec(exe);
|
|
374
|
+
for (let i = execIdx + 1; i < argv.length; i++) {
|
|
375
|
+
const arg = argv[i];
|
|
376
|
+
if (arg === '>' || arg === '>>' || arg === '1>' || arg === '2>') {
|
|
377
|
+
const target = argv[++i];
|
|
378
|
+
if (target && !target.startsWith('&') && this.restrictions.has('fs')) {
|
|
379
|
+
this.restrictions.checkPath(target, 'write');
|
|
380
|
+
}
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
if (arg === '<' || arg === '0<') {
|
|
384
|
+
const target = argv[++i];
|
|
385
|
+
if (target && this.restrictions.has('fs')) {
|
|
386
|
+
this.restrictions.checkPath(target, 'read');
|
|
387
|
+
}
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
if (/^[0-9]?>+/.test(arg)) {
|
|
391
|
+
const target = arg.replace(/^[0-9]?>+/, '');
|
|
392
|
+
if (target && !target.startsWith('&') && this.restrictions.has('fs')) {
|
|
393
|
+
this.restrictions.checkPath(target, 'write');
|
|
394
|
+
}
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (/^[0-9]?<+/.test(arg)) {
|
|
398
|
+
const target = arg.replace(/^[0-9]?<+/, '');
|
|
399
|
+
if (target && this.restrictions.has('fs')) {
|
|
400
|
+
this.restrictions.checkPath(target, 'read');
|
|
401
|
+
}
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
if (isDirSwitch(exe, arg))
|
|
405
|
+
continue;
|
|
223
406
|
const value = arg.startsWith('-') && arg.includes('=') ? arg.slice(arg.indexOf('=') + 1) : arg;
|
|
224
407
|
if (looksLikeUrl(value)) {
|
|
225
408
|
if (this.restrictions.has('net'))
|
|
@@ -229,14 +412,6 @@ export class ToolHost {
|
|
|
229
412
|
this.restrictions.checkPath(value, 'read');
|
|
230
413
|
}
|
|
231
414
|
}
|
|
232
|
-
if (argv.some((a) => a.includes('"'))) {
|
|
233
|
-
throw new Failure('restriction_violation', 'shell command rejected: embedded double quotes are not allowed inside a restrict block');
|
|
234
|
-
}
|
|
235
|
-
if (process.platform === 'win32') {
|
|
236
|
-
// .cmd shims (npm, ...) need a shell on Windows; metacharacters were already rejected.
|
|
237
|
-
return this.run(argv.map((a) => (/\s/.test(a) || a === '' ? `"${a}"` : a)).join(' '), { shell: true });
|
|
238
|
-
}
|
|
239
|
-
return this.run(argv[0], { shell: false, args: argv.slice(1) });
|
|
240
415
|
}
|
|
241
416
|
run(command, opts) {
|
|
242
417
|
return new Promise((resolve, reject) => {
|
package/docs/syntax.ebnf
CHANGED
|
@@ -35,7 +35,8 @@ fail_stmt = "fail" , expr ;
|
|
|
35
35
|
success_stmt = "success" , [ expr ] ;
|
|
36
36
|
|
|
37
37
|
constraint_stmt = ( "enforce" | "forbid" ) , expr , block ;
|
|
38
|
-
restrict_stmt = "restrict" ,
|
|
38
|
+
restrict_stmt = "restrict" , restrict_clause , { "," , restrict_clause } , block ;
|
|
39
|
+
restrict_clause = [ "fs" | "net" | "exec" | "mcp" ] , expr ; (* default kind: fs; clauses intersect *)
|
|
39
40
|
|
|
40
41
|
(* Expressions, lowest to highest precedence *)
|
|
41
42
|
expr = or_expr , [ "if" , or_expr , "else" , expr ] ;
|