diff --git a/README.md b/README.md index 52fbddf..5fd4383 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,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 5bb10ce..a1b5a3f 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 - local upload paths reject control characters and must resolve to readable regular files Output safety notes: diff --git a/skills/putio-cli/references/writes.md b/skills/putio-cli/references/writes.md index da3a1ce..e7e771c 100644 --- a/skills/putio-cli/references/writes.md +++ b/skills/putio-cli/references/writes.md @@ -22,6 +22,8 @@ putio files mkdir --json '{"name":"Projects","parent_id":9}' --output json putio files upload --json '{"path":"./movie.mp4","parent_id":42}' --output json putio files start-from reset --json '{"file_id":42}' --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: @@ -29,4 +31,5 @@ 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`. - `files upload` validates that `path` resolves to a readable regular file before dry-run or execution. diff --git a/src/cli.test.ts b/src/cli.test.ts index d29b366..a1b6d2f 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"); const upload = commands.find((entry) => entry.command === "files upload"); const authApprove = commands.find((entry) => entry.command === "auth approve"); const startFromSet = commands.find((entry) => entry.command === "files start-from set"); @@ -202,6 +203,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 }), + ]), + ); expect(upload?.input.json?.properties).toEqual( expect.arrayContaining([ expect.objectContaining({ name: "path", required: true }), 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 d9795ff..8af14f9 100644 --- a/src/command-paths.test.ts +++ b/src/command-paths.test.ts @@ -1356,6 +1356,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 64f087e..abebe8a 100644 --- a/src/i18n/catalog/en.ts +++ b/src/i18n/catalog/en.ts @@ -272,6 +272,8 @@ export const en = { filesStartFromReset: "Reset the saved watch position for a file.", filesStartFromSet: "Set the saved watch position for a file.", 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.", @@ -286,6 +288,12 @@ export const en = { chooseAuthSubcommand: "Choose `status`, `login`, `logout`, `preview`, or `approve`.", 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 a767db7..f175864 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 3f06e11..566e9e1 100644 --- a/src/internal/metadata.test.ts +++ b/src/internal/metadata.test.ts @@ -71,6 +71,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..be45962 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, @@ -88,6 +89,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"'); }); @@ -112,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" }, @@ -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..22fbb80 --- /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, helper: () => Effect.void })).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..95e1db4 --- /dev/null +++ b/src/internal/sdk-operations.ts @@ -0,0 +1,211 @@ +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") { + if (operationPath.length < 2) { + continue; + } + + 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.`, + ); + } +};