@rex_koh/subagent-budget-guard 0.1.7 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +3 -3
- package/README.md +27 -9
- package/bin/agent-guard.js +69 -0
- package/bin/setup.js +115 -4
- package/lib/guard.js +26 -9
- package/lib/verifier.js +42 -38
- package/package.json +4 -2
- package/skills/doctor/SKILL.md +20 -0
- package/skills/init/SKILL.md +42 -0
- package/skills/report/SKILL.md +6 -4
- package/skills/setup/SKILL.md +20 -7
- package/skills/status/SKILL.md +18 -0
- package/skills/verify/SKILL.md +6 -4
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
|
-
"name": "
|
|
3
|
-
"displayName": "
|
|
2
|
+
"name": "agent-guard",
|
|
3
|
+
"displayName": "Agent Guard",
|
|
4
4
|
"description": "Hard-deny subagent launches, record verified subagent usage, and enforce a session budget against Claude Code's 5-hour rate-limit percentage.",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.2.0",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "ClaudeSubAgentSuppressor"
|
|
8
8
|
},
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Agent Guard
|
|
2
2
|
|
|
3
|
-
Claude Code plugin that
|
|
3
|
+
Claude Code plugin that guards subagent usage, records verified subagent tokens, and enforces a session budget against Claude Code's 5-hour usage percentage.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -8,17 +8,17 @@ Recommended Claude Code install:
|
|
|
8
8
|
|
|
9
9
|
```text
|
|
10
10
|
/plugin marketplace add rexkoh425/ClaudeSubAgentSuppressor
|
|
11
|
-
/plugin install
|
|
12
|
-
/
|
|
13
|
-
/
|
|
11
|
+
/plugin install agent-guard@subagent-budget-tools
|
|
12
|
+
/agent-guard:init
|
|
13
|
+
/agent-guard:doctor
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
After `/
|
|
16
|
+
After `/agent-guard:init`, fully exit and reopen Claude Code before verification so the statusLine bridge from `settings.json` is active. Some Claude Code builds do not provide an in-session plugin reload command.
|
|
17
17
|
|
|
18
18
|
Useful after install:
|
|
19
19
|
|
|
20
20
|
```text
|
|
21
|
-
/
|
|
21
|
+
/agent-guard:status
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
## NPM Package
|
|
@@ -29,7 +29,7 @@ Claude Code plugin discovery is marketplace-based, so npm is mainly useful as a
|
|
|
29
29
|
|
|
30
30
|
```bash
|
|
31
31
|
npm install -g @rex_koh/subagent-budget-guard
|
|
32
|
-
|
|
32
|
+
agent-guard doctor --offline
|
|
33
33
|
```
|
|
34
34
|
|
|
35
35
|
Maintainer publish command:
|
|
@@ -44,7 +44,7 @@ Offline verification:
|
|
|
44
44
|
node bin/verify.js --offline
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
The plugin is strict before setup: `max_concurrent_subagents` defaults to `0`, so normal subagent launches are blocked unless raised. Run `/
|
|
47
|
+
The plugin is strict before setup: `max_concurrent_subagents` defaults to `0`, so normal subagent launches are blocked unless raised. Run `/agent-guard:init` to choose defaults or custom values:
|
|
48
48
|
|
|
49
49
|
```text
|
|
50
50
|
max_concurrent_subagents=1
|
|
@@ -57,4 +57,22 @@ enforcement_enabled=true
|
|
|
57
57
|
|
|
58
58
|
For existing installs, setup also removes obsolete `max_subagents_per_session` and `max_agent_team_tasks_per_session` options from this plugin's Claude settings.
|
|
59
59
|
|
|
60
|
+
The setup skill can also ask for custom values. For direct terminal setup, use:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
agent-guard init
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Or pass explicit values:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
agent-guard init \
|
|
70
|
+
--config max_concurrent_subagents=2 \
|
|
71
|
+
--config max_subagent_tokens_per_session=250000 \
|
|
72
|
+
--config subagent_token_warning_threshold_percent=90 \
|
|
73
|
+
--config session_five_hour_budget_percent=15 \
|
|
74
|
+
--config absolute_five_hour_ceiling_percent=95 \
|
|
75
|
+
--config enforcement_enabled=true
|
|
76
|
+
```
|
|
77
|
+
|
|
60
78
|
`max_subagent_tokens_per_session` is enforced from verified `Agent.totalTokens` values after each completed subagent. `subagent_token_warning_threshold_percent` defaults to `95`; once verified subagent usage reaches that percentage, the plugin tells Claude to stop using subagents and blocks future subagent launches. Claude Code does not expose mid-run per-token subagent streaming to hooks, so a single running subagent can only be evaluated when it reports its final token total.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const BIN_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
|
|
8
|
+
const COMMANDS = Object.freeze({
|
|
9
|
+
init: {
|
|
10
|
+
script: 'setup.js',
|
|
11
|
+
help: 'initialize settings and the statusLine bridge'
|
|
12
|
+
},
|
|
13
|
+
status: {
|
|
14
|
+
script: 'report.js',
|
|
15
|
+
help: 'show the current guard report'
|
|
16
|
+
},
|
|
17
|
+
doctor: {
|
|
18
|
+
script: 'verify.js',
|
|
19
|
+
help: 'verify the install without spending Claude quota'
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function usage() {
|
|
24
|
+
return [
|
|
25
|
+
'Usage: agent-guard <command> [options]',
|
|
26
|
+
'',
|
|
27
|
+
'Commands:',
|
|
28
|
+
...Object.entries(COMMANDS).map(([name, info]) => ` ${name.padEnd(8)} ${info.help}`),
|
|
29
|
+
'',
|
|
30
|
+
'Examples:',
|
|
31
|
+
' agent-guard init',
|
|
32
|
+
' agent-guard init --defaults',
|
|
33
|
+
' agent-guard status',
|
|
34
|
+
' agent-guard doctor --offline'
|
|
35
|
+
].join('\n');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function main() {
|
|
39
|
+
const [command, ...args] = process.argv.slice(2);
|
|
40
|
+
|
|
41
|
+
if (!command || command === '--help' || command === '-h') {
|
|
42
|
+
process.stdout.write(`${usage()}\n`);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const target = COMMANDS[command];
|
|
47
|
+
if (!target) {
|
|
48
|
+
process.stderr.write(`Unknown command "${command}".\n\n${usage()}\n`);
|
|
49
|
+
process.exitCode = 1;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const child = spawnSync(process.execPath, [path.join(BIN_DIR, target.script), ...args], {
|
|
54
|
+
stdio: 'inherit',
|
|
55
|
+
env: process.env,
|
|
56
|
+
cwd: process.cwd(),
|
|
57
|
+
windowsHide: true
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (child.error) {
|
|
61
|
+
process.stderr.write(`${child.error.message}\n`);
|
|
62
|
+
process.exitCode = 1;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
process.exitCode = child.status ?? 1;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
main();
|
package/bin/setup.js
CHANGED
|
@@ -1,17 +1,128 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { createInterface } from 'node:readline/promises';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
CONFIG_KEYS,
|
|
6
|
+
SETUP_CONFIG,
|
|
7
|
+
buildSetupConfig,
|
|
8
|
+
getDataDir,
|
|
9
|
+
getHomeDir,
|
|
10
|
+
getPluginRoot,
|
|
11
|
+
installStatusLineBridge
|
|
12
|
+
} from '../lib/guard.js';
|
|
13
|
+
|
|
14
|
+
const CONFIG_KEY_SET = new Set(CONFIG_KEYS);
|
|
15
|
+
|
|
16
|
+
function usage() {
|
|
17
|
+
return [
|
|
18
|
+
'Usage: agent-guard init [--defaults] [--interactive] [--config key=value ...]',
|
|
19
|
+
'',
|
|
20
|
+
'Config keys:',
|
|
21
|
+
...CONFIG_KEYS.map((key) => ` ${key} (default ${SETUP_CONFIG[key]})`)
|
|
22
|
+
].join('\n');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseConfigPair(pair) {
|
|
26
|
+
const index = pair.indexOf('=');
|
|
27
|
+
if (index <= 0) {
|
|
28
|
+
throw new Error(`Invalid --config value "${pair}". Expected key=value.`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const key = pair.slice(0, index);
|
|
32
|
+
const value = pair.slice(index + 1);
|
|
33
|
+
if (!CONFIG_KEY_SET.has(key)) {
|
|
34
|
+
throw new Error(`Unknown config key "${key}". Valid keys: ${CONFIG_KEYS.join(', ')}`);
|
|
35
|
+
}
|
|
36
|
+
return [key, value];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseArgs(args) {
|
|
40
|
+
const options = {
|
|
41
|
+
interactive: false,
|
|
42
|
+
defaults: false,
|
|
43
|
+
overrides: {}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
47
|
+
const arg = args[index];
|
|
48
|
+
if (arg === '--help' || arg === '-h') {
|
|
49
|
+
process.stdout.write(`${usage()}\n`);
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
if (arg === '--interactive') {
|
|
53
|
+
options.interactive = true;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (arg === '--defaults' || arg === '--yes' || arg === '-y') {
|
|
57
|
+
options.defaults = true;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (arg === '--config') {
|
|
61
|
+
const pair = args[index + 1];
|
|
62
|
+
if (!pair) throw new Error('--config requires key=value');
|
|
63
|
+
const [key, value] = parseConfigPair(pair);
|
|
64
|
+
options.overrides[key] = value;
|
|
65
|
+
index += 1;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (arg.startsWith('--config=')) {
|
|
69
|
+
const [key, value] = parseConfigPair(arg.slice('--config='.length));
|
|
70
|
+
options.overrides[key] = value;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
throw new Error(`Unknown argument "${arg}".\n${usage()}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return options;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function promptForConfig(defaults, { askMode = true } = {}) {
|
|
80
|
+
const rl = createInterface({
|
|
81
|
+
input: process.stdin,
|
|
82
|
+
output: process.stderr
|
|
83
|
+
});
|
|
84
|
+
const answers = {};
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
if (askMode) {
|
|
88
|
+
const useDefaults = await rl.question('Use recommended defaults? [Y/n]: ');
|
|
89
|
+
if (!useDefaults.trim() || /^y(es)?$/i.test(useDefaults.trim())) {
|
|
90
|
+
return buildSetupConfig(defaults);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for (const key of CONFIG_KEYS) {
|
|
95
|
+
const answer = await rl.question(`${key} [${defaults[key]}]: `);
|
|
96
|
+
if (answer.trim()) {
|
|
97
|
+
answers[key] = answer.trim();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} finally {
|
|
101
|
+
rl.close();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return buildSetupConfig({ ...defaults, ...answers });
|
|
105
|
+
}
|
|
3
106
|
|
|
4
107
|
async function main() {
|
|
108
|
+
const options = parseArgs(process.argv.slice(2));
|
|
109
|
+
const defaults = buildSetupConfig(options.overrides);
|
|
110
|
+
const setupConfig = options.interactive
|
|
111
|
+
? await promptForConfig(defaults, { askMode: false })
|
|
112
|
+
: !options.defaults && Object.keys(options.overrides).length === 0 && process.stdin.isTTY
|
|
113
|
+
? await promptForConfig(defaults)
|
|
114
|
+
: defaults;
|
|
5
115
|
const result = await installStatusLineBridge({
|
|
6
116
|
homeDir: getHomeDir(process.env),
|
|
7
117
|
pluginRoot: getPluginRoot(process.env),
|
|
8
|
-
pluginData: getDataDir(process.env)
|
|
118
|
+
pluginData: getDataDir(process.env),
|
|
119
|
+
setupConfig
|
|
9
120
|
});
|
|
10
121
|
|
|
11
122
|
process.stdout.write(
|
|
12
123
|
[
|
|
13
|
-
'
|
|
14
|
-
'
|
|
124
|
+
'Agent Guard statusLine bridge installed.',
|
|
125
|
+
'Plugin config applied:',
|
|
15
126
|
` max_concurrent_subagents=${result.pluginConfigOptions.max_concurrent_subagents}`,
|
|
16
127
|
` max_subagent_tokens_per_session=${result.pluginConfigOptions.max_subagent_tokens_per_session}`,
|
|
17
128
|
` subagent_token_warning_threshold_percent=${result.pluginConfigOptions.subagent_token_warning_threshold_percent}`,
|
package/lib/guard.js
CHANGED
|
@@ -17,8 +17,9 @@ import { fileURLToPath } from 'node:url';
|
|
|
17
17
|
|
|
18
18
|
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
19
19
|
|
|
20
|
-
export const PLUGIN_NAME = '
|
|
21
|
-
export const PLUGIN_ID = '
|
|
20
|
+
export const PLUGIN_NAME = 'agent-guard';
|
|
21
|
+
export const PLUGIN_ID = 'agent-guard@subagent-budget-tools';
|
|
22
|
+
export const LEGACY_PLUGIN_ID = 'subagent-budget-guard@subagent-budget-tools';
|
|
22
23
|
|
|
23
24
|
export const DEFAULT_CONFIG = Object.freeze({
|
|
24
25
|
max_concurrent_subagents: 0,
|
|
@@ -87,7 +88,9 @@ function readSettingsOptions(env) {
|
|
|
87
88
|
try {
|
|
88
89
|
const text = readFileSync(settingsPath, 'utf8');
|
|
89
90
|
const settings = JSON.parse(text.replace(/^\uFEFF/, ''));
|
|
90
|
-
const options =
|
|
91
|
+
const options =
|
|
92
|
+
settings?.pluginConfigs?.[PLUGIN_ID]?.options ||
|
|
93
|
+
settings?.pluginConfigs?.[LEGACY_PLUGIN_ID]?.options;
|
|
91
94
|
return isPlainObject(options) ? options : {};
|
|
92
95
|
} catch (error) {
|
|
93
96
|
if (error.code === 'ENOENT') return {};
|
|
@@ -134,6 +137,12 @@ export function loadConfig(env = process.env) {
|
|
|
134
137
|
return normalizeConfig(config);
|
|
135
138
|
}
|
|
136
139
|
|
|
140
|
+
export function buildSetupConfig(overrides = {}) {
|
|
141
|
+
const config = { ...SETUP_CONFIG };
|
|
142
|
+
applyConfigValues(config, (key) => overrides[key]);
|
|
143
|
+
return normalizeConfig(config);
|
|
144
|
+
}
|
|
145
|
+
|
|
137
146
|
export function getHomeDir(env = process.env) {
|
|
138
147
|
return env.USERPROFILE || env.HOME || os.homedir();
|
|
139
148
|
}
|
|
@@ -754,7 +763,7 @@ export function formatReport(report) {
|
|
|
754
763
|
const { state, config, summary } = report;
|
|
755
764
|
const fiveHour = state.rateLimits.fiveHour;
|
|
756
765
|
const lines = [
|
|
757
|
-
`
|
|
766
|
+
`Agent Guard report for ${report.sessionId}`,
|
|
758
767
|
`Enforcement: ${config.enforcement_enabled ? 'enabled' : 'disabled'}`,
|
|
759
768
|
`Subagents: allowed ${state.subagents.allowed}, denied ${state.subagents.denied}, active ${state.subagents.active}, lifecycle starts ${state.subagents.lifecycleStarted}, lifecycle stops ${state.subagents.lifecycleStopped}`,
|
|
760
769
|
`Verified usage: ${summary.verifiedTokenLabel}, ${state.subagents.totalToolUseCount} subagent tool calls, ${state.subagents.totalDurationMs} ms`,
|
|
@@ -770,7 +779,7 @@ export function formatReport(report) {
|
|
|
770
779
|
);
|
|
771
780
|
} else {
|
|
772
781
|
lines.push(
|
|
773
|
-
'5-hour latest: unavailable. Run /
|
|
782
|
+
'5-hour latest: unavailable. Run /agent-guard:init so the statusLine bridge can capture rate_limits.five_hour.used_percentage.'
|
|
774
783
|
);
|
|
775
784
|
}
|
|
776
785
|
|
|
@@ -816,26 +825,33 @@ function applySetupPluginConfig(
|
|
|
816
825
|
settings.pluginConfigs = {};
|
|
817
826
|
}
|
|
818
827
|
|
|
828
|
+
const legacyEntry = isPlainObject(settings.pluginConfigs[LEGACY_PLUGIN_ID])
|
|
829
|
+
? settings.pluginConfigs[LEGACY_PLUGIN_ID]
|
|
830
|
+
: {};
|
|
819
831
|
const currentEntry = isPlainObject(settings.pluginConfigs[pluginId])
|
|
820
832
|
? settings.pluginConfigs[pluginId]
|
|
821
|
-
:
|
|
833
|
+
: legacyEntry;
|
|
822
834
|
const currentOptions = isPlainObject(currentEntry.options)
|
|
823
835
|
? currentEntry.options
|
|
824
836
|
: {};
|
|
825
837
|
const nextOptions = { ...currentOptions };
|
|
838
|
+
const normalizedSetupConfig = buildSetupConfig(setupConfig);
|
|
826
839
|
|
|
827
840
|
for (const key of REMOVED_CONFIG_KEYS) {
|
|
828
841
|
delete nextOptions[key];
|
|
829
842
|
}
|
|
830
843
|
|
|
831
844
|
for (const key of CONFIG_KEYS) {
|
|
832
|
-
nextOptions[key] =
|
|
845
|
+
nextOptions[key] = normalizedSetupConfig[key];
|
|
833
846
|
}
|
|
834
847
|
|
|
835
848
|
settings.pluginConfigs[pluginId] = {
|
|
836
849
|
...currentEntry,
|
|
837
850
|
options: nextOptions
|
|
838
851
|
};
|
|
852
|
+
if (pluginId !== LEGACY_PLUGIN_ID) {
|
|
853
|
+
delete settings.pluginConfigs[LEGACY_PLUGIN_ID];
|
|
854
|
+
}
|
|
839
855
|
|
|
840
856
|
return nextOptions;
|
|
841
857
|
}
|
|
@@ -843,7 +859,8 @@ function applySetupPluginConfig(
|
|
|
843
859
|
export async function installStatusLineBridge({
|
|
844
860
|
homeDir = getHomeDir(),
|
|
845
861
|
pluginRoot = getPluginRoot(),
|
|
846
|
-
pluginData = getDataDir()
|
|
862
|
+
pluginData = getDataDir(),
|
|
863
|
+
setupConfig = SETUP_CONFIG
|
|
847
864
|
} = {}) {
|
|
848
865
|
await mkdir(pluginData, { recursive: true });
|
|
849
866
|
const { settingsPath, settings } = await ensureSettings(homeDir);
|
|
@@ -861,7 +878,7 @@ export async function installStatusLineBridge({
|
|
|
861
878
|
padding: existing?.padding ?? previousStatusLine?.padding ?? 0,
|
|
862
879
|
refreshInterval: existing?.refreshInterval ?? 5
|
|
863
880
|
};
|
|
864
|
-
const pluginConfigOptions = applySetupPluginConfig(settings);
|
|
881
|
+
const pluginConfigOptions = applySetupPluginConfig(settings, { setupConfig });
|
|
865
882
|
|
|
866
883
|
settings.statusLine = nextStatusLine;
|
|
867
884
|
await writeJsonAtomic(settingsPath, settings);
|
package/lib/verifier.js
CHANGED
|
@@ -64,6 +64,27 @@ function assert(condition, message) {
|
|
|
64
64
|
if (!condition) throw new Error(message);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
async function withIsolatedPluginEnv(env, root, fn) {
|
|
68
|
+
const dataDir = await mkdtemp(path.join(os.tmpdir(), 'sbg-verify-data-'));
|
|
69
|
+
const homeDir = await mkdtemp(path.join(os.tmpdir(), 'sbg-verify-home-'));
|
|
70
|
+
const checkEnv = {
|
|
71
|
+
PATH: env.PATH,
|
|
72
|
+
Path: env.Path,
|
|
73
|
+
SystemRoot: env.SystemRoot,
|
|
74
|
+
USERPROFILE: homeDir,
|
|
75
|
+
HOME: homeDir,
|
|
76
|
+
CLAUDE_PLUGIN_DATA: dataDir,
|
|
77
|
+
CLAUDE_PLUGIN_ROOT: root
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
return await fn(checkEnv, { dataDir, homeDir });
|
|
82
|
+
} finally {
|
|
83
|
+
await rm(dataDir, { recursive: true, force: true });
|
|
84
|
+
await rm(homeDir, { recursive: true, force: true });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
67
88
|
export async function runOfflineVerification({
|
|
68
89
|
repoRoot = process.cwd(),
|
|
69
90
|
env = process.env
|
|
@@ -83,14 +104,14 @@ export async function runOfflineVerification({
|
|
|
83
104
|
const marketplace = await readJson(marketplacePath);
|
|
84
105
|
assert(marketplace.name === 'subagent-budget-tools', 'marketplace name mismatch');
|
|
85
106
|
assert(Array.isArray(marketplace.plugins), 'marketplace.plugins must be an array');
|
|
86
|
-
const entry = marketplace.plugins.find((plugin) => plugin.name === '
|
|
87
|
-
assert(entry, '
|
|
107
|
+
const entry = marketplace.plugins.find((plugin) => plugin.name === 'agent-guard');
|
|
108
|
+
assert(entry, 'agent-guard entry missing');
|
|
88
109
|
assert(entry.source?.source === 'npm', 'marketplace source must use npm');
|
|
89
110
|
assert(
|
|
90
111
|
entry.source?.package === '@rex_koh/subagent-budget-guard',
|
|
91
112
|
'marketplace npm package mismatch'
|
|
92
113
|
);
|
|
93
|
-
assert(entry.source?.version === '0.
|
|
114
|
+
assert(entry.source?.version === '0.2.0', 'marketplace npm version mismatch');
|
|
94
115
|
return marketplacePath;
|
|
95
116
|
});
|
|
96
117
|
} else {
|
|
@@ -108,7 +129,7 @@ export async function runOfflineVerification({
|
|
|
108
129
|
await withCheck(result, 'plugin-manifest-no-install-config', async () => {
|
|
109
130
|
const manifestPath = path.join(root, '.claude-plugin', 'plugin.json');
|
|
110
131
|
const manifest = await readJson(manifestPath);
|
|
111
|
-
assert(manifest.name === '
|
|
132
|
+
assert(manifest.name === 'agent-guard', 'plugin name mismatch');
|
|
112
133
|
assert(
|
|
113
134
|
manifest.hooks === undefined,
|
|
114
135
|
'manifest.hooks must be omitted for default hooks/hooks.json to avoid duplicate loading'
|
|
@@ -147,12 +168,16 @@ export async function runOfflineVerification({
|
|
|
147
168
|
await withCheck(result, 'script-paths', async () => {
|
|
148
169
|
const scripts = [
|
|
149
170
|
'bin/hook.js',
|
|
171
|
+
'bin/agent-guard.js',
|
|
150
172
|
'bin/statusline.js',
|
|
151
173
|
'bin/setup.js',
|
|
152
174
|
'bin/report.js',
|
|
153
175
|
'bin/verify.js',
|
|
154
176
|
'lib/guard.js',
|
|
155
177
|
'lib/verifier.js',
|
|
178
|
+
'skills/init/SKILL.md',
|
|
179
|
+
'skills/status/SKILL.md',
|
|
180
|
+
'skills/doctor/SKILL.md',
|
|
156
181
|
'skills/setup/SKILL.md',
|
|
157
182
|
'skills/report/SKILL.md',
|
|
158
183
|
'skills/verify/SKILL.md'
|
|
@@ -164,13 +189,7 @@ export async function runOfflineVerification({
|
|
|
164
189
|
});
|
|
165
190
|
|
|
166
191
|
await withCheck(result, 'pretool-agent-denies-default', async () => {
|
|
167
|
-
|
|
168
|
-
try {
|
|
169
|
-
const checkEnv = {
|
|
170
|
-
...env,
|
|
171
|
-
CLAUDE_PLUGIN_DATA: dataDir,
|
|
172
|
-
CLAUDE_PLUGIN_ROOT: root
|
|
173
|
-
};
|
|
192
|
+
return withIsolatedPluginEnv(env, root, async (checkEnv) => {
|
|
174
193
|
const output = await handlePreToolUseAgent(
|
|
175
194
|
{
|
|
176
195
|
session_id: 'offline-pretool',
|
|
@@ -185,19 +204,11 @@ export async function runOfflineVerification({
|
|
|
185
204
|
'Agent launch was not denied by default'
|
|
186
205
|
);
|
|
187
206
|
return output.stdout.hookSpecificOutput.permissionDecisionReason;
|
|
188
|
-
}
|
|
189
|
-
await rm(dataDir, { recursive: true, force: true });
|
|
190
|
-
}
|
|
207
|
+
});
|
|
191
208
|
});
|
|
192
209
|
|
|
193
210
|
await withCheck(result, 'posttool-agent-records-verified-tokens', async () => {
|
|
194
|
-
|
|
195
|
-
try {
|
|
196
|
-
const checkEnv = {
|
|
197
|
-
...env,
|
|
198
|
-
CLAUDE_PLUGIN_DATA: dataDir,
|
|
199
|
-
CLAUDE_PLUGIN_ROOT: root
|
|
200
|
-
};
|
|
211
|
+
return withIsolatedPluginEnv(env, root, async (checkEnv) => {
|
|
201
212
|
await handlePostToolUseAgent(
|
|
202
213
|
{
|
|
203
214
|
session_id: 'offline-posttool',
|
|
@@ -217,18 +228,13 @@ export async function runOfflineVerification({
|
|
|
217
228
|
const report = await buildReport('offline-posttool', checkEnv);
|
|
218
229
|
assert(report.state.subagents.verifiedTokens === 101, 'verified token count mismatch');
|
|
219
230
|
return report.summary.verifiedTokenLabel;
|
|
220
|
-
}
|
|
221
|
-
await rm(dataDir, { recursive: true, force: true });
|
|
222
|
-
}
|
|
231
|
+
});
|
|
223
232
|
});
|
|
224
233
|
|
|
225
234
|
await withCheck(result, 'statusline-budget-blocks', async () => {
|
|
226
|
-
|
|
227
|
-
try {
|
|
235
|
+
return withIsolatedPluginEnv(env, root, async (baseEnv) => {
|
|
228
236
|
const checkEnv = {
|
|
229
|
-
...
|
|
230
|
-
CLAUDE_PLUGIN_DATA: dataDir,
|
|
231
|
-
CLAUDE_PLUGIN_ROOT: root,
|
|
237
|
+
...baseEnv,
|
|
232
238
|
CLAUDE_PLUGIN_OPTION_session_five_hour_budget_percent: '3'
|
|
233
239
|
};
|
|
234
240
|
await updateRateLimitFromStatusLine(
|
|
@@ -255,9 +261,7 @@ export async function runOfflineVerification({
|
|
|
255
261
|
);
|
|
256
262
|
assert(output.stdout?.decision === 'block', 'prompt was not blocked');
|
|
257
263
|
return output.stdout.reason;
|
|
258
|
-
}
|
|
259
|
-
await rm(dataDir, { recursive: true, force: true });
|
|
260
|
-
}
|
|
264
|
+
});
|
|
261
265
|
});
|
|
262
266
|
|
|
263
267
|
await withCheck(result, 'statusline-setup-wraps-existing-command', async () => {
|
|
@@ -408,12 +412,12 @@ export async function runLiveVerification({
|
|
|
408
412
|
const list = await runCommand('claude', ['plugin', 'list'], { cwd: repoRoot });
|
|
409
413
|
assert(list.code === 0, list.stderr || list.stdout || 'claude plugin list failed');
|
|
410
414
|
assert(
|
|
411
|
-
list.stdout.includes('
|
|
412
|
-
'
|
|
415
|
+
list.stdout.includes('agent-guard'),
|
|
416
|
+
'agent-guard is not installed'
|
|
413
417
|
);
|
|
414
418
|
assert(
|
|
415
|
-
!/
|
|
416
|
-
'
|
|
419
|
+
!/agent-guard@subagent-budget-tools[\s\S]*failed to load/i.test(list.stdout),
|
|
420
|
+
'agent-guard is installed but failed to load'
|
|
417
421
|
);
|
|
418
422
|
return 'claude plugin list returned output';
|
|
419
423
|
});
|
|
@@ -427,7 +431,7 @@ export async function runLiveVerification({
|
|
|
427
431
|
typeof settings.statusLine?.command === 'string' &&
|
|
428
432
|
settings.statusLine.command.includes('statusline.js') &&
|
|
429
433
|
settings.statusLine.command.includes('--data'),
|
|
430
|
-
'statusLine bridge is not installed; run /
|
|
434
|
+
'statusLine bridge is not installed; run /agent-guard:init'
|
|
431
435
|
);
|
|
432
436
|
return settings.statusLine.command;
|
|
433
437
|
});
|
|
@@ -438,7 +442,7 @@ export async function runLiveVerification({
|
|
|
438
442
|
|
|
439
443
|
export function formatVerificationResult(result) {
|
|
440
444
|
const lines = [
|
|
441
|
-
`
|
|
445
|
+
`Agent Guard ${result.mode} verification`,
|
|
442
446
|
result.ok ? 'PASS' : 'FAIL'
|
|
443
447
|
];
|
|
444
448
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rex_koh/subagent-budget-guard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Claude Code plugin that blocks subagents by default, records verified subagent usage, and enforces 5-hour usage budgets.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "ClaudeSubAgentSuppressor",
|
|
@@ -32,6 +32,8 @@
|
|
|
32
32
|
"LICENSE"
|
|
33
33
|
],
|
|
34
34
|
"bin": {
|
|
35
|
+
"agent-guard": "bin/agent-guard.js",
|
|
36
|
+
"ag": "bin/agent-guard.js",
|
|
35
37
|
"subagent-budget-guard-report": "bin/report.js",
|
|
36
38
|
"subagent-budget-guard-setup": "bin/setup.js",
|
|
37
39
|
"subagent-budget-guard-verify": "bin/verify.js"
|
|
@@ -43,7 +45,7 @@
|
|
|
43
45
|
"test": "node --test test/*.test.js",
|
|
44
46
|
"verify:offline": "node bin/verify.js --offline",
|
|
45
47
|
"verify:live": "node bin/verify.js --live",
|
|
46
|
-
"prepack": "node --check bin/hook.js && node --check bin/statusline.js && node --check bin/setup.js && node --check bin/report.js && node --check bin/verify.js && node --check lib/guard.js && node --check lib/verifier.js"
|
|
48
|
+
"prepack": "node --check bin/hook.js && node --check bin/statusline.js && node --check bin/setup.js && node --check bin/report.js && node --check bin/verify.js && node --check bin/agent-guard.js && node --check lib/guard.js && node --check lib/verifier.js"
|
|
47
49
|
},
|
|
48
50
|
"engines": {
|
|
49
51
|
"node": ">=20"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Verify Agent Guard installation and offline behavior.
|
|
3
|
+
disable-model-invocation: true
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Doctor Agent Guard
|
|
7
|
+
|
|
8
|
+
For default offline verification, run:
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
node "${CLAUDE_PLUGIN_ROOT}/bin/agent-guard.js" doctor --offline
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
For live local installation checks, run:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
node "${CLAUDE_PLUGIN_ROOT}/bin/agent-guard.js" doctor --live
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The live verifier does not submit Claude prompts. It checks local plugin shape, `claude plugin validate` when available, plugin listing shape, and statusLine bridge setup.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Initialize Agent Guard settings and the statusLine bridge.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Init Agent Guard
|
|
6
|
+
|
|
7
|
+
Ask the user whether to use recommended defaults or custom values.
|
|
8
|
+
|
|
9
|
+
Recommended defaults:
|
|
10
|
+
|
|
11
|
+
```text
|
|
12
|
+
max_concurrent_subagents=1
|
|
13
|
+
max_subagent_tokens_per_session=100000
|
|
14
|
+
subagent_token_warning_threshold_percent=95
|
|
15
|
+
session_five_hour_budget_percent=25
|
|
16
|
+
absolute_five_hour_ceiling_percent=95
|
|
17
|
+
enforcement_enabled=true
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
If they choose defaults, run:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
node "${CLAUDE_PLUGIN_ROOT}/bin/agent-guard.js" init --defaults
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
If they choose custom values, ask for each value. Accept a blank answer as the default, then run:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
node "${CLAUDE_PLUGIN_ROOT}/bin/agent-guard.js" init \
|
|
30
|
+
--config max_concurrent_subagents=<value> \
|
|
31
|
+
--config max_subagent_tokens_per_session=<value> \
|
|
32
|
+
--config subagent_token_warning_threshold_percent=<value> \
|
|
33
|
+
--config session_five_hour_budget_percent=<value> \
|
|
34
|
+
--config absolute_five_hour_ceiling_percent=<value> \
|
|
35
|
+
--config enforcement_enabled=<true-or-false>
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Then tell the user to fully exit and reopen Claude Code, interact once so the statusLine bridge receives fresh session JSON, and run:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
node "${CLAUDE_PLUGIN_ROOT}/bin/agent-guard.js" doctor --live
|
|
42
|
+
```
|
package/skills/report/SKILL.md
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
---
|
|
2
|
-
description:
|
|
2
|
+
description: Compatibility alias for /agent-guard:status.
|
|
3
3
|
disable-model-invocation: true
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# Report
|
|
6
|
+
# Report Agent Guard Usage
|
|
7
|
+
|
|
8
|
+
Prefer `/agent-guard:status`.
|
|
7
9
|
|
|
8
10
|
Run this command:
|
|
9
11
|
|
|
10
12
|
```bash
|
|
11
|
-
node "${CLAUDE_PLUGIN_ROOT}/bin/
|
|
13
|
+
node "${CLAUDE_PLUGIN_ROOT}/bin/agent-guard.js" status
|
|
12
14
|
```
|
|
13
15
|
|
|
14
16
|
If the user asks for machine-readable output, run:
|
|
15
17
|
|
|
16
18
|
```bash
|
|
17
|
-
node "${CLAUDE_PLUGIN_ROOT}/bin/
|
|
19
|
+
node "${CLAUDE_PLUGIN_ROOT}/bin/agent-guard.js" status --json
|
|
18
20
|
```
|
package/skills/setup/SKILL.md
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
---
|
|
2
|
-
description:
|
|
3
|
-
disable-model-invocation: true
|
|
2
|
+
description: Compatibility alias for /agent-guard:init.
|
|
4
3
|
---
|
|
5
4
|
|
|
6
|
-
# Setup
|
|
5
|
+
# Setup Agent Guard
|
|
7
6
|
|
|
8
|
-
|
|
7
|
+
Prefer `/agent-guard:init`.
|
|
8
|
+
|
|
9
|
+
Ask the user whether to use the recommended defaults or customize the values. If they choose defaults, run:
|
|
9
10
|
|
|
10
11
|
```bash
|
|
11
|
-
node "${CLAUDE_PLUGIN_ROOT}/bin/
|
|
12
|
+
node "${CLAUDE_PLUGIN_ROOT}/bin/agent-guard.js" init --defaults
|
|
12
13
|
```
|
|
13
14
|
|
|
14
|
-
|
|
15
|
+
If they choose custom values, ask for each value below. The value in parentheses is the default; accept a blank answer as the default.
|
|
15
16
|
|
|
16
17
|
```text
|
|
17
18
|
max_concurrent_subagents=1
|
|
@@ -22,10 +23,22 @@ absolute_five_hour_ceiling_percent=95
|
|
|
22
23
|
enforcement_enabled=true
|
|
23
24
|
```
|
|
24
25
|
|
|
26
|
+
Then run setup with the chosen values:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
node "${CLAUDE_PLUGIN_ROOT}/bin/agent-guard.js" init \
|
|
30
|
+
--config max_concurrent_subagents=<value> \
|
|
31
|
+
--config max_subagent_tokens_per_session=<value> \
|
|
32
|
+
--config subagent_token_warning_threshold_percent=<value> \
|
|
33
|
+
--config session_five_hour_budget_percent=<value> \
|
|
34
|
+
--config absolute_five_hour_ceiling_percent=<value> \
|
|
35
|
+
--config enforcement_enabled=<true-or-false>
|
|
36
|
+
```
|
|
37
|
+
|
|
25
38
|
Then tell the user to fully exit and reopen Claude Code, interact once so the statusLine bridge receives fresh session JSON, and run:
|
|
26
39
|
|
|
27
40
|
```bash
|
|
28
|
-
node "${CLAUDE_PLUGIN_ROOT}/bin/
|
|
41
|
+
node "${CLAUDE_PLUGIN_ROOT}/bin/agent-guard.js" doctor --live
|
|
29
42
|
```
|
|
30
43
|
|
|
31
44
|
The live verifier does not submit Claude prompts. It checks local plugin shape, Claude plugin validation when `claude` is on `PATH`, and whether the statusLine bridge is configured.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Show the current Agent Guard report.
|
|
3
|
+
disable-model-invocation: true
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Status Agent Guard
|
|
7
|
+
|
|
8
|
+
Run:
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
node "${CLAUDE_PLUGIN_ROOT}/bin/agent-guard.js" status
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
If the user asks for machine-readable output, run:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
node "${CLAUDE_PLUGIN_ROOT}/bin/agent-guard.js" status --json
|
|
18
|
+
```
|
package/skills/verify/SKILL.md
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
---
|
|
2
|
-
description:
|
|
2
|
+
description: Compatibility alias for /agent-guard:doctor.
|
|
3
3
|
disable-model-invocation: true
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# Verify
|
|
6
|
+
# Verify Agent Guard
|
|
7
|
+
|
|
8
|
+
Prefer `/agent-guard:doctor`.
|
|
7
9
|
|
|
8
10
|
For default offline verification, run:
|
|
9
11
|
|
|
10
12
|
```bash
|
|
11
|
-
node "${CLAUDE_PLUGIN_ROOT}/bin/
|
|
13
|
+
node "${CLAUDE_PLUGIN_ROOT}/bin/agent-guard.js" doctor --offline
|
|
12
14
|
```
|
|
13
15
|
|
|
14
16
|
For live local installation checks, run:
|
|
15
17
|
|
|
16
18
|
```bash
|
|
17
|
-
node "${CLAUDE_PLUGIN_ROOT}/bin/
|
|
19
|
+
node "${CLAUDE_PLUGIN_ROOT}/bin/agent-guard.js" doctor --live
|
|
18
20
|
```
|
|
19
21
|
|
|
20
22
|
The live verifier does not submit Claude prompts. It checks local plugin shape, `claude plugin validate` when available, plugin listing shape, and statusLine bridge setup.
|