@sema-agent/connectors 0.6.0 → 0.7.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/CHANGELOG.md CHANGED
@@ -4,6 +4,26 @@ All notable changes to this package are documented here. Released sections are
4
4
  frozen: once a version is published its section is never edited again; new text
5
5
  goes into the next version's section.
6
6
 
7
+ ## 0.7.0 (2026-10-16)
8
+
9
+ ### Added
10
+
11
+ - A standalone local per-tool policy (CN-054): a hand-written `connectors/<name>/tools.json`,
12
+ `{"allow": ["<tool name>", …]}` (exactly one key, a list of non-empty tool names with no whitespace, no control
13
+ character, no repeat and never the literal `*`, the scope judge's own wildcard token), read once with the
14
+ connector's secret handles (inside the same directory lock, with a non-blocking open so a FIFO in the file's
15
+ place cannot hang the launch) and returned as `LaunchCopy.policy`. `standaloneResolver(policy?)` takes it and
16
+ mints the standalone principal's scope from it instead of `tool:*` when it is given (one `tool:<name>` per
17
+ allowed name; an empty list grants none). Absent, a policy file this process cannot read, one of another
18
+ shape, or a symbolic link in its place, all stop the launcher before the connector starts (exit 2,
19
+ `connector.internal`, naming the file relative to the configuration directory, never its content); absent the
20
+ file, behaviour is exactly as before. No CLI subcommand writes this file. `tools/list`, the status resource's
21
+ `tools` and `tools/call` (with its receipt) already followed the one scope judge (`isGranted`); they need no
22
+ change to follow the narrower grant too. A parked session (CN-046) carries the same principal, so the scope
23
+ stage still refuses a tool outside the policy first, with `connector.forbidden_scope`, before the parked
24
+ `connector.auth_required` a tool inside it gets. Hosted mode never reads this file (its grant comes from the
25
+ assertion).
26
+
7
27
  ## 0.6.0 (2026-10-16)
8
28
 
9
29
  ### Changed (breaking)
package/dist/launcher.js CHANGED
@@ -504,8 +504,8 @@ function parkedRun(session, connector, stdout, log) {
504
504
  }),
505
505
  };
506
506
  }
507
- async function runStdio(packageName, run, session, stdout, log) {
508
- const resolution = standaloneResolver().resolve({ headers: {} });
507
+ async function runStdio(packageName, run, session, stdout, log, policy) {
508
+ const resolution = standaloneResolver(policy).resolve({ headers: {} });
509
509
  if (!resolution.ok)
510
510
  throw new Error('the standalone principal cannot be refused');
511
511
  const anonymous = resolution.principal;
@@ -628,7 +628,7 @@ export async function main(argv, env) {
628
628
  const run = parked
629
629
  ? parkedRun(session, plan.name, stdout, log)
630
630
  : startConnector(resolved.binPath, childEnvironment(env, handleDirectory(plan.configDir, plan.name), secrets), session, new Scrubber(secrets.values), (data) => stdout.write(data), stdout, log);
631
- return finish(await runStdio(resolved.packageName, run, session, stdout, log));
631
+ return finish(await runStdio(resolved.packageName, run, session, stdout, log, secrets.policy));
632
632
  }
633
633
  finally {
634
634
  secrets.remove();
@@ -41,7 +41,7 @@ export type PrincipalResult = {
41
41
  export interface PrincipalResolver {
42
42
  resolve(request: RequestContext): PrincipalResult;
43
43
  }
44
- export declare function standaloneResolver(): PrincipalResolver;
44
+ export declare function standaloneResolver(policy?: readonly string[] | null): PrincipalResolver;
45
45
  export declare function hostedResolver(options: {
46
46
  readonly issuer: string;
47
47
  readonly audience: string;
package/dist/principal.js CHANGED
@@ -95,8 +95,11 @@ export function verifyAssertion(compactJws, options) {
95
95
  return { ok: true, principal: { kind: 'assertion', sub, space, scope: scope.split(' ') } };
96
96
  }
97
97
  const ANONYMOUS = Object.freeze({ kind: 'anonymous', scope: Object.freeze(['tool:*']) });
98
- export function standaloneResolver() {
99
- return { resolve: () => ({ ok: true, principal: ANONYMOUS }) };
98
+ export function standaloneResolver(policy) {
99
+ if (policy === null || policy === undefined)
100
+ return { resolve: () => ({ ok: true, principal: ANONYMOUS }) };
101
+ const principal = Object.freeze({ kind: 'anonymous', scope: Object.freeze(policy.map((name) => `tool:${name}`)) });
102
+ return { resolve: () => ({ ok: true, principal }) };
100
103
  }
101
104
  export function hostedResolver(options) {
102
105
  if (typeof options?.issuer !== 'string' || options.issuer === '' || typeof options.audience !== 'string' || options.audience === '') {
package/dist/secrets.d.ts CHANGED
@@ -34,6 +34,7 @@ export interface LaunchSecrets {
34
34
  readonly handles: readonly SecretHandle[];
35
35
  readonly files: ReadonlyMap<string, string>;
36
36
  readonly values: readonly string[];
37
+ readonly policy: readonly string[] | null;
37
38
  remove(): void;
38
39
  }
39
40
  export type LaunchCopy = ({
@@ -44,6 +45,14 @@ export type LaunchCopy = ({
44
45
  };
45
46
  export declare const NO_HANDLES: LaunchSecrets;
46
47
  export declare function copyForLaunch(configDir: string, connector: string): LaunchCopy;
48
+ export declare const POLICY_FILE = "tools.json";
49
+ export type PolicyReading = {
50
+ readonly ok: true;
51
+ readonly policy: readonly string[] | null;
52
+ } | {
53
+ readonly ok: false;
54
+ readonly problem: string;
55
+ };
47
56
  export declare function openFileSecrets(options: {
48
57
  readonly configDir: string;
49
58
  readonly connector: string;
package/dist/secrets.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { randomBytes } from 'node:crypto';
2
- import { closeSync, constants, fchmodSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, renameSync, rmSync } from 'node:fs';
2
+ import { closeSync, constants, fchmodSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, renameSync, rmSync } from 'node:fs';
3
3
  import { basename, dirname, isAbsolute, join } from 'node:path';
4
4
  import { CONNECTOR_NAME } from './connector-name.js';
5
5
  import { hasRepeatedKey } from './json-text.js';
@@ -56,7 +56,7 @@ function locked(dir, connector, fn) {
56
56
  release();
57
57
  }
58
58
  }
59
- export const NO_HANDLES = { handles: [], files: new Map(), values: [], remove: () => undefined };
59
+ export const NO_HANDLES = { handles: [], files: new Map(), values: [], policy: null, remove: () => undefined };
60
60
  export function copyForLaunch(configDir, connector) {
61
61
  const dir = handleDirectory(configDir, connector);
62
62
  try {
@@ -74,11 +74,15 @@ export function copyForLaunch(configDir, connector) {
74
74
  return r.ok ? r.value : r;
75
75
  }
76
76
  function copyHandles(configDir, dir, connector) {
77
+ const policyReading = readToolPolicy(dir, connector);
78
+ if (!policyReading.ok)
79
+ return policyReading;
80
+ const { policy } = policyReading;
77
81
  const listing = listHandles(dir, connector);
78
82
  if (!listing.ok)
79
83
  return listing;
80
84
  if (listing.handles.length === 0)
81
- return { ok: true, ...NO_HANDLES };
85
+ return { ok: true, ...NO_HANDLES, policy };
82
86
  const copies = mkdtempSync(join(dir, '.launch-'));
83
87
  const remove = () => rmSync(copies, { recursive: true, force: true });
84
88
  try {
@@ -98,7 +102,7 @@ function copyHandles(configDir, dir, connector) {
98
102
  if (isSecret(handle))
99
103
  values.push(bytes.toString('utf8'));
100
104
  }
101
- return { ok: true, handles: listing.handles, files, values, remove };
105
+ return { ok: true, handles: listing.handles, files, values, policy, remove };
102
106
  }
103
107
  catch (e) {
104
108
  remove();
@@ -142,6 +146,64 @@ function entryProblem(path, kind, label) {
142
146
  return `${label} has permissions wider than ${kind === 'directory' ? '0700' : '0600'}`;
143
147
  return null;
144
148
  }
149
+ export const POLICY_FILE = 'tools.json';
150
+ const ALLOW_NAME = /^[^\s\x00-\x1F\x7F]+$/;
151
+ function validAllowList(v) {
152
+ if (!Array.isArray(v))
153
+ return false;
154
+ const seen = new Set();
155
+ for (const name of v) {
156
+ if (typeof name !== 'string' || name === '*' || !ALLOW_NAME.test(name) || seen.has(name))
157
+ return false;
158
+ seen.add(name);
159
+ }
160
+ return true;
161
+ }
162
+ function readToolPolicy(dir, connector) {
163
+ const relative = `connectors/${connector}/${POLICY_FILE}`;
164
+ const refuse = (problem) => ({ ok: false, problem: `connector ${connector}: ${problem}` });
165
+ const malformed = () => refuse(`${relative} does not hold a JSON object whose only key is "allow", a list of non-empty tool names with no whitespace, no control character and no repeat`);
166
+ let fd;
167
+ try {
168
+ fd = openSync(join(dir, POLICY_FILE), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
169
+ }
170
+ catch (e) {
171
+ const code = e.code;
172
+ if (code === 'ENOENT')
173
+ return { ok: true, policy: null };
174
+ if (code === 'ELOOP')
175
+ return refuse(`${relative} is a symbolic link`);
176
+ return refuse(`${relative} cannot be read`);
177
+ }
178
+ try {
179
+ if (!fstatSync(fd).isFile())
180
+ return refuse(`${relative} is not a regular file`);
181
+ let text;
182
+ try {
183
+ text = readFileSync(fd, 'utf8');
184
+ }
185
+ catch {
186
+ return refuse(`${relative} cannot be read`);
187
+ }
188
+ let value;
189
+ try {
190
+ value = JSON.parse(text);
191
+ }
192
+ catch {
193
+ return malformed();
194
+ }
195
+ if (value === null || typeof value !== 'object' || Array.isArray(value) || hasRepeatedKey(text))
196
+ return malformed();
197
+ const keys = Object.keys(value);
198
+ if (keys.length !== 1 || keys[0] !== 'allow')
199
+ return malformed();
200
+ const allow = value.allow;
201
+ return validAllowList(allow) ? { ok: true, policy: allow } : malformed();
202
+ }
203
+ finally {
204
+ closeSync(fd);
205
+ }
206
+ }
145
207
  function listHandles(dir, connector) {
146
208
  const refuse = (problem) => ({ ok: false, problem: `connector ${connector}: ${problem}` });
147
209
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/connectors",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Shared connector layer and the sema-connector launcher (MCP stdio proxy with principal, scope, normalize, status and trace stages).",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",