@moxxy/sdk 0.33.0 → 0.34.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 +1 -1
- package/dist/audit-export.d.ts +51 -0
- package/dist/audit-export.d.ts.map +1 -0
- package/dist/audit-export.js +4 -0
- package/dist/audit-export.js.map +1 -0
- package/dist/audit.d.ts +86 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +35 -0
- package/dist/audit.js.map +1 -0
- package/dist/define.d.ts +2 -0
- package/dist/define.d.ts.map +1 -1
- package/dist/define.js +1 -0
- package/dist/define.js.map +1 -1
- package/dist/egress.d.ts +109 -0
- package/dist/egress.d.ts.map +1 -0
- package/dist/egress.js +228 -0
- package/dist/egress.js.map +1 -0
- package/dist/events.d.ts +18 -0
- package/dist/events.d.ts.map +1 -1
- package/dist/fs-utils.d.ts +48 -0
- package/dist/fs-utils.d.ts.map +1 -1
- package/dist/fs-utils.js +114 -1
- package/dist/fs-utils.js.map +1 -1
- package/dist/hooks.d.ts +8 -0
- package/dist/hooks.d.ts.map +1 -1
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/plugin.d.ts +10 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/principal-os.d.ts +14 -0
- package/dist/principal-os.d.ts.map +1 -0
- package/dist/principal-os.js +48 -0
- package/dist/principal-os.js.map +1 -0
- package/dist/principal.d.ts +54 -0
- package/dist/principal.d.ts.map +1 -0
- package/dist/principal.js +18 -0
- package/dist/principal.js.map +1 -0
- package/dist/redact.d.ts +36 -0
- package/dist/redact.d.ts.map +1 -0
- package/dist/redact.js +88 -0
- package/dist/redact.js.map +1 -0
- package/dist/schemas.d.ts +18 -18
- package/dist/secret-provider.d.ts +60 -0
- package/dist/secret-provider.d.ts.map +1 -0
- package/dist/secret-provider.js +59 -0
- package/dist/secret-provider.js.map +1 -0
- package/dist/server.d.ts +3 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +6 -1
- package/dist/server.js.map +1 -1
- package/dist/session-like.d.ts +3 -0
- package/dist/session-like.d.ts.map +1 -1
- package/dist/signature.d.ts +21 -0
- package/dist/signature.d.ts.map +1 -0
- package/dist/signature.js +32 -0
- package/dist/signature.js.map +1 -0
- package/dist/tool-icon.d.ts +22 -0
- package/dist/tool-icon.d.ts.map +1 -0
- package/dist/tool-icon.js +44 -0
- package/dist/tool-icon.js.map +1 -0
- package/dist/tool.d.ts +8 -0
- package/dist/tool.d.ts.map +1 -1
- package/package.json +11 -4
- package/src/audit-export.ts +57 -0
- package/src/audit.ts +137 -0
- package/src/cross-process-lock.test.ts +23 -14
- package/src/define.ts +3 -0
- package/src/egress.test.ts +238 -0
- package/src/egress.ts +274 -0
- package/src/events.ts +18 -0
- package/src/fs-utils.test.ts +98 -2
- package/src/fs-utils.ts +120 -1
- package/src/hooks.ts +8 -0
- package/src/host-credential.test.ts +39 -0
- package/src/index.ts +49 -0
- package/src/plugin.ts +10 -0
- package/src/principal-os.ts +48 -0
- package/src/principal.test.ts +47 -0
- package/src/principal.ts +60 -0
- package/src/redact.test.ts +91 -0
- package/src/redact.ts +91 -0
- package/src/secret-provider.ts +84 -0
- package/src/server.ts +17 -0
- package/src/session-like.ts +3 -0
- package/src/signature.ts +34 -0
- package/src/tool-icon.test.ts +46 -0
- package/src/tool-icon.ts +46 -0
- package/src/tool.ts +8 -0
package/src/plugin.ts
CHANGED
|
@@ -17,6 +17,9 @@ import type { ViewRendererDef } from './view-renderer.js';
|
|
|
17
17
|
import type { TunnelProviderDef } from './tunnel.js';
|
|
18
18
|
import type { WorkflowExecutorDef } from './workflow.js';
|
|
19
19
|
import type { EventStoreDef } from './event-store.js';
|
|
20
|
+
import type { AuditSinkDef } from './audit.js';
|
|
21
|
+
import type { AuditExporterDef } from './audit-export.js';
|
|
22
|
+
import type { SecretProviderDef } from './secret-provider.js';
|
|
20
23
|
import type { ReflectorDef } from './reflector.js';
|
|
21
24
|
|
|
22
25
|
export type PluginKind = 'tools' | 'provider' | 'mode' | 'compactor' | 'cache-strategy' | 'view-renderer' | 'tunnel-provider' | 'mcp' | 'cli' | 'channel' | 'surface' | 'hooks' | 'agent' | 'command' | 'transcriber' | 'synthesizer' | 'embedder' | 'isolator' | 'workflow-executor' | 'event-store' | 'reflector';
|
|
@@ -54,6 +57,13 @@ export interface PluginSpec {
|
|
|
54
57
|
* boundary, since the store sees every event). See {@link EventStoreDef}.
|
|
55
58
|
*/
|
|
56
59
|
readonly eventStores?: ReadonlyArray<EventStoreDef>;
|
|
60
|
+
/** Audit-trail sinks (syslog, OTel, S3, webhook). Registered alongside the
|
|
61
|
+
* protected local floor; never auto-activated, see registry-kinds. */
|
|
62
|
+
readonly auditSinks?: ReadonlyArray<AuditSinkDef>;
|
|
63
|
+
readonly auditExporters?: ReadonlyArray<AuditExporterDef>;
|
|
64
|
+
/** External secret stores (Vault, AWS/Azure, 1Password). Registered
|
|
65
|
+
* alongside the vault floor; never auto-activated. */
|
|
66
|
+
readonly secretProviders?: ReadonlyArray<SecretProviderDef>;
|
|
57
67
|
/**
|
|
58
68
|
* Reflector backends — the learning-loop block that watches a finished turn
|
|
59
69
|
* and *proposes* memory/skill improvements without silently writing. One
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { hostname, userInfo } from 'node:os';
|
|
2
|
+
import type { Principal } from './principal.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The local OS account, as a {@link Principal}.
|
|
6
|
+
*
|
|
7
|
+
* This is the FLOOR identity: it is what the TUI and other local surfaces
|
|
8
|
+
* attribute to when no stronger issuer (a channel token, an OIDC assertion) is
|
|
9
|
+
* available. Its trust is exactly the trust of local account separation, which
|
|
10
|
+
* is why {@link Principal.issuer} says `os` rather than pretending to more.
|
|
11
|
+
*
|
|
12
|
+
* The id is `user@host` because a bare username is ambiguous the moment
|
|
13
|
+
* transcripts from several machines reach one audit sink.
|
|
14
|
+
*/
|
|
15
|
+
export function resolveOsPrincipal(): Principal {
|
|
16
|
+
const user = safeUsername();
|
|
17
|
+
const host = safeHostname();
|
|
18
|
+
return {
|
|
19
|
+
id: host ? `${user}@${host}` : user,
|
|
20
|
+
kind: 'human',
|
|
21
|
+
issuer: 'os',
|
|
22
|
+
displayName: user,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `userInfo()` throws when the uid has no passwd entry, which happens in
|
|
28
|
+
* distroless containers and some CI images. An unattributed session is worse
|
|
29
|
+
* than a coarse one, so fall back rather than fail.
|
|
30
|
+
*/
|
|
31
|
+
function safeUsername(): string {
|
|
32
|
+
try {
|
|
33
|
+
const name = userInfo().username.trim();
|
|
34
|
+
if (name.length > 0) return name;
|
|
35
|
+
} catch {
|
|
36
|
+
/* fall through */
|
|
37
|
+
}
|
|
38
|
+
const fromEnv = (process.env.USER ?? process.env.USERNAME ?? '').trim();
|
|
39
|
+
return fromEnv.length > 0 ? fromEnv : 'unknown';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function safeHostname(): string {
|
|
43
|
+
try {
|
|
44
|
+
return hostname().trim();
|
|
45
|
+
} catch {
|
|
46
|
+
return '';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { formatPrincipal, samePrincipal, type Principal } from './principal.js';
|
|
3
|
+
import { resolveOsPrincipal } from './principal-os.js';
|
|
4
|
+
|
|
5
|
+
const alice: Principal = { id: 'alice', kind: 'human', issuer: 'os' };
|
|
6
|
+
|
|
7
|
+
describe('samePrincipal', () => {
|
|
8
|
+
// An id is only unique inside its issuer's namespace. Collapsing that would
|
|
9
|
+
// let a weakly-issued identity impersonate a strongly-issued one of the same
|
|
10
|
+
// name, which is the whole reason `issuer` exists.
|
|
11
|
+
it('requires the issuer to match, not just the id', () => {
|
|
12
|
+
expect(samePrincipal(alice, { ...alice, issuer: 'oidc' })).toBe(false);
|
|
13
|
+
expect(samePrincipal(alice, { ...alice, displayName: 'Alice A.' })).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('treats undefined as unattributed, not as a wildcard', () => {
|
|
17
|
+
expect(samePrincipal(undefined, alice)).toBe(false);
|
|
18
|
+
expect(samePrincipal(alice, undefined)).toBe(false);
|
|
19
|
+
expect(samePrincipal(undefined, undefined)).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('formatPrincipal', () => {
|
|
24
|
+
it('renders issuer:id, never the display name', () => {
|
|
25
|
+
expect(formatPrincipal({ ...alice, displayName: 'Alice A.' })).toBe('os:alice');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('names the absent case explicitly', () => {
|
|
29
|
+
expect(formatPrincipal(undefined)).toBe('unattributed');
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('resolveOsPrincipal', () => {
|
|
34
|
+
it('issues a human principal scoped to the host', () => {
|
|
35
|
+
const p = resolveOsPrincipal();
|
|
36
|
+
expect(p.kind).toBe('human');
|
|
37
|
+
expect(p.issuer).toBe('os');
|
|
38
|
+
expect(p.id.length).toBeGreaterThan(0);
|
|
39
|
+
expect(p.displayName).toBeTruthy();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// A bare username collides the moment transcripts from several machines land
|
|
43
|
+
// in one audit sink.
|
|
44
|
+
it('qualifies the id with the hostname', () => {
|
|
45
|
+
expect(resolveOsPrincipal().id).toContain('@');
|
|
46
|
+
});
|
|
47
|
+
});
|
package/src/principal.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who an action was taken on behalf of.
|
|
3
|
+
*
|
|
4
|
+
* Until this existed the event log recorded `source` (a CATEGORY: user, model,
|
|
5
|
+
* tool, plugin, system, compactor) but never a subject, so a transcript proved
|
|
6
|
+
* that a machine did something, not that a person did. That makes audit,
|
|
7
|
+
* role-based policy, and cost attribution impossible to build on top, and all
|
|
8
|
+
* three are table stakes for a shared or managed deployment.
|
|
9
|
+
*
|
|
10
|
+
* A `Principal` is deliberately small and provider-neutral: the identity blocks
|
|
11
|
+
* that will issue it (OS user, channel bearer token, OAuth/OIDC subject, a
|
|
12
|
+
* GitHub App installation) agree on this shape and nothing more.
|
|
13
|
+
*/
|
|
14
|
+
export interface Principal {
|
|
15
|
+
/**
|
|
16
|
+
* Stable identifier within {@link issuer}'s namespace. Only meaningful when
|
|
17
|
+
* paired with the issuer: `alice` from `os` and `alice` from `oidc` are not
|
|
18
|
+
* the same subject, so consumers comparing identities must compare both.
|
|
19
|
+
*/
|
|
20
|
+
readonly id: string;
|
|
21
|
+
/**
|
|
22
|
+
* `human` when a person is behind the action, `service` for an unattended
|
|
23
|
+
* runner (a schedule, a webhook, a CI job). The distinction matters to policy:
|
|
24
|
+
* "a human approved this" is a different claim from "a daemon did it".
|
|
25
|
+
*/
|
|
26
|
+
readonly kind: 'human' | 'service';
|
|
27
|
+
/**
|
|
28
|
+
* What vouched for the identity: `os`, `channel-token`, `oauth`, or the name
|
|
29
|
+
* of the plugin that resolved it. Records HOW MUCH the identity is worth. An
|
|
30
|
+
* `os` principal is only as trustworthy as local account separation; an
|
|
31
|
+
* `oidc` one carries a verified assertion.
|
|
32
|
+
*/
|
|
33
|
+
readonly issuer: string;
|
|
34
|
+
/** Human-readable label for surfaces. Never used for comparison. */
|
|
35
|
+
readonly displayName?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Additional verified attributes (email, groups, tenant). Kept flat and
|
|
38
|
+
* stringly-typed so it survives JSON round-trips through the event log and
|
|
39
|
+
* the runner wire protocol without a schema per issuer.
|
|
40
|
+
*/
|
|
41
|
+
readonly claims?: Readonly<Record<string, string>>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Whether two principals denote the same subject. Compares issuer AND id: an id
|
|
46
|
+
* is only unique inside its issuer's namespace (see {@link Principal.id}).
|
|
47
|
+
*/
|
|
48
|
+
export function samePrincipal(a: Principal | undefined, b: Principal | undefined): boolean {
|
|
49
|
+
if (!a || !b) return a === b;
|
|
50
|
+
return a.issuer === b.issuer && a.id === b.id;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Compact `issuer:id` label for logs and status lines. Deliberately NOT the
|
|
55
|
+
* display name: an audit line has to be unambiguous, and display names are
|
|
56
|
+
* neither unique nor stable.
|
|
57
|
+
*/
|
|
58
|
+
export function formatPrincipal(principal: Principal | undefined): string {
|
|
59
|
+
return principal ? `${principal.issuer}:${principal.id}` : 'unattributed';
|
|
60
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { isSecretKey, redactSecretText, redactSecrets } from './redact.js';
|
|
3
|
+
|
|
4
|
+
describe('isSecretKey', () => {
|
|
5
|
+
it('recognises the common secret-bearing field names', () => {
|
|
6
|
+
for (const key of ['apiKey', 'api_key', 'SECRET', 'authToken', 'privateKey', 'Authorization']) {
|
|
7
|
+
expect(isSecretKey(key)).toBe(true);
|
|
8
|
+
}
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('does not flag ordinary fields', () => {
|
|
12
|
+
for (const key of ['command', 'path', 'name', 'monkey']) expect(isSecretKey(key)).toBe(false);
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('redactSecretText', () => {
|
|
17
|
+
// The motivating case: the carrier field is `command`, so key-name redaction
|
|
18
|
+
// alone prints the token verbatim.
|
|
19
|
+
it('masks a bearer token inside a shell command', () => {
|
|
20
|
+
const out = redactSecretText('curl -H "Authorization: Bearer sk-ant-api03-XYZ123456789" https://api');
|
|
21
|
+
expect(out).not.toContain('XYZ123456789');
|
|
22
|
+
expect(out).toContain('[redacted]');
|
|
23
|
+
// The attempt itself must stay visible: an auditor needs to see a header
|
|
24
|
+
// was sent, not just that something was hidden.
|
|
25
|
+
expect(out).toContain('curl');
|
|
26
|
+
expect(out.toLowerCase()).toContain('authorization');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('masks vendor key formats wherever they appear', () => {
|
|
30
|
+
const cases: ReadonlyArray<[string, string]> = [
|
|
31
|
+
['sk-ant-api03-ABCDEFGHIJKLMNOP', 'ABCDEFGHIJKLMNOP'],
|
|
32
|
+
['ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ012345', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'],
|
|
33
|
+
['xoxb-1234567890-ABCDEFGH', 'ABCDEFGH'],
|
|
34
|
+
['AKIAIOSFODNN7EXAMPLE', 'IOSFODNN7EXAMPLE'],
|
|
35
|
+
['AIzaSyA1234567890abcdefghijklmnop', 'SyA1234567890abcdefghijklmnop'],
|
|
36
|
+
];
|
|
37
|
+
for (const [input, secret] of cases) {
|
|
38
|
+
expect(redactSecretText(`value=${input}`)).not.toContain(secret);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('masks a JWT', () => {
|
|
43
|
+
const jwt = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NX0.SflKxwRJSMeKKF2QT4fwpMeJf36P';
|
|
44
|
+
expect(redactSecretText(`token ${jwt}`)).toContain('[redacted-jwt]');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('masks credentials embedded in a URL', () => {
|
|
48
|
+
const out = redactSecretText('https://alice:hunter2@proxy.corp:3128/path');
|
|
49
|
+
expect(out).not.toContain('hunter2');
|
|
50
|
+
expect(out).toContain('proxy.corp');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('masks KEY=value style assignments', () => {
|
|
54
|
+
const out = redactSecretText('export GITHUB_TOKEN=abcdef123456 && run');
|
|
55
|
+
expect(out).not.toContain('abcdef123456');
|
|
56
|
+
expect(out).toContain('GITHUB_TOKEN');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('leaves ordinary text alone', () => {
|
|
60
|
+
const text = 'ls -la /home/alice && grep -r "monkey" src/';
|
|
61
|
+
expect(redactSecretText(text)).toBe(text);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('redactSecrets', () => {
|
|
66
|
+
it('drops the value of a secret-named field entirely', () => {
|
|
67
|
+
expect(redactSecrets({ apiKey: 'sk-whatever' })).toEqual({ apiKey: '[redacted]' });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('scans ordinary string fields for secret shapes', () => {
|
|
71
|
+
const out = redactSecrets({ command: 'curl -H "Authorization: Bearer sk-ant-ABCDEFGH12345678"' });
|
|
72
|
+
expect(JSON.stringify(out)).not.toContain('ABCDEFGH12345678');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('walks arrays and nested objects', () => {
|
|
76
|
+
const out = redactSecrets({ steps: [{ env: { TOKEN: 'ghp_ABCDEFGHIJKLMNOPQRSTUV' } }] });
|
|
77
|
+
expect(JSON.stringify(out)).not.toContain('ABCDEFGHIJKLMNOPQRSTUV');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('bounds the walk so a pathological input cannot blow the stack', () => {
|
|
81
|
+
let deep: Record<string, unknown> = { apiKey: 'sk-deep' };
|
|
82
|
+
for (let i = 0; i < 500; i++) deep = { nested: deep };
|
|
83
|
+
expect(() => redactSecrets(deep)).not.toThrow();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('passes non-objects through unchanged', () => {
|
|
87
|
+
expect(redactSecrets(42)).toBe(42);
|
|
88
|
+
expect(redactSecrets(null)).toBe(null);
|
|
89
|
+
expect(redactSecrets(undefined)).toBe(undefined);
|
|
90
|
+
});
|
|
91
|
+
});
|
package/src/redact.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret redaction for anything that leaves the process: an audit record, a
|
|
3
|
+
* terminal prompt, a support paste.
|
|
4
|
+
*
|
|
5
|
+
* Two independent passes, because they catch different things and either alone
|
|
6
|
+
* leaves a real hole:
|
|
7
|
+
*
|
|
8
|
+
* - by KEY NAME, for `{ apiKey: 'sk-…' }`, where the value is opaque.
|
|
9
|
+
* - by VALUE SHAPE, for a secret embedded in an ordinary-looking field. The
|
|
10
|
+
* motivating case is a Bash tool call: its `command` is not a secret-named
|
|
11
|
+
* key, so a key-name pass alone prints
|
|
12
|
+
* `curl -H "Authorization: Bearer sk-ant-…"` verbatim.
|
|
13
|
+
*
|
|
14
|
+
* Deliberately NOT a guarantee. A redactor that claims completeness invites
|
|
15
|
+
* treating redacted output as safe to publish; treat it as reducing accidental
|
|
16
|
+
* exposure, not as sanitisation.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Field names whose VALUES are secret material regardless of shape. */
|
|
20
|
+
const SECRET_KEY =
|
|
21
|
+
/(?:api[_-]?key|secret|token|password|passwd|passphrase|authorization|auth[_-]?token|bearer|credential|private[_-]?key|access[_-]?key)/i;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Value shapes that are secrets wherever they appear. Ordered vendor prefixes
|
|
25
|
+
* first (precise, no false positives) then generic bearer/assignment forms.
|
|
26
|
+
*
|
|
27
|
+
* Each pattern keeps any leading context in a capture group so the replacement
|
|
28
|
+
* can preserve it: masking `Authorization: Bearer X` down to just `[redacted]`
|
|
29
|
+
* would lose the fact that a header was there at all, which matters when the
|
|
30
|
+
* point is auditing what was attempted.
|
|
31
|
+
*/
|
|
32
|
+
const SECRET_VALUE_PATTERNS: ReadonlyArray<readonly [RegExp, string]> = [
|
|
33
|
+
// Vendor-issued key formats.
|
|
34
|
+
[/\b(sk-ant-)[A-Za-z0-9_-]{8,}/g, '$1[redacted]'],
|
|
35
|
+
[/\b(sk-)[A-Za-z0-9_-]{16,}/g, '$1[redacted]'],
|
|
36
|
+
[/\b(gh[pousr]_)[A-Za-z0-9]{16,}/g, '$1[redacted]'],
|
|
37
|
+
[/\b(xox[baprs]-)[A-Za-z0-9-]{8,}/g, '$1[redacted]'],
|
|
38
|
+
[/\b(AKIA)[0-9A-Z]{12,}/g, '$1[redacted]'],
|
|
39
|
+
[/\b(AIza)[A-Za-z0-9_-]{20,}/g, '$1[redacted]'],
|
|
40
|
+
// JWTs: three base64url segments.
|
|
41
|
+
[/\b(ey[A-Za-z0-9_-]{8,})\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, 'eyJ[redacted-jwt]'],
|
|
42
|
+
// `Authorization: Bearer <anything>` in a header or a shell command.
|
|
43
|
+
[/\b(authorization\s*[:=]\s*(?:bearer|basic|token)\s+)\S+/gi, '$1[redacted]'],
|
|
44
|
+
[/\b(bearer\s+)[A-Za-z0-9._~+/=-]{12,}/gi, '$1[redacted]'],
|
|
45
|
+
// `FOO_TOKEN=value` / `--api-key value` style assignments.
|
|
46
|
+
[
|
|
47
|
+
/\b([A-Za-z0-9_-]*(?:key|token|secret|password|passwd)[A-Za-z0-9_-]*\s*[:=]\s*)(?!\s)["']?[^\s"'&;|]{6,}["']?/gi,
|
|
48
|
+
'$1[redacted]',
|
|
49
|
+
],
|
|
50
|
+
// Credentials embedded in a URL.
|
|
51
|
+
[/\b([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)[^\s@]+(@)/gi, '$1[redacted]$2'],
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
export const REDACTED_PLACEHOLDER = '[redacted]';
|
|
55
|
+
|
|
56
|
+
/** True when a field name reads as secret-bearing. */
|
|
57
|
+
export function isSecretKey(key: string): boolean {
|
|
58
|
+
return SECRET_KEY.test(key);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Mask secret-shaped substrings inside a free-text value. This is the pass that
|
|
63
|
+
* covers shell commands, URLs, and headers, where the carrier field has an
|
|
64
|
+
* innocuous name.
|
|
65
|
+
*/
|
|
66
|
+
export function redactSecretText(text: string): string {
|
|
67
|
+
let out = text;
|
|
68
|
+
for (const [pattern, replacement] of SECRET_VALUE_PATTERNS) {
|
|
69
|
+
out = out.replace(pattern, replacement);
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Redact a structured value: secret-named fields lose their value entirely,
|
|
76
|
+
* every other string is scanned for secret-shaped content.
|
|
77
|
+
*
|
|
78
|
+
* `maxDepth` bounds the walk so a pathologically nested input cannot blow the
|
|
79
|
+
* stack; anything deeper is returned unredacted, which is why callers that
|
|
80
|
+
* accept untrusted shapes should also cap size before display.
|
|
81
|
+
*/
|
|
82
|
+
export function redactSecrets(value: unknown, maxDepth = 6, depth = 0): unknown {
|
|
83
|
+
if (typeof value === 'string') return redactSecretText(value);
|
|
84
|
+
if (depth >= maxDepth || value === null || typeof value !== 'object') return value;
|
|
85
|
+
if (Array.isArray(value)) return value.map((v) => redactSecrets(v, maxDepth, depth + 1));
|
|
86
|
+
const out: Record<string, unknown> = {};
|
|
87
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
88
|
+
out[k] = SECRET_KEY.test(k) ? REDACTED_PLACEHOLDER : redactSecrets(v, maxDepth, depth + 1);
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a named secret comes from: a swappable block, like every other.
|
|
3
|
+
*
|
|
4
|
+
* The built-in vault (AES-256-GCM under `~/.moxxy/vault.json`, unlocked via the
|
|
5
|
+
* OS keychain) is a good design for one machine and unusable for a fleet: there
|
|
6
|
+
* is no central issuance, no rotation, and no way to revoke a single
|
|
7
|
+
* workstation. Every organisation already runs something that does those three
|
|
8
|
+
* things, so the answer is to let them plug it in rather than to reimplement it.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately ONE block rather than two. A host-scoped credential (a token for
|
|
11
|
+
* an internal registry or a GitHub Enterprise host) is a named secret with a
|
|
12
|
+
* naming convention on top; splitting it into a second `CredentialProvider`
|
|
13
|
+
* registry would leave two overlapping abstractions to keep in sync for no
|
|
14
|
+
* gain.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface SecretProviderScope {
|
|
18
|
+
readonly cwd: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface SecretProviderSession {
|
|
22
|
+
/**
|
|
23
|
+
* Resolve a secret by name, or null when this provider does not hold it.
|
|
24
|
+
*
|
|
25
|
+
* Returning null rather than throwing is what makes providers composable: the
|
|
26
|
+
* host tries the active provider and falls back to the local vault, so
|
|
27
|
+
* adopting an external store does not require migrating every secret on day
|
|
28
|
+
* one.
|
|
29
|
+
*
|
|
30
|
+
* MUST NOT throw for an ordinary miss. A genuine failure (the store is
|
|
31
|
+
* unreachable, the token expired) SHOULD throw, because silently reporting
|
|
32
|
+
* "no such secret" for an outage would send the caller down a
|
|
33
|
+
* missing-credential path and hide the real cause.
|
|
34
|
+
*/
|
|
35
|
+
get(name: string): Promise<string | null>;
|
|
36
|
+
close(): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SecretProviderDef {
|
|
40
|
+
readonly name: string;
|
|
41
|
+
readonly description?: string;
|
|
42
|
+
open(scope: SecretProviderScope): SecretProviderSession;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Freeze a secret-provider spec, mirroring the other `defineX` factories. */
|
|
46
|
+
export function defineSecretProvider(def: SecretProviderDef): SecretProviderDef {
|
|
47
|
+
return Object.freeze({ ...def });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The canonical secret name holding a credential for `host`.
|
|
52
|
+
*
|
|
53
|
+
* A host credential is a named secret plus a convention, which is exactly why
|
|
54
|
+
* there is no separate `CredentialProvider` registry: whatever store an
|
|
55
|
+
* organisation already plugged in for secrets serves these too.
|
|
56
|
+
*
|
|
57
|
+
* `github.example.com` becomes `MOXXY_CREDENTIAL_GITHUB_EXAMPLE_COM`. The
|
|
58
|
+
* derivation mirrors the provider-key convention (upper-snake, non-alphanumerics
|
|
59
|
+
* to `_`) so an operator who has seen one can predict the other, and a port is
|
|
60
|
+
* dropped because a credential belongs to the host, not to a socket.
|
|
61
|
+
*
|
|
62
|
+
* Accepts a bare host or a full URL; anything unparseable yields null rather
|
|
63
|
+
* than a name derived from garbage, so a caller cannot accidentally look up a
|
|
64
|
+
* secret keyed on a typo.
|
|
65
|
+
*/
|
|
66
|
+
export function hostCredentialName(hostOrUrl: string): string | null {
|
|
67
|
+
const raw = hostOrUrl.trim();
|
|
68
|
+
if (raw.length === 0) return null;
|
|
69
|
+
let host = raw;
|
|
70
|
+
if (raw.includes('://')) {
|
|
71
|
+
try {
|
|
72
|
+
host = new URL(raw).hostname;
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
// Strip a port or a path if the caller passed `host:443/x`.
|
|
78
|
+
host = raw.split('/')[0]!.split(':')[0]!;
|
|
79
|
+
}
|
|
80
|
+
host = host.replace(/^\[|\]$/g, '').trim();
|
|
81
|
+
if (host.length === 0 || /\s/.test(host)) return null;
|
|
82
|
+
const slug = host.toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '');
|
|
83
|
+
return slug.length > 0 ? `MOXXY_CREDENTIAL_${slug}` : null;
|
|
84
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -20,7 +20,24 @@ export {
|
|
|
20
20
|
writeFileAtomicSync,
|
|
21
21
|
moxxyHome,
|
|
22
22
|
moxxyPath,
|
|
23
|
+
ensurePrivateDir,
|
|
24
|
+
ensurePrivateFile,
|
|
25
|
+
pruneStaleTempFiles,
|
|
26
|
+
PRIVATE_DIR_MODE,
|
|
27
|
+
PRIVATE_FILE_MODE,
|
|
23
28
|
} from './fs-utils.js';
|
|
29
|
+
// The local OS account as a Principal (node:os). The type and its pure helpers
|
|
30
|
+
// ride the main barrel.
|
|
31
|
+
export { resolveOsPrincipal } from './principal-os.js';
|
|
32
|
+
// Outbound proxy support for global `fetch` (dynamic `undici` import). Node-only.
|
|
33
|
+
export {
|
|
34
|
+
installEgressProxy,
|
|
35
|
+
readProxyEnv,
|
|
36
|
+
parseNoProxy,
|
|
37
|
+
shouldBypassProxy,
|
|
38
|
+
redactProxyUrl,
|
|
39
|
+
hasProxy,
|
|
40
|
+
} from './egress.js';
|
|
24
41
|
// Cross-process "fire exactly once" lock (node:fs). Value lives here; its
|
|
25
42
|
// options type is re-exported from the main barrel like other erased types.
|
|
26
43
|
export { CrossProcessFireLock } from './cross-process-lock.js';
|
package/src/session-like.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { ApprovalResolver, ModeBadge } from './mode.js';
|
|
|
6
6
|
import type { PermissionResolver } from './permission.js';
|
|
7
7
|
import type { ModelDescriptor, ProviderKeyValidation } from './provider.js';
|
|
8
8
|
import type { PluginSetupSpec } from './schemas.js';
|
|
9
|
+
import type { ToolIcon } from './tool-icon.js';
|
|
9
10
|
import type { ToolCompactPresentation } from './tool.js';
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -80,6 +81,8 @@ export interface ToolInfo {
|
|
|
80
81
|
readonly description: string;
|
|
81
82
|
/** Compact presentation hint (plain data - crosses the wire intact). */
|
|
82
83
|
readonly compact?: ToolCompactPresentation;
|
|
84
|
+
/** Declared icon, so a surface renders the tool's own choice, not a guess. */
|
|
85
|
+
readonly icon?: ToolIcon;
|
|
83
86
|
}
|
|
84
87
|
|
|
85
88
|
/** Serializable skill metadata. */
|
package/src/signature.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createPublicKey, verify as cryptoVerify } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Ed25519 detached-signature verification over exact bytes.
|
|
5
|
+
*
|
|
6
|
+
* Lives in the SDK rather than beside its first caller because two different
|
|
7
|
+
* things now depend on it, and one of them is policy. Policy has to bind on a
|
|
8
|
+
* machine with no plugins installed at all, so the verifier it uses cannot sit
|
|
9
|
+
* inside a plugin: a control that a user can disable by uninstalling something
|
|
10
|
+
* is not a control.
|
|
11
|
+
*
|
|
12
|
+
* Verifies over the bytes as received, never over a re-serialisation of the
|
|
13
|
+
* parsed object. Canonicalising first would let two different documents share a
|
|
14
|
+
* signature whenever the parser and the serialiser disagree about anything,
|
|
15
|
+
* which is how signature-verification bypasses usually happen.
|
|
16
|
+
*
|
|
17
|
+
* Returns false rather than throwing on a malformed key or signature: to a
|
|
18
|
+
* caller "this did not verify" and "this could not be parsed" warrant the same
|
|
19
|
+
* response, and a throw here would tempt a try/catch that swallows both into a
|
|
20
|
+
* success path.
|
|
21
|
+
*/
|
|
22
|
+
export function verifyEd25519(
|
|
23
|
+
bytes: Uint8Array,
|
|
24
|
+
signatureB64: string,
|
|
25
|
+
publicKeyPem: string,
|
|
26
|
+
): boolean {
|
|
27
|
+
if (!publicKeyPem || !signatureB64) return false;
|
|
28
|
+
try {
|
|
29
|
+
const key = createPublicKey(publicKeyPem);
|
|
30
|
+
return cryptoVerify(null, Buffer.from(bytes), key, Buffer.from(signatureB64, 'base64'));
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { TOOL_ICONS, isToolIcon } from './tool-icon.js';
|
|
3
|
+
import { defineTool } from './define.js';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
|
|
6
|
+
describe('tool icons', () => {
|
|
7
|
+
it('is a closed vocabulary, so a surface can map it exhaustively', () => {
|
|
8
|
+
expect(isToolIcon('terminal')).toBe(true);
|
|
9
|
+
// The whole reason the field is not a free string: a name no surface owns
|
|
10
|
+
// would render as nothing anywhere.
|
|
11
|
+
expect(isToolIcon('sparkles-v2')).toBe(false);
|
|
12
|
+
expect(isToolIcon('')).toBe(false);
|
|
13
|
+
expect(isToolIcon(undefined)).toBe(false);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('has no duplicates, which would make a surface map ambiguous', () => {
|
|
17
|
+
expect(new Set(TOOL_ICONS).size).toBe(TOOL_ICONS.length);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('always offers a neutral choice for a tool that fits nothing else', () => {
|
|
21
|
+
expect(TOOL_ICONS).toContain('wrench');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('carries a declared icon through defineTool onto the def', () => {
|
|
25
|
+
const tool = defineTool({
|
|
26
|
+
name: 'thing',
|
|
27
|
+
description: 'does a thing',
|
|
28
|
+
icon: 'workflow',
|
|
29
|
+
inputSchema: z.object({}),
|
|
30
|
+
handler: () => undefined,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
expect(tool.icon).toBe('workflow');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('leaves icon undefined when a tool does not declare one', () => {
|
|
37
|
+
const tool = defineTool({
|
|
38
|
+
name: 'thing',
|
|
39
|
+
description: 'does a thing',
|
|
40
|
+
inputSchema: z.object({}),
|
|
41
|
+
handler: () => undefined,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
expect(tool.icon).toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
});
|
package/src/tool-icon.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a tool looks like, as a semantic name rather than a glyph.
|
|
3
|
+
*
|
|
4
|
+
* A CLOSED vocabulary on purpose. Surfaces render wildly differently: the
|
|
5
|
+
* desktop draws SVG, the TUI prints a character, a future web surface might do
|
|
6
|
+
* something else again. If this were a free string, a plugin could name an icon
|
|
7
|
+
* no surface owns and every surface would have to guess, so in practice
|
|
8
|
+
* everything would fall back and the field would be decorative. A fixed set
|
|
9
|
+
* means each surface maps it exhaustively and a plugin's choice actually
|
|
10
|
+
* survives to the screen.
|
|
11
|
+
*
|
|
12
|
+
* The names describe WHAT THE TOOL TOUCHES, not what it looks like, so a
|
|
13
|
+
* surface is free to change its artwork without every plugin becoming wrong.
|
|
14
|
+
*
|
|
15
|
+
* Adding a member is a breaking change for surfaces (their maps stop being
|
|
16
|
+
* exhaustive and stop compiling), which is the intended cost: a new icon should
|
|
17
|
+
* be a deliberate decision across surfaces, not a silent fallback to a wrench.
|
|
18
|
+
*/
|
|
19
|
+
export const TOOL_ICONS = [
|
|
20
|
+
'file',
|
|
21
|
+
'folder',
|
|
22
|
+
'search',
|
|
23
|
+
'edit',
|
|
24
|
+
'diff',
|
|
25
|
+
'terminal',
|
|
26
|
+
'globe',
|
|
27
|
+
'chat',
|
|
28
|
+
'workflow',
|
|
29
|
+
'agent',
|
|
30
|
+
'settings',
|
|
31
|
+
'lock',
|
|
32
|
+
'plug',
|
|
33
|
+
'mic',
|
|
34
|
+
'speaker',
|
|
35
|
+
'smartphone',
|
|
36
|
+
'workspace',
|
|
37
|
+
'clipboard',
|
|
38
|
+
'spark',
|
|
39
|
+
'wrench',
|
|
40
|
+
] as const;
|
|
41
|
+
|
|
42
|
+
export type ToolIcon = (typeof TOOL_ICONS)[number];
|
|
43
|
+
|
|
44
|
+
export function isToolIcon(value: unknown): value is ToolIcon {
|
|
45
|
+
return typeof value === 'string' && (TOOL_ICONS as ReadonlyArray<string>).includes(value);
|
|
46
|
+
}
|
package/src/tool.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { z } from 'zod';
|
|
2
2
|
import type { EventLogReader } from './log.js';
|
|
3
|
+
import type { ToolIcon } from './tool-icon.js';
|
|
3
4
|
import type { PermissionRule } from './permission.js';
|
|
4
5
|
import type { SessionId, ToolCallId, TurnId } from './ids.js';
|
|
5
6
|
import type { SubagentSpawner } from './subagent.js';
|
|
@@ -191,6 +192,13 @@ export interface ToolDef {
|
|
|
191
192
|
readonly handler: (input: unknown, ctx: ToolContext) => Promise<unknown> | unknown;
|
|
192
193
|
/** Opt-in presentation hint. See `ToolCompactPresentation`. */
|
|
193
194
|
readonly compact?: ToolCompactPresentation;
|
|
195
|
+
/**
|
|
196
|
+
* What this tool touches, so surfaces can show it. Without this a surface can
|
|
197
|
+
* only guess from the NAME, which works for the handful of built-ins it was
|
|
198
|
+
* written against and gives every plugin-contributed tool the same generic
|
|
199
|
+
* fallback. See {@link ToolIcon}.
|
|
200
|
+
*/
|
|
201
|
+
readonly icon?: ToolIcon;
|
|
194
202
|
/**
|
|
195
203
|
* Optional capability declaration. Advisory unless the user enables
|
|
196
204
|
* `@moxxy/plugin-security`, at which point the active `Isolator`
|