dsh-dlp 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 +324 -0
- package/SECURITY.md +47 -0
- package/cordis.patch.yml +22 -0
- package/lib/detectors.js +148 -0
- package/lib/guard.js +133 -0
- package/lib/index.js +212 -0
- package/lib/paths.js +227 -0
- package/lib/policy.js +274 -0
- package/lib/redaction.js +216 -0
- package/lib/results.js +238 -0
- package/lib/sink.js +105 -0
- package/lib/telemetry.js +80 -0
- package/lib/types/detectors.d.ts +102 -0
- package/lib/types/guard.d.ts +68 -0
- package/lib/types/index.d.ts +47 -0
- package/lib/types/paths.d.ts +123 -0
- package/lib/types/policy.d.ts +128 -0
- package/lib/types/redaction.d.ts +113 -0
- package/lib/types/results.d.ts +71 -0
- package/lib/types/sink.d.ts +118 -0
- package/lib/types/telemetry.d.ts +51 -0
- package/package.json +84 -0
package/lib/guard.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The guard floor: the one part of this plugin that holds under attack.
|
|
3
|
+
*
|
|
4
|
+
* It tests the path-typed arguments of a call — never file content — against
|
|
5
|
+
* the credential table, resolving symlinks first, and the whole argument set
|
|
6
|
+
* against the tier-1 detectors when the tool can move data off the machine.
|
|
7
|
+
* The `command` arm inside that is advisory pattern-matching: tokenising a
|
|
8
|
+
* shell command line catches `cat ~/.ssh/id_rsa` and loses to one glob
|
|
9
|
+
* character. README.md says so plainly and so does this comment.
|
|
10
|
+
*
|
|
11
|
+
* `ctx.tools.guard()` takes a synchronous `(exec) => string | undefined`. It
|
|
12
|
+
* has no allow arm, so no registration order can turn a denial back into
|
|
13
|
+
* permission, and the first denial wins. It runs after the whole
|
|
14
|
+
* `tools/pre-execute` waterfall and after `ctx.approval`, which is why the
|
|
15
|
+
* floor lives here rather than in a listener: `exec.arguments` is deep-frozen
|
|
16
|
+
* but `exec` itself is not frozen until after execution, so a pre-execute
|
|
17
|
+
* listener can reassign `exec.arguments` or `exec.name` and succeed. The guard
|
|
18
|
+
* reads what every listener finally left behind.
|
|
19
|
+
*
|
|
20
|
+
* Registered on a plain context, never through `agent.ctx`: a global guard
|
|
21
|
+
* covers every agent, every `run_code` inner sub-call, and every subagent
|
|
22
|
+
* child, while an agent-scoped listener never sees a subagent child's calls
|
|
23
|
+
* because a child agent is a sibling, not a descendant.
|
|
24
|
+
*
|
|
25
|
+
* The floor is not configurable. Per CONVENTIONS, security invariants stay
|
|
26
|
+
* fixed; the repo-local policy tier may only add to its tables.
|
|
27
|
+
* @module dsh-dlp/guard
|
|
28
|
+
*/
|
|
29
|
+
import { DENY_SEVERITY, scanSync, severityRank } from "./detectors.js";
|
|
30
|
+
import { isEgressCapable, matchPathArgument, pathArguments, pathCandidates } from "./paths.js";
|
|
31
|
+
import { nestedStrings } from "./redaction.js";
|
|
32
|
+
/**
|
|
33
|
+
* Denial text for a credential-path match.
|
|
34
|
+
*
|
|
35
|
+
* The matched path is named by rule id and keyed hash, never quoted. A path is
|
|
36
|
+
* itself sensitive — a tenant name, a customer directory, a shell command that
|
|
37
|
+
* happens to end in `.pem` — and this string is both model-visible and written
|
|
38
|
+
* to the audit sink.
|
|
39
|
+
*/
|
|
40
|
+
function credentialPathReason(toolName, ruleId, hash) {
|
|
41
|
+
return `dsh-dlp denied ${JSON.stringify(toolName)}: one of its path arguments is credential material `
|
|
42
|
+
+ `(rule ${ruleId}, keyed hash ${hash}). Reading or passing credential files through a tool is blocked by `
|
|
43
|
+
+ 'policy and cannot be overridden. Ask the user to supply the value you need, or use a path that is not a '
|
|
44
|
+
+ 'credential store.';
|
|
45
|
+
}
|
|
46
|
+
/** Denial text for secrets found in arguments heading to an egress-capable tool. */
|
|
47
|
+
function secretArgumentReason(toolName, ruleIds, hashes) {
|
|
48
|
+
return `dsh-dlp denied ${JSON.stringify(toolName)}: its arguments contain credential material matching `
|
|
49
|
+
+ `${ruleIds.join(', ')} (${hashes.length} finding(s), keyed hash ${hashes.join(', ')}). `
|
|
50
|
+
+ `${JSON.stringify(toolName)} can send data off this machine, so secrets in its arguments are blocked by policy `
|
|
51
|
+
+ 'and cannot be overridden. Remove the credential from the call — reference an environment variable the tool '
|
|
52
|
+
+ 'already has, or ask the user to run the command themselves.';
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Decide whether the floor denies one call.
|
|
56
|
+
*
|
|
57
|
+
* Credential paths are denied for every tool, not only readers: a shell that
|
|
58
|
+
* can `cat` a key can also copy it. Only path-typed arguments are tested —
|
|
59
|
+
* running the table over every string matches file content and denies writing
|
|
60
|
+
* a `.gitignore` that mentions `.env`. Argument secrets are denied only for
|
|
61
|
+
* egress-capable tools, because denying a local editor for holding the text it
|
|
62
|
+
* was asked to write would break ordinary work without closing an exfiltration
|
|
63
|
+
* path.
|
|
64
|
+
* @param exec - the pending call as the guard stage sees it.
|
|
65
|
+
* @param policy - the effective policy after the tighten-only merge.
|
|
66
|
+
* @param hasher - mints the keyed hashes quoted in a denial reason.
|
|
67
|
+
* @returns the denial, or `undefined` to abstain.
|
|
68
|
+
*/
|
|
69
|
+
export function evaluateGuard(exec, policy, hasher) {
|
|
70
|
+
for (const argument of pathArguments(exec.arguments)) {
|
|
71
|
+
const candidates = argument.shell ? pathCandidates(argument.text) : [argument.text];
|
|
72
|
+
for (const candidate of candidates) {
|
|
73
|
+
const rule = matchPathArgument(candidate, policy.credentialPathRules);
|
|
74
|
+
if (rule === undefined)
|
|
75
|
+
continue;
|
|
76
|
+
const hash = hasher.hash(candidate);
|
|
77
|
+
return {
|
|
78
|
+
kind: 'credential-path',
|
|
79
|
+
reason: credentialPathReason(exec.name, rule.id, hash),
|
|
80
|
+
spans: [{
|
|
81
|
+
ruleId: rule.id,
|
|
82
|
+
ruleVersion: rule.version,
|
|
83
|
+
severity: 'critical',
|
|
84
|
+
start: 0,
|
|
85
|
+
end: candidate.length,
|
|
86
|
+
hash,
|
|
87
|
+
}],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (!isEgressCapable(exec.name, policy.extraEgressTools))
|
|
92
|
+
return undefined;
|
|
93
|
+
const spans = [];
|
|
94
|
+
for (const text of nestedStrings(exec.arguments)) {
|
|
95
|
+
for (const detection of scanSync(text, policy.syncRules).detections) {
|
|
96
|
+
if (severityRank(detection.severity) < severityRank(DENY_SEVERITY))
|
|
97
|
+
continue;
|
|
98
|
+
spans.push({ ...detection, hash: hasher.hash(text.slice(detection.start, detection.end)) });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (spans.length === 0)
|
|
102
|
+
return undefined;
|
|
103
|
+
const ruleIds = [...new Set(spans.map(span => span.ruleId))];
|
|
104
|
+
return {
|
|
105
|
+
kind: 'secret-argument',
|
|
106
|
+
reason: secretArgumentReason(exec.name, ruleIds, spans.map(span => span.hash)),
|
|
107
|
+
spans,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Wrap {@link evaluateGuard} so an internal fault becomes a denial.
|
|
112
|
+
*
|
|
113
|
+
* A guard that throws fails the call closed *and* skips `tools/post-execute`,
|
|
114
|
+
* which would silently disable result redaction for that call. Converting the
|
|
115
|
+
* fault into a denial string keeps the post-execute stage running.
|
|
116
|
+
* @param exec - the pending call.
|
|
117
|
+
* @param policy - the effective policy.
|
|
118
|
+
* @param hasher - mints the keyed hashes quoted in a denial reason.
|
|
119
|
+
* @returns the denial, or `undefined` to abstain.
|
|
120
|
+
*/
|
|
121
|
+
export function safeEvaluateGuard(exec, policy, hasher) {
|
|
122
|
+
try {
|
|
123
|
+
return evaluateGuard(exec, policy, hasher);
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
return {
|
|
127
|
+
kind: 'internal-fault',
|
|
128
|
+
reason: `dsh-dlp denied ${JSON.stringify(exec.name)}: the data-loss-prevention floor failed to evaluate `
|
|
129
|
+
+ `this call (${String(error)}) and denies by default. Report this to the deployment operator.`,
|
|
130
|
+
spans: [],
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `dsh-dlp` — data-loss prevention for DeepSeek Harness.
|
|
3
|
+
*
|
|
4
|
+
* Four registrations, in descending order of how much they can be trusted:
|
|
5
|
+
*
|
|
6
|
+
* 1. `ctx.tools.guard()` — an unconditional, non-configurable deny floor for
|
|
7
|
+
* credential paths named in a path-typed argument and for secrets heading
|
|
8
|
+
* into an egress-capable tool. Order-independent, because the guard seam
|
|
9
|
+
* has no allow arm.
|
|
10
|
+
* 2. `tools/pre-execute` — the async breadth tier, which can await
|
|
11
|
+
* `@secretlint/core`. Neutralizable by any listener registered ahead of it.
|
|
12
|
+
* 3. `tools/post-execute` — result redaction, applied before the `tool/result`
|
|
13
|
+
* session event is appended, so the durable log records the redacted copy;
|
|
14
|
+
* a result that cannot be cleaned is withheld rather than accepted.
|
|
15
|
+
* 4. `session-telemetry/record` — fail-closed redaction of exported telemetry,
|
|
16
|
+
* reaching tier 1 only because the waterfall is synchronous.
|
|
17
|
+
*
|
|
18
|
+
* This plugin is not a containment boundary. It runs in-process at the agent's
|
|
19
|
+
* own uid; anything the agent can execute can read the same files the guard
|
|
20
|
+
* denies. See README.md.
|
|
21
|
+
* @module dsh-dlp
|
|
22
|
+
*/
|
|
23
|
+
import { randomBytes } from 'node:crypto';
|
|
24
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import { loadRepoPolicy, resolvePolicy } from "./policy.js";
|
|
26
|
+
import { SpanHasher } from "./redaction.js";
|
|
27
|
+
import { safeEvaluateGuard } from "./guard.js";
|
|
28
|
+
import { breadthTierDenial, evaluateBreadthTier, redactDecision } from "./results.js";
|
|
29
|
+
import { redactRecord } from "./telemetry.js";
|
|
30
|
+
import { AuditSink, CallCorrelator, newDecisionId, RECORD_VERSION } from "./sink.js";
|
|
31
|
+
export { Config } from "./policy.js";
|
|
32
|
+
/** Display metadata; labels the plugin in Cordis diagnostics. */
|
|
33
|
+
export const name = 'dsh-dlp';
|
|
34
|
+
/** Services required before `apply` runs. */
|
|
35
|
+
export const inject = ['tools'];
|
|
36
|
+
/** Bytes generated for a new installation redaction key. */
|
|
37
|
+
const KEY_BYTES = 32;
|
|
38
|
+
/**
|
|
39
|
+
* Read the installation's redaction key, creating it on first mount.
|
|
40
|
+
*
|
|
41
|
+
* The key makes every placeholder a keyed hash rather than a bare digest:
|
|
42
|
+
* without it, anyone holding a candidate secret could confirm it against the
|
|
43
|
+
* audit log.
|
|
44
|
+
* @param path - the key file named by `redactionKeyFile`.
|
|
45
|
+
* @returns the key bytes.
|
|
46
|
+
* @throws when an existing key file is too short to be a key.
|
|
47
|
+
*/
|
|
48
|
+
export function loadOrCreateKey(path) {
|
|
49
|
+
let existing;
|
|
50
|
+
try {
|
|
51
|
+
existing = readFileSync(path);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
// Only absence means "first mount". A permission or I/O failure on an
|
|
55
|
+
// existing key must not mint a new one: every placeholder and every audit
|
|
56
|
+
// hash would change, silently breaking correlation with the whole history.
|
|
57
|
+
if (error.code !== 'ENOENT') {
|
|
58
|
+
throw new Error(`dsh-dlp: cannot read the redaction key at ${path}: ${String(error)}`);
|
|
59
|
+
}
|
|
60
|
+
existing = undefined;
|
|
61
|
+
}
|
|
62
|
+
if (existing !== undefined) {
|
|
63
|
+
if (existing.length < 16) {
|
|
64
|
+
throw new Error(`dsh-dlp: redaction key at ${path} is ${existing.length} bytes; at least 16 are required`);
|
|
65
|
+
}
|
|
66
|
+
return existing;
|
|
67
|
+
}
|
|
68
|
+
const created = randomBytes(KEY_BYTES);
|
|
69
|
+
writeFileSync(path, created, { mode: 0o600 });
|
|
70
|
+
return created;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Load the repo-local policy tier, if the deployment named one.
|
|
74
|
+
*
|
|
75
|
+
* A missing file is no policy at all, and a malformed one is reported and
|
|
76
|
+
* ignored. The floor never depends on a workspace file being present or
|
|
77
|
+
* well-formed: the recommended `policyFile` is workspace-relative, so failing
|
|
78
|
+
* the mount would refuse to start `dsh` in every repository without one, and
|
|
79
|
+
* would let a hostile repository disable the plugin by shipping a broken file.
|
|
80
|
+
* @param ctx - the plugin's context, used only for its logger.
|
|
81
|
+
* @param policyFile - the configured path, or `undefined` when the deployment named none.
|
|
82
|
+
* @returns the validated policy, or `undefined` when there is none to apply.
|
|
83
|
+
*/
|
|
84
|
+
function loadConfiguredPolicy(ctx, policyFile) {
|
|
85
|
+
if (policyFile === undefined)
|
|
86
|
+
return undefined;
|
|
87
|
+
const load = loadRepoPolicy(policyFile);
|
|
88
|
+
switch (load.kind) {
|
|
89
|
+
case 'absent':
|
|
90
|
+
return undefined;
|
|
91
|
+
case 'loaded':
|
|
92
|
+
return load.policy;
|
|
93
|
+
case 'invalid':
|
|
94
|
+
ctx.logger.error(`dsh-dlp: ignoring the repo-local policy at ${policyFile}: ${load.problem}`);
|
|
95
|
+
return undefined;
|
|
96
|
+
/* v8 ignore next 4 -- unreachable while `RepoPolicyLoad` stays closed; the arm exists so adding a variant fails the build. */
|
|
97
|
+
default: {
|
|
98
|
+
const unhandled = load;
|
|
99
|
+
throw new TypeError(`dsh-dlp: unhandled repo policy load ${JSON.stringify(unhandled)}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Mount the plugin.
|
|
105
|
+
* @param ctx - the plugin's context; every registration is undone on unload.
|
|
106
|
+
* @param config - validated `cordis.yml` configuration.
|
|
107
|
+
*/
|
|
108
|
+
export function apply(ctx, config) {
|
|
109
|
+
const policy = resolvePolicy(config, loadConfiguredPolicy(ctx, config.policyFile));
|
|
110
|
+
const hasher = new SpanHasher(loadOrCreateKey(config.redactionKeyFile));
|
|
111
|
+
const correlator = new CallCorrelator();
|
|
112
|
+
const sink = new AuditSink(config.auditLog, (error) => {
|
|
113
|
+
ctx.logger.error(`dsh-dlp: audit sink write failed: ${String(error)}`);
|
|
114
|
+
});
|
|
115
|
+
/** Identity every audit record carries; the session-log envelope carries none of it. */
|
|
116
|
+
const identity = (exec) => {
|
|
117
|
+
const position = correlator.lookup(exec.callId);
|
|
118
|
+
return {
|
|
119
|
+
tool: exec.name,
|
|
120
|
+
callId: exec.callId,
|
|
121
|
+
rootCallId: exec.rootCallId,
|
|
122
|
+
...exec.agent === undefined ? {} : { sessionId: String(exec.agent.session.id) },
|
|
123
|
+
...position === undefined ? {} : { turn: position.turn, step: position.step },
|
|
124
|
+
};
|
|
125
|
+
};
|
|
126
|
+
ctx.on('session/event', (_session, event) => {
|
|
127
|
+
if (event.type === 'tool/call') {
|
|
128
|
+
correlator.note(event.data.callId, { turn: event.data.turn, step: event.data.step });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (event.type === 'tool/result') {
|
|
132
|
+
correlator.forget(event.data.message.source.callId);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
// The floor. Registered on a plain context so it applies globally: to every
|
|
136
|
+
// agent, every `run_code` inner sub-call, and every subagent child.
|
|
137
|
+
ctx.effect(() => ctx.tools.guard((exec) => {
|
|
138
|
+
const verdict = safeEvaluateGuard(exec, policy, hasher);
|
|
139
|
+
if (verdict === undefined)
|
|
140
|
+
return undefined;
|
|
141
|
+
sink.write({
|
|
142
|
+
v: RECORD_VERSION,
|
|
143
|
+
time: new Date().toISOString(),
|
|
144
|
+
kind: 'guard-deny',
|
|
145
|
+
decisionId: newDecisionId(),
|
|
146
|
+
...identity(exec),
|
|
147
|
+
spans: verdict.spans,
|
|
148
|
+
});
|
|
149
|
+
return verdict.reason;
|
|
150
|
+
}), 'dsh-dlp guard floor');
|
|
151
|
+
if (policy.breadthTier) {
|
|
152
|
+
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
153
|
+
const decision = await next();
|
|
154
|
+
if (decision.kind !== 'allow')
|
|
155
|
+
return decision;
|
|
156
|
+
const finding = await evaluateBreadthTier(exec, policy, hasher);
|
|
157
|
+
if (finding === undefined)
|
|
158
|
+
return decision;
|
|
159
|
+
sink.write({
|
|
160
|
+
v: RECORD_VERSION,
|
|
161
|
+
time: new Date().toISOString(),
|
|
162
|
+
kind: 'pre-execute-deny',
|
|
163
|
+
decisionId: newDecisionId(),
|
|
164
|
+
...identity(exec),
|
|
165
|
+
spans: finding.spans,
|
|
166
|
+
});
|
|
167
|
+
return breadthTierDenial(finding.reason);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
if (policy.resultRedaction) {
|
|
171
|
+
ctx.on('tools/post-execute', async (exec, result, next) => {
|
|
172
|
+
const redacted = await redactDecision(await next(), result, policy, hasher);
|
|
173
|
+
// A truncated scan is recorded even with nothing found: without a record
|
|
174
|
+
// an operator cannot tell "this result was clean" from "this result was
|
|
175
|
+
// only partly examined".
|
|
176
|
+
if (redacted.spans.length > 0 || redacted.truncatedScan) {
|
|
177
|
+
sink.write({
|
|
178
|
+
v: RECORD_VERSION,
|
|
179
|
+
time: new Date().toISOString(),
|
|
180
|
+
kind: 'result-redaction',
|
|
181
|
+
decisionId: newDecisionId(),
|
|
182
|
+
...identity(exec),
|
|
183
|
+
spans: redacted.spans,
|
|
184
|
+
...redacted.truncatedScan ? { truncatedScan: true } : {},
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
return redacted.decision;
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
if (policy.telemetryRedaction) {
|
|
191
|
+
ctx.on('session-telemetry/record', (_record, next) => {
|
|
192
|
+
// Throwing here withholds this one record; the coordinator contains it
|
|
193
|
+
// and the agent loop never sees the failure. That is the fail-closed
|
|
194
|
+
// behavior this listener wants.
|
|
195
|
+
const redacted = redactRecord(next(), policy, hasher);
|
|
196
|
+
if (redacted.spans.length > 0) {
|
|
197
|
+
sink.write({
|
|
198
|
+
v: RECORD_VERSION,
|
|
199
|
+
time: new Date().toISOString(),
|
|
200
|
+
kind: 'telemetry-redaction',
|
|
201
|
+
decisionId: newDecisionId(),
|
|
202
|
+
channel: redacted.record.channel,
|
|
203
|
+
...typeof redacted.record.attributes['session.id'] === 'string'
|
|
204
|
+
? { sessionId: redacted.record.attributes['session.id'] }
|
|
205
|
+
: {},
|
|
206
|
+
spans: redacted.spans,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
return redacted.record;
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
package/lib/paths.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two classifications the guard floor runs on: which paths are credential
|
|
3
|
+
* material, and which tools can move data off the machine.
|
|
4
|
+
*
|
|
5
|
+
* Both tables are security invariants. Neither is settable from `cordis.yml`;
|
|
6
|
+
* the repo-local policy tier may only add to them.
|
|
7
|
+
* @module dsh-dlp/paths
|
|
8
|
+
*/
|
|
9
|
+
import { realpathSync } from 'node:fs';
|
|
10
|
+
import { posix } from 'node:path';
|
|
11
|
+
/**
|
|
12
|
+
* File extensions that name source code or documentation rather than a
|
|
13
|
+
* credential store, used by {@link CREDENTIAL_PATH_RULES}'s filename
|
|
14
|
+
* heuristic. Without them `src/auth/token.ts` would be undeniable-file
|
|
15
|
+
* material and ordinary work would stop.
|
|
16
|
+
*/
|
|
17
|
+
const CODE_EXTENSIONS = 'ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|rb|php|c|h|hpp|cc|cpp|cs|swift|kt|md|rst|txt|html|css|scss|sh|bash|zsh|sql|lock|snap|map';
|
|
18
|
+
/**
|
|
19
|
+
* Paths whose contents are credentials. Reading any of them through a tool is
|
|
20
|
+
* denied unconditionally.
|
|
21
|
+
*
|
|
22
|
+
* Order matters only for reporting: the first match wins, so the specific
|
|
23
|
+
* rules precede the filename heuristic and an operator sees the precise rule
|
|
24
|
+
* id in the audit record.
|
|
25
|
+
*
|
|
26
|
+
* `$DSH_HOME/.credentials.yaml` is here because core permits it: the harness
|
|
27
|
+
* has no file-read restriction in any mode ("Reads pass through untouched:
|
|
28
|
+
* every mode permits reading"), so the provider token the agent itself
|
|
29
|
+
* authenticates with is agent-readable. That is the gap this table closes.
|
|
30
|
+
*/
|
|
31
|
+
export const CREDENTIAL_PATH_RULES = [
|
|
32
|
+
{ id: 'dsh-dlp/path-dotenv', version: 2, pattern: /(^|\/)\.env(\.(?!(?:example|sample|template|dist)(?:\/|$))[^/]*)?(\/|$)/i },
|
|
33
|
+
{ id: 'dsh-dlp/path-ssh-dir', version: 2, pattern: /(^|\/)\.ssh[^/]*(\/|$)/i },
|
|
34
|
+
{ id: 'dsh-dlp/path-ssh-key', version: 2, pattern: /(^|\/)id_(rsa|dsa|ecdsa|ed25519)([._-][^/]*)?$/i },
|
|
35
|
+
{ id: 'dsh-dlp/path-aws', version: 2, pattern: /(^|\/)\.aws(\/|$)/i },
|
|
36
|
+
{ id: 'dsh-dlp/path-azure', version: 1, pattern: /(^|\/)\.azure(\/|$)/i },
|
|
37
|
+
{ id: 'dsh-dlp/path-dsh-credentials', version: 1, pattern: /(^|\/)\.credentials\.yaml$/i },
|
|
38
|
+
{ id: 'dsh-dlp/path-netrc', version: 1, pattern: /(^|\/)\.netrc$/i },
|
|
39
|
+
{ id: 'dsh-dlp/path-npmrc', version: 1, pattern: /(^|\/)\.npmrc$/i },
|
|
40
|
+
{ id: 'dsh-dlp/path-pypirc', version: 1, pattern: /(^|\/)\.pypirc$/i },
|
|
41
|
+
{ id: 'dsh-dlp/path-git-credentials', version: 1, pattern: /(^|\/)\.git-credentials$/i },
|
|
42
|
+
{ id: 'dsh-dlp/path-gh-config', version: 1, pattern: /(^|\/)\.config\/gh(\/|$)/i },
|
|
43
|
+
{ id: 'dsh-dlp/path-kubeconfig', version: 2, pattern: /(^|\/)(\.kube\/[^/]*|kubeconfig[^/]*)$/i },
|
|
44
|
+
{ id: 'dsh-dlp/path-kubernetes-conf', version: 1, pattern: /(^|\/)kubernetes\/[^/]*\.conf$/i },
|
|
45
|
+
{ id: 'dsh-dlp/path-docker-config', version: 2, pattern: /(^|\/)(\.docker\/config\.json|\.dockercfg)$/i },
|
|
46
|
+
{ id: 'dsh-dlp/path-gcloud-credentials', version: 1, pattern: /(^|\/)\.config\/gcloud\/[^/]*credential[^/]*$/i },
|
|
47
|
+
{ id: 'dsh-dlp/path-rclone-config', version: 1, pattern: /(^|\/)rclone\.conf$/i },
|
|
48
|
+
{ id: 'dsh-dlp/path-pgpass', version: 1, pattern: /(^|\/)\.pgpass$/i },
|
|
49
|
+
{ id: 'dsh-dlp/path-mysql-config', version: 1, pattern: /(^|\/)\.my\.cnf$/i },
|
|
50
|
+
{ id: 'dsh-dlp/path-service-account', version: 1, pattern: /(^|\/)[^/]*service[._-]?account[^/]*\.json$/i },
|
|
51
|
+
{ id: 'dsh-dlp/path-keystore', version: 2, pattern: /\.(pem|p12|pfx|jks|keystore|key|asc|gpg)$/i },
|
|
52
|
+
{ id: 'dsh-dlp/path-credential-name', version: 1, pattern: new RegExp(String.raw `(^|\/)(?!.*\.(?:${CODE_EXTENSIONS})$)[^/]*(credentials?|secrets?|tokens?)([._-][^/]*)?$`, 'i') },
|
|
53
|
+
];
|
|
54
|
+
/**
|
|
55
|
+
* Normalize one candidate path for matching: Windows separators become
|
|
56
|
+
* forward slashes, surrounding quotes come off, `~` expands to a
|
|
57
|
+
* root-anchored marker, `..` segments collapse so a traversal cannot hide a
|
|
58
|
+
* credential path from the table, and a trailing slash is dropped so `.env/`
|
|
59
|
+
* matches the same rule as `.env`.
|
|
60
|
+
* @param candidate - a raw string from tool arguments.
|
|
61
|
+
* @returns the normalized form the rules are matched against.
|
|
62
|
+
*/
|
|
63
|
+
export function normalizeCandidatePath(candidate) {
|
|
64
|
+
const slashed = candidate.replace(/\\/g, '/');
|
|
65
|
+
const stripped = slashed.replace(/^["']|["']$/g, '');
|
|
66
|
+
const homeExpanded = stripped.startsWith('~/') ? `/~/${stripped.slice(2)}` : stripped;
|
|
67
|
+
const normalized = posix.normalize(homeExpanded);
|
|
68
|
+
return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Whether one string names credential material. Pure: it never touches the
|
|
72
|
+
* filesystem, so a symlink is matched by the name it was given. The guard
|
|
73
|
+
* calls {@link matchPathArgument} instead, which resolves first.
|
|
74
|
+
* @param candidate - a raw string from tool arguments.
|
|
75
|
+
* @param rules - the rule table; defaults to {@link CREDENTIAL_PATH_RULES}.
|
|
76
|
+
* @returns the first matching rule, or `undefined`.
|
|
77
|
+
*/
|
|
78
|
+
export function matchCredentialPath(candidate, rules = CREDENTIAL_PATH_RULES) {
|
|
79
|
+
const normalized = normalizeCandidatePath(candidate);
|
|
80
|
+
return rules.find(rule => rule.pattern.test(normalized));
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Resolve one candidate through the filesystem so a symlink cannot rename a
|
|
84
|
+
* credential file out of the table.
|
|
85
|
+
* @param candidate - a raw string from tool arguments.
|
|
86
|
+
* @returns the canonical path, or `undefined` when it does not resolve.
|
|
87
|
+
*/
|
|
88
|
+
export function resolveCandidatePath(candidate) {
|
|
89
|
+
try {
|
|
90
|
+
return realpathSync(normalizeCandidatePath(candidate));
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// ENOENT for a path being created, ELOOP for a broken link, EACCES for an
|
|
94
|
+
// unreadable parent: in every case the literal spelling is all we have.
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Match one path-typed argument against the credential table, by the name the
|
|
100
|
+
* caller used and by what that name resolves to.
|
|
101
|
+
* @param candidate - a raw string from a path-typed tool argument.
|
|
102
|
+
* @param rules - the rule table; defaults to {@link CREDENTIAL_PATH_RULES}.
|
|
103
|
+
* @returns the first matching rule, or `undefined`.
|
|
104
|
+
*/
|
|
105
|
+
export function matchPathArgument(candidate, rules = CREDENTIAL_PATH_RULES) {
|
|
106
|
+
const literal = matchCredentialPath(candidate, rules);
|
|
107
|
+
if (literal !== undefined)
|
|
108
|
+
return literal;
|
|
109
|
+
const resolved = resolveCandidatePath(candidate);
|
|
110
|
+
return resolved === undefined ? undefined : matchCredentialPath(resolved, rules);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Split one shell command into the substrings worth testing as paths: the
|
|
114
|
+
* whole string, plus each shell-ish token. A `bash` command is a single string
|
|
115
|
+
* argument, so without tokenization `cat ~/.ssh/id_rsa && echo ok` would not
|
|
116
|
+
* match a rule anchored at the end of a path.
|
|
117
|
+
*
|
|
118
|
+
* This is advisory only. A shell command is a program, not a path, and any
|
|
119
|
+
* quoting, globbing, substitution or encoding defeats the split — see the
|
|
120
|
+
* "what this is not" section of README.md.
|
|
121
|
+
* @param text - one shell command line taken from tool arguments.
|
|
122
|
+
* @returns the whole string followed by its tokens.
|
|
123
|
+
*/
|
|
124
|
+
export function pathCandidates(text) {
|
|
125
|
+
const tokens = text.split(/[\s;|&<>()"'`,]+/).filter(token => token.length > 0);
|
|
126
|
+
return [text, ...tokens];
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Argument keys whose values name filesystem paths.
|
|
130
|
+
*
|
|
131
|
+
* The guard tests these and nothing else. Running the credential-path table
|
|
132
|
+
* over every string argument matches file *content* — a `.gitignore` listing
|
|
133
|
+
* `.env`, an edit that mentions `id_rsa`, a grep pattern — and denies ordinary
|
|
134
|
+
* work with a message saying it cannot be overridden.
|
|
135
|
+
*/
|
|
136
|
+
export const PATH_ARGUMENT_KEYS = new Set([
|
|
137
|
+
// Names the shipped tools use: `file_path` (read, write, edit), `path` and
|
|
138
|
+
// `paths` (search, lsp, terminal), `cwd` and `workdir` (the shells), `root`.
|
|
139
|
+
'file_path', 'filePath', 'path', 'paths', 'file', 'files', 'filename', 'file_name',
|
|
140
|
+
'notebook_path', 'notebookPath', 'target_file', 'source_path', 'destination_path',
|
|
141
|
+
'source', 'destination', 'directory', 'dir', 'cwd', 'workdir', 'root',
|
|
142
|
+
'output_path', 'input_path',
|
|
143
|
+
]);
|
|
144
|
+
/**
|
|
145
|
+
* Argument keys whose values are shell command lines. Their tokens are tested
|
|
146
|
+
* as paths; see {@link pathCandidates} for why that is advisory.
|
|
147
|
+
*/
|
|
148
|
+
export const SHELL_ARGUMENT_KEYS = new Set(['command', 'cmd', 'script', 'shell_command']);
|
|
149
|
+
/**
|
|
150
|
+
* Collect the path-typed strings inside one tool's arguments, at any depth.
|
|
151
|
+
*
|
|
152
|
+
* A key names paths for every tool that uses it: the tool registry is open, so
|
|
153
|
+
* a per-tool table would abstain on every plugin and MCP tool this build has
|
|
154
|
+
* never heard of.
|
|
155
|
+
* @param args - the pending call's parsed arguments.
|
|
156
|
+
* @returns each path-typed string, labelled with whether it is a command line.
|
|
157
|
+
*/
|
|
158
|
+
export function pathArguments(args) {
|
|
159
|
+
const found = [];
|
|
160
|
+
const collect = (node, shell) => {
|
|
161
|
+
if (typeof node === 'string') {
|
|
162
|
+
found.push({ text: node, shell });
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (Array.isArray(node)) {
|
|
166
|
+
for (const item of node)
|
|
167
|
+
collect(item, shell);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (typeof node === 'object' && node !== null) {
|
|
171
|
+
for (const item of Object.values(node))
|
|
172
|
+
collect(item, shell);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
const walk = (node) => {
|
|
176
|
+
if (Array.isArray(node)) {
|
|
177
|
+
for (const item of node)
|
|
178
|
+
walk(item);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (typeof node !== 'object' || node === null)
|
|
182
|
+
return;
|
|
183
|
+
for (const [key, value] of Object.entries(node)) {
|
|
184
|
+
if (PATH_ARGUMENT_KEYS.has(key)) {
|
|
185
|
+
collect(value, false);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (SHELL_ARGUMENT_KEYS.has(key)) {
|
|
189
|
+
collect(value, true);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
walk(value);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
walk(args);
|
|
196
|
+
return found;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Tools that provably cannot move data off the machine: they read local
|
|
200
|
+
* state, edit local files, or talk only to the session itself.
|
|
201
|
+
*
|
|
202
|
+
* Anything absent from this set — every shell, `run_code`, the web tools,
|
|
203
|
+
* every `mcp__*` tool, and any plugin tool this build has never heard of — is
|
|
204
|
+
* treated as egress-capable. Unknown defaults to the safe side.
|
|
205
|
+
*/
|
|
206
|
+
export const LOCAL_TOOLS = new Set([
|
|
207
|
+
'read', 'read_image', 'glob', 'grep',
|
|
208
|
+
'write', 'edit', 'str_replace_editor',
|
|
209
|
+
'todo_write', 'ask_user_question',
|
|
210
|
+
'create_goal', 'get_goal', 'update_goal',
|
|
211
|
+
'session_search', 'session_trace',
|
|
212
|
+
'session_event_read', 'session_event_search', 'session_event_trace',
|
|
213
|
+
'list_agents', 'job_list', 'job_output',
|
|
214
|
+
'terminal_list', 'terminal_read',
|
|
215
|
+
'lsp',
|
|
216
|
+
]);
|
|
217
|
+
/**
|
|
218
|
+
* Whether a tool can move data off the machine.
|
|
219
|
+
* @param toolName - the executing tool's registered name.
|
|
220
|
+
* @param extraEgressTools - names the repo-local policy tier added.
|
|
221
|
+
* @returns `true` when the tool is not a known local-only tool.
|
|
222
|
+
*/
|
|
223
|
+
export function isEgressCapable(toolName, extraEgressTools = new Set()) {
|
|
224
|
+
if (extraEgressTools.has(toolName))
|
|
225
|
+
return true;
|
|
226
|
+
return !LOCAL_TOOLS.has(toolName);
|
|
227
|
+
}
|