@cosmovex/agentpager 0.1.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/LICENSE +8 -0
- package/README.md +98 -0
- package/dist/agents/acp.js +311 -0
- package/dist/agents/claude.js +279 -0
- package/dist/agents/codex.js +271 -0
- package/dist/agents/explain.js +39 -0
- package/dist/agents/types.js +29 -0
- package/dist/awake.js +73 -0
- package/dist/cli.js +333 -0
- package/dist/control.js +110 -0
- package/dist/crypto.js +65 -0
- package/dist/freshness.js +60 -0
- package/dist/guard/bin.js +41 -0
- package/dist/guard/cli.js +174 -0
- package/dist/guard/decide.js +165 -0
- package/dist/guard/desk.js +63 -0
- package/dist/guard/explain.js +49 -0
- package/dist/guard/index.js +341 -0
- package/dist/guard/limits.js +74 -0
- package/dist/guard/match.js +363 -0
- package/dist/guard/rules.js +267 -0
- package/dist/guard/spend.js +158 -0
- package/dist/hub.js +558 -0
- package/dist/pair.js +51 -0
- package/dist/projects.js +195 -0
- package/dist/protocol.js +1 -0
- package/dist/relay.js +134 -0
- package/dist/risk.js +78 -0
- package/dist/service.js +117 -0
- package/dist/store.js +83 -0
- package/package.json +58 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// A separate entry point, on purpose.
|
|
3
|
+
//
|
|
4
|
+
// This runs once per tool call, so it must not import the relay, Firebase or the agent SDK the way
|
|
5
|
+
// `cli.ts` does. Going through the main CLI made the hook hang outright — Firebase's module-level
|
|
6
|
+
// init keeps the event loop alive, so the process wrote its answer and then never exited, and Claude
|
|
7
|
+
// Code sat waiting on a hook that had already decided. Fewer imports here is not a micro-optimisation;
|
|
8
|
+
// it is the difference between working and not.
|
|
9
|
+
import { runRuleCommand, USAGE } from './cli.js';
|
|
10
|
+
import { install, runDeskHook, runHook, status, uninstall } from './index.js';
|
|
11
|
+
const arg = process.argv[2];
|
|
12
|
+
try {
|
|
13
|
+
switch (arg) {
|
|
14
|
+
case undefined:
|
|
15
|
+
await runHook();
|
|
16
|
+
break;
|
|
17
|
+
case 'desk':
|
|
18
|
+
await runDeskHook();
|
|
19
|
+
break;
|
|
20
|
+
case 'install':
|
|
21
|
+
install();
|
|
22
|
+
break;
|
|
23
|
+
case 'uninstall':
|
|
24
|
+
uninstall();
|
|
25
|
+
break;
|
|
26
|
+
case 'status':
|
|
27
|
+
await status();
|
|
28
|
+
break;
|
|
29
|
+
default:
|
|
30
|
+
if (!runRuleCommand(arg, process.argv.slice(3)))
|
|
31
|
+
console.log(USAGE);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
// Never let a crash here block a tool call — see the note in index.ts.
|
|
36
|
+
if (!arg)
|
|
37
|
+
process.stdout.write('{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}');
|
|
38
|
+
else
|
|
39
|
+
console.error(`agentpager-guard: ${e?.message ?? e}`);
|
|
40
|
+
}
|
|
41
|
+
process.exit(0);
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// `agentpager guard rules | preset | allow | deny | ask | remove | budget | test`
|
|
2
|
+
//
|
|
3
|
+
// The point of this file is that setting a rule is one short command and trying it is another:
|
|
4
|
+
// agentpager guard deny "docker system prune"
|
|
5
|
+
// agentpager guard test "docker system prune" → DENY, rule r1
|
|
6
|
+
// Import-light like the hook itself: bin.ts loads it on every `agentpager-guard <verb>`.
|
|
7
|
+
import { decide, presetModes } from './decide.js';
|
|
8
|
+
import { commandOf } from './match.js';
|
|
9
|
+
import { freeId, loadRules, patternError, PRESET_IDS, PRESETS, saveRules, validateRules, rulesPath, } from './rules.js';
|
|
10
|
+
import { money, ZERO } from './spend.js';
|
|
11
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
12
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
13
|
+
export const USAGE = `Usage: agentpager guard <command>
|
|
14
|
+
install | uninstall | status put the guard in / take it out of Claude Code
|
|
15
|
+
rules show every rule and what it does
|
|
16
|
+
preset <id> ask|deny|off change a built-in rule (ids: ${PRESET_IDS.join(', ')})
|
|
17
|
+
ask|deny|allow "<pattern>" add your own rule: plain text, or /regex/
|
|
18
|
+
remove <id> delete one of your rules
|
|
19
|
+
budget <usd> [--warn <fraction>] per-session spend stop; 0 turns it off
|
|
20
|
+
test "<command>" dry run: which rule fires, and what happens
|
|
21
|
+
test --read <path> same, for the agent opening a file`;
|
|
22
|
+
/** Splits `--flag value` pairs out of an argument list. */
|
|
23
|
+
function flags(args) {
|
|
24
|
+
const words = [];
|
|
25
|
+
const out = {};
|
|
26
|
+
for (let i = 0; i < args.length; i++) {
|
|
27
|
+
if (args[i].startsWith('--'))
|
|
28
|
+
out[args[i].slice(2)] = args[++i] ?? '';
|
|
29
|
+
else
|
|
30
|
+
words.push(args[i]);
|
|
31
|
+
}
|
|
32
|
+
return { words, flags: out };
|
|
33
|
+
}
|
|
34
|
+
const badge = (d) => (d === 'deny' ? '\x1b[31mDENY\x1b[0m' : d === 'ask' ? '\x1b[33mASK\x1b[0m' : '\x1b[32mALLOW\x1b[0m');
|
|
35
|
+
export function showRules() {
|
|
36
|
+
const r = loadRules();
|
|
37
|
+
const modes = presetModes(r);
|
|
38
|
+
console.log(`${bold('Guard rules')} ${dim(rulesPath())}\n`);
|
|
39
|
+
console.log(`budget ${r.budgetUsd > 0 ? `${money(r.budgetUsd)} per session, warns at ${money(r.budgetUsd * r.warnAt)}, stops at the limit` : 'off'}`);
|
|
40
|
+
console.log(`phone ${r.phone === 'never' ? 'never asked — questions stay in the terminal' : `asked first (${r.phoneTimeoutSec}s), then the terminal`}\n`);
|
|
41
|
+
console.log(bold('Built-in rules'));
|
|
42
|
+
for (const p of PRESETS) {
|
|
43
|
+
console.log(` ${p.id.padEnd(14)} ${modes[p.id].toUpperCase().padEnd(5)} ${p.title} ${dim(`e.g. ${p.example}`)}`);
|
|
44
|
+
}
|
|
45
|
+
if (r.askOnDanger === false)
|
|
46
|
+
console.log(` ${'always_ask'.padEnd(14)} OFF sudo, git reset --hard, plain git push, mkfs … (askOnDanger:false)`);
|
|
47
|
+
console.log(`\n${bold('Your rules')} ${dim('(checked first, top to bottom)')}`);
|
|
48
|
+
if (!r.custom.length)
|
|
49
|
+
console.log(dim(' none — add one: agentpager guard deny "docker system prune"'));
|
|
50
|
+
for (const c of r.custom)
|
|
51
|
+
console.log(` ${c.id.padEnd(8)} ${c.action.toUpperCase().padEnd(5)} ${c.pattern}${c.note ? dim(` — ${c.note}`) : ''}`);
|
|
52
|
+
console.log(dim('\nTry a command: agentpager guard test "<command>"'));
|
|
53
|
+
}
|
|
54
|
+
function setPreset(id, mode) {
|
|
55
|
+
if (!id || !PRESET_IDS.includes(id) || !['ask', 'deny', 'off'].includes(mode ?? '')) {
|
|
56
|
+
console.error(`Usage: agentpager guard preset <id> ask|deny|off\n ids: ${PRESET_IDS.join(', ')}`);
|
|
57
|
+
process.exitCode = 1;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const r = loadRules();
|
|
61
|
+
r.presets[id] = mode;
|
|
62
|
+
saveRules(r);
|
|
63
|
+
const meta = PRESETS.find((p) => p.id === id);
|
|
64
|
+
console.log(`${id} → ${mode.toUpperCase()} (${meta.title})`);
|
|
65
|
+
if (mode === 'off')
|
|
66
|
+
console.log(dim(`The guard will no longer look at things like: ${meta.example}`));
|
|
67
|
+
}
|
|
68
|
+
function addRule(action, args) {
|
|
69
|
+
const { words, flags: f } = flags(args);
|
|
70
|
+
const pattern = words.join(' ').trim();
|
|
71
|
+
const bad = patternError(pattern);
|
|
72
|
+
if (bad) {
|
|
73
|
+
console.error(`${bad}\nUsage: agentpager guard ${action} "<text or /regex/>" [--note "why"]`);
|
|
74
|
+
process.exitCode = 1;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const r = loadRules();
|
|
78
|
+
const rule = { id: freeId(r), pattern, action, ...(f.note ? { note: f.note.slice(0, 200) } : {}) };
|
|
79
|
+
r.custom.push(rule);
|
|
80
|
+
const v = validateRules(r); // last line of defence: never write a file we would refuse to read
|
|
81
|
+
if (!v.ok) {
|
|
82
|
+
console.error(v.error);
|
|
83
|
+
process.exitCode = 1;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
saveRules(v.rules);
|
|
87
|
+
console.log(`${rule.id}: ${action.toUpperCase()} anything matching ${pattern}`);
|
|
88
|
+
console.log(dim(`Check it: agentpager guard test "${pattern.startsWith('/') ? '<a command>' : pattern}" Remove it: agentpager guard remove ${rule.id}`));
|
|
89
|
+
}
|
|
90
|
+
function removeRule(id) {
|
|
91
|
+
const r = loadRules();
|
|
92
|
+
const before = r.custom.length;
|
|
93
|
+
r.custom = r.custom.filter((c) => c.id !== id);
|
|
94
|
+
if (r.custom.length === before) {
|
|
95
|
+
console.error(`No rule of yours has the id "${id ?? ''}". See them with: agentpager guard rules`);
|
|
96
|
+
process.exitCode = 1;
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
saveRules(r);
|
|
100
|
+
console.log(`Removed ${id}.`);
|
|
101
|
+
}
|
|
102
|
+
function setBudget(args) {
|
|
103
|
+
const { words, flags: f } = flags(args);
|
|
104
|
+
const usd = Number(words[0]);
|
|
105
|
+
if (words[0] === undefined || !Number.isFinite(usd) || usd < 0) {
|
|
106
|
+
console.error('Usage: agentpager guard budget <usd> [--warn <fraction, e.g. 0.25>] (0 turns the budget off)');
|
|
107
|
+
process.exitCode = 1;
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const r = loadRules();
|
|
111
|
+
r.budgetUsd = usd;
|
|
112
|
+
if (f.warn !== undefined)
|
|
113
|
+
r.warnAt = Number(f.warn);
|
|
114
|
+
const v = validateRules(r);
|
|
115
|
+
if (!v.ok) {
|
|
116
|
+
console.error(v.error);
|
|
117
|
+
process.exitCode = 1;
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
saveRules(v.rules);
|
|
121
|
+
console.log(usd > 0 ? `Budget ${money(usd)} per session, warning at ${money(usd * v.rules.warnAt)}.` : 'Budget off.');
|
|
122
|
+
}
|
|
123
|
+
/** Dry run: exactly what the hook would decide, without running or asking anything. */
|
|
124
|
+
export function testCommand(args) {
|
|
125
|
+
const { words, flags: f } = flags(args);
|
|
126
|
+
const read = f.read;
|
|
127
|
+
const text = words.join(' ').trim();
|
|
128
|
+
if (!read && !text) {
|
|
129
|
+
console.error('Usage: agentpager guard test "<command>" or agentpager guard test --read <path>');
|
|
130
|
+
process.exitCode = 1;
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const cfg = loadRules();
|
|
134
|
+
const tool = read ? 'Read' : 'Bash';
|
|
135
|
+
const input = read ? { file_path: read } : { command: text };
|
|
136
|
+
const r = decide(tool, input, ZERO, cfg);
|
|
137
|
+
console.log(`${dim(read ? 'Read' : '$')} ${read ?? commandOf(input)}`);
|
|
138
|
+
console.log(`${badge(r.decision)}${r.rule ? ` rule: ${r.rule}` : ' no rule matches'}`);
|
|
139
|
+
if (r.reason)
|
|
140
|
+
console.log(r.reason);
|
|
141
|
+
if (r.context)
|
|
142
|
+
console.log(dim(r.context));
|
|
143
|
+
if (r.decision === 'ask')
|
|
144
|
+
console.log(dim(`(with a phone paired, this question goes to the phone first; headless with no phone it is refused — see docs/GUARD_HEADLESS.md)`));
|
|
145
|
+
if (r.decision === 'allow' && !r.rule)
|
|
146
|
+
console.log(dim('Nothing stops this. To stop it: agentpager guard ask|deny "<text>"'));
|
|
147
|
+
}
|
|
148
|
+
/** Handles the rules-related verbs. Returns false for a verb it does not know. */
|
|
149
|
+
export function runRuleCommand(verb, args) {
|
|
150
|
+
switch (verb) {
|
|
151
|
+
case 'rules':
|
|
152
|
+
showRules();
|
|
153
|
+
return true;
|
|
154
|
+
case 'preset':
|
|
155
|
+
setPreset(args[0], args[1]);
|
|
156
|
+
return true;
|
|
157
|
+
case 'ask':
|
|
158
|
+
case 'deny':
|
|
159
|
+
case 'allow':
|
|
160
|
+
addRule(verb, args);
|
|
161
|
+
return true;
|
|
162
|
+
case 'remove':
|
|
163
|
+
removeRule(args[0]);
|
|
164
|
+
return true;
|
|
165
|
+
case 'budget':
|
|
166
|
+
setBudget(args);
|
|
167
|
+
return true;
|
|
168
|
+
case 'test':
|
|
169
|
+
testCommand(args);
|
|
170
|
+
return true;
|
|
171
|
+
default:
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// The decision, with nothing else attached.
|
|
2
|
+
//
|
|
3
|
+
// Kept pure — in, out, no filesystem, no stdin, no clock — because this is the part that must be
|
|
4
|
+
// right. Everything around it (reading the transcript, parsing the hook payload, writing JSON to
|
|
5
|
+
// stdout) is plumbing that can be exercised separately.
|
|
6
|
+
import { warning } from './explain.js';
|
|
7
|
+
import { commandOf, DEFAULT_SAYS, presetsHit, residualDanger, subjectOf } from './match.js';
|
|
8
|
+
import { compilePattern, defaultPresets, PRESET_IDS } from './rules.js';
|
|
9
|
+
import { read as readLimits, whenBack, worstUsedPct, worstWindow } from './limits.js';
|
|
10
|
+
import { money } from './spend.js';
|
|
11
|
+
export const DEFAULTS = {
|
|
12
|
+
warnAt: 0.8,
|
|
13
|
+
askOnDanger: true,
|
|
14
|
+
};
|
|
15
|
+
/** The custom rules of a config, with legacy `denyPatterns` folded in after them. */
|
|
16
|
+
export function customRulesOf(cfg) {
|
|
17
|
+
const legacy = (cfg.denyPatterns ?? []).map((p, i) => ({
|
|
18
|
+
id: `legacy${i + 1}`,
|
|
19
|
+
pattern: `/${p}/`,
|
|
20
|
+
action: 'deny',
|
|
21
|
+
note: 'from denyPatterns',
|
|
22
|
+
}));
|
|
23
|
+
return [...(cfg.custom ?? []), ...legacy];
|
|
24
|
+
}
|
|
25
|
+
/** Effective mode of every preset for a config (legacy askOnDanger:false with no presets = all off). */
|
|
26
|
+
export function presetModes(cfg) {
|
|
27
|
+
const modes = defaultPresets();
|
|
28
|
+
if (cfg.askOnDanger === false && !cfg.presets)
|
|
29
|
+
for (const id of PRESET_IDS)
|
|
30
|
+
modes[id] = 'off';
|
|
31
|
+
for (const id of PRESET_IDS) {
|
|
32
|
+
const m = cfg.presets?.[id];
|
|
33
|
+
if (m === 'ask' || m === 'deny' || m === 'off')
|
|
34
|
+
modes[id] = m;
|
|
35
|
+
}
|
|
36
|
+
return modes;
|
|
37
|
+
}
|
|
38
|
+
const HOW = (id) => `Change it with: agentpager guard preset ${id} ask|deny|off`;
|
|
39
|
+
/** The one place a tool call is judged. */
|
|
40
|
+
export function decide(tool, input, spend, cfg) {
|
|
41
|
+
const command = commandOf(input);
|
|
42
|
+
const budget = cfg.budgetUsd ?? 0;
|
|
43
|
+
// 0 ── The plan window, which is what a subscriber actually runs out of.
|
|
44
|
+
//
|
|
45
|
+
// Checked before dollars because for most people dollars are a fiction: a Pro or Max plan buys
|
|
46
|
+
// five-hour and weekly windows, not tokens. The reading comes from limits.json, which the bridge
|
|
47
|
+
// writes when it sees rate_limits on the stream — the guard is a hook and cannot see that itself.
|
|
48
|
+
//
|
|
49
|
+
// A stale reading is IGNORED rather than enforced. Blocking somebody's work because of a number
|
|
50
|
+
// from yesterday is the one failure they would never forgive, and worstUsedPct returns null
|
|
51
|
+
// rather than a guess once the snapshot ages out.
|
|
52
|
+
const planCap = cfg.planCapPercent ?? 0;
|
|
53
|
+
if (planCap > 0) {
|
|
54
|
+
const snap = readLimits();
|
|
55
|
+
const used = worstUsedPct(snap);
|
|
56
|
+
if (used !== null && used >= planCap) {
|
|
57
|
+
const back = whenBack(snap);
|
|
58
|
+
const which = worstWindow(snap) === 'sevenDay' ? 'weekly' : '5-hour';
|
|
59
|
+
return {
|
|
60
|
+
decision: 'deny',
|
|
61
|
+
rule: 'plan-limit',
|
|
62
|
+
reason: `Plan limit reached: ${used}% of your ${which} allowance is used, and your cap is ${planCap}%. ` +
|
|
63
|
+
(back ? `It resets ${back}. ` : '') +
|
|
64
|
+
`Raise or remove planCapPercent in ~/.agentpager/guard.json to carry on.`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// 1 ── The budget gate comes FIRST, and it denies rather than asks.
|
|
69
|
+
//
|
|
70
|
+
// Asking would be the polite thing and it is exactly the failure everyone has already lived
|
|
71
|
+
// through: a person who has approved 93% of prompts approves this one too, and the cap becomes a
|
|
72
|
+
// speed bump. #93255 is a user who set $20, was asked, kept tapping, and spent $40. A cap that can
|
|
73
|
+
// be tapped through is a display, not a cap.
|
|
74
|
+
if (budget > 0 && spend.usd >= budget) {
|
|
75
|
+
return {
|
|
76
|
+
decision: 'deny',
|
|
77
|
+
rule: 'budget',
|
|
78
|
+
reason: `Budget reached: this session has spent ${money(spend.usd)} of its ${money(budget)} limit, ` +
|
|
79
|
+
`so no further tool calls will run. Raise it with AGENTPAGER_BUDGET_USD, or edit ` +
|
|
80
|
+
`~/.agentpager/guard.json, then continue.`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
let verdict = null;
|
|
84
|
+
const subject = subjectOf(input);
|
|
85
|
+
const explained = command ? warning(command) : null;
|
|
86
|
+
// 2 ── Your own rules, first match wins. They run before presets so "allow" can exempt a command
|
|
87
|
+
// the presets would ask about, and "deny" can be stricter than any preset.
|
|
88
|
+
for (const rule of customRulesOf(cfg)) {
|
|
89
|
+
if (!subject || !compilePattern(rule.pattern)(subject))
|
|
90
|
+
continue;
|
|
91
|
+
const note = rule.note ? ` (${rule.note})` : '';
|
|
92
|
+
if (rule.action === 'allow') {
|
|
93
|
+
verdict = { decision: 'allow', rule: rule.id };
|
|
94
|
+
}
|
|
95
|
+
else if (rule.action === 'deny') {
|
|
96
|
+
verdict = {
|
|
97
|
+
decision: 'deny',
|
|
98
|
+
rule: rule.id,
|
|
99
|
+
reason: `Refused by your own rule ${rule.id} (${rule.pattern})${note}.${explained ? ` ${explained}` : ''}`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
verdict = {
|
|
104
|
+
decision: 'ask',
|
|
105
|
+
rule: rule.id,
|
|
106
|
+
reason: `Your own rule ${rule.id} (${rule.pattern}) asks first${note}.${explained ? ` ${explained}` : ''}`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
// 3 ── Presets: the worst-first list, each ask | deny | off.
|
|
112
|
+
if (!verdict) {
|
|
113
|
+
const modes = presetModes(cfg);
|
|
114
|
+
const hits = presetsHit(tool, input);
|
|
115
|
+
for (const { id, hit } of hits) {
|
|
116
|
+
const mode = modes[id];
|
|
117
|
+
if (mode === 'off')
|
|
118
|
+
continue;
|
|
119
|
+
const says = hit.says ?? explained ?? DEFAULT_SAYS[id];
|
|
120
|
+
verdict =
|
|
121
|
+
mode === 'deny'
|
|
122
|
+
? { decision: 'deny', rule: id, reason: `Blocked by rule ${id}: ${says} ${HOW(id)}` }
|
|
123
|
+
: { decision: 'ask', rule: id, reason: `${says} (rule: ${id})` };
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
// 4 ── Anything else irreversible still stops for a human — unless a preset owns the command
|
|
127
|
+
// (then "off" means off).
|
|
128
|
+
if (!verdict && !hits.length && command && (cfg.askOnDanger ?? DEFAULTS.askOnDanger)) {
|
|
129
|
+
const says = residualDanger(command);
|
|
130
|
+
if (says)
|
|
131
|
+
verdict = { decision: 'ask', rule: 'always_ask', reason: `${explained ?? says} (rule: always_ask)` };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (verdict && verdict.decision !== 'allow')
|
|
135
|
+
return verdict;
|
|
136
|
+
// 5 ── Approaching the budget is worth saying once, to the model, without blocking anything.
|
|
137
|
+
//
|
|
138
|
+
// The model is the only party that can actually act on it — it can pick a cheaper path, stop
|
|
139
|
+
// fanning out subagents, or finish early. Telling the human at 80% just makes them anxious at a
|
|
140
|
+
// moment when they cannot do anything useful.
|
|
141
|
+
const warnAt = cfg.warnAt ?? DEFAULTS.warnAt;
|
|
142
|
+
if (budget > 0 && spend.usd >= budget * warnAt) {
|
|
143
|
+
const left = Math.max(0, budget - spend.usd);
|
|
144
|
+
return {
|
|
145
|
+
decision: 'allow',
|
|
146
|
+
...(verdict?.rule ? { rule: verdict.rule } : {}),
|
|
147
|
+
context: `Budget notice: ${money(spend.usd)} of ${money(budget)} spent, ${money(left)} left. ` +
|
|
148
|
+
`Prefer cheaper models and avoid spawning parallel subagents; work will be blocked at the limit.`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return verdict ?? { decision: 'allow' };
|
|
152
|
+
}
|
|
153
|
+
/** The exact envelope Claude Code expects back on stdout for a PreToolUse hook. */
|
|
154
|
+
export function hookOutput(r) {
|
|
155
|
+
const out = {
|
|
156
|
+
hookSpecificOutput: {
|
|
157
|
+
hookEventName: 'PreToolUse',
|
|
158
|
+
permissionDecision: r.decision,
|
|
159
|
+
...(r.reason ? { permissionDecisionReason: r.reason } : {}),
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
if (r.context)
|
|
163
|
+
out.hookSpecificOutput = { ...out.hookSpecificOutput, additionalContext: r.context };
|
|
164
|
+
return JSON.stringify(out);
|
|
165
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// The desk lock: "you are working in this session on the computer right now".
|
|
2
|
+
//
|
|
3
|
+
// Two processes writing one Claude session transcript corrupt each other — and the phone resuming a
|
|
4
|
+
// session while you are typing into it at the desk is exactly that. So every guard invocation at the
|
|
5
|
+
// desk leaves a heartbeat, `~/.agentpager/desk/<session_id>` (its mtime is the timestamp), and the
|
|
6
|
+
// bridge refuses a phone `prompt` for a session whose heartbeat is fresh.
|
|
7
|
+
//
|
|
8
|
+
// A session with NO heartbeat is never affected: no guard installed, or nobody at the desk.
|
|
9
|
+
// Import-light: node builtins + rules.ts only (this runs on every tool call).
|
|
10
|
+
import { mkdirSync, readdirSync, rmSync, statSync, utimesSync, closeSync, openSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { agentpagerHome } from './rules.js';
|
|
13
|
+
/** How recent a heartbeat must be to count as "active at the desk". */
|
|
14
|
+
export const DESK_FRESH_MS = 20_000;
|
|
15
|
+
const SAFE_ID = /^[\w-]{8,80}$/;
|
|
16
|
+
export const deskDir = () => join(agentpagerHome(), 'desk');
|
|
17
|
+
/** Records that the desk is active in this session right now. Never throws. */
|
|
18
|
+
export function touchDesk(sessionId) {
|
|
19
|
+
if (typeof sessionId !== 'string' || !SAFE_ID.test(sessionId))
|
|
20
|
+
return;
|
|
21
|
+
try {
|
|
22
|
+
mkdirSync(deskDir(), { recursive: true, mode: 0o700 });
|
|
23
|
+
const p = join(deskDir(), sessionId);
|
|
24
|
+
const now = new Date();
|
|
25
|
+
try {
|
|
26
|
+
utimesSync(p, now, now);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
closeSync(openSync(p, 'a', 0o600)); // first time: create it
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
/* a broken heartbeat must never break the guard */
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Milliseconds since the desk was last active in this session, or null when it never was. */
|
|
37
|
+
export function deskAgeMs(sessionId, now = Date.now()) {
|
|
38
|
+
if (!SAFE_ID.test(sessionId))
|
|
39
|
+
return null;
|
|
40
|
+
try {
|
|
41
|
+
return now - statSync(join(deskDir(), sessionId)).mtimeMs;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export const deskActive = (sessionId, now = Date.now()) => {
|
|
48
|
+
const age = deskAgeMs(sessionId, now);
|
|
49
|
+
return age !== null && age < DESK_FRESH_MS;
|
|
50
|
+
};
|
|
51
|
+
/** Heartbeats are empty files, one per session ever touched: sweep the old ones. */
|
|
52
|
+
export function pruneDesk(maxAgeMs = 7 * 24 * 3600_000) {
|
|
53
|
+
try {
|
|
54
|
+
for (const f of readdirSync(deskDir())) {
|
|
55
|
+
const p = join(deskDir(), f);
|
|
56
|
+
if (Date.now() - statSync(p).mtimeMs > maxAgeMs)
|
|
57
|
+
rmSync(p, { force: true });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
/* nothing to sweep */
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Say what the command will DO, not what it says.
|
|
2
|
+
//
|
|
3
|
+
// Claude Code issue #30505 asked for exactly this — "the prompt only displays the raw command, which
|
|
4
|
+
// requires technical knowledge to evaluate… `rm -rf /` → ⚠️ This command will permanently delete
|
|
5
|
+
// files and cannot be undone" — and was closed "not planned". It was filed because, as agents reach
|
|
6
|
+
// people who are not engineers, a raw shell string is not information. The lawyer in #95637 said the
|
|
7
|
+
// same thing from the other side: "I am a lawyer, not a software engineer; I rely on Claude Code for
|
|
8
|
+
// exactly these decisions."
|
|
9
|
+
//
|
|
10
|
+
// It also matters for people who ARE engineers, because of approval fatigue: Anthropic's own
|
|
11
|
+
// telemetry says users approve 93% of prompts. A wall of identical-looking commands is what trains
|
|
12
|
+
// someone to tap Allow without reading, and then to reach for --dangerously-skip-permissions.
|
|
13
|
+
// A sentence that is different when the stakes are different is the only thing that survives that.
|
|
14
|
+
const RULES = [
|
|
15
|
+
{ re: /\brm\s+(-[a-z]*[rf]|--recursive|--force)/i, says: 'deletes files permanently — there is no undo and no Trash', permanent: true },
|
|
16
|
+
{ re: /\bgit\s+push\b[^|;&]*\s(--force|-f)\b|\bgit\s+push\s+--force/i, says: 'overwrites the remote history — commits on the server can be lost for everyone', permanent: true },
|
|
17
|
+
{ re: /\bgit\s+reset\s+--hard\b/i, says: 'throws away uncommitted work in this repository', permanent: true },
|
|
18
|
+
{ re: /\bgit\s+clean\s+-[a-z]*f/i, says: 'deletes untracked files, including ones git has never seen', permanent: true },
|
|
19
|
+
{ re: /\bgit\s+branch\s+-D\b/i, says: 'force-deletes a branch even if it was never merged' },
|
|
20
|
+
{ re: /\bgit\s+push\b/i, says: 'publishes commits to the shared remote where other people will pull them' },
|
|
21
|
+
{ re: /\bdrop\s+(table|database)\b|\btruncate\s+table\b/i, says: 'destroys database data permanently', permanent: true },
|
|
22
|
+
{ re: /\b(mkfs|dd\s+if=)/i, says: 'writes directly to a disk and can destroy the whole filesystem', permanent: true },
|
|
23
|
+
{ re: /\b(shutdown|reboot)\b/i, says: 'shuts down or restarts this machine' },
|
|
24
|
+
{ re: /\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(ba|z)?sh\b/i, says: 'downloads a script from the internet and runs it immediately, unread' },
|
|
25
|
+
{ re: /\bsudo\b/i, says: 'runs with administrator rights, so it is not limited to your own files' },
|
|
26
|
+
{ re: /\bchmod\s+-R\b|\bchown\s+-R\b/i, says: 'changes permissions or ownership across a whole directory tree' },
|
|
27
|
+
{ re: /\bdocker\s+(system\s+prune|volume\s+rm)\b/i, says: 'removes Docker data, including volumes that may hold the only copy of something', permanent: true },
|
|
28
|
+
{ re: /\bkubectl\s+delete\b|\bterraform\s+destroy\b/i, says: 'tears down live infrastructure', permanent: true },
|
|
29
|
+
{ re: /\bterraform\s+apply\b|\bkubectl\s+apply\b/i, says: 'changes live infrastructure' },
|
|
30
|
+
{ re: /\b(npm|pnpm|yarn|cargo|twine|gem)\s+publish\b/i, says: 'publishes a package publicly — versions cannot be unpublished cleanly', permanent: true },
|
|
31
|
+
{ re: /\bgh\s+repo\s+delete\b/i, says: 'deletes a GitHub repository', permanent: true },
|
|
32
|
+
{ re: /\bgh\s+(release|pr\s+merge)\b/i, says: 'changes the public state of the repository' },
|
|
33
|
+
{ re: /\b(firebase|vercel|netlify|fly|heroku)\s+deploy\b|\bdeploy\b/i, says: 'deploys to a live environment that real users can reach' },
|
|
34
|
+
];
|
|
35
|
+
export function explain(command) {
|
|
36
|
+
const hits = RULES.filter((r) => r.re.test(command));
|
|
37
|
+
if (!hits.length)
|
|
38
|
+
return { says: null, permanent: false };
|
|
39
|
+
// Rules are ordered worst-first, so the first match is the one worth saying out loud. Listing all
|
|
40
|
+
// of them turns a warning back into a wall of text, which is the problem we are solving.
|
|
41
|
+
return { says: hits[0].says, permanent: hits.some((h) => h.permanent === true) };
|
|
42
|
+
}
|
|
43
|
+
/** The full line a person reads before deciding. */
|
|
44
|
+
export function warning(command) {
|
|
45
|
+
const { says, permanent } = explain(command);
|
|
46
|
+
if (!says)
|
|
47
|
+
return null;
|
|
48
|
+
return permanent ? `This ${says}. It cannot be undone.` : `This ${says}.`;
|
|
49
|
+
}
|