@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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { join } from 'node:path'
|
|
2
2
|
import type { Span } from '@opentelemetry/api'
|
|
3
3
|
import type { AuthorizationGate } from '../../authorization/gate.js'
|
|
4
|
+
import { type SkillGrantSet, compileSkillGrant } from '../../authorization/skill-grant.js'
|
|
4
5
|
import { extractFromToolCall, extractFromToolResult } from '../../compaction/extractor.js'
|
|
5
6
|
import type { WorkingStateManager } from '../../compaction/manager.js'
|
|
6
7
|
import { GENAI, NAMZU } from '../../constants/telemetry/index.js'
|
|
@@ -10,7 +11,8 @@ import { ProbeVetoError } from '../../probe/errors.js'
|
|
|
10
11
|
import { probe as defaultProbeRegistry } from '../../probe/registry.js'
|
|
11
12
|
import type { ProbeEnforcement } from '../../probe/registry.js'
|
|
12
13
|
import type { ActivityStore } from '../../store/activity/memory.js'
|
|
13
|
-
import {
|
|
14
|
+
import { sandboxShellSpawn, withoutBashStartup } from '../../tools/command-shell.js'
|
|
15
|
+
import { isAlwaysDestructive } from '../../tools/defineTool.js'
|
|
14
16
|
import { createFileReadTracker } from '../../tools/file-read-tracker.js'
|
|
15
17
|
import { pathOutsideRoots, toolRoots } from '../../tools/paths.js'
|
|
16
18
|
import type { ToolResultGuardrailSpec } from '../../types/guardrail/index.js'
|
|
@@ -33,6 +35,7 @@ import type {
|
|
|
33
35
|
FileReadTracker,
|
|
34
36
|
PreparedToolExecution,
|
|
35
37
|
RequestToolPause,
|
|
38
|
+
ShellDialect,
|
|
36
39
|
SkillRegistryRef,
|
|
37
40
|
ToolContext,
|
|
38
41
|
ToolDispatchOptions,
|
|
@@ -449,6 +452,12 @@ export interface ToolExecutorConfig {
|
|
|
449
452
|
authorizationGate?: AuthorizationGate
|
|
450
453
|
/** Durable refusal sink paired with {@link authorizationGate}. */
|
|
451
454
|
recordAudit?: (input: AuditEventInput) => Promise<unknown>
|
|
455
|
+
/**
|
|
456
|
+
* Where the `skill` tool's `allowed-tools` pre-approvals are recorded for
|
|
457
|
+
* this turn. The review phase reads the same set. Absent: a loaded skill
|
|
458
|
+
* grants nothing, and its tool says so.
|
|
459
|
+
*/
|
|
460
|
+
skillGrants?: SkillGrantSet
|
|
452
461
|
}
|
|
453
462
|
|
|
454
463
|
/**
|
|
@@ -692,58 +701,83 @@ export class ToolExecutor {
|
|
|
692
701
|
private batchMode?: PermissionMode
|
|
693
702
|
|
|
694
703
|
/**
|
|
695
|
-
* The
|
|
696
|
-
*
|
|
697
|
-
* `allowed-tools` was parsed, stored and rendered into the prompt, and
|
|
698
|
-
* read by nothing — advice phrased as a declaration. This is what makes
|
|
699
|
-
* it a restriction, on the same line that already enforces the step's
|
|
700
|
-
* list, because a narrowing the model can decline is not one.
|
|
704
|
+
* The step's list where it has one; the turn's is the default.
|
|
701
705
|
*
|
|
702
|
-
*
|
|
703
|
-
*
|
|
704
|
-
*
|
|
705
|
-
* and refusing half of it teaches nothing except that tools fail at
|
|
706
|
-
* random. `adoptedInBatch` is compared against the batch counter, so the
|
|
707
|
-
* scope takes effect from the next one.
|
|
708
|
-
*
|
|
709
|
-
* **`adoptedInBatch` is redundant TODAY and kept deliberately**, the same
|
|
710
|
-
* bargain `batchMode` above documents. `buildToolContext()` runs once per
|
|
711
|
-
* batch, so every call in a batch already shares one `allowedTools` array
|
|
712
|
-
* computed before any of them could adopt anything — remove this
|
|
713
|
-
* comparison and no test changes, because the guarantee currently comes
|
|
714
|
-
* from where the context happens to be built rather than from here.
|
|
715
|
-
* Moving the context into the per-call spread is a plausible refactor,
|
|
716
|
-
* and it would silently produce a batch whose second half is refused for
|
|
717
|
-
* a scope its first half installed. That is precisely the incoherent
|
|
718
|
-
* batch this line exists to make impossible.
|
|
706
|
+
* A loaded skill no longer narrows this. Its `allowed-tools` used to be
|
|
707
|
+
* intersected in here from the next batch on, which read the field as a
|
|
708
|
+
* restriction when it is a pre-approval; see `grantSkillTools`.
|
|
719
709
|
*/
|
|
720
|
-
private
|
|
721
|
-
|
|
722
|
-
allowedTools: readonly string[]
|
|
723
|
-
adoptedInBatch: number
|
|
710
|
+
private effectiveAllowedTools(): readonly string[] | undefined {
|
|
711
|
+
return this.stepAllowedTools ?? this.config.allowedTools
|
|
724
712
|
}
|
|
725
|
-
private batchCounter = 0
|
|
726
713
|
|
|
727
714
|
/**
|
|
728
|
-
*
|
|
715
|
+
* Compile a skill's `allowed-tools` into pre-approvals for the rest of the
|
|
716
|
+
* turn, say what they would be, and record them only on `commit()`.
|
|
729
717
|
*
|
|
730
|
-
*
|
|
731
|
-
*
|
|
732
|
-
*
|
|
733
|
-
*
|
|
734
|
-
*
|
|
735
|
-
*
|
|
718
|
+
* Names resolve against THIS turn's registry, case-insensitively and
|
|
719
|
+
* through the Agent Skills aliases (`Read` is `read`, `WebFetch` is
|
|
720
|
+
* `web_fetch`), so a grant can only ever name a tool the turn already has.
|
|
721
|
+
* An entry that resolves to nothing, or to a tool every call of which is
|
|
722
|
+
* destructive (and therefore always reviewed), is reported and grants
|
|
723
|
+
* nothing.
|
|
736
724
|
*
|
|
737
|
-
*
|
|
738
|
-
*
|
|
739
|
-
* the
|
|
725
|
+
* Two steps because the `skill` tool can still fail after it knows what
|
|
726
|
+
* to say — its instructions may not fit the output budget — and a skill
|
|
727
|
+
* the model never received must not have approved anything.
|
|
740
728
|
*/
|
|
741
|
-
private
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
729
|
+
private grantSkillTools(grant: {
|
|
730
|
+
readonly skill: string
|
|
731
|
+
readonly allowedTools: readonly string[]
|
|
732
|
+
readonly skillDirectory?: string
|
|
733
|
+
}): {
|
|
734
|
+
readonly granted: readonly string[]
|
|
735
|
+
readonly ignored: readonly {
|
|
736
|
+
readonly entry: string
|
|
737
|
+
readonly reason: string
|
|
738
|
+
}[]
|
|
739
|
+
readonly commit: () => void
|
|
740
|
+
} {
|
|
741
|
+
const grants = this.config.skillGrants
|
|
742
|
+
if (!grants) return { granted: [], ignored: [], commit: () => {} }
|
|
743
|
+
const tools = this.config.tools
|
|
744
|
+
const byLowerName = new Map<string, string>()
|
|
745
|
+
for (const name of tools.listNames()) byLowerName.set(name.toLowerCase(), name)
|
|
746
|
+
// A grant can only ever name a tool this turn — or this step — can call.
|
|
747
|
+
// The registry holds more than that when `allowedTools` withholds some,
|
|
748
|
+
// and telling the model a withheld tool is pre-approved is a promise the
|
|
749
|
+
// executor will refuse to keep.
|
|
750
|
+
const allowed = this.effectiveAllowedTools()
|
|
751
|
+
const compiled = compileSkillGrant(grant.allowedTools, {
|
|
752
|
+
resolveTool: (name) => {
|
|
753
|
+
const registered = byLowerName.get(name.toLowerCase())
|
|
754
|
+
if (registered === undefined) return undefined
|
|
755
|
+
const definition = tools.get(registered)
|
|
756
|
+
const commandArgument = definition?.commandArgument
|
|
757
|
+
return {
|
|
758
|
+
name: registered,
|
|
759
|
+
...(allowed !== undefined && !allowed.includes(registered) ? { unavailable: true } : {}),
|
|
760
|
+
...(commandArgument === undefined ? {} : { commandArgument }),
|
|
761
|
+
...(definition && isAlwaysDestructive(definition) ? { alwaysDestructive: true } : {}),
|
|
762
|
+
}
|
|
763
|
+
},
|
|
764
|
+
...(grant.skillDirectory ? { skillDirectory: grant.skillDirectory } : {}),
|
|
765
|
+
})
|
|
766
|
+
return {
|
|
767
|
+
granted: compiled.entries.map((entry) =>
|
|
768
|
+
entry.pattern === undefined ? entry.tool : entry.declared,
|
|
769
|
+
),
|
|
770
|
+
ignored: compiled.ignored,
|
|
771
|
+
commit: () => {
|
|
772
|
+
grants.grant(grant.skill, compiled)
|
|
773
|
+
if (compiled.ignored.length > 0) {
|
|
774
|
+
this.log.warn('Skill allowed-tools entries were ignored', {
|
|
775
|
+
'namzu.skill.name': grant.skill,
|
|
776
|
+
'namzu.skill.ignored': compiled.ignored.map((item) => item.entry),
|
|
777
|
+
})
|
|
778
|
+
}
|
|
779
|
+
},
|
|
780
|
+
}
|
|
747
781
|
}
|
|
748
782
|
|
|
749
783
|
private resolvePermissionMode(): PermissionMode {
|
|
@@ -757,9 +791,20 @@ export class ToolExecutor {
|
|
|
757
791
|
toolName,
|
|
758
792
|
toolInput: input,
|
|
759
793
|
toolDef: this.config.tools.get(toolName),
|
|
794
|
+
commandDialect: this.commandDialect(toolName),
|
|
760
795
|
})
|
|
761
796
|
}
|
|
762
797
|
|
|
798
|
+
/**
|
|
799
|
+
* The shell a tool's command line will run in this turn, for the
|
|
800
|
+
* permission rules to read it in. A tool that does not say is `sh`, the
|
|
801
|
+
* reading that holds for any POSIX shell.
|
|
802
|
+
*/
|
|
803
|
+
commandDialect(toolName: string): ShellDialect {
|
|
804
|
+
const tool = this.config.tools.get(toolName)
|
|
805
|
+
return tool?.commandDialect?.({ sandboxed: this.config.sandbox !== undefined }) ?? 'sh'
|
|
806
|
+
}
|
|
807
|
+
|
|
763
808
|
/**
|
|
764
809
|
* Resolve repairs and pre-tool hooks, then decode each call exactly once.
|
|
765
810
|
* The returned projection is what policy and a human review; execution later
|
|
@@ -883,8 +928,6 @@ export class ToolExecutor {
|
|
|
883
928
|
}
|
|
884
929
|
assertUniqueToolCallIds(toolCalls)
|
|
885
930
|
|
|
886
|
-
this.batchCounter += 1
|
|
887
|
-
|
|
888
931
|
// Sampled here, once, and held for every call below. See the note on
|
|
889
932
|
// `permissionMode` in the config type.
|
|
890
933
|
this.batchMode = this.resolvePermissionMode()
|
|
@@ -1212,6 +1255,7 @@ export class ToolExecutor {
|
|
|
1212
1255
|
toolName: name,
|
|
1213
1256
|
toolInput: preparedInput,
|
|
1214
1257
|
toolDef: this.config.tools.get(name),
|
|
1258
|
+
commandDialect: this.commandDialect(name),
|
|
1215
1259
|
})
|
|
1216
1260
|
if (gateResult && gateResult.decision !== 'allow') {
|
|
1217
1261
|
const reason =
|
|
@@ -1442,11 +1486,11 @@ export class ToolExecutor {
|
|
|
1442
1486
|
// Same precedence the request already uses when it decides which
|
|
1443
1487
|
// schemas to send, so the menu and the kitchen agree.
|
|
1444
1488
|
allowedTools: this.effectiveAllowedTools(),
|
|
1445
|
-
//
|
|
1446
|
-
//
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1489
|
+
// A skill loaded during this batch pre-approves calls from the NEXT
|
|
1490
|
+
// review on; this batch was already reviewed. See `grantSkillTools`.
|
|
1491
|
+
...(this.config.skillGrants
|
|
1492
|
+
? { grantSkillTools: (grant) => this.grantSkillTools(grant) }
|
|
1493
|
+
: {}),
|
|
1450
1494
|
maxToolOutputChars: this.config.maxToolOutputChars ?? DEFAULT_MAX_TOOL_OUTPUT_CHARS,
|
|
1451
1495
|
// The turn's screens, defaulted HERE rather than on the registry: a
|
|
1452
1496
|
// host builds the registry and hands it over, so a registry-side
|
|
@@ -1498,11 +1542,11 @@ export class ToolExecutor {
|
|
|
1498
1542
|
readonly env?: Record<string, string>
|
|
1499
1543
|
}): JobProcess =>
|
|
1500
1544
|
(this.config.sandbox as Sandbox).spawnDetached?.(
|
|
1501
|
-
|
|
1502
|
-
|
|
1545
|
+
sandboxShellSpawn(job.command).file,
|
|
1546
|
+
sandboxShellSpawn(job.command).args,
|
|
1503
1547
|
{
|
|
1504
1548
|
cwd: job.workingDirectory,
|
|
1505
|
-
...(job.env ? { env: job.env } : {}),
|
|
1549
|
+
...(job.env ? { env: withoutBashStartup(job.env) } : {}),
|
|
1506
1550
|
},
|
|
1507
1551
|
) as JobProcess,
|
|
1508
1552
|
}
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
assertBudgetEnforceable,
|
|
7
7
|
} from '../../advisory/index.js'
|
|
8
8
|
import { AuthorizationGate } from '../../authorization/gate.js'
|
|
9
|
+
import { SkillGrantSet } from '../../authorization/skill-grant.js'
|
|
9
10
|
import { repairToolMessageHistory, toolHistoryRepairChanged } from '../../compaction/dangling.js'
|
|
10
11
|
import { extractFromUserMessage } from '../../compaction/extractor.js'
|
|
11
12
|
import { WorkingStateManager } from '../../compaction/manager.js'
|
|
@@ -1197,8 +1198,13 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
|
|
|
1197
1198
|
: undefined
|
|
1198
1199
|
awaitedJobs?.attach()
|
|
1199
1200
|
|
|
1201
|
+
// Turn-scoped, like `toolGrants` below: what a skill loaded in this turn
|
|
1202
|
+
// pre-approves ends with the turn. Shared by the executor, where the
|
|
1203
|
+
// `skill` tool records a grant, and the review phase, which reads it.
|
|
1204
|
+
const skillGrants = new SkillGrantSet()
|
|
1200
1205
|
const toolExecutor = ToolingBootstrap.init(
|
|
1201
1206
|
{
|
|
1207
|
+
skillGrants,
|
|
1202
1208
|
tools: params.tools,
|
|
1203
1209
|
sessionId: ctx.sessionId,
|
|
1204
1210
|
turnId: ctx.turnId,
|
|
@@ -1496,6 +1502,7 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
|
|
|
1496
1502
|
// Turn-scoped. An approval is a statement about this turn's work;
|
|
1497
1503
|
// carrying one into a later turn would be reuse nobody agreed to.
|
|
1498
1504
|
toolGrants: new ToolGrantSet(),
|
|
1505
|
+
skillGrants,
|
|
1499
1506
|
...(params.reviewAllowedCalls ? { reviewAllowedCalls: params.reviewAllowedCalls } : {}),
|
|
1500
1507
|
// Turn-scoped for the same reason. A repeat count carried into a later
|
|
1501
1508
|
// run is a claim about work nobody repeated, and a module-level map
|
|
@@ -1826,6 +1826,11 @@ export class IterationOrchestrator {
|
|
|
1826
1826
|
private rememberUserMessage(message: Message, arriving = true): void {
|
|
1827
1827
|
if (!isOperatorUserMessage(message)) return
|
|
1828
1828
|
this.latestUserMessage = message
|
|
1829
|
+
// A skill's `allowed-tools` pre-approves for the request that loaded
|
|
1830
|
+
// it. The operator speaking again — a queued message or steering
|
|
1831
|
+
// delivered into this same `query()` — is a new request, and it starts
|
|
1832
|
+
// with nothing pre-approved, exactly as a new turn would.
|
|
1833
|
+
if (arriving) this.ctx.skillGrants?.clear()
|
|
1829
1834
|
// The hook field alone does not reach the model. Preserve arrivals in
|
|
1830
1835
|
// the compaction state too, before their original message or attached
|
|
1831
1836
|
// tool result can be shed. Initial history was already extracted at seed.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AdvisoryContext } from '../../../../advisory/context.js'
|
|
2
|
+
import type { SkillGrantSet } from '../../../../authorization/skill-grant.js'
|
|
2
3
|
import type { AgentBus } from '../../../../bus/index.js'
|
|
3
4
|
import type { WorkingStateManager } from '../../../../compaction/manager.js'
|
|
4
5
|
import type { ContextReducer } from '../../../../compaction/reducer.js'
|
|
@@ -166,6 +167,12 @@ export interface IterationContext {
|
|
|
166
167
|
* asked about again. Absent on paths that do not review tools.
|
|
167
168
|
*/
|
|
168
169
|
readonly toolGrants?: ToolGrantSet
|
|
170
|
+
/**
|
|
171
|
+
* What skills loaded in this turn pre-approve (`allowed-tools`). Read by
|
|
172
|
+
* the review phase to mark covered calls; the review policy decides
|
|
173
|
+
* whether a mark skips the prompt. Absent: nothing is marked.
|
|
174
|
+
*/
|
|
175
|
+
readonly skillGrants?: SkillGrantSet
|
|
169
176
|
/** See `QueryParams.reviewAllowedCalls`. Absent: allowed and granted batches skip review. */
|
|
170
177
|
readonly reviewAllowedCalls?: () => boolean
|
|
171
178
|
/**
|
|
@@ -2,6 +2,7 @@ import type { AuthorizationGate } from '../../../../authorization/index.js'
|
|
|
2
2
|
import type { ToolCallSummary } from '../../../../types/hitl/index.js'
|
|
3
3
|
import type { ChatCompletionResponse } from '../../../../types/provider/index.js'
|
|
4
4
|
import type { SessionEvent } from '../../../../types/session/index.js'
|
|
5
|
+
import type { ShellDialect } from '../../../../types/tool/index.js'
|
|
5
6
|
import type { PreparedToolBatch, ToolCallDenials } from '../../executor.js'
|
|
6
7
|
import {
|
|
7
8
|
awaitProjectInstructionCallback,
|
|
@@ -53,6 +54,15 @@ export async function* runToolReview(
|
|
|
53
54
|
): AsyncGenerator<SessionEvent, ToolReviewOutcome> {
|
|
54
55
|
let executed: readonly import('../../executor.js').ToolCallOutcome[] = []
|
|
55
56
|
let toolMs = 0
|
|
57
|
+
// The shell each call's command line will run in, for the rules and the
|
|
58
|
+
// skill grants to read it the same way. A test double without the method
|
|
59
|
+
// leaves it unset, which reads the line for any POSIX shell.
|
|
60
|
+
const dialectFor = (toolName: string): { commandDialect?: ShellDialect } => {
|
|
61
|
+
const executor = ctx.toolExecutor as { commandDialect?: (name: string) => ShellDialect }
|
|
62
|
+
return typeof executor.commandDialect === 'function'
|
|
63
|
+
? { commandDialect: executor.commandDialect(toolName) }
|
|
64
|
+
: {}
|
|
65
|
+
}
|
|
56
66
|
|
|
57
67
|
const finish = (decision: ToolReviewDecision): ToolReviewOutcome => ({
|
|
58
68
|
decision,
|
|
@@ -255,6 +265,7 @@ export async function* runToolReview(
|
|
|
255
265
|
toolName: tc.name,
|
|
256
266
|
toolInput: tc.input,
|
|
257
267
|
toolDef: ctx.tools.get(tc.name),
|
|
268
|
+
...dialectFor(tc.name),
|
|
258
269
|
}),
|
|
259
270
|
}))
|
|
260
271
|
for (const gr of gateResults) {
|
|
@@ -331,6 +342,24 @@ export async function* runToolReview(
|
|
|
331
342
|
})
|
|
332
343
|
}
|
|
333
344
|
|
|
345
|
+
// A skill's `allowed-tools` pre-approval, marked on the calls it covers
|
|
346
|
+
// and left for the review policy to honour. Marked, never decided here:
|
|
347
|
+
// only the policy knows the mode, and `plan` and `strict` must refuse a
|
|
348
|
+
// call a skill granted exactly as they refuse any other. Nothing stronger
|
|
349
|
+
// may stand in the way — an operator's deny or explicit ask, a
|
|
350
|
+
// destructive call, a path outside the roots or a sandbox escape all
|
|
351
|
+
// leave the call unmarked, so it is reviewed as though no skill had
|
|
352
|
+
// spoken.
|
|
353
|
+
if (ctx.skillGrants && ctx.skillGrants.size > 0) {
|
|
354
|
+
for (const tc of toolCallSummaries) {
|
|
355
|
+
if (gateDenied.has(tc.id)) continue
|
|
356
|
+
if (tc.authorization?.decision === 'deny' || tc.authorization?.explicitReview) continue
|
|
357
|
+
if (tc.isDestructive || tc.escalation !== undefined) continue
|
|
358
|
+
const skill = ctx.skillGrants.coveringSkill(tc, ctx.tools.get(tc.name), dialectFor(tc.name))
|
|
359
|
+
if (skill !== undefined) tc.skillGrant = { skill }
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
334
363
|
// Already approved, at a scope the approver chose — and nothing the
|
|
335
364
|
// operator's policy denied, because `gateDenied` is checked first.
|
|
336
365
|
// Re-asking about a call somebody has already said yes to is how an
|
|
@@ -490,6 +519,7 @@ export async function* runToolReview(
|
|
|
490
519
|
toolName: summary.name,
|
|
491
520
|
toolInput: summary.input,
|
|
492
521
|
toolDef: ctx.tools.get(summary.name),
|
|
522
|
+
...dialectFor(summary.name),
|
|
493
523
|
})
|
|
494
524
|
if (gateResult.decision === 'allow') continue
|
|
495
525
|
const reason =
|
|
@@ -550,6 +580,21 @@ export async function* runToolReview(
|
|
|
550
580
|
if (reviewDecision.action === 'approve_tools' && reviewDecision.remember) {
|
|
551
581
|
ctx.toolGrants?.grant(reviewDecision.remember)
|
|
552
582
|
}
|
|
583
|
+
// A call nobody was asked about, approved because a skill said so,
|
|
584
|
+
// is on the record naming that skill. Only a call that carried the
|
|
585
|
+
// mark counts: a policy that lists an unmarked id has not been
|
|
586
|
+
// given a skill's word for it.
|
|
587
|
+
if (reviewDecision.action === 'approve_tools' && reviewDecision.skillGranted) {
|
|
588
|
+
const listed = new Set(reviewDecision.skillGranted)
|
|
589
|
+
for (const tc of toolCallSummaries) {
|
|
590
|
+
if (!listed.has(tc.id) || !tc.skillGrant || gateDenied.has(tc.id)) continue
|
|
591
|
+
await ctx.recorder.recordAudit({
|
|
592
|
+
what: { action: 'tool_call', tool: tc.name },
|
|
593
|
+
outcome: 'approved',
|
|
594
|
+
reason: `pre-approved by the allowed-tools of skill "${tc.skillGrant.skill}" for this turn; nobody was asked`,
|
|
595
|
+
})
|
|
596
|
+
}
|
|
597
|
+
}
|
|
553
598
|
|
|
554
599
|
await ctx.emitEvent({
|
|
555
600
|
type: 'tool_review_completed',
|
|
@@ -238,6 +238,33 @@ export interface ReviewPolicyOptions {
|
|
|
238
238
|
* in every mode that does not refuse the call outright.
|
|
239
239
|
*/
|
|
240
240
|
readonly unattendedSandboxEscape?: 'refuse' | 'allow'
|
|
241
|
+
/**
|
|
242
|
+
* Whether a skill's `allowed-tools` pre-approval stands in for a person.
|
|
243
|
+
*
|
|
244
|
+
* `'honour'` (the default) approves a batch without asking when every call
|
|
245
|
+
* that would be asked about carries `ToolCallSummary.skillGrant`. `'ignore'`
|
|
246
|
+
* asks as though no skill had been loaded — the behaviour before
|
|
247
|
+
* `allowed-tools` was read as a pre-approval, for a host that does not want
|
|
248
|
+
* repository content to reduce its prompts. `plan` and `strict` refuse
|
|
249
|
+
* either way.
|
|
250
|
+
*/
|
|
251
|
+
readonly skillGrants?: 'honour' | 'ignore'
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Whether a skill's `allowed-tools` grant may stand in for a person on this
|
|
256
|
+
* call: the kernel marked it, and nothing that outranks a skill is present.
|
|
257
|
+
* The second half repeats what the kernel checked before marking, so a mark
|
|
258
|
+
* on a persisted or host-built summary cannot carry more than it should.
|
|
259
|
+
*/
|
|
260
|
+
function isSkillGranted(tc: ToolCallSummary): boolean {
|
|
261
|
+
return (
|
|
262
|
+
tc.skillGrant !== undefined &&
|
|
263
|
+
!tc.authorization?.explicitReview &&
|
|
264
|
+
tc.authorization?.decision !== 'deny' &&
|
|
265
|
+
!tc.isDestructive &&
|
|
266
|
+
tc.escalation === undefined
|
|
267
|
+
)
|
|
241
268
|
}
|
|
242
269
|
|
|
243
270
|
/** The handler behind `createReviewPolicy`, for a host that wants only the function. */
|
|
@@ -338,6 +365,32 @@ export function createReviewHandler(options: ReviewPolicyOptions = {}): ResumeHa
|
|
|
338
365
|
if (mode === 'auto' || !prompt || remembered.all) {
|
|
339
366
|
return { action: 'approve_tools' }
|
|
340
367
|
}
|
|
368
|
+
// Every call a person would be asked about is one a skill loaded in
|
|
369
|
+
// this turn pre-approved (`allowed-tools`). Reached only in `prompt`
|
|
370
|
+
// and `accept-edits`: `plan` and `strict` refused above, so a skill's
|
|
371
|
+
// word never outranks either, and the kernel only marks a call no
|
|
372
|
+
// deny, explicit ask, destructive flag or escalation stands behind.
|
|
373
|
+
// One unmarked call and the whole batch is asked about, as always.
|
|
374
|
+
const needsPerson = request.toolCalls.filter(
|
|
375
|
+
(tc) =>
|
|
376
|
+
tc.authorization?.explicitReview ||
|
|
377
|
+
tc.isDestructive ||
|
|
378
|
+
tc.escalation !== undefined ||
|
|
379
|
+
!(
|
|
380
|
+
exempt(tc.name, tc.input) ||
|
|
381
|
+
(mode === 'accept-edits' && ACCEPT_EDITS_TOOLS.has(tc.name))
|
|
382
|
+
),
|
|
383
|
+
)
|
|
384
|
+
if (
|
|
385
|
+
options.skillGrants !== 'ignore' &&
|
|
386
|
+
needsPerson.length > 0 &&
|
|
387
|
+
needsPerson.every(isSkillGranted)
|
|
388
|
+
) {
|
|
389
|
+
return {
|
|
390
|
+
action: 'approve_tools',
|
|
391
|
+
skillGranted: needsPerson.map((tc) => tc.id),
|
|
392
|
+
}
|
|
393
|
+
}
|
|
341
394
|
const answer = await prompt({
|
|
342
395
|
sessionId: request.sessionId,
|
|
343
396
|
turnId: request.turnId,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AuthorizationGate } from '../../authorization/gate.js'
|
|
2
|
+
import type { SkillGrantSet } from '../../authorization/skill-grant.js'
|
|
2
3
|
import type { PluginLifecycleManager } from '../../plugin/lifecycle.js'
|
|
3
4
|
import type { ActivityStore } from '../../store/activity/memory.js'
|
|
4
5
|
import type { SessionId, TurnId } from '../../types/ids/index.js'
|
|
@@ -67,6 +68,8 @@ export interface ToolingBootstrapConfig {
|
|
|
67
68
|
recordAudit?: (input: AuditEventInput) => Promise<unknown>
|
|
68
69
|
/** Builds the durable-pause seam for one tool call; see ToolContext.requestPause. */
|
|
69
70
|
toolPause?: (toolUseId: string) => RequestToolPause
|
|
71
|
+
/** The turn's skill pre-approvals; see the executor's own field. */
|
|
72
|
+
skillGrants?: SkillGrantSet
|
|
70
73
|
}
|
|
71
74
|
|
|
72
75
|
export class ToolingBootstrap {
|
|
@@ -130,6 +133,7 @@ export class ToolingBootstrap {
|
|
|
130
133
|
: {}),
|
|
131
134
|
...(config.recordAudit !== undefined ? { recordAudit: config.recordAudit } : {}),
|
|
132
135
|
...(config.toolPause !== undefined ? { toolPause: config.toolPause } : {}),
|
|
136
|
+
...(config.skillGrants !== undefined ? { skillGrants: config.skillGrants } : {}),
|
|
133
137
|
},
|
|
134
138
|
activityStore,
|
|
135
139
|
emitEvent,
|
package/src/skills/loader.ts
CHANGED
|
@@ -12,6 +12,13 @@ import { type Logger, resolveLogger } from '../utils/logger.js'
|
|
|
12
12
|
|
|
13
13
|
export const SKILL_FILENAME = 'SKILL.md'
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* `allowed-tools` may be a YAML list. The Agent Skills format writes it
|
|
17
|
+
* space-separated, comma-separated or as a list, and all three must mean the
|
|
18
|
+
* same grant; the reader joins a list into the comma form.
|
|
19
|
+
*/
|
|
20
|
+
export const SKILL_FRONTMATTER_OPTIONS = { lists: ['allowed-tools'] } as const
|
|
21
|
+
|
|
15
22
|
/**
|
|
16
23
|
* How this file's errors name themselves. Passed to the shared reader so a
|
|
17
24
|
* frontmatter failure still reads as a `SKILL.md` failure — the reader is
|
|
@@ -152,7 +159,7 @@ export async function loadSkill(
|
|
|
152
159
|
): Promise<SkillLoadResult> {
|
|
153
160
|
const skillMdPath = join(dirPath, SKILL_FILENAME)
|
|
154
161
|
const raw = await readFile(skillMdPath, 'utf-8')
|
|
155
|
-
const parsed = parseFrontmatter(raw, sourceLabel(dirPath))
|
|
162
|
+
const parsed = parseFrontmatter(raw, sourceLabel(dirPath), SKILL_FRONTMATTER_OPTIONS)
|
|
156
163
|
const metadata = toSkillMetadata(parsed, dirPath)
|
|
157
164
|
|
|
158
165
|
const skill: Skill = {
|
|
@@ -6,6 +6,12 @@ import { DANGEROUS_PATTERNS } from '../../constants/tools/index.js'
|
|
|
6
6
|
import { killTree } from '../../process/kill-tree.js'
|
|
7
7
|
import { subscribeToAbort } from '../../utils/abort.js'
|
|
8
8
|
import { readPositiveIntEnv } from '../../utils/env.js'
|
|
9
|
+
import {
|
|
10
|
+
bashToolDialect,
|
|
11
|
+
hostShellSpawn,
|
|
12
|
+
sandboxShellSpawn,
|
|
13
|
+
withoutBashStartup,
|
|
14
|
+
} from '../command-shell.js'
|
|
9
15
|
import { defineTool } from '../defineTool.js'
|
|
10
16
|
import { scrubInheritedEnv } from '../env-scrub.js'
|
|
11
17
|
|
|
@@ -146,13 +152,20 @@ function execHostShell(
|
|
|
146
152
|
): Promise<{ stdout: string; stderr: string }> {
|
|
147
153
|
options.signal?.throwIfAborted()
|
|
148
154
|
return new Promise((resolve, reject) => {
|
|
149
|
-
|
|
155
|
+
// bash where the host has it, `/bin/sh` where it does not; the
|
|
156
|
+
// permission rules read the line in the matching dialect. See
|
|
157
|
+
// `../command-shell.ts`.
|
|
158
|
+
const shell = hostShellSpawn(command, options.env)
|
|
159
|
+
const spawnOptions = {
|
|
150
160
|
cwd: options.cwd,
|
|
151
|
-
env:
|
|
152
|
-
shell: true,
|
|
161
|
+
env: shell.env,
|
|
153
162
|
// killTree's negative PID must never target the caller's own group.
|
|
154
163
|
detached: process.platform !== 'win32',
|
|
155
|
-
}
|
|
164
|
+
}
|
|
165
|
+
const child =
|
|
166
|
+
shell.file === undefined
|
|
167
|
+
? spawn(command, { ...spawnOptions, shell: true })
|
|
168
|
+
: spawn(shell.file, [...shell.args], spawnOptions)
|
|
156
169
|
const captures = {
|
|
157
170
|
stdout: {
|
|
158
171
|
chunks: [] as Buffer[],
|
|
@@ -302,6 +315,8 @@ export const BashTool = defineTool({
|
|
|
302
315
|
// so a permission rule about it is a rule about several commands more often
|
|
303
316
|
// than not. Naming the argument is what lets the gate read it that way.
|
|
304
317
|
commandArgument: 'command',
|
|
318
|
+
// The shell that runs it, so the rules read the line as that shell will.
|
|
319
|
+
commandDialect: bashToolDialect,
|
|
305
320
|
// The kernel reviews a call that sets it under a sandbox every time and
|
|
306
321
|
// confirms it only by id; see `ToolDefinition.sandboxEscapeArgument`.
|
|
307
322
|
sandboxEscapeArgument: 'dangerously_disable_sandbox',
|
|
@@ -414,9 +429,12 @@ export const BashTool = defineTool({
|
|
|
414
429
|
// builtin doesn't have that requirement today.
|
|
415
430
|
const onOutput = shellProgress(context.report)
|
|
416
431
|
if (context.sandbox && !leaveSandbox) {
|
|
417
|
-
|
|
432
|
+
// bash if the guest has it, `/bin/sh` if not; read in the `sh`
|
|
433
|
+
// dialect, which holds for both.
|
|
434
|
+
const launch = sandboxShellSpawn(input.command)
|
|
435
|
+
const result = await context.sandbox.exec(launch.file, launch.args, {
|
|
418
436
|
timeout: input.timeout,
|
|
419
|
-
env: context.env,
|
|
437
|
+
...(context.env ? { env: withoutBashStartup(context.env) } : {}),
|
|
420
438
|
// Same reason as the host path below: a Stop must reach the
|
|
421
439
|
// process, not just the promise waiting on it.
|
|
422
440
|
signal: context.abortSignal,
|