@sema-agent/connectors 0.0.0-stage → 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/CHANGELOG.md +247 -0
- package/bin/sema-connector.mjs +6 -0
- package/dist/cli-kit.d.ts +33 -0
- package/dist/cli-kit.js +223 -0
- package/dist/config-dir.d.ts +11 -0
- package/dist/config-dir.js +12 -0
- package/dist/connector-name.d.ts +1 -0
- package/dist/connector-name.js +1 -0
- package/dist/http.d.ts +26 -0
- package/dist/http.js +264 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +7 -0
- package/dist/json-text.d.ts +11 -0
- package/dist/json-text.js +93 -0
- package/dist/jwks.d.ts +19 -0
- package/dist/jwks.js +81 -0
- package/dist/launcher.d.ts +2 -0
- package/dist/launcher.js +585 -0
- package/dist/lock-file.d.ts +1 -0
- package/dist/lock-file.js +34 -0
- package/dist/mount.d.ts +23 -0
- package/dist/mount.js +160 -0
- package/dist/normalize.d.ts +1 -0
- package/dist/normalize.js +19 -0
- package/dist/principal.d.ts +50 -0
- package/dist/principal.js +127 -0
- package/dist/redact.d.ts +18 -0
- package/dist/redact.js +121 -0
- package/dist/scope.d.ts +1 -0
- package/dist/scope.js +5 -0
- package/dist/secrets.d.ts +49 -0
- package/dist/secrets.js +264 -0
- package/dist/session.d.ts +59 -0
- package/dist/session.js +256 -0
- package/dist/status.d.ts +12 -0
- package/dist/status.js +21 -0
- package/dist/write-all.d.ts +1 -0
- package/dist/write-all.js +15 -0
- package/package.json +37 -4
- package/README.md +0 -3
package/dist/mount.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { closeSync, fchmodSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync } from 'node:fs';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { absoluteDir } from './config-dir.js';
|
|
5
|
+
import { CONNECTOR_NAME } from './connector-name.js';
|
|
6
|
+
import { skipWhitespace, spansOf } from './json-text.js';
|
|
7
|
+
import { takeLockFile } from './lock-file.js';
|
|
8
|
+
import { writeAll } from './write-all.js';
|
|
9
|
+
const RESERVED_NAMES = ['sema-connector'];
|
|
10
|
+
export function standaloneEntry(name, configDir) {
|
|
11
|
+
return { name, transport: { kind: 'stdio', command: 'sema-connector', args: ['run', name, '--mode', 'standalone', '--transport', 'stdio', '--config-dir', configDir] } };
|
|
12
|
+
}
|
|
13
|
+
function canonical(v) {
|
|
14
|
+
if (Array.isArray(v))
|
|
15
|
+
return `[${v.map(canonical).join(',')}]`;
|
|
16
|
+
if (v !== null && typeof v === 'object') {
|
|
17
|
+
const o = v;
|
|
18
|
+
return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${canonical(o[k])}`).join(',')}}`;
|
|
19
|
+
}
|
|
20
|
+
return JSON.stringify(v) ?? 'undefined';
|
|
21
|
+
}
|
|
22
|
+
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
23
|
+
function plan(before, entry) {
|
|
24
|
+
if (before.trim() === '')
|
|
25
|
+
return { refusal: 'unreadable', message: 'config.d/mcp.json is empty' };
|
|
26
|
+
let doc;
|
|
27
|
+
try {
|
|
28
|
+
doc = JSON.parse(before);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return { refusal: 'unreadable', message: 'config.d/mcp.json is not valid JSON' };
|
|
32
|
+
}
|
|
33
|
+
if (!isObject(doc))
|
|
34
|
+
return { refusal: 'unexpected_shape', message: 'config.d/mcp.json must hold a JSON object' };
|
|
35
|
+
const open = skipWhitespace(before, 0);
|
|
36
|
+
const members = spansOf(before, open, true);
|
|
37
|
+
const serverMembers = members.filter((m) => m.key === 'servers');
|
|
38
|
+
if (serverMembers.length > 1)
|
|
39
|
+
return { refusal: 'unexpected_shape', message: 'config.d/mcp.json gives "servers" more than once' };
|
|
40
|
+
const servers = doc.servers;
|
|
41
|
+
if (servers !== undefined && !Array.isArray(servers))
|
|
42
|
+
return { refusal: 'unexpected_shape', message: '"servers" in config.d/mcp.json is not a list' };
|
|
43
|
+
const named = (servers ?? []).filter((s) => isObject(s) && s.name === entry.name);
|
|
44
|
+
if (named.length === 1 && canonical(named[0]) === canonical(entry))
|
|
45
|
+
return { unchanged: true };
|
|
46
|
+
if (named.length > 0)
|
|
47
|
+
return { refusal: 'conflict', message: `config.d/mcp.json already has another entry named ${entry.name}; mount does not overwrite it` };
|
|
48
|
+
const entryText = JSON.stringify(entry);
|
|
49
|
+
const insertAfter = (at, insertion) => before.slice(0, at) + insertion + before.slice(at);
|
|
50
|
+
let text;
|
|
51
|
+
const serversMember = serverMembers[0];
|
|
52
|
+
if (serversMember === undefined) {
|
|
53
|
+
const member = `"servers": [${entryText}]`;
|
|
54
|
+
const first = members[0];
|
|
55
|
+
const last = members[members.length - 1];
|
|
56
|
+
text = first === undefined || last === undefined ? insertAfter(open + 1, member) : insertAfter(last.valueEnd, ',' + before.slice(open + 1, first.start) + member);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
const arrayOpen = serversMember.valueStart;
|
|
60
|
+
const elements = spansOf(before, arrayOpen, false);
|
|
61
|
+
const first = elements[0];
|
|
62
|
+
const last = elements[elements.length - 1];
|
|
63
|
+
text = first === undefined || last === undefined ? insertAfter(arrayOpen + 1, entryText) : insertAfter(last.valueEnd, ',' + before.slice(arrayOpen + 1, first.start) + entryText);
|
|
64
|
+
}
|
|
65
|
+
const after = JSON.parse(text);
|
|
66
|
+
const { servers: newServers, ...newRest } = after;
|
|
67
|
+
const { servers: _old, ...oldRest } = doc;
|
|
68
|
+
if (canonical(newServers) !== canonical([...(servers ?? []), entry]) || canonical(newRest) !== canonical(oldRest)) {
|
|
69
|
+
throw new Error('mount planned a text that does not add exactly its entry; the file was not written');
|
|
70
|
+
}
|
|
71
|
+
return { text };
|
|
72
|
+
}
|
|
73
|
+
const LOCK_FILE = '.mcp.json.lock';
|
|
74
|
+
const failure = (e) => {
|
|
75
|
+
const code = e?.code;
|
|
76
|
+
return typeof code === 'string' ? code : e instanceof Error ? e.message : 'unknown error';
|
|
77
|
+
};
|
|
78
|
+
const readOrNull = (file) => {
|
|
79
|
+
try {
|
|
80
|
+
return readFileSync(file, 'utf8');
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
if (e.code === 'ENOENT')
|
|
84
|
+
return null;
|
|
85
|
+
throw e;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
export function mount(options) {
|
|
89
|
+
const refuse = (reason, message) => ({ ok: false, reason, message });
|
|
90
|
+
const name = options?.name;
|
|
91
|
+
if (typeof name !== 'string' || !CONNECTOR_NAME.test(name))
|
|
92
|
+
return refuse('invalid_name', 'a connector name is lowercase letters and digits in hyphen-separated words');
|
|
93
|
+
if (RESERVED_NAMES.includes(name))
|
|
94
|
+
return refuse('reserved_name', `${name} is a reserved name`);
|
|
95
|
+
const configDir = absoluteDir(options.configDir);
|
|
96
|
+
if (configDir === null)
|
|
97
|
+
return refuse('config_dir', 'configDir must be the configuration directory as an absolute path; mount reads no environment variable and has no default');
|
|
98
|
+
const dir = join(configDir, 'config.d');
|
|
99
|
+
const file = join(dir, 'mcp.json');
|
|
100
|
+
let unlock;
|
|
101
|
+
try {
|
|
102
|
+
mkdirSync(dir, { recursive: true });
|
|
103
|
+
unlock = takeLockFile(join(dir, LOCK_FILE));
|
|
104
|
+
}
|
|
105
|
+
catch (e) {
|
|
106
|
+
return refuse('write_failed', `config.d or its lock could not be written (${failure(e)}); config.d/mcp.json was left as it was`);
|
|
107
|
+
}
|
|
108
|
+
if (unlock === null)
|
|
109
|
+
return refuse('locked', `another mount is writing config.d/mcp.json; try again, and if no mount is running remove config.d/${LOCK_FILE}`);
|
|
110
|
+
try {
|
|
111
|
+
return write(file, standaloneEntry(name, configDir), refuse);
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
unlock();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function write(file, entry, refuse) {
|
|
118
|
+
const dir = dirname(file);
|
|
119
|
+
let before;
|
|
120
|
+
try {
|
|
121
|
+
before = readOrNull(file);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return refuse('unreadable', 'config.d/mcp.json cannot be read');
|
|
125
|
+
}
|
|
126
|
+
let text;
|
|
127
|
+
if (before === null)
|
|
128
|
+
text = JSON.stringify({ servers: [entry] }, null, 2) + '\n';
|
|
129
|
+
else {
|
|
130
|
+
const p = plan(before, entry);
|
|
131
|
+
if ('refusal' in p)
|
|
132
|
+
return refuse(p.refusal, p.message);
|
|
133
|
+
if ('unchanged' in p)
|
|
134
|
+
return { ok: true, changed: false, file };
|
|
135
|
+
text = p.text;
|
|
136
|
+
}
|
|
137
|
+
const mode = before === null ? null : statSync(file).mode & 0o7777;
|
|
138
|
+
const tmp = join(dir, `.mcp.json.${randomBytes(6).toString('hex')}.tmp`);
|
|
139
|
+
try {
|
|
140
|
+
const fd = openSync(tmp, 'wx', 0o666);
|
|
141
|
+
try {
|
|
142
|
+
if (mode !== null)
|
|
143
|
+
fchmodSync(fd, mode);
|
|
144
|
+
writeAll(fd, text);
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
closeSync(fd);
|
|
148
|
+
}
|
|
149
|
+
if (readOrNull(file) !== before) {
|
|
150
|
+
rmSync(tmp, { force: true });
|
|
151
|
+
return refuse('changed_during_write', 'config.d/mcp.json changed while mount was writing it; nothing was written, run mount again');
|
|
152
|
+
}
|
|
153
|
+
renameSync(tmp, file);
|
|
154
|
+
}
|
|
155
|
+
catch (e) {
|
|
156
|
+
rmSync(tmp, { force: true });
|
|
157
|
+
return refuse('write_failed', `config.d/mcp.json could not be written (${failure(e)}); it was left as it was and nothing else was written`);
|
|
158
|
+
}
|
|
159
|
+
return { ok: true, changed: true, file };
|
|
160
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function normalizeTool(tool: Readonly<Record<string, unknown>>): Record<string, unknown>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const PASSABLE_HINTS = Object.freeze({ destructiveHint: true, idempotentHint: false, openWorldHint: true });
|
|
2
|
+
export function normalizeTool(tool) {
|
|
3
|
+
const out = {};
|
|
4
|
+
for (const [key, value] of Object.entries(tool)) {
|
|
5
|
+
if (key !== 'annotations') {
|
|
6
|
+
out[key] = value;
|
|
7
|
+
continue;
|
|
8
|
+
}
|
|
9
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
10
|
+
continue;
|
|
11
|
+
const kept = {};
|
|
12
|
+
for (const [hint, v] of Object.entries(value)) {
|
|
13
|
+
if (hint === 'title' || (Object.hasOwn(PASSABLE_HINTS, hint) && PASSABLE_HINTS[hint] === v))
|
|
14
|
+
kept[hint] = v;
|
|
15
|
+
}
|
|
16
|
+
out[key] = kept;
|
|
17
|
+
}
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export declare const ASSERTION_HEADER = "connector-assertion";
|
|
2
|
+
export declare const SCOPE_PATTERN: RegExp;
|
|
3
|
+
export type AssertionStep = 'decode' | 'algorithm' | 'key' | 'signature' | 'claims' | 'issuer' | 'audience' | 'expiry' | 'subject' | 'space' | 'scope';
|
|
4
|
+
export interface AnonymousPrincipal {
|
|
5
|
+
readonly kind: 'anonymous';
|
|
6
|
+
readonly scope: readonly string[];
|
|
7
|
+
}
|
|
8
|
+
export interface AssertionPrincipal {
|
|
9
|
+
readonly kind: 'assertion';
|
|
10
|
+
readonly sub: string;
|
|
11
|
+
readonly space: string;
|
|
12
|
+
readonly scope: readonly string[];
|
|
13
|
+
}
|
|
14
|
+
export type Principal = AnonymousPrincipal | AssertionPrincipal;
|
|
15
|
+
export interface VerifyOptions {
|
|
16
|
+
readonly issuer: string;
|
|
17
|
+
readonly audience: string;
|
|
18
|
+
readonly keys: unknown;
|
|
19
|
+
readonly now: number;
|
|
20
|
+
}
|
|
21
|
+
export type VerifyResult = {
|
|
22
|
+
readonly ok: true;
|
|
23
|
+
readonly principal: AssertionPrincipal;
|
|
24
|
+
} | {
|
|
25
|
+
readonly ok: false;
|
|
26
|
+
readonly code: 'connector.bad_assertion';
|
|
27
|
+
readonly step: AssertionStep;
|
|
28
|
+
};
|
|
29
|
+
export declare function verifyAssertion(compactJws: string, options: VerifyOptions): VerifyResult;
|
|
30
|
+
export interface RequestContext {
|
|
31
|
+
readonly headers: Readonly<Record<string, string | readonly string[] | undefined>>;
|
|
32
|
+
}
|
|
33
|
+
export type PrincipalResult = {
|
|
34
|
+
readonly ok: true;
|
|
35
|
+
readonly principal: Principal;
|
|
36
|
+
} | {
|
|
37
|
+
readonly ok: false;
|
|
38
|
+
readonly code: 'connector.bad_assertion';
|
|
39
|
+
readonly reason: string;
|
|
40
|
+
};
|
|
41
|
+
export interface PrincipalResolver {
|
|
42
|
+
resolve(request: RequestContext): PrincipalResult;
|
|
43
|
+
}
|
|
44
|
+
export declare function standaloneResolver(): PrincipalResolver;
|
|
45
|
+
export declare function hostedResolver(options: {
|
|
46
|
+
readonly issuer: string;
|
|
47
|
+
readonly audience: string;
|
|
48
|
+
readonly keys: unknown;
|
|
49
|
+
readonly clock?: () => number;
|
|
50
|
+
}): PrincipalResolver;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { createPublicKey, verify } from 'node:crypto';
|
|
2
|
+
export const ASSERTION_HEADER = 'connector-assertion';
|
|
3
|
+
export const SCOPE_PATTERN = /^[!#-\[\]-~]+( [!#-\[\]-~]+)*$/;
|
|
4
|
+
const EXPIRY_TOLERANCE_SECONDS = 30;
|
|
5
|
+
const BAD_ASSERTION = 'connector.bad_assertion';
|
|
6
|
+
const ALGORITHMS = {
|
|
7
|
+
EdDSA: { kty: 'OKP', crv: 'Ed25519', members: ['kty', 'crv', 'x'] },
|
|
8
|
+
ES256: { kty: 'EC', crv: 'P-256', members: ['kty', 'crv', 'x', 'y'] },
|
|
9
|
+
};
|
|
10
|
+
const REQUIRED_CLAIMS = Object.keys({ iss: 0, sub: 0, aud: 0, exp: 0, iat: 0, jti: 0, space: 0, scope: 0 });
|
|
11
|
+
const B64URL = /^[A-Za-z0-9_-]*$/;
|
|
12
|
+
function decodeSegment(segment) {
|
|
13
|
+
if (!B64URL.test(segment) || segment.length % 4 === 1)
|
|
14
|
+
return null;
|
|
15
|
+
const bytes = Buffer.from(segment, 'base64url');
|
|
16
|
+
return bytes.toString('base64url') === segment ? bytes : null;
|
|
17
|
+
}
|
|
18
|
+
function jsonObject(bytes) {
|
|
19
|
+
if (bytes === null)
|
|
20
|
+
return null;
|
|
21
|
+
try {
|
|
22
|
+
const v = JSON.parse(bytes.toString('utf8'));
|
|
23
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v) ? v : null;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function verifyAssertion(compactJws, options) {
|
|
30
|
+
if (typeof options?.issuer !== 'string' || options.issuer === '' || typeof options.audience !== 'string' || options.audience === '' || !Number.isFinite(options.now)) {
|
|
31
|
+
throw new TypeError('verifyAssertion needs a non-empty issuer and audience and a finite now (seconds since the epoch)');
|
|
32
|
+
}
|
|
33
|
+
const refuse = (step) => ({ ok: false, code: BAD_ASSERTION, step });
|
|
34
|
+
const segments = typeof compactJws === 'string' ? compactJws.split('.') : [];
|
|
35
|
+
if (segments.length !== 3)
|
|
36
|
+
return refuse('decode');
|
|
37
|
+
const [h = '', p = '', s = ''] = segments;
|
|
38
|
+
const header = jsonObject(decodeSegment(h));
|
|
39
|
+
const claims = jsonObject(decodeSegment(p));
|
|
40
|
+
const signature = decodeSegment(s);
|
|
41
|
+
if (header === null || claims === null || signature === null || 'crit' in header)
|
|
42
|
+
return refuse('decode');
|
|
43
|
+
const alg = header.alg;
|
|
44
|
+
if (typeof alg !== 'string' || !Object.hasOwn(ALGORITHMS, alg))
|
|
45
|
+
return refuse('algorithm');
|
|
46
|
+
const wanted = ALGORITHMS[alg];
|
|
47
|
+
const kid = header.kid;
|
|
48
|
+
if (typeof kid !== 'string' || kid === '')
|
|
49
|
+
return refuse('key');
|
|
50
|
+
const set = options.keys !== null && typeof options.keys === 'object' ? options.keys.keys : undefined;
|
|
51
|
+
if (!Array.isArray(set))
|
|
52
|
+
return refuse('key');
|
|
53
|
+
const found = set.filter((k) => k !== null && typeof k === 'object' && k.kid === kid);
|
|
54
|
+
if (found.length !== 1)
|
|
55
|
+
return refuse('key');
|
|
56
|
+
const jwk = found[0];
|
|
57
|
+
if (jwk.kty !== wanted.kty || jwk.crv !== wanted.crv || (jwk.alg !== undefined && jwk.alg !== alg))
|
|
58
|
+
return refuse('key');
|
|
59
|
+
let key;
|
|
60
|
+
try {
|
|
61
|
+
key = createPublicKey({ key: Object.fromEntries(wanted.members.map((m) => [m, jwk[m]])), format: 'jwk' });
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return refuse('key');
|
|
65
|
+
}
|
|
66
|
+
const input = Buffer.from(`${h}.${p}`, 'ascii');
|
|
67
|
+
let valid = false;
|
|
68
|
+
try {
|
|
69
|
+
valid = alg === 'EdDSA' ? verify(null, input, key, signature) : signature.length === 64 && verify('sha256', input, { key, dsaEncoding: 'ieee-p1363' }, signature);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
valid = false;
|
|
73
|
+
}
|
|
74
|
+
if (!valid)
|
|
75
|
+
return refuse('signature');
|
|
76
|
+
if (REQUIRED_CLAIMS.some((c) => !Object.hasOwn(claims, c)))
|
|
77
|
+
return refuse('claims');
|
|
78
|
+
const { iat, jti } = claims;
|
|
79
|
+
if (typeof iat !== 'number' || !Number.isInteger(iat) || iat < 0 || typeof jti !== 'string' || jti === '')
|
|
80
|
+
return refuse('claims');
|
|
81
|
+
if (claims.iss !== options.issuer)
|
|
82
|
+
return refuse('issuer');
|
|
83
|
+
if (claims.aud !== options.audience)
|
|
84
|
+
return refuse('audience');
|
|
85
|
+
const exp = claims.exp;
|
|
86
|
+
if (typeof exp !== 'number' || !Number.isInteger(exp) || exp < 0 || options.now >= exp + EXPIRY_TOLERANCE_SECONDS)
|
|
87
|
+
return refuse('expiry');
|
|
88
|
+
const { sub, space, scope } = claims;
|
|
89
|
+
if (typeof sub !== 'string' || sub === '')
|
|
90
|
+
return refuse('subject');
|
|
91
|
+
if (typeof space !== 'string' || space === '')
|
|
92
|
+
return refuse('space');
|
|
93
|
+
if (typeof scope !== 'string' || !SCOPE_PATTERN.test(scope))
|
|
94
|
+
return refuse('scope');
|
|
95
|
+
return { ok: true, principal: { kind: 'assertion', sub, space, scope: scope.split(' ') } };
|
|
96
|
+
}
|
|
97
|
+
const ANONYMOUS = Object.freeze({ kind: 'anonymous', scope: Object.freeze(['tool:*']) });
|
|
98
|
+
export function standaloneResolver() {
|
|
99
|
+
return { resolve: () => ({ ok: true, principal: ANONYMOUS }) };
|
|
100
|
+
}
|
|
101
|
+
export function hostedResolver(options) {
|
|
102
|
+
if (typeof options?.issuer !== 'string' || options.issuer === '' || typeof options.audience !== 'string' || options.audience === '') {
|
|
103
|
+
throw new TypeError('hostedResolver needs a non-empty issuer and audience');
|
|
104
|
+
}
|
|
105
|
+
const clock = options.clock ?? (() => Math.floor(Date.now() / 1000));
|
|
106
|
+
const refuse = (reason) => ({ ok: false, code: BAD_ASSERTION, reason });
|
|
107
|
+
return {
|
|
108
|
+
resolve(request) {
|
|
109
|
+
const values = [];
|
|
110
|
+
for (const [name, value] of Object.entries(request?.headers ?? {})) {
|
|
111
|
+
if (name.toLowerCase() !== ASSERTION_HEADER || value === undefined)
|
|
112
|
+
continue;
|
|
113
|
+
if (typeof value === 'string')
|
|
114
|
+
values.push(value);
|
|
115
|
+
else
|
|
116
|
+
for (const v of value)
|
|
117
|
+
values.push(v);
|
|
118
|
+
}
|
|
119
|
+
if (values.length === 0)
|
|
120
|
+
return refuse(`the request carries no ${ASSERTION_HEADER} header; a principal is never taken from a name header`);
|
|
121
|
+
if (values.length > 1)
|
|
122
|
+
return refuse(`the ${ASSERTION_HEADER} header is given more than once`);
|
|
123
|
+
const verdict = verifyAssertion(values[0] ?? '', { issuer: options.issuer, audience: options.audience, keys: options.keys, now: clock() });
|
|
124
|
+
return verdict.ok ? { ok: true, principal: verdict.principal } : refuse(`the assertion was refused at the ${verdict.step} step`);
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
package/dist/redact.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export declare function plainValues(values: readonly string[]): string[];
|
|
2
|
+
export declare function unescapeView(text: string, final: boolean): {
|
|
3
|
+
readonly decoded: string;
|
|
4
|
+
readonly starts: number[];
|
|
5
|
+
readonly end: number;
|
|
6
|
+
};
|
|
7
|
+
export declare function secretMatcher(values: readonly string[]): (line: Buffer, parsed: unknown) => boolean;
|
|
8
|
+
export declare const REDACTED = "[redacted]";
|
|
9
|
+
export declare class Scrubber {
|
|
10
|
+
private readonly plain;
|
|
11
|
+
private readonly keep;
|
|
12
|
+
private readonly decoder;
|
|
13
|
+
private held;
|
|
14
|
+
constructor(values: readonly string[]);
|
|
15
|
+
feed(chunk: Buffer): Buffer;
|
|
16
|
+
flush(): Buffer;
|
|
17
|
+
private scan;
|
|
18
|
+
}
|
package/dist/redact.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
2
|
+
export function plainValues(values) {
|
|
3
|
+
return [...new Set(values.flatMap((v) => [v, v.trim()]).filter((v) => v !== ''))];
|
|
4
|
+
}
|
|
5
|
+
const SIMPLE_ESCAPES = { '"': '"', '\\': '\\', '/': '/', b: '\b', f: '\f', n: '\n', r: '\r', t: '\t' };
|
|
6
|
+
const HEX4 = /^[0-9a-fA-F]{4}$/;
|
|
7
|
+
export function unescapeView(text, final) {
|
|
8
|
+
const starts = [];
|
|
9
|
+
let decoded = '';
|
|
10
|
+
let i = 0;
|
|
11
|
+
while (i < text.length) {
|
|
12
|
+
if (text[i] === '\\') {
|
|
13
|
+
const c = text[i + 1];
|
|
14
|
+
if (c === undefined && !final)
|
|
15
|
+
break;
|
|
16
|
+
if (c === 'u') {
|
|
17
|
+
const hex = text.slice(i + 2, i + 6);
|
|
18
|
+
if (hex.length < 4 && !final)
|
|
19
|
+
break;
|
|
20
|
+
if (HEX4.test(hex)) {
|
|
21
|
+
starts.push(i);
|
|
22
|
+
decoded += String.fromCharCode(parseInt(hex, 16));
|
|
23
|
+
i += 6;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
else if (c !== undefined && Object.hasOwn(SIMPLE_ESCAPES, c)) {
|
|
28
|
+
starts.push(i);
|
|
29
|
+
decoded += SIMPLE_ESCAPES[c];
|
|
30
|
+
i += 2;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
starts.push(i);
|
|
35
|
+
decoded += text[i];
|
|
36
|
+
i += 1;
|
|
37
|
+
}
|
|
38
|
+
starts.push(i);
|
|
39
|
+
return { decoded, starts, end: i };
|
|
40
|
+
}
|
|
41
|
+
function inDecodedJson(value, plain) {
|
|
42
|
+
const stack = [value];
|
|
43
|
+
while (stack.length > 0) {
|
|
44
|
+
const v = stack.pop();
|
|
45
|
+
if (typeof v === 'string') {
|
|
46
|
+
if (plain.some((p) => v.includes(p)))
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
else if (Array.isArray(v))
|
|
50
|
+
for (const x of v)
|
|
51
|
+
stack.push(x);
|
|
52
|
+
else if (v !== null && typeof v === 'object') {
|
|
53
|
+
for (const [k, x] of Object.entries(v)) {
|
|
54
|
+
if (plain.some((p) => k.includes(p)))
|
|
55
|
+
return true;
|
|
56
|
+
stack.push(x);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
export function secretMatcher(values) {
|
|
63
|
+
const plain = plainValues(values);
|
|
64
|
+
return (line, parsed) => {
|
|
65
|
+
if (plain.length === 0)
|
|
66
|
+
return false;
|
|
67
|
+
const text = line.toString('utf8');
|
|
68
|
+
const { decoded } = unescapeView(text, true);
|
|
69
|
+
return plain.some((p) => text.includes(p) || decoded.includes(p)) || inDecodedJson(parsed, plain);
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export const REDACTED = '[redacted]';
|
|
73
|
+
export class Scrubber {
|
|
74
|
+
plain;
|
|
75
|
+
keep;
|
|
76
|
+
decoder = new StringDecoder('utf8');
|
|
77
|
+
held = '';
|
|
78
|
+
constructor(values) {
|
|
79
|
+
this.plain = plainValues(values);
|
|
80
|
+
this.keep = this.plain.reduce((n, p) => Math.max(n, p.length - 1), 0);
|
|
81
|
+
}
|
|
82
|
+
feed(chunk) {
|
|
83
|
+
if (this.plain.length === 0)
|
|
84
|
+
return chunk;
|
|
85
|
+
return this.scan(this.held + this.decoder.write(chunk), false);
|
|
86
|
+
}
|
|
87
|
+
flush() {
|
|
88
|
+
if (this.plain.length === 0)
|
|
89
|
+
return Buffer.alloc(0);
|
|
90
|
+
return this.scan(this.held + this.decoder.end(), true);
|
|
91
|
+
}
|
|
92
|
+
scan(text, final) {
|
|
93
|
+
const view = unescapeView(text, final);
|
|
94
|
+
const spans = [];
|
|
95
|
+
for (const p of this.plain) {
|
|
96
|
+
for (let at = text.indexOf(p); at !== -1; at = text.indexOf(p, at + 1))
|
|
97
|
+
spans.push([at, at + p.length]);
|
|
98
|
+
for (let at = view.decoded.indexOf(p); at !== -1; at = view.decoded.indexOf(p, at + 1))
|
|
99
|
+
spans.push([view.starts[at] ?? 0, view.starts[at + p.length] ?? view.end]);
|
|
100
|
+
}
|
|
101
|
+
const units = view.starts.length - 1;
|
|
102
|
+
let cut = final ? text.length : (view.starts[Math.max(0, units - this.keep)] ?? view.end);
|
|
103
|
+
spans.sort((a, b) => a[0] - b[0]);
|
|
104
|
+
for (const [s, e] of spans)
|
|
105
|
+
if (s < cut && e > cut)
|
|
106
|
+
cut = e;
|
|
107
|
+
let out = '';
|
|
108
|
+
let at = 0;
|
|
109
|
+
for (const [s, e] of spans) {
|
|
110
|
+
if (s >= cut)
|
|
111
|
+
break;
|
|
112
|
+
if (e <= at)
|
|
113
|
+
continue;
|
|
114
|
+
out += text.slice(at, Math.max(at, s)) + REDACTED;
|
|
115
|
+
at = Math.max(at, e);
|
|
116
|
+
}
|
|
117
|
+
out += text.slice(at, cut);
|
|
118
|
+
this.held = text.slice(cut);
|
|
119
|
+
return Buffer.from(out, 'utf8');
|
|
120
|
+
}
|
|
121
|
+
}
|
package/dist/scope.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function isGranted(scope: readonly string[], tool: string): boolean;
|
package/dist/scope.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export interface SecretHandle {
|
|
2
|
+
readonly connector: string;
|
|
3
|
+
readonly name: string;
|
|
4
|
+
readonly source: 'file';
|
|
5
|
+
readonly secret: boolean;
|
|
6
|
+
readonly expiresAt?: string;
|
|
7
|
+
}
|
|
8
|
+
export type SecretListing = {
|
|
9
|
+
readonly ok: true;
|
|
10
|
+
readonly handles: readonly SecretHandle[];
|
|
11
|
+
} | {
|
|
12
|
+
readonly ok: false;
|
|
13
|
+
readonly problem: string;
|
|
14
|
+
};
|
|
15
|
+
export interface PutOptions {
|
|
16
|
+
readonly expiresAt?: string;
|
|
17
|
+
readonly secret?: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface FileSecrets {
|
|
20
|
+
readonly directory: string;
|
|
21
|
+
list(): SecretListing;
|
|
22
|
+
put(name: string, value: string, options?: PutOptions): SecretHandle;
|
|
23
|
+
withLock<T>(fn: () => T): T;
|
|
24
|
+
}
|
|
25
|
+
export declare const isSecret: (handle: SecretHandle) => boolean;
|
|
26
|
+
export declare const HANDLE_NAME: RegExp;
|
|
27
|
+
export declare const LOCK_FILE = ".auth.lock";
|
|
28
|
+
export declare const LOCK_WAIT_MS = 5000;
|
|
29
|
+
export declare function handleDirectory(configDir: string, connector: string): string;
|
|
30
|
+
export declare function handleFilePath(configDir: string, handle: SecretHandle): string;
|
|
31
|
+
export declare function readSecretValue(configDir: string, handle: SecretHandle): Buffer;
|
|
32
|
+
export interface LaunchSecrets {
|
|
33
|
+
readonly handles: readonly SecretHandle[];
|
|
34
|
+
readonly files: ReadonlyMap<string, string>;
|
|
35
|
+
readonly values: readonly string[];
|
|
36
|
+
remove(): void;
|
|
37
|
+
}
|
|
38
|
+
export type LaunchCopy = ({
|
|
39
|
+
readonly ok: true;
|
|
40
|
+
} & LaunchSecrets) | {
|
|
41
|
+
readonly ok: false;
|
|
42
|
+
readonly problem: string;
|
|
43
|
+
};
|
|
44
|
+
export declare const NO_HANDLES: LaunchSecrets;
|
|
45
|
+
export declare function copyForLaunch(configDir: string, connector: string): LaunchCopy;
|
|
46
|
+
export declare function openFileSecrets(options: {
|
|
47
|
+
readonly configDir: string;
|
|
48
|
+
readonly connector: string;
|
|
49
|
+
}): FileSecrets;
|