@shomra/agent 0.3.17 → 0.3.19
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/NOTICE +1 -1
- package/README.md +57 -57
- package/package.json +3 -9
- package/shomra.mjs +9 -7168
- package/src/agents/hook-command.mjs +19 -0
- package/src/agents/hook-files.mjs +41 -0
- package/src/agents/installers.mjs +203 -0
- package/src/artifacts/matchers.mjs +59 -0
- package/src/artifacts/report.mjs +50 -0
- package/src/cli/flags.mjs +68 -0
- package/src/cli/help-sections.mjs +309 -0
- package/src/cli/help.mjs +27 -0
- package/src/cli/main.mjs +55 -0
- package/src/cli/registry.mjs +80 -0
- package/src/cli/suggestions.mjs +33 -0
- package/src/commands/add.mjs +149 -0
- package/src/commands/agent-identity.mjs +46 -0
- package/src/commands/check.mjs +194 -0
- package/src/commands/corpus.mjs +126 -0
- package/src/commands/design.mjs +168 -0
- package/src/commands/doctor.mjs +209 -0
- package/src/commands/fix.mjs +115 -0
- package/src/commands/gate.mjs +154 -0
- package/src/commands/git-hooks.mjs +163 -0
- package/src/commands/init.mjs +36 -0
- package/src/commands/install-hook.mjs +51 -0
- package/src/commands/llm-proxy.mjs +153 -0
- package/src/commands/mcp-add.mjs +185 -0
- package/src/commands/mcp.mjs +143 -0
- package/src/commands/memory-scan.mjs +181 -0
- package/src/commands/model-scan.mjs +99 -0
- package/src/commands/models.mjs +145 -0
- package/src/commands/new.mjs +64 -0
- package/src/commands/plan.mjs +87 -0
- package/src/commands/pr.mjs +249 -0
- package/src/commands/protect.mjs +38 -0
- package/src/commands/provenance.mjs +91 -0
- package/src/commands/redteam.mjs +166 -0
- package/src/commands/rules.mjs +220 -0
- package/src/commands/run.mjs +128 -0
- package/src/commands/scan-zip.mjs +118 -0
- package/src/commands/scan.mjs +102 -0
- package/src/commands/secrets.mjs +99 -0
- package/src/commands/status.mjs +50 -0
- package/src/commands/why.mjs +88 -0
- package/src/core/api-client.mjs +66 -0
- package/src/core/api-key.mjs +6 -0
- package/src/core/circuit-breaker.mjs +42 -0
- package/src/core/config.mjs +45 -0
- package/src/core/exit-codes.mjs +9 -0
- package/src/core/json-file.mjs +13 -0
- package/src/core/numbers.mjs +4 -0
- package/src/core/package-root.mjs +10 -0
- package/src/core/terminal.mjs +16 -0
- package/src/core/version.mjs +14 -0
- package/src/core/wire-limits.mjs +53 -0
- package/src/corpus/screening.mjs +127 -0
- package/{ai-usage.mjs → src/detect/ai-usage.mjs} +0 -27
- package/src/detect/code-sast.mjs +2 -0
- package/{design.mjs → src/detect/design.mjs} +17 -106
- package/src/detect/guard-signals.mjs +18 -0
- package/{model-refs.mjs → src/detect/model-refs.mjs} +18 -77
- package/src/detect/sast/chains.mjs +30 -0
- package/src/detect/sast/path-expressions.mjs +76 -0
- package/src/detect/sast/rules-chains.mjs +33 -0
- package/src/detect/sast/rules-config.mjs +51 -0
- package/src/detect/sast/rules-javascript.mjs +109 -0
- package/src/detect/sast/rules-python.mjs +292 -0
- package/src/detect/sast/scanner.mjs +104 -0
- package/src/detect/sast/source-lines.mjs +115 -0
- package/src/detect/sast/taint.mjs +71 -0
- package/src/detect/signals/artifacts.mjs +113 -0
- package/src/detect/signals/autonomy.mjs +55 -0
- package/src/detect/signals/config-markers.mjs +28 -0
- package/src/detect/signals/credential-harvest.mjs +64 -0
- package/src/detect/signals/durable-claims.mjs +73 -0
- package/src/detect/signals/egress.mjs +56 -0
- package/src/detect/signals/execution-hijack.mjs +128 -0
- package/src/detect/signals/gate.mjs +91 -0
- package/src/detect/signals/injection.mjs +55 -0
- package/src/detect/signals/lines.mjs +42 -0
- package/src/detect/signals/masking.mjs +99 -0
- package/src/detect/signals/memory.mjs +357 -0
- package/src/detect/signals/packages.mjs +45 -0
- package/src/detect/signals/propagation.mjs +86 -0
- package/src/detect/signals/prose-context.mjs +82 -0
- package/src/detect/signals/scan.mjs +91 -0
- package/src/detect/signals/secrets.mjs +85 -0
- package/src/detect/signals/sensitive.mjs +9 -0
- package/src/detect/signals/severity.mjs +10 -0
- package/src/detect/signals/shell.mjs +96 -0
- package/src/detect/signals/staged-fetch.mjs +66 -0
- package/src/detect/signals/text-match.mjs +35 -0
- package/src/gate/batch.mjs +157 -0
- package/src/gate/environment.mjs +122 -0
- package/src/gate/repo-policy.mjs +65 -0
- package/src/gate/result.mjs +53 -0
- package/src/gate/sarif.mjs +33 -0
- package/src/gate/sast.mjs +64 -0
- package/src/gate/suppressions.mjs +0 -0
- package/src/guard/classify.mjs +50 -0
- package/src/guard/emit.mjs +51 -0
- package/src/guard/ignore.mjs +24 -0
- package/src/guard/ledger.mjs +112 -0
- package/src/guard/model-load.mjs +50 -0
- package/src/guard/normalize.mjs +77 -0
- package/src/guard/options.mjs +10 -0
- package/src/guard/prompt-guard.mjs +184 -0
- package/src/guard/report.mjs +35 -0
- package/src/guard/result-guard.mjs +140 -0
- package/src/guard/tool-guard.mjs +166 -0
- package/src/inventory/agent-artifacts.mjs +5 -0
- package/src/inventory/agent-posture.mjs +249 -0
- package/src/inventory/artifacts/classify.mjs +27 -0
- package/src/inventory/artifacts/discover.mjs +187 -0
- package/src/inventory/artifacts/file-read.mjs +42 -0
- package/src/inventory/artifacts/hooks.mjs +14 -0
- package/src/inventory/artifacts/limits.mjs +37 -0
- package/src/inventory/artifacts/marketplaces.mjs +45 -0
- package/src/inventory/artifacts/roots.mjs +20 -0
- package/src/inventory/artifacts/walk.mjs +36 -0
- package/src/inventory/discovery/ai-dependencies.mjs +161 -0
- package/src/inventory/discovery/ai-tools.mjs +23 -0
- package/src/inventory/discovery/all.mjs +40 -0
- package/src/inventory/discovery/coding-agents.mjs +77 -0
- package/src/inventory/discovery/fs-read.mjs +36 -0
- package/src/inventory/discovery/local-runtimes.mjs +53 -0
- package/src/inventory/discovery/mcp-clients.mjs +67 -0
- package/src/inventory/discovery/mcp-servers.mjs +78 -0
- package/src/inventory/discovery/model-keys.mjs +97 -0
- package/src/inventory/discovery/platform.mjs +16 -0
- package/src/inventory/discovery/rules-files.mjs +25 -0
- package/src/inventory/discovery/vector-stores.mjs +176 -0
- package/src/inventory/discovery/workspace.mjs +124 -0
- package/src/inventory/discovery.mjs +10 -0
- package/src/mcp/child-process.mjs +50 -0
- package/src/mcp/config-wrapping.mjs +75 -0
- package/src/mcp/connect-gate.mjs +45 -0
- package/src/mcp/hosts.mjs +16 -0
- package/src/mcp/jsonrpc.mjs +48 -0
- package/src/mcp/lookup.mjs +50 -0
- package/src/mcp/screening.mjs +103 -0
- package/src/mcp/server-tools.mjs +97 -0
- package/src/mcp/server.mjs +102 -0
- package/src/mcp/shim.mjs +205 -0
- package/src/models/lookup.mjs +79 -0
- package/src/models/references.mjs +103 -0
- package/src/rules/context.mjs +98 -0
- package/src/rules/generate.mjs +103 -0
- package/src/rules/sections.mjs +145 -0
- package/src/scaffold/agent-project.mjs +185 -0
- package/src/scaffold/artifact-templates.mjs +35 -0
- package/code-sast.mjs +0 -1063
- package/discovery.mjs +0 -977
- package/guard-ledger.mjs +0 -239
- package/guard-signals.mjs +0 -2055
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { MAX_ARTIFACT_BYTES } from '../artifacts/matchers.mjs';
|
|
3
|
+
import { api, gateMachine } from '../core/api-client.mjs';
|
|
4
|
+
import { clampInt } from '../core/numbers.mjs';
|
|
5
|
+
import { SEV_COLOR, bold, dim, gray, green, red, yellow } from '../core/terminal.mjs';
|
|
6
|
+
import { localGate } from '../detect/guard-signals.mjs';
|
|
7
|
+
import { applyRepoPolicy, loadRepoPolicy } from './repo-policy.mjs';
|
|
8
|
+
import { localAsGateResult } from './result.mjs';
|
|
9
|
+
import { collectLocalSast, mergeSastIntoResult } from './sast.mjs';
|
|
10
|
+
import { loadBaseline, loadIgnoreRules, suppressResult } from './suppressions.mjs';
|
|
11
|
+
|
|
12
|
+
const DEFAULT_CONCURRENCY = 8;
|
|
13
|
+
const MIN_CONCURRENCY = 1;
|
|
14
|
+
const MAX_CONCURRENCY = 32;
|
|
15
|
+
const MAX_FINDINGS_SHOWN = 3;
|
|
16
|
+
const FAIL_ON_RANK = { critical: 3, high: 2, medium: 1 };
|
|
17
|
+
|
|
18
|
+
function readArtifact(artifact, quiet) {
|
|
19
|
+
try {
|
|
20
|
+
if (fs.statSync(artifact.full).size > MAX_ARTIFACT_BYTES) {
|
|
21
|
+
if (!quiet) console.log(` ${gray('•')} ${dim(artifact.rel)} ${yellow('skipped (too large)')}`);
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
return fs.readFileSync(artifact.full, 'utf8');
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function analyseLocally(artifacts, quiet) {
|
|
31
|
+
const prepared = [];
|
|
32
|
+
for (const artifact of artifacts) {
|
|
33
|
+
const content = readArtifact(artifact, quiet);
|
|
34
|
+
if (content == null) continue;
|
|
35
|
+
prepared.push({
|
|
36
|
+
artifact,
|
|
37
|
+
content,
|
|
38
|
+
local: localGate(content, { kind: artifact.kind, path: artifact.rel }),
|
|
39
|
+
sast: collectLocalSast({ fullPath: artifact.full, relPath: artifact.rel, kind: artifact.kind, content }),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return prepared;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function requestServerVerdicts(prepared, { apiKey, url, env, flags, quiet }) {
|
|
46
|
+
const verdicts = new Array(prepared.length).fill(null);
|
|
47
|
+
const rejected = [];
|
|
48
|
+
let backendDown = false;
|
|
49
|
+
if (!apiKey) return { verdicts, rejected, backendDown };
|
|
50
|
+
|
|
51
|
+
const concurrency = clampInt(process.env.SHOMRA_GATE_CONCURRENCY, DEFAULT_CONCURRENCY, MIN_CONCURRENCY, MAX_CONCURRENCY);
|
|
52
|
+
let nextIndex = 0;
|
|
53
|
+
|
|
54
|
+
const worker = async () => {
|
|
55
|
+
for (;;) {
|
|
56
|
+
const index = nextIndex;
|
|
57
|
+
nextIndex += 1;
|
|
58
|
+
if (index >= prepared.length || backendDown) return;
|
|
59
|
+
|
|
60
|
+
const { artifact, content } = prepared[index];
|
|
61
|
+
try {
|
|
62
|
+
verdicts[index] = await api(url, apiKey, '/gate/check', {
|
|
63
|
+
kind: artifact.kind,
|
|
64
|
+
path: artifact.rel,
|
|
65
|
+
content,
|
|
66
|
+
machine: gateMachine(),
|
|
67
|
+
env,
|
|
68
|
+
...(flags.project ? { projectId: String(flags.project) } : {}),
|
|
69
|
+
});
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (error?.rejected) {
|
|
72
|
+
rejected.push({ path: artifact.rel, kind: artifact.kind, reason: error.message });
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (!backendDown && !quiet) {
|
|
76
|
+
console.log(` ${yellow('⚠')} ${dim(`backend unavailable (${error.message}) - on-machine analysis for the rest`)}`);
|
|
77
|
+
}
|
|
78
|
+
backendDown = true;
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, prepared.length) }, worker));
|
|
85
|
+
return { verdicts, rejected, backendDown };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function printResult(result, artifact, source) {
|
|
89
|
+
const colour = result.decision === 'BLOCK' ? red : result.decision === 'FLAG' ? yellow : green;
|
|
90
|
+
const suppressedNote = result.suppressedCount ? dim(` · ${result.suppressedCount} suppressed`) : '';
|
|
91
|
+
const pathNote = artifact.rel !== result.name ? ` ${dim(artifact.rel)}` : '';
|
|
92
|
+
const localNote = source === 'local' ? dim(' ·local') : '';
|
|
93
|
+
const findingCount = result.findingCount ?? (result.findings || []).length;
|
|
94
|
+
|
|
95
|
+
console.log(` ${colour('●')} ${bold(result.name)}${pathNote}${localNote} ${colour(result.decision)} ${dim(`risk ${result.riskScore} · ${findingCount} finding(s)`)}${suppressedNote}`);
|
|
96
|
+
|
|
97
|
+
const findings = result.findings || [];
|
|
98
|
+
for (const finding of findings.slice(0, MAX_FINDINGS_SHOWN)) {
|
|
99
|
+
const location = finding.line ? dim(` (${finding.file || artifact.rel}:${finding.line})`) : '';
|
|
100
|
+
console.log(` ${SEV_COLOR[finding.severity](String(finding.severity).padEnd(8))} ${finding.title}${location}`);
|
|
101
|
+
}
|
|
102
|
+
const remaining = findings.length - Math.min(findings.length, MAX_FINDINGS_SHOWN);
|
|
103
|
+
if (remaining > 0) console.log(` ${dim(`… and ${remaining} more (run with --json for all)`)}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function resolveSuppression(flags, root) {
|
|
107
|
+
const enabled = !flags['no-suppress'];
|
|
108
|
+
return {
|
|
109
|
+
enabled,
|
|
110
|
+
rules: enabled ? loadIgnoreRules(root) : { fileGlobs: [], findingRules: [] },
|
|
111
|
+
baseline: enabled && !flags['no-baseline'] ? loadBaseline(root) : null,
|
|
112
|
+
lineCache: new Map(),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function gateArtifactList(artifacts, { apiKey, url, env, flags, root }) {
|
|
117
|
+
const quiet = flags.json || flags.sarif;
|
|
118
|
+
const workingRoot = root || process.cwd();
|
|
119
|
+
const suppression = resolveSuppression(flags, workingRoot);
|
|
120
|
+
const policy = flags['no-policy'] ? null : loadRepoPolicy(workingRoot);
|
|
121
|
+
|
|
122
|
+
const prepared = analyseLocally(artifacts, quiet);
|
|
123
|
+
const { verdicts, rejected, backendDown } = await requestServerVerdicts(prepared, { apiKey, url, env, flags, quiet });
|
|
124
|
+
|
|
125
|
+
const results = [];
|
|
126
|
+
let blocked = 0;
|
|
127
|
+
let flagged = 0;
|
|
128
|
+
let suppressed = 0;
|
|
129
|
+
|
|
130
|
+
prepared.forEach(({ artifact, local, sast }, index) => {
|
|
131
|
+
const serverVerdict = verdicts[index];
|
|
132
|
+
const source = serverVerdict ? 'server' : 'local';
|
|
133
|
+
const base = serverVerdict || localAsGateResult(local, artifact.rel.split('/').pop(), artifact.kind);
|
|
134
|
+
const merged = mergeSastIntoResult(base, sast);
|
|
135
|
+
|
|
136
|
+
const raw = { path: artifact.rel, full: artifact.full, kind: artifact.kind, source, ...merged };
|
|
137
|
+
const withSuppressions = suppression.enabled
|
|
138
|
+
? suppressResult(raw, suppression.rules, suppression.baseline, suppression.lineCache)
|
|
139
|
+
: raw;
|
|
140
|
+
const result = applyRepoPolicy(withSuppressions, policy);
|
|
141
|
+
|
|
142
|
+
suppressed += result.suppressedCount || 0;
|
|
143
|
+
results.push(result);
|
|
144
|
+
if (result.decision === 'BLOCK') blocked += 1;
|
|
145
|
+
else if (result.decision === 'FLAG') flagged += 1;
|
|
146
|
+
|
|
147
|
+
if (!quiet) printResult(result, artifact, source);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
return { results, blocked, flagged, suppressed, backendDown, rejected };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function failOnHit(flags, blocked, flagged) {
|
|
154
|
+
const threshold = FAIL_ON_RANK[String(flags['fail-on'] || 'critical').toLowerCase()] ?? 3;
|
|
155
|
+
const worst = blocked > 0 ? 3 : flagged > 0 ? 2 : 0;
|
|
156
|
+
return worst > 0 && worst >= threshold;
|
|
157
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const GATE_KINDS = ['mcp', 'skill', 'command', 'subagent', 'hook', 'rules', 'agent-card', 'memory', 'auto'];
|
|
6
|
+
|
|
7
|
+
export function detectEnv() {
|
|
8
|
+
const e = process.env;
|
|
9
|
+
const pick = (...keys) => {
|
|
10
|
+
for (const k of keys) if (e[k]?.trim()) return e[k].trim();
|
|
11
|
+
return undefined;
|
|
12
|
+
};
|
|
13
|
+
let ci = null;
|
|
14
|
+
|
|
15
|
+
if (e.GITHUB_ACTIONS)
|
|
16
|
+
ci = {
|
|
17
|
+
ciProvider: 'github-actions',
|
|
18
|
+
repo: e.GITHUB_REPOSITORY,
|
|
19
|
+
|
|
20
|
+
repoUrl: e.GITHUB_SERVER_URL && e.GITHUB_REPOSITORY ? `${e.GITHUB_SERVER_URL.replace(/\/+$/, '')}/${e.GITHUB_REPOSITORY}` : undefined,
|
|
21
|
+
ref: e.GITHUB_REF_NAME,
|
|
22
|
+
commit: e.GITHUB_SHA,
|
|
23
|
+
};
|
|
24
|
+
else if (e.GITLAB_CI)
|
|
25
|
+
ci = { ciProvider: 'gitlab-ci', repo: e.CI_PROJECT_PATH, repoUrl: e.CI_PROJECT_URL, ref: e.CI_COMMIT_REF_NAME, commit: e.CI_COMMIT_SHA };
|
|
26
|
+
else if (e.CIRCLECI)
|
|
27
|
+
ci = {
|
|
28
|
+
ciProvider: 'circleci',
|
|
29
|
+
repo: e.CIRCLE_PROJECT_REPONAME,
|
|
30
|
+
repoUrl: e.CIRCLE_REPOSITORY_URL,
|
|
31
|
+
ref: e.CIRCLE_BRANCH,
|
|
32
|
+
commit: e.CIRCLE_SHA1,
|
|
33
|
+
};
|
|
34
|
+
else if (e.TF_BUILD)
|
|
35
|
+
ci = {
|
|
36
|
+
ciProvider: 'azure-pipelines',
|
|
37
|
+
repo: e.BUILD_REPOSITORY_NAME,
|
|
38
|
+
repoUrl: e.BUILD_REPOSITORY_URI,
|
|
39
|
+
ref: e.BUILD_SOURCEBRANCHNAME,
|
|
40
|
+
commit: e.BUILD_SOURCEVERSION,
|
|
41
|
+
};
|
|
42
|
+
else if (e.BITBUCKET_BUILD_NUMBER)
|
|
43
|
+
ci = {
|
|
44
|
+
ciProvider: 'bitbucket-pipelines',
|
|
45
|
+
repo: e.BITBUCKET_REPO_FULL_NAME,
|
|
46
|
+
repoUrl: e.BITBUCKET_GIT_HTTP_ORIGIN,
|
|
47
|
+
ref: e.BITBUCKET_BRANCH,
|
|
48
|
+
commit: e.BITBUCKET_COMMIT,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
else if (e.JENKINS_URL) ci = { ciProvider: 'jenkins', repo: pick('JOB_NAME'), repoUrl: pick('GIT_URL'), ref: e.GIT_BRANCH, commit: e.GIT_COMMIT };
|
|
52
|
+
else if (e.CI) ci = { ciProvider: 'ci', repo: undefined, repoUrl: undefined, ref: undefined, commit: undefined };
|
|
53
|
+
|
|
54
|
+
if (ci) {
|
|
55
|
+
|
|
56
|
+
const git = gitContext();
|
|
57
|
+
return {
|
|
58
|
+
environment: 'CI',
|
|
59
|
+
ciProvider: ci.ciProvider,
|
|
60
|
+
repo: ci.repo ?? git.repo,
|
|
61
|
+
repoUrl: ci.repoUrl ?? git.repoUrl,
|
|
62
|
+
ref: ci.ref ?? git.ref,
|
|
63
|
+
commit: ci.commit ?? git.commit,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { environment: 'LOCAL', ...gitContext() };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function gitContext() {
|
|
71
|
+
const run = (args) => {
|
|
72
|
+
try {
|
|
73
|
+
return execSync(`git ${args}`, { stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000 })
|
|
74
|
+
.toString()
|
|
75
|
+
.trim();
|
|
76
|
+
} catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
const origin = run('config --get remote.origin.url');
|
|
81
|
+
let repo;
|
|
82
|
+
if (origin) {
|
|
83
|
+
const m = origin.match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/);
|
|
84
|
+
repo = m ? m[1] : undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let repoUrl = origin || undefined;
|
|
88
|
+
if (repoUrl) repoUrl = repoUrl.replace(/^([a-z][\w+.-]*:\/\/)[^/@]*@/i, '$1');
|
|
89
|
+
return { repo, repoUrl, ref: run('rev-parse --abbrev-ref HEAD'), commit: run('rev-parse HEAD') };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function collectSiblings(fullTarget, relPath) {
|
|
93
|
+
const MAX = 400;
|
|
94
|
+
const MAX_DEPTH = 3;
|
|
95
|
+
const SKIP = new Set(['.git', 'node_modules', '.venv', 'venv', '__pycache__', 'dist', 'build']);
|
|
96
|
+
if (!fullTarget || !relPath) return [];
|
|
97
|
+
try {
|
|
98
|
+
const root = path.dirname(fullTarget);
|
|
99
|
+
const rootRel = path.dirname(relPath);
|
|
100
|
+
const out = [];
|
|
101
|
+
const walk = (dir, depth) => {
|
|
102
|
+
if (out.length >= MAX || depth > MAX_DEPTH) return;
|
|
103
|
+
let entries;
|
|
104
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
105
|
+
for (const e of entries) {
|
|
106
|
+
if (out.length >= MAX) return;
|
|
107
|
+
const full = path.join(dir, e.name);
|
|
108
|
+
if (e.isDirectory()) {
|
|
109
|
+
if (!SKIP.has(e.name)) walk(full, depth + 1);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (!e.isFile() || full === fullTarget) continue;
|
|
113
|
+
const rel = path.relative(root, full).split(path.sep).join('/');
|
|
114
|
+
out.push(rootRel && rootRel !== '.' ? `${rootRel}/${rel}` : rel);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
walk(root, 0);
|
|
118
|
+
return out;
|
|
119
|
+
} catch {
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { DEC_RANK } from './sast.mjs';
|
|
4
|
+
|
|
5
|
+
const SEV_THRESH = { none: 99, critical: 5, high: 4, medium: 3, low: 2, info: 1 };
|
|
6
|
+
|
|
7
|
+
const SEV_RANK_LOCAL = { INFO: 1, LOW: 2, MEDIUM: 3, HIGH: 4, CRITICAL: 5 };
|
|
8
|
+
|
|
9
|
+
const WEIGHT_LOCAL = { INFO: 2, LOW: 8, MEDIUM: 20, HIGH: 40, CRITICAL: 70 };
|
|
10
|
+
|
|
11
|
+
function parseSimpleYaml(text) {
|
|
12
|
+
const data = {}; let key = null;
|
|
13
|
+
for (const raw of String(text).split(/\r?\n/)) {
|
|
14
|
+
const line = raw.replace(/\s+#.*$/, '');
|
|
15
|
+
if (!line.trim() || line.trim().startsWith('#')) continue;
|
|
16
|
+
const li = /^\s*-\s+(.*)$/.exec(line);
|
|
17
|
+
if (li && key) { (Array.isArray(data[key]) ? data[key] : (data[key] = [])).push(unquote(li[1].trim())); continue; }
|
|
18
|
+
const kv = /^([A-Za-z0-9_.-]+)\s*:\s*(.*)$/.exec(line);
|
|
19
|
+
if (!kv) continue;
|
|
20
|
+
key = kv[1];
|
|
21
|
+
const v = kv[2].trim();
|
|
22
|
+
data[key] = v === '' ? (data[key] ?? null) : v.startsWith('[') ? v.replace(/^\[|\]$/g, '').split(',').map((s) => unquote(s.trim())).filter(Boolean) : unquote(v);
|
|
23
|
+
}
|
|
24
|
+
return data;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function unquote(s) { return String(s).replace(/^["']|["']$/g, ''); }
|
|
28
|
+
|
|
29
|
+
export function loadRepoPolicy(root) {
|
|
30
|
+
let text, file;
|
|
31
|
+
for (const f of ['policy.yml', 'policy.yaml', 'policy.json']) {
|
|
32
|
+
try { text = fs.readFileSync(path.join(root, '.shomra', f), 'utf8'); file = f; break; } catch { }
|
|
33
|
+
}
|
|
34
|
+
if (text === undefined) return null;
|
|
35
|
+
let raw;
|
|
36
|
+
if (file.endsWith('.json')) { try { raw = JSON.parse(text); } catch { return null; } }
|
|
37
|
+
else raw = parseSimpleYaml(text);
|
|
38
|
+
return {
|
|
39
|
+
block: SEV_THRESH[String(raw.block || '').toLowerCase()] ?? SEV_RANK_LOCAL.CRITICAL,
|
|
40
|
+
flag: SEV_THRESH[String(raw.flag || '').toLowerCase()] ?? SEV_RANK_LOCAL.HIGH,
|
|
41
|
+
allow: [].concat(raw.allow || []).map((s) => String(s).toLowerCase()),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function applyRepoPolicy(r, policy) {
|
|
46
|
+
if (!policy) return r;
|
|
47
|
+
let findings = r.findings || [];
|
|
48
|
+
let dropped = 0;
|
|
49
|
+
if (policy.allow.length) {
|
|
50
|
+
const keep = [];
|
|
51
|
+
for (const f of findings) {
|
|
52
|
+
if (policy.allow.some((sub) => sub && String(f.title || '').toLowerCase().includes(sub))) dropped++;
|
|
53
|
+
else keep.push(f);
|
|
54
|
+
}
|
|
55
|
+
findings = keep;
|
|
56
|
+
}
|
|
57
|
+
let worst = 0;
|
|
58
|
+
for (const f of findings) worst = Math.max(worst, SEV_RANK_LOCAL[f.severity] || 0);
|
|
59
|
+
const pv = worst >= policy.block ? 'BLOCK' : worst >= policy.flag ? 'FLAG' : 'ALLOW';
|
|
60
|
+
const riskScore = Math.min(100, findings.reduce((s, f) => s + (WEIGHT_LOCAL[f.severity] || 0), 0));
|
|
61
|
+
|
|
62
|
+
const decision = r.source === 'server' ? (DEC_RANK[pv] > DEC_RANK[r.decision] ? pv : r.decision) : pv;
|
|
63
|
+
const extra = dropped ? { suppressedCount: (r.suppressedCount || 0) + dropped, suppressedFindings: [...(r.suppressedFindings || []), ...(r.findings || []).filter((f) => !findings.includes(f)).map((f) => ({ ...f, suppressedBy: 'policy allow' }))] } : {};
|
|
64
|
+
return { ...r, findings, findingCount: findings.length, decision, riskScore: r.source === 'server' ? Math.max(r.riskScore || 0, riskScore) : riskScore, ...extra };
|
|
65
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { SEV_COLOR, VERDICT_COLOR, bold, dim, gray, green, red, yellow } from '../core/terminal.mjs';
|
|
2
|
+
|
|
3
|
+
export function localAsGateResult(local, name, kind) {
|
|
4
|
+
return {
|
|
5
|
+
decision: local.verdict,
|
|
6
|
+
name: name || 'artifact',
|
|
7
|
+
kind: kind || 'auto',
|
|
8
|
+
riskScore: local.riskScore,
|
|
9
|
+
findingCount: local.findings.length,
|
|
10
|
+
findings: local.findings.map((f) => ({ severity: f.severity, title: f.title, remediationText: f.remediationText, ...(f.line ? { line: f.line } : {}) })),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function printGateResult(res, source, flags) {
|
|
15
|
+
if (flags.json) {
|
|
16
|
+
console.log(JSON.stringify({ source, ...res }, null, 2));
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const dc = res.decision === 'BLOCK' ? red : res.decision === 'FLAG' ? yellow : green;
|
|
20
|
+
console.log(dc(res.decision) + (source === 'local' ? dim(' (on-machine)') : ''));
|
|
21
|
+
console.log(`\n ${dc('●')} ${bold(res.name)} ${dim(`${res.kind} · risk ${res.riskScore}/100 · ${res.findingCount ?? (res.findings || []).length} finding(s)`)}`);
|
|
22
|
+
for (const f of res.findings || []) {
|
|
23
|
+
console.log(` ${SEV_COLOR[f.severity](String(f.severity).padEnd(8))} ${f.title}`);
|
|
24
|
+
if (f.remediationText) console.log(` ${dim('fix: ' + f.remediationText)}`);
|
|
25
|
+
}
|
|
26
|
+
for (const c of res.catalog || []) {
|
|
27
|
+
const vc = VERDICT_COLOR[c.verdict] || gray;
|
|
28
|
+
console.log(` ${dim('catalog:')} ${c.name} ${vc(c.verdict ?? 'UNSCANNED')} ${dim('risk ' + c.riskScore)}`);
|
|
29
|
+
}
|
|
30
|
+
for (const p of res.policyHits || []) {
|
|
31
|
+
|
|
32
|
+
const note = p.suppressed ? dim(' (not enforced - accepted risk / ignored in your org)') : '';
|
|
33
|
+
console.log(` ${dim('policy:')} ${p.policy} ${dim('→')} ${p.action}${note}`);
|
|
34
|
+
}
|
|
35
|
+
const triaged = (res.policyHits || []).filter((p) => p.suppressed).length;
|
|
36
|
+
if (triaged) console.log(` ${dim(`${triaged} policy hit(s) suppressed by triage - reopen or let the acceptance expire to re-enforce.`)}`);
|
|
37
|
+
const orgNote = source === 'local' ? dim(' (on-machine analysis; org policy not applied)') : '';
|
|
38
|
+
|
|
39
|
+
const sev = (res.findings || []).reduce((a, f) => { a[f.severity] = (a[f.severity] || 0) + 1; return a; }, {});
|
|
40
|
+
const highish = (sev.CRITICAL || 0) + (sev.HIGH || 0);
|
|
41
|
+
const highNote = highish
|
|
42
|
+
? `${sev.CRITICAL || 0} critical · ${sev.HIGH || 0} high still present - allowed by your org policy, not by the analysis.`
|
|
43
|
+
: 'No high-risk findings.';
|
|
44
|
+
if (res.decision === 'BLOCK') console.log(`\n ${red('✗ Blocked.')}${orgNote} ${dim('Review the findings above.')}\n`);
|
|
45
|
+
else if (res.decision === 'FLAG') console.log(`\n ${yellow('⚠ Flagged.')}${orgNote} ${dim('Proceed with caution.')}\n`);
|
|
46
|
+
else if (highish) console.log(`\n ${green('✓ Allowed')} ${yellow('by policy.')}${orgNote} ${dim(highNote)}\n`);
|
|
47
|
+
else console.log(`\n ${green('✓ Allowed.')}${orgNote} ${dim(highNote)}\n`);
|
|
48
|
+
|
|
49
|
+
for (const n of res.notAttempted || []) {
|
|
50
|
+
console.log(` ${yellow('!')} ${bold('Not checked:')} ${n.why}`);
|
|
51
|
+
console.log(` ${dim(n.enabledBy)}\n`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { VERSION } from '../core/version.mjs';
|
|
3
|
+
|
|
4
|
+
const SARIF_LEVEL = { CRITICAL: 'error', HIGH: 'error', MEDIUM: 'warning', LOW: 'note', INFO: 'note' };
|
|
5
|
+
|
|
6
|
+
const SARIF_SEC = { CRITICAL: '9.0', HIGH: '7.5', MEDIUM: '5.0', LOW: '3.0', INFO: '1.0' };
|
|
7
|
+
|
|
8
|
+
function sarifRuleId(f) {
|
|
9
|
+
return f.ruleId || 'shomra.' + String(f.title || 'finding').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function toSarif(results) {
|
|
13
|
+
const rules = new Map();
|
|
14
|
+
const sarifResults = [];
|
|
15
|
+
for (const r of results) {
|
|
16
|
+
for (const f of r.findings || []) {
|
|
17
|
+
const id = sarifRuleId(f);
|
|
18
|
+
if (!rules.has(id)) rules.set(id, { id, name: id, shortDescription: { text: String(f.title || id).slice(0, 200) }, ...(f.cwe ? { properties: { cwe: f.cwe, tags: ['security', f.cwe] } } : { properties: { tags: ['security'] } }) });
|
|
19
|
+
sarifResults.push({
|
|
20
|
+
ruleId: id,
|
|
21
|
+
level: SARIF_LEVEL[f.severity] || 'warning',
|
|
22
|
+
message: { text: f.remediationText ? `${f.title} - ${f.remediationText}` : String(f.title || id) },
|
|
23
|
+
locations: [{ physicalLocation: { artifactLocation: { uri: f.file || r.path }, ...(f.line ? { region: { startLine: f.line } } : {}) } }],
|
|
24
|
+
properties: { severity: f.severity, 'security-severity': SARIF_SEC[f.severity] || '5.0', ...(f.cwe ? { cwe: f.cwe } : {}) },
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
$schema: 'https://json.schemastore.org/sarif-2.1.0.json',
|
|
30
|
+
version: '2.1.0',
|
|
31
|
+
runs: [{ tool: { driver: { name: 'Shomra', informationUri: 'https://shomra.dev', version: VERSION, rules: [...rules.values()] } }, results: sarifResults }],
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { MAX_ARTIFACT_BYTES, SKIP_DIRS } from '../artifacts/matchers.mjs';
|
|
4
|
+
import { isModelConfig, isScannableSource, scanSourceFile } from '../detect/code-sast.mjs';
|
|
5
|
+
import { grade } from '../detect/guard-signals.mjs';
|
|
6
|
+
|
|
7
|
+
const MAX_SAST_FILES = 60;
|
|
8
|
+
|
|
9
|
+
function walkScripts(root) {
|
|
10
|
+
const found = [];
|
|
11
|
+
const stack = [root];
|
|
12
|
+
while (stack.length && found.length < MAX_SAST_FILES) {
|
|
13
|
+
const dir = stack.pop();
|
|
14
|
+
let entries;
|
|
15
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
16
|
+
for (const ent of entries) {
|
|
17
|
+
const full = path.join(dir, ent.name);
|
|
18
|
+
if (ent.isDirectory()) { if (!SKIP_DIRS.has(ent.name)) stack.push(full); continue; }
|
|
19
|
+
if (isScannableSource(ent.name) || isModelConfig(ent.name)) {
|
|
20
|
+
found.push({ full, rel: path.relative(process.cwd(), full).split(path.sep).join('/') });
|
|
21
|
+
if (found.length >= MAX_SAST_FILES) break;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return found;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function sastToFinding(h) {
|
|
29
|
+
const base = h.file ? h.file.split('/').pop() : '';
|
|
30
|
+
return {
|
|
31
|
+
severity: h.severity,
|
|
32
|
+
title: `Risky code - ${h.title}${base ? ` in ${base}` : ''} (${h.sink})`,
|
|
33
|
+
remediationText: h.remediation,
|
|
34
|
+
...(h.line ? { line: h.line } : {}),
|
|
35
|
+
analysis: 'sast', ruleId: h.ruleId, cwe: h.cwe, sink: h.sink, source: h.source,
|
|
36
|
+
file: h.file, snippet: h.snippet, snippetStartLine: h.snippetStartLine,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function collectLocalSast({ fullPath, relPath, kind, content }) {
|
|
41
|
+
const out = [];
|
|
42
|
+
if (relPath && (isScannableSource(relPath) || isModelConfig(relPath))) {
|
|
43
|
+
for (const h of scanSourceFile(content || '', relPath)) out.push(sastToFinding(h));
|
|
44
|
+
}
|
|
45
|
+
const isSkill = kind === 'skill' || /(^|\/)SKILL\.md$/i.test(relPath || '');
|
|
46
|
+
if (isSkill && fullPath) {
|
|
47
|
+
for (const s of walkScripts(path.dirname(fullPath))) {
|
|
48
|
+
let text;
|
|
49
|
+
try { if (fs.statSync(s.full).size > MAX_ARTIFACT_BYTES) continue; text = fs.readFileSync(s.full, 'utf8'); } catch { continue; }
|
|
50
|
+
for (const h of scanSourceFile(text, s.rel)) out.push(sastToFinding(h));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const DEC_RANK = { ALLOW: 0, FLAG: 1, BLOCK: 2 };
|
|
57
|
+
|
|
58
|
+
export function mergeSastIntoResult(result, sastFindings) {
|
|
59
|
+
if (!sastFindings || !sastFindings.length) return result;
|
|
60
|
+
const findings = [...(result.findings || []), ...sastFindings];
|
|
61
|
+
const g = grade(findings);
|
|
62
|
+
const decision = DEC_RANK[g.verdict] > DEC_RANK[result.decision] ? g.verdict : result.decision;
|
|
63
|
+
return { ...result, decision, riskScore: Math.max(result.riskScore || 0, g.riskScore), findingCount: findings.length, findings };
|
|
64
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
const ARTIFACT_PATH_RE = /(^|\/)(\.?mcp\.json|SKILL\.md|CLAUDE\.md|AGENTS\.md|GEMINI\.md|\.cursorrules|\.windsurfrules|\.aider\.conf\.yml|agent[-_]card\.json)$|(^|\/)\.claude\/(commands|agents)\/[^/]+\.md$|(^|\/)\.claude\/settings(\.local)?\.json$|(^|\/)\.well-known\/agent(-card)?\.json$|(^|\/)\.clinerules|(^|\/)\.github\/copilot-instructions\.md$/i;
|
|
4
|
+
|
|
5
|
+
export const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'create_file', 'str_replace_editor', 'str_replace_based_edit_tool', 'write_to_file', 'replace_in_file', 'new_rule']);
|
|
6
|
+
|
|
7
|
+
const SHELL_TOOLS_RE = /^(bash|shell|sh|run_command|run_terminal_cmd|execute_command|terminal|exec)$/i;
|
|
8
|
+
|
|
9
|
+
const EGRESS_TOOL_RE = /fetch|web|http|browser|request|download|curl|url|open/i;
|
|
10
|
+
|
|
11
|
+
const EGRESS_CMD_RE = /\b(curl|wget|nc|ncat|http|https|invoke-restmethod|invoke-webrequest|irm|iwr|scp|rsync|ftp|telnet)\b/i;
|
|
12
|
+
|
|
13
|
+
export function guardText(tool, input) {
|
|
14
|
+
const parts = [];
|
|
15
|
+
if (typeof input.command === 'string') parts.push(input.command);
|
|
16
|
+
if (typeof input.cmd === 'string') parts.push(input.cmd);
|
|
17
|
+
if (typeof input.script === 'string') parts.push(input.script);
|
|
18
|
+
if (typeof input.content === 'string') parts.push(input.content);
|
|
19
|
+
if (typeof input.new_string === 'string') parts.push(input.new_string);
|
|
20
|
+
if (typeof input.new_source === 'string') parts.push(input.new_source);
|
|
21
|
+
if (Array.isArray(input.edits)) parts.push(input.edits.map((e) => e?.new_string ?? '').join('\n'));
|
|
22
|
+
if (!parts.length) { try { parts.push(JSON.stringify(input)); } catch { parts.push(String(input)); } }
|
|
23
|
+
return parts.join('\n');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function guardTargetPath(norm) {
|
|
27
|
+
const i = norm.tool_input || {};
|
|
28
|
+
const p = i.file_path ?? i.path ?? i.notebook_path ?? i.filename ?? null;
|
|
29
|
+
return typeof p === 'string' && p.trim() ? p : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function guardNeedsServer(tool, input, hasIdentity) {
|
|
33
|
+
if (hasIdentity) return true;
|
|
34
|
+
if (WRITE_TOOLS.has(tool)) {
|
|
35
|
+
const target = String(input.file_path ?? input.path ?? input.notebook_path ?? '').replace(/\\/g, '/');
|
|
36
|
+
return ARTIFACT_PATH_RE.test(target);
|
|
37
|
+
}
|
|
38
|
+
if (tool && tool.startsWith('mcp__')) return true;
|
|
39
|
+
if (EGRESS_TOOL_RE.test(tool || '')) return true;
|
|
40
|
+
if (SHELL_TOOLS_RE.test(tool || '')) {
|
|
41
|
+
const cmd = String(input.command ?? input.cmd ?? input.script ?? '');
|
|
42
|
+
if (EGRESS_CMD_RE.test(cmd)) return true;
|
|
43
|
+
if (/\bmcp\s+add\b|claude\s+mcp\b|@modelcontextprotocol\b|\bmcp[-_]server\b/i.test(cmd)) return true;
|
|
44
|
+
}
|
|
45
|
+
const url = input?.url ?? input?.uri ?? input?.href ?? input?.endpoint;
|
|
46
|
+
if (typeof url === 'string' && url) return true;
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const MODEL_WRITE_TOOLS = ['write', 'edit', 'multiedit', 'notebookedit', 'create_file', 'str_replace_editor', 'apply_patch', 'write_file'];
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { dim } from '../core/terminal.mjs';
|
|
2
|
+
|
|
3
|
+
export function emitGuardDeny(agent, reason) {
|
|
4
|
+
if (agent === 'windsurf') {
|
|
5
|
+
process.stderr.write(reason);
|
|
6
|
+
process.exit(2);
|
|
7
|
+
}
|
|
8
|
+
const bodies = {
|
|
9
|
+
cursor: () => ({ permission: 'deny', user_message: reason, agent_message: reason }),
|
|
10
|
+
copilot: () => ({ permissionDecision: 'deny', permissionDecisionReason: reason }),
|
|
11
|
+
gemini: () => ({ decision: 'deny', reason }),
|
|
12
|
+
codex: () => ({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason } }),
|
|
13
|
+
cline: () => ({ decision: 'deny', reason, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason } }),
|
|
14
|
+
claude: () => ({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason } }),
|
|
15
|
+
};
|
|
16
|
+
process.stdout.write(JSON.stringify((bodies[agent] || bodies.claude)()));
|
|
17
|
+
process.exit(0);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function emitResultBlock(agent, reason) {
|
|
21
|
+
|
|
22
|
+
if (agent === 'windsurf') {
|
|
23
|
+
console.error(dim(reason));
|
|
24
|
+
process.exit(0);
|
|
25
|
+
}
|
|
26
|
+
const bodies = {
|
|
27
|
+
cursor: () => ({ permission: 'deny', user_message: reason, agent_message: reason }),
|
|
28
|
+
copilot: () => ({ permissionDecision: 'deny', permissionDecisionReason: reason }),
|
|
29
|
+
gemini: () => ({ decision: 'deny', reason }),
|
|
30
|
+
codex: () => ({ decision: 'block', reason, hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: reason } }),
|
|
31
|
+
cline: () => ({ decision: 'block', reason, hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: reason } }),
|
|
32
|
+
claude: () => ({ decision: 'block', reason, hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: reason } }),
|
|
33
|
+
};
|
|
34
|
+
process.stdout.write(JSON.stringify((bodies[agent] || bodies.claude)()));
|
|
35
|
+
process.exit(0);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function emitGuardAsk(agent, reason) {
|
|
39
|
+
const bodies = {
|
|
40
|
+
cursor: () => ({ permission: 'ask', user_message: reason, agent_message: reason }),
|
|
41
|
+
copilot: () => ({ permissionDecision: 'ask', permissionDecisionReason: reason }),
|
|
42
|
+
gemini: () => ({ decision: 'ask', reason }),
|
|
43
|
+
codex: () => ({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'ask', permissionDecisionReason: reason } }),
|
|
44
|
+
cline: () => ({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'ask', permissionDecisionReason: reason } }),
|
|
45
|
+
claude: () => ({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'ask', permissionDecisionReason: reason } }),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
if (agent === 'windsurf' || !bodies[agent]) { process.stderr.write(reason + '\n'); process.exit(0); }
|
|
49
|
+
process.stdout.write(JSON.stringify(bodies[agent]()));
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { globToRe, loadIgnoreRules } from '../gate/suppressions.mjs';
|
|
3
|
+
|
|
4
|
+
const _guardIgnoreCache = new Map();
|
|
5
|
+
|
|
6
|
+
function guardIgnoreGlobs(root) {
|
|
7
|
+
if (_guardIgnoreCache.has(root)) return _guardIgnoreCache.get(root);
|
|
8
|
+
const globs = [];
|
|
9
|
+
try { for (const re of loadIgnoreRules(root).fileGlobs) globs.push(re); } catch { }
|
|
10
|
+
const env = process.env.SHOMRA_GUARD_IGNORE;
|
|
11
|
+
if (env) for (const g of String(env).split(/[,\n]+/).map((s) => s.trim()).filter(Boolean)) { try { globs.push(globToRe(g)); } catch { } }
|
|
12
|
+
_guardIgnoreCache.set(root, globs);
|
|
13
|
+
return globs;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function guardPathAllowlisted(cwd, filePath) {
|
|
17
|
+
if (!filePath) return false;
|
|
18
|
+
const root = cwd || process.cwd();
|
|
19
|
+
let rel;
|
|
20
|
+
try { rel = path.relative(root, path.resolve(root, filePath)); } catch { rel = filePath; }
|
|
21
|
+
rel = String(rel).split(path.sep).join('/');
|
|
22
|
+
const base = rel.split('/').pop();
|
|
23
|
+
return guardIgnoreGlobs(root).some((re) => re.test(rel) || re.test(base));
|
|
24
|
+
}
|