Skip to content

Commit

Permalink
feat: credentials framework (#212)
Browse files Browse the repository at this point in the history
Signed-off-by: Grant Linville <[email protected]>
  • Loading branch information
g-linville authored Apr 11, 2024
1 parent a1b26c3 commit f4284f4
Show file tree
Hide file tree
Showing 25 changed files with 927 additions and 67 deletions.
99 changes: 99 additions & 0 deletions docs/docs/03-tools/04-credentials.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Credentials

GPTScript supports credential provider tools. These tools can be used to fetch credentials from a secure location (or
directly from user input) and conveniently set them in the environment before running a script.

## Writing a Credential Provider Tool

A credential provider tool looks just like any other GPTScript, with the following caveats:
- It cannot call the LLM and must run a command.
- It must print contents to stdout in the format `{"env":{"ENV_VAR_1":"value1","ENV_VAR_2":"value2"}}`.
- Any args defined on the tool will be ignored.

Here is a simple example of a credential provider tool that uses the builtin `sys.prompt` to ask the user for some input:

```yaml
# my-credential-tool.gpt
name: my-credential-tool

#!/usr/bin/env bash

output=$(gptscript -q --cache=false sys.prompt '{"message":"Please enter your fake credential.","fields":"credential","sensitive":"true"}')
credential=$(echo $output | jq -r '.credential')
echo "{\"env\":{\"MY_ENV_VAR\":\"$credential\"}}"
```

## Using a Credential Provider Tool

Continuing with the above example, this is how you can use it in a script:

```yaml
credentials: my-credential-tool.gpt

#!/usr/bin/env bash

echo "The value of MY_ENV_VAR is $MY_ENV_VAR"
```

When you run the script, GPTScript will call the credential provider tool first, set the environment variables from its
output, and then run the script body. The credential provider tool is called by GPTScript itself. GPTScript does not ask the
LLM about it or even tell the LLM about the tool.

If GPTScript has called the credential provider tool in the same context (more on that later), then it will use the stored
credential instead of fetching it again.

You can also specify multiple credential tools for the same script:

```yaml
credentials: credential-tool-1.gpt, credential-tool-2.gpt

(tool stuff here)
```

## Storing Credentials

By default, credentials are automatically stored in a config file at `$XDG_CONFIG_HOME/gptscript/config.json`.
This config file also has another parameter, `credsStore`, which indicates where the credentials are being stored.

- `file` (default): The credentials are stored directly in the config file.
- `osxkeychain`: The credentials are stored in the macOS Keychain.

In order to use `osxkeychain` as the credsStore, you must have the `gptscript-credential-osxkeychain` executable
available in your PATH. There will probably be better packaging for this in the future, but for now, you can build it
from the [repo](https://github.com/gptscript-ai/gptscript-credential-helpers).

There will likely be support added for other credential stores in the future.

:::note
Credentials received from credential provider tools that are not on GitHub (such as a local file) will not be stored
in the credentials store.
:::

## Credential Contexts

Each stored credential is uniquely identified by the name of its provider tool and the name of its context. A credential
context is basically a namespace for credentials. If you have multiple credentials from the same provider tool, you can
switch between them by defining them in different credential contexts. The default context is called `default`, and this
is used if none is specified.

You can set the credential context to use with the `--credential-context` flag when running GPTScript. For
example:

```bash
gptscript --credential-context my-azure-workspace my-azure-script.gpt
```

Any credentials fetched for that script will be stored in the `my-azure-workspace` context. If you were to call it again
with a different context, you would be able to give it a different set of credentials.

## Listing and Deleting Stored Credentials

The `gptscript credential` command can be used to list and delete stored credentials. Running the command with no
`--credential-context` set will use the `default` credential context. You can also specify that it should list
credentials in all contexts with `--all-contexts`.

You can delete a credential by running the following command:

```bash
gptscript credential delete --credential-context <credential context> <credential tool name>
```
23 changes: 12 additions & 11 deletions docs/docs/07-gpt-file-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,17 +43,18 @@ Tool instructions go here.

Tool parameters are key-value pairs defined at the beginning of a tool block, before any instructional text. They are specified in the format `key: value`. The parser recognizes the following keys (case-insensitive and spaces are ignored):

| Key | Description |
|------------------|-----------------------------------------------------------------------------------------------------------------------------------------|
| `Name` | The name of the tool. |
| `Model Name` | The OpenAI model to use, by default it uses "gpt-4-turbo-preview" |
| `Description` | The description of the tool. It is important that this properly describes the tool's purpose as the description is used by the LLM. |
| `Internal Prompt`| Setting this to `false` will disable the built-in system prompt for this tool. |
| `Tools` | A comma-separated list of tools that are available to be called by this tool. |
| `Args` | Arguments for the tool. Each argument is defined in the format `arg-name: description`. |
| `Max Tokens` | Set to a number if you wish to limit the maximum number of tokens that can be generated by the LLM. |
| `JSON Response` | Setting to `true` will cause the LLM to respond in a JSON format. If you set true you must also include instructions in the tool. |
| `Temperature` | A floating-point number representing the temperature parameter. By default, the temperature is 0. Set to a higher number for more creativity. |
| Key | Description |
|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------------|
| `Name` | The name of the tool. |
| `Model Name` | The OpenAI model to use, by default it uses "gpt-4-turbo-preview" |
| `Description` | The description of the tool. It is important that this properly describes the tool's purpose as the description is used by the LLM. |
| `Internal Prompt` | Setting this to `false` will disable the built-in system prompt for this tool. |
| `Tools` | A comma-separated list of tools that are available to be called by this tool. |
| `Credentials` | A comma-separated list of credential tools to run before the main tool. |
| `Args` | Arguments for the tool. Each argument is defined in the format `arg-name: description`. |
| `Max Tokens` | Set to a number if you wish to limit the maximum number of tokens that can be generated by the LLM. |
| `JSON Response` | Setting to `true` will cause the LLM to respond in a JSON format. If you set true you must also include instructions in the tool. |
| `Temperature` | A floating-point number representing the temperature parameter. By default, the temperature is 0. Set to a higher number for more creativity. |



Expand Down
4 changes: 4 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ require (
github.com/acorn-io/broadcaster v0.0.0-20240105011354-bfadd4a7b45d
github.com/acorn-io/cmd v0.0.0-20240404013709-34f690bde37b
github.com/adrg/xdg v0.4.0
github.com/docker/cli v26.0.0+incompatible
github.com/docker/docker-credential-helpers v0.8.1
github.com/fatih/color v1.16.0
github.com/getkin/kin-openapi v0.123.0
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
Expand Down Expand Up @@ -63,6 +65,7 @@ require (
github.com/olekukonko/tablewriter v0.0.6-0.20230925090304-df64c4bbad77 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pierrec/lz4/v4 v4.1.15 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
Expand All @@ -77,5 +80,6 @@ require (
golang.org/x/sys v0.16.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/tools v0.17.0 // indirect
gotest.tools/v3 v3.5.1 // indirect
mvdan.cc/gofumpt v0.6.0 // indirect
)
8 changes: 8 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/docker/cli v26.0.0+incompatible h1:90BKrx1a1HKYpSnnBFR6AgDq/FqkHxwlUyzJVPxD30I=
github.com/docker/cli v26.0.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker-credential-helpers v0.8.1 h1:j/eKUktUltBtMzKqmfLB0PAgqYyMHOp5vfsD1807oKo=
github.com/docker/docker-credential-helpers v0.8.1/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M=
github.com/dsnet/compress v0.0.1 h1:PlZu0n3Tuv04TzpfPbrnI0HW/YwodEXDS+oPKahKF0Q=
github.com/dsnet/compress v0.0.1/go.mod h1:Aw8dCMJ7RioblQeTqt88akK31OvO8Dhf5JflhBbQEHo=
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
Expand Down Expand Up @@ -191,6 +195,8 @@ github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0V
github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0=
github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
Expand Down Expand Up @@ -432,6 +438,8 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
Expand Down
57 changes: 57 additions & 0 deletions pkg/builtin/builtin.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ import (
"strings"
"time"

"github.com/AlecAivazis/survey/v2"
"github.com/BurntSushi/locker"
"github.com/google/shlex"
"github.com/gptscript-ai/gptscript/pkg/confirm"
"github.com/gptscript-ai/gptscript/pkg/runner"
"github.com/gptscript-ai/gptscript/pkg/types"
"github.com/jaytaylor/html2text"
)
Expand Down Expand Up @@ -149,6 +151,17 @@ var tools = map[string]types.Tool{
},
BuiltinFunc: SysStat,
},
"sys.prompt": {
Parameters: types.Parameters{
Description: "Prompts the user for input",
Arguments: types.ObjectSchema(
"message", "The message to display to the user",
"fields", "A comma-separated list of fields to prompt for",
"sensitive", "(true or false) Whether the input should be hidden",
),
},
BuiltinFunc: SysPrompt,
},
}

func SysProgram() *types.Program {
Expand Down Expand Up @@ -633,3 +646,47 @@ func SysDownload(ctx context.Context, env []string, input string) (_ string, err

return params.Location, nil
}

func SysPrompt(ctx context.Context, _ []string, input string) (_ string, err error) {
monitor := ctx.Value(runner.MonitorKey{})
if monitor == nil {
return "", errors.New("no monitor in context")
}

unpause := monitor.(runner.Monitor).Pause()
defer unpause()

var params struct {
Message string `json:"message,omitempty"`
Fields string `json:"fields,omitempty"`
Sensitive string `json:"sensitive,omitempty"`
}
if err := json.Unmarshal([]byte(input), &params); err != nil {
return "", err
}

if params.Message != "" {
_, _ = fmt.Fprintln(os.Stderr, params.Message)
}

results := map[string]string{}
for _, f := range strings.Split(params.Fields, ",") {
var value string
if params.Sensitive == "true" {
err = survey.AskOne(&survey.Password{Message: f}, &value, survey.WithStdio(os.Stdin, os.Stderr, os.Stderr))
} else {
err = survey.AskOne(&survey.Input{Message: f}, &value, survey.WithStdio(os.Stdin, os.Stderr, os.Stderr))
}
if err != nil {
return "", err
}
results[f] = value
}

resultsStr, err := json.Marshal(results)
if err != nil {
return "", err
}

return string(resultsStr), nil
}
78 changes: 78 additions & 0 deletions pkg/cli/credential.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package cli

import (
"fmt"
"os"
"sort"
"text/tabwriter"

cmd2 "github.com/acorn-io/cmd"
"github.com/gptscript-ai/gptscript/pkg/config"
"github.com/gptscript-ai/gptscript/pkg/credentials"
"github.com/gptscript-ai/gptscript/pkg/version"
"github.com/spf13/cobra"
)

type Credential struct {
root *GPTScript
AllContexts bool `usage:"List credentials for all contexts" local:"true"`
}

func (c *Credential) Customize(cmd *cobra.Command) {
cmd.Use = "credential"
cmd.Version = version.Get().String()
cmd.Aliases = []string{"cred", "creds", "credentials"}
cmd.Short = "List stored credentials"
cmd.Args = cobra.NoArgs
cmd.AddCommand(cmd2.Command(&Delete{root: c.root}))
}

func (c *Credential) Run(_ *cobra.Command, _ []string) error {
cfg, err := config.ReadCLIConfig(c.root.ConfigFile)
if err != nil {
return fmt.Errorf("failed to read CLI config: %w", err)
}

ctx := c.root.CredentialContext
if c.AllContexts {
ctx = "*"
}

store, err := credentials.NewStore(cfg, ctx)
if err != nil {
return fmt.Errorf("failed to get credentials store: %w", err)
}

creds, err := store.List()
if err != nil {
return fmt.Errorf("failed to list credentials: %w", err)
}

if c.AllContexts {
// Sort credentials by context
sort.Slice(creds, func(i, j int) bool {
if creds[i].Context == creds[j].Context {
return creds[i].ToolName < creds[j].ToolName
}
return creds[i].Context < creds[j].Context
})

w := tabwriter.NewWriter(os.Stdout, 10, 1, 3, ' ', 0)
defer w.Flush()
_, _ = w.Write([]byte("CONTEXT\tTOOL\n"))
for _, cred := range creds {
_, _ = fmt.Fprintf(w, "%s\t%s\n", cred.Context, cred.ToolName)
}
} else {
// Sort credentials by tool name
sort.Slice(creds, func(i, j int) bool {
return creds[i].ToolName < creds[j].ToolName
})

for _, cred := range creds {
fmt.Println(cred.ToolName)
}
}

return nil
}
37 changes: 37 additions & 0 deletions pkg/cli/credential_delete.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package cli

import (
"fmt"

"github.com/gptscript-ai/gptscript/pkg/config"
"github.com/gptscript-ai/gptscript/pkg/credentials"
"github.com/spf13/cobra"
)

type Delete struct {
root *GPTScript
}

func (c *Delete) Customize(cmd *cobra.Command) {
cmd.Use = "delete <tool name>"
cmd.SilenceUsage = true
cmd.Short = "Delete a stored credential"
cmd.Args = cobra.ExactArgs(1)
}

func (c *Delete) Run(_ *cobra.Command, args []string) error {
cfg, err := config.ReadCLIConfig(c.root.ConfigFile)
if err != nil {
return fmt.Errorf("failed to read CLI config: %w", err)
}

store, err := credentials.NewStore(cfg, c.root.CredentialContext)
if err != nil {
return fmt.Errorf("failed to get credentials store: %w", err)
}

if err = store.Remove(args[0]); err != nil {
return fmt.Errorf("failed to remove credential: %w", err)
}
return nil
}
Loading

0 comments on commit f4284f4

Please sign in to comment.