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 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 model and reasoning effort of every LLM call.
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. While any restriction is active, `shell` accepts only simple commands (no
105
- `&|;<>` redirects, substitution or chaining) so the check is exact; path-like and URL arguments are also checked
106
- against `fs`/`net`. This is best effort for programs that touch files or the network on their own.
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
- kind: RestrictKind;
166
- arg: Expr;
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 parse the script and exit
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
- * Hard enforcement at the permission layer. Only the five runtime tools are exposed to the model, so any
27
- * other request is denied; requests that would violate a restriction are additionally reported as violations.
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 (read_file, write_file, web_fetch, web_search, shell) only when the task needs them. No other tools exist.',
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
- * Hard enforcement at the permission layer. Only the five runtime tools are exposed to the model, so any
164
- * other request is denied; requests that would violate a restriction are additionally reported as violations.
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 the read_file, write_file, web_fetch, web_search and shell tools may be used.' };
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';
@@ -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.debug(`llm request (${c.kind}):\n${req.prompt}`);
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.debug(`llm response:\n${res.text}`);
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
- let kind = 'fs';
359
- const t = this.tok;
360
- if (t.type === 'name' && (t.value === 'fs' || t.value === 'net' || t.value === 'exec')) {
361
- kind = t.value;
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
- const arg = this.parseExpr();
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;
@@ -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
- return `Shell: you may only run these executables: ${r.values.join(', ')}, one simple command at a time (no pipes, redirects or chaining). Anything else will be blocked.`;
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 (!this.restrictions.active)
213
- return this.run(command, { shell: true });
214
- // With any restriction active only plain commands are accepted, so the checks below are exact.
215
- if (SHELL_META.test(command)) {
216
- throw new Failure('restriction_violation', 'shell command rejected: chaining, redirection and substitution are not allowed inside a restrict block');
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
- const argv = splitCommand(command);
219
- if (argv.length === 0)
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
- this.restrictions.checkExec(argv[0]);
222
- for (const arg of argv.slice(1)) {
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" , [ "fs" | "net" | "exec" ] , expr , block ; (* default kind: fs *)
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 ] ;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spl-lang",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Semantic Prompt Language: a Python-like language with LLM calls built into the syntax",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",