@gaunt-sloth/core 2.0.0-alpha.2 → 2.0.0-alpha.20
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/.gsloth.code.md +10 -0
- package/README.md +6 -6
- package/dist/config/defaults.d.ts +85 -0
- package/dist/config/defaults.js +103 -0
- package/dist/config/defaults.js.map +1 -0
- package/dist/config/jsonc.d.ts +12 -0
- package/dist/config/jsonc.js +41 -0
- package/dist/config/jsonc.js.map +1 -0
- package/dist/config/loader.d.ts +169 -0
- package/dist/config/loader.js +872 -0
- package/dist/config/loader.js.map +1 -0
- package/dist/config/schema.d.ts +672 -0
- package/dist/config/schema.js +519 -0
- package/dist/config/schema.js.map +1 -0
- package/dist/config/shell-policy.d.ts +331 -0
- package/dist/config/shell-policy.js +238 -0
- package/dist/config/shell-policy.js.map +1 -0
- package/dist/config/types.d.ts +586 -0
- package/dist/config/types.js +13 -0
- package/dist/config/types.js.map +1 -0
- package/dist/config.d.ts +18 -647
- package/dist/config.js +15 -516
- package/dist/config.js.map +1 -1
- package/dist/constants.d.ts +42 -0
- package/dist/constants.js +42 -0
- package/dist/constants.js.map +1 -1
- package/dist/core/GthAbstractAgent.d.ts +85 -2
- package/dist/core/GthAbstractAgent.js +389 -28
- package/dist/core/GthAbstractAgent.js.map +1 -1
- package/dist/core/GthAgentRunner.d.ts +158 -1
- package/dist/core/GthAgentRunner.js +367 -4
- package/dist/core/GthAgentRunner.js.map +1 -1
- package/dist/core/GthLangChainAgent.js +193 -16
- package/dist/core/GthLangChainAgent.js.map +1 -1
- package/dist/core/debugCapture.d.ts +59 -0
- package/dist/core/debugCapture.js +101 -0
- package/dist/core/debugCapture.js.map +1 -0
- package/dist/core/gthLeanAgentFactory.d.ts +9 -0
- package/dist/core/gthLeanAgentFactory.js +10 -0
- package/dist/core/gthLeanAgentFactory.js.map +1 -0
- package/dist/core/runStats.d.ts +41 -0
- package/dist/core/runStats.js +73 -0
- package/dist/core/runStats.js.map +1 -0
- package/dist/core/shell/ShellCommandFailedError.d.ts +54 -0
- package/dist/core/shell/ShellCommandFailedError.js +68 -0
- package/dist/core/shell/ShellCommandFailedError.js.map +1 -0
- package/dist/core/shell/allowlist.d.ts +75 -0
- package/dist/core/shell/allowlist.js +187 -0
- package/dist/core/shell/allowlist.js.map +1 -0
- package/dist/core/shell/arity.d.ts +75 -0
- package/dist/core/shell/arity.js +313 -0
- package/dist/core/shell/arity.js.map +1 -0
- package/dist/core/shell/judge.d.ts +161 -0
- package/dist/core/shell/judge.js +261 -0
- package/dist/core/shell/judge.js.map +1 -0
- package/dist/core/shell/normalize.d.ts +27 -0
- package/dist/core/shell/normalize.js +53 -0
- package/dist/core/shell/normalize.js.map +1 -0
- package/dist/core/types.d.ts +145 -0
- package/dist/core/types.js.map +1 -1
- package/dist/history/historyFormat.d.ts +28 -0
- package/dist/history/historyFormat.js +127 -0
- package/dist/history/historyFormat.js.map +1 -0
- package/dist/history/historyStore.d.ts +198 -0
- package/dist/history/historyStore.js +482 -0
- package/dist/history/historyStore.js.map +1 -0
- package/dist/history/recordSession.d.ts +37 -0
- package/dist/history/recordSession.js +56 -0
- package/dist/history/recordSession.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/providers/anthropic.d.ts +1 -1
- package/dist/providers/anthropic.js +5 -10
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/deepseek.d.ts +1 -1
- package/dist/providers/deepseek.js +5 -10
- package/dist/providers/deepseek.js.map +1 -1
- package/dist/providers/geminiSchemaSanitizer.d.ts +52 -0
- package/dist/providers/geminiSchemaSanitizer.js +201 -0
- package/dist/providers/geminiSchemaSanitizer.js.map +1 -0
- package/dist/providers/google-genai.d.ts +1 -1
- package/dist/providers/google-genai.js +9 -11
- package/dist/providers/google-genai.js.map +1 -1
- package/dist/providers/groq.d.ts +1 -1
- package/dist/providers/groq.js +5 -10
- package/dist/providers/groq.js.map +1 -1
- package/dist/providers/huggingface.d.ts +25 -0
- package/dist/providers/huggingface.js +69 -0
- package/dist/providers/huggingface.js.map +1 -0
- package/dist/providers/modelCatalog.d.ts +109 -0
- package/dist/providers/modelCatalog.js +245 -0
- package/dist/providers/modelCatalog.js.map +1 -0
- package/dist/providers/modelDiscovery.d.ts +111 -3
- package/dist/providers/modelDiscovery.js +183 -27
- package/dist/providers/modelDiscovery.js.map +1 -1
- package/dist/providers/ollama.d.ts +19 -5
- package/dist/providers/ollama.js +60 -52
- package/dist/providers/ollama.js.map +1 -1
- package/dist/providers/openai.d.ts +1 -1
- package/dist/providers/openai.js +5 -10
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/openrouter.d.ts +1 -1
- package/dist/providers/openrouter.js +12 -12
- package/dist/providers/openrouter.js.map +1 -1
- package/dist/providers/vertexai.d.ts +1 -1
- package/dist/providers/vertexai.js +9 -11
- package/dist/providers/vertexai.js.map +1 -1
- package/dist/providers/xai.d.ts +1 -1
- package/dist/providers/xai.js +5 -10
- package/dist/providers/xai.js.map +1 -1
- package/dist/runtime/askStructured.d.ts +62 -0
- package/dist/runtime/askStructured.js +76 -0
- package/dist/runtime/askStructured.js.map +1 -0
- package/dist/runtime/conversation.d.ts +59 -0
- package/dist/runtime/conversation.js +137 -0
- package/dist/runtime/conversation.js.map +1 -0
- package/dist/runtime/singleShot.d.ts +24 -5
- package/dist/runtime/singleShot.js +56 -9
- package/dist/runtime/singleShot.js.map +1 -1
- package/dist/utils/aiignoreUtils.js.map +1 -1
- package/dist/utils/binaryOutputUtils.js.map +1 -1
- package/dist/utils/consoleUtils.d.ts +13 -0
- package/dist/utils/consoleUtils.js +31 -0
- package/dist/utils/consoleUtils.js.map +1 -1
- package/dist/utils/debugDump.d.ts +57 -0
- package/dist/utils/debugDump.js +236 -0
- package/dist/utils/debugDump.js.map +1 -0
- package/dist/utils/debugUtils.d.ts +13 -4
- package/dist/utils/debugUtils.js +36 -13
- package/dist/utils/debugUtils.js.map +1 -1
- package/dist/utils/fileUtils.d.ts +24 -2
- package/dist/utils/fileUtils.js +54 -12
- package/dist/utils/fileUtils.js.map +1 -1
- package/dist/utils/llmUtils.js.map +1 -1
- package/dist/utils/redactSecrets.d.ts +63 -0
- package/dist/utils/redactSecrets.js +238 -0
- package/dist/utils/redactSecrets.js.map +1 -0
- package/dist/utils/systemPromptNotes.d.ts +181 -0
- package/dist/utils/systemPromptNotes.js +298 -0
- package/dist/utils/systemPromptNotes.js.map +1 -0
- package/dist/utils/systemUtils.d.ts +32 -1
- package/dist/utils/systemUtils.js +50 -3
- package/dist/utils/systemUtils.js.map +1 -1
- package/dist/utils/toolMatching.d.ts +30 -0
- package/dist/utils/toolMatching.js +44 -0
- package/dist/utils/toolMatching.js.map +1 -0
- package/package.json +17 -8
- package/schema/gsloth-config.schema.json +2102 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** A fresh, empty accumulator. */
|
|
2
|
+
export function createRunStatsAccumulator() {
|
|
3
|
+
return { input: 0, output: 0, sawUsage: false, tools: new Set() };
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Fold one LangChain message (or message chunk) into the accumulator. Fail-soft: any unexpected
|
|
7
|
+
* shape is swallowed so a run is never affected. Harvests, when present:
|
|
8
|
+
* - `usage_metadata.input_tokens` / `.output_tokens` (summed; marks `sawUsage`), and
|
|
9
|
+
* - tool names from an AIMessage's requested `tool_calls[].name` AND from a `ToolMessage`'s own
|
|
10
|
+
* `.name` (the executed tool), so both "requested" and "executed" tools are captured.
|
|
11
|
+
*/
|
|
12
|
+
export function accumulateMessage(acc, message) {
|
|
13
|
+
try {
|
|
14
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
15
|
+
const m = message;
|
|
16
|
+
if (!m || typeof m !== 'object')
|
|
17
|
+
return;
|
|
18
|
+
const usage = m.usage_metadata;
|
|
19
|
+
if (usage && typeof usage === 'object') {
|
|
20
|
+
acc.sawUsage = true;
|
|
21
|
+
if (typeof usage.input_tokens === 'number' && Number.isFinite(usage.input_tokens)) {
|
|
22
|
+
acc.input += usage.input_tokens;
|
|
23
|
+
}
|
|
24
|
+
if (typeof usage.output_tokens === 'number' && Number.isFinite(usage.output_tokens)) {
|
|
25
|
+
acc.output += usage.output_tokens;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// Requested tool calls (AIMessage / AIMessageChunk). Continuation chunks in a streamed
|
|
29
|
+
// tool call carry an empty name, so guard on a non-empty string; the Set dedupes repeats.
|
|
30
|
+
const toolCalls = m.tool_calls;
|
|
31
|
+
if (Array.isArray(toolCalls)) {
|
|
32
|
+
for (const tc of toolCalls) {
|
|
33
|
+
const name = tc?.name;
|
|
34
|
+
if (typeof name === 'string' && name.length > 0)
|
|
35
|
+
acc.tools.add(name);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Executed tool result (ToolMessage). Its `.name` is the tool that produced the result.
|
|
39
|
+
const type = typeof m.getType === 'function' ? m.getType() : m._getType?.();
|
|
40
|
+
if (type === 'tool' && typeof m.name === 'string' && m.name.length > 0) {
|
|
41
|
+
acc.tools.add(m.name);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
/* fail-soft: never let stats capture affect a run */
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Freeze the accumulator into the public {@link GthRunStats}. Tokens omitted unless observed. */
|
|
49
|
+
export function finalizeRunStats(acc) {
|
|
50
|
+
return {
|
|
51
|
+
tokensInput: acc.sawUsage ? acc.input : undefined,
|
|
52
|
+
tokensOutput: acc.sawUsage ? acc.output : undefined,
|
|
53
|
+
tools: [...acc.tools],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* One-shot convenience for the non-streaming path: fold a full `messages[]` (e.g. the final graph
|
|
58
|
+
* state) into a fresh accumulator and finalize. Fail-soft (a non-iterable input yields empties).
|
|
59
|
+
*/
|
|
60
|
+
export function extractRunStats(messages) {
|
|
61
|
+
const acc = createRunStatsAccumulator();
|
|
62
|
+
try {
|
|
63
|
+
if (Array.isArray(messages)) {
|
|
64
|
+
for (const m of messages)
|
|
65
|
+
accumulateMessage(acc, m);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
/* fail-soft */
|
|
70
|
+
}
|
|
71
|
+
return finalizeRunStats(acc);
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=runStats.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"runStats.js","sourceRoot":"","sources":["../../src/core/runStats.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module core/shell/ShellCommandFailedError
|
|
3
|
+
*
|
|
4
|
+
* The typed error a `run_*` shell/dev tool throws when a command did NOT exit cleanly
|
|
5
|
+
* (non-zero exit code, or killed for exceeding the timeout). It carries the FULL model-facing
|
|
6
|
+
* body so a softening middleware can hand the model the exact observation it saw before — the
|
|
7
|
+
* only change being that the tool result's status flips to `'error'` (→ `isError` → the ✗ glyph).
|
|
8
|
+
*
|
|
9
|
+
* Canonical home is **core** so BOTH agents can recognise a shell failure without violating the
|
|
10
|
+
* `agent → core` dependency direction:
|
|
11
|
+
* - the deep agent (`code` mode, `@gaunt-sloth/agent` `GthDeepShellExitSoftening`) and
|
|
12
|
+
* - the lean agent (`exec` / `ask --write`, core `GthLangChainAgent` `GthLeanShellExitSoftening`).
|
|
13
|
+
*
|
|
14
|
+
* The throw site (`GthDevToolkit.executeCommand`) lives in the `agent` package and re-exports this
|
|
15
|
+
* class, so its `throw new ShellCommandFailedError(...)` is one and the same type both agents catch.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* A `run_*` command that did NOT exit cleanly (non-zero exit code, or was killed for exceeding the
|
|
19
|
+
* timeout). Carries the FULL model-facing body text ({@link output}) so a softening middleware can
|
|
20
|
+
* hand the model the exact same observation it saw before — the only change is the tool result's
|
|
21
|
+
* status flips to `'error'`, which drives the ✗ (`isError`) glyph.
|
|
22
|
+
*
|
|
23
|
+
* `executeCommand` previously `resolve()`d on a non-zero exit, so the LangChain `ToolMessage` stayed
|
|
24
|
+
* `status: 'success'` and every failure rendered a ✓. Throwing this typed error instead lets each
|
|
25
|
+
* agent's softening middleware convert it into an error `ToolMessage`. A clean exit (`code === 0`)
|
|
26
|
+
* still `resolve()`s; a spawn-level `child.on('error')` still rejects with a plain `Error`.
|
|
27
|
+
*/
|
|
28
|
+
export declare class ShellCommandFailedError extends Error {
|
|
29
|
+
/** The full model-facing body (command echo + `<COMMAND_OUTPUT>` + the failure/timeout tail). */
|
|
30
|
+
readonly output: string;
|
|
31
|
+
/** The process exit code; `null` when the command was killed (timeout) and never exited cleanly. */
|
|
32
|
+
readonly exitCode: number | null;
|
|
33
|
+
/** The exact command string that was executed. */
|
|
34
|
+
readonly command: string;
|
|
35
|
+
/** The run_* tool name that invoked the command (e.g. `run_tests`, `run_shell_command`). */
|
|
36
|
+
readonly toolName: string;
|
|
37
|
+
constructor(params: {
|
|
38
|
+
output: string;
|
|
39
|
+
exitCode: number | null;
|
|
40
|
+
command: string;
|
|
41
|
+
toolName: string;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Recognise a {@link ShellCommandFailedError} for the softening middleware in either agent.
|
|
46
|
+
*
|
|
47
|
+
* Prefers a plain `instanceof` (both agents share this one core module, so the class identity is
|
|
48
|
+
* the same), but falls back to a STRUCTURAL check keyed on `name === 'ShellCommandFailedError'`
|
|
49
|
+
* plus the carried fields. The structural arm is deliberate defence against a dual-package /
|
|
50
|
+
* realm-boundary hazard: if the error ever crossed a module boundary that broke `instanceof`, we
|
|
51
|
+
* would otherwise silently rethrow a real shell failure and regress the ✗ signal. Every field the
|
|
52
|
+
* softener reads (`output`) is asserted so a narrowed value is safe to use.
|
|
53
|
+
*/
|
|
54
|
+
export declare function isShellCommandFailedError(e: unknown): e is ShellCommandFailedError;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module core/shell/ShellCommandFailedError
|
|
3
|
+
*
|
|
4
|
+
* The typed error a `run_*` shell/dev tool throws when a command did NOT exit cleanly
|
|
5
|
+
* (non-zero exit code, or killed for exceeding the timeout). It carries the FULL model-facing
|
|
6
|
+
* body so a softening middleware can hand the model the exact observation it saw before — the
|
|
7
|
+
* only change being that the tool result's status flips to `'error'` (→ `isError` → the ✗ glyph).
|
|
8
|
+
*
|
|
9
|
+
* Canonical home is **core** so BOTH agents can recognise a shell failure without violating the
|
|
10
|
+
* `agent → core` dependency direction:
|
|
11
|
+
* - the deep agent (`code` mode, `@gaunt-sloth/agent` `GthDeepShellExitSoftening`) and
|
|
12
|
+
* - the lean agent (`exec` / `ask --write`, core `GthLangChainAgent` `GthLeanShellExitSoftening`).
|
|
13
|
+
*
|
|
14
|
+
* The throw site (`GthDevToolkit.executeCommand`) lives in the `agent` package and re-exports this
|
|
15
|
+
* class, so its `throw new ShellCommandFailedError(...)` is one and the same type both agents catch.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* A `run_*` command that did NOT exit cleanly (non-zero exit code, or was killed for exceeding the
|
|
19
|
+
* timeout). Carries the FULL model-facing body text ({@link output}) so a softening middleware can
|
|
20
|
+
* hand the model the exact same observation it saw before — the only change is the tool result's
|
|
21
|
+
* status flips to `'error'`, which drives the ✗ (`isError`) glyph.
|
|
22
|
+
*
|
|
23
|
+
* `executeCommand` previously `resolve()`d on a non-zero exit, so the LangChain `ToolMessage` stayed
|
|
24
|
+
* `status: 'success'` and every failure rendered a ✓. Throwing this typed error instead lets each
|
|
25
|
+
* agent's softening middleware convert it into an error `ToolMessage`. A clean exit (`code === 0`)
|
|
26
|
+
* still `resolve()`s; a spawn-level `child.on('error')` still rejects with a plain `Error`.
|
|
27
|
+
*/
|
|
28
|
+
export class ShellCommandFailedError extends Error {
|
|
29
|
+
/** The full model-facing body (command echo + `<COMMAND_OUTPUT>` + the failure/timeout tail). */
|
|
30
|
+
output;
|
|
31
|
+
/** The process exit code; `null` when the command was killed (timeout) and never exited cleanly. */
|
|
32
|
+
exitCode;
|
|
33
|
+
/** The exact command string that was executed. */
|
|
34
|
+
command;
|
|
35
|
+
/** The run_* tool name that invoked the command (e.g. `run_tests`, `run_shell_command`). */
|
|
36
|
+
toolName;
|
|
37
|
+
constructor(params) {
|
|
38
|
+
// Use the full body as the Error message so any generic logger/handler still surfaces the
|
|
39
|
+
// real command output rather than an opaque wrapper string.
|
|
40
|
+
super(params.output);
|
|
41
|
+
this.name = 'ShellCommandFailedError';
|
|
42
|
+
this.output = params.output;
|
|
43
|
+
this.exitCode = params.exitCode;
|
|
44
|
+
this.command = params.command;
|
|
45
|
+
this.toolName = params.toolName;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Recognise a {@link ShellCommandFailedError} for the softening middleware in either agent.
|
|
50
|
+
*
|
|
51
|
+
* Prefers a plain `instanceof` (both agents share this one core module, so the class identity is
|
|
52
|
+
* the same), but falls back to a STRUCTURAL check keyed on `name === 'ShellCommandFailedError'`
|
|
53
|
+
* plus the carried fields. The structural arm is deliberate defence against a dual-package /
|
|
54
|
+
* realm-boundary hazard: if the error ever crossed a module boundary that broke `instanceof`, we
|
|
55
|
+
* would otherwise silently rethrow a real shell failure and regress the ✗ signal. Every field the
|
|
56
|
+
* softener reads (`output`) is asserted so a narrowed value is safe to use.
|
|
57
|
+
*/
|
|
58
|
+
export function isShellCommandFailedError(e) {
|
|
59
|
+
if (e instanceof ShellCommandFailedError)
|
|
60
|
+
return true;
|
|
61
|
+
return (typeof e === 'object' &&
|
|
62
|
+
e !== null &&
|
|
63
|
+
e.name === 'ShellCommandFailedError' &&
|
|
64
|
+
typeof e.output === 'string' &&
|
|
65
|
+
typeof e.command === 'string' &&
|
|
66
|
+
typeof e.toolName === 'string');
|
|
67
|
+
}
|
|
68
|
+
//# sourceMappingURL=ShellCommandFailedError.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ShellCommandFailedError.js","sourceRoot":"","sources":["../../../src/core/shell/ShellCommandFailedError.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH;;;;;;;;;;GAUG;AACH,MAAM,OAAO,uBAAwB,SAAQ,KAAK;IAChD,iGAAiG;IACxF,MAAM,CAAS;IACxB,oGAAoG;IAC3F,QAAQ,CAAgB;IACjC,kDAAkD;IACzC,OAAO,CAAS;IACzB,4FAA4F;IACnF,QAAQ,CAAS;IAE1B,YAAY,MAKX;QACC,0FAA0F;QAC1F,4DAA4D;QAC5D,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;QACrB,IAAI,CAAC,IAAI,GAAG,yBAAyB,CAAC;QACtC,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC;QAC5B,IAAI,CAAC,QAAQ,GAAG,MAAM,CAAC,QAAQ,CAAC;QAChC,IAAI,CAAC,OAAO,GAAG,MAAM,CAAC,OAAO,CAAC;QAC9B,IAAI,CAAC,QAAQ,GAAG,MAAM,CAAC,QAAQ,CAAC;IAClC,CAAC;CACF;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,yBAAyB,CAAC,CAAU;IAClD,IAAI,CAAC,YAAY,uBAAuB;QAAE,OAAO,IAAI,CAAC;IACtD,OAAO,CACL,OAAO,CAAC,KAAK,QAAQ;QACrB,CAAC,KAAK,IAAI;QACT,CAAwB,CAAC,IAAI,KAAK,yBAAyB;QAC5D,OAAQ,CAA0B,CAAC,MAAM,KAAK,QAAQ;QACtD,OAAQ,CAA2B,CAAC,OAAO,KAAK,QAAQ;QACxD,OAAQ,CAA4B,CAAC,QAAQ,KAAK,QAAQ,CAC3D,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { ToolApprovalScope } from '#src/core/types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Approval persistence scopes, widest-lived last. Alias of the canonical
|
|
4
|
+
* {@link ToolApprovalScope} so the allow-list engine and the decision vocabulary stay in sync.
|
|
5
|
+
*/
|
|
6
|
+
export type ApprovalScope = ToolApprovalScope;
|
|
7
|
+
/**
|
|
8
|
+
* Decide whether a candidate argv contains a flag that *widens* the approved operation.
|
|
9
|
+
*
|
|
10
|
+
* Rule implemented (documented for the coordinator):
|
|
11
|
+
* - Re-derive the meaningful prefix from the candidate's actual argv.
|
|
12
|
+
* - Inspect every token of the candidate that is a flag (starts with `-`).
|
|
13
|
+
* - If any flag token — normalized by stripping a trailing `=value` and lowercasing —
|
|
14
|
+
* is in {@link WIDENING_FLAGS}, the command is considered a widening of the approved
|
|
15
|
+
* operation and the match is REFUSED (returns true).
|
|
16
|
+
*
|
|
17
|
+
* This is purposely a deny-list of operation-changing flags rather than an allow-list of
|
|
18
|
+
* benign flags: benign flag variants (`-b`, `--oneline`, `-la`) are exactly what we WANT
|
|
19
|
+
* to keep auto-approving, while the handful of "run-an-arbitrary-program / redirect-the-
|
|
20
|
+
* transport" flags are what an injected approval must never silently enable.
|
|
21
|
+
*/
|
|
22
|
+
export declare function hasWideningFlag(argv: string[]): boolean;
|
|
23
|
+
/**
|
|
24
|
+
* A holder of approved prefixes with set semantics. Used for both the in-memory session
|
|
25
|
+
* store and the loaded persisted store. Pure data + membership; persistence is layered on
|
|
26
|
+
* top by {@link PersistedAllowlist}.
|
|
27
|
+
*/
|
|
28
|
+
export declare class AllowlistStore {
|
|
29
|
+
private readonly prefixes;
|
|
30
|
+
constructor(initial?: Iterable<string>);
|
|
31
|
+
has(prefix: string): boolean;
|
|
32
|
+
add(prefix: string): void;
|
|
33
|
+
list(): string[];
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The persisted (`always`) allow-list, backed by a JSON file. The path is injected (the
|
|
37
|
+
* runner resolves it via fileUtils → `.gsloth/.gsloth-settings/shell-allowlist.json`) so
|
|
38
|
+
* tests can point it at a temp dir. Loads lazily/defensively: a missing or malformed file
|
|
39
|
+
* yields an empty store rather than throwing (fail-open on READ is safe — an empty
|
|
40
|
+
* allow-list just means "prompt"; it never auto-approves anything).
|
|
41
|
+
*/
|
|
42
|
+
export declare class PersistedAllowlist {
|
|
43
|
+
private readonly store;
|
|
44
|
+
private readonly filePath;
|
|
45
|
+
constructor(filePath: string);
|
|
46
|
+
private static load;
|
|
47
|
+
has(prefix: string): boolean;
|
|
48
|
+
list(): string[];
|
|
49
|
+
/** Add a prefix and persist the whole set to disk. */
|
|
50
|
+
add(prefix: string): void;
|
|
51
|
+
private persist;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Read-only view over the stores consulted for an auto-approval check. The runner passes
|
|
55
|
+
* its per-instance session store and (optionally) the persisted store.
|
|
56
|
+
*/
|
|
57
|
+
export interface ApprovalStores {
|
|
58
|
+
session: Pick<AllowlistStore, 'has'>;
|
|
59
|
+
always?: Pick<PersistedAllowlist, 'has'>;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Decide whether `command` is already approved by the given stores — the gate the runner
|
|
63
|
+
* consults BEFORE prompting the human.
|
|
64
|
+
*
|
|
65
|
+
* Returns true ONLY when ALL of the following hold:
|
|
66
|
+
* 1. {@link classifyCommand} returns a non-null classification (so composition /
|
|
67
|
+
* substitution / redirection commands can never match — anti-injection layer 1);
|
|
68
|
+
* 2. the classified prefix is present in the session OR persisted (`always`) store; AND
|
|
69
|
+
* 3. the safe-bin re-validation passes: the candidate's actual argv contains no
|
|
70
|
+
* operation-widening flag (anti-injection layer 2 — see {@link hasWideningFlag}).
|
|
71
|
+
*
|
|
72
|
+
* Anything else (unclassifiable, unknown prefix, or a widening flag) → false → the human
|
|
73
|
+
* is prompted. Fail-closed by construction.
|
|
74
|
+
*/
|
|
75
|
+
export declare function matchesApproval(command: string, stores: ApprovalStores): boolean;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module core/shell/allowlist
|
|
3
|
+
*
|
|
4
|
+
* EXT-9 Tier-2: the persisted + session-scoped allow-list engine for the opt-in
|
|
5
|
+
* `run_shell_command` tool. Once a human approves a command at `session` or `always`
|
|
6
|
+
* scope, future commands with the same classified prefix ({@link classifyCommand}) are
|
|
7
|
+
* auto-approved without re-prompting — the ergonomics improvement mature agents
|
|
8
|
+
* (opencode/openclaw/hermes) ship.
|
|
9
|
+
*
|
|
10
|
+
* SECURITY — a naive "remember the prefix" allow-list is an injection vector. Two layers
|
|
11
|
+
* guard it:
|
|
12
|
+
* 1. **Classification fail-closed** (arity.ts): any command with shell composition,
|
|
13
|
+
* substitution, or redirection returns `null` and can NEVER match, so
|
|
14
|
+
* `git checkout x; rm -rf /` does not ride an approved `git checkout *`.
|
|
15
|
+
* 2. **Safe-bin anti-widening re-validation** (here, after opencode/openclaw): a stored
|
|
16
|
+
* approval is matched ONLY if the candidate command's first non-flag operand region
|
|
17
|
+
* does not introduce a flag that *widens or redirects* the approved operation. The
|
|
18
|
+
* exact rule is documented on {@link matchesApproval} / {@link hasWideningFlag}.
|
|
19
|
+
*
|
|
20
|
+
* No module-global mutable state: the session store is a per-instance class so concurrent
|
|
21
|
+
* sessions (ACP / AG-UI multi-session) cannot stomp each other. The persisted (`always`)
|
|
22
|
+
* store is a small JSON file the runner loads once per instance.
|
|
23
|
+
*/
|
|
24
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import { classifyCommand, tokenize } from '#src/core/shell/arity.js';
|
|
26
|
+
import { normalizeCommand } from '#src/core/shell/normalize.js';
|
|
27
|
+
const PERSISTED_VERSION = 1;
|
|
28
|
+
/**
|
|
29
|
+
* Flags that, if present in a candidate command but not implied by the approved prefix,
|
|
30
|
+
* could change the *operation* the human approved (point it at a different transport,
|
|
31
|
+
* exec a hook, follow an attacker-controlled URL). Approving `git clone` must not
|
|
32
|
+
* green-light `git clone --upload-pack=evil`. This list is intentionally conservative:
|
|
33
|
+
* it is the openclaw "safe-bin" idea reduced to a deny-set of operation-changing flags
|
|
34
|
+
* that commonly appear in shell-injection / supply-chain abuse.
|
|
35
|
+
*
|
|
36
|
+
* Matched against the flag token with any `=value` stripped, case-insensitively.
|
|
37
|
+
*/
|
|
38
|
+
const WIDENING_FLAGS = new Set([
|
|
39
|
+
// git: remote command / hook overrides.
|
|
40
|
+
'--upload-pack',
|
|
41
|
+
'--receive-pack',
|
|
42
|
+
'--exec',
|
|
43
|
+
'-u', // git clone -u <upload-pack>
|
|
44
|
+
'--config', // git -c is the short form; --config can inject core.sshCommand etc.
|
|
45
|
+
'-c', // git -c core.sshCommand='...' — arbitrary command execution
|
|
46
|
+
// generic "run this program" escape hatches across tools.
|
|
47
|
+
'--exec-path',
|
|
48
|
+
'-exec', // find -exec <cmd>
|
|
49
|
+
'--use-askpass',
|
|
50
|
+
'--ssh-command',
|
|
51
|
+
// package managers: lifecycle-script / arbitrary-script toggles.
|
|
52
|
+
'--unsafe-perm',
|
|
53
|
+
'--ignore-scripts=false',
|
|
54
|
+
'--allow-scripts',
|
|
55
|
+
// curl/wget style follow/exec (in case a bare binary is approved).
|
|
56
|
+
'-o', // write to arbitrary path
|
|
57
|
+
'--output',
|
|
58
|
+
'-T', // upload
|
|
59
|
+
'--upload-file',
|
|
60
|
+
]);
|
|
61
|
+
/**
|
|
62
|
+
* Decide whether a candidate argv contains a flag that *widens* the approved operation.
|
|
63
|
+
*
|
|
64
|
+
* Rule implemented (documented for the coordinator):
|
|
65
|
+
* - Re-derive the meaningful prefix from the candidate's actual argv.
|
|
66
|
+
* - Inspect every token of the candidate that is a flag (starts with `-`).
|
|
67
|
+
* - If any flag token — normalized by stripping a trailing `=value` and lowercasing —
|
|
68
|
+
* is in {@link WIDENING_FLAGS}, the command is considered a widening of the approved
|
|
69
|
+
* operation and the match is REFUSED (returns true).
|
|
70
|
+
*
|
|
71
|
+
* This is purposely a deny-list of operation-changing flags rather than an allow-list of
|
|
72
|
+
* benign flags: benign flag variants (`-b`, `--oneline`, `-la`) are exactly what we WANT
|
|
73
|
+
* to keep auto-approving, while the handful of "run-an-arbitrary-program / redirect-the-
|
|
74
|
+
* transport" flags are what an injected approval must never silently enable.
|
|
75
|
+
*/
|
|
76
|
+
export function hasWideningFlag(argv) {
|
|
77
|
+
for (const tok of argv) {
|
|
78
|
+
if (!tok.startsWith('-'))
|
|
79
|
+
continue;
|
|
80
|
+
const bare = tok.split('=', 1)[0].toLowerCase();
|
|
81
|
+
if (WIDENING_FLAGS.has(bare))
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* A holder of approved prefixes with set semantics. Used for both the in-memory session
|
|
88
|
+
* store and the loaded persisted store. Pure data + membership; persistence is layered on
|
|
89
|
+
* top by {@link PersistedAllowlist}.
|
|
90
|
+
*/
|
|
91
|
+
export class AllowlistStore {
|
|
92
|
+
prefixes;
|
|
93
|
+
constructor(initial = []) {
|
|
94
|
+
this.prefixes = new Set(initial);
|
|
95
|
+
}
|
|
96
|
+
has(prefix) {
|
|
97
|
+
return this.prefixes.has(prefix);
|
|
98
|
+
}
|
|
99
|
+
add(prefix) {
|
|
100
|
+
this.prefixes.add(prefix);
|
|
101
|
+
}
|
|
102
|
+
list() {
|
|
103
|
+
return [...this.prefixes];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The persisted (`always`) allow-list, backed by a JSON file. The path is injected (the
|
|
108
|
+
* runner resolves it via fileUtils → `.gsloth/.gsloth-settings/shell-allowlist.json`) so
|
|
109
|
+
* tests can point it at a temp dir. Loads lazily/defensively: a missing or malformed file
|
|
110
|
+
* yields an empty store rather than throwing (fail-open on READ is safe — an empty
|
|
111
|
+
* allow-list just means "prompt"; it never auto-approves anything).
|
|
112
|
+
*/
|
|
113
|
+
export class PersistedAllowlist {
|
|
114
|
+
store;
|
|
115
|
+
filePath;
|
|
116
|
+
constructor(filePath) {
|
|
117
|
+
this.filePath = filePath;
|
|
118
|
+
this.store = new AllowlistStore(PersistedAllowlist.load(filePath));
|
|
119
|
+
}
|
|
120
|
+
static load(filePath) {
|
|
121
|
+
try {
|
|
122
|
+
if (!existsSync(filePath))
|
|
123
|
+
return [];
|
|
124
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
125
|
+
const parsed = JSON.parse(raw);
|
|
126
|
+
if (!parsed || !Array.isArray(parsed.prefixes))
|
|
127
|
+
return [];
|
|
128
|
+
return parsed.prefixes.filter((p) => typeof p === 'string');
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// Corrupt/unreadable file → behave as empty (fail-closed on auto-approval).
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
has(prefix) {
|
|
136
|
+
return this.store.has(prefix);
|
|
137
|
+
}
|
|
138
|
+
list() {
|
|
139
|
+
return this.store.list();
|
|
140
|
+
}
|
|
141
|
+
/** Add a prefix and persist the whole set to disk. */
|
|
142
|
+
add(prefix) {
|
|
143
|
+
if (this.store.has(prefix))
|
|
144
|
+
return;
|
|
145
|
+
this.store.add(prefix);
|
|
146
|
+
this.persist();
|
|
147
|
+
}
|
|
148
|
+
persist() {
|
|
149
|
+
const file = {
|
|
150
|
+
version: PERSISTED_VERSION,
|
|
151
|
+
prefixes: this.store.list().sort(),
|
|
152
|
+
};
|
|
153
|
+
writeFileSync(this.filePath, JSON.stringify(file, null, 2) + '\n', 'utf8');
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Decide whether `command` is already approved by the given stores — the gate the runner
|
|
158
|
+
* consults BEFORE prompting the human.
|
|
159
|
+
*
|
|
160
|
+
* Returns true ONLY when ALL of the following hold:
|
|
161
|
+
* 1. {@link classifyCommand} returns a non-null classification (so composition /
|
|
162
|
+
* substitution / redirection commands can never match — anti-injection layer 1);
|
|
163
|
+
* 2. the classified prefix is present in the session OR persisted (`always`) store; AND
|
|
164
|
+
* 3. the safe-bin re-validation passes: the candidate's actual argv contains no
|
|
165
|
+
* operation-widening flag (anti-injection layer 2 — see {@link hasWideningFlag}).
|
|
166
|
+
*
|
|
167
|
+
* Anything else (unclassifiable, unknown prefix, or a widening flag) → false → the human
|
|
168
|
+
* is prompted. Fail-closed by construction.
|
|
169
|
+
*/
|
|
170
|
+
export function matchesApproval(command, stores) {
|
|
171
|
+
const classification = classifyCommand(command, normalizeCommand);
|
|
172
|
+
if (!classification)
|
|
173
|
+
return false; // layer 1: composition/redirection/substitution.
|
|
174
|
+
const approved = stores.session.has(classification.prefix) ||
|
|
175
|
+
(stores.always?.has(classification.prefix) ?? false);
|
|
176
|
+
if (!approved)
|
|
177
|
+
return false;
|
|
178
|
+
// Layer 2: re-derive argv from the normalized command and refuse if an operation-
|
|
179
|
+
// widening flag is present that the human's prefix-level approval never implied.
|
|
180
|
+
const argv = tokenize(normalizeCommand(command));
|
|
181
|
+
if (!argv)
|
|
182
|
+
return false; // unbalanced quoting → fail-closed.
|
|
183
|
+
if (hasWideningFlag(argv))
|
|
184
|
+
return false;
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
//# sourceMappingURL=allowlist.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"allowlist.js","sourceRoot":"","sources":["../../../src/core/shell/allowlist.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module core/shell/arity
|
|
3
|
+
*
|
|
4
|
+
* EXT-9 Tier-2 ergonomics: classify a shell command into a stable, human-readable
|
|
5
|
+
* **prefix** (binary + N meaningful subcommands) so an approval the human grants once
|
|
6
|
+
* (`git checkout main`) can be remembered as a pattern (`git checkout *`) and matched
|
|
7
|
+
* against later flag-variants (`git checkout -b foo bar`) WITHOUT re-prompting.
|
|
8
|
+
*
|
|
9
|
+
* The arity table (which tokens past the binary are "meaningful subcommands" rather
|
|
10
|
+
* than operands/flags) is a port of a subset of opencode's ~160-command table
|
|
11
|
+
* (`opencode/packages/opencode/src/permission/arity.ts`). Binaries not in the table
|
|
12
|
+
* default to arity 0 = just the binary.
|
|
13
|
+
*
|
|
14
|
+
* SECURITY — this is the anti-injection core. {@link classifyCommand} returns `null`
|
|
15
|
+
* (fail-closed) whenever the command contains shell composition that could change the
|
|
16
|
+
* target of the operation: separators (`;`, `&&`, `||`, `|`, `&`), newlines, command
|
|
17
|
+
* substitution (`$(...)`, backticks), process substitution (`<(...)`/`>(...)`), or
|
|
18
|
+
* redirections. Such commands NEVER auto-match an allow-list entry — they always go to
|
|
19
|
+
* fresh human approval. This is what stops `git checkout x; rm -rf /` from matching an
|
|
20
|
+
* approved `git checkout *`.
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* Result of classifying a command for allow-list matching.
|
|
24
|
+
*/
|
|
25
|
+
export interface CommandClassification {
|
|
26
|
+
/**
|
|
27
|
+
* The meaningful command prefix — binary plus subcommands per the arity table, with
|
|
28
|
+
* flags removed. This is the allow-list KEY: two commands with the same prefix are the
|
|
29
|
+
* "same operation" for approval purposes (`git checkout main` and `git checkout -b x`
|
|
30
|
+
* both → `git checkout`).
|
|
31
|
+
*/
|
|
32
|
+
prefix: string;
|
|
33
|
+
/**
|
|
34
|
+
* Human-facing display pattern, e.g. `git checkout *` (or `ls *`). The trailing `*`
|
|
35
|
+
* signals "any args/flags". A prefix that already consumed the whole command (no extra
|
|
36
|
+
* operands) still gets ` *` for a consistent, honest "future args allowed" affordance.
|
|
37
|
+
*/
|
|
38
|
+
pattern: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Tokenize a shell command into argv, honoring single and double quotes. Quotes group
|
|
42
|
+
* (and are stripped from) a token; backslash-escaping inside double quotes is collapsed
|
|
43
|
+
* by the prior normalize step, so this tokenizer treats a residual `\` literally.
|
|
44
|
+
*
|
|
45
|
+
* This is a deliberately small tokenizer used ONLY for prefix detection — never for
|
|
46
|
+
* execution. The original command string is what runs.
|
|
47
|
+
*
|
|
48
|
+
* Returns `null` if quoting is unbalanced (an open quote with no close), which is itself
|
|
49
|
+
* a reason to refuse classification (ambiguous parse → fail-closed).
|
|
50
|
+
*/
|
|
51
|
+
export declare function tokenize(command: string): string[] | null;
|
|
52
|
+
/**
|
|
53
|
+
* Look up the arity (number of leading meaningful tokens) for an argv, using the
|
|
54
|
+
* longest-matching-prefix rule against {@link ARITY}. Flag tokens (starting with `-`) are
|
|
55
|
+
* dropped when forming the candidate prefixes so boolean flags like `git --no-pager
|
|
56
|
+
* checkout` still resolve `git checkout`. Unknown binaries default to arity 0 → just the
|
|
57
|
+
* binary.
|
|
58
|
+
*
|
|
59
|
+
* LIMITATION (intentional, fail-closed): we do NOT maintain a per-flag arity table, so an
|
|
60
|
+
* arg-taking flag leaves its operand in the non-flag stream (e.g. `git -C . checkout` →
|
|
61
|
+
* `git .`). That simply fails to match an approved `git checkout` and re-prompts — safe, by
|
|
62
|
+
* design — rather than risking a mis-classification that mis-approves.
|
|
63
|
+
*
|
|
64
|
+
* Returns the list of meaningful tokens (binary + subcommands), flag tokens excluded.
|
|
65
|
+
*/
|
|
66
|
+
export declare function meaningfulPrefixTokens(argv: string[]): string[];
|
|
67
|
+
/**
|
|
68
|
+
* Classify a command into a stable allow-list prefix + display pattern, or `null` when it
|
|
69
|
+
* cannot be safely classified for matching (composition/redirection/substitution present,
|
|
70
|
+
* empty, or unbalanced quotes).
|
|
71
|
+
*
|
|
72
|
+
* @param command Raw command string as the model proposed it.
|
|
73
|
+
* @param normalize Normalizer to apply for the detection form (inject normalizeCommand).
|
|
74
|
+
*/
|
|
75
|
+
export declare function classifyCommand(command: string, normalize: (cmd: string) => string): CommandClassification | null;
|