@gaunt-sloth/core 2.0.0-alpha.23 → 2.0.0-alpha.25
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/README.md +43 -18
- package/dist/config/colour.d.ts +38 -0
- package/dist/config/colour.js +36 -0
- package/dist/config/colour.js.map +1 -0
- package/dist/config/defaults.d.ts +6 -7
- package/dist/config/defaults.js +4 -8
- package/dist/config/defaults.js.map +1 -1
- package/dist/config/loader.d.ts +50 -3
- package/dist/config/loader.js +345 -29
- package/dist/config/loader.js.map +1 -1
- package/dist/config/mouse.d.ts +50 -0
- package/dist/config/mouse.js +44 -0
- package/dist/config/mouse.js.map +1 -0
- package/dist/config/profiles.d.ts +68 -0
- package/dist/config/profiles.js +93 -0
- package/dist/config/profiles.js.map +1 -0
- package/dist/config/schema.d.ts +2441 -116
- package/dist/config/schema.js +930 -19
- package/dist/config/schema.js.map +1 -1
- package/dist/config/shell-policy.d.ts +466 -116
- package/dist/config/shell-policy.js +273 -62
- package/dist/config/shell-policy.js.map +1 -1
- package/dist/config/tool-descriptions.d.ts +165 -0
- package/dist/config/tool-descriptions.js +229 -0
- package/dist/config/tool-descriptions.js.map +1 -0
- package/dist/config/types.d.ts +139 -6
- package/dist/config/types.js.map +1 -1
- package/dist/config.d.ts +4 -0
- package/dist/config.js +4 -0
- package/dist/config.js.map +1 -1
- package/dist/core/GthAbstractAgent.d.ts +85 -2
- package/dist/core/GthAbstractAgent.js +194 -1
- package/dist/core/GthAbstractAgent.js.map +1 -1
- package/dist/core/GthAgentRunner.d.ts +341 -58
- package/dist/core/GthAgentRunner.js +857 -149
- package/dist/core/GthAgentRunner.js.map +1 -1
- package/dist/core/GthLangChainAgent.d.ts +99 -0
- package/dist/core/GthLangChainAgent.js +388 -3
- package/dist/core/GthLangChainAgent.js.map +1 -1
- package/dist/core/approvals/annotations.d.ts +122 -0
- package/dist/core/approvals/annotations.js +137 -0
- package/dist/core/approvals/annotations.js.map +1 -0
- package/dist/core/approvals/grants.d.ts +216 -0
- package/dist/core/approvals/grants.js +469 -0
- package/dist/core/approvals/grants.js.map +1 -0
- package/dist/core/approvals/matcher.d.ts +202 -0
- package/dist/core/approvals/matcher.js +267 -0
- package/dist/core/approvals/matcher.js.map +1 -0
- package/dist/core/approvals/mcpSubjects.d.ts +40 -0
- package/dist/core/approvals/mcpSubjects.js +99 -0
- package/dist/core/approvals/mcpSubjects.js.map +1 -0
- package/dist/core/approvals/toolAnnotationSources.d.ts +105 -0
- package/dist/core/approvals/toolAnnotationSources.js +277 -0
- package/dist/core/approvals/toolAnnotationSources.js.map +1 -0
- package/dist/core/approvals/toolHost.d.ts +46 -0
- package/dist/core/approvals/toolHost.js +108 -0
- package/dist/core/approvals/toolHost.js.map +1 -0
- package/dist/core/debugCapture.d.ts +16 -0
- package/dist/core/debugCapture.js.map +1 -1
- package/dist/core/launchBanner.d.ts +120 -0
- package/dist/core/launchBanner.js +399 -0
- package/dist/core/launchBanner.js.map +1 -0
- package/dist/core/plainToolIndication.js +61 -35
- package/dist/core/plainToolIndication.js.map +1 -1
- package/dist/core/refusal.d.ts +53 -0
- package/dist/core/refusal.js +129 -0
- package/dist/core/refusal.js.map +1 -0
- package/dist/core/shell/approvalStop.d.ts +75 -0
- package/dist/core/shell/approvalStop.js +108 -0
- package/dist/core/shell/approvalStop.js.map +1 -0
- package/dist/core/shell/arity.d.ts +6 -0
- package/dist/core/shell/arity.js +20 -6
- package/dist/core/shell/arity.js.map +1 -1
- package/dist/core/shell/denylist.d.ts +11 -0
- package/dist/core/shell/denylist.js +37 -0
- package/dist/core/shell/denylist.js.map +1 -0
- package/dist/core/shell/normalize.d.ts +41 -1
- package/dist/core/shell/normalize.js +58 -4
- package/dist/core/shell/normalize.js.map +1 -1
- package/dist/core/shell/openWorld.d.ts +138 -0
- package/dist/core/shell/openWorld.js +523 -0
- package/dist/core/shell/openWorld.js.map +1 -0
- package/dist/core/shell/rater.d.ts +560 -0
- package/dist/core/shell/rater.js +895 -0
- package/dist/core/shell/rater.js.map +1 -0
- package/dist/core/shell/raterModel.d.ts +41 -0
- package/dist/core/shell/raterModel.js +51 -0
- package/dist/core/shell/raterModel.js.map +1 -0
- package/dist/core/shell/rejection.d.ts +66 -0
- package/dist/core/shell/rejection.js +38 -0
- package/dist/core/shell/rejection.js.map +1 -0
- package/dist/core/toolCallRepair/grammar.d.ts +41 -0
- package/dist/core/toolCallRepair/grammar.js +116 -0
- package/dist/core/toolCallRepair/grammar.js.map +1 -0
- package/dist/core/toolCallRepair/index.d.ts +2 -0
- package/dist/core/toolCallRepair/index.js +7 -0
- package/dist/core/toolCallRepair/index.js.map +1 -0
- package/dist/core/toolCallRepair/payload.d.ts +36 -0
- package/dist/core/toolCallRepair/payload.js +341 -0
- package/dist/core/toolCallRepair/payload.js.map +1 -0
- package/dist/core/toolCallRepair/promote.d.ts +45 -0
- package/dist/core/toolCallRepair/promote.js +90 -0
- package/dist/core/toolCallRepair/promote.js.map +1 -0
- package/dist/core/toolDisplay.d.ts +14 -1
- package/dist/core/toolDisplay.js +69 -12
- package/dist/core/toolDisplay.js.map +1 -1
- package/dist/core/toolOutputChannel.d.ts +33 -6
- package/dist/core/toolOutputChannel.js +61 -8
- package/dist/core/toolOutputChannel.js.map +1 -1
- package/dist/core/types.d.ts +71 -9
- package/dist/core/types.js.map +1 -1
- package/dist/providers/geminiSchemaSanitizer.d.ts +6 -3
- package/dist/providers/geminiSchemaSanitizer.js +152 -6
- package/dist/providers/geminiSchemaSanitizer.js.map +1 -1
- package/dist/providers/modelDiscovery.d.ts +18 -4
- package/dist/providers/modelDiscovery.js +67 -17
- package/dist/providers/modelDiscovery.js.map +1 -1
- package/dist/providers/openai.js +34 -0
- package/dist/providers/openai.js.map +1 -1
- package/dist/runtime/askStructured.d.ts +8 -7
- package/dist/runtime/askStructured.js +8 -7
- package/dist/runtime/askStructured.js.map +1 -1
- package/dist/runtime/conversation.js +89 -76
- package/dist/runtime/conversation.js.map +1 -1
- package/dist/runtime/singleShot.js +71 -60
- package/dist/runtime/singleShot.js.map +1 -1
- package/dist/utils/ProgressIndicator.d.ts +21 -0
- package/dist/utils/ProgressIndicator.js +30 -3
- package/dist/utils/ProgressIndicator.js.map +1 -1
- package/dist/utils/consoleUtils.d.ts +30 -0
- package/dist/utils/consoleUtils.js +27 -2
- package/dist/utils/consoleUtils.js.map +1 -1
- package/dist/utils/crashHandler.d.ts +87 -0
- package/dist/utils/crashHandler.js +128 -0
- package/dist/utils/crashHandler.js.map +1 -0
- package/dist/utils/debugDump.d.ts +58 -0
- package/dist/utils/debugDump.js +149 -15
- package/dist/utils/debugDump.js.map +1 -1
- package/dist/utils/llmUtils.d.ts +22 -8
- package/dist/utils/llmUtils.js +48 -8
- package/dist/utils/llmUtils.js.map +1 -1
- package/dist/utils/redactSecrets.js +68 -20
- package/dist/utils/redactSecrets.js.map +1 -1
- package/dist/utils/systemUtils.d.ts +17 -0
- package/dist/utils/systemUtils.js +17 -0
- package/dist/utils/systemUtils.js.map +1 -1
- package/package.json +5 -2
- package/schema/gsloth-config.schema.json +1484 -466
- package/dist/core/shell/allowlist.d.ts +0 -75
- package/dist/core/shell/allowlist.js +0 -187
- package/dist/core/shell/allowlist.js.map +0 -1
- package/dist/core/shell/judge.d.ts +0 -161
- package/dist/core/shell/judge.js +0 -261
- package/dist/core/shell/judge.js.map +0 -1
|
@@ -15,9 +15,12 @@
|
|
|
15
15
|
* Stream discipline (matches how the plain surface prints tool activity today): the block is
|
|
16
16
|
* emitted at INFO level through `displayToolIndication` — same stdout channel, same
|
|
17
17
|
* `consoleLevel` gate and session-log treatment as the existing tool notices — so scripted
|
|
18
|
-
* consumers that already silence INFO chatter silence this too. Colour is used
|
|
19
|
-
* `useColour`
|
|
20
|
-
*
|
|
18
|
+
* consumers that already silence INFO chatter silence this too. Colour is used exactly when the
|
|
19
|
+
* resolved `useColour` (the CFG-30 ladder in `config/colour.ts`) says so — TUI-C35 removed the
|
|
20
|
+
* local `&& stdout.isTTY` narrowing this module used to apply on top, which was redundant against
|
|
21
|
+
* the ladder's own rung-4 TTY auto-detection everywhere except `FORCE_COLOR` on a pipe, the one
|
|
22
|
+
* case that variable exists to serve. An ordinary piped run is therefore still clean monochrome
|
|
23
|
+
* (DL-7) — rung 4 decides that — with diff lines readable via their `+`/`-` prefixes.
|
|
21
24
|
*
|
|
22
25
|
* Live-output dedupe: shell-shaped results (`<COMMAND_OUTPUT>`) belong to tools whose child
|
|
23
26
|
* output ALREADY streamed raw via the channel's default sink, so those render with
|
|
@@ -25,9 +28,9 @@
|
|
|
25
28
|
* the user just watched.
|
|
26
29
|
*/
|
|
27
30
|
import { AIMessage, AIMessageChunk, ToolMessage } from '@langchain/core/messages';
|
|
28
|
-
import { buildToolPreviewLines, getToolGlyph,
|
|
31
|
+
import { buildToolPreviewLines, getToolGlyph, isShellShapedResult, renderToolLineAnsi, summariseToolCall, } from '#src/core/toolDisplay.js';
|
|
29
32
|
import { displayToolIndication } from '#src/utils/consoleUtils.js';
|
|
30
|
-
import { getUseColour
|
|
33
|
+
import { getUseColour } from '#src/utils/systemUtils.js';
|
|
31
34
|
const INDENT = ' ';
|
|
32
35
|
/**
|
|
33
36
|
* Create the per-stream observer. State is scoped to one stream (one `agent.stream()` call);
|
|
@@ -59,9 +62,12 @@ export function createPlainToolIndication(emit = displayToolIndication) {
|
|
|
59
62
|
const name = tracked?.name || (typeof message.name === 'string' ? message.name : '') || '';
|
|
60
63
|
const result = typeof message.content === 'string' ? message.content : JSON.stringify(message.content);
|
|
61
64
|
const isError = message.status === 'error';
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
+
// TUI-C35 — colour is exactly what the resolved ladder says, with no local narrowing.
|
|
66
|
+
// This used to AND in `stdout.isTTY`, which was redundant in every case but one: rung 4 of
|
|
67
|
+
// `config/colour.ts` already auto-detects from stdout's TTY status, so an unconfigured piped
|
|
68
|
+
// run is monochrome either way. The one case it changed was `FORCE_COLOR` into a pipe — which
|
|
69
|
+
// it suppressed, defeating the only thing that variable is for.
|
|
70
|
+
const colour = getUseColour();
|
|
65
71
|
const statusGlyph = isError
|
|
66
72
|
? colour
|
|
67
73
|
? '\x1b[31m✗\x1b[0m'
|
|
@@ -78,8 +84,10 @@ export function createPlainToolIndication(emit = displayToolIndication) {
|
|
|
78
84
|
result,
|
|
79
85
|
isError,
|
|
80
86
|
// Shell-shaped results stream their child output live through the channel's default
|
|
81
|
-
// sink on this surface — suppress the duplicated body, keep the status tail.
|
|
82
|
-
|
|
87
|
+
// sink on this surface — suppress the duplicated body, keep the status tail. TUI-C32
|
|
88
|
+
// residual c: gate on the tool NAME + shape (not shape alone), so a non-shell tool whose
|
|
89
|
+
// result merely quotes `<COMMAND_OUTPUT>` keeps its preview body instead of being suppressed.
|
|
90
|
+
liveOutputAlreadyShown: isShellShapedResult(name, result),
|
|
83
91
|
});
|
|
84
92
|
const body = preview.map((line) => INDENT + renderToolLineAnsi(line, colour));
|
|
85
93
|
// Leading newline mirrors the historical notice framing (the model text stream may have
|
|
@@ -91,24 +99,49 @@ export function createPlainToolIndication(emit = displayToolIndication) {
|
|
|
91
99
|
// Order matters: AIMessageChunk extends AIMessage, so test the chunk shape first
|
|
92
100
|
// (mirrors processEventStream).
|
|
93
101
|
if (AIMessageChunk.isInstance(chunk)) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
entry
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
102
|
+
// TUI-C32 residual e — fail-soft, matching the ToolMessage branch: accumulating tool-call
|
|
103
|
+
// deltas (`JSON.stringify(tc.args)` can throw on an unserialisable arg, e.g. a BigInt) must
|
|
104
|
+
// never break the run's stream loop. On any error we simply skip this chunk's tracking.
|
|
105
|
+
try {
|
|
106
|
+
const c = chunk;
|
|
107
|
+
const deltas = c.tool_call_chunks ?? [];
|
|
108
|
+
if (deltas.length > 0) {
|
|
109
|
+
for (const delta of deltas) {
|
|
110
|
+
const index = typeof delta.index === 'number' ? delta.index : 0;
|
|
111
|
+
const entry = streaming.get(index) ?? { name: '', argsText: '' };
|
|
112
|
+
if (delta.id)
|
|
113
|
+
entry.id = delta.id;
|
|
114
|
+
if (delta.name)
|
|
115
|
+
entry.name = entry.name || delta.name;
|
|
116
|
+
if (delta.args)
|
|
117
|
+
entry.argsText += delta.args;
|
|
118
|
+
streaming.set(index, entry);
|
|
119
|
+
}
|
|
107
120
|
}
|
|
121
|
+
else {
|
|
122
|
+
// Some providers surface COMPLETE tool_calls on a chunk instead of deltas.
|
|
123
|
+
for (const tc of c.tool_calls ?? []) {
|
|
124
|
+
if (tc.id) {
|
|
125
|
+
byId.set(tc.id, {
|
|
126
|
+
id: tc.id,
|
|
127
|
+
name: tc.name,
|
|
128
|
+
argsText: JSON.stringify(tc.args ?? {}),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
/* indication is best-effort; the model-facing stream is untouched */
|
|
108
136
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (AIMessage.isInstance(chunk)) {
|
|
140
|
+
// A non-chunk AIMessage (resumed/checkpoint-replayed runs) carries final tool_calls.
|
|
141
|
+
// TUI-C32 residual e — same fail-soft wrap as above/the ToolMessage branch.
|
|
142
|
+
try {
|
|
143
|
+
const m = chunk;
|
|
144
|
+
for (const tc of m.tool_calls ?? []) {
|
|
112
145
|
if (tc.id) {
|
|
113
146
|
byId.set(tc.id, {
|
|
114
147
|
id: tc.id,
|
|
@@ -118,15 +151,8 @@ export function createPlainToolIndication(emit = displayToolIndication) {
|
|
|
118
151
|
}
|
|
119
152
|
}
|
|
120
153
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if (AIMessage.isInstance(chunk)) {
|
|
124
|
-
// A non-chunk AIMessage (resumed/checkpoint-replayed runs) carries final tool_calls.
|
|
125
|
-
const m = chunk;
|
|
126
|
-
for (const tc of m.tool_calls ?? []) {
|
|
127
|
-
if (tc.id) {
|
|
128
|
-
byId.set(tc.id, { id: tc.id, name: tc.name, argsText: JSON.stringify(tc.args ?? {}) });
|
|
129
|
-
}
|
|
154
|
+
catch {
|
|
155
|
+
/* indication is best-effort; the model-facing stream is untouched */
|
|
130
156
|
}
|
|
131
157
|
return;
|
|
132
158
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plainToolIndication.js","sourceRoot":"","sources":["../../src/core/plainToolIndication.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"plainToolIndication.js","sourceRoot":"","sources":["../../src/core/plainToolIndication.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EXT-37 — content-policy refusal detection for the agent run loop.
|
|
3
|
+
*
|
|
4
|
+
* A *successful* model response (HTTP 200) can carry a stop/finish reason that means the model — or
|
|
5
|
+
* the provider's safety system — declined to answer. The content is usually empty, so without this
|
|
6
|
+
* detection the response falls through the empty-response retry in {@link GthAgentRunner} and is
|
|
7
|
+
* mis-surfaced as "no content, try again" — burning a second, paid call to reproduce a
|
|
8
|
+
* DETERMINISTIC refusal. This module normalizes the per-provider shapes into one signal so the run
|
|
9
|
+
* loop can surface the refusal clearly and terminate (never retry the same prompt).
|
|
10
|
+
*
|
|
11
|
+
* Prior art: hermes-agent `conversation_loop.py` treats `finish_reason == "content_filter"` as a
|
|
12
|
+
* terminal, non-retryable "content policy blocked" outcome and surfaces the model's explanation.
|
|
13
|
+
*
|
|
14
|
+
* Detection lives here (and is called from {@link GthAbstractAgent}, the invoke/stream loop over
|
|
15
|
+
* messages/chunks) because that is the only layer where a message's `response_metadata` /
|
|
16
|
+
* `additional_kwargs` — where finish/stop reasons live — are visible; `GthAgentRunner` only ever
|
|
17
|
+
* sees the rendered string.
|
|
18
|
+
*/
|
|
19
|
+
/** One detected refusal, normalized across providers. */
|
|
20
|
+
export interface RefusalInfo {
|
|
21
|
+
/** Best-effort provider family the signal came from (for logging / the surfaced message). */
|
|
22
|
+
provider: 'openai' | 'anthropic' | 'bedrock' | 'unknown';
|
|
23
|
+
/** The raw stop/finish reason token that flagged the refusal (e.g. `content_filter`). */
|
|
24
|
+
reason: string;
|
|
25
|
+
/** Any model-provided explanation text (empty string when the refusal carried none). */
|
|
26
|
+
explanation: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Inspect a finished model message (an `AIMessage` / `AIMessageChunk`, or any object exposing
|
|
30
|
+
* `response_metadata` / `additional_kwargs`) and return a {@link RefusalInfo} when its stop/finish
|
|
31
|
+
* reason indicates a content-policy refusal, else `null`. Defensive: any non-message / unexpected
|
|
32
|
+
* shape yields `null`, so a normal turn is never mistaken for a refusal.
|
|
33
|
+
*
|
|
34
|
+
* Covered shapes:
|
|
35
|
+
* - OpenAI-family `finish_reason: 'content_filter'` (also under `additional_kwargs`).
|
|
36
|
+
* - Anthropic `stop_reason: 'refusal'`.
|
|
37
|
+
* - Bedrock Converse guardrail intervention: `stopReason`/`stop_reason`/`finish_reason` ===
|
|
38
|
+
* `'guardrail_intervened'`, or `additional_kwargs['amazon-bedrock-guardrailAction'] ===
|
|
39
|
+
* `'INTERVENED'`.
|
|
40
|
+
* - Bedrock Converse content filter: `stopReason`/`stop_reason`/`finish_reason` ===
|
|
41
|
+
* `'content_filtered'` (EXT-41 — a distinct `StopReason` enum value from `guardrail_intervened`
|
|
42
|
+
* that was previously mapped to `null`, i.e. a silent empty turn / false negative).
|
|
43
|
+
*/
|
|
44
|
+
export declare function detectRefusal(message: unknown): RefusalInfo | null;
|
|
45
|
+
/**
|
|
46
|
+
* Build the clear, user-facing message shown when the model declines. Framed as the model /
|
|
47
|
+
* provider's own policy decision (not a Gaunt Sloth fault) and stated as terminal — a refusal is
|
|
48
|
+
* deterministic for the same input, so retrying as-is will not help. Any model-provided explanation
|
|
49
|
+
* is included verbatim. This string is BOTH surfaced to the console and RETURNED as the turn's
|
|
50
|
+
* answer, so the non-interactive caller writes it to the output file and exits `ok` (it is a
|
|
51
|
+
* successful, if declined, response — not a failure to be re-wrapped as "Failed to get answer").
|
|
52
|
+
*/
|
|
53
|
+
export declare function buildRefusalMessage(info: RefusalInfo): string;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EXT-37 — content-policy refusal detection for the agent run loop.
|
|
3
|
+
*
|
|
4
|
+
* A *successful* model response (HTTP 200) can carry a stop/finish reason that means the model — or
|
|
5
|
+
* the provider's safety system — declined to answer. The content is usually empty, so without this
|
|
6
|
+
* detection the response falls through the empty-response retry in {@link GthAgentRunner} and is
|
|
7
|
+
* mis-surfaced as "no content, try again" — burning a second, paid call to reproduce a
|
|
8
|
+
* DETERMINISTIC refusal. This module normalizes the per-provider shapes into one signal so the run
|
|
9
|
+
* loop can surface the refusal clearly and terminate (never retry the same prompt).
|
|
10
|
+
*
|
|
11
|
+
* Prior art: hermes-agent `conversation_loop.py` treats `finish_reason == "content_filter"` as a
|
|
12
|
+
* terminal, non-retryable "content policy blocked" outcome and surfaces the model's explanation.
|
|
13
|
+
*
|
|
14
|
+
* Detection lives here (and is called from {@link GthAbstractAgent}, the invoke/stream loop over
|
|
15
|
+
* messages/chunks) because that is the only layer where a message's `response_metadata` /
|
|
16
|
+
* `additional_kwargs` — where finish/stop reasons live — are visible; `GthAgentRunner` only ever
|
|
17
|
+
* sees the rendered string.
|
|
18
|
+
*/
|
|
19
|
+
/** Read a nested record field defensively (returns undefined for non-objects / missing keys). */
|
|
20
|
+
function readField(source, key) {
|
|
21
|
+
if (!source || typeof source !== 'object')
|
|
22
|
+
return undefined;
|
|
23
|
+
return source[key];
|
|
24
|
+
}
|
|
25
|
+
/** Pull any human-readable explanation the refusal carried (message content, then reasoning). */
|
|
26
|
+
function extractRefusalText(message) {
|
|
27
|
+
const content = readField(message, 'content');
|
|
28
|
+
if (typeof content === 'string' && content.trim().length > 0)
|
|
29
|
+
return content.trim();
|
|
30
|
+
// Content-block arrays (Anthropic / Bedrock): concatenate any text parts.
|
|
31
|
+
if (Array.isArray(content)) {
|
|
32
|
+
const text = content
|
|
33
|
+
.map((part) => {
|
|
34
|
+
if (typeof part === 'string')
|
|
35
|
+
return part;
|
|
36
|
+
const t = readField(part, 'text');
|
|
37
|
+
return typeof t === 'string' ? t : '';
|
|
38
|
+
})
|
|
39
|
+
.join('')
|
|
40
|
+
.trim();
|
|
41
|
+
if (text.length > 0)
|
|
42
|
+
return text;
|
|
43
|
+
}
|
|
44
|
+
// Some refusals put the explanation only in the reasoning channel.
|
|
45
|
+
const kwargs = readField(message, 'additional_kwargs');
|
|
46
|
+
const reasoning = readField(kwargs, 'reasoning_content');
|
|
47
|
+
if (typeof reasoning === 'string' && reasoning.trim().length > 0)
|
|
48
|
+
return reasoning.trim();
|
|
49
|
+
// Anthropic exposes the declined text on a dedicated `refusal` field in some SDK shapes.
|
|
50
|
+
const refusalField = readField(kwargs, 'refusal') ?? readField(message, 'refusal');
|
|
51
|
+
if (typeof refusalField === 'string' && refusalField.trim().length > 0)
|
|
52
|
+
return refusalField.trim();
|
|
53
|
+
return '';
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Inspect a finished model message (an `AIMessage` / `AIMessageChunk`, or any object exposing
|
|
57
|
+
* `response_metadata` / `additional_kwargs`) and return a {@link RefusalInfo} when its stop/finish
|
|
58
|
+
* reason indicates a content-policy refusal, else `null`. Defensive: any non-message / unexpected
|
|
59
|
+
* shape yields `null`, so a normal turn is never mistaken for a refusal.
|
|
60
|
+
*
|
|
61
|
+
* Covered shapes:
|
|
62
|
+
* - OpenAI-family `finish_reason: 'content_filter'` (also under `additional_kwargs`).
|
|
63
|
+
* - Anthropic `stop_reason: 'refusal'`.
|
|
64
|
+
* - Bedrock Converse guardrail intervention: `stopReason`/`stop_reason`/`finish_reason` ===
|
|
65
|
+
* `'guardrail_intervened'`, or `additional_kwargs['amazon-bedrock-guardrailAction'] ===
|
|
66
|
+
* `'INTERVENED'`.
|
|
67
|
+
* - Bedrock Converse content filter: `stopReason`/`stop_reason`/`finish_reason` ===
|
|
68
|
+
* `'content_filtered'` (EXT-41 — a distinct `StopReason` enum value from `guardrail_intervened`
|
|
69
|
+
* that was previously mapped to `null`, i.e. a silent empty turn / false negative).
|
|
70
|
+
*/
|
|
71
|
+
export function detectRefusal(message) {
|
|
72
|
+
if (!message || typeof message !== 'object')
|
|
73
|
+
return null;
|
|
74
|
+
const meta = readField(message, 'response_metadata');
|
|
75
|
+
const kwargs = readField(message, 'additional_kwargs');
|
|
76
|
+
// Gather the stop/finish reason from every place providers surface it.
|
|
77
|
+
const finishReason = readField(meta, 'finish_reason') ?? readField(kwargs, 'finish_reason') ?? undefined;
|
|
78
|
+
const stopReasonSnake = readField(meta, 'stop_reason') ?? readField(kwargs, 'stop_reason') ?? undefined;
|
|
79
|
+
const stopReasonCamel = readField(meta, 'stopReason') ?? readField(kwargs, 'stopReason') ?? undefined;
|
|
80
|
+
const asString = (v) => (typeof v === 'string' ? v : undefined);
|
|
81
|
+
const finish = asString(finishReason);
|
|
82
|
+
const stopSnake = asString(stopReasonSnake);
|
|
83
|
+
const stopCamel = asString(stopReasonCamel);
|
|
84
|
+
const explanation = extractRefusalText(message);
|
|
85
|
+
// OpenAI-family content filter.
|
|
86
|
+
if (finish === 'content_filter') {
|
|
87
|
+
return { provider: 'openai', reason: 'content_filter', explanation };
|
|
88
|
+
}
|
|
89
|
+
// Anthropic refusal stop reason.
|
|
90
|
+
if (stopSnake === 'refusal' || stopCamel === 'refusal') {
|
|
91
|
+
return { provider: 'anthropic', reason: 'refusal', explanation };
|
|
92
|
+
}
|
|
93
|
+
// Bedrock Converse guardrail intervention (camelCase `stopReason`, or snake / finish variants).
|
|
94
|
+
if (stopCamel === 'guardrail_intervened' ||
|
|
95
|
+
stopSnake === 'guardrail_intervened' ||
|
|
96
|
+
finish === 'guardrail_intervened' ||
|
|
97
|
+
readField(kwargs, 'amazon-bedrock-guardrailAction') === 'INTERVENED' ||
|
|
98
|
+
readField(meta, 'amazon-bedrock-guardrailAction') === 'INTERVENED') {
|
|
99
|
+
return { provider: 'bedrock', reason: 'guardrail_intervened', explanation };
|
|
100
|
+
}
|
|
101
|
+
// EXT-41 — Bedrock Converse content filter. A distinct `StopReason` enum value from
|
|
102
|
+
// `guardrail_intervened` (both live in the same AWS Converse `StopReason` enum); previously
|
|
103
|
+
// unmapped, so a content-filtered turn returned `null` → the silent empty-turn false negative.
|
|
104
|
+
if (stopCamel === 'content_filtered' ||
|
|
105
|
+
stopSnake === 'content_filtered' ||
|
|
106
|
+
finish === 'content_filtered') {
|
|
107
|
+
return { provider: 'bedrock', reason: 'content_filtered', explanation };
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Build the clear, user-facing message shown when the model declines. Framed as the model /
|
|
113
|
+
* provider's own policy decision (not a Gaunt Sloth fault) and stated as terminal — a refusal is
|
|
114
|
+
* deterministic for the same input, so retrying as-is will not help. Any model-provided explanation
|
|
115
|
+
* is included verbatim. This string is BOTH surfaced to the console and RETURNED as the turn's
|
|
116
|
+
* answer, so the non-interactive caller writes it to the output file and exits `ok` (it is a
|
|
117
|
+
* successful, if declined, response — not a failure to be re-wrapped as "Failed to get answer").
|
|
118
|
+
*/
|
|
119
|
+
export function buildRefusalMessage(info) {
|
|
120
|
+
const head = 'The model declined to respond (safety refusal / content filter) — this is the ' +
|
|
121
|
+
"model/provider's own policy decision, not a Gaunt Sloth error.";
|
|
122
|
+
const detail = info.explanation
|
|
123
|
+
? `Model's explanation: ${info.explanation}`
|
|
124
|
+
: 'The model provided no explanation.';
|
|
125
|
+
const hint = 'A refusal is deterministic for the same input — rephrase the request or try a different ' +
|
|
126
|
+
'model rather than re-running it as-is.';
|
|
127
|
+
return `${head}\n\n${detail}\n\n${hint}`;
|
|
128
|
+
}
|
|
129
|
+
//# sourceMappingURL=refusal.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"refusal.js","sourceRoot":"","sources":["../../src/core/refusal.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module core/shell/approvalStop
|
|
3
|
+
*
|
|
4
|
+
* CFG-27 — the two ways the approvals gate **ends a run** rather than answering a tool call.
|
|
5
|
+
* Both are thrown from `GthAgentRunner.decideToolApproval`, both carry the command and the reason
|
|
6
|
+
* a person needs to see, and both are re-thrown UNCHANGED by `processMessages` (which otherwise
|
|
7
|
+
* wraps failures as `Agent processing failed: …`) so the explanation reaches the user intact.
|
|
8
|
+
*
|
|
9
|
+
* They are errors rather than {@link ../types.js ToolApprovalDecision}s on purpose. A decision is
|
|
10
|
+
* something the model observes as a `ToolMessage` and can respond to; these two are precisely the
|
|
11
|
+
* cases where the spec says the model gets no move at all:
|
|
12
|
+
*
|
|
13
|
+
* - **{@link AttackHaltError}** (§4.2) — an `attack` outcome *halts the run*. "A halt ends the
|
|
14
|
+
* agent loop. It is not a rejection the model can respond to, and no rung except `bypass` can
|
|
15
|
+
* turn it into anything else."
|
|
16
|
+
* - **{@link NonInteractiveEscalationError}** (§6.2) — where no human can answer, *every*
|
|
17
|
+
* escalation is an immediate non-zero exit carrying the command, the rating and its reason.
|
|
18
|
+
* There is no prompt, no waiting, and never a timeout into approval. Teams that need specific
|
|
19
|
+
* commands to run unattended declare them in `approvals.allow` (§3), which is consulted before
|
|
20
|
+
* the rater and therefore never escalates.
|
|
21
|
+
*
|
|
22
|
+
* Exit code: neither class sets one. The single-shot runtime (`runSingleShot`) already reports a
|
|
23
|
+
* thrown run as `ok: false`, and each command entry point turns that into `setExitCode(1)` — so
|
|
24
|
+
* "immediate non-zero exit carrying the explanation" is what a caller already gets, without a new
|
|
25
|
+
* exit path that could diverge from the existing one.
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* Base class for the two run-ending approvals outcomes, so a caller that wants to present them as
|
|
29
|
+
* an ending rather than a crash can catch both with one `instanceof`. Nothing does that today —
|
|
30
|
+
* every surface currently shows the message as-is, which is already the whole explanation — and
|
|
31
|
+
* [[TUI-C26]] is the node that will catch it to render the §6.1 banner.
|
|
32
|
+
*/
|
|
33
|
+
export declare abstract class ApprovalStopError extends Error {
|
|
34
|
+
/** The command that ended the run. */
|
|
35
|
+
readonly command: string;
|
|
36
|
+
protected constructor(message: string, command: string);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* §4.2 — an `attack` outcome: the command's own **structure** evidenced compromise (§4.1.1 —
|
|
40
|
+
* credential targeting, privilege escalation, persistence, deception, obfuscation). Ends the agent
|
|
41
|
+
* loop; the model is told nothing and offered nothing.
|
|
42
|
+
*
|
|
43
|
+
* [[TUI-C26]] will present this interactively as the §6.1 red banner — any key stops, typing
|
|
44
|
+
* `run anyway` runs this one command — BEFORE the throw; until then the run simply ends with this
|
|
45
|
+
* message, which is what a non-interactive session gets either way (§6.2).
|
|
46
|
+
*
|
|
47
|
+
* The recovery this message names is deliberately the **allow-list**, not `bypass`. §4.2 makes
|
|
48
|
+
* `approvals.allow` the supported way to run such a command unattended (it is consulted before the
|
|
49
|
+
* rater, so it never reaches a halt at all); `bypass` also works and is far blunter — it turns off
|
|
50
|
+
* the rater, the escalation and the halt together, for every command, for the whole run. It is a
|
|
51
|
+
* last resort, not the answer, and the wording says so in that order.
|
|
52
|
+
*/
|
|
53
|
+
export declare class AttackHaltError extends ApprovalStopError {
|
|
54
|
+
/** The rater's explanation of what the command's structure showed. */
|
|
55
|
+
readonly reason: string;
|
|
56
|
+
constructor(command: string, reason: string);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* §6.2 — an escalation with nobody to ask (CI, one-shot runs, servers). Fails the build loudly,
|
|
60
|
+
* with everything a person needs in order to see why.
|
|
61
|
+
*/
|
|
62
|
+
export declare class NonInteractiveEscalationError extends ApprovalStopError {
|
|
63
|
+
/** The rater's outcome, when a rating existed (the unrated rungs have none). */
|
|
64
|
+
readonly outcome: string | undefined;
|
|
65
|
+
/** The rater's explanation, when a rating existed. */
|
|
66
|
+
readonly reason: string | undefined;
|
|
67
|
+
/**
|
|
68
|
+
* EXT-71 §3.2 — the declared `approvals.escalate` entry that sent this call to a human, when one
|
|
69
|
+
* did. It changes the recovery the message names: pointing someone at `approvals.allow` when they
|
|
70
|
+
* themselves wrote an escalate entry sends them to a list that cannot win, since a match on
|
|
71
|
+
* `escalate` outranks a match on `allow`.
|
|
72
|
+
*/
|
|
73
|
+
readonly escalatedBy: string | undefined;
|
|
74
|
+
constructor(command: string, outcome?: string, reason?: string, escalatedBy?: string);
|
|
75
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module core/shell/approvalStop
|
|
3
|
+
*
|
|
4
|
+
* CFG-27 — the two ways the approvals gate **ends a run** rather than answering a tool call.
|
|
5
|
+
* Both are thrown from `GthAgentRunner.decideToolApproval`, both carry the command and the reason
|
|
6
|
+
* a person needs to see, and both are re-thrown UNCHANGED by `processMessages` (which otherwise
|
|
7
|
+
* wraps failures as `Agent processing failed: …`) so the explanation reaches the user intact.
|
|
8
|
+
*
|
|
9
|
+
* They are errors rather than {@link ../types.js ToolApprovalDecision}s on purpose. A decision is
|
|
10
|
+
* something the model observes as a `ToolMessage` and can respond to; these two are precisely the
|
|
11
|
+
* cases where the spec says the model gets no move at all:
|
|
12
|
+
*
|
|
13
|
+
* - **{@link AttackHaltError}** (§4.2) — an `attack` outcome *halts the run*. "A halt ends the
|
|
14
|
+
* agent loop. It is not a rejection the model can respond to, and no rung except `bypass` can
|
|
15
|
+
* turn it into anything else."
|
|
16
|
+
* - **{@link NonInteractiveEscalationError}** (§6.2) — where no human can answer, *every*
|
|
17
|
+
* escalation is an immediate non-zero exit carrying the command, the rating and its reason.
|
|
18
|
+
* There is no prompt, no waiting, and never a timeout into approval. Teams that need specific
|
|
19
|
+
* commands to run unattended declare them in `approvals.allow` (§3), which is consulted before
|
|
20
|
+
* the rater and therefore never escalates.
|
|
21
|
+
*
|
|
22
|
+
* Exit code: neither class sets one. The single-shot runtime (`runSingleShot`) already reports a
|
|
23
|
+
* thrown run as `ok: false`, and each command entry point turns that into `setExitCode(1)` — so
|
|
24
|
+
* "immediate non-zero exit carrying the explanation" is what a caller already gets, without a new
|
|
25
|
+
* exit path that could diverge from the existing one.
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* Base class for the two run-ending approvals outcomes, so a caller that wants to present them as
|
|
29
|
+
* an ending rather than a crash can catch both with one `instanceof`. Nothing does that today —
|
|
30
|
+
* every surface currently shows the message as-is, which is already the whole explanation — and
|
|
31
|
+
* [[TUI-C26]] is the node that will catch it to render the §6.1 banner.
|
|
32
|
+
*/
|
|
33
|
+
export class ApprovalStopError extends Error {
|
|
34
|
+
/** The command that ended the run. */
|
|
35
|
+
command;
|
|
36
|
+
constructor(message, command) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.command = command;
|
|
39
|
+
// Restore the prototype chain across the ES5 `extends Error` downlevel, so `instanceof` works.
|
|
40
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
41
|
+
this.name = new.target.name;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* §4.2 — an `attack` outcome: the command's own **structure** evidenced compromise (§4.1.1 —
|
|
46
|
+
* credential targeting, privilege escalation, persistence, deception, obfuscation). Ends the agent
|
|
47
|
+
* loop; the model is told nothing and offered nothing.
|
|
48
|
+
*
|
|
49
|
+
* [[TUI-C26]] will present this interactively as the §6.1 red banner — any key stops, typing
|
|
50
|
+
* `run anyway` runs this one command — BEFORE the throw; until then the run simply ends with this
|
|
51
|
+
* message, which is what a non-interactive session gets either way (§6.2).
|
|
52
|
+
*
|
|
53
|
+
* The recovery this message names is deliberately the **allow-list**, not `bypass`. §4.2 makes
|
|
54
|
+
* `approvals.allow` the supported way to run such a command unattended (it is consulted before the
|
|
55
|
+
* rater, so it never reaches a halt at all); `bypass` also works and is far blunter — it turns off
|
|
56
|
+
* the rater, the escalation and the halt together, for every command, for the whole run. It is a
|
|
57
|
+
* last resort, not the answer, and the wording says so in that order.
|
|
58
|
+
*/
|
|
59
|
+
export class AttackHaltError extends ApprovalStopError {
|
|
60
|
+
/** The rater's explanation of what the command's structure showed. */
|
|
61
|
+
reason;
|
|
62
|
+
constructor(command, reason) {
|
|
63
|
+
super(`Run halted: the auto-rater rated this command as an attack, which ends the run.\n` +
|
|
64
|
+
` Command: ${command}\n` +
|
|
65
|
+
` Reason: ${reason}\n` +
|
|
66
|
+
`This is not negotiable. If this command is legitimate and you need it to run, declare ` +
|
|
67
|
+
`it in approvals.allow — that list is consulted before the auto-rater, so it never ` +
|
|
68
|
+
`reaches a halt. Dropping to approvals "bypass" also works, but it turns off the rater, ` +
|
|
69
|
+
`the prompts and the halt for every command in the run.`, command);
|
|
70
|
+
this.reason = reason;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* §6.2 — an escalation with nobody to ask (CI, one-shot runs, servers). Fails the build loudly,
|
|
75
|
+
* with everything a person needs in order to see why.
|
|
76
|
+
*/
|
|
77
|
+
export class NonInteractiveEscalationError extends ApprovalStopError {
|
|
78
|
+
/** The rater's outcome, when a rating existed (the unrated rungs have none). */
|
|
79
|
+
outcome;
|
|
80
|
+
/** The rater's explanation, when a rating existed. */
|
|
81
|
+
reason;
|
|
82
|
+
/**
|
|
83
|
+
* EXT-71 §3.2 — the declared `approvals.escalate` entry that sent this call to a human, when one
|
|
84
|
+
* did. It changes the recovery the message names: pointing someone at `approvals.allow` when they
|
|
85
|
+
* themselves wrote an escalate entry sends them to a list that cannot win, since a match on
|
|
86
|
+
* `escalate` outranks a match on `allow`.
|
|
87
|
+
*/
|
|
88
|
+
escalatedBy;
|
|
89
|
+
constructor(command, outcome, reason, escalatedBy) {
|
|
90
|
+
super(`Approval required, but this session has no one to ask.\n` +
|
|
91
|
+
` Command: ${command}\n` +
|
|
92
|
+
(outcome ? ` Rating: ${outcome}\n` : '') +
|
|
93
|
+
(reason ? ` Reason: ${reason}\n` : '') +
|
|
94
|
+
(escalatedBy
|
|
95
|
+
? ` Matched approvals.escalate: ${escalatedBy}\n` +
|
|
96
|
+
`An escalate entry always asks a human, whatever the rung would have done, so no ` +
|
|
97
|
+
`entry in approvals.allow can answer it. Remove the escalate entry if this command ` +
|
|
98
|
+
`should run unattended.`
|
|
99
|
+
: `Declare the commands this run is allowed to execute in approvals.allow — write each ` +
|
|
100
|
+
`one as an explicit entry, for example { "type": "shell", "matcher": "exact", ` +
|
|
101
|
+
`"pattern": "npm test" }. That list is consulted before the auto-rater and never ` +
|
|
102
|
+
`escalates.`), command);
|
|
103
|
+
this.outcome = outcome;
|
|
104
|
+
this.reason = reason;
|
|
105
|
+
this.escalatedBy = escalatedBy;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
//# sourceMappingURL=approvalStop.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"approvalStop.js","sourceRoot":"","sources":["../../../src/core/shell/approvalStop.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH;;;;;GAKG;AACH,MAAM,OAAgB,iBAAkB,SAAQ,KAAK;IACnD,sCAAsC;IAC7B,OAAO,CAAS;IAEzB,YAAsB,OAAe,EAAE,OAAe;QACpD,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC;QACvB,+FAA+F;QAC/F,MAAM,CAAC,cAAc,CAAC,IAAI,EAAE,IAAI,MAAM,CAAC,SAAS,CAAC,CAAC;QAClD,IAAI,CAAC,IAAI,GAAG,IAAI,MAAM,CAAC,IAAI,CAAC;IAC9B,CAAC;CACF;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,OAAO,eAAgB,SAAQ,iBAAiB;IACpD,sEAAsE;IAC7D,MAAM,CAAS;IAExB,YAAY,OAAe,EAAE,MAAc;QACzC,KAAK,CACH,mFAAmF;YACjF,cAAc,OAAO,IAAI;YACzB,aAAa,MAAM,IAAI;YACvB,wFAAwF;YACxF,oFAAoF;YACpF,yFAAyF;YACzF,wDAAwD,EAC1D,OAAO,CACR,CAAC;QACF,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;IACvB,CAAC;CACF;AAED;;;GAGG;AACH,MAAM,OAAO,6BAA8B,SAAQ,iBAAiB;IAClE,gFAAgF;IACvE,OAAO,CAAqB;IACrC,sDAAsD;IAC7C,MAAM,CAAqB;IACpC;;;;;OAKG;IACM,WAAW,CAAqB;IAEzC,YAAY,OAAe,EAAE,OAAgB,EAAE,MAAe,EAAE,WAAoB;QAClF,KAAK,CACH,0DAA0D;YACxD,cAAc,OAAO,IAAI;YACzB,CAAC,OAAO,CAAC,CAAC,CAAC,aAAa,OAAO,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC;YACzC,CAAC,MAAM,CAAC,CAAC,CAAC,aAAa,MAAM,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC;YACvC,CAAC,WAAW;gBACV,CAAC,CAAC,iCAAiC,WAAW,IAAI;oBAChD,kFAAkF;oBAClF,oFAAoF;oBACpF,wBAAwB;gBAC1B,CAAC,CAAC,sFAAsF;oBACtF,+EAA+E;oBAC/E,kFAAkF;oBAClF,YAAY,CAAC,EACnB,OAAO,CACR,CAAC;QACF,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC;QACvB,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,WAAW,GAAG,WAAW,CAAC;IACjC,CAAC;CACF"}
|
|
@@ -18,6 +18,12 @@
|
|
|
18
18
|
* redirections. Such commands NEVER auto-match an allow-list entry — they always go to
|
|
19
19
|
* fresh human approval. This is what stops `git checkout x; rm -rf /` from matching an
|
|
20
20
|
* approved `git checkout *`.
|
|
21
|
+
*
|
|
22
|
+
* EXT-55: "newlines" in that list was aspirational until this node. `normalizeCommand` folded
|
|
23
|
+
* a line break to a SPACE, so the check below never saw one and `ls -la\nrm -rf /` classified
|
|
24
|
+
* as the single command `ls`. The separator set now lives in ONE place
|
|
25
|
+
* ({@link import('./normalize.js').COMMAND_SEPARATOR_CLASS}) and a line break survives
|
|
26
|
+
* normalization, so a multi-line command is categorically ambiguous — exactly like `;`.
|
|
21
27
|
*/
|
|
22
28
|
/**
|
|
23
29
|
* Result of classifying a command for allow-list matching.
|
package/dist/core/shell/arity.js
CHANGED
|
@@ -18,7 +18,14 @@
|
|
|
18
18
|
* redirections. Such commands NEVER auto-match an allow-list entry — they always go to
|
|
19
19
|
* fresh human approval. This is what stops `git checkout x; rm -rf /` from matching an
|
|
20
20
|
* approved `git checkout *`.
|
|
21
|
+
*
|
|
22
|
+
* EXT-55: "newlines" in that list was aspirational until this node. `normalizeCommand` folded
|
|
23
|
+
* a line break to a SPACE, so the check below never saw one and `ls -la\nrm -rf /` classified
|
|
24
|
+
* as the single command `ls`. The separator set now lives in ONE place
|
|
25
|
+
* ({@link import('./normalize.js').COMMAND_SEPARATOR_CLASS}) and a line break survives
|
|
26
|
+
* normalization, so a multi-line command is categorically ambiguous — exactly like `;`.
|
|
21
27
|
*/
|
|
28
|
+
import { COMMAND_SEPARATOR_RE, LINE_BREAK_RE } from '#src/core/shell/normalize.js';
|
|
22
29
|
/**
|
|
23
30
|
* Arity table: command-prefix string → number of leading tokens (binary + subcommands,
|
|
24
31
|
* flags excluded) that define the "human-understandable command". Longest matching
|
|
@@ -181,11 +188,9 @@ const ARITY = {
|
|
|
181
188
|
* Note: `&&`/`||`/`|` are covered by the bare `&`/`|` character scan; listed conceptually.
|
|
182
189
|
*/
|
|
183
190
|
function hasUnsafeComposition(normalized) {
|
|
184
|
-
//
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
// Shell control / separator operators and background.
|
|
188
|
-
if (/[;|&]/.test(normalized))
|
|
191
|
+
// Shell control / separator operators, background, AND line breaks — one shared definition of
|
|
192
|
+
// "a new command begins here" (EXT-55), so this can never drift from the hardline floor's.
|
|
193
|
+
if (COMMAND_SEPARATOR_RE.test(normalized))
|
|
189
194
|
return true;
|
|
190
195
|
// Command substitution: $(...) or `...`.
|
|
191
196
|
if (/\$\(/.test(normalized))
|
|
@@ -235,7 +240,9 @@ export function tokenize(command) {
|
|
|
235
240
|
inToken = true;
|
|
236
241
|
continue;
|
|
237
242
|
}
|
|
238
|
-
|
|
243
|
+
// EXT-55: line breaks are whitespace here too, so a token can never be glued across a line
|
|
244
|
+
// (`ls\n-o` must tokenize as `ls` + `-o`, or a caller inspecting the argv would miss the flag).
|
|
245
|
+
if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
|
|
239
246
|
if (inToken) {
|
|
240
247
|
tokens.push(current);
|
|
241
248
|
current = '';
|
|
@@ -294,6 +301,13 @@ export function meaningfulPrefixTokens(argv) {
|
|
|
294
301
|
* @param normalize Normalizer to apply for the detection form (inject normalizeCommand).
|
|
295
302
|
*/
|
|
296
303
|
export function classifyCommand(command, normalize) {
|
|
304
|
+
// EXT-55 — the boundary question is answered HERE, not delegated to the injected normalizer.
|
|
305
|
+
// `normalize` is a parameter, so this function cannot assume it preserved the command
|
|
306
|
+
// separators (folding them away is exactly the bug this node fixed). A line break anywhere
|
|
307
|
+
// INSIDE the command means more than one command → never classifiable. `.trim()` first: a
|
|
308
|
+
// purely leading/trailing break separates nothing, and models routinely emit `"npm test\n"`.
|
|
309
|
+
if (LINE_BREAK_RE.test(command.trim()))
|
|
310
|
+
return null;
|
|
297
311
|
const normalized = normalize(command);
|
|
298
312
|
if (!normalized)
|
|
299
313
|
return null;
|