@moxxy/sdk 0.32.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/mode/react-loop.d.ts.map +1 -1
- package/dist/mode/react-loop.js +10 -1
- package/dist/mode/react-loop.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 +9 -2
- 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/mode/react-loop.ts +10 -1
- 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/fs-utils.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { chmodSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { chmodSync, mkdirSync, promises as fsp, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { chmod, mkdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { dirname, join } from 'node:path';
|
|
@@ -11,6 +11,19 @@ export interface WriteFileAtomicOptions {
|
|
|
11
11
|
readonly encoding?: BufferEncoding;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* The private-path helpers below dispatch through `fs.promises` rather than
|
|
16
|
+
* destructured `node:fs/promises` bindings so the framework keeps ONE mockable
|
|
17
|
+
* filesystem boundary: a destructured binding is captured at import time and no
|
|
18
|
+
* longer observable, which silently blinds the regression guards that count
|
|
19
|
+
* `fs.open` / `fs.mkdir` calls (persistence.ready.test.ts).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Owner-only directory: no group/other bits, so nobody else can even traverse in. */
|
|
23
|
+
export const PRIVATE_DIR_MODE = 0o700;
|
|
24
|
+
/** Owner-only file. */
|
|
25
|
+
export const PRIVATE_FILE_MODE = 0o600;
|
|
26
|
+
|
|
14
27
|
/**
|
|
15
28
|
* Crash-atomic file write: write a unique sibling temp file, then `rename` it
|
|
16
29
|
* over the target. POSIX `rename` is atomic on the same filesystem, so a crash
|
|
@@ -84,3 +97,109 @@ export function moxxyHome(): string {
|
|
|
84
97
|
export function moxxyPath(...segments: string[]): string {
|
|
85
98
|
return join(moxxyHome(), ...segments);
|
|
86
99
|
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* POSIX permission bits are meaningless on Windows (`chmod` only toggles the
|
|
103
|
+
* read-only flag; access is governed by ACLs), so the tightening helpers below
|
|
104
|
+
* are no-ops there rather than pretending to enforce something they cannot.
|
|
105
|
+
*/
|
|
106
|
+
const POSIX_PERMS = process.platform !== 'win32';
|
|
107
|
+
|
|
108
|
+
/** Group/other bits. Their presence is what makes a path readable by another
|
|
109
|
+
* local account, and is the only condition worth a `chmod` syscall. */
|
|
110
|
+
const GROUP_OTHER = 0o077;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* `mkdir -p` a directory that must stay owner-only, tightening it when it
|
|
114
|
+
* already exists with looser bits.
|
|
115
|
+
*
|
|
116
|
+
* The tightening pass is what makes this safe to adopt mid-life: installs
|
|
117
|
+
* created before this was enforced have a `0755` directory on disk, and
|
|
118
|
+
* `mkdir` is a no-op on an existing path, so mode alone would never fix them.
|
|
119
|
+
*
|
|
120
|
+
* Best-effort by design: a permission error (a directory owned by another user,
|
|
121
|
+
* an exotic filesystem, a network mount) must never take down a boot whose only
|
|
122
|
+
* problem is that a chmod did not stick. Directory CREATION still throws, since
|
|
123
|
+
* failing to create the state directory is a real failure.
|
|
124
|
+
*/
|
|
125
|
+
export async function ensurePrivateDir(dir: string): Promise<void> {
|
|
126
|
+
await fsp.mkdir(dir, { recursive: true, mode: PRIVATE_DIR_MODE });
|
|
127
|
+
await tightenIfLoose(dir, PRIVATE_DIR_MODE);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Create-if-absent a file that must stay owner-only, tightening it when it
|
|
132
|
+
* already exists with looser bits. `open(path, 'a')` creates with
|
|
133
|
+
* `0o666 & ~umask` (typically `0644`), so an append-only log needs the mode
|
|
134
|
+
* applied explicitly and retroactively.
|
|
135
|
+
*/
|
|
136
|
+
export async function ensurePrivateFile(file: string): Promise<void> {
|
|
137
|
+
const handle = await fsp.open(file, 'a', PRIVATE_FILE_MODE);
|
|
138
|
+
await handle.close();
|
|
139
|
+
await tightenIfLoose(file, PRIVATE_FILE_MODE);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function tightenIfLoose(target: string, mode: number): Promise<void> {
|
|
143
|
+
if (!POSIX_PERMS) return;
|
|
144
|
+
try {
|
|
145
|
+
const st = await fsp.stat(target);
|
|
146
|
+
if ((st.mode & GROUP_OTHER) === 0) return;
|
|
147
|
+
await fsp.chmod(target, mode);
|
|
148
|
+
} catch {
|
|
149
|
+
/* best-effort: never fail a boot over a chmod */
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The temp-file shape {@link writeFileAtomic} renames from:
|
|
155
|
+
* `<target>.<pid>.<uuid>.tmp`. Anchored, and specific enough that a user file
|
|
156
|
+
* that merely ends in `.tmp` can never match.
|
|
157
|
+
*/
|
|
158
|
+
const ATOMIC_TMP = /\.\d+\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.tmp$/i;
|
|
159
|
+
|
|
160
|
+
/** Age past which an atomic-write temp file is certainly abandoned. A live
|
|
161
|
+
* write is sub-second; anything a day old outlived the process that made it. */
|
|
162
|
+
const STALE_TMP_AGE_MS = 24 * 60 * 60 * 1000;
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Delete abandoned {@link writeFileAtomic} temp files in `dirs`.
|
|
166
|
+
*
|
|
167
|
+
* `writeFileAtomic` unlinks its temp file when the write throws, but it cannot
|
|
168
|
+
* when the process is `SIGKILL`ed mid-write, so residue accumulates for the
|
|
169
|
+
* life of the install (six such files, the oldest a month old, prompted this).
|
|
170
|
+
*
|
|
171
|
+
* Two properties keep this safe against a CONCURRENT moxxy process writing to
|
|
172
|
+
* the same directory: only the exact `.<pid>.<uuid>.tmp` shape is considered,
|
|
173
|
+
* and only entries older than {@link STALE_TMP_AGE_MS}. A temp file that is
|
|
174
|
+
* still live is milliseconds old and can never be selected.
|
|
175
|
+
*
|
|
176
|
+
* Non-recursive and fully best-effort: it returns the number removed and never
|
|
177
|
+
* throws, so callers can fire it detached at boot without guarding it.
|
|
178
|
+
*/
|
|
179
|
+
export async function pruneStaleTempFiles(
|
|
180
|
+
dirs: ReadonlyArray<string>,
|
|
181
|
+
now = Date.now(),
|
|
182
|
+
): Promise<number> {
|
|
183
|
+
let removed = 0;
|
|
184
|
+
for (const dir of dirs) {
|
|
185
|
+
let names: string[];
|
|
186
|
+
try {
|
|
187
|
+
names = await fsp.readdir(dir);
|
|
188
|
+
} catch {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
for (const name of names) {
|
|
192
|
+
if (!ATOMIC_TMP.test(name)) continue;
|
|
193
|
+
const full = join(dir, name);
|
|
194
|
+
try {
|
|
195
|
+
const st = await fsp.stat(full);
|
|
196
|
+
if (now - st.mtimeMs < STALE_TMP_AGE_MS) continue;
|
|
197
|
+
await fsp.rm(full, { force: true });
|
|
198
|
+
removed++;
|
|
199
|
+
} catch {
|
|
200
|
+
/* raced with another pruner, or not ours to delete */
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return removed;
|
|
205
|
+
}
|
package/src/hooks.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { EventLogReader } from './log.js';
|
|
|
3
3
|
import type { PendingToolCall } from './permission.js';
|
|
4
4
|
import type { ProviderRequest } from './provider.js';
|
|
5
5
|
import type { SessionId, TurnId } from './ids.js';
|
|
6
|
+
import type { Principal } from './principal.js';
|
|
6
7
|
import type { ServiceRegistry } from './services.js';
|
|
7
8
|
|
|
8
9
|
export interface AppContext {
|
|
@@ -10,6 +11,13 @@ export interface AppContext {
|
|
|
10
11
|
readonly cwd: string;
|
|
11
12
|
readonly log: EventLogReader;
|
|
12
13
|
readonly env: Readonly<Record<string, string | undefined>>;
|
|
14
|
+
/**
|
|
15
|
+
* Who the session is attributed to, when the surface could establish it.
|
|
16
|
+
* This is what lets a policy or audit hook answer "who is asking" instead of
|
|
17
|
+
* only "what is being asked". Absent means unattributed: a hook that gates on
|
|
18
|
+
* identity must fail closed on `undefined` rather than assuming an admin.
|
|
19
|
+
*/
|
|
20
|
+
readonly actor?: Principal;
|
|
13
21
|
/**
|
|
14
22
|
* Inter-plugin service registry — publish a service in `onInit` for sibling
|
|
15
23
|
* plugins to consume in theirs. See {@link ServiceRegistry}.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { hostCredentialName } from './secret-provider.js';
|
|
3
|
+
|
|
4
|
+
describe('hostCredentialName', () => {
|
|
5
|
+
it('upper-snakes a host, mirroring the provider-key convention', () => {
|
|
6
|
+
expect(hostCredentialName('github.example.com')).toBe('MOXXY_CREDENTIAL_GITHUB_EXAMPLE_COM');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('accepts a full URL and uses only its host', () => {
|
|
10
|
+
expect(hostCredentialName('https://registry.example.internal/moxxy/index.json')).toBe(
|
|
11
|
+
'MOXXY_CREDENTIAL_REGISTRY_EXAMPLE_INTERNAL',
|
|
12
|
+
);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// A credential belongs to the host, not to a socket, so the same secret
|
|
16
|
+
// serves the mirror whether or not the URL happens to name a port.
|
|
17
|
+
it('ignores the port', () => {
|
|
18
|
+
expect(hostCredentialName('registry.internal:8443')).toBe(hostCredentialName('registry.internal'));
|
|
19
|
+
expect(hostCredentialName('https://registry.internal:8443/x')).toBe(
|
|
20
|
+
hostCredentialName('registry.internal'),
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('collapses runs of non-alphanumerics and trims the edges', () => {
|
|
25
|
+
expect(hostCredentialName('my--host..internal')).toBe('MOXXY_CREDENTIAL_MY_HOST_INTERNAL');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('handles an IPv6 literal', () => {
|
|
29
|
+
expect(hostCredentialName('https://[::1]:9000/x')).toBe('MOXXY_CREDENTIAL_1');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Deriving a name from garbage would silently look up the wrong secret, which
|
|
33
|
+
// reads as "no credential configured" rather than as the typo it is.
|
|
34
|
+
it('returns null rather than a name derived from nonsense', () => {
|
|
35
|
+
for (const bad of ['', ' ', 'not a host', 'https://', '://x']) {
|
|
36
|
+
expect(hostCredentialName(bad)).toBeNull();
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -42,6 +42,50 @@ export type {
|
|
|
42
42
|
|
|
43
43
|
export type { EventLogReader } from './log.js';
|
|
44
44
|
|
|
45
|
+
// Identity. The type + pure helpers ride the main barrel; the OS resolver needs
|
|
46
|
+
// `node:os` and is exported from `@moxxy/sdk/server`.
|
|
47
|
+
export type { Principal } from './principal.js';
|
|
48
|
+
export { samePrincipal, formatPrincipal } from './principal.js';
|
|
49
|
+
|
|
50
|
+
export { verifyEd25519 } from './signature.js';
|
|
51
|
+
|
|
52
|
+
export { TOOL_ICONS, isToolIcon, type ToolIcon } from './tool-icon.js';
|
|
53
|
+
|
|
54
|
+
export type {
|
|
55
|
+
AuditExporterDef,
|
|
56
|
+
AuditExportBatch,
|
|
57
|
+
AuditExportContext,
|
|
58
|
+
AuditExportResource,
|
|
59
|
+
} from './audit-export.js';
|
|
60
|
+
export { defineAuditExporter } from './audit-export.js';
|
|
61
|
+
|
|
62
|
+
// Audit trail: the tamper-evident receipt, distinct from the event log.
|
|
63
|
+
export type {
|
|
64
|
+
AuditAction,
|
|
65
|
+
AuditRecord,
|
|
66
|
+
AuditSinkDef,
|
|
67
|
+
AuditSinkScope,
|
|
68
|
+
AuditSinkSession,
|
|
69
|
+
UnchainedAuditRecord,
|
|
70
|
+
} from './audit.js';
|
|
71
|
+
export { defineAuditSink, auditActionOf } from './audit.js';
|
|
72
|
+
|
|
73
|
+
// Where a named secret comes from (external store instead of the local vault).
|
|
74
|
+
export type {
|
|
75
|
+
SecretProviderDef,
|
|
76
|
+
SecretProviderScope,
|
|
77
|
+
SecretProviderSession,
|
|
78
|
+
} from './secret-provider.js';
|
|
79
|
+
export { defineSecretProvider, hostCredentialName } from './secret-provider.js';
|
|
80
|
+
|
|
81
|
+
// Secret redaction for anything leaving the process.
|
|
82
|
+
export {
|
|
83
|
+
isSecretKey,
|
|
84
|
+
redactSecrets,
|
|
85
|
+
redactSecretText,
|
|
86
|
+
REDACTED_PLACEHOLDER,
|
|
87
|
+
} from './redact.js';
|
|
88
|
+
|
|
45
89
|
export type {
|
|
46
90
|
RunTurnOptions,
|
|
47
91
|
SessionLogReader,
|
|
@@ -217,6 +261,11 @@ export {
|
|
|
217
261
|
type ResolvedProviderTools,
|
|
218
262
|
} from './provider-tool-utils.js';
|
|
219
263
|
export type { WriteFileAtomicOptions } from './fs-utils.js';
|
|
264
|
+
export type {
|
|
265
|
+
EgressProxySettings,
|
|
266
|
+
EgressStatus,
|
|
267
|
+
InstallEgressProxyOptions,
|
|
268
|
+
} from './egress.js';
|
|
220
269
|
export type { ChannelRunStatus } from './channel-status.js';
|
|
221
270
|
export type { CrossProcessFireLock, CrossProcessFireLockOptions } from './cross-process-lock.js';
|
|
222
271
|
export { createMutex, type Mutex } from './mutex.js';
|
package/src/mode/react-loop.ts
CHANGED
|
@@ -260,7 +260,16 @@ export async function* runReactLoop(
|
|
|
260
260
|
return;
|
|
261
261
|
}
|
|
262
262
|
|
|
263
|
-
|
|
263
|
+
// A text-only provider cannot call `load_skill`. Advertising the lazy skill
|
|
264
|
+
// index anyway makes agent-shaped transports (notably Claude Code CLI)
|
|
265
|
+
// imitate the unavailable call as plain assistant text and then end the
|
|
266
|
+
// turn. Keep the base prompt, but only advertise lazy skills when the
|
|
267
|
+
// selected model can actually execute the loader. Unknown/dynamic model ids
|
|
268
|
+
// retain the historical tool-capable behavior, matching collect-stream's
|
|
269
|
+
// conservative capability fallback.
|
|
270
|
+
const descriptor = ctx.provider.models.find((candidate) => candidate.id === ctx.model);
|
|
271
|
+
const availableSkills = descriptor?.supportsTools === false ? [] : ctx.skills.list();
|
|
272
|
+
const systemPrompt = buildSystemPromptWithSkills(ctx.systemPrompt, availableSkills);
|
|
264
273
|
const { messages, stablePrefixIndex } = projectMessages(ctx, {
|
|
265
274
|
...(systemPrompt ? { systemPrompt } : {}),
|
|
266
275
|
...(volatileText ? { trailingUserText: volatileText } : {}),
|
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
|
+
}
|