@blogic-cz/agent-tools 0.14.43 → 0.14.45
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 +5 -5
- 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/security.ts +28 -0
- package/src/db-tool/service.ts +10 -2
- package/src/db-tool/types.ts +6 -2
- package/src/gh-tool/pr/commands.ts +5 -1
- package/src/gh-tool/pr/core.ts +5 -7
- package/src/gh-tool/workflow.ts +23 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blogic-cz/agent-tools",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.45",
|
|
4
4
|
"description": "CLI tools for AI coding agent workflows — GitHub, database, Kubernetes, Azure DevOps, logs, sessions, and audit",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent",
|
|
@@ -137,13 +137,13 @@
|
|
|
137
137
|
"test": "vitest run"
|
|
138
138
|
},
|
|
139
139
|
"dependencies": {
|
|
140
|
-
"@effect/platform-bun": "4.0.0-beta.
|
|
140
|
+
"@effect/platform-bun": "4.0.0-beta.90",
|
|
141
141
|
"@toon-format/toon": "2.1.0",
|
|
142
|
-
"effect": "4.0.0-beta.
|
|
142
|
+
"effect": "4.0.0-beta.90"
|
|
143
143
|
},
|
|
144
144
|
"devDependencies": {
|
|
145
145
|
"@effect/language-service": "0.86.2",
|
|
146
|
-
"@effect/vitest": "4.0.0-beta.
|
|
146
|
+
"@effect/vitest": "4.0.0-beta.90",
|
|
147
147
|
"@types/bun": "1.3.12",
|
|
148
148
|
"oxfmt": "0.44.0",
|
|
149
149
|
"oxlint": "1.59.0",
|
|
@@ -151,7 +151,7 @@
|
|
|
151
151
|
"vitest": "^4.1.4"
|
|
152
152
|
},
|
|
153
153
|
"overrides": {
|
|
154
|
-
"@effect/platform-node-shared": "4.0.0-beta.
|
|
154
|
+
"@effect/platform-node-shared": "4.0.0-beta.90"
|
|
155
155
|
},
|
|
156
156
|
"engines": {
|
|
157
157
|
"bun": ">=1.0.0"
|
|
@@ -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/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";
|
|
@@ -712,6 +713,7 @@ export class DbService extends Context.Service<
|
|
|
712
713
|
needsTunnel: accessMode.needsTunnel,
|
|
713
714
|
allowMutations: accessMode.allowMutations,
|
|
714
715
|
allowedMutations: accessMode.allowedMutations,
|
|
716
|
+
allowedMutationTargets: dbConfig.allowedMutationTargets?.[env] ?? {},
|
|
715
717
|
};
|
|
716
718
|
});
|
|
717
719
|
|
|
@@ -725,11 +727,17 @@ export class DbService extends Context.Service<
|
|
|
725
727
|
const password = yield* resolvePassword(resolvedConfig, env);
|
|
726
728
|
const mutation = isMutationQuery(sql);
|
|
727
729
|
const mutationOperation = mutation ? getAllowedMutationOperation(sql) : undefined;
|
|
730
|
+
const mutationTarget = mutation ? getMutationTarget(sql) : undefined;
|
|
728
731
|
const mutationAllowed =
|
|
729
732
|
!mutation ||
|
|
730
733
|
resolvedConfig.allowMutations ||
|
|
731
734
|
(mutationOperation !== undefined &&
|
|
732
|
-
resolvedConfig.allowedMutations.includes(mutationOperation))
|
|
735
|
+
resolvedConfig.allowedMutations.includes(mutationOperation)) ||
|
|
736
|
+
(mutationOperation !== undefined &&
|
|
737
|
+
mutationTarget !== undefined &&
|
|
738
|
+
(resolvedConfig.allowedMutationTargets[mutationOperation] ?? []).includes(
|
|
739
|
+
mutationTarget,
|
|
740
|
+
));
|
|
733
741
|
|
|
734
742
|
if (!mutationAllowed) {
|
|
735
743
|
const allowed =
|
|
@@ -739,7 +747,7 @@ export class DbService extends Context.Service<
|
|
|
739
747
|
return yield* new DbMutationBlockedError({
|
|
740
748
|
message: `Mutation queries are not allowed on environment ${env}. Allowed mutation operations: ${allowed}.`,
|
|
741
749
|
environment: env,
|
|
742
|
-
hint: 'Configure database.<profile>.
|
|
750
|
+
hint: 'Configure database.<profile>.allowedMutationTargets.<env> with explicit targets such as { insert: ["ticker.TimeTickers"] }, or allowedMutations.<env> for broader operation-level access.',
|
|
743
751
|
});
|
|
744
752
|
}
|
|
745
753
|
|
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 = {
|
|
@@ -452,7 +452,11 @@ export const prChecksCommand = Command.make(
|
|
|
452
452
|
repo: repoOption,
|
|
453
453
|
timeout: Flag.integer("timeout").pipe(
|
|
454
454
|
Flag.withDefault(CI_CHECK_WATCH_TIMEOUT_MS / 1000),
|
|
455
|
-
Flag.withDescription("Timeout in seconds for watch mode (default: 600)"),
|
|
455
|
+
Flag.withDescription("Timeout in seconds for watch mode (default: 600, minimum 1)"),
|
|
456
|
+
Flag.filter(
|
|
457
|
+
(n) => n >= 1,
|
|
458
|
+
() => "--timeout must be at least 1 second",
|
|
459
|
+
),
|
|
456
460
|
),
|
|
457
461
|
watch: Flag.boolean("watch").pipe(
|
|
458
462
|
Flag.withDefault(false),
|
package/src/gh-tool/pr/core.ts
CHANGED
|
@@ -20,9 +20,6 @@ import { runLocalCommand } from "./helpers";
|
|
|
20
20
|
|
|
21
21
|
const CHECK_JSON_FIELDS = "name,state,bucket,link";
|
|
22
22
|
const GITHUB_ACTIONS_RUN_ID_RE = /github\.com\/[^/]+\/[^/]+\/actions\/runs\/(\d+)/;
|
|
23
|
-
// A single blocking `--watch` is capped here so an agent never loses a whole turn to a 30-min
|
|
24
|
-
// foreground wait. On hitting the cap we return the partial snapshot, not a failure (H1).
|
|
25
|
-
const MAX_WATCH_SECONDS = 120;
|
|
26
23
|
|
|
27
24
|
const validatePRTitle = Effect.fn("pr.validatePRTitle")(function* (title: string) {
|
|
28
25
|
const gh = yield* GitHubService;
|
|
@@ -874,11 +871,12 @@ export const fetchChecks = Effect.fn("pr.fetchChecks")(function* (
|
|
|
874
871
|
watchArgs.push("--fail-fast");
|
|
875
872
|
}
|
|
876
873
|
|
|
877
|
-
//
|
|
878
|
-
|
|
874
|
+
// Block for the caller's requested --timeout (no artificial cap — blocking isn't the problem;
|
|
875
|
+
// --timeout is validated >= 1s at the CLI boundary). On timeout return a snapshot, never
|
|
876
|
+
// nothing — that was the actual token-wasting bug.
|
|
879
877
|
const watchOutcome = yield* gh.runGh(watchArgs).pipe(
|
|
880
878
|
Effect.timeoutOrElse({
|
|
881
|
-
duration:
|
|
879
|
+
duration: timeoutSeconds * 1000,
|
|
882
880
|
orElse: () => Effect.succeed(null),
|
|
883
881
|
}),
|
|
884
882
|
);
|
|
@@ -887,7 +885,7 @@ export const fetchChecks = Effect.fn("pr.fetchChecks")(function* (
|
|
|
887
885
|
if (watchOutcome === null && results.some((c) => c.bucket === "pending")) {
|
|
888
886
|
const pending = results.filter((c) => c.bucket === "pending").length;
|
|
889
887
|
yield* Console.warn(
|
|
890
|
-
`ℹ️ Watch
|
|
888
|
+
`ℹ️ Watch timed out after ${timeoutSeconds}s; ${pending} check(s) still pending (snapshot returned). ` +
|
|
891
889
|
`Re-run to keep watching:\n ${buildChecksCommand(pr, true)}`,
|
|
892
890
|
);
|
|
893
891
|
}
|
package/src/gh-tool/workflow.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Command, Flag } from "effect/unstable/cli";
|
|
|
2
2
|
import { Console, Effect, Option } from "effect";
|
|
3
3
|
|
|
4
4
|
import { formatOption, logFormatted } from "#shared";
|
|
5
|
+
import { CI_CHECK_WATCH_TIMEOUT_MS } from "#gh/config";
|
|
5
6
|
import { GitHubCommandError, GitHubNotFoundError } from "./errors";
|
|
6
7
|
import { GitHubService } from "./service";
|
|
7
8
|
import type { CheckRunAnnotation, JobAnnotations } from "./types";
|
|
@@ -212,11 +213,15 @@ const cancelRun = Effect.fn("workflow.cancelRun")(function* (runId: number, repo
|
|
|
212
213
|
};
|
|
213
214
|
});
|
|
214
215
|
|
|
215
|
-
// `gh run watch` has no native timeout
|
|
216
|
-
// fall back to a one-shot snapshot
|
|
217
|
-
const
|
|
216
|
+
// `gh run watch` has no native timeout (observed hanging 36 min). Block for the caller's --timeout,
|
|
217
|
+
// then fall back to a one-shot snapshot so a timeout never returns nothing.
|
|
218
|
+
const DEFAULT_WATCH_RUN_TIMEOUT_SECONDS = CI_CHECK_WATCH_TIMEOUT_MS / 1000;
|
|
218
219
|
|
|
219
|
-
const watchRun = Effect.fn("workflow.watchRun")(function* (
|
|
220
|
+
const watchRun = Effect.fn("workflow.watchRun")(function* (
|
|
221
|
+
runId: number,
|
|
222
|
+
repo: string | null,
|
|
223
|
+
timeoutSeconds: number,
|
|
224
|
+
) {
|
|
220
225
|
const gh = yield* GitHubService;
|
|
221
226
|
|
|
222
227
|
const watchArgs = ["run", "watch", String(runId), "--exit-status"];
|
|
@@ -237,7 +242,7 @@ const watchRun = Effect.fn("workflow.watchRun")(function* (runId: number, repo:
|
|
|
237
242
|
return Effect.fail(error);
|
|
238
243
|
}),
|
|
239
244
|
Effect.timeoutOrElse({
|
|
240
|
-
duration:
|
|
245
|
+
duration: timeoutSeconds * 1000,
|
|
241
246
|
orElse: () => Effect.succeed(null),
|
|
242
247
|
}),
|
|
243
248
|
);
|
|
@@ -255,7 +260,7 @@ const watchRun = Effect.fn("workflow.watchRun")(function* (runId: number, repo:
|
|
|
255
260
|
})),
|
|
256
261
|
watchOutput:
|
|
257
262
|
result === null
|
|
258
|
-
? `(watch
|
|
263
|
+
? `(watch timed out after ${timeoutSeconds}s; status taken from snapshot — re-run to keep watching)`
|
|
259
264
|
: result.stdout,
|
|
260
265
|
};
|
|
261
266
|
});
|
|
@@ -650,11 +655,21 @@ export const workflowWatchCommand = Command.make(
|
|
|
650
655
|
format: formatOption,
|
|
651
656
|
repo: repoOption,
|
|
652
657
|
run: Flag.integer("run").pipe(Flag.withDescription("Workflow run ID to watch")),
|
|
658
|
+
timeout: Flag.integer("timeout").pipe(
|
|
659
|
+
Flag.withDescription(
|
|
660
|
+
`Max seconds to block before returning a snapshot (default: ${DEFAULT_WATCH_RUN_TIMEOUT_SECONDS}, minimum 1)`,
|
|
661
|
+
),
|
|
662
|
+
Flag.withDefault(DEFAULT_WATCH_RUN_TIMEOUT_SECONDS),
|
|
663
|
+
Flag.filter(
|
|
664
|
+
(n) => n >= 1,
|
|
665
|
+
() => "--timeout must be at least 1 second",
|
|
666
|
+
),
|
|
667
|
+
),
|
|
653
668
|
},
|
|
654
|
-
({ format, repo, run }) =>
|
|
669
|
+
({ format, repo, run, timeout }) =>
|
|
655
670
|
Effect.gen(function* () {
|
|
656
671
|
const resolvedRepo = yield* resolveRepoArg(repo);
|
|
657
|
-
const result = yield* watchRun(run, resolvedRepo);
|
|
672
|
+
const result = yield* watchRun(run, resolvedRepo, timeout);
|
|
658
673
|
yield* logFormatted(result, format);
|
|
659
674
|
}),
|
|
660
675
|
).pipe(Command.withDescription("Watch a workflow run until it completes, then show final status"));
|