Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
cdfb425
Harden CI checkout credentials and local commit hooks
Timpan4 Oct 5, 2026
e70667a
Redact unparsed Argo comparison state that may hold Secrets
Timpan4 Oct 5, 2026
2ae9485
Reject guarded operations on non-built-in API groups
Timpan4 Oct 5, 2026
929baa5
Clear exec confirmation when the target container changes
Timpan4 Oct 5, 2026
9d5f6ba
Redact full Helm release Secrets and narrow inspection lists
Timpan4 Oct 5, 2026
0b61d19
Fetch topology metadata only and watch it only while the map is open
Timpan4 Oct 5, 2026
3a04b08
Back off watch renewal after early stream EOF
Timpan4 Oct 5, 2026
53f0599
Treat empty Ingress load balancer addresses as pending
Timpan4 Oct 5, 2026
4b726b0
Invalidate cached clients when the fallback kubeconfig changes
Timpan4 Oct 5, 2026
d287165
Drop imported port forwards outside the workspace scope
Timpan4 Oct 5, 2026
127e586
Reset YAML apply state when the draft changes mid-apply
Timpan4 Oct 5, 2026
6c6580b
Preserve existing query defaults when retention is applied
Timpan4 Oct 5, 2026
b0bcbd4
Replace ES2023 toSorted with copied sorts
Timpan4 Oct 5, 2026
c0a2d03
Turn off YAML apply force-conflicts by default
Timpan4 Oct 5, 2026
a920cd6
Make WebView2 remote debugging opt-in for Windows dev
Timpan4 Oct 5, 2026
25d7321
Explain that Service forwards may use a different Pod per connection
Timpan4 Oct 5, 2026
44d3385
Match resource Events by UID and trim health and log payloads
Timpan4 Oct 5, 2026
3c1d862
Require confirmed tunnel targets before sending Argo credentials
Timpan4 Oct 5, 2026
363db9f
Fail closed on unloadable kubeconfig sources for cluster-changing com…
Timpan4 Oct 5, 2026
f7f95d2
Format security fix code with rustfmt
Timpan4 Oct 5, 2026
2482eb2
Update force-conflicts default test to the new off default
Timpan4 Oct 5, 2026
a3ad842
Ignore dev-only braces advisory with no patched release
Timpan4 Oct 5, 2026
d5728ee
Redact Argo state sent as a JSON string literal
Timpan4 Oct 5, 2026
d2dd5ce
Pass the confirmed tunnel target in the real-cluster Argo E2E
Timpan4 Oct 5, 2026
880dcd7
Refetch Argo discovery after a rejected tunnel target
Timpan4 Oct 5, 2026
64551d7
Pin Argo tunnel connections to the confirmed Pod
Timpan4 Oct 5, 2026
885f2c1
Withhold unstructured Argo state for Secret resources
Timpan4 Oct 5, 2026
7146ad3
Connect only to the endpoint shown in the tunnel confirmation
Timpan4 Oct 5, 2026
ba7566e
Key resource Events cache by resource UID
Timpan4 Oct 5, 2026
b760755
Hold the YAML apply lock until the request settles
Timpan4 Oct 5, 2026
21a20ba
Scan staged paths verbatim in the credential hook
Timpan4 Oct 5, 2026
d0e8075
Satisfy anti-slop lint in review fixes
Timpan4 Oct 5, 2026
6e3393e
Replace empty assertions flagged by Rust 1.99 clippy
Timpan4 Oct 5, 2026
8108c29
Release the YAML apply lock only from the apply that took it
Timpan4 Oct 5, 2026
5c98b14
Reject staged paths with any newline before scanning
Timpan4 Oct 5, 2026
92a7ec1
Stop RBAC paging when any continue token recurs
Timpan4 Oct 5, 2026
c59beb8
Discard Argo tunnel confirmation when the cluster scope changes
Timpan4 Oct 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file modified .githooks/pre-commit
100644 → 100755
Empty file.
15 changes: 12 additions & 3 deletions .githooks/pre-commit-user
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,17 @@ if ! git diff --cached --check; then
exit 1
fi

staged_files=$(git diff --cached --name-only --diff-filter=ACMR)
# -z disables path quoting so `git show` gets the exact index path.
newline_count=$(git diff --cached --name-only -z --diff-filter=ACMR | tr -cd '\n' | wc -c)
if [ "$newline_count" -ne 0 ]; then
echo "pre-commit: a staged path contains a newline; rename it so the credential scan can read it."
exit 1
fi
staged_files=$(git diff --cached --name-only -z --diff-filter=ACMR | tr '\0' '\n')

if [ -n "$staged_files" ]; then
for file in $staged_files; do
# Read newline-separated paths so filenames with spaces are scanned.
while IFS= read -r file; do
Comment thread
Timpan4 marked this conversation as resolved.
case "$file" in
.githooks/pre-commit|.githooks/pre-commit-user)
continue
Expand All @@ -36,7 +43,9 @@ if [ -n "$staged_files" ]; then
echo "Move credentials out of the repo or add a narrow exception after review."
exit 1
fi
done
done <<EOF
$staged_files
EOF
fi

# File-size enforcement.
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false

- name: Test CI orchestration
run: node --test scripts/ci-workflow-regression.test.mjs
Expand Down Expand Up @@ -193,6 +195,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false

- name: Install Tauri Linux dependencies
run: |
Expand Down
11 changes: 11 additions & 0 deletions docs/decisions/0017-private-argocd-service-tunnels.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,14 @@ Discovery reports only eligible TCP Service ports and a bounded unavailable reas
## Consequences

Manual external HTTPS profiles remain available. Saved profiles persist endpoint identity and scope but no credentials, TLS override, custom CA material, or tunnel address. Tunnel access is unavailable when discovery cannot prove the required Service target.

## Amendment 2026-10-05: user-confirmed tunnel target

Discovery matches Services by name (`argocd-server`, `argo-cd-argocd-server`) in any readable namespace, so a workload in any namespace can imitate Argo CD and receive a token or local login sent through a tunnel. Service names and labels are attacker-controllable and are not identity.

Trust rule:

- Discovery reports the Pod it resolved for each Service (`targetPod`) and whether the Service carries `app.kubernetes.io/part-of=argocd` (`argoLabeled`). The label is a hint only. Unlabelled Services stay selectable but are flagged in the selector and in a stronger warning.
- Before credentials are sent through a Service tunnel, including reconnects of saved profiles that use a remembered credential, the UI shows the exact namespace, Service name, and Pod with a warning and requires explicit confirmation. The user confirmation is the control.
- `connect_argo_server` takes the confirmed target (namespace, Service name, Pod). For a Service tunnel the backend starts the tunnel, resolves the target, and refuses to send any credential unless the confirmed target matches the resolved namespace, Service, and Pod. A missing confirmation or a target that changed between display and send is rejected, and the user must refresh discovery and confirm again.
- The tunnel stays pinned to the confirmed Pod. Each connection still resolves the Service, and a connection that resolves to a different Pod is refused, so a rollout or selector change requires reconnecting and confirming the new target. Pod and Service port-forward sessions (ADR 0003) keep per-connection re-resolution.
2 changes: 1 addition & 1 deletion docs/development-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ bun run lint:anti-slop

`bun run tauri dev` starts Vite on Bun at `http://localhost:1430`. Opening it in a normal browser runs Svelte with browser-only mock Tauri responses for frontend inspection and automation.

Tauri webview uses real IPC and Rust Kubernetes commands. On Windows, development exposes Chrome DevTools Protocol at `http://127.0.0.1:9222`; set `KUBECOVE_DEVTOOLS_PORT` before launch to change it. Packaged apps do not expose this endpoint.
Tauri webview uses real IPC and Rust Kubernetes commands. On Windows, development does not expose Chrome DevTools Protocol by default. Set `KUBECOVE_DEVTOOLS=1` before `tauri dev` to expose it at `http://127.0.0.1:9222`, and set `KUBECOVE_DEVTOOLS_PORT` to change the port. Packaged apps do not expose this endpoint.

Browser mock mode never receives kubeconfig contents, calls a local Rust bridge, or accesses a real cluster. Treat all browser data as fake.

Expand Down
2 changes: 1 addition & 1 deletion docs/wiki/Edit-and-Apply-YAML.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ The **Allow YAML force-conflicts** setting controls whether dry-run and Apply ma

- Leave it off for normal edits. A field-manager conflict stops the dry run.
- After that conflict, **Allow force-conflicts for this resource** appears. Selecting it reruns the dry run with force enabled; review the new diff before selecting **Apply**.
- When the global setting is on, both dry run and Apply use force-conflicts. Turn it off before editing resources whose existing manager should remain authoritative.
- The setting is off by default. When you turn it on, both dry run and Apply use force-conflicts. Turn it off again before editing resources whose existing manager should remain authoritative.

Force-conflicts can replace another manager's field ownership. It is not a way to bypass RBAC, admission, immutable fields, or invalid manifests.

Expand Down
2 changes: 1 addition & 1 deletion docs/wiki/Settings-Updates-and-Diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Open **Settings** from app navigation. Most settings are global preferences. Whe
| YAML encoding | YAML | Select YAML or KYAML in YAML panels. |
| YAML diff appearance | Clean | Select clean or Git-style rendering for selected-resource dry-run diffs. |
| YAML error lens | On | Shows editor diagnostics below YAML lines. |
| Allow YAML force-conflicts | On | Allows guarded YAML operations to take server-side field ownership. Review dry-run diff and confirmation target before force-applying. |
| Allow YAML force-conflicts | Off | Allows guarded YAML operations to take server-side field ownership. Review dry-run diff and confirmation target before force-applying. |

## Kubeconfig

Expand Down
5 changes: 4 additions & 1 deletion e2e/specs/real/inspection.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
ArgoApplicationInspector,
ArgoApplicationRef,
ArgoApplicationSummary,
ArgoConfirmedTarget,
ArgoConnectionStatus,
ArgoOperationConfirmation,
ArgoOperationPreflight,
Expand Down Expand Up @@ -51,7 +52,7 @@ type CommandMap = {
stop_stream: { args: { streamId: string }; result: boolean };
list_argocd_applications: { args: { clusterContext: string }; result: ArgoApplicationSummary[] };
discover_argo_servers: { args: { clusterContext: string; kubeconfigEnvVar?: string }; result: ArgoServerCapability[] };
connect_argo_server: { args: { id: string; serverUrl: string; endpoint: ArgoServerEndpoint; username?: string; password?: string; insecureTls: boolean; rememberCredential: boolean; clusterContext: string; kubeconfigEnvVar?: string; workspaceId: string }; result: ArgoConnectionStatus };
connect_argo_server: { args: { id: string; serverUrl: string; endpoint: ArgoServerEndpoint; username?: string; password?: string; insecureTls: boolean; rememberCredential: boolean; clusterContext: string; kubeconfigEnvVar?: string; workspaceId: string; confirmedTarget?: ArgoConfirmedTarget }; result: ArgoConnectionStatus };
get_argo_connection_status: { args: { id: string }; result: ArgoConnectionStatus };
disconnect_argo_server: { args: { id: string }; result: undefined };
get_argo_application_inspector: { args: { clusterContext: string; kubeconfigEnvVar?: string; connectionId: string; transport: "connected"; application: ArgoApplicationRef; redactSecrets: boolean }; result: ArgoApplicationInspector };
Expand Down Expand Up @@ -271,6 +272,7 @@ describe("native Kind command boundary", () => {
expect(server.url).toBeNull();
expect(server.unavailableReason).toBeNull();
expect(server.endpoint.servicePort).toBeGreaterThan(0);
if (!server.targetPod) throw new Error("argocd-server Service tunnel has no discovered target Pod");

const password = Buffer.from(await runKubectl(["get", "secret", "argocd-initial-admin-secret", "-n", "argocd", "-o", "jsonpath={.data.password}"]), "base64").toString("utf8");
if (!password) throw new Error("Argo CD initial admin password is empty");
Expand All @@ -288,6 +290,7 @@ describe("native Kind command boundary", () => {
clusterContext: cluster,
kubeconfigEnvVar: e2eKubeconfigSource,
workspaceId,
confirmedTarget: { namespace: server.endpoint.namespace, serviceName: server.endpoint.serviceName, podName: server.targetPod },
});
argoConnections.push(connectionId);
expect(connected).toMatchObject({ connected: true, profile: { rememberCredential: false, endpoint: { kind: "serviceTunnel", serviceName: "argocd-server", servicePort: server.endpoint.servicePort, scheme: "http" } } });
Expand Down
7 changes: 6 additions & 1 deletion scripts/audit-dependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ const ignoredAdvisories = new Map([
["GHSA-jmr9-qjv8-65gv", "extract-zip"],
// TODO(Timpan4): Remove once WebdriverIO accepts brace-expansion 5.0.8+.
["GHSA-mh99-v99m-4gvg", "brace-expansion"],
// TODO(Timpan4): Remove once braces ships a patch or WebdriverIO's mocha drops chokidar 3.
["GHSA-vfj7-8cjw-p6xm", "braces"],
]);

function advisoryId(advisory: AuditAdvisory): string {
Expand Down Expand Up @@ -49,7 +51,10 @@ async function runAudit(productionOnly: boolean): Promise<AuditReport> {
]);

if (stderr) process.stderr.write(stderr);
if (!stdout.trim()) process.exit(exitCode);
if (!stdout.trim()) {
if (exitCode !== 0) process.exit(exitCode);
return {};
}
try {
// SAFETY: `bun audit --json` owns this versioned report payload.
return JSON.parse(stdout) as AuditReport;
Expand Down
2 changes: 2 additions & 0 deletions scripts/tauri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export function tauriEnvironment(
);
}

if (environment.KUBECOVE_DEVTOOLS !== "1") return environment;

const currentArguments = environment[WEBVIEW2_ARGUMENTS]?.trim() ?? "";
if (/(?:^|\s)--remote-debugging-port(?:=|\s)/.test(currentArguments)) {
return environment;
Expand Down
97 changes: 77 additions & 20 deletions src-tauri/src/commands/argo/connected.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ pub(crate) use super::transport::{api_delete, api_get, api_post, redact_secret_f
use super::transport::{
argo_url, http_client, normalize_endpoint, response_json, safe_http_error, url,
};
use super::{scope::scoped_connection, tunnel::ArgoServiceTunnel};
use super::{
scope::scoped_connection,
tunnel::{verify_confirmed_target, ArgoServiceTunnel},
};
use crate::commands::{
gitops_crd::{client_for_context, find_api_resource, get_crd_object},
kubeconfig::KubeconfigSource,
Expand All @@ -11,8 +14,9 @@ use crate::commands::{
use crate::models::AppErrorKind;
use crate::models::{
AppError, ArgoApplicationHistory, ArgoApplicationInspector, ArgoApplicationRef,
ArgoConnectionProfile, ArgoConnectionStatus, ArgoManagedResource, ArgoResourceComparison,
ArgoServerCapability, ArgoServerEndpoint, ArgoServiceTunnelUnavailableReason,
ArgoConfirmedTarget, ArgoConnectionProfile, ArgoConnectionStatus, ArgoManagedResource,
ArgoResourceComparison, ArgoServerCapability, ArgoServerEndpoint,
ArgoServiceTunnelUnavailableReason,
};
use k8s_openapi::api::core::v1::Service;
use kube::{
Expand Down Expand Up @@ -365,6 +369,8 @@ fn unavailable_capability(
endpoint: None,
unavailable_reason: Some(message.into()),
unavailable: Some(reason),
target_pod: None,
argo_labeled: false,
}
}

Expand Down Expand Up @@ -446,11 +452,22 @@ fn servicetunnel_capabilities(service: &Service) -> Vec<ArgoServerCapability> {
}),
unavailable_reason: None,
unavailable: None,
target_pod: None,
argo_labeled: false,
}
})
.collect()
}

fn is_argo_labeled(service: &Service) -> bool {
service
.metadata
.labels
.as_ref()
.and_then(|labels| labels.get("app.kubernetes.io/part-of"))
.is_some_and(|value| value == "argocd")
}

fn tunnel_target_unavailable(error: &AppError) -> ArgoServiceTunnelUnavailableReason {
match error.kind {
AppErrorKind::LiveSessionTargetUnavailable => {
Expand Down Expand Up @@ -482,24 +499,28 @@ pub async fn discover_argo_servers(
let mut capabilities = Vec::new();
for service in list.items {
for mut capability in servicetunnel_capabilities(&service) {
capability.argo_labeled = is_argo_labeled(&service);
if let Some(ArgoServerEndpoint::ServiceTunnel {
namespace,
service_name,
service_port,
..
}) = capability.endpoint.as_ref()
{
if let Err(error) =
crate::commands::sessions::service::resolve_service_target(
client.clone(),
namespace,
service_name,
*service_port,
)
.await
match crate::commands::sessions::service::resolve_service_target(
client.clone(),
namespace,
service_name,
*service_port,
)
.await
{
capability.unavailable = Some(tunnel_target_unavailable(&error));
capability.unavailable_reason = Some(error.message);
Ok(target) => capability.target_pod = Some(target.pod_name),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Err(error) => {
capability.unavailable =
Some(tunnel_target_unavailable(&error));
capability.unavailable_reason = Some(error.message);
}
}
}
capabilities.push(capability);
Expand All @@ -526,6 +547,7 @@ pub async fn connect_argo_server(
cluster_context: Option<String>,
kubeconfig_env_var: Option<String>,
workspace_id: Option<String>,
confirmed_target: Option<ArgoConfirmedTarget>,
) -> Result<ArgoConnectionStatus, AppError> {
let connection_epoch = store.connection_epoch();
let kubeconfig_source_key = kubeconfig_source_key(kubeconfig_env_var.as_deref())?;
Expand Down Expand Up @@ -564,6 +586,12 @@ pub async fn connect_argo_server(
*service_port,
)
.await?;
verify_confirmed_target(
confirmed_target.as_ref(),
namespace,
service_name,
started.pod_name(),
)?;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
let host = argo_url(&profile.url)?
.host_str()
.expect("normalized service endpoint has a host")
Expand Down Expand Up @@ -749,9 +777,23 @@ pub(crate) fn managed_resource(value: &Value) -> ArgoManagedResource {
pub(crate) fn state(value: Option<&Value>, redact: bool) -> Option<Value> {
value
.and_then(|state| match state {
Value::String(text) => serde_json::from_str(text)
Value::String(text) => serde_json::from_str::<Value>(text)
.ok()
.or_else(|| Some(Value::String(text.clone()))),
// A JSON string literal is still unstructured text, so it takes the text path below.
.filter(|parsed| !parsed.is_string())
.or_else(|| {
serde_yaml::from_str::<Value>(text)
.ok()
.filter(|parsed| parsed.is_object() || parsed.is_array())
})
.or_else(|| {
// Unparseable text cannot be redacted structurally; withhold it if it may be a Secret.
Some(if redact && text.contains("Secret") {
Value::String("[REDACTED]".into())
} else {
Value::String(text.clone())
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
}),
_ => Some(state.clone()),
})
.map(|mut state| {
Expand Down Expand Up @@ -860,12 +902,18 @@ fn kubernetes_comparison(resource: ArgoManagedResource) -> ArgoResourceCompariso
}

fn connected_comparison(value: &Value) -> ArgoResourceComparison {
let is_secret = value.get("kind").and_then(Value::as_str) == Some("Secret");
// Unstructured Secret state cannot be redacted field by field, so it is withheld whole.
let redacted_state = |key: &str| match state(value.get(key), true) {
Some(Value::String(_)) if is_secret => Some(Value::String("[REDACTED]".into())),
other => other,
};
ArgoResourceComparison {
resource: managed_resource(value),
target_state: state(value.get("targetState"), true),
live_state: state(value.get("liveState"), true),
normalized_live_state: state(value.get("normalizedLiveState"), true),
predicted_live_state: state(value.get("predictedLiveState"), true),
target_state: redacted_state("targetState"),
live_state: redacted_state("liveState"),
normalized_live_state: redacted_state("normalizedLiveState"),
predicted_live_state: redacted_state("predictedLiveState"),
modified: value.get("modified").and_then(Value::as_bool),
exact: Some(true),
provenance: Some("argocd-managed-resource".into()),
Expand Down Expand Up @@ -1717,6 +1765,15 @@ mod tests {
assert!(!value.to_string().contains("plaintext"));
}

#[test]
fn non_json_secret_state_is_redacted() {
let yaml = serde_json::json!("kind: Secret\ndata:\n password: plaintext\n");
let redacted = state(Some(&yaml), true).unwrap();
assert_eq!(redacted["data"]["password"], "[REDACTED]");
let broken = serde_json::json!("kind: Secret\n\tdata: [plaintext");
assert_eq!(state(Some(&broken), true).unwrap(), "[REDACTED]");
}

#[test]
fn kubernetes_comparisons_do_not_imply_desired_state() {
let comparison = kubernetes_comparison(ArgoManagedResource {
Expand All @@ -1732,7 +1789,7 @@ mod tests {
);
assert!(comparison.target_state.is_none());
assert!(comparison.live_state.is_none());
assert!(comparison.available_actions.is_empty());
assert_eq!(comparison.available_actions.len(), 0);
}

#[test]
Expand Down
Loading
Loading