From f2eb543ce22872c285ccca2a7e1c528653898879 Mon Sep 17 00:00:00 2001 From: Chuck Lantz Date: Sat, 16 May 2026 11:44:03 -0700 Subject: [PATCH 1/3] Add the AddSecretFilterValuesResponse RPC call --- dotnet/src/Client.cs | 24 ++++++++++++++++++ dotnet/src/Types.cs | 20 +++++++++++++++ go/client.go | 16 ++++++++++++ go/client_test.go | 41 ++++++++++++++++++++++++++++++ go/types.go | 5 ++++ nodejs/src/client.ts | 24 ++++++++++++++++++ nodejs/test/client.test.ts | 32 ++++++++++++++++++++++++ python/copilot/client.py | 24 ++++++++++++++++++ python/test_client.py | 51 ++++++++++++++++++++++++++++++++++++++ rust/src/lib.rs | 14 +++++++++++ rust/tests/session_test.rs | 50 +++++++++++++++++++++++++++++++++++++ 11 files changed, 301 insertions(+) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index b1e9dce0e..9d602037a 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -883,6 +883,30 @@ public async Task GetStatusAsync(CancellationToken cancellati connection.Rpc, "status.get", [], cancellationToken); } + /// + /// Registers secret values with the server's SecretFilter for redaction. + /// + /// + /// Dynamically generated secrets (e.g., OIDC tokens) can be injected so they are + /// redacted from session logs, telemetry, trajectory exports, and tool output. + /// + /// Raw secret strings to register for redaction. + /// A that can be used to cancel the operation. + /// A task that completes when the values have been registered. + /// Thrown when the client is not connected. + public async Task AddSecretFilterValuesAsync(IReadOnlyList values, CancellationToken cancellationToken = default) + { + if (values.Count == 0) + { + return; + } + + var connection = await EnsureConnectedAsync(cancellationToken); + + await InvokeRpcAsync( + connection.Rpc, "secrets.addFilterValues", [new AddSecretFilterValuesRequest { Values = values }], cancellationToken); + } + /// /// Gets current authentication status. /// diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 0775280e8..dce33bd1f 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -2743,6 +2743,26 @@ public class PingResponse public int? ProtocolVersion { get; set; } } +/// +/// Request to register secret values for redaction. +/// +internal class AddSecretFilterValuesRequest +{ + /// Raw secret strings to register. + [JsonPropertyName("values")] + public IReadOnlyList Values { get; set; } = []; +} + +/// +/// Response from secrets.addFilterValues. +/// +internal class AddSecretFilterValuesResponse +{ + /// Whether the operation succeeded. + [JsonPropertyName("ok")] + public bool Ok { get; set; } +} + /// /// Response from status.get /// diff --git a/go/client.go b/go/client.go index 45d83d828..c2118f36a 100644 --- a/go/client.go +++ b/go/client.go @@ -1326,6 +1326,22 @@ func (c *Client) GetStatus(ctx context.Context) (*GetStatusResponse, error) { return &response, nil } +// AddSecretFilterValues registers secret values with the server's SecretFilter +// for redaction. Dynamically generated secrets (e.g., OIDC tokens) can be +// injected so they are redacted from session logs, telemetry, trajectory +// exports, and tool output. +func (c *Client) AddSecretFilterValues(ctx context.Context, values []string) error { + if c.client == nil { + return fmt.Errorf("client not connected") + } + if len(values) == 0 { + return nil + } + + _, err := c.client.Request("secrets.addFilterValues", addSecretFilterValuesRequest{Values: values}) + return err +} + // GetAuthStatus returns current authentication status func (c *Client) GetAuthStatus(ctx context.Context) (*GetAuthStatusResponse, error) { if c.client == nil { diff --git a/go/client_test.go b/go/client_test.go index c0335a8a9..e8088f638 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -1425,3 +1425,44 @@ func TestStartCLIServer_StderrFieldSet(t *testing.T) { t.Error("expected Stderr to be *truncbuffer.TruncBuffer after assignment") } } + +func TestAddSecretFilterValuesRequest(t *testing.T) { + t.Run("serializes values correctly", func(t *testing.T) { + req := addSecretFilterValuesRequest{Values: []string{"secret1", "secret2"}} + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + values, ok := m["values"].([]any) + if !ok { + t.Fatalf("Expected values to be an array, got %T", m["values"]) + } + if len(values) != 2 { + t.Fatalf("Expected 2 values, got %d", len(values)) + } + if values[0] != "secret1" { + t.Errorf("Expected first value 'secret1', got %v", values[0]) + } + if values[1] != "secret2" { + t.Errorf("Expected second value 'secret2', got %v", values[1]) + } + }) + + t.Run("serializes empty values as empty array", func(t *testing.T) { + req := addSecretFilterValuesRequest{Values: []string{}} + data, _ := json.Marshal(req) + var m map[string]any + json.Unmarshal(data, &m) + values, ok := m["values"].([]any) + if !ok { + t.Fatalf("Expected values to be an array, got %T", m["values"]) + } + if len(values) != 0 { + t.Errorf("Expected empty values array, got %d elements", len(values)) + } + }) +} diff --git a/go/types.go b/go/types.go index 562019e59..d839f1f41 100644 --- a/go/types.go +++ b/go/types.go @@ -1309,6 +1309,11 @@ type GetStatusResponse struct { // getAuthStatusRequest is the request for auth.getStatus type getAuthStatusRequest struct{} +// addSecretFilterValuesRequest is the request for secrets.addFilterValues +type addSecretFilterValuesRequest struct { + Values []string `json:"values"` +} + // GetAuthStatusResponse is the response from auth.getStatus type GetAuthStatusResponse struct { IsAuthenticated bool `json:"isAuthenticated"` diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 7a32080f5..9d688aff6 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1407,6 +1407,30 @@ export class CopilotClient { } } + /** + * Registers secret values with the server's SecretFilter for redaction. + * + * Dynamically generated secrets (e.g., OIDC tokens) can be injected so they + * are redacted from session logs, telemetry, trajectory exports, and tool output. + * + * @param values - Raw secret strings to register for redaction + * @throws Error if the client is not connected + * + * @example + * ```typescript + * await client.addSecretFilterValues(["my-secret-token"]); + * ``` + */ + async addSecretFilterValues(values: string[]): Promise { + if (!this.connection) { + throw new Error("Client not connected"); + } + if (values.length === 0) { + return; + } + await this.connection.sendRequest("secrets.addFilterValues", { values }); + } + /** * Subscribes to a specific session lifecycle event type. * diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 69c851b7e..29f33f06b 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -1574,4 +1574,36 @@ describe("CopilotClient", () => { expect((client as any).options.sessionIdleTimeoutSeconds).toBe(600); }); }); + + describe("addSecretFilterValues", () => { + it("sends secrets.addFilterValues RPC with values", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const spy = vi.spyOn((client as any).connection!, "sendRequest").mockResolvedValue({ ok: true }); + await client.addSecretFilterValues(["secret1", "secret2"]); + + expect(spy).toHaveBeenCalledWith("secrets.addFilterValues", { values: ["secret1", "secret2"] }); + spy.mockRestore(); + }); + + it("skips RPC call for empty arrays", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const spy = vi.spyOn((client as any).connection!, "sendRequest"); + await client.addSecretFilterValues([]); + + expect(spy).not.toHaveBeenCalledWith("secrets.addFilterValues", expect.anything()); + spy.mockRestore(); + }); + + it("throws when client is not connected", async () => { + const client = new CopilotClient(); + + await expect(client.addSecretFilterValues(["secret"])).rejects.toThrow("Client not connected"); + }); + }); }); diff --git a/python/copilot/client.py b/python/copilot/client.py index 3e2b367e5..18c987710 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -2059,6 +2059,30 @@ async def get_status(self) -> GetStatusResponse: result = await self._client.request("status.get", {}) return GetStatusResponse.from_dict(result) + async def add_secret_filter_values(self, values: list[str]) -> None: + """ + Register secret values with the server's SecretFilter for redaction. + + Dynamically generated secrets (e.g., OIDC tokens) can be injected so + they are redacted from session logs, telemetry, trajectory exports, and + tool output. + + Args: + values: Raw secret strings to register for redaction. + + Raises: + RuntimeError: If the client is not connected. + + Example: + >>> await client.add_secret_filter_values(["my-secret-token"]) + """ + if not self._client: + raise RuntimeError("Client not connected") + if not values: + return + + await self._client.request("secrets.addFilterValues", {"values": values}) + async def get_auth_status(self) -> GetAuthStatusResponse: """ Get current authentication status. diff --git a/python/test_client.py b/python/test_client.py index 64ad1a074..a58954648 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -1013,3 +1013,54 @@ def test_model_field_is_omitted_when_absent(self): } wire = client._convert_custom_agent_to_wire_format(agent) assert "model" not in wire + + +class TestAddSecretFilterValues: + @pytest.mark.asyncio + async def test_sends_rpc_with_values(self): + client = CopilotClient(SubprocessConfig(cli_path=CLI_PATH)) + await client.start() + + try: + captured = {} + + async def mock_request(method, params): + captured[method] = params + return {"ok": True} + + client._client.request = mock_request + + await client.add_secret_filter_values(["secret1", "secret2"]) + assert "secrets.addFilterValues" in captured + assert captured["secrets.addFilterValues"]["values"] == [ + "secret1", + "secret2", + ] + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_skips_rpc_for_empty_list(self): + client = CopilotClient(SubprocessConfig(cli_path=CLI_PATH)) + await client.start() + + try: + captured = {} + + async def mock_request(method, params): + captured[method] = params + return {"ok": True} + + client._client.request = mock_request + + await client.add_secret_filter_values([]) + assert "secrets.addFilterValues" not in captured + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_raises_when_not_connected(self): + client = CopilotClient(SubprocessConfig(cli_path=CLI_PATH)) + + with pytest.raises(RuntimeError, match="Client not connected"): + await client.add_secret_filter_values(["secret"]) diff --git a/rust/src/lib.rs b/rust/src/lib.rs index af30b4191..7ea7fcef7 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -1658,6 +1658,20 @@ impl Client { Ok(serde_json::from_value(value)?) } + /// Register secret values with the server's SecretFilter for redaction. + /// + /// Dynamically generated secrets (e.g., OIDC tokens) can be injected so + /// they are redacted from session logs, telemetry, trajectory exports, and + /// tool output. + pub async fn add_secret_filter_values(&self, values: &[String]) -> Result<(), Error> { + if values.is_empty() { + return Ok(()); + } + let params = serde_json::json!({ "values": values }); + self.call("secrets.addFilterValues", Some(params)).await?; + Ok(()) + } + /// List persisted sessions, optionally filtered by working directory, /// repository, or git context. pub async fn list_sessions( diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index 32196fdda..77e53659b 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -3521,3 +3521,53 @@ async fn wire_omits_trace_fields_when_unset() { .unwrap() .unwrap(); } + +#[tokio::test] +async fn add_secret_filter_values_sends_correct_rpc() { + let (client, mut server_read, mut server_write) = make_client(); + + let call_handle = tokio::spawn({ + let client = client.clone(); + async move { + client + .add_secret_filter_values(&[ + "secret1".to_string(), + "secret2".to_string(), + ]) + .await + .unwrap() + } + }); + + let request = read_framed(&mut server_read).await; + assert_eq!(request["method"], "secrets.addFilterValues"); + assert_eq!(request["params"]["values"][0], "secret1"); + assert_eq!(request["params"]["values"][1], "secret2"); + + let id = request["id"].as_u64().unwrap(); + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "ok": true }, + }); + write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await; + + timeout(TIMEOUT, call_handle).await.unwrap().unwrap(); +} + +#[tokio::test] +async fn add_secret_filter_values_skips_empty() { + let (client, mut server_read, _server_write) = make_client(); + + // Empty values should return immediately without sending any RPC + client.add_secret_filter_values(&[]).await.unwrap(); + + // Verify no request was sent by checking the stream has no data + let mut buf = [0u8; 1]; + let result = tokio::time::timeout( + Duration::from_millis(100), + tokio::io::AsyncReadExt::read(&mut server_read, &mut buf), + ) + .await; + assert!(result.is_err(), "Expected no data on the wire for empty values"); +} From a3bbf40163d6391eb91fa1108a8e61f3260c325e Mon Sep 17 00:00:00 2001 From: Chuck Lantz Date: Mon, 18 May 2026 12:07:15 -0700 Subject: [PATCH 2/3] Fix lint errors in tests --- nodejs/test/client.test.ts | 12 +++++++++--- rust/tests/session_test.rs | 10 +++++----- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 5b800a9ca..5762586f8 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -1606,10 +1606,14 @@ describe("CopilotClient", () => { await client.start(); onTestFinished(() => client.forceStop()); - const spy = vi.spyOn((client as any).connection!, "sendRequest").mockResolvedValue({ ok: true }); + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockResolvedValue({ ok: true }); await client.addSecretFilterValues(["secret1", "secret2"]); - expect(spy).toHaveBeenCalledWith("secrets.addFilterValues", { values: ["secret1", "secret2"] }); + expect(spy).toHaveBeenCalledWith("secrets.addFilterValues", { + values: ["secret1", "secret2"], + }); spy.mockRestore(); }); @@ -1628,7 +1632,9 @@ describe("CopilotClient", () => { it("throws when client is not connected", async () => { const client = new CopilotClient(); - await expect(client.addSecretFilterValues(["secret"])).rejects.toThrow("Client not connected"); + await expect(client.addSecretFilterValues(["secret"])).rejects.toThrow( + "Client not connected" + ); }); }); }); diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index 80ac928c6..719729f46 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -3541,10 +3541,7 @@ async fn add_secret_filter_values_sends_correct_rpc() { let client = client.clone(); async move { client - .add_secret_filter_values(&[ - "secret1".to_string(), - "secret2".to_string(), - ]) + .add_secret_filter_values(&["secret1".to_string(), "secret2".to_string()]) .await .unwrap() } @@ -3580,5 +3577,8 @@ async fn add_secret_filter_values_skips_empty() { tokio::io::AsyncReadExt::read(&mut server_read, &mut buf), ) .await; - assert!(result.is_err(), "Expected no data on the wire for empty values"); + assert!( + result.is_err(), + "Expected no data on the wire for empty values" + ); } From 317c201457b2c10f823f3e8f3b179ce731f3b080 Mon Sep 17 00:00:00 2001 From: Chuck Lantz Date: Mon, 18 May 2026 12:25:43 -0700 Subject: [PATCH 3/3] Address copilot feedback --- dotnet/src/Client.cs | 6 ++++-- go/client_test.go | 9 +++++++-- nodejs/test/client.test.ts | 8 +++++++- rust/src/lib.rs | 5 +++-- rust/tests/session_test.rs | 7 +++++-- 5 files changed, 26 insertions(+), 9 deletions(-) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 6808d9a12..361fb81dc 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -902,13 +902,15 @@ public async Task GetStatusAsync(CancellationToken cancellati /// Thrown when the client is not connected. public async Task AddSecretFilterValuesAsync(IReadOnlyList values, CancellationToken cancellationToken = default) { + ArgumentNullException.ThrowIfNull(values); + + var connection = await EnsureConnectedAsync(cancellationToken); + if (values.Count == 0) { return; } - var connection = await EnsureConnectedAsync(cancellationToken); - await InvokeRpcAsync( connection.Rpc, "secrets.addFilterValues", [new AddSecretFilterValuesRequest { Values = values }], cancellationToken); } diff --git a/go/client_test.go b/go/client_test.go index 5afe38d1f..552afabc1 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -1503,9 +1503,14 @@ func TestAddSecretFilterValuesRequest(t *testing.T) { t.Run("serializes empty values as empty array", func(t *testing.T) { req := addSecretFilterValuesRequest{Values: []string{}} - data, _ := json.Marshal(req) + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("json.Marshal failed: %v", err) + } var m map[string]any - json.Unmarshal(data, &m) + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("json.Unmarshal failed: %v", err) + } values, ok := m["values"].([]any) if !ok { t.Fatalf("Expected values to be an array, got %T", m["values"]) diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 5762586f8..1bdbc7c5a 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -375,7 +375,13 @@ describe("CopilotClient", () => { onTestFinished(() => client.forceStop()); const session = await client.createSession({ onPermissionRequest: approveAll }); - const spy = vi.spyOn((client as any).connection!, "sendRequest"); + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (...args: any[]) => { + const [method, params] = args; + if (method === "session.resume") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); await client.resumeSession(session.sessionId, { defaultAgent: { excludedTools: ["heavy-tool"] }, onPermissionRequest: approveAll, diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 7ea7fcef7..80436d3af 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -1663,11 +1663,12 @@ impl Client { /// Dynamically generated secrets (e.g., OIDC tokens) can be injected so /// they are redacted from session logs, telemetry, trajectory exports, and /// tool output. - pub async fn add_secret_filter_values(&self, values: &[String]) -> Result<(), Error> { + pub async fn add_secret_filter_values(&self, values: &[impl AsRef]) -> Result<(), Error> { if values.is_empty() { return Ok(()); } - let params = serde_json::json!({ "values": values }); + let strs: Vec<&str> = values.iter().map(AsRef::as_ref).collect(); + let params = serde_json::json!({ "values": strs }); self.call("secrets.addFilterValues", Some(params)).await?; Ok(()) } diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index 719729f46..88e3d663a 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -3541,7 +3541,7 @@ async fn add_secret_filter_values_sends_correct_rpc() { let client = client.clone(); async move { client - .add_secret_filter_values(&["secret1".to_string(), "secret2".to_string()]) + .add_secret_filter_values(&["secret1", "secret2"]) .await .unwrap() } @@ -3568,7 +3568,10 @@ async fn add_secret_filter_values_skips_empty() { let (client, mut server_read, _server_write) = make_client(); // Empty values should return immediately without sending any RPC - client.add_secret_filter_values(&[]).await.unwrap(); + client + .add_secret_filter_values(&[] as &[&str]) + .await + .unwrap(); // Verify no request was sent by checking the stream has no data let mut buf = [0u8; 1];