BLA
BLA는 오픈소스 Blue Defense MCP와 CLI로, 소프트웨어 자산을 자동 AI 리버스 엔지니어링으로부터 보호합니다
선박·기업·개인을 제재 회피 조사와 미국·영국·EU·UN 제재 목록에 대조합니다 답변에 출처 포함
Check any ship, company or person against sanctions-evasion research and the US, UK, EU and UN sanctions lists. Every answer comes with its source.
보안JavaScript · HTMLGitHub @Ttheegela온라인운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기Sanctions lookup for journalists: ships, companies and people against our research and the US, UK, EU and UN lists.
저장소 README에서 가져옴
README 전체를 불러오는 중입니다.
README 전문 보기 ↗Check any ship, company or person against sanctions-evasion research and the US, UK, EU and UN sanctions lists. Every answer comes with its source.
Built for journalists and researchers covering how Russia, Iran and others get around sanctions. An independent project.
https://undercurrent-research.vercel.app (the old undercurrent-jet-nine.vercel.app address redirects here)
Type a ship name, a seven-digit IMO number, a company or a person. New here? Press Follow one tanker for a five-step tour.
Each result shows:
Stories: the shadow-fleet map, who lobbies (influence network and clean-names graph), semiconductors into Russia (routes, who reports, time to list, Kyrgyzstan and Kazakhstan), and how sanctions work.
npx serve .
Then open http://localhost:3000 (or the address serve prints). Everything works except the OpenSanctions check and the Connections panel, which run as Vercel Functions (api/search.js, api/connections.js) and show a link or "unavailable" when run this way.
Every night at 04:00 UTC, .github/workflows/update-lists.yml refreshes:
| Data | Source | Where it goes |
|---|---|---|
| Official lists | US Consolidated Screening List, UK Sanctions List, EU financial sanctions list, UN Security Council list | data/lists/ |
| Wider check | OpenSanctions bulk data (CC BY-NC 4.0, no key) | opensanctions.json.gz on the data-latest release |
| Connections | OpenSanctions relationship data | connections.json.gz on the release |
| Lobbying | lda.gov filings for the companies in data/lobbying-clients.json (add a company there) | lobbying.json.gz on the release |
| Semiconductors into Russia | UN Comtrade (HS 8542 exports to Russia, data availability), Eurostat Comext, OFAC SDN advanced XML, the Consolidated Screening List | stories/chips-into-russia/data/ |
| Live graphs | The shadow-fleet map and the influence network, rebuilt from the official lists, the OpenSanctions OFAC and EU Official Journal exports (CC BY-NC 4.0) and the lobbying filings above | stories/shadow-fleet/network/ (fleet-data.json, nodes.json, edges.json), stories/who-lobbies/influence-network/kg.json, the day's changes in data/changes/changes.json (the Updates page), the page figures in data/site-stats.json, and data/live-graphs-meta.json |
scripts/build_live_graphs.py runs after the other steps. If it keeps yesterday's map, or the lobbying asset is there but cannot be read (the map still updates and the network keeps yesterday's lobbying layer), it exits 1, and the nightly issue names "live graphs" and quotes the build's own note from data/live-graphs-meta.json. Its guards:
.tmp file, then all are renamed into place in a fixed order: map payload, nodes, edges, network, page figures, then the change record, then the status file. The record is never ahead of the graphs it describes.stories/chips-into-russia/data/chips-firms.json. Firms are joined by OFAC id and each is named in a US Treasury press release. The home page's "months" figure is read from summary.json.python3 scripts/build_research_index.py reads the map's 25 September files from git (commit ad46320), not the files the nightly rewrites, so it gives the same snapshot.stories/ are the source; the site serves the .html pages that python3 scripts/build_docs.py writes next to them. Edit the , run the script and commit both (a test fails if a page is out of date). redirects the old URLs.publish as a short-lived artifact.., _, - and /main only if the smoke check passed.json files at the expected paths.NIGHTLY_DEPLOY_KEY lives only in the nightly GitHub environment, which only main may use. It is the repo's only deploy key, and the protect-main ruleset lets it push the nightly commit.To replace the key, run this on your own machine (never paste the private key anywhere):
ssh-keygen -t ed25519 -N "" -C "undercurrent-nightly" -f ~/.ssh/undercurrent_nightly -q
gh repo deploy-key add ~/.ssh/undercurrent_nightly.pub -R Ttheegela/undercurrent --allow-write --title "nightly (environment)"
gh secret set NIGHTLY_DEPLOY_KEY --env nightly -R Ttheegela/undercurrent < ~/.ssh/undercurrent_nightly
Then run the workflow once (gh workflow run update-lists.yml --ref main), check both jobs pass, and delete the old deploy key.
A chat panel on every page that answers questions about what is on the site: the official lists, ship and company connections, lobbying, the semiconductor data, the research and the daily updates. An independent project, so it answers only from the site's own data and refuses anything else, including legal or compliance advice.
How it answers: the model writes no sentences. api/ask.js runs it through OpenRouter (Claude Haiku 5.5, provider.data_collection: deny) for at most four rounds (ten calls in all) of lookups in api/_ask_tools.js, then forces one answer call. That call only cites evidence ids, each with a claim. api/_ask_check.js writes every sentence itself, either fixed copy filled from the cited items or one sentence copied word for word from a cited item. The fixed copy covers:
research tool), and the chips research's firms, all of them or those based in one country;check_lists with word: one word, its plural too, counted by record, examples newest first), the word printed only as the lists spell it and never as a listing or clean-bill word. A trailing S folds only from five letters and never after A, I, U or S, so BANK is BANKS but HAMAS is not HAMA. After the last sentence that cites the count or one of its examples comes the line that it matches the word as spelled, in names only, with no country or line of business;Anything it cannot build is dropped: the sentences it could build stand, followed by "I could only answer part of the question. Ask about each name on its own to check the rest.", and an answer with nothing left is a fixed fallback. When the model cites nothing after lookups that all worked, the answer is "Nothing recorded on this site answers that.", unless the daily updates record answered the question itself (no record before its first night, days no longer kept, tonight's run not made yet, no changes, or no update involving the topic): then the answer is that item's own line, with its caveats and source. The relevance gate (below) then drops sentences that don't bear on the question. Ask refuses nothing on its own (only the model can refuse, and then the answer is the fixed refusal), but an answer to a question that looks like a request for advice on doing business ends with a fixed notice that Ask gives no legal or compliance advice and that these are records with their sources. The system prompt is .
| Variable | Where | Meaning |
|---|---|---|
OPENROUTER_API_KEY | Vercel, Production, Secret | A dedicated OpenRouter key with a $5 limit that resets monthly. The owner enters it with vercel env add OPENROUTER_API_KEY production at the hidden prompt. It is never in the repo, a log, a test or a chat. Unset: POST /api/ask answers 503 disabled. |
TYPESAFE_API_KEY | Vercel, Production, Secret | The relevance gate's TypeSafe key, separate from the eval one. The owner enters it with vercel env add TYPESAFE_API_KEY production. Read only in api/_ask_gate.js, never logged. Unset: answers are shown without the gate, and each logs the gate's fixed line. |
ASK_PUBLIC | Vercel, Production | 1 shows the Ask button on every page. Unset, the button stays hidden but the endpoint still answers, so the live evaluation can run against production before launch. |
/api/ must allow POST on /api/ask only, and POST /api/search must still be refused.node scripts/ask-live-eval.mjs --base https://undercurrent-research.vercel.app posts the evaluation questions to a deployed endpoint, prints each answer with its timing, and exits non-zero if any case fails. Its 59 questions are more than the endpoint's 20 an hour, so run them in the three --only batches it prints, an hour apart. With it also counts the declines: the fallback, and an answer that only says nothing recorded answers. It needs no key and costs a few cents. Run it before launch and after any change to the prompt or the model.The 59 real-model questions in tests/ask-eval.json (the cases without "live": false) go through Ask's own loop and are scored in LangSmith, so how Ask does is on public record: the cases, the scores, the failures and the cost. 25 of them cover question patterns across the site's topics (counts, durations, topic filters on the updates, lists by programme, country or flag, records by a word in their names, questions whose honest answer is "none" or that nothing recorded answers, two-part questions and a near-miss name), each with a note on what a good answer would be. 8 more are the questions of a journalist test on the live site (26 Oct 2026): a ship's listings and their age, its owner and manager, why it was renamed, flags, the week's updates, a company on three lists, the chips firms in China, and what the dates and the data cannot show. Where the server can now say it (a count, an age, an undated listing, a none), the case expects that sentence in includes and no fallback. A live run meets that night's data and its own date, so an include holds only what neither can move:
tests/ask-eval.test.js checks every live include against these rules. A case accepts the fallback only where declining can be right. Each case's evidence is recorded once with Ask's real tools, from the committed data and the release assets (re-recorded on 22 Oct 2026 for the new claims; the word cases recorded on 24 Oct 2026, the journalist cases on 26 Oct 2026). It keeps its exact numbers, so the offline suite never changes with the nightly data.
Dashboard: (link added after the first run)
.github/workflows/ask-evals.yml runs scripts/ask-langsmith.mjs.
gh workflow run ask-evals.yml --ref main). A run on main first makes the LangSmith dataset undercurrent-ask match the live cases, then runs them. Until a run on main, weekly or manual, has created the dataset, pull request runs fail with "dataset undercurrent-ask not found".gh workflow run ask-evals.yml --ref main -f model=deepseek/deepseek-v4.1-flash. The same cases then run on that OpenRouter model (vendor/model; anything else stops the run before it starts) instead of the site's. The site itself keeps Claude Haiku 5.5. On main such a run also syncs the dataset, like any manual run there.-f repetitions=3 (1, 2 or 3; the runner's --repetitions N, which only a manual run takes). Every case then runs that many times in the one experiment, as that many runs of the set; they are not retries. The job has 45 minutes for that.ask-weekly-…, ask-manual-… and ask-pr-<number>-…, with the model's name added when a run compares another model (ask-manual-deepseek-v4.1-flash-…). Each carries commit, trigger, pr, model (the model the run used), judge_model (jev-latest, or null when the relevance judge was skipped) and prompt_hash (the first 12 characters of a hash of the system prompt and the tool definitions), so a change in score can be tied to a change in the prompt.quality: Ask answered. unavailable: Ask replied that it is unavailable. budget: the eval key's limit was reached. download_failed: a lookup could not download its data (this wins over quality).pass is recorded only for quality outcomes, so the pass rate leaves infrastructure out. It is 1 when the answer is the expected kind, says nothing the case rules out, and every sentence is grounded in what the lookups returned. On every case: , , , , , and (the input tokens TypeSafe billed for the relevance gate, also in the case's trace outputs; 0 when the gate ran without a key). On answered cases: and, where the case lists sentences the answer must contain, . There are no retries: a case runs once in each run of the set, as a visitor would have met it.vercel.json and enforced.
/stories/, which has inline scripts and pinned CDN libraries with integrity hashes.X-Content-Type-Options, X-Frame-Options: DENY, Referrer-Policy, Permissions-Policy, Cross-Origin-Opener-Policy./api/ is rate-limited to 120 requests a minute per IP.POST /api/ask (see Ask). Queries over 200 characters are rejected with 400, and so are names that normalise to under two characters.exclude_lobbyists (in data/lobbying-clients.json) are never shown. Corrections and removal requests go through GitHub issues, as described on the About page.UptimeRobot checks three things every 5 minutes and emails on failure:
/api/search?q=tassos, which must contain "enabled":true/api/connections?imo=9408695, which must contain "matched":truePublic status page: https://stats.uptimerobot.com/5FeFn9Euy7
A scheduled maintenance agent checks the nightly run every day at 10:30 UTC and opens fix PRs; changes to listing or naming rules, workflows or security settings wait for the owner.
Hosting is Vercel's free Hobby plan. It has no billing, and Vercel pauses the site rather than charging if usage limits are reached.
python3 -m pytest -q scripts && npm test && npm run smoke
The research comes from the VeilTrace team at the Trace the Unseen build day, 25 September 2026: https://github.com/Ttheegela/veil-trace
Code is MIT licensed. Data keeps its sources' terms; see stories/about-the-data.html.
.mdvercel.json.mdapi/_ask_prompt.jsThe relevance gate: before an answer is shown, api/_ask_gate.js sends the question and each of its sentences, with the labels of their sources, to TypeSafe's Jev (jev-latest, POST /v1/systemone), one yes/no question (a noul) per sentence: does it bear on what the question asks? A sentence stays when the probability of yes is at least 0.5 (GATE_THRESHOLD, provisional until the evals tune it). After a drop the sources are numbered again and the "Showing N of M" lines rebuilt; with no sentence left the answer is "Nothing recorded on this site answers that." (the fallback when a lookup failed). Fixed copy (the part-answer line, the advice notice, the Showing lines) is never sent or dropped, and refusals, clarifying questions and the fallback skip the gate. Follow-ups are not checked: when the conversation holds more than one question, Jev could not judge "And the other one?" without the earlier turns, and only the latest question is ever sent, so the answer is shown as the checker wrote it. A caveat (what a none could not check, the source gaps of a range) is not sent either: it stays exactly when the line it qualifies does. It has 3 s, inside the request's 26 s, and fails open: with no key, a timeout, an HTTP error, a malformed reply or an answer the checker did not write, the answer is shown unchecked and one fixed line, ask: relevance gate unavailable, is logged. It never logs the question or a sentence.
Privacy: no accounts, and the conversation lives only in the browser tab. The question and the site's data go to OpenRouter, and each request tells it to use only model providers that don't collect user data. The question and the answer's sentences go to TypeSafe for the relevance gate, and under its terms TypeSafe doesn't use them for training. The server logs no message text and stores none. For the hourly limit it keeps, in memory, a hash of the visitor's address and the times of their last questions.
Limits: 10 messages of at most 500 characters (an earlier answer in the history is cut to 500, since it is context only), a 16 KB body, 20 questions per visitor per rolling hour (best effort, per function instance), at most 10 tool calls and 5 model calls per question, 20 s per model call and 26 s per question. Requests must come from the site's own origin as JSON.
Endpoint: GET /api/ask returns {"ok":true,"enabled":…,"public":…} (cached for 60 seconds) and never calls the model. POST /api/ask takes {"messages":[{"role":"user"|"assistant","text":"…"}]} and returns {"ok":true,"answer":…}, or {"ok":false,"reason":…} with bad_request, forbidden, unsupported, too_large, busy, disabled, resting (with resets, a date, when there is one, and daily: true for the daily budget) or unavailable.
The panel: every reader-facing page loads app/ask.css and app/ask.js (under 1 KB of brotli in all). The loader asks GET /api/ask once and adds the "Ask" button only when enabled and public are both true; the panel itself (app/ask-panel.js, app/ask-panel.css) loads on the first click. A new page needs the two tags (tests/ask-pages.test.js checks every page, and the notes template in scripts/build_docs.py has them). The button sits bottom-right. Where that covers controls, set <html data-ask-side="left">, or lift it with the --ask-lift CSS variable (the map's footer row and the network's story card do). While the button is on, ask.js adds the class ask-on to <html> and ask.css gives body bottom padding of the button's height plus its offset, so a scrolling page can always bring its last control clear of the button; a page that does not scroll (the map's wide layout) sets --ask-pad: 0px. The conversation is a module variable: nothing is stored, closing the panel clears it, and the last 10 messages go with each question.
What the panel shows of an answer: a count's examples and the chips firms come back marked item: true and are drawn as one ├─ └─ list, an item a line. An answer or a clarify that looked something up also gets checks beside it in the reply: where its tool calls looked (lists, research and wider for OpenSanctions and connections, all three from one check_lists call; docs, updates, chips) and whether each found records. The panel shows them as one line under the answer, 4 checks · lists ✓ research ✓ wider 0 docs ✓, where 0 is a check that ran and found nothing. A refusal or a fallback has none.
--limit-checkoutcometool_callslatency_stokens_intokens_outcost_usdgate_tokensfallbackincludes_okdeclined in scripts/ask-judge.mjs, which the runner and the live script share). A decline scores fallback 1, passes only where the case allows the fallback, is never sent to the relevance judge, and is never counted as answered.answer_rate in the result JSON is answered / should_answer. The questions Ask should answer are the cases that expect an answer, or also accept one, and do not expect the line that nothing recorded answers; of those it replied to (infrastructure outcomes are left out, as from the pass rate), the answered ones got an answer or a clarifying question rather than a decline or a refusal. counts carries answered and should_answer, and the job summary gives the rate. It keeps a lower unrelated score from being bought by declining more. The summary's "Replied" line is wider: every reply with the outcome quality, a refusal or a decline too.counts, relevance and answer_rate are over every run together (the job summary calls these "pooled"), and repetitions gives four metrics (pass_rate, mean_relevance, mean_unrelated and answer_rate), each as its mean over the runs (the summary's "mean of runs"), its worst run (the lowest, or for unrelated the highest) and each run's value, since a target is met on the mean and no run may miss it by more than 0.05. failures then lists every case that failed in any run, with how many runs it failed ({"id", "failed"}), so a case that must pass in every run shows when it did not. With one run the result keeps its earlier shape, plus answer_rate.pass cannot see an answer whose records are true but do not answer the question, that gives part of an answer as if it were all of it, or that answers a different aspect. scripts/ask-relevance.mjs asks TypeSafe's Jev model (jev-latest, which returns typed judgments and probabilities, not text) two questions about each answered case whose answer is an answer, given the question, the sentences and the labels of what they cite:
relevance: how directly the answer responds to what was asked, a score over four levels (about something else; the right subject but a different kind of information; only part of it, without saying so; what was asked), scaled to 0 to 1.unrelated: the probability that at least one sentence that cites a record is about something the question does not ask. It judges those sentences only (records in the judge's input): the fixed lines that cite nothing ("Showing N of M.", the part-answer line, the advice notice) are statements of scope, and Ask's live relevance gate doesn't judge them either. relevance still reads the whole answer, so a "Showing" line still tells it the answer is partial. An answer with no record sentence is judged for relevance only, left out of the unrelated mean and counted in unrelated_skipped.pass. A case the judge could not answer for (an error, a malformed reply or a 15-second timeout) gets no relevance scores.relevance: how many cases were judged, the two means, how many had no record sentence for unrelated (unrelated_skipped), the judge's billed input tokens, the judge versions that answered (judge_models, what jev-latest resolved to), how many cases the judge could not answer for (unavailable), how many answers asked the reader to clarify (clarified, counted apart) and the relevance gate's input tokens over the run (gate_tokens, on its own summary line). Without TYPESAFE_API_KEY the run goes on and prints that the judge was skipped.gh workflow run ask-evals.yml --ref main -f task=judge-check. That dispatch skips the evals and runs a job of its own, holding the TypeSafe key alone, which runs node scripts/ask-langsmith.mjs judge-check.
tests/ask-relevance-labels.json holds the owner's five answers from the live site (21 Oct 2026) and seven built from sentences Ask renders, each labelled good, partial or bad, with why. The seven built ones carry sources, the labels of what their sentences cite as the checker renders them, and the judge gets them as a run sends an answer's. The owner's five carry none, so for those the judge sees the question and the sentences only. No label holds a fixed line, so every label sentence is sent as a record, and the check measures unrelated as it did before it was narrowed to record sentences.good when relevance is at least 0.67 and unrelated under 0.5; bad when relevance is under 0.5 or unrelated at least 0.5; partial when relevance is from 0.33 to under 0.9. These thresholds are provisional, set before the judge has seen a real answer, and live in one place (THRESHOLDS in scripts/ask-relevance.mjs). A known tension in the rubric: the bands overlap (relevance from 0.33 to under 0.5 agrees with both partial and bad, and from 0.67 to under 0.9 with both good and partial). The owner's DJI answer, a date where an age was asked and labelled partial, fits both the "different kind of information" level (0.33) and the "only part" level (0.67), so the partial band is wide enough to take either.TYPESAFE_API_KEY.pass 0 gets a note of "Under review · date · commit". To replace it, add { "experiment": "<full experiment name>", "case": "<case id>", "note": "<what went wrong>" } to the array in tests/ask-eval-notes.json and merge to main. The notes job then posts each note over "Under review", and fails if an entry's experiment, case or note is not found.Ask evals: weekly run failed (date) when any answered case fails, or when no case was answered. It lists the failed case ids and links the experiment. An open issue with that title gets a comment instead of a second issue. A weekly run that stops on an error, runs past its time limit or crashes opens the same issue, with the run's link in place of case ids.LANGSMITH_API_KEY, OPENROUTER_EVAL_KEY and TYPESAFE_API_KEY, all GitHub repository secrets and nowhere else. The workflow hands the second to the runner as OPENROUTER_API_KEY, the name Ask reads. Only three steps can see them: the one that runs the cases gets all three, the one that posts the notes gets the LangSmith key, and the judge check gets the TypeSafe key. The issue job holds none, and no key is printed or traced. Without the LangSmith or OpenRouter key the runner stops before any call and names the missing one; without the TypeSafe key it skips the judge. The cases go through Ask's relevance gate as a visitor's question does, on the same TypeSafe key, traced as the step gate with the answer it was handed (never the key).budget, get no pass score, and open no issue unless no case was answered.langsmith is a dev dependency, nothing under api/ imports it, and production calls runAsk without the tracing hook.