@namzu/sdk 44.2.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 +49 -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/prompt/coding-agent-doctrine.d.ts +1 -1
- package/dist/prompt/coding-agent-doctrine.d.ts.map +1 -1
- package/dist/prompt/coding-agent-doctrine.js +1 -1
- package/dist/prompt/coding-agent-doctrine.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/provider/stream.d.ts +10 -0
- package/dist/types/provider/stream.d.ts.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/prompt/coding-agent-doctrine.ts +1 -1
- 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/provider/stream.ts +10 -0
- package/src/types/tool/index.ts +67 -5
- package/src/utils/frontmatter.ts +52 -2
|
@@ -1,23 +1,27 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
2
|
import { z } from 'zod'
|
|
3
3
|
|
|
4
|
+
import { parseAllowedTools } from '../../authorization/skill-grant.js'
|
|
4
5
|
import { isInvocableBy, skillInvocation } from '../../types/skills/index.js'
|
|
5
6
|
import { defineTool } from '../defineTool.js'
|
|
6
7
|
|
|
8
|
+
export { parseAllowedTools }
|
|
9
|
+
|
|
7
10
|
/**
|
|
8
|
-
* Load a skill's instructions, and
|
|
11
|
+
* Load a skill's instructions, and apply what its `allowed-tools` grants.
|
|
9
12
|
*
|
|
10
13
|
* The manifest in the system prompt told the model that a SKILL.md exists
|
|
11
14
|
* and to "read the SKILL.md at its <location> before writing code" — which
|
|
12
15
|
* is a filesystem instruction, so a turn with no filesystem tools could see
|
|
13
|
-
* every skill it had and open none of them.
|
|
14
|
-
* it: *"when the runtime exposes filesystem or skill-loading tools"*. There
|
|
15
|
-
* was no skill-loading tool.
|
|
16
|
+
* every skill it had and open none of them. There was no skill-loading tool.
|
|
16
17
|
*
|
|
17
|
-
* `allowed-tools`
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* `allowed-tools` is a PRE-APPROVAL, as the Agent Skills format defines it:
|
|
19
|
+
* the listed tools skip the approval prompt for the rest of this turn, and
|
|
20
|
+
* every other tool stays callable under the turn's ordinary review. It was
|
|
21
|
+
* read here for a while as a restriction — the listed tools and nothing
|
|
22
|
+
* else, from the next batch — which inverted what skill authors mean by it
|
|
23
|
+
* and left a model that loaded `allowed-tools: Read Grep` without `bash`.
|
|
24
|
+
* See `authorization/skill-grant.ts` for what a grant can and cannot do.
|
|
21
25
|
*/
|
|
22
26
|
|
|
23
27
|
const inputSchema = z.object({
|
|
@@ -47,6 +51,8 @@ interface SkillSnapshot {
|
|
|
47
51
|
readonly name: string
|
|
48
52
|
readonly body: string
|
|
49
53
|
readonly allowedTools: readonly string[] | undefined
|
|
54
|
+
/** Bound into the cursor because `${CLAUDE_SKILL_DIR}` in a grant expands to it. */
|
|
55
|
+
readonly skillDirectory: string | undefined
|
|
50
56
|
readonly invocation: ReturnType<typeof skillInvocation>
|
|
51
57
|
}
|
|
52
58
|
|
|
@@ -76,6 +82,9 @@ function snapshotDigest(snapshot: SkillSnapshot): string {
|
|
|
76
82
|
name: snapshot.name,
|
|
77
83
|
body: snapshot.body,
|
|
78
84
|
allowedTools: snapshot.allowedTools ?? null,
|
|
85
|
+
...(snapshot.skillDirectory === undefined
|
|
86
|
+
? {}
|
|
87
|
+
: { skillDirectory: snapshot.skillDirectory }),
|
|
79
88
|
invocation: snapshot.invocation,
|
|
80
89
|
}),
|
|
81
90
|
)
|
|
@@ -231,27 +240,6 @@ function pageSkillBody(input: {
|
|
|
231
240
|
return undefined
|
|
232
241
|
}
|
|
233
242
|
|
|
234
|
-
/**
|
|
235
|
-
* `allowed-tools` as a list.
|
|
236
|
-
*
|
|
237
|
-
* Comma-separated in the frontmatter because that is what authors write and
|
|
238
|
-
* what the field has always accepted. Split here rather than at parse so
|
|
239
|
-
* the stored metadata keeps the author's own string — the same reasoning
|
|
240
|
-
* `invocation` uses for not defaulting at parse.
|
|
241
|
-
*/
|
|
242
|
-
export function parseAllowedTools(declared: string | undefined): readonly string[] | undefined {
|
|
243
|
-
if (declared === undefined) return undefined
|
|
244
|
-
const names = declared
|
|
245
|
-
.split(',')
|
|
246
|
-
.map((name) => name.trim())
|
|
247
|
-
.filter((name) => name.length > 0)
|
|
248
|
-
// An empty result from a non-empty declaration is a real answer and not
|
|
249
|
-
// the same as "declared nothing": `allowed-tools: ""` is an author
|
|
250
|
-
// saying this skill needs no tools, and collapsing it to `undefined`
|
|
251
|
-
// would silently widen that to everything.
|
|
252
|
-
return names
|
|
253
|
-
}
|
|
254
|
-
|
|
255
243
|
export const SKILL_TOOL_NAME = 'skill'
|
|
256
244
|
|
|
257
245
|
export const SkillTool = defineTool({
|
|
@@ -261,9 +249,9 @@ export const SkillTool = defineTool({
|
|
|
261
249
|
inputSchema,
|
|
262
250
|
category: 'analysis',
|
|
263
251
|
permissions: [],
|
|
264
|
-
// Reads instructions and changes nothing.
|
|
265
|
-
//
|
|
266
|
-
//
|
|
252
|
+
// Reads instructions and changes nothing on disk. What it does change is
|
|
253
|
+
// the turn's approvals, through `grantSkillTools`, and only ever towards
|
|
254
|
+
// fewer prompts for calls the operator's policy already leaves to review.
|
|
267
255
|
readOnly: true,
|
|
268
256
|
destructive: false,
|
|
269
257
|
concurrencySafe: true,
|
|
@@ -382,6 +370,7 @@ export const SkillTool = defineTool({
|
|
|
382
370
|
name: input.name,
|
|
383
371
|
body: skill.body ?? '(this skill has no body)',
|
|
384
372
|
allowedTools: allowed,
|
|
373
|
+
skillDirectory: skill.dirPath,
|
|
385
374
|
invocation,
|
|
386
375
|
}
|
|
387
376
|
const digest = snapshotDigest(snapshot)
|
|
@@ -403,10 +392,20 @@ export const SkillTool = defineTool({
|
|
|
403
392
|
start = parsed.offset
|
|
404
393
|
}
|
|
405
394
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
395
|
+
// Compiled before paging so the notice can say what the grant is, and
|
|
396
|
+
// committed only after paging succeeded: a load that fails here gave
|
|
397
|
+
// the model no instructions, so it must not have approved anything.
|
|
398
|
+
// Idempotent: a continuation call grants the same entries again, and
|
|
399
|
+
// the turn's set keeps one copy.
|
|
400
|
+
let grant: ReturnType<NonNullable<typeof context.grantSkillTools>> | undefined
|
|
401
|
+
if (allowed !== undefined && allowed.length > 0 && context.grantSkillTools) {
|
|
402
|
+
grant = context.grantSkillTools({
|
|
403
|
+
skill: skill.metadata.name,
|
|
404
|
+
allowedTools: allowed,
|
|
405
|
+
...(snapshot.skillDirectory ? { skillDirectory: snapshot.skillDirectory } : {}),
|
|
406
|
+
})
|
|
407
|
+
}
|
|
408
|
+
const notice = grantNotice(allowed, grant, context.grantSkillTools !== undefined)
|
|
410
409
|
const page = pageSkillBody({
|
|
411
410
|
snapshot,
|
|
412
411
|
digest,
|
|
@@ -421,23 +420,7 @@ export const SkillTool = defineTool({
|
|
|
421
420
|
error: `The model-visible tool-output budget is too small to read "${input.name}" safely. Increase maxToolOutputChars and retry.`,
|
|
422
421
|
}
|
|
423
422
|
}
|
|
424
|
-
|
|
425
|
-
// Cursor and policy validation must finish before this mutation. A
|
|
426
|
-
// continuation is bound to the body AND its effective authorization
|
|
427
|
-
// metadata, so an edit to allowed-tools or invocation cannot widen the
|
|
428
|
-
// next batch under an old cursor.
|
|
429
|
-
if (allowed !== undefined) {
|
|
430
|
-
// Adopted, not merely announced. The notice below tells the model
|
|
431
|
-
// what happened; this is what makes it true whether or not the
|
|
432
|
-
// model reads it — the difference between the field as it was and
|
|
433
|
-
// the field as a declaration.
|
|
434
|
-
//
|
|
435
|
-
// Absent `adoptSkillScope`, the notice still goes out and is all
|
|
436
|
-
// there is: a host driving this tool outside a turn has no executor
|
|
437
|
-
// to enforce anything, and saying nothing would be worse than
|
|
438
|
-
// advice.
|
|
439
|
-
context.adoptSkillScope?.({ skill: skill.metadata.name, allowedTools: allowed })
|
|
440
|
-
}
|
|
423
|
+
grant?.commit()
|
|
441
424
|
|
|
442
425
|
return {
|
|
443
426
|
success: true,
|
|
@@ -445,8 +428,46 @@ export const SkillTool = defineTool({
|
|
|
445
428
|
data: {
|
|
446
429
|
skill: skill.metadata.name,
|
|
447
430
|
...(allowed === undefined ? {} : { allowedTools: allowed }),
|
|
431
|
+
...(grant ? { granted: grant.granted, ignored: grant.ignored } : {}),
|
|
448
432
|
...(page.nextCursor === undefined ? {} : { nextCursor: page.nextCursor }),
|
|
449
433
|
},
|
|
450
434
|
}
|
|
451
435
|
},
|
|
452
436
|
})
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* What the model is told about `allowed-tools`.
|
|
440
|
+
*
|
|
441
|
+
* The sentence that matters most is the second one. The old notice said
|
|
442
|
+
* "restrict yourself to", and a model told that does exactly what the owner
|
|
443
|
+
* reported: it stops using `bash` and tries to do the work through the skill.
|
|
444
|
+
* So the notice says, every time, that nothing was taken away.
|
|
445
|
+
*/
|
|
446
|
+
function grantNotice(
|
|
447
|
+
allowed: readonly string[] | undefined,
|
|
448
|
+
grant:
|
|
449
|
+
| {
|
|
450
|
+
granted: readonly string[]
|
|
451
|
+
ignored: readonly { entry: string; reason: string }[]
|
|
452
|
+
}
|
|
453
|
+
| undefined,
|
|
454
|
+
canGrant: boolean,
|
|
455
|
+
): string {
|
|
456
|
+
if (allowed === undefined || allowed.length === 0) return ''
|
|
457
|
+
const unchanged =
|
|
458
|
+
'Every other tool remains available and is reviewed as usual; this skill does not limit which tools you may use.'
|
|
459
|
+
if (!canGrant || !grant) {
|
|
460
|
+
return `\n\n[This skill lists allowed-tools (${allowed.join(', ')}), but this host applies no pre-approval, so those calls are reviewed as usual. ${unchanged}]`
|
|
461
|
+
}
|
|
462
|
+
const lines: string[] = []
|
|
463
|
+
lines.push(
|
|
464
|
+
grant.granted.length > 0
|
|
465
|
+
? `Pre-approved for the rest of this turn: ${grant.granted.join(', ')}. Deny and ask rules, plan and strict mode, and review of destructive calls still apply.`
|
|
466
|
+
: 'Nothing in allowed-tools could be pre-approved.',
|
|
467
|
+
)
|
|
468
|
+
for (const { entry, reason } of grant.ignored) {
|
|
469
|
+
lines.push(`Ignored allowed-tools entry "${entry}": ${reason}.`)
|
|
470
|
+
}
|
|
471
|
+
lines.push(unchanged)
|
|
472
|
+
return `\n\n[${lines.join(' ')}]`
|
|
473
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which shell runs a `bash` tool command, and in what dialect it must be read.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this is one decision
|
|
5
|
+
*
|
|
6
|
+
* The permission rules read a command line before it runs
|
|
7
|
+
* (`authorization/shell-lexer.ts`), and a reading is only as good as its
|
|
8
|
+
* match with the shell that runs the line afterwards. The tool is called
|
|
9
|
+
* `bash` and its description says bash, but it used to spawn `/bin/sh -c`:
|
|
10
|
+
* bash on some hosts, `dash` on Debian and Ubuntu, `busybox sh` in small
|
|
11
|
+
* images. `$'\x3b'`, `|&`, `&>` and `<<<` mean different things in those,
|
|
12
|
+
* so a line the rules read as bash could run as something else.
|
|
13
|
+
*
|
|
14
|
+
* So the host path now runs bash wherever bash exists, and the reading
|
|
15
|
+
* follows what was actually chosen:
|
|
16
|
+
*
|
|
17
|
+
* - bash found (or named by `NAMZU_BASH_SHELL`) → `bash -c`, read in the
|
|
18
|
+
* `bash` dialect;
|
|
19
|
+
* - no bash → `/bin/sh -c`, read in the conservative `sh` dialect, in which
|
|
20
|
+
* every construct whose meaning differs between bash and a POSIX shell
|
|
21
|
+
* makes a line opaque;
|
|
22
|
+
* - inside a sandbox the guest image decides, and the rules cannot see it,
|
|
23
|
+
* so a small launcher runs bash when the guest has it and `/bin/sh`
|
|
24
|
+
* otherwise, and the line is always read in the `sh` dialect, which is
|
|
25
|
+
* right for either.
|
|
26
|
+
*
|
|
27
|
+
* ## Equivalent to what ran before
|
|
28
|
+
*
|
|
29
|
+
* `/bin/sh -c` read no startup file. `bash -c` reads none either, except
|
|
30
|
+
* the file `BASH_ENV` names, and it imports shell functions (`BASH_FUNC_*`)
|
|
31
|
+
* and parser options (`SHELLOPTS`, `BASHOPTS`) from its environment. Any of
|
|
32
|
+
* those would change what a command line means after the rules read it, so
|
|
33
|
+
* they are removed from the environment of the spawned bash.
|
|
34
|
+
* `NAMZU_BASH_SHELL=/bin/sh` restores the old shell exactly.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { constants, accessSync } from 'node:fs'
|
|
38
|
+
import { delimiter, join } from 'node:path'
|
|
39
|
+
|
|
40
|
+
import type { ShellDialect } from '../types/tool/index.js'
|
|
41
|
+
|
|
42
|
+
/** The shell a host-side command runs in. */
|
|
43
|
+
export interface CommandShell {
|
|
44
|
+
/** The executable, run as `<path> -c <command>`. Undefined: Node's platform shell (Windows). */
|
|
45
|
+
readonly path: string | undefined
|
|
46
|
+
/** How the permission rules read a line this shell runs. */
|
|
47
|
+
readonly dialect: ShellDialect
|
|
48
|
+
/** Where the choice came from, for diagnostics. */
|
|
49
|
+
readonly source: 'override' | 'bash' | 'sh' | 'platform'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** What resolution looks at. Injected by tests to simulate a host without bash. */
|
|
53
|
+
export interface CommandShellProbe {
|
|
54
|
+
readonly env: NodeJS.ProcessEnv
|
|
55
|
+
readonly platform: NodeJS.Platform
|
|
56
|
+
readonly isExecutable: (path: string) => boolean
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const WELL_KNOWN_BASH = ['/bin/bash', '/usr/bin/bash']
|
|
60
|
+
|
|
61
|
+
/** Environment variables that change what a bash command line means. */
|
|
62
|
+
const BASH_STARTUP_VARIABLES = new Set(['BASH_ENV', 'ENV', 'SHELLOPTS', 'BASHOPTS'])
|
|
63
|
+
|
|
64
|
+
export function findCommandShell(probe: CommandShellProbe): CommandShell {
|
|
65
|
+
const override = probe.env.NAMZU_BASH_SHELL
|
|
66
|
+
if (override !== undefined && override !== '') {
|
|
67
|
+
// Read as bash only when it is bash; anything else gets the reading
|
|
68
|
+
// that holds for every POSIX shell.
|
|
69
|
+
const name = override.slice(override.lastIndexOf('/') + 1)
|
|
70
|
+
return { path: override, dialect: name === 'bash' ? 'bash' : 'sh', source: 'override' }
|
|
71
|
+
}
|
|
72
|
+
// Windows keeps Node's platform shell. Looking `bash` up on its PATH can
|
|
73
|
+
// find WSL's launcher, which runs the command in another system.
|
|
74
|
+
if (probe.platform === 'win32') return { path: undefined, dialect: 'sh', source: 'platform' }
|
|
75
|
+
for (const directory of (probe.env.PATH ?? '').split(delimiter)) {
|
|
76
|
+
if (directory === '' || !directory.startsWith('/')) continue
|
|
77
|
+
const candidate = join(directory, 'bash')
|
|
78
|
+
if (probe.isExecutable(candidate)) return { path: candidate, dialect: 'bash', source: 'bash' }
|
|
79
|
+
}
|
|
80
|
+
for (const candidate of WELL_KNOWN_BASH) {
|
|
81
|
+
if (probe.isExecutable(candidate)) return { path: candidate, dialect: 'bash', source: 'bash' }
|
|
82
|
+
}
|
|
83
|
+
return { path: '/bin/sh', dialect: 'sh', source: 'sh' }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isExecutable(path: string): boolean {
|
|
87
|
+
try {
|
|
88
|
+
accessSync(path, constants.X_OK)
|
|
89
|
+
return true
|
|
90
|
+
} catch {
|
|
91
|
+
return false
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let resolved: CommandShell | undefined
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The host's command shell, resolved once per process. The same value serves
|
|
99
|
+
* the permission rules and the spawn, so the two cannot disagree.
|
|
100
|
+
*/
|
|
101
|
+
export function hostCommandShell(): CommandShell {
|
|
102
|
+
if (resolved === undefined) {
|
|
103
|
+
resolved = findCommandShell({ env: process.env, platform: process.platform, isExecutable })
|
|
104
|
+
}
|
|
105
|
+
return resolved
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Replace the resolved host shell; `undefined` resolves again on next use. For tests. */
|
|
109
|
+
export function setHostCommandShellForTesting(shell: CommandShell | undefined): void {
|
|
110
|
+
resolved = shell
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The spawn for one command on the host: executable, arguments, environment.
|
|
115
|
+
* For bash, the variables that would change the line's meaning are dropped.
|
|
116
|
+
*/
|
|
117
|
+
export function hostShellSpawn(
|
|
118
|
+
command: string,
|
|
119
|
+
env: NodeJS.ProcessEnv,
|
|
120
|
+
shell: CommandShell = hostCommandShell(),
|
|
121
|
+
): {
|
|
122
|
+
readonly file: string | undefined
|
|
123
|
+
readonly args: readonly string[]
|
|
124
|
+
readonly env: NodeJS.ProcessEnv
|
|
125
|
+
} {
|
|
126
|
+
if (shell.path === undefined) return { file: undefined, args: [command], env }
|
|
127
|
+
if (shell.dialect !== 'bash') return { file: shell.path, args: ['-c', command], env }
|
|
128
|
+
return { file: shell.path, args: ['-c', command], env: withoutBashStartup(env) }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function withoutBashStartup<T extends Readonly<Record<string, string | undefined>>>(
|
|
132
|
+
env: T,
|
|
133
|
+
): T {
|
|
134
|
+
const out: Record<string, string | undefined> = {}
|
|
135
|
+
for (const [name, value] of Object.entries(env)) {
|
|
136
|
+
if (BASH_STARTUP_VARIABLES.has(name) || name.startsWith('BASH_FUNC_')) continue
|
|
137
|
+
out[name] = value
|
|
138
|
+
}
|
|
139
|
+
return out as T
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The command a sandbox runs for one command line: bash when the guest has
|
|
144
|
+
* it, `/bin/sh` otherwise. The rules read a sandboxed line in the `sh`
|
|
145
|
+
* dialect, which holds for both. The command is passed as an argument, never
|
|
146
|
+
* spliced into the launcher's text.
|
|
147
|
+
*
|
|
148
|
+
* The launcher does not `unset` the startup variables: where `/bin/sh` is
|
|
149
|
+
* bash, `SHELLOPTS` arriving in the environment is readonly and `unset`
|
|
150
|
+
* fails. The guest's environment is an allowlist plus the host's `env`, so
|
|
151
|
+
* callers drop them from that `env` with {@link withoutBashStartup}.
|
|
152
|
+
*/
|
|
153
|
+
export const SANDBOX_SHELL_LAUNCHER =
|
|
154
|
+
'if command -v bash >/dev/null 2>&1; then exec bash -c "$1"; fi; exec /bin/sh -c "$1"'
|
|
155
|
+
|
|
156
|
+
export function sandboxShellSpawn(command: string): {
|
|
157
|
+
readonly file: string
|
|
158
|
+
readonly args: string[]
|
|
159
|
+
} {
|
|
160
|
+
return { file: '/bin/sh', args: ['-c', SANDBOX_SHELL_LAUNCHER, 'sh', command] }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The dialect a `bash` tool command is read in. */
|
|
164
|
+
export function bashToolDialect(context: { readonly sandboxed: boolean }): ShellDialect {
|
|
165
|
+
return context.sandboxed ? 'sh' : hostCommandShell().dialect
|
|
166
|
+
}
|
package/src/tools/defineTool.ts
CHANGED
|
@@ -61,6 +61,8 @@ export interface DefineToolOptions<S extends z.ZodType> {
|
|
|
61
61
|
* hand-written definitions can use.
|
|
62
62
|
*/
|
|
63
63
|
commandArgument?: string
|
|
64
|
+
/** The shell the command argument runs in; see {@link ToolDefinition.commandDialect}. */
|
|
65
|
+
commandDialect?: ToolDefinition['commandDialect']
|
|
64
66
|
/** The argument holding a filesystem path; see {@link ToolDefinition.pathArgument}. */
|
|
65
67
|
pathArgument?: string
|
|
66
68
|
/** The argument asking to leave the sandbox; see {@link ToolDefinition.sandboxEscapeArgument}. */
|
|
@@ -68,6 +70,35 @@ export interface DefineToolOptions<S extends z.ZodType> {
|
|
|
68
70
|
execute(input: z.infer<S>, context: ToolContext): Promise<ToolResult>
|
|
69
71
|
}
|
|
70
72
|
|
|
73
|
+
/**
|
|
74
|
+
* The `isDestructive` functions built from a literal `destructive: true`.
|
|
75
|
+
*
|
|
76
|
+
* Such a tool is destructive for EVERY input, so no call of it can ever be
|
|
77
|
+
* approved without review — and a grant that names it (a skill's
|
|
78
|
+
* `allowed-tools: Write`) would be a promise the review phase never keeps.
|
|
79
|
+
* Kept here rather than as a field on the definition so the public
|
|
80
|
+
* `ToolDefinition` shape does not change; see {@link isAlwaysDestructive}.
|
|
81
|
+
*/
|
|
82
|
+
const ALWAYS_DESTRUCTIVE = new WeakSet<object>()
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Whether a tool declares every call destructive, whatever the input.
|
|
86
|
+
*
|
|
87
|
+
* Known only for a tool built by {@link defineTool} with `destructive: true`.
|
|
88
|
+
* A hand-written definition, or one whose flag depends on the input, answers
|
|
89
|
+
* `false`: its calls are still judged one by one, so nothing is lost but an
|
|
90
|
+
* early warning.
|
|
91
|
+
*/
|
|
92
|
+
export function isAlwaysDestructive(tool: Pick<ToolDefinition, 'isDestructive'>): boolean {
|
|
93
|
+
return tool.isDestructive !== undefined && ALWAYS_DESTRUCTIVE.has(tool.isDestructive)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function constantDestructive(value: boolean): () => boolean {
|
|
97
|
+
const fn = () => value
|
|
98
|
+
if (value) ALWAYS_DESTRUCTIVE.add(fn)
|
|
99
|
+
return fn
|
|
100
|
+
}
|
|
101
|
+
|
|
71
102
|
export function defineTool<S extends z.ZodType>(
|
|
72
103
|
options: DefineToolOptions<S>,
|
|
73
104
|
): ToolDefinition<z.infer<S>> {
|
|
@@ -84,6 +115,7 @@ export function defineTool<S extends z.ZodType>(
|
|
|
84
115
|
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
|
|
85
116
|
...(options.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}),
|
|
86
117
|
...(options.commandArgument !== undefined ? { commandArgument: options.commandArgument } : {}),
|
|
118
|
+
...(options.commandDialect !== undefined ? { commandDialect: options.commandDialect } : {}),
|
|
87
119
|
...(options.pathArgument !== undefined ? { pathArgument: options.pathArgument } : {}),
|
|
88
120
|
...(options.sandboxEscapeArgument !== undefined
|
|
89
121
|
? { sandboxEscapeArgument: options.sandboxEscapeArgument }
|
|
@@ -102,7 +134,7 @@ export function defineTool<S extends z.ZodType>(
|
|
|
102
134
|
isDestructive:
|
|
103
135
|
typeof options.destructive === 'function'
|
|
104
136
|
? options.destructive
|
|
105
|
-
: (
|
|
137
|
+
: constantDestructive(options.destructive as boolean),
|
|
106
138
|
isConcurrencySafe: () => options.concurrencySafe,
|
|
107
139
|
|
|
108
140
|
async execute(input: TInput, context: ToolContext): Promise<ToolResult> {
|
package/src/types/hitl/index.ts
CHANGED
|
@@ -40,6 +40,15 @@ export type HITLResumeDecision =
|
|
|
40
40
|
* person said yes to a batch that showed the escape.
|
|
41
41
|
*/
|
|
42
42
|
confirmedEscalations?: readonly string[]
|
|
43
|
+
/**
|
|
44
|
+
* Ids of calls approved on the strength of a skill's `allowed-tools`
|
|
45
|
+
* grant ({@link ToolCallSummary.skillGrant}), with nobody asked.
|
|
46
|
+
*
|
|
47
|
+
* The kernel writes each one to the session's audit trail naming the
|
|
48
|
+
* skill, and only for a call that actually carried the grant — an id
|
|
49
|
+
* listed here for an unmarked call is ignored rather than trusted.
|
|
50
|
+
*/
|
|
51
|
+
skillGranted?: readonly string[]
|
|
43
52
|
}
|
|
44
53
|
| {
|
|
45
54
|
action: 'modify_tools'
|
|
@@ -126,6 +135,18 @@ export interface ToolCallSummary {
|
|
|
126
135
|
* approve it on their own. A `deny` rule still refuses it outright.
|
|
127
136
|
*/
|
|
128
137
|
escalation?: ToolCallEscalation
|
|
138
|
+
/**
|
|
139
|
+
* Present when a skill loaded earlier in this turn pre-approved this call
|
|
140
|
+
* through its `allowed-tools`, and nothing stronger stands in the way.
|
|
141
|
+
*
|
|
142
|
+
* Marked only on a call the operator's policy left to review (no `deny`,
|
|
143
|
+
* no explicit `ask` rule), that is not destructive and that carries no
|
|
144
|
+
* {@link escalation}. The review policy decides what the mark is worth:
|
|
145
|
+
* `createReviewHandler` approves a batch without asking when every call
|
|
146
|
+
* it would have asked about carries one, and still refuses under `plan`
|
|
147
|
+
* and `strict`. A host's own handler may ignore it.
|
|
148
|
+
*/
|
|
149
|
+
skillGrant?: { readonly skill: string }
|
|
129
150
|
}
|
|
130
151
|
|
|
131
152
|
/** See {@link ToolCallSummary.escalation}. */
|
|
@@ -16,6 +16,16 @@ export interface StreamChunk {
|
|
|
16
16
|
id: string
|
|
17
17
|
name: 'web_search'
|
|
18
18
|
status: 'running' | 'completed' | 'failed'
|
|
19
|
+
/**
|
|
20
|
+
* What the provider searched for, when it says. Often absent on
|
|
21
|
+
* `running` and present on the terminal chunk: a provider may decide
|
|
22
|
+
* the query while the call is already under way.
|
|
23
|
+
*/
|
|
24
|
+
query?: string
|
|
25
|
+
/** The page the provider opened or searched within, for a page action rather than a query. */
|
|
26
|
+
url?: string
|
|
27
|
+
/** How many sources the provider reported for this call. Absent means unknown, not zero. */
|
|
28
|
+
results?: number
|
|
19
29
|
}
|
|
20
30
|
|
|
21
31
|
content?: string
|
package/src/types/tool/index.ts
CHANGED
|
@@ -77,6 +77,8 @@ export interface SkillRegistryRef {
|
|
|
77
77
|
invocation?: 'model' | 'operator' | 'both'
|
|
78
78
|
}
|
|
79
79
|
body?: string
|
|
80
|
+
/** The skill's directory, which `${CLAUDE_SKILL_DIR}` in `allowed-tools` names. */
|
|
81
|
+
dirPath?: string
|
|
80
82
|
}
|
|
81
83
|
}
|
|
82
84
|
| undefined
|
|
@@ -473,18 +475,56 @@ export interface ToolContext {
|
|
|
473
475
|
}
|
|
474
476
|
|
|
475
477
|
/**
|
|
476
|
-
*
|
|
478
|
+
* Formerly: narrow the turn's tools to what a skill's `allowed-tools`
|
|
479
|
+
* named. That reading was backwards — the field pre-approves, it never
|
|
480
|
+
* restricts — and the kernel no longer supplies this member, so a tool
|
|
481
|
+
* that calls it through `?.` does nothing.
|
|
477
482
|
*
|
|
478
|
-
*
|
|
479
|
-
*
|
|
480
|
-
*
|
|
481
|
-
* retroactively refuse them.
|
|
483
|
+
* @deprecated Never supplied by the kernel since `allowed-tools` became a
|
|
484
|
+
* pre-approval. Use {@link ToolContext.grantSkillTools}. Removed in the
|
|
485
|
+
* next major.
|
|
482
486
|
*/
|
|
483
487
|
adoptSkillScope?: (scope: {
|
|
484
488
|
skill: string
|
|
485
489
|
allowedTools: readonly string[]
|
|
486
490
|
}) => void
|
|
487
491
|
|
|
492
|
+
/**
|
|
493
|
+
* Pre-approve what a loaded skill's `allowed-tools` names, for the rest of
|
|
494
|
+
* this turn.
|
|
495
|
+
*
|
|
496
|
+
* Called by the `skill` tool. It never narrows anything: every tool the
|
|
497
|
+
* turn had stays callable, and a call the grant does not cover is reviewed
|
|
498
|
+
* exactly as before. A covered call skips the approval prompt, but not an
|
|
499
|
+
* operator `deny` or `ask` rule, plan mode, `strict` mode, a destructive
|
|
500
|
+
* call or one that reaches outside the turn's roots or sandbox. Each call
|
|
501
|
+
* approved this way is written to the session's audit trail naming the
|
|
502
|
+
* skill.
|
|
503
|
+
*
|
|
504
|
+
* Returns what would be granted and what was ignored (an unknown tool
|
|
505
|
+
* name, a pattern on a tool without a command line, a tool every call of
|
|
506
|
+
* which is destructive), so the tool can tell the model. Nothing is
|
|
507
|
+
* recorded until `commit()` is called: the caller commits only once the
|
|
508
|
+
* skill's instructions have actually been delivered, so a load that fails
|
|
509
|
+
* afterwards leaves no approval behind. Absent outside a turn, where
|
|
510
|
+
* there is nothing to grant into.
|
|
511
|
+
*/
|
|
512
|
+
grantSkillTools?: (grant: {
|
|
513
|
+
readonly skill: string
|
|
514
|
+
/** The parsed entries, as `parseAllowedTools` returns them. */
|
|
515
|
+
readonly allowedTools: readonly string[]
|
|
516
|
+
/** The skill's directory, for `${CLAUDE_SKILL_DIR}` / `${NAMZU_SKILL_DIR}`. */
|
|
517
|
+
readonly skillDirectory?: string
|
|
518
|
+
}) => {
|
|
519
|
+
readonly granted: readonly string[]
|
|
520
|
+
readonly ignored: readonly {
|
|
521
|
+
readonly entry: string
|
|
522
|
+
readonly reason: string
|
|
523
|
+
}[]
|
|
524
|
+
/** Record the grant in the turn. Idempotent. */
|
|
525
|
+
readonly commit: () => void
|
|
526
|
+
}
|
|
527
|
+
|
|
488
528
|
/**
|
|
489
529
|
* Effective model-visible character cap for this tool result.
|
|
490
530
|
*
|
|
@@ -677,6 +717,14 @@ export interface ToolResult {
|
|
|
677
717
|
workingState?: readonly import('../../compaction/types.js').WorkingStatePin[]
|
|
678
718
|
}
|
|
679
719
|
|
|
720
|
+
/**
|
|
721
|
+
* The shell a command line will run in, as the permission rules read it.
|
|
722
|
+
* `bash` reads it as bash does. `sh` reads it for a shell that may be bash or
|
|
723
|
+
* a POSIX shell such as `dash`: every construct the two read differently
|
|
724
|
+
* makes the line opaque, so a line that is not opaque means the same in both.
|
|
725
|
+
*/
|
|
726
|
+
export type ShellDialect = 'bash' | 'sh'
|
|
727
|
+
|
|
680
728
|
export interface ToolDefinition<TInput = unknown> extends ToolPresentation<TInput> {
|
|
681
729
|
name: string
|
|
682
730
|
description: string
|
|
@@ -737,6 +785,20 @@ export interface ToolDefinition<TInput = unknown> extends ToolPresentation<TInpu
|
|
|
737
785
|
* chaining — has nothing to decompose and must not claim otherwise.
|
|
738
786
|
*/
|
|
739
787
|
commandArgument?: string
|
|
788
|
+
/**
|
|
789
|
+
* The shell {@link commandArgument} runs in, as the dialect the permission
|
|
790
|
+
* rules must read it in.
|
|
791
|
+
*
|
|
792
|
+
* A rule reads a command line before it runs, and the reading is only
|
|
793
|
+
* right for the shell that runs it: `$'\x3b'`, `|&` and `&>` mean one
|
|
794
|
+
* thing to bash and another to `dash`. `bash` reads the line as bash
|
|
795
|
+
* does. `sh`, the default when this is absent, reads it for a shell that
|
|
796
|
+
* may be bash or a POSIX shell, and every construct the two read
|
|
797
|
+
* differently makes the line opaque, so no allow rule approves it. The
|
|
798
|
+
* shipped `bash` tool answers `bash` when it will spawn bash on the host
|
|
799
|
+
* and `sh` inside a sandbox, whose guest may not have bash.
|
|
800
|
+
*/
|
|
801
|
+
commandDialect?: (context: { readonly sandboxed: boolean }) => ShellDialect
|
|
740
802
|
/**
|
|
741
803
|
* The argument that holds a filesystem path the tool resolves against the
|
|
742
804
|
* turn's roots (the working directory and the added directories).
|