@aimarket/warden 0.22.4 → 0.24.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/CHANGELOG.md +53 -0
- package/README-es.md +19 -9
- package/README-fr.md +19 -9
- package/README-ru.md +19 -9
- package/README-zh.md +18 -9
- package/README.md +29 -11
- package/codex-hooks/hooks.json +17 -0
- package/codex-hooks/requirements.toml +38 -0
- package/codex-hooks/scripts/codex-hook.mjs +15 -0
- package/codex-hooks/scripts/flow-guard-answer.mjs +228 -0
- package/codex-hooks/scripts/flow-guard-classify.mjs +224 -0
- package/codex-hooks/scripts/flow-guard-codex.mjs +222 -0
- package/codex-hooks/scripts/flow-guard-constraints.mjs +69 -0
- package/codex-hooks/scripts/flow-guard-decode.mjs +149 -0
- package/codex-hooks/scripts/flow-guard-destinations.mjs +158 -0
- package/codex-hooks/scripts/flow-guard-fingerprints.mjs +180 -0
- package/codex-hooks/scripts/flow-guard-fragments.mjs +122 -0
- package/codex-hooks/scripts/flow-guard-instructions.mjs +36 -0
- package/codex-hooks/scripts/flow-guard-labels.mjs +55 -0
- package/codex-hooks/scripts/flow-guard-leak.mjs +135 -0
- package/codex-hooks/scripts/flow-guard-paths.mjs +80 -0
- package/codex-hooks/scripts/flow-guard-policy.mjs +179 -0
- package/codex-hooks/scripts/flow-guard-post.mjs +131 -0
- package/codex-hooks/scripts/flow-guard-pre.mjs +339 -0
- package/codex-hooks/scripts/flow-guard-screen.mjs +558 -0
- package/codex-hooks/scripts/flow-guard-state.mjs +129 -0
- package/codex-hooks/scripts/flow-guard-text.mjs +46 -0
- package/codex-hooks/scripts/flow-guard-types.mjs +2 -0
- package/codex-hooks/scripts/flow-guard-unattended.mjs +22 -0
- package/codex-hooks/scripts/flow-guard.mjs +103 -0
- package/dist/flow-guard-codex.d.ts +32 -0
- package/dist/flow-guard-codex.d.ts.map +1 -0
- package/dist/flow-guard-codex.js +222 -0
- package/dist/flow-guard-codex.js.map +1 -0
- package/dist/flow-guard-fingerprints.d.ts +4 -0
- package/dist/flow-guard-fingerprints.d.ts.map +1 -1
- package/dist/flow-guard-fingerprints.js +17 -1
- package/dist/flow-guard-fingerprints.js.map +1 -1
- package/dist/flow-guard-paths.d.ts.map +1 -1
- package/dist/flow-guard-paths.js +9 -1
- package/dist/flow-guard-paths.js.map +1 -1
- package/dist/flow-guard-post.js +2 -2
- package/dist/flow-guard-post.js.map +1 -1
- package/dist/flow-guard-pre.d.ts +8 -1
- package/dist/flow-guard-pre.d.ts.map +1 -1
- package/dist/flow-guard-pre.js +5 -1
- package/dist/flow-guard-pre.js.map +1 -1
- package/dist/flow-guard-unattended.d.ts +27 -0
- package/dist/flow-guard-unattended.d.ts.map +1 -0
- package/dist/flow-guard-unattended.js +22 -0
- package/dist/flow-guard-unattended.js.map +1 -0
- package/dist/flow-guard.d.ts +2 -1
- package/dist/flow-guard.d.ts.map +1 -1
- package/dist/flow-guard.js +1 -1
- package/dist/flow-guard.js.map +1 -1
- package/dist/opencode-plugin.d.ts +35 -0
- package/dist/opencode-plugin.d.ts.map +1 -0
- package/dist/opencode-plugin.js +215 -0
- package/dist/opencode-plugin.js.map +1 -0
- package/docs/codex.es.md +260 -0
- package/docs/codex.fr.md +262 -0
- package/docs/codex.md +251 -0
- package/docs/codex.ru.md +254 -0
- package/docs/codex.zh.md +232 -0
- package/docs/flow-guard.es.md +1 -1
- package/docs/flow-guard.fr.md +1 -1
- package/docs/flow-guard.md +1 -1
- package/docs/flow-guard.ru.md +1 -1
- package/docs/flow-guard.zh.md +1 -1
- package/docs/gates.md +1 -1
- package/docs/opencode.md +153 -0
- package/docs/scan.es.md +2 -2
- package/docs/scan.fr.md +2 -2
- package/docs/scan.md +2 -2
- package/docs/scan.ru.md +2 -2
- package/docs/scan.zh.md +2 -2
- package/opencode-plugin/warden.js +10 -0
- package/package.json +17 -2
- package/scripts/codex-live-check.sh +109 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
// GENERATED from src/flow-guard-pre.ts by scripts/sync-flow-guard.mjs. Do not edit; run npm run build.
|
|
2
|
+
/** Flow guard, PreToolUse: whether a call asks first, is denied or passes. */
|
|
3
|
+
import { mkdirSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { absPath, flowPaths, isSecretPath, sessionKey, stateDir } from './flow-guard-paths.mjs';
|
|
6
|
+
import { globMatch, loadPolicy, publicRequestDigest, taskDelegation } from './flow-guard-policy.mjs';
|
|
7
|
+
import { classifyCall } from './flow-guard-classify.mjs';
|
|
8
|
+
import { guiOperation, screenCall } from './flow-guard-screen.mjs';
|
|
9
|
+
import { DEST_ARG_NAMES, allowedDestination, destinations, hasDestinationArgument, keepsDestination, namedByUser } from './flow-guard-destinations.mjs';
|
|
10
|
+
import { piiTokens, secretPattern } from './flow-guard-fingerprints.mjs';
|
|
11
|
+
import { candidates } from './flow-guard-decode.mjs';
|
|
12
|
+
import { checkFragments } from './flow-guard-fragments.mjs';
|
|
13
|
+
import { checkLeak, checkStream, readFp, readStores } from './flow-guard-leak.mjs';
|
|
14
|
+
import { log, readFlowState, safeToolName, updateFlowState } from './flow-guard-state.mjs';
|
|
15
|
+
const MAX_TAIL_KEYS = 32;
|
|
16
|
+
/** Tainted by MCP definitions: a server the session-start scan blocked or could not check still put its text in the context. */
|
|
17
|
+
function definitionTaint(dir, sessionId) {
|
|
18
|
+
try {
|
|
19
|
+
const v = JSON.parse(readFileSync(join(dir, 'claude-code', 'sessions', `${sessionKey(sessionId)}.json`), 'utf8'));
|
|
20
|
+
const bad = (v.servers || []).find(s => s.allow === false || s.status === 'error' || (s.blockedTools && s.blockedTools.length));
|
|
21
|
+
return bad ? `MCP server "${safeToolName(String(bad.key))}" definitions` : undefined;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function sourcesText(s, extra) {
|
|
28
|
+
// Tool identity establishes which result was read, not which result supplied a destination.
|
|
29
|
+
// Do not echo source URLs (which can carry credentials) or arbitrary result text into the dialog.
|
|
30
|
+
const list = [...(extra ? [extra] : []), ...s.untrusted.map(u => safeToolName(u.tool))];
|
|
31
|
+
const uniq = [...new Set(list)].slice(-3);
|
|
32
|
+
return uniq.join(', ') || 'outside text';
|
|
33
|
+
}
|
|
34
|
+
/** A short destination preview, never the outgoing message, shell program or credential value. */
|
|
35
|
+
function targetSummary(toolName, input, policy, sensitivePayload) {
|
|
36
|
+
const configured = Object.entries(policy.destinationArgs).find(([glob]) => globMatch(glob, toolName))?.[1];
|
|
37
|
+
const names = configured ? new Set(configured.map(n => n.toLowerCase())) : DEST_ARG_NAMES;
|
|
38
|
+
const targets = [];
|
|
39
|
+
let omitted = false;
|
|
40
|
+
const add = (field, value, depth = 0) => {
|
|
41
|
+
if (targets.length >= 4) {
|
|
42
|
+
omitted = true;
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (depth > 4) {
|
|
46
|
+
targets.push(`${field}=[nested target omitted]`);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (Array.isArray(value)) {
|
|
50
|
+
for (const v of value) {
|
|
51
|
+
add(field, v, depth + 1);
|
|
52
|
+
if (omitted)
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (value && typeof value === 'object') {
|
|
58
|
+
for (const v of Object.values(value)) {
|
|
59
|
+
add(field, v, depth + 1);
|
|
60
|
+
if (omitted)
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (typeof value !== 'string' && typeof value !== 'number')
|
|
66
|
+
return;
|
|
67
|
+
const raw = String(value);
|
|
68
|
+
// The main check already loaded the fingerprint stores. Do not reload them per recipient.
|
|
69
|
+
if (sensitivePayload || raw.length > 200 || secretPattern(candidates([raw])) || piiTokens(raw).length) {
|
|
70
|
+
targets.push(`${field}=[redacted]`);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
let preview = raw;
|
|
74
|
+
if (/^https?:\/\//i.test(raw)) {
|
|
75
|
+
try {
|
|
76
|
+
preview = new URL(raw).origin + ' (URL path/query omitted)';
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
preview = '[unrecognized URL]';
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
else if (!/^[#@A-Za-z0-9_.+:/-]{1,100}$/.test(raw)) {
|
|
83
|
+
// Attacker-authored prose, control characters, bidi and markup are not dialog instructions.
|
|
84
|
+
preview = '[target text omitted]';
|
|
85
|
+
}
|
|
86
|
+
targets.push(`${field}=${JSON.stringify(preview)}`);
|
|
87
|
+
};
|
|
88
|
+
for (const [key, value] of Object.entries(input)) {
|
|
89
|
+
if (names.has(key.toLowerCase()))
|
|
90
|
+
add(safeToolName(key), value);
|
|
91
|
+
if (omitted)
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
return targets.length ? targets.join(', ') + (omitted ? '; additional targets omitted — review full call' : '') : 'not identified in destination arguments; review full call';
|
|
95
|
+
}
|
|
96
|
+
function credentialSession(state, stores) {
|
|
97
|
+
return !!(state.credentialRead || state.private.some(s => s.secret) || state.fragments?.index.length || stores.secret.size || stores.tok.size);
|
|
98
|
+
}
|
|
99
|
+
function reservePublicRequest(paths, tool, input, checked, policy, env) {
|
|
100
|
+
const digest = publicRequestDigest(tool, input);
|
|
101
|
+
if (!digest || policy.mode !== 'ask' || !policy.credentialEgress?.allow.some(r => r.tool === tool && r.requestSha256 === digest))
|
|
102
|
+
return;
|
|
103
|
+
let grant;
|
|
104
|
+
mkdirSync(paths.dir, { recursive: true, mode: 0o700 });
|
|
105
|
+
updateFlowState(paths.state, current => {
|
|
106
|
+
const fresh = loadPolicy(env);
|
|
107
|
+
if (fresh.mode !== policy.mode || !fresh.credentialEgress?.allow.some(r => r.tool === tool && r.requestSha256 === digest))
|
|
108
|
+
return;
|
|
109
|
+
if (current.full || current.credentialGrants?.includes(digest) || (current.credentialGrants?.length ?? 0) >= 32)
|
|
110
|
+
return;
|
|
111
|
+
const stores = readStores(paths), leak = checkLeak(paths, checked, current.intent, stores);
|
|
112
|
+
const fragments = checkFragments(checked, current, stores.tok.size > 0, false);
|
|
113
|
+
const stream = checkStream(checked, current.tails ?? {}, stores).leak;
|
|
114
|
+
if (stream.secret || stream.private || stream.pii || leak.secret || leak.private || leak.pii || leak.incomplete || fragments.secret || fragments.review || fragments.unavailable)
|
|
115
|
+
return;
|
|
116
|
+
current.credentialGrants = [...(current.credentialGrants ?? []), digest];
|
|
117
|
+
current.credentialRead = true;
|
|
118
|
+
grant = digest;
|
|
119
|
+
});
|
|
120
|
+
return grant;
|
|
121
|
+
}
|
|
122
|
+
export function flowPre(input, env = process.env, presentation = 'hook', detail) {
|
|
123
|
+
const policy = loadPolicy(env);
|
|
124
|
+
if (policy.mode === 'off')
|
|
125
|
+
return undefined;
|
|
126
|
+
const toolName = typeof input.tool_name === 'string' ? input.tool_name : '';
|
|
127
|
+
const sessionId = typeof input.session_id === 'string' ? input.session_id : '';
|
|
128
|
+
const cwd = typeof input.cwd === 'string' && input.cwd ? input.cwd : process.cwd();
|
|
129
|
+
const toolInput = input.tool_input && typeof input.tool_input === 'object' && !Array.isArray(input.tool_input) ? input.tool_input : {};
|
|
130
|
+
if (!toolName || !sessionId)
|
|
131
|
+
return undefined;
|
|
132
|
+
if (input.tool_input !== undefined && (!input.tool_input || typeof input.tool_input !== 'object' || Array.isArray(input.tool_input)))
|
|
133
|
+
return { permissionDecision: 'deny', permissionDecisionReason: 'WARDEN flow guard: tool arguments must be a JSON object; malformed input cannot receive a public-request grant.' };
|
|
134
|
+
const dir = stateDir(env);
|
|
135
|
+
const paths = flowPaths(dir, sessionId);
|
|
136
|
+
const publicEligible = input.public_request_eligible !== false && (input.tool_input === undefined || !!input.tool_input && typeof input.tool_input === 'object' && !Array.isArray(input.tool_input));
|
|
137
|
+
if (publicEligible && presentation !== 'audit' && taskDelegation(toolName, toolInput, policy, env)) {
|
|
138
|
+
log(paths, { event: 'pre', tool: safeToolName(toolName), decision: 'allow', reason: 'task_adapter_delegated',
|
|
139
|
+
disposition: 'policy_allowed', approvalOutcome: 'unknown', mode: policy.mode });
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
const c = classifyCall(toolName, toolInput, cwd, policy, env);
|
|
143
|
+
const state = readFlowState(paths.state);
|
|
144
|
+
// A click is what it lands on. This only adds caution, so it applies to operator-labelled tools too: a glob that
|
|
145
|
+
// labels a screen server's screenshots must not switch off the check on its clicks.
|
|
146
|
+
const screen = screenCall(toolName, toolInput, state);
|
|
147
|
+
if (screen?.verb)
|
|
148
|
+
c.labels.add('outbound');
|
|
149
|
+
if (screen?.destroy)
|
|
150
|
+
c.labels.add('destructive');
|
|
151
|
+
const defTaint = definitionTaint(dir, sessionId);
|
|
152
|
+
const tainted = state.untrusted.length > 0 || !!defTaint;
|
|
153
|
+
let decision;
|
|
154
|
+
let reason;
|
|
155
|
+
let sensitivePayload = false;
|
|
156
|
+
let publicGrant;
|
|
157
|
+
if (c.protectedTouch) {
|
|
158
|
+
reason = 'protected_settings';
|
|
159
|
+
sensitivePayload = true;
|
|
160
|
+
decision = { permissionDecision: tainted ? 'deny' : 'ask', permissionDecisionReason: `WARDEN flow guard: this call would change WARDEN's policy or Claude Code's hook/MCP settings${tainted ? ` after the session read outside text (${sourcesText(state, defTaint)})` : ''}.` };
|
|
161
|
+
}
|
|
162
|
+
else if (tainted && (c.labels.has('outbound') || toolName.startsWith('mcp__') || ['WebFetch', 'WebSearch'].includes(toolName))) {
|
|
163
|
+
const stores = readStores(paths);
|
|
164
|
+
const leak = checkLeak(paths, toolInput, state.intent, stores);
|
|
165
|
+
const stream = checkStream(toolInput, state.tails ?? {}, stores);
|
|
166
|
+
let credential = credentialSession(state, stores);
|
|
167
|
+
leak.secret ||= stream.leak.secret;
|
|
168
|
+
leak.private ||= stream.leak.private;
|
|
169
|
+
leak.pii ||= stream.leak.pii;
|
|
170
|
+
// Conservative observations include refused calls, and survive prompts/server changes. Re-evaluate under
|
|
171
|
+
// the shared lock so simultaneous proxies cannot both send the two halves of the same credential.
|
|
172
|
+
let fragments = {};
|
|
173
|
+
if (policy.mode === 'audit' || presentation === 'audit') {
|
|
174
|
+
fragments = checkFragments(toolInput, state, stores.tok.size > 0, false);
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
mkdirSync(paths.dir, { recursive: true, mode: 0o700 });
|
|
178
|
+
updateFlowState(paths.state, current => {
|
|
179
|
+
const currentStores = readStores(paths);
|
|
180
|
+
credential ||= credentialSession(current, currentStores);
|
|
181
|
+
if (credential)
|
|
182
|
+
current.credentialRead = true;
|
|
183
|
+
const currentLeak = checkLeak(paths, toolInput, current.intent, currentStores);
|
|
184
|
+
const currentStream = checkStream(toolInput, current.tails ?? {}, currentStores);
|
|
185
|
+
leak.secret ||= currentLeak.secret || currentStream.leak.secret;
|
|
186
|
+
leak.private ||= currentLeak.private || currentStream.leak.private;
|
|
187
|
+
leak.pii ||= currentLeak.pii || currentStream.leak.pii;
|
|
188
|
+
leak.incomplete ||= currentLeak.incomplete;
|
|
189
|
+
fragments = checkFragments(toolInput, current, currentStores.tok.size > 0, true);
|
|
190
|
+
current.tails = Object.fromEntries(Object.entries({ ...(current.tails ?? {}), ...currentStream.next }).slice(-MAX_TAIL_KEYS));
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
leak.secret ||= fragments.secret;
|
|
194
|
+
sensitivePayload = !!(leak.secret || leak.private || leak.pii || leak.incomplete || credential || fragments.review || fragments.unavailable);
|
|
195
|
+
const dests = destinations(toolName, toolInput, policy, c.hosts);
|
|
196
|
+
const targetKnown = hasDestinationArgument(toolName, toolInput, policy);
|
|
197
|
+
const allowedHosts = targetKnown && dests.length > 0 && dests.every(d => allowedDestination(d, policy.allowedDestinations));
|
|
198
|
+
const contacts = state.resolvedVersion === 2 ? state.resolved : undefined;
|
|
199
|
+
const userNamed = (targetKnown && namedByUser(dests, state.intent, state.intentRaw, contacts)) || (dests.length === 0 && keepsDestination(toolName, toolInput, policy));
|
|
200
|
+
if (c.opaqueShell && c.secret)
|
|
201
|
+
leak.secret = 'credential file referenced by shell code';
|
|
202
|
+
if (!leak.secret && fragments.review) {
|
|
203
|
+
reason = 'secret_fragment';
|
|
204
|
+
decision = { permissionDecision: policy.mode === 'deny' ? 'deny' : 'ask', permissionDecisionReason: 'WARDEN flow guard: an argument matches a short fragment of a credential read this session. A short match can be coincidental, so review it before transmission. Named or allowed destinations and earlier approvals do not exempt this call.' };
|
|
205
|
+
}
|
|
206
|
+
else if (!leak.secret && screen?.paste) {
|
|
207
|
+
reason = 'paste_after_secret';
|
|
208
|
+
sensitivePayload = true;
|
|
209
|
+
decision = { permissionDecision: policy.mode === 'deny' ? 'deny' : 'ask', permissionDecisionReason: `WARDEN flow guard: this call pastes the clipboard after the session read a credential (${screen.paste.src.slice(0, 80)}) and outside text (${sourcesText(state, defTaint)}). What a paste carries is not in its arguments, so the guard cannot check it. Allow it only if you know what the clipboard holds.` };
|
|
210
|
+
}
|
|
211
|
+
else if (leak.secret) {
|
|
212
|
+
reason = 'secret';
|
|
213
|
+
decision = { permissionDecision: 'deny', permissionDecisionReason: `WARDEN flow guard: this call carries a secret (${leak.secret}) after the session read outside text (${sourcesText(state, defTaint)}). Denied.` };
|
|
214
|
+
}
|
|
215
|
+
else if (leak.incomplete) {
|
|
216
|
+
reason = 'payload_incomplete';
|
|
217
|
+
decision = { permissionDecision: policy.mode === 'deny' ? 'deny' : 'ask', permissionDecisionReason: 'WARDEN flow guard: the bounded payload decoder could not finish checking this request. Review it before execution; no automatic public-request grant applies.' };
|
|
218
|
+
}
|
|
219
|
+
else if (fragments.unavailable) {
|
|
220
|
+
reason = 'secret_tracking';
|
|
221
|
+
decision = { permissionDecision: policy.mode === 'deny' ? 'deny' : 'ask', permissionDecisionReason: 'WARDEN flow guard: short-secret tracking is incomplete or at capacity. This call after outside text requires review even for a named or allowed destination. Start a new session and reread private sources to restore tracking.' };
|
|
222
|
+
}
|
|
223
|
+
else if (c.opaqueShell) {
|
|
224
|
+
reason = 'opaque_shell';
|
|
225
|
+
decision = { permissionDecision: policy.mode === 'deny' ? 'deny' : 'ask', permissionDecisionReason: 'WARDEN flow guard: after outside text, shell code outside the literal read-only subset requires approval. Named or allowed hosts do not authorize shell programs, hooks, redirections or file uploads.' };
|
|
226
|
+
}
|
|
227
|
+
else if (leak.pii) {
|
|
228
|
+
reason = 'personal_data';
|
|
229
|
+
// Card, passport, account or phone numbers read this session: even to a destination the user named,
|
|
230
|
+
// unless the user typed the value. Which of the user's numbers to send is what an injection would pick.
|
|
231
|
+
decision = { permissionDecision: policy.mode === 'deny' ? 'deny' : 'ask', permissionDecisionReason: `WARDEN flow guard: an argument matches a personal-data fingerprint recorded during this session, after outside text (${sourcesText(state, defTaint)}). The matching source was not retained. Check what is being sent.` };
|
|
232
|
+
}
|
|
233
|
+
else if (leak.private && !userNamed) {
|
|
234
|
+
reason = 'private_text';
|
|
235
|
+
decision = { permissionDecision: policy.mode === 'deny' ? 'deny' : 'ask', permissionDecisionReason: `WARDEN flow guard: an argument matches a private-text fingerprint recorded during this session, after outside text (${sourcesText(state, defTaint)}), and the destination is not confirmed as user-named. The matching source was not retained. Check what is being sent.` };
|
|
236
|
+
}
|
|
237
|
+
else if (credential && !screen?.guarded) {
|
|
238
|
+
if (publicEligible && presentation !== 'audit' && !c.labels.has('destructive') && !guiOperation(toolName, toolInput))
|
|
239
|
+
publicGrant = reservePublicRequest(paths, toolName, toolInput, toolInput, policy, env);
|
|
240
|
+
if (!publicGrant) {
|
|
241
|
+
reason = 'credential_egress';
|
|
242
|
+
decision = { permissionDecision: policy.mode === 'deny' ? 'deny' : 'ask', permissionDecisionReason: 'WARDEN flow guard: this session holds credentials and outside text. Review this outbound request before execution, regardless of fragment length, encoding or destination. This is a credential boundary, not a claim that its contents are a detected secret.' };
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
else if (screen?.guarded) {
|
|
246
|
+
reason = 'screen_action';
|
|
247
|
+
const actions = { pointer: 'a pointer action', paste: 'a clipboard paste', enter: 'Enter/Return', text: 'text input', keyboard: 'a keyboard action', control: 'an unresolved GUI control action' };
|
|
248
|
+
const policyNote = policy.gui?.invalid ? ' Invalid GUI policy: strict behavior is active.' : policy.gui?.mode === 'policy' ? ' Legacy GUI allow rules cannot authorize actions from untrusted screen metadata. Use the built-in task adapter for constrained operations.' : '';
|
|
249
|
+
decision = { permissionDecision: policy.mode === 'deny' ? 'deny' : 'ask', permissionDecisionReason: `WARDEN flow guard: ${actions[screen.guarded]} follows outside text (${sourcesText(state, defTaint)}). Screen labels, coordinates, images and key arguments do not establish what this action will send or change. Check the actual window, target and effect before allowing it. A named or allowed destination does not authorize GUI actions.${policyNote}` };
|
|
250
|
+
}
|
|
251
|
+
else if (c.labels.has('outbound') && !allowedHosts && !userNamed) {
|
|
252
|
+
reason = 'unnamed_destination';
|
|
253
|
+
// Not only leaks: an outbound call acts with the user's authority (a message, a payment, an invite),
|
|
254
|
+
// which an injection wants as much as their data. So it does not wait for a private read.
|
|
255
|
+
const what = state.private.length > 0 || state.full ? 'and private data; this call can send data out or act for you' : '; this call can act for you or send data out';
|
|
256
|
+
decision = { permissionDecision: policy.mode === 'deny' ? 'deny' : 'ask', permissionDecisionReason: `WARDEN flow guard: the session read outside text (${sourcesText(state, defTaint)}) ${what}. A destination is missing or not confirmed by your recorded messages or destination policy; addresses and links in content also count. Its origin is not tracked. Allow only if this action and destination match your request.`.replace(') ;', ');') };
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// A file the agent writes inside the project can leave later without another call: CI publishes docs/, a commit
|
|
260
|
+
// is pushed by a person. After outside text, a key read this session written into an ordinary project file asks;
|
|
261
|
+
// credential files (.env, .npmrc …) remain exempt. Ordinary writes need review after a credential read,
|
|
262
|
+
if (!decision && tainted && ['Write', 'Edit', 'MultiEdit', 'NotebookEdit'].includes(toolName)) {
|
|
263
|
+
const target = typeof toolInput.file_path === 'string' ? toolInput.file_path : typeof toolInput.notebook_path === 'string' ? toolInput.notebook_path : '';
|
|
264
|
+
if (!target || !isSecretPath(absPath(target, cwd))) {
|
|
265
|
+
const written = { content: toolInput.content, new_string: toolInput.new_string, new_source: toolInput.new_source,
|
|
266
|
+
edits: Array.isArray(toolInput.edits) ? toolInput.edits.map(e => (e && typeof e === 'object' ? e.new_string : undefined)) : undefined };
|
|
267
|
+
const leak = checkLeak(paths, written);
|
|
268
|
+
let fragments = {};
|
|
269
|
+
if (policy.mode === 'audit' || presentation === 'audit')
|
|
270
|
+
fragments = checkFragments(written, state, readFp(paths.tok).size > 0, false);
|
|
271
|
+
else {
|
|
272
|
+
mkdirSync(paths.dir, { recursive: true, mode: 0o700 });
|
|
273
|
+
updateFlowState(paths.state, current => { fragments = checkFragments(written, current, readFp(paths.tok).size > 0, true); });
|
|
274
|
+
}
|
|
275
|
+
leak.secret ||= fragments.secret;
|
|
276
|
+
const credential = credentialSession(readFlowState(paths.state), readStores(paths));
|
|
277
|
+
if (leak.secret || fragments.review || fragments.unavailable || leak.incomplete) {
|
|
278
|
+
reason = 'secret_to_file';
|
|
279
|
+
sensitivePayload = true;
|
|
280
|
+
decision = { permissionDecision: policy.mode === 'deny' ? 'deny' : 'ask', permissionDecisionReason: leak.secret ? `WARDEN flow guard: this write puts a secret (${leak.secret}) into a project file after the session read outside text (${sourcesText(state, defTaint)}). Files in the project can be committed or published later. Allow it only if you asked for this key to go into this file.` : fragments.review ? 'WARDEN flow guard: this project-file write matches a short credential fragment. Review it before writing; project files can be published later.' : 'WARDEN flow guard: short-secret tracking is incomplete or at capacity. Review this project-file write after outside text; project files can be published later. Start a new session and reread private sources to restore tracking.' };
|
|
281
|
+
}
|
|
282
|
+
else if (credential) {
|
|
283
|
+
if (publicEligible && presentation !== 'audit' && !c.labels.has('destructive'))
|
|
284
|
+
publicGrant = reservePublicRequest(paths, toolName, toolInput, written, policy, env);
|
|
285
|
+
if (!publicGrant) {
|
|
286
|
+
reason = 'credential_egress';
|
|
287
|
+
sensitivePayload = true;
|
|
288
|
+
decision = { permissionDecision: policy.mode === 'deny' ? 'deny' : 'ask', permissionDecisionReason: 'WARDEN flow guard: review this project-file write after credentials and outside text. Short or encoded data can leave later through published files; an exact reviewed public request is required for automatic execution.' };
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (!decision && tainted && c.labels.has('destructive') && !namedByUser(destinations(toolName, toolInput, policy, c.hosts), state.intent, state.intentRaw, state.resolvedVersion === 2 ? state.resolved : undefined) && !(destinations(toolName, toolInput, policy, c.hosts).length === 0 && keepsDestination(toolName, toolInput, policy))) {
|
|
294
|
+
reason = 'destructive_action';
|
|
295
|
+
decision = { permissionDecision: policy.mode === 'deny' ? 'deny' : 'ask', permissionDecisionReason: `WARDEN flow guard: the session read outside text (${sourcesText(state, defTaint)}); this call changes or deletes things outside the project. Allow it only if you expected it.` };
|
|
296
|
+
}
|
|
297
|
+
if (decision) {
|
|
298
|
+
let targets = targetSummary(toolName, toolInput, policy, sensitivePayload);
|
|
299
|
+
// The screen label is outside text: the prompt names only the guard's own word for it, never the label.
|
|
300
|
+
const word = screen?.verb ?? screen?.destroy;
|
|
301
|
+
if (word || screen?.via) {
|
|
302
|
+
const ref = screen?.ref && /^[A-Za-z0-9_.:-]{1,24}$/.test(screen.ref) ? ` ${JSON.stringify(screen.ref)}` : '';
|
|
303
|
+
const evidence = screen?.evidence === 'form' ? ` in a form with a "${word}" control; Enter may submit it`
|
|
304
|
+
: screen?.evidence === 'identifier' ? ` whose accessibility identifier suggests "${word}"`
|
|
305
|
+
: screen?.evidence === 'semantic' ? ` reported as a "${word}" control`
|
|
306
|
+
: word ? ` whose label says "${word}"` : '';
|
|
307
|
+
const via = screen?.via === 'coordinates' ? ' (coordinates intersect its reported geometry)'
|
|
308
|
+
: screen?.via === 'focus' ? ' (reported focused element)' : '';
|
|
309
|
+
const note = `screen element${ref}${evidence}${via}; last-snapshot context is untrusted, not a verified live target`;
|
|
310
|
+
targets = targets.startsWith('not identified') ? note : `${note}; ${targets}`;
|
|
311
|
+
}
|
|
312
|
+
decision.permissionDecisionReason = `Action: ${safeToolName(toolName)}. Targets: ${targets}.\n${decision.permissionDecisionReason}`;
|
|
313
|
+
if (detail) {
|
|
314
|
+
detail.reason = reason;
|
|
315
|
+
detail.sensitive = sensitivePayload;
|
|
316
|
+
}
|
|
317
|
+
// These are guard decisions, not proof a dialog was shown or a human clicked it. Never infer
|
|
318
|
+
// approval from PostToolUse: execution can also follow automatic client permissions.
|
|
319
|
+
const disposition = policy.mode === 'audit' || presentation === 'audit' ? 'audit_only'
|
|
320
|
+
: decision.permissionDecision === 'deny' || presentation === 'refuse' ? 'refused' : 'approval_requested';
|
|
321
|
+
log(paths, { event: 'pre', tool: safeToolName(toolName), decision: decision.permissionDecision, reason, disposition,
|
|
322
|
+
approvalOutcome: 'unknown', mode: policy.mode, labels: [...c.labels] });
|
|
323
|
+
}
|
|
324
|
+
if (publicGrant)
|
|
325
|
+
log(paths, { event: 'pre', tool: safeToolName(toolName), decision: 'allow', reason: 'credential_public_request', disposition: 'policy_allowed', approvalOutcome: 'unknown', mode: policy.mode });
|
|
326
|
+
if (policy.mode === 'audit')
|
|
327
|
+
return undefined;
|
|
328
|
+
return decision;
|
|
329
|
+
}
|
|
330
|
+
/** What the person answered when `wrap` asked through the client (presentation `elicit`): the one case where the
|
|
331
|
+
* guard knows the outcome rather than inferring it. */
|
|
332
|
+
export function flowApproval(input, outcome, env = process.env) {
|
|
333
|
+
if (!input.session_id || !input.tool_name)
|
|
334
|
+
return;
|
|
335
|
+
log(flowPaths(stateDir(env), input.session_id), { event: 'approval', tool: safeToolName(input.tool_name), approvalOutcome: outcome });
|
|
336
|
+
}
|
|
337
|
+
export function flowScreenValidation(input, outcome, env = process.env) {
|
|
338
|
+
log(flowPaths(stateDir(env), input.session_id), { event: 'screen_validation', tool: safeToolName(input.tool_name), outcome });
|
|
339
|
+
}
|