mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-06 16:59:01 +00:00
Permission rework (#6319)
Co-authored-by: Github Action <action@github.com> Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
This commit is contained in:
163
packages/opencode/src/permission/arity.ts
Normal file
163
packages/opencode/src/permission/arity.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
export namespace BashArity {
|
||||
export function prefix(tokens: string[]) {
|
||||
for (let len = tokens.length; len > 0; len--) {
|
||||
const prefix = tokens.slice(0, len).join(" ")
|
||||
const arity = ARITY[prefix]
|
||||
if (arity !== undefined) return tokens.slice(0, arity)
|
||||
}
|
||||
if (tokens.length === 0) return []
|
||||
return tokens.slice(0, 1)
|
||||
}
|
||||
|
||||
/* Generated with following prompt:
|
||||
You are generating a dictionary of command-prefix arities for bash-style commands.
|
||||
This dictionary is used to identify the "human-understandable command" from an input shell command.### **RULES (follow strictly)**1. Each entry maps a **command prefix string → number**, representing how many **tokens** define the command.
|
||||
2. **Flags NEVER count as tokens**. Only subcommands count.
|
||||
3. **Longest matching prefix wins**.
|
||||
4. **Only include a longer prefix if its arity is different from what the shorter prefix already implies**. * Example: If `git` is 2, then do **not** include `git checkout`, `git commit`, etc. unless they require *different* arity.
|
||||
5. The output must be a **single JSON object**. Each entry should have a comment with an example real world matching command. DO NOT MAKE ANY OTHER COMMENTS. Should be alphabetical
|
||||
6. Include the **most commonly used commands** across many stacks and languages. More is better.### **Semantics examples*** `touch foo.txt` → `touch` (arity 1, explicitly listed)
|
||||
* `git checkout main` → `git checkout` (because `git` has arity 2)
|
||||
* `npm install` → `npm install` (because `npm` has arity 2)
|
||||
* `npm run dev` → `npm run dev` (because `npm run` has arity 3)
|
||||
* `python script.py` → `python script.py` (default: whole input, not in dictionary)### **Now generate the dictionary.**
|
||||
*/
|
||||
const ARITY: Record<string, number> = {
|
||||
cat: 1, // cat file.txt
|
||||
cd: 1, // cd /path/to/dir
|
||||
chmod: 1, // chmod 755 script.sh
|
||||
chown: 1, // chown user:group file.txt
|
||||
cp: 1, // cp source.txt dest.txt
|
||||
echo: 1, // echo "hello world"
|
||||
env: 1, // env
|
||||
export: 1, // export PATH=/usr/bin
|
||||
grep: 1, // grep pattern file.txt
|
||||
kill: 1, // kill 1234
|
||||
killall: 1, // killall process
|
||||
ln: 1, // ln -s source target
|
||||
ls: 1, // ls -la
|
||||
mkdir: 1, // mkdir new-dir
|
||||
mv: 1, // mv old.txt new.txt
|
||||
ps: 1, // ps aux
|
||||
pwd: 1, // pwd
|
||||
rm: 1, // rm file.txt
|
||||
rmdir: 1, // rmdir empty-dir
|
||||
sleep: 1, // sleep 5
|
||||
source: 1, // source ~/.bashrc
|
||||
tail: 1, // tail -f log.txt
|
||||
touch: 1, // touch file.txt
|
||||
unset: 1, // unset VAR
|
||||
which: 1, // which node
|
||||
aws: 3, // aws s3 ls
|
||||
az: 3, // az storage blob list
|
||||
bazel: 2, // bazel build
|
||||
brew: 2, // brew install node
|
||||
bun: 2, // bun install
|
||||
"bun run": 3, // bun run dev
|
||||
"bun x": 3, // bun x vite
|
||||
cargo: 2, // cargo build
|
||||
"cargo add": 3, // cargo add tokio
|
||||
"cargo run": 3, // cargo run main
|
||||
cdk: 2, // cdk deploy
|
||||
cf: 2, // cf push app
|
||||
cmake: 2, // cmake build
|
||||
composer: 2, // composer require laravel
|
||||
consul: 2, // consul members
|
||||
"consul kv": 3, // consul kv get config/app
|
||||
crictl: 2, // crictl ps
|
||||
deno: 2, // deno run server.ts
|
||||
"deno task": 3, // deno task dev
|
||||
doctl: 3, // doctl kubernetes cluster list
|
||||
docker: 2, // docker run nginx
|
||||
"docker builder": 3, // docker builder prune
|
||||
"docker compose": 3, // docker compose up
|
||||
"docker container": 3, // docker container ls
|
||||
"docker image": 3, // docker image prune
|
||||
"docker network": 3, // docker network inspect
|
||||
"docker volume": 3, // docker volume ls
|
||||
eksctl: 2, // eksctl get clusters
|
||||
"eksctl create": 3, // eksctl create cluster
|
||||
firebase: 2, // firebase deploy
|
||||
flyctl: 2, // flyctl deploy
|
||||
gcloud: 3, // gcloud compute instances list
|
||||
gh: 3, // gh pr list
|
||||
git: 2, // git checkout main
|
||||
"git config": 3, // git config user.name
|
||||
"git remote": 3, // git remote add origin
|
||||
"git stash": 3, // git stash pop
|
||||
go: 2, // go build
|
||||
gradle: 2, // gradle build
|
||||
helm: 2, // helm install mychart
|
||||
heroku: 2, // heroku logs
|
||||
hugo: 2, // hugo new site blog
|
||||
ip: 2, // ip link show
|
||||
"ip addr": 3, // ip addr show
|
||||
"ip link": 3, // ip link set eth0 up
|
||||
"ip netns": 3, // ip netns exec foo bash
|
||||
"ip route": 3, // ip route add default via 1.1.1.1
|
||||
kind: 2, // kind delete cluster
|
||||
"kind create": 3, // kind create cluster
|
||||
kubectl: 2, // kubectl get pods
|
||||
"kubectl kustomize": 3, // kubectl kustomize overlays/dev
|
||||
"kubectl rollout": 3, // kubectl rollout restart deploy/api
|
||||
kustomize: 2, // kustomize build .
|
||||
make: 2, // make build
|
||||
mc: 2, // mc ls myminio
|
||||
"mc admin": 3, // mc admin info myminio
|
||||
minikube: 2, // minikube start
|
||||
mongosh: 2, // mongosh test
|
||||
mysql: 2, // mysql -u root
|
||||
mvn: 2, // mvn compile
|
||||
ng: 2, // ng generate component home
|
||||
npm: 2, // npm install
|
||||
"npm exec": 3, // npm exec vite
|
||||
"npm init": 3, // npm init vue
|
||||
"npm run": 3, // npm run dev
|
||||
"npm view": 3, // npm view react version
|
||||
nvm: 2, // nvm use 18
|
||||
nx: 2, // nx build
|
||||
openssl: 2, // openssl genrsa 2048
|
||||
"openssl req": 3, // openssl req -new -key key.pem
|
||||
"openssl x509": 3, // openssl x509 -in cert.pem
|
||||
pip: 2, // pip install numpy
|
||||
pipenv: 2, // pipenv install flask
|
||||
pnpm: 2, // pnpm install
|
||||
"pnpm dlx": 3, // pnpm dlx create-next-app
|
||||
"pnpm exec": 3, // pnpm exec vite
|
||||
"pnpm run": 3, // pnpm run dev
|
||||
poetry: 2, // poetry add requests
|
||||
podman: 2, // podman run alpine
|
||||
"podman container": 3, // podman container ls
|
||||
"podman image": 3, // podman image prune
|
||||
psql: 2, // psql -d mydb
|
||||
pulumi: 2, // pulumi up
|
||||
"pulumi stack": 3, // pulumi stack output
|
||||
pyenv: 2, // pyenv install 3.11
|
||||
python: 2, // python -m venv env
|
||||
rake: 2, // rake db:migrate
|
||||
rbenv: 2, // rbenv install 3.2.0
|
||||
"redis-cli": 2, // redis-cli ping
|
||||
rustup: 2, // rustup update
|
||||
serverless: 2, // serverless invoke
|
||||
sfdx: 3, // sfdx force:org:list
|
||||
skaffold: 2, // skaffold dev
|
||||
sls: 2, // sls deploy
|
||||
sst: 2, // sst deploy
|
||||
swift: 2, // swift build
|
||||
systemctl: 2, // systemctl restart nginx
|
||||
terraform: 2, // terraform apply
|
||||
"terraform workspace": 3, // terraform workspace select prod
|
||||
tmux: 2, // tmux new -s dev
|
||||
turbo: 2, // turbo run build
|
||||
ufw: 2, // ufw allow 22
|
||||
vault: 2, // vault login
|
||||
"vault auth": 3, // vault auth list
|
||||
"vault kv": 3, // vault kv get secret/api
|
||||
vercel: 2, // vercel deploy
|
||||
volta: 2, // volta install node
|
||||
wp: 2, // wp plugin install
|
||||
yarn: 2, // yarn add react
|
||||
"yarn dlx": 3, // yarn dlx create-react-app
|
||||
"yarn run": 3, // yarn run dev
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export namespace Permission {
|
||||
sessionID: z.string(),
|
||||
messageID: z.string(),
|
||||
callID: z.string().optional(),
|
||||
title: z.string(),
|
||||
message: z.string(),
|
||||
metadata: z.record(z.string(), z.any()),
|
||||
time: z.object({
|
||||
created: z.number(),
|
||||
@@ -99,7 +99,7 @@ export namespace Permission {
|
||||
|
||||
export async function ask(input: {
|
||||
type: Info["type"]
|
||||
title: Info["title"]
|
||||
message: Info["message"]
|
||||
pattern?: Info["pattern"]
|
||||
callID?: Info["callID"]
|
||||
sessionID: Info["sessionID"]
|
||||
@@ -123,7 +123,7 @@ export namespace Permission {
|
||||
sessionID: input.sessionID,
|
||||
messageID: input.messageID,
|
||||
callID: input.callID,
|
||||
title: input.title,
|
||||
message: input.message,
|
||||
metadata: input.metadata,
|
||||
time: {
|
||||
created: Date.now(),
|
||||
|
||||
253
packages/opencode/src/permission/next.ts
Normal file
253
packages/opencode/src/permission/next.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Config } from "@/config/config"
|
||||
import { Identifier } from "@/id/id"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { fn } from "@/util/fn"
|
||||
import { Log } from "@/util/log"
|
||||
import { Wildcard } from "@/util/wildcard"
|
||||
import z from "zod"
|
||||
|
||||
export namespace PermissionNext {
|
||||
const log = Log.create({ service: "permission" })
|
||||
|
||||
export const Action = z.enum(["allow", "deny", "ask"]).meta({
|
||||
ref: "PermissionAction",
|
||||
})
|
||||
export type Action = z.infer<typeof Action>
|
||||
|
||||
export const Rule = z
|
||||
.object({
|
||||
permission: z.string(),
|
||||
pattern: z.string(),
|
||||
action: Action,
|
||||
})
|
||||
.meta({
|
||||
ref: "PermissionRule",
|
||||
})
|
||||
export type Rule = z.infer<typeof Rule>
|
||||
|
||||
export const Ruleset = Rule.array().meta({
|
||||
ref: "PermissionRuleset",
|
||||
})
|
||||
export type Ruleset = z.infer<typeof Ruleset>
|
||||
|
||||
export function fromConfig(permission: Config.Permission) {
|
||||
const ruleset: Ruleset = []
|
||||
for (const [key, value] of Object.entries(permission)) {
|
||||
if (typeof value === "string") {
|
||||
ruleset.push({
|
||||
permission: key,
|
||||
action: value,
|
||||
pattern: "*",
|
||||
})
|
||||
continue
|
||||
}
|
||||
ruleset.push(...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern, action })))
|
||||
}
|
||||
return ruleset
|
||||
}
|
||||
|
||||
export function merge(...rulesets: Ruleset[]): Ruleset {
|
||||
return rulesets.flat()
|
||||
}
|
||||
|
||||
export const Request = z
|
||||
.object({
|
||||
id: Identifier.schema("permission"),
|
||||
sessionID: Identifier.schema("session"),
|
||||
permission: z.string(),
|
||||
patterns: z.string().array(),
|
||||
metadata: z.record(z.string(), z.any()),
|
||||
always: z.string().array(),
|
||||
tool: z
|
||||
.object({
|
||||
messageID: z.string(),
|
||||
callID: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.meta({
|
||||
ref: "PermissionRequest",
|
||||
})
|
||||
|
||||
export type Request = z.infer<typeof Request>
|
||||
|
||||
export const Reply = z.enum(["once", "always", "reject"])
|
||||
export type Reply = z.infer<typeof Reply>
|
||||
|
||||
export const Approval = z.object({
|
||||
projectID: z.string(),
|
||||
patterns: z.string().array(),
|
||||
})
|
||||
|
||||
export const Event = {
|
||||
Asked: BusEvent.define("permission.asked", Request),
|
||||
Replied: BusEvent.define(
|
||||
"permission.replied",
|
||||
z.object({
|
||||
sessionID: z.string(),
|
||||
requestID: z.string(),
|
||||
reply: Reply,
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
const state = Instance.state(async () => {
|
||||
const projectID = Instance.project.id
|
||||
const stored = await Storage.read<Ruleset>(["permission", projectID]).catch(() => [] as Ruleset)
|
||||
|
||||
const pending: Record<
|
||||
string,
|
||||
{
|
||||
info: Request
|
||||
resolve: () => void
|
||||
reject: (e: any) => void
|
||||
}
|
||||
> = {}
|
||||
|
||||
return {
|
||||
pending,
|
||||
approved: stored,
|
||||
}
|
||||
})
|
||||
|
||||
export const ask = fn(
|
||||
Request.partial({ id: true }).extend({
|
||||
ruleset: Ruleset,
|
||||
}),
|
||||
async (input) => {
|
||||
const s = await state()
|
||||
const { ruleset, ...request } = input
|
||||
for (const pattern of request.patterns ?? []) {
|
||||
const action = evaluate(request.permission, pattern, ruleset, s.approved)
|
||||
log.info("evaluated", { permission: request.permission, pattern, action })
|
||||
if (action === "deny") throw new RejectedError()
|
||||
if (action === "ask") {
|
||||
const id = input.id ?? Identifier.ascending("permission")
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const info: Request = {
|
||||
id,
|
||||
...request,
|
||||
}
|
||||
s.pending[id] = {
|
||||
info,
|
||||
resolve,
|
||||
reject,
|
||||
}
|
||||
Bus.publish(Event.Asked, info)
|
||||
})
|
||||
}
|
||||
if (action === "allow") continue
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export const reply = fn(
|
||||
z.object({
|
||||
requestID: Identifier.schema("permission"),
|
||||
reply: Reply,
|
||||
}),
|
||||
async (input) => {
|
||||
const s = await state()
|
||||
const existing = s.pending[input.requestID]
|
||||
if (!existing) return
|
||||
delete s.pending[input.requestID]
|
||||
Bus.publish(Event.Replied, {
|
||||
sessionID: existing.info.sessionID,
|
||||
requestID: existing.info.id,
|
||||
reply: input.reply,
|
||||
})
|
||||
if (input.reply === "reject") {
|
||||
existing.reject(new RejectedError())
|
||||
// Reject all other pending permissions for this session
|
||||
const sessionID = existing.info.sessionID
|
||||
for (const [id, pending] of Object.entries(s.pending)) {
|
||||
if (pending.info.sessionID === sessionID) {
|
||||
delete s.pending[id]
|
||||
Bus.publish(Event.Replied, {
|
||||
sessionID: pending.info.sessionID,
|
||||
requestID: pending.info.id,
|
||||
reply: "reject",
|
||||
})
|
||||
pending.reject(new RejectedError())
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (input.reply === "once") {
|
||||
existing.resolve()
|
||||
return
|
||||
}
|
||||
if (input.reply === "always") {
|
||||
for (const pattern of existing.info.always) {
|
||||
s.approved.push({
|
||||
permission: existing.info.permission,
|
||||
pattern,
|
||||
action: "allow",
|
||||
})
|
||||
}
|
||||
|
||||
existing.resolve()
|
||||
|
||||
const sessionID = existing.info.sessionID
|
||||
for (const [id, pending] of Object.entries(s.pending)) {
|
||||
if (pending.info.sessionID !== sessionID) continue
|
||||
const ok = pending.info.patterns.every(
|
||||
(pattern) => evaluate(pending.info.permission, pattern, s.approved) === "allow",
|
||||
)
|
||||
if (!ok) continue
|
||||
delete s.pending[id]
|
||||
Bus.publish(Event.Replied, {
|
||||
sessionID: pending.info.sessionID,
|
||||
requestID: pending.info.id,
|
||||
reply: "always",
|
||||
})
|
||||
pending.resolve()
|
||||
}
|
||||
|
||||
// TODO: we don't save the permission ruleset to disk yet until there's
|
||||
// UI to manage it
|
||||
// await Storage.write(["permission", Instance.project.id], s.approved)
|
||||
return
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Action {
|
||||
const merged = merge(...rulesets)
|
||||
log.info("evaluate", { permission, pattern, ruleset: merged })
|
||||
const match = merged.findLast(
|
||||
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
|
||||
)
|
||||
return match?.action ?? "ask"
|
||||
}
|
||||
|
||||
const EDIT_TOOLS = ["edit", "write", "patch", "multiedit"]
|
||||
|
||||
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
|
||||
const result = new Set<string>()
|
||||
for (const tool of tools) {
|
||||
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
|
||||
if (evaluate(permission, "*", ruleset) === "deny") {
|
||||
result.add(tool)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export class RejectedError extends Error {
|
||||
constructor(public readonly reason?: string) {
|
||||
super(
|
||||
reason !== undefined
|
||||
? reason
|
||||
: `The user rejected permission to use this specific tool call. You may try again with different parameters.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function list() {
|
||||
return state().then((x) => Object.values(x.pending).map((x) => x.info))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user