Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
26 changes: 26 additions & 0 deletions dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -889,6 +889,32 @@ public async Task<GetStatusResponse> GetStatusAsync(CancellationToken cancellati
connection.Rpc, "status.get", [], cancellationToken);
}

/// <summary>
/// Registers secret values with the server's SecretFilter for redaction.
/// </summary>
/// <remarks>
/// Dynamically generated secrets (e.g., OIDC tokens) can be injected so they are
/// redacted from session logs, telemetry, trajectory exports, and tool output.
/// </remarks>
/// <param name="values">Raw secret strings to register for redaction.</param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> that can be used to cancel the operation.</param>
/// <returns>A task that completes when the values have been registered.</returns>
/// <exception cref="InvalidOperationException">Thrown when the client is not connected.</exception>
public async Task AddSecretFilterValuesAsync(IReadOnlyList<string> values, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(values);

var connection = await EnsureConnectedAsync(cancellationToken);

if (values.Count == 0)
{
return;
}

await InvokeRpcAsync<AddSecretFilterValuesResponse>(
connection.Rpc, "secrets.addFilterValues", [new AddSecretFilterValuesRequest { Values = values }], cancellationToken);
}

/// <summary>
/// Gets current authentication status.
/// </summary>
Expand Down
20 changes: 20 additions & 0 deletions dotnet/src/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2812,6 +2812,26 @@ public class PingResponse
public int? ProtocolVersion { get; set; }
}

/// <summary>
/// Request to register secret values for redaction.
/// </summary>
internal class AddSecretFilterValuesRequest
{
/// <summary>Raw secret strings to register.</summary>
[JsonPropertyName("values")]
public IReadOnlyList<string> Values { get; set; } = [];
}

/// <summary>
/// Response from secrets.addFilterValues.
/// </summary>
internal class AddSecretFilterValuesResponse
{
/// <summary>Whether the operation succeeded.</summary>
[JsonPropertyName("ok")]
public bool Ok { get; set; }
}

/// <summary>
/// Response from status.get
/// </summary>
Expand Down
16 changes: 16 additions & 0 deletions go/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
46 changes: 46 additions & 0 deletions go/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
})
}
5 changes: 5 additions & 0 deletions go/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
24 changes: 24 additions & 0 deletions nodejs/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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.
*
Expand Down
46 changes: 45 additions & 1 deletion nodejs/test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"
);
});
});
});
24 changes: 24 additions & 0 deletions python/copilot/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
51 changes: 51 additions & 0 deletions python/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
15 changes: 15 additions & 0 deletions rust/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<str>]) -> 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(
Expand Down
53 changes: 53 additions & 0 deletions rust/tests/session_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}
Loading