@senad-d/observme 0.1.4 → 0.1.5
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 +35 -0
- package/README.md +24 -8
- package/docs/agent-subagent-observability-requirements.md +4 -3
- package/docs/compatibility-matrix.md +11 -2
- package/docs/configuration.md +2 -2
- package/docs/extension-integration.md +20 -13
- package/docs/reference/03-pi-event-and-session-model.md +6 -6
- package/docs/reference/04-telemetry-semantic-conventions.md +1 -0
- package/docs/reference/06-security-privacy-redaction.md +13 -6
- package/docs/reference/08-query-grafana-integration.md +30 -7
- package/docs/reference/11-deployment-runbooks.md +21 -0
- package/docs/reference/12-configuration-reference.md +30 -4
- package/examples/integrations/subagent-runner.ts +8 -5
- package/examples/observme.yaml +2 -2
- package/package.json +12 -4
- package/src/commands/obs-agents.ts +17 -12
- package/src/commands/obs-backfill.ts +2 -2
- package/src/commands/obs-command-support.ts +2 -1
- package/src/commands/obs-cost.ts +15 -7
- package/src/commands/obs-health.ts +22 -2
- package/src/commands/obs-loki-summary.ts +4 -8
- package/src/commands/obs-session.ts +35 -28
- package/src/commands/obs-status.ts +56 -13
- package/src/commands/obs-tools.ts +19 -8
- package/src/commands/obs-trace.ts +9 -0
- package/src/commands/obs.ts +6 -1
- package/src/config/bootstrap-project-config.ts +49 -32
- package/src/config/defaults.ts +0 -2
- package/src/config/load-config.ts +270 -92
- package/src/config/project-paths.ts +318 -6
- package/src/config/query-limits.ts +10 -0
- package/src/config/schema.ts +9 -8
- package/src/config/transport-security.ts +107 -0
- package/src/config/validate.ts +68 -11
- package/src/extension.ts +2 -12
- package/src/integration.ts +6 -2
- package/src/otel/logs.ts +24 -13
- package/src/otel/metrics.ts +24 -13
- package/src/otel/otlp-endpoint.ts +41 -2
- package/src/otel/otlp-http-options.ts +11 -0
- package/src/otel/sdk.ts +155 -9
- package/src/otel/shutdown.ts +39 -11
- package/src/otel/traces.ts +34 -16
- package/src/pi/agent-tree-tracker.ts +14 -1
- package/src/pi/compatibility.ts +121 -0
- package/src/pi/event-handlers/agent-turn.ts +16 -9
- package/src/pi/event-handlers/lifecycle.ts +207 -75
- package/src/pi/event-handlers/llm.ts +17 -9
- package/src/pi/event-handlers/session-events.ts +53 -19
- package/src/pi/event-handlers/tool-bash.ts +21 -27
- package/src/pi/handler-internals.ts +16 -26
- package/src/pi/handler-runtime.ts +138 -51
- package/src/pi/handler-types.ts +30 -15
- package/src/pi/handlers.ts +12 -1
- package/src/pi/integration-api.ts +27 -15
- package/src/pi/session-correlation.ts +167 -0
- package/src/pi/subagent-spawn.ts +164 -31
- package/src/privacy/redact.ts +574 -68
- package/src/privacy/secret-patterns.ts +6 -1
- package/src/query/grafana-readiness.ts +18 -2
- package/src/query/grafana-transport.ts +150 -27
- package/src/query/grafana-url.ts +36 -0
- package/src/query/grafana.ts +4 -136
- package/src/query/loki.ts +2 -4
- package/src/query/prometheus.ts +8 -10
- package/src/query/tempo.ts +2 -4
- package/src/query/trace-link.ts +186 -0
- package/src/safety/display-bounds.ts +84 -0
- package/src/semconv/metrics.ts +1 -0
- package/src/semconv/values.ts +2 -0
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import type { BigIntStats } from "node:fs";
|
|
3
|
+
import { lstat, mkdir, open, realpath, stat, unlink } from "node:fs/promises";
|
|
4
|
+
import type { FileHandle } from "node:fs/promises";
|
|
5
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
2
6
|
|
|
3
7
|
export interface ProjectLocalFilePathOptions {
|
|
4
8
|
readonly cwd?: string;
|
|
@@ -9,17 +13,107 @@ export interface ProjectLocalFilePathOptions {
|
|
|
9
13
|
readonly inputLabel: string;
|
|
10
14
|
}
|
|
11
15
|
|
|
16
|
+
export interface ProjectLocalFileOperationHooks {
|
|
17
|
+
readonly beforeOpen?: () => Promise<void> | void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type ExclusiveProjectLocalFileCreateStatus = "created" | "exists";
|
|
21
|
+
|
|
22
|
+
interface FileIdentity {
|
|
23
|
+
readonly device: bigint;
|
|
24
|
+
readonly inode: bigint;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface CanonicalProjectPathSnapshot {
|
|
28
|
+
readonly rootPath: string;
|
|
29
|
+
readonly canonicalRootPath: string;
|
|
30
|
+
readonly canonicalCandidatePath: string;
|
|
31
|
+
readonly rootIdentity: FileIdentity;
|
|
32
|
+
readonly inputLabel: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
12
35
|
const parentSegment = "..";
|
|
36
|
+
const unsafeProjectPathErrorCode = "OBSERVME_UNSAFE_PROJECT_PATH";
|
|
13
37
|
|
|
14
38
|
export function resolveProjectLocalFilePath(options: ProjectLocalFilePathOptions): string {
|
|
15
39
|
const root = resolve(options.cwd ?? process.cwd());
|
|
16
40
|
const candidatePath = resolveProjectLocalCandidatePath(root, options);
|
|
17
41
|
|
|
18
|
-
|
|
42
|
+
assertProjectContainment(root, candidatePath, options.inputLabel);
|
|
19
43
|
if (shouldPreserveRelativeOverride(options)) return options.overridePath!;
|
|
20
44
|
return candidatePath;
|
|
21
45
|
}
|
|
22
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Project-local I/O policy: lexical paths and their canonical targets must remain
|
|
49
|
+
* inside one stable canonical root. Safe in-root symlinks are pinned to their
|
|
50
|
+
* canonical target, missing final paths are supported, and an opened file's
|
|
51
|
+
* device/inode identity is verified before bytes are read or written.
|
|
52
|
+
*/
|
|
53
|
+
export async function resolveCanonicalProjectLocalFilePath(
|
|
54
|
+
options: ProjectLocalFilePathOptions,
|
|
55
|
+
): Promise<string> {
|
|
56
|
+
return (await createCanonicalProjectPathSnapshot(options)).canonicalCandidatePath;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function readCanonicalProjectLocalFileText(
|
|
60
|
+
options: ProjectLocalFilePathOptions,
|
|
61
|
+
hooks: ProjectLocalFileOperationHooks = {},
|
|
62
|
+
): Promise<string | undefined> {
|
|
63
|
+
const snapshot = await createCanonicalProjectPathSnapshot(options);
|
|
64
|
+
await hooks.beforeOpen?.();
|
|
65
|
+
|
|
66
|
+
let fileHandle: FileHandle | undefined;
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
fileHandle = await openProjectFileForRead(snapshot);
|
|
70
|
+
if (!fileHandle) return undefined;
|
|
71
|
+
await assertOpenedProjectFileIsStable(snapshot, fileHandle);
|
|
72
|
+
return await fileHandle.readFile({ encoding: "utf8" });
|
|
73
|
+
} finally {
|
|
74
|
+
await closeFileHandle(fileHandle);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function createCanonicalProjectLocalFileExclusively(
|
|
79
|
+
options: ProjectLocalFilePathOptions,
|
|
80
|
+
content: string,
|
|
81
|
+
hooks: ProjectLocalFileOperationHooks = {},
|
|
82
|
+
): Promise<ExclusiveProjectLocalFileCreateStatus> {
|
|
83
|
+
const initialSnapshot = await createCanonicalProjectPathSnapshot(options);
|
|
84
|
+
await mkdir(dirname(initialSnapshot.canonicalCandidatePath), { recursive: true });
|
|
85
|
+
const snapshot = await createCanonicalProjectPathSnapshot(options);
|
|
86
|
+
await hooks.beforeOpen?.();
|
|
87
|
+
|
|
88
|
+
let fileHandle: FileHandle | undefined;
|
|
89
|
+
let openedIdentity: FileIdentity | undefined;
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
fileHandle = await openProjectFileForExclusiveCreate(snapshot);
|
|
93
|
+
if (!fileHandle) {
|
|
94
|
+
await assertExistingProjectFileIsSafe(options);
|
|
95
|
+
return "exists";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
openedIdentity = toFileIdentity(await fileHandle.stat({ bigint: true }));
|
|
99
|
+
await assertOpenedProjectFileIsStable(snapshot, fileHandle);
|
|
100
|
+
await fileHandle.writeFile(content, { encoding: "utf8" });
|
|
101
|
+
await assertOpenedProjectFileIsStable(snapshot, fileHandle);
|
|
102
|
+
return "created";
|
|
103
|
+
} catch (error) {
|
|
104
|
+
await closeFileHandle(fileHandle);
|
|
105
|
+
fileHandle = undefined;
|
|
106
|
+
if (openedIdentity) await removeCreatedFileIfIdentityMatches(snapshot, openedIdentity);
|
|
107
|
+
throw error;
|
|
108
|
+
} finally {
|
|
109
|
+
await closeFileHandle(fileHandle);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function isUnsafeProjectPathError(error: unknown): boolean {
|
|
114
|
+
return isErrorWithCode(error) && error.code === unsafeProjectPathErrorCode;
|
|
115
|
+
}
|
|
116
|
+
|
|
23
117
|
function resolveProjectLocalCandidatePath(root: string, options: ProjectLocalFilePathOptions): string {
|
|
24
118
|
if (options.overridePath !== undefined) return resolveOverridePath(root, options.overridePath, options.inputLabel);
|
|
25
119
|
return resolveConfigDirPath(root, options);
|
|
@@ -30,16 +124,16 @@ function shouldPreserveRelativeOverride(options: ProjectLocalFilePathOptions): b
|
|
|
30
124
|
}
|
|
31
125
|
|
|
32
126
|
function resolveOverridePath(root: string, overridePath: string, inputLabel: string): string {
|
|
33
|
-
if (isRelativeTraversalPath(overridePath)) throw
|
|
127
|
+
if (isRelativeTraversalPath(overridePath)) throw createUnsafeProjectPathError(inputLabel);
|
|
34
128
|
return isAbsolute(overridePath) ? resolve(overridePath) : resolve(root, overridePath);
|
|
35
129
|
}
|
|
36
130
|
|
|
37
131
|
function resolveConfigDirPath(root: string, options: ProjectLocalFilePathOptions): string {
|
|
38
132
|
const configDirName = options.configDirName ?? options.defaultConfigDirName;
|
|
39
133
|
|
|
40
|
-
if (!configDirName) throw
|
|
41
|
-
if (isAbsolute(configDirName)) throw
|
|
42
|
-
if (hasParentSegment(configDirName)) throw
|
|
134
|
+
if (!configDirName) throw createUnsafeProjectPathError(options.inputLabel);
|
|
135
|
+
if (isAbsolute(configDirName)) throw createUnsafeProjectPathError(options.inputLabel);
|
|
136
|
+
if (hasParentSegment(configDirName)) throw createUnsafeProjectPathError(options.inputLabel);
|
|
43
137
|
return resolve(root, join(configDirName, options.fileName));
|
|
44
138
|
}
|
|
45
139
|
|
|
@@ -51,11 +145,229 @@ function hasParentSegment(pathInput: string): boolean {
|
|
|
51
145
|
return pathInput.split(/[\\/]+/u).includes(parentSegment);
|
|
52
146
|
}
|
|
53
147
|
|
|
148
|
+
function assertProjectContainment(root: string, candidatePath: string, inputLabel: string): void {
|
|
149
|
+
if (!isPathInsideOrSame(root, candidatePath)) throw createUnsafeProjectPathError(inputLabel);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function createCanonicalProjectPathSnapshot(
|
|
153
|
+
options: ProjectLocalFilePathOptions,
|
|
154
|
+
): Promise<CanonicalProjectPathSnapshot> {
|
|
155
|
+
try {
|
|
156
|
+
const rootPath = resolve(options.cwd ?? process.cwd());
|
|
157
|
+
const candidatePath = resolveProjectLocalCandidatePath(rootPath, options);
|
|
158
|
+
assertProjectContainment(rootPath, candidatePath, options.inputLabel);
|
|
159
|
+
|
|
160
|
+
const canonicalRootPath = await realpath(rootPath);
|
|
161
|
+
const rootStats = await stat(canonicalRootPath, { bigint: true });
|
|
162
|
+
if (!rootStats.isDirectory()) throw createUnsafeProjectPathError(options.inputLabel);
|
|
163
|
+
|
|
164
|
+
const canonicalCandidatePath = await resolveCanonicalPathWithMissingComponents(candidatePath);
|
|
165
|
+
assertProjectContainment(canonicalRootPath, canonicalCandidatePath, options.inputLabel);
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
rootPath,
|
|
169
|
+
canonicalRootPath,
|
|
170
|
+
canonicalCandidatePath,
|
|
171
|
+
rootIdentity: toFileIdentity(rootStats),
|
|
172
|
+
inputLabel: options.inputLabel,
|
|
173
|
+
};
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (isUnsafeProjectPathError(error)) throw error;
|
|
176
|
+
throw createUnsafeProjectPathError(options.inputLabel);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function openProjectFileForRead(snapshot: CanonicalProjectPathSnapshot): Promise<FileHandle | undefined> {
|
|
181
|
+
try {
|
|
182
|
+
return await open(snapshot.canonicalCandidatePath, createReadOpenFlags());
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (!isMissingPathError(error) && !isNotDirectoryError(error)) {
|
|
185
|
+
if (isSymlinkLoopError(error)) throw createUnsafeProjectPathError(snapshot.inputLabel);
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
await assertMissingProjectPathIsSafe(snapshot);
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function openProjectFileForExclusiveCreate(
|
|
195
|
+
snapshot: CanonicalProjectPathSnapshot,
|
|
196
|
+
): Promise<FileHandle | undefined> {
|
|
197
|
+
try {
|
|
198
|
+
return await open(snapshot.canonicalCandidatePath, createExclusiveWriteOpenFlags(), 0o600);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
if (isFileAlreadyExistsError(error)) return undefined;
|
|
201
|
+
if (isSymlinkLoopError(error)) throw createUnsafeProjectPathError(snapshot.inputLabel);
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function createReadOpenFlags(): number {
|
|
207
|
+
return constants.O_RDONLY | optionalOpenFlag(constants.O_NOFOLLOW) | optionalOpenFlag(constants.O_NONBLOCK);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function createExclusiveWriteOpenFlags(): number {
|
|
211
|
+
return constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | optionalOpenFlag(constants.O_NOFOLLOW);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function optionalOpenFlag(flag: number | undefined): number {
|
|
215
|
+
return typeof flag === "number" ? flag : 0;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function assertOpenedProjectFileIsStable(
|
|
219
|
+
snapshot: CanonicalProjectPathSnapshot,
|
|
220
|
+
fileHandle: FileHandle,
|
|
221
|
+
): Promise<void> {
|
|
222
|
+
try {
|
|
223
|
+
await assertCanonicalRootIsStable(snapshot);
|
|
224
|
+
const currentCanonicalPath = await realpath(snapshot.canonicalCandidatePath);
|
|
225
|
+
assertProjectContainment(snapshot.canonicalRootPath, currentCanonicalPath, snapshot.inputLabel);
|
|
226
|
+
|
|
227
|
+
const openedStats = await fileHandle.stat({ bigint: true });
|
|
228
|
+
const currentPathStats = await stat(currentCanonicalPath, { bigint: true });
|
|
229
|
+
if (!openedStats.isFile() || !currentPathStats.isFile()) {
|
|
230
|
+
throw createUnsafeProjectPathError(snapshot.inputLabel);
|
|
231
|
+
}
|
|
232
|
+
if (!hasSameFileIdentity(openedStats, currentPathStats)) {
|
|
233
|
+
throw createUnsafeProjectPathError(snapshot.inputLabel);
|
|
234
|
+
}
|
|
235
|
+
} catch (error) {
|
|
236
|
+
if (isUnsafeProjectPathError(error)) throw error;
|
|
237
|
+
throw createUnsafeProjectPathError(snapshot.inputLabel);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function assertMissingProjectPathIsSafe(snapshot: CanonicalProjectPathSnapshot): Promise<void> {
|
|
242
|
+
try {
|
|
243
|
+
await assertCanonicalRootIsStable(snapshot);
|
|
244
|
+
const currentCanonicalPath = await resolveCanonicalPathWithMissingComponents(
|
|
245
|
+
snapshot.canonicalCandidatePath,
|
|
246
|
+
);
|
|
247
|
+
assertProjectContainment(snapshot.canonicalRootPath, currentCanonicalPath, snapshot.inputLabel);
|
|
248
|
+
} catch (error) {
|
|
249
|
+
if (isUnsafeProjectPathError(error)) throw error;
|
|
250
|
+
throw createUnsafeProjectPathError(snapshot.inputLabel);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function assertExistingProjectFileIsSafe(options: ProjectLocalFilePathOptions): Promise<void> {
|
|
255
|
+
const snapshot = await createCanonicalProjectPathSnapshot(options);
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
await assertCanonicalRootIsStable(snapshot);
|
|
259
|
+
const existingStats = await stat(snapshot.canonicalCandidatePath, { bigint: true });
|
|
260
|
+
if (!existingStats.isFile()) throw createUnsafeProjectPathError(snapshot.inputLabel);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
if (isUnsafeProjectPathError(error)) throw error;
|
|
263
|
+
throw createUnsafeProjectPathError(snapshot.inputLabel);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function assertCanonicalRootIsStable(snapshot: CanonicalProjectPathSnapshot): Promise<void> {
|
|
268
|
+
const currentCanonicalRoot = await realpath(snapshot.rootPath);
|
|
269
|
+
const currentRootStats = await stat(currentCanonicalRoot, { bigint: true });
|
|
270
|
+
if (!currentRootStats.isDirectory()) throw createUnsafeProjectPathError(snapshot.inputLabel);
|
|
271
|
+
if (!hasSameIdentity(snapshot.rootIdentity, toFileIdentity(currentRootStats))) {
|
|
272
|
+
throw createUnsafeProjectPathError(snapshot.inputLabel);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function removeCreatedFileIfIdentityMatches(
|
|
277
|
+
snapshot: CanonicalProjectPathSnapshot,
|
|
278
|
+
openedIdentity: FileIdentity,
|
|
279
|
+
): Promise<void> {
|
|
280
|
+
try {
|
|
281
|
+
const currentStats = await lstat(snapshot.canonicalCandidatePath, { bigint: true });
|
|
282
|
+
if (!hasSameIdentity(openedIdentity, toFileIdentity(currentStats))) return;
|
|
283
|
+
await unlink(snapshot.canonicalCandidatePath);
|
|
284
|
+
} catch {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function closeFileHandle(fileHandle: FileHandle | undefined): Promise<void> {
|
|
290
|
+
if (!fileHandle) return;
|
|
291
|
+
|
|
292
|
+
try {
|
|
293
|
+
await fileHandle.close();
|
|
294
|
+
} catch {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function toFileIdentity(stats: BigIntStats): FileIdentity {
|
|
300
|
+
return { device: stats.dev, inode: stats.ino };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function hasSameFileIdentity(left: BigIntStats, right: BigIntStats): boolean {
|
|
304
|
+
return hasSameIdentity(toFileIdentity(left), toFileIdentity(right));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function hasSameIdentity(left: FileIdentity, right: FileIdentity): boolean {
|
|
308
|
+
return left.device === right.device && left.inode === right.inode;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function resolveCanonicalPathWithMissingComponents(candidatePath: string): Promise<string> {
|
|
312
|
+
const missingComponents: string[] = [];
|
|
313
|
+
let currentPath = candidatePath;
|
|
314
|
+
|
|
315
|
+
while (true) {
|
|
316
|
+
try {
|
|
317
|
+
const canonicalExistingPath = await realpath(currentPath);
|
|
318
|
+
missingComponents.reverse();
|
|
319
|
+
return resolve(canonicalExistingPath, ...missingComponents);
|
|
320
|
+
} catch (error) {
|
|
321
|
+
if (!isMissingPathError(error) || await pathEntryExists(currentPath)) throw error;
|
|
322
|
+
const parentPath = dirname(currentPath);
|
|
323
|
+
if (parentPath === currentPath) throw error;
|
|
324
|
+
missingComponents.push(basename(currentPath));
|
|
325
|
+
currentPath = parentPath;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function pathEntryExists(path: string): Promise<boolean> {
|
|
331
|
+
try {
|
|
332
|
+
await lstat(path);
|
|
333
|
+
return true;
|
|
334
|
+
} catch (error) {
|
|
335
|
+
if (isMissingPathError(error)) return false;
|
|
336
|
+
throw error;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function isMissingPathError(error: unknown): error is NodeJS.ErrnoException {
|
|
341
|
+
return isErrorWithCode(error) && error.code === "ENOENT";
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function isNotDirectoryError(error: unknown): error is NodeJS.ErrnoException {
|
|
345
|
+
return isErrorWithCode(error) && error.code === "ENOTDIR";
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function isSymlinkLoopError(error: unknown): error is NodeJS.ErrnoException {
|
|
349
|
+
return isErrorWithCode(error) && error.code === "ELOOP";
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function isFileAlreadyExistsError(error: unknown): error is NodeJS.ErrnoException {
|
|
353
|
+
return isErrorWithCode(error) && error.code === "EEXIST";
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function isErrorWithCode(error: unknown): error is NodeJS.ErrnoException {
|
|
357
|
+
return typeof error === "object" && error !== null && "code" in error;
|
|
358
|
+
}
|
|
359
|
+
|
|
54
360
|
function isPathInsideOrSame(root: string, candidatePath: string): boolean {
|
|
55
361
|
const pathFromRoot = relative(root, candidatePath);
|
|
56
362
|
return pathFromRoot === "" || (!pathFromRoot.startsWith(parentSegment) && !isAbsolute(pathFromRoot));
|
|
57
363
|
}
|
|
58
364
|
|
|
365
|
+
function createUnsafeProjectPathError(inputLabel: string): NodeJS.ErrnoException {
|
|
366
|
+
const error = new Error(createUnsafeProjectPathMessage(inputLabel)) as NodeJS.ErrnoException;
|
|
367
|
+
error.code = unsafeProjectPathErrorCode;
|
|
368
|
+
return error;
|
|
369
|
+
}
|
|
370
|
+
|
|
59
371
|
function createUnsafeProjectPathMessage(inputLabel: string): string {
|
|
60
372
|
return `Unsafe ObservMe ${inputLabel}: path input must stay inside the active project root.`;
|
|
61
373
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const QUERY_RESULT_COUNT_MINIMUM = 1;
|
|
2
|
+
export const QUERY_RESULT_COUNT_MAXIMUM = 100;
|
|
3
|
+
|
|
4
|
+
export function normalizeQueryResultCount(value: number | undefined): number {
|
|
5
|
+
if (value === undefined || !Number.isFinite(value) || value < QUERY_RESULT_COUNT_MINIMUM) {
|
|
6
|
+
return QUERY_RESULT_COUNT_MINIMUM;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
return Math.min(Math.trunc(value), QUERY_RESULT_COUNT_MAXIMUM);
|
|
10
|
+
}
|
package/src/config/schema.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
|
+
import { QUERY_RESULT_COUNT_MAXIMUM, QUERY_RESULT_COUNT_MINIMUM } from "./query-limits.ts";
|
|
3
4
|
|
|
4
5
|
const observMeEnvironments = ["production", "development", "test"] as const;
|
|
5
6
|
const privacyPathModes = ["hash", "basename", "full", "drop"] as const;
|
|
@@ -13,7 +14,6 @@ export const ACTIVE_AGENT_LEASE_DURATION_MILLIS_MAXIMUM = 300_000;
|
|
|
13
14
|
export const ACTIVE_AGENT_LEASE_EXPORT_SAFETY_MARGIN_MILLIS = 5_000;
|
|
14
15
|
|
|
15
16
|
export interface OtlpTlsConfig {
|
|
16
|
-
enabled: boolean;
|
|
17
17
|
insecureSkipVerify: boolean;
|
|
18
18
|
}
|
|
19
19
|
|
|
@@ -178,7 +178,6 @@ export interface ObservMeConfig {
|
|
|
178
178
|
enabled: boolean;
|
|
179
179
|
environment: ObservMeEnvironment;
|
|
180
180
|
tenant: string;
|
|
181
|
-
replayOnStart: boolean;
|
|
182
181
|
otlp: OtlpConfig;
|
|
183
182
|
resource: ResourceConfig;
|
|
184
183
|
workflow: WorkflowConfig;
|
|
@@ -197,6 +196,10 @@ const environmentSchema = StringEnum(observMeEnvironments);
|
|
|
197
196
|
const otlpProtocolSchema = Type.Literal("http/protobuf");
|
|
198
197
|
const stringRecordSchema = Type.Record(Type.String(), Type.String());
|
|
199
198
|
const positiveIntegerSchema = Type.Integer({ minimum: 1 });
|
|
199
|
+
const queryResultCountSchema = Type.Integer({
|
|
200
|
+
minimum: QUERY_RESULT_COUNT_MINIMUM,
|
|
201
|
+
maximum: QUERY_RESULT_COUNT_MAXIMUM,
|
|
202
|
+
});
|
|
200
203
|
const ratioSchema = Type.Number({ minimum: 0, maximum: 1 });
|
|
201
204
|
const pathModeSchema = StringEnum(privacyPathModes);
|
|
202
205
|
|
|
@@ -213,7 +216,6 @@ export const observMeConfigSchema = Type.Object(
|
|
|
213
216
|
enabled: Type.Boolean(),
|
|
214
217
|
environment: environmentSchema,
|
|
215
218
|
tenant: Type.String({ minLength: 1 }),
|
|
216
|
-
replayOnStart: Type.Boolean(),
|
|
217
219
|
otlp: Type.Object(
|
|
218
220
|
{
|
|
219
221
|
endpoint: Type.String({ minLength: 1 }),
|
|
@@ -222,7 +224,6 @@ export const observMeConfigSchema = Type.Object(
|
|
|
222
224
|
headers: stringRecordSchema,
|
|
223
225
|
tls: Type.Object(
|
|
224
226
|
{
|
|
225
|
-
enabled: Type.Boolean(),
|
|
226
227
|
insecureSkipVerify: Type.Boolean(),
|
|
227
228
|
},
|
|
228
229
|
{ additionalProperties: false },
|
|
@@ -361,10 +362,10 @@ export const observMeConfigSchema = Type.Object(
|
|
|
361
362
|
{
|
|
362
363
|
enabled: Type.Boolean(),
|
|
363
364
|
timeoutMs: positiveIntegerSchema,
|
|
364
|
-
maxLogs:
|
|
365
|
-
maxTraces:
|
|
366
|
-
maxMetricSeries:
|
|
367
|
-
maxAgents:
|
|
365
|
+
maxLogs: queryResultCountSchema,
|
|
366
|
+
maxTraces: queryResultCountSchema,
|
|
367
|
+
maxMetricSeries: queryResultCountSchema,
|
|
368
|
+
maxAgents: queryResultCountSchema,
|
|
368
369
|
links: Type.Object(
|
|
369
370
|
{
|
|
370
371
|
traceUrlTemplate: Type.String(),
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { ObservMeConfig } from "./schema.ts";
|
|
2
|
+
|
|
3
|
+
const tlsVerificationEnabled = "TLS certificate verification enabled";
|
|
4
|
+
const tlsVerificationDisabled = "TLS certificate verification disabled";
|
|
5
|
+
const plaintextHttp = "plaintext HTTP";
|
|
6
|
+
const inactiveObservMe = "inactive (ObservMe disabled)";
|
|
7
|
+
const inactiveSignals = "inactive (all OTLP signals disabled)";
|
|
8
|
+
const inactiveGrafana = "inactive (Grafana queries disabled)";
|
|
9
|
+
const unknownTransport = "unknown or invalid transport";
|
|
10
|
+
|
|
11
|
+
interface OtlpSignalTransport {
|
|
12
|
+
readonly name: "traces" | "metrics" | "logs";
|
|
13
|
+
readonly endpoint: string;
|
|
14
|
+
readonly enabled: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ObsTransportSecuritySnapshot {
|
|
18
|
+
readonly collector: string;
|
|
19
|
+
readonly grafana: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function describeOtlpTransportSecurity(config: ObservMeConfig): string {
|
|
23
|
+
if (!config.enabled) return inactiveObservMe;
|
|
24
|
+
|
|
25
|
+
const signals = getOtlpSignalTransports(config).filter(signal => signal.enabled);
|
|
26
|
+
if (signals.length === 0) return inactiveSignals;
|
|
27
|
+
|
|
28
|
+
const descriptions = signals.map(signal => ({
|
|
29
|
+
name: signal.name,
|
|
30
|
+
description: describeEndpointTransportSecurity(
|
|
31
|
+
signal.endpoint,
|
|
32
|
+
config.otlp.tls.insecureSkipVerify,
|
|
33
|
+
config.privacy.allowInsecureTransport,
|
|
34
|
+
),
|
|
35
|
+
}));
|
|
36
|
+
const uniqueDescriptions = new Set(descriptions.map(signal => signal.description));
|
|
37
|
+
if (uniqueDescriptions.size === 1) return descriptions[0]?.description ?? unknownTransport;
|
|
38
|
+
|
|
39
|
+
return descriptions.map(signal => `${signal.name}: ${signal.description}`).join(", ");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function describeGrafanaTransportSecurity(config: ObservMeConfig): string {
|
|
43
|
+
if (!config.query.enabled) return inactiveGrafana;
|
|
44
|
+
return describeEndpointTransportSecurity(
|
|
45
|
+
config.query.grafana.url,
|
|
46
|
+
config.query.grafana.tls.insecureSkipVerify,
|
|
47
|
+
config.privacy.allowInsecureTransport,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createObsTransportSecuritySnapshot(config: ObservMeConfig): ObsTransportSecuritySnapshot {
|
|
52
|
+
return {
|
|
53
|
+
collector: describeEndpointTransportSecurity(
|
|
54
|
+
config.otlp.endpoint,
|
|
55
|
+
config.otlp.tls.insecureSkipVerify,
|
|
56
|
+
config.privacy.allowInsecureTransport,
|
|
57
|
+
),
|
|
58
|
+
grafana: describeGrafanaTransportSecurity(config),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function describeEndpointTransportSecurity(
|
|
63
|
+
endpoint: string,
|
|
64
|
+
insecureSkipVerify: boolean,
|
|
65
|
+
allowInsecureTransport: boolean,
|
|
66
|
+
): string {
|
|
67
|
+
const protocol = readEndpointProtocol(endpoint);
|
|
68
|
+
if (protocol === "https:") {
|
|
69
|
+
return insecureSkipVerify
|
|
70
|
+
? describeAcknowledgedInsecurity(tlsVerificationDisabled, allowInsecureTransport)
|
|
71
|
+
: tlsVerificationEnabled;
|
|
72
|
+
}
|
|
73
|
+
if (protocol === "http:") return describeAcknowledgedInsecurity(plaintextHttp, allowInsecureTransport);
|
|
74
|
+
return unknownTransport;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function getOtlpSignalTransports(config: ObservMeConfig): OtlpSignalTransport[] {
|
|
78
|
+
return [
|
|
79
|
+
{
|
|
80
|
+
name: "traces",
|
|
81
|
+
endpoint: config.otlp.signalEndpoints?.traces ?? config.otlp.endpoint,
|
|
82
|
+
enabled: config.traces.enabled,
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: "metrics",
|
|
86
|
+
endpoint: config.otlp.signalEndpoints?.metrics ?? config.otlp.endpoint,
|
|
87
|
+
enabled: config.metrics.enabled,
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: "logs",
|
|
91
|
+
endpoint: config.otlp.signalEndpoints?.logs ?? config.otlp.endpoint,
|
|
92
|
+
enabled: config.logs.enabled,
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function readEndpointProtocol(endpoint: string): string | undefined {
|
|
98
|
+
try {
|
|
99
|
+
return new URL(endpoint.trim()).protocol;
|
|
100
|
+
} catch {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function describeAcknowledgedInsecurity(description: string, allowInsecureTransport: boolean): string {
|
|
106
|
+
return allowInsecureTransport ? `${description} (explicitly acknowledged)` : description;
|
|
107
|
+
}
|
package/src/config/validate.ts
CHANGED
|
@@ -6,6 +6,11 @@ import {
|
|
|
6
6
|
} from "./schema.ts";
|
|
7
7
|
import type { ObservMeConfig } from "./schema.ts";
|
|
8
8
|
import { validateCustomRedactionPatterns } from "../privacy/redact.ts";
|
|
9
|
+
import { classifyOtlpEndpointFailure } from "../otel/otlp-endpoint.ts";
|
|
10
|
+
import {
|
|
11
|
+
classifyGrafanaUrlSecurityFailure,
|
|
12
|
+
formatGrafanaUrlSecurityFailure,
|
|
13
|
+
} from "../query/grafana-url.ts";
|
|
9
14
|
|
|
10
15
|
export interface ValidationIssue {
|
|
11
16
|
code: string;
|
|
@@ -57,15 +62,22 @@ const maximumConfigDiagnosticIssueCount = 1_000_000;
|
|
|
57
62
|
const unknownConfigValidationIssueCode = "unknown_config_validation_issue";
|
|
58
63
|
const knownConfigValidationIssueCodes = new Set([
|
|
59
64
|
"invalid_config_shape",
|
|
65
|
+
"config_source_malformed",
|
|
66
|
+
"config_source_rejected",
|
|
67
|
+
"config_source_unreadable",
|
|
60
68
|
"unsafe_capture_without_redaction",
|
|
61
69
|
"insecure_production_transport",
|
|
70
|
+
"invalid_otlp_endpoint",
|
|
62
71
|
"invalid_signal_endpoint_path",
|
|
72
|
+
"embedded_grafana_url_credentials",
|
|
63
73
|
"high_cardinality_metric_label",
|
|
64
74
|
"active_agent_lease_too_short_for_export_interval",
|
|
65
75
|
"custom_redaction_pattern_limit",
|
|
66
76
|
"custom_redaction_pattern_too_long",
|
|
67
77
|
"custom_redaction_pattern_unsupported_construct",
|
|
68
78
|
"custom_redaction_pattern_nested_quantifier",
|
|
79
|
+
"custom_redaction_pattern_ambiguous_alternation",
|
|
80
|
+
"custom_redaction_pattern_ambiguous_repetition",
|
|
69
81
|
"custom_redaction_pattern_empty_match",
|
|
70
82
|
"invalid_custom_redaction_pattern",
|
|
71
83
|
"untrusted_project_config_read",
|
|
@@ -84,7 +96,8 @@ export function validateObservMeConfig(
|
|
|
84
96
|
const issues = [
|
|
85
97
|
...validateRedactionBoundary(config),
|
|
86
98
|
...validateTransportSecurity(config),
|
|
87
|
-
...
|
|
99
|
+
...validateOtlpEndpoints(config),
|
|
100
|
+
...validateGrafanaUrl(config),
|
|
88
101
|
...validateMetricLabels(config),
|
|
89
102
|
...validateActiveAgentLeaseDuration(config),
|
|
90
103
|
...validateCustomRedactionPatternConfig(config),
|
|
@@ -253,7 +266,15 @@ function validateTransportSecurity(config: ObservMeConfig): ValidationIssue[] {
|
|
|
253
266
|
...validateProductionHttpEndpoint("otlp.signalEndpoints.traces", config.otlp.signalEndpoints?.traces),
|
|
254
267
|
...validateProductionHttpEndpoint("otlp.signalEndpoints.metrics", config.otlp.signalEndpoints?.metrics),
|
|
255
268
|
...validateProductionHttpEndpoint("otlp.signalEndpoints.logs", config.otlp.signalEndpoints?.logs),
|
|
269
|
+
...validateProductionTlsVerificationBypass(
|
|
270
|
+
"otlp.tls.insecureSkipVerify",
|
|
271
|
+
config.otlp.tls.insecureSkipVerify,
|
|
272
|
+
),
|
|
256
273
|
...validateProductionHttpEndpoint("query.grafana.url", config.query.grafana.url),
|
|
274
|
+
...validateProductionTlsVerificationBypass(
|
|
275
|
+
"query.grafana.tls.insecureSkipVerify",
|
|
276
|
+
config.query.grafana.tls.insecureSkipVerify,
|
|
277
|
+
),
|
|
257
278
|
];
|
|
258
279
|
}
|
|
259
280
|
|
|
@@ -268,30 +289,54 @@ function validateProductionHttpEndpoint(name: string, endpoint: string | undefin
|
|
|
268
289
|
];
|
|
269
290
|
}
|
|
270
291
|
|
|
292
|
+
function validateProductionTlsVerificationBypass(name: string, insecureSkipVerify: boolean): ValidationIssue[] {
|
|
293
|
+
if (!insecureSkipVerify) return [];
|
|
294
|
+
|
|
295
|
+
return [
|
|
296
|
+
{
|
|
297
|
+
code: "insecure_production_transport",
|
|
298
|
+
message: `${name} must be false in production unless privacy.allowInsecureTransport is true.`,
|
|
299
|
+
},
|
|
300
|
+
];
|
|
301
|
+
}
|
|
302
|
+
|
|
271
303
|
function isHttpEndpoint(endpoint: string): boolean {
|
|
272
304
|
return endpoint.trim().toLowerCase().startsWith("http://");
|
|
273
305
|
}
|
|
274
306
|
|
|
275
|
-
function
|
|
276
|
-
if (config.otlp.protocol !== "http/protobuf") return [];
|
|
277
|
-
|
|
307
|
+
function validateOtlpEndpoints(config: ObservMeConfig): ValidationIssue[] {
|
|
278
308
|
const endpoints = config.otlp.signalEndpoints;
|
|
279
|
-
if (!endpoints) return [];
|
|
280
309
|
|
|
281
310
|
return [
|
|
282
|
-
...
|
|
283
|
-
...validateSignalEndpoint("
|
|
284
|
-
...validateSignalEndpoint("
|
|
311
|
+
...validateOtlpEndpoint("otlp.endpoint", config.otlp.endpoint),
|
|
312
|
+
...validateSignalEndpoint("otlp.signalEndpoints.traces", endpoints?.traces, "/v1/traces"),
|
|
313
|
+
...validateSignalEndpoint("otlp.signalEndpoints.metrics", endpoints?.metrics, "/v1/metrics"),
|
|
314
|
+
...validateSignalEndpoint("otlp.signalEndpoints.logs", endpoints?.logs, "/v1/logs"),
|
|
285
315
|
];
|
|
286
316
|
}
|
|
287
317
|
|
|
288
|
-
function
|
|
289
|
-
|
|
318
|
+
function validateOtlpEndpoint(name: string, endpoint: string): ValidationIssue[] {
|
|
319
|
+
const failureClass = classifyOtlpEndpointFailure(endpoint);
|
|
320
|
+
if (!failureClass) return [];
|
|
321
|
+
|
|
322
|
+
return [
|
|
323
|
+
{
|
|
324
|
+
code: "invalid_otlp_endpoint",
|
|
325
|
+
message: `${name} is invalid (${failureClass}).`,
|
|
326
|
+
},
|
|
327
|
+
];
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function validateSignalEndpoint(name: string, endpoint: string | undefined, requiredPath: string): ValidationIssue[] {
|
|
331
|
+
if (!endpoint) return [];
|
|
332
|
+
|
|
333
|
+
const endpointIssues = validateOtlpEndpoint(name, endpoint);
|
|
334
|
+
if (endpointIssues.length > 0 || endpointPathMatches(endpoint, requiredPath)) return endpointIssues;
|
|
290
335
|
|
|
291
336
|
return [
|
|
292
337
|
{
|
|
293
338
|
code: "invalid_signal_endpoint_path",
|
|
294
|
-
message: `${
|
|
339
|
+
message: `${name} must end with ${requiredPath}.`,
|
|
295
340
|
},
|
|
296
341
|
];
|
|
297
342
|
}
|
|
@@ -304,6 +349,18 @@ function endpointPathMatches(endpoint: string, requiredPath: string): boolean {
|
|
|
304
349
|
}
|
|
305
350
|
}
|
|
306
351
|
|
|
352
|
+
function validateGrafanaUrl(config: ObservMeConfig): ValidationIssue[] {
|
|
353
|
+
const failureClass = classifyGrafanaUrlSecurityFailure(config.query.grafana.url);
|
|
354
|
+
if (!failureClass) return [];
|
|
355
|
+
|
|
356
|
+
return [
|
|
357
|
+
{
|
|
358
|
+
code: "embedded_grafana_url_credentials",
|
|
359
|
+
message: formatGrafanaUrlSecurityFailure(failureClass),
|
|
360
|
+
},
|
|
361
|
+
];
|
|
362
|
+
}
|
|
363
|
+
|
|
307
364
|
function validateMetricLabels(config: ObservMeConfig): ValidationIssue[] {
|
|
308
365
|
const labels = config.metrics.labels ?? [];
|
|
309
366
|
return labels.filter(isForbiddenMetricLabel).map(label => ({
|