From 07fe01904cd5bc8166dedd3f08518ff8d4119163 Mon Sep 17 00:00:00 2001 From: Altay Date: Mon, 10 Aug 2026 23:38:04 +0300 Subject: [PATCH 1/2] feat(cli): add safe sdk calls --- README.md | 14 ++ skills/putio-cli/SKILL.md | 2 +- skills/putio-cli/references/discovery.md | 8 + skills/putio-cli/references/guardrails.md | 1 + skills/putio-cli/references/writes.md | 3 + src/cli.test.ts | 7 + src/cli.ts | 2 + src/command-paths.test.ts | 159 ++++++++++++++ src/commands/sdk.ts | 255 ++++++++++++++++++++++ src/i18n/catalog/en.ts | 8 + src/internal/cli-contract.ts | 2 + src/internal/command-specs.ts | 10 +- src/internal/metadata.test.ts | 2 + src/internal/output-service.ts | 28 ++- src/internal/output.test.ts | 45 ++++ src/internal/sdk-operations.test.ts | 136 ++++++++++++ src/internal/sdk-operations.ts | 207 ++++++++++++++++++ 17 files changed, 883 insertions(+), 6 deletions(-) create mode 100644 src/commands/sdk.ts create mode 100644 src/internal/sdk-operations.test.ts create mode 100644 src/internal/sdk-operations.ts diff --git a/README.md b/README.md index 67b330c..f32edb3 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,20 @@ Stream larger reads: putio transfers list --page-all --output ndjson ``` +Call a JSON-compatible TypeScript SDK operation that does not have a dedicated command: + +```bash +putio sdk list --output json +putio sdk call --operation files.get --args '[42]' --dry-run --output json +putio sdk call --json '{"operation":"files.get","args":[42]}' --execute --output json +``` + +`sdk call` treats every operation as potentially mutating. It requires exactly one of `--dry-run` +or `--execute`, resolves auth through the normal profile selection, and only traverses own SDK +properties. `sdk list` marks operations requiring runtime objects or binary output—and operations +whose positional or scalar credentials cannot be safely redacted—as unsupported. Supported keyed +credential fields and token-bearing URLs are redacted in plans and results. + ## Tips - Use `--output json` when you want a stable machine-readable contract for scripts, agents, and automation. diff --git a/skills/putio-cli/SKILL.md b/skills/putio-cli/SKILL.md index 50b9c99..64ef786 100644 --- a/skills/putio-cli/SKILL.md +++ b/skills/putio-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: putio-cli -description: Use when an agent needs to operate the put.io CLI as a consumer for put.io authentication, device approval, files, downloads, transfers, or cloud storage tasks, including discovering commands with `putio describe --output json`, authenticating with named profiles, reading stable JSON or NDJSON output, narrowing responses with `--fields`, paging safely with `--page-all`, and previewing writes with `--dry-run` and raw `--json`. +description: Use when an agent needs to operate the put.io CLI as a consumer for put.io authentication, device approval, files, downloads, transfers, cloud storage, or generic TypeScript SDK tasks, including discovering commands with `putio describe --output json`, authenticating with named profiles, reading stable JSON or NDJSON output, narrowing responses with `--fields`, paging safely with `--page-all`, and previewing writes with `--dry-run` and raw `--json`. --- # putio-cli diff --git a/skills/putio-cli/references/discovery.md b/skills/putio-cli/references/discovery.md index bc0e8d8..4fd42a6 100644 --- a/skills/putio-cli/references/discovery.md +++ b/skills/putio-cli/references/discovery.md @@ -24,6 +24,14 @@ Structured output defaults: Use `automation` to confirm concrete support such as dry-run on writes, raw JSON input, field selection, streaming reads, redaction, and untrusted-text annotations. Treat missing features as a real contract gap instead of assuming they exist. +When the required API operation has no dedicated command, inspect the pinned TypeScript SDK surface: + +```bash +putio sdk list --output json +``` + +Only operation paths in `operations` are eligible for `sdk call`. Entries in `unsupported` need runtime values, produce binary data, or use positional or scalar credentials that cannot be safely redacted. Supported keyed credential fields and token-bearing URLs are redacted in plans and results. + Versioning rules: - The skill library follows the CLI contract exposed by `putio describe --output json`. diff --git a/skills/putio-cli/references/guardrails.md b/skills/putio-cli/references/guardrails.md index f8c8ea6..2493033 100644 --- a/skills/putio-cli/references/guardrails.md +++ b/skills/putio-cli/references/guardrails.md @@ -24,6 +24,7 @@ Input safety notes: - resource identifiers reject query fragments and traversal-like segments - field selectors reject nested paths and malformed tokens - name-like inputs reject control characters and traversal-like segments +- generic SDK operation paths resolve only listed enumerable own data properties, reject prototype traversal and accessors, accept positional JSON values only, exclude unsafe positional or scalar credentials, and redact supported keyed secrets and token-bearing URLs Output safety notes: diff --git a/skills/putio-cli/references/writes.md b/skills/putio-cli/references/writes.md index 9f5afb2..bf9a58a 100644 --- a/skills/putio-cli/references/writes.md +++ b/skills/putio-cli/references/writes.md @@ -17,6 +17,8 @@ Examples: putio download-links create --json '{"ids":[1,2]}' --output json putio files mkdir --json '{"name":"Projects","parent_id":9}' --output json putio transfers add --json '[{"url":"https://example.com/file.torrent"}]' --output json +putio sdk call --json '{"operation":"files.get","args":[42]}' --dry-run --output json +putio sdk call --json '{"operation":"files.get","args":[42]}' --execute --output json ``` Rules: @@ -24,3 +26,4 @@ Rules: - Prefer `--json` over translating through many bespoke flags. - Prefer `--dry-run` before side effects. - Re-check schema-required keys in `describe` instead of guessing names. +- Treat every `sdk call` operation as potentially mutating and inspect its dry-run before `--execute`. diff --git a/src/cli.test.ts b/src/cli.test.ts index ca18c4b..7693199 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -186,6 +186,7 @@ describe("cli argv parsing", () => { }>; const mkdir = commands.find((entry) => entry.command === "files mkdir"); const deleteFiles = commands.find((entry) => entry.command === "files delete"); + const sdkCall = commands.find((entry) => entry.command === "sdk call"); expect(mkdir?.input.json?.properties).toEqual( expect.arrayContaining([ @@ -199,6 +200,12 @@ describe("cli argv parsing", () => { expect.objectContaining({ name: "skip_trash", required: false }), ]), ); + expect(sdkCall?.input.json?.properties).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "args", required: false }), + expect.objectContaining({ name: "operation", required: true }), + ]), + ); }); it("accepts an explicit output mode on describe", async () => { diff --git a/src/cli.ts b/src/cli.ts index cccf831..e16b28c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,6 +7,7 @@ import { brandCommand, versionCommand } from "./commands/brand.js"; import { downloadLinksCommand } from "./commands/download-links.js"; import { eventsCommand } from "./commands/events.js"; import { filesCommand, searchCommand } from "./commands/files.js"; +import { sdkCommand } from "./commands/sdk.js"; import { translate } from "./i18n/index.js"; import type { CliConfig } from "./internal/config.js"; import { transfersCommand } from "./commands/transfers.js"; @@ -41,6 +42,7 @@ const command = Command.make("putio", {}, () => Console.log(translate("cli.root. eventsCommand, filesCommand, searchCommand, + sdkCommand, transfersCommand, ]), ); diff --git a/src/command-paths.test.ts b/src/command-paths.test.ts index 9f20f27..240c7b3 100644 --- a/src/command-paths.test.ts +++ b/src/command-paths.test.ts @@ -1003,6 +1003,165 @@ describe("cli command paths", () => { ); }); + it("invokes a JSON-compatible sdk operation with explicit execution", async () => { + await expect( + runCliInTest([ + "putio", + "sdk", + "call", + "--operation", + "files.list", + "--args", + '[0,{"per_page":10}]', + "--execute", + "--output", + "json", + ]), + ).resolves.toBeUndefined(); + + expect(mocks.listFilesMock).toHaveBeenCalledWith(0, { per_page: 10 }); + expect(mocks.writeOutputMock).toHaveBeenCalledWith( + { + operation: "files.list", + result: expect.objectContaining({ total: 1 }), + }, + "json", + expect.any(Function), + ); + }); + + it("previews a raw-json sdk operation without authentication or invocation", async () => { + await expect( + runCliInTest([ + "putio", + "sdk", + "call", + "--json", + '{"operation":"files.list","args":[0]}', + "--dry-run", + "--output", + "json", + ]), + ).resolves.toBeUndefined(); + + expect(mocks.withAuthedSdkMock).not.toHaveBeenCalled(); + expect(mocks.listFilesMock).not.toHaveBeenCalled(); + expect(mocks.writeOutputMock).toHaveBeenCalledWith( + { + command: "sdk call", + dryRun: true, + request: { + args: [0], + operation: "files.list", + }, + }, + "json", + expect.any(Function), + ); + }); + + it("requires explicit sdk call execution consent", async () => { + await expect( + runCliInTest(["putio", "sdk", "call", "--operation", "files.list", "--output", "json"]), + ).rejects.toMatchObject({ + message: "Choose exactly one of `sdk call --dry-run` or `sdk call --execute`.", + }); + + expect(mocks.withAuthedSdkMock).not.toHaveBeenCalled(); + }); + + it("rejects secret-bearing sdk operations before dry-run output", async () => { + await expect( + runCliInTest([ + "putio", + "sdk", + "call", + "--operation", + "auth.validateToken", + "--args", + '["secret-token"]', + "--dry-run", + "--output", + "json", + ]), + ).rejects.toMatchObject({ + message: + "SDK operation `auth.validateToken` is not JSON-callable: authentication operations can expose credentials or approval codes.", + }); + + expect(mocks.writeOutputMock).not.toHaveBeenCalled(); + expect(mocks.withAuthedSdkMock).not.toHaveBeenCalled(); + }); + + it("strictly redacts sentinel-looking sdk secrets in dry-run plans", async () => { + await expect( + runCliInTest([ + "putio", + "sdk", + "call", + "--operation", + "files.list", + "--args", + '[0,{"password":"null"}]', + "--dry-run", + "--output", + "json", + ]), + ).resolves.toBeUndefined(); + + expect(mocks.writeOutputMock).toHaveBeenCalledWith( + { + command: "sdk call", + dryRun: true, + request: { + args: [0, { password: "[REDACTED]" }], + operation: "files.list", + }, + }, + "json", + expect.any(Function), + ); + expect(mocks.withAuthedSdkMock).not.toHaveBeenCalled(); + }); + + it("strictly redacts sentinel-looking sdk secrets in execution results", async () => { + mocks.listFilesMock.mockImplementationOnce(() => + Effect.succeed({ + download_token: "null", + files: [], + total: 0, + }), + ); + + await expect( + runCliInTest([ + "putio", + "sdk", + "call", + "--operation", + "files.list", + "--args", + "[0]", + "--execute", + "--output", + "json", + ]), + ).resolves.toBeUndefined(); + + expect(mocks.writeOutputMock).toHaveBeenCalledWith( + { + operation: "files.list", + result: { + download_token: "[REDACTED]", + files: [], + total: 0, + }, + }, + "json", + expect.any(Function), + ); + }); + it("selects top-level file list fields for json output", async () => { await expect( runCliInTest(["putio", "files", "list", "--fields", "files,total", "--output", "json"]), diff --git a/src/commands/sdk.ts b/src/commands/sdk.ts new file mode 100644 index 0000000..7034405 --- /dev/null +++ b/src/commands/sdk.ts @@ -0,0 +1,255 @@ +import { Command } from "effect/unstable/cli"; +import { Console, Effect, Schema } from "effect"; + +import { translate } from "../i18n/index.js"; +import { + defineBooleanOption, + defineTextOption, + dryRunOption, + getOption, + jsonOption, + outputOption, + resolveMutationInput, + withAuthedSdk, + writeDryRunPlan, + CliCommandInputError, +} from "../internal/command.js"; +import { + dryRunFlag, + jsonFlag, + outputFlag, + type CommandJsonShape, + type CommandSpec, +} from "../internal/command-specs.js"; +import { withTerminalLoader } from "../internal/loader-service.js"; +import { + redactSensitiveStructuredValues, + renderJson, + writeOutput, +} from "../internal/output-service.js"; +import { sdk } from "../internal/sdk.js"; +import { + invokeSdkOperation, + listSdkOperations, + normalizeSdkOperationResult, + resolveSdkOperation, +} from "../internal/sdk-operations.js"; + +const NonBlankStringSchema = Schema.String.check( + Schema.makeFilter((value) => + value.trim().length > 0 ? undefined : "Expected a non-empty string", + ), +); + +const SdkCallInputSchema = Schema.Struct({ + args: Schema.optional(Schema.Array(Schema.Json)), + operation: NonBlankStringSchema, +}); + +type SdkCallInput = Schema.Schema.Type; + +const argsConfig = defineTextOption("args", { + defaultValue: "[]", + description: "JSON array of positional arguments passed to the SDK function.", +}); +const executeConfig = defineBooleanOption("execute", { + defaultValue: false, + description: "Explicitly execute the selected SDK operation.", +}); +const operationConfig = defineTextOption("operation", { + description: "Dot-separated SDK function path, for example files.get.", + optional: true, +}); + +const argsOption = argsConfig.option; +const executeOption = executeConfig.option; +const operationOption = operationConfig.option; + +const parseSdkArguments = (raw: string) => { + let parsed: unknown; + + try { + parsed = JSON.parse(raw) as unknown; + } catch { + throw new CliCommandInputError({ + message: "Expected `sdk call --args` to contain valid JSON.", + }); + } + + try { + return Schema.decodeUnknownSync(Schema.Array(Schema.Json))(parsed); + } catch { + throw new CliCommandInputError({ + message: "Expected `sdk call --args` to contain a JSON array.", + }); + } +}; + +const resolveExecutionMode = (dryRun: boolean, execute: boolean) => { + if (dryRun === execute) { + throw new CliCommandInputError({ + message: "Choose exactly one of `sdk call --dry-run` or `sdk call --execute`.", + }); + } + + return dryRun ? "dry-run" : "execute"; +}; + +const resolveInput = (input: SdkCallInput) => ({ + args: input.args ?? [], + operation: input.operation, +}); + +const renderSdkCatalogTerminal = (catalog: ReturnType) => { + const supported = catalog.operations.map((operation) => ` ${operation}`); + const unsupported = catalog.unsupported.map( + ({ operation, reason }) => ` ${operation} — ${reason}`, + ); + + return [ + `JSON-callable SDK operations (${catalog.operations.length})`, + ...supported, + "", + `Unsupported SDK operations (${catalog.unsupported.length})`, + ...unsupported, + ].join("\n"); +}; + +const sdkList = Command.make("list", { output: outputOption }, ({ output }) => + writeOutput(listSdkOperations(sdk), getOption(output), renderSdkCatalogTerminal), +); + +const sdkCall = Command.make( + "call", + { + args: argsOption, + dryRun: dryRunOption, + execute: executeOption, + json: jsonOption, + operation: operationOption, + output: outputOption, + }, + ({ args, dryRun, execute, json, operation, output }) => + Effect.gen(function* () { + const mode = yield* Effect.try({ + try: () => resolveExecutionMode(dryRun, execute), + catch: (error) => error, + }); + const input = yield* resolveMutationInput({ + buildFromFlags: () => ({ + args: parseSdkArguments(args), + operation: + getOption(operation) ?? + (() => { + throw new CliCommandInputError({ + message: "Provide `sdk call --operation` or `sdk call --json`.", + }); + })(), + }), + json, + schema: SdkCallInputSchema, + }).pipe(Effect.map(resolveInput)); + + yield* Effect.try({ + try: () => resolveSdkOperation(sdk, input.operation), + catch: (error) => error, + }); + + if (mode === "dry-run") { + return yield* writeDryRunPlan( + "sdk call", + { + args: redactSensitiveStructuredValues(input.args), + operation: input.operation, + }, + getOption(output), + ); + } + + const result = yield* withTerminalLoader( + { + message: translate("cli.sdk.command.calling", { operation: input.operation }), + output: getOption(output), + }, + withAuthedSdk(({ sdk: authedSdk }) => + invokeSdkOperation(authedSdk, input.operation, input.args), + ), + ); + const normalized = yield* Effect.try({ + try: () => normalizeSdkOperationResult(input.operation, result), + catch: (error) => error, + }); + + yield* writeOutput( + { + operation: input.operation, + result: redactSensitiveStructuredValues(normalized), + }, + getOption(output), + (value) => `${value.operation}\n${renderJson(value.result)}`, + ); + }), +); + +export const sdkCommand = Command.make("sdk", {}, () => + Console.log(translate("cli.sdk.chooseSubcommand")), +).pipe(Command.withSubcommands([sdkList, sdkCall])); + +const sdkCallJsonShape = { + kind: "object", + properties: [ + { + name: "args", + required: false, + schema: { kind: "array", items: { kind: "json" } }, + }, + { + name: "operation", + required: true, + schema: { kind: "string" }, + }, + ], + rules: [ + "`operation` must be an own, JSON-callable SDK function path returned by `sdk list`.", + "Exactly one of `--dry-run` or `--execute` is required.", + ], +} satisfies CommandJsonShape; + +export const sdkCommandSpecs = [ + { + auth: { required: false }, + capabilities: { + dryRun: false, + fieldSelection: false, + rawJsonInput: false, + streaming: false, + }, + command: "sdk list", + input: { flags: [outputFlag()] }, + kind: "utility", + purpose: translate("cli.metadata.sdkList"), + }, + { + auth: { required: true }, + capabilities: { + dryRun: true, + fieldSelection: false, + rawJsonInput: true, + streaming: false, + }, + command: "sdk call", + input: { + flags: [ + argsConfig.flag, + dryRunFlag(), + executeConfig.flag, + jsonFlag(), + operationConfig.flag, + outputFlag(), + ], + json: sdkCallJsonShape, + }, + kind: "write", + purpose: translate("cli.metadata.sdkCall"), + }, +] satisfies ReadonlyArray; diff --git a/src/i18n/catalog/en.ts b/src/i18n/catalog/en.ts index d7c64cc..a8e8953 100644 --- a/src/i18n/catalog/en.ts +++ b/src/i18n/catalog/en.ts @@ -257,6 +257,8 @@ export const en = { filesRename: "Rename a file by id.", filesSearch: "Search files by query and optional file type.", search: "Top-level alias for file search.", + sdkCall: "Invoke a JSON-compatible function from the pinned put.io TypeScript SDK.", + sdkList: "List JSON-callable functions exposed by the pinned put.io TypeScript SDK.", transfersAdd: "Add one or more transfers from URLs or magnet links.", transfersCancel: "Cancel one or more transfers.", transfersClean: "Clean all transfers or a selected set of transfer ids.", @@ -271,6 +273,12 @@ export const en = { chooseAuthSubcommand: "Choose `status`, `login`, `logout`, or `preview`.", help: "Use `putio describe` or `putio --help`.", }, + sdk: { + chooseSubcommand: "Choose `list` or `call`.", + command: { + calling: "Calling SDK operation {{operation}}...", + }, + }, transfers: { command: { adding: "Adding {{count}} transfer(s)...", diff --git a/src/internal/cli-contract.ts b/src/internal/cli-contract.ts index ea46c47..f7cfe28 100644 --- a/src/internal/cli-contract.ts +++ b/src/internal/cli-contract.ts @@ -3,6 +3,7 @@ import { utilityCommandSpecs } from "../commands/brand.js"; import { downloadLinksCommandSpecs } from "../commands/download-links.js"; import { eventsCommandSpecs } from "../commands/events.js"; import { filesCommandSpecs } from "../commands/files.js"; +import { sdkCommandSpecs } from "../commands/sdk.js"; import { transfersCommandSpecs } from "../commands/transfers.js"; import { whoamiCommandSpecs } from "../commands/whoami.js"; import { translate } from "../i18n/index.js"; @@ -39,5 +40,6 @@ export const commandCatalog = decodeCommandSpecs([ ...downloadLinksCommandSpecs, ...eventsCommandSpecs, ...filesCommandSpecs, + ...sdkCommandSpecs, ...transfersCommandSpecs, ]); diff --git a/src/internal/command-specs.ts b/src/internal/command-specs.ts index 415e90e..3b6ec7c 100644 --- a/src/internal/command-specs.ts +++ b/src/internal/command-specs.ts @@ -12,7 +12,13 @@ type CommandKind = Schema.Schema.Type; const CommandOptionTypeSchema = Schema.Literals(["string", "integer", "boolean", "enum"] as const); -const JsonPrimitiveKindSchema = Schema.Literals(["string", "integer", "boolean", "null"] as const); +const JsonPrimitiveKindSchema = Schema.Literals([ + "string", + "integer", + "boolean", + "null", + "json", +] as const); const JsonScalarSchema = Schema.Struct({ kind: JsonPrimitiveKindSchema, }); @@ -26,7 +32,7 @@ const JsonEnumValueSchema = Schema.Union([ export type CommandJsonShape = | { - readonly kind: "string" | "integer" | "boolean" | "null"; + readonly kind: "string" | "integer" | "boolean" | "null" | "json"; } | { readonly kind: "enum"; diff --git a/src/internal/metadata.test.ts b/src/internal/metadata.test.ts index 5a0cea3..19fa934 100644 --- a/src/internal/metadata.test.ts +++ b/src/internal/metadata.test.ts @@ -66,6 +66,8 @@ describe("describeCli", () => { "files move", "files delete", "search", + "sdk list", + "sdk call", "transfers list", "transfers add", "transfers cancel", diff --git a/src/internal/output-service.ts b/src/internal/output-service.ts index f8dfc7e..3cca14a 100644 --- a/src/internal/output-service.ts +++ b/src/internal/output-service.ts @@ -59,7 +59,7 @@ export const detectOutputModeFromArgv = ( }; const SENSITIVE_KEY_PATTERN = - /^(auth_?token|token|access_?token|refresh_?token|authorization|password|secret|cookie)$/i; + /^(authorization|auth_?token|access_?token|refresh_?token|(?:.*_)?(?:token|password|secret|cookie))$/u; const REDACTED_VALUE = "[REDACTED]"; const SAFE_SENSITIVE_SENTINEL_VALUES = new Set([ @@ -89,6 +89,11 @@ const isPlainObject = (value: unknown): value is Record => { return prototype === Object.prototype || prototype === null; }; +const isSensitiveKey = (key: string) => { + const normalized = key.replace(/([a-z0-9])([A-Z])/gu, "$1_$2").toLowerCase(); + return SENSITIVE_KEY_PATTERN.test(normalized); +}; + const redactSensitiveText = (value: string) => value .replace( @@ -126,7 +131,7 @@ export const sanitizeTerminalValue = (value: unknown): unknown => { return Object.fromEntries( Object.entries(value).map(([key, nestedValue]) => [ key, - SENSITIVE_KEY_PATTERN.test(key) && typeof nestedValue === "string" + isSensitiveKey(key) && typeof nestedValue === "string" ? SAFE_SENSITIVE_SENTINEL_VALUES.has(nestedValue) ? nestedValue : REDACTED_VALUE @@ -178,7 +183,7 @@ const sanitizeStructuredValueInternal = ( if (isPlainObject(value)) { const entries = Object.entries(value).map(([key, nestedValue]) => { - if (SENSITIVE_KEY_PATTERN.test(key) && typeof nestedValue === "string") { + if (isSensitiveKey(key) && typeof nestedValue === "string") { return { key, result: { @@ -233,6 +238,23 @@ const sanitizeStructuredValueInternal = ( export const sanitizeStructuredValue = (value: unknown): unknown => sanitizeStructuredValueInternal(value, []).value; +export const redactSensitiveStructuredValues = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(redactSensitiveStructuredValues); + } + + if (isPlainObject(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, nestedValue]) => [ + key, + isSensitiveKey(key) ? REDACTED_VALUE : redactSensitiveStructuredValues(nestedValue), + ]), + ); + } + + return value; +}; + export const renderJson = (value: unknown) => JSON.stringify(sanitizeStructuredValue(value), null, 2); export const renderNdjson = (value: unknown) => JSON.stringify(sanitizeStructuredValue(value)); diff --git a/src/internal/output.test.ts b/src/internal/output.test.ts index 98c8973..eac98b2 100644 --- a/src/internal/output.test.ts +++ b/src/internal/output.test.ts @@ -8,6 +8,7 @@ import { renderJson, renderNdjson, renderTerminal, + redactSensitiveStructuredValues, sanitizeStructuredValue, sanitizeTerminalText, sanitizeTerminalValue, @@ -34,6 +35,28 @@ describe("sanitizeTerminalValue", () => { }); }); + it("redacts snake-case and camel-case credential keys used by sdk payloads", () => { + expect( + sanitizeStructuredValue({ + accesstoken: "compact-access-token", + authtoken: "compact-auth-token", + clientSecret: "client-secret", + download_token: "download-token", + oauthToken: "oauth-token", + push_token: "push-token", + refreshtoken: "compact-refresh-token", + }), + ).toEqual({ + accesstoken: "[REDACTED]", + authtoken: "[REDACTED]", + clientSecret: "[REDACTED]", + download_token: "[REDACTED]", + oauthToken: "[REDACTED]", + push_token: "[REDACTED]", + refreshtoken: "[REDACTED]", + }); + }); + it("preserves non-plain objects for renderers", () => { const date = new Date("2026-01-01T00:00:00.000Z"); @@ -88,6 +111,12 @@ describe("renderJson", () => { ); }); + it("sanitizes token-bearing scalar URLs", () => { + expect(renderJson("https://api.put.io/v2/files/42/download?oauth_token=secret-token")).toBe( + '"https://api.put.io/v2/files/42/download?oauth_token=[REDACTED]"', + ); + }); + it("preserves terminal control characters as escaped json data", () => { expect(renderJson({ name: "safe\u001B[2J" })).toContain('"name": "safe\\u001b[2J"'); }); @@ -166,6 +195,22 @@ describe("sanitizeStructuredValue", () => { }); }); +describe("redactSensitiveStructuredValues", () => { + it("strictly redacts sentinel-looking credential values for sdk payloads", () => { + expect( + redactSensitiveStructuredValues({ + auth_token: "string", + clientSecret: "null", + nested: { password: "[REDACTED]" }, + }), + ).toEqual({ + auth_token: "[REDACTED]", + clientSecret: "[REDACTED]", + nested: { password: "[REDACTED]" }, + }); + }); +}); + describe("renderTerminal", () => { it("defaults terminal output through the sanitizer", () => { expect( diff --git a/src/internal/sdk-operations.test.ts b/src/internal/sdk-operations.test.ts new file mode 100644 index 0000000..33b8de2 --- /dev/null +++ b/src/internal/sdk-operations.test.ts @@ -0,0 +1,136 @@ +import { Effect } from "effect"; +import { describe, expect, it } from "vite-plus/test"; + +import { CliCommandInputError } from "./command.js"; +import { + invokeSdkOperation, + listSdkOperations, + normalizeSdkOperationResult, + resolveSdkOperation, +} from "./sdk-operations.js"; + +describe("sdk operations", () => { + it("discovers nested own callables without invoking accessors", () => { + let getterCalls = 0; + const files = { + get: () => Effect.succeed({ id: 42 }), + }; + Object.defineProperty(files, "secret", { + enumerable: true, + get: () => { + getterCalls += 1; + return () => Effect.void; + }, + }); + + expect(listSdkOperations({ files })).toEqual({ + operations: ["files.get"], + unsupported: [], + }); + expect(getterCalls).toBe(0); + expect(() => resolveSdkOperation({ files }, "files.secret")).toThrow( + "SDK operation paths cannot resolve accessor properties.", + ); + expect(getterCalls).toBe(0); + }); + + it("marks runtime-valued operations as unsupported", () => { + const catalog = listSdkOperations({ + files: { + get: () => Effect.void, + upload: () => Effect.void, + }, + }); + + expect(catalog.operations).toEqual(["files.get"]); + expect(catalog.unsupported).toEqual([ + { + operation: "files.upload", + reason: "requires a Blob-backed file input", + }, + ]); + expect(() => + resolveSdkOperation({ files: { upload: () => Effect.void } }, "files.upload"), + ).toThrowError(CliCommandInputError); + }); + + it("excludes secret-bearing namespaces and scalar credential operations", () => { + const catalog = listSdkOperations({ + account: { + destroy: () => Effect.void, + }, + auth: { + validateToken: () => Effect.void, + }, + oauth: { + regenerateToken: () => Effect.succeed("secret-token"), + }, + }); + + expect(catalog.operations).toEqual([]); + expect(catalog.unsupported).toEqual([ + { + operation: "account.destroy", + reason: "accepts a positional account password", + }, + { + operation: "auth.validateToken", + reason: "authentication operations can expose credentials or approval codes", + }, + { + operation: "oauth.regenerateToken", + reason: "returns a credential as a scalar value", + }, + ]); + }); + + it("rejects prototype traversal and unknown paths", () => { + const client = { files: { get: () => Effect.void } }; + + expect(() => resolveSdkOperation(client, "files.__proto__.toString")).toThrow( + "Expected `--operation` to be a dot-separated SDK path", + ); + expect(() => resolveSdkOperation(client, "files.missing")).toThrow( + "Unknown SDK operation: `files.missing`.", + ); + }); + + it("rejects non-enumerable methods that discovery does not advertise", () => { + const files = {}; + Object.defineProperty(files, "privateCall", { + enumerable: false, + value: () => Effect.void, + }); + const client = { files }; + + expect(listSdkOperations(client).operations).toEqual([]); + expect(() => resolveSdkOperation(client, "files.privateCall")).toThrow( + "Unknown SDK operation: `files.privateCall`.", + ); + }); + + it("invokes Effect and pure JSON operations with positional arguments", async () => { + const client = { + files: { + get: (id: number) => Effect.succeed({ id }), + }, + helpers: { + join: (left: string, right: string) => `${left}:${right}`, + }, + }; + + await expect(Effect.runPromise(invokeSdkOperation(client, "files.get", [42]))).resolves.toEqual( + { id: 42 }, + ); + await expect( + Effect.runPromise(invokeSdkOperation(client, "helpers.join", ["a", "b"])), + ).resolves.toBe("a:b"); + }); + + it("normalizes void to null and rejects non-JSON results", () => { + expect(normalizeSdkOperationResult("family.join", undefined)).toBeNull(); + expect(() => normalizeSdkOperationResult("events.getTorrent", new Uint8Array([1]))).toThrow( + "returned a value that cannot be represented as JSON", + ); + }); +}); diff --git a/src/internal/sdk-operations.ts b/src/internal/sdk-operations.ts new file mode 100644 index 0000000..38a38ae --- /dev/null +++ b/src/internal/sdk-operations.ts @@ -0,0 +1,207 @@ +import type { PutioSdkContext } from "@putdotio/sdk"; +import { Effect, Schema } from "effect"; + +import { CliCommandInputError } from "./command.js"; + +export type SdkJsonValue = Schema.Schema.Type; + +type SdkCallable = (...args: ReadonlyArray) => unknown; + +type ResolvedSdkOperation = { + readonly callable: SdkCallable; + readonly receiver: object; +}; + +const unsupportedOperations = new Map([ + ["account.destroy", "accepts a positional account password"], + ["config.getKeyWith", "requires a runtime schema decoder"], + ["config.readWith", "requires a runtime schema decoder"], + ["events.getTorrent", "returns binary data"], + ["files.createUploadRequest", "requires a Blob-backed file input"], + ["files.upload", "requires a Blob-backed file input"], + ["oauth.setIcon", "requires a Blob-backed icon input"], + ["oauth.regenerateToken", "returns a credential as a scalar value"], +]); + +const unsupportedNamespaces = new Map([ + ["auth", "authentication operations can expose credentials or approval codes"], + ["config", "arbitrary key-value operations can expose secret scalar values"], +]); + +const operationSegmentPattern = /^[a-z][A-Za-z0-9]*$/u; +const blockedOperationSegments = new Set(["__proto__", "constructor", "prototype"]); + +const isTraversableObject = (value: unknown): value is Record => { + if (typeof value !== "object" || value === null) { + return false; + } + + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +}; + +const operationError = (message: string) => new CliCommandInputError({ message }); + +const getUnsupportedReason = (operation: string) => { + const exactReason = unsupportedOperations.get(operation); + if (exactReason !== undefined) { + return exactReason; + } + + const [namespace] = operation.split("."); + return namespace === undefined ? undefined : unsupportedNamespaces.get(namespace); +}; + +const validateOperationPath = (operation: string) => { + const segments = operation.split("."); + + if ( + operation.trim() !== operation || + segments.length < 2 || + segments.some( + (segment) => !operationSegmentPattern.test(segment) || blockedOperationSegments.has(segment), + ) + ) { + throw operationError( + "Expected `--operation` to be a dot-separated SDK path such as `files.get`.", + ); + } + + return segments; +}; + +export type SdkOperationCatalog = { + readonly operations: ReadonlyArray; + readonly unsupported: ReadonlyArray<{ + readonly operation: string; + readonly reason: string; + }>; +}; + +export const listSdkOperations = (client: unknown): SdkOperationCatalog => { + const operations: Array = []; + const discoveredUnsupported: Array<{ readonly operation: string; readonly reason: string }> = []; + const ancestors = new WeakSet(); + + const visit = (value: unknown, path: ReadonlyArray) => { + if (!isTraversableObject(value) || ancestors.has(value)) { + return; + } + + ancestors.add(value); + + for (const key of Object.keys(value).toSorted()) { + if (!operationSegmentPattern.test(key) || blockedOperationSegments.has(key)) { + continue; + } + + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) { + continue; + } + + const operationPath = [...path, key]; + const operation = operationPath.join("."); + + if (typeof descriptor.value === "function") { + const reason = getUnsupportedReason(operation); + if (reason === undefined) { + operations.push(operation); + } else { + discoveredUnsupported.push({ operation, reason }); + } + continue; + } + + visit(descriptor.value, operationPath); + } + + ancestors.delete(value); + }; + + visit(client, []); + + return { + operations: operations.toSorted(), + unsupported: discoveredUnsupported.toSorted((left, right) => + left.operation.localeCompare(right.operation), + ), + }; +}; + +export const resolveSdkOperation = (client: unknown, operation: string): ResolvedSdkOperation => { + const segments = validateOperationPath(operation); + const unsupportedReason = getUnsupportedReason(operation); + + if (unsupportedReason !== undefined) { + throw operationError( + `SDK operation \`${operation}\` is not JSON-callable: ${unsupportedReason}.`, + ); + } + + let current: unknown = client; + let receiver: object | undefined; + + for (const segment of segments) { + if (!isTraversableObject(current)) { + throw operationError(`Unknown SDK operation: \`${operation}\`.`); + } + + const descriptor = Object.getOwnPropertyDescriptor(current, segment); + if (!descriptor || descriptor.enumerable !== true) { + throw operationError(`Unknown SDK operation: \`${operation}\`.`); + } + + if (!("value" in descriptor)) { + throw operationError(`SDK operation paths cannot resolve accessor properties.`); + } + + receiver = current; + current = descriptor.value; + } + + if (receiver === undefined || typeof current !== "function") { + throw operationError(`Unknown SDK operation: \`${operation}\`.`); + } + + return { + callable: current as SdkCallable, + receiver, + }; +}; + +type DynamicSdkEffect = Effect.Effect; + +export const invokeSdkOperation = ( + client: unknown, + operation: string, + args: ReadonlyArray, +): DynamicSdkEffect => + Effect.try({ + try: () => { + const resolved = resolveSdkOperation(client, operation); + return Reflect.apply(resolved.callable, resolved.receiver, args); + }, + catch: (error) => + error instanceof CliCommandInputError + ? error + : operationError(`SDK operation \`${operation}\` rejected its arguments before execution.`), + }).pipe( + Effect.flatMap((result) => + Effect.isEffect(result) ? (result as DynamicSdkEffect) : Effect.succeed(result), + ), + ); + +export const normalizeSdkOperationResult = (operation: string, value: unknown): SdkJsonValue => { + if (value === undefined) { + return null; + } + + try { + return Schema.decodeUnknownSync(Schema.Json)(value); + } catch { + throw operationError( + `SDK operation \`${operation}\` returned a value that cannot be represented as JSON.`, + ); + } +}; From a6d4415c7355a6cd5718eaa238dc7bf45fa1bcf2 Mon Sep 17 00:00:00 2001 From: Altay Date: Mon, 10 Aug 2026 23:54:40 +0300 Subject: [PATCH 2/2] fix(cli): align sdk operation catalog --- src/internal/output.test.ts | 44 ++++++++++++++--------------- src/internal/sdk-operations.test.ts | 2 +- src/internal/sdk-operations.ts | 4 +++ 3 files changed, 27 insertions(+), 23 deletions(-) diff --git a/src/internal/output.test.ts b/src/internal/output.test.ts index eac98b2..be45962 100644 --- a/src/internal/output.test.ts +++ b/src/internal/output.test.ts @@ -35,28 +35,6 @@ describe("sanitizeTerminalValue", () => { }); }); - it("redacts snake-case and camel-case credential keys used by sdk payloads", () => { - expect( - sanitizeStructuredValue({ - accesstoken: "compact-access-token", - authtoken: "compact-auth-token", - clientSecret: "client-secret", - download_token: "download-token", - oauthToken: "oauth-token", - push_token: "push-token", - refreshtoken: "compact-refresh-token", - }), - ).toEqual({ - accesstoken: "[REDACTED]", - authtoken: "[REDACTED]", - clientSecret: "[REDACTED]", - download_token: "[REDACTED]", - oauthToken: "[REDACTED]", - push_token: "[REDACTED]", - refreshtoken: "[REDACTED]", - }); - }); - it("preserves non-plain objects for renderers", () => { const date = new Date("2026-01-01T00:00:00.000Z"); @@ -141,6 +119,28 @@ describe("renderNdjson", () => { }); describe("sanitizeStructuredValue", () => { + it("redacts snake-case and camel-case credential keys used by sdk payloads", () => { + expect( + sanitizeStructuredValue({ + accesstoken: "compact-access-token", + authtoken: "compact-auth-token", + clientSecret: "client-secret", + download_token: "download-token", + oauthToken: "oauth-token", + push_token: "push-token", + refreshtoken: "compact-refresh-token", + }), + ).toEqual({ + accesstoken: "[REDACTED]", + authtoken: "[REDACTED]", + clientSecret: "[REDACTED]", + download_token: "[REDACTED]", + oauthToken: "[REDACTED]", + push_token: "[REDACTED]", + refreshtoken: "[REDACTED]", + }); + }); + it("preserves schema sentinel values for sensitive-looking keys", () => { expect(sanitizeStructuredValue({ persistedConfigShape: { auth_token: "string" } })).toEqual({ persistedConfigShape: { auth_token: "string" }, diff --git a/src/internal/sdk-operations.test.ts b/src/internal/sdk-operations.test.ts index 33b8de2..22fbb80 100644 --- a/src/internal/sdk-operations.test.ts +++ b/src/internal/sdk-operations.test.ts @@ -23,7 +23,7 @@ describe("sdk operations", () => { }, }); - expect(listSdkOperations({ files })).toEqual({ + expect(listSdkOperations({ files, helper: () => Effect.void })).toEqual({ operations: ["files.get"], unsupported: [], }); diff --git a/src/internal/sdk-operations.ts b/src/internal/sdk-operations.ts index 38a38ae..95e1db4 100644 --- a/src/internal/sdk-operations.ts +++ b/src/internal/sdk-operations.ts @@ -104,6 +104,10 @@ export const listSdkOperations = (client: unknown): SdkOperationCatalog => { const operation = operationPath.join("."); if (typeof descriptor.value === "function") { + if (operationPath.length < 2) { + continue; + } + const reason = getUnsupportedReason(operation); if (reason === undefined) { operations.push(operation);