@namzu/sdk 6.2.0 → 7.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.
Files changed (144) hide show
  1. package/CHANGELOG.md +240 -0
  2. package/dist/agents/ReactiveAgent.d.ts.map +1 -1
  3. package/dist/agents/ReactiveAgent.js +5 -0
  4. package/dist/agents/ReactiveAgent.js.map +1 -1
  5. package/dist/agents/SupervisorAgent.d.ts.map +1 -1
  6. package/dist/agents/SupervisorAgent.js +4 -0
  7. package/dist/agents/SupervisorAgent.js.map +1 -1
  8. package/dist/agents/runAgent.d.ts +19 -1
  9. package/dist/agents/runAgent.d.ts.map +1 -1
  10. package/dist/agents/runAgent.js +2 -0
  11. package/dist/agents/runAgent.js.map +1 -1
  12. package/dist/connector/mcp/__tests__/positional-arrays.test.d.ts +2 -0
  13. package/dist/connector/mcp/__tests__/positional-arrays.test.d.ts.map +1 -0
  14. package/dist/connector/mcp/__tests__/positional-arrays.test.js +142 -0
  15. package/dist/connector/mcp/__tests__/positional-arrays.test.js.map +1 -0
  16. package/dist/connector/mcp/adapter.d.ts.map +1 -1
  17. package/dist/connector/mcp/adapter.js +123 -8
  18. package/dist/connector/mcp/adapter.js.map +1 -1
  19. package/dist/constants/agent/index.d.ts +5 -0
  20. package/dist/constants/agent/index.d.ts.map +1 -1
  21. package/dist/constants/agent/index.js +5 -0
  22. package/dist/constants/agent/index.js.map +1 -1
  23. package/dist/constants/plugin/index.d.ts +15 -0
  24. package/dist/constants/plugin/index.d.ts.map +1 -1
  25. package/dist/constants/plugin/index.js +15 -0
  26. package/dist/constants/plugin/index.js.map +1 -1
  27. package/dist/gateway/local.d.ts +11 -0
  28. package/dist/gateway/local.d.ts.map +1 -1
  29. package/dist/gateway/local.js +27 -1
  30. package/dist/gateway/local.js.map +1 -1
  31. package/dist/manager/agent/lifecycle.d.ts.map +1 -1
  32. package/dist/manager/agent/lifecycle.js +6 -0
  33. package/dist/manager/agent/lifecycle.js.map +1 -1
  34. package/dist/provider/thinking-support.d.ts +2 -1
  35. package/dist/provider/thinking-support.d.ts.map +1 -1
  36. package/dist/provider/thinking-support.js +14 -0
  37. package/dist/provider/thinking-support.js.map +1 -1
  38. package/dist/public-runtime.d.ts +1 -1
  39. package/dist/public-runtime.d.ts.map +1 -1
  40. package/dist/public-runtime.js +9 -1
  41. package/dist/public-runtime.js.map +1 -1
  42. package/dist/runtime/query/__tests__/context.test.js +18 -0
  43. package/dist/runtime/query/__tests__/context.test.js.map +1 -1
  44. package/dist/runtime/query/__tests__/effort-reaches-the-wire.test.d.ts +2 -0
  45. package/dist/runtime/query/__tests__/effort-reaches-the-wire.test.d.ts.map +1 -0
  46. package/dist/runtime/query/__tests__/effort-reaches-the-wire.test.js +118 -0
  47. package/dist/runtime/query/__tests__/effort-reaches-the-wire.test.js.map +1 -0
  48. package/dist/runtime/query/__tests__/tool-timeout.test.js +34 -0
  49. package/dist/runtime/query/__tests__/tool-timeout.test.js.map +1 -1
  50. package/dist/runtime/query/context.d.ts.map +1 -1
  51. package/dist/runtime/query/context.js +16 -1
  52. package/dist/runtime/query/context.js.map +1 -1
  53. package/dist/runtime/query/executor.d.ts.map +1 -1
  54. package/dist/runtime/query/executor.js +11 -1
  55. package/dist/runtime/query/executor.js.map +1 -1
  56. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  57. package/dist/runtime/query/iteration/index.js +5 -0
  58. package/dist/runtime/query/iteration/index.js.map +1 -1
  59. package/dist/telemetry/__tests__/model-call-span.test.js +22 -4
  60. package/dist/telemetry/__tests__/model-call-span.test.js.map +1 -1
  61. package/dist/telemetry/__tests__/span-closure.test.js +12 -5
  62. package/dist/telemetry/__tests__/span-closure.test.js.map +1 -1
  63. package/dist/tools/coordinator/__tests__/wait-with-idle-bound.test.d.ts +2 -0
  64. package/dist/tools/coordinator/__tests__/wait-with-idle-bound.test.d.ts.map +1 -0
  65. package/dist/tools/coordinator/__tests__/wait-with-idle-bound.test.js +193 -0
  66. package/dist/tools/coordinator/__tests__/wait-with-idle-bound.test.js.map +1 -0
  67. package/dist/tools/coordinator/index.d.ts +19 -0
  68. package/dist/tools/coordinator/index.d.ts.map +1 -1
  69. package/dist/tools/coordinator/index.js +82 -60
  70. package/dist/tools/coordinator/index.js.map +1 -1
  71. package/dist/tools/coordinator/wait-with-idle-bound.d.ts +66 -0
  72. package/dist/tools/coordinator/wait-with-idle-bound.d.ts.map +1 -0
  73. package/dist/tools/coordinator/wait-with-idle-bound.js +78 -0
  74. package/dist/tools/coordinator/wait-with-idle-bound.js.map +1 -0
  75. package/dist/types/agent/base.d.ts +16 -0
  76. package/dist/types/agent/base.d.ts.map +1 -1
  77. package/dist/types/agent/gateway.d.ts +23 -0
  78. package/dist/types/agent/gateway.d.ts.map +1 -1
  79. package/dist/types/agent/lifecycle-event.d.ts +9 -1
  80. package/dist/types/agent/lifecycle-event.d.ts.map +1 -1
  81. package/dist/types/agent/task.d.ts +5 -0
  82. package/dist/types/agent/task.d.ts.map +1 -1
  83. package/dist/types/hitl/index.d.ts +10 -0
  84. package/dist/types/hitl/index.d.ts.map +1 -1
  85. package/dist/types/hitl/index.js.map +1 -1
  86. package/dist/types/probe/registry.d.ts +6 -0
  87. package/dist/types/probe/registry.d.ts.map +1 -1
  88. package/dist/types/run/config.d.ts +25 -0
  89. package/dist/types/run/config.d.ts.map +1 -1
  90. package/dist/utils/__tests__/abort-reason.test.d.ts +2 -0
  91. package/dist/utils/__tests__/abort-reason.test.d.ts.map +1 -0
  92. package/dist/utils/__tests__/abort-reason.test.js +48 -0
  93. package/dist/utils/__tests__/abort-reason.test.js.map +1 -0
  94. package/dist/utils/abort.d.ts +26 -0
  95. package/dist/utils/abort.d.ts.map +1 -1
  96. package/dist/utils/abort.js +34 -0
  97. package/dist/utils/abort.js.map +1 -1
  98. package/dist/verification/__tests__/rule-order-and-reason.test.d.ts +2 -0
  99. package/dist/verification/__tests__/rule-order-and-reason.test.d.ts.map +1 -0
  100. package/dist/verification/__tests__/rule-order-and-reason.test.js +126 -0
  101. package/dist/verification/__tests__/rule-order-and-reason.test.js.map +1 -0
  102. package/dist/verification/gate.d.ts +17 -1
  103. package/dist/verification/gate.d.ts.map +1 -1
  104. package/dist/verification/gate.js +58 -2
  105. package/dist/verification/gate.js.map +1 -1
  106. package/dist/verification/index.d.ts +1 -1
  107. package/dist/verification/index.d.ts.map +1 -1
  108. package/dist/verification/index.js +1 -1
  109. package/dist/verification/index.js.map +1 -1
  110. package/package.json +1 -1
  111. package/src/agents/ReactiveAgent.ts +5 -0
  112. package/src/agents/SupervisorAgent.ts +4 -0
  113. package/src/agents/runAgent.ts +22 -1
  114. package/src/connector/mcp/__tests__/positional-arrays.test.ts +183 -0
  115. package/src/connector/mcp/adapter.ts +131 -7
  116. package/src/constants/agent/index.ts +5 -0
  117. package/src/constants/plugin/index.ts +15 -0
  118. package/src/gateway/local.ts +26 -1
  119. package/src/manager/agent/lifecycle.ts +6 -0
  120. package/src/provider/thinking-support.ts +19 -2
  121. package/src/public-runtime.ts +9 -0
  122. package/src/runtime/query/__tests__/context.test.ts +24 -0
  123. package/src/runtime/query/__tests__/effort-reaches-the-wire.test.ts +135 -0
  124. package/src/runtime/query/__tests__/tool-timeout.test.ts +38 -0
  125. package/src/runtime/query/context.ts +16 -1
  126. package/src/runtime/query/executor.ts +11 -1
  127. package/src/runtime/query/iteration/index.ts +5 -0
  128. package/src/telemetry/__tests__/model-call-span.test.ts +22 -5
  129. package/src/telemetry/__tests__/span-closure.test.ts +12 -5
  130. package/src/tools/coordinator/__tests__/wait-with-idle-bound.test.ts +247 -0
  131. package/src/tools/coordinator/index.ts +83 -63
  132. package/src/tools/coordinator/wait-with-idle-bound.ts +142 -0
  133. package/src/types/agent/base.ts +17 -0
  134. package/src/types/agent/gateway.ts +24 -0
  135. package/src/types/agent/lifecycle-event.ts +7 -0
  136. package/src/types/agent/task.ts +5 -0
  137. package/src/types/hitl/index.ts +10 -0
  138. package/src/types/probe/registry.ts +6 -0
  139. package/src/types/run/config.ts +26 -0
  140. package/src/utils/__tests__/abort-reason.test.ts +56 -0
  141. package/src/utils/abort.ts +34 -0
  142. package/src/verification/__tests__/rule-order-and-reason.test.ts +149 -0
  143. package/src/verification/gate.ts +61 -3
  144. package/src/verification/index.ts +1 -1
@@ -31,6 +31,12 @@ export interface ProbeOptions<K extends ProbeEventKind = ProbeEventKind> {
31
31
  readonly where?: (event: ProbeEventOf<K>) => boolean
32
32
  readonly priority?: number
33
33
  readonly name?: string
34
+ /**
35
+ * **Not implemented.** Setting it changes nothing; no probe emits
36
+ * telemetry because of it.
37
+ *
38
+ * @deprecated Unimplemented. Removed in the next major.
39
+ */
34
40
  readonly otel?: boolean
35
41
  readonly override?: boolean
36
42
  }
@@ -18,6 +18,32 @@ export interface AgentRunConfig {
18
18
  * driver omits them rather than sending a request it knows will 400.
19
19
  */
20
20
  thinking?: import('../provider/index.js').ThinkingConfig
21
+
22
+ /**
23
+ * How much work the model should spend on each call in the run.
24
+ *
25
+ * A SIBLING of {@link AgentRunConfig.thinking}, not a field inside it.
26
+ * On some models the two are independent controls that apply together —
27
+ * effort shapes the answer while a budget sets thinking depth — so
28
+ * nesting one inside the other would make that combination unsayable.
29
+ *
30
+ * The failure this closes is the one this codebase keeps finding: the
31
+ * field existed on the provider params, a driver already read it and
32
+ * wrote it to the wire, and nothing in the kernel ever set it. So a
33
+ * caller could not reach it at all, and the symptom — every request
34
+ * going out at the model's default — reads as "this model ignores
35
+ * effort" rather than "nobody plumbed it through".
36
+ *
37
+ * Run-level rather than per-step, deliberately. It is a property of what
38
+ * the run is FOR, and a value that moves between steps buys a different
39
+ * answer shape at the cost of the prompt-cache prefix on every step that
40
+ * changes it.
41
+ *
42
+ * A driver that cannot honour it REFUSES rather than dropping it, on the
43
+ * same reasoning as `thinking`: paying for a run you believe was
44
+ * high-effort and silently was not is worse than a startup error.
45
+ */
46
+ effort?: import('../provider/index.js').ReasoningEffort
21
47
  tokenBudget: number
22
48
  costLimitUsd?: number
23
49
  maxIterations?: number
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { abortReasonText } from '../abort.js'
4
+
5
+ /**
6
+ * A cancellation and a deadline arrive by the same mechanism and mean opposite
7
+ * things to whoever reads the result. The distinction only survives if the
8
+ * words a caller attached travel with the abort — and only if the words the
9
+ * platform invents on a caller's behalf do NOT, because a fabricated
10
+ * explanation is worse than an honest silence.
11
+ */
12
+
13
+ describe('a stop carries the words its caller gave it', () => {
14
+ it('reports a reason someone wrote', () => {
15
+ expect(abortReasonText(new Error('deployment window closed'))).toBe('deployment window closed')
16
+ })
17
+
18
+ it('reports a named deadline, which is the case this exists for', () => {
19
+ const reason = new Error('run budget of 30000ms exhausted')
20
+ expect(abortReasonText(reason)).toBe('run budget of 30000ms exhausted')
21
+ })
22
+ })
23
+
24
+ describe('a stop with nothing to say stays silent', () => {
25
+ it('says nothing for a bare abort()', () => {
26
+ // `abort()` with no argument fills `reason` with a DOMException named
27
+ // AbortError. Nobody wrote that word; it is the platform's way of
28
+ // saying the caller gave no reason, and rendering it would turn "we
29
+ // do not know" into what looks like an answer.
30
+ const controller = new AbortController()
31
+ controller.abort()
32
+
33
+ expect(controller.signal.reason).toBeInstanceOf(Error)
34
+ expect((controller.signal.reason as Error).name).toBe('AbortError')
35
+ expect(abortReasonText(controller.signal.reason)).toBeUndefined()
36
+ })
37
+
38
+ it('says nothing for a platform timeout', () => {
39
+ const timeout = new Error('The operation was aborted due to timeout')
40
+ timeout.name = 'TimeoutError'
41
+ expect(abortReasonText(timeout)).toBeUndefined()
42
+ })
43
+
44
+ it('says nothing for a non-Error reason', () => {
45
+ // The agent manager aborts a child with the bare string 'canceled'.
46
+ // Rendering it produces "was cancelled: canceled" — noise wearing the
47
+ // shape of information.
48
+ expect(abortReasonText('canceled')).toBeUndefined()
49
+ expect(abortReasonText(undefined)).toBeUndefined()
50
+ expect(abortReasonText({ message: 'not an Error' })).toBeUndefined()
51
+ })
52
+
53
+ it('says nothing for an Error whose message is empty', () => {
54
+ expect(abortReasonText(new Error(' '))).toBeUndefined()
55
+ })
56
+ })
@@ -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,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
+ })
@@ -15,6 +15,47 @@ 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 'custom_pattern': {
48
+ const where = rule.target === 'both' ? 'name or arguments' : rule.target
49
+ const verb = rule.decision === 'deny' ? 'denied' : 'allowed'
50
+ return `${verb} by a pattern rule matching the ${where}: ${rule.pattern}`
51
+ }
52
+ default: {
53
+ const exhaustive: never = rule
54
+ return `matched an unrecognised rule: ${JSON.stringify(exhaustive)}`
55
+ }
56
+ }
57
+ }
58
+
18
59
  export class VerificationGate {
19
60
  private readonly rules: VerificationRule[]
20
61
  private readonly compiledPatterns: Map<number, RegExp>
@@ -31,14 +72,31 @@ export class VerificationGate {
31
72
 
32
73
  const expandedRules: VerificationRule[] = []
33
74
 
75
+ // Order is the whole meaning of this list, because the first rule to
76
+ // match decides and nothing after it is consulted.
77
+ //
78
+ // The dangerous-pattern denial goes FIRST and stays there: it is the
79
+ // floor, and an operator rule must not be able to open what it closes.
80
+ //
81
+ // The read-only allowance goes LAST, and it used to go second — ahead of
82
+ // the operator's own rules. With first-match-wins that made a rule like
83
+ // "prompt me before every read" UNREACHABLE while allowReadOnlyTools was
84
+ // on: not rejected, not warned about, just never consulted. Someone who
85
+ // writes a rule and is silently ignored gets the worst outcome available
86
+ // — they believe a control is in force and it is not.
87
+ //
88
+ // So it becomes what it always was in substance: a DEFAULT for tools
89
+ // nobody wrote a rule about, rather than an override of the rules they
90
+ // did write. The denial above still outranks both.
34
91
  if (parsed.denyDangerousPatterns) {
35
92
  expandedRules.push({ type: 'deny_dangerous_patterns' })
36
93
  }
94
+
95
+ expandedRules.push(...parsed.rules)
96
+
37
97
  if (parsed.allowReadOnlyTools) {
38
98
  expandedRules.push({ type: 'allow_read_only' })
39
99
  }
40
-
41
- expandedRules.push(...parsed.rules)
42
100
  this.rules = expandedRules
43
101
 
44
102
  this.compiledPatterns = new Map()
@@ -102,7 +160,7 @@ export class VerificationGate {
102
160
  const result: GateEvaluationResult = {
103
161
  decision,
104
162
  matchedRule: rule,
105
- reason: `Matched rule: ${rule.type}`,
163
+ reason: describeRule(rule),
106
164
  }
107
165
 
108
166
  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'