@klars/agentobs 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 +21 -0
- package/README.md +190 -0
- package/bin/agentobs +6 -0
- package/bin/agentobs-hook +6 -0
- package/dist/adapters/claude-code-hook.js +204 -0
- package/dist/adapters/jsonl-watcher.js +185 -0
- package/dist/adapters/process-wrap.js +141 -0
- package/dist/adapters/sink.js +65 -0
- package/dist/adapters/transcript.js +88 -0
- package/dist/adapters/types.js +12 -0
- package/dist/cli.js +108 -0
- package/dist/commands/dashboard.js +45 -0
- package/dist/commands/export.js +72 -0
- package/dist/commands/hook-config.js +59 -0
- package/dist/commands/init.js +45 -0
- package/dist/commands/policy.js +84 -0
- package/dist/commands/run.js +16 -0
- package/dist/commands/stats.js +49 -0
- package/dist/commands/watch.js +20 -0
- package/dist/core/db.js +79 -0
- package/dist/core/paths.js +30 -0
- package/dist/core/policy-engine.js +246 -0
- package/dist/core/pricing.js +100 -0
- package/dist/core/queries.js +143 -0
- package/dist/core/redact.js +150 -0
- package/dist/core/repo.js +89 -0
- package/dist/core/schema.sql +78 -0
- package/dist/server/index.js +162 -0
- package/dist/server/public/app.css +668 -0
- package/dist/server/public/app.js +502 -0
- package/dist/server/public/index.html +196 -0
- package/dist/server/standalone.js +39 -0
- package/package.json +52 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agentobs policy` - guardrail management.
|
|
3
|
+
*
|
|
4
|
+
* `policy test` matters more than it looks: a user has to be able to predict
|
|
5
|
+
* what a rule will do before letting it block their agent mid-task. Without a
|
|
6
|
+
* dry run, the only way to learn a rule is wrong is to have it fire at the
|
|
7
|
+
* worst moment.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import { paths } from '../core/paths.js';
|
|
11
|
+
import { contextFromToolInput, evaluate, loadPolicy, writeDefaultPolicy, } from '../core/policy-engine.js';
|
|
12
|
+
export async function policyInit() {
|
|
13
|
+
const file = paths.policy();
|
|
14
|
+
const existed = existsSync(file);
|
|
15
|
+
writeDefaultPolicy();
|
|
16
|
+
if (existed) {
|
|
17
|
+
console.log(`Policy file already exists, left unchanged: ${file}`);
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
console.log(`Wrote starter policy: ${file}
|
|
21
|
+
|
|
22
|
+
It blocks rm -rf and curl-pipe-to-shell, and asks for approval on .env edits
|
|
23
|
+
and force-pushes. Edit it, then check your work:
|
|
24
|
+
|
|
25
|
+
agentobs policy check
|
|
26
|
+
agentobs policy test Bash "rm -rf ./build"`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export async function policyCheck() {
|
|
30
|
+
const { policy, errors, source } = loadPolicy();
|
|
31
|
+
if (source === 'none') {
|
|
32
|
+
console.log(`No policy file at ${paths.policy()}.
|
|
33
|
+
Every tool call is allowed. Run "agentobs policy init" to add guardrails.`);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (errors.length > 0) {
|
|
37
|
+
console.log(`Problems in ${paths.policy()}:\n`);
|
|
38
|
+
for (const err of errors)
|
|
39
|
+
console.log(` ! ${err}`);
|
|
40
|
+
console.log(`
|
|
41
|
+
Rules with problems are skipped. AgentObs deliberately fails open - a broken
|
|
42
|
+
policy never blocks your agent - so fix these or those rules will not apply.\n`);
|
|
43
|
+
}
|
|
44
|
+
console.log(`Active rules (${policy.rules.length}), first match wins:\n`);
|
|
45
|
+
for (const [i, rule] of policy.rules.entries()) {
|
|
46
|
+
const criteria = [
|
|
47
|
+
rule.match.tool && rule.match.tool !== '*' ? `tool=${rule.match.tool}` : null,
|
|
48
|
+
rule.match.command_pattern ? `command~"${rule.match.command_pattern}"` : null,
|
|
49
|
+
rule.match.path_pattern ? `path~"${rule.match.path_pattern}"` : null,
|
|
50
|
+
]
|
|
51
|
+
.filter(Boolean)
|
|
52
|
+
.join(' ');
|
|
53
|
+
console.log(` ${String(i + 1).padStart(2)}. ${rule.decision.toUpperCase().padEnd(15)} ${rule.name}`);
|
|
54
|
+
console.log(` ${criteria}`);
|
|
55
|
+
}
|
|
56
|
+
console.log(`\n default: ${policy.default_decision}\n`);
|
|
57
|
+
}
|
|
58
|
+
export async function policyTest(tool, input) {
|
|
59
|
+
const { policy, errors } = loadPolicy();
|
|
60
|
+
for (const err of errors)
|
|
61
|
+
console.log(` ! ${err}`);
|
|
62
|
+
// Probe both interpretations: the user types a bare string, and we cannot
|
|
63
|
+
// know whether they mean a command or a path. Testing it as both is more
|
|
64
|
+
// useful than making them guess which field name to supply.
|
|
65
|
+
const asCommand = evaluate(policy, contextFromToolInput(tool, { command: input }));
|
|
66
|
+
const asPath = evaluate(policy, contextFromToolInput(tool, { file_path: input }));
|
|
67
|
+
const chosen = asCommand.rule ? asCommand : asPath.rule ? asPath : asCommand;
|
|
68
|
+
const interpretation = asCommand.rule ? 'command' : asPath.rule ? 'file path' : 'command';
|
|
69
|
+
console.log(`
|
|
70
|
+
Tool ${tool}
|
|
71
|
+
Input ${input}
|
|
72
|
+
Read as ${interpretation}
|
|
73
|
+
Decision ${chosen.decision.toUpperCase()}
|
|
74
|
+
Rule ${chosen.rule?.name ?? '(none — default_decision applied)'}`);
|
|
75
|
+
if (chosen.message)
|
|
76
|
+
console.log(` Message ${chosen.message}`);
|
|
77
|
+
if (chosen.decision === 'allow') {
|
|
78
|
+
console.log('\n This call would be allowed to run.\n');
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
console.log('\n This call would be BLOCKED before running.\n');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
//# sourceMappingURL=policy.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agentobs run -- <command...>` - process-wrap adapter entrypoint.
|
|
3
|
+
*/
|
|
4
|
+
import { runWrapped } from '../adapters/process-wrap.js';
|
|
5
|
+
export async function run(command, opts = {}) {
|
|
6
|
+
if (command.length === 0) {
|
|
7
|
+
console.error('Nothing to run. Usage: agentobs run -- <command> [args...]');
|
|
8
|
+
process.exitCode = 2;
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
const code = await runWrapped(command, { agentName: opts.agent });
|
|
12
|
+
// Pass the wrapped process's exit code straight through, so wrapping a
|
|
13
|
+
// command inside a script or CI job never changes whether it "passed".
|
|
14
|
+
process.exitCode = code;
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=run.js.map
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agentobs stats` - terminal summary.
|
|
3
|
+
*/
|
|
4
|
+
import { openDb } from '../core/db.js';
|
|
5
|
+
import { getSummary, getToolsBreakdown } from '../core/queries.js';
|
|
6
|
+
function toRange(opts) {
|
|
7
|
+
if (opts.today)
|
|
8
|
+
return 'today';
|
|
9
|
+
const v = opts.since;
|
|
10
|
+
return v === 'today' || v === '7d' || v === '30d' || v === 'all' ? v : '7d';
|
|
11
|
+
}
|
|
12
|
+
const money = (v) => (v === null ? '—' : `$${v.toFixed(4)}`);
|
|
13
|
+
export async function stats(opts) {
|
|
14
|
+
const db = openDb();
|
|
15
|
+
const range = toRange(opts);
|
|
16
|
+
const summary = getSummary(db, range);
|
|
17
|
+
const tools = getToolsBreakdown(db, range);
|
|
18
|
+
if (opts.json) {
|
|
19
|
+
console.log(JSON.stringify({ summary, tools }, null, 2));
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
console.log(`
|
|
23
|
+
AgentObs · ${range}
|
|
24
|
+
|
|
25
|
+
Cost ${money(summary.total_cost_usd)}
|
|
26
|
+
Tool calls ${summary.tool_calls}
|
|
27
|
+
Sessions ${summary.sessions}
|
|
28
|
+
Errors ${summary.errors} (${(summary.error_rate * 100).toFixed(1)}%)
|
|
29
|
+
Blocked ${summary.blocked}
|
|
30
|
+
Tokens ${summary.tokens_in.toLocaleString()} in / ${summary.tokens_out.toLocaleString()} out`);
|
|
31
|
+
// State plainly when the cost total is incomplete rather than letting a
|
|
32
|
+
// partial number read as the whole spend.
|
|
33
|
+
if (summary.uncosted_calls > 0) {
|
|
34
|
+
console.log(`\n Note: ${summary.uncosted_calls} call(s) have no price for their model.\n Add it to ~/.agentobs/pricing.json to include them in the total.`);
|
|
35
|
+
}
|
|
36
|
+
if (tools.length > 0) {
|
|
37
|
+
console.log('\n Tool Calls Errors Cost');
|
|
38
|
+
console.log(' ' + '-'.repeat(46));
|
|
39
|
+
for (const t of tools.slice(0, 12)) {
|
|
40
|
+
const name = t.tool_name.slice(0, 18).padEnd(18);
|
|
41
|
+
const calls = String(t.calls).padStart(7);
|
|
42
|
+
const errors = String(t.errors).padStart(8);
|
|
43
|
+
const cost = money(t.cost_usd).padStart(10);
|
|
44
|
+
console.log(` ${name} ${calls} ${errors} ${cost}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
console.log('');
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=stats.js.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agentobs watch <file>` - JSONL adapter entrypoint.
|
|
3
|
+
*/
|
|
4
|
+
import { watchJsonl } from '../adapters/jsonl-watcher.js';
|
|
5
|
+
export async function watch(file, opts = {}) {
|
|
6
|
+
let seen = 0;
|
|
7
|
+
console.log(`Watching ${file} (agent: ${opts.agent ?? 'generic'}) — Ctrl-C to stop.`);
|
|
8
|
+
await watchJsonl(file, {
|
|
9
|
+
agentName: opts.agent,
|
|
10
|
+
follow: opts.follow !== false,
|
|
11
|
+
onEvent: () => {
|
|
12
|
+
seen += 1;
|
|
13
|
+
// Rewrite one line rather than scrolling: this runs in the foreground
|
|
14
|
+
// for the length of a session.
|
|
15
|
+
process.stdout.write(`\r ${seen} event(s) ingested`);
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
process.stdout.write('\n');
|
|
19
|
+
}
|
|
20
|
+
//# sourceMappingURL=watch.js.map
|
package/dist/core/db.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite access layer.
|
|
3
|
+
*
|
|
4
|
+
* Uses node:sqlite (built into Node >=22.5) rather than better-sqlite3 on
|
|
5
|
+
* purpose: better-sqlite3 is a native addon with no prebuilt binary for
|
|
6
|
+
* current Node releases, so installing it demands a C++ toolchain (Visual
|
|
7
|
+
* Studio on Windows). For a tool whose pitch is "npx agentobs init and
|
|
8
|
+
* you're running", a compiler in the install path is disqualifying. The
|
|
9
|
+
* built-in module has the same synchronous, prepared-statement API.
|
|
10
|
+
*/
|
|
11
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
12
|
+
import { readFileSync } from 'node:fs';
|
|
13
|
+
import { dirname, join } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { randomUUID } from 'node:crypto';
|
|
16
|
+
import { ensureHome, paths } from './paths.js';
|
|
17
|
+
let cached = null;
|
|
18
|
+
function schemaSql() {
|
|
19
|
+
// schema.sql sits next to this file in both src/ (dev) and dist/ (built),
|
|
20
|
+
// copied by scripts/copy-assets.mjs.
|
|
21
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
return readFileSync(join(here, 'schema.sql'), 'utf8');
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Opens (and migrates) the local database, memoised per process.
|
|
26
|
+
*
|
|
27
|
+
* The hook adapter runs this on every single tool call the agent makes, so
|
|
28
|
+
* the whole path has to stay well under the ~50ms budget - hence WAL, the
|
|
29
|
+
* memoised handle, and NORMAL synchronous mode.
|
|
30
|
+
*/
|
|
31
|
+
export function openDb(file) {
|
|
32
|
+
if (cached)
|
|
33
|
+
return cached;
|
|
34
|
+
ensureHome();
|
|
35
|
+
const db = new DatabaseSync(file ?? paths.db());
|
|
36
|
+
// WAL lets the dashboard read while a hook writes, instead of the two
|
|
37
|
+
// blocking each other - the single most important setting here, since a
|
|
38
|
+
// reader holding a lock would stall the agent's tool call.
|
|
39
|
+
db.exec('PRAGMA journal_mode = WAL');
|
|
40
|
+
// NORMAL trades a fsync per commit for speed. On a crash the worst case is
|
|
41
|
+
// losing the last few observability rows, which is an acceptable loss for
|
|
42
|
+
// a monitoring tool and not worth taxing every tool call to prevent.
|
|
43
|
+
db.exec('PRAGMA synchronous = NORMAL');
|
|
44
|
+
db.exec('PRAGMA foreign_keys = ON');
|
|
45
|
+
db.exec('PRAGMA busy_timeout = 5000');
|
|
46
|
+
db.exec(schemaSql());
|
|
47
|
+
ensureDeviceId(db);
|
|
48
|
+
cached = db;
|
|
49
|
+
return db;
|
|
50
|
+
}
|
|
51
|
+
/** Closes the memoised handle. Chiefly for tests and clean process exit. */
|
|
52
|
+
export function closeDb() {
|
|
53
|
+
if (cached) {
|
|
54
|
+
cached.close();
|
|
55
|
+
cached = null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export function getMeta(db, key) {
|
|
59
|
+
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get(key);
|
|
60
|
+
return row?.value ?? null;
|
|
61
|
+
}
|
|
62
|
+
export function setMeta(db, key, value) {
|
|
63
|
+
db.prepare('INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(key, value);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* This machine's stable identifier, minted once on first run. Used to
|
|
67
|
+
* attribute sessions to a device in the team view without ever sending a
|
|
68
|
+
* hostname or anything else personally identifying.
|
|
69
|
+
*/
|
|
70
|
+
export function ensureDeviceId(db) {
|
|
71
|
+
const existing = getMeta(db, 'device_id');
|
|
72
|
+
if (existing)
|
|
73
|
+
return existing;
|
|
74
|
+
const id = randomUUID();
|
|
75
|
+
setMeta(db, 'device_id', id);
|
|
76
|
+
return id;
|
|
77
|
+
}
|
|
78
|
+
export { randomUUID as newId };
|
|
79
|
+
//# sourceMappingURL=db.js.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem layout for the AgentObs home directory.
|
|
3
|
+
*
|
|
4
|
+
* Everything lives under one directory so uninstalling is `rm -rf ~/.agentobs`
|
|
5
|
+
* and backing up is copying it. AGENTOBS_HOME overrides the location, which
|
|
6
|
+
* both the test suite and CI rely on to avoid touching a developer's real data.
|
|
7
|
+
*/
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { mkdirSync } from 'node:fs';
|
|
11
|
+
export function agentobsHome() {
|
|
12
|
+
return process.env.AGENTOBS_HOME || join(homedir(), '.agentobs');
|
|
13
|
+
}
|
|
14
|
+
export const paths = {
|
|
15
|
+
home: agentobsHome,
|
|
16
|
+
db: () => join(agentobsHome(), 'agentobs.db'),
|
|
17
|
+
pricing: () => join(agentobsHome(), 'pricing.json'),
|
|
18
|
+
policy: () => join(agentobsHome(), 'policy.json'),
|
|
19
|
+
auth: () => join(agentobsHome(), 'auth.json'),
|
|
20
|
+
logs: () => join(agentobsHome(), 'logs'),
|
|
21
|
+
hookLog: () => join(agentobsHome(), 'logs', 'hook.log'),
|
|
22
|
+
};
|
|
23
|
+
/** Creates the home directory tree if absent. Safe to call repeatedly. */
|
|
24
|
+
export function ensureHome() {
|
|
25
|
+
const home = agentobsHome();
|
|
26
|
+
mkdirSync(home, { recursive: true });
|
|
27
|
+
mkdirSync(paths.logs(), { recursive: true });
|
|
28
|
+
return home;
|
|
29
|
+
}
|
|
30
|
+
//# sourceMappingURL=paths.js.map
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guardrail policy engine.
|
|
3
|
+
*
|
|
4
|
+
* Evaluates a proposed tool call against ~/.agentobs/policy.json and returns
|
|
5
|
+
* a decision the PreToolUse hook enforces. Two properties matter more than
|
|
6
|
+
* features here:
|
|
7
|
+
*
|
|
8
|
+
* 1. Fail open, loudly. A malformed policy file must never wedge the user's
|
|
9
|
+
* agent - a broken guardrail that blocks all work is worse than no
|
|
10
|
+
* guardrail. Parse errors degrade to default-allow and are reported.
|
|
11
|
+
* 2. Predictable matching. Users must be able to reason about what a rule
|
|
12
|
+
* will do before it fires, which is what `agentobs policy test` is for.
|
|
13
|
+
* First matching rule wins, in file order.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { paths } from './paths.js';
|
|
17
|
+
export const DEFAULT_POLICY = {
|
|
18
|
+
rules: [
|
|
19
|
+
{
|
|
20
|
+
name: 'no-recursive-force-delete',
|
|
21
|
+
match: { tool: 'Bash', command_pattern: '*rm -rf*' },
|
|
22
|
+
decision: 'block',
|
|
23
|
+
message: 'Recursive force-delete is blocked by AgentObs policy.',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: 'protect-env-files',
|
|
27
|
+
match: { tool: '*', path_pattern: '**/.env*' },
|
|
28
|
+
decision: 'needs_approval',
|
|
29
|
+
message: 'Editing .env files needs a human decision - they hold credentials.',
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: 'no-force-push',
|
|
33
|
+
match: { tool: 'Bash', command_pattern: '*git push*--force*' },
|
|
34
|
+
decision: 'needs_approval',
|
|
35
|
+
message: 'Force-push rewrites shared history.',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: 'no-curl-pipe-shell',
|
|
39
|
+
match: { tool: 'Bash', command_pattern: '*curl*|*sh*' },
|
|
40
|
+
decision: 'block',
|
|
41
|
+
message: 'Piping a downloaded script straight into a shell is blocked.',
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
default_decision: 'allow',
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Loads and validates the policy file.
|
|
48
|
+
*
|
|
49
|
+
* Returns errors rather than throwing: the caller is usually a hook running
|
|
50
|
+
* inside the user's agent, where an exception would surface as an agent
|
|
51
|
+
* failure rather than a policy problem.
|
|
52
|
+
*/
|
|
53
|
+
export function loadPolicy(file) {
|
|
54
|
+
const target = file ?? paths.policy();
|
|
55
|
+
if (!existsSync(target)) {
|
|
56
|
+
// No policy configured is a legitimate state - observability without
|
|
57
|
+
// enforcement is Phase A's whole product.
|
|
58
|
+
return { policy: { rules: [], default_decision: 'allow' }, errors: [], source: 'none' };
|
|
59
|
+
}
|
|
60
|
+
let raw;
|
|
61
|
+
try {
|
|
62
|
+
raw = JSON.parse(readFileSync(target, 'utf8'));
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
return {
|
|
66
|
+
policy: { rules: [], default_decision: 'allow' },
|
|
67
|
+
errors: [`policy.json is not valid JSON: ${err.message}`],
|
|
68
|
+
source: 'default',
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const errors = [];
|
|
72
|
+
const parsed = raw;
|
|
73
|
+
const rules = [];
|
|
74
|
+
if (!Array.isArray(parsed.rules)) {
|
|
75
|
+
errors.push('policy.json must have a "rules" array');
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
parsed.rules.forEach((rule, i) => {
|
|
79
|
+
const where = rule?.name ? `rule "${rule.name}"` : `rule #${i + 1}`;
|
|
80
|
+
if (!rule || typeof rule !== 'object') {
|
|
81
|
+
errors.push(`${where} is not an object`);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (!rule.match || typeof rule.match !== 'object') {
|
|
85
|
+
errors.push(`${where} is missing a "match" object`);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (!isDecision(rule.decision)) {
|
|
89
|
+
errors.push(`${where} has an invalid decision "${rule.decision}" (allow|block|needs_approval)`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (!rule.match.tool && !rule.match.command_pattern && !rule.match.path_pattern) {
|
|
93
|
+
// A rule matching on nothing would fire on every call - almost
|
|
94
|
+
// certainly a typo, and a destructive one if the decision is block.
|
|
95
|
+
errors.push(`${where} has no match criteria; it would match every tool call`);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
rules.push({ ...rule, name: rule.name ?? `rule-${i + 1}` });
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
const fallback = isDecision(parsed.default_decision) ? parsed.default_decision : 'allow';
|
|
102
|
+
if (parsed.default_decision !== undefined && !isDecision(parsed.default_decision)) {
|
|
103
|
+
errors.push(`invalid default_decision "${parsed.default_decision}", using "allow"`);
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
policy: { rules, default_decision: fallback },
|
|
107
|
+
errors,
|
|
108
|
+
source: errors.length && rules.length === 0 ? 'default' : 'file',
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function isDecision(value) {
|
|
112
|
+
return value === 'allow' || value === 'block' || value === 'needs_approval';
|
|
113
|
+
}
|
|
114
|
+
export function writeDefaultPolicy(file) {
|
|
115
|
+
const target = file ?? paths.policy();
|
|
116
|
+
if (!existsSync(target)) {
|
|
117
|
+
writeFileSync(target, `${JSON.stringify(DEFAULT_POLICY, null, 2)}\n`, 'utf8');
|
|
118
|
+
}
|
|
119
|
+
return target;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Sentinel standing in for `**` between the two star-replacement passes.
|
|
123
|
+
* NUL is used because it cannot legitimately appear in a policy pattern -
|
|
124
|
+
* a printable placeholder (a space, say) would collide with patterns that
|
|
125
|
+
* contain it, such as `*rm -rf*`.
|
|
126
|
+
*/
|
|
127
|
+
const DOUBLE_STAR = '\u0000';
|
|
128
|
+
/**
|
|
129
|
+
* Minimal glob matcher, hand-rolled rather than pulling in a glob dependency:
|
|
130
|
+
* this runs on the hot path of every tool call and the pattern surface is tiny.
|
|
131
|
+
*
|
|
132
|
+
* The regex is anchored, so a pattern must match the whole string. An
|
|
133
|
+
* unanchored `rm -rf` would otherwise fire on `confirm -rfx`.
|
|
134
|
+
*
|
|
135
|
+
* `mode` decides what a bare `*` means, and getting it wrong is a security
|
|
136
|
+
* bug in both directions:
|
|
137
|
+
*
|
|
138
|
+
* - 'path' - `*` stays within one segment, `**` crosses them. The normal
|
|
139
|
+
* glob contract users expect from a pattern like `**\/.env*`.
|
|
140
|
+
* - 'command' - `*` crosses `/` freely. A shell command is not a path; under
|
|
141
|
+
* path semantics `*rm -rf*` fails to match `rm -rf /` purely
|
|
142
|
+
* because the argument contains a slash, silently letting
|
|
143
|
+
* through the exact command the rule exists to stop.
|
|
144
|
+
*/
|
|
145
|
+
export function globMatch(pattern, value, mode = 'path') {
|
|
146
|
+
// A leading `**/` must also match a bare filename at the root: the whole
|
|
147
|
+
// point of `**\/.env*` is protecting `.env`, and that file most often sits
|
|
148
|
+
// in the working directory with no directory prefix at all. Standard glob
|
|
149
|
+
// implementations make this prefix optional for the same reason.
|
|
150
|
+
const optionalLeadingDirs = mode === 'path' && pattern.startsWith('**/');
|
|
151
|
+
const body = optionalLeadingDirs ? pattern.slice(3) : pattern;
|
|
152
|
+
const escaped = body.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
153
|
+
const singleStar = mode === 'command' ? '.*' : '[^/]*';
|
|
154
|
+
const rx = escaped
|
|
155
|
+
.split('**')
|
|
156
|
+
.join(DOUBLE_STAR)
|
|
157
|
+
.split('*')
|
|
158
|
+
.join(singleStar)
|
|
159
|
+
.split(DOUBLE_STAR)
|
|
160
|
+
.join('.*')
|
|
161
|
+
.split('?')
|
|
162
|
+
.join('.');
|
|
163
|
+
const prefix = optionalLeadingDirs ? '(?:.*/)?' : '';
|
|
164
|
+
return new RegExp(`^${prefix}${rx}$`, 'i').test(value);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Evaluates a tool call. First matching rule wins, in file order, so a user
|
|
168
|
+
* can put a narrow allow above a broad block and have it behave the way
|
|
169
|
+
* reading top-to-bottom suggests.
|
|
170
|
+
*/
|
|
171
|
+
export function evaluate(policy, ctx) {
|
|
172
|
+
for (const rule of policy.rules) {
|
|
173
|
+
if (matches(rule.match, ctx)) {
|
|
174
|
+
return {
|
|
175
|
+
decision: rule.decision,
|
|
176
|
+
rule,
|
|
177
|
+
message: rule.message ?? defaultMessage(rule),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return { decision: policy.default_decision, rule: null, message: null };
|
|
182
|
+
}
|
|
183
|
+
function matches(match, ctx) {
|
|
184
|
+
if (match.tool && match.tool !== '*') {
|
|
185
|
+
if (match.tool.toLowerCase() !== ctx.tool.toLowerCase())
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
if (match.command_pattern) {
|
|
189
|
+
const command = ctx.command ?? extractString(ctx.raw);
|
|
190
|
+
if (!command || !globMatch(match.command_pattern, command, 'command'))
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
if (match.path_pattern) {
|
|
194
|
+
const path = ctx.path ?? extractString(ctx.raw);
|
|
195
|
+
if (!path)
|
|
196
|
+
return false;
|
|
197
|
+
// Compare with forward slashes so one pattern works on Windows too - a
|
|
198
|
+
// rule written as **\/.env* must not silently miss C:\repo\.env.
|
|
199
|
+
if (!globMatch(match.path_pattern, path.replace(/\\/g, '/')))
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
/** Best-effort string view of an arbitrary tool input, for pattern matching. */
|
|
205
|
+
function extractString(raw) {
|
|
206
|
+
if (raw == null)
|
|
207
|
+
return null;
|
|
208
|
+
if (typeof raw === 'string')
|
|
209
|
+
return raw;
|
|
210
|
+
try {
|
|
211
|
+
return JSON.stringify(raw);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
function defaultMessage(rule) {
|
|
218
|
+
return rule.decision === 'block'
|
|
219
|
+
? `Blocked by AgentObs policy rule "${rule.name}".`
|
|
220
|
+
: `Rule "${rule.name}" requires approval before this can run.`;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Pulls the command/path out of a tool input payload.
|
|
224
|
+
*
|
|
225
|
+
* Agents name these fields inconsistently (command vs cmd, file_path vs path
|
|
226
|
+
* vs filename), so we check the known aliases rather than assuming one shape:
|
|
227
|
+
* a missed field means a guardrail silently fails to match.
|
|
228
|
+
*/
|
|
229
|
+
export function contextFromToolInput(tool, input) {
|
|
230
|
+
const obj = (input && typeof input === 'object' ? input : {});
|
|
231
|
+
const pick = (...keys) => {
|
|
232
|
+
for (const key of keys) {
|
|
233
|
+
const val = obj[key];
|
|
234
|
+
if (typeof val === 'string' && val.length > 0)
|
|
235
|
+
return val;
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
};
|
|
239
|
+
return {
|
|
240
|
+
tool,
|
|
241
|
+
command: pick('command', 'cmd', 'script', 'shell_command'),
|
|
242
|
+
path: pick('file_path', 'path', 'filename', 'notebook_path', 'target_file'),
|
|
243
|
+
raw: input,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
//# sourceMappingURL=policy-engine.js.map
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token -> USD conversion.
|
|
3
|
+
*
|
|
4
|
+
* Hard rule: an unknown model yields `null`, never a guess. A fabricated
|
|
5
|
+
* cost is worse than a blank one - a user who spots one wrong number stops
|
|
6
|
+
* trusting every number, and cost accuracy is the product's core claim.
|
|
7
|
+
* Unknown models are surfaced in the dashboard as "-" with a hint to add
|
|
8
|
+
* them to pricing.json.
|
|
9
|
+
*
|
|
10
|
+
* Prices are per million tokens, matching how vendors publish them, and live
|
|
11
|
+
* in an editable ~/.agentobs/pricing.json so a price change never requires a
|
|
12
|
+
* new release.
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { paths } from './paths.js';
|
|
16
|
+
/**
|
|
17
|
+
* Seed table written by `agentobs init`. Verify against current vendor
|
|
18
|
+
* pricing pages before a release - these are a starting point the user is
|
|
19
|
+
* expected to edit, not an authoritative source.
|
|
20
|
+
*/
|
|
21
|
+
export const DEFAULT_PRICING = {
|
|
22
|
+
_comment: 'Prices in USD per 1,000,000 tokens. Edit freely - AgentObs reads this file at runtime. A model missing here shows cost as blank rather than a guess.',
|
|
23
|
+
updated: '2026-08-29',
|
|
24
|
+
models: {
|
|
25
|
+
'claude-opus-4': { input_per_mtok: 15, output_per_mtok: 75 },
|
|
26
|
+
'claude-sonnet-4': { input_per_mtok: 3, output_per_mtok: 15 },
|
|
27
|
+
'claude-haiku-4-5': { input_per_mtok: 1, output_per_mtok: 5 },
|
|
28
|
+
'claude-3-5-haiku': { input_per_mtok: 0.8, output_per_mtok: 4 },
|
|
29
|
+
'gpt-4o': { input_per_mtok: 2.5, output_per_mtok: 10 },
|
|
30
|
+
'gpt-4o-mini': { input_per_mtok: 0.15, output_per_mtok: 0.6 },
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
let cache = null;
|
|
34
|
+
export function loadPricing(force = false) {
|
|
35
|
+
if (cache && !force)
|
|
36
|
+
return cache;
|
|
37
|
+
const file = paths.pricing();
|
|
38
|
+
if (!existsSync(file)) {
|
|
39
|
+
cache = DEFAULT_PRICING;
|
|
40
|
+
return cache;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
44
|
+
// A malformed hand-edited file must not take the whole CLI down; fall
|
|
45
|
+
// back to defaults and let cost show blank rather than crashing a hook.
|
|
46
|
+
cache = parsed?.models ? parsed : DEFAULT_PRICING;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
cache = DEFAULT_PRICING;
|
|
50
|
+
}
|
|
51
|
+
return cache;
|
|
52
|
+
}
|
|
53
|
+
export function writeDefaultPricing() {
|
|
54
|
+
const file = paths.pricing();
|
|
55
|
+
if (!existsSync(file)) {
|
|
56
|
+
writeFileSync(file, `${JSON.stringify(DEFAULT_PRICING, null, 2)}\n`, 'utf8');
|
|
57
|
+
}
|
|
58
|
+
return file;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Resolves a reported model id to a price entry.
|
|
62
|
+
*
|
|
63
|
+
* Vendors append dated suffixes (`claude-sonnet-4-20250514`) and platforms
|
|
64
|
+
* add prefixes (`us.anthropic.claude-...`), so an exact-match-only lookup
|
|
65
|
+
* would blank out cost for nearly every real session. Matching the longest
|
|
66
|
+
* configured key contained in the id handles both, and longest-first avoids
|
|
67
|
+
* a short key shadowing a more specific one.
|
|
68
|
+
*/
|
|
69
|
+
export function findModelPrice(model) {
|
|
70
|
+
if (!model)
|
|
71
|
+
return null;
|
|
72
|
+
const table = loadPricing();
|
|
73
|
+
const id = model.toLowerCase();
|
|
74
|
+
if (table.models[model])
|
|
75
|
+
return table.models[model];
|
|
76
|
+
const keys = Object.keys(table.models).sort((a, b) => b.length - a.length);
|
|
77
|
+
for (const key of keys) {
|
|
78
|
+
if (id.includes(key.toLowerCase()))
|
|
79
|
+
return table.models[key];
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Cost for a call, or `null` when the model is unknown - never a guess.
|
|
85
|
+
*/
|
|
86
|
+
export function computeCost(model, tokensIn, tokensOut) {
|
|
87
|
+
const price = findModelPrice(model);
|
|
88
|
+
if (!price)
|
|
89
|
+
return null;
|
|
90
|
+
const inTok = tokensIn ?? 0;
|
|
91
|
+
const outTok = tokensOut ?? 0;
|
|
92
|
+
if (inTok === 0 && outTok === 0)
|
|
93
|
+
return 0;
|
|
94
|
+
return (inTok / 1_000_000) * price.input_per_mtok + (outTok / 1_000_000) * price.output_per_mtok;
|
|
95
|
+
}
|
|
96
|
+
/** Test seam: drops the memoised table so a rewritten file is picked up. */
|
|
97
|
+
export function __resetPricingCache() {
|
|
98
|
+
cache = null;
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=pricing.js.map
|