@sweny-ai/core 0.1.149 → 0.1.151

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.
@@ -37,6 +37,7 @@ import { parseToolResultContent, summarizeToolError, tryParseJSON } from "./pars
37
37
  import { startToolBridge } from "./tool-bridge/server.js";
38
38
  import { defaultSandboxWrapper, prepareAgentSpawn, wrappersFrom, } from "./sandbox-wrapper.js";
39
39
  import { TOKEN_ENV } from "./tool-bridge/protocol.js";
40
+ import { codexAuth } from "./auth.js";
40
41
  export { CODEX_CAPABILITIES };
41
42
  /** Oldest Codex CLI whose flags this adapter uses (`--ignore-user-config`, `--ignore-rules`). */
42
43
  export const MIN_CODEX_VERSION = "0.159.0";
@@ -288,6 +289,7 @@ export class CodexHarness {
288
289
  killGraceMs;
289
290
  preflightResult;
290
291
  version = "unknown";
292
+ authProbe;
291
293
  constructor(opts = {}) {
292
294
  this.model = opts.model;
293
295
  this.maxTurns = opts.maxTurns ?? 20;
@@ -305,12 +307,24 @@ export class CodexHarness {
305
307
  this.codexCommand = opts.codexCommand ?? { command: process.env.SWENY_CODEX_PATH || "codex", args: [] };
306
308
  this.toolBridgeShim = opts.toolBridgeShim;
307
309
  this.killGraceMs = opts.killGraceMs ?? 2000;
310
+ this.authProbe = opts.authProbe ?? (() => codexAuth(process.env));
308
311
  }
309
312
  info() {
310
313
  return { id: this.id, version: this.version };
311
314
  }
315
+ /**
316
+ * Before any node runs (#339): the Codex CLI is installed and new enough,
317
+ * and Codex can authenticate (an API key, an access token, or `codex login`).
318
+ */
319
+ async preflight() {
320
+ const cli = await this.checkCli();
321
+ if (!cli.ok)
322
+ return cli;
323
+ const auth = this.authProbe();
324
+ return auth.ok ? cli : { ok: false, reason: auth.reason };
325
+ }
312
326
  /** `codex --version`, checked against {@link MIN_CODEX_VERSION}. Cached per instance. */
313
- preflight() {
327
+ checkCli() {
314
328
  this.preflightResult ??= new Promise((resolve) => {
315
329
  const { command, args } = this.codexCommand;
316
330
  execFile(command, [...args, "--version"], { timeout: 30_000 }, (err, stdout) => {
@@ -723,7 +737,7 @@ export class CodexHarness {
723
737
  };
724
738
  if (gate.refuse)
725
739
  return refused(gate.refuse);
726
- const pre = await this.preflight();
740
+ const pre = await this.checkCli();
727
741
  if (!pre.ok) {
728
742
  this.logger.error(pre.reason);
729
743
  return tag({ status: "failed", data: { error: pre.reason }, toolCalls: [] });
@@ -922,7 +936,7 @@ export class CodexHarness {
922
936
  : `codex.ask: ${why}. Returning empty string.`);
923
937
  return null;
924
938
  };
925
- const pre = await this.preflight();
939
+ const pre = await this.checkCli();
926
940
  if (!pre.ok)
927
941
  return failClosed(pre.reason);
928
942
  const args = [
@@ -771,15 +771,24 @@ export class PiHarness {
771
771
  tools: req.tools,
772
772
  context: this.defaultContext,
773
773
  logger: this.logger,
774
+ // Wrapped, the shim cannot open the unix socket (srt blocks AF_UNIX on
775
+ // Linux); it tunnels to a loopback port through the sandbox proxy (#439).
776
+ tcp: wrapper !== undefined,
774
777
  ...(this.toolBridgeShim ? { shimCommand: this.toolBridgeShim } : {}),
775
778
  });
776
779
  const shim = bridge.mcpServer;
777
780
  // The token reaches the shim by reference (`${VAR}`), never in a file or argv.
778
781
  env[TOKEN_ENV] = bridge.token;
782
+ const ref = (name) => "${" + name + "}";
779
783
  entries[PI_BRIDGE_SERVER] = {
780
784
  command: shim.command,
781
785
  args: shim.args ?? [],
782
- env: { SWENY_NO_UPDATE_CHECK: "1", [TOKEN_ENV]: "${" + TOKEN_ENV + "}" },
786
+ env: {
787
+ SWENY_NO_UPDATE_CHECK: "1",
788
+ [TOKEN_ENV]: ref(TOKEN_ENV),
789
+ // The proxy srt sets for pi, which pi's MCP client does not pass on by itself.
790
+ ...(bridge.tcp ? { HTTP_PROXY: ref("HTTP_PROXY"), http_proxy: ref("http_proxy") } : {}),
791
+ },
783
792
  exposure: "direct",
784
793
  timeout: 600,
785
794
  };
@@ -802,8 +811,10 @@ export class PiHarness {
802
811
  toolArgs.push("--exclude-tools", [...excluded].join(","));
803
812
  }
804
813
  const args = [...this.baseArgs(model, Object.keys(entries).length > 0), ...toolArgs];
805
- const harnessEgress = piBackendHosts(process.env, model);
806
- if (mode !== "off" && harnessEgress.length === 0 && !this.egressWarned) {
814
+ const backendHosts = piBackendHosts(process.env, model);
815
+ // The bridge's loopback endpoint (wrapped runs only): the one extra host the shim needs.
816
+ const harnessEgress = [...backendHosts, ...(bridge?.egress ?? [])];
817
+ if (mode !== "off" && backendHosts.length === 0 && !this.egressWarned) {
807
818
  this.egressWarned = true;
808
819
  this.logger.warn("pi runs inside the sandbox wrapper with no known model API host; add it with SWENY_SANDBOX_ALLOWED_DOMAINS.");
809
820
  }
@@ -74,6 +74,12 @@ export function policyGate(caps, policy, wrappers = {}) {
74
74
  unenforced.push(`disallowed_tools [${policy.nativeDeny.join(", ")}]: harness has no tools by these names`);
75
75
  }
76
76
  }
77
+ // Exclusive MCP (`permissions.strict`): only the servers sweny passes may
78
+ // load, never the user's own config. A harness that cannot exclude them
79
+ // cannot honor it, so strict refuses the node.
80
+ if (policy.exclusiveMcp && caps.mcp.exclusive === "none") {
81
+ unenforced.push("exclusive MCP: harness cannot keep the user's own MCP servers out of the run");
82
+ }
77
83
  if (policy.egress.length > 0 && !caps.sandbox.network && !wrappers.egress) {
78
84
  unenforced.push("egress allowlist: harness has no network sandbox and no egress wrapper is active");
79
85
  }
@@ -9,6 +9,11 @@
9
9
  * Security properties (this is an injection boundary):
10
10
  * - The socket lives in a fresh `mkdtemp` directory (mode 0700) and is itself
11
11
  * chmod 0600, so other users cannot connect.
12
+ * - With `tcp` (only for agents inside the process sandbox wrapper, #439) the
13
+ * same handler also listens on 127.0.0.1:<random port>. Other local users
14
+ * can open that port, so there the token is the only gate; the sandboxed
15
+ * agent reaches it through srt's proxy only because the adapter allowlists
16
+ * exactly that host:port for the run.
12
17
  * - A random 256-bit token is required on every frame. A wrong or missing
13
18
  * token gets an `unauthorized` error and the connection is dropped.
14
19
  * - Only the tools passed in are listed or callable. The executor has already
@@ -37,12 +42,27 @@ export interface ToolBridgeOptions {
37
42
  };
38
43
  /** Parent directory for the private socket dir. Default: `os.tmpdir()`. */
39
44
  tmpDir?: string;
45
+ /**
46
+ * Also serve on a loopback TCP port, and point the shim there (#439). For an
47
+ * agent inside the process sandbox wrapper: srt blocks `socket(AF_UNIX)` on
48
+ * Linux, so the shim cannot reach the unix socket, but it can reach an
49
+ * allowlisted host through srt's egress proxy. The adapter adds
50
+ * {@link ToolBridge.egress} to the node's egress; nothing else is opened.
51
+ */
52
+ tcp?: boolean;
40
53
  }
41
54
  export interface ToolBridge {
42
55
  /** Absolute path of the unix socket. */
43
56
  readonly socketPath: string;
44
57
  /** Per-run token. Never log it. */
45
58
  readonly token: string;
59
+ /** Loopback TCP endpoint, when started with `tcp: true`. */
60
+ readonly tcp?: {
61
+ host: string;
62
+ port: number;
63
+ };
64
+ /** Hosts a sandboxed shim must reach (`127.0.0.1:<port>` with `tcp`, else none). */
65
+ readonly egress: string[];
46
66
  /** stdio MCP server config a harness uses to start the shim. The token rides in `env`, not argv. */
47
67
  readonly mcpServer: McpServerConfig;
48
68
  /** Stop serving, drop connections, remove the socket and its directory. Idempotent. */
@@ -9,6 +9,11 @@
9
9
  * Security properties (this is an injection boundary):
10
10
  * - The socket lives in a fresh `mkdtemp` directory (mode 0700) and is itself
11
11
  * chmod 0600, so other users cannot connect.
12
+ * - With `tcp` (only for agents inside the process sandbox wrapper, #439) the
13
+ * same handler also listens on 127.0.0.1:<random port>. Other local users
14
+ * can open that port, so there the token is the only gate; the sandboxed
15
+ * agent reaches it through srt's proxy only because the adapter allowlists
16
+ * exactly that host:port for the run.
12
17
  * - A random 256-bit token is required on every frame. A wrong or missing
13
18
  * token gets an `unauthorized` error and the connection is dropped.
14
19
  * - Only the tools passed in are listed or callable. The executor has already
@@ -98,7 +103,7 @@ export async function startToolBridge(opts) {
98
103
  liveDirs.add(dir);
99
104
  installCrashHooks();
100
105
  const sockets = new Set();
101
- const server = net.createServer((sock) => {
106
+ const onConnection = (sock) => {
102
107
  sockets.add(sock);
103
108
  sock.on("close", () => sockets.delete(sock));
104
109
  sock.on("error", () => sock.destroy());
@@ -143,19 +148,29 @@ export async function startToolBridge(opts) {
143
148
  sock.destroy();
144
149
  });
145
150
  sock.on("data", read);
151
+ };
152
+ const server = net.createServer(onConnection);
153
+ // Same handler and token check; only started for sandboxed agents.
154
+ const tcpServer = opts.tcp ? net.createServer(onConnection) : undefined;
155
+ const listen = (s, options) => new Promise((resolve, reject) => {
156
+ s.once("error", reject);
157
+ s.listen(options, () => {
158
+ s.removeListener("error", reject);
159
+ resolve();
160
+ });
146
161
  });
162
+ let tcp;
147
163
  try {
148
- await new Promise((resolve, reject) => {
149
- server.once("error", reject);
150
- server.listen(socketPath, () => {
151
- server.removeListener("error", reject);
152
- resolve();
153
- });
154
- });
164
+ await listen(server, { path: socketPath });
155
165
  fs.chmodSync(socketPath, 0o600);
166
+ if (tcpServer) {
167
+ await listen(tcpServer, { port: 0, host: "127.0.0.1" });
168
+ tcp = { host: "127.0.0.1", port: tcpServer.address().port };
169
+ }
156
170
  }
157
171
  catch (err) {
158
172
  server.close();
173
+ tcpServer?.close();
159
174
  liveDirs.delete(dir);
160
175
  removeDirSync(dir);
161
176
  uninstallCrashHooksIfIdle();
@@ -167,10 +182,12 @@ export async function startToolBridge(opts) {
167
182
  return {
168
183
  socketPath,
169
184
  token,
185
+ tcp,
186
+ egress: tcp ? [`${tcp.host}:${tcp.port}`] : [],
170
187
  mcpServer: {
171
188
  type: "stdio",
172
189
  command: shim.command,
173
- args: [...shim.args, "--socket", socketPath],
190
+ args: [...shim.args, ...(tcp ? ["--connect", `${tcp.host}:${tcp.port}`] : ["--socket", socketPath])],
174
191
  // No update-check nudge in the shim: its stdio belongs to MCP.
175
192
  env: { [TOKEN_ENV]: token, SWENY_NO_UPDATE_CHECK: "1" },
176
193
  },
@@ -180,7 +197,7 @@ export async function startToolBridge(opts) {
180
197
  closed = true;
181
198
  for (const s of sockets)
182
199
  s.destroy();
183
- await new Promise((resolve) => server.close(() => resolve()));
200
+ await Promise.all([server, tcpServer].map((s) => (s ? new Promise((resolve) => s.close(() => resolve())) : undefined)));
184
201
  liveDirs.delete(dir);
185
202
  removeDirSync(dir);
186
203
  uninstallCrashHooksIfIdle();
@@ -1,5 +1,7 @@
1
1
  /**
2
- * SwenyToolBridge shim (#414): `sweny tool-bridge --socket <path>`.
2
+ * SwenyToolBridge shim (#414): `sweny tool-bridge --socket <path>`, or
3
+ * `--connect 127.0.0.1:<port>` inside the process sandbox (#439), where it
4
+ * tunnels through the sandbox's HTTP proxy.
3
5
  *
4
6
  * A stdio MCP server a harness starts as a child process. It holds no tools
5
7
  * of its own: `tools/list` and `tools/call` are forwarded over the per-run
@@ -9,17 +11,49 @@
9
11
  * `--token` or, by default, the `SWENY_TOOL_BRIDGE_TOKEN` env var the server
10
12
  * puts in the MCP server config, so it never shows up in `ps` output.
11
13
  */
14
+ import * as net from "node:net";
12
15
  import { type BridgeMethod, type BridgeResponse } from "./protocol.js";
16
+ /** Where the bridge listens: the per-run unix socket, or its loopback TCP port (#439). */
17
+ export type BridgeEndpoint = {
18
+ socketPath: string;
19
+ } | {
20
+ host: string;
21
+ port: number;
22
+ };
23
+ /** Parse `--connect host:port` (IPv4 literal or `localhost`, or `[v6]:port`). */
24
+ export declare function parseConnectTarget(value: string): {
25
+ host: string;
26
+ port: number;
27
+ } | undefined;
28
+ /**
29
+ * The HTTP proxy a sandboxed shim must tunnel through, from its env. srt sets
30
+ * HTTP_PROXY / HTTPS_PROXY (with its per-run credentials) for everything it
31
+ * wraps; inside the sandbox nothing else reaches the host. NO_PROXY is
32
+ * ignored on purpose: srt lists 127.0.0.1 there, but inside the sandbox's own
33
+ * network namespace 127.0.0.1 is not the host.
34
+ */
35
+ export declare function bridgeProxyFromEnv(env: NodeJS.ProcessEnv): URL | undefined;
36
+ /**
37
+ * Open a byte stream to the bridge. A TCP endpoint goes through an HTTP
38
+ * CONNECT tunnel when the env names a proxy (the sandboxed case), else
39
+ * straight to the port. `leftover` holds any bytes read past the proxy's
40
+ * response header.
41
+ */
42
+ export declare function openBridgeStream(endpoint: BridgeEndpoint, env?: NodeJS.ProcessEnv): Promise<{
43
+ sock: net.Socket;
44
+ leftover: Buffer;
45
+ }>;
13
46
  /** Socket client that multiplexes requests by id over one connection. */
14
47
  export declare class BridgeClient {
15
- private readonly socketPath;
16
48
  private readonly token;
49
+ private readonly env;
17
50
  private sock;
18
51
  private connecting;
19
52
  private nextId;
20
53
  private pending;
21
54
  private closedError;
22
- constructor(socketPath: string, token: string);
55
+ private readonly endpoint;
56
+ constructor(endpoint: string | BridgeEndpoint, token: string, env?: NodeJS.ProcessEnv);
23
57
  private connect;
24
58
  private failAll;
25
59
  request(method: BridgeMethod, params?: {
@@ -29,7 +63,10 @@ export declare class BridgeClient {
29
63
  close(): void;
30
64
  }
31
65
  export interface ShimOptions {
32
- socket: string;
66
+ /** The per-run unix socket. */
67
+ socket?: string;
68
+ /** Or the bridge's loopback TCP endpoint, `host:port` (sandboxed agents, #439). */
69
+ connect?: string;
33
70
  /** Falls back to `SWENY_TOOL_BRIDGE_TOKEN`. */
34
71
  token?: string;
35
72
  version?: string;
@@ -1,5 +1,7 @@
1
1
  /**
2
- * SwenyToolBridge shim (#414): `sweny tool-bridge --socket <path>`.
2
+ * SwenyToolBridge shim (#414): `sweny tool-bridge --socket <path>`, or
3
+ * `--connect 127.0.0.1:<port>` inside the process sandbox (#439), where it
4
+ * tunnels through the sandbox's HTTP proxy.
3
5
  *
4
6
  * A stdio MCP server a harness starts as a child process. It holds no tools
5
7
  * of its own: `tools/list` and `tools/call` are forwarded over the per-run
@@ -14,18 +16,116 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
14
16
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
15
17
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
16
18
  import { BRIDGE_PROTOCOL_VERSION, TOKEN_ENV, createFrameReader, encodeFrame, } from "./protocol.js";
19
+ /** Parse `--connect host:port` (IPv4 literal or `localhost`, or `[v6]:port`). */
20
+ export function parseConnectTarget(value) {
21
+ const m = /^(?:\[([0-9a-fA-F:.]+)\]|([A-Za-z0-9.-]+)):([1-9][0-9]{0,4})$/.exec(value);
22
+ if (!m)
23
+ return undefined;
24
+ const port = Number(m[3]);
25
+ return port <= 65535 ? { host: m[1] ?? m[2], port } : undefined;
26
+ }
27
+ /**
28
+ * The HTTP proxy a sandboxed shim must tunnel through, from its env. srt sets
29
+ * HTTP_PROXY / HTTPS_PROXY (with its per-run credentials) for everything it
30
+ * wraps; inside the sandbox nothing else reaches the host. NO_PROXY is
31
+ * ignored on purpose: srt lists 127.0.0.1 there, but inside the sandbox's own
32
+ * network namespace 127.0.0.1 is not the host.
33
+ */
34
+ export function bridgeProxyFromEnv(env) {
35
+ for (const k of ["HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy"]) {
36
+ const v = env[k];
37
+ if (!v)
38
+ continue;
39
+ try {
40
+ const u = new URL(v);
41
+ if (u.protocol === "http:")
42
+ return u;
43
+ }
44
+ catch {
45
+ // not a URL: try the next variable
46
+ }
47
+ }
48
+ return undefined;
49
+ }
50
+ const MAX_PROXY_RESPONSE_BYTES = 16 * 1024;
51
+ /**
52
+ * Open a byte stream to the bridge. A TCP endpoint goes through an HTTP
53
+ * CONNECT tunnel when the env names a proxy (the sandboxed case), else
54
+ * straight to the port. `leftover` holds any bytes read past the proxy's
55
+ * response header.
56
+ */
57
+ export function openBridgeStream(endpoint, env = process.env) {
58
+ const direct = (sock) => new Promise((resolve, reject) => {
59
+ sock.once("error", reject);
60
+ sock.once("connect", () => {
61
+ sock.removeListener("error", reject);
62
+ resolve({ sock, leftover: Buffer.alloc(0) });
63
+ });
64
+ });
65
+ if ("socketPath" in endpoint)
66
+ return direct(net.createConnection(endpoint.socketPath));
67
+ const proxy = bridgeProxyFromEnv(env);
68
+ if (!proxy)
69
+ return direct(net.createConnection(endpoint.port, endpoint.host));
70
+ return new Promise((resolve, reject) => {
71
+ const target = endpoint.host.includes(":")
72
+ ? `[${endpoint.host}]:${endpoint.port}`
73
+ : `${endpoint.host}:${endpoint.port}`;
74
+ const sock = net.createConnection(Number(proxy.port || 80), proxy.hostname.replace(/^\[|\]$/g, ""));
75
+ let buf = Buffer.alloc(0);
76
+ let done = false;
77
+ const fail = (err) => {
78
+ if (done)
79
+ return;
80
+ done = true;
81
+ sock.destroy();
82
+ reject(err);
83
+ };
84
+ const onData = (chunk) => {
85
+ buf = Buffer.concat([buf, chunk]);
86
+ const end = buf.indexOf("\r\n\r\n");
87
+ if (end === -1) {
88
+ if (buf.length > MAX_PROXY_RESPONSE_BYTES)
89
+ fail(new Error("tool bridge: malformed proxy response"));
90
+ return;
91
+ }
92
+ const status = buf.subarray(0, buf.indexOf("\r\n")).toString("latin1");
93
+ if (!/^HTTP\/1\.[01] 200(\s|$)/.test(status)) {
94
+ fail(new Error(`tool bridge: the sandbox proxy refused ${target} (${status})`));
95
+ return;
96
+ }
97
+ done = true;
98
+ sock.removeListener("data", onData);
99
+ sock.removeListener("error", fail);
100
+ sock.removeListener("close", onClose);
101
+ resolve({ sock, leftover: buf.subarray(end + 4) });
102
+ };
103
+ const onClose = () => fail(new Error("tool bridge: the sandbox proxy closed the connection"));
104
+ sock.on("data", onData);
105
+ sock.once("error", fail);
106
+ sock.once("close", onClose);
107
+ sock.once("connect", () => {
108
+ const user = decodeURIComponent(proxy.username);
109
+ const pass = decodeURIComponent(proxy.password);
110
+ const auth = user || pass ? `Proxy-Authorization: Basic ${Buffer.from(`${user}:${pass}`).toString("base64")}\r\n` : "";
111
+ sock.write(`CONNECT ${target} HTTP/1.1\r\nHost: ${target}\r\n${auth}\r\n`);
112
+ });
113
+ });
114
+ }
17
115
  /** Socket client that multiplexes requests by id over one connection. */
18
116
  export class BridgeClient {
19
- socketPath;
20
117
  token;
118
+ env;
21
119
  sock;
22
120
  connecting;
23
121
  nextId = 1;
24
122
  pending = new Map();
25
123
  closedError;
26
- constructor(socketPath, token) {
27
- this.socketPath = socketPath;
124
+ endpoint;
125
+ constructor(endpoint, token, env = process.env) {
28
126
  this.token = token;
127
+ this.env = env;
128
+ this.endpoint = typeof endpoint === "string" ? { socketPath: endpoint } : endpoint;
29
129
  }
30
130
  connect() {
31
131
  // Once the connection is gone (bad token, server closed) every later call fails fast.
@@ -33,8 +133,7 @@ export class BridgeClient {
33
133
  return Promise.reject(this.closedError);
34
134
  if (this.sock)
35
135
  return Promise.resolve(this.sock);
36
- this.connecting ??= new Promise((resolve, reject) => {
37
- const sock = net.createConnection(this.socketPath);
136
+ this.connecting ??= openBridgeStream(this.endpoint, this.env).then(({ sock, leftover }) => {
38
137
  const read = createFrameReader((value) => {
39
138
  const res = value;
40
139
  if (!res || typeof res.id !== "number")
@@ -50,15 +149,15 @@ export class BridgeClient {
50
149
  }
51
150
  }, () => this.failAll(new Error("tool bridge: response frame too large")));
52
151
  sock.on("data", read);
53
- sock.once("connect", () => {
54
- this.sock = sock;
55
- resolve(sock);
56
- });
57
- sock.once("error", (err) => {
58
- reject(err);
59
- this.failAll(err);
60
- });
152
+ sock.once("error", (err) => this.failAll(err));
61
153
  sock.once("close", () => this.failAll(new Error("tool bridge: connection closed")));
154
+ this.sock = sock;
155
+ if (leftover.length > 0)
156
+ read(leftover);
157
+ return sock;
158
+ }, (err) => {
159
+ this.failAll(err);
160
+ throw err;
62
161
  });
63
162
  return this.connecting;
64
163
  }
@@ -89,13 +188,15 @@ export class BridgeClient {
89
188
  /** Run the stdio MCP shim until stdin closes or the bridge goes away. */
90
189
  export async function runToolBridgeShim(opts) {
91
190
  const token = opts.token ?? process.env[TOKEN_ENV];
92
- if (!opts.socket || !token) {
93
- process.stderr.write(`sweny tool-bridge: --socket and a token (--token or ${TOKEN_ENV}) are required\n`);
191
+ const tcp = opts.connect ? parseConnectTarget(opts.connect) : undefined;
192
+ const endpoint = opts.socket ? { socketPath: opts.socket } : tcp;
193
+ if (!endpoint || (opts.socket && opts.connect) || (opts.connect && !tcp) || !token) {
194
+ process.stderr.write(`sweny tool-bridge: one of --socket <path> or --connect <host:port>, and a token (--token or ${TOKEN_ENV}), are required\n`);
94
195
  process.exit(2);
95
196
  }
96
197
  // Keep the token out of anything the shim might spawn or report.
97
198
  delete process.env[TOKEN_ENV];
98
- const client = new BridgeClient(opts.socket, token);
199
+ const client = new BridgeClient(endpoint, token);
99
200
  const server = new Server({ name: "sweny-core", version: opts.version ?? "0.0.0" }, { capabilities: { tools: {} } });
100
201
  server.setRequestHandler(ListToolsRequestSchema, async () => {
101
202
  const res = await client.request("tools/list");
@@ -55,6 +55,12 @@ export interface NodePolicy {
55
55
  egress: string[];
56
56
  /** Refuse instead of degrade when an opinion cannot be enforced. */
57
57
  strict: boolean;
58
+ /**
59
+ * Only the MCP servers sweny passes may load, never the user's own config
60
+ * (`permissions.strict`, #365). A harness whose `mcp.exclusive` is `none`
61
+ * cannot honor it: reported in `degraded`, refused under `strict`.
62
+ */
63
+ exclusiveMcp?: boolean;
58
64
  /**
59
65
  * Turn budget for the run. Set by adapters whose `turnLimit` is not native,
60
66
  * so the gate reports how the budget is kept (watchdog) or that it is not.
package/dist/index.d.ts CHANGED
@@ -21,7 +21,7 @@
21
21
  * })
22
22
  * ```
23
23
  */
24
- export type { Skill, SkillCategory, SkillDefinition, Tool, ToolContext, ConfigField, JSONSchema, Workflow, Node, Edge, NodeResult, ToolCall, ExecutionEvent, Observer, Claude, Logger, TraceStep, TraceEdge, ExecutionTrace, ExecutionResult, Source, ResolvedSource, SourceKind, SourceResolutionMap, WorkflowInputs, WorkflowInputField, WorkflowInputType, InputValidationError, InputValidationResult, Evaluator, EvaluatorRule, EvalResult, EvaluatorKind, NodeRequires, NodeRetry, NodeToolFilter, OutputMatch, NodeSources, EvalPolicy, RequiresOnFail, NodeOnFail, McpTransport, WorkflowType, SkillHarnessKey, NodeAccess, NodePermissions, NodePermissionsSpec, SafeOutputType, SafeOutputDeclaration, SafeOutputsPolicy, SafeOutputReceipt, AuthorAssociation, } from "./types.js";
24
+ export type { Skill, SkillCategory, SkillDefinition, Tool, ToolContext, ConfigField, JSONSchema, Workflow, Node, Edge, NodeResult, ToolCall, ExecutionEvent, Observer, Claude, Logger, TraceStep, TraceEdge, ExecutionTrace, ExecutionResult, Source, ResolvedSource, SourceKind, SourceResolutionMap, WorkflowInputs, WorkflowInputField, WorkflowInputType, InputValidationError, InputValidationResult, Evaluator, EvaluatorRule, EvalResult, EvaluatorKind, NodeRequires, NodeRetry, NodeToolFilter, OutputMatch, NodeSources, EvalPolicy, RequiresOnFail, NodeOnFail, McpTransport, WorkflowType, SkillHarnessKey, NodeAccess, NodePermissions, NodePermissionsSpec, SafeOutputType, SafeOutputDeclaration, SafeOutputsPolicy, SafeOutputReceipt, SafeOutputPin, AuthorAssociation, } from "./types.js";
25
25
  export { WORKFLOW_INPUT_TYPES } from "./types.js";
26
26
  export { EVALUATOR_KINDS, EVAL_POLICIES, REQUIRES_ON_FAIL, NODE_ON_FAIL, MCP_TRANSPORTS, SKILL_CATEGORIES, SKILL_HARNESSES, SKILL_ID_PATTERN, SKILL_ID_MAX_LENGTH, isValidSkillId, NODE_ACCESS, TOOL_CLASSES, SAFE_OUTPUT_TYPES, SAFE_OUTPUT_APPLIERS, AUTHOR_ASSOCIATIONS, } from "./types.js";
27
27
  export { consoleLogger } from "./types.js";
@@ -38,5 +38,6 @@ export function buildNodePolicy(opts) {
38
38
  ...(opts.disallowedTools && opts.disallowedTools.length > 0 ? { nativeDeny: opts.disallowedTools } : {}),
39
39
  egress: opts.egress ?? [],
40
40
  strict: opts.permissions.strict,
41
+ ...(opts.permissions.strict ? { exclusiveMcp: true } : {}),
41
42
  };
42
43
  }
@@ -9,11 +9,11 @@
9
9
  * the write, so this holds on every harness.
10
10
  *
11
11
  * Order of checks per intent (all deterministic): declared type, workflow
12
- * ceiling, actor trust, expiry, skill, target pin, shape and size, labels,
13
- * title prefix, dedupe, node cap, run cap. An optional model screen runs last
12
+ * ceiling, actor trust, expiry, skill, target pin, issue pin, shape and size,
13
+ * labels, title prefix, dedupe, node cap, run cap. An optional model screen runs last
14
14
  * over the intents that passed and can only veto them.
15
15
  */
16
- import type { Logger, SafeOutputDeclaration, SafeOutputReceipt, SafeOutputsPolicy, SafeOutputType, Skill, Tool } from "./types.js";
16
+ import type { Logger, SafeOutputDeclaration, SafeOutputPin, SafeOutputReceipt, SafeOutputsPolicy, SafeOutputType, Skill, Tool } from "./types.js";
17
17
  /** Name of the intent-recording tool a node with `outputs` receives. */
18
18
  export declare const EMIT_OUTPUT_TOOL = "emit_output";
19
19
  /** Longest title the write stage accepts (after the prefix). */
@@ -43,7 +43,7 @@ export interface SafeOutputIntent {
43
43
  */
44
44
  export declare function createEmitOutputTool(declarations: SafeOutputDeclaration[], buffer: SafeOutputIntent[], now?: () => number): Tool;
45
45
  /** Instruction block that tells the agent how writes work at this node. */
46
- export declare function safeOutputsInstruction(declarations: SafeOutputDeclaration[]): string;
46
+ export declare function safeOutputsInstruction(declarations: SafeOutputDeclaration[], input?: Record<string, unknown>): string;
47
47
  export interface ActorInfo {
48
48
  /** GitHub login of whoever triggered the run. */
49
49
  login?: string;
@@ -54,9 +54,19 @@ export interface ActorInfo {
54
54
  export declare function resolveActor(env: Record<string, string | undefined>, override?: ActorInfo): ActorInfo;
55
55
  /** True when no trust list is declared, or the actor matches one. Unknown actor = untrusted. */
56
56
  export declare function actorTrusted(policy: SafeOutputsPolicy | undefined, actor: ActorInfo): boolean;
57
+ /**
58
+ * The issue or PR a declaration pins, as a string. `{ input: name }` reads the
59
+ * run input; an absent or empty value is `undefined`, which refuses the write.
60
+ */
61
+ export declare function resolvePin(pin: SafeOutputPin, input?: Record<string, unknown>): string | undefined;
57
62
  /** `30m` → 1_800_000. Undefined for anything the schema would reject. */
58
63
  export declare function parseDuration(s: string | undefined): number | undefined;
59
- /** The skill that applies a declaration: its `via`, else the first node skill (then any loaded skill) that can. */
64
+ /**
65
+ * The skill that applies a declaration: its `via`, else the first node skill
66
+ * that can and is configured, else the first node skill that can, else any
67
+ * configured skill that can. A node listing `[linear, github]` therefore files
68
+ * on GitHub when only GitHub has credentials.
69
+ */
60
70
  export declare function resolveOutputSkill(decl: SafeOutputDeclaration, nodeSkills: string[], skills: Map<string, Skill>): string | undefined;
61
71
  /** Declarations whose applying skill is not configured (no credentials loaded). */
62
72
  export declare function unresolvedOutputs(declarations: SafeOutputDeclaration[], nodeSkills: string[], skills: Map<string, Skill>): SafeOutputType[];
@@ -85,6 +95,8 @@ export interface ApplySafeOutputsOptions {
85
95
  staged: boolean;
86
96
  state: WriteStageState;
87
97
  logger: Logger;
98
+ /** The run input, for `number: { input }` pins. */
99
+ input?: Record<string, unknown>;
88
100
  /**
89
101
  * Model screen (only called when `policy.screen` is on and at least one write
90
102
  * passed every deterministic check). Anything but `ALLOW` is a veto.