@blogic-cz/agent-tools 0.14.60 → 0.14.62
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 +6 -0
- package/package.json +1 -1
- package/src/gh-tool/pr/core.ts +120 -54
- package/src/k8s-tool/index.ts +12 -11
- package/src/k8s-tool/security.ts +265 -49
- package/src/k8s-tool/service.ts +136 -16
- package/src/logs-tool/index.ts +1 -1
- package/src/logs-tool/service.ts +94 -47
package/README.md
CHANGED
|
@@ -252,6 +252,12 @@ export default { handleToolExecuteBefore };
|
|
|
252
252
|
|
|
253
253
|
All tools support `--help` for full usage documentation. Legacy `agent-tools-*` binary names (e.g. `agent-tools-gh`) still work for backwards compatibility.
|
|
254
254
|
|
|
255
|
+
### Kubernetes command safety
|
|
256
|
+
|
|
257
|
+
`k8s-tool` parses generic kubectl commands into arguments and invokes `kubectl` directly. Shell pipelines, chaining, substitution, user overrides of the configured cluster or credentials, mutating `config`/`auth` subcommands, and `cluster-info dump` are rejected. Direct Secret reads, raw kubeconfig output, filename/kustomize reads, and `kubectl diff` are also blocked.
|
|
258
|
+
|
|
259
|
+
Pod `exec` is limited to direct `redis-cli PING/INFO` and `ls` diagnostics. Generic exec cannot read file contents; use `logs-tool`, which confines files to the configured log directory, tails them through an internal structured operation, and applies the same case-insensitive literal substring filter locally and remotely. Configured log directories are a trusted boundary and must not permit adversarial symlink replacement during reads.
|
|
260
|
+
|
|
255
261
|
### gh-tool machine contracts
|
|
256
262
|
|
|
257
263
|
`pr view` adds `headSha` and `baseSha`; failed-check evidence adds the same SHA pair. Review summaries, inline comments, and threads add `commitSha` plus `feedbackOrigin`: `current_head` only for an exact `commitSha === headSha`, `pre_existing` for a different known SHA (not an obsolescence verdict), and `unknown` when either SHA is absent. Issue comments always use `commitSha: null` and `feedbackOrigin: unknown`. `review-triage` preserves existing fields and adds `inlineComments` plus per-kind `feedbackOriginCounts`; batch triage returns the same object per PR.
|
package/package.json
CHANGED
package/src/gh-tool/pr/core.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type {
|
|
|
16
16
|
WorkflowRunDetail,
|
|
17
17
|
} from "#gh/types";
|
|
18
18
|
|
|
19
|
+
import type { GitHubAuthError, GitHubNotFoundError } from "#gh/errors";
|
|
19
20
|
import { GitHubCommandError, GitHubMergeError } from "#gh/errors";
|
|
20
21
|
import { GitHubService } from "#gh/service";
|
|
21
22
|
|
|
@@ -24,6 +25,7 @@ import { runLocalCommand } from "./helpers";
|
|
|
24
25
|
import { diagnoseLogEntries, fetchJobLogs, formatLogEntries, parseRawJobLogs } from "#gh/workflow";
|
|
25
26
|
|
|
26
27
|
const CHECK_JSON_FIELDS = "name,state,bucket,link";
|
|
28
|
+
const LONG_LIVED_BRANCHES = new Set(["main", "master", "develop", "staging", "production"]);
|
|
27
29
|
const STABLE_SNAPSHOT_ATTEMPTS = 3;
|
|
28
30
|
const GITHUB_ACTIONS_RUN_ID_RE = /github\.com\/[^/]+\/[^/]+\/actions\/runs\/(\d+)/;
|
|
29
31
|
|
|
@@ -695,13 +697,22 @@ export const mergePR = Effect.fn("pr.mergePR")(function* (opts: {
|
|
|
695
697
|
"number,url,title,headRefName,baseRefName,state,isDraft,mergeable",
|
|
696
698
|
]);
|
|
697
699
|
|
|
700
|
+
const repo = opts.deleteBranch ? yield* gh.getRepoInfo() : null;
|
|
701
|
+
|
|
702
|
+
// A long-lived branch (default/env branch) as PR head means a promotion PR
|
|
703
|
+
// (e.g. main -> staging). PRs based on it are unrelated work, not a stack —
|
|
704
|
+
// retargeting them would mass-rewrite their base — and the branch itself
|
|
705
|
+
// must never be deleted.
|
|
706
|
+
const headIsLongLived =
|
|
707
|
+
LONG_LIVED_BRANCHES.has(info.headRefName) || info.headRefName === repo?.defaultBranch;
|
|
708
|
+
|
|
698
709
|
// Stacked-PR safety: find open PRs that depend on this PR's head branch.
|
|
699
710
|
// Deleting the head branch of an open PR that uses it as its base CLOSES that
|
|
700
711
|
// PR (GitHub CLI behavior, see cli/cli#1168) instead of retargeting it. We
|
|
701
712
|
// retarget such dependents onto this PR's base first, and only delete the
|
|
702
713
|
// branch if EVERY retarget succeeds (fail-closed).
|
|
703
714
|
const dependentOpenPrs =
|
|
704
|
-
opts.deleteBranch && info.headRefName
|
|
715
|
+
opts.deleteBranch && !headIsLongLived && info.headRefName
|
|
705
716
|
? yield* gh.runGhJson<Array<{ number: number; headRefName: string; baseRefName: string }>>([
|
|
706
717
|
"pr",
|
|
707
718
|
"list",
|
|
@@ -722,18 +733,23 @@ export const mergePR = Effect.fn("pr.mergePR")(function* (opts: {
|
|
|
722
733
|
? "PR is mergeable."
|
|
723
734
|
: `PR mergeable status: ${info.mergeable}`;
|
|
724
735
|
|
|
725
|
-
const dependentNote =
|
|
726
|
-
|
|
736
|
+
const dependentNote = headIsLongLived
|
|
737
|
+
? opts.deleteBranch
|
|
738
|
+
? `Head \`${info.headRefName}\` is a long-lived branch; deletion and dependent retargeting are skipped. `
|
|
739
|
+
: ""
|
|
740
|
+
: dependentOpenPrs.length > 0
|
|
727
741
|
? `${dependentOpenPrs.length} dependent open PR(s) (${dependentOpenPrs
|
|
728
742
|
.map((d) => `#${d.number}`)
|
|
729
|
-
.join(", ")}) will be retargeted to \`${info.baseRefName}\` before deletion
|
|
730
|
-
"branch deletion is skipped if any retarget fails. "
|
|
743
|
+
.join(", ")}) will be retargeted to \`${info.baseRefName}\` before deletion ` +
|
|
744
|
+
"(rolled back if the merge fails); branch deletion is skipped if any retarget fails. "
|
|
731
745
|
: "";
|
|
732
746
|
|
|
733
747
|
yield* Console.log(
|
|
734
748
|
`DRY RUN: Would merge PR #${info.number} "${info.title}" via ${opts.strategy.toUpperCase()}. ` +
|
|
735
749
|
`Branch \`${info.headRefName}\` → \`${info.baseRefName}\`. ` +
|
|
736
|
-
(opts.deleteBranch
|
|
750
|
+
(opts.deleteBranch && !headIsLongLived
|
|
751
|
+
? `Remote branch \`${info.headRefName}\` will be deleted. `
|
|
752
|
+
: "") +
|
|
737
753
|
dependentNote +
|
|
738
754
|
mergeableNote,
|
|
739
755
|
);
|
|
@@ -747,14 +763,16 @@ export const mergePR = Effect.fn("pr.mergePR")(function* (opts: {
|
|
|
747
763
|
return result;
|
|
748
764
|
}
|
|
749
765
|
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
let willDeleteBranch = opts.deleteBranch;
|
|
753
|
-
let branchDeleteSkipped = false;
|
|
766
|
+
let willDeleteBranch = opts.deleteBranch && !headIsLongLived;
|
|
767
|
+
let branchDeleteSkipped = opts.deleteBranch && headIsLongLived;
|
|
754
768
|
const retargetedChildren: number[] = [];
|
|
755
|
-
const repo = opts.deleteBranch ? yield* gh.getRepoInfo() : null;
|
|
756
769
|
|
|
757
|
-
|
|
770
|
+
// Retarget dependents BEFORE merging: repos with "Automatically delete head
|
|
771
|
+
// branches" delete the head as part of the merge itself, which closes any PR
|
|
772
|
+
// still based on it (cli/cli#1168). A failed merge rolls the retargets back.
|
|
773
|
+
// If any retarget fails, keep the branch (fail-closed) so no dependent PR is
|
|
774
|
+
// closed.
|
|
775
|
+
if (willDeleteBranch && dependentOpenPrs.length > 0 && repo) {
|
|
758
776
|
for (const child of dependentOpenPrs) {
|
|
759
777
|
const retargeted = yield* gh
|
|
760
778
|
.runGh([
|
|
@@ -780,54 +798,102 @@ export const mergePR = Effect.fn("pr.mergePR")(function* (opts: {
|
|
|
780
798
|
}
|
|
781
799
|
}
|
|
782
800
|
|
|
801
|
+
const rollbackRetargets = Effect.gen(function* () {
|
|
802
|
+
const failed: number[] = [];
|
|
803
|
+
if (retargetedChildren.length === 0 || !repo) {
|
|
804
|
+
return failed;
|
|
805
|
+
}
|
|
806
|
+
for (const child of retargetedChildren) {
|
|
807
|
+
const rolledBack = yield* gh
|
|
808
|
+
.runGh([
|
|
809
|
+
"api",
|
|
810
|
+
"--method",
|
|
811
|
+
"PATCH",
|
|
812
|
+
`repos/${repo.owner}/${repo.name}/pulls/${child}`,
|
|
813
|
+
"-f",
|
|
814
|
+
`base=${info.headRefName}`,
|
|
815
|
+
])
|
|
816
|
+
.pipe(
|
|
817
|
+
Effect.as(true),
|
|
818
|
+
Effect.orElseSucceed(() => false),
|
|
819
|
+
);
|
|
820
|
+
if (!rolledBack) {
|
|
821
|
+
failed.push(child);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
return failed;
|
|
825
|
+
});
|
|
826
|
+
|
|
783
827
|
const mergeArgs = ["pr", "merge", String(opts.pr), `--${opts.strategy}`];
|
|
784
828
|
|
|
785
829
|
const mergeResult = yield* gh.runGh(mergeArgs).pipe(
|
|
786
|
-
Effect.
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
830
|
+
Effect.catch((error) =>
|
|
831
|
+
rollbackRetargets.pipe(
|
|
832
|
+
Effect.andThen(
|
|
833
|
+
(
|
|
834
|
+
rollbackFailed,
|
|
835
|
+
): Effect.Effect<never, GitHubNotFoundError | GitHubAuthError | GitHubMergeError> => {
|
|
836
|
+
const rollbackNote =
|
|
837
|
+
rollbackFailed.length > 0
|
|
838
|
+
? ` ROLLBACK INCOMPLETE: dependent PR(s) ${rollbackFailed
|
|
839
|
+
.map((child) => `#${child}`)
|
|
840
|
+
.join(", ")} are still retargeted to \`${info.baseRefName}\`; ` +
|
|
841
|
+
`manually restore their base to \`${info.headRefName}\`.`
|
|
842
|
+
: "";
|
|
843
|
+
|
|
844
|
+
if (error._tag !== "GitHubCommandError") {
|
|
845
|
+
return rollbackNote === ""
|
|
846
|
+
? Effect.fail(error)
|
|
847
|
+
: Console.error(rollbackNote.trim()).pipe(Effect.andThen(Effect.fail(error)));
|
|
848
|
+
}
|
|
799
849
|
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
850
|
+
const stderr = error.stderr.toLowerCase();
|
|
851
|
+
|
|
852
|
+
if (stderr.includes("merge conflict") || stderr.includes("conflicts")) {
|
|
853
|
+
return Effect.fail(
|
|
854
|
+
new GitHubMergeError({
|
|
855
|
+
message: `PR #${opts.pr} has merge conflicts`,
|
|
856
|
+
reason: "conflicts",
|
|
857
|
+
hint: `Resolve merge conflicts locally, push the fix, then retry the merge.${rollbackNote}`,
|
|
858
|
+
nextCommand: `gh pr diff ${opts.pr}`,
|
|
859
|
+
}),
|
|
860
|
+
);
|
|
861
|
+
}
|
|
811
862
|
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
863
|
+
if (stderr.includes("required status check") || stderr.includes("checks")) {
|
|
864
|
+
return Effect.fail(
|
|
865
|
+
new GitHubMergeError({
|
|
866
|
+
message: `PR #${opts.pr} has failing required checks`,
|
|
867
|
+
reason: "checks_failing",
|
|
868
|
+
hint: `Wait for CI checks to pass or investigate failures before merging.${rollbackNote}`,
|
|
869
|
+
nextCommand: `agent-tools-gh pr checks --pr ${opts.pr}`,
|
|
870
|
+
retryable: true,
|
|
871
|
+
}),
|
|
872
|
+
);
|
|
873
|
+
}
|
|
821
874
|
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
875
|
+
if (stderr.includes("protected branch")) {
|
|
876
|
+
return Effect.fail(
|
|
877
|
+
new GitHubMergeError({
|
|
878
|
+
message: `PR #${opts.pr} targets a protected branch`,
|
|
879
|
+
reason: "branch_protected",
|
|
880
|
+
hint: `This branch has protection rules. Ensure required reviews and checks are satisfied, or ask a repo admin.${rollbackNote}`,
|
|
881
|
+
}),
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
return Effect.fail(
|
|
886
|
+
new GitHubMergeError({
|
|
887
|
+
message: `Failed to merge PR #${opts.pr}: ${error.stderr}`,
|
|
888
|
+
reason: "unknown",
|
|
889
|
+
hint: `Check the PR state and branch protections. The PR may already be merged or closed.${rollbackNote}`,
|
|
890
|
+
nextCommand: `agent-tools-gh pr view --pr ${opts.pr}`,
|
|
891
|
+
}),
|
|
892
|
+
);
|
|
893
|
+
},
|
|
894
|
+
),
|
|
895
|
+
),
|
|
896
|
+
),
|
|
831
897
|
);
|
|
832
898
|
|
|
833
899
|
const shaMatch = mergeResult.stdout.match(/([0-9a-f]{7,40})/);
|
package/src/k8s-tool/index.ts
CHANGED
|
@@ -219,7 +219,7 @@ const kubectlCommand = Command.make(
|
|
|
219
219
|
`Kubernetes CLI Tool for Coding Agents
|
|
220
220
|
|
|
221
221
|
Executes kubectl commands against the correct cluster context.
|
|
222
|
-
|
|
222
|
+
Parses commands into arguments and rejects shell syntax.
|
|
223
223
|
|
|
224
224
|
IMPORTANT FOR AI AGENTS:
|
|
225
225
|
Always use this tool instead of kubectl directly to ensure
|
|
@@ -233,24 +233,25 @@ CLUSTER CONFIGURATION:
|
|
|
233
233
|
|
|
234
234
|
WORKFLOW FOR AI AGENTS:
|
|
235
235
|
1. Use this tool for ALL kubectl operations on test/prod
|
|
236
|
-
2.
|
|
237
|
-
3. Use -
|
|
236
|
+
2. Do not use pipes, chaining, substitution, or shell interpreters
|
|
237
|
+
3. Use logs-tool for remote log filtering
|
|
238
|
+
4. Use -n <namespace> for target namespace
|
|
238
239
|
|
|
239
240
|
EXAMPLES:
|
|
240
241
|
# List pods in test namespace
|
|
241
242
|
bun run src/k8s-tool kubectl --env test --cmd "get pods -n my-app-test"
|
|
242
243
|
|
|
243
|
-
# Get pod logs
|
|
244
|
-
bun run src/k8s-tool kubectl --env test --cmd "logs -l app=web-app -n my-app-test --tail=100
|
|
244
|
+
# Get pod logs (use logs-tool when filtering is required)
|
|
245
|
+
bun run src/k8s-tool kubectl --env test --cmd "logs -l app=web-app -n my-app-test --tail=100"
|
|
245
246
|
|
|
246
247
|
# Check resource usage
|
|
247
248
|
bun run src/k8s-tool kubectl --env test --cmd "top pod -n my-app-test"
|
|
248
249
|
|
|
249
|
-
# Describe pod
|
|
250
|
-
bun run src/k8s-tool kubectl --env test --cmd "describe pod web-app-xxx -n my-app-test
|
|
250
|
+
# Describe a pod
|
|
251
|
+
bun run src/k8s-tool kubectl --env test --cmd "describe pod web-app-xxx -n my-app-test"
|
|
251
252
|
|
|
252
|
-
# Execute
|
|
253
|
-
bun run src/k8s-tool kubectl --env test --cmd "exec web-app-xxx -n my-app-test --
|
|
253
|
+
# Execute an allowlisted diagnostic in a pod
|
|
254
|
+
bun run src/k8s-tool kubectl --env test --cmd "exec web-app-xxx -n my-app-test -- redis-cli INFO commandstats"
|
|
254
255
|
|
|
255
256
|
# Dry run - show command without executing
|
|
256
257
|
bun run src/k8s-tool kubectl --env test --cmd "get pods -n my-app-test" --dry-run
|
|
@@ -367,7 +368,7 @@ const execCommand = Command.make(
|
|
|
367
368
|
...commonFlags,
|
|
368
369
|
pod: Flag.string("pod").pipe(Flag.withDescription("Pod name")),
|
|
369
370
|
execCmd: Flag.string("exec-cmd").pipe(
|
|
370
|
-
Flag.withDescription("
|
|
371
|
+
Flag.withDescription("Allowlisted diagnostic: redis-cli PING/INFO or ls"),
|
|
371
372
|
),
|
|
372
373
|
namespace: Flag.string("namespace").pipe(
|
|
373
374
|
Flag.withDescription("Namespace containing the pod"),
|
|
@@ -391,7 +392,7 @@ const execCommand = Command.make(
|
|
|
391
392
|
]);
|
|
392
393
|
return yield* runK8sCommand(command, { dryRun, env, format, profile });
|
|
393
394
|
}),
|
|
394
|
-
).pipe(Command.withDescription("
|
|
395
|
+
).pipe(Command.withDescription("Run an allowlisted diagnostic in a pod"));
|
|
395
396
|
|
|
396
397
|
const topCommand = Command.make(
|
|
397
398
|
"top",
|
package/src/k8s-tool/security.ts
CHANGED
|
@@ -1,12 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
* K8s Security Module
|
|
3
|
-
*
|
|
4
|
-
* Validates kubectl commands before execution. Only read-only operations
|
|
5
|
-
* are allowed for AI agents. Mutating operations (delete, apply, patch, etc.)
|
|
6
|
-
* are blocked to prevent accidental or unauthorized changes to clusters.
|
|
7
|
-
*/
|
|
1
|
+
import { posix } from "node:path";
|
|
8
2
|
|
|
9
|
-
/** Kubectl verbs that are safe for AI agents (read-only / non-destructive) */
|
|
10
3
|
export const ALLOWED_KUBECTL_VERBS = [
|
|
11
4
|
"get",
|
|
12
5
|
"describe",
|
|
@@ -18,14 +11,10 @@ export const ALLOWED_KUBECTL_VERBS = [
|
|
|
18
11
|
"version",
|
|
19
12
|
"cluster-info",
|
|
20
13
|
"auth",
|
|
21
|
-
"diff",
|
|
22
14
|
"wait",
|
|
23
15
|
"exec",
|
|
24
|
-
"port-forward",
|
|
25
16
|
"config",
|
|
26
17
|
] as const;
|
|
27
|
-
|
|
28
|
-
/** Kubectl verbs that are explicitly blocked (mutating / destructive) */
|
|
29
18
|
export const BLOCKED_KUBECTL_VERBS = [
|
|
30
19
|
"delete",
|
|
31
20
|
"drain",
|
|
@@ -51,51 +40,278 @@ export const BLOCKED_KUBECTL_VERBS = [
|
|
|
51
40
|
export type K8sSecurityCheckResult = {
|
|
52
41
|
allowed: boolean;
|
|
53
42
|
command: string;
|
|
43
|
+
argv?: string[];
|
|
54
44
|
reason?: string;
|
|
45
|
+
hint?: string;
|
|
55
46
|
verb?: string;
|
|
56
47
|
};
|
|
57
48
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
49
|
+
export function parseKubectlCommand(cmd: string): string[] | undefined {
|
|
50
|
+
const argv: string[] = [];
|
|
51
|
+
let word = "";
|
|
52
|
+
let quote: "'" | '"' | undefined;
|
|
53
|
+
let escaped = false;
|
|
54
|
+
for (const char of cmd) {
|
|
55
|
+
if (escaped) {
|
|
56
|
+
word += char;
|
|
57
|
+
escaped = false;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (char === "\n" || char === "\r") return undefined;
|
|
61
|
+
if (char === "\\") {
|
|
62
|
+
escaped = true;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (quote) {
|
|
66
|
+
if (char === quote) quote = undefined;
|
|
67
|
+
else word += char;
|
|
68
|
+
} else if (char === "'" || char === '"') quote = char;
|
|
69
|
+
else if (/[$`;&|<>()[\]]/.test(char)) return undefined;
|
|
70
|
+
else if (/\s/.test(char)) {
|
|
71
|
+
if (word) {
|
|
72
|
+
argv.push(word);
|
|
73
|
+
word = "";
|
|
74
|
+
}
|
|
75
|
+
} else word += char;
|
|
76
|
+
}
|
|
77
|
+
if (quote || escaped) return undefined;
|
|
78
|
+
if (word) argv.push(word);
|
|
79
|
+
return argv.length ? argv : undefined;
|
|
80
|
+
}
|
|
69
81
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
82
|
+
const flagsWithValues = new Set([
|
|
83
|
+
"-n",
|
|
84
|
+
"--namespace",
|
|
85
|
+
"-o",
|
|
86
|
+
"--output",
|
|
87
|
+
"-l",
|
|
88
|
+
"--selector",
|
|
89
|
+
"--field-selector",
|
|
90
|
+
"-L",
|
|
91
|
+
"--label-columns",
|
|
92
|
+
"--chunk-size",
|
|
93
|
+
"--sort-by",
|
|
94
|
+
"--subresource",
|
|
95
|
+
"--template",
|
|
96
|
+
"--context",
|
|
97
|
+
"--kubeconfig",
|
|
98
|
+
"--request-timeout",
|
|
99
|
+
"-s",
|
|
100
|
+
"--server",
|
|
101
|
+
"--as",
|
|
102
|
+
"--as-group",
|
|
103
|
+
"--as-uid",
|
|
104
|
+
"--token",
|
|
105
|
+
"--certificate-authority",
|
|
106
|
+
"--cache-dir",
|
|
107
|
+
"--client-certificate",
|
|
108
|
+
"--client-key",
|
|
109
|
+
"--cluster",
|
|
110
|
+
"--password",
|
|
111
|
+
"--profile",
|
|
112
|
+
"--profile-output",
|
|
113
|
+
"--tls-server-name",
|
|
114
|
+
"--user",
|
|
115
|
+
"--username",
|
|
116
|
+
]);
|
|
117
|
+
const controlledFlags = new Set([
|
|
118
|
+
"-s",
|
|
119
|
+
"--context",
|
|
120
|
+
"--kubeconfig",
|
|
121
|
+
"--server",
|
|
122
|
+
"--token",
|
|
123
|
+
"--user",
|
|
124
|
+
"--username",
|
|
125
|
+
"--password",
|
|
126
|
+
"--profile",
|
|
127
|
+
"--profile-output",
|
|
128
|
+
"--as",
|
|
129
|
+
"--as-group",
|
|
130
|
+
"--as-uid",
|
|
131
|
+
"--certificate-authority",
|
|
132
|
+
"--client-certificate",
|
|
133
|
+
"--client-key",
|
|
134
|
+
"--cluster",
|
|
135
|
+
"--tls-server-name",
|
|
136
|
+
"--insecure-skip-tls-verify",
|
|
137
|
+
"--insecure-skip-tls-verify-backend",
|
|
138
|
+
]);
|
|
139
|
+
const attachedShortValueFlags = ["-n", "-o", "-l", "-L"] as const;
|
|
140
|
+
const flagsWithoutValues = new Set([
|
|
141
|
+
"-A",
|
|
142
|
+
"--all-namespaces",
|
|
143
|
+
"--allow-missing-template-keys",
|
|
144
|
+
"--ignore-not-found",
|
|
145
|
+
"--no-headers",
|
|
146
|
+
"--output-watch-events",
|
|
147
|
+
"-R",
|
|
148
|
+
"--recursive",
|
|
149
|
+
"--server-print",
|
|
150
|
+
"--show-events",
|
|
151
|
+
"--show-kind",
|
|
152
|
+
"--show-labels",
|
|
153
|
+
"--show-managed-fields",
|
|
154
|
+
"--use-openapi-print-columns",
|
|
155
|
+
"-w",
|
|
156
|
+
"--watch",
|
|
157
|
+
"--watch-only",
|
|
158
|
+
]);
|
|
159
|
+
const isSecretResource = (resource: string) =>
|
|
160
|
+
resource.split(",").some((part) => /^(?:secrets?|secrets?\.[^/]+)(?:\/|$)/i.test(part));
|
|
75
161
|
|
|
76
|
-
|
|
77
|
-
|
|
162
|
+
function resourceOperand(argv: string[], verbIndex: number): string | null | undefined {
|
|
163
|
+
let flagsEnded = false;
|
|
164
|
+
for (let i = verbIndex + 1; i < argv.length; i++) {
|
|
165
|
+
const arg = argv[i];
|
|
166
|
+
if (arg === undefined) return undefined;
|
|
167
|
+
if (!flagsEnded && arg === "--") {
|
|
168
|
+
flagsEnded = true;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (!flagsEnded && arg.startsWith("-")) {
|
|
172
|
+
const [flag] = arg.split("=", 1);
|
|
173
|
+
if (flag !== undefined && flagsWithValues.has(flag)) {
|
|
174
|
+
if (!arg.includes("=")) i++;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (
|
|
178
|
+
attachedShortValueFlags.some(
|
|
179
|
+
(shortFlag) => arg.startsWith(shortFlag) && arg.length > shortFlag.length,
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
continue;
|
|
183
|
+
if (flag !== undefined && flagsWithoutValues.has(flag)) continue;
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
return arg;
|
|
78
187
|
}
|
|
188
|
+
}
|
|
79
189
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
190
|
+
export function isSafeLogPath(path: string): boolean {
|
|
191
|
+
const normalizedPath = posix.normalize(path);
|
|
192
|
+
return (
|
|
193
|
+
path.startsWith("/") &&
|
|
194
|
+
!path.split("/").includes("..") &&
|
|
195
|
+
/^\/(?!proc(?:\/|$)|sys(?:\/|$)|var\/run\/secrets(?:\/|$)).*\.log$/.test(normalizedPath) &&
|
|
196
|
+
!normalizedPath.toLowerCase().includes("serviceaccount")
|
|
197
|
+
);
|
|
198
|
+
}
|
|
89
199
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
200
|
+
function execAllowed(argv: string[], separator: number): boolean {
|
|
201
|
+
const command = argv[separator + 1];
|
|
202
|
+
const args = argv.slice(separator + 2);
|
|
203
|
+
if (!command) return false;
|
|
204
|
+
if (command === "redis-cli")
|
|
205
|
+
return (
|
|
206
|
+
(args.length === 1 && args[0] === "PING") ||
|
|
207
|
+
((args.length === 1 || args.length === 2) && args[0]?.toUpperCase() === "INFO")
|
|
208
|
+
);
|
|
209
|
+
if (command === "ls")
|
|
210
|
+
return args.every((arg) => arg === "-la" || (arg.startsWith("/") && !arg.includes("..")));
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
99
213
|
|
|
100
|
-
|
|
214
|
+
export function isKubectlCommandAllowed(cmd: string): K8sSecurityCheckResult {
|
|
215
|
+
const argv = parseKubectlCommand(cmd);
|
|
216
|
+
if (!argv)
|
|
217
|
+
return { allowed: false, command: cmd, reason: "Empty, malformed, or shell syntax command." };
|
|
218
|
+
let verbIndex = 0;
|
|
219
|
+
while (argv[verbIndex]?.startsWith("-")) {
|
|
220
|
+
const flag = argv[verbIndex];
|
|
221
|
+
if (flag !== undefined && flagsWithValues.has(flag)) verbIndex++;
|
|
222
|
+
verbIndex++;
|
|
223
|
+
}
|
|
224
|
+
const verb = argv[verbIndex]?.toLowerCase();
|
|
225
|
+
if (!verb) return { allowed: false, command: cmd, reason: "Empty kubectl command." };
|
|
226
|
+
const denied = (reason: string, hint?: string): K8sSecurityCheckResult => ({
|
|
227
|
+
allowed: false,
|
|
228
|
+
command: cmd,
|
|
229
|
+
verb,
|
|
230
|
+
reason,
|
|
231
|
+
hint,
|
|
232
|
+
});
|
|
233
|
+
if ((BLOCKED_KUBECTL_VERBS as readonly string[]).includes(verb))
|
|
234
|
+
return denied(
|
|
235
|
+
`'${verb}' is a mutating operation blocked for AI agents. Only read-only operations are allowed: ${ALLOWED_KUBECTL_VERBS.join(", ")}.`,
|
|
236
|
+
);
|
|
237
|
+
if (!(ALLOWED_KUBECTL_VERBS as readonly string[]).includes(verb))
|
|
238
|
+
return denied(
|
|
239
|
+
`Unknown kubectl verb '${verb}'. Only known read-only operations are allowed: ${ALLOWED_KUBECTL_VERBS.join(", ")}.`,
|
|
240
|
+
);
|
|
241
|
+
const controlledFlag = argv.find((arg) => {
|
|
242
|
+
const flag = arg.split("=", 1)[0] ?? "";
|
|
243
|
+
return controlledFlags.has(flag) || (arg.startsWith("-s") && !arg.startsWith("--"));
|
|
244
|
+
});
|
|
245
|
+
if (controlledFlag !== undefined)
|
|
246
|
+
return denied(
|
|
247
|
+
`Cluster, authentication, and impersonation flag '${controlledFlag}' is controlled by the selected profile.`,
|
|
248
|
+
"Remove the override and select the intended Kubernetes profile instead.",
|
|
249
|
+
);
|
|
250
|
+
const subcommand = argv[verbIndex + 1]?.toLowerCase();
|
|
251
|
+
if (
|
|
252
|
+
verb === "config" &&
|
|
253
|
+
(subcommand === undefined || !["view", "get-contexts", "current-context"].includes(subcommand))
|
|
254
|
+
)
|
|
255
|
+
return denied(
|
|
256
|
+
"Only read-only kubectl config subcommands are allowed.",
|
|
257
|
+
"Use config view, config get-contexts, or config current-context.",
|
|
258
|
+
);
|
|
259
|
+
if (verb === "auth" && !["can-i", "whoami"].includes(subcommand ?? ""))
|
|
260
|
+
return denied(
|
|
261
|
+
"Only read-only kubectl auth subcommands are allowed.",
|
|
262
|
+
"Use auth can-i or auth whoami.",
|
|
263
|
+
);
|
|
264
|
+
if (verb === "cluster-info" && subcommand === "dump")
|
|
265
|
+
return denied(
|
|
266
|
+
"cluster-info dump is blocked because it may expose sensitive diagnostic data.",
|
|
267
|
+
"Use cluster-info without dump.",
|
|
268
|
+
);
|
|
269
|
+
const resource = resourceOperand(argv, verbIndex);
|
|
270
|
+
const hasSensitiveInputFlag = argv.some(
|
|
271
|
+
(arg) =>
|
|
272
|
+
arg === "-f" ||
|
|
273
|
+
arg.startsWith("-f") ||
|
|
274
|
+
arg === "--filename" ||
|
|
275
|
+
arg === "-k" ||
|
|
276
|
+
arg.startsWith("-k") ||
|
|
277
|
+
arg === "--kustomize" ||
|
|
278
|
+
/^(?:--filename|--kustomize)=/.test(arg) ||
|
|
279
|
+
/^--raw(?:=|$)/.test(arg),
|
|
280
|
+
);
|
|
281
|
+
if ((verb === "get" || verb === "describe") && hasSensitiveInputFlag)
|
|
282
|
+
return denied(
|
|
283
|
+
"Kubernetes Secret reads and file-based reads are blocked because they may expose credentials.",
|
|
284
|
+
"Use a targeted non-secret resource diagnostic instead.",
|
|
285
|
+
);
|
|
286
|
+
if ((verb === "get" || verb === "describe") && resource === null)
|
|
287
|
+
return denied(
|
|
288
|
+
"Unsupported flag before the resource operand.",
|
|
289
|
+
"Use a documented get/describe flag or place the resource first.",
|
|
290
|
+
);
|
|
291
|
+
const hasNamedSecretResource = argv
|
|
292
|
+
.slice(verbIndex + 1)
|
|
293
|
+
.some((arg) => arg.includes("/") && isSecretResource(arg));
|
|
294
|
+
if (
|
|
295
|
+
(verb === "get" || verb === "describe") &&
|
|
296
|
+
((resource !== undefined && resource !== null && isSecretResource(resource)) ||
|
|
297
|
+
hasNamedSecretResource)
|
|
298
|
+
)
|
|
299
|
+
return denied(
|
|
300
|
+
"Kubernetes Secret reads and file-based reads are blocked because they may expose credentials.",
|
|
301
|
+
"Use a targeted non-secret resource diagnostic instead.",
|
|
302
|
+
);
|
|
303
|
+
if (verb === "config" && argv.includes("view") && argv.some((arg) => /^--raw(?:=|$)/.test(arg)))
|
|
304
|
+
return denied(
|
|
305
|
+
"Raw kubeconfig output is blocked because it may expose credentials.",
|
|
306
|
+
"Use 'config view' without --raw.",
|
|
307
|
+
);
|
|
308
|
+
if (verb === "exec") {
|
|
309
|
+
const separator = argv.indexOf("--", verbIndex + 1);
|
|
310
|
+
if (separator < 0 || !execAllowed(argv, separator))
|
|
311
|
+
return denied(
|
|
312
|
+
"Only narrow diagnostic commands are allowed in pods.",
|
|
313
|
+
"Use redis-cli PING/INFO or ls. Use logs-tool to read log files.",
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
return { allowed: true, command: cmd, argv, verb };
|
|
101
317
|
}
|
package/src/k8s-tool/service.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { posix } from "node:path";
|
|
2
|
+
|
|
1
3
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
|
|
2
4
|
import { Context, Effect, Layer, Option, Ref, Stream } from "effect";
|
|
3
5
|
|
|
@@ -16,7 +18,7 @@ import { resolveEnvTemplate } from "#shared/env-template";
|
|
|
16
18
|
import { isPrerequisiteRunError } from "#shared/prerequisites/errors";
|
|
17
19
|
import { runWithProfilePrerequisites } from "#shared/prerequisites/runtime";
|
|
18
20
|
import { buildApiProbeArgs } from "#shared/k8s-probe";
|
|
19
|
-
import { isKubectlCommandAllowed } from "./security";
|
|
21
|
+
import { isKubectlCommandAllowed, isSafeLogPath } from "./security";
|
|
20
22
|
|
|
21
23
|
export class K8sService extends Context.Service<
|
|
22
24
|
K8sService,
|
|
@@ -37,6 +39,16 @@ export class K8sService extends Context.Service<
|
|
|
37
39
|
CommandResult,
|
|
38
40
|
K8sContextError | K8sCommandError | K8sTimeoutError | K8sDangerousCommandError
|
|
39
41
|
>;
|
|
42
|
+
readonly runLogTail: (
|
|
43
|
+
pod: string,
|
|
44
|
+
basePath: string,
|
|
45
|
+
path: string,
|
|
46
|
+
lines: number,
|
|
47
|
+
profile?: string,
|
|
48
|
+
) => Effect.Effect<
|
|
49
|
+
CommandResult,
|
|
50
|
+
K8sContextError | K8sCommandError | K8sTimeoutError | K8sDangerousCommandError
|
|
51
|
+
>;
|
|
40
52
|
}
|
|
41
53
|
>()("@agent-tools/K8sService") {
|
|
42
54
|
static readonly layer = Layer.effect(
|
|
@@ -86,6 +98,10 @@ export class K8sService extends Context.Service<
|
|
|
86
98
|
|
|
87
99
|
const withKubeconfig = (command: string, kubeconfig: string | undefined) =>
|
|
88
100
|
kubeconfig ? `KUBECONFIG=${quoteShellArg(kubeconfig)} ${command}` : command;
|
|
101
|
+
const renderArg = (arg: string) =>
|
|
102
|
+
/^[A-Za-z0-9_./:=,@+-]+$/.test(arg) ? arg : quoteShellArg(arg);
|
|
103
|
+
const renderKubectlCommand = (context: string, argv: readonly string[]) =>
|
|
104
|
+
["kubectl", "--context", context, ...argv].map(renderArg).join(" ");
|
|
89
105
|
|
|
90
106
|
// Cache context by selected profile/cluster instead of a single default profile.
|
|
91
107
|
const contextRef = yield* Ref.make<Record<string, string>>({});
|
|
@@ -253,7 +269,7 @@ export class K8sService extends Context.Service<
|
|
|
253
269
|
});
|
|
254
270
|
|
|
255
271
|
const executeCommand = Effect.fn("K8sService.executeCommand")(function* (
|
|
256
|
-
|
|
272
|
+
argv: readonly string[],
|
|
257
273
|
profile?: string,
|
|
258
274
|
) {
|
|
259
275
|
const k8sConfig = yield* requireK8sConfig(profile);
|
|
@@ -275,9 +291,29 @@ export class K8sService extends Context.Service<
|
|
|
275
291
|
});
|
|
276
292
|
}
|
|
277
293
|
|
|
278
|
-
const fullCommand =
|
|
279
|
-
|
|
280
|
-
|
|
294
|
+
const fullCommand = renderKubectlCommand(context, argv);
|
|
295
|
+
const command = ChildProcess.make("kubectl", ["--context", context, ...argv], {
|
|
296
|
+
stdout: "pipe",
|
|
297
|
+
stderr: "pipe",
|
|
298
|
+
...(kubeconfig ? { env: { KUBECONFIG: kubeconfig }, extendEnv: true } : {}),
|
|
299
|
+
});
|
|
300
|
+
const resultOption = yield* Effect.scoped(
|
|
301
|
+
Effect.gen(function* () {
|
|
302
|
+
const process = yield* executor.spawn(command);
|
|
303
|
+
return yield* collectProcessOutput(process);
|
|
304
|
+
}),
|
|
305
|
+
).pipe(
|
|
306
|
+
Effect.timeoutOption(timeoutMs),
|
|
307
|
+
Effect.mapError(
|
|
308
|
+
(platformError) =>
|
|
309
|
+
new K8sCommandError({
|
|
310
|
+
message: `Command execution failed: ${String(platformError)}`,
|
|
311
|
+
command: fullCommand,
|
|
312
|
+
exitCode: -1,
|
|
313
|
+
stderr: undefined,
|
|
314
|
+
}),
|
|
315
|
+
),
|
|
316
|
+
);
|
|
281
317
|
|
|
282
318
|
if (Option.isNone(resultOption)) {
|
|
283
319
|
return yield* new K8sTimeoutError({
|
|
@@ -316,16 +352,18 @@ export class K8sService extends Context.Service<
|
|
|
316
352
|
) {
|
|
317
353
|
// Security: block dangerous commands before execution
|
|
318
354
|
const securityCheck = isKubectlCommandAllowed(cmd);
|
|
319
|
-
if (!securityCheck.allowed) {
|
|
355
|
+
if (!securityCheck.allowed || !securityCheck.argv) {
|
|
320
356
|
return yield* new K8sDangerousCommandError({
|
|
321
357
|
message: securityCheck.reason ?? "Command not allowed",
|
|
322
358
|
command: cmd,
|
|
323
|
-
verb: securityCheck.verb,
|
|
324
|
-
hint:
|
|
359
|
+
...(securityCheck.verb ? { verb: securityCheck.verb } : {}),
|
|
360
|
+
hint:
|
|
361
|
+
securityCheck.hint ??
|
|
362
|
+
"AI agents can only run read-only kubectl commands. For mutating operations, use kubectl directly or ask a human operator.",
|
|
325
363
|
});
|
|
326
364
|
}
|
|
327
365
|
|
|
328
|
-
const result = yield* executeCommand(
|
|
366
|
+
const result = yield* executeCommand(securityCheck.argv, profile);
|
|
329
367
|
if (result.exitCode !== 0) {
|
|
330
368
|
return yield* new K8sCommandError({
|
|
331
369
|
message: result.stderr ?? `kubectl exited with code ${result.exitCode}`,
|
|
@@ -345,20 +383,22 @@ export class K8sService extends Context.Service<
|
|
|
345
383
|
) {
|
|
346
384
|
// Security: block dangerous commands before execution (even dry-run)
|
|
347
385
|
const securityCheck = isKubectlCommandAllowed(cmd);
|
|
348
|
-
if (!securityCheck.allowed) {
|
|
386
|
+
if (!securityCheck.allowed || !securityCheck.argv) {
|
|
349
387
|
return yield* new K8sDangerousCommandError({
|
|
350
388
|
message: securityCheck.reason ?? "Command not allowed",
|
|
351
389
|
command: cmd,
|
|
352
|
-
verb: securityCheck.verb,
|
|
353
|
-
hint:
|
|
390
|
+
...(securityCheck.verb ? { verb: securityCheck.verb } : {}),
|
|
391
|
+
hint:
|
|
392
|
+
securityCheck.hint ??
|
|
393
|
+
"AI agents can only run read-only kubectl commands. For mutating operations, use kubectl directly or ask a human operator.",
|
|
354
394
|
});
|
|
355
395
|
}
|
|
356
396
|
|
|
357
397
|
const startTime = Date.now();
|
|
358
398
|
if (dryRun) {
|
|
359
399
|
const k8sConfig = yield* requireK8sConfig(profile);
|
|
360
|
-
const { context
|
|
361
|
-
const fullCommand =
|
|
400
|
+
const { context } = yield* resolveContext(profile, k8sConfig);
|
|
401
|
+
const fullCommand = renderKubectlCommand(context, securityCheck.argv);
|
|
362
402
|
return {
|
|
363
403
|
success: true,
|
|
364
404
|
command: fullCommand,
|
|
@@ -367,8 +407,88 @@ export class K8sService extends Context.Service<
|
|
|
367
407
|
};
|
|
368
408
|
}
|
|
369
409
|
|
|
370
|
-
const result = yield* executeCommand(
|
|
410
|
+
const result = yield* executeCommand(securityCheck.argv, profile);
|
|
411
|
+
|
|
412
|
+
if (result.exitCode !== 0) {
|
|
413
|
+
return yield* new K8sCommandError({
|
|
414
|
+
message: result.stderr ?? `kubectl exited with code ${result.exitCode}`,
|
|
415
|
+
command: result.command,
|
|
416
|
+
exitCode: result.exitCode,
|
|
417
|
+
stderr: result.stderr ?? undefined,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return {
|
|
422
|
+
success: true,
|
|
423
|
+
output: result.stdout.trim(),
|
|
424
|
+
command: result.command,
|
|
425
|
+
executionTimeMs: Date.now() - startTime,
|
|
426
|
+
};
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
const runLogTail = Effect.fn("K8sService.runLogTail")(function* (
|
|
430
|
+
pod: string,
|
|
431
|
+
basePath: string,
|
|
432
|
+
path: string,
|
|
433
|
+
lines: number,
|
|
434
|
+
profile?: string,
|
|
435
|
+
) {
|
|
436
|
+
const normalizedBase = posix.resolve(basePath);
|
|
437
|
+
const normalizedPath = posix.resolve(path);
|
|
438
|
+
const lexicalRelative = posix.relative(normalizedBase, normalizedPath);
|
|
439
|
+
if (
|
|
440
|
+
!/^[a-z0-9](?:[-a-z0-9.]*[a-z0-9])?$/.test(pod) ||
|
|
441
|
+
!Number.isInteger(lines) ||
|
|
442
|
+
lines < 1 ||
|
|
443
|
+
lexicalRelative === ".." ||
|
|
444
|
+
lexicalRelative.startsWith("../") ||
|
|
445
|
+
posix.isAbsolute(lexicalRelative) ||
|
|
446
|
+
!isSafeLogPath(normalizedPath)
|
|
447
|
+
) {
|
|
448
|
+
return yield* new K8sDangerousCommandError({
|
|
449
|
+
message: "Invalid internal log-tail request.",
|
|
450
|
+
command: "exec tail",
|
|
451
|
+
verb: "exec",
|
|
452
|
+
hint: "Use logs-tool with a log file inside its configured remote directory.",
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const realpathArgv = ["exec", pod, "--", "realpath", normalizedBase, normalizedPath];
|
|
457
|
+
const realpathResult = yield* executeCommand(realpathArgv, profile);
|
|
458
|
+
if (realpathResult.exitCode !== 0) {
|
|
459
|
+
return yield* new K8sCommandError({
|
|
460
|
+
message:
|
|
461
|
+
realpathResult.stderr ||
|
|
462
|
+
`Remote realpath exited with code ${realpathResult.exitCode}`,
|
|
463
|
+
command: realpathResult.command,
|
|
464
|
+
exitCode: realpathResult.exitCode,
|
|
465
|
+
stderr: realpathResult.stderr || undefined,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
const [canonicalBase, canonicalPath] = realpathResult.stdout.trim().split("\n");
|
|
469
|
+
const canonicalRelative =
|
|
470
|
+
canonicalBase === undefined || canonicalPath === undefined
|
|
471
|
+
? ".."
|
|
472
|
+
: posix.relative(canonicalBase, canonicalPath);
|
|
473
|
+
if (
|
|
474
|
+
canonicalBase === undefined ||
|
|
475
|
+
canonicalPath === undefined ||
|
|
476
|
+
canonicalRelative === ".." ||
|
|
477
|
+
canonicalRelative.startsWith("../") ||
|
|
478
|
+
posix.isAbsolute(canonicalRelative) ||
|
|
479
|
+
!isSafeLogPath(canonicalPath)
|
|
480
|
+
) {
|
|
481
|
+
return yield* new K8sDangerousCommandError({
|
|
482
|
+
message: "Canonical log path escapes the configured remote directory.",
|
|
483
|
+
command: realpathResult.command,
|
|
484
|
+
verb: "exec",
|
|
485
|
+
hint: "Remove symlinks that point outside the configured remote log directory.",
|
|
486
|
+
});
|
|
487
|
+
}
|
|
371
488
|
|
|
489
|
+
const argv = ["exec", pod, "--", "tail", "-n", String(lines), canonicalPath];
|
|
490
|
+
const startTime = Date.now();
|
|
491
|
+
const result = yield* executeCommand(argv, profile);
|
|
372
492
|
if (result.exitCode !== 0) {
|
|
373
493
|
return yield* new K8sCommandError({
|
|
374
494
|
message: result.stderr ?? `kubectl exited with code ${result.exitCode}`,
|
|
@@ -386,7 +506,7 @@ export class K8sService extends Context.Service<
|
|
|
386
506
|
};
|
|
387
507
|
});
|
|
388
508
|
|
|
389
|
-
return { runCommand, runKubectl };
|
|
509
|
+
return { runCommand, runKubectl, runLogTail };
|
|
390
510
|
}),
|
|
391
511
|
),
|
|
392
512
|
);
|
package/src/logs-tool/index.ts
CHANGED
|
@@ -158,7 +158,7 @@ const readCommand = Command.make(
|
|
|
158
158
|
),
|
|
159
159
|
format: formatOption,
|
|
160
160
|
grep: Flag.string("grep").pipe(
|
|
161
|
-
Flag.withDescription("Filter lines containing
|
|
161
|
+
Flag.withDescription("Filter lines containing case-insensitive literal text"),
|
|
162
162
|
Flag.optional,
|
|
163
163
|
),
|
|
164
164
|
pretty: Flag.boolean("pretty").pipe(
|
package/src/logs-tool/service.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
+
import { isAbsolute, posix, relative, resolve } from "node:path";
|
|
2
|
+
|
|
1
3
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
|
|
2
|
-
import { Context, Effect, Layer, Result
|
|
4
|
+
import { Context, Effect, Layer, Result } from "effect";
|
|
3
5
|
|
|
4
6
|
import type { Environment, LogFile, ReadOptions } from "./types";
|
|
5
7
|
|
|
6
|
-
import { K8sCommandError } from "#k8s/errors";
|
|
7
8
|
import { K8sService, K8sServiceLayer } from "#k8s/service";
|
|
8
9
|
import { ConfigService, ConfigServiceLayer, getToolConfig } from "#config/loader";
|
|
9
10
|
import type { LogsConfig } from "#config/types";
|
|
10
11
|
import { LogsNotFoundError, LogsReadError, type LogsError } from "./errors";
|
|
12
|
+
import { collectProcessOutput } from "#shared/exec";
|
|
11
13
|
import { transformLogOutput } from "./transformers";
|
|
12
14
|
|
|
13
15
|
export const parseLogFiles = (output: string): LogFile[] => {
|
|
@@ -46,6 +48,33 @@ export const formatPrettyOutput = (output: string): string => {
|
|
|
46
48
|
export const sanitizeShellArg = (input: string): string => `'${input.replace(/'/g, "'\\''")}'`;
|
|
47
49
|
|
|
48
50
|
const readCommandOutput = (output: unknown): string => (typeof output === "string" ? output : "");
|
|
51
|
+
const filterLogLines = (output: string, grep: string | undefined): string => {
|
|
52
|
+
if (!grep) return output;
|
|
53
|
+
const needle = grep.toLowerCase();
|
|
54
|
+
return output
|
|
55
|
+
.split("\n")
|
|
56
|
+
.filter((line) => line.toLowerCase().includes(needle))
|
|
57
|
+
.join("\n");
|
|
58
|
+
};
|
|
59
|
+
const resolveLocalLogPath = (base: string, file: string): string | undefined => {
|
|
60
|
+
const resolvedBase = resolve(base);
|
|
61
|
+
const resolvedPath = resolve(resolvedBase, file);
|
|
62
|
+
const relativePath = relative(resolvedBase, resolvedPath);
|
|
63
|
+
return relativePath === ".." ||
|
|
64
|
+
relativePath.startsWith(`..${pathSeparator}`) ||
|
|
65
|
+
isAbsolute(relativePath)
|
|
66
|
+
? undefined
|
|
67
|
+
: resolvedPath;
|
|
68
|
+
};
|
|
69
|
+
const pathSeparator = process.platform === "win32" ? "\\" : "/";
|
|
70
|
+
const resolveRemoteLogPath = (base: string, file: string): string | undefined => {
|
|
71
|
+
const resolvedBase = posix.resolve(base);
|
|
72
|
+
const resolvedPath = posix.resolve(resolvedBase, file);
|
|
73
|
+
const relativePath = posix.relative(resolvedBase, resolvedPath);
|
|
74
|
+
return relativePath === ".." || relativePath.startsWith("../") || posix.isAbsolute(relativePath)
|
|
75
|
+
? undefined
|
|
76
|
+
: resolvedPath;
|
|
77
|
+
};
|
|
49
78
|
|
|
50
79
|
export class LogsService extends Context.Service<
|
|
51
80
|
LogsService,
|
|
@@ -73,15 +102,7 @@ export class LogsService extends Context.Service<
|
|
|
73
102
|
stderr: "pipe",
|
|
74
103
|
});
|
|
75
104
|
const process = yield* executor.spawn(command);
|
|
76
|
-
|
|
77
|
-
const stdoutChunk = yield* process.stdout.pipe(Stream.decodeText(), Stream.runCollect);
|
|
78
|
-
const stderrChunk = yield* process.stderr.pipe(Stream.decodeText(), Stream.runCollect);
|
|
79
|
-
|
|
80
|
-
const stdout = stdoutChunk.join("");
|
|
81
|
-
const stderr = stderrChunk.join("");
|
|
82
|
-
const exitCode = yield* process.exitCode;
|
|
83
|
-
|
|
84
|
-
return { stdout, stderr, exitCode };
|
|
105
|
+
return yield* collectProcessOutput(process);
|
|
85
106
|
}),
|
|
86
107
|
).pipe(
|
|
87
108
|
Effect.catch((platformError) =>
|
|
@@ -93,6 +114,22 @@ export class LogsService extends Context.Service<
|
|
|
93
114
|
),
|
|
94
115
|
);
|
|
95
116
|
|
|
117
|
+
const runDirectCommand = (executable: string, args: readonly string[]) =>
|
|
118
|
+
Effect.scoped(
|
|
119
|
+
Effect.gen(function* () {
|
|
120
|
+
const command = ChildProcess.make(executable, args, {
|
|
121
|
+
stdout: "pipe",
|
|
122
|
+
stderr: "pipe",
|
|
123
|
+
});
|
|
124
|
+
const process = yield* executor.spawn(command);
|
|
125
|
+
return yield* collectProcessOutput(process);
|
|
126
|
+
}),
|
|
127
|
+
).pipe(
|
|
128
|
+
Effect.catch((platformError) =>
|
|
129
|
+
Effect.succeed({ stdout: "", stderr: String(platformError), exitCode: -1 }),
|
|
130
|
+
),
|
|
131
|
+
);
|
|
132
|
+
|
|
96
133
|
const getLogsConfig = (profile?: string): LogsConfig | undefined =>
|
|
97
134
|
getToolConfig<LogsConfig>(config, "logs", profile);
|
|
98
135
|
|
|
@@ -196,27 +233,45 @@ export class LogsService extends Context.Service<
|
|
|
196
233
|
logFile = latestPath.split("/").pop() ?? latestPath;
|
|
197
234
|
}
|
|
198
235
|
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
236
|
+
const lexicalPath = resolveLocalLogPath(localDir, logFile);
|
|
237
|
+
if (lexicalPath === undefined) {
|
|
238
|
+
return yield* new LogsReadError({
|
|
239
|
+
message: "Log file must stay within the configured local log directory.",
|
|
240
|
+
source: localDir,
|
|
241
|
+
});
|
|
204
242
|
}
|
|
205
|
-
|
|
243
|
+
const realpathResult = yield* runDirectCommand("realpath", [localDir, lexicalPath]);
|
|
244
|
+
if (realpathResult.exitCode !== 0) {
|
|
245
|
+
return yield* new LogsReadError({
|
|
246
|
+
message:
|
|
247
|
+
realpathResult.stderr.trim() ||
|
|
248
|
+
`realpath failed with exit code ${realpathResult.exitCode}`,
|
|
249
|
+
source: lexicalPath,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
const [canonicalBase, canonicalPath] = realpathResult.stdout.trim().split("\n");
|
|
253
|
+
if (
|
|
254
|
+
canonicalBase === undefined ||
|
|
255
|
+
canonicalPath === undefined ||
|
|
256
|
+
resolveLocalLogPath(canonicalBase, canonicalPath) !== canonicalPath ||
|
|
257
|
+
!canonicalPath.endsWith(".log")
|
|
258
|
+
) {
|
|
259
|
+
return yield* new LogsReadError({
|
|
260
|
+
message: "Canonical log path escapes the configured local log directory.",
|
|
261
|
+
source: localDir,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
const command = `tail -${options.tail} ${sanitizeShellArg(canonicalPath)}`;
|
|
206
265
|
const result = yield* runShellCommand(command);
|
|
207
266
|
|
|
208
|
-
if (result.exitCode !== 0
|
|
267
|
+
if (result.exitCode !== 0) {
|
|
209
268
|
return yield* new LogsReadError({
|
|
210
269
|
message: result.stderr.trim() || `Command failed with exit code ${result.exitCode}`,
|
|
211
|
-
source:
|
|
270
|
+
source: canonicalPath,
|
|
212
271
|
});
|
|
213
272
|
}
|
|
214
273
|
|
|
215
|
-
|
|
216
|
-
return "(no matching lines)";
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
const output = result.stdout.trim();
|
|
274
|
+
const output = filterLogLines(result.stdout, options.grep).trim();
|
|
220
275
|
if (!output) {
|
|
221
276
|
return "(no matching lines)";
|
|
222
277
|
}
|
|
@@ -231,6 +286,14 @@ export class LogsService extends Context.Service<
|
|
|
231
286
|
) {
|
|
232
287
|
const remotePath = logsConfig.remotePath;
|
|
233
288
|
const kubernetesProfile = logsConfig.kubernetesProfile;
|
|
289
|
+
const logFile = options.file ?? "app.log";
|
|
290
|
+
const logPath = resolveRemoteLogPath(remotePath, logFile);
|
|
291
|
+
if (logPath === undefined) {
|
|
292
|
+
return yield* new LogsReadError({
|
|
293
|
+
message: "Log file must stay within the configured remote log directory.",
|
|
294
|
+
source: remotePath,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
234
297
|
|
|
235
298
|
const podResult = yield* k8s
|
|
236
299
|
.runKubectl(
|
|
@@ -249,38 +312,22 @@ export class LogsService extends Context.Service<
|
|
|
249
312
|
);
|
|
250
313
|
|
|
251
314
|
const pod = readCommandOutput(podResult.output).replace(/'/g, "");
|
|
252
|
-
const logFile = options.file ?? "app.log";
|
|
253
|
-
const logPath = `${remotePath}/${logFile}`;
|
|
254
|
-
let command = `tail -${options.tail} ${sanitizeShellArg(logPath)}`;
|
|
255
|
-
|
|
256
|
-
if (options.grep) {
|
|
257
|
-
command += ` | grep -i ${sanitizeShellArg(options.grep)}`;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
315
|
const execResult = yield* k8s
|
|
261
|
-
.
|
|
316
|
+
.runLogTail(pod, remotePath, logPath, options.tail, kubernetesProfile)
|
|
262
317
|
.pipe(Effect.result);
|
|
263
318
|
|
|
264
319
|
return yield* Result.match(execResult, {
|
|
265
|
-
onFailure: (error) =>
|
|
266
|
-
|
|
267
|
-
return Effect.succeed("(no matching lines)");
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
return Effect.fail(
|
|
320
|
+
onFailure: (error) =>
|
|
321
|
+
Effect.fail(
|
|
271
322
|
new LogsReadError({
|
|
272
323
|
message: error instanceof Error ? error.message : "Failed to read remote logs",
|
|
273
324
|
source: `${pod}:${logPath}`,
|
|
274
325
|
}),
|
|
275
|
-
)
|
|
276
|
-
},
|
|
326
|
+
),
|
|
277
327
|
onSuccess: (result) => {
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
return Effect.succeed(transformLogOutput(trimmed));
|
|
328
|
+
const output = readCommandOutput(result.output);
|
|
329
|
+
const trimmed = filterLogLines(output, options.grep).trim();
|
|
330
|
+
return Effect.succeed(trimmed ? transformLogOutput(trimmed) : "(no matching lines)");
|
|
284
331
|
},
|
|
285
332
|
});
|
|
286
333
|
});
|