Skill: w7tf/20-crm (version 0.0.2)

--- name: twenty-crm description: Interact with Twenty CRM (self-hosted) via REST/GraphQL. metadata: {"clawdbot":{"emoji":"🗂️","os":["darwin","linux"],"requirements":{"env":["TWENTY_BASE_URL","TWENTY_API_KEY"],"binaries":["curl","python3"]}}} --- # Twenty CRM Interact with your self-hosted Twenty instance via REST and GraphQL. ## Config Set these env vars directly, or place them in `config/twenty.env`: - `TWENTY_BASE_URL` (e.g. `https://crm.example.com` or `http://localhost:3000`) - `TWENTY_API_KEY` (Bearer token) Scripts auto-load `config/twenty.env` relative to this skill. You can override the path with `TWENTY_CONFIG_FILE`. ## Runtime Requirements - `curl` - `python3` ## Commands ### Low-level helpers - REST GET: `skills/twenty-crm/scripts/twenty-rest-get.sh "/companies" 'filter={"name":{"ilike":"%acme%"}}' "limit=10" "offset=0"` - REST POST: `skills/twenty-crm/scripts/twenty-rest-post.sh "/companies" '{"name":"Acme"}'` - REST PATCH: `skills/twenty-crm/scripts/twenty-rest-patch.sh "/companies/<id>" '{"employees":550}'` - REST DELETE: `skills/twenty-crm/scripts/twenty-rest-delete.sh "/companies/<id>"` - GraphQL: `skills/twenty-crm/scripts/twenty-graphql.sh 'query { companies(limit: 5) { totalCount } }'` ### Common objects (examples) - Create company: `skills/twenty-crm/scripts/twenty-create-company.sh "Acme" "acme.com" 500` - Find companies by name: `skills/twenty-crm/scripts/twenty-find-companies.sh "acme" 10` ## Notes - Twenty supports both REST (`/rest/...`) and GraphQL (`/graphql`). - Object names/endpoints can differ depending on your workspace metadata and Twenty version. - Auth tokens can be short-lived depending on your setup; refresh if you get `401`. ## Security - Keep `TWENTY_API_KEY` out of git and avoid storing it in shared/world-readable files. - If you use `config/twenty.env`, prefer restrictive permissions (for example `chmod 600 config/twenty.env`). - Pass REST query parameters as separate `key=value` arguments; do not append raw query strings to REST paths.

path: _meta.json
content: {
  "owner": "jhumanj",
  "slug": "twenty-crm",
  "displayName": "Twenty CRM",
  "latest": {
    "version": "1.0.0",
    "publishedAt": 1768824153928,
    "commit": "https://github.com/clawdbot/skills/commit/63fd85e86c63c23f7d9390260b8b6f77d452b8ed"
  },
  "history": []
}


path: README.md
content: # twenty-crm (VirusTotal-hardened fork)

This repository is based on the original work:
https://github.com/openclaw/skills/tree/main/skills/jhumanj/twenty-crm

This version focuses on fixing the VirusTotal issues that caused the original skill to be classified as suspicious.

## VirusTotal issue statement

"The skill is classified as suspicious primarily due to a query parameter injection vulnerability in `scripts/twenty-find-companies.sh` and `scripts/twenty-rest-get.sh`. User-provided search terms are incorporated into a URL query string without proper URL encoding, potentially allowing an attacker to inject arbitrary query parameters into the API request. Additionally, `scripts/twenty-config.sh` uses a hardcoded absolute path (`/Users/jhumanj/clawd/config/twenty.env`) for loading configuration, which is a poor practice and indicates a lack of portability or an assumption about a specific execution environment."

## Security fixes included

- Query parameter injection in `scripts/twenty-find-companies.sh` and `scripts/twenty-rest-get.sh` is fixed by removing raw query string concatenation and URL-encoding query parameter values safely.
- The hardcoded absolute config path in `scripts/twenty-config.sh` is removed and replaced with portable config resolution via `TWENTY_CONFIG_FILE` or repo-relative `config/twenty.env`.
- `scripts/twenty-create-company.sh` no longer writes request payload data to a fixed `/tmp` file.
- REST helpers now validate REST path input and reject embedded query strings in the path.

## Runtime requirements

- `curl`
- `python3`

## Secret handling

- Do not commit `TWENTY_API_KEY`.
- If using `config/twenty.env`, keep it private (for example: `chmod 600 config/twenty.env`).

## Scope

This fork is intended as a minimal, compatibility-oriented security remediation of the original `twenty-crm` skill.


path: scripts/twenty-rest-patch.sh
content: #!/bin/bash
set -euo pipefail

# Usage: twenty-rest-patch.sh "/companies/<id>" '{"employees":123}'

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}" )" && pwd)"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/twenty-config.sh"

PATH_PART="${1:-}"
JSON_BODY="${2:-}"

if [ -z "$PATH_PART" ] || [ -z "$JSON_BODY" ]; then
  echo "Usage: twenty-rest-patch.sh \"/path\" '{...json...}'" >&2
  exit 1
fi
validate_rest_path "$PATH_PART"

curl -sS -X PATCH "$TWENTY_BASE_URL/rest${PATH_PART}" \
  -H "Authorization: Bearer $TWENTY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  --data "$JSON_BODY"


path: scripts/twenty-rest-delete.sh
content: #!/bin/bash
set -euo pipefail

# Usage: twenty-rest-delete.sh "/companies/<id>" [destroy]
# If second arg is "destroy", calls the /destroy endpoint.

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}" )" && pwd)"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/twenty-config.sh"

PATH_PART="${1:-}"
MODE="${2:-}"

if [ -z "$PATH_PART" ]; then
  echo "Usage: twenty-rest-delete.sh \"/path\" [destroy]" >&2
  exit 1
fi
validate_rest_path "$PATH_PART"

URL="$TWENTY_BASE_URL/rest${PATH_PART}"
if [ "$MODE" = "destroy" ]; then
  URL="$URL/destroy"
fi

curl -sS -X DELETE "$URL" \
  -H "Authorization: Bearer $TWENTY_API_KEY" \
  -H "Accept: application/json"


path: scripts/twenty-find-companies.sh
content: #!/bin/bash
set -euo pipefail

# Usage: twenty-find-companies.sh "search" [limit]
# Uses REST filter with ilike (may vary by Twenty version/workspace).

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}" )" && pwd)"

SEARCH="${1:-}"
LIMIT="${2:-10}"

if [ -z "$SEARCH" ]; then
  echo "Usage: twenty-find-companies.sh \"search\" [limit]" >&2
  exit 1
fi
if [[ ! "$LIMIT" =~ ^[0-9]+$ ]]; then
  echo "Invalid limit '$LIMIT'. Expected a non-negative integer." >&2
  exit 1
fi
if ! command -v python3 >/dev/null 2>&1; then
  echo "Missing required command: python3" >&2
  exit 1
fi

FILTER=$(python3 - <<'PY' "$SEARCH"
import json,sys
s=sys.argv[1]
print(json.dumps({"name":{"ilike":f"%{s}%"}}))
PY
)

"$SCRIPT_DIR/twenty-rest-get.sh" "/companies" "filter=$FILTER" "limit=$LIMIT" "offset=0"


path: scripts/twenty-rest-post.sh
content: #!/bin/bash
set -euo pipefail

# Usage: twenty-rest-post.sh "/companies" '{"name":"Acme"}'

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}" )" && pwd)"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/twenty-config.sh"

PATH_PART="${1:-}"
JSON_BODY="${2:-}"

if [ -z "$PATH_PART" ] || [ -z "$JSON_BODY" ]; then
  echo "Usage: twenty-rest-post.sh \"/path\" '{...json...}'" >&2
  exit 1
fi
validate_rest_path "$PATH_PART"

curl -sS -X POST "$TWENTY_BASE_URL/rest${PATH_PART}" \
  -H "Authorization: Bearer $TWENTY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  --data "$JSON_BODY"


path: scripts/twenty-create-company.sh
content: #!/bin/bash
set -euo pipefail

# Convenience wrapper using REST.
# Usage: twenty-create-company.sh "Name" [domain] [employees]

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}" )" && pwd)"

NAME="${1:-}"
DOMAIN="${2:-}"
EMPLOYEES="${3:-}"

if [ -z "$NAME" ]; then
  echo "Usage: twenty-create-company.sh \"Name\" [domain] [employees]" >&2
  exit 1
fi
# shellcheck disable=SC1091
source "$SCRIPT_DIR/twenty-config.sh"
require_cmd python3

JSON_BODY="$(python3 - <<'PY' "$NAME" "$DOMAIN" "$EMPLOYEES"
import json,sys
name,domain,employees = sys.argv[1],sys.argv[2],sys.argv[3]
payload = {"name": name}
if domain:
  payload["domainName"] = domain
if employees:
  try:
    payload["employees"] = int(employees)
  except ValueError:
    payload["employees"] = employees
print(json.dumps(payload))
PY
)"

"$SCRIPT_DIR/twenty-rest-post.sh" "/companies" "$JSON_BODY"


path: scripts/twenty-rest-get.sh
content: #!/bin/bash
set -euo pipefail

# Usage: twenty-rest-get.sh "/companies" [key=value ...]
# Example: twenty-rest-get.sh "/companies" "limit=10" "offset=0"

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/twenty-config.sh"

PATH_PART="${1:-}"

if [ -z "$PATH_PART" ]; then
  echo "Usage: twenty-rest-get.sh \"/path\" [key=value ...]" >&2
  exit 1
fi
validate_rest_path "$PATH_PART"

URL="$TWENTY_BASE_URL/rest${PATH_PART}"
CURL_ARGS=()
if [ "$#" -gt 1 ]; then
  for param in "${@:2}"; do
    if [[ "$param" != *=* ]]; then
      echo "Invalid query parameter '$param'. Expected key=value." >&2
      exit 1
    fi
    key="${param%%=*}"
    value="${param#*=}"
    if [[ -z "$key" || ! "$key" =~ ^[A-Za-z0-9._~-]+$ ]]; then
      echo "Invalid query parameter name '$key'." >&2
      exit 1
    fi
    CURL_ARGS+=(--data-urlencode "$key=$value")
  done
fi

curl -sS -G "$URL" "${CURL_ARGS[@]}" \
  -H "Authorization: Bearer $TWENTY_API_KEY" \
  -H "Accept: application/json"


path: scripts/twenty-config.sh
content: #!/bin/bash
set -euo pipefail

# Loads config for Twenty CRM API scripts.
# Config resolution order:
# 1) TWENTY_CONFIG_FILE env var
# 2) ../config/twenty.env relative to this script

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEFAULT_CONFIG_FILE="$(cd "$SCRIPT_DIR/.." && pwd)/config/twenty.env"
CONFIG_FILE="${TWENTY_CONFIG_FILE:-$DEFAULT_CONFIG_FILE}"

require_cmd() {
  local cmd="${1:-}"
  if [ -z "$cmd" ] || ! command -v "$cmd" >/dev/null 2>&1; then
    echo "Missing required command: $cmd" >&2
    exit 1
  fi
}

validate_rest_path() {
  local path_part="${1:-}"
  if [[ -z "$path_part" || "$path_part" != /* || "$path_part" == *"?"* || "$path_part" == *$'\n'* || "$path_part" == *$'\r'* ]]; then
    echo "Invalid REST path '$path_part'. Use an absolute path like /companies and pass query parameters separately." >&2
    exit 1
  fi
}

if [ -f "$CONFIG_FILE" ]; then
  # shellcheck disable=SC1090
  source "$CONFIG_FILE"
fi

if [ -z "${TWENTY_BASE_URL:-}" ]; then
  echo "Missing TWENTY_BASE_URL. Set it in the environment or $CONFIG_FILE." >&2
  exit 1
fi

if [ -z "${TWENTY_API_KEY:-}" ]; then
  echo "Missing TWENTY_API_KEY. Set it in the environment or $CONFIG_FILE." >&2
  exit 1
fi

require_cmd curl

# Normalize trailing slash
TWENTY_BASE_URL="${TWENTY_BASE_URL%/}"

export TWENTY_BASE_URL
export TWENTY_API_KEY


path: scripts/twenty-graphql.sh
content: #!/bin/bash
set -euo pipefail

# Usage: twenty-graphql.sh 'query { companies(limit: 5) { totalCount } }' [variablesJson]

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}" )" && pwd)"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/twenty-config.sh"
require_cmd python3

QUERY="${1:-}"
VARIABLES="${2:-}"

if [ -z "$QUERY" ]; then
  echo "Usage: twenty-graphql.sh 'query { ... }' [variablesJson]" >&2
  exit 1
fi

QUERY_JSON=$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$QUERY")

if [ -n "$VARIABLES" ]; then
  BODY=$(printf '{"query":%s,"variables":%s}' "$QUERY_JSON" "$VARIABLES")
else
  BODY=$(printf '{"query":%s}' "$QUERY_JSON")
fi

curl -sS -X POST "$TWENTY_BASE_URL/graphql" \
  -H "Authorization: Bearer $TWENTY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  --data "$BODY"


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)