@namzu/sdk 6.2.0 → 8.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 +677 -0
- package/dist/agents/ReactiveAgent.d.ts.map +1 -1
- package/dist/agents/ReactiveAgent.js +5 -0
- package/dist/agents/ReactiveAgent.js.map +1 -1
- package/dist/agents/SupervisorAgent.d.ts.map +1 -1
- package/dist/agents/SupervisorAgent.js +172 -158
- package/dist/agents/SupervisorAgent.js.map +1 -1
- package/dist/agents/__tests__/supervisor-inbox-scope.test.d.ts +2 -0
- package/dist/agents/__tests__/supervisor-inbox-scope.test.d.ts.map +1 -0
- package/dist/agents/__tests__/supervisor-inbox-scope.test.js +125 -0
- package/dist/agents/__tests__/supervisor-inbox-scope.test.js.map +1 -0
- package/dist/agents/runAgent.d.ts +19 -1
- package/dist/agents/runAgent.d.ts.map +1 -1
- package/dist/agents/runAgent.js +2 -0
- package/dist/agents/runAgent.js.map +1 -1
- package/dist/bridge/a2a/mapper.d.ts.map +1 -1
- package/dist/bridge/a2a/mapper.js +4 -0
- package/dist/bridge/a2a/mapper.js.map +1 -1
- package/dist/bridge/sse/mapper.d.ts.map +1 -1
- package/dist/bridge/sse/mapper.js +24 -0
- package/dist/bridge/sse/mapper.js.map +1 -1
- package/dist/connector/mcp/__tests__/positional-arrays.test.d.ts +2 -0
- package/dist/connector/mcp/__tests__/positional-arrays.test.d.ts.map +1 -0
- package/dist/connector/mcp/__tests__/positional-arrays.test.js +142 -0
- package/dist/connector/mcp/__tests__/positional-arrays.test.js.map +1 -0
- package/dist/connector/mcp/adapter.d.ts.map +1 -1
- package/dist/connector/mcp/adapter.js +123 -8
- package/dist/connector/mcp/adapter.js.map +1 -1
- package/dist/constants/agent/index.d.ts +5 -0
- package/dist/constants/agent/index.d.ts.map +1 -1
- package/dist/constants/agent/index.js +5 -0
- package/dist/constants/agent/index.js.map +1 -1
- package/dist/constants/plugin/index.d.ts +15 -0
- package/dist/constants/plugin/index.d.ts.map +1 -1
- package/dist/constants/plugin/index.js +15 -0
- package/dist/constants/plugin/index.js.map +1 -1
- package/dist/contracts/api.d.ts +1 -1
- package/dist/contracts/api.d.ts.map +1 -1
- package/dist/gateway/__tests__/completion-inbox.test.js +292 -2
- package/dist/gateway/__tests__/completion-inbox.test.js.map +1 -1
- package/dist/gateway/completion-inbox.d.ts +94 -6
- package/dist/gateway/completion-inbox.d.ts.map +1 -1
- package/dist/gateway/completion-inbox.js +235 -15
- package/dist/gateway/completion-inbox.js.map +1 -1
- package/dist/gateway/local.d.ts +11 -0
- package/dist/gateway/local.d.ts.map +1 -1
- package/dist/gateway/local.js +27 -1
- package/dist/gateway/local.js.map +1 -1
- package/dist/manager/agent/lifecycle.d.ts.map +1 -1
- package/dist/manager/agent/lifecycle.js +6 -0
- package/dist/manager/agent/lifecycle.js.map +1 -1
- package/dist/manager/run/persistence.d.ts +8 -0
- package/dist/manager/run/persistence.d.ts.map +1 -1
- package/dist/manager/run/persistence.js +12 -0
- package/dist/manager/run/persistence.js.map +1 -1
- package/dist/provider/thinking-support.d.ts +2 -1
- package/dist/provider/thinking-support.d.ts.map +1 -1
- package/dist/provider/thinking-support.js +14 -0
- package/dist/provider/thinking-support.js.map +1 -1
- package/dist/public-runtime.d.ts +1 -1
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +9 -1
- package/dist/public-runtime.js.map +1 -1
- package/dist/run/reporter.d.ts.map +1 -1
- package/dist/run/reporter.js +11 -0
- package/dist/run/reporter.js.map +1 -1
- package/dist/runtime/query/__tests__/completion-does-not-erase-the-answer.test.d.ts +2 -0
- package/dist/runtime/query/__tests__/completion-does-not-erase-the-answer.test.d.ts.map +1 -0
- package/dist/runtime/query/__tests__/completion-does-not-erase-the-answer.test.js +142 -0
- package/dist/runtime/query/__tests__/completion-does-not-erase-the-answer.test.js.map +1 -0
- package/dist/runtime/query/__tests__/completion-notification.test.js +414 -32
- package/dist/runtime/query/__tests__/completion-notification.test.js.map +1 -1
- package/dist/runtime/query/__tests__/context-size-on-the-wire.test.d.ts +2 -0
- package/dist/runtime/query/__tests__/context-size-on-the-wire.test.d.ts.map +1 -0
- package/dist/runtime/query/__tests__/context-size-on-the-wire.test.js +100 -0
- package/dist/runtime/query/__tests__/context-size-on-the-wire.test.js.map +1 -0
- package/dist/runtime/query/__tests__/context.test.js +18 -0
- package/dist/runtime/query/__tests__/context.test.js.map +1 -1
- package/dist/runtime/query/__tests__/effort-reaches-the-wire.test.d.ts +2 -0
- package/dist/runtime/query/__tests__/effort-reaches-the-wire.test.d.ts.map +1 -0
- package/dist/runtime/query/__tests__/effort-reaches-the-wire.test.js +118 -0
- package/dist/runtime/query/__tests__/effort-reaches-the-wire.test.js.map +1 -0
- package/dist/runtime/query/__tests__/tool-timeout.test.js +34 -0
- package/dist/runtime/query/__tests__/tool-timeout.test.js.map +1 -1
- package/dist/runtime/query/context.d.ts.map +1 -1
- package/dist/runtime/query/context.js +16 -1
- package/dist/runtime/query/context.js.map +1 -1
- package/dist/runtime/query/executor.d.ts.map +1 -1
- package/dist/runtime/query/executor.js +11 -1
- package/dist/runtime/query/executor.js.map +1 -1
- package/dist/runtime/query/guard.d.ts +28 -0
- package/dist/runtime/query/guard.d.ts.map +1 -1
- package/dist/runtime/query/guard.js +31 -0
- package/dist/runtime/query/guard.js.map +1 -1
- package/dist/runtime/query/iteration/__tests__/settle-grace.test.d.ts +2 -0
- package/dist/runtime/query/iteration/__tests__/settle-grace.test.d.ts.map +1 -0
- package/dist/runtime/query/iteration/__tests__/settle-grace.test.js +226 -0
- package/dist/runtime/query/iteration/__tests__/settle-grace.test.js.map +1 -0
- package/dist/runtime/query/iteration/index.d.ts +92 -0
- package/dist/runtime/query/iteration/index.d.ts.map +1 -1
- package/dist/runtime/query/iteration/index.js +818 -565
- package/dist/runtime/query/iteration/index.js.map +1 -1
- package/dist/runtime/query/iteration/phases/__tests__/compaction-declined.test.d.ts +2 -0
- package/dist/runtime/query/iteration/phases/__tests__/compaction-declined.test.d.ts.map +1 -0
- package/dist/runtime/query/iteration/phases/__tests__/compaction-declined.test.js +95 -0
- package/dist/runtime/query/iteration/phases/__tests__/compaction-declined.test.js.map +1 -0
- package/dist/runtime/query/iteration/phases/compaction.d.ts +34 -0
- package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/compaction.js +61 -4
- package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
- package/dist/telemetry/__tests__/model-call-span.test.js +22 -4
- package/dist/telemetry/__tests__/model-call-span.test.js.map +1 -1
- package/dist/telemetry/__tests__/span-closure.test.js +12 -5
- package/dist/telemetry/__tests__/span-closure.test.js.map +1 -1
- package/dist/tools/__tests__/untrusted-envelope.test.js +16 -0
- package/dist/tools/__tests__/untrusted-envelope.test.js.map +1 -1
- package/dist/tools/coordinator/__tests__/completion-delivery.test.js +117 -0
- package/dist/tools/coordinator/__tests__/completion-delivery.test.js.map +1 -1
- package/dist/tools/coordinator/__tests__/task-list.test.js +57 -0
- package/dist/tools/coordinator/__tests__/task-list.test.js.map +1 -1
- package/dist/tools/coordinator/__tests__/wait-with-idle-bound.test.d.ts +2 -0
- package/dist/tools/coordinator/__tests__/wait-with-idle-bound.test.d.ts.map +1 -0
- package/dist/tools/coordinator/__tests__/wait-with-idle-bound.test.js +193 -0
- package/dist/tools/coordinator/__tests__/wait-with-idle-bound.test.js.map +1 -0
- package/dist/tools/coordinator/index.d.ts +19 -0
- package/dist/tools/coordinator/index.d.ts.map +1 -1
- package/dist/tools/coordinator/index.js +191 -71
- package/dist/tools/coordinator/index.js.map +1 -1
- package/dist/tools/coordinator/wait-with-idle-bound.d.ts +66 -0
- package/dist/tools/coordinator/wait-with-idle-bound.d.ts.map +1 -0
- package/dist/tools/coordinator/wait-with-idle-bound.js +78 -0
- package/dist/tools/coordinator/wait-with-idle-bound.js.map +1 -0
- package/dist/tools/untrusted-envelope.d.ts.map +1 -1
- package/dist/tools/untrusted-envelope.js +9 -1
- package/dist/tools/untrusted-envelope.js.map +1 -1
- package/dist/types/agent/base.d.ts +16 -0
- package/dist/types/agent/base.d.ts.map +1 -1
- package/dist/types/agent/gateway.d.ts +41 -0
- package/dist/types/agent/gateway.d.ts.map +1 -1
- package/dist/types/agent/lifecycle-event.d.ts +9 -1
- package/dist/types/agent/lifecycle-event.d.ts.map +1 -1
- package/dist/types/agent/task.d.ts +5 -0
- package/dist/types/agent/task.d.ts.map +1 -1
- package/dist/types/hitl/index.d.ts +10 -0
- package/dist/types/hitl/index.d.ts.map +1 -1
- package/dist/types/hitl/index.js.map +1 -1
- package/dist/types/probe/registry.d.ts +6 -0
- package/dist/types/probe/registry.d.ts.map +1 -1
- package/dist/types/provider/interface.d.ts +35 -0
- package/dist/types/provider/interface.d.ts.map +1 -1
- package/dist/types/run/config.d.ts +25 -0
- package/dist/types/run/config.d.ts.map +1 -1
- package/dist/types/run/entity.d.ts +16 -0
- package/dist/types/run/entity.d.ts.map +1 -1
- package/dist/types/run/events.d.ts +75 -0
- package/dist/types/run/events.d.ts.map +1 -1
- package/dist/types/run/events.js.map +1 -1
- package/dist/types/run/prepare-step.d.ts +17 -2
- package/dist/types/run/prepare-step.d.ts.map +1 -1
- package/dist/types/verification/index.d.ts +98 -0
- package/dist/types/verification/index.d.ts.map +1 -1
- package/dist/types/verification/index.js +10 -0
- package/dist/types/verification/index.js.map +1 -1
- package/dist/utils/__tests__/abort-reason.test.d.ts +2 -0
- package/dist/utils/__tests__/abort-reason.test.d.ts.map +1 -0
- package/dist/utils/__tests__/abort-reason.test.js +48 -0
- package/dist/utils/__tests__/abort-reason.test.js.map +1 -0
- package/dist/utils/abort.d.ts +26 -0
- package/dist/utils/abort.d.ts.map +1 -1
- package/dist/utils/abort.js +34 -0
- package/dist/utils/abort.js.map +1 -1
- package/dist/verification/__tests__/argument-pattern.test.d.ts +2 -0
- package/dist/verification/__tests__/argument-pattern.test.d.ts.map +1 -0
- package/dist/verification/__tests__/argument-pattern.test.js +122 -0
- package/dist/verification/__tests__/argument-pattern.test.js.map +1 -0
- package/dist/verification/__tests__/rule-order-and-reason.test.d.ts +2 -0
- package/dist/verification/__tests__/rule-order-and-reason.test.d.ts.map +1 -0
- package/dist/verification/__tests__/rule-order-and-reason.test.js +126 -0
- package/dist/verification/__tests__/rule-order-and-reason.test.js.map +1 -0
- package/dist/verification/gate.d.ts +17 -1
- package/dist/verification/gate.d.ts.map +1 -1
- package/dist/verification/gate.js +102 -2
- package/dist/verification/gate.js.map +1 -1
- package/dist/verification/index.d.ts +1 -1
- package/dist/verification/index.d.ts.map +1 -1
- package/dist/verification/index.js +1 -1
- package/dist/verification/index.js.map +1 -1
- package/dist/verification/rules.d.ts.map +1 -1
- package/dist/verification/rules.js +27 -0
- package/dist/verification/rules.js.map +1 -1
- package/package.json +1 -1
- package/src/agents/ReactiveAgent.ts +5 -0
- package/src/agents/SupervisorAgent.ts +175 -162
- package/src/agents/__tests__/supervisor-inbox-scope.test.ts +149 -0
- package/src/agents/runAgent.ts +22 -1
- package/src/bridge/a2a/mapper.ts +4 -0
- package/src/bridge/sse/mapper.ts +25 -0
- package/src/connector/mcp/__tests__/positional-arrays.test.ts +183 -0
- package/src/connector/mcp/adapter.ts +131 -7
- package/src/constants/agent/index.ts +5 -0
- package/src/constants/plugin/index.ts +15 -0
- package/src/contracts/api.ts +1 -0
- package/src/gateway/__tests__/completion-inbox.test.ts +348 -2
- package/src/gateway/completion-inbox.ts +248 -16
- package/src/gateway/local.ts +26 -1
- package/src/manager/agent/lifecycle.ts +6 -0
- package/src/manager/run/persistence.ts +12 -0
- package/src/provider/thinking-support.ts +19 -2
- package/src/public-runtime.ts +9 -0
- package/src/run/reporter.ts +12 -0
- package/src/runtime/query/__tests__/completion-does-not-erase-the-answer.test.ts +163 -0
- package/src/runtime/query/__tests__/completion-notification.test.ts +486 -34
- package/src/runtime/query/__tests__/context-size-on-the-wire.test.ts +122 -0
- package/src/runtime/query/__tests__/context.test.ts +24 -0
- package/src/runtime/query/__tests__/effort-reaches-the-wire.test.ts +135 -0
- package/src/runtime/query/__tests__/tool-timeout.test.ts +38 -0
- package/src/runtime/query/context.ts +16 -1
- package/src/runtime/query/executor.ts +11 -1
- package/src/runtime/query/guard.ts +32 -0
- package/src/runtime/query/iteration/__tests__/settle-grace.test.ts +265 -0
- package/src/runtime/query/iteration/index.ts +906 -635
- package/src/runtime/query/iteration/phases/__tests__/compaction-declined.test.ts +124 -0
- package/src/runtime/query/iteration/phases/compaction.ts +83 -10
- package/src/telemetry/__tests__/model-call-span.test.ts +22 -5
- package/src/telemetry/__tests__/span-closure.test.ts +12 -5
- package/src/tools/__tests__/untrusted-envelope.test.ts +23 -0
- package/src/tools/coordinator/__tests__/completion-delivery.test.ts +147 -0
- package/src/tools/coordinator/__tests__/task-list.test.ts +72 -0
- package/src/tools/coordinator/__tests__/wait-with-idle-bound.test.ts +247 -0
- package/src/tools/coordinator/index.ts +205 -78
- package/src/tools/coordinator/wait-with-idle-bound.ts +142 -0
- package/src/tools/untrusted-envelope.ts +9 -1
- package/src/types/agent/base.ts +17 -0
- package/src/types/agent/gateway.ts +42 -0
- package/src/types/agent/lifecycle-event.ts +7 -0
- package/src/types/agent/task.ts +5 -0
- package/src/types/hitl/index.ts +10 -0
- package/src/types/probe/registry.ts +6 -0
- package/src/types/provider/interface.ts +39 -0
- package/src/types/run/config.ts +26 -0
- package/src/types/run/entity.ts +17 -0
- package/src/types/run/events.ts +75 -0
- package/src/types/run/prepare-step.ts +17 -2
- package/src/types/verification/index.ts +61 -0
- package/src/utils/__tests__/abort-reason.test.ts +56 -0
- package/src/utils/abort.ts +34 -0
- package/src/verification/__tests__/argument-pattern.test.ts +158 -0
- package/src/verification/__tests__/rule-order-and-reason.test.ts +149 -0
- package/src/verification/gate.ts +106 -3
- package/src/verification/index.ts +1 -1
- package/src/verification/rules.ts +28 -0
package/src/utils/abort.ts
CHANGED
|
@@ -1,3 +1,37 @@
|
|
|
1
|
+
import { toErrorMessage } from './error.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The words a caller attached to a stop, or nothing when there were none.
|
|
5
|
+
*
|
|
6
|
+
* A cancellation and a deadline arrive by the same mechanism and mean opposite
|
|
7
|
+
* things to whoever reads the result. "Was cancelled" tells a model that
|
|
8
|
+
* something outside it decided, and nothing about what — so a tool result
|
|
9
|
+
* could not distinguish an operator pressing stop from a budget running out
|
|
10
|
+
* from a parent abandoning a child, and every one of those wants a different
|
|
11
|
+
* next move.
|
|
12
|
+
*
|
|
13
|
+
* Two kinds of reason are deliberately reported as no reason:
|
|
14
|
+
*
|
|
15
|
+
* - `AbortError` and `TimeoutError`. `abort()` with no argument fills `reason`
|
|
16
|
+
* with a DOMException named `AbortError`, so it is not a message anybody
|
|
17
|
+
* wrote; it is the platform's word for "someone stopped and said nothing".
|
|
18
|
+
* Rendering it would turn today's honest silence into a fake explanation.
|
|
19
|
+
* - Anything that is not an `Error`. The agent manager aborts a child with the
|
|
20
|
+
* bare string `'canceled'`, which would otherwise render as "was cancelled:
|
|
21
|
+
* canceled" — noise wearing the shape of information.
|
|
22
|
+
*
|
|
23
|
+
* Name-checked rather than checked by class, because a reason can cross a
|
|
24
|
+
* package boundary where `instanceof` stops holding across duplicate copies.
|
|
25
|
+
* That is the same answer this codebase already settled on for provider
|
|
26
|
+
* errors.
|
|
27
|
+
*/
|
|
28
|
+
export function abortReasonText(reason: unknown): string | undefined {
|
|
29
|
+
if (!(reason instanceof Error)) return undefined
|
|
30
|
+
if (reason.name === 'AbortError' || reason.name === 'TimeoutError') return undefined
|
|
31
|
+
const text = toErrorMessage(reason).trim()
|
|
32
|
+
return text.length > 0 ? text : undefined
|
|
33
|
+
}
|
|
34
|
+
|
|
1
35
|
export function createChildAbortController(parent: AbortController): AbortController {
|
|
2
36
|
const child = new AbortController()
|
|
3
37
|
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import type { ToolDefinition } from '../../types/tool/index.js'
|
|
4
|
+
import type { VerificationGateConfig, VerificationRule } from '../../types/verification/index.js'
|
|
5
|
+
import { getRootLogger } from '../../utils/logger.js'
|
|
6
|
+
import { VerificationGate } from '../gate.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Every pattern rule an operator could write was one of two wrong things.
|
|
10
|
+
*
|
|
11
|
+
* `custom_pattern` carries no tool scope, so a rule written about `bash`
|
|
12
|
+
* decided `edit` calls as well. And `target: 'args'` tests
|
|
13
|
+
* `JSON.stringify(toolInput)`, so the subject is the JSON TEXT of the whole
|
|
14
|
+
* argument object — meaning the natural, anchored thing to write
|
|
15
|
+
* (`^git push.*$`) can never match, and the rule silently decides nothing.
|
|
16
|
+
* Pinning the tool cost the anchor; anchoring cost the tool scope.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
function gate(rules: VerificationGateConfig['rules']): VerificationGate {
|
|
20
|
+
return new VerificationGate(
|
|
21
|
+
{
|
|
22
|
+
enabled: true,
|
|
23
|
+
rules,
|
|
24
|
+
allowReadOnlyTools: false,
|
|
25
|
+
denyDangerousPatterns: false,
|
|
26
|
+
logDecisions: false,
|
|
27
|
+
} as VerificationGateConfig,
|
|
28
|
+
getRootLogger(),
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Only the fields the gate reads. */
|
|
33
|
+
function toolDef(name: string): ToolDefinition {
|
|
34
|
+
return { name, isReadOnly: () => false } as unknown as ToolDefinition
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const PUSH_RULE: VerificationRule = {
|
|
38
|
+
type: 'argument_pattern',
|
|
39
|
+
toolNames: ['bash'],
|
|
40
|
+
argument: 'command',
|
|
41
|
+
pattern: '^git push',
|
|
42
|
+
decision: 'deny',
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function evaluate(
|
|
46
|
+
rules: VerificationGateConfig['rules'],
|
|
47
|
+
toolName: string,
|
|
48
|
+
toolInput: unknown,
|
|
49
|
+
): ReturnType<VerificationGate['evaluate']> {
|
|
50
|
+
return gate(rules).evaluate({ toolName, toolInput, toolDef: toolDef(toolName) })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('a rule can name one tool and one argument at once', () => {
|
|
54
|
+
it('denies the call it was written about', () => {
|
|
55
|
+
const result = evaluate([PUSH_RULE], 'bash', { command: 'git push origin main' })
|
|
56
|
+
expect(result.decision).toBe('deny')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('anchors against the value, which is what the old rule could not do', () => {
|
|
60
|
+
// The whole defect in one assertion: this same pattern under
|
|
61
|
+
// `custom_pattern` with `target: 'args'` is tested against
|
|
62
|
+
// `{"command":"git push origin main"}`, where `^git push` cannot match
|
|
63
|
+
// because the string does not start there.
|
|
64
|
+
const viaOldRule = evaluate(
|
|
65
|
+
[{ type: 'custom_pattern', pattern: '^git push', target: 'args', decision: 'deny' }],
|
|
66
|
+
'bash',
|
|
67
|
+
{ command: 'git push origin main' },
|
|
68
|
+
)
|
|
69
|
+
expect(viaOldRule.decision, 'the old rule silently decided nothing').not.toBe('deny')
|
|
70
|
+
|
|
71
|
+
expect(evaluate([PUSH_RULE], 'bash', { command: 'git push origin main' }).decision).toBe('deny')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('leaves a different tool alone', () => {
|
|
75
|
+
// The other half: a rule about `bash` used to decide `edit` too,
|
|
76
|
+
// because the pattern was matched against a serialisation that has no
|
|
77
|
+
// idea which tool produced it.
|
|
78
|
+
const result = evaluate([PUSH_RULE], 'edit', { command: 'git push origin main' })
|
|
79
|
+
expect(result.decision).not.toBe('deny')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('leaves a different argument alone', () => {
|
|
83
|
+
const result = evaluate([PUSH_RULE], 'bash', { description: 'git push origin main' })
|
|
84
|
+
expect(result.decision).not.toBe('deny')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('says which argument decided, so a model knows whether to reword', () => {
|
|
88
|
+
const result = evaluate([PUSH_RULE], 'bash', { command: 'git push origin main' })
|
|
89
|
+
|
|
90
|
+
expect(result.reason).toContain('command')
|
|
91
|
+
expect(result.reason).toContain('^git push')
|
|
92
|
+
expect(result.reason).toContain('bash')
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
describe('what it deliberately does not decide', () => {
|
|
97
|
+
it('decides nothing when the argument is absent', () => {
|
|
98
|
+
const result = evaluate([PUSH_RULE], 'bash', {})
|
|
99
|
+
expect(result.decision).not.toBe('deny')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('decides nothing about a structured argument', () => {
|
|
103
|
+
// No string a pattern could match says anything true about an object,
|
|
104
|
+
// and serialising it to try would put this rule back where
|
|
105
|
+
// `custom_pattern` already is. An operator who needs to refuse a tool
|
|
106
|
+
// over the SHAPE of its input wants deny_by_name.
|
|
107
|
+
const rule: VerificationRule = { ...PUSH_RULE, argument: 'env', pattern: 'PROD' }
|
|
108
|
+
const result = evaluate([rule], 'bash', { env: { NODE_ENV: 'PROD' } })
|
|
109
|
+
|
|
110
|
+
expect(result.decision).not.toBe('deny')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('reads a number or a boolean rather than skipping it', () => {
|
|
114
|
+
// These render unambiguously, so skipping them would be a fail-open
|
|
115
|
+
// with no upside: a rule about a numeric argument is a rule someone
|
|
116
|
+
// can reasonably write.
|
|
117
|
+
const rule: VerificationRule = {
|
|
118
|
+
type: 'argument_pattern',
|
|
119
|
+
toolNames: ['sleep'],
|
|
120
|
+
argument: 'seconds',
|
|
121
|
+
pattern: '^[0-9]{4,}$',
|
|
122
|
+
decision: 'deny',
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
expect(evaluate([rule], 'sleep', { seconds: 86400 }).decision).toBe('deny')
|
|
126
|
+
expect(evaluate([rule], 'sleep', { seconds: 5 }).decision).not.toBe('deny')
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
describe('a rule that cannot be compiled decides nothing at all', () => {
|
|
131
|
+
it('does not widen into a rule about the whole tool', () => {
|
|
132
|
+
// The failure this forbids: a typo'd regex turning "deny bash when its
|
|
133
|
+
// command matches X" into "deny bash" — a far larger authorization than
|
|
134
|
+
// anybody wrote, granted by a mistake nobody would notice.
|
|
135
|
+
//
|
|
136
|
+
// What actually secures it is the missing-pattern check at the top of
|
|
137
|
+
// `evaluateRule`, which returns before the tool name is consulted. The
|
|
138
|
+
// gate's construction order (compile first, only then record the names)
|
|
139
|
+
// is defence in depth and NOT the mechanism: reversing those two lines
|
|
140
|
+
// fails nothing, which was measured rather than assumed. So this test
|
|
141
|
+
// pins the OUTCOME and the comment in the gate says which line to keep.
|
|
142
|
+
const broken: VerificationRule = { ...PUSH_RULE, pattern: '([unclosed' }
|
|
143
|
+
const result = evaluate([broken], 'bash', { command: 'ls' })
|
|
144
|
+
|
|
145
|
+
expect(result.decision).not.toBe('deny')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('is secured by the pattern check, not by the construction order', () => {
|
|
149
|
+
// The honest version of the mutation: remove the check that actually
|
|
150
|
+
// holds and this fails. A rule whose pattern never compiled has no
|
|
151
|
+
// pattern to test, so it must decide nothing even for a tool it names.
|
|
152
|
+
const broken: VerificationRule = { ...PUSH_RULE, pattern: '([unclosed' }
|
|
153
|
+
|
|
154
|
+
expect(evaluate([broken], 'bash', { command: 'git push origin main' }).decision).not.toBe(
|
|
155
|
+
'deny',
|
|
156
|
+
)
|
|
157
|
+
})
|
|
158
|
+
})
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import type { ToolDefinition } from '../../types/tool/index.js'
|
|
4
|
+
import type { VerificationGateConfig } from '../../types/verification/index.js'
|
|
5
|
+
import { getRootLogger } from '../../utils/logger.js'
|
|
6
|
+
import { VerificationGate, describeRule } from '../gate.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Two things an operator has to be able to trust about a policy gate: that a
|
|
10
|
+
* rule they wrote is consulted, and that a refusal says enough to act on.
|
|
11
|
+
*
|
|
12
|
+
* Neither held. `allowReadOnlyTools` was expanded into a rule AHEAD of the
|
|
13
|
+
* operator's own, and the gate stops at the first match — so "prompt me before
|
|
14
|
+
* every read" was not rejected, not warned about, just never reached. And a
|
|
15
|
+
* denial arrived as the rule's TYPE NAME, which tells a model that a rule
|
|
16
|
+
* matched and nothing whatever about what it said.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
function gate(config: Partial<VerificationGateConfig>): VerificationGate {
|
|
20
|
+
return new VerificationGate(
|
|
21
|
+
{
|
|
22
|
+
enabled: true,
|
|
23
|
+
rules: [],
|
|
24
|
+
allowReadOnlyTools: false,
|
|
25
|
+
denyDangerousPatterns: false,
|
|
26
|
+
logDecisions: false,
|
|
27
|
+
...config,
|
|
28
|
+
} as VerificationGateConfig,
|
|
29
|
+
getRootLogger(),
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Only the fields the gate reads. */
|
|
34
|
+
function toolDef(name: string, readOnly: boolean): ToolDefinition {
|
|
35
|
+
return { name, isReadOnly: () => readOnly } as unknown as ToolDefinition
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('an operator rule outranks the read-only convenience', () => {
|
|
39
|
+
it('lets a rule make a read-only tool prompt', () => {
|
|
40
|
+
// The case that was silently unreachable: `read` observes only, the
|
|
41
|
+
// convenience is on, and the operator has asked to be consulted anyway.
|
|
42
|
+
const result = gate({
|
|
43
|
+
allowReadOnlyTools: true,
|
|
44
|
+
rules: [{ type: 'deny_by_name', toolNames: ['read'] }],
|
|
45
|
+
}).evaluate({
|
|
46
|
+
toolName: 'read',
|
|
47
|
+
toolInput: { path: 'notes.txt' },
|
|
48
|
+
toolDef: toolDef('read', true),
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
expect(result.decision, 'the operator rule was never consulted').toBe('deny')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('still allows a read-only tool nobody wrote a rule about', () => {
|
|
55
|
+
// The convenience keeps doing its job as a DEFAULT, which is what it
|
|
56
|
+
// always was in substance.
|
|
57
|
+
const result = gate({
|
|
58
|
+
allowReadOnlyTools: true,
|
|
59
|
+
rules: [{ type: 'deny_by_name', toolNames: ['write'] }],
|
|
60
|
+
}).evaluate({
|
|
61
|
+
toolName: 'grep',
|
|
62
|
+
toolInput: { pattern: 'x' },
|
|
63
|
+
toolDef: toolDef('grep', true),
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
expect(result.decision).toBe('allow')
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('the safety floor still outranks everything', () => {
|
|
71
|
+
it('refuses a dangerous pattern even when a rule would allow the tool', () => {
|
|
72
|
+
// The denial goes first and stays there. An operator rule must not be
|
|
73
|
+
// able to open what it closes.
|
|
74
|
+
const result = gate({
|
|
75
|
+
denyDangerousPatterns: true,
|
|
76
|
+
rules: [{ type: 'allow_by_name', toolNames: ['bash'] }],
|
|
77
|
+
}).evaluate({
|
|
78
|
+
toolName: 'bash',
|
|
79
|
+
toolInput: { command: 'rm -rf /' },
|
|
80
|
+
toolDef: toolDef('bash', false),
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
expect(result.decision).toBe('deny')
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('a refusal says what the rule said', () => {
|
|
88
|
+
it('names the pattern rather than the rule type', () => {
|
|
89
|
+
// "Matched rule: custom_pattern" tells a model a rule matched and
|
|
90
|
+
// nothing about it, so it rewords the same call and tries again. The
|
|
91
|
+
// pattern is what makes the retry visibly pointless.
|
|
92
|
+
const reason = describeRule({
|
|
93
|
+
type: 'custom_pattern',
|
|
94
|
+
pattern: 'git push*',
|
|
95
|
+
target: 'args',
|
|
96
|
+
decision: 'deny',
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
expect(reason).toContain('git push*')
|
|
100
|
+
expect(reason).toContain('denied')
|
|
101
|
+
expect(reason).not.toContain('custom_pattern')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('says a by-name denial is about the tool, not the input', () => {
|
|
105
|
+
// The distinction that decides the next move: reworded arguments will
|
|
106
|
+
// not help, so the model should stop rather than iterate.
|
|
107
|
+
const reason = describeRule({ type: 'deny_by_name', toolNames: ['bash', 'write'] })
|
|
108
|
+
|
|
109
|
+
expect(reason).toContain('bash')
|
|
110
|
+
expect(reason).toContain('a different input will not change it')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('says a dangerous-pattern refusal is not worth rewording', () => {
|
|
114
|
+
expect(describeRule({ type: 'deny_dangerous_patterns' })).toContain('will not help')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('reaches the model through the gate result, not just the helper', () => {
|
|
118
|
+
const result = gate({ rules: [{ type: 'deny_by_name', toolNames: ['bash'] }] }).evaluate({
|
|
119
|
+
toolName: 'bash',
|
|
120
|
+
toolInput: { command: 'ls' },
|
|
121
|
+
toolDef: toolDef('bash', false),
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
expect(result.reason).toContain('bash')
|
|
125
|
+
expect(result.reason).not.toMatch(/^Matched rule:/)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('is reachable by a caller driving the rules without the gate', async () => {
|
|
129
|
+
// `evaluateRule` is public and answers only WHETHER a rule matched. A
|
|
130
|
+
// caller holding that verdict and no describer is back to switching on
|
|
131
|
+
// the rule's type for its wording — the same defect the gate carried,
|
|
132
|
+
// one layer up. So the two travel together, and this asserts the
|
|
133
|
+
// PAIRING rather than either one alone.
|
|
134
|
+
//
|
|
135
|
+
// Asserted at this barrel rather than the package barrel on purpose.
|
|
136
|
+
// Importing `../../index.js` costs ~4s of module-graph load — measured,
|
|
137
|
+
// on an idle machine, against a default 5s test budget — so the version
|
|
138
|
+
// of this test that looked stronger was one busy CI runner away from
|
|
139
|
+
// failing for a reason that has nothing to do with verification. The
|
|
140
|
+
// package half is gated better elsewhere anyway: the public-surface
|
|
141
|
+
// baseline resolves `dist/index.d.ts` through the checker and reports a
|
|
142
|
+
// REGRESSION naming any symbol that leaves the surface.
|
|
143
|
+
const barrel = await import('../index.js')
|
|
144
|
+
|
|
145
|
+
expect(typeof barrel.evaluateRule).toBe('function')
|
|
146
|
+
expect(typeof barrel.describeRule).toBe('function')
|
|
147
|
+
expect(barrel.describeRule({ type: 'deny_by_name', toolNames: ['bash'] })).toContain('bash')
|
|
148
|
+
})
|
|
149
|
+
})
|
package/src/verification/gate.ts
CHANGED
|
@@ -15,6 +15,55 @@ export interface ToolCallContext {
|
|
|
15
15
|
readonly toolDef: ToolDefinition | undefined
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* What the rule actually said, in words a model can act on.
|
|
20
|
+
*
|
|
21
|
+
* This used to be the rule TYPE and nothing else, so a denial reached the
|
|
22
|
+
* model as "Blocked by the verification gate: Matched rule: deny_by_name" —
|
|
23
|
+
* naming the kind of rule and nothing about it. Not which tool, not which
|
|
24
|
+
* pattern, not whether a different input would fare better.
|
|
25
|
+
*
|
|
26
|
+
* The difference is behavioural rather than cosmetic. Told only that it was
|
|
27
|
+
* denied, a model rewords the same call and tries again, because nothing in
|
|
28
|
+
* the message says a retry is pointless. Told that a pattern rule denies
|
|
29
|
+
* `git push*`, it can stop, say so, and do something else. A refusal that
|
|
30
|
+
* cannot be reasoned about produces thrashing; one that can produces a route
|
|
31
|
+
* around it.
|
|
32
|
+
*/
|
|
33
|
+
export function describeRule(rule: VerificationRule): string {
|
|
34
|
+
switch (rule.type) {
|
|
35
|
+
case 'deny_dangerous_patterns':
|
|
36
|
+
return 'this matches a pattern the operator refuses outright; rewording it will not help'
|
|
37
|
+
case 'allow_read_only':
|
|
38
|
+
return 'allowed because this tool only observes'
|
|
39
|
+
case 'allow_by_name':
|
|
40
|
+
return `allowed by name (${rule.toolNames.join(', ')})`
|
|
41
|
+
case 'deny_by_name':
|
|
42
|
+
return `denied by name (${rule.toolNames.join(', ')}) — this tool is refused for this run, so a different input will not change it`
|
|
43
|
+
case 'allow_by_category':
|
|
44
|
+
return `allowed by category (${rule.categories.join(', ')})`
|
|
45
|
+
case 'allow_by_tier':
|
|
46
|
+
return `allowed by tier (${rule.tiers.join(', ')})`
|
|
47
|
+
case 'argument_pattern': {
|
|
48
|
+
// Names the argument, not just the pattern. That is what tells a
|
|
49
|
+
// model whether a different value could get through — which is the
|
|
50
|
+
// difference between rewording once and rewording forever.
|
|
51
|
+
const verb = rule.decision === 'deny' ? 'denied' : 'allowed'
|
|
52
|
+
return `${verb} because the \`${rule.argument}\` argument matched ${rule.pattern} (this rule applies to ${rule.toolNames.join(', ')})`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
case 'custom_pattern': {
|
|
56
|
+
const where = rule.target === 'both' ? 'name or arguments' : rule.target
|
|
57
|
+
const verb = rule.decision === 'deny' ? 'denied' : 'allowed'
|
|
58
|
+
return `${verb} by a pattern rule matching the ${where}: ${rule.pattern}`
|
|
59
|
+
}
|
|
60
|
+
default: {
|
|
61
|
+
const exhaustive: never = rule
|
|
62
|
+
return `matched an unrecognised rule: ${JSON.stringify(exhaustive)}`
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
18
67
|
export class VerificationGate {
|
|
19
68
|
private readonly rules: VerificationRule[]
|
|
20
69
|
private readonly compiledPatterns: Map<number, RegExp>
|
|
@@ -31,14 +80,31 @@ export class VerificationGate {
|
|
|
31
80
|
|
|
32
81
|
const expandedRules: VerificationRule[] = []
|
|
33
82
|
|
|
83
|
+
// Order is the whole meaning of this list, because the first rule to
|
|
84
|
+
// match decides and nothing after it is consulted.
|
|
85
|
+
//
|
|
86
|
+
// The dangerous-pattern denial goes FIRST and stays there: it is the
|
|
87
|
+
// floor, and an operator rule must not be able to open what it closes.
|
|
88
|
+
//
|
|
89
|
+
// The read-only allowance goes LAST, and it used to go second — ahead of
|
|
90
|
+
// the operator's own rules. With first-match-wins that made a rule like
|
|
91
|
+
// "prompt me before every read" UNREACHABLE while allowReadOnlyTools was
|
|
92
|
+
// on: not rejected, not warned about, just never consulted. Someone who
|
|
93
|
+
// writes a rule and is silently ignored gets the worst outcome available
|
|
94
|
+
// — they believe a control is in force and it is not.
|
|
95
|
+
//
|
|
96
|
+
// So it becomes what it always was in substance: a DEFAULT for tools
|
|
97
|
+
// nobody wrote a rule about, rather than an override of the rules they
|
|
98
|
+
// did write. The denial above still outranks both.
|
|
34
99
|
if (parsed.denyDangerousPatterns) {
|
|
35
100
|
expandedRules.push({ type: 'deny_dangerous_patterns' })
|
|
36
101
|
}
|
|
102
|
+
|
|
103
|
+
expandedRules.push(...parsed.rules)
|
|
104
|
+
|
|
37
105
|
if (parsed.allowReadOnlyTools) {
|
|
38
106
|
expandedRules.push({ type: 'allow_read_only' })
|
|
39
107
|
}
|
|
40
|
-
|
|
41
|
-
expandedRules.push(...parsed.rules)
|
|
42
108
|
this.rules = expandedRules
|
|
43
109
|
|
|
44
110
|
this.compiledPatterns = new Map()
|
|
@@ -68,6 +134,43 @@ export class VerificationGate {
|
|
|
68
134
|
}
|
|
69
135
|
}
|
|
70
136
|
|
|
137
|
+
if (rule.type === 'argument_pattern') {
|
|
138
|
+
// Needs BOTH, because it is the rule that names both.
|
|
139
|
+
//
|
|
140
|
+
// The name set is recorded only after the pattern compiles, so
|
|
141
|
+
// a rule with a typo'd regex leaves neither half behind. That
|
|
142
|
+
// ordering is defence in depth and NOT what makes the guarantee
|
|
143
|
+
// — `evaluateRule` returns null on a missing compiled pattern
|
|
144
|
+
// before it ever looks at the name set, so reversing these two
|
|
145
|
+
// lines changes no behaviour. Measured: reversing them fails no
|
|
146
|
+
// test, and the test that looks like it covers this is really
|
|
147
|
+
// covering the check in `evaluateRule`.
|
|
148
|
+
//
|
|
149
|
+
// Written down because the alternative is someone later reading
|
|
150
|
+
// the order as load-bearing and preserving it for the wrong
|
|
151
|
+
// reason, or removing the real check believing this one covers
|
|
152
|
+
// it.
|
|
153
|
+
if (rule.pattern.length > MAX_CUSTOM_PATTERN_LENGTH) {
|
|
154
|
+
this.log.warn('Argument pattern exceeds max length, skipping', {
|
|
155
|
+
index: i,
|
|
156
|
+
length: rule.pattern.length,
|
|
157
|
+
maxLength: MAX_CUSTOM_PATTERN_LENGTH,
|
|
158
|
+
})
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
const compiled = new RegExp(rule.pattern)
|
|
163
|
+
this.compiledPatterns.set(i, compiled)
|
|
164
|
+
this.nameSets.set(i, new Set(rule.toolNames))
|
|
165
|
+
} catch (err) {
|
|
166
|
+
this.log.warn('Invalid argument pattern regex, skipping', {
|
|
167
|
+
index: i,
|
|
168
|
+
pattern: rule.pattern,
|
|
169
|
+
error: err instanceof Error ? err.message : String(err),
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
71
174
|
if (rule.type === 'allow_by_name') {
|
|
72
175
|
this.nameSets.set(i, new Set(rule.toolNames))
|
|
73
176
|
}
|
|
@@ -102,7 +205,7 @@ export class VerificationGate {
|
|
|
102
205
|
const result: GateEvaluationResult = {
|
|
103
206
|
decision,
|
|
104
207
|
matchedRule: rule,
|
|
105
|
-
reason:
|
|
208
|
+
reason: describeRule(rule),
|
|
106
209
|
}
|
|
107
210
|
|
|
108
211
|
if (this.logDecisions) {
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { VerificationGate, type ToolCallContext } from './gate.js'
|
|
1
|
+
export { VerificationGate, describeRule, type ToolCallContext } from './gate.js'
|
|
2
2
|
export { defaultSandboxedGateConfig, defaultSandboxedShellGateConfig } from './presets.js'
|
|
3
3
|
export { evaluateRule } from './rules.js'
|
|
@@ -63,6 +63,34 @@ export function evaluateRule(
|
|
|
63
63
|
return compiledPattern.test(target) ? rule.decision : null
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
case 'argument_pattern': {
|
|
67
|
+
if (!compiledPattern) return null
|
|
68
|
+
if (!nameSet?.has(toolName)) return null
|
|
69
|
+
|
|
70
|
+
if (typeof toolInput !== 'object' || toolInput === null) return null
|
|
71
|
+
const value = (toolInput as Record<string, unknown>)[rule.argument]
|
|
72
|
+
|
|
73
|
+
// The subject is the argument's own value, which is the whole point
|
|
74
|
+
// — `^git push` here means what a reader expects, where the same
|
|
75
|
+
// pattern against the serialised object could never match.
|
|
76
|
+
//
|
|
77
|
+
// Numbers and booleans are rendered rather than skipped, because
|
|
78
|
+
// `String(4000)` is unambiguous and anchorable. Objects and arrays
|
|
79
|
+
// are NOT: no string a pattern could match says anything true about
|
|
80
|
+
// a structured value, and serialising one would put us back where
|
|
81
|
+
// `custom_pattern` already is. A rule about the shape of an input
|
|
82
|
+
// is a rule about the tool, and `deny_by_name` is where it belongs.
|
|
83
|
+
const subject =
|
|
84
|
+
typeof value === 'string'
|
|
85
|
+
? value
|
|
86
|
+
: typeof value === 'number' || typeof value === 'boolean'
|
|
87
|
+
? String(value)
|
|
88
|
+
: undefined
|
|
89
|
+
if (subject === undefined) return null
|
|
90
|
+
|
|
91
|
+
return compiledPattern.test(subject) ? rule.decision : null
|
|
92
|
+
}
|
|
93
|
+
|
|
66
94
|
case 'allow_by_tier': {
|
|
67
95
|
if (toolDef?.tier && rule.tiers.includes(toolDef.tier)) {
|
|
68
96
|
return 'allow'
|