diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 8f879043a..361fb81dc 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -889,6 +889,32 @@ 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) + { + ArgumentNullException.ThrowIfNull(values); + + var connection = await EnsureConnectedAsync(cancellationToken); + + if (values.Count == 0) + { + return; + } + + 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 f93051111..6feb680cc 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -2812,6 +2812,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 9730fc6d4..9811fbfc5 100644 --- a/go/client.go +++ b/go/client.go @@ -1327,6 +1327,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 42e45ea15..552afabc1 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -1474,3 +1474,49 @@ 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, err := json.Marshal(req) + if err != nil { + t.Fatalf("json.Marshal failed: %v", err) + } + var m map[string]any + 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"]) + } + 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 68a1c38a3..f440cc4b4 100644 --- a/go/types.go +++ b/go/types.go @@ -1331,6 +1331,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 1f0e8e9c9..349efa783 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1408,6 +1408,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 c3090eb76..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, @@ -1599,4 +1605,42 @@ 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 e7acd2c25..4dab3d1b5 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -2096,6 +2096,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 f7c2e3bf0..9ef884394 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -1052,3 +1052,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..80436d3af 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -1658,6 +1658,21 @@ 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: &[impl AsRef]) -> Result<(), Error> { + if values.is_empty() { + return Ok(()); + } + 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(()) + } + /// 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 81ddf54f5..88e3d663a 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -3532,3 +3532,56 @@ 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", "secret2"]) + .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(&[] as &[&str]) + .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" + ); +}