@blogic-cz/agent-tools 0.14.44 → 0.14.46
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/package.json +1 -1
- package/schemas/agent-tools.schema.json +36 -0
- package/src/config/index.ts +1 -0
- package/src/config/loader.ts +9 -0
- package/src/config/types.ts +2 -0
- package/src/db-tool/index.ts +7 -2
- package/src/db-tool/security.ts +28 -0
- package/src/db-tool/service.ts +21 -6
- package/src/db-tool/transformers.ts +7 -2
- package/src/db-tool/types.ts +6 -2
package/package.json
CHANGED
|
@@ -291,6 +291,13 @@
|
|
|
291
291
|
"$ref": "#/definitions/DbMutationOperation"
|
|
292
292
|
}
|
|
293
293
|
}
|
|
294
|
+
},
|
|
295
|
+
"allowedMutationTargets": {
|
|
296
|
+
"description": "Explicitly allowed SQL mutation targets per environment and operation. Use normalized schema-qualified names such as ticker.TimeTickers.",
|
|
297
|
+
"type": "object",
|
|
298
|
+
"additionalProperties": {
|
|
299
|
+
"$ref": "#/definitions/DbAllowedMutationTargets"
|
|
300
|
+
}
|
|
294
301
|
}
|
|
295
302
|
},
|
|
296
303
|
"required": ["environments"]
|
|
@@ -591,6 +598,30 @@
|
|
|
591
598
|
"description": "SQL mutation operation that can be explicitly allowed for a database environment.",
|
|
592
599
|
"type": "string",
|
|
593
600
|
"enum": ["insert", "update", "delete"]
|
|
601
|
+
},
|
|
602
|
+
"DbAllowedMutationTargets": {
|
|
603
|
+
"type": "object",
|
|
604
|
+
"additionalProperties": false,
|
|
605
|
+
"properties": {
|
|
606
|
+
"insert": {
|
|
607
|
+
"type": "array",
|
|
608
|
+
"items": {
|
|
609
|
+
"type": "string"
|
|
610
|
+
}
|
|
611
|
+
},
|
|
612
|
+
"update": {
|
|
613
|
+
"type": "array",
|
|
614
|
+
"items": {
|
|
615
|
+
"type": "string"
|
|
616
|
+
}
|
|
617
|
+
},
|
|
618
|
+
"delete": {
|
|
619
|
+
"type": "array",
|
|
620
|
+
"items": {
|
|
621
|
+
"type": "string"
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
594
625
|
}
|
|
595
626
|
},
|
|
596
627
|
"examples": [
|
|
@@ -640,6 +671,11 @@
|
|
|
640
671
|
"remotePort": 5432,
|
|
641
672
|
"allowedMutations": {
|
|
642
673
|
"test": ["insert"]
|
|
674
|
+
},
|
|
675
|
+
"allowedMutationTargets": {
|
|
676
|
+
"prod": {
|
|
677
|
+
"insert": ["ticker.TimeTickers"]
|
|
678
|
+
}
|
|
643
679
|
}
|
|
644
680
|
}
|
|
645
681
|
},
|
package/src/config/index.ts
CHANGED
package/src/config/loader.ts
CHANGED
|
@@ -96,11 +96,20 @@ const DbEnvConfigSchema = Schema.Struct({
|
|
|
96
96
|
vpn: Schema.optionalKey(Schema.String),
|
|
97
97
|
});
|
|
98
98
|
|
|
99
|
+
const DbAllowedMutationTargetsSchema = Schema.Struct({
|
|
100
|
+
insert: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
101
|
+
update: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
102
|
+
delete: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
103
|
+
});
|
|
104
|
+
|
|
99
105
|
const DatabaseConfigSchema = Schema.Struct({
|
|
100
106
|
environments: Schema.Record(Schema.String, DbEnvConfigSchema),
|
|
101
107
|
allowedMutations: Schema.optionalKey(
|
|
102
108
|
Schema.Record(Schema.String, Schema.Array(DbMutationOperationSchema)),
|
|
103
109
|
),
|
|
110
|
+
allowedMutationTargets: Schema.optionalKey(
|
|
111
|
+
Schema.Record(Schema.String, DbAllowedMutationTargetsSchema),
|
|
112
|
+
),
|
|
104
113
|
kubectl: Schema.optionalKey(
|
|
105
114
|
Schema.Struct({
|
|
106
115
|
kubeconfig: Schema.optionalKey(Schema.String),
|
package/src/config/types.ts
CHANGED
|
@@ -95,6 +95,7 @@ export type DbEnvConfig = ProfilePrerequisites & {
|
|
|
95
95
|
/** SQL mutation operation that can be explicitly allowed for a database environment. */
|
|
96
96
|
export const DbMutationOperationSchema = Schema.Literals(["insert", "update", "delete"]);
|
|
97
97
|
export type DbMutationOperation = Schema.Schema.Type<typeof DbMutationOperationSchema>;
|
|
98
|
+
export type DbAllowedMutationTargets = Partial<Record<DbMutationOperation, readonly string[]>>;
|
|
98
99
|
|
|
99
100
|
/** Database profile configuration */
|
|
100
101
|
export type DatabaseConfig = ProfilePrerequisites & {
|
|
@@ -102,6 +103,7 @@ export type DatabaseConfig = ProfilePrerequisites & {
|
|
|
102
103
|
environments: Record<string, DbEnvConfig>;
|
|
103
104
|
/** Explicitly allowed SQL mutation operations per environment. Non-local environments default to read-only. */
|
|
104
105
|
allowedMutations?: Record<string, readonly DbMutationOperation[]>;
|
|
106
|
+
allowedMutationTargets?: Record<string, DbAllowedMutationTargets>;
|
|
105
107
|
kubectl?: {
|
|
106
108
|
/** Optional kubeconfig path. Supports ${ENV_VAR} templates. */
|
|
107
109
|
kubeconfig?: string;
|
package/src/db-tool/index.ts
CHANGED
|
@@ -64,16 +64,21 @@ const sqlCommand = Command.make(
|
|
|
64
64
|
),
|
|
65
65
|
),
|
|
66
66
|
sql: Flag.string("sql").pipe(Flag.withDescription("SQL query to execute")),
|
|
67
|
+
limit: Flag.optional(Flag.integer("limit")).pipe(
|
|
68
|
+
Flag.withDescription(
|
|
69
|
+
"Max rows to return (default 50). Use 0 for no cap. Prefer a SQL LIMIT for large tables.",
|
|
70
|
+
),
|
|
71
|
+
),
|
|
67
72
|
format: formatOption,
|
|
68
73
|
profile: Flag.optional(Flag.string("profile")).pipe(
|
|
69
74
|
Flag.withDescription("Database profile name from agent-tools.json5 (if multiple configured)"),
|
|
70
75
|
),
|
|
71
76
|
},
|
|
72
|
-
({ env, sql, format }) =>
|
|
77
|
+
({ env, sql, limit, format }) =>
|
|
73
78
|
Effect.gen(function* () {
|
|
74
79
|
const resolvedEnv = yield* resolveEnv(env);
|
|
75
80
|
const db = yield* DbService;
|
|
76
|
-
const result = yield* db.executeQuery(resolvedEnv, sql);
|
|
81
|
+
const result = yield* db.executeQuery(resolvedEnv, sql, Option.getOrUndefined(limit));
|
|
77
82
|
yield* Console.log(formatOutput(result, format));
|
|
78
83
|
}),
|
|
79
84
|
).pipe(Command.withDescription("Execute a SQL query"));
|
package/src/db-tool/security.ts
CHANGED
|
@@ -17,6 +17,13 @@ const ALLOWABLE_MUTATION_PATTERNS: Array<[DbMutationOperation, RegExp]> = [
|
|
|
17
17
|
];
|
|
18
18
|
|
|
19
19
|
const TABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?$/;
|
|
20
|
+
const IDENTIFIER_PATTERN = String.raw`(?:"[^"]+"|[a-zA-Z_][a-zA-Z0-9_]*)`;
|
|
21
|
+
const QUALIFIED_IDENTIFIER_PATTERN = `${IDENTIFIER_PATTERN}(?:\\s*\\.\\s*${IDENTIFIER_PATTERN})?`;
|
|
22
|
+
const MUTATION_TARGET_PATTERNS: Array<[DbMutationOperation, RegExp]> = [
|
|
23
|
+
["insert", new RegExp(String.raw`^\s*INSERT\s+INTO\s+(${QUALIFIED_IDENTIFIER_PATTERN})`, "i")],
|
|
24
|
+
["update", new RegExp(String.raw`^\s*UPDATE\s+(${QUALIFIED_IDENTIFIER_PATTERN})`, "i")],
|
|
25
|
+
["delete", new RegExp(String.raw`^\s*DELETE\s+FROM\s+(${QUALIFIED_IDENTIFIER_PATTERN})`, "i")],
|
|
26
|
+
];
|
|
20
27
|
|
|
21
28
|
/**
|
|
22
29
|
* Strip SQL comments (block and line) while preserving string literals.
|
|
@@ -89,6 +96,27 @@ export function getAllowedMutationOperation(sql: string): DbMutationOperation |
|
|
|
89
96
|
return ALLOWABLE_MUTATION_PATTERNS.find(([, pattern]) => pattern.test(stripped))?.[0];
|
|
90
97
|
}
|
|
91
98
|
|
|
99
|
+
function normalizeSqlIdentifier(identifier: string): string {
|
|
100
|
+
return identifier
|
|
101
|
+
.split(".")
|
|
102
|
+
.map((part) =>
|
|
103
|
+
part
|
|
104
|
+
.trim()
|
|
105
|
+
.replace(/^"(.+)"$/, "$1")
|
|
106
|
+
.replace(/""/g, '"'),
|
|
107
|
+
)
|
|
108
|
+
.join(".");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function getMutationTarget(sql: string): string | undefined {
|
|
112
|
+
const stripped = stripSqlComments(sql);
|
|
113
|
+
const operation = getAllowedMutationOperation(stripped);
|
|
114
|
+
const pattern = MUTATION_TARGET_PATTERNS.find(([candidate]) => candidate === operation)?.[1];
|
|
115
|
+
const target = pattern?.exec(stripped)?.[1];
|
|
116
|
+
|
|
117
|
+
return target === undefined ? undefined : normalizeSqlIdentifier(target);
|
|
118
|
+
}
|
|
119
|
+
|
|
92
120
|
export function isValidTableName(tableName: string): boolean {
|
|
93
121
|
return TABLE_NAME_PATTERN.test(tableName);
|
|
94
122
|
}
|
package/src/db-tool/service.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
import {
|
|
29
29
|
detectSchemaError,
|
|
30
30
|
getAllowedMutationOperation,
|
|
31
|
+
getMutationTarget,
|
|
31
32
|
isValidTableName,
|
|
32
33
|
isMutationQuery,
|
|
33
34
|
} from "./security";
|
|
@@ -58,7 +59,11 @@ export { buildApiProbeArgs } from "#shared/k8s-probe";
|
|
|
58
59
|
export class DbService extends Context.Service<
|
|
59
60
|
DbService,
|
|
60
61
|
{
|
|
61
|
-
readonly executeQuery: (
|
|
62
|
+
readonly executeQuery: (
|
|
63
|
+
env: string,
|
|
64
|
+
sql: string,
|
|
65
|
+
limit?: number,
|
|
66
|
+
) => Effect.Effect<QueryResult, DbError>;
|
|
62
67
|
readonly executeSchemaQuery: (
|
|
63
68
|
env: string,
|
|
64
69
|
mode: SchemaMode,
|
|
@@ -82,7 +87,8 @@ export class DbService extends Context.Service<
|
|
|
82
87
|
environment: env,
|
|
83
88
|
});
|
|
84
89
|
return {
|
|
85
|
-
executeQuery: (env: string, _sql: string) =>
|
|
90
|
+
executeQuery: (env: string, _sql: string, _limit?: number) =>
|
|
91
|
+
Effect.fail(noConfigError(env)),
|
|
86
92
|
executeSchemaQuery: (env: string, _mode: SchemaMode, _table?: string) =>
|
|
87
93
|
Effect.fail(noConfigError(env)),
|
|
88
94
|
};
|
|
@@ -467,6 +473,7 @@ export class DbService extends Context.Service<
|
|
|
467
473
|
password: string,
|
|
468
474
|
startTimeMs: number,
|
|
469
475
|
applyTransform = false,
|
|
476
|
+
limit?: number,
|
|
470
477
|
) {
|
|
471
478
|
const selectSql = sql.trim().replace(/;\s*$/, "");
|
|
472
479
|
const wrappedSql = `SELECT json_agg(t) FROM (${selectSql}) t;`;
|
|
@@ -531,7 +538,7 @@ export class DbService extends Context.Service<
|
|
|
531
538
|
});
|
|
532
539
|
|
|
533
540
|
const transformed = applyTransform
|
|
534
|
-
? transformQueryResult(rawData)
|
|
541
|
+
? transformQueryResult(rawData, limit)
|
|
535
542
|
: {
|
|
536
543
|
data: rawData,
|
|
537
544
|
showing: rawData.length,
|
|
@@ -712,12 +719,14 @@ export class DbService extends Context.Service<
|
|
|
712
719
|
needsTunnel: accessMode.needsTunnel,
|
|
713
720
|
allowMutations: accessMode.allowMutations,
|
|
714
721
|
allowedMutations: accessMode.allowedMutations,
|
|
722
|
+
allowedMutationTargets: dbConfig.allowedMutationTargets?.[env] ?? {},
|
|
715
723
|
};
|
|
716
724
|
});
|
|
717
725
|
|
|
718
726
|
const executeQuery = Effect.fn("DbService.executeQuery")(function* (
|
|
719
727
|
env: string,
|
|
720
728
|
sql: string,
|
|
729
|
+
limit?: number,
|
|
721
730
|
) {
|
|
722
731
|
const config = yield* getConfigForEnv(env);
|
|
723
732
|
const startTimeMs = yield* Clock.currentTimeMillis;
|
|
@@ -725,11 +734,17 @@ export class DbService extends Context.Service<
|
|
|
725
734
|
const password = yield* resolvePassword(resolvedConfig, env);
|
|
726
735
|
const mutation = isMutationQuery(sql);
|
|
727
736
|
const mutationOperation = mutation ? getAllowedMutationOperation(sql) : undefined;
|
|
737
|
+
const mutationTarget = mutation ? getMutationTarget(sql) : undefined;
|
|
728
738
|
const mutationAllowed =
|
|
729
739
|
!mutation ||
|
|
730
740
|
resolvedConfig.allowMutations ||
|
|
731
741
|
(mutationOperation !== undefined &&
|
|
732
|
-
resolvedConfig.allowedMutations.includes(mutationOperation))
|
|
742
|
+
resolvedConfig.allowedMutations.includes(mutationOperation)) ||
|
|
743
|
+
(mutationOperation !== undefined &&
|
|
744
|
+
mutationTarget !== undefined &&
|
|
745
|
+
(resolvedConfig.allowedMutationTargets[mutationOperation] ?? []).includes(
|
|
746
|
+
mutationTarget,
|
|
747
|
+
));
|
|
733
748
|
|
|
734
749
|
if (!mutationAllowed) {
|
|
735
750
|
const allowed =
|
|
@@ -739,13 +754,13 @@ export class DbService extends Context.Service<
|
|
|
739
754
|
return yield* new DbMutationBlockedError({
|
|
740
755
|
message: `Mutation queries are not allowed on environment ${env}. Allowed mutation operations: ${allowed}.`,
|
|
741
756
|
environment: env,
|
|
742
|
-
hint: 'Configure database.<profile>.
|
|
757
|
+
hint: 'Configure database.<profile>.allowedMutationTargets.<env> with explicit targets such as { insert: ["ticker.TimeTickers"] }, or allowedMutations.<env> for broader operation-level access.',
|
|
743
758
|
});
|
|
744
759
|
}
|
|
745
760
|
|
|
746
761
|
const queryEffect = mutation
|
|
747
762
|
? executeMutationQuery(resolvedConfig, sql, password, Number(startTimeMs))
|
|
748
|
-
: executeSelectQuery(resolvedConfig, sql, password, Number(startTimeMs), true);
|
|
763
|
+
: executeSelectQuery(resolvedConfig, sql, password, Number(startTimeMs), true, limit);
|
|
749
764
|
|
|
750
765
|
return yield* runWithVpnPrerequisites(
|
|
751
766
|
resolvedConfig.port,
|
|
@@ -22,12 +22,17 @@ function truncateValue(value: unknown): unknown {
|
|
|
22
22
|
return `${value.slice(0, MAX_VALUE_LENGTH)}...`;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
export function transformQueryResult(
|
|
25
|
+
export function transformQueryResult(
|
|
26
|
+
data: Record<string, unknown>[],
|
|
27
|
+
// limit 0 (or negative) means "no row cap".
|
|
28
|
+
limit: number = DEFAULT_ROW_LIMIT,
|
|
29
|
+
): TransformResult {
|
|
26
30
|
const withoutEmptyColumns = stripEmptyColumns(data);
|
|
27
31
|
const truncatedValues = withoutEmptyColumns.map((record) =>
|
|
28
32
|
Object.fromEntries(Object.entries(record).map(([key, value]) => [key, truncateValue(value)])),
|
|
29
33
|
);
|
|
30
|
-
const
|
|
34
|
+
const effectiveLimit = limit > 0 ? limit : Number.POSITIVE_INFINITY;
|
|
35
|
+
const { rows, truncated, total, showing } = truncateRows(truncatedValues, effectiveLimit);
|
|
31
36
|
|
|
32
37
|
return {
|
|
33
38
|
data: rows,
|
package/src/db-tool/types.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
1
|
+
import type {
|
|
2
|
+
DbAllowedMutationTargets,
|
|
3
|
+
DbMutationOperation,
|
|
4
|
+
ProfilePrerequisites,
|
|
5
|
+
} from "#config/types";
|
|
3
6
|
import type { Environment, OutputFormat } from "#shared";
|
|
4
7
|
|
|
5
8
|
export type { DbMutationOperation };
|
|
@@ -17,6 +20,7 @@ export type DbConfig = ProfilePrerequisites & {
|
|
|
17
20
|
needsTunnel: boolean;
|
|
18
21
|
allowMutations: boolean;
|
|
19
22
|
allowedMutations: readonly DbMutationOperation[];
|
|
23
|
+
allowedMutationTargets: DbAllowedMutationTargets;
|
|
20
24
|
};
|
|
21
25
|
|
|
22
26
|
export type QueryResult = {
|