kritya 0.8.62-beta → 0.8.63-beta
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/dist/agent/toolExecutor.js +34 -0
- package/dist/permissions/recon.js +162 -0
- package/dist/tools/common.js +1 -1
- package/package.json +1 -1
|
@@ -3,6 +3,7 @@ import { classifyLaterExec, classifyLaterExecInShell, recordLaterExec, workspace
|
|
|
3
3
|
import { acknowledgeUnsandboxedFallback, sandboxAvailable, sandboxFallbackWarning, } from "../shell/sandbox.js";
|
|
4
4
|
import { isPlanningDocWrite, loadProjectState } from "./workflow.js";
|
|
5
5
|
import { redactSecrets } from "../tools/secretScan.js";
|
|
6
|
+
import { classifyReconCall, ReconTripwire, RECON_TRIP_THRESHOLD, RECON_WINDOW_CALLS, } from "../permissions/recon.js";
|
|
6
7
|
/** How much tool output to hand the UI (it shows a preview and expands on toggle). */
|
|
7
8
|
const PREVIEW_CHARS = 4000;
|
|
8
9
|
/**
|
|
@@ -91,6 +92,12 @@ function fenceExternal(output) {
|
|
|
91
92
|
export class ToolExecutor {
|
|
92
93
|
tools;
|
|
93
94
|
host;
|
|
95
|
+
/**
|
|
96
|
+
* Per-session recon-pattern score (see permissions/recon.ts). One
|
|
97
|
+
* executor serves one agent session, so the tripwire's state naturally
|
|
98
|
+
* spans exactly that session's calls and resets with it.
|
|
99
|
+
*/
|
|
100
|
+
tripwire = new ReconTripwire();
|
|
94
101
|
constructor(tools, host) {
|
|
95
102
|
this.tools = tools;
|
|
96
103
|
this.host = host;
|
|
@@ -211,6 +218,33 @@ export class ToolExecutor {
|
|
|
211
218
|
};
|
|
212
219
|
if (host.kill.active)
|
|
213
220
|
return killBlocked();
|
|
221
|
+
// Recon-pattern tripwire. The gates below (and the tools' own checks)
|
|
222
|
+
// judge calls one at a time; this is the one place that sees the
|
|
223
|
+
// sequence. Every call is scored, including ones another gate is about
|
|
224
|
+
// to block anyway — an attempt at a credential file is a signal whether
|
|
225
|
+
// or not it succeeds. Recording happens in this synchronous stretch,
|
|
226
|
+
// before any await, so a parallel batch of read-only calls can't race
|
|
227
|
+
// the score. Once tripped, only recon-carrying calls are blocked;
|
|
228
|
+
// ordinary work flows through the normal gates below.
|
|
229
|
+
const recon = this.tripwire.record(classifyReconCall(name, args));
|
|
230
|
+
if (recon.blocked) {
|
|
231
|
+
host.audit?.logPermission({
|
|
232
|
+
tool: name,
|
|
233
|
+
summary,
|
|
234
|
+
verdict: "denied",
|
|
235
|
+
source: "recon-tripwire",
|
|
236
|
+
});
|
|
237
|
+
logToolOutcome("blocked");
|
|
238
|
+
finishSpan("ERROR", "blocked: recon-pattern tripwire");
|
|
239
|
+
handlers.onToolEnd(id, name, summary, "blocked: recon-pattern tripwire", true);
|
|
240
|
+
return (`Stopped by the recon-pattern tripwire: this session's recent calls form a ` +
|
|
241
|
+
`reconnaissance-like pattern (${this.tripwire.tripKinds.join(", ")}) — ${recon.score} ` +
|
|
242
|
+
`points across the last ${RECON_WINDOW_CALLS} calls, over the limit of ` +
|
|
243
|
+
`${RECON_TRIP_THRESHOLD}. This call was blocked, and further reconnaissance-like ` +
|
|
244
|
+
`calls stay blocked for the rest of the session. Ordinary work (reading and ` +
|
|
245
|
+
`editing project files, builds, tests) can continue. If this is legitimate ` +
|
|
246
|
+
`debugging, tell the user what you are doing and why; a new session resets the tripwire.`);
|
|
247
|
+
}
|
|
214
248
|
// The planning-doc exemption is scoped to the active project's own
|
|
215
249
|
// docs/<slug>/ folder, so plan mode can persist its artifact without
|
|
216
250
|
// becoming a way to edit unrelated documentation.
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { commandTouchesSensitivePath, isSensitivePath } from "../tools/common.js";
|
|
2
|
+
const WEIGHTS = {
|
|
3
|
+
"credential-probe": 5,
|
|
4
|
+
"env-dump": 4,
|
|
5
|
+
enumeration: 2,
|
|
6
|
+
"system-probe": 2,
|
|
7
|
+
};
|
|
8
|
+
/** Score at or above which the tripwire latches for the rest of the session. */
|
|
9
|
+
export const RECON_TRIP_THRESHOLD = 10;
|
|
10
|
+
/** How many recent calls the rolling score covers. */
|
|
11
|
+
export const RECON_WINDOW_CALLS = 20;
|
|
12
|
+
function signal(kind, detail) {
|
|
13
|
+
return { kind, weight: WEIGHTS[kind], detail };
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Whole-environment dumps. Only the bare forms score: `env FOO=1 cmd` runs a
|
|
17
|
+
* command with one variable set and `printenv PATH` reads one variable —
|
|
18
|
+
* both ordinary. Dumping *everything* is the recon move.
|
|
19
|
+
*/
|
|
20
|
+
const ENV_DUMP_PATTERNS = [
|
|
21
|
+
{ re: /(^|[|;&]\s*)(env|printenv)(\s*$|\s*[|;&])/, detail: "bare env/printenv dump" },
|
|
22
|
+
{ re: /\bexport\s+-p\b/, detail: "export -p dump" },
|
|
23
|
+
{ re: /\bdeclare\s+-x\b/, detail: "declare -x dump" },
|
|
24
|
+
{ re: /\bcompgen\s+-e\b/, detail: "compgen -e env listing" },
|
|
25
|
+
{ re: /\/proc\/(\d+|self)\/environ\b/, detail: "reads /proc environ" },
|
|
26
|
+
{ re: /\b(dir|ls|gci|Get-ChildItem)\s+env:/i, detail: "PowerShell env: drive listing" },
|
|
27
|
+
];
|
|
28
|
+
/** Identity and network-shape questions — cheap to ask, classic to ask first. */
|
|
29
|
+
const SYSTEM_PROBE_PATTERNS = [
|
|
30
|
+
{ re: /\bwhoami\b/, detail: "whoami" },
|
|
31
|
+
{ re: /\bhostname\b/, detail: "hostname" },
|
|
32
|
+
{ re: /\buname\b/, detail: "uname" },
|
|
33
|
+
{ re: /\bsysteminfo\b/, detail: "systeminfo" },
|
|
34
|
+
{ re: /(^|[|;&]\s*)id\s*($|[|;&])/, detail: "bare id" },
|
|
35
|
+
{ re: /\bnetstat\b/, detail: "netstat" },
|
|
36
|
+
{ re: /\bifconfig\b/, detail: "ifconfig" },
|
|
37
|
+
{ re: /\bipconfig\b/, detail: "ipconfig" },
|
|
38
|
+
{ re: /\bip\s+(addr|address|link|route)\b/, detail: "ip addr/link/route" },
|
|
39
|
+
{ re: /\barp\s+-a\b/, detail: "arp -a" },
|
|
40
|
+
{ re: /\bps\s+(aux|ef|-ef|-aux)\b/, detail: "full process listing" },
|
|
41
|
+
];
|
|
42
|
+
/**
|
|
43
|
+
* Listing the places a person keeps things, rather than the project: home,
|
|
44
|
+
* the filesystem root, /etc, other users' folders. A listing of the
|
|
45
|
+
* workspace itself (relative paths, /tmp, project dirs) scores nothing.
|
|
46
|
+
*/
|
|
47
|
+
const ENUMERATION_PATTERNS = [
|
|
48
|
+
{
|
|
49
|
+
re: /(?:^|[|;&]\s*)(?:ls|dir|find|tree)\b[^|;&]*?\s(?:~|\$HOME|\/etc|\/home|[A-Za-z]:\\Users)(?=[\s|;&]|$)/,
|
|
50
|
+
detail: "lists home/system directories",
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
re: /(?:^|[|;&]\s*)(?:ls|dir|find|tree)\b[^|;&]*?\s\/(?=[\s|;&]|$)/,
|
|
54
|
+
detail: "lists the filesystem root",
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
/** Tools whose path/pattern arguments can aim at a credential file or a system directory. */
|
|
58
|
+
const PATH_ARG_TOOLS = new Set([
|
|
59
|
+
"read_file",
|
|
60
|
+
"read_document",
|
|
61
|
+
"read_notebook",
|
|
62
|
+
"edit_file",
|
|
63
|
+
"write_file",
|
|
64
|
+
"grep",
|
|
65
|
+
"glob",
|
|
66
|
+
"list_dir",
|
|
67
|
+
]);
|
|
68
|
+
/**
|
|
69
|
+
* Classify one tool call into recon signals — at most one signal per kind,
|
|
70
|
+
* so a single busy command can't trip the wire on repetition alone; it
|
|
71
|
+
* takes either one heavy call (a credential probe plus a dump) or a genuine
|
|
72
|
+
* sequence across calls.
|
|
73
|
+
*/
|
|
74
|
+
export function classifyReconCall(toolName, args) {
|
|
75
|
+
const byKind = new Map();
|
|
76
|
+
const add = (s) => {
|
|
77
|
+
if (!byKind.has(s.kind))
|
|
78
|
+
byKind.set(s.kind, s);
|
|
79
|
+
};
|
|
80
|
+
if (toolName === "shell") {
|
|
81
|
+
const command = String(args.command ?? "");
|
|
82
|
+
if (command) {
|
|
83
|
+
const sensitive = commandTouchesSensitivePath(command);
|
|
84
|
+
if (sensitive)
|
|
85
|
+
add(signal("credential-probe", `command references "${sensitive}"`));
|
|
86
|
+
for (const { re, detail } of ENV_DUMP_PATTERNS) {
|
|
87
|
+
if (re.test(command)) {
|
|
88
|
+
add(signal("env-dump", detail));
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
for (const { re, detail } of ENUMERATION_PATTERNS) {
|
|
93
|
+
if (re.test(command)) {
|
|
94
|
+
add(signal("enumeration", detail));
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
for (const { re, detail } of SYSTEM_PROBE_PATTERNS) {
|
|
99
|
+
if (re.test(command)) {
|
|
100
|
+
add(signal("system-probe", detail));
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
else if (PATH_ARG_TOOLS.has(toolName)) {
|
|
107
|
+
for (const key of ["path", "pattern"]) {
|
|
108
|
+
const value = args[key];
|
|
109
|
+
if (typeof value !== "string" || !value)
|
|
110
|
+
continue;
|
|
111
|
+
if (isSensitivePath(value)) {
|
|
112
|
+
add(signal("credential-probe", `${toolName} targets "${value}"`));
|
|
113
|
+
}
|
|
114
|
+
if ((toolName === "list_dir" || toolName === "glob") &&
|
|
115
|
+
/^(~|\$HOME|\/|\/etc|\/home)([\\/]|$)/.test(value)) {
|
|
116
|
+
add(signal("enumeration", `${toolName} targets "${value}"`));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return [...byKind.values()];
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* The rolling score and the latch. One instance per agent session (the
|
|
124
|
+
* tool executor owns it). Blocked calls are recorded too — the executor
|
|
125
|
+
* records before it blocks — so continued probing keeps the score pinned
|
|
126
|
+
* above the threshold instead of decaying while the caller retries.
|
|
127
|
+
*/
|
|
128
|
+
export class ReconTripwire {
|
|
129
|
+
window = [];
|
|
130
|
+
latchedKinds = null;
|
|
131
|
+
get tripped() {
|
|
132
|
+
return this.latchedKinds !== null;
|
|
133
|
+
}
|
|
134
|
+
/** The signal kinds that contributed to the trip, for the block message. */
|
|
135
|
+
get tripKinds() {
|
|
136
|
+
return this.latchedKinds ?? [];
|
|
137
|
+
}
|
|
138
|
+
windowScore() {
|
|
139
|
+
return this.window.reduce((acc, w) => acc + w.score, 0);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Record one call's signals. Returns whether this call must be blocked:
|
|
143
|
+
* the call that pushes the score to the threshold, and — once latched —
|
|
144
|
+
* every later call that carries any recon signal. Signal-free calls are
|
|
145
|
+
* never blocked, tripped or not.
|
|
146
|
+
*/
|
|
147
|
+
record(signals) {
|
|
148
|
+
const score = signals.reduce((acc, s) => acc + s.weight, 0);
|
|
149
|
+
this.window.push({ score, kinds: signals.map((s) => s.kind) });
|
|
150
|
+
if (this.window.length > RECON_WINDOW_CALLS)
|
|
151
|
+
this.window.shift();
|
|
152
|
+
const total = this.windowScore();
|
|
153
|
+
if (this.latchedKinds) {
|
|
154
|
+
return { blocked: signals.length > 0, score: total };
|
|
155
|
+
}
|
|
156
|
+
if (total >= RECON_TRIP_THRESHOLD) {
|
|
157
|
+
this.latchedKinds = [...new Set(this.window.flatMap((w) => w.kinds))];
|
|
158
|
+
return { blocked: signals.length > 0, score: total };
|
|
159
|
+
}
|
|
160
|
+
return { blocked: false, score: total };
|
|
161
|
+
}
|
|
162
|
+
}
|
package/dist/tools/common.js
CHANGED
|
@@ -45,7 +45,7 @@ const SENSITIVE_PATH_PATTERNS = [
|
|
|
45
45
|
/\.p12$/i,
|
|
46
46
|
/\.pfx$/i,
|
|
47
47
|
];
|
|
48
|
-
function isSensitivePath(relPath) {
|
|
48
|
+
export function isSensitivePath(relPath) {
|
|
49
49
|
return SENSITIVE_PATH_PATTERNS.some((re) => re.test(relPath));
|
|
50
50
|
}
|
|
51
51
|
/** Splits a shell command into path-like tokens, stripping quotes/metacharacters. */
|