@namzu/sdk 44.3.0 → 45.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/dist/authorization/command-line.d.ts +66 -19
- package/dist/authorization/command-line.d.ts.map +1 -1
- package/dist/authorization/command-line.js +130 -270
- package/dist/authorization/command-line.js.map +1 -1
- package/dist/authorization/gate.d.ts +7 -0
- package/dist/authorization/gate.d.ts.map +1 -1
- package/dist/authorization/gate.js +1 -1
- package/dist/authorization/gate.js.map +1 -1
- package/dist/authorization/rules.d.ts +10 -1
- package/dist/authorization/rules.d.ts.map +1 -1
- package/dist/authorization/rules.js +21 -5
- package/dist/authorization/rules.js.map +1 -1
- package/dist/authorization/shell-lexer.d.ts +138 -0
- package/dist/authorization/shell-lexer.d.ts.map +1 -0
- package/dist/authorization/shell-lexer.js +2143 -0
- package/dist/authorization/shell-lexer.js.map +1 -0
- package/dist/authorization/skill-grant.d.ts +182 -0
- package/dist/authorization/skill-grant.d.ts.map +1 -0
- package/dist/authorization/skill-grant.js +314 -0
- package/dist/authorization/skill-grant.js.map +1 -0
- package/dist/persona/assembler.d.ts.map +1 -1
- package/dist/persona/assembler.js +5 -2
- package/dist/persona/assembler.js.map +1 -1
- package/dist/public-runtime.d.ts +1 -0
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +4 -0
- package/dist/public-runtime.js.map +1 -1
- package/dist/public-tools.d.ts.map +1 -1
- package/dist/public-tools.js +2 -1
- package/dist/public-tools.js.map +1 -1
- package/dist/public-types.d.ts +3 -1
- package/dist/public-types.d.ts.map +1 -1
- package/dist/runtime/jobs/registry.d.ts +2 -2
- package/dist/runtime/jobs/registry.d.ts.map +1 -1
- package/dist/runtime/jobs/registry.js +6 -2
- package/dist/runtime/jobs/registry.js.map +1 -1
- package/dist/runtime/query/executor.d.ts +34 -40
- package/dist/runtime/query/executor.d.ts.map +1 -1
- package/dist/runtime/query/executor.js +81 -51
- package/dist/runtime/query/executor.js.map +1 -1
- package/dist/runtime/query/index.d.ts.map +1 -1
- package/dist/runtime/query/index.js +7 -0
- package/dist/runtime/query/index.js.map +1 -1
- package/dist/runtime/query/iteration/index.d.ts.map +1 -1
- package/dist/runtime/query/iteration/index.js +6 -0
- package/dist/runtime/query/iteration/index.js.map +1 -1
- package/dist/runtime/query/iteration/phases/context.d.ts +7 -0
- package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/context.js.map +1 -1
- package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/tool-review.js +48 -0
- package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
- package/dist/runtime/query/review-policy.d.ts +11 -0
- package/dist/runtime/query/review-policy.d.ts.map +1 -1
- package/dist/runtime/query/review-policy.js +32 -0
- package/dist/runtime/query/review-policy.js.map +1 -1
- package/dist/runtime/query/tooling.d.ts +3 -0
- package/dist/runtime/query/tooling.d.ts.map +1 -1
- package/dist/runtime/query/tooling.js +1 -0
- package/dist/runtime/query/tooling.js.map +1 -1
- package/dist/skills/loader.d.ts +8 -0
- package/dist/skills/loader.d.ts.map +1 -1
- package/dist/skills/loader.js +7 -1
- package/dist/skills/loader.js.map +1 -1
- package/dist/tools/builtins/bash.d.ts.map +1 -1
- package/dist/tools/builtins/bash.js +18 -6
- package/dist/tools/builtins/bash.js.map +1 -1
- package/dist/tools/builtins/skill.d.ts +2 -9
- package/dist/tools/builtins/skill.d.ts.map +1 -1
- package/dist/tools/builtins/skill.js +59 -51
- package/dist/tools/builtins/skill.js.map +1 -1
- package/dist/tools/command-shell.d.ts +90 -0
- package/dist/tools/command-shell.d.ts.map +1 -0
- package/dist/tools/command-shell.js +129 -0
- package/dist/tools/command-shell.js.map +1 -0
- package/dist/tools/defineTool.d.ts +11 -0
- package/dist/tools/defineTool.d.ts.map +1 -1
- package/dist/tools/defineTool.js +29 -1
- package/dist/tools/defineTool.js.map +1 -1
- package/dist/types/hitl/index.d.ts +23 -0
- package/dist/types/hitl/index.d.ts.map +1 -1
- package/dist/types/hitl/index.js.map +1 -1
- package/dist/types/tool/index.d.ts +67 -5
- package/dist/types/tool/index.d.ts.map +1 -1
- package/dist/types/tool/index.js.map +1 -1
- package/dist/utils/frontmatter.d.ts +17 -1
- package/dist/utils/frontmatter.d.ts.map +1 -1
- package/dist/utils/frontmatter.js +32 -2
- package/dist/utils/frontmatter.js.map +1 -1
- package/package.json +1 -1
- package/src/authorization/command-line.ts +148 -293
- package/src/authorization/gate.ts +8 -0
- package/src/authorization/rules.ts +33 -4
- package/src/authorization/shell-lexer.ts +2319 -0
- package/src/authorization/skill-grant.ts +400 -0
- package/src/persona/assembler.ts +5 -2
- package/src/public-runtime.ts +9 -0
- package/src/public-tools.ts +2 -1
- package/src/public-types.ts +7 -0
- package/src/runtime/jobs/registry.ts +19 -11
- package/src/runtime/query/executor.ts +99 -55
- package/src/runtime/query/index.ts +7 -0
- package/src/runtime/query/iteration/index.ts +5 -0
- package/src/runtime/query/iteration/phases/context.ts +7 -0
- package/src/runtime/query/iteration/phases/tool-review.ts +45 -0
- package/src/runtime/query/review-policy.ts +53 -0
- package/src/runtime/query/tooling.ts +4 -0
- package/src/skills/loader.ts +8 -1
- package/src/tools/builtins/bash.ts +24 -6
- package/src/tools/builtins/skill.ts +74 -53
- package/src/tools/command-shell.ts +166 -0
- package/src/tools/defineTool.ts +33 -1
- package/src/types/hitl/index.ts +21 -0
- package/src/types/tool/index.ts +67 -5
- package/src/utils/frontmatter.ts +52 -2
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A skill's `allowed-tools`, as what it is everywhere else: a pre-approval.
|
|
3
|
+
*
|
|
4
|
+
* The field comes from the Agent Skills format, and the runtime that made it
|
|
5
|
+
* popular documents it plainly: the listed tools may be used without asking
|
|
6
|
+
* for the rest of the turn that loaded the skill, and "it does not restrict
|
|
7
|
+
* which tools are available: every tool remains callable, and your permission
|
|
8
|
+
* settings still govern tools that are not listed."
|
|
9
|
+
*
|
|
10
|
+
* This kernel read it the other way round, as a RESTRICTION. A skill written
|
|
11
|
+
* for that ecosystem says `allowed-tools: Read Grep` to mean "these two are
|
|
12
|
+
* fine without a prompt", and a turn that loaded it here lost `bash` on its
|
|
13
|
+
* next batch and was told to "restrict yourself to" two tools — so the model
|
|
14
|
+
* stopped doing the work and tried to do everything through whatever the
|
|
15
|
+
* skill described. The field was never meant to take anything away.
|
|
16
|
+
*
|
|
17
|
+
* What it grants, and what it does not:
|
|
18
|
+
*
|
|
19
|
+
* - **Grants**: a call that matches an entry skips the approval PROMPT for the
|
|
20
|
+
* rest of the turn, on the same footing as a person answering "allow" for
|
|
21
|
+
* the turn. The turn ends, the grant ends; loading the skill again in a
|
|
22
|
+
* later turn grants again.
|
|
23
|
+
* - **Never overrides**: an operator `deny` rule, an operator `ask` rule, plan
|
|
24
|
+
* mode, `strict` mode, an escalation (a path argument outside the working
|
|
25
|
+
* directory, a sandbox escape), or a call the tool itself declares
|
|
26
|
+
* destructive. Those are all statements by the operator or the tool; a
|
|
27
|
+
* skill is repository content and cannot outrank either. `bash` has no path
|
|
28
|
+
* argument, so a pattern entry refuses output redirection itself (see
|
|
29
|
+
* `coveringSkill`); a whole-tool `Bash` entry grants what any line does.
|
|
30
|
+
* - **Ends early**: when the operator sends another message into the running
|
|
31
|
+
* turn, the iteration clears the set.
|
|
32
|
+
* - **Never adds a tool**: an entry names a tool this turn already has, or it
|
|
33
|
+
* is ignored and the model is told so. An unknown name widens nothing.
|
|
34
|
+
*
|
|
35
|
+
* Granting is equivalent to the operator trusting that skill's commands,
|
|
36
|
+
* which is why only a skill the host chose to load can grant anything; see
|
|
37
|
+
* `docs/` for the CLI's folder-trust story.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { MAX_CUSTOM_PATTERN_LENGTH } from '../constants/authorization/index.js'
|
|
41
|
+
import type { ToolDefinition } from '../types/tool/index.js'
|
|
42
|
+
import { writesThroughRedirection } from './command-line.js'
|
|
43
|
+
import { evaluateRule } from './rules.js'
|
|
44
|
+
import type { ShellDialect } from './shell-lexer.js'
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Split an `allowed-tools` value into entries.
|
|
48
|
+
*
|
|
49
|
+
* Accepts every spelling the format uses: space-separated (`Read Grep Bash`),
|
|
50
|
+
* comma-separated (`Read, Grep`), and — once the frontmatter reader has turned
|
|
51
|
+
* a YAML list into one line — the items of a list. Separators inside
|
|
52
|
+
* parentheses belong to the entry, so `Bash(git add *)` stays one entry.
|
|
53
|
+
*
|
|
54
|
+
* `undefined` means the skill declared nothing. An empty array means it
|
|
55
|
+
* declared the field empty. Neither grants anything, and neither restricts
|
|
56
|
+
* anything, so the distinction survives only for a host that wants to show
|
|
57
|
+
* what the author wrote.
|
|
58
|
+
*/
|
|
59
|
+
export function parseAllowedTools(declared: string | undefined): readonly string[] | undefined {
|
|
60
|
+
if (declared === undefined) return undefined
|
|
61
|
+
const entries: string[] = []
|
|
62
|
+
let current = ''
|
|
63
|
+
let depth = 0
|
|
64
|
+
const flush = () => {
|
|
65
|
+
const entry = current.trim()
|
|
66
|
+
if (entry.length > 0) entries.push(entry)
|
|
67
|
+
current = ''
|
|
68
|
+
}
|
|
69
|
+
for (const char of declared) {
|
|
70
|
+
if (char === '(') depth += 1
|
|
71
|
+
if (char === ')' && depth > 0) depth -= 1
|
|
72
|
+
if (depth === 0 && (char === ',' || /\s/.test(char))) {
|
|
73
|
+
flush()
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
current += char
|
|
77
|
+
}
|
|
78
|
+
flush()
|
|
79
|
+
return entries
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The tool names the Agent Skills ecosystem writes, and what they are here.
|
|
84
|
+
*
|
|
85
|
+
* Keyed lower-case: names are matched without regard to case, because a skill
|
|
86
|
+
* author writes `Read` and this kernel's tool is `read`, and a grant lost to
|
|
87
|
+
* capitalisation is a prompt nobody can explain.
|
|
88
|
+
*/
|
|
89
|
+
export const SKILL_TOOL_NAME_ALIASES: Readonly<Record<string, string>> = Object.freeze({
|
|
90
|
+
read: 'read',
|
|
91
|
+
write: 'write',
|
|
92
|
+
edit: 'edit',
|
|
93
|
+
multiedit: 'edit',
|
|
94
|
+
bash: 'bash',
|
|
95
|
+
grep: 'grep',
|
|
96
|
+
glob: 'glob',
|
|
97
|
+
ls: 'ls',
|
|
98
|
+
webfetch: 'web_fetch',
|
|
99
|
+
websearch: 'web_search',
|
|
100
|
+
skill: 'skill',
|
|
101
|
+
askuserquestion: 'ask_user_question',
|
|
102
|
+
task: 'create_task',
|
|
103
|
+
agent: 'create_task',
|
|
104
|
+
lsp: 'lsp',
|
|
105
|
+
toolsearch: 'search_tools',
|
|
106
|
+
// A background shell's output and its termination are one tool here,
|
|
107
|
+
// `job`, whatever the ecosystem's release called them. Granting `job`
|
|
108
|
+
// for `BashOutput` does not grant a kill without review: `job` declares
|
|
109
|
+
// `kill` destructive, and a destructive call is never skill-approved.
|
|
110
|
+
bashoutput: 'job',
|
|
111
|
+
taskoutput: 'job',
|
|
112
|
+
killshell: 'job',
|
|
113
|
+
killbash: 'job',
|
|
114
|
+
taskstop: 'job',
|
|
115
|
+
taskcreate: 'task_create',
|
|
116
|
+
taskupdate: 'task_update',
|
|
117
|
+
tasklist: 'task_list',
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* How a grant compiler learns what a name means in this turn: the registered
|
|
122
|
+
* tool a name refers to, or `undefined` for one this turn does not have.
|
|
123
|
+
* `commandArgument` is what lets a `Bash(<pattern>)` entry be matched against
|
|
124
|
+
* the command line rather than the serialised input.
|
|
125
|
+
*/
|
|
126
|
+
export type SkillGrantToolResolver = (name: string) =>
|
|
127
|
+
| {
|
|
128
|
+
readonly name: string
|
|
129
|
+
readonly commandArgument?: string
|
|
130
|
+
/**
|
|
131
|
+
* Every call of this tool is destructive, whatever its input (the
|
|
132
|
+
* shipped `write` and `run_code`). A destructive call is always
|
|
133
|
+
* reviewed, so an entry naming such a tool can grant nothing and is
|
|
134
|
+
* reported as ignored rather than listed as pre-approved.
|
|
135
|
+
*/
|
|
136
|
+
readonly alwaysDestructive?: boolean
|
|
137
|
+
/**
|
|
138
|
+
* The tool is registered but withheld from this turn or step by
|
|
139
|
+
* `allowedTools`. The model cannot call it, so an entry naming it is
|
|
140
|
+
* reported as ignored rather than listed as pre-approved.
|
|
141
|
+
*/
|
|
142
|
+
readonly unavailable?: boolean
|
|
143
|
+
}
|
|
144
|
+
| undefined
|
|
145
|
+
|
|
146
|
+
/** One compiled entry: a whole tool, or one tool's command line matching a pattern. */
|
|
147
|
+
export interface SkillGrantEntry {
|
|
148
|
+
/** The registered tool name. */
|
|
149
|
+
readonly tool: string
|
|
150
|
+
/** Present for a pattern entry: the argument holding the command line. */
|
|
151
|
+
readonly argument?: string
|
|
152
|
+
/** Present for a pattern entry: an anchored regular-expression source. */
|
|
153
|
+
readonly pattern?: string
|
|
154
|
+
/** The entry as the author wrote it, for messages. */
|
|
155
|
+
readonly declared: string
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface CompiledSkillGrant {
|
|
159
|
+
readonly entries: readonly SkillGrantEntry[]
|
|
160
|
+
/** Entries that granted nothing, with the reason, for the model and the log. */
|
|
161
|
+
readonly ignored: readonly {
|
|
162
|
+
readonly entry: string
|
|
163
|
+
readonly reason: string
|
|
164
|
+
}[]
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The two placeholders a pattern may use for the skill's own directory. */
|
|
168
|
+
const SKILL_DIR_PLACEHOLDERS = ['${CLAUDE_SKILL_DIR}', '${NAMZU_SKILL_DIR}'] as const
|
|
169
|
+
|
|
170
|
+
const ENTRY_SHAPE = /^([A-Za-z0-9_.\-]+)(?:\(([\s\S]*)\))?$/
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Turn a permission glob into an anchored regular-expression source.
|
|
174
|
+
*
|
|
175
|
+
* The one glob dialect for command permissions, shared with the CLI's
|
|
176
|
+
* `[permissions]` table so that `Bash(git status *)` in a skill and
|
|
177
|
+
* `bash = { "git status *" = "allow" }` in config mean the same commands:
|
|
178
|
+
*
|
|
179
|
+
* - `*` matches any run of characters, `?` exactly one, everything else is
|
|
180
|
+
* literal;
|
|
181
|
+
* - backslashes become forward slashes, so one pattern works on every platform;
|
|
182
|
+
* - a pattern ending in `<space>*` also matches the bare command, so
|
|
183
|
+
* `git status *` covers `git status` as well as `git status -s`, and does
|
|
184
|
+
* not cover `git statusx`.
|
|
185
|
+
*/
|
|
186
|
+
export function permissionPatternToRegExpSource(pattern: string): string {
|
|
187
|
+
const normalized = pattern.replaceAll('\\', '/')
|
|
188
|
+
const escaped = normalized
|
|
189
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
190
|
+
.replace(/\*/g, '.*')
|
|
191
|
+
.replace(/\?/g, '.')
|
|
192
|
+
const trailingSpaceStar = escaped.endsWith(' .*') ? `${escaped.slice(0, -3)}( .*)?` : escaped
|
|
193
|
+
return `^${trailingSpaceStar}$`
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Compile a skill's parsed `allowed-tools` into grant entries.
|
|
198
|
+
*
|
|
199
|
+
* Pure: the resolver supplies everything the turn knows. An entry that cannot
|
|
200
|
+
* be honoured exactly is ignored and reported, never approximated — the only
|
|
201
|
+
* safe approximation of a permission is a narrower one, and "ignored" is the
|
|
202
|
+
* narrowest.
|
|
203
|
+
*/
|
|
204
|
+
export function compileSkillGrant(
|
|
205
|
+
declared: readonly string[],
|
|
206
|
+
options: {
|
|
207
|
+
readonly resolveTool: SkillGrantToolResolver
|
|
208
|
+
/** The skill's directory, for `${CLAUDE_SKILL_DIR}` / `${NAMZU_SKILL_DIR}`. */
|
|
209
|
+
readonly skillDirectory?: string
|
|
210
|
+
},
|
|
211
|
+
): CompiledSkillGrant {
|
|
212
|
+
const entries: SkillGrantEntry[] = []
|
|
213
|
+
const ignored: { entry: string; reason: string }[] = []
|
|
214
|
+
|
|
215
|
+
for (const entry of declared) {
|
|
216
|
+
const shape = ENTRY_SHAPE.exec(entry)
|
|
217
|
+
if (!shape) {
|
|
218
|
+
ignored.push({ entry, reason: 'not a tool name or `Tool(pattern)`' })
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
221
|
+
const written = shape[1] ?? ''
|
|
222
|
+
const specifier = shape[2]?.trim()
|
|
223
|
+
const alias = SKILL_TOOL_NAME_ALIASES[written.toLowerCase()]
|
|
224
|
+
const tool = options.resolveTool(alias ?? written)
|
|
225
|
+
if (!tool) {
|
|
226
|
+
ignored.push({ entry, reason: 'this turn has no tool by that name' })
|
|
227
|
+
continue
|
|
228
|
+
}
|
|
229
|
+
if (tool.unavailable) {
|
|
230
|
+
ignored.push({
|
|
231
|
+
entry,
|
|
232
|
+
reason: `\`${tool.name}\` is not available in this turn, so nothing was granted for it`,
|
|
233
|
+
})
|
|
234
|
+
continue
|
|
235
|
+
}
|
|
236
|
+
if (tool.alwaysDestructive) {
|
|
237
|
+
ignored.push({
|
|
238
|
+
entry,
|
|
239
|
+
reason: `every \`${tool.name}\` call is destructive and is always reviewed, so nothing was granted for it`,
|
|
240
|
+
})
|
|
241
|
+
continue
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (specifier === undefined || specifier === '' || specifier.replaceAll('*', '') === '') {
|
|
245
|
+
entries.push({ tool: tool.name, declared: entry })
|
|
246
|
+
continue
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (tool.commandArgument === undefined) {
|
|
250
|
+
ignored.push({
|
|
251
|
+
entry,
|
|
252
|
+
reason: `a pattern is honoured only for a tool that takes a command line, and \`${tool.name}\` does not; nothing was granted for it`,
|
|
253
|
+
})
|
|
254
|
+
continue
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
let pattern = specifier
|
|
258
|
+
// The legacy prefix spelling `npm run test:*` means `npm run test *`.
|
|
259
|
+
if (pattern.endsWith(':*')) pattern = `${pattern.slice(0, -2)} *`
|
|
260
|
+
if (SKILL_DIR_PLACEHOLDERS.some((placeholder) => pattern.includes(placeholder))) {
|
|
261
|
+
if (!options.skillDirectory) {
|
|
262
|
+
ignored.push({
|
|
263
|
+
entry,
|
|
264
|
+
reason: "the skill's directory is not known in this turn",
|
|
265
|
+
})
|
|
266
|
+
continue
|
|
267
|
+
}
|
|
268
|
+
for (const placeholder of SKILL_DIR_PLACEHOLDERS) {
|
|
269
|
+
pattern = pattern.replaceAll(placeholder, options.skillDirectory)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const source = permissionPatternToRegExpSource(pattern)
|
|
273
|
+
if (source.length > MAX_CUSTOM_PATTERN_LENGTH) {
|
|
274
|
+
ignored.push({ entry, reason: 'the pattern is too long to evaluate' })
|
|
275
|
+
continue
|
|
276
|
+
}
|
|
277
|
+
entries.push({
|
|
278
|
+
tool: tool.name,
|
|
279
|
+
argument: tool.commandArgument,
|
|
280
|
+
pattern: source,
|
|
281
|
+
declared: entry,
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return { entries, ignored }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
interface HeldGrant {
|
|
289
|
+
readonly skill: string
|
|
290
|
+
readonly entry: SkillGrantEntry
|
|
291
|
+
readonly compiled?: RegExp
|
|
292
|
+
readonly names: Set<string>
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The skill grants in force for one turn.
|
|
297
|
+
*
|
|
298
|
+
* Turn-scoped for the reason `ToolGrantSet` is: a grant is a statement about
|
|
299
|
+
* this turn's work. It is created with the turn and dropped with it, so it
|
|
300
|
+
* never reaches the next message, and a delegated child — which runs its own
|
|
301
|
+
* turn — starts with an empty one of its own rather than its parent's.
|
|
302
|
+
*/
|
|
303
|
+
export class SkillGrantSet {
|
|
304
|
+
private readonly held: HeldGrant[] = []
|
|
305
|
+
|
|
306
|
+
/** Record a skill's compiled entries. Loading the same skill twice is harmless. */
|
|
307
|
+
grant(skill: string, compiled: CompiledSkillGrant): void {
|
|
308
|
+
for (const entry of compiled.entries) {
|
|
309
|
+
const duplicate = this.held.some(
|
|
310
|
+
(held) =>
|
|
311
|
+
held.skill === skill &&
|
|
312
|
+
held.entry.tool === entry.tool &&
|
|
313
|
+
held.entry.argument === entry.argument &&
|
|
314
|
+
held.entry.pattern === entry.pattern,
|
|
315
|
+
)
|
|
316
|
+
if (duplicate) continue
|
|
317
|
+
this.held.push({
|
|
318
|
+
skill,
|
|
319
|
+
entry,
|
|
320
|
+
...(entry.pattern !== undefined ? { compiled: new RegExp(entry.pattern) } : {}),
|
|
321
|
+
names: new Set([entry.tool]),
|
|
322
|
+
})
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The skill whose grant covers this call, or `undefined`.
|
|
328
|
+
*
|
|
329
|
+
* A pattern entry is matched the way an operator's `allow` pattern is: per
|
|
330
|
+
* command, every command in the line must match, and a line the reader
|
|
331
|
+
* cannot see through (a substitution, `eval`, a line that does not parse)
|
|
332
|
+
* matches nothing. So
|
|
333
|
+
* `Bash(git status *)` covers `git status -s` and not
|
|
334
|
+
* `git status && git push`.
|
|
335
|
+
*
|
|
336
|
+
* A pattern entry also never covers a line that redirects output into a
|
|
337
|
+
* file (`git status > ~/.bashrc`). The pattern names a command; where its
|
|
338
|
+
* output lands is not part of that command, and the shell's redirection
|
|
339
|
+
* writes wherever the line says without the tool reporting a path.
|
|
340
|
+
* `/dev/null` and descriptor duplication (`2>&1`) are not writes and stay
|
|
341
|
+
* covered. A whole-tool entry (`Bash`) grants the tool as it is, which
|
|
342
|
+
* includes redirection.
|
|
343
|
+
*/
|
|
344
|
+
coveringSkill(
|
|
345
|
+
call: { readonly name: string; readonly input: unknown },
|
|
346
|
+
toolDef?: ToolDefinition,
|
|
347
|
+
options: { readonly commandDialect?: ShellDialect } = {},
|
|
348
|
+
): string | undefined {
|
|
349
|
+
// Read the line for the shell that will run it; unknown is `sh`, the
|
|
350
|
+
// reading that holds for any POSIX shell.
|
|
351
|
+
const dialect = options.commandDialect ?? 'sh'
|
|
352
|
+
for (const held of this.held) {
|
|
353
|
+
if (held.entry.tool !== call.name) continue
|
|
354
|
+
if (held.entry.pattern === undefined || held.entry.argument === undefined) return held.skill
|
|
355
|
+
const line =
|
|
356
|
+
call.input !== null && typeof call.input === 'object'
|
|
357
|
+
? (call.input as Record<string, unknown>)[held.entry.argument]
|
|
358
|
+
: undefined
|
|
359
|
+
if (typeof line !== 'string' || writesThroughRedirection(line, dialect)) continue
|
|
360
|
+
const decision = evaluateRule(
|
|
361
|
+
{
|
|
362
|
+
type: 'argument_pattern',
|
|
363
|
+
toolNames: [held.entry.tool],
|
|
364
|
+
argument: held.entry.argument,
|
|
365
|
+
pattern: held.entry.pattern,
|
|
366
|
+
decision: 'allow',
|
|
367
|
+
},
|
|
368
|
+
call.name,
|
|
369
|
+
call.input,
|
|
370
|
+
toolDef,
|
|
371
|
+
held.compiled,
|
|
372
|
+
held.names,
|
|
373
|
+
{ commandDialect: dialect },
|
|
374
|
+
)
|
|
375
|
+
if (decision === 'allow') return held.skill
|
|
376
|
+
}
|
|
377
|
+
return undefined
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Drop every grant. Called when the operator speaks again inside a running
|
|
382
|
+
* turn: the grant belonged to the request that loaded the skill, and a new
|
|
383
|
+
* message is a new request, even when it reaches the same `query()`.
|
|
384
|
+
*/
|
|
385
|
+
clear(): void {
|
|
386
|
+
this.held.length = 0
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
get size(): number {
|
|
390
|
+
return this.held.length
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** What is granted, by skill, as the authors wrote it. */
|
|
394
|
+
list(): { readonly skill: string; readonly entry: string }[] {
|
|
395
|
+
return this.held.map((held) => ({
|
|
396
|
+
skill: held.skill,
|
|
397
|
+
entry: held.entry.declared,
|
|
398
|
+
}))
|
|
399
|
+
}
|
|
400
|
+
}
|
package/src/persona/assembler.ts
CHANGED
|
@@ -67,8 +67,11 @@ export function renderSkillsSection(skills?: Skill[]): string | null {
|
|
|
67
67
|
if (s.metadata.license) {
|
|
68
68
|
lines.push(`<license>${s.metadata.license}</license>`)
|
|
69
69
|
}
|
|
70
|
+
// Named for what it does. Rendered as `<allowed_tools>` it read as a
|
|
71
|
+
// whitelist, and a model that took it that way stopped reaching for
|
|
72
|
+
// tools the skill did not list — the reverse of the field's meaning.
|
|
70
73
|
if (s.metadata.allowedTools) {
|
|
71
|
-
lines.push(`<
|
|
74
|
+
lines.push(`<pre_approved_tools>${s.metadata.allowedTools}</pre_approved_tools>`)
|
|
72
75
|
}
|
|
73
76
|
lines.push('</skill>')
|
|
74
77
|
return lines.join('\n')
|
|
@@ -81,7 +84,7 @@ export function renderSkillsSection(skills?: Skill[]): string | null {
|
|
|
81
84
|
// instructions and no listing that would let it reason about them.
|
|
82
85
|
const loadedSkills = forModel.filter((s) => s.body)
|
|
83
86
|
const sections = [
|
|
84
|
-
`## Available Skills\nThe following block is a manifest, not the full skill content. Skill metadata is always visible; SKILL.md bodies are loaded only when the user's task matches the skill description.\n\n<available_skills>\n${available}\n</available_skills>\n\nSkill usage protocol:\n- Plain questions do not require a skill.\n- When a matching skill is already listed under Loaded Skills, apply its loaded instructions.\n- When a matching skill is not loaded and the runtime exposes filesystem or skill-loading tools, read the SKILL.md at its <location> before writing code, creating files, running shell commands for that workflow, or calling mutation tools guided by the skill.\n- Do not claim to have read a SKILL.md until its content is actually present in the prompt or returned by a tool.\n- Tool schemas and runtime permissions remain authoritative; skills provide guidance, not hidden tools.`,
|
|
87
|
+
`## Available Skills\nThe following block is a manifest, not the full skill content. Skill metadata is always visible; SKILL.md bodies are loaded only when the user's task matches the skill description.\n\n<available_skills>\n${available}\n</available_skills>\n\nSkill usage protocol:\n- Plain questions do not require a skill.\n- When a matching skill is already listed under Loaded Skills, apply its loaded instructions.\n- When a matching skill is not loaded and the runtime exposes filesystem or skill-loading tools, read the SKILL.md at its <location> before writing code, creating files, running shell commands for that workflow, or calling mutation tools guided by the skill.\n- Do not claim to have read a SKILL.md until its content is actually present in the prompt or returned by a tool.\n- Tool schemas and runtime permissions remain authoritative; skills provide guidance, not hidden tools.\n- <pre_approved_tools> lists tools a skill lets run without an approval prompt once it is loaded. It never limits which tools you may use: every tool stays available.`,
|
|
85
88
|
]
|
|
86
89
|
|
|
87
90
|
if (loadedSkills.length > 0) {
|
package/src/public-runtime.ts
CHANGED
|
@@ -318,6 +318,15 @@ export { deriveTurnStatus } from './types/session/derive-status.js'
|
|
|
318
318
|
// wide their yes is, instead of choosing between 'this one call' and
|
|
319
319
|
// 'everything for the session'.
|
|
320
320
|
export { ToolGrantSet, toolGrantKeys } from './runtime/query/tool-grants.js'
|
|
321
|
+
// A skill's `allowed-tools` as a turn-scoped pre-approval: the parser, the
|
|
322
|
+
// compiler a host can run against its own registry, the per-turn set, and
|
|
323
|
+
// the one permission-glob dialect the CLI's `[permissions]` table shares.
|
|
324
|
+
export {
|
|
325
|
+
SKILL_TOOL_NAME_ALIASES,
|
|
326
|
+
SkillGrantSet,
|
|
327
|
+
compileSkillGrant,
|
|
328
|
+
permissionPatternToRegExpSource,
|
|
329
|
+
} from './authorization/skill-grant.js'
|
|
321
330
|
export type { ToolGrantKeys } from './runtime/query/tool-grants.js'
|
|
322
331
|
// `toWireTurnStatus` comes through `./contracts/session/index.js`.
|
|
323
332
|
// Durable turn state: the snapshot a different process picks a turn up from.
|
package/src/public-tools.ts
CHANGED
|
@@ -61,7 +61,8 @@ export { JobTool } from './tools/builtins/job.js'
|
|
|
61
61
|
// the same reason `job` does: a job with nothing that can block on it is
|
|
62
62
|
// the same unbacked suggestion `job` itself exists to fix.
|
|
63
63
|
export { WaitForJobTool } from './tools/builtins/wait-for-job.js'
|
|
64
|
-
// Loads a skill's instructions, and
|
|
64
|
+
// Loads a skill's instructions, and pre-approves what its `allowed-tools`
|
|
65
|
+
// names for the rest of the turn (never narrowing the tool set).
|
|
65
66
|
// NOT in the default builtin set: a turn with no skills has nothing for it
|
|
66
67
|
// to do, and offering a tool that can only refuse is worse than not
|
|
67
68
|
// offering it. Hosts register it alongside a skills registry.
|
package/src/public-types.ts
CHANGED
|
@@ -113,9 +113,15 @@ export type { CacheRates, ModelPricing } from './utils/cost.js'
|
|
|
113
113
|
export type { VendorRates } from './pricing/index.js'
|
|
114
114
|
export type { PricingSubject } from './manager/session/turn-recorder.js'
|
|
115
115
|
export type {
|
|
116
|
+
FrontmatterOptions,
|
|
116
117
|
FrontmatterValue,
|
|
117
118
|
ParsedFrontmatter,
|
|
118
119
|
} from './utils/frontmatter.js'
|
|
120
|
+
export type {
|
|
121
|
+
CompiledSkillGrant,
|
|
122
|
+
SkillGrantEntry,
|
|
123
|
+
SkillGrantToolResolver,
|
|
124
|
+
} from './authorization/skill-grant.js'
|
|
119
125
|
export type { Logger } from './utils/logger.js'
|
|
120
126
|
export type {
|
|
121
127
|
LevelFilter,
|
|
@@ -255,6 +261,7 @@ export type { AgentBusConfig } from './bus/index.js'
|
|
|
255
261
|
|
|
256
262
|
export type { ToolCallContext } from './authorization/index.js'
|
|
257
263
|
export type { PermissionPreset } from './authorization/index.js'
|
|
264
|
+
export type { EvaluateRuleOptions } from './authorization/rules.js'
|
|
258
265
|
|
|
259
266
|
export type {
|
|
260
267
|
DiskSessionStoreConfig,
|
|
@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process'
|
|
|
2
2
|
|
|
3
3
|
import { SANDBOX_KILL_GRACE_MS } from '../../constants/sandbox/index.js'
|
|
4
4
|
import { killTree } from '../../process/kill-tree.js'
|
|
5
|
+
import { hostShellSpawn } from '../../tools/command-shell.js'
|
|
5
6
|
import { scrubInheritedEnv } from '../../tools/env-scrub.js'
|
|
6
7
|
import { awaitWithAbort } from '../../utils/await-with-abort.js'
|
|
7
8
|
|
|
@@ -72,8 +73,8 @@ export interface StartJobParams {
|
|
|
72
73
|
readonly env?: Readonly<Record<string, string>>
|
|
73
74
|
/**
|
|
74
75
|
* Start the process yourself — a sandbox does, so the job runs inside
|
|
75
|
-
* its boundary. Absent, the registry runs
|
|
76
|
-
* host. The process must be the leader of its own group and must not
|
|
76
|
+
* its boundary. Absent, the registry runs it on the
|
|
77
|
+
* host in the `bash` tool's shell (`tools/command-shell.ts`). The process must be the leader of its own group and must not
|
|
77
78
|
* expect stdin.
|
|
78
79
|
*/
|
|
79
80
|
readonly spawn?: () => JobProcess
|
|
@@ -205,18 +206,25 @@ export class BackgroundJobRegistry {
|
|
|
205
206
|
// started it, which makes the leak longer-lived, not smaller.
|
|
206
207
|
const inherited = scrubInheritedEnv()
|
|
207
208
|
|
|
209
|
+
// The same shell as a foreground `bash` call, so the permission
|
|
210
|
+
// rules' reading of the line holds for the job too.
|
|
211
|
+
const shell = hostShellSpawn(params.command, { ...inherited.env, ...params.env })
|
|
208
212
|
const started = params.spawn
|
|
209
213
|
? params.spawn()
|
|
210
214
|
: {
|
|
211
|
-
child: spawn(
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
215
|
+
child: spawn(
|
|
216
|
+
shell.file ?? '/bin/sh',
|
|
217
|
+
shell.file === undefined ? ['-c', params.command] : [...shell.args],
|
|
218
|
+
{
|
|
219
|
+
cwd: params.workingDirectory,
|
|
220
|
+
env: shell.env,
|
|
221
|
+
// Leader of its own process group, which is what `killTree` needs
|
|
222
|
+
// to reach the command and everything it forks rather than only the
|
|
223
|
+
// wrapping shell. See `process/kill-tree.ts`.
|
|
224
|
+
detached: process.platform !== 'win32',
|
|
225
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
226
|
+
},
|
|
227
|
+
),
|
|
220
228
|
}
|
|
221
229
|
const child = started.child
|
|
222
230
|
// Started, not adopted: this process stays the parent for the job's
|