@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.
Files changed (251) hide show
  1. package/CHANGELOG.md +677 -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 +172 -158
  7. package/dist/agents/SupervisorAgent.js.map +1 -1
  8. package/dist/agents/__tests__/supervisor-inbox-scope.test.d.ts +2 -0
  9. package/dist/agents/__tests__/supervisor-inbox-scope.test.d.ts.map +1 -0
  10. package/dist/agents/__tests__/supervisor-inbox-scope.test.js +125 -0
  11. package/dist/agents/__tests__/supervisor-inbox-scope.test.js.map +1 -0
  12. package/dist/agents/runAgent.d.ts +19 -1
  13. package/dist/agents/runAgent.d.ts.map +1 -1
  14. package/dist/agents/runAgent.js +2 -0
  15. package/dist/agents/runAgent.js.map +1 -1
  16. package/dist/bridge/a2a/mapper.d.ts.map +1 -1
  17. package/dist/bridge/a2a/mapper.js +4 -0
  18. package/dist/bridge/a2a/mapper.js.map +1 -1
  19. package/dist/bridge/sse/mapper.d.ts.map +1 -1
  20. package/dist/bridge/sse/mapper.js +24 -0
  21. package/dist/bridge/sse/mapper.js.map +1 -1
  22. package/dist/connector/mcp/__tests__/positional-arrays.test.d.ts +2 -0
  23. package/dist/connector/mcp/__tests__/positional-arrays.test.d.ts.map +1 -0
  24. package/dist/connector/mcp/__tests__/positional-arrays.test.js +142 -0
  25. package/dist/connector/mcp/__tests__/positional-arrays.test.js.map +1 -0
  26. package/dist/connector/mcp/adapter.d.ts.map +1 -1
  27. package/dist/connector/mcp/adapter.js +123 -8
  28. package/dist/connector/mcp/adapter.js.map +1 -1
  29. package/dist/constants/agent/index.d.ts +5 -0
  30. package/dist/constants/agent/index.d.ts.map +1 -1
  31. package/dist/constants/agent/index.js +5 -0
  32. package/dist/constants/agent/index.js.map +1 -1
  33. package/dist/constants/plugin/index.d.ts +15 -0
  34. package/dist/constants/plugin/index.d.ts.map +1 -1
  35. package/dist/constants/plugin/index.js +15 -0
  36. package/dist/constants/plugin/index.js.map +1 -1
  37. package/dist/contracts/api.d.ts +1 -1
  38. package/dist/contracts/api.d.ts.map +1 -1
  39. package/dist/gateway/__tests__/completion-inbox.test.js +292 -2
  40. package/dist/gateway/__tests__/completion-inbox.test.js.map +1 -1
  41. package/dist/gateway/completion-inbox.d.ts +94 -6
  42. package/dist/gateway/completion-inbox.d.ts.map +1 -1
  43. package/dist/gateway/completion-inbox.js +235 -15
  44. package/dist/gateway/completion-inbox.js.map +1 -1
  45. package/dist/gateway/local.d.ts +11 -0
  46. package/dist/gateway/local.d.ts.map +1 -1
  47. package/dist/gateway/local.js +27 -1
  48. package/dist/gateway/local.js.map +1 -1
  49. package/dist/manager/agent/lifecycle.d.ts.map +1 -1
  50. package/dist/manager/agent/lifecycle.js +6 -0
  51. package/dist/manager/agent/lifecycle.js.map +1 -1
  52. package/dist/manager/run/persistence.d.ts +8 -0
  53. package/dist/manager/run/persistence.d.ts.map +1 -1
  54. package/dist/manager/run/persistence.js +12 -0
  55. package/dist/manager/run/persistence.js.map +1 -1
  56. package/dist/provider/thinking-support.d.ts +2 -1
  57. package/dist/provider/thinking-support.d.ts.map +1 -1
  58. package/dist/provider/thinking-support.js +14 -0
  59. package/dist/provider/thinking-support.js.map +1 -1
  60. package/dist/public-runtime.d.ts +1 -1
  61. package/dist/public-runtime.d.ts.map +1 -1
  62. package/dist/public-runtime.js +9 -1
  63. package/dist/public-runtime.js.map +1 -1
  64. package/dist/run/reporter.d.ts.map +1 -1
  65. package/dist/run/reporter.js +11 -0
  66. package/dist/run/reporter.js.map +1 -1
  67. package/dist/runtime/query/__tests__/completion-does-not-erase-the-answer.test.d.ts +2 -0
  68. package/dist/runtime/query/__tests__/completion-does-not-erase-the-answer.test.d.ts.map +1 -0
  69. package/dist/runtime/query/__tests__/completion-does-not-erase-the-answer.test.js +142 -0
  70. package/dist/runtime/query/__tests__/completion-does-not-erase-the-answer.test.js.map +1 -0
  71. package/dist/runtime/query/__tests__/completion-notification.test.js +414 -32
  72. package/dist/runtime/query/__tests__/completion-notification.test.js.map +1 -1
  73. package/dist/runtime/query/__tests__/context-size-on-the-wire.test.d.ts +2 -0
  74. package/dist/runtime/query/__tests__/context-size-on-the-wire.test.d.ts.map +1 -0
  75. package/dist/runtime/query/__tests__/context-size-on-the-wire.test.js +100 -0
  76. package/dist/runtime/query/__tests__/context-size-on-the-wire.test.js.map +1 -0
  77. package/dist/runtime/query/__tests__/context.test.js +18 -0
  78. package/dist/runtime/query/__tests__/context.test.js.map +1 -1
  79. package/dist/runtime/query/__tests__/effort-reaches-the-wire.test.d.ts +2 -0
  80. package/dist/runtime/query/__tests__/effort-reaches-the-wire.test.d.ts.map +1 -0
  81. package/dist/runtime/query/__tests__/effort-reaches-the-wire.test.js +118 -0
  82. package/dist/runtime/query/__tests__/effort-reaches-the-wire.test.js.map +1 -0
  83. package/dist/runtime/query/__tests__/tool-timeout.test.js +34 -0
  84. package/dist/runtime/query/__tests__/tool-timeout.test.js.map +1 -1
  85. package/dist/runtime/query/context.d.ts.map +1 -1
  86. package/dist/runtime/query/context.js +16 -1
  87. package/dist/runtime/query/context.js.map +1 -1
  88. package/dist/runtime/query/executor.d.ts.map +1 -1
  89. package/dist/runtime/query/executor.js +11 -1
  90. package/dist/runtime/query/executor.js.map +1 -1
  91. package/dist/runtime/query/guard.d.ts +28 -0
  92. package/dist/runtime/query/guard.d.ts.map +1 -1
  93. package/dist/runtime/query/guard.js +31 -0
  94. package/dist/runtime/query/guard.js.map +1 -1
  95. package/dist/runtime/query/iteration/__tests__/settle-grace.test.d.ts +2 -0
  96. package/dist/runtime/query/iteration/__tests__/settle-grace.test.d.ts.map +1 -0
  97. package/dist/runtime/query/iteration/__tests__/settle-grace.test.js +226 -0
  98. package/dist/runtime/query/iteration/__tests__/settle-grace.test.js.map +1 -0
  99. package/dist/runtime/query/iteration/index.d.ts +92 -0
  100. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  101. package/dist/runtime/query/iteration/index.js +818 -565
  102. package/dist/runtime/query/iteration/index.js.map +1 -1
  103. package/dist/runtime/query/iteration/phases/__tests__/compaction-declined.test.d.ts +2 -0
  104. package/dist/runtime/query/iteration/phases/__tests__/compaction-declined.test.d.ts.map +1 -0
  105. package/dist/runtime/query/iteration/phases/__tests__/compaction-declined.test.js +95 -0
  106. package/dist/runtime/query/iteration/phases/__tests__/compaction-declined.test.js.map +1 -0
  107. package/dist/runtime/query/iteration/phases/compaction.d.ts +34 -0
  108. package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
  109. package/dist/runtime/query/iteration/phases/compaction.js +61 -4
  110. package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
  111. package/dist/telemetry/__tests__/model-call-span.test.js +22 -4
  112. package/dist/telemetry/__tests__/model-call-span.test.js.map +1 -1
  113. package/dist/telemetry/__tests__/span-closure.test.js +12 -5
  114. package/dist/telemetry/__tests__/span-closure.test.js.map +1 -1
  115. package/dist/tools/__tests__/untrusted-envelope.test.js +16 -0
  116. package/dist/tools/__tests__/untrusted-envelope.test.js.map +1 -1
  117. package/dist/tools/coordinator/__tests__/completion-delivery.test.js +117 -0
  118. package/dist/tools/coordinator/__tests__/completion-delivery.test.js.map +1 -1
  119. package/dist/tools/coordinator/__tests__/task-list.test.js +57 -0
  120. package/dist/tools/coordinator/__tests__/task-list.test.js.map +1 -1
  121. package/dist/tools/coordinator/__tests__/wait-with-idle-bound.test.d.ts +2 -0
  122. package/dist/tools/coordinator/__tests__/wait-with-idle-bound.test.d.ts.map +1 -0
  123. package/dist/tools/coordinator/__tests__/wait-with-idle-bound.test.js +193 -0
  124. package/dist/tools/coordinator/__tests__/wait-with-idle-bound.test.js.map +1 -0
  125. package/dist/tools/coordinator/index.d.ts +19 -0
  126. package/dist/tools/coordinator/index.d.ts.map +1 -1
  127. package/dist/tools/coordinator/index.js +191 -71
  128. package/dist/tools/coordinator/index.js.map +1 -1
  129. package/dist/tools/coordinator/wait-with-idle-bound.d.ts +66 -0
  130. package/dist/tools/coordinator/wait-with-idle-bound.d.ts.map +1 -0
  131. package/dist/tools/coordinator/wait-with-idle-bound.js +78 -0
  132. package/dist/tools/coordinator/wait-with-idle-bound.js.map +1 -0
  133. package/dist/tools/untrusted-envelope.d.ts.map +1 -1
  134. package/dist/tools/untrusted-envelope.js +9 -1
  135. package/dist/tools/untrusted-envelope.js.map +1 -1
  136. package/dist/types/agent/base.d.ts +16 -0
  137. package/dist/types/agent/base.d.ts.map +1 -1
  138. package/dist/types/agent/gateway.d.ts +41 -0
  139. package/dist/types/agent/gateway.d.ts.map +1 -1
  140. package/dist/types/agent/lifecycle-event.d.ts +9 -1
  141. package/dist/types/agent/lifecycle-event.d.ts.map +1 -1
  142. package/dist/types/agent/task.d.ts +5 -0
  143. package/dist/types/agent/task.d.ts.map +1 -1
  144. package/dist/types/hitl/index.d.ts +10 -0
  145. package/dist/types/hitl/index.d.ts.map +1 -1
  146. package/dist/types/hitl/index.js.map +1 -1
  147. package/dist/types/probe/registry.d.ts +6 -0
  148. package/dist/types/probe/registry.d.ts.map +1 -1
  149. package/dist/types/provider/interface.d.ts +35 -0
  150. package/dist/types/provider/interface.d.ts.map +1 -1
  151. package/dist/types/run/config.d.ts +25 -0
  152. package/dist/types/run/config.d.ts.map +1 -1
  153. package/dist/types/run/entity.d.ts +16 -0
  154. package/dist/types/run/entity.d.ts.map +1 -1
  155. package/dist/types/run/events.d.ts +75 -0
  156. package/dist/types/run/events.d.ts.map +1 -1
  157. package/dist/types/run/events.js.map +1 -1
  158. package/dist/types/run/prepare-step.d.ts +17 -2
  159. package/dist/types/run/prepare-step.d.ts.map +1 -1
  160. package/dist/types/verification/index.d.ts +98 -0
  161. package/dist/types/verification/index.d.ts.map +1 -1
  162. package/dist/types/verification/index.js +10 -0
  163. package/dist/types/verification/index.js.map +1 -1
  164. package/dist/utils/__tests__/abort-reason.test.d.ts +2 -0
  165. package/dist/utils/__tests__/abort-reason.test.d.ts.map +1 -0
  166. package/dist/utils/__tests__/abort-reason.test.js +48 -0
  167. package/dist/utils/__tests__/abort-reason.test.js.map +1 -0
  168. package/dist/utils/abort.d.ts +26 -0
  169. package/dist/utils/abort.d.ts.map +1 -1
  170. package/dist/utils/abort.js +34 -0
  171. package/dist/utils/abort.js.map +1 -1
  172. package/dist/verification/__tests__/argument-pattern.test.d.ts +2 -0
  173. package/dist/verification/__tests__/argument-pattern.test.d.ts.map +1 -0
  174. package/dist/verification/__tests__/argument-pattern.test.js +122 -0
  175. package/dist/verification/__tests__/argument-pattern.test.js.map +1 -0
  176. package/dist/verification/__tests__/rule-order-and-reason.test.d.ts +2 -0
  177. package/dist/verification/__tests__/rule-order-and-reason.test.d.ts.map +1 -0
  178. package/dist/verification/__tests__/rule-order-and-reason.test.js +126 -0
  179. package/dist/verification/__tests__/rule-order-and-reason.test.js.map +1 -0
  180. package/dist/verification/gate.d.ts +17 -1
  181. package/dist/verification/gate.d.ts.map +1 -1
  182. package/dist/verification/gate.js +102 -2
  183. package/dist/verification/gate.js.map +1 -1
  184. package/dist/verification/index.d.ts +1 -1
  185. package/dist/verification/index.d.ts.map +1 -1
  186. package/dist/verification/index.js +1 -1
  187. package/dist/verification/index.js.map +1 -1
  188. package/dist/verification/rules.d.ts.map +1 -1
  189. package/dist/verification/rules.js +27 -0
  190. package/dist/verification/rules.js.map +1 -1
  191. package/package.json +1 -1
  192. package/src/agents/ReactiveAgent.ts +5 -0
  193. package/src/agents/SupervisorAgent.ts +175 -162
  194. package/src/agents/__tests__/supervisor-inbox-scope.test.ts +149 -0
  195. package/src/agents/runAgent.ts +22 -1
  196. package/src/bridge/a2a/mapper.ts +4 -0
  197. package/src/bridge/sse/mapper.ts +25 -0
  198. package/src/connector/mcp/__tests__/positional-arrays.test.ts +183 -0
  199. package/src/connector/mcp/adapter.ts +131 -7
  200. package/src/constants/agent/index.ts +5 -0
  201. package/src/constants/plugin/index.ts +15 -0
  202. package/src/contracts/api.ts +1 -0
  203. package/src/gateway/__tests__/completion-inbox.test.ts +348 -2
  204. package/src/gateway/completion-inbox.ts +248 -16
  205. package/src/gateway/local.ts +26 -1
  206. package/src/manager/agent/lifecycle.ts +6 -0
  207. package/src/manager/run/persistence.ts +12 -0
  208. package/src/provider/thinking-support.ts +19 -2
  209. package/src/public-runtime.ts +9 -0
  210. package/src/run/reporter.ts +12 -0
  211. package/src/runtime/query/__tests__/completion-does-not-erase-the-answer.test.ts +163 -0
  212. package/src/runtime/query/__tests__/completion-notification.test.ts +486 -34
  213. package/src/runtime/query/__tests__/context-size-on-the-wire.test.ts +122 -0
  214. package/src/runtime/query/__tests__/context.test.ts +24 -0
  215. package/src/runtime/query/__tests__/effort-reaches-the-wire.test.ts +135 -0
  216. package/src/runtime/query/__tests__/tool-timeout.test.ts +38 -0
  217. package/src/runtime/query/context.ts +16 -1
  218. package/src/runtime/query/executor.ts +11 -1
  219. package/src/runtime/query/guard.ts +32 -0
  220. package/src/runtime/query/iteration/__tests__/settle-grace.test.ts +265 -0
  221. package/src/runtime/query/iteration/index.ts +906 -635
  222. package/src/runtime/query/iteration/phases/__tests__/compaction-declined.test.ts +124 -0
  223. package/src/runtime/query/iteration/phases/compaction.ts +83 -10
  224. package/src/telemetry/__tests__/model-call-span.test.ts +22 -5
  225. package/src/telemetry/__tests__/span-closure.test.ts +12 -5
  226. package/src/tools/__tests__/untrusted-envelope.test.ts +23 -0
  227. package/src/tools/coordinator/__tests__/completion-delivery.test.ts +147 -0
  228. package/src/tools/coordinator/__tests__/task-list.test.ts +72 -0
  229. package/src/tools/coordinator/__tests__/wait-with-idle-bound.test.ts +247 -0
  230. package/src/tools/coordinator/index.ts +205 -78
  231. package/src/tools/coordinator/wait-with-idle-bound.ts +142 -0
  232. package/src/tools/untrusted-envelope.ts +9 -1
  233. package/src/types/agent/base.ts +17 -0
  234. package/src/types/agent/gateway.ts +42 -0
  235. package/src/types/agent/lifecycle-event.ts +7 -0
  236. package/src/types/agent/task.ts +5 -0
  237. package/src/types/hitl/index.ts +10 -0
  238. package/src/types/probe/registry.ts +6 -0
  239. package/src/types/provider/interface.ts +39 -0
  240. package/src/types/run/config.ts +26 -0
  241. package/src/types/run/entity.ts +17 -0
  242. package/src/types/run/events.ts +75 -0
  243. package/src/types/run/prepare-step.ts +17 -2
  244. package/src/types/verification/index.ts +61 -0
  245. package/src/utils/__tests__/abort-reason.test.ts +56 -0
  246. package/src/utils/abort.ts +34 -0
  247. package/src/verification/__tests__/argument-pattern.test.ts +158 -0
  248. package/src/verification/__tests__/rule-order-and-reason.test.ts +149 -0
  249. package/src/verification/gate.ts +106 -3
  250. package/src/verification/index.ts +1 -1
  251. package/src/verification/rules.ts +28 -0
@@ -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
+ })
@@ -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: `Matched rule: ${rule.type}`,
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'