Skip to content

Add skills#29

Merged
ScriptSmith merged 8 commits intomainfrom
skills
Apr 22, 2026
Merged

Add skills#29
ScriptSmith merged 8 commits intomainfrom
skills

Conversation

@ScriptSmith
Copy link
Copy Markdown
Owner

No description provided.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 22, 2026

Greptile Summary

This PR introduces the full skills feature: DB schema, Rust service/route layer, and a rich React UI covering creation, GitHub/filesystem import, slash-command typeahead, and model invocation via a single Skill tool. Several issues flagged in earlier review rounds (UTF-8 byte counting, disable_model_invocation enforcement, delete confirmation, cache staleness eviction) have been addressed in this revision.

  • P1 — skillsByName name collision: setSkillCatalog keys skills by name; if a user skill and an org skill share the same name (different IDs, different owners), the second overwrites the first and the shadowed skill becomes unreachable via the Skill tool executor for the session.
  • Unresolved from prior rounds: TOCTOU race on per-owner count limit (src/routes/admin/skills.rs), missing ownership context on get/update/delete authz calls, and UTF-16/UTF-8 mismatch in SkillFormModal size display.

Confidence Score: 4/5

Mostly safe to merge; one new P1 (name collision in skill cache) should be resolved before shipping to users with multi-owner skill catalogs.

Many prior P1 concerns were fixed in this revision. The remaining new P1 (skillsByName collision) is a correctness defect that silently drops a skill from the executor in a multi-owner setup. Pre-existing open items (TOCTOU, authz context, SkillFormModal UTF-8) were carried over from earlier threads.

ui/src/pages/chat/utils/skillCache.ts (name collision), src/routes/admin/skills.rs (TOCTOU + authz)

Important Files Changed

Filename Overview
src/models/skill.rs Well-structured model with thorough name/path validation and unit tests; server-side invariants are solid.
src/services/skills.rs Service layer correctly enforces SKILL.md presence, no duplicate paths, and configurable byte-size limit before delegating to the repo.
src/routes/admin/skills.rs TOCTOU race in skill-count limit and missing ownership context on get/update/delete authz checks remain (flagged in prior review threads).
ui/src/pages/chat/utils/skillCache.ts Cache eviction on updated_at is now handled; however, skillsByName keyed by name will shadow one skill if two different owners have skills with identical names.
ui/src/pages/chat/utils/skillExecutor.ts disable_model_invocation is now enforced at runtime in the executor; progressive file-disclosure pattern is correctly implemented.
ui/src/components/SkillImportModal/parseFrontmatter.ts Lightweight YAML parser covers the agentskills.io subset well; parseInlineList comma-splitting breaks tool patterns like Bash(git:, gh:) in inline lists.
ui/src/components/SkillImportModal/filesystemImport.ts UTF-8 byte counting now uses TextEncoder, binary file rejection via null-byte check is correct, nested skill deduplication logic is sound.
ui/src/components/SkillImportModal/githubImport.ts Tree-based walk is efficient; CDN raw fetches avoid REST API rate limits; UTF-8 byte counting correctly uses TextEncoder.
ui/src/hooks/useUserSkills.ts Correct deduplication by ID across user + org sources; 50-skill hard cap per source with UI notice when hasMore is true (partial fix from prior thread).
ui/src/components/SkillsButton/SkillsButton.tsx Delete now uses useConfirm dialog before mutation; toggle-all respects filtered search scope; hasMore notice is shown to users.
ui/src/pages/chat/useChat.ts Skill tool filtering now correctly gates on disable_model_invocation only; user_invocable is treated as a UI-only flag.

Sequence Diagram

sequenceDiagram
    participant U as User
    participant CI as ChatInput
    participant UC as useChat
    participant SE as skillExecutor
    participant SC as skillCache
    participant API as /api/v1/skills/:id

    U->>CI: Types /skill-name (slash command)
    CI->>CI: detectSlashQuery → matchSkills
    CI-->>U: SlashCommandPopover (user_invocable skills)
    U->>CI: Selects skill → inserts /skill-name into message
    U->>CI: Sends message

    UC->>UC: Build tools array (filter disable_model_invocation)
    UC->>UC: buildSkillToolDescription(invocableSkills)
    UC->>API: POST /api/chat with Skill tool in tools[]

    Note over API: Model decides to call Skill({command})
    API-->>UC: SSE: tool_call Skill({command:"skill-name"})
    UC->>SE: skillExecutor({command:"skill-name"})
    SE->>SC: getSkillByName("skill-name")
    SC-->>SE: Skill summary (or undefined)
    SE->>SE: Check disable_model_invocation
    SE->>SC: getFullSkill(id) → cache hit?
    alt Cache miss
        SE->>API: GET /api/v1/skills/:id
        API-->>SE: Full Skill with file contents
        SE->>SC: setFullSkill(skill)
    end
    SE-->>UC: ToolExecutionResult (SKILL.md + manifest)
    UC-->>U: Render artifact (SKILL.md content)
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: ui/src/pages/chat/utils/skillCache.ts
Line: 31-48

Comment:
**Name collision silently shadows one skill**

`skillsByName` is keyed by `skill.name`, but the server only enforces name uniqueness per owner — a user skill and an org skill can share the same name with different IDs. When `setSkillCatalog` iterates the merged list (user skills first, then org skills from `useUserSkills`), the second skill with the same name overwrites the first. The `Skill` tool executor calls `getSkillByName(command)` and can only ever reach the latter entry; the earlier skill becomes permanently unreachable for that session, regardless of which one the model tries to invoke.

The tool description would also list the name twice (once per enabled skill), giving the model conflicting entries in the `command` enum.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: ui/src/components/SkillImportModal/parseFrontmatter.ts
Line: 54-62

Comment:
**Inline list parser breaks on tool patterns with commas**

`parseInlineList` splits on every bare comma, so an `allowed_tools` value like `[Bash(git:*, gh:*), Read]` is split into `["Bash(git:*", " gh:*)", "Read"]` — the parenthesized entry is mangled into two broken fragments. Block-list syntax (`- Bash(git:*, gh:*)`) parses correctly; only inline `[…]` lists are affected.

Consider a parenthesis-aware split for this field, or document that inline lists must not contain tool patterns with embedded commas.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (7): Last reviewed commit: "Review fixes" | Re-trigger Greptile

Comment on lines +1268 to +1273
CREATE INDEX IF NOT EXISTS idx_skill_files_skill ON skill_files(skill_id);

DO $$ BEGIN
CREATE TRIGGER update_skill_files_updated_at BEFORE UPDATE ON skill_files FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
EXCEPTION WHEN duplicate_object THEN null;
END $$;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing compound index for cursor-based pagination

The pagination queries in both the SQLite and PostgreSQL implementations sort and filter on (owner_type, owner_id, created_at, id), but the migration only creates idx_skills_owner ON skills(owner_type, owner_id). Without created_at and id in the index, every paginated request must sort the filtered result set in memory. For skill counts beyond a few hundred per owner, this will become a full-table sort per page request.

Consider adding a composite index:

CREATE INDEX IF NOT EXISTS idx_skills_owner_created ON skills(owner_type, owner_id, created_at DESC, id DESC) WHERE deleted_at IS NULL;

The same gap exists in the SQLite migration.

Prompt To Fix With AI
This is a comment left during a code review.
Path: migrations_sqlx/postgres/20250101000000_initial.sql
Line: 1268-1273

Comment:
**Missing compound index for cursor-based pagination**

The pagination queries in both the SQLite and PostgreSQL implementations sort and filter on `(owner_type, owner_id, created_at, id)`, but the migration only creates `idx_skills_owner ON skills(owner_type, owner_id)`. Without `created_at` and `id` in the index, every paginated request must sort the filtered result set in memory. For skill counts beyond a few hundred per owner, this will become a full-table sort per page request.

Consider adding a composite index:
```sql
CREATE INDEX IF NOT EXISTS idx_skills_owner_created ON skills(owner_type, owner_id, created_at DESC, id DESC) WHERE deleted_at IS NULL;
```
The same gap exists in the SQLite migration.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread ui/src/components/SkillImportModal/filesystemImport.ts Outdated
Comment thread ui/src/components/SkillImportModal/githubImport.ts Outdated
Comment on lines +71 to +82
let max = state.config.limits.resource_limits.max_skills_per_owner;
if max > 0 {
let count = services
.skills
.count_by_owner(input.owner.owner_type(), input.owner.owner_id(), false)
.await?;
if count >= max as i64 {
return Err(AdminError::Conflict(format!(
"Owner has reached the maximum number of skills ({max})"
)));
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 TOCTOU race in per-owner skill count limit

The count check and the subsequent create are two separate DB operations with no transaction or lock between them. Concurrent requests for the same owner can both pass the count >= max check simultaneously and both proceed to create, exceeding the configured max_skills_per_owner limit by up to the number of concurrent requests. If this limit is strictly enforced (e.g., billing/quota), this is a real bypass under load.

Mitigations: enforce the constraint at the database layer (a partial unique index or a CHECK via trigger), or use a serialized lock per-owner.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/routes/admin/skills.rs
Line: 71-82

Comment:
**TOCTOU race in per-owner skill count limit**

The count check and the subsequent `create` are two separate DB operations with no transaction or lock between them. Concurrent requests for the same owner can both pass the `count >= max` check simultaneously and both proceed to create, exceeding the configured `max_skills_per_owner` limit by up to the number of concurrent requests. If this limit is strictly enforced (e.g., billing/quota), this is a real bypass under load.

Mitigations: enforce the constraint at the database layer (a partial unique index or a `CHECK` via trigger), or use a serialized lock per-owner.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread ui/src/pages/chat/utils/skillCache.ts
Comment on lines +124 to +141
#[tracing::instrument(name = "admin.skills.get", skip(state, authz), fields(%id))]
pub async fn get(
State(state): State<AppState>,
Extension(authz): Extension<AuthzContext>,
Path(id): Path<Uuid>,
) -> Result<Json<Skill>, AdminError> {
let services = get_services(&state)?;

authz.require("skill", "read", None, None, None, None)?;

let skill = services
.skills
.get_by_id(id)
.await?
.ok_or_else(|| AdminError::NotFound("Skill not found".to_string()))?;

Ok(Json(skill))
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 get, update, and delete don't pass resource context to authz

The list endpoints (list_by_org, list_by_team, list_by_project) each pass the org/team/project ID to authz.require(...) so the auth layer can enforce org-scoped access. The single-resource endpoints—get, update, and delete—all call authz.require("skill", ..., None, None, None, None), meaning the authz check carries no ownership context. If the authz system relies on those context params to scope admin access to a specific org, any admin with any skill permission can read or mutate skills owned by other orgs.

Is the intention for these admin endpoints to be globally scoped, or should they verify the skill's org ownership (similar to how get_by_id_and_org is available in the service layer)? Are the get, update, and delete skill admin endpoints intentionally globally-scoped (any admin can touch any skill), or should they be org-scoped like the list endpoints?

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/routes/admin/skills.rs
Line: 124-141

Comment:
**`get`, `update`, and `delete` don't pass resource context to authz**

The list endpoints (`list_by_org`, `list_by_team`, `list_by_project`) each pass the org/team/project ID to `authz.require(...)` so the auth layer can enforce org-scoped access. The single-resource endpoints—`get`, `update`, and `delete`—all call `authz.require("skill", ..., None, None, None, None)`, meaning the authz check carries no ownership context. If the authz system relies on those context params to scope admin access to a specific org, any admin with any skill permission can read or mutate skills owned by other orgs.

Is the intention for these admin endpoints to be globally scoped, or should they verify the skill's org ownership (similar to how `get_by_id_and_org` is available in the service layer)? Are the `get`, `update`, and `delete` skill admin endpoints intentionally globally-scoped (any admin can touch any skill), or should they be org-scoped like the list endpoints?

How can I resolve this? If you propose a fix, please make it concise.

@ScriptSmith
Copy link
Copy Markdown
Owner Author

@greptile-apps

Comment on lines +43 to +50
...skillListByUserOptions({
path: { user_id: user?.id ?? "" },
query: { limit: 50 },
}),
staleTime: 5 * 60 * 1000,
enabled: !!user?.id,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 50-skill hard cap silently truncates catalog

Each source (user skills, per-org skills) fetches at most 50 items. hasMore is computed and exported, but nothing ever calls a second page — so any user or org with more than 50 skills will silently see a truncated list in the Skills picker and the model's Skill tool description. The truncation is invisible to the end-user.

The simplest fix for v1 is to either raise the limit to a safer ceiling or display a notice in the SkillsButton when hasMore is true. A proper infinite-fetch approach would use useInfiniteQuery here.

Prompt To Fix With AI
This is a comment left during a code review.
Path: ui/src/hooks/useUserSkills.ts
Line: 43-50

Comment:
**50-skill hard cap silently truncates catalog**

Each source (user skills, per-org skills) fetches at most 50 items. `hasMore` is computed and exported, but nothing ever calls a second page — so any user or org with more than 50 skills will silently see a truncated list in the Skills picker and the model's `Skill` tool description. The truncation is invisible to the end-user.

The simplest fix for v1 is to either raise the limit to a safer ceiling or display a notice in the `SkillsButton` when `hasMore` is `true`. A proper infinite-fetch approach would use `useInfiniteQuery` here.

How can I resolve this? If you propose a fix, please make it concise.

@ScriptSmith
Copy link
Copy Markdown
Owner Author

@greptile-apps

Comment thread ui/src/pages/chat/useChat.ts
Comment thread ui/src/pages/chat/utils/skillExecutor.ts
@ScriptSmith
Copy link
Copy Markdown
Owner Author

@greptile-apps

Comment thread ui/src/components/SkillsButton/SkillsButton.tsx
@ScriptSmith
Copy link
Copy Markdown
Owner Author

@greptile-apps

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 22, 2026

Want your agent to iterate on Greptile's feedback? Try greploops.


const isLoading = createMutation.isPending || updateMutation.isPending || isLoadingFiles;
const error = createMutation.error || updateMutation.error;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 bodySize and bundled file sizes use UTF-16 code units, not UTF-8 bytes

form.watch("body").length returns UTF-16 code units; formatBytes(bodySize) on line 376 then displays this as a byte count. The server measures the same content with Rust's String::len() (UTF-8 bytes). For any SKILL.md containing non-ASCII text (multi-byte characters, emoji, CJK), the displayed size will be smaller than what the server will actually count, and the user may submit a skill that the server silently rejects with a 400.

The same applies on line 308 (f.content.length used as byte_size for bundled file rows). Both filesystemImport.ts and githubImport.ts already use new TextEncoder().encode(content).length for this reason — SkillFormModal needs the same fix:

const utf8Encoder = new TextEncoder();
const bodySize = useMemo(
  () => utf8Encoder.encode(form.watch("body")).length,
  [form]
);

And for bundled rows:

bundledFiles.map((f) => ({
  path: f.path,
  byte_size: utf8Encoder.encode(f.content).length,
}))
Prompt To Fix With AI
This is a comment left during a code review.
Path: ui/src/components/Admin/SkillFormModal/SkillFormModal.tsx
Line: 248

Comment:
**`bodySize` and bundled file sizes use UTF-16 code units, not UTF-8 bytes**

`form.watch("body").length` returns UTF-16 code units; `formatBytes(bodySize)` on line 376 then displays this as a byte count. The server measures the same content with Rust's `String::len()` (UTF-8 bytes). For any SKILL.md containing non-ASCII text (multi-byte characters, emoji, CJK), the displayed size will be smaller than what the server will actually count, and the user may submit a skill that the server silently rejects with a 400.

The same applies on line 308 (`f.content.length` used as `byte_size` for bundled file rows). Both `filesystemImport.ts` and `githubImport.ts` already use `new TextEncoder().encode(content).length` for this reason — `SkillFormModal` needs the same fix:

```typescript
const utf8Encoder = new TextEncoder();
const bodySize = useMemo(
  () => utf8Encoder.encode(form.watch("body")).length,
  [form]
);
```

And for bundled rows:
```typescript
bundledFiles.map((f) => ({
  path: f.path,
  byte_size: utf8Encoder.encode(f.content).length,
}))
```

How can I resolve this? If you propose a fix, please make it concise.

@ScriptSmith ScriptSmith merged commit 77f135d into main Apr 22, 2026
18 of 20 checks passed
Comment on lines +31 to +48
export function setSkillCatalog(skills: Skill[]): void {
const seen = new Set<string>();
skillsByName.clear();
for (const s of skills) {
skillsByName.set(s.name, s);
seen.add(s.id);

const cached = fullSkillsById.get(s.id);
if (cached && cached.updated_at !== s.updated_at) {
fullSkillsById.delete(s.id);
}
}

// Drop any cached full skills that have disappeared from the catalog.
for (const id of fullSkillsById.keys()) {
if (!seen.has(id)) fullSkillsById.delete(id);
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Name collision silently shadows one skill

skillsByName is keyed by skill.name, but the server only enforces name uniqueness per owner — a user skill and an org skill can share the same name with different IDs. When setSkillCatalog iterates the merged list (user skills first, then org skills from useUserSkills), the second skill with the same name overwrites the first. The Skill tool executor calls getSkillByName(command) and can only ever reach the latter entry; the earlier skill becomes permanently unreachable for that session, regardless of which one the model tries to invoke.

The tool description would also list the name twice (once per enabled skill), giving the model conflicting entries in the command enum.

Prompt To Fix With AI
This is a comment left during a code review.
Path: ui/src/pages/chat/utils/skillCache.ts
Line: 31-48

Comment:
**Name collision silently shadows one skill**

`skillsByName` is keyed by `skill.name`, but the server only enforces name uniqueness per owner — a user skill and an org skill can share the same name with different IDs. When `setSkillCatalog` iterates the merged list (user skills first, then org skills from `useUserSkills`), the second skill with the same name overwrites the first. The `Skill` tool executor calls `getSkillByName(command)` and can only ever reach the latter entry; the earlier skill becomes permanently unreachable for that session, regardless of which one the model tries to invoke.

The tool description would also list the name twice (once per enabled skill), giving the model conflicting entries in the `command` enum.

How can I resolve this? If you propose a fix, please make it concise.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant