@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blogic-cz/agent-tools",
3
- "version": "0.14.60",
3
+ "version": "0.14.62",
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",
@@ -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
- dependentOpenPrs.length > 0
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 ? `Remote branch \`${info.headRefName}\` will be deleted. ` : "") +
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
- // Retarget dependents BEFORE merging so the head branch can be deleted safely.
751
- // If any retarget fails, keep the branch (fail-closed) so no dependent PR is closed.
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
- if (opts.deleteBranch && dependentOpenPrs.length > 0 && repo) {
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.catchTag("GitHubCommandError", (error) => {
787
- const stderr = error.stderr.toLowerCase();
788
-
789
- if (stderr.includes("merge conflict") || stderr.includes("conflicts")) {
790
- return Effect.fail(
791
- new GitHubMergeError({
792
- message: `PR #${opts.pr} has merge conflicts`,
793
- reason: "conflicts",
794
- hint: "Resolve merge conflicts locally, push the fix, then retry the merge.",
795
- nextCommand: `gh pr diff ${opts.pr}`,
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
- if (stderr.includes("required status check") || stderr.includes("checks")) {
801
- return Effect.fail(
802
- new GitHubMergeError({
803
- message: `PR #${opts.pr} has failing required checks`,
804
- reason: "checks_failing",
805
- hint: "Wait for CI checks to pass or investigate failures before merging.",
806
- nextCommand: `agent-tools-gh pr checks --pr ${opts.pr}`,
807
- retryable: true,
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
- if (stderr.includes("protected branch")) {
813
- return Effect.fail(
814
- new GitHubMergeError({
815
- message: `PR #${opts.pr} targets a protected branch`,
816
- reason: "branch_protected",
817
- hint: "This branch has protection rules. Ensure required reviews and checks are satisfied, or ask a repo admin.",
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
- return Effect.fail(
823
- new GitHubMergeError({
824
- message: `Failed to merge PR #${opts.pr}: ${error.stderr}`,
825
- reason: "unknown",
826
- hint: "Check the PR state and branch protections. The PR may already be merged or closed.",
827
- nextCommand: `agent-tools-gh pr view --pr ${opts.pr}`,
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})/);
@@ -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
- Supports shell pipes and complex commands.
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. Pipes are supported - use shell syntax
237
- 3. Use -n <namespace> for target namespace
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 with grep
244
- bun run src/k8s-tool kubectl --env test --cmd "logs -l app=web-app -n my-app-test --tail=100 | grep error"
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 with filtered output
250
- bun run src/k8s-tool kubectl --env test --cmd "describe pod web-app-xxx -n my-app-test | grep -A20 Events"
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 command in pod
253
- bun run src/k8s-tool kubectl --env test --cmd "exec web-app-xxx -n my-app-test -- cat /app/logs/app.log | tail -50"
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("Command to run inside the pod; wrap in quotes for spaces"),
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("Execute a command in a pod (kubectl exec <pod> -- <cmd>)"));
395
+ ).pipe(Command.withDescription("Run an allowlisted diagnostic in a pod"));
395
396
 
396
397
  const topCommand = Command.make(
397
398
  "top",
@@ -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
- * Checks if a kubectl command is allowed for AI agent execution.
60
- * Extracts the kubectl verb and validates against allow/block lists.
61
- *
62
- * Handles piped commands by checking only the kubectl portion (before first pipe).
63
- */
64
- export function isKubectlCommandAllowed(cmd: string): K8sSecurityCheckResult {
65
- const trimmed = cmd.trim();
66
-
67
- // Handle piped commands — only validate the kubectl verb (before first |)
68
- const kubectlPart = trimmed.split("|")[0].trim();
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
- // Extract verb: first non-flag word
71
- const verb = kubectlPart
72
- .split(/\s+/)
73
- .find((word) => !word.startsWith("-"))
74
- ?.toLowerCase();
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
- if (!verb) {
77
- return { allowed: false, command: cmd, reason: "Empty kubectl command." };
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
- // Explicit blocklist — clear message about what's blocked and why
81
- if ((BLOCKED_KUBECTL_VERBS as readonly string[]).includes(verb)) {
82
- return {
83
- allowed: false,
84
- command: cmd,
85
- verb,
86
- reason: `'${verb}' is a mutating operation blocked for AI agents. Only read-only operations are allowed: ${ALLOWED_KUBECTL_VERBS.join(", ")}.`,
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
- // Allowlist — unknown verbs are also blocked for safety
91
- if (!(ALLOWED_KUBECTL_VERBS as readonly string[]).includes(verb)) {
92
- return {
93
- allowed: false,
94
- command: cmd,
95
- verb,
96
- reason: `Unknown kubectl verb '${verb}'. Only known read-only operations are allowed: ${ALLOWED_KUBECTL_VERBS.join(", ")}.`,
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
- return { allowed: true, command: cmd, verb };
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
  }
@@ -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
- cmd: string,
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 = withKubeconfig(`kubectl --context ${context} ${cmd}`, kubeconfig);
279
-
280
- const resultOption = yield* runShellCommand(fullCommand, timeoutMs);
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: "AI agents can only run read-only kubectl commands. For mutating operations, use kubectl directly or ask a human operator.",
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(cmd, profile);
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: "AI agents can only run read-only kubectl commands. For mutating operations, use kubectl directly or ask a human operator.",
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, kubeconfig } = yield* resolveContext(profile, k8sConfig);
361
- const fullCommand = withKubeconfig(`kubectl --context ${context} ${cmd}`, kubeconfig);
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(cmd, profile);
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
  );
@@ -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 pattern"),
161
+ Flag.withDescription("Filter lines containing case-insensitive literal text"),
162
162
  Flag.optional,
163
163
  ),
164
164
  pretty: Flag.boolean("pretty").pipe(
@@ -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, Stream } from "effect";
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 fullPath = `${localDir}/${logFile}`;
200
- let command = `tail -${options.tail} ${sanitizeShellArg(fullPath)}`;
201
-
202
- if (options.grep) {
203
- command += ` | grep -i ${sanitizeShellArg(options.grep)}`;
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 && result.exitCode !== 1) {
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: fullPath,
270
+ source: canonicalPath,
212
271
  });
213
272
  }
214
273
 
215
- if (result.exitCode === 1 && options.grep) {
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
- .runKubectl(`exec ${pod} -- sh -c "${command}"`, false, kubernetesProfile)
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
- if (error instanceof K8sCommandError && error.exitCode === 1 && options.grep) {
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 trimmed = readCommandOutput(result.output).trim();
279
- if (!trimmed) {
280
- return Effect.succeed("(no matching lines)");
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
  });