Skip to content

Commit b6d3f3f

Browse files
committed
feat(skill): harden runtime execution
1 parent 72dc7e5 commit b6d3f3f

35 files changed

Lines changed: 2416 additions & 167 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Skill Runtime Hardening Plan
2+
3+
## Data Model
4+
- Add `SkillExtensionConfig` with `version`, `env`, `runtimePolicy`, and `scriptOverrides`.
5+
- Add `SkillScriptDescriptor` generated from `scripts/**/*.{py,js,mjs,cjs,sh}` and merged with sidecar overrides.
6+
- Store sidecars in `<skillsDir>/.deepchat-meta/<skillName>.json`.
7+
8+
## Runtime Flow
9+
- `SkillPresenter` owns sidecar read/write and script discovery.
10+
- `SkillExecutionService` validates active skill access, resolves scripts, merges env, selects runtime, and executes scripts.
11+
- `skill_run` becomes the preferred skill-local execution entrypoint.
12+
13+
## Read Guardrails
14+
- Add shared binary-read helpers for ACP and main agent reads.
15+
- ACP rejects non-text files through `fs/read_text_file`.
16+
- Main agent keeps image OCR fallback, but rejects unsupported binary reads with guidance.
17+
18+
## Process Output Reliability
19+
- Move foreground and background completion semantics from `exit` to `close`.
20+
- Await output flush before returning completed process results.
21+
- Offload large foreground output to session files when possible.
22+
23+
## UI
24+
- Extend the skill editor with runtime policy, env rows, and discovered scripts.
25+
- Show script/env/runtime summary badges on skill cards.
26+
27+
## Tests
28+
- Presenter: sidecar lifecycle, script discovery, overwrite/uninstall behavior.
29+
- Runtime: active skill enforcement, runtime fallback, script path validation.
30+
- Agent tooling: prompt injection, `skill_run`, binary read rejection.
31+
- ACP: `read_text_file` binary rejection.
32+
- Process handling: `close`-based flush behavior.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Skill Runtime Hardening
2+
3+
## Summary
4+
5+
Add runtime-aware skill extensions without changing external `SKILL.md` formats. DeepChat stores skill-only runtime settings in a sidecar directory and uses them to execute bundled scripts safely and predictably.
6+
7+
## User Stories
8+
9+
### US-1: Configure skill environment variables
10+
- As a user, I can define env vars for a skill in Settings.
11+
- Acceptance:
12+
- Env vars are stored outside the skill folder as plaintext sidecar metadata.
13+
- Editing a skill does not rewrite external `SKILL.md` frontmatter to persist env vars.
14+
15+
### US-2: Run skill scripts reliably
16+
- As an agent, I can run scripts bundled in an active skill without guessing relative paths.
17+
- Acceptance:
18+
- Only scripts under `<skillRoot>/scripts/` can be executed.
19+
- The runtime picks system `uv`/`node` first, then falls back to DeepChat bundled runtimes.
20+
- Python scripts run from the skill root and honor `pyproject.toml` via `uv run --project`.
21+
22+
### US-3: Prevent prompt pollution from binary file reads
23+
- As an agent, reading an image or binary file through text-file APIs does not inject raw bytes into prompt context.
24+
- Acceptance:
25+
- ACP `fs/read_text_file` rejects image/PDF/common binary files with remediation guidance.
26+
- Main agent `read` keeps image OCR fallback, but rejects unsupported binary formats instead of returning raw bytes.
27+
28+
### US-4: Guide the model toward stable skill execution
29+
- As an agent, active skill instructions clearly include absolute paths, script inventory, and the preferred execution tool.
30+
- Acceptance:
31+
- Active skill prompt includes `skillRoot`, recommended `base_directory`, runnable scripts, and explicit guardrails against inline `python -c` / `node -e`.
32+
33+
### US-5: Keep process output after process exit
34+
- As an agent, command output is still available when a child process writes a large payload right before exiting.
35+
- Acceptance:
36+
- Foreground exec waits for child `close`.
37+
- Background exec sessions are considered complete only after `close` and log flush.
38+
- Large foreground output is offloaded to a session log file instead of being silently truncated away.
39+
40+
## Non-Goals
41+
- Secret encryption or OS keychain storage for skill env vars.
42+
- Extending external skill formats with DeepChat-only frontmatter fields.
43+
- General workflow orchestration across multiple skills.
44+
45+
## Constraints
46+
- Keep existing skills compatible.
47+
- Ignore `.deepchat-meta` in skill discovery and sync/export flows.
48+
- Reuse the existing `process` tool for background session management.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Skill Runtime Hardening Tasks
2+
3+
1. Add shared types for skill runtime config and script descriptors.
4+
2. Extend `SkillPresenter` with sidecar persistence and script discovery.
5+
3. Add `SkillExecutionService` and wire `skill_run` into agent tools.
6+
4. Harden binary read behavior in ACP and main agent reads.
7+
5. Switch exec completion logic from `exit` to `close` and await output flush.
8+
6. Extend skills settings UI to edit runtime config and show summaries.
9+
7. Add or update tests for presenter, ACP, agent tooling, and process output behavior.

src/main/lib/binaryReadGuard.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import path from 'path'
2+
import { detectMimeType, isLikelyTextFile } from '@/presenter/filePresenter/mime'
3+
4+
const TEXT_LIKE_MIMES = new Set([
5+
'application/json',
6+
'application/xml',
7+
'application/javascript',
8+
'application/x-javascript',
9+
'application/typescript',
10+
'application/x-typescript',
11+
'application/x-sh'
12+
])
13+
14+
const DOCUMENT_MIMES = new Set([
15+
'application/pdf',
16+
'application/msword',
17+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
18+
'application/vnd.ms-powerpoint',
19+
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
20+
'application/vnd.ms-excel',
21+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
22+
'application/vnd.oasis.opendocument.spreadsheet'
23+
])
24+
25+
const ALWAYS_BINARY_MIMES = new Set([
26+
'application/octet-stream',
27+
'application/zip',
28+
'application/x-zip',
29+
'application/gzip',
30+
'application/x-gzip',
31+
'application/x-7z-compressed',
32+
'application/x-rar-compressed',
33+
'application/wasm'
34+
])
35+
36+
export function isTextLikeMime(mimeType: string): boolean {
37+
return mimeType.startsWith('text/') || TEXT_LIKE_MIMES.has(mimeType)
38+
}
39+
40+
export function isDocumentMime(mimeType: string): boolean {
41+
return DOCUMENT_MIMES.has(mimeType)
42+
}
43+
44+
export async function shouldRejectAcpTextRead(filePath: string): Promise<{
45+
reject: boolean
46+
mimeType: string
47+
}> {
48+
const mimeType = await detectMimeType(filePath)
49+
50+
if (isTextLikeMime(mimeType)) {
51+
return { reject: false, mimeType }
52+
}
53+
54+
if (mimeType === 'application/octet-stream') {
55+
const likelyText = await isLikelyTextFile(filePath)
56+
return { reject: !likelyText, mimeType }
57+
}
58+
59+
return { reject: true, mimeType }
60+
}
61+
62+
export async function shouldRejectAgentBinaryRead(
63+
filePath: string,
64+
mimeType: string
65+
): Promise<boolean> {
66+
if (mimeType.startsWith('image/')) {
67+
return false
68+
}
69+
70+
if (isTextLikeMime(mimeType) || isDocumentMime(mimeType) || mimeType === 'text/csv') {
71+
return false
72+
}
73+
74+
if (
75+
ALWAYS_BINARY_MIMES.has(mimeType) ||
76+
mimeType.startsWith('audio/') ||
77+
mimeType.startsWith('video/')
78+
) {
79+
return true
80+
}
81+
82+
if (mimeType === 'application/octet-stream') {
83+
return !(await isLikelyTextFile(filePath))
84+
}
85+
86+
return false
87+
}
88+
89+
export function buildBinaryReadGuidance(
90+
filePath: string,
91+
mimeType: string,
92+
mode: 'agent' | 'acp'
93+
): string {
94+
const fileName = path.basename(filePath)
95+
const shared = `Cannot read "${fileName}" as plain text (detected MIME: ${mimeType}).`
96+
97+
if (mode === 'acp') {
98+
return [
99+
shared,
100+
'`fs/read_text_file` only supports text files.',
101+
'Use OCR/image tooling for images, and convert or extract PDFs/binary formats before reading them as text.'
102+
].join(' ')
103+
}
104+
105+
return [
106+
shared,
107+
'Use image OCR/summary for images, or a dedicated conversion/extraction tool or skill script for binary formats.'
108+
].join(' ')
109+
}

src/main/presenter/agentPresenter/acp/acpFsHandler.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as fs from 'fs/promises'
22
import * as path from 'path'
33
import { RequestError } from '@agentclientprotocol/sdk'
44
import type * as schema from '@agentclientprotocol/sdk/dist/schema.js'
5+
import { buildBinaryReadGuidance, shouldRejectAcpTextRead } from '@/lib/binaryReadGuard'
56

67
export interface FsHandlerOptions {
78
/** Session's working directory (workspace root). Null = allow all. */
@@ -65,6 +66,14 @@ export class AcpFsHandler {
6566
)
6667
}
6768

69+
const { reject, mimeType } = await shouldRejectAcpTextRead(filePath)
70+
if (reject) {
71+
throw RequestError.invalidParams(
72+
{ path: params.path, mimeType },
73+
buildBinaryReadGuidance(filePath, mimeType, 'acp')
74+
)
75+
}
76+
6877
const content = await fs.readFile(filePath, 'utf-8')
6978
const lines = content.split('\n')
7079

0 commit comments

Comments
 (0)