feat(inspect): converge on a repository-first golden path (#32) - #33
Conversation
Closes the gap between "AgentSec has an inventory" and "AgentSec has a
verdict". `project/discovery.py` produced a careful surface inventory that
nothing consumed, and the CI gate that was meant to be the entry point
could not be reached without a configured target — someone else's sprint.
The path is now:
agentsec init → agentsec scan → agentsec scan --verify -t <id> → dashboard
`agentsec scan` needs a checkout and nothing else.
New: a repository risk plane (`inspect/`)
Ten deterministic rules over the discovered surfaces — no model, for the
reason ADR 0002 gives one level downstream. Rules report counts, line
numbers, Unicode codepoint names and their own marker vocabulary, never
the matched text: discovery earns publication without a second redaction
pass by not reading values, and a plane that quoted the offending line
would spend that on the way out.
Two surfaces that did not exist before, both named in the request:
`tool_grants` (one entry per permission rule — "is this configured" is not
the question, *which* tool under *which* constraint is) and `memory`, a
declared manifest location defaulting to `.claude/memory`.
The bridge: a risk is a reason to test, not a result
Each risk resolves to `verified` / `verifiable` / `not_verifiable` by
reusing the `config-surface:` tag convention from #25, extracted into
`scenario/surface_tags.py` so the risk and posture planes cannot answer the
same question differently. `scan --verify` hands the high-severity runnable
subset to the harness and returns a real four-axis verdict.
`not_verifiable` is the default and the common case: neither a pass nor a
failure, but AgentSec saying it found something it cannot settle. `scan`
exits 0 even with critical risks outstanding — a gate that blocks on a
static match teaches its team to bypass the gate.
Also:
- `AGT-CONFIG-003` retagged from `.claude/hooks/guard_agentsec.py` to
`.claude/hooks`. Nothing in that scenario is specific to one hook, and the
narrow tag made it correlate with this repository and nothing else.
- Hook rules strip comments before matching. The first run against this
repository reported network egress from a comment explaining what a
proxied `curl` would do; a rule that reports the documentation of a risk
as the risk teaches its reader to skip the plane.
- `repo_risk` composed into the dashboard as a fifth plane, never merged.
`PUBLISH_SCHEMA_VERSION` 1.2.0 → 1.3.0.
- `agentsec://project/risks`, served by the read-only report gateway.
- docs/feature-matrix.md classifies every capability Core / Supporting /
Experimental / Parked, and states the gaps the plane exposed: nothing
covers the tool-grant or settings surface, so a critical
`ASI-TOOL-PERMISSION-BYPASS` reports `not_verifiable` honestly.
- Roadmap re-sorted by layer rather than completion status.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JygxnNfLiv4ED8AyZN64pZ
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1bf51cbddb
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| _, _, scenarios_with_a_verdict = self._rollup( | ||
| target_id=None, profile=None, limit=200 | ||
| ) |
There was a problem hiding this comment.
Scope the verification rollup to the requested target
When a covering scenario already has a verdict for target A, agentsec scan --verify --target B uses this unfiltered rollup, marks the risk as verified, removes the scenario from verify_queue, and exits without testing target B. Because verdicts are target-specific, pass the requested target into repository inspection and filter this rollup before deciding which scenarios still need to run.
Useful? React with 👍 / 👎.
| result: str | None = None | ||
| target = self.root / surface.path | ||
| try: | ||
| raw = target.read_bytes()[:MAX_READ_BYTES] |
There was a problem hiding this comment.
Bound and report truncated inspection reads
For an instruction or hook larger than 512 KiB, read_bytes() first loads the entire file and then silently discards everything after the limit. A malicious directive placed in the discarded tail therefore produces neither a risk nor a truncation problem while the report still says inspected, and a sufficiently large file can also exhaust memory before the slice is applied. Read a bounded amount from the file and record when additional bytes were omitted.
Useful? React with 👍 / 👎.
| out: list[Surface] = [] | ||
| for path in self.files_under(base, "*", label="memory"): | ||
| rel = self.display(path) | ||
| stem = path.relative_to(base).with_suffix("").as_posix().replace("/", ".") |
There was a problem hiding this comment.
Handle memory symlinks whose targets leave the memory directory
If .claude/memory contains a symlink to another file inside the repository but outside the declared memory directory, files_under() accepts it and returns the resolved target path, so this relative_to(base) call raises ValueError. That exception is not converted to an inspection problem and causes the entire scan to fail; retain the symlink-relative path or explicitly reject targets outside the declared memory directory.
Useful? React with 👍 / 👎.
|
|
||
| @property | ||
| def id(self) -> str: | ||
| return f"{self.rule_id}:{self.file}" |
There was a problem hiding this comment.
Include the surface identity in each risk ID
When the same rule fires for multiple surfaces stored in one file—for example, two credential-bearing MCP servers in .mcp.json or multiple broad tool grants in settings.json—every result receives the same rule_id:file ID. Dashboard consumers that key or diff rows by the documented stable ID can therefore overwrite distinct risks or treat them as one; include surface_id (or another per-surface discriminator) in the ID.
Useful? React with 👍 / 👎.
… family (#34) The catalogue grew from four scenarios to eight in #28 and gained the repository risk plane in #33, but README's scope block still described the original four. A coverage claim that undercounts is the same failure mode the project argues against everywhere else: it reads as "untested" for categories that are, in fact, covered. - OWASP Agentic coverage 4/10 → 8/10, verified against `agentsec coverage` - Name the four AGT-CONFIG-* scenarios in the bundled list; scenarios/ is eight worked examples, not four - Architecture tree gains `inspect/` and `posture/`, both shipped and both absent from it - Detection-rule ids for contributors gain 100901–100904 - CLI table gains `get-run` and `mcp-contract` Claude-Session: https://claude.ai/code/session_01MhAYvfhZE3xs6KX5vWyukX Co-authored-by: Claude <noreply@anthropic.com>
The English README was corrected in #34; this closes the same gaps on the zh-TW side, and folds in the posture/provenance rows drafted in the still-open PR #29 (whose README.md half is now redundant). - OWASP Agentic coverage 4/10 -> 8/10, verified against `agentsec coverage` - Bundled scenarios: four -> eight, naming the AGT-CONFIG-* attack shapes; scenarios/ is eight worked examples - Two capability rows that had no zh-TW equivalent: static posture ingestion and run provenance - Two trust-posture bullets: a scanner's score is never a verdict, and a verdict states how it was proven - Architecture tree gains `inspect/` and `posture/` - Detection rule ids gain 100901-100904; CLI table gains `get-run` and `mcp-contract` The resource counts and the scan section were already current from #33, so they are untouched. Both READMEs now state the same facts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MhAYvfhZE3xs6KX5vWyukX
The English README was corrected in #34; this closes the same gaps on the zh-TW side, and folds in the posture/provenance rows drafted in the still-open PR #29 (whose README.md half is now redundant). - OWASP Agentic coverage 4/10 -> 8/10, verified against `agentsec coverage` - Bundled scenarios: four -> eight, naming the AGT-CONFIG-* attack shapes; scenarios/ is eight worked examples - Two capability rows that had no zh-TW equivalent: static posture ingestion and run provenance - Two trust-posture bullets: a scanner's score is never a verdict, and a verdict states how it was proven - Architecture tree gains `inspect/` and `posture/` - Detection rule ids gain 100901-100904; CLI table gains `get-run` and `mcp-contract` The resource counts and the scan section were already current from #33, so they are untouched. Both READMEs now state the same facts. Claude-Session: https://claude.ai/code/session_01MhAYvfhZE3xs6KX5vWyukX Co-authored-by: Claude <noreply@anthropic.com>
What changed
An engineer can open a local repository and get a ranked list of its AI-agent attack surface — agents, skills, MCP servers, hooks, tool grants, memory/RAG — with, per risk, whether anything here can turn it into a deterministic verdict. Before this,
project/discovery.pyproduced a careful surface inventory that nothing consumed, and the entry point (agentsec run) could not be reached without a configured target.agentsec scanneeds a checkout and nothing else. Converges #32.Type
Purple coverage
AGT-CONFIG-003is retagged (see below), which changes which risks and static findings it correlates with.repo_riskis composed besidepurpleand never merged; a risk is never aPurpleVerdict.tests/test_inspect.pyasserts the verdict vocabulary never appears in the plane, and that risk counts never reachpurple.agentsec://project/risks, taking no arguments — no path, no locator. It reads surfaces already resolved throughproject/resolver.pyat the process boundary. No new tool; no write.Why repository-first rather than the
agentsec checkfaçade #32 proposedWriting that façade surfaced the problem underneath it.
agentsec check --target order-agent-stagingneeds the target to exist: an allowlist entry, a staging agent, usually a Wazuh or OTel backend. That is a security or platform team's work, so the engineer #32 names as the primary user cannot reach the first command of the golden path without someone else's sprint.It is also backwards on its own terms — configuring a target is a real cost, and nothing told anyone why it was worth paying for their repository.
scananswers exactly that, then hands the provable subset to the harness. The CI gate is still the destination; it is now the second step. Full reasoning in ADR 0009.The plane: a risk is a reason to test, not a result
Ten deterministic rules in
inspect/rules.py— no model, for the reason ADR 0002 gives one level downstream: a risk plane whose output changed between two runs of the same commit could not be diffed or argued with.Each risk resolves to one of three states, reusing the
config-surface:tag convention from #25 (extracted intoscenario/surface_tags.pyso the risk and posture planes cannot answer the same question differently):verifiedpurpleverifiablescan --verifydrains this queuenot_verifiablenot_verifiableis the default and the common case: neither a pass nor a failure, but AgentSec saying it found something it cannot settle.scanexits0even with critical risks outstanding — a gate that blocks on a static match teaches its team to bypass the gate.Rules never carry file content. Evidence is counts, line numbers, Unicode codepoint names and the rule's own marker vocabulary.
project/discovery.pyearns publication without a second redaction pass by not reading values; a plane that quoted the offending line would spend that on the way out. Pinned by a test that plants known strings and asserts none reach the serialised output.Two surfaces that did not exist
tool_grants— one entry persettings.jsonpermission rule, not a count. A repository can hold no skills, no agents and no hooks and still hand a model unattended shell access in four words of JSON; "is this configured" is not the question, which tool under which constraint is.memory— a declared manifest location defaulting to.claude/memory. Absent in most repositories, which inventories as empty rather than as safe.Two fixes the plane exposed by being run
AGT-CONFIG-003retagged from.claude/hooks/guard_agentsec.pyto.claude/hooks. Nothing in that scenario is specific to one hook — it seeds an untrusted filename and asserts onrun_shell_hook— so the narrow tag made it correlate with this repository and with nothing in anyone else's. With it fixed, acriticalhook-injection risk in an arbitrary repo becomesverifiableinstead of unprovable.guard_agentsec.py, on the strength of a comment explaining what a proxiedcurlwould do. A rule that reports the documentation of a risk as the risk teaches its reader to skip the plane. Fixed, and pinned as a regression test.Gaps this exposes, stated rather than hidden
Recorded in
docs/feature-matrix.mdand promoted to open Core items on the roadmap:ASI-TOOL-PERMISSION-BYPASSfires atcriticaland reportsnot_verifiable. The highest-value gap the plane found; it needs a fixture, which the guard hook correctly refuses to let an agent write.AGT-XPIA-001is the right shape but carries noconfig-surface:tag.AGT-CONFIG-*still has no recorded fixtures, so--verifyagainstdemo-agent-fixture(environmentlocal) refuses with exit2rather than selecting nothing and reporting success — verified by hand.Docs
docs/feature-matrix.md— every capability classified Core / Supporting / Experimental / Parked against the one path, which is the "收斂功能矩陣" deliverable.docs/adr/0009-repository-first-golden-path.md— the decision, four rejected alternatives, five accepted costs.docs/roadmap.md— re-sorted by layer rather than completion status, as product: 收斂 AgentSec 的工程師使用路徑與功能邊界 #32 asked.docs/deployment.md,packaging/claude-desktop/README.mdrealigned; resource counts corrected to 10 total / 7 published.Checks
make checkpasses — ruff clean, mypy clean on 63 files, 379 tests (345 before + 34 new)agentsec validate --strictpasses on all 8 scenariosagentsec run --target demo-agent-fixture --profile nightlystill exits1with exactlyAGT-TENANT-001andAGT-MEMPOIS-001blockingIf this touches the MCP surface
HarnessServicefirst (inspect_repository), so CLI and CI reach it tootests/test_mcp_contract.pystill passes;test_publish_redaction.pyupdated to include the new URI in the published allowlistrepo_risk_document); the gateway refuses to boot without oneNotes for the reviewer
Three decisions worth challenging:
PUBLISH_SCHEMA_VERSION1.2.0 → 1.3.0, not 2.0.0.repo_riskis required on the composed dashboard, so a consumer validating strictly against the old schema sees a new key. I judged that minor because every shape a consumer was already reading is untouched — but the same argument was made for 1.2.0 and the required-key question will recur.scanexits0with critical risks outstanding. Deliberate, and the most likely thing someone will want to change. If it ever exits1, the plane stops being "a reason to test" and becomes a second gate with no evidence behind it.--fail-onthreshold, no severity overrides. That is one fewer knob, and also one fewer way to make the plane say what someone wanted it to say.Also worth a look:
_SECRET_MARKERS/_SINK_MARKERS/_IMPERATIVEininspect/rules.py.ASI-INSTR-EXFIL-DIRECTIVErequires all three within a 3-line window specifically so security documentation does not trip it, and there is a test asserting prose about exfiltration does not fire the exfiltration rule. That balance is tuned, not derived.Generated by Claude Code